Files
Umbraco-CMS/src/Umbraco.Web.UI.Client/src/backoffice/shared/components/input-tiny-mce/input-tiny-mce.element.ts

487 lines
13 KiB
TypeScript
Raw Normal View History

import { css, html } from 'lit';
2023-03-02 09:59:51 +10:00
import { UUITextStyles } from '@umbraco-ui/uui-css/lib';
2023-03-03 13:51:57 +10:00
import { customElement, property, state } from 'lit/decorators.js';
2023-03-02 12:56:48 +10:00
import { ifDefined } from 'lit-html/directives/if-defined.js';
2023-03-02 09:59:51 +10:00
import { FormControlMixin } from '@umbraco-ui/uui-base/lib/mixins';
import { AstNode, Editor, EditorEvent } from 'tinymce';
import { firstValueFrom } from 'rxjs';
2023-03-21 11:54:28 +10:00
import { UmbModalContext, UMB_MODAL_CONTEXT_TOKEN } from '@umbraco-cms/modal';
import { UmbMediaHelper } from '../../property-editors/uis/tiny-mce/media-helper.service';
2023-03-03 10:09:58 +10:00
import {
UmbCurrentUserStore,
UMB_CURRENT_USER_STORE_CONTEXT_TOKEN,
} from '../../../users/current-user/current-user.store';
import { TinyMcePluginArguments } from '../../property-editors/uis/tiny-mce/plugins/tiny-mce-plugin';
2023-03-02 09:59:51 +10:00
import { UmbLitElement } from '@umbraco-cms/element';
2023-03-03 10:09:58 +10:00
import type { UserDetails } from '@umbraco-cms/models';
2023-03-21 11:54:28 +10:00
import { DataTypePropertyPresentationModel } from '@umbraco-cms/backend-api';
import { umbExtensionsRegistry } from '@umbraco-cms/extensions-api';
import { ManifestTinyMcePlugin } from 'libs/extensions-registry/tinymce-plugin.model';
import '../../../../../public-assets/tiny-mce/tinymce.min.js';
2023-03-02 12:56:48 +10:00
2023-03-02 09:59:51 +10:00
declare global {
interface Window {
tinymce: any;
Umbraco: any;
2023-03-02 09:59:51 +10:00
}
}
@customElement('umb-input-tiny-mce')
export class UmbInputTinyMceElement extends FormControlMixin(UmbLitElement) {
static styles = [
UUITextStyles,
css`
#editor {
position: relative;
min-height: 100px;
}
.tox-tinymce-aux {
z-index: 9000;
}
.tox-tinymce-inline {
z-index: 900;
}
.tox-tinymce-fullscreen {
position: absolute;
}
/* FIXME: Remove this workaround when https://github.com/tinymce/tinymce/issues/6431 has been fixed */
.tox .tox-collection__item-label {
line-height: 1 !important;
}
`,
];
2023-03-02 09:59:51 +10:00
@property()
2023-03-21 11:54:28 +10:00
configuration: Array<DataTypePropertyPresentationModel> = [];
2023-03-02 09:59:51 +10:00
2023-03-21 11:54:28 +10:00
// TODO => type this
2023-03-03 13:51:57 +10:00
@state()
private _configObject: any = {};
2023-03-02 09:59:51 +10:00
private _styleFormats = [
{
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' },
],
},
];
2023-03-03 13:51:57 +10:00
// 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',
];
2023-03-02 09:59:51 +10:00
2023-03-03 13:51:57 +10:00
//define fallback language
#defaultLanguage = 'en_US';
//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',
],
mode: 'classic',
2023-03-03 13:51:57 +10:00
stylesheets: [],
maxImageSize: 500,
};
2023-03-02 09:59:51 +10:00
2023-03-02 17:15:52 +10:00
#currentUserStore?: UmbCurrentUserStore;
2023-03-03 11:29:42 +10:00
modalContext!: UmbModalContext;
2023-03-03 13:51:57 +10:00
#mediaHelper = new UmbMediaHelper(this);
2023-03-02 17:15:52 +10:00
currentUser?: UserDetails;
protected getFormElement() {
return undefined;
}
2023-03-02 09:59:51 +10:00
constructor() {
super();
2023-03-03 10:09:58 +10:00
this.consumeContext(UMB_MODAL_CONTEXT_TOKEN, (instance) => {
this.modalContext = instance;
2023-03-02 09:59:51 +10:00
});
2023-03-02 17:15:52 +10:00
this.consumeContext(UMB_CURRENT_USER_STORE_CONTEXT_TOKEN, (instance) => {
this.#currentUserStore = instance;
this.#observeCurrentUser();
});
}
async #observeCurrentUser() {
if (!this.#currentUserStore) return;
this.observe(this.#currentUserStore.currentUser, (currentUser) => {
this.currentUser = currentUser;
});
2023-03-02 09:59:51 +10:00
}
connectedCallback() {
2023-03-02 12:56:48 +10:00
super.connectedCallback();
2023-03-02 09:59:51 +10:00
// create an object by merging the configuration onto the fallback config
Object.assign(
this._configObject,
this.#fallbackConfig,
this.configuration ? Object.fromEntries(this.configuration.map((x) => [x.alias, x.value])) : {}
);
// no auto resize when a fixed height is set
2023-03-03 13:51:57 +10:00
if (!this._configObject.dimensions?.height) {
2023-03-21 11:54:28 +10:00
this._configObject.plugins ??= [];
2023-03-03 13:51:57 +10:00
this._configObject.plugins.splice(this._configObject.plugins.indexOf('autoresize'), 1);
}
this.#setTinyConfig();
2023-03-02 09:59:51 +10:00
}
#setTinyConfig() {
const target = document.createElement('div');
target.id = 'editor';
this.shadowRoot?.appendChild(target);
// set the default values that will not be modified via configuration
const tinyConfig: { [key: string]: any } = {
2023-03-03 13:51:57 +10:00
autoresize_bottom_margin: 10,
base_url: '/public-assets/tiny-mce',
2023-03-03 13:51:57 +10:00
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,
2023-03-02 09:59:51 +10:00
contextMenu: false,
2023-03-03 13:51:57 +10:00
language: () => this.#getLanguage(),
2023-03-02 12:56:48 +10:00
menubar: false,
2023-03-03 13:51:57 +10:00
paste_remove_styles_if_webkit: true,
paste_preprocess: (_: Editor, args: { content: string }) => this.#cleanupPasteData(args),
relative_urls: false,
2023-03-02 09:59:51 +10:00
resize: false,
target,
2023-03-02 12:56:48 +10:00
statusbar: false,
2023-03-03 10:09:58 +10:00
setup: (editor: Editor) => this.#editorSetup(editor),
};
2023-03-03 13:51:57 +10:00
const plugins: Array<string> = this._configObject.plugins.map((x: any) => x.name);
const toolbar = this._configObject.toolbar.join(' ');
2023-03-03 16:23:30 +10:00
// extend with configuration values
Object.assign(tinyConfig, {
content_css: this._configObject.stylesheets.join(','),
extended_valid_elements: this.#extendedValidElements,
height: ifDefined(this._configObject.dimensions?.height),
invalid_elements: this._configObject.invalidElements,
plugins,
2023-03-08 17:00:46 +10:00
toolbar,
style_formats: this._styleFormats,
valid_elements: this._configObject.validElements,
width: ifDefined(this._configObject.dimensions?.width),
});
2023-03-03 13:51:57 +10:00
// 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()) {
Object.assign(tinyConfig, {
// Update the TinyMCE Config object to allow pasting
images_upload_handler: this.#mediaHelper.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,
});
2023-03-03 13:51:57 +10:00
}
window.tinymce.init(tinyConfig);
2023-03-03 13:51:57 +10:00
}
#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, '<strong$1>$2</strong$3>');
// convert i to em
args.content = args.content.replace(/<\s*i([^>]*)>(.*?)<\s*\/\s*i([^>]*)>/g, '<em$1>$2</em$3>');
}
/**
* 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;
}
2023-03-02 12:56:48 +10:00
#editorSetup(editor: Editor) {
editor.suffix = '.min';
2023-03-06 11:57:24 +10:00
// register custom option maxImageSize
editor.options.register('maxImageSize', { processor: 'number', default: this.#fallbackConfig.maxImageSize });
2023-03-06 11:57:24 +10:00
// register all plugins from manifests
// these receive the default args below, but can also
// provide their own args. Generally though, the additional
// args would be managed in the plugin
(async () => {
const pluginArgs: TinyMcePluginArguments = {
2023-03-21 11:54:28 +10:00
host: this,
editor,
configuration: this.configuration,
};
const observable = umbExtensionsRegistry?.extensionsOfType('tinyMcePlugin');
const plugins = (await firstValueFrom(observable)) as ManifestTinyMcePlugin[];
plugins.forEach((p) => new p.meta.api(pluginArgs, p.meta.args));
})();
2023-03-06 11:57:24 +10:00
// define keyboard shortcuts
editor.addShortcut('Ctrl+S', '', () => this.dispatchEvent(new CustomEvent('rte.shortcut.save')));
editor.addShortcut('Ctrl+P', '', () => this.dispatchEvent(new CustomEvent('rte.shortcut.saveAndPublish')));
// bind editor events
editor.on('init', () => this.#onInit(editor));
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 })));
2023-03-06 11:57:24 +10:00
editor.on('blur', () => {
this.#onChange(editor.getContent());
this.dispatchEvent(new CustomEvent('umb-rte-blur', { composed: true, bubbles: true }));
});
editor.on('ObjectResized', (e) => {
this.#mediaHelper.onResize(e);
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
2023-03-03 13:51:57 +10:00
if (this._configObject.toolbar && !this.#isMediaPickerEnabled()) {
// Wire up the event listener
2023-03-03 10:09:58 +10:00
editor.on('dragstart dragend dragover draggesture dragdrop drop drag', (e: EditorEvent<InputEvent>) => {
e.preventDefault();
2023-03-03 10:09:58 +10:00
if (e.dataTransfer) {
e.dataTransfer.effectAllowed = 'none';
e.dataTransfer.dropEffect = 'none';
}
e.stopPropagation();
});
}
2023-03-02 09:59:51 +10:00
}
2023-03-03 10:09:58 +10:00
#onInit(editor: Editor) {
//enable browser based spell checking
2023-03-03 10:09:58 +10:00
editor.getBody().setAttribute('spellcheck', 'true');
/** Setup sanitization for preventing injecting arbitrary JavaScript execution in attributes:
* https://github.com/advisories/GHSA-w7jx-j77m-wp65
* https://github.com/advisories/GHSA-5vm8-hhgr-jcjp
*/
const uriAttributesToSanitize = [
'src',
'href',
'data',
'background',
'action',
'formaction',
'poster',
'xlink:href',
];
const parseUri = (function () {
// Encapsulated JS logic.
const safeSvgDataUrlElements = ['img', 'video'];
const scriptUriRegExp = /((java|vb)script|mhtml):/i;
// eslint-disable-next-line no-control-regex
const trimRegExp = /[\s\u0000-\u001F]+/g;
const isInvalidUri = (uri: string, tagName: string) => {
if (/^data:image\//i.test(uri)) {
return safeSvgDataUrlElements.indexOf(tagName) !== -1 && /^data:image\/svg\+xml/i.test(uri);
} else {
return /^data:/i.test(uri);
}
};
return function parseUri(uri: string, tagName: string) {
uri = uri.replace(trimRegExp, '');
try {
// Might throw malformed URI sequence
uri = decodeURIComponent(uri);
} catch (ex) {
// Fallback to non UTF-8 decoder
uri = unescape(uri);
}
if (scriptUriRegExp.test(uri)) {
return;
}
if (isInvalidUri(uri, tagName)) {
return;
}
return uri;
};
})();
if (window.Umbraco?.Sys.ServerVariables.umbracoSettings.sanitizeTinyMce) {
2023-03-03 10:09:58 +10:00
uriAttributesToSanitize.forEach((attribute) => {
editor.serializer.addAttributeFilter(attribute, (nodes: AstNode[]) => {
nodes.forEach((node: AstNode) => {
node.attributes?.forEach((attr) => {
if (uriAttributesToSanitize.includes(attr.name.toLowerCase())) {
attr.value = parseUri(attr.value, node.name) ?? '';
}
});
});
});
});
}
}
2023-03-02 09:59:51 +10:00
#onChange(value: string) {
super.value = value;
this.dispatchEvent(new CustomEvent('change', { bubbles: true, composed: true }));
}
#isMediaPickerEnabled() {
2023-03-03 13:51:57 +10:00
return this._configObject.toolbar.includes('umbmediapicker');
}
2023-03-02 09:59:51 +10:00
render() {
return html``;
2023-03-02 09:59:51 +10:00
}
}
export default UmbInputTinyMceElement;
declare global {
interface HTMLElementTagNameMap {
'umb-input-tiny-mce': UmbInputTinyMceElement;
}
}