import { insertRule, deleteRule } from './safeCSSRules'; import { isRootNode } from 'App/player/guards' function isNode(sth: any): sth is Node { return !!sth && sth.nodeType != null } type Callback = (o: T) => void /** * Virtual Node base class. * Implements common abstract methods and lazy node creation logic. * * @privateRemarks * Would be better to export type-only, but didn't find a nice way to do that. */ export abstract class VNode { protected abstract createNode(): T private _node: T | null /** * JS DOM Node getter with lazy node creation * * @returns underneath JS DOM Node * @remarks should not be called unless the real node is required since creation might be expensive * It is better to use `onNode` callback applicator unless in the `applyChanges` implementation */ get node(): T { if (!this._node) { const node = this._node = this.createNode() this.nodeCallbacks.forEach(cb => cb(node)) this.nodeCallbacks = [] } return this._node } private nodeCallbacks: Callback[] = [] /** * Lazy Node callback applicator * * @param callback - Callback that fires on existing JS DOM Node instantly if it exists * or whenever it gets created. Call sequence is concerned. */ onNode(callback: Callback) { if (this._node) { callback(this._node) return } this.nodeCallbacks.push(callback) } /** * Abstract method, should be implemented by the actual classes * It is supposed to apply virtual changes into the actual DOM */ public abstract applyChanges(): void } type VChild = VElement | VText | VSpriteMap abstract class VParent extends VNode{ /** */ protected children: VChild[] = [] private notMontedChildren: Set = new Set() insertChildAt(child: VChild, index: number) { if (child.parentNode) { child.parentNode.removeChild(child) } this.children.splice(index, 0, child) this.notMontedChildren.add(child) child.parentNode = this } removeChild(child: VChild) { this.children = this.children.filter(ch => ch !== child) this.notMontedChildren.delete(child) child.parentNode = null } protected mountChildren(shouldInsert?: (child: VChild) => boolean) { let nextMounted: VChild | null = null for (let i = this.children.length-1; i >= 0; i--) { const child = this.children[i] if (this.notMontedChildren.has(child) && (!shouldInsert || shouldInsert(child)) // is there a better way of not-knowing about subclass logic on prioritized insertion? ) { this.node.insertBefore(child.node, nextMounted ? nextMounted.node : null) this.notMontedChildren.delete(child) } if (!this.notMontedChildren.has(child)) { nextMounted = child } } } applyChanges() { /* Building a sub-trees first (in-memory for non-mounted children) */ this.children.forEach(child => child.applyChanges()) /* Inserting */ this.mountChildren() if (this.notMontedChildren.size !== 0) { console.error("VParent: Something went wrong with children insertion") } /* Removing in-between */ const node = this.node const realChildren = node.childNodes if (realChildren.length > 0 && this.children.length > 0) { for(let j = 0; j < this.children.length; j++) { while (realChildren[j] !== this.children[j].node) { if (isNode(realChildren[j])) { node.removeChild(realChildren[j]) } } } } /* Removing tail */ while(realChildren.length > this.children.length) { node.removeChild(node.lastChild as Node) /* realChildren.length > this.children.length >= 0 so it is not null */ } } } export class VDocument extends VParent { constructor(protected readonly createNode: () => Document) { super() } applyChanges() { if (this.children.length > 1) { console.error("VDocument expected to have a single child.", this) } const child = this.children[0] if (!child) { return } child.applyChanges() const htmlNode = child.node if (htmlNode.parentNode !== this.node) { this.node.replaceChild(htmlNode, this.node.documentElement) } } } export class VShadowRoot extends VParent { constructor(protected readonly createNode: () => ShadowRoot) { super() } } export type VRoot = VDocument | VShadowRoot export class VSpriteMap extends VParent { parentNode: VParent | null = null; /** Should be modified only by he parent itself */ private newAttributes: Map = new Map(); constructor( readonly tagName: string, readonly isSVG = true, public readonly index: number, private readonly nodeId: number ) { super(); this.createNode(); } protected createNode() { try { const element = document.createElementNS( 'http://www.w3.org/2000/svg', this.tagName ); element.dataset['openreplayId'] = this.nodeId.toString(); return element; } catch (e) { console.error( 'Openreplay: Player received invalid html tag', this.tagName, e ); return document.createElement(this.tagName.replace(/[^a-z]/gi, '')); } } applyChanges() { // this is a hack to prevent the sprite map from being removed from the DOM return null; } } export class VElement extends VParent { parentNode: VParent | null = null /** Should be modified only by he parent itself */ private newAttributes: Map = new Map() constructor(readonly tagName: string, readonly isSVG = false, public readonly index: number, private readonly nodeId: number) { super() } protected createNode() { try { const element = this.isSVG ? document.createElementNS('http://www.w3.org/2000/svg', this.tagName) : document.createElement(this.tagName) element.dataset['openreplayId'] = this.nodeId.toString() return element } catch (e) { console.error('Openreplay: Player received invalid html tag', this.tagName, e) return document.createElement(this.tagName.replace(/[^a-z]/gi, '')) } } setAttribute(name: string, value: string) { this.newAttributes.set(name, value) } removeAttribute(name: string) { this.newAttributes.set(name, false) } private applyAttributeChanges() { // "changes" -> "updates" ? this.newAttributes.forEach((value, key) => { if (value === false) { this.node.removeAttribute(key) } else { try { this.node.setAttribute(key, value) } catch (e) { console.error(e) } } }) this.newAttributes.clear() } applyChanges() { this.prioritized && this.applyPrioritizedChanges() this.applyAttributeChanges() super.applyChanges() } /** Insertion Prioritization * Made for styles that should be inserted as prior, * otherwise it will show visual styling lag if there is a transition CSS property) */ prioritized = false insertChildAt(child: VChild, index: number) { super.insertChildAt(child, index) /* Bubble prioritization */ if (child instanceof VElement && child.prioritized) { let parent: VParent | null = this while (parent instanceof VElement && !parent.prioritized) { parent.prioritized = true parent = parent.parentNode } } } private applyPrioritizedChanges() { this.children.forEach(child => { if (child instanceof VText) { child.applyChanges() } else if (child.prioritized) { /* Update prioritized VElement-s */ child.applyPrioritizedChanges() child.applyAttributeChanges() } }) this.mountChildren(child => child instanceof VText || child.prioritized) } } export class VHTMLElement extends VElement { constructor(node: HTMLElement) { super("HTML", false) this.createNode = () => node } } export class VText extends VNode { parentNode: VParent | null = null protected createNode() { return new Text() } private data: string = "" private changed: boolean = false setData(data: string) { this.data = data this.changed = true } applyChanges() { if (this.changed) { this.node.data = this.data this.changed = false } } } class PromiseQueue { onCatch?: (err?: any) => void constructor(private promise: Promise) {} /** * Call sequence is concerned. */ // Doing this with callbacks list instead might be more efficient (but more wordy). TODO: research whenReady(cb: Callback) { this.promise = this.promise.then(vRoot => { cb(vRoot as T) return vRoot }).catch(e => { this.onCatch?.(e) }) } catch(cb:(err?: any) => void) { this.onCatch = cb } } /** * VRoot wrapper that allows to defer all the API calls till the moment * when VNode CAN be created (for example, on