Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

ref(rrweb): allow multi touch gestures to be shown for mobile replays #190

Merged
merged 28 commits into from
May 31, 2024
Merged
Show file tree
Hide file tree
Changes from 25 commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
5f4dfd3
ref(rrweb): allow multi touch gestures to be shown for mobile replays
michellewzhang May 16, 2024
94e7595
some type fixes
michellewzhang May 16, 2024
d87bbb9
rm files
michellewzhang May 16, 2024
8e44b94
more fixes
michellewzhang May 16, 2024
1d0cce1
touchId -> pointerId
michellewzhang May 16, 2024
673d62a
name changes
michellewzhang May 16, 2024
c92bc1b
types
michellewzhang May 16, 2024
52e4399
some fixes
michellewzhang May 28, 2024
07d1d67
forgot a spot
michellewzhang May 28, 2024
b2b12c8
rm export
michellewzhang May 28, 2024
c7039dd
rm changes to package.json and yarn.lock
michellewzhang May 28, 2024
1b453b7
ts
michellewzhang May 28, 2024
ad7d1ce
fix delete
michellewzhang May 28, 2024
583d808
turn "run for branch" on for size limit GHA
billyvg May 29, 2024
a41c292
Update packages/rrweb/src/replay/index.ts
billyvg May 29, 2024
74a19cd
add size limit entry for Replayer
billyvg May 29, 2024
fd3e3ed
optimizations
michellewzhang May 29, 2024
fbb92f6
Merge remote-tracking branch 'origin/sentry-v2' into mz/rrweb-ref-mul…
michellewzhang May 29, 2024
c424b56
rm -1
michellewzhang May 29, 2024
3830e04
update snapshots for failing tests
michellewzhang May 29, 2024
3044563
add tests
michellewzhang May 29, 2024
a2aac2a
:white-check-mark: add test
michellewzhang May 29, 2024
830d8f3
move func out of the class
michellewzhang May 30, 2024
d9d3075
TESTS.
michellewzhang May 30, 2024
56ed9a0
tests that pass
michellewzhang May 30, 2024
0a7aeee
rm code for creating pointers upfront
michellewzhang May 31, 2024
5e465eb
add touch-device class inside createPointer
michellewzhang May 31, 2024
8fee9ca
rm play() from tests
michellewzhang May 31, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
193 changes: 139 additions & 54 deletions packages/rrweb/src/replay/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,8 @@
styleSheetRuleData,
styleDeclarationData,
adoptedStyleSheetData,
mouseInteractionData,
mousemoveData,
} from '@sentry-internal/rrweb-types';
import {
polyfill,
Expand Down Expand Up @@ -98,7 +100,14 @@
strokeStyle: 'red',
} as const;

function indicatesTouchDevice(e: eventWithTime) {
type incrementalSnapshotEventWithTime = incrementalSnapshotEvent & {
timestamp: number;
delay?: number;
};

function indicatesTouchDevice(
e: eventWithTime,
): e is incrementalSnapshotEventWithTime {
return (
e.type == EventType.IncrementalSnapshot &&
(e.data.source == IncrementalSource.TouchMove ||
Expand All @@ -107,6 +116,14 @@
);
}

function getPointerId(
d: incrementalData | mousemoveData | mouseInteractionData,
): number {
const pointerId =
'pointerId' in d && typeof d.pointerId === 'number' ? d.pointerId : -1;
return pointerId;
}

export class Replayer {
public wrapper: HTMLDivElement;
public iframe: HTMLIFrameElement;
Expand All @@ -123,9 +140,7 @@
public usingVirtualDom = false;
public virtualDom: RRDocument = new RRDocument();

private mouse: HTMLDivElement;
private mouseTail: HTMLCanvasElement | null = null;
private tailPositions: Array<{ x: number; y: number }> = [];

private emitter: Emitter = mitt();

Expand All @@ -148,8 +163,16 @@

private newDocumentQueue: addedNodeMutation[] = [];

private mousePos: mouseMovePos | null = null;
private touchActive: boolean | null = null;
// Map of pointer ID to the unique vars used to show pointers and their movements
private pointers: Record<
number,
{
touchActive: boolean | null;
pointerEl: HTMLDivElement;
tailPositions: Array<{ x: number; y: number }>;
pointerPosition: mouseMovePos | null;
}
> = {};
private lastMouseDownEvent: [Node, Event] | null = null;

// Keep the rootNode of the last hovered element. So when hovering a new element, we can remove the last hovered element's :hover style.
Expand Down Expand Up @@ -293,23 +316,32 @@
this.adoptedStyleSheets = [];
}

if (this.mousePos) {
this.moveAndHover(
this.mousePos.x,
this.mousePos.y,
this.mousePos.id,
true,
this.mousePos.debugData,
);
this.mousePos = null;
}
for (const [
pointerId,
{ pointerPosition, touchActive },
] of Object.entries(this.pointers)) {
const id = parseInt(pointerId);
const pointer = this.pointers[id];

if (pointerPosition) {
this.moveAndHover(
pointerPosition.x,
pointerPosition.y,
pointerPosition.id,
true,
pointerPosition.debugData,
id,
);
pointer.pointerPosition = null;
}

if (this.touchActive === true) {
this.mouse.classList.add('touch-active');
} else if (this.touchActive === false) {
this.mouse.classList.remove('touch-active');
if (touchActive === true) {
pointer.pointerEl.classList.add('touch-active');
} else if (touchActive === false) {
pointer.pointerEl.classList.remove('touch-active');
}
pointer.touchActive = null;
}
this.touchActive = null;

if (this.lastMouseDownEvent) {
const [target, event] = this.lastMouseDownEvent;
Expand Down Expand Up @@ -402,9 +434,29 @@
);
}, 1);
}
if (this.service.state.context.events.find(indicatesTouchDevice)) {
this.mouse.classList.add('touch-device');
}
this.service.state.context.events.forEach((e: eventWithTime) => {
if (indicatesTouchDevice(e)) {
const pointerId = getPointerId(e.data);
if (!this.pointers[pointerId]) {
this.createPointer(pointerId);
}

const pointer = this.pointers[pointerId];
pointer.pointerEl.classList.add('touch-device');
}
});
}

