diff --git a/src/Umbraco.Web.UI.Client/package.json b/src/Umbraco.Web.UI.Client/package.json index f3074ff40a..1f79b1c58d 100644 --- a/src/Umbraco.Web.UI.Client/package.json +++ b/src/Umbraco.Web.UI.Client/package.json @@ -33,6 +33,7 @@ "./localization": "./dist-cms/packages/core/localization/index.js", "./macro": "./dist-cms/packages/core/macro/index.js", "./menu": "./dist-cms/packages/core/menu/index.js", + "./meta": "./dist-cms/packages/core/meta/index.js", "./modal": "./dist-cms/packages/core/modal/index.js", "./notification": "./dist-cms/packages/core/notification/index.js", "./picker-input": "./dist-cms/packages/core/picker-input/index.js", diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/components/input-color/input-color.element.ts b/src/Umbraco.Web.UI.Client/src/packages/core/components/input-color/input-color.element.ts index e978447dfb..ad5836627f 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/components/input-color/input-color.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/components/input-color/input-color.element.ts @@ -31,7 +31,10 @@ export class UmbInputColorElement extends FormControlMixin(UmbLitElement) { render() { return html` - ${this._renderColors()} + ${this._renderColors()} `; } 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 d43c6320db..a429861425 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,17 +1,12 @@ -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 { UMB_CURRENT_USER_CONTEXT, UmbCurrentUser } from '@umbraco-cms/backoffice/current-user'; +import { type Editor, type RawEditorOptions, renderEditor } from '@umbraco-cms/backoffice/external/tinymce'; import { TinyMcePluginArguments, UmbTinyMcePluginBase } from '@umbraco-cms/backoffice/components'; -import { ClassConstructor, hasDefaultExport, loadManifestApi } from '@umbraco-cms/backoffice/extension-api'; +import { loadManifestApi } from '@umbraco-cms/backoffice/extension-api'; import { ManifestTinyMcePlugin, umbExtensionsRegistry } from '@umbraco-cms/backoffice/extension-registry'; import { PropertyValueMap, @@ -28,8 +23,8 @@ import { UmbLitElement } from '@umbraco-cms/internal/lit-element'; import { UmbPropertyEditorConfigCollection } from '@umbraco-cms/backoffice/property-editor'; import { UMB_APP_CONTEXT } from '@umbraco-cms/backoffice/app'; import { UmbStylesheetRepository } from '@umbraco-cms/backoffice/stylesheet'; +import { UmbChangeEvent } from '@umbraco-cms/backoffice/event'; -// TODO => integrate macro picker, update stylesheet fetch when backend CLI exists (ref tinymce.service.js in existing backoffice) @customElement('umb-input-tiny-mce') export class UmbInputTinyMceElement extends FormControlMixin(UmbLitElement) { @property({ attribute: false }) @@ -39,8 +34,6 @@ export class UmbInputTinyMceElement extends FormControlMixin(UmbLitElement) { private _tinyConfig: RawEditorOptions = {}; #mediaHelper = new UmbMediaHelper(); - #currentUser?: UmbCurrentUser; - #currentUserContext?: typeof UMB_CURRENT_USER_CONTEXT.TYPE; #plugins: Array UmbTinyMcePluginBase> = []; #editorRef?: Editor | null = null; #stylesheetRepository?: UmbStylesheetRepository; @@ -61,19 +54,6 @@ export class UmbInputTinyMceElement extends FormControlMixin(UmbLitElement) { }); this.#stylesheetRepository = new UmbStylesheetRepository(this); - - // TODO => this breaks tests, removing for now will ignore user language - // and fall back to tinymce default language - // this.consumeContext(UMB_AUTH, (instance) => { - // this.#auth = instance; - // this.#observeCurrentUser(); - // }); - } - - async #observeCurrentUser() { - if (!this.#currentUserContext) return; - - this.observe(this.#currentUserContext.currentUser, (currentUser) => (this.#currentUser = currentUser)); } protected async firstUpdated(_changedProperties: PropertyValueMap | Map): Promise { @@ -117,17 +97,6 @@ export class UmbInputTinyMceElement extends FormControlMixin(UmbLitElement) { const rules: any[] = []; stylesheetPath.forEach((path) => { - //TODO => Legacy path? - /** - * if (val.indexOf(Umbraco.Sys.ServerVariables.umbracoSettings.cssPath + "/") === 0) { - // current format (full path to stylesheet) - stylesheets.push(val); - } - else { - // legacy format (stylesheet name only) - must prefix with stylesheet folder and postfix with ".css" - stylesheets.push(Umbraco.Sys.ServerVariables.umbracoSettings.cssPath + "/" + val + ".css"); - } - */ this.#stylesheetRepository?.getStylesheetRules(path).then(({ data }) => { data?.rules?.forEach((rule) => { const r: { @@ -169,23 +138,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,7 +186,7 @@ export class UmbInputTinyMceElement extends FormControlMixin(UmbLitElement) { 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, // TODO: Cache buster + cache_suffix: `?umb__rnd=${umbMeta.clientVersion}`, contextMenu: false, inline_boundaries_selector: 'a[href],code,.mce-annotation,.umb-embed-holder,.umb-macro-holder', menubar: false, @@ -204,36 +197,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) { @@ -247,7 +216,7 @@ export class UmbInputTinyMceElement extends FormControlMixin(UmbLitElement) { /** * Sets the language to use for TinyMCE */ #setLanguage() { - const localeId = this.#currentUser?.languageIsoCode; + const localeId = this.localize.lang(); //try matching the language using full locale format let languageMatch = availableLanguages.find((x) => localeId?.localeCompare(x) === 0); @@ -268,9 +237,6 @@ export class UmbInputTinyMceElement extends FormControlMixin(UmbLitElement) { #editorSetup(editor: Editor) { editor.suffix = '.min'; - // register custom option maxImageSize - editor.options.register('maxImageSize', { processor: 'number', default: defaultFallbackConfig.maxImageSize }); - // 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 @@ -293,7 +259,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 }))); @@ -307,23 +272,23 @@ export class UmbInputTinyMceElement extends FormControlMixin(UmbLitElement) { this.#onChange(editor.getContent()); }); - 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'; + editor.on('SetContent', (e) => { + /** + * Prevent injecting arbitrary JavaScript execution in on-attributes. + * + * TODO: This used to be toggleable through server variables with window.Umbraco?.Sys.ServerVariables.umbracoSettings.sanitizeTinyMce + */ + 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); + } } - e.stopPropagation(); }); - } + }); + + editor.on('init', () => editor.setContent(this.value?.toString() ?? '')); } #onInit(editor: Editor) { @@ -333,25 +298,13 @@ export class UmbInputTinyMceElement extends FormControlMixin(UmbLitElement) { } #onChange(value: string) { - super.value = value; - 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; + this.value = value; + this.dispatchEvent(new UmbChangeEvent()); } /** * 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/index.ts b/src/Umbraco.Web.UI.Client/src/packages/core/index.ts index e0e9907a0f..fa1cd94e7f 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/index.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/index.ts @@ -29,6 +29,7 @@ export * from './extension-registry/index.js'; export * from './id/index.js'; export * from './macro/index.js'; export * from './menu/index.js'; +export * from './meta/index.js'; export * from './modal/index.js'; export * from './notification/index.js'; export * from './picker-input/index.js'; diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/meta/index.ts b/src/Umbraco.Web.UI.Client/src/packages/core/meta/index.ts new file mode 100644 index 0000000000..90b526bc17 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/core/meta/index.ts @@ -0,0 +1,7 @@ +import packageJson from '../../../../package.json'; + +export const umbMeta = { + name: 'Bellissima', + clientName: packageJson.name, + clientVersion: packageJson.version, +}; diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/property-editor/uis/color-picker/property-editor-ui-color-picker.element.ts b/src/Umbraco.Web.UI.Client/src/packages/core/property-editor/uis/color-picker/property-editor-ui-color-picker.element.ts index 530fbdcfbf..bf54d82db6 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/property-editor/uis/color-picker/property-editor-ui-color-picker.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/property-editor/uis/color-picker/property-editor-ui-color-picker.element.ts @@ -35,6 +35,7 @@ export class UmbPropertyEditorUIColorPickerElement extends UmbLitElement impleme render() { return html``; } 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..d1969701c5 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,9 @@ 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'; +import { UmbTemporaryFileRepository } from '@umbraco-cms/backoffice/temporary-file'; +import { UmbId } from '@umbraco-cms/backoffice/id'; interface MediaPickerTargetData { altText?: string; @@ -29,11 +32,13 @@ export default class UmbTinyMceMediaPickerPlugin extends UmbTinyMcePluginBase { #currentUser?: UmbCurrentUser; #modalContext?: UmbModalManagerContext; #currentUserContext?: typeof UMB_CURRENT_USER_CONTEXT.TYPE; + #temporaryFileRepository; constructor(args: TinyMcePluginArguments) { super(args); this.#mediaHelper = new UmbMediaHelper(); + this.#temporaryFileRepository = new UmbTemporaryFileRepository(args.host); this.consumeContext(UMB_MODAL_MANAGER_CONTEXT_TOKEN, (modalContext) => { this.#modalContext = modalContext; @@ -46,12 +51,39 @@ export default class UmbTinyMceMediaPickerPlugin extends UmbTinyMcePluginBase { // this.#observeCurrentUser(); // }); - this.editor.ui.registry.addButton('umbmediapicker', { + this.editor.ui.registry.addToggleButton('umbmediapicker', { icon: 'image', tooltip: 'Media Picker', - //stateSelector: 'img[data-udi]', TODO => Investigate where stateselector has gone, or if it is still needed onAction: () => this.#onAction(), + onSetup: (api) => { + const changed = this.editor.selection.selectorChangedWithUnbind('img[data-udi]', (state) => + api.setActive(state), + ); + return () => changed.unbind(); + }, }); + + // 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', false); + this.editor.options.set('images_upload_handler', this.#uploadImageHandler); + // This allows images to be pasted in & stored as Base64 until they get uploaded to server + this.editor.options.set('images_replace_blob_uris', true); + + // Listen for SetContent to update images + this.editor.on('SetContent', async (e) => { + const content = e.content; + + // Handle images that are pasted in + this.#mediaHelper.uploadBlobImages(this.editor, content); + }); + } } /* @@ -185,4 +217,38 @@ export default class UmbTinyMceMediaPickerPlugin extends UmbTinyMcePluginBase { } }); } + + #uploadImageHandler: RawEditorOptions['images_upload_handler'] = (blobInfo, progress) => { + return new Promise((resolve, reject) => { + // Fetch does not support progress, so we need to fake it. + progress(0); + + const id = UmbId.new(); + const fileBlob = blobInfo.blob(); + const file = new File([fileBlob], blobInfo.filename(), { type: fileBlob.type }); + + progress(50); + + document.dispatchEvent(new CustomEvent('rte.file.uploading', { composed: true, bubbles: true })); + + this.#temporaryFileRepository + .upload(id, file) + .then((response) => { + if (response.error) { + reject(response.error); + return; + } + + // Put temp location into localstorage (used to update the img with data-tmpimg later on) + const blobUri = window.URL.createObjectURL(fileBlob); + sessionStorage.setItem(`tinymce__${blobUri}`, id); + resolve(blobUri); + }) + .catch(reject) + .finally(() => { + progress(100); + document.dispatchEvent(new CustomEvent('rte.file.uploaded', { composed: true, bubbles: true })); + }); + }); + }; } diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/property-editor/uis/tiny-mce/property-editor-ui-tiny-mce.element.ts b/src/Umbraco.Web.UI.Client/src/packages/core/property-editor/uis/tiny-mce/property-editor-ui-tiny-mce.element.ts index 2c32a2eade..641d384499 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/property-editor/uis/tiny-mce/property-editor-ui-tiny-mce.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/property-editor/uis/tiny-mce/property-editor-ui-tiny-mce.element.ts @@ -4,6 +4,11 @@ import { UmbPropertyEditorUiElement } from '@umbraco-cms/backoffice/extension-re import { UmbLitElement } from '@umbraco-cms/internal/lit-element'; import { UmbPropertyEditorConfigCollection } from '@umbraco-cms/backoffice/property-editor'; +type RichTextEditorValue = { + blocks: object; + markup: string; +}; + /** * @element umb-property-editor-ui-tiny-mce */ @@ -11,8 +16,11 @@ import { UmbPropertyEditorConfigCollection } from '@umbraco-cms/backoffice/prope export class UmbPropertyEditorUITinyMceElement extends UmbLitElement implements UmbPropertyEditorUiElement { #configuration?: UmbPropertyEditorConfigCollection; - @property({ type: String }) - value = ''; + @property({ type: Object }) + value?: RichTextEditorValue = { + blocks: {}, + markup: '', + }; @property({ attribute: false }) public set config(config: UmbPropertyEditorConfigCollection | undefined) { @@ -20,7 +28,10 @@ export class UmbPropertyEditorUITinyMceElement extends UmbLitElement implements } #onChange(event: InputEvent) { - this.value = (event.target as HTMLInputElement).value; + this.value = { + blocks: {}, + markup: (event.target as HTMLInputElement).value, + }; this.dispatchEvent(new CustomEvent('property-value-change')); } @@ -28,7 +39,7 @@ export class UmbPropertyEditorUITinyMceElement extends UmbLitElement implements return html``; + .value=${this.value?.markup ?? ''}>`; } static styles = [UmbTextStyles]; diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/property-editor/uis/tiny-mce/property-editor-ui-tiny-mce.stories.ts b/src/Umbraco.Web.UI.Client/src/packages/core/property-editor/uis/tiny-mce/property-editor-ui-tiny-mce.stories.ts index 14e5bbd6b5..b7671b7cde 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/property-editor/uis/tiny-mce/property-editor-ui-tiny-mce.stories.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/property-editor/uis/tiny-mce/property-editor-ui-tiny-mce.stories.ts @@ -80,7 +80,9 @@ const meta: Meta = { id: 'umb-property-editor-ui-tiny-mce', args: { config: undefined, - value: ` + value: { + blocks: {}, + markup: `

TinyMCE

I am a default value for the TinyMCE text editor story.

@@ -92,6 +94,7 @@ const meta: Meta = { Umbraco documentation

`, + }, }, }; diff --git a/src/Umbraco.Web.UI.Client/src/shared/utils/media-helper.service.ts b/src/Umbraco.Web.UI.Client/src/shared/utils/media-helper.service.ts index b7a4d14010..b90178f579 100644 --- a/src/Umbraco.Web.UI.Client/src/shared/utils/media-helper.service.ts +++ b/src/Umbraco.Web.UI.Client/src/shared/utils/media-helper.service.ts @@ -6,10 +6,7 @@ import type { Editor, EditorEvent } from '@umbraco-cms/backoffice/external/tinym export class UmbMediaHelper { /** - * - * @param editor - * @param imageDomElement - * @param imgUrl + * Sizes an image in the editor */ async sizeImageInEditor(editor: Editor, imageDomElement: HTMLElement, imgUrl?: string) { const size = editor.dom.getSize(imageDomElement); @@ -36,11 +33,7 @@ export class UmbMediaHelper { } /** - * - * @param maxSize - * @param width - * @param height - * @returns + * Scales an image to the max size */ scaleToMaxSize(maxSize: number, width: number, height: number) { const retval = { width, height }; @@ -73,10 +66,7 @@ export class UmbMediaHelper { } /** - * - * @param imagePath - * @param options - * @returns + * Returns the URL of the processed image */ async getProcessedImageUrl(imagePath: string, options: any) { if (!options) { @@ -91,11 +81,10 @@ export class UmbMediaHelper { } /** - * - * @param editor + * Uploads blob images to the server */ - async uploadBlobImages(editor: Editor) { - const content = editor.getContent(); + async uploadBlobImages(editor: Editor, newContent?: string) { + const content = newContent ?? editor.getContent(); // Upload BLOB images (dragged/pasted ones) // find src attribute where value starts with `blob:` @@ -114,7 +103,7 @@ export class UmbMediaHelper { // Get img src const imgSrc = img.getAttribute('src'); - const tmpLocation = localStorage.get(`tinymce__${imgSrc}`); + const tmpLocation = sessionStorage.getItem(`tinymce__${imgSrc}`); // Select the img & add new attr which we can search for // When its being persisted in RTE property editor @@ -136,36 +125,21 @@ export class UmbMediaHelper { blobImageWithNoTmpImgAttribute.forEach((imageElement) => { const blobSrcUri = editor.dom.getAttrib(imageElement, 'src'); - // Find the same image uploaded (Should be in LocalStorage) + // Find the same image uploaded (Should be in SessionStorage) // 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}`); + // So lets fetch the tempurl out of sessionStorage for that blob URI item + const tmpLocation = sessionStorage.getItem(`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 + * Handles the resize event */ async onResize( e: EditorEvent<{ diff --git a/src/Umbraco.Web.UI.Client/tsconfig.json b/src/Umbraco.Web.UI.Client/tsconfig.json index 93e0cfd9d6..f5dadc571b 100644 --- a/src/Umbraco.Web.UI.Client/tsconfig.json +++ b/src/Umbraco.Web.UI.Client/tsconfig.json @@ -73,6 +73,7 @@ "@umbraco-cms/backoffice/localization": ["src/packages/core/localization"], "@umbraco-cms/backoffice/macro": ["src/packages/core/macro"], "@umbraco-cms/backoffice/menu": ["src/packages/core/menu"], + "@umbraco-cms/backoffice/meta": ["src/packages/core/meta"], "@umbraco-cms/backoffice/modal": ["src/packages/core/modal"], "@umbraco-cms/backoffice/notification": ["src/packages/core/notification"], "@umbraco-cms/backoffice/picker-input": ["src/packages/core/picker-input"], diff --git a/src/Umbraco.Web.UI.Client/web-test-runner.config.mjs b/src/Umbraco.Web.UI.Client/web-test-runner.config.mjs index 0b6264800e..23d5ce5960 100644 --- a/src/Umbraco.Web.UI.Client/web-test-runner.config.mjs +++ b/src/Umbraco.Web.UI.Client/web-test-runner.config.mjs @@ -73,6 +73,7 @@ export default { '@umbraco-cms/backoffice/localization': './src/packages/core/localization/index.ts', '@umbraco-cms/backoffice/macro': './src/packages/core/macro/index.ts', '@umbraco-cms/backoffice/menu': './src/packages/core/menu/index.ts', + '@umbraco-cms/backoffice/meta': './src/packages/core/meta/index.ts', '@umbraco-cms/backoffice/modal': './src/packages/core/modal/index.ts', '@umbraco-cms/backoffice/notification': './src/packages/core/notification/index.ts', '@umbraco-cms/backoffice/picker-input': './src/packages/core/picker-input/index.ts',