1003 lines
35 KiB
JavaScript
1003 lines
35 KiB
JavaScript
// MNote debug /tree shell fallback renderer 浏览器运行时模块。
|
|
// 该模块只承接 DOM fallback renderer,状态和命令语义仍由 tree-shell-runtime.js 注入。
|
|
|
|
import {
|
|
TREE_SHELL_ICONS as ICONS,
|
|
createTreeShellActionButton,
|
|
createTreeShellKindBadge as createKindBadge,
|
|
} from "./tree-shell-icons-runtime.js";
|
|
import {
|
|
getFileTreeRowAssetId,
|
|
getFileTreeRowDocumentId,
|
|
getFileTreeRowIconKind,
|
|
getFileTreeRowMetaLabel,
|
|
getFileTreeRowOwnerDocumentId,
|
|
} from "./tree-shell-filetree-runtime.js";
|
|
|
|
export function createTreeShellRenderer(context) {
|
|
const {
|
|
FILETREE_DRAG_MIME,
|
|
PAGE_DRAG_MIME,
|
|
allowRootPick,
|
|
appElement,
|
|
applyPageKeyboardAction,
|
|
applyPickerFocusByItemKey,
|
|
applyPickerStateAction,
|
|
attachInlineRenameInput,
|
|
beginInlineRename,
|
|
canAcceptPageDrop,
|
|
canExpandFileTreeRow,
|
|
clearFileTreeDropFeedback,
|
|
clearFileTreeSelection,
|
|
clearPageDropFeedback,
|
|
executeLocalFileTreeExternalDrop,
|
|
executeLocalFileTreeInternalDrop,
|
|
expanded,
|
|
getActiveFileTreeDropPosition,
|
|
getActiveFileTreeDropRowId,
|
|
getActiveFileTreeRootDrop,
|
|
getActivePageDropNodeId,
|
|
getCurrentActiveDocumentId,
|
|
getElementCenter,
|
|
getFileTreeDropTargetFromEvent,
|
|
getFileTreeRowById,
|
|
focusRowElement,
|
|
getFocusedNodeId,
|
|
getInlineRenameState,
|
|
getItemById,
|
|
getRoots,
|
|
getSelectedFileTreeRowIds,
|
|
getSiblings,
|
|
handleCreate,
|
|
handleFileTreeKeyDown,
|
|
handleMove,
|
|
handleNavigate,
|
|
handlePageDropMove,
|
|
handleRowKeyDown,
|
|
isBusy,
|
|
mode,
|
|
normalizeText,
|
|
normalizeFileTreeSelectionForVisibleRows,
|
|
openContextMenu,
|
|
openFileTreeContextMenu,
|
|
patchFileTreeCutDecoration,
|
|
postPickerPickResultToHost,
|
|
postToHost,
|
|
pushVisibleFileTreeRowId,
|
|
readFileTreeInternalDropPayload,
|
|
readPageDragNodeId,
|
|
resolveFileTreeDraggedRowIds,
|
|
resolvePageDropTargetNodeId,
|
|
resolvePickerRootFocused,
|
|
runFileTreePreflight,
|
|
selectFileTreeContextRow,
|
|
selectFileTreeRow,
|
|
setFileTreeDropFeedback,
|
|
setDraggingFileTreeRowIds,
|
|
setDraggingPageNodeId,
|
|
setLastAction,
|
|
setPageDropFeedback,
|
|
setVisibleFileTreeRowIds,
|
|
sourceKind,
|
|
toggleExpand,
|
|
validateFileTreeInternalDrop,
|
|
workspaceId,
|
|
} = context;
|
|
|
|
const createActionButton = (icon, testId, title, onClick, disabled) =>
|
|
createTreeShellActionButton(icon, testId, title, onClick, disabled, isBusy());
|
|
|
|
const renderNode = (item) => {
|
|
const hasChildren = item.childCount > 0;
|
|
const row = document.createElement("div");
|
|
row.className = "tree-row";
|
|
row.dataset.active = String(item.nodeId === getCurrentActiveDocumentId());
|
|
row.dataset.focused = String(item.nodeId === getFocusedNodeId());
|
|
row.dataset.nodeId = item.nodeId;
|
|
row.dataset.shellMode = mode;
|
|
row.dataset.dropFeedback = String(getActivePageDropNodeId() === item.nodeId);
|
|
row.tabIndex = item.nodeId === getFocusedNodeId() ? 0 : -1;
|
|
row.setAttribute("role", "treeitem");
|
|
row.setAttribute("aria-level", String(item.depth + 1));
|
|
row.setAttribute("aria-expanded", hasChildren ? String(expanded.has(item.nodeId)) : "false");
|
|
row.draggable = mode === "page";
|
|
row.dataset.draggable = String(mode === "page");
|
|
row.addEventListener("focus", () => {
|
|
if (getFocusedNodeId() !== item.nodeId) {
|
|
applyPageKeyboardAction({ kind: "focus", nodeId: item.nodeId }, item, row);
|
|
}
|
|
});
|
|
row.addEventListener("keydown", (event) => handleRowKeyDown(event, item));
|
|
row.addEventListener("contextmenu", (event) => {
|
|
if (mode !== "page") return;
|
|
event.preventDefault();
|
|
openContextMenu(item.nodeId, event.clientX, event.clientY);
|
|
});
|
|
row.addEventListener("dragstart", (event) => {
|
|
if (mode !== "page") return;
|
|
setDraggingPageNodeId(item.nodeId);
|
|
if (event.dataTransfer) {
|
|
event.dataTransfer.effectAllowed = "move";
|
|
event.dataTransfer.setData(PAGE_DRAG_MIME, item.nodeId);
|
|
event.dataTransfer.setData("text/plain", item.nodeId);
|
|
}
|
|
setLastAction(`开始拖拽页面 ${item.title}`);
|
|
});
|
|
row.addEventListener("dragover", (event) => {
|
|
if (mode !== "page") return;
|
|
const sourceNodeId = readPageDragNodeId(event);
|
|
const targetNodeId = resolvePageDropTargetNodeId(event.target);
|
|
if (!canAcceptPageDrop(sourceNodeId, targetNodeId)) {
|
|
clearPageDropFeedback();
|
|
return;
|
|
}
|
|
event.preventDefault();
|
|
if (event.dataTransfer) {
|
|
event.dataTransfer.dropEffect = "move";
|
|
}
|
|
setPageDropFeedback(targetNodeId);
|
|
});
|
|
row.addEventListener("dragleave", (event) => {
|
|
if (mode !== "page") return;
|
|
const relatedTarget =
|
|
event.relatedTarget instanceof Node ? event.relatedTarget : null;
|
|
if (relatedTarget && row.contains(relatedTarget)) {
|
|
return;
|
|
}
|
|
if (getActivePageDropNodeId() === item.nodeId) {
|
|
clearPageDropFeedback();
|
|
}
|
|
});
|
|
row.addEventListener("drop", (event) => {
|
|
if (mode !== "page") return;
|
|
const sourceNodeId = readPageDragNodeId(event);
|
|
const targetNodeId = resolvePageDropTargetNodeId(event.target);
|
|
clearPageDropFeedback();
|
|
setDraggingPageNodeId("");
|
|
if (!canAcceptPageDrop(sourceNodeId, targetNodeId)) {
|
|
return;
|
|
}
|
|
event.preventDefault();
|
|
void handlePageDropMove(sourceNodeId, targetNodeId);
|
|
});
|
|
row.addEventListener("dragend", () => {
|
|
if (mode !== "page") return;
|
|
setDraggingPageNodeId("");
|
|
clearPageDropFeedback();
|
|
});
|
|
|
|
if (hasChildren) {
|
|
const toggleButton = document.createElement("button");
|
|
toggleButton.type = "button";
|
|
toggleButton.className = "tree-toggle";
|
|
toggleButton.setAttribute("data-testid", "tree-node-toggle");
|
|
toggleButton.setAttribute(
|
|
"aria-label",
|
|
`${expanded.has(item.nodeId) ? "折叠" : "展开"} ${item.title}`,
|
|
);
|
|
toggleButton.textContent = expanded.has(item.nodeId) ? "▾" : "▸";
|
|
toggleButton.addEventListener("click", (event) => {
|
|
event.stopPropagation();
|
|
applyPageKeyboardAction({ kind: "toggle", nodeId: item.nodeId }, item, event.currentTarget);
|
|
});
|
|
row.appendChild(toggleButton);
|
|
} else {
|
|
const spacer = document.createElement("div");
|
|
spacer.className = "tree-spacer";
|
|
row.appendChild(spacer);
|
|
}
|
|
|
|
row.appendChild(createKindBadge("page"));
|
|
|
|
const linkButton = document.createElement("button");
|
|
linkButton.type = "button";
|
|
linkButton.className = "tree-link";
|
|
linkButton.setAttribute("data-testid", "tree-node-open");
|
|
linkButton.setAttribute("aria-label", `打开 ${item.title}`);
|
|
linkButton.addEventListener("click", () => {
|
|
if (mode === "picker") {
|
|
applyPickerFocusByItemKey(item.nodeId, { focusDom: true });
|
|
postPickerPickResultToHost(applyPickerStateAction({ kind: "pick" }));
|
|
return;
|
|
}
|
|
handleNavigate(item.nodeId);
|
|
});
|
|
|
|
if (getInlineRenameState().mode === "page" && getInlineRenameState().id === item.nodeId) {
|
|
const renameInput = document.createElement("input");
|
|
renameInput.type = "text";
|
|
renameInput.className = "tree-rename-input";
|
|
renameInput.value = item.title;
|
|
renameInput.setAttribute("aria-label", `重命名 ${item.title}`);
|
|
renameInput.dataset.renameId = item.nodeId;
|
|
attachInlineRenameInput(renameInput, item.title);
|
|
linkButton.appendChild(renameInput);
|
|
} else {
|
|
const titleElement = document.createElement("span");
|
|
titleElement.className = "tree-link-title";
|
|
titleElement.textContent = item.title;
|
|
linkButton.appendChild(titleElement);
|
|
}
|
|
|
|
const metaElement = document.createElement("span");
|
|
metaElement.className = "tree-link-meta";
|
|
metaElement.textContent = `${item.nodeId} · ${item.childCount} 个子页面`;
|
|
linkButton.appendChild(metaElement);
|
|
row.appendChild(linkButton);
|
|
|
|
const actions = document.createElement("div");
|
|
actions.className = "tree-actions";
|
|
const siblingRows = getSiblings(item.parentNodeId);
|
|
const siblingIndex = siblingRows.findIndex((entry) => entry.nodeId === item.nodeId);
|
|
actions.appendChild(
|
|
createActionButton(
|
|
ICONS.add,
|
|
"tree-action-create",
|
|
`在 ${item.title} 下新建子页面`,
|
|
() => handleCreate(item.nodeId),
|
|
false,
|
|
),
|
|
);
|
|
if (mode === "page") {
|
|
actions.appendChild(
|
|
createActionButton(
|
|
ICONS.edit,
|
|
"tree-action-rename",
|
|
`重命名 ${item.title}`,
|
|
() => beginInlineRename("page", item.nodeId),
|
|
false,
|
|
),
|
|
);
|
|
actions.appendChild(
|
|
createActionButton(
|
|
ICONS.up,
|
|
"tree-action-move-up",
|
|
`上移 ${item.title}`,
|
|
() => void handleMove(item.nodeId, -1),
|
|
siblingIndex <= 0,
|
|
),
|
|
);
|
|
actions.appendChild(
|
|
createActionButton(
|
|
ICONS.more,
|
|
"tree-action-menu",
|
|
`打开 ${item.title} 的更多操作`,
|
|
(button) => {
|
|
const center = getElementCenter(button);
|
|
openContextMenu(
|
|
item.nodeId,
|
|
center.x,
|
|
center.y,
|
|
);
|
|
},
|
|
false,
|
|
),
|
|
);
|
|
}
|
|
row.appendChild(actions);
|
|
|
|
const nodeElement = document.createElement("li");
|
|
nodeElement.className = "tree-node";
|
|
nodeElement.dataset.nodeId = item.nodeId;
|
|
nodeElement.appendChild(row);
|
|
|
|
if (hasChildren && expanded.has(item.nodeId)) {
|
|
const children = getSiblings(item.nodeId);
|
|
if (children.length > 0) {
|
|
const childrenList = document.createElement("ul");
|
|
childrenList.className = "tree-children";
|
|
children.forEach((child) => {
|
|
childrenList.appendChild(renderNode(child));
|
|
});
|
|
nodeElement.appendChild(childrenList);
|
|
}
|
|
}
|
|
|
|
return nodeElement;
|
|
};
|
|
|
|
const syncFileTreeSelectionDom = () => {
|
|
if (mode !== "filetree") return;
|
|
appElement.querySelectorAll('[data-rust-rendered-row="filetree"]').forEach((row) => {
|
|
if (!(row instanceof HTMLElement)) return;
|
|
const rowId = normalizeText(row.dataset.rowId);
|
|
row.dataset.selected = String(Boolean(rowId && getSelectedFileTreeRowIds().has(rowId)));
|
|
});
|
|
};
|
|
|
|
const openHydratedFileTreeItem = (item) => {
|
|
const documentId = getFileTreeRowDocumentId(item);
|
|
const ownerDocumentId = getFileTreeRowOwnerDocumentId(item);
|
|
const assetId = getFileTreeRowAssetId(item);
|
|
if (item.rowKind === "document" || item.rowKind === "index") {
|
|
handleNavigate(documentId || item.nodeId);
|
|
return;
|
|
}
|
|
setLastAction(`准备打开资源 ${assetId || item.rowId}`);
|
|
postToHost("tree.asset.open", {
|
|
documentId: ownerDocumentId || null,
|
|
assetId: assetId || null,
|
|
objectIdentity: item.resourceMeta?.objectIdentity || null,
|
|
target: { documentId: ownerDocumentId || null },
|
|
payload: {
|
|
documentId: ownerDocumentId || null,
|
|
assetId: assetId || null,
|
|
rowId: item.rowId,
|
|
rowKind: item.rowKind,
|
|
objectIdentity: item.resourceMeta?.objectIdentity || null,
|
|
},
|
|
});
|
|
};
|
|
|
|
const postHydratedFileTreeDropToHost = (type, target, extra = {}) => {
|
|
postToHost(type, {
|
|
workspaceId,
|
|
rowId: target.rowId,
|
|
rowKind: target.rowKind,
|
|
targetRowId: target.rowId,
|
|
targetRowKind: target.rowKind,
|
|
documentId: target.documentId,
|
|
assetId: target.assetId,
|
|
...extra,
|
|
payload: {
|
|
workspaceId,
|
|
rowId: target.rowId,
|
|
rowKind: target.rowKind,
|
|
targetRowId: target.rowId,
|
|
targetRowKind: target.rowKind,
|
|
documentId: target.documentId,
|
|
assetId: target.assetId,
|
|
...extra,
|
|
},
|
|
});
|
|
};
|
|
|
|
const attachHydratedFileTreeDragSource = (row, item) => {
|
|
row.draggable = true;
|
|
row.addEventListener("dragstart", (event) => {
|
|
const rowIds = resolveFileTreeDraggedRowIds(item.rowId);
|
|
setDraggingFileTreeRowIds(rowIds);
|
|
if (event.dataTransfer) {
|
|
const payload = JSON.stringify({
|
|
type: "mnote-file-tree-dnd",
|
|
version: 1,
|
|
rowIds,
|
|
});
|
|
event.dataTransfer.effectAllowed = "copyMove";
|
|
event.dataTransfer.setData(FILETREE_DRAG_MIME, payload);
|
|
event.dataTransfer.setData("application/x-mnote-file-tree", payload);
|
|
event.dataTransfer.setData("text/plain", payload);
|
|
}
|
|
setLastAction(`开始拖拽 ${rowIds.length} 个文件树节点`);
|
|
});
|
|
row.addEventListener("dragend", () => {
|
|
setDraggingFileTreeRowIds([]);
|
|
clearFileTreeDropFeedback();
|
|
});
|
|
};
|
|
|
|
const bindFileTreeRootEvents = (root) => {
|
|
root.dataset.dropTarget = String(getActiveFileTreeRootDrop());
|
|
root.addEventListener("mousedown", (event) => {
|
|
if (event.target !== event.currentTarget) return;
|
|
clearFileTreeSelection();
|
|
});
|
|
root.addEventListener("contextmenu", (event) => {
|
|
if (event.target !== event.currentTarget) return;
|
|
event.preventDefault();
|
|
clearFileTreeSelection();
|
|
openFileTreeContextMenu({
|
|
documentId: null,
|
|
assetId: null,
|
|
rowId: null,
|
|
rowKind: "root",
|
|
clientX: event.clientX,
|
|
clientY: event.clientY,
|
|
});
|
|
});
|
|
root.addEventListener("dragover", (event) => {
|
|
const internalRowIds = readFileTreeInternalDropPayload(event);
|
|
const files = Array.from(event.dataTransfer?.files || []);
|
|
if (!internalRowIds && files.length === 0) {
|
|
return;
|
|
}
|
|
event.preventDefault();
|
|
const target = getFileTreeDropTargetFromEvent(event);
|
|
if (internalRowIds && !validateFileTreeInternalDrop(target, internalRowIds, event.altKey === true).ok) {
|
|
clearFileTreeDropFeedback();
|
|
return;
|
|
}
|
|
if (event.dataTransfer) {
|
|
event.dataTransfer.dropEffect =
|
|
files.length > 0 || event.altKey ? "copy" : "move";
|
|
}
|
|
setFileTreeDropFeedback(target);
|
|
});
|
|
root.addEventListener("dragleave", (event) => {
|
|
const relatedTarget =
|
|
event.relatedTarget instanceof Node ? event.relatedTarget : null;
|
|
if (relatedTarget && root.contains(relatedTarget)) {
|
|
return;
|
|
}
|
|
clearFileTreeDropFeedback();
|
|
});
|
|
root.addEventListener("drop", (event) => {
|
|
const internalRowIds = readFileTreeInternalDropPayload(event);
|
|
const files = Array.from(event.dataTransfer?.files || []);
|
|
if (!internalRowIds && files.length === 0) {
|
|
return;
|
|
}
|
|
event.preventDefault();
|
|
const target = getFileTreeDropTargetFromEvent(event);
|
|
if (internalRowIds && !validateFileTreeInternalDrop(target, internalRowIds, event.altKey === true).ok) {
|
|
clearFileTreeDropFeedback();
|
|
return;
|
|
}
|
|
if (files.length > 0) {
|
|
if (sourceKind === "local_folder") {
|
|
void executeLocalFileTreeExternalDrop(target, files);
|
|
} else {
|
|
void (async () => {
|
|
const accepted = await runFileTreePreflight("dropFiles", { target, files });
|
|
if (!accepted) return;
|
|
setLastAction(`已发送 ${files.length} 个外部文件到宿主`);
|
|
postHydratedFileTreeDropToHost("tree.filetree.external-drop", target, {
|
|
files,
|
|
});
|
|
})();
|
|
}
|
|
} else {
|
|
if (sourceKind === "local_folder") {
|
|
void executeLocalFileTreeInternalDrop(target, internalRowIds, event.altKey === true);
|
|
} else {
|
|
void (async () => {
|
|
const accepted = await runFileTreePreflight("move", {
|
|
target,
|
|
rowIds: internalRowIds,
|
|
copy: event.altKey === true,
|
|
});
|
|
if (!accepted) return;
|
|
setLastAction(
|
|
event.altKey
|
|
? `已发送复制拖放到 ${target.rowId || "根目录"}`
|
|
: `已发送移动拖放到 ${target.rowId || "根目录"}`,
|
|
);
|
|
postHydratedFileTreeDropToHost("tree.filetree.internal-drop", target, {
|
|
rowIds: internalRowIds,
|
|
copy: event.altKey === true,
|
|
});
|
|
})();
|
|
}
|
|
}
|
|
setDraggingFileTreeRowIds([]);
|
|
clearFileTreeDropFeedback();
|
|
});
|
|
};
|
|
|
|
const bindFileTreeRowEvents = (row, item) => {
|
|
if (!(row instanceof HTMLElement) || !item) return;
|
|
const documentId = getFileTreeRowDocumentId(item) || null;
|
|
const ownerDocumentId = getFileTreeRowOwnerDocumentId(item) || null;
|
|
const assetId = getFileTreeRowAssetId(item) || null;
|
|
row.dataset.active = String(
|
|
item.rowKind === "document" && documentId === getCurrentActiveDocumentId()
|
|
);
|
|
row.dataset.nodeId = item.nodeId;
|
|
row.dataset.rowId = item.rowId;
|
|
row.dataset.rowKind = item.rowKind;
|
|
row.dataset.documentId = documentId || "";
|
|
row.dataset.ownerDocumentId = ownerDocumentId || "";
|
|
row.dataset.assetId = assetId || "";
|
|
row.dataset.objectIdentity = item.resourceMeta?.objectIdentity
|
|
? JSON.stringify(item.resourceMeta.objectIdentity)
|
|
: "";
|
|
row.dataset.objectKind = item.resourceMeta?.objectIdentity?.objectKind || "";
|
|
row.dataset.shellMode = "filetree";
|
|
row.dataset.selected = String(getSelectedFileTreeRowIds().has(item.rowId));
|
|
row.dataset.dropTarget = String(getActiveFileTreeDropRowId() === item.rowId);
|
|
if (getActiveFileTreeDropRowId() === item.rowId && getActiveFileTreeDropPosition()) {
|
|
row.dataset.dropPosition = getActiveFileTreeDropPosition();
|
|
}
|
|
row.tabIndex = 0;
|
|
row.setAttribute("role", "treeitem");
|
|
row.setAttribute("aria-level", String(item.depth + 1));
|
|
row.setAttribute(
|
|
"aria-expanded",
|
|
canExpandFileTreeRow(item) ? String(expanded.has(item.nodeId)) : "false",
|
|
);
|
|
row.addEventListener("click", (event) => {
|
|
selectFileTreeRow(item.rowId, event);
|
|
row.focus({ preventScroll: true });
|
|
syncFileTreeSelectionDom();
|
|
});
|
|
row.addEventListener("dblclick", () => {
|
|
openHydratedFileTreeItem(item);
|
|
});
|
|
row.addEventListener("keydown", (event) => handleFileTreeKeyDown(event, item));
|
|
row.addEventListener("contextmenu", (event) => {
|
|
event.preventDefault();
|
|
selectFileTreeContextRow(item.rowId);
|
|
syncFileTreeSelectionDom();
|
|
openFileTreeContextMenu({
|
|
documentId,
|
|
assetId,
|
|
rowId: item.rowId,
|
|
rowKind: item.rowKind,
|
|
clientX: event.clientX,
|
|
clientY: event.clientY,
|
|
});
|
|
});
|
|
attachHydratedFileTreeDragSource(row, item);
|
|
patchFileTreeCutDecoration();
|
|
row.querySelectorAll("[data-rust-action]").forEach((element) => {
|
|
if (!(element instanceof HTMLElement)) return;
|
|
element.addEventListener("click", (event) => {
|
|
const action = normalizeText(element.dataset.rustAction);
|
|
if (action === "open") {
|
|
openHydratedFileTreeItem(item);
|
|
return;
|
|
}
|
|
if (action === "menu") {
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
selectFileTreeContextRow(item.rowId);
|
|
syncFileTreeSelectionDom();
|
|
const center = getElementCenter(element);
|
|
openFileTreeContextMenu({
|
|
documentId,
|
|
assetId,
|
|
rowId: item.rowId,
|
|
rowKind: item.rowKind,
|
|
clientX: center.x,
|
|
clientY: center.y,
|
|
});
|
|
}
|
|
});
|
|
});
|
|
};
|
|
|
|
const hydrateInitialFileTree = () => {
|
|
if (mode !== "filetree") return false;
|
|
const root = appElement.querySelector('[data-rust-filetree-renderer="initial_v1"]');
|
|
if (!(root instanceof HTMLElement)) {
|
|
return false;
|
|
}
|
|
setVisibleFileTreeRowIds([]);
|
|
bindFileTreeRootEvents(root);
|
|
root.querySelectorAll('[data-rust-rendered-row="filetree"]').forEach((row) => {
|
|
if (!(row instanceof HTMLElement)) return;
|
|
const rowId = normalizeText(row.dataset.rowId);
|
|
const nodeId = normalizeText(row.dataset.nodeId);
|
|
const item = getFileTreeRowById().get(rowId) || getItemById().get(nodeId);
|
|
if (!item) return;
|
|
pushVisibleFileTreeRowId(item.rowId);
|
|
bindFileTreeRowEvents(row, item);
|
|
});
|
|
normalizeFileTreeSelectionForVisibleRows();
|
|
syncFileTreeSelectionDom();
|
|
return true;
|
|
};
|
|
|
|
|
|
const renderFileTree = () => {
|
|
appElement.innerHTML = "";
|
|
setVisibleFileTreeRowIds([]);
|
|
clearFileTreeDropFeedback();
|
|
|
|
const fileRoot = document.createElement("div");
|
|
fileRoot.className = "tree-root";
|
|
fileRoot.setAttribute("role", "tree");
|
|
fileRoot.dataset.dropTarget = String(getActiveFileTreeRootDrop());
|
|
fileRoot.addEventListener("mousedown", (event) => {
|
|
if (event.target !== event.currentTarget) return;
|
|
clearFileTreeSelection();
|
|
});
|
|
|
|
const openFileTreeItem = (item) => {
|
|
const documentId = getFileTreeRowDocumentId(item);
|
|
const ownerDocumentId = getFileTreeRowOwnerDocumentId(item);
|
|
const assetId = getFileTreeRowAssetId(item);
|
|
if (item.rowKind === "document" || item.rowKind === "index" || item.rowKind === "markdown") {
|
|
handleNavigate(documentId || item.nodeId);
|
|
return;
|
|
}
|
|
setLastAction(`准备打开资源 ${assetId || item.rowId}`);
|
|
postToHost("tree.asset.open", {
|
|
documentId: ownerDocumentId || null,
|
|
assetId: assetId || null,
|
|
objectIdentity: item.resourceMeta?.objectIdentity || null,
|
|
target: { documentId: ownerDocumentId || null },
|
|
payload: {
|
|
documentId: ownerDocumentId || null,
|
|
assetId: assetId || null,
|
|
rowId: item.rowId,
|
|
rowKind: item.rowKind,
|
|
objectIdentity: item.resourceMeta?.objectIdentity || null,
|
|
},
|
|
});
|
|
};
|
|
|
|
const postFileTreeDropToHost = (type, target, extra = {}) => {
|
|
postToHost(type, {
|
|
workspaceId,
|
|
rowId: target.rowId,
|
|
rowKind: target.rowKind,
|
|
targetRowId: target.rowId,
|
|
targetRowKind: target.rowKind,
|
|
documentId: target.documentId,
|
|
assetId: target.assetId,
|
|
...extra,
|
|
payload: {
|
|
workspaceId,
|
|
rowId: target.rowId,
|
|
rowKind: target.rowKind,
|
|
targetRowId: target.rowId,
|
|
targetRowKind: target.rowKind,
|
|
documentId: target.documentId,
|
|
assetId: target.assetId,
|
|
...extra,
|
|
},
|
|
});
|
|
};
|
|
|
|
const attachFileTreeDragSource = (row, item) => {
|
|
row.draggable = true;
|
|
row.addEventListener("dragstart", (event) => {
|
|
const rowIds = resolveFileTreeDraggedRowIds(item.rowId);
|
|
setDraggingFileTreeRowIds(rowIds);
|
|
if (event.dataTransfer) {
|
|
const payload = JSON.stringify({
|
|
type: "mnote-file-tree-dnd",
|
|
version: 1,
|
|
rowIds,
|
|
});
|
|
event.dataTransfer.effectAllowed = "copyMove";
|
|
event.dataTransfer.setData(FILETREE_DRAG_MIME, payload);
|
|
event.dataTransfer.setData("application/x-mnote-file-tree", payload);
|
|
event.dataTransfer.setData("text/plain", payload);
|
|
}
|
|
setLastAction(`开始拖拽 ${rowIds.length} 个文件树节点`);
|
|
});
|
|
row.addEventListener("dragend", () => {
|
|
setDraggingFileTreeRowIds([]);
|
|
clearFileTreeDropFeedback();
|
|
});
|
|
};
|
|
|
|
const appendFileTreeRow = (container, item) => {
|
|
const children = getSiblings(item.nodeId);
|
|
const hasBranches = canExpandFileTreeRow(item) && children.length > 0;
|
|
const documentId = getFileTreeRowDocumentId(item) || null;
|
|
const ownerDocumentId = getFileTreeRowOwnerDocumentId(item) || null;
|
|
const assetId = getFileTreeRowAssetId(item) || null;
|
|
|
|
const row = document.createElement("div");
|
|
row.className = "tree-row";
|
|
row.style.marginLeft = `${item.depth * 22}px`;
|
|
row.dataset.active = String(
|
|
item.rowKind === "document" && documentId === getCurrentActiveDocumentId()
|
|
);
|
|
row.dataset.nodeId = item.nodeId;
|
|
row.dataset.rowId = item.rowId;
|
|
row.dataset.rowKind = item.rowKind;
|
|
row.dataset.documentId = documentId || "";
|
|
row.dataset.ownerDocumentId = ownerDocumentId || "";
|
|
row.dataset.assetId = assetId || "";
|
|
row.dataset.objectIdentity = item.resourceMeta?.objectIdentity
|
|
? JSON.stringify(item.resourceMeta.objectIdentity)
|
|
: "";
|
|
row.dataset.objectKind = item.resourceMeta?.objectIdentity?.objectKind || "";
|
|
row.dataset.shellMode = "filetree";
|
|
row.dataset.selected = String(getSelectedFileTreeRowIds().has(item.rowId));
|
|
row.dataset.dropTarget = String(getActiveFileTreeDropRowId() === item.rowId);
|
|
if (getActiveFileTreeDropRowId() === item.rowId && getActiveFileTreeDropPosition()) {
|
|
row.dataset.dropPosition = getActiveFileTreeDropPosition();
|
|
}
|
|
row.setAttribute(
|
|
"data-testid",
|
|
item.rowKind === "document"
|
|
? "filetree-doc-row"
|
|
: item.rowKind === "index"
|
|
? "filetree-index-row"
|
|
: "filetree-asset-row",
|
|
);
|
|
row.tabIndex = 0;
|
|
row.setAttribute("role", "treeitem");
|
|
row.setAttribute("aria-level", String(item.depth + 1));
|
|
row.setAttribute("aria-expanded", hasBranches ? String(expanded.has(item.nodeId)) : "false");
|
|
pushVisibleFileTreeRowId(item.rowId);
|
|
|
|
row.addEventListener("click", (event) => {
|
|
selectFileTreeRow(item.rowId, event);
|
|
renderTree();
|
|
const nextRow = appElement.querySelector(
|
|
`.tree-row[data-shell-mode="filetree"][data-row-id="${CSS.escape(item.rowId)}"]`,
|
|
);
|
|
if (nextRow instanceof HTMLElement) {
|
|
nextRow.focus({ preventScroll: true });
|
|
}
|
|
});
|
|
row.addEventListener("dblclick", () => {
|
|
openFileTreeItem(item);
|
|
});
|
|
row.addEventListener("keydown", (event) => handleFileTreeKeyDown(event, item));
|
|
row.addEventListener("contextmenu", (event) => {
|
|
event.preventDefault();
|
|
selectFileTreeContextRow(item.rowId);
|
|
renderTree();
|
|
openFileTreeContextMenu({
|
|
documentId,
|
|
assetId,
|
|
rowId: item.rowId,
|
|
rowKind: item.rowKind,
|
|
clientX: event.clientX,
|
|
clientY: event.clientY,
|
|
});
|
|
});
|
|
attachFileTreeDragSource(row, item);
|
|
|
|
if (hasBranches) {
|
|
const toggleButton = document.createElement("button");
|
|
toggleButton.type = "button";
|
|
toggleButton.className = "tree-toggle";
|
|
toggleButton.setAttribute(
|
|
"aria-label",
|
|
`${expanded.has(item.nodeId) ? "折叠" : "展开"} ${item.title}`,
|
|
);
|
|
toggleButton.textContent = expanded.has(item.nodeId) ? "▾" : "▸";
|
|
toggleButton.addEventListener("click", (event) => {
|
|
event.stopPropagation();
|
|
toggleExpand(item.nodeId);
|
|
});
|
|
row.appendChild(toggleButton);
|
|
} else {
|
|
const spacer = document.createElement("div");
|
|
spacer.className = "tree-spacer";
|
|
row.appendChild(spacer);
|
|
}
|
|
|
|
row.appendChild(createKindBadge(getFileTreeRowIconKind(item)));
|
|
|
|
const linkButton = document.createElement("button");
|
|
linkButton.type = "button";
|
|
linkButton.className = "tree-link";
|
|
if (item.rowKind === "document") {
|
|
linkButton.setAttribute("data-testid", "filetree-doc-open");
|
|
} else if (item.rowKind === "asset" || item.rowKind === "asset_folder") {
|
|
linkButton.setAttribute("data-testid", "filetree-asset-open");
|
|
}
|
|
linkButton.addEventListener("click", () => openFileTreeItem(item));
|
|
if (getInlineRenameState().mode === "filetree" && getInlineRenameState().id === item.rowId) {
|
|
const renameInput = document.createElement("input");
|
|
renameInput.type = "text";
|
|
renameInput.className = "tree-rename-input";
|
|
renameInput.value = item.title;
|
|
renameInput.setAttribute("aria-label", `重命名 ${item.title}`);
|
|
renameInput.dataset.renameId = item.rowId;
|
|
attachInlineRenameInput(renameInput, item.title);
|
|
linkButton.appendChild(renameInput);
|
|
} else {
|
|
const title = document.createElement("span");
|
|
title.className = "tree-link-title";
|
|
title.textContent = item.title;
|
|
linkButton.appendChild(title);
|
|
}
|
|
const meta = document.createElement("span");
|
|
meta.className = "tree-link-meta";
|
|
meta.textContent = getFileTreeRowMetaLabel(item);
|
|
linkButton.appendChild(meta);
|
|
row.appendChild(linkButton);
|
|
|
|
const actions = document.createElement("div");
|
|
actions.className = "tree-actions";
|
|
if (getFileTreeRowDocumentId(item)) {
|
|
actions.appendChild(
|
|
createActionButton(
|
|
ICONS.edit,
|
|
"filetree-action-rename",
|
|
`重命名 ${item.title}`,
|
|
() => beginInlineRename("filetree", item.rowId),
|
|
false,
|
|
),
|
|
);
|
|
}
|
|
actions.appendChild(
|
|
createActionButton(
|
|
ICONS.more,
|
|
"filetree-action-menu",
|
|
`打开 ${item.title} 的更多操作`,
|
|
(button) => {
|
|
const center = getElementCenter(button);
|
|
openFileTreeContextMenu({
|
|
documentId,
|
|
assetId,
|
|
rowId: item.rowId,
|
|
rowKind: item.rowKind,
|
|
clientX: center.x,
|
|
clientY: center.y,
|
|
});
|
|
},
|
|
false,
|
|
),
|
|
);
|
|
row.appendChild(actions);
|
|
container.appendChild(row);
|
|
|
|
if (!hasBranches || !expanded.has(item.nodeId)) return;
|
|
children.forEach((child) => appendFileTreeRow(container, child));
|
|
};
|
|
|
|
fileRoot.addEventListener("dragover", (event) => {
|
|
const internalRowIds = readFileTreeInternalDropPayload(event);
|
|
const files = Array.from(event.dataTransfer?.files || []);
|
|
if (!internalRowIds && files.length === 0) {
|
|
return;
|
|
}
|
|
event.preventDefault();
|
|
const target = getFileTreeDropTargetFromEvent(event);
|
|
if (internalRowIds && !validateFileTreeInternalDrop(target, internalRowIds, event.altKey === true).ok) {
|
|
clearFileTreeDropFeedback();
|
|
return;
|
|
}
|
|
if (event.dataTransfer) {
|
|
event.dataTransfer.dropEffect =
|
|
files.length > 0 || event.altKey ? "copy" : "move";
|
|
}
|
|
setFileTreeDropFeedback(target);
|
|
});
|
|
fileRoot.addEventListener("dragleave", (event) => {
|
|
const relatedTarget =
|
|
event.relatedTarget instanceof Node ? event.relatedTarget : null;
|
|
if (relatedTarget && fileRoot.contains(relatedTarget)) {
|
|
return;
|
|
}
|
|
clearFileTreeDropFeedback();
|
|
});
|
|
fileRoot.addEventListener("contextmenu", (event) => {
|
|
if (event.target !== event.currentTarget) return;
|
|
event.preventDefault();
|
|
clearFileTreeSelection();
|
|
openFileTreeContextMenu({
|
|
documentId: null,
|
|
assetId: null,
|
|
rowId: null,
|
|
rowKind: "root",
|
|
clientX: event.clientX,
|
|
clientY: event.clientY,
|
|
});
|
|
});
|
|
fileRoot.addEventListener("drop", (event) => {
|
|
const internalRowIds = readFileTreeInternalDropPayload(event);
|
|
const files = Array.from(event.dataTransfer?.files || []);
|
|
if (!internalRowIds && files.length === 0) {
|
|
return;
|
|
}
|
|
event.preventDefault();
|
|
const target = getFileTreeDropTargetFromEvent(event);
|
|
if (internalRowIds && !validateFileTreeInternalDrop(target, internalRowIds, event.altKey === true).ok) {
|
|
clearFileTreeDropFeedback();
|
|
return;
|
|
}
|
|
if (files.length > 0) {
|
|
if (sourceKind === "local_folder") {
|
|
void executeLocalFileTreeExternalDrop(target, files);
|
|
} else {
|
|
void (async () => {
|
|
const accepted = await runFileTreePreflight("dropFiles", { target, files });
|
|
if (!accepted) return;
|
|
setLastAction(`已发送 ${files.length} 个外部文件到宿主`);
|
|
postFileTreeDropToHost("tree.filetree.external-drop", target, {
|
|
files,
|
|
});
|
|
})();
|
|
}
|
|
} else {
|
|
if (sourceKind === "local_folder") {
|
|
void executeLocalFileTreeInternalDrop(target, internalRowIds, event.altKey === true);
|
|
} else {
|
|
void (async () => {
|
|
const accepted = await runFileTreePreflight("move", {
|
|
target,
|
|
rowIds: internalRowIds,
|
|
copy: event.altKey === true,
|
|
});
|
|
if (!accepted) return;
|
|
setLastAction(
|
|
event.altKey
|
|
? `已发送复制拖放到 ${target.rowId || "根目录"}`
|
|
: `已发送移动拖放到 ${target.rowId || "根目录"}`,
|
|
);
|
|
postFileTreeDropToHost("tree.filetree.internal-drop", target, {
|
|
rowIds: internalRowIds,
|
|
copy: event.altKey === true,
|
|
});
|
|
})();
|
|
}
|
|
}
|
|
setDraggingFileTreeRowIds([]);
|
|
clearFileTreeDropFeedback();
|
|
});
|
|
|
|
if (getRoots().length === 0) {
|
|
const empty = document.createElement("div");
|
|
empty.className = "tree-empty";
|
|
empty.textContent = "当前 file tree 没有可渲染的页面。";
|
|
fileRoot.appendChild(empty);
|
|
} else {
|
|
getRoots().forEach((item) => appendFileTreeRow(fileRoot, item));
|
|
}
|
|
normalizeFileTreeSelectionForVisibleRows();
|
|
appElement.appendChild(fileRoot);
|
|
patchFileTreeCutDecoration();
|
|
};
|
|
|
|
const renderTree = () => {
|
|
if (mode === "filetree") {
|
|
renderFileTree();
|
|
return;
|
|
}
|
|
appElement.innerHTML = "";
|
|
if (mode === "picker" && allowRootPick) {
|
|
const rootButton = document.createElement("button");
|
|
rootButton.type = "button";
|
|
rootButton.className = "tree-row";
|
|
rootButton.setAttribute("data-testid", "tree-picker-root");
|
|
rootButton.dataset.focused = String(resolvePickerRootFocused());
|
|
rootButton.tabIndex = resolvePickerRootFocused() ? 0 : -1;
|
|
rootButton.addEventListener("click", () => {
|
|
applyPickerFocusByItemKey("__root__", { focusDom: true });
|
|
postPickerPickResultToHost(applyPickerStateAction({ kind: "pick" }));
|
|
});
|
|
|
|
const spacer = document.createElement("div");
|
|
spacer.className = "tree-spacer";
|
|
rootButton.appendChild(spacer);
|
|
|
|
const label = document.createElement("div");
|
|
label.className = "tree-link";
|
|
const title = document.createElement("span");
|
|
title.className = "tree-link-title";
|
|
title.textContent = "根目录";
|
|
const meta = document.createElement("span");
|
|
meta.className = "tree-link-meta";
|
|
meta.textContent = "选择工作空间根目录";
|
|
label.appendChild(title);
|
|
label.appendChild(meta);
|
|
rootButton.appendChild(label);
|
|
appElement.appendChild(rootButton);
|
|
}
|
|
|
|
if (getRoots().length === 0) {
|
|
const empty = document.createElement("div");
|
|
empty.className = "tree-empty";
|
|
empty.textContent =
|
|
mode === "picker"
|
|
? "当前 projection 没有可选择的页面。"
|
|
: "当前 projection 没有可渲染的页面,点击上方按钮先创建一个根页面。";
|
|
appElement.appendChild(empty);
|
|
return;
|
|
}
|
|
const list = document.createElement("ul");
|
|
list.className = "tree-root";
|
|
list.setAttribute("role", "tree");
|
|
getRoots().forEach((item) => {
|
|
list.appendChild(renderNode(item));
|
|
});
|
|
appElement.appendChild(list);
|
|
if (mode === "page" && getFocusedNodeId()) {
|
|
focusRowElement(getFocusedNodeId());
|
|
}
|
|
};
|
|
|
|
|
|
|
|
return {
|
|
renderFileTree,
|
|
renderNode,
|
|
renderTree,
|
|
syncFileTreeSelectionDom,
|
|
openHydratedFileTreeItem,
|
|
hydrateInitialFileTree,
|
|
};
|
|
}
|