Documentation

DraftX Developer's Guide

Task-oriented guide for embedding and using DraftX in real host applications.

DraftX Developer's Guide

Overview

DraftX is an embeddable drawing widget for web applications. The simplest way to think about it is this:

  1. your app creates the widget in a host element
  2. your app tells the widget what to do
  3. the widget emits events when its state changes
  4. your app uses those events to keep the rest of the interface in sync

If you want to try the same patterns interactively while you read, open the DraftX Playpen.

A Good First Integration

The most reliable integration shape is one long-lived widget instance inside a host screen, panel, or page section.

<div id="draftx-host"></div>
import DraftX from 'draftx';

const widget = new DraftX({
  container: '#draftx-host',
  showGrid: true,
  snapToGrid: true,
  theme: 'light',
});

What happens next:

  • the widget mounts into #draftx-host
  • the canvas becomes interactive immediately
  • grid and snap start in the states you configured
  • the host app can begin wiring controls and subscriptions around it

That gives you a mounted drawing surface you can control from the rest of your UI.

You can pass either:

  • a selector string
  • an HTMLElement

Example with an element:

const host = document.getElementById('draftx-host');
if (!(host instanceof HTMLElement)) {
  throw new Error('Missing #draftx-host');
}

const widget = new DraftX({
  container: host,
  showGrid: true,
  snapToGrid: true,
  theme: 'light',
});

Start With A Document

DraftX works with a simple JSON document model. In practice, most apps either:

  • load a document from storage or a backend
  • start with a small starter drawing
  • create a new blank drawing and let the user build from there

Example document:

const documentData = {
  version: '1.0',
  shapes: [
    {
      id: 'room-1',
      type: 'rect',
      x: 120,
      y: 80,
      width: 280,
      height: 180,
      fill: '#d7fff2',
      stroke: '#2a8f78',
      strokeWidth: 2,
    },
    {
      id: 'label-1',
      type: 'text',
      x: 160,
      y: 140,
      text: 'Conference',
      fontSize: 18,
      fill: '#102019',
    },
  ],
};

Load it and frame it:

widget.setDocument(documentData);
widget.fitToView();

What happens next:

  • the document replaces anything currently in the widget
  • the shapes become visible in the canvas
  • fitToView() reframes the drawing so the user starts from a useful view

If you want the widget to start empty instead:

widget.newDrawing();
widget.setTool('rect');

What happens next:

  • the current drawing is cleared back to an empty document
  • the widget switches into rectangle drawing mode
  • the next pointer interaction in the canvas creates rectangles instead of selecting shapes

That is usually a better first-run experience than forcing a document import flow before the user can do anything.

Save, Reload, And Round-Trip Documents

DraftX gives you two document lanes:

  • object-based document access
  • JSON text document access

Read the current document object:

const documentData = widget.getDocument();

Save the current document as JSON text:

const documentJson = widget.saveDocument();

Load JSON text back into the widget:

widget.loadDocument(documentJson);
widget.fitToView();

This is the right place to keep your app in charge of browser file I/O, uploads, downloads, storage, and network calls. The widget should own drawing behavior, not file pickers.

const file = await pickFile();
const text = await file.text();

widget.loadDocument(text);
widget.fitToView();

const saved = widget.saveDocument();
await saveTextFile(saved);

What happens next:

  • your app stays in control of file handling
  • the widget only deals with document state, not browser dialogs
  • the saved JSON can be reloaded later with loadDocument(...)

Drive The Widget From Host Controls

Most real integrations keep buttons, menus, cards, side panels, and status UI outside the widget itself. The host owns those controls and calls widget methods in response.

Example:

rectButton.addEventListener('click', () => {
  widget.setTool('rect');
});

textButton.addEventListener('click', () => {
  widget.setTool('text');
});

zoomInButton.addEventListener('click', () => {
  widget.zoomIn();
});

fitButton.addEventListener('click', () => {
  widget.fitToView();
});

What happens next:

  • your host controls act like a command surface for the widget
  • the widget updates the canvas state
  • subscriptions can then update the rest of your UI to match

That pattern scales well because it keeps responsibility clear:

  • the host owns controls and layout
  • the widget owns drawing behavior

