Tiptap RTE: Toolbar configuration sorter (#19901)

* Fix issue dragging tiptap toolbar buttons

* Moved the `.items` CSS rules to the group element

* Refactored the toolbar-group element

- Renamed "toolbar-item-click" event to "remove", to show intent
- Reordered the method names alphabetically
- Renamed `value` to `items`, to show intent
- Removed `toolbarValue`, as not required
- Added `data-mark` for menu/styleMenu buttons

* Renamed/relocated "umb-tiptap-toolbar-group-configuration" element

* Updated tag name

---------

Co-authored-by: Lan Nguyen Thuy <lnt@umbraco.dk>
Co-authored-by: leekelleher <leekelleher@gmail.com>
This commit is contained in:
NguyenThuyLan
2025-08-26 20:32:34 +07:00
committed by GitHub
parent 1bd9583cf1
commit ae8411ed7b
3 changed files with 225 additions and 104 deletions

View File

@@ -5,12 +5,15 @@ import type {
UmbTiptapToolbarRowViewModel,
} from '../types.js';
import type { UmbTiptapToolbarValue } from '../../../components/types.js';
import type { UmbTiptapToolbarGroupConfigurationElement } from './tiptap-toolbar-group-configuration.element.js';
import { customElement, css, html, property, repeat, state, when, nothing } from '@umbraco-cms/backoffice/external/lit';
import { debounce } from '@umbraco-cms/backoffice/utils';
import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element';
import { UMB_PROPERTY_CONTEXT } from '@umbraco-cms/backoffice/property';
import type { UmbPropertyEditorUiElement } from '@umbraco-cms/backoffice/property-editor';
import './tiptap-toolbar-group-configuration.element.js';
@customElement('umb-property-editor-ui-tiptap-toolbar-configuration')
export class UmbPropertyEditorUiTiptapToolbarConfigurationElement
extends UmbLitElement
@@ -74,7 +77,14 @@ export class UmbPropertyEditorUiTiptapToolbarConfigurationElement
this.#initialized = true;
}
#onClick(item: UmbTiptapToolbarExtension) {
#onChangeToolbarGroup(event: CustomEvent & { target: UmbTiptapToolbarGroupConfigurationElement }) {
event.stopPropagation();
const element = event.target;
const aliases = element.items.map((item) => item.alias);
this.#context.updateToolbarItem(aliases, [element.rowIndex, element.groupIndex]);
}
#onClickAvailableItem(item: UmbTiptapToolbarExtension) {
const lastRow = (this.#value?.length ?? 1) - 1;
const lastGroup = (this.#value?.[lastRow].length ?? 1) - 1;
const lastItem = this.#value?.[lastRow][lastGroup].length ?? 0;
@@ -128,6 +138,11 @@ export class UmbPropertyEditorUiTiptapToolbarConfigurationElement
this.#debouncedFilter(query);
}
#onRemoveToolbarItem(event: CustomEvent) {
const { groupIndex, index, rowIndex } = event.detail;
this.#context?.removeToolbarItem([rowIndex, groupIndex, index]);
}
override render() {
return html`${this.#renderDesigner()} ${this.#renderAvailableItems()}`;
}
@@ -173,7 +188,7 @@ export class UmbPropertyEditorUiTiptapToolbarConfigurationElement
label=${label}
look=${forbidden ? 'placeholder' : 'outline'}
?disabled=${forbidden || inUse}
@click=${() => this.#onClick(item)}
@click=${() => this.#onClickAvailableItem(item)}
@dragstart=${(e: DragEvent) => this.#onDragStart(e, item.alias)}
@dragend=${this.#onDragEnd}>
<div class="inner">
@@ -242,19 +257,22 @@ export class UmbPropertyEditorUiTiptapToolbarConfigurationElement
#renderGroup(group?: UmbTiptapToolbarGroupViewModel, rowIndex = 0, groupIndex = 0) {
if (!group) return nothing;
const showActionBar = this._toolbar[rowIndex].data.length > 1 && group.data.length === 0;
const items: UmbTiptapToolbarExtension[] = group!.data
.map((alias) => this.#context?.getExtensionByAlias(alias))
.filter((item): item is UmbTiptapToolbarExtension => !!item);
return html`
<div
class="group"
dropzone="move"
@dragover=${this.#onDragOver}
@drop=${(e: DragEvent) => this.#onDrop(e, [rowIndex, groupIndex, group.data.length - 1])}>
<div class="items">
${when(
group?.data.length === 0,
() => html`<em><umb-localize key="tiptap_toolbar_emptyGroup">Empty</umb-localize></em>`,
() => html`${group!.data.map((alias, idx) => this.#renderItem(alias, rowIndex, groupIndex, idx))}`,
)}
</div>
<umb-tiptap-toolbar-group-configuration
.items=${items}
.rowIndex=${rowIndex}
.groupIndex=${groupIndex}
@change=${this.#onChangeToolbarGroup}
@remove=${this.#onRemoveToolbarItem}>
</umb-tiptap-toolbar-group-configuration>
${when(
showActionBar,
() => html`
@@ -276,62 +294,6 @@ export class UmbPropertyEditorUiTiptapToolbarConfigurationElement
`;
}
#renderItem(alias: string, rowIndex = 0, groupIndex = 0, itemIndex = 0) {
const item = this.#context?.getExtensionByAlias(alias);
if (!item) return nothing;
const forbidden = !this.#context?.isExtensionEnabled(item.alias);
const label = this.localize.string(item.label);
switch (item.kind) {
case 'styleMenu':
case 'menu':
return html`
<uui-button
compact
class=${forbidden ? 'forbidden' : ''}
draggable="true"
label=${label}
look=${forbidden ? 'placeholder' : 'outline'}
title=${label}
?disabled=${forbidden}
@click=${() => this.#context.removeToolbarItem([rowIndex, groupIndex, itemIndex])}
@dragend=${this.#onDragEnd}
@dragstart=${(e: DragEvent) => this.#onDragStart(e, alias, [rowIndex, groupIndex, itemIndex])}>
<div class="inner">
<span>${label}</span>
</div>
<uui-symbol-expand slot="extra" open></uui-symbol-expand>
</uui-button>
`;
case 'button':
default:
return html`
<uui-button
compact
class=${forbidden ? 'forbidden' : ''}
data-mark="tiptap-toolbar-item:${item.alias}"
draggable="true"
look=${forbidden ? 'placeholder' : 'outline'}
label=${label}
title=${label}
?disabled=${forbidden}
@click=${() => this.#context.removeToolbarItem([rowIndex, groupIndex, itemIndex])}
@dragend=${this.#onDragEnd}
@dragstart=${(e: DragEvent) => this.#onDragStart(e, alias, [rowIndex, groupIndex, itemIndex])}>
<div class="inner">
${when(
item.icon,
() => html`<umb-icon .name=${item.icon}></umb-icon>`,
() => html`<span>${label}</span>`,
)}
</div>
</uui-button>
`;
}
}
static override readonly styles = [
css`
:host {
@@ -468,45 +430,6 @@ export class UmbPropertyEditorUiTiptapToolbarConfigurationElement
opacity: 1;
}
}
.items {
display: flex;
flex-direction: row;
flex-wrap: wrap;
gap: var(--uui-size-1);
uui-button {
--uui-button-font-weight: normal;
&[draggable='true'],
&[draggable='true'] > .inner {
cursor: move;
}
&[disabled],
&[disabled] > .inner {
cursor: not-allowed;
}
&.forbidden {
--color: var(--uui-color-danger);
--color-standalone: var(--uui-color-danger-standalone);
--color-emphasis: var(--uui-color-danger-emphasis);
--color-contrast: var(--uui-color-danger);
--uui-button-contrast-disabled: var(--uui-color-danger);
--uui-button-border-color-disabled: var(--uui-color-danger);
}
div {
display: flex;
gap: var(--uui-size-1);
}
uui-symbol-expand {
margin-left: var(--uui-size-space-2);
}
}
}
}
}
}

View File

@@ -0,0 +1,177 @@
import { UmbTiptapToolbarConfigurationContext } from '../contexts/tiptap-toolbar-configuration.context.js';
import type { UmbTiptapToolbarExtension } from '../types.js';
import { css, customElement, html, property, repeat, when } from '@umbraco-cms/backoffice/external/lit';
import { UmbChangeEvent } from '@umbraco-cms/backoffice/event';
import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element';
import { UmbSorterController, UmbSorterResolvePlacementAsGrid } from '@umbraco-cms/backoffice/sorter';
@customElement('umb-tiptap-toolbar-group-configuration')
export class UmbTiptapToolbarGroupConfigurationElement<
TiptapToolbarItem extends UmbTiptapToolbarExtension = UmbTiptapToolbarExtension,
> extends UmbLitElement {
#sorter = new UmbSorterController<TiptapToolbarItem, HTMLElement>(this, {
getUniqueOfElement: (element) => element.getAttribute('tiptap-toolbar-alias'),
getUniqueOfModel: (modelEntry) => modelEntry.alias!,
itemSelector: 'uui-button',
identifier: 'umb-tiptap-toolbar-sorter',
containerSelector: '.items',
resolvePlacement: UmbSorterResolvePlacementAsGrid,
onContainerChange: ({ item, model }) => {
this.dispatchEvent(new CustomEvent('container-change', { detail: { item, model } }));
},
onChange: ({ model }) => {
this.#items = model;
this.requestUpdate();
this.dispatchEvent(new UmbChangeEvent());
},
});
#context = new UmbTiptapToolbarConfigurationContext(this);
@property({ type: Array, attribute: false })
public set items(value: Array<TiptapToolbarItem> | undefined) {
this.#items = (value ?? []).filter((item, index, self) => self.findIndex((x) => x.alias === item.alias) === index);
this.#sorter.setModel(this.#items);
}
public get items(): Array<TiptapToolbarItem> {
return this.#items;
}
#items: Array<TiptapToolbarItem> = [];
@property({ type: Number })
rowIndex = 0;
@property({ type: Number })
groupIndex = 0;
#onRequestRemove(item: TiptapToolbarItem, index = 0) {
this.items = this.items.filter((x) => x.alias !== item.alias);
const rowIndex = this.rowIndex;
const groupIndex = this.groupIndex;
this.dispatchEvent(
new CustomEvent('remove', { detail: { rowIndex, groupIndex, index }, bubbles: true, composed: true }),
);
}
override render() {
return html`
<div class="items">
${when(
this.items?.length === 0,
() => html`<em><umb-localize key="tiptap_toolbar_emptyGroup">Empty</umb-localize></em>`,
() =>
repeat(
this.items,
(item) => item.alias,
(item, index) => this.#renderItem(item, index),
),
)}
</div>
`;
}
#renderItem(item: TiptapToolbarItem, index = 0) {
const label = this.localize.string(item.label);
const forbidden = !this.#context?.isExtensionEnabled(item.alias);
switch (item.kind) {
case 'styleMenu':
case 'menu':
return html`
<uui-button
compact
class=${forbidden ? 'forbidden' : ''}
data-mark="tiptap-toolbar-item:${item.alias}"
look=${forbidden ? 'placeholder' : 'outline'}
label=${label}
title=${label}
?disabled=${forbidden}
tiptap-toolbar-alias=${item.alias}
@click=${() => this.#onRequestRemove(item, index)}>
<div class="inner">
<span>${label}</span>
</div>
<uui-symbol-expand slot="extra" open></uui-symbol-expand>
</uui-button>
`;
case 'button':
default:
return html`
<uui-button
compact
class=${forbidden ? 'forbidden' : ''}
data-mark="tiptap-toolbar-item:${item.alias}"
look=${forbidden ? 'placeholder' : 'outline'}
label=${label}
title=${label}
?disabled=${forbidden}
tiptap-toolbar-alias=${item.alias}
@click=${() => this.#onRequestRemove(item, index)}>
<div class="inner">
${when(
item.icon,
() => html`<umb-icon .name=${item.icon}></umb-icon>`,
() => html`<span>${label}</span>`,
)}
</div>
</uui-button>
`;
}
}
static override styles = [
css`
.items {
display: flex;
flex-direction: row;
flex-wrap: wrap;
gap: var(--uui-size-1);
uui-button {
--uui-button-font-weight: normal;
&[draggable='true'],
&[draggable='true'] > .inner {
cursor: move;
}
&[disabled],
&[disabled] > .inner {
cursor: not-allowed;
}
&.forbidden {
--color: var(--uui-color-danger);
--color-standalone: var(--uui-color-danger-standalone);
--color-emphasis: var(--uui-color-danger-emphasis);
--color-contrast: var(--uui-color-danger);
--uui-button-contrast-disabled: var(--uui-color-danger);
--uui-button-border-color-disabled: var(--uui-color-danger);
}
div {
display: flex;
gap: var(--uui-size-1);
}
uui-symbol-expand {
margin-left: var(--uui-size-space-2);
}
}
}
uui-button[look='outline'] {
--uui-button-background-color-hover: var(--uui-color-surface);
}
`,
];
}
export default UmbTiptapToolbarGroupConfigurationElement;
declare global {
interface HTMLElementTagNameMap {
'umb-tiptap-toolbar-group-configuration': UmbTiptapToolbarGroupConfigurationElement;
}
}

View File

@@ -240,6 +240,27 @@ export class UmbTiptapToolbarConfigurationContext extends UmbContextBase {
this.#toolbar.setValue(toolbar);
}
public updateToolbarItem(aliases: Array<string>, to: [number, number]) {
const toolbar = [...this.#toolbar.getValue()];
const [rowIndex, groupIndex] = to;
const newToolbar = toolbar.map((row, rIdx) => {
if (rIdx !== rowIndex) return row;
return {
...row,
data: row.data.map((group, gIdx) => {
if (gIdx !== groupIndex) return group;
return {
...group,
data: [...aliases],
};
}),
};
});
this.#toolbar.setValue(newToolbar);
}
public updateToolbarRow(rowIndex: number, groups: Array<UmbTiptapToolbarGroupViewModel>) {
const toolbar = [...this.#toolbar.getValue()];