revamp loose binding between tinymce raweditoroptions and our data type configuration by checking if configuration options exist first
This commit is contained in:
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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>`;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user