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' }),