Skip to main content

Plugin API

Overview​

Tokenizer 2 exposes a hook-based plugin system that allows other Foundry VTT modules to:

  • Register toolbar tools with custom icons, panels, and consent dialogs
  • Listen to lifecycle hooks fired during editor use
  • Inject license information into the Settings → Licenses tab

Plugins are standard Foundry VTT modules that declare a dependency on tokenizer-2 and register themselves via the tokenizer-2.registerPlugins hook. They talk to the editor through the versioned Editor API.

Quick Start​

1. Create a Foundry module​

my-plugin/
├── module.json
├── scripts/
│ └── index.js
└── lang/
└── en.json

module.json - declare a dependency on tokenizer-2:

{
"id": "my-tokenizer-plugin",
"title": "My Tokenizer Plugin",
"version": "1.0.0",
"compatibility": { "minimum": "13", "verified": "13" },
"relationships": {
"requires": [{ "id": "tokenizer-2", "type": "module" }]
},
"esmodules": ["scripts/index.js"],
"languages": [{ "lang": "en", "name": "English", "path": "lang/en.json" }]
}

2. Register your plugin​

In scripts/index.js:

import { MyTool } from "./MyTool.js";

Hooks.once("tokenizer-2.registerPlugins", (registry) => {
registry.register({
id: "my-tool",
moduleId: "my-tokenizer-plugin",
name: "My Tool",
description: "Does something useful",
version: "1.0.0",
author: "Your Name",
requires: "1.0",
tools: [{
id: "my-tool",
icon: "fa-solid fa-wand-magic-sparkles",
tooltip: "My Tool Tooltip",
toolClass: MyTool,
panel: renderMyPanel,
}],
});
});

function renderMyPanel(container, { tool, editor }) {
const p = document.createElement("p");
p.textContent = "My tool is active!";
container.appendChild(p);
}

Plugin Descriptor​

The object passed to registry.register():

FieldTypeRequiredDescription
idstringyesUnique plugin identifier
moduleIdstringyesThe Foundry module ID (must match module.json)
namestringyesDisplay name
requiresstringrecommendedMinimum Tokenizer 2 API version, "major.minor" (see Versioning). The plugin is skipped, with an error in the console, when the running API cannot satisfy it
descriptionstringnoShort description
versionstringnoPlugin version
authorstringnoAuthor name
licenseobject or object[]noLicense info shown in Settings → Licenses tab; a list adds one entry per item (API 1.2)
license.namestringyes (if license)Library/license name
license.textstringnoDescription text
license.urlstringnoLink to library/license
license.copyrightstringnoCopyright notice
toolsToolDescriptor[]noArray of tools to add to the toolbar
layerMenuLayerMenuItem[]noEntries for the layer list's right-click menu (API 1.2, see Layer Menu Entries)
hooksobjectnoMap of hook names to callback functions

Versioning​

The plugin API has a version, "major.minor", readable as registry.apiVersion inside tokenizer-2.registerPlugins and as Tokenizer2.apiVersion afterwards. The minor version goes up when something is added and the major version when something changes incompatibly. Declare the version you were written against with requires; a plugin needing "1.0" loads on any 1.x.

The versioned surface is: the plugin descriptor, the tool and panel signatures below, the Editor API, the hooks and their payloads, and the public API object (Tokenizer2). Everything reached through the raw editor (app, editor.editor) is internal and can change in any release.

Tool Descriptor​

Each entry in the tools array:

FieldTypeRequiredDescription
idstringyesUnique tool identifier (used as data-tool attribute)
iconstringyesFontAwesome class (e.g. "fa-solid fa-scissors")
tooltipstringyesTooltip text or localization key (keys containing . are passed through game.i18n.localize())
toolClassclassyesES class instantiated when the tool is activated
panelfunctionno(container: HTMLElement, ctx: { tool, editor, app }) => void - builds the left-panel UI
consentobjectnoConsent dialog shown before first use
requiresStaticLayerbooleannoDisable the tool while the active layer is a video (for tools that work on still pixels)

