Files
mnote/rust/crates/mnote-web/browser/tree-shell-render-runtime.js
T

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,
};
}