Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feature: TinyMce Custom Configuration #1978

Merged
merged 11 commits into from
Jun 6, 2024
9 changes: 9 additions & 0 deletions public-assets/App_Plugins/tinyMcePlugin.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
export default class UmbTinyMceMockPlugin {
/**
* @param {TinyMcePluginArguments} args
*/
constructor(args) {
// Add your plugin code here
console.log('editor initialized', args)
}
}
12 changes: 12 additions & 0 deletions src/mocks/handlers/manifests.handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
},
},
},
],
},
{
Expand Down
Original file line number Diff line number Diff line change
@@ -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 {
/**
Expand All @@ -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;
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -53,10 +52,7 @@ export class UmbInputTinyMceElement extends UUIFormControlMixin(UmbLitElement, '
@property({ attribute: false })
configuration?: UmbPropertyEditorConfigCollection;

@state()
private _tinyConfig: RawEditorOptions = {};

#plugins: Array<new (args: TinyMcePluginArguments) => UmbTinyMcePluginBase> = [];
#plugins: Array<ClassConstructor<UmbTinyMcePluginBase> | undefined> = [];
#editorRef?: Editor | null = null;
#stylesheetRepository = new UmbStylesheetDetailRepository(this);
#umbStylesheetRuleManager = new UmbStylesheetRuleManager();
Expand Down Expand Up @@ -85,15 +81,31 @@ export class UmbInputTinyMceElement extends UUIFormControlMixin(UmbLitElement, '
return this.#editorRef;
}

protected async firstUpdated(): Promise<void> {
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();
}

Expand All @@ -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<ManifestTinyMcePlugin>) {
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;
Expand Down Expand Up @@ -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<string[]>('stylesheets') ?? [];
Expand Down Expand Up @@ -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,
Expand All @@ -244,27 +241,31 @@ 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 [];
});
this.#editorRef = editors.pop();
}

/**
* 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);
Expand All @@ -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 })),
Expand Down Expand Up @@ -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) {
Expand Down
Loading