Skip to content

Embed the spreadsheet UI (logisheets-engine)

logisheets-engine is a ready-made, interactive spreadsheet UI. Where the core SDK gives you a headless Workbook to script, the engine gives you a rendered grid your users can click, scroll, select and edit.

It is built on top of logisheets-web and runs the WASM engine plus all rendering inside a Web Worker (via OffscreenCanvas), so heavy work never blocks the UI thread. The grid components are Svelte 5, but you never touch them directly — you drive everything through a single Engine object, which makes it usable from any framework.

bash
npm install logisheets-engine logisheets-web
ts
import {Engine} from 'logisheets-engine'
import 'logisheets-engine/style.css' // required — ships the grid styles

Runnable examples

Want working code to start from? The logisheets-examples repo has minimal, runnable apps for vanilla JS, React, Vue, Svelte and Angular — each embedding the engine together with the logisheets-formula-editor (a formula bar and in-cell editing with autocomplete). Clone it, then cd <framework> && npm install && npm run dev.

The inline snippets below are distilled from those examples and the main LogiSheets app (src/core/engine/, src/components/engine-canvas/ in the LogiSheets repo).

Lifecycle

You construct an Engine once, wait for ready, mount it into a DOM element, and destroy it on teardown.

ts
const engine = new Engine(/* config? */)
engine.on('ready', () => {
    engine.mount(document.getElementById('spreadsheet')!)
})
MethodSignatureDescription
constructornew Engine(config?: Partial<EngineConfig>)Creates the engine and its own Web Worker (you never create a worker yourself). Worker + WASM init asynchronously — wait for the ready event before use.
createSessioncreateSession(): SessionCreate an additional view of the same workbook. See Multiple views.
getDefaultSessiongetDefaultSession(): SessionThe primary Session that backs the legacy per-view methods below. Created lazily.
mountmount(container: HTMLElement, options?: EngineMountOptions): voidMounts the grid UI into container (on the default session). See mount options.
unmountunmount(): voidDetaches the grid UI but keeps the engine/worker alive (you can re-mount).
isMountedisMounted(): booleanWhether the UI is currently mounted.
getMountContainergetMountContainer(): HTMLElement | nullThe element the grid is mounted into, if any.
initOffscreeninitOffscreen(canvas: HTMLCanvasElement): Promise<void>Headless rendering into your own canvas, without mounting the interactive UI. Advanced; only needed for custom render pipelines.
destroydestroy(): voidTears down the worker and all components. Call when you're done with the engine.
isReadyisReady(): booleanWhether the worker + WASM finished initializing.

Constructor config — EngineConfig

All fields are optional (pass a Partial<EngineConfig>); defaults come from DEFAULT_ENGINE_CONFIG.

FieldTypeDescription
leftTopWidthnumberWidth of the left (row-number) header panel, in pixels.
leftTopHeightnumberHeight of the top (column-letter) header panel, in pixels.
showHorizontalGridLinesbooleanDraw horizontal grid lines (default true). Toggle at runtime with setShowGridLines.
showVerticalGridLinesbooleanDraw vertical grid lines (default true). Toggle at runtime with setShowGridLines.
defaultCellWidthnumberDefault column width, in points (pt).
defaultCellHeightnumberDefault row height, in points (pt).
scrollbarSizenumberScrollbar thickness, in pixels.

Mount options

Passed as the second argument to mount(). All optional.

OptionTypeDescription
showSheetTabsbooleanRender the sheet-tab bar at the bottom. Set false if you provide your own tabs.
showScrollbarsbooleanRender scrollbars.
cellLayoutsCellLayout[]Per-cell visual overrides (background, tooltip). See CellLayout.
getIsEditingFormula() => booleanReturn true while the user is editing a formula in your own input, so the canvas doesn't steal focus.
onInvalidFormula() => voidCalled when the user commits an invalid formula.
ts
engine.mount(container, {
    showSheetTabs: false, // app renders its own tabs
    showScrollbars: true,
    cellLayouts,
    getIsEditingFormula: () => isEditingFormulaRef.current(),
})

Events

Subscribe with on(type, cb) and unsubscribe with off(type, cb). The payload type is determined by the event:

