philomena/assets/js/markdowntoolbar.js

267 lines
7.6 KiB
JavaScript
Raw Normal View History

/**
* Markdown toolbar
*/
import { $, $$ } from './utils/dom';
const markdownSyntax = {
bold: {
action: wrapSelection,
2024-07-03 22:54:14 +02:00
options: { prefix: '**', shortcutKey: 'b' },
},
italics: {
action: wrapSelection,
2024-07-03 22:54:14 +02:00
options: { prefix: '*', shortcutKey: 'i' },
},
under: {
action: wrapSelection,
2024-07-03 22:54:14 +02:00
options: { prefix: '__', shortcutKey: 'u' },
},
spoiler: {
action: wrapSelection,
2024-07-03 22:54:14 +02:00
options: { prefix: '||', shortcutKey: 's' },
},
code: {
action: wrapSelectionOrLines,
options: {
prefix: '`',
suffix: '`',
prefixMultiline: '```\n',
suffixMultiline: '\n```',
singleWrap: true,
2024-07-03 22:54:14 +02:00
shortcutKey: 'e',
},
},
strike: {
action: wrapSelection,
2024-07-03 22:54:14 +02:00
options: { prefix: '~~' },
},
superscript: {
action: wrapSelection,
2024-07-03 22:54:14 +02:00
options: { prefix: '^' },
},
subscript: {
action: wrapSelection,
2024-07-03 22:54:14 +02:00
options: { prefix: '%' },
},
quote: {
action: wrapLines,
2024-07-03 22:54:14 +02:00
options: { prefix: '> ' },
},
link: {
action: insertLink,
2024-07-03 22:54:14 +02:00
options: { shortcutKey: 'l' },
},
image: {
action: insertLink,
2024-07-03 22:54:14 +02:00
options: { image: true, shortcutKey: 'k' },
},
escape: {
action: escapeSelection,
2024-07-03 22:54:14 +02:00
options: { escapeChar: '\\' },
},
};
function getSelections(textarea, linesOnly = false) {
let { selectionStart, selectionEnd } = textarea,
2024-07-03 22:54:14 +02:00
selection = textarea.value.substring(selectionStart, selectionEnd),
leadingSpace = '',
trailingSpace = '',
caret;
const processLinesOnly = linesOnly instanceof RegExp ? linesOnly.test(selection) : linesOnly;
if (processLinesOnly) {
const explorer = /\n/g;
let startNewlineIndex = 0,
2024-07-03 22:54:14 +02:00
endNewlineIndex = textarea.value.length;
while (explorer.exec(textarea.value)) {
const { lastIndex } = explorer;
if (lastIndex <= selectionStart) {
startNewlineIndex = lastIndex;
2024-07-03 22:54:14 +02:00
} else if (lastIndex > selectionEnd) {
endNewlineIndex = lastIndex - 1;
break;
}
}
selectionStart = startNewlineIndex;
const startRemovedValue = textarea.value.substring(selectionStart);
const startsWithBlankString = startRemovedValue.match(/^[\s\n]+/);
if (startsWithBlankString) {
// Offset the selection start to the first non-blank line's first non-blank character, since
// Some browsers treat selection up to the start of the line as including the end of the
// previous line
selectionStart += startsWithBlankString[0].length;
}
selectionEnd = endNewlineIndex;
selection = textarea.value.substring(selectionStart, selectionEnd);
2024-07-03 22:54:14 +02:00
} 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 {
processLinesOnly,
selectedText: selection,
beforeSelection: textarea.value.substring(0, selectionStart) + leadingSpace,
2024-07-03 22:54:14 +02:00
afterSelection: trailingSpace + textarea.value.substring(selectionEnd),
};
}
function transformSelection(textarea, transformer, eachLine) {
const { selectedText, beforeSelection, afterSelection, processLinesOnly } = getSelections(textarea, eachLine),
2024-07-03 22:54:14 +02:00
// For long comments, record scrollbar position to restore it later
{ scrollTop } = textarea;
const { newText, caretOffset } = transformer(selectedText, processLinesOnly);
textarea.value = beforeSelection + newText + afterSelection;
2024-07-03 22:54:14 +02:00
const newSelectionStart =
caretOffset >= 1
? beforeSelection.length + caretOffset
: textarea.value.length - afterSelection.length - caretOffset;
textarea.selectionStart = newSelectionStart;
textarea.selectionEnd = newSelectionStart;
textarea.scrollTop = scrollTop;
// Needed for automatic textarea resizing
textarea.dispatchEvent(new Event('change'));
}
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 ? '![' : '[',
2024-07-03 22:54:14 +02:00
suffix = `](${hyperlink})`;
wrapSelection(textarea, { prefix, suffix });
}
function wrapSelection(textarea, options) {
2024-07-03 22:54:14 +02:00
transformSelection(textarea, (selectedText) => {
const { text = selectedText, prefix = '', suffix = options.prefix } = options,
2024-07-03 22:54:14 +02:00
emptyText = text === '';
let newText = text;
if (!emptyText) {
2024-07-03 22:54:14 +02:00
newText = text.replace(/(\n{2,})/g, (match) => {
return suffix + match + prefix;
});
}
newText = prefix + newText + suffix;
return {
newText,
2024-07-03 22:54:14 +02:00
caretOffset: emptyText ? prefix.length : newText.length,
};
2021-09-13 22:26:01 +02:00
});
}
function wrapLines(textarea, options, eachLine = true) {
2024-07-03 22:54:14 +02:00
transformSelection(
textarea,
(selectedText, processLinesOnly) => {
const { text = selectedText, singleWrap = false } = options,
prefix = (processLinesOnly && options.prefixMultiline) || options.prefix || '',
suffix = (processLinesOnly && options.suffixMultiline) || options.suffix || '',
emptyText = text === '';
let newText = singleWrap
? prefix + text.trim() + suffix
: text
.split(/\n/g)
.map((line) => prefix + line.trim() + suffix)
.join('\n');
// Force a space at the end of lines with only blockquote markers
newText = newText.replace(/^((?:>\s+)*)>$/gm, '$1> ');
return { newText, caretOffset: emptyText ? prefix.length : newText.length };
},
eachLine,
);
}
function wrapSelectionOrLines(textarea, options) {
wrapLines(textarea, options, /\n/);
}
function escapeSelection(textarea, options) {
2024-07-03 22:54:14 +02:00
transformSelection(textarea, (selectedText) => {
const { text = selectedText } = options,
2024-07-03 22:54:14 +02:00
emptyText = text === '';
if (emptyText) return;
const newText = text.replace(/([*_[\]()^`%\\~<>#|])/g, '\\$1');
return {
2021-09-13 22:26:01 +02:00
newText,
2024-07-03 22:54:14 +02:00
caretOffset: newText.length,
};
2021-09-13 22:26:01 +02:00
});
}
function clickHandler(event) {
const button = event.target.closest('.communication__toolbar__button');
if (!button) return;
2021-09-13 22:26:01 +02:00
const toolbar = button.closest('.communication__toolbar'),
2024-07-03 22:54:14 +02:00
// 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 toolbar
textarea = $('.js-toolbar-input', toolbar.parentNode),
id = button.dataset.syntaxId;
markdownSyntax[id].action(textarea, markdownSyntax[id].options);
textarea.focus();
}
function shortcutHandler(event) {
2024-07-03 22:54:14 +02:00
if (
!event.ctrlKey ||
(window.navigator.platform === 'MacIntel' && !event.metaKey) ||
event.shiftKey ||
event.altKey
) {
return;
}
const textarea = event.target,
2024-07-03 22:54:14 +02:00
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() {
2024-07-03 22:54:14 +02:00
$$('.communication__toolbar').forEach((toolbar) => {
toolbar.addEventListener('click', clickHandler);
});
2024-07-03 22:54:14 +02:00
$$('.js-toolbar-input').forEach((textarea) => {
textarea.addEventListener('keydown', shortcutHandler);
});
}
export { setupToolbar };