feat: Code Widget + Markdown Widget Internal Overhaul (#2828)

* wip - upgrade to slate 0.43

* wip

* wip

* wip

* wip

* wip

* wip

* wip

* finish list handling logic

* add plugins directory

* tests wip

* setup testing

* wip

* add selection commands

* finish list testing

* stuff

* add codemirror

* abstract codemirror from slate

* wip

* wip

* wip

* wip

* wip

* wip

* wip

* wip

* wip

* codemirror mostly working, some bugs

* upgrade to slate 46

* upgrade to slate 47

* wip

* wip

* progress

* wip

* mostly working links with surrounding marks

* wip

* tests passing

* add test

* fix formatting

* update snapshots

* close self closing tag in markdown html output

* wip - commonmark

* hold on commonmark work

* all tests passing

* fix e2e specs

* ignore tests in esm builds

* break/backspace plugins wip

* finish enter/backspace spec

* fix soft break handling

* wip - editor component deletion

* add insertion points

* make insertion points invisible

* fix empty mark nodes output to markdown

* fix pasting

* improve insertion points

* add static bottom insertion point

* improve click handling at insertion points

* restore current table functionality

* add paste support for Slate fragments

* support cut/copy markdown, paste between rich/raw editor

* fix copy paste

* wip - paste/select bug fixing

* fixed known slate issues

* split plugins

* fix editor toggles

* force text cursor in code widget

* wip - reorg plugins

* finish markdown control reorg

* configure plugin types

* quote block adjacent handling with tests

* wip

* finish quote logic and tests

* fix copy paste plugin migration regressions

* fix force insert before node

* fix trailing insertion point

* remove empty headers

* codemirror working properly in markdown widget

* return focus to codemirror on lang select enter

* fix state issues for widgets with local state

* wip - vim working, just need to work out distribution

* add settings pane

* wip - default modes

* fix deps

* add programming language data

* implement linguist langs in code widget

* everything built in

* remove old registration code, fix focus styling

* fix/update linting setup

* fix js lint errors

* remove stylelint from format script

* fix remaining linting errors

* fix reducer test failures

* chore: update commitlint for worktree support

* chore: fix remaining tests

* chore: drop unused monaco plugin

* chore: remove extraneous global styles rendering

* chore: fix failing tests

* fix: tests

* fix: quote/list nesting (tests still broken)

* fix: update quote tests

* chore: bring back code widget test config

* fix: autofocus

* fix: code blocks without the code widget

* fix: code editor component state issues

* fix: error

* fix: add code block test, few fixes

* chore: remove notes

* fix: [wip] update stateful shortcodes on undo/redo

* fix: support code styled links, handle unknown langs

* fix: few fixes

* fix: autofocus on insert, focus on all clicks

* fix: linting

* fix: autofocus

* fix: update code block fixture

* fix: remove unused cypress snapshot plugin

* fix: drop node 8 test, add node 12

* fix: use lodash.flatten instead of Array.flat

* fix: remove console logs
This commit is contained in:
Shawn Erquhart 2019-12-16 12:17:37 -05:00 committed by Erez Rokah
parent be46293f82
commit 18c579d0e9
110 changed files with 12693 additions and 8516 deletions

View File

@ -1,32 +0,0 @@
{
"parser": "babel-eslint",
"extends": ["eslint:recommended", "plugin:react/recommended", "plugin:cypress/recommended"],
"env": {
"es6": true,
"browser": true,
"node": true,
"jest": true,
"cypress/globals": true
},
"globals": {
"NETLIFY_CMS_VERSION": false,
"NETLIFY_CMS_APP_VERSION": false,
"NETLIFY_CMS_CORE_VERSION": false,
"CMS_ENV": false
},
"rules": {
"no-console": [0],
"react/prop-types": [0],
"no-duplicate-imports": "error",
"emotion/jsx-import": "error",
"emotion/no-vanilla": "error",
"emotion/import-from-emotion": "error",
"emotion/styled-import": "error"
},
"plugins": ["emotion", "cypress"],
"settings": {
"react": {
"version": "detect"
}
}
}

38
.eslintrc.js Normal file
View File

@ -0,0 +1,38 @@
module.exports = {
parser: 'babel-eslint',
extends: [
'eslint:recommended',
'plugin:react/recommended',
'plugin:cypress/recommended',
'prettier/react',
'prettier/babel',
'prettier',
],
env: {
es6: true,
browser: true,
node: true,
jest: true,
'cypress/globals': true,
},
globals: {
NETLIFY_CMS_VERSION: false,
NETLIFY_CMS_APP_VERSION: false,
NETLIFY_CMS_CORE_VERSION: false,
CMS_ENV: false,
},
rules: {
'no-console': [0],
'react/prop-types': [0],
'no-duplicate-imports': 'error',
'emotion/no-vanilla': 'error',
'emotion/import-from-emotion': 'error',
'emotion/styled-import': 'error',
},
plugins: ['babel', 'emotion', 'cypress'],
settings: {
react: {
version: 'detect',
},
},
};

View File

@ -16,7 +16,7 @@ jobs:
strategy:
matrix:
node-version: [8.x, 10.x]
node-version: [10.x, 12.x]
steps:
- uses: actions/checkout@v1

View File

@ -1,21 +1,12 @@
{
"processors": [
["stylelint-processor-styled-components", {
"parserPlugins": [
"jsx",
"objectRestSpread",
"exportDefaultFrom",
"classProperties",
],
}],
],
"extends": [
"stylelint-config-recommended",
"stylelint-config-styled-components",
],
"rules": {
"block-no-empty": null,
"no-duplicate-selectors": null,
"no-empty-source": null,
"no-extra-semicolons": null,
"selector-type-no-unknown": [true, {
"ignoreTypes": ["$dummyValue"],
}],

View File

@ -19,6 +19,10 @@ const defaultPlugins = [
'@babel/plugin-proposal-class-properties',
'@babel/plugin-proposal-object-rest-spread',
'@babel/plugin-proposal-export-default-from',
'@babel/plugin-proposal-nullish-coalescing-operator',
'@babel/plugin-proposal-optional-chaining',
'@babel/plugin-syntax-dynamic-import',
'babel-plugin-inline-json-import',
[
'module-resolver',
isESM
@ -69,20 +73,22 @@ const defaultPlugins = [
];
const presets = () => {
return ['@babel/preset-react', '@babel/preset-env'];
return [
'@babel/preset-react',
'@babel/preset-env',
[
'@emotion/babel-preset-css-prop',
{
autoLabel: true,
},
],
];
};
const plugins = () => {
if (isESM) {
return [
...defaultPlugins,
[
'emotion',
{
sourceMap: true,
autoLabel: true,
},
],
[
'transform-define',
{
@ -104,13 +110,6 @@ const plugins = () => {
if (isTest) {
return [
...defaultPlugins,
[
'emotion',
{
sourceMap: false,
autoLabel: false,
},
],
[
'inline-react-svg',
{
@ -123,29 +122,10 @@ const plugins = () => {
}
if (!isProduction) {
return [
...defaultPlugins,
[
'emotion',
{
sourceMap: true,
autoLabel: true,
},
],
'react-hot-loader/babel',
];
return [...defaultPlugins, 'react-hot-loader/babel'];
}
return [
...defaultPlugins,
[
'emotion',
{
sourceMap: true,
autoLabel: true,
},
],
];
return defaultPlugins;
};
module.exports = {

View File

@ -0,0 +1,82 @@
import '../utils/dismiss-local-backup';
describe('Markdown widget', () => {
before(() => {
Cypress.config('defaultCommandTimeout', 4000);
cy.task('setupBackend', { backend: 'test' });
cy.loginAndNewPost();
});
beforeEach(() => {
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(`
<p></p>
`);
});
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(`
<h1>foo</h1>
`);
});
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(`
<h1>o</h1>
`);
});
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(`
<h1>foobar</h1>
`);
});
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(`
<p>foo</p>
<h1>barbaz</h1>
`)
.setCursorBefore('bar')
.backspace()
.confirmMarkdownEditorContent(`
<p>foobarbaz</p>
`);
});
});
});

View File

@ -0,0 +1,120 @@
import { oneLineTrim, stripIndent } from 'common-tags';
import '../utils/dismiss-local-backup';
describe('Markdown widget', () => {
before(() => {
Cypress.config('defaultCommandTimeout', 4000);
cy.task('setupBackend', { backend: 'test' });
cy.loginAndNewPost();
});
beforeEach(() => {
cy.clearMarkdownEditorContent();
});
after(() => {
cy.task('teardownBackend', { backend: 'test' });
});
describe('code block', () => {
it('outputs code', () => {
cy.insertCodeBlock()
.type('foo')
.enter()
.type('bar')
.confirmMarkdownEditorContent(`
${codeBlock(`
foo
bar
`)}
`)
.clickModeToggle()
.confirmMarkdownEditorContent(`
${codeBlockRaw(`
foo
bar
`)}
`)
})
})
})
function codeBlockRaw(content) {
return ['```', ...stripIndent(content).split('\n'), '```'].map(line => oneLineTrim`
<div>
<span>
<span>
<span>${line}</span>
</span>
</span>
</div>
`).join('');
}
function codeBlock(content) {
const lines = stripIndent(content).split('\n').map((line, idx) => `
<div>
<div>
<div>${idx + 1}</div>
</div>
<pre><span>${line}</span></pre>
</div>
`).join('');
return oneLineTrim`
<div>
<div><span><span><span><span></span><span></span></span></span></span></div>
<div>
<div>
<div></div>
<div>
<div><label>Code Block</label>
<div><button><span><svg>
<path></path>
</svg></span></button>
<div>
<div>
<div><textarea></textarea></div>
<div>
<div></div>
</div>
<div>
<div></div>
</div>
<div></div>
<div></div>
<div>
<div>
<div>
<div>
<div>
<div>
<pre><span>xxxxxxxxxx</span></pre>
</div>
<div></div>
<div></div>
<div>
<div> </div>
</div>
<div>
${lines}
</div>
</div>
</div>
</div>
</div>
<div></div>
<div>
<div></div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<div></div>
</div>
</div>
</div>
`;
}

View File

@ -0,0 +1,108 @@
import '../utils/dismiss-local-backup';
describe('Markdown widget breaks', () => {
before(() => {
Cypress.config('defaultCommandTimeout', 4000);
cy.task('setupBackend', { backend: 'test' });
cy.loginAndNewPost();
});
beforeEach(() => {
cy.clearMarkdownEditorContent();
});
after(() => {
cy.task('teardownBackend', { backend: 'test' });
});
describe('pressing enter', () => {
it('creates new default block from empty block', () => {
cy.focused()
.enter()
.confirmMarkdownEditorContent(`
<p></p>
<p></p>
`);
});
it('creates new default block when selection collapsed at end of block', () => {
cy.focused()
.type('foo')
.enter()
.confirmMarkdownEditorContent(`
<p>foo</p>
<p></p>
`);
});
it('creates new default block when selection collapsed at end of non-default block', () => {
cy.clickHeadingOneButton()
.type('foo')
.enter()
.confirmMarkdownEditorContent(`
<h1>foo</h1>
<p></p>
`);
});
it('creates new default block when selection collapsed in empty non-default block', () => {
cy.clickHeadingOneButton()
.enter()
.confirmMarkdownEditorContent(`
<h1></h1>
<p></p>
`);
});
it('splits block into two same-type blocks when collapsed selection at block start', () => {
cy.clickHeadingOneButton()
.type('foo')
.setCursorBefore('foo')
.enter()
.confirmMarkdownEditorContent(`
<h1></h1>
<h1>foo</h1>
`);
});
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(`
<h1>f</h1>
<h1>oo</h1>
`);
});
it('deletes selected content and splits to same-type block when selection is expanded', () => {
cy.clickHeadingOneButton()
.type('foo bar')
.setSelection('o b')
.enter()
.confirmMarkdownEditorContent(`
<h1>fo</h1>
<h1>ar</h1>
`);
});
});
describe('pressing shift+enter', () => {
it('creates line break', () => {
cy.focused()
.enter({ shift: true })
.confirmMarkdownEditorContent(`
<p>
<br>
</p>
`);
});
it('creates consecutive line break', () => {
cy.focused()
.enter({ shift: true, times: 4 })
.confirmMarkdownEditorContent(`
<p>
<br>
<br>
<br>
<br>
</p>
`);
});
});
});

View File

@ -0,0 +1,681 @@
import '../utils/dismiss-local-backup';
describe('Markdown widget', () => {
describe('list', () => {
before(() => {
Cypress.config('defaultCommandTimeout', 4000);
cy.task('setupBackend', { backend: 'test' });
cy.loginAndNewPost();
});
beforeEach(() => {
cy.clearMarkdownEditorContent();
});
after(() => {
cy.task('teardownBackend', { backend: 'test' });
});
describe('toolbar buttons', () => {
it('creates and focuses empty list', () => {
cy.clickUnorderedListButton()
.confirmMarkdownEditorContent(`
<ul>
<li>
<p></p>
</li>
</ul>
`);
});
it('removes list', () => {
cy.clickUnorderedListButton({ times: 2 })
.confirmMarkdownEditorContent(`
<p></p>
`);
});
it('creates nested list when selection is collapsed in non-first block of list item', () => {
cy.clickUnorderedListButton()
.type('foo')
.enter()
.clickUnorderedListButton()
.confirmMarkdownEditorContent(`
<ul>
<li>
<p>foo</p>
<ul>
<li>
<p></p>
</li>
</ul>
</li>
</ul>
`)
.type('bar')
.enter()
.clickUnorderedListButton()
.confirmMarkdownEditorContent(`
<ul>
<li>
<p>foo</p>
<ul>
<li>
<p>bar</p>
<ul>
<li>
<p></p>
</li>
</ul>
</li>
</ul>
</li>
</ul>
`);
});
it('converts empty nested list item to empty block in parent list item', () => {
cy.clickUnorderedListButton()
.type('foo')
.enter()
.clickUnorderedListButton()
.type('bar')
.enter()
.clickUnorderedListButton()
.confirmMarkdownEditorContent(`
<ul>
<li>
<p>foo</p>
<ul>
<li>
<p>bar</p>
<ul>
<li>
<p></p>
</li>
</ul>
</li>
</ul>
</li>
</ul>
`)
.clickUnorderedListButton()
.confirmMarkdownEditorContent(`
<ul>
<li>
<p>foo</p>
<ul>
<li>
<p>bar</p>
<p></p>
</li>
</ul>
</li>
</ul>
`)
.backspace({ times: 4 })
.clickUnorderedListButton()
.confirmMarkdownEditorContent(`
<ul>
<li>
<p>foo</p>
<p></p>
</li>
</ul>
`);
});
it('moves nested list item content to parent list item when in first block', () => {
cy.clickUnorderedListButton()
.type('foo')
.enter()
.clickUnorderedListButton()
.type('bar')
.enter()
.clickUnorderedListButton()
.type('baz')
.clickUnorderedListButton()
.confirmMarkdownEditorContent(`
<ul>
<li>
<p>foo</p>
<ul>
<li>
<p>bar</p>
<p>baz</p>
</li>
</ul>
</li>
</ul>
`)
.up()
.clickUnorderedListButton()
.confirmMarkdownEditorContent(`
<ul>
<li>
<p>foo</p>
<p>bar</p>
<p>baz</p>
</li>
</ul>
`)
.up()
.clickUnorderedListButton()
.confirmMarkdownEditorContent(`
<p>foo</p>
<p>bar</p>
<p>baz</p>
`);
});
it('affects only the current block with collapsed selection', () => {
cy.focused()
.type('foo')
.enter()
.type('bar')
.enter()
.type('baz')
.up()
.clickUnorderedListButton()
.confirmMarkdownEditorContent(`
<p>foo</p>
<ul>
<li>
<p>bar</p>
</li>
</ul>
<p>baz</p>
`);
});
it('combines adjacent same-typed lists, not differently typed lists', () => {
cy.focused()
.type('foo')
.enter()
.type('bar')
.enter()
.type('baz')
.up()
.clickUnorderedListButton()
.up()
.clickUnorderedListButton()
.confirmMarkdownEditorContent(`
<ul>
<li>
<p>foo</p>
</li>
<li>
<p>bar</p>
</li>
</ul>
<p>baz</p>
`)
.down({ times: 2 })
.clickUnorderedListButton()
.confirmMarkdownEditorContent(`
<ul>
<li>
<p>foo</p>
</li>
<li>
<p>bar</p>
</li>
<li>
<p>baz</p>
</li>
</ul>
`)
.up()
.enter()
.type('qux')
.clickUnorderedListButton()
.confirmMarkdownEditorContent(`
<ul>
<li>
<p>foo</p>
</li>
<li>
<p>bar</p>
<ul>
<li>
<p>qux</p>
</li>
</ul>
</li>
<li>
<p>baz</p>
</li>
</ul>
`)
.up()
.enter()
.type('quux')
.clickUnorderedListButton()
.confirmMarkdownEditorContent(`
<ul>
<li>
<p>foo</p>
</li>
<li>
<p>bar</p>
<ul>
<li>
<p>quux</p>
</li>
<li>
<p>qux</p>
</li>
</ul>
</li>
<li>
<p>baz</p>
</li>
</ul>
`)
.clickOrderedListButton()
.confirmMarkdownEditorContent(`
<ul>
<li>
<p>foo</p>
</li>
<li>
<p>bar</p>
<ol>
<li>
<p>quux</p>
</li>
</ol>
<ul>
<li>
<p>qux</p>
</li>
</ul>
</li>
<li>
<p>baz</p>
</li>
</ul>
`)
.setSelection({
anchorQuery: 'ul > li > ol p',
anchorOffset: 1,
focusQuery: 'ul > li > ul:last-child p',
focusOffset: 2,
});
});
it('affects only selected list items', () => {
cy.clickUnorderedListButton()
.type('foo')
.enter({ times: 2 })
.type('bar')
.enter({ times: 2 })
.type('baz')
.setSelection('bar')
.clickUnorderedListButton()
.confirmMarkdownEditorContent(`
<ul>
<li>
<p>foo</p>
</li>
</ul>
<p>bar</p>
<ul>
<li>
<p>baz</p>
</li>
</ul>
`)
.clickUnorderedListButton()
.setSelection('bar', 'baz')
.clickUnorderedListButton()
.confirmMarkdownEditorContent(`
<ul>
<li>
<p>foo</p>
</li>
</ul>
<p>bar</p>
<p>baz</p>
`)
.clickUnorderedListButton()
.confirmMarkdownEditorContent(`
<ul>
<li>
<p>foo</p>
</li>
<li>
<p>bar</p>
<p>baz</p>
</li>
</ul>
`)
.setSelection('baz')
.clickUnorderedListButton()
.setCursorAfter('baz')
.enter()
.clickUnorderedListButton()
.type('qux')
.setSelection('baz')
.clickOrderedListButton()
.setCursorAfter('qux')
.enter({ times: 4 })
.clickUnorderedListButton()
.confirmMarkdownEditorContent(`
<ul>
<li>
<p>foo</p>
</li>
<li>
<p>bar</p>
<ol>
<li>
<p>baz</p>
<ul>
<li>
<p>qux</p>
</li>
</ul>
</li>
</ol>
<ul>
<li>
<p></p>
</li>
</ul>
</li>
</ul>
`)
});
});
describe('on Enter', () => {
it('removes the list item and list if empty', () => {
cy.clickUnorderedListButton()
.enter()
.confirmMarkdownEditorContent(`
<p></p>
`);
});
it('creates a new paragraph in a non-empty paragraph within a list item', () => {
cy.clickUnorderedListButton()
.type('foo')
.enter()
.confirmMarkdownEditorContent(`
<ul>
<li>
<p>foo</p>
<p></p>
</li>
</ul>
`)
.type('bar')
.enter()
.confirmMarkdownEditorContent(`
<ul>
<li>
<p>foo</p>
<p>bar</p>
<p></p>
</li>
</ul>
`);
});
it('creates a new list item in an empty paragraph within a non-empty list item', () => {
cy.clickUnorderedListButton()
.type('foo')
.enter({ times: 2 })
.confirmMarkdownEditorContent(`
<ul>
<li>
<p>foo</p>
</li>
<li>
<p></p>
</li>
</ul>
`)
.type('bar')
.enter()
.confirmMarkdownEditorContent(`
<ul>
<li>
<p>foo</p>
</li>
<li>
<p>bar</p>
<p></p>
</li>
</ul>
`);
});
it('creates a new block below list', () => {
cy.clickUnorderedListButton()
.type('foo')
.enter({ times: 3 })
.confirmMarkdownEditorContent(`
<ul>
<li>
<p>foo</p>
</li>
</ul>
<p></p>
`);
});
});
describe('on Backspace', () => {
it('removes the list item and list if empty', () => {
cy.clickUnorderedListButton()
.backspace()
.confirmMarkdownEditorContent(`
<p></p>
`);
});
it('removes empty block in non-empty list item', () => {
cy.clickUnorderedListButton()
.type('foo')
.enter()
.backspace()
.confirmMarkdownEditorContent(`
<ul>
<li>
<p>foo</p>
</li>
</ul>
`);
});
it('removes the list item if list not empty', () => {
cy.clickUnorderedListButton()
.type('foo')
.enter({ times: 2 })
.backspace()
.confirmMarkdownEditorContent(`
<ul>
<li>
<p>foo</p>
<p></p>
</li>
</ul>
`);
});
it('does not remove list item if empty with non-default block', () => {
cy.clickUnorderedListButton()
.clickHeadingOneButton()
.backspace()
.confirmMarkdownEditorContent(`
<ul>
<li>
<p></p>
</li>
</ul>
`);
});
});
describe('on Tab', () => {
it('does nothing in top level list', () => {
cy.clickUnorderedListButton()
.tabkey()
.confirmMarkdownEditorContent(`
<ul>
<li>
<p></p>
</li>
</ul>
`)
.type('foo')
.tabkey()
.confirmMarkdownEditorContent(`
<ul>
<li>
<p>foo</p>
</li>
</ul>
`)
});
it('indents nested list items', () => {
cy.clickUnorderedListButton()
.type('foo')
.enter({ times: 2 })
.type('bar')
.tabkey()
.confirmMarkdownEditorContent(`
<ul>
<li>
<p>foo</p>
<ul>
<li>
<p>bar</p>
</li>
</ul>
</li>
</ul>
`)
.enter({ times: 2 })
.tabkey()
.confirmMarkdownEditorContent(`
<ul>
<li>
<p>foo</p>
<ul>
<li>
<p>bar</p>
<ul>
<li>
<p></p>
</li>
</ul>
</li>
</ul>
</li>
</ul>
`)
});
it('only nests up to one level down from the parent list', () => {
cy.clickUnorderedListButton()
.type('foo')
.enter({ times: 2 })
.tabkey({ times: 5 })
.confirmMarkdownEditorContent(`
<ul>
<li>
<p>foo</p>
<ul>
<li>
<p></p>
</li>
</ul>
</li>
</ul>
`);
});
it('unindents nested list items with shift', () => {
cy.clickUnorderedListButton()
.type('foo')
.enter({ times: 2 })
.tabkey()
.tabkey({ shift: true })
.confirmMarkdownEditorContent(`
<ul>
<li>
<p>foo</p>
</li>
<li>
<p></p>
</li>
</ul>
`)
});
it('indents and unindents from one level below parent back to document root', () => {
cy.clickUnorderedListButton()
.type('foo')
.enter({ times: 2 })
.tabkey()
.type('bar')
.enter({ times: 2 })
.tabkey()
.type('baz')
.confirmMarkdownEditorContent(`
<ul>
<li>
<p>foo</p>
<ul>
<li>
<p>bar</p>
<ul>
<li>
<p>baz</p>
</li>
</ul>
</li>
</ul>
</li>
</ul>
`)
.tabkey({ shift: true })
.confirmMarkdownEditorContent(`
<ul>
<li>
<p>foo</p>
<ul>
<li>
<p>bar</p>
</li>
<li>
<p>baz</p>
</li>
</ul>
</li>
</ul>
`)
.tabkey({ shift: true })
.confirmMarkdownEditorContent(`
<ul>
<li>
<p>foo</p>
<ul>
<li>
<p>bar</p>
</li>
</ul>
</li>
<li>
<p>baz</p>
</li>
</ul>
`)
});
});
});
});

View File

@ -0,0 +1,36 @@
import '../utils/dismiss-local-backup';
describe('Markdown widget', () => {
describe('code mark', () => {
before(() => {
Cypress.config('defaultCommandTimeout', 4000);
cy.task('setupBackend', { backend: 'test' });
cy.loginAndNewPost();
});
beforeEach(() => {
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(`
<p>
<em>f</em>
<code>
<em>oo</em>
</code>
</p>
`);
});
});
});
});

View File

@ -0,0 +1,307 @@
import '../utils/dismiss-local-backup';
describe('Markdown widget', () => {
describe('quote block', () => {
before(() => {
Cypress.config('defaultCommandTimeout', 4000);
cy.task('setupBackend', { backend: 'test' });
cy.loginAndNewPost();
});
beforeEach(() => {
cy.clearMarkdownEditorContent();
});
after(() => {
cy.task('teardownBackend', { backend: 'test' });
});
describe('toggle quote', () => {
it('toggles empty quote block on and off in empty editor', () => {
cy.clickQuoteButton()
.confirmMarkdownEditorContent(`
<blockquote>
<p></p>
</blockquote>
`)
.clickQuoteButton()
.confirmMarkdownEditorContent(`
<p></p>
`);
});
it('toggles empty quote block on and off for current block', () => {
cy.focused()
.type('foo')
.clickQuoteButton()
.confirmMarkdownEditorContent(`
<blockquote>
<p>foo</p>
</blockquote>
`)
.clickQuoteButton()
.confirmMarkdownEditorContent(`
<p>foo</p>
`);
});
it('toggles entire quote block without expanded selection', () => {
cy.clickQuoteButton()
.type('foo')
.enter()
.type('bar')
.clickQuoteButton()
.confirmMarkdownEditorContent(`
<p>foo</p>
<p>bar</p>
`);
});
it('toggles entire quote block with complex content', () => {
cy.clickQuoteButton()
.clickUnorderedListButton()
.clickHeadingOneButton()
.type('foo')
.enter({ times: 3 })
.clickQuoteButton()
.confirmMarkdownEditorContent(`
<ul>
<li>
<h1>foo</h1>
</li>
</ul>
<p></p>
`);
});
it('toggles empty quote block on and off for selected blocks', () => {
cy.focused()
.type('foo')
.enter()
.type('bar')
.setSelection('foo', 'bar')
.clickQuoteButton()
.confirmMarkdownEditorContent(`
<blockquote>
<p>foo</p>
<p>bar</p>
</blockquote>
`)
.clickQuoteButton()
.confirmMarkdownEditorContent(`
<p>foo</p>
<p>bar</p>
`)
.clickQuoteButton()
.confirmMarkdownEditorContent(`
<blockquote>
<p>foo</p>
<p>bar</p>
</blockquote>
`);
});
it('toggles empty quote block on and off for partially selected blocks', () => {
cy.focused()
.type('foo')
.enter()
.type('bar')
.setSelection('oo', 'ba')
.clickQuoteButton()
.confirmMarkdownEditorContent(`
<blockquote>
<p>foo</p>
<p>bar</p>
</blockquote>
`)
.clickQuoteButton()
.confirmMarkdownEditorContent(`
<p>foo</p>
<p>bar</p>
`)
.clickQuoteButton()
.confirmMarkdownEditorContent(`
<blockquote>
<p>foo</p>
<p>bar</p>
</blockquote>
`);
});
it('toggles quote block on and off for multiple selected list items', () => {
cy.focused()
.clickUnorderedListButton()
.type('foo')
.enter({ times: 2 })
.type('bar')
.setSelection('foo', 'bar')
.clickQuoteButton()
.confirmMarkdownEditorContent(`
<blockquote>
<ul>
<li>
<p>foo</p>
</li>
<li>
<p>bar</p>
</li>
</ul>
</blockquote>
`)
.clickQuoteButton()
.confirmMarkdownEditorContent(`
<ul>
<li>
<p>foo</p>
</li>
<li>
<p>bar</p>
</li>
</ul>
`)
.setCursorAfter('bar')
.enter({ times: 2 })
.type('baz')
.setSelection('bar', 'baz')
.clickQuoteButton()
.confirmMarkdownEditorContent(`
<ul>
<li>
<p>foo</p>
</li>
</ul>
<blockquote>
<ul>
<li>
<p>bar</p>
</li>
<li>
<p>baz</p>
</li>
</ul>
</blockquote>
`)
});
it('creates new quote block if parent is not a quote, can deeply nest', () => {
cy.clickQuoteButton()
.clickUnorderedListButton()
.clickQuoteButton()
.clickUnorderedListButton()
.clickQuoteButton()
.clickUnorderedListButton()
.clickQuoteButton()
.type('foo')
.enter({ times: 10 })
.type('bar')
.confirmMarkdownEditorContent(`
<blockquote>
<ul>
<li>
<blockquote>
<ul>
<li>
<blockquote>
<ul>
<li>
<blockquote>
<p>foo</p>
</blockquote>
</li>
</ul>
</blockquote>
</li>
</ul>
</blockquote>
</li>
</ul>
<p>bar</p>
</blockquote>
`)
.backspace({ times: 12 })
});
});
describe('backspace inside quote', () => {
it('joins two paragraphs', () => {
cy.clickQuoteButton()
.type('foo')
.enter()
.type('bar')
.setCursorBefore('bar')
.backspace()
.confirmMarkdownEditorContent(`
<blockquote>
<p>foobar</p>
</blockquote>
`);
});
it('joins quote with previous quote', () => {
cy.clickQuoteButton()
.type('foo')
.enter({ times: 2 })
.clickQuoteButton()
.type('bar')
.confirmMarkdownEditorContent(`
<blockquote>
<p>foo</p>
</blockquote>
<blockquote>
<p>bar</p>
</blockquote>
`)
.setCursorBefore('bar')
.backspace()
.confirmMarkdownEditorContent(`
<blockquote>
<p>foo</p>
<p>bar</p>
</blockquote>
`);
});
it('removes first block from quote when focused at first block at start', () => {
cy.clickQuoteButton()
.type('foo')
.enter()
.type('bar')
.setCursorBefore('foo')
.backspace()
.confirmMarkdownEditorContent(`
<p>foo</p>
<blockquote>
<p>bar</p>
</blockquote>
`)
});
});
describe('enter inside quote', () => {
it('creates new block inside quote', () => {
cy.clickQuoteButton()
.type('foo')
.enter()
.confirmMarkdownEditorContent(`
<blockquote>
<p>foo</p>
<p></p>
</blockquote>
`)
.type('bar')
.setCursorAfter('ba')
.enter()
.confirmMarkdownEditorContent(`
<blockquote>
<p>foo</p>
<p>ba</p>
<p>r</p>
</blockquote>
`);
});
it('creates new block after quote from empty last block', () => {
cy.clickQuoteButton()
.type('foo')
.enter()
.enter()
.confirmMarkdownEditorContent(`
<blockquote>
<p>foo</p>
</blockquote>
<p></p>
`)
});
});
});
});

View File

@ -23,8 +23,11 @@
//
// -- This is will overwrite an existing command --
// Cypress.Commands.overwrite("visit", (originalFn, url, options) => { ... })
const { escapeRegExp } = require('../utils/regexp');
const path = require('path');
import path from 'path';
import rehype from 'rehype';
import visit from 'unist-util-visit';
import { oneLineTrim } from 'common-tags';
import { escapeRegExp } from '../utils/regexp';
const matchRoute = (route, fetchArgs) => {
const url = fetchArgs[0];
@ -86,3 +89,241 @@ Cypress.Commands.add('stubFetch', ({ fixture }) => {
cy.on('window:before:load', win => stubFetch(win, routes));
});
});
function runTimes(cyInstance, fn, count = 1) {
let chain = cyInstance, i = count;
while (i) {
i -= 1;
chain = fn(chain);
}
return chain;
}
[
'enter',
'backspace',
['selectAll', 'selectall'],
['up', 'upArrow'],
['down', 'downArrow'],
['left', 'leftArrow'],
['right', 'rightArrow'],
].forEach(key => {
const [ cmd, keyName ] = typeof key === 'object' ? key : [key, key];
Cypress.Commands.add(cmd, { prevSubject: true }, (subject, { shift, times = 1 } = {}) => {
const fn = chain => chain.type(`${shift ? '{shift}' : ''}{${keyName}}`);
return runTimes(cy.wrap(subject), fn, times);
});
});
// Convert `tab` command from plugin to a child command with `times` support
Cypress.Commands.add('tabkey', { prevSubject: true }, (subject, { shift, times } = {}) => {
const fn = chain => chain.tab({ shift });
return runTimes(cy, fn, times).wrap(subject);
});
Cypress.Commands.add('selection', { prevSubject: true }, (subject, fn) => {
cy.wrap(subject)
.trigger('mousedown')
.then(fn)
.trigger('mouseup')
cy.document().trigger('selectionchange');
return cy.wrap(subject);
});
Cypress.Commands.add('print', { prevSubject: 'optional' }, (subject, str) => {
cy.log(str);
console.log(`cy.log: ${str}`);
return cy.wrap(subject);
});
Cypress.Commands.add('setSelection', { prevSubject: true }, (subject, query, endQuery) => {
return cy.wrap(subject)
.selection($el => {
if (typeof query === 'string') {
const anchorNode = getTextNode($el[0], query);
const focusNode = endQuery ? getTextNode($el[0], endQuery) : anchorNode;
const anchorOffset = anchorNode.wholeText.indexOf(query);
const focusOffset = endQuery ?
focusNode.wholeText.indexOf(endQuery) + endQuery.length :
anchorOffset + query.length;
setBaseAndExtent(anchorNode, anchorOffset, focusNode, focusOffset);
} else if (typeof query === 'object') {
const el = $el[0];
const anchorNode = getTextNode(el.querySelector(query.anchorQuery));
const anchorOffset = query.anchorOffset || 0;
const focusNode = query.focusQuery ? getTextNode(el.querySelector(query.focusQuery)) : anchorNode;
const focusOffset = query.focusOffset || 0;
setBaseAndExtent(anchorNode, anchorOffset, focusNode, focusOffset);
}
});
});
Cypress.Commands.add('setCursor', { prevSubject: true }, (subject, query, atStart) => {
return cy.wrap(subject)
.selection($el => {
const node = getTextNode($el[0], query);
const offset = node.wholeText.indexOf(query) + (atStart ? 0 : query.length);
const document = node.ownerDocument;
document.getSelection().removeAllRanges();
document.getSelection().collapse(node, offset);
});
});
Cypress.Commands.add('setCursorBefore', { prevSubject: true }, (subject, query) => {
cy.wrap(subject).setCursor(query, true);
});
Cypress.Commands.add('setCursorAfter', { prevSubject: true }, (subject, query) => {
cy.wrap(subject).setCursor(query);
});
Cypress.Commands.add('login', () => {
cy.viewport(1200, 1200);
cy.visit('/');
cy.contains('button', 'Login').click();
});
Cypress.Commands.add('loginAndNewPost', () => {
cy.login();
cy.contains('a', 'New Post').click();
});
Cypress.Commands.add('drag', { prevSubject: true }, subject => {
return cy.wrap(subject)
.trigger('dragstart', {
dataTransfer: {},
force: true,
});
});
Cypress.Commands.add('drop', { prevSubject: true }, subject => {
return cy.wrap(subject)
.trigger('drop', {
dataTransfer: {},
force: true,
});
});
Cypress.Commands.add('clickToolbarButton', (title, { times } = {}) => {
const isHeading = title.startsWith('Heading')
if (isHeading) {
cy.get('button[title="Headings"]').click();
}
const instance = isHeading ? cy.contains('div', title) : cy.get(`button[title="${title}"]`);
const fn = chain => chain.click();
return runTimes(instance, fn, times).focused();
});
Cypress.Commands.add('insertEditorComponent', title => {
cy.get('button[title="Add Component"]').click()
cy.contains('div', title).click().focused();
});
[
['clickHeadingOneButton', 'Heading 1'],
['clickHeadingTwoButton', 'Heading 2'],
['clickOrderedListButton', 'Numbered List'],
['clickUnorderedListButton', 'Bulleted List'],
['clickCodeButton', 'Code'],
['clickItalicButton', 'Italic'],
['clickQuoteButton', 'Quote'],
].forEach(([commandName, toolbarButtonName]) => {
Cypress.Commands.add(commandName, opts => {
return cy.clickToolbarButton(toolbarButtonName, opts);
});
});
Cypress.Commands.add('clickModeToggle', () => {
cy.get('button[role="switch"]')
.click()
.focused();
});
[
['insertCodeBlock', 'Code Block'],
].forEach(([commandName, componentTitle]) => {
Cypress.Commands.add(commandName, () => {
return cy.insertEditorComponent(componentTitle);
});
});
Cypress.Commands.add('getMarkdownEditor', () => {
return cy.get('[data-slate-editor]');
});
Cypress.Commands.add('confirmMarkdownEditorContent', expectedDomString => {
return cy.getMarkdownEditor()
.should(([element]) => {
// Slate makes the following representations:
// - blank line: 2 BOM's + <br>
// - blank element (placed inside empty elements): 1 BOM + <br>
// We replace to represent a blank line as a single <br>, and remove the
// contents of elements that are actually empty.
const actualDomString = toPlainTree(element.innerHTML)
.replace(/\uFEFF\uFEFF<br>/g, '<br>')
.replace(/\uFEFF<br>/g, '');
expect(actualDomString).toEqual(oneLineTrim(expectedDomString));
});
});
Cypress.Commands.add('clearMarkdownEditorContent', () => {
return cy.getMarkdownEditor()
.selectAll()
.backspace({ times: 2 });
});
function toPlainTree(domString) {
return rehype()
.use(removeSlateArtifacts)
.data('settings', { fragment: true })
.processSync(domString)
.contents;
}
function getActualBlockChildren(node) {
if (node.tagName === 'span') {
return node.children.flatMap(getActualBlockChildren);
}
if (node.children) {
return { ...node, children: node.children.flatMap(getActualBlockChildren) };
}
return node;
}
function removeSlateArtifacts() {
return function transform(tree) {
visit(tree, 'element', node => {
// remove all element attributes
delete node.properties;
// remove slate padding spans to simplify test cases
if (['h1', 'p'].includes(node.tagName)) {
node.children = node.children.flatMap(getActualBlockChildren);
}
});
}
}
function getTextNode(el, match){
const walk = document.createTreeWalker(el, NodeFilter.SHOW_TEXT, null, false);
if (!match) {
return walk.nextNode();
}
const nodes = [];
let node;
while(node = walk.nextNode()) {
if (node.wholeText.includes(match)) {
return node;
}
}
}
function setBaseAndExtent(...args) {
const document = args[0].ownerDocument;
document.getSelection().removeAllRanges();
document.getSelection().setBaseAndExtent(...args);
}

