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