If your tool requires user consent (e.g. for AI/ML features):

FieldTypeRequiredDescription
moduleIdstringnoModule ID for the setting (defaults to plugin's moduleId)
settingKeystringyesSetting key storing consent state ("" = not asked, "yes", "no")
titlestringyesDialog title (or localization key)
contentstringyesDialog body HTML (or localization key)
yesLabelstringyesAccept button label (or localization key)
noLabelstringyesDecline button label (or localization key)

You must register the consent setting in a Hooks.once("init", ...) handler under your module's ID:

Hooks.once("init", () => {
game.settings.register("my-tokenizer-plugin", "my-consent", {
scope: "world",
config: false,
type: String,
default: "",
});
});

Tool Class Interface​

A new instance of your tool class is created each time the tool is activated (toolbar click, editor.activateTool(id)), and deactivate() is called when the user switches away or the editor closes. Every method is optional.

export class MyTool {
/**
* @param {HTMLCanvasElement} overlayCanvas Transparent canvas over the editor canvas; receives pointer events
* @param {object} app The raw editor - internal, prefer context.editor
* @param {object} context
* @param {string} context.toolName Your tool id
* @param {EditorContext} context.editor The Editor API (see below)
*/
async activate(overlayCanvas, app, context) {
this.editor = context.editor;
}

/**
* The user switched tools or closed the editor. Release everything (listeners, timers),
* even if storing your work fails: when this throws, the switch or close goes ahead
* without your tool and the error is logged.
*/
deactivate() {}

/** The user selected another layer while your tool is active. */
onActiveLayerChange(newLayerId) {}

/** Whether your tool holds work not yet in the layers: Cancel then asks before the editor closes without it. */
hasPendingWork() { return false; }

// Pointer events on the overlay canvas. Map them to logical canvas
// coordinates with the Editor API:
onPointerDown(e) {
const { x, y } = this.editor.toCanvasPoint(e.clientX, e.clientY);
}
onPointerMove(e) {}
onPointerUp(e) {}
}

Space+drag always pans the view, whichever tool is active; your pointer handlers do not see those events.

Panel Function​

The panel function is called on every editor render while your tool is active. It receives:

  • container - an empty <div id="tie-plugin-panel"> element. Build your UI by appending DOM nodes.
  • ctx.tool - your tool instance (read its state, call its methods)
  • ctx.editor - the Editor API
  • ctx.app - the raw editor (internal)

The container is cleared (innerHTML = "") before each call, so build the full panel every time. Attach event listeners directly to your elements. Call editor.refresh() to re-render the panel after your tool's state changes.

function renderMyPanel(container, { tool, editor }) {
const heading = document.createElement("h3");
heading.className = "tie-panel__heading";
heading.textContent = "My Tool";
container.appendChild(heading);

// Add sliders, buttons, etc.
}

The Editor API (EditorContext)​

The supported way to work with an open editor. Tools get it as context.editor, panels as ctx.editor, and the editor hooks as context.

MemberDescription
actor, token, element, modeThe actor being edited, the scene token (or null), the editor window element, and "token" or "avatar"
canvasSize{ width, height } of the logical canvas layers are laid out in (512×512 for tokens; avatars can be other sizes)
layerScaleHow many times smaller frames, rings and art are drawn on this token: 2 or 1.3 on an oversized token, otherwise 1 (always 1 for avatars). Scale a new layer by 1 / layerScale to fit it to the token as they are (API 1.2)
toCanvasPoint(clientX, clientY)A pointer position (from your tool's pointer events) in canvas coordinates
viewport{ zoom, offset }. The overlay context arrives in device pixels scaled to logical pixels; apply translate(offset.x, offset.y) then scale(zoom, zoom) to draw in canvas space
layersThe live layer stack, bottom first. Change it inside edit() (or call pushUndo() first)
getLayer(id), getActiveLayer(), setActiveLayer(id)Look up and select layers
createImageLayer(src, name), addLayerAbove(layer, id), removeLayer(id)Build and edit the stack. Load a new layer's picture with loadImage(src) before adding it (or changing a layer's src): the canvas and the saved token only draw pictures the editor has loaded
getFlag(target, scope, key), setFlag(target, scope, key, value)Your own data on a layer or mask entry, stored at target.flags[scope][key] and saved with the layer. Use your module id as the scope; never add top-level fields to layers
edit(change, { refresh }?)Make one undoable change: runs change (may be async), keeps the layers from before it as one undo step and redraws the canvas and panels (refresh: "all", "canvas" or "none"). Return false from change when nothing changed: no step is kept. A change that throws keeps a step only if it had already changed the layers. While an async change is still running, other edits (yours or the user's) join its step, and undo, saves and mode switches wait for it to finish; an edit asked for during a mode switch waits for it and is made on the mode then on screen. Returns what change returned
pushUndo()Record the current layers as an undo step, for a change you make yourself over time (a drag). Call before the change
requestRender()Redraw the canvas (cheap; several calls in one tick draw once)
refresh()Re-render the whole window, including your panel
loadImage(src)Load and cache an image; resolves to the image
invalidateImage(src)Forget the image or video loaded from src and every cached bitmap derived from it, e.g. after overwriting the file
preloadMasks(layers)Load the bitmaps for the masks on layers, so the next render applies them
getMaskBitmap(mask)The bitmap a mask entry renders with (alpha, custom, frame-ray, frame-marching), or null if not loaded
storePNG(canvas, { folder, filename, label })Save a canvas as a PNG layers can reference: uploads into today's subfolder of folder, or returns a data: URL when layer persistence is off. Returns null (after notifying the user) on failure
setOverlay(fn), clearOverlay()Draw on the tool overlay every frame, until cleared
activateTool(id, context)Switch tools as the toolbar does (e.g. back to "move" when your tool cannot run). Resolves true once the tool is active, and false when it was not activated: there is no tool with that id or the current tool could not store its work (a brush stroke whose upload failed), both leaving the current tool on, a later switch took over first, or the editor closed meanwhile
editorThe raw editor. Internal and unversioned

Layer Menu Entries​

Each entry in the layerMenu array adds an item to the menu that opens when a layer in the layer list is right-clicked:

FieldTypeRequiredDescription
idstringyesIdentifier, unique within the plugin
labelstringyesMenu text or localization key (keys containing . are passed through game.i18n.localize())
iconstringnoFontAwesome class
visiblefunctionno(layer, editor) => boolean - whether the entry shows for this layer (every layer when omitted)
onClickfunctionyes(layer, editor) => void | Promise<void> - runs when the entry is picked

editor is the Editor API. Clicking a layer row only selects the layer, so a plugin that edits its own layers should offer that here rather than reacting to the selection, for example by opening its tool on the layer:

layerMenu: [{
id: "edit-my-layer",
label: "MY-MODULE.EditLayer",
icon: "fa-solid fa-pen",
visible: (layer, editor) => Boolean(editor.getFlag(layer, "my-module", "data")),
onClick: (layer, editor) => editor.activateTool("my-tool", { layerId: layer.id }),
}],

The context given to activateTool reaches the tool's activate(overlay, app, context).

Lifecycle Hooks​

Subscribe with the standard Foundry Hooks.on() API, or through the hooks field in your plugin descriptor. editor in a payload is the raw editor (internal); context is the Editor API.

Hook NameFired WhenData
tokenizer-2.registerPluginsOnce at startup, before tokenizer-2.readyregistry - call registry.register({...}); registry.apiVersion
tokenizer-2.registerAnimationPresetsOnce at startupregistry - register per-layer animation presets: (layer, time) -> overrides pure functions
tokenizer-2.registerEncodersOnce at startupregistry - register animated-output encoders (e.g. MP4, GIF). Core's WebM encoder is registered first
tokenizer-2.registerFramesOnce at startupframeRegistry - add tabs/categories to the Frame browser (see the Programmatic API)
tokenizer-2.registerMasksOnce at startupframeRegistry - add tabs/categories to the Mask browser
tokenizer-2.readyOnce, after all plugins registeredapi - the public API object (Tokenizer2)
tokenizer-2.editorOpenEditor finishes its first render{ editor, context, actor, token }
tokenizer-2.editorCloseEditor begins closing{ editor, context, actor }
tokenizer-2.preSaveBefore a token image the editor saves is exported (also the token of Save Both, from either mode); not for avatar or API saves{ editor, context, actor, layers, isAnimated, format, duration }
tokenizer-2.postSaveAfter that token image is saved and its documents updated{ editor, context, actor, path, isAnimated, format, duration }
tokenizer-2.toolActivatedA tool becomes active (of switches that overlap, only the one whose tool ends up active fires it){ toolName, tool, context }
tokenizer-2.toolDeactivatedThe editor's starting tool, or one toolActivated named, is switched away from{ toolName }
tokenizer-2.layerAddedA layer is added to the stack{ layer }
tokenizer-2.layerRemovedA layer is removed from the stack{ layerId }
tokenizer-2.tokenizeProgressEach actor of a batch tokenize{ current, total, actor }
tokenizer-2.customFrameRegisteredA frame is added through registerCustomFrame{ src, label, group, shared }

Using the hooks field​

registry.register({
id: "my-plugin",
moduleId: "my-tokenizer-plugin",
name: "My Plugin",
requires: "1.0",
hooks: {
"tokenizer-2.editorOpen": ({ context, actor }) => {
console.log(`Editor opened for ${actor.name}: ${context.layers.length} layers`);
},
"tokenizer-2.layerAdded": ({ layer }) => {
console.log(`Layer added: ${layer.name}`);
},
},
});

Player Requests​

Added in API 1.3.

A player can send something to the GM for approval: the GM reviews it, and on approval it is carried out on the GM's client, which can do what the player may not (upload a picture, change a document they cannot). The Coat of Arms window uses it for players' coats of arms.

A request type is registered by a module, on every client (the sender's client checks the payload before sending):

Hooks.once("tokenizer-2.ready", (api) => {
api.gmRequests?.register("my-module.portrait", {
label: (record, actor) => `a portrait for ${actor.name}`, // shown on the chat card and in the window
sanitize: (payload) => cleanCopy(payload), // a clean copy of untrusted data; throw when it is not usable
describe: (record) => record.payload.title, // optional line of text in the window
preview: async (record) => drawCanvas(record.payload), // optional: a canvas, or a picture's address
apply: async (record, actor) => { /* carry it out, on the GM's client */ },
maxBytes: 16 * 1024, // optional; the largest payload as JSON characters
});
});

send(type, payload, { actor }) writes the request as a flag on the actor, so the sender must own it, and whispers a chat card to the GMs. That works whether a GM is online or not: the GM reviews it from the card's Review button, or from Tokenizer 2's Player Requests settings menu. A new request of the same type on the same actor replaces the old one. On Approve the payload goes through sanitize again, then apply; the request is cleared and the player is whispered the result. A request whose apply throws stays waiting.

Keep payloads small JSON, with no image data: flags are stored in the world database and sent to every client. Send the recipe for a picture and draw it again in apply.

The Public API​

After tokenizer-2.ready, the API is on window.Tokenizer2 and game.modules.get("tokenizer-2").api (see the Programmatic API for the full list): apiVersion, openEditor, tokenize, exportLayers, pluginRegistry, frameRegistry and more.

Tokenizer Lib​

Tokenizer 2 exposes its helper library as Tokenizer2.lib (storage, naming, logger, frames, and more). It is available from the tokenizer-2.ready hook on; plugin modules load before that, so read it when you use it rather than at import time.

Example: Auto Subject Cutout​

This example will be available on GitHub soon!