@dfosco/storyboard-react 4.0.0-beta.4 → 4.0.0-beta.6
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 +94 -79
- package/src/canvas/ComponentErrorBoundary.jsx +50 -0
- package/src/canvas/canvasReloadGuard.js +37 -0
- package/src/canvas/canvasReloadGuard.test.js +27 -0
- package/src/canvas/componentIsolate.jsx +109 -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/canvas/widgets/WidgetChrome.module.css +2 -1
- 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.6",
|
|
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.6",
|
|
7
|
+
"@dfosco/tiny-canvas": "4.0.0-beta.6",
|
|
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'
|
|
@@ -158,7 +158,7 @@ const FIT_PADDING = 48
|
|
|
158
158
|
* Compute the axis-aligned bounding box that contains every widget and source.
|
|
159
159
|
* Returns { minX, minY, maxX, maxY } in canvas-space coordinates, or null if empty.
|
|
160
160
|
*/
|
|
161
|
-
function computeCanvasBounds(widgets,
|
|
161
|
+
function computeCanvasBounds(widgets, componentEntries) {
|
|
162
162
|
let minX = Infinity
|
|
163
163
|
let minY = Infinity
|
|
164
164
|
let maxX = -Infinity
|
|
@@ -179,24 +179,18 @@ function computeCanvasBounds(widgets, sources, jsxExports) {
|
|
|
179
179
|
hasItems = true
|
|
180
180
|
}
|
|
181
181
|
|
|
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
|
-
}
|
|
182
|
+
// Component widgets (from jsxExports or sources fallback)
|
|
183
|
+
for (const entry of componentEntries) {
|
|
184
|
+
const x = entry.sourceData?.position?.x ?? 0
|
|
185
|
+
const y = entry.sourceData?.position?.y ?? 0
|
|
186
|
+
const fallback = WIDGET_FALLBACK_SIZES['component']
|
|
187
|
+
const width = entry.sourceData?.width ?? fallback.width
|
|
188
|
+
const height = entry.sourceData?.height ?? fallback.height
|
|
189
|
+
minX = Math.min(minX, x)
|
|
190
|
+
minY = Math.min(minY, y)
|
|
191
|
+
maxX = Math.max(maxX, x + width)
|
|
192
|
+
maxY = Math.max(maxY, y + height)
|
|
193
|
+
hasItems = true
|
|
200
194
|
}
|
|
201
195
|
|
|
202
196
|
return hasItems ? { minX, minY, maxX, maxY } : null
|
|
@@ -275,7 +269,7 @@ function ChromeWrappedWidget({
|
|
|
275
269
|
* @param {{ name: string }} props - Canvas name as indexed by the data plugin
|
|
276
270
|
*/
|
|
277
271
|
export default function CanvasPage({ name }) {
|
|
278
|
-
const { canvas, jsxExports, loading } = useCanvas(name)
|
|
272
|
+
const { canvas, jsxExports, jsxError, loading } = useCanvas(name)
|
|
279
273
|
const isLocalDev = typeof window !== 'undefined' && window.__SB_LOCAL_DEV__ === true && !new URLSearchParams(window.location.search).has('prodMode')
|
|
280
274
|
|
|
281
275
|
// Local mutable copy of widgets for instant UI updates
|
|
@@ -294,6 +288,34 @@ export default function CanvasPage({ name }) {
|
|
|
294
288
|
const [snapEnabled, setSnapEnabled] = useState(canvas?.snapToGrid ?? false)
|
|
295
289
|
const [snapGridSize, setSnapGridSize] = useState(canvas?.gridSize || 40)
|
|
296
290
|
|
|
291
|
+
// Centralized list of component export names.
|
|
292
|
+
// When jsxExports is available, use it (discovers new exports not yet in sources).
|
|
293
|
+
// When jsxExports is null (module import failed), fall back to sources so iframes
|
|
294
|
+
// still render — the error is contained inside each iframe.
|
|
295
|
+
const componentEntries = useMemo(() => {
|
|
296
|
+
const sourceMap = Object.fromEntries(
|
|
297
|
+
(localSources || []).filter((s) => s?.export).map((s) => [s.export, s]),
|
|
298
|
+
)
|
|
299
|
+
if (jsxExports) {
|
|
300
|
+
return Object.keys(jsxExports).map((exportName) => ({
|
|
301
|
+
exportName,
|
|
302
|
+
Component: jsxExports[exportName],
|
|
303
|
+
sourceData: sourceMap[exportName] || {},
|
|
304
|
+
}))
|
|
305
|
+
}
|
|
306
|
+
// Fallback: use sources when module import failed (iframe isolation still works)
|
|
307
|
+
if (jsxError && canvas?._jsxModule) {
|
|
308
|
+
return (localSources || [])
|
|
309
|
+
.filter((s) => s?.export)
|
|
310
|
+
.map((s) => ({
|
|
311
|
+
exportName: s.export,
|
|
312
|
+
Component: null,
|
|
313
|
+
sourceData: s,
|
|
314
|
+
}))
|
|
315
|
+
}
|
|
316
|
+
return []
|
|
317
|
+
}, [jsxExports, jsxError, localSources, canvas?._jsxModule])
|
|
318
|
+
|
|
297
319
|
// Undo/redo history — tracks both widgets and sources as a combined snapshot
|
|
298
320
|
const undoRedo = useUndoRedo()
|
|
299
321
|
const stateRef = useRef({ widgets: localWidgets, sources: localSources })
|
|
@@ -673,16 +695,13 @@ export default function CanvasPage({ name }) {
|
|
|
673
695
|
// Check JSX sources (jsx-ExportName)
|
|
674
696
|
if (!widget && targetId.startsWith('jsx-')) {
|
|
675
697
|
const exportName = targetId.slice(4)
|
|
676
|
-
const
|
|
677
|
-
|
|
678
|
-
)
|
|
679
|
-
const sourceData = sourceMap[exportName]
|
|
680
|
-
if (sourceData || (jsxExports && exportName in jsxExports)) {
|
|
698
|
+
const entry = componentEntries.find((e) => e.exportName === exportName)
|
|
699
|
+
if (entry) {
|
|
681
700
|
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
|
|
701
|
+
x = entry.sourceData?.position?.x ?? 0
|
|
702
|
+
y = entry.sourceData?.position?.y ?? 0
|
|
703
|
+
w = entry.sourceData?.width ?? fallback.width
|
|
704
|
+
h = entry.sourceData?.height ?? fallback.height
|
|
686
705
|
}
|
|
687
706
|
}
|
|
688
707
|
|
|
@@ -696,7 +715,7 @@ export default function CanvasPage({ name }) {
|
|
|
696
715
|
const url = new URL(window.location.href)
|
|
697
716
|
url.searchParams.delete('widget')
|
|
698
717
|
window.history.replaceState({}, '', url.toString())
|
|
699
|
-
}, [loading, localWidgets,
|
|
718
|
+
}, [loading, localWidgets, componentEntries])
|
|
700
719
|
|
|
701
720
|
// Persist viewport state (zoom + scroll) to localStorage on changes
|
|
702
721
|
useEffect(() => {
|
|
@@ -893,7 +912,7 @@ export default function CanvasPage({ name }) {
|
|
|
893
912
|
const el = scrollRef.current
|
|
894
913
|
if (!el) return
|
|
895
914
|
|
|
896
|
-
const bounds = computeCanvasBounds(localWidgets,
|
|
915
|
+
const bounds = computeCanvasBounds(localWidgets, componentEntries)
|
|
897
916
|
if (!bounds) return
|
|
898
917
|
|
|
899
918
|
const boxW = bounds.maxX - bounds.minX + FIT_PADDING * 2
|
|
@@ -917,7 +936,7 @@ export default function CanvasPage({ name }) {
|
|
|
917
936
|
}
|
|
918
937
|
document.addEventListener('storyboard:canvas:zoom-to-fit', handleZoomToFit)
|
|
919
938
|
return () => document.removeEventListener('storyboard:canvas:zoom-to-fit', handleZoomToFit)
|
|
920
|
-
}, [localWidgets,
|
|
939
|
+
}, [localWidgets, componentEntries])
|
|
921
940
|
|
|
922
941
|
// Canvas background should follow toolbar theme target.
|
|
923
942
|
useEffect(() => {
|
|
@@ -1328,54 +1347,50 @@ export default function CanvasPage({ name }) {
|
|
|
1328
1347
|
// Merge JSX-sourced widgets (from .canvas.jsx) and JSON widgets
|
|
1329
1348
|
const allChildren = []
|
|
1330
1349
|
|
|
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)
|
|
1350
|
+
// 1. Component widgets (from jsxExports or sources fallback)
|
|
1338
1351
|
const componentFeatures = getFeatures('component')
|
|
1339
|
-
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
|
|
1352
|
+
for (const entry of componentEntries) {
|
|
1353
|
+
const { exportName, Component, sourceData } = entry
|
|
1354
|
+
const sourcePosition = sourceData.position || { x: 0, y: 0 }
|
|
1355
|
+
allChildren.push(
|
|
1356
|
+
<div
|
|
1357
|
+
key={`jsx-${exportName}`}
|
|
1358
|
+
id={`jsx-${exportName}`}
|
|
1359
|
+
data-tc-x={sourcePosition.x}
|
|
1360
|
+
data-tc-y={sourcePosition.y}
|
|
1361
|
+
{...(isLocalDev ? { 'data-tc-handle': '.tc-drag-handle, .tc-drag-surface' } : {})}
|
|
1362
|
+
{...canvasPrimerAttrs}
|
|
1363
|
+
style={canvasThemeVars}
|
|
1364
|
+
onClick={isLocalDev ? (e) => {
|
|
1365
|
+
e.stopPropagation()
|
|
1366
|
+
if (!e.target.closest('.tc-drag-handle')) {
|
|
1367
|
+
handleWidgetSelect(`jsx-${exportName}`, e.shiftKey)
|
|
1368
|
+
}
|
|
1369
|
+
} : undefined}
|
|
1370
|
+
>
|
|
1371
|
+
<WidgetChrome
|
|
1372
|
+
widgetId={`jsx-${exportName}`}
|
|
1373
|
+
features={componentFeatures}
|
|
1374
|
+
selected={selectedWidgetIds.has(`jsx-${exportName}`)}
|
|
1375
|
+
multiSelected={isMultiSelected && selectedWidgetIds.has(`jsx-${exportName}`)}
|
|
1376
|
+
onSelect={(shiftKey) => handleWidgetSelect(`jsx-${exportName}`, shiftKey)}
|
|
1377
|
+
onDeselect={() => setSelectedWidgetIds(new Set())}
|
|
1378
|
+
readOnly={!isLocalDev}
|
|
1358
1379
|
>
|
|
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
|
-
}
|
|
1380
|
+
<ComponentWidget
|
|
1381
|
+
component={Component}
|
|
1382
|
+
jsxModule={canvas?._jsxModule}
|
|
1383
|
+
exportName={exportName}
|
|
1384
|
+
canvasTheme={canvasTheme}
|
|
1385
|
+
isLocalDev={isLocalDev}
|
|
1386
|
+
width={sourceData.width}
|
|
1387
|
+
height={sourceData.height}
|
|
1388
|
+
onUpdate={isLocalDev ? (updates) => handleSourceUpdate(exportName, updates) : undefined}
|
|
1389
|
+
resizable={isResizable('component') && isLocalDev}
|
|
1390
|
+
/>
|
|
1391
|
+
</WidgetChrome>
|
|
1392
|
+
</div>
|
|
1393
|
+
)
|
|
1379
1394
|
}
|
|
1380
1395
|
|
|
1381
1396
|
// 2. JSON-defined mutable widgets (selectable, wrapped in WidgetChrome)
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { Component } from 'react'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Error boundary for canvas component widgets.
|
|
5
|
+
* Catches render-time errors so a single broken component
|
|
6
|
+
* doesn't crash the entire canvas page.
|
|
7
|
+
*
|
|
8
|
+
* Used as a production fallback when iframe isolation is not available.
|
|
9
|
+
*/
|
|
10
|
+
export default class ComponentErrorBoundary extends Component {
|
|
11
|
+
constructor(props) {
|
|
12
|
+
super(props)
|
|
13
|
+
this.state = { error: null }
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
static getDerivedStateFromError(error) {
|
|
17
|
+
return { error }
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
componentDidCatch(error, info) {
|
|
21
|
+
console.error(
|
|
22
|
+
`[storyboard] Component widget "${this.props.name || 'unknown'}" crashed:`,
|
|
23
|
+
error,
|
|
24
|
+
info?.componentStack,
|
|
25
|
+
)
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
render() {
|
|
29
|
+
if (this.state.error) {
|
|
30
|
+
return (
|
|
31
|
+
<div style={{
|
|
32
|
+
padding: '16px',
|
|
33
|
+
color: '#cf222e',
|
|
34
|
+
fontFamily: 'system-ui, -apple-system, sans-serif',
|
|
35
|
+
fontSize: '13px',
|
|
36
|
+
lineHeight: 1.5,
|
|
37
|
+
whiteSpace: 'pre-wrap',
|
|
38
|
+
wordBreak: 'break-word',
|
|
39
|
+
minWidth: 200,
|
|
40
|
+
minHeight: 60,
|
|
41
|
+
}}>
|
|
42
|
+
<strong>{this.props.name || 'Component'}</strong>
|
|
43
|
+
<br />
|
|
44
|
+
{String(this.state.error.message || this.state.error)}
|
|
45
|
+
</div>
|
|
46
|
+
)
|
|
47
|
+
}
|
|
48
|
+
return this.props.children
|
|
49
|
+
}
|
|
50
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
|
2
|
+
import { enableCanvasGuard, disableCanvasGuard, isCanvasGuardActive } from './canvasReloadGuard.js'
|
|
3
|
+
|
|
4
|
+
describe('canvasReloadGuard', () => {
|
|
5
|
+
beforeEach(() => {
|
|
6
|
+
disableCanvasGuard()
|
|
7
|
+
})
|
|
8
|
+
|
|
9
|
+
it('starts inactive', () => {
|
|
10
|
+
expect(isCanvasGuardActive()).toBe(false)
|
|
11
|
+
})
|
|
12
|
+
|
|
13
|
+
it('can be enabled and disabled', () => {
|
|
14
|
+
enableCanvasGuard()
|
|
15
|
+
expect(isCanvasGuardActive()).toBe(true)
|
|
16
|
+
disableCanvasGuard()
|
|
17
|
+
expect(isCanvasGuardActive()).toBe(false)
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
it('enable is idempotent', () => {
|
|
21
|
+
enableCanvasGuard()
|
|
22
|
+
enableCanvasGuard()
|
|
23
|
+
expect(isCanvasGuardActive()).toBe(true)
|
|
24
|
+
disableCanvasGuard()
|
|
25
|
+
expect(isCanvasGuardActive()).toBe(false)
|
|
26
|
+
})
|
|
27
|
+
})
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Canvas Component Isolate — iframe entry point.
|
|
3
|
+
*
|
|
4
|
+
* Renders a single named export from a .canvas.jsx module inside an
|
|
5
|
+
* isolated document. The parent CanvasPage embeds this via an iframe
|
|
6
|
+
* so a broken component cannot crash the entire canvas.
|
|
7
|
+
*
|
|
8
|
+
* Query params:
|
|
9
|
+
* module — absolute or base-relative path to the .canvas.jsx file
|
|
10
|
+
* export — the named export to render
|
|
11
|
+
* theme — canvas theme (light / dark / dark_dimmed)
|
|
12
|
+
*/
|
|
13
|
+
import { createElement, Component as ReactComponent } from 'react'
|
|
14
|
+
import { createRoot } from 'react-dom/client'
|
|
15
|
+
|
|
16
|
+
// ── Error Boundary ──────────────────────────────────────────────────
|
|
17
|
+
class IsolateErrorBoundary extends ReactComponent {
|
|
18
|
+
constructor(props) {
|
|
19
|
+
super(props)
|
|
20
|
+
this.state = { error: null }
|
|
21
|
+
}
|
|
22
|
+
static getDerivedStateFromError(error) {
|
|
23
|
+
return { error }
|
|
24
|
+
}
|
|
25
|
+
render() {
|
|
26
|
+
if (this.state.error) {
|
|
27
|
+
return createElement('div', { style: errorStyle },
|
|
28
|
+
createElement('strong', null, this.props.name || 'Component'),
|
|
29
|
+
createElement('br'),
|
|
30
|
+
String(this.state.error.message || this.state.error),
|
|
31
|
+
)
|
|
32
|
+
}
|
|
33
|
+
return this.props.children
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// ── Styles ──────────────────────────────────────────────────────────
|
|
38
|
+
const errorStyle = {
|
|
39
|
+
padding: '16px',
|
|
40
|
+
color: '#cf222e',
|
|
41
|
+
fontFamily: 'system-ui, -apple-system, sans-serif',
|
|
42
|
+
fontSize: '13px',
|
|
43
|
+
lineHeight: 1.5,
|
|
44
|
+
whiteSpace: 'pre-wrap',
|
|
45
|
+
wordBreak: 'break-word',
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// ── Resolve module path (mirrors useCanvas.resolveCanvasModuleImport) ─
|
|
49
|
+
function resolveModulePath(raw) {
|
|
50
|
+
if (!raw) return raw
|
|
51
|
+
if (/^[a-zA-Z][a-zA-Z\d+\-.]*:/.test(raw)) return raw
|
|
52
|
+
if (!raw.startsWith('/')) return raw
|
|
53
|
+
const base = (import.meta.env?.BASE_URL || '/').replace(/\/$/, '')
|
|
54
|
+
if (!base) return raw
|
|
55
|
+
if (raw.startsWith(base)) return raw
|
|
56
|
+
return `${base}${raw}`
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// ── Main ────────────────────────────────────────────────────────────
|
|
60
|
+
const params = new URLSearchParams(window.location.search)
|
|
61
|
+
const modulePath = params.get('module')
|
|
62
|
+
const exportName = params.get('export')
|
|
63
|
+
const theme = params.get('theme') || 'light'
|
|
64
|
+
|
|
65
|
+
// Apply theme to document for Primer / CSS-var inheritance
|
|
66
|
+
document.documentElement.setAttribute('data-color-mode', theme.startsWith('dark') ? 'dark' : 'light')
|
|
67
|
+
document.documentElement.setAttribute('data-dark-theme', theme.startsWith('dark') ? theme : '')
|
|
68
|
+
document.documentElement.setAttribute('data-light-theme', theme.startsWith('dark') ? '' : theme || 'light')
|
|
69
|
+
|
|
70
|
+
const root = createRoot(document.getElementById('root'))
|
|
71
|
+
|
|
72
|
+
async function mount() {
|
|
73
|
+
if (!modulePath || !exportName) {
|
|
74
|
+
root.render(createElement('div', { style: errorStyle }, 'Missing module or export param'))
|
|
75
|
+
return
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Validate: only allow .canvas.jsx modules
|
|
79
|
+
if (!modulePath.endsWith('.canvas.jsx')) {
|
|
80
|
+
root.render(createElement('div', { style: errorStyle }, 'Invalid module path — only .canvas.jsx files are allowed'))
|
|
81
|
+
return
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
try {
|
|
85
|
+
const resolved = resolveModulePath(modulePath)
|
|
86
|
+
const mod = await import(/* @vite-ignore */ resolved)
|
|
87
|
+
const Component = mod[exportName]
|
|
88
|
+
|
|
89
|
+
if (!Component || typeof Component !== 'function') {
|
|
90
|
+
throw new Error(`Export "${exportName}" not found or is not a component`)
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
root.render(
|
|
94
|
+
createElement(IsolateErrorBoundary, { name: exportName },
|
|
95
|
+
createElement(Component),
|
|
96
|
+
),
|
|
97
|
+
)
|
|
98
|
+
} catch (err) {
|
|
99
|
+
root.render(
|
|
100
|
+
createElement('div', { style: errorStyle },
|
|
101
|
+
createElement('strong', null, exportName),
|
|
102
|
+
createElement('br'),
|
|
103
|
+
String(err.message || err),
|
|
104
|
+
),
|
|
105
|
+
)
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
mount()
|
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
|
})
|
|
@@ -236,7 +236,7 @@
|
|
|
236
236
|
position: absolute;
|
|
237
237
|
top: calc(100% + 10px);
|
|
238
238
|
right: 0;
|
|
239
|
-
min-width:
|
|
239
|
+
min-width: max-content;
|
|
240
240
|
padding: 4px;
|
|
241
241
|
background: var(--bgColor-default, #ffffff);
|
|
242
242
|
border-radius: 10px;
|
|
@@ -265,6 +265,7 @@
|
|
|
265
265
|
color: var(--fgColor-default, #1f2328);
|
|
266
266
|
border-radius: 6px;
|
|
267
267
|
box-sizing: border-box;
|
|
268
|
+
white-space: nowrap;
|
|
268
269
|
}
|
|
269
270
|
|
|
270
271
|
:global([data-sb-canvas-theme^='dark']) .overflowItem {
|
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)
|