Merge pull request #731 from openreplay/iframe-mouse

Iframe mouse fix
This commit is contained in:
Alex K 2022-09-14 13:25:03 +02:00 committed by GitHub
commit 5b70ff35fe
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
4 changed files with 97 additions and 36 deletions

View file

@ -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<Document, OffsetState> = 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()
}
}

View file

@ -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<Options>) {
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 = []

View file

@ -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)
}

View file

@ -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)