@dfosco/storyboard-react 4.0.0-beta.43 → 4.0.0-beta.44

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.
@@ -0,0 +1,20 @@
1
+ import styles from './CanvasPage.module.css'
2
+
3
+ /**
4
+ * Renders the translucent selection rectangle during a marquee drag.
5
+ * Positioned relative to the scroll container.
6
+ */
7
+ export default function MarqueeOverlay({ rect }) {
8
+ if (!rect) return null
9
+ return (
10
+ <div
11
+ className={styles.marqueeRect}
12
+ style={{
13
+ left: rect.x,
14
+ top: rect.y,
15
+ width: rect.w,
16
+ height: rect.h,
17
+ }}
18
+ />
19
+ )
20
+ }
@@ -10,7 +10,15 @@ import styles from './PageSelector.module.css'
10
10
  * @param {{ currentName: string, pages: Array<{ name: string, route: string, title: string }>, isLocalDev?: boolean }} props
11
11
  */
12
12
  export default function PageSelector({ currentName, pages: initialPages, isLocalDev = false }) {
13
- const [open, setOpen] = useState(false)
13
+ const [open, setOpen] = useState(() => {
14
+ try {
15
+ if (typeof sessionStorage !== 'undefined' && sessionStorage.getItem('sb-open-page-selector')) {
16
+ sessionStorage.removeItem('sb-open-page-selector')
17
+ return true
18
+ }
19
+ } catch { /* ignore */ }
20
+ return false
21
+ })
14
22
  const [adding, setAdding] = useState(false)
15
23
  const [newName, setNewName] = useState('')
16
24
  const [creating, setCreating] = useState(false)
@@ -45,7 +53,13 @@ export default function PageSelector({ currentName, pages: initialPages, isLocal
45
53
  if (!trimmed || creating) return
46
54
  setCreating(true)
47
55
  try {
48
- const result = await createCanvas({ name: trimmed, folder: folder || undefined })
56
+ // Single-page canvas (no folder) convert to multi-page folder
57
+ const isSinglePage = !currentName.includes('/')
58
+ const createBody = isSinglePage
59
+ ? { name: trimmed, convertFrom: currentName }
60
+ : { name: trimmed, folder: folder || undefined }
61
+
62
+ const result = await createCanvas(createBody)
49
63
  if (result.error) {
50
64
  console.error('Failed to create canvas page:', result.error)
51
65
  setCreating(false)
@@ -65,6 +79,9 @@ export default function PageSelector({ currentName, pages: initialPages, isLocal
65
79
  setNewName('')
66
80
  setCreating(false)
67
81
 
82
+ // Stash a flag so the page selector opens automatically on the new page
83
+ try { sessionStorage.setItem('sb-open-page-selector', '1') } catch { /* ignore */ }
84
+
68
85
  // Navigate to the new page after Vite picks up the new file
69
86
  if (import.meta.hot) {
70
87
  const timer = setTimeout(() => {
@@ -81,7 +98,7 @@ export default function PageSelector({ currentName, pages: initialPages, isLocal
81
98
  console.error('Failed to create canvas page:', err)
82
99
  setCreating(false)
83
100
  }
84
- }, [newName, folder, creating])
101
+ }, [newName, currentName, folder, creating])
85
102
 
86
103
  // Focus input when entering add mode
87
104
  useEffect(() => {
@@ -1,12 +1,12 @@
1
1
  /**
2
2
  * Canvas Component Isolate — iframe entry point.
3
3
  *
4
- * Renders a single named export from a .canvas.jsx module inside an
4
+ * Renders a single named export from a .story.jsx module inside an
5
5
  * isolated document. The parent CanvasPage embeds this via an iframe
6
6
  * so a broken component cannot crash the entire canvas.
7
7
  *
8
8
  * Query params:
9
- * module — absolute or base-relative path to the .canvas.jsx file
9
+ * module — absolute or base-relative path to the .story.jsx file
10
10
  * export — the named export to render
11
11
  * theme — canvas theme (light / dark / dark_dimmed)
12
12
  */
@@ -97,9 +97,9 @@ async function mount() {
97
97
  return
98
98
  }
99
99
 
100
- // Validate: only allow .canvas.jsx and .story.{jsx,tsx} modules
101
- if (!modulePath.endsWith('.canvas.jsx') && !modulePath.match(/\.story\.(jsx|tsx)$/)) {
102
- root.render(createElement('div', { style: errorStyle }, 'Invalid module path — only .canvas.jsx and .story.jsx/.tsx files are allowed'))
100
+ // Validate: only allow .story.{jsx,tsx} modules
101
+ if (!modulePath.match(/\.story\.(jsx|tsx)$/)) {
102
+ root.render(createElement('div', { style: errorStyle }, 'Invalid module path — only .story.jsx/.tsx files are allowed'))
103
103
  return
104
104
  }
105
105
 
@@ -3,14 +3,14 @@ import { resolveCanvasModuleImport } from './useCanvas.js'
3
3
 
4
4
  describe('resolveCanvasModuleImport', () => {
5
5
  it('prefixes root-relative module paths with BASE_URL', () => {
6
- expect(resolveCanvasModuleImport('/src/canvas/button-patterns.canvas.jsx', '/feature-branch/')).toBe(
7
- '/feature-branch/src/canvas/button-patterns.canvas.jsx',
6
+ expect(resolveCanvasModuleImport('/src/canvas/button-patterns.story.jsx', '/feature-branch/')).toBe(
7
+ '/feature-branch/src/canvas/button-patterns.story.jsx',
8
8
  )
9
9
  })
10
10
 
11
11
  it('keeps root-relative paths unchanged when BASE_URL is root', () => {
12
- expect(resolveCanvasModuleImport('/src/canvas/button-patterns.canvas.jsx', '/')).toBe(
13
- '/src/canvas/button-patterns.canvas.jsx',
12
+ expect(resolveCanvasModuleImport('/src/canvas/button-patterns.story.jsx', '/')).toBe(
13
+ '/src/canvas/button-patterns.story.jsx',
14
14
  )
15
15
  })
16
16
 
@@ -0,0 +1,187 @@
1
+ import { useCallback, useEffect, useRef, useState } from 'react'
2
+
3
+ /**
4
+ * Returns the bounding-box list for all widgets on the canvas.
5
+ * Each entry: { id, x, y, width, height }
6
+ */
7
+ function getWidgetBounds(widgets, componentEntries, fallbackSizes) {
8
+ const bounds = []
9
+ for (const w of (widgets ?? [])) {
10
+ const fb = fallbackSizes[w.type] || { width: 200, height: 150 }
11
+ bounds.push({
12
+ id: w.id,
13
+ x: w?.position?.x ?? 0,
14
+ y: w?.position?.y ?? 0,
15
+ width: w.props?.width ?? fb.width,
16
+ height: w.props?.height ?? fb.height,
17
+ })
18
+ }
19
+ for (const entry of componentEntries) {
20
+ const fb = fallbackSizes['component'] || { width: 200, height: 150 }
21
+ bounds.push({
22
+ id: `jsx-${entry.exportName}`,
23
+ x: entry.sourceData?.position?.x ?? 0,
24
+ y: entry.sourceData?.position?.y ?? 0,
25
+ width: entry.sourceData?.width ?? fb.width,
26
+ height: entry.sourceData?.height ?? fb.height,
27
+ })
28
+ }
29
+ return bounds
30
+ }
31
+
32
+ /** Test whether two axis-aligned rectangles overlap. */
33
+ function rectsIntersect(a, b) {
34
+ return a.x < b.x + b.width &&
35
+ a.x + a.width > b.x &&
36
+ a.y < b.y + b.height &&
37
+ a.y + a.height > b.y
38
+ }
39
+
40
+ /**
41
+ * Hook that powers marquee (lasso) selection on the canvas.
42
+ *
43
+ * @param {Object} opts
44
+ * @param {React.RefObject} opts.scrollRef — ref to the scroll container
45
+ * @param {React.RefObject} opts.zoomRef — ref holding current zoom (number 25-200)
46
+ * @param {Function} opts.setSelectedWidgetIds — state setter for selected IDs (Set)
47
+ * @param {Array} opts.widgets — current localWidgets array
48
+ * @param {Array} opts.componentEntries — current componentEntries array
49
+ * @param {Object} opts.fallbackSizes — WIDGET_FALLBACK_SIZES map
50
+ * @param {boolean} opts.spaceHeld — whether the space key is pressed (panning)
51
+ * @param {boolean} opts.isLocalDev — only enable in local dev
52
+ *
53
+ * @returns {{ marqueeScreenRect: {x,y,w,h}|null, handleMarqueeMouseDown: Function }}
54
+ */
55
+ export default function useMarqueeSelect({
56
+ scrollRef,
57
+ zoomRef,
58
+ setSelectedWidgetIds,
59
+ widgets,
60
+ componentEntries,
61
+ fallbackSizes,
62
+ spaceHeld,
63
+ isLocalDev,
64
+ }) {
65
+ const [marqueeScreenRect, setMarqueeScreenRect] = useState(null)
66
+ const marqueeState = useRef(null) // { startCanvasX, startCanvasY, startClientX, startClientY }
67
+
68
+ /** Convert a client (screen) point to canvas-space coords. */
69
+ const clientToCanvas = useCallback((clientX, clientY) => {
70
+ const el = scrollRef.current
71
+ if (!el) return { x: 0, y: 0 }
72
+ const rect = el.getBoundingClientRect()
73
+ const scale = (zoomRef.current ?? 100) / 100
74
+ return {
75
+ x: (el.scrollLeft + (clientX - rect.left)) / scale,
76
+ y: (el.scrollTop + (clientY - rect.top)) / scale,
77
+ }
78
+ }, [scrollRef, zoomRef])
79
+
80
+ // Ref to track active drag listeners for cleanup on unmount
81
+ const cleanupRef = useRef(null)
82
+
83
+ const handleMarqueeMouseDown = useCallback((e) => {
84
+ if (!isLocalDev) return
85
+ if (spaceHeld) return
86
+ // Only start on direct background click (not on a widget)
87
+ if (e.button !== 0) return
88
+ if (e.target.closest('[data-tc-x]')) return
89
+
90
+ const canvasStart = clientToCanvas(e.clientX, e.clientY)
91
+ marqueeState.current = {
92
+ startCanvasX: canvasStart.x,
93
+ startCanvasY: canvasStart.y,
94
+ startClientX: e.clientX,
95
+ startClientY: e.clientY,
96
+ }
97
+
98
+ // Minimum drag distance before showing the marquee (avoids flicker on clicks)
99
+ const MIN_DRAG = 4
100
+
101
+ function handleMove(ev) {
102
+ const ms = marqueeState.current
103
+ if (!ms) return
104
+
105
+ const dx = ev.clientX - ms.startClientX
106
+ const dy = ev.clientY - ms.startClientY
107
+ if (Math.abs(dx) < MIN_DRAG && Math.abs(dy) < MIN_DRAG) return
108
+
109
+ const el = scrollRef.current
110
+ if (!el) return
111
+ const containerRect = el.getBoundingClientRect()
112
+
113
+ // Content-space rectangle (accounts for scroll offset)
114
+ const sx = Math.min(ms.startClientX, ev.clientX) - containerRect.left + el.scrollLeft
115
+ const sy = Math.min(ms.startClientY, ev.clientY) - containerRect.top + el.scrollTop
116
+ const sw = Math.abs(dx)
117
+ const sh = Math.abs(dy)
118
+
119
+ setMarqueeScreenRect({ x: sx, y: sy, w: sw, h: sh })
120
+ }
121
+
122
+ function removeListeners() {
123
+ document.removeEventListener('mousemove', handleMove)
124
+ document.removeEventListener('mouseup', handleUp)
125
+ window.removeEventListener('blur', handleCancel)
126
+ cleanupRef.current = null
127
+ }
128
+
129
+ function handleCancel() {
130
+ removeListeners()
131
+ marqueeState.current = null
132
+ setMarqueeScreenRect(null)
133
+ }
134
+
135
+ function handleUp(ev) {
136
+ removeListeners()
137
+
138
+ const ms = marqueeState.current
139
+ marqueeState.current = null
140
+ setMarqueeScreenRect(null)
141
+
142
+ if (!ms) return
143
+
144
+ // If the user barely moved, treat as a deselect click
145
+ const dx = ev.clientX - ms.startClientX
146
+ const dy = ev.clientY - ms.startClientY
147
+ if (Math.abs(dx) < MIN_DRAG && Math.abs(dy) < MIN_DRAG) {
148
+ setSelectedWidgetIds(new Set())
149
+ return
150
+ }
151
+
152
+ // Compute the selection rect in canvas space
153
+ const canvasEnd = clientToCanvas(ev.clientX, ev.clientY)
154
+ const selRect = {
155
+ x: Math.min(ms.startCanvasX, canvasEnd.x),
156
+ y: Math.min(ms.startCanvasY, canvasEnd.y),
157
+ width: Math.abs(canvasEnd.x - ms.startCanvasX),
158
+ height: Math.abs(canvasEnd.y - ms.startCanvasY),
159
+ }
160
+
161
+ // Find intersecting widgets
162
+ const allBounds = getWidgetBounds(widgets, componentEntries, fallbackSizes)
163
+ const selected = new Set()
164
+ for (const wb of allBounds) {
165
+ if (rectsIntersect(selRect, wb)) {
166
+ selected.add(wb.id)
167
+ }
168
+ }
169
+
170
+ setSelectedWidgetIds(selected)
171
+ }
172
+
173
+ document.addEventListener('mousemove', handleMove)
174
+ document.addEventListener('mouseup', handleUp)
175
+ window.addEventListener('blur', handleCancel)
176
+ cleanupRef.current = removeListeners
177
+ }, [isLocalDev, spaceHeld, clientToCanvas, scrollRef, setSelectedWidgetIds, widgets, componentEntries, fallbackSizes])
178
+
179
+ // Clean up listeners if component unmounts mid-drag
180
+ useEffect(() => {
181
+ return () => { cleanupRef.current?.() }
182
+ }, [])
183
+
184
+ return { marqueeScreenRect, handleMarqueeMouseDown }
185
+ }
186
+
187
+ export { getWidgetBounds, rectsIntersect }
@@ -0,0 +1,78 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { rectsIntersect, getWidgetBounds } from './useMarqueeSelect.js'
3
+
4
+ describe('rectsIntersect', () => {
5
+ it('returns true for overlapping rectangles', () => {
6
+ const a = { x: 0, y: 0, width: 100, height: 100 }
7
+ const b = { x: 50, y: 50, width: 100, height: 100 }
8
+ expect(rectsIntersect(a, b)).toBe(true)
9
+ })
10
+
11
+ it('returns false for non-overlapping rectangles', () => {
12
+ const a = { x: 0, y: 0, width: 100, height: 100 }
13
+ const b = { x: 200, y: 200, width: 100, height: 100 }
14
+ expect(rectsIntersect(a, b)).toBe(false)
15
+ })
16
+
17
+ it('returns false for edge-touching rectangles (no overlap)', () => {
18
+ const a = { x: 0, y: 0, width: 100, height: 100 }
19
+ const b = { x: 100, y: 0, width: 100, height: 100 }
20
+ expect(rectsIntersect(a, b)).toBe(false)
21
+ })
22
+
23
+ it('returns true when one rectangle contains the other', () => {
24
+ const a = { x: 0, y: 0, width: 200, height: 200 }
25
+ const b = { x: 50, y: 50, width: 50, height: 50 }
26
+ expect(rectsIntersect(a, b)).toBe(true)
27
+ })
28
+
29
+ it('returns true for partial horizontal overlap', () => {
30
+ const a = { x: 0, y: 0, width: 100, height: 100 }
31
+ const b = { x: 50, y: 0, width: 100, height: 50 }
32
+ expect(rectsIntersect(a, b)).toBe(true)
33
+ })
34
+ })
35
+
36
+ describe('getWidgetBounds', () => {
37
+ const fallbackSizes = {
38
+ 'sticky-note': { width: 270, height: 170 },
39
+ 'component': { width: 200, height: 150 },
40
+ }
41
+
42
+ it('returns bounds for JSON widgets', () => {
43
+ const widgets = [
44
+ { id: 'w1', type: 'sticky-note', position: { x: 10, y: 20 }, props: { width: 300, height: 200 } },
45
+ { id: 'w2', type: 'sticky-note', position: { x: 100, y: 200 }, props: {} },
46
+ ]
47
+ const result = getWidgetBounds(widgets, [], fallbackSizes)
48
+ expect(result).toEqual([
49
+ { id: 'w1', x: 10, y: 20, width: 300, height: 200 },
50
+ { id: 'w2', x: 100, y: 200, width: 270, height: 170 },
51
+ ])
52
+ })
53
+
54
+ it('returns bounds for component entries', () => {
55
+ const entries = [
56
+ { exportName: 'MyComp', sourceData: { position: { x: 5, y: 10 }, width: 400, height: 300 } },
57
+ ]
58
+ const result = getWidgetBounds([], entries, fallbackSizes)
59
+ expect(result).toEqual([
60
+ { id: 'jsx-MyComp', x: 5, y: 10, width: 400, height: 300 },
61
+ ])
62
+ })
63
+
64
+ it('handles null/missing widget data gracefully', () => {
65
+ const widgets = [
66
+ { id: 'w1', type: 'unknown' },
67
+ ]
68
+ const result = getWidgetBounds(widgets, [], fallbackSizes)
69
+ expect(result).toEqual([
70
+ { id: 'w1', x: 0, y: 0, width: 200, height: 150 },
71
+ ])
72
+ })
73
+
74
+ it('handles null widgets array', () => {
75
+ const result = getWidgetBounds(null, [], fallbackSizes)
76
+ expect(result).toEqual([])
77
+ })
78
+ })
@@ -7,7 +7,7 @@ import styles from './ComponentWidget.module.css'
7
7
  import overlayStyles from './embedOverlay.module.css'
8
8
 
9
9
  /**
10
- * Renders a live JSX export from a .canvas.jsx companion file.
10
+ * Renders a live JSX export from a .story.jsx file.
11
11
  *
12
12
  * In dev mode (isLocalDev), each component is rendered inside an iframe
13
13
  * via the /_storyboard/canvas/isolate middleware. This isolates broken
@@ -81,7 +81,7 @@ export default forwardRef(function PrototypeEmbed({ id: widgetId, props, onUpdat
81
81
  const base = hashIdx >= 0 ? rawSrc.slice(0, hashIdx) : rawSrc
82
82
  const hash = hashIdx >= 0 ? rawSrc.slice(hashIdx) : ''
83
83
  const sep = base.includes('?') ? '&' : '?'
84
- return `${base}${sep}_sb_embed&_sb_theme_target=prototype&_sb_canvas_theme=${canvasTheme}${hash}`
84
+ return `${base}${sep}_sb_embed&_sb_hide_branch_bar&_sb_theme_target=prototype&_sb_canvas_theme=${canvasTheme}${hash}`
85
85
  }, [rawSrc, canvasTheme])
86
86
 
87
87
  const prototypeIndex = useMemo(() => {
@@ -35,6 +35,7 @@ function resolveStoryUrl(storyId, exportName) {
35
35
  const params = new URLSearchParams()
36
36
  if (exportName) params.set('export', exportName)
37
37
  params.set('_sb_embed', '')
38
+ params.set('_sb_hide_branch_bar', '')
38
39
  return `${base}${story._route}?${params}`
39
40
  }
40
41
 
@@ -215,7 +215,7 @@ describe('Embed interaction overlay', () => {
215
215
  <ComponentWidget
216
216
  {...defaultProps}
217
217
  isLocalDev
218
- jsxModule="/src/canvas/mock.canvas.jsx"
218
+ jsxModule="/src/canvas/mock.story.jsx"
219
219
  exportName="MockComponent"
220
220
  />
221
221
  )
package/src/index.js CHANGED
@@ -37,6 +37,9 @@ export { FormContext } from './context/FormContext.js'
37
37
  // Viewfinder dashboard
38
38
  export { default as Viewfinder } from './Viewfinder.jsx'
39
39
 
40
+ // Command Palette
41
+ export { default as StoryboardCommandPalette } from './CommandPalette/CommandPalette.jsx'
42
+
40
43
  // Canvas
41
44
  export { default as CanvasPage } from './canvas/CanvasPage.jsx'
42
45
  export { useCanvas } from './canvas/useCanvas.js'
@@ -649,13 +649,6 @@ function generateModule({ index, protoFolders, flowRoutes, canvasRoutes, canvasA
649
649
  `[storyboard-data] Canvas "${name}" references JSX file "${parsed.jsx}" but it was not found at ${jsxPath}`
650
650
  )
651
651
  }
652
- } else {
653
- // Auto-detect a same-name .canvas.jsx companion
654
- const autoJsx = absPath.replace(/\.canvas\.(jsonl|jsonc?)$/, '.canvas.jsx')
655
- if (fs.existsSync(autoJsx)) {
656
- const relJsx = '/' + path.relative(root, autoJsx).replace(/\\/g, '/')
657
- parsed = { ...parsed, _jsxModule: relJsx }
658
- }
659
652
  }
660
653
  }
661
654
 
@@ -834,7 +827,7 @@ export default function storyboardDataPlugin() {
834
827
  // can't trace into its deps. Include the remark entry points so
835
828
  // Vite pre-bundles the full chain — covers all transitive CJS
836
829
  // packages (debug, extend, etc.) without whack-a-mole.
837
- include: ['remark', 'remark-gfm', 'remark-html', 'use-sync-external-store/shim', 'use-sync-external-store/shim/with-selector'],
830
+ include: ['react-cmdk', 'remark', 'remark-gfm', 'remark-html', 'use-sync-external-store/shim', 'use-sync-external-store/shim/with-selector'],
838
831
  exclude: ['@dfosco/storyboard-react'],
839
832
  },
840
833
  }
@@ -858,7 +851,7 @@ export default function storyboardDataPlugin() {
858
851
  // ── Component isolate middleware ───────────────────────────────
859
852
  // Serves a minimal HTML shell for iframe-isolated component widgets.
860
853
  // The iframe loads componentIsolate.jsx which reads query params
861
- // (module, export, theme) and renders a single canvas.jsx export.
854
+ // (module, export, theme) and renders a single story export.
862
855
  const isolateEntryPath = new URL('../canvas/componentIsolate.jsx', import.meta.url).pathname
863
856
  server.middlewares.use(async (req, res, next) => {
864
857
  if (!req.url) return next()