diff --git a/frontend/components/Editor/Editor.js b/frontend/components/Editor/Editor.js index dc0e0e6da..a7c3201a0 100644 --- a/frontend/components/Editor/Editor.js +++ b/frontend/components/Editor/Editor.js @@ -67,6 +67,16 @@ type KeyData = { } } + componentDidMount() { + if (!this.props.readOnly) { + if (this.props.text) { + this.focusAtEnd(); + } else { + this.focusAtStart(); + } + } + } + componentDidUpdate(prevProps: Props) { if (prevProps.readOnly && !this.props.readOnly) { this.focusAtEnd(); @@ -121,6 +131,8 @@ type KeyData = { // Handling of keyboard shortcuts outside of editor focus @keydown('meta+s') onSave(ev: SyntheticKeyboardEvent) { + if (this.props.readOnly) return; + ev.preventDefault(); ev.stopPropagation(); this.props.onSave(); @@ -128,6 +140,8 @@ type KeyData = { @keydown('meta+enter') onSaveAndExit(ev: SyntheticKeyboardEvent) { + if (this.props.readOnly) return; + ev.preventDefault(); ev.stopPropagation(); this.props.onSave({ redirect: false }); @@ -135,6 +149,7 @@ type KeyData = { @keydown('esc') onCancel() { + if (this.props.readOnly) return; this.props.onCancel(); } @@ -193,7 +208,8 @@ type KeyData = { (this.editor = ref)} - placeholder="Start with a title..." + placeholder="Start with a title…" + bodyPlaceholder="Insert witty platitude here" className={cx(styles.editor, { readOnly: this.props.readOnly })} schema={this.schema} plugins={this.plugins} diff --git a/frontend/components/Editor/Editor.scss b/frontend/components/Editor/Editor.scss index 808cd0f01..fc26f9493 100644 --- a/frontend/components/Editor/Editor.scss +++ b/frontend/components/Editor/Editor.scss @@ -30,6 +30,18 @@ } } + h1:first-of-type { + .placeholder { + visibility: visible; + } + } + + p:first-of-type { + .placeholder { + visibility: visible; + } + } + ul, ol { margin: 1em .1em; @@ -41,6 +53,10 @@ } } + p { + position: relative; + } + li p { display: inline; margin: 0; @@ -113,8 +129,10 @@ .placeholder { position: absolute; top: 0; + visibility: hidden; pointer-events: none; - color: #ddd; + user-select: none; + color: #B1BECC; } @media all and (max-width: 2000px) and (min-width: 960px) { diff --git a/frontend/components/Editor/components/Heading.js b/frontend/components/Editor/components/Heading.js index 49149a015..a219135a1 100644 --- a/frontend/components/Editor/components/Heading.js +++ b/frontend/components/Editor/components/Heading.js @@ -2,6 +2,7 @@ import React from 'react'; import PropTypes from 'prop-types'; import styled from 'styled-components'; +import { Document } from 'slate'; import _ from 'lodash'; import slug from 'slug'; import StarIcon from 'components/Icon/StarIcon'; @@ -41,8 +42,8 @@ const StyledStar = styled(StarIcon)` } `; -function Heading( - { +function Heading(props: Props, { starred }: Context) { + const { parent, placeholder, node, @@ -52,10 +53,9 @@ function Heading( readOnly, children, component = 'h1', - }: Props, - { starred }: Context -) { - const firstHeading = parent.nodes.first() === node; + } = props; + const parentIsDocument = parent instanceof Document; + const firstHeading = parentIsDocument && parent.nodes.first() === node; const showPlaceholder = placeholder && firstHeading && !node.text; const slugish = _.escape(`${component}-${slug(node.text)}`); const showStar = readOnly && !!onStar; @@ -66,7 +66,7 @@ function Heading( {children} {showPlaceholder && - + {editor.props.placeholder} } {showHash && diff --git a/frontend/components/Editor/components/Paragraph.js b/frontend/components/Editor/components/Paragraph.js new file mode 100644 index 000000000..a3d43ba7b --- /dev/null +++ b/frontend/components/Editor/components/Paragraph.js @@ -0,0 +1,29 @@ +// @flow +import React from 'react'; +import { Document } from 'slate'; +import type { Props } from '../types'; +import styles from '../Editor.scss'; + +export default function Link({ + attributes, + editor, + node, + parent, + children, +}: Props) { + const parentIsDocument = parent instanceof Document; + const firstParagraph = parent && parent.nodes.get(1) === node; + const lastParagraph = parent && parent.nodes.last() === node; + const showPlaceholder = + parentIsDocument && firstParagraph && lastParagraph && !node.text; + + return ( +

+ {children} + {showPlaceholder && + + {editor.props.bodyPlaceholder} + } +

+ ); +} diff --git a/frontend/components/Editor/plugins/MarkdownShortcuts.js b/frontend/components/Editor/plugins/MarkdownShortcuts.js index 745bea0d3..1d4533c65 100644 --- a/frontend/components/Editor/plugins/MarkdownShortcuts.js +++ b/frontend/components/Editor/plugins/MarkdownShortcuts.js @@ -20,6 +20,8 @@ export default function MarkdownShortcuts() { return this.onDash(ev, state); case '`': return this.onBacktick(ev, state); + case 'tab': + return this.onTab(ev, state); case 'space': return this.onSpace(ev, state); case 'backspace': @@ -184,6 +186,17 @@ export default function MarkdownShortcuts() { } }, + /** + * On tab, if at the end of the heading jump to the main body content + * as if it is another input field (act the same as enter). + */ + onTab(ev: SyntheticEvent, state: Object) { + if (state.startBlock.type === 'heading1') { + ev.preventDefault(); + return state.transform().splitBlock().setBlock('paragraph').apply(); + } + }, + /** * On return, if at the end of a node type that should not be extended, * create a new paragraph below it. diff --git a/frontend/components/Editor/schema.js b/frontend/components/Editor/schema.js index 3d96a8e08..0ae017e6c 100644 --- a/frontend/components/Editor/schema.js +++ b/frontend/components/Editor/schema.js @@ -5,6 +5,7 @@ import Image from './components/Image'; import Link from './components/Link'; import ListItem from './components/ListItem'; import Heading from './components/Heading'; +import Paragraph from './components/Paragraph'; import type { Props, Node, Transform } from './types'; import styles from './Editor.scss'; @@ -25,7 +26,7 @@ const createSchema = ({ onStar, onUnstar }: Options) => { }, nodes: { - paragraph: (props: Props) =>

{props.children}

, + paragraph: (props: Props) => , 'block-quote': (props: Props) => (
{props.children}
), diff --git a/frontend/components/LoadingIndicator/LoadingIndicatorBar.js b/frontend/components/LoadingIndicator/LoadingIndicatorBar.js index 33c414c17..dead7652c 100644 --- a/frontend/components/LoadingIndicator/LoadingIndicatorBar.js +++ b/frontend/components/LoadingIndicator/LoadingIndicatorBar.js @@ -21,9 +21,10 @@ const Container = styled.div` z-index: 9999; background-color: #03A9F4; - width: 0; + width: 100%; animation: ${loadingFrame} 4s ease-in-out infinite; animation-delay: 250ms; + margin-left: -100%; `; const Loader = styled.div` diff --git a/frontend/components/LoadingListPlaceholder/LoadingListPlaceholder.js b/frontend/components/LoadingListPlaceholder/LoadingListPlaceholder.js new file mode 100644 index 000000000..26bf3dbcd --- /dev/null +++ b/frontend/components/LoadingListPlaceholder/LoadingListPlaceholder.js @@ -0,0 +1,48 @@ +// @flow +import React from 'react'; +import ReactCSSTransitionGroup from 'react-addons-css-transition-group'; +import styled from 'styled-components'; +import { pulsate } from 'styles/animations'; +import { color } from 'styles/constants'; +import Flex from 'components/Flex'; + +import { randomInteger } from 'utils/random'; + +const randomValues = Array.from( + new Array(5), + () => `${randomInteger(85, 100)}%` +); + +export default (props: Object) => { + return ( + + + + + + + + + + + ); +}; + +const Item = styled(Flex)` + padding: 18px 0; +`; + +const Mask = styled(Flex)` + height: ${props => (props.header ? 28 : 18)}px; + margin-bottom: ${props => (props.header ? 18 : 0)}px; + background-color: ${color.smoke}; + animation: ${pulsate} 1.3s infinite; +`; diff --git a/frontend/components/LoadingListPlaceholder/index.js b/frontend/components/LoadingListPlaceholder/index.js new file mode 100644 index 000000000..17588c5a6 --- /dev/null +++ b/frontend/components/LoadingListPlaceholder/index.js @@ -0,0 +1,3 @@ +// @flow +import LoadingListPlaceholder from './LoadingListPlaceholder'; +export default LoadingListPlaceholder; diff --git a/frontend/components/LoadingPlaceholder/ListPlaceholder.js b/frontend/components/LoadingPlaceholder/ListPlaceholder.js new file mode 100644 index 000000000..bf26f0f52 --- /dev/null +++ b/frontend/components/LoadingPlaceholder/ListPlaceholder.js @@ -0,0 +1,33 @@ +// @flow +import React from 'react'; +import ReactCSSTransitionGroup from 'react-addons-css-transition-group'; +import styled from 'styled-components'; +import Mask from './components/Mask'; +import Flex from 'components/Flex'; + +export default (props: Object) => { + return ( + + + + + + + + + + + ); +}; + +const Item = styled(Flex)` + padding: 18px 0; +`; diff --git a/frontend/components/LoadingPlaceholder/LoadingPlaceholder.js b/frontend/components/LoadingPlaceholder/LoadingPlaceholder.js new file mode 100644 index 000000000..6f978e247 --- /dev/null +++ b/frontend/components/LoadingPlaceholder/LoadingPlaceholder.js @@ -0,0 +1,26 @@ +// @flow +import React from 'react'; +import ReactCSSTransitionGroup from 'react-addons-css-transition-group'; +import Mask from './components/Mask'; +import Flex from 'components/Flex'; + +export default (props: Object) => { + return ( + + + + + + + + + ); +}; diff --git a/frontend/components/LoadingPlaceholder/components/Mask.js b/frontend/components/LoadingPlaceholder/components/Mask.js new file mode 100644 index 000000000..a49810e14 --- /dev/null +++ b/frontend/components/LoadingPlaceholder/components/Mask.js @@ -0,0 +1,38 @@ +// @flow +import React, { Component } from 'react'; +import styled from 'styled-components'; +import { pulsate } from 'styles/animations'; +import { color } from 'styles/constants'; +import { randomInteger } from 'utils/random'; +import Flex from 'components/Flex'; + +class Mask extends Component { + width: number; + + shouldComponentUpdate() { + return false; + } + + constructor(props: Object) { + super(props); + this.width = randomInteger(75, 100); + } + + render() { + return ; + } +} + +const Redacted = styled(Flex)` + width: ${props => (props.header ? props.width / 2 : props.width)}%; + height: ${props => (props.header ? 28 : 18)}px; + margin-bottom: ${props => (props.header ? 18 : 12)}px; + background-color: ${color.smokeDark}; + animation: ${pulsate} 1.3s infinite; + + &:last-child { + margin-bottom: 0; + } +`; + +export default Mask; diff --git a/frontend/components/LoadingPlaceholder/index.js b/frontend/components/LoadingPlaceholder/index.js new file mode 100644 index 000000000..62e7311e2 --- /dev/null +++ b/frontend/components/LoadingPlaceholder/index.js @@ -0,0 +1,6 @@ +// @flow +import LoadingPlaceholder from './LoadingPlaceholder'; +import ListPlaceholder from './ListPlaceholder'; + +export default LoadingPlaceholder; +export { ListPlaceholder }; diff --git a/frontend/components/PreviewLoading/index.js b/frontend/components/PreviewLoading/index.js deleted file mode 100644 index e02fd34c2..000000000 --- a/frontend/components/PreviewLoading/index.js +++ /dev/null @@ -1,3 +0,0 @@ -// @flow -import PreviewLoading from './PreviewLoading'; -export default PreviewLoading; diff --git a/frontend/scenes/Collection/Collection.js b/frontend/scenes/Collection/Collection.js index cd00815c5..7a47bbdba 100644 --- a/frontend/scenes/Collection/Collection.js +++ b/frontend/scenes/Collection/Collection.js @@ -8,7 +8,7 @@ import CollectionsStore from 'stores/CollectionsStore'; import CollectionStore from './CollectionStore'; import CenteredContent from 'components/CenteredContent'; -import PreviewLoading from 'components/PreviewLoading'; +import LoadingListPlaceholder from 'components/LoadingListPlaceholder'; type Props = { collections: CollectionsStore, @@ -33,7 +33,7 @@ type Props = { return this.store.redirectUrl ? : - + ; } } diff --git a/frontend/scenes/Dashboard/Dashboard.js b/frontend/scenes/Dashboard/Dashboard.js index 077b4b4ff..da95a0f2b 100644 --- a/frontend/scenes/Dashboard/Dashboard.js +++ b/frontend/scenes/Dashboard/Dashboard.js @@ -7,6 +7,7 @@ import DocumentsStore from 'stores/DocumentsStore'; import DocumentList from 'components/DocumentList'; import PageTitle from 'components/PageTitle'; import CenteredContent from 'components/CenteredContent'; +import { ListPlaceholder } from 'components/LoadingPlaceholder'; const Subheading = styled.h3` font-size: 11px; @@ -31,16 +32,23 @@ type Props = { this.props.documents.fetchRecentlyViewed(); } + get showPlaceholder() { + const { isLoaded, isFetching } = this.props.documents; + return !isLoaded && isFetching; + } + render() { return (

Home

Recently viewed + {this.showPlaceholder && } Recently edited + {this.showPlaceholder && }
); } diff --git a/frontend/scenes/Document/Document.js b/frontend/scenes/Document/Document.js index 46f353328..1b369a0b3 100644 --- a/frontend/scenes/Document/Document.js +++ b/frontend/scenes/Document/Document.js @@ -12,12 +12,12 @@ import Document from 'models/Document'; import UiStore from 'stores/UiStore'; import DocumentsStore from 'stores/DocumentsStore'; import Menu from './components/Menu'; +import LoadingPlaceholder from 'components/LoadingPlaceholder'; import Editor from 'components/Editor'; import DropToImport from 'components/DropToImport'; import { HeaderAction, SaveAction } from 'components/Layout'; import LoadingIndicator from 'components/LoadingIndicator'; import PublishingInfo from 'components/PublishingInfo'; -import PreviewLoading from 'components/PreviewLoading'; import CenteredContent from 'components/CenteredContent'; import PageTitle from 'components/PageTitle'; @@ -167,7 +167,7 @@ type Props = { const isNew = this.props.newDocument; const isEditing = !!this.props.match.params.edit || isNew; const isFetching = !this.document; - const titleText = get(this.document, 'title', 'Loading'); + const titleText = get(this.document, 'title', ''); const document = this.document; return ( @@ -263,8 +263,8 @@ const Container = styled(Flex)` width: 100%; `; -const LoadingState = styled(PreviewLoading)` - margin: 80px 20px; +const LoadingState = styled(LoadingPlaceholder)` + margin: 90px 0; `; const StyledDropToImport = styled(DropToImport)` diff --git a/frontend/components/PreviewLoading/PreviewLoading.js b/frontend/scenes/Document/components/LoadingPlaceholder/LoadingPlaceholder.js similarity index 82% rename from frontend/components/PreviewLoading/PreviewLoading.js rename to frontend/scenes/Document/components/LoadingPlaceholder/LoadingPlaceholder.js index 150e1c8ef..1052f403f 100644 --- a/frontend/components/PreviewLoading/PreviewLoading.js +++ b/frontend/scenes/Document/components/LoadingPlaceholder/LoadingPlaceholder.js @@ -1,7 +1,9 @@ // @flow import React from 'react'; import ReactCSSTransitionGroup from 'react-addons-css-transition-group'; -import styled, { keyframes } from 'styled-components'; +import styled from 'styled-components'; +import { pulsate } from 'styles/animations'; +import { color } from 'styles/constants'; import Flex from 'components/Flex'; import { randomInteger } from 'utils/random'; @@ -11,7 +13,7 @@ const randomValues = Array.from( () => `${randomInteger(85, 100)}%` ); -export default (props: {}) => { +export default (props: Object) => { return ( { ); }; -const pulsate = keyframes` - 0% { opacity: 1; } - 50% { opacity: 0.5; } - 100% { opacity: 1; } -`; - const Mask = styled(Flex)` height: ${props => (props.header ? 28 : 18)}px; margin-bottom: ${props => (props.header ? 32 : 14)}px; - background-color: #ddd; + background-color: ${color.smoke}; animation: ${pulsate} 1.3s infinite; `; diff --git a/frontend/scenes/Document/components/LoadingPlaceholder/index.js b/frontend/scenes/Document/components/LoadingPlaceholder/index.js new file mode 100644 index 000000000..fd22eb812 --- /dev/null +++ b/frontend/scenes/Document/components/LoadingPlaceholder/index.js @@ -0,0 +1,3 @@ +// @flow +import LoadingPlaceholder from './LoadingPlaceholder'; +export default LoadingPlaceholder; diff --git a/frontend/scenes/Starred/Starred.js b/frontend/scenes/Starred/Starred.js index a14b21e6b..6704045c1 100644 --- a/frontend/scenes/Starred/Starred.js +++ b/frontend/scenes/Starred/Starred.js @@ -2,6 +2,7 @@ import React, { Component } from 'react'; import { observer, inject } from 'mobx-react'; import CenteredContent from 'components/CenteredContent'; +import { ListPlaceholder } from 'components/LoadingPlaceholder'; import PageTitle from 'components/PageTitle'; import DocumentList from 'components/DocumentList'; import DocumentsStore from 'stores/DocumentsStore'; @@ -16,10 +17,13 @@ import DocumentsStore from 'stores/DocumentsStore'; } render() { + const { isLoaded, isFetching } = this.props.documents; + return (

Starred

+ {!isLoaded && isFetching && }
); diff --git a/frontend/stores/DocumentsStore.js b/frontend/stores/DocumentsStore.js index 19e7ae975..f54e44490 100644 --- a/frontend/stores/DocumentsStore.js +++ b/frontend/stores/DocumentsStore.js @@ -26,7 +26,8 @@ class DocumentsStore { @observable recentlyViewedIds: Array = []; @observable data: Map = new ObservableMap([]); @observable isLoaded: boolean = false; - + @observable isFetching: boolean = false; + errors: ErrorsStore; cache: CacheStore; @@ -50,6 +51,8 @@ class DocumentsStore { /* Actions */ @action fetchAll = async (request: string = 'list'): Promise<*> => { + this.isFetching = true; + try { const res = await client.post(`/documents.${request}`); invariant(res && res.data, 'Document list not available'); @@ -63,6 +66,8 @@ class DocumentsStore { return data; } catch (e) { this.errors.add('Failed to load documents'); + } finally { + this.isFetching = false; } }; @@ -79,6 +84,8 @@ class DocumentsStore { }; @action fetch = async (id: string): Promise<*> => { + this.isFetching = true; + try { const res = await client.post('/documents.info', { id }); invariant(res && res.data, 'Document not available'); @@ -93,6 +100,8 @@ class DocumentsStore { return document; } catch (e) { this.errors.add('Failed to load documents'); + } finally { + this.isFetching = false; } }; diff --git a/frontend/styles/animations.js b/frontend/styles/animations.js index bdd611fa8..257528b57 100644 --- a/frontend/styles/animations.js +++ b/frontend/styles/animations.js @@ -12,3 +12,9 @@ export const fadeAndScaleIn = keyframes` transform: scale(1); } `; + +export const pulsate = keyframes` + 0% { opacity: 1; } + 50% { opacity: 0.5; } + 100% { opacity: 1; } +`; diff --git a/frontend/styles/constants.js b/frontend/styles/constants.js index 18c368771..ac4604c93 100644 --- a/frontend/styles/constants.js +++ b/frontend/styles/constants.js @@ -50,6 +50,7 @@ export const color = { /* Light Grays */ smoke: '#F4F7FA', smokeLight: '#F9FBFC', + smokeDark: '#E8EBED', /* Misc */ white: '#FFFFFF',