EventPayloadFires when
readyvoidWorker + WASM finished initializing. Subscribe before doing anything else.
gridChangeGrid | nullThe visible grid changed (scroll, edit, sheet switch, load) and should be re-rendered.
selectionChangeSelectedDataThe user changed the selection (cell, range, row/col).
activeSheetChangenumberThe active sheet index changed.
sheetChangereadonly SheetInfo[]The list of sheets changed (add/remove/rename).
startEdit{row: number; col: number; initialText: string}The user began editing a cell — open your editor at (row, col) seeded with initialText.
contextMenu{context: ContextMenuContext; x: number; y: number}The user right-clicked a cell or a row/column header. The engine renders no menu — render your own at (x, y) (viewport coords). See Context menu.
cellChangevoidA cell value changed.
invalidFormulavoidAn invalid formula was entered.
errorErrorAn internal error occurred.
ts
engine.on('selectionChange', (data) => setSelection(data))
engine.on('gridChange', (grid) => setGrid(grid))
engine.on('startEdit', ({row, col, initialText}) => openEditor(row, col, initialText))
engine.on('contextMenu', ({context, x, y}) => openMyMenu(context, x, y))

Reading and pushing state

The engine caches the current grid/selection/sheet so you can read them synchronously, and lets you push state back in imperatively.

MethodSignatureDescription
getGridgetGrid(): Grid | nullThe currently rendered grid.
getSelectiongetSelection(): SelectedDataCurrent selection.
setSelectionsetSelection(selection: SelectedData): voidMove the selection programmatically.
getCurrentSheetIndexgetCurrentSheetIndex(): numberActive sheet index.
setCurrentSheetIndexsetCurrentSheetIndex(index: number): voidSwitch the active sheet (refreshes the grid).
getSheetsgetSheets(): readonly SheetInfo[]Cached sheet info.
getConfiggetConfig(): EngineConfigThe resolved configuration.

Context menu

The engine does not render a right-click menu of its own — menus are pure host UI, so you build them with your own framework and styles. When the user right-clicks a cell or a row/column header, the engine emits a contextMenu event with the ContextMenuContext and the cursor position; you render whatever menu you like at (x, y) and act on the result through getDataService().

ts
engine.on('contextMenu', ({context, x, y}) => {
    // context.target is 'cell' | 'row' | 'column'
    // context.selectedData / context.row / context.col describe the target
    myMenu.openAt(x, y, context)
})

A typical handler branches on context.target to build different menus (cell: Format / Clear; row/column header: Insert / Delete / Format) and runs the chosen action as a transaction — e.g. inserting rows:

ts
async function insertRows(context, count) {
    const lines = getSelectedLines(context.selectedData) // {start, end, type}
    const start = lines ? Math.min(lines.start, lines.end) : (context.row ?? 0)
    await engine.getDataService().handleTransaction({
        payloads: [{type: 'insertRows', value: {sheetIdx: engine.getCurrentSheetIndex(), start, count}}],
        undoable: true,
        temp: false,
    })
}

Optional menu component

If you don't want to build a menu from scratch, the engine also ships a small, data-driven Svelte ContextMenu component (see Advanced: custom layouts). It's entirely optional — the engine never renders it for you. The main LogiSheets app renders its own MUI menu from the contextMenu event (src/components/engine-canvas/canvas-context-menu.tsx).

Multiple views (Sessions)

By default you have one view, and the legacy mount / setSelection / render / setCurrentSheetIndex methods on Engine operate on it. Under the hood that view is a Session — and you can create more of them to show the same workbook in several windows at once (a split pane, a "second view", a pop-out). All sessions share the engine's single worker and workbook, so an edit in one is reflected in every other immediately.

The split of responsibility:

  • Engine — everything shared: the worker, the workbook, the sheet-info cache, and the workbook-level events (ready, sheetChange, cellChange). One engine per workbook.
  • Session — everything per view: its mounted UI, active sheet, selection, rendered grid and viewport. Each session renders to its own OffscreenCanvas in the shared worker, so views scroll independently and can even display different sheets.
ts
const engine = new Engine()
engine.on('ready', async () => {
    await engine.loadFile(bytes, 'book.xlsx') // load once, on the engine

    // First view.
    const main = engine.createSession()
    main.mount(document.getElementById('view-main')!)

    // Second view of the SAME workbook — edits sync both ways.
    const second = engine.createSession()
    second.mount(document.getElementById('view-second')!)
    second.setCurrentSheetIndex(1) // independent active sheet

    second.on('selectionChange', (sel) => console.log('view 2', sel))

    // When the second window closes:
    second.destroy() // unmounts and frees its canvas in the worker
})

