From 1b9f3a90780f45fe08ba5eccbd888ca1af2d7060 Mon Sep 17 00:00:00 2001 From: Jacob Overgaard <752371+iOvergaard@users.noreply.github.com> Date: Tue, 19 Dec 2023 15:49:01 +0100 Subject: [PATCH] revamp loose binding between tinymce raweditoroptions and our data type configuration by checking if configuration options exist first --- .../input-tiny-mce/input-tiny-mce.defaults.ts | 61 ++++----- .../input-tiny-mce/input-tiny-mce.element.ts | 120 +++++++----------- .../input-tiny-mce/input-tiny-mce.handlers.ts | 44 ------- .../plugins/tiny-mce-mediapicker.plugin.ts | 58 +++++++++ 4 files changed, 133 insertions(+), 150 deletions(-) diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/components/input-tiny-mce/input-tiny-mce.defaults.ts b/src/Umbraco.Web.UI.Client/src/packages/core/components/input-tiny-mce/input-tiny-mce.defaults.ts index f93ee1290d..579cbf0b6d 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/components/input-tiny-mce/input-tiny-mce.defaults.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/components/input-tiny-mce/input-tiny-mce.defaults.ts @@ -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, }; diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/components/input-tiny-mce/input-tiny-mce.element.ts b/src/Umbraco.Web.UI.Client/src/packages/core/components/input-tiny-mce/input-tiny-mce.element.ts index 55cead9f0e..a2ee44ed9e 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/components/input-tiny-mce/input-tiny-mce.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/components/input-tiny-mce/input-tiny-mce.element.ts @@ -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 = { - ...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('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('toolbar'); + if (toolbar) { + configurationOptions.toolbar = toolbar.join(' '); + } + + // set the configured inline mode + const mode = this.configuration?.getValueByAlias('mode'); + if (mode?.toLocaleLowerCase() === 'inline') { + configurationOptions.inline = true; + } + + // set the maximum image size + const maxImageSize = this.configuration?.getValueByAlias('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) => { - 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`
`; diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/components/input-tiny-mce/input-tiny-mce.handlers.ts b/src/Umbraco.Web.UI.Client/src/packages/core/components/input-tiny-mce/input-tiny-mce.handlers.ts index 9c647e1eea..4cfbc3c67a 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/components/input-tiny-mce/input-tiny-mce.handlers.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/components/input-tiny-mce/input-tiny-mce.handlers.ts @@ -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, '$2'); }; - -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); - }); -}; diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/property-editor/uis/tiny-mce/plugins/tiny-mce-mediapicker.plugin.ts b/src/Umbraco.Web.UI.Client/src/packages/core/property-editor/uis/tiny-mce/plugins/tiny-mce-mediapicker.plugin.ts index fe58231a3c..432fca76fe 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/property-editor/uis/tiny-mce/plugins/tiny-mce-mediapicker.plugin.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/property-editor/uis/tiny-mce/plugins/tiny-mce-mediapicker.plugin.ts @@ -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('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); + }); +};