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) => ( + + {style.label} + + )), + + + , + ] + : []), + ...headingLevels.flatMap((level) => [ + ...(textBlockTypeSelectStylesByTag.get(`heading-${level}` as TipTapTextBlockStyleTargetType) ?? []).map((style) => ( + + {style.label} + + )), + + + , + ]), + ]; 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);