Share one dataset across canvas objects.
Keep a collaborative collection in the document and let every plugin surface that names it read the same records.
Manage the collection with browser DOM APIs and explicit listeners.
Choose fields, node records, or a collection
Use node fields for a fixed schema that describes one canvas object. Use node records for a changing set of items owned by one node. Use a record collection when several nodes should subscribe to one dataset without copying its records into each node.
- Node field
- One declared scalar such as a title, mode, or density.
- Node record
- One JSON item attached to a specific visible node.
- Record collection
- A document-scoped set addressed by collection id and owner kind.
Each record has a stable string id and one bounded JSON value. Editful synchronizes the records and their deletions; the plugin owns their schema and validation.
Declare a collection owner
A collection uses a plugin kind as its collaborative owner. Register that kind without a creation tool when the owner should remain an implementation detail.
import { Primitive } from '@editful/canvas-sdk';
const data = context.kind('example:cue-data');
data.pack((node, _services, out) => {
out.quad(
node.x,
node.y,
node.halfW,
node.halfH,
node.rotation,
0,
0,
Primitive.None,
0,
0,
);
});The first setCollectionRecord(...) transaction creates one deterministic owner for the pair example:cue-data and project-timeline. Every writer using that pair converges on the same collection.
Write one undoable record
Write records from commands, editors, importers, and agent actions through a document transaction.
const transaction = action.document.transaction('Add cue');
transaction.setCollectionRecord({
collectionId: 'project-timeline',
ownerKind: 'example:cue-data',
recordId: cue.id,
value: {
label: cue.label,
time: cue.time,
color: cue.color,
},
});
transaction.commit();Pass value: null with the same identity to delete the record. Both writes receive the transaction's label in undo history.
recordCollection(...) is read-only and does not create a missing owner. The collection appears after its first committed write.
const collection = action.document.recordCollection(
'project-timeline',
'example:cue-data',
);
const cues = [...(collection?.records ?? [])].map(([id, json]) => ({
id,
value: JSON.parse(json),
}));Values remain encoded as strings so callers cannot mutate the document through a shared object. Parse and validate each record before using it.
Read the collection from a renderer
A retained renderer resolves the same collection through host.recordCollection(...). collection.ownerId identifies its deterministic document owner, while collection.version lets the renderer skip work when no record changed.
const collection = this.host.recordCollection(
collectionId,
'example:cue-data',
);
if (collection !== null && collection.version !== previousVersion) {
this.replaceCues(collection.records);
previousVersion = collection.version;
this.host.requestFrame();
}Use host.setRecord(...) and host.setCollectionRecord(...) only for automatic renderer-owned state that should synchronize without becoming a user action. A title edit, feature move, import, or deletion belongs in a document transaction because it should advance the semantic document revision and enter undo history.
Add or remove a record from the editor. Both canvas objects redraw from the same collection; neither carries its own copy.
import {
Primitive,
definePlugin,
hexColor,
type PluginActionContext,
type PluginContext,
type PluginEditorInstance,
type PluginJson,
type PluginRendererHost,
type PluginRendererInstance,
type PluginSurfaceFrame,
type PluginSurfaceRenderTarget,
} from '@editful/canvas-sdk';
export const RECORD_PANEL_KIND = 'example:record-panel';
export const RECORD_OWNER_KIND = 'example:cue-data';
export const RECORD_COLLECTION = 'project-timeline';
class RecordPanelRenderer implements PluginRendererInstance {
constructor(private readonly host: PluginRendererHost) {}
render(frame: PluginSurfaceFrame, target: PluginSurfaceRenderTarget): boolean {
const collectionId = typeof frame.state.collectionId === 'string'
? frame.state.collectionId
: '';
const records: ReadonlyMap<string, string> =
this.host.recordCollection(collectionId, RECORD_OWNER_KIND)?.records
?? new Map<string, string>();
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.045, 0.05, 0.065, 1);
gl.clear(gl.COLOR_BUFFER_BIT);
gl.enable(gl.SCISSOR_TEST);
[...records].slice(0, 6).forEach(([, value], index) => {
const item = JSON.parse(value) as { readonly progress?: number };
const width = Math.max(8, Math.round((item.progress ?? 0) * (target.width - 40)));
gl.scissor(20, target.height - 34 - index * 26, width, 12);
gl.clearColor(0.95, 0.43 + index * 0.035, 0.32, 1);
gl.clear(gl.COLOR_BUFFER_BIT);
});
gl.disable(gl.SCISSOR_TEST);
return false;
}
dispose(): void {}
}
export default definePlugin({
register(context) {
registerSharedRecords(context);
context.editor({
id: 'example:records-editor', label: 'Shared records', surface: 'right-sidebar',
selection: { minimum: 1, maximum: 1, kinds: [RECORD_PANEL_KIND] },
mount: mountRecordsEditor,
});
},
});
export function registerSharedRecords(context: PluginContext): void {
context.kind(RECORD_OWNER_KIND).pack((node, _services, out) => {
out.quad(node.x, node.y, node.halfW, node.halfH, node.rotation,
0, 0, Primitive.None, 0, 0);
});
context.renderer({
id: 'example:record-panel-surface',
create: (host) => new RecordPanelRenderer(host),
});
const panel = context.kind(RECORD_PANEL_KIND);
const collectionId = panel.field.string('collection-id', { default: RECORD_COLLECTION });
panel.hit('rect');
panel.pack((node, _services, out) => {
out.quad(node.x, node.y, node.halfW, node.halfH, node.rotation,
10, 1, Primitive.RoundRect, hexColor('#111318'), hexColor('#363b46'));
out.surface('example:record-panel-surface',
{ collectionId: node.get(collectionId) },
node.x, node.y, node.halfW, node.halfH, node.rotation);
});
context.command({
id: 'example:seed-shared-records', label: 'Seed shared records',
run(action) {
writeSeedRecords(action);
return Promise.resolve();
},
});
}
function mountRecordsEditor(container: HTMLElement, initialAction: PluginActionContext): PluginEditorInstance {
let action = initialAction;
let sequence = 4;
const summary = document.createElement('output');
const add = document.createElement('button');
const remove = document.createElement('button');
add.textContent = 'Add shared record';
remove.textContent = 'Remove last';
container.replaceChildren(summary, add, remove);
const sync = () => {
const collection = action.document.recordCollection(RECORD_COLLECTION, RECORD_OWNER_KIND);
summary.value = `${collection?.records.size ?? 0} records · one collection`;
};
add.addEventListener('click', () => {
const id = `task-${sequence++}`;
const transaction = action.document.transaction('Add shared task');
transaction.setCollectionRecord({
collectionId: RECORD_COLLECTION, ownerKind: RECORD_OWNER_KIND, recordId: id,
value: { label: `Task ${id.slice(5)}`, progress: 0.25 + (sequence % 4) * 0.16 },
});
transaction.commit();
sync();
});
remove.addEventListener('click', () => {
const collection = action.document.recordCollection(RECORD_COLLECTION, RECORD_OWNER_KIND);
const recordId = [...(collection?.records.keys() ?? [])].at(-1);
if (recordId === undefined) return;
const transaction = action.document.transaction('Remove shared task');
transaction.setCollectionRecord({
collectionId: RECORD_COLLECTION, ownerKind: RECORD_OWNER_KIND, recordId, value: null,
});
transaction.commit();
sync();
});
sync();
return { update(next) { action = next; sync(); }, dispose() { container.replaceChildren(); } };
}
function writeSeedRecords(action: PluginActionContext): void {
const transaction = action.document.transaction('Add shared schedule');
const records: readonly [string, PluginJson][] = [
['task-1', { label: 'Outline', progress: 0.82 }],
['task-2', { label: 'Build', progress: 0.58 }],
['task-3', { label: 'Review', progress: 0.36 }],
];
for (const [recordId, value] of records) {
transaction.setCollectionRecord({
collectionId: RECORD_COLLECTION, ownerKind: RECORD_OWNER_KIND, recordId, value,
});
}
transaction.commit();
}Collection identity is document-scoped. Two nodes that use the same collection id and owner kind share records; different identities remain separate.