Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions .changeset/tiptap-feature-options.md
Original file line number Diff line number Diff line change
@@ -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.
76 changes: 58 additions & 18 deletions docs/docs/2-core-concepts/2-blocks/tiptap-rich-text-block.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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:
Expand All @@ -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

Expand Down Expand Up @@ -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" },
},
},
});
```
Expand Down Expand Up @@ -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.
Expand Down
43 changes: 21 additions & 22 deletions packages/admin/cms-admin/src/blocks/tipTap/TipTapToolbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ import type {
TipTapChildBlock,
TipTapInlineStyle,
TipTapPlaceholder,
TipTapSupports,
TipTapResolvedOptions,
TipTapTextBlockStyle,
TipTapTextBlockType,
} from "./createTipTapRichTextBlock";
Expand Down Expand Up @@ -159,36 +159,35 @@ 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<string, TipTapChildBlock>;
listLevelMax?: number;
headingLevels?: number[];
}) => {
const intl = useIntl();
const [moreAnchorEl, setMoreAnchorEl] = useState<null | HTMLElement>(null);
const [placeholderAnchorEl, setPlaceholderAnchorEl] = useState<null | HTMLElement>(null);
const [childBlockAnchorEl, setChildBlockAnchorEl] = useState<null | HTMLElement>(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;

Expand Down Expand Up @@ -303,7 +302,7 @@ export const TipTapToolbar = ({
isActive: boolean;
onToggle: () => void;
}[] = [
...(supports.includes("sup")
...(resolvedOptions.sup
? [
{
key: "superscript",
Expand All @@ -314,7 +313,7 @@ export const TipTapToolbar = ({
},
]
: []),
...(supports.includes("sub")
...(resolvedOptions.sub
? [
{
key: "subscript",
Expand Down Expand Up @@ -386,7 +385,7 @@ export const TipTapToolbar = ({
px: "6px",
}}
>
{supports.includes("history") && (
{resolvedOptions.undoRedoButtons && (
<ToolbarGroup>
<ToolbarButton
editor={editor}
Expand All @@ -404,7 +403,7 @@ export const TipTapToolbar = ({
/>
</ToolbarGroup>
)}
{supports.includes("heading") && (
{resolvedOptions.heading && (
<ToolbarGroup>
<FormControl sx={selectFormControlSx}>
<Select
Expand Down Expand Up @@ -456,7 +455,7 @@ export const TipTapToolbar = ({
)}
{(hasInlineFormatButtons || moreOptions || applicableInlineStyles.length > 0) && (
<ToolbarGroup>
{supports.includes("bold") && (
{resolvedOptions.bold && (
<ToolbarButton
editor={editor}
icon={RteBold}
Expand All @@ -465,7 +464,7 @@ export const TipTapToolbar = ({
onToggle={() => editor.chain().focus().toggleBold().run()}
/>
)}
{supports.includes("italic") && (
{resolvedOptions.italic && (
<ToolbarButton
editor={editor}
icon={RteItalic}
Expand All @@ -474,7 +473,7 @@ export const TipTapToolbar = ({
onToggle={() => editor.chain().focus().toggleItalic().run()}
/>
)}
{supports.includes("underline") && (
{resolvedOptions.underline && (
<ToolbarButton
editor={editor}
icon={RteUnderlined}
Expand All @@ -483,7 +482,7 @@ export const TipTapToolbar = ({
onToggle={() => editor.chain().focus().toggleUnderline().run()}
/>
)}
{supports.includes("strike") && (
{resolvedOptions.strike && (
<ToolbarButton
editor={editor}
icon={RteStrikethrough}
Expand Down Expand Up @@ -561,7 +560,7 @@ export const TipTapToolbar = ({
)}
{lists && (
<ToolbarGroup>
{supports.includes("ordered-list") && (
{resolvedOptions.orderedList && (
<ToolbarButton
editor={editor}
icon={RteOl}
Expand All @@ -570,7 +569,7 @@ export const TipTapToolbar = ({
onToggle={() => editor.chain().focus().toggleOrderedList().run()}
/>
)}
{supports.includes("unordered-list") && (
{resolvedOptions.unorderedList && (
<ToolbarButton
editor={editor}
icon={RteUl}
Expand Down Expand Up @@ -653,7 +652,7 @@ export const TipTapToolbar = ({
)}
{specialChars && (
<ToolbarGroup>
{supports.includes("non-breaking-space") && (
{resolvedOptions.nonBreakingSpace && (
<ToolbarButton
editor={editor}
icon={RteNonBreakingSpace}
Expand All @@ -666,7 +665,7 @@ export const TipTapToolbar = ({
onToggle={() => editor.chain().focus().insertContent({ type: "nonBreakingSpace" }).run()}
/>
)}
{supports.includes("soft-hyphen") && (
{resolvedOptions.softHyphen && (
<ToolbarButton
editor={editor}
icon={RteSoftHyphen}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -150,7 +150,18 @@ export const ReadOnly: Story = {
},
};

const BoldOnlyBlock = createTipTapRichTextBlock({ supports: ["bold"] });
const BoldOnlyBlock = createTipTapRichTextBlock({
undoRedoButtons: false,
italic: false,
strike: false,
sub: false,
sup: false,
heading: false,
orderedList: false,
unorderedList: false,
nonBreakingSpace: false,
softHyphen: false,
});

function BoldOnlyStory() {
const [state, setState] = useState<TipTapRichTextBlockState>(BoldOnlyBlock.defaultValues());
Expand Down Expand Up @@ -344,7 +355,15 @@ export const Placeholders: StoryObj<typeof PlaceholdersStory> = {
};

const PlaceholdersWithContentBlock = createTipTapRichTextBlock({
supports: ["bold", "italic"],
undoRedoButtons: false,
strike: false,
sub: false,
sup: false,
heading: false,
orderedList: false,
unorderedList: false,
nonBreakingSpace: false,
softHyphen: false,
placeholders: [
{ name: "firstName", label: "First Name" },
{ name: "lastName", label: "Last Name" },
Expand Down Expand Up @@ -567,7 +586,13 @@ export const TextBlockStyleInteractions: StoryObj<typeof TextBlockStyleInteracti
};

const ListTextBlockStylesBlock = createTipTapRichTextBlock({
supports: ["bold", "ordered-list", "unordered-list", "heading"],
undoRedoButtons: false,
italic: false,
strike: false,
sub: false,
sup: false,
nonBreakingSpace: false,
softHyphen: false,
textBlockStyles: [
{
name: "intro",
Expand Down Expand Up @@ -889,7 +914,7 @@ export const ListLevelMax: StoryObj<typeof ListLevelMaxStory> = {
},
};

const HeadingLevelsBlock = createTipTapRichTextBlock({ headingLevels: [2, 3, 4] });
const HeadingLevelsBlock = createTipTapRichTextBlock({ heading: { levels: [2, 3, 4] } });

function HeadingLevelsStory() {
const [state, setState] = useState<TipTapRichTextBlockState>(HeadingLevelsBlock.defaultValues());
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,14 @@ const config: Meta<typeof InlineStylesBlockStory> = {
export default config;

const InlineStylesBlock = createTipTapRichTextBlock({
supports: ["heading", "sub", "sup"],
undoRedoButtons: false,
bold: false,
italic: false,
strike: false,
orderedList: false,
unorderedList: false,
nonBreakingSpace: false,
softHyphen: false,
inlineStyles: [
{
name: "highlight",
Expand Down
Loading
Loading