Skip to content
Open
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
7 changes: 7 additions & 0 deletions .changeset/tiptap-empty-heading-preview-skeleton.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"@dextinity/site-react": patch
---

Treat a document with only empty headings as empty in `hasTipTapRichTextContent`

An empty heading renders nothing, just like an empty paragraph, so a heading-only TipTap rich text block now shows the preview skeleton while it has no text.
24 changes: 24 additions & 0 deletions .changeset/tiptap-heading-only-block.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
---
"@dextinity/cms-admin": minor
"@dextinity/cms-api": minor
---

Support heading-only TipTap rich text blocks

`paragraph` is now a feature of `createTipTapRichTextBlock` like the other text block types, enabled by default. Turning it off results in a heading-only block (e.g. a headline): the text block type select only offers headings, the editor starts with a heading instead of a paragraph, and content containing a paragraph is rejected during validation.

The `heading` options gain a `defaultLevel`, the level a newly created heading gets. It defaults to the lowest allowed level and must be one of them. `migrateFromDraftJs` uses it for Draft.js blocks that don't carry a heading level, so migrated content doesn't fall back to paragraphs the schema doesn't allow.

**Example**

A headline block that only offers H2-H4 and starts with an H3:

```tsx
createTipTapRichTextBlock({
paragraph: false,
heading: { levels: [2, 3, 4], defaultLevel: 3 },
maxTextBlocks: 1,
});
```

