Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(core): add contenteditable support #1039

Merged
merged 5 commits into from
Apr 15, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ users type values according to predefined format.

- Server Side Rendering and Shadow DOM support.

- You can use it with `HTMLInputElement` and `HTMLTextAreaElement`.
- You can use it with `HTMLInputElement` or `HTMLTextAreaElement` or even with `[contenteditable]` element.

- **Maskito** core is zero-dependency package. You can mask input in your vanilla JavaScript project. However, we have
separate packages for Angular, React and Vue as well.
Expand Down
2 changes: 2 additions & 0 deletions projects/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ export {
} from './lib/constants';
export {Maskito} from './lib/mask';
export {
MaskitoElement,
MaskitoElementPredicate,
MaskitoMask,
MaskitoMaskExpression,
Expand All @@ -13,6 +14,7 @@ export {
MaskitoPreprocessor,
} from './lib/types';
export {
maskitoAdaptContentEditable,
maskitoInitialCalibrationPlugin,
maskitoPipe,
maskitoStrictCompositionPlugin,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
import type {MaskitoElementPredicate} from '../types';
import {maskitoAdaptContentEditable} from '../utils';

export const MASKITO_DEFAULT_ELEMENT_PREDICATE: MaskitoElementPredicate = e =>
e.querySelector<HTMLInputElement | HTMLTextAreaElement>('input,textarea') ||
(e as HTMLInputElement | HTMLTextAreaElement);
e.isContentEditable
? maskitoAdaptContentEditable(e)
: e.querySelector<HTMLInputElement | HTMLTextAreaElement>('input,textarea') ||
(e as HTMLInputElement | HTMLTextAreaElement);
25 changes: 20 additions & 5 deletions projects/core/src/lib/mask.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import {MaskHistory, MaskModel} from './classes';
import {MASKITO_DEFAULT_OPTIONS} from './constants';
import type {
ElementState,
MaskitoElement,
MaskitoOptions,
SelectionRange,
TypedInputEvent,
Expand Down Expand Up @@ -35,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 @@ -99,12 +100,19 @@ 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':
case 'insertFromDrop':
default:
return this.handleInsert(event, event.data || '');
return this.handleInsert(
event,
event.data ||
// `event.data` for `contentEditable` is always `null` for paste/drop events
event.dataTransfer?.getData('text/plain') ||
'',
);
}
});

Expand Down Expand Up @@ -229,7 +237,11 @@ export class Maskito extends MaskHistory {
initialState.value.slice(0, initialFrom) +
initialState.value.slice(initialTo);

if (newPossibleValue === newElementState.value && !force) {
if (
newPossibleValue === newElementState.value &&
!force &&
!this.element.isContentEditable
) {
return;
}

Expand Down Expand Up @@ -277,7 +289,10 @@ export class Maskito extends MaskHistory {
return event.preventDefault();
}

if (newPossibleValue !== newElementState.value) {
if (
newPossibleValue !== newElementState.value ||
this.element.isContentEditable
) {
event.preventDefault();

this.updateElementState(newElementState, {
Expand All @@ -289,7 +304,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 type {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 type {MaskitoOptions} from './mask-options';
import type {MaskitoElement} from './maskito-element';

export type MaskitoPlugin = (
element: HTMLInputElement | HTMLTextAreaElement,
element: 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';
}
51 changes: 51 additions & 0 deletions projects/core/src/lib/utils/content-editable.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import type {MaskitoElement, TextfieldLike} from '../types';
import {getContentEditableSelection} from './dom/get-content-editable-selection';
import {setContentEditableSelection} from './dom/set-content-editable-selection';

class ContentEditableAdapter implements TextfieldLike {
public maxLength = Infinity;

constructor(private readonly element: HTMLElement) {}

public get value(): string {
return this.element.innerText.replace(/\n\n$/, '\n');
}

public set value(value) {
// Setting into innerHTML of element with `white-space: pre;` style
this.element.innerHTML = value.replace(/\n$/, '\n\n');
}

public get selectionStart(): number | null {
return getContentEditableSelection(this.element)[0];
}

public get selectionEnd(): number | null {
return getContentEditableSelection(this.element)[1];
}

public setSelectionRange(from: number | null, to: number | null): void {
setContentEditableSelection(this.element, [from || 0, to || 0]);
}
}

export function maskitoAdaptContentEditable(element: HTMLElement): MaskitoElement {
const adapter = new ContentEditableAdapter(element);

return new Proxy(element, {
get(target, prop: keyof HTMLElement) {
if (prop in adapter) {
return adapter[prop as keyof ContentEditableAdapter];
}

const nativeProperty = target[prop];

return typeof nativeProperty === 'function'
? nativeProperty.bind(target)
: nativeProperty;
},
set(target, prop: keyof HTMLElement, val, receiver) {
return Reflect.set(prop in adapter ? adapter : target, prop, val, receiver);
},
}) as MaskitoElement;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import type {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];
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import type {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);
}
}
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 type {ElementState} from '../../types';
import type {ElementState, MaskitoElement} from '../../types';

/**
* Sets value to element, and dispatches input event
Expand All @@ -13,7 +13,7 @@ import type {ElementState} from '../../types';
* @return void
*/
export function maskitoUpdateElement(
element: HTMLInputElement | HTMLTextAreaElement,
element: MaskitoElement,
valueOrElementState: ElementState | string,
): void {
const initialValue = element.value;
Expand Down
3 changes: 3 additions & 0 deletions projects/core/src/lib/utils/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
export * from './content-editable';
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import {DemoPath} from '@demo/constants';

describe('ContentEditable | Multi-line support', () => {
describe('Deletion', () => {
beforeEach(() => {
cy.visit(DemoPath.ContentEditable);
cy.get('#multi-line [contenteditable]')
.should('be.visible')
.first()
.focus()
.as('element');
});

it('Select all + delete => Empty', () => {
cy.get('@element').type('{selectAll}{del}').should('have.text', '');
});

it('Select all + Backspace => Empty', () => {
cy.get('@element').type('{selectAll}{backspace}').should('have.text', '');
});

it('Long multi-line text', () => {
cy.get('@element')
.clear()
.type('a b{enter}cd ef{enter}aa11bb')
.should('have.text', 'a b\ncd ef\naabb')
.type('{backspace}'.repeat(5))
.should('have.text', 'a b\ncd ef')
.type('{backspace}'.repeat(2))
.should('have.text', 'a b\ncd ')
.type('{backspace}'.repeat(4))
.should('have.text', 'a b');
});
});

describe('Rejects invalid symbols on EVERY line', () => {
beforeEach(() => {
cy.visit(DemoPath.ContentEditable);
cy.get('#multi-line [contenteditable]')
.should('be.visible')
.first()
.focus()
.clear()
.should('have.text', '')
.as('element');
});

const tests = [
// [Typed value, Masked value]
['abc123 def', 'abc def'],
['abc123 def{enter}1a2b3c 4d', 'abc def\nabc d'],
['a{enter}b{enter}{enter}aa11bb', 'a\nb\n\naabb'],
] as const;
nsbarsukov marked this conversation as resolved.
Show resolved Hide resolved

tests.forEach(([typed, masked]) => {
it(`Type ${typed} => ${masked}`, () => {
cy.get('@element').type(typed).should('have.text', masked);
});
});
});
});
Loading
Loading