Fabric.js + DSK
Используйте интерактивный холст Fabric как движок, а DSK как внешнюю панель параметров. Fabric управляет выделением, перетаскиванием, масштабом и поворотом объектов. Те же инпуты, линейки и комментарий DSK, что и в остальных примерах, подключаются к публичному API Fabric.
Редактор загружается вместе со страницей. Перетаскивайте прямоугольник или эллипс, меняйте размер и угол маркерами либо редактируйте X, Y и поворот через DSK. Нажатие на пустое место снимает выделение, его перетаскивание перемещает камеру. Перед масштабированием колёсиком нажмите на холст. Escape освобождает фокус для обычной прокрутки страницы. Уместить показывает обе фигуры, Сбросить возвращает исходные трансформации.
Комментарий следует за прямоугольником, даже если выбран другой объект. Вытягивайте направляющие из линеек и удаляйте их клавишей Delete, когда на них установлен фокус. Это отдельные элементы DSK, они не становятся объектами Fabric и не добавляют привязку. Изменения хранятся в памяти до ухода со страницы.
1. Установите зависимости и создайте контейнер
Пример использует Fabric 7.4.0 с Vite и TypeScript. И Fabric, и DSK работают без фреймворка.
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. Создайте панель DSK и слой комментария
Общая панель держит нативные контролы за пределами области жестов Fabric. Линейки привязаны к внутреннему viewport, а комментарий получает мировые координаты и ту же камеру, что и холст.
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 обновляет инпуты и отключает их при пустом выделении. onTransform принимает изменения пользователя. Программное присваивание значения DSK не генерирует input и не создаёт циклические обновления. Панель также не перезаписывает значение активного инпута во время редактирования.
3. Подключите Fabric и создайте объекты
Сохраните код в src/fabric/mount-example.ts. Именно этот адаптер работает в интерактивном примере.
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 добавляет собственный слой взаимодействия поверх HTML canvas. У обоих объектов явно задано центральное начало координат, поэтому X/Y в панели обозначают центр. Выделение ограничено одной из двух фигур, а перетаскивание пустого места перемещает камеру вместо создания рамки множественного выделения.
Видимые маркеры трансформации предоставляет Fabric. DSK не рисует их имитацию и не заменяет взаимодействие движка.
4. Прочитайте выделение и примените изменения DSK
Свойства left/top относятся к заданному началу координат объекта. angle измеряется в градусах. При центральном начале координат адаптер использует эти значения напрямую:
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();
});Вызов setCoords() важен: простое изменение свойств объекта не обновляет кеш координат для проверки попадания и маркеров выделения. После каждого рендеринга адаптер читает активный объект, поэтому жесты на холсте тоже обновляют DSK. См. API геометрии объекта.
Панель намеренно обрабатывает только прямоугольник и эллипс. Для групп или множественного выделения сначала определите, к каким координатам относятся X/Y: к родителю группы или ко всей сцене.
5. Преобразуйте матрицу камеры
Fabric хранит камеру в аффинной матрице из шести значений. В этом примере используются только равномерный масштаб и смещение, поэтому дополнительное преобразование для DSK не нужно:
// 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);Не применяйте камеру к точке привязки заранее: dsk-canvas-marker делает это самостоятельно. Положение контейнера на странице не является смещением камеры. Поворот или скос камеры потребовал бы другой интеграции линеек; здесь оси камеры совпадают с осями холста.
Для масштабирования относительно курсора canvas.zoomToPoint() принимает точку в пикселях viewport и новый масштаб. Перемещение меняет смещение матрицы через setViewportTransform(). Обе операции обновляют линейки и комментарий. См. API Canvas.
6. Добавьте стили и очистку
Общие стили совмещают линейки с холстом, задают высоту viewport и оформляют панель DSK в тёмной теме:
.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);Очистка отключает наблюдатель за размерами и DOM-обработчики, удаляет подписки Fabric и вызывает canvas.dispose(). Fabric завершает запланированную отрисовку перед освобождением ресурсов. Проверка флага disposed не даёт этой последней отрисовке обновить уже удалённую панель DSK.
Для SSR импортируйте модуль в клиентском обработчике жизненного цикла после появления контейнера. Холст, DOM-контролы и наблюдатель за размерами работают в браузере.
7. Изучите и скачайте проект
Ниже находится полный рабочий проект с комментариями и общей панелью. В ZIP вы получите те же исходники и собранный пакет DSK в папке vendor.
Нужны Node.js 22.12+ и интернет для установки зависимостей. Распакуйте ZIP, откройте терминал в dsk-fabric-example, выполните npm install, затем npm run dev. Команда npm run build проверяет типы и собирает приложение.
Автоматическая загрузка и очистка при навигации или перезагрузке.
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