]> BookStack Code Mirror - bookstack/blob - resources/js/markdown/actions.js
Update SECURITY.md
[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         const selectionText = this.#getSelectionText(selectionRange);
72         selector.show(entity => {
73             const selectedText = selectionText || entity.name;
74             const newText = `[${selectedText}](${entity.link})`;
75             this.#replaceSelection(newText, newText.length, selectionRange);
76         }, selectionText);
77     }
78
79     // Show draw.io if enabled and handle save.
80     startDrawing() {
81         const url = this.editor.config.drawioUrl;
82         if (!url) return;
83
84         const selectionRange = this.#getSelectionRange();
85
86         DrawIO.show(url, () => Promise.resolve(''), async pngData => {
87             const data = {
88                 image: pngData,
89                 uploaded_to: Number(this.editor.config.pageId),
90             };
91
92             try {
93                 const resp = await window.$http.post('/images/drawio', data);
94                 this.#insertDrawing(resp.data, selectionRange);
95                 DrawIO.close();
96             } catch (err) {
97                 this.handleDrawingUploadError(err);
98                 throw new Error(`Failed to save image with error: ${err}`);
99             }
100         });
101     }
102
103     #insertDrawing(image, originalSelectionRange) {
104         const newText = `<div drawio-diagram="${image.id}"><img src="${image.url}"></div>`;
105         this.#replaceSelection(newText, newText.length, originalSelectionRange);
106     }
107
108     // Show draw.io if enabled and handle save.
109     editDrawing(imgContainer) {
110         const {drawioUrl} = this.editor.config;
111         if (!drawioUrl) {
112             return;
113         }
114
115         const selectionRange = this.#getSelectionRange();
116         const drawingId = imgContainer.getAttribute('drawio-diagram');
117
118         DrawIO.show(drawioUrl, () => DrawIO.load(drawingId), async pngData => {
119             const data = {
120                 image: pngData,
121                 uploaded_to: Number(this.editor.config.pageId),
122             };
123
124             try {
125                 const resp = await window.$http.post('/images/drawio', data);
126                 const newText = `<div drawio-diagram="${resp.data.id}"><img src="${resp.data.url}"></div>`;
127                 const newContent = this.#getText().split('\n').map(line => {
128                     if (line.indexOf(`drawio-diagram="${drawingId}"`) !== -1) {
129                         return newText;
130                     }
131                     return line;
132                 }).join('\n');
133                 this.#setText(newContent, selectionRange);
134                 DrawIO.close();
135             } catch (err) {
136                 this.handleDrawingUploadError(err);
137                 throw new Error(`Failed to save image with error: ${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.error(error);
149     }
150
151     // Make the editor full screen
152     fullScreen() {
153         const {container} = this.editor.config;
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 += 1;
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 selectRange = this.#getSelectionRange();
267         const selectionText = this.#getSelectionText(selectRange);
268         if (!selectionText) {
269             this.#wrapLine(start, end);
270             return;
271         }
272
273         let newSelectionText = selectionText;
274         let newRange;
275
276         if (selectionText.startsWith(start) && selectionText.endsWith(end)) {
277             newSelectionText = selectionText.slice(start.length, selectionText.length - end.length);
278             newRange = selectRange.extend(selectRange.from, selectRange.to - (start.length + end.length));
279         } else {
280             newSelectionText = `${start}${selectionText}${end}`;
281             newRange = selectRange.extend(selectRange.from, selectRange.to + (start.length + end.length));
282         }
283
284         this.#dispatchChange(
285             selectRange.from,
286             selectRange.to,
287             newSelectionText,
288             newRange.anchor,
289             newRange.head,
290         );
291     }
292
293     replaceLineStartForOrderedList() {
294         const selectionRange = this.#getSelectionRange();
295         const line = this.editor.cm.state.doc.lineAt(selectionRange.from);
296         const prevLine = this.editor.cm.state.doc.line(line.number - 1);
297
298         const listMatch = prevLine.text.match(/^(\s*)(\d)([).])\s/) || [];
299
300         const number = (Number(listMatch[2]) || 0) + 1;
301         const whiteSpace = listMatch[1] || '';
302         const listMark = listMatch[3] || '.';
303
304         const prefix = `${whiteSpace}${number}${listMark}`;
305         return this.replaceLineStart(prefix);
306     }
307
308     /**
309      * Cycles through the type of callout block within the selection.
310      * Creates a callout block if none existing, and removes it if cycling past the danger type.
311      */
312     cycleCalloutTypeAtSelection() {
313         const selectionRange = this.#getSelectionRange();
314         const line = this.editor.cm.state.doc.lineAt(selectionRange.from);
315
316         const formats = ['info', 'success', 'warning', 'danger'];
317         const joint = formats.join('|');
318         const regex = new RegExp(`class="((${joint})\\s+callout|callout\\s+(${joint}))"`, 'i');
319         const matches = regex.exec(line.text);
320         const format = (matches ? (matches[2] || matches[3]) : '').toLowerCase();
321
322         if (format === formats[formats.length - 1]) {
323             this.#wrapLine(`<p class="callout ${formats[formats.length - 1]}">`, '</p>');
324         } else if (format === '') {
325             this.#wrapLine('<p class="callout info">', '</p>');
326         } else {
327             const newFormatIndex = formats.indexOf(format) + 1;
328             const newFormat = formats[newFormatIndex];
329             const newContent = line.text.replace(matches[0], matches[0].replace(format, newFormat));
330             const lineDiff = newContent.length - line.text.length;
331             this.#dispatchChange(
332                 line.from,
333                 line.to,
334                 newContent,
335                 selectionRange.anchor + lineDiff,
336                 selectionRange.head + lineDiff,
337             );
338         }
339     }
340
341     syncDisplayPosition(event) {
342         // Thanks to http://liuhao.im/english/2015/11/10/the-sync-scroll-of-markdown-editor-in-javascript.html
343         const scrollEl = event.target;
344         const atEnd = Math.abs(scrollEl.scrollHeight - scrollEl.clientHeight - scrollEl.scrollTop) < 1;
345         if (atEnd) {
346             this.editor.display.scrollToIndex(-1);
347             return;
348         }
349
350         const blockInfo = this.editor.cm.lineBlockAtHeight(scrollEl.scrollTop);
351         const range = this.editor.cm.state.sliceDoc(0, blockInfo.from);
352         const parser = new DOMParser();
353         const doc = parser.parseFromString(this.editor.markdown.render(range), 'text/html');
354         const totalLines = doc.documentElement.querySelectorAll('body > *');
355         this.editor.display.scrollToIndex(totalLines.length);
356     }
357
358     /**
359      * Fetch and insert the template of the given ID.
360      * The page-relative position provided can be used to determine insert location if possible.
361      * @param {String} templateId
362      * @param {Number} posX
363      * @param {Number} posY
364      */
365     async insertTemplate(templateId, posX, posY) {
366         const cursorPos = this.editor.cm.posAtCoords({x: posX, y: posY}, false);
367         const {data} = await window.$http.get(`/templates/${templateId}`);
368         const content = data.markdown || data.html;
369         this.#dispatchChange(cursorPos, cursorPos, content, cursorPos);
370     }
371
372     /**
373      * Insert multiple images from the clipboard from an event at the provided
374      * screen coordinates (Typically form a paste event).
375      * @param {File[]} images
376      * @param {Number} posX
377      * @param {Number} posY
378      */
379     insertClipboardImages(images, posX, posY) {
380         const cursorPos = this.editor.cm.posAtCoords({x: posX, y: posY}, false);
381         for (const image of images) {
382             this.uploadImage(image, cursorPos);
383         }
384     }
385
386     /**
387      * Handle image upload and add image into markdown content
388      * @param {File} file
389      * @param {?Number} position
390      */
391     async uploadImage(file, position = null) {
392         if (file === null || file.type.indexOf('image') !== 0) return;
393         let ext = 'png';
394
395         if (position === null) {
396             position = this.#getSelectionRange().from;
397         }
398
399         if (file.name) {
400             const fileNameMatches = file.name.match(/\.(.+)$/);
401             if (fileNameMatches.length > 1) ext = fileNameMatches[1];
402         }
403
404         // Insert image into markdown
405         const id = `image-${Math.random().toString(16).slice(2)}`;
406         const placeholderImage = window.baseUrl(`/loading.gif#upload${id}`);
407         const placeHolderText = `![](${placeholderImage})`;
408         this.#dispatchChange(position, position, placeHolderText, position);
409
410         const remoteFilename = `image-${Date.now()}.${ext}`;
411         const formData = new FormData();
412         formData.append('file', file, remoteFilename);
413         formData.append('uploaded_to', this.editor.config.pageId);
414
415         try {
416             const {data} = await window.$http.post('/images/gallery', formData);
417             const newContent = `[![](${data.thumbs.display})](${data.url})`;
418             this.#findAndReplaceContent(placeHolderText, newContent);
419         } catch (err) {
420             window.$events.error(err?.data?.message || this.editor.config.text.imageUploadError);
421             this.#findAndReplaceContent(placeHolderText, '');
422             console.error(err);
423         }
424     }
425
426     /**
427      * Get the current text of the editor instance.
428      * @return {string}
429      */
430     #getText() {
431         return this.editor.cm.state.doc.toString();
432     }
433
434     /**
435      * Set the text of the current editor instance.
436      * @param {String} text
437      * @param {?SelectionRange} selectionRange
438      */
439     #setText(text, selectionRange = null) {
440         selectionRange = selectionRange || this.#getSelectionRange();
441         const newDoc = this.editor.cm.state.toText(text);
442         const newSelectFrom = Math.min(selectionRange.from, newDoc.length);
443         this.#dispatchChange(0, this.editor.cm.state.doc.length, text, newSelectFrom);
444         this.focus();
445     }
446
447     /**
448      * Replace the current selection and focus the editor.
449      * Takes an offset for the cursor, after the change, relative to the start of the provided string.
450      * Can be provided a selection range to use instead of the current selection range.
451      * @param {String} newContent
452      * @param {Number} cursorOffset
453      * @param {?SelectionRange} selectionRange
454      */
455     #replaceSelection(newContent, cursorOffset = 0, selectionRange = null) {
456         selectionRange = selectionRange || this.editor.cm.state.selection.main;
457         const selectFrom = selectionRange.from + cursorOffset;
458         this.#dispatchChange(selectionRange.from, selectionRange.to, newContent, selectFrom);
459         this.focus();
460     }
461
462     /**
463      * Get the text content of the main current selection.
464      * @param {SelectionRange} selectionRange
465      * @return {string}
466      */
467     #getSelectionText(selectionRange = null) {
468         selectionRange = selectionRange || this.#getSelectionRange();
469         return this.editor.cm.state.sliceDoc(selectionRange.from, selectionRange.to);
470     }
471
472     /**
473      * Get the range of the current main selection.
474      * @return {SelectionRange}
475      */
476     #getSelectionRange() {
477         return this.editor.cm.state.selection.main;
478     }
479
480     /**
481      * Cleans the given text to work with the editor.
482      * Standardises line endings to what's expected.
483      * @param {String} text
484      * @return {String}
485      */
486     #cleanTextForEditor(text) {
487         return text.replace(/\r\n|\r/g, '\n');
488     }
489
490     /**
491      * Find and replace the first occurrence of [search] with [replace]
492      * @param {String} search
493      * @param {String} replace
494      */
495     #findAndReplaceContent(search, replace) {
496         const newText = this.#getText().replace(search, replace);
497         this.#setText(newText);
498     }
499
500     /**
501      * Wrap the line in the given start and end contents.
502      * @param {String} start
503      * @param {String} end
504      */
505     #wrapLine(start, end) {
506         const selectionRange = this.#getSelectionRange();
507         const line = this.editor.cm.state.doc.lineAt(selectionRange.from);
508         const lineContent = line.text;
509         let newLineContent;
510         let lineOffset = 0;
511
512         if (lineContent.startsWith(start) && lineContent.endsWith(end)) {
513             newLineContent = lineContent.slice(start.length, lineContent.length - end.length);
514             lineOffset = -(start.length);
515         } else {
516             newLineContent = `${start}${lineContent}${end}`;
517             lineOffset = start.length;
518         }
519
520         this.#dispatchChange(line.from, line.to, newLineContent, selectionRange.from + lineOffset);
521     }
522
523     /**
524      * Dispatch changes to the editor.
525      * @param {Number} from
526      * @param {?Number} to
527      * @param {?String} text
528      * @param {?Number} selectFrom
529      * @param {?Number} selectTo
530      */
531     #dispatchChange(from, to = null, text = null, selectFrom = null, selectTo = null) {
532         const tr = {changes: {from, to, insert: text}};
533
534         if (selectFrom) {
535             tr.selection = {anchor: selectFrom};
536             if (selectTo) {
537                 tr.selection.head = selectTo;
538             }
539         }
540
541         this.editor.cm.dispatch(tr);
542     }
543
544     /**
545      * Set the current selection range.
546      * Optionally will scroll the new range into view.
547      * @param {Number} from
548      * @param {Number} to
549      * @param {Boolean} scrollIntoView
550      */
551     #setSelection(from, to, scrollIntoView = false) {
552         this.editor.cm.dispatch({
553             selection: {anchor: from, head: to},
554             scrollIntoView,
555         });
556     }
557
558 }