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-default-text-block-style.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
---
"@dextinity/cms-admin": minor
"@dextinity/cms-api": minor
---

TipTap Rich Text Block: add `defaultStyle` to a text block

The styling select always offered a "Default" entry standing for "no style", even where a design has no unstyled variant and every paragraph or heading is meant to carry one of the configured styles.

A text block (or list) with a `defaultStyle` has no such state: the select drops its "Default" entry, and the style is applied to new content, to a text block the editor converts through the type select, and to any text block the editor creates without one — pressing Enter at the end of a text block, the `Mod-Alt-<level>` shortcuts, or pasting. Switching the type keeps a style the new text block also offers and falls back to its `defaultStyle` otherwise.

Toggling a list hands a paragraph from its text block to the list or back, so the styles of whichever now holds it apply. The list keyboard shortcuts do this as well as the toolbar's list buttons.

Because the default sits on the text block rather than on a shared tag, two text blocks with the same tag can have different defaults, and a text block without one keeps the "Default" entry next to text blocks that have one.

**Example**

```tsx
createTipTapRichTextBlock({
textBlocks: [
{ name: "heading-1", label: "Heading 1", tag: "h1", styles: headlineStyles, defaultStyle: "headline300" },
{ name: "heading-2", label: "Heading 2", tag: "h2", styles: headlineStyles, defaultStyle: "headline400" },
],
});
```

`defaultStyle` must be one of the text block's `styles`, otherwise an error is thrown. It only exists next to `styles`, not next to a text block's own `element`.

