@dfosco/storyboard-core 4.1.0-beta.3 → 4.2.0-alpha.10
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/commandpalette.config.json +13 -1
- package/package.json +7 -3
- package/scaffold/agents/terminal-agent.agent.md +144 -0
- package/src/canvas/collision.js +119 -33
- package/src/canvas/collision.test.js +100 -0
- package/src/canvas/materializer.js +47 -0
- package/src/canvas/materializer.test.js +104 -0
- package/src/canvas/server.js +842 -5
- package/src/canvas/terminal-agent-instructions.md +95 -0
- package/src/canvas/terminal-config.js +208 -0
- package/src/canvas/terminal-registry.js +427 -0
- package/src/canvas/terminal-server.js +525 -0
- package/src/canvasConfig.js +13 -1
- package/src/cli/agent.js +85 -0
- package/src/cli/canvasAdd.js +10 -1
- package/src/cli/canvasBounds.js +160 -0
- package/src/cli/canvasRead.js +30 -2
- package/src/cli/canvasUpdate.js +179 -0
- package/src/cli/exit.js +23 -24
- package/src/cli/index.js +42 -0
- package/src/cli/proxy.js +9 -0
- package/src/cli/schemas.js +5 -0
- package/src/cli/server.js +147 -25
- package/src/cli/serverUrl.js +5 -0
- package/src/cli/sessions.js +333 -0
- package/src/cli/setup.js +25 -1
- package/src/cli/terminal-commands.js +276 -0
- package/src/cli/terminal-welcome.js +133 -0
- package/src/index.js +1 -1
- package/src/mountStoryboardCore.js +10 -3
- package/src/server/index.js +6 -1
- package/src/viewfinder.js +15 -0
- package/src/vite/server-plugin.js +61 -1
- package/src/worktree/serverRegistry.js +120 -0
- package/widgets.config.json +290 -11
|
@@ -12,6 +12,12 @@
|
|
|
12
12
|
{ "id": "hide-toolbars", "label": "Hide Toolbars" }
|
|
13
13
|
]
|
|
14
14
|
},
|
|
15
|
+
{ "id": "sep0", "items": [] },
|
|
16
|
+
{
|
|
17
|
+
"id": "starred",
|
|
18
|
+
"title": "Starred",
|
|
19
|
+
"source": "starred"
|
|
20
|
+
},
|
|
15
21
|
{ "id": "sep1", "items": [] },
|
|
16
22
|
{
|
|
17
23
|
"id": "tools2",
|
|
@@ -31,6 +37,12 @@
|
|
|
31
37
|
"title": "Create new",
|
|
32
38
|
"source": "create"
|
|
33
39
|
},
|
|
40
|
+
{ "id": "sep3b", "items": [] },
|
|
41
|
+
{
|
|
42
|
+
"id": "create-widget",
|
|
43
|
+
"title": "Add widget to canvas",
|
|
44
|
+
"source": "create-widget"
|
|
45
|
+
},
|
|
34
46
|
{ "id": "sep3", "items": [] },
|
|
35
47
|
{
|
|
36
48
|
"id": "prototypes",
|
|
@@ -46,7 +58,7 @@
|
|
|
46
58
|
{ "id": "sep5", "items": [] },
|
|
47
59
|
{
|
|
48
60
|
"id": "stories",
|
|
49
|
-
"title": "
|
|
61
|
+
"title": "Components",
|
|
50
62
|
"source": "stories"
|
|
51
63
|
},
|
|
52
64
|
{ "id": "sep6", "items": [] },
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dfosco/storyboard-core",
|
|
3
|
-
"version": "4.
|
|
3
|
+
"version": "4.2.0-alpha.10",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"bin": {
|
|
@@ -66,7 +66,8 @@
|
|
|
66
66
|
"highlight.js": "^11.11.1",
|
|
67
67
|
"html-to-image": "^1.11.13",
|
|
68
68
|
"iconoir": "^7.11.0",
|
|
69
|
-
"jsonc-parser": "^3.3.1"
|
|
69
|
+
"jsonc-parser": "^3.3.1",
|
|
70
|
+
"ws": "^8.0.0"
|
|
70
71
|
},
|
|
71
72
|
"devDependencies": {
|
|
72
73
|
"@lucide/svelte": "^1.7.0",
|
|
@@ -83,5 +84,8 @@
|
|
|
83
84
|
"tailwindcss": "^4.2.2",
|
|
84
85
|
"tw-animate-css": "^1.4.0",
|
|
85
86
|
"vite": "^6.0.0"
|
|
87
|
+
},
|
|
88
|
+
"optionalDependencies": {
|
|
89
|
+
"node-pty": "^1.0.0"
|
|
86
90
|
}
|
|
87
|
-
}
|
|
91
|
+
}
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: terminal-agent
|
|
3
|
+
description: "Canvas-aware terminal agent that reads connected widget context and signals completion via the storyboard API."
|
|
4
|
+
tools:
|
|
5
|
+
- read
|
|
6
|
+
- edit
|
|
7
|
+
- shell
|
|
8
|
+
- search
|
|
9
|
+
---
|
|
10
|
+
|
|
11
|
+
# Terminal Agent Context
|
|
12
|
+
|
|
13
|
+
Before processing ANY user prompt, read the terminal config file for this session.
|
|
14
|
+
|
|
15
|
+
## Step 1: Read terminal config
|
|
16
|
+
|
|
17
|
+
Your widget ID is available via `$STORYBOARD_WIDGET_ID`. Use it to read your config directly:
|
|
18
|
+
```bash
|
|
19
|
+
cat .storyboard/terminals/${STORYBOARD_WIDGET_ID}.json
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
If the env var is empty, source it from the terminal env file first:
|
|
23
|
+
```bash
|
|
24
|
+
# Find the env file for this tmux session
|
|
25
|
+
ENV_FILE=$(ls -t .storyboard/terminals/*.env 2>/dev/null | head -1)
|
|
26
|
+
if [ -n "$ENV_FILE" ]; then source "$ENV_FILE"; fi
|
|
27
|
+
cat .storyboard/terminals/${STORYBOARD_WIDGET_ID}.json
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
As a last resort, list all configs and pick the most recent non-deleted one with `connectedWidgets`:
|
|
31
|
+
```bash
|
|
32
|
+
cat .storyboard/terminals/*.json
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
The config file contains everything you need — no additional API calls required:
|
|
36
|
+
|
|
37
|
+
```json
|
|
38
|
+
{
|
|
39
|
+
"widgetId": "terminal-abc123",
|
|
40
|
+
"canvasId": "storyboarding/my-canvas",
|
|
41
|
+
"branch": "4.2.0--terminal-agents",
|
|
42
|
+
"worktree": "4.2.0--terminal-agents",
|
|
43
|
+
"devDomain": "storyboard-core",
|
|
44
|
+
"serverUrl": "http://localhost:1269",
|
|
45
|
+
"workingDirectory": "/path/to/worktree",
|
|
46
|
+
"connectedWidgets": [
|
|
47
|
+
{
|
|
48
|
+
"id": "sticky-def456",
|
|
49
|
+
"type": "sticky-note",
|
|
50
|
+
"props": { "text": "Build a login form", "color": "yellow" }
|
|
51
|
+
},
|
|
52
|
+
{
|
|
53
|
+
"id": "markdown-ghi789",
|
|
54
|
+
"type": "markdown",
|
|
55
|
+
"props": { "content": "# Requirements\n- Email + password\n- OAuth support" }
|
|
56
|
+
}
|
|
57
|
+
]
|
|
58
|
+
}
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
## Step 2: Use connected widget context
|
|
62
|
+
|
|
63
|
+
The `connectedWidgets` array contains the FULL props of every widget connected to this terminal. This is your highest priority context:
|
|
64
|
+
|
|
65
|
+
- **sticky-note**: `props.text` — instructions, notes, or requirements
|
|
66
|
+
- **markdown**: `props.content` — documentation, specs, or prose
|
|
67
|
+
- **image**: `props.src` — image filename at `assets/canvas/images/{props.src}`
|
|
68
|
+
- **story**: `props.storyId` + `props.exportName` — component to work with
|
|
69
|
+
- **link-preview**: `props.url` — external reference
|
|
70
|
+
- **prototype**: `props.src` — prototype path
|
|
71
|
+
|
|
72
|
+
Interpret the user's prompt in light of these connected widgets.
|
|
73
|
+
|
|
74
|
+
## Step 3: Prefer CLI commands for canvas operations
|
|
75
|
+
|
|
76
|
+
**Always prefer `npx storyboard` CLI commands over HTTP API calls.** CLI commands run directly in the worktree and resolve the dev server automatically — no port numbers or URLs needed.
|
|
77
|
+
|
|
78
|
+
### Reading canvas state
|
|
79
|
+
```bash
|
|
80
|
+
npx storyboard canvas read <canvas-name> --json
|
|
81
|
+
npx storyboard canvas read <canvas-name> --id <widget-id> --json
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
### Updating a widget
|
|
85
|
+
```bash
|
|
86
|
+
# Update text on a sticky note
|
|
87
|
+
npx storyboard canvas update <widget-id> --canvas <canvas-name> --text "New text"
|
|
88
|
+
|
|
89
|
+
# Update markdown content
|
|
90
|
+
npx storyboard canvas update <widget-id> --canvas <canvas-name> --content "# New heading"
|
|
91
|
+
|
|
92
|
+
# Update arbitrary props
|
|
93
|
+
npx storyboard canvas update <widget-id> --canvas <canvas-name> --props '{"key":"value"}'
|
|
94
|
+
|
|
95
|
+
# Move a widget
|
|
96
|
+
npx storyboard canvas update <widget-id> --canvas <canvas-name> --x 100 --y 200
|
|
97
|
+
|
|
98
|
+
# Shorthand flags: --text, --content, --src, --url, --color
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
### Adding a widget
|
|
102
|
+
```bash
|
|
103
|
+
npx storyboard canvas add sticky-note --canvas <canvas-name> --props '{"text":"Hello"}'
|
|
104
|
+
npx storyboard canvas add markdown --canvas <canvas-name> --x 100 --y 200
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
**Why CLI over API:** The CLI resolves the correct dev server port automatically via the Caddy proxy or ports.json. You never need to know the port number. All commands work from any worktree directory.
|
|
108
|
+
|
|
109
|
+
## Step 4: Signal completion
|
|
110
|
+
|
|
111
|
+
When your task is complete:
|
|
112
|
+
```bash
|
|
113
|
+
npx storyboard agent signal --status done --message "Brief summary"
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
On failure:
|
|
117
|
+
```bash
|
|
118
|
+
npx storyboard agent signal --status error --message "What went wrong"
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
**IMPORTANT:**
|
|
122
|
+
- NEVER write directly to `.canvas.jsonl` files — use the canvas CLI or server API
|
|
123
|
+
- **Prefer CLI commands** (`npx storyboard canvas ...`) over direct HTTP calls — they resolve ports automatically
|
|
124
|
+
- Only fall back to HTTP API (`{serverUrl}/_storyboard/canvas/`) if the CLI doesn't support the operation
|
|
125
|
+
- Environment variables `$STORYBOARD_WIDGET_ID`, `$STORYBOARD_CANVAS_ID`, `$STORYBOARD_BRANCH`, `$STORYBOARD_SERVER_URL` are also available in the shell
|
|
126
|
+
|
|
127
|
+
## HTTP API Reference (fallback only)
|
|
128
|
+
|
|
129
|
+
If the CLI fails, use these endpoints. The `serverUrl` is in your terminal config or `$STORYBOARD_SERVER_URL`.
|
|
130
|
+
|
|
131
|
+
### Safe: Update a single widget (PATCH)
|
|
132
|
+
```bash
|
|
133
|
+
curl -s -X PATCH "${STORYBOARD_SERVER_URL}/_storyboard/canvas/widget" \
|
|
134
|
+
-H "Content-Type: application/json" \
|
|
135
|
+
-d '{"name":"<canvasId>","widgetId":"<widgetId>","props":{"text":"new value"}}'
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
### Safe: Read canvas state (GET)
|
|
139
|
+
```bash
|
|
140
|
+
curl -s "${STORYBOARD_SERVER_URL}/_storyboard/canvas/<canvasId>"
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
### ⚠️ NEVER use `PUT /_storyboard/canvas/update` with a `widgets` array
|
|
144
|
+
That endpoint **replaces ALL widgets** in the canvas. Sending one widget = deleting everything else.
|
package/src/canvas/collision.js
CHANGED
|
@@ -5,17 +5,47 @@
|
|
|
5
5
|
* existing widgets and adjusts the position until no collisions remain.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
+
import widgetsConfig from '../../widgets.config.json' with { type: 'json' }
|
|
9
|
+
|
|
10
|
+
const FALLBACK_SIZE = { width: 270, height: 170 }
|
|
11
|
+
|
|
8
12
|
/**
|
|
9
|
-
*
|
|
13
|
+
* Hardcoded fallbacks for widget types that don't specify defaults in config.
|
|
10
14
|
*/
|
|
11
|
-
|
|
12
|
-
'sticky-note': { width: 270, height: 170 },
|
|
15
|
+
const HARDCODED_DEFAULTS = {
|
|
13
16
|
'markdown': { width: 530, height: 240 },
|
|
14
|
-
'prototype': { width: 800, height: 600 },
|
|
15
|
-
'figma-embed': { width: 800, height: 450 },
|
|
16
17
|
'image': { width: 400, height: 300 },
|
|
17
|
-
'link-preview': { width:
|
|
18
|
+
'link-preview': { width: 320, height: 200 },
|
|
18
19
|
'component': { width: 300, height: 200 },
|
|
20
|
+
'story': { width: 780, height: 420 },
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Default widget sizes derived from widgets.config.json prop defaults,
|
|
25
|
+
* with hardcoded fallbacks for types that don't specify both width+height.
|
|
26
|
+
*/
|
|
27
|
+
export const DEFAULT_SIZES = buildDefaultSizes()
|
|
28
|
+
|
|
29
|
+
function buildDefaultSizes() {
|
|
30
|
+
const sizes = {}
|
|
31
|
+
const widgets = widgetsConfig.widgets || {}
|
|
32
|
+
for (const [type, config] of Object.entries(widgets)) {
|
|
33
|
+
const props = config.props || {}
|
|
34
|
+
const hardcoded = HARDCODED_DEFAULTS[type]
|
|
35
|
+
const w = props.width?.default ?? hardcoded?.width ?? FALLBACK_SIZE.width
|
|
36
|
+
const h = props.height?.default ?? hardcoded?.height ?? FALLBACK_SIZE.height
|
|
37
|
+
sizes[type] = { width: w, height: h }
|
|
38
|
+
}
|
|
39
|
+
return sizes
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Get the default size for a widget type from widgets.config.json.
|
|
44
|
+
* @param {string} type - Widget type
|
|
45
|
+
* @returns {{ width: number, height: number }}
|
|
46
|
+
*/
|
|
47
|
+
export function getDefaultSize(type) {
|
|
48
|
+
return DEFAULT_SIZES[type] || FALLBACK_SIZE
|
|
19
49
|
}
|
|
20
50
|
|
|
21
51
|
/**
|
|
@@ -25,7 +55,7 @@ export const DEFAULT_SIZES = {
|
|
|
25
55
|
*/
|
|
26
56
|
export function getWidgetBounds(widget) {
|
|
27
57
|
const { position = { x: 0, y: 0 }, props = {}, type } = widget
|
|
28
|
-
const defaults =
|
|
58
|
+
const defaults = getDefaultSize(type)
|
|
29
59
|
return {
|
|
30
60
|
x: position.x,
|
|
31
61
|
y: position.y,
|
|
@@ -34,6 +64,42 @@ export function getWidgetBounds(widget) {
|
|
|
34
64
|
}
|
|
35
65
|
}
|
|
36
66
|
|
|
67
|
+
/**
|
|
68
|
+
* Compute persistent bounds metadata for a widget.
|
|
69
|
+
* Returns { width, height, startX, startY, endX, endY }.
|
|
70
|
+
* @param {object} widget - Widget with position, props, and type
|
|
71
|
+
* @returns {{ width: number, height: number, startX: number, startY: number, endX: number, endY: number }}
|
|
72
|
+
*/
|
|
73
|
+
export function computeWidgetBounds(widget) {
|
|
74
|
+
const { x, y, width, height } = getWidgetBounds(widget)
|
|
75
|
+
return {
|
|
76
|
+
width,
|
|
77
|
+
height,
|
|
78
|
+
startX: x,
|
|
79
|
+
startY: y,
|
|
80
|
+
endX: x + width,
|
|
81
|
+
endY: y + height,
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Stamp bounds metadata onto a widget, returning a new widget object.
|
|
87
|
+
* @param {object} widget
|
|
88
|
+
* @returns {object} Widget with `bounds` field
|
|
89
|
+
*/
|
|
90
|
+
export function stampBounds(widget) {
|
|
91
|
+
return { ...widget, bounds: computeWidgetBounds(widget) }
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Stamp bounds on every widget in an array.
|
|
96
|
+
* @param {object[]} widgets
|
|
97
|
+
* @returns {object[]}
|
|
98
|
+
*/
|
|
99
|
+
export function stampBoundsAll(widgets) {
|
|
100
|
+
return widgets.map(stampBounds)
|
|
101
|
+
}
|
|
102
|
+
|
|
37
103
|
/**
|
|
38
104
|
* Check if two rectangles overlap.
|
|
39
105
|
* @param {{ x: number, y: number, width: number, height: number }} a
|
|
@@ -54,17 +120,31 @@ export function rectsOverlap(a, b) {
|
|
|
54
120
|
* @param {{ x: number, y: number, width: number, height: number }} rect - Proposed bounds
|
|
55
121
|
* @param {object[]} widgets - Existing widgets array
|
|
56
122
|
* @param {string} [excludeId] - Widget ID to exclude (for move operations)
|
|
57
|
-
* @returns {object
|
|
123
|
+
* @returns {object[]} - All colliding widgets (empty array if none)
|
|
58
124
|
*/
|
|
59
|
-
export function
|
|
125
|
+
export function findCollisions(rect, widgets, excludeId = null) {
|
|
126
|
+
const colliders = []
|
|
60
127
|
for (const widget of widgets) {
|
|
61
128
|
if (excludeId && widget.id === excludeId) continue
|
|
62
129
|
const bounds = getWidgetBounds(widget)
|
|
63
130
|
if (rectsOverlap(rect, bounds)) {
|
|
64
|
-
|
|
131
|
+
colliders.push(widget)
|
|
65
132
|
}
|
|
66
133
|
}
|
|
67
|
-
return
|
|
134
|
+
return colliders
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Check if a proposed position collides with any existing widget.
|
|
139
|
+
* Returns the first colliding widget (for backwards compatibility).
|
|
140
|
+
* @param {{ x: number, y: number, width: number, height: number }} rect - Proposed bounds
|
|
141
|
+
* @param {object[]} widgets - Existing widgets array
|
|
142
|
+
* @param {string} [excludeId] - Widget ID to exclude (for move operations)
|
|
143
|
+
* @returns {object|null} - The first colliding widget, or null if no collision
|
|
144
|
+
*/
|
|
145
|
+
export function findCollision(rect, widgets, excludeId = null) {
|
|
146
|
+
const colliders = findCollisions(rect, widgets, excludeId)
|
|
147
|
+
return colliders.length > 0 ? colliders[0] : null
|
|
68
148
|
}
|
|
69
149
|
|
|
70
150
|
/**
|
|
@@ -82,9 +162,10 @@ export function snapToGrid(value, gridSize) {
|
|
|
82
162
|
*
|
|
83
163
|
* Strategy:
|
|
84
164
|
* 1. Try the initial position
|
|
85
|
-
* 2. If collision,
|
|
86
|
-
* 3.
|
|
87
|
-
* 4.
|
|
165
|
+
* 2. If collision, find the max endX among all colliders and move past it
|
|
166
|
+
* 3. Repeat until no collisions or maxIterations exhausted
|
|
167
|
+
* 4. If horizontal resolution exhausted, fall back to moving down
|
|
168
|
+
* 5. Snap final position to grid
|
|
88
169
|
*
|
|
89
170
|
* @param {object} options
|
|
90
171
|
* @param {number} options.x - Initial X position
|
|
@@ -113,15 +194,13 @@ export function findFreePosition({
|
|
|
113
194
|
let currentX = x
|
|
114
195
|
let currentY = y
|
|
115
196
|
let adjusted = false
|
|
116
|
-
let iteration = 0
|
|
117
197
|
|
|
118
|
-
// Phase 1: Try moving right
|
|
119
|
-
|
|
198
|
+
// Phase 1: Try moving right past all colliders
|
|
199
|
+
for (let i = 0; i < maxIterations; i++) {
|
|
120
200
|
const rect = { x: currentX, y: currentY, width, height }
|
|
121
|
-
const
|
|
201
|
+
const colliders = findCollisions(rect, widgets, excludeId)
|
|
122
202
|
|
|
123
|
-
if (
|
|
124
|
-
// No collision — snap and return
|
|
203
|
+
if (colliders.length === 0) {
|
|
125
204
|
return {
|
|
126
205
|
x: snapToGrid(currentX, gridSize),
|
|
127
206
|
y: snapToGrid(currentY, gridSize),
|
|
@@ -129,22 +208,25 @@ export function findFreePosition({
|
|
|
129
208
|
}
|
|
130
209
|
}
|
|
131
210
|
|
|
132
|
-
//
|
|
133
|
-
|
|
134
|
-
|
|
211
|
+
// Jump past the rightmost edge of all colliders
|
|
212
|
+
let maxEndX = 0
|
|
213
|
+
for (const c of colliders) {
|
|
214
|
+
const b = getWidgetBounds(c)
|
|
215
|
+
const endX = b.x + b.width
|
|
216
|
+
if (endX > maxEndX) maxEndX = endX
|
|
217
|
+
}
|
|
218
|
+
currentX = maxEndX + spacing
|
|
135
219
|
adjusted = true
|
|
136
|
-
iteration++
|
|
137
220
|
}
|
|
138
221
|
|
|
139
222
|
// Phase 2: Reset X, try moving down
|
|
140
223
|
currentX = x
|
|
141
|
-
iteration = 0
|
|
142
224
|
|
|
143
|
-
|
|
225
|
+
for (let i = 0; i < maxIterations; i++) {
|
|
144
226
|
const rect = { x: currentX, y: currentY, width, height }
|
|
145
|
-
const
|
|
227
|
+
const colliders = findCollisions(rect, widgets, excludeId)
|
|
146
228
|
|
|
147
|
-
if (
|
|
229
|
+
if (colliders.length === 0) {
|
|
148
230
|
return {
|
|
149
231
|
x: snapToGrid(currentX, gridSize),
|
|
150
232
|
y: snapToGrid(currentY, gridSize),
|
|
@@ -152,11 +234,15 @@ export function findFreePosition({
|
|
|
152
234
|
}
|
|
153
235
|
}
|
|
154
236
|
|
|
155
|
-
//
|
|
156
|
-
|
|
157
|
-
|
|
237
|
+
// Jump past the bottommost edge of all colliders
|
|
238
|
+
let maxEndY = 0
|
|
239
|
+
for (const c of colliders) {
|
|
240
|
+
const b = getWidgetBounds(c)
|
|
241
|
+
const endY = b.y + b.height
|
|
242
|
+
if (endY > maxEndY) maxEndY = endY
|
|
243
|
+
}
|
|
244
|
+
currentY = maxEndY + spacing
|
|
158
245
|
adjusted = true
|
|
159
|
-
iteration++
|
|
160
246
|
}
|
|
161
247
|
|
|
162
248
|
// Fallback: return the last attempted position (snapped)
|
|
@@ -190,7 +276,7 @@ export function resolvePosition({
|
|
|
190
276
|
excludeId = null,
|
|
191
277
|
gridSize = 24,
|
|
192
278
|
}) {
|
|
193
|
-
const defaults =
|
|
279
|
+
const defaults = getDefaultSize(type)
|
|
194
280
|
const width = props.width ?? defaults.width
|
|
195
281
|
const height = props.height ?? defaults.height
|
|
196
282
|
|
|
@@ -1,8 +1,12 @@
|
|
|
1
1
|
import { describe, it, expect } from 'vitest'
|
|
2
2
|
import {
|
|
3
3
|
getWidgetBounds,
|
|
4
|
+
computeWidgetBounds,
|
|
5
|
+
stampBounds,
|
|
6
|
+
stampBoundsAll,
|
|
4
7
|
rectsOverlap,
|
|
5
8
|
findCollision,
|
|
9
|
+
findCollisions,
|
|
6
10
|
snapToGrid,
|
|
7
11
|
findFreePosition,
|
|
8
12
|
resolvePosition,
|
|
@@ -57,6 +61,24 @@ describe('getWidgetBounds', () => {
|
|
|
57
61
|
height: 170,
|
|
58
62
|
})
|
|
59
63
|
})
|
|
64
|
+
|
|
65
|
+
it('resolves story widget type from DEFAULT_SIZES', () => {
|
|
66
|
+
const widget = { type: 'story', position: { x: 0, y: 0 }, props: {} }
|
|
67
|
+
expect(getWidgetBounds(widget)).toEqual({
|
|
68
|
+
x: 0, y: 0,
|
|
69
|
+
width: DEFAULT_SIZES['story'].width,
|
|
70
|
+
height: DEFAULT_SIZES['story'].height,
|
|
71
|
+
})
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
it('resolves codepen-embed widget type from DEFAULT_SIZES', () => {
|
|
75
|
+
const widget = { type: 'codepen-embed', position: { x: 0, y: 0 }, props: {} }
|
|
76
|
+
expect(getWidgetBounds(widget)).toEqual({
|
|
77
|
+
x: 0, y: 0,
|
|
78
|
+
width: DEFAULT_SIZES['codepen-embed'].width,
|
|
79
|
+
height: DEFAULT_SIZES['codepen-embed'].height,
|
|
80
|
+
})
|
|
81
|
+
})
|
|
60
82
|
})
|
|
61
83
|
|
|
62
84
|
describe('rectsOverlap', () => {
|
|
@@ -117,6 +139,32 @@ describe('findCollision', () => {
|
|
|
117
139
|
})
|
|
118
140
|
})
|
|
119
141
|
|
|
142
|
+
describe('findCollisions', () => {
|
|
143
|
+
const widgets = [
|
|
144
|
+
{ id: 'w1', type: 'sticky-note', position: { x: 0, y: 0 }, props: { width: 270, height: 170 } },
|
|
145
|
+
{ id: 'w2', type: 'sticky-note', position: { x: 200, y: 0 }, props: { width: 270, height: 170 } },
|
|
146
|
+
]
|
|
147
|
+
|
|
148
|
+
it('returns all colliding widgets', () => {
|
|
149
|
+
const rect = { x: 100, y: 50, width: 270, height: 170 }
|
|
150
|
+
const colliders = findCollisions(rect, widgets)
|
|
151
|
+
expect(colliders).toHaveLength(2)
|
|
152
|
+
expect(colliders.map((w) => w.id).sort()).toEqual(['w1', 'w2'])
|
|
153
|
+
})
|
|
154
|
+
|
|
155
|
+
it('returns empty array when no collision', () => {
|
|
156
|
+
const rect = { x: 0, y: 200, width: 270, height: 170 }
|
|
157
|
+
expect(findCollisions(rect, widgets)).toEqual([])
|
|
158
|
+
})
|
|
159
|
+
|
|
160
|
+
it('excludes specified widget ID', () => {
|
|
161
|
+
const rect = { x: 100, y: 50, width: 270, height: 170 }
|
|
162
|
+
const colliders = findCollisions(rect, widgets, 'w1')
|
|
163
|
+
expect(colliders).toHaveLength(1)
|
|
164
|
+
expect(colliders[0].id).toBe('w2')
|
|
165
|
+
})
|
|
166
|
+
})
|
|
167
|
+
|
|
120
168
|
describe('snapToGrid', () => {
|
|
121
169
|
it('snaps to nearest grid line', () => {
|
|
122
170
|
expect(snapToGrid(25, 24)).toBe(24)
|
|
@@ -269,3 +317,55 @@ describe('resolvePosition', () => {
|
|
|
269
317
|
expect(result).toEqual({ x: 504, y: 504, adjusted: false })
|
|
270
318
|
})
|
|
271
319
|
})
|
|
320
|
+
|
|
321
|
+
describe('computeWidgetBounds', () => {
|
|
322
|
+
it('computes bounds from position and size', () => {
|
|
323
|
+
const widget = { type: 'sticky-note', position: { x: 100, y: 200 }, props: { width: 270, height: 170 } }
|
|
324
|
+
expect(computeWidgetBounds(widget)).toEqual({
|
|
325
|
+
width: 270, height: 170,
|
|
326
|
+
startX: 100, startY: 200,
|
|
327
|
+
endX: 370, endY: 370,
|
|
328
|
+
})
|
|
329
|
+
})
|
|
330
|
+
|
|
331
|
+
it('uses defaults when props missing', () => {
|
|
332
|
+
const widget = { type: 'sticky-note', position: { x: 0, y: 0 }, props: {} }
|
|
333
|
+
expect(computeWidgetBounds(widget)).toEqual({
|
|
334
|
+
width: 270, height: 170,
|
|
335
|
+
startX: 0, startY: 0,
|
|
336
|
+
endX: 270, endY: 170,
|
|
337
|
+
})
|
|
338
|
+
})
|
|
339
|
+
})
|
|
340
|
+
|
|
341
|
+
describe('stampBounds', () => {
|
|
342
|
+
it('adds bounds field to widget', () => {
|
|
343
|
+
const widget = { id: 'w1', type: 'sticky-note', position: { x: 48, y: 24 }, props: { width: 270, height: 170 } }
|
|
344
|
+
const stamped = stampBounds(widget)
|
|
345
|
+
expect(stamped.bounds).toEqual({
|
|
346
|
+
width: 270, height: 170,
|
|
347
|
+
startX: 48, startY: 24,
|
|
348
|
+
endX: 318, endY: 194,
|
|
349
|
+
})
|
|
350
|
+
expect(stamped.id).toBe('w1')
|
|
351
|
+
})
|
|
352
|
+
|
|
353
|
+
it('does not mutate original widget', () => {
|
|
354
|
+
const widget = { id: 'w1', type: 'sticky-note', position: { x: 0, y: 0 }, props: {} }
|
|
355
|
+
stampBounds(widget)
|
|
356
|
+
expect(widget.bounds).toBeUndefined()
|
|
357
|
+
})
|
|
358
|
+
})
|
|
359
|
+
|
|
360
|
+
describe('stampBoundsAll', () => {
|
|
361
|
+
it('stamps bounds on all widgets', () => {
|
|
362
|
+
const widgets = [
|
|
363
|
+
{ id: 'w1', type: 'sticky-note', position: { x: 0, y: 0 }, props: { width: 270, height: 170 } },
|
|
364
|
+
{ id: 'w2', type: 'markdown', position: { x: 300, y: 0 }, props: { width: 530, height: 240 } },
|
|
365
|
+
]
|
|
366
|
+
const stamped = stampBoundsAll(widgets)
|
|
367
|
+
expect(stamped).toHaveLength(2)
|
|
368
|
+
expect(stamped[0].bounds.endX).toBe(270)
|
|
369
|
+
expect(stamped[1].bounds.endX).toBe(830)
|
|
370
|
+
})
|
|
371
|
+
})
|
|
@@ -13,6 +13,8 @@
|
|
|
13
13
|
* settings_updated — patch canvas-level settings
|
|
14
14
|
* source_updated — replace the sources array
|
|
15
15
|
* widgets_replaced — replace the entire widgets array (bulk update)
|
|
16
|
+
* connector_added — append a connector between two widgets
|
|
17
|
+
* connector_removed — remove a connector by id
|
|
16
18
|
*/
|
|
17
19
|
|
|
18
20
|
/**
|
|
@@ -101,6 +103,7 @@ export function materialize(events) {
|
|
|
101
103
|
const initial = { ...evt }
|
|
102
104
|
delete initial.event
|
|
103
105
|
delete initial.timestamp
|
|
106
|
+
if (!initial.connectors) initial.connectors = []
|
|
104
107
|
state = initial
|
|
105
108
|
break
|
|
106
109
|
}
|
|
@@ -131,6 +134,12 @@ export function materialize(events) {
|
|
|
131
134
|
state.widgets = (state.widgets || []).filter(
|
|
132
135
|
(w) => w.id !== evt.widgetId,
|
|
133
136
|
)
|
|
137
|
+
// Cascade: remove connectors referencing the deleted widget
|
|
138
|
+
if (state.connectors?.length) {
|
|
139
|
+
state.connectors = state.connectors.filter(
|
|
140
|
+
(c) => c.start.widgetId !== evt.widgetId && c.end.widgetId !== evt.widgetId,
|
|
141
|
+
)
|
|
142
|
+
}
|
|
134
143
|
break
|
|
135
144
|
}
|
|
136
145
|
|
|
@@ -150,6 +159,26 @@ export function materialize(events) {
|
|
|
150
159
|
|
|
151
160
|
case 'widgets_replaced': {
|
|
152
161
|
state.widgets = evt.widgets
|
|
162
|
+
// Orphan cleanup: remove connectors referencing deleted widgets
|
|
163
|
+
if (state.connectors?.length) {
|
|
164
|
+
const widgetIds = new Set((state.widgets || []).map((w) => w.id))
|
|
165
|
+
state.connectors = state.connectors.filter(
|
|
166
|
+
(c) => widgetIds.has(c.start.widgetId) && widgetIds.has(c.end.widgetId),
|
|
167
|
+
)
|
|
168
|
+
}
|
|
169
|
+
break
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
case 'connector_added': {
|
|
173
|
+
if (!state.connectors) state.connectors = []
|
|
174
|
+
state.connectors = [...state.connectors, evt.connector]
|
|
175
|
+
break
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
case 'connector_removed': {
|
|
179
|
+
state.connectors = (state.connectors || []).filter(
|
|
180
|
+
(c) => c.id !== evt.connectorId,
|
|
181
|
+
)
|
|
153
182
|
break
|
|
154
183
|
}
|
|
155
184
|
|
|
@@ -157,6 +186,24 @@ export function materialize(events) {
|
|
|
157
186
|
}
|
|
158
187
|
}
|
|
159
188
|
|
|
189
|
+
// Derive connectorIds on widgets from the connectors array
|
|
190
|
+
if (state.connectors?.length && state.widgets?.length) {
|
|
191
|
+
// Build a map: widgetId → Set of connectorIds
|
|
192
|
+
const widgetConnMap = new Map()
|
|
193
|
+
for (const conn of state.connectors) {
|
|
194
|
+
for (const endpoint of [conn.start, conn.end]) {
|
|
195
|
+
if (!widgetConnMap.has(endpoint.widgetId)) {
|
|
196
|
+
widgetConnMap.set(endpoint.widgetId, new Set())
|
|
197
|
+
}
|
|
198
|
+
widgetConnMap.get(endpoint.widgetId).add(conn.id)
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
state.widgets = state.widgets.map((w) => {
|
|
202
|
+
const ids = widgetConnMap.get(w.id)
|
|
203
|
+
return ids ? { ...w, connectorIds: [...ids] } : w
|
|
204
|
+
})
|
|
205
|
+
}
|
|
206
|
+
|
|
160
207
|
return state
|
|
161
208
|
}
|
|
162
209
|
|