mirror of
https://github.com/flarum/framework.git
synced 2024-11-25 17:57:04 +08:00
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
This commit is contained in:
parent
d5c2a997b1
commit
60dea59815
|
@ -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,
|
||||
|
|
|
@ -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) {
|
||||
|
|
40
framework/core/js/src/common/utils/insertText.ts
Normal file
40
framework/core/js/src/common/utils/insertText.ts
Normal file
|
@ -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 }));
|
||||
}
|
||||
}
|
262
framework/core/js/src/common/utils/styleSelectedText.ts
Normal file
262
framework/core/js/src/common/utils/styleSelectedText.ts
Normal file
|
@ -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 };
|
||||
}
|
Loading…
Reference in New Issue
Block a user