mirror of
https://github.com/philomena-dev/philomena.git
synced 2024-12-22 17:07:59 +01:00
335 lines
9.6 KiB
TypeScript
335 lines
9.6 KiB
TypeScript
/**
|
|
* Markdown toolbar
|
|
*/
|
|
|
|
import { $, $$ } from './utils/dom';
|
|
|
|
// List of options provided to the syntax handler function.
|
|
interface SyntaxHandlerOptions {
|
|
prefix: string;
|
|
shortcutKeyCode: number;
|
|
suffix: string;
|
|
prefixMultiline: string;
|
|
suffixMultiline: string;
|
|
singleWrap: boolean;
|
|
escapeChar: string;
|
|
image: boolean;
|
|
text: string;
|
|
}
|
|
|
|
interface SyntaxHandler {
|
|
action: (textarea: HTMLTextAreaElement, options: Partial<SyntaxHandlerOptions>) => void;
|
|
options: Partial<SyntaxHandlerOptions>;
|
|
}
|
|
|
|
const markdownSyntax: Record<string, SyntaxHandler> = {
|
|
bold: {
|
|
action: wrapSelection,
|
|
options: { prefix: '**', shortcutKeyCode: 66 },
|
|
},
|
|
italics: {
|
|
action: wrapSelection,
|
|
options: { prefix: '*', shortcutKeyCode: 73 },
|
|
},
|
|
under: {
|
|
action: wrapSelection,
|
|
options: { prefix: '__', shortcutKeyCode: 85 },
|
|
},
|
|
spoiler: {
|
|
action: wrapSelection,
|
|
options: { prefix: '||', shortcutKeyCode: 83 },
|
|
},
|
|
code: {
|
|
action: wrapSelectionOrLines,
|
|
options: {
|
|
prefix: '`',
|
|
suffix: '`',
|
|
prefixMultiline: '```\n',
|
|
suffixMultiline: '\n```',
|
|
singleWrap: true,
|
|
shortcutKeyCode: 69,
|
|
},
|
|
},
|
|
strike: {
|
|
action: wrapSelection,
|
|
options: { prefix: '~~' },
|
|
},
|
|
superscript: {
|
|
action: wrapSelection,
|
|
options: { prefix: '^' },
|
|
},
|
|
subscript: {
|
|
action: wrapSelection,
|
|
options: { prefix: '~' },
|
|
},
|
|
quote: {
|
|
action: wrapLines,
|
|
options: { prefix: '> ' },
|
|
},
|
|
link: {
|
|
action: insertLink,
|
|
options: { shortcutKeyCode: 76 },
|
|
},
|
|
image: {
|
|
action: insertLink,
|
|
options: { image: true, shortcutKeyCode: 75 },
|
|
},
|
|
escape: {
|
|
action: escapeSelection,
|
|
options: { escapeChar: '\\' },
|
|
},
|
|
};
|
|
|
|
interface SelectionResult {
|
|
processLinesOnly: boolean;
|
|
selectedText: string;
|
|
beforeSelection: string;
|
|
afterSelection: string;
|
|
}
|
|
|
|
function getSelections(textarea: HTMLTextAreaElement, linesOnly: RegExp | boolean = false): SelectionResult {
|
|
let { selectionStart, selectionEnd } = textarea,
|
|
selection = textarea.value.substring(selectionStart, selectionEnd),
|
|
leadingSpace = '',
|
|
trailingSpace = '',
|
|
caret: number;
|
|
|
|
const processLinesOnly = linesOnly instanceof RegExp ? linesOnly.test(selection) : linesOnly;
|
|
|
|
if (processLinesOnly) {
|
|
const explorer = /\n/g;
|
|
let startNewlineIndex = 0,
|
|
endNewlineIndex = textarea.value.length;
|
|
while (explorer.exec(textarea.value)) {
|
|
const { lastIndex } = explorer;
|
|
if (lastIndex <= selectionStart) {
|
|
startNewlineIndex = lastIndex;
|
|
} 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);
|
|
} 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,
|
|
afterSelection: trailingSpace + textarea.value.substring(selectionEnd),
|
|
};
|
|
}
|
|
|
|
interface TransformResult {
|
|
newText: string;
|
|
caretOffset: number;
|
|
}
|
|
|
|
type TransformCallback = (selectedText: string, processLinesOnly: boolean) => TransformResult;
|
|
|
|
function transformSelection(
|
|
textarea: HTMLTextAreaElement,
|
|
transformer: TransformCallback,
|
|
eachLine: RegExp | boolean = false,
|
|
) {
|
|
const { selectedText, beforeSelection, afterSelection, processLinesOnly } = getSelections(textarea, eachLine),
|
|
// For long comments, record scrollbar position to restore it later
|
|
{ scrollTop } = textarea;
|
|
|
|
const { newText, caretOffset } = transformer(selectedText, processLinesOnly);
|
|
|
|
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;
|
|
// Needed for automatic textarea resizing
|
|
textarea.dispatchEvent(new Event('change'));
|
|
}
|
|
|
|
function insertLink(textarea: HTMLTextAreaElement, options: Partial<SyntaxHandlerOptions>) {
|
|
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 = `](${hyperlink})`;
|
|
|
|
wrapSelection(textarea, { prefix, suffix });
|
|
}
|
|
|
|
function wrapSelection(textarea: HTMLTextAreaElement, options: Partial<SyntaxHandlerOptions>) {
|
|
transformSelection(textarea, (selectedText: string): TransformResult => {
|
|
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;
|
|
});
|
|
}
|
|
|
|
newText = prefix + newText + suffix;
|
|
|
|
return {
|
|
newText,
|
|
caretOffset: emptyText ? prefix.length : newText.length,
|
|
};
|
|
});
|
|
}
|
|
|
|
function wrapLines(
|
|
textarea: HTMLTextAreaElement,
|
|
options: Partial<SyntaxHandlerOptions>,
|
|
eachLine: RegExp | boolean = true,
|
|
) {
|
|
transformSelection(
|
|
textarea,
|
|
(selectedText: string, processLinesOnly: boolean): TransformResult => {
|
|
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: HTMLTextAreaElement, options: Partial<SyntaxHandlerOptions>) {
|
|
wrapLines(textarea, options, /\n/);
|
|
}
|
|
|
|
function escapeSelection(textarea: HTMLTextAreaElement, options: Partial<SyntaxHandlerOptions>) {
|
|
transformSelection(textarea, (selectedText: string): TransformResult => {
|
|
const { text = selectedText } = options,
|
|
emptyText = text === '';
|
|
|
|
// Nothing to escape, so do nothing
|
|
if (emptyText) {
|
|
return {
|
|
newText: text,
|
|
caretOffset: text.length,
|
|
};
|
|
}
|
|
|
|
const newText = text.replace(/([*_[\]()^`%\\~<>#|])/g, '\\$1');
|
|
|
|
return {
|
|
newText,
|
|
caretOffset: newText.length,
|
|
};
|
|
});
|
|
}
|
|
|
|
function clickHandler(event: MouseEvent) {
|
|
if (!(event.target instanceof HTMLElement)) return;
|
|
|
|
const button = event.target.closest<HTMLElement>('.communication__toolbar__button');
|
|
const toolbar = button?.closest<HTMLElement>('.communication__toolbar');
|
|
|
|
if (!button || !toolbar?.parentElement) return;
|
|
|
|
// 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
|
|
const textarea = $<HTMLTextAreaElement>('.js-toolbar-input', toolbar.parentElement),
|
|
id = button.dataset.syntaxId;
|
|
|
|
if (!textarea || !id) return;
|
|
|
|
markdownSyntax[id].action(textarea, markdownSyntax[id].options);
|
|
textarea.focus();
|
|
}
|
|
|
|
function canAcceptShortcut(event: KeyboardEvent): boolean {
|
|
let ctrl: boolean, otherModifier: boolean;
|
|
|
|
switch (window.navigator.platform) {
|
|
case 'MacIntel':
|
|
ctrl = event.metaKey;
|
|
otherModifier = event.ctrlKey || event.shiftKey || event.altKey;
|
|
break;
|
|
default:
|
|
ctrl = event.ctrlKey;
|
|
otherModifier = event.metaKey || event.shiftKey || event.altKey;
|
|
break;
|
|
}
|
|
|
|
return ctrl && !otherModifier;
|
|
}
|
|
|
|
function shortcutHandler(event: KeyboardEvent) {
|
|
if (!canAcceptShortcut(event)) {
|
|
return;
|
|
}
|
|
|
|
const textarea = event.target,
|
|
keyCode = event.keyCode;
|
|
|
|
if (!(textarea instanceof HTMLTextAreaElement)) return;
|
|
|
|
for (const id in markdownSyntax) {
|
|
if (keyCode === markdownSyntax[id].options.shortcutKeyCode) {
|
|
markdownSyntax[id].action(textarea, markdownSyntax[id].options);
|
|
event.preventDefault();
|
|
}
|
|
}
|
|
}
|
|
|
|
function setupToolbar() {
|
|
$$<HTMLElement>('.communication__toolbar').forEach(toolbar => {
|
|
toolbar.addEventListener('click', clickHandler);
|
|
});
|
|
$$<HTMLTextAreaElement>('.js-toolbar-input').forEach(textarea => {
|
|
textarea.addEventListener('keydown', shortcutHandler);
|
|
});
|
|
}
|
|
|
|
export { setupToolbar };
|