@blocknote/core
Version:
A "Notion-style" block-based extensible text editor built on top of Prosemirror and Tiptap.
436 lines (416 loc) • 11.5 kB
text/typescript
import { Node } from "prosemirror-model";
import { NodeSelection, Selection, TextSelection } from "prosemirror-state";
import { CellSelection } from "prosemirror-tables";
import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest";
import { PartialBlock } from "../../blocks/defaultBlocks.js";
import { BlockNoteEditor } from "../../editor/BlockNoteEditor.js";
import { initializeESMDependencies } from "../../util/esmDependencies.js";
import { doPaste } from "../testUtil/paste.js";
import { schema } from "./testUtil.js";
import { selectedFragmentToHTML } from "./toClipboard/copyExtension.js";
type SelectionTestCase = {
testName: string;
createCopySelection: (doc: Node) => Selection;
createPasteSelection?: (doc: Node) => Selection;
};
// These tests are meant to test the copying of user selections in the editor.
// The test cases used for the other HTML conversion tests are not suitable here
// as they are represented in the BlockNote API, whereas here we want to test
// ProseMirror/TipTap selections directly.
describe("Test ProseMirror selection clipboard HTML", () => {
const initialContent: PartialBlock<typeof schema.blockSchema>[] = [
{
type: "heading",
props: {
level: 2,
textColor: "red",
},
content: "Heading 1",
children: [
{
type: "paragraph",
content: "Nested Paragraph 1",
},
{
type: "paragraph",
content: "Nested Paragraph 2",
},
{
type: "paragraph",
content: "Nested Paragraph 3",
},
],
},
{
type: "heading",
props: {
level: 2,
textColor: "red",
},
content: "Heading 2",
children: [
{
type: "paragraph",
content: "Nested Paragraph 1",
},
{
type: "paragraph",
content: "Nested Paragraph 2",
},
{
type: "paragraph",
content: "Nested Paragraph 3",
},
],
},
{
type: "heading",
props: {
level: 2,
textColor: "red",
},
content: [
{
type: "text",
text: "Bold",
styles: {
bold: true,
},
},
{
type: "text",
text: "Italic",
styles: {
italic: true,
},
},
{
type: "text",
text: "Regular",
styles: {},
},
],
children: [
{
type: "image",
props: {
url: "https://ralfvanveen.com/wp-content/uploads/2021/06/Placeholder-_-Glossary.svg",
},
children: [
{
type: "paragraph",
content: "Nested Paragraph",
},
],
},
],
},
{
type: "table",
content: {
type: "tableContent",
rows: [
{
cells: ["Table Cell", "Table Cell"],
},
{
cells: ["Table Cell", "Table Cell"],
},
],
},
// Not needed as selections starting in table cells will get snapped to
// the table boundaries.
// children: [
// {
// type: "table",
// content: {
// type: "tableContent",
// rows: [
// {
// cells: ["Table Cell", "Table Cell"],
// },
// {
// cells: ["Table Cell", "Table Cell"],
// },
// ],
// },
// },
// ],
},
{
type: "paragraph",
content: "Paragraph",
},
{
type: "customParagraph",
content: "Paragraph",
},
{
type: "paragraph",
content: "Paragraph",
},
{
type: "heading",
content: "Heading",
},
{
type: "numberedListItem",
content: "Numbered List Item",
},
{
type: "bulletListItem",
content: "Bullet List Item",
},
{
type: "checkListItem",
content: "Check List Item",
},
{
type: "codeBlock",
content: 'console.log("Hello World");',
},
{
type: "table",
content: {
type: "tableContent",
rows: [
{
cells: [["Table Cell"], ["Table Cell"], ["Table Cell"]],
},
{
cells: [["Table Cell"], ["Table Cell"], ["Table Cell"]],
},
{
cells: [["Table Cell"], ["Table Cell"], ["Table Cell"]],
},
],
},
},
{
type: "image",
},
{
type: "paragraph",
props: {
textColor: "red",
},
content: "Paragraph",
},
{
type: "heading",
props: {
level: 2,
},
content: "Heading",
},
{
type: "numberedListItem",
props: {
start: 2,
},
content: "Numbered List Item",
},
{
type: "bulletListItem",
props: {
backgroundColor: "red",
},
content: "Bullet List Item",
},
{
type: "checkListItem",
props: {
checked: true,
},
content: "Check List Item",
},
{
type: "codeBlock",
props: {
language: "typescript",
},
content: 'console.log("Hello World");',
},
{
type: "table",
content: {
type: "tableContent",
rows: [
{
cells: [["Table Cell"], ["Table Cell"], ["Table Cell"]],
},
{
cells: [["Table Cell"], ["Table Cell"], ["Table Cell"]],
},
{
cells: [["Table Cell"], ["Table Cell"], ["Table Cell"]],
},
],
},
},
{
type: "image",
props: {
name: "1280px-Placeholder_view_vector.svg.png",
url: "https://upload.wikimedia.org/wikipedia/commons/thumb/3/3f/Placeholder_view_vector.svg/1280px-Placeholder_view_vector.svg.png",
caption: "Placeholder",
showPreview: true,
previewWidth: 256,
},
},
{
type: "paragraph",
},
];
let editor: BlockNoteEditor<typeof schema.blockSchema>;
const div = document.createElement("div");
beforeEach(() => {
editor.replaceBlocks(editor.document, initialContent);
});
beforeAll(async () => {
(window as any).__TEST_OPTIONS = (window as any).__TEST_OPTIONS || {};
editor = BlockNoteEditor.create({ schema });
editor.mount(div);
await initializeESMDependencies();
});
afterAll(() => {
editor.mount(undefined);
editor._tiptapEditor.destroy();
editor = undefined as any;
delete (window as Window & { __TEST_OPTIONS?: any }).__TEST_OPTIONS;
});
// Sets the editor selection to the given start and end positions, then
// exports the selected content to HTML and compares it to a snapshot.
async function testSelection(testCase: SelectionTestCase) {
if (!editor.prosemirrorView) {
throw new Error("Editor view not initialized.");
}
editor.dispatch(
editor._tiptapEditor.state.tr.setSelection(
testCase.createCopySelection(editor.prosemirrorView.state.doc)
)
);
const { clipboardHTML, externalHTML } = selectedFragmentToHTML(
editor.prosemirrorView,
editor
);
expect(externalHTML).toMatchFileSnapshot(
`./__snapshots__/internal/${testCase.testName}.html`
);
if (testCase.createPasteSelection) {
editor.dispatch(
editor._tiptapEditor.state.tr.setSelection(
testCase.createPasteSelection(editor.prosemirrorView.state.doc)
)
);
}
const originalDocument = editor.document;
doPaste(
editor.prosemirrorView,
"text",
clipboardHTML,
false,
new ClipboardEvent("paste")
);
const newDocument = editor.document;
expect(newDocument).toStrictEqual(originalDocument);
}
const testCases: SelectionTestCase[] = [
// TODO: Consider adding test cases for nested blocks & double nested blocks.
// Copy/paste all of first heading's children.
{
testName: "multipleChildren",
createCopySelection: (doc) => TextSelection.create(doc, 16, 78),
},
// Copy/paste from start of first heading to end of its first child.
{
testName: "childToParent",
createCopySelection: (doc) => TextSelection.create(doc, 3, 34),
},
// Copy/paste from middle of first heading to the middle of its first child.
{
testName: "partialChildToParent",
createCopySelection: (doc) => TextSelection.create(doc, 6, 23),
},
// Copy/paste from start of first heading's first child to end of second
// heading's content (does not include second heading's children).
{
testName: "childrenToNextParent",
createCopySelection: (doc) => TextSelection.create(doc, 16, 93),
},
// Copy/paste from start of first heading's first child to end of second
// heading's last child.
{
testName: "childrenToNextParentsChildren",
createCopySelection: (doc) => TextSelection.create(doc, 16, 159),
},
// Copy/paste "Regular" text inside third heading.
{
testName: "unstyledText",
createCopySelection: (doc) => TextSelection.create(doc, 175, 182),
},
// Copy/paste "Italic" text inside third heading.
{
testName: "styledText",
createCopySelection: (doc) => TextSelection.create(doc, 169, 175),
},
// Copy/paste third heading's content (does not include third heading's
// children).
{
testName: "multipleStyledText",
createCopySelection: (doc) => TextSelection.create(doc, 165, 182),
},
// Copy/paste the image block content.
{
testName: "image",
createCopySelection: (doc) => NodeSelection.create(doc, 185),
},
// Copy/paste from start of third heading to end of it's last descendant.
{
testName: "nestedImage",
createCopySelection: (doc) => TextSelection.create(doc, 165, 205),
},
// Copy/paste text in first cell of the table.
{
testName: "tableCellText",
createCopySelection: (doc) => TextSelection.create(doc, 216, 226),
},
// Copy/paste first cell of the table.
// TODO: External HTML is wrapped in unnecessary `tr` element.
{
testName: "tableCell",
createCopySelection: (doc) => CellSelection.create(doc, 214),
},
// Copy/paste first row of the table.
{
testName: "tableRow",
createCopySelection: (doc) => CellSelection.create(doc, 214, 228),
},
// Copy/paste all cells of the table.
{
testName: "tableAllCells",
createCopySelection: (doc) => CellSelection.create(doc, 214, 258),
},
// Copy regular paragraph content and paste over custom block content.
{
testName: "paragraphInCustomBlock",
createCopySelection: (doc) => TextSelection.create(doc, 277, 286),
createPasteSelection: (doc) => TextSelection.create(doc, 290, 299),
},
// Copy/paste basic blocks.
{
testName: "basicBlocks",
createCopySelection: (doc) => TextSelection.create(doc, 303, 558),
},
// Copy/paste basic blocks with props.
{
testName: "basicBlocksWithProps",
createCopySelection: (doc) => TextSelection.create(doc, 558, 813),
},
];
for (const testCase of testCases) {
it(`${testCase.testName}`, async () => {
await testSelection(testCase);
});
}
});