Skip to content

Commit

Permalink
Unbind formatting form Selection ranges (#93)
Browse files Browse the repository at this point in the history
* Unbind formatting form Selection ranges

* Remove unused var

* Remove resolved todo

* Fix InlineParentNode.insertAfter method & add isActive state for inline toolbar buttons

* Fix lint & build

* Send event if selection is lost for the input

* Fix lint & tests
  • Loading branch information
gohabereg authored Sep 2, 2024
1 parent 5370c46 commit 2639073
Show file tree
Hide file tree
Showing 13 changed files with 158 additions and 76 deletions.
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -17,6 +17,11 @@ export interface SelectionChangedCoreEventPayload {
* Inline tools available for the current selection
*/
readonly availableInlineTools: Map<InlineToolName, InlineTool>;

/**
* Inline fragments available for the current selection
*/
readonly fragments: InlineFragment[];
}

/**
Expand Down
17 changes: 14 additions & 3 deletions packages/core/src/components/SelectionManager.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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;
}
Expand Down
19 changes: 13 additions & 6 deletions packages/core/src/ui/InlineToolbar/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

/**
Expand Down Expand Up @@ -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 (
Expand All @@ -75,7 +75,7 @@ export class InlineToolbarUI {
return;
}

this.#updateToolsList(availableInlineTools);
this.#updateToolsList(availableInlineTools, index.textRange, fragments);
this.#move();
this.#show();
}
Expand Down Expand Up @@ -129,24 +129,31 @@ 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';
}

/**
* 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<InlineToolName, InlineTool>): void {
#updateToolsList(tools: Map<InlineToolName, InlineTool>, textRange: TextRange, fragments: InlineFragment[]): void {
this.#nodes.buttons.innerHTML = '';

Array.from(tools.entries()).forEach(([name, tool]) => {
const button = make('button');

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);
Expand Down
4 changes: 1 addition & 3 deletions packages/dom-adapters/src/BlockToolAdapter/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
40 changes: 30 additions & 10 deletions packages/dom-adapters/src/CaretAdapter/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,17 +82,45 @@ 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
*
* @param selection - new document selection
*/
#onSelectionChange(selection: Selection | null): void {
if (!selection) {
this.updateIndex(null);

return;
}

Expand Down Expand Up @@ -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;
Expand Down
74 changes: 32 additions & 42 deletions packages/dom-adapters/src/FormattingAdapter/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
*
Expand All @@ -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);
}
Expand Down Expand Up @@ -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);
}
}
}
19 changes: 13 additions & 6 deletions packages/dom-adapters/src/utils/surround.ts
Original file line number Diff line number Diff line change
@@ -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);
}
25 changes: 25 additions & 0 deletions packages/dom-adapters/src/utils/useSelectionChange.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*/
Expand Down Expand Up @@ -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;
}
});
}

Expand Down
4 changes: 2 additions & 2 deletions packages/model/src/CaretManagement/Caret/Caret.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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));
Expand All @@ -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;
}
}
1 change: 1 addition & 0 deletions packages/model/src/CaretManagement/CaretManager.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,7 @@ describe('CaretManager', () => {
expect(handler).toHaveBeenCalledWith(expect.objectContaining({
detail: {
id: caret.id,
index: null,
},
}));
});
Expand Down
Loading

2 comments on commit 2639073

@github-actions
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Coverage report for ./packages/model

St.
Category Percentage Covered / Total
🟢 Statements 96.74% 800/827
🟢 Branches 98.18% 216/220
🟢 Functions 88.07% 192/218
🟢 Lines 96.62% 772/799

Test suite run success

405 tests passing in 24 suites.

Report generated by 🧪jest coverage report action from 2639073

@github-actions
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Coverage report for ./packages/collaboration-manager

St.
Category Percentage Covered / Total
🟢 Statements 86.11% 62/72
🟡 Branches 62.5% 15/24
🟢 Functions 100% 10/10
🟢 Lines 86.11% 62/72

Test suite run success

6 tests passing in 1 suite.

Report generated by 🧪jest coverage report action from 2639073

Please sign in to comment.