@dfosco/storyboard-react 3.11.0-beta.10 → 3.11.0-beta.11
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/canvas/CanvasPage.jsx +17 -13
- package/src/canvas/CanvasPage.module.css +7 -0
- package/src/canvas/widgets/MarkdownBlock.jsx +25 -8
- package/src/canvas/widgets/MarkdownBlock.test.jsx +53 -0
- package/src/canvas/widgets/StickyNote.jsx +12 -9
- package/src/canvas/widgets/StickyNote.test.jsx +14 -0
package/package.json
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dfosco/storyboard-react",
|
|
3
|
-
"version": "3.11.0-beta.
|
|
3
|
+
"version": "3.11.0-beta.11",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"dependencies": {
|
|
6
|
-
"@dfosco/storyboard-core": "3.11.0-beta.
|
|
7
|
-
"@dfosco/tiny-canvas": "3.11.0-beta.
|
|
6
|
+
"@dfosco/storyboard-core": "3.11.0-beta.11",
|
|
7
|
+
"@dfosco/tiny-canvas": "3.11.0-beta.11",
|
|
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
|
|
|
@@ -1410,17 +1410,21 @@ export default function CanvasPage({ name }) {
|
|
|
1410
1410
|
<div className={styles.canvasTitle}>
|
|
1411
1411
|
<div className={styles.canvasTitleWrap}>
|
|
1412
1412
|
<span className={styles.canvasTitleMeasure} aria-hidden="true">{canvasTitle || ' '}</span>
|
|
1413
|
-
|
|
1414
|
-
|
|
1415
|
-
|
|
1416
|
-
|
|
1417
|
-
|
|
1418
|
-
|
|
1419
|
-
|
|
1420
|
-
|
|
1421
|
-
|
|
1422
|
-
|
|
1423
|
-
|
|
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
|
+
)}
|
|
1424
1428
|
</div>
|
|
1425
1429
|
{isLocalDev && (
|
|
1426
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
|
})
|