feat(tracker): maintain mouse actions, cssrules in iframes; console with same API

This commit is contained in:
Alex Kaminskii 2022-08-17 18:35:34 +02:00
parent 5c3fc505ab
commit c31f878ef6
5 changed files with 100 additions and 74 deletions

View file

@ -14,6 +14,16 @@ export interface Options {
type ContextCallback = (context: Window & typeof globalThis) => 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 {
@ -53,6 +63,14 @@ 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 }
}
private iframeObservers: IFrameObserver[] = []
private handleIframe(iframe: HTMLIFrameElement): void {
let doc: Document | null = null
@ -70,6 +88,15 @@ export default class TopObserver extends Observer {
this.iframeObservers.push(observer)
observer.observe(iframe)
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,
}
}
}
if (currentWin && currentWin !== win) {
//@ts-ignore https://github.com/microsoft/TypeScript/issues/41684

View file

@ -122,7 +122,7 @@ export default function (app: App, opts: Partial<Options>): void {
const patchConsole = (console: Console) =>
options.consoleMethods!.forEach((method) => {
if (consoleMethods.indexOf(method) === -1) {
console.error(`OpenReplay: unsupported console method "${method}"`)
app.debug.error(`OpenReplay: unsupported console method "${method}"`)
return
}
const fn = (console as any)[method]
@ -134,23 +134,8 @@ export default function (app: App, opts: Partial<Options>): void {
sendConsoleLog(method, args)
}
})
patchConsole(window.console)
const patchContext = app.safe((context: typeof globalThis) => patchConsole(context.console))
app.nodes.attachNodeCallback(
app.safe((node) => {
if (hasTag(node, 'IFRAME')) {
// TODO: newContextCallback
let context = node.contentWindow
if (context) {
patchConsole((context as Window & typeof globalThis).console)
}
app.attachEventListener(node, 'load', () => {
if (node.contentWindow !== context) {
context = node.contentWindow
patchConsole((context as Window & typeof globalThis).console)
}
})
}
}),
)
patchContext(window)
app.observer.attachContextCallback(patchContext)
}

View file

@ -27,16 +27,20 @@ export default function (app: App | null) {
} // else error?
})
const { insertRule, deleteRule } = CSSStyleSheet.prototype
const patchContext = (context: typeof globalThis) => {
const { insertRule, deleteRule } = context.CSSStyleSheet.prototype
context.CSSStyleSheet.prototype.insertRule = function (rule: string, index = 0) {
processOperation(this, index, rule)
return insertRule.call(this, rule, index)
}
context.CSSStyleSheet.prototype.deleteRule = function (index: number) {
processOperation(this, index)
return deleteRule.call(this, index)
}
}
CSSStyleSheet.prototype.insertRule = function (rule: string, index = 0) {
processOperation(this, index, rule)
return insertRule.call(this, rule, index)
}
CSSStyleSheet.prototype.deleteRule = function (index: number) {
processOperation(this, index)
return deleteRule.call(this, index)
}
patchContext(window)
app.observer.attachContextCallback(patchContext)
app.nodes.attachNodeCallback((node: Node): void => {
if (!hasTag(node, 'STYLE') || !node.sheet) {

View file

@ -49,7 +49,7 @@ const labelElementFor: (element: TextEditableElement) => HTMLLabelElement | unde
}
const id = node.id
if (id) {
const labels = document.querySelectorAll('label[for="' + id + '"]')
const labels = node.ownerDocument.querySelectorAll('label[for="' + id + '"]')
if (labels !== null && labels.length === 1) {
return labels[0] as HTMLLabelElement
}

View file

@ -1,10 +1,10 @@
import type App from '../app/index.js'
import { hasTag, isSVGElement } from '../app/guards.js'
import { hasTag, isSVGElement, isDocument } from '../app/guards.js'
import { normSpaces, hasOpenreplayAttribute, getLabelAttribute } from '../utils.js'
import { MouseMove, MouseClick } from '../app/messages.gen.js'
import { getInputLabel } from './input.js'
function _getSelector(target: Element): string {
function _getSelector(target: Element, document: Document): string {
let el: Element | null = target
let selector: string | null = null
do {
@ -37,18 +37,18 @@ function isClickable(element: Element): boolean {
element.getAttribute('role') === 'button'
)
//|| element.className.includes("btn")
// MBTODO: intersect addEventListener
// MBTODO: intersept addEventListener
}
//TODO: fix (typescript doesn't allow work when the guard is inside the function)
function getTarget(target: EventTarget | null): Element | null {
//TODO: fix (typescript is not sure about target variable after assignation of svg)
function getTarget(target: EventTarget | null, document: Document): Element | null {
if (target instanceof Element) {
return _getTarget(target)
return _getTarget(target, document)
}
return null
}
function _getTarget(target: Element): Element | null {
function _getTarget(target: Element, document: Document): Element | null {
let element: Element | null = target
while (element !== null && element !== document.documentElement) {
if (hasOpenreplayAttribute(element, 'masked')) {
@ -120,48 +120,58 @@ export default function (app: App): void {
}
}
const selectorMap: { [id: number]: string } = {}
function getSelector(id: number, target: Element): string {
return (selectorMap[id] = selectorMap[id] || _getSelector(target))
const patchDocument = (document: Document) => {
const selectorMap: { [id: number]: string } = {}
function getSelector(id: number, target: Element): string {
return (selectorMap[id] = selectorMap[id] || _getSelector(target, document))
}
app.attachEventListener(document.documentElement, 'mouseover', (e: MouseEvent): void => {
const target = getTarget(e.target, document)
if (target !== mouseTarget) {
mouseTarget = target
mouseTargetTime = performance.now()
}
})
app.attachEventListener(
document,
'mousemove',
(e: MouseEvent): void => {
const { top, left } = app.observer.getDocumentOffset(document)
mousePositionX = e.clientX + left
mousePositionY = e.clientY + top
mousePositionChanged = true
},
false,
)
app.attachEventListener(document, 'click', (e: MouseEvent): void => {
const target = getTarget(e.target, document)
if ((!e.clientX && !e.clientY) || target === null) {
return
}
const id = app.nodes.getID(target)
if (id !== undefined) {
sendMouseMove()
app.send(
MouseClick(
id,
mouseTarget === target ? Math.round(performance.now() - mouseTargetTime) : 0,
getTargetLabel(target),
getSelector(id, target),
),
true,
)
}
mouseTarget = null
})
}
app.attachEventListener(document.documentElement, 'mouseover', (e: MouseEvent): void => {
const target = getTarget(e.target)
if (target !== mouseTarget) {
mouseTarget = target
mouseTargetTime = performance.now()
app.nodes.attachNodeCallback((node) => {
if (isDocument(node)) {
patchDocument(node)
}
})
app.attachEventListener(
document,
'mousemove',
(e: MouseEvent): void => {
mousePositionX = e.clientX
mousePositionY = e.clientY
mousePositionChanged = true
},
false,
)
app.attachEventListener(document, 'click', (e: MouseEvent): void => {
const target = getTarget(e.target)
if ((!e.clientX && !e.clientY) || target === null) {
return
}
const id = app.nodes.getID(target)
if (id !== undefined) {
sendMouseMove()
app.send(
MouseClick(
id,
mouseTarget === target ? Math.round(performance.now() - mouseTargetTime) : 0,
getTargetLabel(target),
getSelector(id, target),
),
true,
)
}
mouseTarget = null
})
patchDocument(document)
app.ticker.attach(sendMouseMove, 10)
}