summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorMartin Hanzel <mhanzel@gitlab.com>2019-07-26 07:18:15 +0000
committerKushal Pandya <kushalspandya@gmail.com>2019-07-26 07:18:15 +0000
commitc111d121d6faedfe3f38af1780e16ab056048e30 (patch)
treed7ae3d46966cfa5a499bb3a8532334bad11c0b94
parent96ae5bd83da31350e9856a290127d7aa1469710a (diff)
downloadgitlab-ce-c111d121d6faedfe3f38af1780e16ab056048e30.tar.gz
Add UndoStack class - a custom undo/redo engine
It will be hooked up to the markdown editor later
-rw-r--r--app/assets/javascripts/commons/polyfills.js1
-rw-r--r--app/assets/javascripts/gl_form.js87
-rw-r--r--app/assets/javascripts/helpers/indent_helper.js182
-rw-r--r--app/assets/javascripts/lib/utils/common_utils.js65
-rw-r--r--app/assets/javascripts/lib/utils/keycodes.js10
-rw-r--r--app/assets/javascripts/lib/utils/undo_stack.js105
-rw-r--r--app/assets/javascripts/vue_shared/components/markdown/toolbar.vue41
-rw-r--r--app/views/shared/notes/_hints.html.haml11
-rw-r--r--changelogs/unreleased/mh-editor-indents.yml5
-rw-r--r--locale/gitlab.pot15
-rw-r--r--package.json2
-rw-r--r--spec/features/projects/wiki/user_creates_wiki_page_spec.rb12
-rw-r--r--spec/frontend/helpers/indent_helper_spec.js371
-rw-r--r--spec/frontend/lib/utils/common_utils_spec.js180
-rw-r--r--spec/frontend/lib/utils/undo_stack_spec.js237
-rw-r--r--yarn.lock2
16 files changed, 1287 insertions, 39 deletions
diff --git a/app/assets/javascripts/commons/polyfills.js b/app/assets/javascripts/commons/polyfills.js
index 7a6ad3dc771..daa941a63cd 100644
--- a/app/assets/javascripts/commons/polyfills.js
+++ b/app/assets/javascripts/commons/polyfills.js
@@ -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';
diff --git a/app/assets/javascripts/gl_form.js b/app/assets/javascripts/gl_form.js
index a66555838ba..b98fe9f6ce2 100644
--- a/app/assets/javascripts/gl_form.js
+++ b/app/assets/javascripts/gl_form.js
@@ -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));
}
}
diff --git a/app/assets/javascripts/helpers/indent_helper.js b/app/assets/javascripts/helpers/indent_helper.js
new file mode 100644
index 00000000000..a8815fac04e
--- /dev/null
+++ b/app/assets/javascripts/helpers/indent_helper.js
@@ -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');
+ }
+ }
+ }
+}
diff --git a/app/assets/javascripts/lib/utils/common_utils.js b/app/assets/javascripts/lib/utils/common_utils.js
index 5e90893b684..1a94aee2398 100644
--- a/app/assets/javascripts/lib/utils/common_utils.js
+++ b/app/assets/javascripts/lib/utils/common_utils.js
@@ -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;
diff --git a/app/assets/javascripts/lib/utils/keycodes.js b/app/assets/javascripts/lib/utils/keycodes.js
index 5e0f9b612a2..e24fcf47d71 100644
--- a/app/assets/javascripts/lib/utils/keycodes.js
+++ b/app/assets/javascripts/lib/utils/keycodes.js
@@ -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;
diff --git a/app/assets/javascripts/lib/utils/undo_stack.js b/app/assets/javascripts/lib/utils/undo_stack.js
new file mode 100644
index 00000000000..6cfdc2a0a0f
--- /dev/null
+++ b/app/assets/javascripts/lib/utils/undo_stack.js
@@ -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];
+ }
+}
diff --git a/app/assets/javascripts/vue_shared/components/markdown/toolbar.vue b/app/assets/javascripts/vue_shared/components/markdown/toolbar.vue
index 8ce5b615795..21c44b59520 100644
--- a/app/assets/javascripts/vue_shared/components/markdown/toolbar.vue
+++ b/app/assets/javascripts/vue_shared/components/markdown/toolbar.vue
@@ -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">
diff --git a/app/views/shared/notes/_hints.html.haml b/app/views/shared/notes/_hints.html.haml
index fae7d6526e8..72ede50dd8c 100644
--- a/app/views/shared/notes/_hints.html.haml
+++ b/app/views/shared/notes/_hints.html.haml
@@ -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
diff --git a/changelogs/unreleased/mh-editor-indents.yml b/changelogs/unreleased/mh-editor-indents.yml
new file mode 100644
index 00000000000..a282c0f505d
--- /dev/null
+++ b/changelogs/unreleased/mh-editor-indents.yml
@@ -0,0 +1,5 @@
+---
+title: Markdown editors now have indentation shortcuts and auto-indentation
+merge_request: 28914
+author:
+type: added
diff --git a/locale/gitlab.pot b/locale/gitlab.pot
index ed71a4b42d9..114d245b688 100644
--- a/locale/gitlab.pot
+++ b/locale/gitlab.pot
@@ -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 ""
diff --git a/package.json b/package.json
index 4264064c93d..dce20505fb0 100644
--- a/package.json
+++ b/package.json
@@ -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",
diff --git a/spec/features/projects/wiki/user_creates_wiki_page_spec.rb b/spec/features/projects/wiki/user_creates_wiki_page_spec.rb
index aac095bfa6b..80741ace5d6 100644
--- a/spec/features/projects/wiki/user_creates_wiki_page_spec.rb
+++ b/spec/features/projects/wiki/user_creates_wiki_page_spec.rb
@@ -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")
diff --git a/spec/frontend/helpers/indent_helper_spec.js b/spec/frontend/helpers/indent_helper_spec.js
new file mode 100644
index 00000000000..fca12f0d1ef
--- /dev/null
+++ b/spec/frontend/helpers/indent_helper_spec.js
@@ -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]);
+ }
+ }
+ });
+ });
+ });
+});
diff --git a/spec/frontend/lib/utils/common_utils_spec.js b/spec/frontend/lib/utils/common_utils_spec.js
new file mode 100644
index 00000000000..e3d3b82d2f3
--- /dev/null
+++ b/spec/frontend/lib/utils/common_utils_spec.js
@@ -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);
+ });
+ });
+});
diff --git a/spec/frontend/lib/utils/undo_stack_spec.js b/spec/frontend/lib/utils/undo_stack_spec.js
new file mode 100644
index 00000000000..31ad0e77d6f
--- /dev/null
+++ b/spec/frontend/lib/utils/undo_stack_spec.js
@@ -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]);
+ });
+ });
+});
diff --git a/yarn.lock b/yarn.lock
index 4fa7665b000..0936e14c0a5 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -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=