From aa9ffedfd8d8ba6669610c3562810f5f8fb8714d Mon Sep 17 00:00:00 2001 From: dev Date: Tue, 2 Jun 2026 17:39:01 +0200 Subject: [PATCH] =?UTF-8?q?feat(words):=20table=20cells=20hold=20blocks=20?= =?UTF-8?q?(TableCell.children=20=E2=86=92=20WordsBlock[])?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 (

...

); Markdown -> flatten blocks to inline joined with
(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 (

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) --- .../words/engine/blocks/built-ins.ts | 112 ++++++++---- .../engine/operations/block-format.test.ts | 12 +- .../engine/operations/delete-ops.test.ts | 38 ++-- .../words/engine/operations/delete-ops.ts | 165 ++++++------------ .../words/engine/operations/delete.test.ts | 11 +- .../words/engine/operations/extra-ops.ts | 27 +-- .../words/engine/operations/factories.ts | 6 +- .../words/engine/operations/list-ops.test.ts | 6 +- .../words/engine/operations/marks-ops.test.ts | 23 ++- .../words/engine/operations/normalize.ts | 53 +++--- .../engine/operations/paragraph-ops.test.ts | 10 +- .../engine/operations/selection-walkers.ts | 38 ++-- .../words/engine/operations/serialize-text.ts | 32 +++- .../words/engine/operations/table-ops.test.ts | 91 ++++++++-- .../words/engine/operations/table-ops.ts | 20 +-- .../words/engine/operations/text.test.ts | 52 +++--- .../components/words/engine/render.test.ts | 29 ++- .../words/engine/serialize-html.test.ts | 31 ++-- .../words/engine/serialize-markdown.test.ts | 28 ++- src/uix/soma/components/words/engine/types.ts | 40 ++++- .../components/words/engine/validate.test.ts | 43 ++--- .../words/words-provider.svelte.test.ts | 92 ++++++---- web/routes/uix/components/words/+page.svelte | 69 +++++--- 23 files changed, 586 insertions(+), 442 deletions(-) diff --git a/src/uix/soma/components/words/engine/blocks/built-ins.ts b/src/uix/soma/components/words/engine/blocks/built-ins.ts index ee375a57c..90a2b0ffd 100644 --- a/src/uix/soma/components/words/engine/blocks/built-ins.ts +++ b/src/uix/soma/components/words/engine/blocks/built-ins.ts @@ -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 `${cells}`; }) .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)}`; + // Cells hold blocks (like callout): serialize each child block. + const inner = cell.children.map((child) => ctx.blockToHtml(child)).join(''); + return `<${tag}${attrs}>${inner}`; } // ── 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
(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('
'); + 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('
'); + case 'callout': + return block.children.map((c) => blockToInlineMd(c, ctx)).join('
'); + case 'columns': + return block.columns + .flatMap((col) => col.children.map((c) => blockToInlineMd(c, ctx))) + .join('
'); + case 'table': + return block.rows + .flatMap((r) => + r.cells.map((c) => c.children.map((cc) => blockToInlineMd(cc, ctx)).join(' ')) + ) + .join('
'); + 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++) { diff --git a/src/uix/soma/components/words/engine/operations/block-format.test.ts b/src/uix/soma/components/words/engine/operations/block-format.test.ts index 51099ed0f..ec30d9aff 100644 --- a/src/uix/soma/components/words/engine/operations/block-format.test.ts +++ b/src/uix/soma/components/words/engine/operations/block-format.test.ts @@ -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' }] }, diff --git a/src/uix/soma/components/words/engine/operations/delete-ops.test.ts b/src/uix/soma/components/words/engine/operations/delete-ops.test.ts index 7ab85602b..2e47555da 100644 --- a/src/uix/soma/components/words/engine/operations/delete-ops.test.ts +++ b/src/uix/soma/components/words/engine/operations/delete-ops.test.ts @@ -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); }); }); diff --git a/src/uix/soma/components/words/engine/operations/delete-ops.ts b/src/uix/soma/components/words/engine/operations/delete-ops.ts index 6e9cb7404..9bb2e05ef 100644 --- a/src/uix/soma/components/words/engine/operations/delete-ops.ts +++ b/src/uix/soma/components/words/engine/operations/delete-ops.ts @@ -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; 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: '' }]; } diff --git a/src/uix/soma/components/words/engine/operations/delete.test.ts b/src/uix/soma/components/words/engine/operations/delete.test.ts index cc721e251..98064af0b 100644 --- a/src/uix/soma/components/words/engine/operations/delete.test.ts +++ b/src/uix/soma/components/words/engine/operations/delete.test.ts @@ -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 }); }); - diff --git a/src/uix/soma/components/words/engine/operations/extra-ops.ts b/src/uix/soma/components/words/engine/operations/extra-ops.ts index d3734b5f6..53d0f0ec7 100644 --- a/src/uix/soma/components/words/engine/operations/extra-ops.ts +++ b/src/uix/soma/components/words/engine/operations/extra-ops.ts @@ -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, diff --git a/src/uix/soma/components/words/engine/operations/factories.ts b/src/uix/soma/components/words/engine/operations/factories.ts index b65a3a9b5..583654110 100644 --- a/src/uix/soma/components/words/engine/operations/factories.ts +++ b/src/uix/soma/components/words/engine/operations/factories.ts @@ -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 } : {}), diff --git a/src/uix/soma/components/words/engine/operations/list-ops.test.ts b/src/uix/soma/components/words/engine/operations/list-ops.test.ts index c03b931d0..dac8034d4 100644 --- a/src/uix/soma/components/words/engine/operations/list-ops.test.ts +++ b/src/uix/soma/components/words/engine/operations/list-ops.test.ts @@ -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); diff --git a/src/uix/soma/components/words/engine/operations/marks-ops.test.ts b/src/uix/soma/components/words/engine/operations/marks-ops.test.ts index 53a38822b..f1789c5d6 100644 --- a/src/uix/soma/components/words/engine/operations/marks-ops.test.ts +++ b/src/uix/soma/components/words/engine/operations/marks-ops.test.ts @@ -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); diff --git a/src/uix/soma/components/words/engine/operations/normalize.ts b/src/uix/soma/components/words/engine/operations/normalize.ts index 46c0639fc..2fe1d684d 100644 --- a/src/uix/soma/components/words/engine/operations/normalize.ts +++ b/src/uix/soma/components/words/engine/operations/normalize.ts @@ -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; diff --git a/src/uix/soma/components/words/engine/operations/paragraph-ops.test.ts b/src/uix/soma/components/words/engine/operations/paragraph-ops.test.ts index 3672537e6..7d7a4ab27 100644 --- a/src/uix/soma/components/words/engine/operations/paragraph-ops.test.ts +++ b/src/uix/soma/components/words/engine/operations/paragraph-ops.test.ts @@ -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'); }); }); diff --git a/src/uix/soma/components/words/engine/operations/selection-walkers.ts b/src/uix/soma/components/words/engine/operations/selection-walkers.ts index 4bc65c5fc..d617897a3 100644 --- a/src/uix/soma/components/words/engine/operations/selection-walkers.ts +++ b/src/uix/soma/components/words/engine/operations/selection-walkers.ts @@ -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. diff --git a/src/uix/soma/components/words/engine/operations/serialize-text.ts b/src/uix/soma/components/words/engine/operations/serialize-text.ts index 9206cd8f1..63b4b16e1 100644 --- a/src/uix/soma/components/words/engine/operations/serialize-text.ts +++ b/src/uix/soma/components/words/engine/operations/serialize-text.ts @@ -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; diff --git a/src/uix/soma/components/words/engine/operations/table-ops.test.ts b/src/uix/soma/components/words/engine/operations/table-ops.test.ts index abda81334..5294cb1ff 100644 --- a/src/uix/soma/components/words/engine/operations/table-ops.test.ts +++ b/src/uix/soma/components/words/engine/operations/table-ops.test.ts @@ -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' + } ] } ] diff --git a/src/uix/soma/components/words/engine/operations/table-ops.ts b/src/uix/soma/components/words/engine/operations/table-ops.ts index d9a3017e6..19498b9c8 100644 --- a/src/uix/soma/components/words/engine/operations/table-ops.ts +++ b/src/uix/soma/components/words/engine/operations/table-ops.ts @@ -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)] diff --git a/src/uix/soma/components/words/engine/operations/text.test.ts b/src/uix/soma/components/words/engine/operations/text.test.ts index 01ad072d0..33e10ac7c 100644 --- a/src/uix/soma/components/words/engine/operations/text.test.ts +++ b/src/uix/soma/components/words/engine/operations/text.test.ts @@ -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'); }); }); diff --git a/src/uix/soma/components/words/engine/render.test.ts b/src/uix/soma/components/words/engine/render.test.ts index 72c4ff5ce..ed1ae2cf6 100644 --- a/src/uix/soma/components/words/engine/render.test.ts +++ b/src/uix/soma/components/words/engine/render.test.ts @@ -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]; diff --git a/src/uix/soma/components/words/engine/serialize-html.test.ts b/src/uix/soma/components/words/engine/serialize-html.test.ts index 374ae23b2..33e6e49bf 100644 --- a/src/uix/soma/components/words/engine/serialize-html.test.ts +++ b/src/uix/soma/components/words/engine/serialize-html.test.ts @@ -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('red text'); }); it('quote emits

', () => { - 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('
Hello
'); }); @@ -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( - '
h1
a
' + '

h1

a

' ); }); @@ -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('
Cell!
'); + expect(html).toBe( + '

Cell!

' + ); }); }); diff --git a/src/uix/soma/components/words/engine/serialize-markdown.test.ts b/src/uix/soma/components/words/engine/serialize-markdown.test.ts index 0ef2d7703..775eaa71f 100644 --- a/src/uix/soma/components/words/engine/serialize-markdown.test.ts +++ b/src/uix/soma/components/words/engine/serialize-markdown.test.ts @@ -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`***'); diff --git a/src/uix/soma/components/words/engine/types.ts b/src/uix/soma/components/words/engine/types.ts index 7a50fe42a..1f10d8740 100644 --- a/src/uix/soma/components/words/engine/types.ts +++ b/src/uix/soma/components/words/engine/types.ts @@ -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); diff --git a/src/uix/soma/components/words/engine/validate.test.ts b/src/uix/soma/components/words/engine/validate.test.ts index 66deeba7c..5a1ba0e59 100644 --- a/src/uix/soma/components/words/engine/validate.test.ts +++ b/src/uix/soma/components/words/engine/validate.test.ts @@ -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' }] }] }] } ] }) diff --git a/src/uix/soma/components/words/words-provider.svelte.test.ts b/src/uix/soma/components/words/words-provider.svelte.test.ts index 34a5e6eb6..a1f7eb682 100644 --- a/src/uix/soma/components/words/words-provider.svelte.test.ts +++ b/src/uix/soma/components/words/words-provider.svelte.test.ts @@ -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( - '
Cell!
' + '

Cell!

' ); 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 }, diff --git a/web/routes/uix/components/words/+page.svelte b/web/routes/uix/components/words/+page.svelte index 713bc43c1..4126b124b 100644 --- a/web/routes/uix/components/words/+page.svelte +++ b/web/routes/uix/components/words/+page.svelte @@ -18,8 +18,7 @@ const handleUploadImage: WordsOnUploadImage = (file: File) => new Promise((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 @@

— editors · words

Words

- Rebuilt on a clean base-Block 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-Block model. This is the F2 foundation: the editor surface renders + and edits. Chrome (left gutter menu, right settings gutter, bubble toolbar) is next.

@@ -192,12 +222,7 @@
- +