]> BookStack Code Mirror - bookstack/blob - resources/js/wysiwyg/config.js
Tables: Added fix to ensure proper clear formatting on cell selections
[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 {handleClearFormattingOnTableCells, 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         handleClearFormattingOnTableCells(editor);
195
196         // Custom handler hook
197         window.$events.emitPublic(options.containerElement, 'editor-tinymce::setup', {editor});
198
199         // Inline code format button
200         editor.ui.registry.addButton('inlinecode', {
201             tooltip: 'Inline code',
202             icon: 'sourcecode',
203             onAction() {
204                 editor.execCommand('mceToggleFormat', false, 'code');
205             },
206         });
207     };
208 }
209
210 /**
211  * @param {WysiwygConfigOptions} options
212  */
213 function getContentStyle(options) {
214     return `
215 html, body, html.dark-mode {
216     background: ${options.darkMode ? '#222' : '#fff'};
217
218 body {
219     padding-left: 15px !important;
220     padding-right: 15px !important; 
221     height: initial !important;
222     margin:0!important; 
223     margin-left: auto! important;
224     margin-right: auto !important;
225     overflow-y: hidden !important;
226 }`.trim().replace('\n', '');
227 }
228
229 /**
230  * @param {WysiwygConfigOptions} options
231  * @return {Object}
232  */
233 export function buildForEditor(options) {
234     // Set language
235     window.tinymce.addI18n(options.language, options.translationMap);
236
237     // BookStack Version
238     const version = document.querySelector('script[src*="/dist/app.js"]').getAttribute('src').split('?version=')[1];
239
240     // Return config object
241     return {
242         width: '100%',
243         height: '100%',
244         selector: '#html-editor',
245         cache_suffix: `?version=${version}`,
246         content_css: [
247             window.baseUrl('/dist/styles.css'),
248         ],
249         branding: false,
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,
260         statusbar: false,
261         menubar: 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',
266         valid_children: [
267             '-div[p|h1|h2|h3|h4|h5|h6|blockquote|code-block]',
268             '+div[pre|img]',
269             '-doc-root[doc-root|#text]',
270             '-li[details]',
271             '+code-block[pre]',
272             '+doc-root[p|h1|h2|h3|h4|h5|h6|blockquote|code-block|div|hr]',
273         ].join(','),
274         plugins: gatherPlugins(options),
275         contextmenu: false,
276         toolbar: getPrimaryToolbar(options),
277         content_style: getContentStyle(options),
278         style_formats: styleFormats,
279         style_formats_merge: false,
280         media_alt_source: false,
281         media_poster: false,
282         formats,
283         table_style_by_css: true,
284         table_use_colgroups: true,
285         file_picker_types: 'file image',
286         color_map: colorMap,
287         file_picker_callback: filePickerCallback,
288         paste_preprocess(plugin, args) {
289             const {content} = args;
290             if (content.indexOf('<img src="file://') !== -1) {
291                 args.content = '';
292             }
293         },
294         init_instance_callback(editor) {
295             addCustomHeadContent(editor.getDoc());
296         },
297         setup(editor) {
298             registerCustomIcons(editor);
299             registerAdditionalToolbars(editor);
300             getSetupCallback(options)(editor);
301         },
302     };
303 }
304
305 /**
306  * @param {WysiwygConfigOptions} options
307  * @return {RawEditorOptions}
308  */
309 export function buildForInput(options) {
310     // Set language
311     window.tinymce.addI18n(options.language, options.translationMap);
312
313     // BookStack Version
314     const version = document.querySelector('script[src*="/dist/app.js"]').getAttribute('src').split('?version=')[1];
315
316     // Return config object
317     return {
318         width: '100%',
319         height: '185px',
320         target: options.containerElement,
321         cache_suffix: `?version=${version}`,
322         content_css: [
323             window.baseUrl('/dist/styles.css'),
324         ],
325         branding: false,
326         skin: options.darkMode ? 'tinymce-5-dark' : 'tinymce-5',
327         body_class: 'wysiwyg-input',
328         browser_spellcheck: true,
329         relative_urls: false,
330         language: options.language,
331         directionality: options.textDirection,
332         remove_script_host: false,
333         document_base_url: window.baseUrl('/'),
334         end_container_on_empty_block: true,
335         remove_trailing_brs: false,
336         statusbar: false,
337         menubar: false,
338         plugins: 'link autolink lists',
339         contextmenu: false,
340         toolbar: 'bold italic link bullist numlist',
341         content_style: getContentStyle(options),
342         file_picker_types: 'file',
343         valid_elements: 'p,a[href|title],ol,ul,li,strong,em,br',
344         file_picker_callback: filePickerCallback,
345         init_instance_callback(editor) {
346             addCustomHeadContent(editor.getDoc());
347
348             editor.contentDocument.documentElement.classList.toggle('dark-mode', options.darkMode);
349         },
350     };
351 }
352
353 /**
354  * @typedef {Object} WysiwygConfigOptions
355  * @property {Element} containerElement
356  * @property {string} language
357  * @property {boolean} darkMode
358  * @property {string} textDirection
359  * @property {string} drawioUrl
360  * @property {int} pageId
361  * @property {Object} translations
362  * @property {Object} translationMap
363  */