From dada29114e2d4ed5ecff79ec3394d203d5954212 Mon Sep 17 00:00:00 2001 From: George Berezhnoy Date: Thu, 29 Aug 2024 02:01:53 +0300 Subject: [PATCH] [BlockToolAdapter] Improve native inputs handling (#78) * Improve native inputs handling * Fix types issue * Update packages/dom-adapters/src/utils/findHardLineBoundary.ts Co-authored-by: Peter * Add comments, return white-space: pre; for contenteditable --------- Co-authored-by: Peter --- .../src/BlockToolAdapter/index.ts | 105 +++++++++++++++--- .../src/utils/findHardLineBoundary.ts | 39 +++++++ .../src/utils/findWordBoundary.ts | 62 +++++++++++ packages/dom-adapters/src/utils/index.ts | 2 + .../src/CaretManagement/Caret/Caret.spec.ts | 9 -- .../model/src/CaretManagement/Caret/Caret.ts | 4 - .../ParentInlineNode/index.ts | 2 +- .../specs/ParentInlineNode.spec.ts | 2 +- packages/playground/src/components/Input.vue | 5 +- 9 files changed, 196 insertions(+), 34 deletions(-) create mode 100644 packages/dom-adapters/src/utils/findHardLineBoundary.ts create mode 100644 packages/dom-adapters/src/utils/findWordBoundary.ts diff --git a/packages/dom-adapters/src/BlockToolAdapter/index.ts b/packages/dom-adapters/src/BlockToolAdapter/index.ts index b36b247c..c5653728 100644 --- a/packages/dom-adapters/src/BlockToolAdapter/index.ts +++ b/packages/dom-adapters/src/BlockToolAdapter/index.ts @@ -1,20 +1,25 @@ -import { type DataKey, type EditorJSModel, IndexBuilder, type ModelEvents } from '@editorjs/model'; +import { isNativeInput } from '@editorjs/dom'; import { + type EditorJSModel, + type DataKey, createDataKey, EventAction, EventType, + IndexBuilder, + type ModelEvents, TextAddedEvent, TextRemovedEvent } from '@editorjs/model'; -import { InputType } from './types/InputType.js'; +import type { CaretAdapter } from '../CaretAdapter/index.js'; import { + findNextHardLineBoundary, + findNextWordBoundary, findPreviousHardLineBoundary, + findPreviousWordBoundary, getAbsoluteRangeOffset, getBoundaryPointByAbsoluteOffset, isNonTextInput } from '../utils/index.js'; -import type { CaretAdapter } from '../CaretAdapter/index.js'; - -import { isNativeInput } from '@editorjs/dom'; +import { InputType } from './types/InputType.js'; /** * BlockToolAdapter is using inside Block tools to connect browser DOM elements to the model @@ -99,8 +104,14 @@ export class BlockToolAdapter { let end = input.selectionEnd; /** - * @todo Handle all possible deletion events + * If selection is not collapsed, just remove selected text */ + if (start !== end) { + this.#model.removeText(this.#blockIndex, key, start, end); + + return; + } + switch (inputType) { case InputType.DeleteContentForward: { /** @@ -109,13 +120,55 @@ export class BlockToolAdapter { end = end !== input.value.length ? end + 1 : end; break; } - default: { + case InputType.DeleteContentBackward: { /** * If start is already 0, then there is nothing to delete */ start = start !== 0 ? start - 1 : start; + + break; + } + + case InputType.DeleteWordBackward: { + start = findPreviousWordBoundary(input.value, start); + + break; + } + + case InputType.DeleteWordForward: { + end = findNextWordBoundary(input.value, start); + + break; + } + + case InputType.DeleteHardLineBackward: { + start = findPreviousHardLineBoundary(input.value, start); + + break; + } + case InputType.DeleteHardLineForward: { + end = findNextHardLineBoundary(input.value, start); + + break; } + + case InputType.DeleteSoftLineBackward: + case InputType.DeleteSoftLineForward: + case InputType.DeleteEntireSoftLine: + /** + * @todo Think of how to find soft line boundaries + */ + + case InputType.DeleteByDrag: + case InputType.DeleteByCut: + case InputType.DeleteContent: + + default: + /** + * do nothing, use start and end from user selection + */ } + this.#model.removeText(this.#blockIndex, key, start, end); }; @@ -171,17 +224,25 @@ export class BlockToolAdapter { switch (inputType) { case InputType.InsertReplacementText: + case InputType.InsertFromDrop: case InputType.InsertFromPaste: { - this.#model.removeText(this.#blockIndex, key, start, end); + if (start !== end) { + this.#model.removeText(this.#blockIndex, key, start, end); + } + + let data: string; /** - * DataTransfer object is guaranteed to be not null for these types of event for contenteditable elements - * - * However, it is not guaranteed for INPUT and TEXTAREA elements, so @todo handle this case + * For native inputs data for those events comes from event.data property + * while for contenteditable elements it's stored in event.dataTransfer * * @see https://www.w3.org/TR/input-events-2/#overview */ - const data = event.dataTransfer!.getData('text/plain'); + if (isInputNative) { + data = event.data ?? ''; + } else { + data = event.dataTransfer!.getData('text/plain'); + } this.#model.insertText(this.#blockIndex, key, data, start); @@ -215,6 +276,7 @@ export class BlockToolAdapter { case InputType.DeleteHardLineForward: case InputType.DeleteSoftLineBackward: case InputType.DeleteSoftLineForward: + case InputType.DeleteEntireSoftLine: case InputType.DeleteWordBackward: case InputType.DeleteWordForward: { if (isInputNative === true) { @@ -225,6 +287,13 @@ export class BlockToolAdapter { break; } + case InputType.InsertLineBreak: + /** + * @todo Think if we need to keep that or not + */ + if (isInputNative === true) { + this.#model.insertText(this.#blockIndex, key, '\n', start); + } default: } }; @@ -266,18 +335,18 @@ export class BlockToolAdapter { const action = event.detail.action; - const builder = new IndexBuilder(); + const caretIndexBuilder = new IndexBuilder(); - builder.from(event.detail.index); + caretIndexBuilder.from(event.detail.index); 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.value = prevValue.slice(0, start) + text + prevValue.slice(start); - builder.addTextRange([start + text.length, start + text.length]); + caretIndexBuilder.addTextRange([start + text.length, start + text.length]); break; } @@ -285,13 +354,13 @@ export class BlockToolAdapter { currentElement.value = currentElement.value.slice(0, start) + currentElement.value.slice(end); - builder.addTextRange([start, start]); + caretIndexBuilder.addTextRange([start, start]); break; } } - this.#caretAdapter.updateIndex(builder.build()); + this.#caretAdapter.updateIndex(caretIndexBuilder.build()); }; /** diff --git a/packages/dom-adapters/src/utils/findHardLineBoundary.ts b/packages/dom-adapters/src/utils/findHardLineBoundary.ts new file mode 100644 index 00000000..1a4999d6 --- /dev/null +++ b/packages/dom-adapters/src/utils/findHardLineBoundary.ts @@ -0,0 +1,39 @@ +/** + * Finds nearest next carriage return symbol from passed position + * + * @param text - string to search in + * @param position - search starting position + */ +export function findNextHardLineBoundary(text: string, position: number): number { + const nextLineBoundary = /\n/g; + + nextLineBoundary.lastIndex = position; + + const match = nextLineBoundary.exec(text); + + return match ? match.index : text.length; +} + +/** + * Finds nearest previous caret symbol before passed position + * + * @param text - sting to search in + * @param position - search finish position + */ +export function findPreviousHardLineBoundary(text: string, position: number): number { + const previousLineBoundary = /\n/g; + + let match = previousLineBoundary.exec(text); + + while (match) { + const newMatch = previousLineBoundary.exec(text); + + if (!newMatch || newMatch.index >= position) { + break; + } + + match = newMatch; + } + + return match && match.index < position ? match.index : 0; +} diff --git a/packages/dom-adapters/src/utils/findWordBoundary.ts b/packages/dom-adapters/src/utils/findWordBoundary.ts new file mode 100644 index 00000000..d3a39f65 --- /dev/null +++ b/packages/dom-adapters/src/utils/findWordBoundary.ts @@ -0,0 +1,62 @@ +const APOSTROPHE_AND_CURLY_QUOTES = "['\u2018\u2019]"; +const PUNCTUATION = '.,!?:;"\\(\\){}\\[\\]<>@*~\\/\\-#$&|^%+='; +const WHITESPACE = '\\s'; + +const WHITESPACE_AND_PUNCTUATION = `[${WHITESPACE}${PUNCTUATION}]`; + +/** + * Finds the nearest next word boundary from the passed position + * + * @param text - string to search in + * @param position - search starting position + */ +export function findNextWordBoundary(text: string, position: number): number { + const nextWordBoundary = new RegExp( + /** + * Match whitespace or punctuation + * or an apostrophe or curly quotes followed by a whitespace character or punctuation + */ + `(${WHITESPACE_AND_PUNCTUATION}|${APOSTROPHE_AND_CURLY_QUOTES}(?=${WHITESPACE_AND_PUNCTUATION}))`, + 'g' + ); + + /** + * Start searching from the next character to allow word deletion with one non-word character before the word + */ + nextWordBoundary.lastIndex = position + 1; + + const match = nextWordBoundary.exec(text); + + return match ? match.index : text.length; +} + +/** + * Finds the nearest previous word boundary before the passed position + * + * @param text - string to search in + * @param position - search finish position + */ +export function findPreviousWordBoundary(text: string, position: number): number { + const previousWordBoundary = new RegExp( + /** + * Match whitespace or punctuation, + * or an apostrophe or curly quotes preceded by whitespace or punctuation + */ + `(${WHITESPACE_AND_PUNCTUATION}|(?<=${WHITESPACE_AND_PUNCTUATION})${APOSTROPHE_AND_CURLY_QUOTES})`, + 'g' + ); + + let match = previousWordBoundary.exec(text); + + while (match) { + const newMatch = previousWordBoundary.exec(text); + + if (!newMatch || newMatch.index >= position) { + break; + } + + match = newMatch; + } + + return match && match.index < position ? match.index : 0; +} diff --git a/packages/dom-adapters/src/utils/index.ts b/packages/dom-adapters/src/utils/index.ts index 27a6183d..8dd17ca7 100644 --- a/packages/dom-adapters/src/utils/index.ts +++ b/packages/dom-adapters/src/utils/index.ts @@ -2,3 +2,5 @@ export * from './getAbsoluteRangeOffset.js'; export * from './getRelativeIndex.js'; export * from './isNonTextInput.js'; export * from './useSelectionChange.js'; +export * from './findWordBoundary.js'; +export * from './findHardLineBoundary.js'; diff --git a/packages/model/src/CaretManagement/Caret/Caret.spec.ts b/packages/model/src/CaretManagement/Caret/Caret.spec.ts index 0739d304..bf83eac5 100644 --- a/packages/model/src/CaretManagement/Caret/Caret.spec.ts +++ b/packages/model/src/CaretManagement/Caret/Caret.spec.ts @@ -53,15 +53,6 @@ describe('Caret', () => { })); }); - it('should not update index if it is the same', () => { - const caret = new Caret(new Index()); - const index = new Index(); - - caret.update(index); - - expect(caret.index).not.toBe(index); - }); - it('should serialize to JSON', () => { const index = new Index(); const caret = new Caret(index); diff --git a/packages/model/src/CaretManagement/Caret/Caret.ts b/packages/model/src/CaretManagement/Caret/Caret.ts index f4cf8518..b8a47824 100644 --- a/packages/model/src/CaretManagement/Caret/Caret.ts +++ b/packages/model/src/CaretManagement/Caret/Caret.ts @@ -71,10 +71,6 @@ export class Caret extends EventBus { * @param index - new caret index */ public update(index: Index): void { - if (this.#index?.serialize() === index.serialize()) { - return; - } - this.#index = index; this.dispatchEvent(new CaretUpdatedEvent(this)); diff --git a/packages/model/src/entities/inline-fragments/ParentInlineNode/index.ts b/packages/model/src/entities/inline-fragments/ParentInlineNode/index.ts index 43cf4d06..58458709 100644 --- a/packages/model/src/entities/inline-fragments/ParentInlineNode/index.ts +++ b/packages/model/src/entities/inline-fragments/ParentInlineNode/index.ts @@ -70,7 +70,7 @@ export class ParentInlineNode extends EventBus implements InlineNode { const builder = new IndexBuilder(); - builder.addTextRange([index, index + text.length]); + builder.addTextRange([index, index]); this.dispatchEvent(new TextAddedEvent(builder.build(), text)); } diff --git a/packages/model/src/entities/inline-fragments/specs/ParentInlineNode.spec.ts b/packages/model/src/entities/inline-fragments/specs/ParentInlineNode.spec.ts index c89d39ab..bd72cc6d 100644 --- a/packages/model/src/entities/inline-fragments/specs/ParentInlineNode.spec.ts +++ b/packages/model/src/entities/inline-fragments/specs/ParentInlineNode.spec.ts @@ -228,7 +228,7 @@ describe('ParentInlineNode', () => { expect(event).toBeInstanceOf(TextAddedEvent); expect(event).toHaveProperty('detail', expect.objectContaining({ action: EventAction.Added, - index: expect.objectContaining({ textRange: [index, index + newText.length] }), + index: expect.objectContaining({ textRange: [index, index] }), data: newText, })); }); diff --git a/packages/playground/src/components/Input.vue b/packages/playground/src/components/Input.vue index 16e3eb70..40640eca 100644 --- a/packages/playground/src/components/Input.vue +++ b/packages/playground/src/components/Input.vue @@ -41,7 +41,7 @@ onMounted(() => { ref="input" :contenteditable="type === 'contenteditable' ? true : undefined" type="text" - :class="$style.input" + :class="{ [$style.input]: true, [$style.contenteditable]: type === 'contenteditable' }" :value="type !== 'contenteditable' ? value : undefined" v-html="type === 'contenteditable' ? value : undefined" /> @@ -50,6 +50,7 @@ onMounted(() => {