@brett_lamy/docstream 1.6.1 → 1.8.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 +31 -4
- package/dist/demo/DemoViewer.d.ts +8 -0
- package/dist/demo/DemoViewer.d.ts.map +1 -1
- package/dist/demo/DemoViewer.js +74 -1
- package/dist/demo/DemoViewer.js.map +1 -1
- package/dist/demo/PathBreadcrumb.d.ts +54 -0
- package/dist/demo/PathBreadcrumb.d.ts.map +1 -0
- package/dist/demo/PathBreadcrumb.js +245 -0
- package/dist/demo/PathBreadcrumb.js.map +1 -0
- 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/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 +1 -1
- package/dist/styles.css +791 -79
- package/package.json +7 -1
- package/src/demo/DemoViewer.tsx +94 -2
- package/src/demo/PathBreadcrumb.tsx +367 -0
- package/src/index.ts +2 -1
- package/src/replay/ReplayPlayer.tsx +430 -0
- package/src/replay/ReplayPreview.tsx +111 -85
- 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 +1 -1
- package/src/styles.css +791 -79
- 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
|
@@ -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
|
+
}
|
|
@@ -1,7 +1,12 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { useEffect, useState, type CSSProperties, type ReactNode } from 'react';
|
|
2
|
+
import { BrowserBar, ReplayPlayer } from './ReplayPlayer';
|
|
3
|
+
import type { ReplayPlayerOptions, ReplayPreviewHandle } from './ReplayPlayer';
|
|
2
4
|
import type { ReplayEvent } from './types';
|
|
3
5
|
import { normalizeReplayEmbedUrl } from './url';
|
|
4
6
|
|
|
7
|
+
export { formatReplayTime } from './ReplayPlayer';
|
|
8
|
+
export type { ReplayChrome, ReplayPlayerOptions, ReplayPreviewHandle } from './ReplayPlayer';
|
|
9
|
+
|
|
5
10
|
export interface ReplayPageSource {
|
|
6
11
|
kind: 'url';
|
|
7
12
|
url: string;
|
|
@@ -26,21 +31,25 @@ export type ReplaySource =
|
|
|
26
31
|
| ReplayEventsSource
|
|
27
32
|
| ReplayEventsUrlSource;
|
|
28
33
|
|
|
29
|
-
|
|
30
|
-
/**
|
|
31
|
-
source: ReplaySource;
|
|
32
|
-
/** Avoid mounting the rrweb player until the editor block is visible. */
|
|
34
|
+
interface ReplayPreviewCommon extends ReplayPlayerOptions {
|
|
35
|
+
/** Keep the player unmounted (a placeholder frame) until true, e.g. until an editor block is visible. */
|
|
33
36
|
active?: boolean;
|
|
34
|
-
/** Public-page embeds default to lazy loading; native rrweb playback defaults to paused. */
|
|
35
|
-
autoplay?: boolean;
|
|
36
|
-
loop?: boolean;
|
|
37
|
-
className?: string;
|
|
38
|
-
title?: string;
|
|
39
|
-
/** Playback speed options mirror the Loop QA player. */
|
|
40
|
-
speeds?: number[];
|
|
41
37
|
}
|
|
42
38
|
|
|
43
|
-
|
|
39
|
+
/** Play an rrweb recording you already hold. */
|
|
40
|
+
export interface ReplayPreviewEventsProps extends ReplayPreviewCommon {
|
|
41
|
+
events: ReplayEvent[];
|
|
42
|
+
source?: undefined;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Embed a public Loop QA URL, a public event endpoint, or an in-memory rrweb event list. */
|
|
46
|
+
export interface ReplayPreviewSourceProps extends ReplayPreviewCommon {
|
|
47
|
+
source: ReplaySource;
|
|
48
|
+
events?: undefined;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Pass `events` (the player API) or `source` (the embed API): both render the same player. */
|
|
52
|
+
export type ReplayPreviewProps = ReplayPreviewEventsProps | ReplayPreviewSourceProps;
|
|
44
53
|
|
|
45
54
|
function sourcePage(source: ReplaySource): ReplayPageSource | null {
|
|
46
55
|
if (typeof source === 'string') return { kind: 'url', url: source };
|
|
@@ -59,50 +68,64 @@ function sourceEventsUrl(source: ReplaySource): ReplayEventsUrlSource | null {
|
|
|
59
68
|
return source.kind === 'events-url' ? source : null;
|
|
60
69
|
}
|
|
61
70
|
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
+
/** The frame around a non-player body (an iframe, a loading or error note): same root, same chrome hooks. */
|
|
72
|
+
function Frame({
|
|
73
|
+
kind, chrome, className, title, href, trafficLights = true, ar, style, children,
|
|
74
|
+
}: {
|
|
75
|
+
kind: string;
|
|
76
|
+
chrome: NonNullable<ReplayPlayerOptions['chrome']>;
|
|
77
|
+
className?: string;
|
|
78
|
+
title?: string;
|
|
79
|
+
href?: string;
|
|
80
|
+
trafficLights?: boolean;
|
|
81
|
+
ar?: string;
|
|
82
|
+
style?: CSSProperties;
|
|
83
|
+
children: ReactNode;
|
|
84
|
+
}) {
|
|
71
85
|
return (
|
|
72
|
-
<
|
|
73
|
-
className=
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
86
|
+
<div
|
|
87
|
+
className={className ? `replay-preview ${className}` : 'replay-preview'}
|
|
88
|
+
data-slot="replay-preview"
|
|
89
|
+
data-replay-preview={kind}
|
|
90
|
+
data-chrome={chrome}
|
|
91
|
+
data-fit="width"
|
|
92
|
+
style={style}
|
|
93
|
+
{...(title ? { role: 'group', 'aria-label': title } : null)}
|
|
94
|
+
>
|
|
95
|
+
{chrome === 'browser' ? <BrowserBar href={href ?? ''} trafficLights={trafficLights} loading={false} /> : null}
|
|
96
|
+
<div data-slot="replay-preview-stage" style={ar ? ({ '--replay-ar': ar } as CSSProperties) : undefined}>
|
|
97
|
+
{children}
|
|
98
|
+
</div>
|
|
99
|
+
</div>
|
|
81
100
|
);
|
|
82
101
|
}
|
|
83
102
|
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
103
|
+
function ReplayFrame({
|
|
104
|
+
source, title, chrome = 'minimal', className, style, trafficLights,
|
|
105
|
+
}: { source: ReplayPageSource } & Pick<ReplayPlayerOptions, 'title' | 'chrome' | 'className' | 'style' | 'trafficLights'>) {
|
|
106
|
+
const url = normalizeReplayEmbedUrl(source.url);
|
|
88
107
|
return (
|
|
89
|
-
<
|
|
90
|
-
|
|
91
|
-
|
|
108
|
+
<Frame kind="iframe" chrome={chrome} className={className} style={style} href={url ?? source.url} trafficLights={trafficLights} ar="16 / 9">
|
|
109
|
+
{url ? (
|
|
110
|
+
<iframe
|
|
111
|
+
className="replay-preview__frame"
|
|
112
|
+
src={url}
|
|
113
|
+
title={title ?? source.title ?? 'Replay preview'}
|
|
114
|
+
loading="lazy"
|
|
115
|
+
allow="autoplay; fullscreen"
|
|
116
|
+
allowFullScreen
|
|
117
|
+
referrerPolicy="strict-origin-when-cross-origin"
|
|
118
|
+
/>
|
|
119
|
+
) : (
|
|
120
|
+
<a className="replay-preview__invalid" href={source.url} target="_blank" rel="noreferrer">
|
|
121
|
+
Open replay
|
|
122
|
+
</a>
|
|
123
|
+
)}
|
|
124
|
+
</Frame>
|
|
92
125
|
);
|
|
93
126
|
}
|
|
94
127
|
|
|
95
|
-
function RemoteReplay({
|
|
96
|
-
source,
|
|
97
|
-
autoplay,
|
|
98
|
-
loop,
|
|
99
|
-
speeds,
|
|
100
|
-
}: {
|
|
101
|
-
source: ReplayEventsUrlSource;
|
|
102
|
-
autoplay?: boolean;
|
|
103
|
-
loop?: boolean;
|
|
104
|
-
speeds?: number[];
|
|
105
|
-
}) {
|
|
128
|
+
function RemoteReplay({ source, options }: { source: ReplayEventsUrlSource; options: ReplayPlayerOptions }) {
|
|
106
129
|
const [events, setEvents] = useState<ReplayEvent[] | null>(null);
|
|
107
130
|
const [error, setError] = useState<string | null>(null);
|
|
108
131
|
|
|
@@ -130,48 +153,51 @@ function RemoteReplay({
|
|
|
130
153
|
return () => controller.abort();
|
|
131
154
|
}, [source.url, source.fetchInit]);
|
|
132
155
|
|
|
133
|
-
if (
|
|
134
|
-
|
|
135
|
-
|
|
156
|
+
if (events) return <ReplayPlayer {...options} events={events} />;
|
|
157
|
+
return (
|
|
158
|
+
<Frame kind="rrweb" chrome={options.chrome ?? 'browser'} className={options.className} style={options.style} href={options.url} trafficLights={options.trafficLights} ar="16 / 9">
|
|
159
|
+
{error ? (
|
|
160
|
+
<div className="replay-preview__error" role="alert">{error}</div>
|
|
161
|
+
) : (
|
|
162
|
+
<div className="replay-preview__empty">Loading replay…</div>
|
|
163
|
+
)}
|
|
164
|
+
</Frame>
|
|
165
|
+
);
|
|
136
166
|
}
|
|
137
167
|
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
const page = sourcePage(source);
|
|
150
|
-
const events = sourceEvents(source);
|
|
151
|
-
const eventsUrl = sourceEventsUrl(source);
|
|
152
|
-
const rootClass = `replay-preview ${className ?? ''}`.trim();
|
|
153
|
-
|
|
154
|
-
if (page) {
|
|
155
|
-
return (
|
|
156
|
-
<div className={rootClass} data-replay-preview="iframe">
|
|
157
|
-
<ReplayFrame source={page} title={title} />
|
|
158
|
-
</div>
|
|
159
|
-
);
|
|
160
|
-
}
|
|
161
|
-
if (events) {
|
|
168
|
+
/**
|
|
169
|
+
* A session replay in a browser frame. Pass `events` (an rrweb recording) to play it with the full player:
|
|
170
|
+
* URL bar, poster, scrubber with markers, pointer overlay, keyboard map, fullscreen. Or pass `source` (a
|
|
171
|
+
* public Loop QA URL, an events URL, or an event list), which renders the same player, or an iframe for
|
|
172
|
+
* page URLs. rrweb loads on first render of an event player, never for iframes.
|
|
173
|
+
*/
|
|
174
|
+
export function ReplayPreview(props: ReplayPreviewProps) {
|
|
175
|
+
const { active = true, source, events, ...options } = props;
|
|
176
|
+
const { chrome, className, style } = options;
|
|
177
|
+
|
|
178
|
+
if (!active) {
|
|
162
179
|
return (
|
|
163
|
-
<div
|
|
164
|
-
|
|
165
|
-
|
|
180
|
+
<div
|
|
181
|
+
className={className ? `replay-preview ${className}` : 'replay-preview'}
|
|
182
|
+
data-slot="replay-preview"
|
|
183
|
+
data-replay-preview="paused"
|
|
184
|
+
data-chrome={chrome ?? 'minimal'}
|
|
185
|
+
style={style}
|
|
186
|
+
/>
|
|
166
187
|
);
|
|
167
188
|
}
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
189
|
+
|
|
190
|
+
if (events) return <ReplayPlayer {...options} events={events} />;
|
|
191
|
+
if (source === undefined) return null;
|
|
192
|
+
|
|
193
|
+
const page = sourcePage(source);
|
|
194
|
+
if (page) {
|
|
195
|
+
return <ReplayFrame source={page} title={options.title} chrome={chrome} className={className} style={style} trafficLights={options.trafficLights} />;
|
|
174
196
|
}
|
|
197
|
+
const list = sourceEvents(source);
|
|
198
|
+
if (list) return <ReplayPlayer {...options} events={list.events} />;
|
|
199
|
+
const remote = sourceEventsUrl(source);
|
|
200
|
+
if (remote) return <RemoteReplay source={remote} options={options} />;
|
|
175
201
|
return null;
|
|
176
202
|
}
|
|
177
203
|
|