@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 +3 -3
- package/src/canvas/CanvasControls.jsx +2 -5
- package/src/canvas/CanvasPage.jsx +139 -20
- package/src/canvas/CanvasPage.module.css +1 -5
- package/src/canvas/CanvasToolbar.jsx +2 -5
- package/src/canvas/widgets/ComponentWidget.jsx +51 -3
- package/src/canvas/widgets/ComponentWidget.module.css +18 -0
- package/src/canvas/widgets/MarkdownBlock.module.css +1 -0
- package/src/canvas/widgets/PrototypeEmbed.jsx +36 -46
- package/src/canvas/widgets/ResizeHandle.jsx +56 -0
- package/src/canvas/widgets/ResizeHandle.module.css +29 -0
- package/src/canvas/widgets/StickyNote.jsx +21 -32
- package/src/canvas/widgets/StickyNote.module.css +1 -71
- package/src/canvas/widgets/StickyNote.test.jsx +96 -0
- package/src/canvas/widgets/WidgetChrome.jsx +244 -0
- package/src/canvas/widgets/WidgetChrome.module.css +209 -0
- package/src/canvas/widgets/widgetConfig.js +79 -0
- package/src/canvas/widgets/widgetProps.js +13 -35
- package/src/vite/data-plugin.js +8 -0
package/package.json
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dfosco/storyboard-react",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.10.0-beta.1",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"dependencies": {
|
|
6
|
-
"@dfosco/storyboard-core": "3.
|
|
7
|
-
"@dfosco/tiny-canvas": "3.
|
|
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
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
549
|
+
const sourceDataByExport = Object.fromEntries(
|
|
457
550
|
(localSources || [])
|
|
458
551
|
.filter((source) => source?.export)
|
|
459
|
-
.map((source) => [source.export, source
|
|
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
|
|
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
|
-
<
|
|
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
|
-
<
|
|
609
|
+
<ChromeWrappedWidget
|
|
498
610
|
widget={widget}
|
|
499
|
-
|
|
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
|
-
.
|
|
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
|
|
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
|
-
<
|
|
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
|
+
}
|
|
@@ -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
|
|
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 =
|
|
52
|
-
|
|
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
|
+
}
|