From 40302c30206da9050153be74328235ed3338c234 Mon Sep 17 00:00:00 2001 From: adarshsm <24850536+adarshsm@users.noreply.github.com> Date: Sun, 25 Oct 2026 12:06:16 +0530 Subject: [PATCH 1/3] fix(core): don't paste the comments of copied text Copying text that has a comment and pasting it kept the comment mark with the same thread id. The pasted text was highlighted as part of the thread, but the thread now had two anchored ranges, and resolving or deleting the thread had to deal with both. Strip the comment mark from pasted slices in the comments extension. A drag-and-drop move keeps its comments, since it moves the text instead of copying it. Closes #3158 --- packages/core/src/comments/extension.test.ts | 95 ++++++++++++++++++++ packages/core/src/comments/extension.ts | 33 ++++++- 2 files changed, 127 insertions(+), 1 deletion(-) create mode 100644 packages/core/src/comments/extension.test.ts diff --git a/packages/core/src/comments/extension.test.ts b/packages/core/src/comments/extension.test.ts new file mode 100644 index 0000000000..3e58038c34 --- /dev/null +++ b/packages/core/src/comments/extension.test.ts @@ -0,0 +1,95 @@ +/** + * @vitest-environment jsdom + */ +import { TextSelection } from "prosemirror-state"; +import { describe, expect, it } from "vite-plus/test"; + +import { selectedFragmentToHTML } from "../api/clipboard/toClipboard/copyExtension.js"; +import { BlockNoteEditor } from "../editor/BlockNoteEditor.js"; +import { CommentsExtension } from "./extension.js"; +import type { ThreadStore } from "./threadstore/ThreadStore.js"; + +// Creates an editor whose first paragraph ("Hello world") has a comment on +// "Hello", and returns the clipboard HTML of copying that comment. +function createEditorWithComment() { + const threadStore = { + subscribe: () => () => {}, + getThreads: () => new Map([["thread-1", { id: "thread-1" }]]), + } as unknown as ThreadStore; + const editor = BlockNoteEditor.create({ + initialContent: [ + { type: "paragraph", content: "Hello world" }, + { type: "paragraph", content: "target" }, + ], + trailingBlock: false, + extensions: [ + CommentsExtension({ threadStore, resolveUsers: async () => [] }), + ], + }); + editor.mount(document.createElement("div")); + editor.transact((tr) => + tr.addMark( + 3, + 8, + editor.pmSchema.marks.comment.create({ + threadId: "thread-1", + orphan: false, + }), + ), + ); + editor.transact((tr) => tr.setSelection(TextSelection.create(tr.doc, 3, 8))); + const { clipboardHTML } = selectedFragmentToHTML( + editor.prosemirrorView!, + editor, + ); + return { editor, clipboardHTML }; +} + +function pasteAtEndOfTarget(editor: BlockNoteEditor, html: string) { + let end = 0; + editor.prosemirrorState.doc.descendants((node, pos) => { + if (node.isText && node.text === "target") { + end = pos + node.nodeSize; + } + }); + editor.transact((tr) => tr.setSelection(TextSelection.create(tr.doc, end))); + editor.pasteHTML(html, true); +} + +function commentedText(editor: BlockNoteEditor) { + const texts: string[] = []; + editor.prosemirrorState.doc.descendants((node) => { + if (node.marks.some((mark) => mark.type.name === "comment")) { + texts.push(node.text!); + } + }); + return texts; +} + +describe("CommentsExtension", () => { + it("doesn't paste the comment of copied text", () => { + const { editor, clipboardHTML } = createEditorWithComment(); + + pasteAtEndOfTarget(editor, clipboardHTML); + + expect(editor.document[1].content).toMatchObject([ + { type: "text", text: "targetHello" }, + ]); + // The comment stays on the original text only. + expect(commentedText(editor)).toEqual(["Hello"]); + }); + + it("keeps the comment of text moved by dragging", () => { + const { editor, clipboardHTML } = createEditorWithComment(); + const view = editor.prosemirrorView!; + // Mark the paste as the drop of an internal drag-and-drop move. + (view as any).dragging = { + slice: view.state.selection.content(), + move: true, + }; + + pasteAtEndOfTarget(editor, clipboardHTML); + + expect(commentedText(editor)).toEqual(["Hello", "Hello"]); + }); +}); diff --git a/packages/core/src/comments/extension.ts b/packages/core/src/comments/extension.ts index 5658081552..cb09e76de8 100644 --- a/packages/core/src/comments/extension.ts +++ b/packages/core/src/comments/extension.ts @@ -1,4 +1,4 @@ -import { Node } from "prosemirror-model"; +import { Fragment, Node, Slice } from "prosemirror-model"; import { Plugin, PluginKey } from "prosemirror-state"; import { Decoration, DecorationSet } from "prosemirror-view"; import { @@ -56,6 +56,23 @@ function getUpdatedThreadPositions(doc: Node, markType: string) { return threadPositions; } +/** + * Returns `fragment` without the marks of type `markType`. + */ +function removeMarkFromFragment(fragment: Fragment, markType: string) { + const children: Node[] = []; + fragment.forEach((child) => { + const marks = child.marks.filter((mark) => mark.type.name !== markType); + const unmarked = child.mark(marks); + children.push( + unmarked.content.size > 0 + ? unmarked.copy(removeMarkFromFragment(unmarked.content, markType)) + : unmarked, + ); + }); + return Fragment.fromArray(children); +} + export const CommentsExtension = createExtension( ({ editor, @@ -249,6 +266,20 @@ export const CommentsExtension = createExtension( PLUGIN_KEY.getState(state)?.decorations ?? DecorationSet.empty ); }, + transformPasted: (slice, view) => { + // A pasted copy of commented text would anchor a second range to + // the same thread. Moving text by dragging isn't a copy, so it + // keeps its comments. + if (view.dragging?.move) { + return slice; + } + + return new Slice( + removeMarkFromFragment(slice.content, markType), + slice.openStart, + slice.openEnd, + ); + }, handleClick: (view, pos, event) => { if (event.button !== 0) { return false; From a5fe3c8f674afe7c5b2dab5ca1c88794a7265479 Mon Sep 17 00:00:00 2001 From: Nick the Sick Date: Tue, 27 Oct 2026 16:36:52 +0100 Subject: [PATCH 2/3] fix(core): strip ignored marks when copying content --- .../toClipboard/copyExtension.browser.test.ts | 79 +++++++++++++++++++ .../clipboard/toClipboard/copyExtension.ts | 30 +++++-- packages/core/src/comments/extension.test.ts | 37 +++++---- packages/core/src/comments/extension.ts | 33 +------- 4 files changed, 128 insertions(+), 51 deletions(-) create mode 100644 packages/core/src/api/clipboard/toClipboard/copyExtension.browser.test.ts diff --git a/packages/core/src/api/clipboard/toClipboard/copyExtension.browser.test.ts b/packages/core/src/api/clipboard/toClipboard/copyExtension.browser.test.ts new file mode 100644 index 0000000000..7cf7e39473 --- /dev/null +++ b/packages/core/src/api/clipboard/toClipboard/copyExtension.browser.test.ts @@ -0,0 +1,79 @@ +import { Mark } from "@tiptap/core"; +import { NodeSelection, TextSelection } from "prosemirror-state"; +import { describe, expect, it } from "vite-plus/test"; + +import { BlockNoteEditor } from "../../../editor/BlockNoteEditor.js"; +import { createExtension } from "../../../editor/BlockNoteExtension.js"; +import { BLOCK_LEVEL_SUGGESTION_GROUP } from "../../../pm-nodes/suggestionMarks.js"; +import { selectedFragmentToHTML } from "./copyExtension.js"; + +describe("selectedFragmentToHTML", () => { + it.each(["comment", "insertion", "deletion", "modification", "custom"])( + "strips ignored %s marks while preserving content, formatting, and the source", + (name) => { + const annotation = Mark.create({ + name, + group: BLOCK_LEVEL_SUGGESTION_GROUP, + renderHTML() { + return ["span", { "data-annotation": name }, 0]; + }, + extendMarkSchema(extension) { + return extension.name === name ? { blocknoteIgnore: true } : {}; + }, + }); + const editor = BlockNoteEditor.create({ + initialContent: [ + { + type: "paragraph", + content: [ + { + type: "link", + href: "https://example.com", + content: [ + { type: "text", text: "Hello", styles: { bold: true } }, + ], + }, + ], + }, + ], + trailingBlock: false, + extensions: [ + createExtension({ + key: "test-annotation", + tiptapExtensions: [annotation], + }), + ], + }); + editor.mount(document.createElement("div")); + try { + editor.transact((tr) => { + const mark = editor.pmSchema.marks[name].create(); + tr.addMark(3, 8, mark); + tr.addNodeMark(2, mark); + }); + + // Exercise both an open text slice and a complete block with nested marks. + for (const selection of [ + TextSelection.create(editor.prosemirrorState.doc, 3, 8), + NodeSelection.create(editor.prosemirrorState.doc, 1), + ]) { + editor.transact((tr) => tr.setSelection(selection)); + const before = editor.prosemirrorState.doc.toJSON(); + const { clipboardHTML, externalHTML, markdown } = + selectedFragmentToHTML(editor.prosemirrorView, editor); + + for (const html of [clipboardHTML, externalHTML]) { + expect(html).toContain("Hello"); + expect(html).toContain("https://example.com"); + expect(html).toMatch(/<(strong|b)[ >]/); + expect(html).not.toContain("data-annotation"); + } + expect(markdown).toContain("Hello"); + expect(editor.prosemirrorState.doc.toJSON()).toEqual(before); + } + } finally { + editor.unmount(); + } + }, + ); +}); diff --git a/packages/core/src/api/clipboard/toClipboard/copyExtension.ts b/packages/core/src/api/clipboard/toClipboard/copyExtension.ts index d284d251bb..4a19b035be 100644 --- a/packages/core/src/api/clipboard/toClipboard/copyExtension.ts +++ b/packages/core/src/api/clipboard/toClipboard/copyExtension.ts @@ -1,5 +1,5 @@ import { Extension } from "@tiptap/core"; -import { Fragment, Node } from "prosemirror-model"; +import { Fragment, Node, Slice } from "prosemirror-model"; import { NodeSelection, Plugin } from "prosemirror-state"; import { CellSelection } from "prosemirror-tables"; import type { EditorView } from "prosemirror-view"; @@ -127,16 +127,36 @@ export function selectedFragmentToHTML< ); } + // Strip internal annotations from the copied content, not the source document. + function removeIgnoredMarks(fragment: Fragment): Fragment { + const children: Node[] = []; + fragment.forEach((child) => { + const unmarked = child.mark( + child.marks.filter((mark) => !mark.type.spec.blocknoteIgnore), + ); + children.push( + unmarked.content.size > 0 + ? unmarked.copy(removeIgnoredMarks(unmarked.content)) + : unmarked, + ); + }); + return Fragment.fromArray(children); + } + + const selectedSlice = view.state.selection.content(); + // Uses default ProseMirror clipboard serialization. const clipboardHTML: string = view.serializeForClipboard( - view.state.selection.content(), + new Slice( + removeIgnoredMarks(selectedSlice.content), + selectedSlice.openStart, + selectedSlice.openEnd, + ), ).dom.innerHTML; - const selectedFragment = view.state.selection.content().content; - const externalHTML = fragmentToExternalHTML( view, - selectedFragment, + selectedSlice.content, editor, ); diff --git a/packages/core/src/comments/extension.test.ts b/packages/core/src/comments/extension.test.ts index 3e58038c34..64df530931 100644 --- a/packages/core/src/comments/extension.test.ts +++ b/packages/core/src/comments/extension.test.ts @@ -38,11 +38,8 @@ function createEditorWithComment() { ), ); editor.transact((tr) => tr.setSelection(TextSelection.create(tr.doc, 3, 8))); - const { clipboardHTML } = selectedFragmentToHTML( - editor.prosemirrorView!, - editor, - ); - return { editor, clipboardHTML }; + const clipboard = selectedFragmentToHTML(editor.prosemirrorView!, editor); + return { editor, ...clipboard }; } function pasteAtEndOfTarget(editor: BlockNoteEditor, html: string) { @@ -67,6 +64,21 @@ function commentedText(editor: BlockNoteEditor) { } describe("CommentsExtension", () => { + it("doesn't put comment marks or thread IDs on the clipboard", () => { + const { editor, clipboardHTML, externalHTML, markdown } = + createEditorWithComment(); + + for (const html of [clipboardHTML, externalHTML]) { + expect(html).toContain("Hello"); + expect(html).not.toContain("bn-thread-mark"); + expect(html).not.toContain("data-bn-thread-id"); + expect(html).not.toContain("thread-1"); + } + expect(markdown).toBe("Hello\n"); + // Serializing the selection must not change the original anchor. + expect(commentedText(editor)).toEqual(["Hello"]); + }); + it("doesn't paste the comment of copied text", () => { const { editor, clipboardHTML } = createEditorWithComment(); @@ -79,16 +91,13 @@ describe("CommentsExtension", () => { expect(commentedText(editor)).toEqual(["Hello"]); }); - it("keeps the comment of text moved by dragging", () => { - const { editor, clipboardHTML } = createEditorWithComment(); - const view = editor.prosemirrorView!; - // Mark the paste as the drop of an internal drag-and-drop move. - (view as any).dragging = { - slice: view.state.selection.content(), - move: true, - }; + it("doesn't strip comment marks from incoming HTML", () => { + const { editor } = createEditorWithComment(); - pasteAtEndOfTarget(editor, clipboardHTML); + pasteAtEndOfTarget( + editor, + '

