@dfosco/storyboard-react 4.0.0-beta.5 → 4.0.0-beta.7
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 +7 -4
- package/src/canvas/CanvasPage.jsx +143 -80
- package/src/canvas/canvasReloadGuard.js +37 -0
- package/src/canvas/useCanvas.js +6 -2
- package/src/canvas/widgets/ComponentWidget.jsx +47 -6
- package/src/canvas/widgets/ComponentWidget.module.css +8 -1
- package/src/canvas/widgets/MarkdownBlock.jsx +10 -17
- package/src/canvas/widgets/MarkdownBlock.module.css +82 -0
- package/src/canvas/widgets/MarkdownBlock.test.jsx +39 -0
- package/src/vite/data-plugin.js +35 -0
package/package.json
CHANGED
|
@@ -1,13 +1,16 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dfosco/storyboard-react",
|
|
3
|
-
"version": "4.0.0-beta.
|
|
3
|
+
"version": "4.0.0-beta.7",
|
|
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.7",
|
|
7
|
+
"@dfosco/tiny-canvas": "4.0.0-beta.7",
|
|
8
8
|
"@neodrag/react": "^2.3.1",
|
|
9
9
|
"glob": "^11.0.0",
|
|
10
|
-
"jsonc-parser": "^3.3.1"
|
|
10
|
+
"jsonc-parser": "^3.3.1",
|
|
11
|
+
"remark": "^15.0.1",
|
|
12
|
+
"remark-gfm": "^4.0.1",
|
|
13
|
+
"remark-html": "^16.0.1"
|
|
11
14
|
},
|
|
12
15
|
"license": "MIT",
|
|
13
16
|
"repository": {
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { createElement, useCallback, useEffect, useRef, useState } from 'react'
|
|
1
|
+
import { createElement, useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
|
2
2
|
import { flushSync } from 'react-dom'
|
|
3
3
|
import { Canvas } from '@dfosco/tiny-canvas'
|
|
4
4
|
import '@dfosco/tiny-canvas/style.css'
|
|
@@ -47,6 +47,35 @@ function resolveCanvasThemeFromStorage() {
|
|
|
47
47
|
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
|
|
48
48
|
}
|
|
49
49
|
|
|
50
|
+
/**
|
|
51
|
+
* Get the copyable URL for a widget based on its type.
|
|
52
|
+
* Returns the most relevant URL/path for the widget content.
|
|
53
|
+
*/
|
|
54
|
+
function getWidgetCopyableUrl(widget) {
|
|
55
|
+
const { type, props = {} } = widget
|
|
56
|
+
const base = (typeof import.meta !== 'undefined' && import.meta.env?.BASE_URL) || '/'
|
|
57
|
+
switch (type) {
|
|
58
|
+
case 'prototype':
|
|
59
|
+
// Prototype src is a path like "/MyPrototype" - make it a full URL
|
|
60
|
+
return props.src ? `${window.location.origin}${base.replace(/\/$/, '')}${props.src}` : ''
|
|
61
|
+
case 'figma-embed':
|
|
62
|
+
return props.url || ''
|
|
63
|
+
case 'link-preview':
|
|
64
|
+
return props.url || ''
|
|
65
|
+
case 'image':
|
|
66
|
+
// Return the served image URL
|
|
67
|
+
return props.src ? `${window.location.origin}${base.replace(/\/$/, '')}/_storyboard/canvas/images/${props.src}` : ''
|
|
68
|
+
case 'sticky-note':
|
|
69
|
+
// Sticky notes have text content, not a URL
|
|
70
|
+
return props.text || ''
|
|
71
|
+
case 'markdown':
|
|
72
|
+
// Markdown has content, not a URL
|
|
73
|
+
return props.content || ''
|
|
74
|
+
default:
|
|
75
|
+
return ''
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
50
79
|
/**
|
|
51
80
|
* Debounce helper — returns a function that delays invocation.
|
|
52
81
|
* Exposes `.cancel()` to abort pending calls (used by undo/redo).
|
|
@@ -158,7 +187,7 @@ const FIT_PADDING = 48
|
|
|
158
187
|
* Compute the axis-aligned bounding box that contains every widget and source.
|
|
159
188
|
* Returns { minX, minY, maxX, maxY } in canvas-space coordinates, or null if empty.
|
|
160
189
|
*/
|
|
161
|
-
function computeCanvasBounds(widgets,
|
|
190
|
+
function computeCanvasBounds(widgets, componentEntries) {
|
|
162
191
|
let minX = Infinity
|
|
163
192
|
let minY = Infinity
|
|
164
193
|
let maxX = -Infinity
|
|
@@ -179,24 +208,18 @@ function computeCanvasBounds(widgets, sources, jsxExports) {
|
|
|
179
208
|
hasItems = true
|
|
180
209
|
}
|
|
181
210
|
|
|
182
|
-
//
|
|
183
|
-
const
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
minX = Math.min(minX, x)
|
|
195
|
-
minY = Math.min(minY, y)
|
|
196
|
-
maxX = Math.max(maxX, x + width)
|
|
197
|
-
maxY = Math.max(maxY, y + height)
|
|
198
|
-
hasItems = true
|
|
199
|
-
}
|
|
211
|
+
// Component widgets (from jsxExports or sources fallback)
|
|
212
|
+
for (const entry of componentEntries) {
|
|
213
|
+
const x = entry.sourceData?.position?.x ?? 0
|
|
214
|
+
const y = entry.sourceData?.position?.y ?? 0
|
|
215
|
+
const fallback = WIDGET_FALLBACK_SIZES['component']
|
|
216
|
+
const width = entry.sourceData?.width ?? fallback.width
|
|
217
|
+
const height = entry.sourceData?.height ?? fallback.height
|
|
218
|
+
minX = Math.min(minX, x)
|
|
219
|
+
minY = Math.min(minY, y)
|
|
220
|
+
maxX = Math.max(maxX, x + width)
|
|
221
|
+
maxY = Math.max(maxY, y + height)
|
|
222
|
+
hasItems = true
|
|
200
223
|
}
|
|
201
224
|
|
|
202
225
|
return hasItems ? { minX, minY, maxX, maxY } : null
|
|
@@ -275,7 +298,7 @@ function ChromeWrappedWidget({
|
|
|
275
298
|
* @param {{ name: string }} props - Canvas name as indexed by the data plugin
|
|
276
299
|
*/
|
|
277
300
|
export default function CanvasPage({ name }) {
|
|
278
|
-
const { canvas, jsxExports, loading } = useCanvas(name)
|
|
301
|
+
const { canvas, jsxExports, jsxError, loading } = useCanvas(name)
|
|
279
302
|
const isLocalDev = typeof window !== 'undefined' && window.__SB_LOCAL_DEV__ === true && !new URLSearchParams(window.location.search).has('prodMode')
|
|
280
303
|
|
|
281
304
|
// Local mutable copy of widgets for instant UI updates
|
|
@@ -294,6 +317,34 @@ export default function CanvasPage({ name }) {
|
|
|
294
317
|
const [snapEnabled, setSnapEnabled] = useState(canvas?.snapToGrid ?? false)
|
|
295
318
|
const [snapGridSize, setSnapGridSize] = useState(canvas?.gridSize || 40)
|
|
296
319
|
|
|
320
|
+
// Centralized list of component export names.
|
|
321
|
+
// When jsxExports is available, use it (discovers new exports not yet in sources).
|
|
322
|
+
// When jsxExports is null (module import failed), fall back to sources so iframes
|
|
323
|
+
// still render — the error is contained inside each iframe.
|
|
324
|
+
const componentEntries = useMemo(() => {
|
|
325
|
+
const sourceMap = Object.fromEntries(
|
|
326
|
+
(localSources || []).filter((s) => s?.export).map((s) => [s.export, s]),
|
|
327
|
+
)
|
|
328
|
+
if (jsxExports) {
|
|
329
|
+
return Object.keys(jsxExports).map((exportName) => ({
|
|
330
|
+
exportName,
|
|
331
|
+
Component: jsxExports[exportName],
|
|
332
|
+
sourceData: sourceMap[exportName] || {},
|
|
333
|
+
}))
|
|
334
|
+
}
|
|
335
|
+
// Fallback: use sources when module import failed (iframe isolation still works)
|
|
336
|
+
if (jsxError && canvas?._jsxModule) {
|
|
337
|
+
return (localSources || [])
|
|
338
|
+
.filter((s) => s?.export)
|
|
339
|
+
.map((s) => ({
|
|
340
|
+
exportName: s.export,
|
|
341
|
+
Component: null,
|
|
342
|
+
sourceData: s,
|
|
343
|
+
}))
|
|
344
|
+
}
|
|
345
|
+
return []
|
|
346
|
+
}, [jsxExports, jsxError, localSources, canvas?._jsxModule])
|
|
347
|
+
|
|
297
348
|
// Undo/redo history — tracks both widgets and sources as a combined snapshot
|
|
298
349
|
const undoRedo = useUndoRedo()
|
|
299
350
|
const stateRef = useRef({ widgets: localWidgets, sources: localSources })
|
|
@@ -673,16 +724,13 @@ export default function CanvasPage({ name }) {
|
|
|
673
724
|
// Check JSX sources (jsx-ExportName)
|
|
674
725
|
if (!widget && targetId.startsWith('jsx-')) {
|
|
675
726
|
const exportName = targetId.slice(4)
|
|
676
|
-
const
|
|
677
|
-
|
|
678
|
-
)
|
|
679
|
-
const sourceData = sourceMap[exportName]
|
|
680
|
-
if (sourceData || (jsxExports && exportName in jsxExports)) {
|
|
727
|
+
const entry = componentEntries.find((e) => e.exportName === exportName)
|
|
728
|
+
if (entry) {
|
|
681
729
|
const fallback = WIDGET_FALLBACK_SIZES['component']
|
|
682
|
-
x = sourceData?.position?.x ?? 0
|
|
683
|
-
y = sourceData?.position?.y ?? 0
|
|
684
|
-
w = sourceData?.width ?? fallback.width
|
|
685
|
-
h = sourceData?.height ?? fallback.height
|
|
730
|
+
x = entry.sourceData?.position?.x ?? 0
|
|
731
|
+
y = entry.sourceData?.position?.y ?? 0
|
|
732
|
+
w = entry.sourceData?.width ?? fallback.width
|
|
733
|
+
h = entry.sourceData?.height ?? fallback.height
|
|
686
734
|
}
|
|
687
735
|
}
|
|
688
736
|
|
|
@@ -696,7 +744,7 @@ export default function CanvasPage({ name }) {
|
|
|
696
744
|
const url = new URL(window.location.href)
|
|
697
745
|
url.searchParams.delete('widget')
|
|
698
746
|
window.history.replaceState({}, '', url.toString())
|
|
699
|
-
}, [loading, localWidgets,
|
|
747
|
+
}, [loading, localWidgets, componentEntries])
|
|
700
748
|
|
|
701
749
|
// Persist viewport state (zoom + scroll) to localStorage on changes
|
|
702
750
|
useEffect(() => {
|
|
@@ -893,7 +941,7 @@ export default function CanvasPage({ name }) {
|
|
|
893
941
|
const el = scrollRef.current
|
|
894
942
|
if (!el) return
|
|
895
943
|
|
|
896
|
-
const bounds = computeCanvasBounds(localWidgets,
|
|
944
|
+
const bounds = computeCanvasBounds(localWidgets, componentEntries)
|
|
897
945
|
if (!bounds) return
|
|
898
946
|
|
|
899
947
|
const boxW = bounds.maxX - bounds.minX + FIT_PADDING * 2
|
|
@@ -917,7 +965,7 @@ export default function CanvasPage({ name }) {
|
|
|
917
965
|
}
|
|
918
966
|
document.addEventListener('storyboard:canvas:zoom-to-fit', handleZoomToFit)
|
|
919
967
|
return () => document.removeEventListener('storyboard:canvas:zoom-to-fit', handleZoomToFit)
|
|
920
|
-
}, [localWidgets,
|
|
968
|
+
}, [localWidgets, componentEntries])
|
|
921
969
|
|
|
922
970
|
// Canvas background should follow toolbar theme target.
|
|
923
971
|
useEffect(() => {
|
|
@@ -958,6 +1006,25 @@ export default function CanvasPage({ name }) {
|
|
|
958
1006
|
e.preventDefault()
|
|
959
1007
|
setSelectedWidgetIds(new Set())
|
|
960
1008
|
}
|
|
1009
|
+
// Copy: cmd+c copies URL, alt+cmd+c copies ID (single widget only)
|
|
1010
|
+
const mod = e.metaKey || e.ctrlKey
|
|
1011
|
+
if (mod && e.key === 'c' && selectedWidgetIds.size === 1) {
|
|
1012
|
+
const widgetId = [...selectedWidgetIds][0]
|
|
1013
|
+
const widget = localWidgets?.find(w => w.id === widgetId)
|
|
1014
|
+
if (widget) {
|
|
1015
|
+
e.preventDefault()
|
|
1016
|
+
if (e.altKey) {
|
|
1017
|
+
// alt+cmd+c → copy widget ID
|
|
1018
|
+
navigator.clipboard.writeText(widgetId).catch(() => {})
|
|
1019
|
+
} else {
|
|
1020
|
+
// cmd+c → copy widget URL/content
|
|
1021
|
+
const url = getWidgetCopyableUrl(widget)
|
|
1022
|
+
if (url) {
|
|
1023
|
+
navigator.clipboard.writeText(url).catch(() => {})
|
|
1024
|
+
}
|
|
1025
|
+
}
|
|
1026
|
+
}
|
|
1027
|
+
}
|
|
961
1028
|
if (e.key === 'Delete' || e.key === 'Backspace') {
|
|
962
1029
|
e.preventDefault()
|
|
963
1030
|
if (selectedWidgetIds.size > 1) {
|
|
@@ -983,7 +1050,7 @@ export default function CanvasPage({ name }) {
|
|
|
983
1050
|
}
|
|
984
1051
|
document.addEventListener('keydown', handleKeyDown)
|
|
985
1052
|
return () => document.removeEventListener('keydown', handleKeyDown)
|
|
986
|
-
}, [selectedWidgetIds, handleWidgetRemove, undoRedo, name, debouncedSave])
|
|
1053
|
+
}, [selectedWidgetIds, localWidgets, handleWidgetRemove, undoRedo, name, debouncedSave])
|
|
987
1054
|
|
|
988
1055
|
// Paste handler — images become image widgets, same-origin URLs become prototypes,
|
|
989
1056
|
// other URLs become link previews, text becomes markdown
|
|
@@ -1328,54 +1395,50 @@ export default function CanvasPage({ name }) {
|
|
|
1328
1395
|
// Merge JSX-sourced widgets (from .canvas.jsx) and JSON widgets
|
|
1329
1396
|
const allChildren = []
|
|
1330
1397
|
|
|
1331
|
-
|
|
1332
|
-
(localSources || [])
|
|
1333
|
-
.filter((source) => source?.export)
|
|
1334
|
-
.map((source) => [source.export, source])
|
|
1335
|
-
)
|
|
1336
|
-
|
|
1337
|
-
// 1. JSX-sourced component widgets (wrapped in WidgetChrome, not deletable)
|
|
1398
|
+
// 1. Component widgets (from jsxExports or sources fallback)
|
|
1338
1399
|
const componentFeatures = getFeatures('component')
|
|
1339
|
-
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
|
|
1400
|
+
for (const entry of componentEntries) {
|
|
1401
|
+
const { exportName, Component, sourceData } = entry
|
|
1402
|
+
const sourcePosition = sourceData.position || { x: 0, y: 0 }
|
|
1403
|
+
allChildren.push(
|
|
1404
|
+
<div
|
|
1405
|
+
key={`jsx-${exportName}`}
|
|
1406
|
+
id={`jsx-${exportName}`}
|
|
1407
|
+
data-tc-x={sourcePosition.x}
|
|
1408
|
+
data-tc-y={sourcePosition.y}
|
|
1409
|
+
{...(isLocalDev ? { 'data-tc-handle': '.tc-drag-handle, .tc-drag-surface' } : {})}
|
|
1410
|
+
{...canvasPrimerAttrs}
|
|
1411
|
+
style={canvasThemeVars}
|
|
1412
|
+
onClick={isLocalDev ? (e) => {
|
|
1413
|
+
e.stopPropagation()
|
|
1414
|
+
if (!e.target.closest('.tc-drag-handle')) {
|
|
1415
|
+
handleWidgetSelect(`jsx-${exportName}`, e.shiftKey)
|
|
1416
|
+
}
|
|
1417
|
+
} : undefined}
|
|
1418
|
+
>
|
|
1419
|
+
<WidgetChrome
|
|
1420
|
+
widgetId={`jsx-${exportName}`}
|
|
1421
|
+
features={componentFeatures}
|
|
1422
|
+
selected={selectedWidgetIds.has(`jsx-${exportName}`)}
|
|
1423
|
+
multiSelected={isMultiSelected && selectedWidgetIds.has(`jsx-${exportName}`)}
|
|
1424
|
+
onSelect={(shiftKey) => handleWidgetSelect(`jsx-${exportName}`, shiftKey)}
|
|
1425
|
+
onDeselect={() => setSelectedWidgetIds(new Set())}
|
|
1426
|
+
readOnly={!isLocalDev}
|
|
1358
1427
|
>
|
|
1359
|
-
<
|
|
1360
|
-
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
|
|
1366
|
-
|
|
1367
|
-
|
|
1368
|
-
|
|
1369
|
-
|
|
1370
|
-
|
|
1371
|
-
|
|
1372
|
-
|
|
1373
|
-
resizable={isResizable('component') && isLocalDev}
|
|
1374
|
-
/>
|
|
1375
|
-
</WidgetChrome>
|
|
1376
|
-
</div>
|
|
1377
|
-
)
|
|
1378
|
-
}
|
|
1428
|
+
<ComponentWidget
|
|
1429
|
+
component={Component}
|
|
1430
|
+
jsxModule={canvas?._jsxModule}
|
|
1431
|
+
exportName={exportName}
|
|
1432
|
+
canvasTheme={canvasTheme}
|
|
1433
|
+
isLocalDev={isLocalDev}
|
|
1434
|
+
width={sourceData.width}
|
|
1435
|
+
height={sourceData.height}
|
|
1436
|
+
onUpdate={isLocalDev ? (updates) => handleSourceUpdate(exportName, updates) : undefined}
|
|
1437
|
+
resizable={isResizable('component') && isLocalDev}
|
|
1438
|
+
/>
|
|
1439
|
+
</WidgetChrome>
|
|
1440
|
+
</div>
|
|
1441
|
+
)
|
|
1379
1442
|
}
|
|
1380
1443
|
|
|
1381
1444
|
// 2. JSON-defined mutable widgets (selectable, wrapped in WidgetChrome)
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Canvas reload guard — client-side state for preventing HMR full reloads.
|
|
3
|
+
*
|
|
4
|
+
* This module tracks whether a canvas is currently active. When active,
|
|
5
|
+
* the Vite plugin suppresses full-page reloads to preserve canvas state.
|
|
6
|
+
*
|
|
7
|
+
* The actual guard logic is implemented in:
|
|
8
|
+
* - Server: vite.config.js (ws.send monkey-patch + heartbeat)
|
|
9
|
+
* - Client: CanvasPage.jsx (vite:beforeFullReload + vite:ws:disconnect)
|
|
10
|
+
*
|
|
11
|
+
* This module provides the state that those systems check.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
let active = false
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Enable the canvas reload guard.
|
|
18
|
+
* Call when a canvas page mounts.
|
|
19
|
+
*/
|
|
20
|
+
export function enableCanvasGuard() {
|
|
21
|
+
active = true
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Disable the canvas reload guard.
|
|
26
|
+
* Call when a canvas page unmounts.
|
|
27
|
+
*/
|
|
28
|
+
export function disableCanvasGuard() {
|
|
29
|
+
active = false
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Check if the canvas reload guard is currently active.
|
|
34
|
+
*/
|
|
35
|
+
export function isCanvasGuardActive() {
|
|
36
|
+
return active
|
|
37
|
+
}
|
package/src/canvas/useCanvas.js
CHANGED
|
@@ -32,12 +32,13 @@ export function resolveCanvasModuleImport(modulePath, baseUrl = import.meta.env?
|
|
|
32
32
|
* fresh widget data from the server to pick up persisted edits.
|
|
33
33
|
*
|
|
34
34
|
* @param {string} name - Canvas name as indexed by the data plugin
|
|
35
|
-
* @returns {{ canvas: object|null, jsxExports: object|null, loading: boolean }}
|
|
35
|
+
* @returns {{ canvas: object|null, jsxExports: object|null, jsxError: boolean, loading: boolean }}
|
|
36
36
|
*/
|
|
37
37
|
export function useCanvas(name) {
|
|
38
38
|
const buildTimeCanvas = useMemo(() => getCanvasData(name), [name])
|
|
39
39
|
const [canvas, setCanvas] = useState(buildTimeCanvas)
|
|
40
40
|
const [jsxExports, setJsxExports] = useState(null)
|
|
41
|
+
const [jsxError, setJsxError] = useState(false)
|
|
41
42
|
const [loading, setLoading] = useState(true)
|
|
42
43
|
|
|
43
44
|
// Fetch fresh data from server on mount
|
|
@@ -66,6 +67,7 @@ export function useCanvas(name) {
|
|
|
66
67
|
useEffect(() => {
|
|
67
68
|
if (!jsxModule) {
|
|
68
69
|
setJsxExports(null)
|
|
70
|
+
setJsxError(false)
|
|
69
71
|
return
|
|
70
72
|
}
|
|
71
73
|
|
|
@@ -82,10 +84,12 @@ export function useCanvas(name) {
|
|
|
82
84
|
}
|
|
83
85
|
}
|
|
84
86
|
setJsxExports(exports)
|
|
87
|
+
setJsxError(false)
|
|
85
88
|
})
|
|
86
89
|
.catch((err) => {
|
|
87
90
|
console.error(`[storyboard] Failed to load canvas JSX module: ${jsxModule}`, err)
|
|
88
91
|
setJsxExports(null)
|
|
92
|
+
setJsxError(true)
|
|
89
93
|
})
|
|
90
94
|
}, [jsxModule, jsxImport])
|
|
91
95
|
|
|
@@ -109,5 +113,5 @@ export function useCanvas(name) {
|
|
|
109
113
|
}
|
|
110
114
|
}, [name, buildTimeCanvas])
|
|
111
115
|
|
|
112
|
-
return { canvas, jsxExports, loading }
|
|
116
|
+
return { canvas, jsxExports, jsxError, loading }
|
|
113
117
|
}
|
|
@@ -1,17 +1,33 @@
|
|
|
1
|
-
import { useRef, useCallback, useState, useEffect } from 'react'
|
|
1
|
+
import { useRef, useCallback, useState, useEffect, useMemo } from 'react'
|
|
2
2
|
import WidgetWrapper from './WidgetWrapper.jsx'
|
|
3
3
|
import ResizeHandle from './ResizeHandle.jsx'
|
|
4
|
+
import ComponentErrorBoundary from '../ComponentErrorBoundary.jsx'
|
|
4
5
|
import styles from './ComponentWidget.module.css'
|
|
5
6
|
|
|
6
7
|
/**
|
|
7
8
|
* Renders a live JSX export from a .canvas.jsx companion file.
|
|
8
|
-
*
|
|
9
|
-
*
|
|
9
|
+
*
|
|
10
|
+
* In dev mode (isLocalDev), each component is rendered inside an iframe
|
|
11
|
+
* via the /_storyboard/canvas/isolate middleware. This isolates broken
|
|
12
|
+
* components so they cannot crash the entire canvas page.
|
|
13
|
+
*
|
|
14
|
+
* In production, the component is rendered directly with an ErrorBoundary
|
|
15
|
+
* as a fallback safety net.
|
|
10
16
|
*
|
|
11
17
|
* Double-click the overlay to enter interactive mode (dropdowns, buttons work).
|
|
12
18
|
* Click outside to exit interactive mode.
|
|
13
19
|
*/
|
|
14
|
-
export default function ComponentWidget({
|
|
20
|
+
export default function ComponentWidget({
|
|
21
|
+
component: Component,
|
|
22
|
+
jsxModule,
|
|
23
|
+
exportName,
|
|
24
|
+
canvasTheme,
|
|
25
|
+
isLocalDev,
|
|
26
|
+
width,
|
|
27
|
+
height,
|
|
28
|
+
onUpdate,
|
|
29
|
+
resizable,
|
|
30
|
+
}) {
|
|
15
31
|
const containerRef = useRef(null)
|
|
16
32
|
const [interactive, setInteractive] = useState(false)
|
|
17
33
|
|
|
@@ -33,7 +49,21 @@ export default function ComponentWidget({ component: Component, width, height, o
|
|
|
33
49
|
return () => document.removeEventListener('pointerdown', handlePointerDown)
|
|
34
50
|
}, [interactive])
|
|
35
51
|
|
|
36
|
-
|
|
52
|
+
// Build iframe src for dev isolation
|
|
53
|
+
const iframeSrc = useMemo(() => {
|
|
54
|
+
if (!isLocalDev || !jsxModule || !exportName) return null
|
|
55
|
+
const basePath = (import.meta.env.BASE_URL || '/').replace(/\/$/, '')
|
|
56
|
+
const params = new URLSearchParams({
|
|
57
|
+
module: jsxModule,
|
|
58
|
+
export: exportName,
|
|
59
|
+
theme: canvasTheme || 'light',
|
|
60
|
+
})
|
|
61
|
+
return `${basePath}/_storyboard/canvas/isolate?${params}`
|
|
62
|
+
}, [isLocalDev, jsxModule, exportName, canvasTheme])
|
|
63
|
+
|
|
64
|
+
const useIframe = isLocalDev && iframeSrc
|
|
65
|
+
|
|
66
|
+
if (!useIframe && !Component) return null
|
|
37
67
|
|
|
38
68
|
const sizeStyle = {}
|
|
39
69
|
if (typeof width === 'number') sizeStyle.width = `${width}px`
|
|
@@ -43,7 +73,18 @@ export default function ComponentWidget({ component: Component, width, height, o
|
|
|
43
73
|
<WidgetWrapper>
|
|
44
74
|
<div ref={containerRef} className={styles.container} style={sizeStyle}>
|
|
45
75
|
<div className={styles.content}>
|
|
46
|
-
|
|
76
|
+
{useIframe ? (
|
|
77
|
+
<iframe
|
|
78
|
+
src={iframeSrc}
|
|
79
|
+
className={styles.iframe}
|
|
80
|
+
title={exportName || 'Component widget'}
|
|
81
|
+
sandbox="allow-same-origin allow-scripts"
|
|
82
|
+
/>
|
|
83
|
+
) : Component ? (
|
|
84
|
+
<ComponentErrorBoundary name={exportName}>
|
|
85
|
+
<Component />
|
|
86
|
+
</ComponentErrorBoundary>
|
|
87
|
+
) : null}
|
|
47
88
|
</div>
|
|
48
89
|
{!interactive && (
|
|
49
90
|
<div
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
.container {
|
|
2
2
|
position: relative;
|
|
3
|
-
overflow:
|
|
3
|
+
overflow: hidden;
|
|
4
4
|
min-width: 100px;
|
|
5
5
|
min-height: 60px;
|
|
6
6
|
}
|
|
@@ -10,6 +10,13 @@
|
|
|
10
10
|
height: 100%;
|
|
11
11
|
}
|
|
12
12
|
|
|
13
|
+
.iframe {
|
|
14
|
+
display: block;
|
|
15
|
+
width: 100%;
|
|
16
|
+
height: 100%;
|
|
17
|
+
border: none;
|
|
18
|
+
}
|
|
19
|
+
|
|
13
20
|
.interactOverlay {
|
|
14
21
|
position: absolute;
|
|
15
22
|
inset: 0;
|
|
@@ -1,28 +1,21 @@
|
|
|
1
|
-
import { useState, useRef, useEffect, useCallback } from 'react'
|
|
1
|
+
import { useState, useRef, useEffect, useCallback, useMemo } from 'react'
|
|
2
|
+
import { remark } from 'remark'
|
|
3
|
+
import remarkGfm from 'remark-gfm'
|
|
4
|
+
import remarkHtml from 'remark-html'
|
|
2
5
|
import WidgetWrapper from './WidgetWrapper.jsx'
|
|
3
6
|
import { readProp, markdownSchema } from './widgetProps.js'
|
|
4
7
|
import styles from './MarkdownBlock.module.css'
|
|
5
8
|
|
|
6
9
|
/**
|
|
7
|
-
* Renders markdown
|
|
10
|
+
* Renders markdown to HTML using remark with GitHub Flavored Markdown support.
|
|
8
11
|
*/
|
|
9
12
|
function renderMarkdown(text) {
|
|
10
13
|
if (!text) return ''
|
|
11
|
-
|
|
12
|
-
.
|
|
13
|
-
.
|
|
14
|
-
.
|
|
15
|
-
|
|
16
|
-
.replace(/\*(.+?)\*/g, '<em>$1</em>')
|
|
17
|
-
.replace(/`(.+?)`/g, '<code>$1</code>')
|
|
18
|
-
.replace(/^- (.+)$/gm, '<li>$1</li>')
|
|
19
|
-
.replace(/(<li>.*<\/li>)/s, '<ul>$1</ul>')
|
|
20
|
-
.replace(/\n\n/g, '</p><p>')
|
|
21
|
-
.replace(/\n/g, '<br>')
|
|
22
|
-
.replace(/^(.+)$/gm, (line) => {
|
|
23
|
-
if (line.startsWith('<')) return line
|
|
24
|
-
return `<p>${line}</p>`
|
|
25
|
-
})
|
|
14
|
+
const result = remark()
|
|
15
|
+
.use(remarkGfm)
|
|
16
|
+
.use(remarkHtml, { sanitize: false })
|
|
17
|
+
.processSync(text)
|
|
18
|
+
return String(result)
|
|
26
19
|
}
|
|
27
20
|
|
|
28
21
|
export default function MarkdownBlock({ props, onUpdate }) {
|
|
@@ -65,6 +65,88 @@
|
|
|
65
65
|
margin: 0 0 2px;
|
|
66
66
|
}
|
|
67
67
|
|
|
68
|
+
.preview ol {
|
|
69
|
+
margin: 0 0 8px;
|
|
70
|
+
padding-left: 20px;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/* GFM: Task lists */
|
|
74
|
+
.preview input[type="checkbox"] {
|
|
75
|
+
margin-right: 6px;
|
|
76
|
+
pointer-events: none;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
.preview li:has(input[type="checkbox"]) {
|
|
80
|
+
list-style: none;
|
|
81
|
+
margin-left: -20px;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/* GFM: Strikethrough */
|
|
85
|
+
.preview del {
|
|
86
|
+
text-decoration: line-through;
|
|
87
|
+
color: var(--sb--markdown-muted);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/* GFM: Tables */
|
|
91
|
+
.preview table {
|
|
92
|
+
border-collapse: collapse;
|
|
93
|
+
margin: 8px 0;
|
|
94
|
+
width: 100%;
|
|
95
|
+
font-size: 13px;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
.preview th,
|
|
99
|
+
.preview td {
|
|
100
|
+
border: 1px solid var(--borderColor-default, #d0d7de);
|
|
101
|
+
padding: 6px 12px;
|
|
102
|
+
text-align: left;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
.preview th {
|
|
106
|
+
background: var(--bgColor-muted, #f6f8fa);
|
|
107
|
+
font-weight: 600;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/* GFM: Autolinks */
|
|
111
|
+
.preview a {
|
|
112
|
+
color: var(--sb--markdown-accent);
|
|
113
|
+
text-decoration: none;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
.preview a:hover {
|
|
117
|
+
text-decoration: underline;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/* Code blocks */
|
|
121
|
+
.preview pre {
|
|
122
|
+
background: var(--bgColor-neutral-muted, #afb8c133);
|
|
123
|
+
padding: 12px 16px;
|
|
124
|
+
border-radius: 6px;
|
|
125
|
+
overflow-x: auto;
|
|
126
|
+
margin: 8px 0;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
.preview pre code {
|
|
130
|
+
background: none;
|
|
131
|
+
padding: 0;
|
|
132
|
+
font-size: 13px;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/* Blockquotes */
|
|
136
|
+
.preview blockquote {
|
|
137
|
+
border-left: 4px solid var(--borderColor-default, #d0d7de);
|
|
138
|
+
margin: 8px 0;
|
|
139
|
+
padding: 4px 16px;
|
|
140
|
+
color: var(--sb--markdown-muted);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/* Horizontal rules */
|
|
144
|
+
.preview hr {
|
|
145
|
+
border: none;
|
|
146
|
+
border-top: 1px solid var(--borderColor-default, #d0d7de);
|
|
147
|
+
margin: 16px 0;
|
|
148
|
+
}
|
|
149
|
+
|
|
68
150
|
.preview :global(.placeholder) {
|
|
69
151
|
color: var(--sb--markdown-muted);
|
|
70
152
|
font-style: italic;
|
|
@@ -50,4 +50,43 @@ describe('MarkdownBlock', () => {
|
|
|
50
50
|
|
|
51
51
|
expect(setData).toHaveBeenCalledWith('text/plain', '**Hello**\n- item')
|
|
52
52
|
})
|
|
53
|
+
|
|
54
|
+
describe('GitHub Flavored Markdown', () => {
|
|
55
|
+
it('renders tables', () => {
|
|
56
|
+
const markdown = `| Name | Age |
|
|
57
|
+
| --- | --- |
|
|
58
|
+
| Alice | 30 |`
|
|
59
|
+
const { container } = render(<MarkdownBlock props={{ content: markdown, width: 420 }} />)
|
|
60
|
+
|
|
61
|
+
expect(container.querySelector('table')).not.toBeNull()
|
|
62
|
+
expect(container.querySelector('th')).not.toBeNull()
|
|
63
|
+
expect(screen.getByText('Alice')).toBeTruthy()
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
it('renders task lists', () => {
|
|
67
|
+
const markdown = `- [x] Done
|
|
68
|
+
- [ ] Todo`
|
|
69
|
+
const { container } = render(<MarkdownBlock props={{ content: markdown, width: 420 }} />)
|
|
70
|
+
|
|
71
|
+
const checkboxes = container.querySelectorAll('input[type="checkbox"]')
|
|
72
|
+
expect(checkboxes).toHaveLength(2)
|
|
73
|
+
expect(checkboxes[0].checked).toBe(true)
|
|
74
|
+
expect(checkboxes[1].checked).toBe(false)
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
it('renders strikethrough', () => {
|
|
78
|
+
const { container } = render(<MarkdownBlock props={{ content: '~~deleted~~', width: 420 }} />)
|
|
79
|
+
|
|
80
|
+
expect(container.querySelector('del')).not.toBeNull()
|
|
81
|
+
expect(screen.getByText('deleted')).toBeTruthy()
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
it('renders autolinks', () => {
|
|
85
|
+
const { container } = render(<MarkdownBlock props={{ content: 'https://github.com', width: 420 }} />)
|
|
86
|
+
|
|
87
|
+
const link = container.querySelector('a')
|
|
88
|
+
expect(link).not.toBeNull()
|
|
89
|
+
expect(link.href).toBe('https://github.com/')
|
|
90
|
+
})
|
|
91
|
+
})
|
|
53
92
|
})
|
package/src/vite/data-plugin.js
CHANGED
|
@@ -675,6 +675,41 @@ export default function storyboardDataPlugin() {
|
|
|
675
675
|
},
|
|
676
676
|
|
|
677
677
|
configureServer(server) {
|
|
678
|
+
// ── Component isolate middleware ───────────────────────────────
|
|
679
|
+
// Serves a minimal HTML shell for iframe-isolated component widgets.
|
|
680
|
+
// The iframe loads componentIsolate.jsx which reads query params
|
|
681
|
+
// (module, export, theme) and renders a single canvas.jsx export.
|
|
682
|
+
const isolateEntryPath = new URL('../canvas/componentIsolate.jsx', import.meta.url).pathname
|
|
683
|
+
server.middlewares.use(async (req, res, next) => {
|
|
684
|
+
if (!req.url) return next()
|
|
685
|
+
let url = req.url
|
|
686
|
+
const baseNoTrail = (server.config.base || '/').replace(/\/$/, '')
|
|
687
|
+
if (baseNoTrail && url.startsWith(baseNoTrail)) {
|
|
688
|
+
url = url.slice(baseNoTrail.length) || '/'
|
|
689
|
+
}
|
|
690
|
+
if (!url.startsWith('/_storyboard/canvas/isolate')) return next()
|
|
691
|
+
|
|
692
|
+
const rawHtml = [
|
|
693
|
+
'<!DOCTYPE html>',
|
|
694
|
+
'<html><head>',
|
|
695
|
+
'<style>html,body{margin:0;padding:0;width:100%;height:100%}#root{width:100%;height:100%}</style>',
|
|
696
|
+
'</head><body>',
|
|
697
|
+
'<div id="root"></div>',
|
|
698
|
+
`<script type="module" src="/@fs${isolateEntryPath}"></script>`,
|
|
699
|
+
'</body></html>',
|
|
700
|
+
].join('\n')
|
|
701
|
+
|
|
702
|
+
try {
|
|
703
|
+
const html = await server.transformIndexHtml(req.url, rawHtml)
|
|
704
|
+
res.writeHead(200, { 'Content-Type': 'text/html' })
|
|
705
|
+
res.end(html)
|
|
706
|
+
} catch (err) {
|
|
707
|
+
console.error('[storyboard] Component isolate HTML transform failed:', err)
|
|
708
|
+
res.writeHead(500, { 'Content-Type': 'text/plain' })
|
|
709
|
+
res.end('Component isolate failed')
|
|
710
|
+
}
|
|
711
|
+
})
|
|
712
|
+
|
|
678
713
|
// Watch for data file changes in dev mode
|
|
679
714
|
const watcher = server.watcher
|
|
680
715
|
if (!buildResult) buildResult = buildIndex(root)
|