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..30b10c6bdc4 --- /dev/null +++ b/.changeset/tip-tap-text-block-default-block-type.md @@ -0,0 +1,12 @@ +--- +"@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. + +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/__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"; 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 afd9092d978..83b1159276c 100644 --- a/packages/admin/cms-admin/src/blocks/tipTap/createTipTapRichTextBlock.test.tsx +++ b/packages/admin/cms-admin/src/blocks/tipTap/createTipTapRichTextBlock.test.tsx @@ -1,11 +1,14 @@ -import type { Editor, JSONContent } from "@tiptap/core"; +import { Editor, type JSONContent } from "@tiptap/core"; import { describe, expect, it, vi } from "vitest"; import { render } from "../../testing/test-utils"; 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, @@ -77,6 +80,99 @@ describe("createTipTapRichTextBlock", () => { }); }); + describe("editor", () => { + function createEditor(options: TipTapRichTextBlockFactoryOptions = {}) { + return new Editor({ + extensions: buildTipTapExtensions({ + resolvedOptions: resolveTipTapOptions(options), + inlineStyles: [], + placeholders: [], + childBlocks: {}, + maxTextBlocks: options.maxTextBlocks, + }), + }); + } + + 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" } }] }); + }); + + 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("maxTextBlocks", () => { const paragraph = (text: string): JSONContent => ({ type: "textBlock", diff --git a/packages/admin/cms-admin/src/blocks/tipTap/createTipTapRichTextBlock.tsx b/packages/admin/cms-admin/src/blocks/tipTap/createTipTapRichTextBlock.tsx index ae56d6d3dd0..cdea9b39ca5 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"; @@ -100,7 +101,7 @@ const defaultTextBlocks: TipTapTextBlock[] = [ })), ]; -function resolveTipTapOptions({ +export function resolveTipTapOptions({ undoRedoButtons = true, bold = true, italic = true, @@ -185,7 +186,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`. */ @@ -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 }) => { @@ -448,7 +455,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..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,6 +35,7 @@ export function createTextBlock({ return Node.create({ name: "textBlock", + 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..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,6 +18,7 @@ export function createTextBlock({ }) { return Node.create({ name: "textBlock", + priority: 1000, group: "block", content: "inline*", defining: true, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 860dfd1c6a4..afc8f80ec23 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