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 Apr 2, 2024
1 parent 5d364f9 commit 54abc1c
Show file tree
Hide file tree
Showing 29 changed files with 416 additions and 30 deletions.
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
7 changes: 5 additions & 2 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 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;
}
11 changes: 11 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,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];
}
24 changes: 24 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,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
8 changes: 8 additions & 0 deletions projects/demo/src/app/app.routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,14 @@ export const appRoutes: Routes = [
title: 'Textarea',
},
},
{
path: DemoPath.ContentEditable,
loadComponent: () =>
import('../pages/recipes/content-editable/content-editable-doc.component'),
data: {
title: 'ContentEditable',
},
},
{
path: DemoPath.Prefix,
loadComponent: () => import('../pages/recipes/prefix/prefix-doc.component'),
Expand Down
1 change: 1 addition & 0 deletions projects/demo/src/app/constants/demo-path.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ export const DemoPath = {
Card: 'recipes/card',
Phone: 'recipes/phone',
Textarea: 'recipes/textarea',
ContentEditable: 'recipes/content-editable',
Prefix: 'recipes/prefix',
Postfix: 'recipes/postfix',
Placeholder: 'recipes/placeholder',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,9 +39,11 @@ <h2>Why Maskito?</h2>
<li class="tui-list__item">
You can use it with
<code>HTMLInputElement</code>
and
/
<code>HTMLTextAreaElement</code>
.
or even with
<code>[contenteditable]</code>
element .
</li>

<li class="tui-list__item">
Expand Down
6 changes: 6 additions & 0 deletions projects/demo/src/pages/pages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,12 @@ export const DEMO_PAGES: TuiDocPages = [
route: DemoPath.Textarea,
keywords: 'textarea, latin, mask, recipe',
},
{
section: 'Recipes',
title: 'ContentEditable',
route: DemoPath.ContentEditable,
keywords: 'content, editable, contenteditable, contentEditable, mask, recipe',
},
{
section: 'Recipes',
title: 'With prefix',
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import {ChangeDetectionStrategy, Component} from '@angular/core';
import {RouterLink} from '@angular/router';
import {DemoPath, DocExamplePrimaryTab} from '@demo/constants';
import type {TuiDocExample} from '@taiga-ui/addon-doc';
import {TuiAddonDocModule} from '@taiga-ui/addon-doc';
import {TuiLinkModule, TuiNotificationModule} from '@taiga-ui/core';

import {ContentEditableDocExample1} from './examples/1-time/component';
import {ContentEditableDocExample2} from './examples/2-multi-line/component';

@Component({
standalone: true,
selector: 'content-editable-doc',
imports: [
TuiAddonDocModule,
TuiLinkModule,
RouterLink,
ContentEditableDocExample1,
ContentEditableDocExample2,
TuiNotificationModule,
],
templateUrl: './content-editable-doc.template.html',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export default class ContentEditableDocComponent {
protected readonly coreConceptsOverviewDocPage = `/${DemoPath.CoreConceptsOverview}`;
protected readonly timeMaskDocPage = `/${DemoPath.Time}`;
protected readonly angularDocPage = `/${DemoPath.Angular}`;
protected readonly reactDocPage = `/${DemoPath.React}`;
protected readonly vueDocPage = `/${DemoPath.Vue}`;
protected readonly maskitoWithContentEditableDemo = import(
'./examples/maskito-with-content-editable.md?raw'
);

protected readonly contentEditableExample1: TuiDocExample = {
[DocExamplePrimaryTab.MaskitoOptions]: import('./examples/1-time/mask.ts?raw'),
[DocExamplePrimaryTab.JavaScript]: import('./examples/vanilla-js-tab.md?raw'),
[DocExamplePrimaryTab.Angular]: import('./examples/1-time/component.ts?raw'),
};

protected readonly contentEditableExample2: TuiDocExample = {
[DocExamplePrimaryTab.MaskitoOptions]: import(
'./examples/2-multi-line/mask.ts?raw'
),
[DocExamplePrimaryTab.JavaScript]: import('./examples/vanilla-js-tab.md?raw'),
[DocExamplePrimaryTab.Angular]: import(
'./examples/2-multi-line/component.ts?raw'
),
};
}
Loading

0 comments on commit 54abc1c

Please sign in to comment.