diff --git a/packages/core/src/api/clipboard/toClipboard/copyExtension.ts b/packages/core/src/api/clipboard/toClipboard/copyExtension.ts index 7812ae3c2e..17e1f6eeed 100644 --- a/packages/core/src/api/clipboard/toClipboard/copyExtension.ts +++ b/packages/core/src/api/clipboard/toClipboard/copyExtension.ts @@ -27,13 +27,16 @@ function fragmentToExternalHTML< selectedFragment: Fragment, editor: BlockNoteEditor, ) { - let isWithinBlockContent = false; const isWithinTable = view.state.selection instanceof CellSelection; + // Whether the selection is inline-only inside a single block whose content + // can be cleanly represented as standalone HTML (i.e. not a code block). + // For such "transparent" parents we strip the wrapper so pasting plain text + // into another app doesn't get a `

` around it. For code blocks we keep + // the wrapper so the block's own `toExternalHTML` runs. + let isWithinBlockContent = false; + if (!isWithinTable) { - // Checks whether block ancestry should be included when creating external - // HTML. If the selection is within a block content node, the block ancestry - // is excluded as we only care about the inline content. const fragmentWithoutParents = view.state.doc.slice( view.state.selection.from, view.state.selection.to, @@ -45,13 +48,22 @@ function fragmentToExternalHTML< children.push(fragmentWithoutParents.child(i)); } - isWithinBlockContent = + const isFullyInline = children.find( (child) => child.type.isInGroup("bnBlock") || child.type.name === "blockGroup" || child.type.spec.group === "blockContent", ) === undefined; + + // Only use the inline-only path when the parent block isn't a code-content + // block. Code blocks need their `

` wrapper to keep `\n` as
+    // literal newlines (instead of `
`) and to make the markdown converter + // emit a fenced block instead of escaping each newline as `\`. + const parentIsCode = + view.state.selection.$from.parent.type.spec.code === true; + + isWithinBlockContent = isFullyInline && !parentIsCode; if (isWithinBlockContent) { selectedFragment = fragmentWithoutParents; } diff --git a/packages/core/src/api/exporters/html/util/serializeBlocksExternalHTML.ts b/packages/core/src/api/exporters/html/util/serializeBlocksExternalHTML.ts index 894c0f1c1b..a24f6fb77b 100644 --- a/packages/core/src/api/exporters/html/util/serializeBlocksExternalHTML.ts +++ b/packages/core/src/api/exporters/html/util/serializeBlocksExternalHTML.ts @@ -37,7 +37,7 @@ export function serializeInlineContentExternalHTML< editor: BlockNoteEditor, blockContent: PartialBlock["content"], serializer: DOMSerializer, - options?: { document?: Document }, + options?: { document?: Document; blockType?: string }, ) { let nodes: Node[]; @@ -45,9 +45,22 @@ export function serializeInlineContentExternalHTML< if (!blockContent) { throw new Error("blockContent is required"); } else if (typeof blockContent === "string") { - nodes = inlineContentToNodes([blockContent], editor.pmSchema); + // Pass `blockType` so `inlineContentToNodes` keeps `\n` as text for + // code-content blocks instead of splitting into `hardBreak` nodes — + // otherwise the exported HTML for a code block contains `
` separators + // inside `
` instead of literal newlines. Mirrors the internal
+    // HTML serializer, which already plumbs this through.
+    nodes = inlineContentToNodes(
+      [blockContent],
+      editor.pmSchema,
+      options?.blockType,
+    );
   } else if (Array.isArray(blockContent)) {
-    nodes = inlineContentToNodes(blockContent, editor.pmSchema);
+    nodes = inlineContentToNodes(
+      blockContent,
+      editor.pmSchema,
+      options?.blockType,
+    );
   } else if (blockContent.type === "tableContent") {
     nodes = tableContentToNodes(blockContent, editor.pmSchema);
   } else {
@@ -262,7 +275,7 @@ function serializeBlock<
       editor,
       block.content as any, // TODO
       serializer,
-      options,
+      { ...options, blockType: block.type },
     );
 
     ret.contentDOM.appendChild(ic);
diff --git a/tests/src/unit/core/clipboard/copy/codeBlockMarkdown.test.ts b/tests/src/unit/core/clipboard/copy/codeBlockMarkdown.test.ts
new file mode 100644
index 0000000000..8ce759c2c9
--- /dev/null
+++ b/tests/src/unit/core/clipboard/copy/codeBlockMarkdown.test.ts
@@ -0,0 +1,109 @@
+import { TextSelection } from "@tiptap/pm/state";
+import { describe, expect, it } from "vitest";
+
+import { selectedFragmentToHTML } from "@blocknote/core";
+
+import { createTestEditor } from "../../createTestEditor.js";
+import { testSchema } from "../../testSchema.js";
+import { getPosOfTextNode } from "../../../shared/testUtil.js";
+
+// Regression test: copying inline content from inside a code block previously
+// produced a `text/plain` markdown payload where every newline was prefixed
+// with a backslash (markdown's hard-break syntax leaking through). The root
+// cause was that the external HTML for the selection lacked a `
`
+// wrapper, so `
` separators in the inline content turned into hard +// breaks when converted to markdown. Wrapping the selection as code fixes +// both the HTML semantics and the markdown output. +describe("Copying from inside a code block", () => { + const getEditor = createTestEditor(testSchema); + + const setupCodeBlockSelection = ( + editor: ReturnType, + codeContent: string, + selectStart?: number, + selectEnd?: number, + ) => { + editor.replaceBlocks(editor.document, [ + { + type: "codeBlock", + props: { language: "javascript" }, + content: codeContent, + }, + ]); + + editor.transact((tr) => { + const startPos = getPosOfTextNode(tr.doc, codeContent); + const from = startPos + (selectStart ?? 0); + const to = + selectEnd === undefined + ? getPosOfTextNode(tr.doc, codeContent, true) + : startPos + selectEnd; + tr.setSelection(TextSelection.create(tr.doc, from, to)); + }); + }; + + it("uses the code block's normal external HTML, with language attrs and literal newlines", () => { + const editor = getEditor(); + const codeContent = "{\n abc: '34\n\n}"; + + setupCodeBlockSelection(editor, codeContent); + + const { externalHTML } = selectedFragmentToHTML( + editor.prosemirrorView, + editor, + ); + + expect(externalHTML).toMatch(/^]*>]*>/); + expect(externalHTML).toMatch(/<\/code><\/pre>$/); + expect(externalHTML).toContain('data-language="javascript"'); + expect(externalHTML).toContain("language-javascript"); + // Newlines stay as literal `\n` text — no `
` separators that would + // turn into markdown hard-breaks downstream. + expect(externalHTML).not.toContain(" { + const editor = getEditor(); + const codeContent = "{\n abc: '34\n\n}"; + + setupCodeBlockSelection(editor, codeContent); + + const { markdown } = selectedFragmentToHTML( + editor.prosemirrorView, + editor, + ); + + expect(markdown).not.toMatch(/\\\n/); + // Markdown should be a fenced code block preserving the original content. + expect(markdown).toContain("```"); + expect(markdown).toContain(codeContent); + }); + + it("does not affect copies from non-code blocks", () => { + const editor = getEditor(); + const paragraphText = "hello world"; + + editor.replaceBlocks(editor.document, [ + { + type: "paragraph", + content: paragraphText, + }, + ]); + + editor.transact((tr) => { + const startPos = getPosOfTextNode(tr.doc, paragraphText); + const endPos = getPosOfTextNode(tr.doc, paragraphText, true); + tr.setSelection(TextSelection.create(tr.doc, startPos, endPos)); + }); + + const { externalHTML, markdown } = selectedFragmentToHTML( + editor.prosemirrorView, + editor, + ); + + expect(externalHTML).not.toContain("
-          const hello ='world';console.log(hello);
+          const hello = 'world';
+console.log(hello);
+
         
diff --git a/tests/src/unit/core/formatConversion/export/__snapshots__/blocknoteHTML/codeBlock/empty.html b/tests/src/unit/core/formatConversion/export/__snapshots__/blocknoteHTML/codeBlock/empty.html index c25f830ff4..ce97dbaaac 100644 --- a/tests/src/unit/core/formatConversion/export/__snapshots__/blocknoteHTML/codeBlock/empty.html +++ b/tests/src/unit/core/formatConversion/export/__snapshots__/blocknoteHTML/codeBlock/empty.html @@ -9,9 +9,7 @@
-          
-            
-          
+          
         
diff --git a/tests/src/unit/core/formatConversion/export/__snapshots__/html/codeBlock/contains-newlines.html b/tests/src/unit/core/formatConversion/export/__snapshots__/html/codeBlock/contains-newlines.html index df88fa0937..a7db81b06b 100644 --- a/tests/src/unit/core/formatConversion/export/__snapshots__/html/codeBlock/contains-newlines.html +++ b/tests/src/unit/core/formatConversion/export/__snapshots__/html/codeBlock/contains-newlines.html @@ -1,8 +1,5 @@
-  
-    const hello ='world';
-    
- console.log(hello); -
-
+ const hello = 'world'; +console.log(hello); +
\ No newline at end of file diff --git a/tests/src/unit/shared/formatConversion/export/exportTestExecutors.ts b/tests/src/unit/shared/formatConversion/export/exportTestExecutors.ts index e7ccef6e5b..1a8893b2b8 100644 --- a/tests/src/unit/shared/formatConversion/export/exportTestExecutors.ts +++ b/tests/src/unit/shared/formatConversion/export/exportTestExecutors.ts @@ -11,6 +11,10 @@ import { expect } from "vitest"; import { ExportTestCase } from "./exportTestCase.js"; +// Preserve `` whitespace so code-block snapshots show actual newlines +// instead of having them collapsed by the prettifier. +const PRETTIFY_OPTIONS = { tag_wrap: true, ignore: ["code"] }; + export const testExportBlockNoteHTML = async < B extends BlockSchema, I extends InlineContentSchema, @@ -24,9 +28,7 @@ export const testExportBlockNoteHTML = async < addIdsToBlocks(testCase.content); await expect( - prettify(await editor.blocksToFullHTML(testCase.content), { - tag_wrap: true, - }), + prettify(await editor.blocksToFullHTML(testCase.content), PRETTIFY_OPTIONS), ).toMatchFileSnapshot(`./__snapshots__/blocknoteHTML/${testCase.name}.html`); }; @@ -43,9 +45,7 @@ export const testExportHTML = async < addIdsToBlocks(testCase.content); await expect( - prettify(await editor.blocksToHTMLLossy(testCase.content), { - tag_wrap: true, - }), + prettify(await editor.blocksToHTMLLossy(testCase.content), PRETTIFY_OPTIONS), ).toMatchFileSnapshot(`./__snapshots__/html/${testCase.name}.html`); };