Use The Event Channel To Wire The Rest Of Your UI

DraftX is not just a bag of methods. It is also an event-driven widget.

Treat widget.on(...) as the public event channel for the widget. Your app sends commands into the widget by calling methods, and the widget sends state-change messages back by emitting events.

That event flow is what lets you wire a full UI around the canvas without constantly polling it.

In practice:

  • host controls call widget methods
  • widget events notify the host about state changes
  • callbacks refresh the rest of your interface

Example subscriptions:

const stopDrawingEvents = widget.on('drawing:changed', () => {
  renderJson(widget.getDocument());
});

const stopToolEvents = widget.on('tool:changed', (payload) => {
  syncToolbar(payload.tool);
});

const stopSelectionEvents = widget.on('selection:changed', (payload) => {
  syncInspector(payload.primaryId, payload.ids);
});

What happens next:

  • your app no longer needs to guess when state changed
  • the widget tells the host exactly when tool, selection, drawing, and view state move
  • side panels, cards, readouts, and save state can all react from one consistent pattern

This is the pattern you should reach for first when your app has:

  • a JSON panel
  • a toolbar
  • a property inspector
  • save state
  • zoom readouts
  • selection-dependent controls

Wire Specific UI Areas To Specific Events

The cleanest integrations map each host concern to the event that actually owns it.

Toolbar And Mode Buttons

Use tool:changed to keep tool buttons, pills, or command cards honest.

const stopToolEvents = widget.on('tool:changed', (payload) => {
  selectButton.setAttribute('aria-pressed', String(payload.tool === 'select'));
  rectButton.setAttribute('aria-pressed', String(payload.tool === 'rect'));
  ellipseButton.setAttribute('aria-pressed', String(payload.tool === 'ellipse'));
  lineButton.setAttribute('aria-pressed', String(payload.tool === 'line'));
  textButton.setAttribute('aria-pressed', String(payload.tool === 'text'));
});

JSON Panels, Persistence, And Save State

Use drawing:changed when the host needs the latest document.

const stopDrawingEvents = widget.on('drawing:changed', () => {
  const latestDocument = widget.getDocument();
  jsonPanel.textContent = JSON.stringify(latestDocument, null, 2);
  localStorage.setItem('draftx-document', widget.saveDocument());
  saveButton.disabled = false;
});

Inspectors And Selection-Aware UI

Use selection:changed when your host shows properties or enables commands based on the current selection.

const stopSelectionEvents = widget.on('selection:changed', (payload) => {
  if (payload.primaryId === null) {
    hideInspector();
    deleteButton.disabled = true;
    return;
  }

  const shape = widget.getShapeById(payload.primaryId);
  renderInspector(shape);
  deleteButton.disabled = false;
});

View Readouts And Camera Status

Use view:changed when the host needs to reflect zoom or camera state.

const stopViewEvents = widget.on('view:changed', (payload) => {
  zoomReadout.textContent = `${Math.round(payload.zoom * 100)}%`;
});

Grid And Snap Toggles

Use grid:changed and snap:changed when your host wants stateful controls rather than one-way buttons.

const stopGridEvents = widget.on('grid:changed', (payload) => {
  gridToggle.setAttribute('aria-pressed', String(payload.visible));
});

const stopSnapEvents = widget.on('snap:changed', (payload) => {
  snapToggle.setAttribute('aria-pressed', String(payload.snapToGrid));
});

What happens next:

  • the surrounding UI stays honest even when the widget state changes from more than one place
  • controls stop drifting out of sync with the actual drawing surface

Work With Tools, Shapes, And Selection

The base DraftX tool set is:

  • select
  • rect
  • ellipse
  • line
  • text

Switch tools directly:

widget.setTool('select');
widget.setTool('rect');
widget.setTool('text');

Read the current tool:

const tool = widget.getTool();

Read shapes:

const shapes = widget.getShapes();
const room = widget.getShapeById('room-1');

Select shapes:

widget.selectShape('room-1');
widget.selectShapes(['room-1', 'label-1']);

What happens next:

  • the selected shape or shapes become the active editing target
  • selection events fire so the host can update inspectors and command availability

Transform the current selection:

widget.moveSelection(40, 24);

