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