@dfosco/storyboard-react 4.0.0-beta.14 → 4.0.0-beta.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/canvas/CanvasControls.jsx +51 -2
- package/src/canvas/CanvasControls.module.css +31 -0
- package/src/canvas/CanvasPage.jsx +77 -109
- package/src/canvas/CanvasPage.module.css +3 -47
- package/src/canvas/PageSelector.jsx +102 -0
- package/src/canvas/PageSelector.module.css +93 -0
- package/src/canvas/PageSelector.test.jsx +104 -0
- package/src/canvas/componentIsolate.jsx +3 -3
- package/src/canvas/widgets/FigmaEmbed.jsx +6 -1
- package/src/canvas/widgets/MarkdownBlock.jsx +84 -4
- package/src/canvas/widgets/MarkdownBlock.module.css +30 -4
- package/src/canvas/widgets/PrototypeEmbed.jsx +177 -38
- package/src/canvas/widgets/PrototypeEmbed.module.css +34 -0
- package/src/canvas/widgets/StickyNote.module.css +5 -0
- package/src/canvas/widgets/StoryWidget.jsx +438 -0
- package/src/canvas/widgets/StoryWidget.module.css +200 -0
- package/src/canvas/widgets/WidgetChrome.jsx +30 -3
- package/src/canvas/widgets/index.js +2 -0
- package/src/canvas/widgets/pasteRules.js +295 -0
- package/src/canvas/widgets/pasteRules.test.js +474 -0
- package/src/canvas/widgets/widgetConfig.js +1 -1
- package/src/canvas/widgets/widgetConfig.test.js +4 -1
- package/src/context.jsx +138 -13
- package/src/story/StoryPage.jsx +152 -0
- package/src/story/StoryPage.module.css +73 -0
- package/src/vite/data-plugin.js +234 -27
- package/src/vite/data-plugin.test.js +179 -4
package/package.json
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dfosco/storyboard-react",
|
|
3
|
-
"version": "4.0.0-beta.
|
|
3
|
+
"version": "4.0.0-beta.16",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"dependencies": {
|
|
6
|
-
"@dfosco/storyboard-core": "4.0.0-beta.
|
|
7
|
-
"@dfosco/tiny-canvas": "4.0.0-beta.
|
|
6
|
+
"@dfosco/storyboard-core": "4.0.0-beta.16",
|
|
7
|
+
"@dfosco/tiny-canvas": "4.0.0-beta.16",
|
|
8
8
|
"@neodrag/react": "^2.3.1",
|
|
9
9
|
"glob": "^11.0.0",
|
|
10
10
|
"jsonc-parser": "^3.3.1",
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { useState, useRef, useEffect, useCallback } from 'react'
|
|
2
2
|
import { getMenuWidgetTypes } from './widgets/widgetConfig.js'
|
|
3
|
+
import { listStories, getStoryData } from '@dfosco/storyboard-core'
|
|
3
4
|
import styles from './CanvasControls.module.css'
|
|
4
5
|
|
|
5
6
|
const WIDGET_TYPES = getMenuWidgetTypes()
|
|
@@ -9,6 +10,7 @@ const WIDGET_TYPES = getMenuWidgetTypes()
|
|
|
9
10
|
*/
|
|
10
11
|
export default function CanvasControls({ onAddWidget }) {
|
|
11
12
|
const [menuOpen, setMenuOpen] = useState(false)
|
|
13
|
+
const [storyPicker, setStoryPicker] = useState(false)
|
|
12
14
|
const menuRef = useRef(null)
|
|
13
15
|
|
|
14
16
|
// Close menu on outside click
|
|
@@ -17,6 +19,7 @@ export default function CanvasControls({ onAddWidget }) {
|
|
|
17
19
|
function handlePointerDown(e) {
|
|
18
20
|
if (menuRef.current && !menuRef.current.contains(e.target)) {
|
|
19
21
|
setMenuOpen(false)
|
|
22
|
+
setStoryPicker(false)
|
|
20
23
|
}
|
|
21
24
|
}
|
|
22
25
|
document.addEventListener('pointerdown', handlePointerDown)
|
|
@@ -26,14 +29,23 @@ export default function CanvasControls({ onAddWidget }) {
|
|
|
26
29
|
const handleAddWidget = useCallback((type) => {
|
|
27
30
|
onAddWidget(type)
|
|
28
31
|
setMenuOpen(false)
|
|
32
|
+
setStoryPicker(false)
|
|
29
33
|
}, [onAddWidget])
|
|
30
34
|
|
|
35
|
+
const handleAddStory = useCallback((storyId) => {
|
|
36
|
+
document.dispatchEvent(new CustomEvent('storyboard:canvas:add-story-widget', { detail: { storyId } }))
|
|
37
|
+
setMenuOpen(false)
|
|
38
|
+
setStoryPicker(false)
|
|
39
|
+
}, [])
|
|
40
|
+
|
|
41
|
+
const storyNames = storyPicker ? listStories() : []
|
|
42
|
+
|
|
31
43
|
return (
|
|
32
44
|
<div className={styles.toolbar} role="toolbar" aria-label="Canvas controls">
|
|
33
45
|
<div ref={menuRef} className={styles.createGroup}>
|
|
34
46
|
<button
|
|
35
47
|
className={styles.btn}
|
|
36
|
-
onClick={() => setMenuOpen((v) => !v)}
|
|
48
|
+
onClick={() => { setMenuOpen((v) => !v); setStoryPicker(false) }}
|
|
37
49
|
aria-label="Add widget"
|
|
38
50
|
aria-expanded={menuOpen}
|
|
39
51
|
title="Add widget"
|
|
@@ -42,7 +54,7 @@ export default function CanvasControls({ onAddWidget }) {
|
|
|
42
54
|
<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" />
|
|
43
55
|
</svg>
|
|
44
56
|
</button>
|
|
45
|
-
{menuOpen && (
|
|
57
|
+
{menuOpen && !storyPicker && (
|
|
46
58
|
<div className={styles.menu} role="menu">
|
|
47
59
|
<div className={styles.menuLabel}>Add to canvas</div>
|
|
48
60
|
{WIDGET_TYPES.map((wt) => (
|
|
@@ -55,6 +67,43 @@ export default function CanvasControls({ onAddWidget }) {
|
|
|
55
67
|
{wt.label}
|
|
56
68
|
</button>
|
|
57
69
|
))}
|
|
70
|
+
<div className={styles.menuDivider} />
|
|
71
|
+
<button
|
|
72
|
+
className={styles.menuItem}
|
|
73
|
+
role="menuitem"
|
|
74
|
+
onClick={() => setStoryPicker(true)}
|
|
75
|
+
>
|
|
76
|
+
📖 Component
|
|
77
|
+
</button>
|
|
78
|
+
</div>
|
|
79
|
+
)}
|
|
80
|
+
{menuOpen && storyPicker && (
|
|
81
|
+
<div className={styles.menu} role="menu">
|
|
82
|
+
<div className={styles.menuLabel}>
|
|
83
|
+
<button
|
|
84
|
+
className={styles.backBtn}
|
|
85
|
+
onClick={() => setStoryPicker(false)}
|
|
86
|
+
aria-label="Back"
|
|
87
|
+
>←</button>
|
|
88
|
+
Select component
|
|
89
|
+
</div>
|
|
90
|
+
{storyNames.length === 0 && (
|
|
91
|
+
<div className={styles.menuEmpty}>No stories found</div>
|
|
92
|
+
)}
|
|
93
|
+
{storyNames.map((name) => {
|
|
94
|
+
const story = getStoryData(name)
|
|
95
|
+
return (
|
|
96
|
+
<button
|
|
97
|
+
key={name}
|
|
98
|
+
className={styles.menuItem}
|
|
99
|
+
role="menuitem"
|
|
100
|
+
onClick={() => handleAddStory(name)}
|
|
101
|
+
>
|
|
102
|
+
{name}
|
|
103
|
+
{story?._route && <span className={styles.menuHint}>{story._route}</span>}
|
|
104
|
+
</button>
|
|
105
|
+
)
|
|
106
|
+
})}
|
|
58
107
|
</div>
|
|
59
108
|
)}
|
|
60
109
|
</div>
|
|
@@ -102,3 +102,34 @@
|
|
|
102
102
|
.menuItem:hover {
|
|
103
103
|
background: var(--bgColor-muted, #f6f8fa);
|
|
104
104
|
}
|
|
105
|
+
|
|
106
|
+
.menuDivider {
|
|
107
|
+
height: 1px;
|
|
108
|
+
margin: 4px 8px;
|
|
109
|
+
background: var(--borderColor-muted, rgba(0, 0, 0, 0.1));
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
.menuHint {
|
|
113
|
+
font-size: 11px;
|
|
114
|
+
color: var(--fgColor-muted, #656d76);
|
|
115
|
+
margin-left: 8px;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
.menuEmpty {
|
|
119
|
+
padding: 8px 10px;
|
|
120
|
+
font-size: 12px;
|
|
121
|
+
color: var(--fgColor-muted, #656d76);
|
|
122
|
+
font-style: italic;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
.backBtn {
|
|
126
|
+
all: unset;
|
|
127
|
+
cursor: pointer;
|
|
128
|
+
margin-right: 4px;
|
|
129
|
+
font-size: 13px;
|
|
130
|
+
color: var(--fgColor-muted, #656d76);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
.backBtn:hover {
|
|
134
|
+
color: var(--fgColor-default, #1f2328);
|
|
135
|
+
}
|
|
@@ -8,11 +8,14 @@ import { getCanvasThemeVars, getCanvasPrimerAttrs } from './canvasTheme.js'
|
|
|
8
8
|
import { getWidgetComponent } from './widgets/index.js'
|
|
9
9
|
import { schemas, getDefaults } from './widgets/widgetProps.js'
|
|
10
10
|
import { getFeatures, isResizable } from './widgets/widgetConfig.js'
|
|
11
|
-
import {
|
|
11
|
+
import { createPasteContext, resolvePaste } from './widgets/pasteRules.js'
|
|
12
|
+
import { getPasteRules } from '@dfosco/storyboard-core'
|
|
12
13
|
import WidgetChrome from './widgets/WidgetChrome.jsx'
|
|
13
14
|
import ComponentWidget from './widgets/ComponentWidget.jsx'
|
|
14
15
|
import useUndoRedo from './useUndoRedo.js'
|
|
15
16
|
import { addWidget as addWidgetApi, updateCanvas, removeWidget as removeWidgetApi, uploadImage, getCanvas as getCanvasApi } from './canvasApi.js'
|
|
17
|
+
import PageSelector from './PageSelector.jsx'
|
|
18
|
+
import { stories as storyIndex } from 'virtual:storyboard-data-index'
|
|
16
19
|
import styles from './CanvasPage.module.css'
|
|
17
20
|
|
|
18
21
|
const ZOOM_MIN = 25
|
|
@@ -23,6 +26,14 @@ const CANVAS_BRIDGE_STATE_KEY = '__storyboardCanvasBridgeState'
|
|
|
23
26
|
/** Matches branch-deploy base path prefixes like /branch--my-feature/ */
|
|
24
27
|
const BRANCH_PREFIX_RE = /^\/branch--[^/]+/
|
|
25
28
|
|
|
29
|
+
// Build a reverse map from story route paths → { storyId, route }
|
|
30
|
+
const storyRouteIndex = new Map()
|
|
31
|
+
for (const [storyId, data] of Object.entries(storyIndex || {})) {
|
|
32
|
+
if (data?._route) {
|
|
33
|
+
storyRouteIndex.set(data._route.replace(/\/+$/, ''), storyId)
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
26
37
|
function getToolbarColorMode(theme) {
|
|
27
38
|
return String(theme || 'light').startsWith('dark') ? 'dark' : 'light'
|
|
28
39
|
}
|
|
@@ -265,6 +276,9 @@ function ChromeWrappedWidget({
|
|
|
265
276
|
onRemove?.(widget.id)
|
|
266
277
|
} else if (actionId === 'copy') {
|
|
267
278
|
onCopy?.(widget)
|
|
279
|
+
} else if (actionId === 'copy-text') {
|
|
280
|
+
const text = widget.props?.text || widget.props?.content || ''
|
|
281
|
+
navigator.clipboard?.writeText(text).catch(() => {})
|
|
268
282
|
}
|
|
269
283
|
}, [widget, onRemove, onCopy])
|
|
270
284
|
|
|
@@ -298,7 +312,7 @@ function ChromeWrappedWidget({
|
|
|
298
312
|
*
|
|
299
313
|
* @param {{ name: string }} props - Canvas name as indexed by the data plugin
|
|
300
314
|
*/
|
|
301
|
-
export default function CanvasPage({ name }) {
|
|
315
|
+
export default function CanvasPage({ name, siblingPages = [], canvasMeta = null }) {
|
|
302
316
|
const { canvas, jsxExports, jsxError, loading } = useCanvas(name)
|
|
303
317
|
const isLocalDev = typeof window !== 'undefined' && window.__SB_LOCAL_DEV__ === true && !new URLSearchParams(window.location.search).has('prodMode')
|
|
304
318
|
|
|
@@ -311,8 +325,10 @@ export default function CanvasPage({ name }) {
|
|
|
311
325
|
const zoomRef = useRef(initialViewport?.zoom ?? 100)
|
|
312
326
|
const scrollRef = useRef(null)
|
|
313
327
|
const pendingScrollRestore = useRef(initialViewport)
|
|
314
|
-
|
|
315
|
-
|
|
328
|
+
// Gate viewport persistence until initial positioning is complete (restore,
|
|
329
|
+
// ?widget= deep-link, or first visit). Prevents early save effects from
|
|
330
|
+
// overwriting the saved scroll position with 0,0.
|
|
331
|
+
const viewportInitDone = useRef(false)
|
|
316
332
|
const [localSources, setLocalSources] = useState(canvas?.sources ?? [])
|
|
317
333
|
const [canvasTheme, setCanvasTheme] = useState(() => resolveCanvasThemeFromStorage())
|
|
318
334
|
const [snapEnabled, setSnapEnabled] = useState(canvas?.snapToGrid ?? false)
|
|
@@ -449,10 +465,13 @@ export default function CanvasPage({ name }) {
|
|
|
449
465
|
setTrackedCanvas(canvas)
|
|
450
466
|
setLocalWidgets(canvas?.widgets ?? null)
|
|
451
467
|
setLocalSources(canvas?.sources ?? [])
|
|
452
|
-
setCanvasTitle(canvas?.title || name)
|
|
453
468
|
setSnapEnabled(canvas?.snapToGrid ?? false)
|
|
454
469
|
setSnapGridSize(canvas?.gridSize || 40)
|
|
455
470
|
undoRedo.reset()
|
|
471
|
+
// Reset viewport init gate so save effects don't persist stale positions
|
|
472
|
+
// while the new canvas's viewport is being restored.
|
|
473
|
+
viewportInitDone.current = false
|
|
474
|
+
pendingScrollRestore.current = loadViewportState(name)
|
|
456
475
|
}
|
|
457
476
|
|
|
458
477
|
// Debounced save to server
|
|
@@ -464,27 +483,6 @@ export default function CanvasPage({ name }) {
|
|
|
464
483
|
}, 2000)
|
|
465
484
|
).current
|
|
466
485
|
|
|
467
|
-
const debouncedTitleSave = useRef(
|
|
468
|
-
debounce((canvasName, title) => {
|
|
469
|
-
updateCanvas(canvasName, { settings: { title } }).catch((err) =>
|
|
470
|
-
console.error('[canvas] Failed to save title:', err)
|
|
471
|
-
)
|
|
472
|
-
}, 1000)
|
|
473
|
-
).current
|
|
474
|
-
|
|
475
|
-
const handleTitleChange = useCallback((e) => {
|
|
476
|
-
const newTitle = e.target.value
|
|
477
|
-
setCanvasTitle(newTitle)
|
|
478
|
-
debouncedTitleSave(name, newTitle)
|
|
479
|
-
}, [name, debouncedTitleSave])
|
|
480
|
-
|
|
481
|
-
const handleTitleKeyDown = useCallback((e) => {
|
|
482
|
-
if (e.key === 'Enter') {
|
|
483
|
-
e.target.blur()
|
|
484
|
-
}
|
|
485
|
-
e.stopPropagation()
|
|
486
|
-
}, [])
|
|
487
|
-
|
|
488
486
|
const handleWidgetUpdate = useCallback((widgetId, updates) => {
|
|
489
487
|
undoRedo.snapshot(stateRef.current, 'edit', widgetId)
|
|
490
488
|
// Snap width/height to grid when snap is enabled
|
|
@@ -697,12 +695,18 @@ export default function CanvasPage({ name }) {
|
|
|
697
695
|
// Restore scroll position from localStorage after first render
|
|
698
696
|
useEffect(() => {
|
|
699
697
|
const el = scrollRef.current
|
|
698
|
+
if (!el || loading) return
|
|
700
699
|
const saved = pendingScrollRestore.current
|
|
701
|
-
if (
|
|
700
|
+
if (saved) {
|
|
702
701
|
if (saved.scrollLeft != null) el.scrollLeft = saved.scrollLeft
|
|
703
702
|
if (saved.scrollTop != null) el.scrollTop = saved.scrollTop
|
|
704
703
|
pendingScrollRestore.current = null
|
|
705
704
|
}
|
|
705
|
+
// Mark viewport init complete so save effects can start persisting.
|
|
706
|
+
// This covers: restored saved position, first visit (no saved state),
|
|
707
|
+
// and name changes. The ?widget= effect below may override position
|
|
708
|
+
// and that's fine — it runs after this in the same commit.
|
|
709
|
+
viewportInitDone.current = true
|
|
706
710
|
}, [name, loading])
|
|
707
711
|
|
|
708
712
|
// Center on a specific widget if `?widget=<id>` is in the URL
|
|
@@ -754,6 +758,7 @@ export default function CanvasPage({ name }) {
|
|
|
754
758
|
|
|
755
759
|
// Persist viewport state (zoom + scroll) to localStorage on changes
|
|
756
760
|
useEffect(() => {
|
|
761
|
+
if (!viewportInitDone.current) return
|
|
757
762
|
const el = scrollRef.current
|
|
758
763
|
saveViewportState(name, {
|
|
759
764
|
zoom,
|
|
@@ -766,6 +771,7 @@ export default function CanvasPage({ name }) {
|
|
|
766
771
|
const el = scrollRef.current
|
|
767
772
|
if (!el) return
|
|
768
773
|
function handleScroll() {
|
|
774
|
+
if (!viewportInitDone.current) return
|
|
769
775
|
saveViewportState(name, {
|
|
770
776
|
zoom: zoomRef.current,
|
|
771
777
|
scrollLeft: el.scrollLeft,
|
|
@@ -776,6 +782,7 @@ export default function CanvasPage({ name }) {
|
|
|
776
782
|
|
|
777
783
|
// Flush viewport state on page unload so a refresh never misses it
|
|
778
784
|
function handleBeforeUnload() {
|
|
785
|
+
if (!viewportInitDone.current) return
|
|
779
786
|
saveViewportState(name, {
|
|
780
787
|
zoom: zoomRef.current,
|
|
781
788
|
scrollLeft: el.scrollLeft,
|
|
@@ -888,14 +895,41 @@ export default function CanvasPage({ name }) {
|
|
|
888
895
|
}
|
|
889
896
|
}, [name, undoRedo])
|
|
890
897
|
|
|
898
|
+
// Add a story widget by storyId — used by CanvasControls story picker
|
|
899
|
+
const addStoryWidget = useCallback(async (storyId) => {
|
|
900
|
+
const storyProps = { storyId, exportName: '', width: 600, height: 400 }
|
|
901
|
+
const center = getViewportCenter(scrollRef.current, zoomRef.current / 100)
|
|
902
|
+
const pos = centerPositionForWidget(center, 'story', storyProps)
|
|
903
|
+
try {
|
|
904
|
+
const result = await addWidgetApi(name, {
|
|
905
|
+
type: 'story',
|
|
906
|
+
props: storyProps,
|
|
907
|
+
position: pos,
|
|
908
|
+
})
|
|
909
|
+
if (result.success && result.widget) {
|
|
910
|
+
undoRedo.snapshot(stateRef.current, 'add')
|
|
911
|
+
setLocalWidgets((prev) => [...(prev || []), result.widget])
|
|
912
|
+
}
|
|
913
|
+
} catch (err) {
|
|
914
|
+
console.error('[canvas] Failed to add story widget:', err)
|
|
915
|
+
}
|
|
916
|
+
}, [name, undoRedo])
|
|
917
|
+
|
|
891
918
|
// Listen for CoreUIBar add-widget events
|
|
892
919
|
useEffect(() => {
|
|
893
920
|
function handleAddWidget(e) {
|
|
894
921
|
addWidget(e.detail.type)
|
|
895
922
|
}
|
|
923
|
+
function handleAddStoryWidget(e) {
|
|
924
|
+
addStoryWidget(e.detail.storyId)
|
|
925
|
+
}
|
|
896
926
|
document.addEventListener('storyboard:canvas:add-widget', handleAddWidget)
|
|
897
|
-
|
|
898
|
-
|
|
927
|
+
document.addEventListener('storyboard:canvas:add-story-widget', handleAddStoryWidget)
|
|
928
|
+
return () => {
|
|
929
|
+
document.removeEventListener('storyboard:canvas:add-widget', handleAddWidget)
|
|
930
|
+
document.removeEventListener('storyboard:canvas:add-story-widget', handleAddStoryWidget)
|
|
931
|
+
}
|
|
932
|
+
}, [addWidget, addStoryWidget])
|
|
899
933
|
|
|
900
934
|
// Listen for zoom changes from CoreUIBar
|
|
901
935
|
useEffect(() => {
|
|
@@ -1030,12 +1064,12 @@ export default function CanvasPage({ name }) {
|
|
|
1030
1064
|
setSelectedWidgetIds(new Set())
|
|
1031
1065
|
}
|
|
1032
1066
|
// Copy shortcut (single widget selected):
|
|
1033
|
-
// cmd+c → copy canvasName
|
|
1067
|
+
// cmd+c → copy canvasName::widgetId (for cross-canvas paste-duplicate)
|
|
1034
1068
|
const mod = e.metaKey || e.ctrlKey
|
|
1035
1069
|
if (mod && e.key === 'c' && !e.shiftKey && selectedWidgetIds.size === 1) {
|
|
1036
1070
|
const widgetId = [...selectedWidgetIds][0]
|
|
1037
1071
|
e.preventDefault()
|
|
1038
|
-
navigator.clipboard.writeText(`${name}
|
|
1072
|
+
navigator.clipboard.writeText(`${name}::${widgetId}`).catch(() => {})
|
|
1039
1073
|
}
|
|
1040
1074
|
if (e.key === 'Delete' || e.key === 'Backspace') {
|
|
1041
1075
|
e.preventDefault()
|
|
@@ -1072,43 +1106,7 @@ export default function CanvasPage({ name }) {
|
|
|
1072
1106
|
useEffect(() => {
|
|
1073
1107
|
const origin = window.location.origin
|
|
1074
1108
|
const basePath = (import.meta.env?.BASE_URL || '/').replace(/\/$/, '')
|
|
1075
|
-
const
|
|
1076
|
-
|
|
1077
|
-
// Check if a URL is same-origin, accounting for branch-deploy prefixes.
|
|
1078
|
-
// e.g. https://site.com/branch--my-feature/Proto and https://site.com/storyboard/Proto
|
|
1079
|
-
// are both same-origin prototype URLs.
|
|
1080
|
-
function isSameOriginPrototype(url) {
|
|
1081
|
-
if (!url.startsWith(origin)) return false
|
|
1082
|
-
if (url.startsWith(baseUrl)) return true
|
|
1083
|
-
// Match branch deploy URLs: origin + /branch--*/...
|
|
1084
|
-
const pathAfterOrigin = url.slice(origin.length)
|
|
1085
|
-
return BRANCH_PREFIX_RE.test(pathAfterOrigin)
|
|
1086
|
-
}
|
|
1087
|
-
|
|
1088
|
-
// Strip the base path (or any branch prefix) from a pathname to get a portable src.
|
|
1089
|
-
function extractPrototypeSrc(pathname) {
|
|
1090
|
-
// Strip current base path
|
|
1091
|
-
if (basePath && pathname.startsWith(basePath)) {
|
|
1092
|
-
return pathname.slice(basePath.length) || '/'
|
|
1093
|
-
}
|
|
1094
|
-
// Strip branch prefix: /branch--name/rest → /rest
|
|
1095
|
-
const branchMatch = pathname.match(BRANCH_PREFIX_RE)
|
|
1096
|
-
if (branchMatch) {
|
|
1097
|
-
return pathname.slice(branchMatch[0].length) || '/'
|
|
1098
|
-
}
|
|
1099
|
-
return pathname
|
|
1100
|
-
}
|
|
1101
|
-
|
|
1102
|
-
/** Parse text as a web URL (http/https only). Returns URL object or null. */
|
|
1103
|
-
function looksLikeWebUrl(text) {
|
|
1104
|
-
try {
|
|
1105
|
-
const url = new URL(text)
|
|
1106
|
-
if (url.protocol === 'http:' || url.protocol === 'https:') return url
|
|
1107
|
-
return null
|
|
1108
|
-
} catch {
|
|
1109
|
-
return null
|
|
1110
|
-
}
|
|
1111
|
-
}
|
|
1109
|
+
const pasteCtx = createPasteContext(origin, basePath)
|
|
1112
1110
|
|
|
1113
1111
|
function blobToDataUrl(blob) {
|
|
1114
1112
|
return new Promise((resolve, reject) => {
|
|
@@ -1209,11 +1207,12 @@ export default function CanvasPage({ name }) {
|
|
|
1209
1207
|
const text = e.clipboardData?.getData('text/plain')?.trim()
|
|
1210
1208
|
if (!text) return
|
|
1211
1209
|
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
//
|
|
1215
|
-
const widgetRefMatch = text.match(/^([^/]+)\/([
|
|
1210
|
+
// Detect canvasName::widgetId format for widget duplication (cross-canvas copy-paste)
|
|
1211
|
+
// Also supports legacy canvasName/widgetId for basenames without slashes,
|
|
1212
|
+
// but only when the second segment looks like a widget ID (type-hash).
|
|
1213
|
+
const widgetRefMatch = text.match(/^(.+)::([^:]+)$/) || (text.indexOf('::') === -1 && text.match(/^([^/]+)\/((?:sticky-note|markdown|prototype|link-preview|figma-embed|component|image)-[a-z0-9]+)$/))
|
|
1216
1214
|
if (widgetRefMatch) {
|
|
1215
|
+
e.preventDefault()
|
|
1217
1216
|
const [, sourceCanvas, sourceWidgetId] = widgetRefMatch
|
|
1218
1217
|
// Component widgets are code, not duplicable data — silently consume the ref
|
|
1219
1218
|
if (sourceWidgetId.startsWith('jsx-')) return
|
|
@@ -1245,25 +1244,10 @@ export default function CanvasPage({ name }) {
|
|
|
1245
1244
|
return
|
|
1246
1245
|
}
|
|
1247
1246
|
|
|
1248
|
-
|
|
1249
|
-
const
|
|
1250
|
-
if (
|
|
1251
|
-
|
|
1252
|
-
type = 'figma-embed'
|
|
1253
|
-
props = { url: sanitizeFigmaUrl(text), width: 800, height: 450 }
|
|
1254
|
-
} else if (isSameOriginPrototype(text)) {
|
|
1255
|
-
const pathPortion = url.pathname + url.search + url.hash
|
|
1256
|
-
const src = extractPrototypeSrc(pathPortion)
|
|
1257
|
-
type = 'prototype'
|
|
1258
|
-
props = { src: src || '/', originalSrc: src || '/', label: '', width: 800, height: 600 }
|
|
1259
|
-
} else {
|
|
1260
|
-
type = 'link-preview'
|
|
1261
|
-
props = { url: text, title: '' }
|
|
1262
|
-
}
|
|
1263
|
-
} else {
|
|
1264
|
-
type = 'markdown'
|
|
1265
|
-
props = { content: text }
|
|
1266
|
-
}
|
|
1247
|
+
e.preventDefault()
|
|
1248
|
+
const resolved = resolvePaste(text, pasteCtx, getPasteRules())
|
|
1249
|
+
if (!resolved) return
|
|
1250
|
+
const { type, props } = resolved
|
|
1267
1251
|
|
|
1268
1252
|
const center = getViewportCenter(scrollRef.current, zoomRef.current / 100)
|
|
1269
1253
|
const pos = centerPositionForWidget(center, type, props)
|
|
@@ -1616,24 +1600,8 @@ export default function CanvasPage({ name }) {
|
|
|
1616
1600
|
return (
|
|
1617
1601
|
<>
|
|
1618
1602
|
<div className={styles.canvasTitle}>
|
|
1619
|
-
<
|
|
1620
|
-
|
|
1621
|
-
{isLocalDev ? (
|
|
1622
|
-
<input
|
|
1623
|
-
ref={titleInputRef}
|
|
1624
|
-
className={styles.canvasTitleInput}
|
|
1625
|
-
value={canvasTitle}
|
|
1626
|
-
size={1}
|
|
1627
|
-
onChange={handleTitleChange}
|
|
1628
|
-
onKeyDown={handleTitleKeyDown}
|
|
1629
|
-
onMouseDown={(e) => e.stopPropagation()}
|
|
1630
|
-
spellCheck={false}
|
|
1631
|
-
aria-label="Canvas title"
|
|
1632
|
-
/>
|
|
1633
|
-
) : (
|
|
1634
|
-
<h1 className={styles.canvasTitleStatic}>{canvasTitle}</h1>
|
|
1635
|
-
)}
|
|
1636
|
-
</div>
|
|
1603
|
+
<h1 className={styles.canvasTitleStatic}>{canvasMeta?.title || canvas?.title || name.split('/').pop()}</h1>
|
|
1604
|
+
<PageSelector currentName={name} pages={siblingPages} />
|
|
1637
1605
|
{isLocalDev && (
|
|
1638
1606
|
<span className={styles.localEditingLabel}>Local editing</span>
|
|
1639
1607
|
)}
|
|
@@ -44,58 +44,14 @@
|
|
|
44
44
|
gap: 8px;
|
|
45
45
|
}
|
|
46
46
|
|
|
47
|
-
.
|
|
48
|
-
display: inline-grid;
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
.canvasTitleWrap > * {
|
|
52
|
-
grid-area: 1 / 1;
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
.canvasTitleMeasure {
|
|
56
|
-
visibility: hidden;
|
|
57
|
-
white-space: pre;
|
|
58
|
-
font-family: var(--tc-font-stack, system-ui, -apple-system, sans-serif);
|
|
59
|
-
font-size: 14px;
|
|
60
|
-
font-weight: 600;
|
|
61
|
-
padding: 4px 8px;
|
|
62
|
-
border: 1px solid transparent;
|
|
63
|
-
min-width: 80px;
|
|
64
|
-
pointer-events: none;
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
.canvasTitleInput {
|
|
47
|
+
.canvasTitleStatic {
|
|
68
48
|
font-family: var(--tc-font-stack, system-ui, -apple-system, sans-serif);
|
|
69
49
|
font-size: 14px;
|
|
70
50
|
font-weight: 600;
|
|
71
51
|
color: var(--fgColor-muted, #656d76);
|
|
72
|
-
background: transparent;
|
|
73
|
-
border: 1px solid transparent;
|
|
74
|
-
border-radius: 6px;
|
|
75
|
-
padding: 4px 8px;
|
|
76
52
|
margin: 0;
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
min-width: 0;
|
|
80
|
-
transition: border-color 150ms, background-color 150ms, color 150ms;
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
.canvasTitleInput:hover {
|
|
84
|
-
color: var(--fgColor-default, #1f2328);
|
|
85
|
-
border-color: var(--borderColor-default, #d1d9e0);
|
|
86
|
-
background: var(--bgColor-default, #ffffff);
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
.canvasTitleInput:focus {
|
|
90
|
-
color: var(--fgColor-default, #1f2328);
|
|
91
|
-
border-color: var(--bgColor-accent-emphasis, #2f81f7);
|
|
92
|
-
background: var(--bgColor-default, #ffffff);
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
.canvasTitleStatic {
|
|
96
|
-
composes: canvasTitleInput;
|
|
97
|
-
cursor: default;
|
|
98
|
-
pointer-events: none;
|
|
53
|
+
padding: 4px 8px;
|
|
54
|
+
white-space: nowrap;
|
|
99
55
|
}
|
|
100
56
|
|
|
101
57
|
/* Remove tiny-canvas wrapper clipping — widgets handle their own overflow/radius */
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { useCallback, useRef, useState, useEffect } from 'react'
|
|
2
|
+
import styles from './PageSelector.module.css'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* In-canvas page selector — shows sibling pages in the same canvas group.
|
|
6
|
+
* Only renders when 2+ sibling pages exist.
|
|
7
|
+
* Uses window.location for navigation to avoid requiring a Router context.
|
|
8
|
+
*
|
|
9
|
+
* @param {{ currentName: string, pages: Array<{ name: string, route: string, title: string }> }} props
|
|
10
|
+
*/
|
|
11
|
+
export default function PageSelector({ currentName, pages }) {
|
|
12
|
+
const [open, setOpen] = useState(false)
|
|
13
|
+
const containerRef = useRef(null)
|
|
14
|
+
|
|
15
|
+
const currentPage = pages.find((p) => p.name === currentName)
|
|
16
|
+
const currentLabel = currentPage?.title || currentName.split('/').pop()
|
|
17
|
+
const currentIndex = pages.findIndex((p) => p.name === currentName)
|
|
18
|
+
|
|
19
|
+
const handleSelect = useCallback(
|
|
20
|
+
(page) => {
|
|
21
|
+
if (page.name !== currentName) {
|
|
22
|
+
const base = (import.meta.env?.BASE_URL || '/').replace(/\/$/, '')
|
|
23
|
+
window.location.href = base + page.route
|
|
24
|
+
}
|
|
25
|
+
setOpen(false)
|
|
26
|
+
},
|
|
27
|
+
[currentName],
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
// Close on outside click
|
|
31
|
+
useEffect(() => {
|
|
32
|
+
if (!open) return
|
|
33
|
+
function handleClick(e) {
|
|
34
|
+
if (containerRef.current && !containerRef.current.contains(e.target)) {
|
|
35
|
+
setOpen(false)
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
document.addEventListener('mousedown', handleClick)
|
|
39
|
+
return () => document.removeEventListener('mousedown', handleClick)
|
|
40
|
+
}, [open])
|
|
41
|
+
|
|
42
|
+
// Close on Escape
|
|
43
|
+
useEffect(() => {
|
|
44
|
+
if (!open) return
|
|
45
|
+
function handleKey(e) {
|
|
46
|
+
if (e.key === 'Escape') setOpen(false)
|
|
47
|
+
}
|
|
48
|
+
document.addEventListener('keydown', handleKey)
|
|
49
|
+
return () => document.removeEventListener('keydown', handleKey)
|
|
50
|
+
}, [open])
|
|
51
|
+
|
|
52
|
+
if (!pages || pages.length < 2) return null
|
|
53
|
+
|
|
54
|
+
return (
|
|
55
|
+
<nav ref={containerRef} className={styles.container} aria-label="Canvas pages">
|
|
56
|
+
<button
|
|
57
|
+
className={styles.trigger}
|
|
58
|
+
onClick={() => setOpen((v) => !v)}
|
|
59
|
+
aria-expanded={open}
|
|
60
|
+
aria-haspopup="listbox"
|
|
61
|
+
title="Switch canvas page"
|
|
62
|
+
>
|
|
63
|
+
<span className={styles.label}>{currentLabel}</span>
|
|
64
|
+
<span className={styles.badge}>
|
|
65
|
+
{currentIndex + 1}/{pages.length}
|
|
66
|
+
</span>
|
|
67
|
+
<svg
|
|
68
|
+
className={`${styles.chevron} ${open ? styles.chevronOpen : ''}`}
|
|
69
|
+
width="12"
|
|
70
|
+
height="12"
|
|
71
|
+
viewBox="0 0 12 12"
|
|
72
|
+
fill="none"
|
|
73
|
+
aria-hidden="true"
|
|
74
|
+
>
|
|
75
|
+
<path d="M3 4.5L6 7.5L9 4.5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
|
|
76
|
+
</svg>
|
|
77
|
+
</button>
|
|
78
|
+
{open && (
|
|
79
|
+
<ul className={styles.menu} role="listbox" aria-label="Canvas pages">
|
|
80
|
+
{pages.map((page) => (
|
|
81
|
+
<li
|
|
82
|
+
key={page.name}
|
|
83
|
+
role="option"
|
|
84
|
+
aria-selected={page.name === currentName}
|
|
85
|
+
className={`${styles.item} ${page.name === currentName ? styles.itemActive : ''}`}
|
|
86
|
+
onClick={() => handleSelect(page)}
|
|
87
|
+
onKeyDown={(e) => {
|
|
88
|
+
if (e.key === 'Enter' || e.key === ' ') {
|
|
89
|
+
e.preventDefault()
|
|
90
|
+
handleSelect(page)
|
|
91
|
+
}
|
|
92
|
+
}}
|
|
93
|
+
tabIndex={0}
|
|
94
|
+
>
|
|
95
|
+
{page.title}
|
|
96
|
+
</li>
|
|
97
|
+
))}
|
|
98
|
+
</ul>
|
|
99
|
+
)}
|
|
100
|
+
</nav>
|
|
101
|
+
)
|
|
102
|
+
}
|