refactor(words): extract 8 pure table helpers to extensions/table/utils.ts (F2.3h.1)

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) <noreply@anthropic.com>
active-uix
dev 4 months ago
parent bde6728590
commit 1043f1de4a

@ -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<WordsBlock, { type: 'paragraph' | 'heading' | 'quote' | 'code' }>;
type WordsSetBlockType = Exclude<WordsBlockType, 'list' | 'table'>;
@ -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 (

@ -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';

@ -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));
}
Loading…
Cancel
Save

Powered by TurnKey Linux.