From 9dd6f8e92f505a381510a9d5042e2fb61224fdec Mon Sep 17 00:00:00 2001 From: Ilya Maroz <37909603+ilyamore88@users.noreply.github.com> Date: Fri, 17 Apr 2026 20:55:33 +0100 Subject: [PATCH 01/15] feat(eventbus): create CopyUIEvent entity --- .../EventBus/events/ui/CopyUIEvent.ts | 28 +++++++++++++++++++ .../src/entities/EventBus/events/ui/index.ts | 1 + 2 files changed, 29 insertions(+) create mode 100644 packages/sdk/src/entities/EventBus/events/ui/CopyUIEvent.ts 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..2472dcfe --- /dev/null +++ b/packages/sdk/src/entities/EventBus/events/ui/CopyUIEvent.ts @@ -0,0 +1,28 @@ +import { UIEventBase } from './UIEventBase.js'; + +/** + * Name of the copy UI event (dispatched as `ui:copy`) + */ +export const CopyUIEventName = 'copy'; + +/** + * Payload @todo update doc + */ +export interface CopyUIEventPayload { + /** + * @todo update doc + */ + nativeEvent: ClipboardEvent; +} + +/** + * Delegated copy event from the editor @todo update doc + */ +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'; From b53c482f26f21768c06b98f8e948579507fddb03 Mon Sep 17 00:00:00 2001 From: Ilya Maroz <37909603+ilyamore88@users.noreply.github.com> Date: Fri, 17 Apr 2026 21:19:53 +0100 Subject: [PATCH 02/15] feat(ui-blocks): subscribe for copy event and pass native event object to eventbus --- packages/ui/src/Blocks/Blocks.ts | 21 ++++++++++++++++----- 1 file changed, 16 insertions(+), 5 deletions(-) diff --git a/packages/ui/src/Blocks/Blocks.ts b/packages/ui/src/Blocks/Blocks.ts index 30df927a..e2c367fb 100644 --- a/packages/ui/src/Blocks/Blocks.ts +++ b/packages/ui/src/Blocks/Blocks.ts @@ -1,13 +1,14 @@ -import type { BlockAddedCoreEvent, - BlockRemovedCoreEvent, - EditorjsPlugin, - EditorjsPluginParams } from '@editorjs/sdk'; +import { CopyUIEvent } from '@editorjs/sdk'; import { CoreEventType, UiComponentType, BeforeInputUIEvent } from '@editorjs/sdk'; -import type { EventBus } from '@editorjs/sdk'; +import type { EventBus, + BlockAddedCoreEvent, + BlockRemovedCoreEvent, CopyUIEventPayload, + EditorjsPlugin, + EditorjsPluginParams } from '@editorjs/sdk'; import Style from './Blocks.module.pcss'; import { isNativeInput, make } from '@editorjs/dom'; import { BlocksHolderRenderedUIEvent, BlockSelectedUIEvent } from './events/index.js'; @@ -130,6 +131,16 @@ export class BlocksUI implements EditorjsPlugin { e.preventDefault(); }); + blocksHolder.addEventListener('copy', (e) => { + const payload: CopyUIEventPayload = { + nativeEvent: e, + }; + + this.#eventBus.dispatchEvent(new CopyUIEvent(payload)); + + e.preventDefault(); + }); + return blocksHolder; } From 0be2ca499b3ec69198381072431cd934d10db3a2 Mon Sep 17 00:00:00 2001 From: Ilya Maroz <37909603+ilyamore88@users.noreply.github.com> Date: Fri, 17 Apr 2026 21:22:22 +0100 Subject: [PATCH 03/15] feat(core): init clipboard plugin --- packages/core/src/plugins/ClipboardPlugin.ts | 32 ++++++++++++++++++++ 1 file changed, 32 insertions(+) create mode 100644 packages/core/src/plugins/ClipboardPlugin.ts diff --git a/packages/core/src/plugins/ClipboardPlugin.ts b/packages/core/src/plugins/ClipboardPlugin.ts new file mode 100644 index 00000000..fca4af3e --- /dev/null +++ b/packages/core/src/plugins/ClipboardPlugin.ts @@ -0,0 +1,32 @@ +import type { CopyUIEvent, EditorAPI, EditorjsPlugin, EditorjsPluginParams } from '@editorjs/sdk'; +import { CopyUIEventName } from '@editorjs/sdk'; +import { PluginType } from '@editorjs/sdk'; + +/** + * @todo update doc + */ +export class ClipboardPlugin implements EditorjsPlugin { + public static readonly type = PluginType.Plugin; + + readonly #api: EditorAPI; + + /** + * @param params @todo update doc + */ + constructor(params: EditorjsPluginParams) { + const { api, eventBus } = params; + + this.#api = api; + + eventBus.addEventListener(`ui:${CopyUIEventName}`, (e: CopyUIEvent) => { + console.log('Copied to clipboard plugin', e.detail); + }); + } + + /** + * @todo update doc + */ + public destroy(): void { + // do nothing + } +} From 10c1c22e669f0612168268da557951dfd8d8946e Mon Sep 17 00:00:00 2001 From: Ilya Maroz <37909603+ilyamore88@users.noreply.github.com> Date: Fri, 17 Apr 2026 21:23:12 +0100 Subject: [PATCH 04/15] feat(core): use ClipboardPlugin --- packages/core/src/index.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index c5f7aa6f..69deda5e 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -21,6 +21,7 @@ import { EditorAPI } from './api/index.js'; import { generateId } from './utils/uid.js'; import { Paragraph, BoldInlineTool, LinkInlineTool, ItalicInlineTool } from './tools/internal'; import { ShortcutsPlugin } from './plugins/ShortcutsPlugin.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 @@ -135,6 +136,7 @@ export default class Core { this.use(ItalicInlineTool); this.use(LinkInlineTool); this.use(ShortcutsPlugin); + this.use(ClipboardPlugin); } /** From bc3657c0c55f358f49f4529402fb9dc2487215a1 Mon Sep 17 00:00:00 2001 From: Ilya Maroz <37909603+ilyamore88@users.noreply.github.com> Date: Sun, 24 May 2026 22:29:28 +0100 Subject: [PATCH 05/15] feat: implement clipboard plugin --- packages/core/src/api/SelectionAPI.ts | 9 +++- .../core/src/components/SelectionManager.ts | 30 +++++++++++- packages/core/src/plugins/ClipboardPlugin.ts | 46 ++++++++++++++++++- packages/model/src/entities/Index/index.ts | 8 ++++ packages/sdk/src/api/SelectionAPI.ts | 7 ++- .../EventBus/events/ui/CopyUIEvent.ts | 3 +- packages/ui/src/Blocks/Blocks.ts | 2 - 7 files changed, 98 insertions(+), 7 deletions(-) diff --git a/packages/core/src/api/SelectionAPI.ts b/packages/core/src/api/SelectionAPI.ts index 4e139b26..bcbea136 100644 --- a/packages/core/src/api/SelectionAPI.ts +++ b/packages/core/src/api/SelectionAPI.ts @@ -2,7 +2,7 @@ import 'reflect-metadata'; import { injectable } from 'inversify'; import { SelectionManager } from '../components/SelectionManager.js'; -import { createInlineToolName } from '@editorjs/model'; +import { createInlineToolName, BlockNodeSerialized } from '@editorjs/model'; import { InlineToolFormatData } from '@editorjs/sdk'; import { SelectionAPI as SelectionApiInterface } from '@editorjs/sdk'; @@ -32,4 +32,11 @@ export class SelectionAPI implements SelectionApiInterface { public applyInlineToolForCurrentSelection(toolName: string, data?: InlineToolFormatData): void { this.#selectionManager.applyInlineToolForCurrentSelection(createInlineToolName(toolName), data); } + + /** + * + */ + public get selectedBlocks(): BlockNodeSerialized[] | null { + return this.#selectionManager.selectedBlocks(); + } } diff --git a/packages/core/src/components/SelectionManager.ts b/packages/core/src/components/SelectionManager.ts index 6f2c1ad5..24359efa 100644 --- a/packages/core/src/components/SelectionManager.ts +++ b/packages/core/src/components/SelectionManager.ts @@ -4,7 +4,7 @@ import { createInlineToolData, FormattingAction, InlineFragment, - InlineToolName + InlineToolName, BlockNodeSerialized } from '@editorjs/model'; import { CaretManagerCaretUpdatedEvent, Index, EditorJSModel, createInlineToolName } from '@editorjs/model'; import { EventType } from '@editorjs/model'; @@ -177,4 +177,32 @@ export class SelectionManager { } } }; + + /** + * + */ + public selectedBlocks(): BlockNodeSerialized[] | null { + const userCaret = this.#model.getCaret(this.#config.userId); + const index = userCaret?.index ?? null; + + if (index === null) { + return null; + } + + if (index.isBlockIndex) { + const { blockIndex } = index; + + return [this.#model.serialized.blocks[blockIndex!]]; + } + + if (index.compositeSegments !== undefined) { + return index.compositeSegments.map((segment) => { + const { blockIndex } = segment; + + return this.#model.serialized.blocks[blockIndex!]; + }); + } + + return null; + } } diff --git a/packages/core/src/plugins/ClipboardPlugin.ts b/packages/core/src/plugins/ClipboardPlugin.ts index fca4af3e..08758228 100644 --- a/packages/core/src/plugins/ClipboardPlugin.ts +++ b/packages/core/src/plugins/ClipboardPlugin.ts @@ -19,7 +19,31 @@ export class ClipboardPlugin implements EditorjsPlugin { this.#api = api; eventBus.addEventListener(`ui:${CopyUIEventName}`, (e: CopyUIEvent) => { - console.log('Copied to clipboard plugin', e.detail); + const { nativeEvent } = e.detail; + + const selectedBlocks = this.#api.selection.selectedBlocks; + + /** + * Don't override native event if there are no blocks selected + */ + if (selectedBlocks === null || selectedBlocks.length === 0) { + return; + } + + nativeEvent.preventDefault(); + + const currentDOMSelection = window.getSelection(); + + if (!currentDOMSelection) { + return; + } + + const selectionAsPlainText = currentDOMSelection?.toString() ?? ''; + const selectionAsHTML = this.#parseDOMSelectionToHTML(currentDOMSelection); + + nativeEvent.clipboardData?.setData('text/plain', selectionAsPlainText); + nativeEvent.clipboardData?.setData('text/html', selectionAsHTML); + nativeEvent.clipboardData?.setData('application/x-editor-js', JSON.stringify(selectedBlocks)); }); } @@ -29,4 +53,24 @@ export class ClipboardPlugin implements EditorjsPlugin { public destroy(): void { // do nothing } + + /** + * + * @param selection + */ + #parseDOMSelectionToHTML(selection: Selection): string { + if (selection.rangeCount === 0) { + return ''; + } + + const container = document.createElement('div'); + + for (let i = 0; i < selection.rangeCount; i++) { + const range = selection.getRangeAt(i); + + container.appendChild(range.cloneContents()); + } + + return container.innerHTML; + } } diff --git a/packages/model/src/entities/Index/index.ts b/packages/model/src/entities/Index/index.ts index 91162926..442c6516 100644 --- a/packages/model/src/entities/Index/index.ts +++ b/packages/model/src/entities/Index/index.ts @@ -279,4 +279,12 @@ export class Index { /* Stryker disable next-line ConditionalExpression, LogicalOperator -- compound data-index predicate; .isDataIndex specs cover field combinations */ return this.blockIndex !== undefined && this.tuneName === undefined && this.dataKey !== undefined && this.textRange === undefined; } + + /** + * + */ + public get isCompositeIndex(): boolean { + /* Stryker disable next-line ConditionalExpression, LogicalOperator -- compound data-index predicate; .isDataIndex 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 6e12990e..4a417237 100644 --- a/packages/sdk/src/api/SelectionAPI.ts +++ b/packages/sdk/src/api/SelectionAPI.ts @@ -1,4 +1,4 @@ -import type { InlineToolName } from '@editorjs/model'; +import type { BlockNodeSerialized, InlineToolName } from '@editorjs/model'; /** * Selection API interface @@ -11,4 +11,9 @@ export interface SelectionAPI { * @param data - optional data for the inline tool */ applyInlineToolForCurrentSelection(tool: InlineToolName, data?: Record): void; + + /** + * + */ + get selectedBlocks(): BlockNodeSerialized[] | null; } diff --git a/packages/sdk/src/entities/EventBus/events/ui/CopyUIEvent.ts b/packages/sdk/src/entities/EventBus/events/ui/CopyUIEvent.ts index 2472dcfe..f9b65746 100644 --- a/packages/sdk/src/entities/EventBus/events/ui/CopyUIEvent.ts +++ b/packages/sdk/src/entities/EventBus/events/ui/CopyUIEvent.ts @@ -10,7 +10,8 @@ export const CopyUIEventName = 'copy'; */ export interface CopyUIEventPayload { /** - * @todo update doc + * Native ClipboardEvent + * UI does not call .preventDefault() for this event */ nativeEvent: ClipboardEvent; } diff --git a/packages/ui/src/Blocks/Blocks.ts b/packages/ui/src/Blocks/Blocks.ts index e2c367fb..75aeaf94 100644 --- a/packages/ui/src/Blocks/Blocks.ts +++ b/packages/ui/src/Blocks/Blocks.ts @@ -137,8 +137,6 @@ export class BlocksUI implements EditorjsPlugin { }; this.#eventBus.dispatchEvent(new CopyUIEvent(payload)); - - e.preventDefault(); }); return blocksHolder; From 00d10ac9d205bc44e7178b8aa2c632f4ce652e3a Mon Sep 17 00:00:00 2001 From: Ilya Maroz <37909603+ilyamore88@users.noreply.github.com> Date: Tue, 9 Jun 2026 19:21:18 +0100 Subject: [PATCH 06/15] fix(clipboard-plugin): prevent native event only after checking selection for null --- packages/core/src/plugins/ClipboardPlugin.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/core/src/plugins/ClipboardPlugin.ts b/packages/core/src/plugins/ClipboardPlugin.ts index 08758228..b03c027b 100644 --- a/packages/core/src/plugins/ClipboardPlugin.ts +++ b/packages/core/src/plugins/ClipboardPlugin.ts @@ -30,17 +30,17 @@ export class ClipboardPlugin implements EditorjsPlugin { return; } - nativeEvent.preventDefault(); - const currentDOMSelection = window.getSelection(); if (!currentDOMSelection) { return; } - const selectionAsPlainText = currentDOMSelection?.toString() ?? ''; + const selectionAsPlainText = currentDOMSelection.toString(); const selectionAsHTML = this.#parseDOMSelectionToHTML(currentDOMSelection); + nativeEvent.preventDefault(); + nativeEvent.clipboardData?.setData('text/plain', selectionAsPlainText); nativeEvent.clipboardData?.setData('text/html', selectionAsHTML); nativeEvent.clipboardData?.setData('application/x-editor-js', JSON.stringify(selectedBlocks)); From d426ae486f001f300753f80fcff44cb4381c750c Mon Sep 17 00:00:00 2001 From: Ilya Maroz <37909603+ilyamore88@users.noreply.github.com> Date: Tue, 9 Jun 2026 19:23:38 +0100 Subject: [PATCH 07/15] chore: fix stryker exception description Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- packages/model/src/entities/Index/index.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/model/src/entities/Index/index.ts b/packages/model/src/entities/Index/index.ts index 442c6516..1ae6b2cd 100644 --- a/packages/model/src/entities/Index/index.ts +++ b/packages/model/src/entities/Index/index.ts @@ -281,10 +281,10 @@ export class Index { } /** - * + * Returns true if index points to a composite index */ public get isCompositeIndex(): boolean { - /* Stryker disable next-line ConditionalExpression, LogicalOperator -- compound data-index predicate; .isDataIndex specs cover field combinations */ + /* 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; } } From 9f44277b05d0d1787f9fc0fa19731ba0ad74fd71 Mon Sep 17 00:00:00 2001 From: Ilya Maroz <37909603+ilyamore88@users.noreply.github.com> Date: Tue, 7 Jul 2026 21:13:27 +0100 Subject: [PATCH 08/15] feat(clipboard-plugin): update docs, add destroy method implementation, replace div with template --- packages/core/src/plugins/ClipboardPlugin.ts | 47 +++++++++++++++----- 1 file changed, 35 insertions(+), 12 deletions(-) diff --git a/packages/core/src/plugins/ClipboardPlugin.ts b/packages/core/src/plugins/ClipboardPlugin.ts index b03c027b..02acf39a 100644 --- a/packages/core/src/plugins/ClipboardPlugin.ts +++ b/packages/core/src/plugins/ClipboardPlugin.ts @@ -1,24 +1,32 @@ -import type { CopyUIEvent, EditorAPI, EditorjsPlugin, EditorjsPluginParams } from '@editorjs/sdk'; +import type { CopyUIEvent, EditorAPI, EditorjsPlugin, EditorjsPluginParams, EventBus } from '@editorjs/sdk'; import { CopyUIEventName } from '@editorjs/sdk'; import { PluginType } from '@editorjs/sdk'; /** - * @todo update doc + * 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 @todo update doc + * @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; - eventBus.addEventListener(`ui:${CopyUIEventName}`, (e: CopyUIEvent) => { + this.#copyEventListener = (e: CopyUIEvent) => { const { nativeEvent } = e.detail; const selectedBlocks = this.#api.selection.selectedBlocks; @@ -44,33 +52,48 @@ export class ClipboardPlugin implements EditorjsPlugin { nativeEvent.clipboardData?.setData('text/plain', selectionAsPlainText); nativeEvent.clipboardData?.setData('text/html', selectionAsHTML); nativeEvent.clipboardData?.setData('application/x-editor-js', JSON.stringify(selectedBlocks)); - }); + }; + + eventBus.addEventListener(`ui:${CopyUIEventName}`, this.#copyEventListener); } /** - * @todo update doc + * Destroys the plugin and removes all event listeners */ public destroy(): void { - // do nothing + this.removeEventListener(); + } + + /** + * Removes the event listener for copy events + * @internal + */ + private removeEventListener(): void { + if (this.#copyEventListener !== undefined) { + this.#eventBus.removeEventListener(`ui:${CopyUIEventName}`, this.#copyEventListener); + } + this.#copyEventListener = undefined; } /** - * - * @param selection + * 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 container = document.createElement('div'); + const template = document.createElement('template'); for (let i = 0; i < selection.rangeCount; i++) { const range = selection.getRangeAt(i); - container.appendChild(range.cloneContents()); + template.content.appendChild(range.cloneContents()); } - return container.innerHTML; + return template.innerHTML; } } From 55b62c80e0187c6122ef76a289477c8244f0e61e Mon Sep 17 00:00:00 2001 From: Ilya Maroz <37909603+ilyamore88@users.noreply.github.com> Date: Tue, 7 Jul 2026 21:56:50 +0100 Subject: [PATCH 09/15] chore: use currentSelection in selection manager --- packages/core/src/components/SelectionManager.ts | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/packages/core/src/components/SelectionManager.ts b/packages/core/src/components/SelectionManager.ts index d1e7b74a..ca19b4e5 100644 --- a/packages/core/src/components/SelectionManager.ts +++ b/packages/core/src/components/SelectionManager.ts @@ -231,21 +231,20 @@ export class SelectionManager { * */ public selectedBlocks(): BlockNodeSerialized[] | null { - const userCaret = this.#model.getCaret(this.#config.userId); - const index = userCaret?.index ?? null; + const currentSelectionIndex = this.currentSelection; - if (index === null) { + if (currentSelectionIndex === null) { return null; } - if (index.isBlockIndex) { - const { blockIndex } = index; + if (currentSelectionIndex.isBlockIndex) { + const { blockIndex } = currentSelectionIndex; return [this.#model.serialized.blocks[blockIndex!]]; } - if (index.compositeSegments !== undefined) { - return index.compositeSegments.map((segment) => { + if (currentSelectionIndex.compositeSegments !== undefined) { + return currentSelectionIndex.compositeSegments.map((segment) => { const { blockIndex } = segment; return this.#model.serialized.blocks[blockIndex!]; From 2681e5bde79970a4f3778d84b78dbfb94232c21a Mon Sep 17 00:00:00 2001 From: Ilya Maroz <37909603+ilyamore88@users.noreply.github.com> Date: Wed, 8 Jul 2026 18:57:14 +0100 Subject: [PATCH 10/15] feat(clipboard-plugin): describe clipboard object --- packages/core/src/plugins/ClipboardPlugin.ts | 43 +++++++++++++++++++- 1 file changed, 41 insertions(+), 2 deletions(-) diff --git a/packages/core/src/plugins/ClipboardPlugin.ts b/packages/core/src/plugins/ClipboardPlugin.ts index 02acf39a..95417bcb 100644 --- a/packages/core/src/plugins/ClipboardPlugin.ts +++ b/packages/core/src/plugins/ClipboardPlugin.ts @@ -1,4 +1,4 @@ -import type { CopyUIEvent, EditorAPI, EditorjsPlugin, EditorjsPluginParams, EventBus } from '@editorjs/sdk'; +import type { BlockData, CopyUIEvent, EditorAPI, EditorjsPlugin, EditorjsPluginParams, EventBus } from '@editorjs/sdk'; import { CopyUIEventName } from '@editorjs/sdk'; import { PluginType } from '@editorjs/sdk'; @@ -46,12 +46,13 @@ export class ClipboardPlugin implements EditorjsPlugin { 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(selectedBlocks)); + nativeEvent.clipboardData?.setData('application/x-editor-js', JSON.stringify(clipboardEditorJSObject)); }; eventBus.addEventListener(`ui:${CopyUIEventName}`, this.#copyEventListener); @@ -96,4 +97,42 @@ export class ClipboardPlugin implements EditorjsPlugin { return template.innerHTML; } + + /** + * + * @param blocks - content to create a clipboard object + */ + #createClipboardObject(blocks: BlockData[] = []): ClipboardEditorJSObject { + return { + blocks, + meta: { + version: '3.0.0', + }, + }; + } +} + +/** + * + */ +interface ClipboardEditorJSObject { + /** + * + */ + blocks: BlockData[]; + + /** + * + */ + meta: Meta; +} + +/** + * + */ +interface Meta { + /** + * + */ + version: string; } From 2b288dc5b1bfbd069abcb307134a9fafa0cf479c Mon Sep 17 00:00:00 2001 From: Ilya Maroz <37909603+ilyamore88@users.noreply.github.com> Date: Wed, 8 Jul 2026 20:07:27 +0100 Subject: [PATCH 11/15] refactor(selection-api): return empty array instead of null --- packages/core/src/components/SelectionManager.ts | 6 +++--- packages/core/src/plugins/ClipboardPlugin.ts | 2 +- packages/sdk/src/api/SelectionAPI.ts | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/core/src/components/SelectionManager.ts b/packages/core/src/components/SelectionManager.ts index ca19b4e5..5a75b05b 100644 --- a/packages/core/src/components/SelectionManager.ts +++ b/packages/core/src/components/SelectionManager.ts @@ -230,11 +230,11 @@ export class SelectionManager { /** * */ - public selectedBlocks(): BlockNodeSerialized[] | null { + public selectedBlocks(): BlockNodeSerialized[] { const currentSelectionIndex = this.currentSelection; if (currentSelectionIndex === null) { - return null; + return []; } if (currentSelectionIndex.isBlockIndex) { @@ -251,6 +251,6 @@ export class SelectionManager { }); } - return null; + return []; } } diff --git a/packages/core/src/plugins/ClipboardPlugin.ts b/packages/core/src/plugins/ClipboardPlugin.ts index 95417bcb..8a111aac 100644 --- a/packages/core/src/plugins/ClipboardPlugin.ts +++ b/packages/core/src/plugins/ClipboardPlugin.ts @@ -34,7 +34,7 @@ export class ClipboardPlugin implements EditorjsPlugin { /** * Don't override native event if there are no blocks selected */ - if (selectedBlocks === null || selectedBlocks.length === 0) { + if (selectedBlocks.length === 0) { return; } diff --git a/packages/sdk/src/api/SelectionAPI.ts b/packages/sdk/src/api/SelectionAPI.ts index f7daecfe..d22b5754 100644 --- a/packages/sdk/src/api/SelectionAPI.ts +++ b/packages/sdk/src/api/SelectionAPI.ts @@ -67,5 +67,5 @@ export interface SelectionAPI { /** * */ - get selectedBlocks(): BlockNodeSerialized[] | null; + get selectedBlocks(): BlockNodeSerialized[]; } From f2a60679c05e66a490384308ed3f24119251b9fb Mon Sep 17 00:00:00 2001 From: Ilya Maroz <37909603+ilyamore88@users.noreply.github.com> Date: Wed, 8 Jul 2026 20:15:19 +0100 Subject: [PATCH 12/15] chore: add docs --- packages/core/src/api/SelectionAPI.ts | 4 ++-- packages/core/src/components/SelectionManager.ts | 2 +- packages/core/src/plugins/ClipboardPlugin.ts | 14 ++++++++------ packages/sdk/src/api/SelectionAPI.ts | 2 +- 4 files changed, 12 insertions(+), 10 deletions(-) diff --git a/packages/core/src/api/SelectionAPI.ts b/packages/core/src/api/SelectionAPI.ts index 32be09f9..5e2b323a 100644 --- a/packages/core/src/api/SelectionAPI.ts +++ b/packages/core/src/api/SelectionAPI.ts @@ -90,9 +90,9 @@ export class SelectionAPI implements SelectionApiInterface { } /** - * + * Returns array of selected blocks */ - public get selectedBlocks(): BlockNodeSerialized[] | null { + 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 5a75b05b..4c11f87b 100644 --- a/packages/core/src/components/SelectionManager.ts +++ b/packages/core/src/components/SelectionManager.ts @@ -228,7 +228,7 @@ export class SelectionManager { }; /** - * + * Returns an array of selected blocks using the current selection index */ public selectedBlocks(): BlockNodeSerialized[] { const currentSelectionIndex = this.currentSelection; diff --git a/packages/core/src/plugins/ClipboardPlugin.ts b/packages/core/src/plugins/ClipboardPlugin.ts index 8a111aac..a51be293 100644 --- a/packages/core/src/plugins/ClipboardPlugin.ts +++ b/packages/core/src/plugins/ClipboardPlugin.ts @@ -99,13 +99,15 @@ export class ClipboardPlugin implements EditorjsPlugin { } /** - * + * 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', }, }; @@ -113,26 +115,26 @@ export class ClipboardPlugin implements EditorjsPlugin { } /** - * + * 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/sdk/src/api/SelectionAPI.ts b/packages/sdk/src/api/SelectionAPI.ts index d22b5754..6585d2dc 100644 --- a/packages/sdk/src/api/SelectionAPI.ts +++ b/packages/sdk/src/api/SelectionAPI.ts @@ -65,7 +65,7 @@ export interface SelectionAPI { caretIndex: Index | null; /** - * + * Returns array of selected blocks */ get selectedBlocks(): BlockNodeSerialized[]; } From 264f721669a93b4c731acfbc69c8af7e080596ed Mon Sep 17 00:00:00 2001 From: Ilya Maroz <37909603+ilyamore88@users.noreply.github.com> Date: Wed, 8 Jul 2026 21:04:23 +0100 Subject: [PATCH 13/15] chore: update docs --- .../src/entities/EventBus/events/ui/CopyUIEvent.ts | 5 +++-- packages/ui/src/Blocks/Blocks.ts | 13 ++++--------- 2 files changed, 7 insertions(+), 11 deletions(-) diff --git a/packages/sdk/src/entities/EventBus/events/ui/CopyUIEvent.ts b/packages/sdk/src/entities/EventBus/events/ui/CopyUIEvent.ts index f9b65746..abf86fdf 100644 --- a/packages/sdk/src/entities/EventBus/events/ui/CopyUIEvent.ts +++ b/packages/sdk/src/entities/EventBus/events/ui/CopyUIEvent.ts @@ -6,7 +6,8 @@ import { UIEventBase } from './UIEventBase.js'; export const CopyUIEventName = 'copy'; /** - * Payload @todo update doc + * Payload of CopyUIEvent + * Contains a native clipboard event */ export interface CopyUIEventPayload { /** @@ -17,7 +18,7 @@ export interface CopyUIEventPayload { } /** - * Delegated copy event from the editor @todo update doc + * Delegated copy event from the editor block holder */ export class CopyUIEvent extends UIEventBase { /** diff --git a/packages/ui/src/Blocks/Blocks.ts b/packages/ui/src/Blocks/Blocks.ts index e61cf222..66f3037d 100644 --- a/packages/ui/src/Blocks/Blocks.ts +++ b/packages/ui/src/Blocks/Blocks.ts @@ -1,19 +1,14 @@ -import type { +import type { EventBus, BlockAddedCoreEvent, - BlockRemovedCoreEvent, CopyUIEvent, EditorAPI, + BlockRemovedCoreEvent, CopyUIEventPayload, EditorjsPlugin, - EditorjsPluginParams -} from '@editorjs/sdk'; + EditorjsPluginParams, EditorAPI } from '@editorjs/sdk'; import { CoreEventType, + CopyUIEvent, UiComponentType, BeforeInputUIEvent } from '@editorjs/sdk'; -import type { EventBus, - BlockAddedCoreEvent, - BlockRemovedCoreEvent, CopyUIEventPayload, - EditorjsPlugin, - EditorjsPluginParams } from '@editorjs/sdk'; import Style from './Blocks.module.pcss'; import { isNativeInput, make } from '@editorjs/dom'; import { BlocksHolderRenderedUIEvent, BlockSelectedUIEvent } from './events/index.js'; From f4601a6d56fd3ef0cca46a8690819dd3e4259130 Mon Sep 17 00:00:00 2001 From: Ilya Maroz <37909603+ilyamore88@users.noreply.github.com> Date: Wed, 8 Jul 2026 21:07:55 +0100 Subject: [PATCH 14/15] fix: make removeEventListener js-private --- packages/core/src/plugins/ClipboardPlugin.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/core/src/plugins/ClipboardPlugin.ts b/packages/core/src/plugins/ClipboardPlugin.ts index a51be293..075bf79f 100644 --- a/packages/core/src/plugins/ClipboardPlugin.ts +++ b/packages/core/src/plugins/ClipboardPlugin.ts @@ -62,14 +62,14 @@ export class ClipboardPlugin implements EditorjsPlugin { * Destroys the plugin and removes all event listeners */ public destroy(): void { - this.removeEventListener(); + this.#removeEventListener(); } /** * Removes the event listener for copy events * @internal */ - private removeEventListener(): void { + #removeEventListener(): void { if (this.#copyEventListener !== undefined) { this.#eventBus.removeEventListener(`ui:${CopyUIEventName}`, this.#copyEventListener); } From ec4aca5108782f66020bd58f344682658935e4dd Mon Sep 17 00:00:00 2001 From: Ilya Maroz <37909603+ilyamore88@users.noreply.github.com> Date: Thu, 9 Jul 2026 20:30:33 +0100 Subject: [PATCH 15/15] chore(clipboard-plugin): add unit-tests --- .../core/src/plugins/ClipboardPlugin.spec.ts | 321 ++++++++++++++++++ 1 file changed, 321 insertions(+) create mode 100644 packages/core/src/plugins/ClipboardPlugin.spec.ts diff --git a/packages/core/src/plugins/ClipboardPlugin.spec.ts b/packages/core/src/plugins/ClipboardPlugin.spec.ts new file mode 100644 index 00000000..01ae63c0 --- /dev/null +++ b/packages/core/src/plugins/ClipboardPlugin.spec.ts @@ -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(); + + 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; + } + }); + + it('should add current selection as text to native event', () => { + restoreDOMMocks = mockDOMSelection({ plainText: 'hello', + html: '

hello

' }); + + 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: '

hello

' }); + + new ClipboardPlugin(pluginParamsMock); + + const { setData } = dispatchCopyEvent(); + + expect(setData).toHaveBeenCalledWith('text/html', '

hello

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

hello

' }); + + 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' }, + }) + ); + }); + }); + }); +});