0.1.04 文件树与导入mmap

This commit is contained in:
liaibo
2026-01-07 18:38:56 +08:00
parent 103e36b1c5
commit 294d1d5fb1
28 changed files with 2703 additions and 442 deletions
@@ -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);
});
});
+27
View File
@@ -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);
});
});
+65
View File
@@ -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();
});
});
+62
View File
@@ -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 };
}
}
}
+93
View File
@@ -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;
}
}