diff --git a/packages/rrweb/src/replay/index.ts b/packages/rrweb/src/replay/index.ts index be30e7d8fc..ab7e37856b 100644 --- a/packages/rrweb/src/replay/index.ts +++ b/packages/rrweb/src/replay/index.ts @@ -134,7 +134,7 @@ export class Replayer { private mouseTail: HTMLCanvasElement | null = null; private tailPositions: Array<{ x: number; y: number }> = []; - private emitter: Emitter = mitt(); + private emitter: Emitter = mitt() as Emitter; private nextUserInteractionEvent: eventWithTime | null; @@ -331,6 +331,8 @@ export class Replayer { this.applySelection(this.lastSelectionData); this.lastSelectionData = null; } + + this.emitter.emit(ReplayerEvents.FlushEnd); }); this.emitter.on(ReplayerEvents.PlayBack, () => { this.firstFullSnapshot = null; @@ -525,6 +527,35 @@ export class Replayer { this.emitter.emit(ReplayerEvents.Start); } + /** + * Applies all events synchronously until the given event index. + * @param eventIndex - number + */ + public replayEvent(eventIndex: number) { + const handleFinish = () => { + this.service.send('END'); + this.emitter.off(ReplayerEvents.FlushEnd, handleFinish); + }; + this.emitter.on(ReplayerEvents.FlushEnd, handleFinish); + + if (this.service.state.matches('paused')) { + this.service.send({ + type: 'PLAY_SINGLE_EVENT', + payload: { singleEvent: eventIndex }, + }); + } else { + this.service.send({ type: 'PAUSE' }); + this.service.send({ + type: 'PLAY_SINGLE_EVENT', + payload: { singleEvent: eventIndex }, + }); + } + this.iframe.contentDocument + ?.getElementsByTagName('html')[0] + ?.classList.remove('rrweb-paused'); + this.emitter.emit(ReplayerEvents.Start); + } + public pause(timeOffset?: number) { if (timeOffset === undefined && this.service.state.matches('playing')) { this.service.send({ type: 'PAUSE' }); @@ -558,6 +589,7 @@ export class Replayer { this.mediaManager.reset(); this.config.root.removeChild(this.wrapper); this.emitter.emit(ReplayerEvents.Destroy); + this.emitter.all.clear(); } public startLive(baselineTime?: number) { diff --git a/packages/rrweb/src/replay/machine.ts b/packages/rrweb/src/replay/machine.ts index 08b72c9543..74f1e668c7 100644 --- a/packages/rrweb/src/replay/machine.ts +++ b/packages/rrweb/src/replay/machine.ts @@ -28,6 +28,12 @@ export type PlayerEvent = timeOffset: number; }; } + | { + type: 'PLAY_SINGLE_EVENT'; + payload: { + singleEvent: number; + }; + } | { type: 'CAST_EVENT'; payload: { @@ -78,6 +84,30 @@ export function discardPriorSnapshots( return events; } +function discardPriorSnapshotsToEvent( + events: eventWithTime[], + targetIndex: number, +) { + const targetEvent = events[targetIndex]; + + if (!targetEvent) { + return []; + } + + for (let idx = targetIndex; idx >= 0; idx--) { + const event = events[idx]; + + if (!event) { + continue; + } + + if (event.type === EventType.Meta) { + return events.slice(idx, targetIndex + 1); + } + } + return events; +} + type PlayerAssets = { emitter: Emitter; applyEventsSynchronously(events: Array): void; @@ -119,6 +149,10 @@ export function createPlayerService( target: 'playing', actions: ['recordTimeOffset', 'play'], }, + PLAY_SINGLE_EVENT: { + target: 'playing', + actions: ['playSingleEvent'], + }, CAST_EVENT: { target: 'paused', actions: 'castEvent', @@ -168,6 +202,23 @@ export function createPlayerService( baselineTime: ctx.events[0].timestamp + timeOffset, }; }), + + playSingleEvent(ctx, event) { + if (event.type !== 'PLAY_SINGLE_EVENT') { + return; + } + + const { singleEvent } = event.payload; + + const neededEvents = discardPriorSnapshotsToEvent( + ctx.events, + singleEvent, + ); + + applyEventsSynchronously(neededEvents); + emitter.emit(ReplayerEvents.Flush); + }, + play(ctx) { const { timer, events, baselineTime, lastPlayedEvent } = ctx; timer.clear(); diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index 75155cab34..b6a31f36fb 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -651,6 +651,7 @@ export type Emitter = { on(type: string, handler: Handler): void; emit(type: string, event?: unknown): void; off(type: string, handler: Handler): void; + all: Map; }; export type Arguments = T extends (...payload: infer U) => unknown @@ -675,6 +676,7 @@ export enum ReplayerEvents { EventCast = 'event-cast', CustomEvent = 'custom-event', Flush = 'flush', + FlushEnd = 'flush-end', StateChange = 'state-change', PlayBack = 'play-back', Destroy = 'destroy',