@dfosco/storyboard-react 3.11.0-beta.9 → 3.11.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 +3 -3
- package/src/__mocks__/virtual-storyboard-data-index.js +1 -0
- package/src/canvas/CanvasPage.jsx +18 -13
- package/src/canvas/CanvasPage.module.css +7 -0
- package/src/canvas/widgets/FigmaEmbed.module.css +1 -1
- package/src/canvas/widgets/MarkdownBlock.jsx +25 -8
- package/src/canvas/widgets/MarkdownBlock.test.jsx +53 -0
- package/src/canvas/widgets/PrototypeEmbed.module.css +1 -1
- package/src/canvas/widgets/StickyNote.jsx +12 -9
- package/src/canvas/widgets/StickyNote.test.jsx +14 -0
- package/src/context.jsx +34 -4
- package/src/context.test.jsx +13 -0
package/package.json
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dfosco/storyboard-react",
|
|
3
|
-
"version": "3.11.0
|
|
3
|
+
"version": "3.11.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"dependencies": {
|
|
6
|
-
"@dfosco/storyboard-core": "3.11.0
|
|
7
|
-
"@dfosco/tiny-canvas": "3.11.0
|
|
6
|
+
"@dfosco/storyboard-core": "3.11.0",
|
|
7
|
+
"@dfosco/tiny-canvas": "3.11.0",
|
|
8
8
|
"@neodrag/react": "^2.3.1",
|
|
9
9
|
"glob": "^11.0.0",
|
|
10
10
|
"jsonc-parser": "^3.3.1"
|
|
@@ -276,7 +276,7 @@ function ChromeWrappedWidget({
|
|
|
276
276
|
*/
|
|
277
277
|
export default function CanvasPage({ name }) {
|
|
278
278
|
const { canvas, jsxExports, loading } = useCanvas(name)
|
|
279
|
-
const isLocalDev = typeof window !== 'undefined' && window.__SB_LOCAL_DEV__ === true
|
|
279
|
+
const isLocalDev = typeof window !== 'undefined' && window.__SB_LOCAL_DEV__ === true && !new URLSearchParams(window.location.search).has('prodMode')
|
|
280
280
|
|
|
281
281
|
// Local mutable copy of widgets for instant UI updates
|
|
282
282
|
const [localWidgets, setLocalWidgets] = useState(canvas?.widgets ?? null)
|
|
@@ -382,7 +382,7 @@ export default function CanvasPage({ name }) {
|
|
|
382
382
|
for (const article of articles) {
|
|
383
383
|
article.classList.remove('tc-on-translation')
|
|
384
384
|
}
|
|
385
|
-
},
|
|
385
|
+
}, 150 + 50 + 200)
|
|
386
386
|
peerArticlesRef.current.clear()
|
|
387
387
|
}, [])
|
|
388
388
|
|
|
@@ -1349,6 +1349,7 @@ export default function CanvasPage({ name }) {
|
|
|
1349
1349
|
widgetId={`jsx-${exportName}`}
|
|
1350
1350
|
features={componentFeatures}
|
|
1351
1351
|
selected={selectedWidgetIds.has(`jsx-${exportName}`)}
|
|
1352
|
+
multiSelected={isMultiSelected && selectedWidgetIds.has(`jsx-${exportName}`)}
|
|
1352
1353
|
onSelect={(shiftKey) => handleWidgetSelect(`jsx-${exportName}`, shiftKey)}
|
|
1353
1354
|
onDeselect={() => setSelectedWidgetIds(new Set())}
|
|
1354
1355
|
readOnly={!isLocalDev}
|
|
@@ -1409,17 +1410,21 @@ export default function CanvasPage({ name }) {
|
|
|
1409
1410
|
<div className={styles.canvasTitle}>
|
|
1410
1411
|
<div className={styles.canvasTitleWrap}>
|
|
1411
1412
|
<span className={styles.canvasTitleMeasure} aria-hidden="true">{canvasTitle || ' '}</span>
|
|
1412
|
-
|
|
1413
|
-
|
|
1414
|
-
|
|
1415
|
-
|
|
1416
|
-
|
|
1417
|
-
|
|
1418
|
-
|
|
1419
|
-
|
|
1420
|
-
|
|
1421
|
-
|
|
1422
|
-
|
|
1413
|
+
{isLocalDev ? (
|
|
1414
|
+
<input
|
|
1415
|
+
ref={titleInputRef}
|
|
1416
|
+
className={styles.canvasTitleInput}
|
|
1417
|
+
value={canvasTitle}
|
|
1418
|
+
size={1}
|
|
1419
|
+
onChange={handleTitleChange}
|
|
1420
|
+
onKeyDown={handleTitleKeyDown}
|
|
1421
|
+
onMouseDown={(e) => e.stopPropagation()}
|
|
1422
|
+
spellCheck={false}
|
|
1423
|
+
aria-label="Canvas title"
|
|
1424
|
+
/>
|
|
1425
|
+
) : (
|
|
1426
|
+
<h1 className={styles.canvasTitleStatic}>{canvasTitle}</h1>
|
|
1427
|
+
)}
|
|
1423
1428
|
</div>
|
|
1424
1429
|
{isLocalDev && (
|
|
1425
1430
|
<span className={styles.localEditingLabel}>Local editing</span>
|
|
@@ -73,6 +73,7 @@
|
|
|
73
73
|
border: 1px solid transparent;
|
|
74
74
|
border-radius: 6px;
|
|
75
75
|
padding: 4px 8px;
|
|
76
|
+
margin: 0;
|
|
76
77
|
outline: none;
|
|
77
78
|
width: 100%;
|
|
78
79
|
min-width: 0;
|
|
@@ -91,6 +92,12 @@
|
|
|
91
92
|
background: var(--bgColor-default, #ffffff);
|
|
92
93
|
}
|
|
93
94
|
|
|
95
|
+
.canvasTitleStatic {
|
|
96
|
+
composes: canvasTitleInput;
|
|
97
|
+
cursor: default;
|
|
98
|
+
pointer-events: none;
|
|
99
|
+
}
|
|
100
|
+
|
|
94
101
|
/* Remove tiny-canvas wrapper clipping — widgets handle their own overflow/radius */
|
|
95
102
|
:global(.tc-draggable-inner) {
|
|
96
103
|
overflow: visible;
|
|
@@ -28,7 +28,9 @@ function renderMarkdown(text) {
|
|
|
28
28
|
export default function MarkdownBlock({ props, onUpdate }) {
|
|
29
29
|
const content = readProp(props, 'content', markdownSchema)
|
|
30
30
|
const width = readProp(props, 'width', markdownSchema)
|
|
31
|
+
const canEdit = typeof onUpdate === 'function'
|
|
31
32
|
const [editing, setEditing] = useState(false)
|
|
33
|
+
const editingActive = canEdit && editing
|
|
32
34
|
const textareaRef = useRef(null)
|
|
33
35
|
const blockRef = useRef(null)
|
|
34
36
|
const [editHeight, setEditHeight] = useState(null)
|
|
@@ -37,8 +39,17 @@ export default function MarkdownBlock({ props, onUpdate }) {
|
|
|
37
39
|
onUpdate?.({ content: e.target.value })
|
|
38
40
|
}, [onUpdate])
|
|
39
41
|
|
|
42
|
+
const handleReadOnlyCopy = useCallback((e) => {
|
|
43
|
+
if (canEdit) return
|
|
44
|
+
e.preventDefault()
|
|
45
|
+
e.stopPropagation()
|
|
46
|
+
if (e.clipboardData?.setData) {
|
|
47
|
+
e.clipboardData.setData('text/plain', content || '')
|
|
48
|
+
}
|
|
49
|
+
}, [canEdit, content])
|
|
50
|
+
|
|
40
51
|
useEffect(() => {
|
|
41
|
-
if (
|
|
52
|
+
if (editingActive) {
|
|
42
53
|
// Capture the preview height before switching to editor
|
|
43
54
|
if (blockRef.current && !editHeight) {
|
|
44
55
|
setEditHeight(blockRef.current.offsetHeight)
|
|
@@ -49,7 +60,7 @@ export default function MarkdownBlock({ props, onUpdate }) {
|
|
|
49
60
|
} else {
|
|
50
61
|
setEditHeight(null)
|
|
51
62
|
}
|
|
52
|
-
}, [
|
|
63
|
+
}, [editingActive, editHeight])
|
|
53
64
|
|
|
54
65
|
return (
|
|
55
66
|
<WidgetWrapper>
|
|
@@ -58,7 +69,7 @@ export default function MarkdownBlock({ props, onUpdate }) {
|
|
|
58
69
|
className={styles.block}
|
|
59
70
|
style={{ width, minHeight: editHeight || undefined }}
|
|
60
71
|
>
|
|
61
|
-
{
|
|
72
|
+
{editingActive ? (
|
|
62
73
|
<textarea
|
|
63
74
|
ref={textareaRef}
|
|
64
75
|
className={styles.editor}
|
|
@@ -77,12 +88,18 @@ export default function MarkdownBlock({ props, onUpdate }) {
|
|
|
77
88
|
) : (
|
|
78
89
|
<div
|
|
79
90
|
className={styles.preview}
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
91
|
+
style={!canEdit ? { cursor: 'default' } : undefined}
|
|
92
|
+
data-canvas-allow-text-selection={!canEdit ? '' : undefined}
|
|
93
|
+
onClick={!canEdit ? (e) => e.stopPropagation() : undefined}
|
|
94
|
+
onCopy={!canEdit ? handleReadOnlyCopy : undefined}
|
|
95
|
+
onDoubleClick={canEdit ? () => setEditing(true) : undefined}
|
|
96
|
+
role={canEdit ? 'button' : undefined}
|
|
97
|
+
tabIndex={canEdit ? 0 : undefined}
|
|
98
|
+
onKeyDown={canEdit ? (e) => { if (e.key === 'Enter') setEditing(true) } : undefined}
|
|
84
99
|
dangerouslySetInnerHTML={{
|
|
85
|
-
__html: renderMarkdown(content) ||
|
|
100
|
+
__html: renderMarkdown(content) || (canEdit
|
|
101
|
+
? '<p class="placeholder">Double-click to edit…</p>'
|
|
102
|
+
: '<p class="placeholder">No content</p>'),
|
|
86
103
|
}}
|
|
87
104
|
/>
|
|
88
105
|
)}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from 'vitest'
|
|
2
|
+
import { fireEvent, render, screen } from '@testing-library/react'
|
|
3
|
+
import MarkdownBlock from './MarkdownBlock.jsx'
|
|
4
|
+
|
|
5
|
+
describe('MarkdownBlock', () => {
|
|
6
|
+
it('does not enter edit mode when onUpdate is unavailable (read-only/prod)', () => {
|
|
7
|
+
const { container } = render(<MarkdownBlock props={{ content: 'Hello', width: 420 }} />)
|
|
8
|
+
|
|
9
|
+
fireEvent.doubleClick(screen.getByText('Hello'))
|
|
10
|
+
|
|
11
|
+
expect(screen.queryByRole('textbox')).toBeNull()
|
|
12
|
+
expect(container.querySelector('[data-canvas-allow-text-selection]')).not.toBeNull()
|
|
13
|
+
})
|
|
14
|
+
|
|
15
|
+
it('enters edit mode when onUpdate is available', () => {
|
|
16
|
+
const onUpdate = vi.fn()
|
|
17
|
+
render(<MarkdownBlock props={{ content: 'Hello', width: 420 }} onUpdate={onUpdate} />)
|
|
18
|
+
|
|
19
|
+
fireEvent.doubleClick(screen.getByText('Hello'))
|
|
20
|
+
|
|
21
|
+
expect(screen.queryByRole('textbox')).not.toBeNull()
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
it('shows a non-editable empty-state message in read-only mode', () => {
|
|
25
|
+
render(<MarkdownBlock props={{ content: '', width: 420 }} />)
|
|
26
|
+
|
|
27
|
+
expect(screen.getByText('No content')).toBeTruthy()
|
|
28
|
+
expect(screen.queryByText('Double-click to edit…')).toBeNull()
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
it('stops click propagation in read-only mode', () => {
|
|
32
|
+
const onParentClick = vi.fn()
|
|
33
|
+
render(
|
|
34
|
+
<div onClick={onParentClick}>
|
|
35
|
+
<MarkdownBlock props={{ content: 'Hello', width: 420 }} />
|
|
36
|
+
</div>
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
fireEvent.click(screen.getByText('Hello'))
|
|
40
|
+
|
|
41
|
+
expect(onParentClick).not.toHaveBeenCalled()
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
it('copies markdown source in read-only mode', () => {
|
|
45
|
+
render(<MarkdownBlock props={{ content: '**Hello**\n- item', width: 420 }} />)
|
|
46
|
+
|
|
47
|
+
const preview = screen.getByText('Hello').closest('[data-canvas-allow-text-selection]')
|
|
48
|
+
const setData = vi.fn()
|
|
49
|
+
fireEvent.copy(preview, { clipboardData: { setData } })
|
|
50
|
+
|
|
51
|
+
expect(setData).toHaveBeenCalledWith('text/plain', '**Hello**\n- item')
|
|
52
|
+
})
|
|
53
|
+
})
|
|
@@ -17,21 +17,23 @@ export default function StickyNote({ props, onUpdate, resizable }) {
|
|
|
17
17
|
const color = readProp(props, 'color', stickyNoteSchema)
|
|
18
18
|
const width = readProp(props, 'width', stickyNoteSchema)
|
|
19
19
|
const height = readProp(props, 'height', stickyNoteSchema)
|
|
20
|
+
const canEdit = typeof onUpdate === 'function'
|
|
20
21
|
const palette = COLORS[color] ?? COLORS.yellow
|
|
21
22
|
const textareaRef = useRef(null)
|
|
22
23
|
const stickyRef = useRef(null)
|
|
23
24
|
const [editing, setEditing] = useState(false)
|
|
25
|
+
const editingActive = canEdit && editing
|
|
24
26
|
|
|
25
27
|
const handleResize = useCallback((w, h) => {
|
|
26
28
|
onUpdate?.({ width: w, height: h })
|
|
27
29
|
}, [onUpdate])
|
|
28
30
|
|
|
29
31
|
useEffect(() => {
|
|
30
|
-
if (
|
|
32
|
+
if (editingActive && textareaRef.current) {
|
|
31
33
|
textareaRef.current.focus()
|
|
32
34
|
textareaRef.current.selectionStart = textareaRef.current.value.length
|
|
33
35
|
}
|
|
34
|
-
}, [
|
|
36
|
+
}, [editingActive])
|
|
35
37
|
|
|
36
38
|
const handleTextChange = useCallback((e) => {
|
|
37
39
|
onUpdate?.({ text: e.target.value })
|
|
@@ -51,15 +53,16 @@ export default function StickyNote({ props, onUpdate, resizable }) {
|
|
|
51
53
|
>
|
|
52
54
|
<p
|
|
53
55
|
className={styles.text}
|
|
54
|
-
style={
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
56
|
+
style={editingActive ? { visibility: 'hidden' } : undefined}
|
|
57
|
+
data-canvas-allow-text-selection={!canEdit ? '' : undefined}
|
|
58
|
+
onDoubleClick={canEdit ? () => setEditing(true) : undefined}
|
|
59
|
+
role={canEdit ? 'button' : undefined}
|
|
60
|
+
tabIndex={canEdit ? 0 : undefined}
|
|
61
|
+
onKeyDown={canEdit ? (e) => { if (e.key === 'Enter') setEditing(true) } : undefined}
|
|
59
62
|
>
|
|
60
|
-
{text || 'Double-click to edit…'}
|
|
63
|
+
{text || (canEdit ? 'Double-click to edit…' : 'No content')}
|
|
61
64
|
</p>
|
|
62
|
-
{
|
|
65
|
+
{editingActive && (
|
|
63
66
|
<textarea
|
|
64
67
|
ref={textareaRef}
|
|
65
68
|
className={styles.textarea}
|
|
@@ -99,4 +99,18 @@ describe('StickyNote', () => {
|
|
|
99
99
|
|
|
100
100
|
expect(onUpdate).toHaveBeenCalledWith({ width: 180, height: 60 })
|
|
101
101
|
})
|
|
102
|
+
|
|
103
|
+
it('does not enter edit mode without onUpdate (read-only/prod)', () => {
|
|
104
|
+
const { container } = render(<StickyNote props={{ text: 'Read me' }} />)
|
|
105
|
+
const text = container.querySelector('p')
|
|
106
|
+
fireEvent.doubleClick(text)
|
|
107
|
+
expect(container.querySelector('textarea')).toBeNull()
|
|
108
|
+
expect(container.querySelector('[data-canvas-allow-text-selection]')).not.toBeNull()
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
it('shows non-editable empty-state text in read-only mode', () => {
|
|
112
|
+
const { container } = render(<StickyNote props={{ text: '' }} />)
|
|
113
|
+
expect(container.textContent).toContain('No content')
|
|
114
|
+
expect(container.textContent).not.toContain('Double-click to edit…')
|
|
115
|
+
})
|
|
102
116
|
})
|
package/src/context.jsx
CHANGED
|
@@ -22,6 +22,11 @@ function matchCanvasRoute(pathname) {
|
|
|
22
22
|
return canvasRouteMap.get(normalized) || null
|
|
23
23
|
}
|
|
24
24
|
|
|
25
|
+
function isCanvasPath(pathname) {
|
|
26
|
+
const normalized = pathname.replace(/\/+$/, '') || '/'
|
|
27
|
+
return normalized === '/canvas' || normalized.startsWith('/canvas/')
|
|
28
|
+
}
|
|
29
|
+
|
|
25
30
|
/**
|
|
26
31
|
* Derives the top-level prototype name from a pathname.
|
|
27
32
|
* "/Dashboard" → "Dashboard", "/Dashboard/sub" → "Dashboard"
|
|
@@ -62,6 +67,10 @@ export default function StoryboardProvider({ flowName, sceneName, recordName, re
|
|
|
62
67
|
|
|
63
68
|
// Canvas route detection — matches current URL against registered canvas routes
|
|
64
69
|
const canvasName = useMemo(() => matchCanvasRoute(location.pathname), [location.pathname])
|
|
70
|
+
const isMissingCanvasRoute = useMemo(
|
|
71
|
+
() => isCanvasPath(location.pathname) && !canvasName,
|
|
72
|
+
[location.pathname, canvasName],
|
|
73
|
+
)
|
|
65
74
|
|
|
66
75
|
const searchParams = new URLSearchParams(location.search)
|
|
67
76
|
const sceneParam = searchParams.get('flow') || searchParams.get('scene')
|
|
@@ -70,7 +79,7 @@ export default function StoryboardProvider({ flowName, sceneName, recordName, re
|
|
|
70
79
|
|
|
71
80
|
// Resolve flow name with prototype scoping (skip for canvas pages)
|
|
72
81
|
const activeFlowName = useMemo(() => {
|
|
73
|
-
if (canvasName) return null
|
|
82
|
+
if (canvasName || isMissingCanvasRoute) return null
|
|
74
83
|
const requested = sceneParam || flowName || sceneName
|
|
75
84
|
if (requested) {
|
|
76
85
|
// Allow fully-scoped flow names from URLs/widgets without re-prefixing
|
|
@@ -94,7 +103,7 @@ export default function StoryboardProvider({ flowName, sceneName, recordName, re
|
|
|
94
103
|
// 4. Global default — or null if no flow exists at all
|
|
95
104
|
if (flowExists('default')) return 'default'
|
|
96
105
|
return null
|
|
97
|
-
}, [canvasName, sceneParam, flowName, sceneName, prototypeName, pageFlow])
|
|
106
|
+
}, [canvasName, isMissingCanvasRoute, sceneParam, flowName, sceneName, prototypeName, pageFlow])
|
|
98
107
|
|
|
99
108
|
// Auto-install body class sync (sb-key--value classes on <body>)
|
|
100
109
|
useEffect(() => installBodyClassSync(), [])
|
|
@@ -117,7 +126,7 @@ export default function StoryboardProvider({ flowName, sceneName, recordName, re
|
|
|
117
126
|
|
|
118
127
|
// Skip flow loading for canvas pages and flow-less pages
|
|
119
128
|
const { data, error } = useMemo(() => {
|
|
120
|
-
if (canvasName) return { data: null, error: null }
|
|
129
|
+
if (canvasName || isMissingCanvasRoute) return { data: null, error: null }
|
|
121
130
|
if (!activeFlowName) return { data: {}, error: null }
|
|
122
131
|
try {
|
|
123
132
|
let flowData = loadFlow(activeFlowName)
|
|
@@ -136,7 +145,7 @@ export default function StoryboardProvider({ flowName, sceneName, recordName, re
|
|
|
136
145
|
} catch (err) {
|
|
137
146
|
return { data: null, error: err.message }
|
|
138
147
|
}
|
|
139
|
-
}, [canvasName, activeFlowName, recordName, recordParam, params, prototypeName])
|
|
148
|
+
}, [canvasName, isMissingCanvasRoute, activeFlowName, recordName, recordParam, params, prototypeName])
|
|
140
149
|
|
|
141
150
|
// Canvas pages get their own rendering path — no flow data needed
|
|
142
151
|
if (canvasName) {
|
|
@@ -157,6 +166,27 @@ export default function StoryboardProvider({ flowName, sceneName, recordName, re
|
|
|
157
166
|
)
|
|
158
167
|
}
|
|
159
168
|
|
|
169
|
+
if (isMissingCanvasRoute) {
|
|
170
|
+
const currentUrl = `${location.pathname}${location.search}`
|
|
171
|
+
const truncatedUrl = currentUrl.length > 60
|
|
172
|
+
? currentUrl.slice(0, 60) + '…'
|
|
173
|
+
: currentUrl
|
|
174
|
+
|
|
175
|
+
return (
|
|
176
|
+
<main className={styles.container}>
|
|
177
|
+
<div className={styles.banner}>
|
|
178
|
+
<strong>Canvas not found</strong>
|
|
179
|
+
No canvas matches this route.
|
|
180
|
+
</div>
|
|
181
|
+
<p className={styles.meta}>
|
|
182
|
+
Tried to open{' '}
|
|
183
|
+
<a href={currentUrl} title={currentUrl}>{truncatedUrl}</a>
|
|
184
|
+
</p>
|
|
185
|
+
<a className={styles.homeLink} href="/">← Go to index page</a>
|
|
186
|
+
</main>
|
|
187
|
+
)
|
|
188
|
+
}
|
|
189
|
+
|
|
160
190
|
const value = {
|
|
161
191
|
data,
|
|
162
192
|
error,
|
package/src/context.test.jsx
CHANGED
|
@@ -280,4 +280,17 @@ describe('StoryboardProvider', () => {
|
|
|
280
280
|
)
|
|
281
281
|
expect(screen.getByTestId('ctx')).toHaveTextContent('Global Default')
|
|
282
282
|
})
|
|
283
|
+
|
|
284
|
+
it('shows a simple 404 for unknown canvas routes with an index link', () => {
|
|
285
|
+
mockUseLocation.mockReturnValue({ pathname: '/canvas/unknown-board', search: '', hash: '' })
|
|
286
|
+
|
|
287
|
+
render(
|
|
288
|
+
<StoryboardProvider>
|
|
289
|
+
<ContextReader />
|
|
290
|
+
</StoryboardProvider>,
|
|
291
|
+
)
|
|
292
|
+
|
|
293
|
+
expect(screen.getByText('Canvas not found')).toBeInTheDocument()
|
|
294
|
+
expect(screen.getByRole('link', { name: /go to index page/i })).toHaveAttribute('href', '/')
|
|
295
|
+
})
|
|
283
296
|
})
|