@dfosco/storyboard-react 2.8.0 → 3.0.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.
- package/package.json +6 -3
- package/src/Viewfinder.jsx +5 -4
- package/src/canvas/CanvasPage.jsx +145 -0
- package/src/canvas/CanvasPage.module.css +15 -0
- package/src/canvas/CanvasToolbar.jsx +76 -0
- package/src/canvas/CanvasToolbar.module.css +92 -0
- package/src/canvas/canvasApi.js +41 -0
- package/src/canvas/useCanvas.js +74 -0
- package/src/canvas/widgets/ComponentWidget.jsx +15 -0
- package/src/canvas/widgets/MarkdownBlock.jsx +87 -0
- package/src/canvas/widgets/MarkdownBlock.module.css +78 -0
- package/src/canvas/widgets/PrototypeEmbed.jsx +33 -0
- package/src/canvas/widgets/PrototypeEmbed.module.css +26 -0
- package/src/canvas/widgets/StickyNote.jsx +106 -0
- package/src/canvas/widgets/StickyNote.module.css +136 -0
- package/src/canvas/widgets/WidgetWrapper.jsx +28 -0
- package/src/canvas/widgets/WidgetWrapper.module.css +53 -0
- package/src/canvas/widgets/index.js +21 -0
- package/src/canvas/widgets/widgetProps.js +144 -0
- package/src/hooks/useFeatureFlag.js +2 -4
- package/src/hooks/useFlows.js +50 -0
- package/src/hooks/useFlows.test.js +134 -0
- package/src/index.js +5 -0
- package/src/vite/data-plugin.js +129 -29
- package/src/vite/data-plugin.test.js +3 -3
package/package.json
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dfosco/storyboard-react",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "3.0.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"dependencies": {
|
|
6
|
-
"@dfosco/storyboard-core": "
|
|
6
|
+
"@dfosco/storyboard-core": "3.0.0",
|
|
7
|
+
"@dfosco/tiny-canvas": "file:../tiny-canvas",
|
|
8
|
+
"@neodrag/react": "^2.3.1",
|
|
7
9
|
"glob": "^11.0.0",
|
|
8
10
|
"jsonc-parser": "^3.3.1"
|
|
9
11
|
},
|
|
@@ -24,6 +26,7 @@
|
|
|
24
26
|
"exports": {
|
|
25
27
|
".": "./src/index.js",
|
|
26
28
|
"./vite": "./src/vite/data-plugin.js",
|
|
27
|
-
"./hash-preserver": "./src/hashPreserver.js"
|
|
29
|
+
"./hash-preserver": "./src/hashPreserver.js",
|
|
30
|
+
"./canvas/CanvasPage": "./src/canvas/CanvasPage.jsx"
|
|
28
31
|
}
|
|
29
32
|
}
|
package/src/Viewfinder.jsx
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
|
|
2
|
-
import { useRef, useEffect } from 'react'
|
|
2
|
+
import { useRef, useEffect, useMemo } from 'react'
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
5
|
* Viewfinder — thin React wrapper around the Svelte Viewfinder component.
|
|
@@ -23,9 +23,10 @@ export default function Viewfinder({ pageModules = {}, basePath, title = 'Storyb
|
|
|
23
23
|
|
|
24
24
|
const shouldHideDefault = hideDefaultFlow ?? hideDefaultScene
|
|
25
25
|
|
|
26
|
-
const knownRoutes = Object.keys(pageModules)
|
|
26
|
+
const knownRoutes = useMemo(() => Object.keys(pageModules)
|
|
27
27
|
.map(p => p.replace('/src/prototypes/', '').replace('.jsx', ''))
|
|
28
|
-
.filter(n => !n.startsWith('_') && n !== 'index' && n !== 'viewfinder')
|
|
28
|
+
.filter(n => !n.startsWith('_') && n !== 'index' && n !== 'viewfinder'),
|
|
29
|
+
[pageModules])
|
|
29
30
|
|
|
30
31
|
useEffect(() => {
|
|
31
32
|
if (!containerRef.current) return
|
|
@@ -53,7 +54,7 @@ export default function Viewfinder({ pageModules = {}, basePath, title = 'Storyb
|
|
|
53
54
|
handleRef.current = null
|
|
54
55
|
}
|
|
55
56
|
}
|
|
56
|
-
}, [title, subtitle, basePath, showThumbnails, shouldHideDefault])
|
|
57
|
+
}, [title, subtitle, basePath, knownRoutes, showThumbnails, shouldHideDefault])
|
|
57
58
|
|
|
58
59
|
return <div ref={containerRef} style={{ minHeight: '100vh' }} />
|
|
59
60
|
}
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import { createElement, useCallback, useRef, useState } from 'react'
|
|
2
|
+
import { Canvas } from '@dfosco/tiny-canvas'
|
|
3
|
+
import { useCanvas } from './useCanvas.js'
|
|
4
|
+
import { getWidgetComponent } from './widgets/index.js'
|
|
5
|
+
import ComponentWidget from './widgets/ComponentWidget.jsx'
|
|
6
|
+
import CanvasToolbar from './CanvasToolbar.jsx'
|
|
7
|
+
import { updateCanvas, removeWidget as removeWidgetApi } from './canvasApi.js'
|
|
8
|
+
import styles from './CanvasPage.module.css'
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Debounce helper — returns a function that delays invocation.
|
|
12
|
+
*/
|
|
13
|
+
function debounce(fn, ms) {
|
|
14
|
+
let timer
|
|
15
|
+
return (...args) => {
|
|
16
|
+
clearTimeout(timer)
|
|
17
|
+
timer = setTimeout(() => fn(...args), ms)
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Renders a single JSON-defined widget by type lookup. */
|
|
22
|
+
function WidgetRenderer({ widget, onUpdate, onRemove }) {
|
|
23
|
+
const Component = getWidgetComponent(widget.type)
|
|
24
|
+
if (!Component) {
|
|
25
|
+
console.warn(`[canvas] Unknown widget type: ${widget.type}`)
|
|
26
|
+
return null
|
|
27
|
+
}
|
|
28
|
+
return createElement(Component, {
|
|
29
|
+
id: widget.id,
|
|
30
|
+
props: widget.props,
|
|
31
|
+
onUpdate,
|
|
32
|
+
onRemove,
|
|
33
|
+
})
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Generic canvas page component.
|
|
38
|
+
* Reads canvas data from the index and renders all widgets on a draggable surface.
|
|
39
|
+
*
|
|
40
|
+
* @param {{ name: string }} props - Canvas name as indexed by the data plugin
|
|
41
|
+
*/
|
|
42
|
+
export default function CanvasPage({ name }) {
|
|
43
|
+
const { canvas, jsxExports, loading } = useCanvas(name)
|
|
44
|
+
|
|
45
|
+
// Local mutable copy of widgets for instant UI updates
|
|
46
|
+
const [localWidgets, setLocalWidgets] = useState(canvas?.widgets ?? null)
|
|
47
|
+
const [trackedCanvas, setTrackedCanvas] = useState(canvas)
|
|
48
|
+
if (canvas !== trackedCanvas) {
|
|
49
|
+
setTrackedCanvas(canvas)
|
|
50
|
+
setLocalWidgets(canvas?.widgets ?? null)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Debounced save to server
|
|
54
|
+
const debouncedSave = useRef(
|
|
55
|
+
debounce((canvasName, widgets) => {
|
|
56
|
+
updateCanvas(canvasName, { widgets }).catch((err) =>
|
|
57
|
+
console.error('[canvas] Failed to save:', err)
|
|
58
|
+
)
|
|
59
|
+
}, 2000)
|
|
60
|
+
).current
|
|
61
|
+
|
|
62
|
+
const handleWidgetUpdate = useCallback((widgetId, updates) => {
|
|
63
|
+
setLocalWidgets((prev) => {
|
|
64
|
+
if (!prev) return prev
|
|
65
|
+
const next = prev.map((w) =>
|
|
66
|
+
w.id === widgetId ? { ...w, props: { ...w.props, ...updates } } : w
|
|
67
|
+
)
|
|
68
|
+
debouncedSave(name, next)
|
|
69
|
+
return next
|
|
70
|
+
})
|
|
71
|
+
}, [name, debouncedSave])
|
|
72
|
+
|
|
73
|
+
const handleWidgetRemove = useCallback((widgetId) => {
|
|
74
|
+
setLocalWidgets((prev) => prev ? prev.filter((w) => w.id !== widgetId) : prev)
|
|
75
|
+
removeWidgetApi(name, widgetId).catch((err) =>
|
|
76
|
+
console.error('[canvas] Failed to remove widget:', err)
|
|
77
|
+
)
|
|
78
|
+
}, [name])
|
|
79
|
+
|
|
80
|
+
if (!canvas) {
|
|
81
|
+
return (
|
|
82
|
+
<div className={styles.empty}>
|
|
83
|
+
<p>Canvas “{name}” not found</p>
|
|
84
|
+
</div>
|
|
85
|
+
)
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
if (loading) {
|
|
89
|
+
return (
|
|
90
|
+
<div className={styles.loading}>
|
|
91
|
+
<p>Loading canvas…</p>
|
|
92
|
+
</div>
|
|
93
|
+
)
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const canvasProps = {
|
|
97
|
+
centered: canvas.centered ?? false,
|
|
98
|
+
dotted: canvas.dotted ?? false,
|
|
99
|
+
grid: canvas.grid ?? false,
|
|
100
|
+
gridSize: canvas.gridSize ?? 18,
|
|
101
|
+
colorMode: canvas.colorMode ?? 'auto',
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Merge JSX-sourced widgets (from .canvas.jsx) and JSON widgets
|
|
105
|
+
const allChildren = []
|
|
106
|
+
|
|
107
|
+
// 1. JSX-sourced component widgets
|
|
108
|
+
if (jsxExports) {
|
|
109
|
+
for (const [exportName, Component] of Object.entries(jsxExports)) {
|
|
110
|
+
allChildren.push(
|
|
111
|
+
<div key={`jsx-${exportName}`} id={`jsx-${exportName}`}>
|
|
112
|
+
<ComponentWidget component={Component} />
|
|
113
|
+
</div>
|
|
114
|
+
)
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// 2. JSON-defined mutable widgets
|
|
119
|
+
for (const widget of (localWidgets ?? [])) {
|
|
120
|
+
allChildren.push(
|
|
121
|
+
<div key={widget.id} id={widget.id}>
|
|
122
|
+
<WidgetRenderer
|
|
123
|
+
widget={widget}
|
|
124
|
+
onUpdate={(updates) => handleWidgetUpdate(widget.id, updates)}
|
|
125
|
+
onRemove={() => handleWidgetRemove(widget.id)}
|
|
126
|
+
/>
|
|
127
|
+
</div>
|
|
128
|
+
)
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
return (
|
|
132
|
+
<>
|
|
133
|
+
<Canvas {...canvasProps}>
|
|
134
|
+
{allChildren}
|
|
135
|
+
</Canvas>
|
|
136
|
+
<CanvasToolbar
|
|
137
|
+
canvasName={name}
|
|
138
|
+
onWidgetAdded={() => {
|
|
139
|
+
// Reload the page to pick up the new widget from the updated .canvas.json
|
|
140
|
+
window.location.reload()
|
|
141
|
+
}}
|
|
142
|
+
/>
|
|
143
|
+
</>
|
|
144
|
+
)
|
|
145
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
.empty,
|
|
2
|
+
.loading {
|
|
3
|
+
display: flex;
|
|
4
|
+
align-items: center;
|
|
5
|
+
justify-content: center;
|
|
6
|
+
min-height: 100vh;
|
|
7
|
+
font-family: var(--tc-font-stack, system-ui, -apple-system, sans-serif);
|
|
8
|
+
color: var(--fgColor-muted, #656d76);
|
|
9
|
+
font-size: 16px;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
.empty p,
|
|
13
|
+
.loading p {
|
|
14
|
+
margin: 0;
|
|
15
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { useState } from 'react'
|
|
2
|
+
import { addWidget as addWidgetApi } from './canvasApi.js'
|
|
3
|
+
import { schemas, getDefaults } from './widgets/widgetProps.js'
|
|
4
|
+
import styles from './CanvasToolbar.module.css'
|
|
5
|
+
|
|
6
|
+
const WIDGET_TYPES = [
|
|
7
|
+
{ type: 'sticky-note', label: 'Sticky Note', icon: '📝' },
|
|
8
|
+
{ type: 'markdown', label: 'Markdown', icon: '📄' },
|
|
9
|
+
{ type: 'prototype', label: 'Prototype', icon: '🖥️' },
|
|
10
|
+
]
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Floating toolbar for adding widgets to a canvas.
|
|
14
|
+
*/
|
|
15
|
+
export default function CanvasToolbar({ canvasName, onWidgetAdded }) {
|
|
16
|
+
const [open, setOpen] = useState(false)
|
|
17
|
+
const [adding, setAdding] = useState(false)
|
|
18
|
+
|
|
19
|
+
async function handleAdd(type) {
|
|
20
|
+
if (adding) return
|
|
21
|
+
setAdding(true)
|
|
22
|
+
try {
|
|
23
|
+
const defaultProps = schemas[type] ? getDefaults(schemas[type]) : {}
|
|
24
|
+
const result = await addWidgetApi(canvasName, {
|
|
25
|
+
type,
|
|
26
|
+
props: defaultProps,
|
|
27
|
+
position: { x: 0, y: 0 },
|
|
28
|
+
})
|
|
29
|
+
if (result.success) {
|
|
30
|
+
onWidgetAdded?.(result.widget)
|
|
31
|
+
setOpen(false)
|
|
32
|
+
}
|
|
33
|
+
} catch (err) {
|
|
34
|
+
console.error('[canvas] Failed to add widget:', err)
|
|
35
|
+
} finally {
|
|
36
|
+
setAdding(false)
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
return (
|
|
41
|
+
<nav className={styles.toolbar}>
|
|
42
|
+
{open ? (
|
|
43
|
+
<div className={styles.menu}>
|
|
44
|
+
<header className={styles.menuHeader}>
|
|
45
|
+
<span>Add widget</span>
|
|
46
|
+
<button
|
|
47
|
+
className={styles.closeBtn}
|
|
48
|
+
onClick={() => setOpen(false)}
|
|
49
|
+
aria-label="Close"
|
|
50
|
+
>×</button>
|
|
51
|
+
</header>
|
|
52
|
+
{WIDGET_TYPES.map(({ type, label, icon }) => (
|
|
53
|
+
<button
|
|
54
|
+
key={type}
|
|
55
|
+
className={styles.menuItem}
|
|
56
|
+
onClick={() => handleAdd(type)}
|
|
57
|
+
disabled={adding}
|
|
58
|
+
>
|
|
59
|
+
<span className={styles.menuIcon}>{icon}</span>
|
|
60
|
+
{label}
|
|
61
|
+
</button>
|
|
62
|
+
))}
|
|
63
|
+
</div>
|
|
64
|
+
) : (
|
|
65
|
+
<button
|
|
66
|
+
className={styles.addBtn}
|
|
67
|
+
onClick={() => setOpen(true)}
|
|
68
|
+
title="Add widget"
|
|
69
|
+
aria-label="Add widget"
|
|
70
|
+
>
|
|
71
|
+
+
|
|
72
|
+
</button>
|
|
73
|
+
)}
|
|
74
|
+
</nav>
|
|
75
|
+
)
|
|
76
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
.toolbar {
|
|
2
|
+
position: fixed;
|
|
3
|
+
bottom: 80px;
|
|
4
|
+
left: 24px;
|
|
5
|
+
z-index: 1000;
|
|
6
|
+
font-family: var(--tc-font-stack, system-ui, -apple-system, sans-serif);
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
.addBtn {
|
|
10
|
+
all: unset;
|
|
11
|
+
cursor: pointer;
|
|
12
|
+
width: 48px;
|
|
13
|
+
height: 48px;
|
|
14
|
+
border-radius: 50%;
|
|
15
|
+
background: var(--bgColor-accent-emphasis, #2f81f7);
|
|
16
|
+
color: var(--fgColor-onEmphasis, #ffffff);
|
|
17
|
+
font-size: 28px;
|
|
18
|
+
font-weight: 300;
|
|
19
|
+
display: flex;
|
|
20
|
+
align-items: center;
|
|
21
|
+
justify-content: center;
|
|
22
|
+
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.25);
|
|
23
|
+
transition: transform 150ms, background 150ms;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
.addBtn:hover {
|
|
27
|
+
transform: scale(1.08);
|
|
28
|
+
background: var(--bgColor-accent-emphasis, #388bfd);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
.menu {
|
|
32
|
+
background: var(--bgColor-default, #ffffff);
|
|
33
|
+
border: 1px solid var(--borderColor-muted, rgba(0, 0, 0, 0.15));
|
|
34
|
+
border-radius: 12px;
|
|
35
|
+
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.15);
|
|
36
|
+
overflow: hidden;
|
|
37
|
+
min-width: 180px;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
.menuHeader {
|
|
41
|
+
display: flex;
|
|
42
|
+
align-items: center;
|
|
43
|
+
justify-content: space-between;
|
|
44
|
+
padding: 10px 14px;
|
|
45
|
+
font-size: 12px;
|
|
46
|
+
font-weight: 600;
|
|
47
|
+
text-transform: uppercase;
|
|
48
|
+
letter-spacing: 0.5px;
|
|
49
|
+
color: var(--fgColor-muted, #656d76);
|
|
50
|
+
border-bottom: 1px solid var(--borderColor-muted, rgba(0, 0, 0, 0.1));
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
.closeBtn {
|
|
54
|
+
all: unset;
|
|
55
|
+
cursor: pointer;
|
|
56
|
+
font-size: 16px;
|
|
57
|
+
line-height: 1;
|
|
58
|
+
color: var(--fgColor-muted, #656d76);
|
|
59
|
+
padding: 0 2px;
|
|
60
|
+
border-radius: 4px;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
.closeBtn:hover {
|
|
64
|
+
color: var(--fgColor-default, #1f2328);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
.menuItem {
|
|
68
|
+
all: unset;
|
|
69
|
+
cursor: pointer;
|
|
70
|
+
display: flex;
|
|
71
|
+
align-items: center;
|
|
72
|
+
gap: 10px;
|
|
73
|
+
padding: 10px 14px;
|
|
74
|
+
font-size: 14px;
|
|
75
|
+
color: var(--fgColor-default, #1f2328);
|
|
76
|
+
width: 100%;
|
|
77
|
+
box-sizing: border-box;
|
|
78
|
+
transition: background 100ms;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
.menuItem:hover {
|
|
82
|
+
background: var(--bgColor-muted, #f6f8fa);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
.menuItem:disabled {
|
|
86
|
+
opacity: 0.5;
|
|
87
|
+
cursor: default;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
.menuIcon {
|
|
91
|
+
font-size: 18px;
|
|
92
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Client-side API for canvas CRUD operations.
|
|
3
|
+
* Calls the /_storyboard/canvas/ server endpoints.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
const BASE = '/_storyboard/canvas'
|
|
7
|
+
|
|
8
|
+
function getApiBase() {
|
|
9
|
+
const base = (import.meta.env?.BASE_URL || '/').replace(/\/$/, '')
|
|
10
|
+
return base + BASE
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
async function request(path, method, body) {
|
|
14
|
+
const url = getApiBase() + path
|
|
15
|
+
const res = await fetch(url, {
|
|
16
|
+
method,
|
|
17
|
+
headers: body ? { 'Content-Type': 'application/json' } : undefined,
|
|
18
|
+
body: body ? JSON.stringify(body) : undefined,
|
|
19
|
+
})
|
|
20
|
+
return res.json()
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function listCanvases() {
|
|
24
|
+
return request('/list', 'GET')
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function createCanvas(data) {
|
|
28
|
+
return request('/create', 'POST', data)
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function updateCanvas(name, { widgets, sources, settings }) {
|
|
32
|
+
return request('/update', 'PUT', { name, widgets, sources, settings })
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function addWidget(name, { type, props, position }) {
|
|
36
|
+
return request('/widget', 'POST', { name, type, props, position })
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function removeWidget(name, widgetId) {
|
|
40
|
+
return request('/widget', 'DELETE', { name, widgetId })
|
|
41
|
+
}
|
|
@@ -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,87 @@
|
|
|
1
|
+
import { useState, useRef, useEffect } 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, onRemove }) {
|
|
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
|
+
useEffect(() => {
|
|
37
|
+
if (editing) {
|
|
38
|
+
// Capture the preview height before switching to editor
|
|
39
|
+
if (blockRef.current && !editHeight) {
|
|
40
|
+
setEditHeight(blockRef.current.offsetHeight)
|
|
41
|
+
}
|
|
42
|
+
if (textareaRef.current) {
|
|
43
|
+
textareaRef.current.focus()
|
|
44
|
+
}
|
|
45
|
+
} else {
|
|
46
|
+
setEditHeight(null)
|
|
47
|
+
}
|
|
48
|
+
}, [editing, editHeight])
|
|
49
|
+
|
|
50
|
+
return (
|
|
51
|
+
<WidgetWrapper onRemove={onRemove}>
|
|
52
|
+
<div
|
|
53
|
+
ref={blockRef}
|
|
54
|
+
className={styles.block}
|
|
55
|
+
style={{ width, minHeight: editHeight || undefined }}
|
|
56
|
+
>
|
|
57
|
+
{editing ? (
|
|
58
|
+
<textarea
|
|
59
|
+
ref={textareaRef}
|
|
60
|
+
className={styles.editor}
|
|
61
|
+
style={{ minHeight: editHeight ? editHeight - 2 : undefined }}
|
|
62
|
+
value={content}
|
|
63
|
+
onChange={(e) => onUpdate?.({ content: e.target.value })}
|
|
64
|
+
onBlur={() => setEditing(false)}
|
|
65
|
+
onMouseDown={(e) => e.stopPropagation()}
|
|
66
|
+
onPointerDown={(e) => e.stopPropagation()}
|
|
67
|
+
onKeyDown={(e) => {
|
|
68
|
+
if (e.key === 'Escape') setEditing(false)
|
|
69
|
+
}}
|
|
70
|
+
placeholder="Write markdown…"
|
|
71
|
+
/>
|
|
72
|
+
) : (
|
|
73
|
+
<div
|
|
74
|
+
className={styles.preview}
|
|
75
|
+
onDoubleClick={() => setEditing(true)}
|
|
76
|
+
role="button"
|
|
77
|
+
tabIndex={0}
|
|
78
|
+
onKeyDown={(e) => { if (e.key === 'Enter') setEditing(true) }}
|
|
79
|
+
dangerouslySetInnerHTML={{
|
|
80
|
+
__html: renderMarkdown(content) || '<p class="placeholder">Double-click to edit…</p>',
|
|
81
|
+
}}
|
|
82
|
+
/>
|
|
83
|
+
)}
|
|
84
|
+
</div>
|
|
85
|
+
</WidgetWrapper>
|
|
86
|
+
)
|
|
87
|
+
}
|
|
@@ -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
|
+
}
|