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