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:
parent
be46293f82
commit
18c579d0e9
32
.eslintrc
32
.eslintrc
@ -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
38
.eslintrc.js
Normal 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',
|
||||
},
|
||||
},
|
||||
};
|
2
.github/workflows/nodejs.yml
vendored
2
.github/workflows/nodejs.yml
vendored
@ -16,7 +16,7 @@ jobs:
|
||||
|
||||
strategy:
|
||||
matrix:
|
||||
node-version: [8.x, 10.x]
|
||||
node-version: [10.x, 12.x]
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v1
|
||||
|
13
.stylelintrc
13
.stylelintrc
@ -1,21 +1,12 @@
|
||||
{
|
||||
"processors": [
|
||||
["stylelint-processor-styled-components", {
|
||||
"parserPlugins": [
|
||||
"jsx",
|
||||
"objectRestSpread",
|
||||
"exportDefaultFrom",
|
||||
"classProperties",
|
||||
],
|
||||
}],
|
||||
],
|
||||
"extends": [
|
||||
"stylelint-config-recommended",
|
||||
"stylelint-config-styled-components",
|
||||
],
|
||||
"rules": {
|
||||
"block-no-empty": null,
|
||||
"no-duplicate-selectors": null,
|
||||
"no-empty-source": null,
|
||||
"no-extra-semicolons": null,
|
||||
"selector-type-no-unknown": [true, {
|
||||
"ignoreTypes": ["$dummyValue"],
|
||||
}],
|
||||
|
@ -19,6 +19,10 @@ const defaultPlugins = [
|
||||
'@babel/plugin-proposal-class-properties',
|
||||
'@babel/plugin-proposal-object-rest-spread',
|
||||
'@babel/plugin-proposal-export-default-from',
|
||||
'@babel/plugin-proposal-nullish-coalescing-operator',
|
||||
'@babel/plugin-proposal-optional-chaining',
|
||||
'@babel/plugin-syntax-dynamic-import',
|
||||
'babel-plugin-inline-json-import',
|
||||
[
|
||||
'module-resolver',
|
||||
isESM
|
||||
@ -69,20 +73,22 @@ const defaultPlugins = [
|
||||
];
|
||||
|
||||
const presets = () => {
|
||||
return ['@babel/preset-react', '@babel/preset-env'];
|
||||
return [
|
||||
'@babel/preset-react',
|
||||
'@babel/preset-env',
|
||||
[
|
||||
'@emotion/babel-preset-css-prop',
|
||||
{
|
||||
autoLabel: true,
|
||||
},
|
||||
],
|
||||
];
|
||||
};
|
||||
|
||||
const plugins = () => {
|
||||
if (isESM) {
|
||||
return [
|
||||
...defaultPlugins,
|
||||
[
|
||||
'emotion',
|
||||
{
|
||||
sourceMap: true,
|
||||
autoLabel: true,
|
||||
},
|
||||
],
|
||||
[
|
||||
'transform-define',
|
||||
{
|
||||
@ -104,13 +110,6 @@ const plugins = () => {
|
||||
if (isTest) {
|
||||
return [
|
||||
...defaultPlugins,
|
||||
[
|
||||
'emotion',
|
||||
{
|
||||
sourceMap: false,
|
||||
autoLabel: false,
|
||||
},
|
||||
],
|
||||
[
|
||||
'inline-react-svg',
|
||||
{
|
||||
@ -123,29 +122,10 @@ const plugins = () => {
|
||||
}
|
||||
|
||||
if (!isProduction) {
|
||||
return [
|
||||
...defaultPlugins,
|
||||
[
|
||||
'emotion',
|
||||
{
|
||||
sourceMap: true,
|
||||
autoLabel: true,
|
||||
},
|
||||
],
|
||||
'react-hot-loader/babel',
|
||||
];
|
||||
return [...defaultPlugins, 'react-hot-loader/babel'];
|
||||
}
|
||||
|
||||
return [
|
||||
...defaultPlugins,
|
||||
[
|
||||
'emotion',
|
||||
{
|
||||
sourceMap: true,
|
||||
autoLabel: true,
|
||||
},
|
||||
],
|
||||
];
|
||||
return defaultPlugins;
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
|
82
cypress/integration/markdown_widget_backspace_spec.js
Normal file
82
cypress/integration/markdown_widget_backspace_spec.js
Normal 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>
|
||||
`);
|
||||
});
|
||||
});
|
||||
});
|
120
cypress/integration/markdown_widget_code_block_spec.js
Normal file
120
cypress/integration/markdown_widget_code_block_spec.js
Normal 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>
|
||||
`;
|
||||
}
|
108
cypress/integration/markdown_widget_enter_spec.js
Normal file
108
cypress/integration/markdown_widget_enter_spec.js
Normal 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>
|
||||
`);
|
||||
});
|
||||
});
|
||||
});
|
681
cypress/integration/markdown_widget_list_spec.js
Normal file
681
cypress/integration/markdown_widget_list_spec.js
Normal 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>
|
||||
`)
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
36
cypress/integration/markdown_widget_marks_spec.js
Normal file
36
cypress/integration/markdown_widget_marks_spec.js
Normal 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>
|
||||
`);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
307
cypress/integration/markdown_widget_quote_spec.js
Normal file
307
cypress/integration/markdown_widget_quote_spec.js
Normal 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>
|
||||
`)
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
@ -23,8 +23,11 @@
|
||||
//
|
||||
// -- This is will overwrite an existing command --
|
||||
// Cypress.Commands.overwrite("visit", (originalFn, url, options) => { ... })
|
||||
const { escapeRegExp } = require('../utils/regexp');
|
||||
const path = require('path');
|
||||
import path from 'path';
|
||||
import rehype from 'rehype';
|
||||
import visit from 'unist-util-visit';
|
||||
import { oneLineTrim } from 'common-tags';
|
||||
import { escapeRegExp } from '../utils/regexp';
|
||||
|
||||
const matchRoute = (route, fetchArgs) => {
|
||||
const url = fetchArgs[0];
|
||||
@ -86,3 +89,241 @@ Cypress.Commands.add('stubFetch', ({ fixture }) => {
|
||||
cy.on('window:before:load', win => stubFetch(win, routes));
|
||||
});
|
||||
});
|
||||
|
||||
function runTimes(cyInstance, fn, count = 1) {
|
||||
let chain = cyInstance, i = count;
|
||||
while (i) {
|
||||
i -= 1;
|
||||
chain = fn(chain);
|
||||
}
|
||||
return chain;
|
||||
}
|
||||
|
||||
[
|
||||
'enter',
|
||||
'backspace',
|
||||
['selectAll', 'selectall'],
|
||||
['up', 'upArrow'],
|
||||
['down', 'downArrow'],
|
||||
['left', 'leftArrow'],
|
||||
['right', 'rightArrow'],
|
||||
].forEach(key => {
|
||||
const [ cmd, keyName ] = typeof key === 'object' ? key : [key, key];
|
||||
Cypress.Commands.add(cmd, { prevSubject: true }, (subject, { shift, times = 1 } = {}) => {
|
||||
const fn = chain => chain.type(`${shift ? '{shift}' : ''}{${keyName}}`);
|
||||
return runTimes(cy.wrap(subject), fn, times);
|
||||
});
|
||||
});
|
||||
|
||||
// Convert `tab` command from plugin to a child command with `times` support
|
||||
Cypress.Commands.add('tabkey', { prevSubject: true }, (subject, { shift, times } = {}) => {
|
||||
const fn = chain => chain.tab({ shift });
|
||||
return runTimes(cy, fn, times).wrap(subject);
|
||||
});
|
||||
|
||||
Cypress.Commands.add('selection', { prevSubject: true }, (subject, fn) => {
|
||||
cy.wrap(subject)
|
||||
.trigger('mousedown')
|
||||
.then(fn)
|
||||
.trigger('mouseup')
|
||||
|
||||
cy.document().trigger('selectionchange');
|
||||
return cy.wrap(subject);
|
||||
});
|
||||
|
||||
Cypress.Commands.add('print', { prevSubject: 'optional' }, (subject, str) => {
|
||||
cy.log(str);
|
||||
console.log(`cy.log: ${str}`);
|
||||
return cy.wrap(subject);
|
||||
});
|
||||
|
||||
Cypress.Commands.add('setSelection', { prevSubject: true }, (subject, query, endQuery) => {
|
||||
return cy.wrap(subject)
|
||||
.selection($el => {
|
||||
if (typeof query === 'string') {
|
||||
const anchorNode = getTextNode($el[0], query);
|
||||
const focusNode = endQuery ? getTextNode($el[0], endQuery) : anchorNode;
|
||||
const anchorOffset = anchorNode.wholeText.indexOf(query);
|
||||
const focusOffset = endQuery ?
|
||||
focusNode.wholeText.indexOf(endQuery) + endQuery.length :
|
||||
anchorOffset + query.length;
|
||||
setBaseAndExtent(anchorNode, anchorOffset, focusNode, focusOffset);
|
||||
} else if (typeof query === 'object') {
|
||||
const el = $el[0];
|
||||
const anchorNode = getTextNode(el.querySelector(query.anchorQuery));
|
||||
const anchorOffset = query.anchorOffset || 0;
|
||||
const focusNode = query.focusQuery ? getTextNode(el.querySelector(query.focusQuery)) : anchorNode;
|
||||
const focusOffset = query.focusOffset || 0;
|
||||
setBaseAndExtent(anchorNode, anchorOffset, focusNode, focusOffset);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
Cypress.Commands.add('setCursor', { prevSubject: true }, (subject, query, atStart) => {
|
||||
return cy.wrap(subject)
|
||||
.selection($el => {
|
||||
const node = getTextNode($el[0], query);
|
||||
const offset = node.wholeText.indexOf(query) + (atStart ? 0 : query.length);
|
||||
const document = node.ownerDocument;
|
||||
document.getSelection().removeAllRanges();
|
||||
document.getSelection().collapse(node, offset);
|
||||
});
|
||||
});
|
||||
|
||||
Cypress.Commands.add('setCursorBefore', { prevSubject: true }, (subject, query) => {
|
||||
cy.wrap(subject).setCursor(query, true);
|
||||
});
|
||||
|
||||
Cypress.Commands.add('setCursorAfter', { prevSubject: true }, (subject, query) => {
|
||||
cy.wrap(subject).setCursor(query);
|
||||
});
|
||||
|
||||
Cypress.Commands.add('login', () => {
|
||||
cy.viewport(1200, 1200);
|
||||
cy.visit('/');
|
||||
cy.contains('button', 'Login').click();
|
||||
});
|
||||
|
||||
Cypress.Commands.add('loginAndNewPost', () => {
|
||||
cy.login();
|
||||
cy.contains('a', 'New Post').click();
|
||||
});
|
||||
|
||||
Cypress.Commands.add('drag', { prevSubject: true }, subject => {
|
||||
return cy.wrap(subject)
|
||||
.trigger('dragstart', {
|
||||
dataTransfer: {},
|
||||
force: true,
|
||||
});
|
||||
});
|
||||
|
||||
Cypress.Commands.add('drop', { prevSubject: true }, subject => {
|
||||
return cy.wrap(subject)
|
||||
.trigger('drop', {
|
||||
dataTransfer: {},
|
||||
force: true,
|
||||
});
|
||||
});
|
||||
|
||||
Cypress.Commands.add('clickToolbarButton', (title, { times } = {}) => {
|
||||
const isHeading = title.startsWith('Heading')
|
||||
if (isHeading) {
|
||||
cy.get('button[title="Headings"]').click();
|
||||
}
|
||||
const instance = isHeading ? cy.contains('div', title) : cy.get(`button[title="${title}"]`);
|
||||
const fn = chain => chain.click();
|
||||
return runTimes(instance, fn, times).focused();
|
||||
});
|
||||
|
||||
Cypress.Commands.add('insertEditorComponent', title => {
|
||||
cy.get('button[title="Add Component"]').click()
|
||||
cy.contains('div', title).click().focused();
|
||||
});
|
||||
|
||||
|
||||
[
|
||||
['clickHeadingOneButton', 'Heading 1'],
|
||||
['clickHeadingTwoButton', 'Heading 2'],
|
||||
['clickOrderedListButton', 'Numbered List'],
|
||||
['clickUnorderedListButton', 'Bulleted List'],
|
||||
['clickCodeButton', 'Code'],
|
||||
['clickItalicButton', 'Italic'],
|
||||
['clickQuoteButton', 'Quote'],
|
||||
].forEach(([commandName, toolbarButtonName]) => {
|
||||
Cypress.Commands.add(commandName, opts => {
|
||||
return cy.clickToolbarButton(toolbarButtonName, opts);
|
||||
});
|
||||
});
|
||||
|
||||
Cypress.Commands.add('clickModeToggle', () => {
|
||||
cy.get('button[role="switch"]')
|
||||
.click()
|
||||
.focused();
|
||||
});
|
||||
|
||||
[
|
||||
['insertCodeBlock', 'Code Block'],
|
||||
].forEach(([commandName, componentTitle]) => {
|
||||
Cypress.Commands.add(commandName, () => {
|
||||
return cy.insertEditorComponent(componentTitle);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
Cypress.Commands.add('getMarkdownEditor', () => {
|
||||
return cy.get('[data-slate-editor]');
|
||||
});
|
||||
|
||||
Cypress.Commands.add('confirmMarkdownEditorContent', expectedDomString => {
|
||||
return cy.getMarkdownEditor()
|
||||
.should(([element]) => {
|
||||
// Slate makes the following representations:
|
||||
// - blank line: 2 BOM's + <br>
|
||||
// - blank element (placed inside empty elements): 1 BOM + <br>
|
||||
// We replace to represent a blank line as a single <br>, and remove the
|
||||
// contents of elements that are actually empty.
|
||||
const actualDomString = toPlainTree(element.innerHTML)
|
||||
.replace(/\uFEFF\uFEFF<br>/g, '<br>')
|
||||
.replace(/\uFEFF<br>/g, '');
|
||||
expect(actualDomString).toEqual(oneLineTrim(expectedDomString));
|
||||
});
|
||||
});
|
||||
|
||||
Cypress.Commands.add('clearMarkdownEditorContent', () => {
|
||||
return cy.getMarkdownEditor()
|
||||
.selectAll()
|
||||
.backspace({ times: 2 });
|
||||
});
|
||||
|
||||
function toPlainTree(domString) {
|
||||
return rehype()
|
||||
.use(removeSlateArtifacts)
|
||||
.data('settings', { fragment: true })
|
||||
.processSync(domString)
|
||||
.contents;
|
||||
}
|
||||
|
||||
function getActualBlockChildren(node) {
|
||||
if (node.tagName === 'span') {
|
||||
return node.children.flatMap(getActualBlockChildren);
|
||||
}
|
||||
if (node.children) {
|
||||
return { ...node, children: node.children.flatMap(getActualBlockChildren) };
|
||||
}
|
||||
return node;
|
||||
}
|
||||
|
||||
function removeSlateArtifacts() {
|
||||
return function transform(tree) {
|
||||
visit(tree, 'element', node => {
|
||||
// remove all element attributes
|
||||
delete node.properties;
|
||||
|
||||
// remove slate padding spans to simplify test cases
|
||||
if (['h1', 'p'].includes(node.tagName)) {
|
||||
node.children = node.children.flatMap(getActualBlockChildren);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function getTextNode(el, match){
|
||||
const walk = document.createTreeWalker(el, NodeFilter.SHOW_TEXT, null, false);
|
||||
if (!match) {
|
||||
return walk.nextNode();
|
||||
}
|
||||
|
||||
const nodes = [];
|
||||
let node;
|
||||
while(node = walk.nextNode()) {
|
||||
if (node.wholeText.includes(match)) {
|
||||
return node;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function setBaseAndExtent(...args) {
|
||||
const document = args[0].ownerDocument;
|
||||
document.getSelection().removeAllRanges();
|
||||
document.getSelection().setBaseAndExtent(...args);
|
||||
}
|
||||
|
@ -12,9 +12,11 @@
|
||||
// You can read more here:
|
||||
// https://on.cypress.io/configuration
|
||||
// ***********************************************************
|
||||
require('cypress-plugin-tab');
|
||||
|
||||
// Import commands.js using ES2015 syntax:
|
||||
import './commands'
|
||||
import './commands';
|
||||
|
||||
// Alternatively you can use CommonJS syntax:
|
||||
// require('./commands')
|
||||
import 'cypress-jest-adapter';
|
||||
|
@ -1,7 +1,7 @@
|
||||
const workflowStatus = { draft: 'Drafts', review: 'In Review', ready: 'Ready' };
|
||||
const editorStatus = { draft: 'Draft', review: 'In review', ready: 'Ready' };
|
||||
const setting1 = { limit: 10, author: 'John Doe' };
|
||||
const setting2 = { name: 'Andrew Wommack', description: 'A Gospel Teacher' };
|
||||
const setting2 = { name: 'Jane Doe', description: 'description' };
|
||||
const publishTypes = { publishNow: 'Publish now' };
|
||||
const notifications = {
|
||||
saved: 'Entry saved',
|
||||
|
@ -51,16 +51,10 @@ function updateWorkflowStatus({ title }, fromColumnHeading, toColumnHeading) {
|
||||
cy.contains('h2', fromColumnHeading)
|
||||
.parent()
|
||||
.contains('a', title)
|
||||
.trigger('dragstart', {
|
||||
dataTransfer: {},
|
||||
force: true,
|
||||
});
|
||||
.drag();
|
||||
cy.contains('h2', toColumnHeading)
|
||||
.parent()
|
||||
.trigger('drop', {
|
||||
dataTransfer: {},
|
||||
force: true,
|
||||
});
|
||||
.drop();
|
||||
assertNotification(notifications.updated);
|
||||
}
|
||||
|
||||
@ -171,7 +165,7 @@ function populateEntry(entry) {
|
||||
for (let key of keys) {
|
||||
const value = entry[key];
|
||||
if (key === 'body') {
|
||||
cy.get('[data-slate-editor]')
|
||||
cy.getMarkdownEditor()
|
||||
.click()
|
||||
.clear()
|
||||
.type(value);
|
||||
@ -288,7 +282,7 @@ function validateListFields({ name, description }) {
|
||||
cy.get('input')
|
||||
.eq(2)
|
||||
.type(name);
|
||||
cy.get('[data-slate-editor]')
|
||||
cy.getMarkdownEditor()
|
||||
.eq(2)
|
||||
.type(description);
|
||||
cy.contains('button', 'Save').click();
|
||||
|
35
package.json
35
package.json
@ -20,7 +20,7 @@
|
||||
"test:unit": "cross-env NODE_ENV=test jest --no-cache",
|
||||
"test:e2e": "run-s build:demo test:e2e:run",
|
||||
"test:e2e:ci": "run-s build:demo test:e2e:run-ci",
|
||||
"test:e2e:dev": "start-test develop 8080 test:e2e:exec-dev",
|
||||
"test:e2e:dev": "run-p clean && start-test develop 8080 test:e2e:exec-dev",
|
||||
"test:e2e:serve": "http-server dev-test",
|
||||
"test:e2e:exec": "cypress run",
|
||||
"test:e2e:exec-ci": "cypress run --record --parallel --ci-build-id $GITHUB_SHA --group 'GitHub CI' displayName: 'Run Cypress tests'",
|
||||
@ -31,11 +31,11 @@
|
||||
"mock:server:start": "node -e 'require(\"./cypress/utils/mock-server\").start()'",
|
||||
"mock:server:stop": "node -e 'require(\"./cypress/utils/mock-server\").stop()'",
|
||||
"lint": "run-p -c --aggregate-output \"lint:*\"",
|
||||
"lint-quiet": "run-p -c --aggregate-output \"lint:* -- --quiet\"",
|
||||
"lint-quiet": "run-p -c --aggregate-output \"lint:* --quiet\"",
|
||||
"lint:css": "stylelint --ignore-path .gitignore \"{packages/**/*.{css,js},website/**/*.css}\"",
|
||||
"lint:js": "eslint --color --ignore-path .gitignore \"{{packages,scripts,website}/**/,}*.js\"",
|
||||
"lint:format": "prettier \"{{packages,scripts,website}/**/,}*.{js,css}\" --list-different",
|
||||
"format": "run-s \"lint:css -- --fix --quiet\" \"lint:js -- --fix --quiet\" \"format:prettier -- --write\"",
|
||||
"format": "run-s \"lint:js --fix --quiet\" \"format:prettier --write\"",
|
||||
"format:prettier": "prettier \"{{packages,scripts,website}/**/,}*.{js,css}\"",
|
||||
"publish": "run-s publish:before-manual-version publish:after-manual-version",
|
||||
"publish:ci": "run-s publish:prepare \"publish:version --yes\" build publish:push-git \"publish:from-git --yes\"",
|
||||
@ -72,20 +72,24 @@
|
||||
"@babel/core": "^7.3.4",
|
||||
"@babel/plugin-proposal-class-properties": "^7.3.4",
|
||||
"@babel/plugin-proposal-export-default-from": "^7.2.0",
|
||||
"@babel/plugin-proposal-nullish-coalescing-operator": "^7.4.4",
|
||||
"@babel/plugin-proposal-object-rest-spread": "^7.3.4",
|
||||
"@babel/plugin-proposal-optional-chaining": "^7.2.0",
|
||||
"@babel/plugin-syntax-dynamic-import": "^7.2.0",
|
||||
"@babel/preset-env": "^7.3.4",
|
||||
"@babel/preset-react": "^7.0.0",
|
||||
"@commitlint/cli": "^8.2.0",
|
||||
"@commitlint/cli": "^8.3.3",
|
||||
"@commitlint/config-conventional": "^8.2.0",
|
||||
"@octokit/rest": "^16.28.7",
|
||||
"@testing-library/jest-dom": "^4.2.3",
|
||||
"@testing-library/react": "^9.3.2",
|
||||
"all-contributors-cli": "^6.0.0",
|
||||
"babel-core": "^7.0.0-bridge.0",
|
||||
"babel-eslint": "^10.0.1",
|
||||
"babel-eslint": "^11.0.0-beta.0",
|
||||
"babel-jest": "^24.5.0",
|
||||
"babel-loader": "^8.0.5",
|
||||
"babel-plugin-emotion": "^10.0.9",
|
||||
"babel-plugin-inline-json-import": "^0.3.2",
|
||||
"babel-plugin-inline-react-svg": "^1.1.0",
|
||||
"babel-plugin-lodash": "^3.3.4",
|
||||
"babel-plugin-module-resolver": "^3.2.0",
|
||||
@ -98,6 +102,8 @@
|
||||
"cross-env": "^6.0.0",
|
||||
"css-loader": "^3.0.0",
|
||||
"cypress": "^3.4.1",
|
||||
"cypress-jest-adapter": "^0.0.3",
|
||||
"cypress-plugin-tab": "^1.0.0",
|
||||
"dom-testing-library": "^4.0.0",
|
||||
"dotenv": "^8.0.0",
|
||||
"eslint": "^5.15.1",
|
||||
@ -121,25 +127,36 @@
|
||||
"npm-run-all": "^4.1.5",
|
||||
"prettier": "^1.19.1",
|
||||
"react-test-renderer": "^16.8.4",
|
||||
"rehype": "^7.0.0",
|
||||
"rimraf": "^3.0.0",
|
||||
"simple-git": "^1.124.0",
|
||||
"start-server-and-test": "^1.7.11",
|
||||
"style-loader": "^0.23.1",
|
||||
"stylelint": "^9.10.1",
|
||||
"stylelint-config-recommended": "^2.1.0",
|
||||
"stylelint-config-styled-components": "^0.1.1",
|
||||
"stylelint-processor-styled-components": "^1.5.2",
|
||||
"svg-inline-loader": "^0.8.0",
|
||||
"to-string-loader": "^1.1.5",
|
||||
"unist-util-visit": "^1.4.0",
|
||||
"webpack": "^4.29.6",
|
||||
"webpack-cli": "^3.2.3",
|
||||
"webpack-dev-server": "^3.2.1"
|
||||
},
|
||||
"workspaces": [
|
||||
"packages/*"
|
||||
],
|
||||
"workspaces": {
|
||||
"packages": [
|
||||
"packages/*"
|
||||
],
|
||||
"nohoist": [
|
||||
"husky",
|
||||
"run-node"
|
||||
]
|
||||
},
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@emotion/babel-preset-css-prop": "^10.0.9",
|
||||
"emotion": "^10.0.9",
|
||||
"eslint-config-prettier": "^6.5.0",
|
||||
"eslint-plugin-babel": "^5.3.0",
|
||||
"lerna": "^3.15.0"
|
||||
},
|
||||
"husky": {
|
||||
|
@ -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);
|
@ -1,4 +0,0 @@
|
||||
import { NetlifyCmsCore as CMS } from 'netlify-cms-core';
|
||||
import image from 'netlify-cms-editor-component-image';
|
||||
|
||||
CMS.registerEditorComponent(image);
|
@ -1,4 +1,14 @@
|
||||
// Core
|
||||
import { NetlifyCmsCore as CMS } from 'netlify-cms-core';
|
||||
|
||||
// Backends
|
||||
import { GitHubBackend } from 'netlify-cms-backend-github';
|
||||
import { GitLabBackend } from 'netlify-cms-backend-gitlab';
|
||||
import { GitGatewayBackend } from 'netlify-cms-backend-git-gateway';
|
||||
import { BitbucketBackend } from 'netlify-cms-backend-bitbucket';
|
||||
import { TestBackend } from 'netlify-cms-backend-test';
|
||||
|
||||
// Widgets
|
||||
import NetlifyCmsWidgetString from 'netlify-cms-widget-string';
|
||||
import NetlifyCmsWidgetNumber from 'netlify-cms-widget-number';
|
||||
import NetlifyCmsWidgetText from 'netlify-cms-widget-text';
|
||||
@ -14,6 +24,18 @@ import NetlifyCmsWidgetMap from 'netlify-cms-widget-map';
|
||||
import NetlifyCmsWidgetDate from 'netlify-cms-widget-date';
|
||||
import NetlifyCmsWidgetDatetime from 'netlify-cms-widget-datetime';
|
||||
|
||||
// Editor Components
|
||||
import image from 'netlify-cms-editor-component-image';
|
||||
|
||||
// Locales
|
||||
import { en } from 'netlify-cms-locales';
|
||||
|
||||
// Register all the things
|
||||
CMS.registerBackend('git-gateway', GitGatewayBackend);
|
||||
CMS.registerBackend('github', GitHubBackend);
|
||||
CMS.registerBackend('gitlab', GitLabBackend);
|
||||
CMS.registerBackend('bitbucket', BitbucketBackend);
|
||||
CMS.registerBackend('test-repo', TestBackend);
|
||||
CMS.registerWidget([
|
||||
NetlifyCmsWidgetString.Widget(),
|
||||
NetlifyCmsWidgetNumber.Widget(),
|
||||
@ -30,3 +52,5 @@ CMS.registerWidget([
|
||||
NetlifyCmsWidgetDate.Widget(),
|
||||
NetlifyCmsWidgetDatetime.Widget(),
|
||||
]);
|
||||
CMS.registerEditorComponent(image);
|
||||
CMS.registerLocale('en', en);
|
@ -1,13 +1,8 @@
|
||||
import { NetlifyCmsCore as CMS } from 'netlify-cms-core';
|
||||
import './backends';
|
||||
import './widgets';
|
||||
import './editor-components';
|
||||
import './locales';
|
||||
import './extensions.js';
|
||||
|
||||
// Log version
|
||||
if (typeof window !== 'undefined') {
|
||||
/**
|
||||
* Log the version number.
|
||||
*/
|
||||
if (typeof NETLIFY_CMS_APP_VERSION === 'string') {
|
||||
console.log(`netlify-cms-app ${NETLIFY_CMS_APP_VERSION}`);
|
||||
}
|
||||
|
@ -33,6 +33,7 @@
|
||||
"gotrue-js": "^0.9.24",
|
||||
"gray-matter": "^4.0.2",
|
||||
"history": "^4.7.2",
|
||||
"immer": "^3.1.3",
|
||||
"js-base64": "^2.5.1",
|
||||
"js-yaml": "^3.12.2",
|
||||
"jwt-decode": "^2.1.0",
|
||||
|
@ -1,9 +1,8 @@
|
||||
/** @jsx jsx */
|
||||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
import styled from '@emotion/styled';
|
||||
import { jsx, css } from '@emotion/core';
|
||||
import { css } from '@emotion/core';
|
||||
import { translate } from 'react-polyglot';
|
||||
import { NavLink } from 'react-router-dom';
|
||||
import {
|
||||
|
@ -399,6 +399,7 @@ export class Editor extends React.Component {
|
||||
logoutUser,
|
||||
deployPreview,
|
||||
loadDeployPreview,
|
||||
draftKey,
|
||||
slug,
|
||||
t,
|
||||
} = this.props;
|
||||
@ -421,6 +422,7 @@ export class Editor extends React.Component {
|
||||
|
||||
return (
|
||||
<EditorInterface
|
||||
draftKey={draftKey}
|
||||
entry={entryDraft.get('entry')}
|
||||
getAsset={boundGetAsset}
|
||||
collection={collection}
|
||||
@ -474,6 +476,7 @@ function mapStateToProps(state, ownProps) {
|
||||
const currentStatus = unpublishedEntry && unpublishedEntry.getIn(['metaData', 'status']);
|
||||
const deployPreview = selectDeployPreview(state, collectionName, slug);
|
||||
const localBackup = entryDraft.get('localBackup');
|
||||
const draftKey = entryDraft.get('key');
|
||||
return {
|
||||
collection,
|
||||
collections,
|
||||
@ -493,6 +496,7 @@ function mapStateToProps(state, ownProps) {
|
||||
currentStatus,
|
||||
deployPreview,
|
||||
localBackup,
|
||||
draftKey,
|
||||
};
|
||||
}
|
||||
|
||||
|
@ -1,13 +1,12 @@
|
||||
/** @jsx jsx */
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
import { translate } from 'react-polyglot';
|
||||
import { jsx, ClassNames, Global, css as coreCss } from '@emotion/core';
|
||||
import { ClassNames, Global, css as coreCss } from '@emotion/core';
|
||||
import styled from '@emotion/styled';
|
||||
import { partial, uniqueId } from 'lodash';
|
||||
import { connect } from 'react-redux';
|
||||
import { colors, colorsRaw, transitions, lengths, borders } from 'netlify-cms-ui-default';
|
||||
import { FieldLabel, colors, transitions, lengths, borders } from 'netlify-cms-ui-default';
|
||||
import { resolveWidget, getEditorComponents } from 'Lib/registry';
|
||||
import { clearFieldErrors, loadEntry } from 'Actions/entries';
|
||||
import { addAsset } from 'Actions/media';
|
||||
@ -27,48 +26,6 @@ import Widget from './Widget';
|
||||
* this.
|
||||
*/
|
||||
const styleStrings = {
|
||||
label: `
|
||||
color: ${colors.controlLabel};
|
||||
background-color: ${colors.textFieldBorder};
|
||||
display: inline-block;
|
||||
font-size: 12px;
|
||||
text-transform: uppercase;
|
||||
font-weight: 600;
|
||||
border: 0;
|
||||
border-radius: 3px 3px 0 0;
|
||||
padding: 3px 6px 2px;
|
||||
margin: 0;
|
||||
transition: all ${transitions.main};
|
||||
position: relative;
|
||||
|
||||
/**
|
||||
* Faux outside curve into top of input
|
||||
*/
|
||||
&:before,
|
||||
&:after {
|
||||
content: '';
|
||||
display: block;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: -4px;
|
||||
height: 100%;
|
||||
width: 4px;
|
||||
background-color: inherit;
|
||||
}
|
||||
|
||||
&:after {
|
||||
border-bottom-left-radius: 3px;
|
||||
background-color: #fff;
|
||||
}
|
||||
`,
|
||||
labelActive: `
|
||||
background-color: ${colors.active};
|
||||
color: ${colors.textLight};
|
||||
`,
|
||||
labelError: `
|
||||
background-color: ${colors.errorText};
|
||||
color: ${colorsRaw.white};
|
||||
`,
|
||||
widget: `
|
||||
display: block;
|
||||
width: 100%;
|
||||
@ -85,6 +42,7 @@ const styleStrings = {
|
||||
position: relative;
|
||||
font-size: 15px;
|
||||
line-height: 1.5;
|
||||
overflow: hidden;
|
||||
|
||||
select& {
|
||||
text-indent: 14px;
|
||||
@ -155,6 +113,8 @@ class EditorControl extends React.Component {
|
||||
clearFieldErrors: PropTypes.func.isRequired,
|
||||
loadEntry: PropTypes.func.isRequired,
|
||||
t: PropTypes.func.isRequired,
|
||||
isEditorComponent: PropTypes.bool,
|
||||
isNewEditorComponent: PropTypes.bool,
|
||||
};
|
||||
|
||||
state = {
|
||||
@ -186,6 +146,10 @@ class EditorControl extends React.Component {
|
||||
clearSearch,
|
||||
clearFieldErrors,
|
||||
loadEntry,
|
||||
className,
|
||||
isSelected,
|
||||
isEditorComponent,
|
||||
isNewEditorComponent,
|
||||
t,
|
||||
} = this.props;
|
||||
const widgetName = field.get('widget');
|
||||
@ -199,11 +163,11 @@ class EditorControl extends React.Component {
|
||||
return (
|
||||
<ClassNames>
|
||||
{({ css, cx }) => (
|
||||
<ControlContainer>
|
||||
<ControlContainer className={className}>
|
||||
{widget.globalStyles && <Global styles={coreCss`${widget.globalStyles}`} />}
|
||||
<ControlErrorsList>
|
||||
{errors &&
|
||||
errors.map(
|
||||
{errors && (
|
||||
<ControlErrorsList>
|
||||
{errors.map(
|
||||
error =>
|
||||
error.message &&
|
||||
typeof error.message === 'string' && (
|
||||
@ -212,25 +176,15 @@ class EditorControl extends React.Component {
|
||||
</li>
|
||||
),
|
||||
)}
|
||||
</ControlErrorsList>
|
||||
<label
|
||||
className={cx(
|
||||
css`
|
||||
${styleStrings.label};
|
||||
`,
|
||||
this.state.styleActive &&
|
||||
css`
|
||||
${styleStrings.labelActive};
|
||||
`,
|
||||
!!errors &&
|
||||
css`
|
||||
${styleStrings.labelError};
|
||||
`,
|
||||
)}
|
||||
</ControlErrorsList>
|
||||
)}
|
||||
<FieldLabel
|
||||
isActive={isSelected || this.state.styleActive}
|
||||
hasErrors={!!errors}
|
||||
htmlFor={this.uniqueFieldId}
|
||||
>
|
||||
{`${field.get('label', field.get('name'))}${isFieldOptional ? ' (optional)' : ''}`}
|
||||
</label>
|
||||
</FieldLabel>
|
||||
<Widget
|
||||
classNameWrapper={cx(
|
||||
css`
|
||||
@ -239,7 +193,7 @@ class EditorControl extends React.Component {
|
||||
{
|
||||
[css`
|
||||
${styleStrings.widgetActive};
|
||||
`]: this.state.styleActive,
|
||||
`]: isSelected || this.state.styleActive,
|
||||
},
|
||||
{
|
||||
[css`
|
||||
@ -273,10 +227,11 @@ class EditorControl extends React.Component {
|
||||
onRemoveInsertedMedia={removeInsertedMedia}
|
||||
onAddAsset={addAsset}
|
||||
getAsset={boundGetAsset}
|
||||
hasActiveStyle={this.state.styleActive}
|
||||
hasActiveStyle={isSelected || this.state.styleActive}
|
||||
setActiveStyle={() => this.setState({ styleActive: true })}
|
||||
setInactiveStyle={() => this.setState({ styleActive: false })}
|
||||
resolveWidget={resolveWidget}
|
||||
widget={widget}
|
||||
getEditorComponents={getEditorComponents}
|
||||
ref={processControlRef && partial(processControlRef, field)}
|
||||
controlRef={controlRef}
|
||||
@ -289,10 +244,12 @@ class EditorControl extends React.Component {
|
||||
isFetching={isFetching}
|
||||
fieldsErrors={fieldsErrors}
|
||||
onValidateObject={onValidateObject}
|
||||
isEditorComponent={isEditorComponent}
|
||||
isNewEditorComponent={isNewEditorComponent}
|
||||
t={t}
|
||||
/>
|
||||
{fieldHint && (
|
||||
<ControlHint active={this.state.styleActive} error={!!errors}>
|
||||
<ControlHint active={isSelected || this.state.styleActive} error={!!errors}>
|
||||
{fieldHint}
|
||||
</ControlHint>
|
||||
)}
|
||||
|
@ -44,6 +44,7 @@ export default class Widget extends Component {
|
||||
onRemoveInsertedMedia: PropTypes.func.isRequired,
|
||||
getAsset: PropTypes.func.isRequired,
|
||||
resolveWidget: PropTypes.func.isRequired,
|
||||
widget: PropTypes.object.isRequired,
|
||||
getEditorComponents: PropTypes.func.isRequired,
|
||||
isFetching: PropTypes.bool,
|
||||
controlRef: PropTypes.func,
|
||||
@ -56,6 +57,8 @@ export default class Widget extends Component {
|
||||
loadEntry: PropTypes.func.isRequired,
|
||||
t: PropTypes.func.isRequired,
|
||||
onValidateObject: PropTypes.func,
|
||||
isEditorComponent: PropTypes.bool,
|
||||
isNewEditorComponent: PropTypes.bool,
|
||||
};
|
||||
|
||||
shouldComponentUpdate(nextProps) {
|
||||
@ -238,6 +241,7 @@ export default class Widget extends Component {
|
||||
editorControl,
|
||||
uniqueFieldId,
|
||||
resolveWidget,
|
||||
widget,
|
||||
getEditorComponents,
|
||||
query,
|
||||
queryHits,
|
||||
@ -247,6 +251,8 @@ export default class Widget extends Component {
|
||||
loadEntry,
|
||||
fieldsErrors,
|
||||
controlRef,
|
||||
isEditorComponent,
|
||||
isNewEditorComponent,
|
||||
t,
|
||||
} = this.props;
|
||||
return React.createElement(controlComponent, {
|
||||
@ -275,6 +281,7 @@ export default class Widget extends Component {
|
||||
hasActiveStyle,
|
||||
editorControl,
|
||||
resolveWidget,
|
||||
widget,
|
||||
getEditorComponents,
|
||||
query,
|
||||
queryHits,
|
||||
@ -282,6 +289,8 @@ export default class Widget extends Component {
|
||||
clearFieldErrors,
|
||||
isFetching,
|
||||
loadEntry,
|
||||
isEditorComponent,
|
||||
isNewEditorComponent,
|
||||
fieldsErrors,
|
||||
controlRef,
|
||||
t,
|
||||
|
@ -4,12 +4,11 @@ import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
import { css, Global } from '@emotion/core';
|
||||
import styled from '@emotion/styled';
|
||||
import SplitPane from 'react-split-pane';
|
||||
import { colors, colorsRaw, components, transitions } from 'netlify-cms-ui-default';
|
||||
import { colors, colorsRaw, components, transitions, IconButton } from 'netlify-cms-ui-default';
|
||||
import { ScrollSync, ScrollSyncPane } from 'react-scroll-sync';
|
||||
import EditorControlPane from './EditorControlPane/EditorControlPane';
|
||||
import EditorPreviewPane from './EditorPreviewPane/EditorPreviewPane';
|
||||
import EditorToolbar from './EditorToolbar';
|
||||
import EditorToggle from './EditorToggle';
|
||||
|
||||
const PREVIEW_VISIBLE = 'cms.preview-visible';
|
||||
const SCROLL_SYNC_ENABLED = 'cms.scroll-sync-enabled';
|
||||
@ -27,6 +26,10 @@ const styles = {
|
||||
`,
|
||||
};
|
||||
|
||||
const EditorToggle = styled(IconButton)`
|
||||
margin-bottom: 12px;
|
||||
`;
|
||||
|
||||
const ReactSplitPaneGlobalStyles = () => (
|
||||
<Global
|
||||
styles={css`
|
||||
@ -175,6 +178,7 @@ class EditorInterface extends Component {
|
||||
onLogoutClick,
|
||||
loadDeployPreview,
|
||||
deployPreview,
|
||||
draftKey,
|
||||
} = this.props;
|
||||
|
||||
const { previewVisible, scrollSyncEnabled, showEventBlocker } = this.state;
|
||||
@ -255,22 +259,26 @@ class EditorInterface extends Component {
|
||||
loadDeployPreview={loadDeployPreview}
|
||||
deployPreview={deployPreview}
|
||||
/>
|
||||
<Editor>
|
||||
<Editor key={draftKey}>
|
||||
<ViewControls>
|
||||
<EditorToggle
|
||||
enabled={collectionPreviewEnabled}
|
||||
active={previewVisible}
|
||||
onClick={this.handleTogglePreview}
|
||||
icon="eye"
|
||||
title="Toggle preview"
|
||||
/>
|
||||
<EditorToggle
|
||||
enabled={collectionPreviewEnabled && previewVisible}
|
||||
active={scrollSyncEnabled}
|
||||
onClick={this.handleToggleScrollSync}
|
||||
icon="scroll"
|
||||
title="Sync scrolling"
|
||||
/>
|
||||
{collectionPreviewEnabled && (
|
||||
<EditorToggle
|
||||
isActive={previewVisible}
|
||||
onClick={this.handleTogglePreview}
|
||||
size="large"
|
||||
type="eye"
|
||||
title="Toggle preview"
|
||||
/>
|
||||
)}
|
||||
{collectionPreviewEnabled && previewVisible && (
|
||||
<EditorToggle
|
||||
isActive={scrollSyncEnabled}
|
||||
onClick={this.handleToggleScrollSync}
|
||||
size="large"
|
||||
type="scroll"
|
||||
title="Sync scrolling"
|
||||
/>
|
||||
)}
|
||||
</ViewControls>
|
||||
{collectionPreviewEnabled && this.state.previewVisible ? (
|
||||
editorWithPreview
|
||||
@ -312,6 +320,7 @@ EditorInterface.propTypes = {
|
||||
onLogoutClick: PropTypes.func.isRequired,
|
||||
deployPreview: ImmutablePropTypes.map,
|
||||
loadDeployPreview: PropTypes.func.isRequired,
|
||||
draftKey: PropTypes.string.isRequired,
|
||||
};
|
||||
|
||||
export default EditorInterface;
|
||||
|
@ -26,6 +26,7 @@ export default class PreviewPane extends React.Component {
|
||||
const { getAsset, entry } = props;
|
||||
const widget = resolveWidget(field.get('widget'));
|
||||
const key = idx ? field.get('name') + '_' + idx : field.get('name');
|
||||
const valueIsInMap = value && !widget.allowMapValue && Map.isMap(value);
|
||||
|
||||
/**
|
||||
* Use an HOC to provide conditional updates for all previews.
|
||||
@ -36,7 +37,7 @@ export default class PreviewPane extends React.Component {
|
||||
key={key}
|
||||
field={field}
|
||||
getAsset={getAsset}
|
||||
value={value && Map.isMap(value) ? value.get(field.get('name')) : value}
|
||||
value={valueIsInMap ? value.get(field.get('name')) : value}
|
||||
entry={entry}
|
||||
fieldsMetaData={metadata}
|
||||
/>
|
||||
|
@ -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;
|
@ -1,8 +1,7 @@
|
||||
/** @jsx jsx */
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { jsx, css, Global } from '@emotion/core';
|
||||
import { css, Global } from '@emotion/core';
|
||||
import { translate } from 'react-polyglot';
|
||||
import reduxNotificationsStyles from 'redux-notifications/lib/styles.css';
|
||||
import { shadows, colors, lengths } from 'netlify-cms-ui-default';
|
||||
|
@ -1,8 +1,7 @@
|
||||
/** @jsx jsx */
|
||||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
import { jsx, css } from '@emotion/core';
|
||||
import { css } from '@emotion/core';
|
||||
import styled from '@emotion/styled';
|
||||
import moment from 'moment';
|
||||
import { translate } from 'react-polyglot';
|
||||
|
@ -1,4 +1,5 @@
|
||||
import { Map } from 'immutable';
|
||||
import produce from 'immer';
|
||||
import { oneLine } from 'common-tags';
|
||||
import EditorComponent from 'ValueObjects/EditorComponent';
|
||||
|
||||
@ -23,6 +24,7 @@ export default {
|
||||
getPreviewTemplate,
|
||||
registerWidget,
|
||||
getWidget,
|
||||
getWidgets,
|
||||
resolveWidget,
|
||||
registerEditorComponent,
|
||||
getEditorComponents,
|
||||
@ -81,10 +83,12 @@ export function registerWidget(name, control, preview) {
|
||||
name: widgetName,
|
||||
controlComponent: control,
|
||||
previewComponent: preview,
|
||||
allowMapValue,
|
||||
globalStyles,
|
||||
...options
|
||||
} = name;
|
||||
if (registry.widgets[widgetName]) {
|
||||
console.error(oneLine`
|
||||
console.warn(oneLine`
|
||||
Multiple widgets registered with name "${widgetName}". Only the last widget registered with
|
||||
this name will be used.
|
||||
`);
|
||||
@ -92,7 +96,7 @@ export function registerWidget(name, control, preview) {
|
||||
if (!control) {
|
||||
throw Error(`Widget "${widgetName}" registered without \`controlComponent\`.`);
|
||||
}
|
||||
registry.widgets[widgetName] = { control, preview, globalStyles };
|
||||
registry.widgets[widgetName] = { control, preview, globalStyles, allowMapValue, ...options };
|
||||
} else {
|
||||
console.error('`registerWidget` failed, called with incorrect arguments.');
|
||||
}
|
||||
@ -100,6 +104,11 @@ export function registerWidget(name, control, preview) {
|
||||
export function getWidget(name) {
|
||||
return registry.widgets[name];
|
||||
}
|
||||
export function getWidgets() {
|
||||
return produce(Object.entries(registry.widgets), draft => {
|
||||
return draft.map(([key, value]) => ({ name: key, ...value }));
|
||||
});
|
||||
}
|
||||
export function resolveWidget(name) {
|
||||
return getWidget(name || 'string') || getWidget('unknown');
|
||||
}
|
||||
@ -109,7 +118,19 @@ export function resolveWidget(name) {
|
||||
*/
|
||||
export function registerEditorComponent(component) {
|
||||
const plugin = EditorComponent(component);
|
||||
registry.editorComponents = registry.editorComponents.set(plugin.get('id'), plugin);
|
||||
if (plugin.type === 'code-block') {
|
||||
const codeBlock = registry.editorComponents.find(c => c.type === 'code-block');
|
||||
|
||||
if (codeBlock) {
|
||||
console.warn(oneLine`
|
||||
Only one editor component of type "code-block" may be registered. Previously registered code
|
||||
block component(s) will be overwritten.
|
||||
`);
|
||||
registry.editorComponents = registry.editorComponents.delete(codeBlock.id);
|
||||
}
|
||||
}
|
||||
|
||||
registry.editorComponents = registry.editorComponents.set(plugin.id, plugin);
|
||||
}
|
||||
export function getEditorComponents() {
|
||||
return registry.editorComponents;
|
||||
|
@ -2,12 +2,15 @@ import { Map, List, fromJS } from 'immutable';
|
||||
import * as actions from 'Actions/entries';
|
||||
import reducer from '../entryDraft';
|
||||
|
||||
jest.mock('uuid/v4', () => jest.fn(() => '1'));
|
||||
|
||||
const initialState = Map({
|
||||
entry: Map(),
|
||||
mediaFiles: List(),
|
||||
fieldsMetaData: Map(),
|
||||
fieldsErrors: Map(),
|
||||
hasChanged: false,
|
||||
key: '',
|
||||
});
|
||||
|
||||
const entry = {
|
||||
@ -23,7 +26,8 @@ const entry = {
|
||||
describe('entryDraft reducer', () => {
|
||||
describe('DRAFT_CREATE_FROM_ENTRY', () => {
|
||||
it('should create draft from the entry', () => {
|
||||
expect(reducer(initialState, actions.createDraftFromEntry(fromJS(entry)))).toEqual(
|
||||
const state = reducer(initialState, actions.createDraftFromEntry(fromJS(entry)));
|
||||
expect(state).toEqual(
|
||||
fromJS({
|
||||
entry: {
|
||||
...entry,
|
||||
@ -33,6 +37,7 @@ describe('entryDraft reducer', () => {
|
||||
fieldsMetaData: Map(),
|
||||
fieldsErrors: Map(),
|
||||
hasChanged: false,
|
||||
key: '1',
|
||||
}),
|
||||
);
|
||||
});
|
||||
@ -40,7 +45,8 @@ describe('entryDraft reducer', () => {
|
||||
|
||||
describe('DRAFT_CREATE_EMPTY', () => {
|
||||
it('should create a new draft ', () => {
|
||||
expect(reducer(initialState, actions.emptyDraftCreated(fromJS(entry)))).toEqual(
|
||||
const state = reducer(initialState, actions.emptyDraftCreated(fromJS(entry)));
|
||||
expect(state).toEqual(
|
||||
fromJS({
|
||||
entry: {
|
||||
...entry,
|
||||
@ -50,6 +56,7 @@ describe('entryDraft reducer', () => {
|
||||
fieldsMetaData: Map(),
|
||||
fieldsErrors: Map(),
|
||||
hasChanged: false,
|
||||
key: '1',
|
||||
}),
|
||||
);
|
||||
});
|
||||
@ -127,6 +134,7 @@ describe('entryDraft reducer', () => {
|
||||
fieldsMetaData: {},
|
||||
fieldsErrors: {},
|
||||
hasChanged: false,
|
||||
key: '',
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -144,6 +152,7 @@ describe('entryDraft reducer', () => {
|
||||
fieldsMetaData: {},
|
||||
fieldsErrors: {},
|
||||
hasChanged: false,
|
||||
key: '',
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -161,6 +170,7 @@ describe('entryDraft reducer', () => {
|
||||
fieldsMetaData: {},
|
||||
fieldsErrors: {},
|
||||
hasChanged: false,
|
||||
key: '',
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -181,6 +191,7 @@ describe('entryDraft reducer', () => {
|
||||
fieldsMetaData: {},
|
||||
fieldsErrors: {},
|
||||
hasChanged: true,
|
||||
key: '1',
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -201,6 +212,7 @@ describe('entryDraft reducer', () => {
|
||||
entry,
|
||||
mediaFiles: [{ id: '1' }],
|
||||
},
|
||||
key: '',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
@ -1,4 +1,5 @@
|
||||
import { Map, List, fromJS } from 'immutable';
|
||||
import uuid from 'uuid/v4';
|
||||
import {
|
||||
DRAFT_CREATE_FROM_ENTRY,
|
||||
DRAFT_CREATE_EMPTY,
|
||||
@ -30,6 +31,7 @@ const initialState = Map({
|
||||
fieldsMetaData: Map(),
|
||||
fieldsErrors: Map(),
|
||||
hasChanged: false,
|
||||
key: '',
|
||||
});
|
||||
|
||||
const entryDraftReducer = (state = Map(), action) => {
|
||||
@ -46,6 +48,7 @@ const entryDraftReducer = (state = Map(), action) => {
|
||||
state.set('fieldsMetaData', action.payload.metadata || Map());
|
||||
state.set('fieldsErrors', Map());
|
||||
state.set('hasChanged', false);
|
||||
state.set('key', uuid());
|
||||
});
|
||||
case DRAFT_CREATE_EMPTY:
|
||||
// New Entry
|
||||
@ -56,6 +59,7 @@ const entryDraftReducer = (state = Map(), action) => {
|
||||
state.set('fieldsMetaData', Map());
|
||||
state.set('fieldsErrors', Map());
|
||||
state.set('hasChanged', false);
|
||||
state.set('key', uuid());
|
||||
});
|
||||
case DRAFT_CREATE_FROM_LOCAL_BACKUP:
|
||||
// Local Backup
|
||||
@ -69,6 +73,7 @@ const entryDraftReducer = (state = Map(), action) => {
|
||||
state.set('fieldsMetaData', Map());
|
||||
state.set('fieldsErrors', Map());
|
||||
state.set('hasChanged', true);
|
||||
state.set('key', uuid());
|
||||
});
|
||||
case DRAFT_CREATE_DUPLICATE_FROM_ENTRY:
|
||||
// Duplicate Entry
|
||||
|
@ -1,39 +1,35 @@
|
||||
import { Record, fromJS } from 'immutable';
|
||||
import { fromJS } from 'immutable';
|
||||
import { isFunction } from 'lodash';
|
||||
|
||||
const catchesNothing = /.^/;
|
||||
/* eslint-disable no-unused-vars */
|
||||
const EditorComponent = Record({
|
||||
id: null,
|
||||
label: 'unnamed component',
|
||||
icon: 'exclamation-triangle',
|
||||
fields: [],
|
||||
pattern: catchesNothing,
|
||||
fromBlock(match) {
|
||||
return {};
|
||||
},
|
||||
toBlock(attributes) {
|
||||
return 'Plugin';
|
||||
},
|
||||
toPreview(attributes) {
|
||||
return 'Plugin';
|
||||
},
|
||||
});
|
||||
/* eslint-enable */
|
||||
const bind = fn => isFunction(fn) && fn.bind(null);
|
||||
|
||||
export default function createEditorComponent(config) {
|
||||
const configObj = new EditorComponent({
|
||||
id: config.id || config.label.replace(/[^A-Z0-9]+/gi, '_'),
|
||||
label: config.label,
|
||||
icon: config.icon,
|
||||
fields: fromJS(config.fields),
|
||||
pattern: config.pattern,
|
||||
fromBlock: isFunction(config.fromBlock) ? config.fromBlock.bind(null) : null,
|
||||
toBlock: isFunction(config.toBlock) ? config.toBlock.bind(null) : null,
|
||||
toPreview: isFunction(config.toPreview)
|
||||
? config.toPreview.bind(null)
|
||||
: config.toBlock.bind(null),
|
||||
});
|
||||
const {
|
||||
id = null,
|
||||
label = 'unnamed component',
|
||||
icon = 'exclamation-triangle',
|
||||
type = 'shortcode',
|
||||
widget = 'object',
|
||||
pattern = catchesNothing,
|
||||
fields = [],
|
||||
fromBlock,
|
||||
toBlock,
|
||||
toPreview,
|
||||
...remainingConfig
|
||||
} = config;
|
||||
|
||||
return configObj;
|
||||
return {
|
||||
id: id || label.replace(/[^A-Z0-9]+/gi, '_'),
|
||||
label,
|
||||
type,
|
||||
icon,
|
||||
widget,
|
||||
pattern,
|
||||
fromBlock: bind(fromBlock) || (() => ({})),
|
||||
toBlock: bind(toBlock) || (() => 'Plugin'),
|
||||
toPreview: bind(toPreview) || (!widget && (bind(toBlock) || (() => 'Plugin'))),
|
||||
fields: fromJS(fields),
|
||||
...remainingConfig,
|
||||
};
|
||||
}
|
||||
|
@ -5,7 +5,7 @@
|
||||
"repository": "https://github.com/netlify/netlify-cms/tree/master/packages/netlify-cms-default-exports",
|
||||
"bugs": "https://github.com/netlify/netlify-cms/issues",
|
||||
"module": "dist/esm/index.js",
|
||||
"main": "dist/netlify-cms-editor-component-image.js",
|
||||
"main": "dist/netlify-cms-default-exports.js",
|
||||
"license": "MIT",
|
||||
"keywords": [
|
||||
"netlify",
|
||||
|
58
packages/netlify-cms-ui-default/src/FieldLabel.js
Normal file
58
packages/netlify-cms-ui-default/src/FieldLabel.js
Normal 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;
|
37
packages/netlify-cms-ui-default/src/IconButton.js
Normal file
37
packages/netlify-cms-ui-default/src/IconButton.js
Normal 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;
|
@ -2,6 +2,8 @@ import Dropdown, { DropdownItem, DropdownButton, StyledDropdownButton } from './
|
||||
import Icon from './Icon';
|
||||
import ListItemTopBar from './ListItemTopBar';
|
||||
import Loader from './Loader';
|
||||
import FieldLabel from './FieldLabel';
|
||||
import IconButton from './IconButton';
|
||||
import Toggle, { ToggleContainer, ToggleBackground, ToggleHandle } from './Toggle';
|
||||
import AuthenticationPage from './AuthenticationPage';
|
||||
import WidgetPreviewContainer from './WidgetPreviewContainer';
|
||||
@ -14,6 +16,7 @@ import {
|
||||
lengths,
|
||||
components,
|
||||
buttons,
|
||||
text,
|
||||
shadows,
|
||||
borders,
|
||||
transitions,
|
||||
@ -28,7 +31,9 @@ export const NetlifyCmsUiDefault = {
|
||||
DropdownButton,
|
||||
StyledDropdownButton,
|
||||
ListItemTopBar,
|
||||
FieldLabel,
|
||||
Icon,
|
||||
IconButton,
|
||||
Loader,
|
||||
Toggle,
|
||||
ToggleContainer,
|
||||
@ -44,6 +49,7 @@ export const NetlifyCmsUiDefault = {
|
||||
components,
|
||||
buttons,
|
||||
shadows,
|
||||
text,
|
||||
borders,
|
||||
transitions,
|
||||
effects,
|
||||
@ -56,7 +62,9 @@ export {
|
||||
DropdownButton,
|
||||
StyledDropdownButton,
|
||||
ListItemTopBar,
|
||||
FieldLabel,
|
||||
Icon,
|
||||
IconButton,
|
||||
Loader,
|
||||
Toggle,
|
||||
ToggleContainer,
|
||||
@ -72,6 +80,7 @@ export {
|
||||
components,
|
||||
buttons,
|
||||
shadows,
|
||||
text,
|
||||
borders,
|
||||
transitions,
|
||||
effects,
|
||||
|
@ -120,6 +120,15 @@ const shadows = {
|
||||
`,
|
||||
};
|
||||
|
||||
const text = {
|
||||
fieldLabel: css`
|
||||
font-size: 12px;
|
||||
text-transform: uppercase;
|
||||
font-weight: 600;
|
||||
color: ${colors.controlLabel};
|
||||
`,
|
||||
};
|
||||
|
||||
const gradients = {
|
||||
checkerboard: `
|
||||
linear-gradient(
|
||||
@ -465,6 +474,7 @@ export {
|
||||
lengths,
|
||||
components,
|
||||
buttons,
|
||||
text,
|
||||
shadows,
|
||||
borders,
|
||||
transitions,
|
||||
|
@ -1,8 +1,7 @@
|
||||
/** @jsx jsx */
|
||||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
import { jsx, css } from '@emotion/core';
|
||||
import { css } from '@emotion/core';
|
||||
import { Toggle, ToggleBackground, colors } from 'netlify-cms-ui-default';
|
||||
|
||||
const BooleanBackground = ({ isActive, ...props }) => (
|
||||
|
11
packages/netlify-cms-widget-code/README.md
Normal file
11
packages/netlify-cms-widget-code/README.md
Normal 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)!
|
6133
packages/netlify-cms-widget-code/data/languages-raw.yml
Normal file
6133
packages/netlify-cms-widget-code/data/languages-raw.yml
Normal file
File diff suppressed because it is too large
Load Diff
1
packages/netlify-cms-widget-code/data/languages.json
Normal file
1
packages/netlify-cms-widget-code/data/languages.json
Normal file
File diff suppressed because one or more lines are too long
39
packages/netlify-cms-widget-code/package.json
Normal file
39
packages/netlify-cms-widget-code/package.json
Normal 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"
|
||||
}
|
||||
}
|
@ -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();
|
315
packages/netlify-cms-widget-code/src/CodeControl.js
Normal file
315
packages/netlify-cms-widget-code/src/CodeControl.js
Normal 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>
|
||||
);
|
||||
}
|
||||
}
|
29
packages/netlify-cms-widget-code/src/CodePreview.js
Normal file
29
packages/netlify-cms-widget-code/src/CodePreview.js
Normal 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;
|
31
packages/netlify-cms-widget-code/src/SettingsButton.js
Normal file
31
packages/netlify-cms-widget-code/src/SettingsButton.js
Normal 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;
|
109
packages/netlify-cms-widget-code/src/SettingsPane.js
Normal file
109
packages/netlify-cms-widget-code/src/SettingsPane.js
Normal 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;
|
14
packages/netlify-cms-widget-code/src/index.js
Normal file
14
packages/netlify-cms-widget-code/src/index.js
Normal 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;
|
35
packages/netlify-cms-widget-code/src/languageSelectStyles.js
Normal file
35
packages/netlify-cms-widget-code/src/languageSelectStyles.js
Normal 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;
|
3
packages/netlify-cms-widget-code/webpack.config.js
Normal file
3
packages/netlify-cms-widget-code/webpack.config.js
Normal file
@ -0,0 +1,3 @@
|
||||
const { getConfig } = require('../../scripts/webpack.js');
|
||||
|
||||
module.exports = getConfig();
|
@ -1,7 +1,6 @@
|
||||
/** @jsx jsx */
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { jsx, css } from '@emotion/core';
|
||||
import { css } from '@emotion/core';
|
||||
import reactDateTimeStyles from 'react-datetime/css/react-datetime.css';
|
||||
import DateTime from 'react-datetime';
|
||||
import moment from 'moment';
|
||||
|
@ -5,6 +5,7 @@ const controlComponent = NetlifyCmsWidgetFile.withFileControl({ forImage: true }
|
||||
const Widget = (opts = {}) => ({
|
||||
name: 'image',
|
||||
controlComponent,
|
||||
previewComponent,
|
||||
...opts,
|
||||
});
|
||||
|
||||
|
@ -1,11 +1,11 @@
|
||||
/** @jsx jsx */
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
import styled from '@emotion/styled';
|
||||
import { jsx, css, ClassNames } from '@emotion/core';
|
||||
import { css, ClassNames } from '@emotion/core';
|
||||
import { List, Map, fromJS } from 'immutable';
|
||||
import { partial, isEmpty } from 'lodash';
|
||||
import uuid from 'uuid/v4';
|
||||
import { SortableContainer, SortableElement, SortableHandle } from 'react-sortable-hoc';
|
||||
import NetlifyCmsWidgetObject from 'netlify-cms-widget-object';
|
||||
import {
|
||||
@ -110,6 +110,7 @@ export default class ListControl extends React.Component {
|
||||
this.state = {
|
||||
itemsCollapsed: List(itemsCollapsed),
|
||||
value: valueToString(value),
|
||||
keys: List(),
|
||||
};
|
||||
}
|
||||
|
||||
@ -314,7 +315,7 @@ export default class ListControl extends React.Component {
|
||||
|
||||
onSortEnd = ({ oldIndex, newIndex }) => {
|
||||
const { value } = this.props;
|
||||
const { itemsCollapsed } = this.state;
|
||||
const { itemsCollapsed, keys } = this.state;
|
||||
|
||||
// Update value
|
||||
const item = value.get(oldIndex);
|
||||
@ -324,7 +325,10 @@ export default class ListControl extends React.Component {
|
||||
// Update collapsing
|
||||
const collapsed = itemsCollapsed.get(oldIndex);
|
||||
const updatedItemsCollapsed = itemsCollapsed.delete(oldIndex).insert(newIndex, collapsed);
|
||||
this.setState({ itemsCollapsed: updatedItemsCollapsed });
|
||||
|
||||
// Reset item to ensure updated state
|
||||
const updatedKeys = keys.set(oldIndex, uuid()).set(newIndex, uuid());
|
||||
this.setState({ itemsCollapsed: updatedItemsCollapsed, keys: updatedKeys });
|
||||
};
|
||||
|
||||
// eslint-disable-next-line react/display-name
|
||||
@ -340,8 +344,9 @@ export default class ListControl extends React.Component {
|
||||
resolveWidget,
|
||||
} = this.props;
|
||||
|
||||
const { itemsCollapsed } = this.state;
|
||||
const { itemsCollapsed, keys } = this.state;
|
||||
const collapsed = itemsCollapsed.get(index);
|
||||
const key = keys.get(index) || `item-${index}`;
|
||||
let field = this.props.field;
|
||||
|
||||
if (this.getValueType() === valueTypes.MIXED) {
|
||||
@ -355,7 +360,7 @@ export default class ListControl extends React.Component {
|
||||
<SortableListItem
|
||||
css={[styles.listControlItem, collapsed && styles.listControlItemCollapsed]}
|
||||
index={index}
|
||||
key={`item-${index}`}
|
||||
key={key}
|
||||
>
|
||||
<StyledListItemTopBar
|
||||
collapsed={collapsed}
|
||||
|
@ -25,21 +25,23 @@
|
||||
"is-hotkey": "^0.1.4",
|
||||
"mdast-util-definitions": "^1.2.3",
|
||||
"mdast-util-to-string": "^1.0.5",
|
||||
"rehype-parse": "^3.1.0",
|
||||
"rehype-remark": "^2.0.0",
|
||||
"rehype-stringify": "^3.0.0",
|
||||
"remark-parse": "^3.0.1",
|
||||
"remark-rehype": "^2.0.0",
|
||||
"remark-stringify": "^3.0.1",
|
||||
"slate": "^0.34.0",
|
||||
"slate-edit-list": "^0.11.3",
|
||||
"slate-edit-table": "^0.15.1",
|
||||
"slate-plain-serializer": "^0.5.15",
|
||||
"slate-react": "0.12.9",
|
||||
"slate-soft-break": "^0.6.1",
|
||||
"unified": "^6.1.4",
|
||||
"unist-builder": "^1.0.2",
|
||||
"unist-util-visit-parents": "^1.1.1"
|
||||
"re-resizable": "^4.11.0",
|
||||
"react-monaco-editor": "^0.25.1",
|
||||
"react-select": "^2.4.3",
|
||||
"rehype-parse": "^6.0.0",
|
||||
"rehype-remark": "^5.0.1",
|
||||
"rehype-stringify": "^5.0.0",
|
||||
"remark-parse": "^6.0.3",
|
||||
"remark-rehype": "^4.0.0",
|
||||
"remark-stringify": "^6.0.4",
|
||||
"slate": "^0.47.0",
|
||||
"slate-base64-serializer": "^0.2.107",
|
||||
"slate-plain-serializer": "^0.7.1",
|
||||
"slate-react": "^0.22.0",
|
||||
"slate-soft-break": "^0.9.0",
|
||||
"unified": "^7.1.0",
|
||||
"unist-builder": "^1.0.3",
|
||||
"unist-util-visit-parents": "^2.0.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@emotion/core": "^10.0.9",
|
||||
@ -51,5 +53,11 @@
|
||||
"react": "^16.8.4",
|
||||
"react-dom": "^16.8.4",
|
||||
"react-immutable-proptypes": "^2.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"commonmark": "^0.29.0",
|
||||
"commonmark-spec": "^0.29.0",
|
||||
"monaco-editor-webpack-plugin": "^1.7.0",
|
||||
"slate-hyperscript": "^0.13.3"
|
||||
}
|
||||
}
|
||||
|
@ -3,10 +3,12 @@ import PropTypes from 'prop-types';
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
import styled from '@emotion/styled';
|
||||
import { ClassNames } from '@emotion/core';
|
||||
import { Editor as Slate } from 'slate-react';
|
||||
import Plain from 'slate-plain-serializer';
|
||||
import { debounce } from 'lodash';
|
||||
import { Editor as Slate, setEventTransfer } from 'slate-react';
|
||||
import Plain from 'slate-plain-serializer';
|
||||
import isHotkey from 'is-hotkey';
|
||||
import { lengths, fonts } from 'netlify-cms-ui-default';
|
||||
import { markdownToHtml } from '../serializers';
|
||||
import { editorStyleVars, EditorControlBar } from '../styles';
|
||||
import Toolbar from './Toolbar';
|
||||
|
||||
@ -40,38 +42,61 @@ export default class RawEditor extends React.Component {
|
||||
return !this.state.value.equals(nextState.value);
|
||||
}
|
||||
|
||||
handleChange = change => {
|
||||
if (!this.state.value.document.equals(change.value.document)) {
|
||||
this.handleDocumentChange(change);
|
||||
componentDidMount() {
|
||||
if (this.props.pendingFocus) {
|
||||
this.editor.focus();
|
||||
this.props.pendingFocus();
|
||||
}
|
||||
this.setState({ value: change.value });
|
||||
}
|
||||
|
||||
handleCopy = (event, editor) => {
|
||||
const { getAsset, resolveWidget } = this.props;
|
||||
const markdown = Plain.serialize(editor.value);
|
||||
const html = markdownToHtml(markdown, { getAsset, resolveWidget });
|
||||
setEventTransfer(event, 'text', markdown);
|
||||
setEventTransfer(event, 'html', html);
|
||||
event.preventDefault();
|
||||
};
|
||||
|
||||
handleCut = (event, editor, next) => {
|
||||
this.handleCopy(event, editor, next);
|
||||
editor.delete();
|
||||
};
|
||||
|
||||
handlePaste = (event, editor, next) => {
|
||||
const data = event.clipboardData;
|
||||
if (isHotkey('shift', event)) {
|
||||
return next();
|
||||
}
|
||||
|
||||
const value = Plain.deserialize(data.getData('text/plain'));
|
||||
return editor.insertFragment(value.document);
|
||||
};
|
||||
|
||||
handleChange = editor => {
|
||||
if (!this.state.value.document.equals(editor.value.document)) {
|
||||
this.handleDocumentChange(editor);
|
||||
}
|
||||
this.setState({ value: editor.value });
|
||||
};
|
||||
|
||||
/**
|
||||
* When the document value changes, serialize from Slate's AST back to plain
|
||||
* text (which is Markdown) and pass that up as the new value.
|
||||
*/
|
||||
handleDocumentChange = debounce(change => {
|
||||
const value = Plain.serialize(change.value);
|
||||
handleDocumentChange = debounce(editor => {
|
||||
const value = Plain.serialize(editor.value);
|
||||
this.props.onChange(value);
|
||||
}, 150);
|
||||
|
||||
/**
|
||||
* If a paste contains plain text, deserialize it to Slate's AST and insert
|
||||
* to the document. Selection logic (where to insert, whether to replace) is
|
||||
* handled by Slate.
|
||||
*/
|
||||
handlePaste = (e, data, change) => {
|
||||
if (data.text) {
|
||||
const fragment = Plain.deserialize(data.text).document;
|
||||
return change.insertFragment(fragment);
|
||||
}
|
||||
};
|
||||
|
||||
handleToggleMode = () => {
|
||||
this.props.onMode('visual');
|
||||
};
|
||||
|
||||
processRef = ref => {
|
||||
this.editor = ref;
|
||||
};
|
||||
|
||||
render() {
|
||||
const { className, field } = this.props;
|
||||
return (
|
||||
@ -96,6 +121,9 @@ export default class RawEditor extends React.Component {
|
||||
value={this.state.value}
|
||||
onChange={this.handleChange}
|
||||
onPaste={this.handlePaste}
|
||||
onCut={this.handleCut}
|
||||
onCopy={this.handleCopy}
|
||||
ref={this.processRef}
|
||||
/>
|
||||
)}
|
||||
</ClassNames>
|
||||
|
@ -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>
|
||||
);
|
||||
}
|
||||
}
|
@ -80,9 +80,9 @@ export default class Toolbar extends React.Component {
|
||||
onMarkClick: PropTypes.func,
|
||||
onBlockClick: PropTypes.func,
|
||||
onLinkClick: PropTypes.func,
|
||||
selectionHasMark: PropTypes.func,
|
||||
selectionHasBlock: PropTypes.func,
|
||||
selectionHasLink: PropTypes.func,
|
||||
hasMark: PropTypes.func,
|
||||
hasInline: PropTypes.func,
|
||||
hasBlock: PropTypes.func,
|
||||
};
|
||||
|
||||
isHidden = button => {
|
||||
@ -90,19 +90,29 @@ export default class Toolbar extends React.Component {
|
||||
return List.isList(buttons) ? !buttons.includes(button) : false;
|
||||
};
|
||||
|
||||
handleBlockClick = (event, type) => {
|
||||
if (event) {
|
||||
event.preventDefault();
|
||||
}
|
||||
this.props.onBlockClick(type);
|
||||
};
|
||||
|
||||
handleMarkClick = (event, type) => {
|
||||
event.preventDefault();
|
||||
this.props.onMarkClick(type);
|
||||
};
|
||||
|
||||
render() {
|
||||
const {
|
||||
onMarkClick,
|
||||
onBlockClick,
|
||||
onLinkClick,
|
||||
selectionHasMark,
|
||||
selectionHasBlock,
|
||||
selectionHasLink,
|
||||
onToggleMode,
|
||||
rawMode,
|
||||
plugins,
|
||||
disabled,
|
||||
onSubmit,
|
||||
hasMark = () => {},
|
||||
hasInline = () => {},
|
||||
hasBlock = () => {},
|
||||
} = this.props;
|
||||
|
||||
return (
|
||||
@ -112,8 +122,8 @@ export default class Toolbar extends React.Component {
|
||||
type="bold"
|
||||
label="Bold"
|
||||
icon="bold"
|
||||
onClick={onMarkClick}
|
||||
isActive={selectionHasMark}
|
||||
onClick={this.handleMarkClick}
|
||||
isActive={hasMark('bold')}
|
||||
isHidden={this.isHidden('bold')}
|
||||
disabled={disabled}
|
||||
/>
|
||||
@ -121,8 +131,8 @@ export default class Toolbar extends React.Component {
|
||||
type="italic"
|
||||
label="Italic"
|
||||
icon="italic"
|
||||
onClick={onMarkClick}
|
||||
isActive={selectionHasMark}
|
||||
onClick={this.handleMarkClick}
|
||||
isActive={hasMark('italic')}
|
||||
isHidden={this.isHidden('italic')}
|
||||
disabled={disabled}
|
||||
/>
|
||||
@ -130,8 +140,8 @@ export default class Toolbar extends React.Component {
|
||||
type="code"
|
||||
label="Code"
|
||||
icon="code"
|
||||
onClick={onMarkClick}
|
||||
isActive={selectionHasMark}
|
||||
onClick={this.handleMarkClick}
|
||||
isActive={hasMark('code')}
|
||||
isHidden={this.isHidden('code')}
|
||||
disabled={disabled}
|
||||
/>
|
||||
@ -140,7 +150,7 @@ export default class Toolbar extends React.Component {
|
||||
label="Link"
|
||||
icon="link"
|
||||
onClick={onLinkClick}
|
||||
isActive={selectionHasLink}
|
||||
isActive={hasInline('link')}
|
||||
isHidden={this.isHidden('link')}
|
||||
disabled={disabled}
|
||||
/>
|
||||
@ -158,10 +168,10 @@ export default class Toolbar extends React.Component {
|
||||
label="Headings"
|
||||
icon="hOptions"
|
||||
disabled={disabled}
|
||||
isActive={() =>
|
||||
isActive={
|
||||
!disabled &&
|
||||
Object.keys(headingOptions).some(optionKey => {
|
||||
return selectionHasBlock(optionKey);
|
||||
return hasBlock(optionKey);
|
||||
})
|
||||
}
|
||||
/>
|
||||
@ -175,8 +185,8 @@ export default class Toolbar extends React.Component {
|
||||
<DropdownItem
|
||||
key={idx}
|
||||
label={headingOptions[optionKey]}
|
||||
className={selectionHasBlock(optionKey) ? 'active' : undefined}
|
||||
onClick={() => onBlockClick(undefined, optionKey)}
|
||||
className={hasBlock(optionKey) ? 'active' : ''}
|
||||
onClick={() => this.handleBlockClick(null, optionKey)}
|
||||
/>
|
||||
),
|
||||
)}
|
||||
@ -187,26 +197,17 @@ export default class Toolbar extends React.Component {
|
||||
type="quote"
|
||||
label="Quote"
|
||||
icon="quote"
|
||||
onClick={onBlockClick}
|
||||
isActive={selectionHasBlock}
|
||||
onClick={this.handleBlockClick}
|
||||
isActive={hasBlock('quote')}
|
||||
isHidden={this.isHidden('quote')}
|
||||
disabled={disabled}
|
||||
/>
|
||||
<ToolbarButton
|
||||
type="code"
|
||||
label="Code Block"
|
||||
icon="code-block"
|
||||
onClick={onBlockClick}
|
||||
isActive={selectionHasBlock}
|
||||
isHidden={this.isHidden('code-block')}
|
||||
disabled={disabled}
|
||||
/>
|
||||
<ToolbarButton
|
||||
type="bulleted-list"
|
||||
label="Bulleted List"
|
||||
icon="list-bulleted"
|
||||
onClick={onBlockClick}
|
||||
isActive={selectionHasBlock}
|
||||
onClick={this.handleBlockClick}
|
||||
isActive={hasBlock('bulleted-list')}
|
||||
isHidden={this.isHidden('bulleted-list')}
|
||||
disabled={disabled}
|
||||
/>
|
||||
@ -214,14 +215,15 @@ export default class Toolbar extends React.Component {
|
||||
type="numbered-list"
|
||||
label="Numbered List"
|
||||
icon="list-numbered"
|
||||
onClick={onBlockClick}
|
||||
isActive={selectionHasBlock}
|
||||
onClick={this.handleBlockClick}
|
||||
isActive={hasBlock('numbered-list')}
|
||||
isHidden={this.isHidden('numbered-list')}
|
||||
disabled={disabled}
|
||||
/>
|
||||
<ToolbarDropdownWrapper>
|
||||
<Dropdown
|
||||
dropdownTopOverlap="36px"
|
||||
dropdownWidth="110px"
|
||||
renderButton={() => (
|
||||
<DropdownButton>
|
||||
<ToolbarButton
|
||||
@ -237,11 +239,7 @@ export default class Toolbar extends React.Component {
|
||||
plugins
|
||||
.toList()
|
||||
.map((plugin, idx) => (
|
||||
<DropdownItem
|
||||
key={idx}
|
||||
label={plugin.get('label')}
|
||||
onClick={() => onSubmit(plugin.get('id'))}
|
||||
/>
|
||||
<DropdownItem key={idx} label={plugin.label} onClick={() => onSubmit(plugin)} />
|
||||
))}
|
||||
</Dropdown>
|
||||
</ToolbarDropdownWrapper>
|
||||
|
@ -30,7 +30,7 @@ const ToolbarButton = ({ type, label, icon, onClick, isActive, isHidden, disable
|
||||
|
||||
return (
|
||||
<StyledToolbarButton
|
||||
isActive={isActive && type && isActive(type)}
|
||||
isActive={isActive}
|
||||
onClick={e => onClick && onClick(e, type)}
|
||||
title={label}
|
||||
disabled={disabled}
|
||||
@ -45,7 +45,7 @@ ToolbarButton.propTypes = {
|
||||
label: PropTypes.string.isRequired,
|
||||
icon: PropTypes.string,
|
||||
onClick: PropTypes.func,
|
||||
isActive: PropTypes.func,
|
||||
isActive: PropTypes.bool,
|
||||
isHidden: PropTypes.bool,
|
||||
disabled: PropTypes.bool,
|
||||
};
|
||||
|
@ -1,23 +1,38 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
import { fromJS } from 'immutable';
|
||||
import styled from '@emotion/styled';
|
||||
import { ClassNames } from '@emotion/core';
|
||||
import { get, isEmpty, debounce, uniq } from 'lodash';
|
||||
import { List } from 'immutable';
|
||||
import { css as coreCss, ClassNames } from '@emotion/core';
|
||||
import { get, isEmpty, debounce } from 'lodash';
|
||||
import { Value, Document, Block, Text } from 'slate';
|
||||
import { Editor as Slate } from 'slate-react';
|
||||
import { slateToMarkdown, markdownToSlate, htmlToSlate } from '../serializers';
|
||||
import { lengths, fonts } from 'netlify-cms-ui-default';
|
||||
import { editorStyleVars, EditorControlBar } from '../styles';
|
||||
import { slateToMarkdown, markdownToSlate } from '../serializers';
|
||||
import Toolbar from '../MarkdownControl/Toolbar';
|
||||
import { renderNode, renderMark } from './renderers';
|
||||
import { validateNode } from './validators';
|
||||
import plugins, { EditListConfigured } from './plugins';
|
||||
import onKeyDown from './keys';
|
||||
import visualEditorStyles from './visualEditorStyles';
|
||||
import { EditorControlBar } from '../styles';
|
||||
import { renderBlock, renderInline, renderMark } from './renderers';
|
||||
import plugins from './plugins/visual';
|
||||
import schema from './schema';
|
||||
|
||||
const VisualEditorContainer = styled.div`
|
||||
const visualEditorStyles = `
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
overflow-x: auto;
|
||||
font-family: ${fonts.primary};
|
||||
min-height: ${lengths.richTextEditorMinHeight};
|
||||
border-top-left-radius: 0;
|
||||
border-top-right-radius: 0;
|
||||
border-top: 0;
|
||||
margin-top: -${editorStyleVars.stickyDistanceBottom};
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
`;
|
||||
|
||||
const InsertionPoint = styled.div`
|
||||
flex: 1 1 auto;
|
||||
cursor: text;
|
||||
`;
|
||||
|
||||
const createEmptyRawDoc = () => {
|
||||
@ -26,14 +41,37 @@ const createEmptyRawDoc = () => {
|
||||
return { nodes: [emptyBlock] };
|
||||
};
|
||||
|
||||
const createSlateValue = rawValue => {
|
||||
const rawDoc = rawValue && markdownToSlate(rawValue);
|
||||
const createSlateValue = (rawValue, { voidCodeBlock }) => {
|
||||
const rawDoc = rawValue && markdownToSlate(rawValue, { voidCodeBlock });
|
||||
const rawDocHasNodes = !isEmpty(get(rawDoc, 'nodes'));
|
||||
const document = Document.fromJSON(rawDocHasNodes ? rawDoc : createEmptyRawDoc());
|
||||
return Value.create({ document });
|
||||
};
|
||||
|
||||
export default class Editor extends React.Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
const editorComponents = props.getEditorComponents();
|
||||
this.shortcodeComponents = editorComponents.filter(({ type }) => type === 'shortcode');
|
||||
this.codeBlockComponent = fromJS(editorComponents.find(({ type }) => type === 'code-block'));
|
||||
this.editorComponents =
|
||||
this.codeBlockComponent || editorComponents.has('code-block')
|
||||
? editorComponents
|
||||
: editorComponents.set('code-block', { label: 'Code Block', type: 'code-block' });
|
||||
this.renderBlock = renderBlock({
|
||||
classNameWrapper: props.className,
|
||||
resolveWidget: props.resolveWidget,
|
||||
codeBlockComponent: this.codeBlockComponent,
|
||||
});
|
||||
this.renderInline = renderInline();
|
||||
this.renderMark = renderMark();
|
||||
this.schema = schema({ voidCodeBlock: !!this.codeBlockComponent });
|
||||
this.plugins = plugins({ getAsset: props.getAsset, resolveWidget: props.resolveWidget });
|
||||
this.state = {
|
||||
value: createSlateValue(this.props.value, { voidCodeBlock: !!this.codeBlockComponent }),
|
||||
};
|
||||
}
|
||||
|
||||
static propTypes = {
|
||||
onAddAsset: PropTypes.func.isRequired,
|
||||
getAsset: PropTypes.func.isRequired,
|
||||
@ -45,249 +83,116 @@ export default class Editor extends React.Component {
|
||||
getEditorComponents: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
value: createSlateValue(props.value),
|
||||
lastRawValue: props.value,
|
||||
};
|
||||
}
|
||||
|
||||
shouldComponentUpdate(nextProps, nextState) {
|
||||
const forcePropsValue = this.shouldForcePropsValue(
|
||||
this.props.value,
|
||||
this.state.lastRawValue,
|
||||
nextProps.value,
|
||||
nextState.lastRawValue,
|
||||
);
|
||||
return !this.state.value.equals(nextState.value) || forcePropsValue;
|
||||
return !this.state.value.equals(nextState.value);
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps, prevState) {
|
||||
const forcePropsValue = this.shouldForcePropsValue(
|
||||
prevProps.value,
|
||||
prevState.lastRawValue,
|
||||
this.props.value,
|
||||
this.state.lastRawValue,
|
||||
);
|
||||
|
||||
if (forcePropsValue) {
|
||||
this.setState({
|
||||
value: createSlateValue(this.props.value),
|
||||
lastRawValue: this.props.value,
|
||||
});
|
||||
componentDidMount() {
|
||||
if (this.props.pendingFocus) {
|
||||
this.editor.focus();
|
||||
this.props.pendingFocus();
|
||||
}
|
||||
}
|
||||
|
||||
// If the old props/state values and new state value are all the same, and
|
||||
// the new props value does not match the others, the new props value
|
||||
// originated from outside of this widget and should be used.
|
||||
shouldForcePropsValue(oldPropsValue, oldStateValue, newPropsValue, newStateValue) {
|
||||
return (
|
||||
uniq([oldPropsValue, oldStateValue, newStateValue]).length === 1 &&
|
||||
oldPropsValue !== newPropsValue
|
||||
);
|
||||
}
|
||||
|
||||
handlePaste = (e, data, change) => {
|
||||
if (data.type !== 'html' || data.isShift) {
|
||||
return;
|
||||
}
|
||||
const ast = htmlToSlate(data.html);
|
||||
const doc = Document.fromJSON(ast);
|
||||
return change.insertFragment(doc);
|
||||
handleMarkClick = type => {
|
||||
this.editor.toggleMark(type).focus();
|
||||
};
|
||||
|
||||
selectionHasMark = type => this.state.value.activeMarks.some(mark => mark.type === type);
|
||||
selectionHasBlock = type => this.state.value.blocks.some(node => node.type === type);
|
||||
|
||||
handleMarkClick = (event, type) => {
|
||||
event.preventDefault();
|
||||
const resolvedChange = this.state.value
|
||||
.change()
|
||||
.focus()
|
||||
.toggleMark(type);
|
||||
this.ref.onChange(resolvedChange);
|
||||
this.setState({ value: resolvedChange.value });
|
||||
handleBlockClick = type => {
|
||||
this.editor.toggleBlock(type).focus();
|
||||
};
|
||||
|
||||
handleBlockClick = (event, type) => {
|
||||
if (event) {
|
||||
event.preventDefault();
|
||||
}
|
||||
let { value } = this.state;
|
||||
const { document: doc } = value;
|
||||
const { unwrapList, wrapInList } = EditListConfigured.changes;
|
||||
let change = value.change();
|
||||
|
||||
// Handle everything except list buttons.
|
||||
if (!['bulleted-list', 'numbered-list'].includes(type)) {
|
||||
const isActive = this.selectionHasBlock(type);
|
||||
change = change.setBlocks(isActive ? 'paragraph' : type);
|
||||
}
|
||||
|
||||
// Handle the extra wrapping required for list buttons.
|
||||
else {
|
||||
const isSameListType = value.blocks.some(block => {
|
||||
return !!doc.getClosest(block.key, parent => parent.type === type);
|
||||
});
|
||||
const isInList = EditListConfigured.utils.isSelectionInList(value);
|
||||
|
||||
if (isInList && isSameListType) {
|
||||
change = change.call(unwrapList, type);
|
||||
} else if (isInList) {
|
||||
const currentListType = type === 'bulleted-list' ? 'numbered-list' : 'bulleted-list';
|
||||
change = change.call(unwrapList, currentListType).call(wrapInList, type);
|
||||
} else {
|
||||
change = change.call(wrapInList, type);
|
||||
}
|
||||
}
|
||||
|
||||
const resolvedChange = change.focus();
|
||||
this.ref.onChange(resolvedChange);
|
||||
this.setState({ value: resolvedChange.value });
|
||||
handleLinkClick = () => {
|
||||
this.editor.toggleLink(() => window.prompt('Enter the URL of the link'));
|
||||
};
|
||||
|
||||
hasLinks = () => {
|
||||
return this.state.value.inlines.some(inline => inline.type === 'link');
|
||||
};
|
||||
hasMark = type => this.editor && this.editor.hasMark(type);
|
||||
hasInline = type => this.editor && this.editor.hasInline(type);
|
||||
hasBlock = type => this.editor && this.editor.hasBlock(type);
|
||||
|
||||
handleLink = () => {
|
||||
let change = this.state.value.change();
|
||||
|
||||
// If the current selection contains links, clicking the "link" button
|
||||
// should simply unlink them.
|
||||
if (this.hasLinks()) {
|
||||
change = change.unwrapInline('link');
|
||||
} else {
|
||||
const url = window.prompt('Enter the URL of the link');
|
||||
|
||||
// If nothing is entered in the URL prompt, do nothing.
|
||||
if (!url) return;
|
||||
|
||||
// If no text is selected, use the entered URL as text.
|
||||
if (change.value.isCollapsed) {
|
||||
change = change.insertText(url).extend(0 - url.length);
|
||||
}
|
||||
|
||||
change = change.wrapInline({ type: 'link', data: { url } }).collapseToEnd();
|
||||
}
|
||||
|
||||
this.ref.onChange(change);
|
||||
this.setState({ value: change.value });
|
||||
};
|
||||
|
||||
handlePluginAdd = pluginId => {
|
||||
const { getEditorComponents } = this.props;
|
||||
const { value } = this.state;
|
||||
const nodes = [Text.create('')];
|
||||
|
||||
/**
|
||||
* Get default values for plugin fields.
|
||||
*/
|
||||
const pluginFields = getEditorComponents().getIn([pluginId, 'fields'], List());
|
||||
const defaultValues = pluginFields
|
||||
.toMap()
|
||||
.mapKeys((_, field) => field.get('name'))
|
||||
.filter(field => field.has('default'))
|
||||
.map(field => field.get('default'));
|
||||
|
||||
/**
|
||||
* Create new shortcode block with default values set.
|
||||
*/
|
||||
const block = {
|
||||
object: 'block',
|
||||
type: 'shortcode',
|
||||
data: {
|
||||
shortcode: pluginId,
|
||||
shortcodeNew: true,
|
||||
shortcodeData: defaultValues,
|
||||
},
|
||||
isVoid: true,
|
||||
nodes,
|
||||
};
|
||||
|
||||
let change = value.change();
|
||||
const { focusBlock } = change.value;
|
||||
|
||||
if (focusBlock.text === '' && focusBlock.type === 'paragraph') {
|
||||
change = change.setNodeByKey(focusBlock.key, block);
|
||||
} else {
|
||||
change = change.insertBlock(block);
|
||||
}
|
||||
|
||||
change = change.focus();
|
||||
|
||||
this.ref.onChange(change);
|
||||
this.setState({ value: change.value });
|
||||
};
|
||||
|
||||
handleToggle = () => {
|
||||
handleToggleMode = () => {
|
||||
this.props.onMode('raw');
|
||||
};
|
||||
|
||||
handleDocumentChange = debounce(change => {
|
||||
handleInsertShortcode = pluginConfig => {
|
||||
this.editor.insertShortcode(pluginConfig);
|
||||
};
|
||||
|
||||
handleClickBelowDocument = () => {
|
||||
this.editor.moveToEndOfDocument();
|
||||
};
|
||||
|
||||
handleDocumentChange = debounce(editor => {
|
||||
const { onChange } = this.props;
|
||||
const raw = change.value.document.toJSON();
|
||||
const markdown = slateToMarkdown(raw);
|
||||
this.setState({ lastRawValue: markdown }, () => onChange(markdown));
|
||||
const raw = editor.value.document.toJS();
|
||||
const markdown = slateToMarkdown(raw, { voidCodeBlock: this.codeBlockComponent });
|
||||
onChange(markdown);
|
||||
}, 150);
|
||||
|
||||
handleChange = change => {
|
||||
if (!this.state.value.document.equals(change.value.document)) {
|
||||
this.handleDocumentChange(change);
|
||||
handleChange = editor => {
|
||||
if (!this.state.value.document.equals(editor.value.document)) {
|
||||
this.handleDocumentChange(editor);
|
||||
}
|
||||
this.setState({ value: change.value });
|
||||
this.setState({ value: editor.value });
|
||||
};
|
||||
|
||||
processRef = ref => {
|
||||
this.ref = ref;
|
||||
this.editor = ref;
|
||||
};
|
||||
|
||||
render() {
|
||||
const { onAddAsset, getAsset, className, field, getEditorComponents } = this.props;
|
||||
|
||||
const { onAddAsset, getAsset, className, field } = this.props;
|
||||
return (
|
||||
<VisualEditorContainer>
|
||||
<div
|
||||
css={coreCss`
|
||||
position: relative;
|
||||
`}
|
||||
>
|
||||
<EditorControlBar>
|
||||
<Toolbar
|
||||
onMarkClick={this.handleMarkClick}
|
||||
onBlockClick={this.handleBlockClick}
|
||||
onLinkClick={this.handleLink}
|
||||
selectionHasMark={this.selectionHasMark}
|
||||
selectionHasBlock={this.selectionHasBlock}
|
||||
selectionHasLink={this.hasLinks}
|
||||
onToggleMode={this.handleToggle}
|
||||
plugins={getEditorComponents()}
|
||||
onSubmit={this.handlePluginAdd}
|
||||
onLinkClick={this.handleLinkClick}
|
||||
onToggleMode={this.handleToggleMode}
|
||||
plugins={this.editorComponents}
|
||||
onSubmit={this.handleInsertShortcode}
|
||||
onAddAsset={onAddAsset}
|
||||
getAsset={getAsset}
|
||||
buttons={field.get('buttons')}
|
||||
hasMark={this.hasMark}
|
||||
hasInline={this.hasInline}
|
||||
hasBlock={this.hasBlock}
|
||||
/>
|
||||
</EditorControlBar>
|
||||
<ClassNames>
|
||||
{({ css, cx }) => (
|
||||
<Slate
|
||||
<div
|
||||
className={cx(
|
||||
className,
|
||||
css`
|
||||
${visualEditorStyles}
|
||||
`,
|
||||
)}
|
||||
value={this.state.value}
|
||||
renderNode={renderNode}
|
||||
renderMark={renderMark}
|
||||
validateNode={validateNode}
|
||||
plugins={plugins}
|
||||
onChange={this.handleChange}
|
||||
onKeyDown={onKeyDown}
|
||||
onPaste={this.handlePaste}
|
||||
ref={this.processRef}
|
||||
spellCheck
|
||||
/>
|
||||
>
|
||||
<Slate
|
||||
className={css`
|
||||
padding: 16px 20px 0;
|
||||
`}
|
||||
value={this.state.value}
|
||||
renderBlock={this.renderBlock}
|
||||
renderInline={this.renderInline}
|
||||
renderMark={this.renderMark}
|
||||
schema={this.schema}
|
||||
plugins={this.plugins}
|
||||
onChange={this.handleChange}
|
||||
ref={this.processRef}
|
||||
spellCheck
|
||||
/>
|
||||
<InsertionPoint onClick={this.handleClickBelowDocument} />
|
||||
</div>
|
||||
)}
|
||||
</ClassNames>
|
||||
</VisualEditorContainer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
File diff suppressed because it is too large
Load Diff
@ -9,7 +9,34 @@ describe('Compile markdown to Slate Raw AST', () => {
|
||||
|
||||
sweet body
|
||||
`;
|
||||
expect(parser(value)).toMatchSnapshot();
|
||||
expect(parser(value)).toMatchInlineSnapshot(`
|
||||
Object {
|
||||
"nodes": Array [
|
||||
Object {
|
||||
"nodes": Array [
|
||||
Object {
|
||||
"object": "text",
|
||||
"text": "H1",
|
||||
},
|
||||
],
|
||||
"object": "block",
|
||||
"type": "heading-one",
|
||||
},
|
||||
Object {
|
||||
"nodes": Array [
|
||||
Object {
|
||||
"object": "text",
|
||||
"text": "sweet body",
|
||||
},
|
||||
],
|
||||
"object": "block",
|
||||
"type": "paragraph",
|
||||
},
|
||||
],
|
||||
"object": "block",
|
||||
"type": "root",
|
||||
}
|
||||
`);
|
||||
});
|
||||
|
||||
it('should compile a markdown ordered list', () => {
|
||||
@ -20,7 +47,81 @@ sweet body
|
||||
2. bro
|
||||
3. fro
|
||||
`;
|
||||
expect(parser(value)).toMatchSnapshot();
|
||||
expect(parser(value)).toMatchInlineSnapshot(`
|
||||
Object {
|
||||
"nodes": Array [
|
||||
Object {
|
||||
"nodes": Array [
|
||||
Object {
|
||||
"object": "text",
|
||||
"text": "H1",
|
||||
},
|
||||
],
|
||||
"object": "block",
|
||||
"type": "heading-one",
|
||||
},
|
||||
Object {
|
||||
"data": Object {
|
||||
"start": 1,
|
||||
},
|
||||
"nodes": Array [
|
||||
Object {
|
||||
"nodes": Array [
|
||||
Object {
|
||||
"nodes": Array [
|
||||
Object {
|
||||
"object": "text",
|
||||
"text": "yo",
|
||||
},
|
||||
],
|
||||
"object": "block",
|
||||
"type": "paragraph",
|
||||
},
|
||||
],
|
||||
"object": "block",
|
||||
"type": "list-item",
|
||||
},
|
||||
Object {
|
||||
"nodes": Array [
|
||||
Object {
|
||||
"nodes": Array [
|
||||
Object {
|
||||
"object": "text",
|
||||
"text": "bro",
|
||||
},
|
||||
],
|
||||
"object": "block",
|
||||
"type": "paragraph",
|
||||
},
|
||||
],
|
||||
"object": "block",
|
||||
"type": "list-item",
|
||||
},
|
||||
Object {
|
||||
"nodes": Array [
|
||||
Object {
|
||||
"nodes": Array [
|
||||
Object {
|
||||
"object": "text",
|
||||
"text": "fro",
|
||||
},
|
||||
],
|
||||
"object": "block",
|
||||
"type": "paragraph",
|
||||
},
|
||||
],
|
||||
"object": "block",
|
||||
"type": "list-item",
|
||||
},
|
||||
],
|
||||
"object": "block",
|
||||
"type": "numbered-list",
|
||||
},
|
||||
],
|
||||
"object": "block",
|
||||
"type": "root",
|
||||
}
|
||||
`);
|
||||
});
|
||||
|
||||
it('should compile bulleted lists', () => {
|
||||
@ -31,7 +132,81 @@ sweet body
|
||||
* bro
|
||||
* fro
|
||||
`;
|
||||
expect(parser(value)).toMatchSnapshot();
|
||||
expect(parser(value)).toMatchInlineSnapshot(`
|
||||
Object {
|
||||
"nodes": Array [
|
||||
Object {
|
||||
"nodes": Array [
|
||||
Object {
|
||||
"object": "text",
|
||||
"text": "H1",
|
||||
},
|
||||
],
|
||||
"object": "block",
|
||||
"type": "heading-one",
|
||||
},
|
||||
Object {
|
||||
"data": Object {
|
||||
"start": null,
|
||||
},
|
||||
"nodes": Array [
|
||||
Object {
|
||||
"nodes": Array [
|
||||
Object {
|
||||
"nodes": Array [
|
||||
Object {
|
||||
"object": "text",
|
||||
"text": "yo",
|
||||
},
|
||||
],
|
||||
"object": "block",
|
||||
"type": "paragraph",
|
||||
},
|
||||
],
|
||||
"object": "block",
|
||||
"type": "list-item",
|
||||
},
|
||||
Object {
|
||||
"nodes": Array [
|
||||
Object {
|
||||
"nodes": Array [
|
||||
Object {
|
||||
"object": "text",
|
||||
"text": "bro",
|
||||
},
|
||||
],
|
||||
"object": "block",
|
||||
"type": "paragraph",
|
||||
},
|
||||
],
|
||||
"object": "block",
|
||||
"type": "list-item",
|
||||
},
|
||||
Object {
|
||||
"nodes": Array [
|
||||
Object {
|
||||
"nodes": Array [
|
||||
Object {
|
||||
"object": "text",
|
||||
"text": "fro",
|
||||
},
|
||||
],
|
||||
"object": "block",
|
||||
"type": "paragraph",
|
||||
},
|
||||
],
|
||||
"object": "block",
|
||||
"type": "list-item",
|
||||
},
|
||||
],
|
||||
"object": "block",
|
||||
"type": "bulleted-list",
|
||||
},
|
||||
],
|
||||
"object": "block",
|
||||
"type": "root",
|
||||
}
|
||||
`);
|
||||
});
|
||||
|
||||
it('should compile multiple header levels', () => {
|
||||
@ -42,7 +217,44 @@ sweet body
|
||||
|
||||
### H3
|
||||
`;
|
||||
expect(parser(value)).toMatchSnapshot();
|
||||
expect(parser(value)).toMatchInlineSnapshot(`
|
||||
Object {
|
||||
"nodes": Array [
|
||||
Object {
|
||||
"nodes": Array [
|
||||
Object {
|
||||
"object": "text",
|
||||
"text": "H1",
|
||||
},
|
||||
],
|
||||
"object": "block",
|
||||
"type": "heading-one",
|
||||
},
|
||||
Object {
|
||||
"nodes": Array [
|
||||
Object {
|
||||
"object": "text",
|
||||
"text": "H2",
|
||||
},
|
||||
],
|
||||
"object": "block",
|
||||
"type": "heading-two",
|
||||
},
|
||||
Object {
|
||||
"nodes": Array [
|
||||
Object {
|
||||
"object": "text",
|
||||
"text": "H3",
|
||||
},
|
||||
],
|
||||
"object": "block",
|
||||
"type": "heading-three",
|
||||
},
|
||||
],
|
||||
"object": "block",
|
||||
"type": "root",
|
||||
}
|
||||
`);
|
||||
});
|
||||
|
||||
it('should compile horizontal rules', () => {
|
||||
@ -53,7 +265,38 @@ sweet body
|
||||
|
||||
blue moon
|
||||
`;
|
||||
expect(parser(value)).toMatchSnapshot();
|
||||
expect(parser(value)).toMatchInlineSnapshot(`
|
||||
Object {
|
||||
"nodes": Array [
|
||||
Object {
|
||||
"nodes": Array [
|
||||
Object {
|
||||
"object": "text",
|
||||
"text": "H1",
|
||||
},
|
||||
],
|
||||
"object": "block",
|
||||
"type": "heading-one",
|
||||
},
|
||||
Object {
|
||||
"object": "block",
|
||||
"type": "thematic-break",
|
||||
},
|
||||
Object {
|
||||
"nodes": Array [
|
||||
Object {
|
||||
"object": "text",
|
||||
"text": "blue moon",
|
||||
},
|
||||
],
|
||||
"object": "block",
|
||||
"type": "paragraph",
|
||||
},
|
||||
],
|
||||
"object": "block",
|
||||
"type": "root",
|
||||
}
|
||||
`);
|
||||
});
|
||||
|
||||
it('should compile horizontal rules', () => {
|
||||
@ -64,7 +307,38 @@ blue moon
|
||||
|
||||
blue moon
|
||||
`;
|
||||
expect(parser(value)).toMatchSnapshot();
|
||||
expect(parser(value)).toMatchInlineSnapshot(`
|
||||
Object {
|
||||
"nodes": Array [
|
||||
Object {
|
||||
"nodes": Array [
|
||||
Object {
|
||||
"object": "text",
|
||||
"text": "H1",
|
||||
},
|
||||
],
|
||||
"object": "block",
|
||||
"type": "heading-one",
|
||||
},
|
||||
Object {
|
||||
"object": "block",
|
||||
"type": "thematic-break",
|
||||
},
|
||||
Object {
|
||||
"nodes": Array [
|
||||
Object {
|
||||
"object": "text",
|
||||
"text": "blue moon",
|
||||
},
|
||||
],
|
||||
"object": "block",
|
||||
"type": "paragraph",
|
||||
},
|
||||
],
|
||||
"object": "block",
|
||||
"type": "root",
|
||||
}
|
||||
`);
|
||||
});
|
||||
|
||||
it('should compile soft breaks (double space)', () => {
|
||||
@ -72,14 +346,62 @@ blue moon
|
||||
blue moon
|
||||
footballs
|
||||
`;
|
||||
expect(parser(value)).toMatchSnapshot();
|
||||
expect(parser(value)).toMatchInlineSnapshot(`
|
||||
Object {
|
||||
"nodes": Array [
|
||||
Object {
|
||||
"nodes": Array [
|
||||
Object {
|
||||
"object": "text",
|
||||
"text": "blue moon",
|
||||
},
|
||||
Object {
|
||||
"data": undefined,
|
||||
"object": "inline",
|
||||
"type": "break",
|
||||
},
|
||||
Object {
|
||||
"object": "text",
|
||||
"text": "footballs",
|
||||
},
|
||||
],
|
||||
"object": "block",
|
||||
"type": "paragraph",
|
||||
},
|
||||
],
|
||||
"object": "block",
|
||||
"type": "root",
|
||||
}
|
||||
`);
|
||||
});
|
||||
|
||||
it('should compile images', () => {
|
||||
const value = `
|
||||
![super](duper.jpg)
|
||||
`;
|
||||
expect(parser(value)).toMatchSnapshot();
|
||||
expect(parser(value)).toMatchInlineSnapshot(`
|
||||
Object {
|
||||
"nodes": Array [
|
||||
Object {
|
||||
"nodes": Array [
|
||||
Object {
|
||||
"data": Object {
|
||||
"alt": "super",
|
||||
"title": null,
|
||||
"url": "duper.jpg",
|
||||
},
|
||||
"object": "inline",
|
||||
"type": "image",
|
||||
},
|
||||
],
|
||||
"object": "block",
|
||||
"type": "paragraph",
|
||||
},
|
||||
],
|
||||
"object": "block",
|
||||
"type": "root",
|
||||
}
|
||||
`);
|
||||
});
|
||||
|
||||
it('should compile code blocks', () => {
|
||||
@ -88,7 +410,27 @@ footballs
|
||||
var a = 1;
|
||||
\`\`\`
|
||||
`;
|
||||
expect(parser(value)).toMatchSnapshot();
|
||||
expect(parser(value)).toMatchInlineSnapshot(`
|
||||
Object {
|
||||
"nodes": Array [
|
||||
Object {
|
||||
"data": Object {
|
||||
"lang": "javascript",
|
||||
},
|
||||
"nodes": Array [
|
||||
Object {
|
||||
"object": "text",
|
||||
"text": "var a = 1;",
|
||||
},
|
||||
],
|
||||
"object": "block",
|
||||
"type": "code-block",
|
||||
},
|
||||
],
|
||||
"object": "block",
|
||||
"type": "root",
|
||||
}
|
||||
`);
|
||||
});
|
||||
|
||||
it('should compile nested inline markup', () => {
|
||||
@ -99,7 +441,87 @@ This is **some *hot* content**
|
||||
|
||||
perhaps **scalding** even
|
||||
`;
|
||||
expect(parser(value)).toMatchSnapshot();
|
||||
expect(parser(value)).toMatchInlineSnapshot(`
|
||||
Object {
|
||||
"nodes": Array [
|
||||
Object {
|
||||
"nodes": Array [
|
||||
Object {
|
||||
"object": "text",
|
||||
"text": "Word",
|
||||
},
|
||||
],
|
||||
"object": "block",
|
||||
"type": "heading-one",
|
||||
},
|
||||
Object {
|
||||
"nodes": Array [
|
||||
Object {
|
||||
"object": "text",
|
||||
"text": "This is ",
|
||||
},
|
||||
Object {
|
||||
"marks": Array [
|
||||
Object {
|
||||
"type": "bold",
|
||||
},
|
||||
],
|
||||
"object": "text",
|
||||
"text": "some ",
|
||||
},
|
||||
Object {
|
||||
"marks": Array [
|
||||
Object {
|
||||
"type": "bold",
|
||||
},
|
||||
Object {
|
||||
"type": "italic",
|
||||
},
|
||||
],
|
||||
"object": "text",
|
||||
"text": "hot",
|
||||
},
|
||||
Object {
|
||||
"marks": Array [
|
||||
Object {
|
||||
"type": "bold",
|
||||
},
|
||||
],
|
||||
"object": "text",
|
||||
"text": " content",
|
||||
},
|
||||
],
|
||||
"object": "block",
|
||||
"type": "paragraph",
|
||||
},
|
||||
Object {
|
||||
"nodes": Array [
|
||||
Object {
|
||||
"object": "text",
|
||||
"text": "perhaps ",
|
||||
},
|
||||
Object {
|
||||
"marks": Array [
|
||||
Object {
|
||||
"type": "bold",
|
||||
},
|
||||
],
|
||||
"object": "text",
|
||||
"text": "scalding",
|
||||
},
|
||||
Object {
|
||||
"object": "text",
|
||||
"text": " even",
|
||||
},
|
||||
],
|
||||
"object": "block",
|
||||
"type": "paragraph",
|
||||
},
|
||||
],
|
||||
"object": "block",
|
||||
"type": "root",
|
||||
}
|
||||
`);
|
||||
});
|
||||
|
||||
it('should compile inline code', () => {
|
||||
@ -108,7 +530,47 @@ perhaps **scalding** even
|
||||
|
||||
This is some sweet \`inline code\` yo!
|
||||
`;
|
||||
expect(parser(value)).toMatchSnapshot();
|
||||
expect(parser(value)).toMatchInlineSnapshot(`
|
||||
Object {
|
||||
"nodes": Array [
|
||||
Object {
|
||||
"nodes": Array [
|
||||
Object {
|
||||
"object": "text",
|
||||
"text": "Word",
|
||||
},
|
||||
],
|
||||
"object": "block",
|
||||
"type": "heading-one",
|
||||
},
|
||||
Object {
|
||||
"nodes": Array [
|
||||
Object {
|
||||
"object": "text",
|
||||
"text": "This is some sweet ",
|
||||
},
|
||||
Object {
|
||||
"marks": Array [
|
||||
Object {
|
||||
"type": "code",
|
||||
},
|
||||
],
|
||||
"object": "text",
|
||||
"text": "inline code",
|
||||
},
|
||||
Object {
|
||||
"object": "text",
|
||||
"text": " yo!",
|
||||
},
|
||||
],
|
||||
"object": "block",
|
||||
"type": "paragraph",
|
||||
},
|
||||
],
|
||||
"object": "block",
|
||||
"type": "root",
|
||||
}
|
||||
`);
|
||||
});
|
||||
|
||||
it('should compile links', () => {
|
||||
@ -117,7 +579,52 @@ This is some sweet \`inline code\` yo!
|
||||
|
||||
How far is it to [Google](https://google.com) land?
|
||||
`;
|
||||
expect(parser(value)).toMatchSnapshot();
|
||||
expect(parser(value)).toMatchInlineSnapshot(`
|
||||
Object {
|
||||
"nodes": Array [
|
||||
Object {
|
||||
"nodes": Array [
|
||||
Object {
|
||||
"object": "text",
|
||||
"text": "Word",
|
||||
},
|
||||
],
|
||||
"object": "block",
|
||||
"type": "heading-one",
|
||||
},
|
||||
Object {
|
||||
"nodes": Array [
|
||||
Object {
|
||||
"object": "text",
|
||||
"text": "How far is it to ",
|
||||
},
|
||||
Object {
|
||||
"data": Object {
|
||||
"title": null,
|
||||
"url": "https://google.com",
|
||||
},
|
||||
"nodes": Array [
|
||||
Object {
|
||||
"object": "text",
|
||||
"text": "Google",
|
||||
},
|
||||
],
|
||||
"object": "inline",
|
||||
"type": "link",
|
||||
},
|
||||
Object {
|
||||
"object": "text",
|
||||
"text": " land?",
|
||||
},
|
||||
],
|
||||
"object": "block",
|
||||
"type": "paragraph",
|
||||
},
|
||||
],
|
||||
"object": "block",
|
||||
"type": "root",
|
||||
}
|
||||
`);
|
||||
});
|
||||
|
||||
it('should compile plugins', () => {
|
||||
@ -126,7 +633,39 @@ How far is it to [Google](https://google.com) land?
|
||||
|
||||
{{< test >}}
|
||||
`;
|
||||
expect(parser(value)).toMatchSnapshot();
|
||||
expect(parser(value)).toMatchInlineSnapshot(`
|
||||
Object {
|
||||
"nodes": Array [
|
||||
Object {
|
||||
"nodes": Array [
|
||||
Object {
|
||||
"data": Object {
|
||||
"alt": "test",
|
||||
"title": null,
|
||||
"url": "test.png",
|
||||
},
|
||||
"object": "inline",
|
||||
"type": "image",
|
||||
},
|
||||
],
|
||||
"object": "block",
|
||||
"type": "paragraph",
|
||||
},
|
||||
Object {
|
||||
"nodes": Array [
|
||||
Object {
|
||||
"object": "text",
|
||||
"text": "{{< test >}}",
|
||||
},
|
||||
],
|
||||
"object": "block",
|
||||
"type": "paragraph",
|
||||
},
|
||||
],
|
||||
"object": "block",
|
||||
"type": "root",
|
||||
}
|
||||
`);
|
||||
});
|
||||
|
||||
it('should compile kitchen sink example', () => {
|
||||
|
@ -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);
|
||||
});
|
||||
});
|
@ -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>
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
@ -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;
|
@ -6,6 +6,8 @@ import VisualEditor from './VisualEditor';
|
||||
|
||||
const MODE_STORAGE_KEY = 'cms.md-mode';
|
||||
|
||||
// TODO: passing the editorControl and components like this is horrible, should
|
||||
// be handled through Redux and a separate registry store for instances
|
||||
let editorControl;
|
||||
let _getEditorComponents = () => [];
|
||||
|
||||
@ -32,16 +34,23 @@ export default class MarkdownControl extends React.Component {
|
||||
super(props);
|
||||
editorControl = props.editorControl;
|
||||
_getEditorComponents = props.getEditorComponents;
|
||||
this.state = { mode: localStorage.getItem(MODE_STORAGE_KEY) || 'visual' };
|
||||
this.state = {
|
||||
mode: localStorage.getItem(MODE_STORAGE_KEY) || 'visual',
|
||||
pendingFocus: false,
|
||||
};
|
||||
}
|
||||
|
||||
handleMode = mode => {
|
||||
this.setState({ mode });
|
||||
this.setState({ mode, pendingFocus: true });
|
||||
localStorage.setItem(MODE_STORAGE_KEY, mode);
|
||||
};
|
||||
|
||||
processRef = ref => (this.ref = ref);
|
||||
|
||||
setFocusReceived = () => {
|
||||
this.setState({ pendingFocus: false });
|
||||
};
|
||||
|
||||
render() {
|
||||
const {
|
||||
onChange,
|
||||
@ -51,9 +60,10 @@ export default class MarkdownControl extends React.Component {
|
||||
classNameWrapper,
|
||||
field,
|
||||
getEditorComponents,
|
||||
resolveWidget,
|
||||
} = this.props;
|
||||
|
||||
const { mode } = this.state;
|
||||
const { mode, pendingFocus } = this.state;
|
||||
const visualEditor = (
|
||||
<div className="cms-editor-visual" ref={this.processRef}>
|
||||
<VisualEditor
|
||||
@ -65,6 +75,8 @@ export default class MarkdownControl extends React.Component {
|
||||
value={value}
|
||||
field={field}
|
||||
getEditorComponents={getEditorComponents}
|
||||
resolveWidget={resolveWidget}
|
||||
pendingFocus={pendingFocus && this.setFocusReceived}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
@ -78,6 +90,7 @@ export default class MarkdownControl extends React.Component {
|
||||
className={classNameWrapper}
|
||||
value={value}
|
||||
field={field}
|
||||
pendingFocus={pendingFocus && this.setFocusReceived}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
@ -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);
|
||||
}
|
||||
}
|
@ -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;
|
@ -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;
|
@ -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;
|
@ -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;
|
@ -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;
|
@ -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;
|
@ -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;
|
@ -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;
|
@ -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;
|
@ -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;
|
@ -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;
|
@ -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;
|
@ -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;
|
||||
}
|
@ -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;
|
@ -1,7 +1,118 @@
|
||||
/* eslint-disable react/prop-types */
|
||||
|
||||
/* eslint-disable react/display-name */
|
||||
import React from 'react';
|
||||
import Shortcode from './Shortcode';
|
||||
import { css } from '@emotion/core';
|
||||
import styled from '@emotion/styled';
|
||||
import { colors, lengths } from 'netlify-cms-ui-default';
|
||||
import VoidBlock from './components/VoidBlock';
|
||||
import Shortcode from './components/Shortcode';
|
||||
|
||||
const bottomMargin = '16px';
|
||||
|
||||
const headerStyles = `
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
`;
|
||||
|
||||
const StyledH1 = styled.h1`
|
||||
${headerStyles};
|
||||
font-size: 32px;
|
||||
margin-top: 16px;
|
||||
`;
|
||||
|
||||
const StyledH2 = styled.h2`
|
||||
${headerStyles};
|
||||
font-size: 24px;
|
||||
margin-top: 12px;
|
||||
`;
|
||||
|
||||
const StyledH3 = styled.h3`
|
||||
${headerStyles};
|
||||
font-size: 20px;
|
||||
`;
|
||||
|
||||
const StyledH4 = styled.h4`
|
||||
${headerStyles};
|
||||
font-size: 18px;
|
||||
margin-top: 8px;
|
||||
`;
|
||||
|
||||
const StyledH5 = styled.h5`
|
||||
${headerStyles};
|
||||
font-size: 16px;
|
||||
margin-top: 8px;
|
||||
`;
|
||||
|
||||
const StyledH6 = StyledH5.withComponent('h6');
|
||||
|
||||
const StyledP = styled.p`
|
||||
margin-bottom: ${bottomMargin};
|
||||
`;
|
||||
|
||||
const StyledBlockQuote = styled.blockquote`
|
||||
padding-left: 16px;
|
||||
border-left: 3px solid ${colors.background};
|
||||
margin-left: 0;
|
||||
margin-right: 0;
|
||||
margin-bottom: ${bottomMargin};
|
||||
`;
|
||||
|
||||
const StyledPre = styled.pre`
|
||||
margin-bottom: ${bottomMargin};
|
||||
white-space: pre-wrap;
|
||||
|
||||
& > code {
|
||||
display: block;
|
||||
width: 100%;
|
||||
overflow-y: auto;
|
||||
background-color: #000;
|
||||
color: #ccc;
|
||||
border-radius: ${lengths.borderRadius};
|
||||
padding: 10px;
|
||||
}
|
||||
`;
|
||||
|
||||
const StyledCode = styled.code`
|
||||
background-color: ${colors.background};
|
||||
border-radius: ${lengths.borderRadius};
|
||||
padding: 0 2px;
|
||||
font-size: 85%;
|
||||
`;
|
||||
|
||||
const StyledUl = styled.ul`
|
||||
margin-bottom: ${bottomMargin};
|
||||
padding-left: 30px;
|
||||
`;
|
||||
|
||||
const StyledOl = StyledUl.withComponent('ol');
|
||||
|
||||
const StyledLi = styled.li`
|
||||
& > p:first-child {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
& > p:last-child {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
`;
|
||||
|
||||
const StyledA = styled.a`
|
||||
text-decoration: underline;
|
||||
`;
|
||||
|
||||
const StyledHr = styled.hr`
|
||||
border: 1px solid;
|
||||
margin-bottom: 16px;
|
||||
`;
|
||||
|
||||
const StyledTable = styled.table`
|
||||
border-collapse: collapse;
|
||||
`;
|
||||
|
||||
const StyledTd = styled.td`
|
||||
border: 2px solid black;
|
||||
padding: 8px;
|
||||
text-align: left;
|
||||
`;
|
||||
|
||||
/**
|
||||
* Slate uses React components to render each type of node that it receives.
|
||||
@ -15,56 +126,63 @@ import Shortcode from './Shortcode';
|
||||
const Bold = props => <strong>{props.children}</strong>;
|
||||
const Italic = props => <em>{props.children}</em>;
|
||||
const Strikethrough = props => <s>{props.children}</s>;
|
||||
const Code = props => <code>{props.children}</code>;
|
||||
const Code = props => <StyledCode>{props.children}</StyledCode>;
|
||||
|
||||
/**
|
||||
* Node Components
|
||||
*/
|
||||
const Paragraph = props => <p {...props.attributes}>{props.children}</p>;
|
||||
const ListItem = props => <li {...props.attributes}>{props.children}</li>;
|
||||
const Quote = props => <blockquote {...props.attributes}>{props.children}</blockquote>;
|
||||
const Paragraph = props => <StyledP {...props.attributes}>{props.children}</StyledP>;
|
||||
const ListItem = props => <StyledLi {...props.attributes}>{props.children}</StyledLi>;
|
||||
const Quote = props => <StyledBlockQuote {...props.attributes}>{props.children}</StyledBlockQuote>;
|
||||
const CodeBlock = props => (
|
||||
<pre>
|
||||
<code {...props.attributes}>{props.children}</code>
|
||||
</pre>
|
||||
<StyledPre>
|
||||
<StyledCode {...props.attributes}>{props.children}</StyledCode>
|
||||
</StyledPre>
|
||||
);
|
||||
const HeadingOne = props => <h1 {...props.attributes}>{props.children}</h1>;
|
||||
const HeadingTwo = props => <h2 {...props.attributes}>{props.children}</h2>;
|
||||
const HeadingThree = props => <h3 {...props.attributes}>{props.children}</h3>;
|
||||
const HeadingFour = props => <h4 {...props.attributes}>{props.children}</h4>;
|
||||
const HeadingFive = props => <h5 {...props.attributes}>{props.children}</h5>;
|
||||
const HeadingSix = props => <h6 {...props.attributes}>{props.children}</h6>;
|
||||
const HeadingOne = props => <StyledH1 {...props.attributes}>{props.children}</StyledH1>;
|
||||
const HeadingTwo = props => <StyledH2 {...props.attributes}>{props.children}</StyledH2>;
|
||||
const HeadingThree = props => <StyledH3 {...props.attributes}>{props.children}</StyledH3>;
|
||||
const HeadingFour = props => <StyledH4 {...props.attributes}>{props.children}</StyledH4>;
|
||||
const HeadingFive = props => <StyledH5 {...props.attributes}>{props.children}</StyledH5>;
|
||||
const HeadingSix = props => <StyledH6 {...props.attributes}>{props.children}</StyledH6>;
|
||||
const Table = props => (
|
||||
<table>
|
||||
<StyledTable>
|
||||
<tbody {...props.attributes}>{props.children}</tbody>
|
||||
</table>
|
||||
</StyledTable>
|
||||
);
|
||||
const TableRow = props => <tr {...props.attributes}>{props.children}</tr>;
|
||||
const TableCell = props => <td {...props.attributes}>{props.children}</td>;
|
||||
const ThematicBreak = props => <hr {...props.attributes} />;
|
||||
const BulletedList = props => <ul {...props.attributes}>{props.children}</ul>;
|
||||
const TableCell = props => <StyledTd {...props.attributes}>{props.children}</StyledTd>;
|
||||
const ThematicBreak = props => (
|
||||
<StyledHr
|
||||
{...props.attributes}
|
||||
css={
|
||||
props.editor.isSelected(props.node) &&
|
||||
css`
|
||||
box-shadow: 0 0 0 2px ${colors.active};
|
||||
border-radius: 8px;
|
||||
color: ${colors.active};
|
||||
`
|
||||
}
|
||||
/>
|
||||
);
|
||||
const Break = props => <br {...props.attributes} />;
|
||||
const BulletedList = props => <StyledUl {...props.attributes}>{props.children}</StyledUl>;
|
||||
const NumberedList = props => (
|
||||
<ol {...props.attributes} start={props.node.data.get('start') || 1}>
|
||||
<StyledOl {...props.attributes} start={props.node.data.get('start') || 1}>
|
||||
{props.children}
|
||||
</ol>
|
||||
</StyledOl>
|
||||
);
|
||||
const Link = props => {
|
||||
const data = props.node.get('data');
|
||||
const marks = data.get('marks');
|
||||
const url = data.get('url');
|
||||
const title = data.get('title');
|
||||
const link = (
|
||||
<a href={url} title={title} {...props.attributes}>
|
||||
return (
|
||||
<StyledA href={url} title={title} {...props.attributes}>
|
||||
{props.children}
|
||||
</a>
|
||||
</StyledA>
|
||||
);
|
||||
const result = !marks
|
||||
? link
|
||||
: marks.reduce((acc, mark) => {
|
||||
return renderMark({ mark, children: acc });
|
||||
}, link);
|
||||
return result;
|
||||
};
|
||||
|
||||
const Image = props => {
|
||||
const data = props.node.get('data');
|
||||
const marks = data.get('marks');
|
||||
@ -80,7 +198,7 @@ const Image = props => {
|
||||
return result;
|
||||
};
|
||||
|
||||
export const renderMark = props => {
|
||||
export const renderMark = () => props => {
|
||||
switch (props.mark.type) {
|
||||
case 'bold':
|
||||
return <Bold {...props} />;
|
||||
@ -93,7 +211,18 @@ export const renderMark = props => {
|
||||
}
|
||||
};
|
||||
|
||||
export const renderNode = props => {
|
||||
export const renderInline = () => props => {
|
||||
switch (props.node.type) {
|
||||
case 'link':
|
||||
return <Link {...props} />;
|
||||
case 'image':
|
||||
return <Image {...props} />;
|
||||
case 'break':
|
||||
return <Break {...props} />;
|
||||
}
|
||||
};
|
||||
|
||||
export const renderBlock = ({ classNameWrapper, codeBlockComponent }) => props => {
|
||||
switch (props.node.type) {
|
||||
case 'paragraph':
|
||||
return <Paragraph {...props} />;
|
||||
@ -101,7 +230,19 @@ export const renderNode = props => {
|
||||
return <ListItem {...props} />;
|
||||
case 'quote':
|
||||
return <Quote {...props} />;
|
||||
case 'code':
|
||||
case 'code-block':
|
||||
if (codeBlockComponent) {
|
||||
return (
|
||||
<VoidBlock {...props}>
|
||||
<Shortcode
|
||||
classNameWrapper={classNameWrapper}
|
||||
typeOverload="code-block"
|
||||
dataKey={false}
|
||||
{...props}
|
||||
/>
|
||||
</VoidBlock>
|
||||
);
|
||||
}
|
||||
return <CodeBlock {...props} />;
|
||||
case 'heading-one':
|
||||
return <HeadingOne {...props} />;
|
||||
@ -122,16 +263,20 @@ export const renderNode = props => {
|
||||
case 'table-cell':
|
||||
return <TableCell {...props} />;
|
||||
case 'thematic-break':
|
||||
return <ThematicBreak {...props} />;
|
||||
return (
|
||||
<VoidBlock {...props}>
|
||||
<ThematicBreak editor={props.editor} node={props.node} />
|
||||
</VoidBlock>
|
||||
);
|
||||
case 'bulleted-list':
|
||||
return <BulletedList {...props} />;
|
||||
case 'numbered-list':
|
||||
return <NumberedList {...props} />;
|
||||
case 'link':
|
||||
return <Link {...props} />;
|
||||
case 'image':
|
||||
return <Image {...props} />;
|
||||
case 'shortcode':
|
||||
return <Shortcode {...props} />;
|
||||
return (
|
||||
<VoidBlock {...props}>
|
||||
<Shortcode classNameWrapper={classNameWrapper} {...props} />
|
||||
</VoidBlock>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
@ -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;
|
@ -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),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
@ -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;
|
||||
}
|
||||
`;
|
@ -1,18 +1,29 @@
|
||||
import React from 'react';
|
||||
import React, { useEffect } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { WidgetPreviewContainer } from 'netlify-cms-ui-default';
|
||||
import { markdownToHtml } from './serializers';
|
||||
|
||||
const MarkdownPreview = ({ value, getAsset }) => {
|
||||
let editorPreview;
|
||||
|
||||
export const getEditorPreview = () => editorPreview;
|
||||
|
||||
const MarkdownPreview = props => {
|
||||
const { value, getAsset, resolveWidget } = props;
|
||||
useEffect(() => {
|
||||
editorPreview = props.editorPreview;
|
||||
}, []);
|
||||
|
||||
if (value === null) {
|
||||
return null;
|
||||
}
|
||||
const html = markdownToHtml(value, getAsset);
|
||||
const html = markdownToHtml(value, { getAsset, resolveWidget });
|
||||
return <WidgetPreviewContainer dangerouslySetInnerHTML={{ __html: html }} />;
|
||||
};
|
||||
|
||||
MarkdownPreview.propTypes = {
|
||||
getAsset: PropTypes.func.isRequired,
|
||||
editorPreview: PropTypes.func.isRequired,
|
||||
resolveWidget: PropTypes.func.isRequired,
|
||||
value: PropTypes.string,
|
||||
};
|
||||
|
||||
|
@ -55,7 +55,7 @@ exports[`Markdown Preview renderer Markdown rendering General should render mark
|
||||
dangerouslySetInnerHTML={
|
||||
Object {
|
||||
"__html": "<h1>H1</h1>
|
||||
<p>Text with <strong>bold</strong> & <em>em</em> elements</p>
|
||||
<p>Text with <strong>bold</strong> & <em>em</em> elements</p>
|
||||
<h2>H2</h2>
|
||||
<ul>
|
||||
<li>ul item 1</li>
|
||||
@ -70,9 +70,9 @@ exports[`Markdown Preview renderer Markdown rendering General should render mark
|
||||
<h4>H4</h4>
|
||||
<p><a href=\\"http://google.com\\">link title</a></p>
|
||||
<h5>H5</h5>
|
||||
<p><img src=\\"https://pbs.twimg.com/profile_images/678903331176214528/TQTdqGwD.jpg\\" alt=\\"alt text\\"></p>
|
||||
<p><img src=\\"https://pbs.twimg.com/profile_images/678903331176214528/TQTdqGwD.jpg\\" alt=\\"alt text\\" /></p>
|
||||
<h6>H6</h6>
|
||||
<p><img src=\\"https://pbs.twimg.com/profile_images/678903331176214528/TQTdqGwD.jpg\\"></p>",
|
||||
<p><img src=\\"https://pbs.twimg.com/profile_images/678903331176214528/TQTdqGwD.jpg\\" /></p>",
|
||||
}
|
||||
}
|
||||
/>
|
||||
@ -207,36 +207,3 @@ exports[`Markdown Preview renderer Markdown rendering Links should render links
|
||||
}
|
||||
/>
|
||||
`;
|
||||
|
||||
exports[`Markdown Preview renderer Markdown rendering Lists should render lists 1`] = `
|
||||
.emotion-0 {
|
||||
margin: 15px 2px;
|
||||
}
|
||||
|
||||
<div
|
||||
className="emotion-0 emotion-1"
|
||||
dangerouslySetInnerHTML={
|
||||
Object {
|
||||
"__html": "<ol>
|
||||
<li>ol item 1</li>
|
||||
<li>
|
||||
<p>ol item 2</p>
|
||||
<ul>
|
||||
<li>Sublist 1</li>
|
||||
<li>Sublist 2</li>
|
||||
<li>
|
||||
<p>Sublist 3</p>
|
||||
<ol>
|
||||
<li>Sub-Sublist 1</li>
|
||||
<li>Sub-Sublist 2</li>
|
||||
<li>Sub-Sublist 3</li>
|
||||
</ol>
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
<li>ol item 3</li>
|
||||
</ol>",
|
||||
}
|
||||
}
|
||||
/>
|
||||
`;
|
||||
|
@ -74,18 +74,43 @@ Text with **bold** & _em_ elements
|
||||
renderer
|
||||
.create(<MarkdownPreview value={markdownToHtml(value)} getAsset={jest.fn()} />)
|
||||
.toJSON(),
|
||||
).toMatchSnapshot();
|
||||
).toMatchInlineSnapshot(`
|
||||
.emotion-0 {
|
||||
margin: 15px 2px;
|
||||
}
|
||||
|
||||
<div
|
||||
className="emotion-0 emotion-1"
|
||||
dangerouslySetInnerHTML={
|
||||
Object {
|
||||
"__html": "<ol>
|
||||
<li>ol item 1</li>
|
||||
<li>ol item 2<ul>
|
||||
<li>Sublist 1</li>
|
||||
<li>Sublist 2</li>
|
||||
<li>Sublist 3<ol>
|
||||
<li>Sub-Sublist 1</li>
|
||||
<li>Sub-Sublist 2</li>
|
||||
<li>Sub-Sublist 3</li>
|
||||
</ol></li>
|
||||
</ul></li>
|
||||
<li>ol item 3</li>
|
||||
</ol>",
|
||||
}
|
||||
}
|
||||
/>
|
||||
`);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Links', () => {
|
||||
it('should render links', () => {
|
||||
const value = `
|
||||
I get 10 times more traffic from [Google] [1] than from [Yahoo] [2] or [MSN] [3].
|
||||
I get 10 times more traffic from [Google] than from [Yahoo] or [MSN].
|
||||
|
||||
[1]: http://google.com/ "Google"
|
||||
[2]: http://search.yahoo.com/ "Yahoo Search"
|
||||
[3]: http://search.msn.com/ "MSN Search"
|
||||
[Google]: http://google.com/ "Google"
|
||||
[Yahoo]: http://search.yahoo.com/ "Yahoo Search"
|
||||
[MSN]: http://search.msn.com/ "MSN Search"
|
||||
`;
|
||||
expect(
|
||||
renderer
|
||||
|
File diff suppressed because it is too large
Load Diff
@ -1,45 +1,124 @@
|
||||
import { flow, trim } from 'lodash';
|
||||
import commonmarkSpec from './__fixtures__/commonmark.json';
|
||||
import expected from './__fixtures__/commonmarkExpected.json';
|
||||
import { markdownToSlate, slateToMarkdown, markdownToHtml } from '../index.js';
|
||||
import { flow } from 'lodash';
|
||||
import { tests as commonmarkSpec } from 'commonmark-spec';
|
||||
import commonmark from 'commonmark';
|
||||
import { markdownToSlate, slateToMarkdown } from '../index.js';
|
||||
|
||||
/**
|
||||
* Map the commonmark spec data into an array of arrays for use in Jest's
|
||||
* `test.each`.
|
||||
*/
|
||||
const spec = commonmarkSpec.map(({ markdown, html }) => [markdown, html]);
|
||||
const skips = [
|
||||
{
|
||||
number: [456],
|
||||
reason: 'Remark ¯\\_(ツ)_/¯',
|
||||
},
|
||||
{
|
||||
number: [416, 417, 424, 425, 426, 431, 457, 460, 462, 464, 467],
|
||||
reason: 'Remark does not support infinite (redundant) nested marks',
|
||||
},
|
||||
{
|
||||
number: [455, 469, 470, 471],
|
||||
reason: 'Remark parses the initial set of identical nested delimiters first',
|
||||
},
|
||||
{
|
||||
number: [473, 476, 478, 480],
|
||||
reason: 'we convert underscores to asterisks for strong/emphasis',
|
||||
},
|
||||
{ number: 490, reason: 'Remark strips pointy enclosing pointy brackets from link url' },
|
||||
{ number: 503, reason: 'Remark allows non-breaking space between link url and title' },
|
||||
{ number: 507, reason: 'Remark allows a space between link alt and url' },
|
||||
{
|
||||
number: [
|
||||
511,
|
||||
516,
|
||||
525,
|
||||
528,
|
||||
529,
|
||||
530,
|
||||
532,
|
||||
533,
|
||||
534,
|
||||
540,
|
||||
541,
|
||||
542,
|
||||
543,
|
||||
546,
|
||||
548,
|
||||
560,
|
||||
565,
|
||||
567,
|
||||
],
|
||||
reason: 'we convert link references to standard links, but Remark also fails these',
|
||||
},
|
||||
{
|
||||
number: [569, 570, 571, 572, 573, 581, 585],
|
||||
reason: 'Remark does not recognize or remove marks in image alt text',
|
||||
},
|
||||
{ number: 589, reason: 'Remark does not honor backslash escape of image exclamation point' },
|
||||
{ number: 593, reason: 'Remark removes "mailto:" from autolink text' },
|
||||
{ number: 599, reason: 'Remark does not escape all expected entities' },
|
||||
{ number: 602, reason: 'Remark allows autolink emails to contain backslashes' },
|
||||
];
|
||||
|
||||
const onlys = [
|
||||
// just add the spec number, eg:
|
||||
// 431,
|
||||
];
|
||||
|
||||
/**
|
||||
* Each test receives input markdown and output html as expected for Commonmark
|
||||
* compliance. To test all of our handling in one go, we serialize the markdown
|
||||
* into our Slate AST, then back to raw markdown, and finally to HTML.
|
||||
*/
|
||||
const process = flow([markdownToSlate, slateToMarkdown, markdownToHtml]);
|
||||
const reader = new commonmark.Parser();
|
||||
const writer = new commonmark.HtmlRenderer();
|
||||
const parseWithCommonmark = markdown => {
|
||||
const parsed = reader.parse(markdown);
|
||||
return writer.render(parsed);
|
||||
};
|
||||
const parse = flow([markdownToSlate, slateToMarkdown]);
|
||||
|
||||
/**
|
||||
* Passing this test suite requires 100% Commonmark compliance. There are 624
|
||||
* tests, of which we're passing about 300 as of introduction of this suite. To
|
||||
* work on improving Commonmark support, update __fixtures__/commonmarkExpected.json
|
||||
*/
|
||||
describe.skip('Commonmark support', function() {
|
||||
const specs =
|
||||
onlys.length > 0
|
||||
? commonmarkSpec.filter(({ number }) => onlys.includes(number))
|
||||
: commonmarkSpec;
|
||||
specs.forEach(spec => {
|
||||
const skip = skips.find(({ number }) => {
|
||||
return Array.isArray(number) ? number.includes(spec.number) : number === spec.number;
|
||||
});
|
||||
const specUrl = `https://spec.commonmark.org/0.29/#example-${spec.number}`;
|
||||
const parsed = parse(spec.markdown);
|
||||
const commonmarkParsedHtml = parseWithCommonmark(parsed);
|
||||
const description = `
|
||||
${spec.section}
|
||||
${specUrl}
|
||||
|
||||
describe('Commonmark support', () => {
|
||||
test.each(spec)('%s', (markdown, html) => {
|
||||
// We're trimming the html from the spec as they all have trailing newlines
|
||||
// and we never output trailing newlines. This may be a compliance issue.
|
||||
const trimmedHtml = trim(html);
|
||||
Spec:
|
||||
${JSON.stringify(spec, null, 2)}
|
||||
|
||||
switch (expected[markdown]) {
|
||||
case 'TO_EQUAL':
|
||||
expect(process(markdown)).toEqual(trimmedHtml);
|
||||
break;
|
||||
case 'NOT_TO_EQUAL':
|
||||
expect(process(markdown)).not.toEqual(trimmedHtml);
|
||||
break;
|
||||
case 'TO_ERROR':
|
||||
expect(() => process(markdown)).toThrowError();
|
||||
break;
|
||||
default:
|
||||
throw new Error('Unknown expected type: ' + expected[markdown]);
|
||||
Markdown input:
|
||||
${spec.markdown}
|
||||
|
||||
Markdown parsed through Slate/Remark and back to Markdown:
|
||||
${parsed}
|
||||
|
||||
HTML output:
|
||||
${commonmarkParsedHtml}
|
||||
|
||||
Expected HTML output:
|
||||
${spec.html}
|
||||
`;
|
||||
if (skip) {
|
||||
const showMessage = Array.isArray(skip.number) ? skip.number[0] === spec.number : true;
|
||||
if (showMessage) {
|
||||
//console.log(`skipping spec ${skip.number}\n${skip.reason}\n${specUrl}`);
|
||||
}
|
||||
}
|
||||
const testFn = skip ? test.skip : test;
|
||||
testFn(description, () => {
|
||||
expect(commonmarkParsedHtml).toEqual(spec.html);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
@ -1,4 +1,7 @@
|
||||
/** @jsx h */
|
||||
|
||||
import { flow } from 'lodash';
|
||||
import h from '../../../test-helpers/h';
|
||||
import { markdownToSlate, slateToMarkdown } from '../index';
|
||||
|
||||
const process = flow([markdownToSlate, slateToMarkdown]);
|
||||
@ -12,8 +15,16 @@ describe('slate', () => {
|
||||
expect(process('**a[b](c)d**')).toEqual('**a[b](c)d**');
|
||||
expect(process('**[a](b)**')).toEqual('**[a](b)**');
|
||||
expect(process('**![a](b)**')).toEqual('**![a](b)**');
|
||||
expect(process('_`a`_')).toEqual('_`a`_');
|
||||
expect(process('_`a`b_')).toEqual('_`a`b_');
|
||||
expect(process('_`a`_')).toEqual('*`a`*');
|
||||
});
|
||||
|
||||
it('should handle unstyled code nodes adjacent to styled code nodes', () => {
|
||||
expect(process('`foo`***`bar`***')).toEqual('`foo`***`bar`***');
|
||||
});
|
||||
|
||||
it('should handle styled code nodes adjacent to non-code text', () => {
|
||||
expect(process('_`a`b_')).toEqual('*`a`b*');
|
||||
expect(process('_`a`**b**_')).toEqual('*`a`**b***');
|
||||
});
|
||||
|
||||
it('should condense adjacent, identically styled text and inline nodes', () => {
|
||||
@ -23,7 +34,8 @@ describe('slate', () => {
|
||||
|
||||
it('should handle nested markdown entities', () => {
|
||||
expect(process('**a**b**c**')).toEqual('**a**b**c**');
|
||||
expect(process('**a _b_ c**')).toEqual('**a _b_ c**');
|
||||
expect(process('**a _b_ c**')).toEqual('**a *b* c**');
|
||||
expect(process('*`a`*')).toEqual('*`a`*');
|
||||
});
|
||||
|
||||
it('should parse inline images as images', () => {
|
||||
@ -34,57 +46,249 @@ describe('slate', () => {
|
||||
expect(process('<span>*</span>')).toEqual('<span>*</span>');
|
||||
});
|
||||
|
||||
it('should wrap break tags in surrounding marks', () => {
|
||||
expect(process('*a \nb*')).toEqual('*a\\\nb*');
|
||||
});
|
||||
|
||||
it('should not output empty headers in markdown', () => {
|
||||
// prettier-ignore
|
||||
const slateAst = (
|
||||
<document>
|
||||
<heading-one></heading-one>
|
||||
<paragraph>foo</paragraph>
|
||||
<heading-one></heading-one>
|
||||
</document>
|
||||
);
|
||||
expect(slateToMarkdown(slateAst.toJSON())).toMatchInlineSnapshot(`"foo"`);
|
||||
});
|
||||
|
||||
it('should not output empty marks in markdown', () => {
|
||||
// prettier-ignore
|
||||
const slateAst = (
|
||||
<document>
|
||||
<paragraph>
|
||||
<b></b>
|
||||
foo<i><b></b></i>bar
|
||||
<b></b>baz<i></i>
|
||||
</paragraph>
|
||||
</document>
|
||||
);
|
||||
expect(slateToMarkdown(slateAst.toJSON())).toMatchInlineSnapshot(`"foobarbaz"`);
|
||||
});
|
||||
|
||||
it('should not produce invalid markdown when a styled block has trailing whitespace', () => {
|
||||
const slateAst = {
|
||||
object: 'block',
|
||||
type: 'root',
|
||||
nodes: [
|
||||
{
|
||||
object: 'block',
|
||||
type: 'paragraph',
|
||||
nodes: [
|
||||
{
|
||||
object: 'text',
|
||||
data: undefined,
|
||||
leaves: [
|
||||
{
|
||||
text: 'foo ', // <--
|
||||
marks: [{ type: 'bold' }],
|
||||
},
|
||||
],
|
||||
},
|
||||
{ object: 'text', data: undefined, leaves: [{ text: 'bar' }] },
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
expect(slateToMarkdown(slateAst)).toEqual('**foo** bar');
|
||||
// prettier-ignore
|
||||
const slateAst = (
|
||||
<document>
|
||||
<paragraph>
|
||||
<b>foo </b>bar <b>bim </b><b><i>bam</i></b>
|
||||
</paragraph>
|
||||
</document>
|
||||
);
|
||||
expect(slateToMarkdown(slateAst.toJSON())).toMatchInlineSnapshot(`"**foo** bar **bim *bam***"`);
|
||||
});
|
||||
|
||||
it('should not produce invalid markdown when a styled block has leading whitespace', () => {
|
||||
const slateAst = {
|
||||
object: 'block',
|
||||
type: 'root',
|
||||
nodes: [
|
||||
{
|
||||
object: 'block',
|
||||
type: 'paragraph',
|
||||
nodes: [
|
||||
{ object: 'text', data: undefined, leaves: [{ text: 'foo' }] },
|
||||
{
|
||||
object: 'text',
|
||||
data: undefined,
|
||||
leaves: [
|
||||
{
|
||||
text: ' bar', // <--
|
||||
marks: [{ type: 'bold' }],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
expect(slateToMarkdown(slateAst)).toEqual('foo **bar**');
|
||||
// prettier-ignore
|
||||
const slateAst = (
|
||||
<document>
|
||||
<paragraph>
|
||||
foo<b> bar</b>
|
||||
</paragraph>
|
||||
</document>
|
||||
);
|
||||
expect(slateToMarkdown(slateAst.toJSON())).toMatchInlineSnapshot(`"foo **bar**"`);
|
||||
});
|
||||
|
||||
it('should group adjacent marks into a single mark when possible', () => {
|
||||
// prettier-ignore
|
||||
const slateAst = (
|
||||
<document>
|
||||
<paragraph>
|
||||
<b>shared mark</b>
|
||||
<link url="link">
|
||||
<b><i>link</i></b>
|
||||
</link>
|
||||
{' '}
|
||||
<b>not shared mark</b>
|
||||
<link url="link">
|
||||
<i>another </i>
|
||||
<b><i>link</i></b>
|
||||
</link>
|
||||
</paragraph>
|
||||
</document>
|
||||
);
|
||||
expect(slateToMarkdown(slateAst.toJSON())).toMatchInlineSnapshot(
|
||||
`"**shared mark*[link](link)*** **not shared mark***[another **link**](link)*"`,
|
||||
);
|
||||
});
|
||||
|
||||
describe('links', () => {
|
||||
it('should handle inline code in link content', () => {
|
||||
// prettier-ignore
|
||||
const slateAst = (
|
||||
<document>
|
||||
<paragraph>
|
||||
<link url="link">
|
||||
<code>foo</code>
|
||||
</link>
|
||||
</paragraph>
|
||||
</document>
|
||||
);
|
||||
expect(slateToMarkdown(slateAst.toJSON())).toMatchInlineSnapshot(`"[\`foo\`](link)"`);
|
||||
});
|
||||
});
|
||||
|
||||
describe('code marks', () => {
|
||||
it('can contain other marks', () => {
|
||||
// prettier-ignore
|
||||
const slateAst = (
|
||||
<document>
|
||||
<paragraph>
|
||||
<code><i><b>foo</b></i></code>
|
||||
</paragraph>
|
||||
</document>
|
||||
);
|
||||
expect(slateToMarkdown(slateAst.toJSON())).toMatchInlineSnapshot(`"***\`foo\`***"`);
|
||||
});
|
||||
|
||||
it('can be condensed when no other marks are present', () => {
|
||||
// prettier-ignore
|
||||
const slateAst = (
|
||||
<document>
|
||||
<paragraph>
|
||||
<code>foo</code>
|
||||
<code>bar</code>
|
||||
</paragraph>
|
||||
</document>
|
||||
);
|
||||
expect(slateToMarkdown(slateAst.toJSON())).toMatchInlineSnapshot(`"\`foo\`"`);
|
||||
});
|
||||
});
|
||||
|
||||
describe('with nested styles within a single word', () => {
|
||||
it('should not produce invalid markdown when a bold word has italics applied to a smaller part', () => {
|
||||
// prettier-ignore
|
||||
const slateAst = (
|
||||
<document>
|
||||
<paragraph>
|
||||
<b>h</b>
|
||||
<b><i>e</i></b>
|
||||
<b>y</b>
|
||||
</paragraph>
|
||||
</document>
|
||||
);
|
||||
expect(slateToMarkdown(slateAst.toJSON())).toMatchInlineSnapshot(`"**h*e*y**"`);
|
||||
});
|
||||
|
||||
it('should not produce invalid markdown when an italic word has bold applied to a smaller part', () => {
|
||||
// prettier-ignore
|
||||
const slateAst = (
|
||||
<document>
|
||||
<paragraph>
|
||||
<i>h</i>
|
||||
<i><b>e</b></i>
|
||||
<i>y</i>
|
||||
</paragraph>
|
||||
</document>
|
||||
);
|
||||
expect(slateToMarkdown(slateAst.toJSON())).toMatchInlineSnapshot(`"*h**e**y*"`);
|
||||
});
|
||||
|
||||
it('should handle italics inside bold inside strikethrough', () => {
|
||||
// prettier-ignore
|
||||
const slateAst = (
|
||||
<document>
|
||||
<paragraph>
|
||||
<s>h</s>
|
||||
<s><b>e</b></s>
|
||||
<s><b><i>l</i></b></s>
|
||||
<s><b>l</b></s>
|
||||
<s>o</s>
|
||||
</paragraph>
|
||||
</document>
|
||||
);
|
||||
expect(slateToMarkdown(slateAst.toJSON())).toMatchInlineSnapshot(`"~~h**e*l*l**o~~"`);
|
||||
});
|
||||
|
||||
it('should handle bold inside italics inside strikethrough', () => {
|
||||
// prettier-ignore
|
||||
const slateAst = (
|
||||
<document>
|
||||
<paragraph>
|
||||
<s>h</s>
|
||||
<s><i>e</i></s>
|
||||
<s><i><b>l</b></i></s>
|
||||
<s><i>l</i></s>
|
||||
<s>o</s>
|
||||
</paragraph>
|
||||
</document>
|
||||
);
|
||||
expect(slateToMarkdown(slateAst.toJSON())).toMatchInlineSnapshot(`"~~h*e**l**l*o~~"`);
|
||||
});
|
||||
|
||||
it('should handle strikethrough inside italics inside bold', () => {
|
||||
// prettier-ignore
|
||||
const slateAst = (
|
||||
<document>
|
||||
<paragraph>
|
||||
<b>h</b>
|
||||
<b><i>e</i></b>
|
||||
<b><i><s>l</s></i></b>
|
||||
<b><i>l</i></b>
|
||||
<b>o</b>
|
||||
</paragraph>
|
||||
</document>
|
||||
);
|
||||
expect(slateToMarkdown(slateAst.toJSON())).toMatchInlineSnapshot(`"**h*e~~l~~l*o**"`);
|
||||
});
|
||||
|
||||
it('should handle italics inside strikethrough inside bold', () => {
|
||||
// prettier-ignore
|
||||
const slateAst = (
|
||||
<document>
|
||||
<paragraph>
|
||||
<b>h</b>
|
||||
<b><s>e</s></b>
|
||||
<b><s><i>l</i></s></b>
|
||||
<b><s>l</s></b>
|
||||
<b>o</b>
|
||||
</paragraph>
|
||||
</document>
|
||||
);
|
||||
expect(slateToMarkdown(slateAst.toJSON())).toMatchInlineSnapshot(`"**h~~e*l*l~~o**"`);
|
||||
});
|
||||
|
||||
it('should handle strikethrough inside bold inside italics', () => {
|
||||
// prettier-ignore
|
||||
const slateAst = (
|
||||
<document>
|
||||
<paragraph>
|
||||
<i>h</i>
|
||||
<i><b>e</b></i>
|
||||
<i><b><s>l</s></b></i>
|
||||
<i><b>l</b></i>
|
||||
<i>o</i>
|
||||
</paragraph>
|
||||
</document>
|
||||
);
|
||||
expect(slateToMarkdown(slateAst.toJSON())).toMatchInlineSnapshot(`"*h**e~~l~~l**o*"`);
|
||||
});
|
||||
|
||||
it('should handle bold inside strikethrough inside italics', () => {
|
||||
// prettier-ignore
|
||||
const slateAst = (
|
||||
<document>
|
||||
<paragraph>
|
||||
<i>h</i>
|
||||
<i><s>e</s></i>
|
||||
<i><s><b>l</b></s></i>
|
||||
<i><s>l</s></i>
|
||||
<i>o</i>
|
||||
</paragraph>
|
||||
</document>
|
||||
);
|
||||
expect(slateToMarkdown(slateAst.toJSON())).toMatchInlineSnapshot(`"*h~~e**l**l~~o*"`);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
@ -115,10 +115,11 @@ export const remarkToMarkdown = obj => {
|
||||
listItemIndent: '1',
|
||||
|
||||
/**
|
||||
* Settings to emulate the defaults from the Prosemirror editor, not
|
||||
* necessarily optimal. Should eventually be configurable.
|
||||
* Use asterisk for everything, it's the most versatile. Eventually using
|
||||
* other characters should be an option.
|
||||
*/
|
||||
bullet: '*',
|
||||
emphasis: '*',
|
||||
strong: '*',
|
||||
rule: '-',
|
||||
};
|
||||
@ -147,16 +148,21 @@ export const remarkToMarkdown = obj => {
|
||||
/**
|
||||
* Convert Markdown to HTML.
|
||||
*/
|
||||
export const markdownToHtml = (markdown, getAsset) => {
|
||||
export const markdownToHtml = (markdown, { getAsset, resolveWidget } = {}) => {
|
||||
const mdast = markdownToRemark(markdown);
|
||||
|
||||
const hast = unified()
|
||||
.use(remarkToRehypeShortcodes, { plugins: getEditorComponents(), getAsset })
|
||||
.use(remarkToRehypeShortcodes, { plugins: getEditorComponents(), getAsset, resolveWidget })
|
||||
.use(remarkToRehype, { allowDangerousHTML: true })
|
||||
.runSync(mdast);
|
||||
|
||||
const html = unified()
|
||||
.use(rehypeToHtml, { allowDangerousHTML: true, allowDangerousCharacters: true })
|
||||
.use(rehypeToHtml, {
|
||||
allowDangerousHTML: true,
|
||||
allowDangerousCharacters: true,
|
||||
closeSelfClosing: true,
|
||||
entities: { useNamedReferences: true },
|
||||
})
|
||||
.stringify(hast);
|
||||
|
||||
return html;
|
||||
@ -189,12 +195,12 @@ export const htmlToSlate = html => {
|
||||
/**
|
||||
* Convert Markdown to Slate's Raw AST.
|
||||
*/
|
||||
export const markdownToSlate = markdown => {
|
||||
export const markdownToSlate = (markdown, { voidCodeBlock } = {}) => {
|
||||
const mdast = markdownToRemark(markdown);
|
||||
|
||||
const slateRaw = unified()
|
||||
.use(remarkWrapHtml)
|
||||
.use(remarkToSlate)
|
||||
.use(remarkToSlate, { voidCodeBlock })
|
||||
.runSync(mdast);
|
||||
|
||||
return slateRaw;
|
||||
@ -209,8 +215,8 @@ export const markdownToSlate = markdown => {
|
||||
* MDAST. The conversion is manual because Unified can only operate on Unist
|
||||
* trees.
|
||||
*/
|
||||
export const slateToMarkdown = raw => {
|
||||
const mdast = slateToRemark(raw, { shortcodePlugins: getEditorComponents() });
|
||||
export const slateToMarkdown = (raw, { voidCodeBlock } = {}) => {
|
||||
const mdast = slateToRemark(raw, { voidCodeBlock });
|
||||
const markdown = remarkToMarkdown(mdast);
|
||||
return markdown;
|
||||
};
|
||||
|
@ -1,3 +1,4 @@
|
||||
import React from 'react';
|
||||
import { map, has } from 'lodash';
|
||||
import { renderToString } from 'react-dom/server';
|
||||
import u from 'unist-builder';
|
||||
@ -8,7 +9,7 @@ import u from 'unist-builder';
|
||||
* conversion by replacing the shortcode text with stringified HTML for
|
||||
* previewing the shortcode output.
|
||||
*/
|
||||
export default function remarkToRehypeShortcodes({ plugins, getAsset }) {
|
||||
export default function remarkToRehypeShortcodes({ plugins, getAsset, resolveWidget }) {
|
||||
return transform;
|
||||
|
||||
function transform(root) {
|
||||
@ -37,7 +38,7 @@ export default function remarkToRehypeShortcodes({ plugins, getAsset }) {
|
||||
* an HTML string or a React component. If a React component is returned,
|
||||
* render it to an HTML string.
|
||||
*/
|
||||
const value = plugin.toPreview(shortcodeData, getAsset);
|
||||
const value = getPreview(plugin, shortcodeData);
|
||||
const valueHtml = typeof value === 'string' ? value : renderToString(value);
|
||||
|
||||
/**
|
||||
@ -47,4 +48,20 @@ export default function remarkToRehypeShortcodes({ plugins, getAsset }) {
|
||||
const children = [textNode];
|
||||
return { ...node, children };
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve the shortcode preview component.
|
||||
*/
|
||||
function getPreview(plugin, shortcodeData) {
|
||||
const { toPreview, widget } = plugin;
|
||||
if (toPreview) {
|
||||
return toPreview(shortcodeData, getAsset);
|
||||
}
|
||||
const preview = resolveWidget(widget);
|
||||
return React.createElement(preview.preview, {
|
||||
value: shortcodeData,
|
||||
field: plugin,
|
||||
getAsset,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
@ -1,31 +1,4 @@
|
||||
import { isEmpty, isArray, last, flatMap } from 'lodash';
|
||||
|
||||
/**
|
||||
* A Remark plugin for converting an MDAST to Slate Raw AST. Remark plugins
|
||||
* return a `transform` function that receives the MDAST as it's first argument.
|
||||
*/
|
||||
export default function remarkToSlate() {
|
||||
return transform;
|
||||
}
|
||||
|
||||
function transform(node) {
|
||||
/**
|
||||
* Call `transform` recursively on child nodes.
|
||||
*
|
||||
* If a node returns a falsey value, filter it out. Some nodes do not
|
||||
* translate from MDAST to Slate, such as definitions for link/image
|
||||
* references or footnotes.
|
||||
*/
|
||||
const children =
|
||||
!['strong', 'emphasis', 'delete'].includes(node.type) &&
|
||||
!isEmpty(node.children) &&
|
||||
flatMap(node.children, transform).filter(val => val);
|
||||
|
||||
/**
|
||||
* Run individual nodes through the conversion factory.
|
||||
*/
|
||||
return convertNode(node, children);
|
||||
}
|
||||
import { isEmpty, isArray, flatMap, map, flatten } from 'lodash';
|
||||
|
||||
/**
|
||||
* Map of MDAST node types to Slate node types.
|
||||
@ -34,7 +7,7 @@ const typeMap = {
|
||||
root: 'root',
|
||||
paragraph: 'paragraph',
|
||||
blockquote: 'quote',
|
||||
code: 'code',
|
||||
code: 'code-block',
|
||||
listItem: 'list-item',
|
||||
table: 'table',
|
||||
tableRow: 'table-row',
|
||||
@ -56,60 +29,82 @@ const markMap = {
|
||||
};
|
||||
|
||||
/**
|
||||
* Add nodes to a parent node only if `nodes` is truthy.
|
||||
* A Remark plugin for converting an MDAST to Slate Raw AST. Remark plugins
|
||||
* return a `transformNode` function that receives the MDAST as it's first argument.
|
||||
*/
|
||||
function addNodes(parent, nodes) {
|
||||
return nodes ? { ...parent, nodes } : parent;
|
||||
}
|
||||
export default function remarkToSlate({ voidCodeBlock } = {}) {
|
||||
return transformNode;
|
||||
|
||||
/**
|
||||
* Create a Slate Inline node.
|
||||
*/
|
||||
function createBlock(type, nodes, props = {}) {
|
||||
if (!isArray(nodes)) {
|
||||
props = nodes;
|
||||
nodes = undefined;
|
||||
function transformNode(node) {
|
||||
/**
|
||||
* Call `transformNode` recursively on child nodes.
|
||||
*
|
||||
* If a node returns a falsey value, filter it out. Some nodes do not
|
||||
* translate from MDAST to Slate, such as definitions for link/image
|
||||
* references or footnotes.
|
||||
*/
|
||||
const children =
|
||||
!['strong', 'emphasis', 'delete'].includes(node.type) &&
|
||||
!isEmpty(node.children) &&
|
||||
flatMap(node.children, transformNode).filter(val => val);
|
||||
|
||||
/**
|
||||
* Run individual nodes through the conversion factory.
|
||||
*/
|
||||
const output = convertNode(node, children || undefined);
|
||||
return output;
|
||||
}
|
||||
|
||||
const node = { object: 'block', type, ...props };
|
||||
return addNodes(node, nodes);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a Slate Block node.
|
||||
*/
|
||||
function createInline(type, props = {}, nodes) {
|
||||
const node = { object: 'inline', type, ...props };
|
||||
return addNodes(node, nodes);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a Slate Raw text node.
|
||||
*/
|
||||
function createText(value, data) {
|
||||
const node = { object: 'text', data };
|
||||
const leaves = isArray(value) ? value : [{ text: value }];
|
||||
return { ...node, leaves };
|
||||
}
|
||||
|
||||
function processMarkNode(node, parentMarks = []) {
|
||||
/**
|
||||
* Add the current node's mark type to the marks collected from parent
|
||||
* mark nodes, if any.
|
||||
* Add nodes to a parent node only if `nodes` is truthy.
|
||||
*/
|
||||
const markType = markMap[node.type];
|
||||
const marks = markType ? [...parentMarks, { type: markMap[node.type] }] : parentMarks;
|
||||
function addNodes(parent, nodes) {
|
||||
return nodes ? { ...parent, nodes } : parent;
|
||||
}
|
||||
|
||||
const children = flatMap(node.children, childNode => {
|
||||
/**
|
||||
* Create a Slate Block node.
|
||||
*/
|
||||
function createBlock(type, nodes, props = {}) {
|
||||
if (!isArray(nodes)) {
|
||||
props = nodes;
|
||||
nodes = undefined;
|
||||
}
|
||||
|
||||
const node = { object: 'block', type, ...props };
|
||||
return addNodes(node, nodes);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a Slate Inline node.
|
||||
*/
|
||||
function createInline(type, props = {}, nodes) {
|
||||
const node = { object: 'inline', type, ...props };
|
||||
return addNodes(node, nodes);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a Slate Raw text node.
|
||||
*/
|
||||
function createText(node) {
|
||||
const newNode = { object: 'text' };
|
||||
if (typeof node === 'string') {
|
||||
return { ...newNode, text: node };
|
||||
}
|
||||
const { text, marks } = node;
|
||||
return { ...newNode, text, marks };
|
||||
}
|
||||
|
||||
function processMarkChild(childNode, marks) {
|
||||
switch (childNode.type) {
|
||||
/**
|
||||
* If a text node is a direct child of the current node, it should be
|
||||
* set aside as a leaf, and all marks that have been collected in the
|
||||
* `marks` array should apply to that specific leaf.
|
||||
* set aside as a text, and all marks that have been collected in the
|
||||
* `marks` array should apply to that specific text.
|
||||
*/
|
||||
case 'html':
|
||||
case 'text':
|
||||
return { text: childNode.value, marks };
|
||||
return { ...convertNode(childNode), marks };
|
||||
|
||||
/**
|
||||
* MDAST inline code nodes don't have children, just a text value, similar
|
||||
@ -117,14 +112,14 @@ function processMarkNode(node, parentMarks = []) {
|
||||
* first add the inline code mark to the marks array.
|
||||
*/
|
||||
case 'inlineCode': {
|
||||
const childMarks = [...marks, { type: markMap['inlineCode'] }];
|
||||
return { text: childNode.value, marks: childMarks };
|
||||
const childMarks = [...marks, { type: markMap[childNode.type] }];
|
||||
return { ...convertNode(childNode), marks: childMarks };
|
||||
}
|
||||
|
||||
/**
|
||||
* Process nested style nodes. The recursive results should be pushed into
|
||||
* the leaves array. This way, every MDAST nested text structure becomes a
|
||||
* flat array of leaves that can serve as the value of a single Slate Raw
|
||||
* the texts array. This way, every MDAST nested text structure becomes a
|
||||
* flat array of texts that can serve as the value of a single Slate Raw
|
||||
* text node.
|
||||
*/
|
||||
case 'strong':
|
||||
@ -132,211 +127,236 @@ function processMarkNode(node, parentMarks = []) {
|
||||
case 'delete':
|
||||
return processMarkNode(childNode, marks);
|
||||
|
||||
case 'link': {
|
||||
const nodes = map(childNode.children, child => processMarkChild(child, marks));
|
||||
const result = convertNode(childNode, flatten(nodes));
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remaining nodes simply need mark data added to them, and to then be
|
||||
* added into the cumulative children array.
|
||||
*/
|
||||
default:
|
||||
return { ...childNode, data: { marks } };
|
||||
return transformNode({ ...childNode, data: { ...childNode.data, marks } });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return children;
|
||||
}
|
||||
|
||||
function convertMarkNode(node) {
|
||||
const slateNodes = processMarkNode(node);
|
||||
|
||||
const convertedSlateNodes = slateNodes.reduce((acc, node) => {
|
||||
const lastConvertedNode = last(acc);
|
||||
if (node.text && lastConvertedNode && lastConvertedNode.leaves) {
|
||||
lastConvertedNode.leaves.push(node);
|
||||
} else if (node.text) {
|
||||
acc.push(createText([node]));
|
||||
} else {
|
||||
acc.push(transform(node));
|
||||
}
|
||||
|
||||
return acc;
|
||||
}, []);
|
||||
|
||||
return convertedSlateNodes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a single MDAST node to a Slate Raw node. Uses local node factories
|
||||
* that mimic the unist-builder function utilized in the slateRemark
|
||||
* transformer.
|
||||
*/
|
||||
function convertNode(node, nodes) {
|
||||
switch (node.type) {
|
||||
function processMarkNode(node, parentMarks = []) {
|
||||
/**
|
||||
* General
|
||||
*
|
||||
* Convert simple cases that only require a type and children, with no
|
||||
* additional properties.
|
||||
* Add the current node's mark type to the marks collected from parent
|
||||
* mark nodes, if any.
|
||||
*/
|
||||
case 'root':
|
||||
case 'paragraph':
|
||||
case 'listItem':
|
||||
case 'blockquote':
|
||||
case 'tableRow':
|
||||
case 'tableCell': {
|
||||
return createBlock(typeMap[node.type], nodes);
|
||||
}
|
||||
const markType = markMap[node.type];
|
||||
const marks = markType ? [...parentMarks, { type: markMap[node.type] }] : parentMarks;
|
||||
|
||||
/**
|
||||
* Shortcodes
|
||||
*
|
||||
* Shortcode nodes are represented as "void" blocks in the Slate AST. They
|
||||
* maintain the same data as MDAST shortcode nodes. Slate void blocks must
|
||||
* contain a blank text node.
|
||||
*/
|
||||
case 'shortcode': {
|
||||
const { data } = node;
|
||||
const nodes = [createText('')];
|
||||
return createBlock(typeMap[node.type], nodes, { data, isVoid: true });
|
||||
}
|
||||
const children = flatMap(node.children, child => processMarkChild(child, marks));
|
||||
|
||||
/**
|
||||
* Text
|
||||
*
|
||||
* Text and HTML nodes are both used to render text, and should be treated
|
||||
* the same. HTML is treated as text because we never want to escape or
|
||||
* encode it.
|
||||
*/
|
||||
case 'text':
|
||||
case 'html': {
|
||||
return createText(node.value, node.data);
|
||||
}
|
||||
return children;
|
||||
}
|
||||
|
||||
/**
|
||||
* Inline Code
|
||||
*
|
||||
* Inline code nodes from an MDAST are represented in our Slate schema as
|
||||
* text nodes with a "code" mark. We manually create the "leaf" containing
|
||||
* the inline code value and a "code" mark, and place it in an array for use
|
||||
* as a Slate text node's children array.
|
||||
*/
|
||||
case 'inlineCode': {
|
||||
const leaf = {
|
||||
text: node.value,
|
||||
marks: [{ type: 'code' }],
|
||||
};
|
||||
return createText([leaf]);
|
||||
}
|
||||
/**
|
||||
* Convert a single MDAST node to a Slate Raw node. Uses local node factories
|
||||
* that mimic the unist-builder function utilized in the slateRemark
|
||||
* transformer.
|
||||
*/
|
||||
function convertNode(node, nodes) {
|
||||
switch (node.type) {
|
||||
/**
|
||||
* General
|
||||
*
|
||||
* Convert simple cases that only require a type and children, with no
|
||||
* additional properties.
|
||||
*/
|
||||
case 'root':
|
||||
case 'paragraph':
|
||||
case 'blockquote':
|
||||
case 'tableRow':
|
||||
case 'tableCell': {
|
||||
return createBlock(typeMap[node.type], nodes);
|
||||
}
|
||||
|
||||
/**
|
||||
* Marks
|
||||
*
|
||||
* Marks are typically decorative sub-types that apply to text nodes. In an
|
||||
* MDAST, marks are nodes that can contain other nodes. This nested
|
||||
* hierarchy has to be flattened and split into distinct text nodes with
|
||||
* their own set of marks.
|
||||
*/
|
||||
case 'strong':
|
||||
case 'emphasis':
|
||||
case 'delete': {
|
||||
return convertMarkNode(node);
|
||||
}
|
||||
/**
|
||||
* List Items
|
||||
*
|
||||
* Markdown list items can be empty, but a list item in the Slate schema
|
||||
* should at least have an empty paragraph node.
|
||||
*/
|
||||
case 'listItem': {
|
||||
const children = isEmpty(nodes) ? [createBlock('paragraph')] : nodes;
|
||||
return createBlock(typeMap[node.type], children);
|
||||
}
|
||||
|
||||
/**
|
||||
* Headings
|
||||
*
|
||||
* MDAST headings use a single type with a separate "depth" property to
|
||||
* indicate the heading level, while the Slate schema uses a separate node
|
||||
* type for each heading level. Here we get the proper Slate node name based
|
||||
* on the MDAST node depth.
|
||||
*/
|
||||
case 'heading': {
|
||||
const depthMap = { 1: 'one', 2: 'two', 3: 'three', 4: 'four', 5: 'five', 6: 'six' };
|
||||
const slateType = `heading-${depthMap[node.depth]}`;
|
||||
return createBlock(slateType, nodes);
|
||||
}
|
||||
/**
|
||||
* Shortcodes
|
||||
*
|
||||
* Shortcode nodes are represented as "void" blocks in the Slate AST. They
|
||||
* maintain the same data as MDAST shortcode nodes. Slate void blocks must
|
||||
* contain a blank text node.
|
||||
*/
|
||||
case 'shortcode': {
|
||||
const nodes = [createText('')];
|
||||
const data = { ...node.data };
|
||||
return createBlock(typeMap[node.type], nodes, { data });
|
||||
}
|
||||
|
||||
/**
|
||||
* Code Blocks
|
||||
*
|
||||
* MDAST code blocks are a distinct node type with a simple text value. We
|
||||
* convert that value into a nested child text node for Slate. We also carry
|
||||
* over the "lang" data property if it's defined.
|
||||
*/
|
||||
case 'code': {
|
||||
const data = { lang: node.lang };
|
||||
const text = createText(node.value);
|
||||
const nodes = [text];
|
||||
return createBlock(typeMap[node.type], nodes, { data });
|
||||
}
|
||||
/**
|
||||
* Text
|
||||
*
|
||||
* Text nodes contain plain text. We remove newlines because they don't
|
||||
* carry meaning for a rich text editor - a break in rich text would be
|
||||
* expected to result in a break in output HTML, but that isn't the case.
|
||||
* To avoid this confusion we remove them.
|
||||
*/
|
||||
case 'text': {
|
||||
const text = node.value.replace(/\n/, ' ');
|
||||
return createText(text);
|
||||
}
|
||||
|
||||
/**
|
||||
* Lists
|
||||
*
|
||||
* MDAST has a single list type and an "ordered" property. We derive that
|
||||
* information into the Slate schema's distinct list node types. We also
|
||||
* include the "start" property, which indicates the number an ordered list
|
||||
* starts at, if defined.
|
||||
*/
|
||||
case 'list': {
|
||||
const slateType = node.ordered ? 'numbered-list' : 'bulleted-list';
|
||||
const data = { start: node.start };
|
||||
return createBlock(slateType, nodes, { data });
|
||||
}
|
||||
/**
|
||||
* HTML
|
||||
*
|
||||
* HTML nodes contain plain text like text nodes, except they only contain
|
||||
* HTML. Our serialization results in non-HTML being placed in HTML nodes
|
||||
* sometimes to ensure that we're never escaping HTML from the rich text
|
||||
* editor. We do not replace line feeds in HTML because the HTML is raw
|
||||
* in the rich text editor, so the writer knows they're writing HTML, and
|
||||
* should expect soft breaks to be visually absent in the rendered HTML.
|
||||
*/
|
||||
case 'html': {
|
||||
return createText(node.value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Breaks
|
||||
*
|
||||
* MDAST soft break nodes represent a trailing double space or trailing
|
||||
* slash from a Markdown document. In Slate, these are simply transformed to
|
||||
* line breaks within a text node.
|
||||
*/
|
||||
case 'break': {
|
||||
const textNode = createText('\n');
|
||||
return createInline('break', {}, [textNode]);
|
||||
}
|
||||
/**
|
||||
* Inline Code
|
||||
*
|
||||
* Inline code nodes from an MDAST are represented in our Slate schema as
|
||||
* text nodes with a "code" mark. We manually create the text containing
|
||||
* the inline code value and a "code" mark, and place it in an array for use
|
||||
* as a Slate text node's children array.
|
||||
*/
|
||||
case 'inlineCode': {
|
||||
return createText({ text: node.value, marks: [{ type: 'code' }] });
|
||||
}
|
||||
|
||||
/**
|
||||
* Thematic Breaks
|
||||
*
|
||||
* Thematic breaks are void nodes in the Slate schema.
|
||||
*/
|
||||
case 'thematicBreak': {
|
||||
return createBlock(typeMap[node.type], { isVoid: true });
|
||||
}
|
||||
/**
|
||||
* Marks
|
||||
*
|
||||
* Marks are typically decorative sub-types that apply to text nodes. In an
|
||||
* MDAST, marks are nodes that can contain other nodes. This nested
|
||||
* hierarchy has to be flattened and split into distinct text nodes with
|
||||
* their own set of marks.
|
||||
*/
|
||||
case 'strong':
|
||||
case 'emphasis':
|
||||
case 'delete': {
|
||||
return processMarkNode(node);
|
||||
}
|
||||
|
||||
/**
|
||||
* Links
|
||||
*
|
||||
* MDAST stores the link attributes directly on the node, while our Slate
|
||||
* schema references them in the data object.
|
||||
*/
|
||||
case 'link': {
|
||||
const { title, url, data } = node;
|
||||
const newData = { ...data, title, url };
|
||||
return createInline(typeMap[node.type], { data: newData }, nodes);
|
||||
}
|
||||
/**
|
||||
* Headings
|
||||
*
|
||||
* MDAST headings use a single type with a separate "depth" property to
|
||||
* indicate the heading level, while the Slate schema uses a separate node
|
||||
* type for each heading level. Here we get the proper Slate node name based
|
||||
* on the MDAST node depth.
|
||||
*/
|
||||
case 'heading': {
|
||||
const depthMap = { 1: 'one', 2: 'two', 3: 'three', 4: 'four', 5: 'five', 6: 'six' };
|
||||
const slateType = `heading-${depthMap[node.depth]}`;
|
||||
return createBlock(slateType, nodes);
|
||||
}
|
||||
|
||||
/**
|
||||
* Images
|
||||
*
|
||||
* Identical to link nodes except for the lack of child nodes and addition
|
||||
* of alt attribute data MDAST stores the link attributes directly on the
|
||||
* node, while our Slate schema references them in the data object.
|
||||
*/
|
||||
case 'image': {
|
||||
const { title, url, alt, data } = node;
|
||||
const newData = { ...data, title, alt, url };
|
||||
return createInline(typeMap[node.type], { isVoid: true, data: newData });
|
||||
}
|
||||
/**
|
||||
* Code Blocks
|
||||
*
|
||||
* MDAST code blocks are a distinct node type with a simple text value. We
|
||||
* convert that value into a nested child text node for Slate. If a void
|
||||
* node is required due to a custom code block handler, the value is
|
||||
* stored in the "code" data property instead. We also carry over the "lang"
|
||||
* data property if it's defined.
|
||||
*/
|
||||
case 'code': {
|
||||
const data = {
|
||||
lang: node.lang,
|
||||
...(voidCodeBlock ? { code: node.value } : {}),
|
||||
};
|
||||
const text = createText(voidCodeBlock ? '' : node.value);
|
||||
const nodes = [text];
|
||||
const block = createBlock(typeMap[node.type], nodes, { data });
|
||||
return block;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tables
|
||||
*
|
||||
* Tables are parsed separately because they may include an "align"
|
||||
* property, which should be passed to the Slate node.
|
||||
*/
|
||||
case 'table': {
|
||||
const data = { align: node.align };
|
||||
return createBlock(typeMap[node.type], nodes, { data });
|
||||
/**
|
||||
* Lists
|
||||
*
|
||||
* MDAST has a single list type and an "ordered" property. We derive that
|
||||
* information into the Slate schema's distinct list node types. We also
|
||||
* include the "start" property, which indicates the number an ordered list
|
||||
* starts at, if defined.
|
||||
*/
|
||||
case 'list': {
|
||||
const slateType = node.ordered ? 'numbered-list' : 'bulleted-list';
|
||||
const data = { start: node.start };
|
||||
return createBlock(slateType, nodes, { data });
|
||||
}
|
||||
|
||||
/**
|
||||
* Breaks
|
||||
*
|
||||
* MDAST soft break nodes represent a trailing double space or trailing
|
||||
* slash from a Markdown document. In Slate, these are simply transformed to
|
||||
* line breaks within a text node.
|
||||
*/
|
||||
case 'break': {
|
||||
const { data } = node;
|
||||
return createInline('break', { data });
|
||||
}
|
||||
|
||||
/**
|
||||
* Thematic Breaks
|
||||
*
|
||||
* Thematic breaks are void nodes in the Slate schema.
|
||||
*/
|
||||
case 'thematicBreak': {
|
||||
return createBlock(typeMap[node.type]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Links
|
||||
*
|
||||
* MDAST stores the link attributes directly on the node, while our Slate
|
||||
* schema references them in the data object.
|
||||
*/
|
||||
case 'link': {
|
||||
const { title, url, data } = node;
|
||||
const newData = { ...data, title, url };
|
||||
return createInline(typeMap[node.type], { data: newData }, nodes);
|
||||
}
|
||||
|
||||
/**
|
||||
* Images
|
||||
*
|
||||
* Identical to link nodes except for the lack of child nodes and addition
|
||||
* of alt attribute data MDAST stores the link attributes directly on the
|
||||
* node, while our Slate schema references them in the data object.
|
||||
*/
|
||||
case 'image': {
|
||||
const { title, url, alt, data } = node;
|
||||
const newData = { ...data, title, alt, url };
|
||||
return createInline(typeMap[node.type], { data: newData });
|
||||
}
|
||||
|
||||
/**
|
||||
* Tables
|
||||
*
|
||||
* Tables are parsed separately because they may include an "align"
|
||||
* property, which should be passed to the Slate node.
|
||||
*/
|
||||
case 'table': {
|
||||
const data = { align: node.align };
|
||||
return createBlock(typeMap[node.type], nodes, { data });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -1,5 +1,6 @@
|
||||
import { get, isEmpty, without, flatMap, last, sortBy } from 'lodash';
|
||||
import { get, without, last, map, intersection, omit } from 'lodash';
|
||||
import u from 'unist-builder';
|
||||
import mdastToString from 'mdast-util-to-string';
|
||||
|
||||
/**
|
||||
* Map of Slate node types to MDAST/Remark node types.
|
||||
@ -14,7 +15,7 @@ const typeMap = {
|
||||
'heading-five': 'heading',
|
||||
'heading-six': 'heading',
|
||||
quote: 'blockquote',
|
||||
code: 'code',
|
||||
'code-block': 'code',
|
||||
'numbered-list': 'list',
|
||||
'bulleted-list': 'list',
|
||||
'list-item': 'listItem',
|
||||
@ -38,7 +39,10 @@ const markMap = {
|
||||
code: 'inlineCode',
|
||||
};
|
||||
|
||||
export default function slateToRemark(raw) {
|
||||
const leadingWhitespaceExp = /^\s+\S/;
|
||||
const trailingWhitespaceExp = /(?!\S)\s+$/;
|
||||
|
||||
export default function slateToRemark(raw, { voidCodeBlock }) {
|
||||
/**
|
||||
* The Slate Raw AST generally won't have a top level type, so we set it to
|
||||
* "root" for clarity.
|
||||
@ -46,465 +50,352 @@ export default function slateToRemark(raw) {
|
||||
raw.type = 'root';
|
||||
|
||||
return transform(raw);
|
||||
}
|
||||
|
||||
/**
|
||||
* The transform function mimics the approach of a Remark plugin for
|
||||
* conformity with the other serialization functions. This function converts
|
||||
* Slate nodes to MDAST nodes, and recursively calls itself to process child
|
||||
* nodes to arbitrary depth.
|
||||
*/
|
||||
function transform(node) {
|
||||
/**
|
||||
* Combine adjacent text and inline nodes before processing so they can
|
||||
* share marks.
|
||||
*/
|
||||
const combinedChildren = node.nodes && combineTextAndInline(node.nodes);
|
||||
|
||||
/**
|
||||
* Call `transform` recursively on child nodes, and flatten the resulting
|
||||
* array.
|
||||
* The transform function mimics the approach of a Remark plugin for
|
||||
* conformity with the other serialization functions. This function converts
|
||||
* Slate nodes to MDAST nodes, and recursively calls itself to process child
|
||||
* nodes to arbitrary depth.
|
||||
*/
|
||||
const children = !isEmpty(combinedChildren) && flatMap(combinedChildren, transform);
|
||||
|
||||
/**
|
||||
* Run individual nodes through conversion factories.
|
||||
*/
|
||||
return ['text'].includes(node.object) ? convertTextNode(node) : convertNode(node, children);
|
||||
}
|
||||
|
||||
/**
|
||||
* Includes inline nodes as leaves in adjacent text nodes where appropriate, so
|
||||
* that mark node combining logic can apply to both text and inline nodes. This
|
||||
* is necessary because Slate doesn't allow inline nodes to have marks while
|
||||
* inline nodes in MDAST may be nested within mark nodes. Treating them as if
|
||||
* they were text is a bit of a necessary hack.
|
||||
*/
|
||||
function combineTextAndInline(nodes) {
|
||||
return nodes.reduce((acc, node) => {
|
||||
const prevNode = last(acc);
|
||||
const prevNodeLeaves = get(prevNode, 'leaves');
|
||||
const data = node.data || {};
|
||||
|
||||
function transform(node) {
|
||||
/**
|
||||
* If the previous node has leaves and the current node has marks in data
|
||||
* (only happens when we place them on inline nodes here in the parser), or
|
||||
* the current node also has leaves (because the previous node was
|
||||
* originally an inline node that we've already squashed into a leaf)
|
||||
* combine the current node into the previous.
|
||||
* Combine adjacent text and inline nodes before processing so they can
|
||||
* share marks.
|
||||
*/
|
||||
if (!isEmpty(prevNodeLeaves) && !isEmpty(data.marks)) {
|
||||
prevNodeLeaves.push({ node, marks: data.marks });
|
||||
return acc;
|
||||
}
|
||||
const hasBlockChildren = node.nodes && node.nodes[0] && node.nodes[0].object === 'block';
|
||||
const children = hasBlockChildren
|
||||
? node.nodes.map(transform).filter(v => v)
|
||||
: convertInlineAndTextChildren(node.nodes);
|
||||
|
||||
if (!isEmpty(prevNodeLeaves) && !isEmpty(node.leaves)) {
|
||||
prevNode.leaves = prevNodeLeaves.concat(node.leaves);
|
||||
return acc;
|
||||
}
|
||||
const output = convertBlockNode(node, children);
|
||||
//console.log(JSON.stringify(output, null, 2));
|
||||
return output;
|
||||
}
|
||||
|
||||
/**
|
||||
* Break nodes contain a single child text node with a newline character
|
||||
* for visual purposes in the editor, but Remark break nodes have no
|
||||
* children, so we remove the child node here.
|
||||
*/
|
||||
if (node.type === 'break') {
|
||||
acc.push({ object: 'inline', type: 'break' });
|
||||
return acc;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert remaining inline nodes to standalone text nodes with leaves.
|
||||
*/
|
||||
if (node.object === 'inline') {
|
||||
acc.push({ object: 'text', leaves: [{ node, marks: data.marks }] });
|
||||
return acc;
|
||||
}
|
||||
|
||||
/**
|
||||
* Only remaining case is an actual text node, can be pushed as is.
|
||||
*/
|
||||
acc.push(node);
|
||||
return acc;
|
||||
}, []);
|
||||
}
|
||||
|
||||
/**
|
||||
* Slate treats inline code decoration as a standard mark, but MDAST does
|
||||
* not allow inline code nodes to contain children, only a single text
|
||||
* value. An MDAST inline code node can be nested within mark nodes such
|
||||
* as "emphasis" and "strong", but it cannot contain them.
|
||||
*
|
||||
* Because of this, if a "code" mark (translated to MDAST "inlineCode") is
|
||||
* in the markTypes array, we make the base text node an "inlineCode" type
|
||||
* instead of a standard text node.
|
||||
*/
|
||||
function processCodeMark(markTypes) {
|
||||
const isInlineCode = markTypes.includes('inlineCode');
|
||||
const filteredMarkTypes = isInlineCode ? without(markTypes, 'inlineCode') : markTypes;
|
||||
const textNodeType = isInlineCode ? 'inlineCode' : 'html';
|
||||
return { filteredMarkTypes, textNodeType };
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a Slate Raw text node to an MDAST text node.
|
||||
*
|
||||
* Slate text nodes without marks often simply have a "text" property with
|
||||
* the value. In this case the conversion to MDAST is simple. If a Slate
|
||||
* text node does not have a "text" property, it will instead have a
|
||||
* "leaves" property containing an array of objects, each with an array of
|
||||
* marks, such as "bold" or "italic", along with a "text" property.
|
||||
*
|
||||
* MDAST instead expresses such marks in a nested structure, with individual
|
||||
* nodes for each mark type nested until the deepest mark node, which will
|
||||
* contain the text node.
|
||||
*
|
||||
* To convert a Slate text node's marks to MDAST, we treat each "leaf" as a
|
||||
* separate text node, convert the text node itself to an MDAST text node,
|
||||
* and then recursively wrap the text node for each mark, collecting the results
|
||||
* of each leaf in a single array of child nodes.
|
||||
*
|
||||
* For example, this Slate text node:
|
||||
*
|
||||
* {
|
||||
* object: 'text',
|
||||
* leaves: [
|
||||
* {
|
||||
* text: 'test',
|
||||
* marks: ['bold', 'italic']
|
||||
* },
|
||||
* {
|
||||
* text: 'test two'
|
||||
* }
|
||||
* ]
|
||||
* }
|
||||
*
|
||||
* ...would be converted to this MDAST nested structure:
|
||||
*
|
||||
* [
|
||||
* {
|
||||
* type: 'strong',
|
||||
* children: [{
|
||||
* type: 'emphasis',
|
||||
* children: [{
|
||||
* type: 'text',
|
||||
* value: 'test'
|
||||
* }]
|
||||
* }]
|
||||
* },
|
||||
* {
|
||||
* type: 'text',
|
||||
* value: 'test two'
|
||||
* }
|
||||
* ]
|
||||
*
|
||||
* This example also demonstrates how a single Slate node may need to be
|
||||
* replaced with multiple MDAST nodes, so the resulting array must be flattened.
|
||||
*/
|
||||
function convertTextNode(node) {
|
||||
/**
|
||||
* If the Slate text node has a "leaves" property, translate the Slate AST to
|
||||
* a nested MDAST structure. Otherwise, just return an equivalent MDAST text
|
||||
* node.
|
||||
*/
|
||||
if (node.leaves) {
|
||||
const processedLeaves = node.leaves.map(processLeaves);
|
||||
// Compensate for Slate including leading and trailing whitespace in styled text nodes, which
|
||||
// cannot be represented in markdown (https://github.com/netlify/netlify-cms/issues/1448)
|
||||
for (let i = 0; i < processedLeaves.length; i += 1) {
|
||||
const leaf = processedLeaves[i];
|
||||
if (leaf.marks.length > 0 && leaf.text && leaf.text.trim() !== leaf.text) {
|
||||
const [, leadingWhitespace, trailingWhitespace] = leaf.text.match(/^(\s*).*?(\s*)$/);
|
||||
// Move the leading whitespace to a separate unstyled leaf, unless the current leaf
|
||||
// is preceded by another one with (at least) the same marks applied:
|
||||
if (
|
||||
leadingWhitespace.length > 0 &&
|
||||
(i === 0 ||
|
||||
!leaf.marks.every(
|
||||
mark => processedLeaves[i - 1].marks && processedLeaves[i - 1].marks.includes(mark),
|
||||
))
|
||||
) {
|
||||
processedLeaves.splice(i, 0, {
|
||||
text: leadingWhitespace,
|
||||
marks: [],
|
||||
textNodeType: leaf.textNodeType,
|
||||
});
|
||||
i += 1;
|
||||
leaf.text = leaf.text.replace(/^\s+/, '');
|
||||
function removeMarkFromNodes(nodes, markType) {
|
||||
return nodes.map(node => {
|
||||
switch (node.type) {
|
||||
case 'link': {
|
||||
const updatedNodes = removeMarkFromNodes(node.nodes, markType);
|
||||
return {
|
||||
...node,
|
||||
nodes: updatedNodes,
|
||||
};
|
||||
}
|
||||
// Move the trailing whitespace to a separate unstyled leaf, unless the current leaf
|
||||
// is followed by another one with (at least) the same marks applied:
|
||||
if (
|
||||
trailingWhitespace.length > 0 &&
|
||||
(i === processedLeaves.length - 1 ||
|
||||
!leaf.marks.every(
|
||||
mark => processedLeaves[i + 1].marks && processedLeaves[i + 1].marks.includes(mark),
|
||||
))
|
||||
) {
|
||||
processedLeaves.splice(i + 1, 0, {
|
||||
text: trailingWhitespace,
|
||||
marks: [],
|
||||
textNodeType: leaf.textNodeType,
|
||||
});
|
||||
i += 1;
|
||||
leaf.text = leaf.text.replace(/\s+$/, '');
|
||||
|
||||
case 'image':
|
||||
case 'break': {
|
||||
const data = omit(node.data, 'marks');
|
||||
return { ...node, data };
|
||||
}
|
||||
|
||||
default:
|
||||
return {
|
||||
...node,
|
||||
marks: node.marks.filter(({ type }) => type !== markType),
|
||||
};
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function getNodeMarks(node) {
|
||||
switch (node.type) {
|
||||
case 'link': {
|
||||
// Code marks can't always be condensed together. If all text in a link
|
||||
// is wrapped in a mark, this function returns that mark and the node
|
||||
// ends up nested inside of that mark. Code marks sometimes can't do
|
||||
// that, like when they wrap all of the text content of a link. Here we
|
||||
// remove code marks before processing so that they stay put.
|
||||
const nodesWithoutCode = node.nodes.map(n => ({
|
||||
...n,
|
||||
marks: n.marks ? n.marks.filter(({ type }) => type !== 'code') : n.marks,
|
||||
}));
|
||||
const childMarks = map(nodesWithoutCode, getNodeMarks);
|
||||
return intersection(...childMarks);
|
||||
}
|
||||
|
||||
case 'break':
|
||||
case 'image':
|
||||
return map(get(node, ['data', 'marks']), mark => mark.type);
|
||||
|
||||
default:
|
||||
return map(node.marks, mark => mark.type);
|
||||
}
|
||||
}
|
||||
|
||||
function getSharedMarks(marks, node) {
|
||||
const nodeMarks = getNodeMarks(node);
|
||||
const sharedMarks = intersection(marks, nodeMarks);
|
||||
if (sharedMarks[0] === 'code') {
|
||||
return nodeMarks.length === 1 ? marks : [];
|
||||
}
|
||||
return sharedMarks;
|
||||
}
|
||||
|
||||
function extractFirstMark(nodes) {
|
||||
let firstGroupMarks = getNodeMarks(nodes[0]) || [];
|
||||
|
||||
// If code mark is present, but there are other marks, process others first.
|
||||
// If only the code mark is present, don't allow it to be shared with other
|
||||
// nodes.
|
||||
if (firstGroupMarks[0] === 'code' && firstGroupMarks.length > 1) {
|
||||
firstGroupMarks = [...without('firstGroupMarks', 'code'), 'code'];
|
||||
}
|
||||
|
||||
let splitIndex = 1;
|
||||
|
||||
if (firstGroupMarks.length > 0) {
|
||||
while (splitIndex < nodes.length) {
|
||||
if (nodes[splitIndex]) {
|
||||
const sharedMarks = getSharedMarks(firstGroupMarks, nodes[splitIndex]);
|
||||
if (sharedMarks.length > 0) {
|
||||
firstGroupMarks = sharedMarks;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
splitIndex += 1;
|
||||
}
|
||||
}
|
||||
const condensedNodes = processedLeaves.reduce(condenseNodesReducer, { nodes: [] });
|
||||
return condensedNodes.nodes;
|
||||
}
|
||||
|
||||
if (node.object === 'inline') {
|
||||
return transform(node);
|
||||
}
|
||||
const markType = firstGroupMarks[0];
|
||||
const childNodes = nodes.slice(0, splitIndex);
|
||||
const updatedChildNodes = markType ? removeMarkFromNodes(childNodes, markType) : childNodes;
|
||||
const remainingNodes = nodes.slice(splitIndex);
|
||||
|
||||
return u('html', node.text);
|
||||
}
|
||||
|
||||
/**
|
||||
* Process Slate node leaves in preparation for MDAST transformation.
|
||||
*/
|
||||
function processLeaves(leaf) {
|
||||
/**
|
||||
* Get an array of the mark types, converted to their MDAST equivalent
|
||||
* types.
|
||||
*/
|
||||
const { marks = [], text } = leaf;
|
||||
const markTypes = marks.map(mark => markMap[mark.type]);
|
||||
|
||||
if (typeof leaf.text === 'string') {
|
||||
/**
|
||||
* Code marks must be removed from the marks array, and the presence of a
|
||||
* code mark changes the text node type that should be used.
|
||||
*/
|
||||
const { filteredMarkTypes, textNodeType } = processCodeMark(markTypes);
|
||||
return { text, marks: filteredMarkTypes, textNodeType };
|
||||
}
|
||||
|
||||
return { node: leaf.node, marks: markTypes };
|
||||
}
|
||||
|
||||
/**
|
||||
* Slate's AST doesn't group adjacent text nodes with the same marks - a
|
||||
* change in marks from letter to letter, even if some are in common, results
|
||||
* in a separate leaf. For example, given "**a_b_**", transformation to and
|
||||
* from Slate's AST will result in "**a****_b_**".
|
||||
*
|
||||
* MDAST treats styling entities as distinct nodes that contain children, so a
|
||||
* "strong" node can contain a plain text node with a sibling "emphasis" node,
|
||||
* which contains more text. This reducer serves to create an optimized nested
|
||||
* MDAST without the typical redundancies that Slate's AST would produce if
|
||||
* transformed as-is. The reducer can be called recursively to produce nested
|
||||
* structures.
|
||||
*/
|
||||
function condenseNodesReducer(acc, node, idx, nodes) {
|
||||
/**
|
||||
* Skip any nodes that are being processed as children of an MDAST node
|
||||
* through recursive calls.
|
||||
*/
|
||||
if (typeof acc.nextIndex === 'number' && acc.nextIndex > idx) {
|
||||
return acc;
|
||||
return [markType, updatedChildNodes, remainingNodes];
|
||||
}
|
||||
|
||||
/**
|
||||
* Processing for nodes with marks.
|
||||
* Converts the strings returned from `splitToNamedParts` to Slate nodes.
|
||||
*/
|
||||
if (node.marks && node.marks.length > 0) {
|
||||
/**
|
||||
* For each mark on the current node, get the number of consecutive nodes
|
||||
* (starting with this one) that have the mark. Whichever mark covers the
|
||||
* most nodes is used as the parent node, and the nodes with that mark are
|
||||
* processed as children. If the greatest number of consecutive nodes is
|
||||
* tied between multiple marks, there is no priority as to which goes
|
||||
* first.
|
||||
*/
|
||||
const markLengths = node.marks.map(mark => getMarkLength(mark, nodes.slice(idx)));
|
||||
const parentMarkLength = last(sortBy(markLengths, 'length'));
|
||||
const { markType: parentType, length: parentLength } = parentMarkLength;
|
||||
|
||||
/**
|
||||
* Since this and any consecutive nodes with the parent mark are going to
|
||||
* be processed as children of the parent mark, this reducer should simply
|
||||
* return the accumulator until after the last node to be covered by the
|
||||
* new parent node. Here we set the next index that should be processed,
|
||||
* if any.
|
||||
*/
|
||||
const newNextIndex = idx + parentLength;
|
||||
|
||||
/**
|
||||
* Get the set of nodes that should be processed as children of the new
|
||||
* parent mark node, run each through the reducer as children of the
|
||||
* parent node, and create the parent MDAST node with the resulting
|
||||
* children.
|
||||
*/
|
||||
const children = nodes.slice(idx, newNextIndex);
|
||||
const denestedChildren = children.map(child => ({
|
||||
...child,
|
||||
marks: without(child.marks, parentType),
|
||||
}));
|
||||
const mdastChildren = denestedChildren.reduce(condenseNodesReducer, { nodes: [], parentType })
|
||||
.nodes;
|
||||
const mdastNode = u(parentType, mdastChildren);
|
||||
|
||||
return { ...acc, nodes: [...acc.nodes, mdastNode], nextIndex: newNextIndex };
|
||||
function splitWhitespace(node, { trailing } = {}) {
|
||||
if (!node.text) {
|
||||
return { trimmedNode: node };
|
||||
}
|
||||
const exp = trailing ? trailingWhitespaceExp : leadingWhitespaceExp;
|
||||
const index = node.text.search(exp);
|
||||
if (index > -1) {
|
||||
const substringIndex = trailing ? index : index + 1;
|
||||
const firstSplit = node.text.substring(0, substringIndex);
|
||||
const secondSplit = node.text.substring(substringIndex);
|
||||
const whitespace = trailing ? secondSplit : firstSplit;
|
||||
const text = trailing ? firstSplit : secondSplit;
|
||||
return { whitespace, trimmedNode: { ...node, text } };
|
||||
}
|
||||
return { trimmedNode: node };
|
||||
}
|
||||
|
||||
/**
|
||||
* Create the base text node, and pass in the array of mark types as data
|
||||
* (helpful when optimizing/condensing the final structure).
|
||||
*/
|
||||
const baseNode =
|
||||
typeof node.text === 'string'
|
||||
? u(node.textNodeType, { marks: node.marks }, node.text)
|
||||
: transform(node.node);
|
||||
|
||||
/**
|
||||
* Recursively wrap the base text node in the individual mark nodes, if
|
||||
* any exist.
|
||||
*/
|
||||
return { ...acc, nodes: [...acc.nodes, baseNode] };
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the number of consecutive Slate nodes containing a given mark beginning
|
||||
* from the first received node.
|
||||
*/
|
||||
function getMarkLength(markType, nodes) {
|
||||
let length = 0;
|
||||
while (nodes[length] && nodes[length].marks.includes(markType)) {
|
||||
++length;
|
||||
function collectCenterNodes(nodes, leadingNode, trailingNode) {
|
||||
switch (nodes.length) {
|
||||
case 0:
|
||||
return [];
|
||||
case 1:
|
||||
return [trailingNode];
|
||||
case 2:
|
||||
return [leadingNode, trailingNode];
|
||||
default:
|
||||
return [leadingNode, ...nodes.slice(1, -1), trailingNode];
|
||||
}
|
||||
}
|
||||
return { markType, length };
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a single Slate Raw node to an MDAST node. Uses the unist-builder `u`
|
||||
* function to create MDAST nodes.
|
||||
*/
|
||||
function convertNode(node, children) {
|
||||
switch (node.type) {
|
||||
/**
|
||||
* General
|
||||
*
|
||||
* Convert simple cases that only require a type and children, with no
|
||||
* additional properties.
|
||||
*/
|
||||
case 'root':
|
||||
case 'paragraph':
|
||||
case 'quote':
|
||||
case 'list-item':
|
||||
case 'table':
|
||||
case 'table-row':
|
||||
case 'table-cell': {
|
||||
return u(typeMap[node.type], children);
|
||||
function normalizeFlankingWhitespace(nodes) {
|
||||
const { whitespace: leadingWhitespace, trimmedNode: leadingNode } = splitWhitespace(nodes[0]);
|
||||
const lastNode = nodes.length > 1 ? last(nodes) : leadingNode;
|
||||
const trailingSplitResult = splitWhitespace(lastNode, { trailing: true });
|
||||
const { whitespace: trailingWhitespace, trimmedNode: trailingNode } = trailingSplitResult;
|
||||
const centerNodes = collectCenterNodes(nodes, leadingNode, trailingNode).filter(val => val);
|
||||
return { leadingWhitespace, centerNodes, trailingWhitespace };
|
||||
}
|
||||
|
||||
function convertInlineAndTextChildren(nodes = []) {
|
||||
const convertedNodes = [];
|
||||
let remainingNodes = nodes;
|
||||
|
||||
while (remainingNodes.length > 0) {
|
||||
const nextNode = remainingNodes[0];
|
||||
if (nextNode.object === 'inline' || (nextNode.marks && nextNode.marks.length > 0)) {
|
||||
const [markType, markNodes, remainder] = extractFirstMark(remainingNodes);
|
||||
/**
|
||||
* A node with a code mark will be a text node, and will not be adjacent
|
||||
* to a sibling code node as the Slate schema requires them to be
|
||||
* merged. Markdown also requires at least a space between inline code
|
||||
* nodes.
|
||||
*/
|
||||
if (markType === 'code') {
|
||||
const node = markNodes[0];
|
||||
convertedNodes.push(u(markMap[markType], node.data, node.text));
|
||||
} else if (!markType && markNodes.length === 1 && markNodes[0].object === 'inline') {
|
||||
const node = markNodes[0];
|
||||
convertedNodes.push(convertInlineNode(node, convertInlineAndTextChildren(node.nodes)));
|
||||
} else {
|
||||
const {
|
||||
leadingWhitespace,
|
||||
trailingWhitespace,
|
||||
centerNodes,
|
||||
} = normalizeFlankingWhitespace(markNodes);
|
||||
const children = convertInlineAndTextChildren(centerNodes);
|
||||
const markNode = u(markMap[markType], children);
|
||||
|
||||
// Filter out empty marks, otherwise their output literally by
|
||||
// remark-stringify, eg. an empty bold node becomes "****"
|
||||
if (mdastToString(markNode) === '') {
|
||||
remainingNodes = remainder;
|
||||
continue;
|
||||
}
|
||||
const createText = text => text && u('html', text);
|
||||
const normalizedNodes = [
|
||||
createText(leadingWhitespace),
|
||||
markNode,
|
||||
createText(trailingWhitespace),
|
||||
].filter(val => val);
|
||||
convertedNodes.push(...normalizedNodes);
|
||||
}
|
||||
remainingNodes = remainder;
|
||||
} else {
|
||||
remainingNodes.shift();
|
||||
convertedNodes.push(u('html', nextNode.text));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Shortcodes
|
||||
*
|
||||
* Shortcode nodes only exist in Slate's Raw AST if they were inserted
|
||||
* via the plugin toolbar in memory, so they should always have
|
||||
* shortcode data attached. The "shortcode" data property contains the
|
||||
* name of the registered shortcode plugin, and the "shortcodeData" data
|
||||
* property contains the data received from the shortcode plugin's
|
||||
* `fromBlock` method when the shortcode node was created.
|
||||
*
|
||||
* Here we create a `shortcode` MDAST node that contains only the shortcode
|
||||
* data.
|
||||
*/
|
||||
case 'shortcode': {
|
||||
const { data } = node;
|
||||
return u(typeMap[node.type], { data });
|
||||
}
|
||||
return convertedNodes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Headings
|
||||
*
|
||||
* Slate schemas don't usually infer basic type info from data, so each
|
||||
* level of heading is a separately named type. The MDAST schema just
|
||||
* has a single "heading" type with the depth stored in a "depth"
|
||||
* property on the node. Here we derive the depth from the Slate node
|
||||
* type - e.g., for "heading-two", we need a depth value of "2".
|
||||
*/
|
||||
case 'heading-one':
|
||||
case 'heading-two':
|
||||
case 'heading-three':
|
||||
case 'heading-four':
|
||||
case 'heading-five':
|
||||
case 'heading-six': {
|
||||
const depthMap = { one: 1, two: 2, three: 3, four: 4, five: 5, six: 6 };
|
||||
const depthText = node.type.split('-')[1];
|
||||
const depth = depthMap[depthText];
|
||||
return u(typeMap[node.type], { depth }, children);
|
||||
}
|
||||
function convertBlockNode(node, children) {
|
||||
switch (node.type) {
|
||||
/**
|
||||
* General
|
||||
*
|
||||
* Convert simple cases that only require a type and children, with no
|
||||
* additional properties.
|
||||
*/
|
||||
case 'root':
|
||||
case 'paragraph':
|
||||
case 'quote':
|
||||
case 'list-item':
|
||||
case 'table':
|
||||
case 'table-row':
|
||||
case 'table-cell': {
|
||||
return u(typeMap[node.type], children);
|
||||
}
|
||||
|
||||
/**
|
||||
* Code Blocks
|
||||
*
|
||||
* Code block nodes have a single text child, and may have a code language
|
||||
* stored in the "lang" data property. Here we transfer both the node
|
||||
* value and the "lang" data property to the new MDAST node.
|
||||
*/
|
||||
case 'code': {
|
||||
const value = flatMap(node.nodes, child => {
|
||||
return flatMap(child.leaves, 'text');
|
||||
}).join('');
|
||||
const { lang, ...data } = get(node, 'data', {});
|
||||
return u(typeMap[node.type], { lang, data }, value);
|
||||
}
|
||||
/**
|
||||
* Shortcodes
|
||||
*
|
||||
* Shortcode nodes only exist in Slate's Raw AST if they were inserted
|
||||
* via the plugin toolbar in memory, so they should always have
|
||||
* shortcode data attached. The "shortcode" data property contains the
|
||||
* name of the registered shortcode plugin, and the "shortcodeData" data
|
||||
* property contains the data received from the shortcode plugin's
|
||||
* `fromBlock` method when the shortcode node was created.
|
||||
*
|
||||
* Here we create a `shortcode` MDAST node that contains only the shortcode
|
||||
* data.
|
||||
*/
|
||||
case 'shortcode': {
|
||||
const { data } = node;
|
||||
return u(typeMap[node.type], { data });
|
||||
}
|
||||
|
||||
/**
|
||||
* Lists
|
||||
*
|
||||
* Our Slate schema has separate node types for ordered and unordered
|
||||
* lists, but the MDAST spec uses a single type with a boolean "ordered"
|
||||
* property to indicate whether the list is numbered. The MDAST spec also
|
||||
* allows for a "start" property to indicate the first number used for an
|
||||
* ordered list. Here we translate both values to our Slate schema.
|
||||
*/
|
||||
case 'numbered-list':
|
||||
case 'bulleted-list': {
|
||||
const ordered = node.type === 'numbered-list';
|
||||
const props = { ordered, start: get(node.data, 'start') || 1 };
|
||||
return u(typeMap[node.type], props, children);
|
||||
}
|
||||
/**
|
||||
* Headings
|
||||
*
|
||||
* Slate schemas don't usually infer basic type info from data, so each
|
||||
* level of heading is a separately named type. The MDAST schema just
|
||||
* has a single "heading" type with the depth stored in a "depth"
|
||||
* property on the node. Here we derive the depth from the Slate node
|
||||
* type - e.g., for "heading-two", we need a depth value of "2".
|
||||
*/
|
||||
case 'heading-one':
|
||||
case 'heading-two':
|
||||
case 'heading-three':
|
||||
case 'heading-four':
|
||||
case 'heading-five':
|
||||
case 'heading-six': {
|
||||
const depthMap = { one: 1, two: 2, three: 3, four: 4, five: 5, six: 6 };
|
||||
const depthText = node.type.split('-')[1];
|
||||
const depth = depthMap[depthText];
|
||||
const mdastNode = u(typeMap[node.type], { depth }, children);
|
||||
if (mdastToString(mdastNode)) {
|
||||
return mdastNode;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
/**
|
||||
* Breaks
|
||||
*
|
||||
* Breaks don't have children. We parse them separately for clarity.
|
||||
*/
|
||||
case 'break':
|
||||
case 'thematic-break': {
|
||||
return u(typeMap[node.type]);
|
||||
}
|
||||
/**
|
||||
* Code Blocks
|
||||
*
|
||||
* Code block nodes may have a single text child, or instead be void and
|
||||
* store their value in `data.code`. They also may have a code language
|
||||
* stored in the "lang" data property. Here we transfer both the node value
|
||||
* and the "lang" data property to the new MDAST node, and spread any
|
||||
* remaining data as `data`.
|
||||
*/
|
||||
case 'code-block': {
|
||||
const { lang, code, ...data } = get(node, 'data', {});
|
||||
const value = voidCodeBlock ? code : children[0]?.value;
|
||||
return u(typeMap[node.type], { lang, data }, value || '');
|
||||
}
|
||||
|
||||
/**
|
||||
* Links
|
||||
*
|
||||
* The url and title attributes of link nodes are stored in properties on
|
||||
* the node for both Slate and Remark schemas.
|
||||
*/
|
||||
case 'link': {
|
||||
const { url, title, ...data } = get(node, 'data', {});
|
||||
return u(typeMap[node.type], { url, title, data }, children);
|
||||
}
|
||||
/**
|
||||
* Lists
|
||||
*
|
||||
* Our Slate schema has separate node types for ordered and unordered
|
||||
* lists, but the MDAST spec uses a single type with a boolean "ordered"
|
||||
* property to indicate whether the list is numbered. The MDAST spec also
|
||||
* allows for a "start" property to indicate the first number used for an
|
||||
* ordered list. Here we translate both values to our Slate schema.
|
||||
*/
|
||||
case 'numbered-list':
|
||||
case 'bulleted-list': {
|
||||
const ordered = node.type === 'numbered-list';
|
||||
const props = { ordered, start: get(node.data, 'start') || 1 };
|
||||
return u(typeMap[node.type], props, children);
|
||||
}
|
||||
|
||||
/**
|
||||
* Images
|
||||
*
|
||||
* This transformation is almost identical to that of links, except for the
|
||||
* lack of child nodes and addition of `alt` attribute data.
|
||||
*/
|
||||
case 'image': {
|
||||
const { url, title, alt, ...data } = get(node, 'data', {});
|
||||
return u(typeMap[node.type], { url, title, alt, data });
|
||||
/**
|
||||
* Thematic Break
|
||||
*
|
||||
* Thematic break is a block level break. They cannot have children.
|
||||
*/
|
||||
case 'thematic-break': {
|
||||
return u(typeMap[node.type]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* No default case is supplied because an unhandled case should never
|
||||
* occur. In the event that it does, let the error throw (for now).
|
||||
*/
|
||||
function convertInlineNode(node, children) {
|
||||
switch (node.type) {
|
||||
/**
|
||||
* Break
|
||||
*
|
||||
* Breaks are phrasing level breaks. They cannot have children.
|
||||
*/
|
||||
case 'break': {
|
||||
return u(typeMap[node.type]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Links
|
||||
*
|
||||
* The url and title attributes of link nodes are stored in properties on
|
||||
* the node for both Slate and Remark schemas.
|
||||
*/
|
||||
case 'link': {
|
||||
const { url, title, ...data } = get(node, 'data', {});
|
||||
return u(typeMap[node.type], { url, title, data }, children);
|
||||
}
|
||||
|
||||
/**
|
||||
* Images
|
||||
*
|
||||
* This transformation is almost identical to that of links, except for the
|
||||
* lack of child nodes and addition of `alt` attribute data.
|
||||
*/
|
||||
case 'image': {
|
||||
const { url, title, alt, ...data } = get(node, 'data', {});
|
||||
return u(typeMap[node.type], { url, title, alt, data });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -5,7 +5,7 @@ export const editorStyleVars = {
|
||||
};
|
||||
|
||||
export const EditorControlBar = styled.div`
|
||||
z-index: 1;
|
||||
z-index: 200;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
margin-bottom: ${editorStyleVars.stickyDistanceBottom};
|
||||
|
3
packages/netlify-cms-widget-markdown/src/types.js
Normal file
3
packages/netlify-cms-widget-markdown/src/types.js
Normal file
@ -0,0 +1,3 @@
|
||||
export const SLATE_DEFAULT_BLOCK_TYPE = 'paragraph';
|
||||
|
||||
export const SLATE_BLOCK_PARENT_TYPES = ['list-item', 'quote'];
|
32
packages/netlify-cms-widget-markdown/test-helpers/h.js
Normal file
32
packages/netlify-cms-widget-markdown/test-helpers/h.js
Normal 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;
|
@ -13,7 +13,8 @@ const styleStrings = {
|
||||
border-top-right-radius: 0;
|
||||
`,
|
||||
objectWidgetTopBarContainer: `
|
||||
padding: ${lengths.objectWidgetTopBarContainerPadding}
|
||||
padding: ${lengths.objectWidgetTopBarContainerPadding};
|
||||
overflow: hidden;
|
||||
`,
|
||||
};
|
||||
|
||||
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user