]> BookStack Code Mirror - bookstack/blob - resources/js/wysiwyg/config.js
Input WYSIWYG: Updated tests, Added simple html limiting
[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         }, selectionText, {
89             searchEndpoint: '/search/entity-selector',
90             entityTypes: 'page,book,chapter,bookshelf',
91             entityPermission: 'view',
92         });
93     }
94
95     if (meta.filetype === 'image') {
96         // Show image manager
97         /** @type {ImageManager} * */
98         const imageManager = window.$components.first('image-manager');
99         imageManager.show(image => {
100             callback(image.url, {alt: image.name});
101         }, 'gallery');
102     }
103 }
104
105 /**
106  * @param {WysiwygConfigOptions} options
107  * @return {string[]}
108  */
109 function gatherPlugins(options) {
110     const plugins = [
111         'image',
112         'table',
113         'link',
114         'autolink',
115         'fullscreen',
116         'code',
117         'customhr',
118         'autosave',
119         'lists',
120         'codeeditor',
121         'media',
122         'imagemanager',
123         'about',
124         'details',
125         'tasklist',
126         options.textDirection === 'rtl' ? 'directionality' : '',
127     ];
128
129     window.tinymce.PluginManager.add('codeeditor', getCodeeditorPlugin());
130     window.tinymce.PluginManager.add('customhr', getCustomhrPlugin());
131     window.tinymce.PluginManager.add('imagemanager', getImagemanagerPlugin());
132     window.tinymce.PluginManager.add('about', getAboutPlugin());
133     window.tinymce.PluginManager.add('details', getDetailsPlugin());
134     window.tinymce.PluginManager.add('tasklist', getTasklistPlugin());
135
136     if (options.drawioUrl) {
137         window.tinymce.PluginManager.add('drawio', getDrawioPlugin(options));
138         plugins.push('drawio');
139     }
140
141     return plugins.filter(plugin => Boolean(plugin));
142 }
143
144 /**
145  * Fetch custom HTML head content from the parent page head into the editor.
146  */
147 function fetchCustomHeadContent() {
148     const headContentLines = document.head.innerHTML.split('\n');
149     const startLineIndex = headContentLines.findIndex(line => line.trim() === '<!-- Start: custom user content -->');
150     const endLineIndex = headContentLines.findIndex(line => line.trim() === '<!-- End: custom user content -->');
151     if (startLineIndex === -1 || endLineIndex === -1) {
152         return '';
153     }
154     return headContentLines.slice(startLineIndex + 1, endLineIndex).join('\n');
155 }
156
157 /**
158  * @param {WysiwygConfigOptions} options
159  * @return {function(Editor)}
160  */
161 function getSetupCallback(options) {
162     return function setupCallback(editor) {
163         function editorChange() {
164             if (options.darkMode) {
165                 editor.contentDocument.documentElement.classList.add('dark-mode');
166             }
167             window.$events.emit('editor-html-change', '');
168         }
169
170         editor.on('ExecCommand change input NodeChange ObjectResized', editorChange);
171         listenForCommonEvents(editor);
172         listenForDragAndPaste(editor, options);
173
174         editor.on('init', () => {
175             editorChange();
176             scrollToQueryString(editor);
177             window.editor = editor;
178             registerShortcuts(editor);
179         });
180
181         editor.on('PreInit', () => {
182             setupFilters(editor);
183         });
184
185         handleEmbedAlignmentChanges(editor);
186
187         // Custom handler hook
188         window.$events.emitPublic(options.containerElement, 'editor-tinymce::setup', {editor});
189
190         // Inline code format button
191         editor.ui.registry.addButton('inlinecode', {
192             tooltip: 'Inline code',
193             icon: 'sourcecode',
194             onAction() {
195                 editor.execCommand('mceToggleFormat', false, 'code');
196             },
197         });
198     };
199 }
200
201 /**
202  * @param {WysiwygConfigOptions} options
203  */
204 function getContentStyle(options) {
205     return `
206 html, body, html.dark-mode {
207     background: ${options.darkMode ? '#222' : '#fff'};
208
209 body {
210     padding-left: 15px !important;
211     padding-right: 15px !important; 
212     height: initial !important;
213     margin:0!important; 
214     margin-left: auto! important;
215     margin-right: auto !important;
216     overflow-y: hidden !important;
217 }`.trim().replace('\n', '');
218 }
219
220 /**
221  * @param {WysiwygConfigOptions} options
222  * @return {Object}
223  */
224 export function buildForEditor(options) {
225     // Set language
226     window.tinymce.addI18n(options.language, options.translationMap);
227
228     // BookStack Version
229     const version = document.querySelector('script[src*="/dist/app.js"]').getAttribute('src').split('?version=')[1];
230
231     // Return config object
232     return {
233         width: '100%',
234         height: '100%',
235         selector: '#html-editor',
236         cache_suffix: `?version=${version}`,
237         content_css: [
238             window.baseUrl('/dist/styles.css'),
239         ],
240         branding: false,
241         skin: options.darkMode ? 'tinymce-5-dark' : 'tinymce-5',
242         body_class: 'page-content',
243         browser_spellcheck: true,
244         relative_urls: false,
245         language: options.language,
246         directionality: options.textDirection,
247         remove_script_host: false,
248         document_base_url: window.baseUrl('/'),
249         end_container_on_empty_block: true,
250         remove_trailing_brs: false,
251         statusbar: false,
252         menubar: false,
253         paste_data_images: false,
254         extended_valid_elements: 'pre[*],svg[*],div[drawio-diagram],details[*],summary[*],div[*],li[class|checked|style]',
255         automatic_uploads: false,
256         custom_elements: 'doc-root,code-block',
257         valid_children: [
258             '-div[p|h1|h2|h3|h4|h5|h6|blockquote|code-block]',
259             '+div[pre|img]',
260             '-doc-root[doc-root|#text]',
261             '-li[details]',
262             '+code-block[pre]',
263             '+doc-root[p|h1|h2|h3|h4|h5|h6|blockquote|code-block|div|hr]',
264         ].join(','),
265         plugins: gatherPlugins(options),
266         contextmenu: false,
267         toolbar: getPrimaryToolbar(options),
268         content_style: getContentStyle(options),
269         style_formats: styleFormats,
270         style_formats_merge: false,
271         media_alt_source: false,
272         media_poster: false,
273         formats,
274         table_style_by_css: true,
275         table_use_colgroups: true,
276         file_picker_types: 'file image',
277         color_map: colorMap,
278         file_picker_callback: filePickerCallback,
279         paste_preprocess(plugin, args) {
280             const {content} = args;
281             if (content.indexOf('<img src="file://') !== -1) {
282                 args.content = '';
283             }
284         },
285         init_instance_callback(editor) {
286             const head = editor.getDoc().querySelector('head');
287             head.innerHTML += fetchCustomHeadContent();
288         },
289         setup(editor) {
290             registerCustomIcons(editor);
291             registerAdditionalToolbars(editor);
292             getSetupCallback(options)(editor);
293         },
294     };
295 }
296
297 /**
298  * @param {WysiwygConfigOptions} options
299  * @return {RawEditorOptions}
300  */
301 export function buildForInput(options) {
302     // Set language
303     window.tinymce.addI18n(options.language, options.translationMap);
304
305     // BookStack Version
306     const version = document.querySelector('script[src*="/dist/app.js"]').getAttribute('src').split('?version=')[1];
307
308     // Return config object
309     return {
310         width: '100%',
311         height: '185px',
312         target: options.containerElement,
313         cache_suffix: `?version=${version}`,
314         content_css: [
315             window.baseUrl('/dist/styles.css'),
316         ],
317         branding: false,
318         skin: options.darkMode ? 'tinymce-5-dark' : 'tinymce-5',
319         body_class: 'wysiwyg-input',
320         browser_spellcheck: true,
321         relative_urls: false,
322         language: options.language,
323         directionality: options.textDirection,
324         remove_script_host: false,
325         document_base_url: window.baseUrl('/'),
326         end_container_on_empty_block: true,
327         remove_trailing_brs: false,
328         statusbar: false,
329         menubar: false,
330         plugins: 'link autolink lists',
331         contextmenu: false,
332         toolbar: 'bold italic link bullist numlist',
333         content_style: getContentStyle(options),
334         color_map: colorMap,
335         file_picker_types: 'file',
336         file_picker_callback: filePickerCallback,
337         init_instance_callback(editor) {
338             const head = editor.getDoc().querySelector('head');
339             head.innerHTML += fetchCustomHeadContent();
340         },
341         setup(editor) {
342             //
343         },
344     };
345 }
346
347 /**
348  * @typedef {Object} WysiwygConfigOptions
349  * @property {Element} containerElement
350  * @property {string} language
351  * @property {boolean} darkMode
352  * @property {string} textDirection
353  * @property {string} drawioUrl
354  * @property {int} pageId
355  * @property {Object} translations
356  * @property {Object} translationMap
357  */