diff --git a/.changeset/tiptap-text-block-type-select-style.md b/.changeset/tiptap-text-block-type-select-style.md
new file mode 100644
index 00000000000..f47a7d3bf4d
--- /dev/null
+++ b/.changeset/tiptap-text-block-type-select-style.md
@@ -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 `
`, 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) => ,
+ },
+ ],
+});
+```
+
+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.
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 81b43fba656..23215a634c1 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
@@ -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 ``, 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) => (
+
+ ),
+ },
+ ],
+});
+```
+
+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:
diff --git a/packages/admin/cms-admin/src/blocks/tipTap/TipTapToolbar.tsx b/packages/admin/cms-admin/src/blocks/tipTap/TipTapToolbar.tsx
index 8cc482b5349..9dfe6481ab2 100644
--- a/packages/admin/cms-admin/src/blocks/tipTap/TipTapToolbar.tsx
+++ b/packages/admin/cms-admin/src/blocks/tipTap/TipTapToolbar.tsx
@@ -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());
+
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")) {
@@ -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 ":" 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.
@@ -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,
@@ -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 `` 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) => (
+
+ )),
+ ,
+ ]
+ : []),
+ ...headingLevels.flatMap((level) => [
+ ...(textBlockTypeSelectStylesByTag.get(`heading-${level}` as TipTapTextBlockStyleTargetType) ?? []).map((style) => (
+
+ )),
+ ,
+ ]),
+ ];
const applicableInlineStyles = inlineStyles.filter(
(style) => !style.appliesTo || style.appliesTo.includes(editorState.activeTipTapTextBlockType),
);
@@ -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 "" (e.g. "paragraph", "2") or ":" 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) => {
@@ -424,20 +474,7 @@ export const TipTapToolbar = ({
MenuProps={{ elevation: 1 }}
sx={selectSx}
>
- {hasParagraph && (
-
- )}
- {headingLevels.map((level) => (
-
- ))}
+ {textBlockTypeMenuItems}
diff --git a/packages/admin/cms-admin/src/blocks/tipTap/__stories__/TipTapRichTextBlock.stories.tsx b/packages/admin/cms-admin/src/blocks/tipTap/__stories__/TipTapRichTextBlock.stories.tsx
index 5cb95be18f6..b58fa907c86 100644
--- a/packages/admin/cms-admin/src/blocks/tipTap/__stories__/TipTapRichTextBlock.stories.tsx
+++ b/packages/admin/cms-admin/src/blocks/tipTap/__stories__/TipTapRichTextBlock.stories.tsx
@@ -1349,3 +1349,106 @@ export const DefaultTextBlockStyles: StoryObj) => (
+
+ ),
+ },
+ ],
+});
+
+function IsTextBlockTypeStory() {
+ const [state, setState] = useState(IsTextBlockTypeBlock.defaultValues());
+
+ return (
+
+
+
+ );
+}
+
+/**
+ * `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 ``.
+ * No separate style dropdown is needed for a heading-only block whose only style is promoted this way.
+ */
+export const IsTextBlockType: StoryObj = {
+ render: () => ,
+ 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);
+ });
+ },
+};
diff --git a/packages/admin/cms-admin/src/blocks/tipTap/createTipTapRichTextBlock.tsx b/packages/admin/cms-admin/src/blocks/tipTap/createTipTapRichTextBlock.tsx
index 76ba1dc9fb9..1a57f31d8d7 100644
--- a/packages/admin/cms-admin/src/blocks/tipTap/createTipTapRichTextBlock.tsx
+++ b/packages/admin/cms-admin/src/blocks/tipTap/createTipTapRichTextBlock.tsx
@@ -165,6 +165,14 @@ export interface TipTapTextBlockStyle {
*/
appliesTo?: TipTapTextBlockType[];
element: ComponentType>;
+ /**
+ * Shows this style as its own entry in the text block type dropdown (alongside "Paragraph"/"Heading N"),
+ * instead of in the text block style dropdown. Requires `appliesTo` to name exactly one tag.
+ *
+ * The plain ("Heading N") entry for that tag remains selectable and always means no style — picking it,
+ * or any other tag, clears this style. It never survives a tag switch the way an ordinary style does.
+ */
+ isTextBlockType?: boolean;
}
export interface TipTapInlineStyle {
@@ -802,17 +810,19 @@ export const TipTapEditor = ({
type TipTapRichTextBlockInterface = BlockInterface &
ReadOnlyBlockRenderInterface;
+function isEnabledTextBlockStyleTargetType(tag: TipTapTextBlockStyleTargetType, resolvedOptions: TipTapResolvedOptions): boolean {
+ return tag === "paragraph"
+ ? resolvedOptions.paragraph
+ : resolvedOptions.heading !== false && resolvedOptions.heading.levels.includes(Number(tag.slice("heading-".length)) as HeadingLevel);
+}
+
function validateDefaultTextBlockStyles(
defaultTextBlockStyles: Partial>,
textBlockStyles: TipTapTextBlockStyle[],
resolvedOptions: TipTapResolvedOptions,
): void {
for (const [tag, styleName] of Object.entries(defaultTextBlockStyles) as [TipTapTextBlockStyleTargetType, string][]) {
- const isEnabledTag =
- tag === "paragraph"
- ? resolvedOptions.paragraph
- : resolvedOptions.heading !== false && resolvedOptions.heading.levels.includes(Number(tag.slice("heading-".length)) as HeadingLevel);
- if (!isEnabledTag) {
+ if (!isEnabledTextBlockStyleTargetType(tag, resolvedOptions)) {
throw new Error(`defaultTextBlockStyles has an entry for "${tag}", which is not enabled`);
}
@@ -826,6 +836,39 @@ function validateDefaultTextBlockStyles(
}
}
+function validateTextBlockTypeSelectStyles(
+ textBlockStyles: TipTapTextBlockStyle[],
+ defaultTextBlockStyles: Partial>,
+ resolvedOptions: TipTapResolvedOptions,
+): void {
+ for (const style of textBlockStyles) {
+ if (!style.isTextBlockType) {
+ continue;
+ }
+
+ if (style.name.includes(":")) {
+ throw new Error(`Text block style "${style.name}" has isTextBlockType set, so its name must not contain ":"`);
+ }
+ if (style.appliesTo?.length !== 1) {
+ throw new Error(`Text block style "${style.name}" has isTextBlockType set, so appliesTo must name exactly one text block type`);
+ }
+
+ const [tag] = style.appliesTo as [TipTapTextBlockType];
+ if (tag === "ordered-list" || tag === "unordered-list") {
+ throw new Error(`Text block style "${style.name}" has isTextBlockType set, but "${tag}" is not a text block type dropdown entry`);
+ }
+ if (!isEnabledTextBlockStyleTargetType(tag, resolvedOptions)) {
+ throw new Error(`Text block style "${style.name}" has isTextBlockType set for "${tag}", which is not enabled`);
+ }
+ if (defaultTextBlockStyles[tag] !== undefined) {
+ throw new Error(
+ `Text block style "${style.name}" has isTextBlockType set for "${tag}", which also has a defaultTextBlockStyles entry — the ` +
+ `plain type dropdown entry for "${tag}" always means no style, so a default for it would be silently overwritten`,
+ );
+ }
+ }
+}
+
/**
* @experimental
*/
@@ -844,6 +887,7 @@ export const createTipTapRichTextBlock = (options: TipTapRichTextBlockFactoryOpt
const minHeight = options.minHeight;
validateDefaultTextBlockStyles(defaultTextBlockStyles, textBlockStyles, resolvedOptions);
+ validateTextBlockTypeSelectStyles(textBlockStyles, defaultTextBlockStyles, resolvedOptions);
const emptyContent = buildEmptyContent(resolvedOptions, defaultTextBlockStyles);