@dfosco/storyboard-core 4.0.0-beta.2 → 4.0.0-beta.21
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/dist/storyboard-ui.css +1 -1
- package/dist/storyboard-ui.js +11882 -11126
- package/dist/storyboard-ui.js.map +1 -1
- package/dist/tailwind.css +1 -1
- package/package.json +11 -3
- package/paste.config.json +54 -0
- package/scaffold/deploy.yml +101 -0
- package/scaffold/githooks/pre-push +114 -0
- package/scaffold/manifest.json +11 -0
- package/scaffold/storyboard.config.json +4 -1
- package/src/ActionMenuButton.svelte +12 -2
- package/src/CanvasCreateMenu.svelte +228 -10
- package/src/CanvasSnap.svelte +2 -0
- package/src/CoreUIBar.svelte +152 -3
- package/src/CreateMenuButton.svelte +4 -1
- package/src/InspectorPanel.svelte +2 -0
- package/src/PwaInstallBanner.svelte +124 -0
- package/src/autosync/server.js +99 -111
- package/src/autosync/server.test.js +0 -7
- package/src/canvas/collision.js +206 -0
- package/src/canvas/collision.test.js +271 -0
- package/src/canvas/deriveCanvasId.test.js +40 -0
- package/src/canvas/identity.js +107 -0
- package/src/canvas/identity.test.js +100 -0
- package/src/canvas/server.js +285 -31
- package/src/canvasConfig.js +56 -0
- package/src/canvasConfig.test.js +42 -0
- package/src/cli/canvasAdd.js +185 -0
- package/src/cli/canvasRead.js +208 -0
- package/src/cli/code.js +67 -0
- package/src/cli/create.js +339 -72
- package/src/cli/dev-helpers.js +53 -0
- package/src/cli/dev-helpers.test.js +53 -0
- package/src/cli/dev.js +245 -26
- package/src/cli/flags.js +174 -0
- package/src/cli/flags.test.js +155 -0
- package/src/cli/index.js +84 -13
- package/src/cli/intro.js +37 -0
- package/src/cli/proxy.js +127 -6
- package/src/cli/proxy.test.js +63 -0
- package/src/cli/schemas.js +200 -0
- package/src/cli/serverUrl.js +56 -0
- package/src/cli/setup.js +130 -20
- package/src/cli/snapshots.js +335 -0
- package/src/cli/updateVersion.js +54 -3
- package/src/configSchema.js +125 -0
- package/src/configSchema.test.js +68 -0
- package/src/index.js +5 -0
- package/src/inspector/highlighter.js +10 -2
- package/src/lib/components/ui/trigger-button/trigger-button.svelte +1 -1
- package/src/loader.js +21 -2
- package/src/loader.test.js +63 -1
- package/src/mobileViewport.js +57 -0
- package/src/mobileViewport.test.js +68 -0
- package/src/mountStoryboardCore.js +61 -7
- package/src/rename-watcher/config.json +23 -0
- package/src/rename-watcher/watcher.js +538 -0
- package/src/svelte-plugin-ui/components/Viewfinder.svelte +6 -17
- package/src/tools/handlers/flows.js +6 -7
- package/src/viewfinder.js +21 -9
- package/src/viewfinder.test.js +2 -2
- package/src/vite/server-plugin.js +150 -7
- package/src/workshop/features/createCanvas/CreateCanvasForm.svelte +8 -2
- package/src/workshop/features/createFlow/CreateFlowForm.svelte +1 -1
- package/src/workshop/features/createPage/CreatePageForm.svelte +1 -1
- package/src/workshop/features/createPrototype/CreatePrototypeForm.svelte +2 -2
- package/src/workshop/features/createStory/CreateStoryForm.svelte +160 -0
- package/src/workshop/features/createStory/index.js +14 -0
- package/src/workshop/features/registry.js +2 -0
- package/src/worktree/port.js +57 -1
- package/src/worktree/port.test.js +91 -1
- package/toolbar.config.json +3 -3
- package/widgets.config.json +132 -27
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Canvas Collision Detection — Find collision-free positions for widgets.
|
|
3
|
+
*
|
|
4
|
+
* When placing or moving widgets, this module checks for overlaps with
|
|
5
|
+
* existing widgets and adjusts the position until no collisions remain.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Default widget sizes by type (from widgets.config.json).
|
|
10
|
+
*/
|
|
11
|
+
export const DEFAULT_SIZES = {
|
|
12
|
+
'sticky-note': { width: 270, height: 170 },
|
|
13
|
+
'markdown': { width: 530, height: 240 },
|
|
14
|
+
'prototype': { width: 800, height: 600 },
|
|
15
|
+
'figma-embed': { width: 800, height: 450 },
|
|
16
|
+
'image': { width: 400, height: 300 },
|
|
17
|
+
'link-preview': { width: 400, height: 200 },
|
|
18
|
+
'component': { width: 300, height: 200 },
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Get the bounding box of a widget.
|
|
23
|
+
* @param {object} widget - Widget with position and props
|
|
24
|
+
* @returns {{ x: number, y: number, width: number, height: number }}
|
|
25
|
+
*/
|
|
26
|
+
export function getWidgetBounds(widget) {
|
|
27
|
+
const { position = { x: 0, y: 0 }, props = {}, type } = widget
|
|
28
|
+
const defaults = DEFAULT_SIZES[type] || { width: 270, height: 170 }
|
|
29
|
+
return {
|
|
30
|
+
x: position.x,
|
|
31
|
+
y: position.y,
|
|
32
|
+
width: props.width ?? defaults.width,
|
|
33
|
+
height: props.height ?? defaults.height,
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Check if two rectangles overlap.
|
|
39
|
+
* @param {{ x: number, y: number, width: number, height: number }} a
|
|
40
|
+
* @param {{ x: number, y: number, width: number, height: number }} b
|
|
41
|
+
* @returns {boolean}
|
|
42
|
+
*/
|
|
43
|
+
export function rectsOverlap(a, b) {
|
|
44
|
+
return !(
|
|
45
|
+
a.x + a.width <= b.x || // a is to the left of b
|
|
46
|
+
b.x + b.width <= a.x || // b is to the left of a
|
|
47
|
+
a.y + a.height <= b.y || // a is above b
|
|
48
|
+
b.y + b.height <= a.y // b is above a
|
|
49
|
+
)
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Check if a proposed position collides with any existing widget.
|
|
54
|
+
* @param {{ x: number, y: number, width: number, height: number }} rect - Proposed bounds
|
|
55
|
+
* @param {object[]} widgets - Existing widgets array
|
|
56
|
+
* @param {string} [excludeId] - Widget ID to exclude (for move operations)
|
|
57
|
+
* @returns {object|null} - The first colliding widget, or null if no collision
|
|
58
|
+
*/
|
|
59
|
+
export function findCollision(rect, widgets, excludeId = null) {
|
|
60
|
+
for (const widget of widgets) {
|
|
61
|
+
if (excludeId && widget.id === excludeId) continue
|
|
62
|
+
const bounds = getWidgetBounds(widget)
|
|
63
|
+
if (rectsOverlap(rect, bounds)) {
|
|
64
|
+
return widget
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return null
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Snap a value to grid.
|
|
72
|
+
* @param {number} value
|
|
73
|
+
* @param {number} gridSize
|
|
74
|
+
* @returns {number}
|
|
75
|
+
*/
|
|
76
|
+
export function snapToGrid(value, gridSize) {
|
|
77
|
+
return Math.round(value / gridSize) * gridSize
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Find a collision-free position for a widget.
|
|
82
|
+
*
|
|
83
|
+
* Strategy:
|
|
84
|
+
* 1. Try the initial position
|
|
85
|
+
* 2. If collision, move right by (collider.width + gap)
|
|
86
|
+
* 3. If still colliding after maxIterations, try moving down instead
|
|
87
|
+
* 4. Snap final position to grid
|
|
88
|
+
*
|
|
89
|
+
* @param {object} options
|
|
90
|
+
* @param {number} options.x - Initial X position
|
|
91
|
+
* @param {number} options.y - Initial Y position
|
|
92
|
+
* @param {number} options.width - Widget width
|
|
93
|
+
* @param {number} options.height - Widget height
|
|
94
|
+
* @param {object[]} options.widgets - Existing widgets array
|
|
95
|
+
* @param {string} [options.excludeId] - Widget ID to exclude (for move operations)
|
|
96
|
+
* @param {number} [options.gridSize=24] - Grid size for snapping
|
|
97
|
+
* @param {number} [options.gap] - Gap between widgets (defaults to gridSize)
|
|
98
|
+
* @param {number} [options.maxIterations=50] - Max collision resolution attempts
|
|
99
|
+
* @returns {{ x: number, y: number, adjusted: boolean }}
|
|
100
|
+
*/
|
|
101
|
+
export function findFreePosition({
|
|
102
|
+
x,
|
|
103
|
+
y,
|
|
104
|
+
width,
|
|
105
|
+
height,
|
|
106
|
+
widgets,
|
|
107
|
+
excludeId = null,
|
|
108
|
+
gridSize = 24,
|
|
109
|
+
gap = null,
|
|
110
|
+
maxIterations = 50,
|
|
111
|
+
}) {
|
|
112
|
+
const spacing = gap ?? gridSize
|
|
113
|
+
let currentX = x
|
|
114
|
+
let currentY = y
|
|
115
|
+
let adjusted = false
|
|
116
|
+
let iteration = 0
|
|
117
|
+
|
|
118
|
+
// Phase 1: Try moving right
|
|
119
|
+
while (iteration < maxIterations) {
|
|
120
|
+
const rect = { x: currentX, y: currentY, width, height }
|
|
121
|
+
const collider = findCollision(rect, widgets, excludeId)
|
|
122
|
+
|
|
123
|
+
if (!collider) {
|
|
124
|
+
// No collision — snap and return
|
|
125
|
+
return {
|
|
126
|
+
x: snapToGrid(currentX, gridSize),
|
|
127
|
+
y: snapToGrid(currentY, gridSize),
|
|
128
|
+
adjusted,
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// Move right past the collider
|
|
133
|
+
const colliderBounds = getWidgetBounds(collider)
|
|
134
|
+
currentX = colliderBounds.x + colliderBounds.width + spacing
|
|
135
|
+
adjusted = true
|
|
136
|
+
iteration++
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// Phase 2: Reset X, try moving down
|
|
140
|
+
currentX = x
|
|
141
|
+
iteration = 0
|
|
142
|
+
|
|
143
|
+
while (iteration < maxIterations) {
|
|
144
|
+
const rect = { x: currentX, y: currentY, width, height }
|
|
145
|
+
const collider = findCollision(rect, widgets, excludeId)
|
|
146
|
+
|
|
147
|
+
if (!collider) {
|
|
148
|
+
return {
|
|
149
|
+
x: snapToGrid(currentX, gridSize),
|
|
150
|
+
y: snapToGrid(currentY, gridSize),
|
|
151
|
+
adjusted,
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Move down past the collider
|
|
156
|
+
const colliderBounds = getWidgetBounds(collider)
|
|
157
|
+
currentY = colliderBounds.y + colliderBounds.height + spacing
|
|
158
|
+
adjusted = true
|
|
159
|
+
iteration++
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// Fallback: return the last attempted position (snapped)
|
|
163
|
+
return {
|
|
164
|
+
x: snapToGrid(currentX, gridSize),
|
|
165
|
+
y: snapToGrid(currentY, gridSize),
|
|
166
|
+
adjusted,
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Resolve collision for a widget being placed or moved.
|
|
172
|
+
* Convenience wrapper that extracts size from widget type/props.
|
|
173
|
+
*
|
|
174
|
+
* @param {object} options
|
|
175
|
+
* @param {number} options.x - Target X position
|
|
176
|
+
* @param {number} options.y - Target Y position
|
|
177
|
+
* @param {string} options.type - Widget type
|
|
178
|
+
* @param {object} [options.props={}] - Widget props (may contain width/height)
|
|
179
|
+
* @param {object[]} options.widgets - Existing widgets array
|
|
180
|
+
* @param {string} [options.excludeId] - Widget ID to exclude
|
|
181
|
+
* @param {number} [options.gridSize=24] - Grid size
|
|
182
|
+
* @returns {{ x: number, y: number, adjusted: boolean }}
|
|
183
|
+
*/
|
|
184
|
+
export function resolvePosition({
|
|
185
|
+
x,
|
|
186
|
+
y,
|
|
187
|
+
type,
|
|
188
|
+
props = {},
|
|
189
|
+
widgets,
|
|
190
|
+
excludeId = null,
|
|
191
|
+
gridSize = 24,
|
|
192
|
+
}) {
|
|
193
|
+
const defaults = DEFAULT_SIZES[type] || { width: 270, height: 170 }
|
|
194
|
+
const width = props.width ?? defaults.width
|
|
195
|
+
const height = props.height ?? defaults.height
|
|
196
|
+
|
|
197
|
+
return findFreePosition({
|
|
198
|
+
x,
|
|
199
|
+
y,
|
|
200
|
+
width,
|
|
201
|
+
height,
|
|
202
|
+
widgets,
|
|
203
|
+
excludeId,
|
|
204
|
+
gridSize,
|
|
205
|
+
})
|
|
206
|
+
}
|
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import {
|
|
3
|
+
getWidgetBounds,
|
|
4
|
+
rectsOverlap,
|
|
5
|
+
findCollision,
|
|
6
|
+
snapToGrid,
|
|
7
|
+
findFreePosition,
|
|
8
|
+
resolvePosition,
|
|
9
|
+
DEFAULT_SIZES,
|
|
10
|
+
} from './collision.js'
|
|
11
|
+
|
|
12
|
+
describe('getWidgetBounds', () => {
|
|
13
|
+
it('uses position and props dimensions', () => {
|
|
14
|
+
const widget = {
|
|
15
|
+
type: 'sticky-note',
|
|
16
|
+
position: { x: 100, y: 200 },
|
|
17
|
+
props: { width: 300, height: 180 },
|
|
18
|
+
}
|
|
19
|
+
expect(getWidgetBounds(widget)).toEqual({
|
|
20
|
+
x: 100,
|
|
21
|
+
y: 200,
|
|
22
|
+
width: 300,
|
|
23
|
+
height: 180,
|
|
24
|
+
})
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
it('falls back to default sizes when props missing', () => {
|
|
28
|
+
const widget = {
|
|
29
|
+
type: 'sticky-note',
|
|
30
|
+
position: { x: 50, y: 50 },
|
|
31
|
+
props: {},
|
|
32
|
+
}
|
|
33
|
+
expect(getWidgetBounds(widget)).toEqual({
|
|
34
|
+
x: 50,
|
|
35
|
+
y: 50,
|
|
36
|
+
width: DEFAULT_SIZES['sticky-note'].width,
|
|
37
|
+
height: DEFAULT_SIZES['sticky-note'].height,
|
|
38
|
+
})
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
it('handles missing position and props', () => {
|
|
42
|
+
const widget = { type: 'markdown' }
|
|
43
|
+
expect(getWidgetBounds(widget)).toEqual({
|
|
44
|
+
x: 0,
|
|
45
|
+
y: 0,
|
|
46
|
+
width: DEFAULT_SIZES['markdown'].width,
|
|
47
|
+
height: DEFAULT_SIZES['markdown'].height,
|
|
48
|
+
})
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
it('uses generic fallback for unknown widget types', () => {
|
|
52
|
+
const widget = { type: 'unknown-type', position: { x: 10, y: 20 } }
|
|
53
|
+
expect(getWidgetBounds(widget)).toEqual({
|
|
54
|
+
x: 10,
|
|
55
|
+
y: 20,
|
|
56
|
+
width: 270,
|
|
57
|
+
height: 170,
|
|
58
|
+
})
|
|
59
|
+
})
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
describe('rectsOverlap', () => {
|
|
63
|
+
it('returns true for overlapping rects', () => {
|
|
64
|
+
const a = { x: 0, y: 0, width: 100, height: 100 }
|
|
65
|
+
const b = { x: 50, y: 50, width: 100, height: 100 }
|
|
66
|
+
expect(rectsOverlap(a, b)).toBe(true)
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
it('returns false for non-overlapping rects (side by side)', () => {
|
|
70
|
+
const a = { x: 0, y: 0, width: 100, height: 100 }
|
|
71
|
+
const b = { x: 100, y: 0, width: 100, height: 100 }
|
|
72
|
+
expect(rectsOverlap(a, b)).toBe(false)
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
it('returns false for non-overlapping rects (stacked)', () => {
|
|
76
|
+
const a = { x: 0, y: 0, width: 100, height: 100 }
|
|
77
|
+
const b = { x: 0, y: 100, width: 100, height: 100 }
|
|
78
|
+
expect(rectsOverlap(a, b)).toBe(false)
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
it('returns true when one rect contains another', () => {
|
|
82
|
+
const a = { x: 0, y: 0, width: 200, height: 200 }
|
|
83
|
+
const b = { x: 50, y: 50, width: 50, height: 50 }
|
|
84
|
+
expect(rectsOverlap(a, b)).toBe(true)
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
it('returns true for rects that share an edge partially', () => {
|
|
88
|
+
const a = { x: 0, y: 0, width: 100, height: 100 }
|
|
89
|
+
const b = { x: 50, y: 0, width: 100, height: 100 }
|
|
90
|
+
expect(rectsOverlap(a, b)).toBe(true)
|
|
91
|
+
})
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
describe('findCollision', () => {
|
|
95
|
+
const widgets = [
|
|
96
|
+
{ id: 'w1', type: 'sticky-note', position: { x: 0, y: 0 }, props: { width: 270, height: 170 } },
|
|
97
|
+
{ id: 'w2', type: 'sticky-note', position: { x: 300, y: 0 }, props: { width: 270, height: 170 } },
|
|
98
|
+
]
|
|
99
|
+
|
|
100
|
+
it('returns colliding widget', () => {
|
|
101
|
+
const rect = { x: 100, y: 50, width: 270, height: 170 }
|
|
102
|
+
const collision = findCollision(rect, widgets)
|
|
103
|
+
expect(collision?.id).toBe('w1')
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
it('returns null when no collision', () => {
|
|
107
|
+
const rect = { x: 0, y: 200, width: 270, height: 170 }
|
|
108
|
+
const collision = findCollision(rect, widgets)
|
|
109
|
+
expect(collision).toBeNull()
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
it('excludes specified widget ID', () => {
|
|
113
|
+
// This rect only overlaps w1, not w2 — so excluding w1 means no collision
|
|
114
|
+
const rect = { x: 50, y: 50, width: 200, height: 100 }
|
|
115
|
+
const collision = findCollision(rect, widgets, 'w1')
|
|
116
|
+
expect(collision).toBeNull()
|
|
117
|
+
})
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
describe('snapToGrid', () => {
|
|
121
|
+
it('snaps to nearest grid line', () => {
|
|
122
|
+
expect(snapToGrid(25, 24)).toBe(24)
|
|
123
|
+
expect(snapToGrid(36, 24)).toBe(48)
|
|
124
|
+
expect(snapToGrid(12, 24)).toBe(24) // 12 rounds to 24 (0.5 → 1)
|
|
125
|
+
expect(snapToGrid(11, 24)).toBe(0) // 11 rounds to 0
|
|
126
|
+
expect(snapToGrid(48, 24)).toBe(48)
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
it('works with different grid sizes', () => {
|
|
130
|
+
expect(snapToGrid(15, 10)).toBe(20)
|
|
131
|
+
expect(snapToGrid(14, 10)).toBe(10)
|
|
132
|
+
})
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
describe('findFreePosition', () => {
|
|
136
|
+
it('returns original position when no collision', () => {
|
|
137
|
+
const widgets = []
|
|
138
|
+
const result = findFreePosition({
|
|
139
|
+
x: 100,
|
|
140
|
+
y: 100,
|
|
141
|
+
width: 270,
|
|
142
|
+
height: 170,
|
|
143
|
+
widgets,
|
|
144
|
+
gridSize: 24,
|
|
145
|
+
})
|
|
146
|
+
expect(result).toEqual({ x: 96, y: 96, adjusted: false })
|
|
147
|
+
})
|
|
148
|
+
|
|
149
|
+
it('moves right to avoid collision', () => {
|
|
150
|
+
const widgets = [
|
|
151
|
+
{ id: 'w1', type: 'sticky-note', position: { x: 0, y: 0 }, props: { width: 270, height: 170 } },
|
|
152
|
+
]
|
|
153
|
+
const result = findFreePosition({
|
|
154
|
+
x: 0,
|
|
155
|
+
y: 0,
|
|
156
|
+
width: 270,
|
|
157
|
+
height: 170,
|
|
158
|
+
widgets,
|
|
159
|
+
gridSize: 24,
|
|
160
|
+
})
|
|
161
|
+
// Should move right: 270 (width) + 24 (gap) = 294, snapped to 288 or 312
|
|
162
|
+
expect(result.x).toBeGreaterThanOrEqual(288)
|
|
163
|
+
expect(result.y).toBe(0)
|
|
164
|
+
expect(result.adjusted).toBe(true)
|
|
165
|
+
})
|
|
166
|
+
|
|
167
|
+
it('moves down when rightward movement exhausted', () => {
|
|
168
|
+
// Create a row of widgets that blocks rightward movement
|
|
169
|
+
const widgets = []
|
|
170
|
+
for (let i = 0; i < 10; i++) {
|
|
171
|
+
widgets.push({
|
|
172
|
+
id: `w${i}`,
|
|
173
|
+
type: 'sticky-note',
|
|
174
|
+
position: { x: i * 300, y: 0 },
|
|
175
|
+
props: { width: 270, height: 170 },
|
|
176
|
+
})
|
|
177
|
+
}
|
|
178
|
+
const result = findFreePosition({
|
|
179
|
+
x: 0,
|
|
180
|
+
y: 0,
|
|
181
|
+
width: 270,
|
|
182
|
+
height: 170,
|
|
183
|
+
widgets,
|
|
184
|
+
gridSize: 24,
|
|
185
|
+
maxIterations: 5, // Force early switch to vertical
|
|
186
|
+
})
|
|
187
|
+
// After exhausting horizontal, should move down
|
|
188
|
+
expect(result.adjusted).toBe(true)
|
|
189
|
+
})
|
|
190
|
+
|
|
191
|
+
it('respects excludeId parameter', () => {
|
|
192
|
+
const widgets = [
|
|
193
|
+
{ id: 'w1', type: 'sticky-note', position: { x: 0, y: 0 }, props: { width: 270, height: 170 } },
|
|
194
|
+
]
|
|
195
|
+
const result = findFreePosition({
|
|
196
|
+
x: 0,
|
|
197
|
+
y: 0,
|
|
198
|
+
width: 270,
|
|
199
|
+
height: 170,
|
|
200
|
+
widgets,
|
|
201
|
+
excludeId: 'w1',
|
|
202
|
+
gridSize: 24,
|
|
203
|
+
})
|
|
204
|
+
// Should stay at original position since w1 is excluded
|
|
205
|
+
expect(result).toEqual({ x: 0, y: 0, adjusted: false })
|
|
206
|
+
})
|
|
207
|
+
|
|
208
|
+
it('handles chain of collisions', () => {
|
|
209
|
+
// Two widgets next to each other
|
|
210
|
+
const widgets = [
|
|
211
|
+
{ id: 'w1', type: 'sticky-note', position: { x: 0, y: 0 }, props: { width: 270, height: 170 } },
|
|
212
|
+
{ id: 'w2', type: 'sticky-note', position: { x: 294, y: 0 }, props: { width: 270, height: 170 } },
|
|
213
|
+
]
|
|
214
|
+
const result = findFreePosition({
|
|
215
|
+
x: 0,
|
|
216
|
+
y: 0,
|
|
217
|
+
width: 270,
|
|
218
|
+
height: 170,
|
|
219
|
+
widgets,
|
|
220
|
+
gridSize: 24,
|
|
221
|
+
})
|
|
222
|
+
// Should move past both widgets
|
|
223
|
+
expect(result.x).toBeGreaterThan(294 + 270)
|
|
224
|
+
expect(result.adjusted).toBe(true)
|
|
225
|
+
})
|
|
226
|
+
})
|
|
227
|
+
|
|
228
|
+
describe('resolvePosition', () => {
|
|
229
|
+
it('uses widget type defaults', () => {
|
|
230
|
+
const widgets = [
|
|
231
|
+
{ id: 'w1', type: 'sticky-note', position: { x: 0, y: 0 }, props: {} },
|
|
232
|
+
]
|
|
233
|
+
const result = resolvePosition({
|
|
234
|
+
x: 0,
|
|
235
|
+
y: 0,
|
|
236
|
+
type: 'sticky-note',
|
|
237
|
+
widgets,
|
|
238
|
+
gridSize: 24,
|
|
239
|
+
})
|
|
240
|
+
// Should detect collision and move
|
|
241
|
+
expect(result.adjusted).toBe(true)
|
|
242
|
+
expect(result.x).toBeGreaterThan(0)
|
|
243
|
+
})
|
|
244
|
+
|
|
245
|
+
it('uses custom props dimensions', () => {
|
|
246
|
+
const widgets = [
|
|
247
|
+
{ id: 'w1', type: 'sticky-note', position: { x: 0, y: 0 }, props: { width: 100, height: 100 } },
|
|
248
|
+
]
|
|
249
|
+
const result = resolvePosition({
|
|
250
|
+
x: 0,
|
|
251
|
+
y: 0,
|
|
252
|
+
type: 'sticky-note',
|
|
253
|
+
props: { width: 50, height: 50 },
|
|
254
|
+
widgets,
|
|
255
|
+
gridSize: 24,
|
|
256
|
+
})
|
|
257
|
+
// Should collide with w1 at (0,0) even though new widget is smaller
|
|
258
|
+
expect(result.adjusted).toBe(true)
|
|
259
|
+
})
|
|
260
|
+
|
|
261
|
+
it('returns snapped position when no collision', () => {
|
|
262
|
+
const result = resolvePosition({
|
|
263
|
+
x: 500,
|
|
264
|
+
y: 500,
|
|
265
|
+
type: 'markdown',
|
|
266
|
+
widgets: [],
|
|
267
|
+
gridSize: 24,
|
|
268
|
+
})
|
|
269
|
+
expect(result).toEqual({ x: 504, y: 504, adjusted: false })
|
|
270
|
+
})
|
|
271
|
+
})
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import { toCanvasId } from './identity.js'
|
|
3
|
+
|
|
4
|
+
describe('toCanvasId (path-based canvas identity)', () => {
|
|
5
|
+
it('returns basename for flat canvas in src/canvas/', () => {
|
|
6
|
+
expect(toCanvasId('src/canvas/design-overview.canvas.jsonl')).toBe('design-overview')
|
|
7
|
+
})
|
|
8
|
+
|
|
9
|
+
it('returns path-based ID for canvas inside a .folder/', () => {
|
|
10
|
+
expect(toCanvasId('src/canvas/research.folder/interviews.canvas.jsonl')).toBe('research/interviews')
|
|
11
|
+
})
|
|
12
|
+
|
|
13
|
+
it('strips .folder suffix from path', () => {
|
|
14
|
+
expect(toCanvasId('src/canvas/ux.folder/onboarding.canvas.jsonl')).toBe('ux/onboarding')
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
it('handles nested subdirectories inside a .folder/', () => {
|
|
18
|
+
expect(toCanvasId('src/canvas/team.folder/sub/deep.canvas.jsonl')).toBe('team/sub/deep')
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
it('returns proto:-prefixed ID for prototype-scoped canvas', () => {
|
|
22
|
+
expect(toCanvasId('src/prototypes/Dashboard/overview.canvas.jsonl')).toBe('proto:Dashboard/overview')
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
it('handles prototype inside a .folder/', () => {
|
|
26
|
+
expect(toCanvasId('src/prototypes/main.folder/Dashboard/overview.canvas.jsonl')).toBe('proto:main/Dashboard/overview')
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
it('duplicate basenames in different folders get distinct IDs', () => {
|
|
30
|
+
const id1 = toCanvasId('src/canvas/alpha.folder/overview.canvas.jsonl')
|
|
31
|
+
const id2 = toCanvasId('src/canvas/beta.folder/overview.canvas.jsonl')
|
|
32
|
+
expect(id1).toBe('alpha/overview')
|
|
33
|
+
expect(id2).toBe('beta/overview')
|
|
34
|
+
expect(id1).not.toBe(id2)
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
it('normalizes backslashes to forward slashes', () => {
|
|
38
|
+
expect(toCanvasId('src\\canvas\\research.folder\\interviews.canvas.jsonl')).toBe('research/interviews')
|
|
39
|
+
})
|
|
40
|
+
})
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Canvas Identity — path-based canvas ID utilities.
|
|
3
|
+
*
|
|
4
|
+
* Canonical canvas IDs are normalized relative paths derived from the file
|
|
5
|
+
* location. They uniquely identify a canvas even when two canvases share the
|
|
6
|
+
* same basename in different folders.
|
|
7
|
+
*
|
|
8
|
+
* Format examples:
|
|
9
|
+
* src/canvas/overview.canvas.jsonl → "overview"
|
|
10
|
+
* src/canvas/design.folder/overview.canvas.jsonl → "design/overview"
|
|
11
|
+
* src/canvas/design.folder/sub.folder/a.canvas.jsonl → "design/sub/a"
|
|
12
|
+
* src/prototypes/Main/board.canvas.jsonl → "proto:Main/board"
|
|
13
|
+
*
|
|
14
|
+
* Canvases under src/canvas/ get plain IDs; those under src/prototypes/ get
|
|
15
|
+
* a "proto:" prefix so the two namespaces never collide.
|
|
16
|
+
*
|
|
17
|
+
* Legacy IDs (bare names like "overview") remain supported as aliases when
|
|
18
|
+
* they resolve to exactly one canvas.
|
|
19
|
+
*
|
|
20
|
+
* @module canvas/identity
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
const CANVAS_SUFFIX_RE = /\.canvas\.jsonl$/
|
|
24
|
+
const FOLDER_SUFFIX_RE = /([^/]+)\.folder\//g
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Convert a relative file path to a canonical canvas ID.
|
|
28
|
+
*
|
|
29
|
+
* @param {string} relPath — path relative to project root
|
|
30
|
+
* @returns {string} canonical canvas ID
|
|
31
|
+
*/
|
|
32
|
+
export function toCanvasId(relPath) {
|
|
33
|
+
let p = relPath.replace(/\\/g, '/')
|
|
34
|
+
|
|
35
|
+
let prefix = ''
|
|
36
|
+
if (p.startsWith('src/canvas/')) {
|
|
37
|
+
p = p.slice('src/canvas/'.length)
|
|
38
|
+
} else if (p.startsWith('src/prototypes/')) {
|
|
39
|
+
p = p.slice('src/prototypes/'.length)
|
|
40
|
+
prefix = 'proto:'
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Strip .canvas.jsonl suffix
|
|
44
|
+
p = p.replace(CANVAS_SUFFIX_RE, '')
|
|
45
|
+
|
|
46
|
+
// Normalize .folder segments: "design.folder/" → "design/"
|
|
47
|
+
p = p.replace(FOLDER_SUFFIX_RE, '$1/')
|
|
48
|
+
|
|
49
|
+
// Clean up double slashes and trailing slash
|
|
50
|
+
p = p.replace(/\/+/g, '/').replace(/\/$/, '')
|
|
51
|
+
|
|
52
|
+
return prefix + (p || 'unknown')
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Parse a canvas ID into its segments.
|
|
57
|
+
*
|
|
58
|
+
* @param {string} id — canonical canvas ID
|
|
59
|
+
* @returns {{ namespace: 'canvas' | 'prototype', segments: string[], name: string }}
|
|
60
|
+
*/
|
|
61
|
+
export function parseCanvasId(id) {
|
|
62
|
+
let namespace = 'canvas'
|
|
63
|
+
let raw = id
|
|
64
|
+
if (raw.startsWith('proto:')) {
|
|
65
|
+
namespace = 'prototype'
|
|
66
|
+
raw = raw.slice('proto:'.length)
|
|
67
|
+
}
|
|
68
|
+
const segments = raw.split('/').filter(Boolean)
|
|
69
|
+
const name = segments[segments.length - 1] || raw
|
|
70
|
+
return { namespace, segments, name }
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Extract the basename from a canvas ID (the last segment).
|
|
75
|
+
* Useful for backward-compatible lookups.
|
|
76
|
+
*
|
|
77
|
+
* @param {string} id
|
|
78
|
+
* @returns {string}
|
|
79
|
+
*/
|
|
80
|
+
export function canvasIdBasename(id) {
|
|
81
|
+
return parseCanvasId(id).name
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Check whether an ID is a legacy bare name (no path segments, no namespace prefix).
|
|
86
|
+
*
|
|
87
|
+
* @param {string} id
|
|
88
|
+
* @returns {boolean}
|
|
89
|
+
*/
|
|
90
|
+
export function isLegacyCanvasId(id) {
|
|
91
|
+
return !id.includes('/') && !id.startsWith('proto:')
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Known consumers of canvas identity that must be updated when migrating
|
|
96
|
+
* from basename-only to path-based IDs. This inventory is used by downstream
|
|
97
|
+
* slices to track migration completeness.
|
|
98
|
+
*/
|
|
99
|
+
export const CANVAS_IDENTITY_CONSUMERS = [
|
|
100
|
+
'packages/react/src/vite/data-plugin.js — index & HMR payloads',
|
|
101
|
+
'packages/core/src/canvas/server.js — findCanvasPath() & all API routes',
|
|
102
|
+
'packages/react/src/context.jsx — canvasRouteMap',
|
|
103
|
+
'packages/react/src/canvas/CanvasPage.jsx — cross-canvas copy/paste (canvasName/widgetId)',
|
|
104
|
+
'packages/react/src/canvas/CanvasPage.jsx — viewport persistence keys',
|
|
105
|
+
'packages/core/src/viewfinder.js — canvas listing',
|
|
106
|
+
'packages/core/src/rename-watcher/watcher.js — canvas rename route inference',
|
|
107
|
+
]
|