Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions packages/core/src/api/SelectionAPI.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import type { CoreConfigValidated } from '@editorjs/sdk';
// Mock dependencies before importing the module under test
jest.unstable_mockModule('../components/SelectionManager', () => ({
SelectionManager: jest.fn(() => ({
applyInlineToolForCurrentSelection: jest.fn(),
applyInlineTool: jest.fn(),
})),
}));

Expand Down Expand Up @@ -39,7 +39,10 @@ describe('SelectionAPI', () => {
});

expect(createInlineToolName).toHaveBeenCalledWith('bold');
expect(selectionManager.applyInlineToolForCurrentSelection).toHaveBeenCalledWith('inline:bold', { level: 1 });
expect(selectionManager.applyInlineTool).toHaveBeenCalledWith({
toolName: 'inline:bold',
data: { level: 1 },
});
});
});
});
25 changes: 22 additions & 3 deletions packages/core/src/api/SelectionAPI.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import 'reflect-metadata';
import { inject, injectable } from 'inversify';

import { SelectionManager } from '../components/SelectionManager.js';
import { Caret, CaretManagerEvents, createInlineToolName, EditorJSModel, EventType } from '@editorjs/model';
import { Caret, CaretManagerEvents, createInlineToolName, EditorJSModel, EventType, Index } from '@editorjs/model';
import { CoreConfigValidated } from '@editorjs/sdk';
import { SelectionAPI as SelectionApiInterface } from '@editorjs/sdk';
import { TOKENS } from '../tokens.js';
Expand Down Expand Up @@ -33,14 +33,33 @@ export class SelectionAPI implements SelectionApiInterface {
this.#config = config;
}

/**
* Returns caret index for current user (or null)
* @returns Index of the caret for the current user or null
*/
public get caretIndex(): Index | null {
return this.#selectionManager.currentSelection;
}

/**
* Applies passed inline tool to the current selection
* @param params - methods parameters
* @param params.tool - Inline Tool name from the config to apply on the current selection
* @param [params.data] - Inline Tool data to apply to the current selection (e.g. link data)
* @param [params.caretIndex] - index where to apply the tool, by default equals current selection
* @param [params.action] - by default, method will flip the formatting. You can choose a specific action with this parameter
* @param [params.keepSelection] - if false, selection will be collapsed to the right. If true, selection will be restored to the caretIndex. True by default
* @param [params.userId] - id of a user to attribute the change to
*/
public applyInlineTool({ tool, data }: Parameters<SelectionApiInterface['applyInlineTool']>[0]): void {
this.#selectionManager.applyInlineToolForCurrentSelection(createInlineToolName(tool), data);
public applyInlineTool({ tool, data, caretIndex, userId, action, keepSelection }: Parameters<SelectionApiInterface['applyInlineTool']>[0]): void {
this.#selectionManager.applyInlineTool({
toolName: createInlineToolName(tool),
data,
userId,
caretIndex,
action,
keepSelection,
});
}

