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