@dfosco/storyboard-core 4.0.0-beta.6 → 4.0.0-beta.8
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/package.json +3 -2
- package/src/canvas/collision.js +206 -0
- package/src/canvas/collision.test.js +271 -0
- package/src/cli/canvasRead.js +1 -7
- package/src/cli/create.js +1 -7
- package/src/cli/serverUrl.js +56 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dfosco/storyboard-core",
|
|
3
|
-
"version": "4.0.0-beta.
|
|
3
|
+
"version": "4.0.0-beta.8",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"bin": {
|
|
@@ -48,7 +48,8 @@
|
|
|
48
48
|
"./ui/viewfinder": "./src/ui/viewfinder.ts",
|
|
49
49
|
"./svelte-plugin-ui/styles/base.css": "./dist/tailwind.css",
|
|
50
50
|
"./styles/tailwind.css": "./dist/tailwind.css",
|
|
51
|
-
"./worktree/port": "./src/worktree/port.js"
|
|
51
|
+
"./worktree/port": "./src/worktree/port.js",
|
|
52
|
+
"./canvas/collision": "./src/canvas/collision.js"
|
|
52
53
|
},
|
|
53
54
|
"dependencies": {
|
|
54
55
|
"@clack/prompts": "^1.2.0",
|
|
@@ -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
|
+
})
|
package/src/cli/canvasRead.js
CHANGED
|
@@ -15,18 +15,12 @@
|
|
|
15
15
|
*/
|
|
16
16
|
|
|
17
17
|
import * as p from '@clack/prompts'
|
|
18
|
-
import {
|
|
18
|
+
import { getServerUrl } from './serverUrl.js'
|
|
19
19
|
|
|
20
20
|
const dim = (s) => `\x1b[2m${s}\x1b[0m`
|
|
21
21
|
const bold = (s) => `\x1b[1m${s}\x1b[0m`
|
|
22
22
|
const cyan = (s) => `\x1b[36m${s}\x1b[0m`
|
|
23
23
|
|
|
24
|
-
function getServerUrl() {
|
|
25
|
-
const name = detectWorktreeName()
|
|
26
|
-
const port = getPort(name)
|
|
27
|
-
return `http://localhost:${port}`
|
|
28
|
-
}
|
|
29
|
-
|
|
30
24
|
async function checkServer() {
|
|
31
25
|
try {
|
|
32
26
|
await fetch(getServerUrl(), { signal: AbortSignal.timeout(2000) })
|
package/src/cli/create.js
CHANGED
|
@@ -15,9 +15,9 @@
|
|
|
15
15
|
*/
|
|
16
16
|
|
|
17
17
|
import * as p from '@clack/prompts'
|
|
18
|
-
import { detectWorktreeName, getPort } from '../worktree/port.js'
|
|
19
18
|
import { parseFlags, hasFlags, formatFlagHelp } from './flags.js'
|
|
20
19
|
import { prototypeSchema, canvasSchema, flowSchema, pageSchema } from './schemas.js'
|
|
20
|
+
import { getServerUrl } from './serverUrl.js'
|
|
21
21
|
|
|
22
22
|
const dim = (s) => `\x1b[2m${s}\x1b[0m`
|
|
23
23
|
const green = (s) => `\x1b[32m${s}\x1b[0m`
|
|
@@ -37,12 +37,6 @@ function showHelp(type, schema) {
|
|
|
37
37
|
process.exit(0)
|
|
38
38
|
}
|
|
39
39
|
|
|
40
|
-
function getServerUrl() {
|
|
41
|
-
const name = detectWorktreeName()
|
|
42
|
-
const port = getPort(name)
|
|
43
|
-
return `http://localhost:${port}`
|
|
44
|
-
}
|
|
45
|
-
|
|
46
40
|
async function serverGet(path) {
|
|
47
41
|
const base = getServerUrl()
|
|
48
42
|
const res = await fetch(`${base}${path}`)
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Resolve the dev server URL for the current worktree.
|
|
3
|
+
*
|
|
4
|
+
* Checks the Caddy admin API first to find the actual port mapped to
|
|
5
|
+
* this branch's route, since ports.json can drift from the running
|
|
6
|
+
* dev server. Falls back to ports.json if Caddy isn't reachable.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { detectWorktreeName, getPort } from '../worktree/port.js'
|
|
10
|
+
import { readDevDomain } from './proxy.js'
|
|
11
|
+
import { execSync } from 'child_process'
|
|
12
|
+
|
|
13
|
+
export function getServerUrl() {
|
|
14
|
+
const name = detectWorktreeName()
|
|
15
|
+
|
|
16
|
+
// Try Caddy admin API for the real port
|
|
17
|
+
try {
|
|
18
|
+
const raw = execSync(
|
|
19
|
+
'curl -sf http://localhost:2019/config/apps/http/servers/srv0/routes',
|
|
20
|
+
{ encoding: 'utf8', timeout: 2000, stdio: ['pipe', 'pipe', 'pipe'] }
|
|
21
|
+
)
|
|
22
|
+
const routes = JSON.parse(raw)
|
|
23
|
+
const domain = readDevDomain()
|
|
24
|
+
|
|
25
|
+
for (const route of routes) {
|
|
26
|
+
const hosts = route.match?.[0]?.host || []
|
|
27
|
+
if (!hosts.includes(domain)) continue
|
|
28
|
+
const subroutes = route.handle?.[0]?.routes || []
|
|
29
|
+
|
|
30
|
+
if (name === 'main') {
|
|
31
|
+
// Main is the fallback route (no match path)
|
|
32
|
+
const fallback = subroutes.find(r => !r.match)
|
|
33
|
+
if (fallback) {
|
|
34
|
+
const dial = fallback.handle?.[0]?.upstreams?.[0]?.dial
|
|
35
|
+
if (dial) return `http://${dial}`
|
|
36
|
+
}
|
|
37
|
+
} else {
|
|
38
|
+
// Branch route matches /branch--<name>/*
|
|
39
|
+
const branchRoute = subroutes.find(r => {
|
|
40
|
+
const paths = r.match?.[0]?.path || []
|
|
41
|
+
return paths.some(p => p === `/branch--${name}/*`)
|
|
42
|
+
})
|
|
43
|
+
if (branchRoute) {
|
|
44
|
+
const dial = branchRoute.handle?.[0]?.upstreams?.[0]?.dial
|
|
45
|
+
if (dial) return `http://${dial}`
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
} catch {
|
|
50
|
+
// Caddy not running or not reachable — fall through
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Fallback to ports.json
|
|
54
|
+
const port = getPort(name)
|
|
55
|
+
return `http://localhost:${port}`
|
|
56
|
+
}
|