feat(words): insert blocks INTO table cells (complete P5m's UI path)

P5m made the model accept blocks in cells, but the atomic-insert path
(`insertAtomicBlock`) only special-cased columns — with the caret in a cell it
fell through to the top-level split, dropping the block NEXT TO the table
instead of inside the cell. So "blocks in cells" was model-only, not reachable.

Adds an `insertIntoCell` branch mirroring `insertIntoColumn`, one level deeper
(container path [tableIdx, rowIdx, cellIdx, blockInCell, ...]): empty-cell
paragraph → replace in place (+ trailing paragraph); non-empty → split the
cell's block around the inserted one. So insert-callout / divider / image (and
any atomic insert) with the caret in a cell now land inside that cell.

New test `insert-block-types.test.ts` (3/3): inserts into the cell (single
top-level table preserved), callout lands inside, non-empty cell splits
(para("a") · divider · para("b")). npm run check: 0 new errors.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
active-uix
dev 4 months ago
parent aac80f7630
commit c4acfa4460

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

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

Loading…
Cancel
Save

Powered by TurnKey Linux.