@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 CHANGED
@@ -1,10 +1,10 @@
1
1
  {
2
2
  "name": "@dfosco/storyboard-react",
3
- "version": "4.0.0-beta.14",
3
+ "version": "4.0.0-beta.16",
4
4
  "type": "module",
5
5
  "dependencies": {
6
- "@dfosco/storyboard-core": "4.0.0-beta.14",
7
- "@dfosco/tiny-canvas": "4.0.0-beta.14",
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 { isFigmaUrl, sanitizeFigmaUrl } from './widgets/figmaUrl.js'
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
- const [canvasTitle, setCanvasTitle] = useState(canvas?.title || name)
315
- const titleInputRef = useRef(null)
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 (el && saved) {
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
- return () => document.removeEventListener('storyboard:canvas:add-widget', handleAddWidget)
898
- }, [addWidget])
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/widgetId (for cross-canvas paste-duplicate)
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}/${widgetId}`).catch(() => {})
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 baseUrl = origin + basePath
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
- e.preventDefault()
1213
-
1214
- // Detect canvasName/widgetId format for widget duplication (cross-canvas copy-paste)
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
- let type, props
1249
- const url = looksLikeWebUrl(text)
1250
- if (url) {
1251
- if (isFigmaUrl(text)) {
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
- <div className={styles.canvasTitleWrap}>
1620
- <span className={styles.canvasTitleMeasure} aria-hidden="true">{canvasTitle || ' '}</span>
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
- .canvasTitleWrap {
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
- outline: none;
78
- width: 100%;
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
+ }