Fabric.js + DSK
Use Fabric's interactive canvas as the rendering engine and DSK as its external inspector. Fabric manages object selection, dragging, scaling and rotation. The same DSK inputs, rulers and comment used by the other examples connect to Fabric's public API.
The editor loads with the page. Drag the rectangle or ellipse, use the handles to resize/rotate, or change X, Y and Rotation in the DSK panel. Click empty space to clear selection and drag it to pan. Click the canvas before wheel zoom; Escape releases focus so the page can scroll normally. Fit shows both shapes; Reset restores their initial transforms.
The comment follows the rectangle even when another object is selected. Pull guides from the rulers and press Delete on a focused guide to remove it. These are DSK overlays, not Fabric objects or snapping constraints. Changes are kept in memory until you leave the page.
1. Install and reserve the host
This example uses Fabric 7.4.0 with Vite and TypeScript. Fabric and DSK both work without a runtime framework.
npm i @flowscape-ui/dsk fabric@7.4.0<!-- index.html -->
<div id="example"></div>
<script type="module" src="/src/main.ts"></script>src/
main.ts
fabric/
mount-example.ts
shared/
example-shell.ts
example-shell.css2. Create the DSK inspector and overlay
The shared shell keeps native controls outside Fabric's gesture surface. Its rulers target the inner viewport, and its comment receives world coordinates plus the same camera as the canvas.
import '@flowscape-ui/dsk';
import type { CanvasCamera, DskInput } from '@flowscape-ui/dsk';
import './example-shell.css';
export type TransformField = 'x' | 'y' | 'rotation';
export type ExampleAction = 'select' | 'fit' | 'reset';
export interface ExampleSelection {
x: number;
y: number;
rotation: number;
name: string;
}
/** The native DSK inspector and overlay stay separate from the rendering engine. */
export function createExampleShell(root: HTMLElement, ru = false) {
const t = (en: string, russian: string) => (ru ? russian : en);
const listeners = new AbortController();
root.classList.add('dsk-engine-example');
root.innerHTML = `
<div class="dsk-engine-toolbar">
<button type="button" data-action="select">${t('Select rectangle', 'Выбрать прямоугольник')}</button>
<button type="button" data-action="fit">${t('Fit', 'Уместить')}</button>
<button type="button" data-action="reset">${t('Reset', 'Сбросить')}</button>
</div>
<div class="dsk-engine-inspector">
<label>X <dsk-input data-field="x" step="1" disabled aria-label="X"></dsk-input></label>
<label>Y <dsk-input data-field="y" step="1" disabled aria-label="Y"></dsk-input></label>
<label>${t('Rotation', 'Поворот')} <dsk-input data-field="rotation" step="1" tag="°" disabled aria-label="${t('Rotation', 'Поворот')}"></dsk-input></label>
</div>
<output class="dsk-engine-selection" aria-live="polite"></output>
<div class="dsk-engine-layout">
<span class="dsk-engine-corner"></span>
<dsk-ruler class="dsk-engine-x" snap="10" aria-label="X ruler"></dsk-ruler>
<dsk-ruler class="dsk-engine-y" snap="10" orientation="vertical" aria-label="Y ruler"></dsk-ruler>
<div class="dsk-engine-viewport">
<div class="dsk-engine-surface"></div>
<div class="dsk-engine-overlay"><dsk-canvas-marker label="${t('Comment: check spacing', 'Комментарий: проверить отступ')}" hidden></dsk-canvas-marker></div>
</div>
</div>`;
const surface = root.querySelector<HTMLElement>('.dsk-engine-surface')!;
const viewport = root.querySelector<HTMLElement>('.dsk-engine-viewport')!;
const rulers = Array.from(root.querySelectorAll('dsk-ruler'));
const marker = root.querySelector('dsk-canvas-marker')!;
const status = root.querySelector<HTMLOutputElement>('output')!;
const inputs: Record<TransformField, DskInput> = {
x: root.querySelector('dsk-input[data-field="x"]')!,
y: root.querySelector('dsk-input[data-field="y"]')!,
rotation: root.querySelector('dsk-input[data-field="rotation"]')!,
};
for (const ruler of rulers) ruler.target = viewport;
let current: ExampleSelection | null = null;
let editing: TransformField | undefined;
let transform: ((field: TransformField, value: number) => void) | undefined;
let action: ((value: ExampleAction) => void) | undefined;
const sync = () => {
status.textContent =
current?.name ?? t('Select one object.', 'Выберите один объект.');
for (const key of ['x', 'y', 'rotation'] as const) {
inputs[key].disabled = !current;
if (current && editing !== key)
inputs[key].value = Math.round(current[key] * 100) / 100;
}
};
for (const key of ['x', 'y', 'rotation'] as const) {
const input = inputs[key];
input.addEventListener(
'input',
() => {
if (!current || !Number.isFinite(input.valueAsNumber)) return;
editing = key;
transform?.(key, input.valueAsNumber);
},
{ signal: listeners.signal },
);
const finish = () => {
editing = undefined;
sync();
};
input.addEventListener('change', finish, { signal: listeners.signal });
input.addEventListener('focusout', finish, { signal: listeners.signal });
}
for (const name of ['select', 'fit', 'reset'] as const) {
root
.querySelector<HTMLButtonElement>(`[data-action="${name}"]`)!
.addEventListener('click', () => action?.(name), {
signal: listeners.signal,
});
}
sync();
return {
surface,
viewport,
inputs,
setSelection(value: ExampleSelection | null) {
if (current?.name !== value?.name) editing = undefined;
current = value;
sync();
},
setCamera(camera: CanvasCamera) {
for (const ruler of rulers) ruler.camera = camera;
marker.camera = camera;
},
setMarker(x: number, y: number, visible = true) {
marker.x = x;
marker.y = y;
marker.hidden = !visible;
},
onTransform(callback: (field: TransformField, value: number) => void) {
transform = callback;
},
onAction(callback: (value: ExampleAction) => void) {
action = callback;
},
destroy() {
listeners.abort();
for (const ruler of rulers) ruler.target = null;
root.replaceChildren();
delete root.dataset.ready;
},
};
}setSelection updates the controls and disables them when nothing is selected. onTransform receives user edits. Assigning a DSK input value from code is silent; this prevents a feedback loop. The shell also avoids rewriting a field during an active edit.
3. Mount Fabric and create the objects
Save this as src/fabric/mount-example.ts. This is the adapter running in the live example.
import { Canvas, Ellipse, Point, Rect, type FabricObject } from 'fabric';
import { createExampleShell } from '../shared/example-shell.ts';
/** Fabric owns the canvas and object controls; DSK subscribes to that state. */
export function mountExample(root: HTMLElement, ru = false): () => void {
const shell = createExampleShell(root, ru);
const surface = shell.surface;
surface.tabIndex = 0;
surface.setAttribute(
'aria-label',
ru ? 'Холст Fabric.js' : 'Fabric.js canvas',
);
const element = document.createElement('canvas');
surface.append(element);
const canvas = new Canvas(element, {
width: surface.clientWidth,
height: surface.clientHeight,
selection: false,
preserveObjectStacking: true,
});
const controls = {
originX: 'center',
originY: 'center',
borderColor: '#ffffff',
cornerColor: '#18181b',
cornerStrokeColor: '#ff0073',
cornerSize: 9,
transparentCorners: false,
lockScalingFlip: true,
} as const;
const rectangle = new Rect({
...controls,
left: 180,
top: 145,
width: 180,
height: 130,
rx: 12,
ry: 12,
fill: '#ff0073',
});
const ellipse = new Ellipse({
...controls,
left: 390,
top: 235,
rx: 58,
ry: 48,
fill: '#8b5cf6',
});
canvas.add(rectangle, ellipse);
canvas.setActiveObject(rectangle);
const listeners = new AbortController();
let pan: { point: Point; x: number; y: number } | null = null;
let disposed = false;
const selected = (): FabricObject | undefined => {
const object = canvas.getActiveObject();
return object === rectangle || object === ellipse ? object : undefined;
};
const sync = () => {
if (disposed) return;
const object = selected();
shell.setSelection(
object
? {
x: object.left,
y: object.top,
rotation: object.angle,
name: object === rectangle ? 'Rectangle' : 'Ellipse',
}
: null,
);
const matrix = canvas.viewportTransform;
shell.setCamera({
panX: matrix[4],
panY: matrix[5],
zoom: canvas.getZoom(),
});
// Fabric's bounding rectangle is in the scene, before viewportTransform.
const bounds = rectangle.getBoundingRect();
shell.setMarker(bounds.left + bounds.width, bounds.top + bounds.height);
};
const fit = () => {
const bounds = [rectangle, ellipse].map((object) =>
object.getBoundingRect(),
);
const left = Math.min(...bounds.map((box) => box.left));
const top = Math.min(...bounds.map((box) => box.top));
const right = Math.max(...bounds.map((box) => box.left + box.width));
const bottom = Math.max(...bounds.map((box) => box.top + box.height));
const zoom = Math.max(
0.15,
Math.min(
1.3,
(canvas.getWidth() - 88) / (right - left),
(canvas.getHeight() - 100) / (bottom - top),
),
);
canvas.setViewportTransform([
zoom,
0,
0,
zoom,
canvas.getWidth() / 2 - ((left + right) / 2) * zoom,
canvas.getHeight() / 2 - ((top + bottom) / 2) * zoom,
]);
sync();
};
canvas.on('after:render', sync);
canvas.on('mouse:down', (event) => {
// Drag empty space to pan. Dragging an object remains Fabric's own gesture.
if (!event.target) {
const matrix = canvas.viewportTransform;
pan = { point: event.viewportPoint, x: matrix[4], y: matrix[5] };
canvas.defaultCursor = 'grabbing';
}
});
canvas.on('mouse:move', (event) => {
if (!pan) return;
const matrix = canvas.viewportTransform;
canvas.setViewportTransform([
matrix[0],
matrix[1],
matrix[2],
matrix[3],
pan.x + event.viewportPoint.x - pan.point.x,
pan.y + event.viewportPoint.y - pan.point.y,
]);
sync();
});
const finishPan = () => {
pan = null;
canvas.defaultCursor = 'default';
};
canvas.on('mouse:up', finishPan);
canvas.on('mouse:wheel', ({ e }) => {
if (!surface.contains(document.activeElement)) return;
e.preventDefault();
const bounds = canvas.upperCanvasEl.getBoundingClientRect();
const pointer = new Point(e.clientX - bounds.left, e.clientY - bounds.top);
const zoom = Math.max(
0.15,
Math.min(4, canvas.getZoom() * Math.exp(-e.deltaY * 0.002)),
);
canvas.zoomToPoint(pointer, zoom);
sync();
});
surface.addEventListener(
'pointerdown',
() => surface.focus({ preventScroll: true }),
{
signal: listeners.signal,
},
);
surface.addEventListener('pointercancel', finishPan, {
signal: listeners.signal,
});
surface.addEventListener(
'keydown',
(event) => {
if (event.key === 'Escape') {
canvas.discardActiveObject();
canvas.requestRenderAll();
finishPan();
surface.blur();
}
},
{ signal: listeners.signal },
);
shell.onTransform((field, value) => {
const object = selected();
if (!object) return;
if (field === 'rotation') object.rotate(value);
else object.set(field === 'x' ? 'left' : 'top', value);
// Refresh hit testing and handles after changes made outside Fabric's gestures.
object.setCoords();
canvas.requestRenderAll();
sync();
});
shell.onAction((action) => {
if (action === 'select') {
canvas.setActiveObject(rectangle);
canvas.requestRenderAll();
sync();
}
if (action === 'fit') fit();
if (action === 'reset') {
for (const object of [rectangle, ellipse]) {
object.set({
left: object === rectangle ? 180 : 390,
top: object === rectangle ? 145 : 235,
angle: 0,
scaleX: 1,
scaleY: 1,
});
object.setCoords();
}
canvas.setActiveObject(rectangle);
fit();
}
});
const resize = new ResizeObserver(() => {
canvas.setDimensions({
width: surface.clientWidth,
height: surface.clientHeight,
});
fit();
});
resize.observe(surface);
fit();
root.dataset.ready = 'true';
return () => {
disposed = true;
resize.disconnect();
listeners.abort();
canvas.off();
// Fabric finishes a pending render before releasing its DOM and resources.
void canvas.dispose();
shell.destroy();
delete root.dataset.ready;
};
}Canvas adds its own interaction layer over the HTML canvas. Both objects use explicit center origins, so the inspector's X/Y represent the center. Selection is limited to one of the two shapes; dragging empty space pans instead of starting a multi-selection rectangle.
Fabric supplies the visible transform handles. DSK does not draw an imitation of them or replace Fabric's interaction model.
4. Read selection and apply DSK edits
Fabric's left/top correspond to the object's configured origin. angle is measured in degrees. With centered origins, the adapter can use the values directly:
shell.onTransform((field, value) => {
const object = selected();
if (!object) return;
if (field === 'rotation') object.rotate(value);
else object.set(field === 'x' ? 'left' : 'top', value);
// External changes must refresh controls and hit-testing coordinates.
object.setCoords();
canvas.requestRenderAll();
sync();
});Calling setCoords() matters: setting an object's properties alone does not refresh the cached coordinates used by hit testing and selection controls. The adapter reads the active object after each render so direct canvas gestures update DSK too. See Fabric's object geometry API.
The inspector intentionally handles only the rectangle and ellipse. Extending it to groups or multiple selection requires deciding whether X/Y refer to the group's parent coordinates or the overall scene.
5. Convert the viewport transform
Fabric stores its camera as a six-value affine matrix. This example uses only uniform scale and translation, so it maps to DSK without an additional offset:
// Fabric matrix: [zoom, 0, 0, zoom, panX, panY].
// DSK: screen = world * zoom + pan.
const matrix = canvas.viewportTransform;
shell.setCamera({
panX: matrix[4],
panY: matrix[5],
zoom: canvas.getZoom(),
});
// getBoundingRect() returns scene coordinates, before the camera transform.
const bounds = rectangle.getBoundingRect();
shell.setMarker(bounds.left + bounds.width, bounds.top + bounds.height);Do not apply the camera to the comment's anchor first: dsk-canvas-marker applies it itself. The viewport's location on the page is unrelated to the camera translation. A rotated/skewed viewport would require a different ruler integration; this example deliberately keeps the camera axis aligned.
For pointer-centered wheel zoom, canvas.zoomToPoint() takes a point in viewport pixels and the new zoom factor. Panning changes the matrix translation with setViewportTransform(). Both operations refresh the rulers and marker. See the Canvas API.
6. Add styles and lifecycle cleanup
The shared stylesheet aligns the rulers with the canvas, fixes the viewport height and gives the DSK panel its dark theme:
.dsk-engine-example {
border: 1px solid var(--vp-c-divider, #39393f);
background: #111;
color: #fff;
--dsk-input-background: #171717;
--dsk-input-color: #fff;
--dsk-input-border: 1px solid #39393f;
--dsk-input-border-radius: 0px;
--dsk-input-height: 36px;
--dsk-input-focus-color: #ff0073;
--dsk-ruler-background: #171717;
--dsk-ruler-color: #bbbbc4;
--dsk-marker-background: #ff0073;
--dsk-marker-radius: 0px;
}
.dsk-engine-toolbar {
display: flex;
flex-wrap: wrap;
gap: 8px;
padding: 12px;
}
.dsk-engine-toolbar button {
padding: 6px 10px;
border: 1px solid #39393f;
background: #171717;
color: #fff;
cursor: pointer;
font: inherit;
font-size: 13px;
}
.dsk-engine-toolbar button:hover {
background: #29232a;
}
.dsk-engine-toolbar button:focus-visible {
outline: 2px solid #ff0073;
outline-offset: 2px;
}
.dsk-engine-inspector {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 12px;
padding: 0 12px 12px;
}
.dsk-engine-inspector label {
display: grid;
gap: 6px;
font-size: 13px;
min-width: 0;
}
.dsk-engine-inspector dsk-input {
width: 100%;
min-width: 0;
}
.dsk-engine-selection {
display: block;
padding: 0 12px 12px;
font: 12px/1.5 monospace;
overflow-wrap: anywhere;
}
.dsk-engine-layout {
display: grid;
grid-template-columns: 24px minmax(0, 1fr);
grid-template-rows: 24px 420px;
}
.dsk-engine-x {
grid-column: 2;
grid-row: 1;
}
.dsk-engine-y {
grid-column: 1;
grid-row: 2;
}
.dsk-engine-corner {
background: #171717;
}
.dsk-engine-viewport {
grid-column: 2;
grid-row: 2;
position: relative;
min-width: 0;
overflow: hidden;
}
.dsk-engine-surface,
.dsk-engine-overlay {
position: absolute;
inset: 0;
}
.dsk-engine-overlay {
pointer-events: none;
z-index: 300;
}
.dsk-engine-overlay dsk-canvas-marker {
transform: translate(-100%, 12px);
}
.dsk-engine-surface > canvas {
display: block;
}
.dsk-engine-surface:focus-visible {
outline: 2px solid #ff0073;
outline-offset: -2px;
}
@media (max-width: 648px) {
.dsk-engine-inspector {
grid-template-columns: 1fr;
}
.dsk-engine-inspector label {
grid-template-columns: 72px minmax(0, 1fr);
align-items: center;
}
}// src/main.ts
import { mountExample } from './fabric/mount-example.ts';
const root = document.querySelector<HTMLElement>('#example');
if (!root) throw new Error('Missing example root');
const dispose = mountExample(root);
// Also call dispose() when the owning route or component unmounts.
if (import.meta.hot) import.meta.hot.dispose(dispose);Cleanup disconnects the resize observer and DOM listeners, removes Fabric subscriptions, and calls canvas.dispose(). Fabric finishes a pending render before releasing its resources. The adapter's disposed guard prevents that final render from updating the removed DSK panel.
For SSR, import the mounting module in a client lifecycle hook after the host exists. The canvas, DOM controls and resize observer belong to the browser.
7. Explore and download the project
The file explorer below contains the complete runnable example, including comments and the shared shell. Downloading the ZIP gives you these same sources plus a built DSK package in vendor.
Use Node.js 22.12+ and an internet connection for installing dependencies. Extract the ZIP, open a terminal in dsk-fabric-example, run npm install, then npm run dev. Run npm run build to verify types and build the application.
Automatic loading and cleanup on navigation or hot reload.
import './page.css';
const root = document.querySelector<HTMLElement>('#example');
const loading = document.querySelector<HTMLElement>('#loading');
if (!root || !loading) throw new Error('Missing example elements');
const host = root;
const status = loading;
let active = true;
let dispose: (() => void) | undefined;
const observer = new MutationObserver(() => {
if (host.dataset.ready === 'true') {
status.hidden = true;
observer.disconnect();
}
});
observer.observe(host, { attributes: true, attributeFilter: ['data-ready'] });
// Load this engine only after the browser has created its host.
async function start() {
try {
const { mountExample } = await import('./fabric/mount-example.ts');
if (!active) return;
const cleanup = await mountExample(host, document.documentElement.lang === 'ru');
if (!active) { cleanup(); return; }
dispose = cleanup;
if (host.dataset.ready === 'true') status.hidden = true;
} catch (error) {
if (!active) return;
status.textContent = 'The example could not load. Check the console and reload the page.';
console.error(error);
}
}
void start();
// In a routed application, call cleanup when leaving this page.
function cleanup() {
active = false;
observer.disconnect();
dispose?.();
}
if (import.meta.hot) import.meta.hot.dispose(cleanup);npm installnpm run dev