diff --git a/__mocks__/fileLoaderMock.js b/__mocks__/fileLoaderMock.js new file mode 100644 index 00000000..35a63a0e --- /dev/null +++ b/__mocks__/fileLoaderMock.js @@ -0,0 +1,3 @@ +// See http://facebook.github.io/jest/docs/tutorial-webpack.html#content + +module.exports = 'test-file-stub'; diff --git a/__mocks__/styleLoaderMock.js b/__mocks__/styleLoaderMock.js new file mode 100644 index 00000000..bade080f --- /dev/null +++ b/__mocks__/styleLoaderMock.js @@ -0,0 +1,3 @@ +// See http://facebook.github.io/jest/docs/tutorial-webpack.html#content + +module.exports = {}; diff --git a/package.json b/package.json index f819f24f..643db793 100644 --- a/package.json +++ b/package.json @@ -32,6 +32,13 @@ ] }, "pre-commit": "lint:staged", + "jest": { + "moduleNameMapper": { + "^.+\\.(png|eot|woff|woff2|ttf|svg|gif)$": "/__mocks__/fileLoaderMock.js", + "^.+\\.scss$": "/__mocks__/styleLoaderMock.js", + "^.+\\.css$": "identity-obj-proxy" + } + }, "keywords": [ "netlify", "cms" @@ -39,8 +46,8 @@ "author": "Netlify", "license": "MIT", "devDependencies": { - "@kadira/storybook": "^1.36.0", "babel-core": "^6.5.1", + "babel-jest": "^15.0.0", "babel-loader": "^6.2.2", "babel-plugin-lodash": "^3.2.0", "babel-plugin-transform-class-properties": "^6.5.2", @@ -50,11 +57,14 @@ "babel-preset-react": "^6.5.0", "babel-runtime": "^6.5.0", "css-loader": "^0.23.1", + "enzyme": "^2.4.1", "eslint": "^3.7.1", "eslint-config-netlify": "github:netlify/eslint-config-netlify", "expect": "^1.20.2", "exports-loader": "^0.6.3", "file-loader": "^0.8.5", + "fsevents": "^1.0.14", + "identity-obj-proxy": "^3.0.0", "imports-loader": "^0.6.5", "jest-cli": "^16.0.1", "lint-staged": "^3.1.0", @@ -64,6 +74,7 @@ "postcss-import": "^8.1.2", "postcss-loader": "^0.9.1", "pre-commit": "^1.1.3", + "react-addons-test-utils": "^15.3.2", "sass-loader": "^4.0.2", "style-loader": "^0.13.0", "stylefmt": "^4.3.1", @@ -79,6 +90,7 @@ "webpack-postcss-tools": "^1.1.1" }, "dependencies": { + "@kadira/storybook": "^1.36.0", "autoprefixer": "^6.3.3", "bricks.js": "^1.7.0", "dateformat": "^1.0.12", @@ -112,10 +124,12 @@ "react-toolbox": "^1.2.1", "react-waypoint": "^3.1.3", "redux": "^3.3.1", + "redux-notifications": "^2.1.1", "redux-thunk": "^1.0.3", "selection-position": "^1.0.0", "semaphore": "^1.0.5", - "slate": "^0.13.6", + "slate": "^0.14.14", + "slate-drop-or-paste-images": "^0.2.0", "whatwg-fetch": "^1.0.0" } } diff --git a/src/actions/editor.js b/src/actions/editor.js index c9e35448..37fb0f90 100644 --- a/src/actions/editor.js +++ b/src/actions/editor.js @@ -1,8 +1,16 @@ +import history from '../routing/history'; + export const SWITCH_VISUAL_MODE = 'SWITCH_VISUAL_MODE'; export function switchVisualMode(useVisualMode) { return { type: SWITCH_VISUAL_MODE, - payload: useVisualMode + payload: useVisualMode, + }; +} + +export function cancelEdit() { + return () => { + history.goBack(); }; } diff --git a/src/actions/entries.js b/src/actions/entries.js index bf5d5b46..bd604d93 100644 --- a/src/actions/entries.js +++ b/src/actions/entries.js @@ -1,7 +1,10 @@ +import { actions as notifActions } from 'redux-notifications'; import { currentBackend } from '../backends/backend'; import { getIntegrationProvider } from '../integrations'; import { getMedia, selectIntegration } from '../reducers'; +const { notifSend } = notifActions; + /* * Contant Declarations */ @@ -35,8 +38,8 @@ export function entryLoading(collection, slug) { type: ENTRY_REQUEST, payload: { collection: collection.get('name'), - slug: slug - } + slug, + }, }; } @@ -45,8 +48,8 @@ export function entryLoaded(collection, entry) { type: ENTRY_SUCCESS, payload: { collection: collection.get('name'), - entry: entry - } + entry, + }, }; } @@ -54,8 +57,8 @@ export function entriesLoading(collection) { return { type: ENTRIES_REQUEST, payload: { - collection: collection.get('name') - } + collection: collection.get('name'), + }, }; } @@ -64,9 +67,9 @@ export function entriesLoaded(collection, entries, pagination) { type: ENTRIES_SUCCESS, payload: { collection: collection.get('name'), - entries: entries, - page: pagination - } + entries, + page: pagination, + }, }; } @@ -75,7 +78,7 @@ export function entriesFailed(collection, error) { type: ENTRIES_FAILURE, error: 'Failed to load entries', payload: error.toString(), - meta: { collection: collection.get('name') } + meta: { collection: collection.get('name') }, }; } @@ -83,9 +86,9 @@ export function entryPersisting(collection, entry) { return { type: ENTRY_PERSIST_REQUEST, payload: { - collection: collection, - entry: entry - } + collectionName: collection.get('name'), + entrySlug: entry.get('slug'), + }, }; } @@ -93,52 +96,56 @@ export function entryPersisted(collection, entry) { return { type: ENTRY_PERSIST_SUCCESS, payload: { - collection: collection, - entry: entry - } + collectionName: collection.get('name'), + entrySlug: entry.get('slug'), + }, }; } export function entryPersistFail(collection, entry, error) { return { - type: ENTRIES_FAILURE, + type: ENTRY_PERSIST_FAILURE, error: 'Failed to persist entry', - payload: error.toString() + payload: { + collectionName: collection.get('name'), + entrySlug: entry.get('slug'), + error: error.toString(), + }, }; } export function emmptyDraftCreated(entry) { return { type: DRAFT_CREATE_EMPTY, - payload: entry + payload: entry, }; } export function searchingEntries(searchTerm) { return { type: SEARCH_ENTRIES_REQUEST, - payload: { searchTerm } + payload: { searchTerm }, }; } -export function SearchSuccess(searchTerm, entries, page) { +export function searchSuccess(searchTerm, entries, page) { return { type: SEARCH_ENTRIES_SUCCESS, payload: { searchTerm, entries, - page - } + page, + }, }; } -export function SearchFailure(searchTerm, error) { +export function searchFailure(searchTerm, error) { return { type: SEARCH_ENTRIES_FAILURE, payload: { searchTerm, - error - } + error, + }, }; } @@ -148,20 +155,20 @@ export function SearchFailure(searchTerm, error) { export function createDraftFromEntry(entry) { return { type: DRAFT_CREATE_FROM_ENTRY, - payload: entry + payload: entry, }; } export function discardDraft() { return { - type: DRAFT_DISCARD + type: DRAFT_DISCARD, }; } export function changeDraft(entry) { return { type: DRAFT_CHANGE, - payload: entry + payload: entry, }; } @@ -180,20 +187,22 @@ export function loadEntry(entry, collection, slug) { } else { getPromise = backend.lookupEntry(collection, slug); } - return getPromise.then((loadedEntry) => dispatch(entryLoaded(collection, loadedEntry))); + return getPromise.then(loadedEntry => dispatch(entryLoaded(collection, loadedEntry))); }; } export function loadEntries(collection, page = 0) { return (dispatch, getState) => { - if (collection.get('isFetching')) { return; } + if (collection.get('isFetching')) { + return; + } const state = getState(); const integration = selectIntegration(state, collection.get('name'), 'listEntries'); const provider = integration ? getIntegrationProvider(state.integrations, integration) : currentBackend(state.config); dispatch(entriesLoading(collection)); provider.listEntries(collection, page).then( - (response) => dispatch(entriesLoaded(collection, response.entries, response.pagination)), - (error) => dispatch(entriesFailed(collection, error)) + response => dispatch(entriesLoaded(collection, response.entries, response.pagination)), + error => dispatch(entriesFailed(collection, error)) ); }; } @@ -207,18 +216,31 @@ export function createEmptyDraft(collection) { }; } -export function persistEntry(collection, entry) { +export function persistEntry(collection, entryDraft) { return (dispatch, getState) => { const state = getState(); const backend = currentBackend(state.config); - const MediaProxies = entry.get('mediaFiles').map(path => getMedia(state, path)); + const mediaProxies = entryDraft.get('mediaFiles').map(path => getMedia(state, path)); + const entry = entryDraft.get('entry'); dispatch(entryPersisting(collection, entry)); - backend.persistEntry(state.config, collection, entry, MediaProxies.toJS()).then( - () => { + backend + .persistEntry(state.config, collection, entryDraft, mediaProxies.toJS()) + .then(() => { + dispatch(notifSend({ + message: 'Entry saved', + kind: 'success', + dismissAfter: 4000, + })); dispatch(entryPersisted(collection, entry)); - }, - (error) => dispatch(entryPersistFail(collection, entry, error)) - ); + }) + .catch((error) => { + dispatch(notifSend({ + message: 'Failed to persist entry', + kind: 'danger', + dismissAfter: 4000, + })); + dispatch(entryPersistFail(collection, entry, error)); + }); }; } @@ -228,12 +250,16 @@ export function searchEntries(searchTerm, page = 0) { let collections = state.collections.keySeq().toArray(); collections = collections.filter(collection => selectIntegration(state, collection, 'search')); const integration = selectIntegration(state, collections[0], 'search'); - if (!integration) console.warn('There isn\'t a search integration configured.'); - const provider = integration ? getIntegrationProvider(state.integrations, integration) : currentBackend(state.config); + if (!integration) { + dispatch(searchFailure(searchTerm, 'Search integration is not configured.')); + } + const provider = integration ? + getIntegrationProvider(state.integrations, integration) + : currentBackend(state.config); dispatch(searchingEntries(searchTerm)); provider.search(collections, searchTerm, page).then( - (response) => dispatch(SearchSuccess(searchTerm, response.entries, response.pagination)), - (error) => dispatch(SearchFailure(searchTerm, error)) + response => dispatch(searchSuccess(searchTerm, response.entries, response.pagination)), + error => dispatch(searchFailure(searchTerm, error)) ); }; } diff --git a/src/components/AppHeader/AppHeader.js b/src/components/AppHeader/AppHeader.js index 54ae8a3b..f9984d81 100644 --- a/src/components/AppHeader/AppHeader.js +++ b/src/components/AppHeader/AppHeader.js @@ -1,4 +1,5 @@ -import React from 'react'; +import React, { PropTypes } from 'react'; +import ImmutablePropTypes from 'react-immutable-proptypes'; import pluralize from 'pluralize'; import { IndexLink } from 'react-router'; import { Menu, MenuItem } from 'react-toolbox'; @@ -8,11 +9,20 @@ import styles from './AppHeader.css'; export default class AppHeader extends React.Component { - state = { - createMenuActive: false + static propTypes = { + collections: ImmutablePropTypes.orderedMap.isRequired, + commands: PropTypes.array.isRequired, // eslint-disable-line + defaultCommands: PropTypes.array.isRequired, // eslint-disable-line + runCommand: PropTypes.func.isRequired, + toggleNavDrawer: PropTypes.func.isRequired, + onCreateEntryClick: PropTypes.func.isRequired, }; - handleCreatePostClick = collectionName => { + state = { + createMenuActive: false, + }; + + handleCreatePostClick = (collectionName) => { const { onCreateEntryClick } = this.props; if (onCreateEntryClick) { onCreateEntryClick(collectionName); @@ -21,13 +31,13 @@ export default class AppHeader extends React.Component { handleCreateButtonClick = () => { this.setState({ - createMenuActive: true + createMenuActive: true, }); }; handleCreateMenuHide = () => { this.setState({ - createMenuActive: false + createMenuActive: false, }); }; @@ -37,41 +47,40 @@ export default class AppHeader extends React.Component { commands, defaultCommands, runCommand, - toggleNavDrawer + toggleNavDrawer, } = this.props; const { createMenuActive } = this.state; return ( Dashboard - { collections.valueSeq().map(collection => ) } diff --git a/src/components/ControlPane.js b/src/components/ControlPane.js deleted file mode 100644 index ef764b0a..00000000 --- a/src/components/ControlPane.js +++ /dev/null @@ -1,40 +0,0 @@ -import React, { PropTypes } from 'react'; -import ImmutablePropTypes from 'react-immutable-proptypes'; -import { resolveWidget } from './Widgets'; - -export default class ControlPane extends React.Component { - controlFor(field) { - const { entry, getMedia, onChange, onAddMedia, onRemoveMedia } = this.props; - const widget = resolveWidget(field.get('widget')); - const value = entry.getIn(['data', field.get('name')]); - if (!value) return null; - return
- - {React.createElement(widget.control, { - field: field, - value: value, - onChange: (value) => onChange(entry.setIn(['data', field.get('name')], value)), - onAddMedia: onAddMedia, - onRemoveMedia: onRemoveMedia, - getMedia: getMedia - })} -
; - } - - render() { - const { collection } = this.props; - if (!collection) { return null; } - return
- {collection.get('fields').map((field) =>
{this.controlFor(field)}
)} -
; - } -} - -ControlPane.propTypes = { - collection: ImmutablePropTypes.map.isRequired, - entry: ImmutablePropTypes.map.isRequired, - getMedia: PropTypes.func.isRequired, - onAddMedia: PropTypes.func.isRequired, - onChange: PropTypes.func.isRequired, - onRemoveMedia: PropTypes.func.isRequired, -}; diff --git a/src/components/ControlPanel/ControlPane.css b/src/components/ControlPanel/ControlPane.css new file mode 100644 index 00000000..328ddf27 --- /dev/null +++ b/src/components/ControlPanel/ControlPane.css @@ -0,0 +1,53 @@ +.control { + color: #7c8382; + position: relative; + padding: 20px 0; + + & input, + & textarea, + & select { + font-family: monospace; + display: block; + width: 100%; + padding: 0; + margin: 0; + border: none; + outline: 0; + box-shadow: none; + background: 0 0; + font-size: 18px; + color: #7c8382; + } +} +.label { + color: #AAB0AF; + font-size: 12px; + margin-bottom: 18px; +} +.widget { + border-bottom: 1px solid #e8eae8; + position: relative; + + &:after { + content: ''; + position: absolute; + left: 42px; + bottom: -7px; + width: 12px; + height: 12px; + background-color: #f2f5f4; + -webkit-transform: rotate(45deg); + transform: rotate(45deg); + z-index: 1; + border-right: 1px solid #e8eae8; + border-bottom: 1px solid #e8eae8; + } + + &:last-child { + border-bottom: none; + } + + &:last-child:after { + display: none; + } +} diff --git a/src/components/ControlPanel/ControlPane.js b/src/components/ControlPanel/ControlPane.js new file mode 100644 index 00000000..a450a054 --- /dev/null +++ b/src/components/ControlPanel/ControlPane.js @@ -0,0 +1,62 @@ +import React, { Component, PropTypes } from 'react'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import { resolveWidget } from '../Widgets'; +import styles from './ControlPane.css'; + +export default class ControlPane extends Component { + + controlFor(field) { + const { entry, getMedia, onChange, onAddMedia, onRemoveMedia } = this.props; + const widget = resolveWidget(field.get('widget')); + const fieldName = field.get('name'); + const value = entry.getIn(['data', fieldName]); + if (!value) return null; + return ( +
+ + { + React.createElement(widget.control, { + field, + value, + onChange: val => onChange(entry.setIn(['data', fieldName], val)), + onAddMedia, + onRemoveMedia, + getMedia, + }) + } +
+ ); + } + + render() { + const { collection } = this.props; + if (!collection) { + return null; + } + return ( +
+ { + collection + .get('fields') + .map(field => +
+ {this.controlFor(field)} +
+ ) + } +
+ ); + } +} + +ControlPane.propTypes = { + collection: ImmutablePropTypes.map.isRequired, + entry: ImmutablePropTypes.map.isRequired, + getMedia: PropTypes.func.isRequired, + onAddMedia: PropTypes.func.isRequired, + onChange: PropTypes.func.isRequired, + onRemoveMedia: PropTypes.func.isRequired, +}; diff --git a/src/components/EntryEditor.css b/src/components/EntryEditor.css deleted file mode 100644 index 9cacb875..00000000 --- a/src/components/EntryEditor.css +++ /dev/null @@ -1,26 +0,0 @@ -.entryEditor { - display: flex; - flex-direction: column; - height: 100%; -} -.container { - display: flex; - height: 100%; -} -.footer { - background: #fff; - height: 45px; - border-top: 1px solid #e8eae8; - padding: 10px 20px; - z-index: 10; -} -.controlPane { - width: 50%; - max-height: 100%; - overflow: auto; - padding: 0 20px; - border-right: 1px solid #e8eae8; -} -.previewPane { - width: 50%; -} diff --git a/src/components/EntryEditor.js b/src/components/EntryEditor.js deleted file mode 100644 index 68a0daeb..00000000 --- a/src/components/EntryEditor.js +++ /dev/null @@ -1,65 +0,0 @@ -import React, { PropTypes } from 'react'; -import ImmutablePropTypes from 'react-immutable-proptypes'; -import ControlPane from './ControlPane'; -import PreviewPane from './PreviewPane'; -import styles from './EntryEditor.css'; - -export default class EntryEditor extends React.Component { - - state = {}; - - componentDidMount() { - this.calculateHeight(); - window.addEventListener('resize', this.handleResize, false); - } - - componengWillUnmount() { - window.removeEventListener('resize', this.handleResize); - } - - handleResize = () => { - this.calculateHeight(); - }; - - calculateHeight() { - const height = window.innerHeight - 54; - console.log('setting height to %s', height); - this.setState({ height }); - } - - render() { - const { collection, entry, getMedia, onChange, onAddMedia, onRemoveMedia, onPersist } = this.props; - const { height } = this.state; - - return
-
-
- -
-
- -
-
-
- -
-
; - } -} - -EntryEditor.propTypes = { - collection: ImmutablePropTypes.map.isRequired, - entry: ImmutablePropTypes.map.isRequired, - getMedia: PropTypes.func.isRequired, - onAddMedia: PropTypes.func.isRequired, - onChange: PropTypes.func.isRequired, - onPersist: PropTypes.func.isRequired, - onRemoveMedia: PropTypes.func.isRequired, -}; diff --git a/src/components/EntryEditor/EntryEditor.css b/src/components/EntryEditor/EntryEditor.css new file mode 100644 index 00000000..4db0945e --- /dev/null +++ b/src/components/EntryEditor/EntryEditor.css @@ -0,0 +1,36 @@ +:root { + --defaultColorLight: #eee; + --backgroundColor: #fff; +} + +.root { + position: absolute; + top: 64px; + bottom: 0; + display: flex; + flex-direction: column; +} + +.container { + display: flex; + flex: 1; +} + +.footer { + flex: 0; + padding: 10px 20px; + border-top: 1px solid var(--defaultColorLight); + background: var(--backgroundColor); + text-align: right; +} + +.controlPane { + flex: 1; + overflow: auto; + padding: 0 20px; + border-right: 1px solid var(--defaultColorLight); +} + +.previewPane { + flex: 1; +} diff --git a/src/components/EntryEditor/EntryEditor.js b/src/components/EntryEditor/EntryEditor.js new file mode 100644 index 00000000..dfab685d --- /dev/null +++ b/src/components/EntryEditor/EntryEditor.js @@ -0,0 +1,65 @@ +import React, { PropTypes } from 'react'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import { ScrollSync, ScrollSyncPane } from '../ScrollSync'; +import ControlPane from '../ControlPanel/ControlPane'; +import PreviewPane from '../PreviewPane/PreviewPane'; +import Toolbar from './EntryEditorToolbar'; +import styles from './EntryEditor.css'; + +export default function EntryEditor( + { + collection, + entry, + getMedia, + onChange, + onAddMedia, + onRemoveMedia, + onPersist, + onCancelEdit, + }) { + return ( +
+ +
+ +
+ +
+
+
+ +
+
+
+
+ +
+
+ ); +} + +EntryEditor.propTypes = { + collection: ImmutablePropTypes.map.isRequired, + entry: ImmutablePropTypes.map.isRequired, + getMedia: PropTypes.func.isRequired, + onAddMedia: PropTypes.func.isRequired, + onChange: PropTypes.func.isRequired, + onPersist: PropTypes.func.isRequired, + onRemoveMedia: PropTypes.func.isRequired, + onCancelEdit: PropTypes.func.isRequired, +}; diff --git a/src/components/EntryEditor/EntryEditorToolbar.js b/src/components/EntryEditor/EntryEditorToolbar.js new file mode 100644 index 00000000..a5e29ba1 --- /dev/null +++ b/src/components/EntryEditor/EntryEditorToolbar.js @@ -0,0 +1,35 @@ +import React, { PropTypes } from 'react'; +import { Button } from 'react-toolbox/lib/button'; + +const EntryEditorToolbar = ( + { + isPersisting, + onPersist, + onCancelEdit, + }) => { + const disabled = isPersisting; + return ( +
+ + {' '} + +
+ ); +}; + +EntryEditorToolbar.propTypes = { + isPersisting: PropTypes.bool, + onPersist: PropTypes.func.isRequired, + onCancelEdit: PropTypes.func.isRequired, +}; + +export default EntryEditorToolbar; diff --git a/src/components/EntryEditor/__tests__/EntryEditorToolbar.spec.js b/src/components/EntryEditor/__tests__/EntryEditorToolbar.spec.js new file mode 100644 index 00000000..e907c195 --- /dev/null +++ b/src/components/EntryEditor/__tests__/EntryEditorToolbar.spec.js @@ -0,0 +1,28 @@ +import React from 'react'; +import { shallow } from 'enzyme'; +import EntryEditorToolbar from '../EntryEditorToolbar'; + +describe('EntryEditorToolbar', () => { + it('should have both buttons enabled initially', () => { + const component = shallow( + {}} + onCancelEdit={() => {}} + /> + ); + const tree = component.html(); + expect(tree).toMatchSnapshot(); + }); + + it('should disable and update label of Save button when persisting', () => { + const component = shallow( + {}} + onCancelEdit={() => {}} + /> + ); + const tree = component.html(); + expect(tree).toMatchSnapshot(); + }); +}); diff --git a/src/components/EntryEditor/__tests__/__snapshots__/EntryEditorToolbar.spec.js.snap b/src/components/EntryEditor/__tests__/__snapshots__/EntryEditorToolbar.spec.js.snap new file mode 100644 index 00000000..071091dc --- /dev/null +++ b/src/components/EntryEditor/__tests__/__snapshots__/EntryEditorToolbar.spec.js.snap @@ -0,0 +1,3 @@ +exports[`EntryEditorToolbar should disable and update label of Save button when persisting 1`] = `"
"`; + +exports[`EntryEditorToolbar should have both buttons enabled initially 1`] = `"
"`; diff --git a/src/components/FindBar/FindBar.js b/src/components/FindBar/FindBar.js index effda752..fe670643 100644 --- a/src/components/FindBar/FindBar.js +++ b/src/components/FindBar/FindBar.js @@ -307,11 +307,11 @@ class FindBar extends Component { } return (
this.setIgnoreBlur(true)} - onMouseEnter={() => this.highlightCommandFromMouse(index)} - onClick={() => this.selectCommandFromMouse(command)} + className={this.state.highlightedIndex === index ? styles.highlightedCommand : styles.command} + key={command.token.trim().replace(/[^a-z0-9]+/gi, '-')} + onMouseDown={() => this.setIgnoreBlur(true)} + onMouseEnter={() => this.highlightCommandFromMouse(index)} + onClick={() => this.selectCommandFromMouse(command)} > {children}
@@ -345,15 +345,15 @@ class FindBar extends Component { {menu} diff --git a/src/components/MarkupItReactRenderer/__tests__/MarkupItReactRenderer.spec.js b/src/components/MarkupItReactRenderer/__tests__/MarkupItReactRenderer.spec.js new file mode 100644 index 00000000..cdf49cc5 --- /dev/null +++ b/src/components/MarkupItReactRenderer/__tests__/MarkupItReactRenderer.spec.js @@ -0,0 +1,249 @@ +/* eslint max-len:0 */ + +import React from 'react'; +import { shallow } from 'enzyme'; +import { padStart } from 'lodash'; +import { Map } from 'immutable'; +import MarkupIt from 'markup-it'; +import markdownSyntax from 'markup-it/syntaxes/markdown'; +import htmlSyntax from 'markup-it/syntaxes/html'; +import reInline from 'markup-it/syntaxes/markdown/re/inline'; +import MarkupItReactRenderer from '../'; + +describe('MarkitupReactRenderer', () => { + describe('basics', () => { + it('should re-render properly after a value and syntax update', () => { + const component = shallow( + + ); + const tree1 = component.html(); + component.setProps({ + value: '

Title

', + syntax: htmlSyntax, + }); + const tree2 = component.html(); + expect(tree1).toEqual(tree2); + }); + + it('should not update the parser if syntax didn\'t change', () => { + const component = shallow( + + ); + const syntax1 = component.instance().props.syntax; + component.setProps({ + value: '## Title', + }); + const syntax2 = component.instance().props.syntax; + expect(syntax1).toEqual(syntax2); + }); + }); + + 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( + + ); + 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( + + ); + 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( + + ); + 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( + + ); + expect(component.html()).toMatchSnapshot(); + }); + }); + + describe('Code', () => { + it('should render code', () => { + const value = 'Use the `printf()` function.'; + const component = shallow( + + ); + expect(component.html()).toMatchSnapshot(); + }); + + it('should render code 2', () => { + const value = '``There is a literal backtick (`) here.``'; + const component = shallow( + + ); + expect(component.html()).toMatchSnapshot(); + }); + }); + + describe('HTML', () => { + it('should render HTML as is when using Markdown', () => { + const value = ` +# Title + +
+ +
+
Test HTML content
+
Testing HTML in Markdown
+
+
+ +

Test

+`; + const component = shallow( + + ); + expect(component.html()).toMatchSnapshot(); + }); + }); + }); + + describe('custom elements', () => { + it('should extend default renderers with custom ones', () => { + const myRule = MarkupIt.Rule('mediaproxy') // eslint-disable-line + .regExp(reInline.link, (state, match) => { + if (match[0].charAt(0) !== '!') { + return null; + } + + return { + data: Map({ + alt: match[1], + src: match[2], + title: match[3], + }).filter(Boolean), + }; + }); + + const myCustomSchema = { + mediaproxy: ({ token }) => { //eslint-disable-line + const src = token.getIn(['data', 'src']); + const alt = token.getIn(['data', 'alt']); + return {alt}; + }, + }; + + const myMarkdownSyntax = markdownSyntax.addInlineRules(myRule); + const value = ` +## Title + +![mediaproxy test](http://url.to.image) +`; + const component = shallow( + + ); + expect(component.html()).toMatchSnapshot(); + }); + }); + + describe('HTML rendering', () => { + it('should render HTML', () => { + const value = '

Paragraph with inline element

'; + const component = shallow( + + ); + expect(component.html()).toMatchSnapshot(); + }); + }); +}); diff --git a/src/components/MarkupItReactRenderer/__tests__/__snapshots__/MarkupItReactRenderer.spec.js.snap b/src/components/MarkupItReactRenderer/__tests__/__snapshots__/MarkupItReactRenderer.spec.js.snap new file mode 100644 index 00000000..82d17c3b --- /dev/null +++ b/src/components/MarkupItReactRenderer/__tests__/__snapshots__/MarkupItReactRenderer.spec.js.snap @@ -0,0 +1,40 @@ +exports[`MarkitupReactRenderer HTML rendering should render HTML 1`] = `"

Paragraph with inline element

"`; + +exports[`MarkitupReactRenderer Markdown rendering Code should render code 1`] = `"

Use the printf() function.

"`; + +exports[`MarkitupReactRenderer Markdown rendering Code should render code 2 1`] = `"

There is a literal backtick (\`) here.

"`; + +exports[`MarkitupReactRenderer Markdown rendering General should render markdown 1`] = `"

H1

Text with bold & em elements

H2

  • ul item 1
  • ul item 2

H3

  1. ol item 1
  2. ol item 2
  3. ol item 3

H4

link title

H5

\"alt

H6
"`; + +exports[`MarkitupReactRenderer Markdown rendering HTML should render HTML as is when using Markdown 1`] = ` +"

Title

+ +
+
Test HTML content
+
Testing HTML in Markdown
+
+
+ +

Test

+
" +`; + +exports[`MarkitupReactRenderer Markdown rendering Headings should render Heading 1 1`] = `"

Title

"`; + +exports[`MarkitupReactRenderer Markdown rendering Headings should render Heading 2 1`] = `"

Title

"`; + +exports[`MarkitupReactRenderer Markdown rendering Headings should render Heading 3 1`] = `"

Title

"`; + +exports[`MarkitupReactRenderer Markdown rendering Headings should render Heading 4 1`] = `"

Title

"`; + +exports[`MarkitupReactRenderer Markdown rendering Headings should render Heading 5 1`] = `"
Title
"`; + +exports[`MarkitupReactRenderer Markdown rendering Headings should render Heading 6 1`] = `"
Title
"`; + +exports[`MarkitupReactRenderer Markdown rendering Links should render links 1`] = `""`; + +exports[`MarkitupReactRenderer Markdown rendering Lists should render lists 1`] = `"
  1. ol item 1
  2. ol item 2
    • Sublist 1
    • Sublist 2
    • Sublist 3
      1. Sub-Sublist 1
      2. Sub-Sublist 2
      3. Sub-Sublist 3
  3. ol item 3
"`; + +exports[`MarkitupReactRenderer custom elements should extend default renderers with custom ones 1`] = `"

Title

\"mediaproxy

"`; diff --git a/src/components/MarkupItReactRenderer/index.js b/src/components/MarkupItReactRenderer/index.js new file mode 100644 index 00000000..24c64d4c --- /dev/null +++ b/src/components/MarkupItReactRenderer/index.js @@ -0,0 +1,106 @@ +import React, { PropTypes } from 'react'; +import MarkupIt, { Syntax, BLOCKS, STYLES, ENTITIES } from 'markup-it'; +import { omit } from 'lodash'; + +const defaultSchema = { + [BLOCKS.DOCUMENT]: 'article', + [BLOCKS.TEXT]: null, + [BLOCKS.CODE]: 'code', + [BLOCKS.BLOCKQUOTE]: 'blockquote', + [BLOCKS.PARAGRAPH]: 'p', + [BLOCKS.FOOTNOTE]: 'footnote', + [BLOCKS.HTML]: ({ token }) => { + return
; + }, + [BLOCKS.HR]: 'hr', + [BLOCKS.HEADING_1]: 'h1', + [BLOCKS.HEADING_2]: 'h2', + [BLOCKS.HEADING_3]: 'h3', + [BLOCKS.HEADING_4]: 'h4', + [BLOCKS.HEADING_5]: 'h5', + [BLOCKS.HEADING_6]: 'h6', + [BLOCKS.TABLE]: 'table', + [BLOCKS.TABLE_ROW]: 'tr', + [BLOCKS.TABLE_CELL]: 'td', + [BLOCKS.OL_LIST]: 'ol', + [BLOCKS.UL_LIST]: 'ul', + [BLOCKS.LIST_ITEM]: 'li', + + [STYLES.TEXT]: null, + [STYLES.BOLD]: 'strong', + [STYLES.ITALIC]: 'em', + [STYLES.CODE]: 'code', + [STYLES.STRIKETHROUGH]: 'del', + + [ENTITIES.LINK]: 'a', + [ENTITIES.IMAGE]: 'img', + [ENTITIES.FOOTNOTE_REF]: 'sup', + [ENTITIES.HARD_BREAK]: 'br' +}; + +const notAllowedAttributes = ['loose']; + +function sanitizeProps(props) { + return omit(props, notAllowedAttributes); +} + +function renderToken(schema, token, index = 0, key = '0') { + const type = token.get('type'); + const data = token.get('data'); + const text = token.get('text'); + const tokens = token.get('tokens'); + const nodeType = schema[type]; + key = `${key}.${index}`; + + // Only render if type is registered as renderer + if (typeof nodeType !== 'undefined') { + let children = null; + if (tokens.size) { + children = tokens.map((token, idx) => renderToken(schema, token, idx, key)); + } else if (type === 'text') { + children = text; + } + if (nodeType !== null) { + let props = { key, token }; + if (typeof nodeType !== 'function') { + props = { key, ...sanitizeProps(data.toJS()) }; + } + // If this is a react element + return React.createElement(nodeType, props, children); + } else { + // If this is a text node + return children; + } + } + return null; +} + +export default class MarkupItReactRenderer extends React.Component { + + constructor(props) { + super(props); + const { syntax } = props; + this.parser = new MarkupIt(syntax); + } + + componentWillReceiveProps(nextProps) { + if (nextProps.syntax != this.props.syntax) { + this.parser = new MarkupIt(nextProps.syntax); + } + } + + render() { + const { value, schema } = this.props; + const content = this.parser.toContent(value); + return renderToken({ ...defaultSchema, ...schema }, content.get('token')); + } +} + +MarkupItReactRenderer.propTypes = { + value: PropTypes.string, + syntax: PropTypes.instanceOf(Syntax).isRequired, + schema: PropTypes.objectOf(PropTypes.oneOfType([ + PropTypes.string, + PropTypes.func + ])) +}; diff --git a/src/components/PreviewPane.js b/src/components/PreviewPane.js deleted file mode 100644 index 7ea5d31f..00000000 --- a/src/components/PreviewPane.js +++ /dev/null @@ -1,84 +0,0 @@ -import React, { PropTypes } from 'react'; -import { render } from 'react-dom'; -import ImmutablePropTypes from 'react-immutable-proptypes'; -import registry from '../lib/registry'; -import { resolveWidget } from './Widgets'; -import styles from './PreviewPane.css'; - -class Preview extends React.Component { - static propTypes = { - collection: ImmutablePropTypes.map.isRequired, - entry: ImmutablePropTypes.map.isRequired, - getMedia: PropTypes.func.isRequired, - }; - - previewFor(field) { - const { entry, getMedia } = this.props; - const widget = resolveWidget(field.get('widget')); - return React.createElement(widget.preview, { - field: field, - value: entry.getIn(['data', field.get('name')]), - getMedia: getMedia, - }); - } - - render() { - const { collection } = this.props; - if (!collection) { return null; } - - return
- {collection.get('fields').map((field) =>
{this.previewFor(field)}
)} -
; - } -} - -export default class PreviewPane extends React.Component { - componentDidUpdate() { - this.renderPreview(); - } - - widgetFor = name => { - const { collection, entry, getMedia } = this.props; - const field = collection.get('fields').find((field) => field.get('name') === name); - const widget = resolveWidget(field.get('widget')); - return React.createElement(widget.preview, { - field: field, - value: entry.getIn(['data', field.get('name')]), - getMedia: getMedia, - }); - }; - - renderPreview() { - const props = Object.assign({}, this.props, { widgetFor: this.widgetFor }); - const component = registry.getPreviewTemplate(props.collection.get('name')) || Preview; - - render(React.createElement(component, props), this.previewEl); - } - - handleIframeRef = ref => { - if (ref) { - registry.getPreviewStyles().forEach((style) => { - const linkEl = document.createElement('link'); - linkEl.setAttribute('rel', 'stylesheet'); - linkEl.setAttribute('href', style); - ref.contentDocument.head.appendChild(linkEl); - }); - this.previewEl = document.createElement('div'); - ref.contentDocument.body.appendChild(this.previewEl); - this.renderPreview(); - } - }; - - render() { - const { collection } = this.props; - if (!collection) { return null; } - - return ; - } -} - -PreviewPane.propTypes = { - collection: ImmutablePropTypes.map.isRequired, - entry: ImmutablePropTypes.map.isRequired, - getMedia: PropTypes.func.isRequired, -}; diff --git a/src/components/PreviewPane/Preview.js b/src/components/PreviewPane/Preview.js new file mode 100644 index 00000000..86f03071 --- /dev/null +++ b/src/components/PreviewPane/Preview.js @@ -0,0 +1,21 @@ +import React, { PropTypes } from 'react'; +import ImmutablePropTypes from 'react-immutable-proptypes'; + +export default function Preview({ collection, widgetFor }) { + if (!collection) { + return null; + } + + return ( +
+ {collection.get('fields').map(field => widgetFor(field.get('name')))} +
+ ); +} + +Preview.propTypes = { + collection: ImmutablePropTypes.map.isRequired, + entry: ImmutablePropTypes.map.isRequired, + getMedia: PropTypes.func.isRequired, + widgetFor: PropTypes.func.isRequired, +}; diff --git a/src/components/PreviewPane.css b/src/components/PreviewPane/PreviewPane.css similarity index 78% rename from src/components/PreviewPane.css rename to src/components/PreviewPane/PreviewPane.css index af8c2001..6bf62a0a 100644 --- a/src/components/PreviewPane.css +++ b/src/components/PreviewPane/PreviewPane.css @@ -1,6 +1,6 @@ .frame { width: 100%; - height: 100vh; + height: 100%; border: none; background: #fff; } diff --git a/src/components/PreviewPane/PreviewPane.js b/src/components/PreviewPane/PreviewPane.js new file mode 100644 index 00000000..8134d0a8 --- /dev/null +++ b/src/components/PreviewPane/PreviewPane.js @@ -0,0 +1,75 @@ +import React, { PropTypes } from 'react'; +import ReactDOM from 'react-dom'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import { ScrollSyncPane } from '../ScrollSync'; +import registry from '../../lib/registry'; +import { resolveWidget } from '../Widgets'; +import Preview from './Preview'; +import styles from './PreviewPane.css'; + +export default class PreviewPane extends React.Component { + + componentDidUpdate() { + this.renderPreview(); + } + + widgetFor = name => { + const { collection, entry, getMedia } = this.props; + const field = collection.get('fields').find((field) => field.get('name') === name); + const widget = resolveWidget(field.get('widget')); + return React.createElement(widget.preview, { + key: field.get('name'), + value: entry.getIn(['data', field.get('name')]), + field, + getMedia, + }); + }; + + renderPreview() { + const component = registry.getPreviewTemplate(this.props.collection.get('name')) || Preview; + const previewProps = { + ...this.props, + widgetFor: this.widgetFor + }; + // We need to use this API in order to pass context to the iframe + ReactDOM.unstable_renderSubtreeIntoContainer( + this, + + {React.createElement(component, previewProps)} + + , this.previewEl); + } + + handleIframeRef = ref => { + if (ref) { + registry.getPreviewStyles().forEach((style) => { + const linkEl = document.createElement('link'); + linkEl.setAttribute('rel', 'stylesheet'); + linkEl.setAttribute('href', style); + ref.contentDocument.head.appendChild(linkEl); + }); + this.previewEl = document.createElement('div'); + this.iframeBody = ref.contentDocument.body; + this.iframeBody.appendChild(this.previewEl); + this.renderPreview(); + } + }; + + render() { + const { collection } = this.props; + if (!collection) { + return null; + } + + return ; + } +} + +PreviewPane.propTypes = { + collection: ImmutablePropTypes.map.isRequired, + entry: ImmutablePropTypes.map.isRequired, + getMedia: PropTypes.func.isRequired, + scrollTop: PropTypes.number, + scrollHeight: PropTypes.number, + offsetHeight: PropTypes.number, +}; diff --git a/src/components/ScrollSync/ScrollSync.js b/src/components/ScrollSync/ScrollSync.js new file mode 100644 index 00000000..35ce2fc4 --- /dev/null +++ b/src/components/ScrollSync/ScrollSync.js @@ -0,0 +1,81 @@ +import React, { Component, PropTypes } from 'react'; +import { without } from 'lodash'; + +export default class ScrollSync extends Component { + + static propTypes = { + children: PropTypes.element.isRequired, + }; + + static childContextTypes = { + registerPane: PropTypes.func, + unregisterPane: PropTypes.func, + }; + + panes = []; + + getChildContext() { + return { + registerPane: this.registerPane, + unregisterPane: this.unregisterPane, + }; + } + + registerPane = node => { + if (!this.findPane(node)) { + this.addEvents(node); + this.panes.push(node); + } + }; + + unregisterPane = node => { + if (this.findPane(node)) { + this.removeEvents(node); + this.panes = without(this.panes, node); + } + }; + + addEvents = node => { + node.onscroll = this.handlePaneScroll.bind(this, node); + // node.addEventListener('scroll', this.handlePaneScroll, false) + }; + + removeEvents = node => { + node.onscroll = null; + // node.removeEventListener('scroll', this.handlePaneScroll, false) + }; + + findPane = node => { + return this.panes.find(p => p === node); + }; + + handlePaneScroll = node => { + // const node = evt.target + window.requestAnimationFrame(() => { + this.syncScrollPositions(node); + }); + }; + + syncScrollPositions = scrolledPane => { + const { scrollTop, scrollHeight, clientHeight } = scrolledPane; + this.panes.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); + /* Calculate the actual pane height */ + const paneHeight = pane.scrollHeight - clientHeight; + /* Adjust the scrollTop position of it accordingly */ + pane.scrollTop = paneHeight * scrollTop / (scrollHeight - clientHeight); + /* Re-attach event listeners after we're done scrolling */ + window.requestAnimationFrame(() => { + this.addEvents(pane); + }); + } + }); + }; + + render() { + return React.Children.only(this.props.children); + } +} diff --git a/src/components/ScrollSync/ScrollSyncPane.js b/src/components/ScrollSync/ScrollSyncPane.js new file mode 100644 index 00000000..840edf59 --- /dev/null +++ b/src/components/ScrollSync/ScrollSyncPane.js @@ -0,0 +1,28 @@ +import { Component, PropTypes } from 'react'; +import ReactDOM from 'react-dom'; + +export default class ScrollSyncPane extends Component { + + static propTypes = { + children: PropTypes.node.isRequired, + attachTo: PropTypes.any + }; + + static contextTypes = { + registerPane: PropTypes.func.isRequired, + unregisterPane: PropTypes.func.isRequired, + }; + + componentDidMount() { + this.node = this.props.attachTo || ReactDOM.findDOMNode(this); + this.context.registerPane(this.node); + } + + componentWillUnmount() { + this.context.unregisterPane(this.node); + } + + render() { + return this.props.children; + } +} diff --git a/src/components/ScrollSync/index.js b/src/components/ScrollSync/index.js new file mode 100644 index 00000000..5a50c651 --- /dev/null +++ b/src/components/ScrollSync/index.js @@ -0,0 +1,2 @@ +export { default as ScrollSync } from './ScrollSync'; +export { default as ScrollSyncPane } from './ScrollSyncPane'; diff --git a/src/components/UI/loader/Loader.js b/src/components/UI/loader/Loader.js index 26f25956..0c698008 100644 --- a/src/components/UI/loader/Loader.js +++ b/src/components/UI/loader/Loader.js @@ -36,9 +36,9 @@ export default class Loader extends React.Component { this.setAnimation(); return
{children[currentItem]}
diff --git a/src/components/UI/theme.css b/src/components/UI/theme.css index 87c78c64..e2a167aa 100644 --- a/src/components/UI/theme.css +++ b/src/components/UI/theme.css @@ -2,10 +2,13 @@ --defaultColor: #333; --defaultColorLight: #eee; --backgroundColor: #fff; - --shadowColor: rgba(0, 0, 0, 0.117647); + --shadowColor: rgba(0, 0, 0, .25); + --infoColor: #69c; --successColor: #1c7; --warningColor: #fa0; --errorColor: #f52; + --borderRadius: 2px; + --topmostZindex: 99999; } .base { @@ -13,14 +16,14 @@ } .container { - color: var(--defaultColor); background-color: var(--backgroundColor); + color: var(--defaultColor); } .rounded { - border-radius: 2px; + border-radius: var(--borderRadius); } .depth { - box-shadow: var(--shadowColor) 0px 1px 6px, var(--shadowColor) 0px 1px 4px; + box-shadow: var(--shadowColor) 0 1px 6px; } diff --git a/src/components/UI/toast/Toast.css b/src/components/UI/toast/Toast.css index 2c5bb930..9c8dfa4e 100644 --- a/src/components/UI/toast/Toast.css +++ b/src/components/UI/toast/Toast.css @@ -1,40 +1,41 @@ -@import "../theme.css"; +@import '../theme.css'; -.toast { - composes: base container rounded depth; - position: absolute; - top: 10px; - right: 10px; - z-index: 100; - width: 350px; - padding: 20px 10px; - font-size: 0.9rem; - text-align: center; - color: var(--defaultColorLight); - overflow: hidden; - opacity: 1; - transition: opacity .3s ease-in; +:root { + --iconSize: 30px; } -.hidden { - opacity: 0; +.root { + composes: base container rounded depth from '../theme.css'; + overflow: hidden; + margin: 10px; + padding: 10px 10px 15px; + color: var(--defaultColorLight); } .icon { - position: absolute; - top: calc(50% - 15px); - left: 15px; - font-size: 30px; + position: relative; + top: .15em; + margin-right: .25em; + font-size: var(--iconSize); + line-height: var(--iconSize); +} + +.info { + composes: root; + background-color: var(--infoColor); } .success { + composes: root; background-color: var(--successColor); } .warning { + composes: root; background-color: var(--warningColor); } -.error { +.danger { + composes: root; background-color: var(--errorColor); } diff --git a/src/components/UI/toast/Toast.js b/src/components/UI/toast/Toast.js index e50f2d06..36157815 100644 --- a/src/components/UI/toast/Toast.js +++ b/src/components/UI/toast/Toast.js @@ -2,69 +2,23 @@ import React, { PropTypes } from 'react'; import { Icon } from '../index'; import styles from './Toast.css'; -export default class Toast extends React.Component { +const icons = { + info: 'info', + success: 'check', + warning: 'attention', + danger: 'alert', +}; - state = { - shown: false - }; - - componentWillMount() { - if (this.props.show) { - this.autoHideTimeout(); - this.setState({ shown: true }); - } - } - - componentWillReceiveProps(nextProps) { - if (nextProps !== this.props) { - if (nextProps.show) this.autoHideTimeout(); - this.setState({ shown: nextProps.show }); - } - } - - componentWillUnmount() { - if (this.timeOut) { - clearTimeout(this.timeOut); - } - } - - autoHideTimeout = () => { - clearTimeout(this.timeOut); - this.timeOut = setTimeout(() => { - this.setState({ shown: false }); - }, 4000); - }; - - render() { - const { style, type, className, children } = this.props; - const icons = { - success: 'check', - warning: 'attention', - error: 'alert' - }; - const classes = [styles.toast]; - if (className) classes.push(className); - - let icon = ''; - if (type) { - classes.push(styles[type]); - icon = ; - } - - if (!this.state.shown) { - classes.push(styles.hidden); - } - - return ( -
{icon}{children}
- ); - } +export default function Toast({ kind, message }) { + return ( +
+ + {message} +
+ ); } Toast.propTypes = { - style: PropTypes.object, - type: PropTypes.oneOf(['success', 'warning', 'error']).isRequired, - className: PropTypes.string, - show: PropTypes.bool, - children: PropTypes.node + kind: PropTypes.oneOf(['info', 'success', 'warning', 'danger']).isRequired, + message: PropTypes.string, }; diff --git a/src/components/Widgets/ImageControl.js b/src/components/Widgets/ImageControl.js index 8db5f911..a7fc358c 100644 --- a/src/components/Widgets/ImageControl.js +++ b/src/components/Widgets/ImageControl.js @@ -63,19 +63,19 @@ export default class ImageControl extends React.Component { const imageName = this.renderImageName(); return (
{imageName ? imageName : 'Tip: Click here to upload an image from your file browser, or drag an image directly into this box from your desktop'}
); diff --git a/src/components/Widgets/MarkdownControl.js b/src/components/Widgets/MarkdownControl.js index 4d517e7e..94cfbb43 100644 --- a/src/components/Widgets/MarkdownControl.js +++ b/src/components/Widgets/MarkdownControl.js @@ -16,10 +16,6 @@ class MarkdownControl extends React.Component { value: PropTypes.node, }; - static contextTypes = { - plugins: PropTypes.object, - }; - componentWillMount() { this.useRawEditor(); processEditorPlugins(registry.getEditorComponents()); @@ -33,18 +29,18 @@ class MarkdownControl extends React.Component { this.props.switchVisualMode(false); }; - renderEditor() { + render() { const { editor, onChange, onAddMedia, getMedia, value } = this.props; if (editor.get('useVisualMode')) { return (
{null && }
); @@ -53,24 +49,15 @@ class MarkdownControl extends React.Component {
{null && }
); } } - - render() { - return ( -
- - {this.renderEditor()} -
- ); - } } export default connect( diff --git a/src/components/Widgets/MarkdownControlElements/RawEditor/index.css b/src/components/Widgets/MarkdownControlElements/RawEditor/index.css index e69de29b..eca2a8d4 100644 --- a/src/components/Widgets/MarkdownControlElements/RawEditor/index.css +++ b/src/components/Widgets/MarkdownControlElements/RawEditor/index.css @@ -0,0 +1,13 @@ +.root { + font-family: monospace; + display: block; + width: 100%; + padding: 0; + margin: 0; + border: none; + outline: 0; + box-shadow: none; + background: 0 0; + font-size: 18px; + color: #7c8382; +} diff --git a/src/components/Widgets/MarkdownControlElements/RawEditor/index.js b/src/components/Widgets/MarkdownControlElements/RawEditor/index.js index e8cacbcb..0b5105a3 100644 --- a/src/components/Widgets/MarkdownControlElements/RawEditor/index.js +++ b/src/components/Widgets/MarkdownControlElements/RawEditor/index.js @@ -1,7 +1,10 @@ import React, { PropTypes } from 'react'; import { Editor, Plain, Mark } from 'slate'; import Prism from 'prismjs'; +import PluginDropImages from 'slate-drop-or-paste-images'; +import MediaProxy from '../../../../valueObjects/MediaProxy'; import marks from './prismMarkdown'; +import styles from './index.css'; Prism.languages.markdown = Prism.languages.extend('markup', {}); Prism.languages.insertBefore('markdown', 'prolog', marks); @@ -41,7 +44,6 @@ function renderDecorations(text, block) { return characters.asImmutable(); } - const SCHEMA = { rules: [ { @@ -70,7 +72,15 @@ const SCHEMA = { } }; -class RawEditor extends React.Component { +export default class RawEditor extends React.Component { + + static propTypes = { + onAddMedia: PropTypes.func.isRequired, + getMedia: PropTypes.func.isRequired, + onChange: PropTypes.func.isRequired, + value: PropTypes.string, + }; + constructor(props) { super(props); const content = props.value ? Plain.deserialize(props.value) : Plain.deserialize(''); @@ -78,12 +88,24 @@ class RawEditor extends React.Component { this.state = { state: content }; + + this.plugins = [ + PluginDropImages({ + applyTransform: (transform, file) => { + const mediaProxy = new MediaProxy(file.name, file); + const state = Plain.deserialize(`\n\n![${file.name}](${mediaProxy.public_path})\n\n`); + props.onAddMedia(mediaProxy); + return transform + .insertFragment(state.get('document')); + } + }) + ]; } /** * Slate keeps track of selections, scroll position etc. * So, onChange gets dispatched on every interaction (click, arrows, everything...) - * It also have an onDocumentChange, that get's dispached only when the actual + * It also have an onDocumentChange, that get's dispatched only when the actual * content changes */ handleChange = state => { @@ -98,20 +120,14 @@ class RawEditor extends React.Component { render() { return ( ); } } - -export default RawEditor; - -RawEditor.propTypes = { - onChange: PropTypes.func.isRequired, - value: PropTypes.node, -}; diff --git a/src/components/Widgets/MarkdownControlElements/VisualEditor/BlockTypesMenu.js b/src/components/Widgets/MarkdownControlElements/VisualEditor/BlockTypesMenu.js index ef8a8e9f..33d5e532 100644 --- a/src/components/Widgets/MarkdownControlElements/VisualEditor/BlockTypesMenu.js +++ b/src/components/Widgets/MarkdownControlElements/VisualEditor/BlockTypesMenu.js @@ -1,25 +1,21 @@ import React, { Component, PropTypes } from 'react'; -import Portal from 'react-portal'; +import withPortalAtCursorPosition from './withPortalAtCursorPosition'; import { Icon } from '../../../UI'; import MediaProxy from '../../../../valueObjects/MediaProxy'; import styles from './BlockTypesMenu.css'; -export default class BlockTypesMenu extends Component { - constructor(props) { - super(props); +class BlockTypesMenu extends Component { - this.state = { - expanded: false, - menu: null - }; - } + static propTypes = { + plugins: PropTypes.array.isRequired, + onClickBlock: PropTypes.func.isRequired, + onClickPlugin: PropTypes.func.isRequired, + onClickImage: PropTypes.func.isRequired + }; - /** - * On update, update the menu. - */ - componentDidMount() { - this.updateMenuPosition(); - } + state = { + expanded: false + }; componentWillUpdate() { if (this.state.expanded) { @@ -27,21 +23,6 @@ export default class BlockTypesMenu extends Component { } } - componentDidUpdate() { - this.updateMenuPosition(); - } - - updateMenuPosition = () => { - const { menu } = this.state; - const { position } = this.props; - if (!menu) return; - - menu.style.opacity = 1; - menu.style.top = `${position.top}px`; - menu.style.left = `${position.left - menu.offsetWidth * 2}px`; - - }; - toggleMenu = () => { this.setState({ expanded: !this.state.expanded }); }; @@ -53,7 +34,7 @@ export default class BlockTypesMenu extends Component { handlePluginClick = (e, plugin) => { const data = {}; plugin.fields.forEach(field => { - data[field.name] = window.prompt(field.label); + data[field.name] = window.prompt(field.label); // eslint-disable-line }); this.props.onClickPlugin(plugin.id, data); }; @@ -87,14 +68,14 @@ export default class BlockTypesMenu extends Component { renderBlockTypeButton = (type, icon) => { const onClick = e => this.handleBlockTypeClick(e, type); return ( - + ); }; renderPluginButton = plugin => { const onClick = e => this.handlePluginClick(e, plugin); return ( - + ); }; @@ -105,13 +86,15 @@ export default class BlockTypesMenu extends Component {
{this.renderBlockTypeButton('hr', 'dot-3')} {plugins.map(plugin => this.renderPluginButton(plugin))} - + this._fileInput = el} + type="file" + accept="image/*" + onChange={this.handleFileUploadChange} + className={styles.input} + ref={el => { + this._fileInput = el; + }} />
); @@ -120,34 +103,14 @@ export default class BlockTypesMenu extends Component { } } - /** - * When the portal opens, cache the menu element. - */ - handleOpen = portal => { - this.setState({ menu: portal.firstChild }); - }; - render() { - const { isOpen } = this.props; return ( - -
- - {this.renderMenu()} -
-
+
+ + {this.renderMenu()} +
); } } -BlockTypesMenu.propTypes = { - isOpen: PropTypes.bool.isRequired, - plugins: PropTypes.array.isRequired, - position: PropTypes.shape({ - top: PropTypes.number.isRequired, - left: PropTypes.number.isRequired - }), - onClickBlock: PropTypes.func.isRequired, - onClickPlugin: PropTypes.func.isRequired, - onClickImage: PropTypes.func.isRequired -}; +export default withPortalAtCursorPosition(BlockTypesMenu); diff --git a/src/components/Widgets/MarkdownControlElements/VisualEditor/StylesMenu.js b/src/components/Widgets/MarkdownControlElements/VisualEditor/StylesMenu.js index ce667bb7..3ddf915d 100644 --- a/src/components/Widgets/MarkdownControlElements/VisualEditor/StylesMenu.js +++ b/src/components/Widgets/MarkdownControlElements/VisualEditor/StylesMenu.js @@ -1,36 +1,17 @@ import React, { Component, PropTypes } from 'react'; -import Portal from 'react-portal'; +import withPortalAtCursorPosition from './withPortalAtCursorPosition'; import { Icon } from '../../../UI'; import styles from './StylesMenu.css'; -export default class StylesMenu extends Component { - constructor(props) { - super(props); +class StylesMenu extends Component { - this.state = { - menu: null - }; - } - - /** - * On update, update the menu. - */ - componentDidMount() { - this.updateMenuPosition(); - } - - componentDidUpdate() { - this.updateMenuPosition(); - } - - updateMenuPosition = () => { - const { menu } = this.state; - const { position } = this.props; - if (!menu) return; - - menu.style.opacity = 1; - menu.style.top = `${position.top - menu.offsetHeight}px`; - menu.style.left = `${position.left - menu.offsetWidth / 2 + position.width / 2}px`; + static propTypes = { + marks: PropTypes.object.isRequired, + blocks: PropTypes.object.isRequired, + inlines: PropTypes.object.isRequired, + onClickBlock: PropTypes.func.isRequired, + onClickMark: PropTypes.func.isRequired, + onClickInline: PropTypes.func.isRequired }; /** @@ -46,10 +27,10 @@ export default class StylesMenu extends Component { return blocks.some(node => node.type == type); }; - hasLinks(type) { + hasLinks = type => { const { inlines } = this.props; return inlines.some(inline => inline.type == 'link'); - } + }; handleMarkClick = (e, type) => { e.preventDefault(); @@ -99,42 +80,20 @@ export default class StylesMenu extends Component { ); }; - /** - * When the portal opens, cache the menu element. - */ - handleOpen = portal => { - this.setState({ menu: portal.firstChild }); - }; - render() { - const { isOpen } = this.props; return ( - -
- {this.renderMarkButton('BOLD', 'bold')} - {this.renderMarkButton('ITALIC', 'italic')} - {this.renderMarkButton('CODE', 'code')} - {this.renderLinkButton()} - {this.renderBlockButton('header_one', 'h1')} - {this.renderBlockButton('header_two', 'h2')} - {this.renderBlockButton('blockquote', 'quote-left')} - {this.renderBlockButton('unordered_list', 'list-bullet', 'list_item')} -
-
+
+ {this.renderMarkButton('BOLD', 'bold')} + {this.renderMarkButton('ITALIC', 'italic')} + {this.renderMarkButton('CODE', 'code')} + {this.renderLinkButton()} + {this.renderBlockButton('header_one', 'h1')} + {this.renderBlockButton('header_two', 'h2')} + {this.renderBlockButton('blockquote', 'quote-left')} + {this.renderBlockButton('unordered_list', 'list-bullet', 'list_item')} +
); } } -StylesMenu.propTypes = { - isOpen: PropTypes.bool.isRequired, - position: PropTypes.shape({ - top: PropTypes.number.isRequired, - left: PropTypes.number.isRequired - }), - marks: PropTypes.object.isRequired, - blocks: PropTypes.object.isRequired, - inlines: PropTypes.object.isRequired, - onClickBlock: PropTypes.func.isRequired, - onClickMark: PropTypes.func.isRequired, - onClickInline: PropTypes.func.isRequired -}; +export default withPortalAtCursorPosition(StylesMenu); diff --git a/src/components/Widgets/MarkdownControlElements/VisualEditor/index.js b/src/components/Widgets/MarkdownControlElements/VisualEditor/index.js index c285ed61..053c8847 100644 --- a/src/components/Widgets/MarkdownControlElements/VisualEditor/index.js +++ b/src/components/Widgets/MarkdownControlElements/VisualEditor/index.js @@ -1,19 +1,27 @@ import React, { PropTypes } from 'react'; import _ from 'lodash'; import { Editor, Raw } from 'slate'; -import position from 'selection-position'; +import PluginDropImages from 'slate-drop-or-paste-images'; import MarkupIt, { SlateUtils } from 'markup-it'; -import { emptyParagraphBlock } from '../constants'; +import MediaProxy from '../../../../valueObjects/MediaProxy'; +import { emptyParagraphBlock, mediaproxyBlock } from '../constants'; import { DEFAULT_NODE, SCHEMA } from './schema'; import { getNodes, getSyntaxes, getPlugins } from '../../richText'; import StylesMenu from './StylesMenu'; import BlockTypesMenu from './BlockTypesMenu'; -//import styles from './index.css'; /** * Slate Render Configuration */ -class VisualEditor extends React.Component { +export default class VisualEditor extends React.Component { + + static propTypes = { + onChange: PropTypes.func.isRequired, + onAddMedia: PropTypes.func.isRequired, + getMedia: PropTypes.func.isRequired, + value: PropTypes.string, + }; + constructor(props) { super(props); @@ -23,25 +31,10 @@ class VisualEditor extends React.Component { SCHEMA.nodes = _.merge(SCHEMA.nodes, getNodes()); this.blockEdit = false; - this.menuPositions = { - stylesMenu: { - top: 0, - left: 0, - width: 0, - height: 0 - }, - blockTypesMenu: { - top: 0, - left: 0, - width: 0, - height: 0 - } - }; let rawJson; if (props.value !== undefined) { const content = this.markdown.toContent(props.value); - console.log('md: %o', content); rawJson = SlateUtils.encode(content, null, ['mediaproxy'].concat(getPlugins().map(plugin => plugin.id))); } else { rawJson = emptyParagraphBlock; @@ -50,8 +43,16 @@ class VisualEditor extends React.Component { state: Raw.deserialize(rawJson, { terse: true }) }; - this.calculateHoverMenuPosition = _.throttle(this.calculateHoverMenuPosition.bind(this), 30); - this.calculateBlockMenuPosition = _.throttle(this.calculateBlockMenuPosition.bind(this), 100); + this.plugins = [ + PluginDropImages({ + applyTransform: (transform, file) => { + const mediaProxy = new MediaProxy(file.name, file); + props.onAddMedia(mediaProxy); + return transform + .insertBlock(mediaproxyBlock(mediaProxy)); + } + }) + ]; } getMedia = src => { @@ -61,15 +62,14 @@ class VisualEditor extends React.Component { /** * Slate keeps track of selections, scroll position etc. * So, onChange gets dispatched on every interaction (click, arrows, everything...) - * It also have an onDocumentChange, that get's dispached only when the actual + * It also have an onDocumentChange, that get's dispatched only when the actual * content changes */ handleChange = state => { if (this.blockEdit) { this.blockEdit = false; } else { - this.calculateHoverMenuPosition(); - this.setState({ state }, this.calculateBlockMenuPosition); + this.setState({ state }); } }; @@ -79,32 +79,6 @@ class VisualEditor extends React.Component { this.props.onChange(this.markdown.toText(content)); }; - calculateHoverMenuPosition() { - const rect = position(); - this.menuPositions.stylesMenu = { - top: rect.top + window.scrollY, - left: rect.left + window.scrollX, - width: rect.width, - height: rect.height - }; - } - - calculateBlockMenuPosition() { - // Don't bother calculating position if block is not empty - if (this.state.state.blocks.get(0).isEmpty) { - const blockElement = document.querySelectorAll(`[data-key='${this.state.state.selection.focusKey}']`); - if (blockElement.length > 0) { - const rect = blockElement[0].getBoundingClientRect(); - this.menuPositions.blockTypesMenu = { - top: rect.top + window.scrollY, - left: rect.left + window.scrollX - }; - // Force re-render so the menu is positioned on these new coordinates - this.forceUpdate(); - } - } - } - /** * Toggle marks / blocks when button is clicked */ @@ -166,11 +140,11 @@ class VisualEditor extends React.Component { }; /** - * When clicking a link, if the selection has a link in it, remove the link. - * Otherwise, add a new link with an href and text. - * - * @param {Event} e - */ + * When clicking a link, if the selection has a link in it, remove the link. + * Otherwise, add a new link with an href and text. + * + * @param {Event} e + */ handleInlineClick = (type, isActive) => { let { state } = this.state; @@ -186,7 +160,7 @@ class VisualEditor extends React.Component { } else { - const href = window.prompt('Enter the URL of the link:', 'http://www.'); + const href = window.prompt('Enter the URL of the link:', 'http://www.'); // eslint-disable-line state = state .transform() .wrapInline({ @@ -204,12 +178,12 @@ class VisualEditor extends React.Component { let { state } = this.state; state = state - .transform() - .insertBlock({ - type: type, - isVoid: true - }) - .apply(); + .transform() + .insertBlock({ + type: type, + isVoid: true + }) + .apply(); this.setState({ state }, this.focusAndAddParagraph); }; @@ -238,14 +212,7 @@ class VisualEditor extends React.Component { state = state .transform() - .insertInline({ - type: 'mediaproxy', - isVoid: true, - data: { src: mediaProxy.public_path } - }) - .collapseToEnd() - .insertBlock(DEFAULT_NODE) - .focus() + .insertBlock(mediaproxyBlock(mediaProxy)) .apply(); this.setState({ state }); @@ -264,7 +231,7 @@ class VisualEditor extends React.Component { .apply({ snapshot: false }); - this.setState({ state:normalized }); + this.setState({ state: normalized }); }; handleKeyDown = evt => { @@ -272,9 +239,9 @@ class VisualEditor extends React.Component { this.blockEdit = true; let { state } = this.state; state = state - .transform() - .insertText(' \n') - .apply(); + .transform() + .insertText('\n') + .apply(); this.setState({ state }); } @@ -286,12 +253,11 @@ class VisualEditor extends React.Component { return ( ); }; @@ -302,14 +268,13 @@ class VisualEditor extends React.Component { return ( ); } @@ -320,23 +285,15 @@ class VisualEditor extends React.Component { {this.renderStylesMenu()} {this.renderBlockTypesMenu()}
); } } - -export default VisualEditor; - -VisualEditor.propTypes = { - onChange: PropTypes.func.isRequired, - onAddMedia: PropTypes.func.isRequired, - getMedia: PropTypes.func.isRequired, - value: PropTypes.node, -}; diff --git a/src/components/Widgets/MarkdownControlElements/VisualEditor/withPortalAtCursorPosition.js b/src/components/Widgets/MarkdownControlElements/VisualEditor/withPortalAtCursorPosition.js new file mode 100644 index 00000000..4aab4ea6 --- /dev/null +++ b/src/components/Widgets/MarkdownControlElements/VisualEditor/withPortalAtCursorPosition.js @@ -0,0 +1,59 @@ +import React from 'react'; +import Portal from 'react-portal'; +import position from 'selection-position'; + +export default function withPortalAtCursorPosition(WrappedComponent) { + return class extends React.Component { + + static propTypes = { + isOpen: React.PropTypes.bool.isRequired + } + + state = { + menu: null, + cursorPosition: null + } + + componentDidMount() { + this.adjustPosition(); + } + + componentDidUpdate() { + this.adjustPosition(); + } + + adjustPosition = () => { + const { menu } = this.state; + + if (!menu) return; + + const cursorPosition = position(); // TODO: Results aren't determenistic + const centerX = Math.ceil( + cursorPosition.left + + cursorPosition.width / 2 + + window.scrollX + - menu.offsetWidth / 2 + ); + const centerY = cursorPosition.top + window.scrollY; + menu.style.opacity = 1; + menu.style.top = `${centerY}px`; + menu.style.left = `${centerX}px`; + } + + /** + * When the portal opens, cache the menu element. + */ + handleOpen = (portal) => { + this.setState({ menu: portal.firstChild }); + } + + render() { + const { isOpen, ...rest } = this.props; + return ( + + + + ); + } + }; +} diff --git a/src/components/Widgets/MarkdownControlElements/constants.js b/src/components/Widgets/MarkdownControlElements/constants.js index 74779111..c4609e2c 100644 --- a/src/components/Widgets/MarkdownControlElements/constants.js +++ b/src/components/Widgets/MarkdownControlElements/constants.js @@ -1,6 +1,7 @@ export const emptyParagraphBlock = { nodes: [ - { kind: 'block', + { + kind: 'block', type: 'paragraph', nodes: [{ kind: 'text', @@ -11,3 +12,17 @@ export const emptyParagraphBlock = { } ] }; + +export const mediaproxyBlock = mediaproxy => ({ + kind: 'block', + type: 'paragraph', + nodes: [{ + kind: 'inline', + type: 'mediaproxy', + isVoid: true, + data: { + alt: mediaproxy.name, + src: mediaproxy.public_path + } + }] +}); diff --git a/src/components/Widgets/MarkdownPreview.js b/src/components/Widgets/MarkdownPreview.js index f9f0021c..3c84d884 100644 --- a/src/components/Widgets/MarkdownPreview.js +++ b/src/components/Widgets/MarkdownPreview.js @@ -1,28 +1,34 @@ import React, { PropTypes } from 'react'; -import MarkupIt from 'markup-it'; import { getSyntaxes } from './richText'; +import MarkupItReactRenderer from '../MarkupItReactRenderer/index'; -export default class MarkdownPreview extends React.Component { - - constructor(props) { - super(props); - - const { markdown, html } = getSyntaxes(); - this.markdown = new MarkupIt(markdown); - this.html = new MarkupIt(html); +const MarkdownPreview = ({ value, getMedia }) => { + if (value == null) { + return null; } - render() { - const { value } = this.props; - if (value == null) { return null; } - const content = this.markdown.toContent(value); - const contentHtml = { __html: this.html.toText(content) }; - return ( -
- ); - } -} + const schema = { + 'mediaproxy': ({ token }) => ( // eslint-disable-line + {token.getIn(['data', + ) + }; + + const { markdown } = getSyntaxes(); + return ( + + ); +}; MarkdownPreview.propTypes = { - value: PropTypes.node, + getMedia: PropTypes.func.isRequired, + value: PropTypes.string, }; + +export default MarkdownPreview; diff --git a/src/components/Widgets/richText.js b/src/components/Widgets/richText.js index 082631e0..e3ce7bd5 100644 --- a/src/components/Widgets/richText.js +++ b/src/components/Widgets/richText.js @@ -15,7 +15,6 @@ import { Icon } from '../UI'; let processedPlugins = List([]); - const nodes = {}; let augmentedMarkdownSyntax = markdownSyntax; let augmentedHTMLSyntax = htmlSyntax; @@ -27,7 +26,7 @@ function processEditorPlugins(plugins) { plugins.forEach(plugin => { const basicRule = MarkupIt.Rule(plugin.id).regExp(plugin.pattern, (state, match) => ( - { data: plugin.fromBlock(match) } + { data: plugin.fromBlock(match) } )); const markdownRule = basicRule.toText((state, token) => ( @@ -68,8 +67,8 @@ function processMediaProxyPlugins(getMedia) { } var imgData = Map({ - alt: match[1], - src: match[2], + alt: match[1], + src: match[2], title: match[3] }).filter(Boolean); @@ -78,9 +77,9 @@ function processMediaProxyPlugins(getMedia) { }; }); const mediaProxyMarkdownRule = mediaProxyRule.toText((state, token) => { - var data = token.getData(); - var alt = data.get('alt', ''); - var src = data.get('src', ''); + var data = token.getData(); + var alt = data.get('alt', ''); + var src = data.get('src', ''); var title = data.get('title', ''); if (title) { @@ -90,9 +89,9 @@ function processMediaProxyPlugins(getMedia) { } }); const mediaProxyHTMLRule = mediaProxyRule.toText((state, token) => { - var data = token.getData(); - var alt = data.get('alt', ''); - var src = data.get('src', ''); + var data = token.getData(); + var alt = data.get('alt', ''); + var src = data.get('src', ''); return `${alt}`; }); @@ -103,7 +102,7 @@ function processMediaProxyPlugins(getMedia) { const className = isFocused ? 'active' : null; const src = node.data.get('src'); return ( - + ); }; augmentedMarkdownSyntax = augmentedMarkdownSyntax.addInlineRules(mediaProxyMarkdownRule); @@ -111,9 +110,11 @@ function processMediaProxyPlugins(getMedia) { } function getPlugins() { - return processedPlugins.map(plugin => ( - { id: plugin.id, icon: plugin.icon, fields: plugin.fields } - )).toArray(); + return processedPlugins.map(plugin => ({ + id: plugin.id, + icon: plugin.icon, + fields: plugin.fields + })).toArray(); } function getNodes() { @@ -124,7 +125,7 @@ function getSyntaxes(getMedia) { if (getMedia) { processMediaProxyPlugins(getMedia); } - return { markdown: augmentedMarkdownSyntax, html:augmentedHTMLSyntax }; + return { markdown: augmentedMarkdownSyntax, html: augmentedHTMLSyntax }; } export { processEditorPlugins, getNodes, getSyntaxes, getPlugins }; diff --git a/src/components/stories/FindBar.js b/src/components/stories/FindBar.js index 812d2f8c..b2933b96 100644 --- a/src/components/stories/FindBar.js +++ b/src/components/stories/FindBar.js @@ -1,7 +1,7 @@ import React from 'react'; import { storiesOf, action } from '@kadira/storybook'; -import FindBar from '../UI/FindBar/FindBar'; +import FindBar from '../FindBar/FindBar'; const CREATE_COLLECTION = 'CREATE_COLLECTION'; const CREATE_POST = 'CREATE_POST'; diff --git a/src/components/stories/MarkupItReactRenderer.js b/src/components/stories/MarkupItReactRenderer.js new file mode 100644 index 00000000..2ba1a3ee --- /dev/null +++ b/src/components/stories/MarkupItReactRenderer.js @@ -0,0 +1,34 @@ +import React from 'react'; +import markdownSyntax from 'markup-it/syntaxes/markdown'; +import htmlSyntax from 'markup-it/syntaxes/html'; +import MarkupItReactRenderer from '../MarkupItReactRenderer'; +import { storiesOf } from '@kadira/storybook'; + +const mdContent = ` +# Title + +* List 1 +* List 2 +`; + +const htmlContent = ` +

Title

+
    +
  1. List item 1
  2. +
  3. List item 2
  4. +
+`; + +storiesOf('MarkupItReactRenderer', module) + .add('Markdown', () => ( + + + )).add('HTML', () => ( + + )); diff --git a/src/components/stories/ScrollSync.js b/src/components/stories/ScrollSync.js new file mode 100644 index 00000000..53a19b94 --- /dev/null +++ b/src/components/stories/ScrollSync.js @@ -0,0 +1,55 @@ +import React from 'react'; +import ScrollSync from '../ScrollSync/ScrollSync'; +import ScrollSyncPane from '../ScrollSync/ScrollSyncPane'; +import { storiesOf } from '@kadira/storybook'; + +const paneStyle = { + border: '1px solid green', + overflow: 'auto', +}; + +storiesOf('ScrollSync', module) + .add('Default', () => ( + +
+ +
+
+

Left Pane Content

+

+ Lorem ipsum dolor sit amet, consectetur adipisicing elit. Ab aperiam doloribus + dolorum est eum eveniet exercitationem iste labore minus, neque nobis odit officiis + omnis possimus quasi rerum sed soluta veritatis. +

+
+
+
+ + +
+
+

Right Pane Content

+

+ Lorem ipsum dolor sit amet, consectetur adipisicing elit. Ab aperiam doloribus + dolorum est eum eveniet exercitationem iste labore minus, neque nobis odit officiis + omnis possimus quasi rerum sed soluta veritatis. +

+
+
+
+ + +
+
+

Third Pane Content

+

+ Lorem ipsum dolor sit amet, consectetur adipisicing elit. Ab aperiam doloribus + dolorum est eum eveniet exercitationem iste labore minus, neque nobis odit officiis + omnis possimus quasi rerum sed soluta veritatis. +

+
+
+
+
+
+ )); diff --git a/src/components/stories/Toast.js b/src/components/stories/Toast.js index 6ac4b7c6..b93e4bf7 100644 --- a/src/components/stories/Toast.js +++ b/src/components/stories/Toast.js @@ -1,19 +1,41 @@ import React from 'react'; -import { Toast } from '../UI'; import { storiesOf } from '@kadira/storybook'; +import { Toast } from '../UI'; +const containerStyle = { + position: 'fixed', + top: 0, + right: 0, + width: 360, + height: '100%', +}; storiesOf('Toast', module) + .add('All kinds stacked', () => ( +
+ + + + +
+ )) + .add('Info', () => ( +
+ +
+ )) .add('Success', () => ( -
- A Toast Message +
+
- )).add('Waring', () => ( -
- A Toast Message + )) + .add('Waring', () => ( +
+
- )).add('Error', () => ( -
- A Toast Message + )) + .add('Error', () => ( +
+
)); diff --git a/src/components/stories/index.js b/src/components/stories/index.js index aef4bed3..1e73d155 100644 --- a/src/components/stories/index.js +++ b/src/components/stories/index.js @@ -2,3 +2,5 @@ import './Card'; import './Icon'; import './Toast'; import './FindBar'; +import './MarkupItReactRenderer'; +import './ScrollSync'; diff --git a/src/containers/App.css b/src/containers/App.css index 7c0c10f0..2a9b5418 100644 --- a/src/containers/App.css +++ b/src/containers/App.css @@ -1,20 +1,30 @@ +@import '../components/UI/theme.css'; + .layout .navDrawer .drawerContent { padding-top: 54px; + max-width: 240px; } + .nav { display: block; padding: 1rem; + & .heading { border: none; } } + .main { padding-top: 54px; } -.navDrawer { - max-width: 240px !important; - & .drawerContent { - max-width: 240px !important; - } +.notifsContainer { + position: fixed; + top: 60px; + right: 0; + bottom: 60px; + z-index: var(--topmostZindex); + width: 360px; + pointer-events: none; } + diff --git a/src/containers/App.js b/src/containers/App.js index 7246dc8f..561bc2e4 100644 --- a/src/containers/App.js +++ b/src/containers/App.js @@ -1,7 +1,11 @@ -import React from 'react'; +import React, { PropTypes } from 'react'; +import ImmutablePropTypes from 'react-immutable-proptypes'; import pluralize from 'pluralize'; import { connect } from 'react-redux'; -import { Layout, Panel, NavDrawer, Navigation, Link } from 'react-toolbox'; +import { Layout, Panel, NavDrawer } from 'react-toolbox/lib/layout'; +import { Navigation } from 'react-toolbox/lib/navigation'; +import { Link } from 'react-toolbox/lib/link'; +import { Notifs } from 'redux-notifications'; import { loadConfig } from '../actions/config'; import { loginUser } from '../actions/auth'; import { currentBackend } from '../backends/backend'; @@ -11,39 +15,45 @@ import { HELP, runCommand, navigateToCollection, - createNewEntryInCollection + createNewEntryInCollection, } from '../actions/findbar'; import AppHeader from '../components/AppHeader/AppHeader'; -import { Loader } from '../components/UI/index'; +import { Loader, Toast } from '../components/UI/index'; import styles from './App.css'; class App extends React.Component { + static propTypes = { + auth: ImmutablePropTypes.map, + children: PropTypes.node, + config: ImmutablePropTypes.map, + collections: ImmutablePropTypes.orderedMap, + createNewEntryInCollection: PropTypes.func.isRequired, + dispatch: PropTypes.func.isRequired, + navigateToCollection: PropTypes.func.isRequired, + user: ImmutablePropTypes.map, + runCommand: PropTypes.func.isRequired, + }; + + static configError(config) { + return (
+

Error loading the CMS configuration

+ +
+

The config.yml file could not be loaded or failed to parse properly.

+

Error message: {config.get('error')}

+
+
); + } + state = { - navDrawerIsVisible: true + navDrawerIsVisible: false, }; componentDidMount() { this.props.dispatch(loadConfig()); } - configError(config) { - return
-

Error loading the CMS configuration

- -
-

The "config.yml" file could not be loaded or failed to parse properly.

-

Error message: {config.get('error')}

-
-
; - } - - configLoading() { - return
- Loading configuration... -
; - } - handleLogin(credentials) { this.props.dispatch(loginUser(credentials)); } @@ -56,13 +66,17 @@ class App extends React.Component { return

Waiting for backend...

; } - return
- {React.createElement(backend.authComponent(), { - onLogin: this.handleLogin.bind(this), - error: auth && auth.get('error'), - isFetching: auth && auth.get('isFetching') - })} -
; + return ( +
+ { + React.createElement(backend.authComponent(), { + onLogin: this.handleLogin.bind(this), + error: auth && auth.get('error'), + isFetching: auth && auth.get('isFetching'), + }) + } +
+ ); } generateFindBarCommands() { @@ -70,22 +84,22 @@ class App extends React.Component { const commands = []; const defaultCommands = []; - this.props.collections.forEach(collection => { + this.props.collections.forEach((collection) => { commands.push({ - id: `show_${collection.get('name')}`, - pattern: `Show ${pluralize(collection.get('label'))}`, + id: `show_${ collection.get('name') }`, + pattern: `Show ${ pluralize(collection.get('label')) }`, type: SHOW_COLLECTION, - payload: { collectionName: collection.get('name') } + payload: { collectionName: collection.get('name') }, }); - if (defaultCommands.length < 5) defaultCommands.push(`show_${collection.get('name')}`); + if (defaultCommands.length < 5) defaultCommands.push(`show_${ collection.get('name') }`); if (collection.get('create') === true) { commands.push({ - id: `create_${collection.get('name')}`, - pattern: `Create new ${pluralize(collection.get('label'), 1)}(:itemName as ${pluralize(collection.get('label'), 1)} Name)`, + id: `create_${ collection.get('name') }`, + pattern: `Create new ${ pluralize(collection.get('label'), 1) }(:itemName as ${ pluralize(collection.get('label'), 1) } Name)`, type: CREATE_COLLECTION, - payload: { collectionName: collection.get('name') } + payload: { collectionName: collection.get('name') }, }); } }); @@ -98,7 +112,7 @@ class App extends React.Component { toggleNavDrawer = () => { this.setState({ - navDrawerIsVisible: !this.state.navDrawerIsVisible + navDrawerIsVisible: !this.state.navDrawerIsVisible, }); }; @@ -111,7 +125,7 @@ class App extends React.Component { collections, runCommand, navigateToCollection, - createNewEntryInCollection + createNewEntryInCollection, } = this.props; if (config === null) { @@ -119,11 +133,11 @@ class App extends React.Component { } if (config.get('error')) { - return this.configError(config); + return App.configError(config); } if (config.get('isFetching')) { - return this.configLoading(); + return Loading configuration...; } if (user == null) { @@ -134,20 +148,25 @@ class App extends React.Component { return ( +