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';
|
2023-03-02 16:32:55 +10:00
|
|
|
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';
|
2023-03-02 16:32:55 +10:00
|
|
|
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;
|
2023-03-02 16:32:55 +10:00
|
|
|
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;
|
2023-03-02 16:32:55 +10:00
|
|
|
|
|
|
|
|
protected getFormElement() {
|
|
|
|
|
return undefined;
|
|
|
|
|
}
|
2023-03-02 09:59:51 +10:00
|
|
|
|
|
|
|
|
constructor() {
|
|
|
|
|
super();
|
2023-03-02 16:32:55 +10:00
|
|
|
|
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;
|
2023-03-02 16:32:55 +10:00
|
|
|
|
|
|
|
|
// 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,
|
2023-03-02 16:32:55 +10:00
|
|
|
setup: (editor: any) => this.#editorSetup(editor),
|
|
|
|
|
};
|
|
|
|
|
}
|
2023-03-02 12:56:48 +10:00
|
|
|
|
2023-03-02 16:32:55 +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
|
|
|
|
2023-03-02 16:32:55 +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
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
2023-03-02 16:32:55 +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, {
|
2023-03-02 16:32:55 +10:00
|
|
|
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);
|
2023-03-02 16:32:55 +10:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// 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);
|
2023-03-02 16:32:55 +10:00
|
|
|
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 }));
|
|
|
|
|
}
|
|
|
|
|
|
2023-03-02 16:32:55 +10:00
|
|
|
#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;
|
|
|
|
|
}
|
|
|
|
|
}
|