mirror of
https://github.com/BookStackApp/BookStack.git
synced 2024-10-30 15:42:41 +01:00
a83a7f34f4
- 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
544 lines
21 KiB
JavaScript
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 ;
|