Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
9dd6f8e
feat(eventbus): create CopyUIEvent entity
ilyamore88 Apr 17, 2026
b53c482
feat(ui-blocks): subscribe for copy event and pass native event objec…
ilyamore88 Apr 17, 2026
0be2ca4
feat(core): init clipboard plugin
ilyamore88 Apr 17, 2026
10c1c22
feat(core): use ClipboardPlugin
ilyamore88 Apr 17, 2026
ad5b441
Merge branch 'main' of github.com:editor-js/document-model into feat/…
ilyamore88 Apr 17, 2026
e1359c8
Merge branch 'main' of github.com:editor-js/document-model into feat/…
ilyamore88 Apr 23, 2026
500178d
Merge branch 'main' of github.com:editor-js/document-model into feat/…
ilyamore88 May 24, 2026
bc3657c
feat: implement clipboard plugin
ilyamore88 May 24, 2026
00d10ac
fix(clipboard-plugin): prevent native event only after checking selec…
ilyamore88 Jun 9, 2026
d426ae4
chore: fix stryker exception description
ilyamore88 Jun 9, 2026
31a5758
Merge branch 'main' of github.com:editor-js/document-model into feat/…
ilyamore88 Jun 9, 2026
9f44277
feat(clipboard-plugin): update docs, add destroy method implementatio…
ilyamore88 Jul 7, 2026
f63d6dd
Merge branch 'main' of github.com:editor-js/document-model into feat/…
ilyamore88 Jul 7, 2026
55b62c8
chore: use currentSelection in selection manager
ilyamore88 Jul 7, 2026
2681e5b
feat(clipboard-plugin): describe clipboard object
ilyamore88 Jul 8, 2026
2b288dc
refactor(selection-api): return empty array instead of null
ilyamore88 Jul 8, 2026
f2a6067
chore: add docs
ilyamore88 Jul 8, 2026
264f721
chore: update docs
ilyamore88 Jul 8, 2026
f4601a6
fix: make removeEventListener js-private
ilyamore88 Jul 8, 2026
ec4aca5
chore(clipboard-plugin): add unit-tests
ilyamore88 Jul 9, 2026
c144d28
Merge branch 'main' of github.com:editor-js/document-model into feat/…
ilyamore88 Jul 15, 2026
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
9 changes: 8 additions & 1 deletion packages/core/src/api/SelectionAPI.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { inject, injectable } from 'inversify';

import { SelectionManager } from '../components/SelectionManager.js';
import { Caret, EditorJSModel } from '@editorjs/model';
import { CaretManagerEvents, CoreConfigValidated, createInlineToolName, EventType, Index, SelectionAPI as SelectionApiInterface } from '@editorjs/sdk';
import { CaretManagerEvents, CoreConfigValidated, createInlineToolName, EventType, Index, SelectionAPI as SelectionApiInterface, BlockNodeSerialized } from '@editorjs/sdk';
import { TOKENS } from '../tokens.js';

/**
Expand Down Expand Up @@ -88,4 +88,11 @@ export class SelectionAPI implements SelectionApiInterface {
public getCaret(userId = this.#config.userId): Caret | undefined {
return this.#model.getCaret(userId);
}

/**
* Returns array of selected blocks
*/
public get selectedBlocks(): BlockNodeSerialized[] {
return this.#selectionManager.selectedBlocks();
}
}
29 changes: 28 additions & 1 deletion packages/core/src/components/SelectionManager.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import 'reflect-metadata';
import { EditorJSModel } from '@editorjs/model';
import type { InlineFragment } from '@editorjs/sdk';
import type { BlockNodeSerialized, InlineFragment } from '@editorjs/sdk';
import {
CaretManagerCaretUpdatedEvent,
CaretManagerEvents,
Expand Down Expand Up @@ -226,4 +226,31 @@ export class SelectionManager {
}
}
};

/**
* Returns an array of selected blocks using the current selection index
*/
public selectedBlocks(): BlockNodeSerialized[] {
const currentSelectionIndex = this.currentSelection;

if (currentSelectionIndex === null) {
return [];
}

if (currentSelectionIndex.isBlockIndex) {
const { blockIndex } = currentSelectionIndex;

return [this.#model.serialized.blocks[blockIndex!]];
}

if (currentSelectionIndex.compositeSegments !== undefined) {
return currentSelectionIndex.compositeSegments.map((segment) => {
const { blockIndex } = segment;

return this.#model.serialized.blocks[blockIndex!];
});
}

return [];
}
}
3 changes: 3 additions & 0 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@ import { BlockRenderer } from './components/BlockRenderer.js';
import { SelectionManager } from './components/SelectionManager.js';
import { TOKENS } from './tokens.js';
import { UndoRedoManager } from './components/UndoRedoManager.js';
import { ClipboardPlugin } from './plugins/ClipboardPlugin.js';

