Editful
DocsShared records

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.

  • Document scope
  • Stable record ids
  • Collaborative JSON

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.

ts
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.

ts
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.

ts
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.

ts
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.

shared-records.ts
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.