From bd0cfdfe5d9300a493ed95ff66d995fbab926289 Mon Sep 17 00:00:00 2001 From: Nikita Barsukov Date: Fri, 9 Feb 2024 15:42:53 +0300 Subject: [PATCH] feat(core): add `contenteditable` support --- projects/core/src/index.ts | 2 + .../core/src/lib/classes/content-editable.ts | 54 +++++++++++++++++++ projects/core/src/lib/classes/index.ts | 1 + .../constants/default-element-predicate.ts | 9 ++-- projects/core/src/lib/mask.ts | 13 +++-- .../core/src/lib/types/element-predicate.ts | 7 ++- projects/core/src/lib/types/index.ts | 1 + .../core/src/lib/types/maskito-element.ts | 5 ++ projects/core/src/lib/types/plugin.ts | 3 +- .../core/src/lib/types/typed-input-event.ts | 1 + .../dom/get-content-editable-selection.ts | 11 ++++ .../dom/set-content-editable-selection.ts | 24 +++++++++ .../core/src/lib/utils/dom/update-element.ts | 4 +- projects/core/src/lib/utils/index.ts | 2 + projects/kit/src/lib/plugins/event-handler.ts | 7 +-- projects/react/src/lib/useMaskito.ts | 5 +- 16 files changed, 128 insertions(+), 21 deletions(-) create mode 100644 projects/core/src/lib/classes/content-editable.ts create mode 100644 projects/core/src/lib/types/maskito-element.ts create mode 100644 projects/core/src/lib/utils/dom/get-content-editable-selection.ts create mode 100644 projects/core/src/lib/utils/dom/set-content-editable-selection.ts diff --git a/projects/core/src/index.ts b/projects/core/src/index.ts index c4c2b586c..5f5b574d7 100644 --- a/projects/core/src/index.ts +++ b/projects/core/src/index.ts @@ -1,9 +1,11 @@ +export {MaskitoContentEditable} from './lib/classes'; export { MASKITO_DEFAULT_ELEMENT_PREDICATE, MASKITO_DEFAULT_OPTIONS, } from './lib/constants'; export {Maskito} from './lib/mask'; export { + MaskitoElement, MaskitoElementPredicate, MaskitoMask, MaskitoMaskExpression, diff --git a/projects/core/src/lib/classes/content-editable.ts b/projects/core/src/lib/classes/content-editable.ts new file mode 100644 index 000000000..0b5191f89 --- /dev/null +++ b/projects/core/src/lib/classes/content-editable.ts @@ -0,0 +1,54 @@ +import {MaskitoElement} from '../types'; +import {getContentEditableSelection, setContentEditableSelection} from '../utils'; + +// @ts-ignore +export class MaskitoContentEditable implements MaskitoElement { + maxLength = Infinity; + + constructor(private readonly element: HTMLElement) { + const proxyHost = this; + + return new Proxy(element as any, { + get(target, prop: keyof HTMLElement) { + if (prop in proxyHost) { + // @ts-ignore + return proxyHost[prop]; + } + + const nativeProperty = target[prop]; + + return typeof nativeProperty === 'function' + ? nativeProperty.bind(target) + : target[prop]; + }, + set(target, prop: keyof HTMLElement, val, receiver) { + return Reflect.set( + prop in proxyHost ? proxyHost : target, + prop, + val, + receiver, + ); + }, + }); + } + + get value(): string { + return this.element.textContent || ''; + } + + set value(value) { + this.element.textContent = value; + } + + get selectionStart(): number | null { + return getContentEditableSelection(this.element)[0]; + } + + get selectionEnd(): number | null { + return getContentEditableSelection(this.element)[1]; + } + + setSelectionRange(from: number | null, to: number | null): void { + setContentEditableSelection(this.element, [from || 0, to || 0]); + } +} diff --git a/projects/core/src/lib/classes/index.ts b/projects/core/src/lib/classes/index.ts index 8a76a83b6..507fb6a4f 100644 --- a/projects/core/src/lib/classes/index.ts +++ b/projects/core/src/lib/classes/index.ts @@ -1,2 +1,3 @@ +export {MaskitoContentEditable} from './content-editable'; export {MaskHistory} from './mask-history'; export {MaskModel} from './mask-model/mask-model'; diff --git a/projects/core/src/lib/constants/default-element-predicate.ts b/projects/core/src/lib/constants/default-element-predicate.ts index f5ea33f65..92faaa2d8 100644 --- a/projects/core/src/lib/constants/default-element-predicate.ts +++ b/projects/core/src/lib/constants/default-element-predicate.ts @@ -1,5 +1,8 @@ -import {MaskitoElementPredicate} from '../types'; +import {MaskitoContentEditable} from '../classes'; +import {MaskitoElement, MaskitoElementPredicate} from '../types'; export const MASKITO_DEFAULT_ELEMENT_PREDICATE: MaskitoElementPredicate = e => - e.querySelector('input,textarea') || - (e as HTMLInputElement | HTMLTextAreaElement); + e.isContentEditable + ? (new MaskitoContentEditable(e) as unknown as MaskitoElement) + : e.querySelector('input,textarea') || + (e as HTMLInputElement | HTMLTextAreaElement); diff --git a/projects/core/src/lib/mask.ts b/projects/core/src/lib/mask.ts index a728fa37f..4d3fea74e 100644 --- a/projects/core/src/lib/mask.ts +++ b/projects/core/src/lib/mask.ts @@ -1,6 +1,12 @@ import {MaskHistory, MaskModel} from './classes'; import {MASKITO_DEFAULT_OPTIONS} from './constants'; -import {ElementState, MaskitoOptions, SelectionRange, TypedInputEvent} from './types'; +import { + ElementState, + MaskitoElement, + MaskitoOptions, + SelectionRange, + TypedInputEvent, +} from './types'; import { areElementValuesEqual, EventListener, @@ -30,7 +36,7 @@ export class Maskito extends MaskHistory { ); constructor( - private readonly element: HTMLInputElement | HTMLTextAreaElement, + private readonly element: MaskitoElement, private readonly maskitoOptions: MaskitoOptions, ) { super(); @@ -94,6 +100,7 @@ export class Maskito extends MaskHistory { case 'insertCompositionText': return; // will be handled inside `compositionend` event case 'insertLineBreak': + case 'insertParagraph': return this.handleEnter(event); case 'insertFromPaste': case 'insertText': @@ -284,7 +291,7 @@ export class Maskito extends MaskHistory { } private handleEnter(event: TypedInputEvent): void { - if (this.isTextArea) { + if (this.isTextArea || this.element.isContentEditable) { this.handleInsert(event, '\n'); } } diff --git a/projects/core/src/lib/types/element-predicate.ts b/projects/core/src/lib/types/element-predicate.ts index 9e6bcd56a..1128c75a9 100644 --- a/projects/core/src/lib/types/element-predicate.ts +++ b/projects/core/src/lib/types/element-predicate.ts @@ -1,6 +1,5 @@ +import {MaskitoElement} from './maskito-element'; + export type MaskitoElementPredicate = ( element: HTMLElement, -) => - | HTMLInputElement - | HTMLTextAreaElement - | Promise; +) => MaskitoElement | Promise; diff --git a/projects/core/src/lib/types/index.ts b/projects/core/src/lib/types/index.ts index 84254a051..30cb3ea9b 100644 --- a/projects/core/src/lib/types/index.ts +++ b/projects/core/src/lib/types/index.ts @@ -3,6 +3,7 @@ export * from './element-state'; export * from './mask'; export * from './mask-options'; export * from './mask-processors'; +export * from './maskito-element'; export * from './plugin'; export * from './selection-range'; export * from './typed-input-event'; diff --git a/projects/core/src/lib/types/maskito-element.ts b/projects/core/src/lib/types/maskito-element.ts new file mode 100644 index 000000000..df37daeb0 --- /dev/null +++ b/projects/core/src/lib/types/maskito-element.ts @@ -0,0 +1,5 @@ +export type TextfieldLike = Pick< + HTMLInputElement, + 'maxLength' | 'selectionEnd' | 'selectionStart' | 'setSelectionRange' | 'value' +>; +export type MaskitoElement = HTMLElement & TextfieldLike; diff --git a/projects/core/src/lib/types/plugin.ts b/projects/core/src/lib/types/plugin.ts index f52a65fdb..5376c19af 100644 --- a/projects/core/src/lib/types/plugin.ts +++ b/projects/core/src/lib/types/plugin.ts @@ -1,6 +1,7 @@ import {MaskitoOptions} from './mask-options'; +import {MaskitoElement} from './maskito-element'; export type MaskitoPlugin = ( - element: HTMLInputElement | HTMLTextAreaElement, + element: HTMLInputElement | HTMLTextAreaElement | MaskitoElement, options: Required, ) => (() => void) | void; diff --git a/projects/core/src/lib/types/typed-input-event.ts b/projects/core/src/lib/types/typed-input-event.ts index e43a71821..49826d83f 100644 --- a/projects/core/src/lib/types/typed-input-event.ts +++ b/projects/core/src/lib/types/typed-input-event.ts @@ -15,6 +15,7 @@ export interface TypedInputEvent extends InputEvent { | 'insertFromDrop' | 'insertFromPaste' // Ctrl (Command) + V | 'insertLineBreak' + | 'insertParagraph' | 'insertReplacementText' | 'insertText'; } diff --git a/projects/core/src/lib/utils/dom/get-content-editable-selection.ts b/projects/core/src/lib/utils/dom/get-content-editable-selection.ts new file mode 100644 index 000000000..0525105fb --- /dev/null +++ b/projects/core/src/lib/utils/dom/get-content-editable-selection.ts @@ -0,0 +1,11 @@ +import {SelectionRange} from '../../types'; + +export function getContentEditableSelection(element: HTMLElement): SelectionRange { + const {anchorOffset = 0, focusOffset = 0} = + element.ownerDocument.getSelection() || {}; + + const from = Math.min(anchorOffset, focusOffset); + const to = Math.max(anchorOffset, focusOffset); + + return [from, to]; +} diff --git a/projects/core/src/lib/utils/dom/set-content-editable-selection.ts b/projects/core/src/lib/utils/dom/set-content-editable-selection.ts new file mode 100644 index 000000000..8373f4c96 --- /dev/null +++ b/projects/core/src/lib/utils/dom/set-content-editable-selection.ts @@ -0,0 +1,24 @@ +import {SelectionRange} from '../../types'; + +export function setContentEditableSelection( + element: HTMLElement, + [from, to]: SelectionRange, +): void { + const document = element.ownerDocument; + const range = document.createRange(); + + range.setStart( + element.firstChild || element, + Math.min(from, element.textContent?.length || 0), + ); + range.setEnd( + element.lastChild || element, + Math.min(to, element.textContent?.length || 0), + ); + const selection = document.getSelection(); + + if (selection) { + selection.removeAllRanges(); + selection.addRange(range); + } +} diff --git a/projects/core/src/lib/utils/dom/update-element.ts b/projects/core/src/lib/utils/dom/update-element.ts index 0e8a299d0..91252a481 100644 --- a/projects/core/src/lib/utils/dom/update-element.ts +++ b/projects/core/src/lib/utils/dom/update-element.ts @@ -1,4 +1,4 @@ -import {ElementState} from '../../types'; +import {ElementState, MaskitoElement} from '../../types'; /** * Sets value to element, and dispatches input event @@ -13,7 +13,7 @@ import {ElementState} from '../../types'; * @return void */ export function maskitoUpdateElement( - element: HTMLInputElement | HTMLTextAreaElement, + element: MaskitoElement, valueOrElementState: ElementState | string, ): void { const initialValue = element.value; diff --git a/projects/core/src/lib/utils/index.ts b/projects/core/src/lib/utils/index.ts index caed4fe26..816b1e0c1 100644 --- a/projects/core/src/lib/utils/index.ts +++ b/projects/core/src/lib/utils/index.ts @@ -1,5 +1,7 @@ export * from './dom/event-listener'; +export * from './dom/get-content-editable-selection'; export * from './dom/history-events'; +export * from './dom/set-content-editable-selection'; export * from './dom/update-element'; export * from './element-states-equality'; export * from './get-line-selection'; diff --git a/projects/kit/src/lib/plugins/event-handler.ts b/projects/kit/src/lib/plugins/event-handler.ts index f29fecf6e..a2e73b08f 100644 --- a/projects/kit/src/lib/plugins/event-handler.ts +++ b/projects/kit/src/lib/plugins/event-handler.ts @@ -1,11 +1,8 @@ -import {MaskitoOptions, MaskitoPlugin} from '@maskito/core'; +import {MaskitoElement, MaskitoOptions, MaskitoPlugin} from '@maskito/core'; export function maskitoEventHandler( name: string, - handler: ( - element: HTMLInputElement | HTMLTextAreaElement, - options: Required, - ) => void, + handler: (element: MaskitoElement, options: Required) => void, eventListenerOptions?: AddEventListenerOptions, ): MaskitoPlugin { return (element, maskitoOptions) => { diff --git a/projects/react/src/lib/useMaskito.ts b/projects/react/src/lib/useMaskito.ts index d042590a9..70901bc7b 100644 --- a/projects/react/src/lib/useMaskito.ts +++ b/projects/react/src/lib/useMaskito.ts @@ -2,6 +2,7 @@ import { Maskito, MASKITO_DEFAULT_ELEMENT_PREDICATE, MASKITO_DEFAULT_OPTIONS, + MaskitoElement, MaskitoElementPredicate, MaskitoOptions, } from '@maskito/core'; @@ -35,9 +36,7 @@ export const useMaskito = ({ elementPredicate?: MaskitoElementPredicate; } = {}): RefCallback => { const [hostElement, setHostElement] = useState(null); - const [element, setElement] = useState( - null, - ); + const [element, setElement] = useState(null); const onRefChange: RefCallback = useCallback( (node: HTMLElement | null) => {