From 72d6b91cc7c5b7507b98a3523e554b5fbc3b82a2 Mon Sep 17 00:00:00 2001 From: Alex Kaminskii Date: Tue, 13 Sep 2022 14:47:52 +0200 Subject: [PATCH 1/2] tracker(fix): iframe mousemoves & clicks --- .../src/main/app/observer/iframe_offsets.ts | 68 +++++++++++++++++++ .../src/main/app/observer/top_observer.ts | 33 ++------- tracker/tracker/src/main/modules/mouse.ts | 19 ++++-- 3 files changed, 87 insertions(+), 33 deletions(-) create mode 100644 tracker/tracker/src/main/app/observer/iframe_offsets.ts diff --git a/tracker/tracker/src/main/app/observer/iframe_offsets.ts b/tracker/tracker/src/main/app/observer/iframe_offsets.ts new file mode 100644 index 000000000..dc88a9506 --- /dev/null +++ b/tracker/tracker/src/main/app/observer/iframe_offsets.ts @@ -0,0 +1,68 @@ +// Le truc - for defining an absolute offset for (nested) iframe documents. (To track mouse movments) + +export type Offset = [/*left:*/ number, /*top: */ number] + +type OffsetState = { + offset: Offset | null + parent: OffsetState | null + iFrame: HTMLIFrameElement + clear: () => void +} + +export default class IFrameOffsets { + private readonly states: Map = new Map() + + private calcOffset(state: OffsetState): Offset { + let parLeft = 0, + parTop = 0 + if (state.parent) { + ;[parLeft, parTop] = this.calcOffset(state.parent) + } + if (!state.offset) { + const { left, top } = state.iFrame.getBoundingClientRect() + state.offset = [left, top] + } + const [left, top] = state.offset + return [parLeft + left, parTop + top] // TODO: store absolute sum, invalidate whole subtree. Otherwise it is summated on each mousemove + } + + getDocumentOffset(doc: Document): Offset { + const state = this.states.get(doc) + if (!state) { + return [0, 0] + } // topmost doc + return this.calcOffset(state) + } + + observe(iFrame: HTMLIFrameElement): void { + const doc = iFrame.contentDocument + if (!doc) { + return + } + const parentDoc = iFrame.ownerDocument + const parentState = this.states.get(parentDoc) + const state = { + offset: null, // lazy calc + iFrame, + parent: parentState || null, // null when parentDoc is the topmost document + clear: () => { + parentDoc.removeEventListener('scroll', invalidateOffset) + parentDoc.defaultView?.removeEventListener('resize', invalidateOffset) + }, + } + const invalidateOffset = () => { + state.offset = null + } + + // anything more reliable? This does not cover all cases (layout changes are ignored, for ex.) + parentDoc.addEventListener('scroll', invalidateOffset) + parentDoc.defaultView?.addEventListener('resize', invalidateOffset) + + this.states.set(doc, state) + } + + clear() { + this.states.forEach((s) => s.clear()) + this.states.clear() + } +} diff --git a/tracker/tracker/src/main/app/observer/top_observer.ts b/tracker/tracker/src/main/app/observer/top_observer.ts index d9f2d7f9d..1e69e5bbd 100644 --- a/tracker/tracker/src/main/app/observer/top_observer.ts +++ b/tracker/tracker/src/main/app/observer/top_observer.ts @@ -3,6 +3,7 @@ import { isElementNode, hasTag } from '../guards.js' import IFrameObserver from './iframe_observer.js' import ShadowRootObserver from './shadow_root_observer.js' +import IFrameOffsets, { Offset } from './iframe_offsets.js' import { CreateDocument } from '../messages.gen.js' import App from '../index.js' @@ -13,23 +14,14 @@ export interface Options { } type Context = Window & typeof globalThis - type ContextCallback = (context: Context) => void -// Le truc - for defining an absolute offset for (nested) iframe documents. (To track mouse movments) -type Offset = { top: number; left: number } -type PatchedDocument = Document & { - __openreplay__getOffset: () => Offset -} -function isPatchedDocument(doc: Document): doc is PatchedDocument { - // @ts-ignore - return typeof doc.__openreplay__getOffset === 'function' -} - const attachShadowNativeFn = IN_BROWSER ? Element.prototype.attachShadow : () => new ShadowRoot() export default class TopObserver extends Observer { private readonly options: Options + private readonly iframeOffsets: IFrameOffsets = new IFrameOffsets() + constructor(app: App, options: Partial) { super(app, true) this.options = Object.assign( @@ -66,18 +58,13 @@ export default class TopObserver extends Observer { this.contextCallbacks.push(cb) } - // Le truc getDocumentOffset(doc: Document): Offset { - if (isPatchedDocument(doc)) { - return doc.__openreplay__getOffset() - } - return { top: 0, left: 0 } + return this.iframeOffsets.getDocumentOffset(doc) } private iframeObservers: IFrameObserver[] = [] private handleIframe(iframe: HTMLIFrameElement): void { let doc: Document | null = null - let win: Window | null = null // setTimeout is required. Otherwise some event listeners (scroll, mousemove) applied in modules // do not work on the iframe document when it 've been loaded dynamically ((why?)) const handle = this.app.safe(() => @@ -95,25 +82,18 @@ export default class TopObserver extends Observer { observer.observe(iframe) // TODO: call unregisterNode for the previous doc if present (incapsulate: one iframe - one observer) doc = currentDoc - // Le truc - ;(doc as PatchedDocument).__openreplay__getOffset = () => { - const { top, left } = this.getDocumentOffset(iframe.ownerDocument) - return { - top: iframe.offsetTop + top, - left: iframe.offsetLeft + left, - } - } + this.iframeOffsets.observe(iframe) } if ( currentWin && // Sometimes currentWin.window is null (not in specification). Such window object is not functional currentWin === currentWin.window && !this.contextsSet.has(currentWin) // for each context callbacks called once per Tracker (TopObserver) instance + //TODO: more explicit logic ) { this.contextsSet.add(currentWin) //@ts-ignore https://github.com/microsoft/TypeScript/issues/41684 this.contextCallbacks.forEach((cb) => cb(currentWin)) - win = currentWin } }, 0), ) @@ -155,6 +135,7 @@ export default class TopObserver extends Observer { } disconnect() { + this.iframeOffsets.clear() Element.prototype.attachShadow = attachShadowNativeFn this.iframeObservers.forEach((o) => o.disconnect()) this.iframeObservers = [] diff --git a/tracker/tracker/src/main/modules/mouse.ts b/tracker/tracker/src/main/modules/mouse.ts index 415c1925f..15c5e786c 100644 --- a/tracker/tracker/src/main/modules/mouse.ts +++ b/tracker/tracker/src/main/modules/mouse.ts @@ -105,12 +105,14 @@ export default function (app: App): void { let mousePositionChanged = false let mouseTarget: Element | null = null let mouseTargetTime = 0 + let selectorMap: { [id: number]: string } = {} app.attachStopCallback(() => { mousePositionX = -1 mousePositionY = -1 mousePositionChanged = false mouseTarget = null + selectorMap = {} }) const sendMouseMove = (): void => { @@ -120,31 +122,34 @@ export default function (app: App): void { } } - const patchDocument = (document: Document) => { - const selectorMap: { [id: number]: string } = {} + const patchDocument = (document: Document, topframe = false) => { function getSelector(id: number, target: Element): string { return (selectorMap[id] = selectorMap[id] || _getSelector(target, document)) } - app.attachEventListener(document.documentElement, 'mouseover', (e: MouseEvent): void => { + const attachListener = topframe + ? app.attachEventListener.bind(app) // attached/removed on start/stop + : app.nodes.attachNodeListener.bind(app.nodes) // attached/removed on node register/unregister + + attachListener(document.documentElement, 'mouseover', (e: MouseEvent): void => { const target = getTarget(e.target, document) if (target !== mouseTarget) { mouseTarget = target mouseTargetTime = performance.now() } }) - app.attachEventListener( + attachListener( document, 'mousemove', (e: MouseEvent): void => { - const { top, left } = app.observer.getDocumentOffset(document) + const [left, top] = app.observer.getDocumentOffset(document) // MBTODO?: document-id related message mousePositionX = e.clientX + left mousePositionY = e.clientY + top mousePositionChanged = true }, false, ) - app.attachEventListener(document, 'click', (e: MouseEvent): void => { + attachListener(document, 'click', (e: MouseEvent): void => { const target = getTarget(e.target, document) if ((!e.clientX && !e.clientY) || target === null) { return @@ -171,7 +176,7 @@ export default function (app: App): void { patchDocument(node) } }) - patchDocument(document) + patchDocument(document, true) app.ticker.attach(sendMouseMove, 10) } From 622cc6d488c3f3cea2533a20c62fd9b8978f4eac Mon Sep 17 00:00:00 2001 From: Alex Kaminskii Date: Tue, 13 Sep 2022 17:52:42 +0200 Subject: [PATCH 2/2] fix(tracker): iframe root scroll on start --- tracker/tracker/src/main/modules/scroll.ts | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/tracker/tracker/src/main/modules/scroll.ts b/tracker/tracker/src/main/modules/scroll.ts index 4fc791e2f..74003cd29 100644 --- a/tracker/tracker/src/main/modules/scroll.ts +++ b/tracker/tracker/src/main/modules/scroll.ts @@ -52,9 +52,16 @@ export default function (app: App): void { app.nodes.attachNodeCallback((node, isStart) => { // MBTODO: iterate over all the nodes on start instead of using isStart hack - if (isStart && isElementNode(node) && node.scrollLeft + node.scrollTop > 0) { - nodeScroll.set(node, [node.scrollLeft, node.scrollTop]) - } else if (isRootNode(node)) { + if (isStart) { + if (isElementNode(node) && node.scrollLeft + node.scrollTop > 0) { + nodeScroll.set(node, [node.scrollLeft, node.scrollTop]) + } else if (isDocument(node)) { + // DRY somehow? + nodeScroll.set(node, getDocumentScroll(node)) + } + } + + if (isRootNode(node)) { // scroll is not-composed event (https://javascript.info/shadow-dom-events) app.nodes.attachNodeListener(node, 'scroll', (e: Event): void => { setNodeScroll(e.target)