@dfosco/storyboard-react 4.0.0-beta.23 → 4.0.0-beta.25

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.
@@ -123,7 +123,7 @@ describe('CanvasPage multi-select', () => {
123
123
  })
124
124
 
125
125
  it('shift+click on select handle adds widget to selection', async () => {
126
- render(<CanvasPage name="test-canvas" />)
126
+ render(<CanvasPage canvasId="test-canvas" />)
127
127
 
128
128
  // Select first widget
129
129
  fireEvent.click(screen.getByTestId('select-w1'))
@@ -138,7 +138,7 @@ describe('CanvasPage multi-select', () => {
138
138
  })
139
139
 
140
140
  it('shift+click on already selected widget removes it from selection', async () => {
141
- render(<CanvasPage name="test-canvas" />)
141
+ render(<CanvasPage canvasId="test-canvas" />)
142
142
 
143
143
  // Select both
144
144
  fireEvent.click(screen.getByTestId('select-w1'))
@@ -155,7 +155,7 @@ describe('CanvasPage multi-select', () => {
155
155
  })
156
156
 
157
157
  it('normal click replaces multi-selection with single', async () => {
158
- render(<CanvasPage name="test-canvas" />)
158
+ render(<CanvasPage canvasId="test-canvas" />)
159
159
 
160
160
  // Multi-select
161
161
  fireEvent.click(screen.getByTestId('select-w1'))
@@ -172,7 +172,7 @@ describe('CanvasPage multi-select', () => {
172
172
  })
173
173
 
174
174
  it('sets multiSelected on all selected widgets when multiple are selected', async () => {
175
- render(<CanvasPage name="test-canvas" />)
175
+ render(<CanvasPage canvasId="test-canvas" />)
176
176
 
177
177
  fireEvent.click(screen.getByTestId('select-w1'))
178
178
  fireEvent.click(screen.getByTestId('shift-select-w2'))
@@ -185,7 +185,7 @@ describe('CanvasPage multi-select', () => {
185
185
  })
186
186
 
187
187
  it('Escape clears all selection', async () => {
188
- render(<CanvasPage name="test-canvas" />)
188
+ render(<CanvasPage canvasId="test-canvas" />)
189
189
 
190
190
  fireEvent.click(screen.getByTestId('select-w1'))
191
191
  fireEvent.click(screen.getByTestId('shift-select-w2'))
@@ -199,7 +199,7 @@ describe('CanvasPage multi-select', () => {
199
199
  })
200
200
 
201
201
  it('Delete removes all selected widgets and calls updateCanvas', async () => {
202
- render(<CanvasPage name="test-canvas" />)
202
+ render(<CanvasPage canvasId="test-canvas" />)
203
203
 
204
204
  // Multi-select w1 and w2
205
205
  fireEvent.click(screen.getByTestId('select-w1'))
@@ -224,7 +224,7 @@ describe('CanvasPage multi-select', () => {
224
224
  })
225
225
 
226
226
  it('single-select Delete uses removeWidget API', async () => {
227
- render(<CanvasPage name="test-canvas" />)
227
+ render(<CanvasPage canvasId="test-canvas" />)
228
228
 
229
229
  fireEvent.click(screen.getByTestId('select-w1'))
230
230
  fireEvent.keyDown(document, { key: 'Backspace' })
@@ -234,7 +234,7 @@ describe('CanvasPage multi-select', () => {
234
234
  })
235
235
 
236
236
  it('multi-select move applies delta to all selected widgets', async () => {
237
- render(<CanvasPage name="test-canvas" />)
237
+ render(<CanvasPage canvasId="test-canvas" />)
238
238
 
239
239
  // Multi-select w1 (100,100) and w2 (300,100)
240
240
  fireEvent.click(screen.getByTestId('select-w1'))
@@ -265,7 +265,7 @@ describe('CanvasPage multi-select', () => {
265
265
  })
266
266
 
267
267
  it('multi-select drag captures peer articles on drag start', async () => {
268
- render(<CanvasPage name="test-canvas" />)
268
+ render(<CanvasPage canvasId="test-canvas" />)
269
269
 
270
270
  // Multi-select w1 and w2
271
271
  fireEvent.click(screen.getByTestId('select-w1'))
@@ -288,7 +288,7 @@ describe('CanvasPage multi-select', () => {
288
288
  })
289
289
 
290
290
  it('multi-select drag preserves selection after drag end', async () => {
291
- render(<CanvasPage name="test-canvas" />)
291
+ render(<CanvasPage canvasId="test-canvas" />)
292
292
 
293
293
  // Multi-select w1 and w2
294
294
  fireEvent.click(screen.getByTestId('select-w1'))
@@ -313,7 +313,7 @@ describe('CanvasPage multi-select', () => {
313
313
  })
314
314
 
315
315
  it('any selected widget can serve as drag handler for the group', async () => {
316
- render(<CanvasPage name="test-canvas" />)
316
+ render(<CanvasPage canvasId="test-canvas" />)
317
317
 
318
318
  // Multi-select w1 (100,100), w2 (300,100), w3 (500,200)
319
319
  fireEvent.click(screen.getByTestId('select-w1'))
@@ -9,7 +9,7 @@ const WIDGET_TYPES = getMenuWidgetTypes()
9
9
  /**
10
10
  * Floating toolbar for adding widgets to a canvas.
11
11
  */
12
- export default function CanvasToolbar({ canvasName, onWidgetAdded }) {
12
+ export default function CanvasToolbar({ canvasId, onWidgetAdded }) {
13
13
  const [open, setOpen] = useState(false)
14
14
  const [adding, setAdding] = useState(false)
15
15
 
@@ -18,7 +18,7 @@ export default function CanvasToolbar({ canvasName, onWidgetAdded }) {
18
18
  setAdding(true)
19
19
  try {
20
20
  const defaultProps = schemas[type] ? getDefaults(schemas[type]) : {}
21
- const result = await addWidgetApi(canvasName, {
21
+ const result = await addWidgetApi(canvasId, {
22
22
  type,
23
23
  props: defaultProps,
24
24
  position: { x: 0, y: 0 },
@@ -28,26 +28,28 @@ export function createCanvas(data) {
28
28
  return request('/create', 'POST', data)
29
29
  }
30
30
 
31
- export function updateCanvas(name, { widgets, sources, settings }) {
32
- return request('/update', 'PUT', { name, widgets, sources, settings })
31
+ export function updateCanvas(canvasId, { widgets, sources, settings }) {
32
+ return request('/update', 'PUT', { name: canvasId, widgets, sources, settings })
33
33
  }
34
34
 
35
- export function addWidget(name, { type, props, position }) {
36
- return request('/widget', 'POST', { name, type, props, position })
35
+ export function addWidget(canvasId, { type, props, position }) {
36
+ return request('/widget', 'POST', { name: canvasId, type, props, position })
37
37
  }
38
38
 
39
- export function removeWidget(name, widgetId) {
40
- return request('/widget', 'DELETE', { name, widgetId })
39
+ export function removeWidget(canvasId, widgetId) {
40
+ return request('/widget', 'DELETE', { name: canvasId, widgetId })
41
41
  }
42
42
 
43
- export function uploadImage(dataUrl, canvasName) {
44
- return request('/image', 'POST', { dataUrl, canvasName })
43
+ export function uploadImage(dataUrl, canvasId, filename) {
44
+ const body = { dataUrl, canvasName: canvasId }
45
+ if (filename) body.filename = filename
46
+ return request('/image', 'POST', body)
45
47
  }
46
48
 
47
49
  export function toggleImagePrivacy(filename) {
48
50
  return request('/image/toggle-private', 'POST', { filename })
49
51
  }
50
52
 
51
- export function getCanvas(name) {
52
- return request(`/read?name=${encodeURIComponent(name)}`, 'GET')
53
+ export function getCanvas(canvasId) {
54
+ return request(`/read?name=${encodeURIComponent(canvasId)}`, 'GET')
53
55
  }
@@ -31,11 +31,11 @@ export function resolveCanvasModuleImport(modulePath, baseUrl = import.meta.env?
31
31
  * Uses build-time data for static config (routes, JSX path), but fetches
32
32
  * fresh widget data from the server to pick up persisted edits.
33
33
  *
34
- * @param {string} name - Canvas name as indexed by the data plugin
34
+ * @param {string} canvasId - Canonical canvas ID as indexed by the data plugin
35
35
  * @returns {{ canvas: object|null, jsxExports: object|null, jsxError: boolean, loading: boolean }}
36
36
  */
37
- export function useCanvas(name) {
38
- const buildTimeCanvas = useMemo(() => getCanvasData(name), [name])
37
+ export function useCanvas(canvasId) {
38
+ const buildTimeCanvas = useMemo(() => getCanvasData(canvasId), [canvasId])
39
39
  const [canvas, setCanvas] = useState(buildTimeCanvas)
40
40
  const [jsxExports, setJsxExports] = useState(null)
41
41
  const [jsxError, setJsxError] = useState(false)
@@ -50,7 +50,7 @@ export function useCanvas(name) {
50
50
  }
51
51
 
52
52
  setLoading(true)
53
- fetchCanvasFromServer(name).then((fresh) => {
53
+ fetchCanvasFromServer(canvasId).then((fresh) => {
54
54
  if (fresh) {
55
55
  // Merge: use server data for widgets/sources, keep build-time for _route/_jsxModule
56
56
  setCanvas({ ...buildTimeCanvas, ...fresh })
@@ -59,7 +59,7 @@ export function useCanvas(name) {
59
59
  }
60
60
  setLoading(false)
61
61
  })
62
- }, [name, buildTimeCanvas])
62
+ }, [canvasId, buildTimeCanvas])
63
63
 
64
64
  const jsxModule = canvas?._jsxModule
65
65
  const jsxImport = canvas?._jsxImport
@@ -99,8 +99,9 @@ export function useCanvas(name) {
99
99
  if (!import.meta.hot || !buildTimeCanvas) return
100
100
 
101
101
  const handleCanvasFileChanged = ({ data }) => {
102
- if (!data || data.name !== name) return
103
- fetchCanvasFromServer(name).then((fresh) => {
102
+ const eventId = data?.canvasId || data?.name
103
+ if (!data || eventId !== canvasId) return
104
+ fetchCanvasFromServer(canvasId).then((fresh) => {
104
105
  if (fresh) {
105
106
  setCanvas((prev) => ({ ...(prev || buildTimeCanvas), ...fresh }))
106
107
  }
@@ -111,7 +112,7 @@ export function useCanvas(name) {
111
112
  return () => {
112
113
  import.meta.hot.off('storyboard:canvas-file-changed', handleCanvasFileChanged)
113
114
  }
114
- }, [name, buildTimeCanvas])
115
+ }, [canvasId, buildTimeCanvas])
115
116
 
116
117
  return { canvas, jsxExports, jsxError, loading }
117
118
  }
@@ -2,6 +2,7 @@ import { useRef, useCallback, useState, useEffect, useMemo } from 'react'
2
2
  import WidgetWrapper from './WidgetWrapper.jsx'
3
3
  import ResizeHandle from './ResizeHandle.jsx'
4
4
  import ComponentErrorBoundary from '../ComponentErrorBoundary.jsx'
5
+ import { useIframeDevLogs } from './iframeDevLogs.js'
5
6
  import styles from './ComponentWidget.module.css'
6
7
  import overlayStyles from './embedOverlay.module.css'
7
8
 
@@ -31,6 +32,7 @@ export default function ComponentWidget({
31
32
  }) {
32
33
  const containerRef = useRef(null)
33
34
  const [interactive, setInteractive] = useState(false)
35
+ const [showIframe, setShowIframe] = useState(false)
34
36
 
35
37
  const handleResize = useCallback((w, h) => {
36
38
  onUpdate?.({ width: w, height: h })
@@ -44,6 +46,7 @@ export default function ComponentWidget({
44
46
  function handlePointerDown(e) {
45
47
  if (containerRef.current && !containerRef.current.contains(e.target)) {
46
48
  setInteractive(false)
49
+ setShowIframe(false)
47
50
  }
48
51
  }
49
52
  document.addEventListener('pointerdown', handlePointerDown)
@@ -64,6 +67,12 @@ export default function ComponentWidget({
64
67
 
65
68
  const useIframe = isLocalDev && iframeSrc
66
69
 
70
+ useIframeDevLogs({
71
+ widget: 'ComponentWidget',
72
+ loaded: Boolean(useIframe && showIframe),
73
+ src: iframeSrc,
74
+ })
75
+
67
76
  if (!useIframe && !Component) return null
68
77
 
69
78
  const sizeStyle = {}
@@ -75,12 +84,16 @@ export default function ComponentWidget({
75
84
  <div ref={containerRef} className={styles.container} style={sizeStyle}>
76
85
  <div className={styles.content}>
77
86
  {useIframe ? (
78
- <iframe
79
- src={iframeSrc}
80
- className={styles.iframe}
81
- title={exportName || 'Component widget'}
82
- sandbox="allow-same-origin allow-scripts"
83
- />
87
+ showIframe ? (
88
+ <iframe
89
+ src={iframeSrc}
90
+ className={styles.iframe}
91
+ title={exportName || 'Component widget'}
92
+ sandbox="allow-same-origin allow-scripts"
93
+ />
94
+ ) : (
95
+ <div className={styles.placeholder} />
96
+ )
84
97
  ) : Component ? (
85
98
  <ComponentErrorBoundary name={exportName}>
86
99
  <Component />
@@ -93,6 +106,7 @@ export default function ComponentWidget({
93
106
  onClick={(e) => {
94
107
  // Don't enter interactive mode for modifier clicks (shift/meta/ctrl for multi-select)
95
108
  if (e.shiftKey || e.metaKey || e.ctrlKey || e.altKey) return
109
+ if (useIframe) setShowIframe(true)
96
110
  enterInteractive()
97
111
  }}
98
112
  role="button"
@@ -101,6 +115,7 @@ export default function ComponentWidget({
101
115
  if (e.key === 'Enter' || e.key === ' ') {
102
116
  e.preventDefault()
103
117
  e.stopPropagation()
118
+ if (useIframe) setShowIframe(true)
104
119
  enterInteractive()
105
120
  }
106
121
  }}
@@ -19,3 +19,8 @@
19
19
  height: 100%;
20
20
  border: none;
21
21
  }
22
+
23
+ .placeholder {
24
+ width: 100%;
25
+ height: 100%;
26
+ }
@@ -4,6 +4,7 @@ import WidgetWrapper from './WidgetWrapper.jsx'
4
4
  import { readProp } from './widgetProps.js'
5
5
  import { schemas } from './widgetConfig.js'
6
6
  import { toFigmaEmbedUrl, getFigmaTitle, getFigmaType, isFigmaUrl } from './figmaUrl.js'
7
+ import { useIframeDevLogs } from './iframeDevLogs.js'
7
8
  import styles from './FigmaEmbed.module.css'
8
9
  import overlayStyles from './embedOverlay.module.css'
9
10
 
@@ -30,9 +31,11 @@ export default forwardRef(function FigmaEmbed({ props, onUpdate, resizable }, re
30
31
  const height = readProp(props, 'height', figmaEmbedSchema)
31
32
 
32
33
  const [interactive, setInteractive] = useState(false)
34
+ const [showIframe, setShowIframe] = useState(false)
33
35
  const [expanded, setExpanded] = useState(false)
34
36
 
35
37
  const iframeRef = useRef(null)
38
+ const embedRef = useRef(null)
36
39
  const inlineContainerRef = useRef(null)
37
40
  const modalContainerRef = useRef(null)
38
41
 
@@ -43,7 +46,28 @@ export default forwardRef(function FigmaEmbed({ props, onUpdate, resizable }, re
43
46
  const figmaType = useMemo(() => getFigmaType(url), [url])
44
47
  const typeLabel = figmaType ? TYPE_LABELS[figmaType] : 'Figma'
45
48
 
46
- const enterInteractive = useCallback(() => setInteractive(true), [])
49
+ useIframeDevLogs({
50
+ widget: 'FigmaEmbed',
51
+ loaded: showIframe && Boolean(embedUrl),
52
+ src: embedUrl,
53
+ })
54
+
55
+ const enterInteractive = useCallback(() => {
56
+ setShowIframe(true)
57
+ setInteractive(true)
58
+ }, [])
59
+
60
+ useEffect(() => {
61
+ if (!interactive || expanded) return
62
+ function handlePointerDown(e) {
63
+ if (embedRef.current && !embedRef.current.contains(e.target)) {
64
+ setInteractive(false)
65
+ setShowIframe(false)
66
+ }
67
+ }
68
+ document.addEventListener('pointerdown', handlePointerDown)
69
+ return () => document.removeEventListener('pointerdown', handlePointerDown)
70
+ }, [interactive, expanded])
47
71
 
48
72
  // Close expanded modal on Escape
49
73
  useEffect(() => {
@@ -97,6 +121,7 @@ export default forwardRef(function FigmaEmbed({ props, onUpdate, resizable }, re
97
121
  if (actionId === 'open-external') {
98
122
  if (url) window.open(url, '_blank', 'noopener')
99
123
  } else if (actionId === 'expand') {
124
+ setShowIframe(true)
100
125
  setExpanded(true)
101
126
  }
102
127
  },
@@ -105,26 +130,30 @@ export default forwardRef(function FigmaEmbed({ props, onUpdate, resizable }, re
105
130
  return (
106
131
  <>
107
132
  <WidgetWrapper>
108
- <div className={styles.embed} style={{ width, height }}>
133
+ <div ref={embedRef} className={styles.embed} style={{ width, height }}>
109
134
  <div className={styles.header}>
110
135
  <FigmaLogo />
111
136
  <span className={styles.headerTitle}>{title}</span>
112
137
  </div>
113
138
  {embedUrl ? (
114
139
  <>
115
- <div
116
- ref={inlineContainerRef}
117
- className={styles.iframeContainer}
118
- style={expanded ? { visibility: 'hidden' } : undefined}
119
- >
120
- <iframe
121
- ref={iframeRef}
122
- src={embedUrl}
123
- className={styles.iframe}
124
- title={`Figma ${typeLabel}: ${title}`}
125
- allowFullScreen
126
- />
127
- </div>
140
+ {showIframe ? (
141
+ <div
142
+ ref={inlineContainerRef}
143
+ className={styles.iframeContainer}
144
+ style={expanded ? { visibility: 'hidden' } : undefined}
145
+ >
146
+ <iframe
147
+ ref={iframeRef}
148
+ src={embedUrl}
149
+ className={styles.iframe}
150
+ title={`Figma ${typeLabel}: ${title}`}
151
+ allowFullScreen
152
+ />
153
+ </div>
154
+ ) : (
155
+ <div className={styles.iframeContainer} />
156
+ )}
128
157
  {!interactive && !expanded && (
129
158
  <div
130
159
  className={overlayStyles.interactOverlay}