2023-12-19 15:49:01 +01:00
import { pastePreProcessHandler } from './input-tiny-mce.handlers.js' ;
2024-02-23 22:57:39 +01:00
import { defaultFallbackConfig } from './input-tiny-mce.defaults.js' ;
2023-06-22 11:09:06 +02:00
import { availableLanguages } from './input-tiny-mce.languages.js' ;
import { uriAttributeSanitizer } from './input-tiny-mce.sanitizer.js' ;
2024-02-23 22:57:39 +01:00
import type { TinyMcePluginArguments , UmbTinyMcePluginBase } from './tiny-mce-plugin.js' ;
import { getProcessedImageUrl } from '@umbraco-cms/backoffice/utils' ;
2023-09-05 17:03:37 +02:00
import { FormControlMixin } from '@umbraco-cms/backoffice/external/uui' ;
2024-02-23 13:47:14 +01:00
import type { EditorEvent , Editor , RawEditorOptions } from '@umbraco-cms/backoffice/external/tinymce' ;
2023-12-19 09:19:08 +01:00
import { loadManifestApi } from '@umbraco-cms/backoffice/extension-api' ;
2024-01-24 15:09:54 +01:00
import { type ManifestTinyMcePlugin , umbExtensionsRegistry } from '@umbraco-cms/backoffice/extension-registry' ;
2024-01-24 19:54:31 +01:00
import { css , customElement , html , property , query , state } from '@umbraco-cms/backoffice/external/lit' ;
2023-06-22 11:09:06 +02:00
import { firstValueFrom } from '@umbraco-cms/backoffice/external/rxjs' ;
2024-02-09 22:43:57 +01:00
import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element' ;
2024-01-24 19:54:31 +01:00
import type { UmbPropertyEditorConfigCollection } from '@umbraco-cms/backoffice/property-editor' ;
2024-01-15 16:12:48 +01:00
import { UmbStylesheetDetailRepository , UmbStylesheetRuleManager } from '@umbraco-cms/backoffice/stylesheet' ;
2024-01-09 10:57:22 +01:00
import { UmbChangeEvent } from '@umbraco-cms/backoffice/event' ;
2023-06-21 11:05:33 +10:00
2024-02-23 13:47:14 +01:00
/ * *
* Handles the resize event
* /
// 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 FormControlMixin ( 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
2023-03-03 13:51:57 +10:00
@state ( )
2023-11-08 11:16:15 +01:00
private _tinyConfig : RawEditorOptions = { } ;
2023-03-02 09:59:51 +10:00
2024-01-24 19:54:31 +01:00
// eslint-disable-next-line @typescript-eslint/consistent-type-imports
2024-01-24 15:09:54 +01:00
# renderEditor? : typeof import ( '@umbraco-cms/backoffice/external/tinymce' ) . renderEditor ;
2023-04-21 13:07:28 +10:00
# plugins : Array < new ( args : TinyMcePluginArguments ) = > UmbTinyMcePluginBase > = [ ] ;
2023-11-08 11:16:15 +01:00
# editorRef? : Editor | null = null ;
2024-03-04 11:42:28 +01:00
# stylesheetRepository = new UmbStylesheetDetailRepository ( this ) ;
2024-01-15 16:12:48 +01:00
# umbStylesheetRuleManager = new UmbStylesheetRuleManager ( ) ;
2023-03-02 16:32:55 +10:00
protected getFormElement() {
2023-06-29 13:20:33 +02:00
return this . _editorElement ? . querySelector ( 'iframe' ) ? ? undefined ;
2023-03-02 16:32:55 +10:00
}
2023-03-02 09:59:51 +10:00
2024-01-22 00:01:55 +01:00
set value ( newValue : FormDataEntryValue | FormData ) {
2024-01-24 21:17:53 +01:00
super . value = newValue ;
2024-01-22 00:01:55 +01:00
const newContent = newValue ? . toString ( ) ? ? '' ;
2024-01-30 11:21:53 +01:00
if ( this . # editorRef && this . # editorRef . getContent ( ) != newContent ) {
2024-01-22 00:01:55 +01:00
this . # editorRef . setContent ( newContent ) ;
}
}
2024-01-30 11:21:53 +01:00
2024-01-22 00:01:55 +01:00
get value ( ) : FormDataEntryValue | FormData {
2024-01-29 10:09:24 +01:00
return super . value ;
2024-01-22 00:01:55 +01:00
}
2023-06-22 14:30:55 +02:00
@query ( '#editor' , true )
private _editorElement? : HTMLElement ;
2024-03-04 11:42:28 +01:00
protected async firstUpdated ( ) : Promise < void > {
2024-01-24 15:09:54 +01:00
// Here we want to start the loading of everything at first, not one at a time, which is why this code is not using await.
const loadEditor = import ( '@umbraco-cms/backoffice/external/tinymce' ) . then ( ( tinyMce ) = > {
this . # renderEditor = tinyMce . renderEditor ;
} ) ;
await Promise . all ( [ loadEditor , . . . ( await this . # loadPlugins ( ) ) ] ) ;
2023-06-22 14:12:32 +02:00
await this . # setTinyConfig ( ) ;
2023-03-02 09:59:51 +10:00
}
2023-06-21 14:58:52 +10:00
disconnectedCallback() {
super . disconnectedCallback ( ) ;
2023-06-22 14:13:12 +02:00
if ( this . # editorRef ) {
2023-06-29 13:15:47 +02:00
// TODO: Test if there is any problems with destroying the RTE here, but not initializing on connectedCallback. (firstUpdated is only called first time the element is rendered, not when it is reconnected)
2023-06-22 14:13:12 +02:00
this . # editorRef . destroy ( ) ;
}
2023-06-21 14:58:52 +10:00
}
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 .
* /
2023-03-28 14:52:07 +10:00
async # loadPlugins() {
2024-01-26 13:25:51 +01:00
const observable = umbExtensionsRegistry ? . byType ( 'tinyMcePlugin' ) ;
2023-11-14 11:44:28 +01:00
const manifests = ( await firstValueFrom ( observable ) ) as ManifestTinyMcePlugin [ ] ;
2023-03-28 14:52:07 +10:00
2024-01-24 15:09:54 +01:00
const promises = [ ] ;
2023-11-14 11:44:28 +01:00
for ( const manifest of manifests ) {
2024-01-24 15:09:54 +01:00
if ( manifest . js ) {
promises . push (
loadManifestApi ( manifest . js ) . then ( ( plugin ) = > {
if ( plugin ) {
this . # plugins . push ( plugin ) ;
}
} ) ,
) ;
}
if ( manifest . api ) {
promises . push (
loadManifestApi ( manifest . api ) . then ( ( plugin ) = > {
if ( plugin ) {
this . # plugins . push ( plugin ) ;
}
} ) ,
) ;
2023-03-28 14:52:07 +10:00
}
}
2024-01-24 15:09:54 +01:00
return promises ;
2023-03-28 14:52:07 +10:00
}
2024-01-15 16:12:48 +01:00
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 ) 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 ;
}
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
}
2023-06-22 14:30:55 +02:00
async # setTinyConfig() {
2023-12-19 15:49:01 +01:00
const dimensions = this . configuration ? . getValueByAlias < { width? : number ; height? : number } > ( 'dimensions' ) ;
2024-01-15 16:12:48 +01:00
const stylesheetPaths = this . configuration ? . getValueByAlias < string [ ] > ( 'stylesheets' ) ? ? [ ] ;
const styleFormats = await this . getFormatStyles ( stylesheetPaths ) ;
2023-12-19 15:49:01 +01:00
// Map the stylesheets with server url
const stylesheets =
2024-03-04 11:42:28 +01:00
stylesheetPaths ? . map ( ( stylesheetPath : string ) = > ` /css ${ stylesheetPath . replace ( /\\/g , '/' ) } ` ) ? ? [ ] ;
2023-12-19 15:49:01 +01:00
2024-01-30 11:54:40 +01:00
stylesheets . push ( '/umbraco/backoffice/css/rte-content.css' ) ;
2023-06-21 11:05:33 +10:00
// create an object by merging the configuration onto the fallback config
2023-12-19 15:49:01 +01:00
const configurationOptions : RawEditorOptions = {
2023-06-22 14:30:24 +02:00
. . . defaultFallbackConfig ,
2023-12-19 15:49:01 +01:00
height : dimensions?.height ,
width : dimensions?.width ,
content_css : stylesheets ,
style_formats : styleFormats ,
2023-06-22 14:30:24 +02:00
} ;
2023-06-21 11:05:33 +10:00
// no auto resize when a fixed height is set
2023-12-19 15:49:01 +01:00
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
const toolbar = this . configuration ? . getValueByAlias < string [ ] > ( 'toolbar' ) ;
if ( toolbar ) {
configurationOptions . toolbar = toolbar . join ( ' ' ) ;
}
// set the configured inline mode
2024-03-25 11:42:42 +01:00
const mode = this . configuration ? . getValueByAlias < Array < string > > ( 'mode' ) ;
if ( mode ? . [ 0 ] . toLocaleLowerCase ( ) === 'inline' ) {
2023-12-19 15:49:01 +01:00
configurationOptions . inline = true ;
}
// set the maximum image size
2024-03-25 11:42:42 +01:00
const maxImageSize = this . configuration ? . getValueByAlias < string > ( 'maxImageSize' ) ;
2023-12-19 15:49:01 +01:00
if ( maxImageSize !== undefined ) {
configurationOptions . maxImageSize = maxImageSize ;
2023-06-21 11:05:33 +10:00
}
2023-06-22 11:09:06 +02:00
// set the default values that will not be modified via configuration
2023-06-21 11:05:33 +10:00
this . _tinyConfig = {
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 ,
2023-06-22 14:30:24 +02:00
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 ,
2023-06-22 14:30:24 +02:00
setup : ( editor ) = > this . # editorSetup ( editor ) ,
target : this._editorElement ,
2023-12-19 15:49:01 +01:00
paste_data_images : false ,
2023-03-03 13:51:57 +10:00
2023-12-19 15:49:01 +01:00
// Extend with configuration options
. . . configurationOptions ,
2023-06-22 14:30:24 +02:00
} ;
2023-03-03 14:20:32 +10:00
2023-06-21 11:05:33 +10:00
this . # setLanguage ( ) ;
2023-03-03 13:51:57 +10:00
2023-06-22 14:30:55 +02:00
if ( this . # editorRef ) {
this . # editorRef . destroy ( ) ;
}
2024-01-24 19:54:31 +01:00
if ( ! this . # renderEditor ) {
throw new Error ( 'TinyMCE renderEditor is not loaded' ) ;
}
2024-01-24 15:09:54 +01:00
const editors = await this . # renderEditor ( this . _tinyConfig ) ;
2023-06-22 14:30:55 +02:00
this . # editorRef = editors . pop ( ) ;
2023-03-27 10:57:11 +10:00
}
2023-03-03 13:51:57 +10:00
/ * *
2023-05-17 12:16:08 +10:00
* Sets the language to use for TinyMCE * /
2023-06-21 11:05:33 +10:00
# setLanguage() {
2024-01-09 10:57:22 +01:00
const localeId = this . localize . lang ( ) ;
2023-03-03 13:51:57 +10:00
//try matching the language using full locale format
2023-06-21 11:05:33 +10:00
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
}
}
2023-05-17 12:16:08 +10:00
// only set if language exists, will fall back to tiny default
if ( languageMatch ) {
2023-06-21 11:05:33 +10:00
this . _tinyConfig . language = languageMatch ;
2023-05-17 12:16:08 +10:00
}
2023-03-02 16:32:55 +10:00
}
2023-03-02 12:56:48 +10:00
2023-11-08 11:16:15 +01:00
# editorSetup ( editor : Editor ) {
2023-03-06 11:36:48 +10:00
editor . suffix = '.min' ;
2023-03-08 15:44:07 +10:00
2023-03-28 14:52:07 +10:00
// instantiate plugins - these are already loaded in this.#loadPlugins
// to ensure they are available before setting up the editor.
2023-05-17 12:16:08 +10:00
// Plugins require a reference to the current editor as a param, so can not
// be instantiated until we have an editor
2023-03-28 17:51:39 +10:00
for ( const plugin of this . # plugins ) {
2023-06-29 13:06:02 +02:00
new plugin ( { host : this , editor } ) ;
2023-03-28 17:51:39 +10:00
}
2023-03-03 14:20:32 +10:00
2023-03-06 11:57:24 +10:00
// define keyboard shortcuts
2023-03-22 09:22:12 +10:00
editor . addShortcut ( 'Ctrl+S' , '' , ( ) = >
2023-10-03 15:11:52 +02:00
this . dispatchEvent ( new CustomEvent ( 'rte.shortcut.save' , { composed : true , bubbles : true } ) ) ,
2023-03-22 09:22:12 +10:00
) ;
2023-05-19 14:18:22 +10:00
2023-03-22 09:22:12 +10:00
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-22 09:22:12 +10:00
) ;
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 ( ) ) ) ;
2023-03-08 15:44:07 +10:00
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 ( ) ) ;
} ) ;
2023-03-02 16:32:55 +10:00
2024-01-09 13:57:18 +01:00
editor . on ( 'SetContent' , ( e ) = > {
/ * *
* 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 ) ;
}
}
} ) ;
} ) ;
2023-06-12 20:00:34 +10:00
editor . on ( 'init' , ( ) = > editor . setContent ( this . value ? . toString ( ) ? ? '' ) ) ;
2023-03-02 09:59:51 +10:00
}
2023-11-08 11:16:15 +01:00
# onInit ( editor : Editor ) {
2023-03-02 16:32:55 +10:00
//enable browser based spell checking
2023-03-03 10:09:58 +10:00
editor . getBody ( ) . setAttribute ( 'spellcheck' , 'true' ) ;
2023-06-21 11:05:33 +10:00
uriAttributeSanitizer ( editor ) ;
2023-03-02 16:32:55 +10:00
}
2023-03-02 09:59:51 +10:00
# onChange ( value : string ) {
2024-01-09 10:57:22 +01:00
this . value = value ;
this . dispatchEvent ( new UmbChangeEvent ( ) ) ;
2023-03-02 09:59:51 +10:00
}
2023-03-22 09:22:12 +10:00
/ * *
2024-02-23 13:47:14 +01:00
* Nothing rendered by default - TinyMCE initialization creates
2023-03-22 09:22:12 +10:00
* a target div and binds the RTE to that element
* /
2023-03-02 09:59:51 +10:00
render() {
2023-06-22 14:30:55 +02:00
return html ` <div id="editor"></div> ` ;
2023-03-02 09:59:51 +10:00
}
2023-05-19 14:18:22 +10:00
2023-05-02 14:16:21 +10:00
static styles = [
css `
# editor {
position : relative ;
min - height : 100px ;
}
2023-06-26 11:34:06 +10:00
. tox - tinymce {
border - radius : 0 ;
2023-06-29 21:08:41 +02:00
border : var ( -- uui - input - border - width , 1 px ) solid var ( -- uui - input - border - color , var ( -- uui - color - border , # d8d7d9 ) ) ;
2023-06-26 11:34:06 +10:00
}
2023-05-02 14:16:21 +10:00
. tox - tinymce - aux {
z - index : 9000 ;
}
. tox - tinymce - inline {
z - index : 900 ;
}
. 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 ;
}
2024-01-22 00:20:31 +01:00
2024-01-22 00:33:21 +01:00
/* Solves issue 1019 by lowering un-needed z-index on header.*/
2024-01-22 00:20:31 +01:00
. tox . tox - tinymce . tox - editor - header {
2024-01-24 15:09:54 +01:00
z - index : 0 ;
2024-01-22 00:20:31 +01:00
}
2023-05-02 14:16:21 +10:00
` ,
] ;
2023-03-02 09:59:51 +10:00
}
export default UmbInputTinyMceElement ;
declare global {
interface HTMLElementTagNameMap {
'umb-input-tiny-mce' : UmbInputTinyMceElement ;
}
}