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";
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.#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.#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.#getSelectionText();
59 const newText = `[${selectedText}]()`;
60 const cursorPosDiff = (selectedText === '') ? -3 : -1;
61 this.#replaceSelection(newText, newText.length + cursorPosDiff);
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);
72 // Show the popup link selector and insert a link when finished
74 const selectionRange = this.#getSelectionRange();
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);
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.#getSelectionRange();
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: SelectionRange) {
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.#getSelectionRange();
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.#getText().split('\n').map(line => {
143 if (line.indexOf(`drawio-diagram="${drawingId}"`) !== -1) {
148 this.#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 text = this.editor.cm.state.doc;
182 let scrollToLine = -1;
183 for (const line of text.iterLines()) {
184 if (line.includes(searchText)) {
185 scrollToLine = lineCount;
191 if (scrollToLine === -1) {
195 const line = text.line(scrollToLine);
196 this.#setSelection(line.from, line.to, true);
201 if (!this.editor.cm.hasFocus) {
202 this.editor.cm.focus();
207 * Insert content into the editor.
209 insertContent(content: string) {
210 this.#replaceSelection(content, content.length);
214 * Prepend content to the editor.
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);
225 * Append content to the editor.
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}`);
235 * Replace the editor's contents
237 replaceContent(content: string): void {
238 this.#setText(content);
242 * Replace the start of the line
243 * @param {String} newStart
245 replaceLineStart(newStart: string): void {
246 const selectionRange = this.#getSelectionRange();
247 const line = this.editor.cm.state.doc.lineAt(selectionRange.from);
249 const lineContent = line.text;
250 const lineStart = lineContent.split(' ')[0];
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);
260 let newLineContent = lineContent;
261 const alreadySymbol = /^[#>`]/.test(lineStart);
263 newLineContent = lineContent.replace(lineStart, newStart).trim();
264 } else if (newStart !== '') {
265 newLineContent = `${newStart} ${lineContent}`;
268 const selectFrom = selectionRange.from + (newLineContent.length - lineContent.length);
269 this.#dispatchChange(line.from, line.to, newLineContent, selectFrom);
273 * Wrap the selection in the given contents start and end contents.
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);
283 let newSelectionText = selectionText;
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));
290 newSelectionText = `${start}${selectionText}${end}`;
291 newRange = selectRange.extend(selectRange.from, selectRange.to + (start.length + end.length));
294 this.#dispatchChange(
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);
308 const listMatch = prevLine.text.match(/^(\s*)(\d)([).])\s/) || [];
310 const number = (Number(listMatch[2]) || 0) + 1;
311 const whiteSpace = listMatch[1] || '';
312 const listMark = listMatch[3] || '.';
314 const prefix = `${whiteSpace}${number}${listMark}`;
315 return this.replaceLineStart(prefix);
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.
322 cycleCalloutTypeAtSelection() {
323 const selectionRange = this.#getSelectionRange();
324 const line = this.editor.cm.state.doc.lineAt(selectionRange.from);
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();
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>');
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(
345 selectionRange.anchor + lineDiff,
346 selectionRange.head + lineDiff,
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;
356 this.editor.display.scrollToIndex(-1);
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);
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.
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);
380 * Insert multiple images from the clipboard from an event at the provided
381 * screen coordinates (Typically form a paste event).
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);
391 * Handle image upload and add image into markdown content
393 async uploadImage(file: File, position: number|null = null): Promise<void> {
394 if (file === null || file.type.indexOf('image') !== 0) return;
397 if (position === null) {
398 position = this.#getSelectionRange().from;
402 const fileNameMatches = file.name.match(/\.(.+)$/);
403 if (fileNameMatches && fileNameMatches.length > 1) {
404 ext = fileNameMatches[1];
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 = ``;
412 this.#dispatchChange(position, position, placeHolderText, position);
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);
420 const image = (await window.$http.post('/images/gallery', formData)).data as ImageManagerImage;
421 const newContent = `[](${image.url})`;
422 this.#findAndReplaceContent(placeHolderText, newContent);
424 window.$events.error(err?.data?.message || this.editor.config.text.imageUploadError);
425 this.#findAndReplaceContent(placeHolderText, '');
431 * Get the current text of the editor instance.
435 return this.editor.cm.state.doc.toString();
439 * Set the text of the current editor instance.
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);
448 window.requestAnimationFrame(() => {
449 this.editor.cm.scrollDOM.scrollTop = scrollTop;
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.
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);
466 * Get the text content of the main current selection.
468 #getSelectionText(selectionRange: SelectionRange|null = null): string {
469 selectionRange = selectionRange || this.#getSelectionRange();
470 return this.editor.cm.state.sliceDoc(selectionRange.from, selectionRange.to);
474 * Get the range of the current main selection.
476 #getSelectionRange(): SelectionRange {
477 return this.editor.cm.state.selection.main;
481 * Cleans the given text to work with the editor.
482 * Standardises line endings to what's expected.
484 #cleanTextForEditor(text: string): string {
485 return text.replace(/\r\n|\r/g, '\n');
489 * Find and replace the first occurrence of [search] with [replace]
491 #findAndReplaceContent(search: string, replace: string): void {
492 const newText = this.#getText().replace(search, replace);
493 this.#setText(newText);
497 * Wrap the line in the given start and end contents.
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;
506 if (lineContent.startsWith(start) && lineContent.endsWith(end)) {
507 newLineContent = lineContent.slice(start.length, lineContent.length - end.length);
508 lineOffset = -(start.length);
510 newLineContent = `${start}${lineContent}${end}`;
511 lineOffset = start.length;
514 this.#dispatchChange(line.from, line.to, newLineContent, selectionRange.from + lineOffset);
518 * Dispatch changes to the editor.
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};
526 change.insert = text;
528 const tr: TransactionSpec = {changes: change};
531 tr.selection = {anchor: selectFrom};
533 tr.selection.head = selectTo;
537 this.editor.cm.dispatch(tr);
541 * Set the current selection range.
542 * Optionally will scroll the new range into view.
543 * @param {Number} from
545 * @param {Boolean} scrollIntoView
547 #setSelection(from: number, to: number, scrollIntoView = false) {
548 this.editor.cm.dispatch({
549 selection: {anchor: from, head: to},