feat(words): R4A.1 — foundation V2 ops (types + normalize + setBlockVisual + 5 block ops) + 31 tests

R4A.1 opens the operations layer of the V2 engine. Pure functions only;
no provider coupling. Lives in `engine/operations-v2/`. The full V1
op surface (~50 functions, ~3000 lines) splits across multiple R4A.*
sprints — this one establishes the foundation + the V2-exclusive
visual operation that unlocks POLISH-1b in later sprints.

operations-v2/types.ts (~55 lines):
- WordsEditorStateV2 = { document, selection, activeMarks } over V2
  shapes. Reuses V1's `WordsSelection` (path + offset) verbatim — the
  selection model doesn't change between V1 and V2.
- WordsOperationResultV2 = { state, changed }. Same shape as V1's
  result so future provider migration can plug in directly.
- noOp + changed helpers for guard-clause and result construction.

operations-v2/normalize.ts (~250 lines):
- normalizeDocumentV2(doc, opts?) — defensive shape repair after
  every operation. Returns same document REFERENCE when nothing
  changed (cheap structural compare via mutated flag).
- Autogen `id` for blocks/items/rows/cells without one (delegates
  to defaultIdGenerator from migrate-v2 → crypto.randomUUID + fallback).
- Seeds empty containers: empty document → 1 paragraph; empty list
  → 1 item; empty table → 1×1 cell; empty inline children → 1
  empty text.
- Coalesces adjacent text inlines with identical marks (V1 invariant
  preserved — keeps the model from fragmenting after edits).
- Strips marks from code block children (code is plain text only).

operations-v2/visual.ts (~115 lines):
- setBlockVisual(state, blockIndex, patch) — V2-EXCLUSIVE.
- Shallow-merges patch into block.visual. Defends:
  * blockIndex must be valid.
  * Every patch key must be in WORDS_VISUAL_KEYS_PER_TYPE[block.type]
    (P8 whitelist enforced at runtime; unknown keys silently dropped).
  * Patch values of `undefined` REMOVE the corresponding visual
    property from the merged result (clears via undefined).
  * Empty visual is omitted from the block (no `visual: {}` stored).
  * No-op result when patch matches existing visual (avoids
    spurious re-renders).
- This is the operation POLISH-1b (image radius slider + shadow
  toggles + border controls) will call.

operations-v2/block.ts (~140 lines):
- updateBlockAt(state, blockIndex, patch) — shallow-merges block
  fields, refuses to change `type`.
- deleteBlockAt(state, blockIndex) — removes block; normalizer
  seeds an empty paragraph if it was the last.
- insertBlockAt(state, blockIndex, block) — inserts at index (clamps
  to bounds).
- moveBlockAt(state, blockIndex, direction) — swap with neighbor.
- duplicateBlockAt(state, blockIndex) — inserts a copy with a fresh
  autogen id (drops the original's id before normalize so they're
  distinguishable).
- moveBlockToAt(state, fromIndex, toIndex) — arbitrary repositioning
  for drag-drop.

operations-v2/index.ts: barrel export of the public surface.

Tests (31): normalize (autogen ids preserved when present, empty
containers seeded for doc/list/table/inline children, text coalescing
with marks-aware preservation, code stripping, idempotence via same
reference). setBlockVisual (set/merge/clear/empty-elision/per-type
whitelist enforcement/shadow on image vs paragraph/no-op on match).
Block ops (updateBlockAt with type-change refusal + invalid index
no-op; deleteBlockAt with seed-on-empty; insertBlockAt with clamping;
moveBlockAt with edge no-op; duplicateBlockAt with fresh id;
moveBlockToAt with from-equals-to no-op).

Two TS errors landed mid-write (over-clever readonly modifier on
function param + structural cast across discriminated union) — both
fixed with simpler types. Verification: 257/257 engine tests pass
(85 V1 + 50 V2 validator + 35 V2 migrator/parity + 56 V2 render/
serializers + 31 V2 ops). `npm run check`: 0 errors.

Next: R4A.2 — text + selection operations (insertText, deleteRange,
insertParagraph, deleteBackward, deleteForward, toggleMark, etc.).
~2500 lines of V1 to mechanically translate. Or pause R4 entirely
and tackle the pragmatic shortcut (backport visual to V1 image to
ship POLISH-1b sooner).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
active-uix
dev 4 months ago
parent 194ca208f9
commit 74e54384ea

@ -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. |

@ -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<Record<string, unknown>>
): 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<string, unknown> = {};
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<T>(arr: readonly T[], index: number, value: T): readonly T[] {
return [...arr.slice(0, index), value, ...arr.slice(index + 1)];
}

@ -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';

@ -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';

@ -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: '' }]
};
}

@ -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);
});
});

@ -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 };
}

@ -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<BlockVisual>
): 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<string, unknown> = {};
for (const [k, v] of Object.entries(patch)) {
if (!allowedKeys.includes(k)) continue;
sanitizedPatch[k] = v;
}
const prev = (block as { visual?: Record<string, unknown> }).visual ?? {};
const merged: Record<string, unknown> = { ...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<string, unknown>)[key];
const bv = (b as Record<string, unknown>)[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<string, unknown>;
const bo = bv as Record<string, unknown>;
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<string, unknown> & {
visual?: unknown;
};
return rest as unknown as WordsBlockV2;
}
function replaceAt<T>(arr: readonly T[], index: number, value: T): readonly T[] {
return [...arr.slice(0, index), value, ...arr.slice(index + 1)];
}
Loading…
Cancel
Save

Powered by TurnKey Linux.