Commit 88531a6a authored by Mike Greiling's avatar Mike Greiling

Merge branch 'nfriend-add-markdown-editor-shortcuts' into 'master'

Add Markdown editor keyboard shortcuts

See merge request gitlab-org/gitlab!40328
parents ecf465ce 99bd6659
......@@ -2,6 +2,7 @@ import $ from 'jquery';
import Cookies from 'js-cookie';
import Mousetrap from 'mousetrap';
import Vue from 'vue';
import { flatten } from 'lodash';
import { disableShortcuts, shouldDisableShortcuts } from './shortcuts_toggle';
import ShortcutsToggle from './shortcuts_toggle.vue';
import axios from '../../lib/utils/axios_utils';
......@@ -27,6 +28,39 @@ function initToggleButton() {
});
}
/**
* The key used to save and fetch the local Mousetrap instance
* attached to a `<textarea>` element using `jQuery.data`
*/
const LOCAL_MOUSETRAP_DATA_KEY = 'local-mousetrap-instance';
/**
* Gets a mapping of toolbar button => keyboard shortcuts
* associated to the given markdown editor `<textarea>` element
*
* @param {HTMLTextAreaElement} $textarea The jQuery-wrapped `<textarea>`
* element to extract keyboard shortcuts from
*
* @returns A Map with keys that are jQuery-wrapped toolbar buttons
* (i.e. `$toolbarBtn`) and values that are arrays of string
* keyboard shortcuts (e.g. `['command+k', 'ctrl+k]`).
*/
function getToolbarBtnToShortcutsMap($textarea) {
const $allToolbarBtns = $textarea.closest('.md-area').find('.js-md');
const map = new Map();
$allToolbarBtns.each(function attachToolbarBtnHandler() {
const $toolbarBtn = $(this);
const keyboardShortcuts = $toolbarBtn.data('md-shortcuts');
if (keyboardShortcuts?.length) {
map.set($toolbarBtn, keyboardShortcuts);
}
});
return map;
}
export default class Shortcuts {
constructor() {
this.onToggleHelp = this.onToggleHelp.bind(this);
......@@ -144,4 +178,62 @@ export default class Shortcuts {
e.preventDefault();
}
}
/**
* Initializes markdown editor shortcuts on the provided `<textarea>` element
*
* @param {JQuery} $textarea The jQuery-wrapped `<textarea>` element
* where markdown shortcuts should be enabled
* @param {Function} handler The handler to call when a
* keyboard shortcut is pressed inside the markdown `<textarea>`
*/
static initMarkdownEditorShortcuts($textarea, handler) {
const toolbarBtnToShortcutsMap = getToolbarBtnToShortcutsMap($textarea);
const localMousetrap = new Mousetrap($textarea[0]);
// Save a reference to the local mousetrap instance on the <textarea>
// so that it can be retrieved when unbinding shortcut handlers
$textarea.data(LOCAL_MOUSETRAP_DATA_KEY, localMousetrap);
toolbarBtnToShortcutsMap.forEach((keyboardShortcuts, $toolbarBtn) => {
localMousetrap.bind(keyboardShortcuts, e => {
e.preventDefault();
handler($toolbarBtn);
});
});
// Get an array of all shortcut strings that have been added above
const allShortcuts = flatten([...toolbarBtnToShortcutsMap.values()]);
const originalStopCallback = Mousetrap.prototype.stopCallback;
localMousetrap.stopCallback = function newStopCallback(e, element, combo) {
if (allShortcuts.includes(combo)) {
return false;
}
return originalStopCallback.call(this, e, element, combo);
};
}
/**
* Removes markdown editor shortcut handlers originally attached
* with `initMarkdownEditorShortcuts`.
*
* Note: it is safe to call this function even if `initMarkdownEditorShortcuts`
* has _not_ yet been called on the given `<textarea>`.
*
* @param {JQuery} $textarea The jQuery-wrapped `<textarea>`
* to remove shortcut handlers from
*/
static removeMarkdownEditorShortcuts($textarea) {
const localMousetrap = $textarea.data(LOCAL_MOUSETRAP_DATA_KEY);
if (localMousetrap) {
getToolbarBtnToShortcutsMap($textarea).forEach(keyboardShortcuts => {
localMousetrap.unbind(keyboardShortcuts);
});
}
}
}
/* eslint-disable func-names, no-param-reassign, operator-assignment, consistent-return */
import $ from 'jquery';
import { insertText } from '~/lib/utils/common_utils';
import Shortcuts from '~/behaviors/shortcuts/shortcuts';
const LINK_TAG_PATTERN = '[{text}](url)';
......@@ -336,24 +337,34 @@ export function keypressNoteText(e) {
}
/* eslint-enable @gitlab/require-i18n-strings */
export function updateTextForToolbarBtn($toolbarBtn) {
return updateText({
textArea: $toolbarBtn.closest('.md-area').find('textarea'),
tag: $toolbarBtn.data('mdTag'),
cursorOffset: $toolbarBtn.data('mdCursorOffset'),
blockTag: $toolbarBtn.data('mdBlock'),
wrap: !$toolbarBtn.data('mdPrepend'),
select: $toolbarBtn.data('mdSelect'),
tagContent: $toolbarBtn.data('mdTagContent'),
});
}
export function addMarkdownListeners(form) {
$('.markdown-area', form).on('keydown', keypressNoteText);
return $('.js-md', form)
$('.markdown-area', form)
.on('keydown', keypressNoteText)
.each(function attachTextareaShortcutHandlers() {
Shortcuts.initMarkdownEditorShortcuts($(this), updateTextForToolbarBtn);
});
const $allToolbarBtns = $('.js-md', form)
.off('click')
.on('click', function() {
const $this = $(this);
const tag = this.dataset.mdTag;
return updateText({
textArea: $this.closest('.md-area').find('textarea'),
tag,
cursorOffset: $this.data('mdCursorOffset'),
blockTag: $this.data('mdBlock'),
wrap: !$this.data('mdPrepend'),
select: $this.data('mdSelect'),
tagContent: $this.data('mdTagContent'),
});
const $toolbarBtn = $(this);
return updateTextForToolbarBtn($toolbarBtn);
});
return $allToolbarBtns;
}
export function addEditorMarkdownListeners(editor) {
......@@ -376,6 +387,11 @@ export function addEditorMarkdownListeners(editor) {
}
export function removeMarkdownListeners(form) {
$('.markdown-area', form).off('keydown', keypressNoteText);
$('.markdown-area', form)
.off('keydown', keypressNoteText)
.each(function removeTextareaShortcutHandlers() {
Shortcuts.removeMarkdownEditorShortcuts($(this));
});
return $('.js-md', form).off('click');
}
<script>
import $ from 'jquery';
import { GlPopover, GlButton, GlTooltipDirective, GlIcon } from '@gitlab/ui';
import { s__ } from '~/locale';
import { getSelectedFragment } from '~/lib/utils/common_utils';
import { CopyAsGFM } from '../../../behaviors/markdown/copy_as_gfm';
import ToolbarButton from './toolbar_button.vue';
......@@ -54,6 +55,15 @@ export default {
mdSuggestion() {
return ['```suggestion:-0+0', `{text}`, '```'].join('\n');
},
isMac() {
// Accessing properties using ?. to allow tests to use
// this component without setting up window.gl.client.
// In production, window.gl.client should always be present.
return Boolean(window.gl?.client?.isMac);
},
modifierKey() {
return this.isMac ? '' : s__('KeyboardKey|Ctrl+');
},
},
mounted() {
$(document).on('markdown-preview:show.vue', this.previewMarkdownTab);
......@@ -128,8 +138,22 @@ export default {
</li>
<li :class="{ active: !previewMarkdown }" class="md-header-toolbar">
<div class="d-inline-block">
<toolbar-button tag="**" :button-title="__('Add bold text')" icon="bold" />
<toolbar-button tag="_" :button-title="__('Add italic text')" icon="italic" />
<toolbar-button
tag="**"
:button-title="
sprintf(s__('MarkdownEditor|Add bold text (%{modifierKey}B)'), { modifierKey })
"
:shortcuts="['command+b', 'ctrl+b']"
icon="bold"
/>
<toolbar-button
tag="_"
:button-title="
sprintf(s__('MarkdownEditor|Add italic text (%{modifierKey}I)'), { modifierKey })
"
:shortcuts="['command+i', 'ctrl+i']"
icon="italic"
/>
<toolbar-button
:prepend="true"
:tag="tag"
......@@ -180,7 +204,10 @@ export default {
<toolbar-button
tag="[{text}](url)"
tag-select="url"
:button-title="__('Add a link')"
:button-title="
sprintf(s__('MarkdownEditor|Add a link (%{modifierKey}K)'), { modifierKey })
"
:shortcuts="['command+k', 'ctrl+k']"
icon="link"
/>
</div>
......
......@@ -46,6 +46,26 @@ export default {
required: false,
default: 0,
},
/**
* A string (or an array of strings) of
* [mousetrap](https://craig.is/killing/mice) keyboard shortcuts
* that should be attached to this button. For example:
* "command+k"
* ...or...
* ["command+k", "ctrl+k"]
*/
shortcuts: {
type: [String, Array],
required: false,
default: () => [],
},
},
computed: {
shortcutsString() {
const shortcutArray = Array.isArray(this.shortcuts) ? this.shortcuts : [this.shortcuts];
return JSON.stringify(shortcutArray);
},
},
};
</script>
......@@ -59,6 +79,7 @@ export default {
:data-md-block="tagBlock"
:data-md-tag-content="tagContent"
:data-md-prepend="prepend"
:data-md-shortcuts="shortcutsString"
:title="buttonTitle"
:aria-label="buttonTitle"
type="button"
......
- modifier_key = client_js_flags[:isMac] ? '⌘' : s_('KeyboardKey|Ctrl+');
.md-header-toolbar.active
= markdown_toolbar_button({ icon: "bold", data: { "md-tag" => "**" }, title: _("Add bold text") })
= markdown_toolbar_button({ icon: "italic", data: { "md-tag" => "_" }, title: _("Add italic text") })
= markdown_toolbar_button({ icon: "bold",
data: { "md-tag" => "**", "md-shortcuts": '["command+b","ctrl+b"]' },
title: sprintf(s_("MarkdownEditor|Add bold text (%{modifier_key}B)") % { modifier_key: modifier_key }) })
= markdown_toolbar_button({ icon: "italic",
data: { "md-tag" => "_", "md-shortcuts": '["command+i","ctrl+i"]' },
title: sprintf(s_("MarkdownEditor|Add italic text (%{modifier_key}I)") % { modifier_key: modifier_key }) })
= markdown_toolbar_button({ icon: "quote", data: { "md-tag" => "> ", "md-prepend" => true }, title: _("Insert a quote") })
= markdown_toolbar_button({ icon: "code", data: { "md-tag" => "`", "md-block" => "```" }, title: _("Insert code") })
= markdown_toolbar_button({ icon: "link", data: { "md-tag" => "[{text}](url)", "md-select" => "url" }, title: _("Add a link") })
= markdown_toolbar_button({ icon: "link",
data: { "md-tag" => "[{text}](url)", "md-select" => "url", "md-shortcuts": '["command+k","ctrl+k"]' },
title: sprintf(s_("MarkdownEditor|Add a link (%{modifier_key}K)") % { modifier_key: modifier_key }) })
= markdown_toolbar_button({ icon: "list-bulleted", data: { "md-tag" => "- ", "md-prepend" => true }, title: _("Add a bullet list") })
= markdown_toolbar_button({ icon: "list-numbered", data: { "md-tag" => "1. ", "md-prepend" => true }, title: _("Add a numbered list") })
= markdown_toolbar_button({ icon: "list-task", data: { "md-tag" => "- [ ] ", "md-prepend" => true }, title: _("Add a task list") })
......
---
title: Add keyboard shortcuts for bold, italic, and link in markdown editors
merge_request: 40328
author:
type: added
......@@ -40,6 +40,13 @@ for example comments, replies, issue descriptions, and merge request description
| ---------------------------------------------------------------------- | ----------- |
| <kbd></kbd> | Edit your last comment. You must be in a blank text field below a thread, and you must already have at least one comment in the thread. |
| <kbd></kbd> (Mac) / <kbd>Ctrl</kbd> + <kbd>Shift</kbd> + <kbd>p</kbd> | Toggle Markdown preview, when editing text in a text field that has **Write** and **Preview** tabs at the top. |
| <kbd></kbd> (Mac) / <kbd>Ctrl</kbd> + <kbd>b</kbd> | Bold the selected text (surround it with `**`). |
| <kbd></kbd> (Mac) / <kbd>Ctrl</kbd> + <kbd>i</kbd> | Italicize the selected text (surround it with `_`). |
| <kbd></kbd> (Mac) / <kbd>Ctrl</kbd> + <kbd>k</kbd> | Add a link (surround the selected text with `[]()`). |
NOTE: **Note:**
The shortcuts for editing in text fields are always enabled, even when
other keyboard shortcuts are disabled as explained above.
## Project
......
......@@ -14277,6 +14277,9 @@ msgstr ""
msgid "Keyboard shortcuts"
msgstr ""
msgid "KeyboardKey|Ctrl+"
msgstr ""
msgid "Keys"
msgstr ""
......@@ -15169,6 +15172,24 @@ msgstr ""
msgid "Markdown is supported"
msgstr ""
msgid "MarkdownEditor|Add a link (%{modifierKey}K)"
msgstr ""
msgid "MarkdownEditor|Add a link (%{modifier_key}K)"
msgstr ""
msgid "MarkdownEditor|Add bold text (%{modifierKey}B)"
msgstr ""
msgid "MarkdownEditor|Add bold text (%{modifier_key}B)"
msgstr ""
msgid "MarkdownEditor|Add italic text (%{modifierKey}I)"
msgstr ""
msgid "MarkdownEditor|Add italic text (%{modifier_key}I)"
msgstr ""
msgid "Marked For Deletion At - %{deletion_time}"
msgstr ""
......
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe 'Markdown keyboard shortcuts', :js do
let_it_be(:project) { create(:project, :repository) }
let_it_be(:user) { create(:user) }
before do
project.add_developer(user)
gitlab_sign_in(user)
visit path_to_visit
wait_for_requests
end
shared_examples 'keyboard shortcuts for modifier key' do
it 'bolds text when <modifier>+B is pressed' do
type_and_select('bold')
markdown_field.send_keys([modifier_key, 'b'])
expect(markdown_field.value).to eq('**bold**')
end
it 'italicizes text when <modifier>+I is pressed' do
type_and_select('italic')
markdown_field.send_keys([modifier_key, 'i'])
expect(markdown_field.value).to eq('_italic_')
end
it 'links text when <modifier>+K is pressed' do
type_and_select('link')
markdown_field.send_keys([modifier_key, 'k'])
expect(markdown_field.value).to eq('[link](url)')
# Type some more text to ensure the cursor
# and selection are set correctly
markdown_field.send_keys('https://example.com')
expect(markdown_field.value).to eq('[link](https://example.com)')
end
it 'does not affect non-markdown fields on the same page' do
non_markdown_field.send_keys('some text')
non_markdown_field.send_keys([modifier_key, 'b'])
expect(focused_element).to eq(non_markdown_field.native)
expect(markdown_field.value).to eq('')
end
end
shared_examples 'keyboard shortcuts for implementation' do
context 'Ctrl key' do
let(:modifier_key) { :control }
it_behaves_like 'keyboard shortcuts for modifier key'
end
context '⌘ key' do
let(:modifier_key) { :command }
it_behaves_like 'keyboard shortcuts for modifier key'
end
end
context 'Vue.js markdown editor' do
let(:path_to_visit) { new_project_release_path(project) }
let(:markdown_field) { find_field('Release notes') }
let(:non_markdown_field) { find_field('Release title') }
it_behaves_like 'keyboard shortcuts for implementation'
end
context 'Haml markdown editor' do
let(:path_to_visit) { new_project_issue_path(project) }
let(:markdown_field) { find_field('Description') }
let(:non_markdown_field) { find_field('Title') }
it_behaves_like 'keyboard shortcuts for implementation'
end
def type_and_select(text)
markdown_field.send_keys(text)
text.length.times do
markdown_field.send_keys([:shift, :arrow_left])
end
end
def focused_element
page.driver.browser.switch_to.active_element
end
end
......@@ -22,10 +22,6 @@ import mockResponseNoDesigns from '../../mock_data/no_designs';
import mockAllVersions from '../../mock_data/all_versions';
jest.mock('~/flash');
jest.mock('mousetrap', () => ({
bind: jest.fn(),
unbind: jest.fn(),
}));
const focusInput = jest.fn();
......
......@@ -35,11 +35,6 @@ function factory(routeArg) {
});
}
jest.mock('mousetrap', () => ({
bind: jest.fn(),
unbind: jest.fn(),
}));
describe('Design management router', () => {
afterEach(() => {
window.location.hash = '';
......
import $ from 'jquery';
import { flatten } from 'lodash';
import Shortcuts from '~/behaviors/shortcuts/shortcuts';
const mockMousetrap = {
bind: jest.fn(),
unbind: jest.fn(),
};
jest.mock('mousetrap', () => {
return jest.fn().mockImplementation(() => mockMousetrap);
});
jest.mock('mousetrap/plugins/pause/mousetrap-pause', () => {});
describe('Shortcuts', () => {
const fixtureName = 'snippets/show.html';
const createEvent = (type, target) =>
......@@ -10,16 +22,16 @@ describe('Shortcuts', () => {
preloadFixtures(fixtureName);
describe('toggleMarkdownPreview', () => {
beforeEach(() => {
loadFixtures(fixtureName);
beforeEach(() => {
loadFixtures(fixtureName);
jest.spyOn(document.querySelector('.js-new-note-form .js-md-preview-button'), 'focus');
jest.spyOn(document.querySelector('.edit-note .js-md-preview-button'), 'focus');
jest.spyOn(document.querySelector('.js-new-note-form .js-md-preview-button'), 'focus');
jest.spyOn(document.querySelector('.edit-note .js-md-preview-button'), 'focus');
new Shortcuts(); // eslint-disable-line no-new
});
new Shortcuts(); // eslint-disable-line no-new
});
describe('toggleMarkdownPreview', () => {
it('focuses preview button in form', () => {
Shortcuts.toggleMarkdownPreview(
createEvent('KeyboardEvent', document.querySelector('.js-new-note-form .js-note-text')),
......@@ -43,4 +55,63 @@ describe('Shortcuts', () => {
expect(document.querySelector('.edit-note .js-md-preview-button').focus).toHaveBeenCalled();
});
});
describe('markdown shortcuts', () => {
let shortcuts;
beforeEach(() => {
// Get all shortcuts specified with md-shortcuts attributes in the fixture.
// `shortcuts` will look something like this:
// [
// [ 'command+b', 'ctrl+b' ],
// [ 'command+i', 'ctrl+i' ],
// [ 'command+k', 'ctrl+k' ]
// ]
shortcuts = $('.edit-note .js-md')
.map(function getShortcutsFromToolbarBtn() {
const mdShortcuts = $(this).data('md-shortcuts');
// jQuery.map() automatically unwraps arrays, so we
// have to double wrap the array to counteract this:
// https://stackoverflow.com/a/4875669/1063392
return mdShortcuts ? [mdShortcuts] : undefined;
})
.get();
});
describe('initMarkdownEditorShortcuts', () => {
beforeEach(() => {
Shortcuts.initMarkdownEditorShortcuts($('.edit-note textarea'));
});
it('attaches a Mousetrap handler for every markdown shortcut specified with md-shortcuts', () => {
const expectedCalls = shortcuts.map(s => [s, expect.any(Function)]);
expect(mockMousetrap.bind.mock.calls).toEqual(expectedCalls);
});
it('attaches a stopCallback that allows each markdown shortcut specified with md-shortcuts', () => {
flatten(shortcuts).forEach(s => {
expect(mockMousetrap.stopCallback(null, null, s)).toBe(false);
});
});
});
describe('removeMarkdownEditorShortcuts', () => {
it('does nothing if initMarkdownEditorShortcuts was not previous called', () => {
Shortcuts.removeMarkdownEditorShortcuts($('.edit-note textarea'));
expect(mockMousetrap.unbind.mock.calls).toEqual([]);
});
it('removes Mousetrap handlers for every markdown shortcut specified with md-shortcuts', () => {
Shortcuts.initMarkdownEditorShortcuts($('.edit-note textarea'));
Shortcuts.removeMarkdownEditorShortcuts($('.edit-note textarea'));
const expectedCalls = shortcuts.map(s => [s]);
expect(mockMousetrap.unbind.mock.calls).toEqual(expectedCalls);
});
});
});
});
......@@ -22,6 +22,12 @@ describe('Markdown field header component', () => {
.at(0);
beforeEach(() => {
window.gl = {
client: {
isMac: true,
},
};
createWrapper();
});
......@@ -30,24 +36,40 @@ describe('Markdown field header component', () => {
wrapper = null;
});
it('renders markdown header buttons', () => {
const buttons = [
'Add bold text',
'Add italic text',
'Insert a quote',
'Insert suggestion',
'Insert code',
'Add a link',
'Add a bullet list',
'Add a numbered list',
'Add a task list',
'Add a table',
'Go full screen',
];
const elements = findToolbarButtons();
elements.wrappers.forEach((buttonEl, index) => {
expect(buttonEl.props('buttonTitle')).toBe(buttons[index]);
describe('markdown header buttons', () => {
it('renders the buttons with the correct title', () => {
const buttons = [
'Add bold text (⌘B)',
'Add italic text (⌘I)',
'Insert a quote',
'Insert suggestion',
'Insert code',
'Add a link (⌘K)',
'Add a bullet list',
'Add a numbered list',
'Add a task list',
'Add a table',
'Go full screen',
];
const elements = findToolbarButtons();
elements.wrappers.forEach((buttonEl, index) => {
expect(buttonEl.props('buttonTitle')).toBe(buttons[index]);
});
});
describe('when the user is on a non-Mac', () => {
beforeEach(() => {
delete window.gl.client.isMac;
createWrapper();
});
it('renders keyboard shortcuts with Ctrl+ instead of ⌘', () => {
const boldButton = findToolbarButtonByProp('icon', 'bold');
expect(boldButton.props('buttonTitle')).toBe('Add bold text (Ctrl+B)');
});
});
});
......
import { shallowMount } from '@vue/test-utils';
import ToolbarButton from '~/vue_shared/components/markdown/toolbar_button.vue';
describe('toolbar_button', () => {
let wrapper;
const defaultProps = {
buttonTitle: 'test button',
icon: 'rocket',
tag: 'test tag',
};
const createComponent = propUpdates => {
wrapper = shallowMount(ToolbarButton, {
propsData: {
...defaultProps,
...propUpdates,
},
});
};
afterEach(() => {
wrapper.destroy();
wrapper = null;
});
const getButtonShortcutsAttr = () => {
return wrapper.find('button').attributes('data-md-shortcuts');
};
describe('keyboard shortcuts', () => {
it.each`
shortcutsProp | mdShortcutsAttr
${undefined} | ${JSON.stringify([])}
${[]} | ${JSON.stringify([])}
${'command+b'} | ${JSON.stringify(['command+b'])}
${['command+b', 'ctrl+b']} | ${JSON.stringify(['command+b', 'ctrl+b'])}
`(
'adds the attribute data-md-shortcuts="$mdShortcutsAttr" to the button when the shortcuts prop is $shortcutsProp',
({ shortcutsProp, mdShortcutsAttr }) => {
createComponent({ shortcuts: shortcutsProp });
expect(getButtonShortcutsAttr()).toBe(mdShortcutsAttr);
},
);
});
});
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment