@brett_lamy/docstream 1.5.0 → 1.6.1

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.
Files changed (50) hide show
  1. package/README.md +36 -0
  2. package/dist/index.d.ts +1 -0
  3. package/dist/index.d.ts.map +1 -1
  4. package/dist/index.js.map +1 -1
  5. package/dist/replay/NativeReplay.d.ts +2 -2
  6. package/dist/replay/NativeReplay.d.ts.map +1 -1
  7. package/dist/replay/NativeReplay.js +3 -1
  8. package/dist/replay/NativeReplay.js.map +1 -1
  9. package/dist/replay/ReplayPreview.d.ts +3 -3
  10. package/dist/replay/ReplayPreview.d.ts.map +1 -1
  11. package/dist/replay/ReplayPreview.js.map +1 -1
  12. package/dist/replay/ReplayStage.d.ts +10 -0
  13. package/dist/replay/ReplayStage.d.ts.map +1 -0
  14. package/dist/replay/ReplayStage.js +19 -0
  15. package/dist/replay/ReplayStage.js.map +1 -0
  16. package/dist/replay/ReplayStageImpl.d.ts +5 -0
  17. package/dist/replay/ReplayStageImpl.d.ts.map +1 -0
  18. package/dist/replay/ReplayStageImpl.js +249 -0
  19. package/dist/replay/ReplayStageImpl.js.map +1 -0
  20. package/dist/replay/index.d.ts +6 -0
  21. package/dist/replay/index.d.ts.map +1 -1
  22. package/dist/replay/index.js +3 -0
  23. package/dist/replay/index.js.map +1 -1
  24. package/dist/replay/markers.d.ts +55 -0
  25. package/dist/replay/markers.d.ts.map +1 -0
  26. package/dist/replay/markers.js +177 -0
  27. package/dist/replay/markers.js.map +1 -0
  28. package/dist/replay/sanitize.d.ts +2 -2
  29. package/dist/replay/sanitize.d.ts.map +1 -1
  30. package/dist/replay/sanitize.js +1 -1
  31. package/dist/replay/sanitize.js.map +1 -1
  32. package/dist/replay/stage-types.d.ts +65 -0
  33. package/dist/replay/stage-types.d.ts.map +1 -0
  34. package/dist/replay/stage-types.js +2 -0
  35. package/dist/replay/stage-types.js.map +1 -0
  36. package/dist/replay/types.d.ts +39 -0
  37. package/dist/replay/types.d.ts.map +1 -0
  38. package/dist/replay/types.js +36 -0
  39. package/dist/replay/types.js.map +1 -0
  40. package/package.json +1 -1
  41. package/src/index.ts +1 -0
  42. package/src/replay/NativeReplay.tsx +6 -2
  43. package/src/replay/ReplayPreview.tsx +6 -6
  44. package/src/replay/ReplayStage.tsx +28 -0
  45. package/src/replay/ReplayStageImpl.tsx +281 -0
  46. package/src/replay/index.ts +6 -0
  47. package/src/replay/markers.ts +213 -0
  48. package/src/replay/sanitize.ts +2 -3
  49. package/src/replay/stage-types.ts +63 -0
  50. package/src/replay/types.ts +46 -0
