headless-vpl 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,829 @@
1
+ # Headless VPL
2
+
3
+ > Build any Visual Programming Language — block-based, flow-based, or something entirely new.
4
+
5
+ **Headless VPL** is a framework-agnostic TypeScript library for building Visual Programming Languages. It provides the core engine — drag & drop, snap connections, auto layout, edge routing, undo/redo, selection, and more — while leaving rendering and styling entirely up to you.
6
+
7
+ - **Headless**: No built-in UI. Use React, Vue, Svelte, or vanilla DOM.
8
+ - **Pure TypeScript**: Zero runtime dependencies. Full type safety.
9
+ - **VPL-specialized**: Not a generic canvas library. Purpose-built APIs for visual programming.
10
+ - **Simple > Easy**: Transparent internals you can understand and control.
11
+
12
+ ## Why Headless VPL?
13
+
14
+ | | **Headless VPL** | **Blockly** | **ReactFlow** |
15
+ |---|---|---|---|
16
+ | VPL types | Block + Flow + Hybrid | Block only | Flow only |
17
+ | Framework | Any (vanilla TS) | None (own renderer) | React only |
18
+ | Rendering | You own it (DOM overlay) | Locked to Blockly renderer | Locked to React components |
19
+ | Type safety | Full TypeScript generics | JSON/string-based | Partial |
20
+ | Design freedom | Complete | Limited customization | Component-level only |
21
+ | API surface | Thin, composable functions | Large, opinionated | Event-driven, implicit |
22
+
23
+ ## Quick Start
24
+
25
+ ```bash
26
+ npm install headless-vpl
27
+ ```
28
+
29
+ Minimal example — two draggable nodes connected by an edge:
30
+
31
+ ```typescript
32
+ import {
33
+ Workspace, Container, Connector, Edge, Position,
34
+ SvgRenderer, screenToWorld,
35
+ } from 'headless-vpl'
36
+ import { getMouseState, getPositionDelta } from 'headless-vpl/util/mouse'
37
+ import { animate } from 'headless-vpl/util/animate'
38
+ import { DragAndDrop } from 'headless-vpl/util/dnd'
39
+ import { isCollision } from 'headless-vpl/util/collision_detecion'
40
+
41
+ // 1. Create workspace + renderer
42
+ const workspace = new Workspace()
43
+ const svg = document.querySelector('#workspace') as SVGSVGElement
44
+ new SvgRenderer(svg, workspace)
45
+
46
+ // 2. Create nodes with connectors
47
+ const nodeA = new Container({
48
+ workspace,
49
+ position: new Position(100, 50),
50
+ name: 'nodeA',
51
+ width: 160, height: 60,
52
+ children: {
53
+ output: new Connector({ position: new Position(160, -30), name: 'out', type: 'output' }),
54
+ },
55
+ })
56
+
57
+ const nodeB = new Container({
58
+ workspace,
59
+ position: new Position(400, 50),
60
+ name: 'nodeB',
61
+ width: 160, height: 60,
62
+ children: {
63
+ input: new Connector({ position: new Position(0, -30), name: 'in', type: 'input' }),
64
+ },
65
+ })
66
+
67
+ // 3. Connect them
68
+ new Edge({ start: nodeA.children.output, end: nodeB.children.input })
69
+
70
+ // 4. Set up interaction
71
+ const canvas = svg.parentElement!
72
+ const containers = [nodeA, nodeB]
73
+ let dragEligible = false
74
+ let dragContainers: Container[] = []
75
+ let prevMouse = { x: 0, y: 0 }
76
+ let isPanning = false
77
+
78
+ const mouse = getMouseState(canvas, {
79
+ mousedown: (state, pos) => {
80
+ const world = screenToWorld(pos, workspace.viewport)
81
+ dragEligible = containers.some(c => isCollision(c, world))
82
+ isPanning = !dragEligible
83
+ },
84
+ mouseup: () => { dragEligible = false; dragContainers = []; isPanning = false },
85
+ })
86
+
87
+ const worldMouse = {
88
+ get buttonState() { return mouse.buttonState },
89
+ get mousePosition() { return screenToWorld(mouse.mousePosition, workspace.viewport) },
90
+ }
91
+
92
+ // 5. Animation loop
93
+ animate(() => {
94
+ const delta = getPositionDelta(mouse.mousePosition, prevMouse)
95
+ prevMouse = { ...mouse.mousePosition }
96
+
97
+ if (isPanning && mouse.buttonState.leftButton === 'down') {
98
+ workspace.panBy(delta.x, delta.y)
99
+ } else {
100
+ const s = workspace.viewport.scale
101
+ dragContainers = DragAndDrop(
102
+ containers, { x: delta.x / s, y: delta.y / s },
103
+ worldMouse, dragEligible, dragContainers,
104
+ )
105
+ }
106
+ })
107
+ ```
108
+
109
+ ## Core Concepts
110
+
111
+ ### The Four Patterns
112
+
113
+ Every VPL can be decomposed into four universal patterns:
114
+
115
+ | Pattern | What it covers | Headless VPL API |
116
+ |---|---|---|
117
+ | **Rendering** | Responsive sizing, auto layout | `Container`, `AutoLayout`, `SvgRenderer` |
118
+ | **Connection** | Connectors, edges, parent-child | `Connector`, `Edge`, `SnapConnection` |
119
+ | **Movement** | Drag & drop, snap, group move | `DragAndDrop`, `snap`, `moveGroup` |
120
+ | **Input** | Text, numbers, toggles, sliders | Your own DOM (framework-agnostic) |
121
+
122
+ ### Unidirectional Data Flow
123
+
124
+ ```
125
+ Frontend (React / Vue / vanilla)
126
+ → Mouse/Keyboard events
127
+ → Headless VPL core (Workspace, Containers, Edges)
128
+ → EventBus notifications
129
+ → SvgRenderer (debug wireframe)
130
+ → Your DOM components (production UI)
131
+ ```
132
+
133
+ ### Headless Architecture
134
+
135
+ Headless VPL draws an SVG wireframe for hit-testing and debugging. Your actual UI is a DOM layer on top, positioned to match the headless coordinates:
136
+
137
+ ```
138
+ ┌─ Canvas ─────────────────────────┐
139
+ │ SVG layer (wireframe, invisible)│ ← hit detection, debug
140
+ │ DOM overlay (your components) │ ← what users see
141
+ └──────────────────────────────────┘
142
+ ```
143
+
144
+ Use `DomController` or manual CSS transforms to sync DOM positions with the headless coordinate system.
145
+
146
+ ---
147
+
148
+ ## API Reference
149
+
150
+ ### Core
151
+
152
+ #### `Workspace`
153
+
154
+ The root container for all VPL elements. Manages viewport, selection, history, and event dispatch.
155
+
156
+ ```typescript
157
+ const workspace = new Workspace()
158
+ ```
159
+
160
+ | Property | Type | Description |
161
+ |---|---|---|
162
+ | `eventBus` | `EventBus` | Event system for all workspace events |
163
+ | `viewport` | `Viewport` | Current pan/zoom state `{ x, y, scale }` |
164
+ | `selection` | `SelectionManager` | Selection state manager |
165
+ | `history` | `History` | Undo/redo history stack |
166
+ | `elements` | `readonly IWorkspaceElement[]` | All registered elements |
167
+ | `edges` | `readonly IEdge[]` | All registered edges |
168
+
169
+ | Method | Description |
170
+ |---|---|
171
+ | `addElement(element)` | Register an element (called automatically by Container/Connector constructors) |
172
+ | `addEdge(edge)` | Register an edge (called automatically by Edge constructor) |
173
+ | `removeElement(element)` | Unregister an element |
174
+ | `removeEdge(edge)` | Unregister an edge |
175
+ | `removeContainer(element)` | Remove a container and its related edges, clear parent/child relations, deselect |
176
+ | `on(type, handler)` | Subscribe to events. Returns an unsubscribe function |
177
+ | `pan(x, y)` | Set viewport position |
178
+ | `panBy(dx, dy)` | Pan viewport by delta |
179
+ | `zoomAt(screenX, screenY, newScale)` | Zoom centered on a screen point |
180
+ | `setScale(scale)` | Set zoom scale directly |
181
+ | `fitView(canvasWidth, canvasHeight, padding?)` | Fit all elements in view (padding default: 50) |
182
+
183
+ #### `Container<T>`
184
+
185
+ The primary building block. A rectangular element that can hold connectors, auto layouts, and other movable objects as typed children.
186
+
187
+ ```typescript
188
+ const node = new Container({
189
+ workspace,
190
+ position: new Position(100, 50),
191
+ name: 'myNode',
192
+ color: 'blue',
193
+ width: 200,
194
+ height: 80,
195
+ widthMode: 'hug', // 'fixed' | 'hug' | 'fill'
196
+ heightMode: 'fixed',
197
+ padding: { top: 10, right: 10, bottom: 10, left: 10 },
198
+ minWidth: 50,
199
+ maxWidth: 500,
200
+ resizable: true,
201
+ children: {
202
+ input: new Connector({ position: new Position(0, -40), name: 'in', type: 'input' }),
203
+ output: new Connector({ position: new Position(200, -40), name: 'out', type: 'output' }),
204
+ },
205
+ })
206
+
207
+ // Access typed children
208
+ node.children.input // Connector — fully typed
209
+ ```
210
+
211
+ | Property | Type | Default | Description |
212
+ |---|---|---|---|
213
+ | `color` | `string` | `'red'` | Wireframe color |
214
+ | `width` | `number` | `100` | Width in world units |
215
+ | `height` | `number` | `100` | Height in world units |
216
+ | `widthMode` | `SizingMode` | `'fixed'` | `'fixed'` / `'hug'` (fit content) / `'fill'` |
217
+ | `heightMode` | `SizingMode` | `'fixed'` | Same as widthMode |
218
+ | `padding` | `Padding` | `{0,0,0,0}` | Inner padding |
219
+ | `minWidth` / `maxWidth` | `number` | `0` / `Infinity` | Size constraints |
220
+ | `minHeight` / `maxHeight` | `number` | `0` / `Infinity` | Size constraints |
221
+ | `resizable` | `boolean` | `false` | Enable resize handles |
222
+ | `children` | `T` | `{}` | Typed children map |
223
+ | `selected` | `boolean` | `false` | Selection state (inherited from MovableObject) |
224
+ | `Parent` | `MovableObject \| null` | `null` | Snap parent connection |
225
+ | `Children` | `MovableObject \| null` | `null` | Snap child connection |
226
+
227
+ | Method | Description |
228
+ |---|---|
229
+ | `move(x, y)` | Move to absolute position (updates children) |
230
+ | `setColor(color)` | Change wireframe color |
231
+ | `updateChildren()` | Recalculate child positions |
232
+ | `applyContentSize(w, h)` | Apply content-based sizing (for hug mode) |
233
+ | `toJSON()` | Serialize to plain object |
234
+
235
+ #### `MovableObject` (abstract)
236
+
237
+ Base class for all draggable elements. Extended by `Container` and `Connector`.
238
+
239
+ | Property | Type | Description |
240
+ |---|---|---|
241
+ | `id` | `string` (readonly) | Unique identifier (auto-generated) |
242
+ | `position` | `Position` | Current position |
243
+ | `name` | `string` | Display name |
244
+ | `type` | `string` | Element type identifier |
245
+ | `selected` | `boolean` | Whether currently selected |
246
+ | `Parent` | `MovableObject \| null` | Parent in snap hierarchy |
247
+ | `Children` | `MovableObject \| null` | Child in snap hierarchy |
248
+ | `workspace` | `Workspace` | Associated workspace |
249
+
250
+ | Method | Description |
251
+ |---|---|
252
+ | `move(x, y)` | Move to position, emit `'move'` event |
253
+ | `update()` | Emit `'update'` event |
254
+ | `toJSON()` | Serialize to `{ id, type, name, position, selected }` |
255
+
256
+ #### `Connector`
257
+
258
+ An input or output connection point on a container.
259
+
260
+ ```typescript
261
+ const connector = new Connector({
262
+ workspace, // optional if added as Container child
263
+ position: new Position(0, -30), // relative to parent container
264
+ name: 'input1',
265
+ type: 'input', // 'input' | 'output'
266
+ })
267
+ ```
268
+
269
+ Connector positions are relative to their parent container. When the parent moves, connectors move with it.
270
+
271
+ #### `Edge`
272
+
273
+ A visual connection between two connectors.
274
+
275
+ ```typescript
276
+ const edge = new Edge({
277
+ start: nodeA.children.output, // Connector
278
+ end: nodeB.children.input, // Connector
279
+ edgeType: 'bezier', // 'straight' | 'bezier' | 'step' | 'smoothstep'
280
+ label: 'data flow', // optional label at midpoint
281
+ markerStart: { type: 'none' },
282
+ markerEnd: { type: 'arrowClosed', color: '#333', size: 10 },
283
+ })
284
+ ```
285
+
286
+ | Property | Type | Default | Description |
287
+ |---|---|---|---|
288
+ | `edgeType` | `EdgeType` | `'straight'` | Path algorithm |
289
+ | `label` | `string?` | — | Text label at path midpoint |
290
+ | `markerStart` | `EdgeMarker?` | — | Start arrow marker |
291
+ | `markerEnd` | `EdgeMarker?` | — | End arrow marker |
292
+ | `startConnector` | `Connector` | — | Source connector |
293
+ | `endConnector` | `Connector` | — | Target connector |
294
+
295
+ | Method | Description |
296
+ |---|---|
297
+ | `computePath()` | Returns `{ path: string, labelPosition: IPosition }` |
298
+ | `getLabelPosition()` | Shorthand for `computePath().labelPosition` |
299
+
300
+ #### `AutoLayout`
301
+
302
+ Automatic layout manager for child containers (like CSS Flexbox).
303
+
304
+ ```typescript
305
+ const layout = new AutoLayout({
306
+ position: new Position(10, 10),
307
+ direction: 'horizontal', // 'horizontal' | 'vertical'
308
+ gap: 10,
309
+ alignment: 'center', // 'start' | 'center' | 'end'
310
+ containers: [childA, childB, childC],
311
+ })
312
+ ```
313
+
314
+ Used as a child of a Container:
315
+
316
+ ```typescript
317
+ const parent = new Container({
318
+ workspace,
319
+ position: new Position(0, 0),
320
+ name: 'layoutParent',
321
+ widthMode: 'hug',
322
+ heightMode: 'fixed',
323
+ height: 100,
324
+ padding: { top: 10, right: 10, bottom: 10, left: 10 },
325
+ children: { layout },
326
+ })
327
+ ```
328
+
329
+ | Property | Type | Default | Description |
330
+ |---|---|---|---|
331
+ | `direction` | `'horizontal' \| 'vertical'` | `'horizontal'` | Layout direction |
332
+ | `gap` | `number` | `10` | Spacing between children |
333
+ | `alignment` | `'start' \| 'center' \| 'end'` | `'center'` | Cross-axis alignment |
334
+
335
+ #### `Position`
336
+
337
+ Simple 2D coordinate.
338
+
339
+ ```typescript
340
+ const pos = new Position(100, 200)
341
+ pos.setPosition(150, 250)
342
+ pos.getPosition() // { x: 150, y: 250 }
343
+ ```
344
+
345
+ ---
346
+
347
+ ### Event System
348
+
349
+ #### `EventBus`
350
+
351
+ Publish-subscribe event system for workspace state changes.
352
+
353
+ ```typescript
354
+ // Subscribe (returns unsubscribe function)
355
+ const unsub = workspace.eventBus.on('move', (event) => {
356
+ console.log(event.target, event.data)
357
+ })
358
+
359
+ // Unsubscribe
360
+ unsub()
361
+ ```
362
+
363
+ | Event Type | Emitted When |
364
+ |---|---|
365
+ | `move` | Element position changes |
366
+ | `connect` | Two elements snap together |
367
+ | `disconnect` | Snapped elements separate |
368
+ | `add` | Element added to workspace |
369
+ | `remove` | Element removed from workspace |
370
+ | `update` | Element properties change |
371
+ | `pan` | Viewport pans |
372
+ | `zoom` | Viewport zooms |
373
+ | `select` | Element selected |
374
+ | `deselect` | Element deselected |
375
+
376
+ #### `SelectionManager`
377
+
378
+ Manages selected elements with event notifications.
379
+
380
+ ```typescript
381
+ const sel = workspace.selection
382
+
383
+ sel.select(container) // Select one
384
+ sel.toggleSelect(container) // Toggle selection
385
+ sel.selectAll(workspace.elements as MovableObject[]) // Select all
386
+ sel.deselectAll() // Clear selection
387
+ sel.getSelection() // readonly MovableObject[]
388
+ sel.isSelected(container) // boolean
389
+ sel.size // number
390
+ ```
391
+
392
+ Each `select`/`deselect` call sets `element.selected` and emits the corresponding event.
393
+
394
+ ---
395
+
396
+ ### History
397
+
398
+ #### `History` & `Command`
399
+
400
+ Undo/redo system with command pattern.
401
+
402
+ ```typescript
403
+ interface Command {
404
+ execute(): void
405
+ undo(): void
406
+ }
407
+
408
+ const history = workspace.history
409
+
410
+ history.execute(command) // Execute + push to undo stack
411
+ history.undo() // Pop undo stack, push to redo
412
+ history.redo() // Pop redo stack, push to undo
413
+ history.canUndo // boolean
414
+ history.canRedo // boolean
415
+ history.clear() // Clear both stacks
416
+ ```
417
+
418
+ #### Built-in Commands
419
+
420
+ ```typescript
421
+ import { MoveCommand, AddCommand, RemoveCommand, ConnectCommand } from 'headless-vpl'
422
+
423
+ // Record a move for undo
424
+ new MoveCommand(element, fromX, fromY, toX, toY)
425
+
426
+ // Record add/remove
427
+ new AddCommand(workspace, element)
428
+ new RemoveCommand(workspace, element) // also handles related edges
429
+
430
+ // Record connection
431
+ new ConnectCommand(workspace, parent, child)
432
+ ```
433
+
434
+ ---
435
+
436
+ ### Rendering
437
+
438
+ #### `SvgRenderer`
439
+
440
+ Debug wireframe renderer. Subscribes to EventBus and draws SVG representations of all workspace elements.
441
+
442
+ ```typescript
443
+ const svg = document.querySelector('#workspace') as SVGSVGElement
444
+ new SvgRenderer(svg, workspace)
445
+ ```
446
+
447
+ Creates SVG elements lazily:
448
+ - **Container** → `<rect>` with stroke and fill
449
+ - **Connector** → `<circle>`
450
+ - **Edge** → `<g>` with `<path>` + optional `<text>` label + markers
451
+ - **AutoLayout** → `<rect>` outline
452
+
453
+ Handles viewport transforms (pan/zoom) and selection visual feedback (dashed stroke).
454
+
455
+ ---
456
+
457
+ ### Utilities
458
+
459
+ #### Drag & Drop
460
+
461
+ ```typescript
462
+ import { DragAndDrop } from 'headless-vpl/util/dnd'
463
+
464
+ // Call every frame in your animation loop
465
+ dragContainers = DragAndDrop(
466
+ containers, // all draggable containers
467
+ worldDelta, // { x, y } movement in world coordinates
468
+ mouseState, // { buttonState, mousePosition } in world coords
469
+ dragEligible, // true if mousedown was on a container
470
+ currentDragContainers, // containers currently being dragged
471
+ allowMultiple, // allow dragging multiple containers (default: false)
472
+ callback, // optional per-frame callback
473
+ )
474
+ ```
475
+
476
+ #### Snap & SnapConnection
477
+
478
+ Low-level snap function and high-level connection manager.
479
+
480
+ ```typescript
481
+ import { snap, SnapConnection, childOnly, parentOnly, either } from 'headless-vpl'
482
+
483
+ // High-level: manages snap + parent/child + events
484
+ const connection = new SnapConnection({
485
+ source: childNode,
486
+ sourcePosition: childNode.children.connectorTop.position,
487
+ target: parentNode,
488
+ targetPosition: parentNode.children.connectorBottom.position,
489
+ workspace,
490
+ snapDistance: 50, // default: 50
491
+ strategy: childOnly, // childOnly | parentOnly | either
492
+ validator: () => true, // optional ConnectionValidator
493
+ })
494
+
495
+ // Call every frame
496
+ connection.tick(mouseState, dragContainers)
497
+
498
+ // Call on mousedown to allow re-snapping
499
+ connection.unlock()
500
+ ```
501
+
502
+ **Snap Strategies:**
503
+
504
+ | Strategy | Snaps when... |
505
+ |---|---|
506
+ | `childOnly` | The child (source) is being dragged toward parent |
507
+ | `parentOnly` | The parent (target) is being dragged toward child |
508
+ | `either` | Either element is being dragged |
509
+
510
+ #### Mouse
511
+
512
+ ```typescript
513
+ import { getMousePosition, getMouseState, getPositionDelta } from 'headless-vpl/util/mouse'
514
+
515
+ // Track mouse position relative to an element
516
+ const position = getMousePosition(element) // mutable IPosition
517
+
518
+ // Track position + button state with callbacks
519
+ const mouse = getMouseState(element, {
520
+ mousedown: (state, position, event) => { /* ... */ },
521
+ mouseup: (state, position, event) => { /* ... */ },
522
+ })
523
+ mouse.buttonState // { leftButton: 'down' | 'up' }
524
+ mouse.mousePosition // IPosition
525
+
526
+ // Compute frame delta
527
+ const delta = getPositionDelta(currentPos, previousPos) // { x, y }
528
+ ```
529
+
530
+ #### Viewport
531
+
532
+ ```typescript
533
+ import { screenToWorld, worldToScreen } from 'headless-vpl'
534
+
535
+ const worldPos = screenToWorld(screenPos, workspace.viewport)
536
+ const screenPos = worldToScreen(worldPos, workspace.viewport)
537
+ ```
538
+
539
+ #### Edge Paths
540
+
541
+ Four path algorithms for edge rendering:
542
+
543
+ ```typescript
544
+ import {
545
+ getStraightPath, // M...L straight line
546
+ getBezierPath, // M...C cubic bezier
547
+ getStepPath, // right-angle steps
548
+ getSmoothStepPath, // rounded steps (borderRadius param)
549
+ } from 'headless-vpl'
550
+
551
+ const { path, labelPosition } = getBezierPath(startPos, endPos)
552
+ // path: SVG path string
553
+ // labelPosition: midpoint for label placement
554
+ ```
555
+
556
+ #### Marquee Selection
557
+
558
+ ```typescript
559
+ import { createMarqueeRect, getElementsInMarquee, getElementsInScreenMarquee } from 'headless-vpl'
560
+
561
+ // Create normalized rect from two drag points
562
+ const rect = createMarqueeRect(startPos, endPos)
563
+
564
+ // Find elements in marquee (world coordinates)
565
+ const hits = getElementsInMarquee(elements, rect, 'partial') // 'full' | 'partial'
566
+
567
+ // Screen-coordinate version (auto-converts via viewport)
568
+ const hits = getElementsInScreenMarquee(elements, screenStart, screenEnd, viewport, 'partial')
569
+ ```
570
+
571
+ Elements must implement `{ id, position, width, height }`.
572
+
573
+ #### Snap to Grid
574
+
575
+ ```typescript
576
+ import { snapToGrid, snapDeltaToGrid } from 'headless-vpl'
577
+
578
+ const snapped = snapToGrid({ x: 37, y: 52 }, 24) // { x: 36, y: 48 }
579
+ const snappedDelta = snapDeltaToGrid({ x: 5, y: 3 }, 24) // rounds delta to grid
580
+ ```
581
+
582
+ #### Clipboard
583
+
584
+ ```typescript
585
+ import { copyElements, pasteElements } from 'headless-vpl'
586
+
587
+ // Copy selected elements to clipboard data
588
+ const data = copyElements(selectedElements)
589
+
590
+ // Paste with a factory function
591
+ const newElements = pasteElements(data, (json, position) => {
592
+ return new Container({
593
+ workspace,
594
+ position: new Position(position.x, position.y),
595
+ name: json.name as string,
596
+ width: json.width as number,
597
+ height: json.height as number,
598
+ })
599
+ }, { x: 40, y: 40 }) // offset from original
600
+ ```
601
+
602
+ #### Keyboard
603
+
604
+ ```typescript
605
+ import { KeyboardManager } from 'headless-vpl'
606
+
607
+ const keyboard = new KeyboardManager(document.body)
608
+
609
+ keyboard.bind({
610
+ key: 'z',
611
+ modifiers: ['ctrl'], // 'ctrl' matches both Ctrl and Cmd
612
+ handler: () => workspace.history.undo(),
613
+ })
614
+
615
+ keyboard.bind({
616
+ key: 'z',
617
+ modifiers: ['ctrl', 'shift'],
618
+ handler: () => workspace.history.redo(),
619
+ })
620
+
621
+ // Cleanup
622
+ keyboard.unbind('z', ['ctrl'])
623
+ keyboard.destroy()
624
+ ```
625
+
626
+ #### Auto Pan
627
+
628
+ ```typescript
629
+ import { computeAutoPan } from 'headless-vpl'
630
+
631
+ const result = computeAutoPan(
632
+ mousePos, // screen coordinates
633
+ canvasBounds, // { x, y, width, height }
634
+ isDragging, // only active while dragging
635
+ 40, // threshold (px from edge, default: 40)
636
+ 10, // speed (px/frame, default: 10)
637
+ )
638
+
639
+ if (result.active) {
640
+ workspace.panBy(result.dx, result.dy)
641
+ }
642
+ ```
643
+
644
+ #### Resize
645
+
646
+ ```typescript
647
+ import { detectResizeHandle, beginResize, applyResize } from 'headless-vpl'
648
+
649
+ // On mousedown: check if mouse is on a resize handle
650
+ const handle = detectResizeHandle(worldMousePos, container, 8) // handleSize
651
+
652
+ if (handle) {
653
+ // Start resize
654
+ const state = beginResize(handle, worldMousePos, container)
655
+
656
+ // Each frame during resize:
657
+ const bounds = applyResize(worldMousePos, state, {
658
+ minWidth: 50, maxWidth: 500,
659
+ minHeight: 50, maxHeight: 300,
660
+ })
661
+
662
+ container.move(bounds.x, bounds.y)
663
+ container.width = bounds.width
664
+ container.height = bounds.height
665
+ container.update()
666
+ }
667
+ ```
668
+
669
+ Handle directions: `'n'` | `'s'` | `'e'` | `'w'` | `'ne'` | `'nw'` | `'se'` | `'sw'`
670
+
671
+ #### Animation
672
+
673
+ ```typescript
674
+ import { animate } from 'headless-vpl/util/animate'
675
+
676
+ animate((deltaTime, frame) => {
677
+ // deltaTime: ms since last frame
678
+ // frame: incrementing counter
679
+ })
680
+ ```
681
+
682
+ #### Collision Detection
683
+
684
+ ```typescript
685
+ import { isCollision } from 'headless-vpl/util/collision_detecion'
686
+
687
+ const hit = isCollision(container, worldMousePos) // AABB point-in-rect
688
+ ```
689
+
690
+ #### Distance & Angle
691
+
692
+ ```typescript
693
+ import { getDistance, getAngle } from 'headless-vpl'
694
+
695
+ getDistance(pointA, pointB) // Euclidean distance
696
+ getAngle(pointA, pointB) // Angle in radians (atan2)
697
+ ```
698
+
699
+ #### DOM Controller
700
+
701
+ ```typescript
702
+ import { DomController } from 'headless-vpl/util/domController'
703
+
704
+ const ctrl = new DomController('#my-node') // CSS selector
705
+ ctrl.move(100, 200) // Set absolute position via CSS transform
706
+ ctrl.moveBy(10, 5) // Relative move
707
+ ctrl.getPosition() // { x, y }
708
+ ```
709
+
710
+ ---
711
+
712
+ ### Types
713
+
714
+ All exported types from `headless-vpl`:
715
+
716
+ | Type | Description |
717
+ |---|---|
718
+ | `IWorkspaceElement` | Interface for workspace elements (`id`, `position`, `move()`, `update()`, `toJSON()`) |
719
+ | `IEdge` | Interface for edges (`id`, `startPosition`, `endPosition`, `toJSON()`) |
720
+ | `IPosition` | `{ x: number, y: number }` |
721
+ | `Viewport` | `{ x: number, y: number, scale: number }` |
722
+ | `VplEvent` | Event object `{ type, target, data? }` |
723
+ | `VplEventType` | `'move' \| 'connect' \| 'disconnect' \| 'add' \| 'remove' \| 'update' \| 'pan' \| 'zoom' \| 'select' \| 'deselect'` |
724
+ | `SizingMode` | `'fixed' \| 'hug' \| 'fill'` |
725
+ | `Padding` | `{ top, right, bottom, left }` |
726
+ | `EdgeType` | `'straight' \| 'bezier' \| 'step' \| 'smoothstep'` |
727
+ | `MarkerType` | `'arrow' \| 'arrowClosed' \| 'none'` |
728
+ | `EdgeMarker` | `{ type: MarkerType, color?: string, size?: number }` |
729
+ | `ResizeHandleDirection` | `'n' \| 's' \| 'e' \| 'w' \| 'ne' \| 'nw' \| 'se' \| 'sw'` |
730
+ | `Command` | `{ execute(): void, undo(): void }` |
731
+ | `EdgePathResult` | `{ path: string, labelPosition: IPosition }` |
732
+ | `MarqueeRect` | `{ x, y, width, height }` |
733
+ | `MarqueeMode` | `'full' \| 'partial'` |
734
+ | `MarqueeElement` | `{ id, position, width, height }` |
735
+ | `ClipboardData` | `{ elements: Record<string, unknown>[] }` |
736
+ | `KeyBinding` | `{ key, modifiers?, handler }` |
737
+ | `CanvasBounds` | `{ x, y, width, height }` |
738
+ | `AutoPanResult` | `{ dx, dy, active }` |
739
+ | `ResizableElement` | `{ position, width, height, minWidth?, maxWidth?, minHeight?, maxHeight? }` |
740
+ | `ResizeState` | `{ handle, startMousePos, startBounds }` |
741
+ | `ConnectionValidator` | `() => boolean` |
742
+ | `SnapStrategy` | `(source, target, dragContainers) => boolean` |
743
+ | `SnapConnectionConfig` | Config object for `SnapConnection` constructor |
744
+
745
+ ---
746
+
747
+ ## Recipes
748
+
749
+ ### Block-type VPL (Scratch-style)
750
+
751
+ ```typescript
752
+ // Vertical stacking blocks with snap connections
753
+ const blockA = new Container({
754
+ workspace, position: new Position(100, 50), name: 'move',
755
+ width: 200, height: 60,
756
+ children: {
757
+ top: new Connector({ position: new Position(50, 0), name: 'top', type: 'input' }),
758
+ bottom: new Connector({ position: new Position(50, -60), name: 'bottom', type: 'output' }),
759
+ },
760
+ })
761
+
762
+ const blockB = new Container({
763
+ workspace, position: new Position(100, 200), name: 'turn',
764
+ width: 200, height: 60,
765
+ children: {
766
+ top: new Connector({ position: new Position(50, 0), name: 'top', type: 'input' }),
767
+ bottom: new Connector({ position: new Position(50, -60), name: 'bottom', type: 'output' }),
768
+ },
769
+ })
770
+
771
+ // Snap blockB's top to blockA's bottom
772
+ const connection = new SnapConnection({
773
+ source: blockB,
774
+ sourcePosition: blockB.children.top.position,
775
+ target: blockA,
776
+ targetPosition: blockA.children.bottom.position,
777
+ workspace,
778
+ strategy: childOnly,
779
+ })
780
+ ```
781
+
782
+ ### Flow-type VPL (ReactFlow-style)
783
+
784
+ ```typescript
785
+ // Horizontal flow with bezier edges
786
+ const start = new Container({
787
+ workspace, position: new Position(50, 100), name: 'Start',
788
+ width: 140, height: 50,
789
+ children: {
790
+ out: new Connector({ position: new Position(140, -25), name: 'out', type: 'output' }),
791
+ },
792
+ })
793
+
794
+ const process = new Container({
795
+ workspace, position: new Position(300, 100), name: 'Process',
796
+ width: 140, height: 50,
797
+ children: {
798
+ in: new Connector({ position: new Position(0, -25), name: 'in', type: 'input' }),
799
+ out: new Connector({ position: new Position(140, -25), name: 'out', type: 'output' }),
800
+ },
801
+ })
802
+
803
+ const end = new Container({
804
+ workspace, position: new Position(550, 100), name: 'End',
805
+ width: 140, height: 50,
806
+ children: {
807
+ in: new Connector({ position: new Position(0, -25), name: 'in', type: 'input' }),
808
+ },
809
+ })
810
+
811
+ new Edge({ start: start.children.out, end: process.children.in, edgeType: 'bezier' })
812
+ new Edge({
813
+ start: process.children.out, end: end.children.in,
814
+ edgeType: 'bezier',
815
+ label: 'result',
816
+ markerEnd: { type: 'arrowClosed' },
817
+ })
818
+ ```
819
+
820
+ ## Development
821
+
822
+ ```bash
823
+ npm install && npm run dev # Start dev server
824
+ npm run build # Production build
825
+ ```
826
+
827
+ ## License
828
+
829
+ MIT