@dfosco/storyboard-react 3.11.0-beta.8 → 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 CHANGED
@@ -1,10 +1,10 @@
1
1
  {
2
2
  "name": "@dfosco/storyboard-react",
3
- "version": "3.11.0-beta.8",
3
+ "version": "3.11.0",
4
4
  "type": "module",
5
5
  "dependencies": {
6
- "@dfosco/storyboard-core": "3.11.0-beta.8",
7
- "@dfosco/tiny-canvas": "3.11.0-beta.8",
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"
@@ -1,3 +1,4 @@
1
1
  // Stub for the Vite virtual module used by context.jsx
2
2
  // The actual init() seeding is done in each test's beforeEach
3
+ export const canvases = {}
3
4
  export default {}
@@ -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)
@@ -321,8 +321,11 @@ export default function CanvasPage({ name }) {
321
321
  /**
322
322
  * Selection handler — shift+click toggles in/out of multi-select set,
323
323
  * plain click single-selects (clears others).
324
+ * Suppressed immediately after a multi-drag to prevent the post-drag
325
+ * click from collapsing the selection.
324
326
  */
325
327
  const handleWidgetSelect = useCallback((widgetId, shiftKey) => {
328
+ if (justDraggedRef.current) return
326
329
  if (shiftKey) {
327
330
  setSelectedWidgetIds(prev => {
328
331
  const next = new Set(prev)
@@ -338,62 +341,53 @@ export default function CanvasPage({ name }) {
338
341
  }
339
342
  }, [])
340
343
 
341
- // --- Multi-select live drag preview via imperative DOM transforms ---
342
- // On drag start, snapshot ALL articles' translate values (same coord space).
343
- // On each tick, read dragged article's current translate, compute delta
344
- // from its snapshot, apply same delta to all peers.
345
- const draggedArticleRef = useRef(null)
346
- const draggedStartTranslate = useRef({ x: 0, y: 0 })
347
- const peerSnapshots = useRef(new Map())
348
-
349
- function parseTranslate(article) {
350
- const raw = article?.style.translate || '0px 0px'
351
- const parts = raw.match(/-?[\d.]+/g) || [0, 0]
352
- return { x: parseFloat(parts[0]) || 0, y: parseFloat(parts[1]) || 0 }
353
- }
344
+ // --- Multi-select drag: peers animate to new positions on drag end ---
345
+ // During drag, only the dragged widget moves (via neodrag). On drag end,
346
+ // peer widget positions are updated via React state, and we add the
347
+ // tc-on-translation class so they animate smoothly to their new spots.
348
+ const peerArticlesRef = useRef(new Map())
349
+ // Flag to suppress the click-based selection reset that fires after a drag
350
+ const justDraggedRef = useRef(false)
354
351
 
355
- const handleItemDragStart = useCallback((dragId) => {
352
+ const handleItemDragStart = useCallback((dragId, position) => {
356
353
  const ids = selectedIdsRef.current
357
- peerSnapshots.current.clear()
358
- draggedArticleRef.current = null
354
+ peerArticlesRef.current.clear()
359
355
  if (ids.size <= 1 || !ids.has(dragId)) return
360
356
 
361
- // Snapshot dragged widget's article translate
362
- const draggedEl = document.getElementById(dragId)
363
- const draggedArticle = draggedEl?.closest('article')
364
- if (!draggedArticle) return
365
- draggedArticleRef.current = draggedArticle
366
- draggedStartTranslate.current = parseTranslate(draggedArticle)
357
+ // Suppress selection changes for the duration of the drag
358
+ justDraggedRef.current = true
367
359
 
368
- // Snapshot each peer's article translate
360
+ // Collect peer article elements for transition on drag end
369
361
  for (const id of ids) {
370
362
  if (id === dragId) continue
371
363
  const widgetEl = document.getElementById(id)
372
364
  const article = widgetEl?.closest('article')
373
365
  if (!article) continue
374
- peerSnapshots.current.set(id, {
375
- article,
376
- ...parseTranslate(article),
377
- })
366
+ peerArticlesRef.current.set(id, article)
378
367
  }
379
368
  }, [])
380
369
 
381
370
  const handleItemDrag = useCallback(() => {
382
- if (!draggedArticleRef.current) return
383
-
384
- // Read dragged article's CURRENT translate (set by neodrag)
385
- const current = parseTranslate(draggedArticleRef.current)
386
- const dx = current.x - draggedStartTranslate.current.x
387
- const dy = current.y - draggedStartTranslate.current.y
371
+ // Peers stay put during drag — they animate on drag end
372
+ }, [])
388
373
 
389
- for (const [, peer] of peerSnapshots.current) {
390
- peer.article.style.translate = `${peer.x + dx}px ${peer.y + dy}px`
374
+ /** Add transition class to peer articles so they animate to new positions. */
375
+ const transitionPeers = useCallback(() => {
376
+ for (const [, article] of peerArticlesRef.current) {
377
+ article.classList.add('tc-on-translation')
391
378
  }
379
+ // Remove class after animation completes
380
+ const articles = [...peerArticlesRef.current.values()]
381
+ setTimeout(() => {
382
+ for (const article of articles) {
383
+ article.classList.remove('tc-on-translation')
384
+ }
385
+ }, 150 + 50 + 200)
386
+ peerArticlesRef.current.clear()
392
387
  }, [])
393
388
 
394
389
  const clearDragPreview = useCallback(() => {
395
- peerSnapshots.current.clear()
396
- draggedArticleRef.current = null
390
+ peerArticlesRef.current.clear()
397
391
  }, [])
398
392
 
399
393
  if (canvas !== trackedCanvas) {
@@ -515,40 +509,39 @@ export default function CanvasPage({ name }) {
515
509
  }, [name, debouncedSourceSave, undoRedo, snapEnabled, snapGridSize])
516
510
 
517
511
  const handleItemDragEnd = useCallback((dragId, position) => {
518
- if (!dragId || !position) return
519
- const rounded = { x: Math.max(0, roundPosition(position.x)), y: Math.max(0, roundPosition(position.y)) }
520
-
521
- if (dragId.startsWith('jsx-')) {
522
- undoRedo.snapshot(stateRef.current, 'move', dragId)
523
- const sourceExport = dragId.replace(/^jsx-/, '')
524
- setLocalSources((prev) => {
525
- const current = Array.isArray(prev) ? prev : []
526
- const next = current.some((s) => s?.export === sourceExport)
527
- ? current.map((s) => (s?.export === sourceExport ? { ...s, position: rounded } : s))
528
- : [...current, { export: sourceExport, position: rounded }]
529
- queueWrite(() =>
530
- updateCanvas(name, { sources: next }).catch((err) =>
531
- console.error('[canvas] Failed to save source position:', err)
532
- )
533
- )
534
- return next
535
- })
512
+ if (!dragId || !position) {
513
+ clearDragPreview()
536
514
  return
537
515
  }
516
+ const rounded = { x: Math.max(0, roundPosition(position.x)), y: Math.max(0, roundPosition(position.y)) }
538
517
 
539
518
  const ids = selectedIdsRef.current
540
519
  // Multi-select move: apply same delta to all selected widgets
520
+ // Checked BEFORE the jsx- early return so mixed selections work
541
521
  if (ids.size > 1 && ids.has(dragId)) {
542
- clearDragPreview()
522
+ transitionPeers()
523
+ // Suppress the click-based selection reset that fires after pointerup
524
+ justDraggedRef.current = true
525
+ requestAnimationFrame(() => { justDraggedRef.current = false })
543
526
  undoRedo.snapshot(stateRef.current, 'multi-move')
544
- const currentWidgets = stateRef.current.widgets ?? []
545
- const draggedWidget = currentWidgets.find(w => w.id === dragId)
546
- if (!draggedWidget) return
547
- const oldPos = draggedWidget.position || { x: 0, y: 0 }
527
+
528
+ // Compute delta from the dragged widget's old position
529
+ const isJsx = dragId.startsWith('jsx-')
530
+ let oldPos = { x: 0, y: 0 }
531
+ if (isJsx) {
532
+ const sourceExport = dragId.replace(/^jsx-/, '')
533
+ const source = (stateRef.current.sources ?? []).find(s => s?.export === sourceExport)
534
+ oldPos = source?.position || { x: 0, y: 0 }
535
+ } else {
536
+ const draggedWidget = (stateRef.current.widgets ?? []).find(w => w.id === dragId)
537
+ oldPos = draggedWidget?.position || { x: 0, y: 0 }
538
+ }
548
539
  const dx = rounded.x - oldPos.x
549
540
  const dy = rounded.y - oldPos.y
550
541
 
551
542
  debouncedSave.cancel()
543
+
544
+ // Update JSON widget positions
552
545
  setLocalWidgets((prev) => {
553
546
  if (!prev) return prev
554
547
  const next = prev.map((w) => {
@@ -571,6 +564,57 @@ export default function CanvasPage({ name }) {
571
564
  )
572
565
  return next
573
566
  })
567
+
568
+ // Update JSX source positions
569
+ setLocalSources((prev) => {
570
+ const current = Array.isArray(prev) ? prev : []
571
+ let changed = false
572
+ const next = current.map((s) => {
573
+ if (!s?.export) return s
574
+ const sid = `jsx-${s.export}`
575
+ if (sid === dragId) {
576
+ changed = true
577
+ return { ...s, position: rounded }
578
+ }
579
+ if (ids.has(sid)) {
580
+ changed = true
581
+ return {
582
+ ...s,
583
+ position: {
584
+ x: Math.max(0, roundPosition((s.position?.x ?? 0) + dx)),
585
+ y: Math.max(0, roundPosition((s.position?.y ?? 0) + dy)),
586
+ },
587
+ }
588
+ }
589
+ return s
590
+ })
591
+ if (changed) {
592
+ queueWrite(() =>
593
+ updateCanvas(name, { sources: next }).catch((err) =>
594
+ console.error('[canvas] Failed to save multi-move sources:', err)
595
+ )
596
+ )
597
+ }
598
+ return changed ? next : current
599
+ })
600
+ return
601
+ }
602
+
603
+ if (dragId.startsWith('jsx-')) {
604
+ undoRedo.snapshot(stateRef.current, 'move', dragId)
605
+ const sourceExport = dragId.replace(/^jsx-/, '')
606
+ setLocalSources((prev) => {
607
+ const current = Array.isArray(prev) ? prev : []
608
+ const next = current.some((s) => s?.export === sourceExport)
609
+ ? current.map((s) => (s?.export === sourceExport ? { ...s, position: rounded } : s))
610
+ : [...current, { export: sourceExport, position: rounded }]
611
+ queueWrite(() =>
612
+ updateCanvas(name, { sources: next }).catch((err) =>
613
+ console.error('[canvas] Failed to save source position:', err)
614
+ )
615
+ )
616
+ return next
617
+ })
574
618
  return
575
619
  }
576
620
 
@@ -587,7 +631,7 @@ export default function CanvasPage({ name }) {
587
631
  )
588
632
  return next
589
633
  })
590
- }, [name, undoRedo, debouncedSave, clearDragPreview])
634
+ }, [name, undoRedo, debouncedSave, transitionPeers, clearDragPreview])
591
635
 
592
636
  useEffect(() => {
593
637
  zoomRef.current = zoom
@@ -1305,6 +1349,7 @@ export default function CanvasPage({ name }) {
1305
1349
  widgetId={`jsx-${exportName}`}
1306
1350
  features={componentFeatures}
1307
1351
  selected={selectedWidgetIds.has(`jsx-${exportName}`)}
1352
+ multiSelected={isMultiSelected && selectedWidgetIds.has(`jsx-${exportName}`)}
1308
1353
  onSelect={(shiftKey) => handleWidgetSelect(`jsx-${exportName}`, shiftKey)}
1309
1354
  onDeselect={() => setSelectedWidgetIds(new Set())}
1310
1355
  readOnly={!isLocalDev}
@@ -1365,17 +1410,21 @@ export default function CanvasPage({ name }) {
1365
1410
  <div className={styles.canvasTitle}>
1366
1411
  <div className={styles.canvasTitleWrap}>
1367
1412
  <span className={styles.canvasTitleMeasure} aria-hidden="true">{canvasTitle || ' '}</span>
1368
- <input
1369
- ref={titleInputRef}
1370
- className={styles.canvasTitleInput}
1371
- value={canvasTitle}
1372
- size={1}
1373
- onChange={handleTitleChange}
1374
- onKeyDown={handleTitleKeyDown}
1375
- onMouseDown={(e) => e.stopPropagation()}
1376
- spellCheck={false}
1377
- aria-label="Canvas title"
1378
- />
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
+ )}
1379
1428
  </div>
1380
1429
  {isLocalDev && (
1381
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;
@@ -15,10 +15,14 @@ vi.mock('./useUndoRedo.js', () => ({
15
15
  default: () => MOCK_UNDO_REDO,
16
16
  }))
17
17
 
18
- // Expose onDragEnd so tests can trigger drags with specific IDs
18
+ // Expose drag callbacks so tests can trigger drags with specific IDs
19
+ let capturedOnDragStart = null
20
+ let capturedOnDrag = null
19
21
  let capturedOnDragEnd = null
20
22
  vi.mock('@dfosco/tiny-canvas', () => ({
21
- Canvas: ({ children, onDragEnd }) => {
23
+ Canvas: ({ children, onDragStart, onDrag, onDragEnd }) => {
24
+ capturedOnDragStart = onDragStart
25
+ capturedOnDrag = onDrag
22
26
  capturedOnDragEnd = onDragEnd
23
27
  return <div data-testid="tiny-canvas">{children}</div>
24
28
  },
@@ -109,6 +113,8 @@ describe('CanvasPage multi-select', () => {
109
113
  delete window.__storyboardCanvasBridgeState
110
114
  window.__SB_LOCAL_DEV__ = true
111
115
  vi.clearAllMocks()
116
+ capturedOnDragStart = null
117
+ capturedOnDrag = null
112
118
  capturedOnDragEnd = null
113
119
  })
114
120
 
@@ -257,4 +263,83 @@ describe('CanvasPage multi-select', () => {
257
263
  })
258
264
  )
259
265
  })
266
+
267
+ it('multi-select drag captures peer articles on drag start', async () => {
268
+ render(<CanvasPage name="test-canvas" />)
269
+
270
+ // Multi-select w1 and w2
271
+ fireEvent.click(screen.getByTestId('select-w1'))
272
+ fireEvent.click(screen.getByTestId('shift-select-w2'))
273
+ await act(async () => { await new Promise((r) => setTimeout(r, 0)) })
274
+
275
+ // All drag callbacks should be captured
276
+ expect(capturedOnDragStart).toBeTruthy()
277
+ expect(capturedOnDrag).toBeTruthy()
278
+
279
+ // Trigger drag start — should not throw
280
+ act(() => {
281
+ capturedOnDragStart('w1', { x: 100, y: 100 })
282
+ })
283
+
284
+ // Drag tick — peers stay put (no live preview), should not throw
285
+ act(() => {
286
+ capturedOnDrag('w1', { x: 150, y: 200 })
287
+ })
288
+ })
289
+
290
+ it('multi-select drag preserves selection after drag end', async () => {
291
+ render(<CanvasPage name="test-canvas" />)
292
+
293
+ // Multi-select w1 and w2
294
+ fireEvent.click(screen.getByTestId('select-w1'))
295
+ fireEvent.click(screen.getByTestId('shift-select-w2'))
296
+ await act(async () => { await new Promise((r) => setTimeout(r, 0)) })
297
+
298
+ expect(screen.getByTestId('chrome-w1').dataset.selected).toBeDefined()
299
+ expect(screen.getByTestId('chrome-w2').dataset.selected).toBeDefined()
300
+
301
+ // Simulate full drag: start → drag → end
302
+ act(() => {
303
+ capturedOnDragStart('w1', { x: 100, y: 100 })
304
+ })
305
+ act(() => {
306
+ capturedOnDragEnd('w1', { x: 150, y: 200 })
307
+ })
308
+ await act(async () => { await new Promise((r) => setTimeout(r, 0)) })
309
+
310
+ // Selection should still include both widgets (justDraggedRef prevents collapse)
311
+ expect(screen.getByTestId('chrome-w1').dataset.selected).toBeDefined()
312
+ expect(screen.getByTestId('chrome-w2').dataset.selected).toBeDefined()
313
+ })
314
+
315
+ it('any selected widget can serve as drag handler for the group', async () => {
316
+ render(<CanvasPage name="test-canvas" />)
317
+
318
+ // Multi-select w1 (100,100), w2 (300,100), w3 (500,200)
319
+ fireEvent.click(screen.getByTestId('select-w1'))
320
+ fireEvent.click(screen.getByTestId('shift-select-w2'))
321
+ fireEvent.click(screen.getByTestId('shift-select-w3'))
322
+ await act(async () => { await new Promise((r) => setTimeout(r, 0)) })
323
+
324
+ // Drag w2 (the middle one) to (350, 150) → delta (+50, +50)
325
+ act(() => {
326
+ capturedOnDragStart('w2', { x: 300, y: 100 })
327
+ })
328
+ act(() => {
329
+ capturedOnDragEnd('w2', { x: 350, y: 150 })
330
+ })
331
+ await act(async () => { await new Promise((r) => setTimeout(r, 0)) })
332
+
333
+ // All selected widgets should move by the same delta (+50, +50)
334
+ expect(updateCanvas).toHaveBeenCalledWith(
335
+ 'test-canvas',
336
+ expect.objectContaining({
337
+ widgets: expect.arrayContaining([
338
+ expect.objectContaining({ id: 'w1', position: { x: 150, y: 150 } }),
339
+ expect.objectContaining({ id: 'w2', position: { x: 350, y: 150 } }),
340
+ expect.objectContaining({ id: 'w3', position: { x: 550, y: 250 } }),
341
+ ]),
342
+ })
343
+ )
344
+ })
260
345
  })
@@ -3,7 +3,7 @@
3
3
  overflow: hidden;
4
4
  background: var(--bgColor-default, #ffffff);
5
5
  border: 3px solid var(--borderColor-default, #d0d7de);
6
- border-radius: 8px;
6
+ border-radius: 12px;
7
7
  box-shadow: 0 4px 16px rgba(0, 0, 0, 0.08);
8
8
  }
9
9
 
@@ -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
+ })
@@ -3,7 +3,7 @@
3
3
  overflow: hidden;
4
4
  background: var(--bgColor-default, #ffffff);
5
5
  border: 3px solid var(--borderColor-default, #d0d7de);
6
- border-radius: 8px;
6
+ border-radius: 12px;
7
7
  box-shadow: 0 4px 16px rgba(0, 0, 0, 0.08);
8
8
  }
9
9
 
@@ -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
  })
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,
@@ -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
  })