import { type BlockChunk, identifyBlocks } from "./identify.js"; import { resolveHandler } from "./registry.js"; /** * @module * Pure document state model. * * The editor's hard bugs were all state-sync bugs: block edits living only in * the shadow tree while `parse()` rebuilt from stale source, the source * textarea being re-seeded from the pre-edit document, source mode surviving a * switch to read-only and yielding `""`. Those are decisions about *state*, not * about the DOM, so they live here where they can be tested directly. * * `` owns a `DocumentState` and renders it; the DOM is a * projection, never the source of truth. */ /** A single block in the document. */ export interface DocumentBlock { type: string; raw: string; attrs: Record; } /** * Editor state. * * Exactly one of the two representations is authoritative at a time: * `sourceText` while `sourceMode` is true, `blocks` otherwise. Every transition * that flips `sourceMode` converts between them, so the two can never drift. */ export interface DocumentState { blocks: DocumentBlock[]; sourceMode: boolean; /** Authoritative document text while `sourceMode` is true. */ sourceText: string; } function toBlock(chunk: BlockChunk): DocumentBlock { return { type: chunk.type, raw: chunk.raw, attrs: chunk.attrs ?? {} }; } /** Build state from markdown. Starts in block mode. */ export function stateFromMarkdown(markdown: string): DocumentState { return { blocks: identifyBlocks(markdown).map(toBlock), sourceMode: false, sourceText: "", }; } /** Serialize the block list back to markdown, ignoring source mode. */ export function serializeBlocks(blocks: readonly DocumentBlock[]): string { const out: string[] = []; for (const block of blocks) { const handler = resolveHandler(block.type); if (handler) out.push(handler.serialize(block.raw, block.attrs)); } return out.join("\n\n"); } /** * The document's markdown, from whichever representation is authoritative. * This is what `.raw`, `serialize()` and form submission must all agree on. */ export function serializeState(state: DocumentState): string { return state.sourceMode ? state.sourceText : serializeBlocks(state.blocks); } /** Replace a block's content. Out-of-range indices are ignored. */ export function commitBlock( state: DocumentState, index: number, raw: string, attrs?: Record, ): DocumentState { if (index < 0 || index >= state.blocks.length) return state; const blocks = state.blocks.map((block, i) => i === index ? { ...block, raw, attrs: attrs ?? block.attrs } : block, ); return { ...state, blocks }; } /** Insert a block at `index`, clamped to the document bounds. */ export function insertBlock( state: DocumentState, index: number, block: DocumentBlock, ): DocumentState { const at = Math.max(0, Math.min(index, state.blocks.length)); const blocks = [...state.blocks]; blocks.splice(at, 0, block); return { ...state, blocks }; } /** Remove the block at `index`. Out-of-range indices are ignored. */ export function removeBlock(state: DocumentState, index: number): DocumentState { if (index < 0 || index >= state.blocks.length) return state; return { ...state, blocks: state.blocks.filter((_, i) => i !== index) }; } /** * Switch to source mode, seeding the text from the *current* blocks so * uncommitted-to-source block edits are carried across rather than discarded. */ export function enterSourceMode(state: DocumentState): DocumentState { if (state.sourceMode) return state; return { ...state, sourceMode: true, sourceText: serializeBlocks(state.blocks) }; } /** Record a source-mode edit. No-op outside source mode. */ export function setSourceText(state: DocumentState, text: string): DocumentState { if (!state.sourceMode) return state; return { ...state, sourceText: text }; } /** * Leave source mode, re-parsing the edited text into blocks. Used both by the * Visual toggle and by any transition that makes source mode unavailable * (read-only, disabled), so edits are never dropped on the floor. */ export function exitSourceMode(state: DocumentState): DocumentState { if (!state.sourceMode) return state; return { blocks: identifyBlocks(state.sourceText).map(toBlock), sourceMode: false, sourceText: "", }; } /** * Apply an editability change. Source mode requires an editable, unblocked * document; losing that demotes to block mode instead of leaving a hidden * block list and an empty serialization. */ export function setEditable(state: DocumentState, editable: boolean): DocumentState { return editable ? state : exitSourceMode(state); } /** Replace the whole document, preserving the current mode. */ export function replaceMarkdown(state: DocumentState, markdown: string): DocumentState { if (state.sourceMode) return { ...state, sourceText: markdown }; return { ...state, blocks: identifyBlocks(markdown).map(toBlock) }; }