Tiptap toolbar designer reworking

Observes the enabled extensions,
updates available items accordingly.

Adds a context API and label localizations.
This commit is contained in:
leekelleher
2024-10-14 13:11:18 +01:00
committed by Jacob Overgaard
parent 0b7c50730e
commit bd5b898fce
4 changed files with 627 additions and 245 deletions

View File

@@ -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;

View File

@@ -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;
}
`,
];

View File

@@ -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',
);

View File

@@ -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>>;