/**
Expand Down
41 changes: 25 additions & 16 deletions packages/core/src/components/SelectionManager.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ jest.unstable_mockModule('@editorjs/model', () => {
EditorJSModel,
CaretManagerCaretUpdatedEvent: caretManagerCaretUpdatedEvent,
Index: { parse: jest.fn() },
IndexBuilder: jest.fn(),
EventType: eventType,
createInlineToolData: (data: Record<string, unknown>) => data,
createInlineToolName: (name: string) => name,
Expand Down Expand Up @@ -111,7 +112,7 @@ describe('SelectionManager', () => {
expect(SelectionChangedCoreEvent).toHaveBeenCalledWith(expect.objectContaining({
index: null,
fragments: [],
availableInlineTools: expect.any(Map),
availableInlineTools: expect.any(Array),
}));
expect(eventBus.dispatchEvent).toHaveBeenCalled();
});
Expand Down Expand Up @@ -205,26 +206,26 @@ describe('SelectionManager', () => {

caretEventsListener(event);

const callArg = (SelectionChangedCoreEvent as jest.MockedClass<typeof SelectionChangedCoreEvent>).mock.calls[0][0] as { availableInlineTools: Map<string, unknown> };
const callArg = ((SelectionChangedCoreEvent as jest.MockedClass<typeof SelectionChangedCoreEvent>).mock.calls[0][0] as unknown) as { availableInlineTools: unknown[] };

expect(callArg.availableInlineTools.has('italic')).toBe(true);
expect(callArg.availableInlineTools).toContain(facadeMock);
});
});

describe('.applyInlineToolForCurrentSelection()', () => {
describe('.applyInlineTool()', () => {
it('should throw when caret is not set', () => {
jest.spyOn(model, 'getCaret').mockReturnValue(undefined);

expect(() => {
selectionManager.applyInlineToolForCurrentSelection('bold' as InlineToolName);
selectionManager.applyInlineTool({ toolName: 'bold' as InlineToolName });
}).toThrow();
});

it('should throw when caret index is null', () => {
jest.spyOn(model, 'getCaret').mockReturnValue({ index: null } as unknown as ReturnType<typeof model.getCaret>);

expect(() => {
selectionManager.applyInlineToolForCurrentSelection('bold' as InlineToolName);
selectionManager.applyInlineTool({ toolName: 'bold' as InlineToolName });
}).toThrow();
});

Expand All @@ -234,7 +235,7 @@ describe('SelectionManager', () => {
jest.spyOn(model, 'getCaret').mockReturnValue({ index: indexMock } as unknown as ReturnType<typeof model.getCaret>);

expect(() => {
selectionManager.applyInlineToolForCurrentSelection('bold' as InlineToolName);
selectionManager.applyInlineTool({ toolName: 'bold' as InlineToolName });
Comment on lines 221 to +244
}).toThrow();
});

Expand All @@ -249,8 +250,8 @@ describe('SelectionManager', () => {
(toolsManager as unknown as { inlineTools: Map<unknown, unknown> }).inlineTools = new Map();

expect(() => {
selectionManager.applyInlineToolForCurrentSelection('bold' as InlineToolName);
}).toThrow('SelectionManager[applyInlineToolForCurrentSelection]: tool bold is not attached');
selectionManager.applyInlineTool({ toolName: 'bold' as InlineToolName });
}).toThrow('SelectionManager[applyInlineTool]: tool bold is not attached');
});

it('should call model.format when tool getFormattingOptions returns Format action', () => {
Expand All @@ -269,10 +270,14 @@ describe('SelectionManager', () => {
textRange: [0, 3] }]),
};

jest.spyOn(model, 'getCaret').mockReturnValue({ index: indexMock } as unknown as ReturnType<typeof model.getCaret>);
jest.spyOn(model, 'getCaret')
.mockReturnValue({
index: indexMock,
update: jest.fn(),
} as unknown as ReturnType<typeof model.getCaret>);
jest.spyOn(model, 'getFragments').mockReturnValue([]);

selectionManager.applyInlineToolForCurrentSelection('bold' as InlineToolName);
selectionManager.applyInlineTool({ toolName: 'bold' as InlineToolName });

expect(mockFormat).toHaveBeenCalled();
});
Expand All @@ -293,10 +298,14 @@ describe('SelectionManager', () => {
textRange: [0, 3] }]),
};

jest.spyOn(model, 'getCaret').mockReturnValue({ index: indexMock } as unknown as ReturnType<typeof model.getCaret>);
jest.spyOn(model, 'getCaret')
.mockReturnValue({
index: indexMock,
update: jest.fn(),
} as unknown as ReturnType<typeof model.getCaret>);
jest.spyOn(model, 'getFragments').mockReturnValue([]);

selectionManager.applyInlineToolForCurrentSelection('bold' as InlineToolName);
selectionManager.applyInlineTool({ toolName: 'bold' as InlineToolName });

expect(mockUnformat).toHaveBeenCalled();
});
Expand All @@ -315,7 +324,7 @@ describe('SelectionManager', () => {
jest.spyOn(model, 'getCaret').mockReturnValue({ index: indexMock } as unknown as ReturnType<typeof model.getCaret>);

expect(() => {
selectionManager.applyInlineToolForCurrentSelection('bold' as InlineToolName);
selectionManager.applyInlineTool({ toolName: 'bold' as InlineToolName });
}).toThrow('TextRange of the index should be defined');
});

Expand All @@ -333,7 +342,7 @@ describe('SelectionManager', () => {
jest.spyOn(model, 'getCaret').mockReturnValue({ index: indexMock } as unknown as ReturnType<typeof model.getCaret>);

expect(() => {
selectionManager.applyInlineToolForCurrentSelection('bold' as InlineToolName);
selectionManager.applyInlineTool({ toolName: 'bold' as InlineToolName });
}).toThrow('BlockIndex should be defined');
});

Expand All @@ -351,7 +360,7 @@ describe('SelectionManager', () => {
jest.spyOn(model, 'getCaret').mockReturnValue({ index: indexMock } as unknown as ReturnType<typeof model.getCaret>);

expect(() => {
selectionManager.applyInlineToolForCurrentSelection('bold' as InlineToolName);
selectionManager.applyInlineTool({ toolName: 'bold' as InlineToolName });
}).toThrow('DataKey of the index should be defined');
});
});
Expand Down
80 changes: 59 additions & 21 deletions packages/core/src/components/SelectionManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,11 @@ import 'reflect-metadata';
import {
CaretManagerEvents,
createInlineToolData,
FormattingAction,
FormattingAction, IndexBuilder,
InlineFragment,
InlineToolName
} from '@editorjs/model';
import { CaretManagerCaretUpdatedEvent, Index, EditorJSModel, createInlineToolName } from '@editorjs/model';
import { CaretManagerCaretUpdatedEvent, Index, EditorJSModel } from '@editorjs/model';
import { EventType } from '@editorjs/model';
import {
EventBus,
Expand Down Expand Up @@ -94,11 +94,10 @@ export class SelectionManager {
/**
* @todo implement filter by current BlockTool configuration
*/
availableInlineTools: new Map(
availableInlineTools: Array.from(
this.#toolsManager
.inlineTools
.entries()
.map(([name, facade]) => [createInlineToolName(name), facade.create()])
.values()
),
fragments,
}));
Expand All @@ -109,30 +108,53 @@ export class SelectionManager {
}

