diff --git a/guide.md b/guide.md
index 76f781d096..c2de0111f6 100644
--- a/guide.md
+++ b/guide.md
@@ -144,8 +144,11 @@ The parameter of `rrweb.record` accepts the following options.
| blockSelector | null | Use a string to configure which selector should be blocked, refer to the [privacy](#privacy) chapter |
| ignoreClass | 'rr-ignore' | Use a string or RegExp to configure which elements should be ignored, refer to the [privacy](#privacy) chapter |
| ignoreCSSAttributes | null | array of CSS attributes that should be ignored |
+| maskAllText | false | mask all text content as \* |
| maskTextClass | 'rr-mask' | Use a string or RegExp to configure which elements should be masked, refer to the [privacy](#privacy) chapter |
+| unmaskTextClass | 'rr-unmask' | Use a string or RegExp to configure which elements should be unmasked, refer to the [privacy](#privacy) chapter |
| maskTextSelector | null | Use a string to configure which selector should be masked, refer to the [privacy](#privacy) chapter |
+| unmaskTextSelector | null | Use a string to configure which selector should be unmasked, refer to the [privacy](#privacy) chapter |
| maskAllInputs | false | mask all input content as \* |
| maskInputOptions | { password: true } | mask some kinds of input \*
refer to the [list](https://github.com/rrweb-io/rrweb/blob/588164aa12f1d94576f89ae0210b98f6e971c895/packages/rrweb-snapshot/src/types.ts#L77-L95) |
| maskInputFn | - | customize mask input content recording logic |
@@ -170,6 +173,7 @@ You may find some contents on the webpage which are not willing to be recorded,
- An element with the class name `.rr-block` will not be recorded. Instead, it will replay as a placeholder with the same dimension.
- An element with the class name `.rr-ignore` will not record its input events.
- All text of elements with the class name `.rr-mask` and their children will be masked.
+- All text of elements with the class name `.rr-unmask` and their children will be unmasked, unless any child is marked with `.rr-mask`.
- `input[type="password"]` will be masked by default.
- Mask options to mask the content in input elements.
diff --git a/packages/rrweb-snapshot/src/snapshot.ts b/packages/rrweb-snapshot/src/snapshot.ts
index 99a23ff7be..fba069dcf4 100644
--- a/packages/rrweb-snapshot/src/snapshot.ts
+++ b/packages/rrweb-snapshot/src/snapshot.ts
@@ -280,26 +280,89 @@ export function classMatchesRegex(
regex: RegExp,
checkAncestors: boolean,
): boolean {
- if (!node) return false;
+ return distanceToClassRegexMatch(node, regex, checkAncestors) >= 0;
+}
+
+function distanceToClassRegexMatch(
+ node: Node | null,
+ regex: RegExp,
+ checkAncestors: boolean,
+ distance = 0,
+): number {
+ if (!node) return -1;
if (node.nodeType !== node.ELEMENT_NODE) {
- if (!checkAncestors) return false;
- return classMatchesRegex(node.parentNode, regex, checkAncestors);
+ if (!checkAncestors) return -1;
+ return distanceToClassRegexMatch(node.parentNode, regex, checkAncestors);
}
for (let eIndex = (node as HTMLElement).classList.length; eIndex--; ) {
const className = (node as HTMLElement).classList[eIndex];
if (regex.test(className)) {
- return true;
+ return distance;
}
}
- if (!checkAncestors) return false;
- return classMatchesRegex(node.parentNode, regex, checkAncestors);
+ if (!checkAncestors) return -1;
+ return distanceToClassRegexMatch(
+ node.parentNode,
+ regex,
+ checkAncestors,
+ distance + 1,
+ );
+}
+
+function distanceToSelectorMatch(el: HTMLElement, selector: string): number {
+ if (!el) return -1;
+ if (el.matches(selector)) return 0;
+ const closestParent = el.closest(selector);
+ if (closestParent) {
+ let current = el;
+ let distance = 0;
+ while (current && current !== closestParent) {
+ current = current.parentNode as HTMLElement;
+ if (!current) {
+ return -1;
+ }
+ distance++;
+ }
+ return distance;
+ }
+ return -1;
+}
+
+function distanceToMatch(
+ el: HTMLElement,
+ className: string | RegExp,
+ selector: string | null,
+): number {
+ let classDistance = -1;
+ let selectorDistance = -1;
+
+ if (typeof className === 'string') {
+ classDistance = distanceToSelectorMatch(el, `.${className}`);
+ } else {
+ classDistance = distanceToClassRegexMatch(el, className, true);
+ }
+
+ if (selector) {
+ selectorDistance = distanceToSelectorMatch(el, selector);
+ }
+
+ return selectorDistance >= 0
+ ? classDistance >= 0
+ ? Math.min(classDistance, selectorDistance)
+ : selectorDistance
+ : classDistance >= 0
+ ? classDistance
+ : -1;
}
export function needMaskingText(
node: Node,
maskTextClass: string | RegExp,
maskTextSelector: string | null,
+ unmaskTextClass: string | RegExp,
+ unmaskTextSelector: string | null,
+ maskAllText: boolean,
): boolean {
const el: HTMLElement | null =
node.nodeType === node.ELEMENT_NODE
@@ -307,18 +370,20 @@ export function needMaskingText(
: node.parentElement;
if (el === null) return false;
- if (typeof maskTextClass === 'string') {
- if (el.classList.contains(maskTextClass)) return true;
- if (el.closest(`.${maskTextClass}`)) return true;
- } else {
- if (classMatchesRegex(el, maskTextClass, true)) return true;
- }
+ const maskDistance = distanceToMatch(el, maskTextClass, maskTextSelector);
+ const unmaskDistance = distanceToMatch(
+ el,
+ unmaskTextClass,
+ unmaskTextSelector,
+ );
- if (maskTextSelector) {
- if (el.matches(maskTextSelector)) return true;
- if (el.closest(maskTextSelector)) return true;
- }
- return false;
+ return maskDistance >= 0
+ ? unmaskDistance >= 0
+ ? maskDistance <= unmaskDistance
+ : true
+ : unmaskDistance >= 0
+ ? false
+ : !!maskAllText;
}
// https://stackoverflow.com/a/36155560
@@ -412,8 +477,11 @@ function serializeNode(
mirror: Mirror;
blockClass: string | RegExp;
blockSelector: string | null;
+ maskAllText: boolean;
maskTextClass: string | RegExp;
+ unmaskTextClass: string | RegExp;
maskTextSelector: string | null;
+ unmaskTextSelector: string | null;
inlineStylesheet: boolean;
maskInputOptions: MaskInputOptions;
maskTextFn: MaskTextFn | undefined;
@@ -433,8 +501,11 @@ function serializeNode(
mirror,
blockClass,
blockSelector,
+ maskAllText,
maskTextClass,
+ unmaskTextClass,
maskTextSelector,
+ unmaskTextSelector,
inlineStylesheet,
maskInputOptions = {},
maskTextFn,
@@ -486,8 +557,11 @@ function serializeNode(
});
case n.TEXT_NODE:
return serializeTextNode(n as Text, {
+ maskAllText,
maskTextClass,
+ unmaskTextClass,
maskTextSelector,
+ unmaskTextSelector,
maskTextFn,
rootId,
});
@@ -517,13 +591,24 @@ function getRootId(doc: Document, mirror: Mirror): number | undefined {
function serializeTextNode(
n: Text,
options: {
+ maskAllText: boolean;
maskTextClass: string | RegExp;
+ unmaskTextClass: string | RegExp;
maskTextSelector: string | null;
+ unmaskTextSelector: string | null;
maskTextFn: MaskTextFn | undefined;
rootId: number | undefined;
},
): serializedNode {
- const { maskTextClass, maskTextSelector, maskTextFn, rootId } = options;
+ const {
+ maskAllText,
+ maskTextClass,
+ unmaskTextClass,
+ maskTextSelector,
+ unmaskTextSelector,
+ maskTextFn,
+ rootId,
+ } = options;
// The parent node may not be a html element which has a tagName attribute.
// So just let it be undefined which is ok in this use case.
const parentTagName = n.parentNode && (n.parentNode as HTMLElement).tagName;
@@ -558,7 +643,14 @@ function serializeTextNode(
!isStyle &&
!isScript &&
textContent &&
- needMaskingText(n, maskTextClass, maskTextSelector)
+ needMaskingText(
+ n,
+ maskTextClass,
+ maskTextSelector,
+ unmaskTextClass,
+ unmaskTextSelector,
+ maskAllText,
+ )
) {
textContent = maskTextFn
? maskTextFn(textContent)
@@ -900,11 +992,14 @@ export function serializeNodeWithId(
blockClass: string | RegExp;
blockSelector: string | null;
maskTextClass: string | RegExp;
+ unmaskTextClass: string | RegExp;
maskTextSelector: string | null;
+ unmaskTextSelector: string | null;
skipChild: boolean;
inlineStylesheet: boolean;
newlyAddedElement?: boolean;
maskInputOptions?: MaskInputOptions;
+ maskAllText: boolean;
maskTextFn: MaskTextFn | undefined;
maskInputFn: MaskInputFn | undefined;
slimDOMOptions: SlimDOMOptions;
@@ -931,8 +1026,11 @@ export function serializeNodeWithId(
mirror,
blockClass,
blockSelector,
+ maskAllText,
maskTextClass,
+ unmaskTextClass,
maskTextSelector,
+ unmaskTextSelector,
skipChild = false,
inlineStylesheet = true,
maskInputOptions = {},
@@ -956,8 +1054,11 @@ export function serializeNodeWithId(
mirror,
blockClass,
blockSelector,
+ maskAllText,
maskTextClass,
+ unmaskTextClass,
maskTextSelector,
+ unmaskTextSelector,
inlineStylesheet,
maskInputOptions,
maskTextFn,
@@ -1028,8 +1129,11 @@ export function serializeNodeWithId(
mirror,
blockClass,
blockSelector,
+ maskAllText,
maskTextClass,
+ unmaskTextClass,
maskTextSelector,
+ unmaskTextSelector,
skipChild,
inlineStylesheet,
maskInputOptions,
@@ -1088,8 +1192,11 @@ export function serializeNodeWithId(
mirror,
blockClass,
blockSelector,
+ maskAllText,
maskTextClass,
+ unmaskTextClass,
maskTextSelector,
+ unmaskTextSelector,
skipChild: false,
inlineStylesheet,
maskInputOptions,
@@ -1135,8 +1242,11 @@ export function serializeNodeWithId(
mirror,
blockClass,
blockSelector,
+ maskAllText,
maskTextClass,
+ unmaskTextClass,
maskTextSelector,
+ unmaskTextSelector,
skipChild: false,
inlineStylesheet,
maskInputOptions,
@@ -1176,8 +1286,11 @@ function snapshot(
mirror?: Mirror;
blockClass?: string | RegExp;
blockSelector?: string | null;
+ maskAllText?: boolean;
maskTextClass?: string | RegExp;
+ unmaskTextClass?: string | RegExp;
maskTextSelector?: string | null;
+ unmaskTextSelector?: string | null;
inlineStylesheet?: boolean;
maskAllInputs?: boolean | MaskInputOptions;
maskTextFn?: MaskTextFn;
@@ -1205,8 +1318,11 @@ function snapshot(
mirror = new Mirror(),
blockClass = 'rr-block',
blockSelector = null,
+ maskAllText = false,
maskTextClass = 'rr-mask',
+ unmaskTextClass = 'rr-unmask',
maskTextSelector = null,
+ unmaskTextSelector = null,
inlineStylesheet = true,
inlineImages = false,
recordCanvas = false,
@@ -1271,8 +1387,11 @@ function snapshot(
mirror,
blockClass,
blockSelector,
+ maskAllText,
maskTextClass,
+ unmaskTextClass,
maskTextSelector,
+ unmaskTextSelector,
skipChild: false,
inlineStylesheet,
maskInputOptions,
diff --git a/packages/rrweb-snapshot/test/snapshot.test.ts b/packages/rrweb-snapshot/test/snapshot.test.ts
index 75d635e0c0..9926d1c8bb 100644
--- a/packages/rrweb-snapshot/test/snapshot.test.ts
+++ b/packages/rrweb-snapshot/test/snapshot.test.ts
@@ -6,6 +6,7 @@ import {
absoluteToStylesheet,
serializeNodeWithId,
_isBlockedElement,
+ needMaskingText,
} from '../src/snapshot';
import { serializedNodeWithId } from '../src/types';
import { Mirror } from '../src/utils';
@@ -143,8 +144,11 @@ describe('style elements', () => {
mirror: new Mirror(),
blockClass: 'blockblock',
blockSelector: null,
+ maskAllText: false,
maskTextClass: 'maskmask',
+ unmaskTextClass: 'unmaskmask',
maskTextSelector: null,
+ unmaskTextSelector: null,
skipChild: false,
inlineStylesheet: true,
maskTextFn: undefined,
@@ -188,8 +192,11 @@ describe('scrollTop/scrollLeft', () => {
mirror: new Mirror(),
blockClass: 'blockblock',
blockSelector: null,
+ maskAllText: false,
maskTextClass: 'maskmask',
+ unmaskTextClass: 'unmaskmask',
maskTextSelector: null,
+ unmaskTextSelector: null,
skipChild: false,
inlineStylesheet: true,
maskTextFn: undefined,
@@ -218,3 +225,222 @@ describe('scrollTop/scrollLeft', () => {
});
});
});
+
+describe('needMaskingText', () => {
+ const render = (html: string): HTMLDivElement => {
+ document.write(html);
+ return document.querySelector('div')!;
+ };
+
+ it('should not mask by default', () => {
+ const el = render(`