Skip to content

Commit 1e742e0

Browse files
fix: Copy selection starting in nested block (#870)
* Fixed external HTML/MD when on copy when selection starts in nested block * Cleaned up tests
1 parent a013605 commit 1e742e0

File tree

5 files changed

+174
-8
lines changed

5 files changed

+174
-8
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
<p class="bn-inline-content">Nested Paragraph 1</p><p class="bn-inline-content">Nested Paragraph 2</p><p class="bn-inline-content">Nested Paragraph 3</p><p class="bn-inline-content">Paragraph 2</p>
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
<p class="bn-inline-content">Nested Paragraph 1</p><p class="bn-inline-content">Nested Paragraph 2</p><p class="bn-inline-content">Nested Paragraph 3</p><p class="bn-inline-content">Paragraph 2</p><p class="bn-inline-content">Nested Paragraph 1</p><p class="bn-inline-content">Nested Paragraph 2</p><p class="bn-inline-content">Nested Paragraph 3</p>
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
<p class="bn-inline-content">Nested Paragraph 1</p><p class="bn-inline-content">Nested Paragraph 2</p><p class="bn-inline-content">Nested Paragraph 3</p>

packages/core/src/api/exporters/html/htmlConversion.test.ts

Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { TextSelection } from "prosemirror-state";
12
import { afterEach, beforeEach, describe, expect, it } from "vitest";
23
import { BlockNoteEditor } from "../../../editor/BlockNoteEditor";
34

@@ -101,3 +102,142 @@ describe("Test HTML conversion", () => {
101102
});
102103
}
103104
});
105+
106+
// Fragments created from ProseMirror selections don't always conform to the
107+
// schema. This is because ProseMirror preserves the full ancestry of selected
108+
// nodes, but not the siblings of ancestor nodes. These tests are to verify that
109+
// Fragments like this are exported to HTML properly, as they can't be created
110+
// from Block objects like all the other test cases (Block object conversions
111+
// always conform to the schema).
112+
describe("Test ProseMirror fragment edge case conversion", () => {
113+
let editor: BlockNoteEditor;
114+
const div = document.createElement("div");
115+
beforeEach(() => {
116+
editor = BlockNoteEditor.create();
117+
editor.mount(div);
118+
});
119+
120+
afterEach(() => {
121+
editor.mount(undefined);
122+
editor._tiptapEditor.destroy();
123+
editor = undefined as any;
124+
125+
delete (window as Window & { __TEST_OPTIONS?: any }).__TEST_OPTIONS;
126+
});
127+
128+
// When the selection starts in a nested block, the Fragment from it omits the
129+
// `blockContent` node of the parent `blockContainer` if it's not also
130+
// included in the selection. In the schema, `blockContainer` nodes should
131+
// contain a single `blockContent` node, so this edge case needs to be tested.
132+
describe("No block content", () => {
133+
const blocks: PartialBlock[] = [
134+
{
135+
type: "paragraph",
136+
content: "Paragraph 1",
137+
children: [
138+
{
139+
type: "paragraph",
140+
content: "Nested Paragraph 1",
141+
},
142+
{
143+
type: "paragraph",
144+
content: "Nested Paragraph 2",
145+
},
146+
{
147+
type: "paragraph",
148+
content: "Nested Paragraph 3",
149+
},
150+
],
151+
},
152+
{
153+
type: "paragraph",
154+
content: "Paragraph 2",
155+
children: [
156+
{
157+
type: "paragraph",
158+
content: "Nested Paragraph 1",
159+
},
160+
{
161+
type: "paragraph",
162+
content: "Nested Paragraph 2",
163+
},
164+
{
165+
type: "paragraph",
166+
content: "Nested Paragraph 3",
167+
},
168+
],
169+
},
170+
];
171+
172+
beforeEach(() => {
173+
editor.replaceBlocks(editor.document, blocks);
174+
});
175+
176+
it("Selection within a block's children", () => {
177+
// Selection starts and ends within the first block's children.
178+
editor.prosemirrorView.dispatch(
179+
editor._tiptapEditor.state.tr.setSelection(
180+
TextSelection.create(editor._tiptapEditor.state.doc, 18, 80)
181+
)
182+
);
183+
184+
const copiedFragment =
185+
editor._tiptapEditor.state.selection.content().content;
186+
187+
const exporter = createExternalHTMLExporter(
188+
editor._tiptapEditor.schema,
189+
editor
190+
);
191+
const externalHTML = exporter.exportProseMirrorFragment(copiedFragment);
192+
expect(externalHTML).toMatchFileSnapshot(
193+
"./__snapshots_fragment_edge_cases__/" +
194+
"selectionWithinBlockChildren.html"
195+
);
196+
});
197+
198+
it("Selection leaves a block's children", () => {
199+
// Selection starts and ends within the first block's children and ends
200+
// outside, at a shallower nesting level in the second block.
201+
editor.prosemirrorView.dispatch(
202+
editor._tiptapEditor.state.tr.setSelection(
203+
TextSelection.create(editor._tiptapEditor.state.doc, 18, 97)
204+
)
205+
);
206+
207+
const copiedFragment =
208+
editor._tiptapEditor.state.selection.content().content;
209+
210+
const exporter = createExternalHTMLExporter(
211+
editor._tiptapEditor.schema,
212+
editor
213+
);
214+
const externalHTML = exporter.exportProseMirrorFragment(copiedFragment);
215+
expect(externalHTML).toMatchFileSnapshot(
216+
"./__snapshots_fragment_edge_cases__/" +
217+
"selectionLeavesBlockChildren.html"
218+
);
219+
});
220+
221+
it("Selection spans multiple blocks' children", () => {
222+
// Selection starts and ends within the first block's children and ends
223+
// within the second block's children.
224+
editor.prosemirrorView.dispatch(
225+
editor._tiptapEditor.state.tr.setSelection(
226+
TextSelection.create(editor._tiptapEditor.state.doc, 18, 163)
227+
)
228+
);
229+
230+
const copiedFragment =
231+
editor._tiptapEditor.state.selection.content().content;
232+
const exporter = createExternalHTMLExporter(
233+
editor._tiptapEditor.schema,
234+
editor
235+
);
236+
const externalHTML = exporter.exportProseMirrorFragment(copiedFragment);
237+
expect(externalHTML).toMatchFileSnapshot(
238+
"./__snapshots_fragment_edge_cases__/" +
239+
"selectionSpansBlocksChildren.html"
240+
);
241+
});
242+
});
243+
});

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

