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