0.1.04 文件树与导入mmap
This commit is contained in:
@@ -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:<id>` / `index:<docId>` / `asset:<assetId>`
|
||||||
|
|
||||||
|
**新增/改造文件**
|
||||||
|
- [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<string>`
|
||||||
|
- [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] 多选后右键任一已选行 → 点击“删除到垃圾桶”,应删除整个选择集(而不是仅最后右键项)
|
||||||
@@ -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`:文件资源通用类型
|
||||||
|
|
||||||
@@ -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<string | null, DocRow[]>, 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<string, { id: string; signedUrl: string }>, 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<string, unknown>;
|
||||||
|
const next: Record<string, unknown> = {};
|
||||||
|
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<string, unknown>;
|
||||||
|
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<string, DocRow>();
|
||||||
|
(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<string | null, DocRow[]>();
|
||||||
|
(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<string | null, Set<string>>();
|
||||||
|
const seedTitleSet = (parent: string | null) => {
|
||||||
|
if (existingTitleSetByParent.has(parent)) return;
|
||||||
|
const titles = new Set<string>();
|
||||||
|
(childrenByParent.get(parent) ?? []).forEach((d) => titles.add(normalizeTitle(d.title)));
|
||||||
|
existingTitleSetByParent.set(parent, titles);
|
||||||
|
};
|
||||||
|
seedTitleSet(targetParentId);
|
||||||
|
|
||||||
|
const newIdByOldId = new Map<string, string>();
|
||||||
|
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<string | null, number>([[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<string>();
|
||||||
|
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<string, Map<string, { id: string; signedUrl: string }>>();
|
||||||
|
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<string, { id: string; signedUrl: string }>();
|
||||||
|
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 })),
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { createSupabaseRouteClient } from "@/lib/supabase/server";
|
import { createSupabaseRouteClient } from "@/lib/supabase/server";
|
||||||
|
import { extname } from "path";
|
||||||
|
import { makeUniqueFileName } from "@/lib/file-tree/naming";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
@@ -13,6 +15,39 @@ interface BatchPayload {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const BUCKET = process.env.NEXT_PUBLIC_SUPABASE_MEDIA_BUCKET ?? "workspace";
|
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) {
|
export async function POST(request: Request) {
|
||||||
const supabase = await createSupabaseRouteClient();
|
const supabase = await createSupabaseRouteClient();
|
||||||
@@ -47,9 +82,9 @@ export async function POST(request: Request) {
|
|||||||
case "delete": {
|
case "delete": {
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
assets.map(async (asset) => {
|
assets.map(async (asset) => {
|
||||||
if (asset.storage_path) {
|
const location = resolveAssetLocation(asset);
|
||||||
await supabase.storage.from(asset.bucket || BUCKET).remove([asset.storage_path]);
|
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);
|
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 asset = assets[0];
|
||||||
const ext = asset.file_name?.includes(".") ? `.${asset.file_name.split(".").pop()}` : "";
|
const ext = asset.file_name?.includes(".") ? `.${asset.file_name.split(".").pop()}` : "";
|
||||||
const newFileName = payload.newName.includes(".") || !ext ? payload.newName : `${payload.newName}${ext}`;
|
const newFileName = payload.newName.includes(".") || !ext ? payload.newName : `${payload.newName}${ext}`;
|
||||||
const targetPath = `${session.user.id}/${asset.workspace_id}/${asset.document_id}/assets/${newFileName}`;
|
const location = resolveAssetLocation(asset);
|
||||||
if (asset.storage_path) {
|
const safeName = newFileName.replace(/[\\/]/g, "_");
|
||||||
const moveResult = await supabase.storage
|
if (location) {
|
||||||
.from(asset.bucket || BUCKET)
|
const uniqueId =
|
||||||
.move(asset.storage_path, targetPath);
|
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;
|
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
|
const { error } = await supabase
|
||||||
.from("media_assets")
|
.from("media_assets")
|
||||||
.update({
|
.update({
|
||||||
file_name: newFileName,
|
file_name: safeName,
|
||||||
storage_path: targetPath,
|
|
||||||
file_url: null,
|
|
||||||
thumbnail_url: null,
|
|
||||||
})
|
})
|
||||||
.eq("id", asset.id);
|
.eq("id", asset.id);
|
||||||
if (error) throw error;
|
if (error) throw error;
|
||||||
@@ -95,16 +146,27 @@ export async function POST(request: Request) {
|
|||||||
if (docErr || !targetDoc) {
|
if (docErr || !targetDoc) {
|
||||||
return NextResponse.json({ error: "目标页面不存在" }, { status: 404 });
|
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<string>((existingRows ?? []).map((r) => (r.file_name ?? "").toString()).filter(Boolean));
|
||||||
const results = [];
|
const results = [];
|
||||||
for (const asset of assets) {
|
for (const asset of assets) {
|
||||||
const fileName = asset.file_name ?? "附件";
|
const location = resolveAssetLocation(asset);
|
||||||
const targetPath = `${session.user.id}/${targetDoc.workspace_id}/${payload.targetDocumentId}/assets/${fileName}`;
|
if (!location) continue;
|
||||||
const sourcePath = asset.storage_path;
|
const fileName = makeUniqueFileName(asset.file_name ?? "附件", existingNames).replace(/[\\/]/g, "_");
|
||||||
const bucket = asset.bucket || BUCKET;
|
const uniqueId =
|
||||||
if (!sourcePath) continue;
|
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") {
|
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;
|
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
|
const { data: inserted, error } = await supabase
|
||||||
.from("media_assets")
|
.from("media_assets")
|
||||||
.insert({
|
.insert({
|
||||||
@@ -116,6 +178,8 @@ export async function POST(request: Request) {
|
|||||||
mime_type: asset.mime_type,
|
mime_type: asset.mime_type,
|
||||||
bucket,
|
bucket,
|
||||||
storage_path: targetPath,
|
storage_path: targetPath,
|
||||||
|
file_url: signedUrl,
|
||||||
|
thumbnail_url: signedUrl,
|
||||||
created_by: session.user.id,
|
created_by: session.user.id,
|
||||||
})
|
})
|
||||||
.select("*")
|
.select("*")
|
||||||
@@ -123,8 +187,10 @@ export async function POST(request: Request) {
|
|||||||
if (error) throw error;
|
if (error) throw error;
|
||||||
results.push(inserted);
|
results.push(inserted);
|
||||||
} else {
|
} 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;
|
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
|
const { data: updated, error } = await supabase
|
||||||
.from("media_assets")
|
.from("media_assets")
|
||||||
.update({
|
.update({
|
||||||
@@ -132,8 +198,9 @@ export async function POST(request: Request) {
|
|||||||
document_id: payload.targetDocumentId,
|
document_id: payload.targetDocumentId,
|
||||||
storage_path: targetPath,
|
storage_path: targetPath,
|
||||||
file_name: fileName,
|
file_name: fileName,
|
||||||
file_url: null,
|
bucket,
|
||||||
thumbnail_url: null,
|
file_url: signedUrl,
|
||||||
|
thumbnail_url: signedUrl,
|
||||||
})
|
})
|
||||||
.eq("id", asset.id)
|
.eq("id", asset.id)
|
||||||
.select("*")
|
.select("*")
|
||||||
|
|||||||
@@ -61,6 +61,8 @@ export async function POST(request: Request) {
|
|||||||
document_id: documentId,
|
document_id: documentId,
|
||||||
file_url: signedUrl,
|
file_url: signedUrl,
|
||||||
thumbnail_url: signedUrl,
|
thumbnail_url: signedUrl,
|
||||||
|
bucket: DOC_BUCKET,
|
||||||
|
storage_path: path,
|
||||||
asset_type: assetType,
|
asset_type: assetType,
|
||||||
file_name: file.name,
|
file_name: file.name,
|
||||||
file_size: file.size,
|
file_size: file.size,
|
||||||
|
|||||||
@@ -464,10 +464,9 @@ const MindmapBlockView = ({
|
|||||||
const onFsChange = () => {
|
const onFsChange = () => {
|
||||||
const active = Boolean(document.fullscreenElement);
|
const active = Boolean(document.fullscreenElement);
|
||||||
setFullscreenApiActive(active);
|
setFullscreenApiActive(active);
|
||||||
// 用户按 ESC 退出浏览器全屏时,同步退出沉浸式全屏界面
|
// 注意:浏览器在打开原生对话框(例如 file picker / alert / confirm)时可能会自动退出
|
||||||
if (!active && localFullscreen) {
|
// Fullscreen API。此时不应退出“沉浸式全屏”UI,否则会导致用户在全屏编辑中执行导入/新建
|
||||||
setLocalFullscreen(false);
|
// 等操作时被强制退出全屏。
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
document.addEventListener("fullscreenchange", onFsChange);
|
document.addEventListener("fullscreenchange", onFsChange);
|
||||||
@@ -1442,6 +1441,16 @@ const MindmapBlockView = ({
|
|||||||
return;
|
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
|
// Markdown
|
||||||
if (ext === "md" || ext === "markdown") {
|
if (ext === "md" || ext === "markdown") {
|
||||||
const { transformMarkdownTo } = await import(
|
const { transformMarkdownTo } = await import(
|
||||||
@@ -1458,7 +1467,7 @@ const MindmapBlockView = ({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
window.alert("暂不支持该文件类型,请选择 .json / .smm / .xmind / .md");
|
window.alert("暂不支持该文件类型,请选择 .json / .smm / .xmind / .mmap / .md");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
window.alert("导入失败:文件格式或内容错误");
|
window.alert("导入失败:文件格式或内容错误");
|
||||||
@@ -1798,7 +1807,7 @@ const MindmapBlockView = ({
|
|||||||
>
|
>
|
||||||
<div className="fixed left-0 right-0 top-0 z-30 flex h-12 items-center justify-between border-b border-gray-200 bg-white/90 px-4 backdrop-blur">
|
<div className="fixed left-0 right-0 top-0 z-30 flex h-12 items-center justify-between border-b border-gray-200 bg-white/90 px-4 backdrop-blur">
|
||||||
<div className="text-sm font-medium text-gray-700">
|
<div className="text-sm font-medium text-gray-700">
|
||||||
思维导图编辑{fullscreenApiActive ? "(全屏)" : ""}
|
思维导图编辑(全屏{fullscreenApiActive ? "·真" : ""})
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -209,7 +209,7 @@ export const MindmapToolbar = ({
|
|||||||
<input
|
<input
|
||||||
ref={fileInputRef}
|
ref={fileInputRef}
|
||||||
type="file"
|
type="file"
|
||||||
accept=".json,.smm,.xmind,.md,.markdown,application/json"
|
accept=".json,.smm,.xmind,.mmap,.md,.markdown,application/json,application/vnd.mindjet.mindmanager,application/x-mindmanager"
|
||||||
className="hidden"
|
className="hidden"
|
||||||
onChange={onImport}
|
onChange={onImport}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -0,0 +1,233 @@
|
|||||||
|
type MindMapData = {
|
||||||
|
data: Record<string, unknown>;
|
||||||
|
children?: MindMapData[];
|
||||||
|
};
|
||||||
|
|
||||||
|
const readBlobAsDataUrl = (blob: Blob): Promise<string> =>
|
||||||
|
new Promise((resolve, reject) => {
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = () => resolve(String(reader.result || ""));
|
||||||
|
reader.onerror = (err) => reject(err);
|
||||||
|
reader.readAsDataURL(blob);
|
||||||
|
});
|
||||||
|
|
||||||
|
const getImageSizeFromBlob = async (blob: Blob): Promise<{ width: number; height: number } | null> => {
|
||||||
|
try {
|
||||||
|
if (typeof createImageBitmap === "function") {
|
||||||
|
const bmp = await createImageBitmap(blob);
|
||||||
|
const res = { width: bmp.width, height: bmp.height };
|
||||||
|
try {
|
||||||
|
bmp.close();
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const img = new Image();
|
||||||
|
img.onload = () => {
|
||||||
|
const res = { width: img.naturalWidth, height: img.naturalHeight };
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
resolve(res);
|
||||||
|
};
|
||||||
|
img.onerror = () => {
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
resolve(null);
|
||||||
|
};
|
||||||
|
img.src = url;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const walkElements = (root: Element): Element[] => {
|
||||||
|
const stack: Element[] = [root];
|
||||||
|
const out: Element[] = [];
|
||||||
|
while (stack.length) {
|
||||||
|
const el = stack.pop()!;
|
||||||
|
out.push(el);
|
||||||
|
for (let i = el.children.length - 1; i >= 0; i--) {
|
||||||
|
stack.push(el.children[i] as Element);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
};
|
||||||
|
|
||||||
|
const findFirstByLocalName = (root: Element, name: string): Element | null => {
|
||||||
|
for (const el of walkElements(root)) {
|
||||||
|
if (el.localName === name) return el;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const findFirstChildByLocalName = (root: Element, name: string): Element | null => {
|
||||||
|
for (const el of Array.from(root.children)) {
|
||||||
|
if ((el as Element).localName === name) return el as Element;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const findChildrenByLocalName = (root: Element, name: string): Element[] =>
|
||||||
|
Array.from(root.children).filter((el) => (el as Element).localName === name) as Element[];
|
||||||
|
|
||||||
|
const getTopicPlainText = (topicEl: Element): string => {
|
||||||
|
const textEl = findFirstChildByLocalName(topicEl, "Text");
|
||||||
|
const raw = textEl?.getAttribute("PlainText") ?? "";
|
||||||
|
return raw;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getTopicHyperlink = (topicEl: Element): { url: string; title: string } | null => {
|
||||||
|
const linkEl = findFirstChildByLocalName(topicEl, "Hyperlink");
|
||||||
|
if (!linkEl) return null;
|
||||||
|
const url = linkEl.getAttribute("Url") ?? "";
|
||||||
|
const title = linkEl.getAttribute("Title") ?? "";
|
||||||
|
if (!url) return null;
|
||||||
|
return { url, title };
|
||||||
|
};
|
||||||
|
|
||||||
|
const guessMimeFromImageType = (imageType: string): string => {
|
||||||
|
const t = (imageType || "").toLowerCase();
|
||||||
|
if (t.includes("png")) return "image/png";
|
||||||
|
if (t.includes("jpeg") || t.includes("jpg")) return "image/jpeg";
|
||||||
|
if (t.includes("gif")) return "image/gif";
|
||||||
|
if (t.includes("bmp")) return "image/bmp";
|
||||||
|
if (t.includes("svg")) return "image/svg+xml";
|
||||||
|
return "application/octet-stream";
|
||||||
|
};
|
||||||
|
|
||||||
|
const parseMmArchiveUriToZipPath = (uri: string): string | null => {
|
||||||
|
// 常见格式:mmarch://bin/<uuid>.bin
|
||||||
|
const raw = String(uri || "");
|
||||||
|
const lower = raw.toLowerCase();
|
||||||
|
const prefix = "mmarch://";
|
||||||
|
if (!lower.startsWith(prefix)) return null;
|
||||||
|
let rest = raw.slice(prefix.length);
|
||||||
|
while (rest.startsWith("/")) rest = rest.slice(1);
|
||||||
|
return rest || null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getTopicImageInfo = (topicEl: Element): { uri: string; mime: string } | null => {
|
||||||
|
const oneImage = findFirstChildByLocalName(topicEl, "OneImage");
|
||||||
|
if (!oneImage) return null;
|
||||||
|
const imageEl = findFirstByLocalName(oneImage, "Image");
|
||||||
|
if (!imageEl) return null;
|
||||||
|
const imageDataEl = findFirstByLocalName(imageEl, "ImageData");
|
||||||
|
const uriEl = imageEl ? findFirstByLocalName(imageEl, "Uri") : null;
|
||||||
|
const uri = (uriEl?.textContent ?? "").trim();
|
||||||
|
if (!uri) return null;
|
||||||
|
const mime = guessMimeFromImageType(imageDataEl?.getAttribute("ImageType") ?? "");
|
||||||
|
return { uri, mime };
|
||||||
|
};
|
||||||
|
|
||||||
|
const compactTree = (node: MindMapData, isRoot = false): MindMapData => {
|
||||||
|
const children = (node.children ?? []).map((c) => compactTree(c, false));
|
||||||
|
const data = node.data ?? {};
|
||||||
|
const hasContent =
|
||||||
|
Boolean(String(data.text ?? "").trim()) ||
|
||||||
|
Boolean(String((data as Record<string, unknown>).hyperlink ?? "").trim()) ||
|
||||||
|
Boolean(String((data as Record<string, unknown>).note ?? "").trim()) ||
|
||||||
|
Boolean(String((data as Record<string, unknown>).image ?? "").trim()) ||
|
||||||
|
Boolean(((data as Record<string, unknown>).tag as unknown[] | undefined)?.length) ||
|
||||||
|
Boolean(String((data as Record<string, unknown>).attachmentUrl ?? "").trim());
|
||||||
|
if (!isRoot && !hasContent && children.length === 1) {
|
||||||
|
return children[0];
|
||||||
|
}
|
||||||
|
return { ...node, children };
|
||||||
|
};
|
||||||
|
|
||||||
|
export const parseMindManagerMmapFile = async (file: File): Promise<MindMapData> => {
|
||||||
|
const JSZip = (await import("jszip")).default;
|
||||||
|
const zip = await JSZip.loadAsync(file);
|
||||||
|
|
||||||
|
const xmlFile = zip.file("Document.xml");
|
||||||
|
if (!xmlFile) {
|
||||||
|
throw new Error("Document.xml 不存在");
|
||||||
|
}
|
||||||
|
const xmlText = await xmlFile.async("string");
|
||||||
|
const xmlDoc = new DOMParser().parseFromString(xmlText, "application/xml");
|
||||||
|
if (xmlDoc.getElementsByTagName("parsererror").length > 0) {
|
||||||
|
throw new Error("Document.xml 解析失败");
|
||||||
|
}
|
||||||
|
const docEl = xmlDoc.documentElement;
|
||||||
|
if (!docEl) throw new Error("Document.xml 内容为空");
|
||||||
|
|
||||||
|
const defaultsGroup = findFirstByLocalName(docEl, "RootTopicDefaultsGroup");
|
||||||
|
const defaultTextEl = defaultsGroup ? findFirstByLocalName(defaultsGroup, "DefaultText") : null;
|
||||||
|
const defaultRootText = defaultTextEl?.getAttribute("PlainText") ?? "中心主题";
|
||||||
|
|
||||||
|
const oneTopicEl = findFirstByLocalName(docEl, "OneTopic");
|
||||||
|
const rootTopicEl = oneTopicEl ? findFirstChildByLocalName(oneTopicEl, "Topic") : null;
|
||||||
|
if (!rootTopicEl) {
|
||||||
|
throw new Error("未找到根主题");
|
||||||
|
}
|
||||||
|
|
||||||
|
const binCache = new Map<string, { dataUrl: string; width: number; height: number }>();
|
||||||
|
|
||||||
|
const resolveBinToImageDataUrl = async (
|
||||||
|
uri: string,
|
||||||
|
mime: string,
|
||||||
|
): Promise<{ dataUrl: string; width: number; height: number } | null> => {
|
||||||
|
const zipPath = parseMmArchiveUriToZipPath(uri);
|
||||||
|
if (!zipPath) return null;
|
||||||
|
if (binCache.has(zipPath)) return binCache.get(zipPath)!;
|
||||||
|
|
||||||
|
const entry = zip.file(zipPath) ?? zip.file(`/${zipPath}`);
|
||||||
|
if (!entry) return null;
|
||||||
|
const bytes = await entry.async("uint8array");
|
||||||
|
const blob = new Blob([bytes], { type: mime });
|
||||||
|
const dataUrl = await readBlobAsDataUrl(blob);
|
||||||
|
const size = await getImageSizeFromBlob(blob);
|
||||||
|
const width = size?.width ?? 0;
|
||||||
|
const height = size?.height ?? 0;
|
||||||
|
const res = { dataUrl, width, height };
|
||||||
|
binCache.set(zipPath, res);
|
||||||
|
return res;
|
||||||
|
};
|
||||||
|
|
||||||
|
const walkTopic = async (topicEl: Element, isRoot: boolean): Promise<MindMapData> => {
|
||||||
|
const text = getTopicPlainText(topicEl);
|
||||||
|
const hyperlink = getTopicHyperlink(topicEl);
|
||||||
|
const imageInfo = getTopicImageInfo(topicEl);
|
||||||
|
const nodeData: Record<string, unknown> = {
|
||||||
|
text: text ?? "",
|
||||||
|
};
|
||||||
|
|
||||||
|
if (hyperlink?.url) {
|
||||||
|
nodeData.hyperlink = hyperlink.url;
|
||||||
|
if (hyperlink.title) nodeData.hyperlinkTitle = hyperlink.title;
|
||||||
|
// MindManager 常见:文本为空但带链接标题,作为节点可见文本更符合导入预期
|
||||||
|
if (!String(nodeData.text || "").trim() && hyperlink.title) {
|
||||||
|
nodeData.text = hyperlink.title;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (imageInfo?.uri) {
|
||||||
|
const resolved = await resolveBinToImageDataUrl(imageInfo.uri, imageInfo.mime);
|
||||||
|
if (resolved?.dataUrl) {
|
||||||
|
nodeData.image = resolved.dataUrl;
|
||||||
|
nodeData.imageSize = {
|
||||||
|
width: resolved.width || 0,
|
||||||
|
height: resolved.height || 0,
|
||||||
|
custom: true,
|
||||||
|
};
|
||||||
|
nodeData.imgPlacement = "top";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isRoot && !String(nodeData.text || "").trim()) {
|
||||||
|
nodeData.text = defaultRootText || "中心主题";
|
||||||
|
}
|
||||||
|
|
||||||
|
const subTopicsEl = findFirstChildByLocalName(topicEl, "SubTopics");
|
||||||
|
const childTopicEls = subTopicsEl ? findChildrenByLocalName(subTopicsEl, "Topic") : [];
|
||||||
|
const children = await Promise.all(childTopicEls.map((c) => walkTopic(c, false)));
|
||||||
|
return { data: nodeData, children };
|
||||||
|
};
|
||||||
|
|
||||||
|
const tree = await walkTopic(rootTopicEl, true);
|
||||||
|
return compactTree(tree, true);
|
||||||
|
};
|
||||||
|
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
export type MindMapNode = {
|
||||||
|
getStyle: (key: string, checkRoot?: boolean) => unknown;
|
||||||
|
setStyle?: (key: string, value: unknown) => void;
|
||||||
|
setIcon?: (icons: string[]) => void;
|
||||||
|
setImage?: (img: { url: string; width?: number; height?: number } | null) => void;
|
||||||
|
getData: (key: string) => unknown;
|
||||||
|
nodeData?: { data?: Record<string, unknown> };
|
||||||
|
data?: Record<string, unknown>;
|
||||||
|
active?: () => void;
|
||||||
|
parent?: { children?: unknown[] } | null;
|
||||||
|
children?: unknown[];
|
||||||
|
uid?: string;
|
||||||
|
};
|
||||||
|
|
||||||
@@ -1,341 +1,200 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { ChevronRight, FileText, Folder, Paperclip, Plus } from "lucide-react";
|
import { ChevronRight, FileText, Folder, Paperclip, Plus } from "lucide-react";
|
||||||
import type { DocumentNode } from "@/lib/documents";
|
|
||||||
import type { MediaAsset } from "@/types/media";
|
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { useMemo } from "react";
|
import type { FileTreeRow } from "@/lib/file-tree/types";
|
||||||
|
import { getFileTreeRowLabel } from "@/lib/file-tree/types";
|
||||||
|
import { isRealFileAsset } from "@/lib/file-tree/asset";
|
||||||
|
|
||||||
interface FileTreeProps {
|
interface FileTreeProps {
|
||||||
nodes: DocumentNode[];
|
rows: FileTreeRow[];
|
||||||
assetsByDoc: Record<string, MediaAsset[]>;
|
|
||||||
expanded: Set<string>;
|
|
||||||
activeId: string;
|
activeId: string;
|
||||||
onToggleExpand: (id: string) => void;
|
selectedRowIds: Set<string>;
|
||||||
onOpenDocument: (id: string) => void;
|
onRowClick: (row: FileTreeRow, event: React.MouseEvent) => void;
|
||||||
onOpenAsset: (asset: MediaAsset) => void;
|
onRowDoubleClick: (row: FileTreeRow, event: React.MouseEvent) => void;
|
||||||
|
onRowContextMenu: (row: FileTreeRow, event: React.MouseEvent) => void;
|
||||||
|
onRowDragStart?: (row: FileTreeRow, event: React.DragEvent) => void;
|
||||||
|
onToggleExpand: (docId: string) => void;
|
||||||
onCreateChild: (parentId: string | null) => void;
|
onCreateChild: (parentId: string | null) => void;
|
||||||
onContextMenu: (event: React.MouseEvent, node: DocumentNode) => void;
|
onBlankMouseDown?: (event: React.MouseEvent) => void;
|
||||||
selectedAssetIds?: Set<string>;
|
|
||||||
onToggleAssetSelect?: (assetId: string) => void;
|
|
||||||
onSelectOnlyAsset?: (assetId: string) => void;
|
|
||||||
disableSelection?: boolean;
|
|
||||||
onDropFiles?: (docId: string, files: FileList) => void;
|
onDropFiles?: (docId: string, files: FileList) => void;
|
||||||
onAssetContextMenu?: (event: React.MouseEvent, asset: MediaAsset) => void;
|
onInternalDrop?: (args: { targetRow: FileTreeRow; rowIds: string[]; copy: boolean }) => void;
|
||||||
onAssetClick?: (asset: MediaAsset, event: React.MouseEvent) => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const INDENT = 16;
|
const INDENT = 16;
|
||||||
|
|
||||||
export function FileTree({
|
export function FileTree({
|
||||||
nodes,
|
rows,
|
||||||
assetsByDoc,
|
|
||||||
expanded,
|
|
||||||
activeId,
|
activeId,
|
||||||
|
selectedRowIds,
|
||||||
|
onRowClick,
|
||||||
|
onRowDoubleClick,
|
||||||
|
onRowContextMenu,
|
||||||
|
onRowDragStart,
|
||||||
onToggleExpand,
|
onToggleExpand,
|
||||||
onOpenDocument,
|
|
||||||
onOpenAsset,
|
|
||||||
onCreateChild,
|
onCreateChild,
|
||||||
onContextMenu,
|
onBlankMouseDown,
|
||||||
selectedAssetIds = new Set<string>(),
|
|
||||||
onToggleAssetSelect,
|
|
||||||
onSelectOnlyAsset,
|
|
||||||
disableSelection = false,
|
|
||||||
onDropFiles,
|
onDropFiles,
|
||||||
onAssetContextMenu,
|
onInternalDrop,
|
||||||
onAssetClick,
|
|
||||||
}: FileTreeProps) {
|
}: FileTreeProps) {
|
||||||
if (nodes.length === 0) {
|
if (rows.length === 0) {
|
||||||
return <div className="px-4 py-6 text-sm text-gray-400">暂无页面,点击下方按钮创建</div>;
|
return <div className="px-4 py-6 text-sm text-gray-400">暂无页面,点击下方按钮创建</div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="divide-y divide-[#f4f4f5]"
|
className="space-y-0.5"
|
||||||
onDragOver={(e) => {
|
onDragOver={(event) => {
|
||||||
if (onDropFiles) {
|
if (onDropFiles) event.preventDefault();
|
||||||
e.preventDefault();
|
|
||||||
}
|
|
||||||
}}
|
}}
|
||||||
onDrop={(e) => {
|
onDrop={(event) => {
|
||||||
if (onDropFiles && e.dataTransfer.files?.length) {
|
if (onDropFiles && event.dataTransfer.files?.length) {
|
||||||
e.preventDefault();
|
|
||||||
const files = e.dataTransfer.files;
|
|
||||||
const targetDoc = nodes[0]?.id ?? "";
|
|
||||||
onDropFiles(targetDoc, files);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{nodes.map((node) => (
|
|
||||||
<FileTreeNode
|
|
||||||
key={node.id}
|
|
||||||
node={node}
|
|
||||||
depth={0}
|
|
||||||
assetsByDoc={assetsByDoc}
|
|
||||||
expanded={expanded}
|
|
||||||
activeId={activeId}
|
|
||||||
onToggleExpand={onToggleExpand}
|
|
||||||
onOpenDocument={onOpenDocument}
|
|
||||||
onOpenAsset={onOpenAsset}
|
|
||||||
onCreateChild={onCreateChild}
|
|
||||||
onContextMenu={onContextMenu}
|
|
||||||
selectedAssetIds={selectedAssetIds}
|
|
||||||
onToggleAssetSelect={onToggleAssetSelect}
|
|
||||||
onSelectOnlyAsset={onSelectOnlyAsset}
|
|
||||||
disableSelection={disableSelection}
|
|
||||||
onDropFiles={onDropFiles}
|
|
||||||
onAssetContextMenu={onAssetContextMenu}
|
|
||||||
onAssetClick={onAssetClick}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
interface FileTreeNodeProps {
|
|
||||||
node: DocumentNode;
|
|
||||||
depth: number;
|
|
||||||
assetsByDoc: Record<string, MediaAsset[]>;
|
|
||||||
expanded: Set<string>;
|
|
||||||
activeId: string;
|
|
||||||
onToggleExpand: (id: string) => void;
|
|
||||||
onOpenDocument: (id: string) => void;
|
|
||||||
onOpenAsset: (asset: MediaAsset) => void;
|
|
||||||
onCreateChild: (parentId: string | null) => void;
|
|
||||||
onContextMenu: (event: React.MouseEvent, node: DocumentNode) => void;
|
|
||||||
selectedAssetIds: Set<string>;
|
|
||||||
onToggleAssetSelect?: (assetId: string) => void;
|
|
||||||
onSelectOnlyAsset?: (assetId: string) => void;
|
|
||||||
disableSelection: boolean;
|
|
||||||
onDropFiles?: (docId: string, files: FileList) => void;
|
|
||||||
onAssetContextMenu?: (event: React.MouseEvent, asset: MediaAsset) => void;
|
|
||||||
onAssetClick?: (asset: MediaAsset, event: React.MouseEvent) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
function FileTreeNode({
|
|
||||||
node,
|
|
||||||
depth,
|
|
||||||
assetsByDoc,
|
|
||||||
expanded,
|
|
||||||
activeId,
|
|
||||||
onToggleExpand,
|
|
||||||
onOpenDocument,
|
|
||||||
onOpenAsset,
|
|
||||||
onCreateChild,
|
|
||||||
onContextMenu,
|
|
||||||
selectedAssetIds,
|
|
||||||
onToggleAssetSelect,
|
|
||||||
onSelectOnlyAsset,
|
|
||||||
disableSelection,
|
|
||||||
onDropFiles,
|
|
||||||
onAssetContextMenu,
|
|
||||||
onAssetClick,
|
|
||||||
}: FileTreeNodeProps) {
|
|
||||||
const isExpanded = expanded.has(node.id);
|
|
||||||
const assets = useMemo(() => assetsByDoc[node.id] ?? [], [assetsByDoc, node.id]);
|
|
||||||
const hasChildren = node.children.length > 0 || assets.length > 0;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className="py-0.5"
|
|
||||||
onDragOver={(e) => {
|
|
||||||
if (onDropFiles) e.preventDefault();
|
|
||||||
}}
|
|
||||||
onDrop={(e) => {
|
|
||||||
if (onDropFiles && e.dataTransfer.files?.length) {
|
|
||||||
e.preventDefault();
|
|
||||||
onDropFiles(node.id, e.dataTransfer.files);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
"flex items-center gap-2 rounded-md px-2 py-1.5 text-sm text-gray-700 hover:bg-[#f5f7fb]",
|
|
||||||
activeId === node.id && "bg-[#e8f2ff] text-[#2563eb]",
|
|
||||||
)}
|
|
||||||
style={{ paddingLeft: depth * INDENT + 8 }}
|
|
||||||
onContextMenu={(event) => onContextMenu(event, node)}
|
|
||||||
>
|
|
||||||
{hasChildren ? (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
aria-label="展开或折叠"
|
|
||||||
onClick={() => onToggleExpand(node.id)}
|
|
||||||
className="flex h-5 w-5 items-center justify-center rounded-md hover:bg-gray-100"
|
|
||||||
>
|
|
||||||
<ChevronRight className={cn("h-3.5 w-3.5 transition-transform", isExpanded && "rotate-90")} />
|
|
||||||
</button>
|
|
||||||
) : (
|
|
||||||
<span className="h-5 w-5" />
|
|
||||||
)}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="flex flex-1 items-center gap-2 text-left"
|
|
||||||
onClick={() => onOpenDocument(node.id)}
|
|
||||||
>
|
|
||||||
<Folder className="h-4 w-4 text-[#2563eb]" />
|
|
||||||
<span className="truncate">{node.title || "无标题"}</span>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
aria-label="新建子页面"
|
|
||||||
onClick={() => onCreateChild(node.id)}
|
|
||||||
className="rounded-md p-1 text-gray-400 hover:bg-gray-100 hover:text-gray-700"
|
|
||||||
>
|
|
||||||
<Plus className="h-3.5 w-3.5" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{isExpanded && (
|
|
||||||
<div className="space-y-0.5">
|
|
||||||
<FileLeafRow
|
|
||||||
depth={depth + 1}
|
|
||||||
label="index.md"
|
|
||||||
icon={<FileText className="h-4 w-4 text-gray-500" />}
|
|
||||||
onClick={() => onOpenDocument(node.id)}
|
|
||||||
stopBubble
|
|
||||||
/>
|
|
||||||
{assets.map((asset) => (
|
|
||||||
<FileLeafRow
|
|
||||||
key={asset.id}
|
|
||||||
depth={depth + 1}
|
|
||||||
label={asset.file_name || "附件"}
|
|
||||||
icon={<Paperclip className="h-4 w-4 text-gray-500" />}
|
|
||||||
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) => (
|
|
||||||
<FileTreeNode
|
|
||||||
key={child.id}
|
|
||||||
node={child}
|
|
||||||
depth={depth + 1}
|
|
||||||
assetsByDoc={assetsByDoc}
|
|
||||||
expanded={expanded}
|
|
||||||
activeId={activeId}
|
|
||||||
onToggleExpand={onToggleExpand}
|
|
||||||
onOpenDocument={onOpenDocument}
|
|
||||||
onOpenAsset={onOpenAsset}
|
|
||||||
onCreateChild={onCreateChild}
|
|
||||||
onContextMenu={onContextMenu}
|
|
||||||
selectedAssetIds={selectedAssetIds}
|
|
||||||
onToggleAssetSelect={onToggleAssetSelect}
|
|
||||||
onSelectOnlyAsset={onSelectOnlyAsset}
|
|
||||||
disableSelection={disableSelection}
|
|
||||||
onDropFiles={onDropFiles}
|
|
||||||
onAssetContextMenu={onAssetContextMenu}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
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 (
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
"flex items-center gap-2 rounded-md px-2 py-1 text-sm text-gray-600 hover:bg-[#f8fafc]",
|
|
||||||
selected && "bg-[#e8f2ff] text-[#2563eb]",
|
|
||||||
)}
|
|
||||||
style={{ paddingLeft: depth * INDENT + 32 }}
|
|
||||||
onClick={(e) => {
|
|
||||||
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 === " ") {
|
|
||||||
event.preventDefault();
|
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 ? (
|
{rows.map((row) => {
|
||||||
<input
|
const label = getFileTreeRowLabel(row);
|
||||||
type="checkbox"
|
const selected = selectedRowIds.has(row.rowId);
|
||||||
checked={selected}
|
const active = row.kind !== "asset" && row.docId === activeId;
|
||||||
onChange={(e) => {
|
const draggable =
|
||||||
e.stopPropagation();
|
row.kind === "doc" || (row.kind === "asset" && isRealFileAsset(row.asset));
|
||||||
if (onSelectToggle) onSelectToggle();
|
const baseClass =
|
||||||
}}
|
"flex items-center gap-2 rounded-md px-2 py-1.5 text-sm text-gray-700 hover:bg-[#f5f7fb]";
|
||||||
className="h-4 w-4 rounded border-gray-300 text-[#2563eb]"
|
const activeClass =
|
||||||
/>
|
row.kind === "index"
|
||||||
) : (
|
? "text-[#2563eb] font-medium"
|
||||||
<span className="h-4 w-4" />
|
: row.kind === "doc"
|
||||||
)}
|
? "text-[#2563eb]"
|
||||||
{icon}
|
: "";
|
||||||
<button
|
const paddingLeft =
|
||||||
type="button"
|
row.kind === "doc" ? row.depth * INDENT + 8 : row.depth * INDENT + 32;
|
||||||
className="flex-1 truncate text-left"
|
|
||||||
onClick={(e) => {
|
return (
|
||||||
e.stopPropagation();
|
<div
|
||||||
onClick();
|
key={row.rowId}
|
||||||
}}
|
className={cn(baseClass, active && activeClass, selected && "bg-[#e8f2ff] text-[#2563eb]")}
|
||||||
>
|
style={{ paddingLeft }}
|
||||||
{label}
|
onClick={(event) => onRowClick(row, event)}
|
||||||
</button>
|
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 ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label="展开或折叠"
|
||||||
|
onClick={(event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
onToggleExpand(row.docId);
|
||||||
|
}}
|
||||||
|
className="flex h-5 w-5 items-center justify-center rounded-md hover:bg-gray-100"
|
||||||
|
>
|
||||||
|
<ChevronRight
|
||||||
|
className={cn(
|
||||||
|
"h-3.5 w-3.5 transition-transform",
|
||||||
|
row.isExpanded && "rotate-90",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<span className="h-5 w-5" />
|
||||||
|
)}
|
||||||
|
<Folder className="h-4 w-4 text-[#2563eb]" />
|
||||||
|
<span className="flex-1 truncate text-left">{label}</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label="新建子页面"
|
||||||
|
onClick={(event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
onCreateChild(row.docId);
|
||||||
|
}}
|
||||||
|
className="rounded-md p-1 text-gray-400 hover:bg-gray-100 hover:text-gray-700"
|
||||||
|
>
|
||||||
|
<Plus className="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
) : row.kind === "index" ? (
|
||||||
|
<>
|
||||||
|
<span className="h-4 w-4" />
|
||||||
|
<FileText className="h-4 w-4 text-gray-500" />
|
||||||
|
<span className="flex-1 truncate text-left">{label}</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<span className="h-4 w-4" />
|
||||||
|
<Paperclip className="h-4 w-4 text-gray-500" />
|
||||||
|
<span className="flex-1 truncate text-left">{label}</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,6 +43,20 @@ import { buildSidebarSectionsFromTree, flattenDocumentTree } from "@/lib/sidebar
|
|||||||
import { supabaseBrowser } from "@/lib/supabase/client";
|
import { supabaseBrowser } from "@/lib/supabase/client";
|
||||||
import { useSearchPaletteStore } from "@/store/search-palette";
|
import { useSearchPaletteStore } from "@/store/search-palette";
|
||||||
import { FileTree } from "@/components/sidebar/file-tree";
|
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 type { MediaAsset } from "@/types/media";
|
||||||
import { AssetContextMenu } from "@/components/sidebar/asset-context-menu";
|
import { AssetContextMenu } from "@/components/sidebar/asset-context-menu";
|
||||||
import { ASSETS_CHANGED_EVENT, DOCUMENTS_CHANGED_EVENT, emitAssetsChanged, emitDocumentsChanged } from "@/lib/events";
|
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>(
|
const [assetMenu, setAssetMenu] = useState<{ asset: MediaAsset; x: number; y: number } | null>(
|
||||||
null,
|
null,
|
||||||
);
|
);
|
||||||
const [selectedAssetIds, setSelectedAssetIds] = useState<Set<string>>(new Set());
|
const [fileTreeSelection, setFileTreeSelection] = useState<FileTreeSelectionState>(() => ({
|
||||||
const [lastSelectedAssetId, setLastSelectedAssetId] = useState<string | null>(null);
|
selectedRowIds: new Set(),
|
||||||
|
anchorRowId: null,
|
||||||
|
focusedRowId: null,
|
||||||
|
}));
|
||||||
|
|
||||||
const workspaceMenuRef = useRef<HTMLDivElement>(null);
|
const workspaceMenuRef = useRef<HTMLDivElement>(null);
|
||||||
|
const fileTreeContainerRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setTree(() => {
|
setTree(() => {
|
||||||
@@ -122,8 +140,7 @@ export function Sidebar({ initialData }: SidebarProps) {
|
|||||||
}, [sidebarData.mindmapDocs]);
|
}, [sidebarData.mindmapDocs]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setSelectedAssetIds(new Set());
|
setFileTreeSelection({ selectedRowIds: new Set(), anchorRowId: null, focusedRowId: null });
|
||||||
setLastSelectedAssetId(null);
|
|
||||||
}, [mediaAssets, sidebarData.documents]);
|
}, [mediaAssets, sidebarData.documents]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -265,19 +282,49 @@ export function Sidebar({ initialData }: SidebarProps) {
|
|||||||
return map;
|
return map;
|
||||||
}, [sidebarData.documents, mediaAssets, mindmapDocs]);
|
}, [sidebarData.documents, mediaAssets, mindmapDocs]);
|
||||||
|
|
||||||
const assetOrder = useMemo(() => {
|
const fileTreeRows = useMemo(
|
||||||
const list: string[] = [];
|
() =>
|
||||||
Object.values(assetsByDoc).forEach((arr) => {
|
buildVisibleRows({
|
||||||
arr.forEach((asset) => list.push(asset.id));
|
nodes: filteredPrivateTree,
|
||||||
});
|
expanded,
|
||||||
return list;
|
assetsByDoc,
|
||||||
}, [assetsByDoc]);
|
}),
|
||||||
|
[assetsByDoc, expanded, filteredPrivateTree],
|
||||||
|
);
|
||||||
|
|
||||||
const assetIndexMap = useMemo(() => {
|
const fileTreeVisibleRowIds = useMemo(() => fileTreeRows.map((row) => row.rowId), [fileTreeRows]);
|
||||||
const map = new Map<string, number>();
|
const fileTreeRowById = useMemo(() => new Map(fileTreeRows.map((row) => [row.rowId, row])), [fileTreeRows]);
|
||||||
assetOrder.forEach((id, idx) => map.set(id, idx));
|
|
||||||
|
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<string | null, number>();
|
||||||
|
(sidebarData.documents ?? []).forEach((doc) => {
|
||||||
|
const parentId = doc.parent_id ?? null;
|
||||||
|
map.set(parentId, (map.get(parentId) ?? 0) + 1);
|
||||||
|
});
|
||||||
return map;
|
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 =
|
const activeWorkspace =
|
||||||
sidebarData.workspaces.find((workspace) => workspace.id === sidebarData.activeWorkspaceId) ??
|
sidebarData.workspaces.find((workspace) => workspace.id === sidebarData.activeWorkspaceId) ??
|
||||||
@@ -285,8 +332,6 @@ export function Sidebar({ initialData }: SidebarProps) {
|
|||||||
|
|
||||||
const handleOpenDocument = useCallback(
|
const handleOpenDocument = useCallback(
|
||||||
(documentId: string, mode: "main" | "sidebar") => {
|
(documentId: string, mode: "main" | "sidebar") => {
|
||||||
setSelectedAssetIds(new Set());
|
|
||||||
setLastSelectedAssetId(null);
|
|
||||||
const targetPath = `/documents/${documentId}`;
|
const targetPath = `/documents/${documentId}`;
|
||||||
if (mode === "main") {
|
if (mode === "main") {
|
||||||
router.push(targetPath);
|
router.push(targetPath);
|
||||||
@@ -380,67 +425,204 @@ export function Sidebar({ initialData }: SidebarProps) {
|
|||||||
}
|
}
|
||||||
}, [router, setOpen]);
|
}, [router, setOpen]);
|
||||||
|
|
||||||
const handleAssetContextMenu = useCallback(
|
const handleFileTreeBlankMouseDown = useCallback(() => {
|
||||||
(event: React.MouseEvent, asset: MediaAsset) => {
|
setFileTreeSelection((prev) => reduceFileTreeSelection(prev, { type: "clear" }));
|
||||||
event.preventDefault();
|
}, []);
|
||||||
if (!selectedAssetIds.has(asset.id)) {
|
|
||||||
setSelectedAssetIds(new Set([asset.id]));
|
const handleFileTreeRowClick = useCallback(
|
||||||
setLastSelectedAssetId(asset.id);
|
(row: FileTreeRow, event: React.MouseEvent) => {
|
||||||
}
|
setFileTreeSelection((prev) =>
|
||||||
setAssetMenu({ asset, x: event.clientX, y: event.clientY });
|
reduceFileTreeSelection(prev, {
|
||||||
|
type: "click",
|
||||||
|
rowId: row.rowId,
|
||||||
|
visibleRowIds: fileTreeVisibleRowIds,
|
||||||
|
modifiers: {
|
||||||
|
shiftKey: event.shiftKey,
|
||||||
|
ctrlKey: event.ctrlKey,
|
||||||
|
metaKey: event.metaKey,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
},
|
},
|
||||||
[selectedAssetIds],
|
[fileTreeVisibleRowIds],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleSelectAsset = useCallback(
|
const handleFileTreeRowDragStart = useCallback((row: FileTreeRow) => {
|
||||||
(asset: MediaAsset, event?: { type?: string; shiftKey?: boolean; metaKey?: boolean; ctrlKey?: boolean }) => {
|
setFileTreeSelection((prev) => {
|
||||||
const evtType = event?.type ?? "";
|
if (prev.selectedRowIds.has(row.rowId)) return prev;
|
||||||
if (evtType === "contextmenu" && selectedAssetIds.has(asset.id)) {
|
return reduceFileTreeSelection(prev, {
|
||||||
setLastSelectedAssetId(asset.id);
|
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;
|
return;
|
||||||
}
|
}
|
||||||
setSelectedAssetIds((prev) => {
|
handleOpenDocument(row.docId, "main");
|
||||||
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);
|
|
||||||
},
|
},
|
||||||
[assetIndexMap, assetOrder, lastSelectedAssetId, selectedAssetIds],
|
[handleOpenAsset, handleOpenDocument],
|
||||||
);
|
);
|
||||||
|
|
||||||
const toggleAssetCheckbox = useCallback((assetId: string) => {
|
const handleFileTreeRowContextMenu = useCallback(
|
||||||
setSelectedAssetIds((prev) => {
|
(row: FileTreeRow, event: React.MouseEvent) => {
|
||||||
const next = new Set(prev);
|
event.preventDefault();
|
||||||
if (next.has(assetId)) {
|
event.stopPropagation();
|
||||||
next.delete(assetId);
|
setFileTreeSelection((prev) =>
|
||||||
} else {
|
reduceFileTreeSelection(prev, { type: "contextmenu", rowId: row.rowId }),
|
||||||
next.add(assetId);
|
);
|
||||||
}
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
setLastSelectedAssetId(assetId);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const selectOnlyAsset = useCallback((assetId: string) => {
|
if (row.kind === "asset") {
|
||||||
setSelectedAssetIds(new Set([assetId]));
|
setAssetMenu({ asset: row.asset, x: event.clientX, y: event.clientY });
|
||||||
setLastSelectedAssetId(assetId);
|
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<string, boolean>();
|
||||||
|
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<FileTreeRow, { kind: "asset" }>[];
|
||||||
|
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(
|
const handleCopyAssetLink = useCallback(
|
||||||
async (asset: MediaAsset) => {
|
async (asset: MediaAsset) => {
|
||||||
@@ -552,39 +734,124 @@ export function Sidebar({ initialData }: SidebarProps) {
|
|||||||
|
|
||||||
const handleDeleteAssets = useCallback(
|
const handleDeleteAssets = useCallback(
|
||||||
async (assetIds: string[], assetHint?: MediaAsset) => {
|
async (assetIds: string[], assetHint?: MediaAsset) => {
|
||||||
const target =
|
const uniqueAssetIds = Array.from(new Set(assetIds));
|
||||||
assetHint ??
|
const assets = uniqueAssetIds
|
||||||
mediaAssets.find((item) => assetIds.includes(item.id)) ??
|
.map((id) => mediaAssets.find((item) => item.id === id))
|
||||||
null;
|
.filter(Boolean) as MediaAsset[];
|
||||||
if (target?.asset_type === "mindmap") {
|
|
||||||
const resp = await fetch(`/api/mindmap/${target.document_id}`, { method: "DELETE" });
|
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<string, string[]>();
|
||||||
|
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) {
|
if (!resp.ok) {
|
||||||
const payload = await resp.json().catch(() => ({}));
|
const payload = await resp.json().catch(() => ({}));
|
||||||
window.alert(payload?.error ?? "删除思维导图失败");
|
window.alert(payload?.error ?? "删除思维导图失败");
|
||||||
return;
|
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",
|
if (fileAssetIds.length > 0) {
|
||||||
headers: { "Content-Type": "application/json" },
|
const resp = await fetch("/api/media/batch", {
|
||||||
body: JSON.stringify({ action: "delete", assetIds }),
|
method: "POST",
|
||||||
});
|
headers: { "Content-Type": "application/json" },
|
||||||
if (!resp.ok) {
|
body: JSON.stringify({ action: "delete", assetIds: fileAssetIds }),
|
||||||
const payload = await resp.json().catch(() => ({}));
|
});
|
||||||
window.alert(payload?.error ?? "删除失败");
|
if (!resp.ok) {
|
||||||
return;
|
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);
|
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],
|
[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(
|
const handleResizeStart = useCallback(
|
||||||
(event: React.MouseEvent) => {
|
(event: React.MouseEvent) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
@@ -693,6 +960,147 @@ export function Sidebar({ initialData }: SidebarProps) {
|
|||||||
[moveLocalNode, refreshTree, setExpanded],
|
[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<string>();
|
||||||
|
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<FileTreeRow, { kind: "asset" }>[];
|
||||||
|
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(
|
const handleMovePrompt = useCallback(
|
||||||
async (node: DocumentNode) => {
|
async (node: DocumentNode) => {
|
||||||
if (typeof window === "undefined") {
|
if (typeof window === "undefined") {
|
||||||
@@ -1033,22 +1441,19 @@ export function Sidebar({ initialData }: SidebarProps) {
|
|||||||
) : (
|
) : (
|
||||||
<div className="flex flex-1 flex-col border-t border-[#f1f1f1]">
|
<div className="flex flex-1 flex-col border-t border-[#f1f1f1]">
|
||||||
<div className="flex-1 px-1 pb-2">
|
<div className="flex-1 px-1 pb-2">
|
||||||
<div className="h-full rounded-md border border-[#eff2f6] bg-white">
|
<div ref={fileTreeContainerRef} className="h-full rounded-md border border-[#eff2f6] bg-white">
|
||||||
<FileTree
|
<FileTree
|
||||||
nodes={filteredPrivateTree}
|
rows={fileTreeRows}
|
||||||
assetsByDoc={assetsByDoc}
|
|
||||||
expanded={expanded}
|
|
||||||
activeId={activeId}
|
activeId={activeId}
|
||||||
|
selectedRowIds={fileTreeSelection.selectedRowIds}
|
||||||
|
onRowClick={handleFileTreeRowClick}
|
||||||
|
onRowDoubleClick={(row, event) => handleFileTreeRowDoubleClick(row, event)}
|
||||||
|
onRowContextMenu={handleFileTreeRowContextMenu}
|
||||||
|
onRowDragStart={(row) => handleFileTreeRowDragStart(row)}
|
||||||
onToggleExpand={toggleExpand}
|
onToggleExpand={toggleExpand}
|
||||||
onOpenDocument={(id) => handleOpenDocument(id, "main")}
|
|
||||||
onOpenAsset={handleOpenAsset}
|
|
||||||
onCreateChild={handleCreate}
|
onCreateChild={handleCreate}
|
||||||
onContextMenu={openContextMenu}
|
onBlankMouseDown={handleFileTreeBlankMouseDown}
|
||||||
onAssetContextMenu={handleAssetContextMenu}
|
onInternalDrop={handleFileTreeInternalDrop}
|
||||||
selectedAssetIds={selectedAssetIds}
|
|
||||||
onAssetClick={(asset, e) => handleSelectAsset(asset, e)}
|
|
||||||
onToggleAssetSelect={toggleAssetCheckbox}
|
|
||||||
onSelectOnlyAsset={selectOnlyAsset}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1105,7 +1510,7 @@ export function Sidebar({ initialData }: SidebarProps) {
|
|||||||
onRename={() => void handleRename(contextMenu.node.id, contextMenu.node.title)}
|
onRename={() => void handleRename(contextMenu.node.id, contextMenu.node.title)}
|
||||||
onCreateChild={() => void handleCreate(contextMenu.node.id)}
|
onCreateChild={() => void handleCreate(contextMenu.node.id)}
|
||||||
onConvertChild={() => void handleConvertToChild(contextMenu.node.id)}
|
onConvertChild={() => void handleConvertToChild(contextMenu.node.id)}
|
||||||
onDelete={() => void handleDelete(contextMenu.node.id)}
|
onDelete={() => void handleDeleteFileTreeSelection()}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{assetMenu && (
|
{assetMenu && (
|
||||||
@@ -1120,7 +1525,7 @@ export function Sidebar({ initialData }: SidebarProps) {
|
|||||||
onMove={handleMoveAsset}
|
onMove={handleMoveAsset}
|
||||||
onDelete={(ids) =>
|
onDelete={(ids) =>
|
||||||
void handleDeleteAssets(
|
void handleDeleteAssets(
|
||||||
selectedAssetIds.size > 0 ? Array.from(selectedAssetIds) : ids,
|
selectedAssetIdsForMenu.length > 0 ? selectedAssetIdsForMenu : ids,
|
||||||
assetMenu.asset,
|
assetMenu.asset,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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<string, any>();
|
||||||
|
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<string, any>();
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<FileTreeClipboardPayloadV1>;
|
||||||
|
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<void> {
|
||||||
|
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<FileTreeClipboardPayloadV1 | null> {
|
||||||
|
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<string, FileTreeRow>;
|
||||||
|
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;
|
||||||
|
}
|
||||||
@@ -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([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<string>;
|
||||||
|
parentById: Map<string, string | null>;
|
||||||
|
}): FileTreeDeleteTargets {
|
||||||
|
const { visibleRows, selectedRowIds, parentById } = args;
|
||||||
|
|
||||||
|
const docCandidates: string[] = [];
|
||||||
|
const assetCandidates: string[] = [];
|
||||||
|
const assetDocIdByAssetId = new Map<string, string>();
|
||||||
|
|
||||||
|
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<string>();
|
||||||
|
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 };
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
@@ -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<string, string | null> {
|
||||||
|
const map = new Map<string, string | null>();
|
||||||
|
edges.forEach((edge) => {
|
||||||
|
map.set(edge.id, edge.parentId ?? null);
|
||||||
|
});
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isAncestorOf(
|
||||||
|
ancestorId: string,
|
||||||
|
nodeId: string,
|
||||||
|
parentById: Map<string, string | null>,
|
||||||
|
): 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, string | null>,
|
||||||
|
): 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<string, string | null>;
|
||||||
|
}): 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;
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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<string>(["无标题", "无标题 副本"]);
|
||||||
|
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<string>(["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("无扩展名 副本");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
@@ -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>): 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>): 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;
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
@@ -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<string>;
|
||||||
|
assetsByDoc: Record<string, MediaAsset[]>;
|
||||||
|
}): 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;
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
export type FileTreeModifierKeys = {
|
||||||
|
shiftKey?: boolean;
|
||||||
|
metaKey?: boolean;
|
||||||
|
ctrlKey?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface FileTreeSelectionState {
|
||||||
|
selectedRowIds: Set<string>;
|
||||||
|
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<string>();
|
||||||
|
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 };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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<unknown>;
|
||||||
|
};
|
||||||
|
export default xmind;
|
||||||
|
}
|
||||||
|
|
||||||
|
declare module "simple-mind-map/src/parse/markdownTo.js" {
|
||||||
|
export type MarkdownToMindmapResult = {
|
||||||
|
data?: Record<string, unknown>;
|
||||||
|
children?: unknown[];
|
||||||
|
[key: string]: unknown;
|
||||||
|
};
|
||||||
|
export const transformMarkdownTo: (md: string) => MarkdownToMindmapResult;
|
||||||
|
}
|
||||||
+12
@@ -0,0 +1,12 @@
|
|||||||
|
export {};
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface Window {
|
||||||
|
/**
|
||||||
|
* 仅用于开发调试:在控制台访问当前思维导图实例。
|
||||||
|
* 生产环境不依赖该字段。
|
||||||
|
*/
|
||||||
|
__mindmapInstance?: unknown | null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Reference in New Issue
Block a user