diff --git a/packages/core/package.json b/packages/core/package.json index de1551a103..5fea095acd 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -84,7 +84,7 @@ "prosemirror-model": "^1.23.0", "prosemirror-state": "^1.4.3", "prosemirror-tables": "^1.6.1", - "prosemirror-transform": "^1.9.0", + "prosemirror-transform": "^1.10.2", "prosemirror-view": "^1.33.7", "rehype-format": "^5.0.0", "rehype-parse": "^8.0.4", diff --git a/packages/core/src/api/clipboard/__snapshots__/external/pasteEndOfParagraph.html b/packages/core/src/api/clipboard/__snapshots__/external/pasteEndOfParagraph.html new file mode 100644 index 0000000000..326207757a --- /dev/null +++ b/packages/core/src/api/clipboard/__snapshots__/external/pasteEndOfParagraph.html @@ -0,0 +1,96 @@ +[ + { + "children": [], + "content": [ + { + "styles": {}, + "text": "ParagraphParagraph", + "type": "text", + }, + ], + "id": "0", + "props": { + "backgroundColor": "default", + "textAlignment": "left", + "textColor": "default", + }, + "type": "paragraph", + }, + { + "children": [], + "content": { + "columnWidths": [ + undefined, + undefined, + ], + "rows": [ + { + "cells": [ + [ + { + "styles": {}, + "text": "Table Cell", + "type": "text", + }, + ], + [ + { + "styles": {}, + "text": "Table Cell", + "type": "text", + }, + ], + ], + }, + { + "cells": [ + [ + { + "styles": {}, + "text": "Table Cell", + "type": "text", + }, + ], + [ + { + "styles": {}, + "text": "Table Cell", + "type": "text", + }, + ], + ], + }, + ], + "type": "tableContent", + }, + "id": "1", + "props": { + "textColor": "default", + }, + "type": "table", + }, + { + "children": [], + "content": [ + { + "styles": {}, + "text": "Custom Paragraph", + "type": "text", + }, + ], + "id": "2", + "props": {}, + "type": "customParagraph", + }, + { + "children": [], + "content": [], + "id": "3", + "props": { + "backgroundColor": "default", + "textAlignment": "left", + "textColor": "default", + }, + "type": "paragraph", + }, +] \ No newline at end of file diff --git a/packages/core/src/api/clipboard/__snapshots__/external/pasteEndOfParagraphText.html b/packages/core/src/api/clipboard/__snapshots__/external/pasteEndOfParagraphText.html new file mode 100644 index 0000000000..326207757a --- /dev/null +++ b/packages/core/src/api/clipboard/__snapshots__/external/pasteEndOfParagraphText.html @@ -0,0 +1,96 @@ +[ + { + "children": [], + "content": [ + { + "styles": {}, + "text": "ParagraphParagraph", + "type": "text", + }, + ], + "id": "0", + "props": { + "backgroundColor": "default", + "textAlignment": "left", + "textColor": "default", + }, + "type": "paragraph", + }, + { + "children": [], + "content": { + "columnWidths": [ + undefined, + undefined, + ], + "rows": [ + { + "cells": [ + [ + { + "styles": {}, + "text": "Table Cell", + "type": "text", + }, + ], + [ + { + "styles": {}, + "text": "Table Cell", + "type": "text", + }, + ], + ], + }, + { + "cells": [ + [ + { + "styles": {}, + "text": "Table Cell", + "type": "text", + }, + ], + [ + { + "styles": {}, + "text": "Table Cell", + "type": "text", + }, + ], + ], + }, + ], + "type": "tableContent", + }, + "id": "1", + "props": { + "textColor": "default", + }, + "type": "table", + }, + { + "children": [], + "content": [ + { + "styles": {}, + "text": "Custom Paragraph", + "type": "text", + }, + ], + "id": "2", + "props": {}, + "type": "customParagraph", + }, + { + "children": [], + "content": [], + "id": "3", + "props": { + "backgroundColor": "default", + "textAlignment": "left", + "textColor": "default", + }, + "type": "paragraph", + }, +] \ No newline at end of file diff --git a/packages/core/src/api/clipboard/__snapshots__/external/pasteImage.html b/packages/core/src/api/clipboard/__snapshots__/external/pasteImage.html new file mode 100644 index 0000000000..5bf0ff1271 --- /dev/null +++ b/packages/core/src/api/clipboard/__snapshots__/external/pasteImage.html @@ -0,0 +1,111 @@ +[ + { + "children": [], + "content": [ + { + "styles": {}, + "text": "Paragraph", + "type": "text", + }, + ], + "id": "0", + "props": { + "backgroundColor": "default", + "textAlignment": "left", + "textColor": "default", + }, + "type": "paragraph", + }, + { + "children": [], + "content": undefined, + "id": "4", + "props": { + "backgroundColor": "default", + "caption": "", + "name": "", + "previewWidth": 512, + "showPreview": true, + "textAlignment": "left", + "url": "exampleURL", + }, + "type": "image", + }, + { + "children": [], + "content": { + "columnWidths": [ + undefined, + undefined, + ], + "rows": [ + { + "cells": [ + [ + { + "styles": {}, + "text": "Table Cell", + "type": "text", + }, + ], + [ + { + "styles": {}, + "text": "Table Cell", + "type": "text", + }, + ], + ], + }, + { + "cells": [ + [ + { + "styles": {}, + "text": "Table Cell", + "type": "text", + }, + ], + [ + { + "styles": {}, + "text": "Table Cell", + "type": "text", + }, + ], + ], + }, + ], + "type": "tableContent", + }, + "id": "1", + "props": { + "textColor": "default", + }, + "type": "table", + }, + { + "children": [], + "content": [ + { + "styles": {}, + "text": "Custom Paragraph", + "type": "text", + }, + ], + "id": "2", + "props": {}, + "type": "customParagraph", + }, + { + "children": [], + "content": [], + "id": "3", + "props": { + "backgroundColor": "default", + "textAlignment": "left", + "textColor": "default", + }, + "type": "paragraph", + }, +] \ No newline at end of file diff --git a/packages/core/src/api/clipboard/__snapshots__/external/pasteParagraphInCustomBlock.html b/packages/core/src/api/clipboard/__snapshots__/external/pasteParagraphInCustomBlock.html new file mode 100644 index 0000000000..8b979aa417 --- /dev/null +++ b/packages/core/src/api/clipboard/__snapshots__/external/pasteParagraphInCustomBlock.html @@ -0,0 +1,96 @@ +[ + { + "children": [], + "content": [ + { + "styles": {}, + "text": "Paragraph", + "type": "text", + }, + ], + "id": "0", + "props": { + "backgroundColor": "default", + "textAlignment": "left", + "textColor": "default", + }, + "type": "paragraph", + }, + { + "children": [], + "content": { + "columnWidths": [ + undefined, + undefined, + ], + "rows": [ + { + "cells": [ + [ + { + "styles": {}, + "text": "Table Cell", + "type": "text", + }, + ], + [ + { + "styles": {}, + "text": "Table Cell", + "type": "text", + }, + ], + ], + }, + { + "cells": [ + [ + { + "styles": {}, + "text": "Table Cell", + "type": "text", + }, + ], + [ + { + "styles": {}, + "text": "Table Cell", + "type": "text", + }, + ], + ], + }, + ], + "type": "tableContent", + }, + "id": "1", + "props": { + "textColor": "default", + }, + "type": "table", + }, + { + "children": [], + "content": [ + { + "styles": {}, + "text": "Paragraph", + "type": "text", + }, + ], + "id": "2", + "props": {}, + "type": "customParagraph", + }, + { + "children": [], + "content": [], + "id": "3", + "props": { + "backgroundColor": "default", + "textAlignment": "left", + "textColor": "default", + }, + "type": "paragraph", + }, +] \ No newline at end of file diff --git a/packages/core/src/api/clipboard/__snapshots__/external/pasteTable.html b/packages/core/src/api/clipboard/__snapshots__/external/pasteTable.html new file mode 100644 index 0000000000..4e9713621a --- /dev/null +++ b/packages/core/src/api/clipboard/__snapshots__/external/pasteTable.html @@ -0,0 +1,149 @@ +[ + { + "children": [], + "content": [ + { + "styles": {}, + "text": "Paragraph", + "type": "text", + }, + ], + "id": "0", + "props": { + "backgroundColor": "default", + "textAlignment": "left", + "textColor": "default", + }, + "type": "paragraph", + }, + { + "children": [], + "content": { + "columnWidths": [ + undefined, + undefined, + ], + "rows": [ + { + "cells": [ + [ + { + "styles": {}, + "text": "Cell 1", + "type": "text", + }, + ], + [ + { + "styles": {}, + "text": "Cell 2", + "type": "text", + }, + ], + ], + }, + { + "cells": [ + [ + { + "styles": {}, + "text": "Cell 3", + "type": "text", + }, + ], + [ + { + "styles": {}, + "text": "Cell 4", + "type": "text", + }, + ], + ], + }, + ], + "type": "tableContent", + }, + "id": "4", + "props": { + "textColor": "default", + }, + "type": "table", + }, + { + "children": [], + "content": { + "columnWidths": [ + undefined, + undefined, + ], + "rows": [ + { + "cells": [ + [ + { + "styles": {}, + "text": "Table Cell", + "type": "text", + }, + ], + [ + { + "styles": {}, + "text": "Table Cell", + "type": "text", + }, + ], + ], + }, + { + "cells": [ + [ + { + "styles": {}, + "text": "Table Cell", + "type": "text", + }, + ], + [ + { + "styles": {}, + "text": "Table Cell", + "type": "text", + }, + ], + ], + }, + ], + "type": "tableContent", + }, + "id": "1", + "props": { + "textColor": "default", + }, + "type": "table", + }, + { + "children": [], + "content": [ + { + "styles": {}, + "text": "Custom Paragraph", + "type": "text", + }, + ], + "id": "2", + "props": {}, + "type": "customParagraph", + }, + { + "children": [], + "content": [], + "id": "3", + "props": { + "backgroundColor": "default", + "textAlignment": "left", + "textColor": "default", + }, + "type": "paragraph", + }, +] \ No newline at end of file diff --git a/packages/core/src/api/clipboard/__snapshots__/external/pasteTableInExistingTable.html b/packages/core/src/api/clipboard/__snapshots__/external/pasteTableInExistingTable.html new file mode 100644 index 0000000000..2e6fbce5d2 --- /dev/null +++ b/packages/core/src/api/clipboard/__snapshots__/external/pasteTableInExistingTable.html @@ -0,0 +1,124 @@ +[ + { + "children": [], + "content": [ + { + "styles": {}, + "text": "Paragraph", + "type": "text", + }, + ], + "id": "0", + "props": { + "backgroundColor": "default", + "textAlignment": "left", + "textColor": "default", + }, + "type": "paragraph", + }, + { + "children": [], + "content": { + "columnWidths": [ + undefined, + undefined, + undefined, + ], + "rows": [ + { + "cells": [ + [ + { + "styles": {}, + "text": "Table Cell", + "type": "text", + }, + ], + [ + { + "styles": {}, + "text": "Table Cell", + "type": "text", + }, + ], + [], + ], + }, + { + "cells": [ + [ + { + "styles": {}, + "text": "Table Cell", + "type": "text", + }, + ], + [ + { + "styles": {}, + "text": "Cell 1", + "type": "text", + }, + ], + [ + { + "styles": {}, + "text": "Cell 2", + "type": "text", + }, + ], + ], + }, + { + "cells": [ + [], + [ + { + "styles": {}, + "text": "Cell 3", + "type": "text", + }, + ], + [ + { + "styles": {}, + "text": "Cell 4", + "type": "text", + }, + ], + ], + }, + ], + "type": "tableContent", + }, + "id": "1", + "props": { + "textColor": "default", + }, + "type": "table", + }, + { + "children": [], + "content": [ + { + "styles": {}, + "text": "Custom Paragraph", + "type": "text", + }, + ], + "id": "2", + "props": {}, + "type": "customParagraph", + }, + { + "children": [], + "content": [], + "id": "3", + "props": { + "backgroundColor": "default", + "textAlignment": "left", + "textColor": "default", + }, + "type": "paragraph", + }, +] \ No newline at end of file diff --git a/packages/core/src/api/clipboard/__snapshots__/childToParent.html b/packages/core/src/api/clipboard/__snapshots__/internal/childToParent.html similarity index 100% rename from packages/core/src/api/clipboard/__snapshots__/childToParent.html rename to packages/core/src/api/clipboard/__snapshots__/internal/childToParent.html diff --git a/packages/core/src/api/clipboard/__snapshots__/childrenToNextParent.html b/packages/core/src/api/clipboard/__snapshots__/internal/childrenToNextParent.html similarity index 100% rename from packages/core/src/api/clipboard/__snapshots__/childrenToNextParent.html rename to packages/core/src/api/clipboard/__snapshots__/internal/childrenToNextParent.html diff --git a/packages/core/src/api/clipboard/__snapshots__/childrenToNextParentsChildren.html b/packages/core/src/api/clipboard/__snapshots__/internal/childrenToNextParentsChildren.html similarity index 100% rename from packages/core/src/api/clipboard/__snapshots__/childrenToNextParentsChildren.html rename to packages/core/src/api/clipboard/__snapshots__/internal/childrenToNextParentsChildren.html diff --git a/packages/core/src/api/clipboard/__snapshots__/image.html b/packages/core/src/api/clipboard/__snapshots__/internal/image.html similarity index 100% rename from packages/core/src/api/clipboard/__snapshots__/image.html rename to packages/core/src/api/clipboard/__snapshots__/internal/image.html diff --git a/packages/core/src/api/clipboard/__snapshots__/multipleChildren.html b/packages/core/src/api/clipboard/__snapshots__/internal/multipleChildren.html similarity index 100% rename from packages/core/src/api/clipboard/__snapshots__/multipleChildren.html rename to packages/core/src/api/clipboard/__snapshots__/internal/multipleChildren.html diff --git a/packages/core/src/api/clipboard/__snapshots__/multipleStyledText.html b/packages/core/src/api/clipboard/__snapshots__/internal/multipleStyledText.html similarity index 100% rename from packages/core/src/api/clipboard/__snapshots__/multipleStyledText.html rename to packages/core/src/api/clipboard/__snapshots__/internal/multipleStyledText.html diff --git a/packages/core/src/api/clipboard/__snapshots__/nestedImage.html b/packages/core/src/api/clipboard/__snapshots__/internal/nestedImage.html similarity index 100% rename from packages/core/src/api/clipboard/__snapshots__/nestedImage.html rename to packages/core/src/api/clipboard/__snapshots__/internal/nestedImage.html diff --git a/packages/core/src/api/clipboard/__snapshots__/internal/paragraphInCustomBlock.html b/packages/core/src/api/clipboard/__snapshots__/internal/paragraphInCustomBlock.html new file mode 100644 index 0000000000..c5c4d1fc71 --- /dev/null +++ b/packages/core/src/api/clipboard/__snapshots__/internal/paragraphInCustomBlock.html @@ -0,0 +1 @@ +Paragraph \ No newline at end of file diff --git a/packages/core/src/api/clipboard/__snapshots__/partialChildToParent.html b/packages/core/src/api/clipboard/__snapshots__/internal/partialChildToParent.html similarity index 100% rename from packages/core/src/api/clipboard/__snapshots__/partialChildToParent.html rename to packages/core/src/api/clipboard/__snapshots__/internal/partialChildToParent.html diff --git a/packages/core/src/api/clipboard/__snapshots__/styledText.html b/packages/core/src/api/clipboard/__snapshots__/internal/styledText.html similarity index 100% rename from packages/core/src/api/clipboard/__snapshots__/styledText.html rename to packages/core/src/api/clipboard/__snapshots__/internal/styledText.html diff --git a/packages/core/src/api/clipboard/__snapshots__/tableAllCells.html b/packages/core/src/api/clipboard/__snapshots__/internal/tableAllCells.html similarity index 100% rename from packages/core/src/api/clipboard/__snapshots__/tableAllCells.html rename to packages/core/src/api/clipboard/__snapshots__/internal/tableAllCells.html diff --git a/packages/core/src/api/clipboard/__snapshots__/tableCell.html b/packages/core/src/api/clipboard/__snapshots__/internal/tableCell.html similarity index 100% rename from packages/core/src/api/clipboard/__snapshots__/tableCell.html rename to packages/core/src/api/clipboard/__snapshots__/internal/tableCell.html diff --git a/packages/core/src/api/clipboard/__snapshots__/tableCellText.html b/packages/core/src/api/clipboard/__snapshots__/internal/tableCellText.html similarity index 100% rename from packages/core/src/api/clipboard/__snapshots__/tableCellText.html rename to packages/core/src/api/clipboard/__snapshots__/internal/tableCellText.html diff --git a/packages/core/src/api/clipboard/__snapshots__/tableRow.html b/packages/core/src/api/clipboard/__snapshots__/internal/tableRow.html similarity index 100% rename from packages/core/src/api/clipboard/__snapshots__/tableRow.html rename to packages/core/src/api/clipboard/__snapshots__/internal/tableRow.html diff --git a/packages/core/src/api/clipboard/__snapshots__/unstyledText.html b/packages/core/src/api/clipboard/__snapshots__/internal/unstyledText.html similarity index 100% rename from packages/core/src/api/clipboard/__snapshots__/unstyledText.html rename to packages/core/src/api/clipboard/__snapshots__/internal/unstyledText.html diff --git a/packages/core/src/api/clipboard/clipboardExternal.test.ts b/packages/core/src/api/clipboard/clipboardExternal.test.ts new file mode 100644 index 0000000000..5ce02d9e56 --- /dev/null +++ b/packages/core/src/api/clipboard/clipboardExternal.test.ts @@ -0,0 +1,161 @@ +import { Node } from "prosemirror-model"; +import { Selection, TextSelection } from "prosemirror-state"; +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"; + +type SelectionTestCase = { + testName: string; + createSelection: (doc: Node) => Selection; +} & ( + | { + html: string; + } + | { + plainText: string; + } +); + +// These tests are meant to test the pasting of external HTML in the editor. +// Each test case has an HTML string to be pasted, and a selection in the editor +// to paste at. +describe("Test external clipboard HTML", () => { + const initialContent: PartialBlock[] = [ + { + type: "paragraph", + content: "Paragraph", + }, + { + type: "table", + content: { + type: "tableContent", + rows: [ + { + cells: ["Table Cell", "Table Cell"], + }, + { + cells: ["Table Cell", "Table Cell"], + }, + ], + }, + }, + { + type: "customParagraph", + content: "Custom Paragraph", + }, + { + type: "paragraph", + }, + ]; + + let editor: BlockNoteEditor; + 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 + // pastes the HTML and compares it the document after to a snapshot. + async function testPasteExternalHTML(testCase: SelectionTestCase) { + if (!editor.prosemirrorView) { + throw new Error("Editor view not initialized."); + } + + editor.dispatch( + editor._tiptapEditor.state.tr.setSelection( + testCase.createSelection(editor.prosemirrorView.state.doc) + ) + ); + + doPaste( + editor.prosemirrorView, + "plainText" in testCase ? testCase.plainText : "", + "html" in testCase ? testCase.html : "", + "plainText" in testCase, + new ClipboardEvent("paste") + ); + + expect(editor.document).toMatchFileSnapshot( + `./__snapshots__/external/${testCase.testName}.html` + ); + } + + const testCases: SelectionTestCase[] = [ + { + testName: "pasteEndOfParagraph", + createSelection: (doc) => TextSelection.create(doc, 12), + html: `

