From e954059e30c08474864a29c4cbf7a1d4eef38c64 Mon Sep 17 00:00:00 2001 From: Peter Savchenko Date: Fri, 11 Apr 2025 00:20:49 +0200 Subject: [PATCH 1/4] delegated before input --- .../internal/block-tools/paragraph/index.ts | 2 + .../src/BlockToolAdapter/index.ts | 107 +++++++++++------- .../EventBus/events/ui/BeforeInputUIEvent.ts | 7 +- packages/ui/src/Blocks/Blocks.module.pcss | 12 ++ packages/ui/src/Blocks/Blocks.ts | 45 +++++++- packages/ui/src/main.module.pcss | 5 + 6 files changed, 137 insertions(+), 41 deletions(-) diff --git a/packages/core/src/tools/internal/block-tools/paragraph/index.ts b/packages/core/src/tools/internal/block-tools/paragraph/index.ts index dcf0ce05..4c0bea72 100644 --- a/packages/core/src/tools/internal/block-tools/paragraph/index.ts +++ b/packages/core/src/tools/internal/block-tools/paragraph/index.ts @@ -50,6 +50,8 @@ export class Paragraph implements BlockTool { public render(): HTMLElement { const wrapper = document.createElement('div'); + wrapper.classList.add('editorjs-paragraph'); + wrapper.contentEditable = 'true'; wrapper.style.outline = 'none'; wrapper.style.whiteSpace = 'pre-wrap'; diff --git a/packages/dom-adapters/src/BlockToolAdapter/index.ts b/packages/dom-adapters/src/BlockToolAdapter/index.ts index bed1da9d..d7b653ef 100644 --- a/packages/dom-adapters/src/BlockToolAdapter/index.ts +++ b/packages/dom-adapters/src/BlockToolAdapter/index.ts @@ -20,9 +20,9 @@ import { isNonTextInput } from '../utils/index.js'; import { InputType } from './types/InputType.js'; -import { type BlockToolAdapter as BlockToolAdapterInterface, type CoreConfig } from '@editorjs/sdk'; +import { BeforeInputUIEventName, type BlockToolAdapter as BlockToolAdapterInterface, type CoreConfig } from '@editorjs/sdk'; import type { FormattingAdapter } from '../FormattingAdapter/index.js'; -import type { EventBus } from '@editorjs/sdk'; +import type { BeforeInputUIEvent, BeforeInputUIEventPayload, EventBus } from '@editorjs/sdk'; /** * BlockToolAdapter is using inside Block tools to connect browser DOM elements to the model @@ -60,6 +60,13 @@ export class BlockToolAdapter implements BlockToolAdapterInterface { */ #config: Required; + /** + * Inputs that bound to the model + * + * @todo handle inputs deletion — remove inputs from the map when they are removed from the DOM + */ + #attachedInputs = new Map(); + /** * BlockToolAdapter constructor * @@ -87,9 +94,9 @@ export class BlockToolAdapter implements BlockToolAdapterInterface { this.#formattingAdapter = formattingAdapter; this.#toolName = toolName; - // eventBus.addEventListener(BeforeInputUIEventName, (event: BeforeInputUIEvent) => { - // console.log('BeforeInputUIEventName', event); - // }); + eventBus.addEventListener(`ui:${BeforeInputUIEventName}`, (event: BeforeInputUIEvent) => { + this.#processDelegatedBeforeInput(event); + }); } /** @@ -106,7 +113,7 @@ export class BlockToolAdapter implements BlockToolAdapterInterface { const key = createDataKey(keyRaw); - input.addEventListener('beforeinput', event => this.#handleBeforeInputEvent(event, input, key)); + this.#attachedInputs.set(key, input); this.#model.addEventListener(EventType.Changed, (event: ModelEvents) => this.#handleModelUpdate(event, input, key)); @@ -130,16 +137,61 @@ export class BlockToolAdapter implements BlockToolAdapterInterface { } } + /** + * Check current selection and find it across all attached inputs + * + * @returns tuple of data key and input element or null if no focused input is found + */ + #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(); + if (selection !== null && selection.rangeCount > 0) { + const range = selection.getRangeAt(0); + return input.contains(range.startContainer); + } + } + return false; + }); + + return currentInput !== undefined ? [currentInput[0], currentInput[1]] : null; + } + + /** + * Handles 'beforeinput' event delegated from the blocks host element + * + * @param event - event containig necessary data + */ + #processDelegatedBeforeInput(event: BeforeInputUIEvent): void { + const [dataKey, currentInput] = this.#findFocusedInput() ?? []; + + if (!currentInput || !dataKey) { + return; + } + + this.#handleBeforeInputEvent(event.detail, currentInput, dataKey); + } + /** * Handles delete events in native input * - * @param event - beforeinput event + * @param payload - beforeinput event payload * @param input - input element * @param key - data key input is attached to * @private */ - #handleDeleteInNativeInput(event: InputEvent, input: HTMLInputElement | HTMLTextAreaElement, key: DataKey): void { - const inputType = event.inputType as InputType; + #handleDeleteInNativeInput(payload: BeforeInputUIEventPayload, input: HTMLInputElement | HTMLTextAreaElement, key: DataKey): void { + const inputType = payload.inputType; /** * Check that selection exists in current input @@ -223,12 +275,12 @@ export class BlockToolAdapter implements BlockToolAdapterInterface { /** * Handles delete events in contenteditable element * - * @param event - beforeinput event + * @param payload - beforeinput event payload * @param input - input element * @param key - data key input is attached to */ - #handleDeleteInContentEditable(event: InputEvent, input: HTMLElement, key: DataKey): void { - const targetRanges = event.getTargetRanges(); + #handleDeleteInContentEditable(payload: BeforeInputUIEventPayload, input: HTMLElement, key: DataKey): void { + const { targetRanges } = payload; const range = targetRanges[0]; const start: number = getAbsoluteRangeOffset(input, range.startContainer, range.startOffset); @@ -242,23 +294,18 @@ export class BlockToolAdapter implements BlockToolAdapterInterface { * * We prevent beforeinput event of any type to handle it manually via model update * - * @param event - beforeinput event + * @param payload - payload of input event * @param input - input element * @param key - data key input is attached to */ - #handleBeforeInputEvent(event: InputEvent, input: HTMLElement, key: DataKey): void { - /** - * We prevent all events to handle them manually via model update - */ - event.preventDefault(); + #handleBeforeInputEvent(payload: BeforeInputUIEventPayload, input: HTMLElement, key: DataKey): void { + const { data, inputType, targetRanges } = payload; const isInputNative = isNativeInput(input); - const inputType = event.inputType as InputType; let start: number; let end: number; if (isInputNative === false) { - const targetRanges = event.getTargetRanges(); const range = targetRanges[0]; start = getAbsoluteRangeOffset(input, range.startContainer, range.startOffset); @@ -278,20 +325,6 @@ export class BlockToolAdapter implements BlockToolAdapterInterface { this.#model.removeText(this.#config.userId, this.#blockIndex, key, start, end); } - let data: string; - - /** - * 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 - */ - if (isInputNative) { - data = event.data ?? ''; - } else { - data = event.dataTransfer!.getData('text/plain'); - } - this.#model.insertText(this.#config.userId, this.#blockIndex, key, data, start); break; @@ -309,8 +342,6 @@ export class BlockToolAdapter implements BlockToolAdapterInterface { this.#model.removeText(this.#config.userId, this.#blockIndex, key, start, end); } - const data = event.data as string; - this.#model.insertText(this.#config.userId, this.#blockIndex, key, data, start); break; } @@ -328,9 +359,9 @@ export class BlockToolAdapter implements BlockToolAdapterInterface { case InputType.DeleteWordBackward: case InputType.DeleteWordForward: { if (isInputNative === true) { - this.#handleDeleteInNativeInput(event, input as HTMLInputElement | HTMLTextAreaElement, key); + this.#handleDeleteInNativeInput(payload, input as HTMLInputElement | HTMLTextAreaElement, key); } else { - this.#handleDeleteInContentEditable(event, input, key); + this.#handleDeleteInContentEditable(payload, input, key); } break; } diff --git a/packages/sdk/src/entities/EventBus/events/ui/BeforeInputUIEvent.ts b/packages/sdk/src/entities/EventBus/events/ui/BeforeInputUIEvent.ts index c2539e1e..1963ec11 100644 --- a/packages/sdk/src/entities/EventBus/events/ui/BeforeInputUIEvent.ts +++ b/packages/sdk/src/entities/EventBus/events/ui/BeforeInputUIEvent.ts @@ -15,7 +15,7 @@ export interface BeforeInputUIEventPayload { * This may be an empty string if the change doesn't insert text * (for example, when deleting characters). */ - data: string | null; + data: string; /** * Same as 'beforeinput' event's inputType @@ -26,6 +26,11 @@ export interface BeforeInputUIEventPayload { * Same as 'beforeinput' event's isComposing */ isComposing: boolean; + + /** + * Objects that will be affected by a change to the DOM if the input event is not canceled. + */ + targetRanges: StaticRange[]; } /** diff --git a/packages/ui/src/Blocks/Blocks.module.pcss b/packages/ui/src/Blocks/Blocks.module.pcss index 9aae8b9f..f78f4762 100644 --- a/packages/ui/src/Blocks/Blocks.module.pcss +++ b/packages/ui/src/Blocks/Blocks.module.pcss @@ -1,3 +1,15 @@ .blocks { outline: none; + display: grid; + grid-template-columns: 1fr; +} + +/** + * Zero-width space wrapper that will prevent Safari from deleting blocks if there is no content in host + */ +.host-holder { + line-height: 0; + width: 0; + height: 0; + user-select: none; } \ No newline at end of file diff --git a/packages/ui/src/Blocks/Blocks.ts b/packages/ui/src/Blocks/Blocks.ts index b021e275..26548f97 100644 --- a/packages/ui/src/Blocks/Blocks.ts +++ b/packages/ui/src/Blocks/Blocks.ts @@ -9,6 +9,7 @@ import { } from '@editorjs/sdk'; import type { EventBus } from '@editorjs/sdk'; import Style from './Blocks.module.pcss'; +import { isNativeInput } from '@editorjs/dom'; /** * Editor's main UI renderer for HTML environment @@ -85,13 +86,39 @@ export class BlocksUI implements EditorjsPlugin { blocksHolder.classList.add(Style['blocks']); - blocksHolder.contentEditable = 'false'; + blocksHolder.contentEditable = 'true'; + + /** + * Workaround Safari behavior when it deletes blocks if there is no content in them + * E.g. when you delete all content in the only block, it deletes the block + */ + this.#addHostHolder(blocksHolder); blocksHolder.addEventListener('beforeinput', (e) => { + e.preventDefault(); + + const isInputNative = isNativeInput(e.target as HTMLElement); + + let data: string; + + /** + * 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 + */ + if (isInputNative) { + data = e.data ?? ''; + } else { + data = e.dataTransfer?.getData('text/plain') ?? e.data ?? ''; + } + + console.log('ragne', window.getSelection()?.getRangeAt(0)); + this.#eventBus.dispatchEvent(new BeforeInputUIEvent({ - data: e.data, + data, inputType: e.inputType, isComposing: e.isComposing, + targetRanges: e.getTargetRanges(), })); }); @@ -100,6 +127,20 @@ export class BlocksUI implements EditorjsPlugin { return blocksHolder; } + /** + * Adds host holder that will prevent Safari from deleting blocks if there is no content host + * @param blocksHolder - blocks holder element + */ + #addHostHolder(blocksHolder: HTMLElement): void { + const zeroWidthSpaceWrapper = document.createElement('span'); + const zeroWidthSpace = document.createTextNode('\u200B'); + + zeroWidthSpaceWrapper.classList.add(Style['host-holder']); + zeroWidthSpaceWrapper.appendChild(zeroWidthSpace); + + blocksHolder.appendChild(zeroWidthSpaceWrapper); + } + /** * Renders block's content on the page * @param blockElement - block HTML element to add to the page diff --git a/packages/ui/src/main.module.pcss b/packages/ui/src/main.module.pcss index 3bbf0f4b..282e22cc 100644 --- a/packages/ui/src/main.module.pcss +++ b/packages/ui/src/main.module.pcss @@ -8,3 +8,8 @@ width: 100%; } + +.paragraph { + min-height: 1em; +} + From 9cde69d7ee1e0ffb6c026256ccc52b9a87a0a6b3 Mon Sep 17 00:00:00 2001 From: Peter Savchenko Date: Fri, 11 Apr 2025 00:24:40 +0200 Subject: [PATCH 2/4] lint --- packages/ui/src/Toolbox/Toolbox.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/ui/src/Toolbox/Toolbox.ts b/packages/ui/src/Toolbox/Toolbox.ts index 786cde67..9d05f162 100644 --- a/packages/ui/src/Toolbox/Toolbox.ts +++ b/packages/ui/src/Toolbox/Toolbox.ts @@ -55,7 +55,7 @@ export class ToolboxUI implements EditorjsPlugin { this.#eventBus.addEventListener(`core:${CoreEventType.ToolLoaded}`, (event: ToolLoadedCoreEvent) => { const { tool } = event.detail; - if ('isBlock' in tool && tool.isBlock()) { + if (tool?.isBlock?.() === true) { this.addTool(tool); } }); From beccf3e9c15f965f8d2e981e578bd0e98b308c58 Mon Sep 17 00:00:00 2001 From: Peter Savchenko Date: Fri, 11 Apr 2025 00:28:46 +0200 Subject: [PATCH 3/4] lint --- packages/dom-adapters/.eslintrc.yml | 2 ++ packages/dom-adapters/src/BlockToolAdapter/index.ts | 11 +++++++---- packages/ui/src/Blocks/Blocks.ts | 2 -- 3 files changed, 9 insertions(+), 6 deletions(-) diff --git a/packages/dom-adapters/.eslintrc.yml b/packages/dom-adapters/.eslintrc.yml index 1825e41e..b8400d35 100644 --- a/packages/dom-adapters/.eslintrc.yml +++ b/packages/dom-adapters/.eslintrc.yml @@ -27,6 +27,8 @@ rules: - 0 '@typescript-eslint/no-unsafe-argument': - 0 + 'jsdoc/require-returns-type': + - 0 env: browser: true diff --git a/packages/dom-adapters/src/BlockToolAdapter/index.ts b/packages/dom-adapters/src/BlockToolAdapter/index.ts index 006d6ae1..6a4df4e4 100644 --- a/packages/dom-adapters/src/BlockToolAdapter/index.ts +++ b/packages/dom-adapters/src/BlockToolAdapter/index.ts @@ -12,14 +12,14 @@ import { TextAddedEvent, TextRemovedEvent } from '@editorjs/model'; -import type { +import type { EventBus, BlockToolAdapter as BlockToolAdapterInterface, CoreConfig, BeforeInputUIEvent, BeforeInputUIEventPayload - } from '@editorjs/sdk'; - import { BeforeInputUIEventName } from '@editorjs/sdk'; +} from '@editorjs/sdk'; +import { BeforeInputUIEventName } from '@editorjs/sdk'; import type { CaretAdapter } from '../CaretAdapter/index.js'; import type { FormattingAdapter } from '../FormattingAdapter/index.js'; import { @@ -148,7 +148,7 @@ export class BlockToolAdapter implements BlockToolAdapterInterface { /** * Check current selection and find it across all attached inputs - * + * * @returns tuple of data key and input element or null if no focused input is found */ #findFocusedInput(): [DataKey, HTMLElement] | null { @@ -165,11 +165,14 @@ export class BlockToolAdapter implements BlockToolAdapterInterface { */ if (input.isContentEditable) { const selection = window.getSelection(); + if (selection !== null && selection.rangeCount > 0) { const range = selection.getRangeAt(0); + return input.contains(range.startContainer); } } + return false; }); diff --git a/packages/ui/src/Blocks/Blocks.ts b/packages/ui/src/Blocks/Blocks.ts index 26548f97..5b1a53c3 100644 --- a/packages/ui/src/Blocks/Blocks.ts +++ b/packages/ui/src/Blocks/Blocks.ts @@ -112,8 +112,6 @@ export class BlocksUI implements EditorjsPlugin { data = e.dataTransfer?.getData('text/plain') ?? e.data ?? ''; } - console.log('ragne', window.getSelection()?.getRangeAt(0)); - this.#eventBus.dispatchEvent(new BeforeInputUIEvent({ data, inputType: e.inputType, From 268212577d305014aeede90596c0b4f815ed44f4 Mon Sep 17 00:00:00 2001 From: Peter Savchenko Date: Fri, 11 Apr 2025 00:36:07 +0200 Subject: [PATCH 4/4] lint --- packages/dom-adapters/src/BlockToolAdapter/index.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/dom-adapters/src/BlockToolAdapter/index.ts b/packages/dom-adapters/src/BlockToolAdapter/index.ts index 6a4df4e4..bf3cab25 100644 --- a/packages/dom-adapters/src/BlockToolAdapter/index.ts +++ b/packages/dom-adapters/src/BlockToolAdapter/index.ts @@ -176,7 +176,7 @@ export class BlockToolAdapter implements BlockToolAdapterInterface { return false; }); - return currentInput !== undefined ? [currentInput[0], currentInput[1]] : null; + return currentInput !== undefined ? currentInput : null; } /** @@ -187,7 +187,7 @@ export class BlockToolAdapter implements BlockToolAdapterInterface { #processDelegatedBeforeInput(event: BeforeInputUIEvent): void { const [dataKey, currentInput] = this.#findFocusedInput() ?? []; - if (!currentInput || !dataKey) { + if (currentInput === undefined || dataKey === undefined) { return; }