Editful
DocsSurface features

Make surface features feel native.

Let people point at, select, drag, create, and edit plugin-owned features without teaching Editful their domain format.

  • Hit queries
  • Live dragging
  • Undoable records

Mount the selected feature editor with browser DOM APIs.

Describe the feature under the pointer

A retained renderer already knows what it drew. Implement query(...) to translate a surface-local CSS pixel into the topmost feature at that point.

ts
query({ surfaceId, point }) {
  const cue = this.cuesFor(surfaceId).atPoint(point);
  if (cue === null) return null;

  return {
    cursor: 'grab',
    statusText: cue.label,
    features: [{ id: cue.id, label: cue.label }],
    interaction: {
      featureId: cue.id,
      draggable: true,
      record: {
        recordId: cue.id,
        value: cue,
      },
      editor: {
        editorId: 'example:cue-editor',
        state: { cueId: cue.id },
      },
    },
  };
}

Editful treats the feature id and returned JSON as opaque plugin data. The host uses the interaction description to show the cursor, preserve feature selection, begin a drag, and open the plugin's manual editor.

Return stable feature ids. A label can change; the identity used for selection and records should not.

Stream one drag into one history entry

Editful sends start, move, end, and cancel phases to featureDrag(...). Return the feature's current record on each accepted phase.

ts
featureDrag(surfaceId, phase, point) {
  const drag = this.updateCueDrag(surfaceId, phase, point);
  if (drag === null) return false;

  return {
    mutation: {
      recordId: drag.cue.id,
      value: drag.cue,
      historyLabel: 'Move cue',
    },
    editor: {
      editorId: 'example:cue-editor',
      state: { cueId: drag.cue.id },
    },
  };
}

The host applies move records as the pointer travels, so collaborators see the feature move during the gesture. It records the completed drag as one Move cue history entry. On cancel, return the original record from the plugin's drag state when the feature should snap back; the host applies that value without adding a history entry.

The renderer remains responsible for temporary visuals such as hover or selection. Implement clearFeatureSelection(surfaceId) to clear those visuals as soon as Editful selects another node or feature.

Drag any cue in the timeline. Click a cue without dragging to open its editor, then change the label. The feature remains a plugin-owned record throughout both interactions.

surface-features.ts
import {
  Primitive,
  definePlugin,
  hexColor,
  type PluginActionContext,
  type PluginContext,
  type PluginEditorInstance,
  type PluginJson,
  type PluginRendererHost,
  type PluginRendererInstance,
  type PluginSurfaceFeatureDragResult,
  type PluginSurfaceFrame,
  type PluginSurfacePoint,
  type PluginSurfaceQuery,
  type PluginSurfaceQueryResult,
  type PluginSurfaceRenderTarget,
} from '@editful/canvas-sdk';

export const SURFACE_FEATURE_KIND = 'example:timeline';

interface Cue {
  readonly id: string;
  readonly label: string;
  readonly time: number;
  readonly color: string;
}

interface SurfaceState {
  readonly nodeId: string;
  readonly cssWidth: number;
  readonly cues: readonly Cue[];
  selectedId: string | null;
}

class CueRenderer implements PluginRendererInstance {
  private readonly surfaces = new Map<string, SurfaceState>();
  private drag: { readonly surfaceId: string; readonly original: Cue } | null = null;

  constructor(private readonly host: PluginRendererHost) {}

  render(frame: PluginSurfaceFrame, target: PluginSurfaceRenderTarget): void {
    const cues = [...(this.host.records(frame.surfaceId) ?? [])]
      .map(([id, value]) => cueOf(id, value))
      .filter((cue): cue is Cue => cue !== null);
    const previous = this.surfaces.get(frame.surfaceId);
    this.surfaces.set(frame.surfaceId, {
      nodeId: frame.nodeId,
      cssWidth: frame.cssWidth,
      cues,
      selectedId: previous?.selectedId ?? null,
    });

    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.055, 0.06, 0.075, 1);
    gl.clear(gl.COLOR_BUFFER_BIT);
    gl.enable(gl.SCISSOR_TEST);
    for (const cue of cues) {
      const x = Math.round(cue.time * target.width);
      const selected = cue.id === previous?.selectedId;
      const [red, green, blue] = selected ? [0.95, 0.82, 0.32] : rgb(cue.color);
      gl.scissor(Math.max(0, x - 5), Math.round(target.height * 0.18), 10, Math.round(target.height * 0.64));
      gl.clearColor(red, green, blue, 1);
      gl.clear(gl.COLOR_BUFFER_BIT);
    }
    gl.disable(gl.SCISSOR_TEST);
  }

  query({ surfaceId, point }: PluginSurfaceQuery): PluginSurfaceQueryResult | null {
    const state = this.surfaces.get(surfaceId);
    if (state === undefined) return null;
    const cue = nearestCue(state, point);
    if (cue === null) return { cursor: 'default' as const, features: [] };
    state.selectedId = cue.id;
    this.host.requestFrame();
    return {
      cursor: 'grab' as const,
      statusText: cue.label,
      features: [{ id: cue.id, label: cue.label, time: cue.time }],
      interaction: {
        featureId: cue.id,
        draggable: true,
        record: { recordId: cue.id, value: cue as unknown as PluginJson },
        editor: {
          editorId: 'example:cue-editor',
          state: { nodeId: state.nodeId, cue: cue as unknown as PluginJson },
        },
      },
    };
  }

  featureDrag(
    surfaceId: string,
    phase: 'start' | 'move' | 'end' | 'cancel',
    point: PluginSurfacePoint,
  ): PluginSurfaceFeatureDragResult {
    const state = this.surfaces.get(surfaceId);
    if (state === undefined || state.selectedId === null) return false;
    const current = state.cues.find((cue) => cue.id === state.selectedId);
    if (current === undefined) return false;
    if (phase === 'start') this.drag = { surfaceId, original: current };
    const cue = phase === 'cancel' && this.drag?.surfaceId === surfaceId
      ? this.drag.original
      : { ...current, time: clamp(point.x / state.cssWidth) };
    if (phase === 'end' || phase === 'cancel') this.drag = null;
    return {
      mutation: {
        recordId: cue.id,
        value: cue as unknown as PluginJson,
        historyLabel: 'Move cue',
      },
      editor: {
        editorId: 'example:cue-editor',
        state: { nodeId: state.nodeId, cue: cue as unknown as PluginJson },
      },
    };
  }

  clearFeatureSelection(surfaceId: string): void {
    const state = this.surfaces.get(surfaceId);
    if (state !== undefined) state.selectedId = null;
    this.host.requestFrame();
  }

  surfaceDisposed(surfaceId: string): void { this.surfaces.delete(surfaceId); }
  dispose(): void { this.surfaces.clear(); }
}

