tweb/src/components/inputField.ts
Eduard Kuzmenko c61e31c421 Fix pasting styles and comments
Fallback to plain text if length of rich and plain are different
2022-01-19 07:11:39 +04:00

379 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/*
* https://github.com/morethanwords/tweb
* Copyright (C) 2019-2021 Eduard Kuzmenko
* https://github.com/morethanwords/tweb/blob/master/LICENSE
*/
import simulateEvent from "../helpers/dom/dispatchEvent";
import findUpAttribute from "../helpers/dom/findUpAttribute";
import getRichValue from "../helpers/dom/getRichValue";
import isInputEmpty from "../helpers/dom/isInputEmpty";
import selectElementContents from "../helpers/dom/selectElementContents";
import { MessageEntity } from "../layer";
import { i18n, LangPackKey, _i18n } from "../lib/langPack";
import RichTextProcessor from "../lib/richtextprocessor";
import SetTransition from "./singleTransition";
let init = () => {
document.addEventListener('paste', (e) => {
if(!findUpAttribute(e.target, 'contenteditable="true"')) {
return;
}
e.preventDefault();
let text: string, entities: MessageEntity[];
// @ts-ignore
let plainText: string = (e.originalEvent || e).clipboardData.getData('text/plain');
let usePlainText = true;
// @ts-ignore
let html: string = (e.originalEvent || e).clipboardData.getData('text/html');
if(html.trim()) {
html = html.replace(/<style([\s\S]*)<\/style>/, '');
html = html.replace(/<!--([\s\S]*)-->/, '');
const match = html.match(/<body>([\s\S]*)<\/body>/);
if(match) {
html = match[1].trim();
}
let span: HTMLElement = document.createElement('span');
span.innerHTML = html;
let curChild = span.firstChild;
while(curChild) { // * fix whitespace between elements like <p>asd</p>\n<p>zxc</p>
let nextSibling = curChild.nextSibling;
if(curChild.nodeType === 3) {
if(!curChild.nodeValue.trim()) {
curChild.remove();
}
}
curChild = nextSibling;
}
const richValue = getRichValue(span, true);
if(richValue.value.replace(/\s/g, '').length === plainText.replace(/\s/g, '').length) {
text = richValue.value;
entities = richValue.entities;
usePlainText = false;
let entities2 = RichTextProcessor.parseEntities(text);
entities2 = entities2.filter(e => e._ === 'messageEntityEmoji' || e._ === 'messageEntityLinebreak');
RichTextProcessor.mergeEntities(entities, entities2);
}
}
if(usePlainText) {
text = plainText;
entities = RichTextProcessor.parseEntities(text);
entities = entities.filter(e => e._ === 'messageEntityEmoji' || e._ === 'messageEntityLinebreak');
}
text = RichTextProcessor.wrapDraftText(text, {entities});
window.document.execCommand('insertHTML', false, text);
});
init = null;
};
// ! it doesn't respect symbols other than strongs
/* const checkAndSetRTL = (input: HTMLElement) => {
//const isEmpty = isInputEmpty(input);
//console.log('input', isEmpty);
//const char = [...getRichValue(input)][0];
const char = (input instanceof HTMLInputElement ? input.value : input.innerText)[0];
let direction = 'ltr';
if(char && checkRTL(char)) {
direction = 'rtl';
}
//console.log('RTL', direction, char);
input.style.direction = direction;
}; */
export enum InputState {
Neutral = 0,
Valid = 1,
Error = 2
};
export type InputFieldOptions = {
placeholder?: LangPackKey,
label?: LangPackKey,
labelOptions?: any[],
labelText?: string,
name?: string,
maxLength?: number,
showLengthOn?: number,
plainText?: true,
animate?: boolean,
required?: boolean,
canBeEdited?: boolean,
validate?: () => boolean
};
class InputField {
public container: HTMLElement;
public input: HTMLElement;
public inputFake: HTMLElement;
public label: HTMLLabelElement;
public originalValue: string;
public required: boolean;
public validate: () => boolean;
//public onLengthChange: (length: number, isOverflow: boolean) => void;
// protected wasInputFakeClientHeight: number;
// protected showScrollDebounced: () => void;
constructor(public options: InputFieldOptions = {}) {
this.container = document.createElement('div');
this.container.classList.add('input-field');
this.required = options.required;
this.validate = options.validate;
if(options.maxLength !== undefined && options.showLengthOn === undefined) {
options.showLengthOn = Math.min(40, Math.round(options.maxLength / 3));
}
const {placeholder, maxLength, showLengthOn, name, plainText, canBeEdited = true} = options;
let label = options.label || options.labelText;
let input: HTMLElement;
if(!plainText) {
if(init) {
init();
}
this.container.innerHTML = `
<div contenteditable="${String(!!canBeEdited)}" class="input-field-input"></div>
`;
input = this.container.firstElementChild as HTMLElement;
const observer = new MutationObserver(() => {
//checkAndSetRTL(input);
if(processInput) {
processInput();
}
});
// * because if delete all characters there will br left
input.addEventListener('input', () => {
if(isInputEmpty(input)) {
input.innerHTML = '';
}
if(this.inputFake) {
this.inputFake.innerHTML = input.innerHTML;
this.onFakeInput();
}
});
// ! childList for paste first symbol
observer.observe(input, {characterData: true, childList: true, subtree: true});
if(options.animate) {
input.classList.add('scrollable', 'scrollable-y');
// this.wasInputFakeClientHeight = 0;
// this.showScrollDebounced = debounce(() => this.input.classList.remove('no-scrollbar'), 150, false, true);
this.inputFake = document.createElement('div');
this.inputFake.setAttribute('contenteditable', 'true');
this.inputFake.className = input.className + ' input-field-input-fake';
}
} else {
this.container.innerHTML = `
<input type="text" ${name ? `name="${name}"` : ''} autocomplete="off" ${label ? 'required=""' : ''} class="input-field-input">
`;
input = this.container.firstElementChild as HTMLElement;
//input.addEventListener('input', () => checkAndSetRTL(input));
}
input.setAttribute('dir', 'auto');
if(placeholder) {
_i18n(input, placeholder, undefined, 'placeholder');
if(this.inputFake) {
_i18n(this.inputFake, placeholder, undefined, 'placeholder');
}
}
if(label || placeholder) {
const border = document.createElement('div');
border.classList.add('input-field-border');
this.container.append(border);
}
if(label) {
this.label = document.createElement('label');
this.setLabel();
this.container.append(this.label);
}
let processInput: () => void;
if(maxLength) {
const labelEl = this.container.lastElementChild as HTMLLabelElement;
let showingLength = false;
processInput = () => {
const wasError = input.classList.contains('error');
// * https://stackoverflow.com/a/54369605 #2 to count emoji as 1 symbol
const inputLength = plainText ? (input as HTMLInputElement).value.length : [...getRichValue(input, false).value].length;
const diff = maxLength - inputLength;
const isError = diff < 0;
input.classList.toggle('error', isError);
//this.onLengthChange && this.onLengthChange(inputLength, isError);
if(isError || diff <= showLengthOn) {
this.setLabel();
labelEl.append(` (${maxLength - inputLength})`);
if(!showingLength) showingLength = true;
} else if((wasError && !isError) || showingLength) {
this.setLabel();
showingLength = false;
}
};
input.addEventListener('input', processInput);
}
this.input = input;
}
public select() {
if(!this.value) { // * avoid selecting whole empty field on iOS devices
return;
}
if(this.options.plainText) {
(this.input as HTMLInputElement).select(); // * select text
} else {
selectElementContents(this.input);
}
}
public setLabel() {
this.label.textContent = '';
if(this.options.labelText) {
this.label.innerHTML = this.options.labelText;
} else {
this.label.append(i18n(this.options.label, this.options.labelOptions));
}
}
public onFakeInput(setHeight = true) {
const {scrollHeight: newHeight/* , clientHeight */} = this.inputFake;
/* if(this.wasInputFakeClientHeight && this.wasInputFakeClientHeight !== clientHeight) {
this.input.classList.add('no-scrollbar'); // ! в сафари может вообще не появиться скролл после анимации, так как ему нужен полный reflow блока с overflow.
this.showScrollDebounced();
} */
const currentHeight = +this.input.style.height.replace('px', '');
if(currentHeight === newHeight) {
return;
}
const TRANSITION_DURATION_FACTOR = 50;
const transitionDuration = Math.round(
TRANSITION_DURATION_FACTOR * Math.log(Math.abs(newHeight - currentHeight)),
);
// this.wasInputFakeClientHeight = clientHeight;
this.input.style.transitionDuration = `${transitionDuration}ms`;
if(setHeight) {
this.input.style.height = newHeight ? newHeight + 'px' : '';
}
const className = 'is-changing-height';
SetTransition(this.input, className, true, transitionDuration, () => {
this.input.classList.remove(className);
});
}
get value() {
return this.options.plainText ? (this.input as HTMLInputElement).value : getRichValue(this.input, false).value;
//return getRichValue(this.input);
}
set value(value: string) {
this.setValueSilently(value, false);
simulateEvent(this.input, 'input');
}
public setValueSilently(value: string, fireFakeInput = true) {
if(this.options.plainText) {
(this.input as HTMLInputElement).value = value;
} else {
this.input.innerHTML = value;
if(this.inputFake) {
this.inputFake.innerHTML = value;
if(fireFakeInput) {
this.onFakeInput();
}
}
}
}
public isChanged() {
return this.value !== this.originalValue;
}
public isValid() {
return !this.input.classList.contains('error') &&
(!this.validate || this.validate()) &&
(!this.required || !isInputEmpty(this.input));
}
public isValidToChange() {
return this.isValid() && this.isChanged();
}
public setDraftValue(value = '', silent = false) {
if(!this.options.plainText) {
value = RichTextProcessor.wrapDraftText(value);
}
if(silent) {
this.setValueSilently(value, false);
} else {
this.value = value;
}
}
public setOriginalValue(value: InputField['originalValue'] = '', silent = false) {
this.originalValue = value;
this.setDraftValue(value, silent);
}
public setState(state: InputState, label?: LangPackKey) {
if(label) {
this.label.textContent = '';
this.label.append(i18n(label, this.options.labelOptions));
}
this.input.classList.toggle('error', !!(state & InputState.Error));
this.input.classList.toggle('valid', !!(state & InputState.Valid));
}
public setError(label?: LangPackKey) {
this.setState(InputState.Error, label);
}
}
export default InputField;