Commit 99bd6659 authored by Nathan Friend's avatar Nathan Friend Committed by Mike Greiling

Add markdown editor keyboard shortcuts

This commit adds the ability to add keyboard shortcuts to out markdown
editors.

This commit implements 3 shortcuts: bold (cmd/control+b),
italic (cmd/control+i), and link (cmd/control+k).
parent ce5b7365
......@@ -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
......
......@@ -14160,6 +14160,9 @@ msgstr ""
msgid "Keyboard shortcuts"
msgstr ""
msgid "KeyboardKey|Ctrl+"
msgstr ""
msgid "Keys"
msgstr ""
......@@ -15052,6 +15055,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