From 3efcffb56ff66f9e4bcd5087e032a76078259bf6 Mon Sep 17 00:00:00 2001 From: Nathan Woulfe Date: Fri, 3 Mar 2023 13:51:57 +1000 Subject: [PATCH] move functions to media service --- .../input-tiny-mce/input-tiny-mce.element.ts | 306 +++++++++++------- .../uis/tiny-mce/media-helper.service.ts | 201 +++++++++++- .../plugins/tiny-mce-embeddedmedia.plugin.ts | 79 ++++- .../plugins/tiny-mce-linkpicker.plugin.ts | 26 +- .../tiny-mce/plugins/tiny-mce-macro.plugin.ts | 4 +- .../plugins/tiny-mce-mediapicker.plugin.ts | 4 +- .../src/core/mocks/data/data-type.data.ts | 6 + 7 files changed, 463 insertions(+), 163 deletions(-) diff --git a/src/Umbraco.Web.UI.Client/src/backoffice/shared/components/input-tiny-mce/input-tiny-mce.element.ts b/src/Umbraco.Web.UI.Client/src/backoffice/shared/components/input-tiny-mce/input-tiny-mce.element.ts index 51c92a351f..dd2162d34d 100644 --- a/src/Umbraco.Web.UI.Client/src/backoffice/shared/components/input-tiny-mce/input-tiny-mce.element.ts +++ b/src/Umbraco.Web.UI.Client/src/backoffice/shared/components/input-tiny-mce/input-tiny-mce.element.ts @@ -1,6 +1,6 @@ import { html } from 'lit'; import { UUITextStyles } from '@umbraco-ui/uui-css/lib'; -import { customElement, property } from 'lit/decorators.js'; +import { customElement, property, state } from 'lit/decorators.js'; import { ifDefined } from 'lit-html/directives/if-defined.js'; import { FormControlMixin } from '@umbraco-ui/uui-base/lib/mixins'; import { AstNode, Editor, EditorEvent, TinyMCE } from 'tinymce'; @@ -9,6 +9,7 @@ import { TinyMceCodeEditorPlugin } from '../../property-editors/uis/tiny-mce/plu import { TinyMceLinkPickerPlugin } from '../../property-editors/uis/tiny-mce/plugins/tiny-mce-linkpicker.plugin'; import { TinyMceMacroPlugin } from '../../property-editors/uis/tiny-mce/plugins/tiny-mce-macro.plugin'; import { TinyMceMediaPickerPlugin } from '../../property-editors/uis/tiny-mce/plugins/tiny-mce-mediapicker.plugin'; +import { TinyMceEmbeddedMediaPlugin } from '../../property-editors/uis/tiny-mce/plugins/tiny-mce-embeddedmedia.plugin'; import { UmbCurrentUserStore, UMB_CURRENT_USER_STORE_CONTEXT_TOKEN, @@ -63,10 +64,9 @@ export class UmbInputTinyMceElement extends FormControlMixin(UmbLitElement) { @property() configuration: Array = []; - @property() - private _dimensions?: { [key: string]: number }; + @state() + private _configObject: any = {}; - @property() private _styleFormats = [ { title: 'Headers', @@ -89,21 +89,123 @@ export class UmbInputTinyMceElement extends FormControlMixin(UmbLitElement) { }, ]; - @property({ type: Array }) - private _toolbar: Array = []; + // these languages are available for localization + #availableLanguages = [ + 'ar', + 'ar_SA', + 'hy', + 'az', + 'eu', + 'be', + 'bn_BD', + 'bs', + 'bg_BG', + 'ca', + 'zh_CN', + 'zh_TW', + 'hr', + 'cs', + 'da', + 'dv', + 'nl', + 'en_CA', + 'en_GB', + 'et', + 'fo', + 'fi', + 'fr_FR', + 'gd', + 'gl', + 'ka_GE', + 'de', + 'de_AT', + 'el', + 'he_IL', + 'hi_IN', + 'hu_HU', + 'is_IS', + 'id', + 'it', + 'ja', + 'kab', + 'kk', + 'km_KH', + 'ko_KR', + 'ku', + 'ku_IQ', + 'lv', + 'lt', + 'lb', + 'ml', + 'ml_IN', + 'mn_MN', + 'nb_NO', + 'fa', + 'fa_IR', + 'pl', + 'pt_BR', + 'pt_PT', + 'ro', + 'ru', + 'sr', + 'si_LK', + 'sk', + 'sl_SI', + 'es', + 'es_MX', + 'sv_SE', + 'tg', + 'ta', + 'ta_IN', + 'tt', + 'th_TH', + 'tr', + 'tr_TR', + 'ug', + 'uk', + 'uk_UA', + 'vi', + 'vi_VN', + 'cy', + ]; - @property({ type: Array }) - private _plugins: Array = []; + //define fallback language + #defaultLanguage = 'en_US'; - @property({ type: Array }) - private _stylesheets: Array = []; + //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 + #extendedValidElements = + '@[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'; + + // If no config provided, fallback to these sensible defaults + #fallbackConfig = { + toolbar: [ + 'ace', + 'styles', + 'bold', + 'italic', + 'alignleft', + 'aligncenter', + 'alignright', + 'bullist', + 'numlist', + 'outdent', + 'indent', + 'link', + 'umbmediapicker', + 'umbmacro', + 'umbembeddialog', + ], + stylesheets: [], + maxImageSize: 500, + }; // @property({ type: String }) // private _contentStyle: string = contentUiSkinCss.toString() + '\n' + contentCss.toString(); #currentUserStore?: UmbCurrentUserStore; modalContext!: UmbModalContext; - #mediaHelper = new UmbMediaHelper(); + #mediaHelper = new UmbMediaHelper(this); currentUser?: UserDetails; protected getFormElement() { @@ -135,33 +237,74 @@ export class UmbInputTinyMceElement extends FormControlMixin(UmbLitElement) { connectedCallback() { super.connectedCallback(); - this._dimensions = this.configuration.find((x) => x.alias === 'dimensions')?.value as { [key: string]: number }; - this._toolbar = this.configuration.find((x) => x.alias === 'toolbar')?.value; - this._plugins = this.configuration - .find((x) => x.alias === 'plugins') - ?.value.map((x: { [key: string]: string }) => x.name); - this._stylesheets = this.configuration.find((x) => x.alias === 'stylesheets')?.value; + this._configObject = Object.fromEntries( + (this.configuration ?? this.#fallbackConfig).map((x) => [x.alias, x.value]) + ); // no auto resize when a fixed height is set - if (!this._dimensions.height) { - this._plugins.splice(this._plugins.indexOf('autoresize'), 1); + if (!this._configObject.dimensions?.height) { + this._configObject.plugins.splice(this._configObject.plugins.indexOf('autoresize'), 1); } } // TODO => setup runs before rendering, here we can add any custom plugins - // TODO => fix TinyMCE type definitions #setTinyConfig() { window.tinyConfig = { + autoresize_bottom_margin: 10, + body_class: 'umb-rte', + //see https://www.tiny.cloud/docs/tinymce/6/editor-important-options/#cache_suffix + cache_suffix: '?umb__rnd=' + window.Umbraco?.Sys.ServerVariables.application.cacheBuster, content_css: false, contextMenu: false, - convert_urls: false, + language: () => this.#getLanguage(), menubar: false, + paste_remove_styles_if_webkit: true, + paste_preprocess: (_: Editor, args: { content: string }) => this.#cleanupPasteData(args), + relative_urls: false, resize: false, //skin: false, statusbar: false, - style_formats: this._styleFormats, setup: (editor: Editor) => this.#editorSetup(editor), }; + + // 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()) { + // Update the TinyMCE Config object to allow pasting + window.tinyConfig.images_upload_handler = this.#mediaHelper.uploadImageHandler; + window.tinyConfig.automatic_uploads = false; + window.tinyConfig.images_replace_blob_uris = false; + + // This allows images to be pasted in & stored as Base64 until they get uploaded to server + window.tinyConfig.paste_data_images = true; + } + } + + #cleanupPasteData(args: { content: string }) { + // Remove spans + args.content = args.content.replace(/<\s*span[^>]*>(.*?)<\s*\/\s*span>/g, '$1'); + // Convert b to strong. + args.content = args.content.replace(/<\s*b([^>]*)>(.*?)<\s*\/\s*b([^>]*)>/g, '$2'); + // convert i to em + args.content = args.content.replace(/<\s*i([^>]*)>(.*?)<\s*\/\s*i([^>]*)>/g, '$2'); + } + + /** + * Returns the language to use for TinyMCE */ + #getLanguage() { + const localeId = this.currentUser?.language; + //try matching the language using full locale format + let languageMatch = this.#availableLanguages.find((x) => x.toLowerCase() === localeId); + + //if no matches, try matching using only the language + if (!languageMatch) { + const localeParts = localeId?.split('_'); + if (localeParts) { + languageMatch = this.#availableLanguages.find((x) => x === localeParts[0]); + } + } + + return languageMatch ?? this.#defaultLanguage; } #editorSetup(editor: Editor) { @@ -170,15 +313,16 @@ export class UmbInputTinyMceElement extends FormControlMixin(UmbLitElement) { new TinyMceLinkPickerPlugin(editor, this.modalContext, this.configuration); new TinyMceMacroPlugin(editor, this.modalContext); new TinyMceMediaPickerPlugin(editor, this.modalContext, this.configuration, this.currentUser); + new TinyMceEmbeddedMediaPlugin(editor, this.modalContext); // register custom option maxImageSize - editor.options.register('maxImageSize', { processor: 'number', default: 500 }); + editor.options.register('maxImageSize', { processor: 'number', default: this.#fallbackConfig.maxImageSize }); // 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._toolbar && !this.#isMediaPickerEnabled()) { + if (this._configObject.toolbar && !this.#isMediaPickerEnabled()) { // Wire up the event listener editor.on('dragstart dragend dragover draggesture dragdrop drop drag', (e: EditorEvent) => { e.preventDefault(); @@ -202,37 +346,13 @@ 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.#uploadBlobImages(editor)); + editor.on('SetContent', () => this.#mediaHelper.uploadBlobImages(editor)); editor.on('ObjectResized', (e) => { - this.#onResize(e); + this.#mediaHelper.onResize(e); this.#onChange(editor.getContent()); }); } - async #onResize( - e: EditorEvent<{ - target: HTMLElement; - width: number; - height: number; - origin: string; - }> - ) { - const srcAttr = e.target.getAttribute('src'); - - if (!srcAttr) { - return; - } - - const path = srcAttr.split('?')[0]; - const resizedPath = await this.#mediaHelper.getProcessedImageUrl(path, { - width: e.width, - height: e.height, - mode: 'max', - }); - - e.target.setAttribute('data-mce-src', resizedPath); - } - #onInit(editor: Editor) { //enable browser based spell checking editor.getBody().setAttribute('spellcheck', 'true'); @@ -304,91 +424,29 @@ export class UmbInputTinyMceElement extends FormControlMixin(UmbLitElement) { } } - async #uploadBlobImages(editor: Editor) { - const content = editor.getContent(); - - // Upload BLOB images (dragged/pasted ones) - // find src attribute where value starts with `blob:` - // search is case-insensitive and allows single or double quotes - if (content.search(/src=["']blob:.*?["']/gi) !== -1) { - const data = await editor.uploadImages(); - // Once all images have been uploaded - data.forEach((item) => { - // Skip items that failed upload - if (item.status === false) { - return; - } - - // Select img element - const img = item.element; - - // Get img src - const imgSrc = img.getAttribute('src'); - const tmpLocation = localStorage.get(`tinymce__${imgSrc}`); - - // Select the img & add new attr which we can search for - // When its being persisted in RTE property editor - // To create a media item & delete this tmp one etc - editor.dom.setAttrib(img, 'data-tmpimg', tmpLocation); - - // Resize the image to the max size configured - // NOTE: no imagesrc passed into func as the src is blob://... - // We will append ImageResizing Querystrings on perist to DB with node save - this.#mediaHelper.sizeImageInEditor(editor, img); - }); - - // Get all img where src starts with blob: AND does NOT have a data=tmpimg attribute - // This is most likely seen as a duplicate image that has already been uploaded - // editor.uploadImages() does not give us any indiciation that the image been uploaded already - const blobImageWithNoTmpImgAttribute = editor.dom.select('img[src^="blob:"]:not([data-tmpimg])'); - - //For each of these selected items - blobImageWithNoTmpImgAttribute.forEach((imageElement) => { - const blobSrcUri = editor.dom.getAttrib(imageElement, 'src'); - - // Find the same image uploaded (Should be in LocalStorage) - // May already exist in the editor as duplicate image - // OR added to the RTE, deleted & re-added again - // So lets fetch the tempurl out of localstorage for that blob URI item - - const tmpLocation = localStorage.get(`tinymce__${blobSrcUri}`); - if (tmpLocation) { - this.#mediaHelper.sizeImageInEditor(editor, imageElement); - editor.dom.setAttrib(imageElement, 'data-tmpimg', tmpLocation); - } - }); - } - - if (window.Umbraco?.Sys.ServerVariables.umbracoSettings.sanitizeTinyMce) { - /** prevent injecting arbitrary JavaScript execution in on-attributes. */ - const allNodes = Array.from(editor.dom.doc.getElementsByTagName('*')); - allNodes.forEach((node) => { - for (let i = 0; i < node.attributes.length; i++) { - if (node.attributes[i].name.startsWith('on')) { - node.removeAttribute(node.attributes[i].name); - } - } - }); - } - } - #onChange(value: string) { super.value = value; this.dispatchEvent(new CustomEvent('change', { bubbles: true, composed: true })); } #isMediaPickerEnabled() { - return this._toolbar.includes('umbmediapicker'); + return this._configObject.toolbar.includes('umbmediapicker'); } render() { return html` x.name).join(' ')} + quickbars_insert_toolbar=${this._configObject.toolbar.join(' ')} + quickbars_selection_toolbar=${this._configObject.toolbar.join(' ')} + .style_formats=${this._styleFormats} + toolbar=${this._configObject.toolbar.join(' ')} + valid_elements=${this._configObject.validElements} + width=${ifDefined(this._configObject.dimensions?.width)} >${this.value}`; } diff --git a/src/Umbraco.Web.UI.Client/src/backoffice/shared/property-editors/uis/tiny-mce/media-helper.service.ts b/src/Umbraco.Web.UI.Client/src/backoffice/shared/property-editors/uis/tiny-mce/media-helper.service.ts index e7e1d5148d..dc1f6e6a5e 100644 --- a/src/Umbraco.Web.UI.Client/src/backoffice/shared/property-editors/uis/tiny-mce/media-helper.service.ts +++ b/src/Umbraco.Web.UI.Client/src/backoffice/shared/property-editors/uis/tiny-mce/media-helper.service.ts @@ -1,7 +1,24 @@ // TODO => very much temporary +import { Editor, EditorEvent } from "tinymce"; +import { UmbLitElement } from "@umbraco-cms/element"; + export class UmbMediaHelper { - async sizeImageInEditor(editor: any, imageDomElement: HTMLElement, imgUrl?: string) { + + #host: UmbLitElement; + + constructor(host: UmbLitElement) { + this.#host = host; + } + + + /** + * + * @param editor + * @param imageDomElement + * @param imgUrl + */ + async sizeImageInEditor(editor: Editor, imageDomElement: HTMLElement, imgUrl?: string) { const size = editor.dom.getSize(imageDomElement); const maxImageSize = editor.options.get('maxImageSize'); @@ -21,11 +38,17 @@ export class UmbMediaHelper { editor.dom.setAttrib(imageDomElement, 'data-mce-src', resizedImgUrl); } - editor.execCommand('mceAutoResize', false, null, null); + editor.execCommand('mceAutoResize', false); } } - // Transplanted from mediahelper + /** + * + * @param maxSize + * @param width + * @param height + * @returns + */ scaleToMaxSize(maxSize: number, width: number, height: number) { const retval = { width, height }; @@ -56,6 +79,12 @@ export class UmbMediaHelper { return retval; } + /** + * + * @param imagePath + * @param options + * @returns + */ async getProcessedImageUrl(imagePath: string, options: any) { if (!options) { return imagePath; @@ -63,6 +92,170 @@ export class UmbMediaHelper { const result = await fetch('/umbraco/management/api/v1/images/GetProcessedImageUrl'); - return result; + return result as any; + } + + /** + * + * @param editor + */ + async uploadBlobImages(editor: Editor) { + const content = editor.getContent(); + + // Upload BLOB images (dragged/pasted ones) + // find src attribute where value starts with `blob:` + // search is case-insensitive and allows single or double quotes + if (content.search(/src=["']blob:.*?["']/gi) !== -1) { + const data = await editor.uploadImages(); + // Once all images have been uploaded + data.forEach((item) => { + // Skip items that failed upload + if (item.status === false) { + return; + } + + // Select img element + const img = item.element; + + // Get img src + const imgSrc = img.getAttribute('src'); + const tmpLocation = localStorage.get(`tinymce__${imgSrc}`); + + // Select the img & add new attr which we can search for + // When its being persisted in RTE property editor + // To create a media item & delete this tmp one etc + editor.dom.setAttrib(img, 'data-tmpimg', tmpLocation); + + // Resize the image to the max size configured + // NOTE: no imagesrc passed into func as the src is blob://... + // We will append ImageResizing Querystrings on perist to DB with node save + this.sizeImageInEditor(editor, img); + }); + + // Get all img where src starts with blob: AND does NOT have a data=tmpimg attribute + // This is most likely seen as a duplicate image that has already been uploaded + // editor.uploadImages() does not give us any indiciation that the image been uploaded already + const blobImageWithNoTmpImgAttribute = editor.dom.select('img[src^="blob:"]:not([data-tmpimg])'); + + //For each of these selected items + blobImageWithNoTmpImgAttribute.forEach((imageElement) => { + const blobSrcUri = editor.dom.getAttrib(imageElement, 'src'); + + // Find the same image uploaded (Should be in LocalStorage) + // May already exist in the editor as duplicate image + // OR added to the RTE, deleted & re-added again + // So lets fetch the tempurl out of localstorage for that blob URI item + + const tmpLocation = localStorage.get(`tinymce__${blobSrcUri}`); + if (tmpLocation) { + this.sizeImageInEditor(editor, imageElement); + editor.dom.setAttrib(imageElement, 'data-tmpimg', tmpLocation); + } + }); + } + + if (window.Umbraco?.Sys.ServerVariables.umbracoSettings.sanitizeTinyMce) { + /** prevent injecting arbitrary JavaScript execution in on-attributes. */ + const allNodes = Array.from(editor.dom.doc.getElementsByTagName('*')); + allNodes.forEach((node) => { + for (let i = 0; i < node.attributes.length; i++) { + if (node.attributes[i].name.startsWith('on')) { + node.removeAttribute(node.attributes[i].name); + } + } + }); + } + } + + /** + * + * @param e + * @returns + */ + async onResize( + e: EditorEvent<{ + target: HTMLElement; + width: number; + height: number; + origin: string; + }> + ) { + const srcAttr = e.target.getAttribute('src'); + + if (!srcAttr) { + return; + } + + const path = srcAttr.split('?')[0]; + const resizedPath = await this.getProcessedImageUrl(path, { + width: e.width, + height: e.height, + mode: 'max', + }); + + e.target.setAttribute('data-mce-src', resizedPath); + } + + /** + * + * @param blobInfo + * @param progress + * @returns + */ + uploadImageHandler(blobInfo: any, progress: any) { + return new Promise((resolve, reject) => { + const xhr = new XMLHttpRequest(); + xhr.open('POST', window.Umbraco?.Sys.ServerVariables.umbracoUrls.tinyMceApiBaseUrl + 'UploadImage'); + + xhr.onloadstart = () => this.#host.dispatchEvent(new CustomEvent('rte.file.uploading')); + + xhr.onloadend = () => this.#host.dispatchEvent(new CustomEvent('rte.file.uploaded')); + + 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; + } + + // TODO => confirm this is required given no more Angular handling XHR/HTTP + const data = xhr.responseText.split('\n'); + + if (data.length <= 1) { + reject('Unrecognized text string: ' + data); + return; + } + + let json: { [key: string]: string } = {}; + + try { + json = JSON.parse(data[1]); + } catch (e: any) { + reject('Invalid JSON: ' + data + ' - ' + e.message); + return; + } + + if (!json || typeof json.tmpLocation !== 'string') { + reject('Invalid JSON: ' + data); + 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.blob().name); + + xhr.send(formData); + }); } } diff --git a/src/Umbraco.Web.UI.Client/src/backoffice/shared/property-editors/uis/tiny-mce/plugins/tiny-mce-embeddedmedia.plugin.ts b/src/Umbraco.Web.UI.Client/src/backoffice/shared/property-editors/uis/tiny-mce/plugins/tiny-mce-embeddedmedia.plugin.ts index 920d0ebe22..17ffbcd2d8 100644 --- a/src/Umbraco.Web.UI.Client/src/backoffice/shared/property-editors/uis/tiny-mce/plugins/tiny-mce-embeddedmedia.plugin.ts +++ b/src/Umbraco.Web.UI.Client/src/backoffice/shared/property-editors/uis/tiny-mce/plugins/tiny-mce-embeddedmedia.plugin.ts @@ -1,6 +1,13 @@ import { Editor } from 'tinymce'; import { UmbModalContext } from '@umbraco-cms/modal'; +interface EmbeddedMediaModalData { + url?: string; + width?: number; + height?: number; + constrain?: string; +} + export class TinyMceEmbeddedMediaPlugin { #modalContext: UmbModalContext; editor: Editor; @@ -9,25 +16,73 @@ export class TinyMceEmbeddedMediaPlugin { this.#modalContext = modalContext; this.editor = editor; - editor.ui.registry.addButton('ace', { - icon: 'sourcecode', - tooltip: 'View Source Code', - onAction: () => this.#showCodeEditor(), + editor.ui.registry.addButton('umbembeddialog', { + icon: 'embed', + tooltip: 'Embed', + onAction: () => this.#onAction(), }); } - async #showCodeEditor() { - const modalHandler = this.#modalContext?.codeEditor({ - headline: 'Edit source code', - content: this.editor.getContent(), + #onAction() { + // Get the selected element + // Check nodename is a DIV and the claslist contains 'embeditem' + const selectedElm = this.editor.selection.getNode(); + const nodeName = selectedElm.nodeName; + let modify: EmbeddedMediaModalData = {}; + + if (nodeName.toUpperCase() === "DIV" && selectedElm.classList.contains("embeditem")) { + // See if we can go and get the attributes + const embedUrl = this.editor.dom.getAttrib(selectedElm, "data-embed-url"); + const embedWidth = this.editor.dom.getAttrib(selectedElm, "data-embed-width"); + const embedHeight = this.editor.dom.getAttrib(selectedElm, "data-embed-height"); + const embedConstrain = this.editor.dom.getAttrib(selectedElm, "data-embed-constrain"); + + modify = { + url: embedUrl, + width: parseInt(embedWidth) || 0, + height: parseInt(embedHeight) || 0, + constrain: embedConstrain + }; + } + + this.#showModal(selectedElm, modify); + } + + #insertInEditor(embed: any, activeElement: HTMLElement) { + // Wrap HTML preview content here in a DIV with non-editable class of .mceNonEditable + // This turns it into a selectable/cutable block to move about + const wrapper = this.editor.dom.create('div', + { + 'class': 'mceNonEditable embeditem', + 'data-embed-url': embed.url, + 'data-embed-height': embed.height, + 'data-embed-width': embed.width, + 'data-embed-constrain': embed.constrain, + 'contenteditable': false + }, + embed.preview); + + // Only replace if activeElement is an Embed element. + if (activeElement && activeElement.nodeName.toUpperCase() === "DIV" && activeElement.classList.contains("embeditem")) { + activeElement.replaceWith(wrapper); // directly replaces the html node + } else { + this.editor.selection.setNode(wrapper); + } + } + + // TODO => update when embed modal exists + async #showModal(selectedElm: HTMLElement, modify: EmbeddedMediaModalData) { + const modalHandler = this.#modalContext?.openBasic({ + header: 'Embedded media picker modal', + content: 'Here be the picker', }); if (!modalHandler) return; - const { confirmed, content } = await modalHandler.onClose(); - if (!confirmed) return; + const result = await modalHandler.onClose(); + if (!result) return; - this.editor.setContent(content); - this.editor.dispatch('Change'); + this.#insertInEditor(result, selectedElm); + this.editor.dispatch('Change'); } } diff --git a/src/Umbraco.Web.UI.Client/src/backoffice/shared/property-editors/uis/tiny-mce/plugins/tiny-mce-linkpicker.plugin.ts b/src/Umbraco.Web.UI.Client/src/backoffice/shared/property-editors/uis/tiny-mce/plugins/tiny-mce-linkpicker.plugin.ts index 8a830e1f21..aef204245f 100644 --- a/src/Umbraco.Web.UI.Client/src/backoffice/shared/property-editors/uis/tiny-mce/plugins/tiny-mce-linkpicker.plugin.ts +++ b/src/Umbraco.Web.UI.Client/src/backoffice/shared/property-editors/uis/tiny-mce/plugins/tiny-mce-linkpicker.plugin.ts @@ -97,37 +97,25 @@ export class TinyMceLinkPickerPlugin { createLinkPickerCallback(currentTarget, anchorElm); } - const editorEventSetupCallback = (buttonApi: { setEnabled: (state: boolean) => void }) => { - const editorEventCallback = (eventApi: { element: Element}) => { - buttonApi.setEnabled(eventApi.element.nodeName.toLowerCase() === 'a' && eventApi.element.hasAttribute('href')); - }; + // const editorEventSetupCallback = (buttonApi: { setEnabled: (state: boolean) => void }) => { + // const editorEventCallback = (eventApi: { element: Element}) => { + // buttonApi.setEnabled(eventApi.element.nodeName.toLowerCase() === 'a' && eventApi.element.hasAttribute('href')); + // }; - editor.on('NodeChange', editorEventCallback); - return () => editor.off('NodeChange', editorEventCallback); - }; + // editor.on('NodeChange', editorEventCallback); + // return () => editor.off('NodeChange', editorEventCallback); + // }; editor.ui.registry.addButton('link', { icon: 'link', tooltip: 'Insert/edit link', onAction: showDialog, - onSetup: editorEventSetupCallback, }); editor.ui.registry.addButton('unlink', { icon: 'unlink', tooltip: 'Remove link', onAction: () => editor.execCommand('unlink'), - onSetup: editorEventSetupCallback, - }); - - editor.ui.registry.addMenuItem('link', { - icon: 'link', - text: 'Insert link', - shortcut: 'Ctrl+K', - onAction: showDialog, - onSetup: editorEventSetupCallback, - //context: 'insert', - //prependToContext: true, }); } diff --git a/src/Umbraco.Web.UI.Client/src/backoffice/shared/property-editors/uis/tiny-mce/plugins/tiny-mce-macro.plugin.ts b/src/Umbraco.Web.UI.Client/src/backoffice/shared/property-editors/uis/tiny-mce/plugins/tiny-mce-macro.plugin.ts index 7cffad8482..39141c72bb 100644 --- a/src/Umbraco.Web.UI.Client/src/backoffice/shared/property-editors/uis/tiny-mce/plugins/tiny-mce-macro.plugin.ts +++ b/src/Umbraco.Web.UI.Client/src/backoffice/shared/property-editors/uis/tiny-mce/plugins/tiny-mce-macro.plugin.ts @@ -126,7 +126,7 @@ export class TinyMceMacroPlugin { // }); } - #insertMacroInEditor(macroObject: MacroSyntaxData, activeMacroElement?: HTMLElement) { + #insertInEditor(macroObject: MacroSyntaxData, activeMacroElement?: HTMLElement) { //Important note: the TinyMce plugin "noneditable" is used here so that the macro cannot be edited, // for this to work the mceNonEditable class needs to come last and we also need to use the attribute contenteditable = false // (even though all the docs and examples say that is not necessary) @@ -196,7 +196,7 @@ export class TinyMceMacroPlugin { const { confirmed } = await modalHandler.onClose(); if (!confirmed) return; - this.#insertMacroInEditor({} as MacroSyntaxData, dialogData.activeMacroElement); + this.#insertInEditor({} as MacroSyntaxData, dialogData.activeMacroElement); this.editor.dispatch('Change'); } } diff --git a/src/Umbraco.Web.UI.Client/src/backoffice/shared/property-editors/uis/tiny-mce/plugins/tiny-mce-mediapicker.plugin.ts b/src/Umbraco.Web.UI.Client/src/backoffice/shared/property-editors/uis/tiny-mce/plugins/tiny-mce-mediapicker.plugin.ts index 90571d165a..9c47fea64a 100644 --- a/src/Umbraco.Web.UI.Client/src/backoffice/shared/property-editors/uis/tiny-mce/plugins/tiny-mce-mediapicker.plugin.ts +++ b/src/Umbraco.Web.UI.Client/src/backoffice/shared/property-editors/uis/tiny-mce/plugins/tiny-mce-mediapicker.plugin.ts @@ -102,12 +102,12 @@ export class TinyMceMediaPickerPlugin { const { selection } = await modalHandler.onClose(); if (!selection.length) return; - this.#insertMediaInEditor(selection[0]); + this.#insertInEditor(selection[0]); this.editor.dispatch('Change'); } // TODO => mediaPicker returns a UDI, so need to fetch it. Wait for backend CLI before implementing - async #insertMediaInEditor(img: any) { + async #insertInEditor(img: any) { if (!img) return; // We need to create a NEW DOM element to insert diff --git a/src/Umbraco.Web.UI.Client/src/core/mocks/data/data-type.data.ts b/src/Umbraco.Web.UI.Client/src/core/mocks/data/data-type.data.ts index 5865acbaae..7dabc4fb7d 100644 --- a/src/Umbraco.Web.UI.Client/src/core/mocks/data/data-type.data.ts +++ b/src/Umbraco.Web.UI.Client/src/core/mocks/data/data-type.data.ts @@ -484,6 +484,12 @@ export const data: Array = [ { alias: 'maxImageSize', value: 500 }, { alias: 'mode', value: 'classic' }, { alias: 'ignoreUserStartNodes', value: false }, + { + alias: 'validElements', + value: + '+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,video[*],audio[*],picture[*],source[*],canvas[*]', + }, + { alias: 'invalidElements', value: 'font' }, { alias: 'stylesheets', value: ['/css/dropdownStyles.css'] }, { alias: 'toolbar',