/** * setBlockVisual — merges common style props into a block. * * The common style properties (`align`, `margin`, `padding`, * `background`, `color`, `border`) live on the base `Block`, so every * block accepts any of them — no per-type whitelist. `undefined` * clears a property; a patch that changes nothing is a no-op. */ import { normalizeDocument } from './normalize'; import { getNodeAtPath, updateNodeAtPath } from './helpers'; import { changed, noOp, type WordsEditorState, type WordsOperationResult } from './types'; import type { WordsPath } from '../path'; import type { Block, WordsBlock } from '../types'; /** * A node accepts the common `Block` style props (margin / padding / * color / border / …) when it EXTENDS `Block`. That is every block * (paragraph, heading, …) AND every structural Block-subtype that has * no `type` discriminator: `Column`, `TableRow`, `TableCell`, * `ListItem`. It is NOT the document and NOT an inline (`text` / `link`, * which carry a `type` but are not `Block`s). * * The earlier guard tested `'type' in target`, which was exactly * backwards: it let inlines through (they have a `type`) and rejected * columns / rows / cells (they don't). Those structural nodes are the * whole point — a column carries its own background / padding / border. */ function isBlockLikeTarget(target: unknown): boolean { if (!target || typeof target !== 'object') return false; if ('version' in target) return false; // document root const t = (target as { type?: string }).type; if (t === 'text' || t === 'link') return false; // inline return true; // block OR column / table-row / table-cell / list-item } const STYLE_KEYS = [ 'align', 'margin', 'padding', 'background', 'color', 'border', 'fontSize', 'fontFamily', 'fontWeight', 'lineHeight', 'fontStyle', 'strikethrough', 'letterSpacing', 'wordSpacing', 'textWrap', 'maxWidth', 'tabularNumbers' ] as const; export function setBlockVisual( state: WordsEditorState, blockIndex: number, patch: Partial ): WordsOperationResult { const block = state.document.children[blockIndex]; if (!block) return noOp(state); const prev = block as unknown as Record; const next: Record = { ...prev }; let touched = false; for (const key of STYLE_KEYS) { if (!(key in patch)) continue; const value = (patch as Record)[key]; if (value === undefined) { if (key in next) { delete next[key]; touched = true; } } else if (!styleEqual(next[key], value)) { next[key] = value; touched = true; } } if (!touched) return noOp(state); const nextDoc = normalizeDocument({ ...state.document, children: replaceAt(state.document.children, blockIndex, next as unknown as WordsBlock) }).document; return changed({ ...state, document: nextDoc }); } /** * Path-aware twin of `setBlockVisual`. Walks `blockPath` to find the * target block (top-level OR nested inside `columns` / `callout`) and * applies the same style-merge semantics. Used by the inspector when * the user has clicked into a block nested inside a column — the * top-level `blockIndex` would point at the wrapper, so the path * version is the only way to touch the right block. */ export function setBlockVisualAtPath( state: WordsEditorState, blockPath: WordsPath, patch: Partial ): WordsOperationResult { if (blockPath.length === 0) return noOp(state); const target = getNodeAtPath(state.document, blockPath); if (!isBlockLikeTarget(target)) return noOp(state); const prev = target as unknown as Record; const next: Record = { ...prev }; let touched = false; for (const key of STYLE_KEYS) { if (!(key in patch)) continue; const value = (patch as Record)[key]; if (value === undefined) { if (key in next) { delete next[key]; touched = true; } } else if (!styleEqual(next[key], value)) { next[key] = value; touched = true; } } if (!touched) return noOp(state); const nextDoc = updateNodeAtPath(state.document, blockPath, () => next as unknown as WordsBlock); if (nextDoc === state.document) return noOp(state); const normalized = normalizeDocument(nextDoc).document; return changed({ ...state, document: normalized }); } /** * Path-aware shallow merge — mirrors `updateBlockAt` but accepts a full * document path. Used by the inspector's Block panel (alt / caption / * src / column children…) when the target block sits inside a column. * Preserves the block's discriminator (`type` is filtered out of the * patch, same as the top-level op). */ export function updateBlockAtPath( state: WordsEditorState, blockPath: WordsPath, patch: Readonly> ): WordsOperationResult { if (blockPath.length === 0) return noOp(state); const target = getNodeAtPath(state.document, blockPath); if (!isBlockLikeTarget(target)) return noOp(state); const safePatch: Record = {}; for (const [k, v] of Object.entries(patch)) { if (k === 'type') continue; safePatch[k] = v; } const nextDoc = updateNodeAtPath( state.document, blockPath, (node) => ({ ...(node as unknown as Record), ...safePatch }) as unknown as WordsBlock ); if (nextDoc === state.document) return noOp(state); const normalized = normalizeDocument(nextDoc).document; return changed({ ...state, document: normalized }); } // ── Helpers ────────────────────────────────────────────────────────────── /** Shallow equality, descending one level into the small style objects * (`margin` / `padding` / `border`). */ function styleEqual(a: unknown, b: unknown): boolean { if (a === b) return true; if (typeof a === 'object' && a !== null && typeof b === 'object' && b !== null) { const ao = a as Record; const bo = b as Record; const ak = Object.keys(ao); const bk = Object.keys(bo); if (ak.length !== bk.length) return false; return ak.every((k) => ao[k] === bo[k]); } return false; } function replaceAt(arr: readonly T[], index: number, value: T): readonly T[] { return [...arr.slice(0, index), value, ...arr.slice(index + 1)]; }