diff options
author | Michael Kozono <mkozono@gmail.com> | 2019-08-01 20:44:38 +0000 |
---|---|---|
committer | Michael Kozono <mkozono@gmail.com> | 2019-08-01 20:44:38 +0000 |
commit | 52b857f119debb5a03c216c4199eb21a49d815b6 (patch) | |
tree | e024d7638e8683c1902bf4b220d44fcdd57fe807 | |
parent | b2dd581be375e4f21af9d2c487528ffd06508618 (diff) | |
parent | 84b6c7a5f3bf3d6f96331d73225903d3fd92b4e2 (diff) | |
download | gitlab-ce-52b857f119debb5a03c216c4199eb21a49d815b6.tar.gz |
Merge branch 'revert-editor-indents' into 'master'
Revert "Merge branch 'mh/editor-indents' into 'master'"
See merge request gitlab-org/gitlab-ce!31391
-rw-r--r-- | app/assets/javascripts/commons/polyfills.js | 1 | ||||
-rw-r--r-- | app/assets/javascripts/gl_form.js | 87 | ||||
-rw-r--r-- | app/assets/javascripts/helpers/indent_helper.js | 182 | ||||
-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 | ||||
-rw-r--r-- | app/assets/javascripts/vue_shared/components/markdown/toolbar.vue | 41 | ||||
-rw-r--r-- | app/views/shared/notes/_hints.html.haml | 11 | ||||
-rw-r--r-- | changelogs/unreleased/mh-editor-indents.yml | 5 | ||||
-rw-r--r-- | locale/gitlab.pot | 15 | ||||
-rw-r--r-- | package.json | 2 | ||||
-rw-r--r-- | spec/features/projects/wiki/user_creates_wiki_page_spec.rb | 12 | ||||
-rw-r--r-- | spec/frontend/helpers/indent_helper_spec.js | 371 | ||||
-rw-r--r-- | spec/frontend/lib/utils/common_utils_spec.js | 180 | ||||
-rw-r--r-- | spec/frontend/lib/utils/undo_stack_spec.js | 237 | ||||
-rw-r--r-- | yarn.lock | 2 |
16 files changed, 39 insertions, 1287 deletions
diff --git a/app/assets/javascripts/commons/polyfills.js b/app/assets/javascripts/commons/polyfills.js index daa941a63cd..7a6ad3dc771 100644 --- a/app/assets/javascripts/commons/polyfills.js +++ b/app/assets/javascripts/commons/polyfills.js @@ -12,7 +12,6 @@ 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 b98fe9f6ce2..a66555838ba 100644 --- a/app/assets/javascripts/gl_form.js +++ b/app/assets/javascripts/gl_form.js @@ -3,16 +3,9 @@ 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); @@ -23,10 +16,6 @@ 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 @@ -96,84 +85,9 @@ 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) @@ -185,6 +99,5 @@ 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 deleted file mode 100644 index a8815fac04e..00000000000 --- a/app/assets/javascripts/helpers/indent_helper.js +++ /dev/null @@ -1,182 +0,0 @@ -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 1a94aee2398..5e90893b684 100644 --- a/app/assets/javascripts/lib/utils/common_utils.js +++ b/app/assets/javascripts/lib/utils/common_utils.js @@ -203,71 +203,6 @@ 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 e24fcf47d71..5e0f9b612a2 100644 --- a/app/assets/javascripts/lib/utils/keycodes.js +++ b/app/assets/javascripts/lib/utils/keycodes.js @@ -1,10 +1,4 @@ -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; +export const ENTER_KEY_CODE = 13; +export const ESC_KEY_CODE = 27; diff --git a/app/assets/javascripts/lib/utils/undo_stack.js b/app/assets/javascripts/lib/utils/undo_stack.js deleted file mode 100644 index 6cfdc2a0a0f..00000000000 --- a/app/assets/javascripts/lib/utils/undo_stack.js +++ /dev/null @@ -1,105 +0,0 @@ -/** - * 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 21c44b59520..8ce5b615795 100644 --- a/app/assets/javascripts/vue_shared/components/markdown/toolbar.vue +++ b/app/assets/javascripts/vue_shared/components/markdown/toolbar.vue @@ -1,6 +1,5 @@ <script> import { GlLink } from '@gitlab/ui'; -import { s__, sprintf } from '~/locale'; export default { components: { @@ -23,28 +22,8 @@ export default { }, }, computed: { - 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; + hasQuickActionsDocsPath() { + return this.quickActionsDocsPath !== ''; }, }, }; @@ -53,7 +32,21 @@ export default { <template> <div class="comment-toolbar clearfix"> <div class="toolbar-text"> - <span v-html="toolbarHelpHtml"></span> + <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> </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 72ede50dd8c..fae7d6526e8 100644 --- a/app/views/shared/notes/_hints.html.haml +++ b/app/views/shared/notes/_hints.html.haml @@ -1,13 +1,14 @@ - supports_quick_actions = local_assigns.fetch(:supports_quick_actions, false) .comment-toolbar.clearfix .toolbar-text - - 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 + = link_to _('Markdown'), help_page_path('user/markdown'), target: '_blank', tabindex: -1 - if supports_quick_actions - = 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 } + and + = link_to _('quick actions'), help_page_path('user/project/quick_actions'), target: '_blank', tabindex: -1 + are - else - = s_('Editor|%{mdLinkStart}Markdown is supported%{mdLinkEnd}').html_safe % { mdLinkStart: md_link_start, mdLinkEnd: link_end } + is + supported %span.uploading-container %span.uploading-progress-container.hide diff --git a/changelogs/unreleased/mh-editor-indents.yml b/changelogs/unreleased/mh-editor-indents.yml deleted file mode 100644 index a282c0f505d..00000000000 --- a/changelogs/unreleased/mh-editor-indents.yml +++ /dev/null @@ -1,5 +0,0 @@ ---- -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 4921b3c835b..5e9e371a5fc 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -4052,12 +4052,6 @@ 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 "" @@ -6508,12 +6502,18 @@ 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 "Marked this %{noun} as Work In Progress." msgstr "" @@ -13566,6 +13566,9 @@ msgstr "" msgid "project avatar" msgstr "" +msgid "quick actions" +msgstr "" + msgid "register" msgstr "" diff --git a/package.json b/package.json index c97046b0e84..056f7616cde 100644 --- a/package.json +++ b/package.json @@ -100,7 +100,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 1080976f7ce..cc6dbaa6eb8 100644 --- a/spec/features/projects/wiki/user_creates_wiki_page_spec.rb +++ b/spec/features/projects/wiki/user_creates_wiki_page_spec.rb @@ -134,15 +134,9 @@ describe "User creates wiki page" do fill_in(:wiki_content, with: ascii_content) - # 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('.rspec-create-page-button').click()") + page.within(".wiki-form") do + click_button("Create page") + end 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 deleted file mode 100644 index fca12f0d1ef..00000000000 --- a/spec/frontend/helpers/indent_helper_spec.js +++ /dev/null @@ -1,371 +0,0 @@ -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 deleted file mode 100644 index e3d3b82d2f3..00000000000 --- a/spec/frontend/lib/utils/common_utils_spec.js +++ /dev/null @@ -1,180 +0,0 @@ -import * as cu from '~/lib/utils/common_utils'; - -const CMD_ENTITY = '⌘'; - -// 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 deleted file mode 100644 index 31ad0e77d6f..00000000000 --- a/spec/frontend/lib/utils/undo_stack_spec.js +++ /dev/null @@ -1,237 +0,0 @@ -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 6643b011dab..22145f5a5af 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8371,7 +8371,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= |