/**
* Apply format with data formed in toolbar
* @param toolName - name of the inline tool, whose format would be applied
* @param data - fragment data for the current selection
* Returns index of current user's caret (selection) or null
*/
public applyInlineToolForCurrentSelection(toolName: InlineToolName, data: InlineToolFormatData = {}): void {
/**
* @todo use inline tool data formed in toolbar
*/
public get currentSelection(): Readonly<Index> | null {
const userCaret = this.#model.getCaret(this.#config.userId);

const index = userCaret?.index ?? null;
return userCaret?.index ?? null;
}

if (index === null) {
throw new IndexError('SelectionManager[applyInlineToolForCurrentSelection]: caret index is outside of the input');
/**
* Apply format with data formed in toolbar
* @param params - method parameters, see comments to the param types
*/
public applyInlineTool({
toolName,
data = {},
userId = this.#config.userId,
caretIndex = this.currentSelection,
keepSelection = true,
action: actionOverride,
}: {
/** Name of the inline tool to apply */
toolName: InlineToolName;
/** Inline tool formatting data */
data?: InlineToolFormatData;
/** ID of the user applying the change */
userId?: string | number;
/** Caret index to apply formatting for */
caretIndex?: Readonly<Index> | null;
/** Optional action override for formatting/unformatting */
action?: FormattingAction;
/** If true, Manager will restore the selection after applying the tool. True by defulat */
keepSelection?: boolean;
}): void {
if (caretIndex === null) {
throw new IndexError('SelectionManager[applyInlineTool]: caret index is outside of the input');
}

const caret = this.#model.getCaret(userId);

/**
* @todo do not store middle segments in the index, use only the first and last segments
* Also, we need to sort inputs inside first/last block by document order to restore selection
*/
const segments = index.getTextSegments();
const segments = caretIndex.getTextSegments();

if (segments.length === 0) {
throw new IndexError('SelectionManager[applyInlineToolForCurrentSelection]: caret index is outside of the input');
throw new IndexError('SelectionManager[applyInlineTool]: caret index is outside of the input');
}

const tool = this.#toolsManager.inlineTools.get(toolName)?.create();
Expand All @@ -141,7 +163,7 @@ export class SelectionManager {
* @todo think of config synchronisation. If remote user has some tools current user doesn't there's going to be mismatch in the data
*/
if (tool === undefined) {
throw new Error(`SelectionManager[applyInlineToolForCurrentSelection]: tool ${toolName} is not attached`);
throw new Error(`SelectionManager[applyInlineTool]: tool ${toolName} is not attached`);
}

for (const segment of segments) {
Expand All @@ -165,16 +187,32 @@ export class SelectionManager {

const { action, range } = tool.getFormattingOptions(textRange, fragments);

switch (action) {
switch (actionOverride ?? action) {
case FormattingAction.Format:
this.#model.format(this.#config.userId, blockIndex, dataKey, toolName, ...range, createInlineToolData(data));
this.#model.format(userId, blockIndex, dataKey, toolName, ...range, createInlineToolData(data));

break;
case FormattingAction.Unformat:
this.#model.unformat(this.#config.userId, blockIndex, dataKey, toolName, ...range);
this.#model.unformat(userId, blockIndex, dataKey, toolName, ...range);

break;
}

/**
* Keep selection param is applied only for the current user
*/
if (userId === this.#config.userId) {
if (keepSelection) {
caret?.update(caretIndex);
} else {
caret?.update(
new IndexBuilder()
.from(caretIndex)
.addTextRange([caretIndex.textRange![1], caretIndex.textRange![1]])
.build()
);
}
Comment on lines +207 to +222
}
}
};
}
Loading
Loading