@dfosco/storyboard-react 4.2.0-alpha.15 → 4.2.0-alpha.16
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/BranchBar/BranchBar.jsx +7 -4
- package/src/BranchBar/BranchBar.module.css +7 -2
- package/src/CommandPalette/CommandPalette.jsx +20 -2
- package/src/Icon.jsx +4 -0
- package/src/Viewfinder.jsx +1 -1
- package/src/canvas/CanvasPage.bridge.test.jsx +14 -6
- package/src/canvas/CanvasPage.dragdrop.test.jsx +10 -6
- package/src/canvas/CanvasPage.jsx +22 -30
- package/src/canvas/CanvasPage.module.css +0 -15
- package/src/canvas/CanvasPage.multiselect.test.jsx +10 -6
- package/src/canvas/PageSelector.test.jsx +15 -6
- package/src/canvas/widgets/ImageWidget.jsx +1 -1
- package/src/canvas/widgets/PrototypeEmbed.jsx +16 -18
- package/src/canvas/widgets/PrototypeEmbed.test.jsx +2 -2
- package/src/canvas/widgets/StoryWidget.jsx +18 -21
- package/src/canvas/widgets/TerminalWidget.jsx +100 -98
- package/src/canvas/widgets/TerminalWidget.module.css +49 -1
- package/src/canvas/widgets/embedInteraction.test.jsx +24 -26
- package/src/canvas/widgets/snapshotDisplay.test.jsx +23 -71
- package/src/canvas/widgets/useEmbedsPaused.js +19 -0
- package/src/hooks/useConfig.js +14 -0
- package/src/index.js +1 -0
- package/src/vite/data-plugin.js +230 -13
- package/src/canvas/widgets/useEmbedController.jsx +0 -207
package/package.json
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dfosco/storyboard-react",
|
|
3
|
-
"version": "4.2.0-alpha.
|
|
3
|
+
"version": "4.2.0-alpha.16",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"dependencies": {
|
|
6
6
|
"@base-ui/react": "^1.4.0",
|
|
7
|
-
"@dfosco/storyboard-core": "4.2.0-alpha.
|
|
8
|
-
"@dfosco/tiny-canvas": "4.2.0-alpha.
|
|
7
|
+
"@dfosco/storyboard-core": "4.2.0-alpha.16",
|
|
8
|
+
"@dfosco/tiny-canvas": "4.2.0-alpha.16",
|
|
9
9
|
"@neodrag/react": "^2.3.1",
|
|
10
10
|
"glob": "^11.0.0",
|
|
11
11
|
"jsonc-parser": "^3.3.1",
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* BranchBar —
|
|
2
|
+
* BranchBar — blue accent bar showing current branch and local dev status.
|
|
3
3
|
*
|
|
4
|
-
* Dev:
|
|
5
|
-
* Prod:
|
|
4
|
+
* Dev: always visible (main or branch). Shows "Local development" label.
|
|
5
|
+
* Prod: shows on non-main branches only.
|
|
6
6
|
*/
|
|
7
7
|
import { useState, useEffect, useMemo } from 'react'
|
|
8
8
|
import { GitBranchIcon } from '@primer/octicons-react'
|
|
@@ -22,6 +22,7 @@ export default function BranchBar({ basePath }) {
|
|
|
22
22
|
return m ? m[1] : 'main'
|
|
23
23
|
}, [basePath])
|
|
24
24
|
|
|
25
|
+
const isLocalDev = import.meta.env.DEV
|
|
25
26
|
const isOnBranch = currentBranch !== 'main'
|
|
26
27
|
|
|
27
28
|
useEffect(() => {
|
|
@@ -32,7 +33,7 @@ export default function BranchBar({ basePath }) {
|
|
|
32
33
|
return () => observer.disconnect()
|
|
33
34
|
}, [])
|
|
34
35
|
|
|
35
|
-
if (!isOnBranch || hidden || isHiddenByParam) return null
|
|
36
|
+
if ((!isOnBranch && !isLocalDev) || hidden || isHiddenByParam) return null
|
|
36
37
|
|
|
37
38
|
function hideChrome() {
|
|
38
39
|
window.dispatchEvent(new KeyboardEvent('keydown', {
|
|
@@ -46,6 +47,8 @@ export default function BranchBar({ basePath }) {
|
|
|
46
47
|
<span className={css.barLabel}>
|
|
47
48
|
<GitBranchIcon size={12} />
|
|
48
49
|
<span className={css.barBranchName}>{currentBranch}</span>
|
|
50
|
+
<span className={css.barSeparator}>·</span>
|
|
51
|
+
<span>Local development</span>
|
|
49
52
|
</span>
|
|
50
53
|
<div className={css.barActions}>
|
|
51
54
|
<button className={css.barAction} onClick={hideChrome}>Hide</button>
|
|
@@ -16,8 +16,8 @@
|
|
|
16
16
|
justify-content: center;
|
|
17
17
|
gap: 8px;
|
|
18
18
|
height: 32px;
|
|
19
|
-
background:
|
|
20
|
-
color:
|
|
19
|
+
background: hsl(212, 92%, 45%);
|
|
20
|
+
color: #fff;
|
|
21
21
|
padding: 4px 12px;
|
|
22
22
|
position: relative;
|
|
23
23
|
}
|
|
@@ -68,6 +68,11 @@
|
|
|
68
68
|
white-space: nowrap;
|
|
69
69
|
}
|
|
70
70
|
|
|
71
|
+
.barSeparator {
|
|
72
|
+
opacity: 0.6;
|
|
73
|
+
margin: 0 2px;
|
|
74
|
+
}
|
|
75
|
+
|
|
71
76
|
.barAction {
|
|
72
77
|
background: none;
|
|
73
78
|
border: none;
|
|
@@ -204,6 +204,11 @@ function buildConfigSections(prefix, onNavigateToPage, onCreateAction) {
|
|
|
204
204
|
continue
|
|
205
205
|
}
|
|
206
206
|
|
|
207
|
+
if (tool.inlineAction === 'open-palette') {
|
|
208
|
+
// Skip — no point opening the palette from within itself
|
|
209
|
+
continue
|
|
210
|
+
}
|
|
211
|
+
|
|
207
212
|
// Any remaining tools (all surfaces)
|
|
208
213
|
if (tool.render === 'link' && tool.url) {
|
|
209
214
|
remainingItems.push({
|
|
@@ -489,7 +494,7 @@ function buildDynamicSection(section, prefix, onNavigateToPage, onCreateAction)
|
|
|
489
494
|
/**
|
|
490
495
|
* Build a section from toolbar.config.json tools.
|
|
491
496
|
* If toolIds is provided, only include those tools in that order (with optional custom labels).
|
|
492
|
-
* Otherwise include all command-
|
|
497
|
+
* Otherwise include all command-palette tools.
|
|
493
498
|
*
|
|
494
499
|
* toolIds format: ["theme", "flows"] or [{ id: "theme", label: "Change theme" }]
|
|
495
500
|
*/
|
|
@@ -515,7 +520,7 @@ function buildToolsSection(section, prefix, onNavigateToPage) {
|
|
|
515
520
|
}
|
|
516
521
|
} else {
|
|
517
522
|
for (const [toolId, tool] of Object.entries(tools)) {
|
|
518
|
-
if (tool.surface !== 'command-
|
|
523
|
+
if (tool.surface !== 'command-palette') continue
|
|
519
524
|
const state = getToolbarToolState(toolId)
|
|
520
525
|
if (state === 'disabled' || state === 'hidden') continue
|
|
521
526
|
if (isHiddenInPalette(tool, basePath)) continue
|
|
@@ -543,6 +548,19 @@ function buildToolsSection(section, prefix, onNavigateToPage) {
|
|
|
543
548
|
continue
|
|
544
549
|
}
|
|
545
550
|
|
|
551
|
+
if (tool.inlineAction === 'open-palette') {
|
|
552
|
+
items.push({
|
|
553
|
+
id: `cfg:${section.id}:${toolId}`,
|
|
554
|
+
children: label,
|
|
555
|
+
keywords: [label, toolId, 'command', 'palette', 'search'].filter(Boolean),
|
|
556
|
+
showType: false,
|
|
557
|
+
onClick: () => {
|
|
558
|
+
document.dispatchEvent(new CustomEvent('storyboard:open-palette'))
|
|
559
|
+
},
|
|
560
|
+
})
|
|
561
|
+
continue
|
|
562
|
+
}
|
|
563
|
+
|
|
546
564
|
if (tool.render === 'link' && tool.url) {
|
|
547
565
|
items.push({
|
|
548
566
|
id: `cfg:${section.id}:${toolId}`,
|
package/src/Icon.jsx
CHANGED
|
@@ -15,6 +15,10 @@
|
|
|
15
15
|
/* ─── Custom SVG paths (fill-based, no namespace prefix) ─── */
|
|
16
16
|
|
|
17
17
|
const customIcons = {
|
|
18
|
+
'home': {
|
|
19
|
+
viewBox: '0 0 16 16',
|
|
20
|
+
path: 'M6.906.664a1.749 1.749 0 0 1 2.187 0l5.25 4.2c.415.332.657.835.657 1.367v7.019A1.75 1.75 0 0 1 13.25 15h-3.5a.75.75 0 0 1-.75-.75V9H7v5.25a.75.75 0 0 1-.75.75h-3.5A1.75 1.75 0 0 1 1 13.25V6.23c0-.531.242-1.034.657-1.366l5.25-4.2Zm1.25 1.171a.25.25 0 0 0-.312 0l-5.25 4.2a.25.25 0 0 0-.094.196v7.019c0 .138.112.25.25.25H5.5V8.25a.75.75 0 0 1 .75-.75h3.5a.75.75 0 0 1 .75.75v5.25h2.75a.25.25 0 0 0 .25-.25V6.23a.25.25 0 0 0-.094-.195Z',
|
|
21
|
+
},
|
|
18
22
|
'folder': {
|
|
19
23
|
viewBox: '0 0 24 24',
|
|
20
24
|
path: 'M4 20q-.825 0-1.412-.587T2 18V6q0-.825.588-1.412T4 4h5.175q.4 0 .763.15t.637.425L12 6h8q.825 0 1.413.588T22 8v10q0 .825-.587 1.413T20 20z',
|
package/src/Viewfinder.jsx
CHANGED
|
@@ -1071,7 +1071,7 @@ export default function Viewfinder({
|
|
|
1071
1071
|
{activeTab === 'Starred' && 'No starred items. Click ☆ on a card to star it.'}
|
|
1072
1072
|
{activeTab === 'All' && 'No items found. Create a prototype, canvas, or component to get started.'}
|
|
1073
1073
|
</div>
|
|
1074
|
-
) : groupByFolders && grouped && activeTab === 'All'
|
|
1074
|
+
) : groupByFolders && grouped && activeTab === 'All' ? (
|
|
1075
1075
|
<>
|
|
1076
1076
|
{grouped.folders.map(folder => (
|
|
1077
1077
|
<FolderSection
|
|
@@ -63,12 +63,16 @@ vi.mock('./widgets/widgetProps.js', () => ({
|
|
|
63
63
|
getDefaults: () => ({}),
|
|
64
64
|
}))
|
|
65
65
|
|
|
66
|
-
vi.mock('./widgets/widgetConfig.js', () =>
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
}
|
|
66
|
+
vi.mock('./widgets/widgetConfig.js', async () => {
|
|
67
|
+
const actual = await vi.importActual('./widgets/widgetConfig.js')
|
|
68
|
+
return {
|
|
69
|
+
getFeatures: () => [],
|
|
70
|
+
isResizable: () => false,
|
|
71
|
+
schemas: {},
|
|
72
|
+
getMenuWidgetTypes: () => [],
|
|
73
|
+
getConnectorDefaults: actual.getConnectorDefaults,
|
|
74
|
+
}
|
|
75
|
+
})
|
|
72
76
|
|
|
73
77
|
vi.mock('./widgets/figmaUrl.js', () => ({
|
|
74
78
|
isFigmaUrl: () => false,
|
|
@@ -129,6 +133,8 @@ describe('CanvasPage canvas bridge', () => {
|
|
|
129
133
|
expect(window.__storyboardCanvasBridgeState).toEqual({
|
|
130
134
|
active: true,
|
|
131
135
|
canvasId: 'design-overview',
|
|
136
|
+
connectors: [],
|
|
137
|
+
widgets: [{ id: 'widget-1', type: 'mock-widget', position: { x: 10, y: 20 }, props: {} }],
|
|
132
138
|
zoom: 100,
|
|
133
139
|
})
|
|
134
140
|
expect(mountedHandler).toHaveBeenCalled()
|
|
@@ -138,6 +144,8 @@ describe('CanvasPage canvas bridge', () => {
|
|
|
138
144
|
expect(statusHandler.mock.calls.at(-1)?.[0]?.detail).toEqual({
|
|
139
145
|
active: true,
|
|
140
146
|
canvasId: 'design-overview',
|
|
147
|
+
connectors: [],
|
|
148
|
+
widgets: [{ id: 'widget-1', type: 'mock-widget', position: { x: 10, y: 20 }, props: {} }],
|
|
141
149
|
zoom: 100,
|
|
142
150
|
})
|
|
143
151
|
|
|
@@ -48,12 +48,16 @@ vi.mock('./widgets/widgetProps.js', () => ({
|
|
|
48
48
|
getDefaults: () => ({}),
|
|
49
49
|
}))
|
|
50
50
|
|
|
51
|
-
vi.mock('./widgets/widgetConfig.js', () =>
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
}
|
|
51
|
+
vi.mock('./widgets/widgetConfig.js', async () => {
|
|
52
|
+
const actual = await vi.importActual('./widgets/widgetConfig.js')
|
|
53
|
+
return {
|
|
54
|
+
getFeatures: () => [],
|
|
55
|
+
isResizable: () => false,
|
|
56
|
+
schemas: {},
|
|
57
|
+
getMenuWidgetTypes: () => [],
|
|
58
|
+
getConnectorDefaults: actual.getConnectorDefaults,
|
|
59
|
+
}
|
|
60
|
+
})
|
|
57
61
|
|
|
58
62
|
vi.mock('./widgets/figmaUrl.js', () => ({
|
|
59
63
|
isFigmaUrl: () => false,
|
|
@@ -12,7 +12,6 @@ import { getPasteRules } from '@dfosco/storyboard-core'
|
|
|
12
12
|
import { registerSmoothCorners } from '@dfosco/storyboard-core/smooth-corners'
|
|
13
13
|
import { isGitHubEmbedUrl } from './widgets/githubUrl.js'
|
|
14
14
|
import WidgetChrome from './widgets/WidgetChrome.jsx'
|
|
15
|
-
import { EmbedControllerProvider } from './widgets/useEmbedController.jsx'
|
|
16
15
|
import ComponentWidget from './widgets/ComponentWidget.jsx'
|
|
17
16
|
import useUndoRedo from './useUndoRedo.js'
|
|
18
17
|
import useMarqueeSelect from './useMarqueeSelect.js'
|
|
@@ -531,7 +530,6 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
|
|
|
531
530
|
const [canvasTheme, setCanvasTheme] = useState(() => resolveCanvasThemeFromStorage())
|
|
532
531
|
const [snapEnabled, setSnapEnabled] = useState(canvas?.snapToGrid ?? false)
|
|
533
532
|
const [snapGridSize, setSnapGridSize] = useState(canvas?.gridSize || 40)
|
|
534
|
-
const [perfMode, setPerfMode] = useState(canvas?.performanceMode ?? false)
|
|
535
533
|
const [showGhInstallBanner, setShowGhInstallBanner] = useState(false)
|
|
536
534
|
|
|
537
535
|
// Refs for snap settings (used by drop handler inside effect closure)
|
|
@@ -672,7 +670,6 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
|
|
|
672
670
|
setLocalSources(canvas?.sources ?? [])
|
|
673
671
|
setSnapEnabled(canvas?.snapToGrid ?? false)
|
|
674
672
|
setSnapGridSize(canvas?.gridSize || 40)
|
|
675
|
-
setPerfMode(canvas?.performanceMode ?? false)
|
|
676
673
|
undoRedo.reset()
|
|
677
674
|
// Only reset viewport state when switching to a different canvas,
|
|
678
675
|
// not when the same canvas refreshes with server data.
|
|
@@ -686,11 +683,14 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
|
|
|
686
683
|
}
|
|
687
684
|
}
|
|
688
685
|
|
|
689
|
-
// Debounced save to server
|
|
686
|
+
// Debounced save to server — routed through queueWrite to serialize
|
|
687
|
+
// with deletes and other writes, preventing stale data from overwriting.
|
|
690
688
|
const debouncedSave = useRef(
|
|
691
689
|
debounce((canvasId, widgets) => {
|
|
692
|
-
|
|
693
|
-
|
|
690
|
+
queueWrite(() =>
|
|
691
|
+
updateCanvas(canvasId, { widgets }).catch((err) =>
|
|
692
|
+
console.error('[canvas] Failed to save:', err)
|
|
693
|
+
)
|
|
694
694
|
)
|
|
695
695
|
}, 2000)
|
|
696
696
|
).current
|
|
@@ -714,6 +714,10 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
|
|
|
714
714
|
}, [canvasId, debouncedSave, undoRedo, snapEnabled, snapGridSize])
|
|
715
715
|
|
|
716
716
|
const handleWidgetRemove = useCallback((widgetId) => {
|
|
717
|
+
// Cancel any pending debounced save — it may contain stale data
|
|
718
|
+
// that includes the widget we're about to delete
|
|
719
|
+
debouncedSave.cancel()
|
|
720
|
+
|
|
717
721
|
undoRedo.snapshot(stateRef.current, 'remove', widgetId)
|
|
718
722
|
setLocalWidgets((prev) => prev ? prev.filter((w) => w.id !== widgetId) : prev)
|
|
719
723
|
// Cascade: remove connectors referencing this widget
|
|
@@ -734,7 +738,7 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
|
|
|
734
738
|
console.error('[canvas] Failed to remove widget:', err)
|
|
735
739
|
)
|
|
736
740
|
)
|
|
737
|
-
}, [canvasId, undoRedo])
|
|
741
|
+
}, [canvasId, undoRedo, debouncedSave])
|
|
738
742
|
|
|
739
743
|
const handleConnectorAdd = useCallback(async ({ startWidgetId, startAnchor, endWidgetId, endAnchor }) => {
|
|
740
744
|
try {
|
|
@@ -923,10 +927,14 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
|
|
|
923
927
|
}
|
|
924
928
|
const position = { x: baseX + n * 40, y: baseY + n * 40 }
|
|
925
929
|
try {
|
|
930
|
+
const copyProps = { ...widget.props }
|
|
931
|
+
// Terminal widgets must get unique names — strip prettyName so the server generates a fresh one
|
|
932
|
+
if (widget.type === 'terminal') delete copyProps.prettyName
|
|
933
|
+
|
|
926
934
|
undoRedo.snapshot(stateRef.current, 'add')
|
|
927
935
|
const result = await addWidgetApi(canvasId, {
|
|
928
936
|
type: widget.type,
|
|
929
|
-
props:
|
|
937
|
+
props: copyProps,
|
|
930
938
|
position,
|
|
931
939
|
})
|
|
932
940
|
if (result.success && result.widget) {
|
|
@@ -1127,6 +1135,7 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
|
|
|
1127
1135
|
}
|
|
1128
1136
|
|
|
1129
1137
|
undoRedo.snapshot(stateRef.current, 'move', dragId)
|
|
1138
|
+
debouncedSave.cancel()
|
|
1130
1139
|
setLocalWidgets((prev) => {
|
|
1131
1140
|
if (!prev) return prev
|
|
1132
1141
|
const next = prev.map((w) =>
|
|
@@ -1552,21 +1561,6 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
|
|
|
1552
1561
|
return () => document.removeEventListener('storyboard:canvas:toggle-snap', handleSnapToggle)
|
|
1553
1562
|
}, [canvasId])
|
|
1554
1563
|
|
|
1555
|
-
// Listen for performance mode toggle from command palette
|
|
1556
|
-
useEffect(() => {
|
|
1557
|
-
function handlePerfToggle() {
|
|
1558
|
-
setPerfMode((prev) => {
|
|
1559
|
-
const next = !prev
|
|
1560
|
-
updateCanvas(canvasId, { settings: { performanceMode: next } }).catch((err) =>
|
|
1561
|
-
console.error('[canvas] Failed to persist performance mode:', err)
|
|
1562
|
-
)
|
|
1563
|
-
return next
|
|
1564
|
-
})
|
|
1565
|
-
}
|
|
1566
|
-
document.addEventListener('storyboard:canvas:toggle-performance-mode', handlePerfToggle)
|
|
1567
|
-
return () => document.removeEventListener('storyboard:canvas:toggle-performance-mode', handlePerfToggle)
|
|
1568
|
-
}, [canvasId])
|
|
1569
|
-
|
|
1570
1564
|
// Broadcast snap state to Svelte toolbar
|
|
1571
1565
|
useEffect(() => {
|
|
1572
1566
|
document.dispatchEvent(new CustomEvent('storyboard:canvas:snap-state', {
|
|
@@ -1804,6 +1798,7 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
|
|
|
1804
1798
|
undoRedo.snapshot(stateRef.current, 'add')
|
|
1805
1799
|
setLocalWidgets((prev) => [...(prev || []), result.widget])
|
|
1806
1800
|
setSelectedWidgetIds(new Set([result.widget.id]))
|
|
1801
|
+
navigator.clipboard?.writeText(result.widget.id).catch(() => {})
|
|
1807
1802
|
}
|
|
1808
1803
|
return true
|
|
1809
1804
|
} catch (err) {
|
|
@@ -1895,9 +1890,11 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
|
|
|
1895
1890
|
for (const w of sourceWidgets) {
|
|
1896
1891
|
const relX = (w.position?.x ?? 0) - minX
|
|
1897
1892
|
const relY = (w.position?.y ?? 0) - minY
|
|
1893
|
+
const pasteProps = { ...w.props }
|
|
1894
|
+
if (w.type === 'terminal') delete pasteProps.prettyName
|
|
1898
1895
|
const result = await addWidgetApi(canvasId, {
|
|
1899
1896
|
type: w.type,
|
|
1900
|
-
props:
|
|
1897
|
+
props: pasteProps,
|
|
1901
1898
|
position: { x: baseX + relX, y: baseY + relY },
|
|
1902
1899
|
})
|
|
1903
1900
|
if (result.success && result.widget) {
|
|
@@ -2382,7 +2379,7 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
|
|
|
2382
2379
|
<>
|
|
2383
2380
|
<div className={styles.canvasTitle}>
|
|
2384
2381
|
<a href={(import.meta.env?.BASE_URL || '/')} className={styles.canvasLogo} aria-label="Go to homepage">
|
|
2385
|
-
<Icon name="
|
|
2382
|
+
<Icon name="home" size={16} color="#fff" />
|
|
2386
2383
|
</a>
|
|
2387
2384
|
<CanvasTitleEditable
|
|
2388
2385
|
canvasId={canvasId}
|
|
@@ -2391,9 +2388,6 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
|
|
|
2391
2388
|
isLocalDev={isLocalDev}
|
|
2392
2389
|
/>
|
|
2393
2390
|
<PageSelector currentName={canvasId} pages={siblingPages} isLocalDev={isLocalDev} />
|
|
2394
|
-
{isLocalDev && (
|
|
2395
|
-
<span className={styles.localEditingLabel}>Local editing</span>
|
|
2396
|
-
)}
|
|
2397
2391
|
</div>
|
|
2398
2392
|
<div
|
|
2399
2393
|
ref={scrollRef}
|
|
@@ -2429,11 +2423,9 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
|
|
|
2429
2423
|
dragPreview={connectorDrag}
|
|
2430
2424
|
hidden={widgetDragging}
|
|
2431
2425
|
/>
|
|
2432
|
-
<EmbedControllerProvider performanceMode={perfMode} scrollRef={scrollRef}>
|
|
2433
2426
|
<Canvas {...canvasProps} onDragStart={isLocalDev ? handleItemDragStart : undefined} onDrag={isLocalDev ? handleItemDrag : undefined} onDragEnd={isLocalDev ? handleItemDragEnd : undefined}>
|
|
2434
2427
|
{allChildren}
|
|
2435
2428
|
</Canvas>
|
|
2436
|
-
</EmbedControllerProvider>
|
|
2437
2429
|
</div>
|
|
2438
2430
|
</div>
|
|
2439
2431
|
{showGhInstallBanner && (
|
|
@@ -109,21 +109,6 @@
|
|
|
109
109
|
isolation: isolate;
|
|
110
110
|
}
|
|
111
111
|
|
|
112
|
-
.localEditingLabel {
|
|
113
|
-
display: inline-flex;
|
|
114
|
-
align-items: center;
|
|
115
|
-
padding: 4px 12px;
|
|
116
|
-
background: hsl(212, 92%, 45%);
|
|
117
|
-
color: #fff;
|
|
118
|
-
font-size: 13px;
|
|
119
|
-
font-weight: 600;
|
|
120
|
-
border-radius: 6px;
|
|
121
|
-
letter-spacing: 0.01em;
|
|
122
|
-
white-space: nowrap;
|
|
123
|
-
pointer-events: none;
|
|
124
|
-
user-select: none;
|
|
125
|
-
}
|
|
126
|
-
|
|
127
112
|
.ghInstallBanner {
|
|
128
113
|
position: fixed;
|
|
129
114
|
left: 50%;
|
|
@@ -89,12 +89,16 @@ vi.mock('./widgets/widgetProps.js', () => ({
|
|
|
89
89
|
getDefaults: () => ({}),
|
|
90
90
|
}))
|
|
91
91
|
|
|
92
|
-
vi.mock('./widgets/widgetConfig.js', () =>
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
}
|
|
92
|
+
vi.mock('./widgets/widgetConfig.js', async () => {
|
|
93
|
+
const actual = await vi.importActual('./widgets/widgetConfig.js')
|
|
94
|
+
return {
|
|
95
|
+
getFeatures: () => [],
|
|
96
|
+
isResizable: () => false,
|
|
97
|
+
schemas: {},
|
|
98
|
+
getMenuWidgetTypes: () => [],
|
|
99
|
+
getConnectorDefaults: actual.getConnectorDefaults,
|
|
100
|
+
}
|
|
101
|
+
})
|
|
98
102
|
|
|
99
103
|
vi.mock('./widgets/figmaUrl.js', () => ({
|
|
100
104
|
isFigmaUrl: () => false,
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
|
2
|
-
import { render, screen, fireEvent } from '@testing-library/react'
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
|
2
|
+
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
|
|
3
3
|
import PageSelector from './PageSelector.jsx'
|
|
4
4
|
|
|
5
5
|
const PAGES = [
|
|
@@ -10,9 +10,15 @@ const PAGES = [
|
|
|
10
10
|
|
|
11
11
|
describe('PageSelector', () => {
|
|
12
12
|
beforeEach(() => {
|
|
13
|
-
// Reset location mock
|
|
13
|
+
// Reset location mock — use a setter spy so we can track assignments
|
|
14
14
|
delete window.location
|
|
15
|
-
|
|
15
|
+
const loc = { _href: '' }
|
|
16
|
+
Object.defineProperty(loc, 'href', {
|
|
17
|
+
get() { return loc._href },
|
|
18
|
+
set(v) { loc._href = v },
|
|
19
|
+
configurable: true,
|
|
20
|
+
})
|
|
21
|
+
window.location = loc
|
|
16
22
|
})
|
|
17
23
|
|
|
18
24
|
it('renders nothing when fewer than 2 pages', () => {
|
|
@@ -58,14 +64,17 @@ describe('PageSelector', () => {
|
|
|
58
64
|
expect(options[0].getAttribute('aria-selected')).toBe('false')
|
|
59
65
|
})
|
|
60
66
|
|
|
61
|
-
it('navigates to selected page', () => {
|
|
67
|
+
it('navigates to selected page', async () => {
|
|
62
68
|
render(<PageSelector currentName="research/interviews" pages={PAGES} />)
|
|
63
69
|
fireEvent.click(screen.getByTitle('Switch canvas page'))
|
|
64
70
|
// Click the option in the menu (not the trigger label)
|
|
65
71
|
const options = screen.getAllByRole('option')
|
|
66
72
|
fireEvent.click(options[1]) // Surveys
|
|
67
73
|
|
|
68
|
-
|
|
74
|
+
// Navigation uses a 300ms setTimeout for mouse clicks
|
|
75
|
+
await waitFor(() => {
|
|
76
|
+
expect(window.location.href).toContain('/canvas/research/surveys')
|
|
77
|
+
})
|
|
69
78
|
})
|
|
70
79
|
|
|
71
80
|
it('closes dropdown on Escape', () => {
|
|
@@ -59,7 +59,7 @@ const ImageWidget = forwardRef(function ImageWidget({ props, onUpdate, resizable
|
|
|
59
59
|
const url = getImageUrl(src)
|
|
60
60
|
const a = document.createElement('a')
|
|
61
61
|
a.href = url
|
|
62
|
-
a.download = src.replace(
|
|
62
|
+
a.download = src.replace(/^~/, '')
|
|
63
63
|
document.body.appendChild(a)
|
|
64
64
|
a.click()
|
|
65
65
|
document.body.removeChild(a)
|
|
@@ -6,7 +6,7 @@ import ResizeHandle from './ResizeHandle.jsx'
|
|
|
6
6
|
import { readProp, prototypeEmbedSchema } from './widgetProps.js'
|
|
7
7
|
import { getEmbedChromeVars } from './embedTheme.js'
|
|
8
8
|
import { useIframeDevLogs } from './iframeDevLogs.js'
|
|
9
|
-
import {
|
|
9
|
+
import { useEmbedsPaused } from './useEmbedsPaused.js'
|
|
10
10
|
import styles from './PrototypeEmbed.module.css'
|
|
11
11
|
import overlayStyles from './embedOverlay.module.css'
|
|
12
12
|
|
|
@@ -68,13 +68,14 @@ export default forwardRef(function PrototypeEmbed({ id: widgetId, props, onUpdat
|
|
|
68
68
|
const [expanded, setExpanded] = useState(false)
|
|
69
69
|
const [filter, setFilter] = useState('')
|
|
70
70
|
const [canvasTheme, setCanvasTheme] = useState(() => resolveCanvasThemeFromStorage())
|
|
71
|
+
const embedsPaused = useEmbedsPaused()
|
|
72
|
+
const frozenSrcRef = useRef(null)
|
|
71
73
|
const inputRef = useRef(null)
|
|
72
74
|
const filterRef = useRef(null)
|
|
73
75
|
const embedRef = useRef(null)
|
|
74
76
|
const iframeRef = useRef(null)
|
|
75
77
|
const inlineContainerRef = useRef(null)
|
|
76
78
|
const modalContainerRef = useRef(null)
|
|
77
|
-
const { active: embedActive, activate: activateEmbed, performanceMode, tooMany } = useEmbedActive(widgetId, embedRef)
|
|
78
79
|
|
|
79
80
|
const iframeSrc = useMemo(() => {
|
|
80
81
|
if (!rawSrc) return ''
|
|
@@ -86,7 +87,15 @@ export default forwardRef(function PrototypeEmbed({ id: widgetId, props, onUpdat
|
|
|
86
87
|
return `${base}${sep}_sb_embed&_sb_hide_branch_bar&_sb_theme_target=prototype&_sb_canvas_theme=${canvasTheme}${hash}`
|
|
87
88
|
}, [rawSrc, canvasTheme])
|
|
88
89
|
|
|
89
|
-
|
|
90
|
+
// When paused and not interactive, freeze the iframe src to prevent reloads
|
|
91
|
+
const effectiveSrc = (() => {
|
|
92
|
+
if (!embedsPaused || interactive) {
|
|
93
|
+
frozenSrcRef.current = iframeSrc
|
|
94
|
+
return iframeSrc
|
|
95
|
+
}
|
|
96
|
+
if (frozenSrcRef.current == null) frozenSrcRef.current = iframeSrc
|
|
97
|
+
return frozenSrcRef.current
|
|
98
|
+
})()
|
|
90
99
|
|
|
91
100
|
const prototypeIndex = useMemo(() => {
|
|
92
101
|
try { return buildPrototypeIndex() }
|
|
@@ -157,8 +166,8 @@ export default forwardRef(function PrototypeEmbed({ id: widgetId, props, onUpdat
|
|
|
157
166
|
|
|
158
167
|
useIframeDevLogs({
|
|
159
168
|
widget: 'PrototypeEmbed',
|
|
160
|
-
loaded: Boolean(
|
|
161
|
-
src:
|
|
169
|
+
loaded: Boolean(effectiveSrc && interactive),
|
|
170
|
+
src: effectiveSrc,
|
|
162
171
|
})
|
|
163
172
|
|
|
164
173
|
useEffect(() => {
|
|
@@ -299,7 +308,7 @@ export default forwardRef(function PrototypeEmbed({ id: widgetId, props, onUpdat
|
|
|
299
308
|
className={styles.embed}
|
|
300
309
|
style={{ width, height, ...chromeVars }}
|
|
301
310
|
>
|
|
302
|
-
<div className={`${styles.header}${
|
|
311
|
+
<div className={`${styles.header}${embedsPaused && !interactive ? ` ${styles.headerPaused}` : ''}`}>
|
|
303
312
|
<span className={styles.headerIcon}><CollageFrameIcon size={16} /></span>
|
|
304
313
|
<span className={styles.headerTitle}>{prototypeTitle}</span>
|
|
305
314
|
</div>
|
|
@@ -357,17 +366,6 @@ export default forwardRef(function PrototypeEmbed({ id: widgetId, props, onUpdat
|
|
|
357
366
|
</div>
|
|
358
367
|
</form>
|
|
359
368
|
</div>
|
|
360
|
-
) : iframeSrc && inactive ? (
|
|
361
|
-
<div
|
|
362
|
-
className={overlayStyles.interactOverlay}
|
|
363
|
-
onClick={() => { activateEmbed(); enterInteractive() }}
|
|
364
|
-
role="button"
|
|
365
|
-
tabIndex={0}
|
|
366
|
-
onKeyDown={(e) => { if (e.key === 'Enter') { activateEmbed(); enterInteractive() } }}
|
|
367
|
-
aria-label="Click to refresh"
|
|
368
|
-
>
|
|
369
|
-
<span className={overlayStyles.interactHint}>Click to refresh</span>
|
|
370
|
-
</div>
|
|
371
369
|
) : iframeSrc ? (
|
|
372
370
|
<>
|
|
373
371
|
<div
|
|
@@ -377,7 +375,7 @@ export default forwardRef(function PrototypeEmbed({ id: widgetId, props, onUpdat
|
|
|
377
375
|
>
|
|
378
376
|
<iframe
|
|
379
377
|
ref={iframeRef}
|
|
380
|
-
src={
|
|
378
|
+
src={effectiveSrc}
|
|
381
379
|
className={styles.iframe}
|
|
382
380
|
style={{
|
|
383
381
|
width: width / scale,
|
|
@@ -4,7 +4,7 @@ import { getEmbedChromeVars } from './embedTheme.js'
|
|
|
4
4
|
describe('getEmbedChromeVars', () => {
|
|
5
5
|
it('follows toolbar theme variants for embed edit chrome', () => {
|
|
6
6
|
expect(getEmbedChromeVars('light')['--bgColor-default']).toBe('#ffffff')
|
|
7
|
-
expect(getEmbedChromeVars('dark')['--bgColor-default']).toBe('#
|
|
8
|
-
expect(getEmbedChromeVars('dark_dimmed')['--bgColor-default']).toBe('#
|
|
7
|
+
expect(getEmbedChromeVars('dark')['--bgColor-default']).toBe('#0d1117')
|
|
8
|
+
expect(getEmbedChromeVars('dark_dimmed')['--bgColor-default']).toBe('#212830')
|
|
9
9
|
})
|
|
10
10
|
})
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
*/
|
|
11
11
|
import { forwardRef, useImperativeHandle, useRef, useCallback, useState, useEffect, useMemo } from 'react'
|
|
12
12
|
import { getStoryData } from '@dfosco/storyboard-core'
|
|
13
|
-
import {
|
|
13
|
+
import { useEmbedsPaused } from './useEmbedsPaused.js'
|
|
14
14
|
import { createInspectorHighlighter } from '@dfosco/storyboard-core/inspector/highlighter'
|
|
15
15
|
import WidgetWrapper from './WidgetWrapper.jsx'
|
|
16
16
|
import ResizeHandle from './ResizeHandle.jsx'
|
|
@@ -66,7 +66,8 @@ export default forwardRef(function StoryWidget({ id: widgetId, props, onUpdate,
|
|
|
66
66
|
const [highlightedHtml, setHighlightedHtml] = useState(null)
|
|
67
67
|
const [sourceLoading, setSourceLoading] = useState(false)
|
|
68
68
|
const [storyIndexKey, setStoryIndexKey] = useState(0)
|
|
69
|
-
const
|
|
69
|
+
const embedsPaused = useEmbedsPaused()
|
|
70
|
+
const frozenSrcRef = useRef(null)
|
|
70
71
|
|
|
71
72
|
// Re-resolve story URL when the story index is live-patched
|
|
72
73
|
useEffect(() => {
|
|
@@ -173,17 +174,24 @@ export default forwardRef(function StoryWidget({ id: widgetId, props, onUpdate,
|
|
|
173
174
|
[storyId, exportName, storyIndexKey],
|
|
174
175
|
)
|
|
175
176
|
|
|
176
|
-
//
|
|
177
|
-
const
|
|
177
|
+
// When paused and not interactive, freeze the iframe src to prevent reloads
|
|
178
|
+
const effectiveSrc = (() => {
|
|
179
|
+
if (!embedsPaused || interactive) {
|
|
180
|
+
frozenSrcRef.current = iframeSrc
|
|
181
|
+
return iframeSrc
|
|
182
|
+
}
|
|
183
|
+
// Paused & not interactive — use frozen src (or current if first time)
|
|
184
|
+
if (frozenSrcRef.current == null) frozenSrcRef.current = iframeSrc
|
|
185
|
+
return frozenSrcRef.current
|
|
186
|
+
})()
|
|
178
187
|
|
|
179
188
|
useIframeDevLogs({
|
|
180
189
|
widget: 'StoryWidget',
|
|
181
|
-
loaded: interactive && !showCode && Boolean(
|
|
182
|
-
src:
|
|
190
|
+
loaded: interactive && !showCode && Boolean(effectiveSrc),
|
|
191
|
+
src: effectiveSrc,
|
|
183
192
|
})
|
|
184
193
|
|
|
185
194
|
const displayName = exportName ? `${storyId} / ${exportName}` : storyId
|
|
186
|
-
const inactive = !embedActive
|
|
187
195
|
|
|
188
196
|
if (!storyId) {
|
|
189
197
|
return (
|
|
@@ -198,7 +206,7 @@ export default forwardRef(function StoryWidget({ id: widgetId, props, onUpdate,
|
|
|
198
206
|
)
|
|
199
207
|
}
|
|
200
208
|
|
|
201
|
-
if (!
|
|
209
|
+
if (!effectiveSrc) {
|
|
202
210
|
return (
|
|
203
211
|
<WidgetWrapper>
|
|
204
212
|
<div className={styles.container} ref={containerRef}>
|
|
@@ -218,7 +226,7 @@ export default forwardRef(function StoryWidget({ id: widgetId, props, onUpdate,
|
|
|
218
226
|
return (
|
|
219
227
|
<WidgetWrapper>
|
|
220
228
|
<div ref={containerRef} className={styles.container} style={sizeStyle}>
|
|
221
|
-
<div className={`${styles.header}${
|
|
229
|
+
<div className={`${styles.header}${embedsPaused && !interactive ? ` ${styles.headerPaused}` : ''}`}>
|
|
222
230
|
<span className={styles.headerIcon}><ComponentIcon size={16} /></span>
|
|
223
231
|
<span className={styles.headerTitle}>{displayName}</span>
|
|
224
232
|
</div>
|
|
@@ -242,23 +250,12 @@ export default forwardRef(function StoryWidget({ id: widgetId, props, onUpdate,
|
|
|
242
250
|
<pre className={styles.codeBlock}><code>{sourceCode || ''}</code></pre>
|
|
243
251
|
)}
|
|
244
252
|
</div>
|
|
245
|
-
) : inactive ? (
|
|
246
|
-
<div
|
|
247
|
-
className={overlayStyles.interactOverlay}
|
|
248
|
-
onClick={() => { activateEmbed(); enterInteractive() }}
|
|
249
|
-
role="button"
|
|
250
|
-
tabIndex={0}
|
|
251
|
-
onKeyDown={(e) => { if (e.key === 'Enter') { activateEmbed(); enterInteractive() } }}
|
|
252
|
-
aria-label="Click to refresh"
|
|
253
|
-
>
|
|
254
|
-
<span className={overlayStyles.interactHint}>Click to refresh</span>
|
|
255
|
-
</div>
|
|
256
253
|
) : (
|
|
257
254
|
<>
|
|
258
255
|
<div className={styles.content}>
|
|
259
256
|
<iframe
|
|
260
257
|
ref={iframeRef}
|
|
261
|
-
src={
|
|
258
|
+
src={effectiveSrc}
|
|
262
259
|
className={styles.iframe}
|
|
263
260
|
title={displayName}
|
|
264
261
|
/>
|