diff --git a/.changeset/tiptap-default-text-block-styles.md b/.changeset/tiptap-default-text-block-styles.md new file mode 100644 index 00000000000..b1596903f54 --- /dev/null +++ b/.changeset/tiptap-default-text-block-styles.md @@ -0,0 +1,30 @@ +--- +"@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. + +**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, 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/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/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..6c7eee8515f 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"; @@ -90,6 +91,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 +189,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 +524,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 +584,7 @@ function IsTipTapContent( allowedPlaceholderNames, listLevelMax, headingLevels, + defaultTextBlockStyles, }: { inlineStyles: TipTapInlineStyle[]; linkBlock?: Block; @@ -560,6 +593,7 @@ function IsTipTapContent( allowedPlaceholderNames?: string[]; listLevelMax?: number; headingLevels: HeadingLevel[]; + defaultTextBlockStyles: Partial>; }, validationOptions?: ValidationOptions, ) { @@ -609,6 +643,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 +726,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 +759,7 @@ export function createTipTapRichTextBlock( ): Block { const { textBlockStyles = [], + defaultTextBlockStyles = {}, inlineStyles = [], placeholders = [], indexSearchText = true, @@ -710,6 +774,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; @@ -739,7 +804,7 @@ export function createTipTapRichTextBlock( } } } - const migrate = migrateFromDraftJs + const migrateWithDraftJs = migrateFromDraftJs ? { version: baseMigrate.version == 0 ? 1 : baseMigrate.version, migrations: [ @@ -752,12 +817,27 @@ export function createTipTapRichTextBlock( headingLevels, textBlockStyleMap: draftJsTextBlockStyleMap, inlineStyleMap: draftJsInlineStyleMap, + defaultTextBlockStyles, }), ...baseMigrate.migrations, ], } : 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, textBlockStyles }), + ], + } + : migrateWithDraftJs; + @BlockDataMigrationVersion(migrate.version) class TipTapRichTextBlockData extends BlockData implements TipTapRichTextBlockDataInterface { @BlockField({ type: "tipTapRichTextBlock", childBlocks }) @@ -820,6 +900,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/buildApplyDefaultTextBlockStylesMigration.test.ts b/packages/api/cms-api/src/blocks/tipTap/migrations/buildApplyDefaultTextBlockStylesMigration.test.ts new file mode 100644 index 00000000000..db1bc43c0c0 --- /dev/null +++ b/packages/api/cms-api/src/blocks/tipTap/migrations/buildApplyDefaultTextBlockStylesMigration.test.ts @@ -0,0 +1,121 @@ +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("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( + { + 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..d16e1a7d80b --- /dev/null +++ b/packages/api/cms-api/src/blocks/tipTap/migrations/buildApplyDefaultTextBlockStylesMigration.ts @@ -0,0 +1,87 @@ +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 TipTapTextBlockStyle { + name: string; + appliesTo?: string[]; +} + +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; +} + +// 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 (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, textBlockStyles)) }; + } + 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, 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, textBlockStyles) }; + } + }; +} 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 };