Skip to content
Draft
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
27 changes: 27 additions & 0 deletions .changeset/tiptap-text-block-type-select-style.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
---
"@dextinity/cms-admin": minor
---

Add `isTextBlockType` option to `createTipTapRichTextBlock`'s `textBlockStyles`

A text block style with `isTextBlockType: true` shows up as its own entry in the text block type dropdown (alongside "Paragraph"/"Heading N"), instead of in the separate text block style dropdown. This reproduces a single-dropdown UX some legacy Draft.js RTEs had, where a block type like `display` sat above `header-one` — both rendering as `<h1>`, but `display` a distinct, large hero-style variant.

**Example**

```tsx
createTipTapRichTextBlock({
paragraph: false,
heading: { levels: [1, 2, 3, 4, 5] },
textBlockStyles: [
{
name: "display",
label: "Display",
appliesTo: ["heading-1"],
isTextBlockType: true,
element: (props) => <h1 style={{ fontSize: 56 }} {...props} />,
},
],
});
```

The type dropdown becomes _Display, Heading 1, Heading 2, Heading 3, Heading 4, Heading 5_. The plain "Heading 1" entry stays selectable and always means no style — switching to it, or to any other tag, clears the style. Requires `appliesTo` to name exactly one tag, and that tag can't also have a `defaultTextBlockStyles` entry.
24 changes: 24 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 @@ -388,6 +388,30 @@ export const TipTapRichTextBlock = createTipTapRichTextBlock({

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.

#### Promoting a style into the text block type select

Some Draft.js RTEs had block types that share an HTML tag but are otherwise distinct, editor-facing choices — for instance a `display` type sitting above `header-one`, both rendering as `<h1>`, with `display` a large hero-style variant and `header-one` a normal heading. `isTextBlockType` reproduces that single-dropdown UX: it shows a `textBlockStyles` entry as its own entry in the text block type select, alongside "Paragraph"/"Heading N", instead of in the styling select.

```tsx title="TipTapHeadlineBlock.tsx (Admin)"
export const TipTapHeadlineBlock = createTipTapRichTextBlock({
paragraph: false,
heading: { levels: [1, 2, 3, 4, 5] },
textBlockStyles: [
{
name: "display",
label: "Display",
appliesTo: ["heading-1"],
isTextBlockType: true,
element: (props: HTMLAttributes<HTMLElement>) => (
<h1 style={{ fontSize: 56 }} {...props} />
),
},
],
});
```

The type select becomes _Display, Heading 1, Heading 2, Heading 3, Heading 4, Heading 5_ — `display` sits above the plain heading-1 entry it shares a tag with. That plain entry stays selectable and always means no style: switching to it, or to any other tag, clears the style rather than carrying it over. Requires `appliesTo` naming exactly one tag, and that tag can't also have a `defaultTextBlockStyles` entry (the plain type-select entry always meaning "no style" and a configured default for the same tag would fight each other on every keystroke). `isTextBlockType` is admin-only: it only changes which dropdown offers the style, not the stored content or the API's validation.

### 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
127 changes: 82 additions & 45 deletions packages/admin/cms-admin/src/blocks/tipTap/TipTapToolbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -200,16 +200,24 @@ export const TipTapToolbar = ({
const hasPlaceholders = placeholders.length > 0;
const hasChildBlocks = Object.keys(childBlocks).length > 0;

const textBlockTypeSelectStylesByTag = textBlockStyles.reduce((map, style) => {
if (style.isTextBlockType && style.appliesTo?.length === 1) {
const tag = style.appliesTo[0] as TipTapTextBlockStyleTargetType;
map.set(tag, [...(map.get(tag) ?? []), style]);
}
return map;
}, new Map<TipTapTextBlockStyleTargetType, TipTapTextBlockStyle[]>());

const editorState = useEditorState({
editor,
selector: ({ editor: e }: { editor: Editor }) => {
const activeTextBlockType = (() => {
const activeHeadingLevel = (() => {
for (let level = 1; level <= 6; level++) {
if (e.isActive("heading", { level })) {
return String(level);
return level;
}
}
return hasParagraph || resolvedOptions.heading === false ? "paragraph" : String(resolvedOptions.heading.defaultLevel);
return undefined;
})();
const activeTipTapTextBlockType: TipTapTextBlockType = (() => {
if (e.isActive("orderedList")) {
Expand All @@ -218,14 +226,25 @@ export const TipTapToolbar = ({
if (e.isActive("bulletList")) {
return "unordered-list";
}
for (let level = 1; level <= 6; level++) {
if (e.isActive("heading", { level })) {
return `heading-${level}` as TipTapTextBlockType;
}
}
return "paragraph";
return activeHeadingLevel !== undefined ? (`heading-${activeHeadingLevel}` as TipTapTextBlockType) : "paragraph";
})();
const attrs = e.isActive("heading") || !hasParagraph ? e.getAttributes("heading") : e.getAttributes("paragraph");
const activeTextBlockStyleName = (attrs.textBlockStyle as string) ?? "";

// The type select's value: the tag alone ("paragraph"/"1".."6"), or "<tag>:<styleName>" when the
// current node's style is one promoted into this dropdown (see TipTapTextBlockStyle.isTextBlockType).
const activeTextBlockType = (() => {
const tag =
activeHeadingLevel !== undefined
? String(activeHeadingLevel)
: hasParagraph || resolvedOptions.heading === false
? "paragraph"
: String(resolvedOptions.heading.defaultLevel);
const targetType: TipTapTextBlockStyleTargetType =
tag === "paragraph" ? "paragraph" : (`heading-${tag}` as TipTapTextBlockStyleTargetType);
const typeSelectStyle = textBlockTypeSelectStylesByTag.get(targetType)?.find((style) => style.name === activeTextBlockStyleName);
return typeSelectStyle ? `${tag}:${typeSelectStyle.name}` : tag;
})();

// Calculate current list nesting depth for listLevelMax enforcement.
// The list item node only exists in the schema when lists are enabled.
Expand All @@ -247,7 +266,7 @@ export const TipTapToolbar = ({
return {
activeTextBlockType,
activeTipTapTextBlockType,
activeTextBlockStyle: (attrs.textBlockStyle as string) ?? "",
activeTextBlockStyle: activeTextBlockStyleName,
canUndo: e.can().undo(),
canRedo: e.can().redo(),
canIndent,
Expand Down Expand Up @@ -296,8 +315,36 @@ export const TipTapToolbar = ({
};

const applicableTextBlockStyles = textBlockStyles.filter(
(style) => !style.appliesTo || style.appliesTo.includes(editorState.activeTipTapTextBlockType),
(style) => !style.isTextBlockType && (!style.appliesTo || style.appliesTo.includes(editorState.activeTipTapTextBlockType)),
);

// A flat array, not JSX with nested fragments: MUI's Select reads its popup items via
// `React.Children.toArray(children)`, which flattens arrays but leaves `<Fragment>` wrappers as a single
// opaque child, so a per-tag fragment grouping a promoted style with its plain entry wouldn't render.
const textBlockTypeMenuItems = [
...(hasParagraph
? [
...(textBlockTypeSelectStylesByTag.get("paragraph") ?? []).map((style) => (
<MenuItem key={`paragraph:${style.name}`} value={`paragraph:${style.name}`} dense>
{style.label}
</MenuItem>
)),
<MenuItem key="paragraph" value="paragraph" dense>
<FormattedMessage id="dextinity.blocks.tipTapRichText.textBlockType.paragraph" defaultMessage="Paragraph" />
</MenuItem>,
]
: []),
...headingLevels.flatMap((level) => [
...(textBlockTypeSelectStylesByTag.get(`heading-${level}` as TipTapTextBlockStyleTargetType) ?? []).map((style) => (
<MenuItem key={`${level}:${style.name}`} value={`${level}:${style.name}`} dense>
{style.label}
</MenuItem>
)),
<MenuItem key={level} value={String(level)} dense>
<FormattedMessage id="dextinity.blocks.tipTapRichText.textBlockType.heading" defaultMessage="Heading {level}" values={{ level }} />
</MenuItem>,
]),
];
const applicableInlineStyles = inlineStyles.filter(
(style) => !style.appliesTo || style.appliesTo.includes(editorState.activeTipTapTextBlockType),
);
Expand Down Expand Up @@ -350,29 +397,32 @@ export const TipTapToolbar = ({
];

const handleTextBlockTypeChange = (e: SelectChangeEvent) => {
const value = e.target.value;
if (value === "paragraph") {
editor.chain().focus().setParagraph().run();
} else {
editor
.chain()
.focus()
.setHeading({ level: Number(value) as 1 | 2 | 3 | 4 | 5 | 6 })
.run();
}
// The value is "<tag>" (e.g. "paragraph", "2") or "<tag>:<styleName>" for a style promoted into this
// dropdown via `isTextBlockType` (see textBlockTypeSelectStylesByTag).
const [tagKey, typeSelectStyleName] = e.target.value.split(":");
const nodeType = tagKey === "paragraph" ? "paragraph" : "heading";
const targetType: TipTapTextBlockStyleTargetType =
tagKey === "paragraph" ? "paragraph" : (`heading-${tagKey}` as TipTapTextBlockStyleTargetType);

// Clear textBlockStyle if it's not applicable to the new text block type
if (textBlockStyles.length > 0) {
const { activeTextBlockStyle } = editorState;
if (activeTextBlockStyle) {
const newType: TipTapTextBlockType = value === "paragraph" ? "paragraph" : (`heading-${value}` as TipTapTextBlockType);
const styleConfig = textBlockStyles.find((s) => s.name === activeTextBlockStyle);
if (styleConfig?.appliesTo && !styleConfig.appliesTo.includes(newType)) {
const nodeType = value === "paragraph" ? "paragraph" : "heading";
editor.chain().updateAttributes(nodeType, { textBlockStyle: null }).run();
}
let chain = editor.chain().focus();
chain = tagKey === "paragraph" ? chain.setParagraph() : chain.setHeading({ level: Number(tagKey) as 1 | 2 | 3 | 4 | 5 | 6 });

if (typeSelectStyleName) {
chain = chain.updateAttributes(nodeType, { textBlockStyle: typeSelectStyleName });
} else if (textBlockTypeSelectStylesByTag.has(targetType)) {
// This tag's plain entry always means no style, once one of its styles is promoted into this dropdown.
if (editorState.activeTextBlockStyle) {
chain = chain.updateAttributes(nodeType, { textBlockStyle: null });
}
} else if (editorState.activeTextBlockStyle) {
// Clear textBlockStyle if it's not applicable to the new text block type
const styleConfig = textBlockStyles.find((s) => s.name === editorState.activeTextBlockStyle);
if (styleConfig?.appliesTo && !styleConfig.appliesTo.includes(targetType)) {
chain = chain.updateAttributes(nodeType, { textBlockStyle: null });
}
}

chain.run();
};

const handleTextBlockStyleChange = (e: SelectChangeEvent) => {
Expand Down Expand Up @@ -424,20 +474,7 @@ export const TipTapToolbar = ({
MenuProps={{ elevation: 1 }}
sx={selectSx}
>
{hasParagraph && (
<MenuItem value="paragraph" dense>
<FormattedMessage id="dextinity.blocks.tipTapRichText.textBlockType.paragraph" defaultMessage="Paragraph" />
</MenuItem>
)}
{headingLevels.map((level) => (
<MenuItem key={level} value={String(level)} dense>
<FormattedMessage
id="dextinity.blocks.tipTapRichText.textBlockType.heading"
defaultMessage="Heading {level}"
values={{ level }}
/>
</MenuItem>
))}
{textBlockTypeMenuItems}
</Select>
</FormControl>
</ToolbarGroup>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1349,3 +1349,106 @@ export const DefaultTextBlockStyles: StoryObj<typeof DefaultTextBlockStylesStory
});
},
};

const IsTextBlockTypeBlock = createTipTapRichTextBlock({
paragraph: false,
heading: { levels: [1, 2, 3, 4, 5] },
textBlockStyles: [
{
name: "display",
label: "Display",
appliesTo: ["heading-1"],
isTextBlockType: true,
element: (props: HTMLAttributes<HTMLElement>) => (
<Typography sx={{ fontSize: 56, fontWeight: 700, lineHeight: 1.1 }} variant="h1" {...props} />
),
},
],
});

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

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

/**
* `isTextBlockType` shows a text block style as its own entry in the type dropdown, above the plain heading
* level entry it shares a tag with — matching a legacy single-dropdown block type select (e.g. Draft.js's
* `blocktypeMap`) where "Display" and "Headline 1" were two distinct block types that both render as `<h1>`.
* No separate style dropdown is needed for a heading-only block whose only style is promoted this way.
*/
export const IsTextBlockType: StoryObj<typeof IsTextBlockTypeStory> = {
render: () => <IsTextBlockTypeStory />,
play: async ({ canvas, userEvent, step }) => {
await step("Editor starts on plain Heading 1 — no style dropdown, since heading-1's only style is promoted", async () => {
await waitFor(
() => {
expect(canvas.getByRole("heading", { level: 1 })).toBeInTheDocument();
},
{ timeout: 5000 },
);

expect(canvas.getAllByRole("combobox")).toHaveLength(1);
expect(canvas.getByRole("combobox")).toHaveTextContent("Heading 1");
});

await step("Type dropdown lists Display above Heading 1, then Heading 2-5, no separate style entries", async () => {
await userEvent.click(canvas.getByRole("combobox"));

const body = within(document.body);
await waitFor(() => {
expect(body.getByRole("option", { name: "Display" })).toBeInTheDocument();
});
const optionLabels = body.getAllByRole("option").map((option) => option.textContent);
expect(optionLabels).toEqual(["Display", "Heading 1", "Heading 2", "Heading 3", "Heading 4", "Heading 5"]);

await userEvent.keyboard("{Escape}");
});

await step("Selecting Display sets the style — still only one dropdown", async () => {
await userEvent.click(canvas.getByRole("combobox"));
await userEvent.click(within(document.body).getByRole("option", { name: "Display" }));

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

const state = JSON.parse(canvas.getByText(/"tipTapContent"/).textContent ?? "{}");
expect(state.tipTapContent.content[0]).toMatchObject({ type: "heading", attrs: { level: 1, textBlockStyle: "display" } });
});

await step("Switching back to plain Heading 1 clears the style", async () => {
await userEvent.click(canvas.getByRole("combobox"));
await userEvent.click(within(document.body).getByRole("option", { name: "Heading 1" }));

await waitFor(() => {
const state = JSON.parse(canvas.getByText(/"tipTapContent"/).textContent ?? "{}");
expect(state.tipTapContent.content[0]).toMatchObject({ type: "heading", attrs: { level: 1, textBlockStyle: null } });
});
});

await step("Display -> Heading 2 (no promoted style there) also clears the style", async () => {
await userEvent.click(canvas.getByRole("combobox"));
await userEvent.click(within(document.body).getByRole("option", { name: "Display" }));
await waitFor(() => expect(canvas.getByRole("combobox")).toHaveTextContent("Display"));

await userEvent.click(canvas.getByRole("combobox"));
await userEvent.click(within(document.body).getByRole("option", { name: "Heading 2" }));

await waitFor(() => {
const state = JSON.parse(canvas.getByText(/"tipTapContent"/).textContent ?? "{}");
expect(state.tipTapContent.content[0]).toMatchObject({ type: "heading", attrs: { level: 2, textBlockStyle: null } });
});
expect(canvas.getAllByRole("combobox")).toHaveLength(1);
});
},
};
Loading
Loading