Hello

', + ); expect(commentedText(editor)).toEqual(["Hello", "Hello"]); }); diff --git a/packages/core/src/comments/extension.ts b/packages/core/src/comments/extension.ts index cb09e76de8..5658081552 100644 --- a/packages/core/src/comments/extension.ts +++ b/packages/core/src/comments/extension.ts @@ -1,4 +1,4 @@ -import { Fragment, Node, Slice } from "prosemirror-model"; +import { Node } from "prosemirror-model"; import { Plugin, PluginKey } from "prosemirror-state"; import { Decoration, DecorationSet } from "prosemirror-view"; import { @@ -56,23 +56,6 @@ function getUpdatedThreadPositions(doc: Node, markType: string) { return threadPositions; } -/** - * Returns `fragment` without the marks of type `markType`. - */ -function removeMarkFromFragment(fragment: Fragment, markType: string) { - const children: Node[] = []; - fragment.forEach((child) => { - const marks = child.marks.filter((mark) => mark.type.name !== markType); - const unmarked = child.mark(marks); - children.push( - unmarked.content.size > 0 - ? unmarked.copy(removeMarkFromFragment(unmarked.content, markType)) - : unmarked, - ); - }); - return Fragment.fromArray(children); -} - export const CommentsExtension = createExtension( ({ editor, @@ -266,20 +249,6 @@ export const CommentsExtension = createExtension( PLUGIN_KEY.getState(state)?.decorations ?? DecorationSet.empty ); }, - transformPasted: (slice, view) => { - // A pasted copy of commented text would anchor a second range to - // the same thread. Moving text by dragging isn't a copy, so it - // keeps its comments. - if (view.dragging?.move) { - return slice; - } - - return new Slice( - removeMarkFromFragment(slice.content, markType), - slice.openStart, - slice.openEnd, - ); - }, handleClick: (view, pos, event) => { if (event.button !== 0) { return false; From 6b28176c9230e4a5e50188c16cdedad708088790 Mon Sep 17 00:00:00 2001 From: Nick the Sick Date: Tue, 27 Oct 2026 16:56:51 +0100 Subject: [PATCH 3/3] test(core): unmount comments editors after each test --- packages/core/src/comments/extension.test.ts | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/packages/core/src/comments/extension.test.ts b/packages/core/src/comments/extension.test.ts index 64df530931..52cc1eb270 100644 --- a/packages/core/src/comments/extension.test.ts +++ b/packages/core/src/comments/extension.test.ts @@ -2,13 +2,22 @@ * @vitest-environment jsdom */ import { TextSelection } from "prosemirror-state"; -import { describe, expect, it } from "vite-plus/test"; +import { afterEach, describe, expect, it } from "vite-plus/test"; import { selectedFragmentToHTML } from "../api/clipboard/toClipboard/copyExtension.js"; import { BlockNoteEditor } from "../editor/BlockNoteEditor.js"; import { CommentsExtension } from "./extension.js"; import type { ThreadStore } from "./threadstore/ThreadStore.js"; +const editors: BlockNoteEditor[] = []; + +afterEach(() => { + for (const editor of editors) { + editor.unmount(); + } + editors.length = 0; +}); + // Creates an editor whose first paragraph ("Hello world") has a comment on // "Hello", and returns the clipboard HTML of copying that comment. function createEditorWithComment() { @@ -26,6 +35,7 @@ function createEditorWithComment() { CommentsExtension({ threadStore, resolveUsers: async () => [] }), ], }); + editors.push(editor); editor.mount(document.createElement("div")); editor.transact((tr) => tr.addMark(