Commit c111d121 authored by Martin Hanzel's avatar Martin Hanzel Committed by Kushal Pandya

Add UndoStack class - a custom undo/redo engine

It will be hooked up to the markdown editor later
parent 96ae5bd8
......@@ -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';
......
......@@ -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));
}
}
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');
}
}
}
}
......@@ -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;
......
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;
/**
* 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];
}
}
<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">
......
- 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
......
---
title: Markdown editors now have indentation shortcuts and auto-indentation
merge_request: 28914
author:
type: added
......@@ -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 ""
......
......@@ -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")
......
This diff is collapsed.
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);
});
});
});
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]);
});
});
});
......@@ -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=
......
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment