@dfosco/storyboard-react 4.0.0-beta.2 → 4.0.0-beta.21
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 +7 -4
- package/src/canvas/CanvasControls.jsx +51 -2
- package/src/canvas/CanvasControls.module.css +31 -0
- package/src/canvas/CanvasPage.dragdrop.test.jsx +346 -0
- package/src/canvas/CanvasPage.jsx +512 -235
- package/src/canvas/CanvasPage.module.css +9 -47
- package/src/canvas/ComponentErrorBoundary.jsx +50 -0
- package/src/canvas/PageSelector.jsx +102 -0
- package/src/canvas/PageSelector.module.css +93 -0
- package/src/canvas/PageSelector.test.jsx +104 -0
- package/src/canvas/canvasApi.js +4 -0
- package/src/canvas/canvasReloadGuard.js +37 -0
- package/src/canvas/canvasReloadGuard.test.js +27 -0
- package/src/canvas/componentIsolate.jsx +135 -0
- package/src/canvas/useCanvas.js +6 -2
- package/src/canvas/widgets/ComponentWidget.jsx +67 -9
- package/src/canvas/widgets/ComponentWidget.module.css +9 -6
- package/src/canvas/widgets/FigmaEmbed.jsx +26 -4
- package/src/canvas/widgets/FigmaEmbed.module.css +0 -7
- package/src/canvas/widgets/MarkdownBlock.jsx +94 -21
- package/src/canvas/widgets/MarkdownBlock.module.css +110 -2
- package/src/canvas/widgets/MarkdownBlock.test.jsx +39 -0
- package/src/canvas/widgets/PrototypeEmbed.jsx +196 -40
- package/src/canvas/widgets/PrototypeEmbed.module.css +30 -3
- package/src/canvas/widgets/StickyNote.module.css +5 -0
- package/src/canvas/widgets/StickyNote.test.jsx +9 -9
- package/src/canvas/widgets/StoryWidget.jsx +471 -0
- package/src/canvas/widgets/StoryWidget.module.css +200 -0
- package/src/canvas/widgets/WidgetChrome.jsx +54 -18
- package/src/canvas/widgets/WidgetChrome.module.css +4 -7
- package/src/canvas/widgets/WidgetWrapper.module.css +2 -0
- package/src/canvas/widgets/embedInteraction.test.jsx +155 -0
- package/src/canvas/widgets/embedOverlay.module.css +35 -0
- package/src/canvas/widgets/index.js +2 -0
- package/src/canvas/widgets/pasteRules.js +295 -0
- package/src/canvas/widgets/pasteRules.test.js +474 -0
- package/src/canvas/widgets/widgetConfig.js +16 -5
- package/src/canvas/widgets/widgetConfig.test.js +31 -9
- package/src/context.jsx +138 -13
- package/src/hooks/useSceneData.js +4 -2
- package/src/story/StoryPage.jsx +152 -0
- package/src/story/StoryPage.module.css +73 -0
- package/src/vite/data-plugin.js +441 -58
- package/src/vite/data-plugin.test.js +405 -5
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { useState, useCallback, useRef, useEffect, useSyncExternalStore } from 'react'
|
|
2
2
|
import { Tooltip } from '@primer/react'
|
|
3
|
-
import { EyeIcon as OcticonEye, EyeClosedIcon as OcticonEyeClosed } from '@primer/octicons-react'
|
|
3
|
+
import { EyeIcon as OcticonEye, EyeClosedIcon as OcticonEyeClosed, CodeIcon as OcticonCode, UnwrapIcon as OcticonUnwrap, ImageIcon as OcticonImage } from '@primer/octicons-react'
|
|
4
4
|
import styles from './WidgetChrome.module.css'
|
|
5
5
|
|
|
6
6
|
const STICKY_NOTE_COLORS = {
|
|
@@ -60,6 +60,18 @@ function EyeClosedIcon() {
|
|
|
60
60
|
return <OcticonEyeClosed size={12} />
|
|
61
61
|
}
|
|
62
62
|
|
|
63
|
+
function CodeIcon() {
|
|
64
|
+
return <OcticonCode size={12} />
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function UnwrapIcon() {
|
|
68
|
+
return <OcticonUnwrap size={12} />
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function ImageIcon() {
|
|
72
|
+
return <OcticonImage size={12} />
|
|
73
|
+
}
|
|
74
|
+
|
|
63
75
|
function CopyIcon() {
|
|
64
76
|
return (
|
|
65
77
|
<svg width="12" height="12" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true">
|
|
@@ -119,6 +131,9 @@ const ICON_REGISTRY = {
|
|
|
119
131
|
'open-external': OpenExternalIcon,
|
|
120
132
|
'eye': EyeIcon,
|
|
121
133
|
'eye-closed': EyeClosedIcon,
|
|
134
|
+
'code': CodeIcon,
|
|
135
|
+
'unwrap': UnwrapIcon,
|
|
136
|
+
'image': ImageIcon,
|
|
122
137
|
'copy': CopyIcon,
|
|
123
138
|
'link': LinkIcon,
|
|
124
139
|
'more': MoreIcon,
|
|
@@ -411,16 +426,21 @@ export default function WidgetChrome({
|
|
|
411
426
|
onUpdate?.({ color })
|
|
412
427
|
}, [onUpdate])
|
|
413
428
|
|
|
414
|
-
|
|
429
|
+
// In readOnly mode, features are already filtered to prod-only by getFeatures.
|
|
430
|
+
// Show toolbar if there are prod features even when readOnly.
|
|
431
|
+
const hasFeatures = features.length > 0
|
|
432
|
+
const showToolbar = (hovered || selected) && (!readOnly || hasFeatures)
|
|
415
433
|
const showFeatures = showToolbar && !multiSelected
|
|
434
|
+
const menuFeatures = features.filter((f) => f.menu)
|
|
416
435
|
|
|
417
436
|
return (
|
|
418
437
|
<div
|
|
419
438
|
className={styles.chromeContainer}
|
|
420
|
-
|
|
421
|
-
|
|
439
|
+
data-tc-elevated={(hovered || selected) || undefined}
|
|
440
|
+
onMouseEnter={(readOnly && !hasFeatures) ? undefined : handleMouseEnter}
|
|
441
|
+
onMouseLeave={(readOnly && !hasFeatures) ? undefined : handleMouseLeave}
|
|
422
442
|
>
|
|
423
|
-
<div className={`tc-drag-surface ${styles.widgetSlot} ${selected ? styles.widgetSlotSelected : ''} ${multiSelected ? styles.widgetSlotMultiSelected : ''}`}>
|
|
443
|
+
<div className={`tc-drag-surface ${styles.widgetSlot} ${selected ? styles.widgetSlotSelected : ''} ${multiSelected ? styles.widgetSlotMultiSelected : ''}`} data-widget-selected={selected || undefined}>
|
|
424
444
|
{children}
|
|
425
445
|
</div>
|
|
426
446
|
<div
|
|
@@ -464,6 +484,11 @@ export default function WidgetChrome({
|
|
|
464
484
|
}
|
|
465
485
|
}
|
|
466
486
|
|
|
487
|
+
// Show-code toggle: swap label based on widget state
|
|
488
|
+
if (feature.action === 'show-code' && widgetRef?.current?.getState?.('showCode')) {
|
|
489
|
+
label = 'Show component'
|
|
490
|
+
}
|
|
491
|
+
|
|
467
492
|
return (
|
|
468
493
|
<Tooltip key={feature.id} text={label} direction="n">
|
|
469
494
|
<button
|
|
@@ -495,22 +520,33 @@ export default function WidgetChrome({
|
|
|
495
520
|
|
|
496
521
|
return null
|
|
497
522
|
})}
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
523
|
+
{menuFeatures.length > 0 && (
|
|
524
|
+
<WidgetOverflowMenu
|
|
525
|
+
widgetId={widgetId}
|
|
526
|
+
menuFeatures={menuFeatures}
|
|
527
|
+
onAction={(actionId) => {
|
|
528
|
+
// Route overflow menu actions through the widget ref first
|
|
529
|
+
if (actionId !== 'delete' && actionId !== 'copy' && widgetRef?.current?.handleAction) {
|
|
530
|
+
widgetRef.current.handleAction(actionId)
|
|
531
|
+
} else {
|
|
532
|
+
onAction?.(actionId)
|
|
533
|
+
}
|
|
534
|
+
}}
|
|
535
|
+
/>
|
|
536
|
+
)}
|
|
503
537
|
</div>
|
|
504
538
|
)}
|
|
505
539
|
|
|
506
|
-
|
|
507
|
-
<
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
540
|
+
{!readOnly && (
|
|
541
|
+
<Tooltip text={selected ? "Click and drag to move" : "Select"} direction="n">
|
|
542
|
+
<button
|
|
543
|
+
className={`tc-drag-handle ${styles.selectHandle} ${selected ? styles.selectHandleActive : ''}`}
|
|
544
|
+
onClick={handleHandleClick}
|
|
545
|
+
aria-label={selected ? "Drag to move widget" : "Select widget"}
|
|
546
|
+
aria-pressed={selected}
|
|
547
|
+
/>
|
|
548
|
+
</Tooltip>
|
|
549
|
+
)}
|
|
514
550
|
</div>
|
|
515
551
|
</div>
|
|
516
552
|
</div>
|
|
@@ -33,7 +33,7 @@
|
|
|
33
33
|
top: calc(100% + 10px);
|
|
34
34
|
}
|
|
35
35
|
|
|
36
|
-
/* Trigger dot —
|
|
36
|
+
/* Trigger dot — positioned in the toolbar, visible at rest */
|
|
37
37
|
.triggerDot {
|
|
38
38
|
width: 6px;
|
|
39
39
|
height: 6px;
|
|
@@ -41,10 +41,6 @@
|
|
|
41
41
|
background: var(--borderColor-muted, #d0d7de);
|
|
42
42
|
opacity: 0.5;
|
|
43
43
|
transition: opacity 120ms;
|
|
44
|
-
position: absolute;
|
|
45
|
-
left: 50%;
|
|
46
|
-
top: 50%;
|
|
47
|
-
transform: translate(-50%, -50%);
|
|
48
44
|
}
|
|
49
45
|
|
|
50
46
|
:global([data-sb-canvas-theme^='dark']) .triggerDot {
|
|
@@ -235,8 +231,8 @@
|
|
|
235
231
|
.overflowMenu {
|
|
236
232
|
position: absolute;
|
|
237
233
|
top: calc(100% + 10px);
|
|
238
|
-
|
|
239
|
-
min-width:
|
|
234
|
+
left: 0;
|
|
235
|
+
min-width: max-content;
|
|
240
236
|
padding: 4px;
|
|
241
237
|
background: var(--bgColor-default, #ffffff);
|
|
242
238
|
border-radius: 10px;
|
|
@@ -265,6 +261,7 @@
|
|
|
265
261
|
color: var(--fgColor-default, #1f2328);
|
|
266
262
|
border-radius: 6px;
|
|
267
263
|
box-sizing: border-box;
|
|
264
|
+
white-space: nowrap;
|
|
268
265
|
}
|
|
269
266
|
|
|
270
267
|
:global([data-sb-canvas-theme^='dark']) .overflowItem {
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for embed interaction UX (click-to-interact overlay).
|
|
3
|
+
*/
|
|
4
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
|
5
|
+
import { render, fireEvent, screen } from '@testing-library/react'
|
|
6
|
+
import PrototypeEmbed from './PrototypeEmbed.jsx'
|
|
7
|
+
import FigmaEmbed from './FigmaEmbed.jsx'
|
|
8
|
+
import ComponentWidget from './ComponentWidget.jsx'
|
|
9
|
+
|
|
10
|
+
// Mock buildPrototypeIndex for PrototypeEmbed
|
|
11
|
+
vi.mock('@dfosco/storyboard-core', () => ({
|
|
12
|
+
buildPrototypeIndex: () => ({ folders: [], prototypes: [], globalFlows: [], sorted: { title: { prototypes: [], folders: [] } } }),
|
|
13
|
+
}))
|
|
14
|
+
|
|
15
|
+
// Simple mock wrapper for WidgetWrapper
|
|
16
|
+
vi.mock('./WidgetWrapper.jsx', () => ({
|
|
17
|
+
default: ({ children }) => <div data-testid="widget-wrapper">{children}</div>,
|
|
18
|
+
}))
|
|
19
|
+
|
|
20
|
+
// Mock ResizeHandle
|
|
21
|
+
vi.mock('./ResizeHandle.jsx', () => ({
|
|
22
|
+
default: () => <div data-testid="resize-handle" />,
|
|
23
|
+
}))
|
|
24
|
+
|
|
25
|
+
// Mock ComponentErrorBoundary
|
|
26
|
+
vi.mock('../ComponentErrorBoundary.jsx', () => ({
|
|
27
|
+
default: ({ children }) => <div data-testid="error-boundary">{children}</div>,
|
|
28
|
+
}))
|
|
29
|
+
|
|
30
|
+
describe('Embed interaction overlay', () => {
|
|
31
|
+
describe('PrototypeEmbed', () => {
|
|
32
|
+
const defaultProps = {
|
|
33
|
+
props: { src: '/test', width: 400, height: 300, zoom: 100 },
|
|
34
|
+
onUpdate: vi.fn(),
|
|
35
|
+
resizable: false,
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
it('renders "Click to interact" hint on hover', () => {
|
|
39
|
+
render(<PrototypeEmbed {...defaultProps} />)
|
|
40
|
+
|
|
41
|
+
const hint = screen.getByText('Click to interact')
|
|
42
|
+
expect(hint).toBeInTheDocument()
|
|
43
|
+
// CSS modules mangle class names, just check the element exists
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
it('enters interactive mode on single click (not double-click)', async () => {
|
|
47
|
+
render(<PrototypeEmbed {...defaultProps} />)
|
|
48
|
+
|
|
49
|
+
// Overlay should exist before interaction
|
|
50
|
+
const overlay = screen.getByRole('button', { name: /click to interact/i })
|
|
51
|
+
expect(overlay).toBeInTheDocument()
|
|
52
|
+
|
|
53
|
+
// Single click should remove the overlay (enter interactive mode)
|
|
54
|
+
fireEvent.click(overlay)
|
|
55
|
+
|
|
56
|
+
// Overlay should no longer exist
|
|
57
|
+
expect(screen.queryByRole('button', { name: /click to interact/i })).not.toBeInTheDocument()
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
it('does not enter interactive mode on shift+click (preserves multi-select)', () => {
|
|
61
|
+
render(<PrototypeEmbed {...defaultProps} />)
|
|
62
|
+
|
|
63
|
+
const overlay = screen.getByRole('button', { name: /click to interact/i })
|
|
64
|
+
fireEvent.click(overlay, { shiftKey: true })
|
|
65
|
+
|
|
66
|
+
// Overlay should still exist (did not enter interactive mode)
|
|
67
|
+
expect(screen.getByRole('button', { name: /click to interact/i })).toBeInTheDocument()
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
it('does not enter interactive mode on meta+click (preserves multi-select)', () => {
|
|
71
|
+
render(<PrototypeEmbed {...defaultProps} />)
|
|
72
|
+
|
|
73
|
+
const overlay = screen.getByRole('button', { name: /click to interact/i })
|
|
74
|
+
fireEvent.click(overlay, { metaKey: true })
|
|
75
|
+
|
|
76
|
+
expect(screen.getByRole('button', { name: /click to interact/i })).toBeInTheDocument()
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
it('supports keyboard interaction (Enter key) with event prevention', () => {
|
|
80
|
+
render(<PrototypeEmbed {...defaultProps} />)
|
|
81
|
+
|
|
82
|
+
const overlay = screen.getByRole('button', { name: /click to interact/i })
|
|
83
|
+
const event = { key: 'Enter', preventDefault: vi.fn(), stopPropagation: vi.fn() }
|
|
84
|
+
fireEvent.keyDown(overlay, event)
|
|
85
|
+
|
|
86
|
+
expect(screen.queryByRole('button', { name: /click to interact/i })).not.toBeInTheDocument()
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
it('supports keyboard interaction (Space key) with event prevention', () => {
|
|
90
|
+
render(<PrototypeEmbed {...defaultProps} />)
|
|
91
|
+
|
|
92
|
+
const overlay = screen.getByRole('button', { name: /click to interact/i })
|
|
93
|
+
const event = { key: ' ', preventDefault: vi.fn(), stopPropagation: vi.fn() }
|
|
94
|
+
fireEvent.keyDown(overlay, event)
|
|
95
|
+
|
|
96
|
+
expect(screen.queryByRole('button', { name: /click to interact/i })).not.toBeInTheDocument()
|
|
97
|
+
})
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
describe('FigmaEmbed', () => {
|
|
101
|
+
const defaultProps = {
|
|
102
|
+
props: { url: 'https://www.figma.com/design/abc123/Test', width: 400, height: 300 },
|
|
103
|
+
onUpdate: vi.fn(),
|
|
104
|
+
resizable: false,
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
it('renders "Click to interact" hint', () => {
|
|
108
|
+
render(<FigmaEmbed {...defaultProps} />)
|
|
109
|
+
|
|
110
|
+
const hint = screen.getByText('Click to interact')
|
|
111
|
+
expect(hint).toBeInTheDocument()
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
it('enters interactive mode on single click', () => {
|
|
115
|
+
render(<FigmaEmbed {...defaultProps} />)
|
|
116
|
+
|
|
117
|
+
const overlay = screen.getByRole('button', { name: /click to interact/i })
|
|
118
|
+
fireEvent.click(overlay)
|
|
119
|
+
|
|
120
|
+
expect(screen.queryByRole('button', { name: /click to interact/i })).not.toBeInTheDocument()
|
|
121
|
+
})
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
describe('ComponentWidget', () => {
|
|
125
|
+
const MockComponent = () => <div>Mock Component</div>
|
|
126
|
+
|
|
127
|
+
const defaultProps = {
|
|
128
|
+
component: MockComponent,
|
|
129
|
+
jsxModule: null,
|
|
130
|
+
exportName: 'MockComponent',
|
|
131
|
+
canvasTheme: 'light',
|
|
132
|
+
isLocalDev: false,
|
|
133
|
+
width: 200,
|
|
134
|
+
height: 150,
|
|
135
|
+
onUpdate: vi.fn(),
|
|
136
|
+
resizable: false,
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
it('renders "Click to interact" hint', () => {
|
|
140
|
+
render(<ComponentWidget {...defaultProps} />)
|
|
141
|
+
|
|
142
|
+
const hint = screen.getByText('Click to interact')
|
|
143
|
+
expect(hint).toBeInTheDocument()
|
|
144
|
+
})
|
|
145
|
+
|
|
146
|
+
it('enters interactive mode on single click', () => {
|
|
147
|
+
render(<ComponentWidget {...defaultProps} />)
|
|
148
|
+
|
|
149
|
+
const overlay = screen.getByRole('button', { name: /click to interact/i })
|
|
150
|
+
fireEvent.click(overlay)
|
|
151
|
+
|
|
152
|
+
expect(screen.queryByRole('button', { name: /click to interact/i })).not.toBeInTheDocument()
|
|
153
|
+
})
|
|
154
|
+
})
|
|
155
|
+
})
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared styles for embed interaction overlays.
|
|
3
|
+
* Used by PrototypeEmbed, FigmaEmbed, and ComponentWidget.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
.interactOverlay {
|
|
7
|
+
position: absolute;
|
|
8
|
+
inset: 0;
|
|
9
|
+
z-index: 1;
|
|
10
|
+
cursor: pointer;
|
|
11
|
+
display: flex;
|
|
12
|
+
align-items: center;
|
|
13
|
+
justify-content: center;
|
|
14
|
+
transition: background-color 150ms ease;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
.interactOverlay:hover {
|
|
18
|
+
background-color: rgba(0, 0, 0, 0.15);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
.interactHint {
|
|
22
|
+
opacity: 0;
|
|
23
|
+
color: var(--fgColor-onInverse);
|
|
24
|
+
background-color: var(--bgColor-inverse);
|
|
25
|
+
padding: var(--base-size-12) var(--base-size-16);
|
|
26
|
+
border-radius: var(--base-size-6);
|
|
27
|
+
font-size: 14px;
|
|
28
|
+
font-weight: 600;
|
|
29
|
+
pointer-events: none;
|
|
30
|
+
transition: opacity 150ms ease;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
.interactOverlay:hover .interactHint {
|
|
34
|
+
opacity: 1;
|
|
35
|
+
}
|
|
@@ -4,6 +4,7 @@ import PrototypeEmbed from './PrototypeEmbed.jsx'
|
|
|
4
4
|
import LinkPreview from './LinkPreview.jsx'
|
|
5
5
|
import ImageWidget from './ImageWidget.jsx'
|
|
6
6
|
import FigmaEmbed from './FigmaEmbed.jsx'
|
|
7
|
+
import StoryWidget from './StoryWidget.jsx'
|
|
7
8
|
|
|
8
9
|
/**
|
|
9
10
|
* Maps widget type strings to their React components.
|
|
@@ -16,6 +17,7 @@ export const widgetRegistry = {
|
|
|
16
17
|
'link-preview': LinkPreview,
|
|
17
18
|
'image': ImageWidget,
|
|
18
19
|
'figma-embed': FigmaEmbed,
|
|
20
|
+
'story': StoryWidget,
|
|
19
21
|
}
|
|
20
22
|
|
|
21
23
|
/**
|
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Paste Rules — config-driven paste routing for canvas widgets.
|
|
3
|
+
*
|
|
4
|
+
* All paste routing is defined in paste.config.json (packages/core).
|
|
5
|
+
* Each rule declares a match condition and a widget type + prop template.
|
|
6
|
+
* Rules are evaluated in order — first match wins.
|
|
7
|
+
*
|
|
8
|
+
* Image paste and widget-ref paste remain in CanvasPage.jsx because they
|
|
9
|
+
* require clipboard / canvas API access that doesn't belong here.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import pasteConfig from '@dfosco/storyboard-core/paste.config.json'
|
|
13
|
+
|
|
14
|
+
// ---------------------------------------------------------------------------
|
|
15
|
+
// Branch-prefix pattern (matches /branch--<name> at start of pathname)
|
|
16
|
+
// ---------------------------------------------------------------------------
|
|
17
|
+
|
|
18
|
+
const BRANCH_PREFIX_RE = /^\/branch--[^/]+/
|
|
19
|
+
|
|
20
|
+
// ---------------------------------------------------------------------------
|
|
21
|
+
// Paste context — captures origin + base-path once per effect cycle
|
|
22
|
+
// ---------------------------------------------------------------------------
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Build a paste context object that URL rules can query.
|
|
26
|
+
*
|
|
27
|
+
* @param {string} origin - `window.location.origin`
|
|
28
|
+
* @param {string} basePath - `import.meta.env.BASE_URL` with trailing slash stripped
|
|
29
|
+
* @returns {PasteContext}
|
|
30
|
+
*/
|
|
31
|
+
export function createPasteContext(origin, basePath) {
|
|
32
|
+
const normalizedBase = basePath.replace(/\/$/, '')
|
|
33
|
+
|
|
34
|
+
return {
|
|
35
|
+
origin,
|
|
36
|
+
basePath: normalizedBase,
|
|
37
|
+
baseUrl: origin + normalizedBase,
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Check whether a raw URL string points at the same Storyboard origin,
|
|
41
|
+
* accounting for branch-deploy prefixes.
|
|
42
|
+
* Uses parsed URL comparison (not string prefix) to avoid host spoofing.
|
|
43
|
+
*/
|
|
44
|
+
isSameOrigin(text) {
|
|
45
|
+
const parsed = this.parseUrl(text)
|
|
46
|
+
if (!parsed || parsed.origin !== origin) return false
|
|
47
|
+
const pathname = parsed.pathname
|
|
48
|
+
if (normalizedBase && (pathname === normalizedBase || pathname.startsWith(normalizedBase + '/'))) return true
|
|
49
|
+
if (!normalizedBase) return true
|
|
50
|
+
return BRANCH_PREFIX_RE.test(pathname)
|
|
51
|
+
},
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Strip the base path (or any branch prefix) from a pathname to produce a
|
|
55
|
+
* portable prototype `src` value.
|
|
56
|
+
*/
|
|
57
|
+
extractSrc(pathname) {
|
|
58
|
+
if (normalizedBase && pathname.startsWith(normalizedBase)) {
|
|
59
|
+
return pathname.slice(normalizedBase.length) || '/'
|
|
60
|
+
}
|
|
61
|
+
const m = pathname.match(BRANCH_PREFIX_RE)
|
|
62
|
+
if (m) return pathname.slice(m[0].length) || '/'
|
|
63
|
+
return pathname
|
|
64
|
+
},
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Parse text as an http(s) URL. Returns the URL object or null.
|
|
68
|
+
*/
|
|
69
|
+
parseUrl(text) {
|
|
70
|
+
try {
|
|
71
|
+
const url = new URL(text)
|
|
72
|
+
return (url.protocol === 'http:' || url.protocol === 'https:') ? url : null
|
|
73
|
+
} catch {
|
|
74
|
+
return null
|
|
75
|
+
}
|
|
76
|
+
},
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// ---------------------------------------------------------------------------
|
|
81
|
+
// Template variable resolution
|
|
82
|
+
// ---------------------------------------------------------------------------
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Build the set of template variables available to prop templates.
|
|
86
|
+
*
|
|
87
|
+
* @param {string} text - raw pasted text
|
|
88
|
+
* @param {URL|null} parsed - parsed URL (null for non-URL text)
|
|
89
|
+
* @param {PasteContext} ctx
|
|
90
|
+
* @returns {Record<string, string>}
|
|
91
|
+
*/
|
|
92
|
+
export function buildTemplateVars(text, parsed, ctx) {
|
|
93
|
+
const pathname = parsed?.pathname ?? ''
|
|
94
|
+
return {
|
|
95
|
+
$url: text,
|
|
96
|
+
$text: text,
|
|
97
|
+
$pathname: pathname,
|
|
98
|
+
$src: ctx.extractSrc(pathname),
|
|
99
|
+
$search: parsed?.search ?? '',
|
|
100
|
+
$hash: parsed?.hash ?? '',
|
|
101
|
+
$hostname: parsed?.hostname ?? '',
|
|
102
|
+
$origin: parsed?.origin ?? '',
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Apply URL sanitization to a value per the sanitize spec.
|
|
108
|
+
*
|
|
109
|
+
* @param {string} value - the resolved URL string
|
|
110
|
+
* @param {{ stripParams?: string[], normalizeHost?: string }} spec
|
|
111
|
+
* @returns {string}
|
|
112
|
+
*/
|
|
113
|
+
export function sanitizeUrl(value, spec) {
|
|
114
|
+
try {
|
|
115
|
+
const url = new URL(value)
|
|
116
|
+
if (spec.normalizeHost) url.hostname = spec.normalizeHost
|
|
117
|
+
if (Array.isArray(spec.stripParams)) {
|
|
118
|
+
for (const p of spec.stripParams) url.searchParams.delete(p)
|
|
119
|
+
}
|
|
120
|
+
return url.toString()
|
|
121
|
+
} catch {
|
|
122
|
+
return value
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Resolve a single prop value from config.
|
|
128
|
+
* - Plain values (string, number, boolean) are returned as-is.
|
|
129
|
+
* - Objects with `template` are resolved from template vars.
|
|
130
|
+
* - Objects with `sanitize` have URL sanitization applied after template resolution.
|
|
131
|
+
*
|
|
132
|
+
* @param {*} propDef - the prop definition from config
|
|
133
|
+
* @param {Record<string, string>} vars - template variables
|
|
134
|
+
* @returns {*}
|
|
135
|
+
*/
|
|
136
|
+
export function resolvePropValue(propDef, vars) {
|
|
137
|
+
if (propDef == null) return propDef
|
|
138
|
+
|
|
139
|
+
// Object with template key → resolve template + optional sanitize
|
|
140
|
+
if (typeof propDef === 'object' && propDef.template) {
|
|
141
|
+
let value = propDef.template
|
|
142
|
+
for (const [varName, varValue] of Object.entries(vars)) {
|
|
143
|
+
value = value.replaceAll(varName, varValue)
|
|
144
|
+
}
|
|
145
|
+
if (propDef.sanitize) {
|
|
146
|
+
value = sanitizeUrl(value, propDef.sanitize)
|
|
147
|
+
}
|
|
148
|
+
return value
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// Plain string — substitute template vars
|
|
152
|
+
if (typeof propDef === 'string') {
|
|
153
|
+
let value = propDef
|
|
154
|
+
for (const [varName, varValue] of Object.entries(vars)) {
|
|
155
|
+
value = value.replaceAll(varName, varValue)
|
|
156
|
+
}
|
|
157
|
+
return value
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// Numbers, booleans, etc. — pass through
|
|
161
|
+
return propDef
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// ---------------------------------------------------------------------------
|
|
165
|
+
// Rule compilation
|
|
166
|
+
// ---------------------------------------------------------------------------
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Compile a single rule from paste.config.json into a callable
|
|
170
|
+
* `{ name, match, resolve }` object.
|
|
171
|
+
*
|
|
172
|
+
* Match conditions (all must pass when combined):
|
|
173
|
+
* - `hostname` — regex tested against parsed URL hostname
|
|
174
|
+
* - `pathname` — regex tested against parsed URL pathname
|
|
175
|
+
* - `pattern` — regex tested against the full pasted text
|
|
176
|
+
* - `sameOrigin` — boolean; delegates to ctx.isSameOrigin()
|
|
177
|
+
* - `isUrl` — boolean; true if text is a valid http(s) URL
|
|
178
|
+
* - `any` — boolean; always matches (catch-all)
|
|
179
|
+
*
|
|
180
|
+
* @param {object} ruleDef
|
|
181
|
+
* @returns {{ name: string, match: Function, resolve: Function } | null}
|
|
182
|
+
*/
|
|
183
|
+
export function compileRule(ruleDef) {
|
|
184
|
+
if (!ruleDef || !ruleDef.match || !ruleDef.widget) return null
|
|
185
|
+
|
|
186
|
+
const { match: matchDef, widget, props: propsDef = {}, name = 'unnamed' } = ruleDef
|
|
187
|
+
|
|
188
|
+
// Pre-compile regexes
|
|
189
|
+
const matchers = []
|
|
190
|
+
|
|
191
|
+
if (matchDef.hostname) {
|
|
192
|
+
try {
|
|
193
|
+
const re = new RegExp(matchDef.hostname)
|
|
194
|
+
matchers.push((text, parsed) => parsed !== null && re.test(parsed.hostname))
|
|
195
|
+
} catch {
|
|
196
|
+
console.warn(`[pasteRules] Invalid hostname regex in rule "${name}": ${matchDef.hostname}`)
|
|
197
|
+
return null
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
if (matchDef.pathname) {
|
|
202
|
+
try {
|
|
203
|
+
const re = new RegExp(matchDef.pathname)
|
|
204
|
+
matchers.push((text, parsed) => parsed !== null && re.test(parsed.pathname))
|
|
205
|
+
} catch {
|
|
206
|
+
console.warn(`[pasteRules] Invalid pathname regex in rule "${name}": ${matchDef.pathname}`)
|
|
207
|
+
return null
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
if (matchDef.pattern) {
|
|
212
|
+
try {
|
|
213
|
+
const re = new RegExp(matchDef.pattern)
|
|
214
|
+
matchers.push((text) => re.test(text))
|
|
215
|
+
} catch {
|
|
216
|
+
console.warn(`[pasteRules] Invalid pattern regex in rule "${name}": ${matchDef.pattern}`)
|
|
217
|
+
return null
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
if (matchDef.sameOrigin) {
|
|
222
|
+
matchers.push((text, parsed, ctx) => ctx.isSameOrigin(text))
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
if (matchDef.isUrl) {
|
|
226
|
+
matchers.push((text, parsed) => parsed !== null)
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
if (matchDef.any) {
|
|
230
|
+
matchers.push(() => true)
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
if (matchers.length === 0) {
|
|
234
|
+
console.warn(`[pasteRules] Rule "${name}" has no valid match conditions`)
|
|
235
|
+
return null
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
return {
|
|
239
|
+
name,
|
|
240
|
+
match(text, parsed, ctx) {
|
|
241
|
+
return matchers.every(fn => fn(text, parsed, ctx))
|
|
242
|
+
},
|
|
243
|
+
resolve(text, parsed, ctx) {
|
|
244
|
+
const vars = buildTemplateVars(text, parsed, ctx)
|
|
245
|
+
const resolvedProps = {}
|
|
246
|
+
for (const [key, def] of Object.entries(propsDef)) {
|
|
247
|
+
resolvedProps[key] = resolvePropValue(def, vars)
|
|
248
|
+
}
|
|
249
|
+
return { type: widget, props: resolvedProps }
|
|
250
|
+
},
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// ---------------------------------------------------------------------------
|
|
255
|
+
// Compile rules from paste.config.json at import time
|
|
256
|
+
// ---------------------------------------------------------------------------
|
|
257
|
+
|
|
258
|
+
const COMPILED_RULES = (pasteConfig.rules || [])
|
|
259
|
+
.map(compileRule)
|
|
260
|
+
.filter(Boolean)
|
|
261
|
+
|
|
262
|
+
// ---------------------------------------------------------------------------
|
|
263
|
+
// Main resolver
|
|
264
|
+
// ---------------------------------------------------------------------------
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* Resolve pasted text into a widget `{ type, props }` by running through
|
|
268
|
+
* ordered rules from paste.config.json. Override rules (if any) run first.
|
|
269
|
+
*
|
|
270
|
+
* @param {string} text - trimmed clipboard text
|
|
271
|
+
* @param {PasteContext} context - from `createPasteContext()`
|
|
272
|
+
* @param {object[]} [overrideRules] - raw rule objects from storyboard.config.json canvas.pasteRules
|
|
273
|
+
* @returns {{ type: string, props: object } | null}
|
|
274
|
+
*/
|
|
275
|
+
export function resolvePaste(text, context, overrideRules = []) {
|
|
276
|
+
const parsed = context.parseUrl(text)
|
|
277
|
+
|
|
278
|
+
// Compile any runtime override rules (from storyboard.config.json)
|
|
279
|
+
const overrides = overrideRules.map(compileRule).filter(Boolean)
|
|
280
|
+
const allRules = [...overrides, ...COMPILED_RULES]
|
|
281
|
+
|
|
282
|
+
for (const rule of allRules) {
|
|
283
|
+
if (rule.match(text, parsed, context)) {
|
|
284
|
+
return rule.resolve(text, parsed, context)
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
return null
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// ---------------------------------------------------------------------------
|
|
292
|
+
// Exports for testing
|
|
293
|
+
// ---------------------------------------------------------------------------
|
|
294
|
+
|
|
295
|
+
export { COMPILED_RULES, BRANCH_PREFIX_RE }
|