openreplay/tracker/tracker/src/main/modules/input.ts
2022-07-25 12:21:06 +02:00

197 lines
5.7 KiB
TypeScript

import type App from '../app/index.js';
import { normSpaces, IN_BROWSER, getLabelAttribute, hasOpenreplayAttribute } from '../utils.js';
import { hasTag } from '../app/guards.js';
import { SetInputTarget, SetInputValue, SetInputChecked } from '../../common/messages.js';
const INPUT_TYPES = ['text', 'password', 'email', 'search', 'number', 'range', 'date'];
// TODO: take into consideration "contenteditable" attribute
type TextEditableElement = HTMLInputElement | HTMLTextAreaElement;
function isTextEditable(node: any): node is TextEditableElement {
if (hasTag(node, 'TEXTAREA')) {
return true;
}
if (!hasTag(node, 'INPUT')) {
return false;
}
return INPUT_TYPES.includes(node.type);
}
function isCheckable(node: any): node is HTMLInputElement {
if (!hasTag(node, 'INPUT')) {
return false;
}
const type = node.type;
return type === 'checkbox' || type === 'radio';
}
const labelElementFor: (element: TextEditableElement) => HTMLLabelElement | undefined =
IN_BROWSER && 'labels' in HTMLInputElement.prototype
? (node) => {
let p: Node | null = node;
while ((p = p.parentNode) !== null) {
if (hasTag(p, 'LABEL')) {
return p;
}
}
const labels = node.labels;
if (labels !== null && labels.length === 1) {
return labels[0];
}
}
: (node) => {
let p: Node | null = node;
while ((p = p.parentNode) !== null) {
if (hasTag(p, 'LABEL')) {
return p;
}
}
const id = node.id;
if (id) {
const labels = document.querySelectorAll('label[for="' + id + '"]');
if (labels !== null && labels.length === 1) {
return labels[0] as HTMLLabelElement;
}
}
};
export function getInputLabel(node: TextEditableElement): string {
let label = getLabelAttribute(node);
if (label === null) {
const labelElement = labelElementFor(node);
label =
(labelElement && labelElement.innerText) ||
node.placeholder ||
node.name ||
node.id ||
node.className ||
node.type;
}
return normSpaces(label).slice(0, 100);
}
export declare const enum InputMode {
Plain = 0,
Obscured = 1,
Hidden = 2,
}
export interface Options {
obscureInputNumbers: boolean;
obscureInputEmails: boolean;
defaultInputMode: InputMode;
obscureInputDates: boolean;
}
export default function (app: App, opts: Partial<Options>): void {
const options: Options = Object.assign(
{
obscureInputNumbers: true,
obscureInputEmails: true,
defaultInputMode: InputMode.Plain,
obscureInputDates: false,
},
opts,
);
function sendInputTarget(id: number, node: TextEditableElement): void {
const label = getInputLabel(node);
if (label !== '') {
app.send(new SetInputTarget(id, label));
}
}
function sendInputValue(id: number, node: TextEditableElement | HTMLSelectElement): void {
let value = node.value;
let inputMode: InputMode = options.defaultInputMode;
if (node.type === 'password' || hasOpenreplayAttribute(node, 'hidden')) {
inputMode = InputMode.Hidden;
} else if (
hasOpenreplayAttribute(node, 'obscured') ||
(inputMode === InputMode.Plain &&
((options.obscureInputNumbers && node.type !== 'date' && /\d\d\d\d/.test(value)) ||
(options.obscureInputDates && node.type === 'date') ||
(options.obscureInputEmails && (node.type === 'email' || !!~value.indexOf('@')))))
) {
inputMode = InputMode.Obscured;
}
let mask = 0;
switch (inputMode) {
case InputMode.Hidden:
mask = -1;
value = '';
break;
case InputMode.Obscured:
mask = value.length;
value = '';
break;
}
app.send(new SetInputValue(id, value, mask));
}
const inputValues: Map<number, string> = new Map();
const checkableValues: Map<number, boolean> = new Map();
const registeredTargets: Set<number> = new Set();
app.attachStopCallback(() => {
inputValues.clear();
checkableValues.clear();
registeredTargets.clear();
});
app.ticker.attach((): void => {
inputValues.forEach((value, id) => {
const node = app.nodes.getNode(id);
if (!isTextEditable(node)) {
inputValues.delete(id);
return;
}
if (value !== node.value) {
inputValues.set(id, node.value);
if (!registeredTargets.has(id)) {
registeredTargets.add(id);
sendInputTarget(id, node);
}
sendInputValue(id, node);
}
});
checkableValues.forEach((checked, id) => {
const node = app.nodes.getNode(id);
if (!isCheckable(node)) {
checkableValues.delete(id);
return;
}
if (checked !== node.checked) {
checkableValues.set(id, node.checked);
app.send(new SetInputChecked(id, node.checked));
}
});
});
app.ticker.attach(Set.prototype.clear, 100, false, registeredTargets);
app.nodes.attachNodeCallback(
app.safe((node: Node): void => {
const id = app.nodes.getID(node);
if (id === undefined) {
return;
}
// TODO: support multiple select (?): use selectedOptions; Need send target?
if (hasTag(node, 'SELECT')) {
sendInputValue(id, node);
app.attachEventListener(node, 'change', () => {
sendInputValue(id, node);
});
}
if (isTextEditable(node)) {
inputValues.set(id, node.value);
sendInputValue(id, node);
return;
}
if (isCheckable(node)) {
checkableValues.set(id, node.checked);
app.send(new SetInputChecked(id, node.checked));
return;
}
}),
);
}