Draw live content on the canvas.
Give a canvas node its own retained WebGL renderer while Editful keeps ownership of composition, camera movement, and the frame loop.
Mount the intensity control with browser DOM APIs and explicit cleanup.
Choose a retained surface
Use ordinary primitives for shapes, text, and images that can be packed into Editful's shared display list. Use a retained surface when one node needs a long-lived renderer with its own GPU resources—for example, a waveform, simulation, or dense data view.
The boundary has two parts. The node's pack function places a surface and passes bounded JSON state. A renderer contribution owns the WebGL programs, buffers, fetched resources, and workers used to draw every visible surface with that renderer id.
A retained surface is still part of the canvas. Editful positions it in node paint order, clips it to the visible viewport, and composites it with selection and collaboration UI.
Register the renderer
Add gpu-renderer beside the capabilities already used by the plugin.
capabilities: ['node-kinds', 'gpu-renderer'],Register one renderer contribution during plugin setup. Editful creates one runtime and shares it across every surface emitted with example:pulse-surface.
import {
Primitive,
definePlugin,
type PluginRendererHost,
type PluginRendererInstance,
} from '@editful/canvas-sdk';
class PulseRenderer implements PluginRendererInstance {
constructor(private readonly host: PluginRendererHost) {}
render(frame, target): boolean {
const intensity = Number(frame.state.intensity ?? 0);
const pulse = intensity * (0.7 + Math.sin(frame.time / 240) * 0.3);
const gl = this.host.gl;
gl.bindFramebuffer(gl.FRAMEBUFFER, target.framebuffer);
gl.viewport(0, 0, target.width, target.height);
gl.clearColor(0.08, 0.35 * pulse, 0.5 * pulse, 1);
gl.clear(gl.COLOR_BUFFER_BIT);
return true;
}
dispose(): void {}
}
export default definePlugin({
register(context) {
context.renderer({
id: 'example:pulse-surface',
create: (host) => new PulseRenderer(host),
});
},
});render(...) receives an Editful-owned framebuffer. Returning true asks the host for another animation frame. A renderer waiting on data can return false, then call host.requestFrame() when the data arrives.
Place the surface in a node
The pack function gives the surface a position, size, rotation, and the state needed for this frame. The Pulse node keeps its ordinary body primitive so it retains a predictable canvas hit area.
const pulse = context.kind('example:pulse');
const intensity = pulse.field.f64('intensity', { default: 0.6 });
pulse.create({
label: 'Pulse',
shortcut: 'p',
cursor: 'crosshair',
gesture: 'drag',
});
pulse.hit('rect');
pulse.pack((node, _services, out) => {
out.quad(
node.x,
node.y,
node.halfW,
node.halfH,
node.rotation,
node.cornerRadius,
node.strokeWidth,
Primitive.RoundRect,
node.fill,
node.stroke,
);
out.surface(
'example:pulse-surface',
{ intensity: node.get(intensity) },
node.x,
node.y,
node.halfW,
node.halfH,
node.rotation,
);
});The state object is copied when Editful gathers a changed scene chunk. Keep it small and JSON-only. Mutable runtime state, decoded assets, GPU objects, and request caches belong in PulseRenderer, not in the document or pack callback.
Drag Intensity in the editor. The field commits through the document while the retained renderer keeps its own animation and GPU lifetime.
import {
Primitive,
definePlugin,
type PluginActionContext,
type PluginContext,
type PluginEditorInstance,
type PluginRendererHost,
type PluginRendererInstance,
type PluginSurfaceFrame,
type PluginSurfaceRenderTarget,
} from '@editful/canvas-sdk';
export const LIVE_SURFACE_KIND = 'example:pulse';
class PulseRenderer implements PluginRendererInstance {
constructor(private readonly host: PluginRendererHost) {}
render(frame: PluginSurfaceFrame, target: PluginSurfaceRenderTarget): boolean {
const intensity = Number(frame.state.intensity ?? 0.6);
const pulse = intensity * (0.72 + Math.sin(frame.time / 260) * 0.28);
const gl = this.host.gl;
gl.bindFramebuffer(gl.FRAMEBUFFER, target.framebuffer);
gl.viewport(0, 0, target.width, target.height);
gl.disable(gl.SCISSOR_TEST);
gl.clearColor(0.035, 0.18 + pulse * 0.34, 0.34 + pulse * 0.48, 1);
gl.clear(gl.COLOR_BUFFER_BIT);
return true;
}
dispose(): void {}
}
export default definePlugin({
register(context) {
registerLiveSurface(context);
context.editor({
id: 'example:pulse-editor',
label: 'Pulse intensity',
surface: 'right-sidebar',
selection: { minimum: 1, maximum: 1, kinds: [LIVE_SURFACE_KIND] },
mount: mountIntensityEditor,
});
},
});
export function registerLiveSurface(context: PluginContext): void {
context.renderer({
id: 'example:pulse-surface',
create: (host) => new PulseRenderer(host),
});
const pulse = context.kind(LIVE_SURFACE_KIND);
const intensity = pulse.field.f64('intensity', { default: 0.6 });
pulse.create({
label: 'Pulse', icon: 'activity', shortcut: 'p', cursor: 'crosshair',
gesture: 'drag', order: 40, defaultSize: { width: 340, height: 180 },
styleSlot: 'shape',
});
pulse.hit('rect');
pulse.pack((node, _services, out) => {
out.quad(
node.x, node.y, node.halfW, node.halfH, node.rotation,
12, node.strokeWidth, Primitive.RoundRect, node.fill, node.stroke,
);
out.surface(
'example:pulse-surface',
{ intensity: node.get(intensity) },
node.x, node.y, node.halfW, node.halfH, node.rotation,
);
});
}
function mountIntensityEditor(
container: HTMLElement,
initialAction: PluginActionContext,
): PluginEditorInstance {
let action = initialAction;
const label = document.createElement('label');
const value = document.createElement('output');
const input = document.createElement('input');
label.textContent = 'Intensity';
input.type = 'range';
input.min = '0.1';
input.max = '1';
input.step = '0.05';
label.append(input, value);
container.replaceChildren(label);
const sync = () => {
const [node] = action.document.inspect(action.selection.nodeIds());
const intensity = node?.field<number>('intensity') ?? 0.6;
input.value = String(intensity);
value.value = `${Math.round(intensity * 100)}%`;
};
input.addEventListener('input', () => {
const [nodeId] = action.selection.nodeIds();
if (nodeId === undefined) return;
const next = Number(input.value);
const transaction = action.document.transaction('Change pulse intensity');
transaction.update({ id: nodeId, fields: { intensity: next } });
transaction.commit();
value.value = `${Math.round(next * 100)}%`;
});
sync();
return {
update(nextAction) { action = nextAction; sync(); },
dispose() { container.replaceChildren(); },
};
}Follow the host lifecycle
frame.surfaceId- Identifies one retained surface for the lifetime of its document node.
frame.nodeId- Names the stable public document node that emitted the surface.
frame.renderScale- Reports realized pixels per world unit after camera zoom and GPU budgeting.
target.framebuffer- Receives this surface’s pixels for composition into the canvas.
contextLost()- Releases assumptions about GPU state when the shared context is lost.
dispose()- Deletes buffers, programs, workers, subscriptions, and other renderer-owned resources.
Editful restores its WebGL state after each renderer call. The plugin still owns every GPU object it creates and must recreate those objects after contextRestored() when it implements context-loss handling.