Lists are disabled in a heading-only block, because a list item's content starts with a paragraph. Enabling one explicitly throws, as does turning off `paragraph` and `heading` together, which would leave no text block type at all.
37 changes: 36 additions & 1 deletion docs/docs/2-core-concepts/2-blocks/tiptap-rich-text-block.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ Most features of the Draft.js `RichTextBlock` have a direct equivalent in the Ti
| `rte.supports` (`SupportedThings[]`) | one option per feature ([Features](#features)) |
| `bold`, `italic`, `strikethrough`, `sub`, `sup` | `bold`, `italic`, `strike`, `sub`, `sup` |
| `header-one` … `header-six` | `heading` + the [text-block-type select](#text-block-type-and-styling-selects) (Heading 1–6) |
| `rte.standardBlockType` | [`heading: { defaultLevel }`](#heading-only-blocks) |
| `ordered-list`, `unordered-list` | `orderedList`, `unorderedList` |
| `history` | `undoRedoButtons` (Admin only) |
| `link`, `links-remove` | `link` (pass the link block) |
Expand Down Expand Up @@ -164,14 +165,15 @@ Every editor feature has its own option in the root options object, similar to [
| `strike` | `true` | API + Admin |
| `sub` | `true` | API + Admin |
| `sup` | `true` | API + Admin |
| `paragraph` | `true` | API + Admin |
| `heading` | `true` | API + Admin |
| `orderedList` | `true` | API + Admin |
| `unorderedList` | `true` | API + Admin |
| `nonBreakingSpace` | `true` | API + Admin |
| `softHyphen` | `true` | API + Admin |
| `undoRedoButtons` | `true` | Admin only |

`heading` can be configured further by passing an options object instead of `true`, which limits the allowed `levels`.
`heading` can be configured further by passing an options object instead of `true`, which limits the allowed `levels` and sets the `defaultLevel` of new headings. Turning `paragraph` off results in a [heading-only block](#heading-only-blocks).

Links are the exception to the boolean options: `link` takes the link block that is used for links, and passing it enables the feature. Links are disabled by default.

Expand Down Expand Up @@ -366,6 +368,39 @@ const nodeMapping: Record<string, TipTapNodeHandler> = {
};
```

### 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:

```ts title="tip-tap-headline.block.ts (API)"
export const TipTapHeadlineBlock = createTipTapRichTextBlock(
{
paragraph: false,
heading: { levels: [2, 3, 4], defaultLevel: 3 },
maxTextBlocks: 1,
},
{ name: "TipTapHeadline" },
);
```

```tsx title="TipTapHeadlineBlock.tsx (Admin)"
export const TipTapHeadlineBlock = createTipTapRichTextBlock({
paragraph: false,
heading: { levels: [2, 3, 4], defaultLevel: 3 },
maxTextBlocks: 1,
});
```

The editor starts with an H3, the text block type select only offers _Heading 2_ – _Heading 4_, and `Mod-Alt-2/3/4` switch between them (levels outside `levels` have no shortcut). `defaultLevel` also applies to a block that keeps its paragraphs: it is the level a new heading gets, and defaults to the lowest allowed level.

Lists are disabled in a heading-only block, because a list item's content starts with a paragraph. Enabling one explicitly (`orderedList: true`) throws, as does turning off `paragraph` and `heading` together — that would leave no text block type at all.

:::caution Existing content

The API rejects content that the configuration doesn't allow: a heading level outside the allowed `levels`, or a paragraph in a block without the `paragraph` feature. Narrowing these options for a block that already holds content therefore requires a [migration](./5-migrations.mdx). For content coming from the Draft.js block, the built-in [`migrateFromDraftJs`](#migrating-existing-content) migration already converts Draft.js blocks without a heading level into a heading with `defaultLevel`.

:::

### Child blocks

The TipTap Rich Text Block can embed other blocks directly into the rich text via the `childBlocks` option. This lets editors insert, for example, a product teaser between paragraphs, or an inline product price within a sentence — something the Draft.js block could not do.
Expand Down
22 changes: 13 additions & 9 deletions packages/admin/cms-admin/src/blocks/tipTap/TipTapToolbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -193,6 +193,7 @@ export const TipTapToolbar = ({
const specialChars = resolvedOptions.nonBreakingSpace || resolvedOptions.softHyphen;
const hasLink = resolvedOptions.link && !!linkBlock;
const headingLevels = resolvedOptions.heading ? resolvedOptions.heading.levels : [];
const hasParagraph = resolvedOptions.paragraph;
const hasPlaceholders = placeholders.length > 0;
const hasChildBlocks = Object.keys(childBlocks).length > 0;

Expand All @@ -205,7 +206,7 @@ export const TipTapToolbar = ({
return String(level);
}
}
return "paragraph";
return hasParagraph || resolvedOptions.heading === false ? "paragraph" : String(resolvedOptions.heading.defaultLevel);
})();
const activeTipTapTextBlockType: TipTapTextBlockType = (() => {
if (e.isActive("orderedList")) {
Expand All @@ -221,10 +222,11 @@ export const TipTapToolbar = ({
}
return "paragraph";
})();
const attrs = e.isActive("heading") ? e.getAttributes("heading") : e.getAttributes("paragraph");
const attrs = e.isActive("heading") || !hasParagraph ? e.getAttributes("heading") : e.getAttributes("paragraph");

// Calculate current list nesting depth for listLevelMax enforcement
let canIndent = e.can().sinkListItem("listItem");
// Calculate current list nesting depth for listLevelMax enforcement.
// The list item node only exists in the schema when lists are enabled.
let canIndent = lists && e.can().sinkListItem("listItem");
if (canIndent && listLevelMax !== undefined) {
const { $from } = e.state.selection;
let listDepth = 0;
Expand All @@ -246,7 +248,7 @@ export const TipTapToolbar = ({
canUndo: e.can().undo(),
canRedo: e.can().redo(),
canIndent,
canDedent: e.can().liftListItem("listItem"),
canDedent: lists && e.can().liftListItem("listItem"),
isBoldActive: e.isActive("bold"),
isItalicActive: e.isActive("italic"),
isUnderlineActive: e.isActive("underline"),
Expand Down Expand Up @@ -372,7 +374,7 @@ export const TipTapToolbar = ({

const handleTextBlockStyleChange = (e: SelectChangeEvent) => {
const value = e.target.value || null;
const nodeType = editor.isActive("heading") ? "heading" : "paragraph";
const nodeType = editor.isActive("heading") || !hasParagraph ? "heading" : "paragraph";
editor.chain().focus().updateAttributes(nodeType, { textBlockStyle: value }).run();
};

Expand Down Expand Up @@ -419,9 +421,11 @@ export const TipTapToolbar = ({
MenuProps={{ elevation: 1 }}
sx={selectSx}
>
<MenuItem value="paragraph" dense>
<FormattedMessage id="dextinity.blocks.tipTapRichText.textBlockType.paragraph" defaultMessage="Paragraph" />
</MenuItem>
{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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1071,3 +1071,154 @@ export const StickyToolbar: StoryObj<typeof StickyToolbarStory> = {
});
},
};

const HeadingOnlyBlock = createTipTapRichTextBlock({
paragraph: false,
heading: { levels: [2, 3, 4], defaultLevel: 3 },
nonBreakingSpace: false,
softHyphen: false,
});

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

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

export const HeadingOnly: StoryObj<typeof HeadingOnlyStory> = {
render: () => <HeadingOnlyStory />,
play: async ({ canvas, userEvent, step }) => {
await step("Editor starts with a heading of the default level", async () => {
await waitFor(
() => {
expect(canvas.getByRole("heading", { level: 3 })).toBeInTheDocument();
},
{ timeout: 5000 },
);
});

await step("Text block type dropdown only offers headings, no paragraph", async () => {
await userEvent.click(canvas.getByRole("combobox"));

await waitFor(
() => {
const body = within(document.body);
expect(body.getAllByRole("option").map((option) => option.textContent)).toEqual(["Heading 2", "Heading 3", "Heading 4"]);
},
{ timeout: 3000 },
);

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

await step("Selected heading level is applied", async () => {
await waitFor(
() => {
expect(canvas.getByRole("heading", { level: 2 })).toBeInTheDocument();
},
{ timeout: 3000 },
);
});

await step("Keyboard shortcut switches the heading level instead of toggling to a paragraph", async () => {
const editor = canvas.getByRole("textbox");
await userEvent.click(editor);
const mod = /Mac/i.test(navigator.platform) ? "Meta" : "Control";
await userEvent.keyboard(`{${mod}>}{Alt>}4{/Alt}{/${mod}}`);

await waitFor(
() => {
expect(canvas.getByRole("heading", { level: 4 })).toBeInTheDocument();
},
{ timeout: 3000 },
);
});

await step("Typing into the heading works", async () => {
await userEvent.keyboard("Headline");

await waitFor(
() => {
expect(canvas.getByRole("heading", { level: 4 })).toHaveTextContent("Headline");
},
{ timeout: 3000 },
);
});
},
};

const HeadingOnlyWithTextBlockStylesBlock = createTipTapRichTextBlock({
paragraph: false,
heading: { levels: [2, 3, 4], defaultLevel: 3 },
textBlockStyles: [
{
name: "headline550",
label: "Size 550",
appliesTo: ["heading-2", "heading-3", "heading-4"],
element: (props: HTMLAttributes<HTMLElement>) => <h2 style={{ fontSize: 40, lineHeight: 1.2 }} {...props} />,
},
],
});

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

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

export const HeadingOnlyWithTextBlockStyles: StoryObj<typeof HeadingOnlyWithTextBlockStylesStory> = {
render: () => <HeadingOnlyWithTextBlockStylesStory />,
play: async ({ canvas, userEvent, step }) => {
await step("Editor starts with a heading of the default level", async () => {
await waitFor(
() => {
expect(canvas.getByRole("heading", { level: 3 })).toBeInTheDocument();
},
{ timeout: 5000 },
);
});

await step("Applying a text block style keeps the heading", async () => {
const comboboxes = canvas.getAllByRole("combobox");
expect(comboboxes[0]).toHaveTextContent("Heading 3");
await userEvent.click(comboboxes[1]);

await waitFor(
() => {
expect(within(document.body).getByRole("option", { name: "Size 550" })).toBeInTheDocument();
},
{ timeout: 3000 },
);

await userEvent.click(within(document.body).getByRole("option", { name: "Size 550" }));

await waitFor(
() => {
expect(canvas.getByText("Size 550")).toBeInTheDocument();
},
{ timeout: 3000 },
);
});

await step("Typing into the styled heading works", async () => {
const editor = canvas.getByRole("textbox");
await userEvent.click(editor);
await userEvent.keyboard("Headline");

await waitFor(
() => {
expect(editor).toHaveTextContent("Headline");
},
{ timeout: 3000 },
);
});
},
};
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,25 @@ describe("createTipTapRichTextBlock", () => {
expect(() => createTipTapRichTextBlock({ heading: { levels: [1.5, 2] } })).toThrow();
});

it("should throw when the heading defaultLevel is not one of the allowed levels", () => {
expect(() => createTipTapRichTextBlock({ heading: { levels: [2, 3, 4], defaultLevel: 1 } })).toThrow();
expect(() => createTipTapRichTextBlock({ heading: { defaultLevel: 7 } })).toThrow();
});

it("should throw when paragraphs are disabled and no other text block type is left", () => {
expect(() => createTipTapRichTextBlock({ paragraph: false, heading: false })).toThrow();
});

it("should throw when lists are enabled without paragraphs", () => {
expect(() => createTipTapRichTextBlock({ paragraph: false, unorderedList: true })).toThrow();
expect(() => createTipTapRichTextBlock({ paragraph: false, orderedList: true })).toThrow();
});

it("should start heading-only content with a heading of the default level", () => {
const block = createTipTapRichTextBlock({ paragraph: false, heading: { levels: [2, 3, 4], defaultLevel: 3 } });
expect(block.defaultValues()).toEqual({ tipTapContent: { type: "doc", content: [{ type: "heading", attrs: { level: 3 } }] } });
});

describe("translateContent", () => {
// The HTML round trip only needs to leave non-text data byte-for-byte identical; whether
// translation itself changes the surrounding text is exercised separately below, so an
Expand Down
Loading
Loading