diff --git a/packages/core/src/api/SelectionAPI.ts b/packages/core/src/api/SelectionAPI.ts index 4ea18455..5e2b323a 100644 --- a/packages/core/src/api/SelectionAPI.ts +++ b/packages/core/src/api/SelectionAPI.ts @@ -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'; /** @@ -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(); + } } diff --git a/packages/core/src/components/SelectionManager.ts b/packages/core/src/components/SelectionManager.ts index 354c6540..4c11f87b 100644 --- a/packages/core/src/components/SelectionManager.ts +++ b/packages/core/src/components/SelectionManager.ts @@ -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, @@ -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 []; + } } diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index be421223..439d835a 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -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 */ @@ -115,6 +117,7 @@ export default class Core { this.use(ShortcutsPlugin); this.use(CollaborationManager); this.use(DOMAdapters); + this.use(ClipboardPlugin); } /** diff --git a/packages/core/src/plugins/ClipboardPlugin.spec.ts b/packages/core/src/plugins/ClipboardPlugin.spec.ts new file mode 100644 index 00000000..361c0d2c --- /dev/null +++ b/packages/core/src/plugins/ClipboardPlugin.spec.ts @@ -0,0 +1,168 @@ +/* eslint-disable jsdoc/require-jsdoc, @typescript-eslint/naming-convention */ +import { jest } from '@jest/globals'; +import type { CoreConfigValidated, EditorjsPluginParams } from '@editorjs/sdk'; + +type Listener = (e: Event) => void; + +jest.unstable_mockModule('@editorjs/sdk', () => { + return { + CopyUIEventName: 'copy', + PluginType: { Plugin: 'Plugin' }, + EventBus: jest.fn().mockImplementation(() => { + const listeners = new Map(); + + return { + addEventListener: jest.fn((event: string, fn: Listener) => listeners.set(event, fn)), + removeEventListener: jest.fn((event: string) => listeners.delete(event)), + __fire: (event: string, evt: Event) => listeners.get(event)?.(evt), + }; + }), + }; +}); + +jest.unstable_mockModule('../api', () => ({ + EditorAPI: jest.fn().mockImplementation(() => ({ + selection: { selectedBlocks: [] as unknown[] }, + })), +})); + +const { EventBus, CopyUIEventName } = await import('@editorjs/sdk'); +const { EditorAPI } = await import('../api'); +const { ClipboardPlugin } = await import('./ClipboardPlugin.js'); + +type FireableEventBus = InstanceType & { __fire: (event: string, evt: Event) => void }; + +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).toHaveBeenCalledWith(`ui:${CopyUIEventName}`, expect.any(Function)); + }); + }); + + describe('.destroy()', () => { + it('should remove CopyUIEvent listener', () => { + const { eventBus } = pluginParamsMock; + const cp = new ClipboardPlugin(pluginParamsMock); + + cp.destroy(); + + expect(eventBus.removeEventListener).toHaveBeenCalledWith(`ui:${CopyUIEventName}`, expect.any(Function)); + }); + }); + + describe('CopyUIEvent listener', () => { + function dispatchCopyEvent(): { preventDefault: jest.Mock; + setData: jest.Mock; } { + 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; + + eventBus.__fire(`ui:${CopyUIEventName}`, { detail: { nativeEvent } } as unknown as Event); + + return { preventDefault, + setData }; + } + + function setSelectedBlocks(blocks: unknown[]): void { + (pluginParamsMock.api.selection as { selectedBlocks: unknown[] }).selectedBlocks = blocks; + } + + // Stubs the DOM globals the plugin reads at runtime (test env has no jsdom). + function mockDOMSelection(plainText: string, html: string): () => void { + const selection = { + rangeCount: 1, + toString: () => plainText, + getRangeAt: () => ({ cloneContents: () => ({}) }), + } as unknown as Selection; + const template = { content: { appendChild: (): void => undefined }, + innerHTML: html } as unknown as HTMLTemplateElement; + + globalThis.document = { createElement: () => template } as unknown as Document; + globalThis.window = { getSelection: () => selection } as unknown as Window & typeof globalThis; + + return (): void => { + delete (globalThis as { document?: Document }).document; + delete (globalThis as { window?: Window }).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' }, + ]); + restoreDOMMocks = mockDOMSelection('hello', '

hello

'); + }); + + afterEach(() => restoreDOMMocks()); + + it('should add current selection as text to native event', () => { + new ClipboardPlugin(pluginParamsMock); + + const { setData } = dispatchCopyEvent(); + + expect(setData).toHaveBeenCalledWith('text/plain', 'hello'); + }); + + it('should add current selection as html to native event', () => { + new ClipboardPlugin(pluginParamsMock); + + const { setData } = dispatchCopyEvent(); + + expect(setData).toHaveBeenCalledWith('text/html', '

hello

'); + }); + + it('should add custom editorjs data-type to native event', () => { + 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' }, + }) + ); + }); + }); + }); +}); diff --git a/packages/core/src/plugins/ClipboardPlugin.ts b/packages/core/src/plugins/ClipboardPlugin.ts new file mode 100644 index 00000000..075bf79f --- /dev/null +++ b/packages/core/src/plugins/ClipboardPlugin.ts @@ -0,0 +1,140 @@ +import type { BlockData, CopyUIEvent, EditorAPI, EditorjsPlugin, EditorjsPluginParams, EventBus } from '@editorjs/sdk'; +import { CopyUIEventName } from '@editorjs/sdk'; +import { PluginType } from '@editorjs/sdk'; + +/** + * Clipboard plugin handles copy events and provides rich clipboard data for selected blocks. + * When a user copies content, this plugin intercepts the event and adds EditorJS-specific data + * including the selected blocks metadata alongside plain text and HTML content. + */ +export class ClipboardPlugin implements EditorjsPlugin { + public static readonly type = PluginType.Plugin; + + readonly #api: EditorAPI; + readonly #eventBus: EventBus; + #copyEventListener: ((e: CopyUIEvent) => void) | undefined; + + /** + * @param params - plugin configuration and dependencies + * @param params.config - EditorJS configuration + * @param params.api - EditorAPI instance for block and selection access + * @param params.eventBus - EventBus for event subscriptions + */ + constructor(params: EditorjsPluginParams) { + const { api, eventBus } = params; + + this.#api = api; + this.#eventBus = eventBus; + + this.#copyEventListener = (e: CopyUIEvent) => { + const { nativeEvent } = e.detail; + + const selectedBlocks = this.#api.selection.selectedBlocks; + + /** + * Don't override native event if there are no blocks selected + */ + if (selectedBlocks.length === 0) { + return; + } + + const currentDOMSelection = window.getSelection(); + + if (!currentDOMSelection) { + return; + } + + const selectionAsPlainText = currentDOMSelection.toString(); + const selectionAsHTML = this.#parseDOMSelectionToHTML(currentDOMSelection); + const clipboardEditorJSObject = this.#createClipboardObject(selectedBlocks); + + nativeEvent.preventDefault(); + + nativeEvent.clipboardData?.setData('text/plain', selectionAsPlainText); + nativeEvent.clipboardData?.setData('text/html', selectionAsHTML); + nativeEvent.clipboardData?.setData('application/x-editor-js', JSON.stringify(clipboardEditorJSObject)); + }; + + eventBus.addEventListener(`ui:${CopyUIEventName}`, this.#copyEventListener); + } + + /** + * Destroys the plugin and removes all event listeners + */ + public destroy(): void { + this.#removeEventListener(); + } + + /** + * Removes the event listener for copy events + * @internal + */ + #removeEventListener(): void { + if (this.#copyEventListener !== undefined) { + this.#eventBus.removeEventListener(`ui:${CopyUIEventName}`, this.#copyEventListener); + } + this.#copyEventListener = undefined; + } + + /** + * Parses DOM selection to HTML string + * @internal + * @param selection - DOM selection to parse + * @returns HTML string representation of the selection + */ + #parseDOMSelectionToHTML(selection: Selection): string { + if (selection.rangeCount === 0) { + return ''; + } + + const template = document.createElement('template'); + + for (let i = 0; i < selection.rangeCount; i++) { + const range = selection.getRangeAt(i); + + template.content.appendChild(range.cloneContents()); + } + + return template.innerHTML; + } + + /** + * Creates representation of copied from EditorJS data as an object with metadata + * @internal + * @param blocks - content to create a clipboard object + */ + #createClipboardObject(blocks: BlockData[] = []): ClipboardEditorJSObject { + return { + blocks, + meta: { + // @todo get version info from Core + version: '3.0.0', + }, + }; + } +} + +/** + * Custom EditorJS data-type object for clipboard events + */ +interface ClipboardEditorJSObject { + /** + * Array of copied blocks data + */ + blocks: BlockData[]; + + /** + * Metadata from EditorJS + */ + meta: Meta; +} + +/** + * Metadata from EditorJS + */ +interface Meta { + /** + * Version of EditorJS Core + */ + version: string; +} diff --git a/packages/model-types/src/Index/index.ts b/packages/model-types/src/Index/index.ts index 0eaae773..18ea697b 100644 --- a/packages/model-types/src/Index/index.ts +++ b/packages/model-types/src/Index/index.ts @@ -285,4 +285,12 @@ export class Index { public get isDataIndex(): boolean { return this.blockIndex !== undefined && this.tuneName === undefined && this.dataKey !== undefined && this.textRange === undefined; } + + /** + * Returns true if index points to a composite index + */ + public get isCompositeIndex(): boolean { + /* Stryker disable next-line ConditionalExpression, LogicalOperator -- compound composite-index predicate; .isCompositeIndex specs cover field combinations */ + return this.compositeSegments !== undefined && this.compositeSegments.length > 0 && this.blockIndex === undefined && this.tuneName === undefined && this.dataKey === undefined && this.textRange === undefined; + } } diff --git a/packages/sdk/src/api/SelectionAPI.ts b/packages/sdk/src/api/SelectionAPI.ts index bdba7313..6585d2dc 100644 --- a/packages/sdk/src/api/SelectionAPI.ts +++ b/packages/sdk/src/api/SelectionAPI.ts @@ -1,4 +1,4 @@ -import type { Caret, CaretManagerEvents, FormattingAction, Index } from '@editorjs/model-types'; +import type { BlockNodeSerialized, Caret, CaretManagerEvents, FormattingAction, Index } from '@editorjs/model-types'; /** * Selection API interface @@ -63,4 +63,9 @@ export interface SelectionAPI { * Current caret index */ caretIndex: Index | null; + + /** + * Returns array of selected blocks + */ + get selectedBlocks(): BlockNodeSerialized[]; } diff --git a/packages/sdk/src/entities/EventBus/events/ui/CopyUIEvent.ts b/packages/sdk/src/entities/EventBus/events/ui/CopyUIEvent.ts new file mode 100644 index 00000000..abf86fdf --- /dev/null +++ b/packages/sdk/src/entities/EventBus/events/ui/CopyUIEvent.ts @@ -0,0 +1,30 @@ +import { UIEventBase } from './UIEventBase.js'; + +/** + * Name of the copy UI event (dispatched as `ui:copy`) + */ +export const CopyUIEventName = 'copy'; + +/** + * Payload of CopyUIEvent + * Contains a native clipboard event + */ +export interface CopyUIEventPayload { + /** + * Native ClipboardEvent + * UI does not call .preventDefault() for this event + */ + nativeEvent: ClipboardEvent; +} + +/** + * Delegated copy event from the editor block holder + */ +export class CopyUIEvent extends UIEventBase { + /** + * @param payload - carries the original DOM `ClipboardEvent` as `nativeEvent` for providing rich clipboard data + */ + constructor(payload: CopyUIEventPayload) { + super(CopyUIEventName, payload); + } +} diff --git a/packages/sdk/src/entities/EventBus/events/ui/index.ts b/packages/sdk/src/entities/EventBus/events/ui/index.ts index 69856918..c4770540 100644 --- a/packages/sdk/src/entities/EventBus/events/ui/index.ts +++ b/packages/sdk/src/entities/EventBus/events/ui/index.ts @@ -1,3 +1,4 @@ export * from './UIEventBase.js'; export * from './BeforeInputUIEvent.js'; export * from './KeydownUIEvent.js'; +export * from './CopyUIEvent.js'; diff --git a/packages/ui/src/Blocks/Blocks.ts b/packages/ui/src/Blocks/Blocks.ts index 7e6313ea..66f3037d 100644 --- a/packages/ui/src/Blocks/Blocks.ts +++ b/packages/ui/src/Blocks/Blocks.ts @@ -1,15 +1,14 @@ -import type { +import type { EventBus, BlockAddedCoreEvent, - BlockRemovedCoreEvent, EditorAPI, + BlockRemovedCoreEvent, CopyUIEventPayload, EditorjsPlugin, - EditorjsPluginParams -} from '@editorjs/sdk'; + EditorjsPluginParams, EditorAPI } from '@editorjs/sdk'; import { CoreEventType, + CopyUIEvent, UiComponentType, BeforeInputUIEvent } from '@editorjs/sdk'; -import type { EventBus } from '@editorjs/sdk'; import Style from './Blocks.module.pcss'; import { isNativeInput, make } from '@editorjs/dom'; import { BlocksHolderRenderedUIEvent, BlockSelectedUIEvent } from './events/index.js'; @@ -138,6 +137,14 @@ export class BlocksUI implements EditorjsPlugin { e.preventDefault(); }); + blocksHolder.addEventListener('copy', (e) => { + const payload: CopyUIEventPayload = { + nativeEvent: e, + }; + + this.#eventBus.dispatchEvent(new CopyUIEvent(payload)); + }); + return blocksHolder; }