Turn a node into the board background.
Let a starfield fill the canvas without resizing, moving, or replacing the node that defines it.
Register the background surface
A board background starts with a retained surface and one source node. The source keeps the plugin's durable fields and records. The background contribution tells Editful how to render that same content across the visible canvas.
const background = context.backgroundSurface<
'example:starfield',
{ sourceNodeId: string }
>({
id: 'example:starfield-background',
label: 'Starfield',
sourceKind: 'example:starfield',
stateSchema: {
type: 'object',
properties: { sourceNodeId: { type: 'string' } },
required: ['sourceNodeId'],
additionalProperties: false,
},
sourceNodeId: (state) => state.sourceNodeId,
pack(source, _state, _services, output) {
output.surface(
'example:starfield-renderer',
{ density: source.get(density) },
source.x, source.y, source.halfW, source.halfH, source.rotation,
);
},
});The contribution uses the same renderer instance as an ordinary retained surface. In background presentation, Editful gives that renderer a viewport-sized target and paints it beneath ordinary canvas content. Camera movement changes the surface viewport instead of stretching the source node.
backgroundSurface(...) requires the gpu-renderer capability. The lease and its state use existing document records, so there is no separate board-state capability.
Claim the board background
Claim the background in the same document transaction as any related node or record changes. The state identifies an existing node of the declared sourceKind.
context.command({
id: 'example:expand-starfield',
label: 'Expand to background',
contextMenu: { target: 'selection' },
selection: { minimum: 1, maximum: 1, kinds: ['example:starfield'] },
background: { handle: background, status: 'vacant' },
async run(action) {
const [sourceNodeId] = action.selection.nodeIds();
if (sourceNodeId === undefined) return;
action.document.transaction('Expand starfield to background')
.claimBackground(background, { sourceNodeId })
.commit();
},
});Right-click the selected Starfield and choose Expand to background. Pan or zoom the canvas after it expands. The two cards remain ordinary canvas objects above the field.
The example keeps the stars steady while you zoom and adds a small parallax shift while you pan. That motion belongs to this renderer—not to the background API—so a map can remain geographically anchored while another plugin chooses a more atmospheric response to camera movement.
import {
Primitive,
definePlugin,
type PluginActionContext,
type PluginBackgroundHandle,
type PluginContext,
type PluginEditorInstance,
type PluginJson,
type PluginRendererHost,
type PluginRendererInstance,
type PluginSurfaceFrame,
type PluginSurfaceRenderTarget,
} from '@editful/canvas-sdk';
export const STARFIELD_KIND = 'example:starfield';
const STARFIELD_PARALLAX = 0.18;
const ZOOM_SETTLE_MS = 300;
interface StarfieldBackgroundState extends Record<string, PluginJson> {
readonly sourceNodeId: string;
}
interface StarfieldViewState {
readonly centerX: number;
readonly centerY: number;
readonly cssWidth: number;
readonly cssHeight: number;
readonly zoom: number;
readonly lastZoomTime: number;
readonly originX: number;
readonly originY: number;
}
class StarfieldRenderer implements PluginRendererInstance {
private readonly views = new Map<string, StarfieldViewState>();
constructor(private readonly host: PluginRendererHost) {}
render(frame: PluginSurfaceFrame, target: PluginSurfaceRenderTarget): false {
const gl = this.host.gl;
const density = Number(frame.state.density ?? 0.58);
const scale = target.devicePixelRatio;
const cellSize = 54 * scale;
const view = nextStarfieldView(frame, this.views.get(frame.surfaceId));
this.views.set(frame.surfaceId, view);
const originX = view.originX * scale;
const originY = view.originY * scale;
gl.bindFramebuffer(gl.FRAMEBUFFER, target.framebuffer);
gl.viewport(0, 0, target.width, target.height);
gl.enable(gl.SCISSOR_TEST);
gl.scissor(0, 0, target.width, target.height);
gl.clearColor(0.025, 0.035, 0.075, 1);
gl.clear(gl.COLOR_BUFFER_BIT);
const firstColumn = Math.floor(originX / cellSize) - 1;
const lastColumn = Math.ceil((originX + target.width) / cellSize) + 1;
const firstRow = Math.floor(originY / cellSize) - 1;
const lastRow = Math.ceil((originY + target.height) / cellSize) + 1;
for (let column = firstColumn; column <= lastColumn; column++) {
for (let row = firstRow; row <= lastRow; row++) {
if (noise(column, row, 3) > density) continue;
const size = Math.max(1, Math.round((1 + noise(column, row, 7) * 1.5) * scale));
const x = Math.round(column * cellSize + noise(column, row, 11) * cellSize - originX);
const y = Math.round(row * cellSize + noise(column, row, 17) * cellSize - originY);
const light = 0.62 + noise(column, row, 23) * 0.34;
gl.scissor(x, target.height - y - size, size, size);
gl.clearColor(light * 0.78, light * 0.86, light, 1);
gl.clear(gl.COLOR_BUFFER_BIT);
}
}
gl.disable(gl.SCISSOR_TEST);
return false;
}
query() {
return { cursor: 'default' as const, statusText: 'Starfield background' };
}
surfaceDisposed(surfaceId: string): void {
this.views.delete(surfaceId);
}
dispose(): void {
this.views.clear();
}
}
export function nextStarfieldView(
frame: Pick<
PluginSurfaceFrame,
| 'viewportCenter'
| 'renderScale'
| 'canvasZoom'
| 'cssWidth'
| 'cssHeight'
| 'time'
>,
previous?: StarfieldViewState,
): StarfieldViewState {
const centerX = frame.viewportCenter.x / frame.renderScale;
const centerY = frame.viewportCenter.y / frame.renderScale;
const stableViewport = previous !== undefined &&
Math.abs(previous.cssWidth - frame.cssWidth) < 1 &&
Math.abs(previous.cssHeight - frame.cssHeight) < 1;
const zoomChanged = previous === undefined ||
Math.abs(previous.zoom - frame.canvasZoom) > Number.EPSILON;
const lastZoomTime = zoomChanged
? frame.time
: (previous?.lastZoomTime ?? Number.NEGATIVE_INFINITY);
const zoomSettling = frame.time - lastZoomTime < ZOOM_SETTLE_MS;
const panScale = frame.canvasZoom * STARFIELD_PARALLAX;
return Object.freeze({
centerX,
centerY,
cssWidth: frame.cssWidth,
cssHeight: frame.cssHeight,
zoom: frame.canvasZoom,
lastZoomTime,
originX: stableViewport && !zoomSettling
? previous.originX + (centerX - previous.centerX) * panScale
: (stableViewport ? previous.originX : 0),
originY: stableViewport && !zoomSettling
? previous.originY + (centerY - previous.centerY) * panScale
: (stableViewport ? previous.originY : 0),
});
}
export default definePlugin({
register(context) {
context.renderer({
id: 'example:starfield-renderer',
create: (host) => new StarfieldRenderer(host),
});
const starfield = context.kind(STARFIELD_KIND);
const density = starfield.field.f64('density', { default: 0.58 });
starfield.create({
label: 'Starfield',
shortcut: 's',
cursor: 'crosshair',
gesture: 'drag',
order: 40,
defaultSize: { width: 320, height: 190 },
styleSlot: 'shape',
});
starfield.hit('rect');
starfield.pack((node, _services, output) => {
output.quad(
node.x, node.y, node.halfW, node.halfH, node.rotation,
10, 0, Primitive.RoundRect, node.fill, 0,
);
if (node.halfW > 0 && node.halfH > 0) {
output.surface(
'example:starfield-renderer',
{ density: node.get(density) },
node.x, node.y, node.halfW, node.halfH, node.rotation,
);
}
});
const background = context.backgroundSurface<
typeof STARFIELD_KIND,
StarfieldBackgroundState
>({
id: 'example:starfield-background',
label: 'Starfield',
sourceKind: STARFIELD_KIND,
stateSchema: {
type: 'object',
properties: { sourceNodeId: { type: 'string' } },
required: ['sourceNodeId'],
additionalProperties: false,
},
sourceNodeId: (state) => state.sourceNodeId,
pack(node, _state, _services, output) {
output.surface(
'example:starfield-renderer',
{ density: node.get(density) },
node.x, node.y, node.halfW, node.halfH, node.rotation,
);
},
});
registerBackgroundActions(context, background);
context.editor({
id: 'example:starfield-settings',
label: 'Starfield',
surface: 'right-sidebar',
activation: 'manual',
background,
mount: (container, action) => mountSettings(container, action, background),
});
},
});
function registerBackgroundActions(
context: PluginContext,
background: PluginBackgroundHandle<StarfieldBackgroundState>,
): void {
context.command({
id: 'example:expand-starfield',
label: 'Expand to background',
contextMenu: { target: 'selection' },
selection: { minimum: 1, maximum: 1, kinds: [STARFIELD_KIND] },
background: { handle: background, status: 'vacant' },
run(action) {
const [sourceNodeId] = action.selection.nodeIds();
if (sourceNodeId === undefined) return Promise.resolve();
action.document.transaction('Expand starfield to background')
.claimBackground(background, { sourceNodeId })
.commit();
return Promise.resolve();
},
});
context.command({
id: 'example:starfield-settings-command',
label: 'Starfield settings',
contextMenu: { target: 'background', background },
background: { handle: background, status: 'owned' },
run(action) {
action.editors.open('example:starfield-settings');
return Promise.resolve();
},
});
context.command({
id: 'example:collapse-starfield',
label: 'Collapse to node',
contextMenu: { target: 'background', background },
background: { handle: background, status: 'owned' },
run(action) {
action.document.transaction('Collapse starfield to node')
.releaseBackground(background)
.commit();
return Promise.resolve();
},
});
}
function mountSettings(
container: HTMLElement,
initialAction: PluginActionContext,
background: PluginBackgroundHandle<StarfieldBackgroundState>,
): PluginEditorInstance {
let action = initialAction;
const label = document.createElement('label');
const input = document.createElement('input');
const value = document.createElement('output');
label.textContent = 'Star density';
input.type = 'range';
input.min = '0.15';
input.max = '1';
input.step = '0.05';
label.append(input, value);
container.replaceChildren(label);
const source = () => {
const status = action.document.background(background);
if (status.status !== 'owned') return undefined;
return action.document.inspect([status.state.sourceNodeId])[0];
};
const sync = () => {
const current = source()?.field<number>('density') ?? 0.58;
input.value = String(current);
value.value = `${Math.round(current * 100)}%`;
};
input.addEventListener('input', () => {
const node = source();
if (node === undefined) return;
const next = Number(input.value);
const transaction = action.document.transaction('Change star density');
transaction.update({ id: node.id, fields: { density: next } });
transaction.commit();
value.value = `${Math.round(next * 100)}%`;
});
sync();
return {
update(nextAction) { action = nextAction; sync(); },
dispose() { container.replaceChildren(); },
};
}
function noise(x: number, y: number, salt: number): number {
let value = Math.imul(x, 374_761_393) ^ Math.imul(y, 668_265_263) ^ salt;
value = Math.imul(value ^ (value >>> 13), 1_274_126_177);
return ((value ^ (value >>> 16)) >>> 0) / 4_294_967_295;
}Editful removes the source from ordinary drawing, selection, hit testing, marquee, and zoom-to-fit while the lease is active. It does not change the node's geometry, fields, records, bindings, or paint order.
Keep settings on the source node
A background editor declares the same handle. Editful mounts it only while this plugin owns the background.
context.editor({
id: 'example:starfield-settings',
label: 'Starfield',
surface: 'right-sidebar',
activation: 'manual',
background,
mount(container, action) {
const status = action.document.background(background);
if (status.status !== 'owned') return;
const [source] = action.document.inspect([status.state.sourceNodeId]);
// Mount controls that update source fields in a document transaction.
},
});Right-click the expanded field and choose Starfield settings, then change Star density. The editor reads the source id from the owned lease and writes to the unchanged source node. A map plugin can use the same pattern for its base layer, longitude, latitude, and zoom.
Return a non-null result from the renderer's query(...) method wherever background context commands should be available. Editful checks ordinary nodes first, then routes an unclaimed pointer to the background surface.
Respect the exclusive lease
One contribution owns the board background at a time. Plugins do not coordinate with one another directly. They declare the state they require, and Editful keeps unavailable commands disabled with an owner-aware reason.
vacant- No contribution owns the board background. A claim command may run.
owned- This exact plugin contribution owns the background and can read its typed state.
occupied- Another contribution owns the background. Only safe owner metadata is exposed.
background: { handle, status }- Declares the lease state a command requires before Editful runs it.
claimBackground(...) validates the handle, bounded state, source id, and source kind again when the transaction commits. A competing claim therefore fails as a conflict instead of replacing the current background without either plugin knowing.
Collapse to the original node
Release the lease without reconstructing the source. The original node becomes drawable and selectable again at its existing position and size.
context.command({
id: 'example:collapse-starfield',
label: 'Collapse to node',
contextMenu: { target: 'background', background },
background: { handle: background, status: 'owned' },
async run(action) {
action.document.transaction('Collapse starfield to node')
.releaseBackground(background)
.commit();
},
});The claim and release are ordinary collaborative document changes with undo labels. Deleting the active source releases its lease in the same transaction; undo restores both.