/** * Clipboard import: turn pasted content (Notion / Word / Google Docs HTML, * or plain text) into a block tree the server can insert in ONE batch. * * Why this module: the editor stores text as Tiptap HTML strings and keeps * structure at the BLOCK level (indents and toggles are block nesting, never * inline CSS), so the browser's default HTML paste cannot express it. We * parse the source HTML explicitly: * * - `
` → paragraph, `
` → quote, * `
` → divider * - `
- ` → bulleted_list, `
- ` → numbered_list * - `
- ` with a checkbox input → todo (checked state preserved) * - Notion toggles: an `
- ` whose content includes a * nested list → toggle (the inner list becomes its children — the * parent-child structure QA asked to be preserved) * - indentation = list nesting depth → the child-parent structure of the * pasted tree (the "indentation from the source" requirement) * * The parser is PURE over an Element (input Element → block specs) and * jsdom-testable; `DOMParser`/`document` access stays inside the exported * entry points. Output order is document order with a parent BEFORE its * children, so a single batch of `upsert` ops (server-side order within a * batch is FIFO) inserts each parent before its children. */ export interface PastedBlockSpec { readonly id: string; readonly type: | "paragraph" | "heading_1" | "heading_2" | "heading_3" | "quote" | "bulleted_list" | "numbered_list" | "todo" | "toggle" | "divider"; /** Inline HTML for text blocks (the editor's storage model), or "" for a * divider / empty block. */ readonly content: unknown; readonly properties?: Record
; /** Parent's pasted id, null for a top-level block. */ readonly parentId: string | null; } /** Client-local optimistic id (same shape as packages/editor's). */ export function generateClipboardBlockId(): string { let suffix = ""; for (let i = 0; i < 32; i += 1) { suffix += Math.floor(Math.random() * 16).toString(16); } return `blk_${suffix}`; } /** Inline HTML of `el` with all block-level wrappers unwrapped to their * children (text runs survive, list structure does not — structure is the * PARSER's job, this is the inline payload of one block). */ function inlineHtml(el: Element): string { const clone = el.cloneNode(true) as Element; const BLOCK_TAGS = "div, p, ul, ol, li, h1, h2, h3, h4, h5, h6, table, section, article, header, footer"; for (const b of Array.from(clone.querySelectorAll(BLOCK_TAGS))) { b.replaceWith(...Array.from(b.childNodes)); } const s = (clone.innerHTML ?? "").replace(/\s+[\s]*
/gi, " "); return s.trim(); } /** The first direct nested list of a list item, if any. */ function firstChildList(li: Element): Element | null { for (const child of Array.from(li.children)) { if (child.tagName === "UL" || child.tagName === "OL") return child; } return null; } /** * Parse one list item into a block. Nested lists (indent) and Notion toggle * bodies become CHILDREN of this item — in both cases the parent link is * this item's pasted id, so indentation and toggle parent-child structure * survive the paste. The item's own text is inlined AFTER the nested list * is stripped (a toggle title must not swallow its children's text). */ function parseListLi(li: Element, isOrdered: boolean, parentSpecId: string | null, out: PastedBlockSpec[]): void { const inner = firstChildList(li); const isNotionToggle = inner !== null && li.getAttribute("data-notion") === "toggle"; const id = generateClipboardBlockId(); const checkbox = li.querySelector(":scope > input[type=checkbox]"); // Text of THIS item only: clone and drop the nested list before inlining. const textClone = li.cloneNode(true) as Element; for (const c of Array.from(textClone.children)) { if (c.tagName === "UL" || c.tagName === "OL") c.remove(); } const ownText = inlineHtml(textClone); const type: PastedBlockSpec["type"] = isNotionToggle ? "toggle" : checkbox ? "todo" : isOrdered ? "numbered_list" : "bulleted_list"; const spec: PastedBlockSpec = { id, type, content: ownText, ...(checkbox && !isNotionToggle ? { properties: { checked: checkbox.checked } } : {}), ...(isNotionToggle ? { properties: { collapsed: false } } : {}), parentId: parentSpecId, }; out.push(spec); if (inner) { for (const sub of Array.from(inner.children)) { if (sub.tagName === "LI") parseListLi(sub, inner.tagName === "OL", id, out); } } } /** Parse a UL/OL whose items belong to `parentSpecId` (null = top level). */ function parseListEl(list: Element, parentSpecId: string | null, out: PastedBlockSpec[]): void { const isOrdered = list.tagName === "OL"; for (const li of Array.from(list.children)) { if (li.tagName === "LI") parseListLi(li, isOrdered, parentSpecId, out); } } function blockTypeFor(tag: string): PastedBlockSpec["type"] | null { switch (tag) { case "H1": return "heading_1"; case "H2": return "heading_2"; case "H3": return "heading_3"; case "BLOCKQUOTE": return "quote"; case "HR": return "divider"; case "P": return "paragraph"; default: return null; } } /** * Parse a source HTML element (document body) into a flat block list with * parent links by id. Document order: a parent always precedes its * children, so a FIFO batch insert works without an explicit position * (the server appends each block after the previously inserted one). */ export function parseNotionHtml(root: Element): PastedBlockSpec[] { const out: PastedBlockSpec[] = []; const walk = (container: Element) => { for (const node of Array.from(container.children)) { const tag = node.tagName; if (tag === "UL" || tag === "OL") { parseListEl(node, null, out); continue; } if (tag === "LI") { parseListLi(node, false, null, out); continue; } if (tag === "TABLE") { // No table paste in the MVP: keep the text, never drop it. out.push({ id: generateClipboardBlockId(), type: "paragraph", content: inlineHtml(node) || "", parentId: null }); continue; } const type = blockTypeFor(tag); if (type) { out.push({ id: generateClipboardBlockId(), type, content: type === "divider" ? "" : inlineHtml(node) || "", parentId: null, }); continue; } // Generic wrapper (div/section/article): Notion wraps list groups in // a div — a direct inner list is parsed as the wrapper's content, // otherwise recurse. const innerList = Array.from(node.children).find((c) => c.tagName === "UL" || c.tagName === "OL"); if (innerList) { parseListEl(innerList, null, out); continue; } walk(node); } }; walk(root); return out; } /** Parse a raw pasted HTML string (browser: DOMParser). Empty result when * the HTML carries no recognizable blocks. */ export function pasteHtmlToBlocks(html: string): PastedBlockSpec[] { const doc = new DOMParser().parseFromString(html, "text/html"); const specs = parseNotionHtml(doc.body); return specs; } function escapeHtml(s: string): string { return s.replace(/&/g, "&").replace(//g, ">"); } /** Plain text → paragraph blocks (one per line, blank lines kept as empty * paragraphs so the source line layout survives). */ export function pasteTextToBlocks(text: string): PastedBlockSpec[] { const lines = text.replace(/\r\n?/g, "\n").split("\n"); // Drop a single trailing empty line (an editor newline artifact). if (lines.length > 1 && lines[lines.length - 1] === "") lines.pop(); return lines.map((line) => ({ id: generateClipboardBlockId(), type: "paragraph" as const, content: line.trim() === "" ? "" : escapeHtml(line), parentId: null, })); } /* --------------------------------------------------------------------- * * Internal copy/paste (app-to-app, including subtree copy) * --------------------------------------------------------------------- */ /** The clipboard payload for in-app copies (text/plain, JSON). */ export const CLIPBOARD_APP_MARKER = "ai-software-factory"; export interface ClipboardPayload { readonly app: typeof CLIPBOARD_APP_MARKER; readonly v: 1; readonly blocks: PastedBlockSpec[]; } /** * Serialize a block SUBTREE (the block + all its descendants) into fresh * paste specs. `allBlocks` is the page's full flat list (`depth: "all"`); * the subtree is extracted client-side (the server's `subtreeIds` logic). * Fresh ids: every paste materializes NEW rows. */ export function copySubtreeToClipboardPayload( rootId: string, allBlocks: ReadonlyArray > ): PastedBlockSpec[] { const byParent = new Map (); for (const b of allBlocks) { const list = byParent.get(b.parentId) ?? []; list.push(b.id); byParent.set(b.parentId, list); } const subtreeIds: string[] = []; const queue = [rootId]; while (queue.length > 0) { const id = queue.shift()!; subtreeIds.push(id); queue.push(...(byParent.get(id) ?? [])); } const idMap = new Map (); for (const id of subtreeIds) idMap.set(id, generateClipboardBlockId()); const specs: PastedBlockSpec[] = []; // Emit in tree order (parent before children — the DFS queue order above). const byId = new Map(allBlocks.map((b) => [b.id, b])); const emit = (id: string, parentSpecId: string | null) => { const b = byId.get(id); if (!b) return; specs.push({ id: idMap.get(id)!, type: b.type as PastedBlockSpec["type"], content: b.content, properties: (b.properties ?? undefined) as Record | undefined, parentId: parentSpecId, }); for (const childId of byParent.get(id) ?? []) emit(childId, idMap.get(id)!); }; emit(rootId, null); return specs; } interface BlockLike { id: string; parentId: string | null; type: string; content: unknown; properties: unknown; } /** * Parse a pasted text/plain payload. Returns specs for: * - our internal payload (subtree copy, multi-block) * - the legacy single-block JSON (the old context-menu copy format) * → null for anything else (let the caller try HTML / plain text). */ export function parseClipboardText(text: string): PastedBlockSpec[] | null { if (!text || !text.trim().startsWith("{")) return null; let obj: unknown; try { obj = JSON.parse(text); } catch { return null; } if (!obj || typeof obj !== "object") return null; const o = obj as Record ; if (o.app === CLIPBOARD_APP_MARKER && Array.isArray(o.blocks)) { const blocks = o.blocks.filter( (b): b is PastedBlockSpec => !!b && typeof b === "object" && typeof (b as PastedBlockSpec).id === "string" && typeof (b as PastedBlockSpec).type === "string" ); if (blocks.length === 0) return null; return blocks; } // Legacy single-block shape: the raw `Block` object the old copy wrote // (has `type` + `parentId` — the block wire shape, not a payload). if (typeof o.type === "string" && "parentId" in o) { return [ { id: generateClipboardBlockId(), type: o.type as PastedBlockSpec["type"], content: o.content ?? "", properties: (o.properties ?? undefined) as Record | undefined, parentId: null, } ]; } return null; } /** True when pasted HTML carries block structure (lists, toggles, headings, * quotes, tables, rules) — i.e. it must be imported at BLOCK level. Simple * inline HTML (a bold/italic run) stays an inline editor paste. */ export function htmlHasBlockStructure(html: string): boolean { return /<(ul|ol|li|details|summary|blockquote|hr|table|h[1-6])[\s>/]/i.test(html); }