Lines changed: 31 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -41,11 +41,34 @@ export function simplifyBlocks(options: SimplifyBlocksOptions) {
4141
for (let i = 0; i < numChildElements; i++) {
4242
const blockOuter = tree.children[i] as HASTElement;
4343
const blockContainer = blockOuter.children[0] as HASTElement;
44-
const blockContent = blockContainer.children[0] as HASTElement;
45-
const blockGroup =
46-
blockContainer.children.length === 2
47-
? (blockContainer.children[1] as HASTElement)
48-
: null;
44+
const blockContent = blockContainer.children.find((child) => {
45+
const properties = (child as HASTElement).properties;
46+
const classNames = properties?.["className"] as string[] | undefined;
47+
48+
return classNames?.includes("bn-block-content");
49+
}) as HASTElement | undefined;
50+
const blockGroup = blockContainer.children.find((child) => {
51+
const properties = (child as HASTElement).properties;
52+
const classNames = properties?.["className"] as string[] | undefined;
53+
54+
return classNames?.includes("bn-block-group");
55+
}) as HASTElement | undefined;
56+
57+
// When the selection starts in a nested block, the Fragment from it omits
58+
// the `blockContent` node of the parent `blockContainer` if it's not also
59+
// included in the selection. This is because ProseMirror preserves the
60+
// nesting hierarchy of the nested nodes, even if their ancestors aren't
61+
// fully selected. In this case, we just lift the child `blockContainer`
62+
// nodes up.
63+
// NOTE: This only happens for the first `blockContainer`, since to get to
64+
// any nested blocks later in the document, the selection must also
65+
// include their parents.
66+
if (!blockContent) {
67+
tree.children.splice(i, 1, ...blockGroup!.children);
68+
simplifyBlocksHelper(tree);
69+
70+
return;
71+
}
4972

5073
const isListItemBlock = listItemBlockTypes.has(
5174
blockContent.properties!["dataContentType"] as string
@@ -60,7 +83,7 @@ export function simplifyBlocks(options: SimplifyBlocksOptions) {
6083
: null;
6184

6285
// Plugin runs recursively to process nested blocks.
63-
if (blockGroup !== null) {
86+
if (blockGroup) {
6487
simplifyBlocksHelper(blockGroup);
6588
}
6689

@@ -101,13 +124,13 @@ export function simplifyBlocks(options: SimplifyBlocksOptions) {
101124
listItemElement.children.push(...blockContent.children);
102125
// Nested blocks have already been processed in the recursive function call, so the resulting elements are
103126
// also added to the active list.
104-
if (blockGroup !== null) {
127+
if (blockGroup) {
105128
listItemElement.children.push(...blockGroup.children);
106129
}
107130

108131
// Adds the list item representing the block to the active list.
109132
activeList.children.push(listItemElement);
110-
} else if (blockGroup !== null) {
133+
} else if (blockGroup) {
111134
// Lifts all children out of the current block, as only list items should allow nesting.
112135
tree.children.splice(i + 1, 0, ...blockGroup.children);
113136
// Replaces the block with only the content inside it.

0 commit comments

Comments
 (0)