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