@eristack/spreadsheet-operator
Version:
Headless spreadsheet keyboard operator — active-grid scope, cell navigation, Excel-like Enter/Tab editing
482 lines (475 loc) • 15.7 kB
JavaScript
"use strict";
var __defProp = Object.defineProperty;
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
var __getOwnPropNames = Object.getOwnPropertyNames;
var __hasOwnProp = Object.prototype.hasOwnProperty;
var __export = (target, all) => {
for (var name in all)
__defProp(target, name, { get: all[name], enumerable: true });
};
var __copyProps = (to, from, except, desc) => {
if (from && typeof from === "object" || typeof from === "function") {
for (let key of __getOwnPropNames(from))
if (!__hasOwnProp.call(to, key) && key !== except)
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
}
return to;
};
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
// src/index.ts
var index_exports = {};
__export(index_exports, {
DEFAULT_EDITABLE_KINDS: () => DEFAULT_EDITABLE_KINDS,
DEFAULT_NAVIGABLE_KINDS: () => DEFAULT_NAVIGABLE_KINDS,
addressKey: () => addressKey,
cellKey: () => cellKey,
clampAddress: () => clampAddress,
compareRaster: () => compareRaster,
createSpreadsheetOperator: () => createSpreadsheetOperator,
getNextEditableAddress: () => getNextEditableAddress,
inBounds: () => inBounds,
isEditable: () => isEditable,
isKind: () => isKind,
isNavigable: () => isNavigable,
keyEventToAction: () => keyEventToAction,
kindAt: () => kindAt,
listNavigableAddresses: () => listNavigableAddresses,
moveActiveAddress: () => moveActiveAddress,
reduceOperator: () => reduceOperator,
resolveConfig: () => resolveConfig,
sameAddress: () => sameAddress,
tabActiveAddress: () => tabActiveAddress
});
module.exports = __toCommonJS(index_exports);
// src/core/types.ts
var DEFAULT_NAVIGABLE_KINDS = ["editable", "select"];
var DEFAULT_EDITABLE_KINDS = ["editable", "select"];
// src/core/address.ts
function sameAddress(a, b) {
return a.row === b.row && a.col === b.col;
}
function addressKey(address) {
return `${address.row}:${address.col}`;
}
function cellKey(gridId, address) {
return `${gridId}:${address.row}:${address.col}`;
}
function inBounds(address, grid) {
return address.row >= 0 && address.col >= 0 && address.row < grid.rowCount && address.col < grid.colCount;
}
function clampAddress(address, grid) {
if (grid.rowCount < 1 || grid.colCount < 1) {
return { row: 0, col: 0 };
}
return {
row: Math.min(Math.max(0, address.row), grid.rowCount - 1),
col: Math.min(Math.max(0, address.col), grid.colCount - 1)
};
}
function compareRaster(a, b) {
if (a.row !== b.row) return a.row - b.row;
return a.col - b.col;
}
// src/core/navigation.ts
function resolveConfig(config = {}) {
return {
wrap: config.wrap ?? "wrap",
arrowWrap: config.arrowWrap ?? "stop",
enterMove: config.enterMove ?? "down",
typeToEdit: config.typeToEdit ?? true,
navigableKinds: config.navigableKinds ?? DEFAULT_NAVIGABLE_KINDS,
editableKinds: config.editableKinds ?? DEFAULT_EDITABLE_KINDS,
arrowInEdit: config.arrowInEdit ?? "caret"
};
}
function kindAt(grid, address) {
if (!inBounds(address, grid)) return void 0;
return grid.cellAt(address).kind;
}
function isKind(kind, allowed) {
return kind !== void 0 && allowed.includes(kind);
}
function isNavigable(grid, address, config) {
return isKind(kindAt(grid, address), config.navigableKinds);
}
function isEditable(grid, address, config) {
return isKind(kindAt(grid, address), config.editableKinds);
}
function listNavigableAddresses(grid, config) {
const out = [];
for (let row = 0; row < grid.rowCount; row += 1) {
for (let col = 0; col < grid.colCount; col += 1) {
const address = { row, col };
if (isNavigable(grid, address, config)) out.push(address);
}
}
return out;
}
function step(address, direction) {
switch (direction) {
case "up":
return { row: address.row - 1, col: address.col };
case "down":
return { row: address.row + 1, col: address.col };
case "left":
return { row: address.row, col: address.col - 1 };
case "right":
return { row: address.row, col: address.col + 1 };
}
}
function wrapArrowStart(address, direction, grid) {
switch (direction) {
case "up":
return { row: grid.rowCount - 1, col: address.col };
case "down":
return { row: 0, col: address.col };
case "left":
return { row: address.row, col: grid.colCount - 1 };
case "right":
return { row: address.row, col: 0 };
}
}
function moveActiveAddress(grid, from, direction, config) {
if (grid.rowCount < 1 || grid.colCount < 1) return from;
const origin = clampAddress(from, grid);
let cursor = origin;
const limit = Math.max(grid.rowCount, grid.colCount) + 1;
for (let i = 0; i < limit; i += 1) {
const next = step(cursor, direction);
if (!inBounds(next, grid)) {
if (config.arrowWrap === "stop") return origin;
cursor = wrapArrowStart(origin, direction, grid);
} else {
cursor = next;
}
if (sameAddress(cursor, origin)) return origin;
if (isNavigable(grid, cursor, config)) return cursor;
}
return origin;
}
function nextAfterRaster(list, from, direction) {
if (direction === "next") {
return list.find((cell) => compareRaster(cell, from) > 0);
}
for (let i = list.length - 1; i >= 0; i -= 1) {
const cell = list[i];
if (cell && compareRaster(cell, from) < 0) return cell;
}
return void 0;
}
function tabActiveAddress(grid, from, direction, config) {
const list = listNavigableAddresses(grid, config);
if (list.length === 0) return from;
const idx = list.findIndex((cell) => sameAddress(cell, from));
if (idx === -1) {
const found = nextAfterRaster(list, from, direction);
if (found) return found;
if (config.wrap === "wrap") {
return direction === "next" ? list[0] ?? from : list[list.length - 1] ?? from;
}
return from;
}
const delta = direction === "next" ? 1 : -1;
const nextIdx = idx + delta;
if (nextIdx >= 0 && nextIdx < list.length) {
return list[nextIdx] ?? from;
}
if (config.wrap === "wrap") {
return direction === "next" ? list[0] ?? from : list[list.length - 1] ?? from;
}
return from;
}
function getNextEditableAddress(grid, from, direction, config = {}) {
const resolved = resolveConfig(config);
const origin = clampAddress(from, grid);
if (direction === "next" || direction === "prev") {
const next2 = tabActiveAddress(grid, origin, direction, resolved);
return sameAddress(next2, origin) && !isNavigable(grid, origin, resolved) ? null : next2;
}
const next = moveActiveAddress(grid, origin, direction, resolved);
if (sameAddress(next, origin) && !isNavigable(grid, origin, resolved)) {
return isNavigable(grid, next, resolved) ? next : null;
}
return next;
}
// src/core/keymap.ts
var ARROWS = {
ArrowUp: "up",
ArrowDown: "down",
ArrowLeft: "left",
ArrowRight: "right"
};
function isPrintable(key) {
return key.length === 1;
}
function keyEventToAction(state, event, config) {
if (event.isComposing) return void 0;
if (event.ctrlKey || event.metaKey || event.altKey) return void 0;
const arrow = ARROWS[event.key];
if (arrow) {
if (state.mode === "inactive") return void 0;
if (state.mode === "editing" && config.arrowInEdit === "caret") return void 0;
return { type: "move", direction: arrow };
}
if (event.key === "Tab") {
if (state.mode === "inactive") return void 0;
return { type: "tab", shift: event.shiftKey === true };
}
if (event.key === "Enter") {
if (state.mode === "inactive") return void 0;
return { type: "enter" };
}
if (event.key === "Escape") {
if (state.mode === "inactive") return void 0;
return { type: "escape" };
}
if (event.key === "F2") {
if (state.mode !== "active") return void 0;
return { type: "f2" };
}
if (state.mode === "active" && config.typeToEdit && isPrintable(event.key)) {
return { type: "type", char: event.key };
}
return void 0;
}
// src/core/reduce.ts
function fieldKeyAt(grid, address) {
const cell = grid.cellAt(address);
return cell.fieldKey ?? cell.id;
}
function commitEffect(gridId, address, ctx) {
const grid = ctx.grids.get(gridId);
return {
type: "commit",
gridId,
address,
fieldKey: grid ? fieldKeyAt(grid, address) : void 0,
phase: "commit"
};
}
function cancelEffect(gridId, address) {
return { type: "cancel", gridId, address, phase: "cancel" };
}
function startEditEffect(gridId, address, seed) {
return { type: "startEdit", gridId, address, seed };
}
function gridOf(ctx, gridId) {
return ctx.grids.get(gridId);
}
function editingOf(state) {
return state.mode === "editing" ? state : void 0;
}
function positionedOf(state) {
return state.mode === "inactive" ? void 0 : state;
}
function commitIfEditing(state, ctx) {
const editing = editingOf(state);
if (!editing) return [];
return [commitEffect(editing.gridId, editing.address, ctx)];
}
function enterMoveAddress(grid, from, ctx) {
if (ctx.config.enterMove === "none") return from;
const direction = ctx.config.enterMove === "right" ? "right" : "down";
return moveActiveAddress(grid, from, direction, ctx.config);
}
function reduceOperator(state, action, ctx) {
switch (action.type) {
case "activate": {
const grid = gridOf(ctx, action.gridId);
if (!grid || grid.rowCount < 1 || grid.colCount < 1) {
return { state, effects: [] };
}
const address = clampAddress(action.address, grid);
const effects = commitIfEditing(state, ctx);
const next = {
mode: "active",
gridId: action.gridId,
address
};
if (state.mode === "active" && state.gridId === next.gridId && sameAddress(state.address, next.address) && effects.length === 0) {
return { state, effects: [] };
}
return { state: next, effects };
}
case "deactivate": {
if (state.mode === "inactive") return { state, effects: [] };
return { state: { mode: "inactive" }, effects: commitIfEditing(state, ctx) };
}
case "move": {
const pos = positionedOf(state);
if (!pos) return { state, effects: [] };
if (state.mode === "editing" && ctx.config.arrowInEdit === "caret") {
return { state, effects: [] };
}
const grid = gridOf(ctx, pos.gridId);
if (!grid) return { state, effects: [] };
const effects = commitIfEditing(state, ctx);
const address = moveActiveAddress(grid, pos.address, action.direction, ctx.config);
return {
state: { mode: "active", gridId: pos.gridId, address },
effects
};
}
case "tab": {
const pos = positionedOf(state);
if (!pos) return { state, effects: [] };
const grid = gridOf(ctx, pos.gridId);
if (!grid) return { state, effects: [] };
const effects = commitIfEditing(state, ctx);
const address = tabActiveAddress(
grid,
pos.address,
action.shift ? "prev" : "next",
ctx.config
);
return {
state: { mode: "active", gridId: pos.gridId, address },
effects
};
}
case "enter": {
if (state.mode === "inactive") return { state, effects: [] };
const grid = gridOf(ctx, state.gridId);
if (!grid) return { state, effects: [] };
if (state.mode === "active") {
if (!isEditable(grid, state.address, ctx.config)) {
return { state, effects: [] };
}
return {
state: { mode: "editing", gridId: state.gridId, address: state.address },
effects: [startEditEffect(state.gridId, state.address)]
};
}
const address = enterMoveAddress(grid, state.address, ctx);
return {
state: { mode: "active", gridId: state.gridId, address },
effects: [commitEffect(state.gridId, state.address, ctx)]
};
}
case "escape": {
if (state.mode === "inactive") return { state, effects: [] };
if (state.mode === "editing") {
return {
state: { mode: "active", gridId: state.gridId, address: state.address },
effects: [cancelEffect(state.gridId, state.address)]
};
}
return { state: { mode: "inactive" }, effects: [] };
}
case "f2": {
if (state.mode !== "active") return { state, effects: [] };
const grid = gridOf(ctx, state.gridId);
if (!grid || !isEditable(grid, state.address, ctx.config)) {
return { state, effects: [] };
}
return {
state: { mode: "editing", gridId: state.gridId, address: state.address },
effects: [startEditEffect(state.gridId, state.address)]
};
}
case "type": {
if (!ctx.config.typeToEdit) return { state, effects: [] };
if (state.mode !== "active") return { state, effects: [] };
const grid = gridOf(ctx, state.gridId);
if (!grid || !isEditable(grid, state.address, ctx.config)) {
return { state, effects: [] };
}
return {
state: {
mode: "editing",
gridId: state.gridId,
address: state.address,
seed: action.char
},
effects: [startEditEffect(state.gridId, state.address, action.char)]
};
}
case "commit": {
if (state.mode !== "editing") return { state, effects: [] };
return {
state: { mode: "active", gridId: state.gridId, address: state.address },
effects: [commitEffect(state.gridId, state.address, ctx)]
};
}
case "cancel": {
if (state.mode !== "editing") return { state, effects: [] };
return {
state: { mode: "active", gridId: state.gridId, address: state.address },
effects: [cancelEffect(state.gridId, state.address)]
};
}
}
}
// src/core/create-operator.ts
function createSpreadsheetOperator(config = {}) {
const resolved = resolveConfig(config);
let state = { mode: "inactive" };
const grids = /* @__PURE__ */ new Map();
const listeners = /* @__PURE__ */ new Set();
function notify(effects) {
for (const listener of listeners) listener(state, effects);
}
function dispatch(action) {
const result = reduceOperator(state, action, { grids, config: resolved });
if (result.state === state && result.effects.length === 0) {
return result;
}
state = result.state;
notify(result.effects);
return result;
}
return {
getState() {
return state;
},
dispatch,
handleKeyDown(event) {
const action = keyEventToAction(state, event, resolved);
if (!action) return false;
const before = state;
const result = dispatch(action);
return result.state !== before || result.effects.length > 0;
},
registerGrid(descriptor) {
grids.set(descriptor.id, descriptor);
},
unregisterGrid(gridId) {
const active = state.mode !== "inactive" && state.gridId === gridId;
if (active) dispatch({ type: "deactivate" });
grids.delete(gridId);
},
getGrid(gridId) {
return grids.get(gridId);
},
subscribe(listener) {
listeners.add(listener);
return () => {
listeners.delete(listener);
};
}
};
}
// Annotate the CommonJS export names for ESM import in node:
0 && (module.exports = {
DEFAULT_EDITABLE_KINDS,
DEFAULT_NAVIGABLE_KINDS,
addressKey,
cellKey,
clampAddress,
compareRaster,
createSpreadsheetOperator,
getNextEditableAddress,
inBounds,
isEditable,
isKind,
isNavigable,
keyEventToAction,
kindAt,
listNavigableAddresses,
moveActiveAddress,
reduceOperator,
resolveConfig,
sameAddress,
tabActiveAddress
});
//# sourceMappingURL=index.cjs.map