diff --git a/.eslintrc.js b/.eslintrc.js
index 5eaa0a7a7247..4664c269cbc2 100644
--- a/.eslintrc.js
+++ b/.eslintrc.js
@@ -70,6 +70,7 @@ module.exports = {
node: {
extensions: ['.js', '.jsx', '.ts', '.tsx'],
},
+ exports: {},
},
'import/core-modules': [...packages, 'decap-cms-app/dist/esm'],
},
diff --git a/cypress/e2e/richtext_widget_backspace_spec.js b/cypress/e2e/richtext_widget_backspace_spec.js
new file mode 100644
index 000000000000..9a8076dace22
--- /dev/null
+++ b/cypress/e2e/richtext_widget_backspace_spec.js
@@ -0,0 +1,96 @@
+import '../utils/dismiss-local-backup';
+
+describe('Markdown widget', () => {
+
+ before(() => {
+ Cypress.config('defaultCommandTimeout', 4000);
+ cy.task('setupBackend', { backend: 'test' });
+ cy.task('useRichTextWidget');
+ });
+
+ beforeEach(() => {
+ cy.loginAndNewPost();
+ cy.clearMarkdownEditorContent();
+ });
+
+ after(() => {
+ cy.task('teardownBackend', { backend: 'test' });
+ });
+
+ // describe('pressing backspace', () => {
+ it('sets non-default block to default when empty', () => {
+ cy.focused()
+ .clickHeadingOneButton()
+ .backspace()
+ .confirmMarkdownEditorContent(`
+
+ `);
+ });
+ it('moves to previous block when no character left to delete', () => {
+ cy.focused()
+ .type('foo')
+ .enter()
+ .clickHeadingOneButton()
+ .type('a')
+ .backspace({times: 2})
+ .confirmMarkdownEditorContent(`
+ foo
+ `);
+ });
+ // behaviour change: resets the block to default
+ it('does nothing at start of first block in document when non-empty and non-default', () => {
+ cy.focused()
+ .clickHeadingOneButton()
+ .type('foo')
+ .setCursorBefore('foo')
+ .backspace({ times: 4 })
+ .confirmMarkdownEditorContent(`
+ foo
+ `);
+ });
+ // behaviour change: also resets the block to default (as in previous test)
+ it('deletes individual characters in middle of non-empty non-default block in document', () => {
+ cy.focused()
+ .clickHeadingOneButton()
+ .type('foo')
+ .setCursorAfter('fo')
+ .backspace({ times: 3 })
+ .confirmMarkdownEditorContent(`
+ o
+ `);
+ });
+ it('at beginning of non-first block, moves default block content to previous block', () => {
+ cy.focused()
+ .clickHeadingOneButton()
+ .type('foo')
+ .enter()
+ .type('bar')
+ .setCursorBefore('bar')
+ .backspace()
+ .confirmMarkdownEditorContent(`
+ foobar
+ `);
+ });
+ it('at beginning of non-first block, moves non-default block content to previous block', () => {
+ cy.focused()
+ .type('foo')
+ .enter()
+ .clickHeadingOneButton()
+ .type('bar')
+ .enter()
+ .clickHeadingTwoButton()
+ .type('baz')
+ .setCursorBefore('baz')
+ .backspace()
+ .confirmMarkdownEditorContent(`
+ foo
+ barbaz
+ `)
+ .setCursorBefore('bar')
+ .backspace()
+ .confirmMarkdownEditorContent(`
+ foobarbaz
+ `);
+ // });
+ });
+});
diff --git a/cypress/e2e/richtext_widget_code_block_spec.js b/cypress/e2e/richtext_widget_code_block_spec.js
new file mode 100644
index 000000000000..366e81eb684a
--- /dev/null
+++ b/cypress/e2e/richtext_widget_code_block_spec.js
@@ -0,0 +1,117 @@
+import { oneLineTrim, stripIndent } from 'common-tags';
+import '../utils/dismiss-local-backup';
+
+describe('Markdown widget code block', () => {
+ before(() => {
+ Cypress.config('defaultCommandTimeout', 4000);
+ cy.task('setupBackend', { backend: 'test' });
+ cy.task('useRichTextWidget');
+ });
+
+ beforeEach(() => {
+ cy.loginAndNewPost();
+ cy.clearMarkdownEditorContent();
+ });
+
+ after(() => {
+ cy.task('teardownBackend', { backend: 'test' });
+ });
+ describe('code block', () => {
+ // behaviour change: changes how the raw editor is rendered - single block mode
+ it('outputs code', () => {
+ // eslint-disable-next-line cypress/no-unnecessary-waiting
+ cy
+ .insertCodeBlock()
+ .type('foo')
+ .enter()
+ .type('bar')
+ .confirmMarkdownEditorContent(
+ `
+ ${codeBlock(`
+ foo
+ bar
+ `)}
+ `,
+ )
+ .wait(500)
+ .clickModeToggle().confirmRawEditorContent('``` foo bar ```');
+ });
+ });
+});
+
+function codeBlock(content) {
+ const lines = stripIndent(content)
+ .split('\n')
+ .map(
+ (line, idx) => `
+
+ `,
+ )
+ .join('');
+
+ return oneLineTrim`
+
+ `;
+}
diff --git a/cypress/e2e/richtext_widget_enter_spec.js b/cypress/e2e/richtext_widget_enter_spec.js
new file mode 100644
index 000000000000..b3230491990f
--- /dev/null
+++ b/cypress/e2e/richtext_widget_enter_spec.js
@@ -0,0 +1,113 @@
+import '../utils/dismiss-local-backup';
+
+describe('Richtext widget breaks', () => {
+ before(() => {
+ Cypress.config('defaultCommandTimeout', 4000);
+ cy.task('setupBackend', { backend: 'test' });
+ cy.task('useRichTextWidget');
+ });
+
+ beforeEach(() => {
+ cy.loginAndNewPost();
+ cy.clearMarkdownEditorContent();
+ });
+
+ after(() => {
+ cy.task('teardownBackend', { backend: 'test' });
+ });
+
+ describe('pressing enter', () => {
+ it('creates new default block from empty block', () => {
+ cy.focused()
+ .enter()
+ .confirmMarkdownEditorContent(`
+
+
+ `);
+ });
+ it('creates new default block when selection collapsed at end of block', () => {
+ cy.focused()
+ .type('foo')
+ .enter()
+ .confirmMarkdownEditorContent(`
+ foo
+
+ `);
+ });
+ it('creates new default block when selection collapsed at end of non-default block', () => {
+ cy.clickHeadingOneButton()
+ .type('foo')
+ .enter()
+ .confirmMarkdownEditorContent(`
+ foo
+
+ `);
+ });
+ // behaviour change: plate now creates the new default block before the non-default block
+ it('creates new default block when selection collapsed in empty non-default block', () => {
+ cy.clickHeadingOneButton()
+ .enter()
+ .confirmMarkdownEditorContent(`
+
+
+ `);
+ });
+ // behaviour change: plate now creates the new default block before the non-default block
+ it('splits block into two same-type blocks when collapsed selection at block start', () => {
+ cy.clickHeadingOneButton()
+ .type('foo')
+ .setCursorBefore('foo')
+ .enter()
+ .confirmMarkdownEditorContent(`
+
+ foo
+ `);
+ });
+ // behaviour change: plate now splits into default block
+ it('splits block into two same-type blocks when collapsed in middle of selection at block start', () => {
+ cy.clickHeadingOneButton()
+ .type('foo')
+ .setCursorBefore('oo')
+ .enter()
+ .confirmMarkdownEditorContent(`
+ f
+ oo
+ `);
+ });
+ it('deletes selected content and splits to same-type block when selection is expanded', () => {
+ cy.clickHeadingOneButton()
+ .type('foo bar')
+ .setSelection('o b')
+ .enter()
+ .confirmMarkdownEditorContent(`
+ fo
+ ar
+ `);
+ });
+ });
+
+ // skipped: cypress type event does not trigger actual keyboard event for the Plate to detect
+ describe.skip('pressing shift+enter', () => {
+ it('creates line break', () => {
+ cy.focused()
+ .enter({ shift: true })
+ .confirmMarkdownEditorContent(`
+
+ a
+
+ `);
+ });
+ it('creates consecutive line break', () => {
+ cy.focused()
+ .enter({ shift: true, times: 4 })
+ .confirmMarkdownEditorContent(`
+
+
+
+
+
+
+ `);
+ });
+ });
+});
diff --git a/cypress/e2e/richtext_widget_hotkeys_spec.js b/cypress/e2e/richtext_widget_hotkeys_spec.js
new file mode 100644
index 000000000000..4adeccc15498
--- /dev/null
+++ b/cypress/e2e/richtext_widget_hotkeys_spec.js
@@ -0,0 +1,107 @@
+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}';
+// eslint-disable-next-line func-style
+const replaceMod = (str) => str.replace(/mod\+/g, modifierKey).replace(/shift\+/g, '{shift}');
+
+describe('Markdown widget hotkeys', () => {
+ describe('hot keys', () => {
+ before(() => {
+ Cypress.config('defaultCommandTimeout', 4000);
+ cy.task('setupBackend', { backend: 'test' });
+ cy.task('useRichTextWidget');
+ });
+
+ beforeEach(() => {
+ cy.loginAndNewPost();
+ 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(() => {
+
+ });
+ it('pressing mod+k transforms the text to a link', () => {
+ cy.window().then((win) => {
+ cy.get('@selection')
+ .type(replaceMod(HOT_KEY_MAP['link']))
+ cy.stub(win, 'prompt').returns('https://google.com');
+ cy.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/e2e/richtext_widget_link_spec.js b/cypress/e2e/richtext_widget_link_spec.js
new file mode 100644
index 000000000000..3826b534a5dd
--- /dev/null
+++ b/cypress/e2e/richtext_widget_link_spec.js
@@ -0,0 +1,71 @@
+import '../utils/dismiss-local-backup';
+
+describe('Markdown widget link', () => {
+ before(() => {
+ Cypress.config('defaultCommandTimeout', 4000);
+ cy.task('setupBackend', { backend: 'test' });
+ cy.task('useRichTextWidget');
+ });
+
+ beforeEach(() => {
+ cy.loginAndNewPost();
+ cy.clearMarkdownEditorContent();
+ });
+
+ after(() => {
+ cy.task('teardownBackend', { backend: 'test' });
+ });
+
+ describe('link', () => {
+ it('can add a new valid link', () => {
+ const link = 'https://www.decapcms.org/';
+ cy.window().then(win => {
+ cy.stub(win, 'prompt').returns(link);
+ });
+ cy.focused().clickLinkButton();
+
+ cy.confirmMarkdownEditorContent(`${link}
`);
+ // eslint-disable-next-line cypress/no-unnecessary-waiting
+ cy.wait(300);
+ cy.clickModeToggle();
+
+ cy.confirmRawEditorContent(`<${link}>`);
+ });
+
+ it('can add a new invalid link', () => {
+ const link = 'www.decapcms.org';
+ cy.window().then(win => {
+ cy.stub(win, 'prompt').returns(link);
+ });
+ cy.focused().clickLinkButton();
+
+ cy.confirmMarkdownEditorContent(`${link}
`);
+ // eslint-disable-next-line cypress/no-unnecessary-waiting
+ cy.wait(300);
+ cy.clickModeToggle();
+
+ cy.confirmRawEditorContent(`[${link}](${link})`);
+ });
+
+ it('can select existing text as link', () => {
+ const link = 'https://www.decapcms.org';
+ cy.window().then(win => {
+ cy.stub(win, 'prompt').returns(link);
+ });
+
+ const text = 'Decap CMS';
+ cy.focused()
+ .getMarkdownEditor()
+ .type(text)
+ .setSelection(text)
+ .clickLinkButton();
+
+ cy.confirmMarkdownEditorContent(`${text}
`);
+ // eslint-disable-next-line cypress/no-unnecessary-waiting
+ cy.wait(300);
+ cy.clickModeToggle();
+
+ cy.confirmRawEditorContent(`[${text}](${link})`);
+ });
+ });
+});
diff --git a/cypress/e2e/richtext_widget_marks_spec.js b/cypress/e2e/richtext_widget_marks_spec.js
new file mode 100644
index 000000000000..f711cc1c5936
--- /dev/null
+++ b/cypress/e2e/richtext_widget_marks_spec.js
@@ -0,0 +1,37 @@
+import '../utils/dismiss-local-backup';
+
+describe('Markdown widget', () => {
+ describe('code mark', () => {
+ before(() => {
+ Cypress.config('defaultCommandTimeout', 4000);
+ cy.task('setupBackend', { backend: 'test' });
+ cy.task('useRichTextWidget');
+ });
+
+ beforeEach(() => {
+ cy.loginAndNewPost();
+ cy.clearMarkdownEditorContent();
+ });
+
+ after(() => {
+ cy.task('teardownBackend', { backend: 'test' });
+ });
+
+ describe('toolbar button', () => {
+ it('can combine code mark with other marks', () => {
+ cy.clickItalicButton()
+ .type('foo')
+ .setSelection('oo')
+ .clickCodeButton()
+ .confirmMarkdownEditorContent(`
+
+ f
+
+ oo
+
+
+ `);
+ });
+ });
+ });
+});
diff --git a/cypress/plugins/index.js b/cypress/plugins/index.js
index 13a6ab505495..10988a8ba14d 100644
--- a/cypress/plugins/index.js
+++ b/cypress/plugins/index.js
@@ -174,6 +174,25 @@ module.exports = async (on, config) => {
return null;
},
+ async useRichTextWidget() {
+ console.log('Updating config to use richtext widget');
+ await updateConfig(current => {
+ if (current.collections) {
+ current.collections = current.collections.map(collection => {
+ if (collection.fields) {
+ collection.fields = collection.fields.map(field => {
+ if (field.widget === 'markdown') {
+ return { ...field, widget: 'richtext' };
+ }
+ return field;
+ });
+ }
+ return collection;
+ });
+ }
+ });
+ return null;
+ },
});
addMatchImageSnapshotPlugin(on, config);
diff --git a/dev-test/config.yml b/dev-test/config.yml
index f985684151a7..6c6b5c544a9d 100644
--- a/dev-test/config.yml
+++ b/dev-test/config.yml
@@ -55,7 +55,9 @@ collections: # A list of collections the CMS should be able to edit
required: false
tagname: ''
- - { label: 'Body', name: 'body', widget: 'markdown', hint: 'Main content goes here.' }
+ - { label: 'Body', name: 'body', widget: 'richtext', hint: 'Main content goes here.' }
+ - { label: 'Body', name: 'bodyold', widget: 'markdown', hint: 'Main content goes here.' }
+ - { label: 'Body', name: 'bodyold2', widget: 'markdown', hint: 'Main content goes here 2.' }
- name: 'restaurants' # Used in routes, ie.: /admin/collections/:slug/edit
label: 'Restaurants' # Used in the UI
diff --git a/dev-test/index.html b/dev-test/index.html
index bf1724140601..340567be61ff 100644
--- a/dev-test/index.html
+++ b/dev-test/index.html
@@ -131,7 +131,10 @@
h('p', {},
h('small', {}, "Written " + entry.getIn(['data', 'date']))
),
- h('div', {"className": "text"}, this.props.widgetFor('body'))
+ h('h2', {}, "Richtext Widget"),
+ h('div', {"className": "text"}, this.props.widgetFor('body')),
+ h('h2', {}, "Markdown Widget"),
+ h('div', {"className": "text"}, this.props.widgetFor('bodyold'))
);
}
});
@@ -243,6 +246,39 @@
);
}
});
+ CMS.registerEditorComponent({
+ id: 'container-markdown',
+ label: 'Container Markdown',
+ fields: [
+ { name: 'inner', label: 'Body', widget: 'markdown', editor_components: [] },
+ ],
+ pattern: /^{{< container-markdown >}}(.*?){{< \/container-markdown >}}/s,
+ fromBlock (match) {
+ return {
+ inner: match[1] || '',
+ }
+ },
+ toBlock (obj) {
+ return `{{< container-markdown >}}${obj.inner}{{< /container-markdown >}}`
+ },
+ })
+ CMS.registerEditorComponent({
+ id: 'container-richtext',
+ label: 'Container Richtext',
+ fields: [
+ { name: 'inner', label: 'Body', widget: 'richtext', editor_components: ['container-richtext'] },
+ ],
+ pattern: /^{{< container-richtext >}}(.*?){{< \/container-richtext >}}/s,
+ fromBlock (match) {
+ return {
+ inner: match[1] || '',
+ }
+ },
+ toBlock (obj) {
+ return `{{< container-richtext >}}${obj.inner}{{< /container-richtext >}}`
+ },
+ })
+