diff --git a/package.json b/package.json index de6e07e5..4dc481d0 100644 --- a/package.json +++ b/package.json @@ -5,5 +5,9 @@ "packageManager": "yarn@4.0.1", "workspaces": [ "packages/*" - ] + ], + "scripts": { + "lint": "yarn workspaces foreach -A run lint", + "lint:fix": "yarn workspaces foreach -A run lint --fix" + } } diff --git a/packages/dom-adapters/package.json b/packages/dom-adapters/package.json index 0c500db9..9098ea07 100644 --- a/packages/dom-adapters/package.json +++ b/packages/dom-adapters/package.json @@ -33,6 +33,7 @@ "typescript": "^5.2.2" }, "dependencies": { + "@editorjs/dom": "^1.0.0", "@editorjs/model": "workspace:^" } } diff --git a/packages/dom-adapters/src/BlockToolAdapter/index.ts b/packages/dom-adapters/src/BlockToolAdapter/index.ts index 9784d4ec..a31869e5 100644 --- a/packages/dom-adapters/src/BlockToolAdapter/index.ts +++ b/packages/dom-adapters/src/BlockToolAdapter/index.ts @@ -1,17 +1,20 @@ import type { DataKey, EditorJSModel, ModelEvents } from '@editorjs/model'; -import { EventType, EventAction, composeDataIndex } from '@editorjs/model'; -import { InputType } from './types/InputType.js'; import { + composeDataIndex, + EventAction, + EventType, TextAddedEvent, TextRemovedEvent } from '@editorjs/model'; +import { InputType } from './types/InputType.js'; import { CaretAdapter } from '../caret/CaretAdapter.js'; -import { getAbsoluteRangeOffset, getBoundaryPointByAbsoluteOffset } from '../utils/index.js'; +import { + getAbsoluteRangeOffset, + getBoundaryPointByAbsoluteOffset, + isNonTextInput +} from '../utils/index.js'; -enum NativeInput { - Textarea = 'TEXTAREA', - Input = 'INPUT', -} +import { isNativeInput } from '@editorjs/dom'; /** * BlockToolAdapter is using inside Block tools to connect browser DOM elements to the model @@ -48,13 +51,8 @@ export class BlockToolAdapter { * @param input - input element */ public attachInput(key: DataKey, input: HTMLElement): void { - const inputTag = input.tagName as NativeInput; - - /** - * @todo Filter non-text-editable inputs - */ - if (![NativeInput.Textarea, NativeInput.Input].includes(inputTag) && !input.isContentEditable) { - throw new Error('BlockToolAdapter: input should be either INPUT, TEXTAREA or contenteditable element'); + if (input instanceof HTMLInputElement && isNonTextInput(input)) { + throw new Error('Cannot attach non-text input'); } const caretAdapter = new CaretAdapter(input, this.#model, this.#blockIndex, key); @@ -64,6 +62,65 @@ export class BlockToolAdapter { this.#model.addEventListener(EventType.Changed, (event: ModelEvents) => this.#handleModelUpdate(event, input, key, caretAdapter)); } + /** + * Handles delete events in native input + * + * @param event - beforeinput event + * @param input - input element + * @param key - data key input is attached to + * @private + */ + #handleDeleteInNativeInput(event: InputEvent, input: HTMLInputElement | HTMLTextAreaElement, key: DataKey): void { + const inputType = event.inputType as InputType; + + /** + * Check that selection exists in current input + */ + if (input.selectionStart === null || input.selectionEnd === null) { + return; + } + + let start = input.selectionStart; + let end = input.selectionEnd; + + /** + * @todo Handle all possible deletion events + */ + switch (inputType) { + case InputType.DeleteContentForward: { + /** + * If selection end is already after the last element, then there is nothing to delete + */ + end = end !== input.value.length ? end + 1 : end; + break; + } + default: { + /** + * If start is already 0, then there is nothing to delete + */ + start = start !== 0 ? start - 1 : start; + } + } + this.#model.removeText(this.#blockIndex, key, start, end); + }; + + /** + * Handles delete events in contenteditable element + * + * @param event - beforeinput event + * @param input - input element + * @param key - data key input is attached to + */ + #handleDeleteInContentEditable(event: InputEvent, input: HTMLElement, key: DataKey): void { + const targetRanges = event.getTargetRanges(); + const range = targetRanges[0]; + + const start: number = getAbsoluteRangeOffset(input, range.startContainer, range.startOffset); + const end: number = getAbsoluteRangeOffset(input, range.endContainer, range.endOffset); + + this.#model.removeText(this.#blockIndex, key, start, end); + }; + /** * Handles beforeinput event from user input and updates model data * @@ -79,12 +136,25 @@ export class BlockToolAdapter { */ event.preventDefault(); + const isInputNative = isNativeInput(input); const inputType = event.inputType as InputType; - const targetRanges = event.getTargetRanges(); - const range = targetRanges[0]; - - const start = getAbsoluteRangeOffset(input, range.startContainer, range.startOffset); - const end = getAbsoluteRangeOffset(input, range.endContainer, range.endOffset); + let start: number; + let end: number; + + if (!isInputNative) { + const targetRanges = event.getTargetRanges(); + const range = targetRanges[0]; + + start = getAbsoluteRangeOffset(input, range.startContainer, + range.startOffset); + end = getAbsoluteRangeOffset(input, range.endContainer, + range.endOffset); + } else { + const currentElement = input as HTMLInputElement | HTMLTextAreaElement; + + start = currentElement.selectionStart as number; + end = currentElement.selectionEnd as number; + } switch (inputType) { case InputType.InsertReplacementText: @@ -110,7 +180,8 @@ export class BlockToolAdapter { */ case InputType.InsertCompositionText: { /** - * If start and end aren't equal, it means that user selected some text and replaced it with new one + * If start and end aren't equal, + * it means that user selected some text and replaced it with new one */ if (start !== end) { this.#model.removeText(this.#blockIndex, key, start, end); @@ -133,29 +204,31 @@ export class BlockToolAdapter { case InputType.DeleteSoftLineForward: case InputType.DeleteWordBackward: case InputType.DeleteWordForward: { - this.#model.removeText(this.#blockIndex, key, start, end); - + if (isInputNative) { + this.#handleDeleteInNativeInput(event, input, key); + } else { + this.#handleDeleteInContentEditable(event, input, key); + } break; } default: } - } + }; /** - * Handles model update events and updates DOM + * Handles model update events for native inputs and updates DOM * * @param event - model update event - * @param input - attched input element + * @param input - input element * @param key - data key input is attached to - * @param caretAdapter - caret adapter instance */ - #handleModelUpdate(event: ModelEvents, input: HTMLElement, key: DataKey, caretAdapter: CaretAdapter): void { + #handleModelUpdateForNativeInput(event: ModelEvents, input: HTMLInputElement | HTMLTextAreaElement, key: DataKey): void { if (!(event instanceof TextAddedEvent) && !(event instanceof TextRemovedEvent)) { return; } - const [rangeIndex, dataIndex, blockIndex] = event.detail.index; + const [, dataKey, blockIndex] = event.detail.index; /** * Event is not related to the attached block @@ -167,6 +240,53 @@ export class BlockToolAdapter { /** * Event is not related to the attached data key */ + if (dataKey !== composeDataIndex(key)) { + return; + } + + const currentElement = input; + const [ [start, end] ] = event.detail.index; + + const action = event.detail.action; + + switch (action) { + case EventAction.Added: { + const text = event.detail.data as string; + const prevValue = currentElement.value; + + currentElement.value = prevValue.slice(0, start) + text + prevValue.slice(end - 1); + + currentElement.setSelectionRange(start + text.length, start + text.length); + break; + } + case EventAction.Removed: { + currentElement.value = currentElement.value.slice(0, start) + + currentElement.value.slice(end); + currentElement.setSelectionRange(start, start); + break; + } + } + }; + + /** + * Handles model update events for contenteditable elements and updates DOM + * + * @param event - model update event + * @param input - input element + * @param key - data key input is attached to + * @param caretAdapter - caret adapter instance + */ + #handleModelUpdateForContentEditableElement(event: ModelEvents, input: HTMLElement, key: DataKey, caretAdapter: CaretAdapter): void { + if (!(event instanceof TextAddedEvent) && !(event instanceof TextRemovedEvent)) { + return; + } + + const [rangeIndex, dataIndex, blockIndex] = event.detail.index; + + if (blockIndex !== this.#blockIndex) { + return; + } + if (dataIndex !== composeDataIndex(key)) { return; } @@ -205,5 +325,23 @@ export class BlockToolAdapter { } input.normalize(); - } + }; + + /** + * Handles model update events and updates DOM + * + * @param event - model update event + * @param input - attched input element + * @param key - data key input is attached to + * @param caretAdapter - caret adapter instance + */ + #handleModelUpdate(event: ModelEvents, input: HTMLElement, key: DataKey, caretAdapter: CaretAdapter): void { + const isInputNative = isNativeInput(input); + + if (isInputNative) { + this.#handleModelUpdateForNativeInput(event, input as HTMLInputElement | HTMLTextAreaElement, key); + } else { + this.#handleModelUpdateForContentEditableElement(event, input, key, caretAdapter); + } + }; } diff --git a/packages/dom-adapters/src/caret/CaretAdapter.ts b/packages/dom-adapters/src/caret/CaretAdapter.ts index c740698e..1f966cef 100644 --- a/packages/dom-adapters/src/caret/CaretAdapter.ts +++ b/packages/dom-adapters/src/caret/CaretAdapter.ts @@ -7,8 +7,12 @@ import type { CaretManagerEvents } from '@editorjs/model'; import { useSelectionChange, type Subscriber, type InputWithCaret } from './utils/useSelectionChange.js'; -import { getAbsoluteRangeOffset, getBoundaryPointByAbsoluteOffset } from '../utils/index.js'; +import { + getAbsoluteRangeOffset, + getBoundaryPointByAbsoluteOffset +} from '../utils/index.js'; import { EventType } from '@editorjs/model'; +import { isNativeInput } from '@editorjs/dom'; /** * Caret adapter watches input caret change and passes it to the model @@ -187,17 +191,28 @@ export class CaretAdapter extends EventTarget { return; } - const start = getBoundaryPointByAbsoluteOffset(this.#input!, textIndex[0]); - const end = getBoundaryPointByAbsoluteOffset(this.#input!, textIndex[1]); + const input: HTMLElement = this.#input!; - const selection = document.getSelection()!; - const range = new Range(); + /** + * For native input, we cannot set caret position programmatically + * because there is no enough information to get `start` + * and `end` points in this case. + */ + if (isNativeInput(input)) { + return; + } else { + const start = getBoundaryPointByAbsoluteOffset(input, textIndex[0]); + const end = getBoundaryPointByAbsoluteOffset(input, textIndex[1]); - range.setStart(...start); - range.setEnd(...end); + const selection = document.getSelection()!; + const range = new Range(); - selection.removeAllRanges(); + range.setStart(...start); + range.setEnd(...end); - selection.addRange(range); + selection.removeAllRanges(); + + selection.addRange(range); + } } } diff --git a/packages/dom-adapters/src/caret/utils/useSelectionChange.ts b/packages/dom-adapters/src/caret/utils/useSelectionChange.ts index bb4e801f..7b55829c 100644 --- a/packages/dom-adapters/src/caret/utils/useSelectionChange.ts +++ b/packages/dom-adapters/src/caret/utils/useSelectionChange.ts @@ -48,7 +48,7 @@ export const useSelectionChange = createSingleton(() => { * @param input - input to check */ function isSelectionRelatedToInput(selection: Selection | null, input: InputWithCaret): boolean { - if (!selection) { + if (selection === null) { return false; } diff --git a/packages/dom-adapters/src/utils/index.ts b/packages/dom-adapters/src/utils/index.ts index a79b3a9a..2f912e43 100644 --- a/packages/dom-adapters/src/utils/index.ts +++ b/packages/dom-adapters/src/utils/index.ts @@ -1,2 +1,3 @@ export * from './getAbsoluteRangeOffset.js'; export * from './getRelativeIndex.js'; +export * from './isNonTextInput.js'; diff --git a/packages/dom-adapters/src/utils/isNonTextInput.ts b/packages/dom-adapters/src/utils/isNonTextInput.ts new file mode 100644 index 00000000..31327b46 --- /dev/null +++ b/packages/dom-adapters/src/utils/isNonTextInput.ts @@ -0,0 +1,25 @@ +enum TextInputType { + Text = 'text', + Tel = 'tel', + Search = 'search', + Url = 'url', + Password = 'password', +} + +const TEXT_INPUT_SET = new Set([ + TextInputType.Text, + TextInputType.Tel, + TextInputType.Search, + TextInputType.Url, + TextInputType.Password, +]) as ReadonlySet; + +/** + * Checks if a given HTML input element is not a text input. + * + * @param {HTMLElement} element - The HTML element to check. + * @returns {boolean} - Returns true if the element is not a text input, false otherwise. + */ +export function isNonTextInput(element: HTMLInputElement): boolean { + return !TEXT_INPUT_SET.has(element.type); +} diff --git a/packages/model/package.json b/packages/model/package.json index 969a7307..0084879c 100644 --- a/packages/model/package.json +++ b/packages/model/package.json @@ -6,8 +6,8 @@ "types": "dist/index.d.ts", "type": "module", "scripts": { - "build": "tsc --build tsconfig.build.json", - "dev": "yarn build --watch", + "build": "tsc --project tsconfig.build.json", + "dev": "tsc --project tsconfig.build.json --watch", "lint": "eslint ./src", "lint:ci": "yarn lint --max-warnings 0", "lint:fix": "yarn lint --fix", diff --git a/packages/playground/src/App.vue b/packages/playground/src/App.vue index 07d9be55..b6f18e8a 100644 --- a/packages/playground/src/App.vue +++ b/packages/playground/src/App.vue @@ -41,6 +41,7 @@ model.addEventListener(EventType.Changed, () => {
{{ serialized }}
diff --git a/packages/playground/src/components/Input.vue b/packages/playground/src/components/Input.vue index 00bf5d83..35d0ac46 100644 --- a/packages/playground/src/components/Input.vue +++ b/packages/playground/src/components/Input.vue @@ -13,12 +13,22 @@ import { const input = ref(null); const index = ref(null); -const props = defineProps<{ - /** - * Editor js Document model to attach input to - */ - model: EditorJSModel; -}>(); +const props = withDefaults( + defineProps<{ + /** + * Type of the input to be displayed on the page + */ + type?: 'contenteditable' | 'input' | 'textarea', + + /** + * Editor js Document model to attach input to + */ + model: EditorJSModel; + }>(), + { + type: 'contenteditable', + } +); onMounted(() => { const blockToolAdapter = new BlockToolAdapter(props.model, 0); @@ -34,13 +44,24 @@ onMounted(() => {