@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 CHANGED
@@ -1,13 +1,16 @@
1
1
  {
2
2
  "name": "@dfosco/storyboard-react",
3
- "version": "4.0.0-beta.5",
3
+ "version": "4.0.0-beta.7",
4
4
  "type": "module",
5
5
  "dependencies": {
6
- "@dfosco/storyboard-core": "4.0.0-beta.5",
7
- "@dfosco/tiny-canvas": "4.0.0-beta.5",
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, sources, jsxExports) {
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
- // JSX sources
183
- const sourceMap = Object.fromEntries(
184
- (sources || []).filter((s) => s?.export).map((s) => [s.export, s])
185
- )
186
- if (jsxExports) {
187
- for (const exportName of Object.keys(jsxExports)) {
188
- const sourceData = sourceMap[exportName] || {}
189
- const x = sourceData.position?.x ?? 0
190
- const y = sourceData.position?.y ?? 0
191
- const fallback = WIDGET_FALLBACK_SIZES['component']
192
- const width = sourceData.width ?? fallback.width
193
- const height = sourceData.height ?? fallback.height
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 sourceMap = Object.fromEntries(
677
- (localSources || []).filter((s) => s?.export).map((s) => [s.export, s])
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, localSources, jsxExports])
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, localSources, jsxExports)
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, localSources, jsxExports])
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
- const sourceDataByExport = Object.fromEntries(
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
- if (jsxExports) {
1340
- for (const [exportName, Component] of Object.entries(jsxExports)) {
1341
- const sourceData = sourceDataByExport[exportName] || {}
1342
- const sourcePosition = sourceData.position || { x: 0, y: 0 }
1343
- allChildren.push(
1344
- <div
1345
- key={`jsx-${exportName}`}
1346
- id={`jsx-${exportName}`}
1347
- data-tc-x={sourcePosition.x}
1348
- data-tc-y={sourcePosition.y}
1349
- {...(isLocalDev ? { 'data-tc-handle': '.tc-drag-handle, .tc-drag-surface' } : {})}
1350
- {...canvasPrimerAttrs}
1351
- style={canvasThemeVars}
1352
- onClick={isLocalDev ? (e) => {
1353
- e.stopPropagation()
1354
- if (!e.target.closest('.tc-drag-handle')) {
1355
- handleWidgetSelect(`jsx-${exportName}`, e.shiftKey)
1356
- }
1357
- } : undefined}
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
- <WidgetChrome
1360
- widgetId={`jsx-${exportName}`}
1361
- features={componentFeatures}
1362
- selected={selectedWidgetIds.has(`jsx-${exportName}`)}
1363
- multiSelected={isMultiSelected && selectedWidgetIds.has(`jsx-${exportName}`)}
1364
- onSelect={(shiftKey) => handleWidgetSelect(`jsx-${exportName}`, shiftKey)}
1365
- onDeselect={() => setSelectedWidgetIds(new Set())}
1366
- readOnly={!isLocalDev}
1367
- >
1368
- <ComponentWidget
1369
- component={Component}
1370
- width={sourceData.width}
1371
- height={sourceData.height}
1372
- onUpdate={isLocalDev ? (updates) => handleSourceUpdate(exportName, updates) : undefined}
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
+ }
@@ -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
- * Content is read-only (re-renders on HMR), only position and size are mutable.
9
- * Cannot be deleted from canvas only removed from source code.
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({ component: Component, width, height, onUpdate, resizable }) {
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
- if (!Component) return null
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
- <Component />
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: auto;
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 as plain HTML using a minimal built-in converter.
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
- return text
12
- .replace(/^### (.+)$/gm, '<h3>$1</h3>')
13
- .replace(/^## (.+)$/gm, '<h2>$1</h2>')
14
- .replace(/^# (.+)$/gm, '<h1>$1</h1>')
15
- .replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
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
  })
@@ -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)