feat(words): table cells hold blocks (TableCell.children → WordsBlock[])

P5m. A table cell now holds full BLOCKS (paragraphs, lists, images, nested
tables) like Callout / Column, not just inline text. A fresh cell seeds one
empty paragraph; the existing generic block machinery (path walker, render,
validate, normalize) handles cells the same way it handles callout / column.

Atomic model change across the engine:
- types: TableCell.children: WordsInline[] -> WordsBlock[].
- factories / table-ops / extra-ops: cells seed [paragraph], not [text].
- normalize: normalizeTableCells recurses each child block (mirrors callout);
  empty cell seeds a paragraph.
- built-ins table spec: render -> renderBlock per child; validate ->
  validateBlock per child; HTML -> blockToHtml per child (<td><p>...</p></td>);
  Markdown -> flatten blocks to inline joined with <br> (GFM cells are
  inline-only; lossy, consistent with markdown being export-only).
- serialize-text + selection-walkers: cell text recurses block children.
- delete-ops: inline cross-cell merge (mergeTablePrev/Next) replaced by
  recurseIntoTableMerge — treats the cell's blocks as a sub-document, so
  backspace merges blocks WITHIN a cell; at a cell's first block it is a no-op
  (cells never merge across the fixed grid — matches mainstream editors).

Caret paths into a cell are one level deeper now ([t,r,c,blockIdx,...]). All
table test fixtures wrapped (inline -> paragraph) and 13 assertions updated to
the new behavior (<p> wrappers, deeper paths, no cross-cell merge).

Browser-verified: cells render block content (path 14.0.0.0), table renders
clean. npm run check: 0 words errors (1 pre-existing grafito baseline). Words
tests: 452/452.

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

