diff --git a/.editorconfig b/.editorconfig index 78c6dde..2c0e19c 100644 --- a/.editorconfig +++ b/.editorconfig @@ -6,3 +6,4 @@ insert_final_newline = true charset = utf-8 indent_style = space indent_size = 2 +trim_trailing_whitespace = true diff --git a/packages/core/src/components/BlockManager.ts b/packages/core/src/components/BlockManager.ts index 81320ff..49ef090 100644 --- a/packages/core/src/components/BlockManager.ts +++ b/packages/core/src/components/BlockManager.ts @@ -168,6 +168,13 @@ export class BlocksManager { toolName ); + /** + * We store blocks managers in caret adapter to give it access to blocks` inputs + * without additional storing inputs in the caret adapter + * Thus, it won't care about block index change (block removed, block added, block moved) + */ + this.#caretAdapter.attachBlock(blockToolAdapter); + const tool = this.#toolsManager.blockTools.get(data.name); if (tool === undefined) { diff --git a/packages/dom-adapters/src/BlockToolAdapter/index.ts b/packages/dom-adapters/src/BlockToolAdapter/index.ts index 02104bb..2ed8427 100644 --- a/packages/dom-adapters/src/BlockToolAdapter/index.ts +++ b/packages/dom-adapters/src/BlockToolAdapter/index.ts @@ -10,7 +10,8 @@ import { IndexBuilder, type ModelEvents, TextAddedEvent, - TextRemovedEvent + TextRemovedEvent, + type Index } from '@editorjs/model'; import type { EventBus, @@ -138,8 +139,6 @@ export class BlockToolAdapter implements BlockToolAdapterInterface { builder.addBlockIndex(this.#blockIndex).addDataKey(key); - this.#caretAdapter.attachInput(input, builder.build()); - const value = this.#model.getText(this.#blockIndex, key); const fragments = this.#model.getFragments(this.#blockIndex, key); @@ -167,12 +166,6 @@ export class BlockToolAdapter implements BlockToolAdapterInterface { * @todo Let BlockTool handle DOM update */ input.remove(); - this.#caretAdapter.detachInput( - new IndexBuilder() - .addBlockIndex(this.#blockIndex) - .addDataKey(key) - .build() - ); this.#attachedInputs.delete(key); @@ -180,36 +173,74 @@ export class BlockToolAdapter implements BlockToolAdapterInterface { } /** - * Check current selection and find it across all attached inputs + * Check current selection and find all inputs that contain target ranges * - * @returns tuple of data key and input element or null if no focused input is found + * @param targetRanges - ranges to find inputs for + * @returns array of tuples containing data key and input element */ - #findFocusedInput(): [ DataKey, HTMLElement ] | null { - const currentInput = Array.from(this.#attachedInputs.entries()).find(([_, input]) => { - /** - * Case 1: Input is a native input — check if it has selection - */ - if (input instanceof HTMLInputElement || input instanceof HTMLTextAreaElement) { - return input.selectionStart !== null && input.selectionEnd !== null; - } + #findInputsByRanges(targetRanges: StaticRange[]): [DataKey, HTMLElement][] { + return Array.from(this.#attachedInputs.entries()).filter(([_, input]) => { + return targetRanges.some(range => { + const startContainer = range.startContainer; + const endContainer = range.endContainer; + const isCollapsed = range.collapsed; - /** - * Case 2: Input is a contenteditable element — check if it has range start container - */ - if (input.isContentEditable) { - const selection = window.getSelection(); - - if (selection !== null && selection.rangeCount > 0) { - const range = selection.getRangeAt(0); + /** + * Case 1: Input is a native input — check if it has selection or is between selected inputs + */ + if (input instanceof HTMLInputElement || input instanceof HTMLTextAreaElement) { + /** + * If this input has selection, include it + */ + if (input.selectionStart !== null && input.selectionEnd !== null) { + return true; + } + + /** + * Check if this input is between the range boundaries + */ + const startPosition = startContainer.compareDocumentPosition(input); + const endPosition = input.compareDocumentPosition(endContainer); + + return (startPosition & Node.DOCUMENT_POSITION_FOLLOWING) && + (endPosition & Node.DOCUMENT_POSITION_FOLLOWING); + } - return input.contains(range.startContainer); + /** + * Case 2: Input is a contenteditable element — check if it's between start and end + */ + if (input.isContentEditable) { + /** + * Casw 2.1 — input contains either start or end of selection + */ + if (input.contains(startContainer) || input.contains(endContainer)) { + return true; + } + + /** + * Case 2.2 — collapsed selection inside the input + */ + if (isCollapsed) { + return input.contains(startContainer); + } + + /** + * Case 2.3 — input is between start and end + */ + const startPosition = startContainer.compareDocumentPosition(input); + const endPosition = endContainer.compareDocumentPosition(input); + + const isBetween = ( + Boolean(startPosition & Node.DOCUMENT_POSITION_FOLLOWING) && + Boolean(endPosition & Node.DOCUMENT_POSITION_PRECEDING) + ); + + return isBetween; } - } - return false; + return false; + }); }); - - return currentInput !== undefined ? currentInput : null; } /** @@ -218,13 +249,16 @@ export class BlockToolAdapter implements BlockToolAdapterInterface { * @param event - event containig necessary data */ #processDelegatedBeforeInput(event: BeforeInputUIEvent): void { - const [dataKey, currentInput] = this.#findFocusedInput() ?? []; + const { targetRanges } = event.detail; + const inputs = this.#findInputsByRanges(targetRanges); - if (currentInput === undefined || dataKey === undefined) { + if (inputs.length === 0) { return; } - this.#handleBeforeInputEvent(event.detail, currentInput, dataKey); + inputs.forEach(([dataKey, input]) => { + this.#handleBeforeInputEvent(event.detail, input, dataKey); + }); } /** @@ -233,21 +267,40 @@ export class BlockToolAdapter implements BlockToolAdapterInterface { * @param payload - beforeinput event payload * @param input - input element * @param key - data key input is attached to + * @param range - target range for this input * @private */ - #handleDeleteInNativeInput(payload: BeforeInputUIEventPayload, input: HTMLInputElement | HTMLTextAreaElement, key: DataKey): void { + #handleDeleteInNativeInput( + payload: BeforeInputUIEventPayload, + input: HTMLInputElement | HTMLTextAreaElement, + key: DataKey, + range: StaticRange + ): void { const inputType = payload.inputType; + const inputValue = input.value; + const inputLength = inputValue.length; + + let start = 0; + let end = inputLength; /** - * Check that selection exists in current input + * If range is fully contained within this input */ - if (input.selectionStart === null || input.selectionEnd === null) { - return; + if (input.contains(range.startContainer) && input.contains(range.endContainer)) { + start = range.startOffset; + end = range.endOffset; + } else if (input.contains(range.startContainer)) { + /** + * If only start is in this input, delete from start to end of input + */ + start = range.startOffset; + } else if (input.contains(range.endContainer)) { + /** + * If only end is in this input, delete from start of input to end + */ + end = range.endOffset; } - let start = input.selectionStart; - let end = input.selectionEnd; - /** * If selection is not collapsed, just remove selected text */ @@ -262,7 +315,7 @@ export class BlockToolAdapter implements BlockToolAdapterInterface { /** * If selection end is already after the last element, then there is nothing to delete */ - end = end !== input.value.length ? end + 1 : end; + end = end !== inputValue.length ? end + 1 : end; break; } case InputType.DeleteContentBackward: { @@ -270,69 +323,156 @@ export class BlockToolAdapter implements BlockToolAdapterInterface { * 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); - + start = findPreviousWordBoundary(inputValue, start); break; } - case InputType.DeleteWordForward: { - end = findNextWordBoundary(input.value, start); - + end = findNextWordBoundary(inputValue, start); break; } - case InputType.DeleteHardLineBackward: { - start = findPreviousHardLineBoundary(input.value, start); - + start = findPreviousHardLineBoundary(inputValue, start); break; } case InputType.DeleteHardLineForward: { - end = findNextHardLineBoundary(input.value, start); - + end = findNextHardLineBoundary(inputValue, start); break; } - case InputType.DeleteSoftLineBackward: case InputType.DeleteSoftLineForward: case InputType.DeleteEntireSoftLine: - /** - * @todo Think of how to find soft line boundaries - */ - + /** + * @todo Think of how to find soft line boundaries + */ + break; case InputType.DeleteByDrag: case InputType.DeleteByCut: case InputType.DeleteContent: - default: - /** - * do nothing, use start and end from user selection - */ + /** + * do nothing, use start and end from range + */ } this.#model.removeText(this.#config.userId, this.#blockIndex, key, start, end); - }; + } + + /** + * True if input contains only the start of the cross-input selection + * + * @param input - input element + * @param range - selection range + */ + #isInputContainsOnlyStartOfSelection(input: HTMLElement, range: StaticRange): boolean { + return input.contains(range.startContainer) && !input.contains(range.endContainer); + } + + /** + * True if input contains only the end of the cross-input selection + * + * @param input - input element + * @param range - selection range + */ + #isInputContainsOnlyEndOfSelection(input: HTMLElement, range: StaticRange): boolean { + return input.contains(range.endContainer) && !input.contains(range.startContainer); + } + + /** + * True if input contains the whole selection (not cross-input) + * + * @param input - input element + * @param range - selection range + */ + #isInputContainsWholeSelection(input: HTMLElement, range: StaticRange): boolean { + return input.contains(range.startContainer) && input.contains(range.endContainer); + } + + /** + * True if input is in between cross-input selection + * + * @param input - input element + * @param range - selection range + */ + #isInputInBetweenSelection(input: HTMLElement, range: StaticRange): boolean { + return !this.#isInputContainsWholeSelection(input, range) && + !this.#isInputContainsOnlyStartOfSelection(input, range) && + !this.#isInputContainsOnlyEndOfSelection(input, range); + } /** * Handles delete events in contenteditable element * - * @param payload - beforeinput event payload * @param input - input element * @param key - data key input is attached to + * @param range - target range for this input + * @param isRestoreCaretToTheEnd - by default caret is restored to the range start, + * but sometimes (e.g. when inserting paragraph) + * it should be restored to the end of the input */ - #handleDeleteInContentEditable(payload: BeforeInputUIEventPayload, input: HTMLElement, key: DataKey): void { - const { targetRanges } = payload; - const range = targetRanges[0]; + #handleDeleteInContentEditable( + input: HTMLElement, + key: DataKey, + range: StaticRange, + isRestoreCaretToTheEnd: boolean = false + ): void { + let start: number; + let end: number; + let newCaretIndex: number | null = null; - const start: number = getAbsoluteRangeOffset(input, range.startContainer, range.startOffset); - const end: number = getAbsoluteRangeOffset(input, range.endContainer, range.endOffset); + /** + * If range is fully contained within this input + */ + if (this.#isInputContainsWholeSelection(input, range)) { + start = getAbsoluteRangeOffset(input, range.startContainer, range.startOffset); + end = getAbsoluteRangeOffset(input, range.endContainer, range.endOffset); - this.#model.removeText(this.#config.userId, this.#blockIndex, key, start, end); - }; + this.#model.removeText(this.#config.userId, this.#blockIndex, key, start, end); + } else if (this.#isInputContainsOnlyStartOfSelection(input, range)) { + /** + * If only start is in this input, delete from start to end of input + */ + start = getAbsoluteRangeOffset(input, range.startContainer, range.startOffset); + end = input.textContent?.length ?? 0; + + this.#model.removeText(this.#config.userId, this.#blockIndex, key, start, end); + + if (!isRestoreCaretToTheEnd) { + newCaretIndex = start; + } + } else if (this.#isInputContainsOnlyEndOfSelection(input, range)) { + /** + * If only end is in this input, delete from start of input to end + */ + start = 0; + end = getAbsoluteRangeOffset(input, range.endContainer, range.endOffset); + + const removedText = this.#model.removeText(this.#config.userId, this.#blockIndex, key, start, end); + + if (isRestoreCaretToTheEnd) { + newCaretIndex = end - removedText.length; + } + } else if (this.#isInputInBetweenSelection(input, range)) { + /** + * If range spans across this input, delete everything + */ + start = 0; + end = getAbsoluteRangeOffset(input, input, input.childNodes.length); + this.#model.removeBlock(this.#config.userId, this.#blockIndex); + } + + if (newCaretIndex !== null) { + this.#caretAdapter.updateIndex( + new IndexBuilder() + .addBlockIndex(this.#blockIndex) + .addDataKey(key) + .addTextRange([newCaretIndex, newCaretIndex]) + .build() + ); + } + } /** * Handles beforeinput event from user input and updates model data @@ -345,33 +485,37 @@ export class BlockToolAdapter implements BlockToolAdapterInterface { */ #handleBeforeInputEvent(payload: BeforeInputUIEventPayload, input: HTMLElement, key: DataKey): void { const { data, inputType, targetRanges } = payload; + const range = targetRanges[0]; const isInputNative = isNativeInput(input); let start: number; - let end: number; - - if (isInputNative === false) { - 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; + /** + * @todo support input merging + */ - start = currentElement.selectionStart as number; - end = currentElement.selectionEnd as number; + /** + * In all cases we need to handle delete selected text if range is not collapsed + */ + if (range.collapsed === false) { + if (isInputNative) { + this.#handleDeleteInNativeInput(payload, input as HTMLInputElement | HTMLTextAreaElement, key, range); + } else { + this.#handleDeleteInContentEditable(input, key, range); + } } switch (inputType) { case InputType.InsertReplacementText: case InputType.InsertFromDrop: case InputType.InsertFromPaste: { - if (start !== end) { - this.#model.removeText(this.#config.userId, this.#blockIndex, key, start, end); - } - - this.#model.insertText(this.#config.userId, this.#blockIndex, key, data, start); + if (data !== undefined && input.contains(range.startContainer)) { + start = isInputNative ? + (input as HTMLInputElement | HTMLTextAreaElement).selectionStart as number : + getAbsoluteRangeOffset(input, range.startContainer, range.startOffset); + this.#model.insertText(this.#config.userId, this.#blockIndex, key, data, start); + } break; } case InputType.InsertText: @@ -379,15 +523,13 @@ export class BlockToolAdapter implements BlockToolAdapterInterface { * @todo Handle composition events */ 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 !== end) { - this.#model.removeText(this.#config.userId, this.#blockIndex, key, start, end); - } + if (data !== undefined && input.contains(range.startContainer)) { + start = isInputNative ? + (input as HTMLInputElement | HTMLTextAreaElement).selectionStart as number : + getAbsoluteRangeOffset(input, range.startContainer, range.startOffset); - this.#model.insertText(this.#config.userId, this.#blockIndex, key, data, start); + this.#model.insertText(this.#config.userId, this.#blockIndex, key, data, start); + } break; } @@ -403,28 +545,39 @@ export class BlockToolAdapter implements BlockToolAdapterInterface { case InputType.DeleteEntireSoftLine: case InputType.DeleteWordBackward: case InputType.DeleteWordForward: { - if (isInputNative === true) { - this.#handleDeleteInNativeInput(payload, input as HTMLInputElement | HTMLTextAreaElement, key); - } else { - this.#handleDeleteInContentEditable(payload, input, key); - } + /** + * We already handle delete above + */ break; } case InputType.InsertParagraph: - this.#handleSplit(key, start, end); + /** + * + */ + if ( + (this.#isInputContainsOnlyStartOfSelection(input, range) || this.#isInputContainsWholeSelection(input, range)) && + payload.isCrossInputSelection === false + ) { + start = isInputNative ? + (input as HTMLInputElement | HTMLTextAreaElement).selectionStart as number : + getAbsoluteRangeOffset(input, range.startContainer, range.startOffset); + + this.#handleSplit(key, start, start); + } break; case InputType.InsertLineBreak: /** * @todo Think if we need to keep that or not */ - if (isInputNative === true) { + if (isInputNative && input.contains(range.startContainer)) { + start = (input as HTMLInputElement | HTMLTextAreaElement).selectionStart as number; this.#model.insertText(this.#config.userId, this.#blockIndex, key, '\n', start); } break; default: } - }; + } /** * Splits the current block's data field at the specified index @@ -544,6 +697,8 @@ export class BlockToolAdapter implements BlockToolAdapterInterface { builder.addDataKey(key).addBlockIndex(this.#blockIndex); + let newCaretIndex: number | null = null; + switch (action) { case EventAction.Added: { const text = event.detail.data as string; @@ -551,8 +706,7 @@ export class BlockToolAdapter implements BlockToolAdapterInterface { range.insertNode(textNode); - builder.addTextRange([start + text.length, start + text.length]); - + newCaretIndex = start + text.length; break; } case EventAction.Removed: { @@ -560,15 +714,16 @@ export class BlockToolAdapter implements BlockToolAdapterInterface { range.deleteContents(); - builder.addTextRange([start, start]); - break; } } input.normalize(); - this.#caretAdapter.updateIndex(builder.build(), this.#config.userId); + if (newCaretIndex !== null) { + builder.addTextRange([newCaretIndex, newCaretIndex]); + this.#caretAdapter.updateIndex(builder.build(), this.#config.userId); + } }; /** @@ -622,4 +777,31 @@ export class BlockToolAdapter implements BlockToolAdapterInterface { this.#handleModelUpdateForContentEditableElement(event, input, dataKey!); } }; + + /** + * Public getter for block index. + * Can be used to find a particular block, for example, in caret adapter + */ + public getBlockIndex(): Index { + return new IndexBuilder() + .addBlockIndex(this.#blockIndex) + .build(); + } + + /** + * Public getter for all attached inputs. + * Can be used to loop through all inputs to find a particular input(s) + */ + public getAttachedInputs(): Map { + return this.#attachedInputs; + } + + /** + * Allows access to a particular input by key + * + * @param key - data key of the input + */ + public getInput(key: DataKey): HTMLElement | undefined { + return this.#attachedInputs.get(key); + } } diff --git a/packages/dom-adapters/src/CaretAdapter/index.ts b/packages/dom-adapters/src/CaretAdapter/index.ts index 5e8136f..ade74f8 100644 --- a/packages/dom-adapters/src/CaretAdapter/index.ts +++ b/packages/dom-adapters/src/CaretAdapter/index.ts @@ -1,15 +1,20 @@ import { isNativeInput } from '@editorjs/dom'; +import type { + ModelEvents } from '@editorjs/model'; import { + BlockRemovedEvent, type Caret, type CaretManagerEvents, type EditorJSModel, EventType, Index, IndexBuilder, - type TextRange + type TextRange, + createDataKey } from '@editorjs/model'; import type { CoreConfig } from '@editorjs/sdk'; import { getAbsoluteRangeOffset, getBoundaryPointByAbsoluteOffset, useSelectionChange } from '../utils/index.js'; +import type { BlockToolAdapter } from '../BlockToolAdapter/index.ts'; /** * Caret adapter watches selection change and saves it to the model @@ -19,29 +24,23 @@ import { getAbsoluteRangeOffset, getBoundaryPointByAbsoluteOffset, useSelectionC export class CaretAdapter extends EventTarget { /** * Editor.js DOM container - * - * @private */ #container: HTMLElement; /** * Editor.js model - * - * @private */ #model: EditorJSModel; /** - * Map of inputs - * - * @private + * We store blocks in caret adapter to give it access to blocks` inputs + * without additional storing inputs in the caret adapter + * Thus, it won't care about block index change (block removed, block added, block moved) */ - #inputs = new Map(); + #blocks: Array = []; /** * Current user's caret - * - * @private */ #currentUserCaret: Caret; @@ -77,7 +76,8 @@ export class CaretAdapter extends EventTarget { */ on(container, (selection) => this.#onSelectionChange(selection), this); - this.#model.addEventListener(EventType.CaretManagerUpdated, (event) => this.#onModelUpdate(event)); + this.#model.addEventListener(EventType.CaretManagerUpdated, (event) => this.#onModelCaretUpdate(event)); + this.#model.addEventListener(EventType.Changed, (event: ModelEvents) => this.#handleModelUpdate(event)); } /** @@ -88,22 +88,29 @@ export class CaretAdapter extends EventTarget { } /** - * Adds input to the caret adapter + * Adds block to the caret adapter * - * @param input - input element - * @param index - index of the input in the model tree + * @param block - block tool adapter */ - public attachInput(input: HTMLElement, index: Index): void { - this.#inputs.set(index.serialize(), input); + public attachBlock(block: BlockToolAdapter): void { + this.#blocks.push(block); } /** - * Removes input from the caret adapter + * Removes block from the caret adapter * - * @param index - index of the input to remove + * @param index - index of the block to remove */ - public detachInput(index: Index): void { - this.#inputs.delete(index.serialize()); + public detachBlock(index: Index): void { + const block = this.getBlock(index); + + if (block) { + const blockIndex = this.#blocks.indexOf(block); + + if (blockIndex !== -1) { + this.#blocks.splice(blockIndex, 1); + } + } } /** @@ -130,30 +137,47 @@ export class CaretAdapter extends EventTarget { } /** - * Finds input by index + * Finds block by index * - * @param index - index of the input in the model tree + * @param index - index of the block in the model tree */ - public getInput(index?: Index): HTMLElement | undefined { + public getBlock(index?: Index): BlockToolAdapter | undefined { + if (index === undefined) { + if (this.#currentUserCaret.index === null) { + throw new Error('[CaretManager] No index provided and no user caret index found'); + } + index = this.#currentUserCaret.index; + } + + const blockIndex = index.blockIndex; + + if (blockIndex === undefined) { + return undefined; + } + + return this.#blocks.find(block => block.getBlockIndex().blockIndex === blockIndex); + } + + /** + * Finds input by block index and data key + * + * @param blockIndex - index of the block + * @param dataKeyRaw - data key of the input + * @returns input element or undefined if not found + */ + public findInput(blockIndex: number, dataKeyRaw: string): HTMLElement | undefined { const builder = new IndexBuilder(); + builder.addBlockIndex(blockIndex); + const block = this.getBlock(builder.build()); - if (index !== undefined) { - builder.from(index); - } else if (this.#currentUserCaret.index !== null) { - builder.from(this.#currentUserCaret.index); - } else { - throw new Error('[CaretManager] No index provided and no user caret index found'); + if (!block) { + return undefined; } - /** - * Inputs are stored in the hashmap with serialized index as a key - * Those keys are serialized without document id and text range to cover the input only, so we need to remove them here to find the input - */ - builder.addDocumentId(undefined); - builder.addTextRange(undefined); + const dataKey = createDataKey(dataKeyRaw); - return this.#inputs.get(builder.build().serialize()); + return block.getInput(dataKey); } /** @@ -173,20 +197,51 @@ export class CaretAdapter extends EventTarget { */ const activeElement = document.activeElement; - for (const [index, input] of this.#inputs) { - if (input !== activeElement) { - continue; - } + for (const block of this.#blocks) { + const inputs = block.getAttachedInputs(); + + for (const [key, input] of inputs.entries()) { + if (input !== activeElement) { + continue; + } + + if (isNativeInput(input) === true) { + const textRange = [ + (input as HTMLInputElement | HTMLTextAreaElement).selectionStart, + (input as HTMLInputElement | HTMLTextAreaElement).selectionEnd, + ] as TextRange; - if (isNativeInput(input) === true) { + const builder = new IndexBuilder(); + + builder + .from(block.getBlockIndex()) + .addDataKey(key) + .addTextRange(textRange); + + this.updateIndex(builder.build()); + + /** + * For now we handle only first found input + */ + break; + } + + const range = selection.getRangeAt(0); + + /** + * @todo think of cross-block selection + */ const textRange = [ - (input as HTMLInputElement | HTMLTextAreaElement).selectionStart, - (input as HTMLInputElement | HTMLTextAreaElement).selectionEnd, + getAbsoluteRangeOffset(input, range.startContainer, range.startOffset), + getAbsoluteRangeOffset(input, range.endContainer, range.endOffset), ] as TextRange; const builder = new IndexBuilder(); - builder.from(index).addTextRange(textRange); + builder + .from(block.getBlockIndex()) + .addDataKey(key) + .addTextRange(textRange); this.updateIndex(builder.build()); @@ -195,27 +250,6 @@ export class CaretAdapter extends EventTarget { */ break; } - - const range = selection.getRangeAt(0); - - /** - * @todo think of cross-block selection - */ - const textRange = [ - getAbsoluteRangeOffset(input, range.startContainer, range.startOffset), - getAbsoluteRangeOffset(input, range.endContainer, range.endOffset), - ] as TextRange; - - const builder = new IndexBuilder(); - - builder.from(index).addTextRange(textRange); - - this.updateIndex(builder.build()); - - /** - * For now we handle only first found input - */ - break; } } @@ -227,7 +261,7 @@ export class CaretAdapter extends EventTarget { * * @param event - model update event */ - #onModelUpdate(event: CaretManagerEvents): void { + #onModelCaretUpdate(event: CaretManagerEvents): void { const { index: serializedIndex } = event.detail; if (serializedIndex === null) { @@ -235,10 +269,9 @@ export class CaretAdapter extends EventTarget { } const index = Index.parse(serializedIndex); + const { textRange, dataKey } = index; - const { textRange } = index; - - if (textRange === undefined) { + if (textRange === undefined || dataKey === undefined) { return; } @@ -248,7 +281,13 @@ export class CaretAdapter extends EventTarget { return; } - const input = this.getInput(index); + const block = this.getBlock(index); + + if (!block) { + return; + } + + const input = block.getInput(dataKey); if (!input) { return; @@ -312,4 +351,28 @@ export class CaretAdapter extends EventTarget { selection.removeAllRanges(); selection.addRange(range); } + + /** + * Handles model update events + * + * @param event - model update event + */ + #handleModelUpdate(event: ModelEvents): void { + /** + * When block is removed, we need to remove it from this.#blocks + */ + if (event instanceof BlockRemovedEvent) { + const removedBlockIndex = event.detail.index.blockIndex; + + if (removedBlockIndex === undefined) { + return; + } + + const blocksToRemove = this.#blocks.find(block => block.getBlockIndex().blockIndex === removedBlockIndex); + + if (blocksToRemove) { + this.detachBlock(blocksToRemove.getBlockIndex()); + } + } + } } diff --git a/packages/dom-adapters/src/FormattingAdapter/index.ts b/packages/dom-adapters/src/FormattingAdapter/index.ts index 8d2edfb..731f4f6 100644 --- a/packages/dom-adapters/src/FormattingAdapter/index.ts +++ b/packages/dom-adapters/src/FormattingAdapter/index.ts @@ -1,4 +1,3 @@ - import type { EditorJSModel, InlineFragment, @@ -172,7 +171,7 @@ export class FormattingAdapter { return; } - const input = this.#caretAdapter.getInput(event.detail.index); + const input = this.#caretAdapter.findInput(blockIndex, dataKey.toString()); if (input === undefined) { console.warn('No input found for the index', event.detail.index); diff --git a/packages/model/src/entities/EditorDocument/index.ts b/packages/model/src/entities/EditorDocument/index.ts index 5c934ad..2d1eaf8 100644 --- a/packages/model/src/entities/EditorDocument/index.ts +++ b/packages/model/src/entities/EditorDocument/index.ts @@ -128,7 +128,7 @@ export class EditorDocument extends EventBus { this.#children.splice(index, 0, blockNode); } - this.#listenAndBubbleBlockEvent(blockNode, index); + this.#listenAndBubbleBlockEvent(blockNode); const builder = new IndexBuilder(); @@ -465,9 +465,8 @@ export class EditorDocument extends EventBus { * Listens to BlockNode events and bubbles them to the EditorDocument * * @param block - BlockNode to listen to - * @param index - index of the BlockNode */ - #listenAndBubbleBlockEvent(block: BlockNode, index: number): void { + #listenAndBubbleBlockEvent(block: BlockNode): void { block.addEventListener(EventType.Changed, (event: Event) => { if (!(event instanceof BaseDocumentEvent)) { // Stryker disable next-line StringLiteral @@ -477,6 +476,7 @@ export class EditorDocument extends EventBus { } const builder = new IndexBuilder(); + const index = this.#children.indexOf(block); builder.from(event.detail.index) .addDocumentId(this.identifier) diff --git a/packages/playground/src/App.vue b/packages/playground/src/App.vue index b1aca41..86e5032 100644 --- a/packages/playground/src/App.vue +++ b/packages/playground/src/App.vue @@ -35,9 +35,22 @@ onMounted(() => { { type: 'paragraph', data: { - text: 'Hello, World!', + text: '111', }, }, + { + type: 'paragraph', + data: { + text: '222', + }, + }, + { + type: 'paragraph', + data: { + text: '333', + }, + }, + ], }, onModelUpdate: (m: EditorJSModel) => { diff --git a/packages/sdk/src/entities/EventBus/events/ui/BeforeInputUIEvent.ts b/packages/sdk/src/entities/EventBus/events/ui/BeforeInputUIEvent.ts index 1963ec1..7a5e80e 100644 --- a/packages/sdk/src/entities/EventBus/events/ui/BeforeInputUIEvent.ts +++ b/packages/sdk/src/entities/EventBus/events/ui/BeforeInputUIEvent.ts @@ -31,6 +31,11 @@ export interface BeforeInputUIEventPayload { * Objects that will be affected by a change to the DOM if the input event is not canceled. */ targetRanges: StaticRange[]; + + /** + * Whether the selection is across multiple inputs + */ + isCrossInputSelection: boolean; } /** diff --git a/packages/ui/index.html b/packages/ui/index.html index 83b7272..9cb76ad 100644 --- a/packages/ui/index.html +++ b/packages/ui/index.html @@ -9,12 +9,26 @@ const core = new Core({ holder: document.getElementById('editorjs'), data: { - blocks: [ { - type: 'paragraph', - data: { - text: 'Hello, World!', + blocks: [ + { + type: 'paragraph', + data: { + text: '1', + }, }, - } ], + { + type: 'paragraph', + data: { + text: '2', + }, + }, + { + type: 'paragraph', + data: { + text: '3', + }, + }, + ], }, }); diff --git a/packages/ui/src/Blocks/Blocks.ts b/packages/ui/src/Blocks/Blocks.ts index 5b1a53c..cdf1c12 100644 --- a/packages/ui/src/Blocks/Blocks.ts +++ b/packages/ui/src/Blocks/Blocks.ts @@ -112,11 +112,14 @@ export class BlocksUI implements EditorjsPlugin { data = e.dataTransfer?.getData('text/plain') ?? e.data ?? ''; } + const isCrossInputSelection = e.getTargetRanges().some(range => range.startContainer !== range.endContainer); + this.#eventBus.dispatchEvent(new BeforeInputUIEvent({ data, inputType: e.inputType, isComposing: e.isComposing, targetRanges: e.getTargetRanges(), + isCrossInputSelection, })); });