@dfosco/storyboard-react 4.0.0-beta.26 → 4.0.0-beta.28
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +3 -3
- package/src/canvas/CanvasPage.bridge.test.jsx +87 -2
- package/src/canvas/CanvasPage.jsx +161 -18
- package/src/canvas/CanvasPage.module.css +54 -0
- package/src/canvas/CanvasPage.multiselect.test.jsx +2 -0
- package/src/canvas/canvasApi.js +8 -0
- package/src/canvas/widgets/FigmaEmbed.jsx +42 -7
- package/src/canvas/widgets/FigmaEmbed.module.css +21 -0
- package/src/canvas/widgets/LinkPreview.jsx +247 -18
- package/src/canvas/widgets/LinkPreview.module.css +349 -8
- package/src/canvas/widgets/LinkPreview.test.jsx +71 -0
- package/src/canvas/widgets/MarkdownBlock.jsx +2 -1
- package/src/canvas/widgets/MarkdownBlock.module.css +34 -11
- package/src/canvas/widgets/PrototypeEmbed.jsx +101 -44
- package/src/canvas/widgets/PrototypeEmbed.module.css +1 -0
- package/src/canvas/widgets/StoryWidget.jsx +86 -42
- package/src/canvas/widgets/StoryWidget.module.css +1 -0
- package/src/canvas/widgets/WidgetChrome.jsx +20 -1
- package/src/canvas/widgets/embedInteraction.test.jsx +16 -18
- package/src/canvas/widgets/embedTheme.js +37 -1
- package/src/canvas/widgets/githubUrl.js +82 -0
- package/src/canvas/widgets/githubUrl.test.js +74 -0
- package/src/canvas/widgets/refreshQueue.js +108 -0
- package/src/canvas/widgets/snapshotDisplay.test.jsx +60 -90
- package/src/canvas/widgets/useSnapshotCapture.js +38 -139
- package/src/canvas/widgets/useSnapshotCapture.test.jsx +30 -91
- package/src/canvas/widgets/widgetConfig.test.js +1 -1
- package/src/story/StoryPage.jsx +25 -60
- package/src/story/StoryPage.module.css +0 -55
|
@@ -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, CodeIcon as OcticonCode, UnwrapIcon as OcticonUnwrap, ImageIcon as OcticonImage } from '@primer/octicons-react'
|
|
3
|
+
import { EyeIcon as OcticonEye, EyeClosedIcon as OcticonEyeClosed, CodeIcon as OcticonCode, UnwrapIcon as OcticonUnwrap, ImageIcon as OcticonImage, UnfoldIcon as OcticonUnfold, FoldIcon as OcticonFold } from '@primer/octicons-react'
|
|
4
4
|
import styles from './WidgetChrome.module.css'
|
|
5
5
|
|
|
6
6
|
const STICKY_NOTE_COLORS = {
|
|
@@ -122,6 +122,22 @@ function ExpandIcon() {
|
|
|
122
122
|
)
|
|
123
123
|
}
|
|
124
124
|
|
|
125
|
+
function SyncIcon() {
|
|
126
|
+
return (
|
|
127
|
+
<svg width="12" height="12" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true">
|
|
128
|
+
<path d="M1.705 8.005a.75.75 0 0 1 .834.656 5.5 5.5 0 0 0 9.592 2.97l-1.204-1.204a.25.25 0 0 1 .177-.427h3.646a.25.25 0 0 1 .25.25v3.646a.25.25 0 0 1-.427.177l-1.38-1.38A7.002 7.002 0 0 1 1.05 8.84a.75.75 0 0 1 .656-.834ZM8 2.5a5.487 5.487 0 0 0-4.131 1.869l1.204 1.204A.25.25 0 0 1 4.896 6H1.25A.25.25 0 0 1 1 5.75V2.104a.25.25 0 0 1 .427-.177l1.38 1.38A7.002 7.002 0 0 1 14.95 7.16a.75.75 0 0 1-1.49.178A5.5 5.5 0 0 0 8 2.5Z" />
|
|
129
|
+
</svg>
|
|
130
|
+
)
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function UnfoldIcon() {
|
|
134
|
+
return <OcticonUnfold size={12} />
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function FoldIcon() {
|
|
138
|
+
return <OcticonFold size={12} />
|
|
139
|
+
}
|
|
140
|
+
|
|
125
141
|
/** Icon registry — maps icon name strings from config to React components. */
|
|
126
142
|
const ICON_REGISTRY = {
|
|
127
143
|
'trash': DeleteIcon,
|
|
@@ -140,6 +156,9 @@ const ICON_REGISTRY = {
|
|
|
140
156
|
'chevron-down': ChevronDownIcon,
|
|
141
157
|
'download': DownloadIcon,
|
|
142
158
|
'expand': ExpandIcon,
|
|
159
|
+
'sync': SyncIcon,
|
|
160
|
+
'unfold': UnfoldIcon,
|
|
161
|
+
'fold': FoldIcon,
|
|
143
162
|
}
|
|
144
163
|
|
|
145
164
|
/** Danger-styled actions in the overflow menu. */
|
|
@@ -56,10 +56,10 @@ describe('Embed interaction overlay', () => {
|
|
|
56
56
|
resizable: false,
|
|
57
57
|
}
|
|
58
58
|
|
|
59
|
-
it('renders "Click to
|
|
59
|
+
it('renders "Click to open" hint when no snapshot exists', () => {
|
|
60
60
|
render(<PrototypeEmbed {...defaultProps} />)
|
|
61
61
|
|
|
62
|
-
const hint = screen.getByText('Click to
|
|
62
|
+
const hint = screen.getByText('Click to open')
|
|
63
63
|
expect(hint).toBeInTheDocument()
|
|
64
64
|
// CSS modules mangle class names, just check the element exists
|
|
65
65
|
})
|
|
@@ -68,62 +68,60 @@ describe('Embed interaction overlay', () => {
|
|
|
68
68
|
const { container } = render(<PrototypeEmbed {...defaultProps} />)
|
|
69
69
|
|
|
70
70
|
// Overlay should exist before interaction
|
|
71
|
-
const overlay = screen.getByRole('button', { name: /click to
|
|
71
|
+
const overlay = screen.getByRole('button', { name: /click to open/i })
|
|
72
72
|
expect(overlay).toBeInTheDocument()
|
|
73
73
|
expect(container.querySelector('iframe')).not.toBeInTheDocument()
|
|
74
74
|
expect(screen.getByText('Design Overview')).toBeInTheDocument()
|
|
75
|
-
expect(screen.getByText('Design Overview prototype')).toBeInTheDocument()
|
|
76
75
|
|
|
77
76
|
// Single click should remove the overlay (enter interactive mode)
|
|
78
77
|
fireEvent.click(overlay)
|
|
79
78
|
|
|
80
79
|
// Overlay should no longer exist
|
|
81
|
-
expect(screen.queryByRole('button', { name: /click to
|
|
82
|
-
expect(screen.queryByText('Design Overview prototype')).not.toBeInTheDocument()
|
|
80
|
+
expect(screen.queryByRole('button', { name: /click to open/i })).not.toBeInTheDocument()
|
|
83
81
|
expect(container.querySelector('iframe')).toBeInTheDocument()
|
|
84
82
|
|
|
85
83
|
fireEvent.pointerDown(document.body)
|
|
86
|
-
expect(screen.getByRole('button', { name: /click to
|
|
84
|
+
expect(screen.getByRole('button', { name: /click to open/i })).toBeInTheDocument()
|
|
87
85
|
expect(container.querySelector('iframe')).not.toBeInTheDocument()
|
|
88
86
|
})
|
|
89
87
|
|
|
90
88
|
it('does not enter interactive mode on shift+click (preserves multi-select)', () => {
|
|
91
89
|
render(<PrototypeEmbed {...defaultProps} />)
|
|
92
90
|
|
|
93
|
-
const overlay = screen.getByRole('button', { name: /click to
|
|
91
|
+
const overlay = screen.getByRole('button', { name: /click to open/i })
|
|
94
92
|
fireEvent.click(overlay, { shiftKey: true })
|
|
95
93
|
|
|
96
94
|
// Overlay should still exist (did not enter interactive mode)
|
|
97
|
-
expect(screen.getByRole('button', { name: /click to
|
|
95
|
+
expect(screen.getByRole('button', { name: /click to open/i })).toBeInTheDocument()
|
|
98
96
|
})
|
|
99
97
|
|
|
100
98
|
it('does not enter interactive mode on meta+click (preserves multi-select)', () => {
|
|
101
99
|
render(<PrototypeEmbed {...defaultProps} />)
|
|
102
100
|
|
|
103
|
-
const overlay = screen.getByRole('button', { name: /click to
|
|
101
|
+
const overlay = screen.getByRole('button', { name: /click to open/i })
|
|
104
102
|
fireEvent.click(overlay, { metaKey: true })
|
|
105
103
|
|
|
106
|
-
expect(screen.getByRole('button', { name: /click to
|
|
104
|
+
expect(screen.getByRole('button', { name: /click to open/i })).toBeInTheDocument()
|
|
107
105
|
})
|
|
108
106
|
|
|
109
107
|
it('supports keyboard interaction (Enter key) with event prevention', () => {
|
|
110
108
|
render(<PrototypeEmbed {...defaultProps} />)
|
|
111
109
|
|
|
112
|
-
const overlay = screen.getByRole('button', { name: /click to
|
|
110
|
+
const overlay = screen.getByRole('button', { name: /click to open/i })
|
|
113
111
|
const event = { key: 'Enter', preventDefault: vi.fn(), stopPropagation: vi.fn() }
|
|
114
112
|
fireEvent.keyDown(overlay, event)
|
|
115
113
|
|
|
116
|
-
expect(screen.queryByRole('button', { name: /click to
|
|
114
|
+
expect(screen.queryByRole('button', { name: /click to open/i })).not.toBeInTheDocument()
|
|
117
115
|
})
|
|
118
116
|
|
|
119
117
|
it('supports keyboard interaction (Space key) with event prevention', () => {
|
|
120
118
|
render(<PrototypeEmbed {...defaultProps} />)
|
|
121
119
|
|
|
122
|
-
const overlay = screen.getByRole('button', { name: /click to
|
|
120
|
+
const overlay = screen.getByRole('button', { name: /click to open/i })
|
|
123
121
|
const event = { key: ' ', preventDefault: vi.fn(), stopPropagation: vi.fn() }
|
|
124
122
|
fireEvent.keyDown(overlay, event)
|
|
125
123
|
|
|
126
|
-
expect(screen.queryByRole('button', { name: /click to
|
|
124
|
+
expect(screen.queryByRole('button', { name: /click to open/i })).not.toBeInTheDocument()
|
|
127
125
|
})
|
|
128
126
|
})
|
|
129
127
|
|
|
@@ -167,16 +165,16 @@ describe('Embed interaction overlay', () => {
|
|
|
167
165
|
it('mounts iframe only after user activation', () => {
|
|
168
166
|
const { container } = render(<StoryWidget {...defaultProps} />)
|
|
169
167
|
|
|
170
|
-
const overlay = screen.getByRole('button', { name: /click to
|
|
168
|
+
const overlay = screen.getByRole('button', { name: /click to open story component/i })
|
|
171
169
|
expect(container.querySelector('iframe')).not.toBeInTheDocument()
|
|
172
170
|
|
|
173
171
|
fireEvent.click(overlay)
|
|
174
172
|
|
|
175
|
-
expect(screen.queryByRole('button', { name: /click to
|
|
173
|
+
expect(screen.queryByRole('button', { name: /click to open story component/i })).not.toBeInTheDocument()
|
|
176
174
|
expect(container.querySelector('iframe')).toBeInTheDocument()
|
|
177
175
|
|
|
178
176
|
fireEvent.pointerDown(document.body)
|
|
179
|
-
expect(screen.getByRole('button', { name: /click to
|
|
177
|
+
expect(screen.getByRole('button', { name: /click to open story component/i })).toBeInTheDocument()
|
|
180
178
|
expect(container.querySelector('iframe')).not.toBeInTheDocument()
|
|
181
179
|
})
|
|
182
180
|
})
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
*/
|
|
5
5
|
export function resolveCanvasTheme() {
|
|
6
6
|
if (typeof localStorage === 'undefined') return 'light'
|
|
7
|
-
let sync = { prototype: true, toolbar: false, codeBoxes: true, canvas:
|
|
7
|
+
let sync = { prototype: true, toolbar: false, codeBoxes: true, canvas: true }
|
|
8
8
|
try {
|
|
9
9
|
const rawSync = localStorage.getItem('sb-theme-sync')
|
|
10
10
|
if (rawSync) sync = { ...sync, ...JSON.parse(rawSync) }
|
|
@@ -18,6 +18,42 @@ export function resolveCanvasTheme() {
|
|
|
18
18
|
window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
|
|
19
19
|
}
|
|
20
20
|
|
|
21
|
+
/**
|
|
22
|
+
* Subscribe to canvas theme changes for embed widget snapshot switching.
|
|
23
|
+
*
|
|
24
|
+
* Reads `data-sb-canvas-theme` from the nearest ancestor set by CanvasPage.
|
|
25
|
+
* With canvas sync ON (the default), this attribute follows the global theme.
|
|
26
|
+
* Uses MutationObserver for immediate reaction + event backup.
|
|
27
|
+
*/
|
|
28
|
+
export function subscribeCanvasTheme({ anchorRef, onTheme }) {
|
|
29
|
+
if (typeof onTheme !== 'function') return () => {}
|
|
30
|
+
|
|
31
|
+
let observed = null
|
|
32
|
+
let observer = null
|
|
33
|
+
|
|
34
|
+
function readAndEmit() {
|
|
35
|
+
const el = anchorRef?.current?.closest?.('[data-sb-canvas-theme]') || null
|
|
36
|
+
if (el !== observed) {
|
|
37
|
+
if (observer) observer.disconnect()
|
|
38
|
+
observer = null
|
|
39
|
+
observed = el
|
|
40
|
+
if (el && typeof MutationObserver !== 'undefined') {
|
|
41
|
+
observer = new MutationObserver(readAndEmit)
|
|
42
|
+
observer.observe(el, { attributes: true, attributeFilter: ['data-sb-canvas-theme'] })
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
onTheme(el?.getAttribute('data-sb-canvas-theme') || 'light')
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
readAndEmit()
|
|
49
|
+
document.addEventListener('storyboard:theme:changed', readAndEmit)
|
|
50
|
+
|
|
51
|
+
return () => {
|
|
52
|
+
document.removeEventListener('storyboard:theme:changed', readAndEmit)
|
|
53
|
+
if (observer) observer.disconnect()
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
21
57
|
export function getEmbedChromeVars(theme) {
|
|
22
58
|
const value = String(theme || 'light')
|
|
23
59
|
if (value === 'dark_dimmed') {
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
const GH_HOST_RE = /^(www\.)?github\.com$/i
|
|
2
|
+
const ISSUE_PATH_RE = /^\/([^/]+)\/([^/]+)\/issues\/(\d+)\/?$/
|
|
3
|
+
const DISCUSSION_PATH_RE = /^\/([^/]+)\/([^/]+)\/discussions\/(\d+)\/?$/
|
|
4
|
+
const PULL_REQUEST_PATH_RE = /^\/([^/]+)\/([^/]+)\/pull\/(\d+)\/?$/
|
|
5
|
+
const ISSUE_COMMENT_HASH_RE = /^#issuecomment-(\d+)$/i
|
|
6
|
+
const DISCUSSION_COMMENT_HASH_RE = /^#discussioncomment-(\d+)$/i
|
|
7
|
+
|
|
8
|
+
function toNumber(raw) {
|
|
9
|
+
const value = Number.parseInt(raw, 10)
|
|
10
|
+
return Number.isFinite(value) ? value : null
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Parse supported GitHub embed URLs (issues, discussions, comments).
|
|
15
|
+
* @param {string} rawUrl
|
|
16
|
+
* @returns {null | {
|
|
17
|
+
* kind: 'issue' | 'discussion' | 'comment',
|
|
18
|
+
* parentKind: 'issue' | 'discussion',
|
|
19
|
+
* owner: string,
|
|
20
|
+
* repo: string,
|
|
21
|
+
* number: number,
|
|
22
|
+
* commentId?: number
|
|
23
|
+
* }}
|
|
24
|
+
*/
|
|
25
|
+
export function parseGitHubUrl(rawUrl) {
|
|
26
|
+
try {
|
|
27
|
+
const parsed = new URL(rawUrl)
|
|
28
|
+
if (!GH_HOST_RE.test(parsed.hostname)) return null
|
|
29
|
+
|
|
30
|
+
const issueMatch = parsed.pathname.match(ISSUE_PATH_RE)
|
|
31
|
+
if (issueMatch) {
|
|
32
|
+
const [, owner, repo, numberRaw] = issueMatch
|
|
33
|
+
const number = toNumber(numberRaw)
|
|
34
|
+
if (!number) return null
|
|
35
|
+
|
|
36
|
+
const commentMatch = parsed.hash.match(ISSUE_COMMENT_HASH_RE)
|
|
37
|
+
if (commentMatch) {
|
|
38
|
+
const commentId = toNumber(commentMatch[1])
|
|
39
|
+
if (!commentId) return null
|
|
40
|
+
return { kind: 'comment', parentKind: 'issue', owner, repo, number, commentId }
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
if (parsed.hash) return null
|
|
44
|
+
return { kind: 'issue', parentKind: 'issue', owner, repo, number }
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const discussionMatch = parsed.pathname.match(DISCUSSION_PATH_RE)
|
|
48
|
+
if (discussionMatch) {
|
|
49
|
+
const [, owner, repo, numberRaw] = discussionMatch
|
|
50
|
+
const number = toNumber(numberRaw)
|
|
51
|
+
if (!number) return null
|
|
52
|
+
|
|
53
|
+
const commentMatch = parsed.hash.match(DISCUSSION_COMMENT_HASH_RE)
|
|
54
|
+
if (commentMatch) {
|
|
55
|
+
const commentId = toNumber(commentMatch[1])
|
|
56
|
+
if (!commentId) return null
|
|
57
|
+
return { kind: 'comment', parentKind: 'discussion', owner, repo, number, commentId }
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
if (parsed.hash) return null
|
|
61
|
+
return { kind: 'discussion', parentKind: 'discussion', owner, repo, number }
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const prMatch = parsed.pathname.match(PULL_REQUEST_PATH_RE)
|
|
65
|
+
if (prMatch) {
|
|
66
|
+
const [, owner, repo, numberRaw] = prMatch
|
|
67
|
+
const number = toNumber(numberRaw)
|
|
68
|
+
if (!number) return null
|
|
69
|
+
|
|
70
|
+
if (parsed.hash) return null
|
|
71
|
+
return { kind: 'pull_request', parentKind: 'pull_request', owner, repo, number }
|
|
72
|
+
}
|
|
73
|
+
} catch {
|
|
74
|
+
return null
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
return null
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function isGitHubEmbedUrl(rawUrl) {
|
|
81
|
+
return parseGitHubUrl(rawUrl) !== null
|
|
82
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { isGitHubEmbedUrl, parseGitHubUrl } from './githubUrl.js'
|
|
3
|
+
|
|
4
|
+
describe('parseGitHubUrl', () => {
|
|
5
|
+
it('classifies issue URLs', () => {
|
|
6
|
+
expect(parseGitHubUrl('https://github.com/dfosco/storyboard/issues/12')).toEqual({
|
|
7
|
+
kind: 'issue',
|
|
8
|
+
parentKind: 'issue',
|
|
9
|
+
owner: 'dfosco',
|
|
10
|
+
repo: 'storyboard',
|
|
11
|
+
number: 12,
|
|
12
|
+
})
|
|
13
|
+
})
|
|
14
|
+
|
|
15
|
+
it('classifies discussion URLs', () => {
|
|
16
|
+
expect(parseGitHubUrl('https://github.com/dfosco/storyboard/discussions/99')).toEqual({
|
|
17
|
+
kind: 'discussion',
|
|
18
|
+
parentKind: 'discussion',
|
|
19
|
+
owner: 'dfosco',
|
|
20
|
+
repo: 'storyboard',
|
|
21
|
+
number: 99,
|
|
22
|
+
})
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
it('classifies issue comment URLs', () => {
|
|
26
|
+
expect(parseGitHubUrl('https://github.com/dfosco/storyboard/issues/12#issuecomment-345')).toEqual({
|
|
27
|
+
kind: 'comment',
|
|
28
|
+
parentKind: 'issue',
|
|
29
|
+
owner: 'dfosco',
|
|
30
|
+
repo: 'storyboard',
|
|
31
|
+
number: 12,
|
|
32
|
+
commentId: 345,
|
|
33
|
+
})
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
it('classifies discussion comment URLs', () => {
|
|
37
|
+
expect(parseGitHubUrl('https://github.com/dfosco/storyboard/discussions/99#discussioncomment-888')).toEqual({
|
|
38
|
+
kind: 'comment',
|
|
39
|
+
parentKind: 'discussion',
|
|
40
|
+
owner: 'dfosco',
|
|
41
|
+
repo: 'storyboard',
|
|
42
|
+
number: 99,
|
|
43
|
+
commentId: 888,
|
|
44
|
+
})
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
it('classifies pull request URLs', () => {
|
|
48
|
+
expect(parseGitHubUrl('https://github.com/dfosco/storyboard/pull/12')).toEqual({
|
|
49
|
+
kind: 'pull_request',
|
|
50
|
+
parentKind: 'pull_request',
|
|
51
|
+
owner: 'dfosco',
|
|
52
|
+
repo: 'storyboard',
|
|
53
|
+
number: 12,
|
|
54
|
+
})
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
it('rejects unsupported paths and hashes', () => {
|
|
58
|
+
expect(parseGitHubUrl('https://github.com/dfosco/storyboard/issues/12#random')).toBeNull()
|
|
59
|
+
expect(parseGitHubUrl('https://example.com/dfosco/storyboard/issues/12')).toBeNull()
|
|
60
|
+
expect(parseGitHubUrl('not a url')).toBeNull()
|
|
61
|
+
})
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
describe('isGitHubEmbedUrl', () => {
|
|
65
|
+
it('returns true for supported GitHub URLs', () => {
|
|
66
|
+
expect(isGitHubEmbedUrl('https://github.com/dfosco/storyboard/issues/12')).toBe(true)
|
|
67
|
+
expect(isGitHubEmbedUrl('https://github.com/dfosco/storyboard/discussions/99#discussioncomment-888')).toBe(true)
|
|
68
|
+
expect(isGitHubEmbedUrl('https://github.com/dfosco/storyboard/pull/1')).toBe(true)
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
it('returns false for unsupported URLs', () => {
|
|
72
|
+
expect(isGitHubEmbedUrl('https://example.com')).toBe(false)
|
|
73
|
+
})
|
|
74
|
+
})
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Concurrent refresh queue for bulk snapshot recapture (e.g. on theme change).
|
|
3
|
+
*
|
|
4
|
+
* Captures run in parallel (up to MAX_CONCURRENT) for speed, but REVEALS are
|
|
5
|
+
* staggered on a fixed timeline — widget 0 reveals at 0ms, widget 1 at
|
|
6
|
+
* REVEAL_INTERVAL ms, widget 2 at 2×REVEAL_INTERVAL ms, etc., all relative to
|
|
7
|
+
* batch start. This creates a clean, predictable wave sweep regardless of how
|
|
8
|
+
* fast each capture completes.
|
|
9
|
+
*
|
|
10
|
+
* After a batch completes, any widgets that failed are re-enqueued for a
|
|
11
|
+
* single retry pass.
|
|
12
|
+
*
|
|
13
|
+
* Sorted spatially (top-to-bottom, left-to-right) before assigning reveal slots.
|
|
14
|
+
* Supports cancellation by widget ID.
|
|
15
|
+
*/
|
|
16
|
+
const queue = []
|
|
17
|
+
let running = 0
|
|
18
|
+
let drainScheduled = false
|
|
19
|
+
let batchTotal = 0
|
|
20
|
+
let batchDone = 0
|
|
21
|
+
const batchFailed = []
|
|
22
|
+
|
|
23
|
+
const MAX_CONCURRENT = 4
|
|
24
|
+
export const REVEAL_INTERVAL = 200
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Enqueue a snapshot refresh task for a widget.
|
|
28
|
+
* @param {string} widgetId — unique widget identifier (for cancellation)
|
|
29
|
+
* @param {(meta: { revealOrder: number, batchStart: number }) => Promise<boolean>} fn
|
|
30
|
+
* Must resolve to `true` on success, `false` on failure.
|
|
31
|
+
* @param {{ x: number, y: number }} [pos] — spatial position for wave ordering
|
|
32
|
+
*/
|
|
33
|
+
export function enqueueRefresh(widgetId, fn, pos) {
|
|
34
|
+
const existing = queue.findIndex(item => item.widgetId === widgetId)
|
|
35
|
+
if (existing !== -1) queue.splice(existing, 1)
|
|
36
|
+
|
|
37
|
+
queue.push({ widgetId, fn, x: pos?.x ?? 0, y: pos?.y ?? 0 })
|
|
38
|
+
scheduleDrain()
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Cancel a pending refresh for a widget (e.g. user activated it manually).
|
|
43
|
+
*/
|
|
44
|
+
export function cancelRefresh(widgetId) {
|
|
45
|
+
const idx = queue.findIndex(item => item.widgetId === widgetId)
|
|
46
|
+
if (idx !== -1) queue.splice(idx, 1)
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function scheduleDrain() {
|
|
50
|
+
if (drainScheduled) return
|
|
51
|
+
drainScheduled = true
|
|
52
|
+
// Batch all enqueueRefresh calls from the same React commit, then sort
|
|
53
|
+
// spatially and assign reveal slots before starting captures.
|
|
54
|
+
setTimeout(() => {
|
|
55
|
+
drainScheduled = false
|
|
56
|
+
queue.sort((a, b) => a.y - b.y || a.x - b.x)
|
|
57
|
+
const batchStart = Date.now()
|
|
58
|
+
batchTotal = queue.length
|
|
59
|
+
batchDone = 0
|
|
60
|
+
batchFailed.length = 0
|
|
61
|
+
queue.forEach((item, i) => {
|
|
62
|
+
item.revealOrder = i
|
|
63
|
+
item.batchStart = batchStart
|
|
64
|
+
item.isRetry = item.isRetry || false
|
|
65
|
+
})
|
|
66
|
+
drain()
|
|
67
|
+
}, 0)
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function onTaskDone(success, item) {
|
|
71
|
+
batchDone++
|
|
72
|
+
if (!success && !item.isRetry) {
|
|
73
|
+
batchFailed.push(item)
|
|
74
|
+
}
|
|
75
|
+
// When batch is complete, re-enqueue failures for one retry
|
|
76
|
+
if (batchDone >= batchTotal && batchFailed.length > 0) {
|
|
77
|
+
const retries = batchFailed.splice(0)
|
|
78
|
+
for (const failed of retries) {
|
|
79
|
+
failed.isRetry = true
|
|
80
|
+
queue.push(failed)
|
|
81
|
+
}
|
|
82
|
+
batchTotal = queue.length
|
|
83
|
+
batchDone = 0
|
|
84
|
+
const batchStart = Date.now()
|
|
85
|
+
queue.forEach((item, i) => {
|
|
86
|
+
item.revealOrder = i
|
|
87
|
+
item.batchStart = batchStart
|
|
88
|
+
})
|
|
89
|
+
}
|
|
90
|
+
drain()
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function drain() {
|
|
94
|
+
if (running >= MAX_CONCURRENT || queue.length === 0) return
|
|
95
|
+
|
|
96
|
+
running++
|
|
97
|
+
const item = queue.shift()
|
|
98
|
+
const { fn, revealOrder, batchStart } = item
|
|
99
|
+
Promise.resolve()
|
|
100
|
+
.then(() => fn({ revealOrder, batchStart }))
|
|
101
|
+
.then((success) => { running--; onTaskDone(success !== false, item) })
|
|
102
|
+
.catch(() => { running--; onTaskDone(false, item) })
|
|
103
|
+
|
|
104
|
+
// Start next capture immediately (no stagger on capture start — only reveals are staggered)
|
|
105
|
+
if (queue.length > 0 && running < MAX_CONCURRENT) {
|
|
106
|
+
drain()
|
|
107
|
+
}
|
|
108
|
+
}
|