Backwards compatible

You don't need sessions for a single view. new Engine() + engine.mount(...) keeps working exactly as before — those calls delegate to a lazily-created default (primary) session. Reach for createSession() only when you want more than one view.

Session methods

A Session carries the same per-view surface the legacy Engine methods delegate to:

MethodSignatureDescription
mount / unmount(container, options?) / ()Render / remove this view's UI. Same mount options as the engine.
isMounted / getMountContainer(): boolean / (): HTMLElement | nullMount state.
initOffscreen(canvas: HTMLCanvasElement): Promise<void>Headless rendering without a mounted UI.
render / resize(…) => Promise<Grid | null>Manual render / canvas resize (usually automatic while mounted).
getGrid(): Grid | nullThis view's last rendered grid.
getSelection / setSelection(…)Read / set this view's selection.
getCurrentSheetIndex / setCurrentSheetIndex(…)Read / switch this view's active sheet.
getSheets(): readonly SheetInfo[]Shared sheet list.
getConfig(): EngineConfigResolved configuration.
on / off(type, cb)Per-view events: selectionChange, gridChange, activeSheetChange, startEdit, contextMenu, invalidFormula, error.
destroy(): voidUnmount and release this session from the engine.

Workbook-level events (ready, sheetChange, cellChange) stay on the Engine — subscribe to those once, no matter how many views you open.

Loading, saving and rendering

MethodSignatureDescription
loadFileloadFile(buffer: Uint8Array, filename: string): Promise<Grid | null>Load .xlsx bytes and refresh the mounted grid. Prefer this over DataService.loadWorkbook when the UI is mounted — it also updates the Svelte component's dimensions/anchors.
renderrender(anchorX?: number, anchorY?: number): Promise<Grid | null>Force a render at a scroll anchor. Usually automatic while mounted.
resizeresize(width: number, height: number): Promise<Grid | null>Resize the canvas. Usually automatic while mounted.
ts
const buf = await fetch('workbook.xlsx').then((r) => r.arrayBuffer())
const grid = await engine.loadFile(new Uint8Array(buf), 'workbook.xlsx')

// save
const result = await engine.getWorkbook().save({}) // result.data: Uint8Array

Display settings

Runtime display options that change how the grid is painted. They update the shared config and re-render every mounted view of the workbook.

MethodSignatureDescription
setShowGridLinessetShowGridLines(show: boolean): Promise<void>Show or hide the default cell grid lines across all views. Sets both showHorizontalGridLines and showVerticalGridLines.
ts
// e.g. wired to a "Show gridlines" toggle in your toolbar
await engine.setShowGridLines(false) // hide; pass true to show again

The initial value comes from EngineConfig (showHorizontalGridLines / showVerticalGridLines, both true by default).

Editing data — DataService

engine.getDataService() is the high-level service for edits and rendering. It speaks the same transaction / payload model as the core SDK, but every call is async (the work runs in the worker). Most methods return Promise<T | ErrorMessage> — check with isErrorMessage().

