@dfosco/storyboard-react 2.8.0 → 3.1.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/package.json +6 -3
- package/src/Viewfinder.jsx +5 -4
- package/src/canvas/CanvasControls.jsx +123 -0
- package/src/canvas/CanvasControls.module.css +133 -0
- package/src/canvas/CanvasPage.jsx +433 -0
- package/src/canvas/CanvasPage.module.css +73 -0
- package/src/canvas/CanvasToolbar.jsx +76 -0
- package/src/canvas/CanvasToolbar.module.css +92 -0
- package/src/canvas/canvasApi.js +41 -0
- package/src/canvas/useCanvas.js +74 -0
- package/src/canvas/widgets/ComponentWidget.jsx +15 -0
- package/src/canvas/widgets/LinkPreview.jsx +34 -0
- package/src/canvas/widgets/LinkPreview.module.css +51 -0
- package/src/canvas/widgets/MarkdownBlock.jsx +91 -0
- package/src/canvas/widgets/MarkdownBlock.module.css +78 -0
- package/src/canvas/widgets/PrototypeEmbed.jsx +179 -0
- package/src/canvas/widgets/PrototypeEmbed.module.css +242 -0
- package/src/canvas/widgets/StickyNote.jsx +98 -0
- package/src/canvas/widgets/StickyNote.module.css +111 -0
- package/src/canvas/widgets/WidgetWrapper.jsx +15 -0
- package/src/canvas/widgets/WidgetWrapper.module.css +23 -0
- package/src/canvas/widgets/index.js +23 -0
- package/src/canvas/widgets/widgetProps.js +151 -0
- package/src/hooks/useFeatureFlag.js +2 -4
- package/src/hooks/useFlows.js +50 -0
- package/src/hooks/useFlows.test.js +134 -0
- package/src/index.js +5 -0
- package/src/vite/data-plugin.js +131 -29
- package/src/vite/data-plugin.test.js +3 -3
|
@@ -0,0 +1,433 @@
|
|
|
1
|
+
import { createElement, useCallback, useEffect, useRef, useState } from 'react'
|
|
2
|
+
import { Canvas } from '@dfosco/tiny-canvas'
|
|
3
|
+
import { useCanvas } from './useCanvas.js'
|
|
4
|
+
import { getWidgetComponent } from './widgets/index.js'
|
|
5
|
+
import { schemas, getDefaults } from './widgets/widgetProps.js'
|
|
6
|
+
import ComponentWidget from './widgets/ComponentWidget.jsx'
|
|
7
|
+
import { addWidget as addWidgetApi, updateCanvas, removeWidget as removeWidgetApi } from './canvasApi.js'
|
|
8
|
+
import styles from './CanvasPage.module.css'
|
|
9
|
+
|
|
10
|
+
const ZOOM_MIN = 25
|
|
11
|
+
const ZOOM_MAX = 200
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Debounce helper — returns a function that delays invocation.
|
|
15
|
+
*/
|
|
16
|
+
function debounce(fn, ms) {
|
|
17
|
+
let timer
|
|
18
|
+
return (...args) => {
|
|
19
|
+
clearTimeout(timer)
|
|
20
|
+
timer = setTimeout(() => fn(...args), ms)
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Save a drag position to localStorage so tiny-canvas picks it up on render.
|
|
26
|
+
*/
|
|
27
|
+
function saveWidgetPosition(widgetId, x, y) {
|
|
28
|
+
try {
|
|
29
|
+
const queue = JSON.parse(localStorage.getItem('tiny-canvas-queue')) || []
|
|
30
|
+
const now = new Date().toISOString().replace(/[:.]/g, '-')
|
|
31
|
+
const entry = { id: widgetId, x, y, time: now }
|
|
32
|
+
const idx = queue.findIndex((item) => item.id === widgetId)
|
|
33
|
+
if (idx >= 0) queue[idx] = entry
|
|
34
|
+
else queue.push(entry)
|
|
35
|
+
localStorage.setItem('tiny-canvas-queue', JSON.stringify(queue))
|
|
36
|
+
} catch { /* localStorage unavailable */ }
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Get viewport-center coordinates for placing a new widget.
|
|
41
|
+
*/
|
|
42
|
+
function getViewportCenter() {
|
|
43
|
+
return {
|
|
44
|
+
x: Math.round(window.innerWidth / 2 - 120),
|
|
45
|
+
y: Math.round(window.innerHeight / 2 - 80),
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Renders a single JSON-defined widget by type lookup. */
|
|
50
|
+
function WidgetRenderer({ widget, onUpdate }) {
|
|
51
|
+
const Component = getWidgetComponent(widget.type)
|
|
52
|
+
if (!Component) {
|
|
53
|
+
console.warn(`[canvas] Unknown widget type: ${widget.type}`)
|
|
54
|
+
return null
|
|
55
|
+
}
|
|
56
|
+
return createElement(Component, {
|
|
57
|
+
id: widget.id,
|
|
58
|
+
props: widget.props,
|
|
59
|
+
onUpdate,
|
|
60
|
+
})
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Generic canvas page component.
|
|
65
|
+
* Reads canvas data from the index and renders all widgets on a draggable surface.
|
|
66
|
+
*
|
|
67
|
+
* @param {{ name: string }} props - Canvas name as indexed by the data plugin
|
|
68
|
+
*/
|
|
69
|
+
export default function CanvasPage({ name }) {
|
|
70
|
+
const { canvas, jsxExports, loading } = useCanvas(name)
|
|
71
|
+
|
|
72
|
+
// Local mutable copy of widgets for instant UI updates
|
|
73
|
+
const [localWidgets, setLocalWidgets] = useState(canvas?.widgets ?? null)
|
|
74
|
+
const [trackedCanvas, setTrackedCanvas] = useState(canvas)
|
|
75
|
+
const [selectedWidgetId, setSelectedWidgetId] = useState(null)
|
|
76
|
+
const [zoom, setZoom] = useState(100)
|
|
77
|
+
const scrollRef = useRef(null)
|
|
78
|
+
const [canvasTitle, setCanvasTitle] = useState(canvas?.title || name)
|
|
79
|
+
const titleInputRef = useRef(null)
|
|
80
|
+
|
|
81
|
+
if (canvas !== trackedCanvas) {
|
|
82
|
+
setTrackedCanvas(canvas)
|
|
83
|
+
setLocalWidgets(canvas?.widgets ?? null)
|
|
84
|
+
setCanvasTitle(canvas?.title || name)
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Debounced save to server
|
|
88
|
+
const debouncedSave = useRef(
|
|
89
|
+
debounce((canvasName, widgets) => {
|
|
90
|
+
updateCanvas(canvasName, { widgets }).catch((err) =>
|
|
91
|
+
console.error('[canvas] Failed to save:', err)
|
|
92
|
+
)
|
|
93
|
+
}, 2000)
|
|
94
|
+
).current
|
|
95
|
+
|
|
96
|
+
const debouncedTitleSave = useRef(
|
|
97
|
+
debounce((canvasName, title) => {
|
|
98
|
+
updateCanvas(canvasName, { settings: { title } }).catch((err) =>
|
|
99
|
+
console.error('[canvas] Failed to save title:', err)
|
|
100
|
+
)
|
|
101
|
+
}, 1000)
|
|
102
|
+
).current
|
|
103
|
+
|
|
104
|
+
const handleTitleChange = useCallback((e) => {
|
|
105
|
+
const newTitle = e.target.value
|
|
106
|
+
setCanvasTitle(newTitle)
|
|
107
|
+
debouncedTitleSave(name, newTitle)
|
|
108
|
+
}, [name, debouncedTitleSave])
|
|
109
|
+
|
|
110
|
+
const handleTitleKeyDown = useCallback((e) => {
|
|
111
|
+
if (e.key === 'Enter') {
|
|
112
|
+
e.target.blur()
|
|
113
|
+
}
|
|
114
|
+
e.stopPropagation()
|
|
115
|
+
}, [])
|
|
116
|
+
|
|
117
|
+
const handleWidgetUpdate = useCallback((widgetId, updates) => {
|
|
118
|
+
setLocalWidgets((prev) => {
|
|
119
|
+
if (!prev) return prev
|
|
120
|
+
const next = prev.map((w) =>
|
|
121
|
+
w.id === widgetId ? { ...w, props: { ...w.props, ...updates } } : w
|
|
122
|
+
)
|
|
123
|
+
debouncedSave(name, next)
|
|
124
|
+
return next
|
|
125
|
+
})
|
|
126
|
+
}, [name, debouncedSave])
|
|
127
|
+
|
|
128
|
+
const handleWidgetRemove = useCallback((widgetId) => {
|
|
129
|
+
setLocalWidgets((prev) => prev ? prev.filter((w) => w.id !== widgetId) : prev)
|
|
130
|
+
removeWidgetApi(name, widgetId).catch((err) =>
|
|
131
|
+
console.error('[canvas] Failed to remove widget:', err)
|
|
132
|
+
)
|
|
133
|
+
}, [name])
|
|
134
|
+
|
|
135
|
+
// Signal canvas mount/unmount to CoreUIBar (include zoom state)
|
|
136
|
+
useEffect(() => {
|
|
137
|
+
document.dispatchEvent(new CustomEvent('storyboard:canvas:mounted', {
|
|
138
|
+
detail: { name, zoom }
|
|
139
|
+
}))
|
|
140
|
+
return () => {
|
|
141
|
+
document.dispatchEvent(new CustomEvent('storyboard:canvas:unmounted'))
|
|
142
|
+
}
|
|
143
|
+
}, [name, zoom])
|
|
144
|
+
|
|
145
|
+
// Add a widget by type — used by CanvasControls and CoreUIBar event
|
|
146
|
+
const addWidget = useCallback(async (type) => {
|
|
147
|
+
const defaultProps = schemas[type] ? getDefaults(schemas[type]) : {}
|
|
148
|
+
const pos = getViewportCenter()
|
|
149
|
+
try {
|
|
150
|
+
const result = await addWidgetApi(name, {
|
|
151
|
+
type,
|
|
152
|
+
props: defaultProps,
|
|
153
|
+
position: pos,
|
|
154
|
+
})
|
|
155
|
+
if (result.success && result.widget) {
|
|
156
|
+
saveWidgetPosition(result.widget.id, pos.x, pos.y)
|
|
157
|
+
setLocalWidgets((prev) => [...(prev || []), result.widget])
|
|
158
|
+
}
|
|
159
|
+
} catch (err) {
|
|
160
|
+
console.error('[canvas] Failed to add widget:', err)
|
|
161
|
+
}
|
|
162
|
+
}, [name])
|
|
163
|
+
|
|
164
|
+
// Listen for CoreUIBar add-widget events
|
|
165
|
+
useEffect(() => {
|
|
166
|
+
function handleAddWidget(e) {
|
|
167
|
+
addWidget(e.detail.type)
|
|
168
|
+
}
|
|
169
|
+
document.addEventListener('storyboard:canvas:add-widget', handleAddWidget)
|
|
170
|
+
return () => document.removeEventListener('storyboard:canvas:add-widget', handleAddWidget)
|
|
171
|
+
}, [addWidget])
|
|
172
|
+
|
|
173
|
+
// Listen for zoom changes from CoreUIBar
|
|
174
|
+
useEffect(() => {
|
|
175
|
+
function handleZoom(e) {
|
|
176
|
+
const { zoom: newZoom } = e.detail
|
|
177
|
+
if (typeof newZoom === 'number') {
|
|
178
|
+
setZoom(Math.min(ZOOM_MAX, Math.max(ZOOM_MIN, newZoom)))
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
document.addEventListener('storyboard:canvas:set-zoom', handleZoom)
|
|
182
|
+
return () => document.removeEventListener('storyboard:canvas:set-zoom', handleZoom)
|
|
183
|
+
}, [])
|
|
184
|
+
|
|
185
|
+
// Broadcast zoom level to CoreUIBar whenever it changes
|
|
186
|
+
useEffect(() => {
|
|
187
|
+
document.dispatchEvent(new CustomEvent('storyboard:canvas:zoom-changed', {
|
|
188
|
+
detail: { zoom }
|
|
189
|
+
}))
|
|
190
|
+
}, [zoom])
|
|
191
|
+
|
|
192
|
+
// Delete selected widget on Delete/Backspace key
|
|
193
|
+
useEffect(() => {
|
|
194
|
+
function handleKeyDown(e) {
|
|
195
|
+
if (!selectedWidgetId) return
|
|
196
|
+
const tag = e.target.tagName
|
|
197
|
+
if (tag === 'INPUT' || tag === 'TEXTAREA' || e.target.isContentEditable) return
|
|
198
|
+
if (e.key === 'Delete' || e.key === 'Backspace') {
|
|
199
|
+
e.preventDefault()
|
|
200
|
+
handleWidgetRemove(selectedWidgetId)
|
|
201
|
+
setSelectedWidgetId(null)
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
document.addEventListener('keydown', handleKeyDown)
|
|
205
|
+
return () => document.removeEventListener('keydown', handleKeyDown)
|
|
206
|
+
}, [selectedWidgetId, handleWidgetRemove])
|
|
207
|
+
|
|
208
|
+
// Paste handler — same-origin URLs become prototypes, other URLs become link previews, text becomes markdown
|
|
209
|
+
useEffect(() => {
|
|
210
|
+
const baseUrl = window.location.origin + (import.meta.env?.BASE_URL || '/').replace(/\/$/, '')
|
|
211
|
+
|
|
212
|
+
async function handlePaste(e) {
|
|
213
|
+
const tag = e.target.tagName
|
|
214
|
+
if (tag === 'INPUT' || tag === 'TEXTAREA' || e.target.isContentEditable) return
|
|
215
|
+
|
|
216
|
+
const text = e.clipboardData?.getData('text/plain')?.trim()
|
|
217
|
+
if (!text) return
|
|
218
|
+
|
|
219
|
+
e.preventDefault()
|
|
220
|
+
|
|
221
|
+
let type, props
|
|
222
|
+
try {
|
|
223
|
+
const parsed = new URL(text)
|
|
224
|
+
if (text.startsWith(baseUrl)) {
|
|
225
|
+
// Same-origin URL → prototype embed with the path portion
|
|
226
|
+
const pathPortion = parsed.pathname + parsed.search + parsed.hash
|
|
227
|
+
const basePath = (import.meta.env?.BASE_URL || '/').replace(/\/$/, '')
|
|
228
|
+
const src = basePath ? pathPortion.replace(new RegExp(`^${basePath}`), '') : pathPortion
|
|
229
|
+
type = 'prototype'
|
|
230
|
+
props = { src: src || '/', label: '', width: 800, height: 600 }
|
|
231
|
+
} else {
|
|
232
|
+
type = 'link-preview'
|
|
233
|
+
props = { url: text, title: '' }
|
|
234
|
+
}
|
|
235
|
+
} catch {
|
|
236
|
+
type = 'markdown'
|
|
237
|
+
props = { content: text }
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
const pos = getViewportCenter()
|
|
241
|
+
try {
|
|
242
|
+
const result = await addWidgetApi(name, {
|
|
243
|
+
type,
|
|
244
|
+
props,
|
|
245
|
+
position: pos,
|
|
246
|
+
})
|
|
247
|
+
if (result.success && result.widget) {
|
|
248
|
+
saveWidgetPosition(result.widget.id, pos.x, pos.y)
|
|
249
|
+
setLocalWidgets((prev) => [...(prev || []), result.widget])
|
|
250
|
+
}
|
|
251
|
+
} catch (err) {
|
|
252
|
+
console.error('[canvas] Failed to add widget from paste:', err)
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
document.addEventListener('paste', handlePaste)
|
|
256
|
+
return () => document.removeEventListener('paste', handlePaste)
|
|
257
|
+
}, [name])
|
|
258
|
+
|
|
259
|
+
// Cmd+scroll / trackpad pinch to smooth-zoom the canvas
|
|
260
|
+
// On macOS, pinch-to-zoom fires wheel events with ctrlKey: true and small
|
|
261
|
+
// fractional deltaY values. We accumulate the delta to handle sub-pixel changes.
|
|
262
|
+
const zoomAccum = useRef(0)
|
|
263
|
+
useEffect(() => {
|
|
264
|
+
function handleWheel(e) {
|
|
265
|
+
if (!e.metaKey && !e.ctrlKey) return
|
|
266
|
+
e.preventDefault()
|
|
267
|
+
zoomAccum.current += -e.deltaY
|
|
268
|
+
const step = Math.trunc(zoomAccum.current)
|
|
269
|
+
if (step === 0) return
|
|
270
|
+
zoomAccum.current -= step
|
|
271
|
+
setZoom((z) => Math.min(ZOOM_MAX, Math.max(ZOOM_MIN, z + step)))
|
|
272
|
+
}
|
|
273
|
+
document.addEventListener('wheel', handleWheel, { passive: false })
|
|
274
|
+
return () => document.removeEventListener('wheel', handleWheel)
|
|
275
|
+
}, [])
|
|
276
|
+
|
|
277
|
+
// Space + drag to pan the canvas
|
|
278
|
+
const [spaceHeld, setSpaceHeld] = useState(false)
|
|
279
|
+
const isPanning = useRef(false)
|
|
280
|
+
const [panningActive, setPanningActive] = useState(false)
|
|
281
|
+
const panStart = useRef({ x: 0, y: 0, scrollX: 0, scrollY: 0 })
|
|
282
|
+
|
|
283
|
+
useEffect(() => {
|
|
284
|
+
function handleKeyDown(e) {
|
|
285
|
+
if (e.key === ' ' && !e.repeat) {
|
|
286
|
+
const tag = e.target.tagName
|
|
287
|
+
if (tag === 'INPUT' || tag === 'TEXTAREA' || e.target.isContentEditable) return
|
|
288
|
+
e.preventDefault()
|
|
289
|
+
setSpaceHeld(true)
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
function handleKeyUp(e) {
|
|
293
|
+
if (e.key === ' ') {
|
|
294
|
+
setSpaceHeld(false)
|
|
295
|
+
isPanning.current = false
|
|
296
|
+
setPanningActive(false)
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
document.addEventListener('keydown', handleKeyDown)
|
|
300
|
+
document.addEventListener('keyup', handleKeyUp)
|
|
301
|
+
return () => {
|
|
302
|
+
document.removeEventListener('keydown', handleKeyDown)
|
|
303
|
+
document.removeEventListener('keyup', handleKeyUp)
|
|
304
|
+
}
|
|
305
|
+
}, [])
|
|
306
|
+
|
|
307
|
+
const handlePanStart = useCallback((e) => {
|
|
308
|
+
if (!spaceHeld) return
|
|
309
|
+
e.preventDefault()
|
|
310
|
+
isPanning.current = true
|
|
311
|
+
setPanningActive(true)
|
|
312
|
+
const el = scrollRef.current
|
|
313
|
+
panStart.current = {
|
|
314
|
+
x: e.clientX,
|
|
315
|
+
y: e.clientY,
|
|
316
|
+
scrollX: el?.scrollLeft ?? 0,
|
|
317
|
+
scrollY: el?.scrollTop ?? 0,
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
function handlePanMove(ev) {
|
|
321
|
+
if (!isPanning.current || !el) return
|
|
322
|
+
el.scrollLeft = panStart.current.scrollX - (ev.clientX - panStart.current.x)
|
|
323
|
+
el.scrollTop = panStart.current.scrollY - (ev.clientY - panStart.current.y)
|
|
324
|
+
}
|
|
325
|
+
function handlePanEnd() {
|
|
326
|
+
isPanning.current = false
|
|
327
|
+
setPanningActive(false)
|
|
328
|
+
document.removeEventListener('mousemove', handlePanMove)
|
|
329
|
+
document.removeEventListener('mouseup', handlePanEnd)
|
|
330
|
+
}
|
|
331
|
+
document.addEventListener('mousemove', handlePanMove)
|
|
332
|
+
document.addEventListener('mouseup', handlePanEnd)
|
|
333
|
+
}, [spaceHeld])
|
|
334
|
+
|
|
335
|
+
if (!canvas) {
|
|
336
|
+
return (
|
|
337
|
+
<div className={styles.empty}>
|
|
338
|
+
<p>Canvas “{name}” not found</p>
|
|
339
|
+
</div>
|
|
340
|
+
)
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
if (loading) {
|
|
344
|
+
return (
|
|
345
|
+
<div className={styles.loading}>
|
|
346
|
+
<p>Loading canvas…</p>
|
|
347
|
+
</div>
|
|
348
|
+
)
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
const canvasProps = {
|
|
352
|
+
centered: canvas.centered ?? false,
|
|
353
|
+
dotted: canvas.dotted ?? false,
|
|
354
|
+
grid: canvas.grid ?? false,
|
|
355
|
+
gridSize: canvas.gridSize ?? 18,
|
|
356
|
+
colorMode: canvas.colorMode ?? 'auto',
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
// Merge JSX-sourced widgets (from .canvas.jsx) and JSON widgets
|
|
360
|
+
const allChildren = []
|
|
361
|
+
|
|
362
|
+
// 1. JSX-sourced component widgets
|
|
363
|
+
if (jsxExports) {
|
|
364
|
+
for (const [exportName, Component] of Object.entries(jsxExports)) {
|
|
365
|
+
allChildren.push(
|
|
366
|
+
<div key={`jsx-${exportName}`} id={`jsx-${exportName}`}>
|
|
367
|
+
<ComponentWidget component={Component} />
|
|
368
|
+
</div>
|
|
369
|
+
)
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
// 2. JSON-defined mutable widgets (selectable)
|
|
374
|
+
for (const widget of (localWidgets ?? [])) {
|
|
375
|
+
allChildren.push(
|
|
376
|
+
<div
|
|
377
|
+
key={widget.id}
|
|
378
|
+
id={widget.id}
|
|
379
|
+
onClick={(e) => {
|
|
380
|
+
e.stopPropagation()
|
|
381
|
+
setSelectedWidgetId(widget.id)
|
|
382
|
+
}}
|
|
383
|
+
className={selectedWidgetId === widget.id ? styles.selected : undefined}
|
|
384
|
+
>
|
|
385
|
+
<WidgetRenderer
|
|
386
|
+
widget={widget}
|
|
387
|
+
onUpdate={(updates) => handleWidgetUpdate(widget.id, updates)}
|
|
388
|
+
/>
|
|
389
|
+
</div>
|
|
390
|
+
)
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
const scale = zoom / 100
|
|
394
|
+
|
|
395
|
+
return (
|
|
396
|
+
<>
|
|
397
|
+
<div className={styles.canvasTitle}>
|
|
398
|
+
<input
|
|
399
|
+
ref={titleInputRef}
|
|
400
|
+
className={styles.canvasTitleInput}
|
|
401
|
+
value={canvasTitle}
|
|
402
|
+
onChange={handleTitleChange}
|
|
403
|
+
onKeyDown={handleTitleKeyDown}
|
|
404
|
+
onMouseDown={(e) => e.stopPropagation()}
|
|
405
|
+
spellCheck={false}
|
|
406
|
+
aria-label="Canvas title"
|
|
407
|
+
style={{ width: `${Math.max(80, canvasTitle.length * 8.5 + 20)}px` }}
|
|
408
|
+
/>
|
|
409
|
+
</div>
|
|
410
|
+
<div
|
|
411
|
+
ref={scrollRef}
|
|
412
|
+
className={styles.canvasScroll}
|
|
413
|
+
style={spaceHeld ? { cursor: panningActive ? 'grabbing' : 'grab' } : undefined}
|
|
414
|
+
onClick={() => setSelectedWidgetId(null)}
|
|
415
|
+
onMouseDown={handlePanStart}
|
|
416
|
+
>
|
|
417
|
+
<div
|
|
418
|
+
className={styles.canvasZoom}
|
|
419
|
+
style={{
|
|
420
|
+
transform: `scale(${scale})`,
|
|
421
|
+
transformOrigin: '0 0',
|
|
422
|
+
width: `${Math.max(10000, 100 / scale)}vw`,
|
|
423
|
+
height: `${Math.max(10000, 100 / scale)}vh`,
|
|
424
|
+
}}
|
|
425
|
+
>
|
|
426
|
+
<Canvas {...canvasProps}>
|
|
427
|
+
{allChildren}
|
|
428
|
+
</Canvas>
|
|
429
|
+
</div>
|
|
430
|
+
</div>
|
|
431
|
+
</>
|
|
432
|
+
)
|
|
433
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
.empty,
|
|
2
|
+
.loading {
|
|
3
|
+
display: flex;
|
|
4
|
+
align-items: center;
|
|
5
|
+
justify-content: center;
|
|
6
|
+
min-height: 100vh;
|
|
7
|
+
font-family: var(--tc-font-stack, system-ui, -apple-system, sans-serif);
|
|
8
|
+
color: var(--fgColor-muted, #656d76);
|
|
9
|
+
font-size: 16px;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
.empty p,
|
|
13
|
+
.loading p {
|
|
14
|
+
margin: 0;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
.canvasScroll {
|
|
18
|
+
width: 100vw;
|
|
19
|
+
height: 100vh;
|
|
20
|
+
overflow: auto;
|
|
21
|
+
background-color: var(--bgColor-muted, #f6f8fa);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
@media (prefers-color-scheme: dark) {
|
|
25
|
+
.canvasScroll {
|
|
26
|
+
background-color: var(--bgColor-muted, #161b22);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
.canvasZoom {
|
|
31
|
+
min-width: 100%;
|
|
32
|
+
min-height: 100%;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
.selected {
|
|
36
|
+
outline: 2px solid var(--bgColor-accent-emphasis, #2f81f7);
|
|
37
|
+
outline-offset: 2px;
|
|
38
|
+
border-radius: 4px;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
.canvasTitle {
|
|
42
|
+
position: fixed;
|
|
43
|
+
top: 12px;
|
|
44
|
+
left: 16px;
|
|
45
|
+
z-index: 10;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
.canvasTitleInput {
|
|
49
|
+
font-family: var(--tc-font-stack, system-ui, -apple-system, sans-serif);
|
|
50
|
+
font-size: 14px;
|
|
51
|
+
font-weight: 600;
|
|
52
|
+
color: var(--fgColor-muted, #656d76);
|
|
53
|
+
background: transparent;
|
|
54
|
+
border: 1px solid transparent;
|
|
55
|
+
border-radius: 6px;
|
|
56
|
+
padding: 4px 8px;
|
|
57
|
+
outline: none;
|
|
58
|
+
min-width: 80px;
|
|
59
|
+
max-width: 300px;
|
|
60
|
+
transition: border-color 150ms, background-color 150ms, color 150ms;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
.canvasTitleInput:hover {
|
|
64
|
+
color: var(--fgColor-default, #1f2328);
|
|
65
|
+
border-color: var(--borderColor-default, #d1d9e0);
|
|
66
|
+
background: var(--bgColor-default, #ffffff);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
.canvasTitleInput:focus {
|
|
70
|
+
color: var(--fgColor-default, #1f2328);
|
|
71
|
+
border-color: var(--bgColor-accent-emphasis, #2f81f7);
|
|
72
|
+
background: var(--bgColor-default, #ffffff);
|
|
73
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { useState } from 'react'
|
|
2
|
+
import { addWidget as addWidgetApi } from './canvasApi.js'
|
|
3
|
+
import { schemas, getDefaults } from './widgets/widgetProps.js'
|
|
4
|
+
import styles from './CanvasToolbar.module.css'
|
|
5
|
+
|
|
6
|
+
const WIDGET_TYPES = [
|
|
7
|
+
{ type: 'sticky-note', label: 'Sticky Note', icon: '📝' },
|
|
8
|
+
{ type: 'markdown', label: 'Markdown', icon: '📄' },
|
|
9
|
+
{ type: 'prototype', label: 'Prototype', icon: '🖥️' },
|
|
10
|
+
]
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Floating toolbar for adding widgets to a canvas.
|
|
14
|
+
*/
|
|
15
|
+
export default function CanvasToolbar({ canvasName, onWidgetAdded }) {
|
|
16
|
+
const [open, setOpen] = useState(false)
|
|
17
|
+
const [adding, setAdding] = useState(false)
|
|
18
|
+
|
|
19
|
+
async function handleAdd(type) {
|
|
20
|
+
if (adding) return
|
|
21
|
+
setAdding(true)
|
|
22
|
+
try {
|
|
23
|
+
const defaultProps = schemas[type] ? getDefaults(schemas[type]) : {}
|
|
24
|
+
const result = await addWidgetApi(canvasName, {
|
|
25
|
+
type,
|
|
26
|
+
props: defaultProps,
|
|
27
|
+
position: { x: 0, y: 0 },
|
|
28
|
+
})
|
|
29
|
+
if (result.success) {
|
|
30
|
+
onWidgetAdded?.(result.widget)
|
|
31
|
+
setOpen(false)
|
|
32
|
+
}
|
|
33
|
+
} catch (err) {
|
|
34
|
+
console.error('[canvas] Failed to add widget:', err)
|
|
35
|
+
} finally {
|
|
36
|
+
setAdding(false)
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
return (
|
|
41
|
+
<nav className={styles.toolbar}>
|
|
42
|
+
{open ? (
|
|
43
|
+
<div className={styles.menu}>
|
|
44
|
+
<header className={styles.menuHeader}>
|
|
45
|
+
<span>Add widget</span>
|
|
46
|
+
<button
|
|
47
|
+
className={styles.closeBtn}
|
|
48
|
+
onClick={() => setOpen(false)}
|
|
49
|
+
aria-label="Close"
|
|
50
|
+
>×</button>
|
|
51
|
+
</header>
|
|
52
|
+
{WIDGET_TYPES.map(({ type, label, icon }) => (
|
|
53
|
+
<button
|
|
54
|
+
key={type}
|
|
55
|
+
className={styles.menuItem}
|
|
56
|
+
onClick={() => handleAdd(type)}
|
|
57
|
+
disabled={adding}
|
|
58
|
+
>
|
|
59
|
+
<span className={styles.menuIcon}>{icon}</span>
|
|
60
|
+
{label}
|
|
61
|
+
</button>
|
|
62
|
+
))}
|
|
63
|
+
</div>
|
|
64
|
+
) : (
|
|
65
|
+
<button
|
|
66
|
+
className={styles.addBtn}
|
|
67
|
+
onClick={() => setOpen(true)}
|
|
68
|
+
title="Add widget"
|
|
69
|
+
aria-label="Add widget"
|
|
70
|
+
>
|
|
71
|
+
+
|
|
72
|
+
</button>
|
|
73
|
+
)}
|
|
74
|
+
</nav>
|
|
75
|
+
)
|
|
76
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
.toolbar {
|
|
2
|
+
position: fixed;
|
|
3
|
+
bottom: 80px;
|
|
4
|
+
left: 24px;
|
|
5
|
+
z-index: 1000;
|
|
6
|
+
font-family: var(--tc-font-stack, system-ui, -apple-system, sans-serif);
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
.addBtn {
|
|
10
|
+
all: unset;
|
|
11
|
+
cursor: pointer;
|
|
12
|
+
width: 48px;
|
|
13
|
+
height: 48px;
|
|
14
|
+
border-radius: 50%;
|
|
15
|
+
background: var(--bgColor-accent-emphasis, #2f81f7);
|
|
16
|
+
color: var(--fgColor-onEmphasis, #ffffff);
|
|
17
|
+
font-size: 28px;
|
|
18
|
+
font-weight: 300;
|
|
19
|
+
display: flex;
|
|
20
|
+
align-items: center;
|
|
21
|
+
justify-content: center;
|
|
22
|
+
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.25);
|
|
23
|
+
transition: transform 150ms, background 150ms;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
.addBtn:hover {
|
|
27
|
+
transform: scale(1.08);
|
|
28
|
+
background: var(--bgColor-accent-emphasis, #388bfd);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
.menu {
|
|
32
|
+
background: var(--bgColor-default, #ffffff);
|
|
33
|
+
border: 1px solid var(--borderColor-muted, rgba(0, 0, 0, 0.15));
|
|
34
|
+
border-radius: 12px;
|
|
35
|
+
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.15);
|
|
36
|
+
overflow: hidden;
|
|
37
|
+
min-width: 180px;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
.menuHeader {
|
|
41
|
+
display: flex;
|
|
42
|
+
align-items: center;
|
|
43
|
+
justify-content: space-between;
|
|
44
|
+
padding: 10px 14px;
|
|
45
|
+
font-size: 12px;
|
|
46
|
+
font-weight: 600;
|
|
47
|
+
text-transform: uppercase;
|
|
48
|
+
letter-spacing: 0.5px;
|
|
49
|
+
color: var(--fgColor-muted, #656d76);
|
|
50
|
+
border-bottom: 1px solid var(--borderColor-muted, rgba(0, 0, 0, 0.1));
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
.closeBtn {
|
|
54
|
+
all: unset;
|
|
55
|
+
cursor: pointer;
|
|
56
|
+
font-size: 16px;
|
|
57
|
+
line-height: 1;
|
|
58
|
+
color: var(--fgColor-muted, #656d76);
|
|
59
|
+
padding: 0 2px;
|
|
60
|
+
border-radius: 4px;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
.closeBtn:hover {
|
|
64
|
+
color: var(--fgColor-default, #1f2328);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
.menuItem {
|
|
68
|
+
all: unset;
|
|
69
|
+
cursor: pointer;
|
|
70
|
+
display: flex;
|
|
71
|
+
align-items: center;
|
|
72
|
+
gap: 10px;
|
|
73
|
+
padding: 10px 14px;
|
|
74
|
+
font-size: 14px;
|
|
75
|
+
color: var(--fgColor-default, #1f2328);
|
|
76
|
+
width: 100%;
|
|
77
|
+
box-sizing: border-box;
|
|
78
|
+
transition: background 100ms;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
.menuItem:hover {
|
|
82
|
+
background: var(--bgColor-muted, #f6f8fa);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
.menuItem:disabled {
|
|
86
|
+
opacity: 0.5;
|
|
87
|
+
cursor: default;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
.menuIcon {
|
|
91
|
+
font-size: 18px;
|
|
92
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Client-side API for canvas CRUD operations.
|
|
3
|
+
* Calls the /_storyboard/canvas/ server endpoints.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
const BASE = '/_storyboard/canvas'
|
|
7
|
+
|
|
8
|
+
function getApiBase() {
|
|
9
|
+
const base = (import.meta.env?.BASE_URL || '/').replace(/\/$/, '')
|
|
10
|
+
return base + BASE
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
async function request(path, method, body) {
|
|
14
|
+
const url = getApiBase() + path
|
|
15
|
+
const res = await fetch(url, {
|
|
16
|
+
method,
|
|
17
|
+
headers: body ? { 'Content-Type': 'application/json' } : undefined,
|
|
18
|
+
body: body ? JSON.stringify(body) : undefined,
|
|
19
|
+
})
|
|
20
|
+
return res.json()
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function listCanvases() {
|
|
24
|
+
return request('/list', 'GET')
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function createCanvas(data) {
|
|
28
|
+
return request('/create', 'POST', data)
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function updateCanvas(name, { widgets, sources, settings }) {
|
|
32
|
+
return request('/update', 'PUT', { name, widgets, sources, settings })
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function addWidget(name, { type, props, position }) {
|
|
36
|
+
return request('/widget', 'POST', { name, type, props, position })
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function removeWidget(name, widgetId) {
|
|
40
|
+
return request('/widget', 'DELETE', { name, widgetId })
|
|
41
|
+
}
|