diff --git a/src/uix/soma/components/words/engine/operations/insert-block-types.test.ts b/src/uix/soma/components/words/engine/operations/insert-block-types.test.ts new file mode 100644 index 000000000..ff619214a --- /dev/null +++ b/src/uix/soma/components/words/engine/operations/insert-block-types.test.ts @@ -0,0 +1,76 @@ +/** + * P5m — inserting a block into a table CELL (cells hold blocks like + * columns). The atomic-insert path must target the cell's children, not the + * document root next to the table. + */ + +import { describe, expect, it } from 'vitest'; +import { insertCallout, insertDivider } from './insert-block-types'; +import { createState } from './text'; +import { createCollapsedSelection } from '../selection'; +import { resolveTextNode } from './helpers'; +import { WORDS_VERSION, type WordsDocument } from '../types'; + +function tableDoc(cellText = ''): WordsDocument { + return { + version: WORDS_VERSION, + children: [ + { + type: 'table', + rows: [ + { + cells: [ + { children: [{ type: 'paragraph', children: [{ type: 'text', text: cellText }] }] } + ] + } + ] + } + ] + }; +} + +describe('insert block into a table cell (P5m)', () => { + it('inserts INTO the cell, not beside the table', () => { + const d = tableDoc(); + // Caret in the cell's empty paragraph: [table 0, row 0, cell 0, para 0, text 0]. + const sel = createCollapsedSelection([0, 0, 0, 0, 0], 0); + const r = insertDivider(createState(d, sel)); + expect(r.changed).toBe(true); + // Still a SINGLE top-level block (the table) — the divider did not land + // at the document root next to the table. + expect(r.state.document.children).toHaveLength(1); + const table = r.state.document.children[0]; + if (table.type !== 'table') throw new Error('expected table'); + const cell = table.rows[0].cells[0]; + // The empty paragraph was replaced by [divider, trailing paragraph]. + expect(cell.children.map((b) => b.type)).toEqual(['divider', 'paragraph']); + }); + + it('inserts a callout inside the cell', () => { + const d = tableDoc(); + const sel = createCollapsedSelection([0, 0, 0, 0, 0], 0); + const r = insertCallout(createState(d, sel), 'affirm'); + expect(r.changed).toBe(true); + expect(r.state.document.children).toHaveLength(1); + const table = r.state.document.children[0]; + if (table.type !== 'table') throw new Error('expected table'); + const cell = table.rows[0].cells[0]; + expect(cell.children.some((b) => b.type === 'callout')).toBe(true); + }); + + it('splits a non-empty cell paragraph around the inserted block', () => { + const d = tableDoc('ab'); + // Caret between "a" and "b": [table 0, row 0, cell 0, para 0, text 0], offset 1. + const sel = createCollapsedSelection([0, 0, 0, 0, 0], 1); + const r = insertDivider(createState(d, sel)); + expect(r.changed).toBe(true); + expect(r.state.document.children).toHaveLength(1); + const table = r.state.document.children[0]; + if (table.type !== 'table') throw new Error('expected table'); + const cell = table.rows[0].cells[0]; + // para("a") · divider · para("b") + expect(cell.children.map((b) => b.type)).toEqual(['paragraph', 'divider', 'paragraph']); + expect(resolveTextNode(r.state.document, [0, 0, 0, 0, 0])!.text).toBe('a'); + expect(resolveTextNode(r.state.document, [0, 0, 0, 2, 0])!.text).toBe('b'); + }); +}); diff --git a/src/uix/soma/components/words/engine/operations/insert-block-types.ts b/src/uix/soma/components/words/engine/operations/insert-block-types.ts index 390dedc01..43893cc6f 100644 --- a/src/uix/soma/components/words/engine/operations/insert-block-types.ts +++ b/src/uix/soma/components/words/engine/operations/insert-block-types.ts @@ -37,6 +37,9 @@ import { type WordsBlock, type Column, type ColumnsBlock, + type TableBlock, + type TableCell, + type TableRow, type WordsDocument, type WordsIntent, type WordsImageAlign, @@ -45,10 +48,7 @@ import { // ── replaceDocument ────────────────────────────────────────────────────── -export function replaceDocument( - state: WordsEditorState, - document: unknown -): WordsOperationResult { +export function replaceDocument(state: WordsEditorState, document: unknown): WordsOperationResult { // Accept either a full WordsDocument or a bare `children` array. // Normalize wraps a `children` array into a versioned doc. let next: WordsDocument; @@ -179,6 +179,28 @@ function insertAtomicBlock( ); } + // Table cell: the caret sits inside a cell's child block (cells hold + // blocks like columns since P5m). Insert into THAT cell's children, not at + // top level — otherwise the block ends up next to the table. Path = + // [tableIdx, rowIdx, cellIdx, blockInCell, ...]. Mirror of the columns + // case, one level deeper. + if ( + topBlock?.type === 'table' && + containerPath.length >= 4 && + containerPath[1] !== undefined && + containerPath[2] !== undefined && + containerPath[3] !== undefined + ) { + return insertIntoCell( + state, + block, + topIndex, + containerPath as readonly [number, number, number, ...number[]], + point, + opts + ); + } + // Empty paragraph at top level: replace in place. if ( topBlock?.type === 'paragraph' && @@ -229,9 +251,7 @@ function finalizeAtCaret( blockIndex: number, opts: InsertAtomicOptions ): WordsOperationResult { - const targetPath = opts.caretSubPath - ? [blockIndex, ...opts.caretSubPath] - : [blockIndex]; + const targetPath = opts.caretSubPath ? [blockIndex, ...opts.caretSubPath] : [blockIndex]; const point = pointFromInlineTextOffset(document, targetPath, 0); const sel = createCollapsedSelection(point.path, point.offset); return changed({ @@ -290,11 +310,7 @@ function insertIntoColumn( createParagraph(), ...col.children.slice(innerIdx + 1) ] - : [ - ...col.children.slice(0, innerIdx), - block, - ...col.children.slice(innerIdx + 1) - ]; + : [...col.children.slice(0, innerIdx), block, ...col.children.slice(innerIdx + 1)]; newInnerIdx = innerIdx; } else { // Split the inner inline-container at the caret. `before` keeps the @@ -336,8 +352,7 @@ function insertIntoColumn( isEmptyInner && newInnerIdx + 1 < (nextCol.children as readonly WordsBlock[]).length && (nextCol.children as readonly WordsBlock[])[newInnerIdx + 1]?.type === 'paragraph'; - const caretInnerIdx = - isAtomicInserted && hasTrailingParagraph ? newInnerIdx + 1 : newInnerIdx; + const caretInnerIdx = isAtomicInserted && hasTrailingParagraph ? newInnerIdx + 1 : newInnerIdx; const targetPath = opts.caretSubPath ? [colsIdx, colIdx, caretInnerIdx, ...opts.caretSubPath] @@ -351,6 +366,97 @@ function insertIntoColumn( }); } +/** + * Insert a block into a table CELL's children at the caret. Cells hold + * blocks (like columns, since P5m); container path = + * [tableIdx, rowIdx, cellIdx, innerIdx, ...subPath]. Mirror of + * `insertIntoColumn`, one level deeper (the cell is at depth 2, its block + * at depth 3). + */ +function insertIntoCell( + state: WordsEditorState, + block: WordsBlock, + tableIdx: number, + containerPath: readonly [number, number, number, ...number[]], + point: { path: import('../path').WordsPath; offset: number }, + opts: InsertAtomicOptions +): WordsOperationResult { + const tableBlock = state.document.children[tableIdx] as TableBlock; + const rowIdx = containerPath[1]; + const cellIdx = containerPath[2]; + const innerIdx = containerPath[3] ?? 0; + const row = tableBlock.rows[rowIdx]; + const cell = row?.cells[cellIdx]; + if (!cell) return noOp(state); + const innerBlock = cell.children[innerIdx]; + if (!innerBlock) return noOp(state); + + // Empty paragraph inside the cell → replace in place. + const isEmptyInner = + innerBlock.type === 'paragraph' && + containerPath.length === 4 && + innerBlock.children.length === 1 && + innerBlock.children[0].type === 'text' && + innerBlock.children[0].text.length === 0; + + let nextInner: readonly WordsBlock[]; + let newInnerIdx: number; + if (isEmptyInner) { + // Keep a trailing paragraph after an inserted atomic so the user can + // keep typing below it inside the cell (mirror of the column rule). + const isLastInner = innerIdx === cell.children.length - 1; + nextInner = isLastInner + ? [ + ...cell.children.slice(0, innerIdx), + block, + createParagraph(), + ...cell.children.slice(innerIdx + 1) + ] + : [...cell.children.slice(0, innerIdx), block, ...cell.children.slice(innerIdx + 1)]; + newInnerIdx = innerIdx; + } else { + const split = splitContainerAtPoint(state.document, containerPath, point); + const beforeBlock = mutateInlines(innerBlock, ensureInlineChildren(split.before)); + const afterBlock = createParagraph(ensureInlineChildren(split.after)); + nextInner = [ + ...cell.children.slice(0, innerIdx), + beforeBlock, + block, + afterBlock, + ...cell.children.slice(innerIdx + 1) + ]; + newInnerIdx = innerIdx + 1; + } + + const nextCell: TableCell = { ...cell, children: nextInner }; + const nextCells = row.cells.map((c, i) => (i === cellIdx ? nextCell : c)); + const nextRow: TableRow = { ...row, cells: nextCells }; + const nextRows = tableBlock.rows.map((r, i) => (i === rowIdx ? nextRow : r)); + const nextTable: TableBlock = { ...tableBlock, rows: nextRows }; + const normalized = normalizeDocument({ + ...state.document, + children: replaceAt(state.document.children, tableIdx, nextTable) + }).document; + + const isAtomicInserted = block.type === 'image' || block.type === 'divider'; + const hasTrailingParagraph = + isEmptyInner && + newInnerIdx + 1 < (nextCell.children as readonly WordsBlock[]).length && + (nextCell.children as readonly WordsBlock[])[newInnerIdx + 1]?.type === 'paragraph'; + const caretInnerIdx = isAtomicInserted && hasTrailingParagraph ? newInnerIdx + 1 : newInnerIdx; + + const targetPath = opts.caretSubPath + ? [tableIdx, rowIdx, cellIdx, caretInnerIdx, ...opts.caretSubPath] + : [tableIdx, rowIdx, cellIdx, caretInnerIdx]; + const targetPoint = pointFromInlineTextOffset(normalized, targetPath, 0); + const sel = createCollapsedSelection(targetPoint.path, targetPoint.offset); + return changed({ + document: normalized, + selection: sel, + activeMarks: getActiveMarksForSelection(normalized, sel) + }); +} + function mutateInlines( block: WordsBlock, inlines: readonly import('../types').WordsInline[] @@ -457,9 +563,7 @@ export function insertBlockInColumn( // For list blocks the container is the FIRST LIST ITEM, not the // list itself — list-items hold the inline children directly. const containerPath = - block.type === 'list' - ? [colsIdx, colIdx, newInnerIdx, 0] - : [colsIdx, colIdx, newInnerIdx]; + block.type === 'list' ? [colsIdx, colIdx, newInnerIdx, 0] : [colsIdx, colIdx, newInnerIdx]; const startPoint = pointFromInlineTextOffset(normalized, containerPath, 0); if (stub.length === 0) { sel = createCollapsedSelection(startPoint.path, startPoint.offset);