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