@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 CHANGED
@@ -1,10 +1,10 @@
1
1
  {
2
2
  "name": "@dfosco/storyboard-react",
3
- "version": "3.11.0-beta.10",
3
+ "version": "3.11.0-beta.11",
4
4
  "type": "module",
5
5
  "dependencies": {
6
- "@dfosco/storyboard-core": "3.11.0-beta.10",
7
- "@dfosco/tiny-canvas": "3.11.0-beta.10",
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
- }, 250 * 4)
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
- <input
1414
- ref={titleInputRef}
1415
- className={styles.canvasTitleInput}
1416
- value={canvasTitle}
1417
- size={1}
1418
- onChange={handleTitleChange}
1419
- onKeyDown={handleTitleKeyDown}
1420
- onMouseDown={(e) => e.stopPropagation()}
1421
- spellCheck={false}
1422
- aria-label="Canvas title"
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 (editing) {
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
- }, [editing, editHeight])
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
- {editing ? (
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
- onDoubleClick={() => setEditing(true)}
81
- role="button"
82
- tabIndex={0}
83
- onKeyDown={(e) => { if (e.key === 'Enter') setEditing(true) }}
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) || '<p class="placeholder">Double-click to edit…</p>',
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 (editing && textareaRef.current) {
32
+ if (editingActive && textareaRef.current) {
31
33
  textareaRef.current.focus()
32
34
  textareaRef.current.selectionStart = textareaRef.current.value.length
33
35
  }
34
- }, [editing])
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={editing ? { visibility: 'hidden' } : undefined}
55
- onDoubleClick={() => setEditing(true)}
56
- role="button"
57
- tabIndex={0}
58
- onKeyDown={(e) => { if (e.key === 'Enter') setEditing(true) }}
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
- {editing && (
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
  })