move functions to media service
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
import { html } from 'lit';
|
||||
import { UUITextStyles } from '@umbraco-ui/uui-css/lib';
|
||||
import { customElement, property } from 'lit/decorators.js';
|
||||
import { customElement, property, state } from 'lit/decorators.js';
|
||||
import { ifDefined } from 'lit-html/directives/if-defined.js';
|
||||
import { FormControlMixin } from '@umbraco-ui/uui-base/lib/mixins';
|
||||
import { AstNode, Editor, EditorEvent, TinyMCE } from 'tinymce';
|
||||
@@ -9,6 +9,7 @@ import { TinyMceCodeEditorPlugin } from '../../property-editors/uis/tiny-mce/plu
|
||||
import { TinyMceLinkPickerPlugin } from '../../property-editors/uis/tiny-mce/plugins/tiny-mce-linkpicker.plugin';
|
||||
import { TinyMceMacroPlugin } from '../../property-editors/uis/tiny-mce/plugins/tiny-mce-macro.plugin';
|
||||
import { TinyMceMediaPickerPlugin } from '../../property-editors/uis/tiny-mce/plugins/tiny-mce-mediapicker.plugin';
|
||||
import { TinyMceEmbeddedMediaPlugin } from '../../property-editors/uis/tiny-mce/plugins/tiny-mce-embeddedmedia.plugin';
|
||||
import {
|
||||
UmbCurrentUserStore,
|
||||
UMB_CURRENT_USER_STORE_CONTEXT_TOKEN,
|
||||
@@ -63,10 +64,9 @@ export class UmbInputTinyMceElement extends FormControlMixin(UmbLitElement) {
|
||||
@property()
|
||||
configuration: Array<DataTypePropertyModel> = [];
|
||||
|
||||
@property()
|
||||
private _dimensions?: { [key: string]: number };
|
||||
@state()
|
||||
private _configObject: any = {};
|
||||
|
||||
@property()
|
||||
private _styleFormats = [
|
||||
{
|
||||
title: 'Headers',
|
||||
@@ -89,21 +89,123 @@ export class UmbInputTinyMceElement extends FormControlMixin(UmbLitElement) {
|
||||
},
|
||||
];
|
||||
|
||||
@property({ type: Array<string> })
|
||||
private _toolbar: Array<string> = [];
|
||||
// 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',
|
||||
];
|
||||
|
||||
@property({ type: Array<string> })
|
||||
private _plugins: Array<string> = [];
|
||||
//define fallback language
|
||||
#defaultLanguage = 'en_US';
|
||||
|
||||
@property({ type: Array<string> })
|
||||
private _stylesheets: Array<string> = [];
|
||||
//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',
|
||||
],
|
||||
stylesheets: [],
|
||||
maxImageSize: 500,
|
||||
};
|
||||
|
||||
// @property({ type: String })
|
||||
// private _contentStyle: string = contentUiSkinCss.toString() + '\n' + contentCss.toString();
|
||||
|
||||
#currentUserStore?: UmbCurrentUserStore;
|
||||
modalContext!: UmbModalContext;
|
||||
#mediaHelper = new UmbMediaHelper();
|
||||
#mediaHelper = new UmbMediaHelper(this);
|
||||
currentUser?: UserDetails;
|
||||
|
||||
protected getFormElement() {
|
||||
@@ -135,33 +237,74 @@ export class UmbInputTinyMceElement extends FormControlMixin(UmbLitElement) {
|
||||
connectedCallback() {
|
||||
super.connectedCallback();
|
||||
|
||||
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;
|
||||
this._configObject = Object.fromEntries(
|
||||
(this.configuration ?? this.#fallbackConfig).map((x) => [x.alias, x.value])
|
||||
);
|
||||
|
||||
// no auto resize when a fixed height is set
|
||||
if (!this._dimensions.height) {
|
||||
this._plugins.splice(this._plugins.indexOf('autoresize'), 1);
|
||||
if (!this._configObject.dimensions?.height) {
|
||||
this._configObject.plugins.splice(this._configObject.plugins.indexOf('autoresize'), 1);
|
||||
}
|
||||
}
|
||||
|
||||
// TODO => setup runs before rendering, here we can add any custom plugins
|
||||
// TODO => fix TinyMCE type definitions
|
||||
#setTinyConfig() {
|
||||
window.tinyConfig = {
|
||||
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,
|
||||
content_css: false,
|
||||
contextMenu: false,
|
||||
convert_urls: false,
|
||||
language: () => this.#getLanguage(),
|
||||
menubar: false,
|
||||
paste_remove_styles_if_webkit: true,
|
||||
paste_preprocess: (_: Editor, args: { content: string }) => this.#cleanupPasteData(args),
|
||||
relative_urls: false,
|
||||
resize: false,
|
||||
//skin: false,
|
||||
statusbar: false,
|
||||
style_formats: this._styleFormats,
|
||||
setup: (editor: Editor) => this.#editorSetup(editor),
|
||||
};
|
||||
|
||||
// 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()) {
|
||||
// Update the TinyMCE Config object to allow pasting
|
||||
window.tinyConfig.images_upload_handler = this.#mediaHelper.uploadImageHandler;
|
||||
window.tinyConfig.automatic_uploads = false;
|
||||
window.tinyConfig.images_replace_blob_uris = false;
|
||||
|
||||
// This allows images to be pasted in & stored as Base64 until they get uploaded to server
|
||||
window.tinyConfig.paste_data_images = true;
|
||||
}
|
||||
}
|
||||
|
||||
#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;
|
||||
}
|
||||
|
||||
#editorSetup(editor: Editor) {
|
||||
@@ -170,15 +313,16 @@ export class UmbInputTinyMceElement extends FormControlMixin(UmbLitElement) {
|
||||
new TinyMceLinkPickerPlugin(editor, this.modalContext, this.configuration);
|
||||
new TinyMceMacroPlugin(editor, this.modalContext);
|
||||
new TinyMceMediaPickerPlugin(editor, this.modalContext, this.configuration, this.currentUser);
|
||||
new TinyMceEmbeddedMediaPlugin(editor, this.modalContext);
|
||||
|
||||
// register custom option maxImageSize
|
||||
editor.options.register('maxImageSize', { processor: 'number', default: 500 });
|
||||
editor.options.register('maxImageSize', { processor: 'number', default: this.#fallbackConfig.maxImageSize });
|
||||
|
||||
// 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()) {
|
||||
if (this._configObject.toolbar && !this.#isMediaPickerEnabled()) {
|
||||
// Wire up the event listener
|
||||
editor.on('dragstart dragend dragover draggesture dragdrop drop drag', (e: EditorEvent<InputEvent>) => {
|
||||
e.preventDefault();
|
||||
@@ -202,37 +346,13 @@ 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.#uploadBlobImages(editor));
|
||||
editor.on('SetContent', () => this.#mediaHelper.uploadBlobImages(editor));
|
||||
editor.on('ObjectResized', (e) => {
|
||||
this.#onResize(e);
|
||||
this.#mediaHelper.onResize(e);
|
||||
this.#onChange(editor.getContent());
|
||||
});
|
||||
}
|
||||
|
||||
async #onResize(
|
||||
e: EditorEvent<{
|
||||
target: HTMLElement;
|
||||
width: number;
|
||||
height: number;
|
||||
origin: string;
|
||||
}>
|
||||
) {
|
||||
const srcAttr = e.target.getAttribute('src');
|
||||
|
||||
if (!srcAttr) {
|
||||
return;
|
||||
}
|
||||
|
||||
const path = srcAttr.split('?')[0];
|
||||
const resizedPath = await this.#mediaHelper.getProcessedImageUrl(path, {
|
||||
width: e.width,
|
||||
height: e.height,
|
||||
mode: 'max',
|
||||
});
|
||||
|
||||
e.target.setAttribute('data-mce-src', resizedPath);
|
||||
}
|
||||
|
||||
#onInit(editor: Editor) {
|
||||
//enable browser based spell checking
|
||||
editor.getBody().setAttribute('spellcheck', 'true');
|
||||
@@ -304,91 +424,29 @@ export class UmbInputTinyMceElement extends FormControlMixin(UmbLitElement) {
|
||||
}
|
||||
}
|
||||
|
||||
async #uploadBlobImages(editor: Editor) {
|
||||
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) => {
|
||||
// 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
|
||||
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) => {
|
||||
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) {
|
||||
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.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);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
#onChange(value: string) {
|
||||
super.value = value;
|
||||
this.dispatchEvent(new CustomEvent('change', { bubbles: true, composed: true }));
|
||||
}
|
||||
|
||||
#isMediaPickerEnabled() {
|
||||
return this._toolbar.includes('umbmediapicker');
|
||||
return this._configObject.toolbar.includes('umbmediapicker');
|
||||
}
|
||||
|
||||
render() {
|
||||
return html` <tinymce-editor
|
||||
config="tinyConfig"
|
||||
width=${ifDefined(this._dimensions?.width)}
|
||||
height=${ifDefined(this._dimensions?.height)}
|
||||
plugins=${this._plugins.join(' ')}
|
||||
toolbar=${this._toolbar.join(' ')}
|
||||
content_css=${this._stylesheets.join(',')}
|
||||
content_css=${this._configObject.stylesheets.join(',')}
|
||||
extended_valid_elements=${this.#extendedValidElements}
|
||||
height=${ifDefined(this._configObject.dimensions?.height)}
|
||||
invalid_elements=${this._configObject.invalidElements}
|
||||
plugins=${this._configObject.plugins.map((x: any) => x.name).join(' ')}
|
||||
quickbars_insert_toolbar=${this._configObject.toolbar.join(' ')}
|
||||
quickbars_selection_toolbar=${this._configObject.toolbar.join(' ')}
|
||||
.style_formats=${this._styleFormats}
|
||||
toolbar=${this._configObject.toolbar.join(' ')}
|
||||
valid_elements=${this._configObject.validElements}
|
||||
width=${ifDefined(this._configObject.dimensions?.width)}
|
||||
>${this.value}</tinymce-editor
|
||||
>`;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,24 @@
|
||||
// TODO => very much temporary
|
||||
|
||||
import { Editor, EditorEvent } from "tinymce";
|
||||
import { UmbLitElement } from "@umbraco-cms/element";
|
||||
|
||||
export class UmbMediaHelper {
|
||||
async sizeImageInEditor(editor: any, imageDomElement: HTMLElement, imgUrl?: string) {
|
||||
|
||||
#host: UmbLitElement;
|
||||
|
||||
constructor(host: UmbLitElement) {
|
||||
this.#host = host;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
*
|
||||
* @param editor
|
||||
* @param imageDomElement
|
||||
* @param imgUrl
|
||||
*/
|
||||
async sizeImageInEditor(editor: Editor, imageDomElement: HTMLElement, imgUrl?: string) {
|
||||
const size = editor.dom.getSize(imageDomElement);
|
||||
const maxImageSize = editor.options.get('maxImageSize');
|
||||
|
||||
@@ -21,11 +38,17 @@ export class UmbMediaHelper {
|
||||
editor.dom.setAttrib(imageDomElement, 'data-mce-src', resizedImgUrl);
|
||||
}
|
||||
|
||||
editor.execCommand('mceAutoResize', false, null, null);
|
||||
editor.execCommand('mceAutoResize', false);
|
||||
}
|
||||
}
|
||||
|
||||
// Transplanted from mediahelper
|
||||
/**
|
||||
*
|
||||
* @param maxSize
|
||||
* @param width
|
||||
* @param height
|
||||
* @returns
|
||||
*/
|
||||
scaleToMaxSize(maxSize: number, width: number, height: number) {
|
||||
const retval = { width, height };
|
||||
|
||||
@@ -56,6 +79,12 @@ export class UmbMediaHelper {
|
||||
return retval;
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param imagePath
|
||||
* @param options
|
||||
* @returns
|
||||
*/
|
||||
async getProcessedImageUrl(imagePath: string, options: any) {
|
||||
if (!options) {
|
||||
return imagePath;
|
||||
@@ -63,6 +92,170 @@ export class UmbMediaHelper {
|
||||
|
||||
const result = await fetch('/umbraco/management/api/v1/images/GetProcessedImageUrl');
|
||||
|
||||
return result;
|
||||
return result as any;
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param editor
|
||||
*/
|
||||
async uploadBlobImages(editor: Editor) {
|
||||
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) => {
|
||||
// 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
|
||||
this.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) => {
|
||||
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) {
|
||||
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
|
||||
*/
|
||||
async onResize(
|
||||
e: EditorEvent<{
|
||||
target: HTMLElement;
|
||||
width: number;
|
||||
height: number;
|
||||
origin: string;
|
||||
}>
|
||||
) {
|
||||
const srcAttr = e.target.getAttribute('src');
|
||||
|
||||
if (!srcAttr) {
|
||||
return;
|
||||
}
|
||||
|
||||
const path = srcAttr.split('?')[0];
|
||||
const resizedPath = await this.getProcessedImageUrl(path, {
|
||||
width: e.width,
|
||||
height: e.height,
|
||||
mode: 'max',
|
||||
});
|
||||
|
||||
e.target.setAttribute('data-mce-src', resizedPath);
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param blobInfo
|
||||
* @param progress
|
||||
* @returns
|
||||
*/
|
||||
uploadImageHandler(blobInfo: any, progress: any) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const xhr = new XMLHttpRequest();
|
||||
xhr.open('POST', window.Umbraco?.Sys.ServerVariables.umbracoUrls.tinyMceApiBaseUrl + 'UploadImage');
|
||||
|
||||
xhr.onloadstart = () => this.#host.dispatchEvent(new CustomEvent('rte.file.uploading'));
|
||||
|
||||
xhr.onloadend = () => this.#host.dispatchEvent(new CustomEvent('rte.file.uploaded'));
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
// TODO => confirm this is required given no more Angular handling XHR/HTTP
|
||||
const data = xhr.responseText.split('\n');
|
||||
|
||||
if (data.length <= 1) {
|
||||
reject('Unrecognized text string: ' + data);
|
||||
return;
|
||||
}
|
||||
|
||||
let json: { [key: string]: string } = {};
|
||||
|
||||
try {
|
||||
json = JSON.parse(data[1]);
|
||||
} catch (e: any) {
|
||||
reject('Invalid JSON: ' + data + ' - ' + e.message);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!json || typeof json.tmpLocation !== 'string') {
|
||||
reject('Invalid JSON: ' + data);
|
||||
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.blob().name);
|
||||
|
||||
xhr.send(formData);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
import { Editor } from 'tinymce';
|
||||
import { UmbModalContext } from '@umbraco-cms/modal';
|
||||
|
||||
interface EmbeddedMediaModalData {
|
||||
url?: string;
|
||||
width?: number;
|
||||
height?: number;
|
||||
constrain?: string;
|
||||
}
|
||||
|
||||
export class TinyMceEmbeddedMediaPlugin {
|
||||
#modalContext: UmbModalContext;
|
||||
editor: Editor;
|
||||
@@ -9,25 +16,73 @@ export class TinyMceEmbeddedMediaPlugin {
|
||||
this.#modalContext = modalContext;
|
||||
this.editor = editor;
|
||||
|
||||
editor.ui.registry.addButton('ace', {
|
||||
icon: 'sourcecode',
|
||||
tooltip: 'View Source Code',
|
||||
onAction: () => this.#showCodeEditor(),
|
||||
editor.ui.registry.addButton('umbembeddialog', {
|
||||
icon: 'embed',
|
||||
tooltip: 'Embed',
|
||||
onAction: () => this.#onAction(),
|
||||
});
|
||||
}
|
||||
|
||||
async #showCodeEditor() {
|
||||
const modalHandler = this.#modalContext?.codeEditor({
|
||||
headline: 'Edit source code',
|
||||
content: this.editor.getContent(),
|
||||
#onAction() {
|
||||
// Get the selected element
|
||||
// Check nodename is a DIV and the claslist contains 'embeditem'
|
||||
const selectedElm = this.editor.selection.getNode();
|
||||
const nodeName = selectedElm.nodeName;
|
||||
let modify: EmbeddedMediaModalData = {};
|
||||
|
||||
if (nodeName.toUpperCase() === "DIV" && selectedElm.classList.contains("embeditem")) {
|
||||
// See if we can go and get the attributes
|
||||
const embedUrl = this.editor.dom.getAttrib(selectedElm, "data-embed-url");
|
||||
const embedWidth = this.editor.dom.getAttrib(selectedElm, "data-embed-width");
|
||||
const embedHeight = this.editor.dom.getAttrib(selectedElm, "data-embed-height");
|
||||
const embedConstrain = this.editor.dom.getAttrib(selectedElm, "data-embed-constrain");
|
||||
|
||||
modify = {
|
||||
url: embedUrl,
|
||||
width: parseInt(embedWidth) || 0,
|
||||
height: parseInt(embedHeight) || 0,
|
||||
constrain: embedConstrain
|
||||
};
|
||||
}
|
||||
|
||||
this.#showModal(selectedElm, modify);
|
||||
}
|
||||
|
||||
#insertInEditor(embed: any, activeElement: HTMLElement) {
|
||||
// Wrap HTML preview content here in a DIV with non-editable class of .mceNonEditable
|
||||
// This turns it into a selectable/cutable block to move about
|
||||
const wrapper = this.editor.dom.create('div',
|
||||
{
|
||||
'class': 'mceNonEditable embeditem',
|
||||
'data-embed-url': embed.url,
|
||||
'data-embed-height': embed.height,
|
||||
'data-embed-width': embed.width,
|
||||
'data-embed-constrain': embed.constrain,
|
||||
'contenteditable': false
|
||||
},
|
||||
embed.preview);
|
||||
|
||||
// Only replace if activeElement is an Embed element.
|
||||
if (activeElement && activeElement.nodeName.toUpperCase() === "DIV" && activeElement.classList.contains("embeditem")) {
|
||||
activeElement.replaceWith(wrapper); // directly replaces the html node
|
||||
} else {
|
||||
this.editor.selection.setNode(wrapper);
|
||||
}
|
||||
}
|
||||
|
||||
// TODO => update when embed modal exists
|
||||
async #showModal(selectedElm: HTMLElement, modify: EmbeddedMediaModalData) {
|
||||
const modalHandler = this.#modalContext?.openBasic({
|
||||
header: 'Embedded media picker modal',
|
||||
content: 'Here be the picker',
|
||||
});
|
||||
|
||||
if (!modalHandler) return;
|
||||
|
||||
const { confirmed, content } = await modalHandler.onClose();
|
||||
if (!confirmed) return;
|
||||
const result = await modalHandler.onClose();
|
||||
if (!result) return;
|
||||
|
||||
this.editor.setContent(content);
|
||||
this.editor.dispatch('Change');
|
||||
this.#insertInEditor(result, selectedElm);
|
||||
this.editor.dispatch('Change');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -97,37 +97,25 @@ export class TinyMceLinkPickerPlugin {
|
||||
createLinkPickerCallback(currentTarget, anchorElm);
|
||||
}
|
||||
|
||||
const editorEventSetupCallback = (buttonApi: { setEnabled: (state: boolean) => void }) => {
|
||||
const editorEventCallback = (eventApi: { element: Element}) => {
|
||||
buttonApi.setEnabled(eventApi.element.nodeName.toLowerCase() === 'a' && eventApi.element.hasAttribute('href'));
|
||||
};
|
||||
// const editorEventSetupCallback = (buttonApi: { setEnabled: (state: boolean) => void }) => {
|
||||
// const editorEventCallback = (eventApi: { element: Element}) => {
|
||||
// buttonApi.setEnabled(eventApi.element.nodeName.toLowerCase() === 'a' && eventApi.element.hasAttribute('href'));
|
||||
// };
|
||||
|
||||
editor.on('NodeChange', editorEventCallback);
|
||||
return () => editor.off('NodeChange', editorEventCallback);
|
||||
};
|
||||
// editor.on('NodeChange', editorEventCallback);
|
||||
// return () => editor.off('NodeChange', editorEventCallback);
|
||||
// };
|
||||
|
||||
editor.ui.registry.addButton('link', {
|
||||
icon: 'link',
|
||||
tooltip: 'Insert/edit link',
|
||||
onAction: showDialog,
|
||||
onSetup: editorEventSetupCallback,
|
||||
});
|
||||
|
||||
editor.ui.registry.addButton('unlink', {
|
||||
icon: 'unlink',
|
||||
tooltip: 'Remove link',
|
||||
onAction: () => editor.execCommand('unlink'),
|
||||
onSetup: editorEventSetupCallback,
|
||||
});
|
||||
|
||||
editor.ui.registry.addMenuItem('link', {
|
||||
icon: 'link',
|
||||
text: 'Insert link',
|
||||
shortcut: 'Ctrl+K',
|
||||
onAction: showDialog,
|
||||
onSetup: editorEventSetupCallback,
|
||||
//context: 'insert',
|
||||
//prependToContext: true,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -126,7 +126,7 @@ export class TinyMceMacroPlugin {
|
||||
// });
|
||||
}
|
||||
|
||||
#insertMacroInEditor(macroObject: MacroSyntaxData, activeMacroElement?: HTMLElement) {
|
||||
#insertInEditor(macroObject: MacroSyntaxData, activeMacroElement?: HTMLElement) {
|
||||
//Important note: the TinyMce plugin "noneditable" is used here so that the macro cannot be edited,
|
||||
// for this to work the mceNonEditable class needs to come last and we also need to use the attribute contenteditable = false
|
||||
// (even though all the docs and examples say that is not necessary)
|
||||
@@ -196,7 +196,7 @@ export class TinyMceMacroPlugin {
|
||||
const { confirmed } = await modalHandler.onClose();
|
||||
if (!confirmed) return;
|
||||
|
||||
this.#insertMacroInEditor({} as MacroSyntaxData, dialogData.activeMacroElement);
|
||||
this.#insertInEditor({} as MacroSyntaxData, dialogData.activeMacroElement);
|
||||
this.editor.dispatch('Change');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -102,12 +102,12 @@ export class TinyMceMediaPickerPlugin {
|
||||
const { selection } = await modalHandler.onClose();
|
||||
if (!selection.length) return;
|
||||
|
||||
this.#insertMediaInEditor(selection[0]);
|
||||
this.#insertInEditor(selection[0]);
|
||||
this.editor.dispatch('Change');
|
||||
}
|
||||
|
||||
// TODO => mediaPicker returns a UDI, so need to fetch it. Wait for backend CLI before implementing
|
||||
async #insertMediaInEditor(img: any) {
|
||||
async #insertInEditor(img: any) {
|
||||
if (!img) return;
|
||||
|
||||
// We need to create a NEW DOM <img> element to insert
|
||||
|
||||
@@ -484,6 +484,12 @@ export const data: Array<DataTypeModel & { type: 'data-type' }> = [
|
||||
{ alias: 'maxImageSize', value: 500 },
|
||||
{ alias: 'mode', value: 'classic' },
|
||||
{ alias: 'ignoreUserStartNodes', value: false },
|
||||
{
|
||||
alias: 'validElements',
|
||||
value:
|
||||
'+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,video[*],audio[*],picture[*],source[*],canvas[*]',
|
||||
},
|
||||
{ alias: 'invalidElements', value: 'font' },
|
||||
{ alias: 'stylesheets', value: ['/css/dropdownStyles.css'] },
|
||||
{
|
||||
alias: 'toolbar',
|
||||
|
||||
Reference in New Issue
Block a user