From c111d121d6faedfe3f38af1780e16ab056048e30 Mon Sep 17 00:00:00 2001 From: Martin Hanzel Date: Fri, 26 Jul 2019 07:18:15 +0000 Subject: Add UndoStack class - a custom undo/redo engine It will be hooked up to the markdown editor later --- app/assets/javascripts/commons/polyfills.js | 1 + app/assets/javascripts/gl_form.js | 87 +++++ app/assets/javascripts/helpers/indent_helper.js | 182 ++++++++++ app/assets/javascripts/lib/utils/common_utils.js | 65 ++++ app/assets/javascripts/lib/utils/keycodes.js | 10 +- app/assets/javascripts/lib/utils/undo_stack.js | 105 ++++++ .../vue_shared/components/markdown/toolbar.vue | 41 ++- app/views/shared/notes/_hints.html.haml | 11 +- changelogs/unreleased/mh-editor-indents.yml | 5 + locale/gitlab.pot | 15 +- package.json | 2 +- .../projects/wiki/user_creates_wiki_page_spec.rb | 12 +- spec/frontend/helpers/indent_helper_spec.js | 371 +++++++++++++++++++++ spec/frontend/lib/utils/common_utils_spec.js | 180 ++++++++++ spec/frontend/lib/utils/undo_stack_spec.js | 237 +++++++++++++ yarn.lock | 2 +- 16 files changed, 1287 insertions(+), 39 deletions(-) create mode 100644 app/assets/javascripts/helpers/indent_helper.js create mode 100644 app/assets/javascripts/lib/utils/undo_stack.js create mode 100644 changelogs/unreleased/mh-editor-indents.yml create mode 100644 spec/frontend/helpers/indent_helper_spec.js create mode 100644 spec/frontend/lib/utils/common_utils_spec.js create mode 100644 spec/frontend/lib/utils/undo_stack_spec.js 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 '⌘'; + } + // 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 @@