* 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>
411 lines
13 KiB
TypeScript
411 lines
13 KiB
TypeScript
import { availableLanguages } from './input-tiny-mce.languages.js';
|
|
import { defaultFallbackConfig } from './input-tiny-mce.defaults.js';
|
|
import { pastePreProcessHandler } from './input-tiny-mce.handlers.js';
|
|
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';
|
|
import { UmbChangeEvent } from '@umbraco-cms/backoffice/event';
|
|
import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element';
|
|
import { UmbStylesheetDetailRepository, UmbStylesheetRuleManager } from '@umbraco-cms/backoffice/stylesheet';
|
|
import { UUIFormControlMixin } from '@umbraco-cms/backoffice/external/uui';
|
|
import {
|
|
type EditorEvent,
|
|
type Editor,
|
|
type RawEditorOptions,
|
|
renderEditor,
|
|
} from '@umbraco-cms/backoffice/external/tinymce';
|
|
import type { UmbPropertyEditorConfigCollection } from '@umbraco-cms/backoffice/property-editor';
|
|
|
|
/**
|
|
* Handles the resize event
|
|
* @param e
|
|
*/
|
|
// 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);
|
|
}
|
|
|
|
@customElement('umb-input-tiny-mce')
|
|
export class UmbInputTinyMceElement extends UUIFormControlMixin(UmbLitElement, '') {
|
|
@property({ attribute: false })
|
|
configuration?: UmbPropertyEditorConfigCollection;
|
|
|
|
#plugins: Array<ClassConstructor<UmbTinyMcePluginBase> | undefined> = [];
|
|
#editorRef?: Editor | null = null;
|
|
#stylesheetRepository = new UmbStylesheetDetailRepository(this);
|
|
#umbStylesheetRuleManager = new UmbStylesheetRuleManager();
|
|
|
|
protected override getFormElement() {
|
|
return this._editorElement?.querySelector('iframe') ?? undefined;
|
|
}
|
|
|
|
override set value(newValue: FormDataEntryValue | FormData) {
|
|
super.value = newValue;
|
|
const newContent = newValue?.toString() ?? '';
|
|
|
|
if (this.#editorRef && this.#editorRef.getContent() != newContent) {
|
|
this.#editorRef.setContent(newContent);
|
|
}
|
|
}
|
|
|
|
override get value(): FormDataEntryValue | FormData {
|
|
return super.value;
|
|
}
|
|
|
|
/**
|
|
* Sets the input to readonly mode, meaning value cannot be changed but still able to read and select its content.
|
|
* @type {boolean}
|
|
* @attr
|
|
* @default
|
|
*/
|
|
@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;
|
|
|
|
getEditor() {
|
|
return this.#editorRef;
|
|
}
|
|
|
|
constructor() {
|
|
super();
|
|
this.#loadEditor();
|
|
}
|
|
|
|
async #loadEditor() {
|
|
this.observe(umbExtensionsRegistry.byType('tinyMcePlugin'), async (manifests) => {
|
|
this.#plugins.length = 0;
|
|
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);
|
|
});
|
|
}
|
|
|
|
override disconnectedCallback() {
|
|
super.disconnectedCallback();
|
|
|
|
this.#editorRef?.destroy();
|
|
}
|
|
|
|
/**
|
|
* 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.
|
|
* @param manifests
|
|
*/
|
|
async #loadPlugins(manifests: Array<ManifestTinyMcePlugin>) {
|
|
const promises = [];
|
|
for (const manifest of manifests) {
|
|
if (manifest.js) {
|
|
promises.push(await loadManifestApi(manifest.js));
|
|
}
|
|
if (manifest.api) {
|
|
promises.push(await loadManifestApi(manifest.api));
|
|
}
|
|
}
|
|
return promises;
|
|
}
|
|
|
|
async getFormatStyles(stylesheetPaths: Array<string>) {
|
|
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;
|
|
|
|
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;
|
|
}
|
|
|
|
formatStyles.push(r);
|
|
});
|
|
});
|
|
|
|
return formatStyles;
|
|
}
|
|
|
|
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, '/')}`) ?? [];
|
|
|
|
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');
|
|
if (toolbar && toolbar.length) {
|
|
configurationOptions.toolbar = toolbar?.join(' ');
|
|
} else {
|
|
configurationOptions.toolbar = false;
|
|
}
|
|
|
|
// set the configured inline mode
|
|
const mode = this.configuration?.getValueByAlias<string>('mode');
|
|
if (mode?.toLocaleLowerCase() === 'inline') {
|
|
configurationOptions.inline = true;
|
|
}
|
|
|
|
// set the maximum image size
|
|
const maxImageSize = this.configuration?.getValueByAlias<number>('maxImageSize');
|
|
if (maxImageSize) {
|
|
configurationOptions.maxImageSize = maxImageSize;
|
|
}
|
|
|
|
// set the default values that will not be modified via configuration
|
|
let config: RawEditorOptions = {
|
|
autoresize_bottom_margin: 10,
|
|
body_class: 'umb-rte',
|
|
contextMenu: false,
|
|
inline_boundaries_selector: 'a[href],code,.mce-annotation,.umb-embed-holder,.umb-macro-holder',
|
|
menubar: false,
|
|
paste_remove_styles_if_webkit: true,
|
|
paste_preprocess: pastePreProcessHandler,
|
|
relative_urls: false,
|
|
resize: false,
|
|
statusbar: false,
|
|
setup: (editor) => this.#editorSetup(editor),
|
|
target: this._editorElement,
|
|
paste_data_images: false,
|
|
language: this.#getLanguage(),
|
|
promotion: false,
|
|
convert_unsafe_embeds: true, // [JOV] Workaround for CVE-2024-29881
|
|
readonly: this.#readonly,
|
|
|
|
// Extend with configuration options
|
|
...configurationOptions,
|
|
};
|
|
|
|
// Extend with additional configuration options
|
|
if (additionalConfig) {
|
|
config = umbDeepMerge(additionalConfig, config);
|
|
}
|
|
|
|
this.#editorRef?.destroy();
|
|
|
|
const editors = await renderEditor(config).catch((error) => {
|
|
console.error('Failed to render TinyMCE', error);
|
|
return [];
|
|
});
|
|
this.#editorRef = editors.pop();
|
|
}
|
|
|
|
/**
|
|
* Gets the language to use for TinyMCE
|
|
*/
|
|
#getLanguage() {
|
|
const localeId = this.localize.lang();
|
|
//try matching the language using full locale format
|
|
let languageMatch = availableLanguages.find((x) => localeId?.localeCompare(x) === 0);
|
|
|
|
//if no matches, try matching using only the language
|
|
if (!languageMatch) {
|
|
const localeParts = localeId?.split('_');
|
|
if (localeParts) {
|
|
languageMatch = availableLanguages.find((x) => x === localeParts[0]);
|
|
}
|
|
}
|
|
|
|
return languageMatch;
|
|
}
|
|
|
|
#editorSetup(editor: Editor) {
|
|
editor.suffix = '.min';
|
|
|
|
// define keyboard shortcuts
|
|
editor.addShortcut('Ctrl+S', '', () =>
|
|
this.dispatchEvent(new CustomEvent('rte.shortcut.save', { composed: true, bubbles: true })),
|
|
);
|
|
|
|
editor.addShortcut('Ctrl+P', '', () =>
|
|
this.dispatchEvent(new CustomEvent('rte.shortcut.saveAndPublish', { composed: true, bubbles: true })),
|
|
);
|
|
|
|
// 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 })));
|
|
|
|
editor.on('blur', () => {
|
|
this.#onChange(editor.getContent());
|
|
this.dispatchEvent(new CustomEvent('umb-rte-blur', { composed: true, bubbles: true }));
|
|
});
|
|
|
|
editor.on('ObjectResized', (e) => {
|
|
onResize(e);
|
|
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);
|
|
}
|
|
}
|
|
});
|
|
});
|
|
|
|
// 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
|
|
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.
|
|
new plugin({ host: this, editor });
|
|
}
|
|
}
|
|
}
|
|
|
|
#onInit(editor: Editor) {
|
|
//enable browser based spell checking
|
|
editor.getBody().setAttribute('spellcheck', 'true');
|
|
uriAttributeSanitizer(editor);
|
|
editor.setContent(this.value?.toString() ?? '');
|
|
}
|
|
|
|
#onChange(value: string) {
|
|
this.value = value;
|
|
this.dispatchEvent(new UmbChangeEvent());
|
|
}
|
|
|
|
/**
|
|
* Nothing rendered by default - TinyMCE initialization creates
|
|
* a target div and binds the RTE to that element
|
|
*/
|
|
override render() {
|
|
return html`<div class="editor"></div>`;
|
|
}
|
|
|
|
static override readonly styles = [
|
|
css`
|
|
.tox-tinymce {
|
|
position: relative;
|
|
min-height: 100px;
|
|
border-radius: 0;
|
|
border: var(--uui-input-border-width, 1px) solid var(--uui-input-border-color, var(--uui-color-border, #d8d7d9));
|
|
}
|
|
|
|
.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;
|
|
}
|
|
`,
|
|
];
|
|
}
|
|
|
|
export default UmbInputTinyMceElement;
|
|
|
|
declare global {
|
|
interface HTMLElementTagNameMap {
|
|
'umb-input-tiny-mce': UmbInputTinyMceElement;
|
|
}
|
|
}
|