Skip to content

Commit b000145

Browse files
nperez0111claude
andcommitted
fix: backslash newlines when copying from a code block
When copying inline content from inside a code block, the text/plain clipboard payload had a backslash before every newline (markdown's hard-break syntax) and the text/html had `<br>` separators inside the code instead of literal newlines. Two changes fix this: - copyExtension routes selections inside a code block through the block-export path so the code block's own toExternalHTML produces the proper `<pre><code>` wrapper. - serializeInlineContentExternalHTML now plumbs blockType through to inlineContentToNodes (mirroring the internal HTML serializer) so `\n` in code-block content stays as literal text instead of being split into hardBreak nodes that render as `<br>`. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 447d9af commit b000145

7 files changed

Lines changed: 156 additions & 25 deletions

File tree

packages/core/src/api/clipboard/toClipboard/copyExtension.ts

Lines changed: 17 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -27,13 +27,16 @@ function fragmentToExternalHTML<
2727
selectedFragment: Fragment,
2828
editor: BlockNoteEditor<BSchema, I, S>,
2929
) {
30-
let isWithinBlockContent = false;
3130
const isWithinTable = view.state.selection instanceof CellSelection;
3231

32+
// Whether the selection is inline-only inside a single block whose content
33+
// can be cleanly represented as standalone HTML (i.e. not a code block).
34+
// For such "transparent" parents we strip the wrapper so pasting plain text
35+
// into another app doesn't get a `<p>` around it. For code blocks we keep
36+
// the wrapper so the block's own `toExternalHTML` runs.
37+
let isWithinBlockContent = false;
38+
3339
if (!isWithinTable) {
34-
// Checks whether block ancestry should be included when creating external
35-
// HTML. If the selection is within a block content node, the block ancestry
36-
// is excluded as we only care about the inline content.
3740
const fragmentWithoutParents = view.state.doc.slice(
3841
view.state.selection.from,
3942
view.state.selection.to,
@@ -45,13 +48,22 @@ function fragmentToExternalHTML<
4548
children.push(fragmentWithoutParents.child(i));
4649
}
4750

48-
isWithinBlockContent =
51+
const isFullyInline =
4952
children.find(
5053
(child) =>
5154
child.type.isInGroup("bnBlock") ||
5255
child.type.name === "blockGroup" ||
5356
child.type.spec.group === "blockContent",
5457
) === undefined;
58+
59+
// Only use the inline-only path when the parent block isn't a code-content
60+
// block. Code blocks need their `<pre><code>` wrapper to keep `\n` as
61+
// literal newlines (instead of `<br>`) and to make the markdown converter
62+
// emit a fenced block instead of escaping each newline as `\`.
63+
const parentIsCode =
64+
view.state.selection.$from.parent.type.spec.code === true;
65+
66+
isWithinBlockContent = isFullyInline && !parentIsCode;
5567
if (isWithinBlockContent) {
5668
selectedFragment = fragmentWithoutParents;
5769
}

packages/core/src/api/exporters/html/util/serializeBlocksExternalHTML.ts

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -37,17 +37,30 @@ export function serializeInlineContentExternalHTML<
3737
editor: BlockNoteEditor<any, I, S>,
3838
blockContent: PartialBlock<BSchema, I, S>["content"],
3939
serializer: DOMSerializer,
40-
options?: { document?: Document },
40+
options?: { document?: Document; blockType?: string },
4141
) {
4242
let nodes: Node[];
4343

4444
// TODO: reuse function from nodeconversions?
4545
if (!blockContent) {
4646
throw new Error("blockContent is required");
4747
} else if (typeof blockContent === "string") {
48-
nodes = inlineContentToNodes([blockContent], editor.pmSchema);
48+
// Pass `blockType` so `inlineContentToNodes` keeps `\n` as text for
49+
// code-content blocks instead of splitting into `hardBreak` nodes —
50+
// otherwise the exported HTML for a code block contains `<br>` separators
51+
// inside `<pre><code>` instead of literal newlines. Mirrors the internal
52+
// HTML serializer, which already plumbs this through.
53+
nodes = inlineContentToNodes(
54+
[blockContent],
55+
editor.pmSchema,
56+
options?.blockType,
57+
);
4958
} else if (Array.isArray(blockContent)) {
50-
nodes = inlineContentToNodes(blockContent, editor.pmSchema);
59+
nodes = inlineContentToNodes(
60+
blockContent,
61+
editor.pmSchema,
62+
options?.blockType,
63+
);
5164
} else if (blockContent.type === "tableContent") {
5265
nodes = tableContentToNodes(blockContent, editor.pmSchema);
5366
} else {
@@ -262,7 +275,7 @@ function serializeBlock<
262275
editor,
263276
block.content as any, // TODO
264277
serializer,
265-
options,
278+
{ ...options, blockType: block.type },
266279
);
267280

268281
ret.contentDOM.appendChild(ic);
Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
import { TextSelection } from "@tiptap/pm/state";
2+
import { describe, expect, it } from "vitest";
3+
4+
import { selectedFragmentToHTML } from "@blocknote/core";
5+
6+
import { createTestEditor } from "../../createTestEditor.js";
7+
import { testSchema } from "../../testSchema.js";
8+
import { getPosOfTextNode } from "../../../shared/testUtil.js";
9+
10+
// Regression test: copying inline content from inside a code block previously
11+
// produced a `text/plain` markdown payload where every newline was prefixed
12+
// with a backslash (markdown's hard-break syntax leaking through). The root
13+
// cause was that the external HTML for the selection lacked a `<pre><code>`
14+
// wrapper, so `<br>` separators in the inline content turned into hard
15+
// breaks when converted to markdown. Wrapping the selection as code fixes
16+
// both the HTML semantics and the markdown output.
17+
describe("Copying from inside a code block", () => {
18+
const getEditor = createTestEditor(testSchema);
19+
20+
const setupCodeBlockSelection = (
21+
editor: ReturnType<typeof getEditor>,
22+
codeContent: string,
23+
selectStart?: number,
24+
selectEnd?: number,
25+
) => {
26+
editor.replaceBlocks(editor.document, [
27+
{
28+
type: "codeBlock",
29+
props: { language: "javascript" },
30+
content: codeContent,
31+
},
32+
]);
33+
34+
editor.transact((tr) => {
35+
const startPos = getPosOfTextNode(tr.doc, codeContent);
36+
const from = startPos + (selectStart ?? 0);
37+
const to =
38+
selectEnd === undefined
39+
? getPosOfTextNode(tr.doc, codeContent, true)
40+
: startPos + selectEnd;
41+
tr.setSelection(TextSelection.create(tr.doc, from, to));
42+
});
43+
};
44+
45+
it("uses the code block's normal external HTML, with language attrs and literal newlines", () => {
46+
const editor = getEditor();
47+
const codeContent = "{\n abc: '34\n\n}";
48+
49+
setupCodeBlockSelection(editor, codeContent);
50+
51+
const { externalHTML } = selectedFragmentToHTML(
52+
editor.prosemirrorView,
53+
editor,
54+
);
55+
56+
expect(externalHTML).toMatch(/^<pre[^>]*><code[^>]*>/);
57+
expect(externalHTML).toMatch(/<\/code><\/pre>$/);
58+
expect(externalHTML).toContain('data-language="javascript"');
59+
expect(externalHTML).toContain("language-javascript");
60+
// Newlines stay as literal `\n` text — no `<br>` separators that would
61+
// turn into markdown hard-breaks downstream.
62+
expect(externalHTML).not.toContain("<br");
63+
expect(externalHTML).toContain(codeContent);
64+
});
65+
66+
it("does not insert backslashes before newlines in text/plain", () => {
67+
const editor = getEditor();
68+
const codeContent = "{\n abc: '34\n\n}";
69+
70+
setupCodeBlockSelection(editor, codeContent);
71+
72+
const { markdown } = selectedFragmentToHTML(
73+
editor.prosemirrorView,
74+
editor,
75+
);
76+
77+
expect(markdown).not.toMatch(/\\\n/);
78+
// Markdown should be a fenced code block preserving the original content.
79+
expect(markdown).toContain("```");
80+
expect(markdown).toContain(codeContent);
81+
});
82+
83+
it("does not affect copies from non-code blocks", () => {
84+
const editor = getEditor();
85+
const paragraphText = "hello world";
86+
87+
editor.replaceBlocks(editor.document, [
88+
{
89+
type: "paragraph",
90+
content: paragraphText,
91+
},
92+
]);
93+
94+
editor.transact((tr) => {
95+
const startPos = getPosOfTextNode(tr.doc, paragraphText);
96+
const endPos = getPosOfTextNode(tr.doc, paragraphText, true);
97+
tr.setSelection(TextSelection.create(tr.doc, startPos, endPos));
98+
});
99+
100+
const { externalHTML, markdown } = selectedFragmentToHTML(
101+
editor.prosemirrorView,
102+
editor,
103+
);
104+
105+
expect(externalHTML).not.toContain("<pre");
106+
expect(markdown).not.toContain("```");
107+
expect(markdown.trim()).toBe(paragraphText);
108+
});
109+
});

tests/src/unit/core/formatConversion/export/__snapshots__/blocknoteHTML/codeBlock/contains-newlines.html

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,9 @@
1313
</select>
1414
</div>
1515
<pre>
16-
<code class="bn-inline-content">const hello ='world';console.log(hello);</code>
16+
<code class="bn-inline-content">const hello = 'world';
17+
console.log(hello);
18+
</code>
1719
</pre>
1820
</div>
1921
</div>

tests/src/unit/core/formatConversion/export/__snapshots__/blocknoteHTML/codeBlock/empty.html

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,7 @@
99
</select>
1010
</div>
1111
<pre>
12-
<code class="bn-inline-content">
13-
<span class="ProseMirror-trailingBreak" style="display: inline-block;"></span>
14-
</code>
12+
<code class="bn-inline-content"><span class="ProseMirror-trailingBreak" style="display: inline-block;"></span></code>
1513
</pre>
1614
</div>
1715
</div>
Lines changed: 3 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,5 @@
11
<pre data-language="javascript">
2-
<code class="bn-inline-content language-javascript" data-language="javascript">
3-
const hello ='world';
4-
<br />
5-
console.log(hello);
6-
<br />
7-
</code>
2+
<code class="bn-inline-content language-javascript" data-language="javascript">const hello = 'world';
3+
console.log(hello);
4+
</code>
85
</pre>

tests/src/unit/shared/formatConversion/export/exportTestExecutors.ts

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,10 @@ import { expect } from "vitest";
1111

1212
import { ExportTestCase } from "./exportTestCase.js";
1313

14+
// Preserve `<code>` whitespace so code-block snapshots show actual newlines
15+
// instead of having them collapsed by the prettifier.
16+
const PRETTIFY_OPTIONS = { tag_wrap: true, ignore: ["code"] } as const;
17+
1418
export const testExportBlockNoteHTML = async <
1519
B extends BlockSchema,
1620
I extends InlineContentSchema,
@@ -24,9 +28,7 @@ export const testExportBlockNoteHTML = async <
2428
addIdsToBlocks(testCase.content);
2529

2630
await expect(
27-
prettify(await editor.blocksToFullHTML(testCase.content), {
28-
tag_wrap: true,
29-
}),
31+
prettify(await editor.blocksToFullHTML(testCase.content), PRETTIFY_OPTIONS),
3032
).toMatchFileSnapshot(`./__snapshots__/blocknoteHTML/${testCase.name}.html`);
3133
};
3234

@@ -43,9 +45,7 @@ export const testExportHTML = async <
4345
addIdsToBlocks(testCase.content);
4446

4547
await expect(
46-
prettify(await editor.blocksToHTMLLossy(testCase.content), {
47-
tag_wrap: true,
48-
}),
48+
prettify(await editor.blocksToHTMLLossy(testCase.content), PRETTIFY_OPTIONS),
4949
).toMatchFileSnapshot(`./__snapshots__/html/${testCase.name}.html`);
5050
};
5151

0 commit comments

Comments
 (0)