MethodSignatureDescription
handleTransactionhandleTransaction(tx: Transaction, temp?: boolean): Promise<void | ErrorMessage>Apply a transaction of payloads. temp: true previews without committing to history.
handleTransactionAndAdjustRowHeights(tx, onlyIncrease?, fromRowIdx?, toRowIdx?): Promise<void | ErrorMessage>Apply a transaction and auto-fit row heights in the given range.
checkFormulacheckFormula(formula: string): Promise<boolean>Validate a formula's syntax.
undo / redo(): Promise<void | ErrorMessage>Undo / redo the last undoable transaction.
renderrender(sheetId: number, anchorX: number, anchorY: number): Promise<Grid | ErrorMessage>Render a sheet at a scroll anchor.
resizeresize(width: number, height: number, dpr: number): Promise<Grid | ErrorMessage>Resize and re-render. dpr is the device pixel ratio.
loadWorkbookloadWorkbook(buf: Uint8Array, name: string): Promise<Grid | ErrorMessage>Load .xlsx (worker side only — prefer engine.loadFile while mounted).
initOffscreeninitOffscreen(canvas: OffscreenCanvas): Promise<void | ErrorMessage>Bind an offscreen canvas for rendering.
setShowGridLinessetShowGridLines(horizontal: boolean, vertical: boolean): voidShow/hide grid lines (worker-global). Prefer engine.setShowGridLines, which also re-renders.
getCurrentSheetIdx(): numberActive sheet index.
setCurrentSheetIdx(idx: number): voidSet the active sheet.
getCurrentSheetId(): numberStable id of the active sheet.
getCurrentSheetName(): stringName of the active sheet.
getCacheAllSheetInfo(): readonly SheetInfo[]Cached sheet info (sync).
getSheetDimension(sheetIdx: number): Promise<SheetDimension | ErrorMessage>Max row/col and total height/width.
getCellInfo(sheetIdx, row, col): Promise<Cell | ErrorMessage>Full cell (value + style + formula).
getMergedCells(sheetIdx, startRow, startCol, endRow, endCol): Promise<readonly MergeCell[] | ErrorMessage>Merged ranges intersecting the region.
getAvailableBlockId(sheetIdx: number): Promise<number | ErrorMessage>Allocate a fresh block id (for crafts).
getWorkbook(): WorkbookClientThe full workbook client (below).
registerCellUpdatedCallback(f: () => void, callbackId?: number): voidFire on any cell update.
registerSheetUpdatedCallback(f: () => void): voidFire when sheets change.
registerHeaderUpdatedCallback(f: (sheetIdxes: readonly number[]) => void): voidFire when headers of the given sheets change.
ts
import {CellInputBuilder, tx, type Payload} from 'logisheets-engine'

const dataSvc = engine.getDataService()

const payload: Payload = {
    type: 'cellInput',
    value: new CellInputBuilder()
        .sheetIdx(dataSvc.getCurrentSheetIdx())
        .row(row)
        .col(col)
        .content(newText)
        .build(),
}

await dataSvc.handleTransaction(tx([payload], /* undoable */ true))
await dataSvc.checkFormula('=SUM(A1:A10)')

TIP

logisheets-engine re-exports everything from logisheets-web, so payload types, builders and bindings are imported from the same place.

Full workbook API — WorkbookClient

engine.getWorkbook() exposes the complete workbook API — the same surface as logisheets-web, but Promise-based and running in the worker. Use it when DataService doesn't have what you need. Selected methods:

MethodSignatureDescription
loadWorkbook({content: Uint8Array; name: string}): Promise<void>Load .xlsx.
save({appData?: string}): Promise<{data: Uint8Array; ...}>Export to .xlsx; appData persists custom JSON.
getAllSheetInfo(): Promise<readonly SheetInfo[]>All sheets.
getSheetDimension(sheetIdx: number): Promise<SheetDimension>Sheet bounds.
getSheetIdx / getSheetId({sheetId}) / ({sheetIdx})Convert between sheet idx and stable id.
getSheetNameByIdx(idx: number): Promise<string>Sheet name.
getCell({sheetIdx, row, col}): Promise<CellInfo>One cell.
getCells({sheetIdx, ...range}): Promise<...>A cell range.
getCellPosition({sheetIdx, row, col}): Promise<...>Pixel position of a cell.
getCellId({sheetIdx, row, col}): Promise<SheetCellId>Stable id for a cell.
batchGetCellInfoById(...)Resolve many cells by id at once.
getNextVisibleCell(...)Skip hidden rows/cols when navigating.
getMergedCells({sheetIdx, ...range}): Promise<readonly MergeCell[]>Merged ranges.
getBlockInfo({sheetId, blockId}): Promise<BlockInfo>Block metadata.
getFullyCoveredBlocks(...)Blocks fully inside a region.
getAvailableBlockId({sheetIdx}): Promise<number>New block id.
getCellIdByBlockRef(...)Resolve a cell from a block + key + field.
getAllBlockFields(): Promise<readonly BlockField[]>All block fields.
handleTransaction({transaction, temp}): Promise<void>Apply edits.
handleTransactionWithoutEvents(...)Apply edits without firing update callbacks.
undo / redo(): Promise<void>History.
commitTempStatus / cleanTempStatus(): Promise<void>Commit or discard a temp (preview) transaction.
getTempStatusChanges(): Promise<TempStatusDiff>Pending temp changes.
checkFormula({formula}): Promise<boolean>Validate a formula.
getDisplayUnitsOfFormula(f: string): Promise<FormulaDisplayInfo>Tokenized formula for display/highlighting.
getAppData(): Promise<readonly AppData[]>Custom workbook metadata.
getShadowCellId / getShadowInfoById(...)Shadow (off-grid) cells.
registerCellUpdatedCallback(f, callbackId?): voidFire on cell updates.
registerSheetUpdatedCallback(f): voidFire on sheet changes.
registerHeaderUpdatedCallback(f): voidFire on header changes.
registerCellValueChangedCallback(...)Watch a specific cell.
registerCellValueChangedByCellId(...)Watch a specific cell by stable id.

