From 60dea59815e10d453421117fa681a6474eb29ec8 Mon Sep 17 00:00:00 2001 From: Alexander Skvortsov <38059171+askvortsov1@users.noreply.github.com> Date: Mon, 10 May 2021 17:53:32 -0400 Subject: [PATCH] Use github markdown utils in core, support key handlers (#2826) This simplifies the markdown extension and allows BBCode to use these features. It also allows undoing stuff like inserting replies/mentions --- framework/core/js/src/common/compat.js | 4 + .../js/src/common/utils/BasicEditorDriver.ts | 33 ++- .../core/js/src/common/utils/insertText.ts | 40 +++ .../js/src/common/utils/styleSelectedText.ts | 262 ++++++++++++++++++ 4 files changed, 324 insertions(+), 15 deletions(-) create mode 100644 framework/core/js/src/common/utils/insertText.ts create mode 100644 framework/core/js/src/common/utils/styleSelectedText.ts diff --git a/framework/core/js/src/common/compat.js b/framework/core/js/src/common/compat.js index 5fe1017a9..68abc38d1 100644 --- a/framework/core/js/src/common/compat.js +++ b/framework/core/js/src/common/compat.js @@ -8,6 +8,8 @@ import ItemList from './utils/ItemList'; import mixin from './utils/mixin'; import humanTime from './utils/humanTime'; import computed from './utils/computed'; +import insertText from './utils/insertText'; +import styleSelectedText from './utils/styleSelectedText'; import Drawer from './utils/Drawer'; import anchorScroll from './utils/anchorScroll'; import RequestError from './utils/RequestError'; @@ -88,6 +90,8 @@ export default { 'utils/mixin': mixin, 'utils/humanTime': humanTime, 'utils/computed': computed, + 'utils/insertText': insertText, + 'utils/styleSelectedText': styleSelectedText, 'utils/Drawer': Drawer, 'utils/anchorScroll': anchorScroll, 'utils/RequestError': RequestError, diff --git a/framework/core/js/src/common/utils/BasicEditorDriver.ts b/framework/core/js/src/common/utils/BasicEditorDriver.ts index 9cb6100b7..077853863 100644 --- a/framework/core/js/src/common/utils/BasicEditorDriver.ts +++ b/framework/core/js/src/common/utils/BasicEditorDriver.ts @@ -1,5 +1,7 @@ import getCaretCoordinates from 'textarea-caret'; +import insertText from './insertText'; import EditorDriverInterface, { EditorDriverParams } from './EditorDriverInterface'; +import ItemList from './ItemList'; export default class BasicEditorDriver implements EditorDriverInterface { el: HTMLTextAreaElement; @@ -32,19 +34,25 @@ export default class BasicEditorDriver implements EditorDriverInterface { this.el.onclick = callInputListeners; this.el.onkeyup = callInputListeners; - this.el.addEventListener('keydown', function (e) { - if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') { - params.onsubmit(); - } + this.el.addEventListener('keydown', (e) => { + this.keyHandlers(params) + .toArray() + .forEach((handler) => handler(e)); }); dom.append(this.el); } - protected setValue(value: string) { - $(this.el).val(value).trigger('input'); + keyHandlers(params: EditorDriverParams): ItemList { + const items = new ItemList(); - this.el.dispatchEvent(new CustomEvent('input', { bubbles: true, cancelable: true })); + items.add('submit', function (e) { + if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') { + params.onsubmit(); + } + }); + + return items; } moveCursorTo(position: number) { @@ -69,16 +77,11 @@ export default class BasicEditorDriver implements EditorDriverInterface { this.insertBetween(pos, pos, text); } - insertBetween(start: number, end: number, text: string) { - const value = this.el.value; - - const before = value.slice(0, start); - const after = value.slice(end); - - this.setValue(`${before}${text}${after}`); + insertBetween(selectionStart: number, selectionEnd: number, text: string) { + insertText(this.el, { text, selectionStart, selectionEnd }); // Move the textarea cursor to the end of the content we just inserted. - this.moveCursorTo(start + text.length); + this.moveCursorTo(selectionStart + text.length); } replaceBeforeCursor(start: number, text: string) { diff --git a/framework/core/js/src/common/utils/insertText.ts b/framework/core/js/src/common/utils/insertText.ts new file mode 100644 index 000000000..5e44ec637 --- /dev/null +++ b/framework/core/js/src/common/utils/insertText.ts @@ -0,0 +1,40 @@ +/* + * Original Copyright GitHub, Inc. Licensed under the MIT License. + * See license text at https://github.com/github/markdown-toolbar-element/blob/master/LICENSE. + */ + +export interface SelectionRange { + text: string; + selectionStart: number | undefined; + selectionEnd: number | undefined; +} + +let canInsertText: boolean | null = null; + +export default function insertText(textarea: HTMLTextAreaElement, { text, selectionStart, selectionEnd }: SelectionRange) { + const originalSelectionStart = textarea.selectionStart; + const before = textarea.value.slice(0, originalSelectionStart); + const after = textarea.value.slice(textarea.selectionEnd); + + if (selectionStart != null && selectionEnd != null) { + textarea.setSelectionRange(selectionStart, selectionEnd + 1); + } else { + textarea.setSelectionRange(originalSelectionStart, textarea.selectionEnd); + } + textarea.focus(); + + if (canInsertText === null || canInsertText === true) { + textarea.contentEditable = 'true'; + canInsertText = document.execCommand('insertText', false, text); + textarea.contentEditable = 'false'; + } + + if (canInsertText && !textarea.value.slice(0, textarea.selectionStart).endsWith(text)) { + canInsertText = false; + } + + if (!canInsertText) { + textarea.value = before + text + after; + textarea.dispatchEvent(new CustomEvent('input', { bubbles: true, cancelable: true })); + } +} diff --git a/framework/core/js/src/common/utils/styleSelectedText.ts b/framework/core/js/src/common/utils/styleSelectedText.ts new file mode 100644 index 000000000..eacd5abc1 --- /dev/null +++ b/framework/core/js/src/common/utils/styleSelectedText.ts @@ -0,0 +1,262 @@ +/* + * Original Copyright GitHub, Inc. Licensed under the MIT License. + * See license text at https://github.com/github/markdown-toolbar-element/blob/master/LICENSE. + */ + +import insertText, { SelectionRange } from './insertText'; + +interface StyleArgs { + prefix: string; + suffix: string; + blockPrefix: string; + blockSuffix: string; + multiline: boolean; + replaceNext: string; + prefixSpace: boolean; + scanFor: string; + surroundWithNewlines: boolean; + orderedList: boolean; + trimFirst: boolean; +} + +const defaults: StyleArgs = { + prefix: '', + suffix: '', + blockPrefix: '', + blockSuffix: '', + multiline: false, + replaceNext: '', + prefixSpace: false, + scanFor: '', + surroundWithNewlines: false, + orderedList: false, + trimFirst: false, +}; + +export default function styleSelectedText(textarea: HTMLTextAreaElement, styleArgs: StyleArgs) { + // Next 2 lines are added + textarea.focus(); + styleArgs = Object.assign({}, defaults, styleArgs); + // Prev 2 lines are added + const text = textarea.value.slice(textarea.selectionStart, textarea.selectionEnd); + + let result; + if (styleArgs.orderedList) { + result = orderedList(textarea); + } else if (styleArgs.multiline && isMultipleLines(text)) { + result = multilineStyle(textarea, styleArgs); + } else { + result = blockStyle(textarea, styleArgs); + } + + insertText(textarea, result); +} + +function isMultipleLines(string: string): boolean { + return string.trim().split('\n').length > 1; +} + +function repeat(string: string, n: number): string { + return Array(n + 1).join(string); +} + +function wordSelectionStart(text: string, i: number): number { + let index = i; + while (text[index] && text[index - 1] != null && !text[index - 1].match(/\s/)) { + index--; + } + return index; +} + +function wordSelectionEnd(text: string, i: number, multiline: boolean): number { + let index = i; + const breakpoint = multiline ? /\n/ : /\s/; + while (text[index] && !text[index].match(breakpoint)) { + index++; + } + return index; +} + +function expandSelectedText(textarea: HTMLTextAreaElement, prefixToUse: string, suffixToUse: string, multiline = false): string { + if (textarea.selectionStart === textarea.selectionEnd) { + textarea.selectionStart = wordSelectionStart(textarea.value, textarea.selectionStart); + textarea.selectionEnd = wordSelectionEnd(textarea.value, textarea.selectionEnd, multiline); + } else { + const expandedSelectionStart = textarea.selectionStart - prefixToUse.length; + const expandedSelectionEnd = textarea.selectionEnd + suffixToUse.length; + const beginsWithPrefix = textarea.value.slice(expandedSelectionStart, textarea.selectionStart) === prefixToUse; + const endsWithSuffix = textarea.value.slice(textarea.selectionEnd, expandedSelectionEnd) === suffixToUse; + if (beginsWithPrefix && endsWithSuffix) { + textarea.selectionStart = expandedSelectionStart; + textarea.selectionEnd = expandedSelectionEnd; + } + } + return textarea.value.slice(textarea.selectionStart, textarea.selectionEnd); +} + +interface Newlines { + newlinesToAppend: string; + newlinesToPrepend: string; +} + +function newlinesToSurroundSelectedText(textarea: HTMLTextAreaElement): Newlines { + const beforeSelection = textarea.value.slice(0, textarea.selectionStart); + const afterSelection = textarea.value.slice(textarea.selectionEnd); + + const breaksBefore = beforeSelection.match(/\n*$/); + const breaksAfter = afterSelection.match(/^\n*/); + const newlinesBeforeSelection = breaksBefore ? breaksBefore[0].length : 0; + const newlinesAfterSelection = breaksAfter ? breaksAfter[0].length : 0; + + let newlinesToAppend; + let newlinesToPrepend; + + if (beforeSelection.match(/\S/) && newlinesBeforeSelection < 2) { + newlinesToAppend = repeat('\n', 2 - newlinesBeforeSelection); + } + + if (afterSelection.match(/\S/) && newlinesAfterSelection < 2) { + newlinesToPrepend = repeat('\n', 2 - newlinesAfterSelection); + } + + if (newlinesToAppend == null) { + newlinesToAppend = ''; + } + + if (newlinesToPrepend == null) { + newlinesToPrepend = ''; + } + + return { newlinesToAppend, newlinesToPrepend }; +} + +function blockStyle(textarea: HTMLTextAreaElement, arg: StyleArgs): SelectionRange { + let newlinesToAppend; + let newlinesToPrepend; + + const { prefix, suffix, blockPrefix, blockSuffix, replaceNext, prefixSpace, scanFor, surroundWithNewlines } = arg; + const originalSelectionStart = textarea.selectionStart; + const originalSelectionEnd = textarea.selectionEnd; + + let selectedText = textarea.value.slice(textarea.selectionStart, textarea.selectionEnd); + let prefixToUse = isMultipleLines(selectedText) && blockPrefix.length > 0 ? `${blockPrefix}\n` : prefix; + let suffixToUse = isMultipleLines(selectedText) && blockSuffix.length > 0 ? `\n${blockSuffix}` : suffix; + + if (prefixSpace) { + const beforeSelection = textarea.value[textarea.selectionStart - 1]; + if (textarea.selectionStart !== 0 && beforeSelection != null && !beforeSelection.match(/\s/)) { + prefixToUse = ` ${prefixToUse}`; + } + } + selectedText = expandSelectedText(textarea, prefixToUse, suffixToUse, arg.multiline); + let selectionStart = textarea.selectionStart; + let selectionEnd = textarea.selectionEnd; + const hasReplaceNext = replaceNext.length > 0 && suffixToUse.indexOf(replaceNext) > -1 && selectedText.length > 0; + if (surroundWithNewlines) { + const ref = newlinesToSurroundSelectedText(textarea); + newlinesToAppend = ref.newlinesToAppend; + newlinesToPrepend = ref.newlinesToPrepend; + prefixToUse = newlinesToAppend + prefix; + suffixToUse += newlinesToPrepend; + } + + if (selectedText.startsWith(prefixToUse) && selectedText.endsWith(suffixToUse)) { + const replacementText = selectedText.slice(prefixToUse.length, selectedText.length - suffixToUse.length); + if (originalSelectionStart === originalSelectionEnd) { + let position = originalSelectionStart - prefixToUse.length; + position = Math.max(position, selectionStart); + position = Math.min(position, selectionStart + replacementText.length); + selectionStart = selectionEnd = position; + } else { + selectionEnd = selectionStart + replacementText.length; + } + return { text: replacementText, selectionStart, selectionEnd }; + } else if (!hasReplaceNext) { + let replacementText = prefixToUse + selectedText + suffixToUse; + selectionStart = originalSelectionStart + prefixToUse.length; + selectionEnd = originalSelectionEnd + prefixToUse.length; + const whitespaceEdges = selectedText.match(/^\s*|\s*$/g); + if (arg.trimFirst && whitespaceEdges) { + const leadingWhitespace = whitespaceEdges[0] || ''; + const trailingWhitespace = whitespaceEdges[1] || ''; + replacementText = leadingWhitespace + prefixToUse + selectedText.trim() + suffixToUse + trailingWhitespace; + selectionStart += leadingWhitespace.length; + selectionEnd -= trailingWhitespace.length; + } + return { text: replacementText, selectionStart, selectionEnd }; + } else if (scanFor.length > 0 && selectedText.match(scanFor)) { + suffixToUse = suffixToUse.replace(replaceNext, selectedText); + const replacementText = prefixToUse + suffixToUse; + selectionStart = selectionEnd = selectionStart + prefixToUse.length; + return { text: replacementText, selectionStart, selectionEnd }; + } else { + const replacementText = prefixToUse + selectedText + suffixToUse; + selectionStart = selectionStart + prefixToUse.length + selectedText.length + suffixToUse.indexOf(replaceNext); + selectionEnd = selectionStart + replaceNext.length; + return { text: replacementText, selectionStart, selectionEnd }; + } +} + +function multilineStyle(textarea: HTMLTextAreaElement, arg: StyleArgs) { + const { prefix, suffix, surroundWithNewlines } = arg; + let text = textarea.value.slice(textarea.selectionStart, textarea.selectionEnd); + let selectionStart = textarea.selectionStart; + let selectionEnd = textarea.selectionEnd; + const lines = text.split('\n'); + const undoStyle = lines.every((line) => line.startsWith(prefix) && line.endsWith(suffix)); + + if (undoStyle) { + text = lines.map((line) => line.slice(prefix.length, line.length - suffix.length)).join('\n'); + selectionEnd = selectionStart + text.length; + } else { + text = lines.map((line) => prefix + line + suffix).join('\n'); + if (surroundWithNewlines) { + const { newlinesToAppend, newlinesToPrepend } = newlinesToSurroundSelectedText(textarea); + selectionStart += newlinesToAppend.length; + selectionEnd = selectionStart + text.length; + text = newlinesToAppend + text + newlinesToPrepend; + } + } + + return { text, selectionStart, selectionEnd }; +} + +function orderedList(textarea: HTMLTextAreaElement): SelectionRange { + const orderedListRegex = /^\d+\.\s+/; + const noInitialSelection = textarea.selectionStart === textarea.selectionEnd; + let selectionEnd; + let selectionStart; + let text = textarea.value.slice(textarea.selectionStart, textarea.selectionEnd); + let textToUnstyle = text; + let lines = text.split('\n'); + let startOfLine, endOfLine; + if (noInitialSelection) { + const linesBefore = textarea.value.slice(0, textarea.selectionStart).split(/\n/); + startOfLine = textarea.selectionStart - linesBefore[linesBefore.length - 1].length; + endOfLine = wordSelectionEnd(textarea.value, textarea.selectionStart, true); + textToUnstyle = textarea.value.slice(startOfLine, endOfLine); + } + const linesToUnstyle = textToUnstyle.split('\n'); + const undoStyling = linesToUnstyle.every((line) => orderedListRegex.test(line)); + + if (undoStyling) { + lines = linesToUnstyle.map((line) => line.replace(orderedListRegex, '')); + text = lines.join('\n'); + if (noInitialSelection && startOfLine && endOfLine) { + const lengthDiff = linesToUnstyle[0].length - lines[0].length; + selectionStart = selectionEnd = textarea.selectionStart - lengthDiff; + textarea.selectionStart = startOfLine; + textarea.selectionEnd = endOfLine; + } + } else { + lines = numberedLines(lines); + text = lines.join('\n'); + const { newlinesToAppend, newlinesToPrepend } = newlinesToSurroundSelectedText(textarea); + selectionStart = textarea.selectionStart + newlinesToAppend.length; + selectionEnd = selectionStart + text.length; + if (noInitialSelection) selectionStart = selectionEnd; + text = newlinesToAppend + text + newlinesToPrepend; + } + + return { text, selectionStart, selectionEnd }; +}