diff --git a/continue.md b/continue.md index c4040b601..b52af1ed2 100644 --- a/continue.md +++ b/continue.md @@ -129,7 +129,11 @@ Tras la discusión de arquitectura (separación contenido/diseño, P1-P8 + decis | **R1 — Audit + tipos + validador** | ✅ Cerrado | types-v2.ts + validate-v2.ts + 50 tests | | **R2 — Migrator V1→V2 + parity check** | ✅ Cerrado | migrate-v2.ts (P4: image.status → runtimeState.imageStatus; reshape marks template literal → structured; drop cell.tone / table.striped / table.compact con warnings; idempotente sobre V2; output siempre pasa validateWordsDocument). sema-parity.ts (assertSemaIntentParity contra $uix/intent.INTENTS). 35 tests. | | **R3 — Render + HTML + MD serializers V2** | ✅ Cerrado | render-v2.ts (pure WordsDocumentV2 → WordsRenderNodeV2; emite inline styles desde block.visual; nuevos tags hr/div para divider/callout). serialize-html-v2.ts (HTML escapado correctamente + roundtrip-friendly via inline styles + pretty mode). serialize-markdown-v2.ts (LOSSY: drop visual + color/bg marks; preserva estructura; INTENT_TO_GFM_ADMONITION mapping risk→WARNING/etc.). 56 tests. **POLISH-1b deferida a R5 porque requiere provider V2 (R4 antes)**. Total engine: 226/226 pass. | -| **R4 — Provider V2 migration** | ⏳ Pendiente | Migrar provider a V2 internamente. Operaciones (insertText, splitBlock, etc.) trabajan sobre WordsBlockV2. Sidecar runtime imageStatus poblado en mount via migrateV1ToV2. API pública del provider mantiene shape V1 vía adapter durante la transición. | +| **R4A.1 — Foundation V2 ops** | ✅ Cerrado | operations-v2/{types,normalize,visual,block,index}.ts (~600 líneas). normalizeDocumentV2 (autogen ids + seed empty containers + coalesce text + strip code marks). setBlockVisual (operación EXCLUSIVA V2, P8 whitelist runtime, base directa para POLISH-1b). 5 block ops: updateBlockAt, deleteBlockAt, insertBlockAt, moveBlockAt, duplicateBlockAt, moveBlockToAt. 31 tests. Engine total: 257/257 pass. | +| **R4A.2 — Text + selection ops** | ⏳ Pendiente | Operaciones de texto (insertText, deleteRange, insertParagraph, deleteBackward, deleteForward, toggleMark, clearFormatting, applyMarkdownShortcut). ~2500 líneas de V1 ops a mecánicamente traducir. Sprint dedicado. | +| **R4A.3 — Container ops** | ⏳ Pendiente | List ops (toggleList, increaseIndent, decreaseIndent, toggleCheckItem), Table ops (insertTable, insertTableRow, insertTableColumn, deleteTableRow, deleteTableColumn, moveTableCell, toggleTableHeaderRow, toggleTableHeaderColumn — NOTA: striped/compact NO en V2), link ops (insertLink, unlink), code ops (exitCodeBlock, outdentCodeLine, setCodeLanguage). | +| **R4B — Provider migration** | ⏳ Pendiente | WordsProvider re-cabledado para operar sobre V2 internamente. Mount: migrateV1ToV2 al cargar value. Runtime sidecar imageStatus poblado. API pública mantiene shape V1 vía adapter. | +| **R4C — Eidos opt-in** | ⏳ Pendiente | Wrapper eidos puede pasar V2 doc directamente. Render eidos usa render-v2 cuando recibe doc V2. Compat para consumers V1 vía migrate-on-read. | | **R5 — POLISH-1b (image visuals)** | ⏳ Pendiente | Drawer image panel: sliders (radius, shadow blur), color pickers (shadow color, borderColor, background), toggles (shadow, border). Provider operation `setImageVisual(blockId, patch)`. Requiere R4. | | **R6 — V1 removal** | ⏳ Pendiente | Una vez R4+R5 estables: borrar V1 types/operations. Audit script: 0 referencias a V1 en engine/extensions. | | **R4 — Sweep tokens del contenido** | ⏳ Pendiente | Eliminar referencias `cell-tone` / `table-striped` / `table-compact` del rest del codebase (provider, render, eidos, serializers). Drawer panels actualizados. Audit script: 0 referencias a tokens en engine/extensions. | diff --git a/src/uix/soma/components/words/engine/operations-v2/block.ts b/src/uix/soma/components/words/engine/operations-v2/block.ts new file mode 100644 index 000000000..2c7a9660d --- /dev/null +++ b/src/uix/soma/components/words/engine/operations-v2/block.ts @@ -0,0 +1,175 @@ +/** + * Block-level operations on V2 documents. + * + * Mirror of the V1 ops: `updateBlockAt`, `deleteBlockAt`, + * `insertBlockAt`, `moveBlockAt`, `duplicateBlockAt`, `moveBlockToAt`. + * All are pure functions: input state → output state, no mutation. + * + * Selection handling is intentionally minimal in R4A.1 — operations + * keep the existing selection or clear it as appropriate. The full + * V1 selection-following-the-block-move logic comes in a later + * iteration when text operations are translated. + */ + +import { normalizeDocumentV2 } from './normalize'; +import { changed, noOp, type WordsEditorStateV2, type WordsOperationResultV2 } from './types'; +import type { WordsBlockV2, WordsDocumentV2 } from '../types-v2'; + +// ── updateBlockAt ──────────────────────────────────────────────────────── + +/** + * Shallow-merges `patch` into the block at `blockIndex`. Used by the + * V1 image-float-bar (align change, caption edit, etc.). Preserves + * the block's discriminator (`type`). + */ +export function updateBlockAt( + state: WordsEditorStateV2, + blockIndex: number, + patch: Readonly> +): WordsOperationResultV2 { + const block = state.document.children[blockIndex]; + if (!block) return noOp(state); + + // Defensive: never let `patch.type` change the block type. That would + // produce a half-migrated shape; if the user wants to change a block + // type, use `setBlock` (R4A.2) — a dedicated op with proper handling. + const safePatch: Record = {}; + for (const [k, v] of Object.entries(patch)) { + if (k === 'type') continue; + safePatch[k] = v; + } + + const merged = { ...block, ...safePatch } as WordsBlockV2; + const nextDoc = normalizeDocumentV2({ + ...state.document, + children: replaceAt(state.document.children, blockIndex, merged) + }).document; + + if (nextDoc === state.document) return noOp(state); + return changed({ ...state, document: nextDoc }); +} + +// ── deleteBlockAt ──────────────────────────────────────────────────────── + +/** + * Remove the block at `blockIndex`. Selection is cleared (caller can + * re-establish it via `setWordsSelection` after the op). + */ +export function deleteBlockAt( + state: WordsEditorStateV2, + blockIndex: number +): WordsOperationResultV2 { + if (blockIndex < 0 || blockIndex >= state.document.children.length) return noOp(state); + const nextChildren = [ + ...state.document.children.slice(0, blockIndex), + ...state.document.children.slice(blockIndex + 1) + ]; + const nextDoc = normalizeDocumentV2({ ...state.document, children: nextChildren }).document; + return changed({ ...state, document: nextDoc, selection: null }); +} + +// ── insertBlockAt ──────────────────────────────────────────────────────── + +/** + * Insert `block` at `blockIndex` (pushes existing siblings right). + * Useful for the "+" block-inserter overlay. Caller can clamp the + * index to `[0, children.length]`. + */ +export function insertBlockAt( + state: WordsEditorStateV2, + blockIndex: number, + block: WordsBlockV2 +): WordsOperationResultV2 { + const clamped = Math.max(0, Math.min(blockIndex, state.document.children.length)); + const nextChildren = [ + ...state.document.children.slice(0, clamped), + block, + ...state.document.children.slice(clamped) + ]; + const nextDoc = normalizeDocumentV2({ ...state.document, children: nextChildren }).document; + return changed({ ...state, document: nextDoc, selection: null }); +} + +// ── moveBlockAt (swap with neighbor) ───────────────────────────────────── + +/** + * Swap the block at `blockIndex` with its sibling in `direction`. No-op + * if already at the edge. + */ +export function moveBlockAt( + state: WordsEditorStateV2, + blockIndex: number, + direction: 'up' | 'down' +): WordsOperationResultV2 { + const children = state.document.children; + const j = direction === 'up' ? blockIndex - 1 : blockIndex + 1; + if (blockIndex < 0 || blockIndex >= children.length || j < 0 || j >= children.length) { + return noOp(state); + } + const next = [...children]; + const tmp = next[blockIndex]; + next[blockIndex] = next[j]; + next[j] = tmp; + const nextDoc = normalizeDocumentV2({ ...state.document, children: next }).document; + return changed({ ...state, document: nextDoc }); +} + +// ── duplicateBlockAt ───────────────────────────────────────────────────── + +/** + * Insert a copy of the block at `blockIndex` directly after it. The + * copy gets a fresh `id` via normalize so the runtime sidecar can + * distinguish it from the original. + */ +export function duplicateBlockAt( + state: WordsEditorStateV2, + blockIndex: number +): WordsOperationResultV2 { + const block = state.document.children[blockIndex]; + if (!block) return noOp(state); + // Drop the id from the duplicate so normalize assigns a fresh one. + const { id: _ignored, ...rest } = block; + const dup = rest as WordsBlockV2; + const nextChildren = [ + ...state.document.children.slice(0, blockIndex + 1), + dup, + ...state.document.children.slice(blockIndex + 1) + ]; + const nextDoc = normalizeDocumentV2({ ...state.document, children: nextChildren }).document; + return changed({ ...state, document: nextDoc }); +} + +// ── moveBlockToAt (arbitrary reposition) ───────────────────────────────── + +/** + * Move the block at `fromIndex` to `toIndex`. `toIndex` is interpreted + * in the POST-REMOVAL indexing (i.e. after the block is taken out of + * its current slot). No-op if from === to or invalid indices. + */ +export function moveBlockToAt( + state: WordsEditorStateV2, + fromIndex: number, + toIndex: number +): WordsOperationResultV2 { + const children = state.document.children; + if ( + fromIndex < 0 || + fromIndex >= children.length || + toIndex < 0 || + toIndex > children.length - 1 || + fromIndex === toIndex + ) { + return noOp(state); + } + const next = [...children]; + const [moved] = next.splice(fromIndex, 1); + next.splice(toIndex, 0, moved); + const nextDoc = normalizeDocumentV2({ ...state.document, children: next }).document; + return changed({ ...state, document: nextDoc }); +} + +// ── Helper ─────────────────────────────────────────────────────────────── + +function replaceAt(arr: readonly T[], index: number, value: T): readonly T[] { + return [...arr.slice(0, index), value, ...arr.slice(index + 1)]; +} diff --git a/src/uix/soma/components/words/engine/operations-v2/index-helpers.ts b/src/uix/soma/components/words/engine/operations-v2/index-helpers.ts new file mode 100644 index 000000000..fa067e237 --- /dev/null +++ b/src/uix/soma/components/words/engine/operations-v2/index-helpers.ts @@ -0,0 +1,13 @@ +/** + * Re-export type helpers from `types-v2.ts` for the test suite. + * Lives here so tests can import everything from `./index*` without + * crossing into the engine root. + */ + +export { + WORDS_DOCUMENT_VERSION_V2, + type BlockVisual, + type WordsBlockV2, + type WordsDocumentV2 +} from '../types-v2'; +export type { WordsEditorStateV2, WordsOperationResultV2 } from './types'; diff --git a/src/uix/soma/components/words/engine/operations-v2/index.ts b/src/uix/soma/components/words/engine/operations-v2/index.ts new file mode 100644 index 000000000..f8beaceea --- /dev/null +++ b/src/uix/soma/components/words/engine/operations-v2/index.ts @@ -0,0 +1,28 @@ +/** + * Words V2 operations — public surface. + * + * R4A.1 (foundation): types + normalize + setBlockVisual + 5 block- + * level ops (updateBlockAt, deleteBlockAt, insertBlockAt, moveBlockAt, + * duplicateBlockAt, moveBlockToAt). + * + * Future iterations (R4A.2+) will add text-editing ops (insertText, + * deleteRange, insertParagraph, etc.), table ops, list ops, etc. The + * full V1 op surface is ~50 functions; this module grows incrementally. + */ + +export { normalizeDocumentV2, type NormalizeOptions, type NormalizeResult } from './normalize'; +export { setBlockVisual } from './visual'; +export { + updateBlockAt, + deleteBlockAt, + insertBlockAt, + moveBlockAt, + duplicateBlockAt, + moveBlockToAt +} from './block'; +export { + noOp, + changed, + type WordsEditorStateV2, + type WordsOperationResultV2 +} from './types'; diff --git a/src/uix/soma/components/words/engine/operations-v2/normalize.ts b/src/uix/soma/components/words/engine/operations-v2/normalize.ts new file mode 100644 index 000000000..2b110bdf7 --- /dev/null +++ b/src/uix/soma/components/words/engine/operations-v2/normalize.ts @@ -0,0 +1,312 @@ +/** + * normalizeDocumentV2 — defensive shape repair after each operation. + * + * V1's `normalizeDocument` enforced invariants like "paragraphs always + * have at least one text child" and "table cells always have at least + * one inline". V2 mirrors that contract over the V2 type. + * + * Also autogenerates `id` for blocks/items/rows/cells that don't have + * one yet, so the runtime sidecar (image upload status, selection, + * etc.) can bind by stable identity. + * + * Returns the same document REFERENCE when nothing changed (cheap + * structural comparison via the `mutated` flag) so the provider can + * short-circuit re-renders. + */ + +import { defaultIdGenerator } from '../migrate-v2'; +import { + WORDS_DOCUMENT_VERSION_V2, + type WordsBlockV2, + type WordsCalloutBlockV2, + type WordsDocumentV2, + type WordsInlineV2, + type WordsListBlockV2, + type WordsListItemV2, + type WordsTableBlockV2, + type WordsTableCellV2, + type WordsTableRowV2, + type WordsTextV2 +} from '../types-v2'; + +export interface NormalizeOptions { + /** Override the id generator. Defaults to `defaultIdGenerator` from + * the migrator (crypto.randomUUID + fallback). */ + readonly idGenerator?: () => string; +} + +export interface NormalizeResult { + readonly document: WordsDocumentV2; + readonly mutated: boolean; +} + +export function normalizeDocumentV2( + doc: WordsDocumentV2, + opts: NormalizeOptions = {} +): NormalizeResult { + const idGen = opts.idGenerator ?? defaultIdGenerator; + let mutated = false; + + const nextChildren = doc.children.map((block) => { + const nb = normalizeBlock(block, idGen); + if (nb !== block) mutated = true; + return nb; + }); + + // Empty doc → seed with an empty paragraph (V1 doctrine: always at + // least one block so the caret has somewhere to land). + if (nextChildren.length === 0) { + mutated = true; + nextChildren.push(emptyParagraph(idGen)); + } + + if (!mutated) return { document: doc, mutated: false }; + return { + document: { version: WORDS_DOCUMENT_VERSION_V2, children: nextChildren }, + mutated: true + }; +} + +// ── Block-level normalization ──────────────────────────────────────────── + +function normalizeBlock(block: WordsBlockV2, idGen: () => string): WordsBlockV2 { + const baseId = block.id ?? idGen(); + const idChanged = baseId !== block.id; + + switch (block.type) { + case 'paragraph': + case 'heading': + case 'quote': { + const inlines = normalizeInlineChildren(block.children, idGen); + const inlinesChanged = inlines !== block.children; + if (!idChanged && !inlinesChanged) return block; + return { ...block, id: baseId, children: inlines }; + } + + case 'code': { + const children = normalizeCodeChildren(block.children); + const changed = children !== block.children; + if (!idChanged && !changed) return block; + return { ...block, id: baseId, children }; + } + + case 'list': + return normalizeList(block, baseId, idChanged, idGen); + + case 'table': + return normalizeTable(block, baseId, idChanged, idGen); + + case 'image': + case 'divider': + if (!idChanged) return block; + return { ...block, id: baseId }; + + case 'callout': + return normalizeCallout(block, baseId, idChanged, idGen); + } +} + +function normalizeList( + block: WordsListBlockV2, + id: string, + idChanged: boolean, + idGen: () => string +): WordsListBlockV2 { + const items = normalizeListItems(block.items, idGen); + const itemsChanged = items !== block.items; + if (!idChanged && !itemsChanged) return block; + return { ...block, id, items }; +} + +function normalizeListItems( + items: readonly WordsListItemV2[], + idGen: () => string +): readonly WordsListItemV2[] { + if (items.length === 0) { + // Empty list → seed with one empty item. + return [ + { + id: idGen(), + children: [{ type: 'text', text: '' }] + } + ]; + } + let mutated = false; + const next = items.map((item) => { + const inlines = normalizeInlineChildren(item.children, idGen); + const id = item.id ?? idGen(); + if (id === item.id && inlines === item.children) return item; + mutated = true; + return { ...item, id, children: inlines }; + }); + return mutated ? next : items; +} + +function normalizeTable( + block: WordsTableBlockV2, + id: string, + idChanged: boolean, + idGen: () => string +): WordsTableBlockV2 { + if (block.rows.length === 0) { + // Empty table → seed with a 1×1 row/cell. + return { + ...block, + id, + rows: [ + { + id: idGen(), + cells: [{ id: idGen(), children: [{ type: 'text', text: '' }] }] + } + ] + }; + } + let mutated = idChanged; + const rows = block.rows.map((row) => { + const cells = normalizeTableCells(row.cells, idGen); + const rowId = row.id ?? idGen(); + if (rowId === row.id && cells === row.cells) return row; + mutated = true; + return { ...row, id: rowId, cells }; + }); + if (!mutated) return block; + return { ...block, id, rows }; +} + +function normalizeTableCells( + cells: readonly WordsTableCellV2[], + idGen: () => string +): readonly WordsTableCellV2[] { + if (cells.length === 0) { + return [{ id: idGen(), children: [{ type: 'text', text: '' }] }]; + } + let mutated = false; + const next = cells.map((cell) => { + const inlines = normalizeInlineChildren(cell.children, idGen); + const cellId = cell.id ?? idGen(); + if (cellId === cell.id && inlines === cell.children) return cell; + mutated = true; + return { ...cell, id: cellId, children: inlines }; + }); + return mutated ? next : cells; +} + +function normalizeCallout( + block: WordsCalloutBlockV2, + id: string, + idChanged: boolean, + idGen: () => string +): WordsCalloutBlockV2 { + let mutated = idChanged; + const children = block.children.map((child) => { + const nb = normalizeBlock(child, idGen); + if (nb !== child) mutated = true; + return nb; + }); + if (!mutated) return block; + return { ...block, id, children }; +} + +// ── Inline / text normalization ────────────────────────────────────────── + +function normalizeInlineChildren( + inlines: readonly WordsInlineV2[], + _idGen: () => string +): readonly WordsInlineV2[] { + // Always at least one text inline (caret needs somewhere to land). + if (inlines.length === 0) { + return [{ type: 'text', text: '' }]; + } + // Coalesce adjacent text inlines with identical marks. This is the + // V1 invariant; keeps the model from accumulating fragmented + // text spans after edits. + const next: WordsInlineV2[] = []; + let mutated = false; + for (const inline of inlines) { + const last = next[next.length - 1]; + if ( + inline.type === 'text' && + last?.type === 'text' && + sameMarks(last.marks, inline.marks) + ) { + const merged: WordsTextV2 = { + type: 'text', + text: last.text + inline.text, + ...(last.marks ? { marks: last.marks } : {}) + }; + next[next.length - 1] = merged; + mutated = true; + continue; + } + next.push(inline); + } + return mutated ? next : inlines; +} + +function normalizeCodeChildren( + children: readonly WordsTextV2[] +): readonly WordsTextV2[] { + // Code blocks have no marks, no links — flatten any stray ones. + if (children.length === 0) { + return [{ type: 'text', text: '' }]; + } + let mutated = false; + const next = children.map((c) => { + if (c.marks !== undefined) { + mutated = true; + return { type: 'text' as const, text: c.text }; + } + return c; + }); + if (!mutated) return children; + // Coalesce adjacent text. + const coalesced: WordsTextV2[] = []; + for (const c of next) { + const last = coalesced[coalesced.length - 1]; + if (last) { + coalesced[coalesced.length - 1] = { type: 'text', text: last.text + c.text }; + continue; + } + coalesced.push(c); + } + return coalesced; +} + +// ── Helpers ────────────────────────────────────────────────────────────── + +function sameMarks( + a: WordsTextV2['marks'] | undefined, + b: WordsTextV2['marks'] | undefined +): boolean { + const al = a ?? []; + const bl = b ?? []; + if (al.length !== bl.length) return false; + for (let i = 0; i < al.length; i++) { + const x = al[i] as unknown; + const y = bl[i] as unknown; + if (typeof x !== typeof y) return false; + if (typeof x === 'string') { + if (x !== y) return false; + } else if ( + typeof x === 'object' && + x !== null && + typeof y === 'object' && + y !== null + ) { + const xo = x as { type?: string; value?: string }; + const yo = y as { type?: string; value?: string }; + if (xo.type !== yo.type || xo.value !== yo.value) return false; + } else { + return false; + } + } + return true; +} + +function emptyParagraph(idGen: () => string): WordsBlockV2 { + return { + type: 'paragraph', + id: idGen(), + children: [{ type: 'text', text: '' }] + }; +} diff --git a/src/uix/soma/components/words/engine/operations-v2/operations-v2.test.ts b/src/uix/soma/components/words/engine/operations-v2/operations-v2.test.ts new file mode 100644 index 000000000..ddabc318d --- /dev/null +++ b/src/uix/soma/components/words/engine/operations-v2/operations-v2.test.ts @@ -0,0 +1,443 @@ +/** + * R4A.1 — operations-v2 foundation suite. + * + * Covers: normalizeDocumentV2 (idempotent + autogen ids + coalesce + * text + seed empty containers), setBlockVisual (per-type whitelist + * enforced + clear via undefined + omits empty visual), block-level + * ops (updateBlockAt, deleteBlockAt, insertBlockAt, moveBlockAt, + * duplicateBlockAt, moveBlockToAt). + */ + +import { describe, expect, it } from 'vitest'; +import { + deleteBlockAt, + duplicateBlockAt, + insertBlockAt, + moveBlockAt, + moveBlockToAt, + normalizeDocumentV2, + setBlockVisual, + updateBlockAt, + type WordsEditorStateV2 +} from './index'; +import { WORDS_DOCUMENT_VERSION_V2, type WordsDocumentV2 } from '../types-v2'; + +// ── Deterministic id generator for tests ───────────────────────────────── + +function makeSeqIdGen(): () => string { + let n = 0; + return () => `id-${++n}`; +} + +function makeState(...children: WordsDocumentV2['children']): WordsEditorStateV2 { + return { + document: { version: WORDS_DOCUMENT_VERSION_V2, children }, + selection: null, + activeMarks: [] + }; +} + +// ── normalizeDocumentV2 ────────────────────────────────────────────────── + +describe('normalizeDocumentV2', () => { + it('autogenerates ids for blocks without one', () => { + const idGen = makeSeqIdGen(); + const r = normalizeDocumentV2( + { + version: WORDS_DOCUMENT_VERSION_V2, + children: [ + { type: 'paragraph', children: [{ type: 'text', text: 'a' }] }, + { type: 'paragraph', children: [{ type: 'text', text: 'b' }] } + ] + }, + { idGenerator: idGen } + ); + expect(r.mutated).toBe(true); + expect((r.document.children[0] as { id?: string }).id).toBe('id-1'); + expect((r.document.children[1] as { id?: string }).id).toBe('id-2'); + }); + + it('preserves existing ids', () => { + const r = normalizeDocumentV2( + { + version: WORDS_DOCUMENT_VERSION_V2, + children: [ + { type: 'paragraph', id: 'p-keep', children: [{ type: 'text', text: 'a' }] } + ] + }, + { idGenerator: makeSeqIdGen() } + ); + expect((r.document.children[0] as { id?: string }).id).toBe('p-keep'); + }); + + it('seeds an empty document with one paragraph', () => { + const r = normalizeDocumentV2( + { version: WORDS_DOCUMENT_VERSION_V2, children: [] }, + { idGenerator: makeSeqIdGen() } + ); + expect(r.mutated).toBe(true); + expect(r.document.children).toHaveLength(1); + expect(r.document.children[0].type).toBe('paragraph'); + }); + + it('seeds an empty list with one item', () => { + const r = normalizeDocumentV2( + { + version: WORDS_DOCUMENT_VERSION_V2, + children: [{ type: 'list', kind: 'unordered', items: [] }] + }, + { idGenerator: makeSeqIdGen() } + ); + const list = r.document.children[0]; + if (list.type !== 'list') throw new Error('expected list'); + expect(list.items).toHaveLength(1); + }); + + it('seeds an empty table with a 1×1 cell', () => { + const r = normalizeDocumentV2( + { + version: WORDS_DOCUMENT_VERSION_V2, + children: [{ type: 'table', rows: [] }] + }, + { idGenerator: makeSeqIdGen() } + ); + const table = r.document.children[0]; + if (table.type !== 'table') throw new Error('expected table'); + expect(table.rows).toHaveLength(1); + expect(table.rows[0].cells).toHaveLength(1); + }); + + it('seeds empty inline children with one empty text', () => { + const r = normalizeDocumentV2( + { + version: WORDS_DOCUMENT_VERSION_V2, + children: [{ type: 'paragraph', children: [] }] + }, + { idGenerator: makeSeqIdGen() } + ); + const p = r.document.children[0]; + if (p.type !== 'paragraph') throw new Error('expected paragraph'); + expect(p.children).toHaveLength(1); + expect(p.children[0]).toEqual({ type: 'text', text: '' }); + }); + + it('coalesces adjacent text inlines with identical marks', () => { + const r = normalizeDocumentV2( + { + version: WORDS_DOCUMENT_VERSION_V2, + children: [ + { + type: 'paragraph', + children: [ + { type: 'text', text: 'hel' }, + { type: 'text', text: 'lo ' }, + { type: 'text', text: 'world' } + ] + } + ] + }, + { idGenerator: makeSeqIdGen() } + ); + const p = r.document.children[0]; + if (p.type !== 'paragraph') throw new Error('expected paragraph'); + expect(p.children).toHaveLength(1); + expect(p.children[0]).toEqual({ type: 'text', text: 'hello world' }); + }); + + it('does NOT coalesce text inlines with different marks', () => { + const r = normalizeDocumentV2( + { + version: WORDS_DOCUMENT_VERSION_V2, + children: [ + { + type: 'paragraph', + children: [ + { type: 'text', text: 'a', marks: ['bold'] }, + { type: 'text', text: 'b' } + ] + } + ] + }, + { idGenerator: makeSeqIdGen() } + ); + const p = r.document.children[0]; + if (p.type !== 'paragraph') throw new Error('expected paragraph'); + expect(p.children).toHaveLength(2); + }); + + it('strips marks from code block children', () => { + const r = normalizeDocumentV2( + { + version: WORDS_DOCUMENT_VERSION_V2, + children: [ + { + type: 'code', + children: [{ type: 'text', text: 'x', marks: ['bold'] }] + } + ] + }, + { idGenerator: makeSeqIdGen() } + ); + const code = r.document.children[0]; + if (code.type !== 'code') throw new Error('expected code'); + expect(code.children[0]).toEqual({ type: 'text', text: 'x' }); + }); + + it('returns the same document reference when nothing changes', () => { + const input: WordsDocumentV2 = { + version: WORDS_DOCUMENT_VERSION_V2, + children: [ + { type: 'paragraph', id: 'p-1', children: [{ type: 'text', text: 'x' }] } + ] + }; + const r = normalizeDocumentV2(input, { idGenerator: makeSeqIdGen() }); + expect(r.mutated).toBe(false); + expect(r.document).toBe(input); + }); +}); + +// ── setBlockVisual ─────────────────────────────────────────────────────── + +describe('setBlockVisual', () => { + it('sets a single visual property on a paragraph', () => { + const state = makeState({ + type: 'paragraph', + id: 'p-1', + children: [{ type: 'text', text: 'x' }] + }); + const r = setBlockVisual(state, 0, { background: '#fafafa' }); + expect(r.changed).toBe(true); + expect( + (r.state.document.children[0] as { visual?: { background?: string } }).visual?.background + ).toBe('#fafafa'); + }); + + it('merges with existing visual', () => { + const state = makeState({ + type: 'paragraph', + id: 'p-1', + children: [{ type: 'text', text: 'x' }], + visual: { background: '#fafafa', padding: 12 } + }); + const r = setBlockVisual(state, 0, { cornerRadius: 4 }); + const visual = (r.state.document.children[0] as { visual: any }).visual; + expect(visual.background).toBe('#fafafa'); + expect(visual.padding).toBe(12); + expect(visual.cornerRadius).toBe(4); + }); + + it('clears a property when patched with undefined', () => { + const state = makeState({ + type: 'paragraph', + id: 'p-1', + children: [{ type: 'text', text: 'x' }], + visual: { background: '#fafafa', padding: 12 } + }); + const r = setBlockVisual(state, 0, { background: undefined }); + const visual = (r.state.document.children[0] as { visual?: any }).visual; + expect(visual?.background).toBeUndefined(); + expect(visual?.padding).toBe(12); + }); + + it('omits the visual key entirely when all properties cleared', () => { + const state = makeState({ + type: 'paragraph', + id: 'p-1', + children: [{ type: 'text', text: 'x' }], + visual: { background: '#fafafa' } + }); + const r = setBlockVisual(state, 0, { background: undefined }); + expect((r.state.document.children[0] as { visual?: unknown }).visual).toBeUndefined(); + }); + + it('silently drops visual keys not in the per-type whitelist (P8)', () => { + // `shadow` is allowed on image but NOT paragraph. + const state = makeState({ + type: 'paragraph', + id: 'p-1', + children: [{ type: 'text', text: 'x' }] + }); + const r = setBlockVisual(state, 0, { + shadow: { x: 0, y: 4, blur: 8, color: '#000' } + } as any); + // Nothing applied → no change + expect(r.changed).toBe(false); + expect((r.state.document.children[0] as { visual?: unknown }).visual).toBeUndefined(); + }); + + it('accepts shadow on image', () => { + const state = makeState({ type: 'image', src: 'https://x', id: 'img-1' }); + const r = setBlockVisual(state, 0, { + shadow: { x: 0, y: 4, blur: 12, color: '#00000033' } + }); + expect(r.changed).toBe(true); + const visual = (r.state.document.children[0] as { visual: any }).visual; + expect(visual.shadow.blur).toBe(12); + }); + + it('returns no-op when patch matches existing visual', () => { + const state = makeState({ + type: 'image', + src: 'x', + id: 'img-1', + visual: { cornerRadius: 16 } + }); + const r = setBlockVisual(state, 0, { cornerRadius: 16 }); + expect(r.changed).toBe(false); + }); + + it('returns no-op for invalid blockIndex', () => { + const state = makeState({ + type: 'paragraph', + id: 'p-1', + children: [{ type: 'text', text: 'x' }] + }); + const r = setBlockVisual(state, 99, { background: '#fafafa' }); + expect(r.changed).toBe(false); + }); +}); + +// ── Block-level operations ─────────────────────────────────────────────── + +describe('updateBlockAt', () => { + it('shallow-merges patch into the block', () => { + const state = makeState({ type: 'image', src: 'x', id: 'img-1', alt: 'old' }); + const r = updateBlockAt(state, 0, { alt: 'new', caption: 'fluffy' }); + expect(r.changed).toBe(true); + const img = r.state.document.children[0] as { alt: string; caption: string }; + expect(img.alt).toBe('new'); + expect(img.caption).toBe('fluffy'); + }); + + it('refuses to change the block type', () => { + const state = makeState({ type: 'paragraph', id: 'p-1', children: [{ type: 'text', text: 'x' }] }); + const r = updateBlockAt(state, 0, { type: 'heading' as never, level: 1 }); + const block = r.state.document.children[0]; + expect(block.type).toBe('paragraph'); + }); + + it('returns no-op for invalid blockIndex', () => { + const state = makeState({ type: 'paragraph', id: 'p-1', children: [{ type: 'text', text: 'x' }] }); + const r = updateBlockAt(state, 99, { alt: 'x' }); + expect(r.changed).toBe(false); + }); +}); + +describe('deleteBlockAt', () => { + it('removes the block at the given index', () => { + const state = makeState( + { type: 'paragraph', id: 'a', children: [{ type: 'text', text: 'a' }] }, + { type: 'paragraph', id: 'b', children: [{ type: 'text', text: 'b' }] }, + { type: 'paragraph', id: 'c', children: [{ type: 'text', text: 'c' }] } + ); + const r = deleteBlockAt(state, 1); + expect(r.state.document.children).toHaveLength(2); + expect((r.state.document.children[0] as { id: string }).id).toBe('a'); + expect((r.state.document.children[1] as { id: string }).id).toBe('c'); + }); + + it('seeds an empty paragraph when the last block is deleted', () => { + const state = makeState({ + type: 'paragraph', + id: 'only', + children: [{ type: 'text', text: 'x' }] + }); + const r = deleteBlockAt(state, 0); + expect(r.state.document.children).toHaveLength(1); + expect(r.state.document.children[0].type).toBe('paragraph'); + }); + + it('returns no-op for invalid blockIndex', () => { + const state = makeState({ type: 'paragraph', id: 'p', children: [{ type: 'text', text: 'x' }] }); + const r = deleteBlockAt(state, 99); + expect(r.changed).toBe(false); + }); +}); + +describe('insertBlockAt', () => { + it('inserts the new block at the index', () => { + const state = makeState( + { type: 'paragraph', id: 'a', children: [{ type: 'text', text: 'a' }] }, + { type: 'paragraph', id: 'c', children: [{ type: 'text', text: 'c' }] } + ); + const r = insertBlockAt(state, 1, { + type: 'paragraph', + id: 'b', + children: [{ type: 'text', text: 'b' }] + }); + expect(r.state.document.children).toHaveLength(3); + expect((r.state.document.children[1] as { id: string }).id).toBe('b'); + }); + + it('clamps index above children.length', () => { + const state = makeState({ type: 'paragraph', id: 'a', children: [{ type: 'text', text: 'a' }] }); + const r = insertBlockAt(state, 99, { + type: 'paragraph', + id: 'b', + children: [{ type: 'text', text: 'b' }] + }); + expect(r.state.document.children).toHaveLength(2); + expect((r.state.document.children[1] as { id: string }).id).toBe('b'); + }); +}); + +describe('moveBlockAt', () => { + it('swaps a block up with its predecessor', () => { + const state = makeState( + { type: 'paragraph', id: 'a', children: [{ type: 'text', text: 'a' }] }, + { type: 'paragraph', id: 'b', children: [{ type: 'text', text: 'b' }] } + ); + const r = moveBlockAt(state, 1, 'up'); + expect((r.state.document.children[0] as { id: string }).id).toBe('b'); + expect((r.state.document.children[1] as { id: string }).id).toBe('a'); + }); + + it('returns no-op when moving past edges', () => { + const state = makeState({ + type: 'paragraph', + id: 'a', + children: [{ type: 'text', text: 'a' }] + }); + expect(moveBlockAt(state, 0, 'up').changed).toBe(false); + expect(moveBlockAt(state, 0, 'down').changed).toBe(false); + }); +}); + +describe('duplicateBlockAt', () => { + it('inserts a copy immediately after the original (with fresh id)', () => { + const state = makeState({ + type: 'paragraph', + id: 'orig', + children: [{ type: 'text', text: 'x' }] + }); + const r = duplicateBlockAt(state, 0); + expect(r.state.document.children).toHaveLength(2); + expect((r.state.document.children[0] as { id: string }).id).toBe('orig'); + // The duplicate gets a fresh id from normalize (NOT 'orig'). + expect((r.state.document.children[1] as { id: string }).id).not.toBe('orig'); + expect((r.state.document.children[1] as { id: string }).id).toBeTruthy(); + }); +}); + +describe('moveBlockToAt', () => { + it('moves block 0 to position 2', () => { + const state = makeState( + { type: 'paragraph', id: 'a', children: [{ type: 'text', text: 'a' }] }, + { type: 'paragraph', id: 'b', children: [{ type: 'text', text: 'b' }] }, + { type: 'paragraph', id: 'c', children: [{ type: 'text', text: 'c' }] } + ); + const r = moveBlockToAt(state, 0, 2); + expect((r.state.document.children[0] as { id: string }).id).toBe('b'); + expect((r.state.document.children[1] as { id: string }).id).toBe('c'); + expect((r.state.document.children[2] as { id: string }).id).toBe('a'); + }); + + it('no-op when from === to', () => { + const state = makeState({ + type: 'paragraph', + id: 'a', + children: [{ type: 'text', text: 'a' }] + }); + expect(moveBlockToAt(state, 0, 0).changed).toBe(false); + }); +}); diff --git a/src/uix/soma/components/words/engine/operations-v2/types.ts b/src/uix/soma/components/words/engine/operations-v2/types.ts new file mode 100644 index 000000000..6a1537f7d --- /dev/null +++ b/src/uix/soma/components/words/engine/operations-v2/types.ts @@ -0,0 +1,53 @@ +/** + * Words V2 operations — core types. + * + * Mirrors `WordsEditorState` / `WordsOperationResult` from V1 but over + * the V2 document shape. The runtime sidecar (image upload status, + * etc.) is NOT carried in the state — operations are pure on the + * document + selection. Sidecar state is the provider's concern. + */ + +import type { WordsDocumentV2, WordsMarkV2 } from '../types-v2'; +import type { WordsSelection } from '../selection'; + +/** + * Editor state V2. + * + * - `document` — current V2 document tree. + * - `selection` — current selection, expressed in V1's + * `WordsSelection` shape (path + offset). V2 + * reuses V1's selection model verbatim — the + * shape doesn't change. + * - `activeMarks` — marks that would apply if the user typed at + * the current caret position. Recomputed after + * every selection / document change. + */ +export interface WordsEditorStateV2 { + readonly document: WordsDocumentV2; + readonly selection: WordsSelection | null; + readonly activeMarks: readonly WordsMarkV2[]; +} + +/** + * Result of any V2 operation. `state` is the new state (or the same + * reference when nothing changed); `changed` lets the provider skip + * notifying observers + the history step when nothing happened. + */ +export interface WordsOperationResultV2 { + readonly state: WordsEditorStateV2; + readonly changed: boolean; +} + +/** + * Helper: produce a no-op result for guard-clause short-circuits. + */ +export function noOp(state: WordsEditorStateV2): WordsOperationResultV2 { + return { state, changed: false }; +} + +/** + * Helper: produce a changed-result with a new state. + */ +export function changed(state: WordsEditorStateV2): WordsOperationResultV2 { + return { state, changed: true }; +} diff --git a/src/uix/soma/components/words/engine/operations-v2/visual.ts b/src/uix/soma/components/words/engine/operations-v2/visual.ts new file mode 100644 index 000000000..4031c3337 --- /dev/null +++ b/src/uix/soma/components/words/engine/operations-v2/visual.ts @@ -0,0 +1,136 @@ +/** + * setBlockVisual — V2-exclusive operation. + * + * Updates the `block.visual` sidecar of a block. New in V2 (V1 has no + * `visual` concept). This is the operation that finally unlocks + * POLISH-1b (image radius slider + shadow + border controls) under the + * clean V2 architecture. + * + * The patch is shallow-merged with the existing visual. To CLEAR a + * property, set its value to `undefined` in the patch. To REPLACE the + * entire visual, pass `{ replace: visual }` semantics — not yet + * supported; shallow merge only in R4A.1. + */ + +import { normalizeDocumentV2 } from './normalize'; +import { changed, noOp, type WordsEditorStateV2, type WordsOperationResultV2 } from './types'; +import { + WORDS_VISUAL_KEYS_PER_TYPE, + type BlockVisual, + type WordsBlockV2 +} from '../types-v2'; + +/** + * Shallow-merges `patch` into `block.visual`. Defends: + * + * - The block exists at `blockIndex`. + * - Every patch key is in `WORDS_VISUAL_KEYS_PER_TYPE[block.type]` + * (P8 — per-type whitelist enforced at runtime). Unknown keys are + * SILENTLY DROPPED with no warning; the validator on output will + * also reject them so this is defense-in-depth. + * - `undefined` values in the patch REMOVE the corresponding visual + * property from the merged result. So you can clear a radius by + * passing `{ cornerRadius: undefined }`. + * - When the resulting visual object is empty, the `visual` key is + * OMITTED from the block (not stored as `visual: {}`). + */ +export function setBlockVisual( + state: WordsEditorStateV2, + blockIndex: number, + patch: Partial +): WordsOperationResultV2 { + const block = state.document.children[blockIndex]; + if (!block) return noOp(state); + + const allowedKeys = WORDS_VISUAL_KEYS_PER_TYPE[block.type] as readonly string[]; + const sanitizedPatch: Record = {}; + for (const [k, v] of Object.entries(patch)) { + if (!allowedKeys.includes(k)) continue; + sanitizedPatch[k] = v; + } + + const prev = (block as { visual?: Record }).visual ?? {}; + const merged: Record = { ...prev }; + for (const [k, v] of Object.entries(sanitizedPatch)) { + if (v === undefined) { + delete merged[k]; + } else { + merged[k] = v; + } + } + + const visualEntries = Object.keys(merged); + const nextVisual = visualEntries.length > 0 ? (merged as BlockVisual) : undefined; + + // Bail if nothing actually changed (avoids triggering re-renders + + // history steps for no-op interactions). + if (shallowEqualVisual(prev as BlockVisual, nextVisual)) return noOp(state); + + const nextBlock = ( + nextVisual === undefined + ? omitVisual(block) + : { ...block, visual: nextVisual } + ) as WordsBlockV2; + + const nextDoc = normalizeDocumentV2({ + ...state.document, + children: replaceAt(state.document.children, blockIndex, nextBlock) + }).document; + + return changed({ ...state, document: nextDoc }); +} + +// ── Helpers ────────────────────────────────────────────────────────────── + +function shallowEqualVisual( + a: BlockVisual | undefined, + b: BlockVisual | undefined +): boolean { + if (a === b) return true; + if (!a || !b) return Object.keys(a ?? {}).length === 0 && Object.keys(b ?? {}).length === 0; + const ka = Object.keys(a); + const kb = Object.keys(b); + if (ka.length !== kb.length) return false; + for (const key of ka) { + const av = (a as Record)[key]; + const bv = (b as Record)[key]; + if (av === bv) continue; + // Shadow is the only nested object today. + if ( + key === 'shadow' && + typeof av === 'object' && + av !== null && + typeof bv === 'object' && + bv !== null + ) { + const ao = av as Record; + const bo = bv as Record; + if ( + ao.x === bo.x && + ao.y === bo.y && + ao.blur === bo.blur && + ao.spread === bo.spread && + ao.color === bo.color + ) { + continue; + } + return false; + } + return false; + } + return true; +} + +function omitVisual(block: WordsBlockV2): WordsBlockV2 { + // The `visual?: ...` is structurally optional on every block type, so + // destructuring it off and spreading the rest is sound. TS doesn't + // narrow this through discriminated unions, hence the `unknown` hop. + const { visual: _ignored, ...rest } = block as unknown as Record & { + visual?: unknown; + }; + return rest as unknown as WordsBlockV2; +} + +function replaceAt(arr: readonly T[], index: number, value: T): readonly T[] { + return [...arr.slice(0, index), value, ...arr.slice(index + 1)]; +}