revamp loose binding between tinymce raweditoroptions and our data type configuration by checking if configuration options exist first

This commit is contained in:
Jacob Overgaard
2023-12-19 15:49:01 +01:00
parent de08bae418
commit 1b9f3a9078
4 changed files with 133 additions and 150 deletions

View File

@@ -1,35 +1,15 @@
import type { RawEditorOptions } from '@umbraco-cms/backoffice/external/tinymce';
export type TinyStyleSheet = RawEditorOptions['style_formats'];
export const defaultStyleFormats: TinyStyleSheet = [
{
title: 'Headers',
items: [
{ title: 'Page header', block: 'h2' },
{ title: 'Section header', block: 'h3' },
{ title: 'Paragraph header', block: 'h4' },
],
},
{
title: 'Blocks',
items: [{ title: 'Normal', block: 'p' }],
},
{
title: 'Containers',
items: [
{ title: 'Quote', block: 'blockquote' },
{ title: 'Code', block: 'code' },
],
},
];
//These are absolutely required in order for the macros to render inline
//we put these as extended elements because they get merged on top of the normal allowed elements by tiny mce
export const defaultExtendedValidElements =
'@[id|class|style],-div[id|dir|class|align|style],ins[datetime|cite],-ul[class|style],-li[class|style],-h1[id|dir|class|align|style],-h2[id|dir|class|align|style],-h3[id|dir|class|align|style],-h4[id|dir|class|align|style],-h5[id|dir|class|align|style],-h6[id|style|dir|class|align],span[id|class|style|lang],figure,figcaption';
//so we don't have to specify all the normal elements again
export const defaultFallbackConfig: RawEditorOptions = {
plugins: ['anchor', 'charmap', 'table', 'lists', 'advlist', 'autolink', 'directionality', 'searchreplace'],
valid_elements:
'+a[id|style|rel|data-id|data-udi|rev|charset|hreflang|dir|lang|tabindex|accesskey|type|name|href|target|title|class|onfocus|onblur|onclick|ondblclick|onmousedown|onmouseup|onmouseover|onmousemove|onmouseout|onkeypress|onkeydown|onkeyup],-strong/-b[class|style],-em/-i[class|style],-strike[class|style],-s[class|style],-u[class|style],#p[id|style|dir|class|align],-ol[class|reversed|start|style|type],-ul[class|style],-li[class|style],br[class],img[id|dir|lang|longdesc|usemap|style|class|src|onmouseover|onmouseout|border|alt=|title|hspace|vspace|width|height|align|umbracoorgwidth|umbracoorgheight|onresize|onresizestart|onresizeend|rel|data-id],-sub[style|class],-sup[style|class],-blockquote[dir|style|class],-table[border=0|cellspacing|cellpadding|width|height|class|align|summary|style|dir|id|lang|bgcolor|background|bordercolor],-tr[id|lang|dir|class|rowspan|width|height|align|valign|style|bgcolor|background|bordercolor],tbody[id|class],thead[id|class],tfoot[id|class],#td[id|lang|dir|class|colspan|rowspan|width|height|align|valign|style|bgcolor|background|bordercolor|scope],-th[id|lang|dir|class|colspan|rowspan|width|height|align|valign|style|scope],caption[id|lang|dir|class|style],-div[id|dir|class|align|style],-span[class|align|style],-pre[class|align|style],address[class|align|style],-h1[id|dir|class|align|style],-h2[id|dir|class|align|style],-h3[id|dir|class|align|style],-h4[id|dir|class|align|style],-h5[id|dir|class|align|style],-h6[id|style|dir|class|align|style],hr[class|style],small[class|style],dd[id|class|title|style|dir|lang],dl[id|class|title|style|dir|lang],dt[id|class|title|style|dir|lang],object[class|id|width|height|codebase|*],param[name|value|_value|class],embed[type|width|height|src|class|*],map[name|class],area[shape|coords|href|alt|target|class],bdo[class],button[class],iframe[*],figure,figcaption,cite,video[*],audio[*],picture[*],source[*],canvas[*]',
invalid_elements: 'font',
extended_valid_elements:
'@[id|class|style],-div[id|dir|class|align|style],ins[datetime|cite],-ul[class|style],-li[class|style],-h1[id|dir|class|align|style],-h2[id|dir|class|align|style],-h3[id|dir|class|align|style],-h4[id|dir|class|align|style],-h5[id|dir|class|align|style],-h6[id|style|dir|class|align],span[id|class|style|lang],figure,figcaption',
toolbar: [
'styles',
'bold',
@@ -46,7 +26,30 @@ export const defaultFallbackConfig: RawEditorOptions = {
'umbmacro',
'umbembeddialog',
],
mode: 'classic',
stylesheets: [],
style_formats: [
{
title: 'Headers',
items: [
{ title: 'Page header', block: 'h2' },
{ title: 'Section header', block: 'h3' },
{ title: 'Paragraph header', block: 'h4' },
],
},
{
title: 'Blocks',
items: [{ title: 'Normal', block: 'p' }],
},
{
title: 'Containers',
items: [
{ title: 'Quote', block: 'blockquote' },
{ title: 'Code', block: 'code' },
],
},
],
/**
* @description The maximum image size in pixels that can be inserted into the editor.
* @remarks This is registered and used by the UmbMediaPicker plugin
*/
maxImageSize: 500,
};

View File

@@ -1,15 +1,10 @@
import { defaultExtendedValidElements, defaultFallbackConfig, defaultStyleFormats } from './input-tiny-mce.defaults.js';
import { pastePreProcessHandler, uploadImageHandler } from './input-tiny-mce.handlers.js';
import { defaultFallbackConfig } from './input-tiny-mce.defaults.js';
import { pastePreProcessHandler } from './input-tiny-mce.handlers.js';
import { availableLanguages } from './input-tiny-mce.languages.js';
import { uriAttributeSanitizer } from './input-tiny-mce.sanitizer.js';
import { umbMeta } from '@umbraco-cms/backoffice/meta';
import { FormControlMixin } from '@umbraco-cms/backoffice/external/uui';
import {
type Editor,
type EditorEvent,
type RawEditorOptions,
renderEditor,
} from '@umbraco-cms/backoffice/external/tinymce';
import { type Editor, type RawEditorOptions, renderEditor } from '@umbraco-cms/backoffice/external/tinymce';
import { UMB_CURRENT_USER_CONTEXT, UmbCurrentUser } from '@umbraco-cms/backoffice/current-user';
import { TinyMcePluginArguments, UmbTinyMcePluginBase } from '@umbraco-cms/backoffice/components';
import { loadManifestApi } from '@umbraco-cms/backoffice/extension-api';
@@ -158,23 +153,47 @@ export class UmbInputTinyMceElement extends FormControlMixin(UmbLitElement) {
}
async #setTinyConfig() {
// create an object by merging the configuration onto the fallback config
// TODO: Seems like a too tight coupling between DataTypeConfigCollection and TinyMceConfig, I would love it begin more explicit what we take from DataTypeConfigCollection and parse on, but I understand that this gives some flexibility. Is this flexibility on purpose?
const configurationOptions: Record<string, any> = {
...defaultFallbackConfig,
...(this.configuration ? this.configuration?.toObject() : {}),
};
const dimensions = this.configuration?.getValueByAlias<{ width?: number; height?: number }>('dimensions');
// Map the stylesheets with server url
const stylesheets = configurationOptions.stylesheets.map(
(stylesheetPath: string) => `${this.#serverUrl}/css/${stylesheetPath.replace(/\\/g, '/')}`,
);
const styleFormats = await this.getFormatStyles(configurationOptions.stylesheets);
const stylesheets =
this.configuration
?.getValueByAlias<string[]>('stylesheets')
?.map((stylesheetPath: string) => `${this.#serverUrl}/css/${stylesheetPath.replace(/\\/g, '/')}`) ?? [];
const styleFormats = await this.getFormatStyles(stylesheets);
// create an object by merging the configuration onto the fallback config
const configurationOptions: RawEditorOptions = {
...defaultFallbackConfig,
height: dimensions?.height,
width: dimensions?.width,
content_css: stylesheets,
style_formats: styleFormats,
};
// no auto resize when a fixed height is set
if (!configurationOptions.dimensions?.height) {
configurationOptions.plugins ??= [];
configurationOptions.plugins.splice(configurationOptions.plugins.indexOf('autoresize'), 1);
if (!configurationOptions.height) {
if (Array.isArray(configurationOptions.plugins) && configurationOptions.plugins.includes('autoresize')) {
configurationOptions.plugins.splice(configurationOptions.plugins.indexOf('autoresize'), 1);
}
}
// set the configured toolbar if any
const toolbar = this.configuration?.getValueByAlias<string[]>('toolbar');
if (toolbar) {
configurationOptions.toolbar = toolbar.join(' ');
}
// set the configured inline mode
const mode = this.configuration?.getValueByAlias<string>('mode');
if (mode?.toLocaleLowerCase() === 'inline') {
configurationOptions.inline = true;
}
// set the maximum image size
const maxImageSize = this.configuration?.getValueByAlias<number>('maxImageSize');
if (maxImageSize !== undefined) {
configurationOptions.maxImageSize = maxImageSize;
}
// set the default values that will not be modified via configuration
@@ -193,36 +212,12 @@ export class UmbInputTinyMceElement extends FormControlMixin(UmbLitElement) {
statusbar: false,
setup: (editor) => this.#editorSetup(editor),
target: this._editorElement,
};
paste_data_images: false,
// extend with configuration values
this._tinyConfig = {
...this._tinyConfig,
content_css: stylesheets,
style_formats: styleFormats || defaultStyleFormats,
extended_valid_elements: defaultExtendedValidElements,
height: configurationOptions.height ?? 500,
invalid_elements: configurationOptions.invalidElements,
plugins: configurationOptions.plugins.map((x: any) => x.name),
toolbar: configurationOptions.toolbar.join(' '),
valid_elements: configurationOptions.validElements,
width: configurationOptions.width,
// Extend with configuration options
...configurationOptions,
};
// Need to check if we are allowed to UPLOAD images
// This is done by checking if the insert image toolbar button is available
if (this.#isMediaPickerEnabled()) {
this._tinyConfig = {
...this._tinyConfig,
// Update the TinyMCE Config object to allow pasting
images_upload_handler: uploadImageHandler,
automatic_uploads: false,
images_replace_blob_uris: false,
// This allows images to be pasted in & stored as Base64 until they get uploaded to server
paste_data_images: true,
};
}
this.#setLanguage();
if (this.#editorRef) {
@@ -282,7 +277,6 @@ export class UmbInputTinyMceElement extends FormControlMixin(UmbLitElement) {
editor.on('Change', () => this.#onChange(editor.getContent()));
editor.on('Dirty', () => this.#onChange(editor.getContent()));
editor.on('Keyup', () => this.#onChange(editor.getContent()));
editor.on('SetContent', () => this.#mediaHelper.uploadBlobImages(editor));
editor.on('focus', () => this.dispatchEvent(new CustomEvent('umb-rte-focus', { composed: true, bubbles: true })));
@@ -297,22 +291,6 @@ export class UmbInputTinyMceElement extends FormControlMixin(UmbLitElement) {
});
editor.on('init', () => editor.setContent(this.value?.toString() ?? ''));
// If we can not find the insert image/media toolbar button
// Then we need to add an event listener to the editor
// That will update native browser drag & drop events
// To update the icon to show you can NOT drop something into the editor
if (this._tinyConfig.toolbar && !this.#isMediaPickerEnabled()) {
// Wire up the event listener
editor.on('dragstart dragend dragover draggesture dragdrop drop drag', (e: EditorEvent<InputEvent>) => {
e.preventDefault();
if (e.dataTransfer) {
e.dataTransfer.effectAllowed = 'none';
e.dataTransfer.dropEffect = 'none';
}
e.stopPropagation();
});
}
}
#onInit(editor: Editor) {
@@ -326,21 +304,9 @@ export class UmbInputTinyMceElement extends FormControlMixin(UmbLitElement) {
this.dispatchEvent(new CustomEvent('change'));
}
#isMediaPickerEnabled() {
const toolbar = this._tinyConfig.toolbar;
if (Array.isArray(toolbar) && (toolbar as string[]).includes('umbmediapicker')) {
return true;
} else if (typeof toolbar === 'string' && toolbar.includes('umbmediapicker')) {
return true;
}
return false;
}
/**
* Nothing rendered by default - TinyMCE initialisation creates
* a target div and binds the RTE to that element
* @returns
*/
render() {
return html`<div id="editor"></div>`;

View File

@@ -8,47 +8,3 @@ export const pastePreProcessHandler: RawEditorOptions['paste_preprocess'] = (_ed
// convert i to em
args.content = args.content.replace(/<\s*i([^>]*)>(.*?)<\s*\/\s*i([^>]*)>/g, '<em$1>$2</em$3>');
};
export const uploadImageHandler: RawEditorOptions['images_upload_handler'] = (blobInfo, progress) => {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open('POST', window.Umbraco?.Sys.ServerVariables.umbracoUrls.tinyMceApiBaseUrl + 'UploadImage');
xhr.onloadstart = () =>
document.dispatchEvent(new CustomEvent('rte.file.uploading', { composed: true, bubbles: true }));
xhr.onloadend = () =>
document.dispatchEvent(new CustomEvent('rte.file.uploaded', { composed: true, bubbles: true }));
xhr.upload.onprogress = (e) => progress((e.loaded / e.total) * 100);
xhr.onerror = () => reject('Image upload failed due to a XHR Transport error. Code: ' + xhr.status);
xhr.onload = () => {
if (xhr.status < 200 || xhr.status >= 300) {
reject('HTTP Error: ' + xhr.status);
return;
}
const json = JSON.parse(xhr.responseText);
if (!json || typeof json.tmpLocation !== 'string') {
reject('Invalid JSON: ' + xhr.responseText);
return;
}
// Put temp location into localstorage (used to update the img with data-tmpimg later on)
localStorage.set(`tinymce__${blobInfo.blobUri()}`, json.tmpLocation);
// We set the img src url to be the same as we started
// The Blob URI is stored in TinyMce's cache
// so the img still shows in the editor
resolve(blobInfo.blobUri());
};
const formData = new FormData();
formData.append('file', blobInfo.blob(), blobInfo.filename());
xhr.send(formData);
});
};

View File

@@ -6,6 +6,7 @@ import {
UMB_MODAL_MANAGER_CONTEXT_TOKEN,
} from '@umbraco-cms/backoffice/modal';
import { UMB_CURRENT_USER_CONTEXT, UmbCurrentUser } from '@umbraco-cms/backoffice/current-user';
import { RawEditorOptions } from '@umbraco-cms/backoffice/external/tinymce';
interface MediaPickerTargetData {
altText?: string;
@@ -52,6 +53,18 @@ export default class UmbTinyMceMediaPickerPlugin extends UmbTinyMcePluginBase {
//stateSelector: 'img[data-udi]', TODO => Investigate where stateselector has gone, or if it is still needed
onAction: () => this.#onAction(),
});
// Register global options for the editor
this.editor.options.register('maxImageSize', { processor: 'number', default: 500 });
// Adjust Editor settings to allow pasting images
// but only if the umbmediapicker button is present
const toolbar = this.configuration?.getValueByAlias<string[]>('toolbar');
if (toolbar?.includes('umbmediapicker')) {
this.editor.options.set('paste_data_images', true);
this.editor.options.set('automatic_uploads', true);
this.editor.options.set('images_upload_handler', uploadImageHandler);
}
}
/*
@@ -186,3 +199,48 @@ export default class UmbTinyMceMediaPickerPlugin extends UmbTinyMcePluginBase {
});
}
}
const uploadImageHandler: RawEditorOptions['images_upload_handler'] = (blobInfo, progress) => {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
// FIXME: This must use the new TemporaryFileResource
xhr.open('POST', window.Umbraco?.Sys.ServerVariables.umbracoUrls.tinyMceApiBaseUrl + 'UploadImage');
xhr.onloadstart = () =>
document.dispatchEvent(new CustomEvent('rte.file.uploading', { composed: true, bubbles: true }));
xhr.onloadend = () =>
document.dispatchEvent(new CustomEvent('rte.file.uploaded', { composed: true, bubbles: true }));
xhr.upload.onprogress = (e) => progress((e.loaded / e.total) * 100);
xhr.onerror = () => reject('Image upload failed due to a XHR Transport error. Code: ' + xhr.status);
xhr.onload = () => {
if (xhr.status < 200 || xhr.status >= 300) {
reject('HTTP Error: ' + xhr.status);
return;
}
const json = JSON.parse(xhr.responseText);
if (!json || typeof json.tmpLocation !== 'string') {
reject('Invalid JSON: ' + xhr.responseText);
return;
}
// Put temp location into localstorage (used to update the img with data-tmpimg later on)
localStorage.set(`tinymce__${blobInfo.blobUri()}`, json.tmpLocation);
// We set the img src url to be the same as we started
// The Blob URI is stored in TinyMce's cache
// so the img still shows in the editor
resolve(blobInfo.blobUri());
};
const formData = new FormData();
formData.append('file', blobInfo.blob(), blobInfo.filename());
xhr.send(formData);
});
};