refactor: split tree shell filetree dnd

This commit is contained in:
lix-2026
2026-05-26 03:42:03 +08:00
parent 72ffa5377d
commit 0f0e37ed54
5 changed files with 582 additions and 490 deletions
@@ -23,6 +23,10 @@ import {
import {
createTreeShellFileTreeMenuRuntime,
} from "./tree-shell-filetree-menu-runtime.js";
import {
createTreeShellFileTreeDndRuntime,
TREE_SHELL_FILETREE_DRAG_MIME as FILETREE_DRAG_MIME,
} from "./tree-shell-filetree-dnd-runtime.js";
import {
focusTreeShellPickerRowElement,
hydrateTreeShellInitialPageTree,
@@ -290,15 +294,10 @@ function startTreeShellRuntime() {
let fileTreeFocusedRowId = rendererFocusedRowId || (currentActiveDocumentId ? `doc:${currentActiveDocumentId}` : null);
let visibleFileTreeRowIds = [];
let draggingPageNodeId = "";
let activePageDropNodeId = null;
let draggingFileTreeRowIds = [];
let activeFileTreeDropRowId = null;
let activeFileTreeDropPosition = null;
let activeFileTreeRootDrop = false;
let fileTreeHoverExpandTimer = 0;
let activePageDropNodeId = null;
let fileTreeClipboard = { action: null, rowIds: [] };
let inlineRenameState = { mode: null, id: null, committing: false };
let activeFileTreePreflightElement = null;
let activeCursor = itemById.get(currentActiveDocumentId) || null;
while (activeCursor && activeCursor.parentNodeId && itemById.has(activeCursor.parentNodeId)) {
@@ -367,487 +366,42 @@ function startTreeShellRuntime() {
window.parent.postMessage(payload, targetOrigin);
};
const FILETREE_DRAG_MIME = "application/x-mnote-filetree-row-ids";
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",
nodeId: null,
documentId: null,
ownerDocumentId: null,
assetId: null,
};
}
return {
rowId: normalizeText(row.dataset.rowId) || null,
rowKind: normalizeText(row.dataset.rowKind, "document"),
nodeId: normalizeText(row.dataset.nodeId) || null,
documentId: normalizeText(row.dataset.documentId) || null,
ownerDocumentId: normalizeText(row.dataset.ownerDocumentId) || null,
assetId: normalizeText(row.dataset.assetId) || null,
};
};
const getFileTreeDropTargetFromEvent = (event) => {
const target = getFileTreeDropTargetFromElement(event.target);
if (!target.rowId) return { ...target, dropPosition: "inside" };
const row = event.target instanceof Element
? event.target.closest('.tree-row[data-shell-mode="filetree"]')
: null;
if (!(row instanceof HTMLElement)) return { ...target, dropPosition: "inside" };
const rect = row.getBoundingClientRect();
const offset = rect.height > 0 ? (event.clientY - rect.top) / rect.height : 0.5;
const item = fileTreeRowById.get(target.rowId);
if (offset < 0.25) return { ...target, dropPosition: "before" };
if (offset > 0.75) return { ...target, dropPosition: "after" };
return {
...target,
dropPosition: item && canExpandFileTreeRow(item) ? "inside" : "after",
};
};
const resolveLocalFileTreeParentId = (target) => {
if (!target || target.rowKind === "root") return null;
const targetItem = target.rowId ? fileTreeRowById.get(target.rowId) : null;
if (targetItem && targetItem.rowKind === "folder") {
return targetItem.nodeId;
}
if (targetItem && targetItem.parentNodeId) {
return targetItem.parentNodeId;
}
return null;
};
const isFileTreeDescendantOf = (candidate, ancestor) => {
let cursor = candidate;
while (cursor && cursor.parentNodeId) {
if (cursor.parentNodeId === ancestor.nodeId) return true;
cursor = itemById.get(cursor.parentNodeId) || null;
}
return false;
};
const validateFileTreeInternalDrop = (target, rowIds, copy) => {
const sourceRowIds = Array.isArray(rowIds) ? rowIds.filter(Boolean) : [];
if (sourceRowIds.length === 0) return { ok: false, reason: "empty" };
const targetItem = target?.rowId ? fileTreeRowById.get(target.rowId) : null;
if (targetItem && targetItem.capabilities.some((capability) =>
capability === "readonly" || capability === "readOnly" || capability === "permissionDenied"
)) {
return { ok: false, reason: "readonly" };
}
for (const rowId of sourceRowIds) {
const sourceItem = fileTreeRowById.get(rowId);
if (!sourceItem) return { ok: false, reason: "cross_workspace" };
if (targetItem && targetItem.rowId === sourceItem.rowId) {
return { ok: false, reason: "self" };
}
if (targetItem && sourceItem.rowKind === "folder" && isFileTreeDescendantOf(targetItem, sourceItem)) {
return { ok: false, reason: "descendant" };
}
if (copy && sourceItem.rowKind === "folder") {
return { ok: false, reason: "copy_folder_unsupported" };
}
}
return { ok: true, reason: "" };
};
const filterRedundantFileTreeRowIds = (rowIds) => {
const selected = new Set(Array.isArray(rowIds) ? rowIds.filter(Boolean) : []);
return Array.from(selected).filter((rowId) => {
const item = fileTreeRowById.get(rowId);
if (!item) return false;
let cursor = item;
while (cursor && cursor.parentNodeId) {
const parent = itemById.get(cursor.parentNodeId) || null;
if (parent && selected.has(parent.rowId)) {
return false;
}
cursor = parent;
}
return true;
});
};
const validateFileTreeWritableTarget = (target) => {
const targetItem = target?.rowId ? fileTreeRowById.get(target.rowId) : null;
if (
targetItem &&
targetItem.capabilities.some((capability) =>
capability === "readonly" ||
capability === "readOnly" ||
capability === "permissionDenied"
)
) {
return { ok: false, reason: "readonly", targetItem };
}
return { ok: true, reason: "", targetItem };
};
const getFileTreeRowLabel = (item) =>
item?.title || item?.rowId || item?.nodeId || "选中项";
const getFileTreeTargetLabel = (target) => {
if (!target || target.rowKind === "root" || !target.rowId) return "Explorer 根目录";
const item = fileTreeRowById.get(target.rowId);
if (!item) return target.rowId;
if (item.rowKind === "markdown" || item.rowKind === "document" || item.rowKind === "index") {
return `${item.title} 的父目录`;
}
return item.title;
};
const showFileTreePreflight = (preflight) =>
new Promise((resolve) => {
if (activeFileTreePreflightElement) {
activeFileTreePreflightElement.remove();
activeFileTreePreflightElement = null;
}
const backdrop = document.createElement("div");
backdrop.className = "tree-preflight-backdrop";
backdrop.dataset.testid = "tree-preflight";
backdrop.dataset.preflightKind = preflight.kind || "";
const dialog = document.createElement("div");
dialog.className = "tree-preflight-dialog";
dialog.setAttribute("role", "dialog");
dialog.setAttribute("aria-modal", "true");
const body = document.createElement("div");
body.className = "tree-preflight-body";
const title = document.createElement("h2");
title.className = "tree-preflight-title";
title.textContent = preflight.title || "操作预检";
body.appendChild(title);
const list = document.createElement("ul");
list.className = "tree-preflight-list";
(preflight.lines || []).forEach((line) => {
const item = document.createElement("li");
item.textContent = line;
list.appendChild(item);
});
body.appendChild(list);
dialog.appendChild(body);
const actions = document.createElement("div");
actions.className = "tree-preflight-actions";
const cancel = document.createElement("button");
cancel.type = "button";
cancel.dataset.role = "cancel";
cancel.textContent = "取消";
const confirm = document.createElement("button");
confirm.type = "button";
confirm.dataset.role = "confirm";
confirm.textContent = preflight.confirmLabel || "继续";
actions.appendChild(cancel);
actions.appendChild(confirm);
dialog.appendChild(actions);
backdrop.appendChild(dialog);
const finish = (accepted) => {
backdrop.remove();
activeFileTreePreflightElement = null;
document.removeEventListener("keydown", onKeyDown, true);
resolve(accepted);
};
const onKeyDown = (event) => {
if (event.key === "Escape") {
event.preventDefault();
finish(false);
}
};
cancel.addEventListener("click", () => finish(false));
confirm.addEventListener("click", () => finish(true));
document.addEventListener("keydown", onKeyDown, true);
document.body.appendChild(backdrop);
activeFileTreePreflightElement = backdrop;
cancel.focus({ preventScroll: true });
});
const runFileTreePreflight = async (kind, context = {}) => {
const sourceRowIds = Array.isArray(context.rowIds) ? context.rowIds.filter(Boolean) : [];
const sourceItems = sourceRowIds
.map((rowId) => fileTreeRowById.get(rowId))
.filter(Boolean);
const targetLabel = getFileTreeTargetLabel(context.target);
const sourceLabel =
sourceItems.length === 0
? "外部文件"
: sourceItems.map(getFileTreeRowLabel).join("、");
const fileNames = Array.isArray(context.files)
? context.files.map((file) => file.name || "未命名文件").filter(Boolean)
: [];
if (context.validation && context.validation.ok === false) {
if (context.validation.reason === "readonly") {
await showFileTreePreflight({
kind: "readonly",
title: "操作预检失败",
confirmLabel: "知道了",
lines: [
"目标: readonly",
"原因: 当前目标为只读或权限不足。",
"结果: 不会发送 execute。",
],
});
return false;
}
setLastAction(`预检拒绝: ${context.validation.reason}`, "error");
return false;
}
const writableTarget = validateFileTreeWritableTarget(context.target);
if (!writableTarget.ok) {
await showFileTreePreflight({
kind: "readonly",
title: "操作预检失败",
confirmLabel: "知道了",
lines: [
`目标: ${getFileTreeRowLabel(writableTarget.targetItem)}`,
"原因: 当前目标为只读或权限不足。",
"结果: 不会发送 execute。",
],
});
return false;
}
const preflight = (() => {
if (kind === "delete") {
return {
kind,
title: "删除预检",
confirmLabel: "删除",
lines: [
`目标: ${sourceLabel}`,
`影响: ${Math.max(1, sourceItems.length)} 个文件树节点`,
sourceKind === "local_folder"
? "删除后进入本地 .mnote/trash。"
: "删除将进入云端回收站。",
],
};
}
if (kind === "paste") {
return {
kind,
title: context.copy ? "粘贴复制预检" : "粘贴移动预检",
confirmLabel: "粘贴",
lines: [
`来源: ${sourceLabel}`,
`目标: ${targetLabel}`,
"命名冲突策略: 使用递增命名,不静默覆盖。",
],
};
}
if (kind === "dropFiles") {
return {
kind,
title: "外部拖入预检",
confirmLabel: sourceKind === "local_folder" ? "拖入" : "发送",
lines: [
`文件: ${fileNames.join("、") || "外部文件"}`,
`目标: ${targetLabel}`,
sourceKind === "local_folder"
? "目标可写时复制进本地文件夹;命名冲突使用递增命名。"
: "Convex 目标交给宿主上传到对象存储;命名冲突由上传 executor 处理。",
],
};
}
return {
kind: context.copy ? "copy" : "move",
title: context.copy ? "复制预检" : "移动预检",
confirmLabel: context.copy ? "复制" : "移动",
lines: [
`来源: ${sourceLabel}`,
`目标: ${targetLabel}`,
context.copy
? "命名冲突策略: 使用递增命名,不静默覆盖。"
: "会检查自拖自身、后代目标和跨 workspace 来源。",
],
};
})();
return await showFileTreePreflight(preflight);
};
const executeLocalFileTreeInternalDrop = async (target, rowIds, copy, trigger = "drop") => {
const validation = validateFileTreeInternalDrop(target, rowIds, copy);
if (!validation.ok) {
setLastAction(`已拒绝非法拖拽: ${validation.reason}`, "error");
return false;
}
const accepted = await runFileTreePreflight(trigger === "paste" ? "paste" : "move", {
target,
rowIds,
copy,
validation,
});
if (!accepted) return false;
const parentId = resolveLocalFileTreeParentId(target);
const sourceRowIds = filterRedundantFileTreeRowIds(rowIds);
if (sourceRowIds.length === 0) return false;
for (const rowId of sourceRowIds) {
const sourceItem = fileTreeRowById.get(rowId);
const documentId = getFileTreeRowDocumentId(sourceItem) || sourceItem?.nodeId || "";
if (!documentId) continue;
await sendCommand({
action: copy ? "copy" : "move",
workspaceId,
documentId,
parentId,
sortOrder: 0,
});
}
setStatus(copy ? "复制成功" : "移动成功");
setLastAction(copy ? "本地文件树复制完成" : "本地文件树移动完成");
scheduleRefresh();
return true;
};
const executeLocalFileTreeExternalDrop = async (target, files) => {
const droppedFiles = Array.from(files || []);
if (droppedFiles.length === 0) return false;
const accepted = await runFileTreePreflight("dropFiles", {
target,
files: droppedFiles,
});
if (!accepted) return false;
const parentId = resolveLocalFileTreeParentId(target);
const payload = await Promise.all(
droppedFiles.map(async (file) => ({
name: file.name || "dropped-file",
text: await file.text(),
})),
);
await sendCommand({
action: "dropFiles",
workspaceId,
parentId,
content: payload,
});
setStatus("外部文件拖入成功");
setLastAction(`已拖入 ${payload.length} 个本地文件`);
scheduleRefresh();
return true;
};
const clearFileTreeDropFeedback = () => {
if (fileTreeHoverExpandTimer) {
window.clearTimeout(fileTreeHoverExpandTimer);
fileTreeHoverExpandTimer = 0;
}
if (activeFileTreeDropRowId) {
const previousRow = appElement.querySelector(
`.tree-row[data-shell-mode="filetree"][data-row-id="${activeFileTreeDropRowId}"]`,
);
if (previousRow instanceof HTMLElement) {
previousRow.dataset.dropTarget = "false";
delete previousRow.dataset.dropPosition;
}
}
activeFileTreeDropRowId = null;
activeFileTreeDropPosition = 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;
const nextDropPosition = target?.dropPosition || "inside";
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";
delete previousRow.dataset.dropPosition;
}
}
if (nextRowId) {
const nextRow = appElement.querySelector(
`.tree-row[data-shell-mode="filetree"][data-row-id="${nextRowId}"]`,
);
if (nextRow instanceof HTMLElement) {
nextRow.dataset.dropTarget = "true";
nextRow.dataset.dropPosition = nextDropPosition;
}
activeFileTreeDropRowId = nextRowId;
activeFileTreeDropPosition = nextDropPosition;
activeFileTreeRootDrop = false;
if (fileTreeHoverExpandTimer) {
window.clearTimeout(fileTreeHoverExpandTimer);
fileTreeHoverExpandTimer = 0;
}
const targetItem = fileTreeRowById.get(nextRowId);
if (
nextDropPosition === "inside" &&
targetItem &&
canExpandFileTreeRow(targetItem) &&
!expanded.has(targetItem.nodeId)
) {
fileTreeHoverExpandTimer = window.setTimeout(() => {
expanded.add(targetItem.nodeId);
fileTreeHoverExpandTimer = 0;
renderTree();
}, 650);
}
appElement.querySelectorAll('.tree-root[data-drop-target="true"]').forEach((element) => {
if (element instanceof HTMLElement) {
element.dataset.dropTarget = "false";
}
});
return;
}
activeFileTreeDropRowId = null;
activeFileTreeDropPosition = 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 fileTreeDndRuntime = createTreeShellFileTreeDndRuntime({
appElement,
expanded,
sourceKind,
workspaceId,
canExpandFileTreeRow,
getFileTreeAnchorRowId: () => fileTreeAnchorRowId,
getFileTreeFocusedRowId: () => fileTreeFocusedRowId,
getFileTreeRowById: () => fileTreeRowById,
getItemById: () => itemById,
getSelectedFileTreeRowIds: () => selectedFileTreeRowIds,
getVisibleFileTreeRowIds: () => visibleFileTreeRowIds,
renderTree: () => renderTree(),
scheduleRefresh: (...args) => scheduleRefresh(...args),
sendCommand: (...args) => sendCommand(...args),
setLastAction,
setStatus,
});
const clearFileTreeDropFeedback = fileTreeDndRuntime.clearFileTreeDropFeedback;
const executeLocalFileTreeExternalDrop = fileTreeDndRuntime.executeLocalFileTreeExternalDrop;
const executeLocalFileTreeInternalDrop = fileTreeDndRuntime.executeLocalFileTreeInternalDrop;
const filterRedundantFileTreeRowIds = fileTreeDndRuntime.filterRedundantFileTreeRowIds;
const getActiveFileTreeDropPosition = fileTreeDndRuntime.getActiveFileTreeDropPosition;
const getActiveFileTreeDropRowId = fileTreeDndRuntime.getActiveFileTreeDropRowId;
const getActiveFileTreeRootDrop = fileTreeDndRuntime.getActiveFileTreeRootDrop;
const getFileTreeDropTargetFromEvent = fileTreeDndRuntime.getFileTreeDropTargetFromEvent;
const inferDefaultFileTreeDropDocumentId = fileTreeDndRuntime.inferDefaultFileTreeDropDocumentId;
const runFileTreePreflight = fileTreeDndRuntime.runFileTreePreflight;
const setFileTreeDropFeedback = fileTreeDndRuntime.setFileTreeDropFeedback;
const updateFileTreeDropFeedback = fileTreeDndRuntime.updateFileTreeDropFeedback;
const validateFileTreeInternalDrop = fileTreeDndRuntime.validateFileTreeInternalDrop;
const isExternalFileDrag = (event) => {
const types = event.dataTransfer?.types;
@@ -2760,7 +2314,7 @@ function startTreeShellRuntime() {
};
const bindFileTreeRootEvents = (root) => {
root.dataset.dropTarget = String(activeFileTreeRootDrop);
root.dataset.dropTarget = String(getActiveFileTreeRootDrop());
root.addEventListener("mousedown", (event) => {
if (event.target !== event.currentTarget) return;
clearFileTreeSelection();
@@ -2877,9 +2431,9 @@ function startTreeShellRuntime() {
row.dataset.objectKind = item.resourceMeta?.objectIdentity?.objectKind || "";
row.dataset.shellMode = "filetree";
row.dataset.selected = String(selectedFileTreeRowIds.has(item.rowId));
row.dataset.dropTarget = String(activeFileTreeDropRowId === item.rowId);
if (activeFileTreeDropRowId === item.rowId && activeFileTreeDropPosition) {
row.dataset.dropPosition = activeFileTreeDropPosition;
row.dataset.dropTarget = String(getActiveFileTreeDropRowId() === item.rowId);
if (getActiveFileTreeDropRowId() === item.rowId && getActiveFileTreeDropPosition()) {
row.dataset.dropPosition = getActiveFileTreeDropPosition();
}
row.tabIndex = 0;
row.setAttribute("role", "treeitem");
@@ -3243,7 +2797,7 @@ function startTreeShellRuntime() {
const fileRoot = document.createElement("div");
fileRoot.className = "tree-root";
fileRoot.setAttribute("role", "tree");
fileRoot.dataset.dropTarget = String(activeFileTreeRootDrop);
fileRoot.dataset.dropTarget = String(getActiveFileTreeRootDrop());
fileRoot.addEventListener("mousedown", (event) => {
if (event.target !== event.currentTarget) return;
clearFileTreeSelection();
@@ -3345,9 +2899,9 @@ function startTreeShellRuntime() {
row.dataset.objectKind = item.resourceMeta?.objectIdentity?.objectKind || "";
row.dataset.shellMode = "filetree";
row.dataset.selected = String(selectedFileTreeRowIds.has(item.rowId));
row.dataset.dropTarget = String(activeFileTreeDropRowId === item.rowId);
if (activeFileTreeDropRowId === item.rowId && activeFileTreeDropPosition) {
row.dataset.dropPosition = activeFileTreeDropPosition;
row.dataset.dropTarget = String(getActiveFileTreeDropRowId() === item.rowId);
if (getActiveFileTreeDropRowId() === item.rowId && getActiveFileTreeDropPosition()) {
row.dataset.dropPosition = getActiveFileTreeDropPosition();
}
row.setAttribute(
"data-testid",