]> BookStack Code Mirror - bookstack/blob - resources/js/wysiwyg/config.js
WYSIWYG details: Improved usage reliability and dark mdoe styles
[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
6 import {getPlugin as getCodeeditorPlugin} from "./plugin-codeeditor";
7 import {getPlugin as getDrawioPlugin} from "./plugin-drawio";
8 import {getPlugin as getCustomhrPlugin} from "./plugins-customhr";
9 import {getPlugin as getImagemanagerPlugin} from "./plugins-imagemanager";
10 import {getPlugin as getAboutPlugin} from "./plugins-about";
11 import {getPlugin as getDetailsPlugin} from "./plugins-details";
12
13 const style_formats = [
14     {title: "Large Header", format: "h2", preview: 'color: blue;'},
15     {title: "Medium Header", format: "h3"},
16     {title: "Small Header", format: "h4"},
17     {title: "Tiny Header", format: "h5"},
18     {title: "Paragraph", format: "p", exact: true, classes: ''},
19     {title: "Blockquote", format: "blockquote"},
20     {
21         title: "Callouts", items: [
22             {title: "Information", format: 'calloutinfo'},
23             {title: "Success", format: 'calloutsuccess'},
24             {title: "Warning", format: 'calloutwarning'},
25             {title: "Danger", format: 'calloutdanger'}
26         ]
27     },
28 ];
29
30 const formats = {
31     alignleft: {selector: 'p,h1,h2,h3,h4,h5,h6,td,th,div,ul,ol,li,table,img', classes: 'align-left'},
32     aligncenter: {selector: 'p,h1,h2,h3,h4,h5,h6,td,th,div,ul,ol,li,table,img', classes: 'align-center'},
33     alignright: {selector: 'p,h1,h2,h3,h4,h5,h6,td,th,div,ul,ol,li,table,img', classes: 'align-right'},
34     calloutsuccess: {block: 'p', exact: true, attributes: {class: 'callout success'}},
35     calloutinfo: {block: 'p', exact: true, attributes: {class: 'callout info'}},
36     calloutwarning: {block: 'p', exact: true, attributes: {class: 'callout warning'}},
37     calloutdanger: {block: 'p', exact: true, attributes: {class: 'callout danger'}}
38 };
39
40 function file_picker_callback(callback, value, meta) {
41
42     // field_name, url, type, win
43     if (meta.filetype === 'file') {
44         window.EntitySelectorPopup.show(entity => {
45             callback(entity.link, {
46                 text: entity.name,
47                 title: entity.name,
48             });
49         });
50     }
51
52     if (meta.filetype === 'image') {
53         // Show image manager
54         window.ImageManager.show(function (image) {
55             callback(image.url, {alt: image.name});
56         }, 'gallery');
57     }
58
59 }
60
61 /**
62  * @param {WysiwygConfigOptions} options
63  * @return {{toolbar: string, groupButtons: Object<string, Object>}}
64  */
65 function buildToolbar(options) {
66     const textDirPlugins = options.textDirection === 'rtl' ? 'ltr rtl' : '';
67
68     const groupButtons = {
69         formatoverflow: {
70             icon: 'more-drawer',
71             tooltip: 'More',
72             items: 'strikethrough superscript subscript inlinecode removeformat'
73         },
74         listoverflow: {
75             icon: 'more-drawer',
76             tooltip: 'More',
77             items: 'outdent indent'
78         },
79         insertoverflow: {
80             icon: 'more-drawer',
81             tooltip: 'More',
82             items: 'hr codeeditor drawio media details'
83         }
84     };
85
86     const toolbar = [
87         'undo redo',
88         'styleselect',
89         'bold italic underline forecolor backcolor formatoverflow',
90         'alignleft aligncenter alignright alignjustify',
91         'bullist numlist listoverflow',
92         textDirPlugins,
93         'link table imagemanager-insert insertoverflow',
94         'code about fullscreen'
95     ];
96
97     return {
98         toolbar: toolbar.filter(row => Boolean(row)).join(' | '),
99         groupButtons,
100     };
101 }
102
103 /**
104  * @param {WysiwygConfigOptions} options
105  * @return {string}
106  */
107 function gatherPlugins(options) {
108     const plugins = [
109         "image",
110         "imagetools",
111         "table",
112         "paste",
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         options.textDirection === 'rtl' ? 'directionality' : '',
126     ];
127
128     window.tinymce.PluginManager.add('codeeditor', getCodeeditorPlugin(options));
129     window.tinymce.PluginManager.add('customhr', getCustomhrPlugin(options));
130     window.tinymce.PluginManager.add('imagemanager', getImagemanagerPlugin(options));
131     window.tinymce.PluginManager.add('about', getAboutPlugin(options));
132     window.tinymce.PluginManager.add('details', getDetailsPlugin(options));
133
134     if (options.drawioUrl) {
135         window.tinymce.PluginManager.add('drawio', getDrawioPlugin(options));
136         plugins.push('drawio');
137     }
138
139     return plugins.filter(plugin => Boolean(plugin)).join(' ');
140 }
141
142 /**
143  * Fetch custom HTML head content from the parent page head into the editor.
144  */
145 function fetchCustomHeadContent() {
146     const headContentLines = document.head.innerHTML.split("\n");
147     const startLineIndex = headContentLines.findIndex(line => line.trim() === '<!-- Start: custom user content -->');
148     const endLineIndex = headContentLines.findIndex(line => line.trim() === '<!-- End: custom user content -->');
149     if (startLineIndex === -1 || endLineIndex === -1) {
150         return ''
151     }
152     return headContentLines.slice(startLineIndex + 1, endLineIndex).join('\n');
153 }
154
155 /**
156  * @param {WysiwygConfigOptions} options
157  * @return {function(Editor)}
158  */
159 function getSetupCallback(options) {
160     return function(editor) {
161         editor.on('ExecCommand change input NodeChange ObjectResized', editorChange);
162         listenForCommonEvents(editor);
163         registerShortcuts(editor);
164         listenForDragAndPaste(editor, options);
165
166         editor.on('init', () => {
167             editorChange();
168             scrollToQueryString(editor);
169             window.editor = editor;
170         });
171
172         function editorChange() {
173             const content = editor.getContent();
174             if (options.darkMode) {
175                 editor.contentDocument.documentElement.classList.add('dark-mode');
176             }
177             window.$events.emit('editor-html-change', content);
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 // Custom "Document Root" element, a custom element to identify/define
214 // block that may act as another "editable body".
215 // Using a custom node means we can identify and add/remove these as desired
216 // without affecting user content.
217 class DocRootElement extends HTMLDivElement {
218     constructor() {
219         super();
220     }
221 }
222
223 /**
224  * @param {WysiwygConfigOptions} options
225  * @return {Object}
226  */
227 export function build(options) {
228
229     // Set language
230     window.tinymce.addI18n(options.language, options.translationMap);
231     // Build toolbar content
232     const {toolbar, groupButtons: toolBarGroupButtons} = buildToolbar(options);
233     // Define our custom root node
234     customElements.define('doc-root', DocRootElement, {extends: 'div'});
235
236     // Return config object
237     return {
238         width: '100%',
239         height: '100%',
240         selector: '#html-editor',
241         content_css: [
242             window.baseUrl('/dist/styles.css'),
243         ],
244         branding: false,
245         skin: options.darkMode ? 'oxide-dark' : 'oxide',
246         body_class: 'page-content',
247         browser_spellcheck: true,
248         relative_urls: false,
249         language: options.language,
250         directionality: options.textDirection,
251         remove_script_host: false,
252         document_base_url: window.baseUrl('/'),
253         end_container_on_empty_block: true,
254         statusbar: false,
255         menubar: false,
256         paste_data_images: false,
257         extended_valid_elements: 'pre[*],svg[*],div[drawio-diagram],details[*],summary[*],doc-root',
258         automatic_uploads: false,
259         custom_elements: 'doc-root',
260         valid_children: "-div[p|h1|h2|h3|h4|h5|h6|blockquote|div],+div[pre],+div[img],+doc-root[p|h1|h2|h3|h4|h5|h6|blockquote|pre|img|ul|ol],-doc-root[doc-root|#text]",
261         plugins: gatherPlugins(options),
262         imagetools_toolbar: 'imageoptions',
263         contextmenu: false,
264         toolbar: toolbar,
265         content_style: getContentStyle(options),
266         style_formats,
267         style_formats_merge: false,
268         media_alt_source: false,
269         media_poster: false,
270         formats,
271         file_picker_types: 'file image',
272         file_picker_callback,
273         paste_preprocess(plugin, args) {
274             const content = args.content;
275             if (content.indexOf('<img src="file://') !== -1) {
276                 args.content = '';
277             }
278         },
279         init_instance_callback(editor) {
280             const head = editor.getDoc().querySelector('head');
281             head.innerHTML += fetchCustomHeadContent();
282         },
283         setup(editor) {
284             for (const [key, config] of Object.entries(toolBarGroupButtons)) {
285                 editor.ui.registry.addGroupToolbarButton(key, config);
286             }
287             getSetupCallback(options)(editor);
288         },
289     };
290 }
291
292 /**
293  * @typedef {Object} WysiwygConfigOptions
294  * @property {Element} containerElement
295  * @property {string} language
296  * @property {boolean} darkMode
297  * @property {string} textDirection
298  * @property {string} drawioUrl
299  * @property {int} pageId
300  * @property {Object} translations
301  * @property {Object} translationMap
302  */