Editful
DocsNode bindings

Keep canvas objects connected.

Attach connector endpoints to cards and let Editful recompute the connector whenever either card moves.

  • Role-keyed relations
  • Derived geometry
  • Collaboration-safe

Mount the guide with browser DOM APIs; the binding stays host-managed.

Register the relation

A binding connects one child to a parent under a named role. One connector can therefore bind its source and target roles to different cards.

Register the binding type once during plugin setup. Its resolver receives read-only geometry for the child and its current parents, plus the JSON payload stored for each role.

ts
context.binding('example:connect', {
  roles: ['source', 'target'],
  onParentRemoved: 'unbind',
  resolve(child, parents, payloads) {
    const sourceParent = parents.get('source');
    const targetParent = parents.get('target');
    if (sourceParent === undefined || targetParent === undefined) return null;

    const source = anchorPoint(sourceParent, payloads.get('source'));
    const target = anchorPoint(targetParent, payloads.get('target'));
    const dx = target.x - source.x;
    const dy = target.y - source.y;

    return {
      x: (source.x + target.x) / 2,
      y: (source.y + target.y) / 2,
      width: Math.hypot(dx, dy),
      rotation: Math.atan2(dy, dx),
    };
  },
});

anchorPoint(...) is plugin code. It can use the parent's bounds and a payload such as { side: 'east' } to choose the attachment point. Editful does not interpret that payload.

Connect both endpoints

Create or update the relationship in a document transaction. Each child role holds at most one binding, so binding the same role again replaces its previous parent and payload.

ts
const transaction = action.document.transaction('Connect cards');
transaction
  .bind({
    child: connectorId,
    role: 'source',
    type: 'example:connect',
    parent: firstCardId,
    payload: { side: 'east' },
  })
  .bind({
    child: connectorId,
    role: 'target',
    type: 'example:connect',
    parent: secondCardId,
    payload: { side: 'west' },
  })
  .commit();

Move or resize either card after this commit. Editful runs the resolver and applies the returned connector geometry in the same document model used by collaboration, rendering, and undo. The plugin does not subscribe to pointer movement or publish its own follow-up transaction.

Drag the selected Draft card, then select and drag Review. The connector resolves both endpoints from their role-keyed bindings.

node-bindings.ts
import {
  Primitive,
  TextFont,
  TextVerticalAlign,
  definePlugin,
  hexColor,
  type PluginBindingNodeView,
  type PluginContext,
  type PluginJson,
} from '@editful/canvas-sdk';

export const BINDING_CARD_KIND = 'example:binding-card';
export const CONNECTOR_KIND = 'example:connector';
export const CONNECTOR_BINDING = 'example:connect';

export function registerFlowKinds(context: PluginContext): void {
  const card = context.kind(BINDING_CARD_KIND);
  const title = card.field.string('title', { default: 'Card' });
  card.hit('rect');
  card.pack((node, services, out) => {
    out.quad(node.x, node.y, node.halfW, node.halfH, node.rotation,
      10, 2, Primitive.RoundRect, node.fill, node.stroke);
    out.textBlock({
      x: node.x, y: node.y, halfW: node.halfW, halfH: node.halfH,
      text: node.get(title), style: { ...services.text.sharedTextStyle, size: 16,
        color: hexColor('#171815'), lineHeight: 1.2 }, font: TextFont.Sans,
      wrapWidth: node.halfW * 2 - 24, padding: 12,
      verticalAlign: TextVerticalAlign.Middle,
    });
  });
  const connector = context.kind(CONNECTOR_KIND);
  connector.pack((node, _services, out) => {
    out.quad(node.x, node.y, node.halfW, node.halfH, node.rotation,
      0, 3, Primitive.Line, 0, hexColor('#f1d45b'));
  });
  context.binding(CONNECTOR_BINDING, {
    roles: ['source', 'target'], onParentRemoved: 'unbind',
    resolve(child, parents, payloads) {
      const source = endpoint(child, parents.get('source'), payloads.get('source'), -1);
      const target = endpoint(child, parents.get('target'), payloads.get('target'), 1);
      const dx = target.x - source.x;
      const dy = target.y - source.y;
      return { x: (source.x + target.x) / 2, y: (source.y + target.y) / 2,
        width: Math.hypot(dx, dy), rotation: Math.atan2(dy, dx) };
    },
  });
}

export default definePlugin({
  register(context) {
    registerFlowKinds(context);
    context.editor({
      id: 'example:binding-guide', label: 'Connected objects', surface: 'left-sidebar',
      mount(container) {
        const title = document.createElement('strong');
        const tip = document.createElement('p');
        title.textContent = 'Connector bound at both ends';
        tip.textContent = 'Drag either card. The connector follows without a plugin pointer listener.';
        container.replaceChildren(title, tip);
        return () => container.replaceChildren();
      },
    });
  },
});

function endpoint(
  child: PluginBindingNodeView,
  parent: PluginBindingNodeView | undefined,
  payload: PluginJson | undefined,
  direction: -1 | 1,
): { readonly x: number; readonly y: number } {
  if (parent === undefined) {
    const distance = child.width / 2 * direction;
    return { x: child.x + Math.cos(child.rotation) * distance,
      y: child.y + Math.sin(child.rotation) * distance };
  }
  const side = typeof payload === 'object' && payload !== null && !Array.isArray(payload)
    ? (payload as Record<string, PluginJson>).side : undefined;
  const localX = side === 'west' ? -parent.width / 2 : parent.width / 2;
  return { x: parent.x + Math.cos(parent.rotation) * localX,
    y: parent.y + Math.sin(parent.rotation) * localX };
}

Choose deletion and direct-edit behavior

onParentRemoved: 'unbind'
Keeps the child and removes the role whose parent disappeared.
onParentRemoved: 'cascade'
Deletes the child when one of its bound parents disappears.
resolve(...)
Returns derived geometry or fields whenever a parent or payload changes.
invert(...)
Optionally converts a direct child edit back into new role payloads.
transaction.unbind(...)
Disconnects one child role explicitly.

Implement invert(...) when people can directly drag the bound child and that gesture should update its attachments. Return a record keyed by role, such as { source: nextSourcePayload }. Return null when the edit cannot be represented by the binding.

A resolver must be deterministic. Derive its answer only from the supplied child, parents, and payloads so every collaborator reaches the same geometry.

Inspect the current bindings

action.document.inspect(...) includes each node's bindings as immutable snapshots. Use them to explain a relationship in an editor, validate an action, or choose which role to replace.

ts
const [connector] = action.document.inspect([connectorId]);
const target = connector?.bindings.find(
  (binding) => binding.role === 'target',
);

The snapshot contains the role, registered type, stable parent id, and parsed payload. Mutations still go through bind(...) or unbind(...) in a transaction.