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:
Shawn Erquhart
2019-12-16 12:17:37 -05:00
committed by Erez Rokah
parent be46293f82
commit 18c579d0e9
110 changed files with 12693 additions and 8516 deletions

View File

@ -33,6 +33,7 @@
"gotrue-js": "^0.9.24",
"gray-matter": "^4.0.2",
"history": "^4.7.2",
"immer": "^3.1.3",
"js-base64": "^2.5.1",
"js-yaml": "^3.12.2",
"jwt-decode": "^2.1.0",

View File

@ -1,9 +1,8 @@
/** @jsx jsx */
import PropTypes from 'prop-types';
import React from 'react';
import ImmutablePropTypes from 'react-immutable-proptypes';
import styled from '@emotion/styled';
import { jsx, css } from '@emotion/core';
import { css } from '@emotion/core';
import { translate } from 'react-polyglot';
import { NavLink } from 'react-router-dom';
import {

View File

@ -399,6 +399,7 @@ export class Editor extends React.Component {
logoutUser,
deployPreview,
loadDeployPreview,
draftKey,
slug,
t,
} = this.props;
@ -421,6 +422,7 @@ export class Editor extends React.Component {
return (
<EditorInterface
draftKey={draftKey}
entry={entryDraft.get('entry')}
getAsset={boundGetAsset}
collection={collection}
@ -474,6 +476,7 @@ function mapStateToProps(state, ownProps) {
const currentStatus = unpublishedEntry && unpublishedEntry.getIn(['metaData', 'status']);
const deployPreview = selectDeployPreview(state, collectionName, slug);
const localBackup = entryDraft.get('localBackup');
const draftKey = entryDraft.get('key');
return {
collection,
collections,
@ -493,6 +496,7 @@ function mapStateToProps(state, ownProps) {
currentStatus,
deployPreview,
localBackup,
draftKey,
};
}

View File

@ -1,13 +1,12 @@
/** @jsx jsx */
import React from 'react';
import PropTypes from 'prop-types';
import ImmutablePropTypes from 'react-immutable-proptypes';
import { translate } from 'react-polyglot';
import { jsx, ClassNames, Global, css as coreCss } from '@emotion/core';
import { ClassNames, Global, css as coreCss } from '@emotion/core';
import styled from '@emotion/styled';
import { partial, uniqueId } from 'lodash';
import { connect } from 'react-redux';
import { colors, colorsRaw, transitions, lengths, borders } from 'netlify-cms-ui-default';
import { FieldLabel, colors, transitions, lengths, borders } from 'netlify-cms-ui-default';
import { resolveWidget, getEditorComponents } from 'Lib/registry';
import { clearFieldErrors, loadEntry } from 'Actions/entries';
import { addAsset } from 'Actions/media';
@ -27,48 +26,6 @@ import Widget from './Widget';
* this.
*/
const styleStrings = {
label: `
color: ${colors.controlLabel};
background-color: ${colors.textFieldBorder};
display: inline-block;
font-size: 12px;
text-transform: uppercase;
font-weight: 600;
border: 0;
border-radius: 3px 3px 0 0;
padding: 3px 6px 2px;
margin: 0;
transition: all ${transitions.main};
position: relative;
/**
* Faux outside curve into top of input
*/
&:before,
&:after {
content: '';
display: block;
position: absolute;
top: 0;
right: -4px;
height: 100%;
width: 4px;
background-color: inherit;
}
&:after {
border-bottom-left-radius: 3px;
background-color: #fff;
}
`,
labelActive: `
background-color: ${colors.active};
color: ${colors.textLight};
`,
labelError: `
background-color: ${colors.errorText};
color: ${colorsRaw.white};
`,
widget: `
display: block;
width: 100%;
@ -85,6 +42,7 @@ const styleStrings = {
position: relative;
font-size: 15px;
line-height: 1.5;
overflow: hidden;
select& {
text-indent: 14px;
@ -155,6 +113,8 @@ class EditorControl extends React.Component {
clearFieldErrors: PropTypes.func.isRequired,
loadEntry: PropTypes.func.isRequired,
t: PropTypes.func.isRequired,
isEditorComponent: PropTypes.bool,
isNewEditorComponent: PropTypes.bool,
};
state = {
@ -186,6 +146,10 @@ class EditorControl extends React.Component {
clearSearch,
clearFieldErrors,
loadEntry,
className,
isSelected,
isEditorComponent,
isNewEditorComponent,
t,
} = this.props;
const widgetName = field.get('widget');
@ -199,11 +163,11 @@ class EditorControl extends React.Component {
return (
<ClassNames>
{({ css, cx }) => (
<ControlContainer>
<ControlContainer className={className}>
{widget.globalStyles && <Global styles={coreCss`${widget.globalStyles}`} />}
<ControlErrorsList>
{errors &&
errors.map(
{errors && (
<ControlErrorsList>
{errors.map(
error =>
error.message &&
typeof error.message === 'string' && (
@ -212,25 +176,15 @@ class EditorControl extends React.Component {
</li>
),
)}
</ControlErrorsList>
<label
className={cx(
css`
${styleStrings.label};
`,
this.state.styleActive &&
css`
${styleStrings.labelActive};
`,
!!errors &&
css`
${styleStrings.labelError};
`,
)}
</ControlErrorsList>
)}
<FieldLabel
isActive={isSelected || this.state.styleActive}
hasErrors={!!errors}
htmlFor={this.uniqueFieldId}
>
{`${field.get('label', field.get('name'))}${isFieldOptional ? ' (optional)' : ''}`}
</label>
</FieldLabel>
<Widget
classNameWrapper={cx(
css`
@ -239,7 +193,7 @@ class EditorControl extends React.Component {
{
[css`
${styleStrings.widgetActive};
`]: this.state.styleActive,
`]: isSelected || this.state.styleActive,
},
{
[css`
@ -273,10 +227,11 @@ class EditorControl extends React.Component {
onRemoveInsertedMedia={removeInsertedMedia}
onAddAsset={addAsset}
getAsset={boundGetAsset}
hasActiveStyle={this.state.styleActive}
hasActiveStyle={isSelected || this.state.styleActive}
setActiveStyle={() => this.setState({ styleActive: true })}
setInactiveStyle={() => this.setState({ styleActive: false })}
resolveWidget={resolveWidget}
widget={widget}
getEditorComponents={getEditorComponents}
ref={processControlRef && partial(processControlRef, field)}
controlRef={controlRef}
@ -289,10 +244,12 @@ class EditorControl extends React.Component {
isFetching={isFetching}
fieldsErrors={fieldsErrors}
onValidateObject={onValidateObject}
isEditorComponent={isEditorComponent}
isNewEditorComponent={isNewEditorComponent}
t={t}
/>
{fieldHint && (
<ControlHint active={this.state.styleActive} error={!!errors}>
<ControlHint active={isSelected || this.state.styleActive} error={!!errors}>
{fieldHint}
</ControlHint>
)}

View File

@ -44,6 +44,7 @@ export default class Widget extends Component {
onRemoveInsertedMedia: PropTypes.func.isRequired,
getAsset: PropTypes.func.isRequired,
resolveWidget: PropTypes.func.isRequired,
widget: PropTypes.object.isRequired,
getEditorComponents: PropTypes.func.isRequired,
isFetching: PropTypes.bool,
controlRef: PropTypes.func,
@ -56,6 +57,8 @@ export default class Widget extends Component {
loadEntry: PropTypes.func.isRequired,
t: PropTypes.func.isRequired,
onValidateObject: PropTypes.func,
isEditorComponent: PropTypes.bool,
isNewEditorComponent: PropTypes.bool,
};
shouldComponentUpdate(nextProps) {
@ -238,6 +241,7 @@ export default class Widget extends Component {
editorControl,
uniqueFieldId,
resolveWidget,
widget,
getEditorComponents,
query,
queryHits,
@ -247,6 +251,8 @@ export default class Widget extends Component {
loadEntry,
fieldsErrors,
controlRef,
isEditorComponent,
isNewEditorComponent,
t,
} = this.props;
return React.createElement(controlComponent, {
@ -275,6 +281,7 @@ export default class Widget extends Component {
hasActiveStyle,
editorControl,
resolveWidget,
widget,
getEditorComponents,
query,
queryHits,
@ -282,6 +289,8 @@ export default class Widget extends Component {
clearFieldErrors,
isFetching,
loadEntry,
isEditorComponent,
isNewEditorComponent,
fieldsErrors,
controlRef,
t,

View File

@ -4,12 +4,11 @@ import ImmutablePropTypes from 'react-immutable-proptypes';
import { css, Global } from '@emotion/core';
import styled from '@emotion/styled';
import SplitPane from 'react-split-pane';
import { colors, colorsRaw, components, transitions } from 'netlify-cms-ui-default';
import { colors, colorsRaw, components, transitions, IconButton } from 'netlify-cms-ui-default';
import { ScrollSync, ScrollSyncPane } from 'react-scroll-sync';
import EditorControlPane from './EditorControlPane/EditorControlPane';
import EditorPreviewPane from './EditorPreviewPane/EditorPreviewPane';
import EditorToolbar from './EditorToolbar';
import EditorToggle from './EditorToggle';
const PREVIEW_VISIBLE = 'cms.preview-visible';
const SCROLL_SYNC_ENABLED = 'cms.scroll-sync-enabled';
@ -27,6 +26,10 @@ const styles = {
`,
};
const EditorToggle = styled(IconButton)`
margin-bottom: 12px;
`;
const ReactSplitPaneGlobalStyles = () => (
<Global
styles={css`
@ -175,6 +178,7 @@ class EditorInterface extends Component {
onLogoutClick,
loadDeployPreview,
deployPreview,
draftKey,
} = this.props;
const { previewVisible, scrollSyncEnabled, showEventBlocker } = this.state;
@ -255,22 +259,26 @@ class EditorInterface extends Component {
loadDeployPreview={loadDeployPreview}
deployPreview={deployPreview}
/>
<Editor>
<Editor key={draftKey}>
<ViewControls>
<EditorToggle
enabled={collectionPreviewEnabled}
active={previewVisible}
onClick={this.handleTogglePreview}
icon="eye"
title="Toggle preview"
/>
<EditorToggle
enabled={collectionPreviewEnabled && previewVisible}
active={scrollSyncEnabled}
onClick={this.handleToggleScrollSync}
icon="scroll"
title="Sync scrolling"
/>
{collectionPreviewEnabled && (
<EditorToggle
isActive={previewVisible}
onClick={this.handleTogglePreview}
size="large"
type="eye"
title="Toggle preview"
/>
)}
{collectionPreviewEnabled && previewVisible && (
<EditorToggle
isActive={scrollSyncEnabled}
onClick={this.handleToggleScrollSync}
size="large"
type="scroll"
title="Sync scrolling"
/>
)}
</ViewControls>
{collectionPreviewEnabled && this.state.previewVisible ? (
editorWithPreview
@ -312,6 +320,7 @@ EditorInterface.propTypes = {
onLogoutClick: PropTypes.func.isRequired,
deployPreview: ImmutablePropTypes.map,
loadDeployPreview: PropTypes.func.isRequired,
draftKey: PropTypes.string.isRequired,
};
export default EditorInterface;

View File

@ -26,6 +26,7 @@ export default class PreviewPane extends React.Component {
const { getAsset, entry } = props;
const widget = resolveWidget(field.get('widget'));
const key = idx ? field.get('name') + '_' + idx : field.get('name');
const valueIsInMap = value && !widget.allowMapValue && Map.isMap(value);
/**
* Use an HOC to provide conditional updates for all previews.
@ -36,7 +37,7 @@ export default class PreviewPane extends React.Component {
key={key}
field={field}
getAsset={getAsset}
value={value && Map.isMap(value) ? value.get(field.get('name')) : value}
value={valueIsInMap ? value.get(field.get('name')) : value}
entry={entry}
fieldsMetaData={metadata}
/>

View File

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

View File

@ -1,8 +1,7 @@
/** @jsx jsx */
// eslint-disable-next-line no-unused-vars
import React from 'react';
import PropTypes from 'prop-types';
import { jsx, css, Global } from '@emotion/core';
import { css, Global } from '@emotion/core';
import { translate } from 'react-polyglot';
import reduxNotificationsStyles from 'redux-notifications/lib/styles.css';
import { shadows, colors, lengths } from 'netlify-cms-ui-default';

View File

@ -1,8 +1,7 @@
/** @jsx jsx */
import PropTypes from 'prop-types';
import React from 'react';
import ImmutablePropTypes from 'react-immutable-proptypes';
import { jsx, css } from '@emotion/core';
import { css } from '@emotion/core';
import styled from '@emotion/styled';
import moment from 'moment';
import { translate } from 'react-polyglot';

View File

@ -1,4 +1,5 @@
import { Map } from 'immutable';
import produce from 'immer';
import { oneLine } from 'common-tags';
import EditorComponent from 'ValueObjects/EditorComponent';
@ -23,6 +24,7 @@ export default {
getPreviewTemplate,
registerWidget,
getWidget,
getWidgets,
resolveWidget,
registerEditorComponent,
getEditorComponents,
@ -81,10 +83,12 @@ export function registerWidget(name, control, preview) {
name: widgetName,
controlComponent: control,
previewComponent: preview,
allowMapValue,
globalStyles,
...options
} = name;
if (registry.widgets[widgetName]) {
console.error(oneLine`
console.warn(oneLine`
Multiple widgets registered with name "${widgetName}". Only the last widget registered with
this name will be used.
`);
@ -92,7 +96,7 @@ export function registerWidget(name, control, preview) {
if (!control) {
throw Error(`Widget "${widgetName}" registered without \`controlComponent\`.`);
}
registry.widgets[widgetName] = { control, preview, globalStyles };
registry.widgets[widgetName] = { control, preview, globalStyles, allowMapValue, ...options };
} else {
console.error('`registerWidget` failed, called with incorrect arguments.');
}
@ -100,6 +104,11 @@ export function registerWidget(name, control, preview) {
export function getWidget(name) {
return registry.widgets[name];
}
export function getWidgets() {
return produce(Object.entries(registry.widgets), draft => {
return draft.map(([key, value]) => ({ name: key, ...value }));
});
}
export function resolveWidget(name) {
return getWidget(name || 'string') || getWidget('unknown');
}
@ -109,7 +118,19 @@ export function resolveWidget(name) {
*/
export function registerEditorComponent(component) {
const plugin = EditorComponent(component);
registry.editorComponents = registry.editorComponents.set(plugin.get('id'), plugin);
if (plugin.type === 'code-block') {
const codeBlock = registry.editorComponents.find(c => c.type === 'code-block');
if (codeBlock) {
console.warn(oneLine`
Only one editor component of type "code-block" may be registered. Previously registered code
block component(s) will be overwritten.
`);
registry.editorComponents = registry.editorComponents.delete(codeBlock.id);
}
}
registry.editorComponents = registry.editorComponents.set(plugin.id, plugin);
}
export function getEditorComponents() {
return registry.editorComponents;

View File

@ -2,12 +2,15 @@ import { Map, List, fromJS } from 'immutable';
import * as actions from 'Actions/entries';
import reducer from '../entryDraft';
jest.mock('uuid/v4', () => jest.fn(() => '1'));
const initialState = Map({
entry: Map(),
mediaFiles: List(),
fieldsMetaData: Map(),
fieldsErrors: Map(),
hasChanged: false,
key: '',
});
const entry = {
@ -23,7 +26,8 @@ const entry = {
describe('entryDraft reducer', () => {
describe('DRAFT_CREATE_FROM_ENTRY', () => {
it('should create draft from the entry', () => {
expect(reducer(initialState, actions.createDraftFromEntry(fromJS(entry)))).toEqual(
const state = reducer(initialState, actions.createDraftFromEntry(fromJS(entry)));
expect(state).toEqual(
fromJS({
entry: {
...entry,
@ -33,6 +37,7 @@ describe('entryDraft reducer', () => {
fieldsMetaData: Map(),
fieldsErrors: Map(),
hasChanged: false,
key: '1',
}),
);
});
@ -40,7 +45,8 @@ describe('entryDraft reducer', () => {
describe('DRAFT_CREATE_EMPTY', () => {
it('should create a new draft ', () => {
expect(reducer(initialState, actions.emptyDraftCreated(fromJS(entry)))).toEqual(
const state = reducer(initialState, actions.emptyDraftCreated(fromJS(entry)));
expect(state).toEqual(
fromJS({
entry: {
...entry,
@ -50,6 +56,7 @@ describe('entryDraft reducer', () => {
fieldsMetaData: Map(),
fieldsErrors: Map(),
hasChanged: false,
key: '1',
}),
);
});
@ -127,6 +134,7 @@ describe('entryDraft reducer', () => {
fieldsMetaData: {},
fieldsErrors: {},
hasChanged: false,
key: '',
});
});
});
@ -144,6 +152,7 @@ describe('entryDraft reducer', () => {
fieldsMetaData: {},
fieldsErrors: {},
hasChanged: false,
key: '',
});
});
});
@ -161,6 +170,7 @@ describe('entryDraft reducer', () => {
fieldsMetaData: {},
fieldsErrors: {},
hasChanged: false,
key: '',
});
});
});
@ -181,6 +191,7 @@ describe('entryDraft reducer', () => {
fieldsMetaData: {},
fieldsErrors: {},
hasChanged: true,
key: '1',
});
});
});
@ -201,6 +212,7 @@ describe('entryDraft reducer', () => {
entry,
mediaFiles: [{ id: '1' }],
},
key: '',
});
});
});

View File

@ -1,4 +1,5 @@
import { Map, List, fromJS } from 'immutable';
import uuid from 'uuid/v4';
import {
DRAFT_CREATE_FROM_ENTRY,
DRAFT_CREATE_EMPTY,
@ -30,6 +31,7 @@ const initialState = Map({
fieldsMetaData: Map(),
fieldsErrors: Map(),
hasChanged: false,
key: '',
});
const entryDraftReducer = (state = Map(), action) => {
@ -46,6 +48,7 @@ const entryDraftReducer = (state = Map(), action) => {
state.set('fieldsMetaData', action.payload.metadata || Map());
state.set('fieldsErrors', Map());
state.set('hasChanged', false);
state.set('key', uuid());
});
case DRAFT_CREATE_EMPTY:
// New Entry
@ -56,6 +59,7 @@ const entryDraftReducer = (state = Map(), action) => {
state.set('fieldsMetaData', Map());
state.set('fieldsErrors', Map());
state.set('hasChanged', false);
state.set('key', uuid());
});
case DRAFT_CREATE_FROM_LOCAL_BACKUP:
// Local Backup
@ -69,6 +73,7 @@ const entryDraftReducer = (state = Map(), action) => {
state.set('fieldsMetaData', Map());
state.set('fieldsErrors', Map());
state.set('hasChanged', true);
state.set('key', uuid());
});
case DRAFT_CREATE_DUPLICATE_FROM_ENTRY:
// Duplicate Entry

View File

@ -1,39 +1,35 @@
import { Record, fromJS } from 'immutable';
import { fromJS } from 'immutable';
import { isFunction } from 'lodash';
const catchesNothing = /.^/;
/* eslint-disable no-unused-vars */
const EditorComponent = Record({
id: null,
label: 'unnamed component',
icon: 'exclamation-triangle',
fields: [],
pattern: catchesNothing,
fromBlock(match) {
return {};
},
toBlock(attributes) {
return 'Plugin';
},
toPreview(attributes) {
return 'Plugin';
},
});
/* eslint-enable */
const bind = fn => isFunction(fn) && fn.bind(null);
export default function createEditorComponent(config) {
const configObj = new EditorComponent({
id: config.id || config.label.replace(/[^A-Z0-9]+/gi, '_'),
label: config.label,
icon: config.icon,
fields: fromJS(config.fields),
pattern: config.pattern,
fromBlock: isFunction(config.fromBlock) ? config.fromBlock.bind(null) : null,
toBlock: isFunction(config.toBlock) ? config.toBlock.bind(null) : null,
toPreview: isFunction(config.toPreview)
? config.toPreview.bind(null)
: config.toBlock.bind(null),
});
const {
id = null,
label = 'unnamed component',
icon = 'exclamation-triangle',
type = 'shortcode',
widget = 'object',
pattern = catchesNothing,
fields = [],
fromBlock,
toBlock,
toPreview,
...remainingConfig
} = config;
return configObj;
return {
id: id || label.replace(/[^A-Z0-9]+/gi, '_'),
label,
type,
icon,
widget,
pattern,
fromBlock: bind(fromBlock) || (() => ({})),
toBlock: bind(toBlock) || (() => 'Plugin'),
toPreview: bind(toPreview) || (!widget && (bind(toBlock) || (() => 'Plugin'))),
fields: fromJS(fields),
...remainingConfig,
};
}