diff --git a/.changeset/tiptap-feature-options.md b/.changeset/tiptap-feature-options.md new file mode 100644 index 00000000000..4fb6d8836bc --- /dev/null +++ b/.changeset/tiptap-feature-options.md @@ -0,0 +1,29 @@ +--- +"@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, so `headingLevels` becomes `heading: { levels: [...] }`. + +Every feature is enabled by default (except `underline`) and is disabled by passing `false`, so a configuration only has to state what deviates from the defaults instead of repeating every supported feature. Links stay the exception: they are enabled by passing the link block as `link`. + +**Example** + +```ts +// Before +createTipTapRichTextBlock({ + supports: ["bold", "italic", "strike", "sub", "sup", "heading", "ordered-list", "unordered-list"], + headingLevels: [2, 3], +}); + +// After +createTipTapRichTextBlock({ + nonBreakingSpace: false, + softHyphen: false, + heading: { levels: [2, 3] }, +}); +``` + +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 4d74e511337..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 @@ -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` | `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 | +| `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 @@ -152,6 +152,44 @@ 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 | +| `undoRedoButtons` | `true` | Admin only | + +`heading` can be configured further by passing an options object instead of `true`, which limits the allowed `levels`. + +Links are the exception to the boolean options: `link` takes the link block that is used for links, and passing it enables the feature. Links are disabled by default. + +```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] }, + // Enable links by passing the link block + link: 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: @@ -174,7 +212,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 @@ -209,7 +247,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" }, + }, }, }); ``` @@ -247,7 +287,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. diff --git a/packages/admin/cms-admin/src/blocks/tipTap/TipTapToolbar.tsx b/packages/admin/cms-admin/src/blocks/tipTap/TipTapToolbar.tsx index 41580cf5158..13b205c57d8 100644 --- a/packages/admin/cms-admin/src/blocks/tipTap/TipTapToolbar.tsx +++ b/packages/admin/cms-admin/src/blocks/tipTap/TipTapToolbar.tsx @@ -43,7 +43,7 @@ import type { TipTapChildBlock, TipTapInlineStyle, TipTapPlaceholder, - TipTapSupports, + TipTapResolvedOptions, TipTapTextBlockStyle, TipTapTextBlockType, } from "./createTipTapRichTextBlock"; @@ -159,24 +159,22 @@ const selectSx = { export const TipTapToolbar = ({ editor, - supports, + resolvedOptions, textBlockStyles, inlineStyles, placeholders, linkBlock, childBlocks, listLevelMax, - headingLevels = [1, 2, 3, 4, 5, 6], }: { editor: Editor; - supports: TipTapSupports[]; + resolvedOptions: TipTapResolvedOptions; 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 = resolvedOptions.bold || resolvedOptions.italic || resolvedOptions.underline || resolvedOptions.strike; + const moreOptions = resolvedOptions.sub || resolvedOptions.sup; + const lists = resolvedOptions.orderedList || resolvedOptions.unorderedList; + const specialChars = resolvedOptions.nonBreakingSpace || resolvedOptions.softHyphen; + const hasLink = resolvedOptions.link && !!linkBlock; + const headingLevels = resolvedOptions.heading ? resolvedOptions.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") + ...(resolvedOptions.sup ? [ { key: "superscript", @@ -314,7 +313,7 @@ export const TipTapToolbar = ({ }, ] : []), - ...(supports.includes("sub") + ...(resolvedOptions.sub ? [ { key: "subscript", @@ -386,7 +385,7 @@ export const TipTapToolbar = ({ px: "6px", }} > - {supports.includes("history") && ( + {resolvedOptions.undoRedoButtons && ( )} - {supports.includes("heading") && ( + {resolvedOptions.heading && (