Merge branch 'release/15.0'

This commit is contained in:
Niels Lyngsø
2024-10-16 10:15:06 +02:00
13 changed files with 1074 additions and 678 deletions

View File

@@ -2650,5 +2650,15 @@ export default {
extGroup_interactive: 'Interactive elements',
extGroup_media: 'Embeds and media',
extGroup_structure: 'Content structure',
extGroup_unknown: 'Uncategorized',
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,4 +1,5 @@
import type { UmbTiptapExtensionApi, UmbTiptapToolbarValue } from '../../extensions/types.js';
import type { UmbTiptapExtensionApi } from '../../extensions/types.js';
import type { UmbTiptapToolbarValue } from '../types.js';
import { css, customElement, html, property, state, when } from '@umbraco-cms/backoffice/external/lit';
import { loadManifestApi } from '@umbraco-cms/backoffice/extension-api';
import { umbExtensionsRegistry } from '@umbraco-cms/backoffice/extension-registry';

View File

@@ -1,4 +1,4 @@
import type { UmbTiptapToolbarValue } from '../../extensions/types.js';
import type { UmbTiptapToolbarValue } from '../types.js';
import { css, customElement, html, map, nothing, property, state } from '@umbraco-cms/backoffice/external/lit';
import { umbExtensionsRegistry } from '@umbraco-cms/backoffice/extension-registry';
import { UmbExtensionsElementAndApiInitializer } from '@umbraco-cms/backoffice/extension-api';

View File

@@ -0,0 +1 @@
export type UmbTiptapToolbarValue = Array<Array<Array<string>>>;

View File

@@ -132,298 +132,6 @@ const coreExtensions: Array<ManifestTiptapExtension> = [
];
const toolbarExtensions: Array<ManifestTiptapToolbarExtension> = [
{
type: 'tiptapToolbarExtension',
kind: 'button',
alias: 'Umb.Tiptap.Toolbar.Blockquote',
name: 'Blockquote Tiptap Extension',
api: () => import('./toolbar/blockquote.extension.js'),
weight: 995,
meta: {
alias: 'blockquote',
icon: 'icon-blockquote',
label: 'Blockquote',
},
},
{
type: 'tiptapToolbarExtension',
kind: 'button',
alias: 'Umb.Tiptap.Toolbar.Bold',
name: 'Bold Tiptap Extension',
api: () => import('./toolbar/bold.extension.js'),
weight: 999,
meta: {
alias: 'bold',
icon: 'icon-bold',
label: 'Bold',
},
},
{
type: 'tiptapToolbarExtension',
kind: 'button',
alias: 'Umb.Tiptap.Toolbar.CodeBlock',
name: 'Code Block Tiptap Extension',
api: () => import('./toolbar/code-block.extension.js'),
weight: 994,
meta: {
alias: 'codeBlock',
icon: 'icon-code',
label: 'Code Block',
},
},
{
type: 'tiptapToolbarExtension',
kind: 'button',
alias: 'Umb.Tiptap.Toolbar.BulletList',
name: 'Bullet List Tiptap Extension',
api: () => import('./toolbar/bullet-list.extension.js'),
weight: 993,
meta: {
alias: 'bulletList',
icon: 'icon-bulleted-list',
label: 'Bullet List',
},
},
{
type: 'tiptapToolbarExtension',
kind: 'button',
alias: 'Umb.Tiptap.Toolbar.OrderedList',
name: 'Ordered List Tiptap Extension',
api: () => import('./toolbar/ordered-list.extension.js'),
weight: 992,
meta: {
alias: 'orderedList',
icon: 'icon-ordered-list',
label: 'Ordered List',
},
},
{
type: 'tiptapToolbarExtension',
kind: 'button',
alias: 'Umb.Tiptap.Toolbar.Redo',
name: 'Redo Tiptap Extension',
api: () => import('./toolbar/redo.extension.js'),
element: () => import('../components/toolbar/tiptap-toolbar-button-disabled.element.js'),
weight: 994,
meta: {
alias: 'redo',
icon: 'icon-redo',
label: 'Redo',
},
},
{
type: 'tiptapToolbarExtension',
kind: 'button',
alias: 'Umb.Tiptap.Toolbar.Strike',
name: 'Strike Tiptap Extension',
api: () => import('./toolbar/strike.extension.js'),
weight: 996,
meta: {
alias: 'strike',
icon: 'icon-strikethrough',
label: 'Strike',
},
},
{
type: 'tiptapToolbarExtension',
kind: 'button',
alias: 'Umb.Tiptap.Toolbar.Subscript',
name: 'Subscript Tiptap Extension',
api: () => import('./toolbar/subscript.extension.js'),
weight: 1010,
meta: {
alias: 'subscript',
icon: 'icon-subscript',
label: 'Subscript',
},
},
{
type: 'tiptapToolbarExtension',
kind: 'button',
alias: 'Umb.Tiptap.Toolbar.Superscript',
name: 'Superscript Tiptap Extension',
api: () => import('./toolbar/superscript.extension.js'),
weight: 1011,
meta: {
alias: 'superscript',
icon: 'icon-superscript',
label: 'Superscript',
},
},
{
type: 'tiptapToolbarExtension',
kind: 'button',
alias: 'Umb.Tiptap.Toolbar.Table',
name: 'Table Tiptap Extension',
api: () => import('./toolbar/table.extension.js'),
weight: 909,
meta: {
alias: 'table',
icon: 'icon-table',
label: 'Table',
},
},
{
type: 'tiptapToolbarExtension',
kind: 'button',
alias: 'Umb.Tiptap.Toolbar.Heading1',
name: 'Heading 1 Tiptap Extension',
api: () => import('./toolbar/heading1.extension.js'),
weight: 949,
meta: {
alias: 'heading1',
icon: 'icon-heading-1',
label: 'Heading 1',
},
},
{
type: 'tiptapToolbarExtension',
kind: 'button',
alias: 'Umb.Tiptap.Toolbar.Heading2',
name: 'Heading 2 Tiptap Extension',
api: () => import('./toolbar/heading2.extension.js'),
weight: 948,
meta: {
alias: 'heading2',
icon: 'icon-heading-2',
label: 'Heading 2',
},
},
{
type: 'tiptapToolbarExtension',
kind: 'button',
alias: 'Umb.Tiptap.Toolbar.Heading3',
name: 'Heading 3 Tiptap Extension',
api: () => import('./toolbar/heading3.extension.js'),
weight: 947,
meta: {
alias: 'heading3',
icon: 'icon-heading-3',
label: 'Heading 3',
},
},
{
type: 'tiptapToolbarExtension',
kind: 'button',
alias: 'Umb.Tiptap.Toolbar.HorizontalRule',
name: 'Horizontal Rule Tiptap Extension',
api: () => import('./toolbar/horizontal-rule.extension.js'),
weight: 991,
meta: {
alias: 'horizontalRule',
icon: 'icon-horizontal-rule',
label: 'Horizontal Rule',
},
},
{
type: 'tiptapToolbarExtension',
kind: 'button',
alias: 'Umb.Tiptap.Toolbar.Italic',
name: 'Italic Tiptap Extension',
api: () => import('./toolbar/italic.extension.js'),
weight: 998,
meta: {
alias: 'italic',
icon: 'icon-italic',
label: 'Italic',
},
},
{
type: 'tiptapToolbarExtension',
kind: 'button',
alias: 'Umb.Tiptap.Toolbar.TextAlignCenter',
name: 'Text Align Center Tiptap Extension',
api: () => import('./toolbar/text-align-center.extension.js'),
weight: 918,
meta: {
alias: 'text-align-center',
icon: 'icon-text-align-center',
label: 'Text Align Center',
},
},
{
type: 'tiptapToolbarExtension',
kind: 'button',
alias: 'Umb.Tiptap.Toolbar.TextAlignJustify',
name: 'Text Align Justify Tiptap Extension',
api: () => import('./toolbar/text-align-justify.extension.js'),
weight: 916,
meta: {
alias: 'text-align-justify',
icon: 'icon-text-align-justify',
label: 'Text Align Justify',
},
},
{
type: 'tiptapToolbarExtension',
kind: 'button',
alias: 'Umb.Tiptap.Toolbar.TextAlignLeft',
name: 'Text Align Left Tiptap Extension',
api: () => import('./toolbar/text-align-left.extension.js'),
weight: 919,
meta: {
alias: 'text-align-left',
icon: 'icon-text-align-left',
label: 'Text Align Left',
},
},
{
type: 'tiptapToolbarExtension',
kind: 'button',
alias: 'Umb.Tiptap.Toolbar.TextAlignRight',
name: 'Text Align Right Tiptap Extension',
api: () => import('./toolbar/text-align-right.extension.js'),
weight: 917,
meta: {
alias: 'text-align-right',
icon: 'icon-text-align-right',
label: 'Text Align Right',
},
},
{
type: 'tiptapToolbarExtension',
kind: 'button',
alias: 'Umb.Tiptap.Toolbar.Underline',
name: 'Underline Tiptap Extension',
api: () => import('./toolbar/underline.extension.js'),
weight: 997,
meta: {
alias: 'underline',
icon: 'icon-underline',
label: 'Underline',
},
},
{
type: 'tiptapToolbarExtension',
kind: 'button',
alias: 'Umb.Tiptap.Toolbar.Undo',
name: 'Undo Tiptap Extension',
api: () => import('./toolbar/undo.extension.js'),
element: () => import('../components/toolbar/tiptap-toolbar-button-disabled.element.js'),
weight: 994,
meta: {
alias: 'undo',
icon: 'icon-undo',
label: 'Undo',
},
},
{
type: 'tiptapToolbarExtension',
kind: 'button',
alias: 'Umb.Tiptap.Toolbar.Unlink',
name: 'Unlink Tiptap Extension',
api: () => import('./toolbar/unlink.extension.js'),
element: () => import('../components/toolbar/tiptap-toolbar-button-disabled.element.js'),
weight: 101,
meta: {
alias: 'unlink',
icon: 'icon-unlink',
label: 'Unlink',
},
},
];
const umbToolbarExtensions: Array<ManifestTiptapToolbarExtension> = [
{
type: 'tiptapToolbarExtension',
kind: 'button',
@@ -436,24 +144,289 @@ const umbToolbarExtensions: Array<ManifestTiptapToolbarExtension> = [
label: '#general_viewSourceCode',
},
},
{
type: 'tiptapToolbarExtension',
kind: 'button',
alias: 'Umb.Tiptap.Toolbar.Bold',
name: 'Bold Tiptap Extension',
api: () => import('./toolbar/bold.extension.js'),
meta: {
alias: 'bold',
icon: 'icon-bold',
label: 'Bold',
},
},
{
type: 'tiptapToolbarExtension',
kind: 'button',
alias: 'Umb.Tiptap.Toolbar.Italic',
name: 'Italic Tiptap Extension',
api: () => import('./toolbar/italic.extension.js'),
meta: {
alias: 'italic',
icon: 'icon-italic',
label: 'Italic',
},
},
{
type: 'tiptapToolbarExtension',
kind: 'button',
alias: 'Umb.Tiptap.Toolbar.Underline',
name: 'Underline Tiptap Extension',
api: () => import('./toolbar/underline.extension.js'),
forExtensions: ['Umb.Tiptap.Underline'],
meta: {
alias: 'underline',
icon: 'icon-underline',
label: 'Underline',
},
},
{
type: 'tiptapToolbarExtension',
kind: 'button',
alias: 'Umb.Tiptap.Toolbar.Strike',
name: 'Strike Tiptap Extension',
api: () => import('./toolbar/strike.extension.js'),
meta: {
alias: 'strike',
icon: 'icon-strikethrough',
label: 'Strike',
},
},
{
type: 'tiptapToolbarExtension',
kind: 'button',
alias: 'Umb.Tiptap.Toolbar.TextAlignLeft',
name: 'Text Align Left Tiptap Extension',
api: () => import('./toolbar/text-align-left.extension.js'),
forExtensions: ['Umb.Tiptap.TextAlign'],
meta: {
alias: 'text-align-left',
icon: 'icon-text-align-left',
label: 'Text Align Left',
},
},
{
type: 'tiptapToolbarExtension',
kind: 'button',
alias: 'Umb.Tiptap.Toolbar.TextAlignCenter',
name: 'Text Align Center Tiptap Extension',
api: () => import('./toolbar/text-align-center.extension.js'),
forExtensions: ['Umb.Tiptap.TextAlign'],
meta: {
alias: 'text-align-center',
icon: 'icon-text-align-center',
label: 'Text Align Center',
},
},
{
type: 'tiptapToolbarExtension',
kind: 'button',
alias: 'Umb.Tiptap.Toolbar.TextAlignRight',
name: 'Text Align Right Tiptap Extension',
api: () => import('./toolbar/text-align-right.extension.js'),
forExtensions: ['Umb.Tiptap.TextAlign'],
meta: {
alias: 'text-align-right',
icon: 'icon-text-align-right',
label: 'Text Align Right',
},
},
{
type: 'tiptapToolbarExtension',
kind: 'button',
alias: 'Umb.Tiptap.Toolbar.TextAlignJustify',
name: 'Text Align Justify Tiptap Extension',
api: () => import('./toolbar/text-align-justify.extension.js'),
forExtensions: ['Umb.Tiptap.TextAlign'],
meta: {
alias: 'text-align-justify',
icon: 'icon-text-align-justify',
label: 'Text Align Justify',
},
},
{
type: 'tiptapToolbarExtension',
kind: 'button',
alias: 'Umb.Tiptap.Toolbar.Heading1',
name: 'Heading 1 Tiptap Extension',
api: () => import('./toolbar/heading1.extension.js'),
meta: {
alias: 'heading1',
icon: 'icon-heading-1',
label: 'Heading 1',
},
},
{
type: 'tiptapToolbarExtension',
kind: 'button',
alias: 'Umb.Tiptap.Toolbar.Heading2',
name: 'Heading 2 Tiptap Extension',
api: () => import('./toolbar/heading2.extension.js'),
meta: {
alias: 'heading2',
icon: 'icon-heading-2',
label: 'Heading 2',
},
},
{
type: 'tiptapToolbarExtension',
kind: 'button',
alias: 'Umb.Tiptap.Toolbar.Heading3',
name: 'Heading 3 Tiptap Extension',
api: () => import('./toolbar/heading3.extension.js'),
meta: {
alias: 'heading3',
icon: 'icon-heading-3',
label: 'Heading 3',
},
},
{
type: 'tiptapToolbarExtension',
kind: 'button',
alias: 'Umb.Tiptap.Toolbar.BulletList',
name: 'Bullet List Tiptap Extension',
api: () => import('./toolbar/bullet-list.extension.js'),
meta: {
alias: 'bulletList',
icon: 'icon-bulleted-list',
label: 'Bullet List',
},
},
{
type: 'tiptapToolbarExtension',
kind: 'button',
alias: 'Umb.Tiptap.Toolbar.OrderedList',
name: 'Ordered List Tiptap Extension',
api: () => import('./toolbar/ordered-list.extension.js'),
meta: {
alias: 'orderedList',
icon: 'icon-ordered-list',
label: 'Ordered List',
},
},
{
type: 'tiptapToolbarExtension',
kind: 'button',
alias: 'Umb.Tiptap.Toolbar.Blockquote',
name: 'Blockquote Tiptap Extension',
api: () => import('./toolbar/blockquote.extension.js'),
meta: {
alias: 'blockquote',
icon: 'icon-blockquote',
label: 'Blockquote',
},
},
{
type: 'tiptapToolbarExtension',
kind: 'button',
alias: 'Umb.Tiptap.Toolbar.Link',
name: 'Link Tiptap Extension',
api: () => import('./toolbar/link.extension.js'),
forExtensions: ['Umb.Tiptap.Link'],
meta: {
alias: 'umbLink',
icon: 'icon-link',
label: '#defaultdialogs_urlLinkPicker',
},
},
{
type: 'tiptapToolbarExtension',
kind: 'button',
alias: 'Umb.Tiptap.Toolbar.Unlink',
name: 'Unlink Tiptap Extension',
api: () => import('./toolbar/unlink.extension.js'),
element: () => import('../components/toolbar/tiptap-toolbar-button-disabled.element.js'),
forExtensions: ['Umb.Tiptap.Link'],
meta: {
alias: 'unlink',
icon: 'icon-unlink',
label: 'Unlink',
},
},
{
type: 'tiptapToolbarExtension',
kind: 'button',
alias: 'Umb.Tiptap.Toolbar.CodeBlock',
name: 'Code Block Tiptap Extension',
api: () => import('./toolbar/code-block.extension.js'),
meta: {
alias: 'codeBlock',
icon: 'icon-code',
label: 'Code Block',
},
},
{
type: 'tiptapToolbarExtension',
kind: 'button',
alias: 'Umb.Tiptap.Toolbar.Subscript',
name: 'Subscript Tiptap Extension',
api: () => import('./toolbar/subscript.extension.js'),
forExtensions: ['Umb.Tiptap.Subscript'],
meta: {
alias: 'subscript',
icon: 'icon-subscript',
label: 'Subscript',
},
},
{
type: 'tiptapToolbarExtension',
kind: 'button',
alias: 'Umb.Tiptap.Toolbar.Superscript',
name: 'Superscript Tiptap Extension',
api: () => import('./toolbar/superscript.extension.js'),
forExtensions: ['Umb.Tiptap.Superscript'],
meta: {
alias: 'superscript',
icon: 'icon-superscript',
label: 'Superscript',
},
},
{
type: 'tiptapToolbarExtension',
kind: 'button',
alias: 'Umb.Tiptap.Toolbar.HorizontalRule',
name: 'Horizontal Rule Tiptap Extension',
api: () => import('./toolbar/horizontal-rule.extension.js'),
meta: {
alias: 'horizontalRule',
icon: 'icon-horizontal-rule',
label: 'Horizontal Rule',
},
},
{
type: 'tiptapToolbarExtension',
kind: 'button',
alias: 'Umb.Tiptap.Toolbar.Undo',
name: 'Undo Tiptap Extension',
api: () => import('./toolbar/undo.extension.js'),
element: () => import('../components/toolbar/tiptap-toolbar-button-disabled.element.js'),
meta: {
alias: 'undo',
icon: 'icon-undo',
label: 'Undo',
},
},
{
type: 'tiptapToolbarExtension',
kind: 'button',
alias: 'Umb.Tiptap.Toolbar.Redo',
name: 'Redo Tiptap Extension',
api: () => import('./toolbar/redo.extension.js'),
element: () => import('../components/toolbar/tiptap-toolbar-button-disabled.element.js'),
meta: {
alias: 'redo',
icon: 'icon-redo',
label: 'Redo',
},
},
{
type: 'tiptapToolbarExtension',
kind: 'button',
alias: 'Umb.Tiptap.Toolbar.MediaPicker',
name: 'Media Picker Tiptap Extension',
api: () => import('./toolbar/media-picker.extension.js'),
forExtensions: ['Umb.Tiptap.Figure', 'Umb.Tiptap.Image'],
meta: {
alias: 'umbMedia',
icon: 'icon-picture',
@@ -466,14 +439,28 @@ const umbToolbarExtensions: Array<ManifestTiptapToolbarExtension> = [
alias: 'Umb.Tiptap.Toolbar.EmbeddedMedia',
name: 'Embedded Media Tiptap Extension',
api: () => import('./toolbar/embedded-media.extension.js'),
forExtensions: ['Umb.Tiptap.Embed'],
meta: {
alias: 'umbEmbeddedMedia',
icon: 'icon-embed',
label: '#general_embed',
},
},
{
type: 'tiptapToolbarExtension',
kind: 'button',
alias: 'Umb.Tiptap.Toolbar.Table',
name: 'Table Tiptap Extension',
api: () => import('./toolbar/table.extension.js'),
forExtensions: ['Umb.Tiptap.Table'],
meta: {
alias: 'table',
icon: 'icon-table',
label: 'Table',
},
},
];
const extensions = [...coreExtensions, ...toolbarExtensions, ...umbToolbarExtensions];
const extensions = [...coreExtensions, ...toolbarExtensions];
export const manifests = [...kinds, ...extensions];

View File

@@ -6,6 +6,7 @@ export interface ManifestTiptapToolbarExtension<
MetaType extends MetaTiptapToolbarExtension = MetaTiptapToolbarExtension,
> extends ManifestElementAndApi<UmbControllerHostElement, UmbTiptapToolbarElementApi> {
type: 'tiptapToolbarExtension';
forExtensions?: Array<string>;
meta: MetaType;
}

View File

@@ -98,5 +98,3 @@ export abstract class UmbTiptapToolbarElementApiBase extends UmbControllerBase i
return editor && this.manifest?.meta.alias ? editor?.isActive(this.manifest.meta.alias) : false;
}
}
export type UmbTiptapToolbarValue = Array<Array<Array<string>>>;

View File

@@ -19,7 +19,7 @@ export const manifests: Array<ManifestTiptapExtension | ManifestTiptapToolbarExt
alias: 'Umb.Tiptap.Toolbar.BlockPicker',
name: 'Block Picker Tiptap Extension Button',
api: () => import('./block-picker-toolbar.extension.js'),
weight: 90,
forExtensions: ['Umb.Tiptap.Block'],
meta: {
alias: 'umbblockpicker',
icon: 'icon-plugin',

View File

@@ -1,33 +1,44 @@
import { UmbTextStyles } from '@umbraco-cms/backoffice/style';
import type { PropertyValueMap } from '@umbraco-cms/backoffice/external/lit';
import { customElement, css, html, property, state, repeat } from '@umbraco-cms/backoffice/external/lit';
import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element';
import { umbExtensionsRegistry } from '@umbraco-cms/backoffice/extension-registry';
import {
UmbPropertyValueChangeEvent,
type UmbPropertyEditorConfigCollection,
type UmbPropertyEditorUiElement,
customElement,
css,
html,
ifDefined,
nothing,
property,
state,
repeat,
when,
} from '@umbraco-cms/backoffice/external/lit';
import { umbExtensionsRegistry } from '@umbraco-cms/backoffice/extension-registry';
import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element';
import { UmbPropertyValueChangeEvent } from '@umbraco-cms/backoffice/property-editor';
import type { PropertyValueMap } from '@umbraco-cms/backoffice/external/lit';
import type {
UmbPropertyEditorConfigCollection,
UmbPropertyEditorUiElement,
} from '@umbraco-cms/backoffice/property-editor';
import { UMB_PROPERTY_DATASET_CONTEXT } from '@umbraco-cms/backoffice/property';
type UmbTiptapExtensionConfig = {
type UmbTiptapExtension = {
alias: string;
label: string;
icon?: string;
group: string;
group?: string;
description?: string;
};
type UmbTiptapExtensionGroupItem = {
alias: string;
label: string;
icon?: string;
type UmbTiptapExtensionGroupItem = UmbTiptapExtension & {
selected: boolean;
};
type UmbTiptapExtensionGroup = {
group: string;
extensions: UmbTiptapExtensionGroupItem[];
extensions: Array<UmbTiptapExtensionGroupItem>;
};
const TIPTAP_CORE_EXTENSION_ALIAS = 'Umb.Tiptap.RichTextEssentials';
const TIPTAP_BLOCK_EXTENSION_ALIAS = 'Umb.Tiptap.Block';
const elementName = 'umb-property-editor-ui-tiptap-extensions-configuration';
@customElement(elementName)
@@ -35,167 +46,177 @@ export class UmbPropertyEditorUiTiptapExtensionsConfigurationElement
extends UmbLitElement
implements UmbPropertyEditorUiElement
{
#disabledExtensions = new Set<string>([TIPTAP_CORE_EXTENSION_ALIAS]);
@property({ attribute: false })
value?: Array<string> = [];
value?: Array<string> = [TIPTAP_CORE_EXTENSION_ALIAS];
@property({ attribute: false })
config?: UmbPropertyEditorConfigCollection;
@state()
private _extensionCategories: UmbTiptapExtensionGroup[] = [];
private _extensions: Array<UmbTiptapExtension> = [];
@state()
private _extensionConfigs: UmbTiptapExtensionConfig[] = [];
private _groups: Array<UmbTiptapExtensionGroup> = [];
constructor() {
super();
this.consumeContext(UMB_PROPERTY_DATASET_CONTEXT, async (dataset) => {
this.observe(
await dataset.propertyValueByAlias<Array<unknown>>('blocks'),
(blocks) => {
const tmpValue = this.value ? [...this.value] : [];
// When blocks are configured, the block extension can be enabled;
// otherwise, the block extension must be disabled.
if (blocks?.length) {
// Check if the block extension is already enabled, if not, add it.
if (!tmpValue.includes(TIPTAP_BLOCK_EXTENSION_ALIAS)) {
tmpValue.push(TIPTAP_BLOCK_EXTENSION_ALIAS);
}
this.#disabledExtensions.delete(TIPTAP_BLOCK_EXTENSION_ALIAS);
} else {
// Check if the block extension is enabled, if so, remove it.
const idx = tmpValue.indexOf(TIPTAP_BLOCK_EXTENSION_ALIAS) ?? -1;
if (idx >= 0) {
tmpValue.splice(idx, 1);
}
this.#disabledExtensions.add(TIPTAP_BLOCK_EXTENSION_ALIAS);
}
if (!this.value || !this.#isArrayEqualTo(tmpValue, this.value)) {
this.#setValue(tmpValue);
this.#syncViewModel();
}
},
'_observeBlocks',
);
});
}
protected override async firstUpdated(_changedProperties: PropertyValueMap<unknown>) {
super.firstUpdated(_changedProperties);
this.observe(umbExtensionsRegistry.byType('tiptapExtension'), (extensions) => {
this._extensionConfigs = extensions
this._extensions = extensions
.sort((a, b) => a.alias.localeCompare(b.alias))
.map((ext) => {
return {
alias: ext.alias,
label: ext.meta.label,
icon: ext.meta.icon,
group: ext.meta.group,
};
});
.map((ext) => ({ alias: ext.alias, label: ext.meta.label, icon: ext.meta.icon, group: ext.meta.group }));
// Hardcoded core extension
this._extensions.unshift({
alias: TIPTAP_CORE_EXTENSION_ALIAS,
label: 'Rich Text Essentials',
icon: 'icon-browser-window',
group: '#tiptap_extGroup_formatting',
description: 'This is a core extension, it must be enabled',
});
if (!this.value) {
// The default value is all extensions enabled
this.value = this._extensionConfigs.map((ext) => ext.alias);
this.dispatchEvent(new UmbPropertyValueChangeEvent());
this.#setValue(this._extensions.map((ext) => ext.alias));
}
this.#setupExtensionCategories();
this.#syncViewModel();
});
}
#setupExtensionCategories() {
const useDefault = !this.value; // The default value is all extensions enabled
const withSelectedProperty = this._extensionConfigs.map((extensionConfig) => {
return {
...extensionConfig,
selected: useDefault ? true : this.value!.includes(extensionConfig.alias),
};
});
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-expect-error
const grouped = Object.groupBy(
withSelectedProperty,
(item: UmbTiptapExtensionConfig) => item.group || 'Uncategorized',
);
this._extensionCategories = Object.keys(grouped)
.sort((a, b) => a.localeCompare(b))
.map((key) => ({
group: key,
extensions: grouped[key],
}));
#isArrayEqualTo(a: Array<string>, b: Array<string>) {
return a.length === b.length && a.every((item) => b.includes(item)) && b.every((item) => a.includes(item));
}
#onExtensionClick(item: UmbTiptapExtensionGroupItem) {
#onClick(item: UmbTiptapExtensionGroupItem) {
item.selected = !item.selected;
if (!this.value) {
this.value = [];
}
const tmpValue = item.selected
? [...(this.value ?? []), item.alias]
: (this.value ?? []).filter((alias) => alias !== item.alias);
if (item.selected) {
this.value = [...this.value, item.alias];
} else {
this.value = this.value.filter((alias) => alias !== item.alias);
}
this.#setValue(tmpValue);
}
this.requestUpdate('_extensionCategories');
#setValue(value: Array<string>) {
this.value = value;
this.dispatchEvent(new UmbPropertyValueChangeEvent());
}
#syncViewModel() {
const items: Array<UmbTiptapExtensionGroupItem> = this._extensions.map((extension) => ({
...extension,
selected: this.value!.includes(extension.alias) || extension.alias === TIPTAP_CORE_EXTENSION_ALIAS,
}));
const uncategorizedLabel = this.localize.term('tiptap_extGroup_unknown');
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-expect-error
const grouped = Object.groupBy(items, (item: UmbTiptapExtensionGroupItem) => item.group || uncategorizedLabel);
this._groups = Object.keys(grouped)
.sort((a, b) => a.localeCompare(b))
.map((key) => ({ group: key, extensions: grouped[key] }));
}
override render() {
if (!this._groups.length) return nothing;
return html`
<div class="extensions">
${repeat(
this._extensionCategories,
(group) => html`
<div class="group">
<p class="group-name">${this.localize.string(group.group)}</p>
${repeat(
this._groups,
(group) => html`
<div class="group">
<uui-label>${this.localize.string(group.group)}</uui-label>
<ul>
${repeat(
group.extensions,
(item) => html`
<div class="extension-item">
<uui-button
compact
class=${item.selected ? 'selected' : ''}
<li title=${ifDefined(item.description)}>
<uui-checkbox
label=${this.localize.string(item.label)}
look="outline"
.value=${item.alias}
@click=${() => this.#onExtensionClick(item)}>
<umb-icon name=${item.icon ?? ''}></umb-icon>
</uui-button>
<uui-button
compact
label=${this.localize.string(item.label)}
look="default"
style="--uui-button-content-align:left;"
@click=${() => this.#onExtensionClick(item)}></uui-button>
</div>
value=${item.alias}
?checked=${item.selected}
?disabled=${this.#disabledExtensions.has(item.alias)}
@change=${() => this.#onClick(item)}>
<div class="inner">
${when(item.icon, () => html`<umb-icon .name=${item.icon}></umb-icon>`)}
<span>${this.localize.string(item.label)}</span>
</div>
</uui-checkbox>
</li>
`,
)}
</div>
`,
)}
</div>
</ul>
</div>
`,
)}
`;
}
static override readonly styles = [
UmbTextStyles,
css`
uui-icon {
width: unset;
height: unset;
display: flex;
vertical-align: unset;
}
uui-button.selected {
--uui-button-border-color: var(--uui-color-selected);
--uui-button-border-width: 2px;
}
.extensions {
:host {
display: flex;
flex-wrap: wrap;
gap: 16px;
margin-top: 16px;
}
.extension-item {
display: grid;
grid-template-columns: 36px 1fr;
grid-template-rows: 1fr;
align-items: center;
gap: 9px;
gap: 1rem;
}
.group {
flex: 1;
display: flex;
flex-direction: column;
gap: 6px;
padding: 12px;
background-color: var(--uui-color-surface-alt);
border: 1px solid var(--uui-color-border);
border-radius: 6px;
}
.group-name {
grid-column: 1 / -1;
display: flex;
font-weight: bold;
margin: 0;
ul {
list-style: none;
padding: 0;
margin: 1rem 0 0;
.inner {
display: flex;
flex-direction: row;
gap: 0.5rem;
umb-icon {
font-size: 1.2rem;
}
}
}
}
`,
];

View File

@@ -1,16 +1,16 @@
import type { UmbTiptapToolbarValue } from '../../../extensions/types.js';
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

@@ -13,20 +13,24 @@ export const manifests: Array<ManifestPropertyEditorUi> = [
group: 'richContent',
settings: {
properties: [
{
alias: 'toolbar',
label: 'Toolbar',
description: 'Pick the toolbar items that should be available when editing',
propertyEditorUiAlias: 'Umb.PropertyEditorUi.Tiptap.ToolbarConfiguration',
weight: 5,
},
{
alias: 'extensions',
label: 'Extensions',
description: 'Extensions to enable',
label: 'Capabilities',
description: `Enable extensions enhance the capabilities of the Tiptap editor.
_Once enabled, the extensions will be available in the toolbar._`,
propertyEditorUiAlias: 'Umb.PropertyEditorUi.Tiptap.ExtensionsConfiguration',
weight: 10,
},
{
alias: 'toolbar',
label: 'Toolbar',
description: `Configure the toolbar for the intended editing experience.
_Drag and drop the available items onto the toolbar designer._`,
propertyEditorUiAlias: 'Umb.PropertyEditorUi.Tiptap.ToolbarConfiguration',
weight: 15,
},
{
alias: 'dimensions',
label: 'Dimensions',

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