// MNote debug /tree shell filetree drag/drop and preflight runtime. import { getFileTreeRowDocumentId, } from "./tree-shell-filetree-runtime.js"; import { normalizeTreeShellText as normalizeText } from "./tree-shell-state-runtime.js"; export const TREE_SHELL_FILETREE_DRAG_MIME = "application/x-mnote-filetree-row-ids"; export function createTreeShellFileTreeDndRuntime(context) { let activeFileTreeDropRowId = null; let activeFileTreeDropPosition = null; let activeFileTreeRootDrop = false; let fileTreeHoverExpandTimer = 0; let activeFileTreePreflightElement = null; 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 = context.getFileTreeRowById().get(target.rowId); if (offset < 0.25) return { ...target, dropPosition: "before" }; if (offset > 0.75) return { ...target, dropPosition: "after" }; return { ...target, dropPosition: item && context.canExpandFileTreeRow(item) ? "inside" : "after", }; }; const resolveLocalFileTreeParentId = (target) => { if (!target || target.rowKind === "root") return null; const targetItem = target.rowId ? context.getFileTreeRowById().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 = context.getItemById().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 fileTreeRowById = context.getFileTreeRowById(); 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) : []); const fileTreeRowById = context.getFileTreeRowById(); const itemById = context.getItemById(); 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 ? context.getFileTreeRowById().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 = context.getFileTreeRowById().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, dropContext = {}) => { const sourceRowIds = Array.isArray(dropContext.rowIds) ? dropContext.rowIds.filter(Boolean) : []; const sourceItems = sourceRowIds .map((rowId) => context.getFileTreeRowById().get(rowId)) .filter(Boolean); const targetLabel = getFileTreeTargetLabel(dropContext.target); const sourceLabel = sourceItems.length === 0 ? "外部文件" : sourceItems.map(getFileTreeRowLabel).join("、"); const fileNames = Array.isArray(dropContext.files) ? dropContext.files.map((file) => file.name || "未命名文件").filter(Boolean) : []; if (dropContext.validation && dropContext.validation.ok === false) { if (dropContext.validation.reason === "readonly") { await showFileTreePreflight({ kind: "readonly", title: "操作预检失败", confirmLabel: "知道了", lines: [ "目标: readonly", "原因: 当前目标为只读或权限不足。", "结果: 不会发送 execute。", ], }); return false; } context.setLastAction(`预检拒绝: ${dropContext.validation.reason}`, "error"); return false; } const writableTarget = validateFileTreeWritableTarget(dropContext.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)} 个文件树节点`, context.sourceKind === "local_folder" ? "删除后进入本地 .mnote/trash。" : "删除将进入云端回收站。", ], }; } if (kind === "paste") { return { kind, title: dropContext.copy ? "粘贴复制预检" : "粘贴移动预检", confirmLabel: "粘贴", lines: [ `来源: ${sourceLabel}`, `目标: ${targetLabel}`, "命名冲突策略: 使用递增命名,不静默覆盖。", ], }; } if (kind === "dropFiles") { return { kind, title: "外部拖入预检", confirmLabel: context.sourceKind === "local_folder" ? "拖入" : "发送", lines: [ `文件: ${fileNames.join("、") || "外部文件"}`, `目标: ${targetLabel}`, context.sourceKind === "local_folder" ? "目标可写时复制进本地文件夹;命名冲突使用递增命名。" : "Convex 目标交给宿主上传到对象存储;命名冲突由上传 executor 处理。", ], }; } return { kind: dropContext.copy ? "copy" : "move", title: dropContext.copy ? "复制预检" : "移动预检", confirmLabel: dropContext.copy ? "复制" : "移动", lines: [ `来源: ${sourceLabel}`, `目标: ${targetLabel}`, dropContext.copy ? "命名冲突策略: 使用递增命名,不静默覆盖。" : "会检查自拖自身、后代目标和跨 workspace 来源。", ], }; })(); return await showFileTreePreflight(preflight); }; const executeLocalFileTreeInternalDrop = async (target, rowIds, copy, trigger = "drop") => { const validation = validateFileTreeInternalDrop(target, rowIds, copy); if (!validation.ok) { context.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 = context.getFileTreeRowById().get(rowId); const documentId = getFileTreeRowDocumentId(sourceItem) || sourceItem?.nodeId || ""; if (!documentId) continue; await context.sendCommand({ action: copy ? "copy" : "move", workspaceId: context.workspaceId, documentId, parentId, sortOrder: 0, }); } context.setStatus(copy ? "复制成功" : "移动成功"); context.setLastAction(copy ? "本地文件树复制完成" : "本地文件树移动完成"); context.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 context.sendCommand({ action: "dropFiles", workspaceId: context.workspaceId, parentId, content: payload, }); context.setStatus("外部文件拖入成功"); context.setLastAction(`已拖入 ${payload.length} 个本地文件`); context.scheduleRefresh(); return true; }; const clearFileTreeDropFeedback = () => { if (fileTreeHoverExpandTimer) { window.clearTimeout(fileTreeHoverExpandTimer); fileTreeHoverExpandTimer = 0; } if (activeFileTreeDropRowId) { const previousRow = context.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; context.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 = context.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 = context.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 = context.getFileTreeRowById().get(nextRowId); if ( nextDropPosition === "inside" && targetItem && context.canExpandFileTreeRow(targetItem) && !context.expanded.has(targetItem.nodeId) ) { fileTreeHoverExpandTimer = window.setTimeout(() => { context.expanded.add(targetItem.nodeId); fileTreeHoverExpandTimer = 0; context.renderTree(); }, 650); } context.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 = context.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 = [ context.getFileTreeFocusedRowId(), context.getFileTreeAnchorRowId(), ...Array.from(context.getSelectedFileTreeRowIds()), ].filter(Boolean); const fileTreeRowById = context.getFileTreeRowById(); for (const rowId of candidateRowIds) { if (!rowId) continue; const item = fileTreeRowById.get(rowId); const documentId = getFileTreeRowDocumentId(item); if (documentId) { return documentId; } } const firstDocRowId = context.getVisibleFileTreeRowIds().find((rowId) => rowId.startsWith("doc:")); const firstDocItem = firstDocRowId ? fileTreeRowById.get(firstDocRowId) : null; return getFileTreeRowDocumentId(firstDocItem) || null; }; return { clearFileTreeDropFeedback, executeLocalFileTreeExternalDrop, executeLocalFileTreeInternalDrop, filterRedundantFileTreeRowIds, getActiveFileTreeDropPosition: () => activeFileTreeDropPosition, getActiveFileTreeDropRowId: () => activeFileTreeDropRowId, getActiveFileTreeRootDrop: () => activeFileTreeRootDrop, getFileTreeDropTargetFromEvent, getFileTreeDropTargetFromElement, inferDefaultFileTreeDropDocumentId, runFileTreePreflight, setFileTreeDropFeedback, updateFileTreeDropFeedback, validateFileTreeInternalDrop, }; }