openreplay/frontend/app/player/MessageDistributor/managers/DOM/DOMManager.ts
Bart Riepe af4160eb20
feat(ui): add consistent timestamps to (almost) all items in the player ui
This also tries to make the autoscroll functionality a bit more consistent, where all items are always shown in the list, but items which have not yet occurred will be partially transparent until they happen.

Due to that change, autoscroll behavior which previously always went all the way to the bottom of a list didn't make sense anymore, so now it scrolls to the current item.
2022-08-23 09:34:55 +09:00

329 lines
No EOL
12 KiB
TypeScript

import logger from 'App/logger';
import type StatedScreen from '../../StatedScreen';
import type { Message, SetNodeScroll, CreateElementNode } from '../../messages';
import ListWalker from '../ListWalker';
import StylesManager, { rewriteNodeStyleSheet } from './StylesManager';
import { VElement, VText, VFragment, VDocument, VNode, VStyleElement } from './VirtualDOM';
import type { StyleElement } from './VirtualDOM';
type HTMLElementWithValue = HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement
const IGNORED_ATTRS = [ "autocomplete", "name" ];
const ATTR_NAME_REGEXP = /([^\t\n\f \/>"'=]+)/; // regexp costs ~
// TODO: filter out non-relevant prefixes
// function replaceCSSPrefixes(css: string) {
// return css
// .replace(/\-ms\-/g, "")
// .replace(/\-webkit\-/g, "")
// .replace(/\-moz\-/g, "")
// .replace(/\-webkit\-/g, "")
// }
export default class DOMManager extends ListWalker<Message> {
private vTexts: Map<number, VText> = new Map() // map vs object here?
private vElements: Map<number, VElement> = new Map()
private vRoots: Map<number, VFragment | VDocument> = new Map()
private upperBodyId: number = -1;
private nodeScrollManagers: Map<number, ListWalker<SetNodeScroll>> = new Map()
private stylesManager: StylesManager
constructor(
private readonly screen: StatedScreen,
private readonly isMobile: boolean,
public readonly time: number
) {
super()
this.stylesManager = new StylesManager(screen)
}
append(m: Message): void {
if (m.tp === "set_node_scroll") {
let scrollManager = this.nodeScrollManagers.get(m.id)
if (!scrollManager) {
scrollManager = new ListWalker()
this.nodeScrollManagers.set(m.id, scrollManager)
}
scrollManager.append(m)
return
}
if (m.tp === "create_element_node") {
if(m.tag === "BODY" && this.upperBodyId === -1) {
this.upperBodyId = m.id
}
} else if (m.tp === "set_node_attribute" &&
(IGNORED_ATTRS.includes(m.name) || !ATTR_NAME_REGEXP.test(m.name))) {
logger.log("Ignorring message: ", m)
return; // Ignoring
}
super.append(m)
}
private removeBodyScroll(id: number, vn: VElement): void {
if (this.isMobile && this.upperBodyId === id) { // Need more type safety!
(vn.node as HTMLBodyElement).style.overflow = "hidden"
}
}
// May be make it as a message on message add?
private removeAutocomplete(node: Element): boolean {
const tag = node.tagName
if ([ "FORM", "TEXTAREA", "SELECT" ].includes(tag)) {
node.setAttribute("autocomplete", "off");
return true;
}
if (tag === "INPUT") {
node.setAttribute("autocomplete", "new-password");
return true;
}
return false;
}
private insertNode({ parentID, id, index }: { parentID: number, id: number, index: number }): void {
const child = this.vElements.get(id) || this.vTexts.get(id)
if (!child) {
logger.error("Insert error. Node not found", id);
return;
}
const parent = this.vElements.get(parentID) || this.vRoots.get(parentID)
if (!parent) {
logger.error("Insert error. Parent node not found", parentID);
return;
}
const pNode = parent.node
if ((pNode instanceof HTMLStyleElement) && // TODO: correct ordering OR filter in tracker
pNode.sheet &&
pNode.sheet.cssRules &&
pNode.sheet.cssRules.length > 0 &&
pNode.innerText &&
pNode.innerText.trim().length === 0
) {
logger.log("Trying to insert child to a style tag with virtual rules: ", parent, child);
return;
}
parent.insertChildAt(child, index)
}
private applyMessage = (msg: Message): void => {
let node: Node | undefined
let vn: VNode | undefined
let doc: Document | null
switch (msg.tp) {
case "create_document":
doc = this.screen.document;
if (!doc) {
logger.error("No iframe document found", msg)
return;
}
doc.open();
doc.write("<!DOCTYPE html><html></html>");
doc.close();
const fRoot = doc.documentElement;
fRoot.innerText = '';
vn = new VElement(fRoot)
this.vElements = new Map([[0, vn]])
const vDoc = new VDocument(doc)
vDoc.insertChildAt(vn, 0)
this.vRoots = new Map([[-1, vDoc]]) // todo: start from 0 (sync logic with tracker)
this.stylesManager.reset()
return
case "create_text_node":
vn = new VText()
this.vTexts.set(msg.id, vn)
this.insertNode(msg)
return
case "create_element_node":
let element: Element
if (msg.svg) {
element = document.createElementNS('http://www.w3.org/2000/svg', msg.tag)
} else {
element = document.createElement(msg.tag)
}
if (msg.tag === "STYLE" || msg.tag === "style") {
vn = new VStyleElement(element as StyleElement)
} else {
vn = new VElement(element)
}
this.vElements.set(msg.id, vn)
this.insertNode(msg)
this.removeBodyScroll(msg.id, vn)
this.removeAutocomplete(element)
if (['STYLE', 'style', 'LINK'].includes(msg.tag)) { // Styles in priority
vn.enforceInsertion()
}
return
case "move_node":
this.insertNode(msg);
return
case "remove_node":
vn = this.vElements.get(msg.id) || this.vTexts.get(msg.id)
if (!vn) { logger.error("Node not found", msg); return }
if (!vn.parentNode) { logger.error("Parent node not found", msg); return }
vn.parentNode.removeChild(vn)
return
case "set_node_attribute":
let { name, value } = msg;
vn = this.vElements.get(msg.id)
if (!vn) { logger.error("Node not found", msg); return }
if (name === "href" && vn.node.tagName === "LINK") {
// @ts-ignore ?global ENV type // It've been done on backend (remove after testing in saas)
// if (value.startsWith(window.env.ASSETS_HOST || window.location.origin + '/assets')) {
// value = value.replace("?", "%3F");
// }
if (!value.startsWith("http")) { return }
// blob:... value happened here. https://foss.openreplay.com/3/session/7013553567419137
// that resulted in that link being unable to load and having 4sec timeout in the below function.
this.stylesManager.setStyleHandlers(vn.node as HTMLLinkElement, value);
}
if (vn.node.namespaceURI === 'http://www.w3.org/2000/svg' && value.startsWith("url(")) {
value = "url(#" + (value.split("#")[1] ||")")
}
vn.setAttribute(name, value)
this.removeBodyScroll(msg.id, vn)
return
case "remove_node_attribute":
vn = this.vElements.get(msg.id)
if (!vn) { logger.error("Node not found", msg); return }
vn.removeAttribute(msg.name)
return
case "set_input_value":
vn = this.vElements.get(msg.id)
if (!vn) { logger.error("Node not found", msg); return }
const nodeWithValue = vn.node
if (!(nodeWithValue instanceof HTMLInputElement
|| nodeWithValue instanceof HTMLTextAreaElement
|| nodeWithValue instanceof HTMLSelectElement)
) {
logger.error("Trying to set value of non-Input element", msg)
return
}
const val = msg.mask > 0 ? '*'.repeat(msg.mask) : msg.value
doc = this.screen.document
if (doc && nodeWithValue === doc.activeElement) {
// For the case of Remote Control
nodeWithValue.onblur = () => { nodeWithValue.value = val }
return
}
nodeWithValue.value = val
return
case "set_input_checked":
vn = this.vElements.get(msg.id)
if (!vn) { logger.error("Node not found", msg); return }
(vn.node as HTMLInputElement).checked = msg.checked
return
case "set_node_data":
case "set_css_data": // mbtodo: remove css transitions when timeflow is not natural (on jumps)
vn = this.vTexts.get(msg.id)
if (!vn) { logger.error("Node not found", msg); return }
vn.setData(msg.data)
if (vn.node instanceof HTMLStyleElement) {
doc = this.screen.document
// TODO: move to message parsing
doc && rewriteNodeStyleSheet(doc, vn.node)
}
if (msg.tp === "set_css_data") { // Styles in priority (do we need inlines as well?)
vn.applyChanges()
}
return
case "css_insert_rule":
vn = this.vElements.get(msg.id)
if (!vn) { logger.error("Node not found", msg); return }
if (!(vn instanceof VStyleElement)) {
logger.warn("Non-style node in CSS rules message (or sheet is null)", msg, vn);
return
}
vn.onStyleSheet(sheet => {
try {
sheet.insertRule(msg.rule, msg.index)
} catch (e) {
logger.warn(e, msg)
try {
sheet.insertRule(msg.rule)
} catch (e) {
logger.warn("Cannot insert rule.", e, msg)
}
}
})
return
case "css_delete_rule":
vn = this.vElements.get(msg.id)
if (!vn) { logger.error("Node not found", msg); return }
if (!(vn instanceof VStyleElement)) {
logger.warn("Non-style node in CSS rules message (or sheet is null)", msg, vn);
return
}
vn.onStyleSheet(sheet => {
try {
sheet.deleteRule(msg.index)
} catch (e) {
logger.warn(e, msg)
}
})
return
case "create_i_frame_document":
vn = this.vElements.get(msg.frameID)
if (!vn) { logger.error("Node not found", msg); return }
const host = vn.node
if (host instanceof HTMLIFrameElement) {
const vDoc = new VDocument()
this.vRoots.set(msg.id, vDoc)
host.onload = () => {
const doc = host.contentDocument
if (!doc) {
logger.warn("No iframe doc onload", msg, host)
return
}
vDoc.setDocument(doc)
vDoc.applyChanges()
}
return;
} else if (host instanceof Element) { // shadow DOM
try {
const shadowRoot = host.attachShadow({ mode: 'open' })
vn = new VFragment(shadowRoot)
this.vRoots.set(msg.id, vn)
} catch(e) {
logger.warn("Can not attach shadow dom", e, msg)
}
} else {
logger.warn("Context message host is not Element", msg)
}
return
}
}
moveReady(t: number): Promise<void> {
// MBTODO (back jump optimisation):
// - store intemediate virtual dom state
// - cancel previous moveReady tasks (is it possible?) if new timestamp is less
this.moveApply(t, this.applyMessage) // This function autoresets pointer if necessary (better name?)
this.vRoots.forEach(rt => rt.applyChanges()) // MBTODO (optimisation): affected set
// Thinkabout (read): css preload
// What if we go back before it is ready? We'll have two handlres?
return this.stylesManager.moveReady(t).then(() => {
// Apply all scrolls after the styles got applied
this.nodeScrollManagers.forEach(manager => {
const msg = manager.moveGetLast(t)
if (msg) {
const vElm = this.vElements.get(msg.id)
if (vElm) {
vElm.node.scrollLeft = msg.x
vElm.node.scrollTop = msg.y
}
}
})
})
}
}