From 08f19b8abc6c94595a5512145de0079994995461 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 3 Sep 2026 13:09:12 +0000 Subject: [PATCH 1/5] cms-api, cms-admin: Configure the TipTap rich text block with one option per feature The block was configured with a `supports` array plus separate options for individual features (`headingLevels`, the `link` block). Enabling one feature meant listing every other feature that should stay on, and feature-specific options were disconnected from the feature they configure. `createTipTapRichTextBlock` now takes a root options object with one option per feature, similar to TipTap's StarterKit config. Features are on by default (except `underline` and `link`) and are disabled by passing `false`; feature-specific options live in a nested options object, so `headingLevels` becomes `heading: { levels: [...] }` and the link block `link: { block }`. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01DSMzvHiJUJT1RneuXcGmRV --- .changeset/tiptap-feature-options.md | 31 +++ .../src/common/blocks/TipTapRichTextBlock.tsx | 2 +- .../common/blocks/tip-tap-rich-text.block.ts | 2 +- .../2-blocks/tiptap-rich-text-block.mdx | 94 +++++-- .../src/blocks/tipTap/TipTapToolbar.tsx | 43 ++- .../TipTapRichTextBlock.stories.tsx | 33 ++- ...ipTapRichTextBlockInlineStyles.stories.tsx | 9 +- .../tipTap/createTipTapRichTextBlock.test.tsx | 12 +- .../tipTap/createTipTapRichTextBlock.tsx | 253 +++++++++++------- packages/api/cms-api/generate-block-meta.ts | 2 +- .../tipTap/createTipTapRichTextBlock.test.ts | 102 ++++--- .../tipTap/createTipTapRichTextBlock.ts | 239 +++++++++++------ .../buildDraftJsToTipTapMigration.test.ts | 2 +- .../buildDraftJsToTipTapMigration.ts | 4 +- .../migrations/convertDraftJsToTipTap.test.ts | 163 ++++++----- .../migrations/convertDraftJsToTipTap.ts | 71 ++--- 16 files changed, 672 insertions(+), 390 deletions(-) create mode 100644 .changeset/tiptap-feature-options.md diff --git a/.changeset/tiptap-feature-options.md b/.changeset/tiptap-feature-options.md new file mode 100644 index 00000000000..1d2e9dba136 --- /dev/null +++ b/.changeset/tiptap-feature-options.md @@ -0,0 +1,31 @@ +--- +"@dextinity/cms-admin": minor +"@dextinity/cms-api": minor +--- + +Replace the TipTap Rich Text Block's `supports` array with one option per feature + +`createTipTapRichTextBlock` now takes a single root options object with one option per editor feature, similar to TipTap's `StarterKit` configuration. Feature-specific options move into a nested options object of the feature they belong to: `headingLevels` becomes `heading: { levels: [...] }` and the link block becomes `link: { block: LinkBlock }`. + +Every feature is enabled by default (except `underline` and `link`) and is disabled by passing `false`, so a configuration only has to state what deviates from the defaults instead of repeating every supported feature. + +**Example** + +```ts +// Before +createTipTapRichTextBlock({ + supports: ["bold", "italic", "strike", "sub", "sup", "heading", "ordered-list", "unordered-list"], + headingLevels: [2, 3], + link: LinkBlock, +}); + +// After +createTipTapRichTextBlock({ + nonBreakingSpace: false, + softHyphen: false, + heading: { levels: [2, 3] }, + link: { block: LinkBlock }, +}); +``` + +The features are named after their option: `bold`, `italic`, `underline`, `strike`, `sub`, `sup`, `heading`, `orderedList`, `unorderedList`, `nonBreakingSpace`, `softHyphen`, `link`, and `history` (Admin only). The document-level limits `maxTextBlocks` and `listLevelMax` are unchanged. diff --git a/demo/admin/src/common/blocks/TipTapRichTextBlock.tsx b/demo/admin/src/common/blocks/TipTapRichTextBlock.tsx index 5372ea5512f..39834f76c4a 100644 --- a/demo/admin/src/common/blocks/TipTapRichTextBlock.tsx +++ b/demo/admin/src/common/blocks/TipTapRichTextBlock.tsx @@ -8,7 +8,7 @@ import { FormattedMessage } from "react-intl"; import { LinkBlock } from "./LinkBlock"; export const TipTapRichTextBlock = createTipTapRichTextBlock({ - link: LinkBlock, + link: { block: LinkBlock }, childBlocks: { productPrice: { block: ProductPriceBlock, display: "inline" }, productTeaser: { block: ProductTeaserBlock, display: "block" }, 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..870dddedb4c 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 @@ -7,7 +7,7 @@ import { Heading1ToHeading2Migration } from "./tip-tap-rich-text/migrations/2-he export const TipTapRichTextBlock = createTipTapRichTextBlock( { - link: LinkBlock, + link: { block: LinkBlock }, childBlocks: { productPrice: { block: ProductPriceBlock, display: "inline" }, productTeaser: { block: ProductTeaserBlock, display: "block" }, 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 4d74e511337..7d9d5514c89 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 @@ -46,21 +46,21 @@ The TipTap Rich Text Block only replaces the Draft.js-based `RichTextBlock`. It Most features of the Draft.js `RichTextBlock` have a direct equivalent in the TipTap Rich Text Block. -| Draft.js `RichTextBlock` | TipTap Rich Text Block | -| ----------------------------------------------- | ---------------------------------------------------------------------------------------------------- | -| `createRichTextBlock` | `createTipTapRichTextBlock` | -| `rte.supports` (`SupportedThings[]`) | `supports` (`TipTapSupports[]`) | -| `bold`, `italic`, `strikethrough`, `sub`, `sup` | `bold`, `italic`, `strike`, `sub`, `sup` | -| `header-one` … `header-six` | `heading` support + the [text-block-type select](#text-block-type-and-styling-selects) (Heading 1–6) | -| `ordered-list`, `unordered-list` | `ordered-list`, `unordered-list` | -| `history` | `history` (Admin only) | -| `link`, `links-remove` | pass a `link` block (automatically enables `link` support) | -| `non-breaking-space`, `soft-hyphen` | `non-breaking-space`, `soft-hyphen` | -| `rte.blocktypeMap` (custom block types) | [`textBlockStyles`](#text-block-type-and-styling-selects) + the styling select | -| `rte.customInlineStyles` | [`inlineStyles`](#text-block-type-and-styling-selects) + the inline style select | -| `rte.listLevelMax` | `listLevelMax` | -| `rte.maxBlocks` | `maxTextBlocks` | -| Site rendering with `redraft` + `Renderers` | Site rendering with `renderTipTapRichText` + `nodeMapping`/`markMapping` | +| Draft.js `RichTextBlock` | TipTap Rich Text Block | +| ----------------------------------------------- | -------------------------------------------------------------------------------------------- | +| `createRichTextBlock` | `createTipTapRichTextBlock` | +| `rte.supports` (`SupportedThings[]`) | one option per feature ([Features](#features)) | +| `bold`, `italic`, `strikethrough`, `sub`, `sup` | `bold`, `italic`, `strike`, `sub`, `sup` | +| `header-one` … `header-six` | `heading` + the [text-block-type select](#text-block-type-and-styling-selects) (Heading 1–6) | +| `ordered-list`, `unordered-list` | `orderedList`, `unorderedList` | +| `history` | `history` (Admin only) | +| `link`, `links-remove` | `link: { block: LinkBlock }` | +| `non-breaking-space`, `soft-hyphen` | `nonBreakingSpace`, `softHyphen` | +| `rte.blocktypeMap` (custom block types) | [`textBlockStyles`](#text-block-type-and-styling-selects) + the styling select | +| `rte.customInlineStyles` | [`inlineStyles`](#text-block-type-and-styling-selects) + the inline style select | +| `rte.listLevelMax` | `listLevelMax` | +| `rte.maxBlocks` | `maxTextBlocks` | +| Site rendering with `redraft` + `Renderers` | Site rendering with `renderTipTapRichText` + `nodeMapping`/`markMapping` | ### Setup @@ -73,7 +73,7 @@ import { createTipTapRichTextBlock } from "@dextinity/cms-admin"; import { LinkBlock } from "./LinkBlock"; -export const TipTapRichTextBlock = createTipTapRichTextBlock({ link: LinkBlock }); +export const TipTapRichTextBlock = createTipTapRichTextBlock({ link: { block: LinkBlock } }); ``` In the Admin, the style and placeholder options additionally carry rendering information (`label` and `element`) that is used to preview them in the editor — see [Text block type and styling selects](#text-block-type-and-styling-selects). @@ -87,14 +87,14 @@ import { createTipTapRichTextBlock } from "@dextinity/cms-api"; import { LinkBlock } from "./link.block"; -export const TipTapRichTextBlock = createTipTapRichTextBlock({ link: LinkBlock }); +export const TipTapRichTextBlock = createTipTapRichTextBlock({ link: { block: LinkBlock } }); ``` The factory accepts additional Block Options such as the block name, like the old one: ```ts title="tip-tap-rich-text.block.ts" export const TipTapRichTextBlock = createTipTapRichTextBlock( - { link: LinkBlock }, + { link: { block: LinkBlock } }, { name: "TipTapRichText" }, ); ``` @@ -152,13 +152,49 @@ The generated type `TipTapRichTextBlockData.tipTapContent` is `unknown` on the s ::: +### Features + +Every editor feature has its own option in the root options object, similar to [TipTap's `StarterKit`](https://tiptap.dev/docs/editor/extensions/functionality/starterkit). Most features are enabled by default and are turned off by passing `false`: + +| Option | Default | Available in | +| ------------------ | ------- | ------------ | +| `bold` | `true` | API + Admin | +| `italic` | `true` | API + Admin | +| `underline` | `false` | API + Admin | +| `strike` | `true` | API + Admin | +| `sub` | `true` | API + Admin | +| `sup` | `true` | API + Admin | +| `heading` | `true` | API + Admin | +| `orderedList` | `true` | API + Admin | +| `unorderedList` | `true` | API + Admin | +| `nonBreakingSpace` | `true` | API + Admin | +| `softHyphen` | `true` | API + Admin | +| `link` | `false` | API + Admin | +| `history` | `true` | Admin only | + +Features that can be configured further take an options object instead of `true`: `heading` accepts the allowed `levels`, `link` takes the `block` used for links (passing it enables links). + +```ts title="tip-tap-rich-text.block.ts" +export const TipTapRichTextBlock = createTipTapRichTextBlock({ + // Turn a feature off + strike: false, + // Turn a feature on that is disabled by default + underline: true, + // Configure a feature + heading: { levels: [2, 3] }, + link: { block: LinkBlock }, +}); +``` + +The limits on the document as a whole aren't tied to a single feature and stay at the root of the options object: `maxTextBlocks` limits the number of top-level text blocks and `listLevelMax` the nesting depth of lists. + ### Migrating existing content Existing content stored by the Draft.js `RichTextBlock` (`{ draftContent: { blocks, entityMap } }`) can be migrated in place. Enable the built-in migration with the `migrateFromDraftJs` option on the **API** factory: ```ts title="tip-tap-rich-text.block.ts" export const TipTapRichTextBlock = createTipTapRichTextBlock({ - link: LinkBlock, + link: { block: LinkBlock }, // highlight-next-line migrateFromDraftJs: true, }); @@ -174,7 +210,7 @@ The migration converts: - `LINK` entities → TipTap `link` marks, - ` ` / `­` → non-breaking-space / soft-hyphen nodes. -It uses the block's `supports`, `textBlockStyles`, `link`, and `maxTextBlocks` options to build the target schema and validates the result. The conversion is **best effort**: if validation fails, it falls back to a stripped-down plain-text document in production (logging a warning) and throws in development so you can catch problems early. +It uses the block's enabled features and its `textBlockStyles` and `maxTextBlocks` options to build the target schema and validates the result. The conversion is **best effort**: if validation fails, it falls back to a stripped-down plain-text document in production (logging a warning) and throws in development so you can catch problems early. :::caution Test with production content @@ -188,7 +224,7 @@ If the old block used custom block types (via `blocktypeMap`) or custom inline s ```ts title="tip-tap-rich-text.block.ts" export const TipTapRichTextBlock = createTipTapRichTextBlock({ - link: LinkBlock, + link: { block: LinkBlock }, textBlockStyles: [{ name: "paragraph200", appliesTo: ["paragraph"] }], inlineStyles: [{ name: "highlight" }], // highlight-start @@ -209,7 +245,9 @@ export const TipTapRichTextBlock = createTipTapRichTextBlock({ textBlockStyles: [{ name: "headline450", appliesTo: ["heading-2"] }], migrateFromDraftJs: { // highlight-next-line - textBlockStyleMap: { headline450: { textBlockType: "heading-2", textBlockStyle: "headline450" } }, + textBlockStyleMap: { + headline450: { textBlockType: "heading-2", textBlockStyle: "headline450" }, + }, }, }); ``` @@ -225,7 +263,7 @@ import { createTipTapRichTextBlock, typeSafeBlockMigrationPipe } from "@dextinit export const TipTapRichTextBlock = createTipTapRichTextBlock( { - link: LinkBlock, + link: { block: LinkBlock }, migrateFromDraftJs: true, }, { @@ -247,7 +285,7 @@ Beyond replacing the old block, the TipTap Rich Text Block adds capabilities the The editor toolbar offers two dropdowns to structure and style text blocks: -1. **Text block type** — the semantic type of the current block: _Default_ (paragraph) or _Heading 1_ … _Heading 6_. Shown when `heading` is in `supports`. +1. **Text block type** — the semantic type of the current block: _Default_ (paragraph) or _Heading 1_ … _Heading 6_. Shown when the `heading` feature is enabled. 2. **Styling** — a named style applied to the current text block (`textBlockStyles`). This decouples semantics ("this is a paragraph") from appearance ("small paragraph"), so editors pick a type _and_ a style independently. There is a matching **inline style** dropdown for `inlineStyles`, which applies named styles to a text selection. @@ -256,7 +294,7 @@ Styles are configured on the API (`name` + optional `appliesTo`) and in the Admi ```ts title="tip-tap-rich-text.block.ts (API)" export const TipTapRichTextBlock = createTipTapRichTextBlock({ - link: LinkBlock, + link: { block: LinkBlock }, textBlockStyles: [ { name: "paragraph300", appliesTo: ["paragraph"] }, { name: "paragraph200", appliesTo: ["paragraph"] }, @@ -271,7 +309,7 @@ import type { HTMLAttributes } from "react"; import { FormattedMessage } from "react-intl"; export const TipTapRichTextBlock = createTipTapRichTextBlock({ - link: LinkBlock, + link: { block: LinkBlock }, textBlockStyles: [ { name: "paragraph300", @@ -341,7 +379,7 @@ The same configuration is used on the API and in the Admin: import { createTipTapRichTextBlock } from "@dextinity/cms-api"; export const TipTapRichTextBlock = createTipTapRichTextBlock({ - link: LinkBlock, + link: { block: LinkBlock }, childBlocks: { productPrice: { block: ProductPriceBlock, display: "inline" }, productTeaser: { block: ProductTeaserBlock, display: "block" }, @@ -353,7 +391,7 @@ export const TipTapRichTextBlock = createTipTapRichTextBlock({ import { createTipTapRichTextBlock } from "@dextinity/cms-admin"; export const TipTapRichTextBlock = createTipTapRichTextBlock({ - link: LinkBlock, + link: { block: LinkBlock }, childBlocks: { productPrice: { block: ProductPriceBlock, display: "inline" }, productTeaser: { block: ProductTeaserBlock, display: "block" }, diff --git a/packages/admin/cms-admin/src/blocks/tipTap/TipTapToolbar.tsx b/packages/admin/cms-admin/src/blocks/tipTap/TipTapToolbar.tsx index 41580cf5158..9e40c98ddc5 100644 --- a/packages/admin/cms-admin/src/blocks/tipTap/TipTapToolbar.tsx +++ b/packages/admin/cms-admin/src/blocks/tipTap/TipTapToolbar.tsx @@ -41,9 +41,9 @@ import { FormattedMessage, useIntl } from "react-intl"; import type { BlockInterface, BlockState, LinkBlockInterface } from "../types"; import type { TipTapChildBlock, + TipTapFeatures, TipTapInlineStyle, TipTapPlaceholder, - TipTapSupports, TipTapTextBlockStyle, TipTapTextBlockType, } from "./createTipTapRichTextBlock"; @@ -159,24 +159,22 @@ const selectSx = { export const TipTapToolbar = ({ editor, - supports, + features, textBlockStyles, inlineStyles, placeholders, linkBlock, childBlocks, listLevelMax, - headingLevels = [1, 2, 3, 4, 5, 6], }: { editor: Editor; - supports: TipTapSupports[]; + features: TipTapFeatures; textBlockStyles: TipTapTextBlockStyle[]; inlineStyles: TipTapInlineStyle[]; placeholders: TipTapPlaceholder[]; linkBlock?: BlockInterface & LinkBlockInterface; childBlocks: Record; listLevelMax?: number; - headingLevels?: number[]; }) => { const intl = useIntl(); const [moreAnchorEl, setMoreAnchorEl] = useState(null); @@ -184,11 +182,12 @@ export const TipTapToolbar = ({ const [childBlockAnchorEl, setChildBlockAnchorEl] = useState(null); const [insertChildBlock, setInsertChildBlock] = useState<({ key: string } & TipTapChildBlock) | null>(null); const [linkDialogOpen, setLinkDialogOpen] = useState(false); - const hasInlineFormatButtons = (["bold", "italic", "underline", "strike"] as const).some((s) => supports.includes(s)); - const moreOptions = (["sub", "sup"] as const).some((s) => supports.includes(s)); - const lists = (["ordered-list", "unordered-list"] as const).some((s) => supports.includes(s)); - const specialChars = (["non-breaking-space", "soft-hyphen"] as const).some((s) => supports.includes(s)); - const hasLink = supports.includes("link") && !!linkBlock; + const hasInlineFormatButtons = features.bold || features.italic || features.underline || features.strike; + const moreOptions = features.sub || features.sup; + const lists = features.orderedList || features.unorderedList; + const specialChars = features.nonBreakingSpace || features.softHyphen; + const hasLink = features.link && !!linkBlock; + const headingLevels = features.heading ? features.heading.levels : []; const hasPlaceholders = placeholders.length > 0; const hasChildBlocks = Object.keys(childBlocks).length > 0; @@ -303,7 +302,7 @@ export const TipTapToolbar = ({ isActive: boolean; onToggle: () => void; }[] = [ - ...(supports.includes("sup") + ...(features.sup ? [ { key: "superscript", @@ -314,7 +313,7 @@ export const TipTapToolbar = ({ }, ] : []), - ...(supports.includes("sub") + ...(features.sub ? [ { key: "subscript", @@ -386,7 +385,7 @@ export const TipTapToolbar = ({ px: "6px", }} > - {supports.includes("history") && ( + {features.history && ( )} - {supports.includes("heading") && ( + {features.heading && ( 0) && ( - {features.bold && ( + {resolvedOptions.bold && ( editor.chain().focus().toggleBold().run()} /> )} - {features.italic && ( + {resolvedOptions.italic && ( editor.chain().focus().toggleItalic().run()} /> )} - {features.underline && ( + {resolvedOptions.underline && ( editor.chain().focus().toggleUnderline().run()} /> )} - {features.strike && ( + {resolvedOptions.strike && ( - {features.orderedList && ( + {resolvedOptions.orderedList && ( editor.chain().focus().toggleOrderedList().run()} /> )} - {features.unorderedList && ( + {resolvedOptions.unorderedList && ( - {features.nonBreakingSpace && ( + {resolvedOptions.nonBreakingSpace && ( editor.chain().focus().insertContent({ type: "nonBreakingSpace" }).run()} /> )} - {features.softHyphen && ( + {resolvedOptions.softHyphen && ( >; - features: TipTapFeatures; + resolvedOptions: TipTapResolvedOptions; textBlockStyles: TipTapTextBlockStyle[]; inlineStyles: TipTapInlineStyle[]; placeholders: TipTapPlaceholder[]; @@ -458,7 +458,7 @@ const TipTapEditor = ({ }) => { const hasTextBlockStyles = textBlockStyles.length > 0; const hasInlineStyles = inlineStyles.length > 0; - const hasLink = features.link && !!linkBlock; + const hasLink = resolvedOptions.link && !!linkBlock; const hasPlaceholders = placeholders.length > 0; const childBlockEntries = Object.values(childBlocks); const hasBlockChildBlocks = childBlockEntries.some((childBlock) => childBlock.display === "block"); @@ -468,26 +468,26 @@ const TipTapEditor = ({ const editor = useEditor({ extensions: [ StarterKit.configure({ - bold: features.bold ? {} : false, - italic: features.italic ? {} : false, - underline: features.underline ? {} : false, - strike: features.strike ? {} : false, - heading: features.heading && !hasTextBlockStyles ? { levels: features.heading.levels } : false, + bold: resolvedOptions.bold ? {} : false, + italic: resolvedOptions.italic ? {} : false, + underline: resolvedOptions.underline ? {} : false, + strike: resolvedOptions.strike ? {} : false, + heading: resolvedOptions.heading && !hasTextBlockStyles ? { levels: resolvedOptions.heading.levels } : false, paragraph: hasTextBlockStyles ? false : undefined, - orderedList: features.orderedList ? {} : false, - bulletList: features.unorderedList ? {} : false, + orderedList: resolvedOptions.orderedList ? {} : false, + bulletList: resolvedOptions.unorderedList ? {} : false, blockquote: false, code: false, codeBlock: false, link: false, }), ...(hasTextBlockStyles ? [TextBlockStyleParagraph] : []), - ...(hasTextBlockStyles && features.heading ? [TextBlockStyleHeading.configure({ levels: features.heading.levels })] : []), + ...(hasTextBlockStyles && resolvedOptions.heading ? [TextBlockStyleHeading.configure({ levels: resolvedOptions.heading.levels })] : []), ...(hasInlineStyles ? [InlineStyleMark] : []), - ...(features.sup ? [Superscript] : []), - ...(features.sub ? [Subscript] : []), - ...(features.nonBreakingSpace ? [NonBreakingSpace] : []), - ...(features.softHyphen ? [SoftHyphen] : []), + ...(resolvedOptions.sup ? [Superscript] : []), + ...(resolvedOptions.sub ? [Subscript] : []), + ...(resolvedOptions.nonBreakingSpace ? [NonBreakingSpace] : []), + ...(resolvedOptions.softHyphen ? [SoftHyphen] : []), ...(hasPlaceholders ? [Placeholder] : []), ...(hasLink ? [CmsLink] : []), ...(hasBlockChildBlocks ? [CmsBlock] : []), @@ -554,7 +554,7 @@ const TipTapEditor = ({ { - const features = resolveTipTapFeatures(options); + const resolvedOptions = resolveTipTapOptions(options); const textBlockStyles = options.textBlockStyles ?? []; const inlineStyles = options.inlineStyles ?? []; const placeholders = options.placeholders ?? []; @@ -590,7 +590,7 @@ export const createTipTapRichTextBlock = (options: TipTapRichTextBlockFactoryOpt const listLevelMax = options.listLevelMax; const sharedEditorProps = { - features, + resolvedOptions, textBlockStyles, inlineStyles, placeholders, diff --git a/packages/api/cms-api/src/blocks/tipTap/createTipTapRichTextBlock.ts b/packages/api/cms-api/src/blocks/tipTap/createTipTapRichTextBlock.ts index e2e4086f31c..3d554b8d72c 100644 --- a/packages/api/cms-api/src/blocks/tipTap/createTipTapRichTextBlock.ts +++ b/packages/api/cms-api/src/blocks/tipTap/createTipTapRichTextBlock.ts @@ -48,9 +48,9 @@ interface TipTapHeadingOptions { } /** - * The enabled features, resolved from the block's options. + * The block's options with the defaults applied and the heading levels validated. */ -export interface TipTapFeatures { +export interface TipTapResolvedOptions { bold: boolean; italic: boolean; underline: boolean; @@ -211,7 +211,7 @@ export interface CreateTipTapRichTextBlockOptions { migrateFromDraftJs?: boolean | { textBlockStyleMap?: Record; inlineStyleMap?: Record }; } -export function resolveTipTapFeatures({ +export function resolveTipTapOptions({ bold = true, italic = true, underline = false, @@ -224,7 +224,7 @@ export function resolveTipTapFeatures({ nonBreakingSpace = true, softHyphen = true, link, -}: CreateTipTapRichTextBlockOptions = {}): TipTapFeatures { +}: CreateTipTapRichTextBlockOptions = {}): TipTapResolvedOptions { const headingLevels = (heading !== false && heading !== true ? heading.levels : undefined) ?? allHeadingLevels; if (!isValidHeadingLevels(headingLevels)) { @@ -248,14 +248,14 @@ export function resolveTipTapFeatures({ } function buildExtensions({ - features, + resolvedOptions, textBlockStyles, inlineStyles, placeholders, hasBlockChildBlocks, hasInlineChildBlocks, }: { - features: TipTapFeatures; + resolvedOptions: TipTapResolvedOptions; textBlockStyles: TipTapTextBlockStyle[]; inlineStyles: TipTapInlineStyle[]; placeholders: TipTapPlaceholder[]; @@ -267,28 +267,28 @@ function buildExtensions({ const hasPlaceholders = placeholders.length > 0; return [ StarterKit.configure({ - bold: features.bold ? {} : false, - italic: features.italic ? {} : false, - underline: features.underline ? {} : false, - strike: features.strike ? {} : false, - heading: features.heading && !hasTextBlockStyles ? { levels: features.heading.levels } : false, + bold: resolvedOptions.bold ? {} : false, + italic: resolvedOptions.italic ? {} : false, + underline: resolvedOptions.underline ? {} : false, + strike: resolvedOptions.strike ? {} : false, + heading: resolvedOptions.heading && !hasTextBlockStyles ? { levels: resolvedOptions.heading.levels } : false, paragraph: hasTextBlockStyles ? false : undefined, - orderedList: features.orderedList ? {} : false, - bulletList: features.unorderedList ? {} : false, + orderedList: resolvedOptions.orderedList ? {} : false, + bulletList: resolvedOptions.unorderedList ? {} : false, blockquote: false, code: false, codeBlock: false, link: false, }), ...(hasTextBlockStyles ? [TextBlockStyleParagraph] : []), - ...(hasTextBlockStyles && features.heading ? [TextBlockStyleHeading.configure({ levels: features.heading.levels })] : []), + ...(hasTextBlockStyles && resolvedOptions.heading ? [TextBlockStyleHeading.configure({ levels: resolvedOptions.heading.levels })] : []), ...(hasInlineStyles ? [InlineStyleMark] : []), - ...(features.sup ? [Superscript] : []), - ...(features.sub ? [Subscript] : []), - ...(features.nonBreakingSpace ? [NonBreakingSpace] : []), - ...(features.softHyphen ? [SoftHyphen] : []), + ...(resolvedOptions.sup ? [Superscript] : []), + ...(resolvedOptions.sub ? [Subscript] : []), + ...(resolvedOptions.nonBreakingSpace ? [NonBreakingSpace] : []), + ...(resolvedOptions.softHyphen ? [SoftHyphen] : []), ...(hasPlaceholders ? [Placeholder] : []), - ...(features.link ? [CmsLink] : []), + ...(resolvedOptions.link ? [CmsLink] : []), ...(hasBlockChildBlocks ? [CmsBlock] : []), ...(hasInlineChildBlocks ? [CmsInlineBlock] : []), ]; @@ -629,15 +629,15 @@ export function createTipTapRichTextBlock( const blockName = typeof nameOrOptions === "string" ? nameOrOptions : nameOrOptions.name; const baseMigrate = typeof nameOrOptions !== "string" && nameOrOptions.migrate ? nameOrOptions.migrate : { migrations: [], version: 0 }; - const features = resolveTipTapFeatures(options); - const headingLevels = features.heading ? features.heading.levels : []; + const resolvedOptions = resolveTipTapOptions(options); + const headingLevels = resolvedOptions.heading ? resolvedOptions.heading.levels : []; const childBlocks: Record = Object.fromEntries(Object.entries(childBlocksConfig).map(([key, { block }]) => [key, block])); const childBlockConfigs = Object.values(childBlocksConfig); const hasChildBlocks = childBlockConfigs.length > 0; const hasBlockChildBlocks = childBlockConfigs.some(({ display }) => display === "block"); const hasInlineChildBlocks = childBlockConfigs.some(({ display }) => display === "inline"); const extensions = buildExtensions({ - features, + resolvedOptions, textBlockStyles, inlineStyles, placeholders, @@ -666,7 +666,7 @@ export function createTipTapRichTextBlock( migrations: [ buildDraftJsToTipTapMigration({ schema, - features, + resolvedOptions, link: LinkBlock, maxTextBlocks, listLevelMax, 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 c2197153d63..176a57b7eb2 100644 --- a/packages/api/cms-api/src/blocks/tipTap/migrations/buildDraftJsToTipTapMigration.ts +++ b/packages/api/cms-api/src/blocks/tipTap/migrations/buildDraftJsToTipTapMigration.ts @@ -40,7 +40,7 @@ interface BuildOptions extends ConvertOptions { const EMPTY_DOC: JSONContent = { type: "doc", content: [{ type: "paragraph" }] }; export function buildDraftJsToTipTapMigration(options: BuildOptions): ClassConstructor { - const { schema, maxTextBlocks, headingLevels, features, link, textBlockStyleMap, inlineStyleMap, listLevelMax } = options; + const { schema, maxTextBlocks, headingLevels, resolvedOptions, link, textBlockStyleMap, inlineStyleMap, listLevelMax } = options; return class DraftJsToTipTapMigration extends BlockMigration<(from: From) => To> implements BlockMigrationInterface { public readonly toVersion = 1; @@ -54,7 +54,7 @@ export function buildDraftJsToTipTapMigration(options: BuildOptions): ClassConst return { tipTapContent: EMPTY_DOC }; } - const converted = convertDraftJsToTipTap(from.draftContent, { features, link, textBlockStyleMap, inlineStyleMap, listLevelMax }); + const converted = convertDraftJsToTipTap(from.draftContent, { resolvedOptions, link, textBlockStyleMap, inlineStyleMap, listLevelMax }); if (isValidTipTapContentSync(converted, schema, { maxTextBlocks, listLevelMax, headingLevels })) { return { tipTapContent: converted }; } diff --git a/packages/api/cms-api/src/blocks/tipTap/migrations/convertDraftJsToTipTap.test.ts b/packages/api/cms-api/src/blocks/tipTap/migrations/convertDraftJsToTipTap.test.ts index 1c3e62c40d0..a5d57eeceb6 100644 --- a/packages/api/cms-api/src/blocks/tipTap/migrations/convertDraftJsToTipTap.test.ts +++ b/packages/api/cms-api/src/blocks/tipTap/migrations/convertDraftJsToTipTap.test.ts @@ -1,11 +1,11 @@ import { describe, expect, it } from "vitest"; import type { Block } from "../../block"; -import { resolveTipTapFeatures } from "../createTipTapRichTextBlock"; +import { resolveTipTapOptions } from "../createTipTapRichTextBlock"; import { buildStrippedTipTapDoc, convertDraftJsToTipTap, type DraftJsContent } from "./convertDraftJsToTipTap"; -const allFeatures = resolveTipTapFeatures({ underline: true }); -const noFeatures = resolveTipTapFeatures({ +const allEnabled = resolveTipTapOptions({ underline: true }); +const allDisabled = resolveTipTapOptions({ bold: false, italic: false, strike: false, @@ -39,12 +39,12 @@ describe("convertDraftJsToTipTap", () => { describe("empty input", () => { it("returns minimal doc for undefined input", () => { // eslint-disable-next-line @typescript-eslint/no-explicit-any - const result = convertDraftJsToTipTap(undefined as any, { features: allFeatures }); + const result = convertDraftJsToTipTap(undefined as any, { resolvedOptions: allEnabled }); expect(result).toEqual({ type: "doc", content: [{ type: "paragraph" }] }); }); it("returns minimal doc for empty blocks array", () => { - const result = convertDraftJsToTipTap({ blocks: [], entityMap: {} }, { features: allFeatures }); + const result = convertDraftJsToTipTap({ blocks: [], entityMap: {} }, { resolvedOptions: allEnabled }); expect(result).toEqual({ type: "doc", content: [{ type: "paragraph" }] }); }); }); @@ -53,7 +53,7 @@ describe("convertDraftJsToTipTap", () => { it("maps unstyled to paragraph", () => { const result = convertDraftJsToTipTap( { blocks: [makeBlock({ type: "unstyled", text: "Hello" })], entityMap: {} }, - { features: allFeatures }, + { resolvedOptions: allEnabled }, ); expect(result).toEqual({ type: "doc", @@ -69,14 +69,14 @@ describe("convertDraftJsToTipTap", () => { ["header-five", 5], ["header-six", 6], ])("maps %s to heading level %d", (type, level) => { - const result = convertDraftJsToTipTap({ blocks: [makeBlock({ type, text: "Title" })], entityMap: {} }, { features: allFeatures }); + const result = convertDraftJsToTipTap({ blocks: [makeBlock({ type, text: "Title" })], entityMap: {} }, { resolvedOptions: allEnabled }); expect(result.content).toEqual([{ type: "heading", attrs: { level }, content: [{ type: "text", text: "Title" }] }]); }); it("falls back to paragraph when heading not supported", () => { const result = convertDraftJsToTipTap( { blocks: [makeBlock({ type: "header-one", text: "Title" })], entityMap: {} }, - { features: noFeatures }, + { resolvedOptions: allDisabled }, ); expect(result.content).toEqual([{ type: "paragraph", content: [{ type: "text", text: "Title" }] }]); }); @@ -84,25 +84,31 @@ describe("convertDraftJsToTipTap", () => { it("maps blockquote to paragraph", () => { const result = convertDraftJsToTipTap( { blocks: [makeBlock({ type: "blockquote", text: "Quote" })], entityMap: {} }, - { features: allFeatures }, + { resolvedOptions: allEnabled }, ); expect(result.content).toEqual([{ type: "paragraph", content: [{ type: "text", text: "Quote" }] }]); }); it("maps unknown block type to paragraph", () => { - const result = convertDraftJsToTipTap({ blocks: [makeBlock({ type: "atomic", text: "x" })], entityMap: {} }, { features: allFeatures }); + const result = convertDraftJsToTipTap( + { blocks: [makeBlock({ type: "atomic", text: "x" })], entityMap: {} }, + { resolvedOptions: allEnabled }, + ); expect(result.content).toEqual([{ type: "paragraph", content: [{ type: "text", text: "x" }] }]); }); it("emits empty paragraph for empty text", () => { - const result = convertDraftJsToTipTap({ blocks: [makeBlock({ type: "unstyled", text: "" })], entityMap: {} }, { features: allFeatures }); + const result = convertDraftJsToTipTap( + { blocks: [makeBlock({ type: "unstyled", text: "" })], entityMap: {} }, + { resolvedOptions: allEnabled }, + ); expect(result.content).toEqual([{ type: "paragraph" }]); }); it("maps a block type from textBlockStyleMap to a paragraph with textBlockStyle attr", () => { const result = convertDraftJsToTipTap( { blocks: [makeBlock({ type: "paragraph-small", text: "tiny" })], entityMap: {} }, - { features: allFeatures, textBlockStyleMap: { "paragraph-small": "small" } }, + { resolvedOptions: allEnabled, textBlockStyleMap: { "paragraph-small": "small" } }, ); expect(result.content).toEqual([{ type: "paragraph", attrs: { textBlockStyle: "small" }, content: [{ type: "text", text: "tiny" }] }]); }); @@ -110,7 +116,7 @@ describe("convertDraftJsToTipTap", () => { it("keeps the heading level when a header type is mapped to a textBlockStyle", () => { const result = convertDraftJsToTipTap( { blocks: [makeBlock({ type: "header-two", text: "Title" })], entityMap: {} }, - { features: allFeatures, textBlockStyleMap: { "header-two": "headline450" } }, + { resolvedOptions: allEnabled, textBlockStyleMap: { "header-two": "headline450" } }, ); expect(result.content).toEqual([ { type: "heading", attrs: { level: 2, textBlockStyle: "headline450" }, content: [{ type: "text", text: "Title" }] }, @@ -120,7 +126,7 @@ describe("convertDraftJsToTipTap", () => { it("falls back to a paragraph for a mapped header type when heading is not supported", () => { const result = convertDraftJsToTipTap( { blocks: [makeBlock({ type: "header-two", text: "Title" })], entityMap: {} }, - { features: noFeatures, textBlockStyleMap: { "header-two": "headline450" } }, + { resolvedOptions: allDisabled, textBlockStyleMap: { "header-two": "headline450" } }, ); expect(result.content).toEqual([ { type: "paragraph", attrs: { textBlockStyle: "headline450" }, content: [{ type: "text", text: "Title" }] }, @@ -131,7 +137,7 @@ describe("convertDraftJsToTipTap", () => { const result = convertDraftJsToTipTap( { blocks: [makeBlock({ type: "headline450", text: "Title" })], entityMap: {} }, { - features: allFeatures, + resolvedOptions: allEnabled, textBlockStyleMap: { headline450: { textBlockType: "heading-2", textBlockStyle: "headline450" } }, }, ); @@ -143,7 +149,7 @@ describe("convertDraftJsToTipTap", () => { it("maps a custom block type to a heading without textBlockStyle", () => { const result = convertDraftJsToTipTap( { blocks: [makeBlock({ type: "headline450", text: "Title" })], entityMap: {} }, - { features: allFeatures, textBlockStyleMap: { headline450: { textBlockType: "heading-2" } } }, + { resolvedOptions: allEnabled, textBlockStyleMap: { headline450: { textBlockType: "heading-2" } } }, ); expect(result.content).toEqual([{ type: "heading", attrs: { level: 2 }, content: [{ type: "text", text: "Title" }] }]); }); @@ -151,7 +157,7 @@ describe("convertDraftJsToTipTap", () => { it("textBlockType overrides the heading level derived from the DraftJS header type", () => { const result = convertDraftJsToTipTap( { blocks: [makeBlock({ type: "header-one", text: "Title" })], entityMap: {} }, - { features: allFeatures, textBlockStyleMap: { "header-one": { textBlockType: "heading-2" } } }, + { resolvedOptions: allEnabled, textBlockStyleMap: { "header-one": { textBlockType: "heading-2" } } }, ); expect(result.content).toEqual([{ type: "heading", attrs: { level: 2 }, content: [{ type: "text", text: "Title" }] }]); }); @@ -159,7 +165,7 @@ describe("convertDraftJsToTipTap", () => { it("converts a header type to a paragraph when mapped to textBlockType paragraph", () => { const result = convertDraftJsToTipTap( { blocks: [makeBlock({ type: "header-one", text: "Title" })], entityMap: {} }, - { features: allFeatures, textBlockStyleMap: { "header-one": { textBlockType: "paragraph", textBlockStyle: "huge" } } }, + { resolvedOptions: allEnabled, textBlockStyleMap: { "header-one": { textBlockType: "paragraph", textBlockStyle: "huge" } } }, ); expect(result.content).toEqual([{ type: "paragraph", attrs: { textBlockStyle: "huge" }, content: [{ type: "text", text: "Title" }] }]); }); @@ -168,7 +174,7 @@ describe("convertDraftJsToTipTap", () => { const result = convertDraftJsToTipTap( { blocks: [makeBlock({ type: "headline450", text: "" })], entityMap: {} }, { - features: allFeatures, + resolvedOptions: allEnabled, textBlockStyleMap: { headline450: { textBlockType: "heading-2", textBlockStyle: "headline450" } }, }, ); @@ -183,7 +189,7 @@ describe("convertDraftJsToTipTap", () => { blocks: [makeBlock({ type: "unordered-list-item", text: "a" }), makeBlock({ type: "unordered-list-item", text: "b" })], entityMap: {}, }, - { features: allFeatures }, + { resolvedOptions: allEnabled }, ); expect(result.content).toEqual([ { @@ -202,7 +208,7 @@ describe("convertDraftJsToTipTap", () => { blocks: [makeBlock({ type: "ordered-list-item", text: "1" }), makeBlock({ type: "ordered-list-item", text: "2" })], entityMap: {}, }, - { features: allFeatures }, + { resolvedOptions: allEnabled }, ); expect(result.content?.[0].type).toBe("orderedList"); expect(result.content?.[0].content).toHaveLength(2); @@ -214,7 +220,7 @@ describe("convertDraftJsToTipTap", () => { blocks: [makeBlock({ type: "ordered-list-item", text: "1" }), makeBlock({ type: "unordered-list-item", text: "a" })], entityMap: {}, }, - { features: allFeatures }, + { resolvedOptions: allEnabled }, ); expect(result.content).toHaveLength(2); expect(result.content?.[0].type).toBe("orderedList"); @@ -227,7 +233,7 @@ describe("convertDraftJsToTipTap", () => { blocks: [makeBlock({ type: "unordered-list-item", text: "a" }), makeBlock({ type: "unstyled", text: "after" })], entityMap: {}, }, - { features: allFeatures }, + { resolvedOptions: allEnabled }, ); expect(result.content).toHaveLength(2); expect(result.content?.[0].type).toBe("bulletList"); @@ -237,7 +243,7 @@ describe("convertDraftJsToTipTap", () => { it("falls back to paragraph when list type not supported", () => { const result = convertDraftJsToTipTap( { blocks: [makeBlock({ type: "unordered-list-item", text: "a" })], entityMap: {} }, - { features: noFeatures }, + { resolvedOptions: allDisabled }, ); expect(result.content).toEqual([{ type: "paragraph", content: [{ type: "text", text: "a" }] }]); }); @@ -251,7 +257,7 @@ describe("convertDraftJsToTipTap", () => { ], entityMap: {}, }, - { features: allFeatures }, + { resolvedOptions: allEnabled }, ); const list = result.content?.[0]; expect(list?.content).toHaveLength(2); @@ -270,7 +276,7 @@ describe("convertDraftJsToTipTap", () => { ], entityMap: {}, }, - { features: allFeatures }, + { resolvedOptions: allEnabled }, ); expect(result.content).toEqual([ { @@ -302,7 +308,7 @@ describe("convertDraftJsToTipTap", () => { ], entityMap: {}, }, - { features: allFeatures }, + { resolvedOptions: allEnabled }, ); const subList = result.content?.[0].content?.[0].content?.[1]; expect(subList?.type).toBe("bulletList"); @@ -319,7 +325,7 @@ describe("convertDraftJsToTipTap", () => { ], entityMap: {}, }, - { features: allFeatures }, + { resolvedOptions: allEnabled }, ); const level1 = result.content?.[0].content?.[0].content?.[1]; const level2 = level1?.content?.[0].content?.[1]; @@ -338,7 +344,7 @@ describe("convertDraftJsToTipTap", () => { ], entityMap: {}, }, - { features: allFeatures }, + { resolvedOptions: allEnabled }, ); const list = result.content?.[0]; expect(list?.content).toHaveLength(2); @@ -354,7 +360,7 @@ describe("convertDraftJsToTipTap", () => { ], entityMap: {}, }, - { features: allFeatures }, + { resolvedOptions: allEnabled }, ); const subList = result.content?.[0].content?.[0].content?.[1]; expect(subList?.type).toBe("bulletList"); @@ -364,7 +370,7 @@ describe("convertDraftJsToTipTap", () => { it("places a leading indented item on the top level", () => { const result = convertDraftJsToTipTap( { blocks: [makeBlock({ type: "unordered-list-item", text: "a", depth: 2 })], entityMap: {} }, - { features: allFeatures }, + { resolvedOptions: allEnabled }, ); expect(result.content).toEqual([ { @@ -384,7 +390,7 @@ describe("convertDraftJsToTipTap", () => { ], entityMap: {}, }, - { features: allFeatures }, + { resolvedOptions: allEnabled }, ); const parentItem = result.content?.[0].content?.[0]; expect(parentItem?.content?.map((node) => node.type)).toEqual(["paragraph", "bulletList", "orderedList"]); @@ -399,7 +405,7 @@ describe("convertDraftJsToTipTap", () => { ], entityMap: {}, }, - { features: allFeatures }, + { resolvedOptions: allEnabled }, ); expect(result.content?.[0].type).toBe("bulletList"); expect(result.content?.[0].content?.[0].content?.[1].type).toBe("orderedList"); @@ -415,7 +421,7 @@ describe("convertDraftJsToTipTap", () => { ], entityMap: {}, }, - { features: allFeatures }, + { resolvedOptions: allEnabled }, ); expect(result.content).toHaveLength(2); expect(result.content?.[0].type).toBe("bulletList"); @@ -432,7 +438,7 @@ describe("convertDraftJsToTipTap", () => { ], entityMap: {}, }, - { features: allFeatures, listLevelMax: 2 }, + { resolvedOptions: allEnabled, listLevelMax: 2 }, ); const subList = result.content?.[0].content?.[0].content?.[1]; expect(subList?.content).toHaveLength(2); @@ -448,7 +454,7 @@ describe("convertDraftJsToTipTap", () => { ], entityMap: {}, }, - { features: allFeatures, listLevelMax: 1 }, + { resolvedOptions: allEnabled, listLevelMax: 1 }, ); expect(result.content?.[0].content).toHaveLength(2); expect(result.content?.[0].content?.[1].content).toEqual([{ type: "paragraph", content: [{ type: "text", text: "a.1" }] }]); @@ -462,7 +468,7 @@ describe("convertDraftJsToTipTap", () => { blocks: [makeBlock({ type: "unstyled", text: "bold", inlineStyleRanges: [{ style: "BOLD", offset: 0, length: 4 }] })], entityMap: {}, }, - { features: allFeatures }, + { resolvedOptions: allEnabled }, ); expect(result.content?.[0].content).toEqual([{ type: "text", text: "bold", marks: [{ type: "bold" }] }]); }); @@ -473,7 +479,7 @@ describe("convertDraftJsToTipTap", () => { blocks: [makeBlock({ type: "unstyled", text: "abcdef", inlineStyleRanges: [{ style: "BOLD", offset: 2, length: 2 }] })], entityMap: {}, }, - { features: allFeatures }, + { resolvedOptions: allEnabled }, ); expect(result.content?.[0].content).toEqual([ { type: "text", text: "ab" }, @@ -497,7 +503,7 @@ describe("convertDraftJsToTipTap", () => { ], entityMap: {}, }, - { features: allFeatures }, + { resolvedOptions: allEnabled }, ); const segments = result.content?.[0].content; expect(segments).toEqual([ @@ -514,7 +520,7 @@ describe("convertDraftJsToTipTap", () => { blocks: [makeBlock({ type: "unstyled", text: "x", inlineStyleRanges: [{ style: "STRIKETHROUGH", offset: 0, length: 1 }] })], entityMap: {}, }, - { features: allFeatures }, + { resolvedOptions: allEnabled }, ); expect(result.content?.[0].content?.[0].marks).toEqual([{ type: "strike" }]); }); @@ -534,7 +540,7 @@ describe("convertDraftJsToTipTap", () => { ], entityMap: {}, }, - { features: allFeatures }, + { resolvedOptions: allEnabled }, ); const segments = result.content?.[0].content; expect(segments?.[0].marks).toEqual([{ type: "superscript" }]); @@ -547,7 +553,7 @@ describe("convertDraftJsToTipTap", () => { blocks: [makeBlock({ type: "unstyled", text: "x", inlineStyleRanges: [{ style: "UNDERLINE", offset: 0, length: 1 }] })], entityMap: {}, }, - { features: allFeatures }, + { resolvedOptions: allEnabled }, ); expect(result.content?.[0].content?.[0].marks).toEqual([{ type: "underline" }]); }); @@ -558,7 +564,7 @@ describe("convertDraftJsToTipTap", () => { blocks: [makeBlock({ type: "unstyled", text: "x", inlineStyleRanges: [{ style: "UNDERLINE", offset: 0, length: 1 }] })], entityMap: {}, }, - { features: { ...noFeatures, bold: true } }, + { resolvedOptions: { ...allDisabled, bold: true } }, ); expect(result.content?.[0].content).toEqual([{ type: "text", text: "x" }]); }); @@ -569,7 +575,7 @@ describe("convertDraftJsToTipTap", () => { blocks: [makeBlock({ type: "unstyled", text: "x", inlineStyleRanges: [{ style: "WAT", offset: 0, length: 1 }] })], entityMap: {}, }, - { features: allFeatures }, + { resolvedOptions: allEnabled }, ); expect(result.content?.[0].content).toEqual([{ type: "text", text: "x" }]); }); @@ -580,7 +586,7 @@ describe("convertDraftJsToTipTap", () => { blocks: [makeBlock({ type: "unstyled", text: "x", inlineStyleRanges: [{ style: "BOLD", offset: 0, length: 1 }] })], entityMap: {}, }, - { features: { ...noFeatures, italic: true } }, + { resolvedOptions: { ...allDisabled, italic: true } }, ); expect(result.content?.[0].content).toEqual([{ type: "text", text: "x" }]); }); @@ -598,7 +604,7 @@ describe("convertDraftJsToTipTap", () => { ], entityMap: {}, }, - { features: allFeatures }, + { resolvedOptions: allEnabled }, ), ).not.toThrow(); }); @@ -609,7 +615,7 @@ describe("convertDraftJsToTipTap", () => { blocks: [makeBlock({ type: "unstyled", text: "hi", inlineStyleRanges: [{ style: "highlight", offset: 0, length: 2 }] })], entityMap: {}, }, - { features: allFeatures, inlineStyleMap: { highlight: "highlight" } }, + { resolvedOptions: allEnabled, inlineStyleMap: { highlight: "highlight" } }, ); expect(result.content?.[0].content).toEqual([ { type: "text", text: "hi", marks: [{ type: "inlineStyle", attrs: { type: "highlight" } }] }, @@ -622,7 +628,7 @@ describe("convertDraftJsToTipTap", () => { blocks: [makeBlock({ type: "unstyled", text: "x", inlineStyleRanges: [{ style: "HIGHLIGHT", offset: 0, length: 1 }] })], entityMap: {}, }, - { features: allFeatures, inlineStyleMap: { HIGHLIGHT: "highlight" } }, + { resolvedOptions: allEnabled, inlineStyleMap: { HIGHLIGHT: "highlight" } }, ); expect(result.content?.[0].content).toEqual([ { type: "text", text: "x", marks: [{ type: "inlineStyle", attrs: { type: "highlight" } }] }, @@ -635,7 +641,7 @@ describe("convertDraftJsToTipTap", () => { blocks: [makeBlock({ type: "unstyled", text: "x", inlineStyleRanges: [{ style: "unknown-style", offset: 0, length: 1 }] })], entityMap: {}, }, - { features: allFeatures, inlineStyleMap: { highlight: "highlight" } }, + { resolvedOptions: allEnabled, inlineStyleMap: { highlight: "highlight" } }, ); expect(result.content?.[0].content).toEqual([{ type: "text", text: "x" }]); }); @@ -655,7 +661,7 @@ describe("convertDraftJsToTipTap", () => { ], entityMap: {}, }, - { features: allFeatures, inlineStyleMap: { highlight: "highlight" } }, + { resolvedOptions: allEnabled, inlineStyleMap: { highlight: "highlight" } }, ); expect(result.content?.[0].content).toEqual([ { type: "text", text: "x", marks: [{ type: "bold" }, { type: "inlineStyle", attrs: { type: "highlight" } }] }, @@ -667,7 +673,7 @@ describe("convertDraftJsToTipTap", () => { it("splits a U+00A0 character into a nonBreakingSpace atom node", () => { const result = convertDraftJsToTipTap( { blocks: [makeBlock({ type: "unstyled", text: "a b" })], entityMap: {} }, - { features: allFeatures }, + { resolvedOptions: allEnabled }, ); expect(result.content?.[0].content).toEqual([{ type: "text", text: "a" }, { type: "nonBreakingSpace" }, { type: "text", text: "b" }]); }); @@ -675,7 +681,7 @@ describe("convertDraftJsToTipTap", () => { it("splits a U+00AD character into a softHyphen atom node", () => { const result = convertDraftJsToTipTap( { blocks: [makeBlock({ type: "unstyled", text: "long­word" })], entityMap: {} }, - { features: allFeatures }, + { resolvedOptions: allEnabled }, ); expect(result.content?.[0].content).toEqual([{ type: "text", text: "long" }, { type: "softHyphen" }, { type: "text", text: "word" }]); }); @@ -692,7 +698,7 @@ describe("convertDraftJsToTipTap", () => { ], entityMap: {}, }, - { features: allFeatures }, + { resolvedOptions: allEnabled }, ); expect(result.content?.[0].content).toEqual([ { type: "text", text: "a", marks: [{ type: "bold" }] }, @@ -704,7 +710,7 @@ describe("convertDraftJsToTipTap", () => { it("handles consecutive and mixed atom characters", () => { const result = convertDraftJsToTipTap( { blocks: [makeBlock({ type: "unstyled", text: "a ­b" })], entityMap: {} }, - { features: allFeatures }, + { resolvedOptions: allEnabled }, ); expect(result.content?.[0].content).toEqual([ { type: "text", text: "a" }, @@ -717,7 +723,7 @@ describe("convertDraftJsToTipTap", () => { it("keeps atom characters as plain text when the feature is disabled", () => { const result = convertDraftJsToTipTap( { blocks: [makeBlock({ type: "unstyled", text: "a b­c" })], entityMap: {} }, - { features: { ...noFeatures, bold: true } }, + { resolvedOptions: { ...allDisabled, bold: true } }, ); expect(result.content?.[0].content).toEqual([{ type: "text", text: "a b­c" }]); }); @@ -725,7 +731,7 @@ describe("convertDraftJsToTipTap", () => { it("splits only the supported atom character when one of the two is disabled", () => { const result = convertDraftJsToTipTap( { blocks: [makeBlock({ type: "unstyled", text: "a b­c" })], entityMap: {} }, - { features: { ...noFeatures, nonBreakingSpace: true } }, + { resolvedOptions: { ...allDisabled, nonBreakingSpace: true } }, ); expect(result.content?.[0].content).toEqual([{ type: "text", text: "a" }, { type: "nonBreakingSpace" }, { type: "text", text: "b­c" }]); }); @@ -744,7 +750,7 @@ describe("convertDraftJsToTipTap", () => { ], entityMap: { "0": { type: "LINK", mutability: "MUTABLE", data: { href: "https://example.com" } } }, }, - { features: allFeatures, link: dummyLinkBlock }, + { resolvedOptions: allEnabled, link: dummyLinkBlock }, ); expect(result.content?.[0].content).toEqual([ { type: "text", text: "click", marks: [{ type: "link", attrs: { data: { href: "https://example.com" } } }] }, @@ -763,7 +769,7 @@ describe("convertDraftJsToTipTap", () => { ], entityMap: { "0": { type: "LINK", mutability: "MUTABLE", data: { href: "https://example.com" } } }, }, - { features: allFeatures }, + { resolvedOptions: allEnabled }, ); expect(result.content?.[0].content).toEqual([{ type: "text", text: "click" }]); }); @@ -786,7 +792,7 @@ describe("convertDraftJsToTipTap", () => { "1": { type: "LINK", mutability: "MUTABLE", data: { href: "https://c.com" } }, }, }, - { features: allFeatures, link: dummyLinkBlock }, + { resolvedOptions: allEnabled, link: dummyLinkBlock }, ); const segments = result.content?.[0].content; expect(segments?.[0].marks).toEqual([{ type: "link", attrs: { data: { href: "https://a.com" } } }]); @@ -806,7 +812,7 @@ describe("convertDraftJsToTipTap", () => { ], entityMap: { "0": { type: "LINK", mutability: "MUTABLE", data: { href: "https://x.com" } } }, }, - { features: allFeatures, link: dummyLinkBlock }, + { resolvedOptions: allEnabled, link: dummyLinkBlock }, ); const marks = result.content?.[0].content?.[0].marks; expect(marks).toEqual([{ type: "bold" }, { type: "link", attrs: { data: { href: "https://x.com" } } }]); @@ -824,7 +830,7 @@ describe("convertDraftJsToTipTap", () => { ], entityMap: { "0": { type: "IMAGE", mutability: "IMMUTABLE", data: {} } }, }, - { features: allFeatures, link: dummyLinkBlock }, + { resolvedOptions: allEnabled, link: dummyLinkBlock }, ); expect(result.content?.[0].content).toEqual([{ type: "text", text: "x" }]); }); @@ -841,7 +847,7 @@ describe("convertDraftJsToTipTap", () => { ], entityMap: {}, }, - { features: allFeatures, link: dummyLinkBlock }, + { resolvedOptions: allEnabled, link: dummyLinkBlock }, ); expect(result.content?.[0].content).toEqual([{ type: "text", text: "x" }]); }); 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 eca20f09f8d..c00a59f9764 100644 --- a/packages/api/cms-api/src/blocks/tipTap/migrations/convertDraftJsToTipTap.ts +++ b/packages/api/cms-api/src/blocks/tipTap/migrations/convertDraftJsToTipTap.ts @@ -1,7 +1,7 @@ import type { JSONContent } from "@tiptap/core"; import type { Block } from "../../block"; -import type { TipTapFeatures } from "../createTipTapRichTextBlock"; +import type { TipTapResolvedOptions } from "../createTipTapRichTextBlock"; interface DraftJsInlineStyleRange { style: string; @@ -57,7 +57,7 @@ interface TextBlockStyleMapping { } interface ConvertOptions { - features: TipTapFeatures; + resolvedOptions: TipTapResolvedOptions; link?: Block; /** * Maps DraftJS block types (e.g. custom `paragraph-small`) to a TipTap `textBlockStyle` @@ -81,15 +81,15 @@ interface ConvertOptions { listLevelMax?: number; } -type TipTapMarkFeature = "bold" | "italic" | "underline" | "strike" | "sup" | "sub"; +type TipTapMarkOption = "bold" | "italic" | "underline" | "strike" | "sup" | "sub"; -const INLINE_STYLE_TO_MARK: Record = { - BOLD: { mark: "bold", feature: "bold" }, - ITALIC: { mark: "italic", feature: "italic" }, - UNDERLINE: { mark: "underline", feature: "underline" }, - STRIKETHROUGH: { mark: "strike", feature: "strike" }, - SUP: { mark: "superscript", feature: "sup" }, - SUB: { mark: "subscript", feature: "sub" }, +const INLINE_STYLE_TO_MARK: Record = { + BOLD: { mark: "bold", option: "bold" }, + ITALIC: { mark: "italic", option: "italic" }, + UNDERLINE: { mark: "underline", option: "underline" }, + STRIKETHROUGH: { mark: "strike", option: "strike" }, + SUP: { mark: "superscript", option: "sup" }, + SUB: { mark: "subscript", option: "sub" }, }; const HEADER_TYPE_TO_LEVEL: Record = { @@ -127,13 +127,13 @@ interface InlineSegment { function buildInlineContent({ block, entityMap, - features, + resolvedOptions, hasLink, inlineStyleMap, }: { block: DraftJsBlock; entityMap: Record; - features: TipTapFeatures; + resolvedOptions: TipTapResolvedOptions; hasLink: boolean; inlineStyleMap: Record; }): JSONContent[] { @@ -184,7 +184,7 @@ function buildInlineContent({ for (const range of styleRanges) { if (range.start <= start && range.end >= end) { const mapping = INLINE_STYLE_TO_MARK[range.style]; - if (mapping && features[mapping.feature]) { + if (mapping && resolvedOptions[mapping.option]) { if (!marks.some((mark) => mark.type === mapping.mark)) { marks.push({ type: mapping.mark }); } @@ -213,7 +213,7 @@ function buildInlineContent({ segments.push({ text: segmentText, marks }); } - return segments.flatMap((segment) => splitAtomChars(segment.text, segment.marks, features)); + return segments.flatMap((segment) => splitAtomChars(segment.text, segment.marks, resolvedOptions)); } const NBSP_CHAR = "\u00a0"; @@ -231,8 +231,8 @@ function makeTextNode(text: string, marks: NonNullable): J // RTE persists non-breaking-spaces and soft-hyphens) becomes a dedicated TipTap atom node // when the corresponding feature is supported. Otherwise the characters are preserved as-is // inside the surrounding text node. -function splitAtomChars(text: string, marks: NonNullable, features: TipTapFeatures): JSONContent[] { - const { nonBreakingSpace, softHyphen } = features; +function splitAtomChars(text: string, marks: NonNullable, resolvedOptions: TipTapResolvedOptions): JSONContent[] { + const { nonBreakingSpace, softHyphen } = resolvedOptions; if ((!nonBreakingSpace && !softHyphen) || (!text.includes(NBSP_CHAR) && !text.includes(SOFT_HYPHEN_CHAR))) { return text.length === 0 ? [] : [makeTextNode(text, marks)]; @@ -294,9 +294,9 @@ function makeListItem(inlineContent: JSONContent[]): JSONContent { type ListType = "orderedList" | "bulletList"; -const LIST_BLOCK_TYPE_TO_LIST: Record = { - "unordered-list-item": { listType: "bulletList", feature: "unorderedList" }, - "ordered-list-item": { listType: "orderedList", feature: "orderedList" }, +const LIST_BLOCK_TYPE_TO_LIST: Record = { + "unordered-list-item": { listType: "bulletList", option: "unorderedList" }, + "ordered-list-item": { listType: "orderedList", option: "orderedList" }, }; interface OpenList { @@ -316,7 +316,7 @@ export function convertDraftJsToTipTap(draftContent: DraftJsContent | undefined return makeEmptyDoc(); } - const features = options.features; + const resolvedOptions = options.resolvedOptions; const hasLink = !!options.link; const textBlockStyleMap = options.textBlockStyleMap ?? {}; const inlineStyleMap = options.inlineStyleMap ?? {}; @@ -374,10 +374,10 @@ export function convertDraftJsToTipTap(draftContent: DraftJsContent | undefined }; for (const block of draftContent.blocks) { - const inlineContent = buildInlineContent({ block, entityMap, features, hasLink, inlineStyleMap }); + const inlineContent = buildInlineContent({ block, entityMap, resolvedOptions, hasLink, inlineStyleMap }); const listMapping = LIST_BLOCK_TYPE_TO_LIST[block.type]; - if (listMapping && features[listMapping.feature]) { + if (listMapping && resolvedOptions[listMapping.option]) { addListItem(listMapping.listType, block.depth ?? 0, inlineContent); continue; } @@ -390,7 +390,7 @@ export function convertDraftJsToTipTap(draftContent: DraftJsContent | undefined topLevel.push( makeTextBlockNode(inlineContent, { - headingLevel: headingLevel !== undefined && features.heading !== false ? headingLevel : undefined, + headingLevel: headingLevel !== undefined && resolvedOptions.heading !== false ? headingLevel : undefined, textBlockStyle: mapping?.textBlockStyle, }), ); From c72ef35b317b5dbbf13e585745988dd0164bbc2e Mon Sep 17 00:00:00 2001 From: Johannes Obermair Date: Mon, 7 Sep 2026 11:09:37 +0200 Subject: [PATCH 5/5] Rename the TipTap block's `history` option to `undoRedoButtons` (#6312) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The `history` option introduced in #6311 gates exactly one thing: the undo/redo button group in the toolbar. TipTap's undo/redo itself stays enabled either way, so the keyboard shortcuts keep working — which is the behavior we want, matching every other text input. The name suggests otherwise: it reads as if it turned off the editor's history tracking, which is what sent this PR down the wrong path to begin with. Rename the option to `undoRedoButtons` and document what it actually controls. No behavior change — `resolvedOptions.undoRedoButtons` gates the same toolbar group as before, and `StarterKit` is untouched. https://claude.ai/code/session_01AL6Fd1WgimvhAaUtUiRAcK Co-authored-by: Claude --- .changeset/tiptap-feature-options.md | 2 +- .../2-blocks/tiptap-rich-text-block.mdx | 4 ++-- .../cms-admin/src/blocks/tipTap/TipTapToolbar.tsx | 2 +- .../tipTap/__stories__/TipTapRichTextBlock.stories.tsx | 6 +++--- .../TipTapRichTextBlockInlineStyles.stories.tsx | 2 +- .../src/blocks/tipTap/createTipTapRichTextBlock.tsx | 10 +++++----- 6 files changed, 13 insertions(+), 13 deletions(-) diff --git a/.changeset/tiptap-feature-options.md b/.changeset/tiptap-feature-options.md index 672c6715762..4fb6d8836bc 100644 --- a/.changeset/tiptap-feature-options.md +++ b/.changeset/tiptap-feature-options.md @@ -26,4 +26,4 @@ createTipTapRichTextBlock({ }); ``` -The features are named after their option: `bold`, `italic`, `underline`, `strike`, `sub`, `sup`, `heading`, `orderedList`, `unorderedList`, `nonBreakingSpace`, `softHyphen`, `link`, and `history` (Admin only). The document-level limits `maxTextBlocks` and `listLevelMax` are unchanged. +The features are named after their option: `bold`, `italic`, `underline`, `strike`, `sub`, `sup`, `heading`, `orderedList`, `unorderedList`, `nonBreakingSpace`, `softHyphen` and `link`. Additionally, `undoRedoButtons` (Admin only) shows or hides the undo/redo buttons in the toolbar; the keyboard shortcuts work regardless. The document-level limits `maxTextBlocks` and `listLevelMax` are unchanged. 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 33072201b82..39f92026a4b 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 @@ -53,7 +53,7 @@ Most features of the Draft.js `RichTextBlock` have a direct equivalent in the Ti | `bold`, `italic`, `strikethrough`, `sub`, `sup` | `bold`, `italic`, `strike`, `sub`, `sup` | | `header-one` … `header-six` | `heading` + the [text-block-type select](#text-block-type-and-styling-selects) (Heading 1–6) | | `ordered-list`, `unordered-list` | `orderedList`, `unorderedList` | -| `history` | `history` (Admin only) | +| `history` | `undoRedoButtons` (Admin only) | | `link`, `links-remove` | `link` (pass the link block) | | `non-breaking-space`, `soft-hyphen` | `nonBreakingSpace`, `softHyphen` | | `rte.blocktypeMap` (custom block types) | [`textBlockStyles`](#text-block-type-and-styling-selects) + the styling select | @@ -169,7 +169,7 @@ Every editor feature has its own option in the root options object, similar to [ | `unorderedList` | `true` | API + Admin | | `nonBreakingSpace` | `true` | API + Admin | | `softHyphen` | `true` | API + Admin | -| `history` | `true` | Admin only | +| `undoRedoButtons` | `true` | Admin only | `heading` can be configured further by passing an options object instead of `true`, which limits the allowed `levels`. diff --git a/packages/admin/cms-admin/src/blocks/tipTap/TipTapToolbar.tsx b/packages/admin/cms-admin/src/blocks/tipTap/TipTapToolbar.tsx index fc5f4fc6821..13b205c57d8 100644 --- a/packages/admin/cms-admin/src/blocks/tipTap/TipTapToolbar.tsx +++ b/packages/admin/cms-admin/src/blocks/tipTap/TipTapToolbar.tsx @@ -385,7 +385,7 @@ export const TipTapToolbar = ({ px: "6px", }} > - {resolvedOptions.history && ( + {resolvedOptions.undoRedoButtons && ( = { }; const PlaceholdersWithContentBlock = createTipTapRichTextBlock({ - history: false, + undoRedoButtons: false, strike: false, sub: false, sup: false, @@ -586,7 +586,7 @@ export const TextBlockStyleInteractions: StoryObj = { export default config; const InlineStylesBlock = createTipTapRichTextBlock({ - history: false, + undoRedoButtons: false, bold: false, italic: false, strike: false, diff --git a/packages/admin/cms-admin/src/blocks/tipTap/createTipTapRichTextBlock.tsx b/packages/admin/cms-admin/src/blocks/tipTap/createTipTapRichTextBlock.tsx index f461da52e86..9ba4b3bb6c5 100644 --- a/packages/admin/cms-admin/src/blocks/tipTap/createTipTapRichTextBlock.tsx +++ b/packages/admin/cms-admin/src/blocks/tipTap/createTipTapRichTextBlock.tsx @@ -40,7 +40,7 @@ interface TipTapHeadingOptions { * The block's options with the defaults applied and the heading levels validated. */ export interface TipTapResolvedOptions { - history: boolean; + undoRedoButtons: boolean; bold: boolean; italic: boolean; underline: boolean; @@ -66,7 +66,7 @@ function isValidHeadingLevels(headingLevels: number[]): headingLevels is Heading } function resolveTipTapOptions({ - history = true, + undoRedoButtons = true, bold = true, italic = true, underline = false, @@ -87,7 +87,7 @@ function resolveTipTapOptions({ } return { - history, + undoRedoButtons, bold, italic, underline, @@ -168,9 +168,9 @@ export interface TipTapChildBlock { interface TipTapRichTextBlockFactoryOptions { /** - * Enables undo/redo. Defaults to `true`. + * Shows the undo/redo buttons in the toolbar. The keyboard shortcuts work regardless. Defaults to `true`. */ - history?: boolean; + undoRedoButtons?: boolean; /** * Enables bold text. Defaults to `true`. */