From 394425bc255e5d9ed2bea72f3f0475413c14777b Mon Sep 17 00:00:00 2001 From: Andreas Kamm Date: Mon, 14 Sep 2026 14:48:36 +0200 Subject: [PATCH 1/3] Add defaultTextBlockStyles option to createTipTapRichTextBlock The text block style dropdown always offered an unstyled "Default" entry next to the configured textBlockStyles, even for a tag where every instance should always carry one of them, unlike the Draft.js RTE's standardBlockType, which had no such state for the block types it covered. defaultTextBlockStyles assigns a default style per tag instead: no "Default" entry is offered for it, a newly created or converted heading/paragraph of that tag gets the style automatically, and the API rejects stored content of that tag missing a style. migrateFromDraftJs falls back to the configured default when a mapped Draft.js block doesn't specify a textBlockStyle. A tag without an entry keeps today's behavior. Co-Authored-By: Claude Sonnet 5 --- .../tiptap-default-text-block-styles.md | 26 ++++ .../2-blocks/tiptap-rich-text-block.mdx | 20 +++ .../tipTap/TipTapContentTranslationDialog.tsx | 1 + .../src/blocks/tipTap/TipTapToolbar.tsx | 11 +- .../TipTapRichTextBlock.stories.tsx | 127 ++++++++++++++++++ .../tipTap/createTipTapRichTextBlock.tsx | 80 +++++++++-- .../tipTap/defaultTextBlockStyleHelpers.ts | 51 +++++++ .../tipTap/createTipTapRichTextBlock.ts | 66 +++++++++ .../buildDraftJsToTipTapMigration.ts | 14 +- .../migrations/convertDraftJsToTipTap.ts | 43 ++++-- 10 files changed, 415 insertions(+), 24 deletions(-) create mode 100644 .changeset/tiptap-default-text-block-styles.md create mode 100644 packages/admin/cms-admin/src/blocks/tipTap/defaultTextBlockStyleHelpers.ts diff --git a/.changeset/tiptap-default-text-block-styles.md b/.changeset/tiptap-default-text-block-styles.md new file mode 100644 index 00000000000..0ee8c05945e --- /dev/null +++ b/.changeset/tiptap-default-text-block-styles.md @@ -0,0 +1,26 @@ +--- +"@dextinity/cms-admin": minor +"@dextinity/cms-api": minor +--- + +Add `defaultTextBlockStyles` option to `createTipTapRichTextBlock` + +The text block style dropdown always offered an unstyled `Default` entry next to the configured `textBlockStyles`, even for a tag where every instance should always carry one of them. `defaultTextBlockStyles` assigns a default style per tag: no `Default` entry is offered for it, a newly created or converted heading/paragraph of that tag gets the style automatically, and the API rejects stored content of that tag missing a style. `migrateFromDraftJs` falls back to the configured default when a mapped Draft.js block doesn't specify a `textBlockStyle`. + +**Example** + +```ts +createTipTapRichTextBlock({ + textBlockStyles: [ + { name: "copy100", appliesTo: ["paragraph"] }, + { name: "copy200", appliesTo: ["paragraph"] }, + { name: "headline300", appliesTo: ["heading-2"] }, + ], + defaultTextBlockStyles: { + paragraph: "copy100", + "heading-2": "headline300", + }, +}); +``` + +A tag without an entry (e.g. `heading-3` above) keeps today's behavior — the dropdown still offers "Default" for it. diff --git a/docs/docs/2-core-concepts/2-blocks/tiptap-rich-text-block.mdx b/docs/docs/2-core-concepts/2-blocks/tiptap-rich-text-block.mdx index 53b5aa83de1..81b43fba656 100644 --- a/docs/docs/2-core-concepts/2-blocks/tiptap-rich-text-block.mdx +++ b/docs/docs/2-core-concepts/2-blocks/tiptap-rich-text-block.mdx @@ -368,6 +368,26 @@ const nodeMapping: Record = { }; ``` +#### Default text block styles + +By default, the styling select always offers an unstyled _Default_ entry next to the configured `textBlockStyles`. `defaultTextBlockStyles` assigns one style per tag as that tag's default instead: no _Default_ entry is offered for it, a newly created or converted heading/paragraph of that tag gets the style automatically, and the API rejects stored content of that tag missing a style. This matches the Draft.js RTE's `standardBlockType`, which had no unstyled state to begin with for the block types it covered. + +```ts title="tip-tap-rich-text.block.ts (API and Admin)" +export const TipTapRichTextBlock = createTipTapRichTextBlock({ + textBlockStyles: [ + { name: "copy100", appliesTo: ["paragraph"] }, + { name: "copy200", appliesTo: ["paragraph"] }, + { name: "headline300", appliesTo: ["heading-2"] }, + ], + defaultTextBlockStyles: { + paragraph: "copy100", + "heading-2": "headline300", + }, +}); +``` + +Each value must be the `name` of an entry in `textBlockStyles` whose `appliesTo` (if set) includes that tag. A tag without an entry keeps today's behavior — the styling select still offers _Default_ for it, unchanged. `migrateFromDraftJs` falls back to the configured default when a mapped Draft.js block doesn't specify a `textBlockStyle`, the same way `heading.defaultLevel` supplies a missing heading level. + ### Heading-only blocks Turning the `paragraph` feature off leaves a block that only holds headings — the TipTap equivalent of the Draft.js pattern of a `RichTextBlock` restricted to `header-*` block types with a `standardBlockType`, for instance the headline part of a heading block: diff --git a/packages/admin/cms-admin/src/blocks/tipTap/TipTapContentTranslationDialog.tsx b/packages/admin/cms-admin/src/blocks/tipTap/TipTapContentTranslationDialog.tsx index 7745a5c1adc..e30793d7f50 100644 --- a/packages/admin/cms-admin/src/blocks/tipTap/TipTapContentTranslationDialog.tsx +++ b/packages/admin/cms-admin/src/blocks/tipTap/TipTapContentTranslationDialog.tsx @@ -13,6 +13,7 @@ interface TipTapContentTranslationDialogProps { TipTapEditorProps, | "resolvedOptions" | "textBlockStyles" + | "defaultTextBlockStyles" | "inlineStyles" | "placeholders" | "linkBlock" diff --git a/packages/admin/cms-admin/src/blocks/tipTap/TipTapToolbar.tsx b/packages/admin/cms-admin/src/blocks/tipTap/TipTapToolbar.tsx index 36cd67ff70e..8cc482b5349 100644 --- a/packages/admin/cms-admin/src/blocks/tipTap/TipTapToolbar.tsx +++ b/packages/admin/cms-admin/src/blocks/tipTap/TipTapToolbar.tsx @@ -46,6 +46,7 @@ import type { TipTapPlaceholder, TipTapResolvedOptions, TipTapTextBlockStyle, + TipTapTextBlockStyleTargetType, TipTapTextBlockType, } from "./createTipTapRichTextBlock"; import { TipTapBlockDialog } from "./TipTapBlockDialog"; @@ -162,6 +163,7 @@ export const TipTapToolbar = ({ editor, resolvedOptions, textBlockStyles, + defaultTextBlockStyles, inlineStyles, placeholders, linkBlock, @@ -173,6 +175,7 @@ export const TipTapToolbar = ({ editor: Editor; resolvedOptions: TipTapResolvedOptions; textBlockStyles: TipTapTextBlockStyle[]; + defaultTextBlockStyles: Partial>; inlineStyles: TipTapInlineStyle[]; placeholders: TipTapPlaceholder[]; linkBlock?: BlockInterface & LinkBlockInterface; @@ -450,9 +453,11 @@ export const TipTapToolbar = ({ MenuProps={{ elevation: 1 }} sx={selectSx} > - - - + {!defaultTextBlockStyles[editorState.activeTipTapTextBlockType as TipTapTextBlockStyleTargetType] && ( + + + + )} {applicableTextBlockStyles.map((style) => ( {style.label} 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 dd0767cd992..5cb95be18f6 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 @@ -1222,3 +1222,130 @@ export const HeadingOnlyWithTextBlockStyles: StoryObj) =>