@@ -0,0 +1,281 @@
1
+ /* The headless rrweb stage: mounts rrweb's Replayer, fits the recorded viewport into its box
2
+ and exposes an imperative handle, with no controls of its own. Loaded on demand through
3
+ `ReplayStage` (and by `NativeReplay`), so rrweb and its stylesheet stay out of the eager graph. */
4
+ import { forwardRef, useCallback, useEffect, useImperativeHandle, useLayoutEffect, useMemo, useRef, useState } from 'react'
5
+ import type { CSSProperties } from 'react'
6
+ import { Replayer } from 'rrweb'
7
+ import type { eventWithTime } from 'rrweb'
8
+ import { getReplayMeta } from './markers'
9
+ import { sanitizeReplayEvent } from './sanitize'
10
+ import type { ReplayStageHandle, ReplayStageProps } from './stage-types'
11
+ import { ReplayEventType } from './types'
12
+ import 'rrweb/dist/style.css'
13
+
14
+ const SKIP_IDLE_THRESHOLD_MS = 5_000
15
+
16
+ const useIsoLayoutEffect = typeof window === 'undefined' ? useEffect : useLayoutEffect
17
+
18
+ const ReplayStageImpl = forwardRef<ReplayStageHandle, ReplayStageProps>(function ReplayStageImpl(
19
+ {
20
+ events,
21
+ autoplay = false,
22
+ loop = false,
23
+ speed = 1,
24
+ skipInactive = true,
25
+ initialTime = 0,
26
+ fit = 'width',
27
+ showMouse = true,
28
+ overlay,
29
+ className,
30
+ style,
31
+ onReady,
32
+ onTimeUpdate,
33
+ onPlayingChange,
34
+ onFinish,
35
+ onLayout,
36
+ },
37
+ ref,
38
+ ) {
39
+ const boxRef = useRef<HTMLDivElement>(null)
40
+ const playerRef = useRef<HTMLDivElement>(null)
41
+ const replayerRef = useRef<Replayer | null>(null)
42
+ const playingRef = useRef(false)
43
+ const fedCountRef = useRef(0)
44
+ const rafRef = useRef(0)
45
+ const callbacks = useRef({ onReady, onTimeUpdate, onPlayingChange, onFinish, onLayout })
46
+ callbacks.current = { onReady, onTimeUpdate, onPlayingChange, onFinish, onLayout }
47
+ const options = useRef({ autoplay, loop, speed, skipInactive, initialTime })
48
+ options.current = { autoplay, loop, speed, skipInactive, initialTime }
49
+
50
+ const prepared = useMemo(
51
+ () =>
52
+ events
53
+ .map((event) => sanitizeReplayEvent(event))
54
+ .sort((a, b) => a.timestamp - b.timestamp) as unknown as eventWithTime[],
55
+ [events],
56
+ )
57
+ const meta = useMemo(() => getReplayMeta(events), [events])
58
+ const [dims, setDims] = useState<{ w: number; h: number }>(() => ({ w: meta.width, h: meta.height }))
59
+ const [box, setBox] = useState<{ w: number; h: number }>({ w: 0, h: 0 })
60
+
61
+ const time = useCallback(() => {
62
+ try {
63
+ const replayer = replayerRef.current
64
+ if (!replayer) return 0
65
+ return Math.max(0, Math.min(replayer.getCurrentTime(), replayer.getMetaData().totalTime))
66
+ } catch {
67
+ return 0
68
+ }
69
+ }, [])
70
+
71
+ const setPlaying = useCallback((next: boolean) => {
72
+ if (playingRef.current === next) return
73
+ playingRef.current = next
74
+ callbacks.current.onPlayingChange?.(next)
75
+ }, [])
76
+
77
+ const tick = useCallback(() => {
78
+ cancelAnimationFrame(rafRef.current)
79
+ const step = () => {
80
+ callbacks.current.onTimeUpdate?.(time())
81
+ if (playingRef.current) rafRef.current = requestAnimationFrame(step)
82
+ }
83
+ rafRef.current = requestAnimationFrame(step)
84
+ }, [time])
85
+
86
+ const handle = useMemo<ReplayStageHandle>(
87
+ () => ({
88
+ play(offset) {
89
+ const replayer = replayerRef.current
90
+ if (!replayer) return
91
+ let from = offset ?? time()
92
+ try {
93
+ if (offset === undefined && from >= replayer.getMetaData().totalTime - 1) from = 0
94
+ } catch {
95
+ from = 0
96
+ }
97
+ replayer.play(from)
98
+ setPlaying(true)
99
+ tick()
100
+ },
101
+ pause(offset) {
102
+ const replayer = replayerRef.current
103
+ if (!replayer) return
104
+ if (offset === undefined) replayer.pause()
105
+ else replayer.pause(offset)
106
+ setPlaying(false)
107
+ callbacks.current.onTimeUpdate?.(offset ?? time())
108
+ },
109
+ seek(ms) {
110
+ const replayer = replayerRef.current
111
+ if (!replayer) return
112
+ let total = ms
113
+ try {
114
+ total = replayer.getMetaData().totalTime
115
+ } catch {
116
+ // keep ms
117
+ }
118
+ const next = Math.max(0, Math.min(ms, total))
119
+ if (playingRef.current) replayer.play(next)
120
+ else replayer.pause(next)
121
+ callbacks.current.onTimeUpdate?.(next)
122
+ },
123
+ toggle() {
124
+ if (playingRef.current) this.pause()
125
+ else this.play()
126
+ },
127
+ getCurrentTime: time,
128
+ isPlaying: () => playingRef.current,
129
+ getDuration() {
130
+ try {
131
+ return replayerRef.current?.getMetaData().totalTime ?? meta.duration
132
+ } catch {
133
+ return meta.duration
134
+ }
135
+ },
136
+ }),
137
+ [meta.duration, setPlaying, tick, time],
138
+ )
139
+ useImperativeHandle(ref, () => handle, [handle])
140
+
141
+ // Build / feed the Replayer. Appended events are fed live; a shorter list starts over.
142
+ useEffect(() => {
143
+ if (replayerRef.current && prepared.length < fedCountRef.current) {
144
+ try {
145
+ replayerRef.current.destroy()
146
+ } catch {
147
+ // Ignore stale player teardown.
148
+ }
149
+ replayerRef.current = null
150
+ fedCountRef.current = 0
151
+ }
152
+ const hasSnapshot = prepared.some((event) => event.type === ReplayEventType.FullSnapshot)
153
+ if (!hasSnapshot || !playerRef.current) return
154
+
155
+ if (!replayerRef.current) {
156
+ const { autoplay, speed, skipInactive, initialTime } = options.current
157
+ const replayer = new Replayer(prepared, {
158
+ root: playerRef.current,
159
+ liveMode: false,
160
+ speed,
161
+ skipInactive,
162
+ inactivePeriodThreshold: SKIP_IDLE_THRESHOLD_MS,
163
+ mouseTail: false,
164
+ showWarning: false,
165
+ })
166
+ replayer.on('resize', (payload) => {
167
+ const size = payload as { width?: number; height?: number }
168
+ if (size.width && size.height) setDims({ w: size.width, h: size.height })
169
+ })
170
+ replayer.on('finish', () => {
171
+ if (options.current.loop) {
172
+ replayer.play(0)
173
+ setPlaying(true)
174
+ tick()
175
+ } else {
176
+ setPlaying(false)
177
+ callbacks.current.onTimeUpdate?.(time())
178
+ callbacks.current.onFinish?.()
179
+ }
180
+ })
181
+ replayer.pause(initialTime)
182
+ replayerRef.current = replayer
183
+ fedCountRef.current = prepared.length
184
+ let duration = meta.duration
185
+ try {
186
+ duration = replayer.getMetaData().totalTime
187
+ } catch {
188
+ // keep the event-derived duration
189
+ }
190
+ callbacks.current.onReady?.({ duration, width: meta.width, height: meta.height })
191
+ callbacks.current.onTimeUpdate?.(initialTime)
192
+ if (autoplay) handle.play(initialTime)
193
+ } else if (prepared.length > fedCountRef.current) {
194
+ for (let i = fedCountRef.current; i < prepared.length; i++) replayerRef.current.addEvent(prepared[i]!)
195
+ fedCountRef.current = prepared.length
196
+ }
197
+ }, [prepared, meta, handle, setPlaying, tick, time])
198
+
199
+ useEffect(
200
+ () => () => {
201
+ cancelAnimationFrame(rafRef.current)
202
+ try {
203
+ replayerRef.current?.destroy()
204
+ } catch {
205
+ // Ignore unmount teardown errors from rrweb.
206
+ }
207
+ replayerRef.current = null
208
+ fedCountRef.current = 0
209
+ },
210
+ [],
211
+ )
212
+
213
+ useEffect(() => {
214
+ replayerRef.current?.setConfig({ speed, skipInactive })
215
+ }, [speed, skipInactive])
216
+
217
+ // rrweb's own cursor element; hosts drawing their own pointer overlay hide it.
218
+ useEffect(() => {
219
+ const mouse = playerRef.current?.querySelector<HTMLElement>('.replayer-mouse')
220
+ if (mouse) mouse.style.visibility = showMouse ? '' : 'hidden'
221
+ })
222
+
223
+ useIsoLayoutEffect(() => {
224
+ const el = boxRef.current
225
+ if (!el) return
226
+ const measure = () => setBox({ w: el.clientWidth, h: el.clientHeight })
227
+ measure()
228
+ if (typeof ResizeObserver === 'undefined') return
229
+ const observer = new ResizeObserver(measure)
230
+ observer.observe(el)
231
+ return () => observer.disconnect()
232
+ }, [])
233
+
234
+ const w = dims.w || 1280
235
+ const h = dims.h || 720
236
+ const scale = box.w
237
+ ? fit === 'contain' && box.h
238
+ ? Math.min(box.w / w, box.h / h)
239
+ : Math.min(1, box.w / w)
240
+ : 0
241
+ const offsetX = scale ? Math.max(0, (box.w - w * scale) / 2) : 0
242
+ const offsetY = scale && fit === 'contain' ? Math.max(0, (box.h - h * scale) / 2) : 0
243
+
244
+ useEffect(() => {
245
+ if (scale) callbacks.current.onLayout?.({ width: w, height: h, scale, offsetX, offsetY })
246
+ }, [w, h, scale, offsetX, offsetY])
247
+
248
+ const rootStyle: CSSProperties = {
249
+ position: 'relative',
250
+ overflow: 'hidden',
251
+ width: '100%',
252
+ ...(fit === 'contain' ? { height: '100%' } : { height: scale ? Math.round(h * scale) : undefined, aspectRatio: scale ? undefined : `${w} / ${h}` }),
253
+ ...style,
254
+ }
255
+
256
+ return (
257
+ <div ref={boxRef} className={className} style={rootStyle} data-replay-stage="">
258
+ <div
259
+ data-replay-stage-scaler=""
260
+ style={
261
+ {
262
+ position: 'absolute',
263
+ left: offsetX,
264
+ top: offsetY,
265
+ width: w,
266
+ height: h,
267
+ transform: `scale(${scale || 0.0001})`,
268
+ transformOrigin: 'top left',
269
+ visibility: scale ? 'visible' : 'hidden',
270
+ '--replay-scale': String(scale || 1),
271
+ } as CSSProperties
272
+ }
273
+ >
274
+ <div ref={playerRef} aria-hidden="true" style={{ position: 'relative', width: w, height: h, overflow: 'hidden' }} />
275
+ {overlay ? <div style={{ position: 'absolute', inset: 0, pointerEvents: 'none' }}>{overlay}</div> : null}
276
+ </div>
277
+ </div>
278
+ )
279
+ })
280
+
281
+ export default ReplayStageImpl
@@ -6,5 +6,11 @@ export type {
6
6
  ReplayPreviewProps,
7
7
  ReplaySource,
8
8
  } from './ReplayPreview';
