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)}${tag}>`;
+ // Cells hold blocks (like callout): serialize each child block.
+ const inner = cell.children.map((child) => ctx.blockToHtml(child)).join('');
+ return `<${tag}${attrs}>${inner}${tag}>`;
}
// ── List / table Markdown sub-serializers ─────────────────────────────────
@@ -990,7 +1004,49 @@ function listItemToMd(
}
function cellTextToMd(cell: TableCell, ctx: WordsMarkdownSerializeContext): string {
- return ctx.inlinesToMd(cell.children).replace(/\|/g, '\\|');
+ // GFM table cells are inline-only: flatten each child block to inline
+ // markdown and join with
(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(
- ''
+ ''
);
});
@@ -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('');
+ expect(html).toBe(
+ ''
+ );
});
});
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(
- ''
+ ''
);
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 @@
-
+