Content written before a `defaultStyle` was configured carries no style, and the editor fills it in on the first edit rather than when the document is opened, so opening a document does not mark it as changed.
5 changes: 3 additions & 2 deletions demo/admin/src/common/blocks/TipTapRichTextBlock.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ export const TipTapRichTextBlock = createTipTapRichTextBlock({
element: (props) => <p style={{ fontSize: 18, lineHeight: "18px" }} {...props} />,
},
],
defaultStyle: "paragraph300",
},
{ name: "display", tag: "h1", label: <FormattedMessage id="tipTapRichTextBlock.textBlock.display" defaultMessage="Display" /> },
{ name: "heading-1", tag: "h1", label: <FormattedMessage id="tipTapRichTextBlock.textBlock.heading1" defaultMessage="Heading 1" /> },
Expand All @@ -74,8 +75,8 @@ export const TipTapRichTextBlock = createTipTapRichTextBlock({
{ name: "heading-4", tag: "h4", label: <FormattedMessage id="tipTapRichTextBlock.textBlock.heading4" defaultMessage="Heading 4" /> },
{ name: "heading-5", tag: "h5", label: <FormattedMessage id="tipTapRichTextBlock.textBlock.heading5" defaultMessage="Heading 5" /> },
],
orderedList: { styles: listStyles },
unorderedList: { styles: listStyles },
orderedList: { styles: listStyles, defaultStyle: "list300" },
unorderedList: { styles: listStyles, defaultStyle: "list300" },
inlineStyles: [
{
name: "highlight",
Expand Down
5 changes: 3 additions & 2 deletions demo/api/src/common/blocks/tip-tap-rich-text.block.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ export const TipTapRichTextBlock = createTipTapRichTextBlock(
{ name: "eyebrow500" },
{ name: "eyebrow450" },
],
defaultStyle: "paragraph300",
},
{ name: "display", tag: "h1" },
{ name: "heading-1", tag: "h1" },
Expand All @@ -36,8 +37,8 @@ export const TipTapRichTextBlock = createTipTapRichTextBlock(
{ name: "heading-4", tag: "h4" },
{ name: "heading-5", tag: "h5" },
],
orderedList: { styles: listStyles },
unorderedList: { styles: listStyles },
orderedList: { styles: listStyles, defaultStyle: "list300" },
unorderedList: { styles: listStyles, defaultStyle: "list300" },
inlineStyles: [{ name: "highlight" }, { name: "tag", appliesTo: ["paragraph"] }],
migrateFromDraftJs: {
textBlockMap: {
Expand Down
23 changes: 23 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 @@ -350,6 +350,29 @@ A style's `element` receives the `tag` of the text block it is applied to, so on

An inline style's `appliesTo` limits it to the named text blocks, plus `"ordered-list"` and `"unordered-list"` for a list item's content.

#### Default styles

A text block (or list) with a `defaultStyle` has no "no style" state: the styling select drops its _Default_ entry, and every text block of that type carries one of the styles. Text blocks without a `defaultStyle` keep the _Default_ entry, so both can sit side by side:

```ts title="tip-tap-rich-text.block.ts (API)"
export const TipTapRichTextBlock = createTipTapRichTextBlock({
textBlocks: [
{ name: "heading-1", tag: "h1", styles: headlineStyles, defaultStyle: "headline300" },
{ name: "heading-2", tag: "h2", styles: headlineStyles },
],
});
```

The style is applied to new content, to a text block the editor converts through the type select, and to any text block the editor creates without one — pressing Enter at the end of a text block, using the `Mod-Alt-<level>` shortcuts, or pasting. Switching the type keeps a style the new text block also offers and falls back to its `defaultStyle` otherwise, whether the switch comes from the toolbar or from a keyboard shortcut.

`defaultStyle` must be one of the text block's `styles`, otherwise an error is thrown. It only exists next to `styles`, not next to a text block's own `element`.

:::caution Existing content

Content written before a `defaultStyle` was configured carries no style, and the editor fills it in on the first edit rather than when the document is opened, so opening a document does not mark it as changed.

:::

#### Text blocks without a style choice

A text block that needs no style choice carries its own `element` instead of `styles`. The two exclude each other, and a text block with neither is rendered as its plain tag:
Expand Down
55 changes: 42 additions & 13 deletions packages/admin/cms-admin/src/blocks/tipTap/TipTapToolbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -41,11 +41,21 @@ import { FormattedMessage, useIntl } from "react-intl";

import type { BlockInterface, BlockState, LinkBlockInterface } from "../types";
import type { TipTapChildBlock, TipTapInlineStyle, TipTapPlaceholder, TipTapResolvedOptions } from "./createTipTapRichTextBlock";
import { toggleTextBlockList } from "./extensions/TextBlockList";
import { findListNodeType } from "./findListNodeType";
import { liftOutOfList } from "./liftOutOfList";
import { findTextBlock, getStyledNodes, isTextBlockAllowedInListItem, orderedListName, unorderedListName } from "./textBlocks";
import {
findTextBlock,
getStyledNodes,
isTextBlockAllowedInListItem,
orderedListName,
type TipTapResolvedList,
type TipTapResolvedTextBlock,
unorderedListName,
} from "./textBlocks";
import { TipTapBlockDialog } from "./TipTapBlockDialog";
import { TipTapLinkDialog } from "./TipTapLinkDialog";
import { updateTextBlockStyles } from "./updateTextBlockStyles";

const toolbarButtonSx = {
display: "flex",
Expand Down Expand Up @@ -277,7 +287,11 @@ export const TipTapToolbar = ({
setTimeout(() => editor.commands.focus(), 0);
};

const applicableTextBlockStyles = styledNodes.find((styledNode) => styledNode.name === editorState.activeStyledNode)?.styles ?? [];
const activeStyledNode = styledNodes.find((styledNode) => styledNode.name === editorState.activeStyledNode);
const applicableTextBlockStyles = activeStyledNode?.styles ?? [];
// A configured default style means every text block of that type has one, so the styling select
// drops its "Default" entry and the choice becomes mandatory.
Comment thread
VPS-Andreas marked this conversation as resolved.
const activeDefaultStyle = activeStyledNode?.defaultStyle ?? null;
const applicableInlineStyles = inlineStyles.filter((style) => !style.appliesTo || style.appliesTo.includes(editorState.activeStyledNode));
// Without bold/italic/underline/strike buttons to fold behind it, a "..." menu just for superscript/subscript/inline
// styles adds an extra click for no space savings, so show them as individual buttons instead
Expand Down Expand Up @@ -328,7 +342,7 @@ export const TipTapToolbar = ({
];

const handleTextBlockChange = (e: SelectChangeEvent) => {
const textBlock = textBlocks.find((candidate) => candidate.name === e.target.value);
const textBlock = textBlocks.find((candidate: TipTapResolvedTextBlock) => candidate.name === e.target.value);
if (!textBlock) {
return;
}
Expand All @@ -342,11 +356,24 @@ export const TipTapToolbar = ({
// Switching the type only renames the node's text block - the tag follows from the configuration.
editor.chain().focus().updateAttributes("textBlock", { textBlock: textBlock.name }).run();

// Clear a textBlockStyle the new text block doesn't offer
const { activeTextBlockStyle } = editorState;
if (activeTextBlockStyle && !textBlock.styles.some((style) => style.name === activeTextBlockStyle)) {
editor.chain().updateAttributes("textBlock", { textBlockStyle: null }).run();
}
// A list wins over the text block inside its items, so a switch that stays inside a list
// keeps the list's style instead of falling back to the new text block's. Read after the
// lift above, which leaves no list for a text block that can't be a list item's content.
updateTextBlockStyles(editor, (_, pos) => {
const listNodeType = findListNodeType(editor.state.doc.resolve(pos));
const activeList =
listNodeType === "orderedList" ? resolvedOptions.orderedList : listNodeType === "bulletList" ? resolvedOptions.unorderedList : false;
return activeList || textBlock;
});
};

const handleListToggle = (list: TipTapResolvedList) => {
toggleTextBlockList(editor, {
list,
textBlocks,
orderedList: resolvedOptions.orderedList,
unorderedList: resolvedOptions.unorderedList,
});
};

const handleTextBlockStyleChange = (e: SelectChangeEvent) => {
Expand Down Expand Up @@ -420,9 +447,11 @@ export const TipTapToolbar = ({
MenuProps={{ elevation: 1 }}
sx={selectSx}
>
<MenuItem value="" dense>
<FormattedMessage id="dextinity.blocks.tipTapRichText.textBlockStyle.default" defaultMessage="Default" />
</MenuItem>
{activeDefaultStyle === null && (
<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 Expand Up @@ -555,7 +584,7 @@ export const TipTapToolbar = ({
icon={RteOl}
tooltip={<FormattedMessage id="dextinity.blocks.tipTapRichText.orderedList.tooltip" defaultMessage="Ordered list" />}
isActive="orderedList"
onToggle={() => editor.chain().focus().toggleOrderedList().run()}
onToggle={() => handleListToggle(resolvedOptions.orderedList as TipTapResolvedList)}
/>
)}
{resolvedOptions.unorderedList && (
Expand All @@ -564,7 +593,7 @@ export const TipTapToolbar = ({
icon={RteUl}
tooltip={<FormattedMessage id="dextinity.blocks.tipTapRichText.bulletList.tooltip" defaultMessage="Bullet list" />}
isActive="bulletList"
onToggle={() => editor.chain().focus().toggleBulletList().run()}
onToggle={() => handleListToggle(resolvedOptions.unorderedList as TipTapResolvedList)}
/>
)}
<ToolbarButton
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1533,3 +1533,151 @@ export const TextBlockElement: StoryObj<typeof TextBlockElementStory> = {
});
},
};

// "Heading 1" has a default style, so its styling select offers no "Default" and always holds one of
// its styles. "Heading 2" shares the same styles without a default, so it keeps the "Default" entry.
// The lists carry a default of their own, which takes over while a paragraph sits in one - by the
// toolbar's list buttons as well as by `Mod-Shift-7`/`Mod-Shift-8`.
const DefaultTextBlockStyleBlock = createTipTapRichTextBlock({
undoRedoButtons: false,
textBlocks: [
{ name: "paragraph", label: "Paragraph", tag: "p", styles: [introStyle, highlightStyle], defaultStyle: "intro" },
{ name: "heading-1", label: "Heading 1", tag: "h1", styles: [largeHeadingStyle, chapterHeadingStyle], defaultStyle: "large-heading" },
{ name: "heading-2", label: "Heading 2", tag: "h2", styles: [largeHeadingStyle, chapterHeadingStyle] },
],
orderedList: { styles: [listLargeStyle, listSmallStyle], defaultStyle: "list-large" },
unorderedList: { styles: [listLargeStyle, listSmallStyle], defaultStyle: "list-large" },
});

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

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

export const DefaultTextBlockStyle: StoryObj<typeof DefaultTextBlockStyleStory> = {
render: () => <DefaultTextBlockStyleStory />,
play: async ({ canvas, userEvent, step }) => {
await step("New content starts with the default text block's default style", async () => {
await waitFor(
() => {
expect(canvas.getAllByRole("combobox")[1]).toHaveTextContent("Intro Text");
},
{ timeout: 5000 },
);
});

await step("A text block with a default style offers no Default entry", async () => {
await userEvent.click(canvas.getAllByRole("combobox")[1]);

await waitFor(
() => {
expect(within(document.body).getByRole("option", { name: "Intro Text" })).toBeInTheDocument();
expect(within(document.body).queryByRole("option", { name: "Default" })).toBeNull();
},
{ timeout: 3000 },
);
await userEvent.keyboard("{Escape}");
});

await step("Switching to Heading 1 applies its own default style", async () => {
await userEvent.click(canvas.getAllByRole("combobox")[0]);
await userEvent.click(within(document.body).getByRole("option", { name: "Heading 1" }));

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

await step("Heading 2 shares the styles but keeps the Default entry, having no default style", async () => {
await userEvent.click(canvas.getAllByRole("combobox")[0]);
await userEvent.click(within(document.body).getByRole("option", { name: "Heading 2" }));

// "Large Heading" is offered by both, so switching keeps it instead of resetting.
await waitFor(
() => {
expect(canvas.getAllByRole("combobox")[1]).toHaveTextContent("Large Heading");
},
{ timeout: 3000 },
);

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

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

await step("The list shortcut hands the paragraph to the list's default style and back", async () => {
await userEvent.click(canvas.getAllByRole("combobox")[0]);
await userEvent.click(within(document.body).getByRole("option", { name: "Paragraph" }));

const editor = canvas.getByRole("textbox");
await userEvent.click(editor);
await userEvent.keyboard("List item text");

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

// TipTap binds list shortcuts to Mod-Shift-{7,8}: Meta on Mac, Control elsewhere
const mod = /Mac/i.test(navigator.platform) ? "Meta" : "Control";
await userEvent.keyboard(`{${mod}>}{Shift>}8{/Shift}{/${mod}}`);

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

await userEvent.keyboard(`{${mod}>}{Shift>}8{/Shift}{/${mod}}`);

await waitFor(
() => {
expect(editor.querySelector("ul")).toBeFalsy();
expect(canvas.getAllByRole("combobox")[1]).toHaveTextContent("Intro Text");
},
{ timeout: 3000 },
);
});

await step("The text block shortcut swaps in the new text block's default style", async () => {
const editor = canvas.getByRole("textbox");
await userEvent.click(editor);

// "Intro Text" belongs to the paragraph alone, so the heading can't keep it.
const mod = /Mac/i.test(navigator.platform) ? "Meta" : "Control";
await userEvent.keyboard(`{Alt>}{${mod}>}1{/${mod}}{/Alt}`);

await waitFor(
() => {
expect(canvas.getByRole("heading", { level: 1 })).toHaveAttribute("data-text-block-style", "large-heading");
expect(canvas.getAllByRole("combobox")[1]).toHaveTextContent("Large Heading");
},
{ timeout: 3000 },
);
});
},
};
Loading
Loading