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

411 lines
13 KiB
TypeScript
Raw Normal View History

2023-06-22 11:09:06 +02:00
import { availableLanguages } from './input-tiny-mce.languages.js';
2024-04-25 13:50:29 +01:00
import { defaultFallbackConfig } from './input-tiny-mce.defaults.js';
import { pastePreProcessHandler } from './input-tiny-mce.handlers.js';
2023-06-22 11:09:06 +02:00
import { uriAttributeSanitizer } from './input-tiny-mce.sanitizer.js';
import type { UmbTinyMcePluginBase } from './tiny-mce-plugin.js';
import { type ClassConstructor, loadManifestApi } from '@umbraco-cms/backoffice/extension-api';
import { css, customElement, html, property, query } from '@umbraco-cms/backoffice/external/lit';
import { getProcessedImageUrl, umbDeepMerge } from '@umbraco-cms/backoffice/utils';
import { type ManifestTinyMcePlugin, umbExtensionsRegistry } from '@umbraco-cms/backoffice/extension-registry';
2024-04-25 13:50:29 +01:00
import { UmbChangeEvent } from '@umbraco-cms/backoffice/event';
2024-02-09 22:43:57 +01:00
import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element';
import { UmbStylesheetDetailRepository, UmbStylesheetRuleManager } from '@umbraco-cms/backoffice/stylesheet';
2024-04-25 13:50:29 +01:00
import { UUIFormControlMixin } from '@umbraco-cms/backoffice/external/uui';
import {
type EditorEvent,
type Editor,
type RawEditorOptions,
renderEditor,
} from '@umbraco-cms/backoffice/external/tinymce';
2024-04-25 13:50:29 +01:00
import type { UmbPropertyEditorConfigCollection } from '@umbraco-cms/backoffice/property-editor';
2024-02-23 13:47:14 +01:00
/**
* Handles the resize event
2024-08-06 13:28:42 +02:00
* @param e
2024-02-23 13:47:14 +01:00
*/
// TODO: This does somehow not belong as a utility method as it is very specific to this implementation. [NL]
async function 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 getProcessedImageUrl(path, {
width: e.width,
height: e.height,
mode: 'max',
});
e.target.setAttribute('data-mce-src', resizedPath);
}
2023-03-02 09:59:51 +10:00
@customElement('umb-input-tiny-mce')
export class UmbInputTinyMceElement extends UUIFormControlMixin(UmbLitElement, '') {
2023-06-29 13:06:02 +02:00
@property({ attribute: false })
2023-09-06 14:58:07 +02:00
configuration?: UmbPropertyEditorConfigCollection;
2023-03-02 09:59:51 +10:00
2024-06-03 14:31:15 +02:00
#plugins: Array<ClassConstructor<UmbTinyMcePluginBase> | undefined> = [];
#editorRef?: Editor | null = null;
#stylesheetRepository = new UmbStylesheetDetailRepository(this);
#umbStylesheetRuleManager = new UmbStylesheetRuleManager();
2024-06-25 09:16:45 +02:00
protected override getFormElement() {
2023-06-29 13:20:33 +02:00
return this._editorElement?.querySelector('iframe') ?? undefined;
}
2023-03-02 09:59:51 +10:00
2024-06-21 10:54:17 +02:00
override set value(newValue: FormDataEntryValue | FormData) {
super.value = newValue;
2024-01-22 00:01:55 +01:00
const newContent = newValue?.toString() ?? '';
if (this.#editorRef && this.#editorRef.getContent() != newContent) {
2024-01-22 00:01:55 +01:00
this.#editorRef.setContent(newContent);
}
}
2024-06-21 10:54:17 +02:00
override get value(): FormDataEntryValue | FormData {
return super.value;
2024-01-22 00:01:55 +01:00
}
2024-08-20 11:16:46 +02:00
/**
* Sets the input to readonly mode, meaning value cannot be changed but still able to read and select its content.
* @type {boolean}
* @attr
2024-09-14 20:55:06 +02:00
* @default
2024-08-20 11:16:46 +02:00
*/
@property({ type: Boolean, reflect: true })
public get readonly() {
return this.#readonly;
}
public set readonly(value) {
this.#readonly = value;
const editor = this.getEditor();
const mode = value ? 'readonly' : 'design';
editor?.mode.set(mode);
}
#readonly = false;
@query('.editor', true)
private _editorElement?: HTMLElement;
2024-05-28 01:08:22 +02:00
getEditor() {
return this.#editorRef;
}
constructor() {
super();
this.#loadEditor();
}
async #loadEditor() {
2024-06-03 14:31:15 +02:00
this.observe(umbExtensionsRegistry.byType('tinyMcePlugin'), async (manifests) => {
this.#plugins.length = 0;
2024-06-03 14:31:15 +02:00
this.#plugins = await this.#loadPlugins(manifests);
let config: RawEditorOptions = {};
manifests.forEach((manifest) => {
if (manifest.meta?.config) {
config = umbDeepMerge(manifest.meta.config, config);
}
});
this.#setTinyConfig(config);
});
2023-03-02 09:59:51 +10:00
}
2024-06-21 10:54:17 +02:00
override disconnectedCallback() {
super.disconnectedCallback();
this.#editorRef?.destroy();
}
2023-03-28 17:51:39 +10:00
/**
* Load all custom plugins - need to split loading and instantiating as these
* need the editor instance as a ctor argument. If we load them in the editor
* setup method, the asynchronous nature means the editor is loaded before
* the plugins are ready and so are not associated with the editor.
2024-08-06 13:28:42 +02:00
* @param manifests
2023-03-28 17:51:39 +10:00
*/
2024-06-03 14:31:15 +02:00
async #loadPlugins(manifests: Array<ManifestTinyMcePlugin>) {
const promises = [];
2023-11-14 11:44:28 +01:00
for (const manifest of manifests) {
if (manifest.js) {
2024-06-03 14:31:15 +02:00
promises.push(await loadManifestApi(manifest.js));
}
if (manifest.api) {
2024-06-03 14:31:15 +02:00
promises.push(await loadManifestApi(manifest.api));
}
}
return promises;
}
async getFormatStyles(stylesheetPaths: Array<string>) {
2024-01-15 16:26:57 +01:00
if (!stylesheetPaths) return [];
const formatStyles: any[] = [];
const promises = stylesheetPaths.map((path) => this.#stylesheetRepository?.requestByUnique(path));
const stylesheetResponses = await Promise.all(promises);
stylesheetResponses.forEach(({ data }) => {
if (!data?.content) return;
2024-01-15 16:26:57 +01:00
const rulesFromContent = this.#umbStylesheetRuleManager.extractRules(data.content);
rulesFromContent.forEach((rule) => {
const r: {
title?: string;
inline?: string;
classes?: string;
attributes?: Record<string, string>;
block?: string;
} = {
title: rule.name,
};
if (!rule.selector) return;
if (rule.selector.startsWith('.')) {
r.inline = 'span';
r.classes = rule.selector.substring(1);
} else if (rule.selector.startsWith('#')) {
r.inline = 'span';
r.attributes = { id: rule.selector.substring(1) };
} else if (rule.selector.includes('.')) {
const [block, ...classes] = rule.selector.split('.');
r.block = block;
r.classes = classes.join(' ').replace(/\./g, ' ');
} else if (rule.selector.includes('#')) {
const [block, id] = rule.selector.split('#');
r.block = block;
r.classes = id;
} else {
r.block = rule.selector;
}
2023-10-31 16:33:12 +01:00
2024-01-15 16:26:57 +01:00
formatStyles.push(r);
2023-10-31 16:33:12 +01:00
});
});
2024-01-15 16:26:57 +01:00
return formatStyles;
2023-10-31 16:33:12 +01:00
}
async #setTinyConfig(additionalConfig?: RawEditorOptions) {
const dimensions = this.configuration?.getValueByAlias<{ width?: number; height?: number }>('dimensions');
const stylesheetPaths = this.configuration?.getValueByAlias<string[]>('stylesheets') ?? [];
const styleFormats = await this.getFormatStyles(stylesheetPaths);
// Map the stylesheets with server url
const stylesheets =
stylesheetPaths?.map((stylesheetPath: string) => `/css${stylesheetPath.replace(/\\/g, '/')}`) ?? [];
2024-01-30 11:54:40 +01:00
stylesheets.push('/umbraco/backoffice/css/rte-content.css');
// create an object by merging the configuration onto the fallback config
const configurationOptions: RawEditorOptions = {
...defaultFallbackConfig,
height: dimensions?.height ?? defaultFallbackConfig.height,
width: dimensions?.width ?? defaultFallbackConfig.width,
content_css: stylesheets.length ? stylesheets : defaultFallbackConfig.content_css,
style_formats: styleFormats.length ? styleFormats : defaultFallbackConfig.style_formats,
};
// no auto resize when a fixed height is set
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, otherwise false
const toolbar = this.configuration?.getValueByAlias<string[]>('toolbar');
2024-03-25 12:19:47 +01:00
if (toolbar && toolbar.length) {
configurationOptions.toolbar = toolbar?.join(' ');
} else {
configurationOptions.toolbar = false;
}
// set the configured inline mode
2024-03-25 14:07:49 +01:00
const mode = this.configuration?.getValueByAlias<string>('mode');
if (mode?.toLocaleLowerCase() === 'inline') {
configurationOptions.inline = true;
}
// set the maximum image size
2024-03-25 16:09:47 +01:00
const maxImageSize = this.configuration?.getValueByAlias<number>('maxImageSize');
if (maxImageSize) {
configurationOptions.maxImageSize = maxImageSize;
}
2023-06-22 11:09:06 +02:00
// set the default values that will not be modified via configuration
let config: RawEditorOptions = {
2023-03-03 13:51:57 +10:00
autoresize_bottom_margin: 10,
body_class: 'umb-rte',
2023-03-02 09:59:51 +10:00
contextMenu: false,
2024-01-30 11:54:40 +01:00
inline_boundaries_selector: 'a[href],code,.mce-annotation,.umb-embed-holder,.umb-macro-holder',
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: pastePreProcessHandler,
2023-03-03 13:51:57 +10:00
relative_urls: false,
2023-03-02 09:59:51 +10:00
resize: false,
2023-03-02 12:56:48 +10:00
statusbar: false,
setup: (editor) => this.#editorSetup(editor),
target: this._editorElement,
paste_data_images: false,
2024-06-03 14:31:15 +02:00
language: this.#getLanguage(),
2024-06-04 09:13:54 +02:00
promotion: false,
convert_unsafe_embeds: true, // [JOV] Workaround for CVE-2024-29881
2024-08-20 11:16:46 +02:00
readonly: this.#readonly,
2023-03-03 13:51:57 +10:00
// Extend with configuration options
...configurationOptions,
};
// Extend with additional configuration options
if (additionalConfig) {
config = umbDeepMerge(additionalConfig, config);
}
2024-06-03 14:31:15 +02:00
this.#editorRef?.destroy();
const editors = await renderEditor(config).catch((error) => {
console.error('Failed to render TinyMCE', error);
return [];
});
this.#editorRef = editors.pop();
2023-03-27 10:57:11 +10:00
}
2023-03-03 13:51:57 +10:00
/**
* Gets the language to use for TinyMCE
2024-08-06 13:28:42 +02:00
*/
#getLanguage() {
const localeId = this.localize.lang();
2023-03-03 13:51:57 +10:00
//try matching the language using full locale format
let languageMatch = availableLanguages.find((x) => localeId?.localeCompare(x) === 0);
2023-03-03 13:51:57 +10:00
//if no matches, try matching using only the language
if (!languageMatch) {
const localeParts = localeId?.split('_');
if (localeParts) {
2023-05-17 12:16:08 +10:00
languageMatch = availableLanguages.find((x) => x === localeParts[0]);
2023-03-03 13:51:57 +10:00
}
}
return languageMatch;
}
2023-03-02 12:56:48 +10:00
#editorSetup(editor: Editor) {
editor.suffix = '.min';
2023-03-06 11:57:24 +10:00
// define keyboard shortcuts
editor.addShortcut('Ctrl+S', '', () =>
2023-10-03 15:11:52 +02:00
this.dispatchEvent(new CustomEvent('rte.shortcut.save', { composed: true, bubbles: true })),
);
editor.addShortcut('Ctrl+P', '', () =>
2023-10-03 15:11:52 +02:00
this.dispatchEvent(new CustomEvent('rte.shortcut.saveAndPublish', { composed: true, bubbles: true })),
);
2023-03-06 11:57:24 +10:00
// 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('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) => {
2024-02-23 13:47:14 +01:00
onResize(e);
2023-03-06 11:57:24 +10:00
this.#onChange(editor.getContent());
});
editor.on('SetContent', () => {
/**
* Prevent injecting arbitrary JavaScript execution in on-attributes.
*
* TODO: This used to be toggleable through server variables with window.Umbraco?.Sys.ServerVariables.umbracoSettings.sanitizeTinyMce
*/
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);
}
}
});
});
2024-06-03 14:11:23 +02:00
2024-06-03 14:31:15 +02:00
// instantiate plugins to ensure they are available before setting up the editor.
// Plugins require a reference to the current editor as a param, so can not
// be instantiated until we have an editor
2024-06-03 14:31:15 +02:00
for (const plugin of this.#plugins) {
if (plugin) {
// [v15]: This might be improved by changing to `createExtensionApi` and avoiding the `#loadPlugins` method altogether, but that would require a breaking change
// because that function sends the UmbControllerHost as the first argument, which is not the case here.
2024-06-03 14:31:15 +02:00
new plugin({ host: this, editor });
}
2024-06-03 14:31:15 +02:00
}
2023-03-02 09:59:51 +10:00
}
#onInit(editor: Editor) {
//enable browser based spell checking
2023-03-03 10:09:58 +10:00
editor.getBody().setAttribute('spellcheck', 'true');
uriAttributeSanitizer(editor);
2024-06-03 14:11:23 +02:00
editor.setContent(this.value?.toString() ?? '');
}
2023-03-02 09:59:51 +10:00
#onChange(value: string) {
this.value = value;
this.dispatchEvent(new UmbChangeEvent());
2023-03-02 09:59:51 +10:00
}
/**
2024-02-23 13:47:14 +01:00
* Nothing rendered by default - TinyMCE initialization creates
* a target div and binds the RTE to that element
*/
2024-06-21 11:40:28 +02:00
override render() {
return html`<div class="editor"></div>`;
2023-03-02 09:59:51 +10:00
}
Feature: Tiptap blockpicker (#2335) * fix: editor is always available * fix: remove deprecated v14 stuff * fix: the block manager should not care about the editor * fix: the block manager should not care about the editor * feat: add new tiptap blockpicker extension * fix: save valid content * fix: disable white-space to conform blocks inside text * fix: set block types back to TinyMCE until migration has been completed * feat: define block content when inserting * feat: make `getLayouts` available on the base class * fix: remove unused parameter * feat: cleanup blocks on change * feat: adds inline blocks * feat: set docs for typings and update the interfaces to match and add setEditor to get the editor instance * feat: set docs for typings and update the interfaces to match and add setEditor to get the editor instance * feat: adds blocks in rte * chore: sonarcloud fix * feat: remove delete button as components can be stripped away directly from the DOM * feat: allow custom views for block-rte and filter the views based on conditions * feat: mark tiptap blocks with an outline when active * feat: export data content udi const * fix: add block-rte to vite's importmap so that tinymce works on the dev server * feat(tinymce): get the value from the event target * feat: allow tinymce to insert blocks by listening to the context * chore: mark styles as readonly * chore: cleanup code * fix: remove two fixed TODO comments * feat: used named capturing group * chore: import correct type in testing file * Removed extra `originData` from Block List manager context * Fixed issues in Tiptap toolbar button * Corrected base class for Tiptap Image extension * Fixed up the RTE package vite config to export the Tiptap classes (for CMS build) --------- Co-authored-by: leekelleher <leekelleher@gmail.com>
2024-09-24 19:40:02 +02:00
static override readonly styles = [
css`
.tox-tinymce {
position: relative;
min-height: 100px;
2023-06-26 11:34:06 +10:00
border-radius: 0;
2023-06-29 21:08:41 +02:00
border: var(--uui-input-border-width, 1px) solid var(--uui-input-border-color, var(--uui-color-border, #d8d7d9));
2023-06-26 11:34:06 +10:00
}
.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
}
export default UmbInputTinyMceElement;
declare global {
interface HTMLElementTagNameMap {
'umb-input-tiny-mce': UmbInputTinyMceElement;
}
}