Skip to content

Commit

Permalink
feat(core): add contenteditable support
Browse files Browse the repository at this point in the history
  • Loading branch information
nsbarsukov committed Feb 12, 2024
1 parent b543858 commit 2100589
Show file tree
Hide file tree
Showing 17 changed files with 138 additions and 25 deletions.
2 changes: 2 additions & 0 deletions projects/core/src/index.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down
58 changes: 58 additions & 0 deletions projects/core/src/lib/classes/content-editable.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import {MaskitoElement} from '../types';
import {getContentEditableSelection, setContentEditableSelection} from '../utils';

// @ts-ignore Type MaskitoContentEditable is missing the following properties from type HTMLElement: accessKey, accessKeyLabel, autocapitalize, dir etc.
export class MaskitoContentEditable implements MaskitoElement {
maxLength = Infinity;

constructor(private readonly element: HTMLElement) {
const proxyHost = this;

/**
* We cannot just write `export class MaskitoContentEditable extends Proxy`.
* > The Proxy constructor does not have a prototype property because
* > proxy exotic objects do not have a [[Prototype]] internal slot that requires initialization.
*/
return new Proxy(element as any, {
get(target, prop: keyof HTMLElement) {
if (prop in proxyHost) {
return proxyHost[prop as keyof MaskitoContentEditable];
}

const nativeProperty = target[prop];

return typeof nativeProperty === 'function'
? nativeProperty.bind(target)
: nativeProperty;
},
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]);
}
}
1 change: 1 addition & 0 deletions projects/core/src/lib/classes/index.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
export {MaskitoContentEditable} from './content-editable';
export {MaskHistory} from './mask-history';
export {MaskModel} from './mask-model/mask-model';
9 changes: 6 additions & 3 deletions projects/core/src/lib/constants/default-element-predicate.ts
Original file line number Diff line number Diff line change
@@ -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<HTMLInputElement | HTMLTextAreaElement>('input,textarea') ||
(e as HTMLInputElement | HTMLTextAreaElement);
e.isContentEditable
? (new MaskitoContentEditable(e) as unknown as MaskitoElement)
: e.querySelector<HTMLInputElement | HTMLTextAreaElement>('input,textarea') ||
(e as HTMLInputElement | HTMLTextAreaElement);
13 changes: 10 additions & 3 deletions projects/core/src/lib/mask.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -30,7 +36,7 @@ export class Maskito extends MaskHistory {
);

constructor(
private readonly element: HTMLInputElement | HTMLTextAreaElement,
private readonly element: MaskitoElement,
private readonly maskitoOptions: MaskitoOptions,
) {
super();
Expand Down Expand Up @@ -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':
Expand Down Expand Up @@ -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');
}
}
Expand Down
7 changes: 3 additions & 4 deletions projects/core/src/lib/types/element-predicate.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import {MaskitoElement} from './maskito-element';

export type MaskitoElementPredicate = (
element: HTMLElement,
) =>
| HTMLInputElement
| HTMLTextAreaElement
| Promise<HTMLInputElement | HTMLTextAreaElement>;
) => MaskitoElement | Promise<MaskitoElement>;
1 change: 1 addition & 0 deletions projects/core/src/lib/types/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
5 changes: 5 additions & 0 deletions projects/core/src/lib/types/maskito-element.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
export type TextfieldLike = Pick<
HTMLInputElement,
'maxLength' | 'selectionEnd' | 'selectionStart' | 'setSelectionRange' | 'value'
>;
export type MaskitoElement = HTMLElement & TextfieldLike;
3 changes: 2 additions & 1 deletion projects/core/src/lib/types/plugin.ts
Original file line number Diff line number Diff line change
@@ -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<MaskitoOptions>,
) => (() => void) | void;
1 change: 1 addition & 0 deletions projects/core/src/lib/types/typed-input-event.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ export interface TypedInputEvent extends InputEvent {
| 'insertFromDrop'
| 'insertFromPaste' // Ctrl (Command) + V
| 'insertLineBreak'
| 'insertParagraph'
| 'insertReplacementText'
| 'insertText';
}
12 changes: 12 additions & 0 deletions projects/core/src/lib/utils/dom/get-content-editable-selection.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import {SelectionRange} from '../../types';

// TODO: add multi-line support later
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];
}
25 changes: 25 additions & 0 deletions projects/core/src/lib/utils/dom/set-content-editable-selection.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import {SelectionRange} from '../../types';

// TODO: add multi-line support later
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);
}
}
4 changes: 2 additions & 2 deletions projects/core/src/lib/utils/dom/update-element.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import {ElementState} from '../../types';
import {ElementState, MaskitoElement} from '../../types';

/**
* Sets value to element, and dispatches input event
Expand All @@ -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;
Expand Down
2 changes: 2 additions & 0 deletions projects/core/src/lib/utils/index.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down
7 changes: 2 additions & 5 deletions projects/kit/src/lib/plugins/event-handler.ts
Original file line number Diff line number Diff line change
@@ -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<MaskitoOptions>,
) => void,
handler: (element: MaskitoElement, options: Required<MaskitoOptions>) => void,
eventListenerOptions?: AddEventListenerOptions,
): MaskitoPlugin {
return (element, maskitoOptions) => {
Expand Down
8 changes: 4 additions & 4 deletions projects/kit/src/lib/plugins/reject-event.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
export function maskitoRejectEvent(
element: HTMLInputElement | HTMLTextAreaElement,
): () => void {
import {MaskitoPlugin} from '@maskito/core';

export const maskitoRejectEvent: MaskitoPlugin = element => {
const listener = (): void => {
const value = element.value;

Expand All @@ -20,4 +20,4 @@ export function maskitoRejectEvent(
element.addEventListener('beforeinput', listener, true);

return () => element.removeEventListener('beforeinput', listener, true);
}
};
5 changes: 2 additions & 3 deletions projects/react/src/lib/useMaskito.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import {
Maskito,
MASKITO_DEFAULT_ELEMENT_PREDICATE,
MASKITO_DEFAULT_OPTIONS,
MaskitoElement,
MaskitoElementPredicate,
MaskitoOptions,
} from '@maskito/core';
Expand Down Expand Up @@ -35,9 +36,7 @@ export const useMaskito = ({
elementPredicate?: MaskitoElementPredicate;
} = {}): RefCallback<HTMLElement> => {
const [hostElement, setHostElement] = useState<HTMLElement | null>(null);
const [element, setElement] = useState<HTMLInputElement | HTMLTextAreaElement | null>(
null,
);
const [element, setElement] = useState<MaskitoElement | null>(null);

const onRefChange: RefCallback<HTMLElement> = useCallback(
(node: HTMLElement | null) => {
Expand Down

0 comments on commit 2100589

Please sign in to comment.