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