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