9
+ export { ReplayStage, preloadReplayStage } from './ReplayStage';
10
+ export type { ReplayStageHandle, ReplayStageLayout, ReplayStageProps } from './stage-types';
11
+ export { getReplayMarkers, getReplayMeta, getReplayPointerTrack, replayPointerAt } from './markers';
12
+ export type { ReplayMarker, ReplayMarkerKind, ReplayMeta, ReplayPointerPoint, ReplayPointerTrack } from './markers';
13
+ export { ReplayEventType, ReplayIncrementalSource, ReplayMouseInteraction } from './types';
14
+ export type { ReplayEvent } from './types';
9
15
  export { isReplayQaUrl, normalizeReplayEmbedUrl } from './url';
10
16
  export { sanitizeReplayEvent } from './sanitize';
@@ -0,0 +1,213 @@
1
+ /* Pure readers over an rrweb event list — no rrweb import, so custom player chrome (a
2
+ scrubber with event markers, a cursor overlay) can be drawn before the player chunk loads. */
3
+ import { ReplayEventType, ReplayIncrementalSource, ReplayMouseInteraction, type ReplayEvent } from './types'
4
+
5
+ export interface ReplayMeta {
6
+ /** Timestamp of the first event (ms since epoch). */
7
+ start: number
8
+ /** Timestamp of the last event. */
9
+ end: number
10
+ /** `end - start`: rrweb's `totalTime`. */
11
+ duration: number
12
+ /** Recorded viewport, from the first Meta / viewport-resize event (0 when unknown). */
13
+ width: number
14
+ height: number
15
+ /** The first recorded page URL, if any. */
16
+ href?: string
17
+ /** Whether a full DOM snapshot is present (rrweb can't play without one). */
18
+ playable: boolean
19
+ }
20
+
21
+ export type ReplayMarkerKind = 'click' | 'error' | 'network' | 'navigation' | 'custom'
22
+
23
+ export interface ReplayMarker {
24
+ /** Stable id (`<kind>-<index>`), handy as a React key. */
25
+ id: string
26
+ kind: ReplayMarkerKind
27
+ /** Milliseconds from the start of the recording. */
28
+ time: number
29
+ label: string
30
+ detail?: string
31
+ /** Viewport coordinates, for clicks. */
32
+ x?: number
33
+ y?: number
34
+ }
35
+
36
+ export interface ReplayPointerPoint {
37
+ /** Milliseconds from the start of the recording. */
38
+ time: number
39
+ x: number
40
+ y: number
41
+ }
42
+
43
+ export interface ReplayPointerTrack {
44
+ moves: ReplayPointerPoint[]
45
+ clicks: ReplayPointerPoint[]
46
+ }
47
+
48
+ function sorted(events: readonly ReplayEvent[]): ReplayEvent[] {
49
+ return [...events].sort((a, b) => a.timestamp - b.timestamp)
50
+ }
51
+
52
+ function record(value: unknown): Record<string, unknown> {
53
+ return value && typeof value === 'object' ? (value as Record<string, unknown>) : {}
54
+ }
55
+
56
+ function text(value: unknown): string | undefined {
57
+ if (typeof value === 'string') return value
58
+ if (typeof value === 'number') return String(value)
59
+ if (Array.isArray(value)) return value.map((v) => (typeof v === 'string' ? v.replace(/^"|"$/g, '') : JSON.stringify(v))).join(' ')
60
+ return undefined
61
+ }
62
+
63
+ export function getReplayMeta(events: readonly ReplayEvent[]): ReplayMeta {
64
+ const list = sorted(events)
65
+ const start = list[0]?.timestamp ?? 0
66
+ const end = list[list.length - 1]?.timestamp ?? start
67
+ let width = 0
68
+ let height = 0
69
+ let href: string | undefined
70
+ let playable = false
71
+ for (const event of list) {
72
+ const data = record(event.data)
73
+ if (event.type === ReplayEventType.FullSnapshot) playable = true
74
+ if (event.type === ReplayEventType.Meta) {
75
+ if (!width && typeof data.width === 'number') width = data.width
76
+ if (!height && typeof data.height === 'number') height = data.height
77
+ if (!href && typeof data.href === 'string') href = data.href
78
+ } else if (
79
+ !width &&
80
+ event.type === ReplayEventType.IncrementalSnapshot &&
81
+ data.source === ReplayIncrementalSource.ViewportResize &&
82
+ typeof data.width === 'number' &&
83
+ typeof data.height === 'number'
84
+ ) {
85
+ width = data.width
86
+ height = data.height
87
+ }
88
+ }
89
+ return { start, end, duration: end - start, width, height, href, playable }
90
+ }
91
+
92
+ const ERROR_TAGS = new Set(['error', 'exception', 'console.error', 'uncaught'])
93
+ const NETWORK_TAGS = new Set(['network', 'network.error', 'request.failed', 'fetch.failed'])
94
+ const NAVIGATION_TAGS = new Set(['navigation', 'navigate', 'route'])
95
+
96
+ /**
97
+ * Timeline markers for a scrubber: clicks, console errors (rrweb console plugin), failed
98
+ * requests (rrweb network plugin, status 0 or ≥ 400), page navigations (Meta events after the
99
+ * first) and Custom events (`record.addCustomEvent(tag, payload)`; tags `error`, `network`,
100
+ * `navigation` map to those kinds, anything else is `custom` labelled by `payload.label`).
101
+ */
102
+ export function getReplayMarkers(events: readonly ReplayEvent[]): ReplayMarker[] {
103
+ const list = sorted(events)
104
+ const start = list[0]?.timestamp ?? 0
105
+ const markers: ReplayMarker[] = []
106
+ let seenMeta = false
107
+ const push = (marker: Omit<ReplayMarker, 'id'>) =>
108
+ markers.push({ ...marker, id: `${marker.kind}-${markers.length}` })
109
+
110
+ for (const event of list) {
111
+ const time = event.timestamp - start
112
+ const data = record(event.data)
113
+ switch (event.type) {
114
+ case ReplayEventType.IncrementalSnapshot: {
115
+ if (
116
+ data.source === ReplayIncrementalSource.MouseInteraction &&
117
+ (data.type === ReplayMouseInteraction.Click || data.type === ReplayMouseInteraction.DblClick) &&
118
+ typeof data.x === 'number' &&
119
+ typeof data.y === 'number'
120
+ ) {
121
+ push({ kind: 'click', time, label: data.type === ReplayMouseInteraction.DblClick ? 'Double-click' : 'Click', x: data.x, y: data.y })
122
+ }
123
+ break
124
+ }
125
+ case ReplayEventType.Meta: {
126
+ if (seenMeta && typeof data.href === 'string') push({ kind: 'navigation', time, label: 'Navigated', detail: data.href })
127
+ seenMeta = true
128
+ break
129
+ }
130
+ case ReplayEventType.Plugin: {
131
+ const payload = record(data.payload)
132
+ if (data.plugin === 'rrweb/console@1' && (payload.level === 'error' || payload.level === 'assert')) {
133
+ push({ kind: 'error', time, label: 'Console error', detail: text(payload.payload) })
134
+ } else if (data.plugin === 'rrweb/network@1' && Array.isArray(payload.requests)) {
135
+ for (const raw of payload.requests) {
136
+ const request = record(raw)
137
+ const status = typeof request.status === 'number' ? request.status : typeof request.responseStatus === 'number' ? request.responseStatus : undefined
138
+ if (status === 0 || (status !== undefined && status >= 400) || request.error) {
139
+ const method = typeof request.method === 'string' ? request.method : 'GET'
140
+ push({ kind: 'network', time, label: `${method} ${status || 'failed'}`, detail: text(request.url ?? request.name) })
141
+ }
142
+ }
143
+ }
144
+ break
145
+ }
146
+ case ReplayEventType.Custom: {
147
+ const tag = typeof data.tag === 'string' ? data.tag : 'custom'
148
+ const payload = record(data.payload)
149
+ const detail = text(payload.detail ?? payload.message ?? payload.url)
150
+ const label = text(payload.label)
151
+ if (ERROR_TAGS.has(tag)) push({ kind: 'error', time, label: label ?? 'Error', detail })
152
+ else if (NETWORK_TAGS.has(tag)) {
153
+ const status = text(payload.status)
154
+ const method = text(payload.method) ?? 'GET'
155
+ push({ kind: 'network', time, label: label ?? `${method} ${status ?? 'failed'}`, detail })
156
+ } else if (NAVIGATION_TAGS.has(tag)) push({ kind: 'navigation', time, label: label ?? 'Navigated', detail })
157
+ else push({ kind: 'custom', time, label: label ?? tag, detail })
158
+ break
159
+ }
160
+ }
161
+ }
162
+ return markers
163
+ }
164
+
165
+ /** The recorded pointer path (mouse/touch moves and drags) and click points, in viewport px. */
166
+ export function getReplayPointerTrack(events: readonly ReplayEvent[]): ReplayPointerTrack {
167
+ const list = sorted(events)
168
+ const start = list[0]?.timestamp ?? 0
169
+ const moves: ReplayPointerPoint[] = []
170
+ const clicks: ReplayPointerPoint[] = []
171
+ for (const event of list) {
172
+ if (event.type !== ReplayEventType.IncrementalSnapshot) continue
173
+ const data = record(event.data)
174
+ const source = data.source
175
+ if (
176
+ (source === ReplayIncrementalSource.MouseMove ||
177
+ source === ReplayIncrementalSource.TouchMove ||
178
+ source === ReplayIncrementalSource.Drag) &&
179
+ Array.isArray(data.positions)
180
+ ) {
181
+ for (const raw of data.positions) {
182
+ const p = record(raw)
183
+ if (typeof p.x !== 'number' || typeof p.y !== 'number') continue
184
+ const offset = typeof p.timeOffset === 'number' ? p.timeOffset : 0
185
+ moves.push({ time: Math.max(0, event.timestamp - start + offset), x: p.x, y: p.y })
186
+ }
187
+ } else if (source === ReplayIncrementalSource.MouseInteraction && typeof data.x === 'number' && typeof data.y === 'number') {
188
+ const point = { time: event.timestamp - start, x: data.x, y: data.y }
189
+ moves.push(point)
190
+ if (data.type === ReplayMouseInteraction.Click || data.type === ReplayMouseInteraction.DblClick) clicks.push(point)
191
+ }
192
+ }
193
+ moves.sort((a, b) => a.time - b.time)
194
+ return { moves, clicks }
195
+ }
196
+
197
+ /** The pointer position at `time` (linear between recorded points), or null before the first. */
198
+ export function replayPointerAt(track: ReplayPointerTrack, time: number): { x: number; y: number } | null {
199
+ const { moves } = track
200
+ if (!moves.length || time < moves[0]!.time) return null
201
+ let lo = 0
202
+ let hi = moves.length - 1
203
+ if (time >= moves[hi]!.time) return { x: moves[hi]!.x, y: moves[hi]!.y }
204
+ while (hi - lo > 1) {
205
+ const mid = (lo + hi) >> 1
206
+ if (moves[mid]!.time <= time) lo = mid
207
+ else hi = mid
208
+ }
209
+ const a = moves[lo]!
210
+ const b = moves[hi]!
211
+ const k = b.time === a.time ? 1 : (time - a.time) / (b.time - a.time)
212
+ return { x: a.x + (b.x - a.x) * k, y: a.y + (b.y - a.y) * k }
213
+ }
@@ -1,5 +1,4 @@
1
- import { EventType, IncrementalSource } from 'rrweb';
2
- import type { eventWithTime } from 'rrweb';
1
+ import { ReplayEventType as EventType, ReplayIncrementalSource as IncrementalSource, type ReplayEvent } from './types';
3
2
 
