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