Blocks & crafts — BlockManager

engine.getBlockManager() is the entry point for craft/structured-data state. It holds two sub-managers:

  • fieldManager (FieldManager) — create/query/update/delete the named fields of a block: create, get, getByBlock, getBySheet, getAll, update, setBlockRefName, delete, deleteBlock, deleteSheet, search, count.
  • enumSetManager (EnumSetManager) — manage reusable enum value sets: set, get, getAll, update, addVariant, removeVariant, delete, search, count.

It also serializes craft state alongside the workbook:

  • getPersistentData(): string — serialize fields/enums to embed via save.
  • parseAppData(data: string): void — restore them after loadFile.

This is an advanced area tied to the craft system; reach for it only when building structured data regions.

Supporting types

Grid

What a render returns and what gridChange carries — the visible viewport:

ts
interface Grid {
    anchorX: number
    anchorY: number
    subOffsetX: number // pixels the first visible col is scrolled past the canvas left
    subOffsetY: number // pixels the first visible row is scrolled past the canvas top
    rows: readonly {idx: number; height: number}[]
    columns: readonly {idx: number; width: number}[]
    mergeCells?: readonly MergeCell[]
    blockInfos?: readonly BlockDisplayInfo[]
    preRowHeight?: number
    preColWidth?: number
    nextRowHeight?: number
    nextColWidth?: number
}

SelectedData

ts
interface SelectedData {
    data?:
        | {ty: 'line'; d: {start: number; end: number; type: 'row' | 'col'}}
        | {ty: 'cellRange'; d: {startRow; endRow; startCol; endCol}}
    source: 'editbar' | 'none'
}

Helpers are exported to build/read it: buildSelectedDataFromCell, buildSelectedDataFromCellRange, getSelectedCellRange, getSelectedLines, getSelectedRows, getSelectedColumns.

CellLayout

Per-cell visual override passed via the cellLayouts mount option:

ts
interface CellLayout {
    sheetIdx: number
    row: number
    col: number
    background?: string // CSS color
    tooltip?: string
}

ContextMenuContext

The payload of the contextMenu event describes what was right-clicked:

ts
interface ContextMenuContext {
    selectedData: SelectedData
    target: 'cell' | 'row' | 'column'
    row?: number // index of the clicked row / cell
    col?: number // index of the clicked column / cell
    event: MouseEvent
}

See Context menu for how to use it.

Using with a framework

The engine is framework-agnostic. It renders into a plain DOM element via engine.mount(el), so there is no per-framework component to install and no React/Vue/Angular peer dependency. Your framework only owns the lifecycle — create the engine, mount on attach, tear down on unmount — and everything else is the same Engine API everywhere.