, + }, + { + name: "copy200", + label: "Copy 200", + appliesTo: ["paragraph"], + element: (props: HTMLAttributes) =>

, + }, + { + name: "h2-large", + label: "H2 Large", + appliesTo: ["heading-2"], + element: (p) => , + }, + { + name: "h3-highlight", + label: "H3 Highlight", + appliesTo: ["heading-3"], + element: (p) => , + }, + ], + defaultTextBlockStyles: { + paragraph: "copy100", + "heading-2": "h2-large", + // Heading 3 has an applicable style but no configured default, so it keeps today's behavior. + }, +}); + +function DefaultTextBlockStylesStory() { + const [state, setState] = useState(DefaultTextBlockStylesBlock.defaultValues()); + + return ( + + + + ); +} + +/** + * `defaultTextBlockStyles` assigns a default style per tag: the initial paragraph already carries its + * default ("Copy 100") instead of the "no style" state, and the style dropdown offers no "Default" entry + * for tags with a configured default — matching the pre-TipTap Draft.js RTE, which had no such state + * either. A tag with applicable styles but no configured default (Heading 3 here) keeps today's + * behavior and still offers "Default". A tag with no applicable styles at all (Heading 1) hides the + * style dropdown entirely, as before. + */ +export const DefaultTextBlockStyles: StoryObj = { + render: () => , + play: async ({ canvas, userEvent, step }) => { + await step("The initial paragraph already carries its default style — no 'Default' option is offered", async () => { + await waitFor( + () => { + const comboboxes = canvas.getAllByRole("combobox"); + expect(comboboxes).toHaveLength(2); + expect(comboboxes[1]).toHaveTextContent("Copy 100"); + }, + { timeout: 5000 }, + ); + + await userEvent.click(canvas.getAllByRole("combobox")[1]); + await waitFor(() => { + expect(within(document.body).queryByRole("option", { name: "Default" })).not.toBeInTheDocument(); + expect(within(document.body).getByRole("option", { name: "Copy 200" })).toBeInTheDocument(); + }); + await userEvent.keyboard("{Escape}"); + }); + + await step("Switching to Heading 2 auto-assigns its default style instead of 'Default'", async () => { + await userEvent.click(canvas.getAllByRole("combobox")[0]); + await waitFor(() => { + expect(within(document.body).getByText("Heading 2")).toBeInTheDocument(); + }); + await userEvent.click(within(document.body).getByText("Heading 2")); + + await waitFor( + () => { + expect(canvas.getAllByRole("combobox")[1]).toHaveTextContent("H2 Large"); + }, + { timeout: 3000 }, + ); + }); + + await step("Switching to Heading 3, which has an applicable style but no configured default, still offers 'Default'", async () => { + await userEvent.click(canvas.getAllByRole("combobox")[0]); + await waitFor(() => { + expect(within(document.body).getByText("Heading 3")).toBeInTheDocument(); + }); + await userEvent.click(within(document.body).getByText("Heading 3")); + + await waitFor( + () => { + expect(canvas.getAllByRole("combobox")[1]).toHaveTextContent("Default"); + }, + { timeout: 3000 }, + ); + + await userEvent.click(canvas.getAllByRole("combobox")[1]); + await waitFor(() => { + expect(within(document.body).getByRole("option", { name: "Default" })).toBeInTheDocument(); + expect(within(document.body).getByRole("option", { name: "H3 Highlight" })).toBeInTheDocument(); + }); + await userEvent.keyboard("{Escape}"); + }); + + await step("Switching to Heading 1, which has no configured default, hides the style dropdown entirely", async () => { + await userEvent.click(canvas.getAllByRole("combobox")[0]); + await waitFor(() => { + expect(within(document.body).getByText("Heading 1")).toBeInTheDocument(); + }); + await userEvent.click(within(document.body).getByText("Heading 1")); + + await waitFor( + () => { + expect(canvas.getAllByRole("combobox")).toHaveLength(1); + }, + { timeout: 3000 }, + ); + }); + }, +}; diff --git a/packages/admin/cms-admin/src/blocks/tipTap/createTipTapRichTextBlock.tsx b/packages/admin/cms-admin/src/blocks/tipTap/createTipTapRichTextBlock.tsx index da315dc1264..76ba1dc9fb9 100644 --- a/packages/admin/cms-admin/src/blocks/tipTap/createTipTapRichTextBlock.tsx +++ b/packages/admin/cms-admin/src/blocks/tipTap/createTipTapRichTextBlock.tsx @@ -22,6 +22,7 @@ import { createBlockSkeleton } from "../helpers/createBlockSkeleton"; import { BlockCategory, type BlockInterface, type LinkBlockInterface, type ReadOnlyBlockRenderInterface } from "../types"; import { ChildBlocksContext } from "./ChildBlocksContext"; import { translateTipTapContent } from "./contentTranslation"; +import { createDefaultTextBlockStyleExtension } from "./defaultTextBlockStyleHelpers"; import { CmsBlock, CmsInlineBlock } from "./extensions/CmsBlock"; import { CmsLink } from "./extensions/CmsLink"; import { InlineStyleMark } from "./extensions/InlineStyleMark"; @@ -152,6 +153,9 @@ export type TipTapTextBlockType = | "ordered-list" | "unordered-list"; +// The `textBlockStyle` attribute only exists on paragraph and heading nodes, so lists are excluded here. +export type TipTapTextBlockStyleTargetType = Exclude; + export interface TipTapTextBlockStyle { name: string; label: ReactNode; @@ -284,6 +288,15 @@ interface TipTapRichTextBlockFactoryOptions { * block element) or `"inline"` (inline within the surrounding text). */ childBlocks?: Record; + /** + * Assigns a default text block style per tag: the style a newly created or converted heading/paragraph + * of that tag gets automatically, so the toolbar's text block style dropdown no longer offers an unstyled + * "Default" entry for it. Matches the pre-TipTap Draft.js RTE, where `standardBlockType` played the same + * role and there was no "no style" state to begin with. + * + * Each value must be the `name` of an entry in `textBlockStyles` whose `appliesTo` (if set) includes that tag. + */ + defaultTextBlockStyles?: Partial>; /** * Limits the maximum number of top-level text blocks (paragraphs, headings, lists) * that can be created in the editor. @@ -317,14 +330,25 @@ function getPlainTextFromContent(content: JSONContent): string { // block node and therefore ProseMirror's default block type. const paragraphPriority = 1000; -const buildEmptyContent = (resolvedOptions: TipTapResolvedOptions): JSONContent => ({ - type: "doc", - content: [ - resolvedOptions.paragraph || resolvedOptions.heading === false - ? { type: "paragraph" } - : { type: "heading", attrs: { level: resolvedOptions.heading.defaultLevel } }, - ], -}); +const buildEmptyContent = ( + resolvedOptions: TipTapResolvedOptions, + defaultTextBlockStyles: Partial>, +): JSONContent => { + const isParagraph = resolvedOptions.paragraph || resolvedOptions.heading === false; + const node: JSONContent = isParagraph + ? { type: "paragraph" } + : { type: "heading", attrs: { level: (resolvedOptions.heading as { defaultLevel: HeadingLevel }).defaultLevel } }; + + const targetType: TipTapTextBlockStyleTargetType = isParagraph + ? "paragraph" + : (`heading-${(resolvedOptions.heading as { defaultLevel: HeadingLevel }).defaultLevel}` as TipTapTextBlockStyleTargetType); + const textBlockStyle = defaultTextBlockStyles[targetType]; + if (textBlockStyle) { + node.attrs = { ...node.attrs, textBlockStyle }; + } + + return { type: "doc", content: [node] }; +}; /** * Sets the default heading level and, for heading-only blocks, makes the heading the schema's @@ -509,6 +533,7 @@ function collectLinkMarksData(content: JSONContent): unknown[] { function buildTipTapExtensions({ resolvedOptions, textBlockStyles, + defaultTextBlockStyles, inlineStyles, placeholders, linkBlock, @@ -518,6 +543,7 @@ function buildTipTapExtensions({ }: { resolvedOptions: TipTapResolvedOptions; textBlockStyles: TipTapTextBlockStyle[]; + defaultTextBlockStyles: Partial>; inlineStyles: TipTapInlineStyle[]; placeholders: TipTapPlaceholder[]; linkBlock?: BlockInterface & LinkBlockInterface; @@ -577,6 +603,7 @@ function buildTipTapExtensions({ ...(hasInlineChildBlocks ? [CmsInlineBlock] : []), ...(maxTextBlocks !== undefined ? [createMaxTextBlocksExtension(maxTextBlocks)] : []), ...(listLevelMax !== undefined ? [createListLevelMaxExtension(listLevelMax)] : []), + ...(Object.keys(defaultTextBlockStyles).length > 0 ? [createDefaultTextBlockStyleExtension(defaultTextBlockStyles)] : []), ]; } @@ -595,6 +622,7 @@ export interface TipTapEditorProps { updateState: React.Dispatch>; resolvedOptions: TipTapResolvedOptions; textBlockStyles: TipTapTextBlockStyle[]; + defaultTextBlockStyles: Partial>; inlineStyles: TipTapInlineStyle[]; placeholders: TipTapPlaceholder[]; linkBlock?: BlockInterface & LinkBlockInterface; @@ -610,6 +638,7 @@ export const TipTapEditor = ({ updateState, resolvedOptions, textBlockStyles, + defaultTextBlockStyles, inlineStyles, placeholders, linkBlock, @@ -624,6 +653,7 @@ export const TipTapEditor = ({ const extensions = buildTipTapExtensions({ resolvedOptions, textBlockStyles, + defaultTextBlockStyles, inlineStyles, placeholders, linkBlock, @@ -727,6 +757,7 @@ export const TipTapEditor = ({ editor={editor} resolvedOptions={resolvedOptions} textBlockStyles={textBlockStyles} + defaultTextBlockStyles={defaultTextBlockStyles} inlineStyles={inlineStyles} placeholders={placeholders} linkBlock={linkBlock} @@ -751,6 +782,7 @@ export const TipTapEditor = ({ editorProps={{ resolvedOptions, textBlockStyles, + defaultTextBlockStyles, inlineStyles, placeholders, linkBlock, @@ -770,12 +802,37 @@ export const TipTapEditor = ({ type TipTapRichTextBlockInterface = BlockInterface & ReadOnlyBlockRenderInterface; +function validateDefaultTextBlockStyles( + defaultTextBlockStyles: Partial>, + textBlockStyles: TipTapTextBlockStyle[], + resolvedOptions: TipTapResolvedOptions, +): void { + for (const [tag, styleName] of Object.entries(defaultTextBlockStyles) as [TipTapTextBlockStyleTargetType, string][]) { + const isEnabledTag = + tag === "paragraph" + ? resolvedOptions.paragraph + : resolvedOptions.heading !== false && resolvedOptions.heading.levels.includes(Number(tag.slice("heading-".length)) as HeadingLevel); + if (!isEnabledTag) { + throw new Error(`defaultTextBlockStyles has an entry for "${tag}", which is not enabled`); + } + + const style = textBlockStyles.find((s) => s.name === styleName); + if (!style) { + throw new Error(`defaultTextBlockStyles has an entry for "${tag}" referencing unknown text block style "${styleName}"`); + } + if (style.appliesTo && !style.appliesTo.includes(tag)) { + throw new Error(`defaultTextBlockStyles has an entry for "${tag}", but text block style "${styleName}" does not apply to it`); + } + } +} + /** * @experimental */ export const createTipTapRichTextBlock = (options: TipTapRichTextBlockFactoryOptions = {}): TipTapRichTextBlockInterface => { const resolvedOptions = resolveTipTapOptions(options); const textBlockStyles = options.textBlockStyles ?? []; + const defaultTextBlockStyles = options.defaultTextBlockStyles ?? {}; const inlineStyles = options.inlineStyles ?? []; const placeholders = options.placeholders ?? []; const linkBlock = options.link; @@ -785,11 +842,15 @@ export const createTipTapRichTextBlock = (options: TipTapRichTextBlockFactoryOpt const maxTextBlocks = options.maxTextBlocks; const listLevelMax = options.listLevelMax; const minHeight = options.minHeight; - const emptyContent = buildEmptyContent(resolvedOptions); + + validateDefaultTextBlockStyles(defaultTextBlockStyles, textBlockStyles, resolvedOptions); + + const emptyContent = buildEmptyContent(resolvedOptions, defaultTextBlockStyles); const sharedEditorProps = { resolvedOptions, textBlockStyles, + defaultTextBlockStyles, inlineStyles, placeholders, linkBlock, @@ -802,6 +863,7 @@ export const createTipTapRichTextBlock = (options: TipTapRichTextBlockFactoryOpt const tipTapExtensions = buildTipTapExtensions({ resolvedOptions, textBlockStyles, + defaultTextBlockStyles, inlineStyles, placeholders, linkBlock, diff --git a/packages/admin/cms-admin/src/blocks/tipTap/defaultTextBlockStyleHelpers.ts b/packages/admin/cms-admin/src/blocks/tipTap/defaultTextBlockStyleHelpers.ts new file mode 100644 index 00000000000..c8e37571588 --- /dev/null +++ b/packages/admin/cms-admin/src/blocks/tipTap/defaultTextBlockStyleHelpers.ts @@ -0,0 +1,51 @@ +import { Extension } from "@tiptap/core"; +import { Plugin, PluginKey, type Transaction } from "@tiptap/pm/state"; + +import type { TipTapTextBlockStyleTargetType } from "./createTipTapRichTextBlock"; + +function getTextBlockStyleTargetType(nodeTypeName: string, level: number | undefined): TipTapTextBlockStyleTargetType | undefined { + if (nodeTypeName === "paragraph") { + return "paragraph"; + } + if (nodeTypeName === "heading" && level) { + return `heading-${level}` as TipTapTextBlockStyleTargetType; + } + return undefined; +} + +// Backfills textBlockStyle on any heading/paragraph whose tag has a configured default but no style set, so +// the "no style" state the toolbar no longer offers for that tag (see `defaultTextBlockStyles`) can't persist +// — covers the toolbar's type/style selects, markdown input rules, keyboard shortcuts, and pasted content alike. +export const createDefaultTextBlockStyleExtension = (defaultTextBlockStyles: Partial>) => + Extension.create({ + name: "defaultTextBlockStyle", + addProseMirrorPlugins() { + return [ + new Plugin({ + key: new PluginKey("defaultTextBlockStyle"), + appendTransaction(transactions, _oldState, newState) { + if (!transactions.some((transaction) => transaction.docChanged)) { + return null; + } + + let tr: Transaction | null = null; + newState.doc.descendants((node, pos) => { + if (node.attrs.textBlockStyle) { + return; + } + const targetType = getTextBlockStyleTargetType(node.type.name, node.attrs.level as number | undefined); + if (!targetType) { + return; + } + const defaultStyleName = defaultTextBlockStyles[targetType]; + if (!defaultStyleName) { + return; + } + tr = (tr ?? newState.tr).setNodeAttribute(pos, "textBlockStyle", defaultStyleName); + }); + return tr; + }, + }), + ]; + }, + }); diff --git a/packages/api/cms-api/src/blocks/tipTap/createTipTapRichTextBlock.ts b/packages/api/cms-api/src/blocks/tipTap/createTipTapRichTextBlock.ts index 1b4a55eef91..e64f1155ac7 100644 --- a/packages/api/cms-api/src/blocks/tipTap/createTipTapRichTextBlock.ts +++ b/packages/api/cms-api/src/blocks/tipTap/createTipTapRichTextBlock.ts @@ -90,6 +90,9 @@ type TipTapTextBlockType = | "ordered-list" | "unordered-list"; +// The `textBlockStyle` attribute only exists on paragraph and heading nodes, so lists are excluded here. +type TipTapTextBlockStyleTargetType = Exclude; + interface TipTapTextBlockStyle { name: string; /** @@ -185,6 +188,14 @@ export interface CreateTipTapRichTextBlockOptions { */ link?: Block; textBlockStyles?: TipTapTextBlockStyle[]; + /** + * Assigns a default text block style per tag: content of that tag missing a `textBlockStyle` is + * rejected during validation, mirroring the admin-side `defaultTextBlockStyles` option, which + * assigns it automatically and hides the toolbar's unstyled "Default" entry for that tag. + * + * Each value must be the `name` of an entry in `textBlockStyles` whose `appliesTo` (if set) includes that tag. + */ + defaultTextBlockStyles?: Partial>; inlineStyles?: TipTapInlineStyle[]; placeholders?: TipTapPlaceholder[]; indexSearchText?: boolean; @@ -512,6 +523,26 @@ function getTextBlockTypeFromNode(node: JSONContent): TipTapTextBlockType | unde return undefined; } +function containsMissingDefaultTextBlockStyle( + content: JSONContent, + defaultTextBlockStyles: Partial>, +): boolean { + const textBlockType = getTextBlockTypeFromNode(content) as TipTapTextBlockStyleTargetType | undefined; + if (textBlockType && defaultTextBlockStyles[textBlockType] && !content.attrs?.textBlockStyle) { + return true; + } + + if (Array.isArray(content.content)) { + for (const child of content.content) { + if (containsMissingDefaultTextBlockStyle(child, defaultTextBlockStyles)) { + return true; + } + } + } + + return false; +} + function containsInvalidInlineStyleMarks( content: JSONContent, inlineStyles: TipTapInlineStyle[], @@ -552,6 +583,7 @@ function IsTipTapContent( allowedPlaceholderNames, listLevelMax, headingLevels, + defaultTextBlockStyles, }: { inlineStyles: TipTapInlineStyle[]; linkBlock?: Block; @@ -560,6 +592,7 @@ function IsTipTapContent( allowedPlaceholderNames?: string[]; listLevelMax?: number; headingLevels: HeadingLevel[]; + defaultTextBlockStyles: Partial>; }, validationOptions?: ValidationOptions, ) { @@ -609,6 +642,11 @@ function IsTipTapContent( return false; } + // Enforce defaultTextBlockStyles: reject headings/paragraphs missing a style for a tag that has a default + if (containsMissingDefaultTextBlockStyle(value as JSONContent, defaultTextBlockStyles)) { + return false; + } + // Validate link mark data if (linkBlock) { const linkMarks = collectLinkMarks(value as JSONContent); @@ -687,6 +725,30 @@ function extractTextEntries(node: JSONContent, headingLevel?: number): TextEntry return results; } +function validateDefaultTextBlockStyles( + defaultTextBlockStyles: Partial>, + textBlockStyles: TipTapTextBlockStyle[], + resolvedOptions: TipTapResolvedOptions, +): void { + for (const [tag, styleName] of Object.entries(defaultTextBlockStyles) as [TipTapTextBlockStyleTargetType, string][]) { + const isEnabledTag = + tag === "paragraph" + ? resolvedOptions.paragraph + : resolvedOptions.heading !== false && resolvedOptions.heading.levels.includes(Number(tag.slice("heading-".length)) as HeadingLevel); + if (!isEnabledTag) { + throw new Error(`defaultTextBlockStyles has an entry for "${tag}", which is not enabled`); + } + + const style = textBlockStyles.find((s) => s.name === styleName); + if (!style) { + throw new Error(`defaultTextBlockStyles has an entry for "${tag}" referencing unknown text block style "${styleName}"`); + } + if (style.appliesTo && !style.appliesTo.includes(tag)) { + throw new Error(`defaultTextBlockStyles has an entry for "${tag}", but text block style "${styleName}" does not apply to it`); + } + } +} + /** * @experimental */ @@ -696,6 +758,7 @@ export function createTipTapRichTextBlock( ): Block { const { textBlockStyles = [], + defaultTextBlockStyles = {}, inlineStyles = [], placeholders = [], indexSearchText = true, @@ -710,6 +773,7 @@ export function createTipTapRichTextBlock( const resolvedOptions = resolveTipTapOptions(options); const headingLevels = resolvedOptions.heading ? resolvedOptions.heading.levels : []; + validateDefaultTextBlockStyles(defaultTextBlockStyles, textBlockStyles, resolvedOptions); const childBlocks: Record = Object.fromEntries(Object.entries(childBlocksConfig).map(([key, { block }]) => [key, block])); const childBlockConfigs = Object.values(childBlocksConfig); const hasChildBlocks = childBlockConfigs.length > 0; @@ -752,6 +816,7 @@ export function createTipTapRichTextBlock( headingLevels, textBlockStyleMap: draftJsTextBlockStyleMap, inlineStyleMap: draftJsInlineStyleMap, + defaultTextBlockStyles, }), ...baseMigrate.migrations, ], @@ -820,6 +885,7 @@ export function createTipTapRichTextBlock( allowedPlaceholderNames, listLevelMax, headingLevels, + defaultTextBlockStyles, }) @BlockField({ type: "tipTapRichTextBlock", childBlocks }) tipTapContent: JSONContent; diff --git a/packages/api/cms-api/src/blocks/tipTap/migrations/buildDraftJsToTipTapMigration.ts b/packages/api/cms-api/src/blocks/tipTap/migrations/buildDraftJsToTipTapMigration.ts index 06af7904644..f468155461d 100644 --- a/packages/api/cms-api/src/blocks/tipTap/migrations/buildDraftJsToTipTapMigration.ts +++ b/packages/api/cms-api/src/blocks/tipTap/migrations/buildDraftJsToTipTapMigration.ts @@ -44,8 +44,9 @@ interface BuildOptions extends ConvertOptions { } export function buildDraftJsToTipTapMigration(options: BuildOptions): ClassConstructor { - const { schema, maxTextBlocks, headingLevels, resolvedOptions, link, textBlockStyleMap, inlineStyleMap, listLevelMax } = options; - const emptyDoc = buildEmptyTipTapDoc(resolvedOptions); + const { schema, maxTextBlocks, headingLevels, resolvedOptions, link, textBlockStyleMap, inlineStyleMap, listLevelMax, defaultTextBlockStyles } = + options; + const emptyDoc = buildEmptyTipTapDoc(resolvedOptions, defaultTextBlockStyles); return class DraftJsToTipTapMigration extends BlockMigration<(from: From) => To> implements BlockMigrationInterface { public readonly toVersion = 1; @@ -59,7 +60,14 @@ export function buildDraftJsToTipTapMigration(options: BuildOptions): ClassConst return { tipTapContent: emptyDoc }; } - const converted = convertDraftJsToTipTap(from.draftContent, { resolvedOptions, link, textBlockStyleMap, inlineStyleMap, listLevelMax }); + const converted = convertDraftJsToTipTap(from.draftContent, { + resolvedOptions, + link, + textBlockStyleMap, + inlineStyleMap, + listLevelMax, + defaultTextBlockStyles, + }); if (isValidTipTapContentSync(converted, schema, { maxTextBlocks, listLevelMax, headingLevels })) { return { tipTapContent: converted }; } 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 53bbb674f13..38eabfd773f 100644 --- a/packages/api/cms-api/src/blocks/tipTap/migrations/convertDraftJsToTipTap.ts +++ b/packages/api/cms-api/src/blocks/tipTap/migrations/convertDraftJsToTipTap.ts @@ -68,6 +68,12 @@ interface ConvertOptions { * as `

` into a TipTap heading with level 2. */ textBlockStyleMap?: Record; + /** + * Falls back to the configured default text block style (see `defaultTextBlockStyles`) for a tag + * when `textBlockStyleMap` doesn't provide one, so migrated content doesn't end up in the "no + * style" state the tag no longer allows. + */ + defaultTextBlockStyles?: Partial>; /** * Maps DraftJS custom inline style names (e.g. `highlight` from a DraftJS `customInlineStyles` * configuration) to TipTap `inlineStyle` mark type values. @@ -115,8 +121,11 @@ const TEXT_BLOCK_TYPE_TO_HEADING_LEVEL: Record> = {}, +): JSONContent { + return { type: "doc", content: [makeTextBlockNode([], { resolvedOptions, defaultTextBlockStyles })] }; } function clamp(value: number, min: number, max: number): number { @@ -270,15 +279,25 @@ function makeTextBlockNode( inlineContent: JSONContent[], { headingLevel: explicitHeadingLevel, - textBlockStyle, + textBlockStyle: explicitTextBlockStyle, resolvedOptions, - }: { headingLevel?: number; textBlockStyle?: string; resolvedOptions: TipTapResolvedOptions }, + defaultTextBlockStyles = {}, + }: { + headingLevel?: number; + textBlockStyle?: string; + resolvedOptions: TipTapResolvedOptions; + defaultTextBlockStyles?: Partial>; + }, ): JSONContent { // A heading-only schema has no paragraph to fall back to. const headingLevel = explicitHeadingLevel ?? (resolvedOptions.paragraph || resolvedOptions.heading === false ? undefined : resolvedOptions.heading.defaultLevel); const node: JSONContent = { type: headingLevel !== undefined ? "heading" : "paragraph" }; + const targetType: TipTapTextBlockStyleTargetType = + headingLevel !== undefined ? (`heading-${headingLevel}` as TipTapTextBlockStyleTargetType) : "paragraph"; + const textBlockStyle = explicitTextBlockStyle ?? defaultTextBlockStyles[targetType]; + const attrs: JSONContent["attrs"] = {}; if (headingLevel !== undefined) { attrs.level = headingLevel; @@ -296,10 +315,14 @@ function makeTextBlockNode( return node; } -function makeListItem(inlineContent: JSONContent[], resolvedOptions: TipTapResolvedOptions): JSONContent { +function makeListItem( + inlineContent: JSONContent[], + resolvedOptions: TipTapResolvedOptions, + defaultTextBlockStyles: Partial>, +): JSONContent { return { type: "listItem", - content: [makeTextBlockNode(inlineContent, { resolvedOptions })], + content: [makeTextBlockNode(inlineContent, { resolvedOptions, defaultTextBlockStyles })], }; } @@ -324,9 +347,10 @@ function normalizeTextBlockStyleMapping(mapping: string | TextBlockStyleMapping export function convertDraftJsToTipTap(draftContent: DraftJsContent | undefined | null, options: ConvertOptions): JSONContent { const resolvedOptions = options.resolvedOptions; + const defaultTextBlockStyles = options.defaultTextBlockStyles ?? {}; if (!draftContent || !Array.isArray(draftContent.blocks) || draftContent.blocks.length === 0) { - return buildEmptyTipTapDoc(resolvedOptions); + return buildEmptyTipTapDoc(resolvedOptions, defaultTextBlockStyles); } const hasLink = !!options.link; @@ -382,7 +406,7 @@ 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, defaultTextBlockStyles)); }; for (const block of draftContent.blocks) { @@ -405,6 +429,7 @@ export function convertDraftJsToTipTap(draftContent: DraftJsContent | undefined resolvedOptions, headingLevel: headingLevel !== undefined && resolvedOptions.heading !== false ? headingLevel : undefined, textBlockStyle: mapping?.textBlockStyle, + defaultTextBlockStyles, }), ); } @@ -412,7 +437,7 @@ export function convertDraftJsToTipTap(draftContent: DraftJsContent | undefined flushLists(); if (topLevel.length === 0) { - return buildEmptyTipTapDoc(resolvedOptions); + return buildEmptyTipTapDoc(resolvedOptions, defaultTextBlockStyles); } return { type: "doc", content: topLevel }; From ad3f16618094baf4d5118839eb537086464c1ddf Mon Sep 17 00:00:00 2001 From: Andreas Kamm Date: Tue, 15 Sep 2026 09:44:04 +0200 Subject: [PATCH 2/3] cms-api: Guarantee defaultTextBlockStyles across later migrations A block migration running after migrateFromDraftJs's DraftJS->TipTap conversion (e.g. one remapping heading levels, like the demo's Heading1ToHeading2Migration) could leave a node missing its default style, since only the DraftJS conversion step resolved defaultTextBlockStyles, against the tag/level a node had at that point. Found by migrating old DraftJS demo data through the pipeline. Always append a migration that fills in any default still missing once every other migration has applied, so the guarantee holds regardless of what happens in between. Wire defaultTextBlockStyles into the demo's TipTapRichTextBlock to dogfood the option. Co-Authored-By: Claude Sonnet 5 --- .../tiptap-default-text-block-styles.md | 4 + .../common/blocks/tip-tap-rich-text.block.ts | 2 + .../tipTap/createTipTapRichTextBlock.ts | 17 +++- ...plyDefaultTextBlockStylesMigration.test.ts | 95 +++++++++++++++++++ ...ildApplyDefaultTextBlockStylesMigration.ts | 63 ++++++++++++ 5 files changed, 180 insertions(+), 1 deletion(-) create mode 100644 packages/api/cms-api/src/blocks/tipTap/migrations/buildApplyDefaultTextBlockStylesMigration.test.ts create mode 100644 packages/api/cms-api/src/blocks/tipTap/migrations/buildApplyDefaultTextBlockStylesMigration.ts diff --git a/.changeset/tiptap-default-text-block-styles.md b/.changeset/tiptap-default-text-block-styles.md index 0ee8c05945e..88743fa38d0 100644 --- a/.changeset/tiptap-default-text-block-styles.md +++ b/.changeset/tiptap-default-text-block-styles.md @@ -24,3 +24,7 @@ createTipTapRichTextBlock({ ``` A tag without an entry (e.g. `heading-3` above) keeps today's behavior — the dropdown still offers "Default" for it. + +**Guaranteed even across later migrations** + +A block migration that runs after `migrateFromDraftJs` (for instance one that changes a node's heading level) can leave a node missing its default style, since earlier steps only resolve `defaultTextBlockStyles` against the tag a node has at that point. A migration now always runs last to fill in any default still missing once every other migration — including a block's own — has applied. diff --git a/demo/api/src/common/blocks/tip-tap-rich-text.block.ts b/demo/api/src/common/blocks/tip-tap-rich-text.block.ts index 52bdd09d0e9..745a8b0503d 100644 --- a/demo/api/src/common/blocks/tip-tap-rich-text.block.ts +++ b/demo/api/src/common/blocks/tip-tap-rich-text.block.ts @@ -19,9 +19,11 @@ export const TipTapRichTextBlock = createTipTapRichTextBlock( { name: "eyebrow550", appliesTo: ["paragraph"] }, { name: "eyebrow500", appliesTo: ["paragraph"] }, { name: "eyebrow450", appliesTo: ["paragraph"] }, + { name: "headline300", appliesTo: ["heading-2"] }, { name: "list300", appliesTo: ["ordered-list", "unordered-list"] }, { name: "list200", appliesTo: ["ordered-list", "unordered-list"] }, ], + defaultTextBlockStyles: { paragraph: "paragraph300", "heading-2": "headline300" }, inlineStyles: [{ name: "highlight" }, { name: "tag", appliesTo: ["paragraph"] }], migrateFromDraftJs: { // Map the DraftJS `blocktypeMap` entry `paragraph-small` (configured in the admin RichTextBlock) diff --git a/packages/api/cms-api/src/blocks/tipTap/createTipTapRichTextBlock.ts b/packages/api/cms-api/src/blocks/tipTap/createTipTapRichTextBlock.ts index e64f1155ac7..89e7c2b61f8 100644 --- a/packages/api/cms-api/src/blocks/tipTap/createTipTapRichTextBlock.ts +++ b/packages/api/cms-api/src/blocks/tipTap/createTipTapRichTextBlock.ts @@ -32,6 +32,7 @@ import { Placeholder } from "./extensions/Placeholder"; import { SoftHyphen } from "./extensions/SoftHyphen"; import { TextBlockStyleHeading } from "./extensions/TextBlockStyleHeading"; import { TextBlockStyleParagraph } from "./extensions/TextBlockStyleParagraph"; +import { buildApplyDefaultTextBlockStylesMigration } from "./migrations/buildApplyDefaultTextBlockStylesMigration"; import { buildDraftJsToTipTapMigration } from "./migrations/buildDraftJsToTipTapMigration"; import type { TextBlockStyleMapping } from "./migrations/convertDraftJsToTipTap"; import { containsInvalidHeadingLevel, getListNestingDepth } from "./tipTapValidation"; @@ -803,7 +804,7 @@ export function createTipTapRichTextBlock( } } } - const migrate = migrateFromDraftJs + const migrateWithDraftJs = migrateFromDraftJs ? { version: baseMigrate.version == 0 ? 1 : baseMigrate.version, migrations: [ @@ -823,6 +824,20 @@ export function createTipTapRichTextBlock( } : baseMigrate; + // Safety net, appended after every other migration: a migration that runs before this one (the DraftJS + // conversion, or a block-specific migration such as one that changes a node's heading level) can resolve + // `defaultTextBlockStyles` against a tag/level a node no longer has by the time all migrations have run. + const hasDefaultTextBlockStyles = Object.keys(defaultTextBlockStyles).length > 0; + const migrate = hasDefaultTextBlockStyles + ? { + version: migrateWithDraftJs.version + 1, + migrations: [ + ...migrateWithDraftJs.migrations, + buildApplyDefaultTextBlockStylesMigration({ toVersion: migrateWithDraftJs.version + 1, defaultTextBlockStyles }), + ], + } + : migrateWithDraftJs; + @BlockDataMigrationVersion(migrate.version) class TipTapRichTextBlockData extends BlockData implements TipTapRichTextBlockDataInterface { @BlockField({ type: "tipTapRichTextBlock", childBlocks }) diff --git a/packages/api/cms-api/src/blocks/tipTap/migrations/buildApplyDefaultTextBlockStylesMigration.test.ts b/packages/api/cms-api/src/blocks/tipTap/migrations/buildApplyDefaultTextBlockStylesMigration.test.ts new file mode 100644 index 00000000000..f8b45456f1d --- /dev/null +++ b/packages/api/cms-api/src/blocks/tipTap/migrations/buildApplyDefaultTextBlockStylesMigration.test.ts @@ -0,0 +1,95 @@ +import { describe, expect, it } from "vitest"; + +import { BlockMigration } from "../../migrations/BlockMigration"; +import type { BlockMigrationInterface } from "../../migrations/types"; +import { typeSafeBlockMigrationPipe } from "../../migrations/typeSafeBlockMigrationPipe"; +import { createTipTapRichTextBlock, type TipTapRichTextBlockContent } from "../createTipTapRichTextBlock"; +import type { DraftJsContent } from "./convertDraftJsToTipTap"; + +type DraftBlock = DraftJsContent["blocks"][number]; + +function draftBlock(overrides: Partial = {}): DraftBlock { + return { key: "k", type: "unstyled", text: "", depth: 0, inlineStyleRanges: [], entityRanges: [], ...overrides }; +} + +interface HeadingMigrationShape { + tipTapContent: TipTapRichTextBlockContent; +} + +// Mirrors demo/api's Heading1ToHeading2Migration: bumps every level-1 heading to level 2, unaware of +// defaultTextBlockStyles, to reproduce the interaction the safety-net migration guards against. +function bumpHeading1ToHeading2(node: TipTapRichTextBlockContent): TipTapRichTextBlockContent { + let result = node; + if (node.type === "heading" && node.attrs?.level === 1) { + result = { ...node, attrs: { ...node.attrs, level: 2 } }; + } + if (Array.isArray(result.content)) { + result = { ...result, content: result.content.map(bumpHeading1ToHeading2) }; + } + return result; +} + +class Heading1ToHeading2Migration extends BlockMigration<(from: HeadingMigrationShape) => HeadingMigrationShape> implements BlockMigrationInterface { + public readonly toVersion = 2; + + protected migrate(from: HeadingMigrationShape): HeadingMigrationShape { + return { tipTapContent: bumpHeading1ToHeading2(from.tipTapContent) }; + } +} + +describe("buildApplyDefaultTextBlockStylesMigration", () => { + it("fills in the default style a later migration's tag/level change made unreachable for migrateFromDraftJs", () => { + const block = createTipTapRichTextBlock( + { + migrateFromDraftJs: true, + textBlockStyles: [{ name: "headline300", appliesTo: ["heading-2"] }], + defaultTextBlockStyles: { "heading-2": "headline300" }, + }, + { + name: "HeadingBumpRichText", + migrate: { migrations: typeSafeBlockMigrationPipe([Heading1ToHeading2Migration]), version: 2 }, + }, + ); + + const data = block.blockDataFactory({ + draftContent: { blocks: [draftBlock({ type: "header-one", text: "Title" })], entityMap: {} }, + }); + + expect(data.tipTapContent).toEqual({ + type: "doc", + content: [{ type: "heading", attrs: { level: 2, textBlockStyle: "headline300" }, content: [{ type: "text", text: "Title" }] }], + }); + }); + + it("does not override a style a migration already set", () => { + const block = createTipTapRichTextBlock( + { + textBlockStyles: [{ name: "paragraph200", appliesTo: ["paragraph"] }], + defaultTextBlockStyles: { paragraph: "paragraph200" }, + }, + "PreStyledRichText", + ); + + const data = block.blockDataFactory({ + tipTapContent: { + type: "doc", + content: [{ type: "paragraph", attrs: { textBlockStyle: "paragraph200" }, content: [{ type: "text", text: "already styled" }] }], + }, + }); + + expect(data.tipTapContent).toEqual({ + type: "doc", + content: [{ type: "paragraph", attrs: { textBlockStyle: "paragraph200" }, content: [{ type: "text", text: "already styled" }] }], + }); + }); + + it("is a no-op when defaultTextBlockStyles is not configured", () => { + const block = createTipTapRichTextBlock({ migrateFromDraftJs: true }, "NoDefaultStylesRichText"); + + const data = block.blockDataFactory({ + draftContent: { blocks: [draftBlock({ type: "unstyled", text: "Hello" })], entityMap: {} }, + }); + + expect(data.tipTapContent).toEqual({ type: "doc", content: [{ type: "paragraph", content: [{ type: "text", text: "Hello" }] }] }); + }); +}); diff --git a/packages/api/cms-api/src/blocks/tipTap/migrations/buildApplyDefaultTextBlockStylesMigration.ts b/packages/api/cms-api/src/blocks/tipTap/migrations/buildApplyDefaultTextBlockStylesMigration.ts new file mode 100644 index 00000000000..ed6262d1c2a --- /dev/null +++ b/packages/api/cms-api/src/blocks/tipTap/migrations/buildApplyDefaultTextBlockStylesMigration.ts @@ -0,0 +1,63 @@ +import type { JSONContent } from "@tiptap/core"; +import type { ClassConstructor } from "class-transformer"; + +import { BlockMigration } from "../../migrations/BlockMigration"; +import type { BlockMigrationInterface } from "../../migrations/types"; + +type TipTapTextBlockStyleTargetType = "paragraph" | "heading-1" | "heading-2" | "heading-3" | "heading-4" | "heading-5" | "heading-6"; + +interface From { + tipTapContent: JSONContent; +} + +type To = From; + +function getTextBlockTypeFromNode(node: JSONContent): TipTapTextBlockStyleTargetType | undefined { + if (node.type === "paragraph") { + return "paragraph"; + } + if (node.type === "heading" && node.attrs?.level) { + return `heading-${node.attrs.level}` as TipTapTextBlockStyleTargetType; + } + return undefined; +} + +function applyDefaultTextBlockStyles( + node: JSONContent, + defaultTextBlockStyles: Partial>, +): JSONContent { + const textBlockType = getTextBlockTypeFromNode(node); + const defaultStyle = textBlockType ? defaultTextBlockStyles[textBlockType] : undefined; + + let result = node; + if (defaultStyle !== undefined && !node.attrs?.textBlockStyle) { + result = { ...node, attrs: { ...node.attrs, textBlockStyle: defaultStyle } }; + } + if (Array.isArray(result.content)) { + result = { ...result, content: result.content.map((child) => applyDefaultTextBlockStyles(child, defaultTextBlockStyles)) }; + } + return result; +} + +/** + * Builds a migration that runs after every other migration (`migrateFromDraftJs`'s conversion step and any + * block-specific migrations), so it's the safety net for `defaultTextBlockStyles`: earlier migrations resolve + * the default against the tag/level a node has *at that point*, but a later migration can still change a node's + * tag or heading level without knowing about `defaultTextBlockStyles` (see e.g. a migration that bumps every + * heading level by one). This step re-checks the final content and fills in any still-missing default. + */ +export function buildApplyDefaultTextBlockStylesMigration({ + toVersion, + defaultTextBlockStyles, +}: { + toVersion: number; + defaultTextBlockStyles: Partial>; +}): ClassConstructor { + return class ApplyDefaultTextBlockStylesMigration extends BlockMigration<(from: From) => To> implements BlockMigrationInterface { + public readonly toVersion = toVersion; + + protected migrate(from: From): To { + return { tipTapContent: applyDefaultTextBlockStyles(from.tipTapContent, defaultTextBlockStyles) }; + } + }; +} From 3abbba2f8c4ad4f3b1a7eb3873163bdfbd2f8f3f Mon Sep 17 00:00:00 2001 From: Andreas Kamm Date: Tue, 15 Sep 2026 15:11:44 +0200 Subject: [PATCH 3/3] cms-api: Swap in the default style when a later migration invalidates it The defaultTextBlockStyles safety-net migration only filled in a missing textBlockStyle. A node whose tag changed after the style was already set (e.g. a heading level bump by a later migration) kept its old style even when that style's appliesTo no longer covers the new tag, breaking the guarantee that a valid default is always applied. Co-Authored-By: Claude Sonnet 5 --- .../tiptap-default-text-block-styles.md | 2 +- .../tipTap/createTipTapRichTextBlock.ts | 2 +- ...plyDefaultTextBlockStylesMigration.test.ts | 26 +++++++++++++++ ...ildApplyDefaultTextBlockStylesMigration.ts | 32 ++++++++++++++++--- 4 files changed, 56 insertions(+), 6 deletions(-) diff --git a/.changeset/tiptap-default-text-block-styles.md b/.changeset/tiptap-default-text-block-styles.md index 88743fa38d0..b1596903f54 100644 --- a/.changeset/tiptap-default-text-block-styles.md +++ b/.changeset/tiptap-default-text-block-styles.md @@ -27,4 +27,4 @@ A tag without an entry (e.g. `heading-3` above) keeps today's behavior — the d **Guaranteed even across later migrations** -A block migration that runs after `migrateFromDraftJs` (for instance one that changes a node's heading level) can leave a node missing its default style, since earlier steps only resolve `defaultTextBlockStyles` against the tag a node has at that point. A migration now always runs last to fill in any default still missing once every other migration — including a block's own — has applied. +A block migration that runs after `migrateFromDraftJs` (for instance one that changes a node's heading level) can leave a node missing its default style, or carrying a style that no longer applies to its new tag, since earlier steps only resolve `defaultTextBlockStyles` against the tag a node has at that point. A migration now always runs last to fill in any default still missing, or swap in the default for a style that no longer applies, once every other migration — including a block's own — has applied. diff --git a/packages/api/cms-api/src/blocks/tipTap/createTipTapRichTextBlock.ts b/packages/api/cms-api/src/blocks/tipTap/createTipTapRichTextBlock.ts index 89e7c2b61f8..6c7eee8515f 100644 --- a/packages/api/cms-api/src/blocks/tipTap/createTipTapRichTextBlock.ts +++ b/packages/api/cms-api/src/blocks/tipTap/createTipTapRichTextBlock.ts @@ -833,7 +833,7 @@ export function createTipTapRichTextBlock( version: migrateWithDraftJs.version + 1, migrations: [ ...migrateWithDraftJs.migrations, - buildApplyDefaultTextBlockStylesMigration({ toVersion: migrateWithDraftJs.version + 1, defaultTextBlockStyles }), + buildApplyDefaultTextBlockStylesMigration({ toVersion: migrateWithDraftJs.version + 1, defaultTextBlockStyles, textBlockStyles }), ], } : migrateWithDraftJs; diff --git a/packages/api/cms-api/src/blocks/tipTap/migrations/buildApplyDefaultTextBlockStylesMigration.test.ts b/packages/api/cms-api/src/blocks/tipTap/migrations/buildApplyDefaultTextBlockStylesMigration.test.ts index f8b45456f1d..db1bc43c0c0 100644 --- a/packages/api/cms-api/src/blocks/tipTap/migrations/buildApplyDefaultTextBlockStylesMigration.test.ts +++ b/packages/api/cms-api/src/blocks/tipTap/migrations/buildApplyDefaultTextBlockStylesMigration.test.ts @@ -61,6 +61,32 @@ describe("buildApplyDefaultTextBlockStylesMigration", () => { }); }); + it("swaps a style a later migration's tag/level change made inapplicable for a style with matching appliesTo", () => { + const block = createTipTapRichTextBlock( + { + migrateFromDraftJs: true, + textBlockStyles: [ + { name: "headline100", appliesTo: ["heading-1"] }, + { name: "headline300", appliesTo: ["heading-2"] }, + ], + defaultTextBlockStyles: { "heading-1": "headline100", "heading-2": "headline300" }, + }, + { + name: "HeadingBumpStyledRichText", + migrate: { migrations: typeSafeBlockMigrationPipe([Heading1ToHeading2Migration]), version: 2 }, + }, + ); + + const data = block.blockDataFactory({ + draftContent: { blocks: [draftBlock({ type: "header-one", text: "Title" })], entityMap: {} }, + }); + + expect(data.tipTapContent).toEqual({ + type: "doc", + content: [{ type: "heading", attrs: { level: 2, textBlockStyle: "headline300" }, content: [{ type: "text", text: "Title" }] }], + }); + }); + it("does not override a style a migration already set", () => { const block = createTipTapRichTextBlock( { diff --git a/packages/api/cms-api/src/blocks/tipTap/migrations/buildApplyDefaultTextBlockStylesMigration.ts b/packages/api/cms-api/src/blocks/tipTap/migrations/buildApplyDefaultTextBlockStylesMigration.ts index ed6262d1c2a..d16e1a7d80b 100644 --- a/packages/api/cms-api/src/blocks/tipTap/migrations/buildApplyDefaultTextBlockStylesMigration.ts +++ b/packages/api/cms-api/src/blocks/tipTap/migrations/buildApplyDefaultTextBlockStylesMigration.ts @@ -6,6 +6,11 @@ import type { BlockMigrationInterface } from "../../migrations/types"; type TipTapTextBlockStyleTargetType = "paragraph" | "heading-1" | "heading-2" | "heading-3" | "heading-4" | "heading-5" | "heading-6"; +interface TipTapTextBlockStyle { + name: string; + appliesTo?: string[]; +} + interface From { tipTapContent: JSONContent; } @@ -22,19 +27,35 @@ function getTextBlockTypeFromNode(node: JSONContent): TipTapTextBlockStyleTarget return undefined; } +// A node can carry a `textBlockStyle` that a later migration made invalid for its current tag (e.g. a +// heading bumped from level 1 to level 2 keeps a style whose `appliesTo` only lists `heading-1`), not +// just a missing one, so this checks appliesTo, not just presence, before falling back to the default. +function isTextBlockStyleValidForTag( + styleName: string | undefined, + textBlockType: TipTapTextBlockStyleTargetType, + textBlockStyles: TipTapTextBlockStyle[], +): boolean { + if (styleName === undefined) { + return false; + } + const style = textBlockStyles.find((s) => s.name === styleName); + return style !== undefined && (!style.appliesTo || style.appliesTo.includes(textBlockType)); +} + function applyDefaultTextBlockStyles( node: JSONContent, defaultTextBlockStyles: Partial>, + textBlockStyles: TipTapTextBlockStyle[], ): JSONContent { const textBlockType = getTextBlockTypeFromNode(node); const defaultStyle = textBlockType ? defaultTextBlockStyles[textBlockType] : undefined; let result = node; - if (defaultStyle !== undefined && !node.attrs?.textBlockStyle) { + if (textBlockType && defaultStyle !== undefined && !isTextBlockStyleValidForTag(node.attrs?.textBlockStyle, textBlockType, textBlockStyles)) { result = { ...node, attrs: { ...node.attrs, textBlockStyle: defaultStyle } }; } if (Array.isArray(result.content)) { - result = { ...result, content: result.content.map((child) => applyDefaultTextBlockStyles(child, defaultTextBlockStyles)) }; + result = { ...result, content: result.content.map((child) => applyDefaultTextBlockStyles(child, defaultTextBlockStyles, textBlockStyles)) }; } return result; } @@ -44,20 +65,23 @@ function applyDefaultTextBlockStyles( * block-specific migrations), so it's the safety net for `defaultTextBlockStyles`: earlier migrations resolve * the default against the tag/level a node has *at that point*, but a later migration can still change a node's * tag or heading level without knowing about `defaultTextBlockStyles` (see e.g. a migration that bumps every - * heading level by one). This step re-checks the final content and fills in any still-missing default. + * heading level by one). This step re-checks the final content and fills in any still-missing default, or + * swaps in the default for a style that no longer applies to the node's final tag. */ export function buildApplyDefaultTextBlockStylesMigration({ toVersion, defaultTextBlockStyles, + textBlockStyles, }: { toVersion: number; defaultTextBlockStyles: Partial>; + textBlockStyles: TipTapTextBlockStyle[]; }): ClassConstructor { return class ApplyDefaultTextBlockStylesMigration extends BlockMigration<(from: From) => To> implements BlockMigrationInterface { public readonly toVersion = toVersion; protected migrate(from: From): To { - return { tipTapContent: applyDefaultTextBlockStyles(from.tipTapContent, defaultTextBlockStyles) }; + return { tipTapContent: applyDefaultTextBlockStyles(from.tipTapContent, defaultTextBlockStyles, textBlockStyles) }; } }; }