Skip to content
Closed
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
30 changes: 30 additions & 0 deletions .changeset/tiptap-default-text-block-styles.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
---
"@dextinity/cms-admin": minor
"@dextinity/cms-api": minor
---

Add `defaultTextBlockStyles` option to `createTipTapRichTextBlock`

The text block style dropdown always offered an unstyled `Default` entry next to the configured `textBlockStyles`, even for a tag where every instance should always carry one of them. `defaultTextBlockStyles` assigns a default style per tag: no `Default` entry is offered for it, a newly created or converted heading/paragraph of that tag gets the style automatically, and the API rejects stored content of that tag missing a style. `migrateFromDraftJs` falls back to the configured default when a mapped Draft.js block doesn't specify a `textBlockStyle`.

**Example**

```ts
createTipTapRichTextBlock({
textBlockStyles: [
{ name: "copy100", appliesTo: ["paragraph"] },
{ name: "copy200", appliesTo: ["paragraph"] },
{ name: "headline300", appliesTo: ["heading-2"] },
],
defaultTextBlockStyles: {
paragraph: "copy100",
"heading-2": "headline300",
},
});
```

A tag without an entry (e.g. `heading-3` above) keeps today's behavior — the dropdown still offers "Default" for it.

**Guaranteed even across later migrations**

