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