diff --git a/rust/crates/mnote-web/browser/tree-shell-filetree-dnd-runtime.js b/rust/crates/mnote-web/browser/tree-shell-filetree-dnd-runtime.js new file mode 100644 index 00000000..89b08184 --- /dev/null +++ b/rust/crates/mnote-web/browser/tree-shell-filetree-dnd-runtime.js @@ -0,0 +1,514 @@ +// 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, + }; +} diff --git a/rust/crates/mnote-web/browser/tree-shell-runtime.js b/rust/crates/mnote-web/browser/tree-shell-runtime.js index 05aaeea5..440193e6 100644 --- a/rust/crates/mnote-web/browser/tree-shell-runtime.js +++ b/rust/crates/mnote-web/browser/tree-shell-runtime.js @@ -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", diff --git a/rust/crates/mnote-web/src/routes/mod.rs b/rust/crates/mnote-web/src/routes/mod.rs index d7b63da1..c4535479 100644 --- a/rust/crates/mnote-web/src/routes/mod.rs +++ b/rust/crates/mnote-web/src/routes/mod.rs @@ -186,6 +186,10 @@ pub fn build_router(state: AppState) -> Router { "/api/mnote-browser-runtime/tree-shell-filetree-menu-runtime.js", get(web_shell::tree_shell_filetree_menu_runtime_asset), ) + .route( + "/api/mnote-browser-runtime/tree-shell-filetree-dnd-runtime.js", + get(web_shell::tree_shell_filetree_dnd_runtime_asset), + ) .route( "/api/mnote-browser-runtime/tree-shell-picker-runtime.js", get(web_shell::tree_shell_picker_runtime_asset), @@ -650,6 +654,7 @@ mod tests { "/api/mnote-browser-runtime/tree-shell-icons-runtime.js", "/api/mnote-browser-runtime/tree-shell-filetree-runtime.js", "/api/mnote-browser-runtime/tree-shell-filetree-menu-runtime.js", + "/api/mnote-browser-runtime/tree-shell-filetree-dnd-runtime.js", "/api/mnote-browser-runtime/tree-shell-picker-runtime.js", "/api/mnote-browser-runtime/tree-shell-dom-runtime.js", "/api/mnote-browser-runtime/document-conflict-panel-runtime.js", diff --git a/rust/crates/mnote-web/src/routes/tree.rs b/rust/crates/mnote-web/src/routes/tree.rs index c9497eef..d12d433c 100644 --- a/rust/crates/mnote-web/src/routes/tree.rs +++ b/rust/crates/mnote-web/src/routes/tree.rs @@ -2397,6 +2397,8 @@ mod tests { include_str!("../../browser/tree-shell-filetree-runtime.js"); const TREE_SHELL_FILETREE_MENU_RUNTIME_JS: &str = include_str!("../../browser/tree-shell-filetree-menu-runtime.js"); + const TREE_SHELL_FILETREE_DND_RUNTIME_JS: &str = + include_str!("../../browser/tree-shell-filetree-dnd-runtime.js"); use super::{ collect_filetree_render_rows, create_command_wire, TreeCommandEnvelopeContext, @@ -2679,11 +2681,14 @@ mod tests { assert!(html.contains("\"resourceMeta\"")); assert!(TREE_SHELL_RUNTIME_JS.contains("tree-shell-filetree-runtime.js")); assert!(TREE_SHELL_RUNTIME_JS.contains("tree-shell-filetree-menu-runtime.js")); + assert!(TREE_SHELL_RUNTIME_JS.contains("tree-shell-filetree-dnd-runtime.js")); assert!( TREE_SHELL_FILETREE_RUNTIME_JS.contains("function getFileTreeRowOwnerDocumentId(item)") ); assert!(TREE_SHELL_FILETREE_MENU_RUNTIME_JS .contains("function buildFileTreeMenuTarget(context")); + assert!(TREE_SHELL_FILETREE_DND_RUNTIME_JS + .contains("function createTreeShellFileTreeDndRuntime(context)")); assert!(TREE_SHELL_RUNTIME_JS .contains("row.dataset.ownerDocumentId = ownerDocumentId || \"\";")); assert!( diff --git a/rust/crates/mnote-web/src/routes/web_shell.rs b/rust/crates/mnote-web/src/routes/web_shell.rs index 6c8d8f8b..cfe3b282 100644 --- a/rust/crates/mnote-web/src/routes/web_shell.rs +++ b/rust/crates/mnote-web/src/routes/web_shell.rs @@ -1008,6 +1008,20 @@ pub async fn tree_shell_filetree_menu_runtime_asset() -> Response { .unwrap_or_else(|_| Response::new(Body::empty())) } +pub async fn tree_shell_filetree_dnd_runtime_asset() -> Response { + const JS: &str = include_str!("../../browser/tree-shell-filetree-dnd-runtime.js"); + Response::builder() + .status(StatusCode::OK) + .header( + header::CONTENT_TYPE, + "application/javascript; charset=utf-8", + ) + .header(header::CACHE_CONTROL, "no-store") + .header(HEADER_MNOTE_WEB_OWNER, "mnote-web") + .body(Body::from(JS)) + .unwrap_or_else(|_| Response::new(Body::empty())) +} + pub async fn tree_shell_picker_runtime_asset() -> Response { const JS: &str = include_str!("../../browser/tree-shell-picker-runtime.js"); Response::builder()