Paragraph

`, + }, + { + testName: "pasteEndOfParagraphText", + createSelection: (doc) => TextSelection.create(doc, 12), + plainText: `Paragraph`, + }, + { + testName: "pasteImage", + createSelection: (doc) => TextSelection.create(doc, 12), + html: ``, + }, + { + testName: "pasteTable", + createSelection: (doc) => TextSelection.create(doc, 12), + html: ` + + + + + + + + +
Cell 1Cell 2
Cell 3Cell 4
`, + }, + { + testName: "pasteTableInExistingTable", + createSelection: (doc) => TextSelection.create(doc, 73), + html: ` + + + + + + + + +
Cell 1Cell 2
Cell 3Cell 4
`, + }, + { + testName: "pasteParagraphInCustomBlock", + createSelection: (doc) => TextSelection.create(doc, 80, 96), + html: `

Paragraph

`, + }, + ]; + + for (const testCase of testCases) { + it(`${testCase.testName}`, async () => { + await testPasteExternalHTML(testCase); + }); + } +}); diff --git a/packages/core/src/api/clipboard/clipboard.test.ts b/packages/core/src/api/clipboard/clipboardInternal.test.ts similarity index 65% rename from packages/core/src/api/clipboard/clipboard.test.ts rename to packages/core/src/api/clipboard/clipboardInternal.test.ts index 58a9d5da36..29b5393f07 100644 --- a/packages/core/src/api/clipboard/clipboard.test.ts +++ b/packages/core/src/api/clipboard/clipboardInternal.test.ts @@ -7,11 +7,13 @@ 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; - createSelection: (doc: Node) => Selection; + createCopySelection: (doc: Node) => Selection; + createPasteSelection?: (doc: Node) => Selection; }; // These tests are meant to test the copying of user selections in the editor. @@ -19,7 +21,7 @@ type SelectionTestCase = { // 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[] = [ + const initialContent: PartialBlock[] = [ { type: "heading", props: { @@ -138,9 +140,17 @@ describe("Test ProseMirror selection clipboard HTML", () => { // }, // ], }, + { + type: "paragraph", + content: "Paragraph", + }, + { + type: "customParagraph", + content: "Paragraph", + }, ]; - let editor: BlockNoteEditor; + let editor: BlockNoteEditor; const div = document.createElement("div"); beforeEach(() => { @@ -150,7 +160,7 @@ describe("Test ProseMirror selection clipboard HTML", () => { beforeAll(async () => { (window as any).__TEST_OPTIONS = (window as any).__TEST_OPTIONS || {}; - editor = BlockNoteEditor.create(); + editor = BlockNoteEditor.create({ schema }); editor.mount(div); await initializeESMDependencies(); @@ -173,7 +183,7 @@ describe("Test ProseMirror selection clipboard HTML", () => { editor.dispatch( editor._tiptapEditor.state.tr.setSelection( - testCase.createSelection(editor.prosemirrorView.state.doc) + testCase.createCopySelection(editor.prosemirrorView.state.doc) ) ); @@ -183,9 +193,17 @@ describe("Test ProseMirror selection clipboard HTML", () => { ); expect(externalHTML).toMatchFileSnapshot( - `./__snapshots__/${testCase.testName}.html` + `./__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, @@ -201,81 +219,85 @@ describe("Test ProseMirror selection clipboard HTML", () => { const testCases: SelectionTestCase[] = [ // TODO: Consider adding test cases for nested blocks & double nested blocks. - // Selection spans all of first heading's children. + // Copy/paste all of first heading's children. { testName: "multipleChildren", - createSelection: (doc) => TextSelection.create(doc, 16, 78), + createCopySelection: (doc) => TextSelection.create(doc, 16, 78), }, - // Selection spans from start of first heading to end of its first child. + // Copy/paste from start of first heading to end of its first child. { testName: "childToParent", - createSelection: (doc) => TextSelection.create(doc, 3, 34), + createCopySelection: (doc) => TextSelection.create(doc, 3, 34), }, - // Selection spans from middle of first heading to the middle of its first - // child. + // Copy/paste from middle of first heading to the middle of its first child. { testName: "partialChildToParent", - createSelection: (doc) => TextSelection.create(doc, 6, 23), + createCopySelection: (doc) => TextSelection.create(doc, 6, 23), }, - // Selection spans from start of first heading's first child to end of - // second heading's content (does not include second heading's children). + // 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", - createSelection: (doc) => TextSelection.create(doc, 16, 93), + createCopySelection: (doc) => TextSelection.create(doc, 16, 93), }, - // Selection spans from start of first heading's first child to end of - // second heading's last child. + // Copy/paste from start of first heading's first child to end of second + // heading's last child. { testName: "childrenToNextParentsChildren", - createSelection: (doc) => TextSelection.create(doc, 16, 159), + createCopySelection: (doc) => TextSelection.create(doc, 16, 159), }, - // Selection spans "Regular" text inside third heading. + // Copy/paste "Regular" text inside third heading. { testName: "unstyledText", - createSelection: (doc) => TextSelection.create(doc, 175, 182), + createCopySelection: (doc) => TextSelection.create(doc, 175, 182), }, - // Selection spans "Italic" text inside third heading. + // Copy/paste "Italic" text inside third heading. { testName: "styledText", - createSelection: (doc) => TextSelection.create(doc, 169, 175), + createCopySelection: (doc) => TextSelection.create(doc, 169, 175), }, - // Selection spans third heading's content (does not include third heading's + // Copy/paste third heading's content (does not include third heading's // children). { testName: "multipleStyledText", - createSelection: (doc) => TextSelection.create(doc, 165, 182), + createCopySelection: (doc) => TextSelection.create(doc, 165, 182), }, - // Selection spans the image block content. + // Copy/paste the image block content. { testName: "image", - createSelection: (doc) => NodeSelection.create(doc, 185), + createCopySelection: (doc) => NodeSelection.create(doc, 185), }, - // Selection spans from start of third heading to end of it's last - // descendant. + // Copy/paste from start of third heading to end of it's last descendant. { testName: "nestedImage", - createSelection: (doc) => TextSelection.create(doc, 165, 205), + createCopySelection: (doc) => TextSelection.create(doc, 165, 205), }, - // Selection spans text in first cell of the table. + // Copy/paste text in first cell of the table. { testName: "tableCellText", - createSelection: (doc) => TextSelection.create(doc, 216, 226), + createCopySelection: (doc) => TextSelection.create(doc, 216, 226), }, - // Selection spans first cell of the table. + // Copy/paste first cell of the table. // TODO: External HTML is wrapped in unnecessary `tr` element. { testName: "tableCell", - createSelection: (doc) => CellSelection.create(doc, 214), + createCopySelection: (doc) => CellSelection.create(doc, 214), }, - // Selection spans first row of the table. + // Copy/paste first row of the table. { testName: "tableRow", - createSelection: (doc) => CellSelection.create(doc, 214, 228), + createCopySelection: (doc) => CellSelection.create(doc, 214, 228), }, - // Selection spans all cells of the table. + // Copy/paste all cells of the table. { testName: "tableAllCells", - createSelection: (doc) => CellSelection.create(doc, 214, 258), + 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), }, ]; diff --git a/packages/core/src/api/clipboard/testUtil.ts b/packages/core/src/api/clipboard/testUtil.ts new file mode 100644 index 0000000000..33bcf8305b --- /dev/null +++ b/packages/core/src/api/clipboard/testUtil.ts @@ -0,0 +1,27 @@ +import { createBlockSpec } from "../../schema/index.js"; +import { BlockNoteSchema } from "../../editor/BlockNoteSchema.js"; +import { defaultBlockSpecs } from "../../blocks/defaultBlocks.js"; + +const CustomParagraph = createBlockSpec( + { + type: "customParagraph", + content: "inline", + propSchema: {}, + }, + { + render: () => { + const customParagraph = document.createElement("p"); + + return { + dom: customParagraph, + contentDOM: customParagraph, + }; + }, + } +); +export const schema = BlockNoteSchema.create({ + blockSpecs: { + ...defaultBlockSpecs, + customParagraph: CustomParagraph as any, + }, +}); diff --git a/packages/core/src/api/parsers/markdown/__snapshots__/pasted/styled.json b/packages/core/src/api/parsers/markdown/__snapshots__/pasted/styled.json index 5d3c678201..43eeaff32d 100644 --- a/packages/core/src/api/parsers/markdown/__snapshots__/pasted/styled.json +++ b/packages/core/src/api/parsers/markdown/__snapshots__/pasted/styled.json @@ -1,6 +1,6 @@ [ { - "id": "2", + "id": "0", "type": "paragraph", "props": { "textColor": "default", @@ -48,7 +48,7 @@ "children": [] }, { - "id": "3", + "id": "2", "type": "paragraph", "props": { "textColor": "default", diff --git a/packages/core/src/editor/transformPasted.ts b/packages/core/src/editor/transformPasted.ts index b5225d6688..13017d3403 100644 --- a/packages/core/src/editor/transformPasted.ts +++ b/packages/core/src/editor/transformPasted.ts @@ -1,6 +1,8 @@ import { Fragment, Schema, Slice } from "@tiptap/pm/model"; import { EditorView } from "@tiptap/pm/view"; +import { getBlockInfoFromSelection } from "../api/getBlockInfoFromPos.js"; + // helper function to remove a child from a fragment function removeChild(node: Fragment, n: number) { const children: any[] = []; @@ -49,16 +51,25 @@ export function wrapTableRows(f: Fragment, schema: Schema) { /** * fix for https://github.com/ProseMirror/prosemirror/issues/1430#issuecomment-1822570821 * - * Without this fix, pasting two paragraphs would cause the second one to be indented in the other - * this fix wraps every element in the slice in it's own blockContainer, to prevent Prosemirror from nesting the - * elements on paste. + * This fix wraps pasted ProseMirror nodes in their own `blockContainer` nodes + * in most cases. This is to ensure that ProseMirror inserts them as separate + * blocks, which it sometimes doesn't do because it doesn't have enough context + * about the hierarchy of the pasted nodes. The issue can be seen when pasting + * e.g. an image or two consecutive paragraphs, where PM tries to nest the + * pasted block(s) when it shouldn't. * - * The exception is when we encounter blockGroups with listitems, because those actually should be nested + * However, the fix is not applied in a few cases. See `shouldApplyFix` for + * which cases are excluded. */ export function transformPasted(slice: Slice, view: EditorView) { let f = Fragment.from(slice.content); f = wrapTableRows(f, view.state.schema); + if (!shouldApplyFix(f, view)) { + // Don't apply the fix. + return new Slice(f, slice.openStart, slice.openEnd); + } + for (let i = 0; i < f.childCount; i++) { if (f.child(i).type.spec.group === "blockContent") { const content = [f.child(i)]; @@ -92,3 +103,45 @@ export function transformPasted(slice: Slice, view: EditorView) { } return new Slice(f, slice.openStart, slice.openEnd); } + +/** + * Used in `transformPasted` to check if the fix there should be applied, i.e. + * if the pasted fragment should be wrapped in a `blockContainer` node. This + * will explicitly tell ProseMirror to treat it as a separate block. + */ +function shouldApplyFix(fragment: Fragment, view: EditorView) { + const nodeHasSingleChild = fragment.childCount === 1; + const nodeHasInlineContent = + fragment.firstChild?.type.spec.content === "inline*"; + const nodeHasTableContent = + fragment.firstChild?.type.spec.content === "tableRow+"; + + if (nodeHasSingleChild) { + if (nodeHasInlineContent) { + // Case when we paste a single node with inline content, e.g. a paragraph + // or heading. We want to insert the content in-line for better UX instead + // of a separate block, so we return false. + return false; + } + + if (nodeHasTableContent) { + // Not ideal that we check selection here, as `transformPasted` is called + // for both paste and drop events. Drop events can potentially cause + // issues as they don't always happen at the current selection. + const blockInfo = getBlockInfoFromSelection(view.state); + if (blockInfo.isBlockContainer) { + const selectedBlockHasTableContent = + blockInfo.blockContent.node.type.spec.content === "tableRow+"; + + // Case for when we paste a single node with table content, i.e. a + // table. Normally, we return true as we want to ensure the table is + // inserted as a separate block. However, if the selection is in an + // existing table, we return false, as we want the content of the pasted + // table to be added to the existing one for better UX. + return !selectedBlockHasTableContent; + } + } + } + + return true; +} diff --git a/packages/core/src/schema/blocks/createSpec.ts b/packages/core/src/schema/blocks/createSpec.ts index 2ee5f87693..f6d9c4ae45 100644 --- a/packages/core/src/schema/blocks/createSpec.ts +++ b/packages/core/src/schema/blocks/createSpec.ts @@ -148,7 +148,7 @@ export function createBlockSpec< : "") as T["content"] extends "inline" ? "inline*" : "", group: "blockContent", selectable: blockConfig.isSelectable ?? true, - + isolating: true, addAttributes() { return propsToAttributes(blockConfig.propSchema); }, diff --git a/packages/react/src/schema/ReactBlockSpec.tsx b/packages/react/src/schema/ReactBlockSpec.tsx index 021c1fe7ff..05673edb44 100644 --- a/packages/react/src/schema/ReactBlockSpec.tsx +++ b/packages/react/src/schema/ReactBlockSpec.tsx @@ -121,7 +121,7 @@ export function createReactBlockSpec< : "") as T["content"] extends "inline" ? "inline*" : "", group: "blockContent", selectable: blockConfig.isSelectable ?? true, - + isolating: true, addAttributes() { return propsToAttributes(blockConfig.propSchema); },