begin scaffolding for lerna
This commit is contained in:
@ -1,8 +0,0 @@
|
||||
@import "./NotFoundPage.css";
|
||||
@import "./Header.css";
|
||||
|
||||
.nc-app-main {
|
||||
min-width: 800px;
|
||||
max-width: 1440px;
|
||||
margin: 0 auto;
|
||||
}
|
@ -1,186 +0,0 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
import { hot } from 'react-hot-loader';
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
import { connect } from 'react-redux';
|
||||
import { Route, Switch, Link, Redirect } from 'react-router-dom';
|
||||
import { Notifs } from 'redux-notifications';
|
||||
import TopBarProgress from 'react-topbar-progress-indicator';
|
||||
import { loadConfig as actionLoadConfig } from 'Actions/config';
|
||||
import { loginUser as actionLoginUser, logoutUser as actionLogoutUser } from 'Actions/auth';
|
||||
import { currentBackend } from 'Backends/backend';
|
||||
import { showCollection, createNewEntry } from 'Actions/collections';
|
||||
import { openMediaLibrary as actionOpenMediaLibrary } from 'Actions/mediaLibrary';
|
||||
import MediaLibrary from 'MediaLibrary/MediaLibrary';
|
||||
import { Loader, Toast } from 'UI';
|
||||
import { getCollectionUrl, getNewEntryUrl } from 'Lib/urlHelper';
|
||||
import { SIMPLE, EDITORIAL_WORKFLOW } from 'Constants/publishModes';
|
||||
import Collection from 'Collection/Collection';
|
||||
import Workflow from 'Workflow/Workflow';
|
||||
import Editor from 'Editor/Editor';
|
||||
import NotFoundPage from './NotFoundPage';
|
||||
import Header from './Header';
|
||||
|
||||
TopBarProgress.config({
|
||||
barColors: {
|
||||
/**
|
||||
* Uses value from CSS --colorActive.
|
||||
*/
|
||||
"0": '#3a69c8',
|
||||
'1.0': '#3a69c8',
|
||||
},
|
||||
shadowBlur: 0,
|
||||
barThickness: 2,
|
||||
});
|
||||
|
||||
class App extends React.Component {
|
||||
|
||||
static propTypes = {
|
||||
auth: ImmutablePropTypes.map,
|
||||
config: ImmutablePropTypes.map,
|
||||
collections: ImmutablePropTypes.orderedMap,
|
||||
logoutUser: PropTypes.func.isRequired,
|
||||
dispatch: PropTypes.func.isRequired,
|
||||
user: ImmutablePropTypes.map,
|
||||
isFetching: PropTypes.bool.isRequired,
|
||||
publishMode: PropTypes.oneOf([SIMPLE, EDITORIAL_WORKFLOW]),
|
||||
siteId: PropTypes.string,
|
||||
};
|
||||
|
||||
static configError(config) {
|
||||
return (<div>
|
||||
<h1>Error loading the CMS configuration</h1>
|
||||
|
||||
<div>
|
||||
<p>The <code>config.yml</code> file could not be loaded or failed to parse properly.</p>
|
||||
<p><strong>Error message:</strong> {config.get('error')}</p>
|
||||
<p>Check your console for details.</p>
|
||||
</div>
|
||||
</div>);
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this.props.dispatch(actionLoadConfig());
|
||||
}
|
||||
|
||||
handleLogin(credentials) {
|
||||
this.props.dispatch(actionLoginUser(credentials));
|
||||
}
|
||||
|
||||
authenticating() {
|
||||
const { auth } = this.props;
|
||||
const backend = currentBackend(this.props.config);
|
||||
|
||||
if (backend == null) {
|
||||
return <div><h1>Waiting for backend...</h1></div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Notifs CustomComponent={Toast} />
|
||||
{
|
||||
React.createElement(backend.authComponent(), {
|
||||
onLogin: this.handleLogin.bind(this),
|
||||
error: auth && auth.get('error'),
|
||||
isFetching: auth && auth.get('isFetching'),
|
||||
siteId: this.props.config.getIn(["backend", "site_domain"]),
|
||||
base_url: this.props.config.getIn(["backend", "base_url"], null),
|
||||
authEndpoint: this.props.config.getIn(["backend", "auth_endpoint"]),
|
||||
config: this.props.config,
|
||||
})
|
||||
}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
handleLinkClick(event, handler, ...args) {
|
||||
event.preventDefault();
|
||||
handler(...args);
|
||||
}
|
||||
|
||||
render() {
|
||||
const {
|
||||
user,
|
||||
config,
|
||||
collections,
|
||||
logoutUser,
|
||||
isFetching,
|
||||
publishMode,
|
||||
openMediaLibrary,
|
||||
} = this.props;
|
||||
|
||||
if (config === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (config.get('error')) {
|
||||
return App.configError(config);
|
||||
}
|
||||
|
||||
if (config.get('isFetching')) {
|
||||
return <Loader active>Loading configuration...</Loader>;
|
||||
}
|
||||
|
||||
if (user == null) {
|
||||
return this.authenticating();
|
||||
}
|
||||
|
||||
const defaultPath = `/collections/${collections.first().get('name')}`;
|
||||
const hasWorkflow = publishMode === EDITORIAL_WORKFLOW;
|
||||
|
||||
return (
|
||||
<div className="nc-app-container">
|
||||
<Notifs CustomComponent={Toast} />
|
||||
<Header
|
||||
user={user}
|
||||
collections={collections}
|
||||
onCreateEntryClick={createNewEntry}
|
||||
onLogoutClick={logoutUser}
|
||||
openMediaLibrary={openMediaLibrary}
|
||||
hasWorkflow={hasWorkflow}
|
||||
displayUrl={config.get('display_url')}
|
||||
/>
|
||||
<div className="nc-app-main">
|
||||
{ isFetching && <TopBarProgress /> }
|
||||
<div>
|
||||
<Switch>
|
||||
<Redirect exact from="/" to={defaultPath} />
|
||||
<Redirect exact from="/search/" to={defaultPath} />
|
||||
{ hasWorkflow ? <Route path="/workflow" component={Workflow}/> : null }
|
||||
<Route exact path="/collections/:name" component={Collection} />
|
||||
<Route path="/collections/:name/new" render={props => <Editor {...props} newRecord />} />
|
||||
<Route path="/collections/:name/entries/:slug" component={Editor} />
|
||||
<Route path="/search/:searchTerm" render={props => <Collection {...props} isSearchResults />} />
|
||||
<Route component={NotFoundPage} />
|
||||
</Switch>
|
||||
<MediaLibrary/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function mapStateToProps(state, ownProps) {
|
||||
const { auth, config, collections, globalUI } = state;
|
||||
const user = auth && auth.get('user');
|
||||
const isFetching = globalUI.get('isFetching');
|
||||
const publishMode = config && config.get('publish_mode');
|
||||
return { auth, config, collections, user, isFetching, publishMode };
|
||||
}
|
||||
|
||||
function mapDispatchToProps(dispatch) {
|
||||
return {
|
||||
dispatch,
|
||||
openMediaLibrary: () => {
|
||||
dispatch(actionOpenMediaLibrary());
|
||||
},
|
||||
logoutUser: () => {
|
||||
dispatch(actionLogoutUser());
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export default hot(module)(
|
||||
connect(mapStateToProps, mapDispatchToProps)(App)
|
||||
);
|
@ -1,91 +0,0 @@
|
||||
.nc-appHeader-container {
|
||||
z-index: 300;
|
||||
}
|
||||
|
||||
.nc-appHeader-main {
|
||||
@apply(--dropShadowMain);
|
||||
position: fixed;
|
||||
width: 100%;
|
||||
top: 0;
|
||||
background-color: var(--colorForeground);
|
||||
z-index: 300;
|
||||
height: var(--topBarHeight);
|
||||
}
|
||||
|
||||
.nc-appHeader-content {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
min-width: 800px;
|
||||
max-width: 1440px;
|
||||
padding: 0 12px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.nc-appHeader-button {
|
||||
background-color: transparent;
|
||||
color: #7b8290;
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
display: inline-flex;
|
||||
padding: 16px 20px;
|
||||
align-items: center;
|
||||
|
||||
& .nc-icon {
|
||||
margin-right: 4px;
|
||||
color: #b3b9c4;
|
||||
}
|
||||
|
||||
&:hover,
|
||||
&:active,
|
||||
&:focus,
|
||||
&.nc-appHeader-button-active {
|
||||
background-color: white;
|
||||
color: var(--colorActive);
|
||||
|
||||
& .nc-icon {
|
||||
color: var(--colorActive);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.nc-appHeader-actions {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.nc-appHeader-siteLink {
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
color: #7b8290;
|
||||
padding: 10px 16px;
|
||||
}
|
||||
|
||||
.nc-appHeader-quickNew {
|
||||
@apply(--buttonMedium);
|
||||
@apply(--buttonGray);
|
||||
margin-right: 8px;
|
||||
|
||||
&:after {
|
||||
top: 11px;
|
||||
}
|
||||
}
|
||||
|
||||
.nc-appHeader-avatar {
|
||||
border: 0;
|
||||
padding: 8px;
|
||||
cursor: pointer;
|
||||
color: #1e2532;
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
.nc-appHeader-avatar-image,
|
||||
.nc-appHeader-avatar-placeholder {
|
||||
width: 32px;
|
||||
border-radius: 32px;
|
||||
}
|
||||
|
||||
.nc-appHeader-avatar-placeholder {
|
||||
height: 32px;
|
||||
color: #1e2532;
|
||||
background-color: var(--textFieldBorderColor);
|
||||
}
|
@ -1,115 +0,0 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import React from "react";
|
||||
import ImmutablePropTypes from "react-immutable-proptypes";
|
||||
import { NavLink } from 'react-router-dom';
|
||||
import { Icon, Dropdown, DropdownItem } from 'UI';
|
||||
import { stripProtocol } from 'Lib/urlHelper';
|
||||
|
||||
export default class Header extends React.Component {
|
||||
static propTypes = {
|
||||
user: ImmutablePropTypes.map.isRequired,
|
||||
collections: ImmutablePropTypes.orderedMap.isRequired,
|
||||
onCreateEntryClick: PropTypes.func.isRequired,
|
||||
onLogoutClick: PropTypes.func.isRequired,
|
||||
displayUrl: PropTypes.string,
|
||||
};
|
||||
|
||||
handleCreatePostClick = (collectionName) => {
|
||||
const { onCreateEntryClick } = this.props;
|
||||
if (onCreateEntryClick) {
|
||||
onCreateEntryClick(collectionName);
|
||||
}
|
||||
};
|
||||
|
||||
render() {
|
||||
const {
|
||||
user,
|
||||
collections,
|
||||
toggleDrawer,
|
||||
onLogoutClick,
|
||||
openMediaLibrary,
|
||||
hasWorkflow,
|
||||
displayUrl,
|
||||
} = this.props;
|
||||
|
||||
const avatarUrl = user.get('avatar_url');
|
||||
|
||||
return (
|
||||
<div className="nc-appHeader-container">
|
||||
<div className="nc-appHeader-main">
|
||||
<div className="nc-appHeader-content">
|
||||
<nav>
|
||||
<NavLink
|
||||
to="/"
|
||||
className="nc-appHeader-button"
|
||||
activeClassName="nc-appHeader-button-active"
|
||||
isActive={(match, location) => location.pathname.startsWith('/collections/')}
|
||||
>
|
||||
<Icon type="page"/>
|
||||
Content
|
||||
</NavLink>
|
||||
{
|
||||
hasWorkflow
|
||||
? <NavLink to="/workflow" className="nc-appHeader-button" activeClassName="nc-appHeader-button-active">
|
||||
<Icon type="workflow"/>
|
||||
Workflow
|
||||
</NavLink>
|
||||
: null
|
||||
}
|
||||
<button onClick={openMediaLibrary} className="nc-appHeader-button">
|
||||
<Icon type="media-alt"/>
|
||||
Media
|
||||
</button>
|
||||
</nav>
|
||||
<div className="nc-appHeader-actions">
|
||||
<Dropdown
|
||||
classNameButton="nc-appHeader-button nc-appHeader-quickNew"
|
||||
label="Quick add"
|
||||
dropdownTopOverlap="30px"
|
||||
dropdownWidth="160px"
|
||||
dropdownPosition="left"
|
||||
>
|
||||
{
|
||||
collections.filter(collection => collection.get('create')).toList().map(collection =>
|
||||
<DropdownItem
|
||||
key={collection.get("name")}
|
||||
label={collection.get("label_singular") || collection.get("label")}
|
||||
onClick={() => this.handleCreatePostClick(collection.get('name'))}
|
||||
/>
|
||||
)
|
||||
}
|
||||
</Dropdown>
|
||||
{
|
||||
displayUrl
|
||||
? <a
|
||||
className="nc-appHeader-siteLink"
|
||||
href={displayUrl}
|
||||
target="_blank"
|
||||
>
|
||||
{stripProtocol(displayUrl)}
|
||||
</a>
|
||||
: null
|
||||
}
|
||||
<Dropdown
|
||||
dropdownTopOverlap="50px"
|
||||
dropdownWidth="100px"
|
||||
dropdownPosition="right"
|
||||
button={
|
||||
<button className="nc-appHeader-avatar">
|
||||
{
|
||||
avatarUrl
|
||||
? <img className="nc-appHeader-avatar-image" src={user.get('avatar_url')}/>
|
||||
: <Icon className="nc-appHeader-avatar-placeholder" type="user" size="large"/>
|
||||
}
|
||||
</button>
|
||||
}
|
||||
>
|
||||
<DropdownItem label="Log Out" onClick={onLogoutClick}/>
|
||||
</Dropdown>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
@ -1,3 +0,0 @@
|
||||
.nc-notFound-container {
|
||||
margin: var(--pageMargin);
|
||||
}
|
@ -1,7 +0,0 @@
|
||||
import React from 'react';
|
||||
|
||||
export default () => (
|
||||
<div className="nc-notFound-container">
|
||||
<h2>Not Found</h2>
|
||||
</div>
|
||||
);
|
@ -1,11 +0,0 @@
|
||||
@import "./Sidebar.css";
|
||||
@import "./CollectionTop.css";
|
||||
@import "./Entries/Entries.css";
|
||||
|
||||
.nc-collectionPage-container {
|
||||
margin: var(--pageMargin);
|
||||
}
|
||||
|
||||
.nc-collectionPage-main {
|
||||
padding-left: 280px;
|
||||
}
|
@ -1,71 +0,0 @@
|
||||
import React from 'react';
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
import { connect } from 'react-redux';
|
||||
import { getNewEntryUrl } from 'Lib/urlHelper';
|
||||
import Sidebar from './Sidebar';
|
||||
import CollectionTop from './CollectionTop';
|
||||
import EntriesCollection from './Entries/EntriesCollection';
|
||||
import EntriesSearch from './Entries/EntriesSearch';
|
||||
import { VIEW_STYLE_LIST, VIEW_STYLE_GRID } from 'Constants/collectionViews';
|
||||
|
||||
class Collection extends React.Component {
|
||||
static propTypes = {
|
||||
collection: ImmutablePropTypes.map.isRequired,
|
||||
collections: ImmutablePropTypes.orderedMap.isRequired,
|
||||
};
|
||||
|
||||
state = {
|
||||
viewStyle: VIEW_STYLE_LIST,
|
||||
};
|
||||
|
||||
renderEntriesCollection = () => {
|
||||
const { name, collection } = this.props;
|
||||
return <EntriesCollection collection={collection} name={name} viewStyle={this.state.viewStyle}/>
|
||||
};
|
||||
|
||||
renderEntriesSearch = () => {
|
||||
const { searchTerm, collections } = this.props;
|
||||
return <EntriesSearch collections={collections} searchTerm={searchTerm} />
|
||||
};
|
||||
|
||||
handleChangeViewStyle = (viewStyle) => {
|
||||
if (this.state.viewStyle !== viewStyle) {
|
||||
this.setState({ viewStyle });
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
const { collection, collections, collectionName, isSearchResults, searchTerm } = this.props;
|
||||
const newEntryUrl = collection.get('create') ? getNewEntryUrl(collectionName) : '';
|
||||
return (
|
||||
<div className="nc-collectionPage-container">
|
||||
<Sidebar collections={collections} searchTerm={searchTerm}/>
|
||||
<div className="nc-collectionPage-main">
|
||||
{
|
||||
isSearchResults
|
||||
? null
|
||||
: <CollectionTop
|
||||
collectionLabel={collection.get('label')}
|
||||
collectionLabelSingular={collection.get('label_singular')}
|
||||
collectionDescription={collection.get('description')}
|
||||
newEntryUrl={newEntryUrl}
|
||||
viewStyle={this.state.viewStyle}
|
||||
onChangeViewStyle={this.handleChangeViewStyle}
|
||||
/>
|
||||
}
|
||||
{ isSearchResults ? this.renderEntriesSearch() : this.renderEntriesCollection() }
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function mapStateToProps(state, ownProps) {
|
||||
const { collections } = state;
|
||||
const { isSearchResults, match } = ownProps;
|
||||
const { name, searchTerm } = match.params;
|
||||
const collection = name ? collections.get(name) : collections.first();
|
||||
return { collection, collections, collectionName: name, isSearchResults, searchTerm };
|
||||
}
|
||||
|
||||
export default connect(mapStateToProps)(Collection);
|
@ -1,64 +0,0 @@
|
||||
.nc-collectionPage-top {
|
||||
@apply(--cardTop);
|
||||
}
|
||||
|
||||
.nc-collectionPage-top-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.nc-collectionPage-top-description {
|
||||
@apply(--cardTopDescription)
|
||||
}
|
||||
|
||||
.nc-collectionPage-topHeading {
|
||||
@apply(--cardTopHeading)
|
||||
}
|
||||
|
||||
.nc-collectionPage-topNewButton {
|
||||
@apply(--button);
|
||||
@apply(--dropShadowDeep);
|
||||
@apply(--buttonDefault);
|
||||
@apply(--buttonGray);
|
||||
|
||||
padding: 0 30px;
|
||||
}
|
||||
|
||||
.nc-collectionPage-top-viewControls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
margin-top: 14px;
|
||||
}
|
||||
|
||||
.nc-collectionPage-top-viewControls {
|
||||
margin-top: 24px;
|
||||
}
|
||||
|
||||
.nc-collectionPage-top-viewControls-text {
|
||||
font-size: 14px;
|
||||
color: var(--colorText);
|
||||
margin-right: 12px;
|
||||
}
|
||||
|
||||
.nc-collectionPage-top-viewControls-button {
|
||||
color: #b3b9c4;
|
||||
background-color: transparent;
|
||||
display: block;
|
||||
padding: 0;
|
||||
margin: 0 4px;
|
||||
|
||||
&:last-child {
|
||||
margin-right: 0;
|
||||
}
|
||||
|
||||
& .nc-icon {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
.nc-collectionPage-top-viewControls-buttonActive {
|
||||
color: var(--colorActive);
|
||||
}
|
@ -1,64 +0,0 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
import c from 'classnames';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Icon } from 'UI';
|
||||
import { VIEW_STYLE_LIST, VIEW_STYLE_GRID } from 'Constants/collectionViews';
|
||||
|
||||
const CollectionTop = ({
|
||||
collectionLabel,
|
||||
collectionLabelSingular,
|
||||
collectionDescription,
|
||||
viewStyle,
|
||||
onChangeViewStyle,
|
||||
newEntryUrl,
|
||||
}) => {
|
||||
return (
|
||||
<div className="nc-collectionPage-top">
|
||||
<div className="nc-collectionPage-top-row">
|
||||
<h1 className="nc-collectionPage-topHeading">{collectionLabel}</h1>
|
||||
{
|
||||
newEntryUrl
|
||||
? <Link className="nc-collectionPage-topNewButton" to={newEntryUrl}>
|
||||
{`New ${collectionLabelSingular || collectionLabel}`}
|
||||
</Link>
|
||||
: null
|
||||
}
|
||||
</div>
|
||||
{
|
||||
collectionDescription
|
||||
? <p className="nc-collectionPage-top-description">{collectionDescription}</p>
|
||||
: null
|
||||
}
|
||||
<div className={c('nc-collectionPage-top-viewControls', {
|
||||
'nc-collectionPage-top-viewControls-noDescription': !collectionDescription,
|
||||
})}>
|
||||
<span className="nc-collectionPage-top-viewControls-text">View as:</span>
|
||||
<button
|
||||
className={c('nc-collectionPage-top-viewControls-button', {
|
||||
'nc-collectionPage-top-viewControls-buttonActive': viewStyle === VIEW_STYLE_LIST,
|
||||
})}
|
||||
onClick={() => onChangeViewStyle(VIEW_STYLE_LIST)}
|
||||
>
|
||||
<Icon type="list"/>
|
||||
</button>
|
||||
<button
|
||||
className={c('nc-collectionPage-top-viewControls-button', {
|
||||
'nc-collectionPage-top-viewControls-buttonActive': viewStyle === VIEW_STYLE_GRID,
|
||||
})}
|
||||
onClick={() => onChangeViewStyle(VIEW_STYLE_GRID)}
|
||||
>
|
||||
<Icon type="grid"/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
CollectionTop.propTypes = {
|
||||
collectionLabel: PropTypes.string.isRequired,
|
||||
collectionDescription: PropTypes.string,
|
||||
newEntryUrl: PropTypes.string
|
||||
};
|
||||
|
||||
export default CollectionTop;
|
@ -1,2 +0,0 @@
|
||||
@import "./EntryListing.css";
|
||||
@import "./EntryCard.css";
|
@ -1,55 +0,0 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
import { Loader } from 'UI';
|
||||
import EntryListing from './EntryListing';
|
||||
|
||||
const Entries = ({
|
||||
collections,
|
||||
entries,
|
||||
publicFolder,
|
||||
page,
|
||||
onPaginate,
|
||||
isFetching,
|
||||
viewStyle,
|
||||
cursor,
|
||||
handleCursorActions,
|
||||
}) => {
|
||||
const loadingMessages = [
|
||||
'Loading Entries',
|
||||
'Caching Entries',
|
||||
'This might take several minutes',
|
||||
];
|
||||
|
||||
if (entries) {
|
||||
return (
|
||||
<EntryListing
|
||||
collections={collections}
|
||||
entries={entries}
|
||||
publicFolder={publicFolder}
|
||||
viewStyle={viewStyle}
|
||||
cursor={cursor}
|
||||
handleCursorActions={handleCursorActions}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (isFetching) {
|
||||
return <Loader active>{loadingMessages}</Loader>;
|
||||
}
|
||||
|
||||
return <div className="nc-collectionPage-noEntries">No Entries</div>;
|
||||
}
|
||||
|
||||
Entries.propTypes = {
|
||||
collections: ImmutablePropTypes.map.isRequired,
|
||||
entries: ImmutablePropTypes.list,
|
||||
publicFolder: PropTypes.string.isRequired,
|
||||
page: PropTypes.number,
|
||||
isFetching: PropTypes.bool,
|
||||
viewStyle: PropTypes.string,
|
||||
cursor: PropTypes.any.isRequired,
|
||||
handleCursorActions: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
export default Entries;
|
@ -1,84 +0,0 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
import { connect } from 'react-redux';
|
||||
import { partial } from 'lodash';
|
||||
import {
|
||||
loadEntries as actionLoadEntries,
|
||||
traverseCollectionCursor as actionTraverseCollectionCursor,
|
||||
} from 'Actions/entries';
|
||||
import { selectEntries } from 'Reducers';
|
||||
import { selectCollectionEntriesCursor } from 'Reducers/cursors';
|
||||
import Cursor from 'ValueObjects/Cursor';
|
||||
import Entries from './Entries';
|
||||
|
||||
class EntriesCollection extends React.Component {
|
||||
static propTypes = {
|
||||
collection: ImmutablePropTypes.map.isRequired,
|
||||
publicFolder: PropTypes.string.isRequired,
|
||||
entries: ImmutablePropTypes.list,
|
||||
isFetching: PropTypes.bool.isRequired,
|
||||
viewStyle: PropTypes.string,
|
||||
cursor: PropTypes.object.isRequired,
|
||||
loadEntries: PropTypes.func.isRequired,
|
||||
traverseCollectionCursor: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
componentDidMount() {
|
||||
const { collection, loadEntries } = this.props;
|
||||
if (collection) {
|
||||
loadEntries(collection);
|
||||
}
|
||||
}
|
||||
|
||||
componentWillReceiveProps(nextProps) {
|
||||
const { collection, loadEntries } = this.props;
|
||||
if (nextProps.collection !== collection) {
|
||||
loadEntries(nextProps.collection);
|
||||
}
|
||||
}
|
||||
|
||||
handleCursorActions = (cursor, action) => {
|
||||
const { collection, traverseCollectionCursor } = this.props;
|
||||
traverseCollectionCursor(collection, action);
|
||||
};
|
||||
|
||||
render () {
|
||||
const { collection, entries, publicFolder, isFetching, viewStyle, cursor } = this.props;
|
||||
|
||||
return (
|
||||
<Entries
|
||||
collections={collection}
|
||||
entries={entries}
|
||||
publicFolder={publicFolder}
|
||||
isFetching={isFetching}
|
||||
collectionName={collection.get('label')}
|
||||
viewStyle={viewStyle}
|
||||
cursor={cursor}
|
||||
handleCursorActions={partial(this.handleCursorActions, cursor)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function mapStateToProps(state, ownProps) {
|
||||
const { collection, viewStyle } = ownProps;
|
||||
const { config } = state;
|
||||
const publicFolder = config.get('public_folder');
|
||||
const page = state.entries.getIn(['pages', collection.get('name'), 'page']);
|
||||
|
||||
const entries = selectEntries(state, collection.get('name'));
|
||||
const isFetching = state.entries.getIn(['pages', collection.get('name'), 'isFetching'], false);
|
||||
|
||||
const rawCursor = selectCollectionEntriesCursor(state.cursors, collection.get("name"));
|
||||
const cursor = Cursor.create(rawCursor).clearData();
|
||||
|
||||
return { publicFolder, collection, page, entries, isFetching, viewStyle, cursor };
|
||||
}
|
||||
|
||||
const mapDispatchToProps = {
|
||||
loadEntries: actionLoadEntries,
|
||||
traverseCollectionCursor: actionTraverseCollectionCursor,
|
||||
};
|
||||
|
||||
export default connect(mapStateToProps, mapDispatchToProps)(EntriesCollection);
|
@ -1,88 +0,0 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
import { connect } from 'react-redux';
|
||||
import { selectSearchedEntries } from 'Reducers';
|
||||
import {
|
||||
searchEntries as actionSearchEntries,
|
||||
clearSearch as actionClearSearch
|
||||
} from 'Actions/search';
|
||||
import Cursor from 'ValueObjects/Cursor';
|
||||
import Entries from './Entries';
|
||||
|
||||
class EntriesSearch extends React.Component {
|
||||
static propTypes = {
|
||||
isFetching: PropTypes.bool,
|
||||
searchEntries: PropTypes.func.isRequired,
|
||||
clearSearch: PropTypes.func.isRequired,
|
||||
searchTerm: PropTypes.string.isRequired,
|
||||
collections: ImmutablePropTypes.seq,
|
||||
entries: ImmutablePropTypes.list,
|
||||
page: PropTypes.number,
|
||||
publicFolder: PropTypes.string,
|
||||
};
|
||||
|
||||
componentDidMount() {
|
||||
const { searchTerm, searchEntries } = this.props;
|
||||
searchEntries(searchTerm);
|
||||
}
|
||||
|
||||
componentWillReceiveProps(nextProps) {
|
||||
if (this.props.searchTerm === nextProps.searchTerm) return;
|
||||
const { searchEntries } = this.props;
|
||||
searchEntries(nextProps.searchTerm);
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
this.props.clearSearch();
|
||||
}
|
||||
|
||||
getCursor = () => {
|
||||
const { page } = this.props;
|
||||
return Cursor.create({
|
||||
actions: isNaN(page) ? [] : ["append_next"],
|
||||
});
|
||||
};
|
||||
|
||||
handleCursorActions = (action) => {
|
||||
const { page, searchTerm, searchEntries } = this.props;
|
||||
if (action === "append_next") {
|
||||
const nextPage = page + 1;
|
||||
searchEntries(searchTerm, nextPage);
|
||||
}
|
||||
};
|
||||
|
||||
render () {
|
||||
const { collections, entries, publicFolder, page, isFetching } = this.props;
|
||||
return (
|
||||
<Entries
|
||||
cursor={this.getCursor()}
|
||||
handleCursorActions={this.handleCursorActions}
|
||||
collections={collections}
|
||||
entries={entries}
|
||||
publicFolder={publicFolder}
|
||||
page={page}
|
||||
onPaginate={this.handleLoadMore}
|
||||
isFetching={isFetching}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function mapStateToProps(state, ownProps) {
|
||||
const { searchTerm } = ownProps;
|
||||
const collections = ownProps.collections.toIndexedSeq();
|
||||
const isFetching = state.search.get('isFetching');
|
||||
const page = state.search.get('page');
|
||||
const entries = selectSearchedEntries(state);
|
||||
const publicFolder = state.config.get('public_folder');
|
||||
|
||||
return { isFetching, page, collections, entries, publicFolder, searchTerm };
|
||||
}
|
||||
|
||||
const mapDispatchToProps = {
|
||||
searchEntries: actionSearchEntries,
|
||||
clearSearch: actionClearSearch,
|
||||
};
|
||||
|
||||
export default connect(mapStateToProps, mapDispatchToProps)(EntriesSearch);
|
@ -1,76 +0,0 @@
|
||||
.nc-entryListing-gridCard {
|
||||
@apply(--card);
|
||||
flex: 0 0 335px;
|
||||
height: 240px;
|
||||
background-color: var(--colorForeground);
|
||||
color: var(--colorText);
|
||||
overflow: hidden;
|
||||
margin-bottom: 16px;
|
||||
margin-left: 12px;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--colorForeground);
|
||||
color: var(--colorText);
|
||||
}
|
||||
}
|
||||
|
||||
.nc-entryListing-cardImage {
|
||||
background-position: center center;
|
||||
background-size: cover;
|
||||
background-repeat: no-repeat;
|
||||
height: 150px;
|
||||
}
|
||||
|
||||
.nc-entryListing-cardBody {
|
||||
padding: 16px 22px;
|
||||
height: 90px;
|
||||
position: relative;
|
||||
|
||||
&:after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
display: block;
|
||||
z-index: 1;
|
||||
bottom: 0;
|
||||
left: -20%;
|
||||
height: 140%;
|
||||
width: 140%;
|
||||
box-shadow: inset 0 -15px 24px #fff;
|
||||
}
|
||||
}
|
||||
|
||||
.nc-entryListing-listCard {
|
||||
@apply(--card);
|
||||
width: var(--topCardWidth);
|
||||
max-width: 100%;
|
||||
padding: 16px 22px;
|
||||
margin-left: 12px;
|
||||
margin-bottom: 16px;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--colorForeground);
|
||||
}
|
||||
}
|
||||
|
||||
.nc-entryListing-listCard-title {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.nc-entryListing-listCard-collection-label {
|
||||
font-size: 12px;
|
||||
color: var(--colorTextLead);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.nc-entryListing-cardBody-full {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.nc-entryListing-cardHeading {
|
||||
margin: 0 0 2px;
|
||||
}
|
||||
|
||||
.nc-entryListing-cardListLabel {
|
||||
white-space: nowrap;
|
||||
font-weight: bold;
|
||||
}
|
@ -1,59 +0,0 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
import { Link } from 'react-router-dom';
|
||||
import c from 'classnames';
|
||||
import history from 'Routing/history';
|
||||
import { resolvePath } from 'Lib/pathHelper';
|
||||
import { VIEW_STYLE_LIST, VIEW_STYLE_GRID } from 'Constants/collectionViews';
|
||||
|
||||
const CollectionLabel = ({ label }) =>
|
||||
<h2 className="nc-entryListing-listCard-collection-label">{label}</h2>;
|
||||
|
||||
const EntryCard = ({
|
||||
collection,
|
||||
entry,
|
||||
inferedFields,
|
||||
publicFolder,
|
||||
collectionLabel,
|
||||
viewStyle = VIEW_STYLE_LIST,
|
||||
}) => {
|
||||
const label = entry.get('label');
|
||||
const title = label || entry.getIn(['data', inferedFields.titleField]);
|
||||
const path = `/collections/${collection.get('name')}/entries/${entry.get('slug')}`;
|
||||
let image = entry.getIn(['data', inferedFields.imageField]);
|
||||
image = resolvePath(image, publicFolder);
|
||||
if(image) {
|
||||
image = encodeURI(image);
|
||||
}
|
||||
|
||||
if (viewStyle === VIEW_STYLE_LIST) {
|
||||
return (
|
||||
<Link to={path} className="nc-entryListing-listCard">
|
||||
{ collectionLabel ? <CollectionLabel label={collectionLabel}/> : null }
|
||||
<h2 className="nc-entryListing-listCard-title">{ title }</h2>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
if (viewStyle === VIEW_STYLE_GRID) {
|
||||
return (
|
||||
<Link to={path} className="nc-entryListing-gridCard">
|
||||
<div className={c('nc-entryListing-cardBody', { 'nc-entryListing-cardBody-full': !image })}>
|
||||
{ collectionLabel ? <CollectionLabel label={collectionLabel}/> : null }
|
||||
<h2 className="nc-entryListing-cardHeading">{title}</h2>
|
||||
</div>
|
||||
{
|
||||
image
|
||||
? <div
|
||||
className="nc-entryListing-cardImage"
|
||||
style={{ backgroundImage: `url(${ image })` }}
|
||||
/>
|
||||
: null
|
||||
}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default EntryCard;
|
@ -1,9 +0,0 @@
|
||||
.nc-entryListing-cardsGrid {
|
||||
display: flex;
|
||||
flex-flow: row wrap;
|
||||
margin-left: -12px;
|
||||
}
|
||||
|
||||
.nc-entryListing-cardsList {
|
||||
margin-left: -12px;
|
||||
}
|
@ -1,73 +0,0 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
import Waypoint from 'react-waypoint';
|
||||
import { Map } from 'immutable';
|
||||
import { selectFields, selectInferedField } from 'Reducers/collections';
|
||||
import EntryCard from './EntryCard';
|
||||
import Cursor from 'ValueObjects/Cursor';
|
||||
|
||||
export default class EntryListing extends React.Component {
|
||||
static propTypes = {
|
||||
publicFolder: PropTypes.string.isRequired,
|
||||
collections: PropTypes.oneOfType([
|
||||
ImmutablePropTypes.map,
|
||||
ImmutablePropTypes.iterable,
|
||||
]).isRequired,
|
||||
entries: ImmutablePropTypes.list,
|
||||
viewStyle: PropTypes.string,
|
||||
};
|
||||
|
||||
handleLoadMore = () => {
|
||||
const { cursor, handleCursorActions } = this.props;
|
||||
if (Cursor.create(cursor).actions.has("append_next")) {
|
||||
handleCursorActions("append_next");
|
||||
}
|
||||
};
|
||||
|
||||
inferFields = collection => {
|
||||
const titleField = selectInferedField(collection, 'title');
|
||||
const descriptionField = selectInferedField(collection, 'description');
|
||||
const imageField = selectInferedField(collection, 'image');
|
||||
const fields = selectFields(collection);
|
||||
const inferedFields = [titleField, descriptionField, imageField];
|
||||
const remainingFields = fields && fields.filter(f => inferedFields.indexOf(f.get('name')) === -1);
|
||||
return { titleField, descriptionField, imageField, remainingFields };
|
||||
};
|
||||
|
||||
renderCardsForSingleCollection = () => {
|
||||
const { collections, entries, publicFolder, viewStyle } = this.props;
|
||||
const inferedFields = this.inferFields(collections);
|
||||
const entryCardProps = { collection: collections, inferedFields, publicFolder, viewStyle };
|
||||
return entries.map((entry, idx) => <EntryCard {...{ ...entryCardProps, entry, key: idx }} />);
|
||||
};
|
||||
|
||||
renderCardsForMultipleCollections = () => {
|
||||
const { collections, entries, publicFolder } = this.props;
|
||||
return entries.map((entry, idx) => {
|
||||
const collectionName = entry.get('collection');
|
||||
const collection = collections.find(coll => coll.get('name') === collectionName);
|
||||
const collectionLabel = collection.get('label');
|
||||
const inferedFields = this.inferFields(collection);
|
||||
const entryCardProps = { collection, entry, inferedFields, publicFolder, key: idx, collectionLabel };
|
||||
return <EntryCard {...entryCardProps} />;
|
||||
});
|
||||
};
|
||||
|
||||
render() {
|
||||
const { collections } = this.props;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="nc-entryListing-cardsGrid">
|
||||
{
|
||||
Map.isMap(collections)
|
||||
? this.renderCardsForSingleCollection()
|
||||
: this.renderCardsForMultipleCollections()
|
||||
}
|
||||
<Waypoint onEnter={this.handleLoadMore} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
@ -1,74 +0,0 @@
|
||||
.nc-collectionPage-sidebar {
|
||||
@apply(--card);
|
||||
width: 250px;
|
||||
padding: 8px 0 12px;
|
||||
position: fixed;
|
||||
max-height: calc(100vh - 112px);
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.nc-collectionPage-sidebarHeading {
|
||||
font-size: 23px;
|
||||
font-weight: 600;
|
||||
padding: 0;
|
||||
margin: 18px 12px 12px;
|
||||
color: var(--colorTextLead);
|
||||
}
|
||||
|
||||
.nc-collectionPage-sidebarSearch {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin: 0 8px;
|
||||
position: relative;
|
||||
|
||||
& input {
|
||||
background-color: #eff0f4;
|
||||
border-radius: var(--borderRadius);
|
||||
font-size: 14px;
|
||||
padding: 10px 6px 10px 32px;
|
||||
width: 100%;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
box-shadow: inset 0 0 0 2px var(--colorBlue);
|
||||
}
|
||||
}
|
||||
|
||||
& .nc-icon {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 6px;
|
||||
z-index: 2;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
|
||||
.nc-collectionPage-sidebarLink {
|
||||
display: flex;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
align-items: center;
|
||||
padding: 8px 12px;
|
||||
border-left: 2px solid #fff;
|
||||
|
||||
& .nc-icon {
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
&:hover,
|
||||
&:active,
|
||||
&.nc-collectionPage-sidebarLink-active {
|
||||
color: var(--colorActive);
|
||||
background-color: var(--colorActiveBackground);
|
||||
border-left-color: #4863c6;
|
||||
}
|
||||
|
||||
&:first-of-type {
|
||||
margin-top: 16px;
|
||||
}
|
||||
}
|
@ -1,53 +0,0 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
import { NavLink } from 'react-router-dom';
|
||||
import { searchCollections } from 'Actions/collections';
|
||||
import { getCollectionUrl } from 'Lib/urlHelper';
|
||||
import { Icon } from 'UI';
|
||||
|
||||
export default class Collection extends React.Component {
|
||||
|
||||
static propTypes = {
|
||||
collections: ImmutablePropTypes.orderedMap.isRequired,
|
||||
};
|
||||
|
||||
state = { query: this.props.searchTerm || '' };
|
||||
|
||||
renderLink = collection => {
|
||||
const collectionName = collection.get('name');
|
||||
return (
|
||||
<NavLink
|
||||
key={collectionName}
|
||||
to={`/collections/${collectionName}`}
|
||||
className="nc-collectionPage-sidebarLink"
|
||||
activeClassName="nc-collectionPage-sidebarLink-active"
|
||||
>
|
||||
<Icon type="write"/>
|
||||
{collection.get('label')}
|
||||
</NavLink>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
render() {
|
||||
const { collections } = this.props;
|
||||
const { query } = this.state;
|
||||
|
||||
return (
|
||||
<div className="nc-collectionPage-sidebar">
|
||||
<h1 className="nc-collectionPage-sidebarHeading">Collections</h1>
|
||||
<div className="nc-collectionPage-sidebarSearch">
|
||||
<Icon type="search" size="small"/>
|
||||
<input
|
||||
onChange={e => this.setState({ query: e.target.value })}
|
||||
onKeyDown={e => e.key === 'Enter' && searchCollections(query)}
|
||||
placeholder="Search all"
|
||||
value={query}
|
||||
/>
|
||||
</div>
|
||||
{collections.toList().map(this.renderLink)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
@ -1,6 +0,0 @@
|
||||
@import "./EditorInterface.css";
|
||||
@import "./EditorToolbar.css";
|
||||
@import "./EditorToggle.css";
|
||||
@import "./EditorControlPane/EditorControlPane.css";
|
||||
@import "./EditorControlPane/EditorControl.css";
|
||||
@import "./EditorPreviewPane/EditorPreviewPane.css";
|
@ -1,391 +0,0 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
import { Map } from 'immutable';
|
||||
import { get } from 'lodash';
|
||||
import { connect } from 'react-redux';
|
||||
import history from 'Routing/history';
|
||||
import { logoutUser } from 'Actions/auth';
|
||||
import {
|
||||
loadEntry,
|
||||
loadEntries,
|
||||
createDraftFromEntry,
|
||||
createEmptyDraft,
|
||||
discardDraft,
|
||||
changeDraftField,
|
||||
changeDraftFieldValidation,
|
||||
persistEntry,
|
||||
deleteEntry,
|
||||
} from 'Actions/entries';
|
||||
import {
|
||||
updateUnpublishedEntryStatus,
|
||||
publishUnpublishedEntry,
|
||||
deleteUnpublishedEntry
|
||||
} from 'Actions/editorialWorkflow';
|
||||
import { deserializeValues } from 'Lib/serializeEntryValues';
|
||||
import { addAsset } from 'Actions/media';
|
||||
import { openMediaLibrary, removeInsertedMedia } from 'Actions/mediaLibrary';
|
||||
import { selectEntry, selectUnpublishedEntry, getAsset } from 'Reducers';
|
||||
import { selectFields } from 'Reducers/collections';
|
||||
import { Loader } from 'UI';
|
||||
import { status } from 'Constants/publishModes';
|
||||
import { EDITORIAL_WORKFLOW } from 'Constants/publishModes';
|
||||
import EditorInterface from './EditorInterface';
|
||||
import withWorkflow from './withWorkflow';
|
||||
|
||||
const navigateCollection = (collectionPath) => history.push(`/collections/${collectionPath}`);
|
||||
const navigateToCollection = collectionName => navigateCollection(collectionName);
|
||||
const navigateToNewEntry = collectionName => navigateCollection(`${collectionName}/new`);
|
||||
const navigateToEntry = (collectionName, slug) => navigateCollection(`${collectionName}/entries/${slug}`);
|
||||
|
||||
class Editor extends React.Component {
|
||||
static propTypes = {
|
||||
addAsset: PropTypes.func.isRequired,
|
||||
boundGetAsset: PropTypes.func.isRequired,
|
||||
changeDraftField: PropTypes.func.isRequired,
|
||||
changeDraftFieldValidation: PropTypes.func.isRequired,
|
||||
collection: ImmutablePropTypes.map.isRequired,
|
||||
createDraftFromEntry: PropTypes.func.isRequired,
|
||||
createEmptyDraft: PropTypes.func.isRequired,
|
||||
discardDraft: PropTypes.func.isRequired,
|
||||
entry: ImmutablePropTypes.map,
|
||||
mediaPaths: ImmutablePropTypes.map.isRequired,
|
||||
entryDraft: ImmutablePropTypes.map.isRequired,
|
||||
loadEntry: PropTypes.func.isRequired,
|
||||
persistEntry: PropTypes.func.isRequired,
|
||||
deleteEntry: PropTypes.func.isRequired,
|
||||
showDelete: PropTypes.bool.isRequired,
|
||||
openMediaLibrary: PropTypes.func.isRequired,
|
||||
removeInsertedMedia: PropTypes.func.isRequired,
|
||||
fields: ImmutablePropTypes.list.isRequired,
|
||||
slug: PropTypes.string,
|
||||
newEntry: PropTypes.bool.isRequired,
|
||||
displayUrl: PropTypes.string,
|
||||
hasWorkflow: PropTypes.bool,
|
||||
unpublishedEntry: PropTypes.bool,
|
||||
isModification: PropTypes.bool,
|
||||
collectionEntriesLoaded: PropTypes.bool,
|
||||
updateUnpublishedEntryStatus: PropTypes.func.isRequired,
|
||||
publishUnpublishedEntry: PropTypes.func.isRequired,
|
||||
deleteUnpublishedEntry: PropTypes.func.isRequired,
|
||||
currentStatus: PropTypes.string,
|
||||
logoutUser: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
componentDidMount() {
|
||||
const {
|
||||
entry,
|
||||
newEntry,
|
||||
entryDraft,
|
||||
collection,
|
||||
slug,
|
||||
loadEntry,
|
||||
createEmptyDraft,
|
||||
loadEntries,
|
||||
collectionEntriesLoaded,
|
||||
} = this.props;
|
||||
|
||||
if (newEntry) {
|
||||
createEmptyDraft(collection);
|
||||
} else {
|
||||
loadEntry(collection, slug);
|
||||
}
|
||||
|
||||
const leaveMessage = 'Are you sure you want to leave this page?';
|
||||
|
||||
this.exitBlocker = (event) => {
|
||||
if (this.props.entryDraft.get('hasChanged')) {
|
||||
// This message is ignored in most browsers, but its presence
|
||||
// triggers the confirmation dialog
|
||||
event.returnValue = leaveMessage;
|
||||
return leaveMessage;
|
||||
}
|
||||
};
|
||||
window.addEventListener('beforeunload', this.exitBlocker);
|
||||
|
||||
const navigationBlocker = (location, action) => {
|
||||
/**
|
||||
* New entry being saved and redirected to it's new slug based url.
|
||||
*/
|
||||
const isPersisting = this.props.entryDraft.getIn(['entry', 'isPersisting']);
|
||||
const newRecord = this.props.entryDraft.getIn(['entry', 'newRecord']);
|
||||
const newEntryPath = `/collections/${collection.get('name')}/new`;
|
||||
if (isPersisting && newRecord && this.props.location.pathname === newEntryPath && action === 'PUSH') {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.props.hasChanged) {
|
||||
return leaveMessage;
|
||||
}
|
||||
|
||||
};
|
||||
const unblock = history.block(navigationBlocker);
|
||||
|
||||
/**
|
||||
* This will run as soon as the location actually changes, unless creating
|
||||
* a new post. The confirmation above will run first.
|
||||
*/
|
||||
this.unlisten = history.listen((location, action) => {
|
||||
const newEntryPath = `/collections/${collection.get('name')}/new`;
|
||||
const entriesPath = `/collections/${collection.get('name')}/entries/`;
|
||||
const { pathname } = location;
|
||||
if (pathname.startsWith(newEntryPath) || pathname.startsWith(entriesPath) && action === 'PUSH') {
|
||||
return;
|
||||
}
|
||||
unblock();
|
||||
this.unlisten();
|
||||
});
|
||||
|
||||
if (!collectionEntriesLoaded) {
|
||||
loadEntries(collection);
|
||||
}
|
||||
}
|
||||
|
||||
componentWillReceiveProps(nextProps) {
|
||||
/**
|
||||
* If the old slug is empty and the new slug is not, a new entry was just
|
||||
* saved, and we need to update navigation to the correct url using the
|
||||
* slug.
|
||||
*/
|
||||
const newSlug = nextProps.entryDraft && nextProps.entryDraft.getIn(['entry', 'slug']);
|
||||
if (!this.props.slug && newSlug && nextProps.newEntry) {
|
||||
navigateToEntry(this.props.collection.get('name'), newSlug);
|
||||
nextProps.loadEntry(nextProps.collection, newSlug);
|
||||
}
|
||||
|
||||
if (this.props.entry === nextProps.entry) return;
|
||||
const { entry, newEntry, fields, collection } = nextProps;
|
||||
|
||||
if (entry && !entry.get('isFetching') && !entry.get('error')) {
|
||||
|
||||
/**
|
||||
* Deserialize entry values for widgets with registered serializers before
|
||||
* creating the entry draft.
|
||||
*/
|
||||
const values = deserializeValues(entry.get('data'), fields);
|
||||
const deserializedEntry = entry.set('data', values);
|
||||
const fieldsMetaData = nextProps.entryDraft && nextProps.entryDraft.get('fieldsMetaData');
|
||||
this.createDraft(deserializedEntry, fieldsMetaData);
|
||||
} else if (newEntry) {
|
||||
this.props.createEmptyDraft(collection);
|
||||
}
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
this.props.discardDraft();
|
||||
window.removeEventListener('beforeunload', this.exitBlocker);
|
||||
}
|
||||
|
||||
createDraft = (entry, metadata) => {
|
||||
if (entry) this.props.createDraftFromEntry(entry, metadata);
|
||||
};
|
||||
|
||||
handleChangeStatus = (newStatusName) => {
|
||||
const { entryDraft, updateUnpublishedEntryStatus, collection, slug, currentStatus } = this.props;
|
||||
if (entryDraft.get('hasChanged')) {
|
||||
window.alert('You have unsaved changes, please save before updating status.');
|
||||
return;
|
||||
}
|
||||
const newStatus = status.get(newStatusName);
|
||||
this.props.updateUnpublishedEntryStatus(collection.get('name'), slug, currentStatus, newStatus);
|
||||
}
|
||||
|
||||
handlePersistEntry = async (opts = {}) => {
|
||||
const { createNew = false } = opts;
|
||||
const { persistEntry, collection, entryDraft, newEntry, currentStatus, hasWorkflow, loadEntry, slug, createEmptyDraft } = this.props;
|
||||
|
||||
await persistEntry(collection)
|
||||
|
||||
if (createNew) {
|
||||
navigateToNewEntry(collection.get('name'));
|
||||
createEmptyDraft(collection);
|
||||
}
|
||||
else if (slug && hasWorkflow && !currentStatus) {
|
||||
loadEntry(collection, slug);
|
||||
}
|
||||
};
|
||||
|
||||
handlePublishEntry = async (opts = {}) => {
|
||||
const { createNew = false } = opts;
|
||||
const { publishUnpublishedEntry, entryDraft, collection, slug, currentStatus, loadEntry } = this.props;
|
||||
if (currentStatus !== status.last()) {
|
||||
window.alert('Please update status to "Ready" before publishing.');
|
||||
return;
|
||||
} else if (entryDraft.get('hasChanged')) {
|
||||
window.alert('You have unsaved changes, please save before publishing.');
|
||||
return;
|
||||
} else if (!window.confirm('Are you sure you want to publish this entry?')) {
|
||||
return;
|
||||
}
|
||||
|
||||
await publishUnpublishedEntry(collection.get('name'), slug);
|
||||
|
||||
if (createNew) {
|
||||
navigateToNewEntry(collection.get('name'));
|
||||
}
|
||||
else {
|
||||
loadEntry(collection, slug);
|
||||
}
|
||||
};
|
||||
|
||||
handleDeleteEntry = () => {
|
||||
const { entryDraft, newEntry, collection, deleteEntry, slug } = this.props;
|
||||
if (entryDraft.get('hasChanged')) {
|
||||
if (!window.confirm('Are you sure you want to delete this published entry, as well as your unsaved changes from the current session?')) {
|
||||
return;
|
||||
}
|
||||
} else if (!window.confirm('Are you sure you want to delete this published entry?')) {
|
||||
return;
|
||||
}
|
||||
if (newEntry) {
|
||||
return navigateToCollection(collection.get('name'));
|
||||
}
|
||||
|
||||
setTimeout(async () => {
|
||||
await deleteEntry(collection, slug);
|
||||
return navigateToCollection(collection.get('name'));
|
||||
}, 0);
|
||||
};
|
||||
|
||||
handleDeleteUnpublishedChanges = async () => {
|
||||
const { entryDraft, collection, slug, deleteUnpublishedEntry, loadEntry, isModification } = this.props;
|
||||
if (entryDraft.get('hasChanged') && !window.confirm('This will delete all unpublished changes to this entry, as well as your unsaved changes from the current session. Do you still want to delete?')) {
|
||||
return;
|
||||
} else if (!window.confirm('All unpublished changes to this entry will be deleted. Do you still want to delete?')) {
|
||||
return;
|
||||
}
|
||||
await deleteUnpublishedEntry(collection.get('name'), slug);
|
||||
|
||||
if (isModification) {
|
||||
loadEntry(collection, slug);
|
||||
} else {
|
||||
navigateToCollection(collection.get('name'));
|
||||
}
|
||||
};
|
||||
|
||||
render() {
|
||||
const {
|
||||
entry,
|
||||
entryDraft,
|
||||
fields,
|
||||
mediaPaths,
|
||||
boundGetAsset,
|
||||
collection,
|
||||
changeDraftField,
|
||||
changeDraftFieldValidation,
|
||||
openMediaLibrary,
|
||||
addAsset,
|
||||
removeInsertedMedia,
|
||||
user,
|
||||
hasChanged,
|
||||
displayUrl,
|
||||
hasWorkflow,
|
||||
unpublishedEntry,
|
||||
newEntry,
|
||||
isModification,
|
||||
currentStatus,
|
||||
logoutUser,
|
||||
} = this.props;
|
||||
|
||||
if (entry && entry.get('error')) {
|
||||
return <div><h3>{ entry.get('error') }</h3></div>;
|
||||
} else if (entryDraft == null
|
||||
|| entryDraft.get('entry') === undefined
|
||||
|| (entry && entry.get('isFetching'))) {
|
||||
return <Loader active>Loading entry...</Loader>;
|
||||
}
|
||||
|
||||
return (
|
||||
<EditorInterface
|
||||
entry={entryDraft.get('entry')}
|
||||
getAsset={boundGetAsset}
|
||||
collection={collection}
|
||||
fields={fields}
|
||||
fieldsMetaData={entryDraft.get('fieldsMetaData')}
|
||||
fieldsErrors={entryDraft.get('fieldsErrors')}
|
||||
mediaPaths={mediaPaths}
|
||||
onChange={changeDraftField}
|
||||
onValidate={changeDraftFieldValidation}
|
||||
onOpenMediaLibrary={openMediaLibrary}
|
||||
onAddAsset={addAsset}
|
||||
onRemoveInsertedMedia={removeInsertedMedia}
|
||||
onPersist={this.handlePersistEntry}
|
||||
onDelete={this.handleDeleteEntry}
|
||||
onDeleteUnpublishedChanges={this.handleDeleteUnpublishedChanges}
|
||||
onChangeStatus={this.handleChangeStatus}
|
||||
onPublish={this.handlePublishEntry}
|
||||
showDelete={this.props.showDelete}
|
||||
enableSave={entryDraft.get('hasChanged')}
|
||||
user={user}
|
||||
hasChanged={hasChanged}
|
||||
displayUrl={displayUrl}
|
||||
hasWorkflow={hasWorkflow}
|
||||
hasUnpublishedChanges={unpublishedEntry}
|
||||
isNewEntry={newEntry}
|
||||
isModification={isModification}
|
||||
currentStatus={currentStatus}
|
||||
onLogoutClick={logoutUser}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function mapStateToProps(state, ownProps) {
|
||||
const { collections, entryDraft, mediaLibrary, auth, config, entries } = state;
|
||||
const slug = ownProps.match.params.slug;
|
||||
const collection = collections.get(ownProps.match.params.name);
|
||||
const collectionName = collection.get('name');
|
||||
const newEntry = ownProps.newRecord === true;
|
||||
const fields = selectFields(collection, slug);
|
||||
const entry = newEntry ? null : selectEntry(state, collectionName, slug);
|
||||
const boundGetAsset = getAsset.bind(null, state);
|
||||
const mediaPaths = mediaLibrary.get('controlMedia');
|
||||
const user = auth && auth.get('user');
|
||||
const hasChanged = entryDraft.get('hasChanged');
|
||||
const displayUrl = config.get('display_url');
|
||||
const hasWorkflow = config.get('publish_mode') === EDITORIAL_WORKFLOW;
|
||||
const isModification = entryDraft.getIn(['entry', 'isModification']);
|
||||
const collectionEntriesLoaded = !!entries.getIn(['entities', collectionName])
|
||||
const unpublishedEntry = selectUnpublishedEntry(state, collectionName, slug);
|
||||
const currentStatus = unpublishedEntry && unpublishedEntry.getIn(['metaData', 'status']);
|
||||
return {
|
||||
collection,
|
||||
collections,
|
||||
newEntry,
|
||||
entryDraft,
|
||||
mediaPaths,
|
||||
boundGetAsset,
|
||||
fields,
|
||||
slug,
|
||||
entry,
|
||||
user,
|
||||
hasChanged,
|
||||
displayUrl,
|
||||
hasWorkflow,
|
||||
isModification,
|
||||
collectionEntriesLoaded,
|
||||
currentStatus,
|
||||
};
|
||||
}
|
||||
|
||||
export default connect(
|
||||
mapStateToProps,
|
||||
{
|
||||
changeDraftField,
|
||||
changeDraftFieldValidation,
|
||||
openMediaLibrary,
|
||||
removeInsertedMedia,
|
||||
addAsset,
|
||||
loadEntry,
|
||||
loadEntries,
|
||||
createDraftFromEntry,
|
||||
createEmptyDraft,
|
||||
discardDraft,
|
||||
persistEntry,
|
||||
deleteEntry,
|
||||
updateUnpublishedEntryStatus,
|
||||
publishUnpublishedEntry,
|
||||
deleteUnpublishedEntry,
|
||||
logoutUser,
|
||||
}
|
||||
)(withWorkflow(Editor));
|
@ -1,7 +0,0 @@
|
||||
.nc-controlPane-control {
|
||||
margin-top: 16px;
|
||||
|
||||
&:first-child {
|
||||
margin-top: 36px;
|
||||
}
|
||||
}
|
@ -1,85 +0,0 @@
|
||||
import React from 'react';
|
||||
import { partial, uniqueId } from 'lodash';
|
||||
import c from 'classnames';
|
||||
import { resolveWidget } from 'Lib/registry';
|
||||
import Widget from './Widget';
|
||||
|
||||
export default class EditorControl extends React.Component {
|
||||
state = {
|
||||
activeLabel: false,
|
||||
};
|
||||
|
||||
render() {
|
||||
const {
|
||||
value,
|
||||
field,
|
||||
fieldsMetaData,
|
||||
fieldsErrors,
|
||||
mediaPaths,
|
||||
getAsset,
|
||||
onChange,
|
||||
onOpenMediaLibrary,
|
||||
onAddAsset,
|
||||
onRemoveInsertedMedia,
|
||||
onValidate,
|
||||
processControlRef,
|
||||
} = this.props;
|
||||
const widgetName = field.get('widget');
|
||||
const widget = resolveWidget(widgetName);
|
||||
const fieldName = field.get('name');
|
||||
const uniqueFieldId = uniqueId();
|
||||
const metadata = fieldsMetaData && fieldsMetaData.get(fieldName);
|
||||
const errors = fieldsErrors && fieldsErrors.get(fieldName);
|
||||
return (
|
||||
<div className="nc-controlPane-control">
|
||||
<ul className="nc-controlPane-errors">
|
||||
{
|
||||
errors && errors.map(error =>
|
||||
error.message &&
|
||||
typeof error.message === 'string' &&
|
||||
<li key={error.message.trim().replace(/[^a-z0-9]+/gi, '-')}>{error.message}</li>
|
||||
)
|
||||
}
|
||||
</ul>
|
||||
<label
|
||||
className={c({
|
||||
'nc-controlPane-label': true,
|
||||
'nc-controlPane-labelActive': this.state.styleActive,
|
||||
'nc-controlPane-labelWithError': !!errors,
|
||||
})}
|
||||
htmlFor={fieldName + uniqueFieldId}
|
||||
>
|
||||
{field.get('label')}
|
||||
</label>
|
||||
<Widget
|
||||
classNameWrapper={c({
|
||||
'nc-controlPane-widget': true,
|
||||
'nc-controlPane-widgetActive': this.state.styleActive,
|
||||
'nc-controlPane-widgetError': !!errors,
|
||||
})}
|
||||
classNameWidget="nc-controlPane-widget"
|
||||
classNameWidgetActive="nc-controlPane-widgetNestable"
|
||||
classNameLabel="nc-controlPane-label"
|
||||
classNameLabelActive="nc-controlPane-labelActive"
|
||||
controlComponent={widget.control}
|
||||
field={field}
|
||||
uniqueFieldId={uniqueFieldId}
|
||||
value={value}
|
||||
mediaPaths={mediaPaths}
|
||||
metadata={metadata}
|
||||
onChange={(newValue, newMetadata) => onChange(fieldName, newValue, newMetadata)}
|
||||
onValidate={onValidate && partial(onValidate, fieldName)}
|
||||
onOpenMediaLibrary={onOpenMediaLibrary}
|
||||
onRemoveInsertedMedia={onRemoveInsertedMedia}
|
||||
onAddAsset={onAddAsset}
|
||||
getAsset={getAsset}
|
||||
hasActiveStyle={this.state.styleActive}
|
||||
setActiveStyle={() => this.setState({ styleActive: true })}
|
||||
setInactiveStyle={() => this.setState({ styleActive: false })}
|
||||
ref={processControlRef && partial(processControlRef, fieldName)}
|
||||
editorControl={EditorControl}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
@ -1,107 +0,0 @@
|
||||
:root {
|
||||
--controlPaneLabel: {
|
||||
display: inline-block;
|
||||
color: var(--controlLabelColor);
|
||||
font-size: 12px;
|
||||
text-transform: uppercase;
|
||||
font-weight: 600;
|
||||
background-color: var(--textFieldBorderColor);
|
||||
border: 0;
|
||||
border-radius: 3px 3px 0 0;
|
||||
padding: 3px 6px 2px;
|
||||
margin: 0;
|
||||
transition: all var(--transition);
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
--controlPaneWidget: {
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding: var(--inputPadding);
|
||||
margin: 0;
|
||||
border: var(--textFieldBorder);
|
||||
border-radius: var(--borderRadius);
|
||||
border-top-left-radius: 0;
|
||||
outline: 0;
|
||||
box-shadow: none;
|
||||
background-color: var(--colorInputBackground);
|
||||
color: #444a57;
|
||||
transition: border-color var(--transition);
|
||||
position: relative;
|
||||
font-size: 15px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
}
|
||||
|
||||
.nc-controlPane-root {
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
padding-bottom: 16px;
|
||||
|
||||
& p {
|
||||
font-size: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
.nc-controlPane-label {
|
||||
@apply(--controlPaneLabel);
|
||||
}
|
||||
|
||||
.nc-controlPane-labelActive {
|
||||
background-color: var(--colorActive);
|
||||
color: var(--colorTextLight);
|
||||
}
|
||||
|
||||
.nc-controlPane-widget {
|
||||
@apply(--controlPaneWidget);
|
||||
|
||||
&.nc-controlPane-widgetActive {
|
||||
border-color: var(--colorActive);
|
||||
}
|
||||
}
|
||||
|
||||
select.nc-controlPane-widget {
|
||||
text-indent: 14px;
|
||||
height: 58px;
|
||||
}
|
||||
|
||||
.nc-controlPane-labelWithError {
|
||||
background-color: var(--colorErrorText);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.nc-controlPane-widgetError {
|
||||
border-color: var(--colorErrorText);
|
||||
}
|
||||
|
||||
.nc-controlPane-errors {
|
||||
list-style-type: none;
|
||||
font-size: 12px;
|
||||
color: var(--colorErrorText);
|
||||
margin-bottom: 5px;
|
||||
text-align: right;
|
||||
text-transform: uppercase;
|
||||
position: relative;
|
||||
font-weight: 600;
|
||||
top: 20px;
|
||||
}
|
@ -1,82 +0,0 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
import EditorControl from './EditorControl';
|
||||
|
||||
export default class ControlPane extends React.Component {
|
||||
componentValidate = {};
|
||||
|
||||
processControlRef = (fieldName, wrappedControl) => {
|
||||
if (!wrappedControl) return;
|
||||
this.componentValidate[fieldName] = wrappedControl.validate;
|
||||
};
|
||||
|
||||
validate = () => {
|
||||
this.props.fields.forEach((field) => {
|
||||
if (field.get('widget') === 'hidden') return;
|
||||
this.componentValidate[field.get("name")]();
|
||||
});
|
||||
};
|
||||
|
||||
render() {
|
||||
const {
|
||||
collection,
|
||||
fields,
|
||||
entry,
|
||||
fieldsMetaData,
|
||||
fieldsErrors,
|
||||
mediaPaths,
|
||||
getAsset,
|
||||
onChange,
|
||||
onOpenMediaLibrary,
|
||||
onAddAsset,
|
||||
onRemoveInsertedMedia,
|
||||
onValidate,
|
||||
} = this.props;
|
||||
|
||||
if (!collection || !fields) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (entry.size === 0 || entry.get('partial') === true) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="nc-controlPane-root">
|
||||
{fields.map((field, i) => field.get('widget') === 'hidden' ? null :
|
||||
<EditorControl
|
||||
key={i}
|
||||
field={field}
|
||||
value={entry.getIn(['data', field.get('name')])}
|
||||
fieldsMetaData={fieldsMetaData}
|
||||
fieldsErrors={fieldsErrors}
|
||||
mediaPaths={mediaPaths}
|
||||
getAsset={getAsset}
|
||||
onChange={onChange}
|
||||
onOpenMediaLibrary={onOpenMediaLibrary}
|
||||
onAddAsset={onAddAsset}
|
||||
onRemoveInsertedMedia={onRemoveInsertedMedia}
|
||||
onValidate={onValidate}
|
||||
processControlRef={this.processControlRef}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
ControlPane.propTypes = {
|
||||
collection: ImmutablePropTypes.map.isRequired,
|
||||
entry: ImmutablePropTypes.map.isRequired,
|
||||
fields: ImmutablePropTypes.list.isRequired,
|
||||
fieldsMetaData: ImmutablePropTypes.map.isRequired,
|
||||
fieldsErrors: ImmutablePropTypes.map.isRequired,
|
||||
mediaPaths: ImmutablePropTypes.map.isRequired,
|
||||
getAsset: PropTypes.func.isRequired,
|
||||
onOpenMediaLibrary: PropTypes.func.isRequired,
|
||||
onAddAsset: PropTypes.func.isRequired,
|
||||
onChange: PropTypes.func.isRequired,
|
||||
onValidate: PropTypes.func.isRequired,
|
||||
onRemoveInsertedMedia: PropTypes.func.isRequired,
|
||||
};
|
@ -1,218 +0,0 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import React, { Component } from 'react';
|
||||
import ImmutablePropTypes from "react-immutable-proptypes";
|
||||
import { Map } from 'immutable';
|
||||
import ValidationErrorTypes from 'Constants/validationErrorTypes';
|
||||
|
||||
const truthy = () => ({ error: false });
|
||||
|
||||
const isEmpty = value => (
|
||||
value === null ||
|
||||
value === undefined ||
|
||||
(value.hasOwnProperty('length') && value.length === 0) ||
|
||||
(value.constructor === Object && Object.keys(value).length === 0)
|
||||
);
|
||||
|
||||
export default class Widget extends Component {
|
||||
static propTypes = {
|
||||
controlComponent: PropTypes.func.isRequired,
|
||||
field: ImmutablePropTypes.map.isRequired,
|
||||
hasActiveStyle: PropTypes.bool,
|
||||
setActiveStyle: PropTypes.func.isRequired,
|
||||
setInactiveStyle: PropTypes.func.isRequired,
|
||||
classNameWrapper: PropTypes.string.isRequired,
|
||||
classNameWidget: PropTypes.string.isRequired,
|
||||
classNameWidgetActive: PropTypes.string.isRequired,
|
||||
classNameLabel: PropTypes.string.isRequired,
|
||||
classNameLabelActive: PropTypes.string.isRequired,
|
||||
value: PropTypes.oneOfType([
|
||||
PropTypes.node,
|
||||
PropTypes.object,
|
||||
PropTypes.string,
|
||||
PropTypes.bool,
|
||||
]),
|
||||
mediaPaths: ImmutablePropTypes.map.isRequired,
|
||||
metadata: ImmutablePropTypes.map,
|
||||
onChange: PropTypes.func.isRequired,
|
||||
onValidate: PropTypes.func,
|
||||
onOpenMediaLibrary: PropTypes.func.isRequired,
|
||||
onAddAsset: PropTypes.func.isRequired,
|
||||
onRemoveInsertedMedia: PropTypes.func.isRequired,
|
||||
getAsset: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
shouldComponentUpdate(nextProps) {
|
||||
/**
|
||||
* Allow widgets to provide their own `shouldComponentUpdate` method.
|
||||
*/
|
||||
if (this.wrappedControlShouldComponentUpdate) {
|
||||
return this.wrappedControlShouldComponentUpdate(nextProps);
|
||||
}
|
||||
return this.props.value !== nextProps.value
|
||||
|| this.props.classNameWrapper !== nextProps.classNameWrapper
|
||||
|| this.props.hasActiveStyle !== nextProps.hasActiveStyle;
|
||||
}
|
||||
|
||||
processInnerControlRef = ref => {
|
||||
if (!ref) return;
|
||||
|
||||
/**
|
||||
* If the widget is a container that receives state updates from the store,
|
||||
* we'll need to get the ref of the actual control via the `react-redux`
|
||||
* `getWrappedInstance` method. Note that connected widgets must pass
|
||||
* `withRef: true` to `connect` in the options object.
|
||||
*/
|
||||
const wrappedControl = ref.getWrappedInstance ? ref.getWrappedInstance() : ref;
|
||||
|
||||
this.wrappedControlValid = wrappedControl.isValid || truthy;
|
||||
|
||||
/**
|
||||
* Get the `shouldComponentUpdate` method from the wrapped control, and
|
||||
* provide the control instance is the `this` binding.
|
||||
*/
|
||||
const { shouldComponentUpdate: scu } = wrappedControl;
|
||||
this.wrappedControlShouldComponentUpdate = scu && scu.bind(wrappedControl);
|
||||
};
|
||||
|
||||
validate = (skipWrapped = false) => {
|
||||
const { field, value } = this.props;
|
||||
const errors = [];
|
||||
const validations = [this.validatePresence, this.validatePattern];
|
||||
validations.forEach((func) => {
|
||||
const response = func(field, value);
|
||||
if (response.error) errors.push(response.error);
|
||||
});
|
||||
if (skipWrapped) {
|
||||
if (skipWrapped.error) errors.push(skipWrapped.error);
|
||||
} else {
|
||||
const wrappedError = this.validateWrappedControl(field);
|
||||
if (wrappedError.error) errors.push(wrappedError.error);
|
||||
}
|
||||
this.props.onValidate(errors);
|
||||
};
|
||||
|
||||
validatePresence = (field, value) => {
|
||||
const isRequired = field.get('required', true);
|
||||
if (isRequired && isEmpty(value)) {
|
||||
const error = {
|
||||
type: ValidationErrorTypes.PRESENCE,
|
||||
message: `${ field.get('label', field.get('name')) } is required.`,
|
||||
};
|
||||
|
||||
return { error };
|
||||
}
|
||||
return { error: false };
|
||||
};
|
||||
|
||||
validatePattern = (field, value) => {
|
||||
const pattern = field.get('pattern', false);
|
||||
|
||||
if (isEmpty(value)) {
|
||||
return { error: false };
|
||||
}
|
||||
|
||||
if (pattern && !RegExp(pattern.first()).test(value)) {
|
||||
const error = {
|
||||
type: ValidationErrorTypes.PATTERN,
|
||||
message: `${ field.get('label', field.get('name')) } didn't match the pattern: ${ pattern.last() }`,
|
||||
};
|
||||
|
||||
return { error };
|
||||
}
|
||||
|
||||
return { error: false };
|
||||
};
|
||||
|
||||
validateWrappedControl = (field) => {
|
||||
const response = this.wrappedControlValid();
|
||||
if (typeof response === "boolean") {
|
||||
const isValid = response;
|
||||
return { error: (!isValid) };
|
||||
} else if (response.hasOwnProperty('error')) {
|
||||
return response;
|
||||
} else if (response instanceof Promise) {
|
||||
response.then(
|
||||
() => { this.validate({ error: false }); },
|
||||
(err) => {
|
||||
const error = {
|
||||
type: ValidationErrorTypes.CUSTOM,
|
||||
message: `${ field.get('label', field.get('name')) } - ${ err }.`,
|
||||
};
|
||||
|
||||
this.validate({ error });
|
||||
}
|
||||
);
|
||||
|
||||
const error = {
|
||||
type: ValidationErrorTypes.CUSTOM,
|
||||
message: `${ field.get('label', field.get('name')) } is processing.`,
|
||||
};
|
||||
|
||||
return { error };
|
||||
}
|
||||
return { error: false };
|
||||
};
|
||||
|
||||
/**
|
||||
* In case the `onChangeObject` function is frozen by a child widget implementation,
|
||||
* e.g. when debounced, always get the latest object value instead of using
|
||||
* `this.props.value` directly.
|
||||
*/
|
||||
getObjectValue = () => this.props.value || Map();
|
||||
|
||||
/**
|
||||
* Change handler for fields that are nested within another field.
|
||||
*/
|
||||
onChangeObject = (fieldName, newValue, newMetadata) => {
|
||||
const newObjectValue = this.getObjectValue().set(fieldName, newValue);
|
||||
return this.props.onChange(newObjectValue, newMetadata);
|
||||
};
|
||||
|
||||
render() {
|
||||
const {
|
||||
controlComponent,
|
||||
field,
|
||||
value,
|
||||
mediaPaths,
|
||||
metadata,
|
||||
onChange,
|
||||
onOpenMediaLibrary,
|
||||
onAddAsset,
|
||||
onRemoveInsertedMedia,
|
||||
getAsset,
|
||||
classNameWrapper,
|
||||
classNameWidget,
|
||||
classNameWidgetActive,
|
||||
classNameLabel,
|
||||
classNameLabelActive,
|
||||
setActiveStyle,
|
||||
setInactiveStyle,
|
||||
hasActiveStyle,
|
||||
editorControl,
|
||||
uniqueFieldId
|
||||
} = this.props;
|
||||
return React.createElement(controlComponent, {
|
||||
field,
|
||||
value,
|
||||
mediaPaths,
|
||||
metadata,
|
||||
onChange,
|
||||
onChangeObject: this.onChangeObject,
|
||||
onOpenMediaLibrary,
|
||||
onAddAsset,
|
||||
onRemoveInsertedMedia,
|
||||
getAsset,
|
||||
forID: field.get('name') + uniqueFieldId,
|
||||
ref: this.processInnerControlRef,
|
||||
classNameWrapper,
|
||||
classNameWidget,
|
||||
classNameWidgetActive,
|
||||
classNameLabel,
|
||||
classNameLabelActive,
|
||||
setActiveStyle,
|
||||
setInactiveStyle,
|
||||
hasActiveStyle,
|
||||
editorControl,
|
||||
});
|
||||
}
|
||||
}
|
@ -1,78 +0,0 @@
|
||||
/**
|
||||
* React Split Pane
|
||||
*/
|
||||
.Resizer.vertical {
|
||||
width: 21px;
|
||||
cursor: col-resize;
|
||||
position: relative;
|
||||
transition: background-color var(--transition);
|
||||
|
||||
&:before {
|
||||
content: '';
|
||||
width: 1px;
|
||||
height: 100%;
|
||||
position: relative;
|
||||
left: 10px;
|
||||
background-color: var(--textFieldBorderColor);
|
||||
display: block;
|
||||
}
|
||||
|
||||
&:hover,
|
||||
&:active {
|
||||
background-color: var(--colorGrayLight);
|
||||
}
|
||||
}
|
||||
|
||||
/* Quick fix for preview pane not fully displaying in Safari */
|
||||
.SplitPane .Pane {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.SplitPane,
|
||||
.nc-entryEditor-noPreviewEditorContainer {
|
||||
@apply(--card);
|
||||
border-radius: 0;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.nc-entryEditor-containerOuter {
|
||||
width: 100%;
|
||||
min-width: 800px;
|
||||
height: 100%;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
overflow: hidden;
|
||||
padding-top: 66px;
|
||||
background-color: var(--colorBackground);
|
||||
}
|
||||
|
||||
.nc-entryEditor-container {
|
||||
max-width: 1600px;
|
||||
height: 100%;
|
||||
margin: 0 auto;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.nc-entryEditor-controlPane,
|
||||
.nc-entryEditor-previewPane {
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.nc-entryEditor-controlPane {
|
||||
padding: 0 16px;
|
||||
position: relative;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
.nc-entryEditor-viewControls {
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
right: 10px;
|
||||
z-index: 299;
|
||||
}
|
||||
|
||||
.nc-entryEditor-blocker > * {
|
||||
pointer-events: none;
|
||||
}
|
@ -1,224 +0,0 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import React, { Component } from 'react';
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
import SplitPane from 'react-split-pane';
|
||||
import classnames from 'classnames';
|
||||
import { ScrollSync, ScrollSyncPane } from './EditorScrollSync';
|
||||
import { Icon } from 'UI'
|
||||
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';
|
||||
|
||||
class EditorInterface extends Component {
|
||||
state = {
|
||||
showEventBlocker: false,
|
||||
previewVisible: localStorage.getItem(PREVIEW_VISIBLE) !== "false",
|
||||
scrollSyncEnabled: localStorage.getItem(SCROLL_SYNC_ENABLED) !== "false",
|
||||
};
|
||||
|
||||
handleSplitPaneDragStart = () => {
|
||||
this.setState({ showEventBlocker: true });
|
||||
};
|
||||
|
||||
handleSplitPaneDragFinished = () => {
|
||||
this.setState({ showEventBlocker: false });
|
||||
};
|
||||
|
||||
handleOnPersist = (opts = {}) => {
|
||||
const { createNew = false } = opts;
|
||||
this.controlPaneRef.validate();
|
||||
this.props.onPersist({ createNew });
|
||||
};
|
||||
|
||||
handleOnPublish = (opts = {}) => {
|
||||
const { createNew = false } = opts;
|
||||
this.controlPaneRef.validate();
|
||||
this.props.onPublish({ createNew });
|
||||
};
|
||||
|
||||
handleTogglePreview = () => {
|
||||
const newPreviewVisible = !this.state.previewVisible;
|
||||
this.setState({ previewVisible: newPreviewVisible });
|
||||
localStorage.setItem(PREVIEW_VISIBLE, newPreviewVisible);
|
||||
};
|
||||
|
||||
handleToggleScrollSync = () => {
|
||||
const newScrollSyncEnabled = !this.state.scrollSyncEnabled;
|
||||
this.setState({ scrollSyncEnabled: newScrollSyncEnabled });
|
||||
localStorage.setItem(SCROLL_SYNC_ENABLED, newScrollSyncEnabled);
|
||||
};
|
||||
|
||||
render() {
|
||||
const {
|
||||
collection,
|
||||
entry,
|
||||
fields,
|
||||
fieldsMetaData,
|
||||
fieldsErrors,
|
||||
mediaPaths,
|
||||
getAsset,
|
||||
onChange,
|
||||
enableSave,
|
||||
showDelete,
|
||||
onDelete,
|
||||
onDeleteUnpublishedChanges,
|
||||
onChangeStatus,
|
||||
onPublish,
|
||||
onValidate,
|
||||
onOpenMediaLibrary,
|
||||
onAddAsset,
|
||||
onRemoveInsertedMedia,
|
||||
user,
|
||||
hasChanged,
|
||||
displayUrl,
|
||||
hasWorkflow,
|
||||
hasUnpublishedChanges,
|
||||
isNewEntry,
|
||||
isModification,
|
||||
currentStatus,
|
||||
onLogoutClick,
|
||||
} = this.props;
|
||||
|
||||
const { previewVisible, scrollSyncEnabled, showEventBlocker } = this.state;
|
||||
|
||||
const collectionPreviewEnabled = collection.getIn(['editor', 'preview'], true);
|
||||
|
||||
const editor = (
|
||||
<div className={classnames('nc-entryEditor-controlPane', { 'nc-entryEditor-blocker': showEventBlocker })}>
|
||||
<EditorControlPane
|
||||
collection={collection}
|
||||
entry={entry}
|
||||
fields={fields}
|
||||
fieldsMetaData={fieldsMetaData}
|
||||
fieldsErrors={fieldsErrors}
|
||||
mediaPaths={mediaPaths}
|
||||
getAsset={getAsset}
|
||||
onChange={onChange}
|
||||
onValidate={onValidate}
|
||||
onOpenMediaLibrary={onOpenMediaLibrary}
|
||||
onAddAsset={onAddAsset}
|
||||
onRemoveInsertedMedia={onRemoveInsertedMedia}
|
||||
ref={c => this.controlPaneRef = c} // eslint-disable-line
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
const editorWithPreview = (
|
||||
<ScrollSync enabled={this.state.scrollSyncEnabled}>
|
||||
<div>
|
||||
<SplitPane
|
||||
maxSize={-100}
|
||||
defaultSize="50%"
|
||||
onDragStarted={this.handleSplitPaneDragStart}
|
||||
onDragFinished={this.handleSplitPaneDragFinished}
|
||||
>
|
||||
<ScrollSyncPane>{editor}</ScrollSyncPane>
|
||||
<div className={classnames('nc-entryEditor-previewPane', { 'nc-entryEditor-blocker': showEventBlocker })}>
|
||||
<EditorPreviewPane
|
||||
collection={collection}
|
||||
entry={entry}
|
||||
fields={fields}
|
||||
fieldsMetaData={fieldsMetaData}
|
||||
getAsset={getAsset}
|
||||
/>
|
||||
</div>
|
||||
</SplitPane>
|
||||
</div>
|
||||
</ScrollSync>
|
||||
);
|
||||
|
||||
const editorWithoutPreview = (
|
||||
<div className="nc-entryEditor-noPreviewEditorContainer">
|
||||
{editor}
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="nc-entryEditor-containerOuter">
|
||||
<EditorToolbar
|
||||
isPersisting={entry.get('isPersisting')}
|
||||
isPublishing={entry.get('isPublishing')}
|
||||
isUpdatingStatus={entry.get('isUpdatingStatus')}
|
||||
isDeleting={entry.get('isDeleting')}
|
||||
onPersist={this.handleOnPersist}
|
||||
onPersistAndNew={() => this.handleOnPersist({ createNew: true })}
|
||||
onDelete={onDelete}
|
||||
onDeleteUnpublishedChanges={onDeleteUnpublishedChanges}
|
||||
onChangeStatus={onChangeStatus}
|
||||
showDelete={showDelete}
|
||||
onPublish={onPublish}
|
||||
onPublishAndNew={() => this.handleOnPublish({ createNew: true })}
|
||||
enableSave={enableSave}
|
||||
user={user}
|
||||
hasChanged={hasChanged}
|
||||
displayUrl={displayUrl}
|
||||
collection={collection}
|
||||
hasWorkflow={hasWorkflow}
|
||||
hasUnpublishedChanges={hasUnpublishedChanges}
|
||||
isNewEntry={isNewEntry}
|
||||
isModification={isModification}
|
||||
currentStatus={currentStatus}
|
||||
onLogoutClick={onLogoutClick}
|
||||
/>
|
||||
<div className="nc-entryEditor-container">
|
||||
<div className="nc-entryEditor-viewControls">
|
||||
<EditorToggle
|
||||
enabled={collectionPreviewEnabled}
|
||||
active={previewVisible}
|
||||
onClick={this.handleTogglePreview}
|
||||
icon="eye"
|
||||
/>
|
||||
<EditorToggle
|
||||
enabled={collectionPreviewEnabled && previewVisible}
|
||||
active={scrollSyncEnabled}
|
||||
onClick={this.handleToggleScrollSync}
|
||||
icon="scroll"
|
||||
/>
|
||||
</div>
|
||||
{
|
||||
collectionPreviewEnabled && this.state.previewVisible
|
||||
? editorWithPreview
|
||||
: editorWithoutPreview
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
EditorInterface.propTypes = {
|
||||
collection: ImmutablePropTypes.map.isRequired,
|
||||
entry: ImmutablePropTypes.map.isRequired,
|
||||
fields: ImmutablePropTypes.list.isRequired,
|
||||
fieldsMetaData: ImmutablePropTypes.map.isRequired,
|
||||
fieldsErrors: ImmutablePropTypes.map.isRequired,
|
||||
mediaPaths: ImmutablePropTypes.map.isRequired,
|
||||
getAsset: PropTypes.func.isRequired,
|
||||
onOpenMediaLibrary: PropTypes.func.isRequired,
|
||||
onAddAsset: PropTypes.func.isRequired,
|
||||
onChange: PropTypes.func.isRequired,
|
||||
onValidate: PropTypes.func.isRequired,
|
||||
onPersist: PropTypes.func.isRequired,
|
||||
enableSave: PropTypes.bool.isRequired,
|
||||
showDelete: PropTypes.bool.isRequired,
|
||||
onDelete: PropTypes.func.isRequired,
|
||||
onDeleteUnpublishedChanges: PropTypes.func.isRequired,
|
||||
onPublish: PropTypes.func.isRequired,
|
||||
onChangeStatus: PropTypes.func.isRequired,
|
||||
onRemoveInsertedMedia: PropTypes.func.isRequired,
|
||||
user: ImmutablePropTypes.map,
|
||||
hasChanged: PropTypes.bool,
|
||||
displayUrl: PropTypes.string,
|
||||
hasWorkflow: PropTypes.bool,
|
||||
hasUnpublishedChanges: PropTypes.bool,
|
||||
isNewEntry: PropTypes.bool,
|
||||
isModification: PropTypes.bool,
|
||||
currentStatus: PropTypes.string,
|
||||
onLogoutClick: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
export default EditorInterface;
|
@ -1,37 +0,0 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
|
||||
function isVisible(field) {
|
||||
return field.get('widget') !== 'hidden';
|
||||
}
|
||||
|
||||
const style = {
|
||||
fontFamily: 'Roboto, "Helvetica Neue", HelveticaNeue, Helvetica, Arial, sans-serif',
|
||||
};
|
||||
|
||||
/**
|
||||
* Use a stateful component so that child components can effectively utilize
|
||||
* `shouldComponentUpdate`.
|
||||
*/
|
||||
export default class Preview extends React.Component {
|
||||
render() {
|
||||
const { collection, fields, widgetFor } = this.props;
|
||||
if (!collection || !fields) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<div style={style}>
|
||||
{fields.filter(isVisible).map(field => widgetFor(field.get('name')))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Preview.propTypes = {
|
||||
collection: ImmutablePropTypes.map.isRequired,
|
||||
entry: ImmutablePropTypes.map.isRequired,
|
||||
fields: ImmutablePropTypes.list.isRequired,
|
||||
getAsset: PropTypes.func.isRequired,
|
||||
widgetFor: PropTypes.func.isRequired,
|
||||
};
|
@ -1,28 +0,0 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
import { isElement } from 'react-is';
|
||||
import { ScrollSyncPane } from 'react-scroll-sync';
|
||||
|
||||
/**
|
||||
* We need to create a lightweight component here so that we can access the
|
||||
* context within the Frame. This allows us to attach the ScrollSyncPane to the
|
||||
* body.
|
||||
*/
|
||||
class PreviewContent extends React.Component {
|
||||
render() {
|
||||
const { previewComponent, previewProps } = this.props;
|
||||
return (
|
||||
<ScrollSyncPane attachTo={this.context.document.scrollingElement}>
|
||||
{isElement(previewComponent)
|
||||
? React.cloneElement(previewComponent, previewProps)
|
||||
: React.createElement(previewComponent, previewProps)}
|
||||
</ScrollSyncPane>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
PreviewContent.contextTypes = {
|
||||
document: PropTypes.any,
|
||||
};
|
||||
|
||||
export default PreviewContent;
|
@ -1,7 +0,0 @@
|
||||
.nc-previewPane-frame {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border: none;
|
||||
background: #fff;
|
||||
border-radius: var(--borderRadius);
|
||||
}
|
@ -1,176 +0,0 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
import { List, Map } from 'immutable';
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
import Frame from 'react-frame-component';
|
||||
import { resolveWidget, getPreviewTemplate, getPreviewStyles } from 'Lib/registry';
|
||||
import { ErrorBoundary } from 'UI';
|
||||
import { selectTemplateName, selectInferedField } from 'Reducers/collections';
|
||||
import { INFERABLE_FIELDS } from 'Constants/fieldInference';
|
||||
import EditorPreviewContent from './EditorPreviewContent.js';
|
||||
import PreviewHOC from './PreviewHOC';
|
||||
import EditorPreview from './EditorPreview';
|
||||
|
||||
export default class PreviewPane extends React.Component {
|
||||
|
||||
getWidget = (field, value, props) => {
|
||||
const { fieldsMetaData, getAsset, entry } = props;
|
||||
const widget = resolveWidget(field.get('widget'));
|
||||
|
||||
/**
|
||||
* Use an HOC to provide conditional updates for all previews.
|
||||
*/
|
||||
return !widget.preview ? null : (
|
||||
<PreviewHOC
|
||||
previewComponent={widget.preview}
|
||||
key={field.get('name')}
|
||||
field={field}
|
||||
getAsset={getAsset}
|
||||
value={value && Map.isMap(value) ? value.get(field.get('name')) : value}
|
||||
metadata={fieldsMetaData && fieldsMetaData.get(field.get('name'))}
|
||||
entry={entry}
|
||||
fieldsMetaData={fieldsMetaData}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
inferedFields = {};
|
||||
|
||||
inferFields() {
|
||||
const titleField = selectInferedField(this.props.collection, 'title');
|
||||
const shortTitleField = selectInferedField(this.props.collection, 'shortTitle');
|
||||
const authorField = selectInferedField(this.props.collection, 'author');
|
||||
|
||||
this.inferedFields = {};
|
||||
if (titleField) this.inferedFields[titleField] = INFERABLE_FIELDS.title;
|
||||
if (shortTitleField) this.inferedFields[shortTitleField] = INFERABLE_FIELDS.shortTitle;
|
||||
if (authorField) this.inferedFields[authorField] = INFERABLE_FIELDS.author;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the widget component for a named field, and makes recursive calls
|
||||
* to retrieve components for nested and deeply nested fields, which occur in
|
||||
* object and list type fields. Used internally to retrieve widgets, and also
|
||||
* exposed for use in custom preview templates.
|
||||
*/
|
||||
widgetFor = (name, fields = this.props.fields, values = this.props.entry.get('data')) => {
|
||||
// We retrieve the field by name so that this function can also be used in
|
||||
// custom preview templates, where the field object can't be passed in.
|
||||
let field = fields && fields.find(f => f.get('name') === name);
|
||||
let value = values && values.get(field.get('name'));
|
||||
let nestedFields = field.get('fields');
|
||||
|
||||
if (nestedFields) {
|
||||
field = field.set('fields', this.getNestedWidgets(nestedFields, value));
|
||||
}
|
||||
|
||||
const labelledWidgets = ['string', 'text', 'number'];
|
||||
if (Object.keys(this.inferedFields).indexOf(name) !== -1) {
|
||||
value = this.inferedFields[name].defaultPreview(value);
|
||||
} else if (value && labelledWidgets.indexOf(field.get('widget')) !== -1 && value.toString().length < 50) {
|
||||
value = <div><strong>{field.get('label')}:</strong> {value}</div>;
|
||||
}
|
||||
|
||||
return value ? this.getWidget(field, value, this.props) : null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Retrieves widgets for nested fields (children of object/list fields)
|
||||
*/
|
||||
getNestedWidgets = (fields, values) => {
|
||||
// Fields nested within a list field will be paired with a List of value Maps.
|
||||
if (List.isList(values)) {
|
||||
return values.map(value => this.widgetsForNestedFields(fields, value));
|
||||
}
|
||||
// Fields nested within an object field will be paired with a single Map of values.
|
||||
return this.widgetsForNestedFields(fields, values);
|
||||
};
|
||||
|
||||
/**
|
||||
* Use widgetFor as a mapping function for recursive widget retrieval
|
||||
*/
|
||||
widgetsForNestedFields = (fields, values) => {
|
||||
return fields.map(field => this.widgetFor(field.get('name'), fields, values));
|
||||
};
|
||||
|
||||
/**
|
||||
* This function exists entirely to expose nested widgets for object and list
|
||||
* fields to custom preview templates.
|
||||
*
|
||||
* TODO: see if widgetFor can now provide this functionality for preview templates
|
||||
*/
|
||||
widgetsFor = (name) => {
|
||||
const { fields, entry } = this.props;
|
||||
const field = fields.find(f => f.get('name') === name);
|
||||
const nestedFields = field && field.get('fields');
|
||||
const value = entry.getIn(['data', field.get('name')]);
|
||||
|
||||
if (List.isList(value)) {
|
||||
return value.map((val, index) => {
|
||||
const widgets = nestedFields && Map(nestedFields.map((f, i) => [f.get('name'), <div key={i}>{this.getWidget(f, val, this.props)}</div>]));
|
||||
return Map({ data: val, widgets });
|
||||
});
|
||||
};
|
||||
|
||||
return Map({
|
||||
data: value,
|
||||
widgets: nestedFields && Map(nestedFields.map(f => [f.get('name'), this.getWidget(f, value, this.props)])),
|
||||
});
|
||||
};
|
||||
|
||||
render() {
|
||||
const { entry, collection } = this.props;
|
||||
|
||||
if (!entry || !entry.get('data')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const previewComponent =
|
||||
getPreviewTemplate(selectTemplateName(collection, entry.get('slug'))) ||
|
||||
EditorPreview;
|
||||
|
||||
this.inferFields();
|
||||
|
||||
const previewProps = {
|
||||
...this.props,
|
||||
widgetFor: this.widgetFor,
|
||||
widgetsFor: this.widgetsFor,
|
||||
};
|
||||
|
||||
const styleEls = getPreviewStyles()
|
||||
.map((style, i) => {
|
||||
if (style.raw) {
|
||||
return <style key={i}>{style.value}</style>
|
||||
}
|
||||
return <link key={i} href={style.value} type="text/css" rel="stylesheet" />;
|
||||
});
|
||||
|
||||
if (!collection) {
|
||||
return <Frame className="nc-previewPane-frame" head={styleEls} />;
|
||||
}
|
||||
|
||||
const initialContent = `
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head><base target="_blank"/></head>
|
||||
<body><div></div></body>
|
||||
</html>
|
||||
`;
|
||||
|
||||
return (
|
||||
<ErrorBoundary>
|
||||
<Frame className="nc-previewPane-frame" head={styleEls} initialContent={initialContent}>
|
||||
<EditorPreviewContent {...{ previewComponent, previewProps }}/>
|
||||
</Frame>
|
||||
</ErrorBoundary>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
PreviewPane.propTypes = {
|
||||
collection: ImmutablePropTypes.map.isRequired,
|
||||
fields: ImmutablePropTypes.list.isRequired,
|
||||
entry: ImmutablePropTypes.map.isRequired,
|
||||
fieldsMetaData: ImmutablePropTypes.map.isRequired,
|
||||
getAsset: PropTypes.func.isRequired,
|
||||
};
|
@ -1,21 +0,0 @@
|
||||
import React from 'react';
|
||||
|
||||
class PreviewHOC extends React.Component {
|
||||
|
||||
/**
|
||||
* Only re-render on value change, but always re-render objects and lists.
|
||||
* Their child widgets will each also be wrapped with this component, and
|
||||
* will only be updated on value change.
|
||||
*/
|
||||
shouldComponentUpdate(nextProps) {
|
||||
const isWidgetContainer = ['object', 'list'].includes(nextProps.field.get('widget'));
|
||||
return isWidgetContainer || this.props.value !== nextProps.value;
|
||||
}
|
||||
|
||||
render() {
|
||||
const { previewComponent, ...props } = this.props;
|
||||
return React.createElement(previewComponent, props);
|
||||
}
|
||||
}
|
||||
|
||||
export default PreviewHOC;
|
@ -1,127 +0,0 @@
|
||||
import React, { Component } from 'react'
|
||||
import PropTypes from 'prop-types'
|
||||
|
||||
/**
|
||||
* ScrollSync provider component
|
||||
*
|
||||
*/
|
||||
|
||||
export default class ScrollSync extends Component {
|
||||
|
||||
static propTypes = {
|
||||
children: PropTypes.element.isRequired,
|
||||
proportional: PropTypes.bool,
|
||||
vertical: PropTypes.bool,
|
||||
horizontal: PropTypes.bool,
|
||||
enabled: PropTypes.bool
|
||||
};
|
||||
|
||||
static defaultProps = {
|
||||
proportional: true,
|
||||
vertical: true,
|
||||
horizontal: true,
|
||||
enabled: true
|
||||
};
|
||||
|
||||
static childContextTypes = {
|
||||
registerPane: PropTypes.func,
|
||||
unregisterPane: PropTypes.func
|
||||
}
|
||||
|
||||
getChildContext() {
|
||||
return {
|
||||
registerPane: this.registerPane,
|
||||
unregisterPane: this.unregisterPane
|
||||
}
|
||||
}
|
||||
|
||||
panes = {}
|
||||
|
||||
registerPane = (node, group) => {
|
||||
if (!this.panes[group]) {
|
||||
this.panes[group] = []
|
||||
}
|
||||
|
||||
if (!this.findPane(node, group)) {
|
||||
this.addEvents(node, group)
|
||||
this.panes[group].push(node)
|
||||
}
|
||||
}
|
||||
|
||||
unregisterPane = (node, group) => {
|
||||
if (this.findPane(node, group)) {
|
||||
this.removeEvents(node)
|
||||
this.panes[group].splice(this.panes[group].indexOf(node), 1)
|
||||
}
|
||||
}
|
||||
|
||||
addEvents = (node, group) => {
|
||||
/* For some reason element.addEventListener doesnt work with document.body */
|
||||
node.onscroll = this.handlePaneScroll.bind(this, node, group) // eslint-disable-line
|
||||
}
|
||||
|
||||
removeEvents = (node) => {
|
||||
/* For some reason element.removeEventListener doesnt work with document.body */
|
||||
node.onscroll = null // eslint-disable-line
|
||||
}
|
||||
|
||||
findPane = (node, group) => {
|
||||
if (!this.panes[group]) {
|
||||
return false
|
||||
}
|
||||
|
||||
return this.panes[group].find(pane => pane === node)
|
||||
}
|
||||
|
||||
handlePaneScroll = (node, group) => {
|
||||
if (!this.props.enabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
window.requestAnimationFrame(() => {
|
||||
this.syncScrollPositions(node, group)
|
||||
})
|
||||
}
|
||||
|
||||
syncScrollPositions = (scrolledPane, group) => {
|
||||
const {
|
||||
scrollTop,
|
||||
scrollHeight,
|
||||
clientHeight,
|
||||
scrollLeft,
|
||||
scrollWidth,
|
||||
clientWidth
|
||||
} = scrolledPane
|
||||
|
||||
const scrollTopOffset = scrollHeight - clientHeight
|
||||
const scrollLeftOffset = scrollWidth - clientWidth
|
||||
|
||||
const { proportional, vertical, horizontal } = this.props
|
||||
|
||||
this.panes[group].forEach((pane) => {
|
||||
/* For all panes beside the currently scrolling one */
|
||||
if (scrolledPane !== pane) {
|
||||
/* Remove event listeners from the node that we'll manipulate */
|
||||
this.removeEvents(pane, group)
|
||||
/* Calculate the actual pane height */
|
||||
const paneHeight = pane.scrollHeight - clientHeight
|
||||
const paneWidth = pane.scrollWidth - clientWidth
|
||||
/* Adjust the scrollTop position of it accordingly */
|
||||
if (vertical && scrollTopOffset > 0) {
|
||||
pane.scrollTop = proportional ? (paneHeight * scrollTop) / scrollTopOffset : scrollTop // eslint-disable-line
|
||||
}
|
||||
if (horizontal && scrollLeftOffset > 0) {
|
||||
pane.scrollLeft = proportional ? (paneWidth * scrollLeft) / scrollLeftOffset : scrollLeft // eslint-disable-line
|
||||
}
|
||||
/* Re-attach event listeners after we're done scrolling */
|
||||
window.requestAnimationFrame(() => {
|
||||
this.addEvents(pane, group)
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
render() {
|
||||
return React.Children.only(this.props.children)
|
||||
}
|
||||
}
|
@ -1,50 +0,0 @@
|
||||
import { Component } from 'react'
|
||||
import ReactDOM from 'react-dom'
|
||||
import PropTypes from 'prop-types'
|
||||
|
||||
/**
|
||||
* ScrollSyncPane Component
|
||||
*
|
||||
* Wrap your content in it to keep its scroll position in sync with other panes
|
||||
*
|
||||
* @example ./example.md
|
||||
*/
|
||||
|
||||
|
||||
export default class ScrollSyncPane extends Component {
|
||||
|
||||
static propTypes = {
|
||||
children: PropTypes.node.isRequired,
|
||||
attachTo: PropTypes.object,
|
||||
group: PropTypes.string
|
||||
}
|
||||
|
||||
static defaultProps = {
|
||||
group: 'default'
|
||||
}
|
||||
|
||||
static contextTypes = {
|
||||
registerPane: PropTypes.func.isRequired,
|
||||
unregisterPane: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
componentDidMount() {
|
||||
this.node = this.props.attachTo || ReactDOM.findDOMNode(this)
|
||||
this.context.registerPane(this.node, this.props.group)
|
||||
}
|
||||
|
||||
componentWillReceiveProps(nextProps) {
|
||||
if (this.props.group !== nextProps.group) {
|
||||
this.context.unregisterPane(this.node, this.props.group)
|
||||
this.context.registerPane(this.node, nextProps.group)
|
||||
}
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
this.context.unregisterPane(this.node, this.props.group)
|
||||
}
|
||||
|
||||
render() {
|
||||
return this.props.children
|
||||
}
|
||||
}
|
@ -1,2 +0,0 @@
|
||||
export { default as ScrollSync } from './ScrollSync';
|
||||
export { default as ScrollSyncPane } from './ScrollSyncPane';
|
@ -1,23 +0,0 @@
|
||||
.nc-editor-toggle {
|
||||
@apply(--dropShadowMiddle);
|
||||
background-color: #fff;
|
||||
color: var(--colorInactive);
|
||||
border-radius: 32px;
|
||||
display: block;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
padding: 0;
|
||||
margin-bottom: 12px;
|
||||
|
||||
& .nc-icon {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
}
|
||||
|
||||
.nc-editor-toggleActive {
|
||||
color: var(--colorActive);
|
||||
}
|
@ -1,18 +0,0 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import c from 'classnames';
|
||||
import { Icon } from 'UI';
|
||||
|
||||
const EditorToggle = ({ enabled, active, onClick, icon }) => !enabled ? null :
|
||||
<button className={c('nc-editor-toggle', {'nc-editor-toggleActive': active })} onClick={onClick}>
|
||||
<Icon type={icon} size="large"/>
|
||||
</button>;
|
||||
|
||||
EditorToggle.propTypes = {
|
||||
enabled: PropTypes.bool,
|
||||
active: PropTypes.bool,
|
||||
onClick: PropTypes.func.isRequired,
|
||||
icon: PropTypes.string.isRequired,
|
||||
};
|
||||
|
||||
export default EditorToggle;
|
@ -1,151 +0,0 @@
|
||||
:root {
|
||||
--editorToolbarButtonMargin: 0 10px;
|
||||
}
|
||||
|
||||
.nc-entryEditor-toolbar {
|
||||
box-shadow: 0 2px 6px 0 rgba(68, 74, 87, 0.05),
|
||||
0 1px 3px 0 rgba(68, 74, 87, 0.10),
|
||||
0 2px 54px rgba(0, 0, 0, 0.1);
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
min-width: 800px;
|
||||
z-index: 300;
|
||||
background-color: #fff;
|
||||
height: 66px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.nc-entryEditor-toolbar-mainSection,
|
||||
.nc-entryEditor-toolbar-backSection,
|
||||
.nc-entryEditor-toolbar-metaSection {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
}
|
||||
|
||||
.nc-entryEditor-toolbar-mainSection {
|
||||
flex: 10;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 0 10px;
|
||||
|
||||
& .nc-entryEditor-toolbar-mainSection-left {
|
||||
display: flex;
|
||||
}
|
||||
& .nc-entryEditor-toolbar-mainSection-right {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
}
|
||||
|
||||
.nc-entryEditor-toolbar-backSection,
|
||||
.nc-entryEditor-toolbar-metaSection {
|
||||
border: 0 solid var(--textFieldBorderColor);
|
||||
}
|
||||
|
||||
.nc-entryEditor-toolbar-dropdown {
|
||||
margin: var(--editorToolbarButtonMargin);
|
||||
|
||||
& .nc-icon {
|
||||
color: var(--colorTeal);
|
||||
}
|
||||
}
|
||||
|
||||
.nc-entryEditor-toolbar-publishButton {
|
||||
background-color: var(--colorTeal);
|
||||
}
|
||||
|
||||
.nc-entryEditor-toolbar-statusButton {
|
||||
background-color: var(--colorTealLight);
|
||||
color: var(--colorTeal);
|
||||
}
|
||||
|
||||
.nc-entryEditor-toolbar-backSection {
|
||||
border-right-width: 1px;
|
||||
font-weight: normal;
|
||||
padding: 0 20px;
|
||||
|
||||
&:hover,
|
||||
&:focus {
|
||||
background-color: #F1F2F4;
|
||||
}
|
||||
}
|
||||
|
||||
.nc-entryEditor-toolbar-metaSection {
|
||||
border-left-width: 1px;
|
||||
padding: 0 7px;
|
||||
}
|
||||
|
||||
.nc-entryEditor-toolbar-backArrow {
|
||||
color: var(--colorTextLead);
|
||||
font-size: 21px;
|
||||
font-weight: 600;
|
||||
margin-right: 16px;
|
||||
}
|
||||
|
||||
.nc-entryEditor-toolbar-backCollection {
|
||||
color: var(--colorTextLead);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.nc-entryEditor-toolbar-backStatus {
|
||||
@apply(--textBadgeSuccess);
|
||||
|
||||
&::after {
|
||||
height: 12px;
|
||||
width: 15.5px;
|
||||
color: #005614;
|
||||
margin-left: 5px;
|
||||
|
||||
position: relative;
|
||||
top: 1px;
|
||||
|
||||
content: url("data:image/svg+xml; utf8, <svg xmlns='http://www.w3.org/2000/svg' width='15' height='11'><path fill='#005614' fill-rule='nonzero' d='M4.016 11l-.648-.946a6.202 6.202 0 0 0-.157-.22 9.526 9.526 0 0 1-.096-.133l-.511-.7a7.413 7.413 0 0 0-.162-.214l-.102-.134-.265-.346a26.903 26.903 0 0 0-.543-.687l-.11-.136c-.143-.179-.291-.363-.442-.54l-.278-.332a8.854 8.854 0 0 0-.192-.225L.417 6.28l-.283-.324L0 5.805l1.376-1.602c.04.027.186.132.186.132l.377.272.129.095c.08.058.16.115.237.175l.37.28c.192.142.382.292.565.436l.162.126c.27.21.503.398.714.574l.477.393c.078.064.156.127.23.194l.433.375.171-.205A50.865 50.865 0 0 1 8.18 4.023a35.163 35.163 0 0 1 2.382-2.213c.207-.174.42-.349.635-.518l.328-.255.333-.245c.072-.055.146-.107.221-.159l.117-.083c.11-.077.225-.155.341-.23.163-.11.334-.217.503-.32l1.158 1.74a11.908 11.908 0 0 0-.64.55l-.065.06c-.07.062-.139.125-.207.192l-.258.249-.26.265c-.173.176-.345.357-.512.539a32.626 32.626 0 0 0-1.915 2.313 52.115 52.115 0 0 0-2.572 3.746l-.392.642-.19.322-.233.382H4.016z'/></svg>");
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
.nc-entryEditor-toolbar-backStatus-hasChanged {
|
||||
@apply(--textBadgeDanger);
|
||||
}
|
||||
|
||||
.nc-entryEditor-toolbar-backStatus,
|
||||
.nc-entryEditor-toolbar-backStatus-hasChanged {
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
.nc-entryEditor-toolbar-deleteButton,
|
||||
.nc-entryEditor-toolbar-saveButton {
|
||||
@apply(--buttonDefault);
|
||||
display: block;
|
||||
margin: var(--editorToolbarButtonMargin);
|
||||
}
|
||||
|
||||
.nc-entryEditor-toolbar-deleteButton {
|
||||
@apply(--buttonLightRed);
|
||||
}
|
||||
|
||||
.nc-entryEditor-toolbar-saveButton {
|
||||
@apply(--buttonLightBlue);
|
||||
}
|
||||
|
||||
.nc-entryEditor-toolbar-statusPublished {
|
||||
margin: var(--editorToolbarButtonMargin);
|
||||
border: 1px solid var(--textFieldBorderColor);
|
||||
border-radius: var(--borderRadius);
|
||||
background-color: var(--colorWhite);
|
||||
color: var(--colorTeal);
|
||||
padding: 0 24px;
|
||||
line-height: 36px;
|
||||
cursor: default;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.nc-entryEditor-toolbar-statusMenu-status .nc-icon {
|
||||
color: var(--colorInfoText);
|
||||
}
|
@ -1,248 +0,0 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
import c from 'classnames';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { status } from 'Constants/publishModes';
|
||||
import { Icon, Dropdown, DropdownItem } from 'UI';
|
||||
import { stripProtocol } from 'Lib/urlHelper';
|
||||
|
||||
export default class EditorToolbar extends React.Component {
|
||||
static propTypes = {
|
||||
isPersisting: PropTypes.bool,
|
||||
isPublishing: PropTypes.bool,
|
||||
isUpdatingStatus: PropTypes.bool,
|
||||
isDeleting: PropTypes.bool,
|
||||
onPersist: PropTypes.func.isRequired,
|
||||
onPersistAndNew: PropTypes.func.isRequired,
|
||||
enableSave: PropTypes.bool.isRequired,
|
||||
showDelete: PropTypes.bool.isRequired,
|
||||
onDelete: PropTypes.func.isRequired,
|
||||
onDeleteUnpublishedChanges: PropTypes.func.isRequired,
|
||||
onChangeStatus: PropTypes.func.isRequired,
|
||||
onPublish: PropTypes.func.isRequired,
|
||||
onPublishAndNew: PropTypes.func.isRequired,
|
||||
user: ImmutablePropTypes.map,
|
||||
hasChanged: PropTypes.bool,
|
||||
displayUrl: PropTypes.string,
|
||||
collection: ImmutablePropTypes.map.isRequired,
|
||||
hasWorkflow: PropTypes.bool,
|
||||
hasUnpublishedChanges: PropTypes.bool,
|
||||
isNewEntry: PropTypes.bool,
|
||||
isModification: PropTypes.bool,
|
||||
currentStatus: PropTypes.string,
|
||||
onLogoutClick: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
renderSimpleSaveControls = () => {
|
||||
const { showDelete, onDelete } = this.props;
|
||||
return (
|
||||
<div>
|
||||
{
|
||||
showDelete
|
||||
? <button className="nc-entryEditor-toolbar-deleteButton" onClick={onDelete}>
|
||||
Delete entry
|
||||
</button>
|
||||
: null
|
||||
}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
renderSimplePublishControls = () => {
|
||||
const { collection, onPersist, onPersistAndNew, isPersisting, hasChanged, isNewEntry } = this.props;
|
||||
if (!isNewEntry && !hasChanged) {
|
||||
return <div className="nc-entryEditor-toolbar-statusPublished">Published</div>;
|
||||
}
|
||||
return (
|
||||
<div>
|
||||
<Dropdown
|
||||
className="nc-entryEditor-toolbar-dropdown"
|
||||
classNameButton="nc-entryEditor-toolbar-publishButton"
|
||||
dropdownTopOverlap="40px"
|
||||
dropdownWidth="150px"
|
||||
label={isPersisting ? 'Publishing...' : 'Publish'}
|
||||
>
|
||||
<DropdownItem label="Publish now" icon="arrow" iconDirection="right" onClick={onPersist}/>
|
||||
{
|
||||
collection.get('create')
|
||||
? <DropdownItem label="Publish and create new" icon="add" onClick={onPersistAndNew}/>
|
||||
: null
|
||||
}
|
||||
</Dropdown>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
renderWorkflowSaveControls = () => {
|
||||
const {
|
||||
onPersist,
|
||||
onDelete,
|
||||
onDeleteUnpublishedChanges,
|
||||
hasChanged,
|
||||
hasUnpublishedChanges,
|
||||
isPersisting,
|
||||
isDeleting,
|
||||
isNewEntry,
|
||||
isModification,
|
||||
} = this.props;
|
||||
|
||||
const deleteLabel = (hasUnpublishedChanges && isModification && 'Delete unpublished changes')
|
||||
|| (hasUnpublishedChanges && (isNewEntry || !isModification) && 'Delete unpublished entry')
|
||||
|| (!hasUnpublishedChanges && !isModification && 'Delete published entry');
|
||||
|
||||
return [
|
||||
<button
|
||||
key="save-button"
|
||||
className="nc-entryEditor-toolbar-saveButton"
|
||||
onClick={() => hasChanged && onPersist()}
|
||||
>
|
||||
{isPersisting ? 'Saving...' : 'Save'}
|
||||
</button>,
|
||||
isNewEntry || !deleteLabel ? null
|
||||
: <button
|
||||
key="delete-button"
|
||||
className="nc-entryEditor-toolbar-deleteButton"
|
||||
onClick={hasUnpublishedChanges ? onDeleteUnpublishedChanges : onDelete}
|
||||
>
|
||||
{isDeleting ? 'Deleting...' : deleteLabel}
|
||||
</button>,
|
||||
];
|
||||
};
|
||||
|
||||
renderWorkflowPublishControls = () => {
|
||||
const {
|
||||
collection,
|
||||
onPersist,
|
||||
onPersistAndNew,
|
||||
isUpdatingStatus,
|
||||
isPublishing,
|
||||
onChangeStatus,
|
||||
onPublish,
|
||||
onPublishAndNew,
|
||||
currentStatus,
|
||||
isNewEntry,
|
||||
} = this.props;
|
||||
if (currentStatus) {
|
||||
return [
|
||||
<Dropdown
|
||||
className="nc-entryEditor-toolbar-dropdown"
|
||||
classNameButton="nc-entryEditor-toolbar-statusButton"
|
||||
dropdownTopOverlap="40px"
|
||||
dropdownWidth="120px"
|
||||
label={isUpdatingStatus ? 'Updating...' : 'Set status'}
|
||||
>
|
||||
<DropdownItem
|
||||
className="nc-entryEditor-toolbar-statusMenu-status"
|
||||
label="Draft"
|
||||
onClick={() => onChangeStatus('DRAFT')}
|
||||
icon={currentStatus === status.get('DRAFT') && 'check'}
|
||||
/>
|
||||
<DropdownItem
|
||||
className="nc-entryEditor-toolbar-statusMenu-status"
|
||||
label="In review"
|
||||
onClick={() => onChangeStatus('PENDING_REVIEW')}
|
||||
icon={currentStatus === status.get('PENDING_REVIEW') && 'check'}
|
||||
/>
|
||||
<DropdownItem
|
||||
className="nc-entryEditor-toolbar-statusMenu-status"
|
||||
label="Ready"
|
||||
onClick={() => onChangeStatus('PENDING_PUBLISH')}
|
||||
icon={currentStatus === status.get('PENDING_PUBLISH') && 'check'}
|
||||
/>
|
||||
</Dropdown>,
|
||||
<Dropdown
|
||||
className="nc-entryEditor-toolbar-dropdown"
|
||||
classNameButton="nc-entryEditor-toolbar-publishButton"
|
||||
dropdownTopOverlap="40px"
|
||||
dropdownWidth="150px"
|
||||
label={isPublishing ? 'Publishing...' : 'Publish'}
|
||||
>
|
||||
<DropdownItem label="Publish now" icon="arrow" iconDirection="right" onClick={onPublish}/>
|
||||
{
|
||||
collection.get('create')
|
||||
? <DropdownItem label="Publish and create new" icon="add" onClick={onPublishAndNew}/>
|
||||
: null
|
||||
}
|
||||
</Dropdown>
|
||||
];
|
||||
}
|
||||
|
||||
if (!isNewEntry) {
|
||||
return <div className="nc-entryEditor-toolbar-statusPublished">Published</div>;
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
|
||||
render() {
|
||||
const {
|
||||
isPersisting,
|
||||
onPersist,
|
||||
onPersistAndNew,
|
||||
enableSave,
|
||||
showDelete,
|
||||
onDelete,
|
||||
user,
|
||||
hasChanged,
|
||||
displayUrl,
|
||||
collection,
|
||||
hasWorkflow,
|
||||
hasUnpublishedChanges,
|
||||
onLogoutClick,
|
||||
} = this.props;
|
||||
const disabled = !enableSave || isPersisting;
|
||||
const avatarUrl = user.get('avatar_url');
|
||||
|
||||
return (
|
||||
<div className="nc-entryEditor-toolbar">
|
||||
<Link to={`/collections/${collection.get('name')}`} className="nc-entryEditor-toolbar-backSection">
|
||||
<div className="nc-entryEditor-toolbar-backArrow">←</div>
|
||||
<div>
|
||||
<div className="nc-entryEditor-toolbar-backCollection">
|
||||
Writing in <strong>{collection.get('label')}</strong> collection
|
||||
</div>
|
||||
{
|
||||
hasChanged
|
||||
? <div className="nc-entryEditor-toolbar-backStatus-hasChanged">Unsaved Changes</div>
|
||||
: <div className="nc-entryEditor-toolbar-backStatus">Changes saved</div>
|
||||
}
|
||||
</div>
|
||||
</Link>
|
||||
<div className="nc-entryEditor-toolbar-mainSection">
|
||||
<div className="nc-entryEditor-toolbar-mainSection-left">
|
||||
{ hasWorkflow ? this.renderWorkflowSaveControls() : this.renderSimpleSaveControls() }
|
||||
</div>
|
||||
<div className="nc-entryEditor-toolbar-mainSection-right">
|
||||
{ hasWorkflow ? this.renderWorkflowPublishControls() : this.renderSimplePublishControls() }
|
||||
</div>
|
||||
</div>
|
||||
<div className="nc-entryEditor-toolbar-metaSection">
|
||||
{
|
||||
displayUrl
|
||||
? <a className="nc-appHeader-siteLink" href={displayUrl} target="_blank">
|
||||
{stripProtocol(displayUrl)}
|
||||
</a>
|
||||
: null
|
||||
}
|
||||
<Dropdown
|
||||
dropdownTopOverlap="50px"
|
||||
dropdownWidth="100px"
|
||||
dropdownPosition="right"
|
||||
button={
|
||||
<button className="nc-appHeader-avatar">
|
||||
{
|
||||
avatarUrl
|
||||
? <img className="nc-appHeader-avatar-image" src={user.get('avatar_url')}/>
|
||||
: <Icon className="nc-appHeader-avatar-placeholder" type="user" size="large"/>
|
||||
}
|
||||
</button>
|
||||
}
|
||||
>
|
||||
<DropdownItem label="Log Out" onClick={onLogoutClick}/>
|
||||
</Dropdown>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
};
|
@ -1,58 +0,0 @@
|
||||
import React from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
import { EDITORIAL_WORKFLOW } from 'Constants/publishModes';
|
||||
import { selectUnpublishedEntry, selectEntry } from 'Reducers';
|
||||
import { selectAllowDeletion } from 'Reducers/collections';
|
||||
import { loadUnpublishedEntry, persistUnpublishedEntry } from 'Actions/editorialWorkflow';
|
||||
|
||||
function mapStateToProps(state, ownProps) {
|
||||
const { collections } = state;
|
||||
const isEditorialWorkflow = (state.config.get('publish_mode') === EDITORIAL_WORKFLOW);
|
||||
const collection = collections.get(ownProps.match.params.name);
|
||||
const returnObj = {
|
||||
isEditorialWorkflow,
|
||||
showDelete: !ownProps.newEntry && selectAllowDeletion(collection),
|
||||
};
|
||||
if (isEditorialWorkflow) {
|
||||
const slug = ownProps.match.params.slug;
|
||||
const unpublishedEntry = selectUnpublishedEntry(state, collection.get('name'), slug);
|
||||
if (unpublishedEntry) {
|
||||
returnObj.unpublishedEntry = true;
|
||||
returnObj.entry = unpublishedEntry;
|
||||
}
|
||||
}
|
||||
return returnObj;
|
||||
}
|
||||
|
||||
function mergeProps(stateProps, dispatchProps, ownProps) {
|
||||
const { isEditorialWorkflow, unpublishedEntry } = stateProps;
|
||||
const { dispatch } = dispatchProps;
|
||||
const returnObj = {};
|
||||
|
||||
if (isEditorialWorkflow) {
|
||||
// Overwrite loadEntry to loadUnpublishedEntry
|
||||
returnObj.loadEntry = (collection, slug) =>
|
||||
dispatch(loadUnpublishedEntry(collection, slug));
|
||||
|
||||
// Overwrite persistEntry to persistUnpublishedEntry
|
||||
returnObj.persistEntry = collection =>
|
||||
dispatch(persistUnpublishedEntry(collection, unpublishedEntry));
|
||||
}
|
||||
|
||||
return {
|
||||
...ownProps,
|
||||
...stateProps,
|
||||
...returnObj,
|
||||
};
|
||||
}
|
||||
|
||||
export default function withWorkflow(Editor) {
|
||||
return connect(mapStateToProps, null, mergeProps)(
|
||||
class extends React.Component {
|
||||
render() {
|
||||
return <Editor {...this.props} />;
|
||||
}
|
||||
}
|
||||
);
|
||||
};
|
||||
|
@ -1,9 +0,0 @@
|
||||
.nc-booleanControl-switch {
|
||||
& .nc-toggle-background {
|
||||
background-color: var(--textFieldBorderColor);
|
||||
}
|
||||
|
||||
& .nc-toggle-active .nc-toggle-background {
|
||||
background-color: var(--colorActive);
|
||||
}
|
||||
}
|
@ -1,44 +0,0 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
import ImmutablePropTypes from "react-immutable-proptypes";
|
||||
import { isBoolean } from 'lodash';
|
||||
import { Toggle } from 'UI';
|
||||
|
||||
export default class BooleanControl extends React.Component {
|
||||
render() {
|
||||
const {
|
||||
value,
|
||||
field,
|
||||
forID,
|
||||
onChange,
|
||||
classNameWrapper,
|
||||
setActiveStyle,
|
||||
setInactiveStyle
|
||||
} = this.props;
|
||||
return (
|
||||
<div className={`${classNameWrapper} nc-booleanControl-switch`}>
|
||||
<Toggle
|
||||
id={forID}
|
||||
active={isBoolean(value) ? value : field.get('defaultValue', false)}
|
||||
onChange={onChange}
|
||||
onFocus={setActiveStyle}
|
||||
onBlur={setInactiveStyle}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
BooleanControl.propTypes = {
|
||||
field: ImmutablePropTypes.map.isRequired,
|
||||
onChange: PropTypes.func.isRequired,
|
||||
classNameWrapper: PropTypes.string.isRequired,
|
||||
setActiveStyle: PropTypes.func.isRequired,
|
||||
setInactiveStyle: PropTypes.func.isRequired,
|
||||
forID: PropTypes.string,
|
||||
value: PropTypes.bool,
|
||||
};
|
||||
|
||||
BooleanControl.defaultProps = {
|
||||
value: false,
|
||||
};
|
@ -1,93 +0,0 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import DateTime from 'react-datetime';
|
||||
import moment from 'moment';
|
||||
|
||||
const DEFAULT_DATE_FORMAT = 'YYYY-MM-DD';
|
||||
const DEFAULT_DATETIME_FORMAT = moment.defaultFormat;
|
||||
|
||||
export default class DateControl extends React.Component {
|
||||
static propTypes = {
|
||||
field: PropTypes.object.isRequired,
|
||||
onChange: PropTypes.func.isRequired,
|
||||
classNameWrapper: PropTypes.string.isRequired,
|
||||
setActiveStyle: PropTypes.func.isRequired,
|
||||
setInactiveStyle: PropTypes.func.isRequired,
|
||||
value: PropTypes.oneOfType([
|
||||
PropTypes.object,
|
||||
PropTypes.string,
|
||||
]),
|
||||
includeTime: PropTypes.bool,
|
||||
};
|
||||
|
||||
format = this.props.field.get('format');
|
||||
|
||||
componentDidMount() {
|
||||
const { value } = this.props;
|
||||
|
||||
/**
|
||||
* Set the current date as default value if no default value is provided. An
|
||||
* empty string means the value is intentionally blank.
|
||||
*/
|
||||
if (!value && value !== '') {
|
||||
this.handleChange(new Date());
|
||||
}
|
||||
}
|
||||
|
||||
// Date is valid if datetime is a moment or Date object otherwise it's a string.
|
||||
// Handle the empty case, if the user wants to empty the field.
|
||||
isValidDate = datetime => (moment.isMoment(datetime) || datetime instanceof Date || datetime === '');
|
||||
|
||||
handleChange = datetime => {
|
||||
const { onChange } = this.props;
|
||||
|
||||
/**
|
||||
* Set the date only if it is valid.
|
||||
*/
|
||||
if (!this.isValidDate(datetime)) {
|
||||
return;
|
||||
}
|
||||
|
||||
/**
|
||||
* Produce a formatted string only if a format is set in the config.
|
||||
* Otherwise produce a date object.
|
||||
*/
|
||||
if (this.format) {
|
||||
const formattedValue = moment(datetime).format(this.format);
|
||||
onChange(formattedValue);
|
||||
} else {
|
||||
const value = moment.isMoment(datetime) ? datetime.toDate() : datetime;
|
||||
onChange(value);
|
||||
}
|
||||
};
|
||||
|
||||
onBlur = datetime => {
|
||||
const { setInactiveStyle } = this.props;
|
||||
|
||||
if (!this.isValidDate(datetime)) {
|
||||
const parsedDate = moment(datetime);
|
||||
|
||||
if (parsedDate.isValid()) {
|
||||
this.handleChange(datetime);
|
||||
} else {
|
||||
window.alert('The date you entered is invalid.');
|
||||
}
|
||||
}
|
||||
|
||||
setInactiveStyle();
|
||||
};
|
||||
|
||||
render() {
|
||||
const { includeTime, value, classNameWrapper, setActiveStyle, setInactiveStyle } = this.props;
|
||||
return (
|
||||
<DateTime
|
||||
timeFormat={!!includeTime}
|
||||
value={moment(value, this.format)}
|
||||
onChange={this.handleChange}
|
||||
onFocus={setActiveStyle}
|
||||
onBlur={this.onBlur}
|
||||
inputProps={{ className: classNameWrapper }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
@ -1,10 +0,0 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
|
||||
export default function DatePreview({ value }) {
|
||||
return <div className="nc-widgetPreview">{value ? value.toString() : null}</div>;
|
||||
}
|
||||
|
||||
DatePreview.propTypes = {
|
||||
value: PropTypes.object,
|
||||
};
|
@ -1 +0,0 @@
|
||||
@import "./ReactDatetime.css";
|
@ -1,43 +0,0 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import DateControl from 'EditorWidgets/Date/DateControl';
|
||||
|
||||
export default class DateTimeControl extends React.Component {
|
||||
static propTypes = {
|
||||
field: PropTypes.object.isRequired,
|
||||
onChange: PropTypes.func.isRequired,
|
||||
classNameWrapper: PropTypes.string.isRequired,
|
||||
setActiveStyle: PropTypes.func.isRequired,
|
||||
setInactiveStyle: PropTypes.func.isRequired,
|
||||
value: PropTypes.oneOfType([
|
||||
PropTypes.object,
|
||||
PropTypes.string,
|
||||
]),
|
||||
format: PropTypes.string,
|
||||
};
|
||||
|
||||
render() {
|
||||
const {
|
||||
field,
|
||||
format,
|
||||
onChange,
|
||||
value,
|
||||
classNameWrapper,
|
||||
setActiveStyle,
|
||||
setInactiveStyle
|
||||
} = this.props;
|
||||
|
||||
return (
|
||||
<DateControl
|
||||
onChange={onChange}
|
||||
format={format}
|
||||
value={value}
|
||||
field={field}
|
||||
classNameWrapper={classNameWrapper}
|
||||
setActiveStyle={setActiveStyle}
|
||||
setInactiveStyle={setInactiveStyle}
|
||||
includeTime
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
@ -1,10 +0,0 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
|
||||
export default function DateTimePreview({ value }) {
|
||||
return <div className="nc-widgetPreview">{value ? value.toString() : null}</div>;
|
||||
}
|
||||
|
||||
DateTimePreview.propTypes = {
|
||||
value: PropTypes.object,
|
||||
};
|
@ -1,210 +0,0 @@
|
||||
.rdt {
|
||||
position: relative;
|
||||
}
|
||||
.rdtPicker {
|
||||
display: none;
|
||||
position: absolute;
|
||||
width: 250px;
|
||||
padding: 4px;
|
||||
margin-top: 1px;
|
||||
z-index: 99999 !important;
|
||||
background: #fff;
|
||||
border: 2px solid var(--colorGray);
|
||||
border-radius: 2px;
|
||||
box-shadow: 0 2px 8px 0 rgba(0, 0, 0, .16);
|
||||
}
|
||||
.rdtOpen .rdtPicker {
|
||||
display: block;
|
||||
}
|
||||
.rdtStatic .rdtPicker {
|
||||
box-shadow: none;
|
||||
position: static;
|
||||
}
|
||||
|
||||
.rdtPicker .rdtTimeToggle {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.rdtPicker table {
|
||||
width: 100%;
|
||||
margin: 0;
|
||||
}
|
||||
.rdtPicker td,
|
||||
.rdtPicker th {
|
||||
text-align: center;
|
||||
height: 28px;
|
||||
}
|
||||
.rdtPicker td {
|
||||
cursor: pointer;
|
||||
}
|
||||
.rdtPicker td.rdtDay:hover,
|
||||
.rdtPicker td.rdtHour:hover,
|
||||
.rdtPicker td.rdtMinute:hover,
|
||||
.rdtPicker td.rdtSecond:hover,
|
||||
.rdtPicker .rdtTimeToggle:hover {
|
||||
background: #eeeeee;
|
||||
cursor: pointer;
|
||||
}
|
||||
.rdtPicker td.rdtOld,
|
||||
.rdtPicker td.rdtNew {
|
||||
color: #999999;
|
||||
}
|
||||
.rdtPicker td.rdtToday {
|
||||
position: relative;
|
||||
}
|
||||
.rdtPicker td.rdtToday:before {
|
||||
content: '';
|
||||
display: inline-block;
|
||||
border-left: 7px solid transparent;
|
||||
border-bottom: 7px solid #428bca;
|
||||
border-top-color: rgba(0, 0, 0, 0.2);
|
||||
position: absolute;
|
||||
bottom: 4px;
|
||||
right: 4px;
|
||||
}
|
||||
.rdtPicker td.rdtActive,
|
||||
.rdtPicker td.rdtActive:hover {
|
||||
background-color: #428bca;
|
||||
color: #fff;
|
||||
text-shadow: 0 -1px 0 rgba(0, 0, 0, 0.25);
|
||||
}
|
||||
.rdtPicker td.rdtActive.rdtToday:before {
|
||||
border-bottom-color: #fff;
|
||||
}
|
||||
.rdtPicker td.rdtDisabled,
|
||||
.rdtPicker td.rdtDisabled:hover {
|
||||
background: none;
|
||||
color: #999999;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.rdtPicker td span.rdtOld {
|
||||
color: #999999;
|
||||
}
|
||||
.rdtPicker td span.rdtDisabled,
|
||||
.rdtPicker td span.rdtDisabled:hover {
|
||||
background: none;
|
||||
color: #999999;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.rdtPicker th {
|
||||
border-bottom: 1px solid #f9f9f9;
|
||||
}
|
||||
.rdtPicker .dow {
|
||||
width: 14.2857%;
|
||||
border-bottom: none;
|
||||
}
|
||||
.rdtPicker th.rdtSwitch {
|
||||
width: 100px;
|
||||
}
|
||||
.rdtPicker th.rdtNext,
|
||||
.rdtPicker th.rdtPrev {
|
||||
font-size: 21px;
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
.rdtPrev span,
|
||||
.rdtNext span {
|
||||
display: block;
|
||||
-webkit-touch-callout: none; /* iOS Safari */
|
||||
-webkit-user-select: none; /* Chrome/Safari/Opera */
|
||||
-khtml-user-select: none; /* Konqueror */
|
||||
-moz-user-select: none; /* Firefox */
|
||||
-ms-user-select: none; /* Internet Explorer/Edge */
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.rdtPicker th.rdtDisabled,
|
||||
.rdtPicker th.rdtDisabled:hover {
|
||||
background: none;
|
||||
color: #999999;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.rdtPicker thead tr:first-child th {
|
||||
cursor: pointer;
|
||||
}
|
||||
.rdtPicker thead tr:first-child th:hover {
|
||||
background: #eeeeee;
|
||||
}
|
||||
|
||||
.rdtPicker tfoot {
|
||||
border-top: 1px solid #f9f9f9;
|
||||
}
|
||||
|
||||
.rdtPicker button {
|
||||
border: none;
|
||||
background: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
.rdtPicker button:hover {
|
||||
background-color: #eee;
|
||||
}
|
||||
|
||||
.rdtPicker thead button {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
td.rdtMonth,
|
||||
td.rdtYear {
|
||||
height: 50px;
|
||||
width: 25%;
|
||||
cursor: pointer;
|
||||
}
|
||||
td.rdtMonth:hover,
|
||||
td.rdtYear:hover {
|
||||
background: #eee;
|
||||
}
|
||||
|
||||
.rdtCounters {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.rdtCounters > div {
|
||||
float: left;
|
||||
}
|
||||
|
||||
.rdtCounter {
|
||||
height: 100px;
|
||||
}
|
||||
|
||||
.rdtCounter {
|
||||
width: 40px;
|
||||
}
|
||||
|
||||
.rdtCounterSeparator {
|
||||
line-height: 100px;
|
||||
}
|
||||
|
||||
.rdtCounter .rdtBtn {
|
||||
height: 40%;
|
||||
line-height: 40px;
|
||||
cursor: pointer;
|
||||
display: block;
|
||||
|
||||
-webkit-touch-callout: none; /* iOS Safari */
|
||||
-webkit-user-select: none; /* Chrome/Safari/Opera */
|
||||
-khtml-user-select: none; /* Konqueror */
|
||||
-moz-user-select: none; /* Firefox */
|
||||
-ms-user-select: none; /* Internet Explorer/Edge */
|
||||
user-select: none;
|
||||
}
|
||||
.rdtCounter .rdtBtn:hover {
|
||||
background: #eee;
|
||||
}
|
||||
.rdtCounter .rdtCount {
|
||||
height: 20%;
|
||||
font-size: 1.2em;
|
||||
}
|
||||
|
||||
.rdtMilli {
|
||||
vertical-align: middle;
|
||||
padding-left: 8px;
|
||||
width: 48px;
|
||||
}
|
||||
|
||||
.rdtMilli input {
|
||||
width: 100%;
|
||||
font-size: 1.2em;
|
||||
margin-top: 37px;
|
||||
}
|
@ -1,17 +0,0 @@
|
||||
@import "./Object/Object.css";
|
||||
@import "./List/List.css";
|
||||
@import "./withMedia/withMedia.css";
|
||||
@import "./Image/Image.css";
|
||||
@import "./File/FileControl.css";
|
||||
@import "./Markdown/Markdown.css";
|
||||
@import "./Boolean/Boolean.css";
|
||||
@import "./Relation/Relation.css";
|
||||
@import "./DateTime/DateTime.css";
|
||||
|
||||
:root {
|
||||
--widgetNestDistance: 14px;
|
||||
}
|
||||
|
||||
.nc-widgetPreview {
|
||||
margin: 15px 2px;
|
||||
}
|
@ -1,7 +0,0 @@
|
||||
.nc-fileControl-input {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.nc-fileControl-imageUpload {
|
||||
cursor: pointer;
|
||||
}
|
@ -1,5 +0,0 @@
|
||||
import withMediaControl from 'EditorWidgets/withMedia/withMediaControl';
|
||||
|
||||
const FileControl = withMediaControl();
|
||||
|
||||
export default FileControl;
|
@ -1,15 +0,0 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
|
||||
export default function FilePreview({ value, getAsset }) {
|
||||
return (<div className="nc-widgetPreview">
|
||||
{ value ?
|
||||
<a href={getAsset(value)}>{ value }</a>
|
||||
: null}
|
||||
</div>);
|
||||
}
|
||||
|
||||
FilePreview.propTypes = {
|
||||
getAsset: PropTypes.func.isRequired,
|
||||
value: PropTypes.node,
|
||||
};
|
@ -1,4 +0,0 @@
|
||||
.nc-imagePreview-image {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
}
|
@ -1,5 +0,0 @@
|
||||
import withMediaControl from 'EditorWidgets/withMedia/withMediaControl';
|
||||
|
||||
const ImageControl = withMediaControl(true);
|
||||
|
||||
export default ImageControl;
|
@ -1,19 +0,0 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
|
||||
export default function ImagePreview({ value, getAsset }) {
|
||||
return (<div className='nc-widgetPreview'>
|
||||
{ value ?
|
||||
<img
|
||||
src={getAsset(value)}
|
||||
className='nc-imageWidget-image'
|
||||
role="presentation"
|
||||
/>
|
||||
: null}
|
||||
</div>);
|
||||
}
|
||||
|
||||
ImagePreview.propTypes = {
|
||||
getAsset: PropTypes.func.isRequired,
|
||||
value: PropTypes.node,
|
||||
};
|
@ -1,88 +0,0 @@
|
||||
.nc-listControl {
|
||||
padding: 0 14px 14px;
|
||||
|
||||
&.nc-listControl-collapsed {
|
||||
padding-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.list-item-dragging {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.nc-listControl-topBar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin: 0 -14px;
|
||||
background-color: var(--textFieldBorderColor);
|
||||
padding: 13px;
|
||||
}
|
||||
|
||||
.nc-listControl-addButton {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding: 2px 12px;
|
||||
font-size: 12px;
|
||||
font-weight: bold;
|
||||
border-radius: 3px;
|
||||
|
||||
& .nc-icon {
|
||||
padding-left: 6px;
|
||||
}
|
||||
}
|
||||
|
||||
.nc-listControl-listCollapseToggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.nc-listControl-listCollapseToggleButton{
|
||||
padding: 4px;
|
||||
background-color: transparent;
|
||||
color: inherit;
|
||||
|
||||
&:last-of-type {
|
||||
margin-right: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
.nc-listControl-item {
|
||||
margin-top: 18px;
|
||||
|
||||
&:first-of-type {
|
||||
margin-top: 26px;
|
||||
}
|
||||
}
|
||||
|
||||
.nc-listControl-itemTopBar {
|
||||
background-color: var(--textFieldBorderColor);
|
||||
}
|
||||
|
||||
.nc-listControl-objectLabel {
|
||||
display: none;
|
||||
border-top: 0;
|
||||
background-color: var(--textFieldBorderColor);
|
||||
padding: 13px;
|
||||
border-radius: 0 0 var(--borderRadius) var(--borderRadius);
|
||||
}
|
||||
|
||||
.nc-listControl-objectControl {
|
||||
padding: 6px 14px 14px;
|
||||
border-top: 0;
|
||||
border-top-left-radius: 0;
|
||||
border-top-right-radius: 0;
|
||||
}
|
||||
|
||||
.nc-listControl-collapsed {
|
||||
& .nc-listControl-objectLabel {
|
||||
display: block;
|
||||
}
|
||||
& .nc-listControl-objectControl {
|
||||
display: none;
|
||||
}
|
||||
}
|
@ -1,307 +0,0 @@
|
||||
import React, { Component } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
import { List, Map } from 'immutable';
|
||||
import { partial } from 'lodash';
|
||||
import c from 'classnames';
|
||||
import { SortableContainer, SortableElement, SortableHandle } from 'react-sortable-hoc';
|
||||
import { Icon, ListItemTopBar } from 'UI';
|
||||
import ObjectControl from 'EditorWidgets/Object/ObjectControl';
|
||||
|
||||
function ListItem(props) {
|
||||
return <div {...props} className={`list-item ${ props.className || '' }`}>{props.children}</div>;
|
||||
}
|
||||
ListItem.propTypes = {
|
||||
className: PropTypes.string,
|
||||
children: PropTypes.node,
|
||||
};
|
||||
ListItem.displayName = 'list-item';
|
||||
|
||||
function valueToString(value) {
|
||||
return value ? value.join(',').replace(/,([^\s]|$)/g, ', $1') : '';
|
||||
}
|
||||
|
||||
const SortableListItem = SortableElement(ListItem);
|
||||
|
||||
const TopBar = ({ allowAdd, onAdd, listLabel, onCollapseAllToggle, allItemsCollapsed, itemsCount }) => (
|
||||
<div className="nc-listControl-topBar">
|
||||
<div className="nc-listControl-listCollapseToggle">
|
||||
<button className="nc-listControl-listCollapseToggleButton" onClick={onCollapseAllToggle}>
|
||||
<Icon type="chevron" direction={allItemsCollapsed ? 'right' : 'down'} size="small" />
|
||||
</button>
|
||||
{itemsCount} {listLabel}
|
||||
</div>
|
||||
|
||||
{
|
||||
allowAdd ?
|
||||
<button className="nc-listControl-addButton" onClick={onAdd}>
|
||||
Add {listLabel} <Icon type="add" size="xsmall" />
|
||||
</button>
|
||||
:
|
||||
null
|
||||
}
|
||||
</div>
|
||||
);
|
||||
|
||||
const SortableList = SortableContainer(({ items, renderItem }) => {
|
||||
return <div>{items.map(renderItem)}</div>;
|
||||
});
|
||||
|
||||
const valueTypes = {
|
||||
SINGLE: 'SINGLE',
|
||||
MULTIPLE: 'MULTIPLE',
|
||||
};
|
||||
|
||||
export default class ListControl extends Component {
|
||||
static propTypes = {
|
||||
onChange: PropTypes.func.isRequired,
|
||||
onChangeObject: PropTypes.func.isRequired,
|
||||
value: ImmutablePropTypes.list,
|
||||
field: PropTypes.object,
|
||||
forID: PropTypes.string,
|
||||
mediaPaths: ImmutablePropTypes.map.isRequired,
|
||||
getAsset: PropTypes.func.isRequired,
|
||||
onOpenMediaLibrary: PropTypes.func.isRequired,
|
||||
onAddAsset: PropTypes.func.isRequired,
|
||||
onRemoveInsertedMedia: PropTypes.func.isRequired,
|
||||
classNameWrapper: PropTypes.string.isRequired,
|
||||
setActiveStyle: PropTypes.func.isRequired,
|
||||
setInactiveStyle: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
static defaultProps = {
|
||||
value: List(),
|
||||
};
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
const { field, value } = props;
|
||||
const allItemsCollapsed = field.get('collapsed', true);
|
||||
const itemsCollapsed = value && Array(value.size).fill(allItemsCollapsed);
|
||||
|
||||
this.state = {
|
||||
itemsCollapsed: List(itemsCollapsed),
|
||||
value: valueToString(value),
|
||||
};
|
||||
|
||||
this.valueType = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Always update so that each nested widget has the option to update. This is
|
||||
* required because ControlHOC provides a default `shouldComponentUpdate`
|
||||
* which only updates if the value changes, but every widget must be allowed
|
||||
* to override this.
|
||||
*/
|
||||
shouldComponentUpdate() {
|
||||
return true;
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
const { field } = this.props;
|
||||
|
||||
if (field.get('fields')) {
|
||||
this.valueType = valueTypes.MULTIPLE;
|
||||
} else if (field.get('field')) {
|
||||
this.valueType = valueTypes.SINGLE;
|
||||
}
|
||||
}
|
||||
|
||||
componentWillUpdate(nextProps) {
|
||||
if (this.props.field === nextProps.field) return;
|
||||
|
||||
if (nextProps.field.get('fields')) {
|
||||
this.valueType = valueTypes.MULTIPLE;
|
||||
} else if (nextProps.field.get('field')) {
|
||||
this.valueType = valueTypes.SINGLE;
|
||||
}
|
||||
}
|
||||
|
||||
handleChange = (e) => {
|
||||
const { onChange } = this.props;
|
||||
const oldValue = this.state.value;
|
||||
const newValue = e.target.value;
|
||||
const listValue = e.target.value.split(',');
|
||||
if (newValue.match(/,$/) && oldValue.match(/, $/)) {
|
||||
listValue.pop();
|
||||
}
|
||||
|
||||
const parsedValue = valueToString(listValue);
|
||||
this.setState({ value: parsedValue });
|
||||
onChange(listValue.map(val => val.trim()));
|
||||
};
|
||||
|
||||
handleFocus = () => {
|
||||
this.props.setActiveStyle();
|
||||
}
|
||||
|
||||
handleBlur = (e) => {
|
||||
const listValue = e.target.value.split(',').map(el => el.trim()).filter(el => el);
|
||||
this.setState({ value: valueToString(listValue) });
|
||||
this.props.setInactiveStyle();
|
||||
}
|
||||
|
||||
handleAdd = (e) => {
|
||||
e.preventDefault();
|
||||
const { value, onChange } = this.props;
|
||||
const parsedValue = (this.valueType === valueTypes.SINGLE) ? null : Map();
|
||||
this.setState({ itemsCollapsed: this.state.itemsCollapsed.push(false) });
|
||||
onChange((value || List()).push(parsedValue));
|
||||
}
|
||||
|
||||
/**
|
||||
* In case the `onChangeObject` function is frozen by a child widget implementation,
|
||||
* e.g. when debounced, always get the latest object value instead of using
|
||||
* `this.props.value` directly.
|
||||
*/
|
||||
getObjectValue = idx => this.props.value.get(idx) || Map();
|
||||
|
||||
handleChangeFor(index) {
|
||||
return (fieldName, newValue, newMetadata) => {
|
||||
const { value, metadata, onChange, field } = this.props;
|
||||
const collectionName = field.get('name');
|
||||
const newObjectValue = this.getObjectValue(index).set(fieldName, newValue);
|
||||
const parsedValue = (this.valueType === valueTypes.SINGLE) ? newObjectValue.first() : newObjectValue;
|
||||
const parsedMetadata = {
|
||||
[collectionName]: Object.assign(metadata ? metadata.toJS() : {}, newMetadata ? newMetadata[collectionName] : {}),
|
||||
};
|
||||
onChange(value.set(index, parsedValue), parsedMetadata);
|
||||
};
|
||||
}
|
||||
|
||||
handleRemove = (index, event) => {
|
||||
event.preventDefault();
|
||||
const { itemsCollapsed } = this.state;
|
||||
const { value, metadata, onChange, field } = this.props;
|
||||
const collectionName = field.get('name');
|
||||
const parsedMetadata = metadata && { [collectionName]: metadata.removeIn(value.get(index).valueSeq()) };
|
||||
|
||||
this.setState({ itemsCollapsed: itemsCollapsed.delete(index) });
|
||||
|
||||
onChange(value.remove(index), parsedMetadata);
|
||||
};
|
||||
|
||||
handleItemCollapseToggle = (index, event) => {
|
||||
event.preventDefault();
|
||||
const { itemsCollapsed } = this.state;
|
||||
const collapsed = itemsCollapsed.get(index);
|
||||
this.setState({ itemsCollapsed: itemsCollapsed.set(index, !collapsed) });
|
||||
};
|
||||
|
||||
handleCollapseAllToggle = (e) => {
|
||||
e.preventDefault();
|
||||
const { value } = this.props;
|
||||
const { itemsCollapsed } = this.state;
|
||||
const allItemsCollapsed = itemsCollapsed.every(val => val === true);
|
||||
this.setState({ itemsCollapsed: List(Array(value.size).fill(!allItemsCollapsed)) });
|
||||
};
|
||||
|
||||
objectLabel(item) {
|
||||
const { field } = this.props;
|
||||
const multiFields = field.get('fields');
|
||||
const singleField = field.get('field');
|
||||
const labelField = (multiFields && multiFields.first()) || singleField;
|
||||
const value = multiFields ? item.get(multiFields.first().get('name')) : singleField.get('label');
|
||||
return (value || `No ${ labelField.get('name') }`).toString();
|
||||
}
|
||||
|
||||
onSortEnd = ({ oldIndex, newIndex }) => {
|
||||
const { value, onChange } = this.props;
|
||||
const { itemsCollapsed } = this.state;
|
||||
|
||||
// Update value
|
||||
const item = value.get(oldIndex);
|
||||
const newValue = value.delete(oldIndex).insert(newIndex, item);
|
||||
this.props.onChange(newValue);
|
||||
|
||||
// Update collapsing
|
||||
const collapsed = itemsCollapsed.get(oldIndex);
|
||||
const updatedItemsCollapsed = itemsCollapsed.delete(oldIndex).insert(newIndex, collapsed);
|
||||
this.setState({ itemsCollapsed: updatedItemsCollapsed });
|
||||
};
|
||||
|
||||
renderItem = (item, index) => {
|
||||
const {
|
||||
field,
|
||||
getAsset,
|
||||
mediaPaths,
|
||||
onOpenMediaLibrary,
|
||||
onAddAsset,
|
||||
onRemoveInsertedMedia,
|
||||
classNameWrapper,
|
||||
} = this.props;
|
||||
const { itemsCollapsed } = this.state;
|
||||
const collapsed = itemsCollapsed.get(index);
|
||||
const classNames = ['nc-listControl-item', collapsed ? 'nc-listControl-collapsed' : ''];
|
||||
|
||||
return (<SortableListItem className={classNames.join(' ')} index={index} key={`item-${ index }`}>
|
||||
<ListItemTopBar
|
||||
className="nc-listControl-itemTopBar"
|
||||
collapsed={collapsed}
|
||||
onCollapseToggle={partial(this.handleItemCollapseToggle, index)}
|
||||
onRemove={partial(this.handleRemove, index)}
|
||||
dragHandleHOC={SortableHandle}
|
||||
/>
|
||||
<div className="nc-listControl-objectLabel">{this.objectLabel(item)}</div>
|
||||
<ObjectControl
|
||||
value={item}
|
||||
field={field}
|
||||
onChangeObject={this.handleChangeFor(index)}
|
||||
getAsset={getAsset}
|
||||
onOpenMediaLibrary={onOpenMediaLibrary}
|
||||
mediaPaths={mediaPaths}
|
||||
onAddAsset={onAddAsset}
|
||||
onRemoveInsertedMedia={onRemoveInsertedMedia}
|
||||
classNameWrapper={`${ classNameWrapper } nc-listControl-objectControl`}
|
||||
forList
|
||||
/>
|
||||
</SortableListItem>);
|
||||
};
|
||||
|
||||
renderListControl() {
|
||||
const { value, forID, field, classNameWrapper } = this.props;
|
||||
const { itemsCollapsed } = this.state;
|
||||
const items = value || List();
|
||||
const label = field.get('label');
|
||||
const labelSingular = field.get('label_singular') || field.get('label');
|
||||
|
||||
return (
|
||||
<div id={forID} className={c(classNameWrapper, 'nc-listControl')}>
|
||||
<TopBar
|
||||
allowAdd={field.get('allow_add', true)}
|
||||
onAdd={this.handleAdd}
|
||||
listLabel={items.size === 1 ? labelSingular.toLowerCase() : label.toLowerCase()}
|
||||
onCollapseAllToggle={this.handleCollapseAllToggle}
|
||||
allItemsCollapsed={itemsCollapsed.every(val => val === true)}
|
||||
itemsCount={items.size}
|
||||
/>
|
||||
<SortableList
|
||||
items={items}
|
||||
renderItem={this.renderItem}
|
||||
onSortEnd={this.onSortEnd}
|
||||
useDragHandle
|
||||
lockAxis="y"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
render() {
|
||||
const { field, forID, classNameWrapper } = this.props;
|
||||
const { value } = this.state;
|
||||
|
||||
if (field.get('field') || field.get('fields')) {
|
||||
return this.renderListControl();
|
||||
}
|
||||
|
||||
return (<input
|
||||
type="text"
|
||||
id={forID}
|
||||
value={value}
|
||||
onChange={this.handleChange}
|
||||
onFocus={this.handleFocus}
|
||||
onBlur={this.handleBlur}
|
||||
className={classNameWrapper}
|
||||
/>);
|
||||
}
|
||||
};
|
@ -1,11 +0,0 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import React, { Component } from 'react';
|
||||
import ObjectPreview from 'EditorWidgets/Object/ObjectPreview';
|
||||
|
||||
const ListPreview = ObjectPreview;
|
||||
|
||||
ListPreview.propTypes = {
|
||||
field: PropTypes.node,
|
||||
};
|
||||
|
||||
export default ListPreview;
|
@ -1,4 +0,0 @@
|
||||
@import "./MarkdownControl/RawEditor/index.css";
|
||||
@import "./MarkdownControl/Toolbar/Toolbar.css";
|
||||
@import "./MarkdownControl/Toolbar/ToolbarButton.css";
|
||||
@import "./MarkdownControl/VisualEditor/index.css";
|
@ -1,15 +0,0 @@
|
||||
.nc-rawEditor-rawWrapper {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.nc-rawEditor-rawEditor {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
overflow-x: auto;
|
||||
min-height: var(--richTextEditorMinHeight);
|
||||
font-family: var(--fontFamilyMono);
|
||||
border-top-left-radius: 0;
|
||||
border-top-right-radius: 0;
|
||||
border-top: 0;
|
||||
margin-top: calc(-1 * var(--stickyDistanceBottom));
|
||||
}
|
@ -1,83 +0,0 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
import React from 'react';
|
||||
import { Editor as Slate } from 'slate-react';
|
||||
import Plain from 'slate-plain-serializer';
|
||||
import { debounce } from 'lodash';
|
||||
import Toolbar from 'EditorWidgets/Markdown/MarkdownControl/Toolbar/Toolbar';
|
||||
|
||||
export default class RawEditor extends React.Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
value: Plain.deserialize(this.props.value || ''),
|
||||
};
|
||||
}
|
||||
|
||||
shouldComponentUpdate(nextProps, nextState) {
|
||||
return !this.state.value.equals(nextState.value);
|
||||
}
|
||||
|
||||
handleChange = change => {
|
||||
if (!this.state.value.document.equals(change.value.document)) {
|
||||
this.handleDocumentChange(change);
|
||||
}
|
||||
this.setState({ value: change.value });
|
||||
};
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
handleDocumentChange = debounce(change => {
|
||||
const value = Plain.serialize(change.value);
|
||||
this.props.onChange(value);
|
||||
}, 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 = () => {
|
||||
this.props.onMode('visual');
|
||||
};
|
||||
|
||||
render() {
|
||||
const { className, field } = this.props;
|
||||
return (
|
||||
<div className="nc-rawEditor-rawWrapper">
|
||||
<div className="nc-visualEditor-editorControlBar">
|
||||
<Toolbar
|
||||
onToggleMode={this.handleToggleMode}
|
||||
buttons={field.get('buttons')}
|
||||
className="nc-markdownWidget-toolbarRaw"
|
||||
disabled
|
||||
rawMode
|
||||
/>
|
||||
</div>
|
||||
<Slate
|
||||
className={`${className} nc-rawEditor-rawEditor`}
|
||||
value={this.state.value}
|
||||
onChange={this.handleChange}
|
||||
onPaste={this.handlePaste}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
RawEditor.propTypes = {
|
||||
onChange: PropTypes.func.isRequired,
|
||||
onMode: PropTypes.func.isRequired,
|
||||
className: PropTypes.string.isRequired,
|
||||
value: PropTypes.string,
|
||||
field: ImmutablePropTypes.map
|
||||
};
|
@ -1,48 +0,0 @@
|
||||
.nc-toolbar-Toolbar {
|
||||
background-color: var(--textFieldBorderColor);
|
||||
position: relative;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 11px 14px;
|
||||
min-height: 58px;
|
||||
transition: background-color var(--transition), color var(--transition);
|
||||
}
|
||||
|
||||
.nc-markdownWidget-toolbar-toggle {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
font-size: 14px;
|
||||
margin: 0 10px;
|
||||
}
|
||||
|
||||
.nc-markdownWidget-toolbar-toggle-label {
|
||||
display: inline-block;
|
||||
text-align: center;
|
||||
white-space: nowrap;
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
.nc-markdownWidget-toolbar-toggle-label-active {
|
||||
font-weight: 600;
|
||||
color: #3a69c7;
|
||||
}
|
||||
|
||||
.nc-toolbar-ToolbarActive {
|
||||
background-color: var(--colorActive);
|
||||
color: var(--colorTextLight);
|
||||
|
||||
& .nc-markdownWidget-toolbar-toggle-label {
|
||||
color: var(--colorTextLight);
|
||||
}
|
||||
|
||||
& .nc-markdownWidget-toolbar-toggle-background {
|
||||
background-color: var(--textFieldBorderColor);
|
||||
}
|
||||
}
|
||||
|
||||
.nc-toolbar-dropdown {
|
||||
display: inline-block;
|
||||
position: relative;
|
||||
}
|
@ -1,205 +0,0 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
import { List } from 'immutable';
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
import c from 'classnames';
|
||||
import { Dropdown, DropdownItem, Toggle, Icon } from 'UI';
|
||||
import ToolbarButton from './ToolbarButton';
|
||||
|
||||
export default class Toolbar extends React.Component {
|
||||
static propTypes = {
|
||||
buttons: PropTypes.object,
|
||||
onToggleMode: PropTypes.func.isRequired,
|
||||
rawMode: PropTypes.bool,
|
||||
plugins: ImmutablePropTypes.map,
|
||||
onSubmit: PropTypes.func,
|
||||
onAddAsset: PropTypes.func,
|
||||
getAsset: PropTypes.func,
|
||||
disabled: PropTypes.bool,
|
||||
className: PropTypes.string,
|
||||
buttons: ImmutablePropTypes.list
|
||||
};
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
activePlugin: null,
|
||||
};
|
||||
}
|
||||
|
||||
isHidden = button => {
|
||||
const { buttons } = this.props;
|
||||
return List.isList(buttons) ? !buttons.includes(button) : false;
|
||||
}
|
||||
|
||||
render() {
|
||||
const {
|
||||
onMarkClick,
|
||||
onBlockClick,
|
||||
onLinkClick,
|
||||
selectionHasMark,
|
||||
selectionHasBlock,
|
||||
selectionHasLink,
|
||||
onToggleMode,
|
||||
rawMode,
|
||||
plugins,
|
||||
onAddAsset,
|
||||
getAsset,
|
||||
disabled,
|
||||
onSubmit,
|
||||
className,
|
||||
} = this.props;
|
||||
|
||||
const { activePlugin } = this.state;
|
||||
|
||||
/**
|
||||
* Because the toggle labels change font weight for active/inactive state,
|
||||
* we need to set estimated widths for them to maintain position without
|
||||
* moving other inline items on font weight change.
|
||||
*/
|
||||
const toggleOffLabel = 'Rich text';
|
||||
const toggleOffLabelWidth = '62px';
|
||||
const toggleOnLabel = 'Markdown';
|
||||
const toggleOnLabelWidth = '70px';
|
||||
|
||||
return (
|
||||
<div className={c(className, 'nc-toolbar-Toolbar')}>
|
||||
<div>
|
||||
<ToolbarButton
|
||||
type="bold"
|
||||
label="Bold"
|
||||
icon="bold"
|
||||
onClick={onMarkClick}
|
||||
isActive={selectionHasMark}
|
||||
isHidden={this.isHidden('bold')}
|
||||
disabled={disabled}
|
||||
/>
|
||||
<ToolbarButton
|
||||
type="italic"
|
||||
label="Italic"
|
||||
icon="italic"
|
||||
onClick={onMarkClick}
|
||||
isActive={selectionHasMark}
|
||||
isHidden={this.isHidden('italic')}
|
||||
disabled={disabled}
|
||||
/>
|
||||
<ToolbarButton
|
||||
type="code"
|
||||
label="Code"
|
||||
icon="code"
|
||||
onClick={onMarkClick}
|
||||
isActive={selectionHasMark}
|
||||
isHidden={this.isHidden('code')}
|
||||
disabled={disabled}
|
||||
/>
|
||||
<ToolbarButton
|
||||
type="link"
|
||||
label="Link"
|
||||
icon="link"
|
||||
onClick={onLinkClick}
|
||||
isActive={selectionHasLink}
|
||||
isHidden={this.isHidden('link')}
|
||||
disabled={disabled}
|
||||
/>
|
||||
<ToolbarButton
|
||||
type="heading-one"
|
||||
label="Header 1"
|
||||
icon="h1"
|
||||
onClick={onBlockClick}
|
||||
isActive={selectionHasBlock}
|
||||
isHidden={this.isHidden('heading-one')}
|
||||
disabled={disabled}
|
||||
/>
|
||||
<ToolbarButton
|
||||
type="heading-two"
|
||||
label="Header 2"
|
||||
icon="h2"
|
||||
onClick={onBlockClick}
|
||||
isActive={selectionHasBlock}
|
||||
isHidden={this.isHidden('heading-two')}
|
||||
disabled={disabled}
|
||||
/>
|
||||
<ToolbarButton
|
||||
type="quote"
|
||||
label="Quote"
|
||||
icon="quote"
|
||||
onClick={onBlockClick}
|
||||
isActive={selectionHasBlock}
|
||||
isHidden={this.isHidden('quote')}
|
||||
disabled={disabled}
|
||||
/>
|
||||
<ToolbarButton
|
||||
type="code"
|
||||
label="Code Block"
|
||||
icon="code-block"
|
||||
onClick={onBlockClick}
|
||||
isActive={selectionHasBlock}
|
||||
isHidden={this.isHidden('code-block')}
|
||||
disabled={disabled}
|
||||
/>
|
||||
<ToolbarButton
|
||||
type="bulleted-list"
|
||||
label="Bulleted List"
|
||||
icon="list-bulleted"
|
||||
onClick={onBlockClick}
|
||||
isActive={selectionHasBlock}
|
||||
isHidden={this.isHidden('bulleted-list')}
|
||||
disabled={disabled}
|
||||
/>
|
||||
<ToolbarButton
|
||||
type="numbered-list"
|
||||
label="Numbered List"
|
||||
icon="list-numbered"
|
||||
onClick={onBlockClick}
|
||||
isActive={selectionHasBlock}
|
||||
isHidden={this.isHidden('numbered-list')}
|
||||
disabled={disabled}
|
||||
/>
|
||||
<div className="nc-toolbar-dropdown">
|
||||
<Dropdown
|
||||
dropdownTopOverlap="36px"
|
||||
button={
|
||||
<ToolbarButton
|
||||
label="Add Component"
|
||||
icon="add-with"
|
||||
onClick={this.handleComponentsMenuToggle}
|
||||
disabled={disabled}
|
||||
/>
|
||||
}
|
||||
>
|
||||
{plugins && plugins.toList().map((plugin, idx) => (
|
||||
<DropdownItem key={idx} label={plugin.get('label')} onClick={() => onSubmit(plugin.get('id'))} />
|
||||
))}
|
||||
</Dropdown>
|
||||
</div>
|
||||
</div>
|
||||
<div className="nc-markdownWidget-toolbar-toggle">
|
||||
<span
|
||||
style={{ width: toggleOffLabelWidth }}
|
||||
className={c(
|
||||
'nc-markdownWidget-toolbar-toggle-label',
|
||||
{ 'nc-markdownWidget-toolbar-toggle-label-active': !rawMode },
|
||||
)}
|
||||
>
|
||||
{toggleOffLabel}
|
||||
</span>
|
||||
<Toggle
|
||||
active={rawMode}
|
||||
onChange={onToggleMode}
|
||||
className="nc-markdownWidget-toolbar-toggle"
|
||||
classNameBackground="nc-markdownWidget-toolbar-toggle-background"
|
||||
/>
|
||||
<span
|
||||
style={{ width: toggleOnLabelWidth }}
|
||||
className={c(
|
||||
'nc-markdownWidget-toolbar-toggle-label',
|
||||
{ 'nc-markdownWidget-toolbar-toggle-label-active': rawMode },
|
||||
)}
|
||||
>
|
||||
{toggleOnLabel}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
@ -1,22 +0,0 @@
|
||||
.nc-toolbarButton-button {
|
||||
display: inline-block;
|
||||
padding: 6px;
|
||||
border: none;
|
||||
background-color: transparent;
|
||||
font-size: 16px;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
|
||||
&:disabled {
|
||||
cursor: auto;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
& .nc-icon {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
.nc-toolbarButton-active {
|
||||
color: #1e2532;
|
||||
}
|
@ -1,34 +0,0 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
import c from 'classnames';
|
||||
import { Icon } from 'UI';
|
||||
|
||||
const ToolbarButton = ({ type, label, icon, onClick, isActive, isHidden, disabled }) => {
|
||||
const active = isActive && type && isActive(type);
|
||||
|
||||
if (isHidden) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
className={c('nc-toolbarButton-button', { ['nc-toolbarButton-active']: active })}
|
||||
onClick={e => onClick && onClick(e, type)}
|
||||
title={label}
|
||||
disabled={disabled}
|
||||
>
|
||||
{ icon ? <Icon type={icon}/> : label }
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
ToolbarButton.propTypes = {
|
||||
type: PropTypes.string,
|
||||
label: PropTypes.string.isRequired,
|
||||
icon: PropTypes.string,
|
||||
onClick: PropTypes.func,
|
||||
isActive: PropTypes.func,
|
||||
disabled: PropTypes.bool,
|
||||
};
|
||||
|
||||
export default ToolbarButton;
|
@ -1,22 +0,0 @@
|
||||
.nc-visualEditor-shortcode {
|
||||
border-radius: var(--borderRadius);
|
||||
border: 2px solid var(--textFieldBorderColor);
|
||||
margin: 12px 0;
|
||||
padding: 14px;
|
||||
}
|
||||
|
||||
.nc-visualEditor-shortcode-topBar {
|
||||
background-color: var(--textFieldBorderColor);
|
||||
margin: calc(-1 * var(--widgetNestDistance)) calc(-1 * var(--widgetNestDistance)) 0;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.nc-visualEditor-shortcode-collapsed {
|
||||
background-color: var(--textFieldBorderColor);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.nc-visualEditor-shortcode-collapsedTitle {
|
||||
padding: 8px;
|
||||
color: var(--controlLabelColor);
|
||||
}
|
@ -1,137 +0,0 @@
|
||||
import React from 'react';
|
||||
import c from 'classnames';
|
||||
import { Map } from 'immutable';
|
||||
import { connect } from 'react-redux';
|
||||
import { partial, capitalize } from 'lodash';
|
||||
import { resolveWidget, getEditorComponents } from 'Lib/registry';
|
||||
import { openMediaLibrary, removeInsertedMedia } from 'Actions/mediaLibrary';
|
||||
import { addAsset } from 'Actions/media';
|
||||
import { getAsset } from 'Reducers';
|
||||
import { ListItemTopBar } from 'UI';
|
||||
import { getEditorControl } from '../index';
|
||||
|
||||
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, index) => {
|
||||
const {
|
||||
onAddAsset,
|
||||
boundGetAsset,
|
||||
mediaPaths,
|
||||
onOpenMediaLibrary,
|
||||
onRemoveInsertedMedia,
|
||||
} = this.props;
|
||||
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,
|
||||
onAddAsset,
|
||||
getAsset: boundGetAsset,
|
||||
onChange: this.handleChange,
|
||||
mediaPaths,
|
||||
onOpenMediaLibrary,
|
||||
onRemoveInsertedMedia,
|
||||
};
|
||||
|
||||
return (
|
||||
<div key={key}>
|
||||
<Control {...controlProps}/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
render() {
|
||||
const { attributes, node, editor } = 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);
|
||||
const className = c(
|
||||
'nc-objectControl-root',
|
||||
'nc-visualEditor-shortcode',
|
||||
{ 'nc-visualEditor-shortcode-collapsed': collapsed },
|
||||
);
|
||||
return (
|
||||
<div {...attributes} className={className} onClick={this.handleClick}>
|
||||
<ListItemTopBar
|
||||
className="nc-visualEditor-shortcode-topBar"
|
||||
collapsed={collapsed}
|
||||
onCollapseToggle={this.handleCollapseToggle}
|
||||
onRemove={this.handleRemove}
|
||||
/>
|
||||
{
|
||||
collapsed
|
||||
? <div className="nc-visualEditor-shortcode-collapsedTitle">{capitalize(pluginId)}</div>
|
||||
: plugin.get('fields').map(partial(this.renderControl, shortcodeData))
|
||||
}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const mapStateToProps = (state, ownProps) => {
|
||||
const { attributes, node, editor } = ownProps;
|
||||
return {
|
||||
mediaPaths: state.mediaLibrary.get('controlMedia'),
|
||||
boundGetAsset: getAsset.bind(null, state),
|
||||
attributes,
|
||||
node,
|
||||
editor,
|
||||
};
|
||||
};
|
||||
|
||||
const mapDispatchToProps = {
|
||||
onAddAsset: addAsset,
|
||||
onOpenMediaLibrary: openMediaLibrary,
|
||||
onRemoveInsertedMedia: removeInsertedMedia,
|
||||
};
|
||||
|
||||
export default connect(mapStateToProps, mapDispatchToProps)(Shortcode);
|
File diff suppressed because it is too large
Load Diff
@ -1,269 +0,0 @@
|
||||
import React from 'react';
|
||||
import { fromJS } from 'immutable';
|
||||
import { markdownToSlate } from 'EditorWidgets/Markdown/serializers';
|
||||
|
||||
const parser = markdownToSlate;
|
||||
|
||||
// Temporary plugins test
|
||||
const testPlugins = fromJS([
|
||||
{
|
||||
label: 'Image',
|
||||
id: 'image',
|
||||
fromBlock: match => match && {
|
||||
image: match[2],
|
||||
alt: match[1],
|
||||
},
|
||||
toBlock: data => ``,
|
||||
toPreview: data => <img src={data.image} alt={data.alt} />,
|
||||
pattern: /^!\[([^\]]+)]\(([^)]+)\)$/,
|
||||
fields: [{
|
||||
label: 'Image',
|
||||
name: 'image',
|
||||
widget: 'image',
|
||||
}, {
|
||||
label: 'Alt Text',
|
||||
name: 'alt',
|
||||
}],
|
||||
},
|
||||
{
|
||||
id: "youtube",
|
||||
label: "Youtube",
|
||||
fields: [{name: 'id', label: 'Youtube Video ID'}],
|
||||
pattern: /^{{<\s?youtube (\S+)\s?>}}/,
|
||||
fromBlock: function(match) {
|
||||
return {
|
||||
id: match[1]
|
||||
};
|
||||
},
|
||||
toBlock: function(obj) {
|
||||
return '{{< youtube ' + obj.id + ' >}}';
|
||||
},
|
||||
toPreview: function(obj) {
|
||||
return (
|
||||
'<img src="http://img.youtube.com/vi/' + obj.id + '/maxresdefault.jpg" alt="Youtube Video"/>'
|
||||
);
|
||||
}
|
||||
},
|
||||
]);
|
||||
|
||||
describe("Compile markdown to Slate Raw AST", () => {
|
||||
it("should compile simple markdown", () => {
|
||||
const value = `
|
||||
# H1
|
||||
|
||||
sweet body
|
||||
`;
|
||||
expect(parser(value)).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("should compile a markdown ordered list", () => {
|
||||
const value = `
|
||||
# H1
|
||||
|
||||
1. yo
|
||||
2. bro
|
||||
3. fro
|
||||
`;
|
||||
expect(parser(value)).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("should compile bulleted lists", () => {
|
||||
const value = `
|
||||
# H1
|
||||
|
||||
* yo
|
||||
* bro
|
||||
* fro
|
||||
`;
|
||||
expect(parser(value)).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("should compile multiple header levels", () => {
|
||||
const value = `
|
||||
# H1
|
||||
|
||||
## H2
|
||||
|
||||
### H3
|
||||
`;
|
||||
expect(parser(value)).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("should compile horizontal rules", () => {
|
||||
const value = `
|
||||
# H1
|
||||
|
||||
---
|
||||
|
||||
blue moon
|
||||
`;
|
||||
expect(parser(value)).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("should compile horizontal rules", () => {
|
||||
const value = `
|
||||
# H1
|
||||
|
||||
---
|
||||
|
||||
blue moon
|
||||
`;
|
||||
expect(parser(value)).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("should compile soft breaks (double space)", () => {
|
||||
const value = `
|
||||
blue moon
|
||||
footballs
|
||||
`;
|
||||
expect(parser(value)).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("should compile images", () => {
|
||||
const value = `
|
||||

|
||||
`;
|
||||
expect(parser(value)).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("should compile code blocks", () => {
|
||||
const value = `
|
||||
\`\`\`javascript
|
||||
var a = 1;
|
||||
\`\`\`
|
||||
`;
|
||||
expect(parser(value)).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("should compile nested inline markup", () => {
|
||||
const value = `
|
||||
# Word
|
||||
|
||||
This is **some *hot* content**
|
||||
|
||||
perhaps **scalding** even
|
||||
`;
|
||||
expect(parser(value)).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("should compile inline code", () => {
|
||||
const value = `
|
||||
# Word
|
||||
|
||||
This is some sweet \`inline code\` yo!
|
||||
`;
|
||||
expect(parser(value)).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("should compile links", () => {
|
||||
const value = `
|
||||
# Word
|
||||
|
||||
How far is it to [Google](https://google.com) land?
|
||||
`;
|
||||
expect(parser(value)).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("should compile plugins", () => {
|
||||
const value = `
|
||||

|
||||
|
||||
{{< test >}}
|
||||
`;
|
||||
expect(parser(value)).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("should compile kitchen sink example", () => {
|
||||
const value = `
|
||||
# An exhibit of Markdown
|
||||
|
||||
This note demonstrates some of what Markdown is capable of doing.
|
||||
|
||||
*Note: Feel free to play with this page. Unlike regular notes, this doesn't
|
||||
automatically save itself.*
|
||||
|
||||
## Basic formatting
|
||||
|
||||
Paragraphs can be written like so. A paragraph is the basic block of Markdown.
|
||||
A paragraph is what text will turn into when there is no reason it should
|
||||
become anything else.
|
||||
|
||||
Paragraphs must be separated by a blank line. Basic formatting of *italics* and
|
||||
**bold** is supported. This *can be **nested** like* so.
|
||||
|
||||
## Lists
|
||||
|
||||
### Ordered list
|
||||
|
||||
1. Item 1 2. A second item 3. Number 3 4. Ⅳ
|
||||
|
||||
*Note: the fourth item uses the Unicode character for Roman numeral four.*
|
||||
|
||||
### Unordered list
|
||||
|
||||
* An item Another item Yet another item And there's more...
|
||||
|
||||
## Paragraph modifiers
|
||||
|
||||
### Code block
|
||||
|
||||
Code blocks are very useful for developers and other people who look at
|
||||
code or other things that are written in plain text. As you can see, it
|
||||
uses a fixed-width font.
|
||||
|
||||
You can also make \`inline code\` to add code into other things.
|
||||
|
||||
### Quote
|
||||
|
||||
> Here is a quote. What this is should be self explanatory. Quotes are
|
||||
automatically indented when they are used.
|
||||
|
||||
## Headings
|
||||
|
||||
There are six levels of headings. They correspond with the six levels of HTML
|
||||
headings. You've probably noticed them already in the page. Each level down
|
||||
uses one more hash character.
|
||||
|
||||
### Headings *can* also contain **formatting**
|
||||
|
||||
### They can even contain \`inline code\`
|
||||
|
||||
Of course, demonstrating what headings look like messes up the structure of the
|
||||
page.
|
||||
|
||||
I don't recommend using more than three or four levels of headings here,
|
||||
because, when you're smallest heading isn't too small, and you're largest
|
||||
heading isn't too big, and you want each size up to look noticeably larger and
|
||||
more important, there there are only so many sizes that you can use.
|
||||
|
||||
## URLs
|
||||
|
||||
URLs can be made in a handful of ways:
|
||||
|
||||
* A named link to MarkItDown. The easiest way to do these is to select what you
|
||||
* want to make a link and hit \`Ctrl+L\`. Another named link to
|
||||
* [MarkItDown](http://www.markitdown.net/) Sometimes you just want a URL like
|
||||
* <http://www.markitdown.net/>.
|
||||
|
||||
## Horizontal rule
|
||||
|
||||
A horizontal rule is a line that goes across the middle of the page.
|
||||
|
||||
---
|
||||
|
||||
It's sometimes handy for breaking things up.
|
||||
|
||||
## Images
|
||||
|
||||
Markdown can also contain images. I'll need to add something here sometime.
|
||||
|
||||
## Finally
|
||||
|
||||
There's actually a lot more to Markdown than this. See the official
|
||||
introduction and syntax for more information. However, be aware that this is
|
||||
not using the official implementation, and this might work subtly differently
|
||||
in some of the little things.
|
||||
`;
|
||||
expect(parser(value)).toMatchSnapshot();
|
||||
});
|
||||
});
|
@ -1,130 +0,0 @@
|
||||
@import './Shortcode.css';
|
||||
|
||||
:root {
|
||||
--stickyDistanceBottom: 100px;
|
||||
}
|
||||
|
||||
.nc-visualEditor-editorControlBar {
|
||||
z-index: 1;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
margin-bottom: var(--stickyDistanceBottom);
|
||||
}
|
||||
|
||||
.nc-visualEditor-wrapper {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.nc-visualEditor-editor {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
overflow-x: auto;
|
||||
min-height: var(--richTextEditorMinHeight);
|
||||
font-family: var(--fontFamilyPrimary);
|
||||
border-top-left-radius: 0;
|
||||
border-top-right-radius: 0;
|
||||
border-top: 0;
|
||||
margin-top: calc(-1 * var(--stickyDistanceBottom));
|
||||
}
|
||||
|
||||
.nc-visualEditor-editor h1 {
|
||||
font-size: 32px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.nc-visualEditor-editor h2 {
|
||||
font-size: 24px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.nc-visualEditor-editor h3 {
|
||||
font-size: 20px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.nc-visualEditor-editor h4 {
|
||||
font-size: 18px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.nc-visualEditor-editor h5,
|
||||
.nc-visualEditor-editor h6 {
|
||||
font-size: 16px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.nc-visualEditor-editor h1,
|
||||
.nc-visualEditor-editor h2,
|
||||
.nc-visualEditor-editor h3,
|
||||
.nc-visualEditor-editor h4,
|
||||
.nc-visualEditor-editor h5,
|
||||
.nc-visualEditor-editor h6 {
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.nc-visualEditor-editor p,
|
||||
.nc-visualEditor-editor pre,
|
||||
.nc-visualEditor-editor blockquote,
|
||||
.nc-visualEditor-editor ul,
|
||||
.nc-visualEditor-editor ol {
|
||||
margin-top: 16px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.nc-visualEditor-editor a {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.nc-visualEditor-editor hr {
|
||||
border: 1px solid;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.nc-visualEditor-editor li > p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.nc-visualEditor-editor ul,
|
||||
.nc-visualEditor-editor ol {
|
||||
padding-left: 30px;
|
||||
}
|
||||
|
||||
.nc-visualEditor-editor pre {
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.nc-visualEditor-editor pre > code {
|
||||
display: block;
|
||||
width: 100%;
|
||||
overflow-y: auto;
|
||||
background-color: #000;
|
||||
color: #ccc;
|
||||
border-radius: var(--borderRadius);
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.nc-visualEditor-editor code {
|
||||
background-color: var(--colorBackground);
|
||||
border-radius: var(--borderRadius);
|
||||
padding: 0 2px;
|
||||
font-size: 85%;
|
||||
}
|
||||
|
||||
.nc-visualEditor-editor blockquote {
|
||||
padding-left: 16px;
|
||||
border-left: 3px solid var(--colorBackground);
|
||||
margin-left: 0;
|
||||
margin-right: 0;
|
||||
}
|
||||
|
||||
.nc-visualEditor-editor table {
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.nc-visualEditor-editor td,
|
||||
.nc-visualEditor-editor th {
|
||||
border: 2px solid black;
|
||||
padding: 8px;
|
||||
text-align: left;
|
||||
}
|
@ -1,230 +0,0 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
import React, { Component } from 'react';
|
||||
import { get, isEmpty, debounce } from 'lodash';
|
||||
import { Map } from 'immutable';
|
||||
import { Value, Document, Block, Text } from 'slate';
|
||||
import { Editor as Slate } from 'slate-react';
|
||||
import { slateToMarkdown, markdownToSlate, htmlToSlate } from 'EditorWidgets/Markdown/serializers';
|
||||
import { getEditorComponents } from 'Lib/registry';
|
||||
import Toolbar from 'EditorWidgets/Markdown/MarkdownControl/Toolbar/Toolbar';
|
||||
import { renderNode, renderMark } from './renderers';
|
||||
import { validateNode } from './validators';
|
||||
import plugins, { EditListConfigured } from './plugins';
|
||||
import onKeyDown from './keys';
|
||||
|
||||
const createEmptyRawDoc = () => {
|
||||
const emptyText = Text.create('');
|
||||
const emptyBlock = Block.create({ kind: 'block', type: 'paragraph', nodes: [ emptyText ] });
|
||||
return { nodes: [emptyBlock] };
|
||||
};
|
||||
|
||||
const createSlateValue = (rawValue) => {
|
||||
const rawDoc = rawValue && markdownToSlate(rawValue);
|
||||
const rawDocHasNodes = !isEmpty(get(rawDoc, 'nodes'))
|
||||
const document = Document.fromJSON(rawDocHasNodes ? rawDoc : createEmptyRawDoc());
|
||||
return Value.create({ document });
|
||||
}
|
||||
|
||||
export default class Editor extends Component {
|
||||
static propTypes = {
|
||||
onAddAsset: PropTypes.func.isRequired,
|
||||
getAsset: PropTypes.func.isRequired,
|
||||
onChange: PropTypes.func.isRequired,
|
||||
onMode: PropTypes.func.isRequired,
|
||||
className: PropTypes.string.isRequired,
|
||||
value: PropTypes.string,
|
||||
field: ImmutablePropTypes.map
|
||||
};
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
value: createSlateValue(props.value),
|
||||
shortcodePlugins: getEditorComponents(),
|
||||
};
|
||||
}
|
||||
|
||||
shouldComponentUpdate(nextProps, nextState) {
|
||||
return !this.state.value.equals(nextState.value);
|
||||
}
|
||||
|
||||
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);
|
||||
selectionHasBlock = type => this.state.value.blocks.some(node => node.type === type);
|
||||
|
||||
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) => {
|
||||
event.preventDefault();
|
||||
let { value } = this.state;
|
||||
const { document: doc, selection } = 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.setBlock(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 = () => {
|
||||
return this.state.value.inlines.some(inline => inline.type === 'link');
|
||||
};
|
||||
|
||||
handleLink = () => {
|
||||
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 { value } = this.state;
|
||||
const nodes = [Text.create('')];
|
||||
const block = {
|
||||
kind: 'block',
|
||||
type: 'shortcode',
|
||||
data: {
|
||||
shortcode: pluginId,
|
||||
shortcodeNew: true,
|
||||
shortcodeData: Map(),
|
||||
},
|
||||
isVoid: true,
|
||||
nodes
|
||||
};
|
||||
let change = value.change();
|
||||
const { focusBlock } = change.value;
|
||||
|
||||
if (focusBlock.text === '') {
|
||||
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');
|
||||
};
|
||||
|
||||
|
||||
handleDocumentChange = debounce(change => {
|
||||
const raw = change.value.document.toJSON();
|
||||
const plugins = this.state.shortcodePlugins;
|
||||
const markdown = slateToMarkdown(raw, plugins);
|
||||
this.props.onChange(markdown);
|
||||
}, 150);
|
||||
|
||||
handleChange = change => {
|
||||
if (!this.state.value.document.equals(change.value.document)) {
|
||||
this.handleDocumentChange(change);
|
||||
}
|
||||
this.setState({ value: change.value });
|
||||
};
|
||||
|
||||
processRef = ref => {
|
||||
this.ref = ref;
|
||||
}
|
||||
|
||||
render() {
|
||||
const { onAddAsset, getAsset, className, field } = this.props;
|
||||
|
||||
return (
|
||||
<div className="nc-visualEditor-wrapper">
|
||||
<div className="nc-visualEditor-editorControlBar">
|
||||
<Toolbar
|
||||
onMarkClick={this.handleMarkClick}
|
||||
onBlockClick={this.handleBlockClick}
|
||||
onLinkClick={this.handleLink}
|
||||
selectionHasMark={this.selectionHasMark}
|
||||
selectionHasBlock={this.selectionHasBlock}
|
||||
selectionHasLink={this.hasLinks}
|
||||
onToggleMode={this.handleToggle}
|
||||
plugins={this.state.shortcodePlugins}
|
||||
onSubmit={this.handlePluginAdd}
|
||||
onAddAsset={onAddAsset}
|
||||
getAsset={getAsset}
|
||||
buttons={field.get('buttons')}
|
||||
/>
|
||||
</div>
|
||||
<Slate
|
||||
className={`${className} nc-visualEditor-editor`}
|
||||
value={this.state.value}
|
||||
renderNode={renderNode}
|
||||
renderMark={renderMark}
|
||||
validateNode={validateNode}
|
||||
plugins={plugins}
|
||||
onChange={this.handleChange}
|
||||
onKeyDown={onKeyDown}
|
||||
onPaste={this.handlePaste}
|
||||
ref={this.processRef}
|
||||
spellCheck
|
||||
/>
|
||||
</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, range, 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 [ markKey, markName ] = marks.find(([ key ]) => isHotkey(`mod+${key}`, event)) || [];
|
||||
|
||||
if (markName) {
|
||||
event.preventDefault();
|
||||
return change.toggleMark(markName);
|
||||
}
|
||||
};
|
@ -1,111 +0,0 @@
|
||||
import { Text, Inline } from 'slate';
|
||||
import isHotkey from 'is-hotkey';
|
||||
import SlateSoftBreak from 'slate-soft-break';
|
||||
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.setBlock(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;
|
@ -1,96 +0,0 @@
|
||||
import React from 'react';
|
||||
import { List } from 'immutable';
|
||||
import cn from 'classnames';
|
||||
import Shortcode from './Shortcode';
|
||||
|
||||
/**
|
||||
* Slate uses React components to render each type of node that it receives.
|
||||
* This is the closest thing Slate has to a schema definition. The types are set
|
||||
* by us when we manually deserialize from Remark's MDAST to Slate's AST.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Mark Components
|
||||
*/
|
||||
const Bold = props => <strong>{props.children}</strong>;
|
||||
const Italic = props => <em>{props.children}</em>;
|
||||
const Strikethrough = props => <s>{props.children}</s>;
|
||||
const Code = props => <code>{props.children}</code>;
|
||||
|
||||
/**
|
||||
* Node Components
|
||||
*/
|
||||
const Paragraph = props => <p {...props.attributes}>{props.children}</p>;
|
||||
const ListItem = props => <li {...props.attributes}>{props.children}</li>;
|
||||
const Quote = props => <blockquote {...props.attributes}>{props.children}</blockquote>;
|
||||
const CodeBlock = props => <pre><code {...props.attributes}>{props.children}</code></pre>;
|
||||
const HeadingOne = props => <h1 {...props.attributes}>{props.children}</h1>;
|
||||
const HeadingTwo = props => <h2 {...props.attributes}>{props.children}</h2>;
|
||||
const HeadingThree = props => <h3 {...props.attributes}>{props.children}</h3>;
|
||||
const HeadingFour = props => <h4 {...props.attributes}>{props.children}</h4>;
|
||||
const HeadingFive = props => <h5 {...props.attributes}>{props.children}</h5>;
|
||||
const HeadingSix = props => <h6 {...props.attributes}>{props.children}</h6>;
|
||||
const Table = props => <table><tbody {...props.attributes}>{props.children}</tbody></table>;
|
||||
const TableRow = props => <tr {...props.attributes}>{props.children}</tr>;
|
||||
const TableCell = props => <td {...props.attributes}>{props.children}</td>;
|
||||
const ThematicBreak = props => <hr {...props.attributes}/>;
|
||||
const BulletedList = props => <ul {...props.attributes}>{props.children}</ul>;
|
||||
const NumberedList = props => (
|
||||
<ol {...props.attributes} start={props.node.data.get('start') || 1}>{props.children}</ol>
|
||||
);
|
||||
const Link = props => {
|
||||
const data = props.node.get('data');
|
||||
const marks = data.get('marks');
|
||||
const url = data.get('url');
|
||||
const title = data.get('title');
|
||||
const link = <a href={url} title={title} {...props.attributes}>{props.children}</a>;
|
||||
const result = !marks ? link : marks.reduce((acc, mark) => {
|
||||
return renderMark({ mark, children: acc });
|
||||
}, link);
|
||||
return result;
|
||||
};
|
||||
const Image = props => {
|
||||
const data = props.node.get('data');
|
||||
const marks = data.get('marks');
|
||||
const url = data.get('url');
|
||||
const title = data.get('title');
|
||||
const alt = data.get('alt');
|
||||
const image = <img src={url} title={title} alt={alt} {...props.attributes}/>;
|
||||
const result = !marks ? image : marks.reduce((acc, mark) => {
|
||||
return renderMark({ mark, children: acc });
|
||||
}, image);
|
||||
return result;
|
||||
};
|
||||
|
||||
export const renderMark = props => {
|
||||
switch (props.mark.type) {
|
||||
case 'bold': return <Bold {...props}/>;
|
||||
case 'italic': return <Italic {...props}/>;
|
||||
case 'strikethrough': return <Strikethrough {...props}/>;
|
||||
case 'code': return <Code {...props}/>;
|
||||
}
|
||||
};
|
||||
|
||||
export const renderNode = props => {
|
||||
switch (props.node.type) {
|
||||
case 'paragraph': return <Paragraph {...props}/>;
|
||||
case 'list-item': return <ListItem {...props}/>;
|
||||
case 'quote': return <Quote {...props}/>;
|
||||
case 'code': return <CodeBlock {...props}/>;
|
||||
case 'heading-one': return <HeadingOne {...props}/>;
|
||||
case 'heading-two': return <HeadingTwo {...props}/>;
|
||||
case 'heading-three': return <HeadingThree {...props}/>;
|
||||
case 'heading-four': return <HeadingFour {...props}/>;
|
||||
case 'heading-five': return <HeadingFive {...props}/>;
|
||||
case 'heading-six': return <HeadingSix {...props}/>;
|
||||
case 'table': return <Table {...props}/>;
|
||||
case 'table-row': return <TableRow {...props}/>;
|
||||
case 'table-cell': return <TableCell {...props}/>;
|
||||
case 'thematic-break': return <ThematicBreak {...props}/>;
|
||||
case 'bulleted-list': return <BulletedList {...props}/>;
|
||||
case 'numbered-list': return <NumberedList {...props}/>;
|
||||
case 'link': return <Link {...props}/>;
|
||||
case 'image': return <Image {...props}/>;
|
||||
case 'shortcode': return <Shortcode {...props}/>;
|
||||
}
|
||||
};
|
@ -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.kind === '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,80 +0,0 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
import c from 'classnames';
|
||||
import { markdownToRemark, remarkToMarkdown } from 'EditorWidgets/Markdown/serializers'
|
||||
import RawEditor from './RawEditor';
|
||||
import VisualEditor from './VisualEditor';
|
||||
|
||||
const MODE_STORAGE_KEY = 'cms.md-mode';
|
||||
|
||||
let editorControl;
|
||||
|
||||
export const getEditorControl = () => editorControl;
|
||||
|
||||
export default class MarkdownControl extends React.Component {
|
||||
static propTypes = {
|
||||
onChange: PropTypes.func.isRequired,
|
||||
onAddAsset: PropTypes.func.isRequired,
|
||||
getAsset: PropTypes.func.isRequired,
|
||||
classNameWrapper: PropTypes.string.isRequired,
|
||||
editorControl: PropTypes.func.isRequired,
|
||||
value: PropTypes.string,
|
||||
};
|
||||
|
||||
static defaultProps = {
|
||||
value: '',
|
||||
};
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
editorControl = props.editorControl;
|
||||
this.state = { mode: localStorage.getItem(MODE_STORAGE_KEY) || 'visual' };
|
||||
}
|
||||
|
||||
handleMode = (mode) => {
|
||||
this.setState({ mode });
|
||||
localStorage.setItem(MODE_STORAGE_KEY, mode);
|
||||
};
|
||||
|
||||
processRef = ref => this.ref = ref;
|
||||
|
||||
render() {
|
||||
const {
|
||||
onChange,
|
||||
onAddAsset,
|
||||
getAsset,
|
||||
value,
|
||||
classNameWrapper,
|
||||
field
|
||||
} = this.props;
|
||||
|
||||
const { mode } = this.state;
|
||||
const visualEditor = (
|
||||
<div className="cms-editor-visual" ref={this.processRef}>
|
||||
<VisualEditor
|
||||
onChange={onChange}
|
||||
onAddAsset={onAddAsset}
|
||||
onMode={this.handleMode}
|
||||
getAsset={getAsset}
|
||||
className={classNameWrapper}
|
||||
value={value}
|
||||
field={field}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
const rawEditor = (
|
||||
<div className="cms-editor-raw" ref={this.processRef}>
|
||||
<RawEditor
|
||||
onChange={onChange}
|
||||
onAddAsset={onAddAsset}
|
||||
onMode={this.handleMode}
|
||||
getAsset={getAsset}
|
||||
className={classNameWrapper}
|
||||
value={value}
|
||||
field={field}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
return mode === 'visual' ? visualEditor : rawEditor;
|
||||
}
|
||||
}
|
@ -1,53 +0,0 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import { Component, Children } from 'react';
|
||||
import { List, Record, fromJS } from 'immutable';
|
||||
import { isFunction } from 'lodash';
|
||||
|
||||
const plugins = { editor: List() };
|
||||
|
||||
const catchesNothing = /.^/;
|
||||
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'; },
|
||||
});
|
||||
|
||||
|
||||
class Plugin extends Component { // eslint-disable-line
|
||||
static propTypes = {
|
||||
children: PropTypes.element.isRequired,
|
||||
};
|
||||
|
||||
static childContextTypes = {
|
||||
plugins: PropTypes.object,
|
||||
};
|
||||
|
||||
getChildContext() {
|
||||
return { plugins };
|
||||
}
|
||||
|
||||
render() {
|
||||
return Children.only(this.props.children);
|
||||
}
|
||||
}
|
||||
|
||||
export function newEditorPlugin(config) {
|
||||
const configObj = new EditorComponent({
|
||||
id: config.id || config.label.replace(/[^A-Z0-9]+/ig, '_'),
|
||||
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),
|
||||
});
|
||||
|
||||
|
||||
return configObj;
|
||||
}
|
@ -1,78 +0,0 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`Markdown Preview renderer HTML rendering should render HTML 1`] = `"<div class=\\"nc-widgetPreview\\"><p>Paragraph with <em>inline</em> element</p></div>"`;
|
||||
|
||||
exports[`Markdown Preview renderer Markdown rendering Code should render code 1`] = `"<div class=\\"nc-widgetPreview\\"><p>Use the <code>printf()</code> function.</p></div>"`;
|
||||
|
||||
exports[`Markdown Preview renderer Markdown rendering Code should render code 2 1`] = `"<div class=\\"nc-widgetPreview\\"><p><code>There is a literal backtick (\`) here.</code></p></div>"`;
|
||||
|
||||
exports[`Markdown Preview renderer Markdown rendering General should render markdown 1`] = `
|
||||
"<div class=\\"nc-widgetPreview\\"><h1>H1</h1>
|
||||
<p>Text with <strong>bold</strong> & <em>em</em> elements</p>
|
||||
<h2>H2</h2>
|
||||
<ul>
|
||||
<li>ul item 1</li>
|
||||
<li>ul item 2</li>
|
||||
</ul>
|
||||
<h3>H3</h3>
|
||||
<ol>
|
||||
<li>ol item 1</li>
|
||||
<li>ol item 2</li>
|
||||
<li>ol item 3</li>
|
||||
</ol>
|
||||
<h4>H4</h4>
|
||||
<p><a href=\\"http://google.com\\">link title</a></p>
|
||||
<h5>H5</h5>
|
||||
<p></p>
|
||||
<h6>H6</h6></div>"
|
||||
`;
|
||||
|
||||
exports[`Markdown Preview renderer Markdown rendering HTML should render HTML as is when using Markdown 1`] = `
|
||||
"<div class=\\"nc-widgetPreview\\"><h1>Title</h1>
|
||||
<form action=\\"test\\">
|
||||
<label for=\\"input\\">
|
||||
<input type=\\"checkbox\\" checked=\\"checked\\" id=\\"input\\"/> My label
|
||||
</label>
|
||||
<dl class=\\"test-class another-class\\" style=\\"width: 100%\\">
|
||||
<dt data-attr=\\"test\\">Test HTML content</dt>
|
||||
<dt>Testing HTML in Markdown</dt>
|
||||
</dl>
|
||||
</form>
|
||||
<h1 style=\\"display: block; border: 10px solid #f00; width: 100%\\">Test</h1></div>"
|
||||
`;
|
||||
|
||||
exports[`Markdown Preview renderer Markdown rendering Headings should render Heading 1 1`] = `"<div class=\\"nc-widgetPreview\\"><h1>Title</h1></div>"`;
|
||||
|
||||
exports[`Markdown Preview renderer Markdown rendering Headings should render Heading 2 1`] = `"<div class=\\"nc-widgetPreview\\"><h2>Title</h2></div>"`;
|
||||
|
||||
exports[`Markdown Preview renderer Markdown rendering Headings should render Heading 3 1`] = `"<div class=\\"nc-widgetPreview\\"><h3>Title</h3></div>"`;
|
||||
|
||||
exports[`Markdown Preview renderer Markdown rendering Headings should render Heading 4 1`] = `"<div class=\\"nc-widgetPreview\\"><h4>Title</h4></div>"`;
|
||||
|
||||
exports[`Markdown Preview renderer Markdown rendering Headings should render Heading 5 1`] = `"<div class=\\"nc-widgetPreview\\"><h5>Title</h5></div>"`;
|
||||
|
||||
exports[`Markdown Preview renderer Markdown rendering Headings should render Heading 6 1`] = `"<div class=\\"nc-widgetPreview\\"><h6>Title</h6></div>"`;
|
||||
|
||||
exports[`Markdown Preview renderer Markdown rendering Links should render links 1`] = `"<div class=\\"nc-widgetPreview\\"><p>I get 10 times more traffic from <a href=\\"http://google.com/\\" title=\\"Google\\">Google</a> than from <a href=\\"http://search.yahoo.com/\\" title=\\"Yahoo Search\\">Yahoo</a> or <a href=\\"http://search.msn.com/\\" title=\\"MSN Search\\">MSN</a>.</p></div>"`;
|
||||
|
||||
exports[`Markdown Preview renderer Markdown rendering Lists should render lists 1`] = `
|
||||
"<div class=\\"nc-widgetPreview\\"><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></div>"
|
||||
`;
|
@ -1,132 +0,0 @@
|
||||
/* eslint max-len:0 */
|
||||
|
||||
import React from 'react';
|
||||
import { shallow } from 'enzyme';
|
||||
import { padStart } from 'lodash';
|
||||
import MarkdownPreview from '../index';
|
||||
import { markdownToHtml } from 'EditorWidgets/Markdown/serializers';
|
||||
|
||||
const parser = markdownToHtml;
|
||||
|
||||
describe('Markdown Preview renderer', () => {
|
||||
describe('Markdown rendering', () => {
|
||||
describe('General', () => {
|
||||
it('should render markdown', () => {
|
||||
const value = `
|
||||
# H1
|
||||
|
||||
Text with **bold** & _em_ elements
|
||||
|
||||
## H2
|
||||
|
||||
* ul item 1
|
||||
* ul item 2
|
||||
|
||||
### H3
|
||||
|
||||
1. ol item 1
|
||||
1. ol item 2
|
||||
1. ol item 3
|
||||
|
||||
#### H4
|
||||
|
||||
[link title](http://google.com)
|
||||
|
||||
##### H5
|
||||
|
||||

|
||||
|
||||
###### H6
|
||||
`;
|
||||
const component = shallow(<MarkdownPreview value={markdownToHtml(value)} />);
|
||||
expect(component.html()).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Headings', () => {
|
||||
for (const heading of [...Array(6).keys()]) {
|
||||
it(`should render Heading ${ heading + 1 }`, () => {
|
||||
const value = padStart(' Title', heading + 7, '#');
|
||||
const component = shallow(<MarkdownPreview value={markdownToHtml(value)} />);
|
||||
expect(component.html()).toMatchSnapshot();
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
describe('Lists', () => {
|
||||
it('should render lists', () => {
|
||||
const value = `
|
||||
1. ol item 1
|
||||
1. ol item 2
|
||||
* Sublist 1
|
||||
* Sublist 2
|
||||
* Sublist 3
|
||||
1. Sub-Sublist 1
|
||||
1. Sub-Sublist 2
|
||||
1. Sub-Sublist 3
|
||||
1. ol item 3
|
||||
`;
|
||||
const component = shallow(<MarkdownPreview value={markdownToHtml(value)} />);
|
||||
expect(component.html()).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Links', () => {
|
||||
it('should render links', () => {
|
||||
const value = `
|
||||
I get 10 times more traffic from [Google] [1] than from [Yahoo] [2] or [MSN] [3].
|
||||
|
||||
[1]: http://google.com/ "Google"
|
||||
[2]: http://search.yahoo.com/ "Yahoo Search"
|
||||
[3]: http://search.msn.com/ "MSN Search"
|
||||
`;
|
||||
const component = shallow(<MarkdownPreview value={markdownToHtml(value)} />);
|
||||
expect(component.html()).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Code', () => {
|
||||
it('should render code', () => {
|
||||
const value = 'Use the `printf()` function.';
|
||||
const component = shallow(<MarkdownPreview value={markdownToHtml(value)} />);
|
||||
expect(component.html()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('should render code 2', () => {
|
||||
const value = '``There is a literal backtick (`) here.``';
|
||||
const component = shallow(<MarkdownPreview value={markdownToHtml(value)} />);
|
||||
expect(component.html()).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
|
||||
describe('HTML', () => {
|
||||
it('should render HTML as is when using Markdown', () => {
|
||||
const value = `
|
||||
# Title
|
||||
|
||||
<form action="test">
|
||||
<label for="input">
|
||||
<input type="checkbox" checked="checked" id="input"/> My label
|
||||
</label>
|
||||
<dl class="test-class another-class" style="width: 100%">
|
||||
<dt data-attr="test">Test HTML content</dt>
|
||||
<dt>Testing HTML in Markdown</dt>
|
||||
</dl>
|
||||
</form>
|
||||
|
||||
<h1 style="display: block; border: 10px solid #f00; width: 100%">Test</h1>
|
||||
`;
|
||||
const component = shallow(<MarkdownPreview value={markdownToHtml(value)} />);
|
||||
expect(component.html()).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('HTML rendering', () => {
|
||||
it('should render HTML', () => {
|
||||
const value = '<p>Paragraph with <em>inline</em> element</p>';
|
||||
const component = shallow(<MarkdownPreview value={markdownToHtml(value)} />);
|
||||
expect(component.html()).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
});
|
@ -1,18 +0,0 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
import { markdownToHtml } from 'EditorWidgets/Markdown/serializers';
|
||||
|
||||
const MarkdownPreview = ({ value, getAsset }) => {
|
||||
if (value === null) {
|
||||
return null;
|
||||
}
|
||||
const html = markdownToHtml(value, getAsset);
|
||||
return <div className="nc-widgetPreview" dangerouslySetInnerHTML={{__html: html}}></div>;
|
||||
};
|
||||
|
||||
MarkdownPreview.propTypes = {
|
||||
getAsset: PropTypes.func.isRequired,
|
||||
value: PropTypes.string,
|
||||
};
|
||||
|
||||
export default MarkdownPreview;
|
@ -1,24 +0,0 @@
|
||||
import unified from 'unified';
|
||||
import markdownToRemark from 'remark-parse';
|
||||
import remarkAllowHtmlEntities from '../remarkAllowHtmlEntities';
|
||||
|
||||
const process = markdown => {
|
||||
const mdast = unified().use(markdownToRemark).use(remarkAllowHtmlEntities).parse(markdown);
|
||||
|
||||
/**
|
||||
* The MDAST will look like:
|
||||
*
|
||||
* { type: 'root', children: [
|
||||
* { type: 'paragraph', children: [
|
||||
* // results here
|
||||
* ]}
|
||||
* ]}
|
||||
*/
|
||||
return mdast.children[0].children[0].value;
|
||||
};
|
||||
|
||||
describe('remarkAllowHtmlEntities', () => {
|
||||
it('should not decode HTML entities', () => {
|
||||
expect(process('<div>')).toEqual('<div>');
|
||||
});
|
||||
});
|
@ -1,204 +0,0 @@
|
||||
import u from 'unist-builder';
|
||||
import remarkAssertParents from '../remarkAssertParents';
|
||||
|
||||
const transform = remarkAssertParents();
|
||||
|
||||
describe('remarkAssertParents', () => {
|
||||
it('should unnest invalidly nested blocks', () => {
|
||||
const input = u('root', [
|
||||
u('paragraph', [
|
||||
u('paragraph', [ u('text', 'Paragraph text.') ]),
|
||||
u('heading', { depth: 1 }, [ u('text', 'Heading text.') ]),
|
||||
u('code', 'someCode()'),
|
||||
u('blockquote', [ u('text', 'Quote text.') ]),
|
||||
u('list', [ u('listItem', [ u('text', 'A list item.') ]) ]),
|
||||
u('table', [ u('tableRow', [ u('tableCell', [ u('text', 'Text in a table cell.') ]) ]) ]),
|
||||
u('thematicBreak'),
|
||||
]),
|
||||
]);
|
||||
|
||||
const output = u('root', [
|
||||
u('paragraph', [ u('text', 'Paragraph text.') ]),
|
||||
u('heading', { depth: 1 }, [ u('text', 'Heading text.') ]),
|
||||
u('code', 'someCode()'),
|
||||
u('blockquote', [ u('text', 'Quote text.') ]),
|
||||
u('list', [ u('listItem', [ u('text', 'A list item.') ]) ]),
|
||||
u('table', [ u('tableRow', [ u('tableCell', [ u('text', 'Text in a table cell.') ]) ]) ]),
|
||||
u('thematicBreak'),
|
||||
]);
|
||||
|
||||
expect(transform(input)).toEqual(output);
|
||||
});
|
||||
|
||||
it('should unnest deeply nested blocks', () => {
|
||||
const input = u('root', [
|
||||
u('paragraph', [
|
||||
u('paragraph', [
|
||||
u('paragraph', [
|
||||
u('paragraph', [ u('text', 'Paragraph text.') ]),
|
||||
u('heading', { depth: 1 }, [ u('text', 'Heading text.') ]),
|
||||
u('code', 'someCode()'),
|
||||
u('blockquote', [
|
||||
u('paragraph', [
|
||||
u('strong', [
|
||||
u('heading', [
|
||||
u('text', 'Quote text.'),
|
||||
]),
|
||||
]),
|
||||
]),
|
||||
]),
|
||||
u('list', [ u('listItem', [ u('text', 'A list item.') ]) ]),
|
||||
u('table', [ u('tableRow', [ u('tableCell', [ u('text', 'Text in a table cell.') ]) ]) ]),
|
||||
u('thematicBreak'),
|
||||
]),
|
||||
]),
|
||||
]),
|
||||
]);
|
||||
|
||||
const output = u('root', [
|
||||
u('paragraph', [ u('text', 'Paragraph text.') ]),
|
||||
u('heading', { depth: 1 }, [ u('text', 'Heading text.') ]),
|
||||
u('code', 'someCode()'),
|
||||
u('blockquote', [
|
||||
u('heading', [
|
||||
u('text', 'Quote text.'),
|
||||
]),
|
||||
]),
|
||||
u('list', [ u('listItem', [ u('text', 'A list item.') ]) ]),
|
||||
u('table', [ u('tableRow', [ u('tableCell', [ u('text', 'Text in a table cell.') ]) ]) ]),
|
||||
u('thematicBreak'),
|
||||
]);
|
||||
|
||||
expect(transform(input)).toEqual(output);
|
||||
});
|
||||
|
||||
it('should remove blocks that are emptied as a result of denesting', () => {
|
||||
const input = u('root', [
|
||||
u('paragraph', [
|
||||
u('heading', { depth: 1 }, [ u('text', 'Heading text.') ]),
|
||||
]),
|
||||
]);
|
||||
|
||||
const output = u('root', [
|
||||
u('heading', { depth: 1 }, [ u('text', 'Heading text.') ]),
|
||||
]);
|
||||
|
||||
expect(transform(input)).toEqual(output);
|
||||
});
|
||||
|
||||
it('should remove blocks that are emptied as a result of denesting', () => {
|
||||
const input = u('root', [
|
||||
u('paragraph', [
|
||||
u('heading', { depth: 1 }, [ u('text', 'Heading text.') ]),
|
||||
]),
|
||||
]);
|
||||
|
||||
const output = u('root', [
|
||||
u('heading', { depth: 1 }, [ u('text', 'Heading text.') ]),
|
||||
]);
|
||||
|
||||
expect(transform(input)).toEqual(output);
|
||||
});
|
||||
|
||||
it('should handle assymetrical splits', () => {
|
||||
const input = u('root', [
|
||||
u('paragraph', [
|
||||
u('heading', { depth: 1 }, [ u('text', 'Heading text.') ]),
|
||||
]),
|
||||
]);
|
||||
|
||||
const output = u('root', [
|
||||
u('heading', { depth: 1 }, [ u('text', 'Heading text.') ]),
|
||||
]);
|
||||
|
||||
expect(transform(input)).toEqual(output);
|
||||
});
|
||||
|
||||
it('should nest invalidly nested blocks in the nearest valid ancestor', () => {
|
||||
const input = u('root', [
|
||||
u('paragraph', [
|
||||
u('blockquote', [
|
||||
u('strong', [
|
||||
u('heading', { depth: 1 }, [ u('text', 'Heading text.') ]),
|
||||
]),
|
||||
]),
|
||||
]),
|
||||
]);
|
||||
|
||||
const output = u('root', [
|
||||
u('blockquote', [
|
||||
u('heading', { depth: 1 }, [ u('text', 'Heading text.') ]),
|
||||
]),
|
||||
]);
|
||||
|
||||
expect(transform(input)).toEqual(output);
|
||||
});
|
||||
|
||||
it('should preserve validly nested siblings of invalidly nested blocks', () => {
|
||||
const input = u('root', [
|
||||
u('paragraph', [
|
||||
u('blockquote', [
|
||||
u('strong', [
|
||||
u('text', 'Deep validly nested text a.'),
|
||||
u('heading', { depth: 1 }, [ u('text', 'Heading text.') ]),
|
||||
u('text', 'Deep validly nested text b.'),
|
||||
]),
|
||||
]),
|
||||
u('text', 'Validly nested text.'),
|
||||
]),
|
||||
]);
|
||||
|
||||
const output = u('root', [
|
||||
u('blockquote', [
|
||||
u('strong', [
|
||||
u('text', 'Deep validly nested text a.'),
|
||||
]),
|
||||
u('heading', { depth: 1 }, [ u('text', 'Heading text.') ]),
|
||||
u('strong', [
|
||||
u('text', 'Deep validly nested text b.'),
|
||||
]),
|
||||
]),
|
||||
u('paragraph', [
|
||||
u('text', 'Validly nested text.'),
|
||||
]),
|
||||
]);
|
||||
|
||||
expect(transform(input)).toEqual(output);
|
||||
});
|
||||
|
||||
it('should allow intermediate parents like list and table to contain required block children', () => {
|
||||
const input = u('root', [
|
||||
u('blockquote', [
|
||||
u('list', [
|
||||
u('listItem', [
|
||||
u('table', [
|
||||
u('tableRow', [
|
||||
u('tableCell', [
|
||||
u('heading', { depth: 1 }, [ u('text', 'Validly nested heading text.') ]),
|
||||
]),
|
||||
]),
|
||||
]),
|
||||
]),
|
||||
]),
|
||||
]),
|
||||
]);
|
||||
|
||||
const output = u('root', [
|
||||
u('blockquote', [
|
||||
u('list', [
|
||||
u('listItem', [
|
||||
u('table', [
|
||||
u('tableRow', [
|
||||
u('tableCell', [
|
||||
u('heading', { depth: 1 }, [ u('text', 'Validly nested heading text.') ]),
|
||||
]),
|
||||
]),
|
||||
]),
|
||||
]),
|
||||
]),
|
||||
]),
|
||||
]);
|
||||
|
||||
expect(transform(input)).toEqual(output);
|
||||
});
|
||||
});
|
@ -1,78 +0,0 @@
|
||||
import unified from 'unified';
|
||||
import u from 'unist-builder';
|
||||
import remarkEscapeMarkdownEntities from '../remarkEscapeMarkdownEntities';
|
||||
|
||||
const process = text => {
|
||||
const tree = u('root', [ u('text', text) ]);
|
||||
const escapedMdast = unified()
|
||||
.use(remarkEscapeMarkdownEntities)
|
||||
.runSync(tree);
|
||||
|
||||
return escapedMdast.children[0].value;
|
||||
};
|
||||
|
||||
describe('remarkEscapeMarkdownEntities', () => {
|
||||
it('should escape common markdown entities', () => {
|
||||
expect(process('*a*')).toEqual('\\*a\\*');
|
||||
expect(process('**a**')).toEqual('\\*\\*a\\*\\*');
|
||||
expect(process('***a***')).toEqual('\\*\\*\\*a\\*\\*\\*');
|
||||
expect(process('_a_')).toEqual('\\_a\\_');
|
||||
expect(process('__a__')).toEqual('\\_\\_a\\_\\_');
|
||||
expect(process('~~a~~')).toEqual('\\~\\~a\\~\\~');
|
||||
expect(process('[]')).toEqual('\\[]');
|
||||
expect(process('[]()')).toEqual('\\[]()');
|
||||
expect(process('[a](b)')).toEqual('\\[a](b)');
|
||||
expect(process('[Test sentence.](https://www.example.com)'))
|
||||
.toEqual('\\[Test sentence.](https://www.example.com)');
|
||||
expect(process('')).toEqual('!\\[a](b)');
|
||||
});
|
||||
|
||||
it('should not escape inactive, single markdown entities', () => {
|
||||
expect(process('a*b')).toEqual('a*b');
|
||||
expect(process('_')).toEqual('_');
|
||||
expect(process('~')).toEqual('~');
|
||||
expect(process('[')).toEqual('[');
|
||||
});
|
||||
|
||||
it('should escape leading markdown entities', () => {
|
||||
expect(process('#')).toEqual('\\#');
|
||||
expect(process('-')).toEqual('\\-');
|
||||
expect(process('*')).toEqual('\\*');
|
||||
expect(process('>')).toEqual('\\>');
|
||||
expect(process('=')).toEqual('\\=');
|
||||
expect(process('|')).toEqual('\\|');
|
||||
expect(process('```')).toEqual('\\`\\``');
|
||||
expect(process(' ')).toEqual('\\ ');
|
||||
});
|
||||
|
||||
it('should escape leading markdown entities preceded by whitespace', () => {
|
||||
expect(process('\n #')).toEqual('\\#');
|
||||
expect(process(' \n-')).toEqual('\\-');
|
||||
});
|
||||
|
||||
it('should not escape leading markdown entities preceded by non-whitespace characters', () => {
|
||||
expect(process('a# # b #')).toEqual('a# # b #');
|
||||
expect(process('a- - b -')).toEqual('a- - b -');
|
||||
});
|
||||
|
||||
it('should not escape html tags', () => {
|
||||
expect(process('<a attr="**a**">')).toEqual('<a attr="**a**">');
|
||||
expect(process('a b <c attr="**d**"> e')).toEqual('a b <c attr="**d**"> e');
|
||||
});
|
||||
|
||||
it('should escape the contents of html blocks', () => {
|
||||
expect(process('<div>*a*</div>')).toEqual('<div>\\*a\\*</div>');
|
||||
});
|
||||
|
||||
it('should not escape the contents of preformatted html blocks', () => {
|
||||
expect(process('<pre>*a*</pre>')).toEqual('<pre>*a*</pre>');
|
||||
expect(process('<script>*a*</script>')).toEqual('<script>*a*</script>');
|
||||
expect(process('<style>*a*</style>')).toEqual('<style>*a*</style>');
|
||||
expect(process('<pre>\n*a*\n</pre>')).toEqual('<pre>\n*a*\n</pre>');
|
||||
expect(process('a b <pre>*c*</pre> d e')).toEqual('a b <pre>*c*</pre> d e');
|
||||
});
|
||||
|
||||
it('should not parse footnotes', () => {
|
||||
expect(process('[^a]')).toEqual('\\[^a]');
|
||||
});
|
||||
});
|
@ -1,45 +0,0 @@
|
||||
import unified from 'unified';
|
||||
import markdownToRemark from 'remark-parse';
|
||||
import remarkToMarkdown from 'remark-stringify';
|
||||
import remarkPaddedLinks from '../remarkPaddedLinks';
|
||||
|
||||
const input = markdown =>
|
||||
unified()
|
||||
.use(markdownToRemark)
|
||||
.use(remarkPaddedLinks)
|
||||
.use(remarkToMarkdown)
|
||||
.processSync(markdown)
|
||||
.contents;
|
||||
|
||||
const output = markdown =>
|
||||
unified()
|
||||
.use(markdownToRemark)
|
||||
.use(remarkToMarkdown)
|
||||
.processSync(markdown)
|
||||
.contents;
|
||||
|
||||
describe('remarkPaddedLinks', () => {
|
||||
it('should move leading and trailing spaces outside of a link', () => {
|
||||
expect(input('[ a ](b)')).toEqual(output(' [a](b) '));
|
||||
});
|
||||
|
||||
it('should convert multiple leading or trailing spaces to a single space', () => {
|
||||
expect(input('[ a ](b)')).toEqual(output(' [a](b) '));
|
||||
});
|
||||
|
||||
it('should work with only a leading space or only a trailing space', () => {
|
||||
expect(input('[ a](b)[c ](d)')).toEqual(output(' [a](b)[c](d) '));
|
||||
});
|
||||
|
||||
it('should work for nested links', () => {
|
||||
expect(input('* # a[ b ](c)d')).toEqual(output('* # a [b](c) d'));
|
||||
});
|
||||
|
||||
it('should work for parents with multiple links that are not siblings', () => {
|
||||
expect(input('# a[ b ](c)d **[ e ](f)**')).toEqual(output('# a [b](c) d ** [e](f) **'));
|
||||
});
|
||||
|
||||
it('should work for links with arbitrarily nested children', () => {
|
||||
expect(input('[ a __*b*__ _c_ ](d)')).toEqual(output(' [a __*b*__ _c_](d) '));
|
||||
});
|
||||
});
|
@ -1,24 +0,0 @@
|
||||
import unified from 'unified';
|
||||
import u from 'unist-builder';
|
||||
import remarkStripTrailingBreaks from '../remarkStripTrailingBreaks';
|
||||
|
||||
const process = children => {
|
||||
const tree = u('root', children);
|
||||
const strippedMdast = unified()
|
||||
.use(remarkStripTrailingBreaks)
|
||||
.runSync(tree);
|
||||
|
||||
return strippedMdast.children;
|
||||
};
|
||||
|
||||
describe('remarkStripTrailingBreaks', () => {
|
||||
it('should remove trailing breaks at the end of a block', () => {
|
||||
expect(process([u('break')])).toEqual([]);
|
||||
expect(process([u('break'), u('text', '\n \n')])).toEqual([u('text', '\n \n')]);
|
||||
expect(process([u('text', 'a'), u('break')])).toEqual([u('text', 'a')]);
|
||||
});
|
||||
|
||||
it('should not remove trailing breaks that are not at the end of a block', () => {
|
||||
expect(process([u('break'), u('text', 'a')])).toEqual([u('break'), u('text', 'a')]);
|
||||
});
|
||||
});
|
@ -1,36 +0,0 @@
|
||||
import { flow } from 'lodash';
|
||||
import { markdownToSlate, slateToMarkdown } from '../index';
|
||||
|
||||
const process = flow([markdownToSlate, slateToMarkdown]);
|
||||
|
||||
describe('slate', () => {
|
||||
it('should not decode encoded html entities in inline code', () => {
|
||||
expect(process('<code><div></code>')).toEqual('<code><div></code>');
|
||||
});
|
||||
|
||||
it('should parse non-text children of mark nodes', () => {
|
||||
expect(process('**a[b](c)d**')).toEqual('**a[b](c)d**');
|
||||
expect(process('**[a](b)**')).toEqual('**[a](b)**');
|
||||
expect(process('****')).toEqual('****');
|
||||
expect(process('_`a`_')).toEqual('_`a`_');
|
||||
expect(process('_`a`b_')).toEqual('_`a`b_');
|
||||
});
|
||||
|
||||
it('should condense adjacent, identically styled text and inline nodes', () => {
|
||||
expect(process('**a ~~b~~~~c~~**')).toEqual('**a ~~bc~~**');
|
||||
expect(process('**a ~~b~~~~[c](d)~~**')).toEqual('**a ~~b[c](d)~~**');
|
||||
});
|
||||
|
||||
it('should handle nested markdown entities', () => {
|
||||
expect(process('**a**b**c**')).toEqual('**a**b**c**');
|
||||
expect(process('**a _b_ c**')).toEqual('**a _b_ c**');
|
||||
});
|
||||
|
||||
it('should parse inline images as images', () => {
|
||||
expect(process('a ')).toEqual('a ');
|
||||
});
|
||||
|
||||
it('should not escape markdown entities in html', () => {
|
||||
expect(process('<span>*</span>')).toEqual('<span>*</span>');
|
||||
});
|
||||
});
|
@ -1,224 +0,0 @@
|
||||
import { get, isEmpty, reduce, pull, trimEnd } from 'lodash';
|
||||
import unified from 'unified';
|
||||
import u from 'unist-builder';
|
||||
import markdownToRemarkPlugin from 'remark-parse';
|
||||
import remarkToMarkdownPlugin from 'remark-stringify';
|
||||
import remarkToRehype from 'remark-rehype';
|
||||
import rehypeToHtml from 'rehype-stringify';
|
||||
import htmlToRehype from 'rehype-parse';
|
||||
import rehypeToRemark from 'rehype-remark';
|
||||
import { getEditorComponents } from 'Lib/registry';
|
||||
import remarkToRehypeShortcodes from './remarkRehypeShortcodes';
|
||||
import rehypePaperEmoji from './rehypePaperEmoji';
|
||||
import remarkAssertParents from './remarkAssertParents';
|
||||
import remarkPaddedLinks from './remarkPaddedLinks';
|
||||
import remarkWrapHtml from './remarkWrapHtml';
|
||||
import remarkToSlate from './remarkSlate';
|
||||
import remarkSquashReferences from './remarkSquashReferences';
|
||||
import remarkImagesToText from './remarkImagesToText';
|
||||
import remarkShortcodes from './remarkShortcodes';
|
||||
import remarkEscapeMarkdownEntities from './remarkEscapeMarkdownEntities';
|
||||
import remarkStripTrailingBreaks from './remarkStripTrailingBreaks';
|
||||
import remarkAllowHtmlEntities from './remarkAllowHtmlEntities';
|
||||
import slateToRemark from './slateRemark';
|
||||
|
||||
/**
|
||||
* This module contains all serializers for the Markdown widget.
|
||||
*
|
||||
* The value of a Markdown widget is transformed to various formats during
|
||||
* editing, and these formats are referenced throughout serializer source
|
||||
* documentation. Below is brief glossary of the formats used.
|
||||
*
|
||||
* - Markdown {string}
|
||||
* The stringified Markdown value. The value of the field is persisted
|
||||
* (stored) in this format, and the stringified value is also used when the
|
||||
* editor is in "raw" Markdown mode.
|
||||
*
|
||||
* - MDAST {object}
|
||||
* Also loosely referred to as "Remark". MDAST stands for MarkDown AST
|
||||
* (Abstract Syntax Tree), and is an object representation of a Markdown
|
||||
* document. Underneath, it's a Unist tree with a Markdown-specific schema.
|
||||
* MDAST syntax is a part of the Unified ecosystem, and powers the Remark
|
||||
* processor, so Remark plugins may be used.
|
||||
*
|
||||
* - HAST {object}
|
||||
* Also loosely referred to as "Rehype". HAST, similar to MDAST, is an object
|
||||
* representation of an HTML document. The field value takes this format
|
||||
* temporarily before the document is stringified to HTML.
|
||||
*
|
||||
* - HTML {string}
|
||||
* The field value is stringifed to HTML for preview purposes - the HTML value
|
||||
* is never parsed, it is output only.
|
||||
*
|
||||
* - Slate Raw AST {object}
|
||||
* Slate's Raw AST is a very simple and unopinionated object representation of
|
||||
* a document in a Slate editor. We define our own Markdown-specific schema
|
||||
* for serialization to/from Slate's Raw AST and MDAST.
|
||||
*/
|
||||
|
||||
|
||||
/**
|
||||
* Deserialize a Markdown string to an MDAST.
|
||||
*/
|
||||
export const markdownToRemark = markdown => {
|
||||
/**
|
||||
* Parse the Markdown string input to an MDAST.
|
||||
*/
|
||||
const parsed = unified()
|
||||
.use(markdownToRemarkPlugin, { fences: true, commonmark: true })
|
||||
.use(markdownToRemarkRemoveTokenizers, { inlineTokenizers: ['url'] })
|
||||
.use(remarkAllowHtmlEntities)
|
||||
.parse(markdown);
|
||||
|
||||
/**
|
||||
* Further transform the MDAST with plugins.
|
||||
*/
|
||||
const result = unified()
|
||||
.use(remarkSquashReferences)
|
||||
.use(remarkImagesToText)
|
||||
.use(remarkShortcodes, { plugins: getEditorComponents() })
|
||||
.runSync(parsed);
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
|
||||
/**
|
||||
* Remove named tokenizers from the parser, effectively deactivating them.
|
||||
*/
|
||||
function markdownToRemarkRemoveTokenizers({ inlineTokenizers }) {
|
||||
inlineTokenizers && inlineTokenizers.forEach(tokenizer => {
|
||||
delete this.Parser.prototype.inlineTokenizers[tokenizer];
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Serialize an MDAST to a Markdown string.
|
||||
*/
|
||||
export const remarkToMarkdown = obj => {
|
||||
/**
|
||||
* Rewrite the remark-stringify text visitor to simply return the text value,
|
||||
* without encoding or escaping any characters. This means we're completely
|
||||
* trusting the markdown that we receive.
|
||||
*/
|
||||
function remarkAllowAllText() {
|
||||
const Compiler = this.Compiler;
|
||||
const visitors = Compiler.prototype.visitors;
|
||||
visitors.text = node => node.value;
|
||||
};
|
||||
|
||||
/**
|
||||
* Provide an empty MDAST if no value is provided.
|
||||
*/
|
||||
const mdast = obj || u('root', [u('paragraph', [u('text', '')])]);
|
||||
|
||||
const remarkToMarkdownPluginOpts = {
|
||||
commonmark: true,
|
||||
fences: true,
|
||||
listItemIndent: '1',
|
||||
|
||||
/**
|
||||
* Settings to emulate the defaults from the Prosemirror editor, not
|
||||
* necessarily optimal. Should eventually be configurable.
|
||||
*/
|
||||
bullet: '*',
|
||||
strong: '*',
|
||||
rule: '-',
|
||||
};
|
||||
|
||||
/**
|
||||
* Transform the MDAST with plugins.
|
||||
*/
|
||||
const processedMdast = unified()
|
||||
.use(remarkEscapeMarkdownEntities)
|
||||
.use(remarkStripTrailingBreaks)
|
||||
.runSync(mdast);
|
||||
|
||||
const markdown = unified()
|
||||
.use(remarkToMarkdownPlugin, remarkToMarkdownPluginOpts)
|
||||
.use(remarkAllowAllText)
|
||||
.stringify(processedMdast);
|
||||
|
||||
/**
|
||||
* Return markdown with trailing whitespace removed.
|
||||
*/
|
||||
return trimEnd(markdown);
|
||||
};
|
||||
|
||||
|
||||
/**
|
||||
* Convert Markdown to HTML.
|
||||
*/
|
||||
export const markdownToHtml = (markdown, getAsset) => {
|
||||
const mdast = markdownToRemark(markdown);
|
||||
|
||||
const hast = unified()
|
||||
.use(remarkToRehypeShortcodes, { plugins: getEditorComponents(), getAsset })
|
||||
.use(remarkToRehype, { allowDangerousHTML: true })
|
||||
.runSync(mdast);
|
||||
|
||||
const html = unified()
|
||||
.use(rehypeToHtml, { allowDangerousHTML: true, allowDangerousCharacters: true })
|
||||
.stringify(hast);
|
||||
|
||||
return html;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Deserialize an HTML string to Slate's Raw AST. Currently used for HTML
|
||||
* pastes.
|
||||
*/
|
||||
export const htmlToSlate = html => {
|
||||
const hast = unified()
|
||||
.use(htmlToRehype, { fragment: true })
|
||||
.parse(html);
|
||||
|
||||
const mdast = unified()
|
||||
.use(rehypePaperEmoji)
|
||||
.use(rehypeToRemark, { minify: false })
|
||||
.runSync(hast);
|
||||
|
||||
const slateRaw = unified()
|
||||
.use(remarkAssertParents)
|
||||
.use(remarkPaddedLinks)
|
||||
.use(remarkImagesToText)
|
||||
.use(remarkShortcodes, { plugins: getEditorComponents() })
|
||||
.use(remarkWrapHtml)
|
||||
.use(remarkToSlate)
|
||||
.runSync(mdast);
|
||||
|
||||
return slateRaw;
|
||||
};
|
||||
|
||||
|
||||
/**
|
||||
* Convert Markdown to Slate's Raw AST.
|
||||
*/
|
||||
export const markdownToSlate = markdown => {
|
||||
const mdast = markdownToRemark(markdown);
|
||||
|
||||
const slateRaw = unified()
|
||||
.use(remarkWrapHtml)
|
||||
.use(remarkToSlate)
|
||||
.runSync(mdast);
|
||||
|
||||
return slateRaw;
|
||||
};
|
||||
|
||||
|
||||
/**
|
||||
* Convert a Slate Raw AST to Markdown.
|
||||
*
|
||||
* Requires shortcode plugins to parse shortcode nodes back to text.
|
||||
*
|
||||
* Note that Unified is not utilized for the conversion from Slate's Raw AST to
|
||||
* MDAST. The conversion is manual because Unified can only operate on Unist
|
||||
* trees.
|
||||
*/
|
||||
export const slateToMarkdown = raw => {
|
||||
const mdast = slateToRemark(raw, { shortcodePlugins: getEditorComponents() });
|
||||
const markdown = remarkToMarkdown(mdast);
|
||||
return markdown;
|
||||
};
|
@ -1,15 +0,0 @@
|
||||
/**
|
||||
* Dropbox Paper outputs emoji characters as images, and stores the actual
|
||||
* emoji character in a `data-emoji-ch` attribute on the image. This plugin
|
||||
* replaces the images with the emoji characters.
|
||||
*/
|
||||
export default function rehypePaperEmoji() {
|
||||
const transform = node => {
|
||||
if (node.tagName === 'img' && node.properties.dataEmojiCh) {
|
||||
return { type: 'text', value: node.properties.dataEmojiCh };
|
||||
}
|
||||
node.children = node.children ? node.children.map(transform) : node.children;
|
||||
return node;
|
||||
};
|
||||
return transform;
|
||||
}
|
@ -1,59 +0,0 @@
|
||||
export default function remarkAllowHtmlEntities() {
|
||||
this.Parser.prototype.inlineTokenizers.text = text;
|
||||
|
||||
/**
|
||||
* This is a port of the `remark-parse` text tokenizer, adapted to exclude
|
||||
* HTML entity decoding.
|
||||
*/
|
||||
function text(eat, value, silent) {
|
||||
var self = this;
|
||||
var methods;
|
||||
var tokenizers;
|
||||
var index;
|
||||
var length;
|
||||
var subvalue;
|
||||
var position;
|
||||
var tokenizer;
|
||||
var name;
|
||||
var min;
|
||||
var now;
|
||||
|
||||
/* istanbul ignore if - never used (yet) */
|
||||
if (silent) {
|
||||
return true;
|
||||
}
|
||||
|
||||
methods = self.inlineMethods;
|
||||
length = methods.length;
|
||||
tokenizers = self.inlineTokenizers;
|
||||
index = -1;
|
||||
min = value.length;
|
||||
|
||||
while (++index < length) {
|
||||
name = methods[index];
|
||||
|
||||
if (name === 'text' || !tokenizers[name]) {
|
||||
continue;
|
||||
}
|
||||
|
||||
tokenizer = tokenizers[name].locator;
|
||||
|
||||
if (!tokenizer) {
|
||||
eat.file.fail('Missing locator: `' + name + '`');
|
||||
}
|
||||
|
||||
position = tokenizer.call(self, value, 1);
|
||||
|
||||
if (position !== -1 && position < min) {
|
||||
min = position;
|
||||
}
|
||||
}
|
||||
|
||||
subvalue = value.slice(0, min);
|
||||
|
||||
eat(subvalue)({
|
||||
type: 'text',
|
||||
value: subvalue,
|
||||
});
|
||||
}
|
||||
};
|
@ -1,83 +0,0 @@
|
||||
import { concat, last, nth, isEmpty, set } from 'lodash';
|
||||
import visitParents from 'unist-util-visit-parents';
|
||||
|
||||
/**
|
||||
* remarkUnwrapInvalidNest
|
||||
*
|
||||
* Some MDAST node types can only be nested within specific node types - for
|
||||
* example, a paragraph can't be nested within another paragraph, and a heading
|
||||
* can't be nested in a "strong" type node. This kind of invalid MDAST can be
|
||||
* generated by rehype-remark from invalid HTML.
|
||||
*
|
||||
* This plugin finds instances of invalid nesting, and unwraps the invalidly
|
||||
* nested nodes as far up the parental line as necessary, splitting parent nodes
|
||||
* along the way. The resulting node has no invalidly nested nodes, and all
|
||||
* validly nested nodes retain their ancestry. Nodes that are emptied as a
|
||||
* result of unnesting nodes are removed from the tree.
|
||||
*/
|
||||
export default function remarkUnwrapInvalidNest() {
|
||||
return transform;
|
||||
|
||||
function transform(tree) {
|
||||
const invalidNest = findInvalidNest(tree);
|
||||
|
||||
if (!invalidNest) return tree;
|
||||
|
||||
splitTreeAtNest(tree, invalidNest);
|
||||
|
||||
return transform(tree);
|
||||
}
|
||||
|
||||
/**
|
||||
* visitParents uses unist-util-visit-parent to check every node in the
|
||||
* tree while having access to every ancestor of the node. This is ideal
|
||||
* for determining whether a block node has an ancestor that should not
|
||||
* contain a block node. Note that it operates in a mutable fashion.
|
||||
*/
|
||||
function findInvalidNest(tree) {
|
||||
/**
|
||||
* Node types that are considered "blocks".
|
||||
*/
|
||||
const blocks = ['paragraph', 'heading', 'code', 'blockquote', 'list', 'table', 'thematicBreak'];
|
||||
|
||||
/**
|
||||
* Node types that can contain "block" nodes as direct children. We check
|
||||
*/
|
||||
const canContainBlocks = ['root', 'blockquote', 'listItem', 'tableCell'];
|
||||
|
||||
let invalidNest;
|
||||
|
||||
visitParents(tree, (node, parents) => {
|
||||
const parentType = !isEmpty(parents) && last(parents).type;
|
||||
const isInvalidNest = blocks.includes(node.type) && !canContainBlocks.includes(parentType);
|
||||
|
||||
if (isInvalidNest) {
|
||||
invalidNest = concat(parents, node);
|
||||
return false;
|
||||
}
|
||||
});
|
||||
|
||||
return invalidNest;
|
||||
}
|
||||
|
||||
function splitTreeAtNest(tree, nest) {
|
||||
const grandparent = nth(nest, -3) || tree;
|
||||
const parent = nth(nest, -2);
|
||||
const node = last(nest);
|
||||
|
||||
const splitIndex = grandparent.children.indexOf(parent);
|
||||
const splitChildren = grandparent.children;
|
||||
const splitChildIndex = parent.children.indexOf(node);
|
||||
|
||||
const childrenBefore = parent.children.slice(0, splitChildIndex);
|
||||
const childrenAfter = parent.children.slice(splitChildIndex + 1);
|
||||
const nodeBefore = !isEmpty(childrenBefore) && { ...parent, children: childrenBefore };
|
||||
const nodeAfter = !isEmpty(childrenAfter) && { ...parent, children: childrenAfter };
|
||||
|
||||
const childrenToInsert = [nodeBefore, node, nodeAfter].filter(val => !isEmpty(val));
|
||||
const beforeChildren = splitChildren.slice(0, splitIndex);
|
||||
const afterChildren = splitChildren.slice(splitIndex + 1);
|
||||
const newChildren = concat(beforeChildren, childrenToInsert, afterChildren);
|
||||
grandparent.children = newChildren;
|
||||
}
|
||||
}
|
@ -1,280 +0,0 @@
|
||||
import { has, flow, partial, flatMap, flatten, map } from 'lodash';
|
||||
import { joinPatternSegments, combinePatterns, replaceWhen } from 'Lib/regexHelper';
|
||||
|
||||
/**
|
||||
* Reusable regular expressions segments.
|
||||
*/
|
||||
const patternSegments = {
|
||||
/**
|
||||
* Matches zero or more HTML attributes followed by the tag close bracket,
|
||||
* which may be prepended by zero or more spaces. The attributes can use
|
||||
* single or double quotes and may be prepended by zero or more spaces.
|
||||
*/
|
||||
htmlOpeningTagEnd: /(?: *\w+=(?:(?:"[^"]*")|(?:'[^']*')))* *>/,
|
||||
};
|
||||
|
||||
|
||||
/**
|
||||
* Patterns matching substrings that should not be escaped. Array values must be
|
||||
* joined before use.
|
||||
*/
|
||||
const nonEscapePatterns = {
|
||||
/**
|
||||
* HTML Tags
|
||||
*
|
||||
* Matches HTML opening tags and any attributes. Does not check for contents
|
||||
* between tags or closing tags.
|
||||
*/
|
||||
htmlTags: [
|
||||
/**
|
||||
* Matches the beginning of an HTML tag, excluding preformatted tag types.
|
||||
*/
|
||||
/<(?!pre|style|script)[\w]+/,
|
||||
|
||||
/**
|
||||
* Matches attributes.
|
||||
*/
|
||||
patternSegments.htmlOpeningTagEnd,
|
||||
],
|
||||
|
||||
|
||||
/**
|
||||
* Preformatted HTML Blocks
|
||||
*
|
||||
* Matches HTML blocks with preformatted content. The content of these blocks,
|
||||
* including the tags and attributes, should not be escaped at all.
|
||||
*/
|
||||
preformattedHtmlBlocks: [
|
||||
/**
|
||||
* Matches the names of tags known to have preformatted content. The capture
|
||||
* group is reused when matching the closing tag.
|
||||
*
|
||||
* NOTE: this pattern reuses a capture group, and could break if combined with
|
||||
* other expressions using capture groups.
|
||||
*/
|
||||
/<(pre|style|script)/,
|
||||
|
||||
/**
|
||||
* Matches attributes.
|
||||
*/
|
||||
patternSegments.htmlOpeningTagEnd,
|
||||
|
||||
/**
|
||||
* Allow zero or more of any character (including line breaks) between the
|
||||
* tags. Match lazily in case of subsequent blocks.
|
||||
*/
|
||||
/(.|[\n\r])*?/,
|
||||
|
||||
/**
|
||||
* Match closing tag via first capture group.
|
||||
*/
|
||||
/<\/\1>/,
|
||||
],
|
||||
};
|
||||
|
||||
|
||||
/**
|
||||
* Escape patterns
|
||||
*
|
||||
* Each escape pattern matches a markdown entity and captures up to two
|
||||
* groups. These patterns must use one of the following formulas:
|
||||
*
|
||||
* - Single capture group followed by match content - /(...).../
|
||||
* The captured characters should be escaped and the remaining match should
|
||||
* remain unchanged.
|
||||
*
|
||||
* - Two capture groups surrounding matched content - /(...)...(...)/
|
||||
* The captured characters in both groups should be escaped and the matched
|
||||
* characters in between should remain unchanged.
|
||||
*/
|
||||
const escapePatterns = [
|
||||
/**
|
||||
* Emphasis/Bold - Asterisk
|
||||
*
|
||||
* Match strings surrounded by one or more asterisks on both sides.
|
||||
*/
|
||||
/(\*+)[^\*]*(\1)/g,
|
||||
|
||||
/**
|
||||
* Emphasis - Underscore
|
||||
*
|
||||
* Match strings surrounded by a single underscore on both sides followed by
|
||||
* a word boundary. Remark disregards whether a word boundary exists at the
|
||||
* beginning of an emphasis node.
|
||||
*/
|
||||
/(_)[^_]+(_)\b/g,
|
||||
|
||||
/**
|
||||
* Bold - Underscore
|
||||
*
|
||||
* Match strings surrounded by multiple underscores on both sides. Remark
|
||||
* disregards the absence of word boundaries on either side of a bold node.
|
||||
*/
|
||||
/(_{2,})[^_]*(\1)/g,
|
||||
|
||||
/**
|
||||
* Strikethrough
|
||||
*
|
||||
* Match strings surrounded by multiple tildes on both sides.
|
||||
*/
|
||||
/(~+)[^~]*(\1)/g,
|
||||
|
||||
/**
|
||||
* Inline Code
|
||||
*
|
||||
* Match strings surrounded by backticks.
|
||||
*/
|
||||
/(`+)[^`]*(\1)/g,
|
||||
|
||||
/**
|
||||
* Links, Images, References, and Footnotes
|
||||
*
|
||||
* Match strings surrounded by brackets. This could be improved to
|
||||
* specifically match only the exact syntax of each covered entity, but
|
||||
* doing so through current approach would incur a considerable performance
|
||||
* penalty.
|
||||
*/
|
||||
/(\[)[^\]]*]/g,
|
||||
];
|
||||
|
||||
|
||||
/**
|
||||
* Generate new non-escape expression. The non-escape expression matches
|
||||
* substrings whose contents should not be processed for escaping.
|
||||
*/
|
||||
const joinedNonEscapePatterns = map(nonEscapePatterns, pattern => {
|
||||
return new RegExp(joinPatternSegments(pattern));
|
||||
});
|
||||
const nonEscapePattern = combinePatterns(joinedNonEscapePatterns);
|
||||
|
||||
|
||||
/**
|
||||
* Create chain of successive escape functions for various markdown entities.
|
||||
*/
|
||||
const escapeFunctions = escapePatterns.map(pattern => partial(escapeDelimiters, pattern));
|
||||
const escapeAll = flow(escapeFunctions);
|
||||
|
||||
|
||||
/**
|
||||
* Executes both the `escapeCommonChars` and `escapeLeadingChars` functions.
|
||||
*/
|
||||
function escapeAllChars(text) {
|
||||
const partiallyEscapedMarkdown = escapeCommonChars(text);
|
||||
return escapeLeadingChars(partiallyEscapedMarkdown);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* escapeLeadingChars
|
||||
*
|
||||
* Handles escaping for characters that must be positioned at the beginning of
|
||||
* the string, such as headers and list items.
|
||||
*
|
||||
* Escapes '#', '*', '-', '>', '=', '|', and sequences of 3+ backticks or 4+
|
||||
* spaces when found at the beginning of a string, preceded by zero or more
|
||||
* whitespace characters.
|
||||
*/
|
||||
function escapeLeadingChars(text) {
|
||||
return text.replace(/^\s*([-#*>=|]| {4,}|`{3,})/, '$`\\$1');
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* escapeCommonChars
|
||||
*
|
||||
* Escapes active markdown entities. See escape pattern groups for details on
|
||||
* which entities are replaced.
|
||||
*/
|
||||
function escapeCommonChars(text) {
|
||||
/**
|
||||
* Generate new non-escape expression (must happen at execution time because
|
||||
* we use `RegExp.exec`, which tracks it's own state internally).
|
||||
*/
|
||||
const nonEscapeExpression = new RegExp(nonEscapePattern, 'gm');
|
||||
|
||||
/**
|
||||
* Use `replaceWhen` to escape markdown entities only within substrings that
|
||||
* are eligible for escaping.
|
||||
*/
|
||||
return replaceWhen(nonEscapeExpression, escapeAll, text, true);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* escapeDelimiters
|
||||
*
|
||||
* Executes `String.replace` for a given pattern, but only on the first two
|
||||
* capture groups. Specifically intended for escaping opening (and optionally
|
||||
* closing) markdown entities without escaping the content in between.
|
||||
*/
|
||||
function escapeDelimiters(pattern, text) {
|
||||
return text.replace(pattern, (match, start, end) => {
|
||||
const hasEnd = typeof end === 'string';
|
||||
const matchSliceEnd = hasEnd ? match.length - end.length : match.length;
|
||||
const content = match.slice(start.length, matchSliceEnd);
|
||||
return `${escape(start)}${content}${hasEnd ? escape(end) : ''}`;
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* escape
|
||||
*
|
||||
* Simple replacement function for escaping markdown entities. Prepends every
|
||||
* character in the received string with a backslash.
|
||||
*/
|
||||
function escape(delim) {
|
||||
let result = '';
|
||||
for (const char of delim) {
|
||||
result += `\\${char}`;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* A Remark plugin for escaping markdown entities.
|
||||
*
|
||||
* When markdown entities are entered in raw markdown, they don't appear as
|
||||
* characters in the resulting AST; for example, dashes surrounding a piece of
|
||||
* text cause the text to be inserted in a special node type, but the asterisks
|
||||
* themselves aren't present as text. Therefore, we generally don't expect to
|
||||
* encounter markdown characters in text nodes.
|
||||
*
|
||||
* However, the CMS visual editor does not interpret markdown characters, and
|
||||
* users will expect these characters to be represented literally. In that case,
|
||||
* we need to escape them, otherwise they'll be interpreted during
|
||||
* stringification.
|
||||
*/
|
||||
export default function remarkEscapeMarkdownEntities() {
|
||||
const transform = (node, index) => {
|
||||
/**
|
||||
* Shortcode nodes will intentionally inject markdown entities in text node
|
||||
* children not be escaped.
|
||||
*/
|
||||
if (has(node.data, 'shortcode')) return node;
|
||||
|
||||
const children = node.children && node.children.map(transform);
|
||||
|
||||
/**
|
||||
* Escape characters in text and html nodes only. We store a lot of normal
|
||||
* text in html nodes to keep Remark from escaping html entities.
|
||||
*/
|
||||
if (['text', 'html'].includes(node.type)) {
|
||||
|
||||
/**
|
||||
* Escape all characters if this is the first child node, otherwise only
|
||||
* common characters.
|
||||
*/
|
||||
const value = index === 0 ? escapeAllChars(node.value) : escapeCommonChars(node.value);
|
||||
return { ...node, value, children };
|
||||
}
|
||||
|
||||
/**
|
||||
* Always return nodes with recursively mapped children.
|
||||
*/
|
||||
return {...node, children };
|
||||
};
|
||||
|
||||
return transform;
|
||||
}
|
@ -1,26 +0,0 @@
|
||||
/**
|
||||
* Images must be parsed as shortcodes for asset proxying. This plugin converts
|
||||
* MDAST image nodes back to text to allow shortcode pattern matching. Note that
|
||||
* this transformation only occurs for images that are the sole child of a top
|
||||
* level paragraph - any other image is left alone and treated as an inline
|
||||
* image.
|
||||
*/
|
||||
export default function remarkImagesToText() {
|
||||
return transform;
|
||||
|
||||
function transform(node) {
|
||||
const children = node.children.map(child => {
|
||||
if (
|
||||
child.type === 'paragraph'
|
||||
&& child.children.length === 1
|
||||
&& child.children[0].type === 'image'
|
||||
) {
|
||||
const { alt = '', url = '', title = '' } = child.children[0];
|
||||
const value = ``;
|
||||
child.children = [{ type: 'text', value }];
|
||||
}
|
||||
return child;
|
||||
});
|
||||
return { ...node, children };
|
||||
}
|
||||
}
|
@ -1,128 +0,0 @@
|
||||
import {
|
||||
get,
|
||||
set,
|
||||
find,
|
||||
findLast,
|
||||
startsWith,
|
||||
endsWith,
|
||||
trimStart,
|
||||
trimEnd,
|
||||
concat,
|
||||
flatMap
|
||||
} from 'lodash';
|
||||
import u from 'unist-builder';
|
||||
import toString from 'mdast-util-to-string';
|
||||
|
||||
/**
|
||||
* Convert leading and trailing spaces in a link to single spaces outside of the
|
||||
* link. MDASTs derived from pasted Google Docs HTML require this treatment.
|
||||
*
|
||||
* Note that, because we're potentially replacing characters in a link node's
|
||||
* children with character's in a link node's siblings, we have to operate on a
|
||||
* parent (link) node and its children at once, rather than just processing
|
||||
* children one at a time.
|
||||
*/
|
||||
export default function remarkPaddedLinks() {
|
||||
|
||||
function transform(node) {
|
||||
|
||||
/**
|
||||
* Because we're operating on link nodes and their children at once, we can
|
||||
* exit if the current node has no children.
|
||||
*/
|
||||
if (!node.children) return node;
|
||||
|
||||
/**
|
||||
* Process a node's children if any of them are links. If a node is a link
|
||||
* with leading or trailing spaces, we'll get back an array of nodes instead
|
||||
* of a single node, so we use `flatMap` to keep those nodes as siblings
|
||||
* with the other children.
|
||||
*
|
||||
* If performance improvements are found desirable, we could change this to
|
||||
* only pass in the link nodes instead of the entire array of children, but
|
||||
* this seems unlikely to produce a noticeable perf gain.
|
||||
*/
|
||||
const hasLinkChild = node.children.some(child => child.type === 'link');
|
||||
const processedChildren = hasLinkChild ? flatMap(node.children, transformChildren) : node.children;
|
||||
|
||||
/**
|
||||
* Run all children through the transform recursively.
|
||||
*/
|
||||
const children = processedChildren.map(transform);
|
||||
|
||||
return { ...node, children };
|
||||
};
|
||||
|
||||
function transformChildren(node) {
|
||||
if (node.type !== 'link') return node;
|
||||
|
||||
/**
|
||||
* Get the node's complete string value, check for leading and trailing
|
||||
* whitespace, and get nodes from each edge where whitespace is found.
|
||||
*/
|
||||
const text = toString(node);
|
||||
const leadingWhitespaceNode = startsWith(text, ' ') && getEdgeTextChild(node);
|
||||
const trailingWhitespaceNode = endsWith(text, ' ') && getEdgeTextChild(node, true);
|
||||
|
||||
if (!leadingWhitespaceNode && !trailingWhitespaceNode) return node;
|
||||
|
||||
/**
|
||||
* Trim the edge nodes in place. Unified handles everything in a mutable
|
||||
* fashion, so it's often simpler to do the same when working with Unified
|
||||
* ASTs.
|
||||
*/
|
||||
if (leadingWhitespaceNode) {
|
||||
leadingWhitespaceNode.value = trimStart(leadingWhitespaceNode.value);
|
||||
}
|
||||
|
||||
if (trailingWhitespaceNode) {
|
||||
trailingWhitespaceNode.value = trimEnd(trailingWhitespaceNode.value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an array of nodes. The first and last child will either be `false`
|
||||
* or a text node. We filter out the false values before returning.
|
||||
*/
|
||||
const nodes = [
|
||||
leadingWhitespaceNode && u('text', ' '),
|
||||
node,
|
||||
trailingWhitespaceNode && u('text', ' ')
|
||||
];
|
||||
|
||||
return nodes.filter(val => val);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the first or last non-blank text child of a node, regardless of
|
||||
* nesting. If `end` is truthy, get the last node, otherwise first.
|
||||
*/
|
||||
function getEdgeTextChild(node, end) {
|
||||
/**
|
||||
* This was changed from a ternary to a long form if due to issues with istanbul's instrumentation and babel's code
|
||||
* generation.
|
||||
* TODO: watch https://github.com/istanbuljs/babel-plugin-istanbul/issues/95
|
||||
* when it is resolved then revert to ```const findFn = end ? findLast : find;```
|
||||
*/
|
||||
let findFn;
|
||||
if (end) { findFn = findLast }
|
||||
else { findFn = find };
|
||||
|
||||
let edgeChildWithValue;
|
||||
setEdgeChildWithValue(node);
|
||||
return edgeChildWithValue;
|
||||
|
||||
/**
|
||||
* searchChildren checks a node and all of it's children deeply to find a
|
||||
* non-blank text value. When the text node is found, we set it in an outside
|
||||
* variable, as it may be deep in the tree and therefore wouldn't be returned
|
||||
* by `find`/`findLast`.
|
||||
*/
|
||||
function setEdgeChildWithValue(child) {
|
||||
if (!edgeChildWithValue && child.value) {
|
||||
edgeChildWithValue = child;
|
||||
}
|
||||
findFn(child.children, setEdgeChildWithValue);
|
||||
}
|
||||
}
|
||||
return transform;
|
||||
}
|
@ -1,50 +0,0 @@
|
||||
import { map, has } from 'lodash';
|
||||
import { renderToString } from 'react-dom/server';
|
||||
import u from 'unist-builder';
|
||||
|
||||
/**
|
||||
* This plugin doesn't actually transform Remark (MDAST) nodes to Rehype
|
||||
* (HAST) nodes, but rather, it prepares an MDAST shortcode node for HAST
|
||||
* conversion by replacing the shortcode text with stringified HTML for
|
||||
* previewing the shortcode output.
|
||||
*/
|
||||
export default function remarkToRehypeShortcodes({ plugins, getAsset }) {
|
||||
return transform;
|
||||
|
||||
function transform(root) {
|
||||
const transformedChildren = map(root.children, processShortcodes);
|
||||
return { ...root, children: transformedChildren };
|
||||
}
|
||||
|
||||
/**
|
||||
* Mapping function to transform nodes that contain shortcodes.
|
||||
*/
|
||||
function processShortcodes(node) {
|
||||
/**
|
||||
* If the node doesn't contain shortcode data, return the original node.
|
||||
*/
|
||||
if (!has(node, ['data', 'shortcode'])) return node;
|
||||
|
||||
/**
|
||||
* Get shortcode data from the node, and retrieve the matching plugin by
|
||||
* key.
|
||||
*/
|
||||
const { shortcode, shortcodeData } = node.data;
|
||||
const plugin = plugins.get(shortcode);
|
||||
|
||||
/**
|
||||
* Run the shortcode plugin's `toPreview` method, which will return either
|
||||
* an HTML string or a React component. If a React component is returned,
|
||||
* render it to an HTML string.
|
||||
*/
|
||||
const value = plugin.toPreview(shortcodeData, getAsset);
|
||||
const valueHtml = typeof value === 'string' ? value : renderToString(value);
|
||||
|
||||
/**
|
||||
* Return a new 'html' type node containing the shortcode preview markup.
|
||||
*/
|
||||
const textNode = u('html', valueHtml);
|
||||
const children = [ textNode ];
|
||||
return { ...node, children };
|
||||
}
|
||||
}
|
@ -1,99 +0,0 @@
|
||||
import { map, every } from 'lodash';
|
||||
import u from 'unist-builder';
|
||||
import mdastToString from 'mdast-util-to-string';
|
||||
|
||||
/**
|
||||
* Parse shortcodes from an MDAST.
|
||||
*
|
||||
* Shortcodes are plain text, and must be the lone content of a paragraph. The
|
||||
* paragraph must also be a direct child of the root node. When a shortcode is
|
||||
* found, we just need to add data to the node so the shortcode can be
|
||||
* identified and processed when serializing to a new format. The paragraph
|
||||
* containing the node is also recreated to ensure normalization.
|
||||
*/
|
||||
export default function remarkShortcodes({ plugins }) {
|
||||
return transform;
|
||||
|
||||
/**
|
||||
* Map over children of the root node and convert any found shortcode nodes.
|
||||
*/
|
||||
function transform(root) {
|
||||
const transformedChildren = map(root.children, processShortcodes);
|
||||
return { ...root, children: transformedChildren };
|
||||
}
|
||||
|
||||
/**
|
||||
* Mapping function to transform nodes that contain shortcodes.
|
||||
*/
|
||||
function processShortcodes(node) {
|
||||
/**
|
||||
* If the node is not eligible to contain a shortcode, return the original
|
||||
* node unchanged.
|
||||
*/
|
||||
if (!nodeMayContainShortcode(node)) return node;
|
||||
|
||||
/**
|
||||
* Combine the text values of all children to a single string, check the
|
||||
* string for a shortcode pattern match, and validate the match.
|
||||
*/
|
||||
const text = mdastToString(node).trim();
|
||||
const { plugin, match } = matchTextToPlugin(text);
|
||||
const matchIsValid = validateMatch(text, match);
|
||||
|
||||
/**
|
||||
* If a valid match is found, return a new node with shortcode data
|
||||
* included. Otherwise, return the original node.
|
||||
*/
|
||||
return matchIsValid ? createShortcodeNode(text, plugin, match) : node;
|
||||
};
|
||||
|
||||
/**
|
||||
* Ensure that the node and it's children are acceptable types to contain
|
||||
* shortcodes. Currently, only a paragraph containing text and/or html nodes
|
||||
* may contain shortcodes.
|
||||
*/
|
||||
function nodeMayContainShortcode(node) {
|
||||
const validNodeTypes = ['paragraph'];
|
||||
const validChildTypes = ['text', 'html'];
|
||||
|
||||
if (validNodeTypes.includes(node.type)) {
|
||||
return every(node.children, child => {
|
||||
return validChildTypes.includes(child.type);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the plugin and RegExp.match result from the first plugin with a
|
||||
* pattern that matches the given text.
|
||||
*/
|
||||
function matchTextToPlugin(text) {
|
||||
let match;
|
||||
const plugin = plugins.find(p => {
|
||||
match = text.match(p.pattern);
|
||||
return !!match;
|
||||
});
|
||||
return { plugin, match };
|
||||
}
|
||||
|
||||
/**
|
||||
* A match is only valid if it takes up the entire paragraph.
|
||||
*/
|
||||
function validateMatch(text, match) {
|
||||
return match && match[0].length === text.length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new node with shortcode data included. Use an 'html' node instead
|
||||
* of a 'text' node as the child to ensure the node content is not parsed by
|
||||
* Remark or Rehype. Include the child as an array because an MDAST paragraph
|
||||
* node must have it's children in an array.
|
||||
*/
|
||||
function createShortcodeNode(text, plugin, match) {
|
||||
const shortcode = plugin.id;
|
||||
const shortcodeData = plugin.fromBlock(match);
|
||||
const data = { shortcode, shortcodeData };
|
||||
const textNode = u('html', text);
|
||||
return u('paragraph', { data }, [textNode]);
|
||||
}
|
||||
}
|
@ -1,361 +0,0 @@
|
||||
import { get, isEmpty, isArray, last, flatMap } from 'lodash';
|
||||
import u from 'unist-builder';
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
const typeMap = {
|
||||
root: 'root',
|
||||
paragraph: 'paragraph',
|
||||
blockquote: 'quote',
|
||||
code: 'code',
|
||||
listItem: 'list-item',
|
||||
table: 'table',
|
||||
tableRow: 'table-row',
|
||||
tableCell: 'table-cell',
|
||||
thematicBreak: 'thematic-break',
|
||||
link: 'link',
|
||||
image: 'image',
|
||||
shortcode: 'shortcode',
|
||||
};
|
||||
|
||||
|
||||
/**
|
||||
* Map of MDAST node types to Slate mark types.
|
||||
*/
|
||||
const markMap = {
|
||||
strong: 'bold',
|
||||
emphasis: 'italic',
|
||||
delete: 'strikethrough',
|
||||
inlineCode: 'code',
|
||||
};
|
||||
|
||||
|
||||
/**
|
||||
* Add nodes to a parent node only if `nodes` is truthy.
|
||||
*/
|
||||
function addNodes(parent, nodes) {
|
||||
return nodes ? { ...parent, nodes } : parent;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Create a Slate Inline node.
|
||||
*/
|
||||
function createBlock(type, nodes, props = {}) {
|
||||
if (!isArray(nodes)) {
|
||||
props = nodes;
|
||||
nodes = undefined;
|
||||
}
|
||||
|
||||
const node = { kind: 'block', type, ...props };
|
||||
return addNodes(node, nodes);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Create a Slate Block node.
|
||||
*/
|
||||
function createInline(type, props = {}, nodes) {
|
||||
const node = { kind: 'inline', type, ...props };
|
||||
return addNodes(node, nodes);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Create a Slate Raw text node.
|
||||
*/
|
||||
function createText(value, data) {
|
||||
const node = { kind: '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
|
||||
* mark nodes, if any.
|
||||
*/
|
||||
const markType = markMap[node.type];
|
||||
const marks = markType ? [...parentMarks, { type: markMap[node.type] }] : parentMarks;
|
||||
|
||||
const children = flatMap(node.children, childNode => {
|
||||
switch (childNode.type) {
|
||||
/**
|
||||
* 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
|
||||
* `marks` array should apply to that specific leaf.
|
||||
*/
|
||||
case 'html':
|
||||
case 'text':
|
||||
return { text: childNode.value, marks };
|
||||
|
||||
/**
|
||||
* MDAST inline code nodes don't have children, just a text value, similar
|
||||
* to a text node, so it receives the same treatment as a text node, but we
|
||||
* first add the inline code mark to the marks array.
|
||||
*/
|
||||
case 'inlineCode': {
|
||||
const childMarks = [ ...marks, { type: markMap['inlineCode'] } ];
|
||||
return { text: childNode.value, marks: childMarks };
|
||||
}
|
||||
|
||||
/**
|
||||
* Process nested style nodes. The recursive results should be pushed into
|
||||
* the leaves 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
|
||||
* text node.
|
||||
*/
|
||||
case 'strong':
|
||||
case 'emphasis':
|
||||
case 'delete':
|
||||
return processMarkNode(childNode, marks);
|
||||
|
||||
/**
|
||||
* Remaining nodes simply need mark data added to them, and to then be
|
||||
* added into the cumulative children array.
|
||||
*/
|
||||
default:
|
||||
return { ...childNode, data: { marks } };
|
||||
}
|
||||
});
|
||||
|
||||
return children;
|
||||
}
|
||||
|
||||
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) {
|
||||
|
||||
/**
|
||||
* Unified/Remark processors use mutable operations, so we don't want to
|
||||
* change a node's type directly for conversion purposes, as that tends to
|
||||
* unexpected errors.
|
||||
*/
|
||||
const type = get(node, ['data', 'shortcode']) ? 'shortcode' : node.type;
|
||||
|
||||
switch (type) {
|
||||
|
||||
/**
|
||||
* General
|
||||
*
|
||||
* Convert simple cases that only require a type and children, with no
|
||||
* additional properties.
|
||||
*/
|
||||
case 'root':
|
||||
case 'paragraph':
|
||||
case 'listItem':
|
||||
case 'blockquote':
|
||||
case 'tableRow':
|
||||
case 'tableCell': {
|
||||
return createBlock(typeMap[type], nodes);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 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[type], nodes, { data, isVoid: true });
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
*
|
||||
* Inline code nodes from an MDAST are represented in our Slate schema as
|
||||
* 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
|
||||
* as a Slate text node's children array.
|
||||
*/
|
||||
case 'inlineCode': {
|
||||
const leaf = {
|
||||
text: node.value,
|
||||
marks: [{ type: 'code' }],
|
||||
};
|
||||
return createText([ leaf ]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Marks
|
||||
*
|
||||
* Marks are typically decorative sub-types that apply to text nodes. In an
|
||||
* MDAST, marks are nodes that can contain other nodes. This nested
|
||||
* hierarchy has to be flattened and split into distinct text nodes with
|
||||
* their own set of marks.
|
||||
*/
|
||||
case 'strong':
|
||||
case 'emphasis':
|
||||
case 'delete': {
|
||||
return convertMarkNode(node);
|
||||
}
|
||||
|
||||
/**
|
||||
* Headings
|
||||
*
|
||||
* MDAST headings use a single type with a separate "depth" property to
|
||||
* 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
|
||||
* on the MDAST node depth.
|
||||
*/
|
||||
case 'heading': {
|
||||
const depthMap = { 1: 'one', 2: 'two', 3: 'three', 4: 'four', 5: 'five', 6: 'six' };
|
||||
const slateType = `heading-${depthMap[node.depth]}`;
|
||||
return createBlock(slateType, nodes);
|
||||
}
|
||||
|
||||
/**
|
||||
* Code Blocks
|
||||
*
|
||||
* MDAST code blocks are a distinct node type with a simple text value. We
|
||||
* convert that value into a nested child text node for Slate. We also carry
|
||||
* over the "lang" data property if it's defined.
|
||||
*/
|
||||
case 'code': {
|
||||
const data = { lang: node.lang };
|
||||
const text = createText(node.value);
|
||||
const nodes = [text];
|
||||
return createBlock(typeMap[type], nodes, { data });
|
||||
}
|
||||
|
||||
/**
|
||||
* Lists
|
||||
*
|
||||
* MDAST has a single list type and an "ordered" property. We derive that
|
||||
* information into the Slate schema's distinct list node types. We also
|
||||
* include the "start" property, which indicates the number an ordered list
|
||||
* starts at, if defined.
|
||||
*/
|
||||
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 textNode = createText('\n');
|
||||
return createInline('break', {}, [ textNode ]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Thematic Breaks
|
||||
*
|
||||
* Thematic breaks are void nodes in the Slate schema.
|
||||
*/
|
||||
case 'thematicBreak': {
|
||||
return createBlock(typeMap[type], { isVoid: true });
|
||||
}
|
||||
|
||||
/**
|
||||
* 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[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[type], { isVoid: true, 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[type], nodes, { data });
|
||||
}
|
||||
}
|
||||
}
|
@ -1,74 +0,0 @@
|
||||
import { without, flatten } from 'lodash';
|
||||
import u from 'unist-builder';
|
||||
import mdastDefinitions from 'mdast-util-definitions';
|
||||
|
||||
/**
|
||||
* Raw markdown may contain image references or link references. Because there
|
||||
* is no way to maintain these references within the Slate AST, we convert image
|
||||
* and link references to standard images and links by putting their url's
|
||||
* inline. The definitions are then removed from the document.
|
||||
*
|
||||
* For example, the following markdown:
|
||||
*
|
||||
* ```
|
||||
* ![alpha][bravo]
|
||||
*
|
||||
* [bravo]: http://example.com/example.jpg
|
||||
* ```
|
||||
*
|
||||
* Yields:
|
||||
*
|
||||
* ```
|
||||
* 
|
||||
* ```
|
||||
*
|
||||
*/
|
||||
export default function remarkSquashReferences() {
|
||||
return getTransform;
|
||||
|
||||
function getTransform(node) {
|
||||
const getDefinition = mdastDefinitions(node);
|
||||
return transform.call(null, getDefinition, node);
|
||||
}
|
||||
|
||||
function transform(getDefinition, node) {
|
||||
|
||||
/**
|
||||
* Bind the `getDefinition` function to `transform` and recursively map all
|
||||
* nodes.
|
||||
*/
|
||||
const boundTransform = transform.bind(null, getDefinition);
|
||||
const children = node.children ? node.children.map(boundTransform) : node.children;
|
||||
|
||||
/**
|
||||
* Combine reference and definition nodes into standard image and link
|
||||
* nodes.
|
||||
*/
|
||||
if (['imageReference', 'linkReference'].includes(node.type)) {
|
||||
const type = node.type === 'imageReference' ? 'image' : 'link';
|
||||
const definition = getDefinition(node.identifier);
|
||||
|
||||
if (definition) {
|
||||
const { title, url } = definition;
|
||||
return u(type, { title, url, alt: node.alt }, children);
|
||||
}
|
||||
|
||||
const pre = u('text', node.type === 'imageReference' ? '![' : '[');
|
||||
const post = u('text', ']');
|
||||
const nodes = children || [ u('text', node.alt) ];
|
||||
return [ pre, ...nodes, post];
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove definition nodes and filter the resulting null values from the
|
||||
* filtered children array.
|
||||
*/
|
||||
if(node.type === 'definition') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const filteredChildren = without(children, null);
|
||||
|
||||
return { ...node, children: flatten(filteredChildren) };
|
||||
}
|
||||
}
|
@ -1,56 +0,0 @@
|
||||
import mdastToString from 'mdast-util-to-string';
|
||||
|
||||
/**
|
||||
* Removes break nodes that are at the end of a block.
|
||||
*
|
||||
* When a trailing double space or backslash is encountered at the end of a
|
||||
* markdown block, Remark will interpret the character(s) literally, as only
|
||||
* break entities followed by text qualify as breaks. A manually created MDAST,
|
||||
* however, may have such entities, and users of visual editors shouldn't see
|
||||
* these artifacts in resulting markdown.
|
||||
*/
|
||||
export default function remarkStripTrailingBreaks() {
|
||||
const transform = node => {
|
||||
if (node.children) {
|
||||
node.children = node.children
|
||||
.map((child, idx, children) => {
|
||||
|
||||
/**
|
||||
* Only touch break nodes. Convert all subsequent nodes to their text
|
||||
* value and exclude the break node if no non-whitespace characters
|
||||
* are found.
|
||||
*/
|
||||
if (child.type === 'break') {
|
||||
const subsequentNodes = children.slice(idx + 1);
|
||||
|
||||
/**
|
||||
* Create a small MDAST so that mdastToString can process all
|
||||
* siblings as children of one node rather than making multiple
|
||||
* calls.
|
||||
*/
|
||||
const fragment = { type: 'root', children: subsequentNodes };
|
||||
const subsequentText = mdastToString(fragment);
|
||||
return subsequentText.trim() ? child : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Always return the child if not a break.
|
||||
*/
|
||||
return child;
|
||||
})
|
||||
|
||||
/**
|
||||
* Because some break nodes may be excluded, we filter out the resulting
|
||||
* null values.
|
||||
*/
|
||||
.filter(child => child)
|
||||
|
||||
/**
|
||||
* Recurse through the MDAST by transforming each individual child node.
|
||||
*/
|
||||
.map(transform);
|
||||
}
|
||||
return node;
|
||||
};
|
||||
return transform;
|
||||
};
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user