@dfosco/storyboard-react 3.2.0 → 3.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,9 +1,9 @@
1
1
  {
2
2
  "name": "@dfosco/storyboard-react",
3
- "version": "3.2.0",
3
+ "version": "3.3.1",
4
4
  "type": "module",
5
5
  "dependencies": {
6
- "@dfosco/storyboard-core": "3.2.0",
6
+ "@dfosco/storyboard-core": "3.3.1",
7
7
  "@dfosco/tiny-canvas": "^1.1.0",
8
8
  "@neodrag/react": "^2.3.1",
9
9
  "glob": "^11.0.0",
@@ -33,7 +33,7 @@ export default function Viewfinder({ pageModules = {}, basePath, title = 'Storyb
33
33
 
34
34
  let cancelled = false
35
35
 
36
- import('@dfosco/storyboard-core/ui/viewfinder').then(({ mountViewfinder, unmountViewfinder }) => {
36
+ import('@dfosco/storyboard-core/ui-runtime').then(({ mountViewfinder, unmountViewfinder }) => {
37
37
  if (cancelled) return
38
38
  // Ensure clean state for re-mounts
39
39
  unmountViewfinder()
@@ -8,7 +8,7 @@ export const ZOOM_MAX = ZOOM_STEPS[ZOOM_STEPS.length - 1]
8
8
  const WIDGET_TYPES = [
9
9
  { type: 'sticky-note', label: 'Sticky Note' },
10
10
  { type: 'markdown', label: 'Markdown' },
11
- { type: 'prototype', label: 'Prototype' },
11
+ { type: 'prototype', label: 'Prototype embed' },
12
12
  ]
13
13
 
14
14
  /**
@@ -1,5 +1,6 @@
1
1
  import { createElement, useCallback, useEffect, useRef, useState } from 'react'
2
2
  import { Canvas } from '@dfosco/tiny-canvas'
3
+ import '@dfosco/tiny-canvas/style.css'
3
4
  import { useCanvas } from './useCanvas.js'
4
5
  import { getWidgetComponent } from './widgets/index.js'
5
6
  import { schemas, getDefaults } from './widgets/widgetProps.js'
@@ -71,3 +71,8 @@
71
71
  border-color: var(--bgColor-accent-emphasis, #2f81f7);
72
72
  background: var(--bgColor-default, #ffffff);
73
73
  }
74
+
75
+ /* Remove tiny-canvas wrapper clipping — widgets handle their own overflow/radius */
76
+ :global(.tc-draggable-inner) {
77
+ overflow: visible;
78
+ }
@@ -6,7 +6,7 @@ import styles from './CanvasToolbar.module.css'
6
6
  const WIDGET_TYPES = [
7
7
  { type: 'sticky-note', label: 'Sticky Note', icon: '📝' },
8
8
  { type: 'markdown', label: 'Markdown', icon: '📄' },
9
- { type: 'prototype', label: 'Prototype', icon: '🖥️' },
9
+ { type: 'prototype', label: 'Prototype embed', icon: '🖥️' },
10
10
  ]
11
11
 
12
12
  /**
@@ -1,8 +1,15 @@
1
- import { useState, useRef, useEffect, useCallback } from 'react'
1
+ import { useState, useRef, useEffect, useCallback, useMemo } from 'react'
2
+ import { buildPrototypeIndex } from '@dfosco/storyboard-core'
2
3
  import WidgetWrapper from './WidgetWrapper.jsx'
3
4
  import { readProp, prototypeEmbedSchema } from './widgetProps.js'
4
5
  import styles from './PrototypeEmbed.module.css'
5
6
 
7
+ function formatName(name) {
8
+ return name
9
+ .replace(/[-_]/g, ' ')
10
+ .replace(/\b\w/g, (c) => c.toUpperCase())
11
+ }
12
+
6
13
  export default function PrototypeEmbed({ props, onUpdate }) {
7
14
  const src = readProp(props, 'src', prototypeEmbedSchema)
8
15
  const width = readProp(props, 'width', prototypeEmbedSchema)
@@ -18,15 +25,100 @@ export default function PrototypeEmbed({ props, onUpdate }) {
18
25
 
19
26
  const [editing, setEditing] = useState(false)
20
27
  const [interactive, setInteractive] = useState(false)
28
+ const [filter, setFilter] = useState('')
21
29
  const inputRef = useRef(null)
30
+ const filterRef = useRef(null)
22
31
  const embedRef = useRef(null)
23
32
 
33
+ // Build prototype index for the picker
34
+ const prototypeIndex = useMemo(() => {
35
+ try {
36
+ return buildPrototypeIndex()
37
+ } catch {
38
+ return { folders: [], prototypes: [], globalFlows: [], sorted: { title: { prototypes: [], folders: [] } } }
39
+ }
40
+ }, [])
41
+
42
+ // Build grouped picker entries from the prototype index
43
+ const pickerGroups = useMemo(() => {
44
+ const groups = []
45
+ const idx = prototypeIndex
46
+
47
+ // Collect all prototypes (from folders first, then ungrouped)
48
+ const allProtos = []
49
+ for (const folder of (idx.sorted?.title?.folders || idx.folders || [])) {
50
+ for (const proto of folder.prototypes || []) {
51
+ if (!proto.isExternal) allProtos.push(proto)
52
+ }
53
+ }
54
+ for (const proto of (idx.sorted?.title?.prototypes || idx.prototypes || [])) {
55
+ if (!proto.isExternal) allProtos.push(proto)
56
+ }
57
+
58
+ for (const proto of allProtos) {
59
+ if (proto.hideFlows && proto.flows.length === 1) {
60
+ groups.push({
61
+ label: proto.name,
62
+ items: [{ name: proto.name, route: proto.flows[0].route }],
63
+ })
64
+ } else if (proto.flows.length > 0) {
65
+ groups.push({
66
+ label: proto.name,
67
+ items: proto.flows.map((f) => ({
68
+ name: f.meta?.title || formatName(f.name),
69
+ route: f.route,
70
+ })),
71
+ })
72
+ } else {
73
+ groups.push({
74
+ label: proto.name,
75
+ items: [{ name: proto.name, route: `/${proto.dirName}` }],
76
+ })
77
+ }
78
+ }
79
+
80
+ // Global flows
81
+ const gf = idx.globalFlows || []
82
+ if (gf.length > 0) {
83
+ groups.push({
84
+ label: 'Other flows',
85
+ items: gf.map((f) => ({
86
+ name: f.meta?.title || formatName(f.name),
87
+ route: f.route,
88
+ })),
89
+ })
90
+ }
91
+
92
+ return groups
93
+ }, [prototypeIndex])
94
+
95
+ // Filter groups by search text
96
+ const filteredGroups = useMemo(() => {
97
+ if (!filter) return pickerGroups
98
+ const q = filter.toLowerCase()
99
+ return pickerGroups
100
+ .map((group) => {
101
+ const labelMatch = group.label.toLowerCase().includes(q)
102
+ if (labelMatch) return group
103
+ const matchedItems = group.items.filter((item) =>
104
+ item.name.toLowerCase().includes(q) || item.route.toLowerCase().includes(q)
105
+ )
106
+ if (matchedItems.length === 0) return null
107
+ return { ...group, items: matchedItems }
108
+ })
109
+ .filter(Boolean)
110
+ }, [pickerGroups, filter])
111
+
112
+ const hasPicker = pickerGroups.length > 0
113
+
24
114
  useEffect(() => {
25
- if (editing && inputRef.current) {
115
+ if (editing && hasPicker && filterRef.current) {
116
+ filterRef.current.focus()
117
+ } else if (editing && !hasPicker && inputRef.current) {
26
118
  inputRef.current.focus()
27
119
  inputRef.current.select()
28
120
  }
29
- }, [editing])
121
+ }, [editing, hasPicker])
30
122
 
31
123
  // Exit interactive mode when clicking outside the embed
32
124
  useEffect(() => {
@@ -42,11 +134,23 @@ export default function PrototypeEmbed({ props, onUpdate }) {
42
134
 
43
135
  const enterInteractive = useCallback(() => setInteractive(true), [])
44
136
 
137
+ function handlePickRoute(route) {
138
+ onUpdate?.({ src: route })
139
+ setEditing(false)
140
+ setFilter('')
141
+ }
142
+
45
143
  function handleSubmit(e) {
46
144
  e.preventDefault()
47
145
  const value = inputRef.current?.value?.trim() || ''
48
146
  onUpdate?.({ src: value })
49
147
  setEditing(false)
148
+ setFilter('')
149
+ }
150
+
151
+ function handleCancelEdit() {
152
+ setEditing(false)
153
+ setFilter('')
50
154
  }
51
155
 
52
156
  return (
@@ -57,26 +161,86 @@ export default function PrototypeEmbed({ props, onUpdate }) {
57
161
  style={{ width, height }}
58
162
  >
59
163
  {editing ? (
60
- <form
61
- className={styles.urlForm}
62
- onSubmit={handleSubmit}
164
+ <div
165
+ className={styles.pickerPanel}
63
166
  onMouseDown={(e) => e.stopPropagation()}
64
167
  onPointerDown={(e) => e.stopPropagation()}
65
168
  >
66
- <label className={styles.urlLabel}>Prototype URL path</label>
67
- <input
68
- ref={inputRef}
69
- className={styles.urlInput}
70
- type="text"
71
- defaultValue={src}
72
- placeholder="/MyPrototype/page"
73
- onKeyDown={(e) => { if (e.key === 'Escape') setEditing(false) }}
74
- />
75
- <div className={styles.urlActions}>
76
- <button type="submit" className={styles.urlSave}>Save</button>
77
- <button type="button" className={styles.urlCancel} onClick={() => setEditing(false)}>Cancel</button>
78
- </div>
79
- </form>
169
+ {hasPicker && (
170
+ <>
171
+ <div className={styles.pickerHeader}>
172
+ <span className={styles.urlLabel}>Pick a prototype</span>
173
+ <button
174
+ type="button"
175
+ className={styles.urlCancel}
176
+ onClick={handleCancelEdit}
177
+ aria-label="Cancel"
178
+ >✕</button>
179
+ </div>
180
+ <input
181
+ ref={filterRef}
182
+ className={styles.filterInput}
183
+ type="text"
184
+ value={filter}
185
+ onChange={(e) => setFilter(e.target.value)}
186
+ placeholder="Filter…"
187
+ onKeyDown={(e) => { if (e.key === 'Escape') handleCancelEdit() }}
188
+ />
189
+ <div className={styles.pickerList} role="listbox">
190
+ {filteredGroups.map((group) => (
191
+ <div key={group.label} className={styles.pickerGroup}>
192
+ {group.items.length === 1 && group.items[0].name === group.label ? (
193
+ <button
194
+ className={styles.pickerItem}
195
+ role="option"
196
+ onClick={() => handlePickRoute(group.items[0].route)}
197
+ >
198
+ {group.label}
199
+ </button>
200
+ ) : (
201
+ <>
202
+ <div className={styles.pickerGroupLabel}>{group.label}</div>
203
+ {group.items.map((item) => (
204
+ <button
205
+ key={item.route}
206
+ className={styles.pickerItem}
207
+ role="option"
208
+ onClick={() => handlePickRoute(item.route)}
209
+ >
210
+ {item.name}
211
+ </button>
212
+ ))}
213
+ </>
214
+ )}
215
+ </div>
216
+ ))}
217
+ {filteredGroups.length === 0 && (
218
+ <div className={styles.pickerEmpty}>No matches</div>
219
+ )}
220
+ </div>
221
+ <div className={styles.pickerDivider} />
222
+ </>
223
+ )}
224
+ <form className={styles.customUrlSection} onSubmit={handleSubmit}>
225
+ <label className={styles.urlLabel}>
226
+ {hasPicker ? 'Or enter a custom URL' : 'Prototype URL path'}
227
+ </label>
228
+ <input
229
+ ref={inputRef}
230
+ className={styles.urlInput}
231
+ type="text"
232
+ defaultValue={src}
233
+ placeholder="/MyPrototype/page"
234
+ onKeyDown={(e) => { if (e.key === 'Escape') handleCancelEdit() }}
235
+ />
236
+ <div className={styles.urlActions}>
237
+ <button type="submit" className={styles.urlSave}>Save</button>
238
+ {!hasPicker && (
239
+ <button type="button" className={styles.urlCancel} onClick={handleCancelEdit}>Cancel</button>
240
+ )}
241
+ </div>
242
+ </form>
243
+ </div>
80
244
  ) : iframeSrc ? (
81
245
  <>
82
246
  <div className={styles.iframeContainer}>
@@ -2,6 +2,9 @@
2
2
  position: relative;
3
3
  overflow: hidden;
4
4
  background: var(--bgColor-default, #ffffff);
5
+ border: 3px solid var(--borderColor-default, #d0d7de);
6
+ border-radius: 8px;
7
+ box-shadow: 0 4px 16px rgba(0, 0, 0, 0.08);
5
8
  }
6
9
 
7
10
  .iframeContainer {
@@ -89,6 +92,101 @@
89
92
  justify-content: center;
90
93
  }
91
94
 
95
+ .pickerPanel {
96
+ display: flex;
97
+ flex-direction: column;
98
+ height: 100%;
99
+ box-sizing: border-box;
100
+ overflow: hidden;
101
+ }
102
+
103
+ .pickerHeader {
104
+ display: flex;
105
+ align-items: center;
106
+ justify-content: space-between;
107
+ padding: 12px 16px 0;
108
+ }
109
+
110
+ .filterInput {
111
+ all: unset;
112
+ margin: 8px 16px;
113
+ padding: 6px 10px;
114
+ font-size: 13px;
115
+ border: 1px solid var(--borderColor-default, #d0d7de);
116
+ border-radius: 6px;
117
+ background: var(--bgColor-default, #ffffff);
118
+ color: var(--fgColor-default, #1f2328);
119
+ }
120
+
121
+ .filterInput:focus {
122
+ border-color: var(--bgColor-accent-emphasis, #2f81f7);
123
+ box-shadow: 0 0 0 2px rgba(47, 129, 247, 0.3);
124
+ }
125
+
126
+ .pickerList {
127
+ flex: 1;
128
+ overflow-y: auto;
129
+ padding: 4px 8px;
130
+ }
131
+
132
+ .pickerGroup {
133
+ margin-bottom: 2px;
134
+ }
135
+
136
+ .pickerGroupLabel {
137
+ padding: 6px 8px 2px;
138
+ font-size: 11px;
139
+ font-weight: 600;
140
+ color: var(--fgColor-muted, #656d76);
141
+ text-transform: uppercase;
142
+ letter-spacing: 0.5px;
143
+ }
144
+
145
+ .pickerItem {
146
+ all: unset;
147
+ display: block;
148
+ width: 100%;
149
+ box-sizing: border-box;
150
+ padding: 6px 12px;
151
+ font-size: 13px;
152
+ color: var(--fgColor-default, #1f2328);
153
+ border-radius: 6px;
154
+ cursor: pointer;
155
+ white-space: nowrap;
156
+ overflow: hidden;
157
+ text-overflow: ellipsis;
158
+ }
159
+
160
+ .pickerItem:hover {
161
+ background: var(--bgColor-neutral-muted, #eaeef2);
162
+ }
163
+
164
+ .pickerItem:focus-visible {
165
+ outline: 2px solid var(--bgColor-accent-emphasis, #2f81f7);
166
+ outline-offset: -2px;
167
+ }
168
+
169
+ .pickerEmpty {
170
+ padding: 12px;
171
+ font-size: 13px;
172
+ color: var(--fgColor-muted, #656d76);
173
+ font-style: italic;
174
+ text-align: center;
175
+ }
176
+
177
+ .pickerDivider {
178
+ height: 1px;
179
+ margin: 4px 16px;
180
+ background: var(--borderColor-muted, #d8dee4);
181
+ }
182
+
183
+ .customUrlSection {
184
+ display: flex;
185
+ flex-direction: column;
186
+ gap: 6px;
187
+ padding: 8px 16px 12px;
188
+ }
189
+
92
190
  .urlLabel {
93
191
  font-size: 12px;
94
192
  font-weight: 600;
package/src/context.jsx CHANGED
@@ -1,13 +1,27 @@
1
- import { useEffect, useMemo } from 'react'
1
+ import { useEffect, useMemo, Suspense, lazy } from 'react'
2
2
  import { useParams, useLocation } from 'react-router-dom'
3
- // Side-effect import: seeds the core data index via init()
4
- import 'virtual:storyboard-data-index'
3
+ // Named import seeds the core data index via init() AND provides canvas route data
4
+ import { canvases } from 'virtual:storyboard-data-index'
5
5
  import { loadFlow, flowExists, findRecord, deepMerge, setFlowClass, installBodyClassSync, resolveFlowName, resolveRecordName, isModesEnabled } from '@dfosco/storyboard-core'
6
6
  import { StoryboardContext } from './StoryboardContext.js'
7
7
  import styles from './FlowError.module.css'
8
8
 
9
9
  export { StoryboardContext }
10
10
 
11
+ const CanvasPageLazy = lazy(() => import('./canvas/CanvasPage.jsx'))
12
+
13
+ // Build a map from canvas route paths → canvas names at module load time
14
+ const canvasRouteMap = new Map()
15
+ for (const [name, data] of Object.entries(canvases || {})) {
16
+ const route = (data?._route || `/${name}`).replace(/\/+$/, '')
17
+ canvasRouteMap.set(route, name)
18
+ }
19
+
20
+ function matchCanvasRoute(pathname) {
21
+ const normalized = pathname.replace(/\/+$/, '') || '/'
22
+ return canvasRouteMap.get(normalized) || null
23
+ }
24
+
11
25
  /**
12
26
  * Derives the top-level prototype name from a pathname.
13
27
  * "/Dashboard" → "Dashboard", "/Dashboard/sub" → "Dashboard"
@@ -44,14 +58,19 @@ function getPageFlowName(pathname) {
44
58
  */
45
59
  export default function StoryboardProvider({ flowName, sceneName, recordName, recordParam, children }) {
46
60
  const location = useLocation()
61
+ const params = useParams()
62
+
63
+ // Canvas route detection — matches current URL against registered canvas routes
64
+ const canvasName = useMemo(() => matchCanvasRoute(location.pathname), [location.pathname])
65
+
47
66
  const searchParams = new URLSearchParams(location.search)
48
67
  const sceneParam = searchParams.get('flow') || searchParams.get('scene')
49
68
  const prototypeName = getPrototypeName(location.pathname)
50
69
  const pageFlow = getPageFlowName(location.pathname)
51
- const params = useParams()
52
70
 
53
- // Resolve flow name with prototype scoping
71
+ // Resolve flow name with prototype scoping (skip for canvas pages)
54
72
  const activeFlowName = useMemo(() => {
73
+ if (canvasName) return null
55
74
  const requested = sceneParam || flowName || sceneName
56
75
  if (requested) {
57
76
  return resolveFlowName(prototypeName, requested)
@@ -66,7 +85,7 @@ export default function StoryboardProvider({ flowName, sceneName, recordName, re
66
85
  }
67
86
  // 3. Global default
68
87
  return 'default'
69
- }, [sceneParam, flowName, sceneName, prototypeName, pageFlow])
88
+ }, [canvasName, sceneParam, flowName, sceneName, prototypeName, pageFlow])
70
89
 
71
90
  // Auto-install body class sync (sb-key--value classes on <body>)
72
91
  useEffect(() => installBodyClassSync(), [])
@@ -76,9 +95,9 @@ export default function StoryboardProvider({ flowName, sceneName, recordName, re
76
95
  if (!isModesEnabled()) return
77
96
 
78
97
  let cleanup
79
- import('@dfosco/storyboard-core/ui/design-modes')
80
- .then(({ mountDesignModesUI }) => {
81
- cleanup = mountDesignModesUI()
98
+ import('@dfosco/storyboard-core/ui-runtime')
99
+ .then(({ mountDesignModes }) => {
100
+ cleanup = mountDesignModes()
82
101
  })
83
102
  .catch(() => {
84
103
  // Svelte UI not available — degrade gracefully
@@ -87,7 +106,9 @@ export default function StoryboardProvider({ flowName, sceneName, recordName, re
87
106
  return () => cleanup?.()
88
107
  }, [])
89
108
 
109
+ // Skip flow loading for canvas pages
90
110
  const { data, error } = useMemo(() => {
111
+ if (canvasName) return { data: null, error: null }
91
112
  try {
92
113
  let flowData = loadFlow(activeFlowName)
93
114
 
@@ -105,7 +126,26 @@ export default function StoryboardProvider({ flowName, sceneName, recordName, re
105
126
  } catch (err) {
106
127
  return { data: null, error: err.message }
107
128
  }
108
- }, [activeFlowName, recordName, recordParam, params, prototypeName])
129
+ }, [canvasName, activeFlowName, recordName, recordParam, params, prototypeName])
130
+
131
+ // Canvas pages get their own rendering path — no flow data needed
132
+ if (canvasName) {
133
+ const canvasValue = {
134
+ data: null,
135
+ error: null,
136
+ loading: false,
137
+ flowName: null,
138
+ sceneName: null,
139
+ prototypeName: null,
140
+ }
141
+ return (
142
+ <StoryboardContext.Provider value={canvasValue}>
143
+ <Suspense fallback={null}>
144
+ <CanvasPageLazy name={canvasName} />
145
+ </Suspense>
146
+ </StoryboardContext.Provider>
147
+ )
148
+ }
109
149
 
110
150
  const value = {
111
151
  data,
@@ -295,7 +295,7 @@ function readConfig(root) {
295
295
  }
296
296
 
297
297
  /**
298
- * Read core-ui.config.json from @dfosco/storyboard-core.
298
+ * Read toolbar.config.json from @dfosco/storyboard-core.
299
299
  * Returns the full config object with modes array.
300
300
  * Falls back to hardcoded defaults if not found.
301
301
  */
@@ -311,9 +311,9 @@ function readModesConfig(root) {
311
311
 
312
312
  // Try local workspace path first (monorepo), then node_modules
313
313
  const candidates = [
314
- path.resolve(root, 'packages/core/core-ui.config.json'),
314
+ path.resolve(root, 'packages/core/toolbar.config.json'),
315
315
  path.resolve(root, 'packages/core/configs/modes.config.json'),
316
- path.resolve(root, 'node_modules/@dfosco/storyboard-core/core-ui.config.json'),
316
+ path.resolve(root, 'node_modules/@dfosco/storyboard-core/toolbar.config.json'),
317
317
  path.resolve(root, 'node_modules/@dfosco/storyboard-core/configs/modes.config.json'),
318
318
  ]
319
319