diff options
author | Martin Hanzel <mhanzel@gitlab.com> | 2019-07-26 07:18:15 +0000 |
---|---|---|
committer | Kushal Pandya <kushalspandya@gmail.com> | 2019-07-26 07:18:15 +0000 |
commit | c111d121d6faedfe3f38af1780e16ab056048e30 (patch) | |
tree | d7ae3d46966cfa5a499bb3a8532334bad11c0b94 /app/assets/javascripts/lib | |
parent | 96ae5bd83da31350e9856a290127d7aa1469710a (diff) | |
download | gitlab-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.js | 65 | ||||
-rw-r--r-- | app/assets/javascripts/lib/utils/keycodes.js | 10 | ||||
-rw-r--r-- | app/assets/javascripts/lib/utils/undo_stack.js | 105 |
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 '⌘'; + } + // 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]; + } +} |