@dfosco/storyboard-core 4.1.0-beta.3 → 4.2.0-beta.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/commandpalette.config.json +13 -1
- package/package.json +1 -1
- 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 +447 -4
- package/src/canvas/terminal-registry.js +427 -0
- package/src/canvas/terminal-server.js +368 -0
- package/src/canvasConfig.js +13 -1
- package/src/cli/canvasAdd.js +10 -1
- package/src/cli/canvasBounds.js +160 -0
- package/src/cli/canvasRead.js +30 -2
- package/src/cli/index.js +32 -0
- package/src/cli/schemas.js +5 -0
- package/src/cli/sessions.js +333 -0
- package/src/cli/terminal-commands.js +276 -0
- package/src/cli/terminal-welcome.js +100 -0
- package/src/index.js +1 -1
- package/src/viewfinder.js +15 -0
- package/src/vite/server-plugin.js +60 -0
- package/widgets.config.json +209 -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
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
|
|
|
@@ -176,6 +176,110 @@ describe('materialize', () => {
|
|
|
176
176
|
})
|
|
177
177
|
})
|
|
178
178
|
|
|
179
|
+
describe('connectors', () => {
|
|
180
|
+
const baseEvents = [
|
|
181
|
+
{ event: 'canvas_created', timestamp: '1', title: 'Test', widgets: [
|
|
182
|
+
{ id: 'w1', type: 'sticky-note', position: { x: 0, y: 0 }, props: {} },
|
|
183
|
+
{ id: 'w2', type: 'markdown', position: { x: 200, y: 0 }, props: {} },
|
|
184
|
+
{ id: 'w3', type: 'sticky-note', position: { x: 400, y: 0 }, props: {} },
|
|
185
|
+
] },
|
|
186
|
+
]
|
|
187
|
+
|
|
188
|
+
const connector = {
|
|
189
|
+
id: 'connector-001',
|
|
190
|
+
type: 'connector',
|
|
191
|
+
connectorType: 'default',
|
|
192
|
+
start: { widgetId: 'w1', anchor: 'right' },
|
|
193
|
+
end: { widgetId: 'w2', anchor: 'left' },
|
|
194
|
+
meta: {},
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
it('materializes connector_added events into connectors array', () => {
|
|
198
|
+
const state = materialize([
|
|
199
|
+
...baseEvents,
|
|
200
|
+
{ event: 'connector_added', timestamp: '2', connector },
|
|
201
|
+
])
|
|
202
|
+
expect(state.connectors).toHaveLength(1)
|
|
203
|
+
expect(state.connectors[0].id).toBe('connector-001')
|
|
204
|
+
})
|
|
205
|
+
|
|
206
|
+
it('derives connectorIds on widgets from connectors', () => {
|
|
207
|
+
const state = materialize([
|
|
208
|
+
...baseEvents,
|
|
209
|
+
{ event: 'connector_added', timestamp: '2', connector },
|
|
210
|
+
])
|
|
211
|
+
expect(state.widgets.find((w) => w.id === 'w1').connectorIds).toEqual(['connector-001'])
|
|
212
|
+
expect(state.widgets.find((w) => w.id === 'w2').connectorIds).toEqual(['connector-001'])
|
|
213
|
+
expect(state.widgets.find((w) => w.id === 'w3').connectorIds).toBeUndefined()
|
|
214
|
+
})
|
|
215
|
+
|
|
216
|
+
it('removes connectors with connector_removed', () => {
|
|
217
|
+
const state = materialize([
|
|
218
|
+
...baseEvents,
|
|
219
|
+
{ event: 'connector_added', timestamp: '2', connector },
|
|
220
|
+
{ event: 'connector_removed', timestamp: '3', connectorId: 'connector-001' },
|
|
221
|
+
])
|
|
222
|
+
expect(state.connectors).toHaveLength(0)
|
|
223
|
+
// connectorIds should not appear on widgets when no connectors reference them
|
|
224
|
+
expect(state.widgets.find((w) => w.id === 'w1').connectorIds).toBeUndefined()
|
|
225
|
+
})
|
|
226
|
+
|
|
227
|
+
it('cascades connector removal when widget is removed', () => {
|
|
228
|
+
const state = materialize([
|
|
229
|
+
...baseEvents,
|
|
230
|
+
{ event: 'connector_added', timestamp: '2', connector },
|
|
231
|
+
{ event: 'widget_removed', timestamp: '3', widgetId: 'w1' },
|
|
232
|
+
])
|
|
233
|
+
expect(state.connectors).toHaveLength(0)
|
|
234
|
+
expect(state.widgets).toHaveLength(2)
|
|
235
|
+
})
|
|
236
|
+
|
|
237
|
+
it('cleans orphaned connectors on widgets_replaced', () => {
|
|
238
|
+
const state = materialize([
|
|
239
|
+
...baseEvents,
|
|
240
|
+
{ event: 'connector_added', timestamp: '2', connector },
|
|
241
|
+
{ event: 'widgets_replaced', timestamp: '3', widgets: [
|
|
242
|
+
{ id: 'w2', type: 'markdown', position: { x: 200, y: 0 }, props: {} },
|
|
243
|
+
{ id: 'w3', type: 'sticky-note', position: { x: 400, y: 0 }, props: {} },
|
|
244
|
+
] },
|
|
245
|
+
])
|
|
246
|
+
// w1 removed by replacement, so connector referencing w1 is orphaned
|
|
247
|
+
expect(state.connectors).toHaveLength(0)
|
|
248
|
+
})
|
|
249
|
+
|
|
250
|
+
it('supports multiple connectors on the same widget', () => {
|
|
251
|
+
const conn2 = {
|
|
252
|
+
id: 'connector-002',
|
|
253
|
+
type: 'connector',
|
|
254
|
+
connectorType: 'default',
|
|
255
|
+
start: { widgetId: 'w2', anchor: 'right' },
|
|
256
|
+
end: { widgetId: 'w3', anchor: 'left' },
|
|
257
|
+
meta: {},
|
|
258
|
+
}
|
|
259
|
+
const state = materialize([
|
|
260
|
+
...baseEvents,
|
|
261
|
+
{ event: 'connector_added', timestamp: '2', connector },
|
|
262
|
+
{ event: 'connector_added', timestamp: '3', connector: conn2 },
|
|
263
|
+
])
|
|
264
|
+
expect(state.connectors).toHaveLength(2)
|
|
265
|
+
expect(state.widgets.find((w) => w.id === 'w2').connectorIds).toEqual(
|
|
266
|
+
expect.arrayContaining(['connector-001', 'connector-002']),
|
|
267
|
+
)
|
|
268
|
+
expect(state.widgets.find((w) => w.id === 'w2').connectorIds).toHaveLength(2)
|
|
269
|
+
})
|
|
270
|
+
|
|
271
|
+
it('initializes connectors array when none existed', () => {
|
|
272
|
+
const state = materialize([
|
|
273
|
+
{ event: 'canvas_created', timestamp: '1', title: 'Empty', widgets: [
|
|
274
|
+
{ id: 'w1', type: 'sticky-note', position: { x: 0, y: 0 }, props: {} },
|
|
275
|
+
{ id: 'w2', type: 'sticky-note', position: { x: 100, y: 0 }, props: {} },
|
|
276
|
+
] },
|
|
277
|
+
{ event: 'connector_added', timestamp: '2', connector },
|
|
278
|
+
])
|
|
279
|
+
expect(state.connectors).toHaveLength(1)
|
|
280
|
+
})
|
|
281
|
+
})
|
|
282
|
+
|
|
179
283
|
describe('materializeFromText', () => {
|
|
180
284
|
it('parses and materializes in one step', () => {
|
|
181
285
|
const text = '{"event":"canvas_created","timestamp":"2026-01-01","title":"Test","widgets":[]}\n{"event":"widget_added","timestamp":"2026-01-02","widget":{"id":"w1","type":"sticky-note","position":{"x":0,"y":0},"props":{"text":"Hello"}}}\n'
|