Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
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
34 changes: 34 additions & 0 deletions packages/collaboration-manager/src/BatchedOperation.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -167,5 +167,39 @@ describe('BatchedOperation', () => {

expect(batch.canAdd(op2)).toBe(false);
});

it('should return true for consecutive backspace Delete operations', () => {
// Backspace: position decrements each time – [3,3], [2,2]
/* eslint-disable @typescript-eslint/no-magic-numbers */
const op1 = new Operation(OperationType.Delete, createIndexByRange([3, 3]), { payload: 'b' }, userId);
const op2 = new Operation(OperationType.Delete, createIndexByRange([2, 2]), { payload: 'a' }, userId);
/* eslint-enable @typescript-eslint/no-magic-numbers */

const batch = new BatchedOperation(op1);

expect(batch.canAdd(op2)).toBe(true);
});

it('should return true for consecutive forward Delete operations', () => {
// Forward delete: position stays the same after each deletion – [0,0], [0,0]
const op1 = new Operation(OperationType.Delete, templateIndex, { payload: 'a' }, userId);
const op2 = new Operation(OperationType.Delete, templateIndex, { payload: 'b' }, userId);

const batch = new BatchedOperation(op1);

expect(batch.canAdd(op2)).toBe(true);
});

it('should return false for non-consecutive Delete operations', () => {
// Gap of 2 – should not be batched
/* eslint-disable @typescript-eslint/no-magic-numbers */
const op1 = new Operation(OperationType.Delete, createIndexByRange([3, 3]), { payload: 'c' }, userId);
const op2 = new Operation(OperationType.Delete, createIndexByRange([1, 1]), { payload: 'a' }, userId);
/* eslint-enable @typescript-eslint/no-magic-numbers */

const batch = new BatchedOperation(op1);

expect(batch.canAdd(op2)).toBe(false);
});
});
});
16 changes: 15 additions & 1 deletion packages/collaboration-manager/src/BatchedOperation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,20 @@ export class BatchedOperation<T extends OperationType = OperationType> extends O
return false;
}

return op.index.textRange![0] === lastOp.index.textRange![1] + 1;
if (op.type === OperationType.Insert) {
/**
* For Insert operations, each character is appended sequentially:
* [0,0], [1,1], [2,2] ...
*/
return op.index.textRange![0] === lastOp.index.textRange![1] + 1;
}

/**
* For Delete operations two consecutive patterns are allowed:
* - Backspace: each deletion decrements the position: [3,3], [2,2], [1,1] ...
* - Forward delete: the position stays the same after each deletion: [0,0], [0,0] ...
*/
return op.index.textRange![0] === lastOp.index.textRange![0] - 1
|| op.index.textRange![0] === lastOp.index.textRange![0];
}
}
18 changes: 13 additions & 5 deletions packages/collaboration-manager/src/CollaborationManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,15 @@
TextFormattedEvent, TextRemovedEvent,
TextUnformattedEvent
} from '@editorjs/model';
import type {
UndoCoreEvent,
EditorAPI,
EditorjsPlugin,
EditorjsPluginParams,
RedoCoreEvent
} from '@editorjs/sdk';
import {
CoreEventType,
type EditorAPI,
type EditorjsPlugin,
type EditorjsPluginParams,
PluginType
} from '@editorjs/sdk';
import { OTClient } from './client/index.js';
Expand Down Expand Up @@ -97,10 +101,14 @@
this.#config = config;
this.#undoRedoManager = new UndoRedoManager();

