Tiptap toolbar designer reworking
Observes the enabled extensions, updates available items accordingly. Adds a context API and label localizations.
This commit is contained in:
committed by
Jacob Overgaard
parent
0b7c50730e
commit
bd5b898fce
@@ -2648,5 +2648,14 @@ export default {
|
||||
extGroup_interactive: 'Interactive elements',
|
||||
extGroup_media: 'Embeds and media',
|
||||
extGroup_structure: 'Content structure',
|
||||
toobar_availableItems: 'Available toolbar items',
|
||||
toobar_availableItemsEmpty: 'There are no toolbar extensions to show',
|
||||
toolbar_designer: 'Toolbar designer',
|
||||
toolbar_addRow: 'Add row configuration',
|
||||
toolbar_addGroup: 'Add group',
|
||||
toolbar_addItems: 'Add items',
|
||||
toolbar_removeRow: 'Remove row',
|
||||
toolbar_removeGroup: 'Remove group',
|
||||
toolbar_removeItem: 'Remove item',
|
||||
},
|
||||
} as UmbLocalizationDictionary;
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
import { customElement, css, html, property, state, repeat, nothing } from '@umbraco-cms/backoffice/external/lit';
|
||||
import { umbExtensionsRegistry } from '@umbraco-cms/backoffice/extension-registry';
|
||||
import { UmbTiptapToolbarConfigurationContext } from '../contexts/tiptap-toolbar-configuration.context.js';
|
||||
import type {
|
||||
UmbTiptapToolbarExtension,
|
||||
UmbTiptapToolbarGroupViewModel,
|
||||
UmbTiptapToolbarRowViewModel,
|
||||
} from '../types.js';
|
||||
import type { UmbTiptapToolbarValue } from '../../../components/types.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 { UmbTextStyles } from '@umbraco-cms/backoffice/style';
|
||||
import { UmbPropertyValueChangeEvent, type UmbPropertyEditorUiElement } from '@umbraco-cms/backoffice/property-editor';
|
||||
import type { PropertyValueMap } from '@umbraco-cms/backoffice/external/lit';
|
||||
import { UMB_PROPERTY_CONTEXT } from '@umbraco-cms/backoffice/property';
|
||||
import type { UmbPropertyEditorUiElement } from '@umbraco-cms/backoffice/property-editor';
|
||||
|
||||
type UmbTiptapToolbarExtension = {
|
||||
alias: string;
|
||||
label: string;
|
||||
icon: string;
|
||||
};
|
||||
const elementName = 'umb-property-editor-ui-tiptap-toolbar-configuration';
|
||||
|
||||
@customElement(elementName)
|
||||
@@ -18,56 +18,59 @@ export class UmbPropertyEditorUiTiptapToolbarConfigurationElement
|
||||
extends UmbLitElement
|
||||
implements UmbPropertyEditorUiElement
|
||||
{
|
||||
readonly #inUse: Set<string> = new Set();
|
||||
#context = new UmbTiptapToolbarConfigurationContext(this);
|
||||
|
||||
#currentDragItem?: {
|
||||
alias: string;
|
||||
fromPos?: [number, number, number];
|
||||
};
|
||||
#currentDragItem?: { alias: string; fromPos?: [number, number, number] };
|
||||
|
||||
#lookup?: Map<string, UmbTiptapToolbarExtension>;
|
||||
#debouncedFilter = debounce((query: string) => {
|
||||
this._availableExtensions = this.#context.filterExtensions(query);
|
||||
}, 250);
|
||||
|
||||
@state()
|
||||
private _extensions: Array<UmbTiptapToolbarExtension> = [];
|
||||
private _availableExtensions: Array<UmbTiptapToolbarExtension> = [];
|
||||
|
||||
@state()
|
||||
private _toolbar: Array<UmbTiptapToolbarRowViewModel> = [];
|
||||
|
||||
@property({ attribute: false })
|
||||
set value(value: UmbTiptapToolbarValue | undefined) {
|
||||
if (!this.#isValidTiptapToolbarValue(value)) {
|
||||
this.#value = [[[]]];
|
||||
return;
|
||||
}
|
||||
|
||||
if (value.length > 0) {
|
||||
this.#value = value.map((rows) => rows.map((groups) => [...groups]));
|
||||
value.forEach((row) => row.forEach((group) => group.forEach((alias) => this.#inUse.add(alias))));
|
||||
}
|
||||
if (!value) value = [[[]]];
|
||||
if (value === this.#value) return;
|
||||
this.#context.setToolbar(value);
|
||||
}
|
||||
get value(): UmbTiptapToolbarValue {
|
||||
return this.#value;
|
||||
get value(): UmbTiptapToolbarValue | undefined {
|
||||
return this.#value?.map((rows) => rows.map((groups) => [...groups]));
|
||||
}
|
||||
#value: UmbTiptapToolbarValue = [[[]]];
|
||||
#value?: UmbTiptapToolbarValue;
|
||||
|
||||
protected override async firstUpdated(_changedProperties: PropertyValueMap<unknown>) {
|
||||
super.firstUpdated(_changedProperties);
|
||||
constructor() {
|
||||
super();
|
||||
|
||||
this.observe(umbExtensionsRegistry.byType('tiptapToolbarExtension'), (extensions) => {
|
||||
this._extensions = extensions.map((ext) => ({ alias: ext.alias, label: ext.meta.label, icon: ext.meta.icon }));
|
||||
this.#lookup = new Map(this._extensions.map((ext) => [ext.alias, ext]));
|
||||
this.consumeContext(UMB_PROPERTY_CONTEXT, (propertyContext) => {
|
||||
this.observe(this.#context.extensions, (extensions) => {
|
||||
this._availableExtensions = extensions;
|
||||
});
|
||||
|
||||
this.observe(this.#context.reload, (reload) => {
|
||||
if (reload) {
|
||||
this.requestUpdate();
|
||||
}
|
||||
});
|
||||
|
||||
this.observe(this.#context.toolbar, (toolbar) => {
|
||||
if (!toolbar.length) return;
|
||||
this._toolbar = toolbar;
|
||||
this.#value = toolbar.map((rows) => rows.data.map((groups) => [...groups.data]));
|
||||
propertyContext.setValue(this.#value);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
#isValidTiptapToolbarValue(value: unknown): value is UmbTiptapToolbarValue {
|
||||
if (!Array.isArray(value)) return false;
|
||||
for (const row of value) {
|
||||
if (!Array.isArray(row)) return false;
|
||||
for (const group of row) {
|
||||
if (!Array.isArray(group)) return false;
|
||||
for (const alias of group) {
|
||||
if (typeof alias !== 'string') return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
return true;
|
||||
#onClick(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;
|
||||
this.#context.insertToolbarItem(item.alias, [lastRow, lastGroup, lastItem]);
|
||||
}
|
||||
|
||||
#onDragStart(event: DragEvent, alias: string, fromPos?: [number, number, number]) {
|
||||
@@ -86,7 +89,7 @@ export class UmbPropertyEditorUiTiptapToolbarConfigurationElement
|
||||
const { fromPos } = this.#currentDragItem ?? {};
|
||||
if (!fromPos) return;
|
||||
|
||||
this.#removeItem(fromPos);
|
||||
this.#context.removeToolbarItem(fromPos);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -96,240 +99,353 @@ export class UmbPropertyEditorUiTiptapToolbarConfigurationElement
|
||||
|
||||
// Remove item if no destination position is provided
|
||||
if (fromPos && !toPos) {
|
||||
this.#removeItem(fromPos);
|
||||
this.#context.removeToolbarItem(fromPos);
|
||||
return;
|
||||
}
|
||||
|
||||
// Move item if both source and destination positions are available
|
||||
if (fromPos && toPos) {
|
||||
this.#moveItem(fromPos, toPos);
|
||||
this.#context.moveToolbarItem(fromPos, toPos);
|
||||
return;
|
||||
}
|
||||
|
||||
// Insert item if an alias and a destination position are provided
|
||||
if (alias && toPos) {
|
||||
this.#insertItem(alias, toPos);
|
||||
this.#context.insertToolbarItem(alias, toPos);
|
||||
}
|
||||
}
|
||||
|
||||
#moveItem(from: [number, number, number], to: [number, number, number]) {
|
||||
const [rowIndex, groupIndex, itemIndex] = from;
|
||||
|
||||
// Get the item to move from the 'from' position
|
||||
const itemToMove = this.#value[rowIndex][groupIndex][itemIndex];
|
||||
|
||||
// Remove the item from the original position
|
||||
this.#value[rowIndex][groupIndex].splice(itemIndex, 1);
|
||||
|
||||
this.#insertItem(itemToMove, to);
|
||||
}
|
||||
|
||||
#insertItem(alias: string, toPos: [number, number, number]) {
|
||||
const [rowIndex, groupIndex, itemIndex] = toPos;
|
||||
|
||||
// Insert the item into the new position
|
||||
const inserted = this.#value[rowIndex][groupIndex].splice(itemIndex, 0, alias);
|
||||
inserted.forEach((alias) => this.#inUse.add(alias));
|
||||
|
||||
this.dispatchEvent(new UmbPropertyValueChangeEvent());
|
||||
}
|
||||
|
||||
#removeItem(from: [number, number, number]) {
|
||||
const [rowIndex, groupIndex, itemIndex] = from;
|
||||
|
||||
const removed = this.#value[rowIndex][groupIndex].splice(itemIndex, 1);
|
||||
removed.forEach((alias) => this.#inUse.delete(alias));
|
||||
|
||||
this.dispatchEvent(new UmbPropertyValueChangeEvent());
|
||||
}
|
||||
|
||||
#addGroup(rowIndex: number, groupIndex: number) {
|
||||
this.#value[rowIndex].splice(groupIndex, 0, []);
|
||||
this.dispatchEvent(new UmbPropertyValueChangeEvent());
|
||||
}
|
||||
|
||||
#removeGroup(rowIndex: number, groupIndex: number) {
|
||||
if (this.#value[rowIndex].length > groupIndex) {
|
||||
const removed = this.#value[rowIndex].splice(groupIndex, 1);
|
||||
removed.forEach((group) => group.forEach((alias) => this.#inUse.delete(alias)));
|
||||
}
|
||||
|
||||
// Prevent leaving an empty group
|
||||
if (this.#value[rowIndex].length === 0) {
|
||||
this.#value[rowIndex][groupIndex] = [];
|
||||
}
|
||||
|
||||
this.dispatchEvent(new UmbPropertyValueChangeEvent());
|
||||
}
|
||||
|
||||
#addRow(rowIndex: number) {
|
||||
this.#value.splice(rowIndex, 0, [[]]);
|
||||
this.dispatchEvent(new UmbPropertyValueChangeEvent());
|
||||
}
|
||||
|
||||
#removeRow(rowIndex: number) {
|
||||
if (this.#value.length > rowIndex) {
|
||||
const removed = this.#value.splice(rowIndex, 1);
|
||||
removed.forEach((row) => row.forEach((group) => group.forEach((alias) => this.#inUse.delete(alias))));
|
||||
}
|
||||
|
||||
// Prevent leaving an empty row
|
||||
if (this.#value.length === 0) {
|
||||
this.#value[rowIndex] = [[]];
|
||||
}
|
||||
|
||||
this.dispatchEvent(new UmbPropertyValueChangeEvent());
|
||||
#onFilterInput(event: InputEvent & { target: HTMLInputElement }) {
|
||||
const query = (event.target.value ?? '').toLocaleLowerCase();
|
||||
this.#debouncedFilter(query);
|
||||
}
|
||||
|
||||
override render() {
|
||||
return html`
|
||||
${repeat(this.#value, (row, rowIndex) => this.#renderRow(row, rowIndex))}
|
||||
<uui-button look="secondary" @click=${() => this.#addRow(this.#value.length)}>
|
||||
<uui-icon name="add"></uui-icon>
|
||||
<span>Add row</span>
|
||||
</uui-button>
|
||||
${this.#renderExtensions()}
|
||||
`;
|
||||
return html`${this.#renderAvailableItems()} ${this.#renderDesigner()}`;
|
||||
}
|
||||
|
||||
#renderRow(row: string[][], rowIndex: number) {
|
||||
#renderAvailableItems() {
|
||||
return html`
|
||||
<div class="row">
|
||||
${repeat(row, (group, groupIndex) => this.#renderGroup(group, rowIndex, groupIndex))}
|
||||
<uui-button look="secondary" @click=${() => this.#addGroup(rowIndex, row.length)}>
|
||||
<uui-icon name="add"></uui-icon>
|
||||
<span>Add group</span>
|
||||
</uui-button>
|
||||
<uui-button
|
||||
compact
|
||||
color="danger"
|
||||
look="primary"
|
||||
class="remove-row-button ${rowIndex === 0 && row.length === 1 && row[0].length === 0 ? 'hidden' : undefined}"
|
||||
@click=${() => this.#removeRow(rowIndex)}>
|
||||
<umb-icon name="icon-trash"></umb-icon>
|
||||
</uui-button>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
#renderGroup(group: string[], rowIndex: number, groupIndex: number) {
|
||||
return html`
|
||||
<div
|
||||
class="group"
|
||||
dropzone="move"
|
||||
@dragover=${this.#onDragOver}
|
||||
@drop=${(e: DragEvent) => this.#onDrop(e, [rowIndex, groupIndex, group.length])}>
|
||||
${group.map((alias, itemIndex) => this.#renderItem(alias, rowIndex, groupIndex, itemIndex))}
|
||||
<uui-button
|
||||
compact
|
||||
color="danger"
|
||||
look="primary"
|
||||
class="remove-group-button ${groupIndex === 0 && group.length === 0 ? 'hidden' : undefined}"
|
||||
@click=${() => this.#removeGroup(rowIndex, groupIndex)}>
|
||||
<umb-icon name="icon-trash"></umb-icon>
|
||||
</uui-button>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
#renderItem(alias: string, rowIndex: number, groupIndex: number, itemIndex: number) {
|
||||
const extension = this.#lookup?.get(alias);
|
||||
if (!extension) return nothing;
|
||||
return html`
|
||||
<div
|
||||
title=${this.localize.string(extension.label)}
|
||||
class="item"
|
||||
draggable="true"
|
||||
@dragend=${this.#onDragEnd}
|
||||
@dragstart=${(e: DragEvent) => this.#onDragStart(e, alias, [rowIndex, groupIndex, itemIndex])}>
|
||||
<umb-icon name=${extension.icon ?? ''}></umb-icon>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
#renderExtensions() {
|
||||
return html`
|
||||
<div class="extensions" dropzone="move" @drop=${this.#onDrop} @dragover=${this.#onDragOver}>
|
||||
${repeat(
|
||||
this._extensions.filter((ext) => !this.#inUse.has(ext.alias)),
|
||||
(extension) => html`
|
||||
<div
|
||||
class="item"
|
||||
draggable="true"
|
||||
title=${this.localize.string(extension.label)}
|
||||
@dragstart=${(e: DragEvent) => this.#onDragStart(e, extension.alias)}
|
||||
@dragend=${this.#onDragEnd}>
|
||||
<umb-icon name=${extension.icon ?? ''}></umb-icon>
|
||||
<uui-box class="minimal" headline=${this.localize.term('tiptap_toobar_availableItems')}>
|
||||
<div slot="header-actions">
|
||||
<uui-input
|
||||
type="search"
|
||||
autocomplete="off"
|
||||
placeholder=${this.localize.term('placeholders_filter')}
|
||||
@input=${this.#onFilterInput}>
|
||||
<div slot="prepend">
|
||||
<uui-icon name="search"></uui-icon>
|
||||
</div>
|
||||
</uui-input>
|
||||
</div>
|
||||
<div class="available-items" dropzone="move" @drop=${this.#onDrop} @dragover=${this.#onDragOver}>
|
||||
${when(
|
||||
this._availableExtensions.length === 0,
|
||||
() =>
|
||||
html`<umb-localize key="tiptap_toobar_availableItemsEmpty"
|
||||
>There are no toolbar extensions to show</umb-localize
|
||||
>`,
|
||||
() => repeat(this._availableExtensions, (item) => this.#renderAvailableItem(item)),
|
||||
)}
|
||||
</div>
|
||||
</uui-box>
|
||||
`;
|
||||
}
|
||||
|
||||
#renderAvailableItem(item: UmbTiptapToolbarExtension) {
|
||||
const forbidden = !this.#context.isExtensionEnabled(item.alias);
|
||||
const inUse = this.#context.isExtensionInUse(item.alias);
|
||||
return html`
|
||||
<uui-button
|
||||
compact
|
||||
class=${forbidden ? 'forbidden' : ''}
|
||||
draggable="true"
|
||||
look=${forbidden ? 'placeholder' : 'outline'}
|
||||
?disabled=${forbidden || inUse}
|
||||
@click=${() => this.#onClick(item)}
|
||||
@dragstart=${(e: DragEvent) => this.#onDragStart(e, item.alias)}
|
||||
@dragend=${this.#onDragEnd}>
|
||||
<div class="inner">
|
||||
${when(item.icon, () => html`<umb-icon .name=${item.icon}></umb-icon>`)}
|
||||
<span>${this.localize.string(item.label)}</span>
|
||||
</div>
|
||||
</uui-button>
|
||||
`;
|
||||
}
|
||||
|
||||
#renderDesigner() {
|
||||
return html`
|
||||
<uui-box class="minimal" headline=${this.localize.term('tiptap_toolbar_designer')}>
|
||||
<div id="rows">
|
||||
${repeat(
|
||||
this._toolbar,
|
||||
(row) => row.unique,
|
||||
(row, idx) => this.#renderRow(row, idx),
|
||||
)}
|
||||
</div>
|
||||
<uui-button
|
||||
id="btnAddRow"
|
||||
look="placeholder"
|
||||
label=${this.localize.term('tiptap_toolbar_addRow')}
|
||||
@click=${() => this.#context.insertToolbarRow(this._toolbar.length)}></uui-button>
|
||||
</uui-box>
|
||||
`;
|
||||
}
|
||||
|
||||
#renderRow(row?: UmbTiptapToolbarRowViewModel, rowIndex = 0) {
|
||||
if (!row) return nothing;
|
||||
const hideActionBar = this._toolbar.length === 1;
|
||||
return html`
|
||||
<uui-button-inline-create
|
||||
label=${this.localize.term('tiptap_toolbar_addRow')}
|
||||
@click=${() => this.#context?.insertToolbarRow(rowIndex)}></uui-button-inline-create>
|
||||
<div class="row">
|
||||
<div class="groups">
|
||||
<uui-button-inline-create
|
||||
vertical
|
||||
label=${this.localize.term('tiptap_toolbar_addGroup')}
|
||||
@click=${() => this.#context?.insertToolbarGroup(rowIndex, 0)}></uui-button-inline-create>
|
||||
${repeat(
|
||||
row.data,
|
||||
(group) => group.unique,
|
||||
(group, idx) => this.#renderGroup(group, rowIndex, idx),
|
||||
)}
|
||||
</div>
|
||||
${when(
|
||||
!hideActionBar,
|
||||
() => html`
|
||||
<uui-action-bar>
|
||||
<uui-button
|
||||
color="danger"
|
||||
look="secondary"
|
||||
label=${this.localize.term('tiptap_toolbar_removeRow')}
|
||||
@click=${() => this.#context?.removeToolbarRow(rowIndex)}>
|
||||
<uui-icon name="icon-trash"></uui-icon>
|
||||
</uui-button>
|
||||
</uui-action-bar>
|
||||
`,
|
||||
)}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
#renderGroup(group?: UmbTiptapToolbarGroupViewModel, rowIndex = 0, groupIndex = 0) {
|
||||
if (!group) return nothing;
|
||||
const hideActionBar = this._toolbar[rowIndex].data.length === 1 && group.data.length === 0;
|
||||
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_addItems">Add items</umb-localize></em>`,
|
||||
() => html`${group!.data.map((alias, idx) => this.#renderItem(alias, rowIndex, groupIndex, idx))}`,
|
||||
)}
|
||||
</div>
|
||||
${when(
|
||||
!hideActionBar,
|
||||
() => html`
|
||||
<uui-action-bar>
|
||||
<uui-button
|
||||
color="danger"
|
||||
look="secondary"
|
||||
label=${this.localize.term('tiptap_toolbar_removeGroup')}
|
||||
@click=${() => this.#context?.removeToolbarGroup(rowIndex, groupIndex)}>
|
||||
<uui-icon name="icon-trash"></uui-icon>
|
||||
</uui-button>
|
||||
</uui-action-bar>
|
||||
`,
|
||||
)}
|
||||
</div>
|
||||
<uui-button-inline-create
|
||||
vertical
|
||||
label=${this.localize.term('tiptap_toolbar_addGroup')}
|
||||
@click=${() => this.#context?.insertToolbarGroup(rowIndex, groupIndex + 1)}></uui-button-inline-create>
|
||||
`;
|
||||
}
|
||||
|
||||
#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);
|
||||
return html`
|
||||
<uui-button
|
||||
compact
|
||||
class=${forbidden ? 'forbidden' : ''}
|
||||
draggable="true"
|
||||
look=${forbidden ? 'placeholder' : 'outline'}
|
||||
title=${this.localize.string(item.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>${this.localize.string(item.label)}</span>`,
|
||||
)}
|
||||
</div>
|
||||
</uui-button>
|
||||
`;
|
||||
}
|
||||
|
||||
static override readonly styles = [
|
||||
UmbTextStyles,
|
||||
css`
|
||||
:host {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
gap: var(--uui-size-1);
|
||||
}
|
||||
.extensions {
|
||||
|
||||
uui-box.minimal {
|
||||
--uui-box-header-padding: 0;
|
||||
--uui-box-default-padding: var(--uui-size-2) 0;
|
||||
--uui-box-box-shadow: none;
|
||||
|
||||
[slot='header-actions'] {
|
||||
margin-bottom: var(--uui-size-2);
|
||||
|
||||
uui-icon {
|
||||
color: var(--uui-color-border);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.available-items {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 3px;
|
||||
border-radius: var(--uui-border-radius);
|
||||
gap: var(--uui-size-3);
|
||||
background-color: var(--uui-color-surface-alt);
|
||||
padding: 6px;
|
||||
min-height: 30px;
|
||||
min-width: 30px;
|
||||
}
|
||||
.row {
|
||||
position: relative;
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
.group {
|
||||
position: relative;
|
||||
display: flex;
|
||||
gap: 3px;
|
||||
border-radius: var(--uui-border-radius);
|
||||
background-color: var(--uui-color-surface-alt);
|
||||
padding: 6px;
|
||||
min-height: 32px;
|
||||
min-width: 32px;
|
||||
padding: var(--uui-size-3);
|
||||
|
||||
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);
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
div {
|
||||
display: flex;
|
||||
gap: var(--uui-size-1);
|
||||
}
|
||||
}
|
||||
}
|
||||
.item {
|
||||
padding: var(--uui-size-space-2);
|
||||
border: 1px solid var(--uui-color-border);
|
||||
border-radius: var(--uui-border-radius);
|
||||
background-color: var(--uui-color-surface);
|
||||
|
||||
#rows {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--uui-size-1);
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: var(--uui-size-3);
|
||||
border: 1px solid var(--uui-color-border);
|
||||
border-radius: var(--uui-border-radius);
|
||||
padding: var(--uui-size-3) var(--uui-size-2);
|
||||
|
||||
&:hover {
|
||||
border-color: var(--uui-button-contrast-hover);
|
||||
}
|
||||
|
||||
.groups {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
gap: var(--uui-size-1);
|
||||
|
||||
uui-button-inline-create {
|
||||
height: 40px;
|
||||
}
|
||||
|
||||
.group {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--uui-size-3);
|
||||
|
||||
border: 1px dashed var(--uui-color-border-standalone);
|
||||
border-radius: var(--uui-border-radius);
|
||||
padding: var(--uui-size-3);
|
||||
|
||||
&:hover {
|
||||
border-color: var(--uui-button-contrast-hover);
|
||||
}
|
||||
|
||||
.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);
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
div {
|
||||
display: flex;
|
||||
gap: var(--uui-size-1);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#btnAddRow {
|
||||
display: block;
|
||||
margin-top: var(--uui-size-1);
|
||||
}
|
||||
|
||||
.handle {
|
||||
cursor: move;
|
||||
display: flex;
|
||||
box-sizing: border-box;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.remove-row-button,
|
||||
.remove-group-button {
|
||||
display: none;
|
||||
}
|
||||
.remove-group-button {
|
||||
position: absolute;
|
||||
top: -26px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.row:hover .remove-row-button:not(.hidden),
|
||||
.group:hover .remove-group-button:not(.hidden) {
|
||||
display: flex;
|
||||
}
|
||||
umb-icon {
|
||||
/* Prevents titles from bugging out */
|
||||
pointer-events: none;
|
||||
}
|
||||
`,
|
||||
];
|
||||
|
||||
@@ -0,0 +1,247 @@
|
||||
import type { UmbTiptapToolbarValue } from '../../../components/types.js';
|
||||
import type {
|
||||
UmbTiptapToolbarExtension,
|
||||
UmbTiptapToolbarGroupViewModel,
|
||||
UmbTiptapToolbarRowViewModel,
|
||||
} from '../types.js';
|
||||
import { umbExtensionsRegistry } from '@umbraco-cms/backoffice/extension-registry';
|
||||
import { UmbArrayState, UmbBooleanState } from '@umbraco-cms/backoffice/observable-api';
|
||||
import { UmbContextBase } from '@umbraco-cms/backoffice/class-api';
|
||||
import { UmbContextToken } from '@umbraco-cms/backoffice/context-api';
|
||||
import { UmbId } from '@umbraco-cms/backoffice/id';
|
||||
import { UMB_PROPERTY_DATASET_CONTEXT } from '@umbraco-cms/backoffice/property';
|
||||
import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api';
|
||||
|
||||
export class UmbTiptapToolbarConfigurationContext extends UmbContextBase<UmbTiptapToolbarConfigurationContext> {
|
||||
#extensions = new UmbArrayState<UmbTiptapToolbarExtension>([], (x) => x.alias);
|
||||
public readonly extensions = this.#extensions.asObservable();
|
||||
|
||||
#reload = new UmbBooleanState(false);
|
||||
public readonly reload = this.#reload.asObservable();
|
||||
|
||||
#extensionsEnabled = new Set<string>();
|
||||
|
||||
#extensionsInUse = new Set<string>();
|
||||
|
||||
#lookup?: Map<string, UmbTiptapToolbarExtension>;
|
||||
|
||||
#toolbar = new UmbArrayState<UmbTiptapToolbarRowViewModel>([], (x) => x.unique);
|
||||
public readonly toolbar = this.#toolbar.asObservable();
|
||||
|
||||
constructor(host: UmbControllerHost) {
|
||||
super(host, UMB_TIPTAP_TOOLBAR_CONFIGURATION_CONTEXT);
|
||||
|
||||
this.observe(umbExtensionsRegistry.byType('tiptapToolbarExtension'), (extensions) => {
|
||||
const _extensions = extensions.map((ext) => ({
|
||||
alias: ext.alias,
|
||||
label: ext.meta.label,
|
||||
icon: ext.meta.icon,
|
||||
dependencies: ext.forExtensions,
|
||||
}));
|
||||
|
||||
this.#extensions.setValue(_extensions);
|
||||
|
||||
this.#lookup = new Map(_extensions.map((ext) => [ext.alias, ext]));
|
||||
});
|
||||
|
||||
this.consumeContext(UMB_PROPERTY_DATASET_CONTEXT, async (dataset) => {
|
||||
this.observe(
|
||||
await dataset.propertyValueByAlias<Array<string>>('extensions'),
|
||||
(extensions) => {
|
||||
if (extensions) {
|
||||
this.#extensionsEnabled.clear();
|
||||
this.#reload.setValue(false);
|
||||
|
||||
this.#extensions
|
||||
.getValue()
|
||||
.filter((x) => !x.dependencies || x.dependencies.every((z) => extensions.includes(z)))
|
||||
.map((x) => x.alias)
|
||||
.forEach((alias) => this.#extensionsEnabled.add(alias));
|
||||
|
||||
this.#reload.setValue(true);
|
||||
}
|
||||
},
|
||||
'_observeExtensions',
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
public filterExtensions(query: string): Array<UmbTiptapToolbarExtension> {
|
||||
return this.#extensions
|
||||
.getValue()
|
||||
.filter((ext) => ext.alias?.toLowerCase().includes(query) || ext.label?.toLowerCase().includes(query));
|
||||
}
|
||||
|
||||
public getExtensionByAlias(alias: string): UmbTiptapToolbarExtension | undefined {
|
||||
return this.#lookup?.get(alias);
|
||||
}
|
||||
|
||||
public isExtensionEnabled(alias: string): boolean {
|
||||
return this.#extensionsEnabled.has(alias);
|
||||
}
|
||||
|
||||
public isExtensionInUse(alias: string): boolean {
|
||||
return this.#extensionsInUse.has(alias);
|
||||
}
|
||||
|
||||
public isValidToolbarValue(value: unknown): value is UmbTiptapToolbarValue {
|
||||
if (!Array.isArray(value)) return false;
|
||||
for (const row of value) {
|
||||
if (!Array.isArray(row)) return false;
|
||||
for (const group of row) {
|
||||
if (!Array.isArray(group)) return false;
|
||||
for (const alias of group) {
|
||||
if (typeof alias !== 'string') return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
public insertToolbarItem(alias: string, to: [number, number, number]) {
|
||||
const toolbar = [...this.#toolbar.getValue()];
|
||||
|
||||
const [rowIndex, groupIndex, itemIndex] = to;
|
||||
|
||||
const row = toolbar[rowIndex];
|
||||
const rowData = [...row.data];
|
||||
const group = rowData[groupIndex];
|
||||
const items = [...group.data];
|
||||
|
||||
items.splice(itemIndex, 0, alias);
|
||||
this.#extensionsInUse.add(alias);
|
||||
|
||||
rowData[groupIndex] = { unique: group.unique, data: items };
|
||||
toolbar[rowIndex] = { unique: row.unique, data: rowData };
|
||||
|
||||
this.#toolbar.setValue(toolbar);
|
||||
}
|
||||
|
||||
public insertToolbarGroup(rowIndex: number, groupIndex: number) {
|
||||
const toolbar = [...this.#toolbar.getValue()];
|
||||
const row = toolbar[rowIndex];
|
||||
const groups = [...row.data];
|
||||
groups.splice(groupIndex, 0, { unique: UmbId.new(), data: [] });
|
||||
toolbar[rowIndex] = { unique: row.unique, data: groups };
|
||||
this.#toolbar.setValue(toolbar);
|
||||
}
|
||||
|
||||
public insertToolbarRow(rowIndex: number) {
|
||||
const toolbar = [...this.#toolbar.getValue()];
|
||||
toolbar.splice(rowIndex, 0, { unique: UmbId.new(), data: [{ unique: UmbId.new(), data: [] }] });
|
||||
this.#toolbar.setValue(toolbar);
|
||||
}
|
||||
|
||||
public moveToolbarItem(from: [number, number, number], to: [number, number, number]) {
|
||||
const [fromRowIndex, fromGroupIndex, fromItemIndex] = from;
|
||||
const [toRowIndex, toGroupIndex, toItemIndex] = to;
|
||||
|
||||
const toolbar = [...this.#toolbar.getValue()];
|
||||
|
||||
const fromRow = toolbar[fromRowIndex];
|
||||
const fromRowData = [...fromRow.data];
|
||||
const fromGroup = fromRowData[fromGroupIndex];
|
||||
const fromItems = [...fromGroup.data];
|
||||
|
||||
const toBeMoved = fromItems.splice(fromItemIndex, 1);
|
||||
|
||||
fromRowData[fromGroupIndex] = { unique: fromGroup.unique, data: fromItems };
|
||||
toolbar[fromRowIndex] = { unique: fromRow.unique, data: fromRowData };
|
||||
|
||||
const toRow = toolbar[toRowIndex];
|
||||
const toRowData = [...toRow.data];
|
||||
const toGroup = toRowData[toGroupIndex];
|
||||
const toItems = [...toGroup.data];
|
||||
|
||||
toItems.splice(toItemIndex, 0, toBeMoved[0]);
|
||||
|
||||
toRowData[toGroupIndex] = { unique: toGroup.unique, data: toItems };
|
||||
toolbar[toRowIndex] = { unique: toRow.unique, data: toRowData };
|
||||
|
||||
this.#toolbar.setValue(toolbar);
|
||||
}
|
||||
|
||||
public removeToolbarItem(from: [number, number, number]) {
|
||||
const [rowIndex, groupIndex, itemIndex] = from;
|
||||
|
||||
const toolbar = [...this.#toolbar.getValue()];
|
||||
const row = toolbar[rowIndex];
|
||||
const rowData = [...row.data];
|
||||
const group = rowData[groupIndex];
|
||||
const items = [...group.data];
|
||||
|
||||
const removed = items.splice(itemIndex, 1);
|
||||
removed.forEach((alias) => this.#extensionsInUse.delete(alias));
|
||||
|
||||
rowData[groupIndex] = { unique: group.unique, data: items };
|
||||
toolbar[rowIndex] = { unique: row.unique, data: rowData };
|
||||
|
||||
this.#toolbar.setValue(toolbar);
|
||||
}
|
||||
|
||||
public removeToolbarGroup(rowIndex: number, groupIndex: number) {
|
||||
const toolbar = [...this.#toolbar.getValue()];
|
||||
|
||||
if (toolbar[rowIndex].data.length > groupIndex) {
|
||||
const row = toolbar[rowIndex];
|
||||
const groups = [...row.data];
|
||||
const removed = groups.splice(groupIndex, 1);
|
||||
removed.forEach((group) => group.data.forEach((alias) => this.#extensionsInUse.delete(alias)));
|
||||
toolbar[rowIndex] = { unique: row.unique, data: groups };
|
||||
}
|
||||
|
||||
// Prevent leaving an empty group
|
||||
if (toolbar[rowIndex].data.length === 0) {
|
||||
toolbar[rowIndex].data[0] = { unique: UmbId.new(), data: [] };
|
||||
}
|
||||
|
||||
this.#toolbar.setValue(toolbar);
|
||||
}
|
||||
|
||||
public removeToolbarRow(rowIndex: number) {
|
||||
const toolbar = [...this.#toolbar.getValue()];
|
||||
|
||||
if (toolbar.length > rowIndex) {
|
||||
const removed = toolbar.splice(rowIndex, 1);
|
||||
removed.forEach((row) =>
|
||||
row.data.forEach((group) => group.data.forEach((alias) => this.#extensionsInUse.delete(alias))),
|
||||
);
|
||||
}
|
||||
|
||||
// Prevent leaving an empty row
|
||||
if (toolbar.length === 0) {
|
||||
toolbar[0] = { unique: UmbId.new(), data: [{ unique: UmbId.new(), data: [] }] };
|
||||
}
|
||||
|
||||
this.#toolbar.setValue(toolbar);
|
||||
}
|
||||
|
||||
public setToolbar(value?: UmbTiptapToolbarValue | null) {
|
||||
if (!this.isValidToolbarValue(value)) {
|
||||
value = [[[]]];
|
||||
}
|
||||
|
||||
this.#extensionsInUse.clear();
|
||||
value.forEach((row) => row.forEach((group) => group.forEach((alias) => this.#extensionsInUse.add(alias))));
|
||||
|
||||
const toolbar = value.map((row) => ({
|
||||
unique: UmbId.new(),
|
||||
data: row.map((group) => ({ unique: UmbId.new(), data: group })),
|
||||
}));
|
||||
|
||||
this.#toolbar.setValue(toolbar);
|
||||
}
|
||||
|
||||
public updateToolbarRow(rowIndex: number, groups: Array<UmbTiptapToolbarGroupViewModel>) {
|
||||
const toolbar = [...this.#toolbar.getValue()];
|
||||
|
||||
const row = toolbar[rowIndex];
|
||||
toolbar[rowIndex] = { unique: row.unique, data: groups };
|
||||
|
||||
this.#toolbar.setValue(toolbar);
|
||||
}
|
||||
}
|
||||
|
||||
export const UMB_TIPTAP_TOOLBAR_CONFIGURATION_CONTEXT = new UmbContextToken<UmbTiptapToolbarConfigurationContext>(
|
||||
'UmbTiptapToolbarConfigurationContext',
|
||||
);
|
||||
@@ -0,0 +1,10 @@
|
||||
export type UmbTiptapToolbarExtension = {
|
||||
alias: string;
|
||||
label: string;
|
||||
icon: string;
|
||||
dependencies?: Array<string>;
|
||||
};
|
||||
|
||||
export type UmbTiptapToolbarSortableViewModel<T> = { unique: string; data: T };
|
||||
export type UmbTiptapToolbarRowViewModel = UmbTiptapToolbarSortableViewModel<Array<UmbTiptapToolbarGroupViewModel>>;
|
||||
export type UmbTiptapToolbarGroupViewModel = UmbTiptapToolbarSortableViewModel<Array<string>>;
|
||||
Reference in New Issue
Block a user