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 1/5] 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 2/5] 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 3/5] 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 4/5] 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 5/5] 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;