fix(markdown-widget): support arbitrary component order (#5597)
This commit is contained in:
@ -28,7 +28,6 @@ export default function createEditorComponent(config) {
|
|||||||
type,
|
type,
|
||||||
icon,
|
icon,
|
||||||
widget,
|
widget,
|
||||||
// enforce multiline flag, exclude others
|
|
||||||
pattern,
|
pattern,
|
||||||
fromBlock: bind(fromBlock) || (() => ({})),
|
fromBlock: bind(fromBlock) || (() => ({})),
|
||||||
toBlock: bind(toBlock) || (() => 'Plugin'),
|
toBlock: bind(toBlock) || (() => 'Plugin'),
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
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 { List } from 'immutable';
|
import { List, Map } from 'immutable';
|
||||||
|
|
||||||
import RawEditor from './RawEditor';
|
import RawEditor from './RawEditor';
|
||||||
import VisualEditor from './VisualEditor';
|
import VisualEditor from './VisualEditor';
|
||||||
@ -12,7 +12,7 @@ const MODE_STORAGE_KEY = 'cms.md-mode';
|
|||||||
// be handled through Redux and a separate registry store for instances
|
// be handled through Redux and a separate registry store for instances
|
||||||
let editorControl;
|
let editorControl;
|
||||||
// eslint-disable-next-line func-style
|
// eslint-disable-next-line func-style
|
||||||
let _getEditorComponents = () => [];
|
let _getEditorComponents = () => Map();
|
||||||
|
|
||||||
export function getEditorControl() {
|
export function getEditorControl() {
|
||||||
return editorControl;
|
return editorControl;
|
||||||
|
@ -1,4 +1,6 @@
|
|||||||
import { remarkParseShortcodes } from '../remarkShortcodes';
|
import { Map, OrderedMap } from 'immutable';
|
||||||
|
|
||||||
|
import { remarkParseShortcodes, getLinesWithOffsets } from '../remarkShortcodes';
|
||||||
|
|
||||||
// Stub of Remark Parser
|
// Stub of Remark Parser
|
||||||
function process(value, plugins, processEat = () => {}) {
|
function process(value, plugins, processEat = () => {}) {
|
||||||
@ -14,34 +16,61 @@ function process(value, plugins, processEat = () => {}) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function EditorComponent({ id = 'foo', fromBlock = jest.fn(), pattern }) {
|
function EditorComponent({ id = 'foo', fromBlock = jest.fn(), pattern }) {
|
||||||
// initialize pattern as RegExp as done in the EditorComponent value object
|
return {
|
||||||
return { id, fromBlock, pattern: new RegExp(pattern, 'm') };
|
id,
|
||||||
|
fromBlock,
|
||||||
|
pattern,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
describe('remarkParseShortcodes', () => {
|
describe('remarkParseShortcodes', () => {
|
||||||
describe('pattern matching', () => {
|
describe('pattern matching', () => {
|
||||||
it('should work', () => {
|
it('should work', () => {
|
||||||
const editorComponent = EditorComponent({ pattern: /bar/ });
|
const editorComponent = EditorComponent({ pattern: /bar/ });
|
||||||
process('foo bar', [editorComponent]);
|
process('foo bar', Map({ [editorComponent.id]: editorComponent }));
|
||||||
expect(editorComponent.fromBlock).toHaveBeenCalledWith(expect.arrayContaining(['bar']));
|
expect(editorComponent.fromBlock).toHaveBeenCalledWith(expect.arrayContaining(['bar']));
|
||||||
});
|
});
|
||||||
it('should match value surrounded in newlines', () => {
|
it('should match value surrounded in newlines', () => {
|
||||||
const editorComponent = EditorComponent({ pattern: /^bar$/ });
|
const editorComponent = EditorComponent({ pattern: /^bar$/ });
|
||||||
process('foo\n\nbar\n', [editorComponent]);
|
process('foo\n\nbar\n', Map({ [editorComponent.id]: editorComponent }));
|
||||||
expect(editorComponent.fromBlock).toHaveBeenCalledWith(expect.arrayContaining(['bar']));
|
expect(editorComponent.fromBlock).toHaveBeenCalledWith(expect.arrayContaining(['bar']));
|
||||||
});
|
});
|
||||||
it('should match multiline shortcodes', () => {
|
it('should match multiline shortcodes', () => {
|
||||||
const editorComponent = EditorComponent({ pattern: /^foo\nbar$/ });
|
const editorComponent = EditorComponent({ pattern: /^foo\nbar$/ });
|
||||||
process('foo\nbar', [editorComponent]);
|
process('foo\nbar', Map({ [editorComponent.id]: editorComponent }));
|
||||||
expect(editorComponent.fromBlock).toHaveBeenCalledWith(expect.arrayContaining(['foo\nbar']));
|
expect(editorComponent.fromBlock).toHaveBeenCalledWith(expect.arrayContaining(['foo\nbar']));
|
||||||
});
|
});
|
||||||
it('should match multiline shortcodes with empty lines', () => {
|
it('should match multiline shortcodes with empty lines', () => {
|
||||||
const editorComponent = EditorComponent({ pattern: /^foo\n\nbar$/ });
|
const editorComponent = EditorComponent({ pattern: /^foo\n\nbar$/ });
|
||||||
process('foo\n\nbar', [editorComponent]);
|
process('foo\n\nbar', Map({ [editorComponent.id]: editorComponent }));
|
||||||
expect(editorComponent.fromBlock).toHaveBeenCalledWith(
|
expect(editorComponent.fromBlock).toHaveBeenCalledWith(
|
||||||
expect.arrayContaining(['foo\n\nbar']),
|
expect.arrayContaining(['foo\n\nbar']),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
it('should match shortcodes based on order of occurrence in value', () => {
|
||||||
|
const fooEditorComponent = EditorComponent({ id: 'foo', pattern: /foo/ });
|
||||||
|
const barEditorComponent = EditorComponent({ id: 'bar', pattern: /bar/ });
|
||||||
|
process(
|
||||||
|
'foo\n\nbar',
|
||||||
|
OrderedMap([
|
||||||
|
[barEditorComponent.id, barEditorComponent],
|
||||||
|
[fooEditorComponent.id, fooEditorComponent],
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
expect(fooEditorComponent.fromBlock).toHaveBeenCalledWith(expect.arrayContaining(['foo']));
|
||||||
|
});
|
||||||
|
it('should match shortcodes based on order of occurrence in value even when some use line anchors', () => {
|
||||||
|
const barEditorComponent = EditorComponent({ id: 'bar', pattern: /bar/ });
|
||||||
|
const bazEditorComponent = EditorComponent({ id: 'baz', pattern: /^baz$/ });
|
||||||
|
process(
|
||||||
|
'foo\n\nbar\n\nbaz',
|
||||||
|
OrderedMap([
|
||||||
|
[bazEditorComponent.id, bazEditorComponent],
|
||||||
|
[barEditorComponent.id, barEditorComponent],
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
expect(barEditorComponent.fromBlock).toHaveBeenCalledWith(expect.arrayContaining(['bar']));
|
||||||
|
});
|
||||||
});
|
});
|
||||||
describe('output', () => {
|
describe('output', () => {
|
||||||
it('should be a remark shortcode node', () => {
|
it('should be a remark shortcode node', () => {
|
||||||
@ -49,8 +78,29 @@ describe('remarkParseShortcodes', () => {
|
|||||||
const shortcodeData = { bar: 'baz' };
|
const shortcodeData = { bar: 'baz' };
|
||||||
const expectedNode = { type: 'shortcode', data: { shortcode: 'foo', shortcodeData } };
|
const expectedNode = { type: 'shortcode', data: { shortcode: 'foo', shortcodeData } };
|
||||||
const editorComponent = EditorComponent({ pattern: /bar/, fromBlock: () => shortcodeData });
|
const editorComponent = EditorComponent({ pattern: /bar/, fromBlock: () => shortcodeData });
|
||||||
process('foo bar', [editorComponent], processEat);
|
process('foo bar', Map({ [editorComponent.id]: editorComponent }), processEat);
|
||||||
expect(processEat).toHaveBeenCalledWith(expectedNode);
|
expect(processEat).toHaveBeenCalledWith(expectedNode);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('getLinesWithOffsets', () => {
|
||||||
|
test('should split into lines', () => {
|
||||||
|
const value = ' line1\n\nline2 \n\n line3 \n\n';
|
||||||
|
|
||||||
|
const lines = getLinesWithOffsets(value);
|
||||||
|
expect(lines).toEqual([
|
||||||
|
{ line: ' line1', start: 0 },
|
||||||
|
{ line: 'line2', start: 8 },
|
||||||
|
{ line: ' line3', start: 16 },
|
||||||
|
{ line: '', start: 30 },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should return single item on no match', () => {
|
||||||
|
const value = ' line1 ';
|
||||||
|
|
||||||
|
const lines = getLinesWithOffsets(value);
|
||||||
|
expect(lines).toEqual([{ line: ' line1', start: 0 }]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
@ -8,19 +8,63 @@ export function remarkParseShortcodes({ plugins }) {
|
|||||||
methods.unshift('shortcode');
|
methods.unshift('shortcode');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getLinesWithOffsets(value) {
|
||||||
|
const SEPARATOR = '\n\n';
|
||||||
|
const splitted = value.split(SEPARATOR);
|
||||||
|
const trimmedLines = splitted
|
||||||
|
.reduce(
|
||||||
|
(acc, line) => {
|
||||||
|
const { start: previousLineStart, originalLength: previousLineOriginalLength } =
|
||||||
|
acc[acc.length - 1];
|
||||||
|
|
||||||
|
return [
|
||||||
|
...acc,
|
||||||
|
{
|
||||||
|
line: line.trimEnd(),
|
||||||
|
start: previousLineStart + previousLineOriginalLength + SEPARATOR.length,
|
||||||
|
originalLength: line.length,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
},
|
||||||
|
[{ start: -SEPARATOR.length, originalLength: 0 }],
|
||||||
|
)
|
||||||
|
.slice(1)
|
||||||
|
.map(({ line, start }) => ({ line, start }));
|
||||||
|
return trimmedLines;
|
||||||
|
}
|
||||||
|
|
||||||
|
function matchFromLines({ trimmedLines, plugin }) {
|
||||||
|
for (const { line, start } of trimmedLines) {
|
||||||
|
const match = line.match(plugin.pattern);
|
||||||
|
if (match) {
|
||||||
|
match.index += start;
|
||||||
|
return match;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function createShortcodeTokenizer({ plugins }) {
|
function createShortcodeTokenizer({ plugins }) {
|
||||||
return function tokenizeShortcode(eat, value, silent) {
|
return function tokenizeShortcode(eat, value, silent) {
|
||||||
let match;
|
// Plugin patterns may rely on `^` and `$` tokens, even if they don't
|
||||||
const potentialMatchValue = value.split('\n\n')[0].trimEnd();
|
// use the multiline flag. To support this, we fall back to searching
|
||||||
const plugin = plugins.find(plugin => {
|
// through each line individually, trimming trailing whitespace and
|
||||||
match = value.match(plugin.pattern);
|
// newlines, if we don't initially match on a pattern. We keep track of
|
||||||
|
// the starting position of each line so that we can sort correctly
|
||||||
|
// across the full multiline matches.
|
||||||
|
const trimmedLines = getLinesWithOffsets(value);
|
||||||
|
|
||||||
if (!match) {
|
// Attempt to find a regex match for each plugin's pattern, and then
|
||||||
match = potentialMatchValue.match(plugin.pattern);
|
// select the first by its occurrence in `value`. This ensures we won't
|
||||||
}
|
// skip a plugin that occurs later in the plugin registry, but earlier
|
||||||
|
// in the `value`.
|
||||||
return !!match;
|
const [{ plugin, match } = {}] = plugins
|
||||||
});
|
.toArray()
|
||||||
|
.map(plugin => ({
|
||||||
|
match: value.match(plugin.pattern) || matchFromLines({ trimmedLines, plugin }),
|
||||||
|
plugin,
|
||||||
|
}))
|
||||||
|
.filter(({ match }) => !!match)
|
||||||
|
.sort((a, b) => a.match.index - b.match.index);
|
||||||
|
|
||||||
if (match) {
|
if (match) {
|
||||||
if (silent) {
|
if (silent) {
|
||||||
|
@ -118,29 +118,69 @@ CMS.registerEditorComponent(definition)
|
|||||||
<script>
|
<script>
|
||||||
CMS.registerEditorComponent({
|
CMS.registerEditorComponent({
|
||||||
// Internal id of the component
|
// Internal id of the component
|
||||||
id: "youtube",
|
id: "collapsible-note",
|
||||||
// Visible label
|
// Visible label
|
||||||
label: "Youtube",
|
label: "Collapsible Note",
|
||||||
// Fields the user need to fill out when adding an instance of the component
|
// Fields the user need to fill out when adding an instance of the component
|
||||||
fields: [{name: 'id', label: 'Youtube Video ID', widget: 'string'}],
|
fields: [
|
||||||
// Pattern to identify a block as being an instance of this component
|
{
|
||||||
pattern: /^youtube (\S+)$/,
|
name: 'summary',
|
||||||
// Function to extract data elements from the regexp match
|
label: 'Summary',
|
||||||
|
widget: 'string'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'details',
|
||||||
|
label: 'Details',
|
||||||
|
widget: 'markdown'
|
||||||
|
}
|
||||||
|
],
|
||||||
|
// Regex pattern used to search for instances of this block in the markdown document.
|
||||||
|
// Patterns are run in a multline environment (against the entire markdown document),
|
||||||
|
// and so generally should make use of the multiline flag (`m`). If you need to capture
|
||||||
|
// newlines in your capturing groups, you can either use something like
|
||||||
|
// `([\S\s]*)`, or you can additionally enable the "dot all" flag (`s`),
|
||||||
|
// which will cause `(.*)` to match newlines as well.
|
||||||
|
//
|
||||||
|
// Additionally, it's recommended that you use non-greedy capturing groups (e.g.
|
||||||
|
// `(.*?)` vs `(.*)`), especially if matching against newline characters.
|
||||||
|
pattern: /^<details>$\s*?<summary>(.*?)<\/summary>\n\n(.*?)\n^<\/details>$/ms,
|
||||||
|
// Given a RegExp Match object
|
||||||
|
// (https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/match#return_value),
|
||||||
|
// return an object with one property for each field defined in `fields`.
|
||||||
|
//
|
||||||
|
// This is used to populate the custom widget in the markdown editor in the CMS.
|
||||||
fromBlock: function(match) {
|
fromBlock: function(match) {
|
||||||
return {
|
return {
|
||||||
id: match[1]
|
summary: match[1],
|
||||||
|
detail: match[2]
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
// Function to create a text block from an instance of this component
|
// Given an object with one property for each field defined in `fields`,
|
||||||
toBlock: function(obj) {
|
// return the string you wish to be inserted into your markdown.
|
||||||
return 'youtube ' + obj.id;
|
//
|
||||||
|
// This is used to serialize the data from the custom widget to the
|
||||||
|
// markdown document
|
||||||
|
toBlock: function(data) {
|
||||||
|
return `
|
||||||
|
<details>
|
||||||
|
<summary>${data.summary}</summary>
|
||||||
|
|
||||||
|
${data.detail}
|
||||||
|
|
||||||
|
</details>
|
||||||
|
`;
|
||||||
},
|
},
|
||||||
// Preview output for this component. Can either be a string or a React component
|
// Preview output for this component. Can either be a string or a React component
|
||||||
// (component gives better render performance)
|
// (component gives better render performance)
|
||||||
toPreview: function(obj) {
|
toPreview: function(data) {
|
||||||
return (
|
return `
|
||||||
'<img src="http://img.youtube.com/vi/' + obj.id + '/maxresdefault.jpg" alt="Youtube Video"/>'
|
<details>
|
||||||
);
|
<summary>${data.summary}</summary>
|
||||||
|
|
||||||
|
${data.detail}
|
||||||
|
|
||||||
|
</details>
|
||||||
|
`;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
Reference in New Issue
Block a user