diff --git a/src/Umbraco.Web.UI.Client/public-assets/App_Plugins/tinyMcePlugin.js b/src/Umbraco.Web.UI.Client/public-assets/App_Plugins/tinyMcePlugin.js new file mode 100644 index 0000000000..f7a4a549b1 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/public-assets/App_Plugins/tinyMcePlugin.js @@ -0,0 +1,9 @@ +export default class UmbTinyMceMockPlugin { + /** + * @param {TinyMcePluginArguments} args + */ + constructor(args) { + // Add your plugin code here + console.log('editor initialized', args) + } +} diff --git a/src/Umbraco.Web.UI.Client/src/mocks/handlers/manifests.handlers.ts b/src/Umbraco.Web.UI.Client/src/mocks/handlers/manifests.handlers.ts index b8a500f4bb..6233357ed6 100644 --- a/src/Umbraco.Web.UI.Client/src/mocks/handlers/manifests.handlers.ts +++ b/src/Umbraco.Web.UI.Client/src/mocks/handlers/manifests.handlers.ts @@ -45,6 +45,18 @@ const privateManifests: PackageManifestResponse = [ propertyEditorSchema: 'Umbraco.TextBox', }, }, + { + type: 'tinyMcePlugin', + alias: 'My.TinyMcePlugin.Custom', + name: 'My Custom TinyMce Plugin', + js: '/App_Plugins/tinyMcePlugin.js', + meta: { + config: { + plugins: ['wordcount'], + statusbar: true, + }, + }, + }, ], }, { diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/extension-registry/models/tinymce-plugin.model.ts b/src/Umbraco.Web.UI.Client/src/packages/core/extension-registry/models/tinymce-plugin.model.ts index 61a686ca0c..77c997ae44 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/extension-registry/models/tinymce-plugin.model.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/extension-registry/models/tinymce-plugin.model.ts @@ -1,5 +1,6 @@ import type { UmbTinyMcePluginBase } from '@umbraco-cms/backoffice/tiny-mce'; import type { ManifestApi } from '@umbraco-cms/backoffice/extension-api'; +import type { RawEditorOptions } from '@umbraco-cms/backoffice/external/tinymce'; export interface MetaTinyMcePlugin { /** @@ -26,6 +27,20 @@ export interface MetaTinyMcePlugin { */ icon?: string; }>; + + /** + * Sets the default configuration for the TinyMCE editor. This configuration will be used when the editor is initialized. + * + * @see [TinyMCE Configuration](https://www.tiny.cloud/docs/configure/) for more information. + * @optional + * @examples [ + * { + * "plugins": "wordcount", + * "statusbar": true + * } + * ] + */ + config?: RawEditorOptions; } /** diff --git a/src/Umbraco.Web.UI.Client/src/packages/tiny-mce/components/input-tiny-mce/input-tiny-mce.element.ts b/src/Umbraco.Web.UI.Client/src/packages/tiny-mce/components/input-tiny-mce/input-tiny-mce.element.ts index f8458cb1dd..337f244cca 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/tiny-mce/components/input-tiny-mce/input-tiny-mce.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/tiny-mce/components/input-tiny-mce/input-tiny-mce.element.ts @@ -2,12 +2,11 @@ import { availableLanguages } from './input-tiny-mce.languages.js'; import { defaultFallbackConfig } from './input-tiny-mce.defaults.js'; import { pastePreProcessHandler } from './input-tiny-mce.handlers.js'; import { uriAttributeSanitizer } from './input-tiny-mce.sanitizer.js'; -import type { TinyMcePluginArguments, UmbTinyMcePluginBase } from './tiny-mce-plugin.js'; -import { loadManifestApi } from '@umbraco-cms/backoffice/extension-api'; -import { css, customElement, html, property, query, state } from '@umbraco-cms/backoffice/external/lit'; -import { firstValueFrom } from '@umbraco-cms/backoffice/external/rxjs'; -import { getProcessedImageUrl } from '@umbraco-cms/backoffice/utils'; -import { umbExtensionsRegistry } from '@umbraco-cms/backoffice/extension-registry'; +import type { UmbTinyMcePluginBase } from './tiny-mce-plugin.js'; +import { type ClassConstructor, loadManifestApi } from '@umbraco-cms/backoffice/extension-api'; +import { css, customElement, html, property, query } from '@umbraco-cms/backoffice/external/lit'; +import { getProcessedImageUrl, umbDeepMerge } from '@umbraco-cms/backoffice/utils'; +import { type ManifestTinyMcePlugin, umbExtensionsRegistry } from '@umbraco-cms/backoffice/extension-registry'; import { UmbChangeEvent } from '@umbraco-cms/backoffice/event'; import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element'; import { UmbStylesheetDetailRepository, UmbStylesheetRuleManager } from '@umbraco-cms/backoffice/stylesheet'; @@ -53,10 +52,7 @@ export class UmbInputTinyMceElement extends UUIFormControlMixin(UmbLitElement, ' @property({ attribute: false }) configuration?: UmbPropertyEditorConfigCollection; - @state() - private _tinyConfig: RawEditorOptions = {}; - - #plugins: Array UmbTinyMcePluginBase> = []; + #plugins: Array | undefined> = []; #editorRef?: Editor | null = null; #stylesheetRepository = new UmbStylesheetDetailRepository(this); #umbStylesheetRuleManager = new UmbStylesheetRuleManager(); @@ -85,15 +81,31 @@ export class UmbInputTinyMceElement extends UUIFormControlMixin(UmbLitElement, ' return this.#editorRef; } - protected async firstUpdated(): Promise { - await Promise.all([...(await this.#loadPlugins())]); - await this.#setTinyConfig(); + constructor() { + super(); + + this.#loadEditor(); + } + + async #loadEditor() { + this.observe(umbExtensionsRegistry.byType('tinyMcePlugin'), async (manifests) => { + this.#plugins.length = 0; + this.#plugins = await this.#loadPlugins(manifests); + + let config: RawEditorOptions = {}; + manifests.forEach((manifest) => { + if (manifest.meta?.config) { + config = umbDeepMerge(manifest.meta.config, config); + } + }); + + this.#setTinyConfig(config); + }); } disconnectedCallback() { super.disconnectedCallback(); - // TODO: Test if there is any problems with destroying the RTE here, but not initializing on connectedCallback. (firstUpdated is only called first time the element is rendered, not when it is reconnected) this.#editorRef?.destroy(); } @@ -103,29 +115,14 @@ export class UmbInputTinyMceElement extends UUIFormControlMixin(UmbLitElement, ' * setup method, the asynchronous nature means the editor is loaded before * the plugins are ready and so are not associated with the editor. */ - async #loadPlugins() { - const observable = umbExtensionsRegistry?.byType('tinyMcePlugin'); - const manifests = await firstValueFrom(observable); - + async #loadPlugins(manifests: Array) { const promises = []; for (const manifest of manifests) { if (manifest.js) { - promises.push( - loadManifestApi(manifest.js).then((plugin) => { - if (plugin) { - this.#plugins.push(plugin); - } - }), - ); + promises.push(await loadManifestApi(manifest.js)); } if (manifest.api) { - promises.push( - loadManifestApi(manifest.api).then((plugin) => { - if (plugin) { - this.#plugins.push(plugin); - } - }), - ); + promises.push(await loadManifestApi(manifest.api)); } } return promises; @@ -181,7 +178,7 @@ export class UmbInputTinyMceElement extends UUIFormControlMixin(UmbLitElement, ' return formatStyles; } - async #setTinyConfig() { + async #setTinyConfig(additionalConfig?: RawEditorOptions) { const dimensions = this.configuration?.getValueByAlias<{ width?: number; height?: number }>('dimensions'); const stylesheetPaths = this.configuration?.getValueByAlias('stylesheets') ?? []; @@ -230,7 +227,7 @@ export class UmbInputTinyMceElement extends UUIFormControlMixin(UmbLitElement, ' } // set the default values that will not be modified via configuration - this._tinyConfig = { + let config: RawEditorOptions = { autoresize_bottom_margin: 10, body_class: 'umb-rte', contextMenu: false, @@ -244,18 +241,21 @@ export class UmbInputTinyMceElement extends UUIFormControlMixin(UmbLitElement, ' setup: (editor) => this.#editorSetup(editor), target: this._editorElement, paste_data_images: false, + language: this.#getLanguage(), + promotion: false, // Extend with configuration options ...configurationOptions, }; - this.#setLanguage(); - - if (this.#editorRef) { - this.#editorRef.destroy(); + // Extend with additional configuration options + if (additionalConfig) { + config = umbDeepMerge(additionalConfig, config); } - const editors = await renderEditor(this._tinyConfig).catch((error) => { + this.#editorRef?.destroy(); + + const editors = await renderEditor(config).catch((error) => { console.error('Failed to render TinyMCE', error); return []; }); @@ -263,8 +263,9 @@ export class UmbInputTinyMceElement extends UUIFormControlMixin(UmbLitElement, ' } /** - * Sets the language to use for TinyMCE */ - #setLanguage() { + * Gets the language to use for TinyMCE + **/ + #getLanguage() { const localeId = this.localize.lang(); //try matching the language using full locale format let languageMatch = availableLanguages.find((x) => localeId?.localeCompare(x) === 0); @@ -277,23 +278,12 @@ export class UmbInputTinyMceElement extends UUIFormControlMixin(UmbLitElement, ' } } - // only set if language exists, will fall back to tiny default - if (languageMatch) { - this._tinyConfig.language = languageMatch; - } + return languageMatch; } #editorSetup(editor: Editor) { editor.suffix = '.min'; - // instantiate plugins - these are already loaded in this.#loadPlugins - // to ensure they are available before setting up the editor. - // Plugins require a reference to the current editor as a param, so can not - // be instantiated until we have an editor - for (const plugin of this.#plugins) { - new plugin({ host: this, editor }); - } - // define keyboard shortcuts editor.addShortcut('Ctrl+S', '', () => this.dispatchEvent(new CustomEvent('rte.shortcut.save', { composed: true, bubbles: true })), @@ -336,13 +326,24 @@ export class UmbInputTinyMceElement extends UUIFormControlMixin(UmbLitElement, ' } }); }); - editor.on('init', () => editor.setContent(this.value?.toString() ?? '')); + + // instantiate plugins to ensure they are available before setting up the editor. + // Plugins require a reference to the current editor as a param, so can not + // be instantiated until we have an editor + for (const plugin of this.#plugins) { + if (plugin) { + // [v15]: This might be improved by changing to `createExtensionApi` and avoiding the `#loadPlugins` method altogether, but that would require a breaking change + // because that function sends the UmbControllerHost as the first argument, which is not the case here. + new plugin({ host: this, editor }); + } + } } #onInit(editor: Editor) { //enable browser based spell checking editor.getBody().setAttribute('spellcheck', 'true'); uriAttributeSanitizer(editor); + editor.setContent(this.value?.toString() ?? ''); } #onChange(value: string) {