UNPKG

@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
"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