View File

@ -12,9 +12,11 @@
// You can read more here:
// https://on.cypress.io/configuration
// ***********************************************************
require('cypress-plugin-tab');
// Import commands.js using ES2015 syntax:
import './commands'
import './commands';
// Alternatively you can use CommonJS syntax:
// require('./commands')
import 'cypress-jest-adapter';

View File

@ -1,7 +1,7 @@
const workflowStatus = { draft: 'Drafts', review: 'In Review', ready: 'Ready' };
const editorStatus = { draft: 'Draft', review: 'In review', ready: 'Ready' };
const setting1 = { limit: 10, author: 'John Doe' };
const setting2 = { name: 'Andrew Wommack', description: 'A Gospel Teacher' };
const setting2 = { name: 'Jane Doe', description: 'description' };
const publishTypes = { publishNow: 'Publish now' };
const notifications = {
saved: 'Entry saved',

View File

@ -51,16 +51,10 @@ function updateWorkflowStatus({ title }, fromColumnHeading, toColumnHeading) {
cy.contains('h2', fromColumnHeading)
.parent()
.contains('a', title)
.trigger('dragstart', {
dataTransfer: {},
force: true,
});
.drag();
cy.contains('h2', toColumnHeading)
.parent()
.trigger('drop', {
dataTransfer: {},
force: true,
});
.drop();
assertNotification(notifications.updated);
}
@ -171,7 +165,7 @@ function populateEntry(entry) {
for (let key of keys) {
const value = entry[key];
if (key === 'body') {
cy.get('[data-slate-editor]')
cy.getMarkdownEditor()
.click()
.clear()
.type(value);
@ -288,7 +282,7 @@ function validateListFields({ name, description }) {
cy.get('input')
.eq(2)
.type(name);
cy.get('[data-slate-editor]')
cy.getMarkdownEditor()
.eq(2)
.type(description);
cy.contains('button', 'Save').click();

View File

@ -20,7 +20,7 @@
"test:unit": "cross-env NODE_ENV=test jest --no-cache",
"test:e2e": "run-s build:demo test:e2e:run",
"test:e2e:ci": "run-s build:demo test:e2e:run-ci",
"test:e2e:dev": "start-test develop 8080 test:e2e:exec-dev",
"test:e2e:dev": "run-p clean && start-test develop 8080 test:e2e:exec-dev",
"test:e2e:serve": "http-server dev-test",
"test:e2e:exec": "cypress run",
"test:e2e:exec-ci": "cypress run --record --parallel --ci-build-id $GITHUB_SHA --group 'GitHub CI' displayName: 'Run Cypress tests'",
@ -31,11 +31,11 @@
"mock:server:start": "node -e 'require(\"./cypress/utils/mock-server\").start()'",
"mock:server:stop": "node -e 'require(\"./cypress/utils/mock-server\").stop()'",
"lint": "run-p -c --aggregate-output \"lint:*\"",
"lint-quiet": "run-p -c --aggregate-output \"lint:* -- --quiet\"",
"lint-quiet": "run-p -c --aggregate-output \"lint:* --quiet\"",
"lint:css": "stylelint --ignore-path .gitignore \"{packages/**/*.{css,js},website/**/*.css}\"",
"lint:js": "eslint --color --ignore-path .gitignore \"{{packages,scripts,website}/**/,}*.js\"",
"lint:format": "prettier \"{{packages,scripts,website}/**/,}*.{js,css}\" --list-different",
"format": "run-s \"lint:css -- --fix --quiet\" \"lint:js -- --fix --quiet\" \"format:prettier -- --write\"",
"format": "run-s \"lint:js --fix --quiet\" \"format:prettier --write\"",
"format:prettier": "prettier \"{{packages,scripts,website}/**/,}*.{js,css}\"",
"publish": "run-s publish:before-manual-version publish:after-manual-version",
"publish:ci": "run-s publish:prepare \"publish:version --yes\" build publish:push-git \"publish:from-git --yes\"",
@ -72,20 +72,24 @@
"@babel/core": "^7.3.4",
"@babel/plugin-proposal-class-properties": "^7.3.4",
"@babel/plugin-proposal-export-default-from": "^7.2.0",
"@babel/plugin-proposal-nullish-coalescing-operator": "^7.4.4",
"@babel/plugin-proposal-object-rest-spread": "^7.3.4",
"@babel/plugin-proposal-optional-chaining": "^7.2.0",
"@babel/plugin-syntax-dynamic-import": "^7.2.0",
"@babel/preset-env": "^7.3.4",
"@babel/preset-react": "^7.0.0",
"@commitlint/cli": "^8.2.0",
"@commitlint/cli": "^8.3.3",
"@commitlint/config-conventional": "^8.2.0",
"@octokit/rest": "^16.28.7",
"@testing-library/jest-dom": "^4.2.3",
"@testing-library/react": "^9.3.2",
"all-contributors-cli": "^6.0.0",
"babel-core": "^7.0.0-bridge.0",
"babel-eslint": "^10.0.1",
"babel-eslint": "^11.0.0-beta.0",
"babel-jest": "^24.5.0",
"babel-loader": "^8.0.5",
"babel-plugin-emotion": "^10.0.9",
"babel-plugin-inline-json-import": "^0.3.2",
"babel-plugin-inline-react-svg": "^1.1.0",
"babel-plugin-lodash": "^3.3.4",
"babel-plugin-module-resolver": "^3.2.0",
@ -98,6 +102,8 @@
"cross-env": "^6.0.0",
"css-loader": "^3.0.0",
"cypress": "^3.4.1",
"cypress-jest-adapter": "^0.0.3",
"cypress-plugin-tab": "^1.0.0",
"dom-testing-library": "^4.0.0",
"dotenv": "^8.0.0",
"eslint": "^5.15.1",
@ -121,25 +127,36 @@
"npm-run-all": "^4.1.5",
"prettier": "^1.19.1",
"react-test-renderer": "^16.8.4",
"rehype": "^7.0.0",
"rimraf": "^3.0.0",
"simple-git": "^1.124.0",
"start-server-and-test": "^1.7.11",
"style-loader": "^0.23.1",
"stylelint": "^9.10.1",
"stylelint-config-recommended": "^2.1.0",
"stylelint-config-styled-components": "^0.1.1",
"stylelint-processor-styled-components": "^1.5.2",
"svg-inline-loader": "^0.8.0",
"to-string-loader": "^1.1.5",
"unist-util-visit": "^1.4.0",
"webpack": "^4.29.6",
"webpack-cli": "^3.2.3",
"webpack-dev-server": "^3.2.1"
},
"workspaces": [
"packages/*"
],
"workspaces": {
"packages": [
"packages/*"
],
"nohoist": [
"husky",
"run-node"
]
},
"private": true,
"dependencies": {
"@emotion/babel-preset-css-prop": "^10.0.9",
"emotion": "^10.0.9",
"eslint-config-prettier": "^6.5.0",
"eslint-plugin-babel": "^5.3.0",
"lerna": "^3.15.0"
},
"husky": {

View File

@ -1,12 +0,0 @@
import { NetlifyCmsCore as CMS } from 'netlify-cms-core';
import { GitHubBackend } from 'netlify-cms-backend-github';
import { GitLabBackend } from 'netlify-cms-backend-gitlab';
import { GitGatewayBackend } from 'netlify-cms-backend-git-gateway';
import { BitbucketBackend } from 'netlify-cms-backend-bitbucket';
import { TestBackend } from 'netlify-cms-backend-test';
CMS.registerBackend('git-gateway', GitGatewayBackend);
CMS.registerBackend('github', GitHubBackend);
CMS.registerBackend('gitlab', GitLabBackend);
CMS.registerBackend('bitbucket', BitbucketBackend);
CMS.registerBackend('test-repo', TestBackend);

View File

@ -1,4 +0,0 @@
import { NetlifyCmsCore as CMS } from 'netlify-cms-core';
import image from 'netlify-cms-editor-component-image';
CMS.registerEditorComponent(image);

View File

@ -1,4 +1,14 @@
// Core
import { NetlifyCmsCore as CMS } from 'netlify-cms-core';
// Backends
import { GitHubBackend } from 'netlify-cms-backend-github';
import { GitLabBackend } from 'netlify-cms-backend-gitlab';
import { GitGatewayBackend } from 'netlify-cms-backend-git-gateway';
import { BitbucketBackend } from 'netlify-cms-backend-bitbucket';
import { TestBackend } from 'netlify-cms-backend-test';
// Widgets
import NetlifyCmsWidgetString from 'netlify-cms-widget-string';
import NetlifyCmsWidgetNumber from 'netlify-cms-widget-number';
import NetlifyCmsWidgetText from 'netlify-cms-widget-text';
@ -14,6 +24,18 @@ import NetlifyCmsWidgetMap from 'netlify-cms-widget-map';
import NetlifyCmsWidgetDate from 'netlify-cms-widget-date';
import NetlifyCmsWidgetDatetime from 'netlify-cms-widget-datetime';
// Editor Components
import image from 'netlify-cms-editor-component-image';
// Locales
import { en } from 'netlify-cms-locales';
// Register all the things
CMS.registerBackend('git-gateway', GitGatewayBackend);
CMS.registerBackend('github', GitHubBackend);
CMS.registerBackend('gitlab', GitLabBackend);
CMS.registerBackend('bitbucket', BitbucketBackend);
CMS.registerBackend('test-repo', TestBackend);
CMS.registerWidget([
NetlifyCmsWidgetString.Widget(),
NetlifyCmsWidgetNumber.Widget(),
@ -30,3 +52,5 @@ CMS.registerWidget([
NetlifyCmsWidgetDate.Widget(),
NetlifyCmsWidgetDatetime.Widget(),
]);
CMS.registerEditorComponent(image);
CMS.registerLocale('en', en);

View File

@ -1,13 +1,8 @@
import { NetlifyCmsCore as CMS } from 'netlify-cms-core';
import './backends';
import './widgets';
import './editor-components';
import './locales';
import './extensions.js';
// Log version
if (typeof window !== 'undefined') {
/**
* Log the version number.
*/
if (typeof NETLIFY_CMS_APP_VERSION === 'string') {
console.log(`netlify-cms-app ${NETLIFY_CMS_APP_VERSION}`);
}

View File

@ -33,6 +33,7 @@
"gotrue-js": "^0.9.24",
"gray-matter": "^4.0.2",
"history": "^4.7.2",
"immer": "^3.1.3",
"js-base64": "^2.5.1",
"js-yaml": "^3.12.2",
"jwt-decode": "^2.1.0",

View File

@ -1,9 +1,8 @@
/** @jsx jsx */
import PropTypes from 'prop-types';
import React from 'react';
import ImmutablePropTypes from 'react-immutable-proptypes';
import styled from '@emotion/styled';
import { jsx, css } from '@emotion/core';
import { css } from '@emotion/core';
import { translate } from 'react-polyglot';
import { NavLink } from 'react-router-dom';
import {

View File

@ -399,6 +399,7 @@ export class Editor extends React.Component {
logoutUser,
deployPreview,
loadDeployPreview,
draftKey,
slug,
t,
} = this.props;
@ -421,6 +422,7 @@ export class Editor extends React.Component {
return (
<EditorInterface
draftKey={draftKey}
entry={entryDraft.get('entry')}
getAsset={boundGetAsset}
collection={collection}
@ -474,6 +476,7 @@ function mapStateToProps(state, ownProps) {
const currentStatus = unpublishedEntry && unpublishedEntry.getIn(['metaData', 'status']);
const deployPreview = selectDeployPreview(state, collectionName, slug);
const localBackup = entryDraft.get('localBackup');
const draftKey = entryDraft.get('key');
return {
collection,
collections,
@ -493,6 +496,7 @@ function mapStateToProps(state, ownProps) {
currentStatus,
deployPreview,
localBackup,
draftKey,
};
}

View File

@ -1,13 +1,12 @@
/** @jsx jsx */
import React from 'react';
import PropTypes from 'prop-types';
import ImmutablePropTypes from 'react-immutable-proptypes';
import { translate } from 'react-polyglot';
import { jsx, ClassNames, Global, css as coreCss } from '@emotion/core';
import { ClassNames, Global, css as coreCss } from '@emotion/core';
import styled from '@emotion/styled';
import { partial, uniqueId } from 'lodash';
import { connect } from 'react-redux';
import { colors, colorsRaw, transitions, lengths, borders } from 'netlify-cms-ui-default';
import { FieldLabel, colors, transitions, lengths, borders } from 'netlify-cms-ui-default';
import { resolveWidget, getEditorComponents } from 'Lib/registry';
import { clearFieldErrors, loadEntry } from 'Actions/entries';
import { addAsset } from 'Actions/media';
@ -27,48 +26,6 @@ import Widget from './Widget';
* this.
*/
const styleStrings = {
label: `
color: ${colors.controlLabel};
background-color: ${colors.textFieldBorder};
display: inline-block;
font-size: 12px;
text-transform: uppercase;
font-weight: 600;
border: 0;
border-radius: 3px 3px 0 0;
padding: 3px 6px 2px;
margin: 0;
transition: all ${transitions.main};
position: relative;
/**
* Faux outside curve into top of input
*/
&:before,
&:after {
content: '';
display: block;
position: absolute;
top: 0;
right: -4px;
height: 100%;
width: 4px;
background-color: inherit;
}
&:after {
border-bottom-left-radius: 3px;
background-color: #fff;
}
`,
labelActive: `
background-color: ${colors.active};
color: ${colors.textLight};
`,
labelError: `
background-color: ${colors.errorText};
color: ${colorsRaw.white};
`,
widget: `
display: block;
width: 100%;
@ -85,6 +42,7 @@ const styleStrings = {
position: relative;
font-size: 15px;
line-height: 1.5;
overflow: hidden;
select& {
text-indent: 14px;
@ -155,6 +113,8 @@ class EditorControl extends React.Component {
clearFieldErrors: PropTypes.func.isRequired,
loadEntry: PropTypes.func.isRequired,
t: PropTypes.func.isRequired,
isEditorComponent: PropTypes.bool,
isNewEditorComponent: PropTypes.bool,
};
state = {
@ -186,6 +146,10 @@ class EditorControl extends React.Component {
clearSearch,
clearFieldErrors,
loadEntry,
className,
isSelected,
isEditorComponent,
isNewEditorComponent,
t,
} = this.props;
const widgetName = field.get('widget');
@ -199,11 +163,11 @@ class EditorControl extends React.Component {
return (
<ClassNames>
{({ css, cx }) => (
<ControlContainer>
<ControlContainer className={className}>
{widget.globalStyles && <Global styles={coreCss`${widget.globalStyles}`} />}
<ControlErrorsList>
{errors &&
errors.map(
{errors && (
<ControlErrorsList>
{errors.map(
error =>
error.message &&
typeof error.message === 'string' && (
@ -212,25 +176,15 @@ class EditorControl extends React.Component {
</li>
),
)}
</ControlErrorsList>
<label
className={cx(
css`
${styleStrings.label};
`,
this.state.styleActive &&
css`
${styleStrings.labelActive};
`,
!!errors &&
css`
${styleStrings.labelError};
`,
)}
</ControlErrorsList>
)}
<FieldLabel
isActive={isSelected || this.state.styleActive}
hasErrors={!!errors}
htmlFor={this.uniqueFieldId}
>
{`${field.get('label', field.get('name'))}${isFieldOptional ? ' (optional)' : ''}`}
</label>
</FieldLabel>
<Widget
classNameWrapper={cx(
css`
@ -239,7 +193,7 @@ class EditorControl extends React.Component {
{
[css`
${styleStrings.widgetActive};
`]: this.state.styleActive,
`]: isSelected || this.state.styleActive,
},
{
[css`
@ -273,10 +227,11 @@ class EditorControl extends React.Component {
onRemoveInsertedMedia={removeInsertedMedia}
onAddAsset={addAsset}
getAsset={boundGetAsset}
hasActiveStyle={this.state.styleActive}
hasActiveStyle={isSelected || this.state.styleActive}
setActiveStyle={() => this.setState({ styleActive: true })}
setInactiveStyle={() => this.setState({ styleActive: false })}
resolveWidget={resolveWidget}
widget={widget}
getEditorComponents={getEditorComponents}
ref={processControlRef && partial(processControlRef, field)}
controlRef={controlRef}
@ -289,10 +244,12 @@ class EditorControl extends React.Component {
isFetching={isFetching}
fieldsErrors={fieldsErrors}
onValidateObject={onValidateObject}
isEditorComponent={isEditorComponent}
isNewEditorComponent={isNewEditorComponent}
t={t}
/>
{fieldHint && (
<ControlHint active={this.state.styleActive} error={!!errors}>
<ControlHint active={isSelected || this.state.styleActive} error={!!errors}>
{fieldHint}
</ControlHint>
)}

View File

@ -44,6 +44,7 @@ export default class Widget extends Component {
onRemoveInsertedMedia: PropTypes.func.isRequired,
getAsset: PropTypes.func.isRequired,
resolveWidget: PropTypes.func.isRequired,
widget: PropTypes.object.isRequired,
getEditorComponents: PropTypes.func.isRequired,
isFetching: PropTypes.bool,
controlRef: PropTypes.func,
@ -56,6 +57,8 @@ export default class Widget extends Component {
loadEntry: PropTypes.func.isRequired,
t: PropTypes.func.isRequired,
onValidateObject: PropTypes.func,
isEditorComponent: PropTypes.bool,
isNewEditorComponent: PropTypes.bool,
};
shouldComponentUpdate(nextProps) {
@ -238,6 +241,7 @@ export default class Widget extends Component {
editorControl,
uniqueFieldId,
resolveWidget,
widget,
getEditorComponents,
query,
queryHits,
@ -247,6 +251,8 @@ export default class Widget extends Component {
loadEntry,
fieldsErrors,
controlRef,
isEditorComponent,
isNewEditorComponent,
t,
} = this.props;
return React.createElement(controlComponent, {
@ -275,6 +281,7 @@ export default class Widget extends Component {
hasActiveStyle,
editorControl,
resolveWidget,
widget,
getEditorComponents,
query,
queryHits,
@ -282,6 +289,8 @@ export default class Widget extends Component {
clearFieldErrors,
isFetching,
loadEntry,
isEditorComponent,
isNewEditorComponent,
fieldsErrors,
controlRef,
t,

View File

@ -4,12 +4,11 @@ import ImmutablePropTypes from 'react-immutable-proptypes';
import { css, Global } from '@emotion/core';
import styled from '@emotion/styled';
import SplitPane from 'react-split-pane';
import { colors, colorsRaw, components, transitions } from 'netlify-cms-ui-default';
import { colors, colorsRaw, components, transitions, IconButton } from 'netlify-cms-ui-default';
import { ScrollSync, ScrollSyncPane } from 'react-scroll-sync';
import EditorControlPane from './EditorControlPane/EditorControlPane';
import EditorPreviewPane from './EditorPreviewPane/EditorPreviewPane';
import EditorToolbar from './EditorToolbar';
import EditorToggle from './EditorToggle';
const PREVIEW_VISIBLE = 'cms.preview-visible';
const SCROLL_SYNC_ENABLED = 'cms.scroll-sync-enabled';
@ -27,6 +26,10 @@ const styles = {
`,
};
const EditorToggle = styled(IconButton)`
margin-bottom: 12px;
`;
const ReactSplitPaneGlobalStyles = () => (
<Global
styles={css`
@ -175,6 +178,7 @@ class EditorInterface extends Component {
onLogoutClick,
loadDeployPreview,
deployPreview,
draftKey,
} = this.props;
const { previewVisible, scrollSyncEnabled, showEventBlocker } = this.state;
@ -255,22 +259,26 @@ class EditorInterface extends Component {
loadDeployPreview={loadDeployPreview}
deployPreview={deployPreview}
/>
<Editor>
<Editor key={draftKey}>
<ViewControls>
<EditorToggle
enabled={collectionPreviewEnabled}
active={previewVisible}
onClick={this.handleTogglePreview}
icon="eye"
title="Toggle preview"
/>
<EditorToggle
enabled={collectionPreviewEnabled && previewVisible}
active={scrollSyncEnabled}
onClick={this.handleToggleScrollSync}
icon="scroll"
title="Sync scrolling"
/>
{collectionPreviewEnabled && (
<EditorToggle
isActive={previewVisible}
onClick={this.handleTogglePreview}
size="large"
type="eye"
title="Toggle preview"
/>
)}
{collectionPreviewEnabled && previewVisible && (
<EditorToggle
isActive={scrollSyncEnabled}
onClick={this.handleToggleScrollSync}
size="large"
type="scroll"
title="Sync scrolling"
/>
)}
</ViewControls>
{collectionPreviewEnabled && this.state.previewVisible ? (
editorWithPreview
@ -312,6 +320,7 @@ EditorInterface.propTypes = {
onLogoutClick: PropTypes.func.isRequired,
deployPreview: ImmutablePropTypes.map,
loadDeployPreview: PropTypes.func.isRequired,
draftKey: PropTypes.string.isRequired,
};
export default EditorInterface;

View File

@ -26,6 +26,7 @@ export default class PreviewPane extends React.Component {
const { getAsset, entry } = props;
const widget = resolveWidget(field.get('widget'));
const key = idx ? field.get('name') + '_' + idx : field.get('name');
const valueIsInMap = value && !widget.allowMapValue && Map.isMap(value);
/**
* Use an HOC to provide conditional updates for all previews.
@ -36,7 +37,7 @@ export default class PreviewPane extends React.Component {
key={key}
field={field}
getAsset={getAsset}
value={value && Map.isMap(value) ? value.get(field.get('name')) : value}
value={valueIsInMap ? value.get(field.get('name')) : value}
entry={entry}
fieldsMetaData={metadata}
/>

View File

@ -1,36 +0,0 @@
import React from 'react';
import PropTypes from 'prop-types';
import styled from '@emotion/styled';
import { Icon, colors, colorsRaw, shadows, buttons } from 'netlify-cms-ui-default';
const EditorToggleButton = styled.button`
${buttons.button};
${shadows.dropMiddle};
background-color: ${colorsRaw.white};
color: ${props => colors[props.isActive ? `active` : `inactive`]};
border-radius: 32px;
display: flex;
justify-content: center;
align-items: center;
width: 40px;
height: 40px;
padding: 0;
margin-bottom: 12px;
`;
const EditorToggle = ({ enabled, active, onClick, icon, title }) =>
!enabled ? null : (
<EditorToggleButton onClick={onClick} isActive={active} title={title}>
<Icon type={icon} size="large" />
</EditorToggleButton>
);
EditorToggle.propTypes = {
enabled: PropTypes.bool,
active: PropTypes.bool,
onClick: PropTypes.func.isRequired,
icon: PropTypes.string.isRequired,
title: PropTypes.string.isRequired,
};
export default EditorToggle;

View File

@ -1,8 +1,7 @@
/** @jsx jsx */
// eslint-disable-next-line no-unused-vars
import React from 'react';
import PropTypes from 'prop-types';
import { jsx, css, Global } from '@emotion/core';
import { css, Global } from '@emotion/core';
import { translate } from 'react-polyglot';
import reduxNotificationsStyles from 'redux-notifications/lib/styles.css';
import { shadows, colors, lengths } from 'netlify-cms-ui-default';

View File

@ -1,8 +1,7 @@
/** @jsx jsx */
import PropTypes from 'prop-types';
import React from 'react';
import ImmutablePropTypes from 'react-immutable-proptypes';
import { jsx, css } from '@emotion/core';
import { css } from '@emotion/core';
import styled from '@emotion/styled';
import moment from 'moment';
import { translate } from 'react-polyglot';

View File

@ -1,4 +1,5 @@
import { Map } from 'immutable';
import produce from 'immer';
import { oneLine } from 'common-tags';
import EditorComponent from 'ValueObjects/EditorComponent';
@ -23,6 +24,7 @@ export default {
getPreviewTemplate,
registerWidget,
getWidget,
getWidgets,
resolveWidget,
registerEditorComponent,
getEditorComponents,
@ -81,10 +83,12 @@ export function registerWidget(name, control, preview) {
name: widgetName,
controlComponent: control,
previewComponent: preview,
allowMapValue,
globalStyles,
...options
} = name;
if (registry.widgets[widgetName]) {
console.error(oneLine`
console.warn(oneLine`
Multiple widgets registered with name "${widgetName}". Only the last widget registered with
this name will be used.
`);
@ -92,7 +96,7 @@ export function registerWidget(name, control, preview) {
if (!control) {
throw Error(`Widget "${widgetName}" registered without \`controlComponent\`.`);
}
registry.widgets[widgetName] = { control, preview, globalStyles };
registry.widgets[widgetName] = { control, preview, globalStyles, allowMapValue, ...options };
} else {
console.error('`registerWidget` failed, called with incorrect arguments.');
}
@ -100,6 +104,11 @@ export function registerWidget(name, control, preview) {
export function getWidget(name) {
return registry.widgets[name];
}
export function getWidgets() {
return produce(Object.entries(registry.widgets), draft => {
return draft.map(([key, value]) => ({ name: key, ...value }));
});
}
export function resolveWidget(name) {
return getWidget(name || 'string') || getWidget('unknown');
}
@ -109,7 +118,19 @@ export function resolveWidget(name) {
*/
export function registerEditorComponent(component) {
const plugin = EditorComponent(component);
registry.editorComponents = registry.editorComponents.set(plugin.get('id'), plugin);
if (plugin.type === 'code-block') {
const codeBlock = registry.editorComponents.find(c => c.type === 'code-block');
if (codeBlock) {
console.warn(oneLine`
Only one editor component of type "code-block" may be registered. Previously registered code
block component(s) will be overwritten.
`);
registry.editorComponents = registry.editorComponents.delete(codeBlock.id);
}
}
registry.editorComponents = registry.editorComponents.set(plugin.id, plugin);
}
export function getEditorComponents() {
return registry.editorComponents;

View File

@ -2,12 +2,15 @@ import { Map, List, fromJS } from 'immutable';
import * as actions from 'Actions/entries';
import reducer from '../entryDraft';
jest.mock('uuid/v4', () => jest.fn(() => '1'));
const initialState = Map({
entry: Map(),
mediaFiles: List(),
fieldsMetaData: Map(),
fieldsErrors: Map(),
hasChanged: false,
key: '',
});
const entry = {
@ -23,7 +26,8 @@ const entry = {
describe('entryDraft reducer', () => {
describe('DRAFT_CREATE_FROM_ENTRY', () => {
it('should create draft from the entry', () => {
expect(reducer(initialState, actions.createDraftFromEntry(fromJS(entry)))).toEqual(
const state = reducer(initialState, actions.createDraftFromEntry(fromJS(entry)));
expect(state).toEqual(
fromJS({
entry: {
...entry,
@ -33,6 +37,7 @@ describe('entryDraft reducer', () => {
fieldsMetaData: Map(),
fieldsErrors: Map(),
hasChanged: false,
key: '1',
}),
);
});
@ -40,7 +45,8 @@ describe('entryDraft reducer', () => {
describe('DRAFT_CREATE_EMPTY', () => {
it('should create a new draft ', () => {
expect(reducer(initialState, actions.emptyDraftCreated(fromJS(entry)))).toEqual(
const state = reducer(initialState, actions.emptyDraftCreated(fromJS(entry)));
expect(state).toEqual(
fromJS({
entry: {
...entry,
@ -50,6 +56,7 @@ describe('entryDraft reducer', () => {
fieldsMetaData: Map(),
fieldsErrors: Map(),
hasChanged: false,
key: '1',
}),
);
});
@ -127,6 +134,7 @@ describe('entryDraft reducer', () => {
fieldsMetaData: {},
fieldsErrors: {},
hasChanged: false,
key: '',
});
});
});
@ -144,6 +152,7 @@ describe('entryDraft reducer', () => {
fieldsMetaData: {},
fieldsErrors: {},
hasChanged: false,
key: '',
});
});
});
@ -161,6 +170,7 @@ describe('entryDraft reducer', () => {
fieldsMetaData: {},
fieldsErrors: {},
hasChanged: false,
key: '',
});
});
});
@ -181,6 +191,7 @@ describe('entryDraft reducer', () => {
fieldsMetaData: {},
fieldsErrors: {},
hasChanged: true,
key: '1',
});
});
});
@ -201,6 +212,7 @@ describe('entryDraft reducer', () => {
entry,
mediaFiles: [{ id: '1' }],
},
key: '',
});
});
});

View File

@ -1,4 +1,5 @@
import { Map, List, fromJS } from 'immutable';
import uuid from 'uuid/v4';
import {
DRAFT_CREATE_FROM_ENTRY,
DRAFT_CREATE_EMPTY,
@ -30,6 +31,7 @@ const initialState = Map({
fieldsMetaData: Map(),
fieldsErrors: Map(),
hasChanged: false,
key: '',
});
const entryDraftReducer = (state = Map(), action) => {
@ -46,6 +48,7 @@ const entryDraftReducer = (state = Map(), action) => {
state.set('fieldsMetaData', action.payload.metadata || Map());
state.set('fieldsErrors', Map());
state.set('hasChanged', false);
state.set('key', uuid());
});
case DRAFT_CREATE_EMPTY:
// New Entry
@ -56,6 +59,7 @@ const entryDraftReducer = (state = Map(), action) => {
state.set('fieldsMetaData', Map());
state.set('fieldsErrors', Map());
state.set('hasChanged', false);
state.set('key', uuid());
});
case DRAFT_CREATE_FROM_LOCAL_BACKUP:
// Local Backup
@ -69,6 +73,7 @@ const entryDraftReducer = (state = Map(), action) => {
state.set('fieldsMetaData', Map());
state.set('fieldsErrors', Map());
state.set('hasChanged', true);
state.set('key', uuid());
});
case DRAFT_CREATE_DUPLICATE_FROM_ENTRY:
// Duplicate Entry

View File

@ -1,39 +1,35 @@
import { Record, fromJS } from 'immutable';
import { fromJS } from 'immutable';
import { isFunction } from 'lodash';
const catchesNothing = /.^/;
/* eslint-disable no-unused-vars */
const EditorComponent = Record({
id: null,
label: 'unnamed component',
icon: 'exclamation-triangle',
fields: [],
pattern: catchesNothing,
fromBlock(match) {
return {};
},
toBlock(attributes) {
return 'Plugin';
},
toPreview(attributes) {
return 'Plugin';
},
});
/* eslint-enable */
const bind = fn => isFunction(fn) && fn.bind(null);
export default function createEditorComponent(config) {
const configObj = new EditorComponent({
id: config.id || config.label.replace(/[^A-Z0-9]+/gi, '_'),
label: config.label,
icon: config.icon,
fields: fromJS(config.fields),
pattern: config.pattern,
fromBlock: isFunction(config.fromBlock) ? config.fromBlock.bind(null) : null,
toBlock: isFunction(config.toBlock) ? config.toBlock.bind(null) : null,
toPreview: isFunction(config.toPreview)
? config.toPreview.bind(null)
: config.toBlock.bind(null),
});
const {
id = null,
label = 'unnamed component',
icon = 'exclamation-triangle',
type = 'shortcode',
widget = 'object',
pattern = catchesNothing,
fields = [],
fromBlock,
toBlock,
toPreview,
...remainingConfig
} = config;
return configObj;
return {
id: id || label.replace(/[^A-Z0-9]+/gi, '_'),
label,
type,
icon,
widget,
pattern,
fromBlock: bind(fromBlock) || (() => ({})),
toBlock: bind(toBlock) || (() => 'Plugin'),
toPreview: bind(toPreview) || (!widget && (bind(toBlock) || (() => 'Plugin'))),
fields: fromJS(fields),
...remainingConfig,
};
}

View File

@ -5,7 +5,7 @@
"repository": "https://github.com/netlify/netlify-cms/tree/master/packages/netlify-cms-default-exports",
"bugs": "https://github.com/netlify/netlify-cms/issues",
"module": "dist/esm/index.js",
"main": "dist/netlify-cms-editor-component-image.js",
"main": "dist/netlify-cms-default-exports.js",
"license": "MIT",
"keywords": [
"netlify",

View File

@ -0,0 +1,58 @@
import styled from '@emotion/styled';
import { colors, colorsRaw, transitions, text } from './styles';
const stateColors = {
default: {
background: colors.textFieldBorder,
text: colors.controlLabel,
},
active: {
background: colors.active,
text: colors.textLight,
},
error: {
background: colors.errorText,
text: colorsRaw.white,
},
};
const getStateColors = ({ isActive, hasErrors }) => {
if (hasErrors) return stateColors.error;
if (isActive) return stateColors.active;
return stateColors.default;
};
const FieldLabel = styled.label`
${text.fieldLabel};
color: ${props => getStateColors(props).text};
background-color: ${props => getStateColors(props).background};
display: inline-block;
border: 0;
border-radius: 3px 3px 0 0;
padding: 3px 6px 2px;
margin: 0;
transition: all ${transitions.main};
position: relative;
/**
* Faux outside curve into top of input
*/
&:before,
&:after {
content: '';
display: block;
position: absolute;
top: 0;
right: -4px;
height: 100%;
width: 4px;
background-color: inherit;
}
&:after {
border-bottom-left-radius: 3px;
background-color: #fff;
}
`;
export default FieldLabel;

View File

@ -0,0 +1,37 @@
import React from 'react';
import styled from '@emotion/styled';
import Icon from './Icon';
import { buttons, colors, colorsRaw, shadows } from './styles';
const sizes = {
small: '28px',
large: '40px',
};
const ButtonRound = styled.button`
${buttons.button};
${shadows.dropMiddle};
background-color: ${colorsRaw.white};
color: ${props => colors[props.isActive ? `active` : `inactive`]};
border-radius: 32px;
display: flex;
justify-content: center;
align-items: center;
width: ${props => sizes[props.size]};
height: ${props => sizes[props.size]};
padding: 0;
`;
const IconButton = ({ size, isActive, type, onClick, className, title }) => (
<ButtonRound
size={size}
isActive={isActive}
className={className}
onClick={onClick}
title={title}
>
<Icon type={type} size={size} />
</ButtonRound>
);
export default IconButton;

View File

@ -2,6 +2,8 @@ import Dropdown, { DropdownItem, DropdownButton, StyledDropdownButton } from './
import Icon from './Icon';
import ListItemTopBar from './ListItemTopBar';
import Loader from './Loader';
import FieldLabel from './FieldLabel';
import IconButton from './IconButton';
import Toggle, { ToggleContainer, ToggleBackground, ToggleHandle } from './Toggle';
import AuthenticationPage from './AuthenticationPage';
import WidgetPreviewContainer from './WidgetPreviewContainer';
@ -14,6 +16,7 @@ import {
lengths,
components,
buttons,
text,
shadows,
borders,
transitions,
@ -28,7 +31,9 @@ export const NetlifyCmsUiDefault = {
DropdownButton,
StyledDropdownButton,
ListItemTopBar,
FieldLabel,
Icon,
IconButton,
Loader,
Toggle,
ToggleContainer,
@ -44,6 +49,7 @@ export const NetlifyCmsUiDefault = {
components,
buttons,
shadows,
text,
borders,
transitions,
effects,
@ -56,7 +62,9 @@ export {
DropdownButton,
StyledDropdownButton,
ListItemTopBar,
FieldLabel,
Icon,
IconButton,
Loader,
Toggle,
ToggleContainer,
@ -72,6 +80,7 @@ export {
components,
buttons,
shadows,
text,
borders,
transitions,
effects,

View File

@ -120,6 +120,15 @@ const shadows = {
`,
};
const text = {
fieldLabel: css`
font-size: 12px;
text-transform: uppercase;
font-weight: 600;
color: ${colors.controlLabel};
`,
};
const gradients = {
checkerboard: `
linear-gradient(
@ -465,6 +474,7 @@ export {
lengths,
components,
buttons,
text,
shadows,
borders,
transitions,

View File

@ -1,8 +1,7 @@
/** @jsx jsx */
import PropTypes from 'prop-types';
import React from 'react';
import ImmutablePropTypes from 'react-immutable-proptypes';
import { jsx, css } from '@emotion/core';
import { css } from '@emotion/core';
import { Toggle, ToggleBackground, colors } from 'netlify-cms-ui-default';
const BooleanBackground = ({ isActive, ...props }) => (

View File

@ -0,0 +1,11 @@
# Docs coming soon!
Netlify CMS was recently converted from a single npm package to a "monorepo" of over 20 packages.
That's over 20 Readme's! We haven't created one for this package yet, but we will soon.
In the meantime, you can:
1. Check out the [main readme](https://github.com/netlify/netlify-cms/#readme) or the [documentation
site](https://www.netlifycms.org) for more info.
2. Reach out to the [community chat](https://gitter.im/netlify/netlifycms/) if you need help.
3. Help out and [write the readme yourself](https://github.com/netlify/netlify-cms/edit/master/packages/netlify-cms-widget-code/README.md)!

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,39 @@
{
"name": "netlify-cms-widget-code",
"description": "Widget for editing code in Netlify CMS",
"version": "1.0.0",
"homepage": "https://www.netlifycms.org/docs/widgets/#code",
"repository": "https://github.com/netlify/netlify-cms/tree/master/packages/netlify-cms-widget-code",
"bugs": "https://github.com/netlify/netlify-cms/issues",
"module": "dist/esm/index.js",
"main": "dist/netlify-cms-widget-code.js",
"license": "MIT",
"keywords": [
"netlify",
"netlify-cms",
"widget",
"code",
"codemirror",
"editor",
"code editor"
],
"sideEffects": false,
"scripts": {
"develop": "yarn build:esm --watch",
"build": "cross-env NODE_ENV=production webpack",
"build:esm": "cross-env NODE_ENV=esm babel src --out-dir dist/esm --ignore \"**/__tests__\" --root-mode upward",
"process:languages": "node ./scripts/process-languages"
},
"peerDependencies": {
"@emotion/core": "^10.0.9",
"lodash": "^4.17.11",
"codemirror": "^5.46.0",
"react": "^16.8.4",
"netlify-cms-ui-default": "^2.6.2"
},
"dependencies": {
"re-resizable": "^4.11.0",
"react-codemirror2": "^6.0.0",
"react-select": "^2.4.3"
}
}

View File

@ -0,0 +1,45 @@
const fs = require('fs-extra');
const path = require('path');
const yaml = require('js-yaml');
const uniq = require('lodash/uniq');
const rawDataPath = '../data/languages-raw.yml';
const outputPath = '../data/languages.json';
async function fetchData() {
const filePath = path.resolve(__dirname, rawDataPath);
const fileContent = await fs.readFile(filePath);
return yaml.safeLoad(fileContent);
}
function outputData(data) {
const filePath = path.resolve(__dirname, outputPath);
return fs.writeJson(filePath, data);
}
function transform(data) {
return Object.entries(data).reduce((acc, [label, lang]) => {
const { extensions = [], aliases = [], codemirror_mode, codemirror_mime_type } = lang;
if (codemirror_mode) {
const dotlessExtensions = extensions.map(ext => ext.slice(1));
const identifiers = uniq(
[label.toLowerCase(), ...aliases, ...dotlessExtensions].filter(alias => {
if (!alias) {
return;
}
return !/[^a-zA-Z]/.test(alias);
}),
);
acc.push({ label, identifiers, codemirror_mode, codemirror_mime_type });
}
return acc;
}, []);
}
async function process() {
const data = await fetchData();
const transformedData = transform(data);
return outputData(transformedData);
}
process();

View File

@ -0,0 +1,315 @@
import React from 'react';
import PropTypes from 'prop-types';
import ImmutablePropTypes from 'react-immutable-proptypes';
import { ClassNames } from '@emotion/core';
import { Map } from 'immutable';
import { uniq, isEqual, isEmpty } from 'lodash';
import uuid from 'uuid/v4';
import { UnControlled as ReactCodeMirror } from 'react-codemirror2';
import CodeMirror from 'codemirror';
import 'codemirror/keymap/vim';
import 'codemirror/keymap/sublime';
import 'codemirror/keymap/emacs';
import codeMirrorStyles from 'codemirror/lib/codemirror.css';
import materialTheme from 'codemirror/theme/material.css';
import SettingsPane from './SettingsPane';
import SettingsButton from './SettingsButton';
import languageData from '../data/languages.json';
// TODO: relocate as a utility function
function getChangedProps(previous, next, keys) {
const propNames = keys || uniq(Object.keys(previous), Object.keys(next));
const changedProps = propNames.reduce((acc, prop) => {
if (previous[prop] !== next[prop]) {
acc[prop] = next[prop];
}
return acc;
}, {});
if (!isEmpty(changedProps)) {
return changedProps;
}
}
const languages = languageData.map(lang => ({
label: lang.label,
name: lang.identifiers[0],
mode: lang.codemirror_mode,
mimeType: lang.codemirror_mime_type,
}));
const styleString = `
padding: 0;
`;
const defaultLang = { name: '', mode: '', label: 'none' };
function valueToOption(val) {
if (typeof val === 'string') {
return { value: val, label: val };
}
return { value: val.name, label: val.label || val.name };
}
const modes = languages.map(valueToOption);
const themes = ['default', 'material'];
const settingsPersistKeys = {
theme: 'cms.codemirror.theme',
keyMap: 'cms.codemirror.keymap',
};
export default class CodeControl extends React.Component {
static propTypes = {
field: ImmutablePropTypes.map.isRequired,
onChange: PropTypes.func.isRequired,
value: PropTypes.node,
forID: PropTypes.string.isRequired,
classNameWrapper: PropTypes.string.isRequired,
widget: PropTypes.object.isRequired,
};
keys = this.getKeys(this.props.field);
state = {
isActive: false,
unknownLang: null,
lang: '',
keyMap: localStorage.getItem(settingsPersistKeys['keyMap']) || 'default',
settingsVisible: false,
codeMirrorKey: uuid(),
theme: localStorage.getItem(settingsPersistKeys['theme']) || themes[themes.length - 1],
lastKnownValue: this.valueIsMap() ? this.props.value?.get(this.keys.code) : this.props.value,
};
shouldComponentUpdate(nextProps, nextState) {
return (
!isEqual(this.state, nextState) || this.props.classNameWrapper !== nextProps.classNameWrapper
);
}
componentDidMount() {
this.setState({
lang: this.getInitialLang() || '',
});
}
componentDidUpdate(prevProps, prevState) {
this.updateCodeMirrorProps(prevState);
}
updateCodeMirrorProps(prevState) {
const keys = ['lang', 'theme', 'keyMap'];
const changedProps = getChangedProps(prevState, this.state, keys);
if (changedProps) {
this.handleChangeCodeMirrorProps(changedProps);
}
}
getLanguageByName = name => {
return languages.find(lang => lang.name === name);
};
getKeyMapOptions = () => {
return Object.keys(CodeMirror.keyMap)
.sort()
.filter(keyMap => ['emacs', 'vim', 'sublime', 'default'].includes(keyMap))
.map(keyMap => ({ value: keyMap, label: keyMap }));
};
// This widget is not fully controlled, it only takes a value through props
// upon initialization.
getInitialLang = () => {
const { value, field } = this.props;
const lang =
(this.valueIsMap() && value && value.get(this.keys.lang)) || field.get('defaultLanguage');
const langInfo = this.getLanguageByName(lang);
if (lang && !langInfo) {
this.setState({ unknownLang: lang });
}
return lang;
};
// If `allow_language_selection` is not set, default to true. Otherwise, use
// its value.
allowLanguageSelection =
!this.props.field.has('allow_language_selection') ||
!!this.props.field.get('allow_language_selection');
toValue = this.valueIsMap()
? (type, value) => (this.props.value || Map()).set(this.keys[type], value)
: (type, value) => (type === 'code' ? value : this.props.value);
// If the value is a map, keys can be customized via config.
getKeys(field) {
const defaults = {
code: 'code',
lang: 'lang',
};
// Force default keys if widget is an editor component code block.
if (this.props.isEditorComponent) {
return defaults;
}
const keys = field.get('keys', Map()).toJS();
return { ...defaults, ...keys };
}
// Determine if the persisted value is a map rather than a plain string. A map
// value allows both the code string and the language to be persisted.
valueIsMap() {
const { field, isEditorComponent } = this.props;
return !field.get('output_code_only') || isEditorComponent;
}
async handleChangeCodeMirrorProps(changedProps) {
const { onChange } = this.props;
if (changedProps.lang) {
const { mode } = this.getLanguageByName(changedProps.lang) || {};
if (mode) {
await import(`codemirror/mode/${mode}/${mode}.js`);
}
}
// Changing CodeMirror props requires re-initializing the
// detached/uncontrolled React CodeMirror component, so here we save and
// restore the selections and cursor position after the state change.
if (this.cm) {
const cursor = this.cm.doc.getCursor();
const selections = this.cm.doc.listSelections();
this.setState({ codeMirrorKey: uuid() }, () => {
this.cm.doc.setCursor(cursor);
this.cm.doc.setSelections(selections);
});
}
for (const key of ['theme', 'keyMap']) {
if (changedProps[key]) {
localStorage.setItem(settingsPersistKeys[key], changedProps[key]);
}
}
// Only persist the language change if supported - requires the value to be
// a map rather than just a code string.
if (changedProps.lang && this.valueIsMap()) {
onChange(this.toValue('lang', changedProps.lang));
}
}
handleChange(newValue) {
const cursor = this.cm.doc.getCursor();
const selections = this.cm.doc.listSelections();
this.setState({ lastKnownValue: newValue });
this.props.onChange(this.toValue('code', newValue), { cursor, selections });
}
showSettings = () => {
this.setState({ settingsVisible: true });
};
hideSettings = () => {
if (this.state.settingsVisible) {
this.setState({ settingsVisible: false });
}
this.cm.focus();
};
handleFocus = () => {
this.hideSettings();
this.props.setActiveStyle();
this.setActive();
};
handleBlur = () => {
this.setInactive();
this.props.setInactiveStyle();
};
setActive = () => this.setState({ isActive: true });
setInactive = () => this.setState({ isActive: false });
render() {
const { classNameWrapper, forID, widget, isNewEditorComponent } = this.props;
const { lang, settingsVisible, keyMap, codeMirrorKey, theme, lastKnownValue } = this.state;
const langInfo = this.getLanguageByName(lang);
const mode = langInfo?.mimeType || langInfo?.mode;
return (
<ClassNames>
{({ css, cx }) => (
<div
className={cx(
classNameWrapper,
css`
${codeMirrorStyles};
${materialTheme};
${styleString};
`,
)}
>
{!settingsVisible && <SettingsButton onClick={this.showSettings} />}
{settingsVisible && (
<SettingsPane
hideSettings={this.hideSettings}
forID={forID}
modes={modes}
mode={valueToOption(langInfo || defaultLang)}
theme={themes.find(t => t === theme)}
themes={themes}
keyMap={{ value: keyMap, label: keyMap }}
keyMaps={this.getKeyMapOptions()}
allowLanguageSelection={this.allowLanguageSelection}
onChangeLang={newLang => this.setState({ lang: newLang })}
onChangeTheme={newTheme => this.setState({ theme: newTheme })}
onChangeKeyMap={newKeyMap => this.setState({ keyMap: newKeyMap })}
/>
)}
<ReactCodeMirror
key={codeMirrorKey}
id={forID}
className={css`
height: 100%;
.CodeMirror {
height: auto;
cursor: text;
min-height: 300px;
}
.CodeMirror-scroll {
min-height: 300px;
}
`}
options={{
lineNumbers: true,
...widget.codeMirrorConfig,
extraKeys: {
'Shift-Tab': 'indentLess',
Tab: 'indentMore',
...(widget.codeMirrorConfig.extraKeys || {}),
},
theme,
mode,
keyMap,
viewportMargin: Infinity,
}}
detach={true}
editorDidMount={cm => {
this.cm = cm;
if (isNewEditorComponent) {
this.handleFocus();
}
}}
value={lastKnownValue}
onChange={(editor, data, newValue) => this.handleChange(newValue)}
onFocus={this.handleFocus}
onBlur={this.handleBlur}
/>
</div>
)}
</ClassNames>
);
}
}

View File

@ -0,0 +1,29 @@
import React from 'react';
import PropTypes from 'prop-types';
import { Map } from 'immutable';
import { isString } from 'lodash';
import { WidgetPreviewContainer } from 'netlify-cms-ui-default';
const toValue = (value, field) => {
if (isString(value)) {
return value;
}
if (Map.isMap(value)) {
return value.get(field.getIn(['keys', 'code'], 'code'), '');
}
return '';
};
const CodePreview = props => (
<WidgetPreviewContainer>
<pre>
<code>{toValue(props.value, props.field)}</code>
</pre>
</WidgetPreviewContainer>
);
CodePreview.propTypes = {
value: PropTypes.node,
};
export default CodePreview;

View File

@ -0,0 +1,31 @@
import React from 'react';
import styled from '@emotion/styled';
import { Icon, buttons, shadows } from 'netlify-cms-ui-default';
const StyledSettingsButton = styled.button`
${buttons.button};
${buttons.default};
${shadows.drop};
display: block;
position: absolute;
z-index: 100;
right: 8px;
top: 8px;
opacity: 0.8;
padding: 2px 4px;
line-height: 1;
height: auto;
${Icon} {
position: relative;
top: 1px;
}
`;
const SettingsButton = ({ showClose, onClick }) => (
<StyledSettingsButton onClick={onClick}>
<Icon type={showClose ? 'close' : 'settings'} size="small" />
</StyledSettingsButton>
);
export default SettingsButton;

View File

@ -0,0 +1,109 @@
import React from 'react';
import styled from '@emotion/styled';
import Select from 'react-select';
import isHotkey from 'is-hotkey';
import { text, shadows } from 'netlify-cms-ui-default';
import SettingsButton from './SettingsButton';
import languageSelectStyles from './languageSelectStyles';
const SettingsPaneContainer = styled.div`
position: absolute;
right: 0;
width: 200px;
z-index: 10;
height: 100%;
background-color: #fff;
overflow-y: scroll;
padding: 12px;
${shadows.drop};
`;
const SettingsFieldLabel = styled.label`
${text.fieldLabel};
font-size: 11px;
display: block;
margin-top: 8px;
margin-bottom: 2px;
`;
const SettingsSectionTitle = styled.h3`
font-size: 14px;
margin-top: 14px;
margin-bottom: 0;
&:first-of-type {
margin-top: 4px;
}
`;
const SettingsSelect = ({ value, options, onChange, forID, type, autoFocus }) => (
<Select
inputId={`${forID}-select-${type}`}
styles={languageSelectStyles}
value={value}
options={options}
onChange={opt => onChange(opt.value)}
menuPlacement="auto"
captureMenuScroll={false}
autoFocus={autoFocus}
/>
);
const SettingsPane = ({
hideSettings,
forID,
modes,
mode,
theme,
themes,
keyMap,
keyMaps,
allowLanguageSelection,
onChangeLang,
onChangeTheme,
onChangeKeyMap,
}) => (
<SettingsPaneContainer onKeyDown={e => isHotkey('esc', e) && hideSettings()}>
<SettingsButton onClick={hideSettings} showClose={true} />
{allowLanguageSelection && (
<>
<SettingsSectionTitle>Field Settings</SettingsSectionTitle>
<SettingsFieldLabel htmlFor={`${forID}-select-mode`}>Mode</SettingsFieldLabel>
<SettingsSelect
type="mode"
forID={forID}
value={mode}
options={modes}
onChange={onChangeLang}
autoFocus
/>
</>
)}
<>
<SettingsSectionTitle>Global Settings</SettingsSectionTitle>
{themes && (
<>
<SettingsFieldLabel htmlFor={`${forID}-select-theme`}>Theme</SettingsFieldLabel>
<SettingsSelect
type="theme"
forID={forID}
value={{ value: theme, label: theme }}
options={themes.map(t => ({ value: t, label: t }))}
onChange={onChangeTheme}
autoFocus={!allowLanguageSelection}
/>
</>
)}
<SettingsFieldLabel htmlFor={`${forID}-select-keymap`}>KeyMap</SettingsFieldLabel>
<SettingsSelect
type="keymap"
forID={forID}
value={keyMap}
options={keyMaps}
onChange={onChangeKeyMap}
/>
</>
</SettingsPaneContainer>
);
export default SettingsPane;

View File

@ -0,0 +1,14 @@
import controlComponent from './CodeControl';
import previewComponent from './CodePreview';
const Widget = (opts = {}) => ({
name: 'code',
controlComponent,
previewComponent,
allowMapValue: true,
codeMirrorConfig: {},
...opts,
});
export const NetlifyCmsWidgetCode = { Widget, controlComponent, previewComponent };
export default NetlifyCmsWidgetCode;

View File

@ -0,0 +1,35 @@
import { reactSelectStyles, borders } from 'netlify-cms-ui-default';
const languageSelectStyles = {
...reactSelectStyles,
container: provided => ({
...reactSelectStyles.container(provided),
'margin-top': '2px',
}),
control: provided => ({
...reactSelectStyles.control(provided),
border: borders.textField,
padding: 0,
fontSize: '13px',
minHeight: 'auto',
}),
dropdownIndicator: provided => ({
...reactSelectStyles.dropdownIndicator(provided),
padding: '4px',
}),
option: (provided, state) => ({
...reactSelectStyles.option(provided, state),
padding: 0,
paddingLeft: '8px',
}),
menu: provided => ({
...reactSelectStyles.menu(provided),
margin: '2px 0',
}),
menuList: provided => ({
...provided,
'max-height': '200px',
}),
};
export default languageSelectStyles;

View File

@ -0,0 +1,3 @@
const { getConfig } = require('../../scripts/webpack.js');
module.exports = getConfig();

View File

@ -1,7 +1,6 @@
/** @jsx jsx */
import React from 'react';
import PropTypes from 'prop-types';
import { jsx, css } from '@emotion/core';
import { css } from '@emotion/core';
import reactDateTimeStyles from 'react-datetime/css/react-datetime.css';
import DateTime from 'react-datetime';
import moment from 'moment';

View File

@ -5,6 +5,7 @@ const controlComponent = NetlifyCmsWidgetFile.withFileControl({ forImage: true }
const Widget = (opts = {}) => ({
name: 'image',
controlComponent,
previewComponent,
...opts,
});

View File

@ -1,11 +1,11 @@
/** @jsx jsx */
import React from 'react';
import PropTypes from 'prop-types';
import ImmutablePropTypes from 'react-immutable-proptypes';
import styled from '@emotion/styled';
import { jsx, css, ClassNames } from '@emotion/core';
import { css, ClassNames } from '@emotion/core';
import { List, Map, fromJS } from 'immutable';
import { partial, isEmpty } from 'lodash';
import uuid from 'uuid/v4';
import { SortableContainer, SortableElement, SortableHandle } from 'react-sortable-hoc';
import NetlifyCmsWidgetObject from 'netlify-cms-widget-object';
import {
@ -110,6 +110,7 @@ export default class ListControl extends React.Component {
this.state = {
itemsCollapsed: List(itemsCollapsed),
value: valueToString(value),
keys: List(),
};
}
@ -314,7 +315,7 @@ export default class ListControl extends React.Component {
onSortEnd = ({ oldIndex, newIndex }) => {
const { value } = this.props;
const { itemsCollapsed } = this.state;
const { itemsCollapsed, keys } = this.state;
// Update value
const item = value.get(oldIndex);
@ -324,7 +325,10 @@ export default class ListControl extends React.Component {
// Update collapsing
const collapsed = itemsCollapsed.get(oldIndex);
const updatedItemsCollapsed = itemsCollapsed.delete(oldIndex).insert(newIndex, collapsed);
this.setState({ itemsCollapsed: updatedItemsCollapsed });
// Reset item to ensure updated state
const updatedKeys = keys.set(oldIndex, uuid()).set(newIndex, uuid());
this.setState({ itemsCollapsed: updatedItemsCollapsed, keys: updatedKeys });
};
// eslint-disable-next-line react/display-name
@ -340,8 +344,9 @@ export default class ListControl extends React.Component {
resolveWidget,
} = this.props;
const { itemsCollapsed } = this.state;
const { itemsCollapsed, keys } = this.state;
const collapsed = itemsCollapsed.get(index);
const key = keys.get(index) || `item-${index}`;
let field = this.props.field;
if (this.getValueType() === valueTypes.MIXED) {
@ -355,7 +360,7 @@ export default class ListControl extends React.Component {
<SortableListItem
css={[styles.listControlItem, collapsed && styles.listControlItemCollapsed]}
index={index}
key={`item-${index}`}
key={key}
>
<StyledListItemTopBar
collapsed={collapsed}

View File

@ -25,21 +25,23 @@
"is-hotkey": "^0.1.4",
"mdast-util-definitions": "^1.2.3",
"mdast-util-to-string": "^1.0.5",
"rehype-parse": "^3.1.0",
"rehype-remark": "^2.0.0",
"rehype-stringify": "^3.0.0",
"remark-parse": "^3.0.1",
"remark-rehype": "^2.0.0",
"remark-stringify": "^3.0.1",
"slate": "^0.34.0",
"slate-edit-list": "^0.11.3",
"slate-edit-table": "^0.15.1",
"slate-plain-serializer": "^0.5.15",
"slate-react": "0.12.9",
"slate-soft-break": "^0.6.1",
"unified": "^6.1.4",
"unist-builder": "^1.0.2",
"unist-util-visit-parents": "^1.1.1"
"re-resizable": "^4.11.0",
"react-monaco-editor": "^0.25.1",
"react-select": "^2.4.3",
"rehype-parse": "^6.0.0",
"rehype-remark": "^5.0.1",
"rehype-stringify": "^5.0.0",
"remark-parse": "^6.0.3",
"remark-rehype": "^4.0.0",
"remark-stringify": "^6.0.4",
"slate": "^0.47.0",
"slate-base64-serializer": "^0.2.107",
"slate-plain-serializer": "^0.7.1",
"slate-react": "^0.22.0",
"slate-soft-break": "^0.9.0",
"unified": "^7.1.0",
"unist-builder": "^1.0.3",
"unist-util-visit-parents": "^2.0.1"
},
"peerDependencies": {
"@emotion/core": "^10.0.9",
@ -51,5 +53,11 @@
"react": "^16.8.4",
"react-dom": "^16.8.4",
"react-immutable-proptypes": "^2.1.0"
},
"devDependencies": {
"commonmark": "^0.29.0",
"commonmark-spec": "^0.29.0",
"monaco-editor-webpack-plugin": "^1.7.0",
"slate-hyperscript": "^0.13.3"
}
}

View File

@ -3,10 +3,12 @@ import PropTypes from 'prop-types';
import ImmutablePropTypes from 'react-immutable-proptypes';
import styled from '@emotion/styled';
import { ClassNames } from '@emotion/core';
import { Editor as Slate } from 'slate-react';
import Plain from 'slate-plain-serializer';
import { debounce } from 'lodash';
import { Editor as Slate, setEventTransfer } from 'slate-react';
import Plain from 'slate-plain-serializer';
import isHotkey from 'is-hotkey';
import { lengths, fonts } from 'netlify-cms-ui-default';
import { markdownToHtml } from '../serializers';
import { editorStyleVars, EditorControlBar } from '../styles';
import Toolbar from './Toolbar';
@ -40,38 +42,61 @@ export default class RawEditor extends React.Component {
return !this.state.value.equals(nextState.value);
}
handleChange = change => {
if (!this.state.value.document.equals(change.value.document)) {
this.handleDocumentChange(change);
componentDidMount() {
if (this.props.pendingFocus) {
this.editor.focus();
this.props.pendingFocus();
}
this.setState({ value: change.value });
}
handleCopy = (event, editor) => {
const { getAsset, resolveWidget } = this.props;
const markdown = Plain.serialize(editor.value);
const html = markdownToHtml(markdown, { getAsset, resolveWidget });
setEventTransfer(event, 'text', markdown);
setEventTransfer(event, 'html', html);
event.preventDefault();
};
handleCut = (event, editor, next) => {
this.handleCopy(event, editor, next);
editor.delete();
};
handlePaste = (event, editor, next) => {
const data = event.clipboardData;
if (isHotkey('shift', event)) {
return next();
}
const value = Plain.deserialize(data.getData('text/plain'));
return editor.insertFragment(value.document);
};
handleChange = editor => {
if (!this.state.value.document.equals(editor.value.document)) {
this.handleDocumentChange(editor);
}
this.setState({ value: editor.value });
};
/**
* When the document value changes, serialize from Slate's AST back to plain
* text (which is Markdown) and pass that up as the new value.
*/
handleDocumentChange = debounce(change => {
const value = Plain.serialize(change.value);
handleDocumentChange = debounce(editor => {
const value = Plain.serialize(editor.value);
this.props.onChange(value);
}, 150);
/**
* If a paste contains plain text, deserialize it to Slate's AST and insert
* to the document. Selection logic (where to insert, whether to replace) is
* handled by Slate.
*/
handlePaste = (e, data, change) => {
if (data.text) {
const fragment = Plain.deserialize(data.text).document;
return change.insertFragment(fragment);
}
};
handleToggleMode = () => {
this.props.onMode('visual');
};
processRef = ref => {
this.editor = ref;
};
render() {
const { className, field } = this.props;
return (
@ -96,6 +121,9 @@ export default class RawEditor extends React.Component {
value={this.state.value}
onChange={this.handleChange}
onPaste={this.handlePaste}
onCut={this.handleCut}
onCopy={this.handleCopy}
ref={this.processRef}
/>
)}
</ClassNames>

View File

@ -1,118 +0,0 @@
/* eslint-disable react/prop-types */
import React from 'react';
import { Map } from 'immutable';
import styled from '@emotion/styled';
import { css } from '@emotion/core';
import { partial, capitalize } from 'lodash';
import { ListItemTopBar, components, colors, lengths } from 'netlify-cms-ui-default';
import { getEditorControl, getEditorComponents } from './index';
const ShortcodeContainer = styled.div`
${components.objectWidgetTopBarContainer};
border-radius: ${lengths.borderRadius};
border: 2px solid ${colors.textFieldBorder};
margin: 12px 0;
padding: 14px;
${props =>
props.collapsed &&
css`
background-color: ${colors.textFieldBorder};
cursor: pointer;
`};
`;
const ShortcodeTopBar = styled(ListItemTopBar)`
background-color: ${colors.textFieldBorder};
margin: -14px -14px 0;
border-radius: 0;
`;
const ShortcodeTitle = styled.div`
padding: 8px;
color: ${colors.controlLabel};
`;
export default class Shortcode extends React.Component {
constructor(props) {
super(props);
this.state = {
/**
* The `shortcodeNew` prop is set to `true` when creating a new Shortcode,
* so that the form is immediately open for editing. Otherwise all
* shortcodes are collapsed by default.
*/
collapsed: !props.node.data.get('shortcodeNew'),
};
}
handleChange = (fieldName, value) => {
const { editor, node } = this.props;
const shortcodeData = Map(node.data.get('shortcodeData')).set(fieldName, value);
const data = node.data.set('shortcodeData', shortcodeData);
editor.change(c => c.setNodeByKey(node.key, { data }));
};
handleCollapseToggle = () => {
this.setState({ collapsed: !this.state.collapsed });
};
handleRemove = () => {
const { editor, node } = this.props;
editor.change(change => {
change.removeNodeByKey(node.key).focus();
});
};
handleClick = event => {
/**
* Stop click from propagating to editor, otherwise focus will be passed
* to the editor.
*/
event.stopPropagation();
/**
* If collapsed, any click should open the form.
*/
if (this.state.collapsed) {
this.handleCollapseToggle();
}
};
renderControl = (shortcodeData, field) => {
if (field.get('widget') === 'hidden') return null;
const value = shortcodeData.get(field.get('name'));
const key = `field-${field.get('name')}`;
const Control = getEditorControl();
const controlProps = { field, value, onChange: this.handleChange };
return (
<div key={key}>
<Control {...controlProps} />
</div>
);
};
render() {
const { attributes, node } = this.props;
const { collapsed } = this.state;
const pluginId = node.data.get('shortcode');
const shortcodeData = Map(this.props.node.data.get('shortcodeData'));
const plugin = getEditorComponents().get(pluginId);
return (
<ShortcodeContainer collapsed={collapsed} {...attributes} onClick={this.handleClick}>
<ShortcodeTopBar
collapsed={collapsed}
onCollapseToggle={this.handleCollapseToggle}
onRemove={this.handleRemove}
/>
{collapsed ? (
<ShortcodeTitle>{capitalize(pluginId)}</ShortcodeTitle>
) : (
plugin.get('fields').map(partial(this.renderControl, shortcodeData))
)}
</ShortcodeContainer>
);
}
}

View File

@ -80,9 +80,9 @@ export default class Toolbar extends React.Component {
onMarkClick: PropTypes.func,
onBlockClick: PropTypes.func,
onLinkClick: PropTypes.func,
selectionHasMark: PropTypes.func,
selectionHasBlock: PropTypes.func,
selectionHasLink: PropTypes.func,
hasMark: PropTypes.func,
hasInline: PropTypes.func,
hasBlock: PropTypes.func,
};
isHidden = button => {
@ -90,19 +90,29 @@ export default class Toolbar extends React.Component {
return List.isList(buttons) ? !buttons.includes(button) : false;
};
handleBlockClick = (event, type) => {
if (event) {
event.preventDefault();
}
this.props.onBlockClick(type);
};
handleMarkClick = (event, type) => {
event.preventDefault();
this.props.onMarkClick(type);
};
render() {
const {
onMarkClick,
onBlockClick,
onLinkClick,
selectionHasMark,
selectionHasBlock,
selectionHasLink,
onToggleMode,
rawMode,
plugins,
disabled,
onSubmit,
hasMark = () => {},
hasInline = () => {},
hasBlock = () => {},
} = this.props;
return (
@ -112,8 +122,8 @@ export default class Toolbar extends React.Component {
type="bold"
label="Bold"
icon="bold"
onClick={onMarkClick}
isActive={selectionHasMark}
onClick={this.handleMarkClick}
isActive={hasMark('bold')}
isHidden={this.isHidden('bold')}
disabled={disabled}
/>
@ -121,8 +131,8 @@ export default class Toolbar extends React.Component {
type="italic"
label="Italic"
icon="italic"
onClick={onMarkClick}
isActive={selectionHasMark}
onClick={this.handleMarkClick}
isActive={hasMark('italic')}
isHidden={this.isHidden('italic')}
disabled={disabled}
/>
@ -130,8 +140,8 @@ export default class Toolbar extends React.Component {
type="code"
label="Code"
icon="code"
onClick={onMarkClick}
isActive={selectionHasMark}
onClick={this.handleMarkClick}
isActive={hasMark('code')}
isHidden={this.isHidden('code')}
disabled={disabled}
/>
@ -140,7 +150,7 @@ export default class Toolbar extends React.Component {
label="Link"
icon="link"
onClick={onLinkClick}
isActive={selectionHasLink}
isActive={hasInline('link')}
isHidden={this.isHidden('link')}
disabled={disabled}
/>
@ -158,10 +168,10 @@ export default class Toolbar extends React.Component {
label="Headings"
icon="hOptions"
disabled={disabled}
isActive={() =>
isActive={
!disabled &&
Object.keys(headingOptions).some(optionKey => {
return selectionHasBlock(optionKey);
return hasBlock(optionKey);
})
}
/>
@ -175,8 +185,8 @@ export default class Toolbar extends React.Component {
<DropdownItem
key={idx}
label={headingOptions[optionKey]}
className={selectionHasBlock(optionKey) ? 'active' : undefined}
onClick={() => onBlockClick(undefined, optionKey)}
className={hasBlock(optionKey) ? 'active' : ''}
onClick={() => this.handleBlockClick(null, optionKey)}
/>
),
)}
@ -187,26 +197,17 @@ export default class Toolbar extends React.Component {
type="quote"
label="Quote"
icon="quote"
onClick={onBlockClick}
isActive={selectionHasBlock}
onClick={this.handleBlockClick}
isActive={hasBlock('quote')}
isHidden={this.isHidden('quote')}
disabled={disabled}
/>
<ToolbarButton
type="code"
label="Code Block"
icon="code-block"
onClick={onBlockClick}
isActive={selectionHasBlock}
isHidden={this.isHidden('code-block')}
disabled={disabled}
/>
<ToolbarButton
type="bulleted-list"
label="Bulleted List"
icon="list-bulleted"
onClick={onBlockClick}
isActive={selectionHasBlock}
onClick={this.handleBlockClick}
isActive={hasBlock('bulleted-list')}
isHidden={this.isHidden('bulleted-list')}
disabled={disabled}
/>
@ -214,14 +215,15 @@ export default class Toolbar extends React.Component {
type="numbered-list"
label="Numbered List"
icon="list-numbered"
onClick={onBlockClick}
isActive={selectionHasBlock}
onClick={this.handleBlockClick}
isActive={hasBlock('numbered-list')}
isHidden={this.isHidden('numbered-list')}
disabled={disabled}
/>
<ToolbarDropdownWrapper>
<Dropdown
dropdownTopOverlap="36px"
dropdownWidth="110px"
renderButton={() => (
<DropdownButton>
<ToolbarButton
@ -237,11 +239,7 @@ export default class Toolbar extends React.Component {
plugins
.toList()
.map((plugin, idx) => (
<DropdownItem
key={idx}
label={plugin.get('label')}
onClick={() => onSubmit(plugin.get('id'))}
/>
<DropdownItem key={idx} label={plugin.label} onClick={() => onSubmit(plugin)} />
))}
</Dropdown>
</ToolbarDropdownWrapper>

View File

@ -30,7 +30,7 @@ const ToolbarButton = ({ type, label, icon, onClick, isActive, isHidden, disable
return (
<StyledToolbarButton
isActive={isActive && type && isActive(type)}
isActive={isActive}
onClick={e => onClick && onClick(e, type)}
title={label}
disabled={disabled}
@ -45,7 +45,7 @@ ToolbarButton.propTypes = {
label: PropTypes.string.isRequired,
icon: PropTypes.string,
onClick: PropTypes.func,
isActive: PropTypes.func,
isActive: PropTypes.bool,
isHidden: PropTypes.bool,
disabled: PropTypes.bool,
};

View File

@ -1,23 +1,38 @@
import React from 'react';
import PropTypes from 'prop-types';
import ImmutablePropTypes from 'react-immutable-proptypes';
import { fromJS } from 'immutable';
import styled from '@emotion/styled';
import { ClassNames } from '@emotion/core';
import { get, isEmpty, debounce, uniq } from 'lodash';
import { List } from 'immutable';
import { css as coreCss, ClassNames } from '@emotion/core';
import { get, isEmpty, debounce } from 'lodash';
import { Value, Document, Block, Text } from 'slate';
import { Editor as Slate } from 'slate-react';
import { slateToMarkdown, markdownToSlate, htmlToSlate } from '../serializers';
import { lengths, fonts } from 'netlify-cms-ui-default';
import { editorStyleVars, EditorControlBar } from '../styles';
import { slateToMarkdown, markdownToSlate } from '../serializers';
import Toolbar from '../MarkdownControl/Toolbar';
import { renderNode, renderMark } from './renderers';
import { validateNode } from './validators';
import plugins, { EditListConfigured } from './plugins';
import onKeyDown from './keys';
import visualEditorStyles from './visualEditorStyles';
import { EditorControlBar } from '../styles';
import { renderBlock, renderInline, renderMark } from './renderers';
import plugins from './plugins/visual';
import schema from './schema';
const VisualEditorContainer = styled.div`
const visualEditorStyles = `
position: relative;
overflow: hidden;
overflow-x: auto;
font-family: ${fonts.primary};
min-height: ${lengths.richTextEditorMinHeight};
border-top-left-radius: 0;
border-top-right-radius: 0;
border-top: 0;
margin-top: -${editorStyleVars.stickyDistanceBottom};
padding: 0;
display: flex;
flex-direction: column;
`;
const InsertionPoint = styled.div`
flex: 1 1 auto;
cursor: text;
`;
const createEmptyRawDoc = () => {
@ -26,14 +41,37 @@ const createEmptyRawDoc = () => {
return { nodes: [emptyBlock] };
};
const createSlateValue = rawValue => {
const rawDoc = rawValue && markdownToSlate(rawValue);
const createSlateValue = (rawValue, { voidCodeBlock }) => {
const rawDoc = rawValue && markdownToSlate(rawValue, { voidCodeBlock });
const rawDocHasNodes = !isEmpty(get(rawDoc, 'nodes'));
const document = Document.fromJSON(rawDocHasNodes ? rawDoc : createEmptyRawDoc());
return Value.create({ document });
};
export default class Editor extends React.Component {
constructor(props) {
super(props);
const editorComponents = props.getEditorComponents();
this.shortcodeComponents = editorComponents.filter(({ type }) => type === 'shortcode');
this.codeBlockComponent = fromJS(editorComponents.find(({ type }) => type === 'code-block'));
this.editorComponents =
this.codeBlockComponent || editorComponents.has('code-block')
? editorComponents
: editorComponents.set('code-block', { label: 'Code Block', type: 'code-block' });
this.renderBlock = renderBlock({
classNameWrapper: props.className,
resolveWidget: props.resolveWidget,
codeBlockComponent: this.codeBlockComponent,
});
this.renderInline = renderInline();
this.renderMark = renderMark();
this.schema = schema({ voidCodeBlock: !!this.codeBlockComponent });
this.plugins = plugins({ getAsset: props.getAsset, resolveWidget: props.resolveWidget });
this.state = {
value: createSlateValue(this.props.value, { voidCodeBlock: !!this.codeBlockComponent }),
};
}
static propTypes = {
onAddAsset: PropTypes.func.isRequired,
getAsset: PropTypes.func.isRequired,
@ -45,249 +83,116 @@ export default class Editor extends React.Component {
getEditorComponents: PropTypes.func.isRequired,
};
constructor(props) {
super(props);
this.state = {
value: createSlateValue(props.value),
lastRawValue: props.value,
};
}
shouldComponentUpdate(nextProps, nextState) {
const forcePropsValue = this.shouldForcePropsValue(
this.props.value,
this.state.lastRawValue,
nextProps.value,
nextState.lastRawValue,
);
return !this.state.value.equals(nextState.value) || forcePropsValue;
return !this.state.value.equals(nextState.value);
}
componentDidUpdate(prevProps, prevState) {
const forcePropsValue = this.shouldForcePropsValue(
prevProps.value,
prevState.lastRawValue,
this.props.value,
this.state.lastRawValue,
);
if (forcePropsValue) {
this.setState({
value: createSlateValue(this.props.value),
lastRawValue: this.props.value,
});
componentDidMount() {
if (this.props.pendingFocus) {
this.editor.focus();
this.props.pendingFocus();
}
}
// If the old props/state values and new state value are all the same, and
// the new props value does not match the others, the new props value
// originated from outside of this widget and should be used.
shouldForcePropsValue(oldPropsValue, oldStateValue, newPropsValue, newStateValue) {
return (
uniq([oldPropsValue, oldStateValue, newStateValue]).length === 1 &&
oldPropsValue !== newPropsValue
);
}
handlePaste = (e, data, change) => {
if (data.type !== 'html' || data.isShift) {
return;
}
const ast = htmlToSlate(data.html);
const doc = Document.fromJSON(ast);
return change.insertFragment(doc);
handleMarkClick = type => {
this.editor.toggleMark(type).focus();
};
selectionHasMark = type => this.state.value.activeMarks.some(mark => mark.type === type);
selectionHasBlock = type => this.state.value.blocks.some(node => node.type === type);
handleMarkClick = (event, type) => {
event.preventDefault();
const resolvedChange = this.state.value
.change()
.focus()
.toggleMark(type);
this.ref.onChange(resolvedChange);
this.setState({ value: resolvedChange.value });
handleBlockClick = type => {
this.editor.toggleBlock(type).focus();
};
handleBlockClick = (event, type) => {
if (event) {
event.preventDefault();
}
let { value } = this.state;
const { document: doc } = value;
const { unwrapList, wrapInList } = EditListConfigured.changes;
let change = value.change();
// Handle everything except list buttons.
if (!['bulleted-list', 'numbered-list'].includes(type)) {
const isActive = this.selectionHasBlock(type);
change = change.setBlocks(isActive ? 'paragraph' : type);
}
// Handle the extra wrapping required for list buttons.
else {
const isSameListType = value.blocks.some(block => {
return !!doc.getClosest(block.key, parent => parent.type === type);
});
const isInList = EditListConfigured.utils.isSelectionInList(value);
if (isInList && isSameListType) {
change = change.call(unwrapList, type);
} else if (isInList) {
const currentListType = type === 'bulleted-list' ? 'numbered-list' : 'bulleted-list';
change = change.call(unwrapList, currentListType).call(wrapInList, type);
} else {
change = change.call(wrapInList, type);
}
}
const resolvedChange = change.focus();
this.ref.onChange(resolvedChange);
this.setState({ value: resolvedChange.value });
handleLinkClick = () => {
this.editor.toggleLink(() => window.prompt('Enter the URL of the link'));
};
hasLinks = () => {
return this.state.value.inlines.some(inline => inline.type === 'link');
};
hasMark = type => this.editor && this.editor.hasMark(type);
hasInline = type => this.editor && this.editor.hasInline(type);
hasBlock = type => this.editor && this.editor.hasBlock(type);
handleLink = () => {
let change = this.state.value.change();
// If the current selection contains links, clicking the "link" button
// should simply unlink them.
if (this.hasLinks()) {
change = change.unwrapInline('link');
} else {
const url = window.prompt('Enter the URL of the link');
// If nothing is entered in the URL prompt, do nothing.
if (!url) return;
// If no text is selected, use the entered URL as text.
if (change.value.isCollapsed) {
change = change.insertText(url).extend(0 - url.length);
}
change = change.wrapInline({ type: 'link', data: { url } }).collapseToEnd();
}
this.ref.onChange(change);
this.setState({ value: change.value });
};
handlePluginAdd = pluginId => {
const { getEditorComponents } = this.props;
const { value } = this.state;
const nodes = [Text.create('')];
/**
* Get default values for plugin fields.
*/
const pluginFields = getEditorComponents().getIn([pluginId, 'fields'], List());
const defaultValues = pluginFields
.toMap()
.mapKeys((_, field) => field.get('name'))
.filter(field => field.has('default'))
.map(field => field.get('default'));
/**
* Create new shortcode block with default values set.
*/
const block = {
object: 'block',
type: 'shortcode',
data: {
shortcode: pluginId,
shortcodeNew: true,
shortcodeData: defaultValues,
},
isVoid: true,
nodes,
};
let change = value.change();
const { focusBlock } = change.value;
if (focusBlock.text === '' && focusBlock.type === 'paragraph') {
change = change.setNodeByKey(focusBlock.key, block);
} else {
change = change.insertBlock(block);
}
change = change.focus();
this.ref.onChange(change);
this.setState({ value: change.value });
};
handleToggle = () => {
handleToggleMode = () => {
this.props.onMode('raw');
};
handleDocumentChange = debounce(change => {
handleInsertShortcode = pluginConfig => {
this.editor.insertShortcode(pluginConfig);
};
handleClickBelowDocument = () => {
this.editor.moveToEndOfDocument();
};
handleDocumentChange = debounce(editor => {
const { onChange } = this.props;
const raw = change.value.document.toJSON();
const markdown = slateToMarkdown(raw);
this.setState({ lastRawValue: markdown }, () => onChange(markdown));
const raw = editor.value.document.toJS();
const markdown = slateToMarkdown(raw, { voidCodeBlock: this.codeBlockComponent });
onChange(markdown);
}, 150);
handleChange = change => {
if (!this.state.value.document.equals(change.value.document)) {
this.handleDocumentChange(change);
handleChange = editor => {
if (!this.state.value.document.equals(editor.value.document)) {
this.handleDocumentChange(editor);
}
this.setState({ value: change.value });
this.setState({ value: editor.value });
};
processRef = ref => {
this.ref = ref;
this.editor = ref;
};
render() {
const { onAddAsset, getAsset, className, field, getEditorComponents } = this.props;
const { onAddAsset, getAsset, className, field } = this.props;
return (
<VisualEditorContainer>
<div
css={coreCss`
position: relative;
`}
>
<EditorControlBar>
<Toolbar
onMarkClick={this.handleMarkClick}
onBlockClick={this.handleBlockClick}
onLinkClick={this.handleLink}
selectionHasMark={this.selectionHasMark}
selectionHasBlock={this.selectionHasBlock}
selectionHasLink={this.hasLinks}
onToggleMode={this.handleToggle}
plugins={getEditorComponents()}
onSubmit={this.handlePluginAdd}
onLinkClick={this.handleLinkClick}
onToggleMode={this.handleToggleMode}
plugins={this.editorComponents}
onSubmit={this.handleInsertShortcode}
onAddAsset={onAddAsset}
getAsset={getAsset}
buttons={field.get('buttons')}
hasMark={this.hasMark}
hasInline={this.hasInline}
hasBlock={this.hasBlock}
/>
</EditorControlBar>
<ClassNames>
{({ css, cx }) => (
<Slate
<div
className={cx(
className,
css`
${visualEditorStyles}
`,
)}
value={this.state.value}
renderNode={renderNode}
renderMark={renderMark}
validateNode={validateNode}
plugins={plugins}
onChange={this.handleChange}
onKeyDown={onKeyDown}
onPaste={this.handlePaste}
ref={this.processRef}
spellCheck
/>
>
<Slate
className={css`
padding: 16px 20px 0;
`}
value={this.state.value}
renderBlock={this.renderBlock}
renderInline={this.renderInline}
renderMark={this.renderMark}
schema={this.schema}
plugins={this.plugins}
onChange={this.handleChange}
ref={this.processRef}
spellCheck
/>
<InsertionPoint onClick={this.handleClickBelowDocument} />
</div>
)}
</ClassNames>
</VisualEditorContainer>
</div>
);
}
}

View File

@ -9,7 +9,34 @@ describe('Compile markdown to Slate Raw AST', () => {
sweet body
`;
expect(parser(value)).toMatchSnapshot();
expect(parser(value)).toMatchInlineSnapshot(`
Object {
"nodes": Array [
Object {
"nodes": Array [
Object {
"object": "text",
"text": "H1",
},
],
"object": "block",
"type": "heading-one",
},
Object {
"nodes": Array [
Object {
"object": "text",
"text": "sweet body",
},
],
"object": "block",
"type": "paragraph",
},
],
"object": "block",
"type": "root",
}
`);
});
it('should compile a markdown ordered list', () => {
@ -20,7 +47,81 @@ sweet body
2. bro
3. fro
`;
expect(parser(value)).toMatchSnapshot();
expect(parser(value)).toMatchInlineSnapshot(`
Object {
"nodes": Array [
Object {
"nodes": Array [
Object {
"object": "text",
"text": "H1",
},
],
"object": "block",
"type": "heading-one",
},
Object {
"data": Object {
"start": 1,
},
"nodes": Array [
Object {
"nodes": Array [
Object {
"nodes": Array [
Object {
"object": "text",
"text": "yo",
},
],
"object": "block",
"type": "paragraph",
},
],
"object": "block",
"type": "list-item",
},
Object {
"nodes": Array [
Object {
"nodes": Array [
Object {
"object": "text",
"text": "bro",
},
],
"object": "block",
"type": "paragraph",
},
],
"object": "block",
"type": "list-item",
},
Object {
"nodes": Array [
Object {
"nodes": Array [
Object {
"object": "text",
"text": "fro",
},
],
"object": "block",
"type": "paragraph",
},
],
"object": "block",
"type": "list-item",
},
],
"object": "block",
"type": "numbered-list",
},
],
"object": "block",
"type": "root",
}
`);
});
it('should compile bulleted lists', () => {
@ -31,7 +132,81 @@ sweet body
* bro
* fro
`;
expect(parser(value)).toMatchSnapshot();
expect(parser(value)).toMatchInlineSnapshot(`
Object {
"nodes": Array [
Object {
"nodes": Array [
Object {
"object": "text",
"text": "H1",
},
],
"object": "block",
"type": "heading-one",
},
Object {
"data": Object {
"start": null,
},
"nodes": Array [
Object {
"nodes": Array [
Object {
"nodes": Array [
Object {
"object": "text",
"text": "yo",
},
],
"object": "block",
"type": "paragraph",
},
],
"object": "block",
"type": "list-item",
},
Object {
"nodes": Array [
Object {
"nodes": Array [
Object {
"object": "text",
"text": "bro",
},
],
"object": "block",
"type": "paragraph",
},
],
"object": "block",
"type": "list-item",
},
Object {
"nodes": Array [
Object {
"nodes": Array [
Object {
"object": "text",
"text": "fro",
},
],
"object": "block",
"type": "paragraph",
},
],
"object": "block",
"type": "list-item",
},
],
"object": "block",
"type": "bulleted-list",
},
],
"object": "block",
"type": "root",
}
`);
});
it('should compile multiple header levels', () => {
@ -42,7 +217,44 @@ sweet body
### H3
`;
expect(parser(value)).toMatchSnapshot();
expect(parser(value)).toMatchInlineSnapshot(`
Object {
"nodes": Array [
Object {
"nodes": Array [
Object {
"object": "text",
"text": "H1",
},
],
"object": "block",
"type": "heading-one",
},
Object {
"nodes": Array [
Object {
"object": "text",
"text": "H2",
},
],
"object": "block",
"type": "heading-two",
},
Object {
"nodes": Array [
Object {
"object": "text",
"text": "H3",
},
],
"object": "block",
"type": "heading-three",
},
],
"object": "block",
"type": "root",
}
`);
});
it('should compile horizontal rules', () => {
@ -53,7 +265,38 @@ sweet body
blue moon
`;
expect(parser(value)).toMatchSnapshot();
expect(parser(value)).toMatchInlineSnapshot(`
Object {
"nodes": Array [
Object {
"nodes": Array [
Object {
"object": "text",
"text": "H1",
},
],
"object": "block",
"type": "heading-one",
},
Object {
"object": "block",
"type": "thematic-break",
},
Object {
"nodes": Array [
Object {
"object": "text",
"text": "blue moon",
},
],
"object": "block",
"type": "paragraph",
},
],
"object": "block",
"type": "root",
}
`);
});
it('should compile horizontal rules', () => {
@ -64,7 +307,38 @@ blue moon
blue moon
`;
expect(parser(value)).toMatchSnapshot();
expect(parser(value)).toMatchInlineSnapshot(`
Object {
"nodes": Array [
Object {
"nodes": Array [
Object {
"object": "text",
"text": "H1",
},
],
"object": "block",
"type": "heading-one",
},
Object {
"object": "block",
"type": "thematic-break",
},
Object {
"nodes": Array [
Object {
"object": "text",
"text": "blue moon",
},
],
"object": "block",
"type": "paragraph",
},
],
"object": "block",
"type": "root",
}
`);
});
it('should compile soft breaks (double space)', () => {
@ -72,14 +346,62 @@ blue moon
blue moon
footballs
`;
expect(parser(value)).toMatchSnapshot();
expect(parser(value)).toMatchInlineSnapshot(`
Object {
"nodes": Array [
Object {
"nodes": Array [
Object {
"object": "text",
"text": "blue moon",
},
Object {
"data": undefined,
"object": "inline",
"type": "break",
},
Object {
"object": "text",
"text": "footballs",
},
],
"object": "block",
"type": "paragraph",
},
],
"object": "block",
"type": "root",
}
`);
});
it('should compile images', () => {
const value = `
![super](duper.jpg)
`;
expect(parser(value)).toMatchSnapshot();
expect(parser(value)).toMatchInlineSnapshot(`
Object {
"nodes": Array [
Object {
"nodes": Array [
Object {
"data": Object {
"alt": "super",
"title": null,
"url": "duper.jpg",
},
"object": "inline",
"type": "image",
},
],
"object": "block",
"type": "paragraph",
},
],
"object": "block",
"type": "root",
}
`);
});
it('should compile code blocks', () => {
@ -88,7 +410,27 @@ footballs
var a = 1;
\`\`\`
`;
expect(parser(value)).toMatchSnapshot();
expect(parser(value)).toMatchInlineSnapshot(`
Object {
"nodes": Array [
Object {
"data": Object {
"lang": "javascript",
},
"nodes": Array [
Object {
"object": "text",
"text": "var a = 1;",
},
],
"object": "block",
"type": "code-block",
},
],
"object": "block",
"type": "root",
}
`);
});
it('should compile nested inline markup', () => {
@ -99,7 +441,87 @@ This is **some *hot* content**
perhaps **scalding** even
`;
expect(parser(value)).toMatchSnapshot();
expect(parser(value)).toMatchInlineSnapshot(`
Object {
"nodes": Array [
Object {
"nodes": Array [
Object {
"object": "text",
"text": "Word",
},
],
"object": "block",
"type": "heading-one",
},
Object {
"nodes": Array [
Object {
"object": "text",
"text": "This is ",
},
Object {
"marks": Array [
Object {
"type": "bold",
},
],
"object": "text",
"text": "some ",
},
Object {
"marks": Array [
Object {
"type": "bold",
},
Object {
"type": "italic",
},
],
"object": "text",
"text": "hot",
},
Object {
"marks": Array [
Object {
"type": "bold",
},
],
"object": "text",
"text": " content",
},
],
"object": "block",
"type": "paragraph",
},
Object {
"nodes": Array [
Object {
"object": "text",
"text": "perhaps ",
},
Object {
"marks": Array [
Object {
"type": "bold",
},
],
"object": "text",
"text": "scalding",
},
Object {
"object": "text",
"text": " even",
},
],
"object": "block",
"type": "paragraph",
},
],
"object": "block",
"type": "root",
}
`);
});
it('should compile inline code', () => {
@ -108,7 +530,47 @@ perhaps **scalding** even
This is some sweet \`inline code\` yo!
`;
expect(parser(value)).toMatchSnapshot();
expect(parser(value)).toMatchInlineSnapshot(`
Object {
"nodes": Array [
Object {
"nodes": Array [
Object {
"object": "text",
"text": "Word",
},
],
"object": "block",
"type": "heading-one",
},
Object {
"nodes": Array [
Object {
"object": "text",
"text": "This is some sweet ",
},
Object {
"marks": Array [
Object {
"type": "code",
},
],
"object": "text",
"text": "inline code",
},
Object {
"object": "text",
"text": " yo!",
},
],
"object": "block",
"type": "paragraph",
},
],
"object": "block",
"type": "root",
}
`);
});
it('should compile links', () => {
@ -117,7 +579,52 @@ This is some sweet \`inline code\` yo!
How far is it to [Google](https://google.com) land?
`;
expect(parser(value)).toMatchSnapshot();
expect(parser(value)).toMatchInlineSnapshot(`
Object {
"nodes": Array [
Object {
"nodes": Array [
Object {
"object": "text",
"text": "Word",
},
],
"object": "block",
"type": "heading-one",
},
Object {
"nodes": Array [
Object {
"object": "text",
"text": "How far is it to ",
},
Object {
"data": Object {
"title": null,
"url": "https://google.com",
},
"nodes": Array [
Object {
"object": "text",
"text": "Google",
},
],
"object": "inline",
"type": "link",
},
Object {
"object": "text",
"text": " land?",
},
],
"object": "block",
"type": "paragraph",
},
],
"object": "block",
"type": "root",
}
`);
});
it('should compile plugins', () => {
@ -126,7 +633,39 @@ How far is it to [Google](https://google.com) land?
{{< test >}}
`;
expect(parser(value)).toMatchSnapshot();
expect(parser(value)).toMatchInlineSnapshot(`
Object {
"nodes": Array [
Object {
"nodes": Array [
Object {
"data": Object {
"alt": "test",
"title": null,
"url": "test.png",
},
"object": "inline",
"type": "image",
},
],
"object": "block",
"type": "paragraph",
},
Object {
"nodes": Array [
Object {
"object": "text",
"text": "{{< test >}}",
},
],
"object": "block",
"type": "paragraph",
},
],
"object": "block",
"type": "root",
}
`);
});
it('should compile kitchen sink example', () => {

View File

@ -0,0 +1,50 @@
/** @jsx h */
import h from '../../../test-helpers/h';
import { Editor } from 'slate';
import plugins from '../plugins/visual';
import schema from '../schema';
function run(input, output, fn) {
const editor = new Editor({ plugins: plugins(), schema: schema() });
const opts = { preserveSelection: true };
editor.setValue(input);
fn(editor);
const actual = editor.value.toJSON(opts);
editor.setValue(output);
const expected = editor.value.toJSON(opts);
return [actual, expected];
}
// If we want to use slate-hyperscript for testing our schema direct, we can use
// this setup.
describe.skip('slate', () => {
test('test', () => {
const input = (
<value>
<document>
<paragraph>
a<cursor />
</paragraph>
</document>
</value>
);
const output = (
<value>
<document>
<heading-one>
b<cursor />
</heading-one>
</document>
</value>
);
const fn = editor => {
editor
.deleteBackward()
.insertText('b')
.setBlocks('heading-one');
};
const [actual, expected] = run(input, output, fn);
expect(actual).toEqual(expected);
});
});

View File

@ -0,0 +1,57 @@
/* eslint-disable react/prop-types */
import React from 'react';
import { css } from '@emotion/core';
import { Map, fromJS } from 'immutable';
import { omit } from 'lodash';
import { getEditorControl, getEditorComponents } from '../index';
export default class Shortcode extends React.Component {
state = {
field: Map(),
};
componentDidMount() {
const { node, typeOverload } = this.props;
const plugin = getEditorComponents().get(typeOverload || node.data.get('shortcode'));
const fieldKeys = ['id', 'fromBlock', 'toBlock', 'toPreview', 'pattern', 'icon'];
const field = fromJS(omit(plugin, fieldKeys));
this.setState({ field });
}
render() {
const { editor, node, dataKey = 'shortcodeData' } = this.props;
const { field } = this.state;
const EditorControl = getEditorControl();
const value = dataKey === false ? node.data : fromJS(node.data.get(dataKey));
const handleChange = (fieldName, value, metadata) => {
const dataValue = dataKey === false ? value : node.data.set('shortcodeData', value);
editor.setNodeByKey(node.key, { data: dataValue || Map(), metadata });
};
const handleFocus = () => editor.moveToRangeOfNode(node);
return (
!field.isEmpty() && (
<div onClick={handleFocus} onFocus={handleFocus}>
<EditorControl
css={css`
margin-top: 0;
margin-bottom: 16px;
&:first-of-type {
margin-top: 0;
}
`}
value={value}
field={field}
onChange={handleChange}
isEditorComponent={true}
isNewEditorComponent={node.data.get('shortcodeNew')}
isSelected={editor.isSelected(node)}
/>
</div>
)
);
}
}

View File

@ -0,0 +1,36 @@
/* eslint-disable react/prop-types */
import React from 'react';
import { css } from '@emotion/core';
const InsertionPoint = props => (
<div
css={css`
height: 32px;
cursor: text;
position: relative;
z-index: 1;
margin-top: -16px;
`}
{...props}
/>
);
const VoidBlock = ({ editor, attributes, node, children }) => {
const handleClick = event => {
event.stopPropagation();
};
return (
<div {...attributes} onClick={handleClick}>
{!editor.canInsertBeforeNode(node) && (
<InsertionPoint onClick={() => editor.forceInsertBeforeNode(node)} />
)}
{children}
{!editor.canInsertAfterNode(node) && (
<InsertionPoint onClick={() => editor.forceInsertAfterNode(node)} />
)}
</div>
);
};
export default VoidBlock;

View File

@ -6,6 +6,8 @@ import VisualEditor from './VisualEditor';
const MODE_STORAGE_KEY = 'cms.md-mode';
// TODO: passing the editorControl and components like this is horrible, should
// be handled through Redux and a separate registry store for instances
let editorControl;
let _getEditorComponents = () => [];
@ -32,16 +34,23 @@ export default class MarkdownControl extends React.Component {
super(props);
editorControl = props.editorControl;
_getEditorComponents = props.getEditorComponents;
this.state = { mode: localStorage.getItem(MODE_STORAGE_KEY) || 'visual' };
this.state = {
mode: localStorage.getItem(MODE_STORAGE_KEY) || 'visual',
pendingFocus: false,
};
}
handleMode = mode => {
this.setState({ mode });
this.setState({ mode, pendingFocus: true });
localStorage.setItem(MODE_STORAGE_KEY, mode);
};
processRef = ref => (this.ref = ref);
setFocusReceived = () => {
this.setState({ pendingFocus: false });
};
render() {
const {
onChange,
@ -51,9 +60,10 @@ export default class MarkdownControl extends React.Component {
classNameWrapper,
field,
getEditorComponents,
resolveWidget,
} = this.props;
const { mode } = this.state;
const { mode, pendingFocus } = this.state;
const visualEditor = (
<div className="cms-editor-visual" ref={this.processRef}>
<VisualEditor
@ -65,6 +75,8 @@ export default class MarkdownControl extends React.Component {
value={value}
field={field}
getEditorComponents={getEditorComponents}
resolveWidget={resolveWidget}
pendingFocus={pendingFocus && this.setFocusReceived}
/>
</div>
);
@ -78,6 +90,7 @@ export default class MarkdownControl extends React.Component {
className={classNameWrapper}
value={value}
field={field}
pendingFocus={pendingFocus && this.setFocusReceived}
/>
</div>
);

View File

@ -1,54 +0,0 @@
import { Block, Text } from 'slate';
import isHotkey from 'is-hotkey';
export default onKeyDown;
function onKeyDown(event, change) {
const createDefaultBlock = () => {
return Block.create({
type: 'paragraph',
nodes: [Text.create('')],
});
};
if (isHotkey('Enter', event)) {
/**
* If "Enter" is pressed while a single void block is selected, a new
* paragraph should be added above or below it, and the current selection
* (range) should be collapsed to the start of the new paragraph.
*
* If the selected block is the first block in the document, create the
* new block above it. If not, create the new block below it.
*/
const { document: doc, anchorBlock, focusBlock } = change.value;
const singleBlockSelected = anchorBlock === focusBlock;
if (!singleBlockSelected || !focusBlock.isVoid) return;
event.preventDefault();
const focusBlockParent = doc.getParent(focusBlock.key);
const focusBlockIndex = focusBlockParent.nodes.indexOf(focusBlock);
const focusBlockIsFirstChild = focusBlockIndex === 0;
const newBlock = createDefaultBlock();
const newBlockIndex = focusBlockIsFirstChild ? 0 : focusBlockIndex + 1;
return change
.insertNodeByKey(focusBlockParent.key, newBlockIndex, newBlock)
.collapseToStartOf(newBlock);
}
const marks = [
['b', 'bold'],
['i', 'italic'],
['s', 'strikethrough'],
['`', 'code'],
];
const [, markName] = marks.find(([key]) => isHotkey(`mod+${key}`, event)) || [];
if (markName) {
event.preventDefault();
return change.toggleMark(markName);
}
}

View File

@ -1,115 +0,0 @@
import { Text, Inline } from 'slate';
import isHotkey from 'is-hotkey';
import EditList from 'slate-edit-list';
import EditTable from 'slate-edit-table';
const SoftBreak = (options = {}) => ({
onKeyDown(event, change) {
if (options.shift && !isHotkey('shift+enter', event)) return;
if (!options.shift && !isHotkey('enter', event)) return;
const { onlyIn, ignoreIn, defaultBlock = 'paragraph' } = options;
const { type, text } = change.value.startBlock;
if (onlyIn && !onlyIn.includes(type)) return;
if (ignoreIn && ignoreIn.includes(type)) return;
const shouldClose = text.endsWith('\n');
if (shouldClose) {
return change.deleteBackward(1).insertBlock(defaultBlock);
}
const textNode = Text.create('\n');
const breakNode = Inline.create({ type: 'break', nodes: [textNode] });
return change
.insertInline(breakNode)
.insertText('')
.collapseToStartOfNextText();
},
});
const SoftBreakOpts = {
onlyIn: ['quote', 'code'],
};
export const SoftBreakConfigured = SoftBreak(SoftBreakOpts);
export const ParagraphSoftBreakConfigured = SoftBreak({ onlyIn: ['paragraph'], shift: true });
const BreakToDefaultBlock = ({ onlyIn = [], defaultBlock = 'paragraph' }) => ({
onKeyDown(event, change) {
const { value } = change;
if (!isHotkey('enter', event) || value.isExpanded) return;
if (onlyIn.includes(value.startBlock.type)) {
return change.insertBlock(defaultBlock);
}
},
});
const BreakToDefaultBlockOpts = {
onlyIn: [
'heading-one',
'heading-two',
'heading-three',
'heading-four',
'heading-five',
'heading-six',
],
};
export const BreakToDefaultBlockConfigured = BreakToDefaultBlock(BreakToDefaultBlockOpts);
const BackspaceCloseBlock = (options = {}) => ({
onKeyDown(event, change) {
if (event.key !== 'Backspace') return;
const { defaultBlock = 'paragraph', ignoreIn, onlyIn } = options;
const { startBlock } = change.value;
const { type } = startBlock;
if (onlyIn && !onlyIn.includes(type)) return;
if (ignoreIn && ignoreIn.includes(type)) return;
if (startBlock.text === '') {
return change.setBlocks(defaultBlock).focus();
}
},
});
const BackspaceCloseBlockOpts = {
ignoreIn: [
'paragraph',
'list-item',
'bulleted-list',
'numbered-list',
'table',
'table-row',
'table-cell',
],
};
export const BackspaceCloseBlockConfigured = BackspaceCloseBlock(BackspaceCloseBlockOpts);
const EditListOpts = {
types: ['bulleted-list', 'numbered-list'],
typeItem: 'list-item',
};
export const EditListConfigured = EditList(EditListOpts);
const EditTableOpts = {
typeTable: 'table',
typeRow: 'table-row',
typeCell: 'table-cell',
};
export const EditTableConfigured = EditTable(EditTableOpts);
const plugins = [
SoftBreakConfigured,
ParagraphSoftBreakConfigured,
BackspaceCloseBlockConfigured,
BreakToDefaultBlockConfigured,
EditListConfigured,
];
export default plugins;

View File

@ -0,0 +1,21 @@
import isHotkey from 'is-hotkey';
const BreakToDefaultBlock = ({ defaultType }) => ({
onKeyDown(event, editor, next) {
const { selection, startBlock } = editor.value;
const isEnter = isHotkey('enter', event);
if (!isEnter) {
return next();
}
if (selection.isExpanded) {
editor.delete();
return next();
}
if (selection.start.isAtEndOfNode(startBlock) && startBlock.type !== defaultType) {
return editor.insertBlock(defaultType);
}
return next();
},
});
export default BreakToDefaultBlock;

View File

@ -0,0 +1,23 @@
import isHotkey from 'is-hotkey';
const CloseBlock = ({ defaultType }) => ({
onKeyDown(event, editor, next) {
const { selection, startBlock } = editor.value;
const isBackspace = isHotkey('backspace', event);
if (!isBackspace) {
return next();
}
if (selection.isExpanded) {
return editor.delete();
}
if (!selection.start.isAtStartOfNode(startBlock) || startBlock.text.length > 0) {
return next();
}
if (startBlock.type !== defaultType) {
return editor.setBlocks(defaultType);
}
return next();
},
});
export default CloseBlock;

View File

@ -0,0 +1,121 @@
import { isArray, tail, castArray } from 'lodash';
const CommandsAndQueries = ({ defaultType }) => ({
queries: {
atStartOf(editor, node) {
const { selection } = editor.value;
return selection.isCollapsed && selection.start.isAtStartOfNode(node);
},
getAncestor(editor, firstKey, lastKey) {
if (firstKey === lastKey) {
return editor.value.document.getParent(firstKey);
}
return editor.value.document.getCommonAncestor(firstKey, lastKey);
},
getOffset(editor, node) {
const parent = editor.value.document.getParent(node.key);
return parent.nodes.indexOf(node);
},
getSelectedChildren(editor, node) {
return node.nodes.filter(child => editor.isSelected(child));
},
getCommonAncestor(editor) {
const { startBlock, endBlock, document: doc } = editor.value;
return doc.getCommonAncestor(startBlock.key, endBlock.key);
},
getClosestType(editor, node, type) {
const types = castArray(type);
return editor.value.document.getClosest(node.key, n => types.includes(n.type));
},
getBlockContainer(editor, node) {
const targetTypes = ['bulleted-list', 'numbered-list', 'list-item', 'quote', 'table-cell'];
const { startBlock, selection } = editor.value;
const target = node
? editor.value.document.getParent(node.key)
: (selection.isCollapsed && startBlock) || editor.getCommonAncestor();
if (!target) {
return editor.value.document;
}
if (targetTypes.includes(target.type)) {
return target;
}
return editor.getBlockContainer(target);
},
isSelected(editor, nodes) {
return castArray(nodes).every(node => {
return editor.value.document.isInRange(node.key, editor.value.selection);
});
},
isFirstChild(editor, node) {
return editor.value.document.getParent(node.key).nodes.first().key === node.key;
},
areSiblings(editor, nodes) {
if (!isArray(nodes) || nodes.length < 2) {
return true;
}
const parent = editor.value.document.getParent(nodes[0].key);
return tail(nodes).every(node => {
return editor.value.document.getParent(node.key).key === parent.key;
});
},
everyBlock(editor, type) {
return editor.value.blocks.every(block => block.type === type);
},
hasMark(editor, type) {
return editor.value.activeMarks.some(mark => mark.type === type);
},
hasBlock(editor, type) {
return editor.value.blocks.some(node => node.type === type);
},
hasInline(editor, type) {
return editor.value.inlines.some(node => node.type === type);
},
},
commands: {
toggleBlock(editor, type) {
switch (type) {
case 'heading-one':
case 'heading-two':
case 'heading-three':
case 'heading-four':
case 'heading-five':
case 'heading-six':
return editor.setBlocks(editor.everyBlock(type) ? defaultType : type);
case 'quote':
return editor.toggleQuoteBlock();
case 'numbered-list':
case 'bulleted-list': {
return editor.toggleList(type);
}
}
},
unwrapBlockChildren(editor, block) {
if (!block || block.object !== 'block') {
throw Error(`Expected block but received ${block}.`);
}
const index = editor.value.document.getPath(block.key).last();
const parent = editor.value.document.getParent(block.key);
editor.withoutNormalizing(() => {
block.nodes.forEach((node, idx) => {
editor.moveNodeByKey(node.key, parent.key, index + idx);
});
editor.removeNodeByKey(block.key);
});
},
unwrapNodeToDepth(editor, node, depth) {
let currentDepth = 0;
editor.withoutNormalizing(() => {
while (currentDepth < depth) {
editor.unwrapNodeByKey(node.key);
currentDepth += 1;
}
});
},
unwrapNodeFromAncestor(editor, node, ancestor) {
const depth = ancestor.getDepth(node.key);
editor.unwrapNodeToDepth(node, depth);
},
},
});
export default CommandsAndQueries;

View File

@ -0,0 +1,44 @@
import { Document } from 'slate';
import { setEventTransfer } from 'slate-react';
import base64 from 'slate-base64-serializer';
import isHotkey from 'is-hotkey';
import { slateToMarkdown, markdownToSlate, htmlToSlate, markdownToHtml } from '../../serializers';
const CopyPasteVisual = ({ getAsset, resolveWidget }) => {
const handleCopy = (event, editor) => {
const markdown = slateToMarkdown(editor.value.fragment.toJS());
const html = markdownToHtml(markdown, { getAsset, resolveWidget });
setEventTransfer(event, 'text', markdown);
setEventTransfer(event, 'html', html);
setEventTransfer(event, 'fragment', base64.serializeNode(editor.value.fragment));
event.preventDefault();
};
return {
onPaste(event, editor, next) {
const data = event.clipboardData;
if (isHotkey('shift', event)) {
return next();
}
if (data.types.includes('application/x-slate-fragment')) {
const fragment = base64.deserializeNode(data.getData('application/x-slate-fragment'));
return editor.insertFragment(fragment);
}
const html = data.types.includes('text/html') && data.getData('text/html');
const ast = html ? htmlToSlate(html) : markdownToSlate(data.getData('text/plain'));
const doc = Document.fromJSON(ast);
return editor.insertFragment(doc);
},
onCopy(event, editor, next) {
handleCopy(event, editor, next);
},
onCut(event, editor, next) {
handleCopy(event, editor, next);
editor.delete();
},
};
};
export default CopyPasteVisual;

View File

@ -0,0 +1,42 @@
const ForceInsert = ({ defaultType }) => ({
queries: {
canInsertBeforeNode(editor, node) {
if (!editor.isVoid(node)) {
return true;
}
return !!editor.value.document.getPreviousSibling(node.key);
},
canInsertAfterNode(editor, node) {
if (!editor.isVoid(node)) {
return true;
}
const nextSibling = editor.value.document.getNextSibling(node.key);
return nextSibling && !editor.isVoid(nextSibling);
},
},
commands: {
forceInsertBeforeNode(editor, node) {
const block = { type: defaultType, object: 'block' };
const parent = editor.value.document.getParent(node.key);
return editor
.insertNodeByKey(parent.key, 0, block)
.moveToStartOfNode(parent)
.focus();
},
forceInsertAfterNode(editor, node) {
return editor
.moveToEndOfNode(node)
.insertBlock(defaultType)
.focus();
},
moveToEndOfDocument(editor) {
const lastBlock = editor.value.document.nodes.last();
if (editor.isVoid(lastBlock)) {
editor.insertBlock(defaultType);
}
return editor.moveToEndOfNode(lastBlock).focus();
},
},
});
export default ForceInsert;

View File

@ -0,0 +1,16 @@
import isHotkey from 'is-hotkey';
const LineBreak = () => ({
onKeyDown(event, editor, next) {
const isShiftEnter = isHotkey('shift+enter', event);
if (!isShiftEnter) {
return next();
}
return editor
.insertInline('break')
.insertText('')
.moveToStartOfNextText();
},
});
export default LineBreak;

View File

@ -0,0 +1,21 @@
const Link = ({ type }) => ({
commands: {
toggleLink(editor, getUrl) {
if (editor.hasInline(type)) {
editor.unwrapInline(type);
} else {
const url = getUrl();
if (!url) return;
// If no text is selected, use the entered URL as text.
if (editor.value.isCollapsed) {
editor.insertText(url).moveFocusBackward(0 - url.length);
}
return editor.wrapInline({ type, data: { url } }).moveToEnd();
}
},
},
});
export default Link;

View File

@ -0,0 +1,302 @@
import { List } from 'immutable';
import { castArray, throttle, get } from 'lodash';
import { Range, Block } from 'slate';
import isHotkey from 'is-hotkey';
import { assertType } from './util';
const ListPlugin = ({ defaultType, unorderedListType, orderedListType }) => {
const LIST_TYPES = [orderedListType, unorderedListType];
function oppositeListType(type) {
switch (type) {
case LIST_TYPES[0]:
return LIST_TYPES[1];
case LIST_TYPES[1]:
return LIST_TYPES[0];
}
}
return {
queries: {
getCurrentListItem(editor) {
const { startBlock, endBlock } = editor.value;
const ancestor = editor.value.document.getCommonAncestor(startBlock.key, endBlock.key);
if (ancestor && ancestor.type === 'list-item') {
return ancestor;
}
return editor.value.document.getClosest(ancestor.key, node => node.type === 'list-item');
},
getListOrListItem(editor, { node, ...opts } = {}) {
const listContextNode = editor.getBlockContainer(node);
if (!listContextNode) {
return;
}
if (['bulleted-list', 'numbered-list', 'list-item'].includes(listContextNode.type)) {
return listContextNode;
}
if (opts.force) {
return editor.getListOrListItem({ node: listContextNode, ...opts });
}
},
isList(editor, node) {
return node && LIST_TYPES.includes(node.type);
},
getLowestListItem(editor, list) {
assertType(list, LIST_TYPES);
const lastItem = list.nodes.last();
const lastItemLastChild = lastItem.nodes.last();
if (editor.isList(lastItemLastChild)) {
return editor.getLowestListItem(lastItemLastChild);
}
return lastItem;
},
},
commands: {
wrapInList(editor, type) {
editor.withoutNormalizing(() => {
editor.wrapBlock(type).wrapBlock('list-item');
});
},
unwrapListItem(editor, node) {
assertType(node, 'list-item');
editor.withoutNormalizing(() => {
editor.unwrapNodeByKey(node.key).unwrapBlockChildren(node);
});
},
indentListItems: throttle(function indentListItem(editor, listItemsArg) {
const listItems = List.isList(listItemsArg) ? listItemsArg : List(castArray(listItemsArg));
const firstListItem = listItems.first();
const firstListItemIndex = editor.value.document.getPath(firstListItem.key).last();
const list = editor.value.document.getParent(firstListItem.key);
/**
* If the first list item in the list is in the selection, and the list
* previous sibling is a list of the opposite type, we should still indent
* the list items as children of the last item in the previous list, as
* the behavior otherwise for first items is to do nothing on tab, while
* in this case the user would expect indenting via tab to "just work".
*/
if (firstListItemIndex === 0) {
const listPreviousSibling = editor.value.document.getPreviousSibling(list.key);
if (!listPreviousSibling || listPreviousSibling.type !== oppositeListType(list.type)) {
return;
}
editor.withoutNormalizing(() => {
listItems.forEach((listItem, idx) => {
const index = listPreviousSibling.nodes.size + idx;
editor.moveNodeByKey(listItem.key, listPreviousSibling.key, index);
});
});
}
/**
* Wrap all selected list items into a new list item and list, then merge
* the new parent list item into the previous list item in the list.
*/
const newListItem = Block.create('list-item');
const newList = Block.create(list.type);
editor.withoutNormalizing(() => {
editor
.insertNodeByKey(list.key, firstListItemIndex, newListItem)
.insertNodeByKey(newListItem.key, 0, newList);
listItems.forEach((listItem, index) => {
editor.moveNodeByKey(listItem.key, newList.key, index);
});
editor.mergeNodeByKey(newListItem.key);
});
}, 100),
unindentListItems: throttle(function unindentListItems(editor, listItemsArg) {
// Ensure that `listItems` are children of a list.
const listItems = List.isList(listItemsArg) ? listItemsArg : List(castArray(listItemsArg));
const list = editor.value.document.getParent(listItems.first().key);
if (!editor.isList(list)) {
return;
}
// If the current list isn't nested under a list, we cannot unindent.
const parentListItem = editor.value.document.getParent(list.key);
if (!parentListItem || parentListItem.type !== 'list-item') {
return;
}
// Check if there are more list items after the items being indented.
const nextSibling = editor.value.document.getNextSibling(listItems.last().key);
// Unwrap each selected list item into the parent list.
editor.withoutNormalizing(() => {
listItems.forEach(listItem => editor.unwrapNodeToDepth(listItem, 2));
});
// If there were other list items after the selected items, use the last
// of the unindented list items as the new parent of the remaining items
// list.
if (nextSibling) {
const nextSiblingParentListItem = editor.value.document.getNextSibling(
listItems.last().key,
);
editor.mergeNodeByKey(nextSiblingParentListItem.key);
}
}, 100),
toggleListItemType(editor, listItem) {
assertType(listItem, 'list-item');
const list = editor.value.document.getParent(listItem.key);
const newListType = oppositeListType(list.type);
editor.withoutNormalizing(() => {
editor.unwrapNodeByKey(listItem.key).wrapBlockByKey(listItem.key, newListType);
});
},
toggleList(editor, type) {
if (!LIST_TYPES.includes(type)) {
throw Error(`${type} is not a valid list type, must be one of: ${LIST_TYPES}`);
}
const { startBlock } = editor.value;
const target = editor.getBlockContainer();
switch (get(target, 'type')) {
case 'bulleted-list':
case 'numbered-list': {
const list = target;
if (list.type !== type) {
const newListType = oppositeListType(target.type);
const newList = Block.create(newListType);
editor.withoutNormalizing(() => {
editor.wrapBlock(newList).unwrapNodeByKey(newList.key);
});
} else {
editor.withoutNormalizing(() => {
list.nodes.forEach(listItem => {
if (editor.isSelected(listItem)) {
editor.unwrapListItem(listItem);
}
});
});
}
break;
}
case 'list-item': {
const listItem = target;
const list = editor.value.document.getParent(listItem.key);
if (!editor.isFirstChild(startBlock)) {
editor.wrapInList(type);
} else if (list.type !== type) {
editor.toggleListItemType(listItem);
} else {
editor.unwrapListItem(listItem);
}
break;
}
default: {
editor.wrapInList(type);
break;
}
}
},
},
onKeyDown(event, editor, next) {
// Handle Backspace
if (isHotkey('backspace', event) && editor.value.selection.isCollapsed) {
// If beginning block is not of default type, do nothing
if (editor.value.startBlock.type !== defaultType) {
return next();
}
const listOrListItem = editor.getListOrListItem();
const isListItem = listOrListItem && listOrListItem.type === 'list-item';
// If immediate block is a list item, unwrap it
if (isListItem && editor.value.selection.start.isAtStartOfNode(listOrListItem)) {
const listItem = listOrListItem;
const previousSibling = editor.value.document.getPreviousSibling(listItem.key);
// If this isn't the first item in the list, merge into previous list item
if (previousSibling && previousSibling.type === 'list-item') {
return editor.mergeNodeByKey(listItem.key);
}
return editor.unwrapListItem(listItem);
}
return next();
}
// Handle Tab
if (isHotkey('tab', event) || isHotkey('shift+tab', event)) {
const isTab = isHotkey('tab', event);
const isShiftTab = !isTab;
event.preventDefault();
const listOrListItem = editor.getListOrListItem({ force: true });
if (!listOrListItem) {
return next();
}
if (listOrListItem.type === 'list-item') {
const listItem = listOrListItem;
if (isTab) {
return editor.indentListItems(listItem);
}
if (isShiftTab) {
return editor.unindentListItems(listItem);
}
} else {
const list = listOrListItem;
if (isTab) {
const listItems = editor.getSelectedChildren(list);
return editor.indentListItems(listItems);
}
if (isShiftTab) {
const listItems = editor.getSelectedChildren(list);
return editor.unindentListItems(listItems);
}
}
return next();
}
// Handle Enter
if (isHotkey('enter', event)) {
const listOrListItem = editor.getListOrListItem();
if (!listOrListItem) {
return next();
}
if (editor.value.selection.isExpanded) {
editor.delete();
}
if (listOrListItem.type === 'list-item') {
const listItem = listOrListItem;
// If focus is at start of list item, unwrap the entire list item.
if (editor.atStartOf(listItem)) {
return editor.unwrapListItem(listItem);
}
// If focus is at start of a subsequent block in the list item, move
// everything after the cursor in the current list item to a new list
// item.
if (editor.atStartOf(editor.value.startBlock)) {
const newListItem = Block.create('list-item');
const range = Range.create(editor.value.selection).moveEndToEndOfNode(listItem);
return editor.withoutNormalizing(() => {
editor.wrapBlockAtRange(range, newListItem).unwrapNodeByKey(newListItem.key);
});
}
return next();
} else {
const list = listOrListItem;
if (list.nodes.size === 0) {
return editor.removeNodeByKey(list.key);
}
}
return next();
}
return next();
},
};
};
export default ListPlugin;

View File

@ -0,0 +1,101 @@
import { Block } from 'slate';
import isHotkey from 'is-hotkey';
/**
* TODO: highlight a couple list items and hit the quote button. doesn't work.
*/
const QuoteBlock = ({ type }) => ({
commands: {
/**
* Quotes can contain other blocks, even other quotes. If a selection contains quotes, they
* shouldn't be impacted. The selection's immediate parent should be checked - if it's a
* quote, unwrap the quote (as within are only blocks), and if it's not, wrap all selected
* blocks into a quote. Make sure text is wrapped into paragraphs.
*/
toggleQuoteBlock(editor) {
const blockContainer = editor.getBlockContainer();
if (['bulleted-list', 'numbered-list'].includes(blockContainer.type)) {
const { nodes } = blockContainer;
const allItemsSelected = editor.isSelected([nodes.first(), nodes.last()]);
if (allItemsSelected) {
const nextContainer = editor.getBlockContainer(blockContainer);
if (nextContainer?.type === type) {
editor.unwrapNodeFromAncestor(blockContainer, nextContainer);
} else {
editor.wrapBlockByKey(blockContainer.key, type);
}
} else {
const blockContainerParent = editor.value.document.getParent(blockContainer.key);
editor.withoutNormalizing(() => {
const selectedListItems = nodes.filter(node => editor.isSelected(node));
const newList = Block.create(blockContainer.type);
editor.unwrapNodeByKey(selectedListItems.first());
const offset = editor.getOffset(selectedListItems.first());
editor.insertNodeByKey(blockContainerParent.key, offset + 1, newList);
selectedListItems.forEach(({ key }, idx) =>
editor.moveNodeByKey(key, newList.key, idx),
);
editor.wrapBlockByKey(newList.key, type);
});
}
return;
}
const blocks = editor.value.blocks;
const firstBlockKey = blocks.first().key;
const lastBlockKey = blocks.last().key;
const ancestor = editor.getAncestor(firstBlockKey, lastBlockKey);
if (ancestor.type === type) {
editor.unwrapBlockChildren(ancestor);
} else {
editor.wrapBlock(type);
}
},
},
onKeyDown(event, editor, next) {
if (!isHotkey('enter', event) && !isHotkey('backspace', event)) {
return next();
}
const { selection, startBlock, document: doc } = editor.value;
const parent = doc.getParent(startBlock.key);
const isQuote = parent.type === type;
if (!isQuote) {
return next();
}
if (isHotkey('enter', event)) {
if (selection.isExpanded) {
editor.delete();
}
// If the quote is empty, remove it.
if (editor.atStartOf(parent)) {
return editor.unwrapBlockByKey(parent.key);
}
if (editor.atStartOf(startBlock)) {
const offset = editor.getOffset(startBlock);
return editor
.splitNodeByKey(parent.key, offset)
.unwrapBlockByKey(editor.value.document.getParent(startBlock.key).key);
}
return next();
} else if (isHotkey('backspace', event)) {
if (selection.isExpanded) {
editor.delete();
}
if (!editor.atStartOf(parent)) {
return next();
}
const previousParentSibling = doc.getPreviousSibling(parent.key);
if (previousParentSibling && previousParentSibling.type === type) {
return editor.mergeNodeByKey(parent.key);
}
return editor.unwrapNodeByKey(startBlock.key);
}
return next();
},
});
export default QuoteBlock;

View File

@ -0,0 +1,14 @@
import isHotkey from 'is-hotkey';
const SelectAll = () => ({
onKeyDown(event, editor, next) {
const isModA = isHotkey('mod+a', event);
if (!isModA) {
return next();
}
event.preventDefault();
return editor.moveToRangeOfDocument();
},
});
export default SelectAll;

View File

@ -0,0 +1,47 @@
import { Text, Block } from 'slate';
const createShortcodeBlock = shortcodeConfig => {
// Handle code block component
if (shortcodeConfig.type === 'code-block') {
return Block.create({ type: shortcodeConfig.type, data: { shortcodeNew: true } });
}
const nodes = [Text.create('')];
// Get default values for plugin fields.
const defaultValues = shortcodeConfig.fields
.toMap()
.mapKeys((_, field) => field.get('name'))
.filter(field => field.has('default'))
.map(field => field.get('default'));
// Create new shortcode block with default values set.
return Block.create({
type: 'shortcode',
data: {
shortcode: shortcodeConfig.id,
shortcodeNew: true,
shortcodeData: defaultValues,
},
nodes,
});
};
const Shortcode = ({ defaultType }) => ({
commands: {
insertShortcode(editor, shortcodeConfig) {
const block = createShortcodeBlock(shortcodeConfig);
const { focusBlock } = editor.value;
if (focusBlock.text === '' && focusBlock.type === defaultType) {
editor.replaceNodeByKey(focusBlock.key, block);
} else {
editor.insertBlock(block);
}
editor.focus();
},
},
});
export default Shortcode;

View File

@ -0,0 +1,11 @@
import { castArray, isArray } from 'lodash';
export function assertType(nodes, type) {
const nodesArray = castArray(nodes);
const validate = isArray(type) ? node => type.includes(node.type) : node => type === node.type;
const invalidNode = nodesArray.find(node => !validate(node));
if (invalidNode) {
throw Error(`Expected node of type "${type}", received "${invalidNode.type}".`);
}
return true;
}

View File

@ -0,0 +1,38 @@
//import { Text, Inline } from 'slate';
import isHotkey from 'is-hotkey';
import CommandsAndQueries from './CommandsAndQueries';
import ListPlugin from './List';
import LineBreak from './LineBreak';
import BreakToDefaultBlock from './BreakToDefaultBlock';
import CloseBlock from './CloseBlock';
import QuoteBlock from './QuoteBlock';
import SelectAll from './SelectAll';
import CopyPasteVisual from './CopyPasteVisual';
import Link from './Link';
import ForceInsert from './ForceInsert';
import Shortcode from './Shortcode';
import { SLATE_DEFAULT_BLOCK_TYPE as defaultType } from '../../types';
const plugins = ({ getAsset, resolveWidget }) => [
{
onKeyDown(event, editor, next) {
if (isHotkey('mod+j', event)) {
console.log(JSON.stringify(editor.value.document.toJS(), null, 2));
}
next();
},
},
CommandsAndQueries({ defaultType }),
QuoteBlock({ defaultType, type: 'quote' }),
ListPlugin({ defaultType, unorderedListType: 'bulleted-list', orderedListType: 'numbered-list' }),
Link({ type: 'link' }),
LineBreak(),
BreakToDefaultBlock({ defaultType }),
CloseBlock({ defaultType }),
SelectAll(),
ForceInsert({ defaultType }),
CopyPasteVisual({ getAsset, resolveWidget }),
Shortcode({ defaultType }),
];
export default plugins;

View File

@ -1,7 +1,118 @@
/* eslint-disable react/prop-types */
/* eslint-disable react/display-name */
import React from 'react';
import Shortcode from './Shortcode';
import { css } from '@emotion/core';
import styled from '@emotion/styled';
import { colors, lengths } from 'netlify-cms-ui-default';
import VoidBlock from './components/VoidBlock';
import Shortcode from './components/Shortcode';
const bottomMargin = '16px';
const headerStyles = `
font-weight: 700;
line-height: 1;
`;
const StyledH1 = styled.h1`
${headerStyles};
font-size: 32px;
margin-top: 16px;
`;
const StyledH2 = styled.h2`
${headerStyles};
font-size: 24px;
margin-top: 12px;
`;
const StyledH3 = styled.h3`
${headerStyles};
font-size: 20px;
`;
const StyledH4 = styled.h4`
${headerStyles};
font-size: 18px;
margin-top: 8px;
`;
const StyledH5 = styled.h5`
${headerStyles};
font-size: 16px;
margin-top: 8px;
`;
const StyledH6 = StyledH5.withComponent('h6');
const StyledP = styled.p`
margin-bottom: ${bottomMargin};
`;
const StyledBlockQuote = styled.blockquote`
padding-left: 16px;
border-left: 3px solid ${colors.background};
margin-left: 0;
margin-right: 0;
margin-bottom: ${bottomMargin};
`;
const StyledPre = styled.pre`
margin-bottom: ${bottomMargin};
white-space: pre-wrap;
& > code {
display: block;
width: 100%;
overflow-y: auto;
background-color: #000;
color: #ccc;
border-radius: ${lengths.borderRadius};
padding: 10px;
}
`;
const StyledCode = styled.code`
background-color: ${colors.background};
border-radius: ${lengths.borderRadius};
padding: 0 2px;
font-size: 85%;
`;
const StyledUl = styled.ul`
margin-bottom: ${bottomMargin};
padding-left: 30px;
`;
const StyledOl = StyledUl.withComponent('ol');
const StyledLi = styled.li`
& > p:first-child {
margin-top: 8px;
}
& > p:last-child {
margin-bottom: 8px;
}
`;
const StyledA = styled.a`
text-decoration: underline;
`;
const StyledHr = styled.hr`
border: 1px solid;
margin-bottom: 16px;
`;
const StyledTable = styled.table`
border-collapse: collapse;
`;
const StyledTd = styled.td`
border: 2px solid black;
padding: 8px;
text-align: left;
`;
/**
* Slate uses React components to render each type of node that it receives.
@ -15,56 +126,63 @@ import Shortcode from './Shortcode';
const Bold = props => <strong>{props.children}</strong>;
const Italic = props => <em>{props.children}</em>;
const Strikethrough = props => <s>{props.children}</s>;
const Code = props => <code>{props.children}</code>;
const Code = props => <StyledCode>{props.children}</StyledCode>;
/**
* Node Components
*/
const Paragraph = props => <p {...props.attributes}>{props.children}</p>;
const ListItem = props => <li {...props.attributes}>{props.children}</li>;
const Quote = props => <blockquote {...props.attributes}>{props.children}</blockquote>;
const Paragraph = props => <StyledP {...props.attributes}>{props.children}</StyledP>;
const ListItem = props => <StyledLi {...props.attributes}>{props.children}</StyledLi>;
const Quote = props => <StyledBlockQuote {...props.attributes}>{props.children}</StyledBlockQuote>;
const CodeBlock = props => (
<pre>
<code {...props.attributes}>{props.children}</code>
</pre>
<StyledPre>
<StyledCode {...props.attributes}>{props.children}</StyledCode>
</StyledPre>
);
const HeadingOne = props => <h1 {...props.attributes}>{props.children}</h1>;
const HeadingTwo = props => <h2 {...props.attributes}>{props.children}</h2>;
const HeadingThree = props => <h3 {...props.attributes}>{props.children}</h3>;
const HeadingFour = props => <h4 {...props.attributes}>{props.children}</h4>;
const HeadingFive = props => <h5 {...props.attributes}>{props.children}</h5>;
const HeadingSix = props => <h6 {...props.attributes}>{props.children}</h6>;
const HeadingOne = props => <StyledH1 {...props.attributes}>{props.children}</StyledH1>;
const HeadingTwo = props => <StyledH2 {...props.attributes}>{props.children}</StyledH2>;
const HeadingThree = props => <StyledH3 {...props.attributes}>{props.children}</StyledH3>;
const HeadingFour = props => <StyledH4 {...props.attributes}>{props.children}</StyledH4>;
const HeadingFive = props => <StyledH5 {...props.attributes}>{props.children}</StyledH5>;
const HeadingSix = props => <StyledH6 {...props.attributes}>{props.children}</StyledH6>;
const Table = props => (
<table>
<StyledTable>
<tbody {...props.attributes}>{props.children}</tbody>
</table>
</StyledTable>
);
const TableRow = props => <tr {...props.attributes}>{props.children}</tr>;
const TableCell = props => <td {...props.attributes}>{props.children}</td>;
const ThematicBreak = props => <hr {...props.attributes} />;
const BulletedList = props => <ul {...props.attributes}>{props.children}</ul>;
const TableCell = props => <StyledTd {...props.attributes}>{props.children}</StyledTd>;
const ThematicBreak = props => (
<StyledHr
{...props.attributes}
css={
props.editor.isSelected(props.node) &&
css`
box-shadow: 0 0 0 2px ${colors.active};
border-radius: 8px;
color: ${colors.active};
`
}
/>
);
const Break = props => <br {...props.attributes} />;
const BulletedList = props => <StyledUl {...props.attributes}>{props.children}</StyledUl>;
const NumberedList = props => (
<ol {...props.attributes} start={props.node.data.get('start') || 1}>
<StyledOl {...props.attributes} start={props.node.data.get('start') || 1}>
{props.children}
</ol>
</StyledOl>
);
const Link = props => {
const data = props.node.get('data');
const marks = data.get('marks');
const url = data.get('url');
const title = data.get('title');
const link = (
<a href={url} title={title} {...props.attributes}>
return (
<StyledA href={url} title={title} {...props.attributes}>
{props.children}
</a>
</StyledA>
);
const result = !marks
? link
: marks.reduce((acc, mark) => {
return renderMark({ mark, children: acc });
}, link);
return result;
};
const Image = props => {
const data = props.node.get('data');
const marks = data.get('marks');
@ -80,7 +198,7 @@ const Image = props => {
return result;
};
export const renderMark = props => {
export const renderMark = () => props => {
switch (props.mark.type) {
case 'bold':
return <Bold {...props} />;
@ -93,7 +211,18 @@ export const renderMark = props => {
}
};
export const renderNode = props => {
export const renderInline = () => props => {
switch (props.node.type) {
case 'link':
return <Link {...props} />;
case 'image':
return <Image {...props} />;
case 'break':
return <Break {...props} />;
}
};
export const renderBlock = ({ classNameWrapper, codeBlockComponent }) => props => {
switch (props.node.type) {
case 'paragraph':
return <Paragraph {...props} />;
@ -101,7 +230,19 @@ export const renderNode = props => {
return <ListItem {...props} />;
case 'quote':
return <Quote {...props} />;
case 'code':
case 'code-block':
if (codeBlockComponent) {
return (
<VoidBlock {...props}>
<Shortcode
classNameWrapper={classNameWrapper}
typeOverload="code-block"
dataKey={false}
{...props}
/>
</VoidBlock>
);
}
return <CodeBlock {...props} />;
case 'heading-one':
return <HeadingOne {...props} />;
@ -122,16 +263,20 @@ export const renderNode = props => {
case 'table-cell':
return <TableCell {...props} />;
case 'thematic-break':
return <ThematicBreak {...props} />;
return (
<VoidBlock {...props}>
<ThematicBreak editor={props.editor} node={props.node} />
</VoidBlock>
);
case 'bulleted-list':
return <BulletedList {...props} />;
case 'numbered-list':
return <NumberedList {...props} />;
case 'link':
return <Link {...props} />;
case 'image':
return <Image {...props} />;
case 'shortcode':
return <Shortcode {...props} />;
return (
<VoidBlock {...props}>
<Shortcode classNameWrapper={classNameWrapper} {...props} />
</VoidBlock>
);
}
};

View File

@ -0,0 +1,310 @@
import { Inline, Text } from 'slate';
const codeBlock = {
match: [{ object: 'block', type: 'code-block' }],
nodes: [
{
match: [{ object: 'text' }],
},
],
normalize: (editor, error) => {
switch (error.code) {
// Replace break nodes with newlines
case 'child_object_invalid': {
const { child } = error;
if (Inline.isInline(child) && child.type === 'break') {
editor.replaceNodeByKey(child.key, Text.create({ text: '\n' }));
return;
}
}
}
},
};
const codeBlockOverride = {
match: [{ object: 'block', type: 'code-block' }],
isVoid: true,
};
const schema = ({ voidCodeBlock } = {}) => ({
rules: [
/**
* Document
*/
{
match: [{ object: 'document' }],
nodes: [
{
match: [
{ type: 'paragraph' },
{ type: 'heading-one' },
{ type: 'heading-two' },
{ type: 'heading-three' },
{ type: 'heading-four' },
{ type: 'heading-five' },
{ type: 'heading-six' },
{ type: 'quote' },
{ type: 'code-block' },
{ type: 'bulleted-list' },
{ type: 'numbered-list' },
{ type: 'thematic-break' },
{ type: 'table' },
{ type: 'shortcode' },
],
min: 1,
},
],
normalize: (editor, error) => {
switch (error.code) {
// If no blocks present, insert one.
case 'child_min_invalid': {
const node = { object: 'block', type: 'paragraph' };
editor.insertNodeByKey(error.node.key, 0, node);
return;
}
}
},
},
/**
* Block Containers
*/
{
match: [
{ object: 'block', type: 'quote' },
{ object: 'block', type: 'list-item' },
],
nodes: [
{
match: [
{ type: 'paragraph' },
{ type: 'heading-one' },
{ type: 'heading-two' },
{ type: 'heading-three' },
{ type: 'heading-four' },
{ type: 'heading-five' },
{ type: 'heading-six' },
{ type: 'quote' },
{ type: 'code-block' },
{ type: 'bulleted-list' },
{ type: 'numbered-list' },
{ type: 'thematic-break' },
{ type: 'table' },
],
},
],
},
/**
* List Items
*/
{
match: [{ object: 'block', type: 'list-item' }],
parent: [{ type: 'bulleted-list' }, { type: 'numbered-list' }],
/*
normalize: (editor, error) => {
switch (error.code) {
// If a list item is wrapped in something other than a list, wrap it
// in a list. This is only known to happen when toggling blockquote
// with multiple list items selected.
case 'parent_type_invalid': {
const parent = editor.value.document.getParent(error.node.key)
const grandparent = editor.value.document.getParent(parent.key)
console.log(editor.value.blocks)
if (
!editor.everyBlock('list-item') ||
!editor.areSiblings(editor.value.blocks) ||
!['bulleted-list', 'numbered-list'].includes(grandparent.type)
) {
return;
}
editor.withoutNormalizing(() => {
const entireListSelected = editor.value.blocks.length === parent.nodes.length;
editor.setNodeByKey(parent.key, grandparent.type);
console.log(entireListSelected)
console.log(JSON.stringify(editor.value.document.toJS(), null, 2))
// Wrap the entire list if all list items are selected
if (entireListSelected) {
editor.setNodeByKey(grandparent.key, parent.type)
} else {
editor
.wrapBlockByKey(parent.key, parent.type)
.wrapBlockByKey(grandparent.key, 'list-item');
}
})
return;
}
}
},
*/
},
/**
* Blocks
*/
{
match: [
{ object: 'block', type: 'paragraph' },
{ object: 'block', type: 'heading-one' },
{ object: 'block', type: 'heading-two' },
{ object: 'block', type: 'heading-three' },
{ object: 'block', type: 'heading-four' },
{ object: 'block', type: 'heading-five' },
{ object: 'block', type: 'heading-six' },
{ object: 'block', type: 'table-cell' },
{ object: 'inline', type: 'link' },
],
nodes: [
{
match: [{ object: 'text' }, { type: 'link' }, { type: 'image' }, { type: 'break' }],
},
],
},
/**
* Bulleted List
*/
{
match: [{ object: 'block', type: 'bulleted-list' }],
nodes: [
{
match: [{ type: 'list-item' }],
min: 1,
},
],
next: [
{ type: 'paragraph' },
{ type: 'heading-one' },
{ type: 'heading-two' },
{ type: 'heading-three' },
{ type: 'heading-four' },
{ type: 'heading-five' },
{ type: 'heading-six' },
{ type: 'quote' },
{ type: 'code-block' },
{ type: 'numbered-list' },
{ type: 'thematic-break' },
{ type: 'table' },
{ type: 'shortcode' },
],
normalize: (editor, error) => {
switch (error.code) {
// If a list has no list items, remove the list
case 'child_min_invalid':
editor.removeNodeByKey(error.node.key);
return;
// If two bulleted lists are immediately adjacent, join them
case 'next_sibling_type_invalid':
if (error.next.type === 'bulleted-list') {
editor.mergeNodeByKey(error.next.key);
}
return;
}
},
},
/**
* Numbered List
*/
{
match: [{ object: 'block', type: 'numbered-list' }],
nodes: [
{
match: [{ type: 'list-item' }],
min: 1,
},
],
next: [
{ type: 'paragraph' },
{ type: 'heading-one' },
{ type: 'heading-two' },
{ type: 'heading-three' },
{ type: 'heading-four' },
{ type: 'heading-five' },
{ type: 'heading-six' },
{ type: 'quote' },
{ type: 'code-block' },
{ type: 'bulleted-list' },
{ type: 'thematic-break' },
{ type: 'table' },
{ type: 'shortcode' },
],
normalize: (editor, error) => {
switch (error.code) {
// If a list has no list items, remove the list
case 'child_min_invalid':
editor.removeNodeByKey(error.node.key);
return;
// If two numbered lists are immediately adjacent, join them
case 'next_sibling_type_invalid': {
if (error.next.type === 'numbered-list') {
editor.mergeNodeByKey(error.next.key);
}
return;
}
}
},
},
/**
* Voids
*/
{
match: [
{ object: 'inline', type: 'image' },
{ object: 'inline', type: 'break' },
{ object: 'block', type: 'thematic-break' },
{ object: 'block', type: 'shortcode' },
],
isVoid: true,
},
/**
* Table
*/
{
match: [{ object: 'block', type: 'table' }],
nodes: [
{
match: [{ object: 'block', type: 'table-row' }],
},
],
},
/**
* Table Row
*/
{
match: [{ object: 'block', type: 'table-row' }],
nodes: [
{
match: [{ object: 'block', type: 'table-cell' }],
},
],
},
/**
* Marks
*/
{
match: [
{ object: 'mark', type: 'bold' },
{ object: 'mark', type: 'italic' },
{ object: 'mark', type: 'strikethrough' },
{ object: 'mark', type: 'code' },
],
},
/**
* Overrides
*/
voidCodeBlock ? codeBlockOverride : codeBlock,
],
});
export default schema;

View File

@ -1,89 +0,0 @@
import { Block, Text } from 'slate';
/**
* Validation functions are used to validate the editor state each time it
* changes, to ensure it is never rendered in an undesirable state.
*/
export function validateNode(node) {
/**
* Validation of the document itself.
*/
if (node.object === 'document') {
const doc = node;
/**
* If the editor is ever in an empty state, insert an empty
* paragraph block.
*/
const hasBlocks = !doc.getBlocks().isEmpty();
if (!hasBlocks) {
return change => {
const block = Block.create({
type: 'paragraph',
nodes: [Text.create('')],
});
const { key } = change.value.document;
return change.insertNodeByKey(key, 0, block).focus();
};
}
/**
* Ensure that shortcodes are children of the root node.
*/
const nestedShortcode = doc.findDescendant(descendant => {
const { type, key } = descendant;
return type === 'shortcode' && doc.getParent(key).key !== doc.key;
});
if (nestedShortcode) {
const unwrapShortcode = change => {
const key = nestedShortcode.key;
const newDoc = change.value.document;
const newParent = newDoc.getParent(key);
const docIsParent = newParent.key === newDoc.key;
const newParentParent = newDoc.getParent(newParent.key);
const docIsParentParent = newParentParent && newParentParent.key === newDoc.key;
if (docIsParent) {
return change;
}
/**
* Normalization happens by default, and causes all validation to
* restart with the result of a change upon execution. This unwrap loop
* could temporarily place a shortcode node in conflict with an outside
* plugin's schema, resulting in an infinite loop. To ensure against
* this, we turn off normalization until the last change.
*/
change.unwrapNodeByKey(nestedShortcode.key, { normalize: docIsParentParent });
};
return unwrapShortcode;
}
/**
* Ensure that trailing shortcodes are followed by an empty paragraph.
*/
const trailingShortcode = doc.findDescendant(descendant => {
const { type, key } = descendant;
return type === 'shortcode' && doc.getBlocks().last().key === key;
});
if (trailingShortcode) {
return change => {
const text = Text.create('');
const block = Block.create({ type: 'paragraph', nodes: [text] });
return change.insertNodeByKey(doc.key, doc.get('nodes').size, block);
};
}
}
/**
* Ensure that code blocks contain no marks.
*/
if (node.type === 'code') {
const invalidChild = node.getTexts().find(text => !text.getMarks().isEmpty());
if (invalidChild) {
return change =>
invalidChild
.getMarks()
.forEach(mark =>
change.removeMarkByKey(invalidChild.key, 0, invalidChild.get('characters').size, mark),
);
}
}
}

View File

@ -1,116 +0,0 @@
import { colors, lengths, fonts } from 'netlify-cms-ui-default';
import { editorStyleVars } from '../styles';
export default `
position: relative;
overflow: hidden;
overflow-x: auto;
min-height: ${lengths.richTextEditorMinHeight};
font-family: ${fonts.primary};
border-top-left-radius: 0;
border-top-right-radius: 0;
border-top: 0;
margin-top: -${editorStyleVars.stickyDistanceBottom};
h1 {
font-size: 32px;
margin-top: 16px;
}
h2 {
font-size: 24px;
margin-top: 12px;
}
h3 {
font-size: 20px;
margin-top: 8px;
}
h4 {
font-size: 18px;
margin-top: 8px;
}
h5,
h6 {
font-size: 16px;
margin-top: 8px;
}
h1,
h2,
h3,
h4,
h5,
h6 {
font-weight: 700;
line-height: 1;
}
p,
pre,
blockquote,
ul,
ol {
margin-top: 16px;
margin-bottom: 16px;
}
a {
text-decoration: underline;
}
hr {
border: 1px solid;
margin-bottom: 16px;
}
li > p {
margin: 0;
}
ul,
ol {
padding-left: 30px;
}
pre {
white-space: pre-wrap;
}
code {
background-color: ${colors.background};
border-radius: ${lengths.borderRadius};
padding: 0 2px;
font-size: 85%;
}
pre > code {
display: block;
width: 100%;
overflow-y: auto;
background-color: #000;
color: #ccc;
border-radius: ${lengths.borderRadius};
padding: 10px;
}
blockquote {
padding-left: 16px;
border-left: 3px solid ${colors.background};
margin-left: 0;
margin-right: 0;
}
table {
border-collapse: collapse;
}
td,
th {
border: 2px solid black;
padding: 8px;
text-align: left;
}
`;

View File

@ -1,18 +1,29 @@
import React from 'react';
import React, { useEffect } from 'react';
import PropTypes from 'prop-types';
import { WidgetPreviewContainer } from 'netlify-cms-ui-default';
import { markdownToHtml } from './serializers';
const MarkdownPreview = ({ value, getAsset }) => {
let editorPreview;
export const getEditorPreview = () => editorPreview;
const MarkdownPreview = props => {
const { value, getAsset, resolveWidget } = props;
useEffect(() => {
editorPreview = props.editorPreview;
}, []);
if (value === null) {
return null;
}
const html = markdownToHtml(value, getAsset);
const html = markdownToHtml(value, { getAsset, resolveWidget });
return <WidgetPreviewContainer dangerouslySetInnerHTML={{ __html: html }} />;
};
MarkdownPreview.propTypes = {
getAsset: PropTypes.func.isRequired,
editorPreview: PropTypes.func.isRequired,
resolveWidget: PropTypes.func.isRequired,
value: PropTypes.string,
};

View File

@ -55,7 +55,7 @@ exports[`Markdown Preview renderer Markdown rendering General should render mark
dangerouslySetInnerHTML={
Object {
"__html": "<h1>H1</h1>
<p>Text with <strong>bold</strong> &#x26; <em>em</em> elements</p>
<p>Text with <strong>bold</strong> &amp; <em>em</em> elements</p>
<h2>H2</h2>
<ul>
<li>ul item 1</li>
@ -70,9 +70,9 @@ exports[`Markdown Preview renderer Markdown rendering General should render mark
<h4>H4</h4>
<p><a href=\\"http://google.com\\">link title</a></p>
<h5>H5</h5>
<p><img src=\\"https://pbs.twimg.com/profile_images/678903331176214528/TQTdqGwD.jpg\\" alt=\\"alt text\\"></p>
<p><img src=\\"https://pbs.twimg.com/profile_images/678903331176214528/TQTdqGwD.jpg\\" alt=\\"alt text\\" /></p>
<h6>H6</h6>
<p><img src=\\"https://pbs.twimg.com/profile_images/678903331176214528/TQTdqGwD.jpg\\"></p>",
<p><img src=\\"https://pbs.twimg.com/profile_images/678903331176214528/TQTdqGwD.jpg\\" /></p>",
}
}
/>
@ -207,36 +207,3 @@ exports[`Markdown Preview renderer Markdown rendering Links should render links
}
/>
`;
exports[`Markdown Preview renderer Markdown rendering Lists should render lists 1`] = `
.emotion-0 {
margin: 15px 2px;
}
<div
className="emotion-0 emotion-1"
dangerouslySetInnerHTML={
Object {
"__html": "<ol>
<li>ol item 1</li>
<li>
<p>ol item 2</p>
<ul>
<li>Sublist 1</li>
<li>Sublist 2</li>
<li>
<p>Sublist 3</p>
<ol>
<li>Sub-Sublist 1</li>
<li>Sub-Sublist 2</li>
<li>Sub-Sublist 3</li>
</ol>
</li>
</ul>
</li>
<li>ol item 3</li>
</ol>",
}
}
/>
`;

View File

@ -74,18 +74,43 @@ Text with **bold** & _em_ elements
renderer
.create(<MarkdownPreview value={markdownToHtml(value)} getAsset={jest.fn()} />)
.toJSON(),
).toMatchSnapshot();
).toMatchInlineSnapshot(`
.emotion-0 {
margin: 15px 2px;
}
<div
className="emotion-0 emotion-1"
dangerouslySetInnerHTML={
Object {
"__html": "<ol>
<li>ol item 1</li>
<li>ol item 2<ul>
<li>Sublist 1</li>
<li>Sublist 2</li>
<li>Sublist 3<ol>
<li>Sub-Sublist 1</li>
<li>Sub-Sublist 2</li>
<li>Sub-Sublist 3</li>
</ol></li>
</ul></li>
<li>ol item 3</li>
</ol>",
}
}
/>
`);
});
});
describe('Links', () => {
it('should render links', () => {
const value = `
I get 10 times more traffic from [Google] [1] than from [Yahoo] [2] or [MSN] [3].
I get 10 times more traffic from [Google] than from [Yahoo] or [MSN].
[1]: http://google.com/ "Google"
[2]: http://search.yahoo.com/ "Yahoo Search"
[3]: http://search.msn.com/ "MSN Search"
[Google]: http://google.com/ "Google"
[Yahoo]: http://search.yahoo.com/ "Yahoo Search"
[MSN]: http://search.msn.com/ "MSN Search"
`;
expect(
renderer

View File

@ -1,45 +1,124 @@
import { flow, trim } from 'lodash';
import commonmarkSpec from './__fixtures__/commonmark.json';
import expected from './__fixtures__/commonmarkExpected.json';
import { markdownToSlate, slateToMarkdown, markdownToHtml } from '../index.js';
import { flow } from 'lodash';
import { tests as commonmarkSpec } from 'commonmark-spec';
import commonmark from 'commonmark';
import { markdownToSlate, slateToMarkdown } from '../index.js';
/**
* Map the commonmark spec data into an array of arrays for use in Jest's
* `test.each`.
*/
const spec = commonmarkSpec.map(({ markdown, html }) => [markdown, html]);
const skips = [
{
number: [456],
reason: 'Remark ¯\\_(ツ)_/¯',
},
{
number: [416, 417, 424, 425, 426, 431, 457, 460, 462, 464, 467],
reason: 'Remark does not support infinite (redundant) nested marks',
},
{
number: [455, 469, 470, 471],
reason: 'Remark parses the initial set of identical nested delimiters first',
},
{
number: [473, 476, 478, 480],
reason: 'we convert underscores to asterisks for strong/emphasis',
},
{ number: 490, reason: 'Remark strips pointy enclosing pointy brackets from link url' },
{ number: 503, reason: 'Remark allows non-breaking space between link url and title' },
{ number: 507, reason: 'Remark allows a space between link alt and url' },
{
number: [
511,
516,
525,
528,
529,
530,
532,
533,
534,
540,
541,
542,
543,
546,
548,
560,
565,
567,
],
reason: 'we convert link references to standard links, but Remark also fails these',
},
{
number: [569, 570, 571, 572, 573, 581, 585],
reason: 'Remark does not recognize or remove marks in image alt text',
},
{ number: 589, reason: 'Remark does not honor backslash escape of image exclamation point' },
{ number: 593, reason: 'Remark removes "mailto:" from autolink text' },
{ number: 599, reason: 'Remark does not escape all expected entities' },
{ number: 602, reason: 'Remark allows autolink emails to contain backslashes' },
];
const onlys = [
// just add the spec number, eg:
// 431,
];
/**
* Each test receives input markdown and output html as expected for Commonmark
* compliance. To test all of our handling in one go, we serialize the markdown
* into our Slate AST, then back to raw markdown, and finally to HTML.
*/
const process = flow([markdownToSlate, slateToMarkdown, markdownToHtml]);
const reader = new commonmark.Parser();
const writer = new commonmark.HtmlRenderer();
const parseWithCommonmark = markdown => {
const parsed = reader.parse(markdown);
return writer.render(parsed);
};
const parse = flow([markdownToSlate, slateToMarkdown]);
/**
* Passing this test suite requires 100% Commonmark compliance. There are 624
* tests, of which we're passing about 300 as of introduction of this suite. To
* work on improving Commonmark support, update __fixtures__/commonmarkExpected.json
*/
describe.skip('Commonmark support', function() {
const specs =
onlys.length > 0
? commonmarkSpec.filter(({ number }) => onlys.includes(number))
: commonmarkSpec;
specs.forEach(spec => {
const skip = skips.find(({ number }) => {
return Array.isArray(number) ? number.includes(spec.number) : number === spec.number;
});
const specUrl = `https://spec.commonmark.org/0.29/#example-${spec.number}`;
const parsed = parse(spec.markdown);
const commonmarkParsedHtml = parseWithCommonmark(parsed);
const description = `
${spec.section}
${specUrl}
describe('Commonmark support', () => {
test.each(spec)('%s', (markdown, html) => {
// We're trimming the html from the spec as they all have trailing newlines
// and we never output trailing newlines. This may be a compliance issue.
const trimmedHtml = trim(html);
Spec:
${JSON.stringify(spec, null, 2)}
switch (expected[markdown]) {
case 'TO_EQUAL':
expect(process(markdown)).toEqual(trimmedHtml);
break;
case 'NOT_TO_EQUAL':
expect(process(markdown)).not.toEqual(trimmedHtml);
break;
case 'TO_ERROR':
expect(() => process(markdown)).toThrowError();
break;
default:
throw new Error('Unknown expected type: ' + expected[markdown]);
Markdown input:
${spec.markdown}
Markdown parsed through Slate/Remark and back to Markdown:
${parsed}
HTML output:
${commonmarkParsedHtml}
Expected HTML output:
${spec.html}
`;
if (skip) {
const showMessage = Array.isArray(skip.number) ? skip.number[0] === spec.number : true;
if (showMessage) {
//console.log(`skipping spec ${skip.number}\n${skip.reason}\n${specUrl}`);
}
}
const testFn = skip ? test.skip : test;
testFn(description, () => {
expect(commonmarkParsedHtml).toEqual(spec.html);
});
});
});

View File

@ -1,4 +1,7 @@
/** @jsx h */
import { flow } from 'lodash';
import h from '../../../test-helpers/h';
import { markdownToSlate, slateToMarkdown } from '../index';
const process = flow([markdownToSlate, slateToMarkdown]);
@ -12,8 +15,16 @@ describe('slate', () => {
expect(process('**a[b](c)d**')).toEqual('**a[b](c)d**');
expect(process('**[a](b)**')).toEqual('**[a](b)**');
expect(process('**![a](b)**')).toEqual('**![a](b)**');
expect(process('_`a`_')).toEqual('_`a`_');
expect(process('_`a`b_')).toEqual('_`a`b_');
expect(process('_`a`_')).toEqual('*`a`*');
});
it('should handle unstyled code nodes adjacent to styled code nodes', () => {
expect(process('`foo`***`bar`***')).toEqual('`foo`***`bar`***');
});
it('should handle styled code nodes adjacent to non-code text', () => {
expect(process('_`a`b_')).toEqual('*`a`b*');
expect(process('_`a`**b**_')).toEqual('*`a`**b***');
});
it('should condense adjacent, identically styled text and inline nodes', () => {
@ -23,7 +34,8 @@ describe('slate', () => {
it('should handle nested markdown entities', () => {
expect(process('**a**b**c**')).toEqual('**a**b**c**');
expect(process('**a _b_ c**')).toEqual('**a _b_ c**');
expect(process('**a _b_ c**')).toEqual('**a *b* c**');
expect(process('*`a`*')).toEqual('*`a`*');
});
it('should parse inline images as images', () => {
@ -34,57 +46,249 @@ describe('slate', () => {
expect(process('<span>*</span>')).toEqual('<span>*</span>');
});
it('should wrap break tags in surrounding marks', () => {
expect(process('*a \nb*')).toEqual('*a\\\nb*');
});
it('should not output empty headers in markdown', () => {
// prettier-ignore
const slateAst = (
<document>
<heading-one></heading-one>
<paragraph>foo</paragraph>
<heading-one></heading-one>
</document>
);
expect(slateToMarkdown(slateAst.toJSON())).toMatchInlineSnapshot(`"foo"`);
});
it('should not output empty marks in markdown', () => {
// prettier-ignore
const slateAst = (
<document>
<paragraph>
<b></b>
foo<i><b></b></i>bar
<b></b>baz<i></i>
</paragraph>
</document>
);
expect(slateToMarkdown(slateAst.toJSON())).toMatchInlineSnapshot(`"foobarbaz"`);
});
it('should not produce invalid markdown when a styled block has trailing whitespace', () => {
const slateAst = {
object: 'block',
type: 'root',
nodes: [
{
object: 'block',
type: 'paragraph',
nodes: [
{
object: 'text',
data: undefined,
leaves: [
{
text: 'foo ', // <--
marks: [{ type: 'bold' }],
},
],
},
{ object: 'text', data: undefined, leaves: [{ text: 'bar' }] },
],
},
],
};
expect(slateToMarkdown(slateAst)).toEqual('**foo** bar');
// prettier-ignore
const slateAst = (
<document>
<paragraph>
<b>foo </b>bar <b>bim </b><b><i>bam</i></b>
</paragraph>
</document>
);
expect(slateToMarkdown(slateAst.toJSON())).toMatchInlineSnapshot(`"**foo** bar **bim *bam***"`);
});
it('should not produce invalid markdown when a styled block has leading whitespace', () => {
const slateAst = {
object: 'block',
type: 'root',
nodes: [
{
object: 'block',
type: 'paragraph',
nodes: [
{ object: 'text', data: undefined, leaves: [{ text: 'foo' }] },
{
object: 'text',
data: undefined,
leaves: [
{
text: ' bar', // <--
marks: [{ type: 'bold' }],
},
],
},
],
},
],
};
expect(slateToMarkdown(slateAst)).toEqual('foo **bar**');
// prettier-ignore
const slateAst = (
<document>
<paragraph>
foo<b> bar</b>
</paragraph>
</document>
);
expect(slateToMarkdown(slateAst.toJSON())).toMatchInlineSnapshot(`"foo **bar**"`);
});
it('should group adjacent marks into a single mark when possible', () => {
// prettier-ignore
const slateAst = (
<document>
<paragraph>
<b>shared mark</b>
<link url="link">
<b><i>link</i></b>
</link>
{' '}
<b>not shared mark</b>
<link url="link">
<i>another </i>
<b><i>link</i></b>
</link>
</paragraph>
</document>
);
expect(slateToMarkdown(slateAst.toJSON())).toMatchInlineSnapshot(
`"**shared mark*[link](link)*** **not shared mark***[another **link**](link)*"`,
);
});
describe('links', () => {
it('should handle inline code in link content', () => {
// prettier-ignore
const slateAst = (
<document>
<paragraph>
<link url="link">
<code>foo</code>
</link>
</paragraph>
</document>
);
expect(slateToMarkdown(slateAst.toJSON())).toMatchInlineSnapshot(`"[\`foo\`](link)"`);
});
});
describe('code marks', () => {
it('can contain other marks', () => {
// prettier-ignore
const slateAst = (
<document>
<paragraph>
<code><i><b>foo</b></i></code>
</paragraph>
</document>
);
expect(slateToMarkdown(slateAst.toJSON())).toMatchInlineSnapshot(`"***\`foo\`***"`);
});
it('can be condensed when no other marks are present', () => {
// prettier-ignore
const slateAst = (
<document>
<paragraph>
<code>foo</code>
<code>bar</code>
</paragraph>
</document>
);
expect(slateToMarkdown(slateAst.toJSON())).toMatchInlineSnapshot(`"\`foo\`"`);
});
});
describe('with nested styles within a single word', () => {
it('should not produce invalid markdown when a bold word has italics applied to a smaller part', () => {
// prettier-ignore
const slateAst = (
<document>
<paragraph>
<b>h</b>
<b><i>e</i></b>
<b>y</b>
</paragraph>
</document>
);
expect(slateToMarkdown(slateAst.toJSON())).toMatchInlineSnapshot(`"**h*e*y**"`);
});
it('should not produce invalid markdown when an italic word has bold applied to a smaller part', () => {
// prettier-ignore
const slateAst = (
<document>
<paragraph>
<i>h</i>
<i><b>e</b></i>
<i>y</i>
</paragraph>
</document>
);
expect(slateToMarkdown(slateAst.toJSON())).toMatchInlineSnapshot(`"*h**e**y*"`);
});
it('should handle italics inside bold inside strikethrough', () => {
// prettier-ignore
const slateAst = (
<document>
<paragraph>
<s>h</s>
<s><b>e</b></s>
<s><b><i>l</i></b></s>
<s><b>l</b></s>
<s>o</s>
</paragraph>
</document>
);
expect(slateToMarkdown(slateAst.toJSON())).toMatchInlineSnapshot(`"~~h**e*l*l**o~~"`);
});
it('should handle bold inside italics inside strikethrough', () => {
// prettier-ignore
const slateAst = (
<document>
<paragraph>
<s>h</s>
<s><i>e</i></s>
<s><i><b>l</b></i></s>
<s><i>l</i></s>
<s>o</s>
</paragraph>
</document>
);
expect(slateToMarkdown(slateAst.toJSON())).toMatchInlineSnapshot(`"~~h*e**l**l*o~~"`);
});
it('should handle strikethrough inside italics inside bold', () => {
// prettier-ignore
const slateAst = (
<document>
<paragraph>
<b>h</b>
<b><i>e</i></b>
<b><i><s>l</s></i></b>
<b><i>l</i></b>
<b>o</b>
</paragraph>
</document>
);
expect(slateToMarkdown(slateAst.toJSON())).toMatchInlineSnapshot(`"**h*e~~l~~l*o**"`);
});
it('should handle italics inside strikethrough inside bold', () => {
// prettier-ignore
const slateAst = (
<document>
<paragraph>
<b>h</b>
<b><s>e</s></b>
<b><s><i>l</i></s></b>
<b><s>l</s></b>
<b>o</b>
</paragraph>
</document>
);
expect(slateToMarkdown(slateAst.toJSON())).toMatchInlineSnapshot(`"**h~~e*l*l~~o**"`);
});
it('should handle strikethrough inside bold inside italics', () => {
// prettier-ignore
const slateAst = (
<document>
<paragraph>
<i>h</i>
<i><b>e</b></i>
<i><b><s>l</s></b></i>
<i><b>l</b></i>
<i>o</i>
</paragraph>
</document>
);
expect(slateToMarkdown(slateAst.toJSON())).toMatchInlineSnapshot(`"*h**e~~l~~l**o*"`);
});
it('should handle bold inside strikethrough inside italics', () => {
// prettier-ignore
const slateAst = (
<document>
<paragraph>
<i>h</i>
<i><s>e</s></i>
<i><s><b>l</b></s></i>
<i><s>l</s></i>
<i>o</i>
</paragraph>
</document>
);
expect(slateToMarkdown(slateAst.toJSON())).toMatchInlineSnapshot(`"*h~~e**l**l~~o*"`);
});
});
});

View File

@ -115,10 +115,11 @@ export const remarkToMarkdown = obj => {
listItemIndent: '1',
/**
* Settings to emulate the defaults from the Prosemirror editor, not
* necessarily optimal. Should eventually be configurable.
* Use asterisk for everything, it's the most versatile. Eventually using
* other characters should be an option.
*/
bullet: '*',
emphasis: '*',
strong: '*',
rule: '-',
};
@ -147,16 +148,21 @@ export const remarkToMarkdown = obj => {
/**
* Convert Markdown to HTML.
*/
export const markdownToHtml = (markdown, getAsset) => {
export const markdownToHtml = (markdown, { getAsset, resolveWidget } = {}) => {
const mdast = markdownToRemark(markdown);
const hast = unified()
.use(remarkToRehypeShortcodes, { plugins: getEditorComponents(), getAsset })
.use(remarkToRehypeShortcodes, { plugins: getEditorComponents(), getAsset, resolveWidget })
.use(remarkToRehype, { allowDangerousHTML: true })
.runSync(mdast);
const html = unified()
.use(rehypeToHtml, { allowDangerousHTML: true, allowDangerousCharacters: true })
.use(rehypeToHtml, {
allowDangerousHTML: true,
allowDangerousCharacters: true,
closeSelfClosing: true,
entities: { useNamedReferences: true },
})
.stringify(hast);
return html;
@ -189,12 +195,12 @@ export const htmlToSlate = html => {
/**
* Convert Markdown to Slate's Raw AST.
*/
export const markdownToSlate = markdown => {
export const markdownToSlate = (markdown, { voidCodeBlock } = {}) => {
const mdast = markdownToRemark(markdown);
const slateRaw = unified()
.use(remarkWrapHtml)
.use(remarkToSlate)
.use(remarkToSlate, { voidCodeBlock })
.runSync(mdast);
return slateRaw;
@ -209,8 +215,8 @@ export const markdownToSlate = markdown => {
* MDAST. The conversion is manual because Unified can only operate on Unist
* trees.
*/
export const slateToMarkdown = raw => {
const mdast = slateToRemark(raw, { shortcodePlugins: getEditorComponents() });
export const slateToMarkdown = (raw, { voidCodeBlock } = {}) => {
const mdast = slateToRemark(raw, { voidCodeBlock });
const markdown = remarkToMarkdown(mdast);
return markdown;
};

View File

@ -1,3 +1,4 @@
import React from 'react';
import { map, has } from 'lodash';
import { renderToString } from 'react-dom/server';
import u from 'unist-builder';
@ -8,7 +9,7 @@ import u from 'unist-builder';
* conversion by replacing the shortcode text with stringified HTML for
* previewing the shortcode output.
*/
export default function remarkToRehypeShortcodes({ plugins, getAsset }) {
export default function remarkToRehypeShortcodes({ plugins, getAsset, resolveWidget }) {
return transform;
function transform(root) {
@ -37,7 +38,7 @@ export default function remarkToRehypeShortcodes({ plugins, getAsset }) {
* an HTML string or a React component. If a React component is returned,
* render it to an HTML string.
*/
const value = plugin.toPreview(shortcodeData, getAsset);
const value = getPreview(plugin, shortcodeData);
const valueHtml = typeof value === 'string' ? value : renderToString(value);
/**
@ -47,4 +48,20 @@ export default function remarkToRehypeShortcodes({ plugins, getAsset }) {
const children = [textNode];
return { ...node, children };
}
/**
* Retrieve the shortcode preview component.
*/
function getPreview(plugin, shortcodeData) {
const { toPreview, widget } = plugin;
if (toPreview) {
return toPreview(shortcodeData, getAsset);
}
const preview = resolveWidget(widget);
return React.createElement(preview.preview, {
value: shortcodeData,
field: plugin,
getAsset,
});
}
}

View File

@ -1,31 +1,4 @@
import { isEmpty, isArray, last, flatMap } from 'lodash';
/**
* A Remark plugin for converting an MDAST to Slate Raw AST. Remark plugins
* return a `transform` function that receives the MDAST as it's first argument.
*/
export default function remarkToSlate() {
return transform;
}
function transform(node) {
/**
* Call `transform` recursively on child nodes.
*
* If a node returns a falsey value, filter it out. Some nodes do not
* translate from MDAST to Slate, such as definitions for link/image
* references or footnotes.
*/
const children =
!['strong', 'emphasis', 'delete'].includes(node.type) &&
!isEmpty(node.children) &&
flatMap(node.children, transform).filter(val => val);
/**
* Run individual nodes through the conversion factory.
*/
return convertNode(node, children);
}
import { isEmpty, isArray, flatMap, map, flatten } from 'lodash';
/**
* Map of MDAST node types to Slate node types.
@ -34,7 +7,7 @@ const typeMap = {
root: 'root',
paragraph: 'paragraph',
blockquote: 'quote',
code: 'code',
code: 'code-block',
listItem: 'list-item',
table: 'table',
tableRow: 'table-row',
@ -56,60 +29,82 @@ const markMap = {
};
/**
* Add nodes to a parent node only if `nodes` is truthy.
* A Remark plugin for converting an MDAST to Slate Raw AST. Remark plugins
* return a `transformNode` function that receives the MDAST as it's first argument.
*/
function addNodes(parent, nodes) {
return nodes ? { ...parent, nodes } : parent;
}
export default function remarkToSlate({ voidCodeBlock } = {}) {
return transformNode;
/**
* Create a Slate Inline node.
*/
function createBlock(type, nodes, props = {}) {
if (!isArray(nodes)) {
props = nodes;
nodes = undefined;
function transformNode(node) {
/**
* Call `transformNode` recursively on child nodes.
*
* If a node returns a falsey value, filter it out. Some nodes do not
* translate from MDAST to Slate, such as definitions for link/image
* references or footnotes.
*/
const children =
!['strong', 'emphasis', 'delete'].includes(node.type) &&
!isEmpty(node.children) &&
flatMap(node.children, transformNode).filter(val => val);
/**
* Run individual nodes through the conversion factory.
*/
const output = convertNode(node, children || undefined);
return output;
}
const node = { object: 'block', type, ...props };
return addNodes(node, nodes);
}
/**
* Create a Slate Block node.
*/
function createInline(type, props = {}, nodes) {
const node = { object: 'inline', type, ...props };
return addNodes(node, nodes);
}
/**
* Create a Slate Raw text node.
*/
function createText(value, data) {
const node = { object: 'text', data };
const leaves = isArray(value) ? value : [{ text: value }];
return { ...node, leaves };
}
function processMarkNode(node, parentMarks = []) {
/**
* Add the current node's mark type to the marks collected from parent
* mark nodes, if any.
* Add nodes to a parent node only if `nodes` is truthy.
*/
const markType = markMap[node.type];
const marks = markType ? [...parentMarks, { type: markMap[node.type] }] : parentMarks;
function addNodes(parent, nodes) {
return nodes ? { ...parent, nodes } : parent;
}
const children = flatMap(node.children, childNode => {
/**
* Create a Slate Block node.
*/
function createBlock(type, nodes, props = {}) {
if (!isArray(nodes)) {
props = nodes;
nodes = undefined;
}
const node = { object: 'block', type, ...props };
return addNodes(node, nodes);
}
/**
* Create a Slate Inline node.
*/
function createInline(type, props = {}, nodes) {
const node = { object: 'inline', type, ...props };
return addNodes(node, nodes);
}
/**
* Create a Slate Raw text node.
*/
function createText(node) {
const newNode = { object: 'text' };
if (typeof node === 'string') {
return { ...newNode, text: node };
}
const { text, marks } = node;
return { ...newNode, text, marks };
}
function processMarkChild(childNode, marks) {
switch (childNode.type) {
/**
* If a text node is a direct child of the current node, it should be
* set aside as a leaf, and all marks that have been collected in the
* `marks` array should apply to that specific leaf.
* set aside as a text, and all marks that have been collected in the
* `marks` array should apply to that specific text.
*/
case 'html':
case 'text':
return { text: childNode.value, marks };
return { ...convertNode(childNode), marks };
/**
* MDAST inline code nodes don't have children, just a text value, similar
@ -117,14 +112,14 @@ function processMarkNode(node, parentMarks = []) {
* first add the inline code mark to the marks array.
*/
case 'inlineCode': {
const childMarks = [...marks, { type: markMap['inlineCode'] }];
return { text: childNode.value, marks: childMarks };
const childMarks = [...marks, { type: markMap[childNode.type] }];
return { ...convertNode(childNode), marks: childMarks };
}
/**
* Process nested style nodes. The recursive results should be pushed into
* the leaves array. This way, every MDAST nested text structure becomes a
* flat array of leaves that can serve as the value of a single Slate Raw
* the texts array. This way, every MDAST nested text structure becomes a
* flat array of texts that can serve as the value of a single Slate Raw
* text node.
*/
case 'strong':
@ -132,211 +127,236 @@ function processMarkNode(node, parentMarks = []) {
case 'delete':
return processMarkNode(childNode, marks);
case 'link': {
const nodes = map(childNode.children, child => processMarkChild(child, marks));
const result = convertNode(childNode, flatten(nodes));
return result;
}
/**
* Remaining nodes simply need mark data added to them, and to then be
* added into the cumulative children array.
*/
default:
return { ...childNode, data: { marks } };
return transformNode({ ...childNode, data: { ...childNode.data, marks } });
}
});
}
return children;
}
function convertMarkNode(node) {
const slateNodes = processMarkNode(node);
const convertedSlateNodes = slateNodes.reduce((acc, node) => {
const lastConvertedNode = last(acc);
if (node.text && lastConvertedNode && lastConvertedNode.leaves) {
lastConvertedNode.leaves.push(node);
} else if (node.text) {
acc.push(createText([node]));
} else {
acc.push(transform(node));
}
return acc;
}, []);
return convertedSlateNodes;
}
/**
* Convert a single MDAST node to a Slate Raw node. Uses local node factories
* that mimic the unist-builder function utilized in the slateRemark
* transformer.
*/
function convertNode(node, nodes) {
switch (node.type) {
function processMarkNode(node, parentMarks = []) {
/**
* General
*
* Convert simple cases that only require a type and children, with no
* additional properties.
* Add the current node's mark type to the marks collected from parent
* mark nodes, if any.
*/
case 'root':
case 'paragraph':
case 'listItem':
case 'blockquote':
case 'tableRow':
case 'tableCell': {
return createBlock(typeMap[node.type], nodes);
}
const markType = markMap[node.type];
const marks = markType ? [...parentMarks, { type: markMap[node.type] }] : parentMarks;
/**
* Shortcodes
*
* Shortcode nodes are represented as "void" blocks in the Slate AST. They
* maintain the same data as MDAST shortcode nodes. Slate void blocks must
* contain a blank text node.
*/
case 'shortcode': {
const { data } = node;
const nodes = [createText('')];
return createBlock(typeMap[node.type], nodes, { data, isVoid: true });
}
const children = flatMap(node.children, child => processMarkChild(child, marks));
/**
* Text
*
* Text and HTML nodes are both used to render text, and should be treated
* the same. HTML is treated as text because we never want to escape or
* encode it.
*/
case 'text':
case 'html': {
return createText(node.value, node.data);
}
return children;
}
/**
* Inline Code
*
* Inline code nodes from an MDAST are represented in our Slate schema as
* text nodes with a "code" mark. We manually create the "leaf" containing
* the inline code value and a "code" mark, and place it in an array for use
* as a Slate text node's children array.
*/
case 'inlineCode': {
const leaf = {
text: node.value,
marks: [{ type: 'code' }],
};
return createText([leaf]);
}
/**
* Convert a single MDAST node to a Slate Raw node. Uses local node factories
* that mimic the unist-builder function utilized in the slateRemark
* transformer.
*/
function convertNode(node, nodes) {
switch (node.type) {
/**
* General
*
* Convert simple cases that only require a type and children, with no
* additional properties.
*/
case 'root':
case 'paragraph':
case 'blockquote':
case 'tableRow':
case 'tableCell': {
return createBlock(typeMap[node.type], nodes);
}
/**
* Marks
*
* Marks are typically decorative sub-types that apply to text nodes. In an
* MDAST, marks are nodes that can contain other nodes. This nested
* hierarchy has to be flattened and split into distinct text nodes with
* their own set of marks.
*/
case 'strong':
case 'emphasis':
case 'delete': {
return convertMarkNode(node);
}
/**
* List Items
*
* Markdown list items can be empty, but a list item in the Slate schema
* should at least have an empty paragraph node.
*/
case 'listItem': {
const children = isEmpty(nodes) ? [createBlock('paragraph')] : nodes;
return createBlock(typeMap[node.type], children);
}
/**
* Headings
*
* MDAST headings use a single type with a separate "depth" property to
* indicate the heading level, while the Slate schema uses a separate node
* type for each heading level. Here we get the proper Slate node name based
* on the MDAST node depth.
*/
case 'heading': {
const depthMap = { 1: 'one', 2: 'two', 3: 'three', 4: 'four', 5: 'five', 6: 'six' };
const slateType = `heading-${depthMap[node.depth]}`;
return createBlock(slateType, nodes);
}
/**
* Shortcodes
*
* Shortcode nodes are represented as "void" blocks in the Slate AST. They
* maintain the same data as MDAST shortcode nodes. Slate void blocks must
* contain a blank text node.
*/
case 'shortcode': {
const nodes = [createText('')];
const data = { ...node.data };
return createBlock(typeMap[node.type], nodes, { data });
}
/**
* Code Blocks
*
* MDAST code blocks are a distinct node type with a simple text value. We
* convert that value into a nested child text node for Slate. We also carry
* over the "lang" data property if it's defined.
*/
case 'code': {
const data = { lang: node.lang };
const text = createText(node.value);
const nodes = [text];
return createBlock(typeMap[node.type], nodes, { data });
}
/**
* Text
*
* Text nodes contain plain text. We remove newlines because they don't
* carry meaning for a rich text editor - a break in rich text would be
* expected to result in a break in output HTML, but that isn't the case.
* To avoid this confusion we remove them.
*/
case 'text': {
const text = node.value.replace(/\n/, ' ');
return createText(text);
}
/**
* Lists
*
* MDAST has a single list type and an "ordered" property. We derive that
* information into the Slate schema's distinct list node types. We also
* include the "start" property, which indicates the number an ordered list
* starts at, if defined.
*/
case 'list': {
const slateType = node.ordered ? 'numbered-list' : 'bulleted-list';
const data = { start: node.start };
return createBlock(slateType, nodes, { data });
}
/**
* HTML
*
* HTML nodes contain plain text like text nodes, except they only contain
* HTML. Our serialization results in non-HTML being placed in HTML nodes
* sometimes to ensure that we're never escaping HTML from the rich text
* editor. We do not replace line feeds in HTML because the HTML is raw
* in the rich text editor, so the writer knows they're writing HTML, and
* should expect soft breaks to be visually absent in the rendered HTML.
*/
case 'html': {
return createText(node.value);
}
/**
* Breaks
*
* MDAST soft break nodes represent a trailing double space or trailing
* slash from a Markdown document. In Slate, these are simply transformed to
* line breaks within a text node.
*/
case 'break': {
const textNode = createText('\n');
return createInline('break', {}, [textNode]);
}
/**
* Inline Code
*
* Inline code nodes from an MDAST are represented in our Slate schema as
* text nodes with a "code" mark. We manually create the text containing
* the inline code value and a "code" mark, and place it in an array for use
* as a Slate text node's children array.
*/
case 'inlineCode': {
return createText({ text: node.value, marks: [{ type: 'code' }] });
}
/**
* Thematic Breaks
*
* Thematic breaks are void nodes in the Slate schema.
*/
case 'thematicBreak': {
return createBlock(typeMap[node.type], { isVoid: true });
}
/**
* Marks
*
* Marks are typically decorative sub-types that apply to text nodes. In an
* MDAST, marks are nodes that can contain other nodes. This nested
* hierarchy has to be flattened and split into distinct text nodes with
* their own set of marks.
*/
case 'strong':
case 'emphasis':
case 'delete': {
return processMarkNode(node);
}
/**
* Links
*
* MDAST stores the link attributes directly on the node, while our Slate
* schema references them in the data object.
*/
case 'link': {
const { title, url, data } = node;
const newData = { ...data, title, url };
return createInline(typeMap[node.type], { data: newData }, nodes);
}
/**
* Headings
*
* MDAST headings use a single type with a separate "depth" property to
* indicate the heading level, while the Slate schema uses a separate node
* type for each heading level. Here we get the proper Slate node name based
* on the MDAST node depth.
*/
case 'heading': {
const depthMap = { 1: 'one', 2: 'two', 3: 'three', 4: 'four', 5: 'five', 6: 'six' };
const slateType = `heading-${depthMap[node.depth]}`;
return createBlock(slateType, nodes);
}
/**
* Images
*
* Identical to link nodes except for the lack of child nodes and addition
* of alt attribute data MDAST stores the link attributes directly on the
* node, while our Slate schema references them in the data object.
*/
case 'image': {
const { title, url, alt, data } = node;
const newData = { ...data, title, alt, url };
return createInline(typeMap[node.type], { isVoid: true, data: newData });
}
/**
* Code Blocks
*
* MDAST code blocks are a distinct node type with a simple text value. We
* convert that value into a nested child text node for Slate. If a void
* node is required due to a custom code block handler, the value is
* stored in the "code" data property instead. We also carry over the "lang"
* data property if it's defined.
*/
case 'code': {
const data = {
lang: node.lang,
...(voidCodeBlock ? { code: node.value } : {}),
};
const text = createText(voidCodeBlock ? '' : node.value);
const nodes = [text];
const block = createBlock(typeMap[node.type], nodes, { data });
return block;
}
/**
* Tables
*
* Tables are parsed separately because they may include an "align"
* property, which should be passed to the Slate node.
*/
case 'table': {
const data = { align: node.align };
return createBlock(typeMap[node.type], nodes, { data });
/**
* Lists
*
* MDAST has a single list type and an "ordered" property. We derive that
* information into the Slate schema's distinct list node types. We also
* include the "start" property, which indicates the number an ordered list
* starts at, if defined.
*/
case 'list': {
const slateType = node.ordered ? 'numbered-list' : 'bulleted-list';
const data = { start: node.start };
return createBlock(slateType, nodes, { data });
}
/**
* Breaks
*
* MDAST soft break nodes represent a trailing double space or trailing
* slash from a Markdown document. In Slate, these are simply transformed to
* line breaks within a text node.
*/
case 'break': {
const { data } = node;
return createInline('break', { data });
}
/**
* Thematic Breaks
*
* Thematic breaks are void nodes in the Slate schema.
*/
case 'thematicBreak': {
return createBlock(typeMap[node.type]);
}
/**
* Links
*
* MDAST stores the link attributes directly on the node, while our Slate
* schema references them in the data object.
*/
case 'link': {
const { title, url, data } = node;
const newData = { ...data, title, url };
return createInline(typeMap[node.type], { data: newData }, nodes);
}
/**
* Images
*
* Identical to link nodes except for the lack of child nodes and addition
* of alt attribute data MDAST stores the link attributes directly on the
* node, while our Slate schema references them in the data object.
*/
case 'image': {
const { title, url, alt, data } = node;
const newData = { ...data, title, alt, url };
return createInline(typeMap[node.type], { data: newData });
}
/**
* Tables
*
* Tables are parsed separately because they may include an "align"
* property, which should be passed to the Slate node.
*/
case 'table': {
const data = { align: node.align };
return createBlock(typeMap[node.type], nodes, { data });
}
}
}
}

View File

@ -1,5 +1,6 @@
import { get, isEmpty, without, flatMap, last, sortBy } from 'lodash';
import { get, without, last, map, intersection, omit } from 'lodash';
import u from 'unist-builder';
import mdastToString from 'mdast-util-to-string';
/**
* Map of Slate node types to MDAST/Remark node types.
@ -14,7 +15,7 @@ const typeMap = {
'heading-five': 'heading',
'heading-six': 'heading',
quote: 'blockquote',
code: 'code',
'code-block': 'code',
'numbered-list': 'list',
'bulleted-list': 'list',
'list-item': 'listItem',
@ -38,7 +39,10 @@ const markMap = {
code: 'inlineCode',
};
export default function slateToRemark(raw) {
const leadingWhitespaceExp = /^\s+\S/;
const trailingWhitespaceExp = /(?!\S)\s+$/;
export default function slateToRemark(raw, { voidCodeBlock }) {
/**
* The Slate Raw AST generally won't have a top level type, so we set it to
* "root" for clarity.
@ -46,465 +50,352 @@ export default function slateToRemark(raw) {
raw.type = 'root';
return transform(raw);
}
/**
* The transform function mimics the approach of a Remark plugin for
* conformity with the other serialization functions. This function converts
* Slate nodes to MDAST nodes, and recursively calls itself to process child
* nodes to arbitrary depth.
*/
function transform(node) {
/**
* Combine adjacent text and inline nodes before processing so they can
* share marks.
*/
const combinedChildren = node.nodes && combineTextAndInline(node.nodes);
/**
* Call `transform` recursively on child nodes, and flatten the resulting
* array.
* The transform function mimics the approach of a Remark plugin for
* conformity with the other serialization functions. This function converts
* Slate nodes to MDAST nodes, and recursively calls itself to process child
* nodes to arbitrary depth.
*/
const children = !isEmpty(combinedChildren) && flatMap(combinedChildren, transform);
/**
* Run individual nodes through conversion factories.
*/
return ['text'].includes(node.object) ? convertTextNode(node) : convertNode(node, children);
}
/**
* Includes inline nodes as leaves in adjacent text nodes where appropriate, so
* that mark node combining logic can apply to both text and inline nodes. This
* is necessary because Slate doesn't allow inline nodes to have marks while
* inline nodes in MDAST may be nested within mark nodes. Treating them as if
* they were text is a bit of a necessary hack.
*/
function combineTextAndInline(nodes) {
return nodes.reduce((acc, node) => {
const prevNode = last(acc);
const prevNodeLeaves = get(prevNode, 'leaves');
const data = node.data || {};
function transform(node) {
/**
* If the previous node has leaves and the current node has marks in data
* (only happens when we place them on inline nodes here in the parser), or
* the current node also has leaves (because the previous node was
* originally an inline node that we've already squashed into a leaf)
* combine the current node into the previous.
* Combine adjacent text and inline nodes before processing so they can
* share marks.
*/
if (!isEmpty(prevNodeLeaves) && !isEmpty(data.marks)) {
prevNodeLeaves.push({ node, marks: data.marks });
return acc;
}
const hasBlockChildren = node.nodes && node.nodes[0] && node.nodes[0].object === 'block';
const children = hasBlockChildren
? node.nodes.map(transform).filter(v => v)
: convertInlineAndTextChildren(node.nodes);
if (!isEmpty(prevNodeLeaves) && !isEmpty(node.leaves)) {
prevNode.leaves = prevNodeLeaves.concat(node.leaves);
return acc;
}
const output = convertBlockNode(node, children);
//console.log(JSON.stringify(output, null, 2));
return output;
}
/**
* Break nodes contain a single child text node with a newline character
* for visual purposes in the editor, but Remark break nodes have no
* children, so we remove the child node here.
*/
if (node.type === 'break') {
acc.push({ object: 'inline', type: 'break' });
return acc;
}
/**
* Convert remaining inline nodes to standalone text nodes with leaves.
*/
if (node.object === 'inline') {
acc.push({ object: 'text', leaves: [{ node, marks: data.marks }] });
return acc;
}
/**
* Only remaining case is an actual text node, can be pushed as is.
*/
acc.push(node);
return acc;
}, []);
}
/**
* Slate treats inline code decoration as a standard mark, but MDAST does
* not allow inline code nodes to contain children, only a single text
* value. An MDAST inline code node can be nested within mark nodes such
* as "emphasis" and "strong", but it cannot contain them.
*
* Because of this, if a "code" mark (translated to MDAST "inlineCode") is
* in the markTypes array, we make the base text node an "inlineCode" type
* instead of a standard text node.
*/
function processCodeMark(markTypes) {
const isInlineCode = markTypes.includes('inlineCode');
const filteredMarkTypes = isInlineCode ? without(markTypes, 'inlineCode') : markTypes;
const textNodeType = isInlineCode ? 'inlineCode' : 'html';
return { filteredMarkTypes, textNodeType };
}
/**
* Converts a Slate Raw text node to an MDAST text node.
*
* Slate text nodes without marks often simply have a "text" property with
* the value. In this case the conversion to MDAST is simple. If a Slate
* text node does not have a "text" property, it will instead have a
* "leaves" property containing an array of objects, each with an array of
* marks, such as "bold" or "italic", along with a "text" property.
*
* MDAST instead expresses such marks in a nested structure, with individual
* nodes for each mark type nested until the deepest mark node, which will
* contain the text node.
*
* To convert a Slate text node's marks to MDAST, we treat each "leaf" as a
* separate text node, convert the text node itself to an MDAST text node,
* and then recursively wrap the text node for each mark, collecting the results
* of each leaf in a single array of child nodes.
*
* For example, this Slate text node:
*
* {
* object: 'text',
* leaves: [
* {
* text: 'test',
* marks: ['bold', 'italic']
* },
* {
* text: 'test two'
* }
* ]
* }
*
* ...would be converted to this MDAST nested structure:
*
* [
* {
* type: 'strong',
* children: [{
* type: 'emphasis',
* children: [{
* type: 'text',
* value: 'test'
* }]
* }]
* },
* {
* type: 'text',
* value: 'test two'
* }
* ]
*
* This example also demonstrates how a single Slate node may need to be
* replaced with multiple MDAST nodes, so the resulting array must be flattened.
*/
function convertTextNode(node) {
/**
* If the Slate text node has a "leaves" property, translate the Slate AST to
* a nested MDAST structure. Otherwise, just return an equivalent MDAST text
* node.
*/
if (node.leaves) {
const processedLeaves = node.leaves.map(processLeaves);
// Compensate for Slate including leading and trailing whitespace in styled text nodes, which
// cannot be represented in markdown (https://github.com/netlify/netlify-cms/issues/1448)
for (let i = 0; i < processedLeaves.length; i += 1) {
const leaf = processedLeaves[i];
if (leaf.marks.length > 0 && leaf.text && leaf.text.trim() !== leaf.text) {
const [, leadingWhitespace, trailingWhitespace] = leaf.text.match(/^(\s*).*?(\s*)$/);
// Move the leading whitespace to a separate unstyled leaf, unless the current leaf
// is preceded by another one with (at least) the same marks applied:
if (
leadingWhitespace.length > 0 &&
(i === 0 ||
!leaf.marks.every(
mark => processedLeaves[i - 1].marks && processedLeaves[i - 1].marks.includes(mark),
))
) {
processedLeaves.splice(i, 0, {
text: leadingWhitespace,
marks: [],
textNodeType: leaf.textNodeType,
});
i += 1;
leaf.text = leaf.text.replace(/^\s+/, '');
function removeMarkFromNodes(nodes, markType) {
return nodes.map(node => {
switch (node.type) {
case 'link': {
const updatedNodes = removeMarkFromNodes(node.nodes, markType);
return {
...node,
nodes: updatedNodes,
};
}
// Move the trailing whitespace to a separate unstyled leaf, unless the current leaf
// is followed by another one with (at least) the same marks applied:
if (
trailingWhitespace.length > 0 &&
(i === processedLeaves.length - 1 ||
!leaf.marks.every(
mark => processedLeaves[i + 1].marks && processedLeaves[i + 1].marks.includes(mark),
))
) {
processedLeaves.splice(i + 1, 0, {
text: trailingWhitespace,
marks: [],
textNodeType: leaf.textNodeType,
});
i += 1;
leaf.text = leaf.text.replace(/\s+$/, '');
case 'image':
case 'break': {
const data = omit(node.data, 'marks');
return { ...node, data };
}
default:
return {
...node,
marks: node.marks.filter(({ type }) => type !== markType),
};
}
});
}
function getNodeMarks(node) {
switch (node.type) {
case 'link': {
// Code marks can't always be condensed together. If all text in a link
// is wrapped in a mark, this function returns that mark and the node
// ends up nested inside of that mark. Code marks sometimes can't do
// that, like when they wrap all of the text content of a link. Here we
// remove code marks before processing so that they stay put.
const nodesWithoutCode = node.nodes.map(n => ({
...n,
marks: n.marks ? n.marks.filter(({ type }) => type !== 'code') : n.marks,
}));
const childMarks = map(nodesWithoutCode, getNodeMarks);
return intersection(...childMarks);
}
case 'break':
case 'image':
return map(get(node, ['data', 'marks']), mark => mark.type);
default:
return map(node.marks, mark => mark.type);
}
}
function getSharedMarks(marks, node) {
const nodeMarks = getNodeMarks(node);
const sharedMarks = intersection(marks, nodeMarks);
if (sharedMarks[0] === 'code') {
return nodeMarks.length === 1 ? marks : [];
}
return sharedMarks;
}
function extractFirstMark(nodes) {
let firstGroupMarks = getNodeMarks(nodes[0]) || [];
// If code mark is present, but there are other marks, process others first.
// If only the code mark is present, don't allow it to be shared with other
// nodes.
if (firstGroupMarks[0] === 'code' && firstGroupMarks.length > 1) {
firstGroupMarks = [...without('firstGroupMarks', 'code'), 'code'];
}
let splitIndex = 1;
if (firstGroupMarks.length > 0) {
while (splitIndex < nodes.length) {
if (nodes[splitIndex]) {
const sharedMarks = getSharedMarks(firstGroupMarks, nodes[splitIndex]);
if (sharedMarks.length > 0) {
firstGroupMarks = sharedMarks;
} else {
break;
}
}
splitIndex += 1;
}
}
const condensedNodes = processedLeaves.reduce(condenseNodesReducer, { nodes: [] });
return condensedNodes.nodes;
}
if (node.object === 'inline') {
return transform(node);
}
const markType = firstGroupMarks[0];
const childNodes = nodes.slice(0, splitIndex);
const updatedChildNodes = markType ? removeMarkFromNodes(childNodes, markType) : childNodes;
const remainingNodes = nodes.slice(splitIndex);
return u('html', node.text);
}
/**
* Process Slate node leaves in preparation for MDAST transformation.
*/
function processLeaves(leaf) {
/**
* Get an array of the mark types, converted to their MDAST equivalent
* types.
*/
const { marks = [], text } = leaf;
const markTypes = marks.map(mark => markMap[mark.type]);
if (typeof leaf.text === 'string') {
/**
* Code marks must be removed from the marks array, and the presence of a
* code mark changes the text node type that should be used.
*/
const { filteredMarkTypes, textNodeType } = processCodeMark(markTypes);
return { text, marks: filteredMarkTypes, textNodeType };
}
return { node: leaf.node, marks: markTypes };
}
/**
* Slate's AST doesn't group adjacent text nodes with the same marks - a
* change in marks from letter to letter, even if some are in common, results
* in a separate leaf. For example, given "**a_b_**", transformation to and
* from Slate's AST will result in "**a****_b_**".
*
* MDAST treats styling entities as distinct nodes that contain children, so a
* "strong" node can contain a plain text node with a sibling "emphasis" node,
* which contains more text. This reducer serves to create an optimized nested
* MDAST without the typical redundancies that Slate's AST would produce if
* transformed as-is. The reducer can be called recursively to produce nested
* structures.
*/
function condenseNodesReducer(acc, node, idx, nodes) {
/**
* Skip any nodes that are being processed as children of an MDAST node
* through recursive calls.
*/
if (typeof acc.nextIndex === 'number' && acc.nextIndex > idx) {
return acc;
return [markType, updatedChildNodes, remainingNodes];
}
/**
* Processing for nodes with marks.
* Converts the strings returned from `splitToNamedParts` to Slate nodes.
*/
if (node.marks && node.marks.length > 0) {
/**
* For each mark on the current node, get the number of consecutive nodes
* (starting with this one) that have the mark. Whichever mark covers the
* most nodes is used as the parent node, and the nodes with that mark are
* processed as children. If the greatest number of consecutive nodes is
* tied between multiple marks, there is no priority as to which goes
* first.
*/
const markLengths = node.marks.map(mark => getMarkLength(mark, nodes.slice(idx)));
const parentMarkLength = last(sortBy(markLengths, 'length'));
const { markType: parentType, length: parentLength } = parentMarkLength;
/**
* Since this and any consecutive nodes with the parent mark are going to
* be processed as children of the parent mark, this reducer should simply
* return the accumulator until after the last node to be covered by the
* new parent node. Here we set the next index that should be processed,
* if any.
*/
const newNextIndex = idx + parentLength;
/**
* Get the set of nodes that should be processed as children of the new
* parent mark node, run each through the reducer as children of the
* parent node, and create the parent MDAST node with the resulting
* children.
*/
const children = nodes.slice(idx, newNextIndex);
const denestedChildren = children.map(child => ({
...child,
marks: without(child.marks, parentType),
}));
const mdastChildren = denestedChildren.reduce(condenseNodesReducer, { nodes: [], parentType })
.nodes;
const mdastNode = u(parentType, mdastChildren);
return { ...acc, nodes: [...acc.nodes, mdastNode], nextIndex: newNextIndex };
function splitWhitespace(node, { trailing } = {}) {
if (!node.text) {
return { trimmedNode: node };
}
const exp = trailing ? trailingWhitespaceExp : leadingWhitespaceExp;
const index = node.text.search(exp);
if (index > -1) {
const substringIndex = trailing ? index : index + 1;
const firstSplit = node.text.substring(0, substringIndex);
const secondSplit = node.text.substring(substringIndex);
const whitespace = trailing ? secondSplit : firstSplit;
const text = trailing ? firstSplit : secondSplit;
return { whitespace, trimmedNode: { ...node, text } };
}
return { trimmedNode: node };
}
/**
* Create the base text node, and pass in the array of mark types as data
* (helpful when optimizing/condensing the final structure).
*/
const baseNode =
typeof node.text === 'string'
? u(node.textNodeType, { marks: node.marks }, node.text)
: transform(node.node);
/**
* Recursively wrap the base text node in the individual mark nodes, if
* any exist.
*/
return { ...acc, nodes: [...acc.nodes, baseNode] };
}
/**
* Get the number of consecutive Slate nodes containing a given mark beginning
* from the first received node.
*/
function getMarkLength(markType, nodes) {
let length = 0;
while (nodes[length] && nodes[length].marks.includes(markType)) {
++length;
function collectCenterNodes(nodes, leadingNode, trailingNode) {
switch (nodes.length) {
case 0:
return [];
case 1:
return [trailingNode];
case 2:
return [leadingNode, trailingNode];
default:
return [leadingNode, ...nodes.slice(1, -1), trailingNode];
}
}
return { markType, length };
}
/**
* Convert a single Slate Raw node to an MDAST node. Uses the unist-builder `u`
* function to create MDAST nodes.
*/
function convertNode(node, children) {
switch (node.type) {
/**
* General
*
* Convert simple cases that only require a type and children, with no
* additional properties.
*/
case 'root':
case 'paragraph':
case 'quote':
case 'list-item':
case 'table':
case 'table-row':
case 'table-cell': {
return u(typeMap[node.type], children);
function normalizeFlankingWhitespace(nodes) {
const { whitespace: leadingWhitespace, trimmedNode: leadingNode } = splitWhitespace(nodes[0]);
const lastNode = nodes.length > 1 ? last(nodes) : leadingNode;
const trailingSplitResult = splitWhitespace(lastNode, { trailing: true });
const { whitespace: trailingWhitespace, trimmedNode: trailingNode } = trailingSplitResult;
const centerNodes = collectCenterNodes(nodes, leadingNode, trailingNode).filter(val => val);
return { leadingWhitespace, centerNodes, trailingWhitespace };
}
function convertInlineAndTextChildren(nodes = []) {
const convertedNodes = [];
let remainingNodes = nodes;
while (remainingNodes.length > 0) {
const nextNode = remainingNodes[0];
if (nextNode.object === 'inline' || (nextNode.marks && nextNode.marks.length > 0)) {
const [markType, markNodes, remainder] = extractFirstMark(remainingNodes);
/**
* A node with a code mark will be a text node, and will not be adjacent
* to a sibling code node as the Slate schema requires them to be
* merged. Markdown also requires at least a space between inline code
* nodes.
*/
if (markType === 'code') {
const node = markNodes[0];
convertedNodes.push(u(markMap[markType], node.data, node.text));
} else if (!markType && markNodes.length === 1 && markNodes[0].object === 'inline') {
const node = markNodes[0];
convertedNodes.push(convertInlineNode(node, convertInlineAndTextChildren(node.nodes)));
} else {
const {
leadingWhitespace,
trailingWhitespace,
centerNodes,
} = normalizeFlankingWhitespace(markNodes);
const children = convertInlineAndTextChildren(centerNodes);
const markNode = u(markMap[markType], children);
// Filter out empty marks, otherwise their output literally by
// remark-stringify, eg. an empty bold node becomes "****"
if (mdastToString(markNode) === '') {
remainingNodes = remainder;
continue;
}
const createText = text => text && u('html', text);
const normalizedNodes = [
createText(leadingWhitespace),
markNode,
createText(trailingWhitespace),
].filter(val => val);
convertedNodes.push(...normalizedNodes);
}
remainingNodes = remainder;
} else {
remainingNodes.shift();
convertedNodes.push(u('html', nextNode.text));
}
}
/**
* Shortcodes
*
* Shortcode nodes only exist in Slate's Raw AST if they were inserted
* via the plugin toolbar in memory, so they should always have
* shortcode data attached. The "shortcode" data property contains the
* name of the registered shortcode plugin, and the "shortcodeData" data
* property contains the data received from the shortcode plugin's
* `fromBlock` method when the shortcode node was created.
*
* Here we create a `shortcode` MDAST node that contains only the shortcode
* data.
*/
case 'shortcode': {
const { data } = node;
return u(typeMap[node.type], { data });
}
return convertedNodes;
}
/**
* Headings
*
* Slate schemas don't usually infer basic type info from data, so each
* level of heading is a separately named type. The MDAST schema just
* has a single "heading" type with the depth stored in a "depth"
* property on the node. Here we derive the depth from the Slate node
* type - e.g., for "heading-two", we need a depth value of "2".
*/
case 'heading-one':
case 'heading-two':
case 'heading-three':
case 'heading-four':
case 'heading-five':
case 'heading-six': {
const depthMap = { one: 1, two: 2, three: 3, four: 4, five: 5, six: 6 };
const depthText = node.type.split('-')[1];
const depth = depthMap[depthText];
return u(typeMap[node.type], { depth }, children);
}
function convertBlockNode(node, children) {
switch (node.type) {
/**
* General
*
* Convert simple cases that only require a type and children, with no
* additional properties.
*/
case 'root':
case 'paragraph':
case 'quote':
case 'list-item':
case 'table':
case 'table-row':
case 'table-cell': {
return u(typeMap[node.type], children);
}
/**
* Code Blocks
*
* Code block nodes have a single text child, and may have a code language
* stored in the "lang" data property. Here we transfer both the node
* value and the "lang" data property to the new MDAST node.
*/
case 'code': {
const value = flatMap(node.nodes, child => {
return flatMap(child.leaves, 'text');
}).join('');
const { lang, ...data } = get(node, 'data', {});
return u(typeMap[node.type], { lang, data }, value);
}
/**
* Shortcodes
*
* Shortcode nodes only exist in Slate's Raw AST if they were inserted
* via the plugin toolbar in memory, so they should always have
* shortcode data attached. The "shortcode" data property contains the
* name of the registered shortcode plugin, and the "shortcodeData" data
* property contains the data received from the shortcode plugin's
* `fromBlock` method when the shortcode node was created.
*
* Here we create a `shortcode` MDAST node that contains only the shortcode
* data.
*/
case 'shortcode': {
const { data } = node;
return u(typeMap[node.type], { data });
}
/**
* Lists
*
* Our Slate schema has separate node types for ordered and unordered
* lists, but the MDAST spec uses a single type with a boolean "ordered"
* property to indicate whether the list is numbered. The MDAST spec also
* allows for a "start" property to indicate the first number used for an
* ordered list. Here we translate both values to our Slate schema.
*/
case 'numbered-list':
case 'bulleted-list': {
const ordered = node.type === 'numbered-list';
const props = { ordered, start: get(node.data, 'start') || 1 };
return u(typeMap[node.type], props, children);
}
/**
* Headings
*
* Slate schemas don't usually infer basic type info from data, so each
* level of heading is a separately named type. The MDAST schema just
* has a single "heading" type with the depth stored in a "depth"
* property on the node. Here we derive the depth from the Slate node
* type - e.g., for "heading-two", we need a depth value of "2".
*/
case 'heading-one':
case 'heading-two':
case 'heading-three':
case 'heading-four':
case 'heading-five':
case 'heading-six': {
const depthMap = { one: 1, two: 2, three: 3, four: 4, five: 5, six: 6 };
const depthText = node.type.split('-')[1];
const depth = depthMap[depthText];
const mdastNode = u(typeMap[node.type], { depth }, children);
if (mdastToString(mdastNode)) {
return mdastNode;
}
return;
}
/**
* Breaks
*
* Breaks don't have children. We parse them separately for clarity.
*/
case 'break':
case 'thematic-break': {
return u(typeMap[node.type]);
}
/**
* Code Blocks
*
* Code block nodes may have a single text child, or instead be void and
* store their value in `data.code`. They also may have a code language
* stored in the "lang" data property. Here we transfer both the node value
* and the "lang" data property to the new MDAST node, and spread any
* remaining data as `data`.
*/
case 'code-block': {
const { lang, code, ...data } = get(node, 'data', {});
const value = voidCodeBlock ? code : children[0]?.value;
return u(typeMap[node.type], { lang, data }, value || '');
}
/**
* Links
*
* The url and title attributes of link nodes are stored in properties on
* the node for both Slate and Remark schemas.
*/
case 'link': {
const { url, title, ...data } = get(node, 'data', {});
return u(typeMap[node.type], { url, title, data }, children);
}
/**
* Lists
*
* Our Slate schema has separate node types for ordered and unordered
* lists, but the MDAST spec uses a single type with a boolean "ordered"
* property to indicate whether the list is numbered. The MDAST spec also
* allows for a "start" property to indicate the first number used for an
* ordered list. Here we translate both values to our Slate schema.
*/
case 'numbered-list':
case 'bulleted-list': {
const ordered = node.type === 'numbered-list';
const props = { ordered, start: get(node.data, 'start') || 1 };
return u(typeMap[node.type], props, children);
}
/**
* Images
*
* This transformation is almost identical to that of links, except for the
* lack of child nodes and addition of `alt` attribute data.
*/
case 'image': {
const { url, title, alt, ...data } = get(node, 'data', {});
return u(typeMap[node.type], { url, title, alt, data });
/**
* Thematic Break
*
* Thematic break is a block level break. They cannot have children.
*/
case 'thematic-break': {
return u(typeMap[node.type]);
}
}
}
/**
* No default case is supplied because an unhandled case should never
* occur. In the event that it does, let the error throw (for now).
*/
function convertInlineNode(node, children) {
switch (node.type) {
/**
* Break
*
* Breaks are phrasing level breaks. They cannot have children.
*/
case 'break': {
return u(typeMap[node.type]);
}
/**
* Links
*
* The url and title attributes of link nodes are stored in properties on
* the node for both Slate and Remark schemas.
*/
case 'link': {
const { url, title, ...data } = get(node, 'data', {});
return u(typeMap[node.type], { url, title, data }, children);
}
/**
* Images
*
* This transformation is almost identical to that of links, except for the
* lack of child nodes and addition of `alt` attribute data.
*/
case 'image': {
const { url, title, alt, ...data } = get(node, 'data', {});
return u(typeMap[node.type], { url, title, alt, data });
}
}
}
}

View File

@ -5,7 +5,7 @@ export const editorStyleVars = {
};
export const EditorControlBar = styled.div`
z-index: 1;
z-index: 200;
position: sticky;
top: 0;
margin-bottom: ${editorStyleVars.stickyDistanceBottom};

View File

@ -0,0 +1,3 @@
export const SLATE_DEFAULT_BLOCK_TYPE = 'paragraph';
export const SLATE_BLOCK_PARENT_TYPES = ['list-item', 'quote'];

View File

@ -0,0 +1,32 @@
import { createHyperscript } from 'slate-hyperscript';
const h = createHyperscript({
blocks: {
paragraph: 'paragraph',
'heading-one': 'heading-one',
'heading-two': 'heading-two',
'heading-three': 'heading-three',
'heading-four': 'heading-four',
'heading-five': 'heading-five',
'heading-six': 'heading-six',
quote: 'quote',
'code-block': 'code-block',
'bulleted-list': 'bulleted-list',
'numbered-list': 'numbered-list',
'thematic-break': 'thematic-break',
table: 'table',
},
inlines: {
link: 'link',
break: 'break',
image: 'image',
},
marks: {
b: 'bold',
i: 'italic',
s: 'strikethrough',
code: 'code',
},
});
export default h;

View File

@ -13,7 +13,8 @@ const styleStrings = {
border-top-right-radius: 0;
`,
objectWidgetTopBarContainer: `
padding: ${lengths.objectWidgetTopBarContainerPadding}
padding: ${lengths.objectWidgetTopBarContainerPadding};
overflow: hidden;
`,
};

Some files were not shown because too many files have changed in this diff Show More