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, WORDS_VERTICAL_ALIGNS,
isWordsIntent isWordsIntent
} from '../types'; } from '../types';
import type { WordsBlock } from '../types';
import type { WordsPath } from '../path'; import type { WordsPath } from '../path';
import type { import type {
WordsBlockRenderContext, WordsBlockRenderContext,
@ -156,7 +157,7 @@ const headingSpec: WordsBlockSpec = {
}, },
render(block, ctx) { render(block, ctx) {
const b = block as HeadingBlock; const b = block as HeadingBlock;
const tag = (`h${b.level}` as const) satisfies WordsRenderTag; const tag = `h${b.level}` as const satisfies WordsRenderTag;
return { return {
kind: 'element', kind: 'element',
tag, tag,
@ -184,7 +185,10 @@ const quoteSpec: WordsBlockSpec = {
], ],
validate(block, ctx) { validate(block, ctx) {
ctx.validateInlineChildren(block.children, `${ctx.path}/children`); 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.error(
`${ctx.path}/cite`, `${ctx.path}/cite`,
'invalid-href', 'invalid-href',
@ -386,7 +390,9 @@ const tableSpec: WordsBlockSpec = {
} }
const body = bodyRows const body = bodyRows
.map((row) => { .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>`; return `<tr>${cells}</tr>`;
}) })
.join(''); .join('');
@ -559,9 +565,7 @@ const imageSpec: WordsBlockSpec = {
kind: 'element', kind: 'element',
tag: 'span', tag: 'span',
attrs: { 'data-words-image-placeholder-hint': '' }, attrs: { 'data-words-image-placeholder-hint': '' },
children: [ children: [{ kind: 'text', text: 'Open the inspector and paste a URL.' }]
{ 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') { 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)) { if (!Array.isArray(block.children)) {
ctx.error( ctx.error(
@ -842,7 +850,8 @@ function renderTableCell(
...(cell.rowspan ? { rowspan: String(cell.rowspan) } : {}), ...(cell.rowspan ? { rowspan: String(cell.rowspan) } : {}),
...(cellStyle ? { style: cellStyle } : {}) ...(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 // own. Mirror the pre-registry private validators; read fields defensively
// off the untrusted `unknown` input. // off the untrusted `unknown` input.
function validateListItemFields( function validateListItemFields(item: unknown, path: string, ctx: WordsBlockValidateContext): void {
item: unknown,
path: string,
ctx: WordsBlockValidateContext
): void {
if (!ctx.isPlainObject(item)) { if (!ctx.isPlainObject(item)) {
ctx.error(path, 'invalid-block-shape', 'list item must be an object'); ctx.error(path, 'invalid-block-shape', 'list item must be an object');
return; return;
@ -863,7 +868,11 @@ function validateListItemFields(
ctx.validateId(item.id, `${path}/id`); ctx.validateId(item.id, `${path}/id`);
ctx.validateInlineChildren(item.children, `${path}/children`); ctx.validateInlineChildren(item.children, `${path}/children`);
if (item.checked !== undefined && typeof item.checked !== 'boolean') { 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) { if (item.indent !== undefined) {
const n = item.indent as number; const n = item.indent as number;
@ -877,11 +886,7 @@ function validateListItemFields(
} }
} }
function validateTableRowFields( function validateTableRowFields(row: unknown, path: string, ctx: WordsBlockValidateContext): void {
row: unknown,
path: string,
ctx: WordsBlockValidateContext
): void {
if (!ctx.isPlainObject(row)) { if (!ctx.isPlainObject(row)) {
ctx.error(path, 'invalid-block-shape', 'table row must be an object'); ctx.error(path, 'invalid-block-shape', 'table row must be an object');
return; return;
@ -915,7 +920,14 @@ function validateTableCellFields(
} }
ctx.validateId(cell.id, `${path}/id`); ctx.validateId(cell.id, `${path}/id`);
ctx.validateStyle(cell, path); 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`); ctx.validateOptionalEnum(cell.verticalAlign, WORDS_VERTICAL_ALIGNS, `${path}/verticalAlign`);
if (cell.colspan !== undefined) { if (cell.colspan !== undefined) {
const n = cell.colspan as number; 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}"`; if (cell.rowspan !== undefined && cell.rowspan > 1) attrs += ` rowspan="${cell.rowspan}"`;
const styled = ctx.stringifyStyle(style); const styled = ctx.stringifyStyle(style);
if (styled !== undefined) attrs += ` style="${ctx.escapeAttr(styled)}"`; 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 ───────────────────────────────── // ── List / table Markdown sub-serializers ─────────────────────────────────
@ -990,7 +1004,49 @@ function listItemToMd(
} }
function cellTextToMd(cell: TableCell, ctx: WordsMarkdownSerializeContext): string { 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 = { const columnsSpec: WordsBlockSpec = {
@ -1113,11 +1169,7 @@ const columnsSpec: WordsBlockSpec = {
} }
}; };
function validateColumnFields( function validateColumnFields(col: unknown, path: string, ctx: WordsBlockValidateContext): void {
col: unknown,
path: string,
ctx: WordsBlockValidateContext
): void {
if (!ctx.isPlainObject(col)) { if (!ctx.isPlainObject(col)) {
ctx.error(path, 'invalid-block-shape', 'column must be an object'); ctx.error(path, 'invalid-block-shape', 'column must be an object');
return; return;
@ -1125,11 +1177,7 @@ function validateColumnFields(
ctx.validateId(col.id, `${path}/id`); ctx.validateId(col.id, `${path}/id`);
ctx.validateStyle(col, path); ctx.validateStyle(col, path);
if (!Array.isArray(col.children)) { if (!Array.isArray(col.children)) {
ctx.error( ctx.error(`${path}/children`, 'invalid-children', 'column.children must be an array of blocks');
`${path}/children`,
'invalid-children',
'column.children must be an array of blocks'
);
return; return;
} }
for (let i = 0; i < col.children.length; i++) { for (let i = 0; i < col.children.length; i++) {

@ -109,7 +109,11 @@ describe('setBlock — paragraph ↔ heading ↔ quote ↔ code', () => {
{ type: 'divider' }, { type: 'divider' },
{ {
type: 'table', 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); 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: 'list', kind: 'unordered', items: [{ children: [{ type: 'text', text: 'L' }] }] },
{ {
type: 'table', 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: 'image', src: 'i.jpg' },
{ type: 'code', children: [{ type: 'text', text: 'C' }] }, { type: 'code', children: [{ type: 'text', text: 'C' }] },

@ -155,33 +155,38 @@ describe('deleteBackward — list', () => {
// ── deleteBackward — table cell ────────────────────────────────────────── // ── deleteBackward — table cell ──────────────────────────────────────────
describe('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({ const d = doc({
type: 'table', type: 'table',
rows: [ rows: [
{ {
cells: [ cells: [
{ children: [{ type: 'text', text: 'A' }] }, { children: [{ type: 'paragraph', children: [{ type: 'text', text: 'A' }] }] },
{ children: [{ type: 'text', text: 'B' }] } { 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)); const r = deleteBackward(createState(d, sel));
expect(r.changed).toBe(true); expect(r.changed).toBe(false);
const table = r.state.document.children[0]; const table = r.state.document.children[0];
if (table.type !== 'table') throw new Error('expected table'); if (table.type !== 'table') throw new Error('expected table');
expect(table.rows[0].cells).toHaveLength(1); expect(table.rows[0].cells).toHaveLength(2);
expect(resolveTextNode(r.state.document, [0, 0, 0, 0])!.text).toBe('AB');
}); });
it('cell col 0: no-op (does NOT merge across rows)', () => { it('cell col 0: no-op (does NOT merge across rows)', () => {
const d = doc({ const d = doc({
type: 'table', type: 'table',
rows: [ 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); 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' }); 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({ const d = doc({
type: 'table', type: 'table',
rows: [ rows: [
{ {
cells: [ cells: [
{ children: [{ type: 'text', text: 'A' }] }, { children: [{ type: 'paragraph', children: [{ type: 'text', text: 'A' }] }] },
{ children: [{ type: 'text', text: 'B' }] } { 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)); const r = deleteForward(createState(d, sel));
expect(r.changed).toBe(true); expect(r.changed).toBe(false);
const table = r.state.document.children[0]; const table = r.state.document.children[0];
if (table.type !== 'table') throw new Error('expected table'); if (table.type !== 'table') throw new Error('expected table');
expect(table.rows[0].cells).toHaveLength(1); expect(table.rows[0].cells).toHaveLength(2);
expect(resolveTextNode(r.state.document, [0, 0, 0, 0])!.text).toBe('AB');
}); });
}); });

@ -28,11 +28,7 @@
*/ */
import { samePath, type WordsPath } from '../path'; import { samePath, type WordsPath } from '../path';
import { import { createCollapsedSelection, normalizeRange, type WordsSelection } from '../selection';
createCollapsedSelection,
normalizeRange,
type WordsSelection
} from '../selection';
import { deleteRange } from './delete'; import { deleteRange } from './delete';
import { import {
editingContainerPath, editingContainerPath,
@ -127,7 +123,7 @@ function mergeWithPreviousContainer(
} }
if (block.type === 'table') { if (block.type === 'table') {
return mergeTablePrev(state, block, containerPath); return recurseIntoTableMerge(state, block, blockIndex, 'backward');
} }
if (block.type === 'callout' && containerPath.length >= 2) { if (block.type === 'callout' && containerPath.length >= 2) {
@ -208,11 +204,7 @@ function mergeListPrev(
}; };
const nextList: ListBlock = { const nextList: ListBlock = {
...block, ...block,
items: [ items: [...block.items.slice(0, itemIndex - 1), mergedItem, ...block.items.slice(itemIndex + 1)]
...block.items.slice(0, itemIndex - 1),
mergedItem,
...block.items.slice(itemIndex + 1)
]
}; };
const normalized = normalizeDocument({ const normalized = normalizeDocument({
...state.document, ...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, state: WordsEditorState,
block: TableBlock, table: TableBlock,
containerPath: WordsPath tableIndex: number,
direction: 'backward' | 'forward'
): WordsOperationResult { ): WordsOperationResult {
const blockIndex = containerPath[0] ?? -1; const selection = state.selection;
const rowIndex = containerPath[1] ?? -1; if (!selection) return noOp(state);
const cellIndex = containerPath[2] ?? -1; const rowIndex = selection.anchor.path[1] ?? -1;
if (rowIndex < 0 || cellIndex <= 0) return noOp(state); const cellIndex = selection.anchor.path[2] ?? -1;
const row = block.rows[rowIndex]; const row = table.rows[rowIndex];
const previous = row?.cells[cellIndex - 1]; const cell = row?.cells[cellIndex];
const current = row?.cells[cellIndex]; if (!row || !cell) return noOp(state);
if (!row || !previous || !current) return noOp(state);
const offset = inlineTextLength(previous.children); const subDoc: WordsDocument = { version: WORDS_VERSION, children: cell.children };
const nextCell: TableCell = { const subSelection: WordsSelection = {
...previous, anchor: { path: selection.anchor.path.slice(3), offset: selection.anchor.offset },
children: ensureInlinesNonEmpty([...previous.children, ...current.children]) focus: { path: selection.focus.path.slice(3), offset: selection.focus.offset }
};
const nextRow: TableRow = {
...row,
cells: [
...row.cells.slice(0, cellIndex - 1),
nextCell,
...row.cells.slice(cellIndex + 1)
]
}; };
const nextTable: TableBlock = { const subState: WordsEditorState = {
...block, document: subDoc,
rows: replaceAt(block.rows, rowIndex, nextRow) 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({ const normalized = normalizeDocument({
...state.document, ...state.document,
children: replaceAt(state.document.children, blockIndex, nextTable) children: replaceAt(state.document.children, tableIndex, newTable)
}).document; }).document;
const point = pointFromInlineTextOffset(
normalized,
[blockIndex, rowIndex, cellIndex - 1],
offset
);
const sel = createCollapsedSelection(point.path, point.offset);
return changed({ return changed({
document: normalized, document: normalized,
selection: sel, selection: newSelection,
activeMarks: getActiveMarksForSelection(normalized, sel) activeMarks: newSelection
? getActiveMarksForSelection(normalized, newSelection)
: state.activeMarks
}); });
} }
@ -290,7 +295,7 @@ function mergeWithNextContainer(
} }
if (block.type === 'table') { if (block.type === 'table') {
return mergeTableNext(state, block, containerPath); return recurseIntoTableMerge(state, block, blockIndex, 'forward');
} }
if (block.type === 'callout' && containerPath.length >= 2) { if (block.type === 'callout' && containerPath.length >= 2) {
@ -342,11 +347,7 @@ function mergeListNext(
}; };
const nextList: ListBlock = { const nextList: ListBlock = {
...block, ...block,
items: [ items: [...block.items.slice(0, itemIndex), mergedItem, ...block.items.slice(itemIndex + 2)]
...block.items.slice(0, itemIndex),
mergedItem,
...block.items.slice(itemIndex + 2)
]
}; };
const normalized = normalizeDocument({ const normalized = normalizeDocument({
...state.document, ...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 ──────────────────────────────────────────── // ── Callout recursive descent ────────────────────────────────────────────
function recurseIntoCalloutMerge( function recurseIntoCalloutMerge(
@ -434,8 +387,7 @@ function recurseIntoCalloutMerge(
activeMarks: state.activeMarks activeMarks: state.activeMarks
}; };
const subResult = const subResult = direction === 'backward' ? deleteBackward(subState) : deleteForward(subState);
direction === 'backward' ? deleteBackward(subState) : deleteForward(subState);
if (!subResult.changed) return noOp(state); if (!subResult.changed) return noOp(state);
const newCallout: CalloutBlock = { const newCallout: CalloutBlock = {
@ -473,10 +425,7 @@ function recurseIntoCalloutMerge(
// ── Helpers ────────────────────────────────────────────────────────────── // ── Helpers ──────────────────────────────────────────────────────────────
type WordsInlineBlock = Extract< type WordsInlineBlock = Extract<WordsBlock, { type: 'paragraph' | 'heading' | 'quote' | 'code' }>;
WordsBlock,
{ type: 'paragraph' | 'heading' | 'quote' | 'code' }
>;
function isInlineBlock(block: WordsBlock): block is WordsInlineBlock { function isInlineBlock(block: WordsBlock): block is WordsInlineBlock {
return ( return (
@ -519,8 +468,6 @@ function mergeInlinesForBlock(
return combined; return combined;
} }
function ensureInlinesNonEmpty( function ensureInlinesNonEmpty(inlines: readonly WordsInline[]): readonly WordsInline[] {
inlines: readonly WordsInline[]
): readonly WordsInline[] {
return inlines.length > 0 ? inlines : [{ type: 'text', text: '' }]; return inlines.length > 0 ? inlines : [{ type: 'text', text: '' }];
} }

@ -124,7 +124,7 @@ describe('deleteRange — same inline container', () => {
expect(p.children).toEqual([{ type: 'text', text: '' }]); 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({ const d = doc({
type: 'paragraph', type: 'paragraph',
children: [ children: [
@ -399,7 +399,13 @@ describe('deleteRange — atomic blocks no-op', () => {
{ type: 'paragraph', children: [{ type: 'text', text: 'before' }] }, { type: 'paragraph', children: [{ type: 'text', text: 'before' }] },
{ {
type: 'table', 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); 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 expect(r.state.selection?.focus.offset).toBe(3); // "hel" length
}); });
}); });

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

@ -24,6 +24,7 @@ import type {
TableCell, TableCell,
TableRow, TableRow,
WordsAlign, WordsAlign,
WordsBlock,
WordsDocument, WordsDocument,
WordsHeadingLevel, WordsHeadingLevel,
WordsImageAlign, WordsImageAlign,
@ -130,7 +131,7 @@ export function createList(kind: WordsListKind, items: readonly ListItem[] = [])
} }
export function createTableCell( export function createTableCell(
children: readonly WordsInline[] = [], children: readonly WordsBlock[] = [],
opts: { opts: {
align?: WordsAlign; align?: WordsAlign;
verticalAlign?: WordsVerticalAlign; verticalAlign?: WordsVerticalAlign;
@ -139,7 +140,8 @@ export function createTableCell(
} = {} } = {}
): TableCell { ): TableCell {
return { 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.align ? { align: opts.align } : {}),
...(opts.verticalAlign ? { verticalAlign: opts.verticalAlign } : {}), ...(opts.verticalAlign ? { verticalAlign: opts.verticalAlign } : {}),
...(opts.colspan && opts.colspan > 1 ? { colspan: opts.colspan } : {}), ...(opts.colspan && opts.colspan > 1 ? { colspan: opts.colspan } : {}),

@ -103,7 +103,11 @@ describe('toggleList', () => {
{ type: 'paragraph', children: [{ type: 'text', text: 'A' }] }, { type: 'paragraph', children: [{ type: 'text', text: 'A' }] },
{ {
type: 'table', 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); const sel = rangeSelection([0, 0], 0, [1, 0, 0, 0], 1);

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

@ -141,10 +141,7 @@ function normalizeList(
return { ...block, id, items }; return { ...block, id, items };
} }
function normalizeListItems( function normalizeListItems(items: readonly ListItem[], idGen: () => string): readonly ListItem[] {
items: readonly ListItem[],
idGen: () => string
): readonly ListItem[] {
if (items.length === 0) { if (items.length === 0) {
// Empty list → seed with one empty item. // Empty list → seed with one empty item.
return [ return [
@ -179,7 +176,7 @@ function normalizeTable(
rows: [ rows: [
{ {
id: idGen(), 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 }; 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( function normalizeTableCells(
cells: readonly TableCell[], cells: readonly TableCell[],
idGen: () => string idGen: () => string
): readonly TableCell[] { ): readonly TableCell[] {
if (cells.length === 0) { if (cells.length === 0) {
return [{ id: idGen(), children: [{ type: 'text', text: '' }] }]; return [{ id: idGen(), children: seedCellChildren(idGen) }];
} }
let mutated = false; let mutated = false;
const next = cells.map((cell) => { 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(); 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; mutated = true;
return { ...cell, id: cellId, children: inlines }; return { ...cell, id: cellId, children };
}); });
return mutated ? next : cells; return mutated ? next : cells;
} }
@ -248,11 +257,7 @@ function normalizeInlineChildren(
let mutated = false; let mutated = false;
for (const inline of inlines) { for (const inline of inlines) {
const last = next[next.length - 1]; const last = next[next.length - 1];
if ( if (inline.type === 'text' && last?.type === 'text' && sameMarks(last.marks, inline.marks)) {
inline.type === 'text' &&
last?.type === 'text' &&
sameMarks(last.marks, inline.marks)
) {
const merged: WordsText = { const merged: WordsText = {
type: 'text', type: 'text',
text: last.text + inline.text, text: last.text + inline.text,
@ -275,9 +280,7 @@ function normalizeInlineChildren(
return mutated ? next : inlines; return mutated ? next : inlines;
} }
function normalizeLinkChildren( function normalizeLinkChildren(children: readonly WordsText[]): readonly WordsText[] {
children: readonly WordsText[]
): readonly WordsText[] {
if (children.length <= 1) return children; if (children.length <= 1) return children;
const next: WordsText[] = []; const next: WordsText[] = [];
let mutated = false; let mutated = false;
@ -297,9 +300,7 @@ function normalizeLinkChildren(
return mutated ? next : children; return mutated ? next : children;
} }
function normalizeCodeChildren( function normalizeCodeChildren(children: readonly WordsText[]): readonly WordsText[] {
children: readonly WordsText[]
): readonly WordsText[] {
// Code blocks have no marks, no links — flatten any stray ones. // Code blocks have no marks, no links — flatten any stray ones.
if (children.length === 0) { if (children.length === 0) {
return [{ type: 'text', text: '' }]; return [{ type: 'text', text: '' }];
@ -332,10 +333,7 @@ function normalizeCodeChildren(
// ── Helpers ────────────────────────────────────────────────────────────── // ── Helpers ──────────────────────────────────────────────────────────────
function sameMarks( function sameMarks(a: WordsText['marks'] | undefined, b: WordsText['marks'] | undefined): boolean {
a: WordsText['marks'] | undefined,
b: WordsText['marks'] | undefined
): boolean {
const al = a ?? []; const al = a ?? [];
const bl = b ?? []; const bl = b ?? [];
if (al.length !== bl.length) return false; if (al.length !== bl.length) return false;
@ -345,12 +343,7 @@ function sameMarks(
if (typeof x !== typeof y) return false; if (typeof x !== typeof y) return false;
if (typeof x === 'string') { if (typeof x === 'string') {
if (x !== y) return false; if (x !== y) return false;
} else if ( } else if (typeof x === 'object' && x !== null && typeof y === 'object' && y !== null) {
typeof x === 'object' &&
x !== null &&
typeof y === 'object' &&
y !== null
) {
const xo = x as { type?: string; value?: string }; const xo = x as { type?: string; value?: string };
const yo = y as { type?: string; value?: string }; const yo = y as { type?: string; value?: string };
if (xo.type !== yo.type || xo.value !== yo.value) return false; 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', () => { it('inserts \\n inside the active cell', () => {
const d = doc({ const d = doc({
type: 'table', 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)); const r = insertParagraph(createState(d, sel));
expect(r.changed).toBe(true); 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( function collectListItemText(item: ListItem, path: WordsPath, out: WordsTextEntry[]): void {
item: ListItem,
path: WordsPath,
out: WordsTextEntry[]
): void {
collectInlineText(item.children, path, out); collectInlineText(item.children, path, out);
} }
function collectTableRowText( function collectTableRowText(row: TableRow, path: WordsPath, out: WordsTextEntry[]): void {
row: TableRow,
path: WordsPath,
out: WordsTextEntry[]
): void {
for (let i = 0; i < row.cells.length; i++) { for (let i = 0; i < row.cells.length; i++) {
collectTableCellText(row.cells[i], [...path, i], out); collectTableCellText(row.cells[i], [...path, i], out);
} }
} }
function collectTableCellText( function collectTableCellText(cell: TableCell, path: WordsPath, out: WordsTextEntry[]): void {
cell: TableCell, // Cells hold blocks (like callout / column): recurse into each child block.
path: WordsPath, for (let i = 0; i < cell.children.length; i++) {
out: WordsTextEntry[] collectBlockText(cell.children[i], [...path, i], out);
): void { }
collectInlineText(cell.children, path, out);
} }
function collectInlineText( function collectInlineText(
@ -166,11 +157,7 @@ function collectInlineText(
} }
} }
function collectLinkText( function collectLinkText(link: WordsLink, parentPath: WordsPath, out: WordsTextEntry[]): void {
link: WordsLink,
parentPath: WordsPath,
out: WordsTextEntry[]
): void {
for (let i = 0; i < link.children.length; i++) { for (let i = 0; i < link.children.length; i++) {
out.push({ path: [...parentPath, i], text: link.children[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.start.path) < 0) continue;
if (comparePath(entry.path, range.end.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 start = samePath(entry.path, range.start.path) ? range.start.offset : 0;
const end = samePath(entry.path, range.end.path) const end = samePath(entry.path, range.end.path) ? range.end.offset : entry.text.text.length;
? range.end.offset
: entry.text.text.length;
if (end <= start) continue; if (end <= start) continue;
out.push({ entry, start, end }); out.push({ entry, start, end });
} }
@ -290,10 +275,7 @@ export function setWordsSelection(
}); });
} }
function clampSelectionForSet( function clampSelectionForSet(doc: WordsDocument, selection: WordsSelection): WordsSelection {
doc: WordsDocument,
selection: WordsSelection
): WordsSelection {
// We can't import dom-helpers to avoid a circular dep — replicate // We can't import dom-helpers to avoid a circular dep — replicate
// the clamp logic inline (text-bound offset + first-text-node // the clamp logic inline (text-bound offset + first-text-node
// fallback). Cheap; the math fits in 10 lines. // fallback). Cheap; the math fits in 10 lines.

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

@ -28,23 +28,59 @@ function table3x3() {
rows: [ rows: [
{ {
cells: [ cells: [
{ children: [{ type: 'text' as const, text: '00' }] }, {
{ children: [{ type: 'text' as const, text: '01' }] }, children: [
{ children: [{ type: 'text' as const, text: '02' }] } { 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: [ cells: [
{ children: [{ type: 'text' as const, text: '10' }] }, {
{ children: [{ type: 'text' as const, text: '11' }] }, children: [
{ children: [{ type: 'text' as const, text: '12' }] } { 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: [ cells: [
{ children: [{ type: 'text' as const, text: '20' }] }, {
{ children: [{ type: 'text' as const, text: '21' }] }, children: [
{ children: [{ type: 'text' as const, text: '22' }] } { 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'); if (table.type !== 'table') throw new Error('expected table');
expect(table.rows).toHaveLength(4); expect(table.rows).toHaveLength(4);
// Original row 1 is now at index 2. // 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', () => { 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 sel = createCollapsedSelection([0, 0], 0);
const r = insertTableRow(createState(d, sel)); const r = insertTableRow(createState(d, sel));
expect(r.changed).toBe(false); expect(r.changed).toBe(false);
@ -109,7 +145,7 @@ describe('insertTableColumn', () => {
if (table.type !== 'table') throw new Error('expected table'); if (table.type !== 'table') throw new Error('expected table');
expect(table.rows[0].cells).toHaveLength(4); expect(table.rows[0].cells).toHaveLength(4);
// Original cell 1 ("01") is now at cell index 2. // 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'); if (table.type !== 'table') throw new Error('expected table');
expect(table.rows).toHaveLength(2); expect(table.rows).toHaveLength(2);
// Row 2 stays — now at index 1. // 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', () => { it('collapses to a paragraph when removing the last row', () => {
const d = doc({ const d = doc({
type: 'table', 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 sel = createCollapsedSelection([0, 0, 0, 0], 0);
const r = deleteTableRow(createState(d, sel)); const r = deleteTableRow(createState(d, sel));
@ -155,14 +199,22 @@ describe('deleteTableColumn', () => {
expect(row.cells).toHaveLength(2); expect(row.cells).toHaveLength(2);
} }
// Column 0 and column 2 survive. // 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, 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, 1, 0, 0])!.text).toBe('02');
}); });
it('collapses to a paragraph when removing the last column', () => { it('collapses to a paragraph when removing the last column', () => {
const d = doc({ const d = doc({
type: 'table', 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 sel = createCollapsedSelection([0, 0, 0, 0], 0);
const r = deleteTableColumn(createState(d, sel)); const r = deleteTableColumn(createState(d, sel));
@ -228,7 +280,10 @@ describe('setCellTextAlign + setCellVerticalAlign', () => {
rows: [ rows: [
{ {
cells: [ 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({ return changed({
document: normalized, document: normalized,
selection: sel, selection: sel,
activeMarks: sel activeMarks: sel ? getActiveMarksForSelection(normalized, sel) : state.activeMarks
? getActiveMarksForSelection(normalized, sel)
: state.activeMarks
}); });
} }
// ── setCellTextAlign / setCellVerticalAlign ────────────────────────────── // ── setCellTextAlign / setCellVerticalAlign ──────────────────────────────
export function setCellTextAlign( export function setCellTextAlign(state: WordsEditorState, align: WordsAlign): WordsOperationResult {
state: WordsEditorState,
align: WordsAlign
): WordsOperationResult {
return updateActiveCell(state, (cell) => { return updateActiveCell(state, (cell) => {
const current = cell.align ?? 'left'; const current = cell.align ?? 'left';
if (current === align) return cell; if (current === align) return cell;
@ -336,9 +331,7 @@ function updateActiveCell(
return changed({ return changed({
document: normalized, document: normalized,
selection: sel, selection: sel,
activeMarks: sel activeMarks: sel ? getActiveMarksForSelection(normalized, sel) : state.activeMarks
? getActiveMarksForSelection(normalized, sel)
: state.activeMarks
}); });
} }
@ -373,13 +366,16 @@ function tableColumnCount(block: TableBlock): number {
function emptyTableRow(columnCount: number): TableRow { function emptyTableRow(columnCount: number): TableRow {
const cells: TableCell[] = []; const cells: TableCell[] = [];
for (let i = 0; i < columnCount; i++) { 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 }; return { cells };
} }
function insertCellInRow(row: TableRow, index: number): TableRow { 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 { return {
...row, ...row,
cells: [...row.cells.slice(0, index), newCell, ...row.cells.slice(index)] 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', () => { it('handles table.rows + row.cells renames', () => {
const d = doc({ const d = doc({
type: 'table', 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', () => { it('walks into callout block children', () => {
@ -145,10 +149,7 @@ describe('helpers — inline offset math', () => {
describe('marks — normalizeMarks', () => { describe('marks — normalizeMarks', () => {
it('dedupes boolean marks + canonicalises order', () => { it('dedupes boolean marks + canonicalises order', () => {
expect(normalizeMarks(['italic', 'bold', 'bold', 'italic'])).toEqual([ expect(normalizeMarks(['italic', 'bold', 'bold', 'italic'])).toEqual(['bold', 'italic']);
'bold',
'italic'
]);
}); });
it('preserves parametric marks and dedupes by type (last wins)', () => { it('preserves parametric marks and dedupes by type (last wins)', () => {
@ -183,12 +184,9 @@ describe('marks — sameMarks', () => {
}); });
it('parametric value differences detected', () => { it('parametric value differences detected', () => {
expect( expect(sameMarks([{ type: 'color', value: '#fff' }], [{ type: 'color', value: '#000' }])).toBe(
sameMarks( false
[{ 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', () => { it('replaces parametric of same type with new value', () => {
const result = toggleMarkSet( const result = toggleMarkSet([{ type: 'color', value: '#fff' }], {
[{ type: 'color', value: '#fff' }], type: 'color',
{ type: 'color', value: '#000' } value: '#000'
); });
expect(result).toEqual([{ type: 'color', value: '#000' }]); expect(result).toEqual([{ type: 'color', value: '#000' }]);
}); });
it('toggle exact parametric off when same value', () => { it('toggle exact parametric off when same value', () => {
const result = toggleMarkSet( const result = toggleMarkSet([{ type: 'color', value: '#fff' }], {
[{ type: 'color', value: '#fff' }], type: 'color',
{ type: 'color', value: '#fff' } value: '#fff'
); });
expect(result).toEqual([]); expect(result).toEqual([]);
}); });
@ -366,12 +364,18 @@ describe('insertText — collapsed selection', () => {
it('inserts into a table cell', () => { it('inserts into a table cell', () => {
const d = doc({ const d = doc({
type: 'table', 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 state = createState(d, sel);
const r = insertText(state, 'hello '); 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'); expect(text).toBe('hello world');
}); });
}); });

@ -231,7 +231,7 @@ describe('render — block style props → inline style', () => {
rows: [ rows: [
{ {
background: '#fafafa', 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: [ cells: [
{ {
children: [{ type: 'text', text: 'a' }], children: [{ type: 'paragraph', children: [{ type: 'text', text: 'a' }] }],
background: '#eef5ff', background: '#eef5ff',
padding: { block: 8, inline: 8 } padding: { block: 8, inline: 8 }
} }
@ -277,8 +277,18 @@ describe('render — table headers', () => {
type: 'table', type: 'table',
headerRow: true, headerRow: true,
rows: [ 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', type: 'table',
headerCol: true, headerCol: true,
rows: [ 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( const r = renderWordsDocument(
doc({ doc({
type: 'paragraph', type: 'paragraph',
children: [ children: [{ type: 'text', text: 'x', marks: [{ type: 'color', value: '#ff5500' }] }]
{ type: 'text', text: 'x', marks: [{ type: 'color', value: '#ff5500' }] }
]
}) })
); );
const node = r[0].children[0]; const node = r[0].children[0];

@ -92,18 +92,14 @@ describe('serializeHtml', () => {
const html = serializeHtml( const html = serializeHtml(
doc({ doc({
type: 'paragraph', type: 'paragraph',
children: [ children: [{ type: 'text', text: 'red text', marks: [{ type: 'color', value: '#ff0000' }] }]
{ type: 'text', text: 'red text', marks: [{ type: 'color', value: '#ff0000' }] }
]
}) })
); );
expect(html).toContain('<span style="color:#ff0000">red text</span>'); expect(html).toContain('<span style="color:#ff0000">red text</span>');
}); });
it('quote emits <blockquote>', () => { it('quote emits <blockquote>', () => {
const html = serializeHtml( const html = serializeHtml(doc({ type: 'quote', children: [{ type: 'text', text: 'Hello' }] }));
doc({ type: 'quote', children: [{ type: 'text', text: 'Hello' }] })
);
expect(html).toBe('<blockquote>Hello</blockquote>'); expect(html).toBe('<blockquote>Hello</blockquote>');
}); });
@ -144,13 +140,17 @@ describe('serializeHtml', () => {
type: 'table', type: 'table',
headerRow: true, headerRow: true,
rows: [ 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( 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>'
); );
}); });
@ -162,6 +162,9 @@ describe('serializeHtml', () => {
{ {
cells: [ cells: [
{ {
children: [
{
type: 'paragraph',
children: [ children: [
{ type: 'text', text: 'Cell' }, { type: 'text', text: 'Cell' },
{ type: 'text', text: '!', marks: ['bold'] } { type: 'text', text: '!', marks: ['bold'] }
@ -170,8 +173,12 @@ 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: [ rows: [
{ {
cells: [ 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: [ cells: [
{ children: [{ type: 'text', text: 'Alice' }] }, { children: [{ type: 'paragraph', children: [{ type: 'text', text: 'Alice' }] }] },
{ children: [{ type: 'text', text: '30' }] } { children: [{ type: 'paragraph', children: [{ type: 'text', text: '30' }] }] }
] ]
} }
] ]
@ -148,8 +154,14 @@ describe('serializeMarkdown — basic blocks', () => {
type: 'table', type: 'table',
headerRow: true, headerRow: true,
rows: [ 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( const md = serializeMarkdown(
doc({ doc({
type: 'paragraph', type: 'paragraph',
children: [ children: [{ type: 'text', text: 'x', marks: ['bold', 'italic', 'code'] }]
{ type: 'text', text: 'x', marks: ['bold', 'italic', 'code'] }
]
}) })
); );
expect(md).toContain('***`x`***'); expect(md).toContain('***`x`***');

@ -206,7 +206,14 @@ export interface ListBlock extends Block {
} }
export interface TableCell 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. */ /** Vertical alignment within the cell. */
readonly verticalAlign?: WordsVerticalAlign; readonly verticalAlign?: WordsVerticalAlign;
readonly colspan?: number; readonly colspan?: number;
@ -396,11 +403,32 @@ export const WORDS_BLOCK_TYPES = [
] as const satisfies readonly WordsBlockType[]; ] as const satisfies readonly WordsBlockType[];
export const WORDS_HEADING_LEVELS = [1, 2, 3] as const satisfies readonly WordsHeadingLevel[]; 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_ALIGNS = [
export const WORDS_LIST_KINDS = ['ordered', 'unordered', 'check'] as const satisfies readonly WordsListKind[]; 'left',
export const WORDS_IMAGE_ALIGNS = ['left', 'center', 'right'] as const satisfies readonly WordsImageAlign[]; 'center',
export const WORDS_VERTICAL_ALIGNS = ['top', 'middle', 'bottom'] as const satisfies readonly WordsVerticalAlign[]; 'right',
export const WORDS_BORDER_STYLES = ['solid', 'dashed', 'dotted'] as const satisfies readonly WordsBorderStyle[]; '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 { export function isWordsBooleanMark(value: unknown): value is WordsBooleanMark {
return typeof value === 'string' && (WORDS_BOOLEAN_MARKS as readonly string[]).includes(value); 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', () => { describe('block type registry', () => {
it('rejects unknown block types', () => { it('rejects unknown block types', () => {
const r = validateWordsDocument( const r = validateWordsDocument(doc({ type: 'mystery-block', children: [] } as never));
doc({ type: 'mystery-block', children: [] } as never)
);
expect(r.valid).toBe(false); expect(r.valid).toBe(false);
if (!r.valid) { if (!r.valid) {
expect(r.errors[0].code).toBe('invalid-block-type'); expect(r.errors[0].code).toBe('invalid-block-type');
@ -83,7 +81,11 @@ describe('block type registry', () => {
{ {
type: 'table', type: 'table',
rows: [ 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' }, { type: 'image', src: 'https://example.com/cat.png', alt: 'cat' },
@ -374,26 +376,15 @@ describe('image', () => {
}); });
it('rejects negative width', () => { it('rejects negative width', () => {
const r = validateWordsDocument( const r = validateWordsDocument(doc({ type: 'image', src: 'https://x', width: -1 }));
doc({ type: 'image', src: 'https://x', width: -1 })
);
expect(r.valid).toBe(false); expect(r.valid).toBe(false);
}); });
}); });
describe('callout (P7 — sema intent vocabulary)', () => { describe('callout (P7 — sema intent vocabulary)', () => {
it('accepts every SemaIntent value', () => { it('accepts every SemaIntent value', () => {
for (const intent of [ for (const intent of ['neutral', 'affirm', 'fulfill', 'risk', 'threat', 'loss'] as const) {
'neutral', const r = validateWordsDocument(doc({ type: 'callout', intent, children: [paragraph('x')] }));
'affirm',
'fulfill',
'risk',
'threat',
'loss'
] as const) {
const r = validateWordsDocument(
doc({ type: 'callout', intent, children: [paragraph('x')] })
);
expect(r.valid).toBe(true); expect(r.valid).toBe(true);
} }
}); });
@ -408,9 +399,7 @@ describe('callout (P7 — sema intent vocabulary)', () => {
}); });
it('rejects callout without intent', () => { it('rejects callout without intent', () => {
const r = validateWordsDocument( const r = validateWordsDocument(doc({ type: 'callout', children: [paragraph('x')] } as never));
doc({ type: 'callout', children: [paragraph('x')] } as never)
);
expect(r.valid).toBe(false); expect(r.valid).toBe(false);
}); });
@ -568,8 +557,8 @@ describe('table', () => {
rows: [ rows: [
{ {
cells: [ cells: [
{ children: [{ type: 'text', text: 'a' }] }, { children: [{ type: 'paragraph', children: [{ type: 'text', text: 'a' }] }] },
{ children: [{ type: 'text', text: 'b' }] } { children: [{ type: 'paragraph', children: [{ type: 'text', text: 'b' }] }] }
] ]
} }
] ]
@ -586,7 +575,7 @@ describe('table', () => {
{ {
cells: [ cells: [
{ {
children: [{ type: 'text', text: 'a' }], children: [{ type: 'paragraph', children: [{ type: 'text', text: 'a' }] }],
background: '#fff5e6', background: '#fff5e6',
padding: { block: 8, inline: 8 } padding: { block: 8, inline: 8 }
} }
@ -606,7 +595,7 @@ describe('table', () => {
{ {
cells: [ cells: [
{ {
children: [{ type: 'text', text: 'a' }], children: [{ type: 'paragraph', children: [{ type: 'text', text: 'a' }] }],
background: 'accent' as never background: 'accent' as never
} }
] ]
@ -625,7 +614,7 @@ describe('table', () => {
rows: [ rows: [
{ {
background: '#f5f5f5', background: '#f5f5f5',
cells: [{ children: [{ type: 'text', text: 'a' }] }] cells: [{ children: [{ type: 'paragraph', children: [{ type: 'text', text: 'a' }] }] }]
} }
] ]
}) })
@ -640,7 +629,7 @@ describe('table', () => {
rows: [ rows: [
{ {
background: 'red' as never, 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< onValueCommit: state<
((value: WordsDocument, reason: 'programmatic' | 'blur' | 'button') => void) | undefined ((value: WordsDocument, reason: 'programmatic' | 'blur' | 'button') => void) | undefined
>(undefined), >(undefined),
onUploadImage: state< onUploadImage: state<((file: File) => Promise<{ url: string; alt?: string }>) | undefined>(
((file: File) => Promise<{ url: string; alt?: string }>) | undefined undefined
>(undefined) )
}; };
} }
@ -1416,7 +1416,9 @@ describe('WordsProvider', () => {
document.body.append(root, contentEl, buttonEl); document.body.append(root, contentEl, buttonEl);
const opts = wordsOpts(root, { const opts = wordsOpts(root, {
version: WORDS_VERSION, version: WORDS_VERSION,
children: [createTable([createTableRow([createTableCell([createText('Cell')])])])] children: [
createTable([createTableRow([createTableCell([createParagraph([createText('Cell')])])])])
]
}); });
const { result, cleanup } = withEffectRoot(() => { const { result, cleanup } = withEffectRoot(() => {
@ -1453,7 +1455,7 @@ describe('WordsProvider', () => {
} as never); } as never);
expect(result.provider.exportContent('html')).toBe( 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(); cleanup();
@ -1653,11 +1655,7 @@ describe('WordsProvider', () => {
expect(result.provider.document.children).toMatchObject([ expect(result.provider.document.children).toMatchObject([
{ {
type: 'table', type: 'table',
rows: [ rows: [{ cells: [{}, {}, {}] }, { cells: [{}, {}, {}] }, { cells: [{}, {}, {}] }]
{ cells: [{}, {}, {}] },
{ cells: [{}, {}, {}] },
{ cells: [{}, {}, {}] }
]
}, },
{ type: 'paragraph', children: [{ type: 'text', text: '' }] } { type: 'paragraph', children: [{ type: 'text', text: '' }] }
]); ]);
@ -1679,10 +1677,13 @@ describe('WordsProvider', () => {
children: [ children: [
createTable([ createTable([
createTableRow([ createTableRow([
createTableCell([createText('A1')]), createTableCell([createParagraph([createText('A1')])]),
createTableCell([createText('B1')]) 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({ expect(result.provider.document.children[0]).toMatchObject({
type: 'table', type: 'table',
rows: [ rows: [
{ cells: [{ children: [{ text: 'A1' }] }, { children: [{ text: 'B1' }] }] }, {
{ cells: [{ children: [{ text: '' }] }, { children: [{ text: '' }] }] }, cells: [
{ cells: [{ children: [{ text: 'A2' }] }, { children: [{ text: 'B2' }] }] } { 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({ expect(result.provider.selection).toEqual({
@ -1714,11 +1730,7 @@ describe('WordsProvider', () => {
result.provider.runCommandName('insert-table-column'); result.provider.runCommandName('insert-table-column');
expect(result.provider.document.children[0]).toMatchObject({ expect(result.provider.document.children[0]).toMatchObject({
type: 'table', type: 'table',
rows: [ rows: [{ cells: [{}, {}, {}] }, { cells: [{}, {}, {}] }, { cells: [{}, {}, {}] }]
{ cells: [{}, {}, {}] },
{ cells: [{}, {}, {}] },
{ cells: [{}, {}, {}] }
]
}); });
expect(result.provider.selection).toEqual({ expect(result.provider.selection).toEqual({
anchor: { path: [0, 1, 1, 0], offset: 0 }, anchor: { path: [0, 1, 1, 0], offset: 0 },
@ -1730,8 +1742,18 @@ describe('WordsProvider', () => {
expect(result.provider.document.children[0]).toMatchObject({ expect(result.provider.document.children[0]).toMatchObject({
type: 'table', type: 'table',
rows: [ 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: [ children: [
createTable([ createTable([
createTableRow([ createTableRow([
createTableCell([createText('A1')]), createTableCell([createParagraph([createText('A1')])]),
createTableCell([createText('B1')]) 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, headerRow: true,
rows: [ rows: [
{ {
cells: [ cells: [{}, { verticalAlign: 'middle' }]
{},
{ verticalAlign: 'middle' }
]
}, },
{ {
cells: [{}, {}] cells: [{}, {}]
@ -1800,7 +1822,10 @@ describe('WordsProvider', () => {
version: WORDS_VERSION, version: WORDS_VERSION,
children: [ children: [
createTable([ 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.history.past).toHaveLength(1);
expect(result.provider.document.children[0]).toMatchObject({ expect(result.provider.document.children[0]).toMatchObject({
type: 'table', type: 'table',
rows: [ rows: [{ cells: [{}, {}] }, { cells: [{}, {}] }]
{ cells: [{}, {}] },
{ cells: [{}, {}] }
]
}); });
expect(result.provider.selection).toEqual({ expect(result.provider.selection).toEqual({
anchor: { path: [0, 1, 0, 0], offset: 0 }, anchor: { path: [0, 1, 0, 0], offset: 0 },

@ -18,8 +18,7 @@
const handleUploadImage: WordsOnUploadImage = (file: File) => const handleUploadImage: WordsOnUploadImage = (file: File) =>
new Promise<WordsImageUploadResult>((resolve, reject) => { new Promise<WordsImageUploadResult>((resolve, reject) => {
const reader = new FileReader(); const reader = new FileReader();
reader.onerror = () => reader.onerror = () => reject(reader.error ?? new Error('FileReader failed'));
reject(reader.error ?? new Error('FileReader failed'));
reader.onload = () => { reader.onload = () => {
const url = typeof reader.result === 'string' ? reader.result : ''; const url = typeof reader.result === 'string' ? reader.result : '';
if (!url) { if (!url) {
@ -53,7 +52,10 @@
children: [ children: [
{ type: 'text', text: 'A clean editor rebuilt on a base ' }, { type: 'text', text: 'A clean editor rebuilt on a base ' },
{ type: 'text', text: 'Block', marks: ['code'] }, { 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: 'extended', marks: ['italic'] },
{ type: 'text', text: ' per type.' } { type: 'text', text: ' per type.' }
] ]
@ -70,7 +72,12 @@
{ type: 'text', text: ', ' }, { type: 'text', text: ', ' },
{ type: 'text', text: 'strike', marks: ['strike'] }, { type: 'text', text: 'strike', marks: ['strike'] },
{ type: 'text', text: ', and a ' }, { 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: '.' } { type: 'text', text: '.' }
] ]
}, },
@ -81,12 +88,22 @@
}, },
{ {
type: 'quote', 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', type: 'code',
language: 'ts', 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', type: 'heading',
@ -106,7 +123,10 @@
kind: 'check', kind: 'check',
items: [ items: [
{ checked: true, children: [{ type: 'text', text: 'Engine reworked (base Block)' }] }, { 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', intent: 'affirm',
title: 'Foundation', title: 'Foundation',
children: [ 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' }, { type: 'divider' },
@ -141,20 +169,22 @@
rows: [ rows: [
{ {
cells: [ cells: [
{ children: [{ type: 'text', text: 'Phase' }] }, { children: [{ type: 'paragraph', children: [{ type: 'text', text: 'Phase' }] }] },
{ children: [{ type: 'text', text: 'Status' }] } { children: [{ type: 'paragraph', children: [{ type: 'text', text: 'Status' }] }] }
] ]
}, },
{ {
cells: [ cells: [
{ children: [{ type: 'text', text: 'Engine' }] }, { children: [{ type: 'paragraph', children: [{ type: 'text', text: 'Engine' }] }] },
{ children: [{ type: 'text', text: 'Done' }] } { children: [{ type: 'paragraph', children: [{ type: 'text', text: 'Done' }] }] }
] ]
}, },
{ {
cells: [ cells: [
{ children: [{ type: 'text', text: 'Visual' }] }, { children: [{ type: 'paragraph', children: [{ type: 'text', text: 'Visual' }] }] },
{ children: [{ type: 'text', text: 'In progress' }] } {
children: [{ type: 'paragraph', children: [{ type: 'text', text: 'In progress' }] }]
}
] ]
} }
] ]
@ -167,8 +197,8 @@
<p data-uix-eyebrow>— editors · words</p> <p data-uix-eyebrow>— editors · words</p>
<h1>Words</h1> <h1>Words</h1>
<p> <p>
Rebuilt on a clean base-<code>Block</code> model. This is the F2 foundation: the editor surface Rebuilt on a clean base-<code>Block</code> model. This is the F2 foundation: the editor surface renders
renders and edits. Chrome (left gutter menu, right settings gutter, bubble toolbar) is next. and edits. Chrome (left gutter menu, right settings gutter, bubble toolbar) is next.
</p> </p>
</header> </header>
@ -192,12 +222,7 @@
</div> </div>
<div class="demo-canvas"> <div class="demo-canvas">
<Words <Words bind:value {inspector} placeholder="Write something…" onUploadImage={handleUploadImage} />
bind:value
{inspector}
placeholder="Write something…"
onUploadImage={handleUploadImage}
/>
</div> </div>
<details class="demo-trace"> <details class="demo-trace">

Loading…
Cancel
Save

Powered by TurnKey Linux.