@ -24,6 +24,7 @@ import {
WORDS_VERTICAL_ALIGNS,
isWordsIntent
} from '../types';
import type { WordsBlock } from '../types';
import type { WordsPath } from '../path';
import type {
WordsBlockRenderContext,
@ -156,7 +157,7 @@ const headingSpec: WordsBlockSpec = {
},
render(block, ctx) {
const b = block as HeadingBlock;
const tag = (`h${b.level}` as const) satisfies WordsRenderTag;
const tag = `h${b.level}` as const satisfies WordsRenderTag;
return {
kind: 'element',
tag,
@ -184,7 +185,10 @@ const quoteSpec: WordsBlockSpec = {
],
validate(block, ctx) {
ctx.validateInlineChildren(block.children, `${ctx.path}/children`);
if (block.cite !== undefined && (typeof block.cite !== 'string' || !ctx.isParseableUrl(block.cite))) {
if (
block.cite !== undefined &&
(typeof block.cite !== 'string' || !ctx.isParseableUrl(block.cite))
) {
ctx.error(
`${ctx.path}/cite`,
'invalid-href',
@ -386,7 +390,9 @@ const tableSpec: WordsBlockSpec = {
}
const body = bodyRows
.map((row) => {
const cells = row.cells.map((cell, i) => cellToHtml(cell, headerCol && i === 0, ctx)).join('');
const cells = row.cells
.map((cell, i) => cellToHtml(cell, headerCol && i === 0, ctx))
.join('');
return `<tr>${cells}</tr>`;
})
.join('');
@ -559,9 +565,7 @@ const imageSpec: WordsBlockSpec = {
kind: 'element',
tag: 'span',
attrs: { 'data-words-image-placeholder-hint': '' },
children: [
{ kind: 'text', text: 'Open the inspector and paste a URL.' }
]
children: [{ kind: 'text', text: 'Open the inspector and paste a URL.' }]
}
]
}
@ -687,7 +691,11 @@ const calloutSpec: WordsBlockSpec = {
);
}
if (block.title !== undefined && typeof block.title !== 'string') {
ctx.error(`${ctx.path}/title`, 'invalid-block-shape', 'callout.title must be a string if present');
ctx.error(
`${ctx.path}/title`,
'invalid-block-shape',
'callout.title must be a string if present'
);
}
if (!Array.isArray(block.children)) {
ctx.error(
@ -842,7 +850,8 @@ function renderTableCell(
...(cell.rowspan ? { rowspan: String(cell.rowspan) } : {}),
...(cellStyle ? { style: cellStyle } : {})
},
children: ctx.renderInlines(cell.children, path)
// Cells hold blocks (like callout / column): render each child block.
children: cell.children.map((child, i) => ctx.renderBlock(child, [...path, i]))
};
}
@ -851,11 +860,7 @@ function renderTableCell(
// own. Mirror the pre-registry private validators; read fields defensively
// off the untrusted `unknown` input.
function validateListItemFields(
item: unknown,
path: string,
ctx: WordsBlockValidateContext
): void {
function validateListItemFields(item: unknown, path: string, ctx: WordsBlockValidateContext): void {
if (!ctx.isPlainObject(item)) {
ctx.error(path, 'invalid-block-shape', 'list item must be an object');
return;
@ -863,7 +868,11 @@ function validateListItemFields(
ctx.validateId(item.id, `${path}/id`);
ctx.validateInlineChildren(item.children, `${path}/children`);
if (item.checked !== undefined && typeof item.checked !== 'boolean') {
ctx.error(`${path}/checked`, 'invalid-block-shape', 'list-item.checked must be boolean if present');
ctx.error(
`${path}/checked`,
'invalid-block-shape',
'list-item.checked must be boolean if present'
);
}
if (item.indent !== undefined) {
const n = item.indent as number;
@ -877,11 +886,7 @@ function validateListItemFields(
}
}
function validateTableRowFields(
row: unknown,
path: string,
ctx: WordsBlockValidateContext
): void {
function validateTableRowFields(row: unknown, path: string, ctx: WordsBlockValidateContext): void {
if (!ctx.isPlainObject(row)) {
ctx.error(path, 'invalid-block-shape', 'table row must be an object');
return;
@ -915,7 +920,14 @@ function validateTableCellFields(
}
ctx.validateId(cell.id, `${path}/id`);
ctx.validateStyle(cell, path);
ctx.validateInlineChildren(cell.children, `${path}/children`);
// Cells hold blocks (like callout / column): validate each child block.
if (!Array.isArray(cell.children)) {
ctx.error(`${path}/children`, 'invalid-children', 'cell.children must be an array of blocks');
} else {
for (let i = 0; i < cell.children.length; i++) {
ctx.validateBlock(cell.children[i], `${path}/children/${i}`);
}
}
ctx.validateOptionalEnum(cell.verticalAlign, WORDS_VERTICAL_ALIGNS, `${path}/verticalAlign`);
if (cell.colspan !== undefined) {
const n = cell.colspan as number;
@ -964,7 +976,9 @@ function cellToHtml(cell: TableCell, isHeader: boolean, ctx: WordsHtmlSerializeC
if (cell.rowspan !== undefined && cell.rowspan > 1) attrs += ` rowspan="${cell.rowspan}"`;
const styled = ctx.stringifyStyle(style);
if (styled !== undefined) attrs += ` style="${ctx.escapeAttr(styled)}"`;
return `<${tag}${attrs}>${ctx.inlinesToHtml(cell.children)}</${tag}>`;
// Cells hold blocks (like callout): serialize each child block.
const inner = cell.children.map((child) => ctx.blockToHtml(child)).join('');
return `<${tag}${attrs}>${inner}</${tag}>`;
}
// ── List / table Markdown sub-serializers ─────────────────────────────────
@ -990,7 +1004,49 @@ function listItemToMd(
}
function cellTextToMd(cell: TableCell, ctx: WordsMarkdownSerializeContext): string {
return ctx.inlinesToMd(cell.children).replace(/\|/g, '\\|');
// GFM table cells are inline-only: flatten each child block to inline
// markdown and join with <br> (the GFM idiom for multi-line cells).
// Lossy by design — block structure (lists, nested tables) collapses to
// inline text, consistent with markdown being an export-only format.
const flat = cell.children
.map((child) => blockToInlineMd(child, ctx))
.filter((s) => s.length > 0)
.join('<br>');
return flat.replace(/\|/g, '\\|');
}
/**
* Flatten ONE block to inline markdown for a GFM table cell (which cannot
* hold block content). Recurses through nestable containers. Lossy.
*/
function blockToInlineMd(block: WordsBlock, ctx: WordsMarkdownSerializeContext): string {
switch (block.type) {
case 'paragraph':
case 'heading':
case 'quote':
return ctx.inlinesToMd(block.children);
case 'code':
return '`' + block.children.map((t) => t.text).join(' ') + '`';
case 'list':
return block.items.map((it) => ctx.inlinesToMd(it.children)).join('<br>');
case 'callout':
return block.children.map((c) => blockToInlineMd(c, ctx)).join('<br>');
case 'columns':
return block.columns
.flatMap((col) => col.children.map((c) => blockToInlineMd(c, ctx)))
.join('<br>');
case 'table':
return block.rows
.flatMap((r) =>
r.cells.map((c) => c.children.map((cc) => blockToInlineMd(cc, ctx)).join(' '))
)
.join('<br>');
case 'image':
return block.alt ?? '';
default:
// divider + any plugin block: no meaningful inline text.
return '';
}
}
const columnsSpec: WordsBlockSpec = {
@ -1113,11 +1169,7 @@ const columnsSpec: WordsBlockSpec = {
}
};
function validateColumnFields(
col: unknown,
path: string,
ctx: WordsBlockValidateContext
): void {
function validateColumnFields(col: unknown, path: string, ctx: WordsBlockValidateContext): void {
if (!ctx.isPlainObject(col)) {
ctx.error(path, 'invalid-block-shape', 'column must be an object');
return;
@ -1125,11 +1177,7 @@ function validateColumnFields(
ctx.validateId(col.id, `${path}/id`);
ctx.validateStyle(col, path);
if (!Array.isArray(col.children)) {
ctx.error(
`${path}/children`,
'invalid-children',
'column.children must be an array of blocks'
);
ctx.error(`${path}/children`, 'invalid-children', 'column.children must be an array of blocks');
return;
}
for (let i = 0; i < col.children.length; i++) {

@ -109,7 +109,11 @@ describe('setBlock — paragraph ↔ heading ↔ quote ↔ code', () => {
{ type: 'divider' },
{
type: 'table',
rows: [{ cells: [{ children: [{ type: 'text', text: 'c' }] }] }]
rows: [
{
cells: [{ children: [{ type: 'paragraph', children: [{ type: 'text', text: 'c' }] }] }]
}
]
}
);
const sel = rangeSelection([0], 0, [2, 0, 0, 0], 1);
@ -186,7 +190,11 @@ describe('setTextAlign', () => {
{ type: 'list', kind: 'unordered', items: [{ children: [{ type: 'text', text: 'L' }] }] },
{
type: 'table',
rows: [{ cells: [{ children: [{ type: 'text', text: 'T' }] }] }]
rows: [
{
cells: [{ children: [{ type: 'paragraph', children: [{ type: 'text', text: 'T' }] }] }]
}
]
},
{ type: 'image', src: 'i.jpg' },
{ type: 'code', children: [{ type: 'text', text: 'C' }] },

@ -155,33 +155,38 @@ describe('deleteBackward — list', () => {
// ── deleteBackward — table cell ──────────────────────────────────────────
describe('deleteBackward — table cell', () => {
it('cell col > 0: merges with previous cell in row', () => {
it('cell col > 0: backspace at cell start does NOT merge across cells', () => {
const d = doc({
type: 'table',
rows: [
{
cells: [
{ children: [{ type: 'text', text: 'A' }] },
{ children: [{ type: 'text', text: 'B' }] }
{ children: [{ type: 'paragraph', children: [{ type: 'text', text: 'A' }] }] },
{ children: [{ type: 'paragraph', children: [{ type: 'text', text: 'B' }] }] }
]
}
]
});
const sel = createCollapsedSelection([0, 0, 1, 0], 0);
// Caret at the start of the SECOND cell's first block. Cells are
// fixed grid positions — backspace here is a no-op (no cross-cell merge).
const sel = createCollapsedSelection([0, 0, 1, 0, 0], 0);
const r = deleteBackward(createState(d, sel));
expect(r.changed).toBe(true);
expect(r.changed).toBe(false);
const table = r.state.document.children[0];
if (table.type !== 'table') throw new Error('expected table');
expect(table.rows[0].cells).toHaveLength(1);
expect(resolveTextNode(r.state.document, [0, 0, 0, 0])!.text).toBe('AB');
expect(table.rows[0].cells).toHaveLength(2);
});
it('cell col 0: no-op (does NOT merge across rows)', () => {
const d = doc({
type: 'table',
rows: [
{ cells: [{ children: [{ type: 'text', text: 'r0c0' }] }] },
{ cells: [{ children: [{ type: 'text', text: 'r1c0' }] }] }
{
cells: [{ children: [{ type: 'paragraph', children: [{ type: 'text', text: 'r0c0' }] }] }]
},
{
cells: [{ children: [{ type: 'paragraph', children: [{ type: 'text', text: 'r1c0' }] }] }]
}
]
});
const sel = createCollapsedSelection([0, 1, 0, 0], 0);
@ -288,24 +293,25 @@ describe('deleteForward — list / table', () => {
expect(list.items[0].children[0]).toMatchObject({ text: 'firstsecond' });
});
it('table cell: merges with next cell in row at end of current', () => {
it('table cell: deleteForward at cell end does NOT merge across cells', () => {
const d = doc({
type: 'table',
rows: [
{
cells: [
{ children: [{ type: 'text', text: 'A' }] },
{ children: [{ type: 'text', text: 'B' }] }
{ children: [{ type: 'paragraph', children: [{ type: 'text', text: 'A' }] }] },
{ children: [{ type: 'paragraph', children: [{ type: 'text', text: 'B' }] }] }
]
}
]
});
const sel = createCollapsedSelection([0, 0, 0, 0], 1);
// Caret at the end of the FIRST cell's first block. Cells are fixed
// grid positions — deleteForward here is a no-op (no cross-cell merge).
const sel = createCollapsedSelection([0, 0, 0, 0, 0], 1);
const r = deleteForward(createState(d, sel));
expect(r.changed).toBe(true);
expect(r.changed).toBe(false);
const table = r.state.document.children[0];
if (table.type !== 'table') throw new Error('expected table');
expect(table.rows[0].cells).toHaveLength(1);
expect(resolveTextNode(r.state.document, [0, 0, 0, 0])!.text).toBe('AB');
expect(table.rows[0].cells).toHaveLength(2);
});
});

@ -28,11 +28,7 @@
*/
import { samePath, type WordsPath } from '../path';
import {
createCollapsedSelection,
normalizeRange,
type WordsSelection
} from '../selection';
import { createCollapsedSelection, normalizeRange, type WordsSelection } from '../selection';
import { deleteRange } from './delete';
import {
editingContainerPath,
@ -127,7 +123,7 @@ function mergeWithPreviousContainer(
}
if (block.type === 'table') {
return mergeTablePrev(state, block, containerPath);
return recurseIntoTableMerge(state, block, blockIndex, 'backward');
}
if (block.type === 'callout' && containerPath.length >= 2) {
@ -208,11 +204,7 @@ function mergeListPrev(
};
const nextList: ListBlock = {
...block,
items: [
...block.items.slice(0, itemIndex - 1),
mergedItem,
...block.items.slice(itemIndex + 1)
]
items: [...block.items.slice(0, itemIndex - 1), mergedItem, ...block.items.slice(itemIndex + 1)]
};
const normalized = normalizeDocument({
...state.document,
@ -227,51 +219,64 @@ function mergeListPrev(
});
}
function mergeTablePrev(
// Cells hold blocks (like callout / column): a caret inside a cell sits in a
// block at path [tableIndex, rowIndex, cellIndex, ...rest]. Treat the cell's
// block list as a sub-document and recurse the delete there — so backspace at
// the start of a NON-first block in a cell merges WITHIN the cell, while at the
// first block it finds no previous block and is a no-op (cells never merge
// across the fixed grid). Mirrors `recurseIntoCalloutMerge`.
function recurseIntoTableMerge(
state: WordsEditorState,
block: TableBlock,
containerPath: WordsPath
table: TableBlock,
tableIndex: number,
direction: 'backward' | 'forward'
): WordsOperationResult {
const blockIndex = containerPath[0] ?? -1;
const rowIndex = containerPath[1] ?? -1;
const cellIndex = containerPath[2] ?? -1;
if (rowIndex < 0 || cellIndex <= 0) return noOp(state);
const row = block.rows[rowIndex];
const previous = row?.cells[cellIndex - 1];
const current = row?.cells[cellIndex];
if (!row || !previous || !current) return noOp(state);
const selection = state.selection;
if (!selection) return noOp(state);
const rowIndex = selection.anchor.path[1] ?? -1;
const cellIndex = selection.anchor.path[2] ?? -1;
const row = table.rows[rowIndex];
const cell = row?.cells[cellIndex];
if (!row || !cell) return noOp(state);
const offset = inlineTextLength(previous.children);
const nextCell: TableCell = {
...previous,
children: ensureInlinesNonEmpty([...previous.children, ...current.children])
};
const nextRow: TableRow = {
...row,
cells: [
...row.cells.slice(0, cellIndex - 1),
nextCell,
...row.cells.slice(cellIndex + 1)
]
const subDoc: WordsDocument = { version: WORDS_VERSION, children: cell.children };
const subSelection: WordsSelection = {
anchor: { path: selection.anchor.path.slice(3), offset: selection.anchor.offset },
focus: { path: selection.focus.path.slice(3), offset: selection.focus.offset }
};
const nextTable: TableBlock = {
...block,
rows: replaceAt(block.rows, rowIndex, nextRow)
const subState: WordsEditorState = {
document: subDoc,
selection: subSelection,
activeMarks: state.activeMarks
};
const subResult = direction === 'backward' ? deleteBackward(subState) : deleteForward(subState);
if (!subResult.changed) return noOp(state);
const newCell: TableCell = { ...cell, children: subResult.state.document.children };
const newRow: TableRow = { ...row, cells: replaceAt(row.cells, cellIndex, newCell) };
const newTable: TableBlock = { ...table, rows: replaceAt(table.rows, rowIndex, newRow) };
const subSel = subResult.state.selection;
const prefix = [tableIndex, rowIndex, cellIndex];
const newSelection: WordsSelection | null = subSel
? {
anchor: { path: [...prefix, ...subSel.anchor.path], offset: subSel.anchor.offset },
focus: { path: [...prefix, ...subSel.focus.path], offset: subSel.focus.offset }
}
: null;
const normalized = normalizeDocument({
...state.document,
children: replaceAt(state.document.children, blockIndex, nextTable)
children: replaceAt(state.document.children, tableIndex, newTable)
}).document;
const point = pointFromInlineTextOffset(
normalized,
[blockIndex, rowIndex, cellIndex - 1],
offset
);
const sel = createCollapsedSelection(point.path, point.offset);
return changed({
document: normalized,
selection: sel,
activeMarks: getActiveMarksForSelection(normalized, sel)
selection: newSelection,
activeMarks: newSelection
? getActiveMarksForSelection(normalized, newSelection)
: state.activeMarks
});
}
@ -290,7 +295,7 @@ function mergeWithNextContainer(
}
if (block.type === 'table') {
return mergeTableNext(state, block, containerPath);
return recurseIntoTableMerge(state, block, blockIndex, 'forward');
}
if (block.type === 'callout' && containerPath.length >= 2) {
@ -342,11 +347,7 @@ function mergeListNext(
};
const nextList: ListBlock = {
...block,
items: [
...block.items.slice(0, itemIndex),
mergedItem,
...block.items.slice(itemIndex + 2)
]
items: [...block.items.slice(0, itemIndex), mergedItem, ...block.items.slice(itemIndex + 2)]
};
const normalized = normalizeDocument({
...state.document,
@ -361,54 +362,6 @@ function mergeListNext(
});
}
function mergeTableNext(
state: WordsEditorState,
block: TableBlock,
containerPath: WordsPath
): WordsOperationResult {
const blockIndex = containerPath[0] ?? -1;
const rowIndex = containerPath[1] ?? -1;
const cellIndex = containerPath[2] ?? -1;
if (rowIndex < 0 || cellIndex < 0) return noOp(state);
const row = block.rows[rowIndex];
const current = row?.cells[cellIndex];
const next = row?.cells[cellIndex + 1];
if (!row || !current || !next) return noOp(state);
const offset = inlineTextLength(current.children);
const nextCell: TableCell = {
...current,
children: ensureInlinesNonEmpty([...current.children, ...next.children])
};
const nextRow: TableRow = {
...row,
cells: [
...row.cells.slice(0, cellIndex),
nextCell,
...row.cells.slice(cellIndex + 2)
]
};
const nextTable: TableBlock = {
...block,
rows: replaceAt(block.rows, rowIndex, nextRow)
};
const normalized = normalizeDocument({
...state.document,
children: replaceAt(state.document.children, blockIndex, nextTable)
}).document;
const point = pointFromInlineTextOffset(
normalized,
[blockIndex, rowIndex, cellIndex],
offset
);
const sel = createCollapsedSelection(point.path, point.offset);
return changed({
document: normalized,
selection: sel,
activeMarks: getActiveMarksForSelection(normalized, sel)
});
}
// ── Callout recursive descent ────────────────────────────────────────────
function recurseIntoCalloutMerge(
@ -434,8 +387,7 @@ function recurseIntoCalloutMerge(
activeMarks: state.activeMarks
};
const subResult =
direction === 'backward' ? deleteBackward(subState) : deleteForward(subState);
const subResult = direction === 'backward' ? deleteBackward(subState) : deleteForward(subState);
if (!subResult.changed) return noOp(state);
const newCallout: CalloutBlock = {
@ -473,10 +425,7 @@ function recurseIntoCalloutMerge(
// ── Helpers ──────────────────────────────────────────────────────────────
type WordsInlineBlock = Extract<
WordsBlock,
{ type: 'paragraph' | 'heading' | 'quote' | 'code' }
>;
type WordsInlineBlock = Extract<WordsBlock, { type: 'paragraph' | 'heading' | 'quote' | 'code' }>;
function isInlineBlock(block: WordsBlock): block is WordsInlineBlock {
return (
@ -519,8 +468,6 @@ function mergeInlinesForBlock(
return combined;
}
function ensureInlinesNonEmpty(
inlines: readonly WordsInline[]
): readonly WordsInline[] {
function ensureInlinesNonEmpty(inlines: readonly WordsInline[]): readonly WordsInline[] {
return inlines.length > 0 ? inlines : [{ type: 'text', text: '' }];
}

@ -124,7 +124,7 @@ describe('deleteRange — same inline container', () => {
expect(p.children).toEqual([{ type: 'text', text: '' }]);
});
it('deletes within a link\'s text children — link stays whole, text coalesces', () => {
it("deletes within a link's text children — link stays whole, text coalesces", () => {
const d = doc({
type: 'paragraph',
children: [
@ -399,7 +399,13 @@ describe('deleteRange — atomic blocks no-op', () => {
{ type: 'paragraph', children: [{ type: 'text', text: 'before' }] },
{
type: 'table',
rows: [{ cells: [{ children: [{ type: 'text', text: 'cell' }] }] }]
rows: [
{
cells: [
{ children: [{ type: 'paragraph', children: [{ type: 'text', text: 'cell' }] }] }
]
}
]
}
);
const sel = rangeSelection([0, 0], 2, [1, 0, 0, 0], 2);
@ -505,4 +511,3 @@ describe('deleteRange — caret placement', () => {
expect(r.state.selection?.focus.offset).toBe(3); // "hel" length
});
});

@ -30,14 +30,7 @@ import { deleteRange } from './delete';
import { normalizeDocument } from './normalize';
import { getActiveMarksForSelection } from './selection-walkers';
import { changed, noOp, type WordsEditorState, type WordsOperationResult } from './types';
import type {
WordsBlock,
WordsInline,
TableBlock,
TableCell,
TableRow,
WordsText
} from '../types';
import type { WordsBlock, WordsInline, TableBlock, TableCell, TableRow, WordsText } from '../types';
// ── Word / line boundary deletes ─────────────────────────────────────────
@ -220,11 +213,7 @@ export function outdentCodeLine(state: WordsEditorState): WordsOperationResult {
if (!deleted.changed) return deleted;
const nextOffset = offset > lineStart ? Math.max(lineStart, offset - deleteLength) : offset;
const nextPoint = pointFromInlineTextOffset(
deleted.state.document,
containerPath,
nextOffset
);
const nextPoint = pointFromInlineTextOffset(deleted.state.document, containerPath, nextOffset);
const sel = createCollapsedSelection(nextPoint.path, nextPoint.offset);
return changed({
document: deleted.state.document,
@ -273,7 +262,7 @@ export function moveTableCell(
const columnCount = tableColumnCount(table);
const emptyRow: TableRow = {
cells: Array.from({ length: columnCount }, () => ({
children: [{ type: 'text', text: '' }] as WordsText[]
children: [{ type: 'paragraph', children: [{ type: 'text', text: '' }] }]
}))
};
const nextTable: TableBlock = { ...table, rows: [...table.rows, emptyRow] };
@ -295,18 +284,12 @@ function selectCell(
}
function selectCellInDoc(
document: WordsBlock extends never
? never
: import('../types').WordsDocument,
document: WordsBlock extends never ? never : import('../types').WordsDocument,
blockIndex: number,
rowIndex: number,
cellIndex: number
): WordsOperationResult {
const point = pointFromInlineTextOffset(
document,
[blockIndex, rowIndex, cellIndex],
0
);
const point = pointFromInlineTextOffset(document, [blockIndex, rowIndex, cellIndex], 0);
const sel = createCollapsedSelection(point.path, point.offset);
return changed({
document,

@ -24,6 +24,7 @@ import type {
TableCell,
TableRow,
WordsAlign,
WordsBlock,
WordsDocument,
WordsHeadingLevel,
WordsImageAlign,
@ -130,7 +131,7 @@ export function createList(kind: WordsListKind, items: readonly ListItem[] = [])
}
export function createTableCell(
children: readonly WordsInline[] = [],
children: readonly WordsBlock[] = [],
opts: {
align?: WordsAlign;
verticalAlign?: WordsVerticalAlign;
@ -139,7 +140,8 @@ export function createTableCell(
} = {}
): TableCell {
return {
children: ensureInlines(children),
// Cells hold blocks (like callout / column); seed an empty paragraph.
children: children.length > 0 ? children : [createParagraph()],
...(opts.align ? { align: opts.align } : {}),
...(opts.verticalAlign ? { verticalAlign: opts.verticalAlign } : {}),
...(opts.colspan && opts.colspan > 1 ? { colspan: opts.colspan } : {}),

@ -103,7 +103,11 @@ describe('toggleList', () => {
{ type: 'paragraph', children: [{ type: 'text', text: 'A' }] },
{
type: 'table',
rows: [{ cells: [{ children: [{ type: 'text', text: 'T' }] }] }]
rows: [
{
cells: [{ children: [{ type: 'paragraph', children: [{ type: 'text', text: 'T' }] }] }]
}
]
}
);
const sel = rangeSelection([0, 0], 0, [1, 0, 0, 0], 1);

@ -84,21 +84,25 @@ describe('getTextEntries', () => {
},
{
type: 'table',
rows: [{ cells: [{ children: [{ type: 'text', text: 'cell' }] }] }]
rows: [
{
cells: [
{ children: [{ type: 'paragraph', children: [{ type: 'text', text: 'cell' }] }] }
]
}
]
},
{
type: 'callout',
intent: 'risk',
children: [
{ type: 'paragraph', children: [{ type: 'text', text: 'callout' }] }
]
children: [{ type: 'paragraph', children: [{ type: 'text', text: 'callout' }] }]
}
);
const entries = getTextEntries(d);
expect(entries).toHaveLength(3);
expect(entries[0].path).toEqual([0, 0, 0]);
expect(entries[0].text.text).toBe('list');
expect(entries[1].path).toEqual([1, 0, 0, 0]);
expect(entries[1].path).toEqual([1, 0, 0, 0, 0]);
expect(entries[1].text.text).toBe('cell');
expect(entries[2].path).toEqual([2, 0, 0]);
expect(entries[2].text.text).toBe('callout');
@ -207,9 +211,7 @@ describe('getActiveMarksForSelection', () => {
type: 'paragraph',
children: [{ type: 'text', text: 'hi', marks: ['bold'] }]
});
const marks = getActiveMarksForSelection(d, createCollapsedSelection([0, 0], 1), [
'italic'
]);
const marks = getActiveMarksForSelection(d, createCollapsedSelection([0, 0], 1), ['italic']);
expect(marks).toEqual(['italic']);
});
@ -459,10 +461,7 @@ describe('toggleMark — ranged', () => {
// but if we pass a non-collapsed selection with start === end on
// a different path, segments collect nothing. Safer demo: range
// spans into an image which has no entries.
const d2 = doc(
{ type: 'image', src: 'x' },
{ type: 'image', src: 'y' }
);
const d2 = doc({ type: 'image', src: 'x' }, { type: 'image', src: 'y' });
const sel = rangeSelection([0], 0, [1], 0);
const r = toggleMark(createState(d2, sel), 'bold');
expect(r.changed).toBe(false);

@ -141,10 +141,7 @@ function normalizeList(
return { ...block, id, items };
}
function normalizeListItems(
items: readonly ListItem[],
idGen: () => string
): readonly ListItem[] {
function normalizeListItems(items: readonly ListItem[], idGen: () => string): readonly ListItem[] {
if (items.length === 0) {
// Empty list → seed with one empty item.
return [
@ -179,7 +176,7 @@ function normalizeTable(
rows: [
{
id: idGen(),
cells: [{ id: idGen(), children: [{ type: 'text', text: '' }] }]
cells: [{ id: idGen(), children: seedCellChildren(idGen) }]
}
]
};
@ -196,20 +193,32 @@ function normalizeTable(
return { ...block, id, rows };
}
function seedCellChildren(idGen: () => string): readonly WordsBlock[] {
// A fresh / empty cell holds one empty paragraph (like callout / column).
return [normalizeBlock({ type: 'paragraph', children: [{ type: 'text', text: '' }] }, idGen)];
}
function normalizeTableCells(
cells: readonly TableCell[],
idGen: () => string
): readonly TableCell[] {
if (cells.length === 0) {
return [{ id: idGen(), children: [{ type: 'text', text: '' }] }];
return [{ id: idGen(), children: seedCellChildren(idGen) }];
}
let mutated = false;
const next = cells.map((cell) => {
const inlines = normalizeInlineChildren(cell.children, idGen);
// Cells hold blocks (like callout): normalize each child block; seed
// an empty paragraph when the cell has none.
const children =
cell.children.length > 0
? cell.children.map((child) => normalizeBlock(child, idGen))
: seedCellChildren(idGen);
const cellId = cell.id ?? idGen();
if (cellId === cell.id && inlines === cell.children) return cell;
const childrenChanged =
children.length !== cell.children.length || children.some((c, i) => c !== cell.children[i]);
if (cellId === cell.id && !childrenChanged) return cell;
mutated = true;
return { ...cell, id: cellId, children: inlines };
return { ...cell, id: cellId, children };
});
return mutated ? next : cells;
}
@ -248,11 +257,7 @@ function normalizeInlineChildren(
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)
) {
if (inline.type === 'text' && last?.type === 'text' && sameMarks(last.marks, inline.marks)) {
const merged: WordsText = {
type: 'text',
text: last.text + inline.text,
@ -275,9 +280,7 @@ function normalizeInlineChildren(
return mutated ? next : inlines;
}
function normalizeLinkChildren(
children: readonly WordsText[]
): readonly WordsText[] {
function normalizeLinkChildren(children: readonly WordsText[]): readonly WordsText[] {
if (children.length <= 1) return children;
const next: WordsText[] = [];
let mutated = false;
@ -297,9 +300,7 @@ function normalizeLinkChildren(
return mutated ? next : children;
}
function normalizeCodeChildren(
children: readonly WordsText[]
): readonly WordsText[] {
function normalizeCodeChildren(children: readonly WordsText[]): readonly WordsText[] {
// Code blocks have no marks, no links — flatten any stray ones.
if (children.length === 0) {
return [{ type: 'text', text: '' }];
@ -332,10 +333,7 @@ function normalizeCodeChildren(
// ── Helpers ──────────────────────────────────────────────────────────────
function sameMarks(
a: WordsText['marks'] | undefined,
b: WordsText['marks'] | undefined
): boolean {
function sameMarks(a: WordsText['marks'] | undefined, b: WordsText['marks'] | undefined): boolean {
const al = a ?? [];
const bl = b ?? [];
if (al.length !== bl.length) return false;
@ -345,12 +343,7 @@ function sameMarks(
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
) {
} 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;

@ -320,12 +320,16 @@ describe('insertParagraph — table → in-cell line break', () => {
it('inserts \\n inside the active cell', () => {
const d = doc({
type: 'table',
rows: [{ cells: [{ children: [{ type: 'text', text: 'cell' }] }] }]
rows: [
{
cells: [{ children: [{ type: 'paragraph', children: [{ type: 'text', text: 'cell' }] }] }]
}
]
});
const sel = createCollapsedSelection([0, 0, 0, 0], 4);
const sel = createCollapsedSelection([0, 0, 0, 0, 0], 4);
const r = insertParagraph(createState(d, sel));
expect(r.changed).toBe(true);
expect(resolveTextNode(r.state.document, [0, 0, 0, 0])!.text).toBe('cell\n');
expect(resolveTextNode(r.state.document, [0, 0, 0, 0, 0])!.text).toBe('cell\n');
});
});

@ -124,30 +124,21 @@ function collectBlockText(
}
}
function collectListItemText(
item: ListItem,
path: WordsPath,
out: WordsTextEntry[]
): void {
function collectListItemText(item: ListItem, path: WordsPath, out: WordsTextEntry[]): void {
collectInlineText(item.children, path, out);
}
function collectTableRowText(
row: TableRow,
path: WordsPath,
out: WordsTextEntry[]
): void {
function collectTableRowText(row: TableRow, path: WordsPath, out: WordsTextEntry[]): void {
for (let i = 0; i < row.cells.length; i++) {
collectTableCellText(row.cells[i], [...path, i], out);
}
}
function collectTableCellText(
cell: TableCell,
path: WordsPath,
out: WordsTextEntry[]
): void {
collectInlineText(cell.children, path, out);
function collectTableCellText(cell: TableCell, path: WordsPath, out: WordsTextEntry[]): void {
// Cells hold blocks (like callout / column): recurse into each child block.
for (let i = 0; i < cell.children.length; i++) {
collectBlockText(cell.children[i], [...path, i], out);
}
}
function collectInlineText(
@ -166,11 +157,7 @@ function collectInlineText(
}
}
function collectLinkText(
link: WordsLink,
parentPath: WordsPath,
out: WordsTextEntry[]
): void {
function collectLinkText(link: WordsLink, parentPath: WordsPath, out: WordsTextEntry[]): void {
for (let i = 0; i < link.children.length; i++) {
out.push({ path: [...parentPath, i], text: link.children[i] });
}
@ -198,9 +185,7 @@ export function getSelectedTextSegments(
if (comparePath(entry.path, range.start.path) < 0) continue;
if (comparePath(entry.path, range.end.path) > 0) continue;
const start = samePath(entry.path, range.start.path) ? range.start.offset : 0;
const end = samePath(entry.path, range.end.path)
? range.end.offset
: entry.text.text.length;
const end = samePath(entry.path, range.end.path) ? range.end.offset : entry.text.text.length;
if (end <= start) continue;
out.push({ entry, start, end });
}
@ -290,10 +275,7 @@ export function setWordsSelection(
});
}
function clampSelectionForSet(
doc: WordsDocument,
selection: WordsSelection
): WordsSelection {
function clampSelectionForSet(doc: WordsDocument, selection: WordsSelection): WordsSelection {
// We can't import dom-helpers to avoid a circular dep — replicate
// the clamp logic inline (text-bound offset + first-text-node
// fallback). Cheap; the math fits in 10 lines.

@ -22,9 +22,7 @@ export function parseWordsPlainText(text: string): WordsDocument {
const lines = normalized.split('\n');
return {
version: WORDS_VERSION,
children: (lines.length ? lines : ['']).map((line) =>
createParagraph([createText(line)])
)
children: (lines.length ? lines : ['']).map((line) => createParagraph([createText(line)]))
};
}
@ -48,9 +46,7 @@ export function renderWordsPlainText(doc: WordsDocument): string {
}
case 'table': {
for (const row of block.rows) {
const rowText = row.cells
.map((c) => extractText(c.children))
.join('\t');
const rowText = row.cells.map((c) => cellPlainText(c)).join('\t');
lines.push(rowText);
}
break;
@ -81,9 +77,27 @@ export function renderWordsPlainText(doc: WordsDocument): string {
// ── Helpers ──────────────────────────────────────────────────────────────
function extractText(
inlines: readonly import('../types').WordsInline[]
): string {
// A cell holds blocks; join the inline text of its paragraph-like children
// (same shape the callout case reads). Other block types contribute nothing
// to the flat plain-text row.
function cellPlainText(cell: import('../types').TableCell): string {
const parts: string[] = [];
for (const inner of cell.children) {
switch (inner.type) {
case 'paragraph':
case 'heading':
case 'quote':
case 'code':
parts.push(extractText(inner.children));
break;
default:
break;
}
}
return parts.filter((s) => s.length > 0).join(' ');
}
function extractText(inlines: readonly import('../types').WordsInline[]): string {
let acc = '';
for (const i of inlines) {
if (i.type === 'text') acc += i.text;

@ -28,23 +28,59 @@ function table3x3() {
rows: [
{
cells: [
{ children: [{ type: 'text' as const, text: '00' }] },
{ children: [{ type: 'text' as const, text: '01' }] },
{ children: [{ type: 'text' as const, text: '02' }] }
{
children: [
{ type: 'paragraph' as const, children: [{ type: 'text' as const, text: '00' }] }
]
},
{
children: [
{ type: 'paragraph' as const, children: [{ type: 'text' as const, text: '01' }] }
]
},
{
children: [
{ type: 'paragraph' as const, children: [{ type: 'text' as const, text: '02' }] }
]
}
]
},
{
cells: [
{ children: [{ type: 'text' as const, text: '10' }] },
{ children: [{ type: 'text' as const, text: '11' }] },
{ children: [{ type: 'text' as const, text: '12' }] }
{
children: [
{ type: 'paragraph' as const, children: [{ type: 'text' as const, text: '10' }] }
]
},
{
children: [
{ type: 'paragraph' as const, children: [{ type: 'text' as const, text: '11' }] }
]
},
{
children: [
{ type: 'paragraph' as const, children: [{ type: 'text' as const, text: '12' }] }
]
}
]
},
{
cells: [
{ children: [{ type: 'text' as const, text: '20' }] },
{ children: [{ type: 'text' as const, text: '21' }] },
{ children: [{ type: 'text' as const, text: '22' }] }
{
children: [
{ type: 'paragraph' as const, children: [{ type: 'text' as const, text: '20' }] }
]
},
{
children: [
{ type: 'paragraph' as const, children: [{ type: 'text' as const, text: '21' }] }
]
},
{
children: [
{ type: 'paragraph' as const, children: [{ type: 'text' as const, text: '22' }] }
]
}
]
}
]
@ -75,11 +111,11 @@ describe('insertTableRow', () => {
if (table.type !== 'table') throw new Error('expected table');
expect(table.rows).toHaveLength(4);
// Original row 1 is now at index 2.
expect(resolveTextNode(r.state.document, [0, 2, 1, 0])!.text).toBe('11');
expect(resolveTextNode(r.state.document, [0, 2, 1, 0, 0])!.text).toBe('11');
});
it('no-op when selection is not inside a table', () => {
const d = doc({ type: 'paragraph', children: [{ type: 'text', text: 'x' }] });
const d = doc({ type: 'paragraph' as const, children: [{ type: 'text', text: 'x' }] });
const sel = createCollapsedSelection([0, 0], 0);
const r = insertTableRow(createState(d, sel));
expect(r.changed).toBe(false);
@ -109,7 +145,7 @@ describe('insertTableColumn', () => {
if (table.type !== 'table') throw new Error('expected table');
expect(table.rows[0].cells).toHaveLength(4);
// Original cell 1 ("01") is now at cell index 2.
expect(resolveTextNode(r.state.document, [0, 0, 2, 0])!.text).toBe('01');
expect(resolveTextNode(r.state.document, [0, 0, 2, 0, 0])!.text).toBe('01');
});
});
@ -125,13 +161,21 @@ describe('deleteTableRow', () => {
if (table.type !== 'table') throw new Error('expected table');
expect(table.rows).toHaveLength(2);
// Row 2 stays — now at index 1.
expect(resolveTextNode(r.state.document, [0, 1, 0, 0])!.text).toBe('20');
expect(resolveTextNode(r.state.document, [0, 1, 0, 0, 0])!.text).toBe('20');
});
it('collapses to a paragraph when removing the last row', () => {
const d = doc({
type: 'table',
rows: [{ cells: [{ children: [{ type: 'text', text: 'only' }] }] }]
rows: [
{
cells: [
{
children: [{ type: 'paragraph' as const, children: [{ type: 'text', text: 'only' }] }]
}
]
}
]
});
const sel = createCollapsedSelection([0, 0, 0, 0], 0);
const r = deleteTableRow(createState(d, sel));
@ -155,14 +199,22 @@ describe('deleteTableColumn', () => {
expect(row.cells).toHaveLength(2);
}
// Column 0 and column 2 survive.
expect(resolveTextNode(r.state.document, [0, 0, 0, 0])!.text).toBe('00');
expect(resolveTextNode(r.state.document, [0, 0, 1, 0])!.text).toBe('02');
expect(resolveTextNode(r.state.document, [0, 0, 0, 0, 0])!.text).toBe('00');
expect(resolveTextNode(r.state.document, [0, 0, 1, 0, 0])!.text).toBe('02');
});
it('collapses to a paragraph when removing the last column', () => {
const d = doc({
type: 'table',
rows: [{ cells: [{ children: [{ type: 'text', text: 'only' }] }] }]
rows: [
{
cells: [
{
children: [{ type: 'paragraph' as const, children: [{ type: 'text', text: 'only' }] }]
}
]
}
]
});
const sel = createCollapsedSelection([0, 0, 0, 0], 0);
const r = deleteTableColumn(createState(d, sel));
@ -228,7 +280,10 @@ describe('setCellTextAlign + setCellVerticalAlign', () => {
rows: [
{
cells: [
{ children: [{ type: 'text', text: 'A' }], align: 'right' }
{
children: [{ type: 'paragraph' as const, children: [{ type: 'text', text: 'A' }] }],
align: 'right'
}
]
}
]

@ -167,18 +167,13 @@ function toggleTableHeader(
return changed({
document: normalized,
selection: sel,
activeMarks: sel
? getActiveMarksForSelection(normalized, sel)
: state.activeMarks
activeMarks: sel ? getActiveMarksForSelection(normalized, sel) : state.activeMarks
});
}
// ── setCellTextAlign / setCellVerticalAlign ──────────────────────────────
export function setCellTextAlign(
state: WordsEditorState,
align: WordsAlign
): WordsOperationResult {
export function setCellTextAlign(state: WordsEditorState, align: WordsAlign): WordsOperationResult {
return updateActiveCell(state, (cell) => {
const current = cell.align ?? 'left';
if (current === align) return cell;
@ -336,9 +331,7 @@ function updateActiveCell(
return changed({
document: normalized,
selection: sel,
activeMarks: sel
? getActiveMarksForSelection(normalized, sel)
: state.activeMarks
activeMarks: sel ? getActiveMarksForSelection(normalized, sel) : state.activeMarks
});
}
@ -373,13 +366,16 @@ function tableColumnCount(block: TableBlock): number {
function emptyTableRow(columnCount: number): TableRow {
const cells: TableCell[] = [];
for (let i = 0; i < columnCount; i++) {
cells.push({ children: [{ type: 'text', text: '' }] });
// Cells hold blocks: seed one empty paragraph (normalize fills ids).
cells.push({ children: [{ type: 'paragraph', children: [{ type: 'text', text: '' }] }] });
}
return { cells };
}
function insertCellInRow(row: TableRow, index: number): TableRow {
const newCell: TableCell = { children: [{ type: 'text', text: '' }] };
const newCell: TableCell = {
children: [{ type: 'paragraph', children: [{ type: 'text', text: '' }] }]
};
return {
...row,
cells: [...row.cells.slice(0, index), newCell, ...row.cells.slice(index)]

@ -46,9 +46,13 @@ describe('helpers — getNodeAtPath + resolveTextNode', () => {
it('handles table.rows + row.cells renames', () => {
const d = doc({
type: 'table',
rows: [{ cells: [{ children: [{ type: 'text', text: 'r0c0' }] }] }]
rows: [
{
cells: [{ children: [{ type: 'paragraph', children: [{ type: 'text', text: 'r0c0' }] }] }]
}
]
});
expect(resolveTextNode(d, [0, 0, 0, 0])?.text).toBe('r0c0');
expect(resolveTextNode(d, [0, 0, 0, 0, 0])?.text).toBe('r0c0');
});
it('walks into callout block children', () => {
@ -114,7 +118,7 @@ describe('helpers — inline offset math', () => {
]
});
const off = textOffsetInInlineContainer(d, [0], { path: [0, 1], offset: 3 });
expect(off).toBe(5 + 3); // "hello" + 3 chars into " wo"
expect(off).toBe(5 + 3); // "hello" + 3 chars into " wo"
});
it('pointFromInlineTextOffset round-trips with textOffset', () => {
@ -145,10 +149,7 @@ describe('helpers — inline offset math', () => {
describe('marks — normalizeMarks', () => {
it('dedupes boolean marks + canonicalises order', () => {
expect(normalizeMarks(['italic', 'bold', 'bold', 'italic'])).toEqual([
'bold',
'italic'
]);
expect(normalizeMarks(['italic', 'bold', 'bold', 'italic'])).toEqual(['bold', 'italic']);
});
it('preserves parametric marks and dedupes by type (last wins)', () => {
@ -183,12 +184,9 @@ describe('marks — sameMarks', () => {
});
it('parametric value differences detected', () => {
expect(
sameMarks(
[{ type: 'color', value: '#fff' }],
[{ type: 'color', value: '#000' }]
)
).toBe(false);
expect(sameMarks([{ type: 'color', value: '#fff' }], [{ type: 'color', value: '#000' }])).toBe(
false
);
});
});
@ -202,18 +200,18 @@ describe('marks — toggleMarkSet', () => {
});
it('replaces parametric of same type with new value', () => {
const result = toggleMarkSet(
[{ type: 'color', value: '#fff' }],
{ type: 'color', value: '#000' }
);
const result = toggleMarkSet([{ type: 'color', value: '#fff' }], {
type: 'color',
value: '#000'
});
expect(result).toEqual([{ type: 'color', value: '#000' }]);
});
it('toggle exact parametric off when same value', () => {
const result = toggleMarkSet(
[{ type: 'color', value: '#fff' }],
{ type: 'color', value: '#fff' }
);
const result = toggleMarkSet([{ type: 'color', value: '#fff' }], {
type: 'color',
value: '#fff'
});
expect(result).toEqual([]);
});
@ -366,12 +364,18 @@ describe('insertText — collapsed selection', () => {
it('inserts into a table cell', () => {
const d = doc({
type: 'table',
rows: [{ cells: [{ children: [{ type: 'text', text: 'world' }] }] }]
rows: [
{
cells: [
{ children: [{ type: 'paragraph', children: [{ type: 'text', text: 'world' }] }] }
]
}
]
});
const sel = createCollapsedSelection([0, 0, 0, 0], 0);
const sel = createCollapsedSelection([0, 0, 0, 0, 0], 0);
const state = createState(d, sel);
const r = insertText(state, 'hello ');
const text = resolveTextNode(r.state.document, [0, 0, 0, 0])!.text;
const text = resolveTextNode(r.state.document, [0, 0, 0, 0, 0])!.text;
expect(text).toBe('hello world');
});
});

@ -231,7 +231,7 @@ describe('render — block style props → inline style', () => {
rows: [
{
background: '#fafafa',
cells: [{ children: [{ type: 'text', text: 'a' }] }]
cells: [{ children: [{ type: 'paragraph', children: [{ type: 'text', text: 'a' }] }] }]
}
]
})
@ -251,7 +251,7 @@ describe('render — block style props → inline style', () => {
{
cells: [
{
children: [{ type: 'text', text: 'a' }],
children: [{ type: 'paragraph', children: [{ type: 'text', text: 'a' }] }],
background: '#eef5ff',
padding: { block: 8, inline: 8 }
}
@ -277,8 +277,18 @@ describe('render — table headers', () => {
type: 'table',
headerRow: true,
rows: [
{ cells: [{ children: [{ type: 'text', text: 'h1' }] }, { children: [{ type: 'text', text: 'h2' }] }] },
{ cells: [{ children: [{ type: 'text', text: 'a' }] }, { children: [{ type: 'text', text: 'b' }] }] }
{
cells: [
{ children: [{ type: 'paragraph', children: [{ type: 'text', text: 'h1' }] }] },
{ children: [{ type: 'paragraph', children: [{ type: 'text', text: 'h2' }] }] }
]
},
{
cells: [
{ children: [{ type: 'paragraph', children: [{ type: 'text', text: 'a' }] }] },
{ children: [{ type: 'paragraph', children: [{ type: 'text', text: 'b' }] }] }
]
}
]
})
);
@ -296,7 +306,12 @@ describe('render — table headers', () => {
type: 'table',
headerCol: true,
rows: [
{ cells: [{ children: [{ type: 'text', text: 'rh' }] }, { children: [{ type: 'text', text: 'v' }] }] }
{
cells: [
{ children: [{ type: 'paragraph', children: [{ type: 'text', text: 'rh' }] }] },
{ children: [{ type: 'paragraph', children: [{ type: 'text', text: 'v' }] }] }
]
}
]
})
);
@ -327,9 +342,7 @@ describe('render — text marks', () => {
const r = renderWordsDocument(
doc({
type: 'paragraph',
children: [
{ type: 'text', text: 'x', marks: [{ type: 'color', value: '#ff5500' }] }
]
children: [{ type: 'text', text: 'x', marks: [{ type: 'color', value: '#ff5500' }] }]
})
);
const node = r[0].children[0];

@ -92,18 +92,14 @@ describe('serializeHtml', () => {
const html = serializeHtml(
doc({
type: 'paragraph',
children: [
{ type: 'text', text: 'red text', marks: [{ type: 'color', value: '#ff0000' }] }
]
children: [{ type: 'text', text: 'red text', marks: [{ type: 'color', value: '#ff0000' }] }]
})
);
expect(html).toContain('<span style="color:#ff0000">red text</span>');
});
it('quote emits <blockquote>', () => {
const html = serializeHtml(
doc({ type: 'quote', children: [{ type: 'text', text: 'Hello' }] })
);
const html = serializeHtml(doc({ type: 'quote', children: [{ type: 'text', text: 'Hello' }] }));
expect(html).toBe('<blockquote>Hello</blockquote>');
});
@ -144,13 +140,17 @@ describe('serializeHtml', () => {
type: 'table',
headerRow: true,
rows: [
{ cells: [{ children: [{ type: 'text', text: 'h1' }] }] },
{ cells: [{ children: [{ type: 'text', text: 'a' }] }] }
{
cells: [{ children: [{ type: 'paragraph', children: [{ type: 'text', text: 'h1' }] }] }]
},
{
cells: [{ children: [{ type: 'paragraph', children: [{ type: 'text', text: 'a' }] }] }]
}
]
})
);
expect(html).toBe(
'<table><thead><tr><th>h1</th></tr></thead><tbody><tr><td>a</td></tr></tbody></table>'
'<table><thead><tr><th><p>h1</p></th></tr></thead><tbody><tr><td><p>a</p></td></tr></tbody></table>'
);
});
@ -163,8 +163,13 @@ describe('serializeHtml', () => {
cells: [
{
children: [
{ type: 'text', text: 'Cell' },
{ type: 'text', text: '!', marks: ['bold'] }
{
type: 'paragraph',
children: [
{ type: 'text', text: 'Cell' },
{ type: 'text', text: '!', marks: ['bold'] }
]
}
]
}
]
@ -172,6 +177,8 @@ describe('serializeHtml', () => {
]
})
);
expect(html).toBe('<table><tbody><tr><td>Cell<strong>!</strong></td></tr></tbody></table>');
expect(html).toBe(
'<table><tbody><tr><td><p>Cell<strong>!</strong></p></td></tr></tbody></table>'
);
});
});

@ -124,14 +124,20 @@ describe('serializeMarkdown — basic blocks', () => {
rows: [
{
cells: [
{ children: [{ type: 'text', text: 'name' }], align: 'left' },
{ children: [{ type: 'text', text: 'age' }], align: 'right' }
{
children: [{ type: 'paragraph', children: [{ type: 'text', text: 'name' }] }],
align: 'left'
},
{
children: [{ type: 'paragraph', children: [{ type: 'text', text: 'age' }] }],
align: 'right'
}
]
},
{
cells: [
{ children: [{ type: 'text', text: 'Alice' }] },
{ children: [{ type: 'text', text: '30' }] }
{ children: [{ type: 'paragraph', children: [{ type: 'text', text: 'Alice' }] }] },
{ children: [{ type: 'paragraph', children: [{ type: 'text', text: '30' }] }] }
]
}
]
@ -148,8 +154,14 @@ describe('serializeMarkdown — basic blocks', () => {
type: 'table',
headerRow: true,
rows: [
{ cells: [{ children: [{ type: 'text', text: 'pipe|here' }] }] },
{ cells: [{ children: [{ type: 'text', text: 'a' }] }] }
{
cells: [
{ children: [{ type: 'paragraph', children: [{ type: 'text', text: 'pipe|here' }] }] }
]
},
{
cells: [{ children: [{ type: 'paragraph', children: [{ type: 'text', text: 'a' }] }] }]
}
]
})
);
@ -201,9 +213,7 @@ describe('serializeMarkdown — marks', () => {
const md = serializeMarkdown(
doc({
type: 'paragraph',
children: [
{ type: 'text', text: 'x', marks: ['bold', 'italic', 'code'] }
]
children: [{ type: 'text', text: 'x', marks: ['bold', 'italic', 'code'] }]
})
);
expect(md).toContain('***`x`***');

@ -206,7 +206,14 @@ export interface ListBlock extends Block {
}
export interface TableCell extends Block {
readonly children: readonly WordsInline[];
/**
* Cell content. Holds full BLOCKS (like `CalloutBlock` / `Column`), so a
* cell can contain paragraphs, lists, images, even nested tables — not
* just inline text. A freshly-created cell seeds a single empty
* paragraph. Markdown export flattens these blocks to inline text
* (GFM table cells are inline-only); HTML export keeps the block markup.
*/
readonly children: readonly WordsBlock[];
/** Vertical alignment within the cell. */
readonly verticalAlign?: WordsVerticalAlign;
readonly colspan?: number;
@ -396,11 +403,32 @@ export const WORDS_BLOCK_TYPES = [
] as const satisfies readonly WordsBlockType[];
export const WORDS_HEADING_LEVELS = [1, 2, 3] as const satisfies readonly WordsHeadingLevel[];
export const WORDS_ALIGNS = ['left', 'center', 'right', 'justify'] as const satisfies readonly WordsAlign[];
export const WORDS_LIST_KINDS = ['ordered', 'unordered', 'check'] as const satisfies readonly WordsListKind[];
export const WORDS_IMAGE_ALIGNS = ['left', 'center', 'right'] as const satisfies readonly WordsImageAlign[];
export const WORDS_VERTICAL_ALIGNS = ['top', 'middle', 'bottom'] as const satisfies readonly WordsVerticalAlign[];
export const WORDS_BORDER_STYLES = ['solid', 'dashed', 'dotted'] as const satisfies readonly WordsBorderStyle[];
export const WORDS_ALIGNS = [
'left',
'center',
'right',
'justify'
] as const satisfies readonly WordsAlign[];
export const WORDS_LIST_KINDS = [
'ordered',
'unordered',
'check'
] as const satisfies readonly WordsListKind[];
export const WORDS_IMAGE_ALIGNS = [
'left',
'center',
'right'
] as const satisfies readonly WordsImageAlign[];
export const WORDS_VERTICAL_ALIGNS = [
'top',
'middle',
'bottom'
] as const satisfies readonly WordsVerticalAlign[];
export const WORDS_BORDER_STYLES = [
'solid',
'dashed',
'dotted'
] as const satisfies readonly WordsBorderStyle[];
export function isWordsBooleanMark(value: unknown): value is WordsBooleanMark {
return typeof value === 'string' && (WORDS_BOOLEAN_MARKS as readonly string[]).includes(value);

@ -63,9 +63,7 @@ describe('validateWordsDocument — top-level shape', () => {
describe('block type registry', () => {
it('rejects unknown block types', () => {
const r = validateWordsDocument(
doc({ type: 'mystery-block', children: [] } as never)
);
const r = validateWordsDocument(doc({ type: 'mystery-block', children: [] } as never));
expect(r.valid).toBe(false);
if (!r.valid) {
expect(r.errors[0].code).toBe('invalid-block-type');
@ -83,7 +81,11 @@ describe('block type registry', () => {
{
type: 'table',
rows: [
{ cells: [{ children: [{ type: 'text', text: 'a' }] }] }
{
cells: [
{ children: [{ type: 'paragraph', children: [{ type: 'text', text: 'a' }] }] }
]
}
]
},
{ type: 'image', src: 'https://example.com/cat.png', alt: 'cat' },
@ -374,26 +376,15 @@ describe('image', () => {
});
it('rejects negative width', () => {
const r = validateWordsDocument(
doc({ type: 'image', src: 'https://x', width: -1 })
);
const r = validateWordsDocument(doc({ type: 'image', src: 'https://x', width: -1 }));
expect(r.valid).toBe(false);
});
});
describe('callout (P7 — sema intent vocabulary)', () => {
it('accepts every SemaIntent value', () => {
for (const intent of [
'neutral',
'affirm',
'fulfill',
'risk',
'threat',
'loss'
] as const) {
const r = validateWordsDocument(
doc({ type: 'callout', intent, children: [paragraph('x')] })
);
for (const intent of ['neutral', 'affirm', 'fulfill', 'risk', 'threat', 'loss'] as const) {
const r = validateWordsDocument(doc({ type: 'callout', intent, children: [paragraph('x')] }));
expect(r.valid).toBe(true);
}
});
@ -408,9 +399,7 @@ describe('callout (P7 — sema intent vocabulary)', () => {
});
it('rejects callout without intent', () => {
const r = validateWordsDocument(
doc({ type: 'callout', children: [paragraph('x')] } as never)
);
const r = validateWordsDocument(doc({ type: 'callout', children: [paragraph('x')] } as never));
expect(r.valid).toBe(false);
});
@ -568,8 +557,8 @@ describe('table', () => {
rows: [
{
cells: [
{ children: [{ type: 'text', text: 'a' }] },
{ children: [{ type: 'text', text: 'b' }] }
{ children: [{ type: 'paragraph', children: [{ type: 'text', text: 'a' }] }] },
{ children: [{ type: 'paragraph', children: [{ type: 'text', text: 'b' }] }] }
]
}
]
@ -586,7 +575,7 @@ describe('table', () => {
{
cells: [
{
children: [{ type: 'text', text: 'a' }],
children: [{ type: 'paragraph', children: [{ type: 'text', text: 'a' }] }],
background: '#fff5e6',
padding: { block: 8, inline: 8 }
}
@ -606,7 +595,7 @@ describe('table', () => {
{
cells: [
{
children: [{ type: 'text', text: 'a' }],
children: [{ type: 'paragraph', children: [{ type: 'text', text: 'a' }] }],
background: 'accent' as never
}
]
@ -625,7 +614,7 @@ describe('table', () => {
rows: [
{
background: '#f5f5f5',
cells: [{ children: [{ type: 'text', text: 'a' }] }]
cells: [{ children: [{ type: 'paragraph', children: [{ type: 'text', text: 'a' }] }] }]
}
]
})
@ -640,7 +629,7 @@ describe('table', () => {
rows: [
{
background: 'red' as never,
cells: [{ children: [{ type: 'text', text: 'a' }] }]
cells: [{ children: [{ type: 'paragraph', children: [{ type: 'text', text: 'a' }] }] }]
}
]
})

@ -97,9 +97,9 @@ function wordsOpts(root = document.createElement('div'), value = createEmptyDocu
onValueCommit: state<
((value: WordsDocument, reason: 'programmatic' | 'blur' | 'button') => void) | undefined
>(undefined),
onUploadImage: state<
((file: File) => Promise<{ url: string; alt?: string }>) | undefined
>(undefined)
onUploadImage: state<((file: File) => Promise<{ url: string; alt?: string }>) | undefined>(
undefined
)
};
}
@ -1416,7 +1416,9 @@ describe('WordsProvider', () => {
document.body.append(root, contentEl, buttonEl);
const opts = wordsOpts(root, {
version: WORDS_VERSION,
children: [createTable([createTableRow([createTableCell([createText('Cell')])])])]
children: [
createTable([createTableRow([createTableCell([createParagraph([createText('Cell')])])])])
]
});
const { result, cleanup } = withEffectRoot(() => {
@ -1453,7 +1455,7 @@ describe('WordsProvider', () => {
} as never);
expect(result.provider.exportContent('html')).toBe(
'<table><tbody><tr><td>Cell<strong>!</strong></td></tr></tbody></table>'
'<table><tbody><tr><td><p>Cell<strong>!</strong></p></td></tr></tbody></table>'
);
cleanup();
@ -1653,11 +1655,7 @@ describe('WordsProvider', () => {
expect(result.provider.document.children).toMatchObject([
{
type: 'table',
rows: [
{ cells: [{}, {}, {}] },
{ cells: [{}, {}, {}] },
{ cells: [{}, {}, {}] }
]
rows: [{ cells: [{}, {}, {}] }, { cells: [{}, {}, {}] }, { cells: [{}, {}, {}] }]
},
{ type: 'paragraph', children: [{ type: 'text', text: '' }] }
]);
@ -1679,10 +1677,13 @@ describe('WordsProvider', () => {
children: [
createTable([
createTableRow([
createTableCell([createText('A1')]),
createTableCell([createText('B1')])
createTableCell([createParagraph([createText('A1')])]),
createTableCell([createParagraph([createText('B1')])])
]),
createTableRow([createTableCell([createText('A2')]), createTableCell([createText('B2')])])
createTableRow([
createTableCell([createParagraph([createText('A2')])]),
createTableCell([createParagraph([createText('B2')])])
])
])
]
});
@ -1701,9 +1702,24 @@ describe('WordsProvider', () => {
expect(result.provider.document.children[0]).toMatchObject({
type: 'table',
rows: [
{ cells: [{ children: [{ text: 'A1' }] }, { children: [{ text: 'B1' }] }] },
{ cells: [{ children: [{ text: '' }] }, { children: [{ text: '' }] }] },
{ cells: [{ children: [{ text: 'A2' }] }, { children: [{ text: 'B2' }] }] }
{
cells: [
{ children: [{ children: [{ text: 'A1' }] }] },
{ children: [{ children: [{ text: 'B1' }] }] }
]
},
{
cells: [
{ children: [{ children: [{ text: '' }] }] },
{ children: [{ children: [{ text: '' }] }] }
]
},
{
cells: [
{ children: [{ children: [{ text: 'A2' }] }] },
{ children: [{ children: [{ text: 'B2' }] }] }
]
}
]
});
expect(result.provider.selection).toEqual({
@ -1714,11 +1730,7 @@ describe('WordsProvider', () => {
result.provider.runCommandName('insert-table-column');
expect(result.provider.document.children[0]).toMatchObject({
type: 'table',
rows: [
{ cells: [{}, {}, {}] },
{ cells: [{}, {}, {}] },
{ cells: [{}, {}, {}] }
]
rows: [{ cells: [{}, {}, {}] }, { cells: [{}, {}, {}] }, { cells: [{}, {}, {}] }]
});
expect(result.provider.selection).toEqual({
anchor: { path: [0, 1, 1, 0], offset: 0 },
@ -1730,8 +1742,18 @@ describe('WordsProvider', () => {
expect(result.provider.document.children[0]).toMatchObject({
type: 'table',
rows: [
{ cells: [{ children: [{ text: 'A1' }] }, { children: [{ text: 'B1' }] }] },
{ cells: [{ children: [{ text: 'A2' }] }, { children: [{ text: 'B2' }] }] }
{
cells: [
{ children: [{ children: [{ text: 'A1' }] }] },
{ children: [{ children: [{ text: 'B1' }] }] }
]
},
{
cells: [
{ children: [{ children: [{ text: 'A2' }] }] },
{ children: [{ children: [{ text: 'B2' }] }] }
]
}
]
});
@ -1748,10 +1770,13 @@ describe('WordsProvider', () => {
children: [
createTable([
createTableRow([
createTableCell([createText('A1')]),
createTableCell([createText('B1')])
createTableCell([createParagraph([createText('A1')])]),
createTableCell([createParagraph([createText('B1')])])
]),
createTableRow([createTableCell([createText('A2')]), createTableCell([createText('B2')])])
createTableRow([
createTableCell([createParagraph([createText('A2')])]),
createTableCell([createParagraph([createText('B2')])])
])
])
]
});
@ -1776,10 +1801,7 @@ describe('WordsProvider', () => {
headerRow: true,
rows: [
{
cells: [
{},
{ verticalAlign: 'middle' }
]
cells: [{}, { verticalAlign: 'middle' }]
},
{
cells: [{}, {}]
@ -1800,7 +1822,10 @@ describe('WordsProvider', () => {
version: WORDS_VERSION,
children: [
createTable([
createTableRow([createTableCell([createText('A1')]), createTableCell([createText('B1')])])
createTableRow([
createTableCell([createParagraph([createText('A1')])]),
createTableCell([createParagraph([createText('B1')])])
])
])
]
});
@ -1851,10 +1876,7 @@ describe('WordsProvider', () => {
expect(result.provider.history.past).toHaveLength(1);
expect(result.provider.document.children[0]).toMatchObject({
type: 'table',
rows: [
{ cells: [{}, {}] },
{ cells: [{}, {}] }
]
rows: [{ cells: [{}, {}] }, { cells: [{}, {}] }]
});
expect(result.provider.selection).toEqual({
anchor: { path: [0, 1, 0, 0], offset: 0 },

@ -18,8 +18,7 @@
const handleUploadImage: WordsOnUploadImage = (file: File) =>
new Promise<WordsImageUploadResult>((resolve, reject) => {
const reader = new FileReader();
reader.onerror = () =>
reject(reader.error ?? new Error('FileReader failed'));
reader.onerror = () => reject(reader.error ?? new Error('FileReader failed'));
reader.onload = () => {
const url = typeof reader.result === 'string' ? reader.result : '';
if (!url) {
@ -53,7 +52,10 @@
children: [
{ type: 'text', text: 'A clean editor rebuilt on a base ' },
{ type: 'text', text: 'Block', marks: ['code'] },
{ type: 'text', text: ' model — common style props (margins, colors, spacing, borders, align) shared by every block and ' },
{
type: 'text',
text: ' model — common style props (margins, colors, spacing, borders, align) shared by every block and '
},
{ type: 'text', text: 'extended', marks: ['italic'] },
{ type: 'text', text: ' per type.' }
]
@ -70,7 +72,12 @@
{ type: 'text', text: ', ' },
{ type: 'text', text: 'strike', marks: ['strike'] },
{ type: 'text', text: ', and a ' },
{ type: 'link', href: 'https://tiptap.dev', target: '_blank', children: [{ type: 'text', text: 'link' }] },
{
type: 'link',
href: 'https://tiptap.dev',
target: '_blank',
children: [{ type: 'text', text: 'link' }]
},
{ type: 'text', text: '.' }
]
},
@ -81,12 +88,22 @@
},
{
type: 'quote',
children: [{ type: 'text', text: 'Design is not just what it looks like and feels like. Design is how it works.' }]
children: [
{
type: 'text',
text: 'Design is not just what it looks like and feels like. Design is how it works.'
}
]
},
{
type: 'code',
language: 'ts',
children: [{ type: 'text', text: "const greet = (name: string) => `Hello, ${name}!`\nconsole.log(greet('Words'))" }]
children: [
{
type: 'text',
text: "const greet = (name: string) => `Hello, ${name}!`\nconsole.log(greet('Words'))"
}
]
},
{
type: 'heading',
@ -106,7 +123,10 @@
kind: 'check',
items: [
{ checked: true, children: [{ type: 'text', text: 'Engine reworked (base Block)' }] },
{ checked: false, children: [{ type: 'text', text: 'Chrome (gutters + bubble + drawer)' }] }
{
checked: false,
children: [{ type: 'text', text: 'Chrome (gutters + bubble + drawer)' }]
}
]
},
{
@ -114,7 +134,15 @@
intent: 'affirm',
title: 'Foundation',
children: [
{ type: 'paragraph', children: [{ type: 'text', text: 'This surface renders directly from the model. Editing round-trips through the engine.' }] }
{
type: 'paragraph',
children: [
{
type: 'text',
text: 'This surface renders directly from the model. Editing round-trips through the engine.'
}
]
}
]
},
{ type: 'divider' },
@ -141,20 +169,22 @@
rows: [
{
cells: [
{ children: [{ type: 'text', text: 'Phase' }] },
{ children: [{ type: 'text', text: 'Status' }] }
{ children: [{ type: 'paragraph', children: [{ type: 'text', text: 'Phase' }] }] },
{ children: [{ type: 'paragraph', children: [{ type: 'text', text: 'Status' }] }] }
]
},
{
cells: [
{ children: [{ type: 'text', text: 'Engine' }] },
{ children: [{ type: 'text', text: 'Done' }] }
{ children: [{ type: 'paragraph', children: [{ type: 'text', text: 'Engine' }] }] },
{ children: [{ type: 'paragraph', children: [{ type: 'text', text: 'Done' }] }] }
]
},
{
cells: [
{ children: [{ type: 'text', text: 'Visual' }] },
{ children: [{ type: 'text', text: 'In progress' }] }
{ children: [{ type: 'paragraph', children: [{ type: 'text', text: 'Visual' }] }] },
{
children: [{ type: 'paragraph', children: [{ type: 'text', text: 'In progress' }] }]
}
]
}
]
@ -167,8 +197,8 @@
<p data-uix-eyebrow>— editors · words</p>
<h1>Words</h1>
<p>
Rebuilt on a clean base-<code>Block</code> model. This is the F2 foundation: the editor surface
renders and edits. Chrome (left gutter menu, right settings gutter, bubble toolbar) is next.
Rebuilt on a clean base-<code>Block</code> model. This is the F2 foundation: the editor surface renders
and edits. Chrome (left gutter menu, right settings gutter, bubble toolbar) is next.
</p>
</header>
@ -192,12 +222,7 @@
</div>
<div class="demo-canvas">
<Words
bind:value
{inspector}
placeholder="Write something…"
onUploadImage={handleUploadImage}
/>
<Words bind:value {inspector} placeholder="Write something…" onUploadImage={handleUploadImage} />
</div>
<details class="demo-trace">

Loading…
Cancel
Save

Powered by TurnKey Linux.