Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Use github markdown utils in core, support key handlers #2826

Merged
merged 6 commits into from
May 10, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions js/src/common/compat.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -85,6 +87,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,
Expand Down
33 changes: 18 additions & 15 deletions js/src/common/utils/BasicEditorDriver.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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') {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is Win + Enter causing a post to submit intentional, as it seems like it will do.

https://developer.mozilla.org/en-US/docs/Web/API/KeyboardEvent/metaKey

Copy link
Member

@davwheat davwheat May 6, 2021

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In Markdown, we check against the user agent to decide between Cmd and Ctrl.

Maybe we could add the getOS helper I had in a PR a while back to make this info part of core and available to extensions instead of duplicating logic.

Something like app.operatingSystem or window.__operatingSystem?

Copy link
Sponsor Member Author

@askvortsov1 askvortsov1 May 8, 2021

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In the long term, I'd to introduce some form of "normalize keys" util like exists in prosemirror, which turns Meta-KEY into the appropriate cmd vs ctrl. I haven't found a library yet.

For now, I'm not sure this PR is the best place to change existing behavior?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If this is the current action of MDArea/Prosmirror then I say we keep it this way for now and modify it later once we normalize things.

params.onsubmit();
}
});

return items;
}

moveCursorTo(position: number) {
Expand All @@ -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) {
Expand Down
40 changes: 40 additions & 0 deletions js/src/common/utils/insertText.ts
Original file line number Diff line number Diff line change
@@ -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 changes: 262 additions & 0 deletions js/src/common/utils/styleSelectedText.ts
Original file line number Diff line number Diff line change
@@ -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 };
}