BookStack/resources/js/components/markdown-editor.js
Dan Brown a83a7f34f4
Better standardised and fixes areas of image pasting
- Extracted logic to get images from paste/drop event into own file to
align usage in both events for both editors.
- Fixed non-ability to drag+drop into WYSIWYG editor.
- Updated check for table data to look for table specific rich-text
instead of just any text since some the old check was too general and
was preventing some legitimate image paste events.

Tested on Chrome and FireFox on Ubuntu.
Attempted to test on Safari via browserstack but environment was
unreliable and could not access folders to test drag/drop of files.

Relates to #1651 and #1697
2019-12-21 15:48:03 +00:00

544 lines
21 KiB
JavaScript

import MarkdownIt from "markdown-it";
import mdTasksLists from 'markdown-it-task-lists';
import code from '../services/code';
import Clipboard from "../services/clipboard";
import {debounce} from "../services/util";
import DrawIO from "../services/drawio";
class MarkdownEditor {
constructor(elem) {
this.elem = elem;
const pageEditor = document.getElementById('page-editor');
this.pageId = pageEditor.getAttribute('page-id');
this.textDirection = pageEditor.getAttribute('text-direction');
this.markdown = new MarkdownIt({html: true});
this.markdown.use(mdTasksLists, {label: true});
this.display = this.elem.querySelector('.markdown-display');
this.displayStylesLoaded = false;
this.input = this.elem.querySelector('textarea');
this.htmlInput = this.elem.querySelector('input[name=html]');
this.cm = code.markdownEditor(this.input);
this.onMarkdownScroll = this.onMarkdownScroll.bind(this);
this.display.addEventListener('load', () => {
this.displayDoc = this.display.contentDocument;
this.init();
});
window.$events.emitPublic(elem, 'editor-markdown::setup', {
markdownIt: this.markdown,
displayEl: this.display,
codeMirrorInstance: this.cm,
});
}
init() {
let lastClick = 0;
// Prevent markdown display link click redirect
this.displayDoc.addEventListener('click', event => {
let isDblClick = Date.now() - lastClick < 300;
let link = event.target.closest('a');
if (link !== null) {
event.preventDefault();
window.open(link.getAttribute('href'));
return;
}
let drawing = event.target.closest('[drawio-diagram]');
if (drawing !== null && isDblClick) {
this.actionEditDrawing(drawing);
return;
}
lastClick = Date.now();
});
// Button actions
this.elem.addEventListener('click', event => {
let button = event.target.closest('button[data-action]');
if (button === null) return;
let action = button.getAttribute('data-action');
if (action === 'insertImage') this.actionInsertImage();
if (action === 'insertLink') this.actionShowLinkSelector();
if (action === 'insertDrawing' && (event.ctrlKey || event.metaKey)) {
this.actionShowImageManager();
return;
}
if (action === 'insertDrawing') this.actionStartDrawing();
});
// Mobile section toggling
this.elem.addEventListener('click', event => {
const toolbarLabel = event.target.closest('.editor-toolbar-label');
if (!toolbarLabel) return;
const currentActiveSections = this.elem.querySelectorAll('.markdown-editor-wrap');
for (let activeElem of currentActiveSections) {
activeElem.classList.remove('active');
}
toolbarLabel.closest('.markdown-editor-wrap').classList.add('active');
});
window.$events.listen('editor-markdown-update', value => {
this.cm.setValue(value);
this.updateAndRender();
});
this.codeMirrorSetup();
this.listenForBookStackEditorEvents();
// Scroll to text if needed.
const queryParams = (new URL(window.location)).searchParams;
const scrollText = queryParams.get('content-text');
if (scrollText) {
this.scrollToText(scrollText);
}
}
// Update the input content and render the display.
updateAndRender() {
const content = this.cm.getValue();
this.input.value = content;
const html = this.markdown.render(content);
window.$events.emit('editor-html-change', html);
window.$events.emit('editor-markdown-change', content);
// Set body content
this.displayDoc.body.className = 'page-content';
this.displayDoc.body.innerHTML = html;
this.htmlInput.value = html;
// Copy styles from page head and set custom styles for editor
this.loadStylesIntoDisplay();
}
loadStylesIntoDisplay() {
if (this.displayStylesLoaded) return;
this.displayDoc.documentElement.className = 'markdown-editor-display';
this.displayDoc.head.innerHTML = '';
const styles = document.head.querySelectorAll('style,link[rel=stylesheet]');
for (let style of styles) {
const copy = style.cloneNode(true);
this.displayDoc.head.appendChild(copy);
}
this.displayStylesLoaded = true;
}
onMarkdownScroll(lineCount) {
const elems = this.displayDoc.body.children;
if (elems.length <= lineCount) return;
const topElem = (lineCount === -1) ? elems[elems.length-1] : elems[lineCount];
topElem.scrollIntoView({ block: 'start', inline: 'nearest', behavior: 'smooth'});
}
codeMirrorSetup() {
const cm = this.cm;
const context = this;
// Text direction
// cm.setOption('direction', this.textDirection);
cm.setOption('direction', 'ltr'); // Will force to remain as ltr for now due to issues when HTML is in editor.
// Custom key commands
let metaKey = code.getMetaKey();
const extraKeys = {};
// Insert Image shortcut
extraKeys[`${metaKey}-Alt-I`] = function(cm) {
let selectedText = cm.getSelection();
let newText = `![${selectedText}](http://)`;
let cursorPos = cm.getCursor('from');
cm.replaceSelection(newText);
cm.setCursor(cursorPos.line, cursorPos.ch + newText.length -1);
};
// Save draft
extraKeys[`${metaKey}-S`] = cm => {window.$events.emit('editor-save-draft')};
// Save page
extraKeys[`${metaKey}-Enter`] = cm => {window.$events.emit('editor-save-page')};
// Show link selector
extraKeys[`Shift-${metaKey}-K`] = cm => {this.actionShowLinkSelector()};
// Insert Link
extraKeys[`${metaKey}-K`] = cm => {insertLink()};
// FormatShortcuts
extraKeys[`${metaKey}-1`] = cm => {replaceLineStart('##');};
extraKeys[`${metaKey}-2`] = cm => {replaceLineStart('###');};
extraKeys[`${metaKey}-3`] = cm => {replaceLineStart('####');};
extraKeys[`${metaKey}-4`] = cm => {replaceLineStart('#####');};
extraKeys[`${metaKey}-5`] = cm => {replaceLineStart('');};
extraKeys[`${metaKey}-d`] = cm => {replaceLineStart('');};
extraKeys[`${metaKey}-6`] = cm => {replaceLineStart('>');};
extraKeys[`${metaKey}-q`] = cm => {replaceLineStart('>');};
extraKeys[`${metaKey}-7`] = cm => {wrapSelection('\n```\n', '\n```');};
extraKeys[`${metaKey}-8`] = cm => {wrapSelection('`', '`');};
extraKeys[`Shift-${metaKey}-E`] = cm => {wrapSelection('`', '`');};
extraKeys[`${metaKey}-9`] = cm => {wrapSelection('<p class="callout info">', '</p>');};
cm.setOption('extraKeys', extraKeys);
// Update data on content change
cm.on('change', (instance, changeObj) => {
this.updateAndRender();
});
const onScrollDebounced = debounce((instance) => {
// Thanks to http://liuhao.im/english/2015/11/10/the-sync-scroll-of-markdown-editor-in-javascript.html
let scroll = instance.getScrollInfo();
let atEnd = scroll.top + scroll.clientHeight === scroll.height;
if (atEnd) {
this.onMarkdownScroll(-1);
return;
}
let lineNum = instance.lineAtHeight(scroll.top, 'local');
let range = instance.getRange({line: 0, ch: null}, {line: lineNum, ch: null});
let parser = new DOMParser();
let doc = parser.parseFromString(this.markdown.render(range), 'text/html');
let totalLines = doc.documentElement.querySelectorAll('body > *');
this.onMarkdownScroll(totalLines.length);
}, 100);
// Handle scroll to sync display view
cm.on('scroll', instance => {
onScrollDebounced(instance);
});
// Handle image paste
cm.on('paste', (cm, 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) {
uploadImage(image);
}
});
// Handle image & content drag n drop
cm.on('drop', (cm, event) => {
const templateId = event.dataTransfer.getData('bookstack/template');
if (templateId) {
const cursorPos = cm.coordsChar({left: event.pageX, top: event.pageY});
cm.setCursor(cursorPos);
event.preventDefault();
window.$http.get(`/templates/${templateId}`).then(resp => {
const content = resp.data.markdown || resp.data.html;
cm.replaceSelection(content);
});
}
const clipboard = new Clipboard(event.dataTransfer);
if (clipboard.hasItems()) {
const cursorPos = cm.coordsChar({left: event.pageX, top: event.pageY});
cm.setCursor(cursorPos);
event.stopPropagation();
event.preventDefault();
const images = clipboard.getImages();
for (const image of images) {
uploadImage(image);
}
}
});
// Helper to replace editor content
function replaceContent(search, replace) {
let text = cm.getValue();
let cursor = cm.listSelections();
cm.setValue(text.replace(search, replace));
cm.setSelections(cursor);
}
// Helper to replace the start of the line
function replaceLineStart(newStart) {
let cursor = cm.getCursor();
let lineContent = cm.getLine(cursor.line);
let lineLen = lineContent.length;
let lineStart = lineContent.split(' ')[0];
// Remove symbol if already set
if (lineStart === newStart) {
lineContent = lineContent.replace(`${newStart} `, '');
cm.replaceRange(lineContent, {line: cursor.line, ch: 0}, {line: cursor.line, ch: lineLen});
cm.setCursor({line: cursor.line, ch: cursor.ch - (newStart.length + 1)});
return;
}
let alreadySymbol = /^[#>`]/.test(lineStart);
let posDif = 0;
if (alreadySymbol) {
posDif = newStart.length - lineStart.length;
lineContent = lineContent.replace(lineStart, newStart).trim();
} else if (newStart !== '') {
posDif = newStart.length + 1;
lineContent = newStart + ' ' + lineContent;
}
cm.replaceRange(lineContent, {line: cursor.line, ch: 0}, {line: cursor.line, ch: lineLen});
cm.setCursor({line: cursor.line, ch: cursor.ch + posDif});
}
function wrapLine(start, end) {
let cursor = cm.getCursor();
let lineContent = cm.getLine(cursor.line);
let lineLen = lineContent.length;
let newLineContent = lineContent;
if (lineContent.indexOf(start) === 0 && lineContent.slice(-end.length) === end) {
newLineContent = lineContent.slice(start.length, lineContent.length - end.length);
} else {
newLineContent = `${start}${lineContent}${end}`;
}
cm.replaceRange(newLineContent, {line: cursor.line, ch: 0}, {line: cursor.line, ch: lineLen});
cm.setCursor({line: cursor.line, ch: cursor.ch + start.length});
}
function wrapSelection(start, end) {
let selection = cm.getSelection();
if (selection === '') return wrapLine(start, end);
let newSelection = selection;
let frontDiff = 0;
let endDiff = 0;
if (selection.indexOf(start) === 0 && selection.slice(-end.length) === end) {
newSelection = selection.slice(start.length, selection.length - end.length);
endDiff = -(end.length + start.length);
} else {
newSelection = `${start}${selection}${end}`;
endDiff = start.length + end.length;
}
let selections = cm.listSelections()[0];
cm.replaceSelection(newSelection);
let headFirst = selections.head.ch <= selections.anchor.ch;
selections.head.ch += headFirst ? frontDiff : endDiff;
selections.anchor.ch += headFirst ? endDiff : frontDiff;
cm.setSelections([selections]);
}
// Handle image upload and add image into markdown content
function 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 = cm.getSelection();
const placeHolderText = `![${selectedText}](${placeholderImage})`;
const cursor = cm.getCursor();
cm.replaceSelection(placeHolderText);
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', context.pageId);
window.$http.post('/images/gallery', formData).then(resp => {
const newContent = `[![${selectedText}](${resp.data.thumbs.display})](${resp.data.url})`;
replaceContent(placeHolderText, newContent);
}).catch(err => {
window.$events.emit('error', trans('errors.image_upload_error'));
replaceContent(placeHolderText, selectedText);
console.log(err);
});
}
function insertLink() {
let cursorPos = cm.getCursor('from');
let selectedText = cm.getSelection() || '';
let newText = `[${selectedText}]()`;
cm.focus();
cm.replaceSelection(newText);
let cursorPosDiff = (selectedText === '') ? -3 : -1;
cm.setCursor(cursorPos.line, cursorPos.ch + newText.length+cursorPosDiff);
}
this.updateAndRender();
}
actionInsertImage() {
const cursorPos = this.cm.getCursor('from');
window.ImageManager.show(image => {
let selectedText = this.cm.getSelection();
let newText = "[![" + (selectedText || image.name) + "](" + image.thumbs.display + ")](" + image.url + ")";
this.cm.focus();
this.cm.replaceSelection(newText);
this.cm.setCursor(cursorPos.line, cursorPos.ch + newText.length);
}, 'gallery');
}
actionShowImageManager() {
const cursorPos = this.cm.getCursor('from');
window.ImageManager.show(image => {
this.insertDrawing(image, cursorPos);
}, 'drawio');
}
// Show the popup link selector and insert a link when finished
actionShowLinkSelector() {
const cursorPos = this.cm.getCursor('from');
window.EntitySelectorPopup.show(entity => {
let selectedText = this.cm.getSelection() || entity.name;
let newText = `[${selectedText}](${entity.link})`;
this.cm.focus();
this.cm.replaceSelection(newText);
this.cm.setCursor(cursorPos.line, cursorPos.ch + newText.length);
});
}
// Show draw.io if enabled and handle save.
actionStartDrawing() {
if (document.querySelector('[drawio-enabled]').getAttribute('drawio-enabled') !== 'true') return;
let cursorPos = this.cm.getCursor('from');
DrawIO.show(() => {
return Promise.resolve('');
}, (pngData) => {
// let id = "image-" + Math.random().toString(16).slice(2);
// let loadingImage = window.baseUrl('/loading.gif');
let data = {
image: pngData,
uploaded_to: Number(document.getElementById('page-editor').getAttribute('page-id'))
};
window.$http.post(window.baseUrl('/images/drawio'), data).then(resp => {
this.insertDrawing(resp.data, cursorPos);
DrawIO.close();
}).catch(err => {
window.$events.emit('error', trans('errors.image_upload_error'));
console.log(err);
});
});
}
insertDrawing(image, originalCursor) {
const newText = `<div drawio-diagram="${image.id}"><img src="${image.url}"></div>`;
this.cm.focus();
this.cm.replaceSelection(newText);
this.cm.setCursor(originalCursor.line, originalCursor.ch + newText.length);
}
// Show draw.io if enabled and handle save.
actionEditDrawing(imgContainer) {
const drawingDisabled = document.querySelector('[drawio-enabled]').getAttribute('drawio-enabled') !== 'true';
if (drawingDisabled) {
return;
}
const cursorPos = this.cm.getCursor('from');
const drawingId = imgContainer.getAttribute('drawio-diagram');
DrawIO.show(() => {
return DrawIO.load(drawingId);
}, (pngData) => {
let data = {
image: pngData,
uploaded_to: Number(document.getElementById('page-editor').getAttribute('page-id'))
};
window.$http.post(window.baseUrl(`/images/drawio`), data).then(resp => {
let newText = `<div drawio-diagram="${resp.data.id}"><img src="${resp.data.url}"></div>`;
let newContent = this.cm.getValue().split('\n').map(line => {
if (line.indexOf(`drawio-diagram="${drawingId}"`) !== -1) {
return newText;
}
return line;
}).join('\n');
this.cm.setValue(newContent);
this.cm.setCursor(cursorPos);
this.cm.focus();
DrawIO.close();
}).catch(err => {
window.$events.emit('error', trans('errors.image_upload_error'));
console.log(err);
});
});
}
// Scroll to a specified text
scrollToText(searchText) {
if (!searchText) {
return;
}
const content = this.cm.getValue();
const lines = content.split(/\r?\n/);
let lineNumber = lines.findIndex(line => {
return line && line.indexOf(searchText) !== -1;
});
if (lineNumber === -1) {
return;
}
this.cm.scrollIntoView({
line: lineNumber,
}, 200);
this.cm.focus();
// set the cursor location.
this.cm.setCursor({
line: lineNumber,
char: lines[lineNumber].length
})
}
listenForBookStackEditorEvents() {
function getContentToInsert({html, markdown}) {
return markdown || html;
}
// Replace editor content
window.$events.listen('editor::replace', (eventContent) => {
const markdown = getContentToInsert(eventContent);
this.cm.setValue(markdown);
});
// Append editor content
window.$events.listen('editor::append', (eventContent) => {
const cursorPos = this.cm.getCursor('from');
const markdown = getContentToInsert(eventContent);
const content = this.cm.getValue() + '\n' + markdown;
this.cm.setValue(content);
this.cm.setCursor(cursorPos.line, cursorPos.ch);
});
// Prepend editor content
window.$events.listen('editor::prepend', (eventContent) => {
const cursorPos = this.cm.getCursor('from');
const markdown = getContentToInsert(eventContent);
const content = markdown + '\n' + this.cm.getValue();
this.cm.setValue(content);
const prependLineCount = markdown.split('\n').length;
this.cm.setCursor(cursorPos.line + prependLineCount, cursorPos.ch);
});
}
}
export default MarkdownEditor ;