The recipe is always the same:

  1. Construct one Engine (it spins up its own Web Worker — keep it in a module/store/context, don't recreate it on every render).
  2. Wait for the ready event — the worker + WASM initialize asynchronously.
  3. mount(element, options) into a sized container (give it width/height).
  4. Subscribe to events (gridChange, selectionChange, …) and drive edits through getDataService() / getWorkbook().
  5. On teardown: unmount() (the engine stays alive and re-mountable); call destroy() when you're done with it for good (terminates the worker).

A small "mount once ready" helper captures step 2–3 (handles the case where the engine is already ready, since ready won't fire again):

ts
import type {Engine} from 'logisheets-engine'

export function mountWhenReady(engine: Engine, el: HTMLElement) {
    const run = () => engine.mount(el, {showSheetTabs: true, showScrollbars: true})
    engine.isReady() ? run() : engine.on('ready', run)
}

Full runnable versions

The snippets below are intentionally minimal. For complete, runnable apps for each framework — wired up with the formula editor and the required bundler config — see the logisheets-examples repo.

Bundler config

logisheets-engine ships as a pre-built ES module that spawns a Web Worker and loads WASM (both inlined into the bundle — no asset copying needed). Tell your bundler not to re-process it:

  • Vite — exclude it from dependency pre-bundling, or the worker/WASM URLs break:
    ts
    // vite.config.ts
    export default defineConfig({optimizeDeps: {exclude: ['logisheets-engine']}})
  • Angular — the bundle is large; raise the build budgets in angular.json and keep the zone.js polyfill. No worker/WASM config is needed.

React

tsx
import {useEffect, useRef} from 'react'
import {Engine, type Grid} from 'logisheets-engine'
import 'logisheets-engine/style.css'

// Created once at module scope so it survives re-renders. In a real app you'd
// usually hold it in a context/store (the LogiSheets app uses `initEngine()` +
// an `EngineProvider`, see `src/core/engine/`).
const engine = new Engine()

export function Spreadsheet() {
    const ref = useRef<HTMLDivElement>(null)

    useEffect(() => {
        const onReady = () => engine.mount(ref.current!, {showSheetTabs: true})
        engine.isReady() ? onReady() : engine.on('ready', onReady)

        const onGrid = (_g: Grid | null) => {
            /* react to each render, e.g. update overlays */
        }
        engine.on('gridChange', onGrid)

        return () => {
            engine.off('gridChange', onGrid)
            engine.unmount() // keep `engine` alive for the next mount
        }
    }, [])

    return <div ref={ref} style={{width: '100%', height: '100%'}} />
}

Vue 3

vue
<script setup lang="ts">
import {onMounted, onBeforeUnmount, ref} from 'vue'
import {Engine} from 'logisheets-engine'
import 'logisheets-engine/style.css'

const host = ref<HTMLDivElement>()
const engine = new Engine()

onMounted(() => {
    const mount = () => engine.mount(host.value!, {showSheetTabs: true})
    engine.isReady() ? mount() : engine.on('ready', mount)
})
onBeforeUnmount(() => engine.destroy())
</script>

<template>
    <div ref="host" style="width: 100%; height: 100%" />
</template>

Svelte

svelte
<script lang="ts">
import {onMount, onDestroy} from 'svelte'
import {Engine} from 'logisheets-engine'
import 'logisheets-engine/style.css'

let host: HTMLDivElement
const engine = new Engine()

onMount(() => {
    const mount = () => engine.mount(host, {showSheetTabs: true})
    engine.isReady() ? mount() : engine.on('ready', mount)
})
onDestroy(() => engine.destroy())
</script>

<div bind:this={host} style="width: 100%; height: 100%"></div>

Angular

ts
import {AfterViewInit, Component, ElementRef, OnDestroy, ViewChild} from '@angular/core'
import {Engine} from 'logisheets-engine'
import 'logisheets-engine/style.css'

@Component({
    selector: 'app-spreadsheet',
    template: `<div #host style="width:100%;height:100%"></div>`,
})
export class SpreadsheetComponent implements AfterViewInit, OnDestroy {
    @ViewChild('host', {static: true}) host!: ElementRef<HTMLDivElement>
    private engine = new Engine()

    ngAfterViewInit() {
        const mount = () => this.engine.mount(this.host.nativeElement, {showSheetTabs: true})
        this.engine.isReady() ? mount() : this.engine.on('ready', mount)
    }
    ngOnDestroy() {
        this.engine.destroy()
    }
}

Vanilla JS / <script> tag

As an ES module (with a bundler):

ts
import {Engine} from 'logisheets-engine'
import 'logisheets-engine/style.css'

const engine = new Engine()
engine.on('ready', () => engine.mount(document.getElementById('app')!))

Or via the UMD build with no bundler — the whole API hangs off the LogiSheetsEngine global:

html
<link rel="stylesheet" href="logisheets-engine.css" />
<script src="logisheets-engine.umd.js"></script>
<script>
  const engine = new LogiSheetsEngine.Engine()
  engine.on('ready', () => engine.mount(document.getElementById('app')))
</script>

Common gotchas

  • Size the container. The grid fills its host element; a <div> with no height renders nothing.
  • One engine, mount/unmount many times. Don't new Engine() on every render — reuse the instance and destroy() only on final teardown.
  • Strict / double-invoked effects (React 18 dev, etc.): mount() warns if already mounted. Pair every mount with an unmount in the cleanup, as above.

Advanced: custom layouts

For fully custom layouts you can import the raw Svelte components directly — Spreadsheet, ColumnHeaders, RowHeaders, Selector, SheetTabs, Scrollbar — and compose them yourself instead of using mount().

ContextMenu is also exported, but it is an optional menu helper, not part of the rendered grid: feed it items + a position and it draws a styled menu (it supports plain action items and an inline numeric stepper item). Most hosts skip it and render their own menu from the contextMenu event. It takes {visible, x, y, items: ContextMenuItem[], context, onItemClick, onClose}, where each ContextMenuItem is {id, label, icon?, disabled?, separator?, shortcut?, children?, type?: 'action' | 'stepper', min?, max?, value?}.

The adapters module additionally exports React-oriented prop types (SpreadsheetAdapterProps, CanvasAdapterProps) and a convertCanvasPropsToAdapterProps helper for teams writing their own React wrapper; these are type-level conveniences, not a shipped React component.

Formula editor (logisheets-formula-editor)

The engine renders the grid but does not ship a formula bar or an in-cell editor — it only emits startEdit and lets you read/write cells. The companion package logisheets-formula-editor provides both, built on CodeMirror 6 with function autocomplete, signature help and cell-reference highlighting.

bash
npm install logisheets-formula-editor

It is framework-agnostic, with entry points for each use:

ImportWhat it is
logisheets-formula-editorReact <FormulaEditor> component (thin wrapper over /core).
logisheets-formula-editor/corecreateFormulaEditor(el, opts) — the vanilla editor, no framework.
logisheets-formula-editor/enginecreateEngineFormulaSource(dataService) — one-call wiring from the engine.
logisheets-formula-editor/inlinecreateInlineCellEditor(opts) — the in-cell editor controller.

Styles are injected at runtime, so there is no stylesheet to import.

Wiring it to the engine

createEngineFormulaSource turns engine.getDataService() into the tokenizer + function list the editor needs. Spread the result into either editor.

Formula bar (vanilla /core; the React <FormulaEditor> takes the same props):

ts
import {createFormulaEditor} from 'logisheets-formula-editor/core'
import {createEngineFormulaSource} from 'logisheets-formula-editor/engine'

const source = createEngineFormulaSource(engine.getDataService())
const bar = createFormulaEditor(document.getElementById('formula-bar')!, {
    ...source,
    onSubmit: (value) => {
        const sel = engine.getSelection()
        if (sel.data?.ty !== 'cellRange') return
        const {startRow, startCol} = sel.data.d
        // commit — see "Editing data" above
        engine.getWorkbook().handleTransaction({
            transaction: {
                payloads: [{type: 'cellInput', value: {sheetIdx: engine.getCurrentSheetIndex(), row: startRow, col: startCol, content: value}}],
                undoable: true,
                temp: false,
            },
            temp: false,
        })
    },
})
// keep the bar showing the selected cell
engine.on('selectionChange', async (sel) => {
    if (sel.data?.ty !== 'cellRange') return
    const {startRow, startCol} = sel.data.d
    const cell = await engine.getDataService().getCellInfo(engine.getCurrentSheetIndex(), startRow, startCol)
    if (!isErrorMessage(cell)) bar.setValue(cell.getFormula() ? `=${cell.getFormula()}` : cell.getText())
})

In-cell editor — the controller listens for the engine's startEdit, positions an editor over the target cell, commits, and paints reference highlights. Keep it in sync with gridChange:

ts
import {createInlineCellEditor} from 'logisheets-formula-editor/inline'

const inline = createInlineCellEditor({
    container: gridEl, // the same element you mounted the engine into
    eventSource: engine, // the Engine (or a Session) emitting startEdit
    dataService: engine.getDataService(),
    sheetIdx: engine.getCurrentSheetIndex(),
    getSheetName: () => engine.getSheets()[engine.getCurrentSheetIndex()]?.name ?? '',
    inputCell: (sheetIdx, row, col, text) => engine.getWorkbook().handleTransaction({
        transaction: {payloads: [{type: 'cellInput', value: {sheetIdx, row, col, content: text}}], undoable: true, temp: false},
        temp: false,
    }),
    setSelection: (sel) => engine.setSelection(sel),
    grid: engine.getGrid(),
})
engine.on('gridChange', (g) => inline.setGrid(g))

For full, runnable wiring in every framework, see the logisheets-examples repo.