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 new file mode 100644 index 0000000000..52cc1eb270 --- /dev/null +++ b/packages/core/src/comments/extension.test.ts @@ -0,0 +1,114 @@ +/** + * @vitest-environment jsdom + */ +import { TextSelection } from "prosemirror-state"; +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() { + 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 () => [] }), + ], + }); + editors.push(editor); + 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 clipboard = selectedFragmentToHTML(editor.prosemirrorView!, editor); + return { editor, ...clipboard }; +} + +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 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(); + + 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("doesn't strip comment marks from incoming HTML", () => { + const { editor } = createEditorWithComment(); + + pasteAtEndOfTarget( + editor, + '

Hello

', + ); + + expect(commentedText(editor)).toEqual(["Hello", "Hello"]); + }); +});