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: strategy:
matrix: matrix:
node-version: [8.x, 10.x] node-version: [10.x, 12.x]
steps: steps:
- uses: actions/checkout@v1 - uses: actions/checkout@v1

View File

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

View File

@ -19,6 +19,10 @@ const defaultPlugins = [
'@babel/plugin-proposal-class-properties', '@babel/plugin-proposal-class-properties',
'@babel/plugin-proposal-object-rest-spread', '@babel/plugin-proposal-object-rest-spread',
'@babel/plugin-proposal-export-default-from', '@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', 'module-resolver',
isESM isESM
@ -69,20 +73,22 @@ const defaultPlugins = [
]; ];
const presets = () => { 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 = () => { const plugins = () => {
if (isESM) { if (isESM) {
return [ return [
...defaultPlugins, ...defaultPlugins,
[
'emotion',
{
sourceMap: true,
autoLabel: true,
},
],
[ [
'transform-define', 'transform-define',
{ {
@ -104,13 +110,6 @@ const plugins = () => {
if (isTest) { if (isTest) {
return [ return [
...defaultPlugins, ...defaultPlugins,
[
'emotion',
{
sourceMap: false,
autoLabel: false,
},
],
[ [
'inline-react-svg', 'inline-react-svg',
{ {
@ -123,29 +122,10 @@ const plugins = () => {
} }
if (!isProduction) { if (!isProduction) {
return [ return [...defaultPlugins, 'react-hot-loader/babel'];
...defaultPlugins,
[
'emotion',
{
sourceMap: true,
autoLabel: true,
},
],
'react-hot-loader/babel',
];
} }
return [ return defaultPlugins;
...defaultPlugins,
[
'emotion',
{
sourceMap: true,
autoLabel: true,
},
],
];
}; };
module.exports = { 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 -- // -- This is will overwrite an existing command --
// Cypress.Commands.overwrite("visit", (originalFn, url, options) => { ... }) // Cypress.Commands.overwrite("visit", (originalFn, url, options) => { ... })
const { escapeRegExp } = require('../utils/regexp'); import path from 'path';
const path = require('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 matchRoute = (route, fetchArgs) => {
const url = fetchArgs[0]; const url = fetchArgs[0];
@ -86,3 +89,241 @@ Cypress.Commands.add('stubFetch', ({ fixture }) => {
cy.on('window:before:load', win => stubFetch(win, routes)); 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: // You can read more here:
// https://on.cypress.io/configuration // https://on.cypress.io/configuration
// *********************************************************** // ***********************************************************
require('cypress-plugin-tab');
// Import commands.js using ES2015 syntax: // Import commands.js using ES2015 syntax:
import './commands' import './commands';
// Alternatively you can use CommonJS syntax: // Alternatively you can use CommonJS syntax:
// require('./commands') // require('./commands')
import 'cypress-jest-adapter';

View File

@ -1,7 +1,7 @@
const workflowStatus = { draft: 'Drafts', review: 'In Review', ready: 'Ready' }; const workflowStatus = { draft: 'Drafts', review: 'In Review', ready: 'Ready' };
const editorStatus = { draft: 'Draft', review: 'In review', ready: 'Ready' }; const editorStatus = { draft: 'Draft', review: 'In review', ready: 'Ready' };
const setting1 = { limit: 10, author: 'John Doe' }; 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 publishTypes = { publishNow: 'Publish now' };
const notifications = { const notifications = {
saved: 'Entry saved', saved: 'Entry saved',

View File

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

View File

@ -20,7 +20,7 @@
"test:unit": "cross-env NODE_ENV=test jest --no-cache", "test:unit": "cross-env NODE_ENV=test jest --no-cache",
"test:e2e": "run-s build:demo test:e2e:run", "test:e2e": "run-s build:demo test:e2e:run",
"test:e2e:ci": "run-s build:demo test:e2e:run-ci", "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:serve": "http-server dev-test",
"test:e2e:exec": "cypress run", "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'", "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:start": "node -e 'require(\"./cypress/utils/mock-server\").start()'",
"mock:server:stop": "node -e 'require(\"./cypress/utils/mock-server\").stop()'", "mock:server:stop": "node -e 'require(\"./cypress/utils/mock-server\").stop()'",
"lint": "run-p -c --aggregate-output \"lint:*\"", "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:css": "stylelint --ignore-path .gitignore \"{packages/**/*.{css,js},website/**/*.css}\"",
"lint:js": "eslint --color --ignore-path .gitignore \"{{packages,scripts,website}/**/,}*.js\"", "lint:js": "eslint --color --ignore-path .gitignore \"{{packages,scripts,website}/**/,}*.js\"",
"lint:format": "prettier \"{{packages,scripts,website}/**/,}*.{js,css}\" --list-different", "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}\"", "format:prettier": "prettier \"{{packages,scripts,website}/**/,}*.{js,css}\"",
"publish": "run-s publish:before-manual-version publish:after-manual-version", "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\"", "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/core": "^7.3.4",
"@babel/plugin-proposal-class-properties": "^7.3.4", "@babel/plugin-proposal-class-properties": "^7.3.4",
"@babel/plugin-proposal-export-default-from": "^7.2.0", "@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-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-env": "^7.3.4",
"@babel/preset-react": "^7.0.0", "@babel/preset-react": "^7.0.0",
"@commitlint/cli": "^8.2.0", "@commitlint/cli": "^8.3.3",
"@commitlint/config-conventional": "^8.2.0", "@commitlint/config-conventional": "^8.2.0",
"@octokit/rest": "^16.28.7", "@octokit/rest": "^16.28.7",
"@testing-library/jest-dom": "^4.2.3", "@testing-library/jest-dom": "^4.2.3",
"@testing-library/react": "^9.3.2", "@testing-library/react": "^9.3.2",
"all-contributors-cli": "^6.0.0", "all-contributors-cli": "^6.0.0",
"babel-core": "^7.0.0-bridge.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-jest": "^24.5.0",
"babel-loader": "^8.0.5", "babel-loader": "^8.0.5",
"babel-plugin-emotion": "^10.0.9", "babel-plugin-emotion": "^10.0.9",
"babel-plugin-inline-json-import": "^0.3.2",
"babel-plugin-inline-react-svg": "^1.1.0", "babel-plugin-inline-react-svg": "^1.1.0",
"babel-plugin-lodash": "^3.3.4", "babel-plugin-lodash": "^3.3.4",
"babel-plugin-module-resolver": "^3.2.0", "babel-plugin-module-resolver": "^3.2.0",
@ -98,6 +102,8 @@
"cross-env": "^6.0.0", "cross-env": "^6.0.0",
"css-loader": "^3.0.0", "css-loader": "^3.0.0",
"cypress": "^3.4.1", "cypress": "^3.4.1",
"cypress-jest-adapter": "^0.0.3",
"cypress-plugin-tab": "^1.0.0",
"dom-testing-library": "^4.0.0", "dom-testing-library": "^4.0.0",
"dotenv": "^8.0.0", "dotenv": "^8.0.0",
"eslint": "^5.15.1", "eslint": "^5.15.1",
@ -121,25 +127,36 @@
"npm-run-all": "^4.1.5", "npm-run-all": "^4.1.5",
"prettier": "^1.19.1", "prettier": "^1.19.1",
"react-test-renderer": "^16.8.4", "react-test-renderer": "^16.8.4",
"rehype": "^7.0.0",
"rimraf": "^3.0.0", "rimraf": "^3.0.0",
"simple-git": "^1.124.0", "simple-git": "^1.124.0",
"start-server-and-test": "^1.7.11", "start-server-and-test": "^1.7.11",
"style-loader": "^0.23.1",
"stylelint": "^9.10.1", "stylelint": "^9.10.1",
"stylelint-config-recommended": "^2.1.0", "stylelint-config-recommended": "^2.1.0",
"stylelint-config-styled-components": "^0.1.1", "stylelint-config-styled-components": "^0.1.1",
"stylelint-processor-styled-components": "^1.5.2",
"svg-inline-loader": "^0.8.0", "svg-inline-loader": "^0.8.0",
"to-string-loader": "^1.1.5", "to-string-loader": "^1.1.5",
"unist-util-visit": "^1.4.0",
"webpack": "^4.29.6", "webpack": "^4.29.6",
"webpack-cli": "^3.2.3", "webpack-cli": "^3.2.3",
"webpack-dev-server": "^3.2.1" "webpack-dev-server": "^3.2.1"
}, },
"workspaces": [ "workspaces": {
"packages/*" "packages": [
], "packages/*"
],
"nohoist": [
"husky",
"run-node"
]
},
"private": true, "private": true,
"dependencies": { "dependencies": {
"@emotion/babel-preset-css-prop": "^10.0.9",
"emotion": "^10.0.9", "emotion": "^10.0.9",
"eslint-config-prettier": "^6.5.0",
"eslint-plugin-babel": "^5.3.0",
"lerna": "^3.15.0" "lerna": "^3.15.0"
}, },
"husky": { "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'; 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 NetlifyCmsWidgetString from 'netlify-cms-widget-string';
import NetlifyCmsWidgetNumber from 'netlify-cms-widget-number'; import NetlifyCmsWidgetNumber from 'netlify-cms-widget-number';
import NetlifyCmsWidgetText from 'netlify-cms-widget-text'; 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 NetlifyCmsWidgetDate from 'netlify-cms-widget-date';
import NetlifyCmsWidgetDatetime from 'netlify-cms-widget-datetime'; 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([ CMS.registerWidget([
NetlifyCmsWidgetString.Widget(), NetlifyCmsWidgetString.Widget(),
NetlifyCmsWidgetNumber.Widget(), NetlifyCmsWidgetNumber.Widget(),
@ -30,3 +52,5 @@ CMS.registerWidget([
NetlifyCmsWidgetDate.Widget(), NetlifyCmsWidgetDate.Widget(),
NetlifyCmsWidgetDatetime.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 { NetlifyCmsCore as CMS } from 'netlify-cms-core';
import './backends'; import './extensions.js';
import './widgets';
import './editor-components';
import './locales';
// Log version
if (typeof window !== 'undefined') { if (typeof window !== 'undefined') {
/**
* Log the version number.
*/
if (typeof NETLIFY_CMS_APP_VERSION === 'string') { if (typeof NETLIFY_CMS_APP_VERSION === 'string') {
console.log(`netlify-cms-app ${NETLIFY_CMS_APP_VERSION}`); console.log(`netlify-cms-app ${NETLIFY_CMS_APP_VERSION}`);
} }

View File

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

View File

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

View File

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

View File

@ -1,13 +1,12 @@
/** @jsx jsx */
import React from 'react'; import React from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import ImmutablePropTypes from 'react-immutable-proptypes'; import ImmutablePropTypes from 'react-immutable-proptypes';
import { translate } from 'react-polyglot'; 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 styled from '@emotion/styled';
import { partial, uniqueId } from 'lodash'; import { partial, uniqueId } from 'lodash';
import { connect } from 'react-redux'; 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 { resolveWidget, getEditorComponents } from 'Lib/registry';
import { clearFieldErrors, loadEntry } from 'Actions/entries'; import { clearFieldErrors, loadEntry } from 'Actions/entries';
import { addAsset } from 'Actions/media'; import { addAsset } from 'Actions/media';
@ -27,48 +26,6 @@ import Widget from './Widget';
* this. * this.
*/ */
const styleStrings = { 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: ` widget: `
display: block; display: block;
width: 100%; width: 100%;
@ -85,6 +42,7 @@ const styleStrings = {
position: relative; position: relative;
font-size: 15px; font-size: 15px;
line-height: 1.5; line-height: 1.5;
overflow: hidden;
select& { select& {
text-indent: 14px; text-indent: 14px;
@ -155,6 +113,8 @@ class EditorControl extends React.Component {
clearFieldErrors: PropTypes.func.isRequired, clearFieldErrors: PropTypes.func.isRequired,
loadEntry: PropTypes.func.isRequired, loadEntry: PropTypes.func.isRequired,
t: PropTypes.func.isRequired, t: PropTypes.func.isRequired,
isEditorComponent: PropTypes.bool,
isNewEditorComponent: PropTypes.bool,
}; };
state = { state = {
@ -186,6 +146,10 @@ class EditorControl extends React.Component {
clearSearch, clearSearch,
clearFieldErrors, clearFieldErrors,
loadEntry, loadEntry,
className,
isSelected,
isEditorComponent,
isNewEditorComponent,
t, t,
} = this.props; } = this.props;
const widgetName = field.get('widget'); const widgetName = field.get('widget');
@ -199,11 +163,11 @@ class EditorControl extends React.Component {
return ( return (
<ClassNames> <ClassNames>
{({ css, cx }) => ( {({ css, cx }) => (
<ControlContainer> <ControlContainer className={className}>
{widget.globalStyles && <Global styles={coreCss`${widget.globalStyles}`} />} {widget.globalStyles && <Global styles={coreCss`${widget.globalStyles}`} />}
<ControlErrorsList> {errors && (
{errors && <ControlErrorsList>
errors.map( {errors.map(
error => error =>
error.message && error.message &&
typeof error.message === 'string' && ( typeof error.message === 'string' && (
@ -212,25 +176,15 @@ class EditorControl extends React.Component {
</li> </li>
), ),
)} )}
</ControlErrorsList> </ControlErrorsList>
<label )}
className={cx( <FieldLabel
css` isActive={isSelected || this.state.styleActive}
${styleStrings.label}; hasErrors={!!errors}
`,
this.state.styleActive &&
css`
${styleStrings.labelActive};
`,
!!errors &&
css`
${styleStrings.labelError};
`,
)}
htmlFor={this.uniqueFieldId} htmlFor={this.uniqueFieldId}
> >
{`${field.get('label', field.get('name'))}${isFieldOptional ? ' (optional)' : ''}`} {`${field.get('label', field.get('name'))}${isFieldOptional ? ' (optional)' : ''}`}
</label> </FieldLabel>
<Widget <Widget
classNameWrapper={cx( classNameWrapper={cx(
css` css`
@ -239,7 +193,7 @@ class EditorControl extends React.Component {
{ {
[css` [css`
${styleStrings.widgetActive}; ${styleStrings.widgetActive};
`]: this.state.styleActive, `]: isSelected || this.state.styleActive,
}, },
{ {
[css` [css`
@ -273,10 +227,11 @@ class EditorControl extends React.Component {
onRemoveInsertedMedia={removeInsertedMedia} onRemoveInsertedMedia={removeInsertedMedia}
onAddAsset={addAsset} onAddAsset={addAsset}
getAsset={boundGetAsset} getAsset={boundGetAsset}
hasActiveStyle={this.state.styleActive} hasActiveStyle={isSelected || this.state.styleActive}
setActiveStyle={() => this.setState({ styleActive: true })} setActiveStyle={() => this.setState({ styleActive: true })}
setInactiveStyle={() => this.setState({ styleActive: false })} setInactiveStyle={() => this.setState({ styleActive: false })}
resolveWidget={resolveWidget} resolveWidget={resolveWidget}
widget={widget}
getEditorComponents={getEditorComponents} getEditorComponents={getEditorComponents}
ref={processControlRef && partial(processControlRef, field)} ref={processControlRef && partial(processControlRef, field)}
controlRef={controlRef} controlRef={controlRef}
@ -289,10 +244,12 @@ class EditorControl extends React.Component {
isFetching={isFetching} isFetching={isFetching}
fieldsErrors={fieldsErrors} fieldsErrors={fieldsErrors}
onValidateObject={onValidateObject} onValidateObject={onValidateObject}
isEditorComponent={isEditorComponent}
isNewEditorComponent={isNewEditorComponent}
t={t} t={t}
/> />
{fieldHint && ( {fieldHint && (
<ControlHint active={this.state.styleActive} error={!!errors}> <ControlHint active={isSelected || this.state.styleActive} error={!!errors}>
{fieldHint} {fieldHint}
</ControlHint> </ControlHint>
)} )}

View File

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

View File

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

View File

@ -26,6 +26,7 @@ export default class PreviewPane extends React.Component {
const { getAsset, entry } = props; const { getAsset, entry } = props;
const widget = resolveWidget(field.get('widget')); const widget = resolveWidget(field.get('widget'));
const key = idx ? field.get('name') + '_' + idx : field.get('name'); 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. * Use an HOC to provide conditional updates for all previews.
@ -36,7 +37,7 @@ export default class PreviewPane extends React.Component {
key={key} key={key}
field={field} field={field}
getAsset={getAsset} getAsset={getAsset}
value={value && Map.isMap(value) ? value.get(field.get('name')) : value} value={valueIsInMap ? value.get(field.get('name')) : value}
entry={entry} entry={entry}
fieldsMetaData={metadata} 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 // eslint-disable-next-line no-unused-vars
import React from 'react'; import React from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { jsx, css, Global } from '@emotion/core'; import { css, Global } from '@emotion/core';
import { translate } from 'react-polyglot'; import { translate } from 'react-polyglot';
import reduxNotificationsStyles from 'redux-notifications/lib/styles.css'; import reduxNotificationsStyles from 'redux-notifications/lib/styles.css';
import { shadows, colors, lengths } from 'netlify-cms-ui-default'; import { shadows, colors, lengths } from 'netlify-cms-ui-default';

View File

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

View File

@ -1,4 +1,5 @@
import { Map } from 'immutable'; import { Map } from 'immutable';
import produce from 'immer';
import { oneLine } from 'common-tags'; import { oneLine } from 'common-tags';
import EditorComponent from 'ValueObjects/EditorComponent'; import EditorComponent from 'ValueObjects/EditorComponent';
@ -23,6 +24,7 @@ export default {
getPreviewTemplate, getPreviewTemplate,
registerWidget, registerWidget,
getWidget, getWidget,
getWidgets,
resolveWidget, resolveWidget,
registerEditorComponent, registerEditorComponent,
getEditorComponents, getEditorComponents,
@ -81,10 +83,12 @@ export function registerWidget(name, control, preview) {
name: widgetName, name: widgetName,
controlComponent: control, controlComponent: control,
previewComponent: preview, previewComponent: preview,
allowMapValue,
globalStyles, globalStyles,
...options
} = name; } = name;
if (registry.widgets[widgetName]) { if (registry.widgets[widgetName]) {
console.error(oneLine` console.warn(oneLine`
Multiple widgets registered with name "${widgetName}". Only the last widget registered with Multiple widgets registered with name "${widgetName}". Only the last widget registered with
this name will be used. this name will be used.
`); `);
@ -92,7 +96,7 @@ export function registerWidget(name, control, preview) {
if (!control) { if (!control) {
throw Error(`Widget "${widgetName}" registered without \`controlComponent\`.`); throw Error(`Widget "${widgetName}" registered without \`controlComponent\`.`);
} }
registry.widgets[widgetName] = { control, preview, globalStyles }; registry.widgets[widgetName] = { control, preview, globalStyles, allowMapValue, ...options };
} else { } else {
console.error('`registerWidget` failed, called with incorrect arguments.'); console.error('`registerWidget` failed, called with incorrect arguments.');
} }
@ -100,6 +104,11 @@ export function registerWidget(name, control, preview) {
export function getWidget(name) { export function getWidget(name) {
return registry.widgets[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) { export function resolveWidget(name) {
return getWidget(name || 'string') || getWidget('unknown'); return getWidget(name || 'string') || getWidget('unknown');
} }
@ -109,7 +118,19 @@ export function resolveWidget(name) {
*/ */
export function registerEditorComponent(component) { export function registerEditorComponent(component) {
const plugin = EditorComponent(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() { export function getEditorComponents() {
return registry.editorComponents; return registry.editorComponents;

View File

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

View File

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

View File

@ -1,39 +1,35 @@
import { Record, fromJS } from 'immutable'; import { fromJS } from 'immutable';
import { isFunction } from 'lodash'; import { isFunction } from 'lodash';
const catchesNothing = /.^/; const catchesNothing = /.^/;
/* eslint-disable no-unused-vars */ const bind = fn => isFunction(fn) && fn.bind(null);
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 */
export default function createEditorComponent(config) { export default function createEditorComponent(config) {
const configObj = new EditorComponent({ const {
id: config.id || config.label.replace(/[^A-Z0-9]+/gi, '_'), id = null,
label: config.label, label = 'unnamed component',
icon: config.icon, icon = 'exclamation-triangle',
fields: fromJS(config.fields), type = 'shortcode',
pattern: config.pattern, widget = 'object',
fromBlock: isFunction(config.fromBlock) ? config.fromBlock.bind(null) : null, pattern = catchesNothing,
toBlock: isFunction(config.toBlock) ? config.toBlock.bind(null) : null, fields = [],
toPreview: isFunction(config.toPreview) fromBlock,
? config.toPreview.bind(null) toBlock,
: config.toBlock.bind(null), 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", "repository": "https://github.com/netlify/netlify-cms/tree/master/packages/netlify-cms-default-exports",
"bugs": "https://github.com/netlify/netlify-cms/issues", "bugs": "https://github.com/netlify/netlify-cms/issues",
"module": "dist/esm/index.js", "module": "dist/esm/index.js",
"main": "dist/netlify-cms-editor-component-image.js", "main": "dist/netlify-cms-default-exports.js",
"license": "MIT", "license": "MIT",
"keywords": [ "keywords": [
"netlify", "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 Icon from './Icon';
import ListItemTopBar from './ListItemTopBar'; import ListItemTopBar from './ListItemTopBar';
import Loader from './Loader'; import Loader from './Loader';
import FieldLabel from './FieldLabel';
import IconButton from './IconButton';
import Toggle, { ToggleContainer, ToggleBackground, ToggleHandle } from './Toggle'; import Toggle, { ToggleContainer, ToggleBackground, ToggleHandle } from './Toggle';
import AuthenticationPage from './AuthenticationPage'; import AuthenticationPage from './AuthenticationPage';
import WidgetPreviewContainer from './WidgetPreviewContainer'; import WidgetPreviewContainer from './WidgetPreviewContainer';
@ -14,6 +16,7 @@ import {
lengths, lengths,
components, components,
buttons, buttons,
text,
shadows, shadows,
borders, borders,
transitions, transitions,
@ -28,7 +31,9 @@ export const NetlifyCmsUiDefault = {
DropdownButton, DropdownButton,
StyledDropdownButton, StyledDropdownButton,
ListItemTopBar, ListItemTopBar,
FieldLabel,
Icon, Icon,
IconButton,
Loader, Loader,
Toggle, Toggle,
ToggleContainer, ToggleContainer,
@ -44,6 +49,7 @@ export const NetlifyCmsUiDefault = {
components, components,
buttons, buttons,
shadows, shadows,
text,
borders, borders,
transitions, transitions,
effects, effects,
@ -56,7 +62,9 @@ export {
DropdownButton, DropdownButton,
StyledDropdownButton, StyledDropdownButton,
ListItemTopBar, ListItemTopBar,
FieldLabel,
Icon, Icon,
IconButton,
Loader, Loader,
Toggle, Toggle,
ToggleContainer, ToggleContainer,
@ -72,6 +80,7 @@ export {
components, components,
buttons, buttons,
shadows, shadows,
text,
borders, borders,
transitions, transitions,
effects, 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 = { const gradients = {
checkerboard: ` checkerboard: `
linear-gradient( linear-gradient(
@ -465,6 +474,7 @@ export {
lengths, lengths,
components, components,
buttons, buttons,
text,
shadows, shadows,
borders, borders,
transitions, transitions,

View File

@ -1,8 +1,7 @@
/** @jsx jsx */
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import React from 'react'; import React from 'react';
import ImmutablePropTypes from 'react-immutable-proptypes'; 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'; import { Toggle, ToggleBackground, colors } from 'netlify-cms-ui-default';
const BooleanBackground = ({ isActive, ...props }) => ( 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 React from 'react';
import PropTypes from 'prop-types'; 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 reactDateTimeStyles from 'react-datetime/css/react-datetime.css';
import DateTime from 'react-datetime'; import DateTime from 'react-datetime';
import moment from 'moment'; import moment from 'moment';

View File

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

View File

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

View File

@ -25,21 +25,23 @@
"is-hotkey": "^0.1.4", "is-hotkey": "^0.1.4",
"mdast-util-definitions": "^1.2.3", "mdast-util-definitions": "^1.2.3",
"mdast-util-to-string": "^1.0.5", "mdast-util-to-string": "^1.0.5",
"rehype-parse": "^3.1.0", "re-resizable": "^4.11.0",
"rehype-remark": "^2.0.0", "react-monaco-editor": "^0.25.1",
"rehype-stringify": "^3.0.0", "react-select": "^2.4.3",
"remark-parse": "^3.0.1", "rehype-parse": "^6.0.0",
"remark-rehype": "^2.0.0", "rehype-remark": "^5.0.1",
"remark-stringify": "^3.0.1", "rehype-stringify": "^5.0.0",
"slate": "^0.34.0", "remark-parse": "^6.0.3",
"slate-edit-list": "^0.11.3", "remark-rehype": "^4.0.0",
"slate-edit-table": "^0.15.1", "remark-stringify": "^6.0.4",
"slate-plain-serializer": "^0.5.15", "slate": "^0.47.0",
"slate-react": "0.12.9", "slate-base64-serializer": "^0.2.107",
"slate-soft-break": "^0.6.1", "slate-plain-serializer": "^0.7.1",
"unified": "^6.1.4", "slate-react": "^0.22.0",
"unist-builder": "^1.0.2", "slate-soft-break": "^0.9.0",
"unist-util-visit-parents": "^1.1.1" "unified": "^7.1.0",
"unist-builder": "^1.0.3",
"unist-util-visit-parents": "^2.0.1"
}, },
"peerDependencies": { "peerDependencies": {
"@emotion/core": "^10.0.9", "@emotion/core": "^10.0.9",
@ -51,5 +53,11 @@
"react": "^16.8.4", "react": "^16.8.4",
"react-dom": "^16.8.4", "react-dom": "^16.8.4",
"react-immutable-proptypes": "^2.1.0" "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 ImmutablePropTypes from 'react-immutable-proptypes';
import styled from '@emotion/styled'; import styled from '@emotion/styled';
import { ClassNames } from '@emotion/core'; import { ClassNames } from '@emotion/core';
import { Editor as Slate } from 'slate-react';
import Plain from 'slate-plain-serializer';
import { debounce } from 'lodash'; 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 { lengths, fonts } from 'netlify-cms-ui-default';
import { markdownToHtml } from '../serializers';
import { editorStyleVars, EditorControlBar } from '../styles'; import { editorStyleVars, EditorControlBar } from '../styles';
import Toolbar from './Toolbar'; import Toolbar from './Toolbar';
@ -40,38 +42,61 @@ export default class RawEditor extends React.Component {
return !this.state.value.equals(nextState.value); return !this.state.value.equals(nextState.value);
} }
handleChange = change => { componentDidMount() {
if (!this.state.value.document.equals(change.value.document)) { if (this.props.pendingFocus) {
this.handleDocumentChange(change); 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 * 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. * text (which is Markdown) and pass that up as the new value.
*/ */
handleDocumentChange = debounce(change => { handleDocumentChange = debounce(editor => {
const value = Plain.serialize(change.value); const value = Plain.serialize(editor.value);
this.props.onChange(value); this.props.onChange(value);
}, 150); }, 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 = () => { handleToggleMode = () => {
this.props.onMode('visual'); this.props.onMode('visual');
}; };
processRef = ref => {
this.editor = ref;
};
render() { render() {
const { className, field } = this.props; const { className, field } = this.props;
return ( return (
@ -96,6 +121,9 @@ export default class RawEditor extends React.Component {
value={this.state.value} value={this.state.value}
onChange={this.handleChange} onChange={this.handleChange}
onPaste={this.handlePaste} onPaste={this.handlePaste}
onCut={this.handleCut}
onCopy={this.handleCopy}
ref={this.processRef}
/> />
)} )}
</ClassNames> </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, onMarkClick: PropTypes.func,
onBlockClick: PropTypes.func, onBlockClick: PropTypes.func,
onLinkClick: PropTypes.func, onLinkClick: PropTypes.func,
selectionHasMark: PropTypes.func, hasMark: PropTypes.func,
selectionHasBlock: PropTypes.func, hasInline: PropTypes.func,
selectionHasLink: PropTypes.func, hasBlock: PropTypes.func,
}; };
isHidden = button => { isHidden = button => {
@ -90,19 +90,29 @@ export default class Toolbar extends React.Component {
return List.isList(buttons) ? !buttons.includes(button) : false; 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() { render() {
const { const {
onMarkClick,
onBlockClick,
onLinkClick, onLinkClick,
selectionHasMark,
selectionHasBlock,
selectionHasLink,
onToggleMode, onToggleMode,
rawMode, rawMode,
plugins, plugins,
disabled, disabled,
onSubmit, onSubmit,
hasMark = () => {},
hasInline = () => {},
hasBlock = () => {},
} = this.props; } = this.props;
return ( return (
@ -112,8 +122,8 @@ export default class Toolbar extends React.Component {
type="bold" type="bold"
label="Bold" label="Bold"
icon="bold" icon="bold"
onClick={onMarkClick} onClick={this.handleMarkClick}
isActive={selectionHasMark} isActive={hasMark('bold')}
isHidden={this.isHidden('bold')} isHidden={this.isHidden('bold')}
disabled={disabled} disabled={disabled}
/> />
@ -121,8 +131,8 @@ export default class Toolbar extends React.Component {
type="italic" type="italic"
label="Italic" label="Italic"
icon="italic" icon="italic"
onClick={onMarkClick} onClick={this.handleMarkClick}
isActive={selectionHasMark} isActive={hasMark('italic')}
isHidden={this.isHidden('italic')} isHidden={this.isHidden('italic')}
disabled={disabled} disabled={disabled}
/> />
@ -130,8 +140,8 @@ export default class Toolbar extends React.Component {
type="code" type="code"
label="Code" label="Code"
icon="code" icon="code"
onClick={onMarkClick} onClick={this.handleMarkClick}
isActive={selectionHasMark} isActive={hasMark('code')}
isHidden={this.isHidden('code')} isHidden={this.isHidden('code')}
disabled={disabled} disabled={disabled}
/> />
@ -140,7 +150,7 @@ export default class Toolbar extends React.Component {
label="Link" label="Link"
icon="link" icon="link"
onClick={onLinkClick} onClick={onLinkClick}
isActive={selectionHasLink} isActive={hasInline('link')}
isHidden={this.isHidden('link')} isHidden={this.isHidden('link')}
disabled={disabled} disabled={disabled}
/> />
@ -158,10 +168,10 @@ export default class Toolbar extends React.Component {
label="Headings" label="Headings"
icon="hOptions" icon="hOptions"
disabled={disabled} disabled={disabled}
isActive={() => isActive={
!disabled && !disabled &&
Object.keys(headingOptions).some(optionKey => { Object.keys(headingOptions).some(optionKey => {
return selectionHasBlock(optionKey); return hasBlock(optionKey);
}) })
} }
/> />
@ -175,8 +185,8 @@ export default class Toolbar extends React.Component {
<DropdownItem <DropdownItem
key={idx} key={idx}
label={headingOptions[optionKey]} label={headingOptions[optionKey]}
className={selectionHasBlock(optionKey) ? 'active' : undefined} className={hasBlock(optionKey) ? 'active' : ''}
onClick={() => onBlockClick(undefined, optionKey)} onClick={() => this.handleBlockClick(null, optionKey)}
/> />
), ),
)} )}
@ -187,26 +197,17 @@ export default class Toolbar extends React.Component {
type="quote" type="quote"
label="Quote" label="Quote"
icon="quote" icon="quote"
onClick={onBlockClick} onClick={this.handleBlockClick}
isActive={selectionHasBlock} isActive={hasBlock('quote')}
isHidden={this.isHidden('quote')} isHidden={this.isHidden('quote')}
disabled={disabled} disabled={disabled}
/> />
<ToolbarButton
type="code"
label="Code Block"
icon="code-block"
onClick={onBlockClick}
isActive={selectionHasBlock}
isHidden={this.isHidden('code-block')}
disabled={disabled}
/>
<ToolbarButton <ToolbarButton
type="bulleted-list" type="bulleted-list"
label="Bulleted List" label="Bulleted List"
icon="list-bulleted" icon="list-bulleted"
onClick={onBlockClick} onClick={this.handleBlockClick}
isActive={selectionHasBlock} isActive={hasBlock('bulleted-list')}
isHidden={this.isHidden('bulleted-list')} isHidden={this.isHidden('bulleted-list')}
disabled={disabled} disabled={disabled}
/> />
@ -214,14 +215,15 @@ export default class Toolbar extends React.Component {
type="numbered-list" type="numbered-list"
label="Numbered List" label="Numbered List"
icon="list-numbered" icon="list-numbered"
onClick={onBlockClick} onClick={this.handleBlockClick}
isActive={selectionHasBlock} isActive={hasBlock('numbered-list')}
isHidden={this.isHidden('numbered-list')} isHidden={this.isHidden('numbered-list')}
disabled={disabled} disabled={disabled}
/> />
<ToolbarDropdownWrapper> <ToolbarDropdownWrapper>
<Dropdown <Dropdown
dropdownTopOverlap="36px" dropdownTopOverlap="36px"
dropdownWidth="110px"
renderButton={() => ( renderButton={() => (
<DropdownButton> <DropdownButton>
<ToolbarButton <ToolbarButton
@ -237,11 +239,7 @@ export default class Toolbar extends React.Component {
plugins plugins
.toList() .toList()
.map((plugin, idx) => ( .map((plugin, idx) => (
<DropdownItem <DropdownItem key={idx} label={plugin.label} onClick={() => onSubmit(plugin)} />
key={idx}
label={plugin.get('label')}
onClick={() => onSubmit(plugin.get('id'))}
/>
))} ))}
</Dropdown> </Dropdown>
</ToolbarDropdownWrapper> </ToolbarDropdownWrapper>

View File

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

View File

@ -1,23 +1,38 @@
import React from 'react'; import React from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import ImmutablePropTypes from 'react-immutable-proptypes'; import ImmutablePropTypes from 'react-immutable-proptypes';
import { fromJS } from 'immutable';
import styled from '@emotion/styled'; import styled from '@emotion/styled';
import { ClassNames } from '@emotion/core'; import { css as coreCss, ClassNames } from '@emotion/core';
import { get, isEmpty, debounce, uniq } from 'lodash'; import { get, isEmpty, debounce } from 'lodash';
import { List } from 'immutable';
import { Value, Document, Block, Text } from 'slate'; import { Value, Document, Block, Text } from 'slate';
import { Editor as Slate } from 'slate-react'; 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 Toolbar from '../MarkdownControl/Toolbar';
import { renderNode, renderMark } from './renderers'; import { renderBlock, renderInline, renderMark } from './renderers';
import { validateNode } from './validators'; import plugins from './plugins/visual';
import plugins, { EditListConfigured } from './plugins'; import schema from './schema';
import onKeyDown from './keys';
import visualEditorStyles from './visualEditorStyles';
import { EditorControlBar } from '../styles';
const VisualEditorContainer = styled.div` const visualEditorStyles = `
position: relative; 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 = () => { const createEmptyRawDoc = () => {
@ -26,14 +41,37 @@ const createEmptyRawDoc = () => {
return { nodes: [emptyBlock] }; return { nodes: [emptyBlock] };
}; };
const createSlateValue = rawValue => { const createSlateValue = (rawValue, { voidCodeBlock }) => {
const rawDoc = rawValue && markdownToSlate(rawValue); const rawDoc = rawValue && markdownToSlate(rawValue, { voidCodeBlock });
const rawDocHasNodes = !isEmpty(get(rawDoc, 'nodes')); const rawDocHasNodes = !isEmpty(get(rawDoc, 'nodes'));
const document = Document.fromJSON(rawDocHasNodes ? rawDoc : createEmptyRawDoc()); const document = Document.fromJSON(rawDocHasNodes ? rawDoc : createEmptyRawDoc());
return Value.create({ document }); return Value.create({ document });
}; };
export default class Editor extends React.Component { 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 = { static propTypes = {
onAddAsset: PropTypes.func.isRequired, onAddAsset: PropTypes.func.isRequired,
getAsset: PropTypes.func.isRequired, getAsset: PropTypes.func.isRequired,
@ -45,249 +83,116 @@ export default class Editor extends React.Component {
getEditorComponents: PropTypes.func.isRequired, getEditorComponents: PropTypes.func.isRequired,
}; };
constructor(props) {
super(props);
this.state = {
value: createSlateValue(props.value),
lastRawValue: props.value,
};
}
shouldComponentUpdate(nextProps, nextState) { shouldComponentUpdate(nextProps, nextState) {
const forcePropsValue = this.shouldForcePropsValue( return !this.state.value.equals(nextState.value);
this.props.value,
this.state.lastRawValue,
nextProps.value,
nextState.lastRawValue,
);
return !this.state.value.equals(nextState.value) || forcePropsValue;
} }
componentDidUpdate(prevProps, prevState) { componentDidMount() {
const forcePropsValue = this.shouldForcePropsValue( if (this.props.pendingFocus) {
prevProps.value, this.editor.focus();
prevState.lastRawValue, this.props.pendingFocus();
this.props.value,
this.state.lastRawValue,
);
if (forcePropsValue) {
this.setState({
value: createSlateValue(this.props.value),
lastRawValue: this.props.value,
});
} }
} }
// If the old props/state values and new state value are all the same, and handleMarkClick = type => {
// the new props value does not match the others, the new props value this.editor.toggleMark(type).focus();
// 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);
}; };
selectionHasMark = type => this.state.value.activeMarks.some(mark => mark.type === type); handleBlockClick = type => {
selectionHasBlock = type => this.state.value.blocks.some(node => node.type === type); this.editor.toggleBlock(type).focus();
handleMarkClick = (event, type) => {
event.preventDefault();
const resolvedChange = this.state.value
.change()
.focus()
.toggleMark(type);
this.ref.onChange(resolvedChange);
this.setState({ value: resolvedChange.value });
}; };
handleBlockClick = (event, type) => { handleLinkClick = () => {
if (event) { this.editor.toggleLink(() => window.prompt('Enter the URL of the link'));
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 });
}; };
hasLinks = () => { hasMark = type => this.editor && this.editor.hasMark(type);
return this.state.value.inlines.some(inline => inline.type === 'link'); hasInline = type => this.editor && this.editor.hasInline(type);
}; hasBlock = type => this.editor && this.editor.hasBlock(type);
handleLink = () => { handleToggleMode = () => {
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 = () => {
this.props.onMode('raw'); 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 { onChange } = this.props;
const raw = change.value.document.toJSON(); const raw = editor.value.document.toJS();
const markdown = slateToMarkdown(raw); const markdown = slateToMarkdown(raw, { voidCodeBlock: this.codeBlockComponent });
this.setState({ lastRawValue: markdown }, () => onChange(markdown)); onChange(markdown);
}, 150); }, 150);
handleChange = change => { handleChange = editor => {
if (!this.state.value.document.equals(change.value.document)) { if (!this.state.value.document.equals(editor.value.document)) {
this.handleDocumentChange(change); this.handleDocumentChange(editor);
} }
this.setState({ value: change.value }); this.setState({ value: editor.value });
}; };
processRef = ref => { processRef = ref => {
this.ref = ref; this.editor = ref;
}; };
render() { render() {
const { onAddAsset, getAsset, className, field, getEditorComponents } = this.props; const { onAddAsset, getAsset, className, field } = this.props;
return ( return (
<VisualEditorContainer> <div
css={coreCss`
position: relative;
`}
>
<EditorControlBar> <EditorControlBar>
<Toolbar <Toolbar
onMarkClick={this.handleMarkClick} onMarkClick={this.handleMarkClick}
onBlockClick={this.handleBlockClick} onBlockClick={this.handleBlockClick}
onLinkClick={this.handleLink} onLinkClick={this.handleLinkClick}
selectionHasMark={this.selectionHasMark} onToggleMode={this.handleToggleMode}
selectionHasBlock={this.selectionHasBlock} plugins={this.editorComponents}
selectionHasLink={this.hasLinks} onSubmit={this.handleInsertShortcode}
onToggleMode={this.handleToggle}
plugins={getEditorComponents()}
onSubmit={this.handlePluginAdd}
onAddAsset={onAddAsset} onAddAsset={onAddAsset}
getAsset={getAsset} getAsset={getAsset}
buttons={field.get('buttons')} buttons={field.get('buttons')}
hasMark={this.hasMark}
hasInline={this.hasInline}
hasBlock={this.hasBlock}
/> />
</EditorControlBar> </EditorControlBar>
<ClassNames> <ClassNames>
{({ css, cx }) => ( {({ css, cx }) => (
<Slate <div
className={cx( className={cx(
className, className,
css` css`
${visualEditorStyles} ${visualEditorStyles}
`, `,
)} )}
value={this.state.value} >
renderNode={renderNode} <Slate
renderMark={renderMark} className={css`
validateNode={validateNode} padding: 16px 20px 0;
plugins={plugins} `}
onChange={this.handleChange} value={this.state.value}
onKeyDown={onKeyDown} renderBlock={this.renderBlock}
onPaste={this.handlePaste} renderInline={this.renderInline}
ref={this.processRef} renderMark={this.renderMark}
spellCheck schema={this.schema}
/> plugins={this.plugins}
onChange={this.handleChange}
ref={this.processRef}
spellCheck
/>
<InsertionPoint onClick={this.handleClickBelowDocument} />
</div>
)} )}
</ClassNames> </ClassNames>
</VisualEditorContainer> </div>
); );
} }
} }

View File

@ -9,7 +9,34 @@ describe('Compile markdown to Slate Raw AST', () => {
sweet body 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', () => { it('should compile a markdown ordered list', () => {
@ -20,7 +47,81 @@ sweet body
2. bro 2. bro
3. fro 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', () => { it('should compile bulleted lists', () => {
@ -31,7 +132,81 @@ sweet body
* bro * bro
* fro * 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', () => { it('should compile multiple header levels', () => {
@ -42,7 +217,44 @@ sweet body
### H3 ### 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', () => { it('should compile horizontal rules', () => {
@ -53,7 +265,38 @@ sweet body
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 horizontal rules', () => { it('should compile horizontal rules', () => {
@ -64,7 +307,38 @@ blue moon
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)', () => { it('should compile soft breaks (double space)', () => {
@ -72,14 +346,62 @@ blue moon
blue moon blue moon
footballs 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', () => { it('should compile images', () => {
const value = ` const value = `
![super](duper.jpg) ![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', () => { it('should compile code blocks', () => {
@ -88,7 +410,27 @@ footballs
var a = 1; 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', () => { it('should compile nested inline markup', () => {
@ -99,7 +441,87 @@ This is **some *hot* content**
perhaps **scalding** even 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', () => { it('should compile inline code', () => {
@ -108,7 +530,47 @@ perhaps **scalding** even
This is some sweet \`inline code\` yo! 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', () => { 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? 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', () => { it('should compile plugins', () => {
@ -126,7 +633,39 @@ How far is it to [Google](https://google.com) land?
{{< test >}} {{< 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', () => { 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'; 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 editorControl;
let _getEditorComponents = () => []; let _getEditorComponents = () => [];
@ -32,16 +34,23 @@ export default class MarkdownControl extends React.Component {
super(props); super(props);
editorControl = props.editorControl; editorControl = props.editorControl;
_getEditorComponents = props.getEditorComponents; _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 => { handleMode = mode => {
this.setState({ mode }); this.setState({ mode, pendingFocus: true });
localStorage.setItem(MODE_STORAGE_KEY, mode); localStorage.setItem(MODE_STORAGE_KEY, mode);
}; };
processRef = ref => (this.ref = ref); processRef = ref => (this.ref = ref);
setFocusReceived = () => {
this.setState({ pendingFocus: false });
};
render() { render() {
const { const {
onChange, onChange,
@ -51,9 +60,10 @@ export default class MarkdownControl extends React.Component {
classNameWrapper, classNameWrapper,
field, field,
getEditorComponents, getEditorComponents,
resolveWidget,
} = this.props; } = this.props;
const { mode } = this.state; const { mode, pendingFocus } = this.state;
const visualEditor = ( const visualEditor = (
<div className="cms-editor-visual" ref={this.processRef}> <div className="cms-editor-visual" ref={this.processRef}>
<VisualEditor <VisualEditor
@ -65,6 +75,8 @@ export default class MarkdownControl extends React.Component {
value={value} value={value}
field={field} field={field}
getEditorComponents={getEditorComponents} getEditorComponents={getEditorComponents}
resolveWidget={resolveWidget}
pendingFocus={pendingFocus && this.setFocusReceived}
/> />
</div> </div>
); );
@ -78,6 +90,7 @@ export default class MarkdownControl extends React.Component {
className={classNameWrapper} className={classNameWrapper}
value={value} value={value}
field={field} field={field}
pendingFocus={pendingFocus && this.setFocusReceived}
/> />
</div> </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 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. * 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 Bold = props => <strong>{props.children}</strong>;
const Italic = props => <em>{props.children}</em>; const Italic = props => <em>{props.children}</em>;
const Strikethrough = props => <s>{props.children}</s>; const Strikethrough = props => <s>{props.children}</s>;
const Code = props => <code>{props.children}</code>; const Code = props => <StyledCode>{props.children}</StyledCode>;
/** /**
* Node Components * Node Components
*/ */
const Paragraph = props => <p {...props.attributes}>{props.children}</p>; const Paragraph = props => <StyledP {...props.attributes}>{props.children}</StyledP>;
const ListItem = props => <li {...props.attributes}>{props.children}</li>; const ListItem = props => <StyledLi {...props.attributes}>{props.children}</StyledLi>;
const Quote = props => <blockquote {...props.attributes}>{props.children}</blockquote>; const Quote = props => <StyledBlockQuote {...props.attributes}>{props.children}</StyledBlockQuote>;
const CodeBlock = props => ( const CodeBlock = props => (
<pre> <StyledPre>
<code {...props.attributes}>{props.children}</code> <StyledCode {...props.attributes}>{props.children}</StyledCode>
</pre> </StyledPre>
); );
const HeadingOne = props => <h1 {...props.attributes}>{props.children}</h1>; const HeadingOne = props => <StyledH1 {...props.attributes}>{props.children}</StyledH1>;
const HeadingTwo = props => <h2 {...props.attributes}>{props.children}</h2>; const HeadingTwo = props => <StyledH2 {...props.attributes}>{props.children}</StyledH2>;
const HeadingThree = props => <h3 {...props.attributes}>{props.children}</h3>; const HeadingThree = props => <StyledH3 {...props.attributes}>{props.children}</StyledH3>;
const HeadingFour = props => <h4 {...props.attributes}>{props.children}</h4>; const HeadingFour = props => <StyledH4 {...props.attributes}>{props.children}</StyledH4>;
const HeadingFive = props => <h5 {...props.attributes}>{props.children}</h5>; const HeadingFive = props => <StyledH5 {...props.attributes}>{props.children}</StyledH5>;
const HeadingSix = props => <h6 {...props.attributes}>{props.children}</h6>; const HeadingSix = props => <StyledH6 {...props.attributes}>{props.children}</StyledH6>;
const Table = props => ( const Table = props => (
<table> <StyledTable>
<tbody {...props.attributes}>{props.children}</tbody> <tbody {...props.attributes}>{props.children}</tbody>
</table> </StyledTable>
); );
const TableRow = props => <tr {...props.attributes}>{props.children}</tr>; const TableRow = props => <tr {...props.attributes}>{props.children}</tr>;
const TableCell = props => <td {...props.attributes}>{props.children}</td>; const TableCell = props => <StyledTd {...props.attributes}>{props.children}</StyledTd>;
const ThematicBreak = props => <hr {...props.attributes} />; const ThematicBreak = props => (
const BulletedList = props => <ul {...props.attributes}>{props.children}</ul>; <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 => ( 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} {props.children}
</ol> </StyledOl>
); );
const Link = props => { const Link = props => {
const data = props.node.get('data'); const data = props.node.get('data');
const marks = data.get('marks');
const url = data.get('url'); const url = data.get('url');
const title = data.get('title'); const title = data.get('title');
const link = ( return (
<a href={url} title={title} {...props.attributes}> <StyledA href={url} title={title} {...props.attributes}>
{props.children} {props.children}
</a> </StyledA>
); );
const result = !marks
? link
: marks.reduce((acc, mark) => {
return renderMark({ mark, children: acc });
}, link);
return result;
}; };
const Image = props => { const Image = props => {
const data = props.node.get('data'); const data = props.node.get('data');
const marks = data.get('marks'); const marks = data.get('marks');
@ -80,7 +198,7 @@ const Image = props => {
return result; return result;
}; };
export const renderMark = props => { export const renderMark = () => props => {
switch (props.mark.type) { switch (props.mark.type) {
case 'bold': case 'bold':
return <Bold {...props} />; 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) { switch (props.node.type) {
case 'paragraph': case 'paragraph':
return <Paragraph {...props} />; return <Paragraph {...props} />;
@ -101,7 +230,19 @@ export const renderNode = props => {
return <ListItem {...props} />; return <ListItem {...props} />;
case 'quote': case 'quote':
return <Quote {...props} />; 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} />; return <CodeBlock {...props} />;
case 'heading-one': case 'heading-one':
return <HeadingOne {...props} />; return <HeadingOne {...props} />;
@ -122,16 +263,20 @@ export const renderNode = props => {
case 'table-cell': case 'table-cell':
return <TableCell {...props} />; return <TableCell {...props} />;
case 'thematic-break': case 'thematic-break':
return <ThematicBreak {...props} />; return (
<VoidBlock {...props}>
<ThematicBreak editor={props.editor} node={props.node} />
</VoidBlock>
);
case 'bulleted-list': case 'bulleted-list':
return <BulletedList {...props} />; return <BulletedList {...props} />;
case 'numbered-list': case 'numbered-list':
return <NumberedList {...props} />; return <NumberedList {...props} />;
case 'link':
return <Link {...props} />;
case 'image':
return <Image {...props} />;
case 'shortcode': 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 PropTypes from 'prop-types';
import { WidgetPreviewContainer } from 'netlify-cms-ui-default'; import { WidgetPreviewContainer } from 'netlify-cms-ui-default';
import { markdownToHtml } from './serializers'; 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) { if (value === null) {
return null; return null;
} }
const html = markdownToHtml(value, getAsset); const html = markdownToHtml(value, { getAsset, resolveWidget });
return <WidgetPreviewContainer dangerouslySetInnerHTML={{ __html: html }} />; return <WidgetPreviewContainer dangerouslySetInnerHTML={{ __html: html }} />;
}; };
MarkdownPreview.propTypes = { MarkdownPreview.propTypes = {
getAsset: PropTypes.func.isRequired, getAsset: PropTypes.func.isRequired,
editorPreview: PropTypes.func.isRequired,
resolveWidget: PropTypes.func.isRequired,
value: PropTypes.string, value: PropTypes.string,
}; };

View File

@ -55,7 +55,7 @@ exports[`Markdown Preview renderer Markdown rendering General should render mark
dangerouslySetInnerHTML={ dangerouslySetInnerHTML={
Object { Object {
"__html": "<h1>H1</h1> "__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> <h2>H2</h2>
<ul> <ul>
<li>ul item 1</li> <li>ul item 1</li>
@ -70,9 +70,9 @@ exports[`Markdown Preview renderer Markdown rendering General should render mark
<h4>H4</h4> <h4>H4</h4>
<p><a href=\\"http://google.com\\">link title</a></p> <p><a href=\\"http://google.com\\">link title</a></p>
<h5>H5</h5> <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> <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 renderer
.create(<MarkdownPreview value={markdownToHtml(value)} getAsset={jest.fn()} />) .create(<MarkdownPreview value={markdownToHtml(value)} getAsset={jest.fn()} />)
.toJSON(), .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', () => { describe('Links', () => {
it('should render links', () => { it('should render links', () => {
const value = ` 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" [Google]: http://google.com/ "Google"
[2]: http://search.yahoo.com/ "Yahoo Search" [Yahoo]: http://search.yahoo.com/ "Yahoo Search"
[3]: http://search.msn.com/ "MSN Search" [MSN]: http://search.msn.com/ "MSN Search"
`; `;
expect( expect(
renderer renderer

View File

@ -1,45 +1,124 @@
import { flow, trim } from 'lodash'; import { flow } from 'lodash';
import commonmarkSpec from './__fixtures__/commonmark.json'; import { tests as commonmarkSpec } from 'commonmark-spec';
import expected from './__fixtures__/commonmarkExpected.json'; import commonmark from 'commonmark';
import { markdownToSlate, slateToMarkdown, markdownToHtml } from '../index.js'; import { markdownToSlate, slateToMarkdown } from '../index.js';
/** const skips = [
* Map the commonmark spec data into an array of arrays for use in Jest's {
* `test.each`. number: [456],
*/ reason: 'Remark ¯\\_(ツ)_/¯',
const spec = commonmarkSpec.map(({ markdown, html }) => [markdown, html]); },
{
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 * 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 * 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. * 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 * 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 * tests, of which we're passing about 300 as of introduction of this suite. To
* work on improving Commonmark support, update __fixtures__/commonmarkExpected.json * 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', () => { Spec:
test.each(spec)('%s', (markdown, html) => { ${JSON.stringify(spec, null, 2)}
// 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);
switch (expected[markdown]) { Markdown input:
case 'TO_EQUAL': ${spec.markdown}
expect(process(markdown)).toEqual(trimmedHtml);
break; Markdown parsed through Slate/Remark and back to Markdown:
case 'NOT_TO_EQUAL': ${parsed}
expect(process(markdown)).not.toEqual(trimmedHtml);
break; HTML output:
case 'TO_ERROR': ${commonmarkParsedHtml}
expect(() => process(markdown)).toThrowError();
break; Expected HTML output:
default: ${spec.html}
throw new Error('Unknown expected type: ' + expected[markdown]); `;
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 { flow } from 'lodash';
import h from '../../../test-helpers/h';
import { markdownToSlate, slateToMarkdown } from '../index'; import { markdownToSlate, slateToMarkdown } from '../index';
const process = flow([markdownToSlate, slateToMarkdown]); 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](c)d**')).toEqual('**a[b](c)d**');
expect(process('**[a](b)**')).toEqual('**[a](b)**'); expect(process('**[a](b)**')).toEqual('**[a](b)**');
expect(process('**![a](b)**')).toEqual('**![a](b)**'); expect(process('**![a](b)**')).toEqual('**![a](b)**');
expect(process('_`a`_')).toEqual('_`a`_'); expect(process('_`a`_')).toEqual('*`a`*');
expect(process('_`a`b_')).toEqual('_`a`b_'); });
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', () => { it('should condense adjacent, identically styled text and inline nodes', () => {
@ -23,7 +34,8 @@ describe('slate', () => {
it('should handle nested markdown entities', () => { 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 _b_ c**')).toEqual('**a *b* c**');
expect(process('*`a`*')).toEqual('*`a`*');
}); });
it('should parse inline images as images', () => { it('should parse inline images as images', () => {
@ -34,57 +46,249 @@ describe('slate', () => {
expect(process('<span>*</span>')).toEqual('<span>*</span>'); 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', () => { it('should not produce invalid markdown when a styled block has trailing whitespace', () => {
const slateAst = { // prettier-ignore
object: 'block', const slateAst = (
type: 'root', <document>
nodes: [ <paragraph>
{ <b>foo </b>bar <b>bim </b><b><i>bam</i></b>
object: 'block', </paragraph>
type: 'paragraph', </document>
nodes: [ );
{ expect(slateToMarkdown(slateAst.toJSON())).toMatchInlineSnapshot(`"**foo** bar **bim *bam***"`);
object: 'text',
data: undefined,
leaves: [
{
text: 'foo ', // <--
marks: [{ type: 'bold' }],
},
],
},
{ object: 'text', data: undefined, leaves: [{ text: 'bar' }] },
],
},
],
};
expect(slateToMarkdown(slateAst)).toEqual('**foo** bar');
}); });
it('should not produce invalid markdown when a styled block has leading whitespace', () => { it('should not produce invalid markdown when a styled block has leading whitespace', () => {
const slateAst = { // prettier-ignore
object: 'block', const slateAst = (
type: 'root', <document>
nodes: [ <paragraph>
{ foo<b> bar</b>
object: 'block', </paragraph>
type: 'paragraph', </document>
nodes: [ );
{ object: 'text', data: undefined, leaves: [{ text: 'foo' }] }, expect(slateToMarkdown(slateAst.toJSON())).toMatchInlineSnapshot(`"foo **bar**"`);
{ });
object: 'text',
data: undefined, it('should group adjacent marks into a single mark when possible', () => {
leaves: [ // prettier-ignore
{ const slateAst = (
text: ' bar', // <-- <document>
marks: [{ type: 'bold' }], <paragraph>
}, <b>shared mark</b>
], <link url="link">
}, <b><i>link</i></b>
], </link>
}, {' '}
], <b>not shared mark</b>
}; <link url="link">
expect(slateToMarkdown(slateAst)).toEqual('foo **bar**'); <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', listItemIndent: '1',
/** /**
* Settings to emulate the defaults from the Prosemirror editor, not * Use asterisk for everything, it's the most versatile. Eventually using
* necessarily optimal. Should eventually be configurable. * other characters should be an option.
*/ */
bullet: '*', bullet: '*',
emphasis: '*',
strong: '*', strong: '*',
rule: '-', rule: '-',
}; };
@ -147,16 +148,21 @@ export const remarkToMarkdown = obj => {
/** /**
* Convert Markdown to HTML. * Convert Markdown to HTML.
*/ */
export const markdownToHtml = (markdown, getAsset) => { export const markdownToHtml = (markdown, { getAsset, resolveWidget } = {}) => {
const mdast = markdownToRemark(markdown); const mdast = markdownToRemark(markdown);
const hast = unified() const hast = unified()
.use(remarkToRehypeShortcodes, { plugins: getEditorComponents(), getAsset }) .use(remarkToRehypeShortcodes, { plugins: getEditorComponents(), getAsset, resolveWidget })
.use(remarkToRehype, { allowDangerousHTML: true }) .use(remarkToRehype, { allowDangerousHTML: true })
.runSync(mdast); .runSync(mdast);
const html = unified() const html = unified()
.use(rehypeToHtml, { allowDangerousHTML: true, allowDangerousCharacters: true }) .use(rehypeToHtml, {
allowDangerousHTML: true,
allowDangerousCharacters: true,
closeSelfClosing: true,
entities: { useNamedReferences: true },
})
.stringify(hast); .stringify(hast);
return html; return html;
@ -189,12 +195,12 @@ export const htmlToSlate = html => {
/** /**
* Convert Markdown to Slate's Raw AST. * Convert Markdown to Slate's Raw AST.
*/ */
export const markdownToSlate = markdown => { export const markdownToSlate = (markdown, { voidCodeBlock } = {}) => {
const mdast = markdownToRemark(markdown); const mdast = markdownToRemark(markdown);
const slateRaw = unified() const slateRaw = unified()
.use(remarkWrapHtml) .use(remarkWrapHtml)
.use(remarkToSlate) .use(remarkToSlate, { voidCodeBlock })
.runSync(mdast); .runSync(mdast);
return slateRaw; return slateRaw;
@ -209,8 +215,8 @@ export const markdownToSlate = markdown => {
* MDAST. The conversion is manual because Unified can only operate on Unist * MDAST. The conversion is manual because Unified can only operate on Unist
* trees. * trees.
*/ */
export const slateToMarkdown = raw => { export const slateToMarkdown = (raw, { voidCodeBlock } = {}) => {
const mdast = slateToRemark(raw, { shortcodePlugins: getEditorComponents() }); const mdast = slateToRemark(raw, { voidCodeBlock });
const markdown = remarkToMarkdown(mdast); const markdown = remarkToMarkdown(mdast);
return markdown; return markdown;
}; };

View File

@ -1,3 +1,4 @@
import React from 'react';
import { map, has } from 'lodash'; import { map, has } from 'lodash';
import { renderToString } from 'react-dom/server'; import { renderToString } from 'react-dom/server';
import u from 'unist-builder'; import u from 'unist-builder';
@ -8,7 +9,7 @@ import u from 'unist-builder';
* conversion by replacing the shortcode text with stringified HTML for * conversion by replacing the shortcode text with stringified HTML for
* previewing the shortcode output. * previewing the shortcode output.
*/ */
export default function remarkToRehypeShortcodes({ plugins, getAsset }) { export default function remarkToRehypeShortcodes({ plugins, getAsset, resolveWidget }) {
return transform; return transform;
function transform(root) { 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, * an HTML string or a React component. If a React component is returned,
* render it to an HTML string. * 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); const valueHtml = typeof value === 'string' ? value : renderToString(value);
/** /**
@ -47,4 +48,20 @@ export default function remarkToRehypeShortcodes({ plugins, getAsset }) {
const children = [textNode]; const children = [textNode];
return { ...node, children }; 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'; import { isEmpty, isArray, flatMap, map, flatten } 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);
}
/** /**
* Map of MDAST node types to Slate node types. * Map of MDAST node types to Slate node types.
@ -34,7 +7,7 @@ const typeMap = {
root: 'root', root: 'root',
paragraph: 'paragraph', paragraph: 'paragraph',
blockquote: 'quote', blockquote: 'quote',
code: 'code', code: 'code-block',
listItem: 'list-item', listItem: 'list-item',
table: 'table', table: 'table',
tableRow: 'table-row', 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) { export default function remarkToSlate({ voidCodeBlock } = {}) {
return nodes ? { ...parent, nodes } : parent; return transformNode;
}
/** function transformNode(node) {
* Create a Slate Inline node. /**
*/ * Call `transformNode` recursively on child nodes.
function createBlock(type, nodes, props = {}) { *
if (!isArray(nodes)) { * If a node returns a falsey value, filter it out. Some nodes do not
props = nodes; * translate from MDAST to Slate, such as definitions for link/image
nodes = undefined; * 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 * Add nodes to a parent node only if `nodes` is truthy.
* mark nodes, if any.
*/ */
const markType = markMap[node.type]; function addNodes(parent, nodes) {
const marks = markType ? [...parentMarks, { type: markMap[node.type] }] : parentMarks; 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) { switch (childNode.type) {
/** /**
* If a text node is a direct child of the current node, it should be * 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 * set aside as a text, and all marks that have been collected in the
* `marks` array should apply to that specific leaf. * `marks` array should apply to that specific text.
*/ */
case 'html': case 'html':
case 'text': case 'text':
return { text: childNode.value, marks }; return { ...convertNode(childNode), marks };
/** /**
* MDAST inline code nodes don't have children, just a text value, similar * 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. * first add the inline code mark to the marks array.
*/ */
case 'inlineCode': { case 'inlineCode': {
const childMarks = [...marks, { type: markMap['inlineCode'] }]; const childMarks = [...marks, { type: markMap[childNode.type] }];
return { text: childNode.value, marks: childMarks }; return { ...convertNode(childNode), marks: childMarks };
} }
/** /**
* Process nested style nodes. The recursive results should be pushed into * Process nested style nodes. The recursive results should be pushed into
* the leaves array. This way, every MDAST nested text structure becomes a * the texts 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 * flat array of texts that can serve as the value of a single Slate Raw
* text node. * text node.
*/ */
case 'strong': case 'strong':
@ -132,211 +127,236 @@ function processMarkNode(node, parentMarks = []) {
case 'delete': case 'delete':
return processMarkNode(childNode, marks); 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 * Remaining nodes simply need mark data added to them, and to then be
* added into the cumulative children array. * added into the cumulative children array.
*/ */
default: default:
return { ...childNode, data: { marks } }; return transformNode({ ...childNode, data: { ...childNode.data, marks } });
} }
}); }
return children; function processMarkNode(node, parentMarks = []) {
}
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) {
/** /**
* General * Add the current node's mark type to the marks collected from parent
* * mark nodes, if any.
* Convert simple cases that only require a type and children, with no
* additional properties.
*/ */
case 'root': const markType = markMap[node.type];
case 'paragraph': const marks = markType ? [...parentMarks, { type: markMap[node.type] }] : parentMarks;
case 'listItem':
case 'blockquote':
case 'tableRow':
case 'tableCell': {
return createBlock(typeMap[node.type], nodes);
}
/** const children = flatMap(node.children, child => processMarkChild(child, marks));
* 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 });
}
/** return children;
* 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);
}
/** /**
* Inline Code * Convert a single MDAST node to a Slate Raw node. Uses local node factories
* * that mimic the unist-builder function utilized in the slateRemark
* Inline code nodes from an MDAST are represented in our Slate schema as * transformer.
* 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 function convertNode(node, nodes) {
* as a Slate text node's children array. switch (node.type) {
*/ /**
case 'inlineCode': { * General
const leaf = { *
text: node.value, * Convert simple cases that only require a type and children, with no
marks: [{ type: 'code' }], * additional properties.
}; */
return createText([leaf]); case 'root':
} case 'paragraph':
case 'blockquote':
case 'tableRow':
case 'tableCell': {
return createBlock(typeMap[node.type], nodes);
}
/** /**
* Marks * List Items
* *
* Marks are typically decorative sub-types that apply to text nodes. In an * Markdown list items can be empty, but a list item in the Slate schema
* MDAST, marks are nodes that can contain other nodes. This nested * should at least have an empty paragraph node.
* hierarchy has to be flattened and split into distinct text nodes with */
* their own set of marks. case 'listItem': {
*/ const children = isEmpty(nodes) ? [createBlock('paragraph')] : nodes;
case 'strong': return createBlock(typeMap[node.type], children);
case 'emphasis': }
case 'delete': {
return convertMarkNode(node);
}
/** /**
* Headings * Shortcodes
* *
* MDAST headings use a single type with a separate "depth" property to * Shortcode nodes are represented as "void" blocks in the Slate AST. They
* indicate the heading level, while the Slate schema uses a separate node * maintain the same data as MDAST shortcode nodes. Slate void blocks must
* type for each heading level. Here we get the proper Slate node name based * contain a blank text node.
* on the MDAST node depth. */
*/ case 'shortcode': {
case 'heading': { const nodes = [createText('')];
const depthMap = { 1: 'one', 2: 'two', 3: 'three', 4: 'four', 5: 'five', 6: 'six' }; const data = { ...node.data };
const slateType = `heading-${depthMap[node.depth]}`; return createBlock(typeMap[node.type], nodes, { data });
return createBlock(slateType, nodes); }
}
/** /**
* Code Blocks * Text
* *
* MDAST code blocks are a distinct node type with a simple text value. We * Text nodes contain plain text. We remove newlines because they don't
* convert that value into a nested child text node for Slate. We also carry * carry meaning for a rich text editor - a break in rich text would be
* over the "lang" data property if it's defined. * expected to result in a break in output HTML, but that isn't the case.
*/ * To avoid this confusion we remove them.
case 'code': { */
const data = { lang: node.lang }; case 'text': {
const text = createText(node.value); const text = node.value.replace(/\n/, ' ');
const nodes = [text]; return createText(text);
return createBlock(typeMap[node.type], nodes, { data }); }
}
/** /**
* Lists * HTML
* *
* MDAST has a single list type and an "ordered" property. We derive that * HTML nodes contain plain text like text nodes, except they only contain
* information into the Slate schema's distinct list node types. We also * HTML. Our serialization results in non-HTML being placed in HTML nodes
* include the "start" property, which indicates the number an ordered list * sometimes to ensure that we're never escaping HTML from the rich text
* starts at, if defined. * 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
case 'list': { * should expect soft breaks to be visually absent in the rendered HTML.
const slateType = node.ordered ? 'numbered-list' : 'bulleted-list'; */
const data = { start: node.start }; case 'html': {
return createBlock(slateType, nodes, { data }); return createText(node.value);
} }
/** /**
* Breaks * Inline Code
* *
* MDAST soft break nodes represent a trailing double space or trailing * Inline code nodes from an MDAST are represented in our Slate schema as
* slash from a Markdown document. In Slate, these are simply transformed to * text nodes with a "code" mark. We manually create the text containing
* line breaks within a text node. * 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 'break': { */
const textNode = createText('\n'); case 'inlineCode': {
return createInline('break', {}, [textNode]); return createText({ text: node.value, marks: [{ type: 'code' }] });
} }
/** /**
* Thematic Breaks * Marks
* *
* Thematic breaks are void nodes in the Slate schema. * Marks are typically decorative sub-types that apply to text nodes. In an
*/ * MDAST, marks are nodes that can contain other nodes. This nested
case 'thematicBreak': { * hierarchy has to be flattened and split into distinct text nodes with
return createBlock(typeMap[node.type], { isVoid: true }); * their own set of marks.
} */
case 'strong':
case 'emphasis':
case 'delete': {
return processMarkNode(node);
}
/** /**
* Links * Headings
* *
* MDAST stores the link attributes directly on the node, while our Slate * MDAST headings use a single type with a separate "depth" property to
* schema references them in the data object. * 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
case 'link': { * on the MDAST node depth.
const { title, url, data } = node; */
const newData = { ...data, title, url }; case 'heading': {
return createInline(typeMap[node.type], { data: newData }, nodes); 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 * Code Blocks
* *
* Identical to link nodes except for the lack of child nodes and addition * MDAST code blocks are a distinct node type with a simple text value. We
* of alt attribute data MDAST stores the link attributes directly on the * convert that value into a nested child text node for Slate. If a void
* node, while our Slate schema references them in the data object. * 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"
case 'image': { * data property if it's defined.
const { title, url, alt, data } = node; */
const newData = { ...data, title, alt, url }; case 'code': {
return createInline(typeMap[node.type], { isVoid: true, data: newData }); 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 * Lists
* *
* Tables are parsed separately because they may include an "align" * MDAST has a single list type and an "ordered" property. We derive that
* property, which should be passed to the Slate node. * information into the Slate schema's distinct list node types. We also
*/ * include the "start" property, which indicates the number an ordered list
case 'table': { * starts at, if defined.
const data = { align: node.align }; */
return createBlock(typeMap[node.type], nodes, { data }); 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 u from 'unist-builder';
import mdastToString from 'mdast-util-to-string';
/** /**
* Map of Slate node types to MDAST/Remark node types. * Map of Slate node types to MDAST/Remark node types.
@ -14,7 +15,7 @@ const typeMap = {
'heading-five': 'heading', 'heading-five': 'heading',
'heading-six': 'heading', 'heading-six': 'heading',
quote: 'blockquote', quote: 'blockquote',
code: 'code', 'code-block': 'code',
'numbered-list': 'list', 'numbered-list': 'list',
'bulleted-list': 'list', 'bulleted-list': 'list',
'list-item': 'listItem', 'list-item': 'listItem',
@ -38,7 +39,10 @@ const markMap = {
code: 'inlineCode', 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 * The Slate Raw AST generally won't have a top level type, so we set it to
* "root" for clarity. * "root" for clarity.
@ -46,465 +50,352 @@ export default function slateToRemark(raw) {
raw.type = 'root'; raw.type = 'root';
return transform(raw); 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 * The transform function mimics the approach of a Remark plugin for
* array. * 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); function transform(node) {
/**
* 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 || {};
/** /**
* If the previous node has leaves and the current node has marks in data * Combine adjacent text and inline nodes before processing so they can
* (only happens when we place them on inline nodes here in the parser), or * share marks.
* 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.
*/ */
if (!isEmpty(prevNodeLeaves) && !isEmpty(data.marks)) { const hasBlockChildren = node.nodes && node.nodes[0] && node.nodes[0].object === 'block';
prevNodeLeaves.push({ node, marks: data.marks }); const children = hasBlockChildren
return acc; ? node.nodes.map(transform).filter(v => v)
} : convertInlineAndTextChildren(node.nodes);
if (!isEmpty(prevNodeLeaves) && !isEmpty(node.leaves)) { const output = convertBlockNode(node, children);
prevNode.leaves = prevNodeLeaves.concat(node.leaves); //console.log(JSON.stringify(output, null, 2));
return acc; return output;
} }
/** function removeMarkFromNodes(nodes, markType) {
* Break nodes contain a single child text node with a newline character return nodes.map(node => {
* for visual purposes in the editor, but Remark break nodes have no switch (node.type) {
* children, so we remove the child node here. case 'link': {
*/ const updatedNodes = removeMarkFromNodes(node.nodes, markType);
if (node.type === 'break') { return {
acc.push({ object: 'inline', type: 'break' }); ...node,
return acc; nodes: updatedNodes,
} };
/**
* 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+/, '');
} }
// 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: case 'image':
if ( case 'break': {
trailingWhitespace.length > 0 && const data = omit(node.data, 'marks');
(i === processedLeaves.length - 1 || return { ...node, data };
!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+$/, '');
} }
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') { const markType = firstGroupMarks[0];
return transform(node); const childNodes = nodes.slice(0, splitIndex);
} const updatedChildNodes = markType ? removeMarkFromNodes(childNodes, markType) : childNodes;
const remainingNodes = nodes.slice(splitIndex);
return u('html', node.text); return [markType, updatedChildNodes, remainingNodes];
}
/**
* 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;
} }
/** /**
* Processing for nodes with marks. * Converts the strings returned from `splitToNamedParts` to Slate nodes.
*/ */
if (node.marks && node.marks.length > 0) { function splitWhitespace(node, { trailing } = {}) {
/** if (!node.text) {
* For each mark on the current node, get the number of consecutive nodes return { trimmedNode: node };
* (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 const exp = trailing ? trailingWhitespaceExp : leadingWhitespaceExp;
* processed as children. If the greatest number of consecutive nodes is const index = node.text.search(exp);
* tied between multiple marks, there is no priority as to which goes if (index > -1) {
* first. const substringIndex = trailing ? index : index + 1;
*/ const firstSplit = node.text.substring(0, substringIndex);
const markLengths = node.marks.map(mark => getMarkLength(mark, nodes.slice(idx))); const secondSplit = node.text.substring(substringIndex);
const parentMarkLength = last(sortBy(markLengths, 'length')); const whitespace = trailing ? secondSplit : firstSplit;
const { markType: parentType, length: parentLength } = parentMarkLength; const text = trailing ? firstSplit : secondSplit;
return { whitespace, trimmedNode: { ...node, text } };
/** }
* Since this and any consecutive nodes with the parent mark are going to return { trimmedNode: node };
* 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 collectCenterNodes(nodes, leadingNode, trailingNode) {
* Create the base text node, and pass in the array of mark types as data switch (nodes.length) {
* (helpful when optimizing/condensing the final structure). case 0:
*/ return [];
const baseNode = case 1:
typeof node.text === 'string' return [trailingNode];
? u(node.textNodeType, { marks: node.marks }, node.text) case 2:
: transform(node.node); return [leadingNode, trailingNode];
default:
/** return [leadingNode, ...nodes.slice(1, -1), trailingNode];
* 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;
} }
return { markType, length };
}
/** function normalizeFlankingWhitespace(nodes) {
* Convert a single Slate Raw node to an MDAST node. Uses the unist-builder `u` const { whitespace: leadingWhitespace, trimmedNode: leadingNode } = splitWhitespace(nodes[0]);
* function to create MDAST nodes. const lastNode = nodes.length > 1 ? last(nodes) : leadingNode;
*/ const trailingSplitResult = splitWhitespace(lastNode, { trailing: true });
function convertNode(node, children) { const { whitespace: trailingWhitespace, trimmedNode: trailingNode } = trailingSplitResult;
switch (node.type) { const centerNodes = collectCenterNodes(nodes, leadingNode, trailingNode).filter(val => val);
/** return { leadingWhitespace, centerNodes, trailingWhitespace };
* General }
*
* Convert simple cases that only require a type and children, with no function convertInlineAndTextChildren(nodes = []) {
* additional properties. const convertedNodes = [];
*/ let remainingNodes = nodes;
case 'root':
case 'paragraph': while (remainingNodes.length > 0) {
case 'quote': const nextNode = remainingNodes[0];
case 'list-item': if (nextNode.object === 'inline' || (nextNode.marks && nextNode.marks.length > 0)) {
case 'table': const [markType, markNodes, remainder] = extractFirstMark(remainingNodes);
case 'table-row': /**
case 'table-cell': { * A node with a code mark will be a text node, and will not be adjacent
return u(typeMap[node.type], children); * 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));
}
} }
/** return convertedNodes;
* 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 });
}
/** function convertBlockNode(node, children) {
* Headings switch (node.type) {
* /**
* Slate schemas don't usually infer basic type info from data, so each * General
* level of heading is a separately named type. The MDAST schema just *
* has a single "heading" type with the depth stored in a "depth" * Convert simple cases that only require a type and children, with no
* property on the node. Here we derive the depth from the Slate node * additional properties.
* type - e.g., for "heading-two", we need a depth value of "2". */
*/ case 'root':
case 'heading-one': case 'paragraph':
case 'heading-two': case 'quote':
case 'heading-three': case 'list-item':
case 'heading-four': case 'table':
case 'heading-five': case 'table-row':
case 'heading-six': { case 'table-cell': {
const depthMap = { one: 1, two: 2, three: 3, four: 4, five: 5, six: 6 }; return u(typeMap[node.type], children);
const depthText = node.type.split('-')[1]; }
const depth = depthMap[depthText];
return u(typeMap[node.type], { depth }, children);
}
/** /**
* Code Blocks * Shortcodes
* *
* Code block nodes have a single text child, and may have a code language * Shortcode nodes only exist in Slate's Raw AST if they were inserted
* stored in the "lang" data property. Here we transfer both the node * via the plugin toolbar in memory, so they should always have
* value and the "lang" data property to the new MDAST node. * shortcode data attached. The "shortcode" data property contains the
*/ * name of the registered shortcode plugin, and the "shortcodeData" data
case 'code': { * property contains the data received from the shortcode plugin's
const value = flatMap(node.nodes, child => { * `fromBlock` method when the shortcode node was created.
return flatMap(child.leaves, 'text'); *
}).join(''); * Here we create a `shortcode` MDAST node that contains only the shortcode
const { lang, ...data } = get(node, 'data', {}); * data.
return u(typeMap[node.type], { lang, data }, value); */
} case 'shortcode': {
const { data } = node;
return u(typeMap[node.type], { data });
}
/** /**
* Lists * Headings
* *
* Our Slate schema has separate node types for ordered and unordered * Slate schemas don't usually infer basic type info from data, so each
* lists, but the MDAST spec uses a single type with a boolean "ordered" * level of heading is a separately named type. The MDAST schema just
* property to indicate whether the list is numbered. The MDAST spec also * has a single "heading" type with the depth stored in a "depth"
* allows for a "start" property to indicate the first number used for an * property on the node. Here we derive the depth from the Slate node
* ordered list. Here we translate both values to our Slate schema. * type - e.g., for "heading-two", we need a depth value of "2".
*/ */
case 'numbered-list': case 'heading-one':
case 'bulleted-list': { case 'heading-two':
const ordered = node.type === 'numbered-list'; case 'heading-three':
const props = { ordered, start: get(node.data, 'start') || 1 }; case 'heading-four':
return u(typeMap[node.type], props, children); 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 * Code Blocks
* *
* Breaks don't have children. We parse them separately for clarity. * 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
case 'break': * stored in the "lang" data property. Here we transfer both the node value
case 'thematic-break': { * and the "lang" data property to the new MDAST node, and spread any
return u(typeMap[node.type]); * 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 * Lists
* *
* The url and title attributes of link nodes are stored in properties on * Our Slate schema has separate node types for ordered and unordered
* the node for both Slate and Remark schemas. * 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
case 'link': { * allows for a "start" property to indicate the first number used for an
const { url, title, ...data } = get(node, 'data', {}); * ordered list. Here we translate both values to our Slate schema.
return u(typeMap[node.type], { url, title, data }, children); */
} 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 * Thematic Break
* *
* This transformation is almost identical to that of links, except for the * Thematic break is a block level break. They cannot have children.
* lack of child nodes and addition of `alt` attribute data. */
*/ case 'thematic-break': {
case 'image': { return u(typeMap[node.type]);
const { url, title, alt, ...data } = get(node, 'data', {}); }
return u(typeMap[node.type], { url, title, alt, data });
} }
}
/** function convertInlineNode(node, children) {
* No default case is supplied because an unhandled case should never switch (node.type) {
* occur. In the event that it does, let the error throw (for now). /**
*/ * 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` export const EditorControlBar = styled.div`
z-index: 1; z-index: 200;
position: sticky; position: sticky;
top: 0; top: 0;
margin-bottom: ${editorStyleVars.stickyDistanceBottom}; 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; border-top-right-radius: 0;
`, `,
objectWidgetTopBarContainer: ` 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