diff --git a/frontend/components/Editor/Editor.js b/frontend/components/Editor/Editor.js index e6172fae0..75ed07f1b 100644 --- a/frontend/components/Editor/Editor.js +++ b/frontend/components/Editor/Editor.js @@ -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; - - 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 = { >
+ {!readOnly && - } + } {!readOnly && } @@ -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} diff --git a/frontend/components/Editor/components/Contents.js b/frontend/components/Editor/components/Contents.js new file mode 100644 index 000000000..7a1f19a44 --- /dev/null +++ b/frontend/components/Editor/components/Contents.js @@ -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 { + 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 ( + + + {this.headings.map(heading => { + const slug = headingToSlug(heading); + const active = this.activeHeading === slug; + + return ( + + + {heading.text} + + + ); + })} + + + ); + } +} + +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; diff --git a/frontend/components/Editor/components/Heading.js b/frontend/components/Editor/components/Heading.js index a107a30a6..29a7ccc37 100644 --- a/frontend/components/Editor/components/Heading.js +++ b/frontend/components/Editor/components/Heading.js @@ -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, + 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 ( - - {children} + + + {children} + {showPlaceholder && {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) => ( + +); +export const Heading2 = (props: Props) => ( + +); +export const Heading3 = (props: Props) => ( + +); +export const Heading4 = (props: Props) => ( + +); +export const Heading5 = (props: Props) => ( + +); +export const Heading6 = (props: Props) => ( + +); diff --git a/frontend/components/Editor/headingToSlug.js b/frontend/components/Editor/headingToSlug.js new file mode 100644 index 000000000..04b9d6273 --- /dev/null +++ b/frontend/components/Editor/headingToSlug.js @@ -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}`); +} diff --git a/frontend/components/Editor/types.js b/frontend/components/Editor/types.js index 0db4d68ae..4406f20f9 100644 --- a/frontend/components/Editor/types.js +++ b/frontend/components/Editor/types.js @@ -66,6 +66,7 @@ export type Editor = { export type Node = { key: string, kind: string, + type: string, length: number, text: string, data: Map,