* Perf: don't run the regex replace unless the selectorText contains a colon (rules generally contain colons) * Need to check type before querying selectorText property - also good as it means we only try to fix colons at the leaf level --------- Authored-by: eoghan murray <eoghan@getthere.ie>
334 lines
9.8 KiB
TypeScript
334 lines
9.8 KiB
TypeScript
import {
|
|
idNodeMap,
|
|
MaskInputFn,
|
|
MaskInputOptions,
|
|
nodeMetaMap,
|
|
IMirror,
|
|
serializedNodeWithId,
|
|
serializedNode,
|
|
NodeType,
|
|
documentNode,
|
|
documentTypeNode,
|
|
textNode,
|
|
elementNode,
|
|
} from './types';
|
|
|
|
export function isElement(n: Node): n is Element {
|
|
return n.nodeType === n.ELEMENT_NODE;
|
|
}
|
|
|
|
export function isShadowRoot(n: Node): n is ShadowRoot {
|
|
const host: Element | null = (n as ShadowRoot)?.host;
|
|
return Boolean(host?.shadowRoot === n);
|
|
}
|
|
|
|
/**
|
|
* To fix the issue https://github.com/rrweb-io/rrweb/issues/933.
|
|
* Some websites use polyfilled shadow dom and this function is used to detect this situation.
|
|
*/
|
|
export function isNativeShadowDom(shadowRoot: ShadowRoot) {
|
|
return Object.prototype.toString.call(shadowRoot) === '[object ShadowRoot]';
|
|
}
|
|
|
|
/**
|
|
* Browsers sometimes destructively modify the css rules they receive.
|
|
* This function tries to rectify the modifications the browser made to make it more cross platform compatible.
|
|
* @param cssText - output of `CSSStyleRule.cssText`
|
|
* @returns `cssText` with browser inconsistencies fixed.
|
|
*/
|
|
function fixBrowserCompatibilityIssuesInCSS(cssText: string): string {
|
|
/**
|
|
* Chrome outputs `-webkit-background-clip` as `background-clip` in `CSSStyleRule.cssText`.
|
|
* But then Chrome ignores `background-clip` as css input.
|
|
* Re-introduce `-webkit-background-clip` to fix this issue.
|
|
*/
|
|
if (
|
|
cssText.includes(' background-clip: text;') &&
|
|
!cssText.includes(' -webkit-background-clip: text;')
|
|
) {
|
|
cssText = cssText.replace(
|
|
' background-clip: text;',
|
|
' -webkit-background-clip: text; background-clip: text;',
|
|
);
|
|
}
|
|
return cssText;
|
|
}
|
|
|
|
// Remove this declaration once typescript has added `CSSImportRule.supportsText` to the lib.
|
|
declare interface CSSImportRule extends CSSRule {
|
|
readonly href: string;
|
|
readonly layerName: string | null;
|
|
readonly media: MediaList;
|
|
readonly styleSheet: CSSStyleSheet;
|
|
/**
|
|
* experimental API, currently only supported in firefox
|
|
* https://developer.mozilla.org/en-US/docs/Web/API/CSSImportRule/supportsText
|
|
*/
|
|
readonly supportsText?: string | null;
|
|
}
|
|
|
|
/**
|
|
* Browsers sometimes incorrectly escape `@import` on `.cssText` statements.
|
|
* This function tries to correct the escaping.
|
|
* more info: https://bugs.chromium.org/p/chromium/issues/detail?id=1472259
|
|
* @param cssImportRule
|
|
* @returns `cssText` with browser inconsistencies fixed, or null if not applicable.
|
|
*/
|
|
export function escapeImportStatement(rule: CSSImportRule): string {
|
|
const { cssText } = rule;
|
|
if (cssText.split('"').length < 3) return cssText;
|
|
|
|
const statement = ['@import', `url(${JSON.stringify(rule.href)})`];
|
|
if (rule.layerName === '') {
|
|
statement.push(`layer`);
|
|
} else if (rule.layerName) {
|
|
statement.push(`layer(${rule.layerName})`);
|
|
}
|
|
if (rule.supportsText) {
|
|
statement.push(`supports(${rule.supportsText})`);
|
|
}
|
|
if (rule.media.length) {
|
|
statement.push(rule.media.mediaText);
|
|
}
|
|
return statement.join(' ') + ';';
|
|
}
|
|
|
|
export function stringifyStylesheet(s: CSSStyleSheet): string | null {
|
|
try {
|
|
const rules = s.rules || s.cssRules;
|
|
return rules
|
|
? fixBrowserCompatibilityIssuesInCSS(
|
|
Array.from(rules).map(stringifyRule).join(''),
|
|
)
|
|
: null;
|
|
} catch (error) {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
export function stringifyRule(rule: CSSRule): string {
|
|
let importStringified;
|
|
if (isCSSImportRule(rule)) {
|
|
try {
|
|
importStringified =
|
|
// for same-origin stylesheets,
|
|
// we can access the imported stylesheet rules directly
|
|
stringifyStylesheet(rule.styleSheet) ||
|
|
// work around browser issues with the raw string `@import url(...)` statement
|
|
escapeImportStatement(rule);
|
|
} catch (error) {
|
|
// ignore
|
|
}
|
|
} else if (isCSSStyleRule(rule) && rule.selectorText.includes(':')) {
|
|
// Safari does not escape selectors with : properly
|
|
// see https://bugs.webkit.org/show_bug.cgi?id=184604
|
|
return fixSafariColons(rule.cssText);
|
|
}
|
|
|
|
return importStringified || rule.cssText;
|
|
}
|
|
|
|
export function fixSafariColons(cssStringified: string): string {
|
|
// Replace e.g. [aa:bb] with [aa\\:bb]
|
|
const regex = /(\[(?:[\w-]+)[^\\])(:(?:[\w-]+)\])/gm;
|
|
return cssStringified.replace(regex, '$1\\$2');
|
|
}
|
|
|
|
export function isCSSImportRule(rule: CSSRule): rule is CSSImportRule {
|
|
return 'styleSheet' in rule;
|
|
}
|
|
|
|
export function isCSSStyleRule(rule: CSSRule): rule is CSSStyleRule {
|
|
return 'selectorText' in rule;
|
|
}
|
|
|
|
export class Mirror implements IMirror<Node> {
|
|
private idNodeMap: idNodeMap = new Map();
|
|
private nodeMetaMap: nodeMetaMap = new WeakMap();
|
|
|
|
getId(n: Node | undefined | null): number {
|
|
if (!n) return -1;
|
|
|
|
const id = this.getMeta(n)?.id;
|
|
|
|
// if n is not a serialized Node, use -1 as its id.
|
|
return id ?? -1;
|
|
}
|
|
|
|
getNode(id: number): Node | null {
|
|
return this.idNodeMap.get(id) || null;
|
|
}
|
|
|
|
getIds(): number[] {
|
|
return Array.from(this.idNodeMap.keys());
|
|
}
|
|
|
|
getMeta(n: Node): serializedNodeWithId | null {
|
|
return this.nodeMetaMap.get(n) || null;
|
|
}
|
|
|
|
// removes the node from idNodeMap
|
|
// doesn't remove the node from nodeMetaMap
|
|
removeNodeFromMap(n: Node) {
|
|
const id = this.getId(n);
|
|
this.idNodeMap.delete(id);
|
|
|
|
if (n.childNodes) {
|
|
n.childNodes.forEach((childNode) =>
|
|
this.removeNodeFromMap(childNode as unknown as Node),
|
|
);
|
|
}
|
|
}
|
|
has(id: number): boolean {
|
|
return this.idNodeMap.has(id);
|
|
}
|
|
|
|
hasNode(node: Node): boolean {
|
|
return this.nodeMetaMap.has(node);
|
|
}
|
|
|
|
add(n: Node, meta: serializedNodeWithId) {
|
|
const id = meta.id;
|
|
this.idNodeMap.set(id, n);
|
|
this.nodeMetaMap.set(n, meta);
|
|
}
|
|
|
|
replace(id: number, n: Node) {
|
|
const oldNode = this.getNode(id);
|
|
if (oldNode) {
|
|
const meta = this.nodeMetaMap.get(oldNode);
|
|
if (meta) this.nodeMetaMap.set(n, meta);
|
|
}
|
|
this.idNodeMap.set(id, n);
|
|
}
|
|
|
|
reset() {
|
|
this.idNodeMap = new Map();
|
|
this.nodeMetaMap = new WeakMap();
|
|
}
|
|
}
|
|
|
|
export function createMirror(): Mirror {
|
|
return new Mirror();
|
|
}
|
|
|
|
export function maskInputValue({
|
|
element,
|
|
maskInputOptions,
|
|
tagName,
|
|
type,
|
|
value,
|
|
maskInputFn,
|
|
}: {
|
|
element: HTMLElement;
|
|
maskInputOptions: MaskInputOptions;
|
|
tagName: string;
|
|
type: string | null;
|
|
value: string | null;
|
|
maskInputFn?: MaskInputFn;
|
|
}): string {
|
|
let text = value || '';
|
|
const actualType = type && toLowerCase(type);
|
|
|
|
if (
|
|
maskInputOptions[tagName.toLowerCase() as keyof MaskInputOptions] ||
|
|
(actualType && maskInputOptions[actualType as keyof MaskInputOptions])
|
|
) {
|
|
if (maskInputFn) {
|
|
text = maskInputFn(text, element);
|
|
} else {
|
|
text = '*'.repeat(text.length);
|
|
}
|
|
}
|
|
return text;
|
|
}
|
|
|
|
export function toLowerCase<T extends string>(str: T): Lowercase<T> {
|
|
return str.toLowerCase() as unknown as Lowercase<T>;
|
|
}
|
|
|
|
const ORIGINAL_ATTRIBUTE_NAME = '__rrweb_original__';
|
|
type PatchedGetImageData = {
|
|
[ORIGINAL_ATTRIBUTE_NAME]: CanvasImageData['getImageData'];
|
|
} & CanvasImageData['getImageData'];
|
|
|
|
export function is2DCanvasBlank(canvas: HTMLCanvasElement): boolean {
|
|
const ctx = canvas.getContext('2d');
|
|
if (!ctx) return true;
|
|
|
|
const chunkSize = 50;
|
|
|
|
// get chunks of the canvas and check if it is blank
|
|
for (let x = 0; x < canvas.width; x += chunkSize) {
|
|
for (let y = 0; y < canvas.height; y += chunkSize) {
|
|
// eslint-disable-next-line @typescript-eslint/unbound-method
|
|
const getImageData = ctx.getImageData as PatchedGetImageData;
|
|
const originalGetImageData =
|
|
ORIGINAL_ATTRIBUTE_NAME in getImageData
|
|
? getImageData[ORIGINAL_ATTRIBUTE_NAME]
|
|
: getImageData;
|
|
// by getting the canvas in chunks we avoid an expensive
|
|
// `getImageData` call that retrieves everything
|
|
// even if we can already tell from the first chunk(s) that
|
|
// the canvas isn't blank
|
|
const pixelBuffer = new Uint32Array(
|
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument, @typescript-eslint/no-unsafe-member-access
|
|
originalGetImageData.call(
|
|
ctx,
|
|
x,
|
|
y,
|
|
Math.min(chunkSize, canvas.width - x),
|
|
Math.min(chunkSize, canvas.height - y),
|
|
).data.buffer,
|
|
);
|
|
if (pixelBuffer.some((pixel) => pixel !== 0)) return false;
|
|
}
|
|
}
|
|
return true;
|
|
}
|
|
|
|
export function isNodeMetaEqual(a: serializedNode, b: serializedNode): boolean {
|
|
if (!a || !b || a.type !== b.type) return false;
|
|
if (a.type === NodeType.Document)
|
|
return a.compatMode === (b as documentNode).compatMode;
|
|
else if (a.type === NodeType.DocumentType)
|
|
return (
|
|
a.name === (b as documentTypeNode).name &&
|
|
a.publicId === (b as documentTypeNode).publicId &&
|
|
a.systemId === (b as documentTypeNode).systemId
|
|
);
|
|
else if (
|
|
a.type === NodeType.Comment ||
|
|
a.type === NodeType.Text ||
|
|
a.type === NodeType.CDATA
|
|
)
|
|
return a.textContent === (b as textNode).textContent;
|
|
else if (a.type === NodeType.Element)
|
|
return (
|
|
a.tagName === (b as elementNode).tagName &&
|
|
JSON.stringify(a.attributes) ===
|
|
JSON.stringify((b as elementNode).attributes) &&
|
|
a.isSVG === (b as elementNode).isSVG &&
|
|
a.needBlock === (b as elementNode).needBlock
|
|
);
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Get the type of an input element.
|
|
* This takes care of the case where a password input is changed to a text input.
|
|
* In this case, we continue to consider this of type password, in order to avoid leaking sensitive data
|
|
* where passwords should be masked.
|
|
*/
|
|
export function getInputType(element: HTMLElement): Lowercase<string> | null {
|
|
// when omitting the type of input element(e.g. <input />), the type is treated as text
|
|
const type = (element as HTMLInputElement).type;
|
|
|
|
return element.hasAttribute('data-rr-is-password')
|
|
? 'password'
|
|
: type
|
|
? // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion
|
|
toLowerCase(type)
|
|
: null;
|
|
}
|