Cells is built on Reactor. Every way
to extend a grid is a Reactor contribution point named datalayer.cells.*,
and the built-in features (the formula functions, the cell types, the fill
rules, the clipboard handlers, CSV and XLSX, the chrome, charts, collaboration)
are plugins contributing through the same points. A third-party plugin is
written exactly the same way, and a grid runs on the core plugin plus any set
of them.
This guide walks through examples/src/examples/plugin/acme.ts,
the @acme/everything plugin of the examples site (the A Reactor Plugin
page, https://cells.datalayer.tech/#/plugin), which contributes to every
point. The tests in src/points/__tests__/everyPoint.test.ts run each
contribution on a grid built from CellsCorePlugin and that plugin alone.
import { contribution, definePlugin, buildReactorFromPlugins } from '@datalayer/reactor';
import { CellsCorePlugin, CellsFunctions, createCellsModel, Spreadsheet } from '@datalayer/cells';
export const MyPlugin = definePlugin({
name: '@acme/my-plugin',
displayName: 'My plugin',
contributes: [contribution(CellsFunctions, { name: 'SHOUT', /* … */ }, { id: 'SHOUT' })],
});
// With the built-ins (functions, cell types, fill, clipboard, …) and your plugin:
const model = createCellsModel({ plugins: [MyPlugin] });
<Spreadsheet plugins={[MyPlugin]} />;
// On a platform you own, with the core plugin and nothing else:
const reactor = buildReactorFromPlugins([CellsCorePlugin, MyPlugin]);
reactor.start();
const bare = createCellsModel({ reactor });A host already running Reactor passes its platform (reactor), with
CellsCorePlugin among its plugins, so a plugin installed in the host extends
every grid. @datalayer/reactor is a peer dependency: host and Cells share
one instance.
Resolution. The model reads each point once into a table keyed by id (a function's name, a command's type, a cell type's id, …), and reads it again only when the platform's revision moves (a plugin enabled or disabled). A key contributed twice is refused at start, with a sentence naming both plugins:
The function 'SHOUT' is contributed twice to datalayer.cells.function: by
@acme/everything (id 'SHOUT') and by @acme/twice (id 'SHOUT'). Disable one of
the two plugins, or rename one function.
A contribution of the wrong shape is refused naming its plugin and id. A
plugin switched off takes its contributions with it: formulas that used its
functions show #NAME?, cells of its types draw as plain values, its layers
stop drawing, a sheet bound to its data source says so in the cells.
The ids below are the datalayer.cells.* points CellsCorePlugin declares
(CELLS_POINTS); the types are exported from @datalayer/cells (and from
@datalayer/cells/core for code that must not reach React).
A command is a typed object the model dispatches; its handler refuses it with
reasons (allow) or turns it into patches (apply). A command never declares
its own undo: undo replays the inverses of the patches it produced.
contribution(CellsCommands, {
type: 'UPPERCASE',
apply: ({ workbook }, c: { type: 'UPPERCASE'; sheet: string; zone: Zone }) => {
const sheet = workbook.sheet(c.sheet);
const contents = [...sheet.cells.entries(c.zone)]
.filter(([, , x]) => typeof x.value === 'string' && x.formula === undefined)
.map(([row, col, x]) => [row, col, { ...x, value: (x.value as string).toUpperCase() }] as const);
return contents.length === 0 ? [] : [cellsPatch(sheet, c.zone, contents)];
},
}, { id: 'UPPERCASE' });
model.dispatch({ type: 'UPPERCASE', sheet: 's1', zone: { top: 1, left: 0, bottom: 1, right: 1 } });cellsPatch builds a core cells patch (a zone with its cells before and
after). A plugin that needs state of its own registers a patch op.
A patch op is how a change is applied and undone: apply(workbook, patch),
invert(patch) (a patch is its own inverse's type) and example(workbook),
which the forcing test uses to check invert(invert(p)) is p for every op
of every plugin. Acme keeps notes on cells beside the workbook:
contribution(CellsPatchOps, {
op: 'acme.note',
apply: (workbook, p) => { /* write p.after into the plugin's own map */ },
invert: (p) => ({ ...p, before: p.after, after: p.before }),
example: () => ({ op: 'acme.note', sheet: 's1', row: 0, col: 0, before: null, after: 'Check this' }),
}, { id: 'acme.note' });A formula function: an upper-case name, typed arguments (scalar, range,
any, lazy), compute(args, context), and metadata for autocomplete and
the functions page. An async function returns a promise: its cell shows
#BUSY! until it settles, then its dependents recalculate.
contribution(CellsFunctions, {
name: 'ECHO',
args: [{ name: 'text', type: 'scalar' }],
returns: 'string',
category: 'text',
async: true,
compute: ([text]) => fetchFromSomewhere(toText(text as CellValue)),
description: 'The text, twice, after a while.',
examples: ['=ECHO("hey") → hey…hey'],
}, { id: 'ECHO' });A cell takes a type through its style: { type: 'megaphone' }, set with
SET_STYLE as any style property (per cell, row or column; persisted, synced
and undone with the style). The type draws the cell (render, in a rectangle
that carries its zoom), says how wide it wants to be (measure, for Fit
columns), what a click (hitTest, activate) or a key (activateOn) does, a
command the grid dispatches or a URL it opens, whether typing edits it
(editable), and what a screen reader hears (accessibleName).
contribution(CellsCellTypes, {
id: 'megaphone',
render: (ctx, rect, value, style, theme) => { /* draw on the canvas */ },
accessibleName: (value) => `Shouted: ${toText(value).toUpperCase()}`,
}, { id: 'megaphone' });The built-in checkbox (Space toggles), link (Enter opens) and progress
types are written the same way, in src/builtins/cellTypes.ts.
A layer drawn after the core layer of its kind, once per pane, clipped to it:
background (after the fills and grid lines), cells (after the text and
borders), overlay (after the selection, on the overlay canvas). draw(ctx, view, model) gets the pane's rows and columns, its zoom and cellRect(row, col); model is the CellsModel drawn.
contribution(CellsRenderLayers, {
id: 'acme.noteMarks',
layer: 'cells',
draw: (ctx, view, model) => { /* a red corner on each noted cell of view.zone */ },
}, { id: 'acme.noteMarks' });One handler per feature: copy(context, sheet, zone) returns its part of the
slice (or undefined), paste(context, sheet, part, target, options, source)
turns the part into patches. The built-ins handle cells, merges, sizes and
charts; Acme carries its notes.
The fill handle tries the rules in order; the first whose match(source)
returns a series wins, and next(series, index) gives the value at each index
past the source (negative indexes fill backwards). Acme extends Roman
numerals: I, II, III → IV, V, …
An importer reads a file (string, Uint8Array or a stream) into a
CellsWorkbook; an exporter writes a model out. Both are found by MIME type
and extension. Acme reads and writes Markdown tables.
A read-only, paged source of columns: an id, a rowCount, columns and
fetch({ from, to }), which resolves to one array per column for those rows.
A sheet binds a source at a cell with BIND_DATA_SOURCE; its rows show from
there, read in pages of 1,024 rows as the grid scrolls over them and as
formulas read them (#BUSY! meanwhile), kept in a bounded cache, #N/A with
the sentence when a page fails (and onDataSourceError told). The bound
cells cannot be edited.
contribution(CellsDataSources, {
id: 'acme.primes',
rowCount: 100_000,
columns: [{ name: 'n', type: 'number' }, { name: 'prime', type: 'number' }],
fetch: ({ from, to }) => server.primes(from, to), // → [ns, primes]
}, { id: 'acme.primes' });
model.dispatch({ type: 'BIND_DATA_SOURCE', sheet: 's1', source: 'acme.primes', at: { row: 1, col: 5 } });How a shared document reaches its peers: connect(doc, room) returns the
provider. Exactly one wins (the first by order; none means collaboration
off; a tie at the winning order is refused). The built-in provider is
lexical-loro's WebSocket transport; Acme's acme.local relays updates
between the documents of one page, with no server.
An entry naming a Reactor command by id, with an optional group. The
command itself goes in the plugin's commands (id, name, octicon,
keybinding, execute({ model })), so the toolbar, the context menu, a
shell's palette and a keybinding all run the same thing.
definePlugin({
name: '@acme/everything',
commands: [{ id: 'acme.uppercase', name: 'UPPERCASE the selection', keybinding: 'Mod+Shift+U',
execute: ({ model }) => { /* dispatch UPPERCASE on the selection */ } }],
contributes: [
contribution(CellsToolbarItems, { command: 'acme.uppercase', group: 'acme' }, { id: 'acme.uppercase' }),
contribution(CellsMenuItems, { command: 'acme.uppercase', group: 'acme' }, { id: 'acme.uppercase' }),
],
});A chart type (ChartType) brings the ECharts modules it needs (modules,
registered by @datalayer/cells/charts when the type is resolved, so the
built-ins never load a radar chart), its option(spec, data, theme) (a pure
function, tested in Node) and describe(spec, data) for a screen reader. A
chart option transform (ChartOptionTransform) rewrites every option after
its type built it, in order: a house style, a watermark. A chart renderer
(ChartRenderer) is the one component drawing charts: @datalayer/cells/charts
contributes ECharts at order: 100; Acme's text renderer at order: 500
draws when no ECharts is loaded.
A gate lets a plugin veto a command before it applies: check({ command, context }) returns true or the sentence of the refusal, which comes back as
a Gate refusal of dispatch.
contribution(CellsEditGate, {
check: ({ command }) =>
command.type === 'SET_CELLS' && command.at.row === 0 ? "Row 1 is Acme's header row: locked." : true,
}, { id: 'acme.headerLocked' });How code cells' outputs are drawn and their sources edited: { id, output, editor? }, two React components typed loosely (CodeOutputRendererProps:
the outputs, the cell's name, the frame's size; CodeEditorRendererProps:
the cell, the model, onClose). The first in order wins.
@datalayer/cells/jupyter contributes jupyter-react's Output and Cell;
with none, the grid shows the outputs' text/plain and a plain textarea.
The kernels code cells run on (§ code cells): { id, label, connect() },
where connect resolves, once, on the first run, to a CellsKernel whose
execute(code) gives back nbformat outputs. Runs go to the first kernel
contributed. @datalayer/cells/jupyter contributes jupyter-react's (the
browser's Pyodide, a Jupyter server, a Datalayer runtime); with none, a run
says No kernel. A kernel that is not Python reads the cell's source and
the values it reads with programParts(code) and answers with a
display_data output under RESULT_MIME:
export const wordCount = {
id: 'acme.words',
label: 'Acme word count',
connect: async () => ({
async execute(code: string) {
const source = programParts(code)?.source ?? '';
const words = source.split(/\s+/).filter((w) => w !== '').length;
return {
outputs: [
{ output_type: 'stream' as const, name: 'stdout', text: source },
{ output_type: 'display_data' as const, data: { [RESULT_MIME]: { result: words } } },
],
};
},
}),
};useContributions(point) lists a point's contributions with their plugins,
usePlugin(name) switches a plugin on and off, useCellsCommands() lists
the Reactor commands with their keybindings and useExecuteCommand() runs
one; useCellType(ref), useDataSource(sheet) and useCharts() read what
the points resolved for a cell, a sheet and a chart. The A Reactor Plugin
page shows them all.