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)); +}