Writing a plugin
A plugin is a plain object with an id, the version of the contract it was written for, and a setup function. setup gets a context: the editor’s handle, and places to add commands, file sources, dialogs and panels.
This page builds one plugin as it goes, Recent files, which lists the workbooks opened lately so they open again in a click. It uses most of the contract:
- a file source, “Recent files”, whose files are a submenu of the Open menu;
- a panel listing the files, each with a button to forget it;
- a command in More that clears the list, enabled only while there is one;
- storage for the list, events to hear what was opened, and the editor’s language.
The whole plugin is at the end of the page. It is in the repository, with its tests, under examples/recent-files.
The contract
Section titled “The contract”Plugins are written against @ruled/sheet/plugin. It holds the contract’s types, so a plugin imports only types from it and never brings a second copy of the editor:
import type { PluginContext, RuledPlugin } from '@ruled/sheet/plugin';
export function recentFiles(options: RecentFilesOptions = {}): RuledPlugin { return { id: 'com.example.recent-files', apiVersion: 1, translations, setup: (ctx) => setup(ctx, options), };}| Field | What it is |
|---|---|
id |
Unique, reverse-DNS style. Commands, sources, panels, messages and storage are kept under it. |
apiVersion |
1, the contract this page describes. An editor that doesn’t know a plugin’s version doesn’t start it, and says why. |
translations |
The plugin’s messages, by language. See Language. |
setup(ctx) |
Called once, a moment after the editor is made (the code that runs plugins loads first). It may return a function to clean up, called when the editor is destroyed, or a promise of one. See When setup runs. |
A plugin is usually a function that takes options and returns the object, as recentFiles() and oneDrive() do. definePlugin(plugin), the one thing the module exports at run time, returns its argument unchanged and only helps TypeScript infer it; satisfies RuledPlugin does the same with no import.
To publish one, give it its own package, with @ruled/sheet as a peer dependency.
The context
Section titled “The context”| Member | What it is |
|---|---|
editor |
The same handle your app has: open, save, getWorkbook, goTo, status, fileName, origin, subscribe… |
signal |
Aborted when the editor is destroyed, or the plugin is stopped after failing. Pass it to fetch and addEventListener. |
on(listener) |
What the editor does, as onEvent hears it: opened, saved, savedToSource… Returns a function that stops listening. |
commands |
register(command) and refresh(). See Commands. |
files |
registerSource, open, fetch and current. See File sources. |
ui |
toast, confirm, dialog and registerPanel. See Dialogs and panels. |
text(text) |
A plugin’s text in the editor’s language as it is now, for DOM the plugin draws. |
limits |
{ maxFileBytes }: the largest file the editor opens, so a source can refuse one before downloading it. |
storage |
Small values kept in this browser. See Storage. |
register, registerSource and registerPanel each return a function that removes what they added. Everything a plugin added goes when the editor is destroyed.
Commands
Section titled “Commands”A command is an action with a label, put on the toolbar, in More, or in the cell menu:
ctx.commands.register({ id: 'clear', label: msg('clear'), where: 'more', enabled: () => list.length > 0, async run() { if (await ctx.ui.confirm({ title: msg('clear'), body: msg('clearBody'), confirmLabel: msg('clearConfirm') })) await changed([]); },});| Field | What it is |
|---|---|
id |
Unique within the plugin. |
label |
Its text: see Language. |
icon |
SVG path data on a 24×24 grid, drawn with the text colour, as the icons option takes. |
where |
'toolbar', in the toolbar’s row; 'more', with the editor’s less used controls, in the row on wide screens and behind More on phones; or 'cellMenu'. |
run(context) |
What it does. It runs inside the click, so a popup it opens straight away isn’t blocked. |
enabled(context), visible(context) |
Read again whenever the editor’s state changes. |
preload() |
Called when the menu holding the command opens, or the pointer rests on it: load an SDK here. |
State of the plugin’s own. enabled and visible are read when the editor’s state changes. When they depend on something the plugin keeps, such as Recent files’ list, call ctx.commands.refresh() after it changes.
Popups. Browsers allow a popup only during a click. A run that first awaits import('some-sdk') has lost the click by the time it opens one. Load the SDK in preload, or at idle time after setup, and open the popup before any other await in run.
The cell menu. A cellMenu command appears after the editor’s own entries: on right-click, on the menu key or Shift+F10, and in the phone’s cell menu. Its run, enabled and visible get the selection: the sheet’s name, the active cell and the selected ranges, in A1 style.
ctx.commands.register({ id: 'sum', label: 'Show the total', where: 'cellMenu', // Only for one range of more than one cell. visible: ({ selection }) => selection?.ranges.length === 1 && selection.ranges[0].includes(':'), async run({ selection }) { const workbook = await ctx.editor.getWorkbook(); // …work it out from workbook.bytes, then: ctx.ui.toast('info', `Total of ${selection!.ranges[0]} on ${selection!.sheet}: …`); },});File sources
Section titled “File sources”A file source is somewhere workbooks are opened from, and may be saved to: a cloud drive, your app’s own file store, or Recent files. With one registered, the editor’s Open button becomes a menu: This device, then each source.
A source offers files in one of two ways, or both. pick lets the person choose, in a picker of its own: OneDrive’s opens Microsoft’s. items names files to choose straight from the menu: the source’s entry becomes a submenu of them, followed by Browse… for pick when it has one. Recent files has only items:
ctx.files.registerSource({ id: 'recent', label: msg('title'), icon: ICON, // A submenu of Open; choosing one fetches it through the source its origin names. items: () => list.map((f) => shown(f.origin)), // The files from this device, from the bytes kept. async fetch(origin) { const entry = list.find((f) => f.id === origin.ref); if (!entry?.bytes) { ctx.ui.toast('error', msg('gone', { name: origin.name })); return null; } return { bytes: fromBase64(entry.bytes), origin: shown(entry.origin) }; },});| Field | What it is |
|---|---|
id |
Unique among every plugin’s sources. A workbook’s origin.source names it. |
label, icon |
Shown in the Open menu, as Save to label and in Save a copy. |
preload() |
Called when a menu holding the source opens: load an SDK here, so pick can open a popup inside the click. |
items() |
Optional. Files to offer by name, as origins: read each time the Open menu opens, and shown as a submenu, each with its location on hover. Choosing one fetches it with the fetch of the source its origin names, inside the click. |
pick() |
Optional. Lets the person choose a file, and fetches it: a RemoteFile, or null when they cancel. It is called inside the click. A source needs items or pick. |
save(file, origin) |
Optional. Writes the workbook back over the file it came from. See Saving. |
saveAs(file) |
Optional. Writes the workbook as a new file. |
fetch(origin) |
Optional. Fetches the file an origin names again, as it is now, for opening it without pick: from items, or from another plugin, as Recent files opens OneDrive’s files. |
A RemoteFile is the file’s bytes and its origin: where it came from. The editor keeps the origin while the workbook is open, hands it back to save, shows its name and location, and never writes it into the file.
A RemoteFile may also carry a notice, which the editor shows in its banner as the file opens, until the person closes it or opens another file: what they should know before editing, such as that the file was converted to open here. It is { text, items?, kind? }: text and each of items (listed under it) are PluginText, and kind: 'warn' marks something lost or wrong, 'info' otherwise. The Google Drive connector uses one to say a Google Sheet opened as an Excel copy.
FileOrigin field |
What it is |
|---|---|
source |
The source’s id. |
name |
The file’s name, shown as the workbook’s. |
ref |
The source’s own identity for the file, such as a drive and an item id. Any JSON. |
version |
Its version as opened, such as an ETag, so save can tell when someone else saved it since. |
writable |
false when saving back isn’t possible or wise. Only Save a copy is offered then. |
location |
Where the file is, such as Shared/Finance/prices.xlsx, shown when the pointer rests on its name. |
Files found some other way
Section titled “Files found some other way”Three more functions are for plugins that find files without a picker:
files.open(file)opens aRemoteFile, as if a source had picked it. Unsaved changes are asked about first, and it resolves whether the file opened.files.fetch(origin)asks the source an origin names, which may be another plugin’s, to fetch the file again. It resolvesnullwhen the person cancels, or when no source here can: the editor then says why.files.current()is the workbook open now, as it was opened: itsname, itsorigin, itssize,bytes()(a copy, made when asked for), andfrom, which says whether it came from this device (the Open button, or a dropped file), your app’s code, or a source.
Recent files uses all three. When the editor says it opened a file, the plugin reads current() and keeps its origin. A small file from this device has no origin, so the plugin makes one of its own and keeps the bytes too, since a browser can’t open a local file again by itself. Files the host opened aren’t listed: reopening them is the host’s business.
ctx.on((event) => { if (event.type === 'opened') void remember();});
async function remember() { const file = ctx.files.current(); if (!file || file.from === 'host') return; // …a source's file as its origin; one from this device with an origin of the plugin's and file.bytes(), if it's small}Every file in the list is then an origin, so opening one again is the same for all of them: from the submenu, the editor fetches it; from the panel, the plugin does. The source the origin names fetches it: OneDrive for its files, signing in when it needs to, and Recent files itself for files from this device. Both run inside the click, so a sign-in popup isn’t blocked:
async function reopen(entry: RecentFile) { const file = await ctx.files.fetch(entry.origin); if (file) await ctx.files.open(file);}The OneDrive file comes back with its current version as its origin, so Save to OneDrive works as if it had been picked. A file from this device comes back with Recent files’ own origin, which has no save, so saving downloads it as it did the first time.
Saving
Section titled “Saving”The editor has three ways to save, each with its own meaning and button:
| Action | What it does | Shown when |
|---|---|---|
| Save, the host’s | Runs the schema’s checks, then the host’s onSave. |
The host gives onSave. |
| Save to source | Calls the source’s save with the workbook and the origin it was opened with. |
The workbook came from a source with save, its origin isn’t writable: false, and the host hasn’t set features.sourceSave: false. |
| Save a copy | A menu: This device (a download), and each source with saveAs. |
Downloads are on, or a source has saveAs. |
The primary button, and Ctrl+S (⌘S), is the host’s Save when there is one, then Save to source for a writable origin, then Save a copy to the origin’s source when it can’t be written back, and otherwise a download. editor.save() runs the primary one.
A source’s part:
save(file, origin)writesfile.bytesover the fileoriginnames, and resolves with its origin as now saved: a newversion, and a newnameif it changed. The editor keeps that origin, so the next save is checked against the version just written.- When the file changed since it was opened, throw an error whose
nameis'SaveConflictError'(SaveConflictErrorfrom@ruled/sheetis one, but you needn’t import it). The editor keeps the changes and offers to download them, as for a conflict fromonSave. With an empty message, the editor explains it in its own words. saveAs(file)lets the person choose where, writes a new file, and resolves with its origin, ornullwhen they cancel. The workbook stays the one it came from; the copy isn’t opened in its place.- Schema errors don’t stop a save to a source: the editor asks first, and the person may carry on.
- Any other error keeps the changes, and the editor says the save failed, with the error’s text.
- Popups.
saveandsaveAsare called once the editor has written the workbook, which takes a moment. A popup opened then relies on the browser still counting the click as recent: about five seconds in Chrome and Firefox, less in Safari. When one is blocked, ask withui.confirmand open it on that click, as the OneDrive connector does.
file is a SavedWorkbook: the .xlsx bytes, the file name, the schema’s values, and its origin.
Dialogs and panels
Section titled “Dialogs and panels”Plugins draw their own UI in plain DOM, in the editor’s frames. That keeps them free of any framework; a React plugin can createRoot into the element it’s given.
| Function | What it shows |
|---|---|
ui.toast(kind, text) |
A short message, 'info' or 'error'. |
ui.confirm({ title, body, confirmLabel }) |
A question with a confirm and a cancel button. Resolves true when confirmed. |
ui.dialog({ title, render, actions }) |
A dialog in the editor’s frame, with its title, buttons and focus handling. Resolves with the id of the action chosen, or null when it’s closed. |
ui.registerPanel({ id, title, icon, render }) |
A side panel beside Issues and Changes, with a button that shows and hides it. |
render(body, close) fills body with the plugin’s DOM, and may return a function to clean up when the dialog or panel closes. Without actions, a dialog has a Close button. render is called again, after cleaning up, when the editor’s language changes. To show new content meanwhile, change the body’s DOM yourself: Recent files keeps the panels on screen in a set, and draws each again when its list changes.
ctx.ui.registerPanel({ id: 'list', title: msg('title'), icon: ICON, // Drawn again whenever the list changes, until the panel closes. render: (body) => { const draw = () => drawList(body); draw(); views.add(draw); return () => void views.delete(draw); },});A dialog’s close(action) ends it from inside the body, resolving dialog with that action, for a choice that isn’t a button under it:
const colour = await ctx.ui.dialog({ title: 'Choose a colour', render(body, close) { for (const name of ['Red', 'Green', 'Blue']) { const button = document.createElement('button'); button.className = 'wbe-btn'; button.textContent = name; button.addEventListener('click', () => close(name)); body.append(button); } },});Looking like the editor
Section titled “Looking like the editor”The editor’s styles are scoped to it, and plugin DOM is drawn inside it, so it can use them:
- The classes
wbe-btn(a button) andwbe-input(a text field) are stable for plugins. - The theme tokens are CSS custom properties, so DOM that uses them follows the host’s theme and dark mode. The ones plugins need most:
| Token | What it is |
|---|---|
--wbe-text, --wbe-text-muted, --wbe-text-subtle |
Text, and quieter text. |
--wbe-surface, --wbe-surface-alt |
Backgrounds. |
--wbe-border |
Lines between things. |
--wbe-accent, --wbe-on-accent |
The accent colour, and text on it. |
--wbe-danger |
Errors. |
--wbe-font, --wbe-font-size, --wbe-font-size-sm, --wbe-font-size-xs |
Type. |
--wbe-radius, --wbe-radius-sm |
Corners. |
Other classes the editor uses may change between versions. Give your own elements classes of your own, outside the wbe- prefix.
Language
Section titled “Language”A plugin’s text is one of three things:
- written text,
'Show the total', shown as it is; - text in several languages,
{ en: 'Recent files', de: 'Zuletzt geöffnet' }, shown in the editor’s language, or English; - one of its own messages,
{ key: 'plugin.<id>.<key>', params }, from itstranslations.
Messages are the way to go for anything a host might reword. The editor keeps a plugin’s translations under plugin.<id>., so its keys can’t collide with the editor’s or another plugin’s, and the host’s translations can reword them. They use the editor’s placeholders, {{name}}. A small helper makes them:
export const translations = { en: { title: 'Recent files', forget: 'Remove {{name}} from the list' /* … */ }, de: { title: 'Zuletzt geöffnet', forget: '{{name}} aus der Liste entfernen' /* … */ },};
const msg = (key: keyof (typeof translations)['en'], params?: Record<string, unknown>): PluginText => ({ key: `plugin.${RECENT_FILES_ID}.${key}`, params,});Labels, toasts, confirms and dialog titles take a plugin’s text and translate it. For DOM the plugin draws, ctx.text(text) gives it in the editor’s language as it is now:
empty.textContent = ctx.text(msg('empty'));Ship English at least. Ruled’s own plugins also ship German, as the editor does.
When setup runs
Section titled “When setup runs”setup runs once the code that runs plugins has loaded, a moment after the editor is made. That does not depend on the editor having rendered: the toolbar and its elements may not exist yet, and an editor with no workbook open has run setup too. Register commands, sources and panels; the editor draws them whenever it renders, so don’t look for the editor’s DOM from setup.
To know when every plugin is set up and the editor can draw what they added, wait for the handle’s ready promise (await editor.ready), or await element.whenReady() on <workbook-editor>; the element’s ready event only says the editor was made. ready resolves at once when there are no plugins and never rejects: a setup that throws or rejects is reported as a pluginError, and the rest still count. If the editor is destroyed first, ready resolves and the plugins never start.
setup may be async, for a plugin that has to load something before it can register its commands or sources. ready waits for the promise it returns, and the promise may resolve to the plugin’s cleanup:
async setup(ctx) { const sdk = await import('some-sdk'); ctx.commands.register({ id: 'share', label: msg('share'), where: 'toolbar', run: () => sdk.share() }); return () => sdk.close();}There is no timeout: a setup that never settles holds ready back until the editor is destroyed, which resolves it. If the plugin is stopped before its promise settles (the editor destroyed, or the plugin failed), ctx.signal is already aborted, anything it registers is ignored, and the cleanup it resolves to runs at once. Keep the wait short, as hosts may hold their own UI back until ready; an SDK only needed when a command runs is better loaded in preload.
Errors
Section titled “Errors”Every call into a plugin is caught: setup, run, enabled and visible, items, pick, save, saveAs, fetch, render, cleanups and event listeners. When one throws, or a promise it returns rejects:
- the editor shows a message naming the plugin, with the error’s text;
- the host’s
onEventhears{ type: 'pluginError', plugin, message }; - a throw from
setup, or a rejection of the promise it returned, stops the plugin, removing what it added, and the editor carries on without it; - a command whose
enabledorvisiblethrows is hidden from then on; - a failed
saveorsaveAskeeps the changes.
An error’s message is shown as it is. To show text in the editor’s language, give the error a text, any plugin text:
throw Object.assign(new Error('Drive refused the request (403)'), { text: msg('refused', { status: 403 }) });Your DOM’s own event listeners are yours: the editor doesn’t call them, so it can’t catch what they throw. Catch there, and show it with ui.toast('error', …).
Not everything that goes wrong is a fault. When a file Recent files lists is no longer kept, its fetch says so with ui.toast and resolves null, rather than throwing and being reported as a failing plugin.
Storage
Section titled “Storage”ctx.storage keeps small values in this browser, for this plugin: get(key), set(key, value) and delete(key), each returning a promise. Values are anything JSON can hold.
const loaded = ctx.storage.get<RecentFile[]>('files').then((kept) => { if (Array.isArray(kept)) list = kept;});await ctx.storage.set('files', list);It is localStorage, under ruled-plugin:<id>, which a page shares and browsers cap at about 5 MB. It may be unavailable (some private windows) or full: then nothing is kept, without an error, so don’t rely on a value being there.
That is why Recent files keeps the bytes only of files from this device, only up to 100 kB each (a typical template is 10 to 50 kB), and only 8 files. Larger files from this device aren’t listed. Files from a source are kept as their origin, a few hundred bytes, and fetched again.
Keep sign-in tokens out of it: they belong in the provider’s library, as MSAL keeps OneDrive’s in sessionStorage.
Testing
Section titled “Testing”A plugin is a function of its context, so it can be tested with a fake one: an object with the members it uses, recording what it registers and shows. The OneDrive connector’s tests work that way, with fetch stubbed. Recent files’ tests run it in the editor’s own state with a few elements standing in for the DOM, under examples/recent-files/tests.
The whole plugin
Section titled “The whole plugin”import type { FileOrigin, PluginContext, PluginText, RuledPlugin } from '@ruled/sheet/plugin';
/** * A "Recent files" plugin: the workbooks opened lately, to open again in a * click. It is a file source whose files are a submenu of Open, a panel * listing them, and a More command that clears the list. * * Every file is kept as its origin. Files from a source (OneDrive) are * fetched again through that source, so they open as they are now and save * back to it. Files from this device are kept with their bytes too, as a * browser can't open a local file again by itself, and this source fetches * them; only small ones, since they are kept in localStorage. Files the host * opened are its own business, so they aren't listed. */
export const RECENT_FILES_ID = 'com.example.recent-files';const SOURCE_ID = 'recent';
export interface RecentFilesOptions { /** How many files to list. Default 8. */ max?: number; /** The largest file from this device to keep, in bytes. Larger ones aren't listed. Default 100 kB. */ keepBytesUpTo?: number;}
/** A file in the list. */export interface RecentFile { /** Its source and identity there, or its name for a file from this device. */ id: string; /** When it was last opened. */ openedAt: number; /** Where it is kept: its source's origin, or this plugin's for a file from this device. */ origin: FileOrigin; /** Its bytes, as base64, for a file from this device. */ bytes?: string;}
export const translations = { en: { title: 'Recent files', empty: 'Workbooks you open from this device or a file source appear here.', thisDevice: 'This device', forget: 'Remove {{name}} from the list', clear: 'Clear recent files', clearBody: 'The list is kept in this browser only. The files themselves are left as they are.', clearConfirm: 'Clear', gone: '{{name}} is no longer kept in this browser. Open it again from this device.', }, de: { title: 'Zuletzt geöffnet', empty: 'Arbeitsmappen, die Sie von diesem Gerät oder aus einer Dateiquelle öffnen, erscheinen hier.', thisDevice: 'Dieses Gerät', forget: '{{name}} aus der Liste entfernen', clear: 'Liste leeren', clearBody: 'Die Liste wird nur in diesem Browser gespeichert. Die Dateien selbst bleiben unverändert.', clearConfirm: 'Leeren', gone: '{{name}} ist in diesem Browser nicht mehr gespeichert. Öffnen Sie die Datei erneut von diesem Gerät.', },};
type Key = keyof (typeof translations)['en'];
/** One of this plugin's messages, for the editor to show in its language. */const msg = (key: Key, params?: Record<string, unknown>): PluginText => ({ key: `plugin.${RECENT_FILES_ID}.${key}`, params });
/** A clock, on the editor's 24×24 icon grid. */const ICON = 'M12 7v5l3 2M3 12a9 9 0 1 0 18 0a9 9 0 1 0-18 0';
export function recentFiles({ max = 8, keepBytesUpTo = 100 * 1024 }: RecentFilesOptions = {}): RuledPlugin { return { id: RECENT_FILES_ID, apiVersion: 1, translations, setup: (ctx) => setup(ctx, max, keepBytesUpTo), };}
function setup(ctx: PluginContext, max: number, keepBytesUpTo: number): () => void { let list: RecentFile[] = []; /** Panels showing the list, drawn again when it changes. */ const views = new Set<() => void>(); const changed = async (next: RecentFile[]) => { list = next; await ctx.storage.set('files', list); for (const draw of views) draw(); ctx.commands.refresh(); }; const loaded = ctx.storage.get<RecentFile[]>('files').then((kept) => { if (Array.isArray(kept)) list = kept.filter((f) => f?.origin); for (const draw of views) draw(); ctx.commands.refresh(); });
/** Puts the workbook just opened at the top of the list. */ async function remember() { const file = ctx.files.current(); if (!file || file.from === 'host') return; await loaded; let entry: RecentFile | undefined; if (file.origin?.source === SOURCE_ID) { // Opened from this list: it is already kept. entry = list.find((f) => f.id === file.origin!.ref); } else if (file.origin) { entry = { id: `${file.origin.source}:${JSON.stringify(file.origin.ref)}`, openedAt: 0, origin: file.origin }; } else if (file.size <= keepBytesUpTo) { const id = `device:${file.name}`; entry = { id, openedAt: 0, origin: { source: SOURCE_ID, name: file.name, ref: id }, bytes: toBase64(file.bytes()) }; } if (!entry) return; await changed([{ ...entry, openedAt: Date.now() }, ...list.filter((f) => f.id !== entry.id)].slice(0, max)); }
const fromDevice = (origin: FileOrigin) => origin.source === SOURCE_ID; /** The origin as shown: a file from this device says so. */ const shown = (origin: FileOrigin): FileOrigin => (fromDevice(origin) ? { ...origin, location: ctx.text(msg('thisDevice')) } : origin);
/** Opens a listed file: fetched again through the source its origin names, which for this device's files is this one. */ async function reopen(entry: RecentFile) { const file = await ctx.files.fetch(entry.origin); if (file) await ctx.files.open(file); }
/** The list as buttons that open the files, each with a button that removes it. */ function drawList(body: HTMLElement) { if (!list.length) { const empty = document.createElement('p'); empty.textContent = ctx.text(msg('empty')); empty.style.cssText = 'margin: 12px; color: var(--wbe-text-muted)'; body.replaceChildren(empty); return; } const ul = document.createElement('ul'); ul.style.cssText = 'list-style: none; margin: 0; padding: 8px; display: grid; gap: 4px'; for (const entry of list) { const li = document.createElement('li'); li.style.cssText = 'display: flex; gap: 4px; align-items: center'; const open = document.createElement('button'); open.className = 'wbe-btn'; open.style.cssText = 'flex: 1; min-width: 0; height: auto; padding-block: 6px; flex-direction: column; align-items: flex-start; text-align: start'; const name = document.createElement('span'); name.textContent = entry.origin.name; const where = document.createElement('small'); const origin = shown(entry.origin); where.textContent = origin.location ?? origin.source; where.style.cssText = 'color: var(--wbe-text-subtle); font-size: var(--wbe-font-size-xs)'; open.append(name, where); open.addEventListener('click', () => void reopen(entry)); const remove = document.createElement('button'); remove.className = 'wbe-btn'; remove.textContent = '×'; remove.setAttribute('aria-label', ctx.text(msg('forget', { name: entry.origin.name }))); remove.title = remove.getAttribute('aria-label')!; remove.addEventListener('click', () => void changed(list.filter((f) => f !== entry))); li.append(open, remove); ul.append(li); } body.replaceChildren(ul); }
const stop = ctx.on((event) => { if (event.type === 'opened') void remember(); });
ctx.files.registerSource({ id: SOURCE_ID, label: msg('title'), icon: ICON, // A submenu of Open; choosing one fetches it through the source its origin names. items: () => list.map((f) => shown(f.origin)), // The files from this device, from the bytes kept. This source has no `save`, so saving downloads them as before. async fetch(origin) { const entry = list.find((f) => f.id === origin.ref); if (!entry?.bytes) { ctx.ui.toast('error', msg('gone', { name: origin.name })); return null; } return { bytes: fromBase64(entry.bytes), origin: shown(entry.origin) }; }, });
ctx.ui.registerPanel({ id: 'list', title: msg('title'), icon: ICON, // Drawn again whenever the list changes, until the panel closes. render: (body) => { const draw = () => drawList(body); draw(); views.add(draw); return () => void views.delete(draw); }, });
ctx.commands.register({ id: 'clear', label: msg('clear'), where: 'more', enabled: () => list.length > 0, async run() { if (await ctx.ui.confirm({ title: msg('clear'), body: msg('clearBody'), confirmLabel: msg('clearConfirm') })) await changed([]); }, });
return () => { stop(); views.clear(); };}
function toBase64(bytes: ArrayBuffer): string { const all = new Uint8Array(bytes); let text = ''; for (let i = 0; i < all.length; i += 0x8000) text += String.fromCharCode(...all.subarray(i, i + 0x8000)); return btoa(text);}
function fromBase64(text: string): Uint8Array { return Uint8Array.from(atob(text), (c) => c.charCodeAt(0));}Use it as any other plugin:
import { recentFiles } from './recentFiles';
createWorkbookEditor({ plugins: [recentFiles({ max: 10 })] });