@brett_lamy/docstream 1.22.1 → 1.24.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/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/replay/ReplayDevtools.d.ts +6 -3
- package/dist/replay/ReplayDevtools.d.ts.map +1 -1
- package/dist/replay/ReplayDevtools.js +38 -16
- package/dist/replay/ReplayDevtools.js.map +1 -1
- package/dist/replay/ReplayFile.d.ts +7 -1
- package/dist/replay/ReplayFile.d.ts.map +1 -1
- package/dist/replay/ReplayFile.js +17 -3
- package/dist/replay/ReplayFile.js.map +1 -1
- package/dist/replay/ReplayPlayer.d.ts +18 -4
- package/dist/replay/ReplayPlayer.d.ts.map +1 -1
- package/dist/replay/ReplayPlayer.js +130 -20
- package/dist/replay/ReplayPlayer.js.map +1 -1
- package/dist/replay/devtools.d.ts +5 -0
- package/dist/replay/devtools.d.ts.map +1 -1
- package/dist/replay/devtools.js +3 -2
- package/dist/replay/devtools.js.map +1 -1
- package/dist/replay/index.d.ts +3 -1
- package/dist/replay/index.d.ts.map +1 -1
- package/dist/replay/index.js +2 -1
- package/dist/replay/index.js.map +1 -1
- package/dist/replay/inspections.d.ts +36 -0
- package/dist/replay/inspections.d.ts.map +1 -0
- package/dist/replay/inspections.js +64 -0
- package/dist/replay/inspections.js.map +1 -0
- package/dist/styles.css +34 -8
- package/package.json +1 -1
- package/src/index.ts +1 -1
- package/src/replay/ReplayDevtools.tsx +42 -19
- package/src/replay/ReplayFile.tsx +16 -3
- package/src/replay/ReplayPlayer.tsx +152 -28
- package/src/replay/devtools.ts +8 -2
- package/src/replay/index.ts +3 -1
- package/src/replay/inspections.ts +105 -0
- package/src/styles.css +34 -8
package/dist/styles.css
CHANGED
|
@@ -4260,6 +4260,24 @@
|
|
|
4260
4260
|
transform: translateY(5px);
|
|
4261
4261
|
}
|
|
4262
4262
|
|
|
4263
|
+
/* The time window: a band under the track, on the same geometry as the markers (4px padding, half a thumb). */
|
|
4264
|
+
:where([data-slot="replay-preview-window"]) {
|
|
4265
|
+
position: absolute;
|
|
4266
|
+
top: 50%;
|
|
4267
|
+
left: calc(10px + (100% - 20px) * var(--replay-window-start, 0));
|
|
4268
|
+
width: max(4px, calc((100% - 20px) * var(--replay-window-size, 1)));
|
|
4269
|
+
height: 10px;
|
|
4270
|
+
border-radius: 999px;
|
|
4271
|
+
background: color-mix(in srgb, var(--primary, #4f46e5) 34%, transparent);
|
|
4272
|
+
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--primary, #4f46e5) 60%, transparent);
|
|
4273
|
+
translate: 0 -50%;
|
|
4274
|
+
pointer-events: none;
|
|
4275
|
+
}
|
|
4276
|
+
|
|
4277
|
+
:where([data-slot="replay-preview-scrubber"][data-markers] > [data-slot="replay-preview-window"]) {
|
|
4278
|
+
translate: 0 calc(-50% + 5px);
|
|
4279
|
+
}
|
|
4280
|
+
|
|
4263
4281
|
:where([data-slot="replay-preview-markers"] > button) {
|
|
4264
4282
|
position: absolute;
|
|
4265
4283
|
top: 50%;
|
|
@@ -4309,6 +4327,8 @@
|
|
|
4309
4327
|
}
|
|
4310
4328
|
|
|
4311
4329
|
:where([data-slot="replay-preview-scrubber"] > input[type="range"]) {
|
|
4330
|
+
/* Above the time-window band. */
|
|
4331
|
+
position: relative;
|
|
4312
4332
|
--replay-track: 4px;
|
|
4313
4333
|
--replay-thumb: 12px;
|
|
4314
4334
|
appearance: none;
|
|
@@ -4331,8 +4351,11 @@
|
|
|
4331
4351
|
[data-slot="replay-preview-scrubber"] > input[type="range"]::-webkit-slider-runnable-track {
|
|
4332
4352
|
height: var(--replay-track);
|
|
4333
4353
|
border-radius: 999px;
|
|
4354
|
+
/* Played: from the time window's start (0 without one) to the playhead. */
|
|
4334
4355
|
background: linear-gradient(
|
|
4335
4356
|
to right,
|
|
4357
|
+
color-mix(in srgb, var(--muted-foreground, #71717a) 26%, transparent) var(--replay-progress-start, 0%),
|
|
4358
|
+
var(--primary, #4f46e5) var(--replay-progress-start, 0%),
|
|
4336
4359
|
var(--primary, #4f46e5) var(--replay-progress, 0%),
|
|
4337
4360
|
color-mix(in srgb, var(--muted-foreground, #71717a) 26%, transparent) var(--replay-progress, 0%)
|
|
4338
4361
|
);
|
|
@@ -4601,19 +4624,17 @@
|
|
|
4601
4624
|
box-shadow: inset 0 0 0 2px var(--ring, #6366f1);
|
|
4602
4625
|
}
|
|
4603
4626
|
|
|
4604
|
-
|
|
4605
|
-
|
|
4606
|
-
align-items: center;
|
|
4607
|
-
margin-inline-start: auto;
|
|
4608
|
-
padding-inline: 8px;
|
|
4609
|
-
color: var(--replay-devtools-dim);
|
|
4610
|
-
font-variant-numeric: tabular-nums;
|
|
4611
|
-
}
|
|
4627
|
+
|
|
4628
|
+
|
|
4612
4629
|
|
|
4613
4630
|
:where([data-slot="replay-devtools-tabs"] > [data-slot="replay-devtools-close"], [data-slot="replay-devtools-detail-tabs"] > [data-slot="replay-devtools-close"]) {
|
|
4614
4631
|
padding: 0 8px;
|
|
4615
4632
|
}
|
|
4616
4633
|
|
|
4634
|
+
:where([data-slot="replay-devtools-tabs"] > [data-slot="replay-devtools-close"]) {
|
|
4635
|
+
margin-inline-start: auto;
|
|
4636
|
+
}
|
|
4637
|
+
|
|
4617
4638
|
:where([data-slot="replay-devtools-pane"]) {
|
|
4618
4639
|
display: flex;
|
|
4619
4640
|
flex: 1 1 0%;
|
|
@@ -4882,6 +4903,11 @@
|
|
|
4882
4903
|
:where([data-slot="replay-devtools-table"] tbody tr:focus-visible) {
|
|
4883
4904
|
box-shadow: inset 0 0 0 2px var(--ring, #6366f1);
|
|
4884
4905
|
}
|
|
4906
|
+
/* The inspected request or message: a bar down its leading edge (the selection tint shows it's open). */
|
|
4907
|
+
:where([data-slot="replay-devtools-table"] tbody tr[data-inspected], [data-slot="replay-devtools-message"][data-inspected]) {
|
|
4908
|
+
box-shadow: inset 3px 0 0 var(--primary, #2563eb);
|
|
4909
|
+
}
|
|
4910
|
+
|
|
4885
4911
|
|
|
4886
4912
|
:where([data-slot="replay-devtools-table"] tr[data-failed]) {
|
|
4887
4913
|
color: var(--replay-devtools-red);
|
package/package.json
CHANGED
package/src/index.ts
CHANGED
|
@@ -90,7 +90,7 @@ export type {
|
|
|
90
90
|
} from "./replay/ReplayPreview"
|
|
91
91
|
export type { ReplayEvent } from "./replay/types"
|
|
92
92
|
export { isReplayQaUrl, normalizeReplayEmbedUrl } from "./replay/url"
|
|
93
|
-
export { ReplayFile, isRrwebUrl } from "./replay/ReplayFile"
|
|
93
|
+
export { ReplayFile, isRrwebUrl, splitReplayFragment } from "./replay/ReplayFile"
|
|
94
94
|
export { ImageThumbnail, leadingThumbnail, thumbnailLabel } from "./docs/ImageThumbnail"
|
|
95
95
|
export type { ReplayFileProps } from "./replay/ReplayFile"
|
|
96
96
|
export { VideoEmbed } from "./video"
|
|
@@ -5,10 +5,12 @@
|
|
|
5
5
|
waiting at the playhead shows as pending), and a request's row opens its headers, payload and response.
|
|
6
6
|
|
|
7
7
|
Everything is a function of the playhead: scrub back and the lists shrink to what DevTools showed then. A row's
|
|
8
|
-
time jumps the player there. Esc closes the panel.
|
|
8
|
+
time jumps the player there. Esc closes the panel. An inspection (inspections.ts) selects its request or
|
|
9
|
+
message and marks it. Plain React + plain CSS, like the player. */
|
|
9
10
|
import { useEffect, useMemo, useRef, useState, type KeyboardEvent as ReactKeyboardEvent, type PointerEvent as ReactPointerEvent } from 'react'
|
|
10
11
|
import { ChevronRight, CircleX, Info, Network, SquareTerminal, TriangleAlert, X } from 'lucide-react'
|
|
11
12
|
import type { ReplayConsoleEntry, ReplayDevtoolsState, ReplayRequest } from './devtools'
|
|
13
|
+
import type { ResolvedReplayInspection } from './inspections'
|
|
12
14
|
|
|
13
15
|
export type ReplayDevtoolsTab = 'console' | 'network'
|
|
14
16
|
|
|
@@ -55,14 +57,16 @@ export function DevtoolsButtons({ state, open, onOpen }: {
|
|
|
55
57
|
)
|
|
56
58
|
}
|
|
57
59
|
|
|
58
|
-
/** The panel over the page: Console and Network, cut at the playhead.
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
60
|
+
/** The panel over the page: Console and Network, cut at the playhead. `inspection` selects and marks its target;
|
|
61
|
+
* `autoFocus` (default true) moves focus into the panel as it opens — off when an inspection opened it mid-play. */
|
|
62
|
+
export function DevtoolsPanel({ state, tab, onTab, onClose, onSeek, inspection, autoFocus = true }: {
|
|
63
|
+
state: ReplayDevtoolsState; tab: ReplayDevtoolsTab; onTab: (tab: ReplayDevtoolsTab) => void
|
|
64
|
+
onClose: () => void; onSeek: (ms: number) => void; inspection?: ResolvedReplayInspection | null; autoFocus?: boolean
|
|
62
65
|
}) {
|
|
63
66
|
const panel = useRef<HTMLDivElement>(null)
|
|
64
|
-
|
|
65
|
-
|
|
67
|
+
// Opened by an inspection, the panel starts taller: its point is the request's details.
|
|
68
|
+
const [height, setHeight] = useState(inspection ? 72 : 58)
|
|
69
|
+
useEffect(() => { if (autoFocus) panel.current?.focus({ preventScroll: true }) }, [autoFocus])
|
|
66
70
|
|
|
67
71
|
// Drag the top edge to resize, as a fraction of the stage.
|
|
68
72
|
const resize = (e: ReactPointerEvent<HTMLDivElement>) => {
|
|
@@ -116,10 +120,11 @@ export function DevtoolsPanel({ state, at, tab, onTab, onClose, onSeek }: {
|
|
|
116
120
|
</button>
|
|
117
121
|
))}
|
|
118
122
|
</div>
|
|
119
|
-
<span data-part="at">As of {time(at)}</span>
|
|
120
123
|
<button type="button" data-slot="replay-devtools-close" aria-label="Close DevTools" onClick={onClose}><X size={14} /></button>
|
|
121
124
|
</div>
|
|
122
|
-
{tab === 'console'
|
|
125
|
+
{tab === 'console'
|
|
126
|
+
? <ConsolePane state={state} onSeek={onSeek} inspected={inspection?.tab === 'console' ? inspection : null} />
|
|
127
|
+
: <NetworkPane state={state} onSeek={onSeek} inspected={inspection?.tab === 'network' ? inspection : null} />}
|
|
123
128
|
</div>
|
|
124
129
|
)
|
|
125
130
|
}
|
|
@@ -129,7 +134,9 @@ export function DevtoolsPanel({ state, at, tab, onTab, onClose, onSeek }: {
|
|
|
129
134
|
type LevelFilter = 'all' | 'error' | 'warn' | 'info'
|
|
130
135
|
const LEVEL_ICON = { error: CircleX, warn: TriangleAlert, info: Info } as const
|
|
131
136
|
|
|
132
|
-
function ConsolePane({ state, onSeek }: {
|
|
137
|
+
function ConsolePane({ state, onSeek, inspected }: {
|
|
138
|
+
state: ReplayDevtoolsState; onSeek: (ms: number) => void; inspected: ResolvedReplayInspection | null
|
|
139
|
+
}) {
|
|
133
140
|
const [query, setQuery] = useState('')
|
|
134
141
|
const [level, setLevel] = useState<LevelFilter>('all')
|
|
135
142
|
const list = useRef<HTMLDivElement>(null)
|
|
@@ -139,12 +146,17 @@ function ConsolePane({ state, onSeek }: { state: ReplayDevtoolsState; onSeek: (m
|
|
|
139
146
|
(level === 'all' || (level === 'info' ? e.level === 'info' || e.level === 'log' || e.level === 'debug' : e.level === level)) &&
|
|
140
147
|
(!q || e.message.toLowerCase().includes(q)))
|
|
141
148
|
}, [state.console, query, level])
|
|
142
|
-
// Follow new messages as they're logged, unless you've scrolled up to read.
|
|
149
|
+
// Follow new messages as they're logged, unless you've scrolled up to read (or an inspection is showing one).
|
|
143
150
|
const pinned = useRef(true)
|
|
151
|
+
const inspectedId = inspected?.messageId
|
|
152
|
+
const shown = inspectedId !== undefined && rows.some((r) => r.id === inspectedId)
|
|
144
153
|
useEffect(() => {
|
|
145
154
|
const el = list.current
|
|
146
|
-
if (el && pinned.current) el.scrollTop = el.scrollHeight
|
|
147
|
-
}, [rows.length])
|
|
155
|
+
if (el && pinned.current && !shown) el.scrollTop = el.scrollHeight
|
|
156
|
+
}, [rows.length, shown])
|
|
157
|
+
useEffect(() => {
|
|
158
|
+
if (shown) list.current?.querySelector('[data-inspected]')?.scrollIntoView?.({ block: 'nearest' })
|
|
159
|
+
}, [shown, inspected?.id])
|
|
148
160
|
return (
|
|
149
161
|
<div data-slot="replay-devtools-pane" data-pane="console">
|
|
150
162
|
<div data-slot="replay-devtools-toolbar">
|
|
@@ -167,7 +179,7 @@ function ConsolePane({ state, onSeek }: { state: ReplayDevtoolsState; onSeek: (m
|
|
|
167
179
|
aria-label="Console messages"
|
|
168
180
|
onScroll={(e) => { const el = e.currentTarget; pinned.current = el.scrollHeight - el.scrollTop - el.clientHeight < 8 }}
|
|
169
181
|
>
|
|
170
|
-
{rows.map((entry) => <ConsoleRow key={entry.id} entry={entry} onSeek={onSeek} />)}
|
|
182
|
+
{rows.map((entry) => <ConsoleRow key={entry.id} entry={entry} onSeek={onSeek} inspected={entry.id === inspectedId} />)}
|
|
171
183
|
{!rows.length ? <p data-slot="replay-devtools-empty">{state.console.length ? 'No messages match the filter' : 'Nothing logged yet'}</p> : null}
|
|
172
184
|
{state.counts.laterMessages ? <p data-slot="replay-devtools-later">{state.counts.laterMessages} more later in the recording</p> : null}
|
|
173
185
|
</div>
|
|
@@ -175,7 +187,7 @@ function ConsolePane({ state, onSeek }: { state: ReplayDevtoolsState; onSeek: (m
|
|
|
175
187
|
)
|
|
176
188
|
}
|
|
177
189
|
|
|
178
|
-
function ConsoleRow({ entry, onSeek }: { entry: ReplayConsoleEntry; onSeek: (ms: number) => void }) {
|
|
190
|
+
function ConsoleRow({ entry, onSeek, inspected }: { entry: ReplayConsoleEntry; onSeek: (ms: number) => void; inspected: boolean }) {
|
|
179
191
|
const Glyph = entry.level === 'error' || entry.level === 'warn' || entry.level === 'info' ? LEVEL_ICON[entry.level] : null
|
|
180
192
|
const body = (
|
|
181
193
|
<>
|
|
@@ -184,7 +196,7 @@ function ConsoleRow({ entry, onSeek }: { entry: ReplayConsoleEntry; onSeek: (ms:
|
|
|
184
196
|
</>
|
|
185
197
|
)
|
|
186
198
|
return (
|
|
187
|
-
<div data-slot="replay-devtools-message" data-level={entry.level}>
|
|
199
|
+
<div data-slot="replay-devtools-message" data-level={entry.level} data-inspected={inspected || undefined}>
|
|
188
200
|
{entry.trace?.length ? (
|
|
189
201
|
<details>
|
|
190
202
|
<summary>{body}</summary>
|
|
@@ -203,10 +215,20 @@ type Row = ReplayRequest & { pending: boolean }
|
|
|
203
215
|
|
|
204
216
|
const statusText = (r: Row) => (r.pending ? '(pending)' : r.status === undefined ? (r.failed ? '(failed)' : '—') : r.status === 0 ? '(failed)' : String(r.status))
|
|
205
217
|
|
|
206
|
-
function NetworkPane({ state, onSeek }: {
|
|
218
|
+
function NetworkPane({ state, onSeek, inspected }: {
|
|
219
|
+
state: ReplayDevtoolsState; onSeek: (ms: number) => void; inspected: ResolvedReplayInspection | null
|
|
220
|
+
}) {
|
|
207
221
|
const [query, setQuery] = useState('')
|
|
208
222
|
const [filter, setFilter] = useState<RequestFilter>('all')
|
|
209
|
-
const
|
|
223
|
+
const inspectedId = inspected?.requestId
|
|
224
|
+
const [selectedId, setSelectedId] = useState<string | null>(inspectedId ?? null)
|
|
225
|
+
const table = useRef<HTMLDivElement>(null)
|
|
226
|
+
// A new inspection selects its request (once it has been sent) and brings its row into view.
|
|
227
|
+
useEffect(() => { if (inspectedId) setSelectedId(inspectedId) }, [inspected?.id, inspectedId])
|
|
228
|
+
const sent = inspectedId !== undefined && state.network.some((r) => r.id === inspectedId)
|
|
229
|
+
useEffect(() => {
|
|
230
|
+
if (sent) table.current?.querySelector('[data-inspected]')?.scrollIntoView?.({ block: 'nearest' })
|
|
231
|
+
}, [sent, inspected?.id])
|
|
210
232
|
const rows = useMemo(() => {
|
|
211
233
|
const q = query.trim().toLowerCase()
|
|
212
234
|
return state.network.filter((r) =>
|
|
@@ -235,7 +257,7 @@ function NetworkPane({ state, onSeek }: { state: ReplayDevtoolsState; onSeek: (m
|
|
|
235
257
|
</div>
|
|
236
258
|
</div>
|
|
237
259
|
<div data-slot="replay-devtools-network">
|
|
238
|
-
<div data-slot="replay-devtools-table-wrap">
|
|
260
|
+
<div ref={table} data-slot="replay-devtools-table-wrap">
|
|
239
261
|
<table data-slot="replay-devtools-table">
|
|
240
262
|
<thead>
|
|
241
263
|
<tr>
|
|
@@ -251,6 +273,7 @@ function NetworkPane({ state, onSeek }: { state: ReplayDevtoolsState; onSeek: (m
|
|
|
251
273
|
data-failed={(r.failed && !r.pending) || undefined}
|
|
252
274
|
data-pending={r.pending || undefined}
|
|
253
275
|
aria-selected={r.id === selectedId}
|
|
276
|
+
data-inspected={r.id === inspectedId || undefined}
|
|
254
277
|
tabIndex={0}
|
|
255
278
|
onClick={() => setSelectedId(r.id === selectedId ? null : r.id)}
|
|
256
279
|
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); setSelectedId(r.id === selectedId ? null : r.id) } }}
|
|
@@ -11,10 +11,12 @@ export interface ReplayFileProps extends Omit<ReplayPlayerOptions, 'url'> {
|
|
|
11
11
|
/**
|
|
12
12
|
* An rrweb recording file (a JSON event array, or `{ events }`), played in the replay browser frame.
|
|
13
13
|
* The URL goes through `AssetResolverProvider`, and the file is fetched only once the frame nears the
|
|
14
|
-
* viewport, so a page of recordings doesn't download them all up front.
|
|
14
|
+
* viewport, so a page of recordings doesn't download them all up front. A media fragment on the URL,
|
|
15
|
+
* `recording.rrweb#t=4.7,7.4` (seconds), is its `timeWindow`.
|
|
15
16
|
*/
|
|
16
17
|
export function ReplayFile({ url, ...options }: ReplayFileProps) {
|
|
17
|
-
const
|
|
18
|
+
const { file, segment } = splitReplayFragment(url);
|
|
19
|
+
const src = useAsset(file);
|
|
18
20
|
const ref = useRef<HTMLDivElement>(null);
|
|
19
21
|
const [near, setNear] = useState(false);
|
|
20
22
|
|
|
@@ -28,7 +30,18 @@ export function ReplayFile({ url, ...options }: ReplayFileProps) {
|
|
|
28
30
|
}, [near]);
|
|
29
31
|
|
|
30
32
|
if (!near || src === undefined) return <div ref={ref} className="docstream-asset-pending" data-slot="replay-file" aria-busy="true" />;
|
|
31
|
-
return <ReplayPreview source={{ kind: 'events-url', url: src }} chrome="browser" {...options} />;
|
|
33
|
+
return <ReplayPreview source={{ kind: 'events-url', url: src }} chrome="browser" timeWindow={segment} {...options} />;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Splits `#t=start,end` (a media fragment, in seconds; `#t=start` runs to the end) off a recording's URL. */
|
|
37
|
+
export function splitReplayFragment(url: string): { file: string; segment?: [number, number] } {
|
|
38
|
+
const hash = url.indexOf('#');
|
|
39
|
+
if (hash < 0) return { file: url };
|
|
40
|
+
const m = url.slice(hash + 1).match(/(?:^|&)t=(?:npt:)?([\d.]*)(?:,([\d.]+))?(?:&|$)/);
|
|
41
|
+
if (!m) return { file: url };
|
|
42
|
+
const start = m[1] ? Number(m[1]) * 1000 : 0;
|
|
43
|
+
const end = m[2] ? Number(m[2]) * 1000 : Infinity;
|
|
44
|
+
return { file: url.slice(0, hash), segment: Number.isFinite(start) && end > start ? [start, end] : undefined };
|
|
32
45
|
}
|
|
33
46
|
|
|
34
47
|
const RRWEB = /\.rrweb(?:[?#]|$)/i;
|
|
@@ -2,7 +2,9 @@
|
|
|
2
2
|
plays the recording (rrweb loads lazily, on first render); this adds the chrome around it: a URL bar that
|
|
3
3
|
follows the recording's navigations, play/pause, a scrubber with the recording's moments on a rail above
|
|
4
4
|
it, a speed control, fullscreen, a drawn cursor whose clicks ripple, and DevTools: Console and Network buttons
|
|
5
|
-
in the top bar with what had been logged and requested by the playhead, which open a DevTools panel over the page
|
|
5
|
+
in the top bar with what had been logged and requested by the playhead, which open a DevTools panel over the page,
|
|
6
|
+
and inspections (inspections.ts) that open it on a request or message as playback reaches them. A time window
|
|
7
|
+
highlights a segment on the scrubber, opens there and plays just that, until you scrub outside it. Until the player is ready a
|
|
6
8
|
poster (a page-shaped skeleton by default) holds the frame's exact size, so nothing jumps.
|
|
7
9
|
|
|
8
10
|
Plain React + plain CSS (`@layer docstream.replay` in styles.css, `[data-slot]` hooks). Keys (focus inside
|
|
@@ -18,6 +20,7 @@ import { Lock, Maximize, Minimize, Pause, Play, RotateCcw } from 'lucide-react'
|
|
|
18
20
|
import { ReplayStage } from './ReplayStage'
|
|
19
21
|
import { DevtoolsButtons, DevtoolsPanel, type ReplayDevtoolsTab } from './ReplayDevtools'
|
|
20
22
|
import { getReplayDevtools, replayDevtoolsAt } from './devtools'
|
|
23
|
+
import { getReplayInspections, replayInspectionAt, resolveReplayInspections, type ReplayInspection } from './inspections'
|
|
21
24
|
import { getReplayMarkers, getReplayMeta, getReplayPointerTrack, replayPointerAt } from './markers'
|
|
22
25
|
import type { ReplayMarker, ReplayMarkerKind, ReplayPointerTrack } from './markers'
|
|
23
26
|
import type { ReplayStageHandle } from './stage-types'
|
|
@@ -36,6 +39,8 @@ export interface ReplayPreviewHandle {
|
|
|
36
39
|
seek(ms: number): void
|
|
37
40
|
toggle(): void
|
|
38
41
|
getCurrentTime(): number
|
|
42
|
+
/** Jump to an inspection (by its id) and open DevTools on what it found. */
|
|
43
|
+
inspect(id: string): void
|
|
39
44
|
}
|
|
40
45
|
|
|
41
46
|
/** Options shared by every `ReplayPreview` (an `events` player or a `source` embed). */
|
|
@@ -52,9 +57,14 @@ export interface ReplayPlayerOptions {
|
|
|
52
57
|
/** Play/pause, scrubber, speed and fullscreen (default true). */
|
|
53
58
|
controls?: boolean
|
|
54
59
|
autoplay?: boolean
|
|
60
|
+
/** Restart at the end (with a `timeWindow`, at the window's end, from its start). */
|
|
55
61
|
loop?: boolean
|
|
56
|
-
/** Where the player starts, in ms from the start. */
|
|
62
|
+
/** Where the player starts, in ms from the start (default 0, or the `timeWindow`'s start). */
|
|
57
63
|
initialTime?: number
|
|
64
|
+
/** `[start, end]` in ms: the segment that matters. The player opens at `start` with the segment highlighted on the
|
|
65
|
+
* scrubber, and playing from inside it stops at `end` (play again to replay it). Scrub or seek outside it and
|
|
66
|
+
* playback runs on through the rest of the recording. */
|
|
67
|
+
timeWindow?: readonly [number, number]
|
|
58
68
|
/** Speeds the speed button cycles through; the first is the initial speed (default 1, 2, 4, 0.5). */
|
|
59
69
|
speeds?: number[]
|
|
60
70
|
/** Markers on the scrubber rail: `true` (default) reads them from the recording, `false` hides the rail,
|
|
@@ -63,11 +73,17 @@ export interface ReplayPlayerOptions {
|
|
|
63
73
|
/** Which recorded marker kinds to show (default all). */
|
|
64
74
|
markerKinds?: ReplayMarkerKind[]
|
|
65
75
|
/** Console and Network buttons (in the top bar, or beside the controls without the browser chrome) that open a
|
|
66
|
-
* DevTools panel cut at the playhead
|
|
67
|
-
*
|
|
76
|
+
* DevTools panel cut at the playhead, shown when the recording has console messages, requests or inspections.
|
|
77
|
+
* `disableDevtools` removes them, the panel and the inspections. */
|
|
78
|
+
disableDevtools?: boolean
|
|
79
|
+
/** @deprecated Use `disableDevtools`. `false` is the same as `disableDevtools`. */
|
|
68
80
|
devtools?: boolean
|
|
69
81
|
/** Open the DevTools panel on this tab at mount. */
|
|
70
82
|
defaultDevtoolsTab?: ReplayDevtoolsTab
|
|
83
|
+
/** Moments where a request or console message was inspected. As playback reaches one, DevTools opens on its
|
|
84
|
+
* tab with that request or message selected and the label above it; playback doesn't stop. Added to the
|
|
85
|
+
* recording's own `inspect` custom events, and to the rail as checkpoints. */
|
|
86
|
+
inspections?: ReplayInspection[]
|
|
71
87
|
/** Draw the recorded pointer and click ripples (default true). */
|
|
72
88
|
cursor?: boolean
|
|
73
89
|
/** `width` (default): the page scales to the frame's width and sets its height. `contain`: fill the frame's
|
|
@@ -97,8 +113,8 @@ const MARKER_NOUN: Record<ReplayMarkerKind, string> = {
|
|
|
97
113
|
|
|
98
114
|
export function ReplayPlayer({
|
|
99
115
|
events, url, title = 'Session replay', chrome = 'browser', trafficLights = true, controls = true,
|
|
100
|
-
autoplay = false, loop = false, initialTime
|
|
101
|
-
cursor = true, devtools = true, defaultDevtoolsTab, fit = 'width', poster, lazy = true, playerRef, onTimeUpdate, onPlayingChange, onMarkerPress,
|
|
116
|
+
autoplay = false, loop = false, initialTime: initialTimeProp, timeWindow, speeds = DEFAULT_PLAYER_SPEEDS, markers = true, markerKinds,
|
|
117
|
+
cursor = true, disableDevtools = false, devtools = true, defaultDevtoolsTab, inspections, fit = 'width', poster, lazy = true, playerRef, onTimeUpdate, onPlayingChange, onMarkerPress,
|
|
102
118
|
className, style,
|
|
103
119
|
}: ReplayPlayerOptions & { events: ReplayEvent[] }) {
|
|
104
120
|
const rootRef = useRef<HTMLDivElement>(null)
|
|
@@ -107,14 +123,35 @@ export function ReplayPlayer({
|
|
|
107
123
|
const meta = useMemo(() => getReplayMeta(events), [events])
|
|
108
124
|
const track = useMemo(() => getReplayPointerTrack(events), [events])
|
|
109
125
|
const recorded = useMemo(() => getReplayMarkers(events), [events])
|
|
110
|
-
const shown = useMemo(() => {
|
|
111
|
-
const list = Array.isArray(markers) ? markers : markers ? recorded : []
|
|
112
|
-
return markerKinds ? list.filter((m) => markerKinds.includes(m.kind)) : list
|
|
113
|
-
}, [markers, recorded, markerKinds])
|
|
114
126
|
|
|
127
|
+
const toolsOn = devtools && !disableDevtools
|
|
115
128
|
const tools = useMemo(() => getReplayDevtools(events), [events])
|
|
116
|
-
const
|
|
129
|
+
const resolved = useMemo(
|
|
130
|
+
() => (toolsOn ? resolveReplayInspections([...getReplayInspections(events), ...(inspections ?? [])], tools) : []),
|
|
131
|
+
[toolsOn, events, inspections, tools],
|
|
132
|
+
)
|
|
133
|
+
const showTools = toolsOn && (tools.console.length > 0 || tools.network.length > 0 || resolved.length > 0)
|
|
117
134
|
const [panel, setPanel] = useState<ReplayDevtoolsTab | null>(defaultDevtoolsTab ?? null)
|
|
135
|
+
// Whether an inspection (rather than a press) opened the panel: it then leaves focus where it was.
|
|
136
|
+
const [autoOpened, setAutoOpened] = useState(false)
|
|
137
|
+
|
|
138
|
+
const shown = useMemo(() => {
|
|
139
|
+
// Inspections from the prop join the rail as checkpoints (the recording's own are already among `recorded`).
|
|
140
|
+
const own: ReplayMarker[] = toolsOn ? (inspections ?? []).map((inspection, i) => ({
|
|
141
|
+
id: `inspection-${i}`, kind: 'custom', time: Math.max(0, inspection.time), label: inspection.label ?? 'Inspected',
|
|
142
|
+
})) : []
|
|
143
|
+
const list = Array.isArray(markers) ? markers : markers ? [...recorded, ...own].sort((a, b) => a.time - b.time) : []
|
|
144
|
+
return markerKinds ? list.filter((m) => markerKinds.includes(m.kind)) : list
|
|
145
|
+
}, [markers, recorded, markerKinds, inspections, toolsOn])
|
|
146
|
+
|
|
147
|
+
// The time window, clamped to the recording; dropped when it's empty.
|
|
148
|
+
const span = useMemo(() => {
|
|
149
|
+
if (!timeWindow) return null
|
|
150
|
+
const start = Math.max(0, Math.min(timeWindow[0], meta.duration))
|
|
151
|
+
const end = Math.max(start, Math.min(timeWindow[1], meta.duration))
|
|
152
|
+
return end - start >= 1 ? { start, end } : null
|
|
153
|
+
}, [timeWindow, meta.duration])
|
|
154
|
+
const initialTime = initialTimeProp ?? span?.start ?? 0
|
|
118
155
|
|
|
119
156
|
const [visible, setVisible] = useState(!lazy)
|
|
120
157
|
const [ready, setReady] = useState(false)
|
|
@@ -122,6 +159,8 @@ export function ReplayPlayer({
|
|
|
122
159
|
const [time, setTime] = useState(initialTime)
|
|
123
160
|
const [playing, setPlaying] = useState(false)
|
|
124
161
|
const [finished, setFinished] = useState(false)
|
|
162
|
+
// Stopped at the window's end: the center button replays the segment.
|
|
163
|
+
const [windowDone, setWindowDone] = useState(false)
|
|
125
164
|
const [speedIndex, setSpeedIndex] = useState(0)
|
|
126
165
|
const [fullscreen, setFullscreen] = useState(false)
|
|
127
166
|
const duration = meta.duration
|
|
@@ -151,17 +190,73 @@ export function ReplayPlayer({
|
|
|
151
190
|
return () => clearTimeout(timer)
|
|
152
191
|
}, [ready, reduced])
|
|
153
192
|
|
|
154
|
-
const
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
193
|
+
const resolvedRef = useRef(resolved)
|
|
194
|
+
resolvedRef.current = resolved
|
|
195
|
+
// The window, the playhead and the play state, for the handle (which never re-creates) and onTime.
|
|
196
|
+
const spanRef = useRef(span)
|
|
197
|
+
spanRef.current = span
|
|
198
|
+
const timeRef = useRef(initialTime)
|
|
199
|
+
const playingRef = useRef(false)
|
|
200
|
+
// Whether playback is held to the window: it is when it starts (or is moved) inside it.
|
|
201
|
+
const inWindow = (ms: number) => !!spanRef.current && ms >= spanRef.current.start && ms < spanRef.current.end
|
|
202
|
+
const held = useRef(inWindow(initialTime))
|
|
203
|
+
const handle = useMemo<ReplayPreviewHandle>(() => {
|
|
204
|
+
const play = (offset?: number) => {
|
|
205
|
+
const w = spanRef.current
|
|
206
|
+
if (!w) { setFinished(false); return stage.current?.play(offset) }
|
|
207
|
+
let from = offset ?? timeRef.current
|
|
208
|
+
// Play at the window's end replays the window.
|
|
209
|
+
if (w && offset === undefined && Math.abs(from - w.end) < 60) from = w.start
|
|
210
|
+
held.current = inWindow(from)
|
|
211
|
+
setFinished(false)
|
|
212
|
+
setWindowDone(false)
|
|
213
|
+
if (from !== timeRef.current) stage.current?.play(from)
|
|
214
|
+
else stage.current?.play(offset)
|
|
215
|
+
}
|
|
216
|
+
const seek = (ms: number) => {
|
|
217
|
+
held.current = inWindow(ms)
|
|
218
|
+
setFinished(false)
|
|
219
|
+
setWindowDone(false)
|
|
220
|
+
stage.current?.seek(ms)
|
|
221
|
+
}
|
|
222
|
+
return {
|
|
223
|
+
play,
|
|
224
|
+
pause: (offset) => stage.current?.pause(offset),
|
|
225
|
+
seek,
|
|
226
|
+
toggle: () => {
|
|
227
|
+
if (!spanRef.current) { setFinished(false); return stage.current?.toggle() }
|
|
228
|
+
return playingRef.current ? stage.current?.pause() : play()
|
|
229
|
+
},
|
|
230
|
+
getCurrentTime: () => stage.current?.getCurrentTime() ?? 0,
|
|
231
|
+
inspect: (id) => {
|
|
232
|
+
const inspection = resolvedRef.current.find((x) => x.id === id)
|
|
233
|
+
if (!inspection) return
|
|
234
|
+
seek(inspection.time)
|
|
235
|
+
setPanel(inspection.tab)
|
|
236
|
+
setAutoOpened(false)
|
|
237
|
+
},
|
|
238
|
+
}
|
|
239
|
+
}, [])
|
|
161
240
|
useImperativeHandle(playerRef, () => handle, [handle])
|
|
162
241
|
|
|
163
|
-
const onTime = useCallback((ms: number) => {
|
|
242
|
+
const onTime = useCallback((ms: number) => {
|
|
243
|
+
timeRef.current = ms
|
|
244
|
+
// Held to the window: stop at its end, or go round again with `loop`.
|
|
245
|
+
const w = spanRef.current
|
|
246
|
+
if (w && held.current && playingRef.current && ms >= w.end) {
|
|
247
|
+
if (loop) stage.current?.seek(w.start)
|
|
248
|
+
else {
|
|
249
|
+
stage.current?.pause(w.end)
|
|
250
|
+
setWindowDone(true)
|
|
251
|
+
ms = w.end
|
|
252
|
+
timeRef.current = ms
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
setTime(ms)
|
|
256
|
+
onTimeUpdate?.(ms)
|
|
257
|
+
}, [onTimeUpdate, loop])
|
|
164
258
|
const onPlaying = useCallback((p: boolean) => {
|
|
259
|
+
playingRef.current = p
|
|
165
260
|
setPlaying(p)
|
|
166
261
|
if (p) setFinished(false)
|
|
167
262
|
onPlayingChange?.(p)
|
|
@@ -209,7 +304,23 @@ export function ReplayPlayer({
|
|
|
209
304
|
}, [url, meta.href, recorded, time])
|
|
210
305
|
|
|
211
306
|
const toolsState = useMemo(() => replayDevtoolsAt(tools, time), [tools, time])
|
|
212
|
-
const openTools = (tab: ReplayDevtoolsTab) => setPanel((p) => (p === tab ? null : tab))
|
|
307
|
+
const openTools = (tab: ReplayDevtoolsTab) => { setAutoOpened(false); setPanel((p) => (p === tab ? null : tab)) }
|
|
308
|
+
|
|
309
|
+
// Inspect mode: the inspection at the playhead opens DevTools on what it found. Only a change of inspection does
|
|
310
|
+
// (closing the panel keeps it closed until the next one); scrubbing back before the first closes what it opened.
|
|
311
|
+
const inspection = showTools ? replayInspectionAt(resolved, time) : undefined
|
|
312
|
+
const inspectionId = inspection?.id
|
|
313
|
+
const inspectionTab = inspection?.tab
|
|
314
|
+
const lastInspection = useRef<string | undefined>(undefined)
|
|
315
|
+
useEffect(() => {
|
|
316
|
+
if (inspectionId === lastInspection.current) return
|
|
317
|
+
const had = lastInspection.current
|
|
318
|
+
lastInspection.current = inspectionId
|
|
319
|
+
if (inspectionTab) {
|
|
320
|
+
setPanel(inspectionTab)
|
|
321
|
+
setAutoOpened(true)
|
|
322
|
+
} else if (had !== undefined && autoOpened) setPanel(null)
|
|
323
|
+
}, [inspectionId, inspectionTab, autoOpened])
|
|
213
324
|
const toolButtons = showTools ? <DevtoolsButtons state={toolsState} open={panel} onOpen={openTools} /> : null
|
|
214
325
|
|
|
215
326
|
const w = meta.width || 1280
|
|
@@ -247,7 +358,7 @@ export function ReplayPlayer({
|
|
|
247
358
|
events={events}
|
|
248
359
|
fit="contain"
|
|
249
360
|
speed={speed}
|
|
250
|
-
loop={loop}
|
|
361
|
+
loop={loop && !span}
|
|
251
362
|
autoplay={autoplay}
|
|
252
363
|
initialTime={initialTime}
|
|
253
364
|
showMouse={false}
|
|
@@ -269,11 +380,12 @@ export function ReplayPlayer({
|
|
|
269
380
|
{showTools && panel ? (
|
|
270
381
|
<DevtoolsPanel
|
|
271
382
|
state={toolsState}
|
|
272
|
-
at={time}
|
|
273
383
|
tab={panel}
|
|
274
384
|
onTab={setPanel}
|
|
275
385
|
onClose={() => { setPanel(null); rootRef.current?.focus({ preventScroll: true }) }}
|
|
276
386
|
onSeek={(ms) => handle.seek(ms)}
|
|
387
|
+
inspection={inspection}
|
|
388
|
+
autoFocus={!autoOpened}
|
|
277
389
|
/>
|
|
278
390
|
) : null}
|
|
279
391
|
|
|
@@ -282,11 +394,11 @@ export function ReplayPlayer({
|
|
|
282
394
|
<button
|
|
283
395
|
type="button"
|
|
284
396
|
tabIndex={-1}
|
|
285
|
-
aria-label={finished ? 'Replay from the start' : 'Play'}
|
|
397
|
+
aria-label={finished ? 'Replay from the start' : windowDone ? 'Replay the segment' : 'Play'}
|
|
286
398
|
// The overlay leaves as playback starts; keep focus (and the keys) in the player.
|
|
287
399
|
onClick={() => { rootRef.current?.focus({ preventScroll: true }); toggle() }}
|
|
288
400
|
>
|
|
289
|
-
{finished ? <RotateCcw size={22} strokeWidth={2.4} /> : <Play size={22} strokeWidth={2.4} fill="currentColor" />}
|
|
401
|
+
{finished || windowDone ? <RotateCcw size={22} strokeWidth={2.4} /> : <Play size={22} strokeWidth={2.4} fill="currentColor" />}
|
|
290
402
|
</button>
|
|
291
403
|
</div>
|
|
292
404
|
) : null}
|
|
@@ -303,6 +415,7 @@ export function ReplayPlayer({
|
|
|
303
415
|
<Scrubber
|
|
304
416
|
time={time}
|
|
305
417
|
duration={duration}
|
|
418
|
+
span={span}
|
|
306
419
|
markers={shown}
|
|
307
420
|
disabled={!ready}
|
|
308
421
|
onSeek={(ms) => handle.seek(ms)}
|
|
@@ -391,8 +504,8 @@ const PointerOverlay = memo(function PointerOverlay({ track, time, reduced }: {
|
|
|
391
504
|
)
|
|
392
505
|
})
|
|
393
506
|
|
|
394
|
-
function Scrubber({ time, duration, markers, disabled, onSeek, onMarker }: {
|
|
395
|
-
time: number; duration: number; markers: ReplayMarker[]; disabled: boolean
|
|
507
|
+
function Scrubber({ time, duration, span, markers, disabled, onSeek, onMarker }: {
|
|
508
|
+
time: number; duration: number; span: { start: number; end: number } | null; markers: ReplayMarker[]; disabled: boolean
|
|
396
509
|
onSeek: (ms: number) => void; onMarker: (m: ReplayMarker) => void
|
|
397
510
|
}) {
|
|
398
511
|
const max = Math.max(1, duration)
|
|
@@ -411,6 +524,14 @@ function Scrubber({ time, duration, markers, disabled, onSeek, onMarker }: {
|
|
|
411
524
|
}, [markers, max])
|
|
412
525
|
return (
|
|
413
526
|
<div data-slot="replay-preview-scrubber" data-markers={markers.length ? '' : undefined}>
|
|
527
|
+
{span ? (
|
|
528
|
+
<div
|
|
529
|
+
data-slot="replay-preview-window"
|
|
530
|
+
aria-hidden="true"
|
|
531
|
+
title={`${formatReplayTime(span.start)}–${formatReplayTime(span.end)}`}
|
|
532
|
+
style={{ '--replay-window-start': span.start / max, '--replay-window-size': (span.end - span.start) / max } as CSSProperties}
|
|
533
|
+
/>
|
|
534
|
+
) : null}
|
|
414
535
|
{markers.length ? (
|
|
415
536
|
<div data-slot="replay-preview-markers">
|
|
416
537
|
{markers.map((m) => (
|
|
@@ -430,13 +551,16 @@ function Scrubber({ time, duration, markers, disabled, onSeek, onMarker }: {
|
|
|
430
551
|
<input
|
|
431
552
|
type="range"
|
|
432
553
|
aria-label="Playback position"
|
|
433
|
-
aria-valuetext={`${formatReplayTime(value)} of ${formatReplayTime(duration)}`}
|
|
554
|
+
aria-valuetext={`${formatReplayTime(value)} of ${formatReplayTime(duration)}${span ? `, segment ${formatReplayTime(span.start)} to ${formatReplayTime(span.end)}` : ''}`}
|
|
434
555
|
min={0}
|
|
435
556
|
max={max}
|
|
436
557
|
step={10}
|
|
437
558
|
value={value}
|
|
438
559
|
disabled={disabled}
|
|
439
|
-
style={{
|
|
560
|
+
style={{
|
|
561
|
+
'--replay-progress': `${(value / max) * 100}%`,
|
|
562
|
+
...(span ? { '--replay-progress-start': `${(span.start / max) * 100}%` } : {}),
|
|
563
|
+
} as CSSProperties}
|
|
440
564
|
onChange={(e) => onSeek(Number(e.currentTarget.value))}
|
|
441
565
|
/>
|
|
442
566
|
</div>
|
package/src/replay/devtools.ts
CHANGED
|
@@ -17,6 +17,8 @@ export type ReplayConsoleLevel = 'log' | 'info' | 'warn' | 'error' | 'debug'
|
|
|
17
17
|
export interface ReplayConsoleEntry {
|
|
18
18
|
/** Stable id (`console-<n>`). */
|
|
19
19
|
id: string
|
|
20
|
+
/** The recording's own id for the message (`payload.id` of a custom event), which an inspection can name. */
|
|
21
|
+
key?: string
|
|
20
22
|
/** Milliseconds from the start of the recording. */
|
|
21
23
|
time: number
|
|
22
24
|
level: ReplayConsoleLevel
|
|
@@ -33,6 +35,9 @@ export interface ReplayConsoleEntry {
|
|
|
33
35
|
export interface ReplayRequest {
|
|
34
36
|
/** Stable id (`request-<n>`). */
|
|
35
37
|
id: string
|
|
38
|
+
/** The recording's own id for the request (`id` / `requestId`, e.g. a Replay request id), which an
|
|
39
|
+
* inspection can name. */
|
|
40
|
+
key?: string
|
|
36
41
|
/** When the request was sent, in ms from the start of the recording. */
|
|
37
42
|
time: number
|
|
38
43
|
/** How long it took, when known: the response arrived at `time + duration`. */
|
|
@@ -153,6 +158,7 @@ export function getReplayDevtools(events: readonly ReplayEvent[]): ReplayDevtool
|
|
|
153
158
|
// request was sent that far before it (its own duration, for a lone request).
|
|
154
159
|
const sent = Math.max(0, startTime !== undefined && batchEnd !== undefined ? at - (batchEnd - startTime) : at - (duration ?? 0))
|
|
155
160
|
requests.push({
|
|
161
|
+
key: str(raw.requestId) ?? str(raw.id),
|
|
156
162
|
time: sent,
|
|
157
163
|
duration,
|
|
158
164
|
method: (str(raw.method) ?? 'GET').toUpperCase(),
|
|
@@ -197,10 +203,10 @@ export function getReplayDevtools(events: readonly ReplayEvent[]): ReplayDevtool
|
|
|
197
203
|
const detail = str(payload.message) ?? str(payload.detail)
|
|
198
204
|
const message = detail ? `Uncaught ${label}: ${detail}` : `Uncaught ${label}`
|
|
199
205
|
const trace = Array.isArray(payload.stack) ? payload.stack.map(String) : str(payload.stack)?.split('\n').map((l) => l.trim()).filter(Boolean)
|
|
200
|
-
consoleEntries.push({ time, level: 'error', message, args: [message], trace: trace?.length ? trace : undefined, source: 'exception' })
|
|
206
|
+
consoleEntries.push({ key: str(payload.id), time, level: 'error', message, args: [message], trace: trace?.length ? trace : undefined, source: 'exception' })
|
|
201
207
|
} else if (CONSOLE_TAGS.has(tag)) {
|
|
202
208
|
const args = Array.isArray(payload.args) ? payload.args.map((a) => asText(a) ?? '') : [str(payload.message) ?? str(payload.label) ?? asText(payload.detail) ?? '']
|
|
203
|
-
consoleEntries.push({ time, level: levelOf(payload.level ?? tag), message: args.join(' '), args, source: 'custom' })
|
|
209
|
+
consoleEntries.push({ key: str(payload.id), time, level: levelOf(payload.level ?? tag), message: args.join(' '), args, source: 'custom' })
|
|
204
210
|
}
|
|
205
211
|
}
|
|
206
212
|
}
|