1 import {register as registerShortcuts} from './shortcuts';
2 import {listen as listenForCommonEvents} from './common-events';
3 import {scrollToQueryString} from './scrolling';
4 import {listenForDragAndPaste} from './drop-paste-handling';
5 import {getPrimaryToolbar, registerAdditionalToolbars} from './toolbars';
6 import {registerCustomIcons} from './icons';
8 import {getPlugin as getCodeeditorPlugin} from './plugin-codeeditor';
9 import {getPlugin as getDrawioPlugin} from './plugin-drawio';
10 import {getPlugin as getCustomhrPlugin} from './plugins-customhr';
11 import {getPlugin as getImagemanagerPlugin} from './plugins-imagemanager';
12 import {getPlugin as getAboutPlugin} from './plugins-about';
13 import {getPlugin as getDetailsPlugin} from './plugins-details';
14 import {getPlugin as getTasklistPlugin} from './plugins-tasklist';
16 const styleFormats = [
17 {title: 'Large Header', format: 'h2', preview: 'color: blue;'},
18 {title: 'Medium Header', format: 'h3'},
19 {title: 'Small Header', format: 'h4'},
20 {title: 'Tiny Header', format: 'h5'},
22 title: 'Paragraph', format: 'p', exact: true, classes: '',
24 {title: 'Blockquote', format: 'blockquote'},
28 {title: 'Information', format: 'calloutinfo'},
29 {title: 'Success', format: 'calloutsuccess'},
30 {title: 'Warning', format: 'calloutwarning'},
31 {title: 'Danger', format: 'calloutdanger'},
37 alignleft: {selector: 'p,h1,h2,h3,h4,h5,h6,td,th,div,ul,ol,li,table,img', classes: 'align-left'},
38 aligncenter: {selector: 'p,h1,h2,h3,h4,h5,h6,td,th,div,ul,ol,li,table,img', classes: 'align-center'},
39 alignright: {selector: 'p,h1,h2,h3,h4,h5,h6,td,th,div,ul,ol,li,table,img', classes: 'align-right'},
40 calloutsuccess: {block: 'p', exact: true, attributes: {class: 'callout success'}},
41 calloutinfo: {block: 'p', exact: true, attributes: {class: 'callout info'}},
42 calloutwarning: {block: 'p', exact: true, attributes: {class: 'callout warning'}},
43 calloutdanger: {block: 'p', exact: true, attributes: {class: 'callout danger'}},
75 function filePickerCallback(callback, value, meta) {
76 // field_name, url, type, win
77 if (meta.filetype === 'file') {
78 /** @type {EntitySelectorPopup} * */
79 const selector = window.$components.first('entity-selector-popup');
80 selector.show(entity => {
81 callback(entity.link, {
88 if (meta.filetype === 'image') {
90 /** @type {ImageManager} * */
91 const imageManager = window.$components.first('image-manager');
92 imageManager.show(image => {
93 callback(image.url, {alt: image.name});
99 * @param {WysiwygConfigOptions} options
102 function gatherPlugins(options) {
119 options.textDirection === 'rtl' ? 'directionality' : '',
122 window.tinymce.PluginManager.add('codeeditor', getCodeeditorPlugin());
123 window.tinymce.PluginManager.add('customhr', getCustomhrPlugin());
124 window.tinymce.PluginManager.add('imagemanager', getImagemanagerPlugin());
125 window.tinymce.PluginManager.add('about', getAboutPlugin());
126 window.tinymce.PluginManager.add('details', getDetailsPlugin());
127 window.tinymce.PluginManager.add('tasklist', getTasklistPlugin());
129 if (options.drawioUrl) {
130 window.tinymce.PluginManager.add('drawio', getDrawioPlugin(options));
131 plugins.push('drawio');
134 return plugins.filter(plugin => Boolean(plugin));
138 * Fetch custom HTML head content from the parent page head into the editor.
140 function fetchCustomHeadContent() {
141 const headContentLines = document.head.innerHTML.split('\n');
142 const startLineIndex = headContentLines.findIndex(line => line.trim() === '<!-- Start: custom user content -->');
143 const endLineIndex = headContentLines.findIndex(line => line.trim() === '<!-- End: custom user content -->');
144 if (startLineIndex === -1 || endLineIndex === -1) {
147 return headContentLines.slice(startLineIndex + 1, endLineIndex).join('\n');
151 * Setup a serializer filter for <br> tags to ensure they're not rendered
152 * within code blocks and that we use newlines there instead.
153 * @param {Editor} editor
155 function setupBrFilter(editor) {
156 editor.serializer.addNodeFilter('br', nodes => {
157 for (const node of nodes) {
158 if (node.parent && node.parent.name === 'code') {
159 const newline = window.tinymce.html.Node.create('#text');
160 newline.value = '\n';
161 node.replace(newline);
168 * @param {WysiwygConfigOptions} options
169 * @return {function(Editor)}
171 function getSetupCallback(options) {
172 return function setupCallback(editor) {
173 function editorChange() {
174 if (options.darkMode) {
175 editor.contentDocument.documentElement.classList.add('dark-mode');
177 window.$events.emit('editor-html-change', '');
180 editor.on('ExecCommand change input NodeChange ObjectResized', editorChange);
181 listenForCommonEvents(editor);
182 listenForDragAndPaste(editor, options);
184 editor.on('init', () => {
186 scrollToQueryString(editor);
187 window.editor = editor;
188 registerShortcuts(editor);
191 editor.on('PreInit', () => {
192 setupBrFilter(editor);
195 // Custom handler hook
196 window.$events.emitPublic(options.containerElement, 'editor-tinymce::setup', {editor});
198 // Inline code format button
199 editor.ui.registry.addButton('inlinecode', {
200 tooltip: 'Inline code',
203 editor.execCommand('mceToggleFormat', false, 'code');
210 * @param {WysiwygConfigOptions} options
212 function getContentStyle(options) {
214 html, body, html.dark-mode {
215 background: ${options.darkMode ? '#222' : '#fff'};
218 padding-left: 15px !important;
219 padding-right: 15px !important;
220 height: initial !important;
222 margin-left: auto! important;
223 margin-right: auto !important;
224 overflow-y: hidden !important;
225 }`.trim().replace('\n', '');
229 * @param {WysiwygConfigOptions} options
232 export function build(options) {
234 window.tinymce.addI18n(options.language, options.translationMap);
237 const version = document.querySelector('script[src*="/dist/app.js"]').getAttribute('src').split('?version=')[1];
239 // Return config object
243 selector: '#html-editor',
244 cache_suffix: `?version=${version}`,
246 window.baseUrl('/dist/styles.css'),
249 skin: options.darkMode ? 'tinymce-5-dark' : 'tinymce-5',
250 body_class: 'page-content',
251 browser_spellcheck: true,
252 relative_urls: false,
253 language: options.language,
254 directionality: options.textDirection,
255 remove_script_host: false,
256 document_base_url: window.baseUrl('/'),
257 end_container_on_empty_block: true,
258 remove_trailing_brs: false,
261 paste_data_images: false,
262 extended_valid_elements: 'pre[*],svg[*],div[drawio-diagram],details[*],summary[*],div[*],li[class|checked|style]',
263 automatic_uploads: false,
264 custom_elements: 'doc-root,code-block',
266 '-div[p|h1|h2|h3|h4|h5|h6|blockquote|code-block]',
268 '-doc-root[doc-root|#text]',
271 '+doc-root[p|h1|h2|h3|h4|h5|h6|blockquote|code-block|div|hr]',
273 plugins: gatherPlugins(options),
275 toolbar: getPrimaryToolbar(options),
276 content_style: getContentStyle(options),
277 style_formats: styleFormats,
278 style_formats_merge: false,
279 media_alt_source: false,
282 table_style_by_css: true,
283 table_use_colgroups: true,
284 file_picker_types: 'file image',
286 file_picker_callback: filePickerCallback,
287 paste_preprocess(plugin, args) {
288 const {content} = args;
289 if (content.indexOf('<img src="file://') !== -1) {
293 init_instance_callback(editor) {
294 const head = editor.getDoc().querySelector('head');
295 head.innerHTML += fetchCustomHeadContent();
298 registerCustomIcons(editor);
299 registerAdditionalToolbars(editor);
300 getSetupCallback(options)(editor);
306 * @typedef {Object} WysiwygConfigOptions
307 * @property {Element} containerElement
308 * @property {string} language
309 * @property {boolean} darkMode
310 * @property {string} textDirection
311 * @property {string} drawioUrl
312 * @property {int} pageId
313 * @property {Object} translations
314 * @property {Object} translationMap