begin scaffolding for lerna

This commit is contained in:
Shawn Erquhart
2018-07-03 15:47:15 -04:00
parent 26f7c38a9f
commit 768fcbaa1d
320 changed files with 50292 additions and 464 deletions

View File

@ -1,8 +0,0 @@
@import "./NotFoundPage.css";
@import "./Header.css";
.nc-app-main {
min-width: 800px;
max-width: 1440px;
margin: 0 auto;
}

View File

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

View File

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

View File

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

View File

@ -1,3 +0,0 @@
.nc-notFound-container {
margin: var(--pageMargin);
}

View File

@ -1,7 +0,0 @@
import React from 'react';
export default () => (
<div className="nc-notFound-container">
<h2>Not Found</h2>
</div>
);

View File

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

View File

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

View File

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

View File

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

View File

@ -1,2 +0,0 @@
@import "./EntryListing.css";
@import "./EntryCard.css";

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,9 +0,0 @@
.nc-entryListing-cardsGrid {
display: flex;
flex-flow: row wrap;
margin-left: -12px;
}
.nc-entryListing-cardsList {
margin-left: -12px;
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,7 +0,0 @@
.nc-controlPane-control {
margin-top: 16px;
&:first-child {
margin-top: 36px;
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,7 +0,0 @@
.nc-previewPane-frame {
width: 100%;
height: 100%;
border: none;
background: #fff;
border-radius: var(--borderRadius);
}

View File

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

View File

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

View File

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

View File

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

View File

@ -1,2 +0,0 @@
export { default as ScrollSync } from './ScrollSync';
export { default as ScrollSyncPane } from './ScrollSyncPane';

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,9 +0,0 @@
.nc-booleanControl-switch {
& .nc-toggle-background {
background-color: var(--textFieldBorderColor);
}
& .nc-toggle-active .nc-toggle-background {
background-color: var(--colorActive);
}
}

View File

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

View File

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

View File

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

View File

@ -1 +0,0 @@
@import "./ReactDatetime.css";

View File

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

View File

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

View File

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

View File

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

View File

@ -1,7 +0,0 @@
.nc-fileControl-input {
display: none !important;
}
.nc-fileControl-imageUpload {
cursor: pointer;
}

View File

@ -1,5 +0,0 @@
import withMediaControl from 'EditorWidgets/withMedia/withMediaControl';
const FileControl = withMediaControl();
export default FileControl;

View File

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

View File

@ -1,4 +0,0 @@
.nc-imagePreview-image {
max-width: 100%;
height: auto;
}

View File

@ -1,5 +0,0 @@
import withMediaControl from 'EditorWidgets/withMedia/withMediaControl';
const ImageControl = withMediaControl(true);
export default ImageControl;

View File

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

View File

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

View File

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

View File

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

View File

@ -1,4 +0,0 @@
@import "./MarkdownControl/RawEditor/index.css";
@import "./MarkdownControl/Toolbar/Toolbar.css";
@import "./MarkdownControl/Toolbar/ToolbarButton.css";
@import "./MarkdownControl/VisualEditor/index.css";

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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 => `![${ data.alt }](${ data.image })`,
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 = `
![super](duper.jpg)
`;
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](test.png)
{{< 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();
});
});

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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> &#x26; <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>![alt text](https://pbs.twimg.com/profile_images/678903331176214528/TQTdqGwD.jpg)</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>"
`;

View File

@ -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
![alt text](https://pbs.twimg.com/profile_images/678903331176214528/TQTdqGwD.jpg)
###### 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();
});
});
});

View File

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

View File

@ -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('&lt;div&gt;')).toEqual('&lt;div&gt;');
});
});

View File

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

View File

@ -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('![a](b)')).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]');
});
});

View File

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

View File

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

View File

@ -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>&lt;div&gt;</code>')).toEqual('<code>&lt;div&gt;</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('**![a](b)**')).toEqual('**![a](b)**');
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 ![b](c)')).toEqual('a ![b](c)');
});
it('should not escape markdown entities in html', () => {
expect(process('<span>*</span>')).toEqual('<span>*</span>');
});
});

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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 = `![${alt}](${url}${title ? ' title' : ''})`;
child.children = [{ type: 'text', value }];
}
return child;
});
return { ...node, children };
}
}

View File

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

View File

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

View File

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

View File

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

View File

@ -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:
*
* ```
* ![alpha](http://example.com/example.jpg)
* ```
*
*/
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) };
}
}

View File

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