1/** 2 * block-content — pure content-shape helpers for text blocks. 3 * 4 * The editor's blank-area flow (page-view) needs three answers about a 5 * block, and all of them must be DOM-free + deterministic (unit-testable 6 * without a browser, per AGENTS.md "prefer deterministic behavior"): 7 * 8 * 1. `isAutoCleanableType(type)` — which block types the empty-block 9 * cleanup may touch (text-editable types only). 10 * 2. `isContentEmpty(content)` — is the stored content empty? 11 * 3. `isEmptyTextBlock(block)` — the full guard: text-editable type, 12 * NOT the page title, and empty content. 13 * 14 * WHY THE STRICT CONTENT CHECK (never guess "empty"): 15 * Deleting a block is irreversible. The stored `content` has exactly two 16 * shapes in the wild (see `InlineEditor.toHtml`): 17 * 18 * - `string` — web edits store Tiptap HTML. Empty ⇔ no tags AND no 19 * non-whitespace text (`""`, `"<p></p>"`, `"<p><br></p>"`). 20 * - `{}` — the API/Prisma default for a freshly-created block (nothing 21 * ever typed). Empty: an object with no keys provably carries no data, 22 * so deleting/reusing it can lose nothing. 23 * - `{ text?: string }` / `{ title?: string }` — the API/AI canonical 24 * envelope (page-title block, blocks created via API/import/AI). 25 * Empty ⇔ no displayable text. 26 * 27 * Any OTHER unrecognized object shape (tables, arrays, nested docs, 28 * non-empty objects) is treated as NON-EMPTY. A false negative just 29 * leaves a block around; a false positive deletes user data. 30 * 31 * WHY `isTitle` IS PROTECTED: 32 * Every page is seeded with a title block (a top-level `paragraph` with 33 * `properties.isTitle: true`, see page.service.ts `create`). It shares the 34 * top-level list with content blocks, so the type check alone cannot 35 * distinguish it — the flag must. Deleting it would orphan the page's 36 * title; the header already edits the title via `PATCH /pages/:id`. 37 */ 38 39/** 40 * Block types edited through the inline text editor (Tiptap) that may be 41 * auto-cleaned. These are exactly the types rendered via `InlineEditor`, 42 * which is where the `onEmptyBlur` hook + the shared caret-mover 43 * (`focusBlockEditable`) attach. 44 * 45 * Deliberately EXCLUDED: 46 * - `toggle` — a toggle with no title text can still own CHILD blocks (a 47 * container — deleting it cascade-deletes its children), and its child 48 * rows are nested rows outside the top-level blank-area scope. 49 * - `code` — edited by `CodeBlock`'s own `<textarea>`, NOT `InlineEditor`, 50 * so neither the blur hook nor the caret-mover reach it; an empty code 51 * box also reads as a deliberate (if empty) artifact, not a stray 52 * input field. 53 * Everything else non-text (image / media / table / database / structural) 54 * is likewise excluded. 55 */ 56const TEXT_LIKE_BLOCK_TYPES: ReadonlySet<string> = new Set([ 57 "paragraph", 58 "heading_1", 59 "heading_2", 60 "heading_3", 61 "bulleted_list", 62 "numbered_list", 63 "quote", 64 "callout", 65 "todo", 66]); 67 68/** True for text-editable types (the ones the empty-block cleanup may touch). */ 69export function isAutoCleanableType(type: string | undefined | null): boolean { 70 return type !== undefined && type !== null && TEXT_LIKE_BLOCK_TYPES.has(type); 71} 72 73/** Strip HTML tags so a stored Tiptap HTML string is judged by its DATA. */ 74function stripTags(html: string): string { 75 return html.replace(/<[^>]*>/g, ""); 76} 77 78/** 79 * Is a stored block `content` empty? 80 * 81 * - `null` / `undefined` → empty (nothing stored). 82 * - HTML string → empty when it has no tags AND no non-whitespace text. 83 * - `{}` → empty (the freshly-created default — no keys, nothing to lose). 84 * - `{ text: " " }` / `{ title: "" }` envelope → empty (whitespace-only 85 * counts as empty — nothing a user "typed"). 86 * - `{ text: 42 }`, `{ checked: true }`, arrays, unknown objects… → 87 * NON-empty (no displayable text, but not a recognized empty shape — 88 * never guess). 89 */ 90export function isContentEmpty(content: unknown): boolean { 91 if (content === null || content === undefined) return true; 92 if (typeof content === "string") return stripTags(content).trim() === ""; 93 if (typeof content === "object") { 94 if (Array.isArray(content)) return false; // arrays are not a text shape — never guess 95 const c = content as Record<string, unknown>; 96 const raw = typeof c.text === "string" ? c.text : typeof c.title === "string" ? c.title : undefined; 97 if (raw !== undefined) return raw.trim() === ""; 98 // `{}` — the freshly-created default — is empty (no keys, nothing to lose). 99 if (Object.keys(c).length === 0) return true; 100 // A non-empty unknown object shape (table, nested doc, …) — never guess. 101 return false; 102 } 103 // Numbers / booleans are not displayable text; nothing to lose. 104 return false; 105} 106 107export interface BlockLikeForEmptiness { 108 type: string; 109 content: unknown; 110 properties?: { isTitle?: boolean } | null; 111} 112 113/** 114 * The full empty-text-block guard (the delete-on-blur + click-reuse target): 115 * a text-editable type, not the page title, with empty content. 116 */ 117export function isEmptyTextBlock(block: BlockLikeForEmptiness): boolean { 118 if (block.properties?.isTitle === true) return false; 119 if (!isAutoCleanableType(block.type)) return false; 120 return isContentEmpty(block.content); 121} 122