@brett_lamy/docstream 1.6.0 → 1.7.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/README.md +34 -7
- package/dist/index.d.ts +2 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/replay/ReplayPlayer.d.ts +64 -0
- package/dist/replay/ReplayPlayer.d.ts.map +1 -0
- package/dist/replay/ReplayPlayer.js +211 -0
- package/dist/replay/ReplayPlayer.js.map +1 -0
- package/dist/replay/ReplayPreview.d.ts +24 -12
- package/dist/replay/ReplayPreview.d.ts.map +1 -1
- package/dist/replay/ReplayPreview.js +37 -32
- package/dist/replay/ReplayPreview.js.map +1 -1
- package/dist/replay/ReplayStage.d.ts +1 -1
- package/dist/replay/ReplayStage.js +1 -1
- package/dist/replay/ReplayStageImpl.js +1 -1
- package/dist/replay/ReplayStageImpl.js.map +1 -1
- package/dist/replay/demo/index.d.ts +3 -0
- package/dist/replay/demo/index.d.ts.map +1 -0
- package/dist/replay/demo/index.js +66 -0
- package/dist/replay/demo/index.js.map +1 -0
- package/dist/replay/index.d.ts +2 -2
- package/dist/replay/index.d.ts.map +1 -1
- package/dist/replay/index.js +1 -1
- package/dist/replay/index.js.map +1 -1
- package/dist/replay/types.js +3 -3
- package/dist/replay/types.js.map +1 -1
- package/dist/styles.css +565 -75
- package/package.json +8 -6
- package/src/index.ts +2 -1
- package/src/replay/ReplayPlayer.tsx +430 -0
- package/src/replay/ReplayPreview.tsx +111 -85
- package/src/replay/ReplayStage.tsx +1 -1
- package/src/replay/ReplayStageImpl.tsx +1 -1
- package/src/replay/demo/index.ts +67 -0
- package/src/replay/index.ts +6 -1
- package/src/replay/types.ts +3 -3
- package/src/styles.css +565 -75
- package/dist/replay/NativeReplay.d.ts +0 -9
- package/dist/replay/NativeReplay.d.ts.map +0 -1
- package/dist/replay/NativeReplay.js +0 -175
- package/dist/replay/NativeReplay.js.map +0 -1
- package/src/replay/NativeReplay.tsx +0 -240
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@brett_lamy/docstream",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.7.0",
|
|
4
4
|
"description": "GitBook-aware readonly markdown and AI stream renderer.",
|
|
5
5
|
"publishConfig": {
|
|
6
6
|
"access": "public"
|
|
@@ -68,6 +68,12 @@
|
|
|
68
68
|
"import": "./dist/replay/index.js",
|
|
69
69
|
"default": "./dist/replay/index.js"
|
|
70
70
|
},
|
|
71
|
+
"./replay/demo": {
|
|
72
|
+
"source": "./src/replay/demo/index.ts",
|
|
73
|
+
"types": "./dist/replay/demo/index.d.ts",
|
|
74
|
+
"import": "./dist/replay/demo/index.js",
|
|
75
|
+
"default": "./dist/replay/demo/index.js"
|
|
76
|
+
},
|
|
71
77
|
"./video": {
|
|
72
78
|
"source": "./src/video/index.ts",
|
|
73
79
|
"types": "./dist/video/index.d.ts",
|
|
@@ -113,6 +119,7 @@
|
|
|
113
119
|
"lucide-react": "^1.17.0",
|
|
114
120
|
"mermaid": "^11.15.0",
|
|
115
121
|
"react-aria": "^3.52.1",
|
|
122
|
+
"rrweb": "2.0.0-alpha.20",
|
|
116
123
|
"yaml": "^2.9.0"
|
|
117
124
|
},
|
|
118
125
|
"peerDependencies": {
|
|
@@ -120,7 +127,6 @@
|
|
|
120
127
|
"@brett_lamy/viz-engine": ">=0.2.0",
|
|
121
128
|
"react": ">=18",
|
|
122
129
|
"react-dom": ">=18",
|
|
123
|
-
"rrweb": "2.0.0-alpha.20",
|
|
124
130
|
"vite": ">=5"
|
|
125
131
|
},
|
|
126
132
|
"peerDependenciesMeta": {
|
|
@@ -132,9 +138,6 @@
|
|
|
132
138
|
},
|
|
133
139
|
"vite": {
|
|
134
140
|
"optional": true
|
|
135
|
-
},
|
|
136
|
-
"rrweb": {
|
|
137
|
-
"optional": true
|
|
138
141
|
}
|
|
139
142
|
},
|
|
140
143
|
"repository": {
|
|
@@ -148,7 +151,6 @@
|
|
|
148
151
|
"@types/react": "^18.3.3",
|
|
149
152
|
"@types/react-dom": "^18.3.7",
|
|
150
153
|
"happy-dom": "^20.14.5",
|
|
151
|
-
"rrweb": "2.0.0-alpha.20",
|
|
152
154
|
"typescript": "^5.5.4",
|
|
153
155
|
"vite": "^7.0.0",
|
|
154
156
|
"vitest": "^3.2.4"
|
package/src/index.ts
CHANGED
|
@@ -68,11 +68,12 @@ export type {
|
|
|
68
68
|
} from "./demo"
|
|
69
69
|
export { CitationSources, DocstreamRefContext, InlineReference } from "./docs/reference"
|
|
70
70
|
export type { CitationPreview, CitationPreviewResolver, DocstreamRefContextValue } from "./docs/reference"
|
|
71
|
-
export { ReplayEmbed, ReplayPreview } from "./replay/ReplayPreview"
|
|
71
|
+
export { ReplayEmbed, ReplayPreview, formatReplayTime } from "./replay/ReplayPreview"
|
|
72
72
|
export type {
|
|
73
73
|
ReplayEventsSource,
|
|
74
74
|
ReplayEventsUrlSource,
|
|
75
75
|
ReplayPageSource,
|
|
76
|
+
ReplayPreviewHandle,
|
|
76
77
|
ReplayPreviewProps,
|
|
77
78
|
ReplaySource,
|
|
78
79
|
} from "./replay/ReplayPreview"
|
|
@@ -0,0 +1,430 @@
|
|
|
1
|
+
/* The player behind `ReplayPreview`: an rrweb session replay in a browser frame. The headless `ReplayStage`
|
|
2
|
+
plays the recording (rrweb loads lazily, on first render); this adds the chrome around it: a URL bar that
|
|
3
|
+
follows the recording's navigations, play/pause, a scrubber with the recording's moments on a rail above
|
|
4
|
+
it, a speed control, fullscreen, and a drawn cursor whose clicks ripple. Until the player is ready a
|
|
5
|
+
poster (a page-shaped skeleton by default) holds the frame's exact size, so nothing jumps.
|
|
6
|
+
|
|
7
|
+
Plain React + plain CSS (`@layer docstream.replay` in styles.css, `[data-slot]` hooks). Keys (focus inside
|
|
8
|
+
the player): Space / K play and pause, ← → seek 5 s, J / L 10 s, < > speed, F fullscreen, Home / End jump
|
|
9
|
+
to the ends. Everything drawn over the page is a function of the playhead, so scrubbing backwards replays
|
|
10
|
+
the ripples and the URL too. */
|
|
11
|
+
import {
|
|
12
|
+
memo, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState,
|
|
13
|
+
type CSSProperties, type KeyboardEvent as ReactKeyboardEvent, type ReactNode, type Ref,
|
|
14
|
+
} from 'react'
|
|
15
|
+
import { useReducedMotion } from 'framer-motion'
|
|
16
|
+
import { Lock, Maximize, Minimize, Pause, Play, RotateCcw } from 'lucide-react'
|
|
17
|
+
import { ReplayStage } from './ReplayStage'
|
|
18
|
+
import { getReplayMarkers, getReplayMeta, getReplayPointerTrack, replayPointerAt } from './markers'
|
|
19
|
+
import type { ReplayMarker, ReplayMarkerKind, ReplayPointerTrack } from './markers'
|
|
20
|
+
import type { ReplayStageHandle } from './stage-types'
|
|
21
|
+
import type { ReplayEvent } from './types'
|
|
22
|
+
|
|
23
|
+
const TRAFFIC_LIGHTS = ['#FF5F57', '#FEBC2E', '#28C840'] as const
|
|
24
|
+
export const DEFAULT_PLAYER_SPEEDS = [1, 2, 4, 0.5]
|
|
25
|
+
const RIPPLE_MS = 650
|
|
26
|
+
|
|
27
|
+
export type ReplayChrome = 'browser' | 'minimal' | 'none'
|
|
28
|
+
|
|
29
|
+
export interface ReplayPreviewHandle {
|
|
30
|
+
play(offset?: number): void
|
|
31
|
+
pause(offset?: number): void
|
|
32
|
+
/** Jump to `ms` from the start, keeping the play state. */
|
|
33
|
+
seek(ms: number): void
|
|
34
|
+
toggle(): void
|
|
35
|
+
getCurrentTime(): number
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Options shared by every `ReplayPreview` (an `events` player or a `source` embed). */
|
|
39
|
+
export interface ReplayPlayerOptions {
|
|
40
|
+
/** Address shown in the URL bar (default: the recording's own URL, following its navigations). */
|
|
41
|
+
url?: string
|
|
42
|
+
/** Accessible name of the player (default "Session replay"). */
|
|
43
|
+
title?: string
|
|
44
|
+
/** `browser` (default): window buttons, URL bar, controls. `minimal`: the page and its controls in a rounded
|
|
45
|
+
* card. `none`: no frame, fill the parent (a hero, a lightbox). */
|
|
46
|
+
chrome?: ReplayChrome
|
|
47
|
+
/** macOS window buttons in the browser chrome (default true). */
|
|
48
|
+
trafficLights?: boolean
|
|
49
|
+
/** Play/pause, scrubber, speed and fullscreen (default true). */
|
|
50
|
+
controls?: boolean
|
|
51
|
+
autoplay?: boolean
|
|
52
|
+
loop?: boolean
|
|
53
|
+
/** Where the player starts, in ms from the start. */
|
|
54
|
+
initialTime?: number
|
|
55
|
+
/** Speeds the speed button cycles through; the first is the initial speed (default 1, 2, 4, 0.5). */
|
|
56
|
+
speeds?: number[]
|
|
57
|
+
/** Markers on the scrubber rail: `true` (default) reads them from the recording, `false` hides the rail,
|
|
58
|
+
* or pass your own. */
|
|
59
|
+
markers?: boolean | ReplayMarker[]
|
|
60
|
+
/** Which recorded marker kinds to show (default all). */
|
|
61
|
+
markerKinds?: ReplayMarkerKind[]
|
|
62
|
+
/** Draw the recorded pointer and click ripples (default true). */
|
|
63
|
+
cursor?: boolean
|
|
64
|
+
/** `width` (default): the page scales to the frame's width and sets its height. `contain`: fill the frame's
|
|
65
|
+
* height (give it one) and letterbox the page. */
|
|
66
|
+
fit?: 'width' | 'contain'
|
|
67
|
+
/** Shown until the player is ready (default: a page-shaped skeleton). */
|
|
68
|
+
poster?: ReactNode
|
|
69
|
+
/** Mount the player only once the frame nears the viewport (default true). */
|
|
70
|
+
lazy?: boolean
|
|
71
|
+
/** Imperative control (play, pause, seek). */
|
|
72
|
+
playerRef?: Ref<ReplayPreviewHandle | null>
|
|
73
|
+
onTimeUpdate?: (ms: number) => void
|
|
74
|
+
onPlayingChange?: (playing: boolean) => void
|
|
75
|
+
onMarkerPress?: (marker: ReplayMarker) => void
|
|
76
|
+
className?: string
|
|
77
|
+
style?: CSSProperties
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function formatReplayTime(ms: number) {
|
|
81
|
+
const s = Math.max(0, Math.floor(ms / 1000))
|
|
82
|
+
return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}`
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const MARKER_NOUN: Record<ReplayMarkerKind, string> = {
|
|
86
|
+
click: 'Click', error: 'Error', network: 'Request failed', navigation: 'Navigation', custom: 'Checkpoint',
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function ReplayPlayer({
|
|
90
|
+
events, url, title = 'Session replay', chrome = 'browser', trafficLights = true, controls = true,
|
|
91
|
+
autoplay = false, loop = false, initialTime = 0, speeds = DEFAULT_PLAYER_SPEEDS, markers = true, markerKinds,
|
|
92
|
+
cursor = true, fit = 'width', poster, lazy = true, playerRef, onTimeUpdate, onPlayingChange, onMarkerPress,
|
|
93
|
+
className, style,
|
|
94
|
+
}: ReplayPlayerOptions & { events: ReplayEvent[] }) {
|
|
95
|
+
const rootRef = useRef<HTMLDivElement>(null)
|
|
96
|
+
const stage = useRef<ReplayStageHandle>(null)
|
|
97
|
+
const reduced = useReducedMotion()
|
|
98
|
+
const meta = useMemo(() => getReplayMeta(events), [events])
|
|
99
|
+
const track = useMemo(() => getReplayPointerTrack(events), [events])
|
|
100
|
+
const recorded = useMemo(() => getReplayMarkers(events), [events])
|
|
101
|
+
const shown = useMemo(() => {
|
|
102
|
+
const list = Array.isArray(markers) ? markers : markers ? recorded : []
|
|
103
|
+
return markerKinds ? list.filter((m) => markerKinds.includes(m.kind)) : list
|
|
104
|
+
}, [markers, recorded, markerKinds])
|
|
105
|
+
|
|
106
|
+
const [visible, setVisible] = useState(!lazy)
|
|
107
|
+
const [ready, setReady] = useState(false)
|
|
108
|
+
const [posterGone, setPosterGone] = useState(false)
|
|
109
|
+
const [time, setTime] = useState(initialTime)
|
|
110
|
+
const [playing, setPlaying] = useState(false)
|
|
111
|
+
const [finished, setFinished] = useState(false)
|
|
112
|
+
const [speedIndex, setSpeedIndex] = useState(0)
|
|
113
|
+
const [fullscreen, setFullscreen] = useState(false)
|
|
114
|
+
const duration = meta.duration
|
|
115
|
+
const speedList = speeds.length ? speeds : DEFAULT_PLAYER_SPEEDS
|
|
116
|
+
const speed = speedList[speedIndex % speedList.length] ?? 1
|
|
117
|
+
|
|
118
|
+
// Lazy mount: start the player (and fetch rrweb) once the frame is near the viewport.
|
|
119
|
+
useEffect(() => {
|
|
120
|
+
if (visible) return
|
|
121
|
+
const el = rootRef.current
|
|
122
|
+
if (!el || typeof IntersectionObserver === 'undefined') return setVisible(true)
|
|
123
|
+
const io = new IntersectionObserver((entries) => entries.some((e) => e.isIntersecting) && setVisible(true), { rootMargin: '240px' })
|
|
124
|
+
io.observe(el)
|
|
125
|
+
return () => io.disconnect()
|
|
126
|
+
}, [visible])
|
|
127
|
+
|
|
128
|
+
useEffect(() => {
|
|
129
|
+
const on = () => setFullscreen(document.fullscreenElement === rootRef.current)
|
|
130
|
+
document.addEventListener('fullscreenchange', on)
|
|
131
|
+
return () => document.removeEventListener('fullscreenchange', on)
|
|
132
|
+
}, [])
|
|
133
|
+
|
|
134
|
+
// The poster dissolves (CSS) once the page is ready, then leaves the tree.
|
|
135
|
+
useEffect(() => {
|
|
136
|
+
if (!ready) return setPosterGone(false)
|
|
137
|
+
const timer = setTimeout(() => setPosterGone(true), reduced ? 120 : 260)
|
|
138
|
+
return () => clearTimeout(timer)
|
|
139
|
+
}, [ready, reduced])
|
|
140
|
+
|
|
141
|
+
const handle = useMemo<ReplayPreviewHandle>(() => ({
|
|
142
|
+
play: (offset) => { setFinished(false); stage.current?.play(offset) },
|
|
143
|
+
pause: (offset) => stage.current?.pause(offset),
|
|
144
|
+
seek: (ms) => { setFinished(false); stage.current?.seek(ms) },
|
|
145
|
+
toggle: () => { setFinished(false); stage.current?.toggle() },
|
|
146
|
+
getCurrentTime: () => stage.current?.getCurrentTime() ?? 0,
|
|
147
|
+
}), [])
|
|
148
|
+
useImperativeHandle(playerRef, () => handle, [handle])
|
|
149
|
+
|
|
150
|
+
const onTime = useCallback((ms: number) => { setTime(ms); onTimeUpdate?.(ms) }, [onTimeUpdate])
|
|
151
|
+
const onPlaying = useCallback((p: boolean) => {
|
|
152
|
+
setPlaying(p)
|
|
153
|
+
if (p) setFinished(false)
|
|
154
|
+
onPlayingChange?.(p)
|
|
155
|
+
}, [onPlayingChange])
|
|
156
|
+
|
|
157
|
+
const toggle = () => handle.toggle()
|
|
158
|
+
const seekBy = (d: number) => handle.seek(Math.max(0, Math.min(duration, time + d)))
|
|
159
|
+
const cycleSpeed = (d = 1) => setSpeedIndex((i) => (i + d + speedList.length) % speedList.length)
|
|
160
|
+
const toggleFullscreen = () => {
|
|
161
|
+
const el = rootRef.current
|
|
162
|
+
if (!el) return
|
|
163
|
+
if (document.fullscreenElement) void document.exitFullscreen?.().catch(() => {})
|
|
164
|
+
else void el.requestFullscreen?.().catch(() => {})
|
|
165
|
+
}
|
|
166
|
+
const jumpTo = (m: ReplayMarker) => {
|
|
167
|
+
handle.seek(Math.max(0, m.time - 250))
|
|
168
|
+
onMarkerPress?.(m)
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const onKeyDown = (e: ReactKeyboardEvent) => {
|
|
172
|
+
const target = e.target as HTMLElement
|
|
173
|
+
if (e.metaKey || e.ctrlKey || e.altKey || target.closest('input:not([type=range]), textarea, [contenteditable=true]')) return
|
|
174
|
+
const onSlider = target.matches('input[type=range]')
|
|
175
|
+
const run = (fn: () => void) => { e.preventDefault(); fn() }
|
|
176
|
+
switch (e.key) {
|
|
177
|
+
case ' ': case 'k': case 'K': if (target.closest('button') && e.key === ' ') return; return run(toggle)
|
|
178
|
+
case 'ArrowLeft': if (onSlider) return; return run(() => seekBy(-5000))
|
|
179
|
+
case 'ArrowRight': if (onSlider) return; return run(() => seekBy(5000))
|
|
180
|
+
case 'j': case 'J': return run(() => seekBy(-10000))
|
|
181
|
+
case 'l': case 'L': return run(() => seekBy(10000))
|
|
182
|
+
case 'Home': if (onSlider) return; return run(() => handle.seek(0))
|
|
183
|
+
case 'End': if (onSlider) return; return run(() => handle.seek(duration))
|
|
184
|
+
case '>': case '.': return run(() => cycleSpeed(1))
|
|
185
|
+
case '<': case ',': return run(() => cycleSpeed(-1))
|
|
186
|
+
case 'f': case 'F': return run(toggleFullscreen)
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// The URL bar follows the recording: the latest navigation at or before the playhead.
|
|
191
|
+
const href = useMemo(() => {
|
|
192
|
+
if (url) return url
|
|
193
|
+
let current = meta.href ?? ''
|
|
194
|
+
for (const m of recorded) if (m.kind === 'navigation' && m.time <= time && m.detail) current = m.detail
|
|
195
|
+
return current
|
|
196
|
+
}, [url, meta.href, recorded, time])
|
|
197
|
+
|
|
198
|
+
const w = meta.width || 1280
|
|
199
|
+
const h = meta.height || 800
|
|
200
|
+
const contain = fit === 'contain' || fullscreen
|
|
201
|
+
|
|
202
|
+
return (
|
|
203
|
+
<div
|
|
204
|
+
ref={rootRef}
|
|
205
|
+
className={className ? `replay-preview ${className}` : 'replay-preview'}
|
|
206
|
+
data-slot="replay-preview"
|
|
207
|
+
data-replay-preview="rrweb"
|
|
208
|
+
data-chrome={chrome}
|
|
209
|
+
data-fit={contain ? 'contain' : 'width'}
|
|
210
|
+
data-state={!ready ? 'loading' : playing ? 'playing' : 'paused'}
|
|
211
|
+
data-fullscreen={fullscreen || undefined}
|
|
212
|
+
role="group"
|
|
213
|
+
aria-label={title}
|
|
214
|
+
aria-busy={!ready || undefined}
|
|
215
|
+
tabIndex={-1}
|
|
216
|
+
onKeyDown={onKeyDown}
|
|
217
|
+
// A click anywhere in the player (the page, the frame) makes its keys live, without a focus ring.
|
|
218
|
+
onPointerDown={() => {
|
|
219
|
+
const root = rootRef.current
|
|
220
|
+
if (root && !root.contains(document.activeElement)) root.focus({ preventScroll: true })
|
|
221
|
+
}}
|
|
222
|
+
style={style}
|
|
223
|
+
>
|
|
224
|
+
{chrome === 'browser' ? <BrowserBar href={href} trafficLights={trafficLights} loading={!ready && meta.playable} /> : null}
|
|
225
|
+
|
|
226
|
+
<div data-slot="replay-preview-stage" style={{ '--replay-ar': `${w} / ${h}` } as CSSProperties}>
|
|
227
|
+
{visible && meta.playable ? (
|
|
228
|
+
<ReplayStage
|
|
229
|
+
ref={stage}
|
|
230
|
+
events={events}
|
|
231
|
+
fit="contain"
|
|
232
|
+
speed={speed}
|
|
233
|
+
loop={loop}
|
|
234
|
+
autoplay={autoplay}
|
|
235
|
+
initialTime={initialTime}
|
|
236
|
+
showMouse={false}
|
|
237
|
+
className="replay-preview__page"
|
|
238
|
+
overlay={cursor ? <PointerOverlay track={track} time={time} reduced={!!reduced} /> : null}
|
|
239
|
+
onReady={() => setReady(true)}
|
|
240
|
+
onTimeUpdate={onTime}
|
|
241
|
+
onPlayingChange={onPlaying}
|
|
242
|
+
onFinish={() => setFinished(true)}
|
|
243
|
+
/>
|
|
244
|
+
) : null}
|
|
245
|
+
|
|
246
|
+
{!posterGone ? (
|
|
247
|
+
<div data-slot="replay-preview-poster" data-leaving={ready || undefined}>
|
|
248
|
+
{poster ?? <PagePoster empty={!meta.playable} />}
|
|
249
|
+
</div>
|
|
250
|
+
) : null}
|
|
251
|
+
|
|
252
|
+
{controls ? (
|
|
253
|
+
<div data-slot="replay-preview-center" data-show={(ready && !playing) || undefined}>
|
|
254
|
+
<button
|
|
255
|
+
type="button"
|
|
256
|
+
tabIndex={-1}
|
|
257
|
+
aria-label={finished ? 'Replay from the start' : 'Play'}
|
|
258
|
+
// The overlay leaves as playback starts; keep focus (and the keys) in the player.
|
|
259
|
+
onClick={() => { rootRef.current?.focus({ preventScroll: true }); toggle() }}
|
|
260
|
+
>
|
|
261
|
+
{finished ? <RotateCcw size={22} strokeWidth={2.4} /> : <Play size={22} strokeWidth={2.4} fill="currentColor" />}
|
|
262
|
+
</button>
|
|
263
|
+
</div>
|
|
264
|
+
) : null}
|
|
265
|
+
</div>
|
|
266
|
+
|
|
267
|
+
{controls ? (
|
|
268
|
+
<div data-slot="replay-preview-controls">
|
|
269
|
+
<button type="button" data-slot="replay-preview-play" aria-label={playing ? 'Pause' : 'Play'} onClick={toggle} disabled={!ready}>
|
|
270
|
+
{playing ? <Pause size={15} fill="currentColor" /> : <Play size={15} fill="currentColor" />}
|
|
271
|
+
</button>
|
|
272
|
+
<span data-slot="replay-preview-time">
|
|
273
|
+
<span>{formatReplayTime(time)}</span> / {formatReplayTime(duration)}
|
|
274
|
+
</span>
|
|
275
|
+
<Scrubber
|
|
276
|
+
time={time}
|
|
277
|
+
duration={duration}
|
|
278
|
+
markers={shown}
|
|
279
|
+
disabled={!ready}
|
|
280
|
+
onSeek={(ms) => handle.seek(ms)}
|
|
281
|
+
onMarker={jumpTo}
|
|
282
|
+
/>
|
|
283
|
+
<button type="button" data-slot="replay-preview-speed" aria-label={`Playback speed ${speed}×`} onClick={() => cycleSpeed(1)}>
|
|
284
|
+
{`${speed}×`}
|
|
285
|
+
</button>
|
|
286
|
+
<button type="button" data-slot="replay-preview-fullscreen" aria-label={fullscreen ? 'Exit full screen' : 'Full screen'} onClick={toggleFullscreen}>
|
|
287
|
+
{fullscreen ? <Minimize size={15} /> : <Maximize size={15} />}
|
|
288
|
+
</button>
|
|
289
|
+
</div>
|
|
290
|
+
) : null}
|
|
291
|
+
</div>
|
|
292
|
+
)
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/* ── parts ───────────────────────────────────────────────────────────────────────── */
|
|
296
|
+
|
|
297
|
+
export function BrowserBar({ href, trafficLights, loading }: { href: string; trafficLights: boolean; loading: boolean }) {
|
|
298
|
+
const shown = href.replace(/^https?:\/\//, '')
|
|
299
|
+
const slash = shown.indexOf('/')
|
|
300
|
+
const host = slash < 0 ? shown : shown.slice(0, slash)
|
|
301
|
+
const path = slash < 0 ? '' : shown.slice(slash)
|
|
302
|
+
return (
|
|
303
|
+
<div data-slot="replay-preview-bar">
|
|
304
|
+
{trafficLights ? (
|
|
305
|
+
<span aria-hidden="true" data-slot="replay-preview-lights">
|
|
306
|
+
{TRAFFIC_LIGHTS.map((c) => <span key={c} style={{ '--light': c } as CSSProperties} />)}
|
|
307
|
+
</span>
|
|
308
|
+
) : null}
|
|
309
|
+
<div data-slot="replay-preview-address">
|
|
310
|
+
<span data-slot="replay-preview-lock" aria-hidden="true">
|
|
311
|
+
{loading ? <span data-slot="replay-preview-spinner" /> : <Lock size={10} strokeWidth={2.5} />}
|
|
312
|
+
</span>
|
|
313
|
+
<span data-slot="replay-preview-url" title={href}>
|
|
314
|
+
<span>{host}</span>
|
|
315
|
+
<span>{path}</span>
|
|
316
|
+
</span>
|
|
317
|
+
</div>
|
|
318
|
+
{trafficLights ? <span aria-hidden="true" data-slot="replay-preview-lights-spacer" /> : null}
|
|
319
|
+
</div>
|
|
320
|
+
)
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/** The recorded pointer and its click ripples, in recorded-viewport coordinates. */
|
|
324
|
+
const PointerOverlay = memo(function PointerOverlay({ track, time, reduced }: { track: ReplayPointerTrack; time: number; reduced: boolean }) {
|
|
325
|
+
const at = replayPointerAt(track, time)
|
|
326
|
+
const ripples = track.clicks.filter((c) => time >= c.time && time - c.time < RIPPLE_MS)
|
|
327
|
+
return (
|
|
328
|
+
<div data-slot="replay-preview-pointer" aria-hidden="true">
|
|
329
|
+
{ripples.map((c) => {
|
|
330
|
+
const p = (time - c.time) / RIPPLE_MS
|
|
331
|
+
const ease = 1 - (1 - p) ** 3
|
|
332
|
+
return (
|
|
333
|
+
<span
|
|
334
|
+
key={c.time}
|
|
335
|
+
data-slot="replay-preview-ripple"
|
|
336
|
+
style={{
|
|
337
|
+
left: c.x, top: c.y,
|
|
338
|
+
transform: `translate(-50%, -50%) scale(calc(${reduced ? 0.8 : 0.25 + ease * 1.05} / var(--replay-scale, 1)))`,
|
|
339
|
+
opacity: 1 - p,
|
|
340
|
+
}}
|
|
341
|
+
/>
|
|
342
|
+
)
|
|
343
|
+
})}
|
|
344
|
+
{at ? (
|
|
345
|
+
<svg
|
|
346
|
+
data-slot="replay-preview-cursor"
|
|
347
|
+
viewBox="0 0 20 24" width="20" height="24"
|
|
348
|
+
style={{ left: at.x - 2, top: at.y - 1, transform: 'scale(calc(1 / var(--replay-scale, 1)))' }}
|
|
349
|
+
>
|
|
350
|
+
<path d="M2.5 1.5v18.2l4.7-4.5 3 6.8 3.1-1.4-3-6.6h6.5z" fill="#000" stroke="#fff" strokeWidth="1.4" strokeLinejoin="round" />
|
|
351
|
+
</svg>
|
|
352
|
+
) : null}
|
|
353
|
+
</div>
|
|
354
|
+
)
|
|
355
|
+
})
|
|
356
|
+
|
|
357
|
+
function Scrubber({ time, duration, markers, disabled, onSeek, onMarker }: {
|
|
358
|
+
time: number; duration: number; markers: ReplayMarker[]; disabled: boolean
|
|
359
|
+
onSeek: (ms: number) => void; onMarker: (m: ReplayMarker) => void
|
|
360
|
+
}) {
|
|
361
|
+
const max = Math.max(1, duration)
|
|
362
|
+
const value = Math.min(time, max)
|
|
363
|
+
// Dots closer than ~1.5% of the timeline would sit on top of each other: step each one 10px right of the last.
|
|
364
|
+
const nudges = useMemo(() => {
|
|
365
|
+
const out = new Map<string, number>()
|
|
366
|
+
let last: { time: number; n: number } | null = null
|
|
367
|
+
for (const m of [...markers].sort((a, b) => a.time - b.time)) {
|
|
368
|
+
if (m.kind === 'click') continue
|
|
369
|
+
const n: number = last && m.time - last.time < max * 0.015 ? last.n + 1 : 0
|
|
370
|
+
out.set(m.id, n)
|
|
371
|
+
last = { time: m.time, n }
|
|
372
|
+
}
|
|
373
|
+
return out
|
|
374
|
+
}, [markers, max])
|
|
375
|
+
return (
|
|
376
|
+
<div data-slot="replay-preview-scrubber" data-markers={markers.length ? '' : undefined}>
|
|
377
|
+
{markers.length ? (
|
|
378
|
+
<div data-slot="replay-preview-markers">
|
|
379
|
+
{markers.map((m) => (
|
|
380
|
+
<button
|
|
381
|
+
key={m.id}
|
|
382
|
+
type="button"
|
|
383
|
+
data-kind={m.kind}
|
|
384
|
+
aria-label={`${m.label} at ${formatReplayTime(m.time)}`}
|
|
385
|
+
title={`${formatReplayTime(m.time)} · ${MARKER_NOUN[m.kind]}\n${m.detail ? `${m.label} — ${m.detail}` : m.label}`}
|
|
386
|
+
style={{ left: `calc(${(m.time / max) * 100}% + ${(nudges.get(m.id) ?? 0) * 10}px)` }}
|
|
387
|
+
onClick={() => onMarker(m)}
|
|
388
|
+
disabled={disabled}
|
|
389
|
+
/>
|
|
390
|
+
))}
|
|
391
|
+
</div>
|
|
392
|
+
) : null}
|
|
393
|
+
<input
|
|
394
|
+
type="range"
|
|
395
|
+
aria-label="Playback position"
|
|
396
|
+
aria-valuetext={`${formatReplayTime(value)} of ${formatReplayTime(duration)}`}
|
|
397
|
+
min={0}
|
|
398
|
+
max={max}
|
|
399
|
+
step={10}
|
|
400
|
+
value={value}
|
|
401
|
+
disabled={disabled}
|
|
402
|
+
style={{ '--replay-progress': `${(value / max) * 100}%` } as CSSProperties}
|
|
403
|
+
onChange={(e) => onSeek(Number(e.currentTarget.value))}
|
|
404
|
+
/>
|
|
405
|
+
</div>
|
|
406
|
+
)
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
/** A page-shaped placeholder: header, hero lines and a card grid, sized to the frame. */
|
|
410
|
+
function PagePoster({ empty }: { empty: boolean }) {
|
|
411
|
+
return (
|
|
412
|
+
<div data-slot="replay-preview-skeleton">
|
|
413
|
+
<div data-part="header">
|
|
414
|
+
<i style={{ width: '12%' }} />
|
|
415
|
+
<i style={{ width: '8%' }} />
|
|
416
|
+
<i style={{ width: '8%' }} />
|
|
417
|
+
<span />
|
|
418
|
+
<i data-tone="block" style={{ width: '9%' }} />
|
|
419
|
+
</div>
|
|
420
|
+
<div data-part="hero">
|
|
421
|
+
<i style={{ width: '38%', height: 12 }} />
|
|
422
|
+
<i style={{ width: '24%', height: 8 }} />
|
|
423
|
+
</div>
|
|
424
|
+
<div data-part="grid">
|
|
425
|
+
{[0, 1, 2, 3].map((i) => <i key={i} data-tone="block" />)}
|
|
426
|
+
</div>
|
|
427
|
+
{empty ? <span data-part="empty"><span>This recording has no page snapshot to play</span></span> : null}
|
|
428
|
+
</div>
|
|
429
|
+
)
|
|
430
|
+
}
|