487 lines
16 KiB
JavaScript
Raw Normal View History

WIP - Global UI (#785) * update top bar and collections sidebar UI * update collection entries UI * improve global layout * merge search page into collection page * enable new entry button * search fixup * wip -initial editor update * update editor scrolling and markdown toolbar position * wip * editor toolbar progress * editor toolbar wip * finished basic editor toolbar * add standalone toggle component * improve markdown toolbar spacing * add user avatar placeholder * finish markdown toggle styling * refactor icon setup, add new icons * add new icons to markdown editor toolbar * remove extra app container * add markdown active mark style * relation and text widget styling * widget design updates, basic list/object design update * widget style updates, image widget improvements * refactor widget directory, fix file removal * widget focus styles * finish editor widget focus styles * migrate media library modal to react-modal * wip - migrate editor component form to modal * wip - move editor component form to modal * wip - embed plugin forms in the editor * inline shortcode forms working * disable react hot loading, its breaking things * improve shortcode form styles * make shortcode form collapsible, improve styling * add close functionality to shortcode blocks * improve base media library styling * fix shortcode label * migrate unstyled workflow to new UI * wip - reorganizing everything * more work moving everything * finish more moving and eliminating stuff * restructure, remove react-toolbox * wip - removing old stuff, more restructure * finish restructure * wip - css arch * switch back to test repo * update react-datetime to ^2.11.0 * remove leftover react-toolbox button * more restructuring clean-up * fix UI component directory case * wip -css editor control style * wip - consolidate widget styles * wip - use a single control renderer * fixed object values breaking * wip - editor control active styles * pass control wrapper to widgets * ensure branch name is trimmed * wip - improve widget authoring support * import Map to Widget component * refactor toolbar buttons * wip - more widget active styles * break out editor toggle component * add local scroll sync back * update editor toggle icons * limit editor control pane content width * fix editor control spacing * migrate markdown toolbar stickiness to css * fix markdown toolbar border radius * temporarily use test backend * stop markdown toolbar from going to bottom * restore disabled markdown toolbar buttons for raw * test markdown widget without focus styles * more widget updates * remove card visuals from editor * disable dragging editor split off screen * use editorControl component for shortcode fields * make header site link configurable * add configurable collection descriptions * temporarily add example assets * add basic list view * remove outdated css mixins * add and implement search icon * activate quick add menu * visualize usable space in editor view * fix entry close, other improvements * wip - editorial workflow updates * some dropshadow and other CSS tweaks * workflow ui updates * add worfklow card buttons * fix workflow card button handlers * some dropshadow and other CSS tweaks * make workflow board wider * center workflow and collection views * add basic responsiveness * a bunch of fun UI fixes! a BUNCH! (#875) * give `.nc-entryEditor-toolbar-mainSection` left and right child divs * a bunch of fun UI fixes! a BUNCH! * remove obscure --buttonShadow * revert to test repo * fix not found page styling * allow workflow publishing from any column * disallow publishing from all columns, with feedback * fix new entry button * fix markdown state persisting across entries * enable simple workflow save and new from editor * update slug in address bar when saving new entry * wip - workflow updates, deletion working * add status change functionality to editor * wip - improving status change from editor * editor toolbar back button improvements, loading improvements, cleanup * progress on the media library UI cleanup * remove font smothing css * a quick fix for these buttons * tweaks * progress on media library modal— broken FYI * fix media library functionality, finish migrating footer * remove media library footer files * remove leftover css import * fix media library * editor publishing functionality complete (unstyled) * remove leftover loader var from media library * wip - editor publishing styles * add status dropdown styling * editor toolbar style updates * editor toolbar state improvements * progress on the media library UI cleanup, style improvements * finish editorial workflow editor styling * finish media library styling * fix config * add what-input to optimize focus styling * fix button * fix navigation blocking for simple workflow * improve simple workflow publishing * add avatar dropdown to editor top bar * style github and test-repo auth pages * add git gateway auth page styles * improve editor error styling
2017-12-07 12:37:10 -05:00
import { get, isEmpty, without, flatMap, last, sortBy } from 'lodash';
2017-07-31 16:41:40 -04:00
import u from 'unist-builder';
/**
* Map of Slate node types to MDAST/Remark node types.
*/
const typeMap = {
root: 'root',
paragraph: 'paragraph',
2017-07-31 16:41:40 -04:00
'heading-one': 'heading',
'heading-two': 'heading',
'heading-three': 'heading',
'heading-four': 'heading',
'heading-five': 'heading',
'heading-six': 'heading',
quote: 'blockquote',
code: 'code',
2017-07-31 16:41:40 -04:00
'numbered-list': 'list',
'bulleted-list': 'list',
'list-item': 'listItem',
table: 'table',
2017-07-31 16:41:40 -04:00
'table-row': 'tableRow',
'table-cell': 'tableCell',
break: 'break',
2017-07-31 16:41:40 -04:00
'thematic-break': 'thematicBreak',
link: 'link',
image: 'image',
2017-07-31 16:41:40 -04:00
};
/**
* Map of Slate mark types to MDAST/Remark node types.
*/
const markMap = {
bold: 'strong',
italic: 'emphasis',
strikethrough: 'delete',
code: 'inlineCode',
};
let shortcodePlugins;
export default function slateToRemark(raw, opts) {
/**
* Set shortcode plugins in outer scope.
*/
({ shortcodePlugins } = opts);
/**
* The Slate Raw AST generally won't have a top level type, so we set it to
* "root" for clarity.
*/
raw.type = 'root';
return transform(raw);
}
/**
* The transform function mimics the approach of a Remark plugin for
* conformity with the other serialization functions. This function converts
* Slate nodes to MDAST nodes, and recursively calls itself to process child
* nodes to arbitrary depth.
*/
function transform(node) {
/**
* Combine adjacent text and inline nodes before processing so they can
* share marks.
*/
const combinedChildren = node.nodes && combineTextAndInline(node.nodes);
/**
* Call `transform` recursively on child nodes, and flatten the resulting
* array.
*/
const children = !isEmpty(combinedChildren) && flatMap(combinedChildren, transform);
/**
* Run individual nodes through conversion factories.
*/
return ['text'].includes(node.object)
? convertTextNode(node)
: convertNode(node, children, shortcodePlugins);
}
/**
2017-11-16 10:15:45 -05:00
* 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);
2017-11-16 10:15:45 -05:00
const prevNodeLeaves = get(prevNode, 'leaves');
const data = node.data || {};
/**
2017-11-16 10:15:45 -05:00
* If the previous node has leaves and the current node has marks in data
* (only happens when we place them on inline nodes here in the parser), or
2017-11-16 10:15:45 -05:00
* 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.
*/
2017-11-16 10:15:45 -05:00
if (!isEmpty(prevNodeLeaves) && !isEmpty(data.marks)) {
prevNodeLeaves.push({ node, marks: data.marks });
return acc;
}
2017-11-16 10:15:45 -05:00
if (!isEmpty(prevNodeLeaves) && !isEmpty(node.leaves)) {
prevNode.leaves = prevNodeLeaves.concat(node.leaves);
return acc;
}
2017-09-25 18:17:44 -04:00
/**
* Break nodes contain a single child text node with a newline character
* for visual purposes in the editor, but Remark break nodes have no
* children, so we remove the child node here.
*/
if (node.type === 'break') {
acc.push({ object: 'inline', type: 'break' });
2017-09-25 18:17:44 -04:00
return acc;
}
/**
2017-11-16 10:15:45 -05:00
* 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;
}, []);
}
2017-07-31 16:41:40 -04:00
/**
* 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
2017-11-16 10:15:45 -05:00
* "leaves" property containing an array of objects, each with an array of
2017-07-31 16:41:40 -04:00
* 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.
*
2017-11-16 10:15:45 -05:00
* To convert a Slate text node's marks to MDAST, we treat each "leaf" as a
2017-07-31 16:41:40 -04:00
* 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
2017-11-16 10:15:45 -05:00
* of each leaf in a single array of child nodes.
2017-07-31 16:41:40 -04:00
*
* For example, this Slate text node:
*
* {
* object: 'text',
2017-11-16 10:15:45 -05:00
* leaves: [
2017-07-31 16:41:40 -04:00
* {
* 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) {
/**
2017-11-16 10:15:45 -05:00
* 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.
2017-07-31 16:41:40 -04:00
*/
2017-11-16 10:15:45 -05:00
if (node.leaves) {
const processedLeaves = node.leaves.map(processLeaves);
const condensedNodes = processedLeaves.reduce(condenseNodesReducer, { nodes: [] });
return condensedNodes.nodes;
}
if (node.object === 'inline') {
return transform(node);
2017-07-31 16:41:40 -04:00
}
return u('html', node.text);
}
/**
2017-11-16 10:15:45 -05:00
* Process Slate node leaves in preparation for MDAST transformation.
*/
2017-11-16 16:37:02 -05:00
function processLeaves(leaf) {
2017-07-31 16:41:40 -04:00
/**
* Get an array of the mark types, converted to their MDAST equivalent
* types.
2017-07-31 16:41:40 -04:00
*/
2017-11-16 10:15:45 -05:00
const { marks = [], text } = leaf;
const markTypes = marks.map(mark => markMap[mark.type]);
2017-07-31 16:41:40 -04:00
2017-11-16 10:15:45 -05:00
if (typeof leaf.text === 'string') {
2017-07-31 16:41:40 -04:00
/**
* 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 };
}
2017-11-16 10:15:45 -05:00
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
2017-11-16 10:15:45 -05:00
* 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.
*/
if (node.marks && node.marks.length > 0) {
/**
* For each mark on the current node, get the number of consecutive nodes
* (starting with this one) that have the mark. Whichever mark covers the
* most nodes is used as the parent node, and the nodes with that mark are
* processed as children. If the greatest number of consecutive nodes is
* tied between multiple marks, there is no priority as to which goes
* first.
*/
const markLengths = node.marks.map(mark => getMarkLength(mark, nodes.slice(idx)));
const parentMarkLength = last(sortBy(markLengths, 'length'));
const { markType: parentType, length: parentLength } = parentMarkLength;
/**
* Since this and any consecutive nodes with the parent mark are going to
* be processed as children of the parent mark, this reducer should simply
* return the accumulator until after the last node to be covered by the
* new parent node. Here we set the next index that should be processed,
* if any.
*/
const newNextIndex = idx + parentLength;
2017-07-31 16:41:40 -04:00
/**
* 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.
2017-07-31 16:41:40 -04:00
*/
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 };
}
/**
* Create the base text node, and pass in the array of mark types as data
* (helpful when optimizing/condensing the final structure).
*/
const baseNode =
typeof node.text === 'string'
? u(node.textNodeType, { marks: node.marks }, node.text)
: transform(node.node);
2017-07-31 16:41:40 -04:00
/**
* Recursively wrap the base text node in the individual mark nodes, if
* any exist.
2017-07-31 16:41:40 -04:00
*/
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 };
2017-07-31 16:41:40 -04:00
}
/**
* Convert a single Slate Raw node to an MDAST node. Uses the unist-builder `u`
* function to create MDAST nodes and parses shortcodes.
*/
function convertNode(node, children, shortcodePlugins) {
switch (node.type) {
/**
* General
*
* Convert simple cases that only require a type and children, with no
* additional properties.
*/
case 'root':
case 'paragraph':
case 'quote':
case 'list-item':
case 'table':
case 'table-row':
case 'table-cell': {
return u(typeMap[node.type], children);
}
/**
* 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 get the shortcode plugin from the registry and use it's
* `toBlock` method to recreate the original markdown shortcode. We then
* insert that text into a new "html" type node (a "text" type node
* might get encoded or escaped by remark-stringify). Finally, we wrap
* the "html" node in a "paragraph" type node, as shortcode nodes must
* be alone in their own paragraph.
*/
case 'shortcode': {
const { data } = node;
const plugin = shortcodePlugins.get(data.shortcode);
const text = plugin.toBlock(data.shortcodeData);
const textNode = u('html', text);
return u('paragraph', { data }, [textNode]);
2017-07-31 16:41:40 -04:00
}
/**
* Headings
*
* Slate schemas don't usually infer basic type info from data, so each
* level of heading is a separately named type. The MDAST schema just
* has a single "heading" type with the depth stored in a "depth"
* property on the node. Here we derive the depth from the Slate node
* type - e.g., for "heading-two", we need a depth value of "2".
*/
case 'heading-one':
case 'heading-two':
case 'heading-three':
case 'heading-four':
case 'heading-five':
case 'heading-six': {
const depthMap = { one: 1, two: 2, three: 3, four: 4, five: 5, six: 6 };
const depthText = node.type.split('-')[1];
const depth = depthMap[depthText];
return u(typeMap[node.type], { depth }, children);
}
/**
* Code Blocks
*
* Code block nodes have a single text child, and may have a code language
* stored in the "lang" data property. Here we transfer both the node
* value and the "lang" data property to the new MDAST node.
*/
case 'code': {
const value = flatMap(node.nodes, child => {
2017-11-16 10:15:45 -05:00
return flatMap(child.leaves, 'text');
}).join('');
const { lang, ...data } = get(node, 'data', {});
return u(typeMap[node.type], { lang, data }, value);
2017-07-31 16:41:40 -04:00
}
/**
* Lists
*
* Our Slate schema has separate node types for ordered and unordered
* lists, but the MDAST spec uses a single type with a boolean "ordered"
* property to indicate whether the list is numbered. The MDAST spec also
* allows for a "start" property to indicate the first number used for an
* ordered list. Here we translate both values to our Slate schema.
*/
case 'numbered-list':
case 'bulleted-list': {
const ordered = node.type === 'numbered-list';
const props = { ordered, start: get(node.data, 'start') || 1 };
return u(typeMap[node.type], props, children);
}
/**
2017-09-25 18:17:44 -04:00
* Breaks
2017-07-31 16:41:40 -04:00
*
2017-09-25 18:17:44 -04:00
* Breaks don't have children. We parse them separately for clarity.
2017-07-31 16:41:40 -04:00
*/
2017-09-25 18:17:44 -04:00
case 'break':
2017-07-31 16:41:40 -04:00
case 'thematic-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);
2017-07-31 16:41:40 -04:00
}
2017-09-19 16:49:45 -04:00
/**
* Images
*
* This transformation is almost identical to that of links, except for the
* lack of child nodes and addition of `alt` attribute data. Currently the
* CMS handles block images by shortcode, so this case will only apply to
* inline images, which currently can only occur through raw markdown
* insertion.
*/
case 'image': {
const { url, title, alt, ...data } = get(node, 'data', {});
return u(typeMap[node.type], { url, title, alt, data });
}
2017-07-31 16:41:40 -04:00
/**
* No default case is supplied because an unhandled case should never
* occur. In the event that it does, let the error throw (for now).
*/
}
}