Files
mnote/wolai-frontend/src/lib/file-tree/clipboard.ts
T
lix-2026 384da4e44c feat(tree): checkpoint resource lifecycle work
提交当前顶层 mnote Git 工作区,范围集中在 04-tree-domain 的 resource/trash/filetree 生命周期、mnote-web resource_trash 路由、Convex/Next 兼容接口、sidebar/file-tree 客户端适配、smoke 脚本与对应设计/bug 记录。

不包含被 ignore 的 design/05-editor-mainline/reference-code/leptos-tiptap 嵌套仓库改动。新增 smoke 的测试密码改为运行时读取 MNOTE_E2E_PASSWORD,避免提交明文 credential assignment。
2026-05-16 07:38:45 +08:00

128 lines
3.9 KiB
TypeScript

"use client";
import type { FileTreeRow } from "./types";
import { parseFileTreeRowId } from "./types";
export type FileTreeClipboardAction = "copy" | "cut";
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" && parsed.action !== "cut") 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 async function clearFileTreeClipboardPayload(): Promise<void> {
memoryClipboardText = null;
if (typeof navigator !== "undefined" && navigator.clipboard?.writeText) {
try {
await navigator.clipboard.writeText("");
} catch {
// ignore, memory fallback 已清空
}
}
}
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-folder") {
const row = rowById.get(focusedRowId);
return row?.kind === "asset-folder" ? row.docId : null;
}
if (parsed?.kind === "asset") {
const row = rowById.get(focusedRowId);
return row?.kind === "asset" ? row.docId : null;
}
}
return activeDocId || null;
}