Functional TOC

This commit is contained in:
Tom Moor
2017-10-15 19:21:47 -07:00
parent 53d9e221a5
commit 5f4b5f6d33
5 changed files with 103 additions and 32 deletions

View File

@ -1,5 +1,6 @@
// @flow // @flow
import React, { Component } from 'react'; import React, { Component } from 'react';
import { observable } from 'mobx';
import { observer } from 'mobx-react'; import { observer } from 'mobx-react';
import { Editor, Plain } from 'slate'; import { Editor, Plain } from 'slate';
import keydown from 'react-keydown'; import keydown from 'react-keydown';
@ -9,6 +10,7 @@ import Flex from 'components/Flex';
import ClickablePadding from './components/ClickablePadding'; import ClickablePadding from './components/ClickablePadding';
import Toolbar from './components/Toolbar'; import Toolbar from './components/Toolbar';
import Placeholder from './components/Placeholder'; import Placeholder from './components/Placeholder';
import Minimap from './components/Minimap';
import Markdown from './serializer'; import Markdown from './serializer';
import createSchema from './schema'; import createSchema from './schema';
import createPlugins from './plugins'; import createPlugins from './plugins';
@ -36,10 +38,7 @@ type KeyData = {
editor: EditorType; editor: EditorType;
schema: Object; schema: Object;
plugins: Array<Object>; plugins: Array<Object>;
@observable editorState: State;
state: {
state: State,
};
constructor(props: Props) { constructor(props: Props) {
super(props); super(props);
@ -51,9 +50,9 @@ type KeyData = {
}); });
if (props.text) { if (props.text) {
this.state = { state: Markdown.deserialize(props.text) }; this.editorState = Markdown.deserialize(props.text);
} else { } else {
this.state = { state: Plain.deserialize('') }; this.editorState = Plain.deserialize('');
} }
} }
@ -73,12 +72,12 @@ type KeyData = {
} }
} }
onChange = (state: State) => { onChange = (editorState: State) => {
this.setState({ state }); this.editorState = editorState;
}; };
onDocumentChange = (document: Document, state: State) => { onDocumentChange = (document: Document, editorState: State) => {
this.props.onChange(Markdown.serialize(state)); this.props.onChange(Markdown.serialize(editorState));
}; };
handleDrop = async (ev: SyntheticEvent) => { handleDrop = async (ev: SyntheticEvent) => {
@ -161,7 +160,7 @@ type KeyData = {
const transform = state.transform(); const transform = state.transform();
transform.collapseToStartOf(state.document); transform.collapseToStartOf(state.document);
transform.focus(); transform.focus();
this.setState({ state: transform.apply() }); this.editorState = transform.apply();
}; };
focusAtEnd = () => { focusAtEnd = () => {
@ -169,7 +168,7 @@ type KeyData = {
const transform = state.transform(); const transform = state.transform();
transform.collapseToEndOf(state.document); transform.collapseToEndOf(state.document);
transform.focus(); transform.focus();
this.setState({ state: transform.apply() }); this.editorState = transform.apply();
}; };
render = () => { render = () => {
@ -184,7 +183,8 @@ type KeyData = {
> >
<MaxWidth column auto> <MaxWidth column auto>
<Header onClick={this.focusAtStart} readOnly={this.props.readOnly} /> <Header onClick={this.focusAtStart} readOnly={this.props.readOnly} />
<Toolbar state={this.state.state} onChange={this.onChange} /> <Toolbar state={this.editorState} onChange={this.onChange} />
<Minimap state={this.editorState} />
<StyledEditor <StyledEditor
innerRef={ref => (this.editor = ref)} innerRef={ref => (this.editor = ref)}
placeholder="Start with a title…" placeholder="Start with a title…"
@ -192,7 +192,7 @@ type KeyData = {
schema={this.schema} schema={this.schema}
plugins={this.plugins} plugins={this.plugins}
emoji={this.props.emoji} emoji={this.props.emoji}
state={this.state.state} state={this.editorState}
onKeyDown={this.onKeyDown} onKeyDown={this.onKeyDown}
onChange={this.onChange} onChange={this.onChange}
onDocumentChange={this.onDocumentChange} onDocumentChange={this.onDocumentChange}

View File

@ -2,13 +2,12 @@
import React from 'react'; import React from 'react';
import { Document } from 'slate'; import { Document } from 'slate';
import styled from 'styled-components'; import styled from 'styled-components';
import _ from 'lodash'; import headingToSlug from '../headingToSlug';
import slug from 'slug';
import type { Node, Editor } from '../types'; import type { Node, Editor } from '../types';
import Placeholder from './Placeholder'; import Placeholder from './Placeholder';
type Props = { type Props = {
children: React$Element<any>, children: React$Element<*>,
placeholder?: boolean, placeholder?: boolean,
parent: Node, parent: Node,
node: Node, node: Node,
@ -31,7 +30,7 @@ function Heading(props: Props) {
const parentIsDocument = parent instanceof Document; const parentIsDocument = parent instanceof Document;
const firstHeading = parentIsDocument && parent.nodes.first() === node; const firstHeading = parentIsDocument && parent.nodes.first() === node;
const showPlaceholder = placeholder && firstHeading && !node.text; const showPlaceholder = placeholder && firstHeading && !node.text;
const slugish = _.escape(`${component}-${slug(node.text)}`); const slugish = headingToSlug(node.type, node.text);
const showHash = readOnly && !!slugish; const showHash = readOnly && !!slugish;
const Component = component; const Component = component;
const emoji = editor.props.emoji || ''; const emoji = editor.props.emoji || '';
@ -40,8 +39,10 @@ function Heading(props: Props) {
emoji && title.match(new RegExp(`^${emoji}\\s`)); emoji && title.match(new RegExp(`^${emoji}\\s`));
return ( return (
<Component {...rest}> <Component {...rest} id={slugish}>
<Wrapper hasEmoji={startsWithEmojiAndSpace}>{children}</Wrapper> <Wrapper hasEmoji={startsWithEmojiAndSpace}>
{children}
</Wrapper>
{showPlaceholder && {showPlaceholder &&
<Placeholder contentEditable={false}> <Placeholder contentEditable={false}>
{editor.props.placeholder} {editor.props.placeholder}
@ -53,7 +54,7 @@ function Heading(props: Props) {
const Wrapper = styled.div` const Wrapper = styled.div`
display: inline; display: inline;
margin-left: ${props => (props.hasEmoji ? '-1.2em' : 0)} margin-left: ${(props: Props) => (props.hasEmoji ? '-1.2em' : 0)}
`; `;
const Anchor = styled.a` const Anchor = styled.a`
@ -66,19 +67,28 @@ const Anchor = styled.a`
} }
`; `;
export const Heading1 = styled(Heading)` export const StyledHeading = styled(Heading)`
position: relative; position: relative;
&:hover { &:hover {
${Anchor} { ${Anchor} { visibility: visible; }
visibility: visible;
}
} }
`; `;
export const Heading2 = Heading1.withComponent('h2'); export const Heading1 = (props: Props) => (
export const Heading3 = Heading1.withComponent('h3'); <StyledHeading component="h1" {...props} />
export const Heading4 = Heading1.withComponent('h4'); );
export const Heading5 = Heading1.withComponent('h5'); export const Heading2 = (props: Props) => (
export const Heading6 = Heading1.withComponent('h6'); <StyledHeading component="h2" {...props} />
);
export default Heading; 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,52 @@
// @flow
import React, { Component } from 'react';
import { List } from 'immutable';
import headingToSlug from '../headingToSlug';
import type { State, Block } from '../types';
import styled from 'styled-components';
type Props = {
state: State,
};
class Minimap extends Component {
props: Props;
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() {
return (
<Wrapper>
<Headings>
{this.headings.map(heading => (
<li>
<a href={`#${headingToSlug(heading.type, heading.text)}`}>
{heading.text}
</a>
</li>
))}
</Headings>
</Wrapper>
);
}
}
const Headings = styled.ol`
margin: 0;
padding: 0;
`;
const Wrapper = styled.div`
position: fixed;
left: 0;
top: 50%;
`;
export default Minimap;

View File

@ -0,0 +1,8 @@
// @flow
import { escape } from 'lodash';
import slug from 'slug';
export default function headingToSlug(heading: string, title: string) {
const level = heading.replace('heading', 'h');
return escape(`${level}-${slug(title)}`);
}

View File

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