]> BookStack Code Mirror - bookstack/blob - resources/assets/js/pages/page-form.js
Merge branch 'master' of github.com:BookStackApp/BookStack
[bookstack] / resources / assets / js / pages / page-form.js
1 "use strict";
2
3 const Code = require('../code');
4
5 /**
6  * Handle pasting images from clipboard.
7  * @param {ClipboardEvent} event
8  * @param editor
9  */
10 function editorPaste(event, editor) {
11     if (!event.clipboardData || !event.clipboardData.items) return;
12     let items = event.clipboardData.items;
13
14     for (let i = 0; i < items.length; i++) {
15         if (items[i].type.indexOf("image") === -1) continue;
16         event.preventDefault();
17
18         let id = "image-" + Math.random().toString(16).slice(2);
19         let loadingImage = window.baseUrl('/loading.gif');
20         let file = items[i].getAsFile();
21         setTimeout(() => {
22             editor.insertContent(`<p><img src="${loadingImage}" id="${id}"></p>`);
23             uploadImageFile(file).then(resp => {
24                 editor.dom.setAttrib(id, 'src', resp.thumbs.display);
25             }).catch(err => {
26                 editor.dom.remove(id);
27                 window.$events.emit('error', trans('errors.image_upload_error'));
28                 console.log(err);
29             });
30         }, 10);
31     }
32 }
33
34 /**
35  * Upload an image file to the server
36  * @param {File} file
37  */
38 function uploadImageFile(file) {
39     if (file === null || file.type.indexOf('image') !== 0) return Promise.reject(`Not an image file`);
40
41     let ext = 'png';
42     if (file.name) {
43         let fileNameMatches = file.name.match(/\.(.+)$/);
44         if (fileNameMatches.length > 1) ext = fileNameMatches[1];
45     }
46
47     let remoteFilename = "image-" + Date.now() + "." + ext;
48     let formData = new FormData();
49     formData.append('file', file, remoteFilename);
50
51     return window.$http.post('/images/gallery/upload', formData).then(resp => (resp.data));
52 }
53
54 function registerEditorShortcuts(editor) {
55     // Headers
56     for (let i = 1; i < 5; i++) {
57         editor.shortcuts.add('meta+' + i, '', ['FormatBlock', false, 'h' + (i+1)]);
58     }
59
60     // Other block shortcuts
61     editor.shortcuts.add('meta+5', '', ['FormatBlock', false, 'p']);
62     editor.shortcuts.add('meta+d', '', ['FormatBlock', false, 'p']);
63     editor.shortcuts.add('meta+6', '', ['FormatBlock', false, 'blockquote']);
64     editor.shortcuts.add('meta+q', '', ['FormatBlock', false, 'blockquote']);
65     editor.shortcuts.add('meta+7', '', ['codeeditor', false, 'pre']);
66     editor.shortcuts.add('meta+e', '', ['codeeditor', false, 'pre']);
67     editor.shortcuts.add('meta+8', '', ['FormatBlock', false, 'code']);
68     editor.shortcuts.add('meta+shift+E', '', ['FormatBlock', false, 'code']);
69     // Loop through callout styles
70     editor.shortcuts.add('meta+9', '', function() {
71         let selectedNode = editor.selection.getNode();
72         let formats = ['info', 'success', 'warning', 'danger'];
73
74         if (!selectedNode || selectedNode.className.indexOf('callout') === -1) {
75             editor.formatter.apply('calloutinfo');
76             return;
77         }
78
79         for (let i = 0; i < formats.length; i++) {
80             if (selectedNode.className.indexOf(formats[i]) === -1) continue;
81             let newFormat = (i === formats.length -1) ? formats[0] : formats[i+1];
82             editor.formatter.apply('callout' + newFormat);
83             return;
84         }
85         editor.formatter.apply('p');
86     });
87 }
88
89
90 /**
91  * Create and enable our custom code plugin
92  */
93 function codePlugin() {
94
95     function elemIsCodeBlock(elem) {
96         return elem.className === 'CodeMirrorContainer';
97     }
98
99     function showPopup(editor) {
100         let selectedNode = editor.selection.getNode();
101
102         if (!elemIsCodeBlock(selectedNode)) {
103             let providedCode = editor.selection.getNode().textContent;
104             window.vues['code-editor'].open(providedCode, '', (code, lang) => {
105                 let wrap = document.createElement('div');
106                 wrap.innerHTML = `<pre><code class="language-${lang}"></code></pre>`;
107                 wrap.querySelector('code').innerText = code;
108
109                 editor.formatter.toggle('pre');
110                 let node = editor.selection.getNode();
111                 editor.dom.setHTML(node, wrap.querySelector('pre').innerHTML);
112                 editor.fire('SetContent');
113             });
114             return;
115         }
116
117         let lang = selectedNode.hasAttribute('data-lang') ? selectedNode.getAttribute('data-lang') : '';
118         let currentCode = selectedNode.querySelector('textarea').textContent;
119
120         window.vues['code-editor'].open(currentCode, lang, (code, lang) => {
121             let editorElem = selectedNode.querySelector('.CodeMirror');
122             let cmInstance = editorElem.CodeMirror;
123             if (cmInstance) {
124                 Code.setContent(cmInstance, code);
125                 Code.setMode(cmInstance, lang);
126             }
127             let textArea = selectedNode.querySelector('textarea');
128             if (textArea) textArea.textContent = code;
129             selectedNode.setAttribute('data-lang', lang);
130         });
131     }
132
133     function codeMirrorContainerToPre($codeMirrorContainer) {
134         let textArea = $codeMirrorContainer[0].querySelector('textarea');
135         let code = textArea.textContent;
136         let lang = $codeMirrorContainer[0].getAttribute('data-lang');
137
138         $codeMirrorContainer.removeAttr('contentEditable');
139         let $pre = $('<pre></pre>');
140         $pre.append($('<code></code>').each((index, elem) => {
141             // Needs to be textContent since innerText produces BR:s
142             elem.textContent = code;
143         }).attr('class', `language-${lang}`));
144         $codeMirrorContainer.replaceWith($pre);
145     }
146
147     window.tinymce.PluginManager.add('codeeditor', function(editor, url) {
148
149         let $ = editor.$;
150
151         editor.addButton('codeeditor', {
152             text: 'Code block',
153             icon: false,
154             cmd: 'codeeditor'
155         });
156
157         editor.addCommand('codeeditor', () => {
158             showPopup(editor);
159         });
160
161         // Convert
162         editor.on('PreProcess', function (e) {
163             $('div.CodeMirrorContainer', e.node).
164             each((index, elem) => {
165                 let $elem = $(elem);
166                 codeMirrorContainerToPre($elem);
167             });
168         });
169
170         editor.on('dblclick', event => {
171             let selectedNode = editor.selection.getNode();
172             if (!elemIsCodeBlock(selectedNode)) return;
173             showPopup(editor);
174         });
175
176         editor.on('SetContent', function () {
177
178             // Recover broken codemirror instances
179             $('.CodeMirrorContainer').filter((index ,elem) => {
180                 return typeof elem.querySelector('.CodeMirror').CodeMirror === 'undefined';
181             }).each((index, elem) => {
182                 codeMirrorContainerToPre($(elem));
183             });
184
185             let codeSamples = $('body > pre').filter((index, elem) => {
186                 return elem.contentEditable !== "false";
187             });
188
189             if (!codeSamples.length) return;
190             editor.undoManager.transact(function () {
191                 codeSamples.each((index, elem) => {
192                     Code.wysiwygView(elem);
193                 });
194             });
195         });
196
197     });
198 }
199
200 function hrPlugin() {
201     window.tinymce.PluginManager.add('customhr', function (editor) {
202         editor.addCommand('InsertHorizontalRule', function () {
203             let hrElem = document.createElement('hr');
204             let cNode = editor.selection.getNode();
205             let parentNode = cNode.parentNode;
206             parentNode.insertBefore(hrElem, cNode);
207         });
208
209         editor.addButton('hr', {
210             icon: 'hr',
211             tooltip: 'Horizontal line',
212             cmd: 'InsertHorizontalRule'
213         });
214
215         editor.addMenuItem('hr', {
216             icon: 'hr',
217             text: 'Horizontal line',
218             cmd: 'InsertHorizontalRule',
219             context: 'insert'
220         });
221     });
222 }
223
224 module.exports = function() {
225     hrPlugin();
226     codePlugin();
227     let settings = {
228         selector: '#html-editor',
229         content_css: [
230             window.baseUrl('/css/styles.css'),
231             window.baseUrl('/libs/material-design-iconic-font/css/material-design-iconic-font.min.css')
232         ],
233         branding: false,
234         body_class: 'page-content',
235         browser_spellcheck: true,
236         relative_urls: false,
237         remove_script_host: false,
238         document_base_url: window.baseUrl('/'),
239         statusbar: false,
240         menubar: false,
241         paste_data_images: false,
242         extended_valid_elements: 'pre[*]',
243         automatic_uploads: false,
244         valid_children: "-div[p|h1|h2|h3|h4|h5|h6|blockquote],+div[pre]",
245         plugins: "image table textcolor paste link autolink fullscreen imagetools code customhr autosave lists codeeditor",
246         imagetools_toolbar: 'imageoptions',
247         toolbar: "undo redo | styleselect | bold italic underline strikethrough superscript subscript | forecolor backcolor | alignleft aligncenter alignright alignjustify | bullist numlist outdent indent | table image-insert link hr | removeformat code fullscreen",
248         content_style: "body {padding-left: 15px !important; padding-right: 15px !important; margin:0!important; margin-left:auto!important;margin-right:auto!important;}",
249         style_formats: [
250             {title: "Header Large", format: "h2"},
251             {title: "Header Medium", format: "h3"},
252             {title: "Header Small", format: "h4"},
253             {title: "Header Tiny", format: "h5"},
254             {title: "Paragraph", format: "p", exact: true, classes: ''},
255             {title: "Blockquote", format: "blockquote"},
256             {title: "Code Block", icon: "code", cmd: 'codeeditor', format: 'codeeditor'},
257             {title: "Inline Code", icon: "code", inline: "code"},
258             {title: "Callouts", items: [
259                 {title: "Info", format: 'calloutinfo'},
260                 {title: "Success", format: 'calloutsuccess'},
261                 {title: "Warning", format: 'calloutwarning'},
262                 {title: "Danger", format: 'calloutdanger'}
263             ]},
264         ],
265         style_formats_merge: false,
266         formats: {
267             codeeditor: {selector: 'p,h1,h2,h3,h4,h5,h6,td,th,div'},
268             alignleft: {selector: 'p,h1,h2,h3,h4,h5,h6,td,th,div,ul,ol,li,table,img', classes: 'align-left'},
269             aligncenter: {selector: 'p,h1,h2,h3,h4,h5,h6,td,th,div,ul,ol,li,table,img', classes: 'align-center'},
270             alignright: {selector: 'p,h1,h2,h3,h4,h5,h6,td,th,div,ul,ol,li,table,img', classes: 'align-right'},
271             calloutsuccess: {block: 'p', exact: true, attributes: {class: 'callout success'}},
272             calloutinfo: {block: 'p', exact: true, attributes: {class: 'callout info'}},
273             calloutwarning: {block: 'p', exact: true, attributes: {class: 'callout warning'}},
274             calloutdanger: {block: 'p', exact: true, attributes: {class: 'callout danger'}}
275         },
276         file_browser_callback: function (field_name, url, type, win) {
277
278             if (type === 'file') {
279                 window.EntitySelectorPopup.show(function(entity) {
280                     let originalField = win.document.getElementById(field_name);
281                     originalField.value = entity.link;
282                     $(originalField).closest('.mce-form').find('input').eq(2).val(entity.name);
283                 });
284             }
285
286             if (type === 'image') {
287                 // Show image manager
288                 window.ImageManager.show(function (image) {
289
290                     // Set popover link input to image url then fire change event
291                     // to ensure the new value sticks
292                     win.document.getElementById(field_name).value = image.url;
293                     if ("createEvent" in document) {
294                         let evt = document.createEvent("HTMLEvents");
295                         evt.initEvent("change", false, true);
296                         win.document.getElementById(field_name).dispatchEvent(evt);
297                     } else {
298                         win.document.getElementById(field_name).fireEvent("onchange");
299                     }
300
301                     // Replace the actively selected content with the linked image
302                     let html = `<a href="${image.url}" target="_blank">`;
303                     html += `<img src="${image.thumbs.display}" alt="${image.name}">`;
304                     html += '</a>';
305                     win.tinyMCE.activeEditor.execCommand('mceInsertContent', false, html);
306                 });
307             }
308
309         },
310         paste_preprocess: function (plugin, args) {
311             let content = args.content;
312             if (content.indexOf('<img src="file://') !== -1) {
313                 args.content = '';
314             }
315         },
316         extraSetups: [],
317         setup: function (editor) {
318
319             // Run additional setup actions
320             // Used by the angular side of things
321             for (let i = 0; i < settings.extraSetups.length; i++) {
322                 settings.extraSetups[i](editor);
323             }
324
325             registerEditorShortcuts(editor);
326
327             let wrap;
328
329             function hasTextContent(node) {
330                 return node && !!( node.textContent || node.innerText );
331             }
332
333             editor.on('dragstart', function () {
334                 let node = editor.selection.getNode();
335
336                 if (node.nodeName !== 'IMG') return;
337                 wrap = editor.dom.getParent(node, '.mceTemp');
338
339                 if (!wrap && node.parentNode.nodeName === 'A' && !hasTextContent(node.parentNode)) {
340                     wrap = node.parentNode;
341                 }
342             });
343
344             editor.on('drop', function (event) {
345                 let dom = editor.dom,
346                     rng = tinymce.dom.RangeUtils.getCaretRangeFromPoint(event.clientX, event.clientY, editor.getDoc());
347
348                 // Don't allow anything to be dropped in a captioned image.
349                 if (dom.getParent(rng.startContainer, '.mceTemp')) {
350                     event.preventDefault();
351                 } else if (wrap) {
352                     event.preventDefault();
353
354                     editor.undoManager.transact(function () {
355                         editor.selection.setRng(rng);
356                         editor.selection.setNode(wrap);
357                         dom.remove(wrap);
358                     });
359                 }
360
361                 wrap = null;
362             });
363
364             // Custom Image picker button
365             editor.addButton('image-insert', {
366                 title: 'My title',
367                 icon: 'image',
368                 tooltip: 'Insert an image',
369                 onclick: function () {
370                     window.ImageManager.show(function (image) {
371                         let html = `<a href="${image.url}" target="_blank">`;
372                         html += `<img src="${image.thumbs.display}" alt="${image.name}">`;
373                         html += '</a>';
374                         editor.execCommand('mceInsertContent', false, html);
375                     });
376                 }
377             });
378
379             // Paste image-uploads
380             editor.on('paste', event => { editorPaste(event, editor) });
381         }
382     };
383     return settings;
384 };