Konva.js + DSK
Build a small canvas editor with Konva's scene graph and native DSK controls. Konva draws, selects and transforms the objects. DSK supplies the position/rotation inspector, rulers, guides and a comment attached to the rectangle.
The editor loads with this page. Drag a shape, resize or rotate it with the handles, then edit X, Y or Rotation in the inspector. Click empty space to clear selection, or drag it to pan. Click the canvas before using the wheel to zoom; press Escape to release it and continue scrolling the page. Fit brings both shapes into view, and Reset restores their initial transforms. Select the ellipse to see the same controls edit another object.
Pull a guide from either ruler. Focus a guide and press Delete to remove it. Guides are DSK visual aids; they do not make Konva snap. The comment remains attached to the rectangle when selection changes. Everything stays in memory and resets when you leave the page.
1. Install and reserve the host
This example uses Konva 10.7.1 in a Vite TypeScript app. No runtime framework or Konva wrapper is needed.
npm i @flowscape-ui/dsk konva@10.7.1<!-- index.html -->
<div id="example"></div>
<script type="module" src="/src/main.ts"></script>The project keeps the reusable DSK panel separate from the engine:
src/
main.ts
konva/
mount-example.ts
shared/
example-shell.ts
example-shell.css2. Build the DSK panel
createExampleShell creates three dsk-input elements, two rulers and an overlay with a dsk-canvas-marker. The engine receives only surface. The ruler target is viewport, so their origin matches the canvas rather than the whole document.
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;
},
};
}The shell accepts a selection, a camera and a marker anchor. It emits callbacks for field edits and toolbar actions. Programmatic DSK value assignments do not emit input, so reflecting engine changes does not produce an update loop. The active field is not overwritten while it is being edited.
3. Mount the actual Konva stage
This is the complete adapter used by the example above. Save it as 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 owns navigation, Layer contains the shapes, and Transformer supplies selection handles. Clicking a shape connects it to the transformer. An empty selection disables the DSK fields. The inspector deliberately edits one object at a time.
Konva resizing changes scaleX and scaleY; it does not rewrite the rectangle's width and height. Reset restores scale as well as position and rotation. See the official Transformer example.
4. Connect positions and rotation
The rectangle's offset is half its width and height, so its X/Y describe its center. The ellipse already has a centered origin. Both engine gestures and DSK therefore rotate around the center. Konva's rotation is measured in degrees, just like the inspector.
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();
});The adapter listens to dragmove and transform to reflect canvas changes in the fields. It reads the engine's current values instead of maintaining a second object model.
5. Share the camera with rulers and comments
Konva stage position and scale map directly to the DSK camera:
// 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);The example uses uniform scale and an unrotated camera. The comment follows the rectangle's axis-aligned bounding box after resize or rotation. It is a DSK annotation, not a Konva document object. In a real editor, persist its text and the associated object ID in your document model.
The wheel handler first finds the world point beneath the pointer, changes scale, then adjusts stage position to keep that point stationary. See Konva's pointer-centered zoom example. Document scroll offsets are not part of this camera mapping.
6. Add styles and start
Use the shared styles for a fixed canvas height, aligned ruler origins and an overlay that does not intercept canvas gestures:
.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);The disposer disconnects the resize observer, removes DOM listeners, destroys the Konva stage and removes the DSK panel. For SSR, dynamically import this module on the client after the host exists. Konva's browser canvas and DSK custom elements must not be mounted during server rendering.
7. Explore and download the project
Browse every file below or download the ZIP. The engine adapter and shared shell come from the exact sources used in this page. The archive includes a built DSK package in vendor, so the example runs independently of this repository.
Use Node.js 22.12+ and an internet connection for dependencies. Extract the archive, open a terminal in dsk-konva-example, run npm install, then npm run dev. Run npm run build to type-check and produce a production build.
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('./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