]> BookStack Code Mirror - bookstack/blob - resources/js/markdown/actions.js
Merge branch 'basic-pwa-support' of https://github.com/GamerClassN7/BookStack into...
[bookstack] / resources / js / markdown / actions.js
1 import * as DrawIO from '../services/drawio';
2
3 export class Actions {
4
5     /**
6      * @param {MarkdownEditor} editor
7      */
8     constructor(editor) {
9         this.editor = editor;
10         this.lastContent = {
11             html: '',
12             markdown: '',
13         };
14     }
15
16     updateAndRender() {
17         const content = this.#getText();
18         this.editor.config.inputEl.value = content;
19
20         const html = this.editor.markdown.render(content);
21         window.$events.emit('editor-html-change', '');
22         window.$events.emit('editor-markdown-change', '');
23         this.lastContent.html = html;
24         this.lastContent.markdown = content;
25         this.editor.display.patchWithHtml(html);
26     }
27
28     getContent() {
29         return this.lastContent;
30     }
31
32     showImageInsert() {
33         /** @type {ImageManager} * */
34         const imageManager = window.$components.first('image-manager');
35
36         imageManager.show(image => {
37             const imageUrl = image.thumbs.display || image.url;
38             const selectedText = this.#getSelectionText();
39             const newText = `[![${selectedText || image.name}](${imageUrl})](${image.url})`;
40             this.#replaceSelection(newText, newText.length);
41         }, 'gallery');
42     }
43
44     insertImage() {
45         const newText = `![${this.#getSelectionText()}](http://)`;
46         this.#replaceSelection(newText, newText.length - 1);
47     }
48
49     insertLink() {
50         const selectedText = this.#getSelectionText();
51         const newText = `[${selectedText}]()`;
52         const cursorPosDiff = (selectedText === '') ? -3 : -1;
53         this.#replaceSelection(newText, newText.length + cursorPosDiff);
54     }
55
56     showImageManager() {
57         const selectionRange = this.#getSelectionRange();
58         /** @type {ImageManager} * */
59         const imageManager = window.$components.first('image-manager');
60         imageManager.show(image => {
61             this.#insertDrawing(image, selectionRange);
62         }, 'drawio');
63     }
64
65     // Show the popup link selector and insert a link when finished
66     showLinkSelector() {
67         const selectionRange = this.#getSelectionRange();
68
69         /** @type {EntitySelectorPopup} * */
70         const selector = window.$components.first('entity-selector-popup');
71         selector.show(entity => {
72             const selectedText = this.#getSelectionText(selectionRange) || entity.name;
73             const newText = `[${selectedText}](${entity.link})`;
74             this.#replaceSelection(newText, newText.length, selectionRange);
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 selectionRange = this.#getSelectionRange();
84
85         DrawIO.show(url, () => Promise.resolve(''), async pngData => {
86             const data = {
87                 image: pngData,
88                 uploaded_to: Number(this.editor.config.pageId),
89             };
90
91             try {
92                 const resp = await window.$http.post('/images/drawio', data);
93                 this.#insertDrawing(resp.data, selectionRange);
94                 DrawIO.close();
95             } catch (err) {
96                 this.handleDrawingUploadError(err);
97                 throw new Error(`Failed to save image with error: ${err}`);
98             }
99         });
100     }
101
102     #insertDrawing(image, originalSelectionRange) {
103         const newText = `<div drawio-diagram="${image.id}"><img src="${image.url}"></div>`;
104         this.#replaceSelection(newText, newText.length, originalSelectionRange);
105     }
106
107     // Show draw.io if enabled and handle save.
108     editDrawing(imgContainer) {
109         const {drawioUrl} = this.editor.config;
110         if (!drawioUrl) {
111             return;
112         }
113
114         const selectionRange = this.#getSelectionRange();
115         const drawingId = imgContainer.getAttribute('drawio-diagram');
116
117         DrawIO.show(drawioUrl, () => DrawIO.load(drawingId), async pngData => {
118             const data = {
119                 image: pngData,
120                 uploaded_to: Number(this.editor.config.pageId),
121             };
122
123             try {
124                 const resp = await window.$http.post('/images/drawio', data);
125                 const newText = `<div drawio-diagram="${resp.data.id}"><img src="${resp.data.url}"></div>`;
126                 const newContent = this.#getText().split('\n').map(line => {
127                     if (line.indexOf(`drawio-diagram="${drawingId}"`) !== -1) {
128                         return newText;
129                     }
130                     return line;
131                 }).join('\n');
132                 this.#setText(newContent, selectionRange);
133                 DrawIO.close();
134             } catch (err) {
135                 this.handleDrawingUploadError(err);
136                 throw new Error(`Failed to save image with error: ${err}`);
137             }
138         });
139     }
140
141     handleDrawingUploadError(error) {
142         if (error.status === 413) {
143             window.$events.emit('error', this.editor.config.text.serverUploadLimit);
144         } else {
145             window.$events.emit('error', this.editor.config.text.imageUploadError);
146         }
147         console.error(error);
148     }
149
150     // Make the editor full screen
151     fullScreen() {
152         const {container} = this.editor.config;
153         const alreadyFullscreen = container.classList.contains('fullscreen');
154         container.classList.toggle('fullscreen', !alreadyFullscreen);
155         document.body.classList.toggle('markdown-fullscreen', !alreadyFullscreen);
156     }
157
158     // Scroll to a specified text
159     scrollToText(searchText) {
160         if (!searchText) {
161             return;
162         }
163
164         const text = this.editor.cm.state.doc;
165         let lineCount = 1;
166         let scrollToLine = -1;
167         for (const line of text.iterLines()) {
168             if (line.includes(searchText)) {
169                 scrollToLine = lineCount;
170                 break;
171             }
172             lineCount += 1;
173         }
174
175         if (scrollToLine === -1) {
176             return;
177         }
178
179         const line = text.line(scrollToLine);
180         this.#setSelection(line.from, line.to, true);
181         this.focus();
182     }
183
184     focus() {
185         if (!this.editor.cm.hasFocus) {
186             this.editor.cm.focus();
187         }
188     }
189
190     /**
191      * Insert content into the editor.
192      * @param {String} content
193      */
194     insertContent(content) {
195         this.#replaceSelection(content, content.length);
196     }
197
198     /**
199      * Prepend content to the editor.
200      * @param {String} content
201      */
202     prependContent(content) {
203         content = this.#cleanTextForEditor(content);
204         const selectionRange = this.#getSelectionRange();
205         const selectFrom = selectionRange.from + content.length + 1;
206         this.#dispatchChange(0, 0, `${content}\n`, selectFrom);
207         this.focus();
208     }
209
210     /**
211      * Append content to the editor.
212      * @param {String} content
213      */
214     appendContent(content) {
215         content = this.#cleanTextForEditor(content);
216         this.#dispatchChange(this.editor.cm.state.doc.length, `\n${content}`);
217         this.focus();
218     }
219
220     /**
221      * Replace the editor's contents
222      * @param {String} content
223      */
224     replaceContent(content) {
225         this.#setText(content);
226     }
227
228     /**
229      * Replace the start of the line
230      * @param {String} newStart
231      */
232     replaceLineStart(newStart) {
233         const selectionRange = this.#getSelectionRange();
234         const line = this.editor.cm.state.doc.lineAt(selectionRange.from);
235
236         const lineContent = line.text;
237         const lineStart = lineContent.split(' ')[0];
238
239         // Remove symbol if already set
240         if (lineStart === newStart) {
241             const newLineContent = lineContent.replace(`${newStart} `, '');
242             const selectFrom = selectionRange.from + (newLineContent.length - lineContent.length);
243             this.#dispatchChange(line.from, line.to, newLineContent, selectFrom);
244             return;
245         }
246
247         let newLineContent = lineContent;
248         const alreadySymbol = /^[#>`]/.test(lineStart);
249         if (alreadySymbol) {
250             newLineContent = lineContent.replace(lineStart, newStart).trim();
251         } else if (newStart !== '') {
252             newLineContent = `${newStart} ${lineContent}`;
253         }
254
255         const selectFrom = selectionRange.from + (newLineContent.length - lineContent.length);
256         this.#dispatchChange(line.from, line.to, newLineContent, selectFrom);
257     }
258
259     /**
260      * Wrap the selection in the given contents start and end contents.
261      * @param {String} start
262      * @param {String} end
263      */
264     wrapSelection(start, end) {
265         const selectRange = this.#getSelectionRange();
266         const selectionText = this.#getSelectionText(selectRange);
267         if (!selectionText) {
268             this.#wrapLine(start, end);
269             return;
270         }
271
272         let newSelectionText = selectionText;
273         let newRange;
274
275         if (selectionText.startsWith(start) && selectionText.endsWith(end)) {
276             newSelectionText = selectionText.slice(start.length, selectionText.length - end.length);
277             newRange = selectRange.extend(selectRange.from, selectRange.to - (start.length + end.length));
278         } else {
279             newSelectionText = `${start}${selectionText}${end}`;
280             newRange = selectRange.extend(selectRange.from, selectRange.to + (start.length + end.length));
281         }
282
283         this.#dispatchChange(
284             selectRange.from,
285             selectRange.to,
286             newSelectionText,
287             newRange.anchor,
288             newRange.head,
289         );
290     }
291
292     replaceLineStartForOrderedList() {
293         const selectionRange = this.#getSelectionRange();
294         const line = this.editor.cm.state.doc.lineAt(selectionRange.from);
295         const prevLine = this.editor.cm.state.doc.line(line.number - 1);
296
297         const listMatch = prevLine.text.match(/^(\s*)(\d)([).])\s/) || [];
298
299         const number = (Number(listMatch[2]) || 0) + 1;
300         const whiteSpace = listMatch[1] || '';
301         const listMark = listMatch[3] || '.';
302
303         const prefix = `${whiteSpace}${number}${listMark}`;
304         return this.replaceLineStart(prefix);
305     }
306
307     /**
308      * Cycles through the type of callout block within the selection.
309      * Creates a callout block if none existing, and removes it if cycling past the danger type.
310      */
311     cycleCalloutTypeAtSelection() {
312         const selectionRange = this.#getSelectionRange();
313         const line = this.editor.cm.state.doc.lineAt(selectionRange.from);
314
315         const formats = ['info', 'success', 'warning', 'danger'];
316         const joint = formats.join('|');
317         const regex = new RegExp(`class="((${joint})\\s+callout|callout\\s+(${joint}))"`, 'i');
318         const matches = regex.exec(line.text);
319         const format = (matches ? (matches[2] || matches[3]) : '').toLowerCase();
320
321         if (format === formats[formats.length - 1]) {
322             this.#wrapLine(`<p class="callout ${formats[formats.length - 1]}">`, '</p>');
323         } else if (format === '') {
324             this.#wrapLine('<p class="callout info">', '</p>');
325         } else {
326             const newFormatIndex = formats.indexOf(format) + 1;
327             const newFormat = formats[newFormatIndex];
328             const newContent = line.text.replace(matches[0], matches[0].replace(format, newFormat));
329             const lineDiff = newContent.length - line.text.length;
330             this.#dispatchChange(
331                 line.from,
332                 line.to,
333                 newContent,
334                 selectionRange.anchor + lineDiff,
335                 selectionRange.head + lineDiff,
336             );
337         }
338     }
339
340     syncDisplayPosition(event) {
341         // Thanks to http://liuhao.im/english/2015/11/10/the-sync-scroll-of-markdown-editor-in-javascript.html
342         const scrollEl = event.target;
343         const atEnd = Math.abs(scrollEl.scrollHeight - scrollEl.clientHeight - scrollEl.scrollTop) < 1;
344         if (atEnd) {
345             this.editor.display.scrollToIndex(-1);
346             return;
347         }
348
349         const blockInfo = this.editor.cm.lineBlockAtHeight(scrollEl.scrollTop);
350         const range = this.editor.cm.state.sliceDoc(0, blockInfo.from);
351         const parser = new DOMParser();
352         const doc = parser.parseFromString(this.editor.markdown.render(range), 'text/html');
353         const totalLines = doc.documentElement.querySelectorAll('body > *');
354         this.editor.display.scrollToIndex(totalLines.length);
355     }
356
357     /**
358      * Fetch and insert the template of the given ID.
359      * The page-relative position provided can be used to determine insert location if possible.
360      * @param {String} templateId
361      * @param {Number} posX
362      * @param {Number} posY
363      */
364     async insertTemplate(templateId, posX, posY) {
365         const cursorPos = this.editor.cm.posAtCoords({x: posX, y: posY}, false);
366         const {data} = await window.$http.get(`/templates/${templateId}`);
367         const content = data.markdown || data.html;
368         this.#dispatchChange(cursorPos, cursorPos, content, cursorPos);
369     }
370
371     /**
372      * Insert multiple images from the clipboard from an event at the provided
373      * screen coordinates (Typically form a paste event).
374      * @param {File[]} images
375      * @param {Number} posX
376      * @param {Number} posY
377      */
378     insertClipboardImages(images, posX, posY) {
379         const cursorPos = this.editor.cm.posAtCoords({x: posX, y: posY}, false);
380         for (const image of images) {
381             this.uploadImage(image, cursorPos);
382         }
383     }
384
385     /**
386      * Handle image upload and add image into markdown content
387      * @param {File} file
388      * @param {?Number} position
389      */
390     async uploadImage(file, position = null) {
391         if (file === null || file.type.indexOf('image') !== 0) return;
392         let ext = 'png';
393
394         if (position === null) {
395             position = this.#getSelectionRange().from;
396         }
397
398         if (file.name) {
399             const fileNameMatches = file.name.match(/\.(.+)$/);
400             if (fileNameMatches.length > 1) ext = fileNameMatches[1];
401         }
402
403         // Insert image into markdown
404         const id = `image-${Math.random().toString(16).slice(2)}`;
405         const placeholderImage = window.baseUrl(`/loading.gif#upload${id}`);
406         const placeHolderText = `![](${placeholderImage})`;
407         this.#dispatchChange(position, position, placeHolderText, position);
408
409         const remoteFilename = `image-${Date.now()}.${ext}`;
410         const formData = new FormData();
411         formData.append('file', file, remoteFilename);
412         formData.append('uploaded_to', this.editor.config.pageId);
413
414         try {
415             const {data} = await window.$http.post('/images/gallery', formData);
416             const newContent = `[![](${data.thumbs.display})](${data.url})`;
417             this.#findAndReplaceContent(placeHolderText, newContent);
418         } catch (err) {
419             window.$events.emit('error', this.editor.config.text.imageUploadError);
420             this.#findAndReplaceContent(placeHolderText, '');
421             console.error(err);
422         }
423     }
424
425     /**
426      * Get the current text of the editor instance.
427      * @return {string}
428      */
429     #getText() {
430         return this.editor.cm.state.doc.toString();
431     }
432
433     /**
434      * Set the text of the current editor instance.
435      * @param {String} text
436      * @param {?SelectionRange} selectionRange
437      */
438     #setText(text, selectionRange = null) {
439         selectionRange = selectionRange || this.#getSelectionRange();
440         const newDoc = this.editor.cm.state.toText(text);
441         const newSelectFrom = Math.min(selectionRange.from, newDoc.length);
442         this.#dispatchChange(0, this.editor.cm.state.doc.length, text, newSelectFrom);
443         this.focus();
444     }
445
446     /**
447      * Replace the current selection and focus the editor.
448      * Takes an offset for the cursor, after the change, relative to the start of the provided string.
449      * Can be provided a selection range to use instead of the current selection range.
450      * @param {String} newContent
451      * @param {Number} cursorOffset
452      * @param {?SelectionRange} selectionRange
453      */
454     #replaceSelection(newContent, cursorOffset = 0, selectionRange = null) {
455         selectionRange = selectionRange || this.editor.cm.state.selection.main;
456         const selectFrom = selectionRange.from + cursorOffset;
457         this.#dispatchChange(selectionRange.from, selectionRange.to, newContent, selectFrom);
458         this.focus();
459     }
460
461     /**
462      * Get the text content of the main current selection.
463      * @param {SelectionRange} selectionRange
464      * @return {string}
465      */
466     #getSelectionText(selectionRange = null) {
467         selectionRange = selectionRange || this.#getSelectionRange();
468         return this.editor.cm.state.sliceDoc(selectionRange.from, selectionRange.to);
469     }
470
471     /**
472      * Get the range of the current main selection.
473      * @return {SelectionRange}
474      */
475     #getSelectionRange() {
476         return this.editor.cm.state.selection.main;
477     }
478
479     /**
480      * Cleans the given text to work with the editor.
481      * Standardises line endings to what's expected.
482      * @param {String} text
483      * @return {String}
484      */
485     #cleanTextForEditor(text) {
486         return text.replace(/\r\n|\r/g, '\n');
487     }
488
489     /**
490      * Find and replace the first occurrence of [search] with [replace]
491      * @param {String} search
492      * @param {String} replace
493      */
494     #findAndReplaceContent(search, replace) {
495         const newText = this.#getText().replace(search, replace);
496         this.#setText(newText);
497     }
498
499     /**
500      * Wrap the line in the given start and end contents.
501      * @param {String} start
502      * @param {String} end
503      */
504     #wrapLine(start, end) {
505         const selectionRange = this.#getSelectionRange();
506         const line = this.editor.cm.state.doc.lineAt(selectionRange.from);
507         const lineContent = line.text;
508         let newLineContent;
509         let lineOffset = 0;
510
511         if (lineContent.startsWith(start) && lineContent.endsWith(end)) {
512             newLineContent = lineContent.slice(start.length, lineContent.length - end.length);
513             lineOffset = -(start.length);
514         } else {
515             newLineContent = `${start}${lineContent}${end}`;
516             lineOffset = start.length;
517         }
518
519         this.#dispatchChange(line.from, line.to, newLineContent, selectionRange.from + lineOffset);
520     }
521
522     /**
523      * Dispatch changes to the editor.
524      * @param {Number} from
525      * @param {?Number} to
526      * @param {?String} text
527      * @param {?Number} selectFrom
528      * @param {?Number} selectTo
529      */
530     #dispatchChange(from, to = null, text = null, selectFrom = null, selectTo = null) {
531         const tr = {changes: {from, to, insert: text}};
532
533         if (selectFrom) {
534             tr.selection = {anchor: selectFrom};
535             if (selectTo) {
536                 tr.selection.head = selectTo;
537             }
538         }
539
540         this.editor.cm.dispatch(tr);
541     }
542
543     /**
544      * Set the current selection range.
545      * Optionally will scroll the new range into view.
546      * @param {Number} from
547      * @param {Number} to
548      * @param {Boolean} scrollIntoView
549      */
550     #setSelection(from, to, scrollIntoView = false) {
551         this.editor.cm.dispatch({
552             selection: {anchor: from, head: to},
553             scrollIntoView,
554         });
555     }
556
557 }