]> BookStack Code Mirror - bookstack/blob - resources/js/wysiwyg/config.js
Comments: Added HTML filter on load, tinymce elem filtering
[bookstack] / resources / js / wysiwyg / config.js
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';
7 import {setupFilters} from './filters';
8
9 import {getPlugin as getCodeeditorPlugin} from './plugin-codeeditor';
10 import {getPlugin as getDrawioPlugin} from './plugin-drawio';
11 import {getPlugin as getCustomhrPlugin} from './plugins-customhr';
12 import {getPlugin as getImagemanagerPlugin} from './plugins-imagemanager';
13 import {getPlugin as getAboutPlugin} from './plugins-about';
14 import {getPlugin as getDetailsPlugin} from './plugins-details';
15 import {getPlugin as getTasklistPlugin} from './plugins-tasklist';
16 import {handleEmbedAlignmentChanges} from './fixes';
17
18 const styleFormats = [
19     {title: 'Large Header', format: 'h2', preview: 'color: blue;'},
20     {title: 'Medium Header', format: 'h3'},
21     {title: 'Small Header', format: 'h4'},
22     {title: 'Tiny Header', format: 'h5'},
23     {
24         title: 'Paragraph', format: 'p', exact: true, classes: '',
25     },
26     {title: 'Blockquote', format: 'blockquote'},
27     {
28         title: 'Callouts',
29         items: [
30             {title: 'Information', format: 'calloutinfo'},
31             {title: 'Success', format: 'calloutsuccess'},
32             {title: 'Warning', format: 'calloutwarning'},
33             {title: 'Danger', format: 'calloutdanger'},
34         ],
35     },
36 ];
37
38 const formats = {
39     alignleft: {selector: 'p,h1,h2,h3,h4,h5,h6,td,th,div,ul,ol,li,table,img,iframe,video,span', classes: 'align-left'},
40     aligncenter: {selector: 'p,h1,h2,h3,h4,h5,h6,td,th,div,ul,ol,li,table,img,iframe,video,span', classes: 'align-center'},
41     alignright: {selector: 'p,h1,h2,h3,h4,h5,h6,td,th,div,ul,ol,li,table,img,iframe,video,span', classes: 'align-right'},
42     calloutsuccess: {block: 'p', exact: true, attributes: {class: 'callout success'}},
43     calloutinfo: {block: 'p', exact: true, attributes: {class: 'callout info'}},
44     calloutwarning: {block: 'p', exact: true, attributes: {class: 'callout warning'}},
45     calloutdanger: {block: 'p', exact: true, attributes: {class: 'callout danger'}},
46 };
47
48 const colorMap = [
49     '#BFEDD2', '',
50     '#FBEEB8', '',
51     '#F8CAC6', '',
52     '#ECCAFA', '',
53     '#C2E0F4', '',
54
55     '#2DC26B', '',
56     '#F1C40F', '',
57     '#E03E2D', '',
58     '#B96AD9', '',
59     '#3598DB', '',
60
61     '#169179', '',
62     '#E67E23', '',
63     '#BA372A', '',
64     '#843FA1', '',
65     '#236FA1', '',
66
67     '#ECF0F1', '',
68     '#CED4D9', '',
69     '#95A5A6', '',
70     '#7E8C8D', '',
71     '#34495E', '',
72
73     '#000000', '',
74     '#ffffff', '',
75 ];
76
77 function filePickerCallback(callback, value, meta) {
78     // field_name, url, type, win
79     if (meta.filetype === 'file') {
80         /** @type {EntitySelectorPopup} * */
81         const selector = window.$components.first('entity-selector-popup');
82         const selectionText = this.selection.getContent({format: 'text'}).trim();
83         selector.show(entity => {
84             callback(entity.link, {
85                 text: entity.name,
86                 title: entity.name,
87             });
88         }, {
89             initialValue: selectionText,
90             searchEndpoint: '/search/entity-selector',
91             entityTypes: 'page,book,chapter,bookshelf',
92             entityPermission: 'view',
93         });
94     }
95
96     if (meta.filetype === 'image') {
97         // Show image manager
98         /** @type {ImageManager} * */
99         const imageManager = window.$components.first('image-manager');
100         imageManager.show(image => {
101             callback(image.url, {alt: image.name});
102         }, 'gallery');
103     }
104 }
105
106 /**
107  * @param {WysiwygConfigOptions} options
108  * @return {string[]}
109  */
110 function gatherPlugins(options) {
111     const plugins = [
112         'image',
113         'table',
114         'link',
115         'autolink',
116         'fullscreen',
117         'code',
118         'customhr',
119         'autosave',
120         'lists',
121         'codeeditor',
122         'media',
123         'imagemanager',
124         'about',
125         'details',
126         'tasklist',
127         options.textDirection === 'rtl' ? 'directionality' : '',
128     ];
129
130     window.tinymce.PluginManager.add('codeeditor', getCodeeditorPlugin());
131     window.tinymce.PluginManager.add('customhr', getCustomhrPlugin());
132     window.tinymce.PluginManager.add('imagemanager', getImagemanagerPlugin());
133     window.tinymce.PluginManager.add('about', getAboutPlugin());
134     window.tinymce.PluginManager.add('details', getDetailsPlugin());
135     window.tinymce.PluginManager.add('tasklist', getTasklistPlugin());
136
137     if (options.drawioUrl) {
138         window.tinymce.PluginManager.add('drawio', getDrawioPlugin(options));
139         plugins.push('drawio');
140     }
141
142     return plugins.filter(plugin => Boolean(plugin));
143 }
144
145 /**
146  * Fetch custom HTML head content nodes from the outer page head
147  * and add them to the given editor document.
148  * @param {Document} editorDoc
149  */
150 function addCustomHeadContent(editorDoc) {
151     const headContentLines = document.head.innerHTML.split('\n');
152     const startLineIndex = headContentLines.findIndex(line => line.trim() === '<!-- Start: custom user content -->');
153     const endLineIndex = headContentLines.findIndex(line => line.trim() === '<!-- End: custom user content -->');
154     if (startLineIndex === -1 || endLineIndex === -1) {
155         return;
156     }
157
158     const customHeadHtml = headContentLines.slice(startLineIndex + 1, endLineIndex).join('\n');
159     const el = editorDoc.createElement('div');
160     el.innerHTML = customHeadHtml;
161
162     editorDoc.head.append(...el.children);
163 }
164
165 /**
166  * @param {WysiwygConfigOptions} options
167  * @return {function(Editor)}
168  */
169 function getSetupCallback(options) {
170     return function setupCallback(editor) {
171         function editorChange() {
172             if (options.darkMode) {
173                 editor.contentDocument.documentElement.classList.add('dark-mode');
174             }
175             window.$events.emit('editor-html-change', '');
176         }
177
178         editor.on('ExecCommand change input NodeChange ObjectResized', editorChange);
179         listenForCommonEvents(editor);
180         listenForDragAndPaste(editor, options);
181
182         editor.on('init', () => {
183             editorChange();
184             scrollToQueryString(editor);
185             window.editor = editor;
186             registerShortcuts(editor);
187         });
188
189         editor.on('PreInit', () => {
190             setupFilters(editor);
191         });
192
193         handleEmbedAlignmentChanges(editor);
194
195         // Custom handler hook
196         window.$events.emitPublic(options.containerElement, 'editor-tinymce::setup', {editor});
197
198         // Inline code format button
199         editor.ui.registry.addButton('inlinecode', {
200             tooltip: 'Inline code',
201             icon: 'sourcecode',
202             onAction() {
203                 editor.execCommand('mceToggleFormat', false, 'code');
204             },
205         });
206     };
207 }
208
209 /**
210  * @param {WysiwygConfigOptions} options
211  */
212 function getContentStyle(options) {
213     return `
214 html, body, html.dark-mode {
215     background: ${options.darkMode ? '#222' : '#fff'};
216
217 body {
218     padding-left: 15px !important;
219     padding-right: 15px !important; 
220     height: initial !important;
221     margin:0!important; 
222     margin-left: auto! important;
223     margin-right: auto !important;
224     overflow-y: hidden !important;
225 }`.trim().replace('\n', '');
226 }
227
228 /**
229  * @param {WysiwygConfigOptions} options
230  * @return {Object}
231  */
232 export function buildForEditor(options) {
233     // Set language
234     window.tinymce.addI18n(options.language, options.translationMap);
235
236     // BookStack Version
237     const version = document.querySelector('script[src*="/dist/app.js"]').getAttribute('src').split('?version=')[1];
238
239     // Return config object
240     return {
241         width: '100%',
242         height: '100%',
243         selector: '#html-editor',
244         cache_suffix: `?version=${version}`,
245         content_css: [
246             window.baseUrl('/dist/styles.css'),
247         ],
248         branding: false,
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,
259         statusbar: false,
260         menubar: 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',
265         valid_children: [
266             '-div[p|h1|h2|h3|h4|h5|h6|blockquote|code-block]',
267             '+div[pre|img]',
268             '-doc-root[doc-root|#text]',
269             '-li[details]',
270             '+code-block[pre]',
271             '+doc-root[p|h1|h2|h3|h4|h5|h6|blockquote|code-block|div|hr]',
272         ].join(','),
273         plugins: gatherPlugins(options),
274         contextmenu: false,
275         toolbar: getPrimaryToolbar(options),
276         content_style: getContentStyle(options),
277         style_formats: styleFormats,
278         style_formats_merge: false,
279         media_alt_source: false,
280         media_poster: false,
281         formats,
282         table_style_by_css: true,
283         table_use_colgroups: true,
284         file_picker_types: 'file image',
285         color_map: colorMap,
286         file_picker_callback: filePickerCallback,
287         paste_preprocess(plugin, args) {
288             const {content} = args;
289             if (content.indexOf('<img src="file://') !== -1) {
290                 args.content = '';
291             }
292         },
293         init_instance_callback(editor) {
294             addCustomHeadContent(editor.getDoc());
295         },
296         setup(editor) {
297             registerCustomIcons(editor);
298             registerAdditionalToolbars(editor);
299             getSetupCallback(options)(editor);
300         },
301     };
302 }
303
304 /**
305  * @param {WysiwygConfigOptions} options
306  * @return {RawEditorOptions}
307  */
308 export function buildForInput(options) {
309     // Set language
310     window.tinymce.addI18n(options.language, options.translationMap);
311
312     // BookStack Version
313     const version = document.querySelector('script[src*="/dist/app.js"]').getAttribute('src').split('?version=')[1];
314
315     // Return config object
316     return {
317         width: '100%',
318         height: '185px',
319         target: options.containerElement,
320         cache_suffix: `?version=${version}`,
321         content_css: [
322             window.baseUrl('/dist/styles.css'),
323         ],
324         branding: false,
325         skin: options.darkMode ? 'tinymce-5-dark' : 'tinymce-5',
326         body_class: 'wysiwyg-input',
327         browser_spellcheck: true,
328         relative_urls: false,
329         language: options.language,
330         directionality: options.textDirection,
331         remove_script_host: false,
332         document_base_url: window.baseUrl('/'),
333         end_container_on_empty_block: true,
334         remove_trailing_brs: false,
335         statusbar: false,
336         menubar: false,
337         plugins: 'link autolink lists',
338         contextmenu: false,
339         toolbar: 'bold italic link bullist numlist',
340         content_style: getContentStyle(options),
341         file_picker_types: 'file',
342         valid_elements: 'p,a[href|title],ol,ul,li,strong,em,br',
343         file_picker_callback: filePickerCallback,
344         init_instance_callback(editor) {
345             addCustomHeadContent(editor.getDoc());
346
347             editor.contentDocument.documentElement.classList.toggle('dark-mode', options.darkMode);
348         },
349     };
350 }
351
352 /**
353  * @typedef {Object} WysiwygConfigOptions
354  * @property {Element} containerElement
355  * @property {string} language
356  * @property {boolean} darkMode
357  * @property {string} textDirection
358  * @property {string} drawioUrl
359  * @property {int} pageId
360  * @property {Object} translations
361  * @property {Object} translationMap
362  */