Add UndoStack class - a custom undo/redo engine

It will be hooked up to the markdown editor later
This commit is contained in:
Martin Hanzel 2019-07-26 07:18:15 +00:00 committed by Kushal Pandya
parent 96ae5bd83d
commit c111d121d6
16 changed files with 1287 additions and 39 deletions

View file

@ -12,6 +12,7 @@ import 'core-js/es/promise/finally';
import 'core-js/es/string/code-point-at';
import 'core-js/es/string/from-code-point';
import 'core-js/es/string/includes';
import 'core-js/es/string/repeat';
import 'core-js/es/string/starts-with';
import 'core-js/es/string/ends-with';
import 'core-js/es/symbol';

View file

@ -3,9 +3,16 @@ import autosize from 'autosize';
import GfmAutoComplete, { defaultAutocompleteConfig } from 'ee_else_ce/gfm_auto_complete';
import dropzoneInput from './dropzone_input';
import { addMarkdownListeners, removeMarkdownListeners } from './lib/utils/text_markdown';
import IndentHelper from './helpers/indent_helper';
import { keystroke } from './lib/utils/common_utils';
import * as keys from './lib/utils/keycodes';
import UndoStack from './lib/utils/undo_stack';
export default class GLForm {
constructor(form, enableGFM = {}) {
this.handleKeyShortcuts = this.handleKeyShortcuts.bind(this);
this.setState = this.setState.bind(this);
this.form = form;
this.textarea = this.form.find('textarea.js-gfm-input');
this.enableGFM = Object.assign({}, defaultAutocompleteConfig, enableGFM);
@ -16,6 +23,10 @@ export default class GLForm {
this.enableGFM[item] = Boolean(dataSources[item]);
}
});
this.undoStack = new UndoStack();
this.indentHelper = new IndentHelper(this.textarea[0]);
// Before we start, we should clean up any previous data for this form
this.destroy();
// Set up the form
@ -85,9 +96,84 @@ export default class GLForm {
clearEventListeners() {
this.textarea.off('focus');
this.textarea.off('blur');
this.textarea.off('keydown');
removeMarkdownListeners(this.form);
}
setState(state) {
const selection = [this.textarea[0].selectionStart, this.textarea[0].selectionEnd];
this.textarea.val(state);
this.textarea[0].setSelectionRange(selection[0], selection[1]);
}
/*
Handle keypresses for a custom undo/redo stack.
We need this because the toolbar buttons and indentation helpers mess with the browser's
native undo/redo capability.
*/
handleUndo(event) {
const content = this.textarea.val();
const { selectionStart, selectionEnd } = this.textarea[0];
const stack = this.undoStack;
if (stack.isEmpty()) {
// ==== Save initial state in undo history ====
stack.save(content);
}
if (keystroke(event, keys.Z_KEY_CODE, 'l')) {
// ==== Undo ====
event.preventDefault();
stack.save(content);
if (stack.canUndo()) {
this.setState(stack.undo());
}
} else if (keystroke(event, keys.Z_KEY_CODE, 'ls') || keystroke(event, keys.Y_KEY_CODE, 'l')) {
// ==== Redo ====
event.preventDefault();
if (stack.canRedo()) {
this.setState(stack.redo());
}
} else if (
keystroke(event, keys.SPACE_KEY_CODE) ||
keystroke(event, keys.ENTER_KEY_CODE) ||
selectionStart !== selectionEnd
) {
// ==== Save after finishing a word or before deleting a large selection ====
stack.save(content);
} else if (content === '') {
// ==== Save after deleting everything ====
stack.save('');
} else {
// ==== Save after 1 second of inactivity ====
stack.scheduleSave(content);
}
}
handleIndent(event) {
if (keystroke(event, keys.LEFT_BRACKET_KEY_CODE, 'l')) {
// ==== Unindent selected lines ====
event.preventDefault();
this.indentHelper.unindent();
} else if (keystroke(event, keys.RIGHT_BRACKET_KEY_CODE, 'l')) {
// ==== Indent selected lines ====
event.preventDefault();
this.indentHelper.indent();
} else if (keystroke(event, keys.ENTER_KEY_CODE)) {
// ==== Auto-indent new lines ====
event.preventDefault();
this.indentHelper.newline();
} else if (keystroke(event, keys.BACKSPACE_KEY_CODE)) {
// ==== Auto-delete indents at the beginning of the line ====
this.indentHelper.backspace(event);
}
}
handleKeyShortcuts(event) {
this.handleIndent(event);
this.handleUndo(event);
}
addEventListeners() {
this.textarea.on('focus', function focusTextArea() {
$(this)
@ -99,5 +185,6 @@ export default class GLForm {
.closest('.md-area')
.removeClass('is-focused');
});
this.textarea.on('keydown', e => this.handleKeyShortcuts(e.originalEvent));
}
}

View file

@ -0,0 +1,182 @@
const INDENT_SEQUENCE = ' ';
function countLeftSpaces(text) {
const i = text.split('').findIndex(c => c !== ' ');
return i === -1 ? text.length : i;
}
/**
* IndentHelper provides methods that allow manual and smart indentation in
* textareas. It supports line indent/unindent, selection indent/unindent,
* auto indentation on newlines, and smart deletion of indents with backspace.
*/
export default class IndentHelper {
/**
* Creates a new IndentHelper and binds it to the given `textarea`. You can provide a custom indent sequence in the second parameter, but the `newline` and `backspace` operations may work funny if the indent sequence isn't spaces only.
*/
constructor(textarea, indentSequence = INDENT_SEQUENCE) {
this.element = textarea;
this.seq = indentSequence;
}
getSelection() {
return { start: this.element.selectionStart, end: this.element.selectionEnd };
}
isRangeSelection() {
return this.element.selectionStart !== this.element.selectionEnd;
}
/**
* Re-implementation of textarea's setRangeText method, because IE/Edge don't support it.
*
* @see https://html.spec.whatwg.org/multipage/form-control-infrastructure.html#dom-textarea%2Finput-setrangetext
*/
setRangeText(replacement, start, end, selectMode) {
// Disable eslint to remain as faithful as possible to the above linked spec
/* eslint-disable no-param-reassign, no-case-declarations */
const text = this.element.value;
if (start > end) {
throw new RangeError('setRangeText: start index must be less than or equal to end index');
}
// Clamp to [0, len]
start = Math.max(0, Math.min(start, text.length));
end = Math.max(0, Math.min(end, text.length));
let selection = { start: this.element.selectionStart, end: this.element.selectionEnd };
this.element.value = text.slice(0, start) + replacement + text.slice(end);
const newLength = replacement.length;
const newEnd = start + newLength;
switch (selectMode) {
case 'select':
selection = { start, newEnd };
break;
case 'start':
selection = { start, end: start };
break;
case 'end':
selection = { start: newEnd, end: newEnd };
break;
case 'preserve':
default:
const oldLength = end - start;
const delta = newLength - oldLength;
if (selection.start > end) {
selection.start += delta;
} else if (selection.start > start) {
selection.start = start;
}
if (selection.end > end) {
selection.end += delta;
} else if (selection.end > start) {
selection.end = newEnd;
}
}
this.element.setSelectionRange(selection.start, selection.end);
/* eslint-enable no-param-reassign, no-case-declarations */
}
/**
* Returns an array of lines in the textarea, with information about their
* start/end offsets and whether they are included in the current selection.
*/
splitLines() {
const { start, end } = this.getSelection();
const lines = this.element.value.split('\n');
let textStart = 0;
const lineObjects = [];
lines.forEach(line => {
const lineObj = {
text: line,
start: textStart,
end: textStart + line.length,
};
lineObj.inSelection = lineObj.start <= end && lineObj.end >= start;
lineObjects.push(lineObj);
textStart += line.length + 1;
});
return lineObjects;
}
/**
* Indents selected lines by one level.
*/
indent() {
const { start } = this.getSelection();
const selectedLines = this.splitLines().filter(line => line.inSelection);
if (!this.isRangeSelection() && start === selectedLines[0].start) {
// Special case: if cursor is at the beginning of the line, move it one
// indent right.
const line = selectedLines[0];
this.setRangeText(this.seq, line.start, line.start, 'end');
} else {
selectedLines.reverse();
selectedLines.forEach(line => {
this.setRangeText(INDENT_SEQUENCE, line.start, line.start, 'preserve');
});
}
}
/**
* Unindents selected lines by one level.
*/
unindent() {
const lines = this.splitLines().filter(line => line.inSelection);
lines.reverse();
lines
.filter(line => line.text.startsWith(this.seq))
.forEach(line => {
this.setRangeText('', line.start, line.start + this.seq.length, 'preserve');
});
}
/**
* Emulates a newline keypress, automatically indenting the new line.
*/
newline() {
const { start, end } = this.getSelection();
if (this.isRangeSelection()) {
// Manually kill the selection before calculating the indent
this.setRangeText('', start, end, 'start');
}
// Auto-indent the next line
const currentLine = this.splitLines().find(line => line.end >= start);
const spaces = countLeftSpaces(currentLine.text);
this.setRangeText(`\n${' '.repeat(spaces)}`, start, start, 'end');
}
/**
* If the cursor is positioned at the end of a line's leading indents,
* emulates a backspace keypress by deleting a single level of indents.
* @param event The DOM KeyboardEvent that triggers this action, or null.
*/
backspace(event) {
const { start } = this.getSelection();
// If the cursor is at the end of leading indents, delete an indent.
if (!this.isRangeSelection()) {
const currentLine = this.splitLines().find(line => line.end >= start);
const cursorPosition = start - currentLine.start;
if (countLeftSpaces(currentLine.text) === cursorPosition && cursorPosition > 0) {
if (event) event.preventDefault();
let spacesToDelete = cursorPosition % this.seq.length;
if (spacesToDelete === 0) {
spacesToDelete = this.seq.length;
}
this.setRangeText('', start - spacesToDelete, start, 'start');
}
}
}
}

View file

@ -203,6 +203,71 @@ export const isMetaKey = e => e.metaKey || e.ctrlKey || e.altKey || e.shiftKey;
// 3) Middle-click or Mouse Wheel Click (e.which is 2)
export const isMetaClick = e => e.metaKey || e.ctrlKey || e.which === 2;
export const getPlatformLeaderKey = () => {
// eslint-disable-next-line @gitlab/i18n/no-non-i18n-strings
if (navigator && navigator.platform && navigator.platform.startsWith('Mac')) {
return 'meta';
}
return 'ctrl';
};
export const getPlatformLeaderKeyHTML = () => {
if (getPlatformLeaderKey() === 'meta') {
return '&#8984;';
}
// eslint-disable-next-line @gitlab/i18n/no-non-i18n-strings
return 'Ctrl';
};
export const isPlatformLeaderKey = e => {
if (getPlatformLeaderKey() === 'meta') {
return Boolean(e.metaKey);
}
return Boolean(e.ctrlKey);
};
/**
* Tests if a KeyboardEvent corresponds exactly to a keystroke.
*
* This function avoids hacking around an old version of Mousetrap, which we ship at the moment. It should be removed after we upgrade to the newest Mousetrap. See:
* - https://gitlab.com/gitlab-org/gitlab-ce/issues/63182
* - https://gitlab.com/gitlab-org/gitlab-ce/issues/64246
*
* @example
* // Matches the enter key with exactly zero modifiers
* keystroke(event, 13)
*
* @example
* // Matches Control-Shift-Z
* keystroke(event, 90, 'cs')
*
* @param e The KeyboardEvent to test.
* @param keyCode The key code of the key to test. Why keycodes? IE/Edge don't support the more convenient `key` and `code` properties.
* @param modifiers A string of modifiers keys. Each modifier key is represented by one character. The set of pressed modifier keys must match the given string exactly. Available options are 'a' for Alt/Option, 'c' for Control, 'm' for Meta/Command, 's' for Shift, and 'l' for the leader key (Meta on MacOS and Control otherwise).
* @returns {boolean} True if the KeyboardEvent corresponds to the given keystroke.
*/
export const keystroke = (e, keyCode, modifiers = '') => {
if (!e || !keyCode) {
return false;
}
const leader = getPlatformLeaderKey();
const mods = modifiers.toLowerCase().replace('l', leader.charAt(0));
// Match depressed modifier keys
if (
e.altKey !== mods.includes('a') ||
e.ctrlKey !== mods.includes('c') ||
e.metaKey !== mods.includes('m') ||
e.shiftKey !== mods.includes('s')
) {
return false;
}
// Match the depressed key
return keyCode === (e.keyCode || e.which);
};
export const contentTop = () => {
const perfBar = $('#js-peek').outerHeight() || 0;
const mrTabsHeight = $('.merge-request-tabs').outerHeight() || 0;

View file

@ -1,4 +1,10 @@
export const UP_KEY_CODE = 38;
export const DOWN_KEY_CODE = 40;
export const BACKSPACE_KEY_CODE = 8;
export const ENTER_KEY_CODE = 13;
export const ESC_KEY_CODE = 27;
export const SPACE_KEY_CODE = 32;
export const UP_KEY_CODE = 38;
export const DOWN_KEY_CODE = 40;
export const Y_KEY_CODE = 89;
export const Z_KEY_CODE = 90;
export const LEFT_BRACKET_KEY_CODE = 219;
export const RIGHT_BRACKET_KEY_CODE = 221;

View file

@ -0,0 +1,105 @@
/**
* UndoStack provides a custom implementation of an undo/redo engine. It was originally written for GitLab's Markdown editor (`gl_form.js`), whose rich text editing capabilities broke native browser undo/redo behaviour.
*
* UndoStack supports predictable undos/redos, debounced saves, maximum history length, and duplicate detection.
*
* Usage:
* - `stack = new UndoStack();`
* - Saves a state to the stack with `stack.save(state)`.
* - Get the current state with `stack.current()`.
* - Revert to the previous state with `stack.undo()`.
* - Redo a previous undo with `stack.redo()`;
* - Queue a future save with `stack.scheduleSave(state, delay)`. Useful for text editors.
* - See the full undo history in `stack.history`.
*/
export default class UndoStack {
constructor(maxLength = 1000) {
this.clear();
this.maxLength = maxLength;
// If you're storing reference-types in the undo stack, you might want to
// reassign this property to some deep-equals function.
this.comparator = (a, b) => a === b;
}
current() {
if (this.cursor === -1) {
return undefined;
}
return this.history[this.cursor];
}
isEmpty() {
return this.history.length === 0;
}
clear() {
this.clearPending();
this.history = [];
this.cursor = -1;
}
save(state) {
this.clearPending();
if (this.comparator(state, this.current())) {
// Don't save state if it's the same as the current state
return;
}
this.history.length = this.cursor + 1;
this.history.push(state);
this.cursor += 1;
if (this.history.length > this.maxLength) {
this.history.shift();
this.cursor -= 1;
}
}
scheduleSave(state, delay = 1000) {
this.clearPending();
this.pendingState = state;
this.timeout = setTimeout(this.saveNow.bind(this), delay);
}
saveNow() {
// Persists scheduled saves immediately
this.save(this.pendingState);
this.clearPending();
}
clearPending() {
// Cancels any scheduled saves
if (this.timeout) {
clearTimeout(this.timeout);
delete this.timeout;
delete this.pendingState;
}
}
canUndo() {
return this.cursor > 0;
}
undo() {
this.clearPending();
if (!this.canUndo()) {
return undefined;
}
this.cursor -= 1;
return this.history[this.cursor];
}
canRedo() {
return this.cursor >= 0 && this.cursor < this.history.length - 1;
}
redo() {
this.clearPending();
if (!this.canRedo()) {
return undefined;
}
this.cursor += 1;
return this.history[this.cursor];
}
}

View file

@ -1,5 +1,6 @@
<script>
import { GlLink } from '@gitlab/ui';
import { s__, sprintf } from '~/locale';
export default {
components: {
@ -22,8 +23,28 @@ export default {
},
},
computed: {
hasQuickActionsDocsPath() {
return this.quickActionsDocsPath !== '';
toolbarHelpHtml() {
const mdLinkStart = `<a href="${this.markdownDocsPath}" target="_blank" rel="noopener noreferrer" tabindex="-1">`;
const actionsLinkStart = `<a href="${this.quickActionsDocsPath}" target="_blank" rel="noopener noreferrer" tabindex="-1">`;
const linkEnd = '</a>';
if (this.markdownDocsPath && !this.quickActionsDocsPath) {
return sprintf(
s__('Editor|%{mdLinkStart}Markdown is supported%{mdLinkEnd}'),
{ mdLinkStart, mdLinkEnd: linkEnd },
false,
);
} else if (this.markdownDocsPath && this.quickActionsDocsPath) {
return sprintf(
s__(
'Editor|%{mdLinkStart}Markdown%{mdLinkEnd} and %{actionsLinkStart}quick actions%{actionsLinkEnd} are supported',
),
{ mdLinkStart, mdLinkEnd: linkEnd, actionsLinkStart, actionsLinkEnd: linkEnd },
false,
);
}
return null;
},
},
};
@ -32,21 +53,7 @@ export default {
<template>
<div class="comment-toolbar clearfix">
<div class="toolbar-text">
<template v-if="!hasQuickActionsDocsPath && markdownDocsPath">
<gl-link :href="markdownDocsPath" target="_blank" tabindex="-1">{{
__('Markdown is supported')
}}</gl-link>
</template>
<template v-if="hasQuickActionsDocsPath && markdownDocsPath">
<gl-link :href="markdownDocsPath" target="_blank" tabindex="-1">{{
__('Markdown')
}}</gl-link>
and
<gl-link :href="quickActionsDocsPath" target="_blank" tabindex="-1">{{
__('quick actions')
}}</gl-link>
are supported
</template>
<span v-html="toolbarHelpHtml"></span>
</div>
<span v-if="canAttachFile" class="uploading-container">
<span class="uploading-progress-container hide">

View file

@ -1,14 +1,13 @@
- supports_quick_actions = local_assigns.fetch(:supports_quick_actions, false)
.comment-toolbar.clearfix
.toolbar-text
= link_to _('Markdown'), help_page_path('user/markdown'), target: '_blank', tabindex: -1
- md_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer" tabindex="-1">'.html_safe % { url: help_page_path('user/markdown') }
- actions_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer" tabindex="-1">'.html_safe % { url: help_page_path('user/project/quick_actions') }
- link_end = '</a>'.html_safe
- if supports_quick_actions
and
= link_to _('quick actions'), help_page_path('user/project/quick_actions'), target: '_blank', tabindex: -1
are
= s_('Editor|%{mdLinkStart}Markdown%{mdLinkEnd} and %{actionsLinkStart}quick actions%{actionsLinkEnd} are supported').html_safe % { mdLinkStart: md_link_start, mdLinkEnd: link_end, actionsLinkStart: actions_link_start, actionsLinkEnd: link_end }
- else
is
supported
= s_('Editor|%{mdLinkStart}Markdown is supported%{mdLinkEnd}').html_safe % { mdLinkStart: md_link_start, mdLinkEnd: link_end }
%span.uploading-container
%span.uploading-progress-container.hide

View file

@ -0,0 +1,5 @@
---
title: Markdown editors now have indentation shortcuts and auto-indentation
merge_request: 28914
author:
type: added

View file

@ -3941,6 +3941,12 @@ msgstr ""
msgid "Edit public deploy key"
msgstr ""
msgid "Editor|%{mdLinkStart}Markdown is supported%{mdLinkEnd}"
msgstr ""
msgid "Editor|%{mdLinkStart}Markdown%{mdLinkEnd} and %{actionsLinkStart}quick actions%{actionsLinkEnd} are supported"
msgstr ""
msgid "Email"
msgstr ""
@ -6382,18 +6388,12 @@ msgstr ""
msgid "Mark to do as done"
msgstr ""
msgid "Markdown"
msgstr ""
msgid "Markdown Help"
msgstr ""
msgid "Markdown enabled"
msgstr ""
msgid "Markdown is supported"
msgstr ""
msgid "Marks this issue as a duplicate of %{duplicate_reference}."
msgstr ""
@ -13314,9 +13314,6 @@ msgstr ""
msgid "project avatar"
msgstr ""
msgid "quick actions"
msgstr ""
msgid "register"
msgstr ""

View file

@ -99,7 +99,7 @@
"mermaid": "^8.2.3",
"monaco-editor": "^0.15.6",
"monaco-editor-webpack-plugin": "^1.7.0",
"mousetrap": "^1.4.6",
"mousetrap": "1.4.6",
"pdfjs-dist": "^2.0.943",
"pikaday": "^1.6.1",
"popper.js": "^1.14.7",

View file

@ -132,9 +132,15 @@ describe "User creates wiki page" do
fill_in(:wiki_content, with: ascii_content)
page.within(".wiki-form") do
click_button("Create page")
end
# This is the dumbest bug in the world:
# When the #wiki_content textarea is filled in, JS captures the `Enter` keydown event in order to do
# auto-indentation and manually inserts a newline. However, for whatever reason, when you try to click on the
# submit button in Capybara, it will not trigger the `click` event if a \n or \r character has been manually
# added to the textarea. It will, however, trigger ALL OTHER EVENTS, including `mouseover`/down/up, focus, and
# blur. Just not `click`. But only when you manually insert \n or \r - if you manually insert any other sequence
# then `click` is fired normally. And it's only Capybara. Browsers and JSDOM don't have this issue.
# So that's why the next line performs the click via JS.
page.execute_script("document.querySelector('.qa-create-page-button').click()")
page.within ".md" do
expect(page).to have_selector(".katex", count: 3).and have_content("2+2 is 4")

View file

@ -0,0 +1,371 @@
import IndentHelper from '~/helpers/indent_helper';
function createMockTextarea() {
const el = document.createElement('textarea');
el.setCursor = pos => el.setSelectionRange(pos, pos);
el.setCursorToEnd = () => el.setCursor(el.value.length);
el.selection = () => [el.selectionStart, el.selectionEnd];
el.cursor = () => {
const [start, end] = el.selection();
return start === end ? start : undefined;
};
return el;
}
describe('indent_helper', () => {
let element;
let ih;
beforeEach(() => {
element = createMockTextarea();
ih = new IndentHelper(element);
});
describe('indents', () => {
describe('a single line', () => {
it('when on an empty line; and cursor follows', () => {
element.value = '';
ih.indent();
expect(element.value).toBe(' ');
expect(element.cursor()).toBe(4);
ih.indent();
expect(element.value).toBe(' ');
expect(element.cursor()).toBe(8);
});
it('when at the start of a line; and cursor stays at start', () => {
element.value = 'foobar';
element.setCursor(0);
ih.indent();
expect(element.value).toBe(' foobar');
expect(element.cursor()).toBe(4);
});
it('when the cursor is in the middle; and cursor follows', () => {
element.value = 'foobar';
element.setCursor(3);
ih.indent();
expect(element.value).toBe(' foobar');
expect(element.cursor()).toBe(7);
});
});
describe('several lines', () => {
it('when everything is selected; and everything remains selected', () => {
element.value = 'foo\nbar\nbaz';
element.setSelectionRange(0, 11);
ih.indent();
expect(element.value).toBe(' foo\n bar\n baz');
expect(element.selection()).toEqual([0, 23]);
});
it('when all lines are partially selected; and the selection adapts', () => {
element.value = 'foo\nbar\nbaz';
element.setSelectionRange(2, 9);
ih.indent();
expect(element.value).toBe(' foo\n bar\n baz');
expect(element.selection()).toEqual([6, 21]);
});
it('when some lines are entirely selected; and entire lines remain selected', () => {
element.value = 'foo\nbar\nbaz';
element.setSelectionRange(4, 11);
ih.indent();
expect(element.value).toBe('foo\n bar\n baz');
expect(element.selection()).toEqual([4, 19]);
});
it('when some lines are partially selected; and the selection adapts', () => {
element.value = 'foo\nbar\nbaz';
element.setSelectionRange(5, 9);
ih.indent();
expect(element.value).toBe('foo\n bar\n baz');
expect(element.selection()).toEqual([5 + 4, 9 + 2 * 4]);
});
it('having different indentation when some lines are entirely selected; and entire lines remain selected', () => {
element.value = ' foo\nbar\n baz';
element.setSelectionRange(8, 19);
ih.indent();
expect(element.value).toBe(' foo\n bar\n baz');
expect(element.selection()).toEqual([8, 27]);
});
it('having different indentation when some lines are partially selected; and the selection adapts', () => {
element.value = ' foo\nbar\n baz';
element.setSelectionRange(9, 14);
ih.indent();
expect(element.value).toBe(' foo\n bar\n baz');
expect(element.selection()).toEqual([13, 22]);
});
});
});
describe('unindents', () => {
describe('a single line', () => {
it('but does nothing if there is not indent', () => {
element.value = 'foobar';
element.setCursor(2);
ih.unindent();
expect(element.value).toBe('foobar');
expect(element.cursor()).toBe(2);
});
it('but does nothing if there is a partial indent', () => {
element.value = ' foobar';
element.setCursor(1);
ih.unindent();
expect(element.value).toBe(' foobar');
expect(element.cursor()).toBe(1);
});
it('when the cursor is in the line text; cursor follows', () => {
element.value = ' foobar';
element.setCursor(6);
ih.unindent();
expect(element.value).toBe('foobar');
expect(element.cursor()).toBe(2);
});
it('when the cursor is in the indent; and cursor goes to start', () => {
element.value = ' foobar';
element.setCursor(2);
ih.unindent();
expect(element.value).toBe('foobar');
expect(element.cursor()).toBe(0);
});
it('when the cursor is at line start; and cursor stays at start', () => {
element.value = ' foobar';
element.setCursor(0);
ih.unindent();
expect(element.value).toBe('foobar');
expect(element.cursor()).toBe(0);
});
it('when a selection includes part of the indent and text', () => {
element.value = ' foobar';
element.setSelectionRange(2, 8);
ih.unindent();
expect(element.value).toBe('foobar');
expect(element.selection()).toEqual([0, 4]);
});
it('when a selection includes part of the indent only', () => {
element.value = ' foobar';
element.setSelectionRange(0, 4);
ih.unindent();
expect(element.value).toBe('foobar');
expect(element.cursor()).toBe(0);
element.value = ' foobar';
element.setSelectionRange(1, 3);
ih.unindent();
expect(element.value).toBe('foobar');
expect(element.cursor()).toBe(0);
});
});
describe('several lines', () => {
it('when everything is selected', () => {
element.value = ' foo\n bar\n baz';
element.setSelectionRange(0, 27);
ih.unindent();
expect(element.value).toBe('foo\n bar\nbaz');
expect(element.selection()).toEqual([0, 15]);
});
it('when all lines are partially selected', () => {
element.value = ' foo\n bar\n baz';
element.setSelectionRange(5, 26);
ih.unindent();
expect(element.value).toBe('foo\n bar\nbaz');
expect(element.selection()).toEqual([1, 14]);
});
it('when all lines are entirely selected', () => {
element.value = ' foo\n bar\n baz';
element.setSelectionRange(8, 27);
ih.unindent();
expect(element.value).toBe(' foo\n bar\nbaz');
expect(element.selection()).toEqual([8, 19]);
});
it('when some lines are entirely selected', () => {
element.value = ' foo\n bar\n baz';
element.setSelectionRange(8, 27);
ih.unindent();
expect(element.value).toBe(' foo\n bar\nbaz');
expect(element.selection()).toEqual([8, 19]);
});
it('when some lines are partially selected', () => {
element.value = ' foo\n bar\n baz';
element.setSelectionRange(17, 26);
ih.unindent();
expect(element.value).toBe(' foo\n bar\nbaz');
expect(element.selection()).toEqual([13, 18]);
});
it('when some lines are partially selected within their indents', () => {
element.value = ' foo\n bar\n baz';
element.setSelectionRange(10, 22);
ih.unindent();
expect(element.value).toBe(' foo\n bar\nbaz');
expect(element.selection()).toEqual([8, 16]);
});
});
});
describe('newline', () => {
describe('on a single line', () => {
it('auto-indents the new line', () => {
element.value = 'foo\n bar\n baz\n qux';
element.setCursor(3);
ih.newline();
expect(element.value).toBe('foo\n\n bar\n baz\n qux');
expect(element.cursor()).toBe(4);
element.setCursor(9);
ih.newline();
expect(element.value).toBe('foo\n\n bar\n \n baz\n qux');
expect(element.cursor()).toBe(11);
element.setCursor(19);
ih.newline();
expect(element.value).toBe('foo\n\n bar\n \n baz\n \n qux');
expect(element.cursor()).toBe(24);
element.setCursor(36);
ih.newline();
expect(element.value).toBe('foo\n\n bar\n \n baz\n \n qux\n ');
expect(element.cursor()).toBe(45);
});
it('splits a line and auto-indents', () => {
element.value = ' foobar';
element.setCursor(7);
ih.newline();
expect(element.value).toBe(' foo\n bar');
expect(element.cursor()).toBe(12);
});
it('replaces selection with an indented newline', () => {
element.value = ' foobarbaz';
element.setSelectionRange(7, 10);
ih.newline();
expect(element.value).toBe(' foo\n baz');
expect(element.cursor()).toBe(12);
});
});
it('on several lines.replaces selection with indented newline', () => {
element.value = ' foo\n bar\n baz';
element.setSelectionRange(4, 17);
ih.newline();
expect(element.value).toBe(' fo\n az');
expect(element.cursor()).toBe(7);
});
});
describe('backspace', () => {
let event;
// This suite tests only the special indent-removing behaviour of the
// backspace() method, since non-special cases are handled natively as a
// backspace keypress.
beforeEach(() => {
event = { preventDefault: jest.fn() };
});
describe('on a single line', () => {
it('does nothing special if in the line text', () => {
element.value = ' foobar';
element.setCursor(7);
ih.backspace(event);
expect(event.preventDefault).not.toHaveBeenCalled();
});
it('does nothing special if after a non-leading indent', () => {
element.value = ' foo bar';
element.setCursor(11);
ih.backspace(event);
expect(event.preventDefault).not.toHaveBeenCalled();
});
it('deletes one leading indent', () => {
element.value = ' foo';
element.setCursor(8);
ih.backspace(event);
expect(event.preventDefault).toHaveBeenCalled();
expect(element.value).toBe(' foo');
expect(element.cursor()).toBe(4);
});
it('does nothing if cursor is inside the leading indent', () => {
element.value = ' foo';
element.setCursor(4);
ih.backspace(event);
expect(event.preventDefault).not.toHaveBeenCalled();
});
it('does nothing if cursor is at the start of the line', () => {
element.value = ' foo';
element.setCursor(0);
ih.backspace(event);
expect(event.preventDefault).not.toHaveBeenCalled();
});
it('deletes one partial indent', () => {
element.value = ' foo';
element.setCursor(6);
ih.backspace(event);
expect(event.preventDefault).toHaveBeenCalled();
expect(element.value).toBe(' foo');
expect(element.cursor()).toBe(4);
});
it('deletes indents sequentially', () => {
element.value = ' foo';
element.setCursor(10);
ih.backspace(event);
ih.backspace(event);
ih.backspace(event);
expect(event.preventDefault).toHaveBeenCalled();
expect(element.value).toBe('foo');
expect(element.cursor()).toBe(0);
});
});
describe('on several lines', () => {
it('deletes indent only on its own line', () => {
element.value = ' foo\n bar\n baz';
element.setCursor(16);
ih.backspace(event);
expect(event.preventDefault).toHaveBeenCalled();
expect(element.value).toBe(' foo\n bar\n baz');
expect(element.cursor()).toBe(12);
});
it('has no special behaviour with any range selection', () => {
const text = ' foo\n bar\n baz';
for (let start = 0; start < text.length; start += 1) {
for (let end = start + 1; end < text.length; end += 1) {
element.value = text;
element.setSelectionRange(start, end);
ih.backspace(event);
expect(event.preventDefault).not.toHaveBeenCalled();
// Ensure that the backspace() method doesn't change state
// In reality, these two statements won't hold because the browser
// will natively process the backspace event.
expect(element.value).toBe(text);
expect(element.selection()).toEqual([start, end]);
}
}
});
});
});
});

View file

@ -0,0 +1,180 @@
import * as cu from '~/lib/utils/common_utils';
const CMD_ENTITY = '&#8984;';
// Redefine `navigator.platform` because it's unsettable by default in JSDOM.
let platform;
Object.defineProperty(navigator, 'platform', {
configurable: true,
get: () => platform,
set: val => {
platform = val;
},
});
describe('common_utils', () => {
describe('platform leader key helpers', () => {
const CTRL_EVENT = { ctrlKey: true };
const META_EVENT = { metaKey: true };
const BOTH_EVENT = { ctrlKey: true, metaKey: true };
it('should return "ctrl" if navigator.platform is unset', () => {
expect(cu.getPlatformLeaderKey()).toBe('ctrl');
expect(cu.getPlatformLeaderKeyHTML()).toBe('Ctrl');
expect(cu.isPlatformLeaderKey(CTRL_EVENT)).toBe(true);
expect(cu.isPlatformLeaderKey(META_EVENT)).toBe(false);
expect(cu.isPlatformLeaderKey(BOTH_EVENT)).toBe(true);
});
it('should return "meta" on MacOS', () => {
navigator.platform = 'MacIntel';
expect(cu.getPlatformLeaderKey()).toBe('meta');
expect(cu.getPlatformLeaderKeyHTML()).toBe(CMD_ENTITY);
expect(cu.isPlatformLeaderKey(CTRL_EVENT)).toBe(false);
expect(cu.isPlatformLeaderKey(META_EVENT)).toBe(true);
expect(cu.isPlatformLeaderKey(BOTH_EVENT)).toBe(true);
});
it('should return "ctrl" on Linux', () => {
navigator.platform = 'Linux is great';
expect(cu.getPlatformLeaderKey()).toBe('ctrl');
expect(cu.getPlatformLeaderKeyHTML()).toBe('Ctrl');
expect(cu.isPlatformLeaderKey(CTRL_EVENT)).toBe(true);
expect(cu.isPlatformLeaderKey(META_EVENT)).toBe(false);
expect(cu.isPlatformLeaderKey(BOTH_EVENT)).toBe(true);
});
it('should return "ctrl" on Windows', () => {
navigator.platform = 'Win32';
expect(cu.getPlatformLeaderKey()).toBe('ctrl');
expect(cu.getPlatformLeaderKeyHTML()).toBe('Ctrl');
expect(cu.isPlatformLeaderKey(CTRL_EVENT)).toBe(true);
expect(cu.isPlatformLeaderKey(META_EVENT)).toBe(false);
expect(cu.isPlatformLeaderKey(BOTH_EVENT)).toBe(true);
});
});
describe('keystroke', () => {
const CODE_BACKSPACE = 8;
const CODE_TAB = 9;
const CODE_ENTER = 13;
const CODE_SPACE = 32;
const CODE_4 = 52;
const CODE_F = 70;
const CODE_Z = 90;
// Helper function that quickly creates KeyboardEvents
const k = (code, modifiers = '') => ({
keyCode: code,
which: code,
altKey: modifiers.includes('a'),
ctrlKey: modifiers.includes('c'),
metaKey: modifiers.includes('m'),
shiftKey: modifiers.includes('s'),
});
const EV_F = k(CODE_F);
const EV_ALT_F = k(CODE_F, 'a');
const EV_CONTROL_F = k(CODE_F, 'c');
const EV_META_F = k(CODE_F, 'm');
const EV_SHIFT_F = k(CODE_F, 's');
const EV_CONTROL_SHIFT_F = k(CODE_F, 'cs');
const EV_ALL_F = k(CODE_F, 'scma');
const EV_ENTER = k(CODE_ENTER);
const EV_TAB = k(CODE_TAB);
const EV_SPACE = k(CODE_SPACE);
const EV_BACKSPACE = k(CODE_BACKSPACE);
const EV_4 = k(CODE_4);
const EV_$ = k(CODE_4, 's');
const { keystroke } = cu;
it('short-circuits with bad arguments', () => {
expect(keystroke()).toBe(false);
expect(keystroke({})).toBe(false);
});
it('handles keystrokes using key codes', () => {
// Test a letter key with modifiers
expect(keystroke(EV_F, CODE_F)).toBe(true);
expect(keystroke(EV_F, CODE_F, '')).toBe(true);
expect(keystroke(EV_ALT_F, CODE_F, 'a')).toBe(true);
expect(keystroke(EV_CONTROL_F, CODE_F, 'c')).toBe(true);
expect(keystroke(EV_META_F, CODE_F, 'm')).toBe(true);
expect(keystroke(EV_SHIFT_F, CODE_F, 's')).toBe(true);
expect(keystroke(EV_CONTROL_SHIFT_F, CODE_F, 'cs')).toBe(true);
expect(keystroke(EV_ALL_F, CODE_F, 'acms')).toBe(true);
// Test non-letter keys
expect(keystroke(EV_TAB, CODE_TAB)).toBe(true);
expect(keystroke(EV_ENTER, CODE_ENTER)).toBe(true);
expect(keystroke(EV_SPACE, CODE_SPACE)).toBe(true);
expect(keystroke(EV_BACKSPACE, CODE_BACKSPACE)).toBe(true);
// Test a number/symbol key
expect(keystroke(EV_4, CODE_4)).toBe(true);
expect(keystroke(EV_$, CODE_4, 's')).toBe(true);
// Test wrong input
expect(keystroke(EV_F, CODE_Z)).toBe(false);
expect(keystroke(EV_SHIFT_F, CODE_F)).toBe(false);
expect(keystroke(EV_SHIFT_F, CODE_F, 'c')).toBe(false);
});
it('is case-insensitive', () => {
expect(keystroke(EV_ALL_F, CODE_F, 'ACMS')).toBe(true);
});
it('handles bogus inputs', () => {
expect(keystroke(EV_F, 'not a keystroke')).toBe(false);
expect(keystroke(EV_F, null)).toBe(false);
});
it('handles exact modifier keys, in any order', () => {
// Test permutations of modifiers
expect(keystroke(EV_ALL_F, CODE_F, 'acms')).toBe(true);
expect(keystroke(EV_ALL_F, CODE_F, 'smca')).toBe(true);
expect(keystroke(EV_ALL_F, CODE_F, 'csma')).toBe(true);
expect(keystroke(EV_CONTROL_SHIFT_F, CODE_F, 'cs')).toBe(true);
expect(keystroke(EV_CONTROL_SHIFT_F, CODE_F, 'sc')).toBe(true);
// Test wrong modifiers
expect(keystroke(EV_ALL_F, CODE_F, 'smca')).toBe(true);
expect(keystroke(EV_ALL_F, CODE_F)).toBe(false);
expect(keystroke(EV_ALL_F, CODE_F, '')).toBe(false);
expect(keystroke(EV_ALL_F, CODE_F, 'c')).toBe(false);
expect(keystroke(EV_ALL_F, CODE_F, 'ca')).toBe(false);
expect(keystroke(EV_ALL_F, CODE_F, 'ms')).toBe(false);
expect(keystroke(EV_CONTROL_SHIFT_F, CODE_F, 'cs')).toBe(true);
expect(keystroke(EV_CONTROL_SHIFT_F, CODE_F, 'c')).toBe(false);
expect(keystroke(EV_CONTROL_SHIFT_F, CODE_F, 's')).toBe(false);
expect(keystroke(EV_CONTROL_SHIFT_F, CODE_F, 'csa')).toBe(false);
expect(keystroke(EV_CONTROL_SHIFT_F, CODE_F, 'm')).toBe(false);
expect(keystroke(EV_SHIFT_F, CODE_F, 's')).toBe(true);
expect(keystroke(EV_SHIFT_F, CODE_F, 'c')).toBe(false);
expect(keystroke(EV_SHIFT_F, CODE_F, 'csm')).toBe(false);
});
it('handles the platform-dependent leader key', () => {
navigator.platform = 'Win32';
let EV_UNDO = k(CODE_Z, 'c');
let EV_REDO = k(CODE_Z, 'cs');
expect(keystroke(EV_UNDO, CODE_Z, 'l')).toBe(true);
expect(keystroke(EV_UNDO, CODE_Z, 'c')).toBe(true);
expect(keystroke(EV_UNDO, CODE_Z, 'm')).toBe(false);
expect(keystroke(EV_REDO, CODE_Z, 'sl')).toBe(true);
expect(keystroke(EV_REDO, CODE_Z, 'sc')).toBe(true);
expect(keystroke(EV_REDO, CODE_Z, 'sm')).toBe(false);
navigator.platform = 'MacIntel';
EV_UNDO = k(CODE_Z, 'm');
EV_REDO = k(CODE_Z, 'ms');
expect(keystroke(EV_UNDO, CODE_Z, 'l')).toBe(true);
expect(keystroke(EV_UNDO, CODE_Z, 'c')).toBe(false);
expect(keystroke(EV_UNDO, CODE_Z, 'm')).toBe(true);
expect(keystroke(EV_REDO, CODE_Z, 'sl')).toBe(true);
expect(keystroke(EV_REDO, CODE_Z, 'sc')).toBe(false);
expect(keystroke(EV_REDO, CODE_Z, 'sm')).toBe(true);
});
});
});

View file

@ -0,0 +1,237 @@
import UndoStack from '~/lib/utils/undo_stack';
import { isEqual } from 'underscore';
describe('UndoStack', () => {
let stack;
beforeEach(() => {
stack = new UndoStack();
});
afterEach(() => {
// Make sure there's not pending saves
const history = Array.from(stack.history);
jest.runAllTimers();
expect(stack.history).toEqual(history);
});
it('is blank on construction', () => {
expect(stack.isEmpty()).toBe(true);
expect(stack.history).toEqual([]);
expect(stack.cursor).toBe(-1);
expect(stack.canUndo()).toBe(false);
expect(stack.canRedo()).toBe(false);
});
it('handles simple undo/redo behaviour', () => {
stack.save(10);
stack.save(11);
stack.save(12);
expect(stack.history).toEqual([10, 11, 12]);
expect(stack.cursor).toBe(2);
expect(stack.current()).toBe(12);
expect(stack.isEmpty()).toBe(false);
expect(stack.canUndo()).toBe(true);
expect(stack.canRedo()).toBe(false);
stack.undo();
expect(stack.history).toEqual([10, 11, 12]);
expect(stack.current()).toBe(11);
expect(stack.canUndo()).toBe(true);
expect(stack.canRedo()).toBe(true);
stack.undo();
expect(stack.current()).toBe(10);
expect(stack.canUndo()).toBe(false);
expect(stack.canRedo()).toBe(true);
stack.redo();
expect(stack.current()).toBe(11);
stack.redo();
expect(stack.current()).toBe(12);
expect(stack.isEmpty()).toBe(false);
expect(stack.canUndo()).toBe(true);
expect(stack.canRedo()).toBe(false);
// Saving should clear the redo stack
stack.undo();
stack.save(13);
expect(stack.history).toEqual([10, 11, 13]);
expect(stack.current()).toBe(13);
});
it('clear() should clear the undo history', () => {
stack.save(0);
stack.save(1);
stack.save(2);
stack.clear();
expect(stack.history).toEqual([]);
expect(stack.current()).toBeUndefined();
});
it('undo and redo are no-ops if unavailable', () => {
stack.save(10);
expect(stack.canRedo()).toBe(false);
expect(stack.canUndo()).toBe(false);
stack.save(11);
expect(stack.canRedo()).toBe(false);
expect(stack.canUndo()).toBe(true);
expect(stack.redo()).toBeUndefined();
expect(stack.history).toEqual([10, 11]);
expect(stack.current()).toBe(11);
expect(stack.canRedo()).toBe(false);
expect(stack.canUndo()).toBe(true);
expect(stack.undo()).toBe(10);
expect(stack.undo()).toBeUndefined();
expect(stack.history).toEqual([10, 11]);
expect(stack.current()).toBe(10);
expect(stack.canRedo()).toBe(true);
expect(stack.canUndo()).toBe(false);
});
it('should not save a duplicate state', () => {
stack.save(10);
stack.save(11);
stack.save(11);
stack.save(10);
stack.save(10);
expect(stack.history).toEqual([10, 11, 10]);
});
it('uses the === operator to detect duplicates', () => {
stack.save(10);
stack.save(10);
expect(stack.history).toEqual([10]);
// eslint-disable-next-line eqeqeq
expect(2 == '2' && '2' == 2).toBe(true);
stack.clear();
stack.save(2);
stack.save(2);
stack.save('2');
stack.save('2');
stack.save(2);
expect(stack.history).toEqual([2, '2', 2]);
const obj = {};
stack.clear();
stack.save(obj);
stack.save(obj);
stack.save({});
stack.save({});
expect(stack.history).toEqual([{}, {}, {}]);
});
it('should allow custom comparators', () => {
stack.comparator = isEqual;
const obj = {};
stack.clear();
stack.save(obj);
stack.save(obj);
stack.save({});
stack.save({});
expect(stack.history).toEqual([{}]);
});
it('should enforce a max number of undo states', () => {
// Try 2000 saves. Only the last 1000 should be preserved.
const sequence = Array(2000)
.fill(0)
.map((el, i) => i);
sequence.forEach(stack.save.bind(stack));
expect(stack.history.length).toBe(1000);
expect(stack.history).toEqual(sequence.slice(1000));
expect(stack.current()).toBe(1999);
expect(stack.canUndo()).toBe(true);
expect(stack.canRedo()).toBe(false);
// Saving drops the oldest elements from the stack
stack.save('end');
expect(stack.history.length).toBe(1000);
expect(stack.current()).toBe('end');
expect(stack.history).toEqual([...sequence.slice(1001), 'end']);
// If states were undone but the history is full, can still add.
stack.undo();
stack.undo();
expect(stack.current()).toBe(1998);
stack.save(3000);
expect(stack.history.length).toBe(999);
// should be [1001, 1002, ..., 1998, 3000]
expect(stack.history).toEqual([...sequence.slice(1001, 1999), 3000]);
// Try a different max length
stack = new UndoStack(2);
stack.save(0);
expect(stack.history).toEqual([0]);
stack.save(1);
expect(stack.history).toEqual([0, 1]);
stack.save(2);
expect(stack.history).toEqual([1, 2]);
});
describe('scheduled saves', () => {
it('should work', () => {
// Schedules 1000 ms ahead by default
stack.save(0);
stack.scheduleSave(1);
expect(stack.history).toEqual([0]);
jest.advanceTimersByTime(999);
expect(stack.history).toEqual([0]);
jest.advanceTimersByTime(1);
expect(stack.history).toEqual([0, 1]);
});
it('should have an adjustable delay', () => {
stack.scheduleSave(2, 100);
jest.advanceTimersByTime(100);
expect(stack.history).toEqual([2]);
});
it('should cancel previous scheduled saves', () => {
stack.scheduleSave(3);
jest.advanceTimersByTime(100);
stack.scheduleSave(4);
jest.runAllTimers();
expect(stack.history).toEqual([4]);
});
it('should be canceled by explicit saves', () => {
stack.scheduleSave(5);
stack.save(6);
jest.runAllTimers();
expect(stack.history).toEqual([6]);
});
it('should be canceled by undos and redos', () => {
stack.save(1);
stack.save(2);
stack.scheduleSave(3);
stack.undo();
jest.runAllTimers();
expect(stack.history).toEqual([1, 2]);
expect(stack.current()).toBe(1);
stack.scheduleSave(4);
stack.redo();
jest.runAllTimers();
expect(stack.history).toEqual([1, 2]);
expect(stack.current()).toBe(2);
});
it('should be persisted immediately with saveNow()', () => {
stack.scheduleSave(7);
stack.scheduleSave(8);
stack.saveNow();
jest.runAllTimers();
expect(stack.history).toEqual([8]);
});
});
});

View file

@ -8340,7 +8340,7 @@ monaco-editor@^0.15.6:
resolved "https://registry.yarnpkg.com/monaco-editor/-/monaco-editor-0.15.6.tgz#d63b3b06f86f803464f003b252627c3eb4a09483"
integrity sha512-JoU9V9k6KqT9R9Tiw1RTU8ohZ+Xnf9DMg6Ktqqw5hILumwmq7xqa/KLXw513uTUsWbhtnHoSJYYR++u3pkyxJg==
mousetrap@^1.4.6:
mousetrap@1.4.6:
version "1.4.6"
resolved "https://registry.yarnpkg.com/mousetrap/-/mousetrap-1.4.6.tgz#eaca72e22e56d5b769b7555873b688c3332e390a"
integrity sha1-6spy4i5W1bdpt1VYc7aIwzMuOQo=