diff --git a/README.md b/README.md index 716f4c00d..092a38d98 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/projects/core/src/index.ts b/projects/core/src/index.ts index c4c2b586c..ead10d94b 100644 --- a/projects/core/src/index.ts +++ b/projects/core/src/index.ts @@ -4,6 +4,7 @@ export { } from './lib/constants'; export {Maskito} from './lib/mask'; export { + MaskitoElement, MaskitoElementPredicate, MaskitoMask, MaskitoMaskExpression, @@ -13,6 +14,7 @@ export { MaskitoPreprocessor, } from './lib/types'; export { + maskitoAdaptContentEditable, maskitoInitialCalibrationPlugin, maskitoPipe, maskitoStrictCompositionPlugin, diff --git a/projects/core/src/lib/constants/default-element-predicate.ts b/projects/core/src/lib/constants/default-element-predicate.ts index edca6d3be..d2cfcedfb 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 type {MaskitoElementPredicate} from '../types'; +import {maskitoAdaptContentEditable} from '../utils'; export const MASKITO_DEFAULT_ELEMENT_PREDICATE: MaskitoElementPredicate = e => - e.querySelector('input,textarea') || - (e as HTMLInputElement | HTMLTextAreaElement); + e.isContentEditable + ? maskitoAdaptContentEditable(e) + : 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 d17db746c..a42ad675b 100644 --- a/projects/core/src/lib/mask.ts +++ b/projects/core/src/lib/mask.ts @@ -2,6 +2,7 @@ import {MaskHistory, MaskModel} from './classes'; import {MASKITO_DEFAULT_OPTIONS} from './constants'; import type { ElementState, + MaskitoElement, MaskitoOptions, SelectionRange, TypedInputEvent, @@ -35,7 +36,7 @@ export class Maskito extends MaskHistory { ); constructor( - private readonly element: HTMLInputElement | HTMLTextAreaElement, + private readonly element: MaskitoElement, private readonly maskitoOptions: MaskitoOptions, ) { super(); @@ -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') || + '', + ); } }); @@ -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; } @@ -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, { @@ -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'); } } diff --git a/projects/core/src/lib/types/element-predicate.ts b/projects/core/src/lib/types/element-predicate.ts index 9e6bcd56a..a78d3a506 100644 --- a/projects/core/src/lib/types/element-predicate.ts +++ b/projects/core/src/lib/types/element-predicate.ts @@ -1,6 +1,5 @@ +import type {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 896b02f82..bf08449e2 100644 --- a/projects/core/src/lib/types/plugin.ts +++ b/projects/core/src/lib/types/plugin.ts @@ -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, ) => (() => 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/content-editable.ts b/projects/core/src/lib/utils/content-editable.ts new file mode 100644 index 000000000..f42e99880 --- /dev/null +++ b/projects/core/src/lib/utils/content-editable.ts @@ -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; +} 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..670be08b5 --- /dev/null +++ b/projects/core/src/lib/utils/dom/get-content-editable-selection.ts @@ -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]; +} 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..75dc81209 --- /dev/null +++ b/projects/core/src/lib/utils/dom/set-content-editable-selection.ts @@ -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); + } +} diff --git a/projects/core/src/lib/utils/dom/update-element.ts b/projects/core/src/lib/utils/dom/update-element.ts index 131611e59..389086234 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 type {ElementState} from '../../types'; +import type {ElementState, MaskitoElement} from '../../types'; /** * Sets value to element, and dispatches input event @@ -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; diff --git a/projects/core/src/lib/utils/index.ts b/projects/core/src/lib/utils/index.ts index caed4fe26..3849ab5e8 100644 --- a/projects/core/src/lib/utils/index.ts +++ b/projects/core/src/lib/utils/index.ts @@ -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'; diff --git a/projects/demo/src/app/app.routes.ts b/projects/demo/src/app/app.routes.ts index f971f1558..0aeadfef1 100644 --- a/projects/demo/src/app/app.routes.ts +++ b/projects/demo/src/app/app.routes.ts @@ -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'), diff --git a/projects/demo/src/app/constants/demo-path.ts b/projects/demo/src/app/constants/demo-path.ts index 0cd61c22f..ee0fc015f 100644 --- a/projects/demo/src/app/constants/demo-path.ts +++ b/projects/demo/src/app/constants/demo-path.ts @@ -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', diff --git a/projects/demo/src/pages/documentation/what-is-maskito/what-is-maskito.template.html b/projects/demo/src/pages/documentation/what-is-maskito/what-is-maskito.template.html index c5829476e..529bc2f4b 100644 --- a/projects/demo/src/pages/documentation/what-is-maskito/what-is-maskito.template.html +++ b/projects/demo/src/pages/documentation/what-is-maskito/what-is-maskito.template.html @@ -39,9 +39,11 @@

Why Maskito?

  • You can use it with HTMLInputElement - and + / HTMLTextAreaElement - . + or even with + [contenteditable] + element .
  • diff --git a/projects/demo/src/pages/pages.ts b/projects/demo/src/pages/pages.ts index 8208d557f..5fff8353a 100644 --- a/projects/demo/src/pages/pages.ts +++ b/projects/demo/src/pages/pages.ts @@ -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', diff --git a/projects/demo/src/pages/recipes/content-editable/content-editable-doc.component.ts b/projects/demo/src/pages/recipes/content-editable/content-editable-doc.component.ts new file mode 100644 index 000000000..02ae2020a --- /dev/null +++ b/projects/demo/src/pages/recipes/content-editable/content-editable-doc.component.ts @@ -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' + ), + }; +} diff --git a/projects/demo/src/pages/recipes/content-editable/content-editable-doc.template.html b/projects/demo/src/pages/recipes/content-editable/content-editable-doc.template.html new file mode 100644 index 000000000..16169a85c --- /dev/null +++ b/projects/demo/src/pages/recipes/content-editable/content-editable-doc.template.html @@ -0,0 +1,107 @@ + +
    +

    + You can use + Maskito + with + + contentEditable + + too. +

    +

    + Just wrap the element with + maskitoAdaptContentEditable + utility and use + Maskito + in the same way as + HTMLInputElement + / + HTMLTextAreaElement + . +

    + + + No need to use + maskitoAdaptContentEditable + if you use + + @maskito/angular + + , + + @maskito/react + + or + + @maskito/vue + + with the default element predicate (it will be wrapped automatically). + + + + +

    + Learn more in the + + "Core Concepts" + + section. +

    +
    + + + + With built-in + + Time + + mask + + + + + + + Use + white-space: pre + for multi-line mode + + + +
    diff --git a/projects/demo/src/pages/recipes/content-editable/examples/1-time/component.ts b/projects/demo/src/pages/recipes/content-editable/examples/1-time/component.ts new file mode 100644 index 000000000..7bfbe7ad5 --- /dev/null +++ b/projects/demo/src/pages/recipes/content-editable/examples/1-time/component.ts @@ -0,0 +1,27 @@ +import {ChangeDetectionStrategy, Component} from '@angular/core'; +import {MaskitoDirective} from '@maskito/angular'; + +import mask from './mask'; + +@Component({ + standalone: true, + selector: 'content-editable-doc-example-1', + imports: [MaskitoDirective], + template: ` + Meeting time: + + 12:00 + + `, + styles: [ + ':host {font-size: 2.5rem}', + '[contenteditable] {border: 3px dashed lightgrey}', + ], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class ContentEditableDocExample1 { + protected readonly mask = mask; +} diff --git a/projects/demo/src/pages/recipes/content-editable/examples/1-time/mask.ts b/projects/demo/src/pages/recipes/content-editable/examples/1-time/mask.ts new file mode 100644 index 000000000..69f9b9c91 --- /dev/null +++ b/projects/demo/src/pages/recipes/content-editable/examples/1-time/mask.ts @@ -0,0 +1,5 @@ +import {maskitoTimeOptionsGenerator} from '@maskito/kit'; + +export default maskitoTimeOptionsGenerator({ + mode: 'HH:MM', +}); diff --git a/projects/demo/src/pages/recipes/content-editable/examples/2-multi-line/component.ts b/projects/demo/src/pages/recipes/content-editable/examples/2-multi-line/component.ts new file mode 100644 index 000000000..b05cab710 --- /dev/null +++ b/projects/demo/src/pages/recipes/content-editable/examples/2-multi-line/component.ts @@ -0,0 +1,35 @@ +import {ChangeDetectionStrategy, Component} from '@angular/core'; +import {MaskitoDirective} from '@maskito/angular'; + +import mask from './mask'; + +@Component({ + standalone: true, + selector: 'content-editable-doc-example-2', + imports: [MaskitoDirective], + template: ` + Enter message: +

    + `, + styles: [ + ` + [contenteditable] { + white-space: pre; + border: 3px dashed lightgrey; + max-width: 30rem; + padding: 1rem; + } + `, + ], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class ContentEditableDocExample2 { + protected readonly mask = mask; + protected initialText = `Hello, world! +How are you today? +Do not forget to read description of this example!`; +} diff --git a/projects/demo/src/pages/recipes/content-editable/examples/2-multi-line/mask.ts b/projects/demo/src/pages/recipes/content-editable/examples/2-multi-line/mask.ts new file mode 100644 index 000000000..42c172149 --- /dev/null +++ b/projects/demo/src/pages/recipes/content-editable/examples/2-multi-line/mask.ts @@ -0,0 +1,5 @@ +import type {MaskitoOptions} from '@maskito/core'; + +export default { + mask: /^[a-z\s.,/!?]+$/i, +} as MaskitoOptions; diff --git a/projects/demo/src/pages/recipes/content-editable/examples/maskito-with-content-editable.md b/projects/demo/src/pages/recipes/content-editable/examples/maskito-with-content-editable.md new file mode 100644 index 000000000..f2fc6ed27 --- /dev/null +++ b/projects/demo/src/pages/recipes/content-editable/examples/maskito-with-content-editable.md @@ -0,0 +1,14 @@ +```ts +import {Maskito, maskitoAdaptContentEditable, MaskitoOptions} from '@maskito/core'; + +const maskitoOptions: MaskitoOptions = { + mask: /^\d+$/, +}; + +const element = document.querySelector('[contenteditable]')!; + +const maskedInput = new Maskito( + maskitoAdaptContentEditable(element), // <-- This is the only difference + maskitoOptions, +); +``` diff --git a/projects/demo/src/pages/recipes/content-editable/examples/vanilla-js-tab.md b/projects/demo/src/pages/recipes/content-editable/examples/vanilla-js-tab.md new file mode 100644 index 000000000..e6ce712db --- /dev/null +++ b/projects/demo/src/pages/recipes/content-editable/examples/vanilla-js-tab.md @@ -0,0 +1,11 @@ +```ts +import {Maskito, maskitoAdaptContentEditable} from '@maskito/core'; + +import maskitoOptions from './mask'; + +const element = document.querySelector('[contenteditable]')!; + +const maskedInput = new Maskito(maskitoAdaptContentEditable(element), maskitoOptions); + +console.info('Call this function when the element is detached from DOM', maskedInput.destroy); +``` diff --git a/projects/kit/src/lib/plugins/event-handler.ts b/projects/kit/src/lib/plugins/event-handler.ts index 2c86b8e9e..f21f0dcc8 100644 --- a/projects/kit/src/lib/plugins/event-handler.ts +++ b/projects/kit/src/lib/plugins/event-handler.ts @@ -1,11 +1,8 @@ -import type {MaskitoOptions, MaskitoPlugin} from '@maskito/core'; +import type {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/kit/src/lib/plugins/reject-event.ts b/projects/kit/src/lib/plugins/reject-event.ts index d5ed7407a..fc89a6c81 100644 --- a/projects/kit/src/lib/plugins/reject-event.ts +++ b/projects/kit/src/lib/plugins/reject-event.ts @@ -1,6 +1,6 @@ -export function maskitoRejectEvent( - element: HTMLInputElement | HTMLTextAreaElement, -): () => void { +import type {MaskitoPlugin} from '@maskito/core'; + +export const maskitoRejectEvent: MaskitoPlugin = element => { const listener = (): void => { const value = element.value; @@ -20,4 +20,4 @@ export function maskitoRejectEvent( element.addEventListener('beforeinput', listener, true); return () => element.removeEventListener('beforeinput', listener, true); -} +}; diff --git a/projects/react/src/lib/useMaskito.ts b/projects/react/src/lib/useMaskito.ts index 1dfdc555b..bc399b246 100644 --- a/projects/react/src/lib/useMaskito.ts +++ b/projects/react/src/lib/useMaskito.ts @@ -1,4 +1,8 @@ -import type {MaskitoElementPredicate, MaskitoOptions} from '@maskito/core'; +import type { + MaskitoElement, + MaskitoElementPredicate, + MaskitoOptions, +} from '@maskito/core'; import {Maskito, MASKITO_DEFAULT_ELEMENT_PREDICATE} from '@maskito/core'; import type {RefCallback} from 'react'; import {useCallback, useRef, useState} from 'react'; @@ -31,9 +35,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) => {