]> BookStack Code Mirror - bookstack/blob - resources/js/components/markdown-editor.js
Better standardised and fixes areas of image pasting
[bookstack] / resources / js / components / markdown-editor.js
1 import MarkdownIt from "markdown-it";
2 import mdTasksLists from 'markdown-it-task-lists';
3 import code from '../services/code';
4 import Clipboard from "../services/clipboard";
5 import {debounce} from "../services/util";
6
7 import DrawIO from "../services/drawio";
8
9 class MarkdownEditor {
10
11     constructor(elem) {
12         this.elem = elem;
13
14         const pageEditor = document.getElementById('page-editor');
15         this.pageId = pageEditor.getAttribute('page-id');
16         this.textDirection = pageEditor.getAttribute('text-direction');
17
18         this.markdown = new MarkdownIt({html: true});
19         this.markdown.use(mdTasksLists, {label: true});
20
21         this.display = this.elem.querySelector('.markdown-display');
22
23         this.displayStylesLoaded = false;
24         this.input = this.elem.querySelector('textarea');
25         this.htmlInput = this.elem.querySelector('input[name=html]');
26         this.cm = code.markdownEditor(this.input);
27
28         this.onMarkdownScroll = this.onMarkdownScroll.bind(this);
29
30         this.display.addEventListener('load', () => {
31             this.displayDoc = this.display.contentDocument;
32             this.init();
33         });
34
35         window.$events.emitPublic(elem, 'editor-markdown::setup', {
36             markdownIt: this.markdown,
37             displayEl: this.display,
38             codeMirrorInstance: this.cm,
39         });
40     }
41
42     init() {
43
44         let lastClick = 0;
45
46         // Prevent markdown display link click redirect
47         this.displayDoc.addEventListener('click', event => {
48             let isDblClick = Date.now() - lastClick < 300;
49
50             let link = event.target.closest('a');
51             if (link !== null) {
52                 event.preventDefault();
53                 window.open(link.getAttribute('href'));
54                 return;
55             }
56
57             let drawing = event.target.closest('[drawio-diagram]');
58             if (drawing !== null && isDblClick) {
59                 this.actionEditDrawing(drawing);
60                 return;
61             }
62
63             lastClick = Date.now();
64         });
65
66         // Button actions
67         this.elem.addEventListener('click', event => {
68             let button = event.target.closest('button[data-action]');
69             if (button === null) return;
70
71             let action = button.getAttribute('data-action');
72             if (action === 'insertImage') this.actionInsertImage();
73             if (action === 'insertLink') this.actionShowLinkSelector();
74             if (action === 'insertDrawing' && (event.ctrlKey || event.metaKey)) {
75                 this.actionShowImageManager();
76                 return;
77             }
78             if (action === 'insertDrawing') this.actionStartDrawing();
79         });
80
81         // Mobile section toggling
82         this.elem.addEventListener('click', event => {
83             const toolbarLabel = event.target.closest('.editor-toolbar-label');
84             if (!toolbarLabel) return;
85
86             const currentActiveSections = this.elem.querySelectorAll('.markdown-editor-wrap');
87             for (let activeElem of currentActiveSections) {
88                 activeElem.classList.remove('active');
89             }
90
91             toolbarLabel.closest('.markdown-editor-wrap').classList.add('active');
92         });
93
94         window.$events.listen('editor-markdown-update', value => {
95             this.cm.setValue(value);
96             this.updateAndRender();
97         });
98
99         this.codeMirrorSetup();
100         this.listenForBookStackEditorEvents();
101
102         // Scroll to text if needed.
103         const queryParams = (new URL(window.location)).searchParams;
104         const scrollText = queryParams.get('content-text');
105         if (scrollText) {
106             this.scrollToText(scrollText);
107         }
108     }
109
110     // Update the input content and render the display.
111     updateAndRender() {
112         const content = this.cm.getValue();
113         this.input.value = content;
114         const html = this.markdown.render(content);
115         window.$events.emit('editor-html-change', html);
116         window.$events.emit('editor-markdown-change', content);
117
118         // Set body content
119         this.displayDoc.body.className = 'page-content';
120         this.displayDoc.body.innerHTML = html;
121         this.htmlInput.value = html;
122
123         // Copy styles from page head and set custom styles for editor
124         this.loadStylesIntoDisplay();
125     }
126
127     loadStylesIntoDisplay() {
128         if (this.displayStylesLoaded) return;
129         this.displayDoc.documentElement.className = 'markdown-editor-display';
130
131         this.displayDoc.head.innerHTML = '';
132         const styles = document.head.querySelectorAll('style,link[rel=stylesheet]');
133         for (let style of styles) {
134             const copy = style.cloneNode(true);
135             this.displayDoc.head.appendChild(copy);
136         }
137
138         this.displayStylesLoaded = true;
139     }
140
141     onMarkdownScroll(lineCount) {
142         const elems = this.displayDoc.body.children;
143         if (elems.length <= lineCount) return;
144
145         const topElem = (lineCount === -1) ? elems[elems.length-1] : elems[lineCount];
146         topElem.scrollIntoView({ block: 'start', inline: 'nearest', behavior: 'smooth'});
147     }
148
149     codeMirrorSetup() {
150         const cm = this.cm;
151         const context = this;
152
153         // Text direction
154         // cm.setOption('direction', this.textDirection);
155         cm.setOption('direction', 'ltr'); // Will force to remain as ltr for now due to issues when HTML is in editor.
156         // Custom key commands
157         let metaKey = code.getMetaKey();
158         const extraKeys = {};
159         // Insert Image shortcut
160         extraKeys[`${metaKey}-Alt-I`] = function(cm) {
161             let selectedText = cm.getSelection();
162             let newText = `![${selectedText}](http://)`;
163             let cursorPos = cm.getCursor('from');
164             cm.replaceSelection(newText);
165             cm.setCursor(cursorPos.line, cursorPos.ch + newText.length -1);
166         };
167         // Save draft
168         extraKeys[`${metaKey}-S`] = cm => {window.$events.emit('editor-save-draft')};
169         // Save page
170         extraKeys[`${metaKey}-Enter`] = cm => {window.$events.emit('editor-save-page')};
171         // Show link selector
172         extraKeys[`Shift-${metaKey}-K`] = cm => {this.actionShowLinkSelector()};
173         // Insert Link
174         extraKeys[`${metaKey}-K`] = cm => {insertLink()};
175         // FormatShortcuts
176         extraKeys[`${metaKey}-1`] = cm => {replaceLineStart('##');};
177         extraKeys[`${metaKey}-2`] = cm => {replaceLineStart('###');};
178         extraKeys[`${metaKey}-3`] = cm => {replaceLineStart('####');};
179         extraKeys[`${metaKey}-4`] = cm => {replaceLineStart('#####');};
180         extraKeys[`${metaKey}-5`] = cm => {replaceLineStart('');};
181         extraKeys[`${metaKey}-d`] = cm => {replaceLineStart('');};
182         extraKeys[`${metaKey}-6`] = cm => {replaceLineStart('>');};
183         extraKeys[`${metaKey}-q`] = cm => {replaceLineStart('>');};
184         extraKeys[`${metaKey}-7`] = cm => {wrapSelection('\n```\n', '\n```');};
185         extraKeys[`${metaKey}-8`] = cm => {wrapSelection('`', '`');};
186         extraKeys[`Shift-${metaKey}-E`] = cm => {wrapSelection('`', '`');};
187         extraKeys[`${metaKey}-9`] = cm => {wrapSelection('<p class="callout info">', '</p>');};
188         cm.setOption('extraKeys', extraKeys);
189
190         // Update data on content change
191         cm.on('change', (instance, changeObj) => {
192             this.updateAndRender();
193         });
194
195         const onScrollDebounced = debounce((instance) => {
196             // Thanks to http://liuhao.im/english/2015/11/10/the-sync-scroll-of-markdown-editor-in-javascript.html
197             let scroll = instance.getScrollInfo();
198             let atEnd = scroll.top + scroll.clientHeight === scroll.height;
199             if (atEnd) {
200                 this.onMarkdownScroll(-1);
201                 return;
202             }
203
204             let lineNum = instance.lineAtHeight(scroll.top, 'local');
205             let range = instance.getRange({line: 0, ch: null}, {line: lineNum, ch: null});
206             let parser = new DOMParser();
207             let doc = parser.parseFromString(this.markdown.render(range), 'text/html');
208             let totalLines = doc.documentElement.querySelectorAll('body > *');
209             this.onMarkdownScroll(totalLines.length);
210         }, 100);
211
212         // Handle scroll to sync display view
213         cm.on('scroll', instance => {
214             onScrollDebounced(instance);
215         });
216
217         // Handle image paste
218         cm.on('paste', (cm, event) => {
219             const clipboard = new Clipboard(event.clipboardData || event.dataTransfer);
220
221             // Don't handle the event ourselves if no items exist of contains table-looking data
222             if (!clipboard.hasItems() || clipboard.containsTabularData()) {
223                 return;
224             }
225
226             const images = clipboard.getImages();
227             for (const image of images) {
228                 uploadImage(image);
229             }
230         });
231
232         // Handle image & content drag n drop
233         cm.on('drop', (cm, event) => {
234
235             const templateId = event.dataTransfer.getData('bookstack/template');
236             if (templateId) {
237                 const cursorPos = cm.coordsChar({left: event.pageX, top: event.pageY});
238                 cm.setCursor(cursorPos);
239                 event.preventDefault();
240                 window.$http.get(`/templates/${templateId}`).then(resp => {
241                     const content = resp.data.markdown || resp.data.html;
242                     cm.replaceSelection(content);
243                 });
244             }
245
246             const clipboard = new Clipboard(event.dataTransfer);
247             if (clipboard.hasItems()) {
248                 const cursorPos = cm.coordsChar({left: event.pageX, top: event.pageY});
249                 cm.setCursor(cursorPos);
250                 event.stopPropagation();
251                 event.preventDefault();
252                 const images = clipboard.getImages();
253                 for (const image of images) {
254                     uploadImage(image);
255                 }
256             }
257
258         });
259
260         // Helper to replace editor content
261         function replaceContent(search, replace) {
262             let text = cm.getValue();
263             let cursor = cm.listSelections();
264             cm.setValue(text.replace(search, replace));
265             cm.setSelections(cursor);
266         }
267
268         // Helper to replace the start of the line
269         function replaceLineStart(newStart) {
270             let cursor = cm.getCursor();
271             let lineContent = cm.getLine(cursor.line);
272             let lineLen = lineContent.length;
273             let lineStart = lineContent.split(' ')[0];
274
275             // Remove symbol if already set
276             if (lineStart === newStart) {
277                 lineContent = lineContent.replace(`${newStart} `, '');
278                 cm.replaceRange(lineContent, {line: cursor.line, ch: 0}, {line: cursor.line, ch: lineLen});
279                 cm.setCursor({line: cursor.line, ch: cursor.ch - (newStart.length + 1)});
280                 return;
281             }
282
283             let alreadySymbol = /^[#>`]/.test(lineStart);
284             let posDif = 0;
285             if (alreadySymbol) {
286                 posDif = newStart.length - lineStart.length;
287                 lineContent = lineContent.replace(lineStart, newStart).trim();
288             } else if (newStart !== '') {
289                 posDif = newStart.length + 1;
290                 lineContent = newStart + ' ' + lineContent;
291             }
292             cm.replaceRange(lineContent, {line: cursor.line, ch: 0}, {line: cursor.line, ch: lineLen});
293             cm.setCursor({line: cursor.line, ch: cursor.ch + posDif});
294         }
295
296         function wrapLine(start, end) {
297             let cursor = cm.getCursor();
298             let lineContent = cm.getLine(cursor.line);
299             let lineLen = lineContent.length;
300             let newLineContent = lineContent;
301
302             if (lineContent.indexOf(start) === 0 && lineContent.slice(-end.length) === end) {
303                 newLineContent = lineContent.slice(start.length, lineContent.length - end.length);
304             } else {
305                 newLineContent = `${start}${lineContent}${end}`;
306             }
307
308             cm.replaceRange(newLineContent, {line: cursor.line, ch: 0}, {line: cursor.line, ch: lineLen});
309             cm.setCursor({line: cursor.line, ch: cursor.ch + start.length});
310         }
311
312         function wrapSelection(start, end) {
313             let selection = cm.getSelection();
314             if (selection === '') return wrapLine(start, end);
315
316             let newSelection = selection;
317             let frontDiff = 0;
318             let endDiff = 0;
319
320             if (selection.indexOf(start) === 0 && selection.slice(-end.length) === end) {
321                 newSelection = selection.slice(start.length, selection.length - end.length);
322                 endDiff = -(end.length + start.length);
323             } else {
324                 newSelection = `${start}${selection}${end}`;
325                 endDiff = start.length + end.length;
326             }
327
328             let selections = cm.listSelections()[0];
329             cm.replaceSelection(newSelection);
330             let headFirst = selections.head.ch <= selections.anchor.ch;
331             selections.head.ch += headFirst ? frontDiff : endDiff;
332             selections.anchor.ch += headFirst ? endDiff : frontDiff;
333             cm.setSelections([selections]);
334         }
335
336         // Handle image upload and add image into markdown content
337         function uploadImage(file) {
338             if (file === null || file.type.indexOf('image') !== 0) return;
339             let ext = 'png';
340
341             if (file.name) {
342                 let fileNameMatches = file.name.match(/\.(.+)$/);
343                 if (fileNameMatches.length > 1) ext = fileNameMatches[1];
344             }
345
346             // Insert image into markdown
347             const id = "image-" + Math.random().toString(16).slice(2);
348             const placeholderImage = window.baseUrl(`/loading.gif#upload${id}`);
349             const selectedText = cm.getSelection();
350             const placeHolderText = `![${selectedText}](${placeholderImage})`;
351             const cursor = cm.getCursor();
352             cm.replaceSelection(placeHolderText);
353             cm.setCursor({line: cursor.line, ch: cursor.ch + selectedText.length + 3});
354
355             const remoteFilename = "image-" + Date.now() + "." + ext;
356             const formData = new FormData();
357             formData.append('file', file, remoteFilename);
358             formData.append('uploaded_to', context.pageId);
359
360             window.$http.post('/images/gallery', formData).then(resp => {
361                 const newContent = `[![${selectedText}](${resp.data.thumbs.display})](${resp.data.url})`;
362                 replaceContent(placeHolderText, newContent);
363             }).catch(err => {
364                 window.$events.emit('error', trans('errors.image_upload_error'));
365                 replaceContent(placeHolderText, selectedText);
366                 console.log(err);
367             });
368         }
369
370         function insertLink() {
371             let cursorPos = cm.getCursor('from');
372             let selectedText = cm.getSelection() || '';
373             let newText = `[${selectedText}]()`;
374             cm.focus();
375             cm.replaceSelection(newText);
376             let cursorPosDiff = (selectedText === '') ? -3 : -1;
377             cm.setCursor(cursorPos.line, cursorPos.ch + newText.length+cursorPosDiff);
378         }
379
380        this.updateAndRender();
381     }
382
383     actionInsertImage() {
384         const cursorPos = this.cm.getCursor('from');
385         window.ImageManager.show(image => {
386             let selectedText = this.cm.getSelection();
387             let newText = "[![" + (selectedText || image.name) + "](" + image.thumbs.display + ")](" + image.url + ")";
388             this.cm.focus();
389             this.cm.replaceSelection(newText);
390             this.cm.setCursor(cursorPos.line, cursorPos.ch + newText.length);
391         }, 'gallery');
392     }
393
394     actionShowImageManager() {
395         const cursorPos = this.cm.getCursor('from');
396         window.ImageManager.show(image => {
397             this.insertDrawing(image, cursorPos);
398         }, 'drawio');
399     }
400
401     // Show the popup link selector and insert a link when finished
402     actionShowLinkSelector() {
403         const cursorPos = this.cm.getCursor('from');
404         window.EntitySelectorPopup.show(entity => {
405             let selectedText = this.cm.getSelection() || entity.name;
406             let newText = `[${selectedText}](${entity.link})`;
407             this.cm.focus();
408             this.cm.replaceSelection(newText);
409             this.cm.setCursor(cursorPos.line, cursorPos.ch + newText.length);
410         });
411     }
412
413     // Show draw.io if enabled and handle save.
414     actionStartDrawing() {
415         if (document.querySelector('[drawio-enabled]').getAttribute('drawio-enabled') !== 'true') return;
416         let cursorPos = this.cm.getCursor('from');
417
418         DrawIO.show(() => {
419             return Promise.resolve('');
420         }, (pngData) => {
421             // let id = "image-" + Math.random().toString(16).slice(2);
422             // let loadingImage = window.baseUrl('/loading.gif');
423             let data = {
424                 image: pngData,
425                 uploaded_to: Number(document.getElementById('page-editor').getAttribute('page-id'))
426             };
427
428             window.$http.post(window.baseUrl('/images/drawio'), data).then(resp => {
429                 this.insertDrawing(resp.data, cursorPos);
430                 DrawIO.close();
431             }).catch(err => {
432                 window.$events.emit('error', trans('errors.image_upload_error'));
433                 console.log(err);
434             });
435         });
436     }
437
438     insertDrawing(image, originalCursor) {
439         const newText = `<div drawio-diagram="${image.id}"><img src="${image.url}"></div>`;
440         this.cm.focus();
441         this.cm.replaceSelection(newText);
442         this.cm.setCursor(originalCursor.line, originalCursor.ch + newText.length);
443     }
444
445     // Show draw.io if enabled and handle save.
446     actionEditDrawing(imgContainer) {
447         const drawingDisabled = document.querySelector('[drawio-enabled]').getAttribute('drawio-enabled') !== 'true';
448         if (drawingDisabled) {
449             return;
450         }
451
452         const cursorPos = this.cm.getCursor('from');
453         const drawingId = imgContainer.getAttribute('drawio-diagram');
454
455         DrawIO.show(() => {
456             return DrawIO.load(drawingId);
457         }, (pngData) => {
458
459             let data = {
460                 image: pngData,
461                 uploaded_to: Number(document.getElementById('page-editor').getAttribute('page-id'))
462             };
463
464             window.$http.post(window.baseUrl(`/images/drawio`), data).then(resp => {
465                 let newText = `<div drawio-diagram="${resp.data.id}"><img src="${resp.data.url}"></div>`;
466                 let newContent = this.cm.getValue().split('\n').map(line => {
467                     if (line.indexOf(`drawio-diagram="${drawingId}"`) !== -1) {
468                         return newText;
469                     }
470                     return line;
471                 }).join('\n');
472                 this.cm.setValue(newContent);
473                 this.cm.setCursor(cursorPos);
474                 this.cm.focus();
475                 DrawIO.close();
476             }).catch(err => {
477                 window.$events.emit('error', trans('errors.image_upload_error'));
478                 console.log(err);
479             });
480         });
481     }
482
483     // Scroll to a specified text
484     scrollToText(searchText) {
485         if (!searchText) {
486             return;
487         }
488
489         const content = this.cm.getValue();
490         const lines = content.split(/\r?\n/);
491         let lineNumber = lines.findIndex(line => {
492             return line && line.indexOf(searchText) !== -1;
493         });
494
495         if (lineNumber === -1) {
496             return;
497         }
498
499         this.cm.scrollIntoView({
500             line: lineNumber,
501         }, 200);
502         this.cm.focus();
503         // set the cursor location.
504         this.cm.setCursor({
505             line: lineNumber,
506             char: lines[lineNumber].length
507         })
508     }
509
510     listenForBookStackEditorEvents() {
511
512         function getContentToInsert({html, markdown}) {
513             return markdown || html;
514         }
515
516         // Replace editor content
517         window.$events.listen('editor::replace', (eventContent) => {
518             const markdown = getContentToInsert(eventContent);
519             this.cm.setValue(markdown);
520         });
521
522         // Append editor content
523         window.$events.listen('editor::append', (eventContent) => {
524             const cursorPos = this.cm.getCursor('from');
525             const markdown = getContentToInsert(eventContent);
526             const content = this.cm.getValue() + '\n' + markdown;
527             this.cm.setValue(content);
528             this.cm.setCursor(cursorPos.line, cursorPos.ch);
529         });
530
531         // Prepend editor content
532         window.$events.listen('editor::prepend', (eventContent) => {
533             const cursorPos = this.cm.getCursor('from');
534             const markdown = getContentToInsert(eventContent);
535             const content = markdown + '\n' + this.cm.getValue();
536             this.cm.setValue(content);
537             const prependLineCount = markdown.split('\n').length;
538             this.cm.setCursor(cursorPos.line + prependLineCount, cursorPos.ch);
539         });
540     }
541 }
542
543 export default MarkdownEditor ;