4-10 树域 Rust 家族化

This commit is contained in:
lix-2026
2026-04-24 06:10:18 +08:00
parent 41e958769e
commit 94631f3636
49 changed files with 5751 additions and 557 deletions
+618 -222
View File
@@ -380,6 +380,16 @@ fn build_tree_shell_html(
background: rgba(15, 108, 132, 0.1);
border-color: rgba(15, 108, 132, 0.24);
}
.tree-row[data-drop-feedback="true"] {
background: rgba(148, 163, 184, 0.18);
border-color: rgba(15, 108, 132, 0.28);
}
.tree-row[data-draggable="true"] {
cursor: grab;
}
.tree-row[data-draggable="true"]:active {
cursor: grabbing;
}
.tree-toggle,
.tree-action {
border: 0;
@@ -588,6 +598,13 @@ fn build_tree_shell_html(
box-shadow: none;
background: rgba(37, 99, 235, 0.12);
}
.tree-row[data-drop-target="true"] {
background: rgba(15, 108, 132, 0.14);
outline: 1px solid rgba(15, 108, 132, 0.35);
}
.tree-root[data-drop-target="true"] {
background: rgba(15, 108, 132, 0.05);
}
.tree-toggle,
.tree-action {
border-radius: 4px;
@@ -738,6 +755,11 @@ fn build_tree_shell_html(
};
const state = parseState();
const hostOverride =
window.__MNOTE_TREE_SHELL_OVERRIDE__ &&
typeof window.__MNOTE_TREE_SHELL_OVERRIDE__ === "object"
? window.__MNOTE_TREE_SHELL_OVERRIDE__
: {};
const channel =
typeof state.channel === "string" && state.channel.trim()
? state.channel.trim()
@@ -807,17 +829,68 @@ fn build_tree_shell_html(
return Number.isFinite(value) ? Number(value) : fallback;
};
const normalizedItems = Array.isArray(state.items)
? state.items
.map((item) => ({
nodeId: normalizeText(item?.nodeId),
parentNodeId: normalizeParent(item?.parentNodeId),
title: normalizeText(item?.title, "无标题"),
depth: normalizeNumber(item?.depth, 0),
childCount: normalizeNumber(item?.childCount, 0),
position: normalizeNumber(item?.position),
expandedByDefault: item?.expandedByDefault !== false,
}))
const normalizeRowKind = (value) => {
const normalized = normalizeText(value).toLowerCase();
if (normalized === "index") return "index";
if (normalized === "asset") return "asset";
if (normalized === "asset_folder") return "asset_folder";
return "document";
};
const normalizeCapabilities = (value) =>
Array.isArray(value)
? value
.map((item) => normalizeText(item))
.filter(Boolean)
: [];
const normalizeResourceMeta = (value) => {
if (!value || typeof value !== "object") {
return {
resourceKind: "",
documentId: "",
assetId: "",
assetKind: "",
};
}
return {
resourceKind: normalizeText(value?.resourceKind),
documentId: normalizeText(value?.documentId),
assetId: normalizeText(value?.assetId),
assetKind: normalizeText(value?.assetKind),
};
};
const rawItems = Array.isArray(hostOverride.items) ? hostOverride.items : state.items;
const normalizedItems = Array.isArray(rawItems)
? rawItems
.map((item) => {
const nodeId = normalizeText(item?.nodeId);
const resourceMeta = normalizeResourceMeta(item?.resourceMeta);
const rowKind = normalizeRowKind(item?.rowKind);
const fallbackRowId =
rowKind === "index"
? `index:${resourceMeta.documentId || nodeId.replace(/^index:/, "")}`
: rowKind === "asset"
? `asset:${resourceMeta.assetId || nodeId.replace(/^asset:/, "")}`
: rowKind === "asset_folder"
? `asset-folder:${resourceMeta.assetId || nodeId.replace(/^asset-folder:/, "")}`
: `doc:${resourceMeta.documentId || nodeId}`;
return {
rowId: normalizeText(item?.rowId, fallbackRowId),
rowKind,
nodeId,
parentNodeId: normalizeParent(item?.parentNodeId),
title: normalizeText(item?.title, rowKind === "index" ? "index.md" : "无标题"),
depth: normalizeNumber(item?.depth, 0),
childCount: normalizeNumber(item?.childCount, 0),
position: normalizeNumber(item?.position),
expandedByDefault: item?.expandedByDefault !== false,
iconHint: normalizeText(item?.iconHint),
capabilities: normalizeCapabilities(item?.capabilities),
resourceMeta,
};
})
.filter((item) => item.nodeId && !excludedIds.has(item.nodeId))
: [];
@@ -870,6 +943,12 @@ fn build_tree_shell_html(
? activeDocumentId
: roots[0]?.nodeId || "";
let selectedFileTreeRowIds = new Set(activeDocumentId ? [`doc:${activeDocumentId}`, `index:${activeDocumentId}`] : []);
let fileTreeAnchorRowId = activeDocumentId ? `doc:${activeDocumentId}` : null;
let fileTreeFocusedRowId = activeDocumentId ? `doc:${activeDocumentId}` : null;
let visibleFileTreeRowIds = [];
let draggingFileTreeRowIds = [];
let activeFileTreeDropRowId = null;
let activeFileTreeRootDrop = false;
let activeCursor = itemById.get(activeDocumentId) || null;
while (activeCursor && activeCursor.parentNodeId && itemById.has(activeCursor.parentNodeId)) {
@@ -938,6 +1017,316 @@ fn build_tree_shell_html(
window.parent.postMessage(payload, targetOrigin);
};
const FILETREE_DRAG_MIME = "application/x-mnote-filetree-row-ids";
const getFileTreeRowDocumentId = (item) => {
if (!item) return "";
if (item.resourceMeta?.documentId) return item.resourceMeta.documentId;
if (item.rowKind === "document") return item.nodeId;
if (item.rowKind === "index") return item.nodeId.replace(/^index:/, "");
return "";
};
const getFileTreeRowAssetId = (item) => {
if (!item) return "";
if (item.resourceMeta?.assetId) return item.resourceMeta.assetId;
if (item.rowKind === "asset") return item.nodeId.replace(/^asset:/, "");
if (item.rowKind === "asset_folder") return item.nodeId.replace(/^asset-folder:/, "");
return "";
};
const getFileTreeRowIconKind = (item) => {
if (!item) return "file";
const iconHint = normalizeText(item.iconHint).toLowerCase();
if (iconHint === "mindmap") return "mindmap";
if (iconHint === "table") return "table";
if (iconHint === "index") return "index";
if (iconHint === "page") return "page";
if (item.rowKind === "document") return "page";
if (item.rowKind === "index") return "index";
if (item.rowKind === "asset_folder") return "mindmap";
if (item.resourceMeta?.resourceKind === "table") return "table";
if (item.resourceMeta?.resourceKind === "mindmap") return "mindmap";
return "file";
};
const getFileTreeRowMetaLabel = (item) => {
if (!item) return "";
if (item.rowKind === "document") {
return `${getFileTreeRowDocumentId(item)} · 页面`;
}
if (item.rowKind === "index") {
return "页面正文";
}
if (item.rowKind === "asset_folder") {
return `${item.resourceMeta?.resourceKind || "mindmap"} · 资源目录`;
}
return `${item.resourceMeta?.resourceKind || item.resourceMeta?.assetKind || "asset"} · ${getFileTreeRowAssetId(item)}`;
};
const canExpandFileTreeRow = (item) => {
if (!item) return false;
return item.childCount > 0 || item.capabilities.includes("expand");
};
const getFileTreeDropTargetFromElement = (element) => {
const row = element instanceof Element
? element.closest('.tree-row[data-shell-mode="filetree"]')
: null;
if (!(row instanceof HTMLElement)) {
return {
rowId: null,
rowKind: "root",
documentId: null,
assetId: null,
};
}
return {
rowId: normalizeText(row.dataset.rowId) || null,
rowKind: normalizeText(row.dataset.rowKind, "document"),
documentId: normalizeText(row.dataset.documentId) || null,
assetId: normalizeText(row.dataset.assetId) || null,
};
};
const clearFileTreeDropFeedback = () => {
if (activeFileTreeDropRowId) {
const previousRow = appElement.querySelector(
`.tree-row[data-shell-mode="filetree"][data-row-id="${activeFileTreeDropRowId}"]`,
);
if (previousRow instanceof HTMLElement) {
previousRow.dataset.dropTarget = "false";
}
}
activeFileTreeDropRowId = null;
activeFileTreeRootDrop = false;
appElement.querySelectorAll('.tree-root[data-drop-target="true"]').forEach((element) => {
if (element instanceof HTMLElement) {
element.dataset.dropTarget = "false";
}
});
};
const setFileTreeDropFeedback = (target) => {
const nextRowId = target?.rowId || null;
if (activeFileTreeDropRowId && activeFileTreeDropRowId !== nextRowId) {
const previousRow = appElement.querySelector(
`.tree-row[data-shell-mode="filetree"][data-row-id="${activeFileTreeDropRowId}"]`,
);
if (previousRow instanceof HTMLElement) {
previousRow.dataset.dropTarget = "false";
}
}
if (nextRowId) {
const nextRow = appElement.querySelector(
`.tree-row[data-shell-mode="filetree"][data-row-id="${nextRowId}"]`,
);
if (nextRow instanceof HTMLElement) {
nextRow.dataset.dropTarget = "true";
}
activeFileTreeDropRowId = nextRowId;
activeFileTreeRootDrop = false;
appElement.querySelectorAll('.tree-root[data-drop-target="true"]').forEach((element) => {
if (element instanceof HTMLElement) {
element.dataset.dropTarget = "false";
}
});
return;
}
activeFileTreeDropRowId = null;
const root = appElement.querySelector(".tree-root");
if (root instanceof HTMLElement) {
root.dataset.dropTarget = "true";
}
activeFileTreeRootDrop = true;
};
const updateFileTreeDropFeedback = (rowId) => {
if (rowId) {
setFileTreeDropFeedback({ rowId });
return;
}
clearFileTreeDropFeedback();
};
const inferDefaultFileTreeDropDocumentId = () => {
const candidateRowIds = [
fileTreeFocusedRowId,
fileTreeAnchorRowId,
...Array.from(selectedFileTreeRowIds),
].filter(Boolean);
for (const rowId of candidateRowIds) {
if (!rowId) continue;
const item = fileTreeRowById.get(rowId);
const documentId = getFileTreeRowDocumentId(item);
if (documentId) {
return documentId;
}
}
const firstDocRowId = visibleFileTreeRowIds.find((rowId) => rowId.startsWith("doc:"));
const firstDocItem = firstDocRowId ? fileTreeRowById.get(firstDocRowId) : null;
return getFileTreeRowDocumentId(firstDocItem) || null;
};
const resolveFileTreeDraggedRowIds = (rowId) => {
if (!rowId) return [];
if (selectedFileTreeRowIds.has(rowId)) {
return Array.from(selectedFileTreeRowIds);
}
return [rowId];
};
const isExternalFileDrag = (event) => {
const types = event.dataTransfer?.types;
return Array.isArray(types)
? types.includes("Files")
: types instanceof DOMStringList
? types.contains("Files")
: false;
};
const isInternalFileTreeDrag = (event) => {
const types = event.dataTransfer?.types;
return Array.isArray(types)
? types.includes(FILETREE_DRAG_MIME)
: types instanceof DOMStringList
? types.contains(FILETREE_DRAG_MIME)
: false;
};
const emitFileTreeSelectionChange = () => {
if (mode !== "filetree") return;
postToHost("tree.filetree.selection.changed", {
selectedRowIds: Array.from(selectedFileTreeRowIds),
anchorRowId: fileTreeAnchorRowId,
focusedRowId: fileTreeFocusedRowId,
payload: {
selectedRowIds: Array.from(selectedFileTreeRowIds),
anchorRowId: fileTreeAnchorRowId,
focusedRowId: fileTreeFocusedRowId,
},
});
};
const getFileTreeRangeRowIds = (fromId, toId) => {
const fromIndex = visibleFileTreeRowIds.indexOf(fromId);
const toIndex = visibleFileTreeRowIds.indexOf(toId);
if (fromIndex < 0 || toIndex < 0) {
return [toId];
}
const lo = Math.min(fromIndex, toIndex);
const hi = Math.max(fromIndex, toIndex);
return visibleFileTreeRowIds.slice(lo, hi + 1);
};
const commitFileTreeSelection = (nextSelection) => {
selectedFileTreeRowIds = nextSelection.selectedRowIds;
fileTreeAnchorRowId = nextSelection.anchorRowId;
fileTreeFocusedRowId = nextSelection.focusedRowId;
emitFileTreeSelectionChange();
};
const selectFileTreeRow = (rowId, modifiers = {}) => {
const shiftKey = modifiers.shiftKey === true;
const metaKey = modifiers.metaKey === true;
const ctrlKey = modifiers.ctrlKey === true;
const toggleSelection = metaKey || ctrlKey;
if (shiftKey) {
const anchor = fileTreeAnchorRowId || fileTreeFocusedRowId || rowId;
const nextSelection = toggleSelection ? new Set(selectedFileTreeRowIds) : new Set();
getFileTreeRangeRowIds(anchor, rowId).forEach((id) => {
nextSelection.add(id);
});
commitFileTreeSelection({
selectedRowIds: nextSelection,
anchorRowId: fileTreeAnchorRowId || anchor,
focusedRowId: rowId,
});
return;
}
if (toggleSelection) {
const nextSelection = new Set(selectedFileTreeRowIds);
if (nextSelection.has(rowId)) {
nextSelection.delete(rowId);
} else {
nextSelection.add(rowId);
}
commitFileTreeSelection({
selectedRowIds: nextSelection,
anchorRowId: rowId,
focusedRowId: rowId,
});
return;
}
commitFileTreeSelection({
selectedRowIds: new Set([rowId]),
anchorRowId: rowId,
focusedRowId: rowId,
});
};
const selectFileTreeContextRow = (rowId) => {
if (selectedFileTreeRowIds.has(rowId)) {
commitFileTreeSelection({
selectedRowIds: new Set(selectedFileTreeRowIds),
anchorRowId: fileTreeAnchorRowId,
focusedRowId: rowId,
});
return;
}
commitFileTreeSelection({
selectedRowIds: new Set([rowId]),
anchorRowId: rowId,
focusedRowId: rowId,
});
};
const clearFileTreeSelection = () => {
if (mode !== "filetree") return;
if (
selectedFileTreeRowIds.size === 0 &&
!fileTreeAnchorRowId &&
!fileTreeFocusedRowId
) {
return;
}
commitFileTreeSelection({
selectedRowIds: new Set(),
anchorRowId: null,
focusedRowId: null,
});
renderTree();
};
const normalizeFileTreeSelectionForVisibleRows = () => {
if (mode !== "filetree") return;
const visibleSet = new Set(visibleFileTreeRowIds);
const nextSelectedRowIds = Array.from(selectedFileTreeRowIds).filter((rowId) => visibleSet.has(rowId));
const nextAnchorRowId =
fileTreeAnchorRowId && visibleSet.has(fileTreeAnchorRowId) ? fileTreeAnchorRowId : null;
const nextFocusedRowId =
fileTreeFocusedRowId && visibleSet.has(fileTreeFocusedRowId) ? fileTreeFocusedRowId : null;
if (
nextSelectedRowIds.length === selectedFileTreeRowIds.size &&
nextAnchorRowId === fileTreeAnchorRowId &&
nextFocusedRowId === fileTreeFocusedRowId
) {
return;
}
commitFileTreeSelection({
selectedRowIds: new Set(nextSelectedRowIds),
anchorRowId: nextAnchorRowId,
focusedRowId: nextFocusedRowId,
});
};
const scheduleRefresh = () => {
window.setTimeout(() => {
window.location.reload();
@@ -1142,6 +1531,30 @@ fn build_tree_shell_html(
};
};
const readFileTreeInternalDropPayload = (event) => {
const raw =
event.dataTransfer?.getData("application/x-mnote-file-tree") ||
event.dataTransfer?.getData("text/plain") ||
"";
if (!raw) return null;
try {
const payload = JSON.parse(raw);
if (
payload?.type !== "mnote-file-tree-dnd" ||
payload.version !== 1 ||
!Array.isArray(payload.rowIds)
) {
return null;
}
const rowIds = payload.rowIds
.map((value) => normalizeText(value))
.filter(Boolean);
return rowIds.length > 0 ? rowIds : null;
} catch {
return null;
}
};
const handleRowKeyDown = (event, item) => {
if (mode !== "page") return;
const visible = getVisiblePageItems();
@@ -1506,163 +1919,148 @@ fn build_tree_shell_html(
const renderFileTree = () => {
appElement.innerHTML = "";
visibleFileTreeRowIds = [];
clearFileTreeDropFeedback();
const fileRoot = document.createElement("div");
fileRoot.className = "tree-root";
fileRoot.setAttribute("role", "tree");
fileRoot.dataset.dropTarget = String(activeFileTreeRootDrop);
fileRoot.addEventListener("mousedown", (event) => {
if (event.target !== event.currentTarget) return;
clearFileTreeSelection();
});
const appendAssetRow = (container, asset, depth) => {
const row = document.createElement("div");
row.className = "tree-row";
row.style.marginLeft = `${depth * 22}px`;
row.setAttribute("data-testid", "filetree-asset-row");
row.dataset.assetId = asset.id;
row.dataset.rowId = `asset:${asset.id}`;
row.dataset.rowKind = "asset";
row.dataset.shellMode = "filetree";
row.dataset.selected = String(selectedFileTreeRowIds.has(`asset:${asset.id}`));
row.tabIndex = 0;
row.setAttribute("role", "treeitem");
row.setAttribute("aria-level", String(depth + 1));
row.addEventListener("click", () => {
selectedFileTreeRowIds = new Set([`asset:${asset.id}`]);
renderTree();
});
row.addEventListener("contextmenu", (event) => {
event.preventDefault();
selectedFileTreeRowIds = new Set([`asset:${asset.id}`]);
renderTree();
openFileTreeContextMenu({
documentId: asset.documentId,
assetId: asset.id,
rowId: `asset:${asset.id}`,
rowKind: "asset",
clientX: event.clientX,
clientY: event.clientY,
});
});
const spacer = document.createElement("div");
spacer.className = "tree-spacer";
row.appendChild(spacer);
row.appendChild(
createKindBadge(
asset.assetType === "mindmap"
? "mindmap"
: asset.assetType === "luckysheet"
? "table"
: "file",
),
);
const button = document.createElement("button");
button.type = "button";
button.className = "tree-link";
button.setAttribute("data-testid", "filetree-asset-open");
button.addEventListener("click", () => {
setLastAction(`准备打开附件 ${asset.id}`);
postToHost("tree.asset.open", {
assetId: asset.id,
documentId: asset.documentId,
target: { documentId: asset.documentId },
payload: { documentId: asset.documentId, assetId: asset.id },
});
});
const title = document.createElement("span");
title.className = "tree-link-title";
title.textContent = asset.fileName;
const meta = document.createElement("span");
meta.className = "tree-link-meta";
meta.textContent = `${asset.assetType} · ${asset.id}`;
button.appendChild(title);
button.appendChild(meta);
row.appendChild(button);
const actions = document.createElement("div");
actions.className = "tree-actions";
actions.appendChild(
createActionButton(
ICONS.more,
"filetree-action-menu",
`打开 ${asset.fileName} 的更多操作`,
(button) => {
const center = getElementCenter(button);
openFileTreeContextMenu({
documentId: asset.documentId,
assetId: asset.id,
rowId: `asset:${asset.id}`,
rowKind: "asset",
clientX: center.x,
clientY: center.y,
});
},
false,
),
);
row.appendChild(actions);
container.appendChild(row);
const childIds = Array.isArray(mindmapAssetChildren[asset.id]) ? mindmapAssetChildren[asset.id] : [];
if (childIds.length === 0) return;
childIds.forEach((childId) => {
const child = mediaAssets.find((entry) => normalizeText(entry?.id) === childId);
if (!child) return;
appendAssetRow(
container,
{
id: normalizeText(child?.id),
documentId: normalizeText(child?.document_id),
assetType: normalizeText(child?.asset_type, "file"),
fileName: normalizeText(child?.file_name, "附件"),
storagePath: normalizeText(child?.storage_path),
},
depth + 1,
);
const openFileTreeItem = (item) => {
const documentId = getFileTreeRowDocumentId(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: documentId || null,
assetId: assetId || null,
target: { documentId: documentId || null },
payload: {
documentId: documentId || null,
assetId: assetId || null,
rowId: item.rowId,
rowKind: item.rowKind,
},
});
};
const appendDoc = (item, depth) => {
const wrapper = document.createElement("div");
wrapper.className = "tree-node";
wrapper.dataset.nodeId = item.nodeId;
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 assetId = getFileTreeRowAssetId(item) || null;
const row = document.createElement("div");
row.className = "tree-row";
row.style.marginLeft = `${depth * 22}px`;
row.dataset.active = String(item.nodeId === activeDocumentId);
row.setAttribute("data-testid", "filetree-doc-row");
row.dataset.rowId = `doc:${item.nodeId}`;
row.dataset.rowKind = "doc";
row.style.marginLeft = `${item.depth * 22}px`;
row.dataset.active = String(item.rowKind === "document" && documentId === activeDocumentId);
row.dataset.nodeId = item.nodeId;
row.dataset.rowId = item.rowId;
row.dataset.rowKind = item.rowKind;
row.dataset.documentId = documentId || "";
row.dataset.assetId = assetId || "";
row.dataset.shellMode = "filetree";
row.dataset.selected = String(selectedFileTreeRowIds.has(`doc:${item.nodeId}`));
row.dataset.selected = String(selectedFileTreeRowIds.has(item.rowId));
row.dataset.dropTarget = String(activeFileTreeDropRowId === item.rowId);
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(depth + 1));
const children = getSiblings(item.nodeId);
const assets = assetsByDocId.get(item.nodeId) || [];
const hasBranches = children.length > 0 || assets.length > 0;
row.setAttribute("aria-level", String(item.depth + 1));
row.setAttribute("aria-expanded", hasBranches ? String(expanded.has(item.nodeId)) : "false");
row.addEventListener("click", () => {
selectedFileTreeRowIds = new Set([`doc:${item.nodeId}`]);
visibleFileTreeRowIds.push(item.rowId);
row.addEventListener("click", (event) => {
selectFileTreeRow(item.rowId, event);
renderTree();
});
row.addEventListener("dblclick", () => {
openFileTreeItem(item);
});
row.addEventListener("contextmenu", (event) => {
event.preventDefault();
selectedFileTreeRowIds = new Set([`doc:${item.nodeId}`]);
selectFileTreeContextRow(item.rowId);
renderTree();
openFileTreeContextMenu({
documentId: item.nodeId,
rowId: `doc:${item.nodeId}`,
rowKind: "doc",
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.setAttribute(
"aria-label",
`${expanded.has(item.nodeId) ? "折叠" : "展开"} ${item.title}`,
);
toggleButton.textContent = expanded.has(item.nodeId) ? "▾" : "▸";
toggleButton.addEventListener("click", (event) => {
event.stopPropagation();
@@ -1675,21 +2073,27 @@ fn build_tree_shell_html(
row.appendChild(spacer);
}
row.appendChild(createKindBadge("page"));
row.appendChild(createKindBadge(getFileTreeRowIconKind(item)));
const linkButton = document.createElement("button");
linkButton.type = "button";
linkButton.className = "tree-link";
linkButton.setAttribute("data-testid", "filetree-doc-open");
linkButton.addEventListener("click", () => handleNavigate(item.nodeId));
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));
const title = document.createElement("span");
title.className = "tree-link-title";
title.textContent = item.title;
const meta = document.createElement("span");
meta.className = "tree-link-meta";
meta.textContent = `${item.nodeId} · 页面`;
meta.textContent = getFileTreeRowMetaLabel(item);
linkButton.appendChild(title);
linkButton.appendChild(meta);
row.appendChild(linkButton);
const actions = document.createElement("div");
actions.className = "tree-actions";
actions.appendChild(
@@ -1700,9 +2104,10 @@ fn build_tree_shell_html(
(button) => {
const center = getElementCenter(button);
openFileTreeContextMenu({
documentId: item.nodeId,
rowId: `doc:${item.nodeId}`,
rowKind: "doc",
documentId,
assetId,
rowId: item.rowId,
rowKind: item.rowKind,
clientX: center.x,
clientY: center.y,
});
@@ -1711,90 +2116,71 @@ fn build_tree_shell_html(
),
);
row.appendChild(actions);
wrapper.appendChild(row);
container.appendChild(row);
const indexRow = document.createElement("div");
indexRow.className = "tree-row";
indexRow.style.marginLeft = `${(depth + 1) * 22}px`;
indexRow.setAttribute("data-testid", "filetree-index-row");
indexRow.dataset.rowId = `index:${item.nodeId}`;
indexRow.dataset.rowKind = "index";
indexRow.dataset.shellMode = "filetree";
indexRow.dataset.selected = String(selectedFileTreeRowIds.has(`index:${item.nodeId}`));
indexRow.tabIndex = 0;
indexRow.setAttribute("role", "treeitem");
indexRow.setAttribute("aria-level", String(depth + 2));
indexRow.addEventListener("click", () => {
selectedFileTreeRowIds = new Set([`index:${item.nodeId}`]);
renderTree();
});
indexRow.addEventListener("contextmenu", (event) => {
event.preventDefault();
selectedFileTreeRowIds = new Set([`index:${item.nodeId}`]);
renderTree();
openFileTreeContextMenu({
documentId: item.nodeId,
rowId: `index:${item.nodeId}`,
rowKind: "index",
clientX: event.clientX,
clientY: event.clientY,
});
});
const indexSpacer = document.createElement("div");
indexSpacer.className = "tree-spacer";
indexRow.appendChild(indexSpacer);
indexRow.appendChild(createKindBadge("index"));
const indexLink = document.createElement("button");
indexLink.type = "button";
indexLink.className = "tree-link";
indexLink.addEventListener("click", () => handleNavigate(item.nodeId));
const indexTitle = document.createElement("span");
indexTitle.className = "tree-link-title";
indexTitle.textContent = "index.md";
const indexMeta = document.createElement("span");
indexMeta.className = "tree-link-meta";
indexMeta.textContent = "页面正文";
indexLink.appendChild(indexTitle);
indexLink.appendChild(indexMeta);
indexRow.appendChild(indexLink);
const indexActions = document.createElement("div");
indexActions.className = "tree-actions";
indexActions.appendChild(
createActionButton(
ICONS.more,
"filetree-action-menu",
`打开 ${item.title} 正文的更多操作`,
(button) => {
const center = getElementCenter(button);
openFileTreeContextMenu({
documentId: item.nodeId,
rowId: `index:${item.nodeId}`,
rowKind: "index",
clientX: center.x,
clientY: center.y,
});
},
false,
),
);
indexRow.appendChild(indexActions);
if (expanded.has(item.nodeId) || !hasBranches) {
wrapper.appendChild(indexRow);
assets.forEach((asset) => appendAssetRow(wrapper, asset, depth + 1));
children.forEach((child) => appendDoc(child, depth + 1));
}
fileRoot.appendChild(wrapper);
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 = getFileTreeDropTargetFromElement(event.target);
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("drop", (event) => {
const internalRowIds = readFileTreeInternalDropPayload(event);
const files = Array.from(event.dataTransfer?.files || []);
if (!internalRowIds && files.length === 0) {
return;
}
event.preventDefault();
const target = getFileTreeDropTargetFromElement(event.target);
if (files.length > 0) {
setLastAction(`已发送 ${files.length} 个外部文件到宿主`);
postFileTreeDropToHost("tree.filetree.external-drop", target, {
files,
});
} else {
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 没有可渲染的页面。";
appElement.appendChild(empty);
return;
fileRoot.appendChild(empty);
} else {
roots.forEach((item) => appendFileTreeRow(fileRoot, item));
}
roots.forEach((item) => appendDoc(item, 0));
normalizeFileTreeSelectionForVisibleRows();
appElement.appendChild(fileRoot);
};
@@ -1871,6 +2257,9 @@ fn build_tree_shell_html(
};
renderTree();
if (mode === "filetree") {
emitFileTreeSelectionChange();
}
setStatus(
mode === "picker"
? "Tree picker 已就绪,可以展开目录并选择目标页面。"
@@ -2219,7 +2608,7 @@ mod tests {
convex_url: None,
convex_admin_key: None,
allow_dev_fixtures: true,
query_fixtures_json: Some(r#"{"sidebar:datasetList":{"active_workspace_id":"ws_demo","documents":[{"id":"page_root","workspace_id":"ws_demo","title":"工作区首页","parent_id":null,"sort_order":0,"is_starred":true,"is_template":false,"created_at":"2026-04-16T00:00:00Z","updated_at":"2026-04-16T00:00:00Z"},{"id":"page_child","workspace_id":"ws_demo","title":"子页面","parent_id":"page_root","sort_order":1,"is_starred":false,"is_template":false,"created_at":"2026-04-16T00:00:00Z","updated_at":"2026-04-16T00:00:00Z"}]}}"#.into()),
query_fixtures_json: Some(r#"{"sidebar:datasetList":{"active_workspace_id":"ws_demo","documents":[{"id":"page_root","workspace_id":"ws_demo","title":"工作区首页","parent_id":null,"sort_order":0,"is_starred":true,"is_template":false,"created_at":"2026-04-16T00:00:00Z","updated_at":"2026-04-16T00:00:00Z"},{"id":"page_child","workspace_id":"ws_demo","title":"子页面","parent_id":"page_root","sort_order":1,"is_starred":false,"is_template":false,"created_at":"2026-04-16T00:00:00Z","updated_at":"2026-04-16T00:00:00Z"}],"media_assets":[{"id":"asset_file_1","workspace_id":"ws_demo","document_id":"page_root","asset_type":"file","file_name":"封面.png","mime_type":"image/png","created_at":"2026-04-16T00:00:00Z","updated_at":"2026-04-16T00:00:00Z"},{"id":"asset_child_1","workspace_id":"ws_demo","document_id":"page_root","asset_type":"file","file_name":"节点图片.png","mime_type":"image/png","created_at":"2026-04-16T00:00:00Z","updated_at":"2026-04-16T00:00:00Z"},{"id":"asset_pdf_1","workspace_id":"ws_demo","document_id":"page_root","asset_type":"file","file_name":"说明书.pdf","mime_type":"application/pdf","created_at":"2026-04-16T00:00:00Z","updated_at":"2026-04-16T00:00:00Z"},{"id":"asset_book_1","workspace_id":"ws_demo","document_id":"page_root","asset_type":"file","file_name":"小说.epub","mime_type":"application/epub+zip","created_at":"2026-04-16T00:00:00Z","updated_at":"2026-04-16T00:00:00Z"}],"mindmap_assets":[{"id":"mind_1","workspace_id":"ws_demo","document_id":"page_root","asset_type":"mindmap","file_name":"头脑风暴.json","mime_type":"application/json","created_at":"2026-04-16T00:00:00Z","updated_at":"2026-04-16T00:00:00Z"}],"table_assets":[{"id":"table_1","workspace_id":"ws_demo","document_id":"page_root","asset_type":"luckysheet","file_name":"预算.luckysheet","mime_type":"application/json","created_at":"2026-04-16T00:00:00Z","updated_at":"2026-04-16T00:00:00Z"}],"mindmap_asset_children":{"mind_1":["asset_child_1"]}}}"#.into()),
mutation_fixtures_json: Some(r#"{"documents:createWithParentReference":{"ok":true,"id":"page_new","updated_at":"2026-04-17T00:00:00Z"},"documents:updateTitle":{"ok":true,"updated_at":"2026-04-17T00:00:00Z"},"documents:move":{"ok":true,"updated_at":"2026-04-17T00:00:00Z"}}"#.into()),
dev_user_id: "dev-user".into(),
dev_user_name: "开发用户".into(),
@@ -2281,6 +2670,7 @@ mod tests {
assert!(html.contains("\"allowRootPick\":true"));
assert!(html.contains("\"excludeIds\":[\"page_child\"]"));
assert!(html.contains("tree.pick.root"));
assert!(html.contains("__MNOTE_TREE_SHELL_OVERRIDE__"));
}
#[tokio::test]
@@ -2302,6 +2692,12 @@ mod tests {
let html = String::from_utf8(body.to_vec()).expect("utf8");
assert!(html.contains("filetree-doc-row"));
assert!(html.contains("\"mediaAssets\""));
assert!(html.contains("tree.filetree.selection.changed"));
assert!(html.contains("tree.filetree.internal-drop"));
assert!(html.contains("tree.filetree.external-drop"));
assert!(html.contains("\"rowKind\":\"asset_folder\""));
assert!(html.contains("\"resourceMeta\""));
assert!(html.contains("dragover"));
}
#[tokio::test]
+7
View File
@@ -0,0 +1,7 @@
Blocking waiting for file lock on package cache
Blocking waiting for file lock on package cache
Blocking waiting for file lock on package cache
Compiling mnote-web v0.1.0 (/mnt/Data1T/mnote/rust/crates/mnote-web)
Finished `dev` profile [unoptimized + debuginfo] target(s) in 3.69s
Running `target/debug/mnote-web`
2026-04-23T16:14:28.636856Z INFO mnote-web 最小骨架已启动 bind_addr=127.0.0.1:3104