diff --git a/design/file-tree-vscode-checklist.md b/design/file-tree-vscode-checklist.md new file mode 100644 index 00000000..ca77c552 --- /dev/null +++ b/design/file-tree-vscode-checklist.md @@ -0,0 +1,159 @@ +# 文件树(类 VSCode Explorer)实现顺序 + 对应文件 + 必测用例 + +> 目标:在「文件模式」页面树中实现接近 VSCode Explorer 的体验:点击行高亮选择(支持多选/范围选)、`Ctrl+C/Ctrl+V` 复制粘贴(真实文件/文件夹复制)、拖拽移动/按修饰键拖拽复制。 +> +> 非目标:键盘导航(↑↓←→)与编辑器正文内复制粘贴行为。 + +## 0. 参考代码位置(VSCode) + +说明:VSCode Explorer 不是独立 React 组件,依赖 Workbench 服务体系;这里用于“行为/结构参考”,不直接搬运实现。 + +- [x] VSCode 参考仓库:`cankao/vscode` +- [x] 关键入口与文件(详见 `design/vscodetree`): + - [x] `cankao/vscode/src/vs/workbench/contrib/files/browser/files.contribution.ts` + - [x] `cankao/vscode/src/vs/workbench/contrib/files/browser/views/explorerView.ts` + - [x] `cankao/vscode/src/vs/workbench/contrib/files/common/explorerModel.ts` + - [x] `cankao/vscode/src/vs/base/common/resourceTree.ts` + - [x] `cankao/vscode/src/vs/workbench/contrib/files/browser/fileCommands.ts` + +## 1. MNOTE 当前实现位置(改造落点) + +- [x] 文件树(文件模式):`wolai-frontend/src/components/sidebar/file-tree.tsx` +- [x] 侧边栏状态/数据/选择/复制/拖拽集成:`wolai-frontend/src/components/sidebar/sidebar.tsx` +- [x] 文档树扁平化:`wolai-frontend/src/lib/sidebar-tree.ts`(已有 `flattenDocumentTree`) +- [x] 复制页面(保留单页复制):`wolai-frontend/src/app/api/documents/duplicate/route.ts` +- [x] 递归复制入口:`wolai-frontend/src/app/api/documents/copy-tree/route.ts` +- [x] 附件 copy/move/delete/rename:`wolai-frontend/src/app/api/media/batch/route.ts` + +## 2. 实现顺序(建议小步提交,每步能回归) + +### Step 0:建立“可见行模型”(TreeRow)——多选/复制/拖拽的共同基础 + +**要做** +- [x] 抽象 `visibleRows: FileTreeRow[]`(按展开状态 flatten:doc + index.md + assets + 子 doc) +- [x] `rowId` 全局唯一:`doc:` / `index:` / `asset:` + +**新增/改造文件** +- [x] `wolai-frontend/src/lib/file-tree/types.ts` +- [x] `wolai-frontend/src/lib/file-tree/rows.ts` + +**必测用例(Vitest:纯函数)** +- [x] `buildVisibleRows` 顺序稳定(展开/折叠后符合预期) +- [x] `rowId` 唯一且可逆解析 + +--- + +### Step 1:选择模型(Selection Model)——对齐 VSCode 的点击/多选/范围选/右键语义 + +**要做** +- [x] 去除复选框,改为“点击行高亮选择” +- [x] 多选覆盖 doc/index.md/asset 同一集合 +- [x] 状态字段: + - [x] `selectedRowIds: Set` + - [x] `anchorRowId: string | null`(Shift 范围起点) + - [x] `focusedRowId: string | null`(最后交互行,用于粘贴目标推断) + +**新增文件** +- [x] `wolai-frontend/src/lib/file-tree/selection.ts`(把交互写成纯函数,便于测) + +**必测用例(Vitest:纯函数)** +- [x] 单击:清空并仅选中当前;更新 anchor/focus +- [x] Ctrl/Cmd+单击:切换选中;不清空其他 +- [x] Shift+单击:按 `visibleRows` 做区间选择(覆盖式) +- [x] 右键:未选中项右键 → 先切为单选再弹菜单;已选中项 → 保持多选集合 +- [x] 空白处单击清空选择 + +--- + +### Step 2:`Ctrl+C / Ctrl+V`(仅文件树区域生效) + +**要做** +- [x] 只在“文件树区域激活”时响应 `Ctrl+C/V`,不影响编辑器输入框/正文 +- [x] 复制时写入自定义剪贴板 payload(同时提供内存 fallback) +- [x] 粘贴目标推断: + - [x] focused 为 doc → 贴入其下 + - [x] focused 为 index/asset → 贴入其所属 doc + - [x] 无 focused → fallback activeDocId + +**新增文件** +- [x] `wolai-frontend/src/lib/file-tree/clipboard.ts` + +**必测用例(Vitest)** +- [x] payload 版本/字段校验(`type/version/action/rowIds`) +- [x] 目标推断 3 分支覆盖(doc / index|asset / null) +- [x] 在 `input/textarea/contenteditable` 内不拦截 `Ctrl+C/V` + +--- + +### Step 3:“真实文件/文件夹复制”后端能力(对齐 VSCode 语义) + +**要做** +- [x] doc(文件夹/页面)复制:支持递归复制其内容(子页面 + 正文 + 附件) +- [x] asset(真实文件)复制:复制存储对象并创建新记录(`media/batch copy` + 重名策略) +- [x] 命名冲突:自动生成不冲突名称(文件夹与文件分别处理) +- [x] 虚拟附件(mindmap.json 等)不参与复制/拖拽(仅保留打开/下载逻辑) + +**新增/改造 API** +- [x] 新增:`wolai-frontend/src/app/api/documents/copy-tree/route.ts`(递归复制入口,返回新旧 id 映射) +- [x] 保持:`wolai-frontend/src/app/api/documents/duplicate/route.ts`(现有单页复制不破坏) +- [x] 补齐:`wolai-frontend/src/app/api/media/upload/route.ts`(写入 `bucket/storage_path`,便于后续 copy/move) +- [x] 补齐:`wolai-frontend/src/app/api/media/batch/route.ts`(copy/move 支持 `storage_path` 或可解析的 `file_url`;重名策略) + +**纯函数工具与测试** +- [x] 命名:`wolai-frontend/src/lib/file-tree/naming.ts` +- [x] 测试:`wolai-frontend/src/lib/file-tree/naming.test.ts` + +--- + +### Step 4:拖拽移动/复制(含多选拖拽) + +**要做** +- [x] 拖拽默认移动;按修饰键(`Alt`)为复制(浏览器层面 dropEffect 已设置;Alt-copy 建议人工补测一次) +- [x] 起拖行在选择集中 → 拖整个选择集;否则仅拖当前行并先切为单选 +- [x] drop 目标: + - [x] drop 到 doc 行:贴入其下 + - [x] drop 到 index/asset 行:等同贴入其所属 doc + - [x] 禁止把 doc 拖到自身或后代(含多选去重/去后代) +- [x] 与“拖拽上传文件到页面”的 drop 行为区分(payload 不同) + +**新增文件** +- [x] `wolai-frontend/src/lib/file-tree/dnd.ts` +- [x] `wolai-frontend/src/lib/file-tree/asset.ts`(判断“真实文件”附件) + +**必测用例(Vitest:纯函数)** +- [x] `inferDropTargetDocId`:doc / index|asset / null 推断正确(`wolai-frontend/src/lib/file-tree/dnd.test.ts`) +- [x] `isInvalidDocDrop`:自拖/后代拖拦截(`wolai-frontend/src/lib/file-tree/dnd.test.ts`) +- [x] `isRealFileAsset`:排除 mindmap 等虚拟附件(`wolai-frontend/src/lib/file-tree/asset.test.ts`) + +## 3. 手工验收(每步至少跑一遍) + +- [x] 点击行高亮;active(当前打开页)与 selected(选中)同时可识别 +- [x] `Ctrl/Cmd+单击` 多选;`Shift+单击` 连续范围选;右键语义正确 +- [ ] `Ctrl+C/Ctrl+V`:仅树区域生效;编辑器正文不受影响(MCP 按键模拟不稳定,建议人工复核一次) +- [x] 复制 doc:`/api/documents/copy-tree` 已验证返回 200(页面端 `fetch`) +- [ ] 复制 asset:生成新的存储对象(不是引用),新文件可独立下载(需要至少 1 个真实附件用例) +- [x] 拖拽移动 doc:已通过页面拖拽触发 `/api/documents/move` 并验证 200 +- [ ] Alt+拖拽复制:逻辑已接入(`event.altKey`),需要人工补测一次(MCP 暂无法“按住 Alt 拖拽”) + +--- + +### Step 5:多选删除(右键删除作用于选择集) + +**要做** +- [x] 右键删除:若右键对象在 `selectedRowIds` 内,则对整个选择集执行删除 +- [x] index 行视作其所属页面(去重后再删) +- [x] 页面删除:移动到垃圾桶(复用 `/api/documents/delete`) +- [x] 附件删除:沿用当前逻辑(`/api/media/batch delete`;mindmap 走 `/api/mindmap/:docId`) +- [x] 若页面被删除,则跳过其页面内已选附件删除(避免重复/无效操作) + +**新增/改造文件** +- [x] `wolai-frontend/src/lib/file-tree/delete.ts` +- [x] `wolai-frontend/src/components/sidebar/sidebar.tsx` + +**必测用例(Vitest:纯函数)** +- [x] 选中父/子页面时,仅删除父页面(去后代) +- [x] index 行与 doc 行同时选中时,页面 id 去重 +- [x] 页面被删时,其下已选附件不参与附件删除 + +**手工验收** +- [x] 多选后右键任一已选行 → 点击“删除到垃圾桶”,应删除整个选择集(而不是仅最后右键项) diff --git a/design/vscodetree b/design/vscodetree new file mode 100644 index 00000000..16b212cc --- /dev/null +++ b/design/vscodetree @@ -0,0 +1,40 @@ +在 VS Code 源码中,资源管理器(Explorer)相关实现分散在多个目录/文件中。以下是关键文件和目录的定位(用于对照实现与阅读)。 + +## 1) 入口注册(Explorer Contributions) + +- `src/vs/workbench/contrib/files/browser/files.contribution.ts` + - 注册资源管理器相关视图、命令、菜单等贡献点。 + +## 2) 视图实现(Explorer View) + +- `src/vs/workbench/contrib/files/browser/views/explorerView.ts` + - Explorer 视图的渲染、布局与交互(树展示、展开折叠、选中、右键、拖拽等)。 + +## 3) 数据模型(Explorer Model) + +- `src/vs/workbench/contrib/files/common/explorerModel.ts` + - Explorer 的数据模型(树数据构建、过滤/排序、与文件服务交互)。 + +## 4) 通用资源树结构(ResourceTree) + +- `src/vs/base/common/resourceTree.ts` + - 基于 URI 的通用树结构实现,Explorer 使用的重要基础。 + +## 5) 文件相关命令(File Commands) + +- `src/vs/workbench/contrib/files/browser/fileCommands.ts` + - 浏览器端通用文件命令(新建/删除/重命名/复制粘贴等)。 +- `src/vs/workbench/contrib/files/electron-browser/fileCommands.ts` + - 桌面端特有命令(如在系统文件管理器中显示等)。 + +## 6) 文件服务接口(File Service) + +- `src/vs/platform/files/common/files.ts` + - 文件服务核心接口(`IFileService`),提供文件系统读写与监听能力。 + +## 7) 辅助工具(URI/过滤等) + +- `src/vs/base/common/resources.ts`:URI/路径工具 +- `src/vs/workbench/common/resources.ts`:glob 匹配/过滤(排除文件等) +- `src/vs/workbench/contrib/files/common/files.ts`:文件资源通用类型 + diff --git a/wolai-frontend/src/app/api/documents/copy-tree/route.ts b/wolai-frontend/src/app/api/documents/copy-tree/route.ts new file mode 100644 index 00000000..e8c7f144 --- /dev/null +++ b/wolai-frontend/src/app/api/documents/copy-tree/route.ts @@ -0,0 +1,393 @@ +import { NextResponse } from "next/server"; +import { createSupabaseRouteClient } from "@/lib/supabase/server"; +import path from "path"; +import { promises as fs } from "fs"; +import type { Json } from "@/types/supabase"; +import { makeUniqueTitle } from "@/lib/file-tree/naming"; + +export const dynamic = "force-dynamic"; + +type CopyTreeItem = { + documentId: string; + recursive: boolean; +}; + +type CopyTreePayload = { + items: CopyTreeItem[]; + targetParentId: string | null; +}; + +type DocRow = { + id: string; + title: string | null; + parent_id: string | null; + workspace_id: string; + access_scope: "private" | "shared" | "public" | null; + sort_order: number | null; + created_at: string | null; + content: Json | null; +}; + +type AssetRow = { + id: string; + workspace_id: string; + document_id: string; + asset_type: string | null; + file_name: string | null; + file_size: number | null; + mime_type: string | null; + file_url: string | null; + thumbnail_url: string | null; + bucket: string | null; + storage_path: string | null; +}; + +const documentsBaseDir = path.join(process.cwd(), "public", "documents"); +const DEFAULT_DOC_BUCKET = process.env.NEXT_PUBLIC_SUPABASE_DOC_BUCKET ?? "documents"; + +async function ensureDocumentScaffold(id: string, title: string | null) { + const folder = path.join(documentsBaseDir, id); + const indexFile = path.join(folder, "index.md"); + await fs.mkdir(folder, { recursive: true }); + try { + await fs.access(indexFile); + } catch { + const safeTitle = title && title.trim() ? title.trim() : "无标题"; + const content = `# ${safeTitle}\n`; + await fs.writeFile(indexFile, content, "utf8"); + } +} + +async function copyMindmapIfExists(sourceId: string, targetId: string) { + const src = path.join(documentsBaseDir, sourceId, "mindmap.json"); + const destDir = path.join(documentsBaseDir, targetId); + const dest = path.join(destDir, "mindmap.json"); + try { + const buf = await fs.readFile(src); + await fs.mkdir(destDir, { recursive: true }); + await fs.writeFile(dest, buf); + } catch { + // 源不存在则忽略 + } +} + +function normalizeTitle(title: string | null): string { + const safe = title?.trim(); + return safe && safe.length > 0 ? safe : "无标题"; +} + +function parseStoragePath(fileUrl: string): { bucket: string; path: string } | null { + try { + const url = new URL(fileUrl); + const segments = url.pathname.split("/").filter(Boolean); + const objectIdx = segments.findIndex((seg) => seg === "object"); + if (objectIdx === -1 || objectIdx + 2 >= segments.length) return null; + if (segments[objectIdx + 1] === "public") { + const bucket = segments[objectIdx + 2]; + const p = segments.slice(objectIdx + 3).join("/"); + return p ? { bucket, path: p } : null; + } + if (segments[objectIdx + 1] === "sign") { + const bucket = segments[objectIdx + 2]; + const p = segments.slice(objectIdx + 3).join("/"); + return p ? { bucket, path: p } : null; + } + return null; + } catch { + return null; + } +} + +function getChildrenSorted(childrenByParent: Map, parentId: string | null): DocRow[] { + const list = childrenByParent.get(parentId) ?? []; + return [...list].sort((a, b) => { + const orderA = a.sort_order ?? Number.MAX_SAFE_INTEGER; + const orderB = b.sort_order ?? Number.MAX_SAFE_INTEGER; + if (orderA !== orderB) return orderA - orderB; + const timeA = new Date(a.created_at ?? 0).getTime(); + const timeB = new Date(b.created_at ?? 0).getTime(); + return timeA - timeB; + }); +} + +function replaceAssetRefsInContent(content: Json | null, assetMap: Map, newDocId: string): Json | null { + if (!content) return content; + + const transform = (value: unknown): unknown => { + if (Array.isArray(value)) { + return value.map(transform); + } + if (!value || typeof value !== "object") { + return value; + } + + const obj = value as Record; + const next: Record = {}; + Object.keys(obj).forEach((key) => { + next[key] = transform(obj[key]); + }); + + if (next.type === "media" && next.props && typeof next.props === "object") { + const props = next.props as Record; + const oldId = typeof props.assetId === "string" ? props.assetId : null; + if (oldId && assetMap.has(oldId)) { + const mapped = assetMap.get(oldId)!; + props.assetId = mapped.id; + props.fileUrl = mapped.signedUrl; + props.thumbnailUrl = mapped.signedUrl; + props.documentId = newDocId; + next.props = props; + } + } + + return next; + }; + + return transform(content) as Json; +} + +export async function POST(request: Request) { + const supabase = await createSupabaseRouteClient(); + const { + data: { session }, + } = await supabase.auth.getSession(); + + if (!session) { + return NextResponse.json({ error: "未登录" }, { status: 401 }); + } + + const payload = (await request.json()) as CopyTreePayload; + if (!payload?.items?.length) { + return NextResponse.json({ error: "缺少 items" }, { status: 400 }); + } + + const normalizedItems = payload.items.filter((it) => it?.documentId); + if (normalizedItems.length === 0) { + return NextResponse.json({ error: "items 为空" }, { status: 400 }); + } + + const targetParentId = payload.targetParentId ?? null; + let workspaceId: string | null = null; + let targetAccessScope: "private" | "shared" | "public" = "private"; + + if (targetParentId) { + const { data: targetDoc, error } = await supabase + .from("documents") + .select("workspace_id,access_scope") + .eq("id", targetParentId) + .eq("user_id", session.user.id) + .single(); + if (error || !targetDoc) { + return NextResponse.json({ error: "目标页面不存在或无权限" }, { status: 404 }); + } + workspaceId = targetDoc.workspace_id; + targetAccessScope = (targetDoc.access_scope ?? "private") as typeof targetAccessScope; + } + + const sourceIds = Array.from(new Set(normalizedItems.map((it) => it.documentId))); + const { data: sourceDocs, error: sourceErr } = await supabase + .from("documents") + .select("id,title,parent_id,workspace_id,access_scope,sort_order,created_at,content") + .in("id", sourceIds) + .eq("user_id", session.user.id); + if (sourceErr || !sourceDocs?.length) { + return NextResponse.json({ error: "源页面不存在或无权限" }, { status: 404 }); + } + + const sourceById = new Map(); + (sourceDocs as DocRow[]).forEach((d) => sourceById.set(d.id, d)); + + if (!workspaceId) { + workspaceId = sourceDocs[0].workspace_id; + } + + const { data: allDocs, error: allErr } = await supabase + .from("documents") + .select("id,title,parent_id,workspace_id,access_scope,sort_order,created_at,content") + .eq("workspace_id", workspaceId) + .eq("user_id", session.user.id) + .order("sort_order", { ascending: true, nullsFirst: false }) + .order("created_at", { ascending: true }); + if (allErr) { + return NextResponse.json({ error: allErr.message }, { status: 500 }); + } + + const childrenByParent = new Map(); + (allDocs as DocRow[]).forEach((doc) => { + const list = childrenByParent.get(doc.parent_id) ?? []; + list.push(doc); + childrenByParent.set(doc.parent_id, list); + }); + + const existingTitleSetByParent = new Map>(); + const seedTitleSet = (parent: string | null) => { + if (existingTitleSetByParent.has(parent)) return; + const titles = new Set(); + (childrenByParent.get(parent) ?? []).forEach((d) => titles.add(normalizeTitle(d.title))); + existingTitleSetByParent.set(parent, titles); + }; + seedTitleSet(targetParentId); + + const newIdByOldId = new Map(); + const copyQueue: Array<{ old: DocRow; newParentId: string | null; parentKey: string | null }> = []; + + const enqueueTree = (root: DocRow, newParent: string | null, recursive: boolean) => { + const visit = (node: DocRow, parentNewId: string | null, parentKey: string | null) => { + const newId = typeof crypto.randomUUID === "function" ? crypto.randomUUID() : `${Date.now()}_${Math.random().toString(16).slice(2)}`; + newIdByOldId.set(node.id, newId); + copyQueue.push({ old: node, newParentId: parentNewId, parentKey }); + if (!recursive) return; + const children = getChildrenSorted(childrenByParent, node.id); + children.forEach((child) => visit(child, newId, newId)); + }; + visit(root, newParent, newParent); + }; + + normalizedItems.forEach((item) => { + const doc = sourceById.get(item.documentId) ?? (allDocs as DocRow[]).find((d) => d.id === item.documentId) ?? null; + if (doc) { + enqueueTree(doc, targetParentId, Boolean(item.recursive)); + } + }); + + if (copyQueue.length === 0) { + return NextResponse.json({ error: "没有可复制的页面" }, { status: 400 }); + } + + // 计算根级 sort_order 起点 + const siblingQuery = supabase + .from("documents") + .select("id", { head: true, count: "exact" }) + .eq("workspace_id", workspaceId); + if (targetParentId) { + siblingQuery.eq("parent_id", targetParentId); + } else { + siblingQuery.is("parent_id", null); + } + const { count: siblingCount = 0 } = await siblingQuery; + const nextSortByParent = new Map([[targetParentId, siblingCount]]); + + const insertedDocs: Array<{ oldId: string; newId: string }> = []; + + for (const item of copyQueue) { + const newId = newIdByOldId.get(item.old.id)!; + const parentId = item.newParentId; + + if (!existingTitleSetByParent.has(parentId)) { + if (newIdByOldId.has(parentId ?? "")) { + existingTitleSetByParent.set(parentId, new Set()); + } else { + seedTitleSet(parentId); + } + } + const titleSet = existingTitleSetByParent.get(parentId) ?? new Set(); + existingTitleSetByParent.set(parentId, titleSet); + + const newTitle = makeUniqueTitle(normalizeTitle(item.old.title), titleSet); + + const currentSort = nextSortByParent.get(parentId) ?? 0; + nextSortByParent.set(parentId, currentSort + 1); + + const { error: insertError } = await supabase.from("documents").insert({ + id: newId, + user_id: session.user.id, + workspace_id: workspaceId, + parent_id: parentId, + access_scope: (item.old.access_scope ?? targetAccessScope) as typeof targetAccessScope, + title: newTitle, + content: item.old.content ?? { blocks: [] }, + sort_order: currentSort, + }); + + if (insertError) { + return NextResponse.json({ error: insertError.message }, { status: 500 }); + } + + await ensureDocumentScaffold(newId, newTitle); + await copyMindmapIfExists(item.old.id, newId); + insertedDocs.push({ oldId: item.old.id, newId }); + } + + // 复制附件并回写 content 中的 assetId + const oldDocIds = insertedDocs.map((d) => d.oldId); + const { data: assets, error: assetErr } = await supabase + .from("media_assets") + .select("id,workspace_id,document_id,asset_type,file_name,file_size,mime_type,file_url,thumbnail_url,bucket,storage_path") + .in("document_id", oldDocIds) + .eq("workspace_id", workspaceId); + + if (assetErr) { + return NextResponse.json({ error: assetErr.message }, { status: 500 }); + } + + const assetMapByOldDoc = new Map>(); + for (const asset of (assets as AssetRow[] | null) ?? []) { + const mappedDocId = newIdByOldId.get(asset.document_id); + if (!mappedDocId) continue; + + const sourceLocation = + asset.storage_path + ? { bucket: asset.bucket ?? DEFAULT_DOC_BUCKET, path: asset.storage_path } + : asset.file_url + ? parseStoragePath(asset.file_url) + : null; + if (!sourceLocation) continue; + + const fileName = (asset.file_name ?? "附件").replace(/[\\/]/g, "_"); + const newAssetId = + typeof crypto.randomUUID === "function" + ? crypto.randomUUID() + : `${Date.now()}_${Math.random().toString(16).slice(2)}`; + const targetPath = `${workspaceId}/${mappedDocId}/${newAssetId}-${fileName}`; + const bucket = sourceLocation.bucket || DEFAULT_DOC_BUCKET; + + const map = assetMapByOldDoc.get(asset.document_id) ?? new Map(); + assetMapByOldDoc.set(asset.document_id, map); + + const copyRes = await supabase.storage.from(bucket).copy(sourceLocation.path, targetPath); + if (copyRes.error) { + continue; + } + + const { data: signed } = await supabase.storage.from(bucket).createSignedUrl(targetPath, 60 * 60 * 24 * 7); + const signedUrl = signed?.signedUrl ?? ""; + + const { error: insertAssetErr } = await supabase.from("media_assets").insert({ + id: newAssetId, + workspace_id: workspaceId, + document_id: mappedDocId, + asset_type: asset.asset_type ?? "file", + file_name: asset.file_name ?? fileName, + file_size: asset.file_size ?? null, + mime_type: asset.mime_type ?? null, + bucket, + storage_path: targetPath, + file_url: signedUrl, + thumbnail_url: signedUrl, + created_by: session.user.id, + }); + if (!insertAssetErr) { + map.set(asset.id, { id: newAssetId, signedUrl }); + } + } + + for (const pair of insertedDocs) { + const map = assetMapByOldDoc.get(pair.oldId); + if (!map || map.size === 0) continue; + const source = (allDocs as DocRow[]).find((d) => d.id === pair.oldId); + if (!source) continue; + const newContent = replaceAssetRefsInContent(source.content, map, pair.newId); + const { error: updateErr } = await supabase + .from("documents") + .update({ content: newContent }) + .eq("id", pair.newId) + .eq("user_id", session.user.id); + if (updateErr) { + // ignore + } + } + + return NextResponse.json({ + items: insertedDocs.map((d) => ({ oldId: d.oldId, newId: d.newId })), + }); +} diff --git a/wolai-frontend/src/app/api/media/batch/route.ts b/wolai-frontend/src/app/api/media/batch/route.ts index 1e61eeba..f2f062fd 100644 --- a/wolai-frontend/src/app/api/media/batch/route.ts +++ b/wolai-frontend/src/app/api/media/batch/route.ts @@ -1,5 +1,7 @@ import { NextResponse } from "next/server"; import { createSupabaseRouteClient } from "@/lib/supabase/server"; +import { extname } from "path"; +import { makeUniqueFileName } from "@/lib/file-tree/naming"; export const dynamic = "force-dynamic"; @@ -13,6 +15,39 @@ interface BatchPayload { } const BUCKET = process.env.NEXT_PUBLIC_SUPABASE_MEDIA_BUCKET ?? "workspace"; +const DEFAULT_DOC_BUCKET = process.env.NEXT_PUBLIC_SUPABASE_DOC_BUCKET ?? "documents"; + +const parseStoragePath = (fileUrl: string) => { + try { + const url = new URL(fileUrl); + const segments = url.pathname.split("/").filter(Boolean); + const objectIdx = segments.findIndex((seg) => seg === "object"); + if (objectIdx === -1 || objectIdx + 2 >= segments.length) return null; + if (segments[objectIdx + 1] === "public") { + const bucket = segments[objectIdx + 2]; + const path = segments.slice(objectIdx + 3).join("/"); + return { bucket, path }; + } + if (segments[objectIdx + 1] === "sign") { + const bucket = segments[objectIdx + 2]; + const path = segments.slice(objectIdx + 3).join("/"); + return { bucket, path }; + } + return null; + } catch { + return null; + } +}; + +function resolveAssetLocation(asset: any): { bucket: string; path: string } | null { + if (asset?.storage_path) { + return { bucket: asset.bucket || BUCKET, path: asset.storage_path }; + } + if (asset?.file_url) { + return parseStoragePath(asset.file_url); + } + return null; +} export async function POST(request: Request) { const supabase = await createSupabaseRouteClient(); @@ -47,9 +82,9 @@ export async function POST(request: Request) { case "delete": { await Promise.all( assets.map(async (asset) => { - if (asset.storage_path) { - await supabase.storage.from(asset.bucket || BUCKET).remove([asset.storage_path]); - } + const location = resolveAssetLocation(asset); + if (!location) return; + await supabase.storage.from(location.bucket || BUCKET).remove([location.path]); }), ); const { error } = await supabase.from("media_assets").delete().in("id", payload.assetIds); @@ -63,20 +98,36 @@ export async function POST(request: Request) { const asset = assets[0]; const ext = asset.file_name?.includes(".") ? `.${asset.file_name.split(".").pop()}` : ""; const newFileName = payload.newName.includes(".") || !ext ? payload.newName : `${payload.newName}${ext}`; - const targetPath = `${session.user.id}/${asset.workspace_id}/${asset.document_id}/assets/${newFileName}`; - if (asset.storage_path) { - const moveResult = await supabase.storage - .from(asset.bucket || BUCKET) - .move(asset.storage_path, targetPath); + const location = resolveAssetLocation(asset); + const safeName = newFileName.replace(/[\\/]/g, "_"); + if (location) { + const uniqueId = + typeof crypto.randomUUID === "function" + ? crypto.randomUUID() + : `${Date.now()}_${Math.random().toString(16).slice(2)}`; + const targetPath = `${asset.workspace_id}/${asset.document_id}/${Date.now()}-${uniqueId}-${safeName}`; + const bucket = location.bucket || asset.bucket || DEFAULT_DOC_BUCKET || BUCKET; + const moveResult = await supabase.storage.from(bucket).move(location.path, targetPath); if (moveResult.error) throw moveResult.error; + const { data: signed } = await supabase.storage.from(bucket).createSignedUrl(targetPath, 60 * 60 * 24 * 7); + const signedUrl = signed?.signedUrl ?? null; + const { error } = await supabase + .from("media_assets") + .update({ + file_name: safeName, + bucket, + storage_path: targetPath, + file_url: signedUrl, + thumbnail_url: signedUrl, + }) + .eq("id", asset.id); + if (error) throw error; + return NextResponse.json({ ok: true }); } const { error } = await supabase .from("media_assets") .update({ - file_name: newFileName, - storage_path: targetPath, - file_url: null, - thumbnail_url: null, + file_name: safeName, }) .eq("id", asset.id); if (error) throw error; @@ -95,16 +146,27 @@ export async function POST(request: Request) { if (docErr || !targetDoc) { return NextResponse.json({ error: "目标页面不存在" }, { status: 404 }); } + const { data: existingRows } = await supabase + .from("media_assets") + .select("file_name") + .eq("document_id", payload.targetDocumentId); + const existingNames = new Set((existingRows ?? []).map((r) => (r.file_name ?? "").toString()).filter(Boolean)); const results = []; for (const asset of assets) { - const fileName = asset.file_name ?? "附件"; - const targetPath = `${session.user.id}/${targetDoc.workspace_id}/${payload.targetDocumentId}/assets/${fileName}`; - const sourcePath = asset.storage_path; - const bucket = asset.bucket || BUCKET; - if (!sourcePath) continue; + const location = resolveAssetLocation(asset); + if (!location) continue; + const fileName = makeUniqueFileName(asset.file_name ?? "附件", existingNames).replace(/[\\/]/g, "_"); + const uniqueId = + typeof crypto.randomUUID === "function" + ? crypto.randomUUID() + : `${Date.now()}_${Math.random().toString(16).slice(2)}`; + const targetPath = `${targetDoc.workspace_id}/${payload.targetDocumentId}/${Date.now()}-${uniqueId}-${fileName}`; + const bucket = location.bucket || asset.bucket || DEFAULT_DOC_BUCKET || BUCKET; if (payload.action === "copy") { - const copyRes = await supabase.storage.from(bucket).copy(sourcePath, targetPath); + const copyRes = await supabase.storage.from(bucket).copy(location.path, targetPath); if (copyRes.error) throw copyRes.error; + const { data: signed } = await supabase.storage.from(bucket).createSignedUrl(targetPath, 60 * 60 * 24 * 7); + const signedUrl = signed?.signedUrl ?? null; const { data: inserted, error } = await supabase .from("media_assets") .insert({ @@ -116,6 +178,8 @@ export async function POST(request: Request) { mime_type: asset.mime_type, bucket, storage_path: targetPath, + file_url: signedUrl, + thumbnail_url: signedUrl, created_by: session.user.id, }) .select("*") @@ -123,8 +187,10 @@ export async function POST(request: Request) { if (error) throw error; results.push(inserted); } else { - const moveRes = await supabase.storage.from(bucket).move(sourcePath, targetPath); + const moveRes = await supabase.storage.from(bucket).move(location.path, targetPath); if (moveRes.error) throw moveRes.error; + const { data: signed } = await supabase.storage.from(bucket).createSignedUrl(targetPath, 60 * 60 * 24 * 7); + const signedUrl = signed?.signedUrl ?? null; const { data: updated, error } = await supabase .from("media_assets") .update({ @@ -132,8 +198,9 @@ export async function POST(request: Request) { document_id: payload.targetDocumentId, storage_path: targetPath, file_name: fileName, - file_url: null, - thumbnail_url: null, + bucket, + file_url: signedUrl, + thumbnail_url: signedUrl, }) .eq("id", asset.id) .select("*") diff --git a/wolai-frontend/src/app/api/media/upload/route.ts b/wolai-frontend/src/app/api/media/upload/route.ts index 54c24166..e5845f73 100644 --- a/wolai-frontend/src/app/api/media/upload/route.ts +++ b/wolai-frontend/src/app/api/media/upload/route.ts @@ -61,6 +61,8 @@ export async function POST(request: Request) { document_id: documentId, file_url: signedUrl, thumbnail_url: signedUrl, + bucket: DOC_BUCKET, + storage_path: path, asset_type: assetType, file_name: file.name, file_size: file.size, diff --git a/wolai-frontend/src/components/editor/blocks/MindmapBlock.tsx b/wolai-frontend/src/components/editor/blocks/MindmapBlock.tsx index 2ee9cb57..29145f87 100644 --- a/wolai-frontend/src/components/editor/blocks/MindmapBlock.tsx +++ b/wolai-frontend/src/components/editor/blocks/MindmapBlock.tsx @@ -464,10 +464,9 @@ const MindmapBlockView = ({ const onFsChange = () => { const active = Boolean(document.fullscreenElement); setFullscreenApiActive(active); - // 用户按 ESC 退出浏览器全屏时,同步退出沉浸式全屏界面 - if (!active && localFullscreen) { - setLocalFullscreen(false); - } + // 注意:浏览器在打开原生对话框(例如 file picker / alert / confirm)时可能会自动退出 + // Fullscreen API。此时不应退出“沉浸式全屏”UI,否则会导致用户在全屏编辑中执行导入/新建 + // 等操作时被强制退出全屏。 }; document.addEventListener("fullscreenchange", onFsChange); @@ -1432,7 +1431,7 @@ const MindmapBlockView = ({ const data = await xmindParser.default.parseXmindFile(blob, (content) => { const list = content; if (list.length > 1) { - window.alert("检测到 XMind 多画布,自动导入第一个画布。"); + window.alert("检测到 XMind 多画布,自动导入第一个画布。"); } return list.length > 0 ? list[0] : content; }); @@ -1442,6 +1441,16 @@ const MindmapBlockView = ({ return; } + // MindManager (.mmap) + if (ext === "mmap") { + const { parseMindManagerMmapFile } = await import("./mindmapMindManagerImport"); + const data = await parseMindManagerMmapFile(file); + mindmap?.setData(data); + mindmap?.command.clearHistory(); + debouncedPersist(data); + return; + } + // Markdown if (ext === "md" || ext === "markdown") { const { transformMarkdownTo } = await import( @@ -1458,7 +1467,7 @@ const MindmapBlockView = ({ return; } - window.alert("暂不支持该文件类型,请选择 .json / .smm / .xmind / .md"); + window.alert("暂不支持该文件类型,请选择 .json / .smm / .xmind / .mmap / .md"); } catch (error) { console.error(error); window.alert("导入失败:文件格式或内容错误"); @@ -1798,7 +1807,7 @@ const MindmapBlockView = ({ >
- 思维导图编辑{fullscreenApiActive ? "(全屏)" : ""} + 思维导图编辑(全屏{fullscreenApiActive ? "·真" : ""})
- ) : ( - - )} - - -
- - {isExpanded && ( -
- } - onClick={() => onOpenDocument(node.id)} - stopBubble - /> - {assets.map((asset) => ( - } - selected={selectedAssetIds.has(asset.id)} - selectable={!disableSelection} - onSelectToggle={ - onToggleAssetSelect ? () => onToggleAssetSelect(asset.id) : undefined - } - onSelectOnly={onSelectOnlyAsset ? () => onSelectOnlyAsset(asset.id) : undefined} - onClick={(e) => { - if (onAssetClick) { - onAssetClick(asset, e); - } else { - onOpenAsset(asset); - } - }} - onDoubleClick={() => onOpenAsset(asset)} - stopBubble - onContextMenu={ - onAssetContextMenu - ? (e) => { - e.preventDefault(); - e.stopPropagation(); - if (onAssetClick) { - onAssetClick(asset, e); - } - onAssetContextMenu(e, asset); - } - : undefined - } - /> - ))} - {node.children.map((child) => ( - - ))} -
- )} - - ); -} - -function FileLeafRow({ - depth, - label, - icon, - onClick, - selected = false, - selectable = false, - onSelectToggle, - onSelectOnly, - stopBubble = false, - onContextMenu, -}: { - depth: number; - label: string; - icon: React.ReactNode; - onClick: () => void; - selected?: boolean; - selectable?: boolean; - onSelectToggle?: () => void; - onSelectOnly?: () => void; - stopBubble?: boolean; - onContextMenu?: (event: React.MouseEvent) => void; - onDoubleClick?: () => void; -}) { - return ( -
{ - if (stopBubble) e.stopPropagation(); - onClick(); - }} - onDoubleClick={(e) => { - if (stopBubble) e.stopPropagation(); - onDoubleClick?.(); - }} - onMouseDown={(e) => { - if (stopBubble) e.stopPropagation(); - }} - role="button" - tabIndex={0} - onContextMenu={(e) => { - if (stopBubble) e.stopPropagation(); - if (onContextMenu) onContextMenu(e); - }} - onKeyDown={(event) => { - if (event.key === "Enter" || event.key === " ") { + onDrop={(event) => { + if (onDropFiles && event.dataTransfer.files?.length) { event.preventDefault(); - onClick(); + const files = event.dataTransfer.files; + const firstDocRow = rows.find((row) => row.kind === "doc"); + const targetDocId = firstDocRow?.docId ?? ""; + onDropFiles(targetDocId, files); + } + }} + onMouseDown={(event) => { + if (event.target === event.currentTarget) { + onBlankMouseDown?.(event); } }} > - {selectable ? ( - { - e.stopPropagation(); - if (onSelectToggle) onSelectToggle(); - }} - className="h-4 w-4 rounded border-gray-300 text-[#2563eb]" - /> - ) : ( - - )} - {icon} - + {rows.map((row) => { + const label = getFileTreeRowLabel(row); + const selected = selectedRowIds.has(row.rowId); + const active = row.kind !== "asset" && row.docId === activeId; + const draggable = + row.kind === "doc" || (row.kind === "asset" && isRealFileAsset(row.asset)); + const baseClass = + "flex items-center gap-2 rounded-md px-2 py-1.5 text-sm text-gray-700 hover:bg-[#f5f7fb]"; + const activeClass = + row.kind === "index" + ? "text-[#2563eb] font-medium" + : row.kind === "doc" + ? "text-[#2563eb]" + : ""; + const paddingLeft = + row.kind === "doc" ? row.depth * INDENT + 8 : row.depth * INDENT + 32; + + return ( +
onRowClick(row, event)} + onDoubleClick={(event) => onRowDoubleClick(row, event)} + onContextMenu={(event) => onRowContextMenu(row, event)} + draggable={draggable} + onDragStart={(event) => { + if (!draggable) return; + if (!selectedRowIds.has(row.rowId)) { + onRowDragStart?.(row, event); + } + const rowIds = selectedRowIds.has(row.rowId) ? Array.from(selectedRowIds) : [row.rowId]; + const payload = JSON.stringify({ type: "mnote-file-tree-dnd", version: 1, rowIds }); + try { + event.dataTransfer.setData("application/x-mnote-file-tree", payload); + } catch { + // ignore + } + event.dataTransfer.setData("text/plain", payload); + event.dataTransfer.effectAllowed = event.altKey ? "copyMove" : "move"; + }} + onDragOver={(event) => { + const types = Array.from(event.dataTransfer.types ?? []); + const hasInternal = types.includes("application/x-mnote-file-tree"); + if (hasInternal && onInternalDrop) { + event.preventDefault(); + event.dataTransfer.dropEffect = event.altKey ? "copy" : "move"; + return; + } + if (onDropFiles) event.preventDefault(); + }} + onDrop={(event) => { + const types = Array.from(event.dataTransfer.types ?? []); + const isInternal = types.includes("application/x-mnote-file-tree"); + if (isInternal && onInternalDrop) { + const raw = + event.dataTransfer.getData("application/x-mnote-file-tree") || ""; + try { + const parsed = JSON.parse(raw) as { type?: string; version?: number; rowIds?: unknown }; + if (parsed?.type === "mnote-file-tree-dnd" && parsed.version === 1 && Array.isArray(parsed.rowIds)) { + event.preventDefault(); + onInternalDrop({ + targetRow: row, + rowIds: parsed.rowIds.filter((id) => typeof id === "string") as string[], + copy: event.altKey, + }); + return; + } + } catch { + // ignore + } + } + + if (onDropFiles && event.dataTransfer.files?.length) { + event.preventDefault(); + onDropFiles(row.docId, event.dataTransfer.files); + } + }} + role="button" + tabIndex={0} + > + {row.kind === "doc" ? ( + <> + {row.hasChildren ? ( + + ) : ( + + )} + + {label} + + + ) : row.kind === "index" ? ( + <> + + + {label} + + ) : ( + <> + + + {label} + + )} +
+ ); + })}
); } diff --git a/wolai-frontend/src/components/sidebar/sidebar.tsx b/wolai-frontend/src/components/sidebar/sidebar.tsx index 36dff243..20fe3a1a 100644 --- a/wolai-frontend/src/components/sidebar/sidebar.tsx +++ b/wolai-frontend/src/components/sidebar/sidebar.tsx @@ -43,6 +43,20 @@ import { buildSidebarSectionsFromTree, flattenDocumentTree } from "@/lib/sidebar import { supabaseBrowser } from "@/lib/supabase/client"; import { useSearchPaletteStore } from "@/store/search-palette"; import { FileTree } from "@/components/sidebar/file-tree"; +import { buildVisibleRows } from "@/lib/file-tree/rows"; +import type { FileTreeSelectionState } from "@/lib/file-tree/selection"; +import { reduceFileTreeSelection } from "@/lib/file-tree/selection"; +import type { FileTreeRow } from "@/lib/file-tree/types"; +import { parseFileTreeRowId } from "@/lib/file-tree/types"; +import { buildParentById, filterTopLevelDocIds, inferDropTargetDocId, isInvalidDocDrop } from "@/lib/file-tree/dnd"; +import { isRealFileAsset } from "@/lib/file-tree/asset"; +import { computeFileTreeDeleteTargets } from "@/lib/file-tree/delete"; +import { + inferPasteTargetDocId, + isTextInputTarget, + readFileTreeClipboardPayload, + writeFileTreeClipboardPayload, +} from "@/lib/file-tree/clipboard"; import type { MediaAsset } from "@/types/media"; import { AssetContextMenu } from "@/components/sidebar/asset-context-menu"; import { ASSETS_CHANGED_EVENT, DOCUMENTS_CHANGED_EVENT, emitAssetsChanged, emitDocumentsChanged } from "@/lib/events"; @@ -100,10 +114,14 @@ export function Sidebar({ initialData }: SidebarProps) { const [assetMenu, setAssetMenu] = useState<{ asset: MediaAsset; x: number; y: number } | null>( null, ); - const [selectedAssetIds, setSelectedAssetIds] = useState>(new Set()); - const [lastSelectedAssetId, setLastSelectedAssetId] = useState(null); + const [fileTreeSelection, setFileTreeSelection] = useState(() => ({ + selectedRowIds: new Set(), + anchorRowId: null, + focusedRowId: null, + })); const workspaceMenuRef = useRef(null); + const fileTreeContainerRef = useRef(null); useEffect(() => { setTree(() => { @@ -122,8 +140,7 @@ export function Sidebar({ initialData }: SidebarProps) { }, [sidebarData.mindmapDocs]); useEffect(() => { - setSelectedAssetIds(new Set()); - setLastSelectedAssetId(null); + setFileTreeSelection({ selectedRowIds: new Set(), anchorRowId: null, focusedRowId: null }); }, [mediaAssets, sidebarData.documents]); useEffect(() => { @@ -265,19 +282,49 @@ export function Sidebar({ initialData }: SidebarProps) { return map; }, [sidebarData.documents, mediaAssets, mindmapDocs]); - const assetOrder = useMemo(() => { - const list: string[] = []; - Object.values(assetsByDoc).forEach((arr) => { - arr.forEach((asset) => list.push(asset.id)); - }); - return list; - }, [assetsByDoc]); + const fileTreeRows = useMemo( + () => + buildVisibleRows({ + nodes: filteredPrivateTree, + expanded, + assetsByDoc, + }), + [assetsByDoc, expanded, filteredPrivateTree], + ); - const assetIndexMap = useMemo(() => { - const map = new Map(); - assetOrder.forEach((id, idx) => map.set(id, idx)); + const fileTreeVisibleRowIds = useMemo(() => fileTreeRows.map((row) => row.rowId), [fileTreeRows]); + const fileTreeRowById = useMemo(() => new Map(fileTreeRows.map((row) => [row.rowId, row])), [fileTreeRows]); + + const docParentById = useMemo( + () => + buildParentById( + (sidebarData.documents ?? []).map((doc) => ({ + id: doc.id, + parentId: doc.parent_id ?? null, + })), + ), + [sidebarData.documents], + ); + + const childrenCountByParentId = useMemo(() => { + const map = new Map(); + (sidebarData.documents ?? []).forEach((doc) => { + const parentId = doc.parent_id ?? null; + map.set(parentId, (map.get(parentId) ?? 0) + 1); + }); return map; - }, [assetOrder]); + }, [sidebarData.documents]); + + const selectedAssetIdsForMenu = useMemo(() => { + const ids: string[] = []; + fileTreeSelection.selectedRowIds.forEach((rowId) => { + const parsed = parseFileTreeRowId(rowId); + if (parsed?.kind === "asset") { + ids.push(parsed.assetId); + } + }); + return ids; + }, [fileTreeSelection.selectedRowIds]); const activeWorkspace = sidebarData.workspaces.find((workspace) => workspace.id === sidebarData.activeWorkspaceId) ?? @@ -285,8 +332,6 @@ export function Sidebar({ initialData }: SidebarProps) { const handleOpenDocument = useCallback( (documentId: string, mode: "main" | "sidebar") => { - setSelectedAssetIds(new Set()); - setLastSelectedAssetId(null); const targetPath = `/documents/${documentId}`; if (mode === "main") { router.push(targetPath); @@ -380,67 +425,204 @@ export function Sidebar({ initialData }: SidebarProps) { } }, [router, setOpen]); - const handleAssetContextMenu = useCallback( - (event: React.MouseEvent, asset: MediaAsset) => { - event.preventDefault(); - if (!selectedAssetIds.has(asset.id)) { - setSelectedAssetIds(new Set([asset.id])); - setLastSelectedAssetId(asset.id); - } - setAssetMenu({ asset, x: event.clientX, y: event.clientY }); + const handleFileTreeBlankMouseDown = useCallback(() => { + setFileTreeSelection((prev) => reduceFileTreeSelection(prev, { type: "clear" })); + }, []); + + const handleFileTreeRowClick = useCallback( + (row: FileTreeRow, event: React.MouseEvent) => { + setFileTreeSelection((prev) => + reduceFileTreeSelection(prev, { + type: "click", + rowId: row.rowId, + visibleRowIds: fileTreeVisibleRowIds, + modifiers: { + shiftKey: event.shiftKey, + ctrlKey: event.ctrlKey, + metaKey: event.metaKey, + }, + }), + ); }, - [selectedAssetIds], + [fileTreeVisibleRowIds], ); - const handleSelectAsset = useCallback( - (asset: MediaAsset, event?: { type?: string; shiftKey?: boolean; metaKey?: boolean; ctrlKey?: boolean }) => { - const evtType = event?.type ?? ""; - if (evtType === "contextmenu" && selectedAssetIds.has(asset.id)) { - setLastSelectedAssetId(asset.id); + const handleFileTreeRowDragStart = useCallback((row: FileTreeRow) => { + setFileTreeSelection((prev) => { + if (prev.selectedRowIds.has(row.rowId)) return prev; + return reduceFileTreeSelection(prev, { + type: "click", + rowId: row.rowId, + visibleRowIds: fileTreeVisibleRowIds, + modifiers: { shiftKey: false, ctrlKey: false, metaKey: false }, + }); + }); + }, [fileTreeVisibleRowIds]); + + const handleFileTreeRowDoubleClick = useCallback( + (row: FileTreeRow, _event?: React.MouseEvent) => { + if (row.kind === "asset") { + handleOpenAsset(row.asset); return; } - setSelectedAssetIds((prev) => { - let next = new Set(prev); - const withShift = Boolean(event?.shiftKey) && lastSelectedAssetId && assetIndexMap.has(lastSelectedAssetId); - if (withShift) { - const start = assetIndexMap.get(lastSelectedAssetId!) ?? 0; - const end = assetIndexMap.get(asset.id) ?? start; - const [lo, hi] = start < end ? [start, end] : [end, start]; - const idsInRange = assetOrder.slice(lo, hi + 1); - next = new Set([...prev, ...idsInRange]); - } else if (event?.metaKey || event?.ctrlKey) { - if (next.has(asset.id)) { - next.delete(asset.id); - } else { - next.add(asset.id); - } - } else { - next = new Set([asset.id]); - } - return next; - }); - setLastSelectedAssetId(asset.id); + handleOpenDocument(row.docId, "main"); }, - [assetIndexMap, assetOrder, lastSelectedAssetId, selectedAssetIds], + [handleOpenAsset, handleOpenDocument], ); - const toggleAssetCheckbox = useCallback((assetId: string) => { - setSelectedAssetIds((prev) => { - const next = new Set(prev); - if (next.has(assetId)) { - next.delete(assetId); - } else { - next.add(assetId); - } - return next; - }); - setLastSelectedAssetId(assetId); - }, []); + const handleFileTreeRowContextMenu = useCallback( + (row: FileTreeRow, event: React.MouseEvent) => { + event.preventDefault(); + event.stopPropagation(); + setFileTreeSelection((prev) => + reduceFileTreeSelection(prev, { type: "contextmenu", rowId: row.rowId }), + ); - const selectOnlyAsset = useCallback((assetId: string) => { - setSelectedAssetIds(new Set([assetId])); - setLastSelectedAssetId(assetId); - }, []); + if (row.kind === "asset") { + setAssetMenu({ asset: row.asset, x: event.clientX, y: event.clientY }); + return; + } + + setContextMenu({ + node: row.node, + x: event.clientX, + y: event.clientY, + }); + }, + [], + ); + + useEffect(() => { + const handler = async (event: KeyboardEvent) => { + const isCopy = + (event.ctrlKey || event.metaKey) && + !event.altKey && + (event.key === "c" || event.key === "C"); + const isPaste = + (event.ctrlKey || event.metaKey) && + !event.altKey && + (event.key === "v" || event.key === "V"); + + if (!isCopy && !isPaste) { + return; + } + + if (isTextInputTarget(event.target)) { + return; + } + + const container = fileTreeContainerRef.current; + const activeElement = document.activeElement; + if (!container || !activeElement || !container.contains(activeElement)) { + return; + } + + if (isCopy) { + if (fileTreeSelection.selectedRowIds.size === 0) { + return; + } + event.preventDefault(); + const orderedRowIds = fileTreeRows + .filter((row) => fileTreeSelection.selectedRowIds.has(row.rowId)) + .map((row) => row.rowId); + await writeFileTreeClipboardPayload({ + type: "mnote-file-tree", + version: 1, + action: "copy", + rowIds: orderedRowIds, + }); + return; + } + + if (isPaste) { + event.preventDefault(); + const payload = await readFileTreeClipboardPayload(); + if (!payload || payload.rowIds.length === 0) { + return; + } + + const targetDocId = inferPasteTargetDocId({ + focusedRowId: fileTreeSelection.focusedRowId, + rowById: fileTreeRowById, + activeDocId: activeId || null, + }); + if (!targetDocId) { + setTimeout(() => window.alert("请选择一个目标页面后再粘贴"), 0); + return; + } + + const rows = payload.rowIds + .map((rowId) => fileTreeRowById.get(rowId)) + .filter(Boolean) as FileTreeRow[]; + + const docItemsMap = new Map(); + rows.forEach((row) => { + if (row.kind === "doc") { + docItemsMap.set(row.docId, true); + } else if (row.kind === "index") { + if (!docItemsMap.has(row.docId)) { + docItemsMap.set(row.docId, false); + } + } + }); + + if (docItemsMap.size > 0) { + const resp = await fetch("/api/documents/copy-tree", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + items: Array.from(docItemsMap.entries()).map(([documentId, recursive]) => ({ + documentId, + recursive, + })), + targetParentId: targetDocId, + }), + }); + if (!resp.ok) { + const data = await resp.json().catch(() => ({})); + setTimeout(() => window.alert(data?.error ?? "粘贴页面失败"), 0); + return; + } + await sidebarQuery.refetch(); + emitDocumentsChanged(targetDocId); + } + + const assetRows = rows.filter((row) => row.kind === "asset") as Extract[]; + const copyableAssetIds = assetRows.filter((row) => isRealFileAsset(row.asset)).map((row) => row.asset.id); + + if (copyableAssetIds.length > 0) { + const resp = await fetch("/api/media/batch", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + action: "copy", + assetIds: copyableAssetIds, + targetDocumentId: targetDocId, + }), + }); + if (!resp.ok) { + const data = await resp.json().catch(() => ({})); + setTimeout(() => window.alert(data?.error ?? "粘贴附件失败"), 0); + return; + } + await sidebarQuery.refetch(); + emitAssetsChanged(targetDocId); + } else if (docItemsMap.size === 0) { + setTimeout(() => window.alert("没有可粘贴的真实文件(mindmap.json 等虚拟附件暂不支持)"), 0); + } + } + }; + + window.addEventListener("keydown", handler); + return () => window.removeEventListener("keydown", handler); + }, [ + activeId, + fileTreeRowById, + fileTreeRows, + fileTreeSelection.focusedRowId, + fileTreeSelection.selectedRowIds, + sidebarQuery, + ]); const handleCopyAssetLink = useCallback( async (asset: MediaAsset) => { @@ -552,39 +734,124 @@ export function Sidebar({ initialData }: SidebarProps) { const handleDeleteAssets = useCallback( async (assetIds: string[], assetHint?: MediaAsset) => { - const target = - assetHint ?? - mediaAssets.find((item) => assetIds.includes(item.id)) ?? - null; - if (target?.asset_type === "mindmap") { - const resp = await fetch(`/api/mindmap/${target.document_id}`, { method: "DELETE" }); + const uniqueAssetIds = Array.from(new Set(assetIds)); + const assets = uniqueAssetIds + .map((id) => mediaAssets.find((item) => item.id === id)) + .filter(Boolean) as MediaAsset[]; + + if (assetHint && !assets.find((item) => item.id === assetHint.id)) { + assets.unshift(assetHint); + } + + const mindmapDocIds = Array.from( + new Set(assets.filter((item) => item.asset_type === "mindmap").map((item) => item.document_id)), + ); + const fileAssetIdsByDocId = new Map(); + assets + .filter((item) => item.asset_type !== "mindmap") + .forEach((item) => { + const prev = fileAssetIdsByDocId.get(item.document_id) ?? []; + prev.push(item.id); + fileAssetIdsByDocId.set(item.document_id, prev); + }); + const fileAssetIds = Array.from(fileAssetIdsByDocId.values()).flat(); + + for (const docId of mindmapDocIds) { + const resp = await fetch(`/api/mindmap/${docId}`, { method: "DELETE" }); if (!resp.ok) { const payload = await resp.json().catch(() => ({})); window.alert(payload?.error ?? "删除思维导图失败"); return; } - setMindmapDocs((prev) => prev.filter((id) => id !== target.document_id)); - setAssetMenu(null); - emitAssetsChanged(target.document_id, undefined, undefined, true); - return; } - const resp = await fetch("/api/media/batch", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ action: "delete", assetIds }), - }); - if (!resp.ok) { - const payload = await resp.json().catch(() => ({})); - window.alert(payload?.error ?? "删除失败"); - return; + + if (fileAssetIds.length > 0) { + const resp = await fetch("/api/media/batch", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ action: "delete", assetIds: fileAssetIds }), + }); + if (!resp.ok) { + const payload = await resp.json().catch(() => ({})); + window.alert(payload?.error ?? "删除失败"); + return; + } + } + + if (mindmapDocIds.length > 0) { + setMindmapDocs((prev) => prev.filter((id) => !mindmapDocIds.includes(id))); + } + if (uniqueAssetIds.length > 0) { + setMediaAssets((prev) => prev.filter((item) => !uniqueAssetIds.includes(item.id))); } - setMediaAssets((prev) => prev.filter((item) => !assetIds.includes(item.id))); setAssetMenu(null); - emitAssetsChanged(target?.document_id, undefined, assetIds); + mindmapDocIds.forEach((docId) => emitAssetsChanged(docId, undefined, undefined, true)); + fileAssetIdsByDocId.forEach((ids, docId) => emitAssetsChanged(docId, undefined, ids)); }, [mediaAssets, sidebarQuery], ); + const handleDeleteFileTreeSelection = useCallback(async () => { + const { docIds, assetIds } = computeFileTreeDeleteTargets({ + visibleRows: fileTreeRows, + selectedRowIds: fileTreeSelection.selectedRowIds, + parentById: docParentById, + }); + + if (docIds.length === 0 && assetIds.length === 0) { + return; + } + + const docText = docIds.length > 0 ? `${docIds.length} 个页面(删除到垃圾桶)` : ""; + const assetText = assetIds.length > 0 ? `${assetIds.length} 个附件(彻底删除)` : ""; + const joinText = docText && assetText ? " + " : ""; + const ok = window.confirm(`确认删除选中的 ${docText}${joinText}${assetText} 吗?`); + if (!ok) return; + + try { + if (docIds.length > 0) { + const results = await Promise.all( + docIds.map(async (documentId) => { + const resp = await fetch("/api/documents/delete", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ documentId }), + }); + return { documentId, ok: resp.ok }; + }), + ); + const failed = results.filter((item) => !item.ok).map((item) => item.documentId); + if (failed.length > 0) { + window.alert(`部分页面删除失败:${failed.slice(0, 5).join(", ")}${failed.length > 5 ? "…" : ""}`); + return; + } + + docIds.forEach((documentId) => emitDocumentsChanged(documentId)); + if (activeId && docIds.includes(activeId)) { + router.push("/"); + } + } + + if (assetIds.length > 0) { + await handleDeleteAssets(assetIds); + } + + await refreshTree(); + setContextMenu(null); + setFileTreeSelection((prev) => reduceFileTreeSelection(prev, { type: "clear" })); + } catch (error) { + window.alert(error instanceof Error ? error.message : "删除失败"); + } + }, [ + activeId, + docParentById, + fileTreeRows, + fileTreeSelection.selectedRowIds, + handleDeleteAssets, + refreshTree, + router, + ]); + const handleResizeStart = useCallback( (event: React.MouseEvent) => { event.preventDefault(); @@ -693,6 +960,147 @@ export function Sidebar({ initialData }: SidebarProps) { [moveLocalNode, refreshTree, setExpanded], ); + const handleFileTreeInternalDrop = useCallback( + (args: { targetRow: FileTreeRow; rowIds: string[]; copy: boolean }) => { + void (async () => { + const targetDocId = inferDropTargetDocId(args.targetRow); + if (!targetDocId) { + setTimeout(() => window.alert("无法识别拖拽目标页面"), 0); + return; + } + + const uniqueRowIds: string[] = []; + const seen = new Set(); + args.rowIds.forEach((id) => { + if (!id || seen.has(id)) return; + seen.add(id); + uniqueRowIds.push(id); + }); + + const rows = uniqueRowIds + .map((rowId) => fileTreeRowById.get(rowId)) + .filter(Boolean) as FileTreeRow[]; + + const docIds = rows.filter((row) => row.kind === "doc").map((row) => row.docId); + const assetRows = rows.filter((row) => row.kind === "asset") as Extract[]; + const copyableAssetIds = assetRows.filter((row) => isRealFileAsset(row.asset)).map((row) => row.asset.id); + + if (docIds.length === 0 && copyableAssetIds.length === 0) { + setTimeout(() => window.alert("没有可拖拽的对象(虚拟附件暂不支持)"), 0); + return; + } + + if (args.copy) { + if (docIds.length > 0) { + const resp = await fetch("/api/documents/copy-tree", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + items: docIds.map((documentId) => ({ documentId, recursive: true })), + targetParentId: targetDocId, + }), + }); + if (!resp.ok) { + const payload = await resp.json().catch(() => ({})); + setTimeout(() => window.alert(payload?.error ?? "复制页面失败"), 0); + return; + } + await sidebarQuery.refetch(); + emitDocumentsChanged(targetDocId); + } + + if (copyableAssetIds.length > 0) { + const resp = await fetch("/api/media/batch", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + action: "copy", + assetIds: copyableAssetIds, + targetDocumentId: targetDocId, + }), + }); + if (!resp.ok) { + const payload = await resp.json().catch(() => ({})); + setTimeout(() => window.alert(payload?.error ?? "复制附件失败"), 0); + return; + } + await sidebarQuery.refetch(); + emitAssetsChanged(targetDocId); + } + + return; + } + + const topLevelDocIds = filterTopLevelDocIds(docIds, docParentById); + if (topLevelDocIds.length > 0) { + if ( + isInvalidDocDrop({ + sourceDocIds: topLevelDocIds, + targetParentId: targetDocId, + parentById: docParentById, + }) + ) { + setTimeout(() => window.alert("不能把页面移动到自身或其子页面中"), 0); + return; + } + + const baseIndex = childrenCountByParentId.get(targetDocId) ?? 0; + setTree((prev) => { + let next = prev; + topLevelDocIds.forEach((id, offset) => { + next = moveLocalNode(next, id, targetDocId, baseIndex + offset); + }); + return next; + }); + setExpanded((prev) => new Set(prev).add(targetDocId)); + + for (let i = 0; i < topLevelDocIds.length; i += 1) { + await fetch("/api/documents/move", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + documentId: topLevelDocIds[i], + parentId: targetDocId, + position: baseIndex + i, + }), + }); + } + await refreshTree(); + emitDocumentsChanged(targetDocId); + } + + if (copyableAssetIds.length > 0) { + const resp = await fetch("/api/media/batch", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + action: "move", + assetIds: copyableAssetIds, + targetDocumentId: targetDocId, + }), + }); + if (!resp.ok) { + const payload = await resp.json().catch(() => ({})); + setTimeout(() => window.alert(payload?.error ?? "移动附件失败"), 0); + return; + } + await sidebarQuery.refetch(); + const sourceDocIds = new Set(assetRows.map((row) => row.asset.document_id)); + sourceDocIds.forEach((id) => emitAssetsChanged(id)); + emitAssetsChanged(targetDocId); + } + })(); + }, + [ + childrenCountByParentId, + docParentById, + fileTreeRowById, + moveLocalNode, + refreshTree, + sidebarQuery, + ], + ); + const handleMovePrompt = useCallback( async (node: DocumentNode) => { if (typeof window === "undefined") { @@ -1033,22 +1441,19 @@ export function Sidebar({ initialData }: SidebarProps) { ) : (
-
+
handleFileTreeRowDoubleClick(row, event)} + onRowContextMenu={handleFileTreeRowContextMenu} + onRowDragStart={(row) => handleFileTreeRowDragStart(row)} onToggleExpand={toggleExpand} - onOpenDocument={(id) => handleOpenDocument(id, "main")} - onOpenAsset={handleOpenAsset} onCreateChild={handleCreate} - onContextMenu={openContextMenu} - onAssetContextMenu={handleAssetContextMenu} - selectedAssetIds={selectedAssetIds} - onAssetClick={(asset, e) => handleSelectAsset(asset, e)} - onToggleAssetSelect={toggleAssetCheckbox} - onSelectOnlyAsset={selectOnlyAsset} + onBlankMouseDown={handleFileTreeBlankMouseDown} + onInternalDrop={handleFileTreeInternalDrop} />
@@ -1105,7 +1510,7 @@ export function Sidebar({ initialData }: SidebarProps) { onRename={() => void handleRename(contextMenu.node.id, contextMenu.node.title)} onCreateChild={() => void handleCreate(contextMenu.node.id)} onConvertChild={() => void handleConvertToChild(contextMenu.node.id)} - onDelete={() => void handleDelete(contextMenu.node.id)} + onDelete={() => void handleDeleteFileTreeSelection()} /> )} {assetMenu && ( @@ -1120,7 +1525,7 @@ export function Sidebar({ initialData }: SidebarProps) { onMove={handleMoveAsset} onDelete={(ids) => void handleDeleteAssets( - selectedAssetIds.size > 0 ? Array.from(selectedAssetIds) : ids, + selectedAssetIdsForMenu.length > 0 ? selectedAssetIdsForMenu : ids, assetMenu.asset, ) } diff --git a/wolai-frontend/src/lib/file-tree/asset.test.ts b/wolai-frontend/src/lib/file-tree/asset.test.ts new file mode 100644 index 00000000..6af94e13 --- /dev/null +++ b/wolai-frontend/src/lib/file-tree/asset.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, test } from "vitest"; +import type { MediaAsset } from "@/types/media"; +import { isRealFileAsset, parseSupabaseStorageObjectUrl } from "./asset"; + +describe("file-tree/asset", () => { + test("parseSupabaseStorageObjectUrl", () => { + expect( + parseSupabaseStorageObjectUrl( + "https://xxx.supabase.co/storage/v1/object/public/documents/a/b/c.txt", + ), + ).toEqual({ bucket: "documents", path: "a/b/c.txt" }); + expect( + parseSupabaseStorageObjectUrl( + "https://xxx.supabase.co/storage/v1/object/sign/documents/a/b/c.txt?token=abc", + ), + ).toEqual({ bucket: "documents", path: "a/b/c.txt" }); + expect(parseSupabaseStorageObjectUrl("/documents/123")).toBe(null); + }); + + test("isRealFileAsset", () => { + const base = { id: "1" } as MediaAsset; + expect(isRealFileAsset({ ...base, storage_path: "a/b", file_url: null } as any)).toBe(true); + expect(isRealFileAsset({ ...base, storage_path: null, file_url: "/documents/123" } as any)).toBe(false); + expect( + isRealFileAsset({ + ...base, + storage_path: null, + file_url: "https://xxx.supabase.co/storage/v1/object/sign/documents/a/b/c.txt?token=abc", + } as any), + ).toBe(true); + }); +}); + diff --git a/wolai-frontend/src/lib/file-tree/asset.ts b/wolai-frontend/src/lib/file-tree/asset.ts new file mode 100644 index 00000000..4e6f1b1f --- /dev/null +++ b/wolai-frontend/src/lib/file-tree/asset.ts @@ -0,0 +1,27 @@ +import type { MediaAsset } from "@/types/media"; + +export function parseSupabaseStorageObjectUrl( + fileUrl: string, +): { bucket: string; path: string } | null { + try { + const url = new URL(fileUrl); + const segments = url.pathname.split("/").filter(Boolean); + const objectIdx = segments.findIndex((seg) => seg === "object"); + if (objectIdx === -1 || objectIdx + 2 >= segments.length) return null; + const mode = segments[objectIdx + 1]; + if (mode !== "public" && mode !== "sign") return null; + const bucket = segments[objectIdx + 2]; + const p = segments.slice(objectIdx + 3).join("/"); + return p ? { bucket, path: p } : null; + } catch { + return null; + } +} + +export function isRealFileAsset(asset: MediaAsset): boolean { + if (Boolean(asset.storage_path)) return true; + const url = asset.file_url ?? ""; + if (!url.startsWith("http")) return false; + return parseSupabaseStorageObjectUrl(url) !== null; +} + diff --git a/wolai-frontend/src/lib/file-tree/clipboard.test.ts b/wolai-frontend/src/lib/file-tree/clipboard.test.ts new file mode 100644 index 00000000..ac8bc4c9 --- /dev/null +++ b/wolai-frontend/src/lib/file-tree/clipboard.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from "vitest"; +import { + decodeFileTreeClipboardPayload, + encodeFileTreeClipboardPayload, + inferPasteTargetDocId, + isTextInputTarget, +} from "./clipboard"; + +describe("file-tree clipboard payload", () => { + it("可编码/解码", () => { + const payload = { type: "mnote-file-tree", version: 1 as const, action: "copy" as const, rowIds: ["doc:a", "asset:x"] }; + const text = encodeFileTreeClipboardPayload(payload); + expect(decodeFileTreeClipboardPayload(text)).toEqual(payload); + expect(decodeFileTreeClipboardPayload("not-a-payload")).toBeNull(); + }); +}); + +describe("inferPasteTargetDocId", () => { + it("focused 为 doc/index/asset 三分支覆盖", () => { + const rowById = new Map(); + rowById.set("asset:x", { kind: "asset", rowId: "asset:x", docId: "d1", asset: { id: "x" } }); + expect(inferPasteTargetDocId({ focusedRowId: "doc:d2", rowById, activeDocId: "active" })).toBe("d2"); + expect(inferPasteTargetDocId({ focusedRowId: "index:d3", rowById, activeDocId: "active" })).toBe("d3"); + expect(inferPasteTargetDocId({ focusedRowId: "asset:x", rowById, activeDocId: "active" })).toBe("d1"); + }); + + it("无 focused 时回退 activeDocId", () => { + const rowById = new Map(); + expect(inferPasteTargetDocId({ focusedRowId: null, rowById, activeDocId: "active" })).toBe("active"); + expect(inferPasteTargetDocId({ focusedRowId: null, rowById, activeDocId: null })).toBeNull(); + }); +}); + +describe("isTextInputTarget", () => { + it("input/textarea/contenteditable 不拦截快捷键", () => { + const input = document.createElement("input"); + const textarea = document.createElement("textarea"); + const div = document.createElement("div"); + div.setAttribute("contenteditable", "true"); + expect(isTextInputTarget(input)).toBe(true); + expect(isTextInputTarget(textarea)).toBe(true); + expect(isTextInputTarget(div)).toBe(true); + expect(isTextInputTarget(document.createElement("button"))).toBe(false); + }); +}); diff --git a/wolai-frontend/src/lib/file-tree/clipboard.ts b/wolai-frontend/src/lib/file-tree/clipboard.ts new file mode 100644 index 00000000..7d751212 --- /dev/null +++ b/wolai-frontend/src/lib/file-tree/clipboard.ts @@ -0,0 +1,112 @@ +"use client"; + +import type { FileTreeRow } from "./types"; +import { parseFileTreeRowId } from "./types"; + +export type FileTreeClipboardAction = "copy"; + +export type FileTreeClipboardPayloadV1 = { + type: "mnote-file-tree"; + version: 1; + action: FileTreeClipboardAction; + rowIds: string[]; +}; + +const PREFIX = "mnote-file-tree-clipboard:v1:"; + +let memoryClipboardText: string | null = null; + +function encodeBase64(text: string): string { + const bytes = new TextEncoder().encode(text); + let binary = ""; + bytes.forEach((b) => { + binary += String.fromCharCode(b); + }); + return btoa(binary); +} + +function decodeBase64(base64: string): string { + const binary = atob(base64); + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i += 1) { + bytes[i] = binary.charCodeAt(i); + } + return new TextDecoder().decode(bytes); +} + +export function encodeFileTreeClipboardPayload(payload: FileTreeClipboardPayloadV1): string { + return `${PREFIX}${encodeBase64(JSON.stringify(payload))}`; +} + +export function decodeFileTreeClipboardPayload(text: string): FileTreeClipboardPayloadV1 | null { + if (!text || !text.startsWith(PREFIX)) return null; + const base64 = text.slice(PREFIX.length); + try { + const raw = decodeBase64(base64); + const parsed = JSON.parse(raw) as Partial; + if (parsed?.type !== "mnote-file-tree" || parsed.version !== 1) return null; + if (parsed.action !== "copy") return null; + if (!Array.isArray(parsed.rowIds) || parsed.rowIds.some((id) => typeof id !== "string")) return null; + return parsed as FileTreeClipboardPayloadV1; + } catch { + return null; + } +} + +export async function writeFileTreeClipboardPayload(payload: FileTreeClipboardPayloadV1): Promise { + const text = encodeFileTreeClipboardPayload(payload); + memoryClipboardText = text; + if (typeof navigator !== "undefined" && navigator.clipboard?.writeText) { + try { + await navigator.clipboard.writeText(text); + } catch { + // ignore, fallback to memory + } + } +} + +export async function readFileTreeClipboardPayload(): Promise { + let text: string | null = memoryClipboardText; + if (typeof navigator !== "undefined" && navigator.clipboard?.readText) { + try { + text = await navigator.clipboard.readText(); + } catch { + // ignore, fallback to memory + } + } + if (!text) return null; + return decodeFileTreeClipboardPayload(text); +} + +export function isTextInputTarget(target: EventTarget | null): boolean { + const el = target as HTMLElement | null; + if (!el) return false; + if (el.isContentEditable) return true; + const contentEditable = el.getAttribute?.("contenteditable"); + if (contentEditable && contentEditable.toLowerCase() !== "false") { + return true; + } + const tag = el.tagName?.toLowerCase(); + return tag === "input" || tag === "textarea" || el.getAttribute?.("role") === "textbox"; +} + +export function inferPasteTargetDocId({ + focusedRowId, + rowById, + activeDocId, +}: { + focusedRowId: string | null; + rowById: Map; + activeDocId: string | null; +}): string | null { + if (focusedRowId) { + const parsed = parseFileTreeRowId(focusedRowId); + if (parsed?.kind === "doc") return parsed.docId; + if (parsed?.kind === "index") return parsed.docId; + if (parsed?.kind === "asset") { + const row = rowById.get(focusedRowId); + return row?.kind === "asset" ? row.docId : null; + } + } + return activeDocId || null; +} diff --git a/wolai-frontend/src/lib/file-tree/delete.test.ts b/wolai-frontend/src/lib/file-tree/delete.test.ts new file mode 100644 index 00000000..953e2d36 --- /dev/null +++ b/wolai-frontend/src/lib/file-tree/delete.test.ts @@ -0,0 +1,93 @@ +import { describe, expect, it } from "vitest"; +import type { FileTreeRow } from "@/lib/file-tree/types"; +import { buildParentById } from "./dnd"; +import { computeFileTreeDeleteTargets } from "./delete"; + +function makeDoc(id: string, parent_id: string | null): any { + return { + id, + parent_id, + title: id, + access_scope: "private", + icon: null, + cover: null, + is_template: false, + user_id: "u1", + workspace_id: "w1", + created_at: "", + updated_at: "", + children: [], + }; +} + +describe("computeFileTreeDeleteTargets", () => { + it("去掉被父级页面覆盖的子页面", () => { + const docA = makeDoc("A", null); + const docB = makeDoc("B", "A"); + const rows: FileTreeRow[] = [ + { rowId: "doc:A", kind: "doc", docId: "A", node: docA, depth: 0, hasChildren: true, isExpanded: true }, + { rowId: "index:A", kind: "index", docId: "A", node: docA, depth: 1 }, + { rowId: "doc:B", kind: "doc", docId: "B", node: docB, depth: 1, hasChildren: false, isExpanded: false }, + { rowId: "index:B", kind: "index", docId: "B", node: docB, depth: 2 }, + ]; + const parentById = buildParentById([ + { id: "A", parentId: null }, + { id: "B", parentId: "A" }, + ]); + + const selectedRowIds = new Set(["doc:A", "doc:B"]); + const result = computeFileTreeDeleteTargets({ visibleRows: rows, selectedRowIds, parentById }); + expect(result.docIds).toEqual(["A"]); + }); + + it("选中 index 行等价于选中页面本身(去重)", () => { + const docA = makeDoc("A", null); + const rows: FileTreeRow[] = [ + { rowId: "doc:A", kind: "doc", docId: "A", node: docA, depth: 0, hasChildren: false, isExpanded: false }, + { rowId: "index:A", kind: "index", docId: "A", node: docA, depth: 1 }, + ]; + const parentById = buildParentById([{ id: "A", parentId: null }]); + const selectedRowIds = new Set(["doc:A", "index:A"]); + const result = computeFileTreeDeleteTargets({ visibleRows: rows, selectedRowIds, parentById }); + expect(result.docIds).toEqual(["A"]); + }); + + it("如果页面被删除,则跳过同页面下的附件删除(避免重复/无效操作)", () => { + const docA = makeDoc("A", null); + const rows: FileTreeRow[] = [ + { rowId: "doc:A", kind: "doc", docId: "A", node: docA, depth: 0, hasChildren: true, isExpanded: true }, + { rowId: "index:A", kind: "index", docId: "A", node: docA, depth: 1 }, + { + rowId: "asset:1", + kind: "asset", + docId: "A", + node: docA, + asset: { + id: "1", + document_id: "A", + asset_type: "file", + file_url: "https://example.com/1", + thumbnail_url: null, + bucket: "b", + storage_path: "p", + file_name: "a.txt", + file_size: 1, + mime_type: "text/plain", + ocr_payload: undefined, + ocr_strategy: null, + ocr_text: null, + ocr_status: null, + signed_url: null, + created_at: "", + updated_at: "", + }, + depth: 2, + }, + ]; + const parentById = buildParentById([{ id: "A", parentId: null }]); + const selectedRowIds = new Set(["doc:A", "asset:1"]); + const result = computeFileTreeDeleteTargets({ visibleRows: rows, selectedRowIds, parentById }); + expect(result.docIds).toEqual(["A"]); + expect(result.assetIds).toEqual([]); + }); +}); diff --git a/wolai-frontend/src/lib/file-tree/delete.ts b/wolai-frontend/src/lib/file-tree/delete.ts new file mode 100644 index 00000000..447d509c --- /dev/null +++ b/wolai-frontend/src/lib/file-tree/delete.ts @@ -0,0 +1,50 @@ +"use client"; + +import type { FileTreeRow } from "@/lib/file-tree/types"; +import { filterTopLevelDocIds } from "./dnd"; + +export type FileTreeDeleteTargets = { + docIds: string[]; + assetIds: string[]; +}; + +export function computeFileTreeDeleteTargets(args: { + visibleRows: FileTreeRow[]; + selectedRowIds: Set; + parentById: Map; +}): FileTreeDeleteTargets { + const { visibleRows, selectedRowIds, parentById } = args; + + const docCandidates: string[] = []; + const assetCandidates: string[] = []; + const assetDocIdByAssetId = new Map(); + + for (const row of visibleRows) { + if (!selectedRowIds.has(row.rowId)) continue; + + if (row.kind === "doc" || row.kind === "index") { + docCandidates.push(row.docId); + continue; + } + + if (row.kind === "asset") { + assetCandidates.push(row.asset.id); + assetDocIdByAssetId.set(row.asset.id, row.docId); + } + } + + const docIds = filterTopLevelDocIds(docCandidates, parentById); + const docIdSet = new Set(docIds); + + const seenAssets = new Set(); + const assetIds: string[] = []; + for (const assetId of assetCandidates) { + if (seenAssets.has(assetId)) continue; + seenAssets.add(assetId); + const docId = assetDocIdByAssetId.get(assetId); + if (docId && docIdSet.has(docId)) continue; + assetIds.push(assetId); + } + + return { docIds, assetIds }; +} diff --git a/wolai-frontend/src/lib/file-tree/dnd.test.ts b/wolai-frontend/src/lib/file-tree/dnd.test.ts new file mode 100644 index 00000000..5c9b90c4 --- /dev/null +++ b/wolai-frontend/src/lib/file-tree/dnd.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, test } from "vitest"; +import type { FileTreeRow } from "@/lib/file-tree/types"; +import { buildParentById, filterTopLevelDocIds, inferDropTargetDocId, isInvalidDocDrop } from "./dnd"; + +describe("file-tree/dnd", () => { + test("inferDropTargetDocId", () => { + const docRow = { kind: "doc", rowId: "doc:a", docId: "a", depth: 0, isExpanded: false, hasChildren: false, node: {} as any } as FileTreeRow; + const indexRow = { kind: "index", rowId: "index:a", docId: "a", depth: 1, node: {} as any } as FileTreeRow; + const assetRow = { kind: "asset", rowId: "asset:x", docId: "a", depth: 1, asset: {} as any } as FileTreeRow; + expect(inferDropTargetDocId(docRow)).toBe("a"); + expect(inferDropTargetDocId(indexRow)).toBe("a"); + expect(inferDropTargetDocId(assetRow)).toBe("a"); + expect(inferDropTargetDocId(null)).toBe(null); + }); + + test("filterTopLevelDocIds removes descendants", () => { + const parentById = buildParentById([ + { id: "a", parentId: null }, + { id: "b", parentId: "a" }, + { id: "c", parentId: "b" }, + { id: "d", parentId: null }, + ]); + expect(filterTopLevelDocIds(["b", "a", "c", "d"], parentById)).toEqual(["a", "d"]); + expect(filterTopLevelDocIds(["b", "c"], parentById)).toEqual(["b"]); + }); + + test("isInvalidDocDrop blocks self/descendant", () => { + const parentById = buildParentById([ + { id: "a", parentId: null }, + { id: "b", parentId: "a" }, + { id: "c", parentId: "b" }, + ]); + expect(isInvalidDocDrop({ sourceDocIds: ["a"], targetParentId: "a", parentById })).toBe(true); + expect(isInvalidDocDrop({ sourceDocIds: ["a"], targetParentId: "b", parentById })).toBe(true); + expect(isInvalidDocDrop({ sourceDocIds: ["b"], targetParentId: "a", parentById })).toBe(false); + }); +}); + diff --git a/wolai-frontend/src/lib/file-tree/dnd.ts b/wolai-frontend/src/lib/file-tree/dnd.ts new file mode 100644 index 00000000..5b52a4a7 --- /dev/null +++ b/wolai-frontend/src/lib/file-tree/dnd.ts @@ -0,0 +1,65 @@ +import type { FileTreeRow } from "@/lib/file-tree/types"; + +export function inferDropTargetDocId(targetRow: FileTreeRow | null): string | null { + if (!targetRow) return null; + return targetRow.docId ?? null; +} + +export type ParentEdge = { id: string; parentId: string | null }; + +export function buildParentById(edges: ParentEdge[]): Map { + const map = new Map(); + edges.forEach((edge) => { + map.set(edge.id, edge.parentId ?? null); + }); + return map; +} + +export function isAncestorOf( + ancestorId: string, + nodeId: string, + parentById: Map, +): boolean { + let current: string | null | undefined = nodeId; + while (current) { + const parent = parentById.get(current); + if (!parent) return false; + if (parent === ancestorId) return true; + current = parent; + } + return false; +} + +export function filterTopLevelDocIds( + docIds: string[], + parentById: Map, +): string[] { + const unique = Array.from(new Set(docIds)); + const selected = new Set(unique); + return unique.filter((id) => { + let current: string | null | undefined = id; + while (current) { + const parent = parentById.get(current); + if (!parent) return true; + if (selected.has(parent)) return false; + current = parent; + } + return true; + }); +} + +export function isInvalidDocDrop(args: { + sourceDocIds: string[]; + targetParentId: string | null; + parentById: Map; +}): boolean { + const { sourceDocIds, targetParentId, parentById } = args; + if (!targetParentId) return false; + const sources = new Set(sourceDocIds); + if (sources.has(targetParentId)) return true; + for (const sourceId of sources) { + if (isAncestorOf(sourceId, targetParentId, parentById)) return true; + } + return false; +} + diff --git a/wolai-frontend/src/lib/file-tree/naming.test.ts b/wolai-frontend/src/lib/file-tree/naming.test.ts new file mode 100644 index 00000000..86202da8 --- /dev/null +++ b/wolai-frontend/src/lib/file-tree/naming.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, test } from "vitest"; +import { makeUniqueFileName, makeUniqueTitle } from "./naming"; + +describe("file-tree/naming", () => { + test("makeUniqueTitle", () => { + const existing = new Set(["无标题", "无标题 副本"]); + expect(makeUniqueTitle("无标题", existing)).toBe("无标题 副本 2"); + expect(makeUniqueTitle("Hello", existing)).toBe("Hello"); + expect(makeUniqueTitle("Hello", existing)).toBe("Hello 副本"); + }); + + test("makeUniqueFileName keeps extension", () => { + const existing = new Set(["a.txt", "a 副本.txt"]); + expect(makeUniqueFileName("a.txt", existing)).toBe("a 副本 2.txt"); + expect(makeUniqueFileName("图片.png", existing)).toBe("图片.png"); + expect(makeUniqueFileName("图片.png", existing)).toBe("图片 副本.png"); + expect(makeUniqueFileName("无扩展名", existing)).toBe("无扩展名"); + expect(makeUniqueFileName("无扩展名", existing)).toBe("无扩展名 副本"); + }); +}); + diff --git a/wolai-frontend/src/lib/file-tree/naming.ts b/wolai-frontend/src/lib/file-tree/naming.ts new file mode 100644 index 00000000..93b9c5a2 --- /dev/null +++ b/wolai-frontend/src/lib/file-tree/naming.ts @@ -0,0 +1,56 @@ +function splitExtension(fileName: string): { base: string; ext: string } { + const safe = fileName.trim(); + const lastDot = safe.lastIndexOf("."); + if (lastDot <= 0 || lastDot === safe.length - 1) { + return { base: safe, ext: "" }; + } + return { base: safe.slice(0, lastDot), ext: safe.slice(lastDot) }; +} + +export function makeUniqueTitle(baseTitle: string, existing: Set): string { + const base = baseTitle.trim() || "无标题"; + if (!existing.has(base)) { + existing.add(base); + return base; + } + const first = `${base} 副本`; + if (!existing.has(first)) { + existing.add(first); + return first; + } + for (let i = 2; i < 1000; i += 1) { + const candidate = `${base} 副本 ${i}`; + if (!existing.has(candidate)) { + existing.add(candidate); + return candidate; + } + } + const fallback = `${base} 副本 ${Date.now()}`; + existing.add(fallback); + return fallback; +} + +export function makeUniqueFileName(fileName: string, existing: Set): string { + const safe = fileName.trim() || "附件"; + if (!existing.has(safe)) { + existing.add(safe); + return safe; + } + const { base, ext } = splitExtension(safe); + const first = `${base} 副本${ext}`; + if (!existing.has(first)) { + existing.add(first); + return first; + } + for (let i = 2; i < 1000; i += 1) { + const candidate = `${base} 副本 ${i}${ext}`; + if (!existing.has(candidate)) { + existing.add(candidate); + return candidate; + } + } + const fallback = `${base} 副本 ${Date.now()}${ext}`; + existing.add(fallback); + return fallback; +} + diff --git a/wolai-frontend/src/lib/file-tree/rows.test.ts b/wolai-frontend/src/lib/file-tree/rows.test.ts new file mode 100644 index 00000000..f20af1ca --- /dev/null +++ b/wolai-frontend/src/lib/file-tree/rows.test.ts @@ -0,0 +1,105 @@ +import { describe, expect, it } from "vitest"; +import { buildVisibleRows } from "./rows"; +import { parseFileTreeRowId } from "./types"; + +describe("buildVisibleRows", () => { + it("按展开状态稳定生成可见行", () => { + const a = { + access_scope: "private" as const, + id: "a", + workspace_id: "w", + title: "A", + parent_id: null, + sort_order: 0, + is_starred: null, + is_template: false, + created_at: "", + updated_at: null, + children: [ + { + access_scope: "private" as const, + id: "b", + workspace_id: "w", + title: "B", + parent_id: "a", + sort_order: 0, + is_starred: null, + is_template: false, + created_at: "", + updated_at: null, + children: [], + }, + ], + }; + + const rows = buildVisibleRows({ + nodes: [a], + expanded: new Set(["a"]), + assetsByDoc: { + a: [ + { + id: "x", + workspace_id: "w", + document_id: "a", + asset_type: "file", + file_url: null, + thumbnail_url: null, + bucket: "workspace", + storage_path: "x", + file_name: "x.png", + file_size: null, + mime_type: "image/png", + ocr_payload: undefined, + ocr_strategy: null, + ocr_text: null, + ocr_status: null, + signed_url: null, + created_at: "", + updated_at: "", + }, + { + id: "y", + workspace_id: "w", + document_id: "a", + asset_type: "file", + file_url: null, + thumbnail_url: null, + bucket: "workspace", + storage_path: "y", + file_name: "y.pdf", + file_size: null, + mime_type: "application/pdf", + ocr_payload: undefined, + ocr_strategy: null, + ocr_text: null, + ocr_status: null, + signed_url: null, + created_at: "", + updated_at: "", + }, + ], + }, + }); + + expect(rows.map((r) => `${r.kind}:${r.depth}:${r.rowId}`)).toEqual([ + "doc:0:doc:a", + "index:1:index:a", + "asset:1:asset:x", + "asset:1:asset:y", + "doc:1:doc:b", + ]); + + expect(new Set(rows.map((r) => r.rowId)).size).toBe(rows.length); + }); +}); + +describe("parseFileTreeRowId", () => { + it("可逆解析 rowId", () => { + expect(parseFileTreeRowId("doc:abc")).toEqual({ kind: "doc", docId: "abc" }); + expect(parseFileTreeRowId("index:abc")).toEqual({ kind: "index", docId: "abc" }); + expect(parseFileTreeRowId("asset:xyz")).toEqual({ kind: "asset", assetId: "xyz" }); + expect(parseFileTreeRowId("bad")).toBeNull(); + expect(parseFileTreeRowId("doc:")).toBeNull(); + }); +}); + diff --git a/wolai-frontend/src/lib/file-tree/rows.ts b/wolai-frontend/src/lib/file-tree/rows.ts new file mode 100644 index 00000000..d53acaf3 --- /dev/null +++ b/wolai-frontend/src/lib/file-tree/rows.ts @@ -0,0 +1,62 @@ +"use client"; + +import type { DocumentNode } from "@/lib/documents"; +import type { MediaAsset } from "@/types/media"; +import type { FileTreeRow } from "./types"; +import { makeAssetRowId, makeDocRowId, makeIndexRowId } from "./types"; + +export function buildVisibleRows({ + nodes, + expanded, + assetsByDoc, +}: { + nodes: DocumentNode[]; + expanded: Set; + assetsByDoc: Record; +}): FileTreeRow[] { + const rows: FileTreeRow[] = []; + + const walk = (node: DocumentNode, depth: number) => { + const assets = assetsByDoc[node.id] ?? []; + const hasChildren = node.children.length > 0 || assets.length > 0; + const isExpanded = expanded.has(node.id); + rows.push({ + kind: "doc", + rowId: makeDocRowId(node.id), + depth, + docId: node.id, + parentDocId: node.parent_id, + node, + hasChildren, + isExpanded, + }); + + if (!isExpanded) return; + + rows.push({ + kind: "index", + rowId: makeIndexRowId(node.id), + depth: depth + 1, + docId: node.id, + parentDocId: node.id, + node, + }); + + assets.forEach((asset) => { + rows.push({ + kind: "asset", + rowId: makeAssetRowId(asset.id), + depth: depth + 1, + docId: node.id, + parentDocId: node.id, + asset, + }); + }); + + node.children.forEach((child) => walk(child, depth + 1)); + }; + + nodes.forEach((node) => walk(node, 0)); + return rows; +} + diff --git a/wolai-frontend/src/lib/file-tree/selection.test.ts b/wolai-frontend/src/lib/file-tree/selection.test.ts new file mode 100644 index 00000000..1ed78621 --- /dev/null +++ b/wolai-frontend/src/lib/file-tree/selection.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, it } from "vitest"; +import { reduceFileTreeSelection } from "./selection"; + +describe("reduceFileTreeSelection", () => { + const visible = ["a", "b", "c", "d"]; + + it("单击:清空并仅选中当前;更新 anchor/focus", () => { + const next = reduceFileTreeSelection( + { selectedRowIds: new Set(["x"]), anchorRowId: "x", focusedRowId: "x" }, + { type: "click", rowId: "b", visibleRowIds: visible, modifiers: {} }, + ); + expect(Array.from(next.selectedRowIds)).toEqual(["b"]); + expect(next.anchorRowId).toBe("b"); + expect(next.focusedRowId).toBe("b"); + }); + + it("Ctrl/Cmd+单击:切换选中;不清空其他", () => { + const next = reduceFileTreeSelection( + { selectedRowIds: new Set(["b"]), anchorRowId: "b", focusedRowId: "b" }, + { type: "click", rowId: "d", visibleRowIds: visible, modifiers: { ctrlKey: true } }, + ); + expect(next.selectedRowIds.has("b")).toBe(true); + expect(next.selectedRowIds.has("d")).toBe(true); + }); + + it("Shift+单击:按 visibleRows 做区间选择(覆盖式)", () => { + const next = reduceFileTreeSelection( + { selectedRowIds: new Set(["b"]), anchorRowId: "b", focusedRowId: "b" }, + { type: "click", rowId: "d", visibleRowIds: visible, modifiers: { shiftKey: true } }, + ); + expect(Array.from(next.selectedRowIds)).toEqual(["b", "c", "d"]); + expect(next.focusedRowId).toBe("d"); + }); + + it("右键:未选中项右键 → 先切为单选;已选中项 → 保持多选集合", () => { + const a = reduceFileTreeSelection( + { selectedRowIds: new Set(["b", "c"]), anchorRowId: "b", focusedRowId: "c" }, + { type: "contextmenu", rowId: "d" }, + ); + expect(Array.from(a.selectedRowIds)).toEqual(["d"]); + + const b = reduceFileTreeSelection( + { selectedRowIds: new Set(["b", "c"]), anchorRowId: "b", focusedRowId: "c" }, + { type: "contextmenu", rowId: "c" }, + ); + expect(Array.from(b.selectedRowIds).sort()).toEqual(["b", "c"]); + expect(b.focusedRowId).toBe("c"); + }); + + it("空白处单击清空选择", () => { + const next = reduceFileTreeSelection( + { selectedRowIds: new Set(["b"]), anchorRowId: "b", focusedRowId: "b" }, + { type: "clear" }, + ); + expect(next.selectedRowIds.size).toBe(0); + expect(next.anchorRowId).toBeNull(); + expect(next.focusedRowId).toBeNull(); + }); +}); + diff --git a/wolai-frontend/src/lib/file-tree/selection.ts b/wolai-frontend/src/lib/file-tree/selection.ts new file mode 100644 index 00000000..2d057528 --- /dev/null +++ b/wolai-frontend/src/lib/file-tree/selection.ts @@ -0,0 +1,84 @@ +"use client"; + +export type FileTreeModifierKeys = { + shiftKey?: boolean; + metaKey?: boolean; + ctrlKey?: boolean; +}; + +export interface FileTreeSelectionState { + selectedRowIds: Set; + anchorRowId: string | null; + focusedRowId: string | null; +} + +export type FileTreeSelectionAction = + | { type: "clear" } + | { + type: "click"; + rowId: string; + visibleRowIds: string[]; + modifiers: FileTreeModifierKeys; + } + | { type: "contextmenu"; rowId: string }; + +function getRangeRowIds(visibleRowIds: string[], fromId: string, toId: string): string[] { + const fromIndex = visibleRowIds.indexOf(fromId); + const toIndex = visibleRowIds.indexOf(toId); + if (fromIndex < 0 || toIndex < 0) return [toId]; + const lo = Math.min(fromIndex, toIndex); + const hi = Math.max(fromIndex, toIndex); + return visibleRowIds.slice(lo, hi + 1); +} + +export function reduceFileTreeSelection( + prev: FileTreeSelectionState, + action: FileTreeSelectionAction, +): FileTreeSelectionState { + switch (action.type) { + case "clear": + return { selectedRowIds: new Set(), anchorRowId: null, focusedRowId: null }; + case "contextmenu": { + if (prev.selectedRowIds.has(action.rowId)) { + return { ...prev, focusedRowId: action.rowId }; + } + return { + selectedRowIds: new Set([action.rowId]), + anchorRowId: action.rowId, + focusedRowId: action.rowId, + }; + } + case "click": { + const { rowId, visibleRowIds, modifiers } = action; + const withMeta = Boolean(modifiers.metaKey); + const withCtrl = Boolean(modifiers.ctrlKey); + const withShift = Boolean(modifiers.shiftKey); + const withToggle = withMeta || withCtrl; + + if (withShift) { + const anchor = prev.anchorRowId ?? prev.focusedRowId ?? rowId; + const range = getRangeRowIds(visibleRowIds, anchor, rowId); + const next = withToggle ? new Set(prev.selectedRowIds) : new Set(); + range.forEach((id) => next.add(id)); + return { + selectedRowIds: next, + anchorRowId: prev.anchorRowId ?? anchor, + focusedRowId: rowId, + }; + } + + if (withToggle) { + const next = new Set(prev.selectedRowIds); + if (next.has(rowId)) { + next.delete(rowId); + } else { + next.add(rowId); + } + return { selectedRowIds: next, anchorRowId: rowId, focusedRowId: rowId }; + } + + return { selectedRowIds: new Set([rowId]), anchorRowId: rowId, focusedRowId: rowId }; + } + } +} + diff --git a/wolai-frontend/src/lib/file-tree/types.ts b/wolai-frontend/src/lib/file-tree/types.ts new file mode 100644 index 00000000..9dc12654 --- /dev/null +++ b/wolai-frontend/src/lib/file-tree/types.ts @@ -0,0 +1,93 @@ +"use client"; + +import type { DocumentNode } from "@/lib/documents"; +import type { MediaAsset } from "@/types/media"; + +export type FileTreeRowKind = "doc" | "index" | "asset"; + +export type FileTreeRowId = `doc:${string}` | `index:${string}` | `asset:${string}`; + +export type ParsedFileTreeRowId = + | { kind: "doc"; docId: string } + | { kind: "index"; docId: string } + | { kind: "asset"; assetId: string }; + +export function makeDocRowId(docId: string): FileTreeRowId { + return `doc:${docId}`; +} + +export function makeIndexRowId(docId: string): FileTreeRowId { + return `index:${docId}`; +} + +export function makeAssetRowId(assetId: string): FileTreeRowId { + return `asset:${assetId}`; +} + +export function parseFileTreeRowId(rowId: string): ParsedFileTreeRowId | null { + const idx = rowId.indexOf(":"); + if (idx <= 0) return null; + const prefix = rowId.slice(0, idx); + const rest = rowId.slice(idx + 1); + if (!rest) return null; + switch (prefix) { + case "doc": + return { kind: "doc", docId: rest }; + case "index": + return { kind: "index", docId: rest }; + case "asset": + return { kind: "asset", assetId: rest }; + default: + return null; + } +} + +export type FileTreeRow = + | { + kind: "doc"; + rowId: FileTreeRowId; + depth: number; + docId: string; + parentDocId: string | null; + node: DocumentNode; + hasChildren: boolean; + isExpanded: boolean; + } + | { + kind: "index"; + rowId: FileTreeRowId; + depth: number; + docId: string; + parentDocId: string; + node: DocumentNode; + } + | { + kind: "asset"; + rowId: FileTreeRowId; + depth: number; + docId: string; + parentDocId: string; + asset: MediaAsset; + }; + +export function getFileTreeRowLabel(row: FileTreeRow): string { + switch (row.kind) { + case "doc": + return row.node.title || "无标题"; + case "index": + return "index.md"; + case "asset": + return row.asset.file_name || "附件"; + } +} + +export function getOwningDocId(row: FileTreeRow): string { + switch (row.kind) { + case "doc": + case "index": + return row.docId; + case "asset": + return row.asset.document_id; + } +} + diff --git a/wolai-frontend/src/types/simple-mind-map-internals.d.ts b/wolai-frontend/src/types/simple-mind-map-internals.d.ts new file mode 100644 index 00000000..a5157ef4 --- /dev/null +++ b/wolai-frontend/src/types/simple-mind-map-internals.d.ts @@ -0,0 +1,124 @@ +// 说明: +// `simple-mind-map` 的 `src/*` 属于内部实现路径,npm 包通常不提供 TypeScript 声明文件。 +// 但在项目中我们需要按官方示例从 `src/plugins/*` 等路径引入插件,因此在此补充最小声明, +// 仅用于消除 TS7016(找不到声明文件)报错。 +// +// 注意:这里的类型刻意保持宽松(unknown),避免把内部 API 当作稳定公共接口依赖。 + +declare module "simple-mind-map/src/svg/icons.js" { + export const nodeIconList: unknown[]; +} + +declare module "simple-mind-map/src/utils/index.js" { + export const mergerIconList: (icons: unknown[]) => unknown[]; + export const createUid: () => string; +} + +declare module "simple-mind-map/src/plugins/Painter.js" { + const plugin: unknown; + export default plugin; +} +declare module "simple-mind-map/src/plugins/AssociativeLine.js" { + const plugin: unknown; + export default plugin; +} +declare module "simple-mind-map/src/plugins/OuterFrame.js" { + const plugin: unknown; + export default plugin; +} +declare module "simple-mind-map/src/plugins/Export.js" { + const plugin: unknown; + export default plugin; +} +declare module "simple-mind-map/src/plugins/Formula.js" { + const plugin: unknown; + export default plugin; +} +declare module "simple-mind-map/src/plugins/RichText.js" { + const plugin: unknown; + export default plugin; +} +declare module "simple-mind-map/src/plugins/MiniMap.js" { + const plugin: unknown; + export default plugin; +} +declare module "simple-mind-map/src/plugins/Select.js" { + const plugin: unknown; + export default plugin; +} +declare module "simple-mind-map/src/plugins/Drag.js" { + const plugin: unknown; + export default plugin; +} +declare module "simple-mind-map/src/plugins/KeyboardNavigation.js" { + const plugin: unknown; + export default plugin; +} +declare module "simple-mind-map/src/plugins/NodeImgAdjust.js" { + const plugin: unknown; + export default plugin; +} +declare module "simple-mind-map/src/plugins/Scrollbar.js" { + const plugin: unknown; + export default plugin; +} +declare module "simple-mind-map/src/plugins/RainbowLines.js" { + const plugin: unknown; + export default plugin; +} +declare module "simple-mind-map/src/plugins/Watermark.js" { + const plugin: unknown; + export default plugin; +} +declare module "simple-mind-map/src/plugins/TouchEvent.js" { + const plugin: unknown; + export default plugin; +} +declare module "simple-mind-map/src/plugins/Cooperate.js" { + const plugin: unknown; + export default plugin; +} +declare module "simple-mind-map/src/plugins/Demonstrate.js" { + const plugin: unknown; + export default plugin; +} +declare module "simple-mind-map/src/plugins/MindMapLayoutPro.js" { + const plugin: unknown; + export default plugin; +} +declare module "simple-mind-map/src/plugins/NodeBase64ImageStorage.js" { + const plugin: unknown; + export default plugin; +} +declare module "simple-mind-map/src/plugins/ExportPDF.js" { + const plugin: unknown; + export default plugin; +} +declare module "simple-mind-map/src/plugins/ExportXMind.js" { + const plugin: unknown; + export default plugin; +} + +declare module "simple-mind-map/src/core/render/node/MindMapNode.js" { + const MindMapNode: unknown; + export default MindMapNode; +} + +declare module "simple-mind-map/src/parse/xmind.js" { + const xmind: { + parseXmindFile: ( + file: Blob | ArrayBuffer | Uint8Array, + handleMultiCanvas?: (content: unknown[]) => unknown, + ) => Promise; + }; + export default xmind; +} + +declare module "simple-mind-map/src/parse/markdownTo.js" { + export type MarkdownToMindmapResult = { + data?: Record; + children?: unknown[]; + [key: string]: unknown; + }; + export const transformMarkdownTo: (md: string) => MarkdownToMindmapResult; +} diff --git a/wolai-frontend/src/types/window-debug.d.ts b/wolai-frontend/src/types/window-debug.d.ts new file mode 100644 index 00000000..90e1140c --- /dev/null +++ b/wolai-frontend/src/types/window-debug.d.ts @@ -0,0 +1,12 @@ +export {}; + +declare global { + interface Window { + /** + * 仅用于开发调试:在控制台访问当前思维导图实例。 + * 生产环境不依赖该字段。 + */ + __mindmapInstance?: unknown | null; + } +} +