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

392 lines
12 KiB
TypeScript
Raw Normal View History

2023-03-02 09:59:51 +10:00
import { html } from 'lit';
import { UUITextStyles } from '@umbraco-ui/uui-css/lib';
import { customElement, property } 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 } from 'tinymce';
import { UmbMediaHelper } from '../../property-editors/uis/tiny-mce/media-helper.service';
2023-03-02 09:59:51 +10:00
import { AcePlugin } from '../../property-editors/uis/tiny-mce/plugins/ace.plugin';
import { LinkPickerPlugin } from '../../property-editors/uis/tiny-mce/plugins/linkpicker.plugin';
import { MacroPlugin } from '../../property-editors/uis/tiny-mce/plugins/macro.plugin';
2023-03-02 09:59:51 +10:00
import { UmbLitElement } from '@umbraco-cms/element';
import { UmbModalService, UMB_MODAL_SERVICE_CONTEXT_TOKEN } from '@umbraco-cms/modal';
/// TINY MCE
// import 'tinymce';
2023-03-02 12:56:48 +10:00
import '@tinymce/tinymce-webcomponent';
2023-03-02 17:15:52 +10:00
import { MediaPickerPlugin } from '../../property-editors/uis/tiny-mce/plugins/mediapicker.plugin';
import { UmbCurrentUserStore, UMB_CURRENT_USER_STORE_CONTEXT_TOKEN } from 'src/backoffice/users/current-user/current-user.store';
import type { UserDetails } from '@umbraco-cms/models';
2023-03-02 12:56:48 +10:00
2023-03-02 09:59:51 +10:00
// /* Default icons are required. After that, import custom icons if applicable */
// import 'tinymce/icons/default';
// /* Required TinyMCE components */
// import 'tinymce/themes/silver';
// import 'tinymce/models/dom';
// /* Import a skin (can be a custom skin instead of the default) */
2023-03-02 12:56:48 +10:00
// import 'tinymce/skins/ui/oxide/skin.shadowdom.css';
// /* content UI CSS is required */
// import contentUiSkinCss from 'tinymce/skins/ui/oxide/content.css';
// /* The default content CSS can be changed or replaced with appropriate CSS for the editor content. */
// import contentCss from 'tinymce/skins/content/default/content.css';
2023-03-02 09:59:51 +10:00
// /* Import plugins */
// import 'tinymce/plugins/advlist';
2023-03-02 12:56:48 +10:00
// import 'tinymce/plugins/anchor';
// import 'tinymce/plugins/autolink';
// import 'tinymce/plugins/charmap';
// import 'tinymce/plugins/directionality';
2023-03-02 09:59:51 +10:00
// import 'tinymce/plugins/lists';
2023-03-02 12:56:48 +10:00
// import 'tinymce/plugins/searchreplace';
2023-03-02 09:59:51 +10:00
// import 'tinymce/plugins/table';
declare global {
interface Window {
tinyConfig: any;
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];
@property()
configuration: Array<any> = [];
@property()
private _dimensions?: { [key: string]: number };
@property()
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' },
],
},
];
@property({ type: Array<string> })
private _toolbar: Array<string> = [];
@property({ type: Array<string> })
private _plugins: Array<string> = [];
@property({ type: Array<string> })
private _stylesheets: Array<string> = [];
2023-03-02 12:56:48 +10:00
// @property({ type: String })
// private _contentStyle: string = contentUiSkinCss.toString() + '\n' + contentCss.toString();
2023-03-02 17:15:52 +10:00
#currentUserStore?: UmbCurrentUserStore;
2023-03-02 09:59:51 +10:00
modalService?: UmbModalService;
2023-03-02 17:15:52 +10:00
#mediaHelper = new UmbMediaHelper();
currentUser?: UserDetails;
protected getFormElement() {
return undefined;
}
2023-03-02 09:59:51 +10:00
constructor() {
super();
2023-03-02 09:59:51 +10:00
this.consumeContext(UMB_MODAL_SERVICE_CONTEXT_TOKEN, (instance) => {
this.modalService = instance;
this.#setTinyConfig();
});
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
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;
// no auto resize when a fixed height is set
if (!this._dimensions.height) {
this._plugins.splice(this._plugins.indexOf('autoresize'), 1);
}
2023-03-02 09:59:51 +10:00
}
// TODO => setup runs before rendering, here we can add any custom plugins
// TODO => fix TinyMCE type definitions
#setTinyConfig() {
window.tinyConfig = {
2023-03-02 14:46:31 +10:00
content_css: false,
2023-03-02 09:59:51 +10:00
contextMenu: false,
2023-03-02 12:56:48 +10:00
convert_urls: false,
menubar: false,
2023-03-02 09:59:51 +10:00
resize: false,
2023-03-02 12:56:48 +10:00
//skin: false,
statusbar: false,
2023-03-02 09:59:51 +10:00
style_formats: this._styleFormats,
setup: (editor: any) => this.#editorSetup(editor),
};
}
2023-03-02 12:56:48 +10:00
#editorSetup(editor: any) {
// initialise core plugins
new AcePlugin(editor, this.modalService);
new LinkPickerPlugin(editor, this.modalService, this.configuration);
new MacroPlugin(editor, this.modalService);
2023-03-02 17:15:52 +10:00
new MediaPickerPlugin(editor, this.configuration, this.modalService, this.currentUser);
2023-03-02 09:59:51 +10:00
// register custom option maxImageSize
editor.options.register('maxImageSize', { processor: 'number', default: 500 });
// 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()) {
// Wire up the event listener
editor.on('dragstart dragend dragover draggesture dragdrop drop drag', (e: any) => {
e.preventDefault();
e.dataTransfer.effectAllowed = 'none';
e.dataTransfer.dropEffect = 'none';
e.stopPropagation();
});
}
editor.addShortcut('Ctrl+S', '', () => this.dispatchEvent(new CustomEvent('rte.shortcut.save')));
editor.addShortcut('Ctrl+P', '', () => this.dispatchEvent(new CustomEvent('rte.shortcut.saveAndPublish')));
editor.on('init', () => this.#onInit(editor));
editor.on('focus', () => this.dispatchEvent(new CustomEvent('umb-rte-focus', { composed: true, bubbles: true })));
editor.on('blur', () => {
this.#onChange(editor.getContent());
this.dispatchEvent(new CustomEvent('umb-rte-blur', { composed: true, bubbles: true }));
});
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('ObjectResized', (e: any) => {
this.#onResize(e);
this.#onChange(editor.getContent());
});
2023-03-02 09:59:51 +10:00
}
async #onResize(e: any) {
const srcAttr = e.target.getAttribute('src');
if (!srcAttr) {
return;
}
const path = srcAttr.split('?')[0];
2023-03-02 17:15:52 +10:00
const resizedPath = await this.#mediaHelper.getProcessedImageUrl(path, {
width: e.width,
height: e.height,
mode: 'max',
});
e.target.setAttribute('data-mce-src', resizedPath);
}
#onInit(editor: any) {
//enable browser based spell checking
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) {
editor.serializer.addAttributeFilter(uriAttributesToSanitize, (nodes: AstNode[]) => {
nodes.forEach((node: AstNode) => {
node.attributes?.forEach((attr) => {
const attrName = attr.name.toLowerCase();
if (uriAttributesToSanitize.indexOf(attrName) !== -1) {
attr.value = parseUri(attr.value, node.name) ?? '';
}
});
});
});
}
}
async #uploadBlobImages(editor: any) {
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: any) => {
// 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
2023-03-02 17:15:52 +10:00
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: any) => {
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) {
2023-03-02 17:15:52 +10:00
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.prototype.slice.call(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);
}
}
});
}
}
2023-03-02 09:59:51 +10:00
#onChange(value: string) {
super.value = value;
this.dispatchEvent(new CustomEvent('change', { bubbles: true, composed: true }));
}
#isMediaPickerEnabled() {
return this._toolbar.includes('umbmediapicker');
}
2023-03-02 09:59:51 +10:00
render() {
return html` <tinymce-editor
config="tinyConfig"
2023-03-02 12:56:48 +10:00
width=${ifDefined(this._dimensions?.width)}
height=${ifDefined(this._dimensions?.height)}
2023-03-02 09:59:51 +10:00
plugins=${this._plugins.join(' ')}
toolbar=${this._toolbar.join(' ')}
2023-03-02 14:46:31 +10:00
content_css=${this._stylesheets.join(',')}
2023-03-02 09:59:51 +10:00
>${this.value}</tinymce-editor
>`;
}
}
export default UmbInputTinyMceElement;
declare global {
interface HTMLElementTagNameMap {
'umb-input-tiny-mce': UmbInputTinyMceElement;
}
}