Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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();
}
},
);
});
30 changes: 25 additions & 5 deletions packages/core/src/api/clipboard/toClipboard/copyExtension.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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<BSchema, I, S>(
view,
selectedFragment,
selectedSlice.content,
editor,
);

Expand Down
114 changes: 114 additions & 0 deletions packages/core/src/comments/extension.test.ts
Original file line number Diff line number Diff line change
@@ -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,
'<p><span class="bn-thread-mark" data-bn-thread-id="thread-1">Hello</span></p>',
);

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