Skip to content

Commit afa6358

Browse files
committed
fix(core): normalize container parsing and export
1 parent f13b2b7 commit afa6358

45 files changed

Lines changed: 421 additions & 1443 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎docs/content/docs/features/custom-schemas/container-blocks.mdx‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -206,8 +206,9 @@ Configurations are checked when the schema is created, and fail up front with a
206206
- an `allow` array naming an unknown type, or naming a regular block type (per-type filtering of regular blocks is [not supported](#restricting-children));
207207
- `children` combined with any `content` other than `"none"` or `"inline"`;
208208
- `placeable: "namedOnly"` on a regular block;
209-
- restricted child types or an explicit `min` on a titled block;
210-
- container cycles: a container that (transitively) requires a child that requires it back could never be created. An `allow` that permits regular blocks breaks the cycle, since they're always satisfiable.
209+
- restricted child types or an explicit `min` on a titled block.
210+
211+
Recursive content expressions and automatic child filling are handled by ProseMirror.
211212

212213
Documents are checked too. `initialContent` that doesn't fit the schema throws when the editor is created, rather than loading in a broken state. This matters when you change a `children` config on a schema whose documents are already saved somewhere: a stored document that no longer fits, say a `columnList` left with a single column, now fails at load. Migrate those documents before shipping the change.
213214

‎packages/core/src/api/blockManipulation/commands/moveBlocks/moveBlocks.test.ts‎

Lines changed: 36 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -7,16 +7,25 @@ import {
77
getBlockInfoFromSelection,
88
getNodeId,
99
} from "../../../getBlockInfoFromPos.js";
10-
import { setupTestEnv } from "../../setupTestEnv.js";
10+
import { getNodeById } from "../../../nodeUtil.js";
11+
import { containerSchema } from "../../containers/containers.fixture.js";
12+
import { setupTestEnv, testDocument } from "../../setupTestEnv.js";
1113
import {
1214
moveBlocksDown,
1315
moveBlocksUp,
1416
moveSelectedBlocksAndSelection,
1517
} from "./moveBlocks.js";
1618

17-
const getEditor = setupTestEnv();
19+
const getEditor = setupTestEnv<
20+
typeof containerSchema.blockSchema,
21+
typeof containerSchema.inlineContentSchema,
22+
typeof containerSchema.styleSchema
23+
>({
24+
schema: containerSchema,
25+
document: testDocument,
26+
});
1827

19-
function makeSelectionSpanContent(selectionType: "text" | "node" | "cell") {
28+
function makeSelectionSpanContent(selectionType: "text" | "cell") {
2029
const blockInfo = getEditor().transact((tr) => getBlockInfoFromSelection(tr));
2130
if (!blockInfo.hasContent) {
2231
throw new Error(
@@ -36,10 +45,6 @@ function makeSelectionSpanContent(selectionType: "text" | "node" | "cell") {
3645
),
3746
),
3847
);
39-
} else if (selectionType === "node") {
40-
editor.transact((tr) =>
41-
tr.setSelection(NodeSelection.create(tr.doc, content.beforePos)),
42-
);
4348
} else {
4449
editor.transact((tr) =>
4550
tr.setSelection(
@@ -69,19 +74,30 @@ describe("Test moveSelectedBlockAndSelection", () => {
6974
).toBeTruthy();
7075
});
7176

72-
it("Node selection", () => {
73-
getEditor().setTextCursorPosition("image-0");
74-
makeSelectionSpanContent("node");
75-
76-
moveSelectedBlocksAndSelection(getEditor(), "paragraph-0", "before");
77-
78-
const selection = getEditor().transact((tr) => tr.selection);
79-
getEditor().setTextCursorPosition("image-0");
80-
makeSelectionSpanContent("node");
81-
82-
expect(
83-
selection.eq(getEditor().transact((tr) => tr.selection)),
84-
).toBeTruthy();
77+
it.each([
78+
{ type: "image", offset: 1 },
79+
{ type: "callout", offset: 0 },
80+
] as const)("Node selection: $type", ({ type, offset }) => {
81+
const editor = getEditor();
82+
editor.insertBlocks([{ id: "selected", type }], "paragraph-1", "after");
83+
editor.transact((tr) => {
84+
const block = getNodeById("selected", tr.doc)!;
85+
tr.setSelection(
86+
NodeSelection.create(tr.doc, block.posBeforeNode + offset),
87+
);
88+
});
89+
90+
moveSelectedBlocksAndSelection(editor, "paragraph-0", "before");
91+
92+
expect(editor.document[0].id).toBe("selected");
93+
editor.transact((tr) => {
94+
const moved = getNodeById("selected", tr.doc)!;
95+
expect(
96+
tr.selection.eq(
97+
NodeSelection.create(tr.doc, moved.posBeforeNode + offset),
98+
),
99+
).toBe(true);
100+
});
85101
});
86102

87103
it("Cell selection", () => {

‎packages/core/src/api/blockManipulation/commands/moveBlocks/moveBlocks.ts‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -137,7 +137,11 @@ function updateBlockSelectionFromData(
137137
anchorBlockPos + data.headCellOffset,
138138
);
139139
} else if (data.type === "node") {
140-
selection = NodeSelection.create(tr.doc, anchorBlockPos + 1);
140+
const blockInfo = getBlockInfoAt(tr.doc, anchorBlockPos);
141+
selection = NodeSelection.create(
142+
tr.doc,
143+
blockInfo.hasContent ? blockInfo.content.beforePos : anchorBlockPos,
144+
);
141145
} else {
142146
const headBlockPos = getNodeById(data.headBlockId, tr.doc)?.posBeforeNode;
143147
if (headBlockPos === undefined) {

‎packages/core/src/api/blockManipulation/containers/containers.test.ts‎

Lines changed: 24 additions & 100 deletions
Original file line numberDiff line numberDiff line change
@@ -48,23 +48,29 @@ beforeEach(() => {
4848
});
4949

5050
describe("children insertion & filling", () => {
51-
// Regression: nothing filled a container inserted without children, so
52-
// inserting one threw a raw ProseMirror
53-
// `RangeError: Invalid content for node ...`.
54-
it("fills a container inserted without children, with real child ids", () => {
55-
expect(() =>
56-
editor.insertBlocks([{ type: "callout", id: "c-0" }], "p-1", "after"),
57-
).not.toThrow();
58-
59-
const callout = editor.getBlock("c-0")!;
60-
expect(callout.children).toHaveLength(1);
61-
expect(callout.children[0].type).toBe("paragraph");
62-
// Auto-filled nodes come from the schema with `id: null`, and the
63-
// UniqueID plugin never sees them because `insertBlocks` converts back
64-
// through `nodeToBlock` before the transaction is dispatched.
65-
expect(callout.children[0].id).toBeTruthy();
66-
expect(editor.getBlock(callout.children[0].id)).toBeDefined();
67-
});
51+
it.each([
52+
{ block: { type: "callout" }, childType: "paragraph", min: 1 },
53+
{
54+
block: { type: "callout", children: [] },
55+
childType: "paragraph",
56+
min: 1,
57+
},
58+
{ block: { type: "pair" }, childType: "paragraph", min: 2 },
59+
{ block: { type: "grid" }, childType: "gridCell", min: 2 },
60+
] satisfies { block: PartialBlock; childType: string; min: number }[])(
61+
"fills $block to its minimum with identifiable $childType children",
62+
({ block, childType, min }) => {
63+
editor.insertBlocks([{ ...block, id: "container" }], "p-1", "after");
64+
const children = editor.getBlock("container")!.children;
65+
expect(children.map((child) => child.type)).toEqual(
66+
Array(min).fill(childType),
67+
);
68+
for (const child of children) {
69+
expect(child.id).toBeTruthy();
70+
expect(editor.getBlock(child.id)).toBeDefined();
71+
}
72+
},
73+
);
6874

6975
it("does not re-fill a container round-tripped through the document", () => {
7076
editor.insertBlocks([{ type: "callout", id: "c-0" }], "p-1", "after");
@@ -79,34 +85,6 @@ describe("children insertion & filling", () => {
7985
);
8086
});
8187

82-
// Regression: `children: []` was taken at face value, building a node below
83-
// `min: 1`, and `insertBlocks` threw a raw
84-
// `Invalid content for node callout: <>` from its `node.check()`.
85-
it("fills an explicitly empty `children` array up to `min`", () => {
86-
expect(() =>
87-
editor.insertBlocks(
88-
[{ type: "callout", id: "c-0", children: [] }],
89-
"p-1",
90-
"after",
91-
),
92-
).not.toThrow();
93-
94-
const callout = editor.getBlock("c-0")!;
95-
expect(callout.children).toHaveLength(1);
96-
expect(callout.children[0].type).toBe("paragraph");
97-
expect(callout.children[0].id).toBeTruthy();
98-
});
99-
100-
it("fills a container with a higher `min` up to it", () => {
101-
editor.insertBlocks([{ type: "pair", id: "s-0" }], "p-1", "after");
102-
103-
const pair = editor.getBlock("s-0")!;
104-
expect(pair.children).toHaveLength(2);
105-
expect(pair.children.every((child) => child.type === "paragraph")).toBe(
106-
true,
107-
);
108-
});
109-
11088
it("accepts arbitrary block children, including nested containers", () => {
11189
editor.insertBlocks(
11290
[
@@ -339,6 +317,7 @@ describe("children repair", () => {
339317
"cell-b-p",
340318
"trailing",
341319
]);
320+
expect(() => editor.prosemirrorState.doc.check()).not.toThrow();
342321
});
343322

344323
// An emptied child of the container is dropped even when the container
@@ -382,45 +361,6 @@ describe("children repair", () => {
382361
const grid = editor.getBlock("g-0")!;
383362
expect(grid.children.map((cell) => cell.id)).toEqual(["cell-a", "cell-b"]);
384363
});
385-
386-
it("drops a namedOnly child emptied past its own minimum", () => {
387-
editor.replaceBlocks(editor.document, [
388-
{
389-
type: "grid",
390-
id: "g-0",
391-
children: [
392-
{
393-
type: "gridCell",
394-
id: "cell-a",
395-
children: [
396-
{ id: "cell-a-p", type: "paragraph", content: "A" },
397-
{ id: "cell-a-extra", type: "paragraph", content: "A2" },
398-
],
399-
},
400-
{
401-
type: "gridCell",
402-
id: "cell-b",
403-
children: [{ id: "cell-b-p", type: "paragraph", content: "B" }],
404-
},
405-
],
406-
},
407-
{ id: "trailing", type: "paragraph", content: "" },
408-
]);
409-
410-
// Removing both of cell A's blocks leaves the cell with no children,
411-
// which its `min: 1` forbids. The cell only exists inside the grid, so
412-
// the grid's repair drops it — and with one cell left, the grid itself
413-
// unwraps into that cell's blocks.
414-
editor.removeBlocks(["cell-a-extra"]);
415-
editor.removeBlocks(["cell-a-p"]);
416-
417-
expect(editor.getBlock("g-0")).toBeUndefined();
418-
expect(editor.document.map((block) => block.id)).toEqual([
419-
"cell-b-p",
420-
"trailing",
421-
]);
422-
expect(() => editor.prosemirrorState.doc.check()).not.toThrow();
423-
});
424364
});
425365

426366
describe("repair edge cases", () => {
@@ -565,22 +505,6 @@ describe("children selection", () => {
565505
});
566506
});
567507

568-
describe("empty container insertion", () => {
569-
// A container with a `min` above one and a restricted allow list used to
570-
// build a schema-invalid node when inserted without children, so
571-
// `node.check()` (run before the repair pass) threw a raw RangeError
572-
// instead of inserting.
573-
it("inserting a container without children does not throw", () => {
574-
editor.replaceBlocks(editor.document, [
575-
{ id: "p-0", type: "paragraph", content: "Paragraph 0" },
576-
]);
577-
578-
expect(() =>
579-
editor.insertBlocks([{ type: "grid" }] as any, "p-0", "after"),
580-
).not.toThrow();
581-
});
582-
});
583-
584508
// Every mutation that can empty a container records its ancestors with
585509
// `getAncestorContainers` and hands them to `fixContainersById`, which repairs
586510
// them deepest-first. Removing a block therefore repairs the whole chain it

‎packages/core/src/api/blockManipulation/containers/titledBlocks.test.ts‎

Lines changed: 0 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -304,20 +304,6 @@ describe("blocks that declare no children are untouched", () => {
304304
editor._tiptapEditor.destroy();
305305
});
306306

307-
it("a titled block round-trips through HTML, keeping title and body", () => {
308-
// Dragging a block, and copying one, both go through this: a titled block
309-
// that serialized like a container would come back as a paragraph.
310-
const editor = editorWith(withAlert());
311-
312-
const html = editor.blocksToFullHTML(editor.document as any);
313-
const parsed = editor.tryParseHTMLToBlocks(html);
314-
315-
expect(shape(parsed)).toBe(
316-
'paragraph"Before", alert"Title"[paragraph"One", paragraph"Two"], paragraph"After"',
317-
);
318-
editor._tiptapEditor.destroy();
319-
});
320-
321307
it("survives the clipboard round-trip that copy and drag use", () => {
322308
// Dragging a block inside the editor re-parses it from the HTML
323309
// ProseMirror serializes the dragged slice to, so a titled block whose

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

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -229,10 +229,11 @@ function serializeBlock<
229229

230230
const blockImplementation = editor.blockImplementations[block.type as any]
231231
.implementation as BlockImplementation;
232+
const blockWithDefaults = { ...block, props, children: block.children ?? [] };
232233
const ret =
233234
blockImplementation.toExternalHTML?.call(
234235
{},
235-
{ ...block, props } as any,
236+
blockWithDefaults as any,
236237
editor as any,
237238
{
238239
nestingLevel,
@@ -241,7 +242,7 @@ function serializeBlock<
241242
renderBlockToDOM(
242243
blockImplementation,
243244
editor.schema.blockSchema[block.type as any] as any,
244-
{ ...block, props },
245+
blockWithDefaults,
245246
editor,
246247
{},
247248
);

‎packages/core/src/api/getBlockInfoFromPos.ts‎

Lines changed: 7 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -610,18 +610,14 @@ export function getInsertionPos(
610610
* an empty container.
611611
*/
612612
export function getFirstLeafBlock(info: BlockInfo): BlockInfo | null {
613-
if (info.hasContent) {
614-
// Not a container: the block is its own first leaf.
615-
return info;
616-
}
617-
const children = info.children;
618-
const firstChild = children.node.firstChild;
619-
if (!firstChild) {
620-
return null;
613+
while (!info.hasContent) {
614+
const { node, childrenStart } = info.children;
615+
if (!node.firstChild) {
616+
return null;
617+
}
618+
info = getBlockInfoFromNode(node.firstChild, childrenStart);
621619
}
622-
return getFirstLeafBlock(
623-
getBlockInfoFromNode(firstChild, children.childrenStart),
624-
);
620+
return info;
625621
}
626622

627623
/**

0 commit comments

Comments
 (0)