From c628186b7ffecf374c182f847a52d72d425d6437 Mon Sep 17 00:00:00 2001 From: Sebastian Date: Tue, 6 Oct 2026 16:30:59 +0200 Subject: [PATCH 1/4] Fix saving a TipTap rich text that ends with a list Since 10.8.0, the editor added an empty bulletList after a list at the end of the content. The API rejects a list without items, so saving failed. Removing the list didn't fix it: the empty list stayed at the end of the content, where editors couldn't see or remove it. ProseMirror's default block type is the schema's first block node. TrailingNode inserts it after content that doesn't end with a text block, and an emptied document is filled with it. The textBlock node, which replaced the paragraph and heading nodes, had no priority, so it was registered after StarterKit's lists, and the default block type became bulletList (or horizontalRule in a heading-only block). The textBlock node gets the paragraph's priority, in the Admin and in the API, whose schema has to match. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../tip-tap-text-block-default-block-type.md | 10 +++ .../tipTap/createTipTapRichTextBlock.test.tsx | 65 ++++++++++++++++++- .../tipTap/createTipTapRichTextBlock.tsx | 6 +- .../blocks/tipTap/extensions/TextBlock.tsx | 4 ++ .../tipTap/createTipTapRichTextBlock.test.ts | 21 ++++++ .../tipTap/createTipTapRichTextBlock.ts | 2 +- .../src/blocks/tipTap/extensions/TextBlock.ts | 3 + 7 files changed, 106 insertions(+), 5 deletions(-) create mode 100644 .changeset/tip-tap-text-block-default-block-type.md diff --git a/.changeset/tip-tap-text-block-default-block-type.md b/.changeset/tip-tap-text-block-default-block-type.md new file mode 100644 index 00000000000..d75c27630e8 --- /dev/null +++ b/.changeset/tip-tap-text-block-default-block-type.md @@ -0,0 +1,10 @@ +--- +"@dextinity/cms-admin": patch +"@dextinity/cms-api": patch +--- + +Fix saving a TipTap rich text that ends with a list + +Since v10.8.0, the editor added an empty list after a list at the end of the content. The API rejects a list without items, so saving failed with "Validation failed". Removing the list didn't fix it: the empty list stayed at the end of the content, where editors couldn't see or remove it. + +ProseMirror fills content with the schema's first block node, for instance after a list at the end of the content or when all content is deleted. When the `textBlock` node replaced the paragraph and heading nodes, it lost the paragraph's priority, so a list became the first block node. The `textBlock` node gets the paragraph's priority again, so the editor adds an empty text block after a list, as it did before v10.8.0. diff --git a/packages/admin/cms-admin/src/blocks/tipTap/createTipTapRichTextBlock.test.tsx b/packages/admin/cms-admin/src/blocks/tipTap/createTipTapRichTextBlock.test.tsx index 90c17887578..c0f22d22737 100644 --- a/packages/admin/cms-admin/src/blocks/tipTap/createTipTapRichTextBlock.test.tsx +++ b/packages/admin/cms-admin/src/blocks/tipTap/createTipTapRichTextBlock.test.tsx @@ -1,10 +1,13 @@ -import type { JSONContent } from "@tiptap/core"; +import { Editor, type JSONContent } from "@tiptap/core"; import { describe, expect, it } from "vitest"; import { createBlockSkeleton } from "../helpers/createBlockSkeleton"; import { BlockCategory, type BlockInterface, type LinkBlockInterface } from "../types"; import { + buildTipTapExtensions, createTipTapRichTextBlock, + resolveTipTapOptions, + type TipTapRichTextBlockFactoryOptions, type TipTapRichTextBlockState, type TipTapTextBlock, type TipTapTextBlockElementProps, @@ -76,6 +79,66 @@ describe("createTipTapRichTextBlock", () => { }); }); + describe("editor", () => { + function createEditor(options: TipTapRichTextBlockFactoryOptions = {}) { + return new Editor({ + extensions: buildTipTapExtensions({ + resolvedOptions: resolveTipTapOptions(options), + inlineStyles: [], + placeholders: [], + childBlocks: {}, + }), + }); + } + + const listItem = (text: string): JSONContent => ({ + type: "listItem", + content: [{ type: "textBlock", attrs: { textBlock: "paragraph" }, content: [{ type: "text", text }] }], + }); + + it("should use the text block as the schema's default block type, not a list", () => { + const editor = createEditor(); + expect(editor.schema.topNodeType.contentMatch.defaultType?.name).toBe("textBlock"); + }); + + it.each(["orderedList", "bulletList"])("should end content ending with an %s with an empty default text block", (listType) => { + const editor = createEditor(); + + editor.commands.setContent({ type: "doc", content: [{ type: listType, content: [listItem("item")] }] }); + + expect(editor.state.doc.lastChild?.toJSON()).toEqual({ type: "textBlock", attrs: { textBlock: "paragraph" } }); + }); + + it("should leave a single empty default text block after deleting all content", () => { + const editor = createEditor(); + editor.commands.setContent({ type: "doc", content: [{ type: "orderedList", content: [listItem("item")] }] }); + + editor.commands.selectAll(); + editor.commands.deleteSelection(); + + expect(editor.getJSON()).toEqual({ type: "doc", content: [{ type: "textBlock", attrs: { textBlock: "paragraph" } }] }); + }); + + it("should fill an emptied heading-only block with the default text block", () => { + const editor = createEditor({ + textBlocks: [ + { name: "heading-1", label: "Heading 1", tag: "h1" }, + { name: "heading-2", label: "Heading 2", tag: "h2" }, + ], + defaultTextBlock: "heading-2", + }); + editor.commands.setContent({ + type: "doc", + content: [{ type: "textBlock", attrs: { textBlock: "heading-1" }, content: [{ type: "text", text: "Title" }] }], + }); + + editor.commands.selectAll(); + editor.commands.deleteSelection(); + + expect(editor.getJSON()).toEqual({ type: "doc", content: [{ type: "textBlock", attrs: { textBlock: "heading-2" } }] }); + }); + }); + describe("translateContent", () => { // The HTML round trip only needs to leave non-text data byte-for-byte identical; whether // translation itself changes the surrounding text is exercised separately below, so an diff --git a/packages/admin/cms-admin/src/blocks/tipTap/createTipTapRichTextBlock.tsx b/packages/admin/cms-admin/src/blocks/tipTap/createTipTapRichTextBlock.tsx index de44a0a028b..743597546e2 100644 --- a/packages/admin/cms-admin/src/blocks/tipTap/createTipTapRichTextBlock.tsx +++ b/packages/admin/cms-admin/src/blocks/tipTap/createTipTapRichTextBlock.tsx @@ -100,7 +100,7 @@ const defaultTextBlocks: TipTapTextBlock[] = [ })), ]; -function resolveTipTapOptions({ +export function resolveTipTapOptions({ undoRedoButtons = true, bold = true, italic = true, @@ -185,7 +185,7 @@ export interface TipTapChildBlock { display: "block" | "inline"; } -interface TipTapRichTextBlockFactoryOptions { +export interface TipTapRichTextBlockFactoryOptions { /** * Shows the undo/redo buttons in the toolbar. The keyboard shortcuts work regardless. Defaults to `true`. */ @@ -448,7 +448,7 @@ function collectLinkMarksData(content: JSONContent): unknown[] { return results; } -function buildTipTapExtensions({ +export function buildTipTapExtensions({ resolvedOptions, inlineStyles, placeholders, diff --git a/packages/admin/cms-admin/src/blocks/tipTap/extensions/TextBlock.tsx b/packages/admin/cms-admin/src/blocks/tipTap/extensions/TextBlock.tsx index ef79d23b9f9..01f315efeb4 100644 --- a/packages/admin/cms-admin/src/blocks/tipTap/extensions/TextBlock.tsx +++ b/packages/admin/cms-admin/src/blocks/tipTap/extensions/TextBlock.tsx @@ -35,6 +35,10 @@ export function createTextBlock({ return Node.create({ name: "textBlock", + // TipTap's own priority for the paragraph extension, which makes the text block the schema's + // first block node and therefore ProseMirror's default block type. Without it, a list comes + // first, and TrailingNode and an emptied document fill the content with an empty list. + priority: 1000, group: "block", content: "inline*", defining: true, diff --git a/packages/api/cms-api/src/blocks/tipTap/createTipTapRichTextBlock.test.ts b/packages/api/cms-api/src/blocks/tipTap/createTipTapRichTextBlock.test.ts index ef36a16fdb9..0518d87ef1c 100644 --- a/packages/api/cms-api/src/blocks/tipTap/createTipTapRichTextBlock.test.ts +++ b/packages/api/cms-api/src/blocks/tipTap/createTipTapRichTextBlock.test.ts @@ -1,9 +1,11 @@ +import { getSchema } from "@tiptap/core"; import { validate } from "class-validator"; import { describe, expect, it } from "vitest"; import { ExternalLinkBlock } from "../externalLink/external-link.block"; import { createLinkBlock } from "../factories/createLinkBlock"; import { + buildExtensions, createTipTapRichTextBlock, type CreateTipTapRichTextBlockOptions, resolveTipTapOptions, @@ -44,6 +46,25 @@ const onlyFeatures = (options: CreateTipTapRichTextBlockOptions = {}): CreateTip ...options, }); +describe("createTipTapRichTextBlock schema", () => { + it.each([ + ["default text blocks", {}], + ["heading-only text blocks", { textBlocks: headingOnly234 }], + ])("should use the text block as the default block type with %s, like the Admin's schema", (_name, options) => { + const schema = getSchema( + buildExtensions({ + resolvedOptions: resolveTipTapOptions(options), + inlineStyles: [], + placeholders: [], + hasBlockChildBlocks: true, + hasInlineChildBlocks: false, + }), + ); + + expect(schema.topNodeType.contentMatch.defaultType?.name).toBe("textBlock"); + }); +}); + describe("createTipTapRichTextBlock validation", () => { describe("default schema (default features)", () => { const block = createTipTapRichTextBlock({}, "TestDefault"); diff --git a/packages/api/cms-api/src/blocks/tipTap/createTipTapRichTextBlock.ts b/packages/api/cms-api/src/blocks/tipTap/createTipTapRichTextBlock.ts index 39b451d8457..8b32ae88484 100644 --- a/packages/api/cms-api/src/blocks/tipTap/createTipTapRichTextBlock.ts +++ b/packages/api/cms-api/src/blocks/tipTap/createTipTapRichTextBlock.ts @@ -248,7 +248,7 @@ export function resolveTipTapOptions({ }; } -function buildExtensions({ +export function buildExtensions({ resolvedOptions, inlineStyles, placeholders, diff --git a/packages/api/cms-api/src/blocks/tipTap/extensions/TextBlock.ts b/packages/api/cms-api/src/blocks/tipTap/extensions/TextBlock.ts index dcfb07f9b5e..1e7bc0baa02 100644 --- a/packages/api/cms-api/src/blocks/tipTap/extensions/TextBlock.ts +++ b/packages/api/cms-api/src/blocks/tipTap/extensions/TextBlock.ts @@ -18,6 +18,9 @@ export function createTextBlock({ }) { return Node.create({ name: "textBlock", + // TipTap's own priority for the paragraph extension, which makes the text block the schema's + // first block node and therefore ProseMirror's default block type, as in the Admin's schema. + priority: 1000, group: "block", content: "inline*", defining: true, From 033fc25fd755848b920d125cf1e57ee045c85cb7 Mon Sep 17 00:00:00 2001 From: Sebastian Date: Tue, 6 Oct 2026 16:40:25 +0200 Subject: [PATCH 2/4] Remove the comments on the text block's priority Co-Authored-By: Claude Opus 5.5 (1M context) --- .../admin/cms-admin/src/blocks/tipTap/extensions/TextBlock.tsx | 3 --- packages/api/cms-api/src/blocks/tipTap/extensions/TextBlock.ts | 2 -- 2 files changed, 5 deletions(-) diff --git a/packages/admin/cms-admin/src/blocks/tipTap/extensions/TextBlock.tsx b/packages/admin/cms-admin/src/blocks/tipTap/extensions/TextBlock.tsx index 01f315efeb4..8122f6f38df 100644 --- a/packages/admin/cms-admin/src/blocks/tipTap/extensions/TextBlock.tsx +++ b/packages/admin/cms-admin/src/blocks/tipTap/extensions/TextBlock.tsx @@ -35,9 +35,6 @@ export function createTextBlock({ return Node.create({ name: "textBlock", - // TipTap's own priority for the paragraph extension, which makes the text block the schema's - // first block node and therefore ProseMirror's default block type. Without it, a list comes - // first, and TrailingNode and an emptied document fill the content with an empty list. priority: 1000, group: "block", content: "inline*", diff --git a/packages/api/cms-api/src/blocks/tipTap/extensions/TextBlock.ts b/packages/api/cms-api/src/blocks/tipTap/extensions/TextBlock.ts index 1e7bc0baa02..9e608c93314 100644 --- a/packages/api/cms-api/src/blocks/tipTap/extensions/TextBlock.ts +++ b/packages/api/cms-api/src/blocks/tipTap/extensions/TextBlock.ts @@ -18,8 +18,6 @@ export function createTextBlock({ }) { return Node.create({ name: "textBlock", - // TipTap's own priority for the paragraph extension, which makes the text block the schema's - // first block node and therefore ProseMirror's default block type, as in the Admin's schema. priority: 1000, group: "block", content: "inline*", From 4ccd574a76dde2c8e223131cd81e7a5829b8fb2d Mon Sep 17 00:00:00 2001 From: Sebastian Date: Wed, 7 Oct 2026 07:59:24 +0200 Subject: [PATCH 3/4] cms-admin: Click the list item in the list text block styles story The story clicked the editor to place the caret at the end of the list item. Now that TrailingNode adds an empty text block after a list again, that click can place the caret in the text block instead, so the nested bullet list was created outside of the ordered list. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../blocks/tipTap/__stories__/TipTapRichTextBlock.stories.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/admin/cms-admin/src/blocks/tipTap/__stories__/TipTapRichTextBlock.stories.tsx b/packages/admin/cms-admin/src/blocks/tipTap/__stories__/TipTapRichTextBlock.stories.tsx index b8aac4aa594..27ba5abb3b3 100644 --- a/packages/admin/cms-admin/src/blocks/tipTap/__stories__/TipTapRichTextBlock.stories.tsx +++ b/packages/admin/cms-admin/src/blocks/tipTap/__stories__/TipTapRichTextBlock.stories.tsx @@ -713,7 +713,7 @@ export const ListTextBlockStyles: StoryObj = { await step("Switch to ordered list via keyboard shortcut", async () => { const editor = canvas.getByRole("textbox"); - await userEvent.click(editor); + await userEvent.click(within(editor).getByText("List item text")); const mod = /Mac/i.test(navigator.platform) ? "Meta" : "Control"; await userEvent.keyboard(`{${mod}>}{Shift>}7{/Shift}{/${mod}}`); @@ -777,7 +777,7 @@ export const ListTextBlockStyles: StoryObj = { await step("Nest a bullet list inside the ordered list", async () => { const editor = canvas.getByRole("textbox"); - await userEvent.click(editor); + await userEvent.click(within(editor).getByText("List item text")); await userEvent.keyboard("{Enter}Nested item{Tab}"); const mod = /Mac/i.test(navigator.platform) ? "Meta" : "Control"; From f915a41c3a03d58f5140780bc4ae5b4e99ac7392 Mon Sep 17 00:00:00 2001 From: Sebastian Date: Wed, 7 Oct 2026 08:23:08 +0200 Subject: [PATCH 4/4] cms-admin: Don't add the trailing empty text block beyond maxTextBlocks With the text block as the default block type, TrailingNode adds an empty text block after a list at the end of the content. When the list is the last block maxTextBlocks allows, that block exceeds the limit, and the limit handler can't remove it for good, because TrailingNode adds it again. The content then can't be saved. Transactions that leave the content at or over the limit now skip TrailingNode. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../tip-tap-text-block-default-block-type.md | 2 ++ packages/admin/cms-admin/package.json | 1 + .../tipTap/createTipTapRichTextBlock.test.tsx | 33 +++++++++++++++++++ .../tipTap/createTipTapRichTextBlock.tsx | 7 ++++ pnpm-lock.yaml | 3 ++ 5 files changed, 46 insertions(+) diff --git a/.changeset/tip-tap-text-block-default-block-type.md b/.changeset/tip-tap-text-block-default-block-type.md index d75c27630e8..30b10c6bdc4 100644 --- a/.changeset/tip-tap-text-block-default-block-type.md +++ b/.changeset/tip-tap-text-block-default-block-type.md @@ -8,3 +8,5 @@ Fix saving a TipTap rich text that ends with a list Since v10.8.0, the editor added an empty list after a list at the end of the content. The API rejects a list without items, so saving failed with "Validation failed". Removing the list didn't fix it: the empty list stayed at the end of the content, where editors couldn't see or remove it. ProseMirror fills content with the schema's first block node, for instance after a list at the end of the content or when all content is deleted. When the `textBlock` node replaced the paragraph and heading nodes, it lost the paragraph's priority, so a list became the first block node. The `textBlock` node gets the paragraph's priority again, so the editor adds an empty text block after a list, as it did before v10.8.0. + +With `maxTextBlocks`, the editor only adds this empty text block when the content stays within the limit. Otherwise, a list as the last allowed block was followed by a block over the limit, and the content couldn't be saved. diff --git a/packages/admin/cms-admin/package.json b/packages/admin/cms-admin/package.json index 130eb11c67f..b3edcca552c 100644 --- a/packages/admin/cms-admin/package.json +++ b/packages/admin/cms-admin/package.json @@ -53,6 +53,7 @@ "@tiptap/core": "^3.22.3", "@tiptap/extension-subscript": "^3.22.3", "@tiptap/extension-superscript": "^3.22.3", + "@tiptap/extensions": "^3.22.3", "@tiptap/pm": "^3.22.3", "@tiptap/react": "^3.22.3", "@tiptap/starter-kit": "^3.22.3", diff --git a/packages/admin/cms-admin/src/blocks/tipTap/createTipTapRichTextBlock.test.tsx b/packages/admin/cms-admin/src/blocks/tipTap/createTipTapRichTextBlock.test.tsx index c0f22d22737..4c80070c230 100644 --- a/packages/admin/cms-admin/src/blocks/tipTap/createTipTapRichTextBlock.test.tsx +++ b/packages/admin/cms-admin/src/blocks/tipTap/createTipTapRichTextBlock.test.tsx @@ -87,6 +87,7 @@ describe("createTipTapRichTextBlock", () => { inlineStyles: [], placeholders: [], childBlocks: {}, + maxTextBlocks: options.maxTextBlocks, }), }); } @@ -137,6 +138,38 @@ describe("createTipTapRichTextBlock", () => { expect(editor.getJSON()).toEqual({ type: "doc", content: [{ type: "textBlock", attrs: { textBlock: "heading-2" } }] }); }); + + const toggleList = (editor: Editor, listType: "orderedList" | "bulletList") => + listType === "orderedList" ? editor.commands.toggleOrderedList() : editor.commands.toggleBulletList(); + + const paragraph = (text: string): JSONContent => ({ + type: "textBlock", + attrs: { textBlock: "paragraph" }, + content: [{ type: "text", text }], + }); + + it.each(["orderedList", "bulletList"] as const)( + "should not add an empty text block after the %s when it would exceed maxTextBlocks", + (listType) => { + const editor = createEditor({ maxTextBlocks: 2 }); + editor.commands.setContent({ type: "doc", content: [paragraph("first"), paragraph("second")] }); + editor.commands.setTextSelection(editor.state.doc.content.size - 1); + + toggleList(editor, listType); + + expect(editor.getJSON().content?.map((node) => node.type)).toEqual(["textBlock", listType]); + }, + ); + + it.each(["orderedList", "bulletList"] as const)("should add an empty text block after the %s below maxTextBlocks", (listType) => { + const editor = createEditor({ maxTextBlocks: 2 }); + editor.commands.setContent({ type: "doc", content: [paragraph("first")] }); + editor.commands.setTextSelection(editor.state.doc.content.size - 1); + + toggleList(editor, listType); + + expect(editor.getJSON().content?.map((node) => node.type)).toEqual([listType, "textBlock"]); + }); }); describe("translateContent", () => { diff --git a/packages/admin/cms-admin/src/blocks/tipTap/createTipTapRichTextBlock.tsx b/packages/admin/cms-admin/src/blocks/tipTap/createTipTapRichTextBlock.tsx index 743597546e2..bd7359f5804 100644 --- a/packages/admin/cms-admin/src/blocks/tipTap/createTipTapRichTextBlock.tsx +++ b/packages/admin/cms-admin/src/blocks/tipTap/createTipTapRichTextBlock.tsx @@ -4,6 +4,7 @@ import { styled } from "@mui/material/styles"; import { Extension, type Extensions } from "@tiptap/core"; import Subscript from "@tiptap/extension-subscript"; import Superscript from "@tiptap/extension-superscript"; +import { skipTrailingNodeMeta } from "@tiptap/extensions"; import { EditorContent, type JSONContent, useEditor } from "@tiptap/react"; import StarterKit from "@tiptap/starter-kit"; import isEqual from "lodash.isequal"; @@ -309,6 +310,12 @@ const isCmsBlockNode = (content: JSONContent): boolean => content.type === "cmsB const createMaxTextBlocksExtension = (maxTextBlocks: number) => Extension.create({ name: "maxTextBlocks", + dispatchTransaction({ transaction, next }) { + if (transaction.doc.childCount >= maxTextBlocks) { + transaction.setMeta(skipTrailingNodeMeta, true); + } + next(transaction); + }, addKeyboardShortcuts() { return { Enter: ({ editor }) => { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6f52a0cc9f9..91aa2befa36 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1696,6 +1696,9 @@ importers: '@tiptap/extension-superscript': specifier: ^3.22.3 version: 3.22.3(@tiptap/core@3.22.3(@tiptap/pm@3.22.3))(@tiptap/pm@3.22.3) + '@tiptap/extensions': + specifier: ^3.22.3 + version: 3.22.3(@tiptap/core@3.22.3(@tiptap/pm@3.22.3))(@tiptap/pm@3.22.3) '@tiptap/pm': specifier: ^3.22.3 version: 3.22.3