Document Viewers (#79)
* Recording document views * Add 'views' to document response * Basic displaying of document views, probably want it more sublte than this? But hey, lets get it in there * Bigly improves. RESTful > RPC * Display of who's viewed doc * Add Popover, add Scrollable, move views store * Working server tests 💁 * Document Stars (#81) * Added: Starred documents * UI is dumb but functionality works * Star now displayed inline in title * Optimistic rendering * Documents Endpoints (#85) * More seeds, documents.list endpoint * Upgrade deprecated middleware * document.viewers, specs * Add documents.starred Add request specs for star / unstar endpoints * Basic /starred page * Remove comment * Fixed double layout
This commit is contained in:
@@ -1,13 +1,14 @@
|
||||
// @flow
|
||||
import React, { Component } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { observer } from 'mobx-react';
|
||||
import { Editor, Plain } from 'slate';
|
||||
import classnames from 'classnames/bind';
|
||||
import type { Document, State, Editor as EditorType } from './types';
|
||||
import ClickablePadding from './components/ClickablePadding';
|
||||
import Toolbar from './components/Toolbar';
|
||||
import schema from './schema';
|
||||
import Markdown from './serializer';
|
||||
import createSchema from './schema';
|
||||
import createPlugins from './plugins';
|
||||
import styles from './Editor.scss';
|
||||
|
||||
@@ -18,8 +19,11 @@ type Props = {
|
||||
onChange: Function,
|
||||
onSave: Function,
|
||||
onCancel: Function,
|
||||
onStar: Function,
|
||||
onUnstar: Function,
|
||||
onImageUploadStart: Function,
|
||||
onImageUploadStop: Function,
|
||||
starred: boolean,
|
||||
readOnly: boolean,
|
||||
};
|
||||
|
||||
@@ -28,10 +32,10 @@ type KeyData = {
|
||||
key: string,
|
||||
};
|
||||
|
||||
@observer
|
||||
export default class MarkdownEditor extends Component {
|
||||
@observer class MarkdownEditor extends Component {
|
||||
props: Props;
|
||||
editor: EditorType;
|
||||
schema: Object;
|
||||
plugins: Array<Object>;
|
||||
|
||||
state: {
|
||||
@@ -41,6 +45,10 @@ export default class MarkdownEditor extends Component {
|
||||
constructor(props: Props) {
|
||||
super(props);
|
||||
|
||||
this.schema = createSchema({
|
||||
onStar: props.onStar,
|
||||
onUnstar: props.onUnstar,
|
||||
});
|
||||
this.plugins = createPlugins({
|
||||
onImageUploadStart: props.onImageUploadStart,
|
||||
onImageUploadStop: props.onImageUploadStop,
|
||||
@@ -53,6 +61,10 @@ export default class MarkdownEditor extends Component {
|
||||
}
|
||||
}
|
||||
|
||||
getChildContext() {
|
||||
return { starred: this.props.starred };
|
||||
}
|
||||
|
||||
onChange = (state: State) => {
|
||||
this.setState({ state });
|
||||
};
|
||||
@@ -103,10 +115,11 @@ export default class MarkdownEditor extends Component {
|
||||
<ClickablePadding onClick={this.focusAtStart} />}
|
||||
<Toolbar state={this.state.state} onChange={this.onChange} />
|
||||
<Editor
|
||||
key={this.props.starred}
|
||||
ref={ref => (this.editor = ref)}
|
||||
placeholder="Start with a title…"
|
||||
className={cx(styles.editor, { readOnly: this.props.readOnly })}
|
||||
schema={schema}
|
||||
schema={this.schema}
|
||||
plugins={this.plugins}
|
||||
state={this.state.state}
|
||||
onChange={this.onChange}
|
||||
@@ -121,3 +134,9 @@ export default class MarkdownEditor extends Component {
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
MarkdownEditor.childContextTypes = {
|
||||
starred: PropTypes.bool,
|
||||
};
|
||||
|
||||
export default MarkdownEditor;
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
// @flow
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import styled from 'styled-components';
|
||||
import _ from 'lodash';
|
||||
import slug from 'slug';
|
||||
import StarIcon from 'components/Icon/StarIcon';
|
||||
import type { Node, Editor } from '../types';
|
||||
import styles from '../Editor.scss';
|
||||
|
||||
@@ -10,23 +13,53 @@ type Props = {
|
||||
placeholder?: boolean,
|
||||
parent: Node,
|
||||
node: Node,
|
||||
onStar?: Function,
|
||||
onUnstar?: Function,
|
||||
editor: Editor,
|
||||
readOnly: boolean,
|
||||
component?: string,
|
||||
};
|
||||
|
||||
export default function Heading({
|
||||
parent,
|
||||
placeholder,
|
||||
node,
|
||||
editor,
|
||||
readOnly,
|
||||
children,
|
||||
component = 'h1',
|
||||
}: Props) {
|
||||
type Context = {
|
||||
starred?: boolean,
|
||||
};
|
||||
|
||||
const StyledStar = styled(StarIcon)`
|
||||
top: 3px;
|
||||
position: relative;
|
||||
margin-left: 4px;
|
||||
opacity: ${props => (props.solid ? 1 : 0.25)};
|
||||
transition: opacity 100ms ease-in-out;
|
||||
|
||||
&:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
svg {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
}
|
||||
`;
|
||||
|
||||
function Heading(
|
||||
{
|
||||
parent,
|
||||
placeholder,
|
||||
node,
|
||||
editor,
|
||||
onStar,
|
||||
onUnstar,
|
||||
readOnly,
|
||||
children,
|
||||
component = 'h1',
|
||||
}: Props,
|
||||
{ starred }: Context
|
||||
) {
|
||||
const firstHeading = parent.nodes.first() === node;
|
||||
const showPlaceholder = placeholder && firstHeading && !node.text;
|
||||
const slugish = readOnly && _.escape(`${component}-${slug(node.text)}`);
|
||||
const slugish = _.escape(`${component}-${slug(node.text)}`);
|
||||
const showStar = readOnly && !!onStar;
|
||||
const showHash = readOnly && !!slugish && !showStar;
|
||||
const Component = component;
|
||||
|
||||
return (
|
||||
@@ -36,8 +69,16 @@ export default function Heading({
|
||||
<span className={styles.placeholder}>
|
||||
{editor.props.placeholder}
|
||||
</span>}
|
||||
{slugish &&
|
||||
{showHash &&
|
||||
<a name={slugish} className={styles.anchor} href={`#${slugish}`}>#</a>}
|
||||
{showStar && starred && <a onClick={onUnstar}><StyledStar solid /></a>}
|
||||
{showStar && !starred && <a onClick={onStar}><StyledStar /></a>}
|
||||
</Component>
|
||||
);
|
||||
}
|
||||
|
||||
Heading.contextTypes = {
|
||||
starred: PropTypes.bool,
|
||||
};
|
||||
|
||||
export default Heading;
|
||||
|
||||
@@ -12,15 +12,12 @@ import MarkdownShortcuts from './plugins/MarkdownShortcuts';
|
||||
|
||||
const onlyInCode = node => node.type === 'code';
|
||||
|
||||
type CreatePluginsOptions = {
|
||||
type Options = {
|
||||
onImageUploadStart: Function,
|
||||
onImageUploadStop: Function,
|
||||
};
|
||||
|
||||
const createPlugins = ({
|
||||
onImageUploadStart,
|
||||
onImageUploadStop,
|
||||
}: CreatePluginsOptions) => {
|
||||
const createPlugins = ({ onImageUploadStart, onImageUploadStop }: Options) => {
|
||||
return [
|
||||
PasteLinkify({
|
||||
type: 'link',
|
||||
|
||||
@@ -8,87 +8,98 @@ import Heading from './components/Heading';
|
||||
import type { Props, Node, Transform } from './types';
|
||||
import styles from './Editor.scss';
|
||||
|
||||
const schema = {
|
||||
marks: {
|
||||
bold: (props: Props) => <strong>{props.children}</strong>,
|
||||
code: (props: Props) => <code>{props.children}</code>,
|
||||
italic: (props: Props) => <em>{props.children}</em>,
|
||||
underlined: (props: Props) => <u>{props.children}</u>,
|
||||
deleted: (props: Props) => <del>{props.children}</del>,
|
||||
added: (props: Props) => <mark>{props.children}</mark>,
|
||||
},
|
||||
|
||||
nodes: {
|
||||
paragraph: (props: Props) => <p>{props.children}</p>,
|
||||
'block-quote': (props: Props) => <blockquote>{props.children}</blockquote>,
|
||||
'horizontal-rule': (props: Props) => <hr />,
|
||||
'bulleted-list': (props: Props) => <ul>{props.children}</ul>,
|
||||
'ordered-list': (props: Props) => <ol>{props.children}</ol>,
|
||||
'todo-list': (props: Props) => (
|
||||
<ul className={styles.todoList}>{props.children}</ul>
|
||||
),
|
||||
table: (props: Props) => <table>{props.children}</table>,
|
||||
'table-row': (props: Props) => <tr>{props.children}</tr>,
|
||||
'table-head': (props: Props) => <th>{props.children}</th>,
|
||||
'table-cell': (props: Props) => <td>{props.children}</td>,
|
||||
code: Code,
|
||||
image: Image,
|
||||
link: Link,
|
||||
'list-item': ListItem,
|
||||
heading1: (props: Props) => <Heading placeholder {...props} />,
|
||||
heading2: (props: Props) => <Heading component="h2" {...props} />,
|
||||
heading3: (props: Props) => <Heading component="h3" {...props} />,
|
||||
heading4: (props: Props) => <Heading component="h4" {...props} />,
|
||||
heading5: (props: Props) => <Heading component="h5" {...props} />,
|
||||
heading6: (props: Props) => <Heading component="h6" {...props} />,
|
||||
},
|
||||
|
||||
rules: [
|
||||
// ensure first node is a heading
|
||||
{
|
||||
match: (node: Node) => {
|
||||
return node.kind === 'document';
|
||||
},
|
||||
validate: (document: Node) => {
|
||||
const firstNode = document.nodes.first();
|
||||
return firstNode && firstNode.type === 'heading1' ? null : firstNode;
|
||||
},
|
||||
normalize: (transform: Transform, document: Node, firstNode: Node) => {
|
||||
transform.setBlock({ type: 'heading1' });
|
||||
},
|
||||
},
|
||||
|
||||
// remove any marks in first heading
|
||||
{
|
||||
match: (node: Node) => {
|
||||
return node.kind === 'heading1';
|
||||
},
|
||||
validate: (heading: Node) => {
|
||||
const hasMarks = heading.getMarks().isEmpty();
|
||||
const hasInlines = heading.getInlines().isEmpty();
|
||||
|
||||
return !(hasMarks && hasInlines);
|
||||
},
|
||||
normalize: (transform: Transform, heading: Node) => {
|
||||
transform.unwrapInlineByKey(heading.key);
|
||||
|
||||
heading.getMarks().forEach(mark => {
|
||||
heading.nodes.forEach(textNode => {
|
||||
if (textNode.kind === 'text') {
|
||||
transform.removeMarkByKey(
|
||||
textNode.key,
|
||||
0,
|
||||
textNode.text.length,
|
||||
mark
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return transform;
|
||||
},
|
||||
},
|
||||
],
|
||||
type Options = {
|
||||
onStar: Function,
|
||||
onUnstar: Function,
|
||||
};
|
||||
|
||||
export default schema;
|
||||
const createSchema = ({ onStar, onUnstar }: Options) => {
|
||||
return {
|
||||
marks: {
|
||||
bold: (props: Props) => <strong>{props.children}</strong>,
|
||||
code: (props: Props) => <code>{props.children}</code>,
|
||||
italic: (props: Props) => <em>{props.children}</em>,
|
||||
underlined: (props: Props) => <u>{props.children}</u>,
|
||||
deleted: (props: Props) => <del>{props.children}</del>,
|
||||
added: (props: Props) => <mark>{props.children}</mark>,
|
||||
},
|
||||
|
||||
nodes: {
|
||||
paragraph: (props: Props) => <p>{props.children}</p>,
|
||||
'block-quote': (props: Props) => (
|
||||
<blockquote>{props.children}</blockquote>
|
||||
),
|
||||
'horizontal-rule': (props: Props) => <hr />,
|
||||
'bulleted-list': (props: Props) => <ul>{props.children}</ul>,
|
||||
'ordered-list': (props: Props) => <ol>{props.children}</ol>,
|
||||
'todo-list': (props: Props) => (
|
||||
<ul className={styles.todoList}>{props.children}</ul>
|
||||
),
|
||||
table: (props: Props) => <table>{props.children}</table>,
|
||||
'table-row': (props: Props) => <tr>{props.children}</tr>,
|
||||
'table-head': (props: Props) => <th>{props.children}</th>,
|
||||
'table-cell': (props: Props) => <td>{props.children}</td>,
|
||||
code: Code,
|
||||
image: Image,
|
||||
link: Link,
|
||||
'list-item': ListItem,
|
||||
heading1: (props: Props) => (
|
||||
<Heading placeholder onStar={onStar} onUnstar={onUnstar} {...props} />
|
||||
),
|
||||
heading2: (props: Props) => <Heading component="h2" {...props} />,
|
||||
heading3: (props: Props) => <Heading component="h3" {...props} />,
|
||||
heading4: (props: Props) => <Heading component="h4" {...props} />,
|
||||
heading5: (props: Props) => <Heading component="h5" {...props} />,
|
||||
heading6: (props: Props) => <Heading component="h6" {...props} />,
|
||||
},
|
||||
|
||||
rules: [
|
||||
// ensure first node is a heading
|
||||
{
|
||||
match: (node: Node) => {
|
||||
return node.kind === 'document';
|
||||
},
|
||||
validate: (document: Node) => {
|
||||
const firstNode = document.nodes.first();
|
||||
return firstNode && firstNode.type === 'heading1' ? null : firstNode;
|
||||
},
|
||||
normalize: (transform: Transform, document: Node, firstNode: Node) => {
|
||||
transform.setBlock({ type: 'heading1' });
|
||||
},
|
||||
},
|
||||
|
||||
// remove any marks in first heading
|
||||
{
|
||||
match: (node: Node) => {
|
||||
return node.kind === 'heading1';
|
||||
},
|
||||
validate: (heading: Node) => {
|
||||
const hasMarks = heading.getMarks().isEmpty();
|
||||
const hasInlines = heading.getInlines().isEmpty();
|
||||
|
||||
return !(hasMarks && hasInlines);
|
||||
},
|
||||
normalize: (transform: Transform, heading: Node) => {
|
||||
transform.unwrapInlineByKey(heading.key);
|
||||
|
||||
heading.getMarks().forEach(mark => {
|
||||
heading.nodes.forEach(textNode => {
|
||||
if (textNode.kind === 'text') {
|
||||
transform.removeMarkByKey(
|
||||
textNode.key,
|
||||
0,
|
||||
textNode.text.length,
|
||||
mark
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return transform;
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
};
|
||||
|
||||
export default createSchema;
|
||||
|
||||
Reference in New Issue
Block a user