4
3
  const TRANSPARENT_PIXEL =
5
4
  'data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw==';
@@ -57,7 +56,7 @@ function walkNode(node: unknown): void {
57
56
  }
58
57
 
59
58
  /** Prevent a replay iframe from making the viewer fetch localhost/private assets. */
60
- export function sanitizeReplayEvent(event: eventWithTime): eventWithTime {
59
+ export function sanitizeReplayEvent<T extends ReplayEvent>(event: T): T {
61
60
  try {
62
61
  if (event.type === EventType.FullSnapshot) {
63
62
  walkNode((event.data as { node?: unknown }).node);
@@ -0,0 +1,63 @@
1
+ import type { CSSProperties, ReactNode } from 'react'
2
+ import type { ReplayEvent } from './types'
3
+
4
+ /** Imperative control over a mounted `ReplayStage`. Times are ms from the recording's start. */
5
+ export interface ReplayStageHandle {
6
+ /** Play from `offset` (default: the current time; from 0 when at the end). */
7
+ play(offset?: number): void
8
+ /** Pause, optionally jumping to `offset`. */
9
+ pause(offset?: number): void
10
+ /** Jump to `ms`, keeping the play state. */
11
+ seek(ms: number): void
12
+ toggle(): void
13
+ getCurrentTime(): number
14
+ getDuration(): number
15
+ isPlaying(): boolean
16
+ }
17
+
18
+ export interface ReplayStageLayout {
19
+ /** Recorded viewport size (px). */
20
+ width: number
21
+ height: number
22
+ /** How much the viewport is scaled to fit the stage box. */
23
+ scale: number
24
+ /** Where the scaled viewport sits inside the box. */
25
+ offsetX: number
26
+ offsetY: number
27
+ }
28
+
29
+ export interface ReplayStageProps {
30
+ /** rrweb events; appended events are fed to the running player. */
31
+ events: ReplayEvent[]
32
+ autoplay?: boolean
33
+ loop?: boolean
34
+ /** Playback rate (1 = real time). */
35
+ speed?: number
36
+ /** Fast-forward idle stretches longer than 5s (rrweb `skipInactive`). Default true. */
37
+ skipInactive?: boolean
38
+ /** Where the paused player starts (ms). */
39
+ initialTime?: number
40
+ /**
41
+ * `width` (default): scale down to the box width, height follows the recording's aspect.
42
+ * `contain`: fill the box (give it a height) and letterbox the viewport inside it.
43
+ */
44
+ fit?: 'width' | 'contain'
45
+ /** rrweb's own cursor. Hide it when drawing a pointer overlay. Default true. */
46
+ showMouse?: boolean
47
+ /**
48
+ * Rendered over the page, in recorded-viewport coordinates (a box of `width × height` px
49
+ * scaled with the page). `var(--replay-scale)` holds the scale, so an overlay can
50
+ * counter-scale elements that should keep their on-screen size.
51
+ */
52
+ overlay?: ReactNode
53
+ className?: string
54
+ style?: CSSProperties
55
+ /** Shown while the player chunk (rrweb) loads. */
56
+ fallback?: ReactNode
57
+ onReady?(info: { duration: number; width: number; height: number }): void
58
+ /** Every frame while playing, and after each seek/pause. */
59
+ onTimeUpdate?(ms: number): void
60
+ onPlayingChange?(playing: boolean): void
61
+ onFinish?(): void
62
+ onLayout?(layout: ReplayStageLayout): void
63
+ }
@@ -0,0 +1,46 @@
1
+ /* rrweb's event shape and the numeric enums the replay helpers read, declared locally so the
2
+ eager replay modules (and their published declarations) never import rrweb. rrweb is
3
+ lazy: only the player chunk (`ReplayStageImpl` / `NativeReplay`) imports it, so pages that
4
+ never play native events never download it. */
5
+
6
+ /** One recorded rrweb event. rrweb's own `eventWithTime` is assignable to it. */
7
+ export interface ReplayEvent {
8
+ type: number
9
+ data: unknown
10
+ timestamp: number
11
+ delay?: number
12
+ }
13
+
14
+ /** rrweb `EventType`. */
15
+ export const ReplayEventType = {
16
+ DomContentLoaded: 0,
17
+ Load: 1,
18
+ FullSnapshot: 2,
19
+ IncrementalSnapshot: 3,
20
+ Meta: 4,
21
+ Custom: 5,
22
+ Plugin: 6,
23
+ } as const
24
+
25
+ /** rrweb `IncrementalSource` (the subset the helpers read). */
26
+ export const ReplayIncrementalSource = {
27
+ Mutation: 0,
28
+ MouseMove: 1,
29
+ MouseInteraction: 2,
30
+ Scroll: 3,
31
+ ViewportResize: 4,
32
+ Input: 5,
33
+ TouchMove: 6,
34
+ Drag: 12,
35
+ } as const
36
+
37
+ /** rrweb `MouseInteractions` (the subset the helpers read). */
38
+ export const ReplayMouseInteraction = {
39
+ MouseUp: 0,
40
+ MouseDown: 1,
41
+ Click: 2,
42
+ ContextMenu: 3,
43
+ DblClick: 4,
44
+ TouchStart: 7,
45
+ TouchEnd: 9,
46
+ } as const