@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 +3 -3
- package/src/__mocks__/virtual-storyboard-data-index.js +1 -0
- package/src/canvas/CanvasPage.jsx +122 -73
- package/src/canvas/CanvasPage.module.css +7 -0
- package/src/canvas/CanvasPage.multiselect.test.jsx +87 -2
- 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)
|
|
@@ -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
|
|
342
|
-
//
|
|
343
|
-
//
|
|
344
|
-
//
|
|
345
|
-
const
|
|
346
|
-
|
|
347
|
-
const
|
|
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
|
-
|
|
358
|
-
draggedArticleRef.current = null
|
|
354
|
+
peerArticlesRef.current.clear()
|
|
359
355
|
if (ids.size <= 1 || !ids.has(dragId)) return
|
|
360
356
|
|
|
361
|
-
//
|
|
362
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
375
|
-
article,
|
|
376
|
-
...parseTranslate(article),
|
|
377
|
-
})
|
|
366
|
+
peerArticlesRef.current.set(id, article)
|
|
378
367
|
}
|
|
379
368
|
}, [])
|
|
380
369
|
|
|
381
370
|
const handleItemDrag = useCallback(() => {
|
|
382
|
-
|
|
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
|
-
|
|
390
|
-
|
|
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
|
-
|
|
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)
|
|
519
|
-
|
|
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
|
-
|
|
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
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
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
|
-
|
|
1369
|
-
|
|
1370
|
-
|
|
1371
|
-
|
|
1372
|
-
|
|
1373
|
-
|
|
1374
|
-
|
|
1375
|
-
|
|
1376
|
-
|
|
1377
|
-
|
|
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
|
|
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
|
})
|
|
@@ -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
|
})
|