@dfosco/storyboard-react 3.9.1 → 3.10.0-beta.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,10 +1,10 @@
1
1
  {
2
2
  "name": "@dfosco/storyboard-react",
3
- "version": "3.9.1",
3
+ "version": "3.10.0-beta.1",
4
4
  "type": "module",
5
5
  "dependencies": {
6
- "@dfosco/storyboard-core": "3.9.1",
7
- "@dfosco/tiny-canvas": "3.9.1",
6
+ "@dfosco/storyboard-core": "3.10.0-beta.1",
7
+ "@dfosco/tiny-canvas": "3.10.0-beta.1",
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.
@@ -6,6 +6,8 @@ import { shouldPreventCanvasTextSelection } from './textSelection.js'
6
6
  import { getCanvasThemeVars, getCanvasPrimerAttrs } from './canvasTheme.js'
7
7
  import { getWidgetComponent } from './widgets/index.js'
8
8
  import { schemas, getDefaults } from './widgets/widgetProps.js'
9
+ import { getFeatures } from './widgets/widgetConfig.js'
10
+ import WidgetChrome from './widgets/WidgetChrome.jsx'
9
11
  import ComponentWidget from './widgets/ComponentWidget.jsx'
10
12
  import { addWidget as addWidgetApi, updateCanvas, removeWidget as removeWidgetApi } from './canvasApi.js'
11
13
  import styles from './CanvasPage.module.css'
@@ -15,6 +17,9 @@ const ZOOM_MAX = 200
15
17
 
16
18
  const CANVAS_BRIDGE_STATE_KEY = '__storyboardCanvasBridgeState'
17
19
 
20
+ /** Matches branch-deploy base path prefixes like /branch--my-feature/ */
21
+ const BRANCH_PREFIX_RE = /^\/branch--[^/]+/
22
+
18
23
  function getToolbarColorMode(theme) {
19
24
  return String(theme || 'light').startsWith('dark') ? 'dark' : 'light'
20
25
  }
@@ -65,17 +70,61 @@ function roundPosition(value) {
65
70
  }
66
71
 
67
72
  /** Renders a single JSON-defined widget by type lookup. */
68
- function WidgetRenderer({ widget, onUpdate }) {
73
+ function WidgetRenderer({ widget, onUpdate, widgetRef }) {
69
74
  const Component = getWidgetComponent(widget.type)
70
75
  if (!Component) {
71
76
  console.warn(`[canvas] Unknown widget type: ${widget.type}`)
72
77
  return null
73
78
  }
74
- return createElement(Component, {
75
- id: widget.id,
76
- props: widget.props,
77
- onUpdate,
78
- })
79
+ // Only pass ref to forwardRef-wrapped components (e.g. PrototypeEmbed)
80
+ const elementProps = { id: widget.id, props: widget.props, onUpdate }
81
+ if (Component.$$typeof === Symbol.for('react.forward_ref')) {
82
+ elementProps.ref = widgetRef
83
+ }
84
+ return createElement(Component, elementProps)
85
+ }
86
+
87
+ /**
88
+ * Wrapper for each JSON widget that holds its own ref for imperative actions.
89
+ * This allows WidgetChrome to dispatch actions to the widget via ref.
90
+ */
91
+ function ChromeWrappedWidget({
92
+ widget,
93
+ selected,
94
+ onSelect,
95
+ onDeselect,
96
+ onUpdate,
97
+ onRemove,
98
+ }) {
99
+ const widgetRef = useRef(null)
100
+ const features = getFeatures(widget.type)
101
+
102
+ const handleAction = useCallback((actionId) => {
103
+ if (actionId === 'delete') {
104
+ onRemove(widget.id)
105
+ }
106
+ }, [widget.id, onRemove])
107
+
108
+ return (
109
+ <WidgetChrome
110
+ widgetId={widget.id}
111
+ widgetType={widget.type}
112
+ features={features}
113
+ selected={selected}
114
+ widgetProps={widget.props}
115
+ widgetRef={widgetRef}
116
+ onSelect={onSelect}
117
+ onDeselect={onDeselect}
118
+ onAction={handleAction}
119
+ onUpdate={(updates) => onUpdate(widget.id, updates)}
120
+ >
121
+ <WidgetRenderer
122
+ widget={widget}
123
+ onUpdate={(updates) => onUpdate(widget.id, updates)}
124
+ widgetRef={widgetRef}
125
+ />
126
+ </WidgetChrome>
127
+ )
79
128
  }
80
129
 
81
130
  /**
@@ -154,6 +203,25 @@ export default function CanvasPage({ name }) {
154
203
  )
155
204
  }, [name])
156
205
 
206
+ const debouncedSourceSave = useRef(
207
+ debounce((canvasName, sources) => {
208
+ updateCanvas(canvasName, { sources }).catch((err) =>
209
+ console.error('[canvas] Failed to save sources:', err)
210
+ )
211
+ }, 2000)
212
+ ).current
213
+
214
+ const handleSourceUpdate = useCallback((exportName, updates) => {
215
+ setLocalSources((prev) => {
216
+ const current = Array.isArray(prev) ? prev : []
217
+ const next = current.some((s) => s?.export === exportName)
218
+ ? current.map((s) => (s?.export === exportName ? { ...s, ...updates } : s))
219
+ : [...current, { export: exportName, ...updates }]
220
+ debouncedSourceSave(name, next)
221
+ return next
222
+ })
223
+ }, [name, debouncedSourceSave])
224
+
157
225
  const handleItemDragEnd = useCallback((dragId, position) => {
158
226
  if (!dragId || !position) return
159
227
  const rounded = { x: Math.max(0, roundPosition(position.x)), y: Math.max(0, roundPosition(position.y)) }
@@ -296,7 +364,34 @@ export default function CanvasPage({ name }) {
296
364
 
297
365
  // Paste handler — same-origin URLs become prototypes, other URLs become link previews, text becomes markdown
298
366
  useEffect(() => {
299
- const baseUrl = window.location.origin + (import.meta.env?.BASE_URL || '/').replace(/\/$/, '')
367
+ const origin = window.location.origin
368
+ const basePath = (import.meta.env?.BASE_URL || '/').replace(/\/$/, '')
369
+ const baseUrl = origin + basePath
370
+
371
+ // Check if a URL is same-origin, accounting for branch-deploy prefixes.
372
+ // e.g. https://site.com/branch--my-feature/Proto and https://site.com/storyboard/Proto
373
+ // are both same-origin prototype URLs.
374
+ function isSameOriginPrototype(url) {
375
+ if (!url.startsWith(origin)) return false
376
+ if (url.startsWith(baseUrl)) return true
377
+ // Match branch deploy URLs: origin + /branch--*/...
378
+ const pathAfterOrigin = url.slice(origin.length)
379
+ return BRANCH_PREFIX_RE.test(pathAfterOrigin)
380
+ }
381
+
382
+ // Strip the base path (or any branch prefix) from a pathname to get a portable src.
383
+ function extractPrototypeSrc(pathname) {
384
+ // Strip current base path
385
+ if (basePath && pathname.startsWith(basePath)) {
386
+ return pathname.slice(basePath.length) || '/'
387
+ }
388
+ // Strip branch prefix: /branch--name/rest → /rest
389
+ const branchMatch = pathname.match(BRANCH_PREFIX_RE)
390
+ if (branchMatch) {
391
+ return pathname.slice(branchMatch[0].length) || '/'
392
+ }
393
+ return pathname
394
+ }
300
395
 
301
396
  async function handlePaste(e) {
302
397
  const tag = e.target.tagName
@@ -310,11 +405,9 @@ export default function CanvasPage({ name }) {
310
405
  let type, props
311
406
  try {
312
407
  const parsed = new URL(text)
313
- if (text.startsWith(baseUrl)) {
314
- // Same-origin URL → prototype embed with the path portion
408
+ if (isSameOriginPrototype(text)) {
315
409
  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
410
+ const src = extractPrototypeSrc(pathPortion)
318
411
  type = 'prototype'
319
412
  props = { src: src || '/', label: '', width: 800, height: 600 }
320
413
  } else {
@@ -453,32 +546,51 @@ export default function CanvasPage({ name }) {
453
546
  // Merge JSX-sourced widgets (from .canvas.jsx) and JSON widgets
454
547
  const allChildren = []
455
548
 
456
- const sourcePositionByExport = Object.fromEntries(
549
+ const sourceDataByExport = Object.fromEntries(
457
550
  (localSources || [])
458
551
  .filter((source) => source?.export)
459
- .map((source) => [source.export, source.position || { x: 0, y: 0 }])
552
+ .map((source) => [source.export, source])
460
553
  )
461
554
 
462
- // 1. JSX-sourced component widgets
555
+ // 1. JSX-sourced component widgets (wrapped in WidgetChrome, not deletable)
556
+ const componentFeatures = getFeatures('component')
463
557
  if (jsxExports) {
464
558
  for (const [exportName, Component] of Object.entries(jsxExports)) {
465
- const sourcePosition = sourcePositionByExport[exportName] || { x: 0, y: 0 }
559
+ const sourceData = sourceDataByExport[exportName] || {}
560
+ const sourcePosition = sourceData.position || { x: 0, y: 0 }
466
561
  allChildren.push(
467
562
  <div
468
563
  key={`jsx-${exportName}`}
469
564
  id={`jsx-${exportName}`}
470
565
  data-tc-x={sourcePosition.x}
471
566
  data-tc-y={sourcePosition.y}
567
+ data-tc-handle=".tc-drag-handle"
472
568
  {...canvasPrimerAttrs}
473
569
  style={canvasThemeVars}
570
+ onClick={(e) => {
571
+ e.stopPropagation()
572
+ setSelectedWidgetId(`jsx-${exportName}`)
573
+ }}
474
574
  >
475
- <ComponentWidget component={Component} />
575
+ <WidgetChrome
576
+ features={componentFeatures}
577
+ selected={selectedWidgetId === `jsx-${exportName}`}
578
+ onSelect={() => setSelectedWidgetId(`jsx-${exportName}`)}
579
+ onDeselect={() => setSelectedWidgetId(null)}
580
+ >
581
+ <ComponentWidget
582
+ component={Component}
583
+ width={sourceData.width}
584
+ height={sourceData.height}
585
+ onUpdate={(updates) => handleSourceUpdate(exportName, updates)}
586
+ />
587
+ </WidgetChrome>
476
588
  </div>
477
589
  )
478
590
  }
479
591
  }
480
592
 
481
- // 2. JSON-defined mutable widgets (selectable)
593
+ // 2. JSON-defined mutable widgets (selectable, wrapped in WidgetChrome)
482
594
  for (const widget of (localWidgets ?? [])) {
483
595
  allChildren.push(
484
596
  <div
@@ -486,17 +598,24 @@ export default function CanvasPage({ name }) {
486
598
  id={widget.id}
487
599
  data-tc-x={widget?.position?.x ?? 0}
488
600
  data-tc-y={widget?.position?.y ?? 0}
601
+ data-tc-handle=".tc-drag-handle"
489
602
  {...canvasPrimerAttrs}
490
603
  style={canvasThemeVars}
491
604
  onClick={(e) => {
492
605
  e.stopPropagation()
493
606
  setSelectedWidgetId(widget.id)
494
607
  }}
495
- className={selectedWidgetId === widget.id ? styles.selected : undefined}
496
608
  >
497
- <WidgetRenderer
609
+ <ChromeWrappedWidget
498
610
  widget={widget}
499
- onUpdate={(updates) => handleWidgetUpdate(widget.id, updates)}
611
+ selected={selectedWidgetId === widget.id}
612
+ onSelect={() => setSelectedWidgetId(widget.id)}
613
+ onDeselect={() => setSelectedWidgetId(null)}
614
+ onUpdate={handleWidgetUpdate}
615
+ onRemove={(id) => {
616
+ handleWidgetRemove(id)
617
+ setSelectedWidgetId(null)
618
+ }}
500
619
  />
501
620
  </div>
502
621
  )
@@ -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)
@@ -36,7 +36,16 @@ export default function PrototypeEmbed({ props, onUpdate }) {
36
36
  const label = readProp(props, 'label', prototypeEmbedSchema) || src
37
37
 
38
38
  const basePath = (import.meta.env.BASE_URL || '/').replace(/\/$/, '')
39
- const rawSrc = src ? `${basePath}${src}` : ''
39
+ const baseSegment = basePath.replace(/^\//, '')
40
+ const rawSrc = useMemo(() => {
41
+ if (!src) return ''
42
+ if (/^https?:\/\//.test(src)) return 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}`
48
+ }, [src, basePath, baseSegment])
40
49
 
41
50
  const scale = zoom / 100
42
51
 
@@ -48,9 +57,14 @@ export default function PrototypeEmbed({ props, onUpdate }) {
48
57
  const filterRef = useRef(null)
49
58
  const embedRef = useRef(null)
50
59
 
51
- const iframeSrc = rawSrc
52
- ? `${rawSrc}${rawSrc.includes('?') ? '&' : '?'}_sb_embed&_sb_theme_target=prototype&_sb_canvas_theme=${canvasTheme}`
53
- : ''
60
+ const iframeSrc = useMemo(() => {
61
+ if (!rawSrc) return ''
62
+ const hashIdx = rawSrc.indexOf('#')
63
+ const base = hashIdx >= 0 ? rawSrc.slice(0, hashIdx) : rawSrc
64
+ const hash = hashIdx >= 0 ? rawSrc.slice(hashIdx) : ''
65
+ const sep = base.includes('?') ? '&' : '?'
66
+ return `${base}${sep}_sb_embed&_sb_theme_target=prototype&_sb_canvas_theme=${canvasTheme}${hash}`
67
+ }, [rawSrc, canvasTheme])
54
68
 
55
69
  // Build prototype index for the picker
56
70
  const prototypeIndex = useMemo(() => {
@@ -167,6 +181,21 @@ export default function PrototypeEmbed({ props, onUpdate }) {
167
181
 
168
182
  const enterInteractive = useCallback(() => setInteractive(true), [])
169
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
+
170
199
  function handlePickRoute(route) {
171
200
  onUpdate?.({ src: route })
172
201
  setEditing(false)
@@ -308,45 +337,6 @@ export default function PrototypeEmbed({ props, onUpdate }) {
308
337
  <p>Double-click to set prototype URL</p>
309
338
  </div>
310
339
  )}
311
- {iframeSrc && !editing && (
312
- <button
313
- className={styles.editBtn}
314
- onClick={(e) => { e.stopPropagation(); setEditing(true) }}
315
- onMouseDown={(e) => e.stopPropagation()}
316
- onPointerDown={(e) => e.stopPropagation()}
317
- title="Edit URL"
318
- aria-label="Edit prototype URL"
319
- >
320
- <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>
321
- </button>
322
- )}
323
- {iframeSrc && !editing && (
324
- <div
325
- className={styles.zoomBar}
326
- onMouseDown={(e) => e.stopPropagation()}
327
- onPointerDown={(e) => e.stopPropagation()}
328
- >
329
- <button
330
- className={styles.zoomBtn}
331
- onClick={() => {
332
- const step = zoom <= 75 ? 5 : 25
333
- onUpdate?.({ zoom: Math.max(25, zoom - step) })
334
- }}
335
- disabled={zoom <= 25}
336
- aria-label="Zoom out"
337
- >−</button>
338
- <span className={styles.zoomLabel}>{zoom}%</span>
339
- <button
340
- className={styles.zoomBtn}
341
- onClick={() => {
342
- const step = zoom < 75 ? 5 : 25
343
- onUpdate?.({ zoom: Math.min(200, zoom + step) })
344
- }}
345
- disabled={zoom >= 200}
346
- aria-label="Zoom in"
347
- >+</button>
348
- </div>
349
- )}
350
340
  </div>
351
341
  <div
352
342
  className={styles.resizeHandle}
@@ -373,4 +363,4 @@ export default function PrototypeEmbed({ props, onUpdate }) {
373
363
  />
374
364
  </WidgetWrapper>
375
365
  )
376
- }
366
+ })
@@ -0,0 +1,56 @@
1
+ import { useCallback } from 'react'
2
+ import styles from './ResizeHandle.module.css'
3
+
4
+ /**
5
+ * Shared resize handle for canvas widgets.
6
+ *
7
+ * Renders a small drag handle in the bottom-right corner of the parent.
8
+ * On drag, calls `onResize(width, height)` with new dimensions.
9
+ *
10
+ * The parent must have `position: relative` for correct positioning.
11
+ *
12
+ * @param {Object} props
13
+ * @param {React.RefObject} props.targetRef - ref to the element being resized (reads offsetWidth/Height)
14
+ * @param {number} [props.minWidth=180] - minimum allowed width
15
+ * @param {number} [props.minHeight=60] - minimum allowed height
16
+ * @param {Function} props.onResize - callback: (width, height) => void
17
+ */
18
+ export default function ResizeHandle({ targetRef, minWidth = 180, minHeight = 60, onResize }) {
19
+ const handleMouseDown = useCallback((e) => {
20
+ e.stopPropagation()
21
+ e.preventDefault()
22
+
23
+ const el = targetRef?.current
24
+ if (!el) return
25
+
26
+ const startX = e.clientX
27
+ const startY = e.clientY
28
+ const startW = el.offsetWidth
29
+ const startH = el.offsetHeight
30
+
31
+ function onMove(ev) {
32
+ const newW = Math.max(minWidth, startW + ev.clientX - startX)
33
+ const newH = Math.max(minHeight, startH + ev.clientY - startY)
34
+ onResize?.(newW, newH)
35
+ }
36
+
37
+ function onUp() {
38
+ document.removeEventListener('mousemove', onMove)
39
+ document.removeEventListener('mouseup', onUp)
40
+ }
41
+
42
+ document.addEventListener('mousemove', onMove)
43
+ document.addEventListener('mouseup', onUp)
44
+ }, [targetRef, minWidth, minHeight, onResize])
45
+
46
+ return (
47
+ <div
48
+ className={styles.handle}
49
+ onMouseDown={handleMouseDown}
50
+ onPointerDown={(e) => e.stopPropagation()}
51
+ role="separator"
52
+ aria-orientation="horizontal"
53
+ aria-label="Resize"
54
+ />
55
+ )
56
+ }
@@ -0,0 +1,29 @@
1
+ .handle {
2
+ position: absolute;
3
+ bottom: 0;
4
+ right: 0;
5
+ width: 16px;
6
+ height: 16px;
7
+ cursor: nwse-resize;
8
+ background: linear-gradient(
9
+ 135deg,
10
+ transparent 40%,
11
+ var(--borderColor-muted, rgba(0, 0, 0, 0.15)) 40%,
12
+ var(--borderColor-muted, rgba(0, 0, 0, 0.15)) 50%,
13
+ transparent 50%,
14
+ transparent 65%,
15
+ var(--borderColor-muted, rgba(0, 0, 0, 0.15)) 65%,
16
+ var(--borderColor-muted, rgba(0, 0, 0, 0.15)) 75%,
17
+ transparent 75%
18
+ );
19
+ opacity: 0;
20
+ transition: opacity 150ms;
21
+ z-index: 2;
22
+ border-radius: 0 0 6px 0;
23
+ }
24
+
25
+ /* Show on parent hover or direct hover */
26
+ *:hover > .handle,
27
+ .handle:hover {
28
+ opacity: 1;
29
+ }