`, '
'); + this.#wrapLine(``, '
'); } else if (format === '') { - this.wrapLine('', '
'); + this.#wrapLine('', '
'); } else { const newFormatIndex = formats.indexOf(format) + 1; const newFormat = formats[newFormatIndex]; - const newContent = lineContent.replace(matches[0], matches[0].replace(format, newFormat)); - this.editor.cm.replaceRange(newContent, contentRange.anchor, contentRange.head); - - const chDiff = newContent.length - lineContent.length; - selectionRange.anchor.ch += chDiff; - if (selectionRange.anchor !== selectionRange.head) { - selectionRange.head.ch += chDiff; - } - this.editor.cm.setSelection(selectionRange.anchor, selectionRange.head); + const newContent = line.text.replace(matches[0], matches[0].replace(format, newFormat)); + const lineDiff = newContent.length - line.text.length; + this.#dispatchChange(line.from, line.to, newContent, selectionRange.anchor + lineDiff, selectionRange.head + lineDiff); } } - /** - * Handle image upload and add image into markdown content - * @param {File} file - */ - uploadImage(file) { - if (file === null || file.type.indexOf('image') !== 0) return; - let ext = 'png'; - - if (file.name) { - let 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 selectedText = this.editor.cm.getSelection(); - const placeHolderText = `![${selectedText}](${placeholderImage})`; - const cursor = this.editor.cm.getCursor(); - this.editor.cm.replaceSelection(placeHolderText); - this.editor.cm.setCursor({line: cursor.line, ch: cursor.ch + selectedText.length + 3}); - - const remoteFilename = "image-" + Date.now() + "." + ext; - const formData = new FormData(); - formData.append('file', file, remoteFilename); - formData.append('uploaded_to', this.editor.config.pageId); - - window.$http.post('/images/gallery', formData).then(resp => { - const newContent = `[![${selectedText}](${resp.data.thumbs.display})](${resp.data.url})`; - this.findAndReplaceContent(placeHolderText, newContent); - }).catch(err => { - window.$events.emit('error', this.editor.config.text.imageUploadError); - this.findAndReplaceContent(placeHolderText, selectedText); - console.log(err); - }); - } - - syncDisplayPosition() { + syncDisplayPosition(event) { // Thanks to http://liuhao.im/english/2015/11/10/the-sync-scroll-of-markdown-editor-in-javascript.html - const scroll = this.editor.cm.getScrollInfo(); - const atEnd = scroll.top + scroll.clientHeight === scroll.height; + const scrollEl = event.target; + const atEnd = Math.abs(scrollEl.scrollHeight - scrollEl.clientHeight - scrollEl.scrollTop) < 1; if (atEnd) { this.editor.display.scrollToIndex(-1); return; } - const lineNum = this.editor.cm.lineAtHeight(scroll.top, 'local'); - const range = this.editor.cm.getRange({line: 0, ch: null}, {line: lineNum, ch: null}); + const blockInfo = this.editor.cm.lineBlockAtHeight(scrollEl.scrollTop); + const range = this.editor.cm.state.sliceDoc(0, blockInfo.from); const parser = new DOMParser(); const doc = parser.parseFromString(this.editor.markdown.render(range), 'text/html'); const totalLines = doc.documentElement.querySelectorAll('body > *'); @@ -435,24 +347,190 @@ export class Actions { * @param {Number} posX * @param {Number} posY */ - insertTemplate(templateId, posX, posY) { - const cursorPos = this.editor.cm.coordsChar({left: posX, top: posY}); - this.editor.cm.setCursor(cursorPos); - window.$http.get(`/templates/${templateId}`).then(resp => { - const content = resp.data.markdown || resp.data.html; - this.editor.cm.replaceSelection(content); - }); + async insertTemplate(templateId, posX, posY) { + const cursorPos = this.editor.cm.posAtCoords({x: posX, y: posY}, false); + const {data} = await window.$http.get(`/templates/${templateId}`); + const content = data.markdown || data.html; + this.#dispatchChange(cursorPos, cursorPos, content, cursorPos); } /** - * Insert multiple images from the clipboard. + * Insert multiple images from the clipboard from an event at the provided + * screen coordinates (Typically form a paste event). * @param {File[]} images + * @param {Number} posX + * @param {Number} posY */ - insertClipboardImages(images) { - const cursorPos = this.editor.cm.coordsChar({left: event.pageX, top: event.pageY}); - this.editor.cm.setCursor(cursorPos); + insertClipboardImages(images, posX, posY) { + const cursorPos = this.editor.cm.posAtCoords({x: posX, y: posY}, false); for (const image of images) { - this.uploadImage(image); + this.uploadImage(image, cursorPos); } } + + /** + * 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) { + let 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 = `![](${placeholderImage})`; + 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.thumbs.display})](${data.url})`; + this.#findAndReplaceContent(placeHolderText, newContent); + } catch (err) { + window.$events.emit('error', this.editor.config.text.imageUploadError); + this.#findAndReplaceContent(placeHolderText, ''); + console.log(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(); + this.#dispatchChange(0, this.editor.cm.state.doc.length, text, selectionRange.from); + 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; + this.#dispatchChange(selectionRange.from, selectionRange.to, newContent, selectionRange.from + cursorOffset); + 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: to, insert: text}}; + + if (selectFrom) { + tr.selection = {anchor: selectFrom}; + } + + 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, + }); + } } \ No newline at end of file diff --git a/resources/js/markdown/codemirror.js b/resources/js/markdown/codemirror.js index 8724a23c8..55ea485e3 100644 --- a/resources/js/markdown/codemirror.js +++ b/resources/js/markdown/codemirror.js @@ -1,4 +1,4 @@ -import {provide as provideShortcuts} from "./shortcuts"; +import {provideKeyBindings} from "./shortcuts"; import {debounce} from "../services/util"; import Clipboard from "../services/clipboard"; @@ -9,62 +9,65 @@ import Clipboard from "../services/clipboard"; */ export async function init(editor) { const Code = await window.importVersioned('code'); - const cm = Code.markdownEditor(editor.config.inputEl); - // Will force to remain as ltr for now due to issues when HTML is in editor. - cm.setOption('direction', 'ltr'); - // Register shortcuts - cm.setOption('extraKeys', provideShortcuts(editor, Code.getMetaKey())); + /** + * @param {ViewUpdate} v + */ + function onViewUpdate(v) { + if (v.docChanged) { + editor.actions.updateAndRender(); + } + } - - // Register codemirror events - - // Update data on content change - cm.on('change', (instance, changeObj) => editor.actions.updateAndRender()); - - // Handle scroll to sync display view const onScrollDebounced = debounce(editor.actions.syncDisplayPosition.bind(editor.actions), 100, false); let syncActive = editor.settings.get('scrollSync'); editor.settings.onChange('scrollSync', val => syncActive = val); - cm.on('scroll', instance => { - if (syncActive) { - onScrollDebounced(instance); + + const domEventHandlers = { + // Handle scroll to sync display view + scroll: (event) => syncActive && onScrollDebounced(event), + // Handle image & content drag n drop + drop: (event) => { + const templateId = event.dataTransfer.getData('bookstack/template'); + if (templateId) { + event.preventDefault(); + editor.actions.insertTemplate(templateId, event.pageX, event.pageY); + } + + const clipboard = new Clipboard(event.dataTransfer); + const clipboardImages = clipboard.getImages(); + if (clipboardImages.length > 0) { + event.stopPropagation(); + event.preventDefault(); + editor.actions.insertClipboardImages(clipboardImages, event.pageX, event.pageY); + } + }, + // Handle image paste + paste: (event) => { + const clipboard = new Clipboard(event.clipboardData || event.dataTransfer); + + // Don't handle the event ourselves if no items exist of contains table-looking data + if (!clipboard.hasItems() || clipboard.containsTabularData()) { + return; + } + + const images = clipboard.getImages(); + for (const image of images) { + editor.actions.uploadImage(image); + } } - }); + } - // Handle image paste - cm.on('paste', (cm, event) => { - const clipboard = new Clipboard(event.clipboardData || event.dataTransfer); + const cm = Code.markdownEditor( + editor.config.inputEl, + onViewUpdate, + domEventHandlers, + provideKeyBindings(editor), + ); - // Don't handle the event ourselves if no items exist of contains table-looking data - if (!clipboard.hasItems() || clipboard.containsTabularData()) { - return; - } - - const images = clipboard.getImages(); - for (const image of images) { - editor.actions.uploadImage(image); - } - }); - - // Handle image & content drag n drop - cm.on('drop', (cm, event) => { - - const templateId = event.dataTransfer.getData('bookstack/template'); - if (templateId) { - event.preventDefault(); - editor.actions.insertTemplate(templateId, event.pageX, event.pageY); - } - - const clipboard = new Clipboard(event.dataTransfer); - const clipboardImages = clipboard.getImages(); - if (clipboardImages.length > 0) { - event.stopPropagation(); - event.preventDefault(); - editor.actions.insertClipboardImages(clipboardImages); - } - - }); + // Add editor view to window for easy access/debugging. + // Not part of official API/Docs + window.mdEditorView = cm; return cm; } \ No newline at end of file diff --git a/resources/js/markdown/editor.js b/resources/js/markdown/editor.js index 1cf4cef2b..cb5bf7d1a 100644 --- a/resources/js/markdown/editor.js +++ b/resources/js/markdown/editor.js @@ -49,6 +49,6 @@ export async function init(config) { * @property {Display} display * @property {Markdown} markdown * @property {Actions} actions - * @property {CodeMirror} cm + * @property {EditorView} cm * @property {Settings} settings */ \ No newline at end of file diff --git a/resources/js/markdown/shortcuts.js b/resources/js/markdown/shortcuts.js index 17ffe2fb3..c4a86e544 100644 --- a/resources/js/markdown/shortcuts.js +++ b/resources/js/markdown/shortcuts.js @@ -1,48 +1,64 @@ /** - * Provide shortcuts for the given codemirror instance. + * Provide shortcuts for the editor instance. * @param {MarkdownEditor} editor - * @param {String} metaKey * @returns {Object