@dfosco/storyboard-react 2.8.0 → 3.1.0

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,74 @@
1
+ import { useState, useEffect, useMemo } from 'react'
2
+ import { getCanvasData } from '@dfosco/storyboard-core'
3
+
4
+ /**
5
+ * Fetch fresh canvas data from the server's .canvas.json file.
6
+ * Falls back to build-time data if the server is unavailable.
7
+ */
8
+ async function fetchCanvasFromServer(name) {
9
+ try {
10
+ const base = (import.meta.env?.BASE_URL || '/').replace(/\/$/, '')
11
+ const res = await fetch(`${base}/_storyboard/canvas/read?name=${encodeURIComponent(name)}`)
12
+ if (res.ok) return res.json()
13
+ } catch { /* fall back to build-time data */ }
14
+ return null
15
+ }
16
+
17
+ /**
18
+ * Hook to load canvas data by name.
19
+ * Uses build-time data for static config (routes, JSX path), but fetches
20
+ * fresh widget data from the server to pick up persisted edits.
21
+ *
22
+ * @param {string} name - Canvas name as indexed by the data plugin
23
+ * @returns {{ canvas: object|null, jsxExports: object|null, loading: boolean }}
24
+ */
25
+ export function useCanvas(name) {
26
+ const buildTimeCanvas = useMemo(() => getCanvasData(name), [name])
27
+ const [canvas, setCanvas] = useState(buildTimeCanvas)
28
+ const [jsxExports, setJsxExports] = useState(null)
29
+ const [loading, setLoading] = useState(true)
30
+
31
+ // Fetch fresh data from server on mount
32
+ useEffect(() => {
33
+ if (!buildTimeCanvas) {
34
+ setCanvas(null)
35
+ setLoading(false)
36
+ return
37
+ }
38
+
39
+ setLoading(true)
40
+ fetchCanvasFromServer(name).then((fresh) => {
41
+ if (fresh) {
42
+ // Merge: use server data for widgets/sources, keep build-time for _route/_jsxModule
43
+ setCanvas({ ...buildTimeCanvas, ...fresh })
44
+ } else {
45
+ setCanvas(buildTimeCanvas)
46
+ }
47
+ setLoading(false)
48
+ })
49
+ }, [name, buildTimeCanvas])
50
+
51
+ useEffect(() => {
52
+ if (!canvas?._jsxModule) {
53
+ setJsxExports(null)
54
+ return
55
+ }
56
+
57
+ import(/* @vite-ignore */ canvas._jsxModule)
58
+ .then((mod) => {
59
+ const exports = {}
60
+ for (const [key, value] of Object.entries(mod)) {
61
+ if (key !== 'default' && typeof value === 'function') {
62
+ exports[key] = value
63
+ }
64
+ }
65
+ setJsxExports(exports)
66
+ })
67
+ .catch((err) => {
68
+ console.error(`[storyboard] Failed to load canvas JSX module: ${canvas._jsxModule}`, err)
69
+ setJsxExports(null)
70
+ })
71
+ }, [canvas?._jsxModule])
72
+
73
+ return { canvas, jsxExports, loading }
74
+ }
@@ -0,0 +1,15 @@
1
+ import WidgetWrapper from './WidgetWrapper.jsx'
2
+
3
+ /**
4
+ * Renders a live JSX export from a .canvas.jsx companion file.
5
+ * Content is read-only (re-renders on HMR), only position is mutable.
6
+ */
7
+ export default function ComponentWidget({ component: Component }) {
8
+ if (!Component) return null
9
+
10
+ return (
11
+ <WidgetWrapper>
12
+ <Component />
13
+ </WidgetWrapper>
14
+ )
15
+ }
@@ -0,0 +1,34 @@
1
+ import WidgetWrapper from './WidgetWrapper.jsx'
2
+ import { readProp, linkPreviewSchema } from './widgetProps.js'
3
+ import styles from './LinkPreview.module.css'
4
+
5
+ export default function LinkPreview({ props }) {
6
+ const url = readProp(props, 'url', linkPreviewSchema)
7
+ const title = readProp(props, 'title', linkPreviewSchema)
8
+
9
+ let hostname = ''
10
+ try {
11
+ hostname = new URL(url).hostname
12
+ } catch { /* invalid URL */ }
13
+
14
+ return (
15
+ <WidgetWrapper>
16
+ <div className={styles.card}>
17
+ <span className={styles.icon}>🔗</span>
18
+ <div className={styles.text}>
19
+ {title && <p className={styles.title}>{title}</p>}
20
+ <a
21
+ href={url}
22
+ target="_blank"
23
+ rel="noopener noreferrer"
24
+ className={styles.url}
25
+ onMouseDown={(e) => e.stopPropagation()}
26
+ onPointerDown={(e) => e.stopPropagation()}
27
+ >
28
+ {hostname || url}
29
+ </a>
30
+ </div>
31
+ </div>
32
+ </WidgetWrapper>
33
+ )
34
+ }
@@ -0,0 +1,51 @@
1
+ .card {
2
+ display: flex;
3
+ align-items: center;
4
+ gap: 12px;
5
+ padding: 14px 16px;
6
+ text-decoration: none;
7
+ color: inherit;
8
+ min-width: 240px;
9
+ transition: background 150ms;
10
+ }
11
+
12
+ .card:hover {
13
+ background: var(--bgColor-muted, #f6f8fa);
14
+ }
15
+
16
+ .icon {
17
+ font-size: 20px;
18
+ flex-shrink: 0;
19
+ }
20
+
21
+ .text {
22
+ overflow: hidden;
23
+ min-width: 0;
24
+ }
25
+
26
+ .title {
27
+ margin: 0;
28
+ font-size: 14px;
29
+ font-weight: 600;
30
+ line-height: 1.4;
31
+ color: var(--fgColor-default, #1f2328);
32
+ white-space: nowrap;
33
+ overflow: hidden;
34
+ text-overflow: ellipsis;
35
+ }
36
+
37
+ .url {
38
+ margin: 0;
39
+ font-size: 12px;
40
+ color: var(--fgColor-muted, #656d76);
41
+ white-space: nowrap;
42
+ overflow: hidden;
43
+ text-overflow: ellipsis;
44
+ text-decoration: none;
45
+ display: block;
46
+ }
47
+
48
+ .url:hover {
49
+ text-decoration: underline;
50
+ color: var(--fgColor-accent, #0969da);
51
+ }
@@ -0,0 +1,91 @@
1
+ import { useState, useRef, useEffect, useCallback } from 'react'
2
+ import WidgetWrapper from './WidgetWrapper.jsx'
3
+ import { readProp, markdownSchema } from './widgetProps.js'
4
+ import styles from './MarkdownBlock.module.css'
5
+
6
+ /**
7
+ * Renders markdown as plain HTML using a minimal built-in converter.
8
+ */
9
+ function renderMarkdown(text) {
10
+ 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
+ })
26
+ }
27
+
28
+ export default function MarkdownBlock({ props, onUpdate }) {
29
+ const content = readProp(props, 'content', markdownSchema)
30
+ const width = readProp(props, 'width', markdownSchema)
31
+ const [editing, setEditing] = useState(false)
32
+ const textareaRef = useRef(null)
33
+ const blockRef = useRef(null)
34
+ const [editHeight, setEditHeight] = useState(null)
35
+
36
+ const handleContentChange = useCallback((e) => {
37
+ onUpdate?.({ content: e.target.value })
38
+ }, [onUpdate])
39
+
40
+ useEffect(() => {
41
+ if (editing) {
42
+ // Capture the preview height before switching to editor
43
+ if (blockRef.current && !editHeight) {
44
+ setEditHeight(blockRef.current.offsetHeight)
45
+ }
46
+ if (textareaRef.current) {
47
+ textareaRef.current.focus()
48
+ }
49
+ } else {
50
+ setEditHeight(null)
51
+ }
52
+ }, [editing, editHeight])
53
+
54
+ return (
55
+ <WidgetWrapper>
56
+ <div
57
+ ref={blockRef}
58
+ className={styles.block}
59
+ style={{ width, minHeight: editHeight || undefined }}
60
+ >
61
+ {editing ? (
62
+ <textarea
63
+ ref={textareaRef}
64
+ className={styles.editor}
65
+ style={{ minHeight: editHeight ? editHeight - 2 : undefined }}
66
+ value={content}
67
+ onChange={handleContentChange}
68
+ onBlur={() => setEditing(false)}
69
+ onMouseDown={(e) => e.stopPropagation()}
70
+ onPointerDown={(e) => e.stopPropagation()}
71
+ onKeyDown={(e) => {
72
+ if (e.key === 'Escape') setEditing(false)
73
+ }}
74
+ placeholder="Write markdown…"
75
+ />
76
+ ) : (
77
+ <div
78
+ className={styles.preview}
79
+ onDoubleClick={() => setEditing(true)}
80
+ role="button"
81
+ tabIndex={0}
82
+ onKeyDown={(e) => { if (e.key === 'Enter') setEditing(true) }}
83
+ dangerouslySetInnerHTML={{
84
+ __html: renderMarkdown(content) || '<p class="placeholder">Double-click to edit…</p>',
85
+ }}
86
+ />
87
+ )}
88
+ </div>
89
+ </WidgetWrapper>
90
+ )
91
+ }
@@ -0,0 +1,78 @@
1
+ .block {
2
+ min-height: 80px;
3
+ background: var(--bgColor-default, #ffffff);
4
+ font-family: var(--tc-font-stack, system-ui, -apple-system, sans-serif);
5
+ }
6
+
7
+ .preview {
8
+ padding: 16px 20px;
9
+ font-size: 14px;
10
+ line-height: 1.6;
11
+ color: var(--fgColor-default, #1f2328);
12
+ cursor: text;
13
+ min-height: 60px;
14
+ }
15
+
16
+ .preview h1 {
17
+ font-size: 20px;
18
+ font-weight: 700;
19
+ margin: 0 0 8px;
20
+ line-height: 1.3;
21
+ }
22
+
23
+ .preview h2 {
24
+ font-size: 17px;
25
+ font-weight: 600;
26
+ margin: 0 0 6px;
27
+ line-height: 1.3;
28
+ }
29
+
30
+ .preview h3 {
31
+ font-size: 15px;
32
+ font-weight: 600;
33
+ margin: 0 0 4px;
34
+ line-height: 1.3;
35
+ }
36
+
37
+ .preview p {
38
+ margin: 0 0 8px;
39
+ }
40
+
41
+ .preview code {
42
+ background: var(--bgColor-neutral-muted, #afb8c133);
43
+ padding: 2px 5px;
44
+ border-radius: 4px;
45
+ font-size: 13px;
46
+ font-family: ui-monospace, monospace;
47
+ }
48
+
49
+ .preview ul {
50
+ margin: 0 0 8px;
51
+ padding-left: 20px;
52
+ }
53
+
54
+ .preview li {
55
+ margin: 0 0 2px;
56
+ }
57
+
58
+ .preview :global(.placeholder) {
59
+ color: var(--fgColor-muted, #656d76);
60
+ font-style: italic;
61
+ }
62
+
63
+ .editor {
64
+ display: block;
65
+ width: 100%;
66
+ height: 100%;
67
+ box-sizing: border-box;
68
+ min-height: 120px;
69
+ padding: 16px 20px;
70
+ border: none;
71
+ outline: none;
72
+ background: var(--bgColor-default, #ffffff);
73
+ font-family: ui-monospace, SFMono-Regular, monospace;
74
+ font-size: 13px;
75
+ line-height: 1.5;
76
+ color: var(--fgColor-default, #1f2328);
77
+ resize: none;
78
+ }
@@ -0,0 +1,179 @@
1
+ import { useState, useRef, useEffect, useCallback } from 'react'
2
+ import WidgetWrapper from './WidgetWrapper.jsx'
3
+ import { readProp, prototypeEmbedSchema } from './widgetProps.js'
4
+ import styles from './PrototypeEmbed.module.css'
5
+
6
+ export default function PrototypeEmbed({ props, onUpdate }) {
7
+ const src = readProp(props, 'src', prototypeEmbedSchema)
8
+ const width = readProp(props, 'width', prototypeEmbedSchema)
9
+ const height = readProp(props, 'height', prototypeEmbedSchema)
10
+ const zoom = readProp(props, 'zoom', prototypeEmbedSchema)
11
+ const label = readProp(props, 'label', prototypeEmbedSchema) || src
12
+
13
+ const basePath = (import.meta.env.BASE_URL || '/').replace(/\/$/, '')
14
+ const rawSrc = src ? `${basePath}${src}` : ''
15
+ const iframeSrc = rawSrc ? `${rawSrc}${rawSrc.includes('?') ? '&' : '?'}_sb_embed` : ''
16
+
17
+ const scale = zoom / 100
18
+
19
+ const [editing, setEditing] = useState(false)
20
+ const [interactive, setInteractive] = useState(false)
21
+ const inputRef = useRef(null)
22
+ const embedRef = useRef(null)
23
+
24
+ useEffect(() => {
25
+ if (editing && inputRef.current) {
26
+ inputRef.current.focus()
27
+ inputRef.current.select()
28
+ }
29
+ }, [editing])
30
+
31
+ // Exit interactive mode when clicking outside the embed
32
+ useEffect(() => {
33
+ if (!interactive) return
34
+ function handlePointerDown(e) {
35
+ if (embedRef.current && !embedRef.current.contains(e.target)) {
36
+ setInteractive(false)
37
+ }
38
+ }
39
+ document.addEventListener('pointerdown', handlePointerDown)
40
+ return () => document.removeEventListener('pointerdown', handlePointerDown)
41
+ }, [interactive])
42
+
43
+ const enterInteractive = useCallback(() => setInteractive(true), [])
44
+
45
+ function handleSubmit(e) {
46
+ e.preventDefault()
47
+ const value = inputRef.current?.value?.trim() || ''
48
+ onUpdate?.({ src: value })
49
+ setEditing(false)
50
+ }
51
+
52
+ return (
53
+ <WidgetWrapper>
54
+ <div
55
+ ref={embedRef}
56
+ className={styles.embed}
57
+ style={{ width, height }}
58
+ >
59
+ {editing ? (
60
+ <form
61
+ className={styles.urlForm}
62
+ onSubmit={handleSubmit}
63
+ onMouseDown={(e) => e.stopPropagation()}
64
+ onPointerDown={(e) => e.stopPropagation()}
65
+ >
66
+ <label className={styles.urlLabel}>Prototype URL path</label>
67
+ <input
68
+ ref={inputRef}
69
+ className={styles.urlInput}
70
+ type="text"
71
+ defaultValue={src}
72
+ placeholder="/MyPrototype/page"
73
+ onKeyDown={(e) => { if (e.key === 'Escape') setEditing(false) }}
74
+ />
75
+ <div className={styles.urlActions}>
76
+ <button type="submit" className={styles.urlSave}>Save</button>
77
+ <button type="button" className={styles.urlCancel} onClick={() => setEditing(false)}>Cancel</button>
78
+ </div>
79
+ </form>
80
+ ) : iframeSrc ? (
81
+ <>
82
+ <div className={styles.iframeContainer}>
83
+ <iframe
84
+ src={iframeSrc}
85
+ className={styles.iframe}
86
+ style={{
87
+ width: width / scale,
88
+ height: height / scale,
89
+ transform: `scale(${scale})`,
90
+ transformOrigin: '0 0',
91
+ }}
92
+ title={label || 'Prototype embed'}
93
+ sandbox="allow-same-origin allow-scripts allow-forms allow-popups"
94
+ />
95
+ </div>
96
+ {!interactive && (
97
+ <div
98
+ className={styles.dragOverlay}
99
+ onDoubleClick={enterInteractive}
100
+ />
101
+ )}
102
+ </>
103
+ ) : (
104
+ <div
105
+ className={styles.empty}
106
+ onDoubleClick={() => setEditing(true)}
107
+ role="button"
108
+ tabIndex={0}
109
+ onKeyDown={(e) => { if (e.key === 'Enter') setEditing(true) }}
110
+ >
111
+ <p>Double-click to set prototype URL</p>
112
+ </div>
113
+ )}
114
+ {iframeSrc && !editing && (
115
+ <button
116
+ className={styles.editBtn}
117
+ onClick={(e) => { e.stopPropagation(); setEditing(true) }}
118
+ onMouseDown={(e) => e.stopPropagation()}
119
+ onPointerDown={(e) => e.stopPropagation()}
120
+ title="Edit URL"
121
+ aria-label="Edit prototype URL"
122
+ >
123
+ <svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true"><path d="M11.013 1.427a1.75 1.75 0 0 1 2.474 0l1.086 1.086a1.75 1.75 0 0 1 0 2.474l-8.61 8.61c-.21.21-.47.364-.756.445l-3.251.93a.75.75 0 0 1-.927-.928l.929-3.25c.081-.286.235-.547.445-.758l8.61-8.61Zm.176 4.823L9.75 4.81l-6.286 6.287a.253.253 0 0 0-.064.108l-.558 1.953 1.953-.558a.253.253 0 0 0 .108-.064Zm1.238-3.763a.25.25 0 0 0-.354 0L10.811 3.75l1.439 1.44 1.263-1.263a.25.25 0 0 0 0-.354Z"/></svg>
124
+ </button>
125
+ )}
126
+ {iframeSrc && !editing && (
127
+ <div
128
+ className={styles.zoomBar}
129
+ onMouseDown={(e) => e.stopPropagation()}
130
+ onPointerDown={(e) => e.stopPropagation()}
131
+ >
132
+ <button
133
+ className={styles.zoomBtn}
134
+ onClick={() => {
135
+ const step = zoom <= 75 ? 5 : 25
136
+ onUpdate?.({ zoom: Math.max(25, zoom - step) })
137
+ }}
138
+ disabled={zoom <= 25}
139
+ aria-label="Zoom out"
140
+ >−</button>
141
+ <span className={styles.zoomLabel}>{zoom}%</span>
142
+ <button
143
+ className={styles.zoomBtn}
144
+ onClick={() => {
145
+ const step = zoom < 75 ? 5 : 25
146
+ onUpdate?.({ zoom: Math.min(200, zoom + step) })
147
+ }}
148
+ disabled={zoom >= 200}
149
+ aria-label="Zoom in"
150
+ >+</button>
151
+ </div>
152
+ )}
153
+ </div>
154
+ <div
155
+ className={styles.resizeHandle}
156
+ onMouseDown={(e) => {
157
+ e.stopPropagation()
158
+ e.preventDefault()
159
+ const startX = e.clientX
160
+ const startY = e.clientY
161
+ const startW = width
162
+ const startH = height
163
+ function onMove(ev) {
164
+ const newW = Math.max(200, startW + ev.clientX - startX)
165
+ const newH = Math.max(150, startH + ev.clientY - startY)
166
+ onUpdate?.({ width: newW, height: newH })
167
+ }
168
+ function onUp() {
169
+ document.removeEventListener('mousemove', onMove)
170
+ document.removeEventListener('mouseup', onUp)
171
+ }
172
+ document.addEventListener('mousemove', onMove)
173
+ document.addEventListener('mouseup', onUp)
174
+ }}
175
+ onPointerDown={(e) => e.stopPropagation()}
176
+ />
177
+ </WidgetWrapper>
178
+ )
179
+ }