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 style_formats = [
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"},
21 {title: "Paragraph", format: "p", exact: true, classes: ''},
22 {title: "Blockquote", format: "blockquote"},
24 title: "Callouts", items: [
25 {title: "Information", format: 'calloutinfo'},
26 {title: "Success", format: 'calloutsuccess'},
27 {title: "Warning", format: 'calloutwarning'},
28 {title: "Danger", format: 'calloutdanger'}
34 alignleft: {selector: 'p,h1,h2,h3,h4,h5,h6,td,th,div,ul,ol,li,table,img', classes: 'align-left'},
35 aligncenter: {selector: 'p,h1,h2,h3,h4,h5,h6,td,th,div,ul,ol,li,table,img', classes: 'align-center'},
36 alignright: {selector: 'p,h1,h2,h3,h4,h5,h6,td,th,div,ul,ol,li,table,img', classes: 'align-right'},
37 calloutsuccess: {block: 'p', exact: true, attributes: {class: 'callout success'}},
38 calloutinfo: {block: 'p', exact: true, attributes: {class: 'callout info'}},
39 calloutwarning: {block: 'p', exact: true, attributes: {class: 'callout warning'}},
40 calloutdanger: {block: 'p', exact: true, attributes: {class: 'callout danger'}}
72 function file_picker_callback(callback, value, meta) {
74 // field_name, url, type, win
75 if (meta.filetype === 'file') {
76 /** @type {EntitySelectorPopup} **/
77 const selector = window.$components.first('entity-selector-popup');
78 selector.show(entity => {
79 callback(entity.link, {
86 if (meta.filetype === 'image') {
88 /** @type {ImageManager} **/
89 const imageManager = window.$components.first('image-manager');
90 imageManager.show(function (image) {
91 callback(image.url, {alt: image.name});
98 * @param {WysiwygConfigOptions} options
101 function gatherPlugins(options) {
118 options.textDirection === 'rtl' ? 'directionality' : '',
121 window.tinymce.PluginManager.add('codeeditor', getCodeeditorPlugin(options));
122 window.tinymce.PluginManager.add('customhr', getCustomhrPlugin(options));
123 window.tinymce.PluginManager.add('imagemanager', getImagemanagerPlugin(options));
124 window.tinymce.PluginManager.add('about', getAboutPlugin(options));
125 window.tinymce.PluginManager.add('details', getDetailsPlugin(options));
126 window.tinymce.PluginManager.add('tasklist', getTasklistPlugin(options));
128 if (options.drawioUrl) {
129 window.tinymce.PluginManager.add('drawio', getDrawioPlugin(options));
130 plugins.push('drawio');
133 return plugins.filter(plugin => Boolean(plugin));
137 * Fetch custom HTML head content from the parent page head into the editor.
139 function fetchCustomHeadContent() {
140 const headContentLines = document.head.innerHTML.split("\n");
141 const startLineIndex = headContentLines.findIndex(line => line.trim() === '<!-- Start: custom user content -->');
142 const endLineIndex = headContentLines.findIndex(line => line.trim() === '<!-- End: custom user content -->');
143 if (startLineIndex === -1 || endLineIndex === -1) {
146 return headContentLines.slice(startLineIndex + 1, endLineIndex).join('\n');
150 * Setup a serializer filter for <br> tags to ensure they're not rendered
151 * within code blocks and that we use newlines there instead.
152 * @param {Editor} editor
154 function setupBrFilter(editor) {
155 editor.serializer.addNodeFilter('br', function(nodes) {
156 for (const node of nodes) {
157 if (node.parent && node.parent.name === 'code') {
158 const newline = tinymce.html.Node.create('#text');
159 newline.value = '\n';
160 node.replace(newline);
167 * @param {WysiwygConfigOptions} options
168 * @return {function(Editor)}
170 function getSetupCallback(options) {
171 return function(editor) {
172 editor.on('ExecCommand change input NodeChange ObjectResized', editorChange);
173 listenForCommonEvents(editor);
174 listenForDragAndPaste(editor, options);
176 editor.on('init', () => {
178 scrollToQueryString(editor);
179 window.editor = editor;
180 registerShortcuts(editor);
183 editor.on('PreInit', () => {
184 setupBrFilter(editor);
187 function editorChange() {
188 const content = editor.getContent();
189 if (options.darkMode) {
190 editor.contentDocument.documentElement.classList.add('dark-mode');
192 window.$events.emit('editor-html-change', content);
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) {
235 window.tinymce.addI18n(options.language, options.translationMap);
238 const version = document.querySelector('script[src*="/dist/app.js"]').getAttribute('src').split('?version=')[1];
240 // Return config object
244 selector: '#html-editor',
245 cache_suffix: '?version=' + version,
247 window.baseUrl('/dist/styles.css'),
250 skin: options.darkMode ? 'tinymce-5-dark' : 'tinymce-5',
251 body_class: 'page-content',
252 browser_spellcheck: true,
253 relative_urls: false,
254 language: options.language,
255 directionality: options.textDirection,
256 remove_script_host: false,
257 document_base_url: window.baseUrl('/'),
258 end_container_on_empty_block: true,
259 remove_trailing_brs: false,
262 paste_data_images: false,
263 extended_valid_elements: 'pre[*],svg[*],div[drawio-diagram],details[*],summary[*],div[*],li[class|checked|style]',
264 automatic_uploads: false,
265 custom_elements: 'doc-root,code-block',
267 "-div[p|h1|h2|h3|h4|h5|h6|blockquote|code-block]",
269 "-doc-root[doc-root|#text]",
272 "+doc-root[p|h1|h2|h3|h4|h5|h6|blockquote|code-block|div]"
274 plugins: gatherPlugins(options),
276 toolbar: getPrimaryToolbar(options),
277 content_style: getContentStyle(options),
279 style_formats_merge: false,
280 media_alt_source: false,
283 table_style_by_css: true,
284 table_use_colgroups: true,
285 file_picker_types: 'file image',
287 file_picker_callback,
288 paste_preprocess(plugin, args) {
289 const content = args.content;
290 if (content.indexOf('<img src="file://') !== -1) {
294 init_instance_callback(editor) {
295 const head = editor.getDoc().querySelector('head');
296 head.innerHTML += fetchCustomHeadContent();
299 registerCustomIcons(editor);
300 registerAdditionalToolbars(editor, options);
301 getSetupCallback(options)(editor);
307 * @typedef {Object} WysiwygConfigOptions
308 * @property {Element} containerElement
309 * @property {string} language
310 * @property {boolean} darkMode
311 * @property {string} textDirection
312 * @property {string} drawioUrl
313 * @property {int} pageId
314 * @property {Object} translations
315 * @property {Object} translationMap