Build your plugin's first editor.
Add a selection-aware inspector to the editable Card and work with the current canvas state.
Mount the inspector with browser DOM APIs and explicit cleanup.
Choose a surface by role
An editor is trusted HTML mounted in a surface controlled by Editful. Choose the surface for the job it performs. Editful owns its position, panel chrome, stacking, and available space.
The right sidebar fits properties that follow the selection. The left sidebar fits a focused tool someone opens from the canvas. The area above the toolbar holds a few frequent controls and is covered in the Toolbar guide.
Position by role, not coordinates. A plugin chooses a surface, not an absolute screen position.
Mount a Card inspector
The inspector uses the editable Card from the Toolbar guide. It appears in the right sidebar while exactly one Card is selected, reads the current title, and writes changes back to the same field.
Keep node-kinds and add editor-ui to the plugin's capabilities. Select the Card in the preview and edit its title from the sidebar.
The Vanilla TypeScript version creates a form with browser DOM APIs and returns an editor instance that stays in sync with Editful.
import { definePlugin } from '@editful/canvas-sdk';
import { registerEditableCard } from './toolbar-editable-text';
export default definePlugin({
register(context) {
registerEditableCard(context);
context.editor({
id: 'example:card-properties',
label: 'Card properties',
surface: 'right-sidebar',
selection: { minimum: 1, maximum: 1, kinds: ['example:card'] },
mount(container, initialAction) {
let action = initialAction;
const form = document.createElement('form');
const label = document.createElement('label');
const input = document.createElement('input');
label.textContent = 'Title';
input.setAttribute('aria-label', 'Card title');
label.append(input);
form.append(label);
container.replaceChildren(form);
const sync = () => {
const [card] = action.document.inspect(action.selection.nodeIds());
input.value = card?.field<string>('title') ?? '';
};
const updateTitle = () => {
const [nodeId] = action.selection.nodeIds();
if (nodeId === undefined) return;
const transaction = action.document.transaction('Rename card');
transaction.update({ id: nodeId, fields: { title: input.value } });
transaction.commit();
};
input.addEventListener('input', updateTitle);
sync();
return {
update(nextAction) {
action = nextAction;
sync();
},
dispose() {
input.removeEventListener('input', updateTitle);
container.replaceChildren();
},
};
},
});
},
});Read and change the selected Card
Every mount and update receives an action context for the current canvas state. The inspector reads immutable node snapshots and commits document changes through a transaction.
action.selection.nodeIds()- Returns the node ids in the current selection.
action.document.inspect(ids)- Returns read-only snapshots for those nodes.
action.document.transaction(label)- Starts one named document change for undo history.
transaction.update(...)- Writes the title or another supported node property.
The title input reads from inspect(...) and writes with transaction.update(...). transaction.commit() records the change as one undoable action.
Node snapshots are read-only. Local form state does not change the canvas until the plugin commits a transaction.
Own the editor lifecycle
Editful keeps the surface in place while the selection and document change. The editor owns everything it mounts inside the supplied container.
mount(container, context)- Creates the editor and receives its first action context.
update(context)- Receives current selection and document state without remounting.
dispose()- Releases roots, listeners, observers, and mounted elements.
context.signal- Stops asynchronous work when the host deactivates the editor.
The Vanilla TypeScript editor keeps the latest context in its closure, updates the input from update(...), and removes its listener in dispose().
Keep an editor available
Some controls belong beside the canvas whenever a plugin is active. Mason stays in the left sidebar so its layout controls are ready for the current selection.
An editor is automatic by default. Leave out both activation: 'manual' and a selection rule, and Editful mounts it without a command or toolbar button. Its update(context) method still receives every selection change, so the controls can respond without the editor disappearing.
- Automatic by default
- The host mounts the editor while its plugin is active.
- No selection rule
- The editor remains visible when the selection changes or clears.
- No Mason command
- The editor adds no launcher to the toolbar or command palette.
update(context)- The mounted editor responds to current canvas state.
The Card inspector above is also automatic, but its selection rule controls when it appears. Mason has no selection rule, so the panel stays put and disables its action when fewer than two Cards are selected.
Work with the current selection
Mason reads the selected Cards, preserves their current sizes, and enables its controls when at least two Cards are available. Columns and gap describe the arrangement visible on the canvas.
Adjust the controls and arrange the selected Cards. Click empty canvas space to clear the selection: the editor remains visible, but its controls become unavailable. The Card creation and color actions remain in the toolbar as supporting parts of this complete plugin.
The persistent editor itself needs editor-ui. This complete example also defines the editable Card and its toolbar actions, so it includes node-kinds and commands.
import {
definePlugin,
type PluginNodeSnapshot,
type PluginNodeUpdate,
} from '@editful/canvas-sdk';
import { registerCardColorEditor } from './toolbar-editor';
import { registerEditableCard } from './toolbar-editable-text';
export default definePlugin({
register(context) {
registerEditableCard(context);
registerCardColorEditor(context);
context.editor({
id: 'example:masonry-layout',
label: 'Masonry layout',
surface: 'left-sidebar',
mount(container, initialAction) {
let action = initialAction;
const listeners = new AbortController();
const root = document.createElement('div');
const summary = document.createElement('output');
const tip = document.createElement('p');
const controls = document.createElement('div');
const columns = numberField('Columns', 1, 5, 3);
const gap = numberField('Gap', 0, 64, 12);
const arrange = document.createElement('button');
root.className = 'masonry-editor';
summary.className = 'masonry-editor-summary';
tip.className = 'masonry-editor-tip';
controls.className = 'masonry-editor-controls';
arrange.type = 'button';
arrange.textContent = 'Arrange as masonry';
controls.append(columns.label, gap.label);
root.append(summary, tip, controls, arrange);
container.replaceChildren(root);
const selectedCards = () =>
action.document
.inspect(action.selection.nodeIds())
.filter((node) => node.kind === 'example:card');
const sync = () => {
const cards = selectedCards();
const ready = cards.length >= 2;
columns.input.max = String(Math.max(1, cards.length));
columns.input.value = String(
Math.min(cards.length, integerValue(columns.input, 3)),
);
summary.value = ready
? `${cards.length} cards selected · sizes preserved`
: cards.length === 1 ? '1 card selected' : 'No cards selected';
tip.textContent = cards.length === 1
? 'Select one more card. Shift-click to add it to the selection.'
: 'Select at least two cards. Shift-click to build a selection.';
tip.hidden = ready;
columns.input.disabled = !ready;
gap.input.disabled = !ready;
arrange.disabled = !ready;
};
const applyLayout = () => {
const cards = selectedCards();
const updates = masonryLayout(
cards,
integerValue(columns.input, 3),
integerValue(gap.input, 12),
);
if (updates.length === 0) return;
const transaction = action.document.transaction('Arrange as masonry');
for (const update of updates) transaction.update(update);
transaction.commit();
};
arrange.addEventListener('click', applyLayout, {
signal: listeners.signal,
});
columns.input.addEventListener('change', sync, {
signal: listeners.signal,
});
sync();
return {
update(nextAction) {
action = nextAction;
sync();
},
dispose() {
listeners.abort();
container.replaceChildren();
},
};
},
});
},
});
function masonryLayout(
nodes: readonly PluginNodeSnapshot[],
requestedColumns: number,
requestedGap: number,
): readonly PluginNodeUpdate[] {
if (nodes.length < 2) return [];
const columnCount = Math.max(1, Math.min(nodes.length, requestedColumns));
const gap = Math.max(0, requestedGap);
const top = Math.min(...nodes.map((node) => node.y - node.height / 2));
const left = Math.min(...nodes.map((node) => node.x - node.width / 2));
const buckets = Array.from(
{ length: columnCount },
() => [] as PluginNodeSnapshot[],
);
const heights = Array.from({ length: columnCount }, () => 0);
for (const node of [...nodes].sort(
(a, b) => b.height - a.height || b.width - a.width,
)) {
const column = shortestColumn(heights);
buckets[column]!.push(node);
heights[column] = heights[column]! + node.height + gap;
}
const widths = buckets.map((bucket) =>
Math.max(...bucket.map((node) => node.width)),
);
const updates: PluginNodeUpdate[] = [];
let x = left;
buckets.forEach((bucket, column) => {
let y = top;
for (const node of bucket) {
updates.push({
id: node.id,
x: x + widths[column]! / 2,
y: y + node.height / 2,
});
y += node.height + gap;
}
x += widths[column]! + gap;
});
return updates;
}
function shortestColumn(heights: readonly number[]): number {
let shortest = 0;
for (let column = 1; column < heights.length; column++) {
if (heights[column]! < heights[shortest]!) shortest = column;
}
return shortest;
}
function numberField(
text: string,
minimum: number,
maximum: number,
value: number,
): { readonly label: HTMLLabelElement; readonly input: HTMLInputElement } {
const label = document.createElement('label');
const input = document.createElement('input');
label.textContent = text;
input.type = 'number';
input.min = String(minimum);
input.max = String(maximum);
input.value = String(value);
label.append(input);
return { label, input };
}
function integerValue(input: HTMLInputElement, fallback: number): number {
const value = Number(input.value);
return Number.isFinite(value) ? Math.round(value) : fallback;
}- Current selection
- The editor derives its summary and enabled state from the selected Cards.
- Unavailable action
- Inputs and the arrange button stay disabled until two Cards are selected.
- Derived layout
- The plugin calculates positions from current geometry without changing card sizes.
- One transaction
- Every position update commits together as one undoable canvas action.