#853 Second try: fast-forward implementation v2: virtual dom optimization (#895)

* rrdom: add a diff function for properties

* implement diffChildren function and unit tests

* finish basic functions of diff algorithm

* fix several bugs in the diff algorithm

* replace the virtual parent optimization in applyMutation()

* fix: moveAndHover after the diff algorithm is executed

* replace virtual style map with rrdom

cssom version has to be above 0.5.0 to pass virtual style tests

* fix: failed virtual style tests in replayer.test.ts

* fix: failed polyfill tests caused by nodejs compatibility of different versions

* fix: svg viewBox attribute doesn't work

Cause the attribute viewBox is case sensitive, set value for viewbox doesn't work

* feat: replace treeIndex optimization with rrdom

* fix bug of diffProps and disable smooth scrolling animation in fast-forward mode

* feat: add iframe support

* fix: @rollup/plugin-typescript build errors in rrweb-player

Error: @rollup/plugin-typescript TS1371: This import is never used as a value and must use 'import type' because the 'importsNotUsedAsValues' is set to 'error'

* fix: bug when fast-forward input events and add test for it

* add test for fast-forward scroll events

* fix: custom style rules don't get inserted into some iframe elements

* code style tweak

* fix: enable to diff iframe elements

* fix  the jest error "Unexpected token 'export'"

* try to fix build error of rrweb-player

* correct the attributes definition in rrdom

* fix: custom style rules are not inserted in some iframes

* add support for shadow dom

* add support for MediaInteraction

* add canvas support

* fix unit test error in rrdom

* add support for Text, Comment

* try to refactor RRDom

* refactor RRDom to reduce duplicate code

* rename document-browser to virtual-dom

* increase the test coverage for document.ts and add ownerDocument for it

* Merge branch 'master' into virtual-dom

* add more test for virtual-dom.ts

* use cssstyle in document-nodejs

* fix: bundle error

* improve document-nodejs

* enable to diff scroll positions of an element

* rename rrdom to virtualDom for more readability and make the tree public

* revert unknown change

* improve the css style parser for comments

* improve code style

* update typings

* add handling for the case where legacy_missingNodeRetryMap is not empty

* only import types from rrweb into rrdom

* Apply suggestions from code review

Co-authored-by: Justin Halsall <Juice10@users.noreply.github.com>

* Apply suggestions from code review

* fix building error in rrweb

* add a method setDefaultSN to set a default value for a RRNode's __sn

* fix rrweb test error and bump up other packages

* add support for custom property of css styles

* add a switch for virtual-dom optimization

* Apply suggestions from code review

1. add an enum type for NodeType
2. rename nodeType from rrweb-snapshot to RRNodeType
3. rename notSerializedId to unserializedId
4. add comments for some confusing variables

* adapt changes of #865 to virtual-dom and improve the test case for more coverage

* apply review suggestions

https://github.com/rrweb-io/rrweb/pull/853#pullrequestreview-922474953

* tweak the diff algorithm

* add description of the flag useVirtualDom and remove outdated logConfig

* Remove console.log

* Contain changes to document

* Upgrade rollup to 2.70.2

* Revert "Upgrade rollup to 2.70.2"

This reverts commit b1be81a2a76565935c9dc391f31beb7f64d25956.

* Fix type checking rrdom

* Fix typing error while bundling

* Fix tslib error on build

Rollup would output the following error:
`semantic error TS2343: This syntax requires an imported helper named '__spreadArray' which does not exist in 'tslib'. Consider upgrading your version of 'tslib'.`

* Increase memory limit for rollup

* Use esbuild for bundling

Speeds up bundling significantly

* Avoid circular dependencies and import un-bundled rrdom

* Fix imports

* Revert back to pre-esbuild

This reverts the following commits:
b7b3c8dbaa551a0129da1477136b1baaad28e6e1
72e23b8e27f9030d911358d3a17fe5ad1b3b5d4f
85d600a20c56cfa764cf1f858932ba14e67b1d23
61e1a5d323212ca8fbe0569e0b3062ddd53fc612

* Set node to lts (12 is no longer supported)

* Speed up bundling and use less memory

This fixes the out of memory errors happening while bundling

* remove __sn from rrdom

* fix typo

* test: add a test case for StyleSheet mutation exceptions while fast-forwarding

* rename Array.prototype.slice.call() to Array.from()

* improve test cases

* fix: PR #887 in 'virtual-dom' branch

* apply justin's suggestion on 'Array.from' refactor