/**
* If no holder is provided via config, the editor will be appended to the element with this id
*/
Expand Down Expand Up @@ -115,6 +117,7 @@ export default class Core {
this.use(ShortcutsPlugin);
this.use(CollaborationManager);
this.use(DOMAdapters);
this.use(ClipboardPlugin);
}

/**
Expand Down
321 changes: 321 additions & 0 deletions packages/core/src/plugins/ClipboardPlugin.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,321 @@
/* eslint-disable @typescript-eslint/naming-convention */
import { jest } from '@jest/globals';
import { ClipboardPlugin } from './ClipboardPlugin';
import type { CoreConfigValidated, EditorjsPluginParams } from '@editorjs/sdk';
import { EventBus } from '@editorjs/sdk';
import { CopyUIEventName } from '@editorjs/sdk';
import { EditorAPI } from '../api';

jest.mock('@editorjs/sdk', () => {
const originalModule = jest.requireActual('@editorjs/sdk');

return {
// @ts-expect-error - jest.requireActual returns object
...originalModule,
EventBus: jest.fn().mockImplementation(() => {
const listeners = new Map<string, EventListener>();

return {
addEventListener: jest.fn((event: string, fn: EventListener) => {
listeners.set(event, fn);
}),
removeEventListener: jest.fn((event: string) => {
listeners.delete(event);
}),
/**
* Test helper: dispatch a previously-registered event
* @param event - event name to fire
* @param evt - event payload
*/
__fire: (event: string, evt: Event) => listeners.get(event)?.(evt),
};
}),
};
});

jest.mock('../api', () => {
return {
EditorAPI: jest.fn().mockImplementation(() => ({
selection: {
selectedBlocks: [] as unknown[],
},
})),
};
});

