]> BookStack Code Mirror - bookstack/blob - resources/js/markdown/actions.js
Got md shortcuts working, marked actions for update
[bookstack] / resources / js / markdown / actions.js
1 import DrawIO from "../services/drawio";
2
3 export class Actions {
4     /**
5      * @param {MarkdownEditor} editor
6      */
7     constructor(editor) {
8         this.editor = editor;
9         this.lastContent = {
10             html: '',
11             markdown: '',
12         };
13     }
14
15     updateAndRender() {
16         const content = this.editor.cm.state.doc.toString();
17         this.editor.config.inputEl.value = content;
18
19         const html = this.editor.markdown.render(content);
20         window.$events.emit('editor-html-change', '');
21         window.$events.emit('editor-markdown-change', '');
22         this.lastContent.html = html;
23         this.lastContent.markdown = content;
24         this.editor.display.patchWithHtml(html);
25     }
26
27     getContent() {
28         return this.lastContent;
29     }
30
31     showImageInsert() {
32         // TODO
33         const cursorPos = this.editor.cm.getCursor('from');
34         /** @type {ImageManager} **/
35         const imageManager = window.$components.first('image-manager');
36         imageManager.show(image => {
37             const imageUrl = image.thumbs.display || image.url;
38             let selectedText = this.editor.cm.getSelection();
39             let newText = "[![" + (selectedText || image.name) + "](" + imageUrl + ")](" + image.url + ")";
40             this.editor.cm.focus();
41             this.editor.cm.replaceSelection(newText);
42             this.editor.cm.setCursor(cursorPos.line, cursorPos.ch + newText.length);
43         }, 'gallery');
44     }
45
46     insertImage() {
47         // TODO
48         const selectedText = this.editor.cm.getSelection();
49         const newText = `![${selectedText}](http://)`;
50         const cursorPos = this.editor.cm.getCursor('from');
51         this.editor.cm.replaceSelection(newText);
52         this.editor.cm.setCursor(cursorPos.line, cursorPos.ch + newText.length -1);
53     }
54
55     insertLink() {
56         // TODO
57         const cursorPos = this.editor.cm.getCursor('from');
58         const selectedText = this.editor.cm.getSelection() || '';
59         const newText = `[${selectedText}]()`;
60         this.editor.cm.focus();
61         this.editor.cm.replaceSelection(newText);
62         const cursorPosDiff = (selectedText === '') ? -3 : -1;
63         this.editor.cm.setCursor(cursorPos.line, cursorPos.ch + newText.length+cursorPosDiff);
64     }
65
66     showImageManager() {
67         // TODO
68         const cursorPos = this.editor.cm.getCursor('from');
69         /** @type {ImageManager} **/
70         const imageManager = window.$components.first('image-manager');
71         imageManager.show(image => {
72             this.insertDrawing(image, cursorPos);
73         }, 'drawio');
74     }
75
76     // Show the popup link selector and insert a link when finished
77     showLinkSelector() {
78         // TODO
79         const cursorPos = this.editor.cm.getCursor('from');
80         /** @type {EntitySelectorPopup} **/
81         const selector = window.$components.first('entity-selector-popup');
82         selector.show(entity => {
83             let selectedText = this.editor.cm.getSelection() || entity.name;
84             let newText = `[${selectedText}](${entity.link})`;
85             this.editor.cm.focus();
86             this.editor.cm.replaceSelection(newText);
87             this.editor.cm.setCursor(cursorPos.line, cursorPos.ch + newText.length);
88         });
89     }
90
91     // Show draw.io if enabled and handle save.
92     startDrawing() {
93         // TODO
94         const url = this.editor.config.drawioUrl;
95         if (!url) return;
96
97         const cursorPos = this.editor.cm.getCursor('from');
98
99         DrawIO.show(url,() => {
100             return Promise.resolve('');
101         }, (pngData) => {
102
103             const data = {
104                 image: pngData,
105                 uploaded_to: Number(this.editor.config.pageId),
106             };
107
108             window.$http.post("/images/drawio", data).then(resp => {
109                 this.insertDrawing(resp.data, cursorPos);
110                 DrawIO.close();
111             }).catch(err => {
112                 this.handleDrawingUploadError(err);
113             });
114         });
115     }
116
117     insertDrawing(image, originalCursor) {
118         // TODO
119         const newText = `<div drawio-diagram="${image.id}"><img src="${image.url}"></div>`;
120         this.editor.cm.focus();
121         this.editor.cm.replaceSelection(newText);
122         this.editor.cm.setCursor(originalCursor.line, originalCursor.ch + newText.length);
123     }
124
125     // Show draw.io if enabled and handle save.
126     editDrawing(imgContainer) {
127         // TODO
128         const drawioUrl = this.editor.config.drawioUrl;
129         if (!drawioUrl) {
130             return;
131         }
132
133         const cursorPos = this.editor.cm.getCursor('from');
134         const drawingId = imgContainer.getAttribute('drawio-diagram');
135
136         DrawIO.show(drawioUrl, () => {
137             return DrawIO.load(drawingId);
138         }, (pngData) => {
139
140             const data = {
141                 image: pngData,
142                 uploaded_to: Number(this.editor.config.pageId),
143             };
144
145             window.$http.post("/images/drawio", data).then(resp => {
146                 const newText = `<div drawio-diagram="${resp.data.id}"><img src="${resp.data.url}"></div>`;
147                 const newContent = this.editor.cm.getValue().split('\n').map(line => {
148                     if (line.indexOf(`drawio-diagram="${drawingId}"`) !== -1) {
149                         return newText;
150                     }
151                     return line;
152                 }).join('\n');
153                 this.editor.cm.setValue(newContent);
154                 this.editor.cm.setCursor(cursorPos);
155                 this.editor.cm.focus();
156                 DrawIO.close();
157             }).catch(err => {
158                 this.handleDrawingUploadError(err);
159             });
160         });
161     }
162
163     handleDrawingUploadError(error) {
164         // TODO
165         if (error.status === 413) {
166             window.$events.emit('error', this.editor.config.text.serverUploadLimit);
167         } else {
168             window.$events.emit('error', this.editor.config.text.imageUploadError);
169         }
170         console.log(error);
171     }
172
173     // Make the editor full screen
174     fullScreen() {
175         // TODO
176         const container = this.editor.config.container;
177         const alreadyFullscreen = container.classList.contains('fullscreen');
178         container.classList.toggle('fullscreen', !alreadyFullscreen);
179         document.body.classList.toggle('markdown-fullscreen', !alreadyFullscreen);
180     }
181
182     // Scroll to a specified text
183     scrollToText(searchText) {
184         // TODO
185         if (!searchText) {
186             return;
187         }
188
189         const content = this.editor.cm.getValue();
190         const lines = content.split(/\r?\n/);
191         let lineNumber = lines.findIndex(line => {
192             return line && line.indexOf(searchText) !== -1;
193         });
194
195         if (lineNumber === -1) {
196             return;
197         }
198
199         this.editor.cm.scrollIntoView({
200             line: lineNumber,
201         }, 200);
202         this.editor.cm.focus();
203         // set the cursor location.
204         this.editor.cm.setCursor({
205             line: lineNumber,
206             char: lines[lineNumber].length
207         })
208     }
209
210     focus() {
211         // TODO
212         this.editor.cm.focus();
213     }
214
215     /**
216      * Insert content into the editor.
217      * @param {String} content
218      */
219     insertContent(content) {
220         // TODO
221         this.editor.cm.replaceSelection(content);
222     }
223
224     /**
225      * Prepend content to the editor.
226      * @param {String} content
227      */
228     prependContent(content) {
229         // TODO
230         const cursorPos = this.editor.cm.getCursor('from');
231         const newContent = content + '\n' + this.editor.cm.getValue();
232         this.editor.cm.setValue(newContent);
233         const prependLineCount = content.split('\n').length;
234         this.editor.cm.setCursor(cursorPos.line + prependLineCount, cursorPos.ch);
235     }
236
237     /**
238      * Append content to the editor.
239      * @param {String} content
240      */
241     appendContent(content) {
242         // TODO
243         const cursorPos = this.editor.cm.getCursor('from');
244         const newContent = this.editor.cm.getValue() + '\n' + content;
245         this.editor.cm.setValue(newContent);
246         this.editor.cm.setCursor(cursorPos.line, cursorPos.ch);
247     }
248
249     /**
250      * Replace the editor's contents
251      * @param {String} content
252      */
253     replaceContent(content) {
254         // TODO
255         this.editor.cm.setValue(content);
256     }
257
258     /**
259      * @param {String|RegExp} search
260      * @param {String} replace
261      */
262     findAndReplaceContent(search, replace) {
263         // TODO
264         const text = this.editor.cm.getValue();
265         const cursor = this.editor.cm.listSelections();
266         this.editor.cm.setValue(text.replace(search, replace));
267         this.editor.cm.setSelections(cursor);
268     }
269
270     /**
271      * Replace the start of the line
272      * @param {String} newStart
273      */
274     replaceLineStart(newStart) {
275         // TODO
276         const cursor = this.editor.cm.getCursor();
277         let lineContent = this.editor.cm.getLine(cursor.line);
278         const lineLen = lineContent.length;
279         const lineStart = lineContent.split(' ')[0];
280
281         // Remove symbol if already set
282         if (lineStart === newStart) {
283             lineContent = lineContent.replace(`${newStart} `, '');
284             this.editor.cm.replaceRange(lineContent, {line: cursor.line, ch: 0}, {line: cursor.line, ch: lineLen});
285             this.editor.cm.setCursor({line: cursor.line, ch: cursor.ch - (newStart.length + 1)});
286             return;
287         }
288
289         const alreadySymbol = /^[#>`]/.test(lineStart);
290         let posDif = 0;
291         if (alreadySymbol) {
292             posDif = newStart.length - lineStart.length;
293             lineContent = lineContent.replace(lineStart, newStart).trim();
294         } else if (newStart !== '') {
295             posDif = newStart.length + 1;
296             lineContent = newStart + ' ' + lineContent;
297         }
298         this.editor.cm.replaceRange(lineContent, {line: cursor.line, ch: 0}, {line: cursor.line, ch: lineLen});
299         this.editor.cm.setCursor({line: cursor.line, ch: cursor.ch + posDif});
300     }
301
302     /**
303      * Wrap the line in the given start and end contents.
304      * @param {String} start
305      * @param {String} end
306      */
307     wrapLine(start, end) {
308         // TODO
309         const cursor = this.editor.cm.getCursor();
310         const lineContent = this.editor.cm.getLine(cursor.line);
311         const lineLen = lineContent.length;
312         let newLineContent = lineContent;
313
314         if (lineContent.indexOf(start) === 0 && lineContent.slice(-end.length) === end) {
315             newLineContent = lineContent.slice(start.length, lineContent.length - end.length);
316         } else {
317             newLineContent = `${start}${lineContent}${end}`;
318         }
319
320         this.editor.cm.replaceRange(newLineContent, {line: cursor.line, ch: 0}, {line: cursor.line, ch: lineLen});
321         this.editor.cm.setCursor({line: cursor.line, ch: cursor.ch + start.length});
322     }
323
324     /**
325      * Wrap the selection in the given contents start and end contents.
326      * @param {String} start
327      * @param {String} end
328      */
329     wrapSelection(start, end) {
330         // TODO
331         const selection = this.editor.cm.getSelection();
332         if (selection === '') return this.wrapLine(start, end);
333
334         let newSelection = selection;
335         const frontDiff = 0;
336         let endDiff;
337
338         if (selection.indexOf(start) === 0 && selection.slice(-end.length) === end) {
339             newSelection = selection.slice(start.length, selection.length - end.length);
340             endDiff = -(end.length + start.length);
341         } else {
342             newSelection = `${start}${selection}${end}`;
343             endDiff = start.length + end.length;
344         }
345
346         const selections = this.editor.cm.listSelections()[0];
347         this.editor.cm.replaceSelection(newSelection);
348         const headFirst = selections.head.ch <= selections.anchor.ch;
349         selections.head.ch += headFirst ? frontDiff : endDiff;
350         selections.anchor.ch += headFirst ? endDiff : frontDiff;
351         this.editor.cm.setSelections([selections]);
352     }
353
354     replaceLineStartForOrderedList() {
355         // TODO
356         const cursor = this.editor.cm.getCursor();
357         const prevLineContent = this.editor.cm.getLine(cursor.line - 1) || '';
358         const listMatch = prevLineContent.match(/^(\s*)(\d)([).])\s/) || [];
359
360         const number = (Number(listMatch[2]) || 0) + 1;
361         const whiteSpace = listMatch[1] || '';
362         const listMark = listMatch[3] || '.'
363
364         const prefix = `${whiteSpace}${number}${listMark}`;
365         return this.replaceLineStart(prefix);
366     }
367
368     /**
369      * Cycles through the type of callout block within the selection.
370      * Creates a callout block if none existing, and removes it if cycling past the danger type.
371      */
372     cycleCalloutTypeAtSelection() {
373         // TODO
374         const selectionRange = this.editor.cm.listSelections()[0];
375         const lineContent = this.editor.cm.getLine(selectionRange.anchor.line);
376         const lineLength = lineContent.length;
377         const contentRange = {
378             anchor: {line: selectionRange.anchor.line, ch: 0},
379             head: {line: selectionRange.anchor.line, ch: lineLength},
380         };
381
382         const formats = ['info', 'success', 'warning', 'danger'];
383         const joint = formats.join('|');
384         const regex = new RegExp(`class="((${joint})\\s+callout|callout\\s+(${joint}))"`, 'i');
385         const matches = regex.exec(lineContent);
386         const format = (matches ? (matches[2] || matches[3]) : '').toLowerCase();
387
388         if (format === formats[formats.length - 1]) {
389             this.wrapLine(`<p class="callout ${formats[formats.length - 1]}">`, '</p>');
390         } else if (format === '') {
391             this.wrapLine('<p class="callout info">', '</p>');
392         } else {
393             const newFormatIndex = formats.indexOf(format) + 1;
394             const newFormat = formats[newFormatIndex];
395             const newContent = lineContent.replace(matches[0], matches[0].replace(format, newFormat));
396             this.editor.cm.replaceRange(newContent, contentRange.anchor, contentRange.head);
397
398             const chDiff = newContent.length - lineContent.length;
399             selectionRange.anchor.ch += chDiff;
400             if (selectionRange.anchor !== selectionRange.head) {
401                 selectionRange.head.ch += chDiff;
402             }
403             this.editor.cm.setSelection(selectionRange.anchor, selectionRange.head);
404         }
405     }
406
407     /**
408      * Handle image upload and add image into markdown content
409      * @param {File} file
410      */
411     uploadImage(file) {
412         // TODO
413         if (file === null || file.type.indexOf('image') !== 0) return;
414         let ext = 'png';
415
416         if (file.name) {
417             let fileNameMatches = file.name.match(/\.(.+)$/);
418             if (fileNameMatches.length > 1) ext = fileNameMatches[1];
419         }
420
421         // Insert image into markdown
422         const id = "image-" + Math.random().toString(16).slice(2);
423         const placeholderImage = window.baseUrl(`/loading.gif#upload${id}`);
424         const selectedText = this.editor.cm.getSelection();
425         const placeHolderText = `![${selectedText}](${placeholderImage})`;
426         const cursor = this.editor.cm.getCursor();
427         this.editor.cm.replaceSelection(placeHolderText);
428         this.editor.cm.setCursor({line: cursor.line, ch: cursor.ch + selectedText.length + 3});
429
430         const remoteFilename = "image-" + Date.now() + "." + ext;
431         const formData = new FormData();
432         formData.append('file', file, remoteFilename);
433         formData.append('uploaded_to', this.editor.config.pageId);
434
435         window.$http.post('/images/gallery', formData).then(resp => {
436             const newContent = `[![${selectedText}](${resp.data.thumbs.display})](${resp.data.url})`;
437             this.findAndReplaceContent(placeHolderText, newContent);
438         }).catch(err => {
439             window.$events.emit('error', this.editor.config.text.imageUploadError);
440             this.findAndReplaceContent(placeHolderText, selectedText);
441             console.log(err);
442         });
443     }
444
445     syncDisplayPosition(event) {
446         // Thanks to http://liuhao.im/english/2015/11/10/the-sync-scroll-of-markdown-editor-in-javascript.html
447         const scrollEl = event.target;
448         const atEnd = Math.abs(scrollEl.scrollHeight - scrollEl.clientHeight - scrollEl.scrollTop) < 1;
449         if (atEnd) {
450             this.editor.display.scrollToIndex(-1);
451             return;
452         }
453
454         const blockInfo = this.editor.cm.lineBlockAtHeight(scrollEl.scrollTop);
455         const range = this.editor.cm.state.sliceDoc(0, blockInfo.from);
456         const parser = new DOMParser();
457         const doc = parser.parseFromString(this.editor.markdown.render(range), 'text/html');
458         const totalLines = doc.documentElement.querySelectorAll('body > *');
459         this.editor.display.scrollToIndex(totalLines.length);
460     }
461
462     /**
463      * Fetch and insert the template of the given ID.
464      * The page-relative position provided can be used to determine insert location if possible.
465      * @param {String} templateId
466      * @param {Number} posX
467      * @param {Number} posY
468      */
469     insertTemplate(templateId, posX, posY) {
470         // TODO
471         const cursorPos = this.editor.cm.coordsChar({left: posX, top: posY});
472         this.editor.cm.setCursor(cursorPos);
473         window.$http.get(`/templates/${templateId}`).then(resp => {
474             const content = resp.data.markdown || resp.data.html;
475             this.editor.cm.replaceSelection(content);
476         });
477     }
478
479     /**
480      * Insert multiple images from the clipboard.
481      * @param {File[]} images
482      */
483     insertClipboardImages(images) {
484         // TODO
485         const cursorPos = this.editor.cm.coordsChar({left: event.pageX, top: event.pageY});
486         this.editor.cm.setCursor(cursorPos);
487         for (const image of images) {
488             this.uploadImage(image);
489         }
490     }
491 }