fix(markdown-widget): support arbitrary component order (#5597)

This commit is contained in:
SMores
2021-08-17 06:01:52 -04:00
committed by GitHub
parent f7f07c5ad7
commit fbfab7cda5
5 changed files with 168 additions and 35 deletions

View File

@ -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'),

View File

@ -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;

View File

@ -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 }]);
});
});

View File

@ -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) {

View File

@ -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>