Tiptap RTE: Toolbar menu active highlighting (#19532)

* Adds optional parameter to Tiptap toolbar item's `isActive`

* Adds `isActive` support to toolbar menus and cascading menus

* Adds `isActive` support to the font menus

* Adds `isActive` support to the table menu

+ UI/CSS tweak

* Adds `isActive` support to the style menu API

+ refactored the commands

* Improves cascading menu popover closing

it previously didn't close the menu when an action was clicked.
This commit is contained in:
Lee Kelleher
2025-06-27 12:33:17 +01:00
committed by GitHub
parent ede906e152
commit cdf7b3dbef
8 changed files with 95 additions and 24 deletions

View File

@@ -9,6 +9,7 @@ export type UmbCascadingMenuItem = {
element?: HTMLElement;
separatorAfter?: boolean;
style?: string;
isActive?: () => boolean | undefined;
execute?: () => void;
};
@@ -21,8 +22,12 @@ export class UmbCascadingMenuPopoverElement extends UmbElementMixin(UUIPopoverCo
return this.shadowRoot?.querySelector(`#${popoverId}`) as UUIPopoverContainerElement;
}
#onMouseEnter(item: UmbCascadingMenuItem, popoverId: string) {
if (!item.items?.length) return;
#isMenuActive(items?: UmbCascadingMenuItem[]): boolean {
return !!items?.some((item) => item.isActive?.() || this.#isMenuActive(item.items));
}
#onMouseEnter(item: UmbCascadingMenuItem, popoverId?: string) {
if (!item.items?.length || !popoverId) return;
const popover = this.#getPopoverById(popoverId);
if (!popover) return;
@@ -33,7 +38,9 @@ export class UmbCascadingMenuPopoverElement extends UmbElementMixin(UUIPopoverCo
popover.showPopover();
}
#onMouseLeave(item: UmbCascadingMenuItem, popoverId: string) {
#onMouseLeave(item: UmbCascadingMenuItem, popoverId?: string) {
if (!popoverId) return;
const popover = this.#getPopoverById(popoverId);
if (!popover) return;
@@ -43,12 +50,16 @@ export class UmbCascadingMenuPopoverElement extends UmbElementMixin(UUIPopoverCo
popover.hidePopover();
}
#onClick(item: UmbCascadingMenuItem, popoverId: string) {
#onClick(item: UmbCascadingMenuItem, popoverId?: string) {
item.execute?.();
setTimeout(() => {
this.#onMouseLeave(item, popoverId);
}, 100);
if (!popoverId) {
setTimeout(() => {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
this.hidePopover();
}, 100);
}
}
override render() {
@@ -64,14 +75,15 @@ export class UmbCascadingMenuPopoverElement extends UmbElementMixin(UUIPopoverCo
}
#renderItem(item: UmbCascadingMenuItem, index: number) {
const popoverId = `item-${index}`;
const popoverId = item.items ? `menu-${index}` : undefined;
const element = item.element;
if (element) {
if (element && popoverId) {
element.setAttribute('popovertarget', popoverId);
}
const label = this.localize.string(item.label);
const isActive = item.isActive?.() || this.#isMenuActive(item.items) || false;
return html`
<div
@@ -84,7 +96,9 @@ export class UmbCascadingMenuPopoverElement extends UmbElementMixin(UUIPopoverCo
<uui-menu-item
class=${item.separatorAfter ? 'separator' : ''}
label=${label}
popovertarget=${popoverId}
popovertarget=${ifDefined(popoverId)}
select-mode="highlight"
?selected=${isActive}
@click-label=${() => this.#onClick(item, popoverId)}>
${when(item.icon, (icon) => html`<uui-icon slot="icon" name=${icon}></uui-icon>`)}
<div slot="label" class="menu-item">
@@ -94,8 +108,13 @@ export class UmbCascadingMenuPopoverElement extends UmbElementMixin(UUIPopoverCo
</uui-menu-item>
`,
)}
<umb-cascading-menu-popover id=${popoverId} placement="right-start" .items=${item.items}>
</umb-cascading-menu-popover>
${when(
popoverId,
(popoverId) => html`
<umb-cascading-menu-popover id=${popoverId} placement="right-start" .items=${item.items}>
</umb-cascading-menu-popover>
`,
)}
</div>
`;
}

View File

@@ -2,14 +2,28 @@ import { UmbTiptapToolbarElementApiBase } from '../../extensions/base.js';
import type { MetaTiptapToolbarStyleMenuItem } from '../../extensions/types.js';
import type { ChainedCommands, Editor } from '@umbraco-cms/backoffice/external/tiptap';
type UmbTiptapToolbarStyleMenuCommandType = {
type: string;
command: (chain: ChainedCommands) => ChainedCommands;
isActive?: (editor?: Editor) => boolean | undefined;
};
export default class UmbTiptapToolbarStyleMenuApi extends UmbTiptapToolbarElementApiBase {
#commands: Record<string, { type: string; command: (chain: ChainedCommands) => ChainedCommands }> = {
h1: { type: 'heading', command: (chain) => chain.toggleHeading({ level: 1 }) },
h2: { type: 'heading', command: (chain) => chain.toggleHeading({ level: 2 }) },
h3: { type: 'heading', command: (chain) => chain.toggleHeading({ level: 3 }) },
h4: { type: 'heading', command: (chain) => chain.toggleHeading({ level: 4 }) },
h5: { type: 'heading', command: (chain) => chain.toggleHeading({ level: 5 }) },
h6: { type: 'heading', command: (chain) => chain.toggleHeading({ level: 6 }) },
#headingCommand(level: 1 | 2 | 3 | 4 | 5 | 6): UmbTiptapToolbarStyleMenuCommandType {
return {
type: 'heading',
command: (chain) => chain.toggleHeading({ level }),
isActive: (editor) => editor?.isActive('heading', { level }),
};
}
#commands: Record<string, UmbTiptapToolbarStyleMenuCommandType> = {
h1: this.#headingCommand(1),
h2: this.#headingCommand(2),
h3: this.#headingCommand(3),
h4: this.#headingCommand(4),
h5: this.#headingCommand(5),
h6: this.#headingCommand(6),
p: { type: 'paragraph', command: (chain) => chain.setParagraph() },
blockquote: { type: 'blockquote', command: (chain) => chain.toggleBlockquote() },
code: { type: 'code', command: (chain) => chain.toggleCode() },
@@ -24,6 +38,20 @@ export default class UmbTiptapToolbarStyleMenuApi extends UmbTiptapToolbarElemen
ul: { type: 'bulletList', command: (chain) => chain.toggleBulletList() },
};
override isActive(editor?: Editor, item?: MetaTiptapToolbarStyleMenuItem) {
if (!editor || !item?.data) return false;
const { tag, id, class: className } = item.data;
const ext = tag ? this.#commands[tag] : null;
const attrs = editor?.getAttributes(ext?.type ?? 'paragraph');
const tagMatch = !tag ? true : ext ? (ext.isActive?.(editor) ?? editor?.isActive(ext.type) ?? false) : false;
const idMatch = !id ? true : attrs.id === id;
const classMatch = !className ? true : attrs.class?.includes(className) === true;
return tagMatch && idMatch && classMatch;
}
override execute(editor?: Editor, item?: MetaTiptapToolbarStyleMenuItem) {
if (!editor || !item?.data) return;
const { tag, id, class: className } = item.data;

View File

@@ -99,13 +99,18 @@ export class UmbTiptapToolbarMenuElement extends UmbLitElement {
style: item.appearance?.style ?? item.style,
separatorAfter: item.separatorAfter,
element,
isActive: () => this.api?.isActive(this.editor, item),
execute: () => this.api?.execute(this.editor, item),
};
}
#isMenuActive(items?: UmbCascadingMenuItem[]): boolean {
return !!items?.some((item) => item.isActive?.() || this.#isMenuActive(item.items));
}
readonly #onEditorUpdate = () => {
if (this.api && this.editor && this.manifest) {
this.isActive = this.api.isActive(this.editor);
this.isActive = this.api.isActive(this.editor) || this.#isMenuActive(this.#menu) || false;
}
};
@@ -117,8 +122,8 @@ export class UmbTiptapToolbarMenuElement extends UmbLitElement {
() => html`
<uui-button
compact
look=${this.isActive ? 'outline' : 'default'}
label=${ifDefined(label)}
look=${this.isActive ? 'outline' : 'default'}
title=${label}
popovertarget="popover-menu">
${when(
@@ -130,7 +135,11 @@ export class UmbTiptapToolbarMenuElement extends UmbLitElement {
</uui-button>
`,
() => html`
<uui-button compact label=${ifDefined(label)} popovertarget="popover-menu">
<uui-button
compact
label=${ifDefined(label)}
look=${this.isActive ? 'outline' : 'default'}
popovertarget="popover-menu">
<span>${label}</span>
<uui-symbol-expand slot="extra" open></uui-symbol-expand>
</uui-button>

View File

@@ -32,7 +32,7 @@ export class UmbTiptapTableToolbarMenuElement extends UmbTiptapToolbarMenuElemen
`,
)}
${this.renderMenu()}
<uui-popover-container id="popover-insert">
<uui-popover-container id="popover-insert" style="box-shadow: var(--uui-shadow-depth-3);">
<umb-tiptap-table-insert .editor=${this.editor}></umb-tiptap-table-insert>
</uui-popover-container>
`;

View File

@@ -29,6 +29,11 @@ export class UmbTiptapToolbarTableExtensionApi extends UmbTiptapToolbarElementAp
tableProperties: (editor) => this.#tableProperties(editor),
};
override isActive(editor?: Editor, item?: unknown) {
if (!item) return super.isActive(editor);
return false;
}
async #tableProperties(editor?: Editor) {
if (!editor || !editor.isActive('table')) return;

View File

@@ -3,6 +3,11 @@ import type { MetaTiptapToolbarMenuItem } from '../types.js';
import type { Editor } from '@umbraco-cms/backoffice/external/tiptap';
export default class UmbTiptapToolbarFontFamilyExtensionApi extends UmbTiptapToolbarElementApiBase {
override isActive(editor?: Editor, item?: MetaTiptapToolbarMenuItem) {
const styles = editor?.getAttributes('span')?.style;
return styles?.includes(`font-family: ${item?.data};`) === true;
}
override execute(editor?: Editor, item?: MetaTiptapToolbarMenuItem) {
if (!item?.data) return;
editor?.chain().focus().toggleSpanStyle(`font-family: ${item.data};`).run();

View File

@@ -3,6 +3,11 @@ import type { MetaTiptapToolbarMenuItem } from '../types.js';
import type { Editor } from '@umbraco-cms/backoffice/external/tiptap';
export default class UmbTiptapToolbarFontFamilyExtensionApi extends UmbTiptapToolbarElementApiBase {
override isActive(editor?: Editor, item?: MetaTiptapToolbarMenuItem) {
const styles = editor?.getAttributes('span')?.style;
return styles?.includes(`font-size: ${item?.data};`) === true;
}
override execute(editor?: Editor, item?: MetaTiptapToolbarMenuItem) {
if (!item?.data) return;
editor?.chain().focus().toggleSpanStyle(`font-size: ${item.data};`).run();

View File

@@ -54,7 +54,7 @@ export interface UmbTiptapToolbarElementApi extends UmbApi, UmbTiptapExtensionAr
/**
* Checks if the toolbar element is active.
*/
isActive(editor?: Editor): boolean;
isActive(editor?: Editor, ...args: Array<unknown>): boolean;
/**
* Checks if the toolbar element is disabled.