related commit 0f6729d27a323260b36fbe79485a86715c0bc98a

* improve import code structure

Co-authored-by: Yun Feng <yun.feng@anu.edu.au>
This commit is contained in:
Justin Halsall
2022-05-12 06:01:13 +02:00
committed by GitHub
parent 69499be6e2
commit de755ae577
99 changed files with 7087 additions and 2821 deletions

View File

@@ -1,68 +1,24 @@
import { INode, NodeType, serializedNodeWithId } from 'rrweb-snapshot';
import { NWSAPI } from 'nwsapi';
import { parseCSSText, camelize, toCSSText } from './style';
import { NodeType as RRNodeType } from 'rrweb-snapshot';
import type { NWSAPI } from 'nwsapi';
import type { CSSStyleDeclaration as CSSStyleDeclarationType } from 'cssstyle';
import {
BaseRRCDATASectionImpl,
BaseRRCommentImpl,
BaseRRDocumentImpl,
BaseRRDocumentTypeImpl,
BaseRRElementImpl,
BaseRRMediaElementImpl,
BaseRRNode,
BaseRRTextImpl,
ClassList,
IRRDocument,
CSSStyleDeclaration,
} from './document';
const nwsapi = require('nwsapi');
const cssom = require('cssom');
const cssstyle = require('cssstyle');
export abstract class RRNode {
__sn: serializedNodeWithId | undefined;
children: Array<RRNode> = [];
parentElement: RRElement | null = null;
parentNode: RRNode | null = null;
ownerDocument: RRDocument | null = null;
ELEMENT_NODE = 1;
TEXT_NODE = 3;
get firstChild() {
return this.children[0];
}
get nodeType() {
if (this instanceof RRDocument) return NodeType.Document;
if (this instanceof RRDocumentType) return NodeType.DocumentType;
if (this instanceof RRElement) return NodeType.Element;
if (this instanceof RRText) return NodeType.Text;
if (this instanceof RRCDATASection) return NodeType.CDATA;
if (this instanceof RRComment) return NodeType.Comment;
}
get childNodes() {
return this.children;
}
appendChild(newChild: RRNode): RRNode {
throw new Error(
`RRDomException: Failed to execute 'appendChild' on 'RRNode': This RRNode type does not support this method.`,
);
}
insertBefore(newChild: RRNode, refChild: RRNode | null): RRNode {
throw new Error(
`RRDomException: Failed to execute 'insertBefore' on 'RRNode': This RRNode type does not support this method.`,
);
}
contains(node: RRNode) {
if (node === this) return true;
for (const child of this.children) {
if (child.contains(node)) return true;
}
return false;
}
removeChild(node: RRNode) {
const indexOfChild = this.children.indexOf(node);
if (indexOfChild !== -1) {
this.children.splice(indexOfChild, 1);
node.parentElement = null;
node.parentNode = null;
}
}
toString(nodeName?: string) {
return `${JSON.stringify(this.__sn?.id) || ''} ${nodeName}`;
}
}
export class RRNode extends BaseRRNode {}
export class RRWindow {
scrollLeft = 0;
@@ -74,8 +30,10 @@ export class RRWindow {
}
}
export class RRDocument extends RRNode {
private mirror: Map<number, RRNode> = new Map();
export class RRDocument
extends BaseRRDocumentImpl(RRNode)
implements IRRDocument {
readonly nodeName: '#document' = '#document';
private _nwsapi: NWSAPI;
get nwsapi() {
if (!this._nwsapi) {
@@ -95,66 +53,32 @@ export class RRDocument extends RRNode {
return this._nwsapi;
}
get documentElement(): RRElement {
return this.children.find(
(node) => node instanceof RRElement && node.tagName === 'HTML',
) as RRElement;
get documentElement(): RRElement | null {
return super.documentElement as RRElement | null;
}
get body() {
return (
this.documentElement?.children.find(
(node) => node instanceof RRElement && node.tagName === 'BODY',
) || null
);
get body(): RRElement | null {
return super.body as RRElement | null;
}
get head() {
return (
this.documentElement?.children.find(
(node) => node instanceof RRElement && node.tagName === 'HEAD',
) || null
);
return super.head as RRElement | null;
}
get implementation() {
get implementation(): RRDocument {
return this;
}
get firstElementChild() {
return this.documentElement;
get firstElementChild(): RRElement | null {
return this.documentElement as RRElement | null;
}
appendChild(childNode: RRNode) {
const nodeType = childNode.nodeType;
if (nodeType === NodeType.Element || nodeType === NodeType.DocumentType) {
if (this.children.some((s) => s.nodeType === nodeType)) {
throw new Error(
`RRDomException: Failed to execute 'appendChild' on 'RRNode': Only one ${
nodeType === NodeType.Element ? 'RRElement' : 'RRDoctype'
} on RRDocument allowed.`,
);
}
}
childNode.parentElement = null;
childNode.parentNode = this;
childNode.ownerDocument = this;
this.children.push(childNode);
return childNode;
return super.appendChild(childNode);
}
insertBefore(newChild: RRNode, refChild: RRNode | null) {
if (refChild === null) return this.appendChild(newChild);
const childIndex = this.children.indexOf(refChild);
if (childIndex == -1)
throw new Error(
"Failed to execute 'insertBefore' on 'RRNode': The RRNode before which the new node is to be inserted is not a child of this RRNode.",
);
this.children.splice(childIndex, 0, newChild);
newChild.parentElement = null;
newChild.parentNode = this;
newChild.ownerDocument = this;
return newChild;
return super.insertBefore(newChild, refChild);
}
querySelectorAll(selectors: string): RRNode[] {
@@ -216,16 +140,16 @@ export class RRDocument extends RRNode {
element = new RRMediaElement(upperTagName);
break;
case 'IFRAME':
element = new RRIframeElement(upperTagName);
element = new RRIFrameElement(upperTagName);
break;
case 'IMG':
element = new RRImageElement('IMG');
element = new RRImageElement(upperTagName);
break;
case 'CANVAS':
element = new RRCanvasElement('CANVAS');
element = new RRCanvasElement(upperTagName);
break;
case 'STYLE':
element = new RRStyleElement('STYLE');
element = new RRStyleElement(upperTagName);
break;
default:
element = new RRElement(upperTagName);
@@ -235,10 +159,7 @@ export class RRDocument extends RRNode {
return element;
}
createElementNS(
_namespaceURI: 'http://www.w3.org/2000/svg',
qualifiedName: string,
) {
createElementNS(_namespaceURI: string, qualifiedName: string) {
return this.createElement(qualifiedName as keyof HTMLElementTagNameMap);
}
@@ -259,266 +180,40 @@ export class RRDocument extends RRNode {
textNode.ownerDocument = this;
return textNode;
}
/**
* This does come with some side effects. For example:
* 1. All event listeners currently registered on the document, nodes inside the document, or the document's window are removed.
* 2. All existing nodes are removed from the document.
*/
open() {
this.children = [];
}
close() {}
buildFromDom(dom: Document) {
let notSerializedId = -1;
const NodeTypeMap: Record<number, number> = {};
NodeTypeMap[document.DOCUMENT_NODE] = NodeType.Document;
NodeTypeMap[document.DOCUMENT_TYPE_NODE] = NodeType.DocumentType;
NodeTypeMap[document.ELEMENT_NODE] = NodeType.Element;
NodeTypeMap[document.TEXT_NODE] = NodeType.Text;
NodeTypeMap[document.CDATA_SECTION_NODE] = NodeType.CDATA;
NodeTypeMap[document.COMMENT_NODE] = NodeType.Comment;
function getValidTagName(element: HTMLElement): string {
if (element instanceof HTMLFormElement) {
return 'FORM';
}
return element.tagName.toUpperCase().trim();
}
const walk = function (node: INode) {
let serializedNodeWithId = node.__sn;
let rrNode: RRNode;
if (!serializedNodeWithId) {
serializedNodeWithId = {
type: NodeTypeMap[node.nodeType],
textContent: '',
id: notSerializedId,
};
notSerializedId -= 1;
node.__sn = serializedNodeWithId;
}
if (!this.mirror.has(serializedNodeWithId.id)) {
switch (node.nodeType) {
case node.DOCUMENT_NODE:
if (
serializedNodeWithId.rootId &&
serializedNodeWithId.rootId !== serializedNodeWithId.id
)
rrNode = this.createDocument();
else rrNode = this;
break;
case node.DOCUMENT_TYPE_NODE:
const documentType = (node as unknown) as DocumentType;
rrNode = this.createDocumentType(
documentType.name,
documentType.publicId,
documentType.systemId,
);
break;
case node.ELEMENT_NODE:
const elementNode = (node as unknown) as HTMLElement;
const tagName = getValidTagName(elementNode);
rrNode = this.createElement(tagName);
const rrElement = rrNode as RRElement;
for (const { name, value } of Array.from(elementNode.attributes)) {
rrElement.attributes[name] = value;
}
// form fields
if (
tagName === 'INPUT' ||
tagName === 'TEXTAREA' ||
tagName === 'SELECT'
) {
const value = (elementNode as
| HTMLInputElement
| HTMLTextAreaElement).value;
if (
['RADIO', 'CHECKBOX', 'SUBMIT', 'BUTTON'].includes(
rrElement.attributes.type as string,
) &&
value
) {
rrElement.attributes.value = value;
} else if ((elementNode as HTMLInputElement).checked) {
rrElement.attributes.checked = (elementNode as HTMLInputElement).checked;
}
}
if (tagName === 'OPTION') {
const selectValue = (elementNode as HTMLOptionElement)
.parentElement;
if (
rrElement.attributes.value ===
(selectValue as HTMLSelectElement).value
) {
rrElement.attributes.selected = (elementNode as HTMLOptionElement).selected;
}
}
// canvas image data
if (tagName === 'CANVAS') {
rrElement.attributes.rr_dataURL = (elementNode as HTMLCanvasElement).toDataURL();
}
// media elements
if (tagName === 'AUDIO' || tagName === 'VIDEO') {
const rrMediaElement = rrElement as RRMediaElement;
rrMediaElement.paused = (elementNode as HTMLMediaElement).paused;
rrMediaElement.currentTime = (elementNode as HTMLMediaElement).currentTime;
}
// scroll
if (elementNode.scrollLeft) {
rrElement.scrollLeft = elementNode.scrollLeft;
}
if (elementNode.scrollTop) {
rrElement.scrollTop = elementNode.scrollTop;
}
break;
case node.TEXT_NODE:
rrNode = this.createTextNode(
((node as unknown) as Text).textContent,
);
break;
case node.CDATA_SECTION_NODE:
rrNode = this.createCDATASection();
break;
case node.COMMENT_NODE:
rrNode = this.createComment(
((node as unknown) as Comment).textContent || '',
);
break;
default:
return;
}
rrNode.__sn = serializedNodeWithId;
this.mirror.set(serializedNodeWithId.id, rrNode);
} else {
rrNode = this.mirror.get(serializedNodeWithId.id);
rrNode.parentElement = null;
rrNode.parentNode = null;
rrNode.children = [];
}
const parentNode = node.parentElement || node.parentNode;
if (parentNode) {
const parentSN = ((parentNode as unknown) as INode).__sn;
const parentRRNode = this.mirror.get(parentSN.id);
parentRRNode.appendChild(rrNode);
rrNode.parentNode = parentRRNode;
rrNode.parentElement =
parentRRNode instanceof RRElement ? parentRRNode : null;
}
if (
serializedNodeWithId.type === NodeType.Document ||
serializedNodeWithId.type === NodeType.Element
) {
node.childNodes.forEach((node) => walk((node as unknown) as INode));
}
}.bind(this);
if (dom) {
this.destroyTree();
walk((dom as unknown) as INode);
}
}
destroyTree() {
this.children = [];
this.mirror.clear();
}
toString() {
return super.toString('RRDocument');
}
}
export class RRDocumentType extends RRNode {
readonly name: string;
readonly publicId: string;
readonly systemId: string;
constructor(qualifiedName: string, publicId: string, systemId: string) {
super();
this.name = qualifiedName;
this.publicId = publicId;
this.systemId = systemId;
}
toString() {
return super.toString('RRDocumentType');
}
}
export class RRElement extends RRNode {
tagName: string;
attributes: Record<string, string | number | boolean> = {};
scrollLeft: number = 0;
scrollTop: number = 0;
shadowRoot: RRElement | null = null;
export class RRDocumentType extends BaseRRDocumentTypeImpl(RRNode) {}
export class RRElement extends BaseRRElementImpl(RRNode) {
private _style: CSSStyleDeclarationType;
constructor(tagName: string) {
super();
this.tagName = tagName;
}
get classList() {
return new ClassList(
this.attributes.class as string | undefined,
(newClassName) => {
this.attributes.class = newClassName;
super(tagName);
this._style = new cssstyle.CSSStyleDeclaration();
const style = this._style;
Object.defineProperty(this.attributes, 'style', {
get() {
return style.cssText;
},
);
set(cssText: string) {
style.cssText = cssText;
},
});
}
get id() {
return this.attributes.id;
}
get className() {
return this.attributes.class || '';
}
get textContent() {
return '';
}
set textContent(newText: string) {}
get style() {
const style = (this.attributes.style
? parseCSSText(this.attributes.style as string)
: {}) as Record<string, string> & {
setProperty: (
name: string,
value: string | null,
priority?: string | null,
) => void;
};
style.setProperty = (name: string, value: string | null) => {
const normalizedName = camelize(name);
if (!value) delete style[normalizedName];
else style[normalizedName] = value;
this.attributes.style = toCSSText(style);
};
// This is used to bypass the smoothscroll polyfill in rrweb player.
style.scrollBehavior = '';
return style;
return (this._style as unknown) as CSSStyleDeclaration;
}
get firstElementChild(): RRElement | null {
for (let child of this.children)
if (child instanceof RRElement) return child;
return null;
attachShadow(_init: ShadowRootInit): RRElement {
return super.attachShadow(_init) as RRElement;
}
get nextElementSibling(): RRElement | null {
let parentNode = this.parentNode;
if (!parentNode) return null;
const siblings = parentNode.children;
let index = siblings.indexOf(this);
for (let i = index + 1; i < siblings.length; i++)
if (siblings[i] instanceof RRElement) return siblings[i] as RRElement;
return null;
appendChild(newChild: RRNode): RRNode {
return super.appendChild(newChild) as RRNode;
}
insertBefore(newChild: RRNode, refChild: RRNode | null): RRNode {
return super.insertBefore(newChild, refChild) as RRNode;
}
getAttribute(name: string) {
@@ -531,57 +226,44 @@ export class RRElement extends RRNode {
this.attributes[name.toLowerCase()] = attribute;
}
hasAttribute(name: string) {
return (name && name.toLowerCase()) in this.attributes;
}
setAttributeNS(
_namespace: string | null,
qualifiedName: string,
value: string,
): void {
this.setAttribute(qualifiedName, value);
}
removeAttribute(name: string) {
delete this.attributes[name];
delete this.attributes[name.toLowerCase()];
}
appendChild(newChild: RRNode): RRNode {
this.children.push(newChild);
newChild.parentNode = this;
newChild.parentElement = this;
newChild.ownerDocument = this.ownerDocument;
return newChild;
get firstElementChild(): RRElement | null {
for (let child of this.childNodes)
if (child.RRNodeType === RRNodeType.Element) return child as RRElement;
return null;
}
insertBefore(newChild: RRNode, refChild: RRNode | null): RRNode {
if (refChild === null) return this.appendChild(newChild);
const childIndex = this.children.indexOf(refChild);
if (childIndex == -1)
throw new Error(
"Failed to execute 'insertBefore' on 'RRNode': The RRNode before which the new node is to be inserted is not a child of this RRNode.",
);
this.children.splice(childIndex, 0, newChild);
newChild.parentElement = null;
newChild.parentNode = this;
newChild.ownerDocument = this.ownerDocument;
return newChild;
get nextElementSibling(): RRElement | null {
let parentNode = this.parentNode;
if (!parentNode) return null;
const siblings = parentNode.childNodes;
let index = siblings.indexOf(this);
for (let i = index + 1; i < siblings.length; i++)
if (siblings[i] instanceof RRElement) return siblings[i] as RRElement;
return null;
}
querySelectorAll(selectors: string): RRNode[] {
const result: RRElement[] = [];
if (this.ownerDocument !== null) {
return (this.ownerDocument.nwsapi.select(
((this.ownerDocument as RRDocument).nwsapi.select(
selectors,
(this as unknown) as Element,
(element) => {
if (((element as unknown) as RRElement) !== this)
result.push((element as unknown) as RRElement);
},
) as unknown) as RRNode[];
}
return [];
return result;
}
getElementById(elementId: string): RRElement | null {
if (this instanceof RRElement && this.id === elementId) return this;
for (const child of this.children) {
if (this.id === elementId) return this;
for (const child of this.childNodes) {
if (child instanceof RRElement) {
const result = child.getElementById(elementId);
if (result !== null) return result;
@@ -596,12 +278,12 @@ export class RRElement extends RRNode {
// Make sure this element has all queried class names.
if (
this instanceof RRElement &&
queryClassList.filter((queriedClassName) =>
this.classList.some((name) => name === queriedClassName),
).length == queryClassList.length
queryClassList.classes.filter((queriedClassName) =>
this.classList.classes.some((name) => name === queriedClassName),
).length == queryClassList.classes.length
)
elements.push(this);
for (const child of this.children) {
for (const child of this.childNodes) {
if (child instanceof RRElement)
elements = elements.concat(child.getElementsByClassName(className));
}
@@ -613,32 +295,12 @@ export class RRElement extends RRNode {
const normalizedTagName = tagName.toUpperCase();
if (this instanceof RRElement && this.tagName === normalizedTagName)
elements.push(this);
for (const child of this.children) {
for (const child of this.childNodes) {
if (child instanceof RRElement)
elements = elements.concat(child.getElementsByTagName(tagName));
}
return elements;
}
dispatchEvent(_event: Event) {
return true;
}
/**
* Creates a shadow root for element and returns it.
*/
attachShadow(init: ShadowRootInit): RRElement {
this.shadowRoot = init.mode === 'open' ? this : null;
return this;
}
toString() {
let attributeString = '';
for (let attribute in this.attributes) {
attributeString += `${attribute}="${this.attributes[attribute]}" `;
}
return `${super.toString(this.tagName)} ${attributeString}`;
}
}
export class RRImageElement extends RRElement {
@@ -648,16 +310,7 @@ export class RRImageElement extends RRElement {
onload: ((this: GlobalEventHandlers, ev: Event) => any) | null;
}
export class RRMediaElement extends RRElement {
currentTime: number = 0;
paused: boolean = true;
async play() {
this.paused = false;
}
async pause() {
this.paused = true;
}
}
export class RRMediaElement extends BaseRRMediaElementImpl(RRElement) {}
export class RRCanvasElement extends RRElement {
/**
@@ -675,7 +328,7 @@ export class RRStyleElement extends RRElement {
if (!this._sheet) {
let result = '';
for (let child of this.childNodes)
if (child.nodeType === NodeType.Text)
if (child.RRNodeType === RRNodeType.Text)
result += (child as RRText).textContent;
this._sheet = cssom.parse(result);
}
@@ -683,7 +336,7 @@ export class RRStyleElement extends RRElement {
}
}
export class RRIframeElement extends RRElement {
export class RRIFrameElement extends RRElement {
width: string = '';
height: string = '';
src: string = '';
@@ -699,89 +352,27 @@ export class RRIframeElement extends RRElement {
}
}
export class RRText extends RRNode {
textContent: string;
constructor(data: string) {
super();
this.textContent = data;
}
toString() {
return `${super.toString('RRText')} text=${JSON.stringify(
this.textContent,
)}`;
}
export class RRText extends BaseRRTextImpl(RRNode) {
readonly nodeName: '#text' = '#text';
}
export class RRComment extends RRNode {
data: string;
constructor(data: string) {
super();
this.data = data;
}
toString() {
return `${super.toString('RRComment')} data=${JSON.stringify(this.data)}`;
}
export class RRComment extends BaseRRCommentImpl(RRNode) {
readonly nodeName: '#comment' = '#comment';
}
export class RRCDATASection extends RRNode {
data: string;
constructor(data: string) {
super();
this.data = data;
}
toString() {
return `${super.toString('RRCDATASection')} data=${JSON.stringify(
this.data,
)}`;
}
export class RRCDATASection extends BaseRRCDATASectionImpl(RRNode) {
readonly nodeName: '#cdata-section' = '#cdata-section';
}
interface RRElementTagNameMap {
img: RRImageElement;
audio: RRMediaElement;
canvas: RRCanvasElement;
iframe: RRIFrameElement;
img: RRImageElement;
style: RRStyleElement;
video: RRMediaElement;
}
type RRElementType<
K extends keyof HTMLElementTagNameMap
> = K extends keyof RRElementTagNameMap ? RRElementTagNameMap[K] : RRElement;
class ClassList extends Array {
private onChange: ((newClassText: string) => void) | undefined;
constructor(
classText?: string,
onChange?: ((newClassText: string) => void) | undefined,
) {
super();
if (classText) {
const classes = classText.trim().split(/\s+/);
super.push(...classes);
}
this.onChange = onChange;
}
add = (...classNames: string[]) => {
for (const item of classNames) {
const className = String(item);
if (super.indexOf(className) >= 0) continue;
super.push(className);
}
this.onChange && this.onChange(super.join(' '));
};
remove = (...classNames: string[]) => {
for (const item of classNames) {
const className = String(item);
const index = super.indexOf(className);
if (index < 0) continue;
super.splice(index, 1);
}
this.onChange && this.onChange(super.join(' '));
};
}