+
+ /**
+ * Handle image upload and add image into markdown content
+ * @param {File} file
+ * @param {?Number} position
+ */
+ async uploadImage(file, position = null) {
+ if (file === null || file.type.indexOf('image') !== 0) return;
+ let ext = 'png';
+
+ if (position === null) {
+ position = this.#getSelectionRange().from;
+ }
+
+ if (file.name) {
+ const fileNameMatches = file.name.match(/\.(.+)$/);
+ if (fileNameMatches.length > 1) ext = fileNameMatches[1];
+ }
+
+ // Insert image into markdown
+ const id = `image-${Math.random().toString(16).slice(2)}`;
+ const placeholderImage = window.baseUrl(`/loading.gif#upload${id}`);
+ const placeHolderText = ``;
+ this.#dispatchChange(position, position, placeHolderText, position);
+
+ const remoteFilename = `image-${Date.now()}.${ext}`;
+ const formData = new FormData();
+ formData.append('file', file, remoteFilename);
+ formData.append('uploaded_to', this.editor.config.pageId);
+
+ try {
+ const {data} = await window.$http.post('/images/gallery', formData);
+ const newContent = `[](${data.url})`;
+ this.#findAndReplaceContent(placeHolderText, newContent);
+ } catch (err) {
+ window.$events.error(err?.data?.message || this.editor.config.text.imageUploadError);
+ this.#findAndReplaceContent(placeHolderText, '');
+ console.error(err);
+ }
+ }
+
+ /**
+ * Get the current text of the editor instance.
+ * @return {string}
+ */
+ #getText() {
+ return this.editor.cm.state.doc.toString();
+ }
+
+ /**
+ * Set the text of the current editor instance.
+ * @param {String} text
+ * @param {?SelectionRange} selectionRange
+ */
+ #setText(text, selectionRange = null) {
+ selectionRange = selectionRange || this.#getSelectionRange();
+ const newDoc = this.editor.cm.state.toText(text);
+ const newSelectFrom = Math.min(selectionRange.from, newDoc.length);
+ this.#dispatchChange(0, this.editor.cm.state.doc.length, text, newSelectFrom);
+ this.focus();
+ }
+
+ /**
+ * Replace the current selection and focus the editor.
+ * Takes an offset for the cursor, after the change, relative to the start of the provided string.
+ * Can be provided a selection range to use instead of the current selection range.
+ * @param {String} newContent
+ * @param {Number} cursorOffset
+ * @param {?SelectionRange} selectionRange
+ */
+ #replaceSelection(newContent, cursorOffset = 0, selectionRange = null) {
+ selectionRange = selectionRange || this.editor.cm.state.selection.main;
+ const selectFrom = selectionRange.from + cursorOffset;
+ this.#dispatchChange(selectionRange.from, selectionRange.to, newContent, selectFrom);
+ this.focus();
+ }
+
+ /**
+ * Get the text content of the main current selection.
+ * @param {SelectionRange} selectionRange
+ * @return {string}
+ */
+ #getSelectionText(selectionRange = null) {
+ selectionRange = selectionRange || this.#getSelectionRange();
+ return this.editor.cm.state.sliceDoc(selectionRange.from, selectionRange.to);
+ }
+
+ /**
+ * Get the range of the current main selection.
+ * @return {SelectionRange}
+ */
+ #getSelectionRange() {
+ return this.editor.cm.state.selection.main;
+ }
+
+ /**
+ * Cleans the given text to work with the editor.
+ * Standardises line endings to what's expected.
+ * @param {String} text
+ * @return {String}
+ */
+ #cleanTextForEditor(text) {
+ return text.replace(/\r\n|\r/g, '\n');
+ }
+
+ /**
+ * Find and replace the first occurrence of [search] with [replace]
+ * @param {String} search
+ * @param {String} replace
+ */
+ #findAndReplaceContent(search, replace) {
+ const newText = this.#getText().replace(search, replace);
+ this.#setText(newText);
+ }
+
+ /**
+ * Wrap the line in the given start and end contents.
+ * @param {String} start
+ * @param {String} end
+ */
+ #wrapLine(start, end) {
+ const selectionRange = this.#getSelectionRange();
+ const line = this.editor.cm.state.doc.lineAt(selectionRange.from);
+ const lineContent = line.text;
+ let newLineContent;
+ let lineOffset = 0;
+
+ if (lineContent.startsWith(start) && lineContent.endsWith(end)) {
+ newLineContent = lineContent.slice(start.length, lineContent.length - end.length);
+ lineOffset = -(start.length);
+ } else {
+ newLineContent = `${start}${lineContent}${end}`;
+ lineOffset = start.length;
+ }
+
+ this.#dispatchChange(line.from, line.to, newLineContent, selectionRange.from + lineOffset);
+ }
+
+ /**
+ * Dispatch changes to the editor.
+ * @param {Number} from
+ * @param {?Number} to
+ * @param {?String} text
+ * @param {?Number} selectFrom
+ * @param {?Number} selectTo
+ */
+ #dispatchChange(from, to = null, text = null, selectFrom = null, selectTo = null) {
+ const tr = {changes: {from, to, insert: text}};
+
+ if (selectFrom) {
+ tr.selection = {anchor: selectFrom};
+ if (selectTo) {
+ tr.selection.head = selectTo;
+ }
+ }
+
+ this.editor.cm.dispatch(tr);
+ }
+
+ /**
+ * Set the current selection range.
+ * Optionally will scroll the new range into view.
+ * @param {Number} from
+ * @param {Number} to
+ * @param {Boolean} scrollIntoView
+ */
+ #setSelection(from, to, scrollIntoView = false) {
+ this.editor.cm.dispatch({
+ selection: {anchor: from, head: to},
+ scrollIntoView,
+ });
+ }
+
+}