1 import * as DrawIO from '../services/drawio';
2 import {MarkdownEditor} from "./index.mjs";
3 import {EntitySelectorPopup, ImageManager} from "../components";
4 import {MarkdownEditorInputSelection} from "./inputs/interface";
6 interface ImageManagerImage {
9 thumbs: { display: string; };
13 export class Actions {
15 protected readonly editor: MarkdownEditor;
16 protected lastContent: { html: string; markdown: string } = {
21 constructor(editor: MarkdownEditor) {
26 const content = this.editor.input.getText();
27 this.editor.config.inputEl.value = content;
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);
38 return this.lastContent;
42 const imageManager = window.$components.first('image-manager') as ImageManager;
44 imageManager.show((image: ImageManagerImage) => {
45 const imageUrl = image.thumbs?.display || image.url;
46 const selectedText = this.editor.input.getSelectionText();
47 const newText = `[](${image.url})`;
48 this.#replaceSelection(newText, newText.length);
53 const newText = ``;
54 this.#replaceSelection(newText, newText.length - 1);
58 const selectedText = this.editor.input.getSelectionText();
59 const newText = `[${selectedText}]()`;
60 const cursorPosDiff = (selectedText === '') ? -3 : -1;
61 this.#replaceSelection(newText, newText.length + cursorPosDiff);
65 const selectionRange = this.editor.input.getSelection();
66 const imageManager = window.$components.first('image-manager') as ImageManager;
67 imageManager.show((image: ImageManagerImage) => {
68 this.#insertDrawing(image, selectionRange);
72 // Show the popup link selector and insert a link when finished
74 const selectionRange = this.editor.input.getSelection();
76 const selector = window.$components.first('entity-selector-popup') as EntitySelectorPopup;
77 const selectionText = this.editor.input.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);
83 initialValue: selectionText,
84 searchEndpoint: '/search/entity-selector',
85 entityTypes: 'page,book,chapter,bookshelf',
86 entityPermission: 'view',
90 // Show draw.io if enabled and handle save.
92 const url = this.editor.config.drawioUrl;
95 const selectionRange = this.editor.input.getSelection();
97 DrawIO.show(url, () => Promise.resolve(''), async pngData => {
100 uploaded_to: Number(this.editor.config.pageId),
104 const resp = await window.$http.post('/images/drawio', data);
105 this.#insertDrawing(resp.data as ImageManagerImage, selectionRange);
108 this.handleDrawingUploadError(err);
109 throw new Error(`Failed to save image with error: ${err}`);
114 #insertDrawing(image: ImageManagerImage, originalSelectionRange: MarkdownEditorInputSelection) {
115 const newText = `<div drawio-diagram="${image.id}"><img src="${image.url}"></div>`;
116 this.#replaceSelection(newText, newText.length, originalSelectionRange);
119 // Show draw.io if enabled and handle save.
120 editDrawing(imgContainer: HTMLElement) {
121 const {drawioUrl} = this.editor.config;
126 const selectionRange = this.editor.input.getSelection();
127 const drawingId = imgContainer.getAttribute('drawio-diagram') || '';
132 DrawIO.show(drawioUrl, () => DrawIO.load(drawingId), async pngData => {
135 uploaded_to: Number(this.editor.config.pageId),
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.editor.input.getText().split('\n').map(line => {
143 if (line.indexOf(`drawio-diagram="${drawingId}"`) !== -1) {
148 this.editor.input.setText(newContent, selectionRange);
151 this.handleDrawingUploadError(err);
152 throw new Error(`Failed to save image with error: ${err}`);
157 handleDrawingUploadError(error: any): void {
158 if (error.status === 413) {
159 window.$events.emit('error', this.editor.config.text.serverUploadLimit);
161 window.$events.emit('error', this.editor.config.text.imageUploadError);
163 console.error(error);
166 // Make the editor full screen
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);
174 // Scroll to a specified text
175 scrollToText(searchText: string): void {
180 const lineRange = this.editor.input.searchForLineContaining(searchText);
182 this.editor.input.setSelection(lineRange, true);
183 this.editor.input.focus();
188 this.editor.input.focus();
192 * Insert content into the editor.
194 insertContent(content: string) {
195 this.#replaceSelection(content, content.length);
199 * Prepend content to the editor.
201 prependContent(content: string): void {
202 content = this.#cleanTextForEditor(content);
203 const selectionRange = this.editor.input.getSelection();
204 const selectFrom = selectionRange.from + content.length + 1;
205 this.editor.input.spliceText(0, 0, `${content}\n`, {from: selectFrom});
206 this.editor.input.focus();
210 * Append content to the editor.
212 appendContent(content: string): void {
213 content = this.#cleanTextForEditor(content);
214 this.editor.input.appendText(content);
215 this.editor.input.focus();
219 * Replace the editor's contents
221 replaceContent(content: string): void {
222 this.editor.input.setText(content);
226 * Replace the start of the line
227 * @param {String} newStart
229 replaceLineStart(newStart: string): void {
230 const selectionRange = this.editor.input.getSelection();
231 const lineRange = this.editor.input.getLineRangeFromPosition(selectionRange.from);
232 const lineContent = this.editor.input.getSelectionText(lineRange);
233 const lineStart = lineContent.split(' ')[0];
235 // Remove symbol if already set
236 if (lineStart === newStart) {
237 const newLineContent = lineContent.replace(`${newStart} `, '');
238 const selectFrom = selectionRange.from + (newLineContent.length - lineContent.length);
239 this.editor.input.spliceText(selectionRange.from, selectionRange.to, newLineContent, {from: selectFrom});
243 let newLineContent = lineContent;
244 const alreadySymbol = /^[#>`]/.test(lineStart);
246 newLineContent = lineContent.replace(lineStart, newStart).trim();
247 } else if (newStart !== '') {
248 newLineContent = `${newStart} ${lineContent}`;
251 const selectFrom = selectionRange.from + (newLineContent.length - lineContent.length);
252 this.editor.input.spliceText(lineRange.from, lineRange.to, newLineContent, {from: selectFrom});
256 * Wrap the selection in the given contents start and end contents.
258 wrapSelection(start: string, end: string): void {
259 const selectRange = this.editor.input.getSelection();
260 const selectionText = this.editor.input.getSelectionText(selectRange);
261 if (!selectionText) {
262 this.#wrapLine(start, end);
266 let newSelectionText: string;
267 let newRange = {from: selectRange.from, to: selectRange.to};
269 if (selectionText.startsWith(start) && selectionText.endsWith(end)) {
270 newSelectionText = selectionText.slice(start.length, selectionText.length - end.length);
271 newRange.to = selectRange.to - (start.length + end.length);
273 newSelectionText = `${start}${selectionText}${end}`;
274 newRange.to = selectRange.to + (start.length + end.length);
277 this.editor.input.spliceText(
285 replaceLineStartForOrderedList() {
286 const selectionRange = this.editor.input.getSelection();
287 const lineRange = this.editor.input.getLineRangeFromPosition(selectionRange.from);
288 const prevLineRange = this.editor.input.getLineRangeFromPosition(lineRange.from - 1);
289 const prevLineText = this.editor.input.getSelectionText(prevLineRange);
291 const listMatch = prevLineText.match(/^(\s*)(\d)([).])\s/) || [];
293 const number = (Number(listMatch[2]) || 0) + 1;
294 const whiteSpace = listMatch[1] || '';
295 const listMark = listMatch[3] || '.';
297 const prefix = `${whiteSpace}${number}${listMark}`;
298 return this.replaceLineStart(prefix);
302 * Cycles through the type of callout block within the selection.
303 * Creates a callout block if none existing, and removes it if cycling past the danger type.
305 cycleCalloutTypeAtSelection() {
306 const selectionRange = this.editor.input.getSelection();
307 const lineRange = this.editor.input.getLineRangeFromPosition(selectionRange.from);
308 const lineText = this.editor.input.getSelectionText(lineRange);
310 const formats = ['info', 'success', 'warning', 'danger'];
311 const joint = formats.join('|');
312 const regex = new RegExp(`class="((${joint})\\s+callout|callout\\s+(${joint}))"`, 'i');
313 const matches = regex.exec(lineText);
314 const format = (matches ? (matches[2] || matches[3]) : '').toLowerCase();
316 if (format === formats[formats.length - 1]) {
317 this.#wrapLine(`<p class="callout ${formats[formats.length - 1]}">`, '</p>');
318 } else if (format === '') {
319 this.#wrapLine('<p class="callout info">', '</p>');
320 } else if (matches) {
321 const newFormatIndex = formats.indexOf(format) + 1;
322 const newFormat = formats[newFormatIndex];
323 const newContent = lineText.replace(matches[0], matches[0].replace(format, newFormat));
324 const lineDiff = newContent.length - lineText.length;
325 const anchor = Math.min(selectionRange.from, selectionRange.to);
326 const head = Math.max(selectionRange.from, selectionRange.to);
327 this.editor.input.spliceText(
331 {from: anchor + lineDiff, to: head + lineDiff}
336 syncDisplayPosition(event: Event): void {
337 // Thanks to http://liuhao.im/english/2015/11/10/the-sync-scroll-of-markdown-editor-in-javascript.html
338 const scrollEl = event.target as HTMLElement;
339 const atEnd = Math.abs(scrollEl.scrollHeight - scrollEl.clientHeight - scrollEl.scrollTop) < 1;
341 this.editor.display.scrollToIndex(-1);
345 const range = this.editor.input.getTextAboveView();
346 const parser = new DOMParser();
347 const doc = parser.parseFromString(this.editor.markdown.render(range), 'text/html');
348 const totalLines = doc.documentElement.querySelectorAll('body > *');
349 this.editor.display.scrollToIndex(totalLines.length);
353 * Fetch and insert the template of the given ID.
354 * The page-relative position provided can be used to determine insert location if possible.
356 async insertTemplate(templateId: string, posX: number, posY: number): Promise<void> {
357 const cursorPos = this.editor.input.coordsToSelection(posX, posY).from;
358 const responseData = (await window.$http.get(`/templates/${templateId}`)).data as {markdown: string, html: string};
359 const content = responseData.markdown || responseData.html;
360 this.editor.input.spliceText(cursorPos, cursorPos, content, {from: cursorPos});
364 * Insert multiple images from the clipboard from an event at the provided
365 * screen coordinates (Typically form a paste event).
367 insertClipboardImages(images: File[], posX: number, posY: number): void {
368 const cursorPos = this.editor.input.coordsToSelection(posX, posY).from;
369 for (const image of images) {
370 this.uploadImage(image, cursorPos);
375 * Handle image upload and add image into Markdown content
377 async uploadImage(file: File, position: number|null = null): Promise<void> {
378 if (file === null || file.type.indexOf('image') !== 0) return;
381 if (position === null) {
382 position = this.editor.input.getSelection().from;
386 const fileNameMatches = file.name.match(/\.(.+)$/);
387 if (fileNameMatches && fileNameMatches.length > 1) {
388 ext = fileNameMatches[1];
392 // Insert image into markdown
393 const id = `image-${Math.random().toString(16).slice(2)}`;
394 const placeholderImage = window.baseUrl(`/loading.gif#upload${id}`);
395 const placeHolderText = ``;
396 this.editor.input.spliceText(position, position, placeHolderText, {from: position});
398 const remoteFilename = `image-${Date.now()}.${ext}`;
399 const formData = new FormData();
400 formData.append('file', file, remoteFilename);
401 formData.append('uploaded_to', this.editor.config.pageId);
404 const image = (await window.$http.post('/images/gallery', formData)).data as ImageManagerImage;
405 const newContent = `[](${image.url})`;
406 this.#findAndReplaceContent(placeHolderText, newContent);
408 window.$events.error(err?.data?.message || this.editor.config.text.imageUploadError);
409 this.#findAndReplaceContent(placeHolderText, '');
415 * Replace the current selection and focus the editor.
416 * Takes an offset for the cursor, after the change, relative to the start of the provided string.
417 * Can be provided a selection range to use instead of the current selection range.
419 #replaceSelection(newContent: string, offset: number = 0, selection: MarkdownEditorInputSelection|null = null) {
420 selection = selection || this.editor.input.getSelection();
421 const selectFrom = selection.from + offset;
422 this.editor.input.spliceText(selection.from, selection.to, newContent, {from: selectFrom, to: selectFrom});
423 this.editor.input.focus();
427 * Cleans the given text to work with the editor.
428 * Standardises line endings to what's expected.
430 #cleanTextForEditor(text: string): string {
431 return text.replace(/\r\n|\r/g, '\n');
435 * Find and replace the first occurrence of [search] with [replace]
437 #findAndReplaceContent(search: string, replace: string): void {
438 const newText = this.editor.input.getText().replace(search, replace);
439 this.editor.input.setText(newText);
443 * Wrap the line in the given start and end contents.
445 #wrapLine(start: string, end: string): void {
446 const selectionRange = this.editor.input.getSelection();
447 const lineRange = this.editor.input.getLineRangeFromPosition(selectionRange.from);
448 const lineContent = this.editor.input.getSelectionText(lineRange);
449 let newLineContent: string;
450 let lineOffset: number;
452 if (lineContent.startsWith(start) && lineContent.endsWith(end)) {
453 newLineContent = lineContent.slice(start.length, lineContent.length - end.length);
454 lineOffset = -(start.length);
456 newLineContent = `${start}${lineContent}${end}`;
457 lineOffset = start.length;
460 this.editor.input.spliceText(lineRange.from, lineRange.to, newLineContent, {from: selectionRange.from + lineOffset});