diff --git a/design/10-review/process/16-mnote-web-runtime-module-maintainability-checklist-v1.md b/design/10-review/process/16-mnote-web-runtime-module-maintainability-checklist-v1.md index ceeb3759..6f465e32 100644 --- a/design/10-review/process/16-mnote-web-runtime-module-maintainability-checklist-v1.md +++ b/design/10-review/process/16-mnote-web-runtime-module-maintainability-checklist-v1.md @@ -210,7 +210,7 @@ cargo test --manifest-path rust/Cargo.toml -p mnote-web --lib ssr::pages::layout - [x] D4. `tree-shell-picker-runtime.js`:picker search/focus/pick root。 - [x] D5. `tree-shell-dom-runtime.js`:DOM patch/render helpers。 - [x] D6. `tree-shell-icons-runtime.js`:icon templates 和 resource kind badge。 -- [ ] D7. entrypoint 少于 2,500 行。 +- [x] D7. entrypoint 少于 2,500 行。 验收命令: diff --git a/rust/crates/mnote-web/browser/tree-shell-render-runtime.js b/rust/crates/mnote-web/browser/tree-shell-render-runtime.js new file mode 100644 index 00000000..deb6e61a --- /dev/null +++ b/rust/crates/mnote-web/browser/tree-shell-render-runtime.js @@ -0,0 +1,1002 @@ +// 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, + }; +} diff --git a/rust/crates/mnote-web/browser/tree-shell-runtime.js b/rust/crates/mnote-web/browser/tree-shell-runtime.js index 440193e6..6c655a95 100644 --- a/rust/crates/mnote-web/browser/tree-shell-runtime.js +++ b/rust/crates/mnote-web/browser/tree-shell-runtime.js @@ -5,11 +5,6 @@ import { reconcilePageRuntimeResult, reducePageActionWithRuntime, } from "./tree-shell-page-runtime.js"; -import { - TREE_SHELL_ICONS as ICONS, - createTreeShellActionButton, - createTreeShellKindBadge as createKindBadge, -} from "./tree-shell-icons-runtime.js"; import { buildFileTreeRuntimeEnvironment, compareTreeShellItems, @@ -27,6 +22,9 @@ import { createTreeShellFileTreeDndRuntime, TREE_SHELL_FILETREE_DRAG_MIME as FILETREE_DRAG_MIME, } from "./tree-shell-filetree-dnd-runtime.js"; +import { + createTreeShellRenderer, +} from "./tree-shell-render-runtime.js"; import { focusTreeShellPickerRowElement, hydrateTreeShellInitialPageTree, @@ -298,6 +296,15 @@ function startTreeShellRuntime() { let activePageDropNodeId = null; let fileTreeClipboard = { action: null, rowIds: [] }; let inlineRenameState = { mode: null, id: null, committing: false }; + let renderNode = () => { + throw new Error("tree shell renderer is not ready"); + }; + let renderTree = () => { + throw new Error("tree shell renderer is not ready"); + }; + let syncFileTreeSelectionDom = () => {}; + let openHydratedFileTreeItem = () => {}; + let hydrateInitialFileTree = () => false; let activeCursor = itemById.get(currentActiveDocumentId) || null; while (activeCursor && activeCursor.parentNodeId && itemById.has(activeCursor.parentNodeId)) { @@ -2233,288 +2240,6 @@ function startTreeShellRuntime() { focusRowElement, }); - 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 && selectedFileTreeRowIds.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); - draggingFileTreeRowIds = 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", () => { - draggingFileTreeRowIds = []; - 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, - }); - })(); - } - } - draggingFileTreeRowIds = []; - 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 === currentActiveDocumentId - ); - 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(selectedFileTreeRowIds.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; - } - visibleFileTreeRowIds = []; - 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 = fileTreeRowById.get(rowId) || itemById.get(nodeId); - if (!item) return; - visibleFileTreeRowIds.push(item.rowId); - bindFileTreeRowEvents(row, item); - }); - normalizeFileTreeSelectionForVisibleRows(); - syncFileTreeSelectionDom(); - return true; - }; - const bindPickerRootEvents = (row) => { if (!(row instanceof HTMLElement)) return; row.dataset.focused = String(resolvePickerRootFocused()); @@ -2577,627 +2302,87 @@ function startTreeShellRuntime() { return false; }; - const createActionButton = (icon, testId, title, onClick, disabled) => - createTreeShellActionButton(icon, testId, title, onClick, disabled, busy); - - const renderNode = (item) => { - const hasChildren = item.childCount > 0; - const row = document.createElement("div"); - row.className = "tree-row"; - row.dataset.active = String(item.nodeId === currentActiveDocumentId); - row.dataset.focused = String(item.nodeId === focusedNodeId); - row.dataset.nodeId = item.nodeId; - row.dataset.shellMode = mode; - row.dataset.dropFeedback = String(activePageDropNodeId === item.nodeId); - row.tabIndex = item.nodeId === focusedNodeId ? 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 (focusedNodeId !== 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; - draggingPageNodeId = 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 (activePageDropNodeId === item.nodeId) { - clearPageDropFeedback(); - } - }); - row.addEventListener("drop", (event) => { - if (mode !== "page") return; - const sourceNodeId = readPageDragNodeId(event); - const targetNodeId = resolvePageDropTargetNodeId(event.target); - clearPageDropFeedback(); - draggingPageNodeId = ""; - if (!canAcceptPageDrop(sourceNodeId, targetNodeId)) { - return; - } - event.preventDefault(); - void handlePageDropMove(sourceNodeId, targetNodeId); - }); - row.addEventListener("dragend", () => { - if (mode !== "page") return; - draggingPageNodeId = ""; - 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 (inlineRenameState.mode === "page" && inlineRenameState.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 renderFileTree = () => { - appElement.innerHTML = ""; - visibleFileTreeRowIds = []; - 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); - draggingFileTreeRowIds = 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", () => { - draggingFileTreeRowIds = []; - 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 === currentActiveDocumentId - ); - 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(selectedFileTreeRowIds.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"); - visibleFileTreeRowIds.push(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 (inlineRenameState.mode === "filetree" && inlineRenameState.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, - }); - })(); - } - } - draggingFileTreeRowIds = []; - clearFileTreeDropFeedback(); - }); - - if (roots.length === 0) { - const empty = document.createElement("div"); - empty.className = "tree-empty"; - empty.textContent = "当前 file tree 没有可渲染的页面。"; - fileRoot.appendChild(empty); - } else { - roots.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 (roots.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"); - roots.forEach((item) => { - list.appendChild(renderNode(item)); - }); - appElement.appendChild(list); - if (mode === "page" && focusedNodeId) { - focusRowElement(focusedNodeId); - } - }; + const treeShellRenderer = createTreeShellRenderer({ + 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: () => activePageDropNodeId, + getCurrentActiveDocumentId: () => currentActiveDocumentId, + getElementCenter, + getFileTreeDropTargetFromEvent, + getFileTreeRowById: () => fileTreeRowById, + focusRowElement, + getFocusedNodeId: () => focusedNodeId, + getInlineRenameState: () => inlineRenameState, + getItemById: () => itemById, + getRoots: () => roots, + getSelectedFileTreeRowIds: () => selectedFileTreeRowIds, + getSiblings, + handleCreate, + handleFileTreeKeyDown, + handleMove, + handleNavigate, + handlePageDropMove, + handleRowKeyDown, + isBusy: () => busy, + mode, + normalizeText, + normalizeFileTreeSelectionForVisibleRows, + openContextMenu, + openFileTreeContextMenu, + patchFileTreeCutDecoration, + postPickerPickResultToHost, + postToHost, + pushVisibleFileTreeRowId: (rowId) => { + visibleFileTreeRowIds.push(rowId); + }, + readFileTreeInternalDropPayload, + readPageDragNodeId, + resolveFileTreeDraggedRowIds, + resolvePageDropTargetNodeId, + resolvePickerRootFocused, + runFileTreePreflight, + selectFileTreeContextRow, + selectFileTreeRow, + setFileTreeDropFeedback, + setDraggingFileTreeRowIds: (rowIds) => { + draggingFileTreeRowIds = rowIds; + }, + setDraggingPageNodeId: (nodeId) => { + draggingPageNodeId = nodeId; + }, + setLastAction, + setPageDropFeedback, + setVisibleFileTreeRowIds: (rowIds) => { + visibleFileTreeRowIds = rowIds; + }, + sourceKind, + toggleExpand, + validateFileTreeInternalDrop, + workspaceId, + }); + renderNode = treeShellRenderer.renderNode; + renderTree = treeShellRenderer.renderTree; + syncFileTreeSelectionDom = treeShellRenderer.syncFileTreeSelectionDom; + openHydratedFileTreeItem = treeShellRenderer.openHydratedFileTreeItem; + hydrateInitialFileTree = treeShellRenderer.hydrateInitialFileTree; window.addEventListener("message", (event) => { const payload = event.data; diff --git a/rust/crates/mnote-web/src/routes/mod.rs b/rust/crates/mnote-web/src/routes/mod.rs index c4535479..6b875a83 100644 --- a/rust/crates/mnote-web/src/routes/mod.rs +++ b/rust/crates/mnote-web/src/routes/mod.rs @@ -166,6 +166,10 @@ pub fn build_router(state: AppState) -> Router { "/api/mnote-browser-runtime/tree-shell-runtime.js", get(web_shell::tree_shell_runtime_asset), ) + .route( + "/api/mnote-browser-runtime/tree-shell-render-runtime.js", + get(web_shell::tree_shell_render_runtime_asset), + ) .route( "/api/mnote-browser-runtime/tree-shell-page-runtime.js", get(web_shell::tree_shell_page_runtime_asset), @@ -649,6 +653,7 @@ mod tests { "/api/mnote-browser-runtime/sidebar-tree-runtime.js", "/api/mnote-browser-runtime/tree-live-controller.js", "/api/mnote-browser-runtime/tree-shell-runtime.js", + "/api/mnote-browser-runtime/tree-shell-render-runtime.js", "/api/mnote-browser-runtime/tree-shell-page-runtime.js", "/api/mnote-browser-runtime/tree-shell-state-runtime.js", "/api/mnote-browser-runtime/tree-shell-icons-runtime.js", diff --git a/rust/crates/mnote-web/src/routes/tree.rs b/rust/crates/mnote-web/src/routes/tree.rs index d12d433c..ce77cf71 100644 --- a/rust/crates/mnote-web/src/routes/tree.rs +++ b/rust/crates/mnote-web/src/routes/tree.rs @@ -2393,6 +2393,8 @@ pub async fn reduce_tree_shell_runtime( #[cfg(test)] mod tests { const TREE_SHELL_RUNTIME_JS: &str = include_str!("../../browser/tree-shell-runtime.js"); + const TREE_SHELL_RENDER_RUNTIME_JS: &str = + include_str!("../../browser/tree-shell-render-runtime.js"); const TREE_SHELL_FILETREE_RUNTIME_JS: &str = include_str!("../../browser/tree-shell-filetree-runtime.js"); const TREE_SHELL_FILETREE_MENU_RUNTIME_JS: &str = @@ -2542,7 +2544,7 @@ mod tests { assert!(html.contains("tree-create-root")); assert!(TREE_SHELL_RUNTIME_JS.contains("tree.ready")); assert!(html.contains("test-shell")); - assert!(TREE_SHELL_RUNTIME_JS.contains("tree-action-menu")); + assert!(TREE_SHELL_RENDER_RUNTIME_JS.contains("tree-action-menu")); assert!(TREE_SHELL_RUNTIME_JS.contains("tree.page.context-menu")); assert!(TREE_SHELL_RUNTIME_JS.contains("tree.page.expand.changed")); assert!(TREE_SHELL_RUNTIME_JS.contains("tree.page.focus.changed")); @@ -2675,13 +2677,15 @@ mod tests { assert!(TREE_SHELL_RUNTIME_JS.contains("tree.filetree.selection.changed")); assert!(html.contains("\"contractName\":\"rust_filetree_selection_reducer_v1\"")); assert!(TREE_SHELL_RUNTIME_JS.contains("applyFileTreeSelectionAction")); - assert!(TREE_SHELL_RUNTIME_JS.contains("tree.filetree.internal-drop")); - assert!(TREE_SHELL_RUNTIME_JS.contains("tree.filetree.external-drop")); + assert!(TREE_SHELL_RENDER_RUNTIME_JS.contains("tree.filetree.internal-drop")); + assert!(TREE_SHELL_RENDER_RUNTIME_JS.contains("tree.filetree.external-drop")); assert!(html.contains("\"rowKind\":\"asset_folder\"")); assert!(html.contains("\"resourceMeta\"")); + assert!(TREE_SHELL_RUNTIME_JS.contains("tree-shell-render-runtime.js")); assert!(TREE_SHELL_RUNTIME_JS.contains("tree-shell-filetree-runtime.js")); assert!(TREE_SHELL_RUNTIME_JS.contains("tree-shell-filetree-menu-runtime.js")); assert!(TREE_SHELL_RUNTIME_JS.contains("tree-shell-filetree-dnd-runtime.js")); + assert!(TREE_SHELL_RENDER_RUNTIME_JS.contains("function createTreeShellRenderer(context)")); assert!( TREE_SHELL_FILETREE_RUNTIME_JS.contains("function getFileTreeRowOwnerDocumentId(item)") ); @@ -2689,14 +2693,14 @@ mod tests { .contains("function buildFileTreeMenuTarget(context")); assert!(TREE_SHELL_FILETREE_DND_RUNTIME_JS .contains("function createTreeShellFileTreeDndRuntime(context)")); - assert!(TREE_SHELL_RUNTIME_JS + assert!(TREE_SHELL_RENDER_RUNTIME_JS .contains("row.dataset.ownerDocumentId = ownerDocumentId || \"\";")); assert!( TREE_SHELL_RUNTIME_JS.contains("if (rowId && getFileTreeRowDocumentId(renameItem))") ); - assert!(TREE_SHELL_RUNTIME_JS.contains("if (getFileTreeRowDocumentId(item))")); - assert!(TREE_SHELL_RUNTIME_JS.contains("documentId: ownerDocumentId || null")); - assert!(TREE_SHELL_RUNTIME_JS.contains("dragover")); + assert!(TREE_SHELL_RENDER_RUNTIME_JS.contains("if (getFileTreeRowDocumentId(item))")); + assert!(TREE_SHELL_RENDER_RUNTIME_JS.contains("documentId: ownerDocumentId || null")); + assert!(TREE_SHELL_RENDER_RUNTIME_JS.contains("dragover")); assert!(TREE_SHELL_RUNTIME_JS.contains("hydrateInitialFileTree")); assert!(TREE_SHELL_RUNTIME_JS .contains("const usedRustInitialRenderer = hydrateInitialRenderer();")); diff --git a/rust/crates/mnote-web/src/routes/web_shell.rs b/rust/crates/mnote-web/src/routes/web_shell.rs index cfe3b282..a6271c02 100644 --- a/rust/crates/mnote-web/src/routes/web_shell.rs +++ b/rust/crates/mnote-web/src/routes/web_shell.rs @@ -938,6 +938,20 @@ pub async fn tree_shell_runtime_asset() -> Response { .unwrap_or_else(|_| Response::new(Body::empty())) } +pub async fn tree_shell_render_runtime_asset() -> Response { + const JS: &str = include_str!("../../browser/tree-shell-render-runtime.js"); + Response::builder() + .status(StatusCode::OK) + .header( + header::CONTENT_TYPE, + "application/javascript; charset=utf-8", + ) + .header(header::CACHE_CONTROL, "no-store") + .header(HEADER_MNOTE_WEB_OWNER, "mnote-web") + .body(Body::from(JS)) + .unwrap_or_else(|_| Response::new(Body::empty())) +} + pub async fn tree_shell_page_runtime_asset() -> Response { const JS: &str = include_str!("../../browser/tree-shell-page-runtime.js"); Response::builder()