@dfosco/storyboard-react 3.10.0-beta.0 → 3.10.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.10.0-beta.0",
3
+ "version": "3.10.0",
4
4
  "type": "module",
5
5
  "dependencies": {
6
- "@dfosco/storyboard-core": "3.10.0-beta.0",
7
- "@dfosco/tiny-canvas": "3.10.0-beta.0",
6
+ "@dfosco/storyboard-core": "3.10.0",
7
+ "@dfosco/tiny-canvas": "3.10.0",
8
8
  "@neodrag/react": "^2.3.1",
9
9
  "glob": "^11.0.0",
10
10
  "jsonc-parser": "^3.3.1"
@@ -1,15 +1,12 @@
1
1
  import { useState, useRef, useEffect, useCallback } from 'react'
2
+ import { getMenuWidgetTypes } from './widgets/widgetConfig.js'
2
3
  import styles from './CanvasControls.module.css'
3
4
 
4
5
  const ZOOM_STEPS = [25, 50, 75, 100, 125, 150, 200]
5
6
  export const ZOOM_MIN = ZOOM_STEPS[0]
6
7
  export const ZOOM_MAX = ZOOM_STEPS[ZOOM_STEPS.length - 1]
7
8
 
8
- const WIDGET_TYPES = [
9
- { type: 'sticky-note', label: 'Sticky Note' },
10
- { type: 'markdown', label: 'Markdown' },
11
- { type: 'prototype', label: 'Prototype embed' },
12
- ]
9
+ const WIDGET_TYPES = getMenuWidgetTypes()
13
10
 
14
11
  /**
15
12
  * Focused canvas toolbar — bottom-left controls for zoom and widget creation.
@@ -1,4 +1,5 @@
1
1
  import { createElement, useCallback, useEffect, useRef, useState } from 'react'
2
+ import { flushSync } from 'react-dom'
2
3
  import { Canvas } from '@dfosco/tiny-canvas'
3
4
  import '@dfosco/tiny-canvas/style.css'
4
5
  import { useCanvas } from './useCanvas.js'
@@ -6,6 +7,8 @@ import { shouldPreventCanvasTextSelection } from './textSelection.js'
6
7
  import { getCanvasThemeVars, getCanvasPrimerAttrs } from './canvasTheme.js'
7
8
  import { getWidgetComponent } from './widgets/index.js'
8
9
  import { schemas, getDefaults } from './widgets/widgetProps.js'
10
+ import { getFeatures } from './widgets/widgetConfig.js'
11
+ import WidgetChrome from './widgets/WidgetChrome.jsx'
9
12
  import ComponentWidget from './widgets/ComponentWidget.jsx'
10
13
  import { addWidget as addWidgetApi, updateCanvas, removeWidget as removeWidgetApi } from './canvasApi.js'
11
14
  import styles from './CanvasPage.module.css'
@@ -15,6 +18,9 @@ const ZOOM_MAX = 200
15
18
 
16
19
  const CANVAS_BRIDGE_STATE_KEY = '__storyboardCanvasBridgeState'
17
20
 
21
+ /** Matches branch-deploy base path prefixes like /branch--my-feature/ */
22
+ const BRANCH_PREFIX_RE = /^\/branch--[^/]+/
23
+
18
24
  function getToolbarColorMode(theme) {
19
25
  return String(theme || 'light').startsWith('dark') ? 'dark' : 'light'
20
26
  }
@@ -51,12 +57,41 @@ function debounce(fn, ms) {
51
57
  }
52
58
 
53
59
  /**
54
- * Get viewport-center coordinates for placing a new widget.
60
+ * Get viewport-center coordinates in canvas space for placing a new widget.
61
+ * Converts the visible center of the scroll container to unscaled canvas coordinates.
55
62
  */
56
- function getViewportCenter() {
63
+ function getViewportCenter(scrollEl, scale) {
64
+ if (!scrollEl) {
65
+ return { x: 0, y: 0 }
66
+ }
67
+ const cx = scrollEl.scrollLeft + scrollEl.clientWidth / 2
68
+ const cy = scrollEl.scrollTop + scrollEl.clientHeight / 2
57
69
  return {
58
- x: Math.round(window.innerWidth / 2 - 120),
59
- y: Math.round(window.innerHeight / 2 - 80),
70
+ x: Math.round(cx / scale),
71
+ y: Math.round(cy / scale),
72
+ }
73
+ }
74
+
75
+ /** Fallback sizes for widget types without explicit width/height defaults. */
76
+ const WIDGET_FALLBACK_SIZES = {
77
+ 'sticky-note': { width: 180, height: 60 },
78
+ 'markdown': { width: 360, height: 200 },
79
+ 'prototype': { width: 800, height: 600 },
80
+ 'link-preview': { width: 320, height: 120 },
81
+ 'component': { width: 200, height: 150 },
82
+ }
83
+
84
+ /**
85
+ * Offset a position so the widget's center (not its top-left corner)
86
+ * lands on the given point.
87
+ */
88
+ function centerPositionForWidget(pos, type, props) {
89
+ const fallback = WIDGET_FALLBACK_SIZES[type] || { width: 200, height: 150 }
90
+ const w = props?.width ?? fallback.width
91
+ const h = props?.height ?? fallback.height
92
+ return {
93
+ x: Math.round(pos.x - w / 2),
94
+ y: Math.round(pos.y - h / 2),
60
95
  }
61
96
  }
62
97
 
@@ -65,17 +100,61 @@ function roundPosition(value) {
65
100
  }
66
101
 
67
102
  /** Renders a single JSON-defined widget by type lookup. */
68
- function WidgetRenderer({ widget, onUpdate }) {
103
+ function WidgetRenderer({ widget, onUpdate, widgetRef }) {
69
104
  const Component = getWidgetComponent(widget.type)
70
105
  if (!Component) {
71
106
  console.warn(`[canvas] Unknown widget type: ${widget.type}`)
72
107
  return null
73
108
  }
74
- return createElement(Component, {
75
- id: widget.id,
76
- props: widget.props,
77
- onUpdate,
78
- })
109
+ // Only pass ref to forwardRef-wrapped components (e.g. PrototypeEmbed)
110
+ const elementProps = { id: widget.id, props: widget.props, onUpdate }
111
+ if (Component.$$typeof === Symbol.for('react.forward_ref')) {
112
+ elementProps.ref = widgetRef
113
+ }
114
+ return createElement(Component, elementProps)
115
+ }
116
+
117
+ /**
118
+ * Wrapper for each JSON widget that holds its own ref for imperative actions.
119
+ * This allows WidgetChrome to dispatch actions to the widget via ref.
120
+ */
121
+ function ChromeWrappedWidget({
122
+ widget,
123
+ selected,
124
+ onSelect,
125
+ onDeselect,
126
+ onUpdate,
127
+ onRemove,
128
+ }) {
129
+ const widgetRef = useRef(null)
130
+ const features = getFeatures(widget.type)
131
+
132
+ const handleAction = useCallback((actionId) => {
133
+ if (actionId === 'delete') {
134
+ onRemove(widget.id)
135
+ }
136
+ }, [widget.id, onRemove])
137
+
138
+ return (
139
+ <WidgetChrome
140
+ widgetId={widget.id}
141
+ widgetType={widget.type}
142
+ features={features}
143
+ selected={selected}
144
+ widgetProps={widget.props}
145
+ widgetRef={widgetRef}
146
+ onSelect={onSelect}
147
+ onDeselect={onDeselect}
148
+ onAction={handleAction}
149
+ onUpdate={(updates) => onUpdate(widget.id, updates)}
150
+ >
151
+ <WidgetRenderer
152
+ widget={widget}
153
+ onUpdate={(updates) => onUpdate(widget.id, updates)}
154
+ widgetRef={widgetRef}
155
+ />
156
+ </WidgetChrome>
157
+ )
79
158
  }
80
159
 
81
160
  /**
@@ -154,6 +233,25 @@ export default function CanvasPage({ name }) {
154
233
  )
155
234
  }, [name])
156
235
 
236
+ const debouncedSourceSave = useRef(
237
+ debounce((canvasName, sources) => {
238
+ updateCanvas(canvasName, { sources }).catch((err) =>
239
+ console.error('[canvas] Failed to save sources:', err)
240
+ )
241
+ }, 2000)
242
+ ).current
243
+
244
+ const handleSourceUpdate = useCallback((exportName, updates) => {
245
+ setLocalSources((prev) => {
246
+ const current = Array.isArray(prev) ? prev : []
247
+ const next = current.some((s) => s?.export === exportName)
248
+ ? current.map((s) => (s?.export === exportName ? { ...s, ...updates } : s))
249
+ : [...current, { export: exportName, ...updates }]
250
+ debouncedSourceSave(name, next)
251
+ return next
252
+ })
253
+ }, [name, debouncedSourceSave])
254
+
157
255
  const handleItemDragEnd = useCallback((dragId, position) => {
158
256
  if (!dragId || !position) return
159
257
  const rounded = { x: Math.max(0, roundPosition(position.x)), y: Math.max(0, roundPosition(position.y)) }
@@ -189,6 +287,43 @@ export default function CanvasPage({ name }) {
189
287
  zoomRef.current = zoom
190
288
  }, [zoom])
191
289
 
290
+ /**
291
+ * Zoom to a new level, anchoring on an optional client-space point.
292
+ * When a cursor position is provided (e.g. from a wheel event), the
293
+ * canvas point under the cursor stays fixed. Otherwise falls back to
294
+ * the viewport center.
295
+ */
296
+ function applyZoom(newZoom, clientX, clientY) {
297
+ const el = scrollRef.current
298
+ const clampedZoom = Math.min(ZOOM_MAX, Math.max(ZOOM_MIN, newZoom))
299
+
300
+ if (!el) {
301
+ setZoom(clampedZoom)
302
+ return
303
+ }
304
+
305
+ const oldScale = zoomRef.current / 100
306
+ const newScale = clampedZoom / 100
307
+
308
+ // Anchor point in scroll-container space
309
+ const rect = el.getBoundingClientRect()
310
+ const useViewportCenter = clientX == null || clientY == null
311
+ const anchorX = useViewportCenter ? el.clientWidth / 2 : clientX - rect.left
312
+ const anchorY = useViewportCenter ? el.clientHeight / 2 : clientY - rect.top
313
+
314
+ // Anchor → canvas coordinate
315
+ const canvasX = (el.scrollLeft + anchorX) / oldScale
316
+ const canvasY = (el.scrollTop + anchorY) / oldScale
317
+
318
+ // Synchronous render so the DOM has the new transform before we adjust scroll
319
+ zoomRef.current = clampedZoom
320
+ flushSync(() => setZoom(clampedZoom))
321
+
322
+ // Scroll so the same canvas point stays under the anchor
323
+ el.scrollLeft = canvasX * newScale - anchorX
324
+ el.scrollTop = canvasY * newScale - anchorY
325
+ }
326
+
192
327
  // Signal canvas mount/unmount to CoreUIBar
193
328
  useEffect(() => {
194
329
  window[CANVAS_BRIDGE_STATE_KEY] = { active: true, name, zoom: zoomRef.current }
@@ -213,7 +348,8 @@ export default function CanvasPage({ name }) {
213
348
  // Add a widget by type — used by CanvasControls and CoreUIBar event
214
349
  const addWidget = useCallback(async (type) => {
215
350
  const defaultProps = schemas[type] ? getDefaults(schemas[type]) : {}
216
- const pos = getViewportCenter()
351
+ const center = getViewportCenter(scrollRef.current, zoomRef.current / 100)
352
+ const pos = centerPositionForWidget(center, type, defaultProps)
217
353
  try {
218
354
  const result = await addWidgetApi(name, {
219
355
  type,
@@ -242,7 +378,7 @@ export default function CanvasPage({ name }) {
242
378
  function handleZoom(e) {
243
379
  const { zoom: newZoom } = e.detail
244
380
  if (typeof newZoom === 'number') {
245
- setZoom(Math.min(ZOOM_MAX, Math.max(ZOOM_MIN, newZoom)))
381
+ applyZoom(newZoom)
246
382
  }
247
383
  }
248
384
  document.addEventListener('storyboard:canvas:set-zoom', handleZoom)
@@ -296,7 +432,34 @@ export default function CanvasPage({ name }) {
296
432
 
297
433
  // Paste handler — same-origin URLs become prototypes, other URLs become link previews, text becomes markdown
298
434
  useEffect(() => {
299
- const baseUrl = window.location.origin + (import.meta.env?.BASE_URL || '/').replace(/\/$/, '')
435
+ const origin = window.location.origin
436
+ const basePath = (import.meta.env?.BASE_URL || '/').replace(/\/$/, '')
437
+ const baseUrl = origin + basePath
438
+
439
+ // Check if a URL is same-origin, accounting for branch-deploy prefixes.
440
+ // e.g. https://site.com/branch--my-feature/Proto and https://site.com/storyboard/Proto
441
+ // are both same-origin prototype URLs.
442
+ function isSameOriginPrototype(url) {
443
+ if (!url.startsWith(origin)) return false
444
+ if (url.startsWith(baseUrl)) return true
445
+ // Match branch deploy URLs: origin + /branch--*/...
446
+ const pathAfterOrigin = url.slice(origin.length)
447
+ return BRANCH_PREFIX_RE.test(pathAfterOrigin)
448
+ }
449
+
450
+ // Strip the base path (or any branch prefix) from a pathname to get a portable src.
451
+ function extractPrototypeSrc(pathname) {
452
+ // Strip current base path
453
+ if (basePath && pathname.startsWith(basePath)) {
454
+ return pathname.slice(basePath.length) || '/'
455
+ }
456
+ // Strip branch prefix: /branch--name/rest → /rest
457
+ const branchMatch = pathname.match(BRANCH_PREFIX_RE)
458
+ if (branchMatch) {
459
+ return pathname.slice(branchMatch[0].length) || '/'
460
+ }
461
+ return pathname
462
+ }
300
463
 
301
464
  async function handlePaste(e) {
302
465
  const tag = e.target.tagName
@@ -310,11 +473,9 @@ export default function CanvasPage({ name }) {
310
473
  let type, props
311
474
  try {
312
475
  const parsed = new URL(text)
313
- if (text.startsWith(baseUrl)) {
314
- // Same-origin URL → prototype embed with the path portion
476
+ if (isSameOriginPrototype(text)) {
315
477
  const pathPortion = parsed.pathname + parsed.search + parsed.hash
316
- const basePath = (import.meta.env?.BASE_URL || '/').replace(/\/$/, '')
317
- const src = basePath ? pathPortion.replace(new RegExp(`^${basePath}`), '') : pathPortion
478
+ const src = extractPrototypeSrc(pathPortion)
318
479
  type = 'prototype'
319
480
  props = { src: src || '/', label: '', width: 800, height: 600 }
320
481
  } else {
@@ -326,7 +487,8 @@ export default function CanvasPage({ name }) {
326
487
  props = { content: text }
327
488
  }
328
489
 
329
- const pos = getViewportCenter()
490
+ const center = getViewportCenter(scrollRef.current, zoomRef.current / 100)
491
+ const pos = centerPositionForWidget(center, type, props)
330
492
  try {
331
493
  const result = await addWidgetApi(name, {
332
494
  type,
@@ -356,7 +518,7 @@ export default function CanvasPage({ name }) {
356
518
  const step = Math.trunc(zoomAccum.current)
357
519
  if (step === 0) return
358
520
  zoomAccum.current -= step
359
- setZoom((z) => Math.min(ZOOM_MAX, Math.max(ZOOM_MIN, z + step)))
521
+ applyZoom(zoomRef.current + step, e.clientX, e.clientY)
360
522
  }
361
523
  document.addEventListener('wheel', handleWheel, { passive: false })
362
524
  return () => document.removeEventListener('wheel', handleWheel)
@@ -453,32 +615,51 @@ export default function CanvasPage({ name }) {
453
615
  // Merge JSX-sourced widgets (from .canvas.jsx) and JSON widgets
454
616
  const allChildren = []
455
617
 
456
- const sourcePositionByExport = Object.fromEntries(
618
+ const sourceDataByExport = Object.fromEntries(
457
619
  (localSources || [])
458
620
  .filter((source) => source?.export)
459
- .map((source) => [source.export, source.position || { x: 0, y: 0 }])
621
+ .map((source) => [source.export, source])
460
622
  )
461
623
 
462
- // 1. JSX-sourced component widgets
624
+ // 1. JSX-sourced component widgets (wrapped in WidgetChrome, not deletable)
625
+ const componentFeatures = getFeatures('component')
463
626
  if (jsxExports) {
464
627
  for (const [exportName, Component] of Object.entries(jsxExports)) {
465
- const sourcePosition = sourcePositionByExport[exportName] || { x: 0, y: 0 }
628
+ const sourceData = sourceDataByExport[exportName] || {}
629
+ const sourcePosition = sourceData.position || { x: 0, y: 0 }
466
630
  allChildren.push(
467
631
  <div
468
632
  key={`jsx-${exportName}`}
469
633
  id={`jsx-${exportName}`}
470
634
  data-tc-x={sourcePosition.x}
471
635
  data-tc-y={sourcePosition.y}
636
+ data-tc-handle=".tc-drag-handle"
472
637
  {...canvasPrimerAttrs}
473
638
  style={canvasThemeVars}
639
+ onClick={(e) => {
640
+ e.stopPropagation()
641
+ setSelectedWidgetId(`jsx-${exportName}`)
642
+ }}
474
643
  >
475
- <ComponentWidget component={Component} />
644
+ <WidgetChrome
645
+ features={componentFeatures}
646
+ selected={selectedWidgetId === `jsx-${exportName}`}
647
+ onSelect={() => setSelectedWidgetId(`jsx-${exportName}`)}
648
+ onDeselect={() => setSelectedWidgetId(null)}
649
+ >
650
+ <ComponentWidget
651
+ component={Component}
652
+ width={sourceData.width}
653
+ height={sourceData.height}
654
+ onUpdate={(updates) => handleSourceUpdate(exportName, updates)}
655
+ />
656
+ </WidgetChrome>
476
657
  </div>
477
658
  )
478
659
  }
479
660
  }
480
661
 
481
- // 2. JSON-defined mutable widgets (selectable)
662
+ // 2. JSON-defined mutable widgets (selectable, wrapped in WidgetChrome)
482
663
  for (const widget of (localWidgets ?? [])) {
483
664
  allChildren.push(
484
665
  <div
@@ -486,17 +667,24 @@ export default function CanvasPage({ name }) {
486
667
  id={widget.id}
487
668
  data-tc-x={widget?.position?.x ?? 0}
488
669
  data-tc-y={widget?.position?.y ?? 0}
670
+ data-tc-handle=".tc-drag-handle"
489
671
  {...canvasPrimerAttrs}
490
672
  style={canvasThemeVars}
491
673
  onClick={(e) => {
492
674
  e.stopPropagation()
493
675
  setSelectedWidgetId(widget.id)
494
676
  }}
495
- className={selectedWidgetId === widget.id ? styles.selected : undefined}
496
677
  >
497
- <WidgetRenderer
678
+ <ChromeWrappedWidget
498
679
  widget={widget}
499
- onUpdate={(updates) => handleWidgetUpdate(widget.id, updates)}
680
+ selected={selectedWidgetId === widget.id}
681
+ onSelect={() => setSelectedWidgetId(widget.id)}
682
+ onDeselect={() => setSelectedWidgetId(null)}
683
+ onUpdate={handleWidgetUpdate}
684
+ onRemove={(id) => {
685
+ handleWidgetRemove(id)
686
+ setSelectedWidgetId(null)
687
+ }}
500
688
  />
501
689
  </div>
502
690
  )
@@ -32,11 +32,7 @@
32
32
  min-height: 100%;
33
33
  }
34
34
 
35
- .selected {
36
- outline: 2px solid var(--bgColor-accent-emphasis, #2f81f7);
37
- outline-offset: 2px;
38
- border-radius: 4px;
39
- }
35
+ /* Selection outline is now handled by WidgetChrome.module.css (.widgetSlotSelected) */
40
36
 
41
37
  .canvasTitle {
42
38
  position: fixed;
@@ -1,13 +1,10 @@
1
1
  import { useState } from 'react'
2
2
  import { addWidget as addWidgetApi } from './canvasApi.js'
3
3
  import { schemas, getDefaults } from './widgets/widgetProps.js'
4
+ import { getMenuWidgetTypes } from './widgets/widgetConfig.js'
4
5
  import styles from './CanvasToolbar.module.css'
5
6
 
6
- const WIDGET_TYPES = [
7
- { type: 'sticky-note', label: 'Sticky Note', icon: '📝' },
8
- { type: 'markdown', label: 'Markdown', icon: '📄' },
9
- { type: 'prototype', label: 'Prototype embed', icon: '🖥️' },
10
- ]
7
+ const WIDGET_TYPES = getMenuWidgetTypes()
11
8
 
12
9
  /**
13
10
  * Floating toolbar for adding widgets to a canvas.
@@ -1,15 +1,63 @@
1
+ import { useRef, useCallback, useState, useEffect } from 'react'
1
2
  import WidgetWrapper from './WidgetWrapper.jsx'
3
+ import ResizeHandle from './ResizeHandle.jsx'
4
+ import styles from './ComponentWidget.module.css'
2
5
 
3
6
  /**
4
7
  * Renders a live JSX export from a .canvas.jsx companion file.
5
- * Content is read-only (re-renders on HMR), only position is mutable.
8
+ * Content is read-only (re-renders on HMR), only position and size are mutable.
9
+ * Cannot be deleted from canvas — only removed from source code.
10
+ *
11
+ * Double-click the overlay to enter interactive mode (dropdowns, buttons work).
12
+ * Click outside to exit interactive mode.
6
13
  */
7
- export default function ComponentWidget({ component: Component }) {
14
+ export default function ComponentWidget({ component: Component, width, height, onUpdate }) {
15
+ const containerRef = useRef(null)
16
+ const [interactive, setInteractive] = useState(false)
17
+
18
+ const handleResize = useCallback((w, h) => {
19
+ onUpdate?.({ width: w, height: h })
20
+ }, [onUpdate])
21
+
22
+ const enterInteractive = useCallback(() => setInteractive(true), [])
23
+
24
+ // Exit interactive mode when clicking outside the component
25
+ useEffect(() => {
26
+ if (!interactive) return
27
+ function handlePointerDown(e) {
28
+ if (containerRef.current && !containerRef.current.contains(e.target)) {
29
+ setInteractive(false)
30
+ }
31
+ }
32
+ document.addEventListener('pointerdown', handlePointerDown)
33
+ return () => document.removeEventListener('pointerdown', handlePointerDown)
34
+ }, [interactive])
35
+
8
36
  if (!Component) return null
9
37
 
38
+ const sizeStyle = {}
39
+ if (typeof width === 'number') sizeStyle.width = `${width}px`
40
+ if (typeof height === 'number') sizeStyle.height = `${height}px`
41
+
10
42
  return (
11
43
  <WidgetWrapper>
12
- <Component />
44
+ <div ref={containerRef} className={styles.container} style={sizeStyle}>
45
+ <div className={styles.content}>
46
+ <Component />
47
+ </div>
48
+ {!interactive && (
49
+ <div
50
+ className={styles.interactOverlay}
51
+ onDoubleClick={enterInteractive}
52
+ />
53
+ )}
54
+ <ResizeHandle
55
+ targetRef={containerRef}
56
+ minWidth={100}
57
+ minHeight={60}
58
+ onResize={handleResize}
59
+ />
60
+ </div>
13
61
  </WidgetWrapper>
14
62
  )
15
63
  }
@@ -0,0 +1,18 @@
1
+ .container {
2
+ position: relative;
3
+ overflow: auto;
4
+ min-width: 100px;
5
+ min-height: 60px;
6
+ }
7
+
8
+ .content {
9
+ width: 100%;
10
+ height: 100%;
11
+ }
12
+
13
+ .interactOverlay {
14
+ position: absolute;
15
+ inset: 0;
16
+ z-index: 1;
17
+ cursor: default;
18
+ }
@@ -20,6 +20,7 @@
20
20
 
21
21
  .preview :global(*) {
22
22
  color: inherit;
23
+ pointer-events: none;
23
24
  }
24
25
 
25
26
  .preview h1 {
@@ -1,4 +1,4 @@
1
- import { useState, useRef, useEffect, useCallback, useMemo } from 'react'
1
+ import { useState, useRef, useEffect, useCallback, useMemo, forwardRef, useImperativeHandle } from 'react'
2
2
  import { buildPrototypeIndex } from '@dfosco/storyboard-core'
3
3
  import WidgetWrapper from './WidgetWrapper.jsx'
4
4
  import { readProp, prototypeEmbedSchema } from './widgetProps.js'
@@ -28,7 +28,7 @@ function resolveCanvasThemeFromStorage() {
28
28
  return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
29
29
  }
30
30
 
31
- export default function PrototypeEmbed({ props, onUpdate }) {
31
+ export default forwardRef(function PrototypeEmbed({ props, onUpdate }, ref) {
32
32
  const src = readProp(props, 'src', prototypeEmbedSchema)
33
33
  const width = readProp(props, 'width', prototypeEmbedSchema)
34
34
  const height = readProp(props, 'height', prototypeEmbedSchema)
@@ -40,9 +40,11 @@ export default function PrototypeEmbed({ props, onUpdate }) {
40
40
  const rawSrc = useMemo(() => {
41
41
  if (!src) return ''
42
42
  if (/^https?:\/\//.test(src)) return src
43
- if (baseSegment && src.startsWith(basePath)) return src
44
- if (baseSegment && src.startsWith(baseSegment)) return `/${src}`
45
- return `${basePath}${src}`
43
+ // Strip stale branch prefixes from stored src (e.g. /branch--old-feat/Page)
44
+ const cleaned = src.replace(/^\/branch--[^/]+/, '')
45
+ if (baseSegment && cleaned.startsWith(basePath)) return cleaned
46
+ if (baseSegment && cleaned.startsWith(baseSegment)) return `/${cleaned}`
47
+ return `${basePath}${cleaned}`
46
48
  }, [src, basePath, baseSegment])
47
49
 
48
50
  const scale = zoom / 100
@@ -179,6 +181,21 @@ export default function PrototypeEmbed({ props, onUpdate }) {
179
181
 
180
182
  const enterInteractive = useCallback(() => setInteractive(true), [])
181
183
 
184
+ // Expose imperative action handlers for WidgetChrome
185
+ useImperativeHandle(ref, () => ({
186
+ handleAction(actionId) {
187
+ if (actionId === 'edit') {
188
+ setEditing(true)
189
+ } else if (actionId === 'zoom-in') {
190
+ const step = zoom < 75 ? 5 : 25
191
+ onUpdate?.({ zoom: Math.min(200, zoom + step) })
192
+ } else if (actionId === 'zoom-out') {
193
+ const step = zoom <= 75 ? 5 : 25
194
+ onUpdate?.({ zoom: Math.max(25, zoom - step) })
195
+ }
196
+ },
197
+ }), [zoom, onUpdate])
198
+
182
199
  function handlePickRoute(route) {
183
200
  onUpdate?.({ src: route })
184
201
  setEditing(false)
@@ -320,45 +337,6 @@ export default function PrototypeEmbed({ props, onUpdate }) {
320
337
  <p>Double-click to set prototype URL</p>
321
338
  </div>
322
339
  )}
323
- {iframeSrc && !editing && (
324
- <button
325
- className={styles.editBtn}
326
- onClick={(e) => { e.stopPropagation(); setEditing(true) }}
327
- onMouseDown={(e) => e.stopPropagation()}
328
- onPointerDown={(e) => e.stopPropagation()}
329
- title="Edit URL"
330
- aria-label="Edit prototype URL"
331
- >
332
- <svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true"><path d="M11.013 1.427a1.75 1.75 0 0 1 2.474 0l1.086 1.086a1.75 1.75 0 0 1 0 2.474l-8.61 8.61c-.21.21-.47.364-.756.445l-3.251.93a.75.75 0 0 1-.927-.928l.929-3.25c.081-.286.235-.547.445-.758l8.61-8.61Zm.176 4.823L9.75 4.81l-6.286 6.287a.253.253 0 0 0-.064.108l-.558 1.953 1.953-.558a.253.253 0 0 0 .108-.064Zm1.238-3.763a.25.25 0 0 0-.354 0L10.811 3.75l1.439 1.44 1.263-1.263a.25.25 0 0 0 0-.354Z"/></svg>
333
- </button>
334
- )}
335
- {iframeSrc && !editing && (
336
- <div
337
- className={styles.zoomBar}
338
- onMouseDown={(e) => e.stopPropagation()}
339
- onPointerDown={(e) => e.stopPropagation()}
340
- >
341
- <button
342
- className={styles.zoomBtn}
343
- onClick={() => {
344
- const step = zoom <= 75 ? 5 : 25
345
- onUpdate?.({ zoom: Math.max(25, zoom - step) })
346
- }}
347
- disabled={zoom <= 25}
348
- aria-label="Zoom out"
349
- >−</button>
350
- <span className={styles.zoomLabel}>{zoom}%</span>
351
- <button
352
- className={styles.zoomBtn}
353
- onClick={() => {
354
- const step = zoom < 75 ? 5 : 25
355
- onUpdate?.({ zoom: Math.min(200, zoom + step) })
356
- }}
357
- disabled={zoom >= 200}
358
- aria-label="Zoom in"
359
- >+</button>
360
- </div>
361
- )}
362
340
  </div>
363
341
  <div
364
342
  className={styles.resizeHandle}
@@ -385,4 +363,4 @@ export default function PrototypeEmbed({ props, onUpdate }) {
385
363
  />
386
364
  </WidgetWrapper>
387
365
  )
388
- }
366
+ })
@@ -37,10 +37,6 @@ export default function StickyNote({ props, onUpdate }) {
37
37
  onUpdate?.({ text: e.target.value })
38
38
  }, [onUpdate])
39
39
 
40
- const handleColorChange = useCallback((newColor) => {
41
- onUpdate?.({ color: newColor })
42
- }, [onUpdate])
43
-
44
40
  return (
45
41
  <div className={styles.container}>
46
42
  <article
@@ -86,33 +82,6 @@ export default function StickyNote({ props, onUpdate }) {
86
82
  onResize={handleResize}
87
83
  />
88
84
  </article>
89
-
90
- {/* Color picker — dot trigger below the sticky */}
91
- <div
92
- className={styles.pickerArea}
93
- onMouseDown={(e) => e.stopPropagation()}
94
- onPointerDown={(e) => e.stopPropagation()}
95
- >
96
- <span
97
- className={styles.pickerDot}
98
- style={{ background: palette.dot }}
99
- />
100
- <div className={styles.pickerPopup}>
101
- {Object.entries(COLORS).map(([colorName, c]) => (
102
- <button
103
- key={colorName}
104
- className={`${styles.colorDot} ${colorName === color ? styles.active : ''}`}
105
- style={{ background: c.bg, borderColor: c.border }}
106
- onClick={(e) => {
107
- e.stopPropagation()
108
- handleColorChange(colorName)
109
- }}
110
- title={colorName}
111
- aria-label={`Set color to ${colorName}`}
112
- />
113
- ))}
114
- </div>
115
- </div>
116
85
  </div>
117
86
  )
118
87
  }
@@ -60,73 +60,3 @@
60
60
  :global([data-sb-canvas-theme^='dark']) .textarea {
61
61
  color: color-mix(in srgb, var(--sticky-bg) 26%, #f0f6fc 74%);
62
62
  }
63
-
64
- /* Color picker area — sits below the sticky */
65
-
66
- .pickerArea {
67
- display: flex;
68
- justify-content: center;
69
- padding-top: 6px;
70
- position: relative;
71
- }
72
-
73
- .pickerDot {
74
- width: 8px;
75
- height: 8px;
76
- border-radius: 50%;
77
- opacity: 0.5;
78
- transition: opacity 150ms;
79
- cursor: pointer;
80
- }
81
-
82
- .pickerPopup {
83
- position: absolute;
84
- top: 4px;
85
- display: flex;
86
- gap: 5px;
87
- padding: 6px 10px;
88
- background: var(--bgColor-default, #ffffff);
89
- border-radius: 20px;
90
- box-shadow:
91
- 0 0 0 1px rgba(0, 0, 0, 0.08),
92
- 0 4px 12px rgba(0, 0, 0, 0.12);
93
- opacity: 0;
94
- pointer-events: none;
95
- transition: opacity 150ms;
96
- z-index: 10;
97
- }
98
-
99
- :global([data-sb-canvas-theme^='dark']) .pickerPopup {
100
- background: var(--bgColor-muted, #161b22);
101
- box-shadow:
102
- 0 0 0 1px rgba(255, 255, 255, 0.08),
103
- 0 4px 12px rgba(0, 0, 0, 0.45);
104
- }
105
-
106
- .pickerArea:hover .pickerDot {
107
- opacity: 0;
108
- }
109
-
110
- .pickerArea:hover .pickerPopup {
111
- opacity: 1;
112
- pointer-events: auto;
113
- }
114
-
115
- .colorDot {
116
- all: unset;
117
- width: 20px;
118
- height: 20px;
119
- border-radius: 50%;
120
- border: 2px solid transparent;
121
- cursor: pointer;
122
- transition: transform 100ms;
123
- }
124
-
125
- .colorDot:hover {
126
- transform: scale(1.15);
127
- }
128
-
129
- .colorDot.active {
130
- border-color: var(--sticky-border);
131
- box-shadow: 0 0 0 1px var(--sticky-border);
132
- }
@@ -0,0 +1,244 @@
1
+ import { useState, useCallback, useRef } from 'react'
2
+ import styles from './WidgetChrome.module.css'
3
+
4
+ const STICKY_NOTE_COLORS = {
5
+ yellow: { bg: '#fff8c5', border: '#d4a72c', dot: '#e8c846' },
6
+ blue: { bg: '#ddf4ff', border: '#54aeff', dot: '#74b9ff' },
7
+ green: { bg: '#dafbe1', border: '#4ac26b', dot: '#6dd58c' },
8
+ pink: { bg: '#ffebe9', border: '#ff8182', dot: '#ff9a9e' },
9
+ purple: { bg: '#fbefff', border: '#c297ff', dot: '#d4a8ff' },
10
+ orange: { bg: '#fff1e5', border: '#d18616', dot: '#e8a844' },
11
+ }
12
+
13
+ function DeleteIcon() {
14
+ return (
15
+ <svg width="12" height="12" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true">
16
+ <path d="M11 1.75V3h2.25a.75.75 0 0 1 0 1.5H2.75a.75.75 0 0 1 0-1.5H5V1.75C5 .784 5.784 0 6.75 0h2.5C10.216 0 11 .784 11 1.75ZM4.496 6.675l.66 6.6a.25.25 0 0 0 .249.225h5.19a.25.25 0 0 0 .249-.225l.66-6.6a.75.75 0 0 1 1.492.15l-.66 6.6A1.748 1.748 0 0 1 10.595 15h-5.19a1.75 1.75 0 0 1-1.741-1.575l-.66-6.6a.75.75 0 1 1 1.492-.15ZM6.5 1.75V3h3V1.75a.25.25 0 0 0-.25-.25h-2.5a.25.25 0 0 0-.25.25Z" />
17
+ </svg>
18
+ )
19
+ }
20
+
21
+ function ZoomInIcon() {
22
+ return (
23
+ <svg width="12" height="12" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true">
24
+ <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" />
25
+ </svg>
26
+ )
27
+ }
28
+
29
+ function ZoomOutIcon() {
30
+ return (
31
+ <svg width="12" height="12" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true">
32
+ <path d="M2.75 7.25h10.5a.75.75 0 0 1 0 1.5H2.75a.75.75 0 0 1 0-1.5Z" />
33
+ </svg>
34
+ )
35
+ }
36
+
37
+ function EditIcon() {
38
+ return (
39
+ <svg width="12" height="12" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true">
40
+ <path d="M11.013 1.427a1.75 1.75 0 0 1 2.474 0l1.086 1.086a1.75 1.75 0 0 1 0 2.474l-8.61 8.61c-.21.21-.47.364-.756.445l-3.251.93a.75.75 0 0 1-.927-.928l.929-3.25c.081-.286.235-.547.445-.758l8.61-8.61Zm.176 4.823L9.75 4.81l-6.286 6.287a.253.253 0 0 0-.064.108l-.558 1.953 1.953-.558a.253.253 0 0 0 .108-.064Zm1.238-3.763a.25.25 0 0 0-.354 0L10.811 3.75l1.439 1.44 1.263-1.263a.25.25 0 0 0 0-.354Z" />
41
+ </svg>
42
+ )
43
+ }
44
+
45
+ const ACTION_ICONS = {
46
+ 'delete': DeleteIcon,
47
+ 'zoom-in': ZoomInIcon,
48
+ 'zoom-out': ZoomOutIcon,
49
+ 'edit': EditIcon,
50
+ }
51
+
52
+ const ACTION_LABELS = {
53
+ 'delete': 'Delete widget',
54
+ 'zoom-in': 'Zoom in',
55
+ 'zoom-out': 'Zoom out',
56
+ 'edit': 'Edit',
57
+ }
58
+
59
+ /**
60
+ * ColorPicker feature button — shows a dot that reveals color options on hover.
61
+ */
62
+ function ColorPickerFeature({ currentColor, options, onColorChange }) {
63
+ const palette = STICKY_NOTE_COLORS[currentColor] ?? STICKY_NOTE_COLORS.yellow
64
+
65
+ return (
66
+ <div
67
+ className={styles.colorPickerWrapper}
68
+ onMouseDown={(e) => e.stopPropagation()}
69
+ onPointerDown={(e) => e.stopPropagation()}
70
+ >
71
+ <button
72
+ className={styles.featureBtn}
73
+ style={{ background: palette.dot }}
74
+ aria-label="Change color"
75
+ title="Change color"
76
+ >
77
+ <span className={styles.colorDotInner} style={{ background: palette.dot }} />
78
+ </button>
79
+ <div className={styles.colorPopup}>
80
+ {(options || Object.keys(STICKY_NOTE_COLORS)).map((colorName) => {
81
+ const c = STICKY_NOTE_COLORS[colorName]
82
+ if (!c) return null
83
+ return (
84
+ <button
85
+ key={colorName}
86
+ className={`${styles.colorOption} ${colorName === currentColor ? styles.colorOptionActive : ''}`}
87
+ style={{ background: c.bg, borderColor: c.border }}
88
+ onClick={(e) => {
89
+ e.stopPropagation()
90
+ onColorChange(colorName)
91
+ }}
92
+ title={colorName}
93
+ aria-label={`Set color to ${colorName}`}
94
+ />
95
+ )
96
+ })}
97
+ </div>
98
+ </div>
99
+ )
100
+ }
101
+
102
+ /**
103
+ * WidgetChrome — universal hover toolbar rendered below every canvas widget.
104
+ *
105
+ * Provides:
106
+ * - A trigger dot (visible at rest) that transitions to a toolbar on hover
107
+ * - Feature buttons (left) driven by widget config
108
+ * - A select handle (right) for selection toggling
109
+ *
110
+ * Widget components can expose imperative action handlers via a ref:
111
+ * useImperativeHandle(ref, () => ({ handleAction(actionId) { ... } }))
112
+ * WidgetChrome will call widgetRef.current.handleAction(actionId) for
113
+ * non-standard actions (anything other than 'delete').
114
+ */
115
+ export default function WidgetChrome({
116
+ features = [],
117
+ selected = false,
118
+ widgetProps,
119
+ widgetRef,
120
+ onSelect,
121
+ onDeselect,
122
+ onAction,
123
+ onUpdate,
124
+ children,
125
+ }) {
126
+ const [hovered, setHovered] = useState(false)
127
+ const leaveTimer = useRef(null)
128
+ const pointerStartPos = useRef(null)
129
+
130
+ const handleMouseEnter = useCallback(() => {
131
+ clearTimeout(leaveTimer.current)
132
+ setHovered(true)
133
+ }, [])
134
+
135
+ const handleMouseLeave = useCallback(() => {
136
+ leaveTimer.current = setTimeout(() => setHovered(false), 80)
137
+ }, [])
138
+
139
+ // Track pointer position on the handle to distinguish click from drag.
140
+ const handleHandlePointerDown = useCallback((e) => {
141
+ pointerStartPos.current = { x: e.clientX, y: e.clientY }
142
+ }, [])
143
+
144
+ const handleHandlePointerUp = useCallback((e) => {
145
+ if (!pointerStartPos.current) return
146
+ const start = pointerStartPos.current
147
+ pointerStartPos.current = null
148
+ // Only toggle selection if the pointer stayed close (click, not drag)
149
+ const dist = Math.hypot(e.clientX - start.x, e.clientY - start.y)
150
+ if (dist > 10) return
151
+ e.stopPropagation()
152
+ if (selected) {
153
+ onDeselect?.()
154
+ } else {
155
+ onSelect?.()
156
+ }
157
+ }, [selected, onSelect, onDeselect])
158
+
159
+ const handleActionClick = useCallback((actionId, e) => {
160
+ e.stopPropagation()
161
+ // Standard actions go through onAction (handled by CanvasPage)
162
+ if (actionId === 'delete') {
163
+ onAction?.(actionId)
164
+ return
165
+ }
166
+ // Widget-specific actions go through the widget's imperative ref
167
+ if (widgetRef?.current?.handleAction) {
168
+ widgetRef.current.handleAction(actionId)
169
+ return
170
+ }
171
+ // Fallback to generic handler
172
+ onAction?.(actionId)
173
+ }, [onAction, widgetRef])
174
+
175
+ const handleColorChange = useCallback((color) => {
176
+ onUpdate?.({ color })
177
+ }, [onUpdate])
178
+
179
+ const showToolbar = hovered || selected
180
+
181
+ return (
182
+ <div
183
+ className={styles.chromeContainer}
184
+ onMouseEnter={handleMouseEnter}
185
+ onMouseLeave={handleMouseLeave}
186
+ >
187
+ <div className={`${styles.widgetSlot} ${selected ? styles.widgetSlotSelected : ''}`}>
188
+ {children}
189
+ </div>
190
+ <div
191
+ className={styles.toolbar}
192
+ >
193
+ {/* Trigger dot — visible at rest */}
194
+ <span
195
+ className={`${styles.triggerDot} ${showToolbar ? styles.triggerDotHidden : ''}`}
196
+ />
197
+
198
+ {/* Toolbar content — visible on hover */}
199
+ <div className={`${styles.toolbarContent} ${showToolbar ? styles.toolbarContentVisible : ''}`}>
200
+ <div className={styles.featureButtons}>
201
+ {features.map((feature) => {
202
+ if (feature.type === 'color-picker') {
203
+ return (
204
+ <ColorPickerFeature
205
+ key={feature.id}
206
+ currentColor={widgetProps?.[feature.prop] || 'yellow'}
207
+ options={feature.options}
208
+ onColorChange={handleColorChange}
209
+ />
210
+ )
211
+ }
212
+
213
+ if (feature.type === 'action') {
214
+ const Icon = ACTION_ICONS[feature.action]
215
+ return (
216
+ <button
217
+ key={feature.id}
218
+ className={styles.featureBtn}
219
+ onClick={(e) => handleActionClick(feature.action, e)}
220
+ title={ACTION_LABELS[feature.action] || feature.action}
221
+ aria-label={ACTION_LABELS[feature.action] || feature.action}
222
+ >
223
+ {Icon ? <Icon /> : feature.action}
224
+ </button>
225
+ )
226
+ }
227
+
228
+ return null
229
+ })}
230
+ </div>
231
+
232
+ <button
233
+ className={`tc-drag-handle ${styles.selectHandle} ${selected ? styles.selectHandleActive : ''}`}
234
+ onPointerDown={handleHandlePointerDown}
235
+ onPointerUp={handleHandlePointerUp}
236
+ title={selected ? 'Deselect' : 'Select'}
237
+ aria-label={selected ? 'Deselect widget' : 'Select widget'}
238
+ aria-pressed={selected}
239
+ />
240
+ </div>
241
+ </div>
242
+ </div>
243
+ )
244
+ }
@@ -0,0 +1,209 @@
1
+ /* WidgetChrome — universal hover toolbar for canvas widgets */
2
+
3
+ .chromeContainer {
4
+ position: relative;
5
+ }
6
+
7
+ /* Widget slot — contains the actual widget; selection outline targets this */
8
+ .widgetSlot {
9
+ position: relative;
10
+ border-radius: 4px;
11
+ }
12
+
13
+ .widgetSlotSelected {
14
+ outline: 2px solid var(--bgColor-accent-emphasis, #2f81f7);
15
+ outline-offset: 2px;
16
+ border-radius: 4px;
17
+ }
18
+
19
+ /* Toolbar — absolutely positioned below the widget so it doesn't affect
20
+ the draggable box dimensions (tiny-canvas measures children for drag). */
21
+ .toolbar {
22
+ display: flex;
23
+ align-items: center;
24
+ justify-content: center;
25
+ height: 28px;
26
+ position: absolute;
27
+ left: 0;
28
+ right: 0;
29
+ top: calc(100% + 4px);
30
+ }
31
+
32
+ /* Trigger dot — centered, visible at rest */
33
+ .triggerDot {
34
+ width: 6px;
35
+ height: 6px;
36
+ border-radius: 50%;
37
+ background: var(--borderColor-muted, #d0d7de);
38
+ opacity: 0.5;
39
+ transition: opacity 120ms;
40
+ position: absolute;
41
+ left: 50%;
42
+ top: 50%;
43
+ transform: translate(-50%, -50%);
44
+ }
45
+
46
+ :global([data-sb-canvas-theme^='dark']) .triggerDot {
47
+ background: var(--borderColor-muted, #373e47);
48
+ opacity: 0.6;
49
+ }
50
+
51
+ .triggerDotHidden {
52
+ opacity: 0;
53
+ pointer-events: none;
54
+ }
55
+
56
+ /* Toolbar content — feature buttons + select handle */
57
+ .toolbarContent {
58
+ display: flex;
59
+ align-items: center;
60
+ justify-content: space-between;
61
+ width: 100%;
62
+ opacity: 0;
63
+ pointer-events: none;
64
+ transition: opacity 120ms;
65
+ }
66
+
67
+ .toolbarContentVisible {
68
+ opacity: 1;
69
+ pointer-events: auto;
70
+ }
71
+
72
+ /* Feature buttons — left-aligned group */
73
+ .featureButtons {
74
+ display: flex;
75
+ align-items: center;
76
+ gap: 3px;
77
+ }
78
+
79
+ /* Individual feature button */
80
+ .featureBtn {
81
+ all: unset;
82
+ cursor: pointer;
83
+ display: flex;
84
+ align-items: center;
85
+ justify-content: center;
86
+ width: 24px;
87
+ height: 24px;
88
+ border-radius: 12px;
89
+ border: 1.6px solid var(--borderColor-muted, #d0d7de);
90
+ background: var(--bgColor-default, #ffffff);
91
+ color: var(--fgColor-muted, #656d76);
92
+ font-size: 12px;
93
+ transition: background 100ms, color 100ms, border-color 100ms;
94
+ }
95
+
96
+ :global([data-sb-canvas-theme^='dark']) .featureBtn {
97
+ background: var(--bgColor-muted, #161b22);
98
+ border-color: var(--borderColor-muted, #373e47);
99
+ color: var(--fgColor-muted, #8b949e);
100
+ }
101
+
102
+ .featureBtn:hover {
103
+ background: var(--bgColor-neutral-muted, #eaeef2);
104
+ color: var(--fgColor-default, #1f2328);
105
+ border-color: var(--borderColor-default, #d0d7de);
106
+ }
107
+
108
+ :global([data-sb-canvas-theme^='dark']) .featureBtn:hover {
109
+ background: var(--bgColor-neutral-muted, #272c33);
110
+ color: var(--fgColor-default, #e6edf3);
111
+ border-color: var(--borderColor-default, #484f58);
112
+ }
113
+
114
+ /* Select handle — right-aligned rounded rect */
115
+ .selectHandle {
116
+ all: unset;
117
+ cursor: grab;
118
+ width: 18px;
119
+ height: 12px;
120
+ border-radius: 4px;
121
+ border: 1.6px solid var(--borderColor-muted, #d0d7de);
122
+ background: var(--bgColor-default, #ffffff);
123
+ transition: background 100ms, border-color 100ms;
124
+ flex-shrink: 0;
125
+ }
126
+
127
+ :global([data-sb-canvas-theme^='dark']) .selectHandle {
128
+ background: var(--bgColor-muted, #161b22);
129
+ border-color: var(--borderColor-muted, #373e47);
130
+ }
131
+
132
+ .selectHandle:hover {
133
+ border-color: var(--bgColor-accent-emphasis, #2f81f7);
134
+ }
135
+
136
+ .selectHandleActive {
137
+ background: var(--bgColor-accent-emphasis, #2f81f7);
138
+ border-color: var(--bgColor-accent-emphasis, #2f81f7);
139
+ }
140
+
141
+ .selectHandleActive:hover {
142
+ background: var(--bgColor-accent-emphasis, #388bfd);
143
+ border-color: var(--bgColor-accent-emphasis, #388bfd);
144
+ }
145
+
146
+ /* Color picker feature */
147
+ .colorPickerWrapper {
148
+ position: relative;
149
+ display: flex;
150
+ align-items: center;
151
+ }
152
+
153
+ .colorDotInner {
154
+ width: 10px;
155
+ height: 10px;
156
+ border-radius: 50%;
157
+ display: block;
158
+ }
159
+
160
+ .colorPopup {
161
+ position: absolute;
162
+ bottom: calc(100% + 6px);
163
+ left: 50%;
164
+ transform: translateX(-50%);
165
+ display: flex;
166
+ gap: 5px;
167
+ padding: 6px 10px;
168
+ background: var(--bgColor-default, #ffffff);
169
+ border-radius: 20px;
170
+ box-shadow:
171
+ 0 0 0 1px rgba(0, 0, 0, 0.08),
172
+ 0 4px 12px rgba(0, 0, 0, 0.12);
173
+ opacity: 0;
174
+ pointer-events: none;
175
+ transition: opacity 150ms;
176
+ z-index: 10;
177
+ white-space: nowrap;
178
+ }
179
+
180
+ :global([data-sb-canvas-theme^='dark']) .colorPopup {
181
+ background: var(--bgColor-muted, #161b22);
182
+ box-shadow:
183
+ 0 0 0 1px rgba(255, 255, 255, 0.08),
184
+ 0 4px 12px rgba(0, 0, 0, 0.45);
185
+ }
186
+
187
+ .colorPickerWrapper:hover .colorPopup {
188
+ opacity: 1;
189
+ pointer-events: auto;
190
+ }
191
+
192
+ .colorOption {
193
+ all: unset;
194
+ width: 20px;
195
+ height: 20px;
196
+ border-radius: 50%;
197
+ border: 2px solid transparent;
198
+ cursor: pointer;
199
+ transition: transform 100ms;
200
+ }
201
+
202
+ .colorOption:hover {
203
+ transform: scale(1.15);
204
+ }
205
+
206
+ .colorOptionActive {
207
+ border-color: currentColor;
208
+ box-shadow: 0 0 0 1px currentColor;
209
+ }
@@ -0,0 +1,79 @@
1
+ /**
2
+ * Widget Config Loader
3
+ *
4
+ * Reads widgets.config.json from @dfosco/storyboard-core and builds
5
+ * schema objects compatible with the existing readProp/readAllProps/getDefaults API.
6
+ *
7
+ * The config is the single source of truth for widget definitions —
8
+ * prop schemas, feature lists, labels, and icons all come from here.
9
+ */
10
+ import widgetsConfig from '@dfosco/storyboard-core/widgets.config.json'
11
+
12
+ /**
13
+ * Convert a config prop definition to the schema shape used by widgetProps.js.
14
+ * Config uses `"default"`, schema uses `"defaultValue"`.
15
+ */
16
+ function configPropToSchema(propDef) {
17
+ const schema = {
18
+ type: propDef.type,
19
+ label: propDef.label,
20
+ category: propDef.category,
21
+ }
22
+ if (propDef.default !== undefined) schema.defaultValue = propDef.default
23
+ if (propDef.options) schema.options = propDef.options
24
+ if (propDef.min !== undefined) schema.min = propDef.min
25
+ if (propDef.max !== undefined) schema.max = propDef.max
26
+ return schema
27
+ }
28
+
29
+ /**
30
+ * Build schema objects for all widget types from the config.
31
+ * Returns the same shape as the old hardcoded schemas in widgetProps.js.
32
+ */
33
+ function buildSchemas() {
34
+ const result = {}
35
+ for (const [type, def] of Object.entries(widgetsConfig.widgets)) {
36
+ const schema = {}
37
+ for (const [key, propDef] of Object.entries(def.props || {})) {
38
+ schema[key] = configPropToSchema(propDef)
39
+ }
40
+ result[type] = schema
41
+ }
42
+ return result
43
+ }
44
+
45
+ /** All widget schemas, keyed by type string. */
46
+ export const schemas = buildSchemas()
47
+
48
+ /** Full widget config entries, keyed by type string. */
49
+ export const widgetTypes = widgetsConfig.widgets
50
+
51
+ /**
52
+ * Get the feature list for a widget type.
53
+ * @param {string} type — widget type string
54
+ * @returns {Array} features array from config, or empty array
55
+ */
56
+ export function getFeatures(type) {
57
+ return widgetTypes[type]?.features ?? []
58
+ }
59
+
60
+ /**
61
+ * Get the display metadata (label, icon) for a widget type.
62
+ * @param {string} type — widget type string
63
+ * @returns {{ label: string, icon: string } | null}
64
+ */
65
+ export function getWidgetMeta(type) {
66
+ const def = widgetTypes[type]
67
+ if (!def) return null
68
+ return { label: def.label, icon: def.icon }
69
+ }
70
+
71
+ /**
72
+ * Get all widget types as an array of { type, label, icon } for menus.
73
+ * Excludes link-preview which is created via paste only.
74
+ */
75
+ export function getMenuWidgetTypes() {
76
+ return Object.entries(widgetTypes)
77
+ .filter(([type]) => type !== 'link-preview')
78
+ .map(([type, def]) => ({ type, label: def.label, icon: def.icon }))
79
+ }
@@ -54,8 +54,9 @@
54
54
  *
55
55
  * ## Declaring Widget Props (Schema)
56
56
  *
57
- * Each widget type exports a `schema` describing its props.
58
- * This is used by the toolbar, canvas settings, and future widget inspectors.
57
+ * Widget prop schemas are defined in widgets.config.json (packages/core)
58
+ * and loaded via widgetConfig.js. This module re-exports the generated
59
+ * schemas and provides utility functions for reading props with defaults.
59
60
  */
60
61
 
61
62
  /**
@@ -71,6 +72,8 @@
71
72
  * @property {number} [max] — maximum for 'number' type
72
73
  */
73
74
 
75
+ import { schemas as configSchemas } from './widgetConfig.js'
76
+
74
77
  /**
75
78
  * Read a prop value with fallback to schema default.
76
79
  * @param {object} props — widget props object (may be null)
@@ -114,40 +117,13 @@ export function getDefaults(schema) {
114
117
  return result
115
118
  }
116
119
 
117
- // ── Widget Schemas ──────────────────────────────────────────────────
118
-
119
- export const stickyNoteSchema = {
120
- text: { type: 'text', label: 'Text', category: 'content', defaultValue: '' },
121
- color: { type: 'select', label: 'Color', category: 'settings', defaultValue: 'yellow',
122
- options: ['yellow', 'blue', 'green', 'pink', 'purple', 'orange'] },
123
- width: { type: 'number', label: 'Width', category: 'size', min: 180 },
124
- height: { type: 'number', label: 'Height', category: 'size', min: 60 },
125
- }
126
-
127
- export const markdownSchema = {
128
- content: { type: 'text', label: 'Content', category: 'content', defaultValue: '' },
129
- width: { type: 'number', label: 'Width', category: 'size', defaultValue: 360, min: 200, max: 1200 },
130
- }
120
+ // ── Config-driven schemas ───────────────────────────────────────────
131
121
 
132
- export const prototypeEmbedSchema = {
133
- src: { type: 'url', label: 'URL', category: 'content', defaultValue: '' },
134
- label: { type: 'text', label: 'Label', category: 'settings', defaultValue: '' },
135
- zoom: { type: 'number', label: 'Zoom', category: 'settings', defaultValue: 100, min: 25, max: 200 },
136
- width: { type: 'number', label: 'Width', category: 'size', defaultValue: 800, min: 200, max: 2000 },
137
- height: { type: 'number', label: 'Height', category: 'size', defaultValue: 600, min: 200, max: 1500 },
138
- }
122
+ /** Schema registry maps widget type strings to their schemas. */
123
+ export const schemas = configSchemas
139
124
 
140
- export const linkPreviewSchema = {
141
- url: { type: 'url', label: 'URL', category: 'content', defaultValue: '' },
142
- title: { type: 'text', label: 'Title', category: 'content', defaultValue: '' },
143
- }
144
-
145
- /**
146
- * Schema registry — maps widget type strings to their schemas.
147
- */
148
- export const schemas = {
149
- 'sticky-note': stickyNoteSchema,
150
- 'markdown': markdownSchema,
151
- 'prototype': prototypeEmbedSchema,
152
- 'link-preview': linkPreviewSchema,
153
- }
125
+ // Named exports for backward compatibility with widget imports
126
+ export const stickyNoteSchema = schemas['sticky-note']
127
+ export const markdownSchema = schemas['markdown']
128
+ export const prototypeEmbedSchema = schemas['prototype']
129
+ export const linkPreviewSchema = schemas['link-preview']