Convert markdown toolbar logic to TypeScript

Additionally, these changes contain bugfix for the "Escape" button
throwing an error if nothing is selected.
This commit is contained in:
KoloMl 2024-08-29 01:52:01 +04:00
parent 073ca2881b
commit fee1c3e656

View file

@ -4,7 +4,25 @@
import { $, $$ } from './utils/dom'; import { $, $$ } from './utils/dom';
const markdownSyntax = { // List of options provided to the syntax handler function.
type SyntaxHandlerOptions = {
prefix: string;
shortcutKeyCode: number;
suffix: string;
prefixMultiline: string;
suffixMultiline: string;
singleWrap: boolean;
escapeChar: string;
image: boolean;
text: string;
};
type SyntaxHandler = {
action: (textarea: HTMLTextAreaElement, options: Partial<SyntaxHandlerOptions>) => void;
options: Partial<SyntaxHandlerOptions>;
};
const markdownSyntax: Record<string, SyntaxHandler> = {
bold: { bold: {
action: wrapSelection, action: wrapSelection,
options: { prefix: '**', shortcutKeyCode: 66 }, options: { prefix: '**', shortcutKeyCode: 66 },
@ -62,14 +80,22 @@ const markdownSyntax = {
}, },
}; };
function getSelections(textarea, linesOnly = false) { type SelectionResult = {
processLinesOnly: boolean;
selectedText: string;
beforeSelection: string;
afterSelection: string;
};
function getSelections(textarea: HTMLTextAreaElement, linesOnly: RegExp | boolean = false): SelectionResult {
let { selectionStart, selectionEnd } = textarea, let { selectionStart, selectionEnd } = textarea,
selection = textarea.value.substring(selectionStart, selectionEnd), selection = textarea.value.substring(selectionStart, selectionEnd),
leadingSpace = '', leadingSpace = '',
trailingSpace = '', trailingSpace = '',
caret; caret: number;
const processLinesOnly = linesOnly instanceof RegExp ? linesOnly.test(selection) : linesOnly; const processLinesOnly = linesOnly instanceof RegExp ? linesOnly.test(selection) : linesOnly;
if (processLinesOnly) { if (processLinesOnly) {
const explorer = /\n/g; const explorer = /\n/g;
let startNewlineIndex = 0, let startNewlineIndex = 0,
@ -119,7 +145,18 @@ function getSelections(textarea, linesOnly = false) {
}; };
} }
function transformSelection(textarea, transformer, eachLine) { type 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), const { selectedText, beforeSelection, afterSelection, processLinesOnly } = getSelections(textarea, eachLine),
// For long comments, record scrollbar position to restore it later // For long comments, record scrollbar position to restore it later
{ scrollTop } = textarea; { scrollTop } = textarea;
@ -140,7 +177,7 @@ function transformSelection(textarea, transformer, eachLine) {
textarea.dispatchEvent(new Event('change')); textarea.dispatchEvent(new Event('change'));
} }
function insertLink(textarea, options) { function insertLink(textarea: HTMLTextAreaElement, options: Partial<SyntaxHandlerOptions>) {
let hyperlink = window.prompt(options.image ? 'Image link:' : 'Link:'); let hyperlink = window.prompt(options.image ? 'Image link:' : 'Link:');
if (!hyperlink || hyperlink === '') return; if (!hyperlink || hyperlink === '') return;
@ -155,10 +192,11 @@ function insertLink(textarea, options) {
wrapSelection(textarea, { prefix, suffix }); wrapSelection(textarea, { prefix, suffix });
} }
function wrapSelection(textarea, options) { function wrapSelection(textarea: HTMLTextAreaElement, options: Partial<SyntaxHandlerOptions>) {
transformSelection(textarea, selectedText => { transformSelection(textarea, (selectedText: string): TransformResult => {
const { text = selectedText, prefix = '', suffix = options.prefix } = options, const { text = selectedText, prefix = '', suffix = options.prefix } = options,
emptyText = text === ''; emptyText = text === '';
let newText = text; let newText = text;
if (!emptyText) { if (!emptyText) {
@ -176,10 +214,14 @@ function wrapSelection(textarea, options) {
}); });
} }
function wrapLines(textarea, options, eachLine = true) { function wrapLines(
textarea: HTMLTextAreaElement,
options: Partial<SyntaxHandlerOptions>,
eachLine: RegExp | boolean = true,
) {
transformSelection( transformSelection(
textarea, textarea,
(selectedText, processLinesOnly) => { (selectedText: string, processLinesOnly: boolean): TransformResult => {
const { text = selectedText, singleWrap = false } = options, const { text = selectedText, singleWrap = false } = options,
prefix = (processLinesOnly && options.prefixMultiline) || options.prefix || '', prefix = (processLinesOnly && options.prefixMultiline) || options.prefix || '',
suffix = (processLinesOnly && options.suffixMultiline) || options.suffix || '', suffix = (processLinesOnly && options.suffixMultiline) || options.suffix || '',
@ -200,16 +242,22 @@ function wrapLines(textarea, options, eachLine = true) {
); );
} }
function wrapSelectionOrLines(textarea, options) { function wrapSelectionOrLines(textarea: HTMLTextAreaElement, options: Partial<SyntaxHandlerOptions>) {
wrapLines(textarea, options, /\n/); wrapLines(textarea, options, /\n/);
} }
function escapeSelection(textarea, options) { function escapeSelection(textarea: HTMLTextAreaElement, options: Partial<SyntaxHandlerOptions>) {
transformSelection(textarea, selectedText => { transformSelection(textarea, (selectedText: string): TransformResult => {
const { text = selectedText } = options, const { text = selectedText } = options,
emptyText = text === ''; emptyText = text === '';
if (emptyText) return; // Even if there is nothing to escape, we still need to return the result, otherwise the error would be thrown.
if (emptyText) {
return {
newText: text,
caretOffset: text.length,
};
}
const newText = text.replace(/([*_[\]()^`%\\~<>#|])/g, '\\$1'); const newText = text.replace(/([*_[\]()^`%\\~<>#|])/g, '\\$1');
@ -220,22 +268,28 @@ function escapeSelection(textarea, options) {
}); });
} }
function clickHandler(event) { function clickHandler(event: MouseEvent) {
const button = event.target.closest('.communication__toolbar__button'); if (!(event.target instanceof HTMLElement)) return;
if (!button) return;
const toolbar = button.closest('.communication__toolbar'), const button = event.target?.closest<HTMLElement>('.communication__toolbar__button');
// There may be multiple toolbars present on the page, const toolbar = button?.closest<HTMLElement>('.communication__toolbar');
// in the case of image pages with description edit active
// we target the textarea that shares the same parent as the toolbar if (!button || !toolbar?.parentElement) return;
textarea = $('.js-toolbar-input', toolbar.parentNode),
// 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; id = button.dataset.syntaxId;
if (!textarea || !id) return;
markdownSyntax[id].action(textarea, markdownSyntax[id].options); markdownSyntax[id].action(textarea, markdownSyntax[id].options);
textarea.focus(); textarea.focus();
} }
function canAcceptShortcut(event) { function canAcceptShortcut(event: KeyboardEvent): boolean {
let ctrl, otherModifier; let ctrl: boolean, otherModifier: boolean;
switch (window.navigator.platform) { switch (window.navigator.platform) {
case 'MacIntel': case 'MacIntel':
@ -251,7 +305,7 @@ function canAcceptShortcut(event) {
return ctrl && !otherModifier; return ctrl && !otherModifier;
} }
function shortcutHandler(event) { function shortcutHandler(event: KeyboardEvent) {
if (!canAcceptShortcut(event)) { if (!canAcceptShortcut(event)) {
return; return;
} }
@ -259,6 +313,8 @@ function shortcutHandler(event) {
const textarea = event.target, const textarea = event.target,
keyCode = event.keyCode; keyCode = event.keyCode;
if (!(textarea instanceof HTMLTextAreaElement)) return;
for (const id in markdownSyntax) { for (const id in markdownSyntax) {
if (keyCode === markdownSyntax[id].options.shortcutKeyCode) { if (keyCode === markdownSyntax[id].options.shortcutKeyCode) {
markdownSyntax[id].action(textarea, markdownSyntax[id].options); markdownSyntax[id].action(textarea, markdownSyntax[id].options);
@ -268,10 +324,10 @@ function shortcutHandler(event) {
} }
function setupToolbar() { function setupToolbar() {
$$('.communication__toolbar').forEach(toolbar => { $$<HTMLElement>('.communication__toolbar').forEach(toolbar => {
toolbar.addEventListener('click', clickHandler); toolbar.addEventListener('click', clickHandler);
}); });
$$('.js-toolbar-input').forEach(textarea => { $$<HTMLTextAreaElement>('.js-toolbar-input').forEach(textarea => {
textarea.addEventListener('keydown', shortcutHandler); textarea.addEventListener('keydown', shortcutHandler);
}); });
} }