private createPointer(pointerId: number) {
const newMouse = document.createElement('div');
newMouse.classList.add('replayer-mouse');
this.pointers[pointerId] = {
touchActive: null,
pointerEl: newMouse,
tailPositions: [],
pointerPosition: null,
};
this.wrapper.appendChild(newMouse);
}

public on(event: string, handler: Handler) {
Expand Down Expand Up @@ -542,7 +594,13 @@
? this.config.unpackFn(rawEvent as string)
: (rawEvent as eventWithTime);
if (indicatesTouchDevice(event)) {
this.mouse.classList.add('touch-device');
const pointerId = getPointerId(event.data);
if (!this.pointers[pointerId]) {
this.createPointer(pointerId);
}

const pointer = this.pointers[pointerId];
pointer.pointerEl.classList.add('touch-device');
}
void Promise.resolve().then(() =>
this.service.send({ type: 'ADD_EVENT', payload: { event } }),
Expand Down Expand Up @@ -572,10 +630,6 @@
this.wrapper.classList.add('replayer-wrapper');
this.config.root.appendChild(this.wrapper);

this.mouse = document.createElement('div');
this.mouse.classList.add('replayer-mouse');
this.wrapper.appendChild(this.mouse);

if (this.config.mouseTail !== false) {
this.mouseTail = document.createElement('canvas');
this.mouseTail.classList.add('replayer-mouse-tail');
Expand Down Expand Up @@ -887,7 +941,7 @@
sn?.type === NodeType.Element &&
sn?.tagName.toUpperCase() === 'HTML'
) {
const { documentElement, head } = iframeEl.contentDocument!;

Check warning on line 944 in packages/rrweb/src/replay/index.ts

View workflow job for this annotation

GitHub Actions / ESLint Report Analysis

packages/rrweb/src/replay/index.ts#L944

[@typescript-eslint/no-non-null-assertion] Forbidden non-null assertion.
this.insertStyleRules(
documentElement as HTMLElement | RRElement,
head as HTMLElement | RRElement,
Expand All @@ -906,14 +960,14 @@
};

buildNodeWithSN(mutation.node, {
doc: iframeEl.contentDocument! as Document,

Check warning on line 963 in packages/rrweb/src/replay/index.ts

View workflow job for this annotation

GitHub Actions / ESLint Report Analysis

packages/rrweb/src/replay/index.ts#L963

[@typescript-eslint/no-non-null-assertion] Forbidden non-null assertion.
mirror: mirror as Mirror,
hackCss: true,
skipChild: false,
afterAppend,
cache: this.cache,
});
afterAppend(iframeEl.contentDocument! as Document, mutation.node.id);

Check warning on line 970 in packages/rrweb/src/replay/index.ts

View workflow job for this annotation

GitHub Actions / ESLint Report Analysis

packages/rrweb/src/replay/index.ts#L970

[@typescript-eslint/no-non-null-assertion] Forbidden non-null assertion.

for (const { mutationInQueue, builtNode } of collected) {
this.attachDocumentToIframe(mutationInQueue, builtNode);
Expand Down Expand Up @@ -1000,7 +1054,7 @@
* pause when there are some canvas drawImage args need to be loaded
*/
private async preloadAllImages(): Promise<void[]> {
let beforeLoadState = this.service.state;

Check warning on line 1057 in packages/rrweb/src/replay/index.ts

View workflow job for this annotation

GitHub Actions / ESLint Report Analysis

packages/rrweb/src/replay/index.ts#L1057

[@typescript-eslint/no-unused-vars] 'beforeLoadState' is assigned a value but never used.
const stateHandler = () => {
beforeLoadState = this.service.state;
};
Expand Down Expand Up @@ -1035,8 +1089,8 @@
const ctx = canvas.getContext('2d');
const imgd = ctx?.createImageData(canvas.width, canvas.height);
let d = imgd?.data;
d = JSON.parse(data.args[0]) as Uint8ClampedArray;

Check warning on line 1092 in packages/rrweb/src/replay/index.ts

View workflow job for this annotation

GitHub Actions / ESLint Report Analysis

packages/rrweb/src/replay/index.ts#L1092

[@typescript-eslint/no-unused-vars] 'd' is assigned a value but never used.
ctx?.putImageData(imgd!, 0, 0);

Check warning on line 1093 in packages/rrweb/src/replay/index.ts

View workflow job for this annotation

GitHub Actions / ESLint Report Analysis

packages/rrweb/src/replay/index.ts#L1093

[@typescript-eslint/no-non-null-assertion] Forbidden non-null assertion.
}
}
private async deserializeAndPreloadCanvasEvents(
Expand Down Expand Up @@ -1085,10 +1139,20 @@
}
case IncrementalSource.Drag:
case IncrementalSource.TouchMove:
case IncrementalSource.MouseMove:
case IncrementalSource.MouseMove: {
const pointerId = getPointerId(d);
if (!this.pointers[pointerId]) {
this.createPointer(pointerId);
}

const pointer = this.pointers[pointerId];

if (indicatesTouchDevice(e)) {
pointer.pointerEl.classList.add('touch-device');
}
if (isSync) {
const lastPosition = d.positions[d.positions.length - 1];
this.mousePos = {
pointer.pointerPosition = {
x: lastPosition.x,
y: lastPosition.y,
id: lastPosition.id,
Expand All @@ -1098,7 +1162,7 @@
d.positions.forEach((p) => {
const action = {
doAction: () => {
this.moveAndHover(p.x, p.y, p.id, isSync, d);
this.moveAndHover(p.x, p.y, p.id, isSync, d, pointerId);
},
delay:
p.timeOffset +
Expand All @@ -1117,7 +1181,19 @@
});
}
break;
}
case IncrementalSource.MouseInteraction: {
const pointerId = getPointerId(d);
if (!this.pointers[pointerId]) {
this.createPointer(pointerId);
}

const pointer = this.pointers[pointerId];

if (indicatesTouchDevice(e)) {
pointer.pointerEl.classList.add('touch-device');
}

/**
* Same as the situation of missing input target.
*/
Expand Down Expand Up @@ -1154,16 +1230,16 @@
case MouseInteractions.MouseUp:
if (isSync) {
if (d.type === MouseInteractions.TouchStart) {
this.touchActive = true;
pointer.touchActive = true;
} else if (d.type === MouseInteractions.TouchEnd) {
this.touchActive = false;
pointer.touchActive = false;
}
if (d.type === MouseInteractions.MouseDown) {
this.lastMouseDownEvent = [target, event];
} else if (d.type === MouseInteractions.MouseUp) {
this.lastMouseDownEvent = null;
}
this.mousePos = {
pointer.pointerPosition = {
x: d.x || 0,
y: d.y || 0,
id: d.id,
Expand All @@ -1172,9 +1248,9 @@
} else {
if (d.type === MouseInteractions.TouchStart) {
// don't draw a trail as user has lifted finger and is placing at a new point
this.tailPositions.length = 0;
pointer.tailPositions.length = 0;
}
this.moveAndHover(d.x || 0, d.y || 0, d.id, isSync, d);
this.moveAndHover(d.x || 0, d.y || 0, d.id, isSync, d, pointerId);
if (d.type === MouseInteractions.Click) {
/*
* don't want target.click() here as could trigger an iframe navigation
Expand All @@ -1184,14 +1260,15 @@
* removal and addition of .active class (along with void line to trigger repaint)
* triggers the 'click' css animation in styles/style.css
*/
this.mouse.classList.remove('active');
void this.mouse.offsetWidth;
this.mouse.classList.add('active');
pointer.pointerEl.classList.remove('active');
void pointer.pointerEl.offsetWidth;
pointer.pointerEl.classList.add('active');
} else if (d.type === MouseInteractions.TouchStart) {
void this.mouse.offsetWidth; // needed for the position update of moveAndHover to apply without the .touch-active transition
this.mouse.classList.add('touch-active');
void pointer.pointerEl.offsetWidth; // needed for the position update of moveAndHover to apply without the .touch-active transition
pointer.pointerEl.classList.add('touch-active');
} else if (d.type === MouseInteractions.TouchEnd) {
this.mouse.classList.remove('touch-active');
pointer.pointerEl.remove();
delete this.pointers[pointerId];
} else {
// for MouseDown & MouseUp also invoke default behavior
target.dispatchEvent(event);
Expand All @@ -1200,9 +1277,9 @@
break;
case MouseInteractions.TouchCancel:
if (isSync) {
this.touchActive = false;
pointer.touchActive = false;
} else {
this.mouse.classList.remove('touch-active');
pointer.pointerEl.classList.remove('touch-active');
}
break;
default:
Expand Down Expand Up @@ -1373,7 +1450,7 @@
// Only apply virtual dom optimization if the fast-forward process has node mutation. Because the cost of creating a virtual dom tree and executing the diff algorithm is usually higher than directly applying other kind of events.
if (this.config.useVirtualDom && !this.usingVirtualDom && isSync) {
this.usingVirtualDom = true;
buildFromDom(this.iframe.contentDocument!, this.mirror, this.virtualDom);

Check warning on line 1453 in packages/rrweb/src/replay/index.ts

View workflow job for this annotation

GitHub Actions / ESLint Report Analysis

packages/rrweb/src/replay/index.ts#L1453

[@typescript-eslint/no-non-null-assertion] Forbidden non-null assertion.
// If these legacy missing nodes haven't been resolved, they should be converted to virtual nodes.
if (Object.keys(this.legacy_missingNodeRetryMap).length) {
for (const key in this.legacy_missingNodeRetryMap) {
Expand Down Expand Up @@ -1490,7 +1567,7 @@
// If the parent is attached a shadow dom after it's created, it won't have a shadow root.
if (!hasShadowRoot(parent)) {
(parent as Element | RRElement).attachShadow({ mode: 'open' });
parent = (parent as Element | RRElement).shadowRoot! as Node | RRNode;

Check warning on line 1570 in packages/rrweb/src/replay/index.ts

View workflow job for this annotation

GitHub Actions / ESLint Report Analysis

packages/rrweb/src/replay/index.ts#L1570

[@typescript-eslint/no-non-null-assertion] Forbidden non-null assertion.
} else parent = parent.shadowRoot as Node | RRNode;
}

Expand Down Expand Up @@ -2080,6 +2157,7 @@
id: number,
isSync: boolean,
debugData: incrementalData,
pointerId: number,
) {
const target = this.mirror.getNode(id);
if (!target) {
Expand All @@ -2090,15 +2168,16 @@
const _x = x * base.absoluteScale + base.x;
const _y = y * base.absoluteScale + base.y;

this.mouse.style.left = `${_x}px`;
this.mouse.style.top = `${_y}px`;
const pointer = this.pointers[pointerId];
pointer.pointerEl.style.left = `${_x}px`;
pointer.pointerEl.style.top = `${_y}px`;
if (!isSync) {
this.drawMouseTail({ x: _x, y: _y });
this.drawMouseTail({ x: _x, y: _y }, pointerId);
}
this.hoverElements(target as Element);
}

private drawMouseTail(position: { x: number; y: number }) {
private drawMouseTail(position: { x: number; y: number }, pointerId: number) {
if (!this.mouseTail) {
return;
}
Expand All @@ -2108,29 +2187,35 @@
? defaultMouseTailConfig
: Object.assign({}, defaultMouseTailConfig, this.config.mouseTail);

const pointer = this.pointers[pointerId];

const draw = () => {
if (!this.mouseTail) {
return;
}
const ctx = this.mouseTail.getContext('2d');
if (!ctx || !this.tailPositions.length) {
if (!ctx || !pointer.tailPositions.length) {
return;
}
ctx.clearRect(0, 0, this.mouseTail.width, this.mouseTail.height);
ctx.beginPath();
ctx.lineWidth = lineWidth;
ctx.lineCap = lineCap;
ctx.strokeStyle = strokeStyle;
ctx.moveTo(this.tailPositions[0].x, this.tailPositions[0].y);
this.tailPositions.forEach((p) => ctx.lineTo(p.x, p.y));
ctx.moveTo(pointer.tailPositions[0].x, pointer.tailPositions[0].y);
pointer.tailPositions.forEach((p) => ctx.lineTo(p.x, p.y));
ctx.stroke();
};

this.tailPositions.push(position);
pointer.tailPositions.push(position);
draw();
setTimeout(() => {
this.tailPositions = this.tailPositions.filter((p) => p !== position);
draw();
if (pointerId in this.pointers) {
michellewzhang marked this conversation as resolved.
Show resolved Hide resolved
pointer.tailPositions = pointer.tailPositions.filter(
(p) => p !== position,
);
draw();
}
}, duration / this.speedService.state.context.timer.speed);
}

Expand Down
Loading
Loading