diff --git a/packages/core/src/components/EventBus/core-events/SelectionChangedCoreEvent.ts b/packages/core/src/components/EventBus/core-events/SelectionChangedCoreEvent.ts index 80c49519..cd29d849 100644 --- a/packages/core/src/components/EventBus/core-events/SelectionChangedCoreEvent.ts +++ b/packages/core/src/components/EventBus/core-events/SelectionChangedCoreEvent.ts @@ -1,7 +1,7 @@ import type { InlineTool } from '@editorjs/sdk'; import { CoreEventBase } from './CoreEventBase.js'; import { CoreEventType } from './CoreEventType.js'; -import type { Index, InlineToolName } from '@editorjs/model'; +import type { Index, InlineFragment, InlineToolName } from '@editorjs/model'; /** * Payload of SelectionChangedCoreEvent custom event @@ -17,6 +17,11 @@ export interface SelectionChangedCoreEventPayload { * Inline tools available for the current selection */ readonly availableInlineTools: Map; + + /** + * Inline fragments available for the current selection + */ + readonly fragments: InlineFragment[]; } /** diff --git a/packages/core/src/components/SelectionManager.ts b/packages/core/src/components/SelectionManager.ts index 3c2cbed7..be92c9b6 100644 --- a/packages/core/src/components/SelectionManager.ts +++ b/packages/core/src/components/SelectionManager.ts @@ -1,6 +1,6 @@ import 'reflect-metadata'; import { FormattingAdapter } from '@editorjs/dom-adapters'; -import type { CaretManagerEvents, InlineToolName } from '@editorjs/model'; +import type { CaretManagerEvents, InlineFragment, InlineToolName } from '@editorjs/model'; import { CaretManagerCaretUpdatedEvent, Index, EditorJSModel, createInlineToolData, createInlineToolName } from '@editorjs/model'; import { EventType } from '@editorjs/model'; import { Service } from 'typedi'; @@ -73,15 +73,26 @@ export class SelectionManager { */ #handleCaretManagerUpdate(event: CaretManagerEvents): void { switch (true) { - case event instanceof CaretManagerCaretUpdatedEvent: + case event instanceof CaretManagerCaretUpdatedEvent: { + const { index: serializedIndex } = event.detail; + const index = serializedIndex !== null ? Index.parse(serializedIndex) : null; + let fragments: InlineFragment[] = []; + + if (index !== null && index.blockIndex !== undefined && index.dataKey !== undefined && index.textRange !== undefined) { + fragments = this.#model.getFragments(index.blockIndex, index.dataKey, ...index.textRange); + } + this.#eventBus.dispatchEvent(new SelectionChangedCoreEvent({ - index: event.detail.index !== null ? Index.parse(event.detail.index) : null, + index, /** * @todo implement filter by current BlockTool configuration */ availableInlineTools: this.#inlineTools, + fragments, })); + break; + } default: break; } diff --git a/packages/core/src/ui/InlineToolbar/index.ts b/packages/core/src/ui/InlineToolbar/index.ts index cc0377f2..c4e84876 100644 --- a/packages/core/src/ui/InlineToolbar/index.ts +++ b/packages/core/src/ui/InlineToolbar/index.ts @@ -6,7 +6,7 @@ import { InlineToolbarRenderedUIEvent } from './InlineToolbarRenderedUIEvent.js' import { CoreEventType, EventBus, SelectionChangedCoreEvent } from '../../components/EventBus/index.js'; import { EditorAPI } from '../../api/index.js'; import { InlineTool, InlineToolFormatData } from '@editorjs/sdk'; -import { InlineToolName } from '@editorjs/model'; +import { InlineFragment, InlineToolName, TextRange } from '@editorjs/model'; import { CoreConfigValidated } from '../../entities/index.js'; /** @@ -56,7 +56,7 @@ export class InlineToolbarUI { * @param event - SelectionChangedCoreEvent event */ #handleSelectionChange(event: SelectionChangedCoreEvent): void { - const { availableInlineTools, index } = event.detail; + const { availableInlineTools, index, fragments } = event.detail; const selection = window.getSelection(); if ( @@ -75,7 +75,7 @@ export class InlineToolbarUI { return; } - this.#updateToolsList(availableInlineTools); + this.#updateToolsList(availableInlineTools, index.textRange, fragments); this.#move(); this.#show(); } @@ -129,8 +129,7 @@ export class InlineToolbarUI { const rect = range.getBoundingClientRect(); - // eslint-disable-next-line @typescript-eslint/no-magic-numbers - this.#nodes.holder.style.top = `${rect.top + 16}px`; + this.#nodes.holder.style.top = `${rect.top}px`; this.#nodes.holder.style.left = `${rect.left}px`; this.#nodes.holder.style.zIndex = '1000'; } @@ -138,8 +137,10 @@ export class InlineToolbarUI { /** * Renders the list of available inline tools in the Inline Toolbar * @param tools - Inline Tools available for the current selection + * @param textRange - current selection text range + * @param fragments - inline fragments for the current selection */ - #updateToolsList(tools: Map): void { + #updateToolsList(tools: Map, textRange: TextRange, fragments: InlineFragment[]): void { this.#nodes.buttons.innerHTML = ''; Array.from(tools.entries()).forEach(([name, tool]) => { @@ -147,6 +148,12 @@ export class InlineToolbarUI { button.textContent = name; + const isActive = tool.isActive(textRange, fragments.filter((fragment: InlineFragment) => fragment.tool === name)); + + if (isActive) { + button.style.fontWeight = 'bold'; + } + if (Object.hasOwnProperty.call(tool.constructor.prototype, 'renderActions')) { button.addEventListener('click', () => { this.#renderToolActions(name, tool); diff --git a/packages/dom-adapters/src/BlockToolAdapter/index.ts b/packages/dom-adapters/src/BlockToolAdapter/index.ts index 40b1c0bf..79d7dd5d 100644 --- a/packages/dom-adapters/src/BlockToolAdapter/index.ts +++ b/packages/dom-adapters/src/BlockToolAdapter/index.ts @@ -101,10 +101,8 @@ export class BlockToolAdapter implements BlockToolAdapterInterface { input.textContent = value; - const nodeToFormat = input.firstChild as HTMLElement; // we just set textContent, so it's always a TextNode - fragments.forEach(fragment => { - this.#formattingAdapter.formatElementContent(nodeToFormat, fragment); + this.#formattingAdapter.formatElementContent(input, fragment); }); } catch (_) { // do nothing — TextNode is not created yet as there is no initial data in the model diff --git a/packages/dom-adapters/src/CaretAdapter/index.ts b/packages/dom-adapters/src/CaretAdapter/index.ts index 3d77873c..327694e6 100644 --- a/packages/dom-adapters/src/CaretAdapter/index.ts +++ b/packages/dom-adapters/src/CaretAdapter/index.ts @@ -82,10 +82,36 @@ export class CaretAdapter extends EventTarget { * * @param index - new caret index */ - public updateIndex(index: Index): void { + public updateIndex(index: Index | null): void { this.#userCaret.update(index); } + /** + * Finds input by index + * + * @param index - index of the input in the model tree + */ + public getInput(index?: Index): HTMLElement | undefined { + const builder = new IndexBuilder(); + + + if (index !== undefined) { + builder.from(index); + } else if (this.#userCaret.index !== null) { + builder.from(this.#userCaret.index); + } else { + throw new Error('[CaretManager] No index provided and no user caret index found'); + } + + /** + * Inputs are stored in the hashmap with serialized index as a key + * Those keys are serialized without text range to cover the whole input, so we need to remove it here to find the input + */ + builder.addTextRange(undefined); + + return this.#inputs.get(builder.build().serialize()); + } + /** * Selection change handler * @@ -93,6 +119,8 @@ export class CaretAdapter extends EventTarget { */ #onSelectionChange(selection: Selection | null): void { if (!selection) { + this.updateIndex(null); + return; } @@ -176,15 +204,7 @@ export class CaretAdapter extends EventTarget { return; } - const builder = new IndexBuilder(); - - /** - * Inputs are stored in the hashmap with serialized index as a key - * Those keys are serialized without text range to cover the whole input, so we need to remove it here to find the input - */ - builder.from(index).addTextRange(undefined); - - const input = this.#inputs.get(builder.build().serialize()); + const input = this.getInput(index); if (!input) { return; diff --git a/packages/dom-adapters/src/FormattingAdapter/index.ts b/packages/dom-adapters/src/FormattingAdapter/index.ts index c3a72c1f..b2e92985 100644 --- a/packages/dom-adapters/src/FormattingAdapter/index.ts +++ b/packages/dom-adapters/src/FormattingAdapter/index.ts @@ -50,35 +50,6 @@ export class FormattingAdapter { this.#model.addEventListener(EventType.Changed, (event: ModelEvents) => this.#handleModelUpdates(event)); } - /** - * @todo move event handling to BlockToolAdapter - * Handles text format and unformat model events - * - * @param event - model change event - */ - #handleModelUpdates(event: ModelEvents): void { - if (event instanceof TextFormattedEvent) { - const tool = this.#tools.get(event.detail.data.tool); - - if (tool === undefined) { - return; - } - - const selection = window.getSelection(); - - /** - * Render inline tool for current range - */ - if (selection) { - const range = selection.getRangeAt(0); - - const inlineElement = tool.createWrapper(event.detail.data.data); - - surround(range, inlineElement); - } - } - } - /** * Allows to render formatting inside a passed input * @@ -91,28 +62,18 @@ export class FormattingAdapter { public formatElementContent(input: HTMLElement, inlineFragment: InlineFragment): void { const toolName = inlineFragment.tool; const toolData = inlineFragment.data; - const index = inlineFragment.range; + const textRange = inlineFragment.range; const tool = this.#tools.get(toolName); if (tool === undefined) { throw new Error(`FormattingAdapter: tool ${toolName} is not attached`); - } - - const [start, end] = index; + }; try { - /** - * Create range with positions specified in index - */ - const range = document.createRange(); - - range.setStart(input, start); - range.setEnd(input, end); - const inlineElement = tool.createWrapper(toolData); - surround(range, inlineElement); + surround(inlineElement, input, textRange); } catch (e) { console.error('Error while formatting element content', e); } @@ -186,4 +147,33 @@ export class FormattingAdapter { break; } } + + + /** + * Handles text format and unformat model events + * + * @param event - model change event + */ + #handleModelUpdates(event: ModelEvents): void { + if (event instanceof TextFormattedEvent) { + const tool = this.#tools.get(event.detail.data.tool); + const { textRange } = event.detail.index; + + if (tool === undefined || textRange === undefined) { + return; + } + + const input = this.#caretAdapter.getInput(event.detail.index); + + if (input === undefined) { + console.warn('No input found for the index', event.detail.index); + + return; + } + + const inlineElement = tool.createWrapper(event.detail.data.data); + + surround(inlineElement, input, textRange); + } + } } diff --git a/packages/dom-adapters/src/utils/surround.ts b/packages/dom-adapters/src/utils/surround.ts index 9dfc54b2..e295ab16 100644 --- a/packages/dom-adapters/src/utils/surround.ts +++ b/packages/dom-adapters/src/utils/surround.ts @@ -1,18 +1,25 @@ +import type { TextRange } from '@editorjs/model'; +import { getBoundaryPointByAbsoluteOffset } from './getRelativeIndex.js'; + /** * Function, that surrounds passed range with passed html element * - * @param range - range to be surrounded - * @param wrapper - wrapper to surround the range + * @param wrapper - element to surround the range + * @param inputElement - element, where the range is located + * @param textRange - range to be surrounded */ -export function surround(range: Range, wrapper: HTMLElement): void { - const inlineElement = wrapper; +export function surround(wrapper: HTMLElement, inputElement: HTMLElement, textRange: TextRange): void { + const range = document.createRange(); + + range.setStart(...getBoundaryPointByAbsoluteOffset(inputElement, textRange[0])); + range.setEnd(...getBoundaryPointByAbsoluteOffset(inputElement, textRange[1])); const extracted = range.extractContents(); /** * Insert contents from range to new inline element and put created element in range */ - inlineElement.appendChild(extracted); + wrapper.appendChild(extracted); - range.insertNode(inlineElement); + range.insertNode(wrapper); } diff --git a/packages/dom-adapters/src/utils/useSelectionChange.ts b/packages/dom-adapters/src/utils/useSelectionChange.ts index d0a8fdc7..1fd3286c 100644 --- a/packages/dom-adapters/src/utils/useSelectionChange.ts +++ b/packages/dom-adapters/src/utils/useSelectionChange.ts @@ -31,6 +31,12 @@ export type InputWithCaret = HTMLElement; * Utility composable that watches for document "selection change" event and delegates the provided callbacks to subscribers. */ export const useSelectionChange = createSingleton(() => { + /** + * Stores the last input that was related to the selection. + * We need that to send update when selection is moved to another input. + */ + let lastRelatedInput: HTMLElement | null = null; + /** * Used to iterate over all inputs and check if selection is related to them. */ @@ -72,9 +78,28 @@ export const useSelectionChange = createSingleton(() => { inputsWatched.forEach((input) => { const subscriber = subscribers.get(input); + let isRelatedInputFound = false; + if (subscriber && isSelectionRelatedToInput(selection, input)) { + lastRelatedInput = input; + isRelatedInputFound = true; + subscriber.callback.call(subscriber.context, selection); } + + /** + * If no related input found or input is changed, we need to notify subscriber selection is out of it's input + */ + if (!isRelatedInputFound || input !== lastRelatedInput) { + subscriber?.callback.call(subscriber.context, null); + } + + /** + * If no related input found, we need to reset last related input + */ + if (!isRelatedInputFound) { + lastRelatedInput = null; + } }); } diff --git a/packages/model/src/CaretManagement/Caret/Caret.ts b/packages/model/src/CaretManagement/Caret/Caret.ts index b8a47824..fa6b8dd7 100644 --- a/packages/model/src/CaretManagement/Caret/Caret.ts +++ b/packages/model/src/CaretManagement/Caret/Caret.ts @@ -70,7 +70,7 @@ export class Caret extends EventBus { * * @param index - new caret index */ - public update(index: Index): void { + public update(index: Index | null): void { this.#index = index; this.dispatchEvent(new CaretUpdatedEvent(this)); @@ -82,7 +82,7 @@ export class Caret extends EventBus { public toJSON(): CaretSerialized { return { id: this.id, - index: this.index?.serialize(), + index: this.index !== null ? this.index.serialize() : null, } as CaretSerialized; } } diff --git a/packages/model/src/CaretManagement/CaretManager.spec.ts b/packages/model/src/CaretManagement/CaretManager.spec.ts index b9a603cf..3d0636f3 100644 --- a/packages/model/src/CaretManagement/CaretManager.spec.ts +++ b/packages/model/src/CaretManagement/CaretManager.spec.ts @@ -97,6 +97,7 @@ describe('CaretManager', () => { expect(handler).toHaveBeenCalledWith(expect.objectContaining({ detail: { id: caret.id, + index: null, }, })); }); diff --git a/packages/model/src/entities/inline-fragments/mixins/ParentNode/index.ts b/packages/model/src/entities/inline-fragments/mixins/ParentNode/index.ts index 60b6777f..a8b4430b 100644 --- a/packages/model/src/entities/inline-fragments/mixins/ParentNode/index.ts +++ b/packages/model/src/entities/inline-fragments/mixins/ParentNode/index.ts @@ -137,6 +137,11 @@ export function ParentNode(constr * @param children - children nodes to insert */ public insertAfter(target: ChildNode, ...children: ChildNode[]): void { + /** + * We need to get the index first before any manipulations with children array + */ + const targetIndex = this.children.indexOf(target); + /** * Append children to the parent to set their parent property */ @@ -154,9 +159,7 @@ export function ParentNode(constr /** * Insert added children to correct places */ - const index = this.children.indexOf(target); - - this.children.splice(index + 1, 0, ...children); + this.children.splice(targetIndex + 1, 0, ...children); } diff --git a/packages/model/src/entities/inline-fragments/specs/ParentNode.spec.ts b/packages/model/src/entities/inline-fragments/specs/ParentNode.spec.ts index 23177423..a296f040 100644 --- a/packages/model/src/entities/inline-fragments/specs/ParentNode.spec.ts +++ b/packages/model/src/entities/inline-fragments/specs/ParentNode.spec.ts @@ -182,6 +182,19 @@ describe('ParentNode mixin', () => { expect(dummy.children).toEqual([childMock, anotherChildMock, childMockToInsert]); }); + + it('should correctly insert children when it contains the target child', () => { + const childMock = new ChildDummy(); + const anotherChildMock = new ChildDummy(); + const childMockToInsert = new ChildDummy(); + const anotherChildMockToInsert = new ChildDummy(); + + dummy.append(childMock, childMockToInsert, anotherChildMock); + + dummy.insertAfter(childMockToInsert, anotherChildMockToInsert, childMockToInsert); + + expect(dummy.children).toEqual([childMock, anotherChildMock, anotherChildMockToInsert, childMockToInsert]); + }); }); describe('.removeChild()', () => { diff --git a/packages/playground/src/App.vue b/packages/playground/src/App.vue index 1f12564d..3f2a12ef 100644 --- a/packages/playground/src/App.vue +++ b/packages/playground/src/App.vue @@ -132,6 +132,8 @@ onMounted(() => { background-color: #111; border-radius: 8px; padding: 10px; + + font-size: 2em; } .sectionHeading {