refactor: split tree shell filetree dnd
This commit is contained in:
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -23,6 +23,10 @@ import {
|
|||||||
import {
|
import {
|
||||||
createTreeShellFileTreeMenuRuntime,
|
createTreeShellFileTreeMenuRuntime,
|
||||||
} from "./tree-shell-filetree-menu-runtime.js";
|
} 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 {
|
import {
|
||||||
focusTreeShellPickerRowElement,
|
focusTreeShellPickerRowElement,
|
||||||
hydrateTreeShellInitialPageTree,
|
hydrateTreeShellInitialPageTree,
|
||||||
@@ -290,15 +294,10 @@ function startTreeShellRuntime() {
|
|||||||
let fileTreeFocusedRowId = rendererFocusedRowId || (currentActiveDocumentId ? `doc:${currentActiveDocumentId}` : null);
|
let fileTreeFocusedRowId = rendererFocusedRowId || (currentActiveDocumentId ? `doc:${currentActiveDocumentId}` : null);
|
||||||
let visibleFileTreeRowIds = [];
|
let visibleFileTreeRowIds = [];
|
||||||
let draggingPageNodeId = "";
|
let draggingPageNodeId = "";
|
||||||
let activePageDropNodeId = null;
|
|
||||||
let draggingFileTreeRowIds = [];
|
let draggingFileTreeRowIds = [];
|
||||||
let activeFileTreeDropRowId = null;
|
let activePageDropNodeId = null;
|
||||||
let activeFileTreeDropPosition = null;
|
|
||||||
let activeFileTreeRootDrop = false;
|
|
||||||
let fileTreeHoverExpandTimer = 0;
|
|
||||||
let fileTreeClipboard = { action: null, rowIds: [] };
|
let fileTreeClipboard = { action: null, rowIds: [] };
|
||||||
let inlineRenameState = { mode: null, id: null, committing: false };
|
let inlineRenameState = { mode: null, id: null, committing: false };
|
||||||
let activeFileTreePreflightElement = null;
|
|
||||||
|
|
||||||
let activeCursor = itemById.get(currentActiveDocumentId) || null;
|
let activeCursor = itemById.get(currentActiveDocumentId) || null;
|
||||||
while (activeCursor && activeCursor.parentNodeId && itemById.has(activeCursor.parentNodeId)) {
|
while (activeCursor && activeCursor.parentNodeId && itemById.has(activeCursor.parentNodeId)) {
|
||||||
@@ -367,487 +366,42 @@ function startTreeShellRuntime() {
|
|||||||
window.parent.postMessage(payload, targetOrigin);
|
window.parent.postMessage(payload, targetOrigin);
|
||||||
};
|
};
|
||||||
|
|
||||||
const FILETREE_DRAG_MIME = "application/x-mnote-filetree-row-ids";
|
|
||||||
|
|
||||||
const canExpandFileTreeRow = (item) => {
|
const canExpandFileTreeRow = (item) => {
|
||||||
if (!item) return false;
|
if (!item) return false;
|
||||||
return item.childCount > 0 || item.capabilities.includes("expand");
|
return item.childCount > 0 || item.capabilities.includes("expand");
|
||||||
};
|
};
|
||||||
|
|
||||||
const getFileTreeDropTargetFromElement = (element) => {
|
const fileTreeDndRuntime = createTreeShellFileTreeDndRuntime({
|
||||||
const row = element instanceof Element
|
appElement,
|
||||||
? element.closest('.tree-row[data-shell-mode="filetree"]')
|
expanded,
|
||||||
: null;
|
sourceKind,
|
||||||
if (!(row instanceof HTMLElement)) {
|
workspaceId,
|
||||||
return {
|
canExpandFileTreeRow,
|
||||||
rowId: null,
|
getFileTreeAnchorRowId: () => fileTreeAnchorRowId,
|
||||||
rowKind: "root",
|
getFileTreeFocusedRowId: () => fileTreeFocusedRowId,
|
||||||
nodeId: null,
|
getFileTreeRowById: () => fileTreeRowById,
|
||||||
documentId: null,
|
getItemById: () => itemById,
|
||||||
ownerDocumentId: null,
|
getSelectedFileTreeRowIds: () => selectedFileTreeRowIds,
|
||||||
assetId: null,
|
getVisibleFileTreeRowIds: () => visibleFileTreeRowIds,
|
||||||
};
|
renderTree: () => renderTree(),
|
||||||
}
|
scheduleRefresh: (...args) => scheduleRefresh(...args),
|
||||||
return {
|
sendCommand: (...args) => sendCommand(...args),
|
||||||
rowId: normalizeText(row.dataset.rowId) || null,
|
setLastAction,
|
||||||
rowKind: normalizeText(row.dataset.rowKind, "document"),
|
setStatus,
|
||||||
nodeId: normalizeText(row.dataset.nodeId) || null,
|
});
|
||||||
documentId: normalizeText(row.dataset.documentId) || null,
|
const clearFileTreeDropFeedback = fileTreeDndRuntime.clearFileTreeDropFeedback;
|
||||||
ownerDocumentId: normalizeText(row.dataset.ownerDocumentId) || null,
|
const executeLocalFileTreeExternalDrop = fileTreeDndRuntime.executeLocalFileTreeExternalDrop;
|
||||||
assetId: normalizeText(row.dataset.assetId) || null,
|
const executeLocalFileTreeInternalDrop = fileTreeDndRuntime.executeLocalFileTreeInternalDrop;
|
||||||
};
|
const filterRedundantFileTreeRowIds = fileTreeDndRuntime.filterRedundantFileTreeRowIds;
|
||||||
};
|
const getActiveFileTreeDropPosition = fileTreeDndRuntime.getActiveFileTreeDropPosition;
|
||||||
|
const getActiveFileTreeDropRowId = fileTreeDndRuntime.getActiveFileTreeDropRowId;
|
||||||
const getFileTreeDropTargetFromEvent = (event) => {
|
const getActiveFileTreeRootDrop = fileTreeDndRuntime.getActiveFileTreeRootDrop;
|
||||||
const target = getFileTreeDropTargetFromElement(event.target);
|
const getFileTreeDropTargetFromEvent = fileTreeDndRuntime.getFileTreeDropTargetFromEvent;
|
||||||
if (!target.rowId) return { ...target, dropPosition: "inside" };
|
const inferDefaultFileTreeDropDocumentId = fileTreeDndRuntime.inferDefaultFileTreeDropDocumentId;
|
||||||
const row = event.target instanceof Element
|
const runFileTreePreflight = fileTreeDndRuntime.runFileTreePreflight;
|
||||||
? event.target.closest('.tree-row[data-shell-mode="filetree"]')
|
const setFileTreeDropFeedback = fileTreeDndRuntime.setFileTreeDropFeedback;
|
||||||
: null;
|
const updateFileTreeDropFeedback = fileTreeDndRuntime.updateFileTreeDropFeedback;
|
||||||
if (!(row instanceof HTMLElement)) return { ...target, dropPosition: "inside" };
|
const validateFileTreeInternalDrop = fileTreeDndRuntime.validateFileTreeInternalDrop;
|
||||||
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 isExternalFileDrag = (event) => {
|
const isExternalFileDrag = (event) => {
|
||||||
const types = event.dataTransfer?.types;
|
const types = event.dataTransfer?.types;
|
||||||
@@ -2760,7 +2314,7 @@ function startTreeShellRuntime() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const bindFileTreeRootEvents = (root) => {
|
const bindFileTreeRootEvents = (root) => {
|
||||||
root.dataset.dropTarget = String(activeFileTreeRootDrop);
|
root.dataset.dropTarget = String(getActiveFileTreeRootDrop());
|
||||||
root.addEventListener("mousedown", (event) => {
|
root.addEventListener("mousedown", (event) => {
|
||||||
if (event.target !== event.currentTarget) return;
|
if (event.target !== event.currentTarget) return;
|
||||||
clearFileTreeSelection();
|
clearFileTreeSelection();
|
||||||
@@ -2877,9 +2431,9 @@ function startTreeShellRuntime() {
|
|||||||
row.dataset.objectKind = item.resourceMeta?.objectIdentity?.objectKind || "";
|
row.dataset.objectKind = item.resourceMeta?.objectIdentity?.objectKind || "";
|
||||||
row.dataset.shellMode = "filetree";
|
row.dataset.shellMode = "filetree";
|
||||||
row.dataset.selected = String(selectedFileTreeRowIds.has(item.rowId));
|
row.dataset.selected = String(selectedFileTreeRowIds.has(item.rowId));
|
||||||
row.dataset.dropTarget = String(activeFileTreeDropRowId === item.rowId);
|
row.dataset.dropTarget = String(getActiveFileTreeDropRowId() === item.rowId);
|
||||||
if (activeFileTreeDropRowId === item.rowId && activeFileTreeDropPosition) {
|
if (getActiveFileTreeDropRowId() === item.rowId && getActiveFileTreeDropPosition()) {
|
||||||
row.dataset.dropPosition = activeFileTreeDropPosition;
|
row.dataset.dropPosition = getActiveFileTreeDropPosition();
|
||||||
}
|
}
|
||||||
row.tabIndex = 0;
|
row.tabIndex = 0;
|
||||||
row.setAttribute("role", "treeitem");
|
row.setAttribute("role", "treeitem");
|
||||||
@@ -3243,7 +2797,7 @@ function startTreeShellRuntime() {
|
|||||||
const fileRoot = document.createElement("div");
|
const fileRoot = document.createElement("div");
|
||||||
fileRoot.className = "tree-root";
|
fileRoot.className = "tree-root";
|
||||||
fileRoot.setAttribute("role", "tree");
|
fileRoot.setAttribute("role", "tree");
|
||||||
fileRoot.dataset.dropTarget = String(activeFileTreeRootDrop);
|
fileRoot.dataset.dropTarget = String(getActiveFileTreeRootDrop());
|
||||||
fileRoot.addEventListener("mousedown", (event) => {
|
fileRoot.addEventListener("mousedown", (event) => {
|
||||||
if (event.target !== event.currentTarget) return;
|
if (event.target !== event.currentTarget) return;
|
||||||
clearFileTreeSelection();
|
clearFileTreeSelection();
|
||||||
@@ -3345,9 +2899,9 @@ function startTreeShellRuntime() {
|
|||||||
row.dataset.objectKind = item.resourceMeta?.objectIdentity?.objectKind || "";
|
row.dataset.objectKind = item.resourceMeta?.objectIdentity?.objectKind || "";
|
||||||
row.dataset.shellMode = "filetree";
|
row.dataset.shellMode = "filetree";
|
||||||
row.dataset.selected = String(selectedFileTreeRowIds.has(item.rowId));
|
row.dataset.selected = String(selectedFileTreeRowIds.has(item.rowId));
|
||||||
row.dataset.dropTarget = String(activeFileTreeDropRowId === item.rowId);
|
row.dataset.dropTarget = String(getActiveFileTreeDropRowId() === item.rowId);
|
||||||
if (activeFileTreeDropRowId === item.rowId && activeFileTreeDropPosition) {
|
if (getActiveFileTreeDropRowId() === item.rowId && getActiveFileTreeDropPosition()) {
|
||||||
row.dataset.dropPosition = activeFileTreeDropPosition;
|
row.dataset.dropPosition = getActiveFileTreeDropPosition();
|
||||||
}
|
}
|
||||||
row.setAttribute(
|
row.setAttribute(
|
||||||
"data-testid",
|
"data-testid",
|
||||||
|
|||||||
@@ -186,6 +186,10 @@ pub fn build_router(state: AppState) -> Router {
|
|||||||
"/api/mnote-browser-runtime/tree-shell-filetree-menu-runtime.js",
|
"/api/mnote-browser-runtime/tree-shell-filetree-menu-runtime.js",
|
||||||
get(web_shell::tree_shell_filetree_menu_runtime_asset),
|
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(
|
.route(
|
||||||
"/api/mnote-browser-runtime/tree-shell-picker-runtime.js",
|
"/api/mnote-browser-runtime/tree-shell-picker-runtime.js",
|
||||||
get(web_shell::tree_shell_picker_runtime_asset),
|
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-icons-runtime.js",
|
||||||
"/api/mnote-browser-runtime/tree-shell-filetree-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-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-picker-runtime.js",
|
||||||
"/api/mnote-browser-runtime/tree-shell-dom-runtime.js",
|
"/api/mnote-browser-runtime/tree-shell-dom-runtime.js",
|
||||||
"/api/mnote-browser-runtime/document-conflict-panel-runtime.js",
|
"/api/mnote-browser-runtime/document-conflict-panel-runtime.js",
|
||||||
|
|||||||
@@ -2397,6 +2397,8 @@ mod tests {
|
|||||||
include_str!("../../browser/tree-shell-filetree-runtime.js");
|
include_str!("../../browser/tree-shell-filetree-runtime.js");
|
||||||
const TREE_SHELL_FILETREE_MENU_RUNTIME_JS: &str =
|
const TREE_SHELL_FILETREE_MENU_RUNTIME_JS: &str =
|
||||||
include_str!("../../browser/tree-shell-filetree-menu-runtime.js");
|
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::{
|
use super::{
|
||||||
collect_filetree_render_rows, create_command_wire, TreeCommandEnvelopeContext,
|
collect_filetree_render_rows, create_command_wire, TreeCommandEnvelopeContext,
|
||||||
@@ -2679,11 +2681,14 @@ mod tests {
|
|||||||
assert!(html.contains("\"resourceMeta\""));
|
assert!(html.contains("\"resourceMeta\""));
|
||||||
assert!(TREE_SHELL_RUNTIME_JS.contains("tree-shell-filetree-runtime.js"));
|
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-menu-runtime.js"));
|
||||||
|
assert!(TREE_SHELL_RUNTIME_JS.contains("tree-shell-filetree-dnd-runtime.js"));
|
||||||
assert!(
|
assert!(
|
||||||
TREE_SHELL_FILETREE_RUNTIME_JS.contains("function getFileTreeRowOwnerDocumentId(item)")
|
TREE_SHELL_FILETREE_RUNTIME_JS.contains("function getFileTreeRowOwnerDocumentId(item)")
|
||||||
);
|
);
|
||||||
assert!(TREE_SHELL_FILETREE_MENU_RUNTIME_JS
|
assert!(TREE_SHELL_FILETREE_MENU_RUNTIME_JS
|
||||||
.contains("function buildFileTreeMenuTarget(context"));
|
.contains("function buildFileTreeMenuTarget(context"));
|
||||||
|
assert!(TREE_SHELL_FILETREE_DND_RUNTIME_JS
|
||||||
|
.contains("function createTreeShellFileTreeDndRuntime(context)"));
|
||||||
assert!(TREE_SHELL_RUNTIME_JS
|
assert!(TREE_SHELL_RUNTIME_JS
|
||||||
.contains("row.dataset.ownerDocumentId = ownerDocumentId || \"\";"));
|
.contains("row.dataset.ownerDocumentId = ownerDocumentId || \"\";"));
|
||||||
assert!(
|
assert!(
|
||||||
|
|||||||
@@ -1008,6 +1008,20 @@ pub async fn tree_shell_filetree_menu_runtime_asset() -> Response {
|
|||||||
.unwrap_or_else(|_| Response::new(Body::empty()))
|
.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 {
|
pub async fn tree_shell_picker_runtime_asset() -> Response {
|
||||||
const JS: &str = include_str!("../../browser/tree-shell-picker-runtime.js");
|
const JS: &str = include_str!("../../browser/tree-shell-picker-runtime.js");
|
||||||
Response::builder()
|
Response::builder()
|
||||||
|
|||||||
Reference in New Issue
Block a user