describe('ClipboardPlugin', () => {
let pluginParamsMock: EditorjsPluginParams;

beforeEach(() => {
pluginParamsMock = {
eventBus: new EventBus(),
api: new EditorAPI(),
config: {} as CoreConfigValidated,
};
});

describe('constructor()', () => {
it('should add CopyUIEvent listener', () => {
const { eventBus } = pluginParamsMock;

new ClipboardPlugin(pluginParamsMock);

expect(eventBus.addEventListener).toHaveBeenCalled();
});
});

describe('.destroy()', () => {
it('should remove CopyUIEvent listener', () => {
const { eventBus } = pluginParamsMock;
const cp = new ClipboardPlugin(pluginParamsMock);

cp.destroy();

expect(eventBus.removeEventListener).toHaveBeenCalled();
});
});

describe('CopyUIEvent listener', () => {
/**
* Shape of `document` exposed to the plugin under test.
*/
interface DocumentStub {
/**
* Creates a fake element; only `'template'` is exercised by the plugin.
*/
createElement: (tag: string) => unknown;
}

/**
* Shape of `window` exposed to the plugin under test.
*/
interface WindowStub {
/**
* Returns a fake `Selection` matching the `MockDOMSelectionOptions` shape.
*/
getSelection: () => unknown;
}

/**
* `globalThis` augmented with the DOM globals the plugin reads at runtime.
* Used to stub `window.getSelection` / `document.createElement` under the
* `node` Jest test environment.
*/
interface GlobalThisWithDOM {
/**
* `document` global; only present under DOM-capable test environments.
*/
document?: DocumentStub;
/**
* `window` global; only present under DOM-capable test environments.
*/
window?: WindowStub;
}

// eslint-disable-next-line jsdoc/require-jsdoc
type FireableEventBus = EventBus & { __fire: (event: string, evt: Event) => void };

/**
* Spies returned by `dispatchCopyEvent` for assertions on the native event.
*/
interface DispatchedEventSpies {
/**
* Spy on the native event's `preventDefault` method.
*/
preventDefault: jest.Mock;
/**
* Spy on the native event's `clipboardData.setData` method.
*/
setData: jest.Mock;
}

/**
* Builds a stub `CopyUIEvent`-shaped object with a `preventDefault` spy and a
* `clipboardData.setData` spy, then dispatches it through the EventBus mock.
* @returns spies for `preventDefault` and `clipboardData.setData`
*/
function dispatchCopyEvent(): DispatchedEventSpies {
const eventBus = pluginParamsMock.eventBus as unknown as FireableEventBus;
const preventDefault = jest.fn();
const setData = jest.fn();

const nativeEvent = {
preventDefault,
clipboardData: { setData },
} as unknown as ClipboardEvent;
const uiEvent = { detail: { nativeEvent } } as unknown as Event;

eventBus.__fire(`ui:${CopyUIEventName}`, uiEvent);

return {
preventDefault,
setData,
};
}

// eslint-disable-next-line jsdoc/require-jsdoc
function setSelectedBlocks(blocks: unknown[]): void {
const { api } = pluginParamsMock;

// eslint-disable-next-line jsdoc/require-jsdoc
(api.selection as { selectedBlocks: unknown[] }).selectedBlocks = blocks;
}

/**
* Describes a stubbed DOM selection for the duration of a single test.
*/
interface MockDOMSelectionOptions {
/**
* Value returned by `selection.toString()`.
*/
plainText: string;
/**
* Inner HTML of the resulting `HTMLTemplateElement`.
*/
html: string;
/**
* Number of ranges in the selection. Defaults to `1`.
*/
rangeCount?: number;
}

/**
* Stubs the DOM globals (`window.getSelection`, `document.createElement`) so the
* plugin's DOM-dependent code paths can be exercised under the `node` test env.
* Returns a `restore` callback to undo the mocks in `afterEach`.
* @param options - plain text and HTML the selection should expose
* @returns restore function that reinstates the original globals
*/
function mockDOMSelection(options: MockDOMSelectionOptions): () => void {
const { plainText, html, rangeCount = 1 } = options;

const cloneContents = jest.fn(() => ({}));
const range = { cloneContents };
const selection = {
rangeCount,
toString: jest.fn(() => plainText),
getRangeAt: jest.fn(() => range),
};
const templateContent = {
appendChild: jest.fn(),
};
const template = {
content: templateContent,
get innerHTML(): string {
return html;
},
};
const documentStub = {
createElement: jest.fn((tag: string) => {
if (tag === 'template') {
return template;
}

return undefined;
}),
};
const windowStub = {
getSelection: jest.fn(() => selection),
};
const hadDocument = Object.prototype.hasOwnProperty.call(globalThis, 'document');
const hadWindow = Object.prototype.hasOwnProperty.call(globalThis, 'window');
const g = globalThis as GlobalThisWithDOM;
const originalDocument = g.document;
const originalWindow = g.window;

g.document = documentStub;
g.window = windowStub;

return (): void => {
if (hadDocument) {
g.document = originalDocument;
} else {
delete g.document;
}
if (hadWindow) {
g.window = originalWindow;
} else {
delete g.window;
}
};
}

describe('when no blocks are selected', () => {
beforeEach(() => {
setSelectedBlocks([]);
});

it('should not prevent native event', () => {
new ClipboardPlugin(pluginParamsMock);

const { preventDefault } = dispatchCopyEvent();

expect(preventDefault).not.toHaveBeenCalled();
});
});

describe('when blocks are selected', () => {
let restoreDOMMocks: () => void;

beforeEach(() => {
setSelectedBlocks([
{ id: 'b1',
type: 'paragraph' },
{ id: 'b2',
type: 'header' },
]);
});

afterEach(() => {
if (restoreDOMMocks !== undefined) {
restoreDOMMocks();
restoreDOMMocks = (): void => undefined;
}
});
Comment on lines +258 to +274

it('should add current selection as text to native event', () => {
restoreDOMMocks = mockDOMSelection({ plainText: 'hello',
html: '<p>hello</p>' });

new ClipboardPlugin(pluginParamsMock);

const { setData } = dispatchCopyEvent();

expect(setData).toHaveBeenCalledWith('text/plain', 'hello');
});

it('should add current selection as html to native event', () => {
restoreDOMMocks = mockDOMSelection({ plainText: 'hello',
html: '<p>hello</p>' });

new ClipboardPlugin(pluginParamsMock);

const { setData } = dispatchCopyEvent();

expect(setData).toHaveBeenCalledWith('text/html', '<p>hello</p>');
});

it('should add custom editorjs data-type to native event', () => {
restoreDOMMocks = mockDOMSelection({ plainText: 'hello',
html: '<p>hello</p>' });

new ClipboardPlugin(pluginParamsMock);

const { setData } = dispatchCopyEvent();

expect(setData).toHaveBeenCalledWith(
'application/x-editor-js',
JSON.stringify({
blocks: [
{ id: 'b1',
type: 'paragraph' },
{ id: 'b2',
type: 'header' },
],
meta: { version: '3.0.0' },
})
);
});
});
});
});
Loading
Loading