Plugins
A plugin is an ES module that exports the plugin as its default export and
registers it through the plugin registry, which TissUUmaps exposes as
window.tissuumaps once the application has started up:
const plugin = {
id: "my-plugin",
name: "My plugin",
setup: ({ appStore, dataStore, projectStore, settingsStore }) => {
return () => {}; // teardown
},
};
export default plugin;
if (window.tissuumaps !== undefined) {
window.tissuumaps.registerPlugin(plugin); // startup already finished
} else {
window.addEventListener(
"tissuumaps-loaded",
() => window.tissuumaps.registerPlugin(plugin),
{ once: true },
);
}
Written this way, the same file works however it gets into TissUUmaps: the user can load it through the plugins menu, which uses the default export (see Loading plugins from the plugins menu), and a deployment can include it in the page, where it registers itself:
<script type="module" src="my-plugin.js"></script>
Because of the export, a plugin included in the page has to be loaded with
type="module"; a classic <script> fails with a syntax error.
A plugin can be registered at any time, but window.tissuumaps only exists once
the application has started up. TissUUmaps signals this by dispatching a
tissuumaps-loaded event on window at the end of its startup — a plain event
that is not replayed, so a listener added afterwards never fires. Whether a
plugin runs before or after startup usually cannot be guaranteed: module scripts
run in document order, so it depends on where the plugin's <script> is placed
relative to the application's, and an async or dynamically imported module
races it. Checking window.tissuumaps first and listening for
tissuumaps-loaded otherwise, as above, is correct either way.
Note that the project is not yet loaded when tissuumaps-loaded fires — loading
is only started during startup. A plugin that depends on project contents should
subscribe to projectStore in setup instead of reading it once.
A plugin is unregistered again — unmounting it if it is mounted, and tearing it
down — using window.tissuumaps.unregisterPlugin(pluginId).
For TypeScript, the Plugin, PluginRegistry and PluginStores types are
exported from @tissuumaps/core, and window.tissuumaps is typed as
PluginRegistry | undefined.
The plugins shipped with TissUUmaps in @tissuumaps/plugins are the exception:
the application registers them itself on startup, so they only need to export the
plugin.
Loading plugins from the plugins menu
The user can load a plugin through the plugins menu in the tab bar: Load plugin
from file… picks a local file, and Load plugin from URL… asks for a URL,
absolute or relative to the page. TissUUmaps runs the module, registers its
default export, which sets the plugin up, and opens the plugin's panel right away
if it has a mount. Loading a module without a default export fails, even if
the module registers a plugin itself when it runs — that plugin then stays
registered, but is not opened.
- A module loaded from a URL on another origin has to be served with CORS headers, as common public hosts such as GitHub, jsDelivr or unpkg do.
- A module loaded from a local file has to be a single file, bundled if need be:
its relative imports cannot be resolved, and neither can paths relative to
import.meta.url. Imports of absolute URLs work. - A plugin that registered itself when its module ran is not registered again,
so its
setupis only called once. - Loading the same URL again does not run the module again, since the browser caches modules by URL. If the plugin is still registered, it is only opened again; if it has been unregistered in the meantime, it is registered again.
- Loaded plugins are not remembered: after reloading the page, they have to be loaded again.
A plugin runs with full access to TissUUmaps and the data it has opened. TissUUmaps asks for confirmation before loading one, but cannot check what it does: only load plugins from sources you trust.
Plugin properties
id(required): a unique identifier for the plugin. Registering a plugin whoseidis already in use unregisters the previous plugin first.name(required): a human-readable name for the plugin.setup(optional): called once, immediately upon registration, with a reference to each of the application's Zustand stores (see below). It may return a teardown callback, see Plugin lifecycle. Errors thrown bysetupare caught and logged; they do not abort application startup. The plugin is then not registered, and no teardown happens - a teardown never has to cope with a half-initialized plugin. Asetupthat can fail part-way through is responsible for releasing what it had already set up before it rethrows. A plugin that only adds a user interface does not need asetup, because itsmountreceives the same stores.mount(optional): mounts a user interface panel when the user opens the plugin, and may return an unmount callback, see User interface plugins.
Plugin lifecycle
A plugin goes through four steps, all of them optional:
setupis called once, when the plugin is registered.mountis called when the plugin is mounted, i.e. when the user opens it from the plugins menu, or throughwindow.tissuumaps.mountPlugin(pluginId). Registering a plugin does not mount it.- The unmount callback returned by
mountis called when the plugin is unmounted, i.e. when the user closes its panel, or throughwindow.tissuumaps.unmountPlugin(pluginId). The plugin stays registered and set up, and can be mounted again later, which callsmountagain. - The teardown callback returned by
setupis called last, when the plugin is unregistered, once a mounted user interface has been unmounted.
Unregistering happens through window.tissuumaps.unregisterPlugin(pluginId), by
registering another plugin with the same id, and when the plugin registry is
stopped (currently only on hot module replacement during development, not on
page unload). Closing a plugin's panel does not unregister it. The unmount and
teardown callbacks are only ever called for a plugin whose setup succeeded;
errors they throw are caught and logged. If mount throws, the error is logged
and the plugin is not mounted, but it stays registered.
Stores
setup and mount receive the application's four Zustand stores:
| Store | Contents |
|---|---|
appStore | Application state: workspace, whether a project is open, interaction mode, data providers, plugins |
dataStore | Data references (DataRef) for the loaded data of each project object |
projectStore | The currently loaded project (name, layers, images, labels, points, shapes, tables, maps) and its URL |
settingsStore | User settings that are persisted across sessions |
Each store is a Zustand store API. Using appStore as an example, a plugin can
read the current value of myProperty using appStore.getState().myProperty,
call the action myAction using appStore.getState().myAction(...), and observe
changes using appStore.subscribe((state, prevState) => {}).
The stores use the Immer middleware, so
setState takes a recipe that mutates a draft:
appStore.setState((draft) => {
draft.myProperty = myValue;
});
The recipe must not return a value. Writing appStore.setState((draft) => draft.myMap.set(k, v))
returns the map and makes Immer reject the update — always use a block body.
dataStore is derived state: its contents are reconciled from appStore and
projectStore by the application. Plugins should treat it as read-only and drive
data loading by changing the project instead, for example
projectStore.getState().updateTable(tableId, { dataSource }). Likewise,
appStore's plugins is written by the registry alone.
appStore's activePanelId is the ID of the active panel, i.e. the panel whose
tab the user selected last, and is kept in sync with the panel layout in both
directions. A plugin brings a panel to the front with
appStore.getState().setActivePanelId(panelId). The built-in panels have the
IDs "project", "images", "labels", "points", "shapes" and "tables",
and a mounted plugin's panel has "plugin:" followed by the plugin's id, for
example "plugin:my-plugin". The viewer has no tab and is never the active
panel: while the user interacts with the viewer, activePanelId keeps the panel
that was active last. Setting an ID that does not identify a panel of the
layout, e.g. that of a plugin that is not mounted, has no effect.
appStore's expandedImageIds, expandedLabelsIds, expandedPointsIds,
expandedShapesIds and expandedTableIds are the IDs of the objects whose
entries are expanded in the respective panels, and are set with
setExpandedImageIds etc. They are cleared whenever another project is loaded
or the project is closed, but may contain the IDs of deleted objects. A plugin shows the user an image, e.g. one it
created, by expanding its entry and bringing the Images panel to the front:
const { expandedImageIds, setExpandedImageIds, setActivePanelId } =
appStore.getState();
setExpandedImageIds([...expandedImageIds, imageId]);
setActivePanelId("images");
The entry is expanded but not scrolled into view.
User interface plugins
A plugin adds a panel to the TissUUmaps user interface by declaring a mount.
Every such plugin is listed by its name in the plugins menu, the kebab button
in the tab bar next to the dark mode toggle. Picking it there mounts the plugin,
and its panel appears as a tab titled with the plugin's name, next to the
built-in Project, Images, Labels, Points, Shapes and Tables panels:
const plugin = {
id: "my-plugin",
name: "My plugin",
mount: (container, { projectStore }) => {
const paragraph = document.createElement("p");
const update = (state) => {
paragraph.textContent = `${state.images.length} images`;
};
update(projectStore.getState());
container.append(paragraph);
return projectStore.subscribe(update); // unmount
},
};
// exported and registered as shown above
Because mount receives the stores itself, a plugin that only adds a user
interface does not need a setup.
mount is called with an empty HTMLElement owned by TissUUmaps, into which the
plugin renders its user interface, and with the same four stores that setup
receives. It may return a callback that unmounts that user interface again.
The container is discarded when the plugin is unmounted, so the callback only has
to release what is not plain DOM, such as store subscriptions, listeners on window,
or a React root.
Panel lifetime
The panel is shown for exactly as long as the plugin is mounted:
mountis called when the user picks the plugin in the plugins menu, or whenwindow.tissuumaps.mountPlugin(pluginId)is called, and the panel is made active. Picking a plugin that is already mounted only activates its panel.- The container is not part of the document when
mountis called: TissUUmaps attaches it to the panel once the panel is shown. Measure the container's size with aResizeObserverrather than once inmount. - The user interface stays mounted while other tabs of its group are active, and when the panel is dragged elsewhere — neither remounts it.
- Closing the panel unmounts the plugin, calling the callback returned by
mount, but keeps it registered: whateversetupset up stays in place, and the plugin can be opened from the plugins menu again, which callsmountagain with a fresh container. - Unregistering a mounted plugin calls the callback returned by
mountfirst and the callback returned bysetupafterwards, so an unmount callback may still rely on whateversetupset up.
Dropping files
TissUUmaps ignores files dropped outside of its own drop targets. To accept
dropped files, a plugin element handles dragover and drop itself, and stops
the propagation of dragover:
element.addEventListener("dragover", (event) => {
event.preventDefault();
event.stopPropagation();
event.dataTransfer.dropEffect = "copy";
});
element.addEventListener("drop", (event) => {
event.preventDefault();
const files = Array.from(event.dataTransfer.files);
// ...
});
Without stopPropagation() on dragover, the drop is refused. Let drop
propagate, so that TissUUmaps notices that the drag has ended.
Bringing your own framework
mount is a plain DOM contract, so a plugin can use whichever framework it
likes — or none at all. With React, for example:
mount: (container, stores) => {
const root = ReactDOM.createRoot(container);
root.render(React.createElement(MyPanel, { stores }));
return () => root.unmount();
};