diff --git a/cypress/integration/markdown_widget_hotkeys_spec.js b/cypress/integration/markdown_widget_hotkeys_spec.js
new file mode 100644
index 00000000..4c80a3dd
--- /dev/null
+++ b/cypress/integration/markdown_widget_hotkeys_spec.js
@@ -0,0 +1,103 @@
+import '../utils/dismiss-local-backup';
+import {HOT_KEY_MAP} from "../utils/constants";
+const headingNumberToWord = ['', 'one', 'two', 'three', 'four', 'five', 'six'];
+const isMac = Cypress.platform === 'darwin';
+const modifierKey = isMac ? '{meta}' : '{ctrl}';
+const replaceMod = (str) => str.replace(/mod\+/g, modifierKey).replace(/shift\+/g, '{shift}');
+
+describe('Markdown widget', () => {
+ describe('hot keys', () => {
+ before(() => {
+ Cypress.config('defaultCommandTimeout', 4000);
+ cy.task('setupBackend', { backend: 'test' });
+ cy.loginAndNewPost();
+
+ });
+
+ beforeEach(() => {
+ cy.clearMarkdownEditorContent();
+ cy.focused()
+ .type('foo')
+ .setSelection('foo').as('selection');
+ });
+
+ after(() => {
+ cy.task('teardownBackend', { backend: 'test' });
+ });
+
+ describe('bold', () => {
+ it('pressing mod+b bolds the text', () => {
+ cy.get('@selection')
+ .type(replaceMod(HOT_KEY_MAP['bold']))
+ .confirmMarkdownEditorContent(`
+
+ foo
+
+ `)
+ .type(replaceMod(HOT_KEY_MAP['bold']));
+ });
+ });
+
+ describe('italic', () => {
+ it('pressing mod+i italicizes the text', () => {
+ cy.get('@selection')
+ .type(replaceMod(HOT_KEY_MAP['italic']))
+ .confirmMarkdownEditorContent(`
+
+ foo
+
+ `)
+ .type(replaceMod(HOT_KEY_MAP['italic']));
+ });
+ });
+
+ describe('strikethrough', () => {
+ it('pressing mod+shift+s displays a strike through the text', () => {
+ cy.get('@selection')
+ .type(replaceMod(HOT_KEY_MAP['strikethrough']))
+ .confirmMarkdownEditorContent(`
+
+ foo
+
+ `).type(replaceMod(HOT_KEY_MAP['strikethrough']));
+ });
+ });
+
+ describe('code', () => {
+ it('pressing mod+shift+c displays a code block around the text', () => {
+ cy.get('@selection')
+ .type(replaceMod(HOT_KEY_MAP['code']))
+ .confirmMarkdownEditorContent(`
+
+ foo
+
+ `).type(replaceMod(HOT_KEY_MAP['code']));
+ });
+ });
+
+ describe('link', () => {
+ before(() => {
+ cy.window().then((win) => {
+ cy.stub(win, 'prompt').returns('https://google.com');
+ });
+ });
+ it('pressing mod+k transforms the text to a link', () => {
+ cy.get('@selection')
+ .type(replaceMod(HOT_KEY_MAP['link']))
+ .confirmMarkdownEditorContent('foo
')
+ .type(replaceMod(HOT_KEY_MAP['link']));
+ });
+ });
+
+ describe('headings', () => {
+ for (let i = 1; i <= 6; i++) {
+ it(`pressing mod+${i} transforms the text to a heading`, () => {
+ cy.get('@selection')
+ .type(replaceMod(HOT_KEY_MAP[`heading-${headingNumberToWord[i]}`]))
+ .confirmMarkdownEditorContent(`foo`)
+ .type(replaceMod(HOT_KEY_MAP[`heading-${headingNumberToWord[i]}`]))
+ });
+ }
+ });
+ });
+});
diff --git a/cypress/support/commands.js b/cypress/support/commands.js
index 23401a9d..6dbb9626 100644
--- a/cypress/support/commands.js
+++ b/cypress/support/commands.js
@@ -295,11 +295,14 @@ Cypress.Commands.add('confirmMarkdownEditorContent', expectedDomString => {
// Slate makes the following representations:
// - blank line: 2 BOM's +
// - blank element (placed inside empty elements): 1 BOM +
- // We replace to represent a blank line as a single
, and remove the
- // contents of elements that are actually empty.
+ // - inline element (e.g. link tag ) are wrapped with BOM characters (https://github.com/ianstormtaylor/slate/issues/2722)
+ // We replace to represent a blank line as a single
, remove the
+ // contents of elements that are actually empty, and remove BOM characters wrapping tags
const actualDomString = toPlainTree(element.innerHTML)
.replace(/\uFEFF\uFEFF
/g, '
')
- .replace(/\uFEFF
/g, '');
+ .replace(/\uFEFF
/g, '')
+ .replace(/\uFEFF/g, '')
+ .replace(/<\/a>\uFEFF/g, '');
expect(actualDomString).toEqual(oneLineTrim(expectedDomString));
});
});
@@ -335,7 +338,7 @@ function removeSlateArtifacts() {
delete node.properties;
// remove slate padding spans to simplify test cases
- if (['h1', 'p'].includes(node.tagName)) {
+ if (['h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'p'].includes(node.tagName)) {
node.children = node.children.flatMap(getActualBlockChildren);
}
});
diff --git a/cypress/utils/constants.js b/cypress/utils/constants.js
index 786eef64..a04ebef0 100644
--- a/cypress/utils/constants.js
+++ b/cypress/utils/constants.js
@@ -19,6 +19,19 @@ const notifications = {
},
},
};
+const HOT_KEY_MAP = {
+ 'bold': 'mod+b',
+ 'code': 'mod+shift+c',
+ 'italic': 'mod+i',
+ 'strikethrough': 'mod+shift+s',
+ 'heading-one': 'mod+1',
+ 'heading-two': 'mod+2',
+ 'heading-three': 'mod+3',
+ 'heading-four': 'mod+4',
+ 'heading-five': 'mod+5',
+ 'heading-six': 'mod+6',
+ 'link': 'mod+k',
+};
module.exports = {
workflowStatus,
@@ -27,4 +40,5 @@ module.exports = {
setting2,
notifications,
publishTypes,
+ HOT_KEY_MAP
};
diff --git a/packages/netlify-cms-widget-markdown/src/MarkdownControl/plugins/Hotkey.js b/packages/netlify-cms-widget-markdown/src/MarkdownControl/plugins/Hotkey.js
new file mode 100644
index 00000000..830a72a8
--- /dev/null
+++ b/packages/netlify-cms-widget-markdown/src/MarkdownControl/plugins/Hotkey.js
@@ -0,0 +1,29 @@
+import isHotkey from 'is-hotkey';
+
+export const HOT_KEY_MAP = {
+ bold: 'mod+b',
+ code: 'mod+shift+c',
+ italic: 'mod+i',
+ strikethrough: 'mod+shift+s',
+ 'heading-one': 'mod+1',
+ 'heading-two': 'mod+2',
+ 'heading-three': 'mod+3',
+ 'heading-four': 'mod+4',
+ 'heading-five': 'mod+5',
+ 'heading-six': 'mod+6',
+ link: 'mod+k',
+};
+
+const Hotkey = (key, fn) => {
+ return {
+ onKeyDown(event, editor, next) {
+ if (!isHotkey(key, event)) {
+ return next();
+ }
+ event.preventDefault();
+ editor.command(fn);
+ },
+ };
+};
+
+export default Hotkey;
diff --git a/packages/netlify-cms-widget-markdown/src/MarkdownControl/plugins/visual.js b/packages/netlify-cms-widget-markdown/src/MarkdownControl/plugins/visual.js
index d5d58087..a55139df 100644
--- a/packages/netlify-cms-widget-markdown/src/MarkdownControl/plugins/visual.js
+++ b/packages/netlify-cms-widget-markdown/src/MarkdownControl/plugins/visual.js
@@ -12,6 +12,7 @@ import Link from './Link';
import ForceInsert from './ForceInsert';
import Shortcode from './Shortcode';
import { SLATE_DEFAULT_BLOCK_TYPE as defaultType } from '../../types';
+import Hotkey, { HOT_KEY_MAP } from './Hotkey';
const plugins = ({ getAsset, resolveWidget }) => [
{
@@ -22,6 +23,17 @@ const plugins = ({ getAsset, resolveWidget }) => [
next();
},
},
+ Hotkey(HOT_KEY_MAP['bold'], e => e.toggleMark('bold')),
+ Hotkey(HOT_KEY_MAP['code'], e => e.toggleMark('code')),
+ Hotkey(HOT_KEY_MAP['italic'], e => e.toggleMark('italic')),
+ Hotkey(HOT_KEY_MAP['strikethrough'], e => e.toggleMark('strikethrough')),
+ Hotkey(HOT_KEY_MAP['heading-one'], e => e.toggleBlock('heading-one')),
+ Hotkey(HOT_KEY_MAP['heading-two'], e => e.toggleBlock('heading-two')),
+ Hotkey(HOT_KEY_MAP['heading-three'], e => e.toggleBlock('heading-three')),
+ Hotkey(HOT_KEY_MAP['heading-four'], e => e.toggleBlock('heading-four')),
+ Hotkey(HOT_KEY_MAP['heading-five'], e => e.toggleBlock('heading-five')),
+ Hotkey(HOT_KEY_MAP['heading-six'], e => e.toggleBlock('heading-six')),
+ Hotkey(HOT_KEY_MAP['link'], e => e.toggleLink(() => window.prompt('Enter the URL of the link'))),
CommandsAndQueries({ defaultType }),
QuoteBlock({ defaultType, type: 'quote' }),
ListPlugin({ defaultType, unorderedListType: 'bulleted-list', orderedListType: 'numbered-list' }),