DraftX Developer's Guide
Overview
DraftX is an embeddable drawing widget for web applications. The simplest way to think about it is this:
- your app creates the widget in a host element
- your app tells the widget what to do
- the widget emits events when its state changes
- 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:
selectrectellipselinetext
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:changedandselection:changedlet 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:
- create the widget
- register subscriptions
- keep the returned unsubscribe functions
- 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.