A block migration that runs after `migrateFromDraftJs` (for instance one that changes a node's heading level) can leave a node missing its default style, or carrying a style that no longer applies to its new tag, since earlier steps only resolve `defaultTextBlockStyles` against the tag a node has at that point. A migration now always runs last to fill in any default still missing, or swap in the default for a style that no longer applies, once every other migration — including a block's own — has applied.
2 changes: 2 additions & 0 deletions demo/api/src/common/blocks/tip-tap-rich-text.block.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,9 +19,11 @@ export const TipTapRichTextBlock = createTipTapRichTextBlock(
{ name: "eyebrow550", appliesTo: ["paragraph"] },
{ name: "eyebrow500", appliesTo: ["paragraph"] },
{ name: "eyebrow450", appliesTo: ["paragraph"] },
{ name: "headline300", appliesTo: ["heading-2"] },
{ name: "list300", appliesTo: ["ordered-list", "unordered-list"] },
{ name: "list200", appliesTo: ["ordered-list", "unordered-list"] },
],
defaultTextBlockStyles: { paragraph: "paragraph300", "heading-2": "headline300" },
inlineStyles: [{ name: "highlight" }, { name: "tag", appliesTo: ["paragraph"] }],
migrateFromDraftJs: {
// Map the DraftJS `blocktypeMap` entry `paragraph-small` (configured in the admin RichTextBlock)
Expand Down
20 changes: 20 additions & 0 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 @@ -368,6 +368,26 @@ const nodeMapping: Record<string, TipTapNodeHandler> = {
};
```

#### Default text block styles

By default, the styling select always offers an unstyled _Default_ entry next to the configured `textBlockStyles`. `defaultTextBlockStyles` assigns one style per tag as that tag's default instead: no _Default_ entry is offered for it, a newly created or converted heading/paragraph of that tag gets the style automatically, and the API rejects stored content of that tag missing a style. This matches the Draft.js RTE's `standardBlockType`, which had no unstyled state to begin with for the block types it covered.

```ts title="tip-tap-rich-text.block.ts (API and Admin)"
export const TipTapRichTextBlock = createTipTapRichTextBlock({
textBlockStyles: [
{ name: "copy100", appliesTo: ["paragraph"] },
{ name: "copy200", appliesTo: ["paragraph"] },
{ name: "headline300", appliesTo: ["heading-2"] },
],
defaultTextBlockStyles: {
paragraph: "copy100",
"heading-2": "headline300",
},
});
```

Each value must be the `name` of an entry in `textBlockStyles` whose `appliesTo` (if set) includes that tag. A tag without an entry keeps today's behavior — the styling select still offers _Default_ for it, unchanged. `migrateFromDraftJs` falls back to the configured default when a mapped Draft.js block doesn't specify a `textBlockStyle`, the same way `heading.defaultLevel` supplies a missing heading level.

### Heading-only blocks

Turning the `paragraph` feature off leaves a block that only holds headings — the TipTap equivalent of the Draft.js pattern of a `RichTextBlock` restricted to `header-*` block types with a `standardBlockType`, for instance the headline part of a heading block:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ interface TipTapContentTranslationDialogProps {
TipTapEditorProps,
| "resolvedOptions"
| "textBlockStyles"
| "defaultTextBlockStyles"
| "inlineStyles"
| "placeholders"
| "linkBlock"
Expand Down
11 changes: 8 additions & 3 deletions packages/admin/cms-admin/src/blocks/tipTap/TipTapToolbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ import type {
TipTapPlaceholder,
TipTapResolvedOptions,
TipTapTextBlockStyle,
TipTapTextBlockStyleTargetType,
TipTapTextBlockType,
} from "./createTipTapRichTextBlock";
import { TipTapBlockDialog } from "./TipTapBlockDialog";
Expand Down Expand Up @@ -162,6 +163,7 @@ export const TipTapToolbar = ({
editor,
resolvedOptions,
textBlockStyles,
defaultTextBlockStyles,
inlineStyles,
placeholders,
linkBlock,
Expand All @@ -173,6 +175,7 @@ export const TipTapToolbar = ({
editor: Editor;
resolvedOptions: TipTapResolvedOptions;
textBlockStyles: TipTapTextBlockStyle[];
defaultTextBlockStyles: Partial<Record<TipTapTextBlockStyleTargetType, string>>;
inlineStyles: TipTapInlineStyle[];
placeholders: TipTapPlaceholder[];
linkBlock?: BlockInterface & LinkBlockInterface;
Expand Down Expand Up @@ -450,9 +453,11 @@ export const TipTapToolbar = ({
MenuProps={{ elevation: 1 }}
sx={selectSx}
>
<MenuItem value="" dense>
<FormattedMessage id="dextinity.blocks.tipTapRichText.textBlockStyle.default" defaultMessage="Default" />
</MenuItem>
{!defaultTextBlockStyles[editorState.activeTipTapTextBlockType as TipTapTextBlockStyleTargetType] && (
<MenuItem value="" dense>
<FormattedMessage id="dextinity.blocks.tipTapRichText.textBlockStyle.default" defaultMessage="Default" />
</MenuItem>
)}
{applicableTextBlockStyles.map((style) => (
<MenuItem key={style.name} value={style.name} dense>
{style.label}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1222,3 +1222,130 @@ export const HeadingOnlyWithTextBlockStyles: StoryObj<typeof HeadingOnlyWithText
});
},
};

const DefaultTextBlockStylesBlock = createTipTapRichTextBlock({
textBlockStyles: [
{
name: "copy100",
label: "Copy 100",
appliesTo: ["paragraph"],
element: (props: HTMLAttributes<HTMLElement>) => <p style={{ fontSize: 16 }} {...props} />,
},
{
name: "copy200",
label: "Copy 200",
appliesTo: ["paragraph"],
element: (props: HTMLAttributes<HTMLElement>) => <p style={{ fontSize: 20 }} {...props} />,
},
{
name: "h2-large",
label: "H2 Large",
appliesTo: ["heading-2"],
element: (p) => <Typography sx={{ fontSize: 36, lineHeight: 1.2 }} variant="h2" {...p} />,
},
{
name: "h3-highlight",
label: "H3 Highlight",
appliesTo: ["heading-3"],
element: (p) => <Typography sx={{ color: "darkorange" }} variant="h3" {...p} />,
},
],
defaultTextBlockStyles: {
paragraph: "copy100",
"heading-2": "h2-large",
// Heading 3 has an applicable style but no configured default, so it keeps today's behavior.
},
});

function DefaultTextBlockStylesStory() {
const [state, setState] = useState<TipTapRichTextBlockState>(DefaultTextBlockStylesBlock.defaultValues());

return (
<StoryWrapper state={state}>
<DefaultTextBlockStylesBlock.AdminComponent state={state} updateState={setState} />
</StoryWrapper>
);
}

/**
* `defaultTextBlockStyles` assigns a default style per tag: the initial paragraph already carries its
* default ("Copy 100") instead of the "no style" state, and the style dropdown offers no "Default" entry
* for tags with a configured default — matching the pre-TipTap Draft.js RTE, which had no such state
* either. A tag with applicable styles but no configured default (Heading 3 here) keeps today's
* behavior and still offers "Default". A tag with no applicable styles at all (Heading 1) hides the
* style dropdown entirely, as before.
*/
export const DefaultTextBlockStyles: StoryObj<typeof DefaultTextBlockStylesStory> = {
render: () => <DefaultTextBlockStylesStory />,
play: async ({ canvas, userEvent, step }) => {
await step("The initial paragraph already carries its default style — no 'Default' option is offered", async () => {
await waitFor(
() => {
const comboboxes = canvas.getAllByRole("combobox");
expect(comboboxes).toHaveLength(2);
expect(comboboxes[1]).toHaveTextContent("Copy 100");
},
{ timeout: 5000 },
);

await userEvent.click(canvas.getAllByRole("combobox")[1]);
await waitFor(() => {
expect(within(document.body).queryByRole("option", { name: "Default" })).not.toBeInTheDocument();
expect(within(document.body).getByRole("option", { name: "Copy 200" })).toBeInTheDocument();
});
await userEvent.keyboard("{Escape}");
});

await step("Switching to Heading 2 auto-assigns its default style instead of 'Default'", async () => {
await userEvent.click(canvas.getAllByRole("combobox")[0]);
await waitFor(() => {
expect(within(document.body).getByText("Heading 2")).toBeInTheDocument();
});
await userEvent.click(within(document.body).getByText("Heading 2"));

await waitFor(
() => {
expect(canvas.getAllByRole("combobox")[1]).toHaveTextContent("H2 Large");
},
{ timeout: 3000 },
);
});

await step("Switching to Heading 3, which has an applicable style but no configured default, still offers 'Default'", async () => {
await userEvent.click(canvas.getAllByRole("combobox")[0]);
await waitFor(() => {
expect(within(document.body).getByText("Heading 3")).toBeInTheDocument();
});
await userEvent.click(within(document.body).getByText("Heading 3"));

await waitFor(
() => {
expect(canvas.getAllByRole("combobox")[1]).toHaveTextContent("Default");
},
{ timeout: 3000 },
);

await userEvent.click(canvas.getAllByRole("combobox")[1]);
await waitFor(() => {
expect(within(document.body).getByRole("option", { name: "Default" })).toBeInTheDocument();
expect(within(document.body).getByRole("option", { name: "H3 Highlight" })).toBeInTheDocument();
});
await userEvent.keyboard("{Escape}");
});

await step("Switching to Heading 1, which has no configured default, hides the style dropdown entirely", async () => {
await userEvent.click(canvas.getAllByRole("combobox")[0]);
await waitFor(() => {
expect(within(document.body).getByText("Heading 1")).toBeInTheDocument();
});
await userEvent.click(within(document.body).getByText("Heading 1"));

await waitFor(
() => {
expect(canvas.getAllByRole("combobox")).toHaveLength(1);
},
{ timeout: 3000 },
);
});
},
};
Loading
Loading