@acheron-grid/core
An experimental headless TypeScript data-grid engine. Core owns data, selection, layout, editing, TSV clipboard operations and delta history. It has no runtime dependencies or browser/framework types.
Build
From the engine repository root, use Node.js 22 or later:
npm ci
npm run build
npm run typecheck
npm test
Build runs core before Canvas. This package exports ESM JavaScript and TypeScript declarations from dist/. It is not published; private: true prevents accidental npm publication. To use the built package in another project:
npm install /path/to/acheron-grid-engine/packages/core
Usage
Structured clipboard blocks can carry optional per-cell formats (background, textColor, contentFormat). The content hint is plain, html or markdown; core stores this sparse metadata without parsing markup. Formatted paste checks paste/writable/formatting permissions and commits values and formatting in one history entry. format:change events include the paste source for that operation. Plain TSV remains value-only. Clipboard helpers encodeBlocks, decodeBlocks and blocksToTsv are exported for renderer integration.
The root entry exports createGridEngine, LocalDataSource, LocalDataView and their public types. It runs in Node without DOM, Canvas or framework globals. The earlier @acheron-grid/core/headless subpath remains an alias. Browser rendering is provided by @acheron-grid/canvas.
import { createGridEngine, LocalDataSource } from '@acheron-grid/core';
const engine = createGridEngine({
columns: [{ key: 'name', title: 'Name', editable: true }],
dataSource: new LocalDataSource([{ id: 1, name: 'Ada' }], row => row.id),
});
engine.select(0, 0);
engine.editCell(0, 0, 'Grace');
engine.copySelection(); // 'Grace'; no system clipboard access
engine.undo();
engine.destroy();
The engine owns selection/anchor, sparse row/column layout, text parsing, TSV operations, data commands and delta history. The Canvas grid uses this same engine. Browser focus, scrolling, editor drafts, menus, dialogs, OS clipboard and frame scheduling stay in the browser layer.
select(rowIndex, columnIndex, extend?) rejects invalid coordinates and returns whether selection changed; denied targets return false; clearSelection() clears the range. getSelection() and getSelectionRange() return copies for the active cell/range. addSelection(rowIndex, columnIndex) retains earlier rectangles and starts a new active range. getSelectionRanges() returns copies in insertion order, active range last. Extending changes only that last range; plain select replaces all ranges. Selection is bounded to 128 rectangles, allows overlaps and does not allocate per selected cell. rows and columnsLayout expose read-only size, position, indexAt and range geometry queries; mutations use setRowHeight and setColumnWidth. Columns are frozen snapshots. getValue, canEdit, canPaste, canUndo and canRedo provide queries. editCell(rowIndex, columnIndex, text) applies the same editable/parser rules as the DOM editor; updateCells retains its programmatic, already-validated-value semantics. canPaste checks the starting cell and write capability; paste validates the entire rectangle before writing.
An optional synchronous onInvalidate(change) renderer hook receives cells, selection, layout or structure notifications after committed state/history. These notifications describe repaint needs, separate from public domain events. The hook may query committed state; if it throws, the exception propagates and does not roll back an already committed mutation. destroy() drops the hook and history, clears selection without notification and is idempotent. Mutations/copy/paste throw after destruction; undo/redo return false. As with the browser API, source values are shallow references and external source writes are outside history. Row count changes only through structural commands; external source structure changes require a new mount.
Build/typecheck includes a separate ES2022-only TypeScript configuration with no DOM or ambient Node types. Unit tests also compile the headless dependency closure and verify it excludes browser modules.
DataSource and mutation contracts
DataSource exposes synchronous getRowCount/getRowId/getValue and optional setValue/setValues. Row IDs remain stable through core-owned structure changes. LocalDataSource copies the row array and shallow row snapshots, with unique stable row IDs; nested values remain caller-owned.
updateCells accepts already-validated values and intentionally does not apply column parsers or the editor's editable flag. editCell and paste apply resolved editable/pasteable permissions and text parsers. All writes, including API updates and undo/redo, require writable permission. Batches with more than one changed cell require setValues; a source with only setValue can accept single-cell writes. Batch setters must be synchronous and atomic, leaving data unchanged on failure. Validation completes before writes. Duplicate updates use the last value, Object.is no-ops preserve history, and undo/redo retain at most 100 delta commands with shallow value references. External writes are outside history; replay rejects row identity/current value conflicts. Explicit resize and freeze changes share this history.
Clipboard processing is limited to 100,000 cells and 10 million UTF-16 code units. Async sources are not implemented. React and Vue adapters live in separate packages. Multiple selection ranges support packed TSV and an internal structured clipboard payload.
Browser import migration
This unpublished preview moved createGrid, Grid and GridOptions from core to @acheron-grid/canvas. Install both local packages and change imports:
import { createGrid } from '@acheron-grid/canvas';
import type { Grid, GridOptions } from '@acheron-grid/canvas';
import { LocalDataSource } from '@acheron-grid/core';
import type { Column, CellSelection, SelectionRange } from '@acheron-grid/core';
The createGrid methods and behavior remain the same; see the Canvas guide. Core does not re-export Canvas because that would invert the dependency direction. Existing core/headless consumers continue to work.
Capabilities and domain events
Core and Canvas options accept permissions (grid scope), column permissions, and a pure resolveCellPermission(cell) callback for application row/cell rules. Policies are partial booleans: editable, selectable, copyable, pasteable, writable, formatting. Any explicit false veto survives later scopes. By default selection/copy/write are allowed, while edit/paste follow column editable. Policies may enable edit/paste on a column with no editable opt-in; writable: false always denies both.
getCellPermission(rowIndex, columnIndex) returns a frozen resolved snapshot or throws for invalid coordinates. Setter/parser availability is separate: canEdit checks those too, and canPaste checks only the starting cell and setter availability. Paste checks every destination before any parser or setter. Copy denies the entire TSV if any cell is not copyable. Selection checks only the active endpoint, so a rectangular range may cover non-selectable interior cells. Denied pointer/keyboard targets keep the previous selection without searching for another cell.
Value API updates and value undo/redo check current writable permissions, without applying UI editable or pasteable rules. Denied batches never write or move history. canUndo/canRedo indicate stack availability; replay may still fail permission/conflict checks. Grid/column policies are construction snapshots; callback rules are resolved on every operation/query. Existing selection is not automatically removed on a policy change. Canvas callers can call render() after changing callback policy; an open editor rechecks permissions at commit. These capabilities do not replace server authorization.
Pass synchronous onEvent(event) to either factory. The discriminated GridEvent union contains:
cell:change: sourceapi/edit/paste/undo/redo, one batch of{ rowIndex, rowId, columnKey, previous, value }changes.selection:change: activeselectionand normalizedrange, plus frozenrangesin insertion order. Clear emits null active selection/range and an empty ranges array.column:resize/row:resize:index,previous,size; explicit resize participates in shared history.lock:change: explicittargetandlockedstate; locks stay outside history.freeze:change: previous/current row and column prefix counts; freeze changes participate in history.format:change: sourceapi/undo/redoand sparse target/patch changes; formatting shares value history.
State and history commit before renderer invalidation, then the domain event. Failures before commit and no-ops emit nothing. Both notification hooks are attempted even if one throws; the first error propagates after both, without rolling back committed state. Event envelopes/payload metadata are frozen; values remain shallow caller-owned references. Parser, resolver, setter and notification hooks cannot issue nested engine mutations; queries of committed state are allowed. Destroy silently releases hooks/history. Canvas legacy selection callbacks remain separate and are not duplicated by onEvent.
Frozen panes and viewport geometry
Set construction options frozenRows / frozenColumns to safe integers from zero to the row/column count (default zero). They freeze leading data rows/left columns; the header is separate. Readonly getters expose current counts; engine.setFrozen(rows, columns) changes them atomically after validating both arguments. Resize still updates sparse sizes. Prefixes larger than the viewport are clipped, with no scrolling area on that dimension; they are not reduced automatically.
engine.getViewport({ width, height, scrollLeft, scrollTop }) takes body client dimensions excluding header/scrollbars. All inputs must be finite and nonnegative; offsets clamp to dataset bounds. The readonly ViewportLayout contains normalized offsets, clipped frozen extents and at most four nonempty regions. Each region has clip, end-exclusive rows/columns, and offsetX/offsetY translations for axis positions.
view.hitTest(x, y) maps body-local coordinates to { row, col } or null for blank/outside/nonfinite coordinates. view.cellRect(row, col) returns the full body-local rectangle and its pane clip, throwing for invalid dataset coordinates. Coordinates remain dataset indices; these helpers do not apply permissions. Frozen geometry never reads source values and bounds region work by visible indices, even if a million rows are frozen. Query a fresh viewport after scroll, viewport resize or axis mutations; it holds axis references and is not a durable layout snapshot. Canvas uses these same mappings for painting, pointer input, editor placement and menu targets.
Multiple-range clipboard
copySelection() packs ranges in reading order: ranges sharing their top row and height concatenate horizontally; others stack vertically and pad to the widest range. paste(text) broadcasts the TSV matrix at each target range start. copySelectionBlocks() / pasteSelectionBlocks(text) preserve relative offsets and gaps for one target, or pair equal counts of source/target ranges. Conflicting overlap, malformed payloads, bounds, limits, permissions and parsing failures are rejected before one atomic write/history command. Editing and history operate on the active cell/data, preserving the range list. Clear/destroy discard all ranges. Selectable permission still checks only target endpoints.
Runtime frozen changes
Runtime frozen changes preserve values, selection/ranges and data undo history. No-op/invalid changes emit nothing; changes emit freeze:change with previousRows, previousColumns, rows and columns, after layout invalidation. Canvas setters throw while editing or destroyed. The cell menu freezes prefixes through the clicked row/column, both through the cell, or unfreezes rows/columns/table. Menu freeze actions are disabled if the requested prefix would consume the viewport; the API still allows all-frozen layouts. These actions do not lock editing.
Cell, row, column and table locks
setLocked(target, locked) adds/removes a value lock. Targets are { scope: 'table' }, { scope: 'row', rowIndex }, { scope: 'column', columnIndex }, or { scope: 'cell', rowIndex, columnIndex }, with zero-based coordinates. isLocked(target) reports the explicit lock at that scope. Any applicable lock vetoes writable/editable/pasteable; selection/copy retain their permissions. Unlocking a cell does not clear a row/column/table lock; unlocking the table removes only its table flag.
Locks use sparse sets and preserve selection and data undo history, but are themselves outside history and disappear on destroy/remount. API/edit/paste/undo/redo all enforce current locks. lock:change carries a frozen target and locked, after layout invalidation; no-op/invalid requests emit nothing. Canvas setters throw while editing or destroyed. Right-click provides Lock/Unlock actions and indicates when the cell is read-only.
Host policies retain their false veto after unlock. Set allowLockChanges: false at construction to disable management in the API/menu; canManageLocks() reports availability. Use grid/column/resolver permissions for mandatory application restrictions. These local controls do not replace server authorization. Use the formatting capability for color restrictions; persisted/user-specific locks are not provided here.
Sparse cell formatting
engine.format(targets, patch) atomically applies colors to cell/row/column/table targets (the lock target shapes) or { scope: 'range', range: { startRow, endRow, startColumn, endColumn } }. Range ends are inclusive. patch accepts background / textColor as hex colors (#RGB/#RGBA/#RRGGBB/#RRGGBBAA) or null. A property set to null resets that color to the theme within the target; a null patch removes that target's explicit overlay and may reveal earlier formatting. getFormat(row, col) returns a frozen effective snapshot; canFormat(targets) checks current permission availability.
The last applied property wins across intersecting targets. Updating only background preserves the previous priority of text color. Multiple targets are one atomic command. Colors are sparse overlays, not per-cell arrays; static permissions need only column checks, while a dynamic resolver must validate every targeted cell. Each formatted cell lookup scans O(formatted areas), intended for modest local formatting sets. Formatting reads/writes no source values and does not reorder rows.
formatting defaults to true and has the same false veto as other capabilities, independently of writable and value locks. Formatting and value edits share the 100-command undo/redo history, with current formatting permissions checked on style replay. format:change carries source api/undo/redo and frozen changes containing target/previous/value patches, after layout invalidation. Styles are local and cleared on destroy/remount; persistence, arbitrary CSS, fonts, borders and conditional formatting are not implemented.
Explicit rectangular selection
engine.selectRange({startRow, endRow, startColumn, endColumn}, mode?) accepts 'replace' (default), 'add' or 'extend'. Replace discards existing ranges; add retains the previous active range; extend updates the active rectangle while keeping retained ranges. The 128-range cap applies atomically to add. It updates selection atomically and emits at most one selection event. Bounds are inclusive safe integer coordinates. Start and end cells must both be selectable; interior checks remain operation-specific. The active cell is the start corner, anchor the end corner. This is sparse and reads no source values, even for a whole million-row column. Invalid input or denied endpoints preserve prior selection. Plain navigation returns to its normal single-cell behavior.
Local row views
new LocalDataView(source, {sort: {columnKey, direction: 'asc' | 'desc'}, filters: [{columnKey, query, operator: 'contains' | 'equals' | 'not-empty' | 'empty'}]}) builds an immutable index projection for synchronous local data. All filters combine with AND; contains/equals compare case-insensitive text, and empty means null/undefined/empty string. Empty text criteria do not restrict rows. Numeric pairs sort numerically; other values use a case-insensitive numeric-aware Intl.Collator. Nullish values sort last in either direction; equal values preserve source order.
LocalDataView remains an immutable projection that delegates reads/writes to source indices. For a live view, pass the original source to createGridEngine and use engine.setView(criteria), or initial options.view. The view getter is immutable; rowCount is the visible count, sourceRowCount is the original count, and getRowId(displayIndex) returns stable identity. Edits, paste, undo and redo automatically reapply criteria. Selection, locks, colors and heights remain in source coordinates and follow their records, including hidden rows. Public cell/selection/format/lock/resize APIs use display coordinates; permission resolvers and non-selection domain events use source coordinates. Selection events use display coordinates. Clear criteria before structural commands or structural undo/redo. Freeze remains a positional prefix, clamped for the visible view. A hidden selected record reappears when criteria are cleared; visible fragments are selected without including hidden intervening rows.
This is O(source rows) index memory/filter work and O(matching rows log matching rows) sorting; it does not preserve virtualization while evaluating the full source. Only use it for local datasets sized for that cost. Source row order/count/identities must remain stable; column keys must be supplied by the application. No remote paging, async criteria or multi-column sort is implemented.
License
Licensed under MIT. Copyright (c) 2026 Hao Duong. No npm release is available. Lucide assets belong to the separate Canvas package and are not dependencies of this headless core.
Structural commands and layout history
insertRows(beforeIndex, rows) accepts {id, values} snapshots. deleteRows(indices), moveRows(indices, beforeIndex), insertColumns(beforeIndex, columns), deleteColumns(indices) and moveColumns(indices, beforeIndex) use zero-based coordinates. Move boundaries refer to the current order before removing selected items; relative order is stable, including noncontiguous selections.
Row operations require optional synchronous DataSource.getRow() and atomic spliceRows(splices). Sequential splices must succeed entirely or leave the source unchanged. getRow includes fields outside visible columns. Column insertion/deletion requires addColumns(keys, defaults?), which atomically permits missing fields and initializes own Column.defaultValue values. Existing hidden fields take precedence. Deleted column definitions retain source fields. LocalDataSource applies registered defaults to subsequent inserted/restored rows; insertion/default initialization share one history command. LocalDataSource implements these contracts. LocalDataView intentionally does not: clear sorting/filtering before changing source structure.
The columns and rowCount getters reflect current structure. Sizes, locks and format areas follow surviving row IDs/column keys. Rectangles split into multiple rectangles when necessary instead of selecting or formatting intervening items. Changes exceeding 128 selection ranges fail before writing. Selection on deleted items moves to a remaining selected rectangle or clears. Table/column formatting covers inserted rows; previous row/cell/range formatting excludes them. Frozen prefixes remain positional counts, clamped after deletion.
canChangeStructure(request) is an optional host veto for API, undo and redo. Requests expose axis/kind/count/indices/beforeIndex, the exact target order (-1 means a new item), and target column definitions. Table locks deny structural changes; row deletion additionally requires writable permission and applicable locks. Column deletion checks column locks and host policy; it removes definitions while retaining data fields. Insertion/reorder do not derive permission from editable. Locks remain outside history: current surviving locks are mapped during replay, and restoring deleted items restores their former locks.
Changes emit structure:change with source api/undo/redo, rowCount and columnKeys after a structure invalidation containing old-to-new axis maps. Structural operations, value edits, formatting, explicit resize and freeze share the 100-command stack. Resize/freeze replay events retain their original shapes. measureRowHeight is a renderer operation outside history; isRowHeightManual distinguishes explicit row sizes. Initial columnWidths configures key-based sizes without commands.
Structural history retains row identity arrays, coordinate maps, sparse metadata snapshots and affected shallow rows. This uses O(rows + columns + metadata) memory per command, plus range/format fragmentation. Local sequential splices copy row arrays per contiguous block. This is intended for local datasets, not remote transactions or an unbounded million-row structural history. External identity/count changes or changed rows a replay would replace cause an error and leave history available for retry. Nested values remain caller-owned.
Live local views use O(rows) index memory/filter work and O(matches log matches) sorting per value command. Sparse geometry is rebuilt from resized rows. Large source selections can split into many visible fragments when sorted/filtered. External source writes require setView(engine.view) to refresh membership/order; remote paging and async filtering are not implemented.
Merged cells and manual row groups
engine.mergeCells({ startRow: 1, endRow: 2, startColumn: 0, endColumn: 1 });
engine.unmergeCells({ startRow: 1, endRow: 2, startColumn: 0, endColumn: 1 });
const groupId = engine.groupRows(3, 8);
engine.setGroupCollapsed(groupId, true);
engine.setGroupCollapsed(groupId, false);
engine.ungroupRows(groupId);
Merged cells use the top-left value; other source values remain intact. getMerge(row, col) returns a span in visible coordinates. Numeric viewport geometry and hit testing resolve the anchor, including when it is scrolled outside the viewport. Selection expands to include complete intersecting spans. Copy exports the anchor and blank covered cells. Paste rejects nonempty writes to covered cells; blank clipboard placeholders preserve hidden values. Explicit updateCells still addresses source values through visible coordinates.
Manual row groups can be nested or disjoint. Their first row remains visible when collapsed. getRowGroups() and getMergedCells() return immutable source-coordinate metadata; getRowSourceIndex(visibleRow) maps visible rows. Editing, selection, locks and row sizes retain record identity across collapse/expand. Grouping, ungrouping, collapse/expand and merge/unmerge share undo/redo. Use allowMerging, allowRowGrouping or canChangeLayout(request) to control capabilities. Merge/unmerge require writable cells; row grouping respects table/row locks and the host veto.
Current limits: 1,024 spans/groups each; merge permission checks cover at most 100,000 cells. Sorting/filtering requires removing merges and groups. Collapsing rows that contain merged cells, or crossing a frozen boundary, is rejected. Expand all groups before structural changes. Moving an intact span/group preserves it; operations splitting it are rejected, and deletion of a member dissolves its metadata (undo restores it). Collapse rebuilds an O(row-count) local projection; metadata stays sparse and rendering remains viewport based.
TypeScript contracts from this build
These declaration snapshots complement the explanations above. Imports refer to the package modules; use root exports when integrating.
engine
import type { LocalViewOptions } from './data-source.js';
import type { StructureRequest } from './structure.js';
import type { CellUpdate, DataSource, RowId, DataRow } from './data-source.js';
import type { Column, CellSelection, SelectionRange, CellLockTarget, CellFormatTarget, CellFormat, CellFormatPatch, LayoutRequest } from './types.js';
import type { CellPermission, CellPermissionPolicy, CellPermissionResolver } from './permissions.js';
import type { GridEvent } from './events.js';
import type { ViewportOptions } from './panes.js';
export type GridInvalidation = {
readonly type: 'cells';
readonly cells: readonly {
readonly rowIndex: number;
readonly columnKey: string;
}[];
} | {
readonly type: 'selection';
readonly changed: boolean;
readonly rangeChanged: boolean;
} | {
readonly type: 'layout';
} | {
readonly type: 'structure';
readonly rowMap: readonly number[];
readonly columnMap: readonly number[];
};
export interface GridEngineOptions {
allowMerging?: boolean;
allowRowGrouping?: boolean;
canChangeLayout?: (request: Readonly<LayoutRequest>) => boolean;
columns: readonly Column[];
view?: LocalViewOptions;
canChangeStructure?: (request: Readonly<StructureRequest>) => boolean;
dataSource: DataSource;
permissions?: CellPermissionPolicy;
resolveCellPermission?: CellPermissionResolver;
onEvent?: (event: GridEvent) => void;
rowHeight?: number;
columnWidth?: number;
columnWidths?: Readonly<Record<string, number>>;
allowLockChanges?: boolean;
frozenRows?: number;
frozenColumns?: number;
/** Synchronous renderer notification after state and history have committed. */
onInvalidate?: (change: GridInvalidation) => void;
}
/** Domain state and operations. No browser globals or per-cell state allocation. */
export declare function createGridEngine(options: GridEngineOptions): Readonly<{
setView: (next: LocalViewOptions) => void;
readonly view: Readonly<LocalViewOptions>;
readonly sourceRowCount: number;
getRowId: (row: number) => RowId;
readonly columns: readonly Readonly<{
permissions?: CellPermissionPolicy;
defaultValue?: unknown;
key: string;
title: string;
editable?: boolean;
parse?: (text: string) => unknown;
}>[];
readonly rowCount: number;
readonly frozenRows: number;
readonly frozenColumns: number;
getViewport: (viewport: ViewportOptions) => Readonly<{
hitTest(x: number, y: number): {
row: number;
col: number;
} | null;
cellRect(row: number, col: number): Readonly<{
x: number;
y: number;
width: number;
height: number;
clip: Readonly<{
x: number;
y: number;
width: number;
height: number;
}>;
}>;
width: number;
height: number;
scrollLeft: number;
scrollTop: number;
frozenWidth: number;
frozenHeight: number;
regions: readonly import("./panes.js").ViewportRegion[];
}>;
getMergedCells: () => readonly Readonly<{
startRow: number;
endRow: number;
startColumn: number;
endColumn: number;
}>[];
getMerge: (row: number, col: number) => Readonly<{
startRow: number;
endRow: number;
startColumn: number;
endColumn: number;
}> | null;
canMerge: (range: SelectionRange) => boolean;
mergeCells: (range: SelectionRange) => void;
unmergeCells: (range: SelectionRange) => void;
getRowGroups: () => readonly Readonly<{
id: string;
startRow: number;
endRow: number;
collapsed: boolean;
}>[];
canChangeLayout: (request: LayoutRequest) => boolean;
getRowSourceIndex: (row: number) => number;
groupRows: (start: number, end: number) => string;
ungroupRows: (id: string) => void;
setGroupCollapsed: (id: string, collapsed: boolean) => void;
rows: Readonly<{
size: (i: number) => number;
position: (i: number) => number;
indexAt: (offset: number) => number;
range: (offset: number, extent: number) => {
start: number;
end: number;
};
}>;
columnsLayout: Readonly<{
size: (index: number) => number;
position: (index: number) => number;
indexAt: (offset: number) => number;
range: (offset: number, extent: number) => {
start: number;
end: number;
};
}>;
getValue: (row: number, key: string) => unknown;
insertColumns: (index: number, added: readonly Column[]) => void;
deleteColumns: (indices: readonly number[]) => void;
insertRows: (index: number, rows: readonly DataRow[]) => void;
deleteRows: (indices: readonly number[]) => void;
moveRows: (indices: readonly number[], beforeIndex: number) => void;
moveColumns: (indices: readonly number[], beforeIndex: number) => void;
canChangeStructure: (request: Readonly<StructureRequest>) => boolean;
isRowHeightManual: (index: number) => boolean;
measureRowHeight: (index: number, size: number) => void;
getSelection: () => CellSelection | null;
getSelectionRange: () => SelectionRange | null;
getSelectionRanges: () => SelectionRange[];
getCellPermission: (row: number, col: number) => CellPermission;
canEdit: (row: number, col: number) => boolean;
canPaste: () => boolean;
select: (row: number, col: number, extend?: boolean) => boolean;
selectRange: (range: SelectionRange, mode?: "replace" | "add" | "extend") => boolean;
addSelection: (row: number, col: number) => boolean;
clearSelection: () => void;
editCell: (row: number, col: number, text: string) => void;
updateCells: (updates: readonly CellUpdate[]) => void;
copySelectionBlocks: () => string;
pasteSelectionBlocks: (text: string) => void;
copySelection: () => string;
paste: (text: string) => void;
undo: () => boolean;
redo: () => boolean;
canUndo: () => boolean;
canRedo: () => boolean;
getFormat: (row: number, col: number) => Readonly<CellFormat>;
canFormat: (targets: readonly CellFormatTarget[]) => boolean;
format: (targets: readonly CellFormatTarget[], patch: CellFormatPatch | null) => void;
isLocked: (target: CellLockTarget) => boolean;
canManageLocks: () => boolean;
setLocked: (target: CellLockTarget, locked: boolean) => void;
setFrozen: (rows: number, columns: number) => void;
setColumnWidth: (index: number, size: number) => void;
setRowHeight: (index: number, size: number) => void;
destroy: () => void;
}>;
export type GridEngine = ReturnType<typeof createGridEngine>;
data-source
export type RowId = string | number;
export interface CellUpdate {
rowIndex: number;
columnKey: string;
value: unknown;
}
export interface DataRow {
readonly id: RowId;
readonly values: Readonly<Record<string, unknown>>;
}
export interface RowSplice {
readonly index: number;
readonly deleteCount: number;
readonly rows: readonly DataRow[];
}
export interface DataSource {
getRowCount(): number;
/** Full shallow row snapshot, including fields outside the visible columns. */
getRow?(index: number): DataRow;
/** Initialize missing fields atomically; existing hidden column values are retained. */
addColumns?(keys: readonly string[], defaults?: Readonly<Record<string, unknown>>): void;
/** Apply sequential splices atomically, preserving unique row IDs. */
spliceRows?(splices: readonly RowSplice[]): void;
getRowId(index: number): RowId;
getValue(index: number, columnKey: string): unknown;
setValue?(index: number, columnKey: string, value: unknown): void;
/** Synchronous, atomic: either all writes succeed or none do. */
setValues?(updates: readonly CellUpdate[]): void;
}
/** A shallow snapshot of local rows. Nested values remain caller-owned. */
export declare class LocalDataSource<T extends Record<string, unknown>> implements DataSource {
private rows;
private ids;
private readonly addedColumns;
private columnDefaults;
constructor(rows: readonly T[], getRowId: (row: T, index: number) => RowId);
getRowCount(): number;
getRowId(index: number): RowId;
getValue(index: number, columnKey: string): unknown;
/** Replace one snapshot value; row identity remains fixed at construction. */
setValue(index: number, columnKey: string, value: unknown): void;
setValues(updates: readonly CellUpdate[]): void;
addColumns(keys: readonly string[], defaults?: Readonly<Record<string, unknown>>): void;
getRow(index: number): DataRow;
spliceRows(splices: readonly RowSplice[]): void;
private assertIndex;
}
export interface LocalViewOptions {
readonly sort?: {
readonly columnKey: string;
readonly direction: 'asc' | 'desc';
};
readonly filters?: readonly {
readonly columnKey: string;
readonly query: string;
readonly operator?: 'contains' | 'equals' | 'not-empty' | 'empty';
}[];
}
/** An immutable local row projection. Build a new view to reapply sorting/filtering after edits. */
export declare class LocalDataView implements DataSource {
private readonly source;
private readonly indices;
readonly setValue?: (index: number, key: string, value: unknown) => void;
readonly setValues?: (updates: readonly CellUpdate[]) => void;
constructor(source: DataSource, options?: LocalViewOptions);
private sourceIndex;
getSourceIndex(index: number): number;
getRowCount(): number;
getRowId(index: number): RowId;
getValue(index: number, columnKey: string): unknown;
}
types
import type { RowId } from './data-source.js';
import type { CellPermissionPolicy } from './permissions.js';
export interface Column {
defaultValue?: unknown;
key: string;
title: string;
editable?: boolean;
permissions?: CellPermissionPolicy;
parse?: (text: string) => unknown;
}
export interface CellSelection {
rowIndex: number;
rowId: RowId;
columnIndex: number;
columnKey: string;
}
export interface SelectionRange {
startRow: number;
endRow: number;
startColumn: number;
endColumn: number;
}
export type CellLockTarget = {
readonly scope: 'table';
} | {
readonly scope: 'row';
readonly rowIndex: number;
} | {
readonly scope: 'column';
readonly columnIndex: number;
} | {
readonly scope: 'cell';
readonly rowIndex: number;
readonly columnIndex: number;
};
export type CellFormatTarget = CellLockTarget | {
readonly scope: 'range';
readonly range: Readonly<SelectionRange>;
};
export interface CellFormat {
readonly background?: string;
readonly textColor?: string;
readonly contentFormat?: 'plain' | 'html' | 'markdown';
readonly fontWeight?: 'normal' | 'bold';
readonly fontStyle?: 'normal' | 'italic';
}
export interface CellFormatPatch {
readonly background?: string | null;
readonly textColor?: string | null;
readonly contentFormat?: 'plain' | 'html' | 'markdown' | null;
readonly fontWeight?: 'normal' | 'bold' | null;
readonly fontStyle?: 'normal' | 'italic' | null;
}
export interface RowGroup {
readonly id: string;
readonly startRow: number;
readonly endRow: number;
readonly collapsed: boolean;
}
export type LayoutRequest = {
readonly kind: 'merge' | 'unmerge';
readonly range: Readonly<SelectionRange>;
} | {
readonly kind: 'group' | 'ungroup' | 'collapse' | 'expand';
readonly group: Readonly<RowGroup>;
};
permissions
import type { CellSelection } from './types.js';
export interface CellPermission {
readonly editable: boolean;
readonly selectable: boolean;
readonly copyable: boolean;
readonly pasteable: boolean;
readonly writable: boolean;
readonly formatting: boolean;
}
export type CellPermissionPolicy = Partial<CellPermission>;
export type CellPermissionResolver = (cell: Readonly<CellSelection>) => CellPermissionPolicy | undefined;
/** Explicit denials survive every later scope; defaults are not denials. */
export declare function resolvePermissions(editable: boolean, ...policies: readonly (CellPermissionPolicy | undefined)[]): CellPermission;
events
import type { LocalViewOptions } from './data-source.js';
import type { StructureRequest } from './structure.js';
import type { CellUpdate, RowId } from './data-source.js';
import type { CellSelection, SelectionRange, CellLockTarget, CellFormatTarget, CellFormatPatch } from './types.js';
export type GridChangeSource = 'api' | 'edit' | 'paste' | 'undo' | 'redo';
export type GridEvent = {
readonly type: 'merge:change' | 'group:change';
readonly source: 'api' | 'undo' | 'redo';
} | {
readonly type: 'view:change';
readonly view: Readonly<LocalViewOptions>;
readonly rowCount: number;
readonly sourceRowCount: number;
} | {
readonly type: 'structure:change';
readonly source: 'api' | 'undo' | 'redo';
readonly request: Readonly<StructureRequest>;
readonly rowCount: number;
readonly columnKeys: readonly string[];
} | {
readonly type: 'cell:change';
readonly source: GridChangeSource;
readonly changes: readonly Readonly<CellUpdate & {
rowId: RowId;
previous: unknown;
}>[];
} | {
readonly type: 'selection:change';
readonly selection: Readonly<CellSelection> | null;
readonly range: Readonly<SelectionRange> | null;
readonly ranges: readonly Readonly<SelectionRange>[];
} | {
readonly type: 'format:change';
readonly source: 'api' | 'paste' | 'undo' | 'redo';
readonly changes: readonly {
readonly target: Readonly<CellFormatTarget>;
readonly previous: Readonly<CellFormatPatch> | null;
readonly value: Readonly<CellFormatPatch> | null;
}[];
} | {
readonly type: 'lock:change';
readonly target: Readonly<CellLockTarget>;
readonly locked: boolean;
} | {
readonly type: 'freeze:change';
readonly previousRows: number;
readonly previousColumns: number;
readonly rows: number;
readonly columns: number;
} | {
readonly type: 'column:resize' | 'row:resize';
readonly index: number;
readonly previous: number;
readonly size: number;
};
panes
import type { GridAxis } from './axis.js';
export interface ViewportOptions {
width: number;
height: number;
scrollLeft: number;
scrollTop: number;
}
export interface ViewportRect {
readonly x: number;
readonly y: number;
readonly width: number;
readonly height: number;
}
export interface ViewportRegion {
readonly clip: ViewportRect;
readonly rows: Readonly<{
start: number;
end: number;
}>;
readonly columns: Readonly<{
start: number;
end: number;
}>;
readonly offsetX: number;
readonly offsetY: number;
}
/** Numeric geometry only. Re-query after scrolling or changing axis sizes. */
export declare function createViewport(rows: GridAxis, columns: GridAxis, frozenRows: number, frozenColumns: number, options: ViewportOptions): Readonly<{
width: number;
height: number;
scrollLeft: number;
scrollTop: number;
frozenWidth: number;
frozenHeight: number;
regions: readonly ViewportRegion[];
hitTest(x: number, y: number): {
row: number;
col: number;
} | null;
cellRect(row: number, col: number): Readonly<{
x: number;
y: number;
width: number;
height: number;
clip: Readonly<{
x: number;
y: number;
width: number;
height: number;
}>;
}>;
}>;
export type ViewportLayout = ReturnType<typeof createViewport>;
clipboard
import type { CellFormat } from './types.js';
export interface ClipboardBlock {
readonly row: number;
readonly column: number;
readonly values: readonly (readonly string[])[];
readonly formats?: readonly (readonly CellFormat[])[];
}
export declare const gridClipboardType = "application/x-acheron-grid+json";
export declare function encodeBlocks(blocks: readonly ClipboardBlock[]): string;
export declare function decodeBlocks(text: string): ClipboardBlock[];
export declare function blocksToTsv(blocks: readonly ClipboardBlock[]): string;
structure
import type { Column } from './types.js';
export interface StructureRequest {
readonly columns?: readonly Column[];
readonly order?: readonly number[];
readonly axis: 'row' | 'column';
readonly kind: 'insert' | 'delete' | 'move';
readonly indices: readonly number[];
readonly beforeIndex: number;
readonly count: number;
}
export declare function reorderedIndices(count: number, indices: readonly number[], beforeIndex: number): number[];
Portable layout and view configuration
import { createGridEngine, restoreGridConfiguration } from '@acheron-grid/core';
const saved = JSON.stringify(engine.exportConfiguration());
const restoredOptions = restoreGridConfiguration(
JSON.parse(saved), applicationColumns, dataSource.getRowCount(),
);
const restoredEngine = createGridEngine({
...restoredOptions, dataSource, permissions: applicationPermissions,
});
exportConfiguration() returns a detached version-1 JSON-compatible snapshot of column keys/order/current widths, source frozen-row count, frozen-column count and local sort/filters. restoreGridConfiguration(input, columns, rowCount) validates unknown input and returns initialization options. It requires exactly the current column keys once each; unknown versions, missing/duplicate/unknown keys, invalid sizes, counts or view definitions throw before grid construction. Existing application column definitions (including parsers and permission policies) are reused; configuration cannot supply executable definitions.
Storage is host-owned; the core does not access localStorage or a server. Filter queries may contain sensitive text, so choose storage and access controls accordingly. Apply returned options to a new engine; this API does not change a mounted grid or create undo history. Configuration excludes cell data, row order/heights, default row height, merges/groups, formatting, locks, selection and history. Frozen rows are a count, not stable row identities; restore against the intended source order. Keep rowHeight and other host options explicit. Structural column additions/deletions require the host's matching schema when restoring.
Cut and single-cell paste
A one-cell clipboard value fills every cell in the selected target range(s), including its structured cell formatting. Permissions, parsers and formatting permissions are validated before the combined write. Larger clipboard rectangles retain their existing anchor/paired-range behavior; matrix tiling is not implemented.
engine.select(0, 0);
const cut = engine.cutSelectionBlocks(); // Stages a move; source is unchanged.
engine.select(1, 1);
engine.pasteCutSelectionBlocks(cut); // Destination write and source clear: one undo.
// engine.cancelCut(); // Cancel without changing source data.
Cut is a staged move within the same engine. Source cells must be copyable and writable; paste validates destination permissions/parsers and unchanged source row IDs, column order and values before writing. A failed validation leaves both sides unchanged. Source content is cleared to null; source cell formatting remains, while copied formatting is applied at the destination. Overlapping source/destination cells preserve the pasted result. Undo/redo replays the combined change once. Cut from merged cells is rejected; unmerge first. Use the payload from the staged cut with pasteCutSelectionBlocks; ordinary paste/pasteSelectionBlocks remain copy operations. The host owns clipboard transfer and must not treat arbitrary external clipboard data as a staged move. Cross-engine/browser/application moves and matrix tiling are not supported.