summaryrefslogtreecommitdiff
path: root/app/assets/javascripts/lib
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 /app/assets/javascripts/lib
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
Diffstat (limited to 'app/assets/javascripts/lib')
-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
3 files changed, 178 insertions, 2 deletions
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];
+ }
+}