From 4f934bae5b365eab5102b4769f5134084de6289e Mon Sep 17 00:00:00 2001 From: Andreas Kamm Date: Wed, 16 Sep 2026 15:18:36 +0200 Subject: [PATCH 1/7] Add defaultStyle to a TipTap text block The styling select always offered a "Default" entry standing for "no style", even where a design has no unstyled variant. A text block with a defaultStyle drops that entry, so every paragraph or heading of that type carries one of its styles. The editor creates text blocks in several ways that don't go through the toolbar, so a ProseMirror plugin fills the style in for any node that has none, rather than reimplementing Enter, the keyboard shortcuts and paste one by one. It runs only on a document change, so opening older content leaves it untouched. Co-Authored-By: Claude Opus 5 (1M context) --- .changeset/tiptap-default-text-block-style.md | 27 ++++++ .../src/blocks/tipTap/TipTapToolbar.tsx | 65 ++++++++++--- .../TipTapRichTextBlock.stories.tsx | 91 +++++++++++++++++++ .../tipTap/createTipTapRichTextBlock.test.tsx | 19 ++++ .../tipTap/createTipTapRichTextBlock.tsx | 14 ++- .../extensions/DefaultTextBlockStyle.ts | 65 +++++++++++++ .../cms-admin/src/blocks/tipTap/textBlocks.ts | 58 ++++++++---- .../tipTap/createTipTapRichTextBlock.test.ts | 11 ++- .../migrations/convertDraftJsToTipTap.ts | 23 +++-- .../cms-api/src/blocks/tipTap/textBlocks.ts | 55 +++++++---- 10 files changed, 365 insertions(+), 63 deletions(-) create mode 100644 .changeset/tiptap-default-text-block-style.md create mode 100644 packages/admin/cms-admin/src/blocks/tipTap/extensions/DefaultTextBlockStyle.ts diff --git a/.changeset/tiptap-default-text-block-style.md b/.changeset/tiptap-default-text-block-style.md new file mode 100644 index 00000000000..6dbf767abef --- /dev/null +++ b/.changeset/tiptap-default-text-block-style.md @@ -0,0 +1,27 @@ +--- +"@dextinity/cms-admin": minor +"@dextinity/cms-api": minor +--- + +TipTap Rich Text Block: add `defaultStyle` to a text block + +The styling select always offered a "Default" entry standing for "no style", even where a design has no unstyled variant and every paragraph or heading is meant to carry one of the configured styles. + +A text block (or list) with a `defaultStyle` has no such state: the select drops its "Default" entry, and the style is applied to new content, to a text block the editor converts through the type select, and to any text block the editor creates without one — pressing Enter at the end of a text block, the `Mod-Alt-` shortcuts, or pasting. Switching the type keeps a style the new text block also offers and falls back to its `defaultStyle` otherwise. + +Because the default sits on the text block rather than on a shared tag, two text blocks with the same tag can have different defaults, and a text block without one keeps the "Default" entry next to text blocks that have one. + +**Example** + +```tsx +createTipTapRichTextBlock({ + textBlocks: [ + { name: "heading-1", label: "Heading 1", tag: "h1", styles: headlineStyles, defaultStyle: "headline300" }, + { name: "heading-2", label: "Heading 2", tag: "h2", styles: headlineStyles, defaultStyle: "headline400" }, + ], +}); +``` + +`defaultStyle` must be one of the text block's `styles`, otherwise an error is thrown. It only exists next to `styles`, not next to a text block's own `element`. + +Content written before a `defaultStyle` was configured carries no style, and the editor fills it in on the first edit rather than when the document is opened, so opening a document does not mark it as changed. diff --git a/packages/admin/cms-admin/src/blocks/tipTap/TipTapToolbar.tsx b/packages/admin/cms-admin/src/blocks/tipTap/TipTapToolbar.tsx index 213b6e6cc14..f2e69fd60f5 100644 --- a/packages/admin/cms-admin/src/blocks/tipTap/TipTapToolbar.tsx +++ b/packages/admin/cms-admin/src/blocks/tipTap/TipTapToolbar.tsx @@ -43,7 +43,17 @@ import type { BlockInterface, BlockState, LinkBlockInterface } from "../types"; import type { TipTapChildBlock, TipTapInlineStyle, TipTapPlaceholder, TipTapResolvedOptions } from "./createTipTapRichTextBlock"; import { findListNodeType } from "./findListNodeType"; import { liftOutOfList } from "./liftOutOfList"; -import { findTextBlock, getStyledNodes, isTextBlockAllowedInListItem, orderedListName, unorderedListName } from "./textBlocks"; +import { + findTextBlock, + getStyledNodes, + hasStyle, + isTextBlockAllowedInListItem, + orderedListName, + type TipTapResolvedList, + type TipTapResolvedStyledNode, + type TipTapResolvedTextBlock, + unorderedListName, +} from "./textBlocks"; import { TipTapBlockDialog } from "./TipTapBlockDialog"; import { TipTapLinkDialog } from "./TipTapLinkDialog"; @@ -133,6 +143,13 @@ const toolbarSlotSx = { const ToolbarGroup = ({ children }: { children: ReactNode }) => {children}; +/** + * Keeps the applied style if the text block (or list) offers it, and falls back to its default style + * otherwise. + */ +const resolveStyle = (styledNode: TipTapResolvedStyledNode, activeStyle: string | null): string | null => + activeStyle !== null && hasStyle(styledNode, activeStyle) ? activeStyle : styledNode.defaultStyle; + const selectFormControlSx = { [`& .${inputBaseClasses.root}`]: { backgroundColor: "transparent", @@ -277,7 +294,11 @@ export const TipTapToolbar = ({ setTimeout(() => editor.commands.focus(), 0); }; - const applicableTextBlockStyles = styledNodes.find((styledNode) => styledNode.name === editorState.activeStyledNode)?.styles ?? []; + const activeStyledNode = styledNodes.find((styledNode) => styledNode.name === editorState.activeStyledNode); + const applicableTextBlockStyles = activeStyledNode?.styles ?? []; + // A configured default style means every text block of that type has one, so the styling select + // drops its "Default" entry and the choice becomes mandatory. + const activeDefaultStyle = activeStyledNode?.defaultStyle ?? null; const applicableInlineStyles = inlineStyles.filter((style) => !style.appliesTo || style.appliesTo.includes(editorState.activeStyledNode)); // Without bold/italic/underline/strike buttons to fold behind it, a "..." menu just for superscript/subscript/inline // styles adds an extra click for no space savings, so show them as individual buttons instead @@ -327,8 +348,10 @@ export const TipTapToolbar = ({ })), ]; + const activeStyle = editorState.activeTextBlockStyle || null; + const handleTextBlockChange = (e: SelectChangeEvent) => { - const textBlock = textBlocks.find((candidate) => candidate.name === e.target.value); + const textBlock = textBlocks.find((candidate: TipTapResolvedTextBlock) => candidate.name === e.target.value); if (!textBlock) { return; } @@ -340,12 +363,26 @@ export const TipTapToolbar = ({ } // Switching the type only renames the node's text block - the tag follows from the configuration. - editor.chain().focus().updateAttributes("textBlock", { textBlock: textBlock.name }).run(); + editor + .chain() + .focus() + .updateAttributes("textBlock", { textBlock: textBlock.name, textBlockStyle: resolveStyle(textBlock, activeStyle) }) + .run(); + }; + + // Toggling a list hands the cursor's text block to the list or back, so the style of whichever + // now holds it applies. + const handleListToggle = (list: TipTapResolvedList) => { + const wasActive = editor.isActive(list.tag === "ol" ? "orderedList" : "bulletList"); + const chain = editor.chain().focus(); + (list.tag === "ol" ? chain.toggleOrderedList() : chain.toggleBulletList()).run(); - // Clear a textBlockStyle the new text block doesn't offer - const { activeTextBlockStyle } = editorState; - if (activeTextBlockStyle && !textBlock.styles.some((style) => style.name === activeTextBlockStyle)) { - editor.chain().updateAttributes("textBlock", { textBlockStyle: null }).run(); + const styledNode = wasActive ? textBlocks.find((textBlock) => textBlock.name === editorState.activeTextBlock) : list; + if (styledNode) { + editor + .chain() + .updateAttributes("textBlock", { textBlockStyle: resolveStyle(styledNode, activeStyle) }) + .run(); } }; @@ -420,9 +457,11 @@ export const TipTapToolbar = ({ MenuProps={{ elevation: 1 }} sx={selectSx} > - - - + {activeDefaultStyle === null && ( + + + + )} {applicableTextBlockStyles.map((style) => ( {style.label} @@ -555,7 +594,7 @@ export const TipTapToolbar = ({ icon={RteOl} tooltip={} isActive="orderedList" - onToggle={() => editor.chain().focus().toggleOrderedList().run()} + onToggle={() => handleListToggle(resolvedOptions.orderedList as TipTapResolvedList)} /> )} {resolvedOptions.unorderedList && ( @@ -564,7 +603,7 @@ export const TipTapToolbar = ({ icon={RteUl} tooltip={} isActive="bulletList" - onToggle={() => editor.chain().focus().toggleBulletList().run()} + onToggle={() => handleListToggle(resolvedOptions.unorderedList as TipTapResolvedList)} /> )} = { }); }, }; + +// "Heading 1" has a default style, so its styling select offers no "Default" and always holds one of +// its styles. "Heading 2" shares the same styles without a default, so it keeps the "Default" entry. +const DefaultTextBlockStyleBlock = createTipTapRichTextBlock({ + undoRedoButtons: false, + textBlocks: [ + { name: "paragraph", label: "Paragraph", tag: "p", styles: [introStyle, highlightStyle], defaultStyle: "intro" }, + { name: "heading-1", label: "Heading 1", tag: "h1", styles: [largeHeadingStyle, chapterHeadingStyle], defaultStyle: "large-heading" }, + { name: "heading-2", label: "Heading 2", tag: "h2", styles: [largeHeadingStyle, chapterHeadingStyle] }, + ], + orderedList: false, + unorderedList: false, +}); + +function DefaultTextBlockStyleStory() { + const [state, setState] = useState(DefaultTextBlockStyleBlock.defaultValues()); + + return ( + + + + ); +} + +export const DefaultTextBlockStyle: StoryObj = { + render: () => , + play: async ({ canvas, userEvent, step }) => { + await step("New content starts with the default text block's default style", async () => { + await waitFor( + () => { + expect(canvas.getAllByRole("combobox")[1]).toHaveTextContent("Intro Text"); + }, + { timeout: 5000 }, + ); + }); + + await step("A text block with a default style offers no Default entry", async () => { + await userEvent.click(canvas.getAllByRole("combobox")[1]); + + await waitFor( + () => { + expect(within(document.body).getByRole("option", { name: "Intro Text" })).toBeInTheDocument(); + expect(within(document.body).queryByRole("option", { name: "Default" })).toBeNull(); + }, + { timeout: 3000 }, + ); + await userEvent.keyboard("{Escape}"); + }); + + await step("Switching to Heading 1 applies its own default style", async () => { + await userEvent.click(canvas.getAllByRole("combobox")[0]); + await userEvent.click(within(document.body).getByRole("option", { name: "Heading 1" })); + + await waitFor( + () => { + expect(canvas.getAllByRole("combobox")[1]).toHaveTextContent("Large Heading"); + }, + { timeout: 3000 }, + ); + }); + + await step("Heading 2 shares the styles but keeps the Default entry, having no default style", async () => { + await userEvent.click(canvas.getAllByRole("combobox")[0]); + await userEvent.click(within(document.body).getByRole("option", { name: "Heading 2" })); + + // "Large Heading" is offered by both, so switching keeps it instead of resetting. + await waitFor( + () => { + expect(canvas.getAllByRole("combobox")[1]).toHaveTextContent("Large Heading"); + }, + { timeout: 3000 }, + ); + + await userEvent.click(canvas.getAllByRole("combobox")[1]); + await waitFor( + () => { + expect(within(document.body).getByRole("option", { name: "Default" })).toBeInTheDocument(); + }, + { timeout: 3000 }, + ); + await userEvent.click(within(document.body).getByRole("option", { name: "Default" })); + + await waitFor( + () => { + expect(canvas.getAllByRole("combobox")[1]).toHaveTextContent("Default"); + }, + { timeout: 3000 }, + ); + }); + }, +}; 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..b9130ed0605 100644 --- a/packages/admin/cms-admin/src/blocks/tipTap/createTipTapRichTextBlock.test.tsx +++ b/packages/admin/cms-admin/src/blocks/tipTap/createTipTapRichTextBlock.test.tsx @@ -38,6 +38,25 @@ describe("createTipTapRichTextBlock", () => { expect(() => createTipTapRichTextBlock({ orderedList: { styles: [style, style] } })).toThrow(); }); + it("should throw when the defaultStyle is not one of the text block's styles", () => { + const style = { name: "copy100", label: "Copy 100", element: (props: TipTapTextBlockElementProps) =>

}; + expect(() => + createTipTapRichTextBlock({ + textBlocks: [{ name: "paragraph", label: "Paragraph", tag: "p", styles: [style], defaultStyle: "copy200" }], + }), + ).toThrow(); + }); + + it("should start content with the default text block's default style", () => { + const style = { name: "copy100", label: "Copy 100", element: (props: TipTapTextBlockElementProps) =>

}; + const block = createTipTapRichTextBlock({ + textBlocks: [{ name: "paragraph", label: "Paragraph", tag: "p", styles: [style], defaultStyle: "copy100" }], + }); + expect(block.defaultValues()).toEqual({ + tipTapContent: { type: "doc", content: [{ type: "textBlock", attrs: { textBlock: "paragraph", textBlockStyle: "copy100" } }] }, + }); + }); + it("should throw when the defaultTextBlock is not one of the text blocks", () => { expect(() => createTipTapRichTextBlock({ textBlocks: [{ name: "paragraph", label: "Paragraph", tag: "p" }], defaultTextBlock: "heading-1" }), diff --git a/packages/admin/cms-admin/src/blocks/tipTap/createTipTapRichTextBlock.tsx b/packages/admin/cms-admin/src/blocks/tipTap/createTipTapRichTextBlock.tsx index de44a0a028b..9f30ea29b40 100644 --- a/packages/admin/cms-admin/src/blocks/tipTap/createTipTapRichTextBlock.tsx +++ b/packages/admin/cms-admin/src/blocks/tipTap/createTipTapRichTextBlock.tsx @@ -25,6 +25,7 @@ import { ChildBlocksContext } from "./ChildBlocksContext"; import { translateTipTapContent } from "./contentTranslation"; import { CmsBlock, CmsInlineBlock } from "./extensions/CmsBlock"; import { CmsLink } from "./extensions/CmsLink"; +import { createDefaultTextBlockStyle } from "./extensions/DefaultTextBlockStyle"; import { InlineStyleMark } from "./extensions/InlineStyleMark"; import { NonBreakingSpace } from "./extensions/NonBreakingSpace"; import { Placeholder } from "./extensions/Placeholder"; @@ -299,10 +300,14 @@ function getPlainTextFromContent(content: JSONContent): string { return text; } -const buildEmptyContent = ({ defaultTextBlock }: TipTapResolvedOptions): JSONContent => ({ - type: "doc", - content: [{ type: "textBlock", attrs: { textBlock: defaultTextBlock.name } }], -}); +const buildEmptyContent = ({ defaultTextBlock }: TipTapResolvedOptions): JSONContent => { + const attrs: JSONContent["attrs"] = { textBlock: defaultTextBlock.name }; + if (defaultTextBlock.defaultStyle !== null) { + attrs.textBlockStyle = defaultTextBlock.defaultStyle; + } + + return { type: "doc", content: [{ type: "textBlock", attrs }] }; +}; const isCmsBlockNode = (content: JSONContent): boolean => content.type === "cmsBlock" || content.type === "cmsInlineBlock"; @@ -502,6 +507,7 @@ function buildTipTapExtensions({ }), createTextBlock({ ...resolvedOptions, hasTextBlockStyles: hasStyles, styled }), ...(hasParagraph ? [TextBlockListItem] : []), + ...(styledNodes.some((styledNode) => styledNode.defaultStyle !== null) ? [createDefaultTextBlockStyle(resolvedOptions)] : []), ...(hasInlineStyles ? [InlineStyleMark] : []), ...(resolvedOptions.sup ? [Superscript] : []), ...(resolvedOptions.sub ? [Subscript] : []), diff --git a/packages/admin/cms-admin/src/blocks/tipTap/extensions/DefaultTextBlockStyle.ts b/packages/admin/cms-admin/src/blocks/tipTap/extensions/DefaultTextBlockStyle.ts new file mode 100644 index 00000000000..744a0332e4b --- /dev/null +++ b/packages/admin/cms-admin/src/blocks/tipTap/extensions/DefaultTextBlockStyle.ts @@ -0,0 +1,65 @@ +import { Extension } from "@tiptap/core"; +import { Plugin, PluginKey } from "@tiptap/pm/state"; + +import { findListNodeType } from "../findListNodeType"; +import { findTextBlock, type TipTapResolvedList, type TipTapResolvedStyledNode, type TipTapResolvedTextBlock } from "../textBlocks"; + +/** + * Applies a text block's (or a list's) `defaultStyle` to every text block that carries no style yet. + * The editor creates such nodes in several ways that don't go through the toolbar - pressing Enter + * at the end of a text block, the keyboard shortcuts, pasting - so filling the style in here keeps + * them all consistent without reimplementing each of those. + * + * Only runs on an actual document change, so opening content written before a `defaultStyle` was + * configured leaves it untouched until it is edited. + */ +export function createDefaultTextBlockStyle({ + textBlocks, + orderedList, + unorderedList, +}: { + textBlocks: TipTapResolvedTextBlock[]; + orderedList: false | TipTapResolvedList; + unorderedList: false | TipTapResolvedList; +}) { + return Extension.create({ + name: "defaultTextBlockStyle", + + addProseMirrorPlugins() { + return [ + new Plugin({ + key: new PluginKey("defaultTextBlockStyle"), + + appendTransaction(transactions, _, newState) { + if (!transactions.some((transaction) => transaction.docChanged)) { + return null; + } + + const transaction = newState.tr; + let changed = false; + + newState.doc.descendants((node, pos) => { + if (node.type.name !== "textBlock" || node.attrs.textBlockStyle != null) { + return; + } + + // A list wins over the text block inside its items, so a list item's + // content starts with the list's default style. + const listNodeType = findListNodeType(newState.doc.resolve(pos)); + const list = listNodeType === "orderedList" ? orderedList : listNodeType === "bulletList" ? unorderedList : false; + const styledNode: TipTapResolvedStyledNode | undefined = + (list || undefined) ?? findTextBlock({ name: node.attrs.textBlock, textBlocks }); + + if (styledNode?.defaultStyle != null) { + transaction.setNodeAttribute(pos, "textBlockStyle", styledNode.defaultStyle); + changed = true; + } + }); + + return changed ? transaction : null; + }, + }), + ]; + }, + }); +} diff --git a/packages/admin/cms-admin/src/blocks/tipTap/textBlocks.ts b/packages/admin/cms-admin/src/blocks/tipTap/textBlocks.ts index 8cc6e216d94..1b0dada1739 100644 --- a/packages/admin/cms-admin/src/blocks/tipTap/textBlocks.ts +++ b/packages/admin/cms-admin/src/blocks/tipTap/textBlocks.ts @@ -41,9 +41,18 @@ export interface TipTapTextBlockStyle { * exclude each other, and leaving both out renders the plain tag. */ export type TipTapStyling = - | { styles: TipTapTextBlockStyle[]; element?: never } - | { element: TipTapTextBlockElement; styles?: never } - | { styles?: never; element?: never }; + | { + styles: TipTapTextBlockStyle[]; + /** + * Name of the style applied to a newly created or converted text block. Must be one of + * `styles`, otherwise an error is thrown. With it the styling select loses its "Default" + * entry, so the editor always picks one of the styles. + */ + defaultStyle?: string; + element?: never; + } + | { element: TipTapTextBlockElement; styles?: never; defaultStyle?: never } + | { styles?: never; element?: never; defaultStyle?: never }; export interface TipTapTextBlockBase { /** @@ -64,22 +73,24 @@ export interface TipTapTextBlockBase { export type TipTapTextBlock = TipTapTextBlockBase & TipTapStyling; -export interface TipTapResolvedTextBlock extends TipTapTextBlockBase { +export interface TipTapResolvedStyledNode { + name: string; + styles: TipTapTextBlockStyle[]; + defaultStyle: string | null; + element?: TipTapTextBlockElement; +} + +export interface TipTapResolvedTextBlock extends TipTapTextBlockBase, TipTapResolvedStyledNode { /** * Heading level of the text block's tag, `undefined` for a paragraph. */ level?: HeadingLevel; - styles: TipTapTextBlockStyle[]; - element?: TipTapTextBlockElement; } export type TipTapListOptions = TipTapStyling; -export interface TipTapResolvedList { - name: string; +export interface TipTapResolvedList extends TipTapResolvedStyledNode { tag: TipTapListTag; - styles: TipTapTextBlockStyle[]; - element?: TipTapTextBlockElement; } export const orderedListName = "ordered-list"; @@ -95,13 +106,21 @@ export const allHeadingLevels: HeadingLevel[] = [1, 2, 3, 4, 5, 6]; * Checks that the styled node offers no style twice, since a style's name identifies it in the * content. */ -function resolveStyles({ name, styles = [] }: { name: string; styles?: TipTapTextBlockStyle[] }): TipTapTextBlockStyle[] { +function resolveStyles({ name, styles = [], defaultStyle }: { name: string; styles?: TipTapTextBlockStyle[]; defaultStyle?: string }): { + styles: TipTapTextBlockStyle[]; + defaultStyle: string | null; +} { const styleNames = styles.map((style) => style.name); const duplicate = styleNames.find((styleName, index) => styleNames.indexOf(styleName) !== index); if (duplicate !== undefined) { throw new Error(`"${name}" offers the text block style "${duplicate}" twice`); } - return styles; + + if (defaultStyle !== undefined && !styleNames.includes(defaultStyle)) { + throw new Error(`"${name}" has the defaultStyle "${defaultStyle}", which is not one of its styles`); + } + + return { styles, defaultStyle: defaultStyle ?? null }; } /** @@ -124,7 +143,7 @@ export function resolveTextBlocks(textBlocks: TipTapTextBlock[]): TipTapResolved } } - return textBlocks.map((textBlock) => ({ ...textBlock, level: headingLevelByTag[textBlock.tag], styles: resolveStyles(textBlock) })); + return textBlocks.map((textBlock) => ({ ...textBlock, level: headingLevelByTag[textBlock.tag], ...resolveStyles(textBlock) })); } /** @@ -144,7 +163,7 @@ export function resolveList({ return false; } const listOptions = list === true ? {} : list; - return { name, tag, ...listOptions, styles: resolveStyles({ name, styles: listOptions.styles }) }; + return { name, tag, ...listOptions, ...resolveStyles({ name, ...listOptions }) }; } /** @@ -158,11 +177,7 @@ export const getStyledNodes = ({ textBlocks: TipTapResolvedTextBlock[]; orderedList: false | TipTapResolvedList; unorderedList: false | TipTapResolvedList; -}): Array => [ - ...textBlocks, - ...(orderedList ? [orderedList] : []), - ...(unorderedList ? [unorderedList] : []), -]; +}): TipTapResolvedStyledNode[] => [...textBlocks, ...(orderedList ? [orderedList] : []), ...(unorderedList ? [unorderedList] : [])]; /** * Whether anything offers a style at all, which decides whether the text block node carries a @@ -236,4 +251,9 @@ export function findTextBlockPerTag(textBlocks: TipTapResolvedTextBlock[]): TipT */ export const isTextBlockAllowedInListItem = (textBlock: TipTapResolvedTextBlock): boolean => textBlock.tag === "p"; +/** + * Whether the styled node offers the style. + */ +export const hasStyle = (styledNode: TipTapResolvedStyledNode, style: string): boolean => styledNode.styles.some(({ name }) => name === style); + export const hasParagraphTextBlock = (textBlocks: TipTapResolvedTextBlock[]): boolean => textBlocks.some((textBlock) => textBlock.tag === "p"); 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..24d1e56830d 100644 --- a/packages/api/cms-api/src/blocks/tipTap/createTipTapRichTextBlock.test.ts +++ b/packages/api/cms-api/src/blocks/tipTap/createTipTapRichTextBlock.test.ts @@ -1447,6 +1447,15 @@ describe("createTipTapRichTextBlock validation", () => { ).toThrow(); }); + it("should throw when the defaultStyle is not one of the text block's styles", () => { + expect(() => + createTipTapRichTextBlock( + { textBlocks: [{ name: "paragraph", tag: "p", styles: [{ name: "copy100" }], defaultStyle: "copy200" }] }, + "TestInvalidDefaultStyle", + ), + ).toThrow(); + }); + it("should throw for a duplicate text block name", () => { expect(() => createTipTapRichTextBlock( @@ -1553,7 +1562,7 @@ describe("createTipTapRichTextBlock validation", () => { const resolvedOptions = resolveTipTapOptions({ textBlocks: headingOnly234 }); expect(resolvedOptions.orderedList).toBe(false); expect(resolvedOptions.unorderedList).toBe(false); - expect(resolvedOptions.defaultTextBlock).toEqual({ name: "heading-2", tag: "h2", level: 2, styles: [] }); + expect(resolvedOptions.defaultTextBlock).toEqual({ name: "heading-2", tag: "h2", level: 2, styles: [], defaultStyle: null }); }); }); diff --git a/packages/api/cms-api/src/blocks/tipTap/migrations/convertDraftJsToTipTap.ts b/packages/api/cms-api/src/blocks/tipTap/migrations/convertDraftJsToTipTap.ts index 3b24595d1c8..3c8b640be6b 100644 --- a/packages/api/cms-api/src/blocks/tipTap/migrations/convertDraftJsToTipTap.ts +++ b/packages/api/cms-api/src/blocks/tipTap/migrations/convertDraftJsToTipTap.ts @@ -2,7 +2,7 @@ import type { JSONContent } from "@tiptap/core"; import type { Block } from "../../block"; import type { TipTapResolvedOptions } from "../createTipTapRichTextBlock"; -import { findTextBlockForTag, type TipTapResolvedTextBlock, type TipTapTextBlockTag } from "../textBlocks"; +import { findTextBlockForTag, type TipTapResolvedStyledNode, type TipTapResolvedTextBlock, type TipTapTextBlockTag } from "../textBlocks"; interface DraftJsInlineStyleRange { style: string; @@ -312,12 +312,14 @@ function resolveTargetTextBlock({ function makeTextBlockNode( inlineContent: JSONContent[], - { textBlock, textBlockStyle }: { textBlock: TipTapResolvedTextBlock; textBlockStyle?: string }, + { styledNode, textBlock, textBlockStyle }: { styledNode?: TipTapResolvedStyledNode; textBlock: TipTapResolvedTextBlock; textBlockStyle?: string }, ): JSONContent { const node: JSONContent = { type: "textBlock", attrs: { textBlock: textBlock.name } }; - if (textBlockStyle !== undefined) { - node.attrs = { ...node.attrs, textBlockStyle }; + // A list item's text block draws its style from the list, not from its own text block. + const style = textBlockStyle ?? (styledNode ?? textBlock).defaultStyle ?? undefined; + if (style !== undefined) { + node.attrs = { ...node.attrs, textBlockStyle: style }; } if (inlineContent.length > 0) { node.content = inlineContent; @@ -325,10 +327,10 @@ function makeTextBlockNode( return node; } -function makeListItem(inlineContent: JSONContent[], resolvedOptions: TipTapResolvedOptions): JSONContent { +function makeListItem(inlineContent: JSONContent[], resolvedOptions: TipTapResolvedOptions, list: TipTapResolvedStyledNode): JSONContent { return { type: "listItem", - content: [makeTextBlockNode(inlineContent, { textBlock: resolveTargetTextBlock({ resolvedOptions }) })], + content: [makeTextBlockNode(inlineContent, { styledNode: list, textBlock: resolveTargetTextBlock({ resolvedOptions }) })], }; } @@ -386,7 +388,7 @@ export function convertDraftJsToTipTap(draftContent: DraftJsContent | undefined } }; - const addListItem = (listType: ListType, depth: number, inlineContent: JSONContent[]) => { + const addListItem = (listType: ListType, depth: number, inlineContent: JSONContent[], list: TipTapResolvedStyledNode) => { // A list item may only be indented one level deeper than its predecessor, no matter how // large the gap in Draft.js is. `listLevelMax` limits the nesting further. let level = Math.min(Math.max(depth, 0), openLists.length); @@ -404,15 +406,16 @@ export function convertDraftJsToTipTap(draftContent: DraftJsContent | undefined openLists.push({ type: listType, items: [] }); } - openLists[openLists.length - 1].items.push(makeListItem(inlineContent, resolvedOptions)); + openLists[openLists.length - 1].items.push(makeListItem(inlineContent, resolvedOptions, list)); }; for (const block of draftContent.blocks) { const inlineContent = buildInlineContent({ block, entityMap, resolvedOptions, hasLink, inlineStyleMap }); const listMapping = LIST_BLOCK_TYPE_TO_LIST[block.type]; - if (listMapping && resolvedOptions[listMapping.option]) { - addListItem(listMapping.listType, block.depth ?? 0, inlineContent); + const list = listMapping ? resolvedOptions[listMapping.option] : false; + if (listMapping && list) { + addListItem(listMapping.listType, block.depth ?? 0, inlineContent, list); continue; } diff --git a/packages/api/cms-api/src/blocks/tipTap/textBlocks.ts b/packages/api/cms-api/src/blocks/tipTap/textBlocks.ts index ed545bc3de7..997cdb955c1 100644 --- a/packages/api/cms-api/src/blocks/tipTap/textBlocks.ts +++ b/packages/api/cms-api/src/blocks/tipTap/textBlocks.ts @@ -27,22 +27,31 @@ export interface TipTapTextBlockBase { // The API doesn't render, so a text block that needs no style choice - which carries its own // `element` in the Admin - is configured here by leaving `styles` out. -export type TipTapTextBlock = TipTapTextBlockBase & { styles?: TipTapTextBlockStyle[] }; +export type TipTapTextBlock = TipTapTextBlockBase & { + styles?: TipTapTextBlockStyle[]; + /** + * Style applied to a newly created or converted text block. Must be one of `styles`, otherwise + * an error is thrown. Without it the text block starts out without a style. + */ + defaultStyle?: string; +}; + +export interface TipTapResolvedStyledNode { + name: string; + styles: TipTapTextBlockStyle[]; + defaultStyle: string | null; +} -export interface TipTapResolvedTextBlock extends TipTapTextBlockBase { +export interface TipTapResolvedTextBlock extends TipTapTextBlockBase, TipTapResolvedStyledNode { /** * Heading level of the text block's tag, `undefined` for a paragraph. */ level?: HeadingLevel; - styles: TipTapTextBlockStyle[]; } -export interface TipTapResolvedList { - name: string; - styles: TipTapTextBlockStyle[]; -} +export type TipTapResolvedList = TipTapResolvedStyledNode; -export type TipTapListOptions = { styles: TipTapTextBlockStyle[] }; +export type TipTapListOptions = { styles: TipTapTextBlockStyle[]; defaultStyle?: string }; export const orderedListName = "ordered-list"; export const unorderedListName = "unordered-list"; @@ -65,21 +74,34 @@ export const defaultTextBlocks: TipTapTextBlock[] = [ * Checks that the styled node offers no style twice, since a style's name identifies it in the * content. */ -function resolveStyles