@dfosco/storyboard-react 4.0.0-beta.0 → 4.0.0-beta.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,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()
@@ -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
  })
@@ -13,16 +13,16 @@ describe('stickyNoteSchema', () => {
13
13
  )
14
14
  })
15
15
 
16
- it('does not include default values for width/height so new widgets size naturally', () => {
16
+ it('includes default values for width/height from config', () => {
17
17
  const defaults = getDefaults(stickyNoteSchema)
18
- expect(defaults).not.toHaveProperty('width')
19
- expect(defaults).not.toHaveProperty('height')
18
+ expect(defaults).toHaveProperty('width', 270)
19
+ expect(defaults).toHaveProperty('height', 170)
20
20
  })
21
21
 
22
- it('returns null when width/height are not saved in props', () => {
22
+ it('returns default value when width/height are not saved in props', () => {
23
23
  const props = { text: 'hello', color: 'yellow' }
24
- expect(readProp(props, 'width', stickyNoteSchema)).toBeNull()
25
- expect(readProp(props, 'height', stickyNoteSchema)).toBeNull()
24
+ expect(readProp(props, 'width', stickyNoteSchema)).toBe(270)
25
+ expect(readProp(props, 'height', stickyNoteSchema)).toBe(170)
26
26
  })
27
27
 
28
28
  it('returns saved width/height when present in props', () => {
@@ -33,11 +33,11 @@ describe('stickyNoteSchema', () => {
33
33
  })
34
34
 
35
35
  describe('StickyNote', () => {
36
- it('renders without explicit dimensions when width/height are not saved', () => {
36
+ it('applies default dimensions as inline styles when not saved in props', () => {
37
37
  const { container } = render(<StickyNote props={{ text: 'Hi' }} onUpdate={vi.fn()} />)
38
38
  const sticky = container.querySelector('article')
39
- expect(sticky.style.width).toBe('')
40
- expect(sticky.style.height).toBe('')
39
+ expect(sticky.style.width).toBe('270px')
40
+ expect(sticky.style.height).toBe('170px')
41
41
  })
42
42
 
43
43
  it('applies saved dimensions as inline styles', () => {
@@ -236,7 +236,7 @@
236
236
  position: absolute;
237
237
  top: calc(100% + 10px);
238
238
  right: 0;
239
- min-width: 180px;
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 {
@@ -2,14 +2,14 @@ import { describe, expect, it } from 'vitest'
2
2
  import { isResizable, getFeatures, getWidgetMeta } from './widgetConfig.js'
3
3
 
4
4
  describe('isResizable', () => {
5
- // Vitest runs with import.meta.env.PROD = true, so prod: false widgets
6
- // correctly return false. This tests the production behavior.
7
- it('returns false for resize-enabled widgets when prod is false (production env)', () => {
8
- expect(isResizable('sticky-note')).toBe(false)
9
- expect(isResizable('prototype')).toBe(false)
10
- expect(isResizable('figma-embed')).toBe(false)
11
- expect(isResizable('image')).toBe(false)
12
- expect(isResizable('component')).toBe(false)
5
+ // Vitest runs in dev mode by default (import.meta.env.PROD = false)
6
+ // In dev mode, all resize-enabled widgets are resizable
7
+ it('returns true for resize-enabled widgets in dev mode', () => {
8
+ expect(isResizable('sticky-note')).toBe(true)
9
+ expect(isResizable('prototype')).toBe(true)
10
+ expect(isResizable('figma-embed')).toBe(true)
11
+ expect(isResizable('image')).toBe(true)
12
+ expect(isResizable('component')).toBe(true)
13
13
  })
14
14
 
15
15
  it('returns false for widget types with resize disabled', () => {