From bd5b898fcec60c91f328e8248fbe501ed2b72526 Mon Sep 17 00:00:00 2001 From: leekelleher Date: Mon, 14 Oct 2024 13:11:18 +0100 Subject: [PATCH] Tiptap toolbar designer reworking Observes the enabled extensions, updates available items accordingly. Adds a context API and label localizations. --- .../src/assets/lang/en.ts | 9 + ...ui-tiptap-toolbar-configuration.element.ts | 606 +++++++++++------- .../tiptap-toolbar-configuration.context.ts | 247 +++++++ .../tiptap/property-editors/tiptap/types.ts | 10 + 4 files changed, 627 insertions(+), 245 deletions(-) create mode 100644 src/Umbraco.Web.UI.Client/src/packages/tiptap/property-editors/tiptap/contexts/tiptap-toolbar-configuration.context.ts create mode 100644 src/Umbraco.Web.UI.Client/src/packages/tiptap/property-editors/tiptap/types.ts diff --git a/src/Umbraco.Web.UI.Client/src/assets/lang/en.ts b/src/Umbraco.Web.UI.Client/src/assets/lang/en.ts index 18827472af..363c255c74 100644 --- a/src/Umbraco.Web.UI.Client/src/assets/lang/en.ts +++ b/src/Umbraco.Web.UI.Client/src/assets/lang/en.ts @@ -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; diff --git a/src/Umbraco.Web.UI.Client/src/packages/tiptap/property-editors/tiptap/components/property-editor-ui-tiptap-toolbar-configuration.element.ts b/src/Umbraco.Web.UI.Client/src/packages/tiptap/property-editors/tiptap/components/property-editor-ui-tiptap-toolbar-configuration.element.ts index 74203c67a0..4fd205cead 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/tiptap/property-editors/tiptap/components/property-editor-ui-tiptap-toolbar-configuration.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/tiptap/property-editors/tiptap/components/property-editor-ui-tiptap-toolbar-configuration.element.ts @@ -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 = new Set(); + #context = new UmbTiptapToolbarConfigurationContext(this); - #currentDragItem?: { - alias: string; - fromPos?: [number, number, number]; - }; + #currentDragItem?: { alias: string; fromPos?: [number, number, number] }; - #lookup?: Map; + #debouncedFilter = debounce((query: string) => { + this._availableExtensions = this.#context.filterExtensions(query); + }, 250); @state() - private _extensions: Array = []; + private _availableExtensions: Array = []; + + @state() + private _toolbar: Array = []; @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) { - 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))} - this.#addRow(this.#value.length)}> - - Add row - - ${this.#renderExtensions()} - `; + return html`${this.#renderAvailableItems()} ${this.#renderDesigner()}`; } - #renderRow(row: string[][], rowIndex: number) { + #renderAvailableItems() { return html` -
- ${repeat(row, (group, groupIndex) => this.#renderGroup(group, rowIndex, groupIndex))} - this.#addGroup(rowIndex, row.length)}> - - Add group - - this.#removeRow(rowIndex)}> - - -
- `; - } - - #renderGroup(group: string[], rowIndex: number, groupIndex: number) { - return html` -
this.#onDrop(e, [rowIndex, groupIndex, group.length])}> - ${group.map((alias, itemIndex) => this.#renderItem(alias, rowIndex, groupIndex, itemIndex))} - this.#removeGroup(rowIndex, groupIndex)}> - - -
- `; - } - - #renderItem(alias: string, rowIndex: number, groupIndex: number, itemIndex: number) { - const extension = this.#lookup?.get(alias); - if (!extension) return nothing; - return html` -
this.#onDragStart(e, alias, [rowIndex, groupIndex, itemIndex])}> - -
- `; - } - - #renderExtensions() { - return html` -
- ${repeat( - this._extensions.filter((ext) => !this.#inUse.has(ext.alias)), - (extension) => html` -
this.#onDragStart(e, extension.alias)} - @dragend=${this.#onDragEnd}> - + +
+ +
+
+
+
+
+ ${when( + this._availableExtensions.length === 0, + () => + html`There are no toolbar extensions to show`, + () => repeat(this._availableExtensions, (item) => this.#renderAvailableItem(item)), + )} +
+
+ `; + } + + #renderAvailableItem(item: UmbTiptapToolbarExtension) { + const forbidden = !this.#context.isExtensionEnabled(item.alias); + const inUse = this.#context.isExtensionInUse(item.alias); + return html` + this.#onClick(item)} + @dragstart=${(e: DragEvent) => this.#onDragStart(e, item.alias)} + @dragend=${this.#onDragEnd}> +
+ ${when(item.icon, () => html``)} + ${this.localize.string(item.label)} +
+
+ `; + } + + #renderDesigner() { + return html` + +
+ ${repeat( + this._toolbar, + (row) => row.unique, + (row, idx) => this.#renderRow(row, idx), + )} +
+ this.#context.insertToolbarRow(this._toolbar.length)}> +
+ `; + } + + #renderRow(row?: UmbTiptapToolbarRowViewModel, rowIndex = 0) { + if (!row) return nothing; + const hideActionBar = this._toolbar.length === 1; + return html` + this.#context?.insertToolbarRow(rowIndex)}> +
+
+ this.#context?.insertToolbarGroup(rowIndex, 0)}> + ${repeat( + row.data, + (group) => group.unique, + (group, idx) => this.#renderGroup(group, rowIndex, idx), + )} +
+ ${when( + !hideActionBar, + () => html` + + this.#context?.removeToolbarRow(rowIndex)}> + + + `, )}
`; } + #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` +
this.#onDrop(e, [rowIndex, groupIndex, group.data.length - 1])}> +
+ ${when( + group?.data.length === 0, + () => html`Add items`, + () => html`${group!.data.map((alias, idx) => this.#renderItem(alias, rowIndex, groupIndex, idx))}`, + )} +
+ ${when( + !hideActionBar, + () => html` + + this.#context?.removeToolbarGroup(rowIndex, groupIndex)}> + + + + `, + )} +
+ this.#context?.insertToolbarGroup(rowIndex, groupIndex + 1)}> + `; + } + + #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` + this.#context.removeToolbarItem([rowIndex, groupIndex, itemIndex])} + @dragend=${this.#onDragEnd} + @dragstart=${(e: DragEvent) => this.#onDragStart(e, alias, [rowIndex, groupIndex, itemIndex])}> +
+ ${when( + item.icon, + () => html``, + () => html`${this.localize.string(item.label)}`, + )} +
+
+ `; + } + 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; } `, ]; diff --git a/src/Umbraco.Web.UI.Client/src/packages/tiptap/property-editors/tiptap/contexts/tiptap-toolbar-configuration.context.ts b/src/Umbraco.Web.UI.Client/src/packages/tiptap/property-editors/tiptap/contexts/tiptap-toolbar-configuration.context.ts new file mode 100644 index 0000000000..a6d73979c5 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/tiptap/property-editors/tiptap/contexts/tiptap-toolbar-configuration.context.ts @@ -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 { + #extensions = new UmbArrayState([], (x) => x.alias); + public readonly extensions = this.#extensions.asObservable(); + + #reload = new UmbBooleanState(false); + public readonly reload = this.#reload.asObservable(); + + #extensionsEnabled = new Set(); + + #extensionsInUse = new Set(); + + #lookup?: Map; + + #toolbar = new UmbArrayState([], (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>('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 { + 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) { + 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', +); diff --git a/src/Umbraco.Web.UI.Client/src/packages/tiptap/property-editors/tiptap/types.ts b/src/Umbraco.Web.UI.Client/src/packages/tiptap/property-editors/tiptap/types.ts new file mode 100644 index 0000000000..305cd36967 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/tiptap/property-editors/tiptap/types.ts @@ -0,0 +1,10 @@ +export type UmbTiptapToolbarExtension = { + alias: string; + label: string; + icon: string; + dependencies?: Array; +}; + +export type UmbTiptapToolbarSortableViewModel = { unique: string; data: T }; +export type UmbTiptapToolbarRowViewModel = UmbTiptapToolbarSortableViewModel>; +export type UmbTiptapToolbarGroupViewModel = UmbTiptapToolbarSortableViewModel>;