widget.resizeSelection({
  x1: 120,
  y1: 80,
  x2: 420,
  y2: 300,
});

widget.rotateSelection(Math.PI / 12);

Delete the current selection:

widget.deleteSelection();

What happens next:

  • the selected shapes are removed from the document
  • drawing:changed and selection:changed let the host react immediately

Edit Shape Properties

Property editing is selection-driven. Select a shape first, then read or change its properties.

Read the current selected shape properties:

const props = widget.getShapeProperties();

Update the selected shape:

widget.setShapeProperties({
  fill: '#d7fff2',
  stroke: '#2a8f78',
  strokeWidth: 2,
});

Examples:

widget.setShapeProperties({ rotation: Math.PI / 6 });
widget.setShapeProperties({ text: 'Updated label' });
widget.setShapeProperties({ fontSize: 22 });

What happens next:

  • the selected shape updates in place on the canvas
  • the document changes without replacing the whole drawing
  • host panels that listen to drawing or selection events can refresh automatically

Copy, Paste, Undo, And Redo

These are the operations that make the widget feel like a real working drawing surface rather than a one-shot renderer.

Copy and paste are widget-local and in memory.

Copy the current selection:

widget.copySelection();

Paste with the default offset:

widget.pasteSelection();

Paste at a specific world point:

widget.pasteSelection({ x: 420, y: 240 });

Undo and redo:

if (widget.canUndo()) {
  widget.undo();
}

if (widget.canRedo()) {
  widget.redo();
}

What happens next:

  • users can treat the widget like a working editor instead of a one-shot renderer
  • your host app can enable or disable undo and redo based on current availability

In most host apps, these commands should be enabled or disabled from current state, not left as blind buttons.

Control The View And Appearance

View and appearance methods are what make the widget feel integrated with the rest of the page.

View controls:

widget.zoomIn();
widget.zoomOut();
widget.resetView();
widget.fitToView();

Read the current view:

const view = widget.getView();
console.log(view.center.x, view.center.y, view.zoom);

Focus a specific shape:

const focused = widget.focusShapeInView('room-1');

What happens next:

  • if the shape exists, the camera moves so that shape becomes the visual target
  • this is useful when a list, card, or search result needs to bring attention back to the canvas

Appearance controls:

widget.setTheme('light');
widget.setTheme('dark');

widget.setGridVisible(true);
widget.setGridVisible(false);

widget.setSnapToGrid(true);
widget.setSnapToGrid(false);

widget.setHighlightColor('#0f6d5f');

Read back appearance state:

const theme = widget.getTheme();
const gridVisible = widget.getGridVisible();
const snapEnabled = widget.getSnapToGrid();

What happens next:

  • the host can treat the widget as a state source, not just a command target
  • that makes it easier to build toggles, readouts, and persisted preferences around the canvas

Manage Subscription Lifetime

The cleanest subscription pattern is:

  1. create the widget
  2. register subscriptions
  3. keep the returned unsubscribe functions
  4. release them during teardown

Example:

const cleanup: Array<() => void> = [];

cleanup.push(
  widget.on('drawing:changed', () => {
    renderJson(widget.getDocument());
  }),
);

cleanup.push(
  widget.on('selection:changed', (payload) => {
    syncInspector(payload.primaryId, payload.ids);
  }),
);

cleanup.push(
  widget.on('tool:changed', (payload) => {
    syncToolbar(payload.tool);
  }),
);

Then clean them up with the host view:

for (const stop of cleanup) {
  stop();
}

widget.destroy();

What happens next:

  • the widget releases its DOM/runtime resources
  • subscriptions created through widget.on(...) are also released
  • your host view can unmount cleanly without leaving widget behavior behind

widget.destroy() also releases subscriptions created through widget.on(...), but keeping your own cleanup list still gives your host code a clearer lifecycle.

Close The Loop Cleanly

When the host screen, tab, route, or panel goes away, destroy the widget.

window.addEventListener(
  'pagehide',
  () => {
    widget.destroy();
  },
  { once: true },
);

That is the final step in a healthy integration:

  • mount once
  • drive from host controls
  • sync the rest of the UI from events
  • save what you need
  • clean up on exit

Next

Use the API manual when you want the exact method list, event payloads, and document schema.