Skip to content
Open
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
22 changes: 17 additions & 5 deletions packages/core/src/api/clipboard/toClipboard/copyExtension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,13 +27,16 @@ function fragmentToExternalHTML<
selectedFragment: Fragment,
editor: BlockNoteEditor<BSchema, I, S>,
) {
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 `<p>` 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,
Expand All @@ -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 `<pre><code>` wrapper to keep `\n` as
// literal newlines (instead of `<br>`) 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;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,17 +37,30 @@ export function serializeInlineContentExternalHTML<
editor: BlockNoteEditor<any, I, S>,
blockContent: PartialBlock<BSchema, I, S>["content"],
serializer: DOMSerializer,
options?: { document?: Document },
options?: { document?: Document; blockType?: string },
) {
let nodes: Node[];

// TODO: reuse function from nodeconversions?
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 `<br>` separators
// inside `<pre><code>` 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 {
Expand Down Expand Up @@ -262,7 +275,7 @@ function serializeBlock<
editor,
block.content as any, // TODO
serializer,
options,
{ ...options, blockType: block.type },
);

ret.contentDOM.appendChild(ic);
Expand Down
109 changes: 109 additions & 0 deletions tests/src/unit/core/clipboard/copy/codeBlockMarkdown.test.ts
Original file line number Diff line number Diff line change
@@ -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 `<pre><code>`
// wrapper, so `<br>` 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<typeof getEditor>,
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(/^<pre[^>]*><code[^>]*>/);
expect(externalHTML).toMatch(/<\/code><\/pre>$/);
expect(externalHTML).toContain('data-language="javascript"');
expect(externalHTML).toContain("language-javascript");
// Newlines stay as literal `\n` text — no `<br>` separators that would
// turn into markdown hard-breaks downstream.
expect(externalHTML).not.toContain("<br");
expect(externalHTML).toContain(codeContent);
});

it("does not insert backslashes before newlines in text/plain", () => {
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("<pre");
expect(markdown).not.toContain("```");
expect(markdown.trim()).toBe(paragraphText);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,9 @@
</select>
</div>
<pre>
<code class="bn-inline-content">const hello ='world';console.log(hello);</code>
<code class="bn-inline-content">const hello = 'world';
console.log(hello);
</code>
</pre>
</div>
</div>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,7 @@
</select>
</div>
<pre>
<code class="bn-inline-content">
<span class="ProseMirror-trailingBreak" style="display: inline-block;"></span>
</code>
<code class="bn-inline-content"><span class="ProseMirror-trailingBreak" style="display: inline-block;"></span></code>
</pre>
</div>
</div>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,5 @@
<pre data-language="javascript">
<code class="bn-inline-content language-javascript" data-language="javascript">
const hello ='world';
<br />
console.log(hello);
<br />
</code>
<code class="bn-inline-content language-javascript" data-language="javascript">const hello = 'world';
console.log(hello);
</code>
</pre>
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,10 @@ import { expect } from "vitest";

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

// Preserve `<code>` 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,
Expand All @@ -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`);
};

Expand All @@ -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`);
};

Expand Down
Loading