const onUndo = (): void => {
const onUndo = (e: UndoCoreEvent): void => {
e.preventDefault();

this.undo();
};
const onRedo = (): void => {
const onRedo = (e: RedoCoreEvent): void => {

Check warning on line 109 in packages/collaboration-manager/src/CollaborationManager.ts

View workflow job for this annotation

GitHub Actions / Coverage annotations (🧪 jest-coverage-report-action)

🕹️ Function is not covered

Warning! Not covered function
e.preventDefault();

this.redo();
};
const onReady = (): void => {
Expand Down
13 changes: 7 additions & 6 deletions packages/collaboration-manager/test/mocks/createManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import type { EditorDocumentSerialized, ModelEvents } from '@editorjs/model';
import { EventType } from '@editorjs/model';
import type { EditorJSModel } from '@editorjs/model';
import { EventBus } from '@editorjs/sdk';
import type { CoreConfigValidated, DocumentAPI, EditorAPI, InsertRemoveDataParams, ModifyDataParams } from '@editorjs/sdk';
import type { CoreConfigValidated, DocumentAPI, EditorAPI, InsertRemoveDataParams, ModifyDataParams, BlocksAPI } from '@editorjs/sdk';
import { CollaborationManager } from '../../src/CollaborationManager.js';

/**
Expand All @@ -28,7 +28,7 @@ function createMockDocumentAPI(model: EditorJSModel): DocumentAPI {
modifyData({ userId, index, data }: ModifyDataParams): void {
model.modifyData(userId, index, data);
},
};
} as DocumentAPI;
}

/**
Expand All @@ -37,16 +37,17 @@ function createMockDocumentAPI(model: EditorJSModel): DocumentAPI {
* @param model - the EditorJS model instance
* @returns an object containing the manager and the eventBus used
*/
export function createManager(config: CoreConfigValidated, model: EditorJSModel): { manager: CollaborationManager;
eventBus: EventBus; } {
export function createManager(config: CoreConfigValidated, model: EditorJSModel): {
manager: CollaborationManager;
eventBus: EventBus;
} {
const eventBus = new EventBus();

const api: EditorAPI = {
document: createMockDocumentAPI(model),
// eslint-disable-next-line @typescript-eslint/no-explicit-any
blocks: {
render: () => undefined,
} as any,
} as unknown as BlocksAPI,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
selection: {} as any,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
Expand Down
36 changes: 34 additions & 2 deletions packages/core/src/api/DocumentAPI/DocumentAPI.spec.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,16 @@
/* eslint-disable @typescript-eslint/naming-convention */
import { beforeEach, describe, expect, jest } from '@jest/globals';
import type { CoreConfigValidated } from '@editorjs/sdk';
import type { CoreConfigValidated, EventBus } from '@editorjs/sdk';

jest.unstable_mockModule('@editorjs/sdk', () => ({
UndoCoreEvent: class UndoCoreEvent {
public name = 'undo';
},
RedoCoreEvent: class RedoCoreEvent {
public name = 'redo';
},
EventBus: jest.fn(),
}));

jest.unstable_mockModule('@editorjs/model', () => {
const EditorJSModel = jest.fn(() => ({
Expand All @@ -22,7 +32,13 @@ describe('DocumentAPI', () => {
// @ts-expect-error - mock object, don't need to pass any arguments
const model = new EditorJSModel();

const documentAPI = new DocumentAPI(model, {} as unknown as CoreConfigValidated);
const dispatchEvent = jest.fn();

const documentAPI = new DocumentAPI(
model,
{} as unknown as CoreConfigValidated,
{ dispatchEvent } as unknown as EventBus
);

beforeEach(() => {
jest.resetAllMocks();
Expand Down Expand Up @@ -52,4 +68,20 @@ describe('DocumentAPI', () => {
expect(data).toEqual(mockedSerializedModel);
});
});

describe('.undo()', () => {
it('should dispatch an undo core event', () => {
documentAPI.undo();

expect(dispatchEvent).toBeCalledWith(expect.objectContaining({ name: 'undo' }));
});
});

describe('.redo()', () => {
it('should dispatch an redo core event', () => {
documentAPI.redo();

expect(dispatchEvent).toBeCalledWith(expect.objectContaining({ name: 'redo' }));
});
});
});
36 changes: 33 additions & 3 deletions packages/core/src/api/DocumentAPI/DocumentAPI.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,9 @@ import 'reflect-metadata';
import { type EditorDocumentSerialized, EditorJSModel, EventType, type ModelEvents } from '@editorjs/model';
import {
CoreConfigValidated,
DocumentAPI as DocumentApiInterface,
DocumentAPI as DocumentApiInterface, EventBus,
type InsertRemoveDataParams,
type ModifyDataParams
type ModifyDataParams, RedoCoreEvent, UndoCoreEvent
} from '@editorjs/sdk';
import { inject, injectable } from 'inversify';
import { TOKENS } from '../../tokens.js';
Expand All @@ -26,18 +26,26 @@ export class DocumentAPI implements DocumentApiInterface {
*/
#config: CoreConfigValidated;

/**
* Editor's event bus instance
*/
#eventBus: EventBus;

/**
* DocumentAPI constructor
* All parameters are injected through the IoC container
* @param model - Editor's Document Model instance
* @param config - Editor's config
* @param eventBus - Editor's event bus instance
*/
constructor(
model: EditorJSModel,
@inject(TOKENS.EditorConfig) config: CoreConfigValidated
@inject(TOKENS.EditorConfig) config: CoreConfigValidated,
eventBus: EventBus
) {
this.#model = model;
this.#config = config;
this.#eventBus = eventBus;
}

/**
Expand Down Expand Up @@ -91,4 +99,26 @@ export class DocumentAPI implements DocumentApiInterface {
public modifyData({ userId = this.#config.userId, index, data }: ModifyDataParams): void {
this.#model.modifyData(userId, index, data);
}

/**
* Undoes the last change in the document
*/
public undo(): void {
/**
* To enable Plugins to cancel the default undo/redo behavior,
* we have to dispatch event here instead of a direct call
*/
this.#eventBus.dispatchEvent(new UndoCoreEvent());
}

/**
* Redoes the last undone change in the document
*/
public redo(): void {
/**
* To enable Plugins to cancel the default undo/redo behavior,
* we have to dispatch event here instead of a direct call
*/
this.#eventBus.dispatchEvent(new RedoCoreEvent());
}
Comment on lines +106 to +123
}
Loading
Loading