4-10 树域 Rust 家族化
This commit is contained in:
@@ -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]
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user