From 1043f1de4a96d3692bca8bbf8b89b392fd452d77 Mon Sep 17 00:00:00 2001 From: dev Date: Wed, 27 May 2026 18:39:55 +0200 Subject: [PATCH] refactor(words): extract 8 pure table helpers to extensions/table/utils.ts (F2.3h.1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Moves the table-pure helpers (no engine selection/normalize/marks dependencies) from `engine/operations.ts` to `extensions/table/utils.ts`: - createTableGrid (2×2 default, clamped 1..12 × 1..8) - tableCellPathFromContainer (3-tuple path resolver) - tableOptions / tableCellOptions (option-bag readers) - tableColumnCount (max-cells-across-rows, min 1) - createEmptyTableRow (N-cell row factory) - insertTableCellInRow / deleteTableCellFromRow (cell ops over a row) - WordsTableCellPath type alias The 6 *mixed* helpers (currentTableCellPath, selectTableCell, setTableCellTextAlign, updateCurrentTableCell, updateCurrentTable, preserveTableFormattingSelection) still live in operations.ts — they read private engine state and can only move once F2.3i full lands a visitor pattern that injects those engine APIs as parameters. clampInteger stays duplicated locally in utils.ts (9 LoC, generic numeric helper) instead of being exported from the engine, to keep the extension's surface tight. If a third caller emerges, promote to a shared location. 149/149 tests pass in the full words soma scope. TS check clean in extensions+engine area. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../components/words/engine/operations.ts | 89 ++-------- .../words/extensions/table/index.ts | 12 ++ .../words/extensions/table/utils.ts | 167 ++++++++++++++++++ 3 files changed, 195 insertions(+), 73 deletions(-) create mode 100644 src/uix/soma/components/words/extensions/table/utils.ts diff --git a/src/uix/soma/components/words/engine/operations.ts b/src/uix/soma/components/words/engine/operations.ts index f1801d522..f3935f27d 100644 --- a/src/uix/soma/components/words/engine/operations.ts +++ b/src/uix/soma/components/words/engine/operations.ts @@ -54,6 +54,17 @@ import { type WordsSelection, type WordsTextEntry } from './selection'; +import { + createEmptyTableRow, + createTableGrid, + deleteTableCellFromRow, + insertTableCellInRow, + tableCellOptions, + tableCellPathFromContainer, + tableColumnCount, + tableOptions, + type WordsTableCellPath +} from '../extensions/table/utils'; export interface WordsEditorState { readonly document: WordsDocument; @@ -91,7 +102,7 @@ type WordsMarkdownShortcut = | { readonly type: 'list'; readonly kind: WordsListKind; readonly checked?: boolean }; type WordsListItemPath = readonly [number, number]; -type WordsTableCellPath = readonly [number, number, number]; +// WordsTableCellPath moved to ../extensions/table/utils.ts (F2.3h.1) type WordsInlineBlock = Extract; type WordsSetBlockType = Exclude; @@ -2401,15 +2412,7 @@ function withInlineChildren(block: WordsInlineBlock, children: readonly WordsInl } } -function createTableGrid(rows: number | undefined, columns: number | undefined): WordsTableBlock { - const rowCount = clampInteger(rows, 2, 1, 12); - const columnCount = clampInteger(columns, 2, 1, 8); - return createTable( - Array.from({ length: rowCount }, () => - createTableRow(Array.from({ length: columnCount }, () => createTableCell())) - ) - ); -} +// createTableGrid moved to ../extensions/table/utils.ts (F2.3h.1) function currentTableCellPath( document: WordsDocument, @@ -2515,69 +2518,9 @@ function preserveTableFormattingSelection( }; } -function tableOptions(block: WordsTableBlock): { striped?: boolean; compact?: boolean } { - return { - striped: block.striped === true, - compact: block.compact === true - }; -} - -function tableCellOptions(cell: WordsTableCell): { - header?: boolean; - textAlign?: WordsTextAlign; - verticalAlign?: WordsTableCellVerticalAlign; - tone?: WordsTableCellTone; -} { - return { - header: cell.header === true, - textAlign: cell.textAlign, - verticalAlign: cell.verticalAlign, - tone: cell.tone - }; -} - -function tableColumnCount(block: WordsTableBlock): number { - return Math.max(1, ...block.children.map((row) => row.children.length)); -} - -function createEmptyTableRow(columns: number): WordsTableRow { - return createTableRow(Array.from({ length: Math.max(1, columns) }, () => createTableCell())); -} - -function insertTableCellInRow(row: WordsTableRow, columnIndex: number): WordsTableRow { - const cells = [...row.children]; - while (cells.length < columnIndex) cells.push(createTableCell()); - return createTableRow([ - ...cells.slice(0, columnIndex), - createTableCell(), - ...cells.slice(columnIndex) - ]); -} - -function deleteTableCellFromRow( - row: WordsTableRow, - columnIndex: number, - columnCount: number -): WordsTableRow { - const cells = [...row.children]; - while (cells.length < columnCount) cells.push(createTableCell()); - return createTableRow(cells.filter((_, index) => index !== columnIndex)); -} - -function tableCellPathFromContainer( - block: WordsTableBlock, - containerPath: WordsPath -): WordsTableCellPath | undefined { - const blockIndex = containerPath[0]; - const rowIndex = containerPath[1]; - const cellIndex = containerPath[2]; - if (blockIndex === undefined || rowIndex === undefined || cellIndex === undefined) { - return undefined; - } - return block.children[rowIndex]?.children[cellIndex] - ? [blockIndex, rowIndex, cellIndex] - : undefined; -} +// tableOptions, tableCellOptions, tableColumnCount, createEmptyTableRow, +// insertTableCellInRow, deleteTableCellFromRow and tableCellPathFromContainer +// moved to ../extensions/table/utils.ts (F2.3h.1). function isInlineBlock(block: WordsBlock): block is WordsInlineBlock { return ( diff --git a/src/uix/soma/components/words/extensions/table/index.ts b/src/uix/soma/components/words/extensions/table/index.ts index 41a30aa18..a07f485cf 100644 --- a/src/uix/soma/components/words/extensions/table/index.ts +++ b/src/uix/soma/components/words/extensions/table/index.ts @@ -67,3 +67,15 @@ export type { } from './render'; export { tableExtension } from './table-extension'; + +export { + createEmptyTableRow, + createTableGrid, + deleteTableCellFromRow, + insertTableCellInRow, + tableCellOptions, + tableCellPathFromContainer, + tableColumnCount, + tableOptions +} from './utils'; +export type { WordsTableCellPath } from './utils'; diff --git a/src/uix/soma/components/words/extensions/table/utils.ts b/src/uix/soma/components/words/extensions/table/utils.ts new file mode 100644 index 000000000..2e43ecbab --- /dev/null +++ b/src/uix/soma/components/words/extensions/table/utils.ts @@ -0,0 +1,167 @@ +/** + * Table extension — pure utility helpers used by the table reducers. + * + * **F2.3h.1 stage**: this file holds the 8 helpers that have NO + * dependency on engine private internals (selection / normalize / marks). + * They are pure functions over `WordsTableBlock` / `WordsTableRow` / + * `WordsTableCell` plus a small `clampInteger` numeric helper kept + * local to avoid pulling a private engine util into the extension. + * + * The 6 *mixed* helpers (`currentTableCellPath`, `selectTableCell`, + * `setTableCellTextAlign`, `updateCurrentTableCell`, `updateCurrentTable`, + * `preserveTableFormattingSelection`) still live in `engine/operations.ts` + * — they read private engine state (selection range, active marks, + * normalization) and can only move once F2.3i full lands a visitor + * pattern that injects those engine APIs as parameters. + */ + +import type { WordsPath } from '../../engine/path'; +import { + createTable, + createTableCell, + createTableRow +} from './factories'; +import type { + WordsTableBlock, + WordsTableCell, + WordsTableCellTone, + WordsTableCellVerticalAlign, + WordsTableRow +} from './types'; +import type { WordsTextAlign } from '../../engine/document'; + +/** + * Path triple `[blockIndex, rowIndex, cellIndex]` addressing a single + * cell inside a table block. Returned by `currentTableCellPath` (still + * in the engine for now) and consumed by reducers + utilities. + */ +export type WordsTableCellPath = readonly [number, number, number]; + +/** + * Construct an N×M default-empty table. Used when a user inserts a + * fresh table via slash menu / toolbar command. Rows / columns are + * clamped to sensible bounds (1..12 rows, 1..8 columns) defaulting to + * 2×2 if undefined. + */ +export function createTableGrid( + rows: number | undefined, + columns: number | undefined +): WordsTableBlock { + const rowCount = clampInteger(rows, 2, 1, 12); + const columnCount = clampInteger(columns, 2, 1, 8); + return createTable( + Array.from({ length: rowCount }, () => + createTableRow(Array.from({ length: columnCount }, () => createTableCell())) + ) + ); +} + +/** + * Resolve a container path (e.g. the path of the current editing + * container) to a typed `WordsTableCellPath` if the container points + * to a cell inside the given table block. Returns `undefined` if any + * coordinate is missing or out of range. + */ +export function tableCellPathFromContainer( + block: WordsTableBlock, + containerPath: WordsPath +): WordsTableCellPath | undefined { + const blockIndex = containerPath[0]; + const rowIndex = containerPath[1]; + const cellIndex = containerPath[2]; + if (blockIndex === undefined || rowIndex === undefined || cellIndex === undefined) { + return undefined; + } + return block.children[rowIndex]?.children[cellIndex] + ? [blockIndex, rowIndex, cellIndex] + : undefined; +} + +/** Read the table-level options off a block as a plain options bag. */ +export function tableOptions( + block: WordsTableBlock +): { striped?: boolean; compact?: boolean } { + return { + striped: block.striped === true, + compact: block.compact === true + }; +} + +/** Read the cell-level options off a cell as a plain options bag. */ +export function tableCellOptions(cell: WordsTableCell): { + header?: boolean; + textAlign?: WordsTextAlign; + verticalAlign?: WordsTableCellVerticalAlign; + tone?: WordsTableCellTone; +} { + return { + header: cell.header === true, + textAlign: cell.textAlign, + verticalAlign: cell.verticalAlign, + tone: cell.tone + }; +} + +/** + * Effective column count of a table — max cells across all rows, with + * a floor of 1 (an empty table is still a 1-column table for layout). + */ +export function tableColumnCount(block: WordsTableBlock): number { + return Math.max(1, ...block.children.map((row) => row.children.length)); +} + +/** Build a row with `columns` empty cells (min 1). */ +export function createEmptyTableRow(columns: number): WordsTableRow { + return createTableRow( + Array.from({ length: Math.max(1, columns) }, () => createTableCell()) + ); +} + +/** + * Return a new row with a fresh empty cell inserted at `columnIndex`. + * If the row has fewer cells than `columnIndex`, the missing cells are + * back-filled with empties (this keeps the column grid square). + */ +export function insertTableCellInRow( + row: WordsTableRow, + columnIndex: number +): WordsTableRow { + const cells = [...row.children]; + while (cells.length < columnIndex) cells.push(createTableCell()); + return createTableRow([ + ...cells.slice(0, columnIndex), + createTableCell(), + ...cells.slice(columnIndex) + ]); +} + +/** + * Return a new row with the cell at `columnIndex` removed. Back-fills + * the row up to `columnCount` empties before removing to keep grid + * shape consistent across rows of different lengths. + */ +export function deleteTableCellFromRow( + row: WordsTableRow, + columnIndex: number, + columnCount: number +): WordsTableRow { + const cells = [...row.children]; + while (cells.length < columnCount) cells.push(createTableCell()); + return createTableRow(cells.filter((_, index) => index !== columnIndex)); +} + +/** + * Local copy of `clampInteger` — generic numeric utility used only by + * `createTableGrid` here. Kept local to avoid pulling a private engine + * util into the extension. If a third caller emerges, promote to a + * shared location. + */ +function clampInteger( + value: number | undefined, + fallback: number, + min: number, + max: number +): number { + if (typeof value !== 'number' || !Number.isInteger(value)) return fallback; + return Math.max(min, Math.min(value, max)); +}