@dfosco/storyboard-react 3.11.1-beta.0 → 3.11.1
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 -3
- package/src/Viewfinder.jsx +5 -3
- package/src/__mocks__/virtual-storyboard-data-index.js +1 -0
- package/src/canvas/CanvasControls.jsx +2 -59
- package/src/canvas/CanvasControls.module.css +0 -29
- package/src/canvas/CanvasPage.bridge.test.jsx +68 -42
- package/src/canvas/CanvasPage.jsx +791 -68
- package/src/canvas/CanvasPage.module.css +47 -2
- package/src/canvas/CanvasPage.multiselect.test.jsx +345 -0
- package/src/canvas/canvasApi.js +8 -0
- package/src/canvas/computeCanvasBounds.test.js +121 -0
- package/src/canvas/useCanvas.js +2 -1
- package/src/canvas/useUndoRedo.js +86 -0
- package/src/canvas/useUndoRedo.test.js +231 -0
- package/src/canvas/widgets/ComponentWidget.jsx +9 -7
- package/src/canvas/widgets/FigmaEmbed.jsx +195 -0
- package/src/canvas/widgets/FigmaEmbed.module.css +147 -0
- package/src/canvas/widgets/ImageWidget.jsx +115 -0
- package/src/canvas/widgets/ImageWidget.module.css +39 -0
- package/src/canvas/widgets/MarkdownBlock.jsx +25 -8
- package/src/canvas/widgets/MarkdownBlock.test.jsx +53 -0
- package/src/canvas/widgets/PrototypeEmbed.jsx +132 -26
- package/src/canvas/widgets/PrototypeEmbed.module.css +66 -2
- package/src/canvas/widgets/StickyNote.jsx +21 -16
- package/src/canvas/widgets/StickyNote.test.jsx +24 -4
- package/src/canvas/widgets/WidgetChrome.jsx +276 -50
- package/src/canvas/widgets/WidgetChrome.module.css +91 -10
- package/src/canvas/widgets/figmaUrl.js +118 -0
- package/src/canvas/widgets/figmaUrl.test.js +139 -0
- package/src/canvas/widgets/index.js +4 -0
- package/src/canvas/widgets/widgetConfig.js +74 -6
- package/src/canvas/widgets/widgetConfig.test.js +46 -0
- package/src/canvas/widgets/widgetProps.js +2 -0
- package/src/context.jsx +34 -4
- package/src/context.test.jsx +13 -0
|
@@ -39,6 +39,29 @@
|
|
|
39
39
|
top: 12px;
|
|
40
40
|
left: 16px;
|
|
41
41
|
z-index: 10;
|
|
42
|
+
display: flex;
|
|
43
|
+
align-items: center;
|
|
44
|
+
gap: 8px;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
.canvasTitleWrap {
|
|
48
|
+
display: inline-grid;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
.canvasTitleWrap > * {
|
|
52
|
+
grid-area: 1 / 1;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
.canvasTitleMeasure {
|
|
56
|
+
visibility: hidden;
|
|
57
|
+
white-space: pre;
|
|
58
|
+
font-family: var(--tc-font-stack, system-ui, -apple-system, sans-serif);
|
|
59
|
+
font-size: 14px;
|
|
60
|
+
font-weight: 600;
|
|
61
|
+
padding: 4px 8px;
|
|
62
|
+
border: 1px solid transparent;
|
|
63
|
+
min-width: 80px;
|
|
64
|
+
pointer-events: none;
|
|
42
65
|
}
|
|
43
66
|
|
|
44
67
|
.canvasTitleInput {
|
|
@@ -50,9 +73,10 @@
|
|
|
50
73
|
border: 1px solid transparent;
|
|
51
74
|
border-radius: 6px;
|
|
52
75
|
padding: 4px 8px;
|
|
76
|
+
margin: 0;
|
|
53
77
|
outline: none;
|
|
54
|
-
|
|
55
|
-
|
|
78
|
+
width: 100%;
|
|
79
|
+
min-width: 0;
|
|
56
80
|
transition: border-color 150ms, background-color 150ms, color 150ms;
|
|
57
81
|
}
|
|
58
82
|
|
|
@@ -68,7 +92,28 @@
|
|
|
68
92
|
background: var(--bgColor-default, #ffffff);
|
|
69
93
|
}
|
|
70
94
|
|
|
95
|
+
.canvasTitleStatic {
|
|
96
|
+
composes: canvasTitleInput;
|
|
97
|
+
cursor: default;
|
|
98
|
+
pointer-events: none;
|
|
99
|
+
}
|
|
100
|
+
|
|
71
101
|
/* Remove tiny-canvas wrapper clipping — widgets handle their own overflow/radius */
|
|
72
102
|
:global(.tc-draggable-inner) {
|
|
73
103
|
overflow: visible;
|
|
74
104
|
}
|
|
105
|
+
|
|
106
|
+
.localEditingLabel {
|
|
107
|
+
display: inline-flex;
|
|
108
|
+
align-items: center;
|
|
109
|
+
padding: 4px 12px;
|
|
110
|
+
background: hsl(212, 92%, 45%);
|
|
111
|
+
color: #fff;
|
|
112
|
+
font-size: 13px;
|
|
113
|
+
font-weight: 600;
|
|
114
|
+
border-radius: 6px;
|
|
115
|
+
letter-spacing: 0.01em;
|
|
116
|
+
white-space: nowrap;
|
|
117
|
+
pointer-events: none;
|
|
118
|
+
user-select: none;
|
|
119
|
+
}
|
|
@@ -0,0 +1,345 @@
|
|
|
1
|
+
import { fireEvent, render, screen, act } from '@testing-library/react'
|
|
2
|
+
import CanvasPage from './CanvasPage.jsx'
|
|
3
|
+
import { updateCanvas, removeWidget } from './canvasApi.js'
|
|
4
|
+
|
|
5
|
+
const MOCK_UNDO_REDO = {
|
|
6
|
+
snapshot: vi.fn(),
|
|
7
|
+
undo: vi.fn(),
|
|
8
|
+
redo: vi.fn(),
|
|
9
|
+
reset: vi.fn(),
|
|
10
|
+
canUndo: false,
|
|
11
|
+
canRedo: false,
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
vi.mock('./useUndoRedo.js', () => ({
|
|
15
|
+
default: () => MOCK_UNDO_REDO,
|
|
16
|
+
}))
|
|
17
|
+
|
|
18
|
+
// Expose drag callbacks so tests can trigger drags with specific IDs
|
|
19
|
+
let capturedOnDragStart = null
|
|
20
|
+
let capturedOnDrag = null
|
|
21
|
+
let capturedOnDragEnd = null
|
|
22
|
+
vi.mock('@dfosco/tiny-canvas', () => ({
|
|
23
|
+
Canvas: ({ children, onDragStart, onDrag, onDragEnd }) => {
|
|
24
|
+
capturedOnDragStart = onDragStart
|
|
25
|
+
capturedOnDrag = onDrag
|
|
26
|
+
capturedOnDragEnd = onDragEnd
|
|
27
|
+
return <div data-testid="tiny-canvas">{children}</div>
|
|
28
|
+
},
|
|
29
|
+
}))
|
|
30
|
+
|
|
31
|
+
const mockCanvas = {
|
|
32
|
+
title: 'Multi-Select Test',
|
|
33
|
+
widgets: [
|
|
34
|
+
{ id: 'w1', type: 'sticky-note', position: { x: 100, y: 100 }, props: {} },
|
|
35
|
+
{ id: 'w2', type: 'sticky-note', position: { x: 300, y: 100 }, props: {} },
|
|
36
|
+
{ id: 'w3', type: 'markdown', position: { x: 500, y: 200 }, props: {} },
|
|
37
|
+
],
|
|
38
|
+
sources: [],
|
|
39
|
+
centered: false,
|
|
40
|
+
dotted: false,
|
|
41
|
+
grid: false,
|
|
42
|
+
gridSize: 18,
|
|
43
|
+
colorMode: 'auto',
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
vi.mock('./useCanvas.js', () => ({
|
|
47
|
+
useCanvas: () => ({
|
|
48
|
+
canvas: mockCanvas,
|
|
49
|
+
jsxExports: null,
|
|
50
|
+
loading: false,
|
|
51
|
+
}),
|
|
52
|
+
}))
|
|
53
|
+
|
|
54
|
+
vi.mock('./widgets/index.js', () => ({
|
|
55
|
+
getWidgetComponent: () => function MockWidget({ id }) {
|
|
56
|
+
return <div data-testid={`widget-content-${id}`}>widget</div>
|
|
57
|
+
},
|
|
58
|
+
}))
|
|
59
|
+
|
|
60
|
+
// WidgetChrome mock that exposes onSelect with shift parameter
|
|
61
|
+
vi.mock('./widgets/WidgetChrome.jsx', () => ({
|
|
62
|
+
default: ({ children, onSelect, selected, multiSelected, widgetId }) => (
|
|
63
|
+
<div
|
|
64
|
+
data-testid={`chrome-${widgetId}`}
|
|
65
|
+
data-selected={selected || undefined}
|
|
66
|
+
data-multi-selected={multiSelected || undefined}
|
|
67
|
+
>
|
|
68
|
+
{children}
|
|
69
|
+
<button
|
|
70
|
+
className="tc-drag-handle"
|
|
71
|
+
data-testid={`select-${widgetId}`}
|
|
72
|
+
onClick={(e) => { e.stopPropagation(); onSelect?.(false) }}
|
|
73
|
+
>
|
|
74
|
+
select
|
|
75
|
+
</button>
|
|
76
|
+
<button
|
|
77
|
+
className="tc-drag-handle"
|
|
78
|
+
data-testid={`shift-select-${widgetId}`}
|
|
79
|
+
onClick={(e) => { e.stopPropagation(); onSelect?.(true) }}
|
|
80
|
+
>
|
|
81
|
+
shift-select
|
|
82
|
+
</button>
|
|
83
|
+
</div>
|
|
84
|
+
),
|
|
85
|
+
}))
|
|
86
|
+
|
|
87
|
+
vi.mock('./widgets/widgetProps.js', () => ({
|
|
88
|
+
schemas: {},
|
|
89
|
+
getDefaults: () => ({}),
|
|
90
|
+
}))
|
|
91
|
+
|
|
92
|
+
vi.mock('./widgets/widgetConfig.js', () => ({
|
|
93
|
+
getFeatures: () => [],
|
|
94
|
+
isResizable: () => false,
|
|
95
|
+
schemas: {},
|
|
96
|
+
getMenuWidgetTypes: () => [],
|
|
97
|
+
}))
|
|
98
|
+
|
|
99
|
+
vi.mock('./widgets/figmaUrl.js', () => ({
|
|
100
|
+
isFigmaUrl: () => false,
|
|
101
|
+
sanitizeFigmaUrl: (url) => url,
|
|
102
|
+
}))
|
|
103
|
+
|
|
104
|
+
vi.mock('./canvasApi.js', () => ({
|
|
105
|
+
addWidget: vi.fn(),
|
|
106
|
+
updateCanvas: vi.fn(() => Promise.resolve({ success: true })),
|
|
107
|
+
removeWidget: vi.fn(() => Promise.resolve({ success: true })),
|
|
108
|
+
uploadImage: vi.fn(),
|
|
109
|
+
}))
|
|
110
|
+
|
|
111
|
+
describe('CanvasPage multi-select', () => {
|
|
112
|
+
beforeEach(() => {
|
|
113
|
+
delete window.__storyboardCanvasBridgeState
|
|
114
|
+
window.__SB_LOCAL_DEV__ = true
|
|
115
|
+
vi.clearAllMocks()
|
|
116
|
+
capturedOnDragStart = null
|
|
117
|
+
capturedOnDrag = null
|
|
118
|
+
capturedOnDragEnd = null
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
afterEach(() => {
|
|
122
|
+
delete window.__SB_LOCAL_DEV__
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
it('shift+click on select handle adds widget to selection', async () => {
|
|
126
|
+
render(<CanvasPage name="test-canvas" />)
|
|
127
|
+
|
|
128
|
+
// Select first widget
|
|
129
|
+
fireEvent.click(screen.getByTestId('select-w1'))
|
|
130
|
+
await act(async () => { await new Promise((r) => setTimeout(r, 0)) })
|
|
131
|
+
expect(screen.getByTestId('chrome-w1').dataset.selected).toBeDefined()
|
|
132
|
+
|
|
133
|
+
// Shift+select second widget
|
|
134
|
+
fireEvent.click(screen.getByTestId('shift-select-w2'))
|
|
135
|
+
await act(async () => { await new Promise((r) => setTimeout(r, 0)) })
|
|
136
|
+
expect(screen.getByTestId('chrome-w1').dataset.selected).toBeDefined()
|
|
137
|
+
expect(screen.getByTestId('chrome-w2').dataset.selected).toBeDefined()
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
it('shift+click on already selected widget removes it from selection', async () => {
|
|
141
|
+
render(<CanvasPage name="test-canvas" />)
|
|
142
|
+
|
|
143
|
+
// Select both
|
|
144
|
+
fireEvent.click(screen.getByTestId('select-w1'))
|
|
145
|
+
fireEvent.click(screen.getByTestId('shift-select-w2'))
|
|
146
|
+
await act(async () => { await new Promise((r) => setTimeout(r, 0)) })
|
|
147
|
+
expect(screen.getByTestId('chrome-w1').dataset.selected).toBeDefined()
|
|
148
|
+
expect(screen.getByTestId('chrome-w2').dataset.selected).toBeDefined()
|
|
149
|
+
|
|
150
|
+
// Shift+click w1 again to remove it
|
|
151
|
+
fireEvent.click(screen.getByTestId('shift-select-w1'))
|
|
152
|
+
await act(async () => { await new Promise((r) => setTimeout(r, 0)) })
|
|
153
|
+
expect(screen.getByTestId('chrome-w1').dataset.selected).toBeUndefined()
|
|
154
|
+
expect(screen.getByTestId('chrome-w2').dataset.selected).toBeDefined()
|
|
155
|
+
})
|
|
156
|
+
|
|
157
|
+
it('normal click replaces multi-selection with single', async () => {
|
|
158
|
+
render(<CanvasPage name="test-canvas" />)
|
|
159
|
+
|
|
160
|
+
// Multi-select
|
|
161
|
+
fireEvent.click(screen.getByTestId('select-w1'))
|
|
162
|
+
fireEvent.click(screen.getByTestId('shift-select-w2'))
|
|
163
|
+
fireEvent.click(screen.getByTestId('shift-select-w3'))
|
|
164
|
+
await act(async () => { await new Promise((r) => setTimeout(r, 0)) })
|
|
165
|
+
|
|
166
|
+
// Normal click on w1 clears multi-select
|
|
167
|
+
fireEvent.click(screen.getByTestId('select-w1'))
|
|
168
|
+
await act(async () => { await new Promise((r) => setTimeout(r, 0)) })
|
|
169
|
+
expect(screen.getByTestId('chrome-w1').dataset.selected).toBeDefined()
|
|
170
|
+
expect(screen.getByTestId('chrome-w2').dataset.selected).toBeUndefined()
|
|
171
|
+
expect(screen.getByTestId('chrome-w3').dataset.selected).toBeUndefined()
|
|
172
|
+
})
|
|
173
|
+
|
|
174
|
+
it('sets multiSelected on all selected widgets when multiple are selected', async () => {
|
|
175
|
+
render(<CanvasPage name="test-canvas" />)
|
|
176
|
+
|
|
177
|
+
fireEvent.click(screen.getByTestId('select-w1'))
|
|
178
|
+
fireEvent.click(screen.getByTestId('shift-select-w2'))
|
|
179
|
+
await act(async () => { await new Promise((r) => setTimeout(r, 0)) })
|
|
180
|
+
|
|
181
|
+
expect(screen.getByTestId('chrome-w1').dataset.multiSelected).toBeDefined()
|
|
182
|
+
expect(screen.getByTestId('chrome-w2').dataset.multiSelected).toBeDefined()
|
|
183
|
+
// Unselected widget should not have multiSelected
|
|
184
|
+
expect(screen.getByTestId('chrome-w3').dataset.multiSelected).toBeUndefined()
|
|
185
|
+
})
|
|
186
|
+
|
|
187
|
+
it('Escape clears all selection', async () => {
|
|
188
|
+
render(<CanvasPage name="test-canvas" />)
|
|
189
|
+
|
|
190
|
+
fireEvent.click(screen.getByTestId('select-w1'))
|
|
191
|
+
fireEvent.click(screen.getByTestId('shift-select-w2'))
|
|
192
|
+
await act(async () => { await new Promise((r) => setTimeout(r, 0)) })
|
|
193
|
+
|
|
194
|
+
fireEvent.keyDown(document, { key: 'Escape' })
|
|
195
|
+
await act(async () => { await new Promise((r) => setTimeout(r, 0)) })
|
|
196
|
+
|
|
197
|
+
expect(screen.getByTestId('chrome-w1').dataset.selected).toBeUndefined()
|
|
198
|
+
expect(screen.getByTestId('chrome-w2').dataset.selected).toBeUndefined()
|
|
199
|
+
})
|
|
200
|
+
|
|
201
|
+
it('Delete removes all selected widgets and calls updateCanvas', async () => {
|
|
202
|
+
render(<CanvasPage name="test-canvas" />)
|
|
203
|
+
|
|
204
|
+
// Multi-select w1 and w2
|
|
205
|
+
fireEvent.click(screen.getByTestId('select-w1'))
|
|
206
|
+
fireEvent.click(screen.getByTestId('shift-select-w2'))
|
|
207
|
+
await act(async () => { await new Promise((r) => setTimeout(r, 0)) })
|
|
208
|
+
|
|
209
|
+
// Press Delete
|
|
210
|
+
fireEvent.keyDown(document, { key: 'Delete' })
|
|
211
|
+
await act(async () => { await new Promise((r) => setTimeout(r, 0)) })
|
|
212
|
+
|
|
213
|
+
// Should call updateCanvas with only w3 remaining
|
|
214
|
+
expect(updateCanvas).toHaveBeenCalledWith(
|
|
215
|
+
'test-canvas',
|
|
216
|
+
expect.objectContaining({
|
|
217
|
+
widgets: [expect.objectContaining({ id: 'w3' })],
|
|
218
|
+
})
|
|
219
|
+
)
|
|
220
|
+
// Should NOT use individual removeWidget API for multi-delete
|
|
221
|
+
expect(removeWidget).not.toHaveBeenCalled()
|
|
222
|
+
// Should snapshot for undo
|
|
223
|
+
expect(MOCK_UNDO_REDO.snapshot).toHaveBeenCalled()
|
|
224
|
+
})
|
|
225
|
+
|
|
226
|
+
it('single-select Delete uses removeWidget API', async () => {
|
|
227
|
+
render(<CanvasPage name="test-canvas" />)
|
|
228
|
+
|
|
229
|
+
fireEvent.click(screen.getByTestId('select-w1'))
|
|
230
|
+
fireEvent.keyDown(document, { key: 'Backspace' })
|
|
231
|
+
await act(async () => { await new Promise((r) => setTimeout(r, 0)) })
|
|
232
|
+
|
|
233
|
+
expect(removeWidget).toHaveBeenCalledWith('test-canvas', 'w1')
|
|
234
|
+
})
|
|
235
|
+
|
|
236
|
+
it('multi-select move applies delta to all selected widgets', async () => {
|
|
237
|
+
render(<CanvasPage name="test-canvas" />)
|
|
238
|
+
|
|
239
|
+
// Multi-select w1 (100,100) and w2 (300,100)
|
|
240
|
+
fireEvent.click(screen.getByTestId('select-w1'))
|
|
241
|
+
fireEvent.click(screen.getByTestId('shift-select-w2'))
|
|
242
|
+
|
|
243
|
+
// Wait for selectedIdsRef to sync
|
|
244
|
+
await act(async () => { await new Promise((r) => setTimeout(r, 0)) })
|
|
245
|
+
|
|
246
|
+
// Drag w1 to (150, 200) → delta is (+50, +100)
|
|
247
|
+
expect(capturedOnDragEnd).toBeTruthy()
|
|
248
|
+
act(() => {
|
|
249
|
+
capturedOnDragEnd('w1', { x: 150, y: 200 })
|
|
250
|
+
})
|
|
251
|
+
await act(async () => { await new Promise((r) => setTimeout(r, 0)) })
|
|
252
|
+
|
|
253
|
+
// w1 → (150, 200), w2 → (300+50, 100+100) = (350, 200)
|
|
254
|
+
expect(updateCanvas).toHaveBeenCalledWith(
|
|
255
|
+
'test-canvas',
|
|
256
|
+
expect.objectContaining({
|
|
257
|
+
widgets: expect.arrayContaining([
|
|
258
|
+
expect.objectContaining({ id: 'w1', position: { x: 150, y: 200 } }),
|
|
259
|
+
expect.objectContaining({ id: 'w2', position: { x: 350, y: 200 } }),
|
|
260
|
+
// w3 unchanged
|
|
261
|
+
expect.objectContaining({ id: 'w3', position: { x: 500, y: 200 } }),
|
|
262
|
+
]),
|
|
263
|
+
})
|
|
264
|
+
)
|
|
265
|
+
})
|
|
266
|
+
|
|
267
|
+
it('multi-select drag captures peer articles on drag start', async () => {
|
|
268
|
+
render(<CanvasPage name="test-canvas" />)
|
|
269
|
+
|
|
270
|
+
// Multi-select w1 and w2
|
|
271
|
+
fireEvent.click(screen.getByTestId('select-w1'))
|
|
272
|
+
fireEvent.click(screen.getByTestId('shift-select-w2'))
|
|
273
|
+
await act(async () => { await new Promise((r) => setTimeout(r, 0)) })
|
|
274
|
+
|
|
275
|
+
// All drag callbacks should be captured
|
|
276
|
+
expect(capturedOnDragStart).toBeTruthy()
|
|
277
|
+
expect(capturedOnDrag).toBeTruthy()
|
|
278
|
+
|
|
279
|
+
// Trigger drag start — should not throw
|
|
280
|
+
act(() => {
|
|
281
|
+
capturedOnDragStart('w1', { x: 100, y: 100 })
|
|
282
|
+
})
|
|
283
|
+
|
|
284
|
+
// Drag tick — peers stay put (no live preview), should not throw
|
|
285
|
+
act(() => {
|
|
286
|
+
capturedOnDrag('w1', { x: 150, y: 200 })
|
|
287
|
+
})
|
|
288
|
+
})
|
|
289
|
+
|
|
290
|
+
it('multi-select drag preserves selection after drag end', async () => {
|
|
291
|
+
render(<CanvasPage name="test-canvas" />)
|
|
292
|
+
|
|
293
|
+
// Multi-select w1 and w2
|
|
294
|
+
fireEvent.click(screen.getByTestId('select-w1'))
|
|
295
|
+
fireEvent.click(screen.getByTestId('shift-select-w2'))
|
|
296
|
+
await act(async () => { await new Promise((r) => setTimeout(r, 0)) })
|
|
297
|
+
|
|
298
|
+
expect(screen.getByTestId('chrome-w1').dataset.selected).toBeDefined()
|
|
299
|
+
expect(screen.getByTestId('chrome-w2').dataset.selected).toBeDefined()
|
|
300
|
+
|
|
301
|
+
// Simulate full drag: start → drag → end
|
|
302
|
+
act(() => {
|
|
303
|
+
capturedOnDragStart('w1', { x: 100, y: 100 })
|
|
304
|
+
})
|
|
305
|
+
act(() => {
|
|
306
|
+
capturedOnDragEnd('w1', { x: 150, y: 200 })
|
|
307
|
+
})
|
|
308
|
+
await act(async () => { await new Promise((r) => setTimeout(r, 0)) })
|
|
309
|
+
|
|
310
|
+
// Selection should still include both widgets (justDraggedRef prevents collapse)
|
|
311
|
+
expect(screen.getByTestId('chrome-w1').dataset.selected).toBeDefined()
|
|
312
|
+
expect(screen.getByTestId('chrome-w2').dataset.selected).toBeDefined()
|
|
313
|
+
})
|
|
314
|
+
|
|
315
|
+
it('any selected widget can serve as drag handler for the group', async () => {
|
|
316
|
+
render(<CanvasPage name="test-canvas" />)
|
|
317
|
+
|
|
318
|
+
// Multi-select w1 (100,100), w2 (300,100), w3 (500,200)
|
|
319
|
+
fireEvent.click(screen.getByTestId('select-w1'))
|
|
320
|
+
fireEvent.click(screen.getByTestId('shift-select-w2'))
|
|
321
|
+
fireEvent.click(screen.getByTestId('shift-select-w3'))
|
|
322
|
+
await act(async () => { await new Promise((r) => setTimeout(r, 0)) })
|
|
323
|
+
|
|
324
|
+
// Drag w2 (the middle one) to (350, 150) → delta (+50, +50)
|
|
325
|
+
act(() => {
|
|
326
|
+
capturedOnDragStart('w2', { x: 300, y: 100 })
|
|
327
|
+
})
|
|
328
|
+
act(() => {
|
|
329
|
+
capturedOnDragEnd('w2', { x: 350, y: 150 })
|
|
330
|
+
})
|
|
331
|
+
await act(async () => { await new Promise((r) => setTimeout(r, 0)) })
|
|
332
|
+
|
|
333
|
+
// All selected widgets should move by the same delta (+50, +50)
|
|
334
|
+
expect(updateCanvas).toHaveBeenCalledWith(
|
|
335
|
+
'test-canvas',
|
|
336
|
+
expect.objectContaining({
|
|
337
|
+
widgets: expect.arrayContaining([
|
|
338
|
+
expect.objectContaining({ id: 'w1', position: { x: 150, y: 150 } }),
|
|
339
|
+
expect.objectContaining({ id: 'w2', position: { x: 350, y: 150 } }),
|
|
340
|
+
expect.objectContaining({ id: 'w3', position: { x: 550, y: 250 } }),
|
|
341
|
+
]),
|
|
342
|
+
})
|
|
343
|
+
)
|
|
344
|
+
})
|
|
345
|
+
})
|
package/src/canvas/canvasApi.js
CHANGED
|
@@ -39,3 +39,11 @@ export function addWidget(name, { type, props, position }) {
|
|
|
39
39
|
export function removeWidget(name, widgetId) {
|
|
40
40
|
return request('/widget', 'DELETE', { name, widgetId })
|
|
41
41
|
}
|
|
42
|
+
|
|
43
|
+
export function uploadImage(dataUrl, canvasName) {
|
|
44
|
+
return request('/image', 'POST', { dataUrl, canvasName })
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function toggleImagePrivacy(filename) {
|
|
48
|
+
return request('/image/toggle-private', 'POST', { filename })
|
|
49
|
+
}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
|
|
3
|
+
// computeCanvasBounds is not exported, so we replicate it here for unit testing.
|
|
4
|
+
// This keeps the test decoupled from CanvasPage internals while validating the algorithm.
|
|
5
|
+
|
|
6
|
+
const WIDGET_FALLBACK_SIZES = {
|
|
7
|
+
'sticky-note': { width: 180, height: 60 },
|
|
8
|
+
'markdown': { width: 360, height: 200 },
|
|
9
|
+
'prototype': { width: 800, height: 600 },
|
|
10
|
+
'link-preview': { width: 320, height: 120 },
|
|
11
|
+
'figma-embed': { width: 800, height: 450 },
|
|
12
|
+
'component': { width: 200, height: 150 },
|
|
13
|
+
'image': { width: 400, height: 300 },
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function computeCanvasBounds(widgets, sources, jsxExports) {
|
|
17
|
+
let minX = Infinity
|
|
18
|
+
let minY = Infinity
|
|
19
|
+
let maxX = -Infinity
|
|
20
|
+
let maxY = -Infinity
|
|
21
|
+
let hasItems = false
|
|
22
|
+
|
|
23
|
+
for (const w of (widgets ?? [])) {
|
|
24
|
+
const x = w?.position?.x ?? 0
|
|
25
|
+
const y = w?.position?.y ?? 0
|
|
26
|
+
const fallback = WIDGET_FALLBACK_SIZES[w.type] || { width: 200, height: 150 }
|
|
27
|
+
const width = w.props?.width ?? fallback.width
|
|
28
|
+
const height = w.props?.height ?? fallback.height
|
|
29
|
+
minX = Math.min(minX, x)
|
|
30
|
+
minY = Math.min(minY, y)
|
|
31
|
+
maxX = Math.max(maxX, x + width)
|
|
32
|
+
maxY = Math.max(maxY, y + height)
|
|
33
|
+
hasItems = true
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const sourceMap = Object.fromEntries(
|
|
37
|
+
(sources || []).filter((s) => s?.export).map((s) => [s.export, s])
|
|
38
|
+
)
|
|
39
|
+
if (jsxExports) {
|
|
40
|
+
for (const exportName of Object.keys(jsxExports)) {
|
|
41
|
+
const sourceData = sourceMap[exportName] || {}
|
|
42
|
+
const x = sourceData.position?.x ?? 0
|
|
43
|
+
const y = sourceData.position?.y ?? 0
|
|
44
|
+
const fallback = WIDGET_FALLBACK_SIZES['component']
|
|
45
|
+
const width = sourceData.width ?? fallback.width
|
|
46
|
+
const height = sourceData.height ?? fallback.height
|
|
47
|
+
minX = Math.min(minX, x)
|
|
48
|
+
minY = Math.min(minY, y)
|
|
49
|
+
maxX = Math.max(maxX, x + width)
|
|
50
|
+
maxY = Math.max(maxY, y + height)
|
|
51
|
+
hasItems = true
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
return hasItems ? { minX, minY, maxX, maxY } : null
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
describe('computeCanvasBounds', () => {
|
|
59
|
+
it('returns null for empty canvas', () => {
|
|
60
|
+
expect(computeCanvasBounds([], [], null)).toBeNull()
|
|
61
|
+
expect(computeCanvasBounds(null, null, null)).toBeNull()
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
it('computes bounds for a single widget using props dimensions', () => {
|
|
65
|
+
const widgets = [
|
|
66
|
+
{ type: 'sticky-note', position: { x: 100, y: 200 }, props: { width: 300, height: 100 } },
|
|
67
|
+
]
|
|
68
|
+
const bounds = computeCanvasBounds(widgets, [], null)
|
|
69
|
+
expect(bounds).toEqual({ minX: 100, minY: 200, maxX: 400, maxY: 300 })
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
it('uses fallback dimensions when props are missing', () => {
|
|
73
|
+
const widgets = [
|
|
74
|
+
{ type: 'sticky-note', position: { x: 50, y: 50 }, props: {} },
|
|
75
|
+
]
|
|
76
|
+
const bounds = computeCanvasBounds(widgets, [], null)
|
|
77
|
+
expect(bounds).toEqual({ minX: 50, minY: 50, maxX: 230, maxY: 110 }) // 50+180, 50+60
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
it('computes bounds spanning multiple widgets', () => {
|
|
81
|
+
const widgets = [
|
|
82
|
+
{ type: 'sticky-note', position: { x: 0, y: 0 }, props: { width: 180, height: 60 } },
|
|
83
|
+
{ type: 'markdown', position: { x: 500, y: 300 }, props: { width: 360, height: 200 } },
|
|
84
|
+
]
|
|
85
|
+
const bounds = computeCanvasBounds(widgets, [], null)
|
|
86
|
+
expect(bounds).toEqual({ minX: 0, minY: 0, maxX: 860, maxY: 500 })
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
it('includes JSX sources in bounds', () => {
|
|
90
|
+
const sources = [{ export: 'Hero', position: { x: -100, y: -50 }, width: 400, height: 300 }]
|
|
91
|
+
const jsxExports = { Hero: () => null }
|
|
92
|
+
const bounds = computeCanvasBounds([], sources, jsxExports)
|
|
93
|
+
expect(bounds).toEqual({ minX: -100, minY: -50, maxX: 300, maxY: 250 })
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
it('combines widgets and JSX sources', () => {
|
|
97
|
+
const widgets = [
|
|
98
|
+
{ type: 'sticky-note', position: { x: 200, y: 200 }, props: { width: 180, height: 60 } },
|
|
99
|
+
]
|
|
100
|
+
const sources = [{ export: 'Nav', position: { x: 0, y: 0 }, width: 100, height: 100 }]
|
|
101
|
+
const jsxExports = { Nav: () => null }
|
|
102
|
+
const bounds = computeCanvasBounds(widgets, sources, jsxExports)
|
|
103
|
+
expect(bounds).toEqual({ minX: 0, minY: 0, maxX: 380, maxY: 260 })
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
it('handles widgets with missing position (defaults to 0,0)', () => {
|
|
107
|
+
const widgets = [
|
|
108
|
+
{ type: 'sticky-note', props: { width: 180, height: 60 } },
|
|
109
|
+
]
|
|
110
|
+
const bounds = computeCanvasBounds(widgets, [], null)
|
|
111
|
+
expect(bounds).toEqual({ minX: 0, minY: 0, maxX: 180, maxY: 60 })
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
it('uses component fallback for JSX sources without explicit size', () => {
|
|
115
|
+
const sources = [{ export: 'Card', position: { x: 10, y: 10 } }]
|
|
116
|
+
const jsxExports = { Card: () => null }
|
|
117
|
+
const bounds = computeCanvasBounds([], sources, jsxExports)
|
|
118
|
+
// component fallback: 200x150
|
|
119
|
+
expect(bounds).toEqual({ minX: 10, minY: 10, maxX: 210, maxY: 160 })
|
|
120
|
+
})
|
|
121
|
+
})
|
package/src/canvas/useCanvas.js
CHANGED
|
@@ -9,7 +9,8 @@ async function fetchCanvasFromServer(name) {
|
|
|
9
9
|
try {
|
|
10
10
|
const base = (import.meta.env?.BASE_URL || '/').replace(/\/$/, '')
|
|
11
11
|
const res = await fetch(`${base}/_storyboard/canvas/read?name=${encodeURIComponent(name)}`)
|
|
12
|
-
|
|
12
|
+
const contentType = res.headers.get('content-type') || ''
|
|
13
|
+
if (res.ok && contentType.includes('application/json')) return res.json()
|
|
13
14
|
} catch { /* fall back to build-time data */ }
|
|
14
15
|
return null
|
|
15
16
|
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { useRef, useState, useCallback } from 'react'
|
|
2
|
+
|
|
3
|
+
const MAX_HISTORY = 100
|
|
4
|
+
const COALESCE_MS = 2000
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Snapshot-based undo/redo history for canvas widgets.
|
|
8
|
+
*
|
|
9
|
+
* Tracks past/future stacks of widget array clones. The present state
|
|
10
|
+
* is always the live `localWidgets` — this hook only manages history.
|
|
11
|
+
*
|
|
12
|
+
* Edit coalescing: continuous edits to the same widget within COALESCE_MS
|
|
13
|
+
* are merged into one undo step (like Figma).
|
|
14
|
+
*/
|
|
15
|
+
export default function useUndoRedo() {
|
|
16
|
+
const pastRef = useRef([])
|
|
17
|
+
const futureRef = useRef([])
|
|
18
|
+
const lastActionRef = useRef({ type: null, widgetId: null, time: 0 })
|
|
19
|
+
// State counter drives canUndo/canRedo re-renders without cloning the stacks
|
|
20
|
+
const [counts, setCounts] = useState({ past: 0, future: 0 })
|
|
21
|
+
|
|
22
|
+
const snapshot = useCallback((currentWidgets, actionType, widgetId) => {
|
|
23
|
+
const widgets = currentWidgets ?? []
|
|
24
|
+
|
|
25
|
+
// Edit coalescing — skip snapshot if same edit target within timeout
|
|
26
|
+
if (actionType === 'edit' && widgetId) {
|
|
27
|
+
const last = lastActionRef.current
|
|
28
|
+
const now = Date.now()
|
|
29
|
+
if (
|
|
30
|
+
last.type === 'edit' &&
|
|
31
|
+
last.widgetId === widgetId &&
|
|
32
|
+
now - last.time < COALESCE_MS
|
|
33
|
+
) {
|
|
34
|
+
lastActionRef.current = { type: 'edit', widgetId, time: now }
|
|
35
|
+
return
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
pastRef.current.push(structuredClone(widgets))
|
|
40
|
+
if (pastRef.current.length > MAX_HISTORY) pastRef.current.shift()
|
|
41
|
+
futureRef.current = []
|
|
42
|
+
lastActionRef.current = {
|
|
43
|
+
type: actionType,
|
|
44
|
+
widgetId: widgetId || null,
|
|
45
|
+
time: Date.now(),
|
|
46
|
+
}
|
|
47
|
+
setCounts({ past: pastRef.current.length, future: 0 })
|
|
48
|
+
}, [])
|
|
49
|
+
|
|
50
|
+
const undo = useCallback((currentWidgets) => {
|
|
51
|
+
if (pastRef.current.length === 0) return null
|
|
52
|
+
futureRef.current.push(structuredClone(currentWidgets))
|
|
53
|
+
const previous = pastRef.current.pop()
|
|
54
|
+
lastActionRef.current = { type: 'undo', widgetId: null, time: Date.now() }
|
|
55
|
+
setCounts({ past: pastRef.current.length, future: futureRef.current.length })
|
|
56
|
+
return previous
|
|
57
|
+
}, [])
|
|
58
|
+
|
|
59
|
+
const redo = useCallback((currentWidgets) => {
|
|
60
|
+
if (futureRef.current.length === 0) return null
|
|
61
|
+
pastRef.current.push(structuredClone(currentWidgets))
|
|
62
|
+
const next = futureRef.current.pop()
|
|
63
|
+
lastActionRef.current = { type: 'redo', widgetId: null, time: Date.now() }
|
|
64
|
+
setCounts({ past: pastRef.current.length, future: futureRef.current.length })
|
|
65
|
+
return next
|
|
66
|
+
}, [])
|
|
67
|
+
|
|
68
|
+
const reset = useCallback(() => {
|
|
69
|
+
pastRef.current = []
|
|
70
|
+
futureRef.current = []
|
|
71
|
+
lastActionRef.current = { type: null, widgetId: null, time: 0 }
|
|
72
|
+
setCounts((prev) => {
|
|
73
|
+
if (prev.past === 0 && prev.future === 0) return prev
|
|
74
|
+
return { past: 0, future: 0 }
|
|
75
|
+
})
|
|
76
|
+
}, [])
|
|
77
|
+
|
|
78
|
+
return {
|
|
79
|
+
snapshot,
|
|
80
|
+
undo,
|
|
81
|
+
redo,
|
|
82
|
+
reset,
|
|
83
|
+
canUndo: counts.past > 0,
|
|
84
|
+
canRedo: counts.future > 0,
|
|
85
|
+
}
|
|
86
|
+
}
|