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/LICENSE +21 -0
- package/README.md +829 -0
- package/dist/core/AutoLayout.d.ts +51 -0
- package/dist/core/Connector.d.ts +14 -0
- package/dist/core/Container.d.ts +54 -0
- package/dist/core/Edge.d.ts +42 -0
- package/dist/core/EventBus.d.ts +10 -0
- package/dist/core/History.d.ts +28 -0
- package/dist/core/MovableObject.d.ts +29 -0
- package/dist/core/Position.d.ts +12 -0
- package/dist/core/SelectionManager.d.ts +19 -0
- package/dist/core/Workspace.d.ts +40 -0
- package/dist/core/commands.d.ts +55 -0
- package/dist/core/types.d.ts +51 -0
- package/dist/headless-vpl.js +1107 -0
- package/dist/index.d.ts +35 -0
- package/dist/rendering/SvgRenderer.d.ts +35 -0
- package/dist/util/animate.d.ts +8 -0
- package/dist/util/autoPan.d.ts +23 -0
- package/dist/util/clipboard.d.ts +20 -0
- package/dist/util/collision_detecion.d.ts +3 -0
- package/dist/util/distance.d.ts +3 -0
- package/dist/util/dnd.d.ts +18 -0
- package/dist/util/domController.d.ts +13 -0
- package/dist/util/edgeBuilder.d.ts +65 -0
- package/dist/util/edgePath.d.ts +24 -0
- package/dist/util/keyboard.d.ts +21 -0
- package/dist/util/marquee.d.ts +30 -0
- package/dist/util/mouse.d.ts +16 -0
- package/dist/util/moveContainersGroup.d.ts +5 -0
- package/dist/util/resize.d.ts +53 -0
- package/dist/util/snap.d.ts +68 -0
- package/dist/util/snapToGrid.d.ts +10 -0
- package/dist/util/viewport.d.ts +4 -0
- package/package.json +55 -0
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
|