mirror of
https://github.com/philomena-dev/philomena.git
synced 2024-11-24 04:27:59 +01:00
260 lines
7.5 KiB
JavaScript
260 lines
7.5 KiB
JavaScript
/**
|
|
* Markdown toolbar
|
|
*/
|
|
|
|
import { $, $$, showEl } from './utils/dom';
|
|
|
|
const markdownSyntax = {
|
|
bold: {
|
|
action: wrapSelection,
|
|
options: { prefix: '**', shortcutKey: 'b' }
|
|
},
|
|
italics: {
|
|
action: wrapSelection,
|
|
options: { prefix: '*', shortcutKey: 'i' }
|
|
},
|
|
under: {
|
|
action: wrapSelection,
|
|
options: { prefix: '__', shortcutKey: 'u' }
|
|
},
|
|
spoiler: {
|
|
action: wrapSelection,
|
|
options: { prefix: '||', shortcutKey: 's' }
|
|
},
|
|
code: {
|
|
action: wrapSelection,
|
|
options: { prefix: '`', shortcutKey: 'e' }
|
|
},
|
|
strike: {
|
|
action: wrapSelection,
|
|
options: { prefix: '~~' }
|
|
},
|
|
superscript: {
|
|
action: wrapSelection,
|
|
options: { prefix: '^' }
|
|
},
|
|
subscript: {
|
|
action: wrapSelection,
|
|
options: { prefix: '%' }
|
|
},
|
|
quote: {
|
|
action: wrapLines,
|
|
options: { prefix: '> ' }
|
|
},
|
|
link: {
|
|
action: insertLink,
|
|
options: { shortcutKey: 'l' }
|
|
},
|
|
image: {
|
|
action: insertLink,
|
|
options: { image: true, shortcutKey: 'k' }
|
|
},
|
|
noParse: {
|
|
action: escapeSelection,
|
|
options: { escapeChar: '\\' }
|
|
}
|
|
};
|
|
|
|
function getSelections(textarea, linesOnly = false) {
|
|
let { selectionStart, selectionEnd } = textarea,
|
|
selection = textarea.value.substring(selectionStart, selectionEnd),
|
|
leadingSpace = '',
|
|
trailingSpace = '',
|
|
caret;
|
|
|
|
if (linesOnly) {
|
|
let startNewlineIndex = 0,
|
|
endNewlineIndex = textarea.value.length;
|
|
const explorer = /\n/g;
|
|
while (explorer.exec(textarea.value)) {
|
|
const { lastIndex } = explorer;
|
|
if (lastIndex < selectionStart) {
|
|
startNewlineIndex = lastIndex + 1;
|
|
}
|
|
else if (lastIndex > selectionEnd) {
|
|
endNewlineIndex = lastIndex;
|
|
break;
|
|
}
|
|
}
|
|
|
|
selectionStart = startNewlineIndex;
|
|
selectionEnd = endNewlineIndex;
|
|
selection = textarea.value.substring(selectionStart, selectionEnd);
|
|
}
|
|
else {
|
|
// Deselect trailing space and line break
|
|
for (caret = selection.length - 1; caret > 0; caret--) {
|
|
if (selection[caret] !== ' ' && selection[caret] !== '\n') break;
|
|
trailingSpace = selection[caret] + trailingSpace;
|
|
}
|
|
selection = selection.substring(0, caret + 1);
|
|
|
|
// Deselect leading space and line break
|
|
for (caret = 0; caret < selection.length; caret++) {
|
|
if (selection[caret] !== ' ' && selection[caret] !== '\n') break;
|
|
leadingSpace += selection[caret];
|
|
}
|
|
selection = selection.substring(caret);
|
|
}
|
|
|
|
return {
|
|
selectedText: selection,
|
|
beforeSelection: textarea.value.substring(0, selectionStart) + leadingSpace,
|
|
afterSelection: trailingSpace + textarea.value.substring(selectionEnd)
|
|
};
|
|
}
|
|
|
|
function getSurroundingTwoLines(beforeText, afterText) {
|
|
// Selection typically includes the new line right before it
|
|
// therefore you need to include two lines before and after
|
|
return {
|
|
twoLinesBefore: beforeText.split('\n').slice(-2).join('\n'),
|
|
twoLinesAfter: afterText.split('\n').slice(0, 2).join('\n')
|
|
};
|
|
}
|
|
|
|
function transformSelection(textarea, transformer, eachLine) {
|
|
const { selectedText, beforeSelection, afterSelection } = getSelections(textarea, eachLine),
|
|
// For long comments, record scrollbar position to restore it later
|
|
{ scrollTop } = textarea;
|
|
|
|
const { newText, caretOffset } = transformer(selectedText, beforeSelection, afterSelection);
|
|
|
|
textarea.value = beforeSelection + newText + afterSelection;
|
|
|
|
const newSelectionStart = caretOffset >= 1
|
|
? beforeSelection.length + caretOffset
|
|
: textarea.value.length - afterSelection.length - caretOffset;
|
|
|
|
textarea.selectionStart = newSelectionStart;
|
|
textarea.selectionEnd = newSelectionStart;
|
|
textarea.scrollTop = scrollTop;
|
|
textarea.dispatchEvent(new Event('keydown'));
|
|
}
|
|
|
|
function insertLink(textarea, options) {
|
|
let hyperlink = window.prompt(options.image ? 'Image link:' : 'Link:');
|
|
if (!hyperlink || hyperlink === '') return;
|
|
|
|
// Change on-site link to use relative url
|
|
if (!options.image && hyperlink.startsWith(window.location.origin)) {
|
|
hyperlink = hyperlink.substring(window.location.origin.length);
|
|
}
|
|
|
|
const prefix = options.image ? '![' : '[',
|
|
suffix = `](${escapeHyperlink(hyperlink)})`;
|
|
|
|
wrapSelection(textarea, { prefix, suffix });
|
|
}
|
|
|
|
function wrapSelection(textarea, options) {
|
|
transformSelection(textarea, selectedText => {
|
|
const { text = selectedText, prefix = '', suffix = options.prefix } = options,
|
|
emptyText = text === '';
|
|
let newText = text;
|
|
|
|
if (!emptyText) {
|
|
newText = text.replace(/(\n{2,})/g, match => {
|
|
return suffix + match + prefix;
|
|
});
|
|
}
|
|
|
|
return {
|
|
newText: prefix + newText + suffix,
|
|
caretOffset: emptyText ? prefix.length : -suffix.length
|
|
};
|
|
});
|
|
}
|
|
|
|
function wrapLines(textarea, options) {
|
|
transformSelection(textarea, (selectedText, before, after) => {
|
|
const { text = selectedText, prefix = '', suffix = '' } = options,
|
|
{ twoLinesBefore, twoLinesAfter } = getSurroundingTwoLines(before, after),
|
|
emptyText = text === '';
|
|
let newText = prefix;
|
|
|
|
if (!emptyText) {
|
|
newText = text.split(/\n/g).map(line => prefix + line.trim() + suffix).join('\n');
|
|
}
|
|
else {
|
|
newText += suffix;
|
|
}
|
|
|
|
// Add blank lines before/after if surrounding line are not empty
|
|
if (isNotBlank(twoLinesBefore)) newText = `\n${newText}`;
|
|
if (isNotBlank(twoLinesAfter)) newText += '\n';
|
|
|
|
return { newText, caretOffset: newText.length - suffix.length };
|
|
});
|
|
}
|
|
|
|
function escapeSelection(textarea, options) {
|
|
transformSelection(textarea, selectedText => {
|
|
const { text = selectedText } = options,
|
|
emptyText = text === '';
|
|
|
|
if (emptyText) return;
|
|
|
|
const newText = text.replace(/([[\]()*_`\\~<>^])/g, '\\$1').replace(/\|\|/g, '\\|\\|');
|
|
|
|
return {
|
|
newText,
|
|
caretOffset: newText.length
|
|
};
|
|
});
|
|
}
|
|
|
|
function escapeHyperlink(url) {
|
|
return typeof url === 'string' ? url.replace(/([()])/g, '\\$1') : url;
|
|
}
|
|
|
|
function isNotBlank(string) {
|
|
return /\S/.test(string);
|
|
}
|
|
|
|
function clickHandler(event) {
|
|
const button = event.target.closest('.communication__toolbar__button');
|
|
if (!button) return;
|
|
const toolbar = button.closest('.communication__toolbar'),
|
|
// There may be multiple toolbars present on the page,
|
|
// in the case of image pages with description edit active
|
|
// we target the textarea that shares the same parent as the toolabr
|
|
textarea = $('.js-toolbar-input', toolbar.parentNode),
|
|
id = button.dataset.syntaxId;
|
|
|
|
markdownSyntax[id].action(textarea, markdownSyntax[id].options);
|
|
textarea.focus();
|
|
}
|
|
|
|
function shortcutHandler(event) {
|
|
if (!event.ctrlKey || (window.navigator.platform === 'MacIntel' && !event.metaKey) || event.shiftKey || event.altKey) return;
|
|
const textarea = event.target,
|
|
key = event.key.toLowerCase();
|
|
|
|
for (const id in markdownSyntax) {
|
|
if (key === markdownSyntax[id].options.shortcutKey) {
|
|
markdownSyntax[id].action(textarea, markdownSyntax[id].options);
|
|
event.preventDefault();
|
|
}
|
|
}
|
|
}
|
|
|
|
function setupToolbar() {
|
|
$$('.communication__toolbar').forEach(toolbar => {
|
|
toolbar.addEventListener('click', clickHandler);
|
|
});
|
|
$$('.js-toolbar-input').forEach(textarea => {
|
|
textarea.addEventListener('keydown', shortcutHandler);
|
|
});
|
|
|
|
// Transform non-JS basic editor to two-column layout with preview
|
|
$$('.js-preview-input-wrapper').forEach(wrapper => {
|
|
wrapper.classList.remove('block__column--full');
|
|
wrapper.classList.add('block__column--half');
|
|
});
|
|
$$('.js-preview-output-wrapper').forEach(wrapper => {
|
|
showEl(wrapper);
|
|
});
|
|
}
|
|
|
|
export { setupToolbar };
|