@dfosco/storyboard-react 3.0.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 CHANGED
@@ -1,10 +1,10 @@
1
1
  {
2
2
  "name": "@dfosco/storyboard-react",
3
- "version": "3.0.0",
3
+ "version": "3.1.0",
4
4
  "type": "module",
5
5
  "dependencies": {
6
- "@dfosco/storyboard-core": "3.0.0",
7
- "@dfosco/tiny-canvas": "file:../tiny-canvas",
6
+ "@dfosco/storyboard-core": "3.1.0",
7
+ "@dfosco/tiny-canvas": "^1.1.0",
8
8
  "@neodrag/react": "^2.3.1",
9
9
  "glob": "^11.0.0",
10
10
  "jsonc-parser": "^3.3.1"
@@ -0,0 +1,123 @@
1
+ import { useState, useRef, useEffect, useCallback } from 'react'
2
+ import styles from './CanvasControls.module.css'
3
+
4
+ const ZOOM_STEPS = [25, 50, 75, 100, 125, 150, 200]
5
+ export const ZOOM_MIN = ZOOM_STEPS[0]
6
+ export const ZOOM_MAX = ZOOM_STEPS[ZOOM_STEPS.length - 1]
7
+
8
+ const WIDGET_TYPES = [
9
+ { type: 'sticky-note', label: 'Sticky Note' },
10
+ { type: 'markdown', label: 'Markdown' },
11
+ { type: 'prototype', label: 'Prototype' },
12
+ ]
13
+
14
+ /**
15
+ * Focused canvas toolbar — bottom-left controls for zoom and widget creation.
16
+ */
17
+ export default function CanvasControls({ zoom, onZoomChange, onAddWidget }) {
18
+ const [menuOpen, setMenuOpen] = useState(false)
19
+ const menuRef = useRef(null)
20
+
21
+ // Close menu on outside click
22
+ useEffect(() => {
23
+ if (!menuOpen) return
24
+ function handlePointerDown(e) {
25
+ if (menuRef.current && !menuRef.current.contains(e.target)) {
26
+ setMenuOpen(false)
27
+ }
28
+ }
29
+ document.addEventListener('pointerdown', handlePointerDown)
30
+ return () => document.removeEventListener('pointerdown', handlePointerDown)
31
+ }, [menuOpen])
32
+
33
+ const zoomIn = useCallback(() => {
34
+ onZoomChange((z) => {
35
+ const next = ZOOM_STEPS.find((s) => s > z)
36
+ return next ?? ZOOM_MAX
37
+ })
38
+ }, [onZoomChange])
39
+
40
+ const zoomOut = useCallback(() => {
41
+ onZoomChange((z) => {
42
+ const next = [...ZOOM_STEPS].reverse().find((s) => s < z)
43
+ return next ?? ZOOM_MIN
44
+ })
45
+ }, [onZoomChange])
46
+
47
+ const resetZoom = useCallback(() => {
48
+ onZoomChange(100)
49
+ }, [onZoomChange])
50
+
51
+ const handleAddWidget = useCallback((type) => {
52
+ onAddWidget(type)
53
+ setMenuOpen(false)
54
+ }, [onAddWidget])
55
+
56
+ return (
57
+ <div className={styles.toolbar} role="toolbar" aria-label="Canvas controls">
58
+ {/* Create widget */}
59
+ <div ref={menuRef} className={styles.createGroup}>
60
+ <button
61
+ className={styles.btn}
62
+ onClick={() => setMenuOpen((v) => !v)}
63
+ aria-label="Add widget"
64
+ aria-expanded={menuOpen}
65
+ title="Add widget"
66
+ >
67
+ <svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true">
68
+ <path d="M7.75 2a.75.75 0 0 1 .75.75V7h4.25a.75.75 0 0 1 0 1.5H8.5v4.25a.75.75 0 0 1-1.5 0V8.5H2.75a.75.75 0 0 1 0-1.5H7V2.75A.75.75 0 0 1 7.75 2Z" />
69
+ </svg>
70
+ </button>
71
+ {menuOpen && (
72
+ <div className={styles.menu} role="menu">
73
+ <div className={styles.menuLabel}>Add to canvas</div>
74
+ {WIDGET_TYPES.map((wt) => (
75
+ <button
76
+ key={wt.type}
77
+ className={styles.menuItem}
78
+ role="menuitem"
79
+ onClick={() => handleAddWidget(wt.type)}
80
+ >
81
+ {wt.label}
82
+ </button>
83
+ ))}
84
+ </div>
85
+ )}
86
+ </div>
87
+
88
+ <div className={styles.divider} />
89
+
90
+ {/* Zoom controls */}
91
+ <button
92
+ className={styles.btn}
93
+ onClick={zoomOut}
94
+ disabled={zoom <= ZOOM_MIN}
95
+ aria-label="Zoom out"
96
+ title="Zoom out"
97
+ >
98
+ <svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true">
99
+ <path d="M2.75 7.25h10.5a.75.75 0 0 1 0 1.5H2.75a.75.75 0 0 1 0-1.5Z" />
100
+ </svg>
101
+ </button>
102
+ <button
103
+ className={styles.zoomLevel}
104
+ onClick={resetZoom}
105
+ title="Reset to 100%"
106
+ aria-label={`Zoom ${zoom}%, click to reset`}
107
+ >
108
+ {zoom}%
109
+ </button>
110
+ <button
111
+ className={styles.btn}
112
+ onClick={zoomIn}
113
+ disabled={zoom >= ZOOM_MAX}
114
+ aria-label="Zoom in"
115
+ title="Zoom in"
116
+ >
117
+ <svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true">
118
+ <path d="M7.75 2a.75.75 0 0 1 .75.75V7h4.25a.75.75 0 0 1 0 1.5H8.5v4.25a.75.75 0 0 1-1.5 0V8.5H2.75a.75.75 0 0 1 0-1.5H7V2.75A.75.75 0 0 1 7.75 2Z" />
119
+ </svg>
120
+ </button>
121
+ </div>
122
+ )
123
+ }
@@ -0,0 +1,133 @@
1
+ .toolbar {
2
+ position: fixed;
3
+ bottom: 24px;
4
+ left: 24px;
5
+ z-index: 9998;
6
+ display: flex;
7
+ align-items: center;
8
+ gap: 2px;
9
+ padding: 4px;
10
+ background: rgba(255, 255, 255, 0.92);
11
+ backdrop-filter: blur(12px);
12
+ -webkit-backdrop-filter: blur(12px);
13
+ border: 1px solid rgba(0, 0, 0, 0.12);
14
+ border-radius: 10px;
15
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.12);
16
+ font-family: var(--tc-font-stack, system-ui, -apple-system, sans-serif);
17
+ }
18
+
19
+ @media (prefers-color-scheme: dark) {
20
+ .toolbar {
21
+ background: rgba(22, 27, 34, 0.88);
22
+ border-color: rgba(255, 255, 255, 0.1);
23
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.4);
24
+ }
25
+ }
26
+
27
+ .btn {
28
+ all: unset;
29
+ cursor: pointer;
30
+ display: flex;
31
+ align-items: center;
32
+ justify-content: center;
33
+ width: 32px;
34
+ height: 32px;
35
+ border-radius: 8px;
36
+ color: var(--fgColor-default, #1f2328);
37
+ transition: background 120ms;
38
+ }
39
+
40
+ .btn:hover:not(:disabled) {
41
+ background: var(--bgColor-muted, #f6f8fa);
42
+ }
43
+
44
+ .btn:active:not(:disabled) {
45
+ background: var(--bgColor-neutral-muted, #eaeef2);
46
+ }
47
+
48
+ .btn:disabled {
49
+ opacity: 0.35;
50
+ cursor: default;
51
+ }
52
+
53
+ .zoomLevel {
54
+ all: unset;
55
+ cursor: pointer;
56
+ display: flex;
57
+ align-items: center;
58
+ justify-content: center;
59
+ min-width: 44px;
60
+ height: 32px;
61
+ padding: 0 4px;
62
+ border-radius: 8px;
63
+ font-size: 12px;
64
+ font-weight: 500;
65
+ font-variant-numeric: tabular-nums;
66
+ color: var(--fgColor-muted, #656d76);
67
+ transition: background 120ms;
68
+ }
69
+
70
+ .zoomLevel:hover {
71
+ background: var(--bgColor-muted, #f6f8fa);
72
+ color: var(--fgColor-default, #1f2328);
73
+ }
74
+
75
+ .divider {
76
+ width: 1px;
77
+ height: 20px;
78
+ margin: 0 2px;
79
+ background: var(--borderColor-muted, #d8dee4);
80
+ }
81
+
82
+ /* Create widget menu */
83
+ .createGroup {
84
+ position: relative;
85
+ }
86
+
87
+ .menu {
88
+ position: absolute;
89
+ bottom: calc(100% + 8px);
90
+ left: 0;
91
+ min-width: 160px;
92
+ padding: 4px;
93
+ background: rgba(255, 255, 255, 0.95);
94
+ backdrop-filter: blur(12px);
95
+ -webkit-backdrop-filter: blur(12px);
96
+ border: 1px solid rgba(0, 0, 0, 0.12);
97
+ border-radius: 10px;
98
+ box-shadow: 0 4px 16px rgba(0, 0, 0, 0.15);
99
+ z-index: 1;
100
+ }
101
+
102
+ @media (prefers-color-scheme: dark) {
103
+ .menu {
104
+ background: rgba(22, 27, 34, 0.92);
105
+ border-color: rgba(255, 255, 255, 0.1);
106
+ box-shadow: 0 4px 16px rgba(0, 0, 0, 0.5);
107
+ }
108
+ }
109
+
110
+ .menuLabel {
111
+ padding: 6px 10px 4px;
112
+ font-size: 11px;
113
+ font-weight: 600;
114
+ color: var(--fgColor-muted, #656d76);
115
+ text-transform: uppercase;
116
+ letter-spacing: 0.4px;
117
+ }
118
+
119
+ .menuItem {
120
+ all: unset;
121
+ cursor: pointer;
122
+ display: block;
123
+ width: 100%;
124
+ padding: 6px 10px;
125
+ font-size: 13px;
126
+ color: var(--fgColor-default, #1f2328);
127
+ border-radius: 6px;
128
+ box-sizing: border-box;
129
+ }
130
+
131
+ .menuItem:hover {
132
+ background: var(--bgColor-muted, #f6f8fa);
133
+ }
@@ -1,12 +1,15 @@
1
- import { createElement, useCallback, useRef, useState } from 'react'
1
+ import { createElement, useCallback, useEffect, useRef, useState } from 'react'
2
2
  import { Canvas } from '@dfosco/tiny-canvas'
3
3
  import { useCanvas } from './useCanvas.js'
4
4
  import { getWidgetComponent } from './widgets/index.js'
5
+ import { schemas, getDefaults } from './widgets/widgetProps.js'
5
6
  import ComponentWidget from './widgets/ComponentWidget.jsx'
6
- import CanvasToolbar from './CanvasToolbar.jsx'
7
- import { updateCanvas, removeWidget as removeWidgetApi } from './canvasApi.js'
7
+ import { addWidget as addWidgetApi, updateCanvas, removeWidget as removeWidgetApi } from './canvasApi.js'
8
8
  import styles from './CanvasPage.module.css'
9
9
 
10
+ const ZOOM_MIN = 25
11
+ const ZOOM_MAX = 200
12
+
10
13
  /**
11
14
  * Debounce helper — returns a function that delays invocation.
12
15
  */
@@ -18,8 +21,33 @@ function debounce(fn, ms) {
18
21
  }
19
22
  }
20
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
+
21
49
  /** Renders a single JSON-defined widget by type lookup. */
22
- function WidgetRenderer({ widget, onUpdate, onRemove }) {
50
+ function WidgetRenderer({ widget, onUpdate }) {
23
51
  const Component = getWidgetComponent(widget.type)
24
52
  if (!Component) {
25
53
  console.warn(`[canvas] Unknown widget type: ${widget.type}`)
@@ -29,7 +57,6 @@ function WidgetRenderer({ widget, onUpdate, onRemove }) {
29
57
  id: widget.id,
30
58
  props: widget.props,
31
59
  onUpdate,
32
- onRemove,
33
60
  })
34
61
  }
35
62
 
@@ -45,9 +72,16 @@ export default function CanvasPage({ name }) {
45
72
  // Local mutable copy of widgets for instant UI updates
46
73
  const [localWidgets, setLocalWidgets] = useState(canvas?.widgets ?? null)
47
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
+
48
81
  if (canvas !== trackedCanvas) {
49
82
  setTrackedCanvas(canvas)
50
83
  setLocalWidgets(canvas?.widgets ?? null)
84
+ setCanvasTitle(canvas?.title || name)
51
85
  }
52
86
 
53
87
  // Debounced save to server
@@ -59,6 +93,27 @@ export default function CanvasPage({ name }) {
59
93
  }, 2000)
60
94
  ).current
61
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
+
62
117
  const handleWidgetUpdate = useCallback((widgetId, updates) => {
63
118
  setLocalWidgets((prev) => {
64
119
  if (!prev) return prev
@@ -77,6 +132,206 @@ export default function CanvasPage({ name }) {
77
132
  )
78
133
  }, [name])
79
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
+
80
335
  if (!canvas) {
81
336
  return (
82
337
  <div className={styles.empty}>
@@ -115,31 +370,64 @@ export default function CanvasPage({ name }) {
115
370
  }
116
371
  }
117
372
 
118
- // 2. JSON-defined mutable widgets
373
+ // 2. JSON-defined mutable widgets (selectable)
119
374
  for (const widget of (localWidgets ?? [])) {
120
375
  allChildren.push(
121
- <div key={widget.id} id={widget.id}>
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
+ >
122
385
  <WidgetRenderer
123
386
  widget={widget}
124
387
  onUpdate={(updates) => handleWidgetUpdate(widget.id, updates)}
125
- onRemove={() => handleWidgetRemove(widget.id)}
126
388
  />
127
389
  </div>
128
390
  )
129
391
  }
130
392
 
393
+ const scale = zoom / 100
394
+
131
395
  return (
132
396
  <>
133
- <Canvas {...canvasProps}>
134
- {allChildren}
135
- </Canvas>
136
- <CanvasToolbar
137
- canvasName={name}
138
- onWidgetAdded={() => {
139
- // Reload the page to pick up the new widget from the updated .canvas.json
140
- window.location.reload()
141
- }}
142
- />
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>
143
431
  </>
144
432
  )
145
433
  }