export default definePlugin({
  register(context) {
    registerSurfaceFeatures(context);
    context.editor({
      id: 'example:cue-editor', label: 'Cue', surface: 'right-sidebar',
      activation: 'manual', selection: { maximum: 0 }, mount: mountCueEditor,
    });
  },
});

export function registerSurfaceFeatures(context: PluginContext): void {
    context.renderer({ id: 'example:cue-surface', create: (host) => new CueRenderer(host) });
    const timeline = context.kind(SURFACE_FEATURE_KIND);
    timeline.hit('rect');
    timeline.pack((node, _services, out) => {
      out.quad(node.x, node.y, node.halfW, node.halfH, node.rotation, 10,
        1, Primitive.RoundRect, hexColor('#111318'), hexColor('#3a3f4b'));
      out.surface('example:cue-surface', { recordsVersion: node.recordsVersion },
        node.x, node.y, node.halfW, node.halfH, node.rotation);
    });
}

function mountCueEditor(container: HTMLElement, initialAction: PluginActionContext): PluginEditorInstance {
  let action = initialAction;
  const label = document.createElement('label');
  const input = document.createElement('input');
  label.textContent = 'Cue label';
  label.append(input);
  container.replaceChildren(label);
  const state = () => action.editors.state('example:cue-editor') as {
    readonly nodeId?: string; readonly cue?: Cue;
  } | undefined;
  const sync = () => { input.value = state()?.cue?.label ?? ''; };
  input.addEventListener('change', () => {
    const current = state();
    if (current?.nodeId === undefined || current.cue === undefined) return;
    const transaction = action.document.transaction('Rename cue');
    transaction.setRecord({
      node: current.nodeId,
      recordId: current.cue.id,
      value: { ...current.cue, label: input.value },
    });
    transaction.commit();
  });
  sync();
  return { update(next) { action = next; sync(); }, dispose() { container.replaceChildren(); } };
}

function nearestCue(state: SurfaceState, point: PluginSurfacePoint): Cue | null {
  let nearest: Cue | null = null;
  let distance = 14;
  for (const cue of state.cues) {
    const next = Math.abs(point.x - cue.time * state.cssWidth);
    if (next < distance) { nearest = cue; distance = next; }
  }
  return nearest;
}

function cueOf(id: string, value: string): Cue | null {
  try {
    const cue = JSON.parse(value) as Partial<Cue>;
    return typeof cue.label === 'string' && typeof cue.time === 'number' && typeof cue.color === 'string'
      ? { id, label: cue.label, time: clamp(cue.time), color: cue.color }
      : null;
  } catch { return null; }
}

function clamp(value: number): number { return Math.max(0.04, Math.min(0.96, value)); }
function rgb(value: string): readonly [number, number, number] {
  const number = Number.parseInt(value.replace('#', ''), 16);
  return [((number >> 16) & 255) / 255, ((number >> 8) & 255) / 255, (number & 255) / 255];
}

Place features with a canvas tool

A plugin creation tool can place a record inside a retained surface instead of leaving a separate node on top of it. Implement place(...) and accept only the kind owned by that tool.

ts
place(surfaceId, _nodeId, kindId, point) {
  if (kindId !== 'example:cue') return false;
  const cue = this.cueAtPoint(surfaceId, point);

  return {
    mutation: {
      recordId: cue.id,
      value: cue,
      historyLabel: 'Add cue',
    },
    selection: {
      featureId: cue.id,
      draggable: true,
      record: { recordId: cue.id, value: cue },
      editor: {
        editorId: 'example:cue-editor',
        state: { cueId: cue.id },
      },
    },
  };
}

Editful owns the tool gesture, document write, undo entry, and resulting selection. The renderer owns the conversion from surface pixels to a valid domain record.

Keep responsibilities on the right side

Plugin renderer
Finds features, converts coordinates, returns records, and draws hover or selection state.
Editful host
Captures pointers, coordinates selection, writes records, broadcasts movement, and owns undo history.
Manual editor
Receives the opaque state declared by the selected feature and edits through a document transaction.
Document
Stores the durable JSON record without learning what the record means.

This boundary keeps the interaction native to the canvas while the feature model stays entirely inside the plugin.