@brett_lamy/docstream 0.1.0 → 0.3.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.
- package/README.md +215 -0
- package/package.json +34 -3
- package/src/docs/DocsRenderer.tsx +30 -2
- package/src/gitbook/ast.ts +4 -0
- package/src/gitbook/index.ts +6 -0
- package/src/gitbook/parse.ts +33 -5
- package/src/gitbook/serialize.ts +6 -2
- package/src/index.ts +28 -0
- package/src/playground/ReactCodePreview.tsx +161 -0
- package/src/playground/filesystem.ts +202 -0
- package/src/playground/index.ts +19 -0
- package/src/replay/ReplayPreview.tsx +398 -0
- package/src/replay/index.ts +10 -0
- package/src/replay/sanitize.ts +83 -0
- package/src/replay/styles.css +87 -0
- package/src/replay/url.ts +41 -0
- package/src/styles.css +108 -21
- package/src/video/VideoEmbed.tsx +62 -0
- package/src/video/index.ts +2 -0
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
import type { ContainerInstance, ContainerOptions, VirtualFS } from "@agent-wasm/core"
|
|
2
|
+
|
|
3
|
+
export type AlmostNodeFileContent = string | Uint8Array
|
|
4
|
+
|
|
5
|
+
export interface AlmostNodeFile {
|
|
6
|
+
path: string
|
|
7
|
+
content: AlmostNodeFileContent
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export type AlmostNodeFiles =
|
|
11
|
+
| Readonly<Record<string, AlmostNodeFileContent>>
|
|
12
|
+
| readonly AlmostNodeFile[]
|
|
13
|
+
|
|
14
|
+
export interface AlmostNodeFilesystemOptions {
|
|
15
|
+
/** Prefix for relative file paths. Defaults to the virtual root. */
|
|
16
|
+
root?: string
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface AlmostNodeWorkspaceOptions extends Omit<ContainerOptions, "cwd"> {
|
|
20
|
+
/** Project root used as the container cwd and Vite root. */
|
|
21
|
+
root?: string
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface AlmostNodeWorkspace {
|
|
25
|
+
container: ContainerInstance
|
|
26
|
+
root: string
|
|
27
|
+
writeFile(path: string, content: AlmostNodeFileContent): void
|
|
28
|
+
writeFiles(files: AlmostNodeFiles): void
|
|
29
|
+
listFiles(): string[]
|
|
30
|
+
dispose(): void
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
type AlmostNodeModule = typeof import("@agent-wasm/core")
|
|
34
|
+
|
|
35
|
+
async function loadAlmostNode(): Promise<AlmostNodeModule> {
|
|
36
|
+
try {
|
|
37
|
+
return await import("@agent-wasm/core")
|
|
38
|
+
} catch (error) {
|
|
39
|
+
const message = error instanceof Error ? error.message : String(error)
|
|
40
|
+
throw new Error(
|
|
41
|
+
`Docstream live previews require @agent-wasm/core (almost-node). Install it in the host app. ${message}`,
|
|
42
|
+
{ cause: error },
|
|
43
|
+
)
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function normalizeAlmostNodePath(path: string, root = "/"): string {
|
|
48
|
+
const normalizedRoot = normalizeRoot(root)
|
|
49
|
+
const source = path.replaceAll("\\", "/")
|
|
50
|
+
const absolute = source.startsWith("/") ? source : `${normalizedRoot}/${source}`
|
|
51
|
+
const result = normalizeAbsolutePath(absolute)
|
|
52
|
+
|
|
53
|
+
if (normalizedRoot !== "/" && result !== normalizedRoot && !result.startsWith(`${normalizedRoot}/`)) {
|
|
54
|
+
throw new Error(`File path escapes the almost-node root: ${path}`)
|
|
55
|
+
}
|
|
56
|
+
return result
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function normalizeAbsolutePath(path: string): string {
|
|
60
|
+
const parts: string[] = []
|
|
61
|
+
|
|
62
|
+
for (const part of path.split("/")) {
|
|
63
|
+
if (!part || part === ".") continue
|
|
64
|
+
if (part === "..") {
|
|
65
|
+
if (parts.length > 0) parts.pop()
|
|
66
|
+
continue
|
|
67
|
+
}
|
|
68
|
+
if (part.includes("\0")) throw new Error("almost-node file paths cannot contain null bytes")
|
|
69
|
+
parts.push(part)
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const result = `/${parts.join("/")}`
|
|
73
|
+
return result === "" ? "/" : result
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function normalizeAlmostNodeFiles(files: AlmostNodeFiles): AlmostNodeFile[] {
|
|
77
|
+
if (Array.isArray(files)) return files.map((file) => ({ ...file }))
|
|
78
|
+
return Object.entries(files).map(([path, content]) => ({ path, content }))
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function toAlmostNodeFileMap(files: AlmostNodeFiles): Record<string, AlmostNodeFileContent> {
|
|
82
|
+
return Object.fromEntries(normalizeAlmostNodeFiles(files).map(({ path, content }) => [path, content]))
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function normalizeRoot(root: string): string {
|
|
86
|
+
const normalized = normalizeAbsolutePath(root.replaceAll("\\", "/"))
|
|
87
|
+
return normalized === "/" ? normalized : normalized.replace(/\/$/, "")
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function parentDirectory(path: string): string {
|
|
91
|
+
const index = path.lastIndexOf("/")
|
|
92
|
+
return index <= 0 ? "/" : path.slice(0, index)
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function writeFilesToVfs(vfs: VirtualFS, files: AlmostNodeFiles, root: string): void {
|
|
96
|
+
for (const { path, content } of normalizeAlmostNodeFiles(files)) {
|
|
97
|
+
const absolutePath = normalizeAlmostNodePath(path, root)
|
|
98
|
+
vfs.mkdirSync(parentDirectory(absolutePath), { recursive: true })
|
|
99
|
+
vfs.writeFileSync(absolutePath, content)
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Create a complete almost-node filesystem from a map or list of files.
|
|
105
|
+
* The runtime is loaded lazily so ordinary markdown rendering does not pull
|
|
106
|
+
* the WASM/browser runtime into the bundle until a live demo is used.
|
|
107
|
+
*/
|
|
108
|
+
export async function createAlmostNodeFilesystem(
|
|
109
|
+
files: AlmostNodeFiles,
|
|
110
|
+
options: AlmostNodeFilesystemOptions = {},
|
|
111
|
+
): Promise<VirtualFS> {
|
|
112
|
+
const { VirtualFS } = await loadAlmostNode()
|
|
113
|
+
const root = normalizeRoot(options.root ?? "/")
|
|
114
|
+
const vfs = new VirtualFS()
|
|
115
|
+
writeFilesToVfs(vfs, files, root)
|
|
116
|
+
return vfs
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Create a container and populate its entire virtual filesystem in one call.
|
|
121
|
+
* Use the returned container for `run`, `runFile`, npm installs, or Vite.
|
|
122
|
+
*/
|
|
123
|
+
export async function createAlmostNodeWorkspace(
|
|
124
|
+
files: AlmostNodeFiles,
|
|
125
|
+
options: AlmostNodeWorkspaceOptions = {},
|
|
126
|
+
): Promise<AlmostNodeWorkspace> {
|
|
127
|
+
const { root: requestedRoot, ...containerOptions } = options
|
|
128
|
+
const root = normalizeRoot(requestedRoot ?? "/")
|
|
129
|
+
const { createContainer } = await loadAlmostNode()
|
|
130
|
+
const container = createContainer({ ...containerOptions, cwd: root })
|
|
131
|
+
|
|
132
|
+
const writeFiles = (nextFiles: AlmostNodeFiles) => writeFilesToVfs(container.vfs, nextFiles, root)
|
|
133
|
+
writeFiles(files)
|
|
134
|
+
|
|
135
|
+
return {
|
|
136
|
+
container,
|
|
137
|
+
root,
|
|
138
|
+
writeFile(path, content) {
|
|
139
|
+
writeFiles([{ path, content }])
|
|
140
|
+
},
|
|
141
|
+
writeFiles,
|
|
142
|
+
listFiles() {
|
|
143
|
+
return container.vfs
|
|
144
|
+
.toSnapshot()
|
|
145
|
+
.files.filter((entry) => entry.type === "file")
|
|
146
|
+
.map((entry) => entry.path)
|
|
147
|
+
.sort()
|
|
148
|
+
},
|
|
149
|
+
dispose() {
|
|
150
|
+
container.dispose()
|
|
151
|
+
},
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export interface ReactDemoFilesOptions {
|
|
156
|
+
entry?: string
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Add the tiny Vite/React shell needed by a file map that only contains source
|
|
161
|
+
* files. Existing package.json and index.html files are always respected.
|
|
162
|
+
*/
|
|
163
|
+
export function createReactDemoFiles(
|
|
164
|
+
files: AlmostNodeFiles,
|
|
165
|
+
options: ReactDemoFilesOptions = {},
|
|
166
|
+
): Record<string, AlmostNodeFileContent> {
|
|
167
|
+
const map = toAlmostNodeFileMap(files)
|
|
168
|
+
const entry = normalizeAlmostNodePath(options.entry ?? "/src/main.jsx")
|
|
169
|
+
const has = (candidate: string) => Object.keys(map).some((path) => normalizeAlmostNodePath(path) === candidate)
|
|
170
|
+
|
|
171
|
+
if (!has("/package.json")) {
|
|
172
|
+
map["/package.json"] = JSON.stringify(
|
|
173
|
+
{
|
|
174
|
+
name: "docstream-react-demo",
|
|
175
|
+
private: true,
|
|
176
|
+
type: "module",
|
|
177
|
+
dependencies: { react: "^19.0.0", "react-dom": "^19.0.0" },
|
|
178
|
+
},
|
|
179
|
+
null,
|
|
180
|
+
2,
|
|
181
|
+
)
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
if (!has("/index.html")) {
|
|
185
|
+
const entryUrl = `.${entry}`
|
|
186
|
+
map["/index.html"] = `<!doctype html>
|
|
187
|
+
<html lang="en">
|
|
188
|
+
<head>
|
|
189
|
+
<meta charset="UTF-8" />
|
|
190
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
191
|
+
<title>Docstream React demo</title>
|
|
192
|
+
</head>
|
|
193
|
+
<body>
|
|
194
|
+
<div id="root"></div>
|
|
195
|
+
<script type="module" src="${entryUrl}"></script>
|
|
196
|
+
</body>
|
|
197
|
+
</html>
|
|
198
|
+
`
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
return map
|
|
202
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
export { ReactCodePreview, ReactDemo } from "./ReactCodePreview"
|
|
2
|
+
export type { ReactCodePreviewProps, ReactDemoProps } from "./ReactCodePreview"
|
|
3
|
+
export {
|
|
4
|
+
createAlmostNodeFilesystem,
|
|
5
|
+
createAlmostNodeWorkspace,
|
|
6
|
+
createReactDemoFiles,
|
|
7
|
+
normalizeAlmostNodeFiles,
|
|
8
|
+
normalizeAlmostNodePath,
|
|
9
|
+
toAlmostNodeFileMap,
|
|
10
|
+
} from "./filesystem"
|
|
11
|
+
export type {
|
|
12
|
+
AlmostNodeFile,
|
|
13
|
+
AlmostNodeFileContent,
|
|
14
|
+
AlmostNodeFiles,
|
|
15
|
+
AlmostNodeFilesystemOptions,
|
|
16
|
+
AlmostNodeWorkspace,
|
|
17
|
+
AlmostNodeWorkspaceOptions,
|
|
18
|
+
ReactDemoFilesOptions,
|
|
19
|
+
} from "./filesystem"
|
|
@@ -0,0 +1,398 @@
|
|
|
1
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
2
|
+
import { EventType, Replayer } from 'rrweb';
|
|
3
|
+
import type { eventWithTime } from 'rrweb';
|
|
4
|
+
import { normalizeReplayEmbedUrl } from './url';
|
|
5
|
+
import { sanitizeReplayEvent } from './sanitize';
|
|
6
|
+
import 'rrweb/dist/style.css';
|
|
7
|
+
import './styles.css';
|
|
8
|
+
|
|
9
|
+
export interface ReplayPageSource {
|
|
10
|
+
kind: 'url';
|
|
11
|
+
url: string;
|
|
12
|
+
title?: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface ReplayEventsSource {
|
|
16
|
+
kind: 'events';
|
|
17
|
+
events: eventWithTime[];
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface ReplayEventsUrlSource {
|
|
21
|
+
kind: 'events-url';
|
|
22
|
+
url: string;
|
|
23
|
+
fetchInit?: RequestInit;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export type ReplaySource =
|
|
27
|
+
| string
|
|
28
|
+
| eventWithTime[]
|
|
29
|
+
| ReplayPageSource
|
|
30
|
+
| ReplayEventsSource
|
|
31
|
+
| ReplayEventsUrlSource;
|
|
32
|
+
|
|
33
|
+
export interface ReplayPreviewProps {
|
|
34
|
+
/** A public Loop QA URL, a public event endpoint, or an in-memory rrweb event list. */
|
|
35
|
+
source: ReplaySource;
|
|
36
|
+
/** Avoid mounting the rrweb player until the editor block is visible. */
|
|
37
|
+
active?: boolean;
|
|
38
|
+
/** Public-page embeds default to lazy loading; native rrweb playback defaults to paused. */
|
|
39
|
+
autoplay?: boolean;
|
|
40
|
+
loop?: boolean;
|
|
41
|
+
className?: string;
|
|
42
|
+
title?: string;
|
|
43
|
+
/** Playback speed options mirror the Loop QA player. */
|
|
44
|
+
speeds?: number[];
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const DEFAULT_SPEEDS = [0.5, 1, 2, 4, 8];
|
|
48
|
+
const SKIP_IDLE_THRESHOLD_MS = 5_000;
|
|
49
|
+
|
|
50
|
+
function sourcePage(source: ReplaySource): ReplayPageSource | null {
|
|
51
|
+
if (typeof source === 'string') return { kind: 'url', url: source };
|
|
52
|
+
if (Array.isArray(source)) return null;
|
|
53
|
+
return source.kind === 'url' ? source : null;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function sourceEvents(source: ReplaySource): ReplayEventsSource | null {
|
|
57
|
+
if (Array.isArray(source)) return { kind: 'events', events: source };
|
|
58
|
+
if (typeof source === 'string') return null;
|
|
59
|
+
return source.kind === 'events' ? source : null;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function sourceEventsUrl(source: ReplaySource): ReplayEventsUrlSource | null {
|
|
63
|
+
if (Array.isArray(source) || typeof source === 'string') return null;
|
|
64
|
+
return source.kind === 'events-url' ? source : null;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function ReplayFrame({ source, title }: { source: ReplayPageSource; title?: string }) {
|
|
68
|
+
const url = normalizeReplayEmbedUrl(source.url);
|
|
69
|
+
if (!url) {
|
|
70
|
+
return (
|
|
71
|
+
<a className="replay-preview__invalid" href={source.url} target="_blank" rel="noreferrer">
|
|
72
|
+
Open replay
|
|
73
|
+
</a>
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
return (
|
|
77
|
+
<iframe
|
|
78
|
+
className="replay-preview__frame"
|
|
79
|
+
src={url}
|
|
80
|
+
title={title ?? source.title ?? 'Replay preview'}
|
|
81
|
+
loading="lazy"
|
|
82
|
+
allow="autoplay; fullscreen"
|
|
83
|
+
allowFullScreen
|
|
84
|
+
referrerPolicy="strict-origin-when-cross-origin"
|
|
85
|
+
/>
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function NativeReplay({
|
|
90
|
+
events,
|
|
91
|
+
autoplay = false,
|
|
92
|
+
loop = false,
|
|
93
|
+
speeds = DEFAULT_SPEEDS,
|
|
94
|
+
}: {
|
|
95
|
+
events: eventWithTime[];
|
|
96
|
+
autoplay?: boolean;
|
|
97
|
+
loop?: boolean;
|
|
98
|
+
speeds?: number[];
|
|
99
|
+
}) {
|
|
100
|
+
const stageRef = useRef<HTMLDivElement>(null);
|
|
101
|
+
const playerRef = useRef<HTMLDivElement>(null);
|
|
102
|
+
const replayerRef = useRef<Replayer | null>(null);
|
|
103
|
+
const fedCountRef = useRef(0);
|
|
104
|
+
const [playing, setPlaying] = useState(false);
|
|
105
|
+
const [speed, setSpeed] = useState(1);
|
|
106
|
+
const [dims, setDims] = useState<{ w: number; h: number } | null>(null);
|
|
107
|
+
const [scale, setScale] = useState(1);
|
|
108
|
+
const [currentTime, setCurrentTime] = useState(0);
|
|
109
|
+
const [totalTime, setTotalTime] = useState(0);
|
|
110
|
+
const [scrubbing, setScrubbing] = useState<number | null>(null);
|
|
111
|
+
const speedRef = useRef(speed);
|
|
112
|
+
const loopRef = useRef(loop);
|
|
113
|
+
const autoplayRef = useRef(autoplay);
|
|
114
|
+
|
|
115
|
+
const prepared = useMemo(
|
|
116
|
+
() => events.map((event) => sanitizeReplayEvent(event)).sort((a, b) => a.timestamp - b.timestamp),
|
|
117
|
+
[events],
|
|
118
|
+
);
|
|
119
|
+
const hasSnapshot = prepared.some((event) => event.type === EventType.FullSnapshot);
|
|
120
|
+
|
|
121
|
+
useEffect(() => {
|
|
122
|
+
if (replayerRef.current && prepared.length < fedCountRef.current) {
|
|
123
|
+
try {
|
|
124
|
+
replayerRef.current.destroy();
|
|
125
|
+
} catch {
|
|
126
|
+
// Ignore stale player teardown.
|
|
127
|
+
}
|
|
128
|
+
replayerRef.current = null;
|
|
129
|
+
fedCountRef.current = 0;
|
|
130
|
+
}
|
|
131
|
+
if (!hasSnapshot || !playerRef.current) return;
|
|
132
|
+
|
|
133
|
+
if (!replayerRef.current) {
|
|
134
|
+
const replayer = new Replayer(prepared, {
|
|
135
|
+
root: playerRef.current,
|
|
136
|
+
liveMode: false,
|
|
137
|
+
speed: speedRef.current,
|
|
138
|
+
skipInactive: true,
|
|
139
|
+
inactivePeriodThreshold: SKIP_IDLE_THRESHOLD_MS,
|
|
140
|
+
mouseTail: false,
|
|
141
|
+
showWarning: false,
|
|
142
|
+
});
|
|
143
|
+
replayer.on('resize', (payload) => {
|
|
144
|
+
const size = payload as { width?: number; height?: number };
|
|
145
|
+
if (size.width && size.height) setDims({ w: size.width, h: size.height });
|
|
146
|
+
});
|
|
147
|
+
replayer.on('finish', () => {
|
|
148
|
+
if (loopRef.current) {
|
|
149
|
+
replayer.play(0);
|
|
150
|
+
setPlaying(true);
|
|
151
|
+
} else {
|
|
152
|
+
setPlaying(false);
|
|
153
|
+
}
|
|
154
|
+
});
|
|
155
|
+
replayer.pause(0);
|
|
156
|
+
replayerRef.current = replayer;
|
|
157
|
+
fedCountRef.current = prepared.length;
|
|
158
|
+
if (autoplayRef.current) {
|
|
159
|
+
replayer.play(0);
|
|
160
|
+
setPlaying(true);
|
|
161
|
+
}
|
|
162
|
+
} else if (prepared.length > fedCountRef.current) {
|
|
163
|
+
for (let i = fedCountRef.current; i < prepared.length; i++) {
|
|
164
|
+
replayerRef.current.addEvent(prepared[i]!);
|
|
165
|
+
}
|
|
166
|
+
fedCountRef.current = prepared.length;
|
|
167
|
+
}
|
|
168
|
+
}, [hasSnapshot, prepared]);
|
|
169
|
+
|
|
170
|
+
useEffect(
|
|
171
|
+
() => () => {
|
|
172
|
+
try {
|
|
173
|
+
replayerRef.current?.destroy();
|
|
174
|
+
} catch {
|
|
175
|
+
// Ignore unmount teardown errors from rrweb.
|
|
176
|
+
}
|
|
177
|
+
replayerRef.current = null;
|
|
178
|
+
fedCountRef.current = 0;
|
|
179
|
+
},
|
|
180
|
+
[],
|
|
181
|
+
);
|
|
182
|
+
|
|
183
|
+
useEffect(() => {
|
|
184
|
+
speedRef.current = speed;
|
|
185
|
+
replayerRef.current?.setConfig({ speed });
|
|
186
|
+
}, [speed]);
|
|
187
|
+
|
|
188
|
+
useEffect(() => {
|
|
189
|
+
loopRef.current = loop;
|
|
190
|
+
}, [loop]);
|
|
191
|
+
|
|
192
|
+
useEffect(() => {
|
|
193
|
+
autoplayRef.current = autoplay;
|
|
194
|
+
}, [autoplay]);
|
|
195
|
+
|
|
196
|
+
const recalcScale = useCallback(() => {
|
|
197
|
+
if (!dims || !stageRef.current || dims.w <= 0) return;
|
|
198
|
+
setScale(Math.min(1, stageRef.current.clientWidth / dims.w));
|
|
199
|
+
}, [dims]);
|
|
200
|
+
|
|
201
|
+
useEffect(() => {
|
|
202
|
+
recalcScale();
|
|
203
|
+
window.addEventListener('resize', recalcScale);
|
|
204
|
+
return () => window.removeEventListener('resize', recalcScale);
|
|
205
|
+
}, [recalcScale]);
|
|
206
|
+
|
|
207
|
+
useEffect(() => {
|
|
208
|
+
if (!hasSnapshot) return;
|
|
209
|
+
const interval = window.setInterval(() => {
|
|
210
|
+
const replayer = replayerRef.current;
|
|
211
|
+
if (!replayer) return;
|
|
212
|
+
try {
|
|
213
|
+
const total = replayer.getMetaData().totalTime;
|
|
214
|
+
const current = Math.max(0, Math.min(replayer.getCurrentTime(), total));
|
|
215
|
+
setTotalTime(total);
|
|
216
|
+
if (scrubbing === null) setCurrentTime(current);
|
|
217
|
+
} catch {
|
|
218
|
+
// rrweb can briefly be unready while a full snapshot is applied.
|
|
219
|
+
}
|
|
220
|
+
}, 200);
|
|
221
|
+
return () => window.clearInterval(interval);
|
|
222
|
+
}, [hasSnapshot, scrubbing]);
|
|
223
|
+
|
|
224
|
+
const seek = useCallback(
|
|
225
|
+
(value: number) => {
|
|
226
|
+
const replayer = replayerRef.current;
|
|
227
|
+
if (!replayer) return;
|
|
228
|
+
const next = Math.max(0, Math.min(value, totalTime));
|
|
229
|
+
if (playing) replayer.play(next);
|
|
230
|
+
else replayer.pause(next);
|
|
231
|
+
setCurrentTime(next);
|
|
232
|
+
},
|
|
233
|
+
[playing, totalTime],
|
|
234
|
+
);
|
|
235
|
+
|
|
236
|
+
const toggle = () => {
|
|
237
|
+
const replayer = replayerRef.current;
|
|
238
|
+
if (!replayer) return;
|
|
239
|
+
if (playing) {
|
|
240
|
+
replayer.pause();
|
|
241
|
+
setPlaying(false);
|
|
242
|
+
return;
|
|
243
|
+
}
|
|
244
|
+
let offset = currentTime;
|
|
245
|
+
try {
|
|
246
|
+
if (offset >= replayer.getMetaData().totalTime - 1) offset = 0;
|
|
247
|
+
} catch {
|
|
248
|
+
offset = 0;
|
|
249
|
+
}
|
|
250
|
+
replayer.play(offset);
|
|
251
|
+
setPlaying(true);
|
|
252
|
+
};
|
|
253
|
+
|
|
254
|
+
const restart = () => {
|
|
255
|
+
replayerRef.current?.play(0);
|
|
256
|
+
setCurrentTime(0);
|
|
257
|
+
setPlaying(true);
|
|
258
|
+
};
|
|
259
|
+
|
|
260
|
+
if (!hasSnapshot) {
|
|
261
|
+
return (
|
|
262
|
+
<div className="replay-preview__empty">
|
|
263
|
+
{events.length ? `Captured ${events.length} events, but no full DOM snapshot is available.` : 'Replay data is not available yet.'}
|
|
264
|
+
</div>
|
|
265
|
+
);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
return (
|
|
269
|
+
<div className="replay-preview__native">
|
|
270
|
+
<div
|
|
271
|
+
ref={stageRef}
|
|
272
|
+
className="replay-preview__stage"
|
|
273
|
+
style={dims ? { height: Math.max(1, Math.round(dims.h * scale)) } : undefined}
|
|
274
|
+
>
|
|
275
|
+
<div
|
|
276
|
+
className="replay-preview__scaler"
|
|
277
|
+
style={{ transform: `scale(${scale})`, transformOrigin: 'top left' }}
|
|
278
|
+
>
|
|
279
|
+
<div ref={playerRef} className="replay-preview__player" aria-hidden="true" />
|
|
280
|
+
</div>
|
|
281
|
+
</div>
|
|
282
|
+
<div className="replay-preview__controls">
|
|
283
|
+
<button type="button" onClick={toggle} aria-label={playing ? 'Pause replay' : 'Play replay'}>
|
|
284
|
+
{playing ? 'Pause' : 'Play'}
|
|
285
|
+
</button>
|
|
286
|
+
<button type="button" onClick={restart} aria-label="Restart replay">
|
|
287
|
+
Restart
|
|
288
|
+
</button>
|
|
289
|
+
<input
|
|
290
|
+
type="range"
|
|
291
|
+
min={0}
|
|
292
|
+
max={Math.max(totalTime, 1)}
|
|
293
|
+
step={1}
|
|
294
|
+
value={scrubbing ?? currentTime}
|
|
295
|
+
onChange={(event) => setScrubbing(Number(event.target.value))}
|
|
296
|
+
onPointerUp={(event) => {
|
|
297
|
+
seek(Number((event.currentTarget as HTMLInputElement).value));
|
|
298
|
+
setScrubbing(null);
|
|
299
|
+
}}
|
|
300
|
+
aria-label="Replay position"
|
|
301
|
+
/>
|
|
302
|
+
<select value={speed} onChange={(event) => setSpeed(Number(event.target.value))} aria-label="Replay speed">
|
|
303
|
+
{speeds.map((option) => (
|
|
304
|
+
<option key={option} value={option}>
|
|
305
|
+
{option}×
|
|
306
|
+
</option>
|
|
307
|
+
))}
|
|
308
|
+
</select>
|
|
309
|
+
</div>
|
|
310
|
+
</div>
|
|
311
|
+
);
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
function RemoteReplay({
|
|
315
|
+
source,
|
|
316
|
+
autoplay,
|
|
317
|
+
loop,
|
|
318
|
+
speeds,
|
|
319
|
+
}: {
|
|
320
|
+
source: ReplayEventsUrlSource;
|
|
321
|
+
autoplay?: boolean;
|
|
322
|
+
loop?: boolean;
|
|
323
|
+
speeds?: number[];
|
|
324
|
+
}) {
|
|
325
|
+
const [events, setEvents] = useState<eventWithTime[] | null>(null);
|
|
326
|
+
const [error, setError] = useState<string | null>(null);
|
|
327
|
+
|
|
328
|
+
useEffect(() => {
|
|
329
|
+
const controller = new AbortController();
|
|
330
|
+
setEvents(null);
|
|
331
|
+
setError(null);
|
|
332
|
+
fetch(source.url, { ...source.fetchInit, signal: controller.signal })
|
|
333
|
+
.then(async (response) => {
|
|
334
|
+
if (!response.ok) throw new Error(`Replay event request failed (${response.status})`);
|
|
335
|
+
const body = (await response.json()) as unknown;
|
|
336
|
+
const value = Array.isArray(body)
|
|
337
|
+
? body
|
|
338
|
+
: body && typeof body === 'object' && Array.isArray((body as { events?: unknown }).events)
|
|
339
|
+
? (body as { events: unknown[] }).events
|
|
340
|
+
: null;
|
|
341
|
+
if (!value) throw new Error('Replay event endpoint must return an event array or { events }');
|
|
342
|
+
return value as eventWithTime[];
|
|
343
|
+
})
|
|
344
|
+
.then(setEvents)
|
|
345
|
+
.catch((reason: unknown) => {
|
|
346
|
+
if (reason instanceof DOMException && reason.name === 'AbortError') return;
|
|
347
|
+
setError(reason instanceof Error ? reason.message : 'Unable to load replay data');
|
|
348
|
+
});
|
|
349
|
+
return () => controller.abort();
|
|
350
|
+
}, [source.url, source.fetchInit]);
|
|
351
|
+
|
|
352
|
+
if (error) return <div className="replay-preview__error" role="alert">{error}</div>;
|
|
353
|
+
if (!events) return <div className="replay-preview__empty">Loading replay…</div>;
|
|
354
|
+
return <NativeReplay events={events} autoplay={autoplay} loop={loop} speeds={speeds} />;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
export function ReplayPreview({
|
|
358
|
+
source,
|
|
359
|
+
active = true,
|
|
360
|
+
autoplay = false,
|
|
361
|
+
loop = false,
|
|
362
|
+
className,
|
|
363
|
+
title,
|
|
364
|
+
speeds = DEFAULT_SPEEDS,
|
|
365
|
+
}: ReplayPreviewProps) {
|
|
366
|
+
if (!active) return <div className={`replay-preview ${className ?? ''}`.trim()} data-replay-preview="paused" />;
|
|
367
|
+
|
|
368
|
+
const page = sourcePage(source);
|
|
369
|
+
const events = sourceEvents(source);
|
|
370
|
+
const eventsUrl = sourceEventsUrl(source);
|
|
371
|
+
const rootClass = `replay-preview ${className ?? ''}`.trim();
|
|
372
|
+
|
|
373
|
+
if (page) {
|
|
374
|
+
return (
|
|
375
|
+
<div className={rootClass} data-replay-preview="iframe">
|
|
376
|
+
<ReplayFrame source={page} title={title} />
|
|
377
|
+
</div>
|
|
378
|
+
);
|
|
379
|
+
}
|
|
380
|
+
if (events) {
|
|
381
|
+
return (
|
|
382
|
+
<div className={rootClass} data-replay-preview="rrweb">
|
|
383
|
+
<NativeReplay events={events.events} autoplay={autoplay} loop={loop} speeds={speeds} />
|
|
384
|
+
</div>
|
|
385
|
+
);
|
|
386
|
+
}
|
|
387
|
+
if (eventsUrl) {
|
|
388
|
+
return (
|
|
389
|
+
<div className={rootClass} data-replay-preview="rrweb">
|
|
390
|
+
<RemoteReplay source={eventsUrl} autoplay={autoplay} loop={loop} speeds={speeds} />
|
|
391
|
+
</div>
|
|
392
|
+
);
|
|
393
|
+
}
|
|
394
|
+
return null;
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
/** Semantic alias for markdown/document renderers. */
|
|
398
|
+
export const ReplayEmbed = ReplayPreview;
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export { ReplayEmbed, ReplayPreview } from './ReplayPreview';
|
|
2
|
+
export type {
|
|
3
|
+
ReplayEventsSource,
|
|
4
|
+
ReplayEventsUrlSource,
|
|
5
|
+
ReplayPageSource,
|
|
6
|
+
ReplayPreviewProps,
|
|
7
|
+
ReplaySource,
|
|
8
|
+
} from './ReplayPreview';
|
|
9
|
+
export { isReplayQaUrl, normalizeReplayEmbedUrl } from './url';
|
|
10
|
+
export { sanitizeReplayEvent } from './sanitize';
|