diff --git a/packages/core/src/utils/composeDataFromVersion2.ts b/packages/core/src/utils/composeDataFromVersion2.ts index 6b90f5d9..3190271d 100644 --- a/packages/core/src/utils/composeDataFromVersion2.ts +++ b/packages/core/src/utils/composeDataFromVersion2.ts @@ -1,5 +1,5 @@ import type { OutputData } from '@editorjs/editorjs'; -import { BlockChildType, type BlockNodeDataSerializedValue, type BlockNodeSerialized, type TextNodeSerialized } from '@editorjs/model'; +import { TextNode, ValueNode, type BlockNodeSerialized } from '@editorjs/model'; /** * Converst OutputData from version 2 to version 3 @@ -19,20 +19,19 @@ export function composeDataFromVersion2(data: OutputData): { Object .entries(block.data as Record) .map(([key, value]) => { - const valueObject: BlockNodeDataSerializedValue = { - value, - }; - if (typeof value === 'string') { - (valueObject as TextNodeSerialized).$t = BlockChildType.Text; - } + const textNode = new TextNode({ value }); - return [ - key, { - value, - $t: typeof value === 'string' ? '$t' : '$v', - }, - ]; + return [ + key, textNode.serialized, + ]; + } else { + const valueNode = new ValueNode({ value }); + + return [ + key, valueNode.serialized, + ]; + } }) ), }; 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 c2073d76..df3b2915 100644 --- a/packages/dom-adapters/src/utils/index.ts +++ b/packages/dom-adapters/src/utils/index.ts @@ -2,5 +2,7 @@ export * from './getAbsoluteRangeOffset.js'; export * from './getRelativeIndex.js'; export * from './isNonTextInput.js'; export * from './useSelectionChange.js'; +export * from './findWordBoundary.js'; +export * from './findHardLineBoundary.js'; export * from './doRangesIntersect.js'; export * from './mergeTextRanges.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/BlockNode/BlockNode.spec.ts b/packages/model/src/entities/BlockNode/BlockNode.spec.ts index 645ea9a0..1316ffe9 100644 --- a/packages/model/src/entities/BlockNode/BlockNode.spec.ts +++ b/packages/model/src/entities/BlockNode/BlockNode.spec.ts @@ -552,7 +552,7 @@ describe('BlockNode', () => { expect(() => { blockNode.updateValue(dataKey, value); }) - .toThrowError(`BlockNode: data with key ${dataKey} does not exist`); + .toThrowError(`BlockNode: data with key "${dataKey}" does not exist`); }); it('should throw an error if the ValueNode with the passed dataKey is not a ValueNode', () => { @@ -570,7 +570,7 @@ describe('BlockNode', () => { expect(() => { blockNode.updateValue(dataKey, value); }) - .toThrowError(`BlockNode: data with key ${dataKey} is not a ValueNode`); + .toThrowError(`BlockNode: data with key "${dataKey}" is not a ValueNode`); }); }); diff --git a/packages/model/src/entities/BlockNode/index.ts b/packages/model/src/entities/BlockNode/index.ts index bd743915..52661943 100644 --- a/packages/model/src/entities/BlockNode/index.ts +++ b/packages/model/src/entities/BlockNode/index.ts @@ -325,11 +325,11 @@ export class BlockNode extends EventBus { */ #validateKey(key: DataKey, Node?: typeof ValueNode | typeof TextNode): void { if (!has(this.#data, key as string)) { - throw new Error(`BlockNode: data with key ${key} does not exist`); + throw new Error(`BlockNode: data with key "${key}" does not exist`); } if (Node && !(get(this.#data, key as string) instanceof Node)) { - throw new Error(`BlockNode: data with key ${key} is not a ${Node.name}`); + throw new Error(`BlockNode: data with key "${key}" is not a ${Node.name}`); } } @@ -430,12 +430,10 @@ export class BlockNode extends EventBus { export type { BlockToolName, DataKey, - BlockNodeSerialized, - BlockNodeDataSerializedValue + BlockNodeSerialized }; export { createBlockToolName, - createDataKey, - BlockChildType + createDataKey }; diff --git a/packages/model/src/entities/inline-fragments/ParentInlineNode/index.ts b/packages/model/src/entities/inline-fragments/ParentInlineNode/index.ts index d8a938fc..e2c24a48 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 42fa5b92..2b67b989 100644 --- a/packages/playground/src/components/Input.vue +++ b/packages/playground/src/components/Input.vue @@ -40,7 +40,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" /> @@ -49,6 +49,7 @@ onMounted(() => {