Merge pull request #311 from jorilallo/toc

TOC
This commit is contained in:
Jori Lallo
2017-10-24 23:00:44 -07:00
committed by GitHub
5 changed files with 202 additions and 31 deletions

View File

@@ -1,5 +1,6 @@
// @flow
import React, { Component } from 'react';
import { observable } from 'mobx';
import { observer } from 'mobx-react';
import { Editor, Plain } from 'slate';
import keydown from 'react-keydown';
@@ -10,6 +11,7 @@ import ClickablePadding from './components/ClickablePadding';
import Toolbar from './components/Toolbar';
import BlockInsert from './components/BlockInsert';
import Placeholder from './components/Placeholder';
import Contents from './components/Contents';
import Markdown from './serializer';
import createSchema from './schema';
import createPlugins from './plugins';
@@ -37,10 +39,7 @@ type KeyData = {
editor: EditorType;
schema: Object;
plugins: Array<Object>;
state: {
state: State,
};
@observable editorState: State;
constructor(props: Props) {
super(props);
@@ -52,9 +51,9 @@ type KeyData = {
});
if (props.text.trim().length) {
this.state = { state: Markdown.deserialize(props.text) };
this.editorState = Markdown.deserialize(props.text);
} else {
this.state = { state: Plain.deserialize('') };
this.editorState = Plain.deserialize('');
}
}
@@ -74,12 +73,12 @@ type KeyData = {
}
}
onChange = (state: State) => {
this.setState({ state });
if (this.state.state !== state) {
this.props.onChange(Markdown.serialize(state));
onChange = (editorState: State) => {
if (this.editorState !== editorState) {
this.props.onChange(Markdown.serialize(editorState));
}
this.editorState = editorState;
};
handleDrop = async (ev: SyntheticEvent) => {
@@ -165,7 +164,7 @@ type KeyData = {
const transform = state.transform();
transform.collapseToStartOf(state.document);
transform.focus();
this.setState({ state: transform.apply() });
this.editorState = transform.apply();
};
focusAtEnd = () => {
@@ -173,7 +172,7 @@ type KeyData = {
const transform = state.transform();
transform.collapseToEndOf(state.document);
transform.focus();
this.setState({ state: transform.apply() });
this.editorState = transform.apply();
};
render = () => {
@@ -190,11 +189,12 @@ type KeyData = {
>
<MaxWidth column auto>
<Header onClick={this.focusAtStart} readOnly={readOnly} />
<Contents state={this.editorState} />
{!readOnly &&
<Toolbar state={this.state.state} onChange={this.onChange} />}
<Toolbar state={this.editorState} onChange={this.onChange} />}
{!readOnly &&
<BlockInsert
state={this.state.state}
state={this.editorState}
onChange={this.onChange}
onInsertImage={this.insertImageFile}
/>}
@@ -205,7 +205,7 @@ type KeyData = {
schema={this.schema}
plugins={this.plugins}
emoji={emoji}
state={this.state.state}
state={this.editorState}
onKeyDown={this.onKeyDown}
onChange={this.onChange}
onSave={onSave}

View File

@@ -0,0 +1,149 @@
// @flow
import React, { Component } from 'react';
import { observable } from 'mobx';
import { observer } from 'mobx-react';
import { List } from 'immutable';
import { color } from 'styles/constants';
import headingToSlug from '../headingToSlug';
import type { State, Block } from '../types';
import styled from 'styled-components';
type Props = {
state: State,
};
@observer class Contents extends Component {
props: Props;
@observable activeHeading: ?string;
componentDidMount() {
window.addEventListener('scroll', this.updateActiveHeading);
this.updateActiveHeading();
}
componentWillUnmount() {
window.removeEventListener('scroll', this.updateActiveHeading);
}
updateActiveHeading = () => {
const elements = this.headingElements;
if (!elements.length) return;
let activeHeading = elements[0].id;
for (const element of elements) {
const bounds = element.getBoundingClientRect();
if (bounds.top <= 0) activeHeading = element.id;
}
this.activeHeading = activeHeading;
};
get headingElements(): HTMLElement[] {
const elements = [];
const tagNames = ['h1', 'h2', 'h3', 'h4', 'h5', 'h6'];
for (const tagName of tagNames) {
for (const ele of document.getElementsByTagName(tagName)) {
elements.push(ele);
}
}
return elements;
}
get headings(): List<Block> {
const { state } = this.props;
return state.document.nodes.filter((node: Block) => {
if (!node.text) return false;
return node.type.match(/^heading/);
});
}
render() {
// If there are one or less headings in the document no need for a minimap
if (this.headings.size <= 1) return null;
return (
<Wrapper>
<Sections>
{this.headings.map(heading => {
const slug = headingToSlug(heading);
const active = this.activeHeading === slug;
return (
<ListItem type={heading.type} active={active}>
<Anchor href={`#${slug}`} active={active}>
{heading.text}
</Anchor>
</ListItem>
);
})}
</Sections>
</Wrapper>
);
}
}
const Wrapper = styled.div`
position: fixed;
right: 0;
top: 150px;
z-index: 100;
`;
const Anchor = styled.a`
color: ${props => (props.active ? color.slateDark : color.slate)};
font-weight: ${props => (props.active ? 500 : 400)};
opacity: 0;
transition: all 100ms ease-in-out;
margin-right: -5px;
padding: 2px 0;
pointer-events: none;
text-overflow: ellipsis;
&:hover {
color: ${color.primary};
}
`;
const ListItem = styled.li`
position: relative;
margin-left: ${props => (props.type.match(/heading[12]/) ? '8px' : '16px')};
text-align: right;
color: ${color.slate};
padding-right: 16px;
white-space: nowrap;
&:after {
color: ${props => (props.active ? color.slateDark : color.slate)};
content: "${props => (props.type.match(/heading[12]/) ? '—' : '')}";
position: absolute;
right: 0;
}
`;
const Sections = styled.ol`
margin: 0 0 0 -8px;
padding: 0;
list-style: none;
font-size: 13px;
width: 100px;
transition-delay: 1s;
transition: width 100ms ease-in-out;
&:hover {
width: 300px;
transition-delay: 0s;
${Anchor} {
opacity: 1;
margin-right: 0;
background: ${color.white};
pointer-events: all;
}
}
`;
export default Contents;

View File

@@ -2,13 +2,12 @@
import React from 'react';
import { Document } from 'slate';
import styled from 'styled-components';
import _ from 'lodash';
import slug from 'slug';
import headingToSlug from '../headingToSlug';
import type { Node, Editor } from '../types';
import Placeholder from './Placeholder';
type Props = {
children: React$Element<any>,
children: React$Element<*>,
placeholder?: boolean,
parent: Node,
node: Node,
@@ -31,7 +30,7 @@ function Heading(props: 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 slugish = headingToSlug(node);
const showHash = readOnly && !!slugish;
const Component = component;
const emoji = editor.props.emoji || '';
@@ -40,8 +39,10 @@ function Heading(props: Props) {
emoji && title.match(new RegExp(`^${emoji}\\s`));
return (
<Component {...rest}>
<Wrapper hasEmoji={startsWithEmojiAndSpace}>{children}</Wrapper>
<Component {...rest} id={slugish}>
<Wrapper hasEmoji={startsWithEmojiAndSpace}>
{children}
</Wrapper>
{showPlaceholder &&
<Placeholder contentEditable={false}>
{editor.props.placeholder}
@@ -53,7 +54,7 @@ function Heading(props: Props) {
const Wrapper = styled.div`
display: inline;
margin-left: ${props => (props.hasEmoji ? '-1.2em' : 0)}
margin-left: ${(props: Props) => (props.hasEmoji ? '-1.2em' : 0)}
`;
const Anchor = styled.a`
@@ -66,7 +67,7 @@ const Anchor = styled.a`
}
`;
export const Heading1 = styled(Heading)`
export const StyledHeading = styled(Heading)`
position: relative;
&:hover {
@@ -76,10 +77,21 @@ export const Heading1 = styled(Heading)`
}
}
`;
export const Heading2 = Heading1.withComponent('h2');
export const Heading3 = Heading1.withComponent('h3');
export const Heading4 = Heading1.withComponent('h4');
export const Heading5 = Heading1.withComponent('h5');
export const Heading6 = Heading1.withComponent('h6');
export default Heading;
export const Heading1 = (props: Props) => (
<StyledHeading component="h1" {...props} />
);
export const Heading2 = (props: Props) => (
<StyledHeading component="h2" {...props} />
);
export const Heading3 = (props: Props) => (
<StyledHeading component="h3" {...props} />
);
export const Heading4 = (props: Props) => (
<StyledHeading component="h4" {...props} />
);
export const Heading5 = (props: Props) => (
<StyledHeading component="h5" {...props} />
);
export const Heading6 = (props: Props) => (
<StyledHeading component="h6" {...props} />
);

View File

@@ -0,0 +1,9 @@
// @flow
import { escape } from 'lodash';
import type { Node } from './types';
import slug from 'slug';
export default function headingToSlug(node: Node) {
const level = node.type.replace('heading', 'h');
return escape(`${level}-${slug(node.text)}-${node.key}`);
}

View File

@@ -66,6 +66,7 @@ export type Editor = {
export type Node = {
key: string,
kind: string,
type: string,
length: number,
text: string,
data: Map<string, any>,