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