/** * @factory/editor — clipboard (TASK-0059). * * Two representations of a block selection: * * - **internal** — a `CopiedBlock[]` subtree with stable (regenerated) * ids so parent links survive an internal paste; ids are supplied on * insert (the insert payload accepts a client `id`). The *system* * clipboard never receives this JSON (E2E invariant). * - **markdown** — what the system clipboard holds for external copy / * "Copy as markdown" (lossy by design: block structure maps to markdown * constructs; atomic blocks map to their reference). * * Pasting is source-detected: internal JSON round-trips losslessly * (ids preserved); markdown is re-parsed into a *flat* block list * (nested markdown is not a thing — the caller nests via drag/indent); * plain text becomes paragraphs. Pasted blocks get fresh ids + sequential * positions that the caller re-keys into the target page. */ import type { Position } from "@factory/shared/positions"; import type { BlockType } from "@factory/shared/dto"; import { betweenPosition } from "./positions.js"; import { generateBlockId } from "./codec.js"; /** A block in the internal clipboard (ids included — for parent links). */ export interface CopiedBlock { readonly id: string; readonly type: BlockType; readonly content: Record; readonly properties?: Record; /** Parent's *copied* id, or null for a subtree root. */ readonly parentId: string | null; /** Fractional position (re-keyed by the caller on paste). */ readonly position: Position; } /** A block ready for insert (server may keep or reassign the client id). */ export interface PastedBlock { readonly id: string; readonly type: BlockType; readonly content: Record; readonly properties?: Record; /** Parent's pasted id, or null for a top-level paste. */ readonly parentId: string | null; readonly position: Position; } /** * Serialize a selection (a block + its descendants, in DFS order) to the * internal clipboard. Ids are regenerated by default (a copy); pass * `regenerateIds: false` to preserve them (an internal drag, where the * server keeps the same ids). */ export function copyBlocks( selection: readonly CopiedBlock[], options?: { readonly regenerateIds?: boolean }, ): { readonly blocks: readonly CopiedBlock[]; readonly idsRegenerated: boolean } { const regenerate = options?.regenerateIds ?? true; const idMap = new Map(); const remap = (id: string | null): string | null => { if (id === null) return null; const existing = idMap.get(id); if (existing !== undefined) return existing; const next = regenerate ? generateBlockId() : id; idMap.set(id, next); return next; }; const blocks = selection.map((b) => { const block: CopiedBlock = { id: remap(b.id) ?? b.id, type: b.type, content: b.content, parentId: remap(b.parentId), position: b.position, ...(b.properties !== undefined ? { properties: b.properties } : {}), }; return block; }); return { blocks, idsRegenerated: regenerate }; } // --- markdown (system clipboard) --------------------------------------------- /** Render one block to markdown (its own construct / line). */ export function blockToMarkdown(block: Pick): string { const text = typeof (block.content as { text?: unknown }).text === "string" ? ((block.content as { text: string }).text) : ""; const checked = block.properties?.checked === true ? "[x]" : "[ ]"; switch (block.type) { case "heading_1": return `# ${text}`; case "heading_2": return `## ${text}`; case "heading_3": return `### ${text}`; case "quote": return `> ${text}`; case "bulleted_list": return `- ${text}`; case "numbered_list": return `1. ${text}`; case "todo": return `- ${checked} ${text}`; case "toggle": return `### ${text}`; // toggle → heading on export (no markdown collapse) case "code": { const code = (block.content as { text?: string }).text ?? ""; const lang = (block.content as { language?: string }).language ?? ""; return "```" + lang + "\n" + code + "\n```"; } case "divider": return "---"; case "spacer": { const height = (block.content as { height?: number }).height ?? 0; return ``; } case "image": case "file": case "bookmark": case "embed": case "video": case "audio": return referenceMarkdown(block.type, block.content); case "table": { const rows = (block.content as { rows?: string[][] }).rows ?? []; return rows.map((r) => `| ${r.join(" | ")} |`).join("\n"); } case "equation": return `$${text}$`; default: return text; } } function referenceMarkdown(type: string, content: Record): string { const url = typeof content.url === "string" ? content.url : ""; const fileId = typeof content.fileId === "string" ? content.fileId : ""; if (url.length > 0) return `[${type}](${url})`; if (fileId.length > 0) return `[${type}:${fileId}]`; return `[${type}]`; } /** Render a selection (DFS) to a single markdown string (system clipboard). */ export function blocksToMarkdown( blocks: readonly Pick[], ): string { return blocks.map(blockToMarkdown).join("\n\n"); } // --- markdown → blocks (paste) ------------------------------------------------ /** * Parse markdown / plain text into a FLAT block list (fresh ids + * sequential positions). Supports: headings, quotes, todo + bullet lists, * numbered lists, fenced code, dividers, spacer comments, equations, * `[type](url)` references; everything else → paragraph. */ export function markdownToBlocks(input: string): PastedBlock[] { const lines = input.split(/\r?\n/); const blocks: PastedBlock[] = []; let position: Position = "0"; const push = ( type: BlockType, content: Record, properties?: Record, ): void => { const block: PastedBlock = { id: generateBlockId(), type, content, parentId: null, position, ...(properties !== undefined ? { properties } : {}), }; blocks.push(block); position = betweenPosition(position, "zzzz"); }; let i = 0; while (i < lines.length) { const line = lines[i] ?? ""; const fence = line.match(/^```(\w*)\s*$/); if (fence !== null) { const language = fence[1] ?? ""; const codeLines: string[] = []; i += 1; while (i < lines.length && (lines[i] ?? "") !== "```") { codeLines.push(lines[i] ?? ""); i += 1; } i += 1; // consume the closing fence const codeContent: Record = { text: codeLines.join("\n") }; if (language.length > 0) codeContent.language = language; push("code", codeContent); continue; } const heading = line.match(/^(#{1,3})\s+(.*)$/); if (heading !== null) { const level = (heading[1] ?? "#").length; push( level === 1 ? "heading_1" : level === 2 ? "heading_2" : "heading_3", { text: heading[2] ?? "" }, ); i += 1; continue; } const quote = line.match(/^>\s?(.*)$/); if (quote !== null) { push("quote", { text: quote[1] ?? "" }); i += 1; continue; } const todo = line.match(/^[-*]\s+\[( |x|X)\]\s+(.*)$/); if (todo !== null) { push("todo", { text: todo[2] ?? "" }, { checked: todo[1] !== " " }); i += 1; continue; } const bullet = line.match(/^[-*]\s+(.*)$/); if (bullet !== null) { push("bulleted_list", { text: bullet[1] ?? "" }); i += 1; continue; } const numbered = line.match(/^\d+\.\s+(.*)$/); if (numbered !== null) { push("numbered_list", { text: numbered[1] ?? "" }); i += 1; continue; } if (line.trim() === "---") { push("divider", {}); i += 1; continue; } const spacer = line.match(/^$/); if (spacer !== null) { push("spacer", { height: Number(spacer[1]) || 0 }); i += 1; continue; } const eq = line.match(/^\$(.*)\$$/); if (eq !== null) { push("equation", { text: eq[1] ?? "" }); i += 1; continue; } const ref = line.match(/^\[([a-z_]+)\]\((.+)\)$/); if (ref !== null) { push("bookmark", { url: ref[2] ?? "" }); i += 1; continue; } if (line.trim() === "") { i += 1; continue; } push("paragraph", { text: line }); i += 1; } return blocks; } /** * Parse pasted content by source kind. `internal` is the canonical JSON * (lossless, ids preserved); `markdown` re-parses markdown; `text` → * paragraphs (the markdown parser degrades to paragraphs for plain text). */ export function parsePasted(raw: string, source: "internal" | "markdown" | "text"): PastedBlock[] { if (source === "internal") { const parsed = JSON.parse(raw) as unknown; if (Array.isArray(parsed)) { return parsed as PastedBlock[]; } throw new Error("internal clipboard payload must be a block array"); } return markdownToBlocks(raw); }