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