From 7c2743a65b161fd691f261e34c38d6dc6d8508fd Mon Sep 17 00:00:00 2001 From: Peter Savchenko Date: Sat, 12 Apr 2025 02:21:30 +0200 Subject: [PATCH 1/5] feat(cis): cross-input delete, split --- .editorconfig | 1 + .../src/BlockToolAdapter/index.ts | 351 +++++++++++++----- .../src/entities/EditorDocument/index.ts | 6 +- packages/playground/src/App.vue | 15 +- .../EventBus/events/ui/BeforeInputUIEvent.ts | 5 + packages/ui/index.html | 24 +- packages/ui/src/Blocks/Blocks.ts | 3 + 7 files changed, 293 insertions(+), 112 deletions(-) diff --git a/.editorconfig b/.editorconfig index 78c6ddee..2c0e19c8 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/dom-adapters/src/BlockToolAdapter/index.ts b/packages/dom-adapters/src/BlockToolAdapter/index.ts index bf3cab25..5811f949 100644 --- a/packages/dom-adapters/src/BlockToolAdapter/index.ts +++ b/packages/dom-adapters/src/BlockToolAdapter/index.ts @@ -17,7 +17,7 @@ import type { BlockToolAdapter as BlockToolAdapterInterface, CoreConfig, BeforeInputUIEvent, - BeforeInputUIEventPayload + BeforeInputUIEventPayload, } from '@editorjs/sdk'; import { BeforeInputUIEventName } from '@editorjs/sdk'; import type { CaretAdapter } from '../CaretAdapter/index.js'; @@ -147,36 +147,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; - } - - /** - * Case 2: Input is a contenteditable element — check if it has range start container - */ - if (input.isContentEditable) { - const selection = window.getSelection(); + #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; - 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; } /** @@ -185,13 +223,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); + }); } /** @@ -200,27 +241,45 @@ 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 */ if (start !== end) { this.#model.removeText(this.#config.userId, this.#blockIndex, key, start, end); - return; } @@ -229,7 +288,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: { @@ -237,69 +296,142 @@ 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); - }; + } + + #isInputContainsOnlyStartOfSelection(input: HTMLElement, range: StaticRange): boolean { + return input.contains(range.startContainer) && !input.contains(range.endContainer); + } + + #isInputContainsOnlyEndOfSelection(input: HTMLElement, range: StaticRange): boolean { + return input.contains(range.endContainer) && !input.contains(range.startContainer); + } + + #isInputContainsWholeSelection(input: HTMLElement, range: StaticRange): boolean { + return input.contains(range.startContainer) && input.contains(range.endContainer); + } + + #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); + // console.log('delete in input', input); - this.#model.removeText(this.#config.userId, this.#blockIndex, key, start, end); - }; + /** + * If range is fully contained within this input + */ + if (this.#isInputContainsWholeSelection(input, range)) { + // console.log('range is fully contained within this input'); + + 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); + + // newCaretIndex = start; + } else if (this.#isInputContainsOnlyStartOfSelection(input, range)) { + // console.log('only start is in this input'); + /** + * 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)) { + // console.log('only end is in this input'); + /** + * 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)) { + // console.log('range spans across this input'); + /** + * 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) { + console.info('restore caret: block %o index %o caret %o', this.#blockIndex, newCaretIndex); + this.#caretAdapter.updateIndex( + new IndexBuilder() + .addBlockIndex(this.#blockIndex) + .addDataKey(key) + .addTextRange([newCaretIndex, newCaretIndex]) + .build() + ); + } + } /** * Handles beforeinput event from user input and updates model data @@ -312,33 +444,23 @@ 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; - - start = currentElement.selectionStart as number; - end = currentElement.selectionEnd as number; - } - 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 && 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: @@ -346,15 +468,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 && 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; } @@ -371,27 +491,50 @@ export class BlockToolAdapter implements BlockToolAdapterInterface { case InputType.DeleteWordBackward: case InputType.DeleteWordForward: { if (isInputNative === true) { - this.#handleDeleteInNativeInput(payload, input as HTMLInputElement | HTMLTextAreaElement, key); + this.#handleDeleteInNativeInput(payload, input as HTMLInputElement | HTMLTextAreaElement, key, range); } else { - this.#handleDeleteInContentEditable(payload, input, key); + this.#handleDeleteInContentEditable(input, key, range); } break; } case InputType.InsertParagraph: - this.#handleSplit(key, start, end); + console.log('insert paragraph', input); + + if (isInputNative) { + // start = (input as HTMLInputElement | HTMLTextAreaElement).selectionStart as number; + this.#handleDeleteInNativeInput(payload, input as HTMLInputElement | HTMLTextAreaElement, key, range); + } else { + this.#handleDeleteInContentEditable(input, key, range, true); + // start = getAbsoluteRangeOffset(input, range.startContainer, range.startOffset); + } + + /** + * + */ + if ( + (this.#isInputContainsOnlyStartOfSelection(input, range) || this.#isInputContainsWholeSelection(input, range)) && + !payload.isCrossInputSelection + ) { + const 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 @@ -555,6 +698,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; @@ -562,24 +707,24 @@ export class BlockToolAdapter implements BlockToolAdapterInterface { range.insertNode(textNode); - builder.addTextRange([start + text.length, start + text.length]); - + newCaretIndex = start + text.length; break; } case EventAction.Removed: { range.setEnd(endNode, endOffset); 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); + } }; /** diff --git a/packages/model/src/entities/EditorDocument/index.ts b/packages/model/src/entities/EditorDocument/index.ts index d8f35aaa..f944b55d 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(); @@ -430,9 +430,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 @@ -442,6 +441,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 b1aca417..86e50320 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 1963ec11..7a5e80ee 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 83b72727..9cb76ad1 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 5b1a53c3..cdf1c123 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, })); }); From 14c1cd36d82da66353719ebe46ca2c72e24a5315 Mon Sep 17 00:00:00 2001 From: Peter Savchenko Date: Sat, 12 Apr 2025 04:06:16 +0200 Subject: [PATCH 2/5] caret adapter now stores blocks instead of inputs --- packages/core/src/components/BlockManager.ts | 7 + .../src/BlockToolAdapter/index.ts | 70 +++--- .../dom-adapters/src/CaretAdapter/index.ts | 201 +++++++++++------- .../src/FormattingAdapter/index.ts | 3 +- 4 files changed, 181 insertions(+), 100 deletions(-) diff --git a/packages/core/src/components/BlockManager.ts b/packages/core/src/components/BlockManager.ts index 81320fff..ab213149 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, index); + 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 93ea5a35..ed7c7005 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, + 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); @@ -481,7 +474,21 @@ export class BlockToolAdapter implements BlockToolAdapterInterface { const isInputNative = isNativeInput(input); let start: number; - let end: number; + + /** + * @todo support input merging + */ + + /** + * In all cases we need to handle delete selected text if range is not collapsed + */ + if (!range.collapsed) { + if (isInputNative) { + this.#handleDeleteInNativeInput(payload, input as HTMLInputElement | HTMLTextAreaElement, key, range); + } else { + this.#handleDeleteInContentEditable(input, key, range); + } + } switch (inputType) { case InputType.InsertReplacementText: @@ -523,25 +530,13 @@ 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, range); - } else { - this.#handleDeleteInContentEditable(input, key, range); - } + /** + * We already handle delete above + */ break; } case InputType.InsertParagraph: - console.log('insert paragraph', input); - - if (isInputNative) { - // start = (input as HTMLInputElement | HTMLTextAreaElement).selectionStart as number; - this.#handleDeleteInNativeInput(payload, input as HTMLInputElement | HTMLTextAreaElement, key, range); - } else { - this.#handleDeleteInContentEditable(input, key, range, true); - // start = getAbsoluteRangeOffset(input, range.startContainer, range.startOffset); - } - /** * */ @@ -767,4 +762,29 @@ 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 + */ + 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 5e8136fe..2ebea612 100644 --- a/packages/dom-adapters/src/CaretAdapter/index.ts +++ b/packages/dom-adapters/src/CaretAdapter/index.ts @@ -1,15 +1,19 @@ import { isNativeInput } from '@editorjs/dom'; import { + BlockRemovedEvent, type Caret, type CaretManagerEvents, type EditorJSModel, EventType, Index, IndexBuilder, - type TextRange + ModelEvents, + 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 +23,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 +75,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 +87,28 @@ 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 + * @param index - index of the block in the model tree */ - public attachInput(input: HTMLElement, index: Index): void { - this.#inputs.set(index.serialize(), input); + public attachBlock(block: BlockToolAdapter, index: Index): 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 index = this.#blocks.indexOf(block); + if (index !== -1) { + this.#blocks.splice(index, 1); + } + } } /** @@ -130,30 +135,44 @@ 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 { - const builder = new IndexBuilder(); - + 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; + } - 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'); + const blockIndex = index.blockIndex; + if (blockIndex === undefined) { + 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); + return this.#blocks.find(block => block.getBlockIndex().blockIndex === blockIndex); + } - return this.#inputs.get(builder.build().serialize()); + /** + * Finds input by block index and data key + * + * @param blockIndex - index of the block + * @param dataKey - 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 (!block) { + return undefined; + } + + const dataKey = createDataKey(dataKeyRaw); + return block.getInput(dataKey); } /** @@ -173,20 +192,45 @@ 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; + + const builder = new IndexBuilder(); - if (isNativeInput(input) === true) { + 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 +239,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 +250,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 +258,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 +270,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 +340,31 @@ 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; + } + + /** + * Find all blocks that match the removed block index + */ + 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 8d2edfb9..731f4f66 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); From 933132b76afa2d1dca413e5939e269e643a0fce7 Mon Sep 17 00:00:00 2001 From: Peter Savchenko Date: Sat, 12 Apr 2025 04:25:27 +0200 Subject: [PATCH 3/5] lint --- packages/core/src/components/BlockManager.ts | 2 +- .../src/BlockToolAdapter/index.ts | 57 ++++++++++++------- .../dom-adapters/src/CaretAdapter/index.ts | 29 +++++++--- 3 files changed, 58 insertions(+), 30 deletions(-) diff --git a/packages/core/src/components/BlockManager.ts b/packages/core/src/components/BlockManager.ts index ab213149..49ef0906 100644 --- a/packages/core/src/components/BlockManager.ts +++ b/packages/core/src/components/BlockManager.ts @@ -173,7 +173,7 @@ export class BlocksManager { * 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, index); + this.#caretAdapter.attachBlock(blockToolAdapter); const tool = this.#toolsManager.blockTools.get(data.name); diff --git a/packages/dom-adapters/src/BlockToolAdapter/index.ts b/packages/dom-adapters/src/BlockToolAdapter/index.ts index ed7c7005..08903608 100644 --- a/packages/dom-adapters/src/BlockToolAdapter/index.ts +++ b/packages/dom-adapters/src/BlockToolAdapter/index.ts @@ -11,14 +11,14 @@ import { type ModelEvents, TextAddedEvent, TextRemovedEvent, - Index + type Index } from '@editorjs/model'; import type { EventBus, BlockToolAdapter as BlockToolAdapterInterface, CoreConfig, BeforeInputUIEvent, - BeforeInputUIEventPayload, + BeforeInputUIEventPayload } from '@editorjs/sdk'; import { BeforeInputUIEventName } from '@editorjs/sdk'; import type { CaretAdapter } from '../CaretAdapter/index.js'; @@ -306,6 +306,7 @@ export class BlockToolAdapter implements BlockToolAdapterInterface { */ if (start !== end) { this.#model.removeText(this.#config.userId, this.#blockIndex, key, start, end); + return; } @@ -359,18 +360,42 @@ export class BlockToolAdapter implements BlockToolAdapterInterface { 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) && @@ -397,22 +422,15 @@ export class BlockToolAdapter implements BlockToolAdapterInterface { let end: number; let newCaretIndex: number | null = null; - // console.log('delete in input', input); - /** * If range is fully contained within this input */ if (this.#isInputContainsWholeSelection(input, range)) { - // console.log('range is fully contained within this input'); - 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); - - // newCaretIndex = start; } else if (this.#isInputContainsOnlyStartOfSelection(input, range)) { - // console.log('only start is in this input'); /** * If only start is in this input, delete from start to end of input */ @@ -425,20 +443,18 @@ export class BlockToolAdapter implements BlockToolAdapterInterface { newCaretIndex = start; } } else if (this.#isInputContainsOnlyEndOfSelection(input, range)) { - // console.log('only end is in this input'); /** * If only end is in this input, delete from start of input to end - */ - start = 0; - end = getAbsoluteRangeOffset(input, range.endContainer, range.endOffset); + */ + start = 0; + end = getAbsoluteRangeOffset(input, range.endContainer, range.endOffset); + const removedText = this.#model.removeText(this.#config.userId, this.#blockIndex, key, start, end); - const removedText = this.#model.removeText(this.#config.userId, this.#blockIndex, key, start, end); - if (isRestoreCaretToTheEnd) { - newCaretIndex = end - removedText.length; - } + if (isRestoreCaretToTheEnd) { + newCaretIndex = end - removedText.length; + } } else if (this.#isInputInBetweenSelection(input, range)) { - // console.log('range spans across this input'); /** * If range spans across this input, delete everything */ @@ -448,7 +464,6 @@ export class BlockToolAdapter implements BlockToolAdapterInterface { } if (newCaretIndex !== null) { - console.info('restore caret: block %o index %o caret %o', this.#blockIndex, newCaretIndex); this.#caretAdapter.updateIndex( new IndexBuilder() .addBlockIndex(this.#blockIndex) @@ -544,7 +559,7 @@ export class BlockToolAdapter implements BlockToolAdapterInterface { (this.#isInputContainsOnlyStartOfSelection(input, range) || this.#isInputContainsWholeSelection(input, range)) && !payload.isCrossInputSelection ) { - const start = isInputNative ? + start = isInputNative ? (input as HTMLInputElement | HTMLTextAreaElement).selectionStart as number : getAbsoluteRangeOffset(input, range.startContainer, range.startOffset); @@ -783,6 +798,8 @@ export class BlockToolAdapter implements BlockToolAdapterInterface { /** * 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 2ebea612..913e38aa 100644 --- a/packages/dom-adapters/src/CaretAdapter/index.ts +++ b/packages/dom-adapters/src/CaretAdapter/index.ts @@ -1,4 +1,6 @@ import { isNativeInput } from '@editorjs/dom'; +import type { + ModelEvents } from '@editorjs/model'; import { BlockRemovedEvent, type Caret, @@ -7,7 +9,6 @@ import { EventType, Index, IndexBuilder, - ModelEvents, type TextRange, createDataKey } from '@editorjs/model'; @@ -90,9 +91,8 @@ export class CaretAdapter extends EventTarget { * Adds block to the caret adapter * * @param block - block tool adapter - * @param index - index of the block in the model tree */ - public attachBlock(block: BlockToolAdapter, index: Index): void { + public attachBlock(block: BlockToolAdapter): void { this.#blocks.push(block); } @@ -103,10 +103,12 @@ export class CaretAdapter extends EventTarget { */ public detachBlock(index: Index): void { const block = this.getBlock(index); + if (block) { - const index = this.#blocks.indexOf(block); - if (index !== -1) { - this.#blocks.splice(index, 1); + const blockIndex = this.#blocks.indexOf(block); + + if (blockIndex !== -1) { + this.#blocks.splice(blockIndex, 1); } } } @@ -148,6 +150,7 @@ export class CaretAdapter extends EventTarget { } const blockIndex = index.blockIndex; + if (blockIndex === undefined) { return undefined; } @@ -159,11 +162,12 @@ export class CaretAdapter extends EventTarget { * Finds input by block index and data key * * @param blockIndex - index of the block - * @param dataKey - data key of the input + * @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()); @@ -172,6 +176,7 @@ export class CaretAdapter extends EventTarget { } const dataKey = createDataKey(dataKeyRaw); + return block.getInput(dataKey); } @@ -208,7 +213,10 @@ export class CaretAdapter extends EventTarget { const builder = new IndexBuilder(); - builder.from(block.getBlockIndex()).addDataKey(key).addTextRange(textRange); + builder + .from(block.getBlockIndex()) + .addDataKey(key) + .addTextRange(textRange); this.updateIndex(builder.build()); @@ -230,7 +238,10 @@ export class CaretAdapter extends EventTarget { const builder = new IndexBuilder(); - builder.from(block.getBlockIndex()).addDataKey(key).addTextRange(textRange); + builder + .from(block.getBlockIndex()) + .addDataKey(key) + .addTextRange(textRange); this.updateIndex(builder.build()); From ce3d402d74dcad0487246854aa2cc11c2c9477a6 Mon Sep 17 00:00:00 2001 From: Peter Savchenko Date: Sat, 12 Apr 2025 04:35:30 +0200 Subject: [PATCH 4/5] lint --- packages/dom-adapters/src/BlockToolAdapter/index.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/dom-adapters/src/BlockToolAdapter/index.ts b/packages/dom-adapters/src/BlockToolAdapter/index.ts index 08903608..2ed84278 100644 --- a/packages/dom-adapters/src/BlockToolAdapter/index.ts +++ b/packages/dom-adapters/src/BlockToolAdapter/index.ts @@ -497,7 +497,7 @@ export class BlockToolAdapter implements BlockToolAdapterInterface { /** * In all cases we need to handle delete selected text if range is not collapsed */ - if (!range.collapsed) { + if (range.collapsed === false) { if (isInputNative) { this.#handleDeleteInNativeInput(payload, input as HTMLInputElement | HTMLTextAreaElement, key, range); } else { @@ -509,7 +509,7 @@ export class BlockToolAdapter implements BlockToolAdapterInterface { case InputType.InsertReplacementText: case InputType.InsertFromDrop: case InputType.InsertFromPaste: { - if (data && input.contains(range.startContainer)) { + if (data !== undefined && input.contains(range.startContainer)) { start = isInputNative ? (input as HTMLInputElement | HTMLTextAreaElement).selectionStart as number : getAbsoluteRangeOffset(input, range.startContainer, range.startOffset); @@ -523,7 +523,7 @@ export class BlockToolAdapter implements BlockToolAdapterInterface { * @todo Handle composition events */ case InputType.InsertCompositionText: { - if (data && input.contains(range.startContainer)) { + if (data !== undefined && input.contains(range.startContainer)) { start = isInputNative ? (input as HTMLInputElement | HTMLTextAreaElement).selectionStart as number : getAbsoluteRangeOffset(input, range.startContainer, range.startOffset); @@ -557,7 +557,7 @@ export class BlockToolAdapter implements BlockToolAdapterInterface { */ if ( (this.#isInputContainsOnlyStartOfSelection(input, range) || this.#isInputContainsWholeSelection(input, range)) && - !payload.isCrossInputSelection + payload.isCrossInputSelection === false ) { start = isInputNative ? (input as HTMLInputElement | HTMLTextAreaElement).selectionStart as number : From b6d80228067b58af4c2d4a1efe84cdc6d2613d47 Mon Sep 17 00:00:00 2001 From: Peter Savchenko Date: Sat, 12 Apr 2025 10:44:29 +0200 Subject: [PATCH 5/5] Update index.ts --- packages/dom-adapters/src/CaretAdapter/index.ts | 3 --- 1 file changed, 3 deletions(-) diff --git a/packages/dom-adapters/src/CaretAdapter/index.ts b/packages/dom-adapters/src/CaretAdapter/index.ts index 913e38aa..ade74f87 100644 --- a/packages/dom-adapters/src/CaretAdapter/index.ts +++ b/packages/dom-adapters/src/CaretAdapter/index.ts @@ -368,9 +368,6 @@ export class CaretAdapter extends EventTarget { return; } - /** - * Find all blocks that match the removed block index - */ const blocksToRemove = this.#blocks.find(block => block.getBlockIndex().blockIndex === removedBlockIndex); if (blocksToRemove) {