Konva.js + DSK
Небольшой редактор на основе сцены Konva и нативных компонентов DSK. Konva рисует объекты, управляет их выделением и трансформациями. DSK добавляет инпуты позиции и поворота, линейки, направляющие и комментарий, привязанный к прямоугольнику.
Редактор загружается вместе со страницей. Перетащите фигуру, измените её размер или угол маркерами, затем отредактируйте X, Y или поворот в панели. Нажатие на пустое место снимает выделение, а перетаскивание пустого места перемещает камеру. Перед масштабированием колёсиком нажмите на холст. Escape освобождает фокус, чтобы снова можно было прокручивать страницу. Уместить показывает обе фигуры, Сбросить возвращает исходные трансформации. Выберите эллипс, чтобы изменить другой объект теми же инпутами.
Вытяните направляющую из любой линейки. Чтобы удалить её, установите на неё фокус и нажмите Delete. Направляющие DSK здесь служат визуальными ориентирами и не добавляют привязку в Konva. Комментарий остаётся у прямоугольника при смене выделения. Изменения хранятся в памяти и сбрасываются при уходе со страницы.
1. Установите зависимости и создайте контейнер
Пример использует Konva 10.7.1 в приложении Vite с TypeScript. Фреймворк и дополнительная обёртка над Konva не нужны.
npm i @flowscape-ui/dsk konva@10.7.1<!-- index.html -->
<div id="example"></div>
<script type="module" src="/src/main.ts"></script>Переиспользуемая панель DSK отделена от движка:
src/
main.ts
konva/
mount-example.ts
shared/
example-shell.ts
example-shell.css2. Создайте панель DSK
createExampleShell создаёт три dsk-input, две линейки и отдельный слой с dsk-canvas-marker. Движок получает только surface. Целью линеек служит 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;
},
};
}Панель принимает выделенный объект, камеру и точку привязки комментария. Обратные вызовы сообщают об изменении инпутов и действиях кнопок. Программное присваивание значения DSK не вызывает input, поэтому синхронизация не зацикливается. Значение активного инпута не перезаписывается во время редактирования.
3. Подключите настоящий холст Konva
Это полный адаптер работающего примера выше. Сохраните его как src/konva/mount-example.ts.
import Konva from 'konva';
import { createExampleShell } from '../shared/example-shell.ts';
/** Keep Konva in charge of drawing and transforms; DSK only edits its model. */
export function mountExample(root: HTMLElement, ru = false): () => void {
const shell = createExampleShell(root, ru);
const surface = shell.surface;
if (!(surface instanceof HTMLDivElement))
throw new Error('Expected a div for the Konva surface');
surface.tabIndex = 0;
surface.setAttribute('aria-label', ru ? 'Холст Konva' : 'Konva canvas');
const stage = new Konva.Stage({
container: surface,
width: surface.clientWidth,
height: surface.clientHeight,
draggable: true,
});
const layer = new Konva.Layer();
stage.add(layer);
// Explicit offsets make X/Y the center and keep rotations centered.
const rectangle = new Konva.Rect({
x: 180,
y: 145,
width: 180,
height: 130,
offsetX: 90,
offsetY: 65,
fill: '#ff0073',
cornerRadius: 12,
draggable: true,
});
const ellipse = new Konva.Ellipse({
x: 390,
y: 235,
radiusX: 58,
radiusY: 48,
fill: '#8b5cf6',
draggable: true,
});
const transformer = new Konva.Transformer({
nodes: [rectangle],
borderStroke: '#ffffff',
anchorStroke: '#ff0073',
anchorFill: '#18181b',
anchorSize: 9,
rotateAnchorOffset: 28,
flipEnabled: false,
boundBoxFunc: (previous, next) =>
Math.abs(next.width) < 24 || Math.abs(next.height) < 24 ? previous : next,
});
layer.add(rectangle, ellipse, transformer);
let selected: Konva.Shape | null = rectangle;
const listeners = new AbortController();
const sync = () => {
shell.setCamera({ panX: stage.x(), panY: stage.y(), zoom: stage.scaleX() });
shell.setSelection(
selected
? {
x: selected.x(),
y: selected.y(),
rotation: selected.rotation(),
name: selected === rectangle ? 'Rectangle' : 'Ellipse',
}
: null,
);
// relativeTo removes the stage camera: the marker expects world coordinates.
const bounds = rectangle.getClientRect({ relativeTo: stage });
shell.setMarker(bounds.x + bounds.width, bounds.y + bounds.height);
};
const select = (shape: Konva.Shape | null) => {
selected = shape;
transformer.nodes(shape ? [shape] : []);
sync();
};
const fit = () => {
const bounds = [rectangle, ellipse].map((shape) =>
shape.getClientRect({ relativeTo: stage }),
);
const left = Math.min(...bounds.map((box) => box.x));
const top = Math.min(...bounds.map((box) => box.y));
const right = Math.max(...bounds.map((box) => box.x + box.width));
const bottom = Math.max(...bounds.map((box) => box.y + box.height));
const zoom = Math.max(
0.15,
Math.min(
1.3,
(stage.width() - 88) / (right - left),
(stage.height() - 100) / (bottom - top),
),
);
stage.scale({ x: zoom, y: zoom });
stage.position({
x: stage.width() / 2 - ((left + right) / 2) * zoom,
y: stage.height() / 2 - ((top + bottom) / 2) * zoom,
});
sync();
};
stage.on('click.dsk tap.dsk', (event) => {
if (event.target === stage) select(null);
else if (event.target === rectangle || event.target === ellipse)
select(event.target);
});
const shapes: Konva.Shape[] = [rectangle, ellipse];
for (const shape of shapes) {
shape.on('dragstart.dsk', () => select(shape));
shape.on('dragmove.dsk transform.dsk', sync);
}
stage.on('dragmove.dsk', sync);
stage.on('wheel.dsk', (event) => {
if (!surface.contains(document.activeElement)) return;
event.evt.preventDefault();
const pointer = stage.getPointerPosition();
if (!pointer) return;
const previous = stage.scaleX();
const zoom = Math.max(
0.15,
Math.min(4, previous * Math.exp(-event.evt.deltaY * 0.002)),
);
const world = {
x: (pointer.x - stage.x()) / previous,
y: (pointer.y - stage.y()) / previous,
};
stage.scale({ x: zoom, y: zoom });
stage.position({
x: pointer.x - world.x * zoom,
y: pointer.y - world.y * zoom,
});
sync();
});
surface.addEventListener(
'pointerdown',
() => surface.focus({ preventScroll: true }),
{
signal: listeners.signal,
},
);
surface.addEventListener(
'keydown',
(event) => {
if (event.key === 'Escape') {
select(null);
surface.blur();
}
},
{ signal: listeners.signal },
);
shell.onTransform((field, value) => {
if (!selected) return;
if (field === 'rotation') selected.rotation(value);
else if (field === 'x') selected.x(value);
else selected.y(value);
sync();
});
shell.onAction((action) => {
if (action === 'select') select(rectangle);
if (action === 'fit') fit();
if (action === 'reset') {
rectangle.setAttrs({ x: 180, y: 145, rotation: 0, scaleX: 1, scaleY: 1 });
ellipse.setAttrs({ x: 390, y: 235, rotation: 0, scaleX: 1, scaleY: 1 });
select(rectangle);
fit();
}
});
const resize = new ResizeObserver(() => {
stage.size({ width: surface.clientWidth, height: surface.clientHeight });
fit();
});
resize.observe(surface);
fit();
root.dataset.ready = 'true';
return () => {
resize.disconnect();
listeners.abort();
stage.destroy();
shell.destroy();
delete root.dataset.ready;
};
}Stage отвечает за камеру, Layer содержит фигуры, а Transformer предоставляет маркеры выделения. Нажатие на фигуру подключает её к трансформеру. При пустом выделении инпуты DSK отключены. Панель намеренно редактирует один объект за раз.
При изменении размера Konva меняет scaleX и scaleY, а не ширину и высоту прямоугольника. Поэтому Сбросить возвращает исходный масштаб вместе с позицией и углом. Подробнее в официальном примере Transformer.
4. Свяжите позицию и поворот
Смещение прямоугольника равно половине его ширины и высоты, поэтому X/Y обозначают центр. У эллипса начало координат уже находится в центре. И жесты движка, и инпуты DSK поворачивают фигуру вокруг центра. Konva измеряет угол в градусах, как и наша панель.
shell.onTransform((field, value) => {
if (!selected) return;
if (field === 'rotation') selected.rotation(value);
else if (field === 'x') selected.x(value);
else selected.y(value);
sync();
});Адаптер слушает dragmove и transform, чтобы отражать изменения на холсте в инпутах. Он читает текущие значения движка и не создаёт вторую модель объектов.
5. Передайте камеру линейкам и комментарию
Позиция и масштаб сцены Konva напрямую соответствуют камере DSK:
// Both use: screen = world * zoom + pan.
shell.setCamera({
panX: stage.x(),
panY: stage.y(),
zoom: stage.scaleX(),
});
// Exclude the stage transform to get an anchor in world coordinates.
const bounds = rectangle.getClientRect({ relativeTo: stage });
shell.setMarker(bounds.x + bounds.width, bounds.y + bounds.height);В примере масштаб одинаков по обеим осям, камера не поворачивается. Комментарий следует за правым нижним углом ограничивающего прямоугольника после изменения размера или поворота фигуры. Это аннотация DSK, а не объект документа Konva. В настоящем редакторе сохраняйте её текст и идентификатор связанной фигуры в модели документа.
Обработчик колёсика сначала находит мировую точку под курсором, меняет масштаб, затем корректирует позицию сцены, чтобы эта точка осталась на месте. См. пример масштабирования относительно курсора. Прокрутка документа не входит в координаты камеры.
6. Добавьте стили и запустите пример
Общие стили задают высоту холста, совмещают начало координат линеек и создают слой комментария, который не перехватывает жесты холста:
.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 './konva/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 your route or component unmounts.
if (import.meta.hot) import.meta.hot.dispose(dispose);Функция очистки отключает наблюдатель за размерами, удаляет DOM-обработчики, уничтожает сцену Konva и удаляет панель DSK. Для SSR импортируйте модуль динамически на клиенте после появления контейнера. Браузерный холст Konva и пользовательские элементы DSK не нужно монтировать при серверном рендеринге.
7. Изучите и скачайте проект
Просмотрите все файлы ниже или скачайте ZIP. Адаптер движка и общая панель взяты из тех же исходников, которые работают на этой странице. Архив содержит собранный пакет DSK в vendor, поэтому пример запускается независимо от этого репозитория.
Нужны Node.js 22.12+ и интернет для установки зависимостей. Распакуйте архив, откройте терминал в dsk-konva-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('./konva/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