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' ;
2024-06-03 14:11:59 +02:00
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' ;
2024-06-03 14:43:05 +02:00
import { getProcessedImageUrl , umbDeepMerge } from '@umbraco-cms/backoffice/utils' ;
2024-06-03 14:11:59 +02:00
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' ;
2024-01-15 16:12:48 +01:00
import { UmbStylesheetDetailRepository , UmbStylesheetRuleManager } from '@umbraco-cms/backoffice/stylesheet' ;
2024-04-25 13:50:29 +01:00
import { UUIFormControlMixin } from '@umbraco-cms/backoffice/external/uui' ;
2024-05-10 15:33:09 +02:00
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' ;
2023-06-21 11:05:33 +10:00
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' )
2024-04-11 10:59:56 +02:00
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 > = [ ] ;
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
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 16:32:55 +10:00
}
2023-03-02 09:59:51 +10:00
2024-06-21 10:54:17 +02:00
override 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-06-21 10:54:17 +02:00
override get value ( ) : FormDataEntryValue | FormData {
2024-01-29 10:09:24 +01:00
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 ;
2024-05-10 15:33:09 +02:00
@query ( '.editor' , true )
2023-06-22 14:30:55 +02:00
private _editorElement? : HTMLElement ;
2024-05-28 01:08:22 +02:00
getEditor() {
return this . # editorRef ;
}
2024-06-03 14:11:59 +02:00
constructor ( ) {
super ( ) ;
this . # loadEditor ( ) ;
}
async # loadEditor() {
2024-06-03 14:31:15 +02:00
this . observe ( umbExtensionsRegistry . byType ( 'tinyMcePlugin' ) , async ( manifests ) = > {
2024-06-03 14:11:59 +02:00
this . # plugins . length = 0 ;
2024-06-03 14:31:15 +02:00
this . # plugins = await this . # loadPlugins ( manifests ) ;
2024-06-03 14:11:59 +02:00
let config : RawEditorOptions = { } ;
manifests . forEach ( ( manifest ) = > {
if ( manifest . meta ? . config ) {
2024-06-03 14:43:05 +02:00
config = umbDeepMerge ( manifest . meta . config , config ) ;
2024-06-03 14:11:59 +02:00
}
} ) ;
2024-06-03 14:43:05 +02:00
2024-06-03 14:11:59 +02:00
this . # setTinyConfig ( config ) ;
} ) ;
2023-03-02 09:59:51 +10:00
}
2024-06-21 10:54:17 +02:00
override disconnectedCallback() {
2023-06-21 14:58:52 +10:00
super . disconnectedCallback ( ) ;
2023-06-22 14:13:12 +02:00
2024-05-10 15:32:28 +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 .
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 > ) {
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 ) {
2024-06-03 14:31:15 +02:00
promises . push ( await loadManifestApi ( manifest . js ) ) ;
2024-01-24 15:09:54 +01:00
}
if ( manifest . api ) {
2024-06-03 14:31:15 +02:00
promises . push ( await loadManifestApi ( manifest . api ) ) ;
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 } ) = > {
2024-03-25 13:21:25 +01:00
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
}
2024-06-03 14:11:59 +02:00
async # setTinyConfig ( additionalConfig? : RawEditorOptions ) {
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 ,
2024-05-10 15:41:43 +02:00
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 ,
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 ) ;
}
}
2024-03-25 11:52:33 +01:00
// set the configured toolbar if any, otherwise false
2023-12-19 15:49:01 +01:00
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 ;
}
2023-12-19 15:49:01 +01:00
// set the configured inline mode
2024-03-25 14:07:49 +01:00
const mode = this . configuration ? . getValueByAlias < string > ( 'mode' ) ;
if ( mode ? . toLocaleLowerCase ( ) === 'inline' ) {
2023-12-19 15:49:01 +01:00
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 ) {
2023-12-19 15:49:01 +01:00
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
2024-06-03 14:43:05 +02:00
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 ,
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 ,
2024-06-03 14:31:15 +02:00
language : this. # getLanguage ( ) ,
2024-06-04 09:13:54 +02:00
promotion : false ,
2024-06-11 12:41:00 +02:00
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
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
2024-06-03 14:43:05 +02:00
// Extend with additional configuration options
if ( additionalConfig ) {
config = umbDeepMerge ( additionalConfig , config ) ;
2023-06-22 14:30:55 +02:00
}
2024-06-03 14:31:15 +02:00
this . # editorRef ? . destroy ( ) ;
2023-06-22 14:30:55 +02:00
2024-06-03 14:10:31 +02:00
const editors = await renderEditor ( config ) . catch ( ( error ) = > {
2024-05-10 15:32:28 +02:00
console . error ( 'Failed to render TinyMCE' , error ) ;
return [ ] ;
} ) ;
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
/ * *
2024-06-03 14:10:31 +02:00
* Gets the language to use for TinyMCE
2024-08-06 13:28:42 +02:00
* /
2024-06-03 14:10:31 +02:00
# getLanguage() {
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
}
}
2024-06-03 14:10:31 +02:00
return languageMatch ;
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-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-05-10 15:32:28 +02:00
editor . on ( 'SetContent' , ( ) = > {
2024-01-09 13:57:18 +01:00
/ * *
* 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.
2024-06-03 14:11:59 +02:00
// 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 ) {
2024-06-03 14:11:59 +02:00
// [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:11:59 +02:00
}
2024-06-03 14:31:15 +02:00
}
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 ) ;
2024-06-03 14:11:23 +02:00
editor . setContent ( this . value ? . toString ( ) ? ? '' ) ;
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
* /
2024-06-21 11:40:28 +02:00
override render() {
2024-05-10 15:33:09 +02:00
return html ` <div class="editor"></div> ` ;
2023-03-02 09:59:51 +10:00
}
2023-05-19 14:18:22 +10:00
2024-09-24 19:40:02 +02:00
static override readonly styles = [
2023-05-02 14:16:21 +10:00
css `
2024-05-10 15:34:25 +02:00
. tox - tinymce {
2023-05-02 14:16:21 +10:00
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 , 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 - 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 ;
}
}