From 5f4b5f6d33f6361977f1aacb1e681571040b9e7a Mon Sep 17 00:00:00 2001 From: Tom Moor Date: Sun, 15 Oct 2017 19:21:47 -0700 Subject: [PATCH 1/6] Functional TOC --- frontend/components/Editor/Editor.js | 28 +++++----- .../components/Editor/components/Heading.js | 46 +++++++++------- .../components/Editor/components/Minimap.js | 52 +++++++++++++++++++ frontend/components/Editor/headingToSlug.js | 8 +++ frontend/components/Editor/types.js | 1 + 5 files changed, 103 insertions(+), 32 deletions(-) create mode 100644 frontend/components/Editor/components/Minimap.js create mode 100644 frontend/components/Editor/headingToSlug.js diff --git a/frontend/components/Editor/Editor.js b/frontend/components/Editor/Editor.js index 922dc5f04..7c4d27a32 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'; @@ -9,6 +10,7 @@ import Flex from 'components/Flex'; import ClickablePadding from './components/ClickablePadding'; import Toolbar from './components/Toolbar'; import Placeholder from './components/Placeholder'; +import Minimap from './components/Minimap'; import Markdown from './serializer'; import createSchema from './schema'; import createPlugins from './plugins'; @@ -36,10 +38,7 @@ type KeyData = { editor: EditorType; schema: Object; plugins: Array; - - state: { - state: State, - }; + @observable editorState: State; constructor(props: Props) { super(props); @@ -51,9 +50,9 @@ type KeyData = { }); if (props.text) { - this.state = { state: Markdown.deserialize(props.text) }; + this.editorState = Markdown.deserialize(props.text); } else { - this.state = { state: Plain.deserialize('') }; + this.editorState = Plain.deserialize(''); } } @@ -73,12 +72,12 @@ type KeyData = { } } - onChange = (state: State) => { - this.setState({ state }); + onChange = (editorState: State) => { + this.editorState = editorState; }; - onDocumentChange = (document: Document, state: State) => { - this.props.onChange(Markdown.serialize(state)); + onDocumentChange = (document: Document, editorState: State) => { + this.props.onChange(Markdown.serialize(editorState)); }; handleDrop = async (ev: SyntheticEvent) => { @@ -161,7 +160,7 @@ type KeyData = { const transform = state.transform(); transform.collapseToStartOf(state.document); transform.focus(); - this.setState({ state: transform.apply() }); + this.editorState = transform.apply(); }; focusAtEnd = () => { @@ -169,7 +168,7 @@ type KeyData = { const transform = state.transform(); transform.collapseToEndOf(state.document); transform.focus(); - this.setState({ state: transform.apply() }); + this.editorState = transform.apply(); }; render = () => { @@ -184,7 +183,8 @@ type KeyData = { >
- + + (this.editor = ref)} placeholder="Start with a title…" @@ -192,7 +192,7 @@ type KeyData = { schema={this.schema} plugins={this.plugins} emoji={this.props.emoji} - state={this.state.state} + state={this.editorState} onKeyDown={this.onKeyDown} onChange={this.onChange} onDocumentChange={this.onDocumentChange} diff --git a/frontend/components/Editor/components/Heading.js b/frontend/components/Editor/components/Heading.js index c8ceb4464..0a9891d93 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.type, node.text); 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,19 +67,28 @@ const Anchor = styled.a` } `; -export const Heading1 = styled(Heading)` +export const StyledHeading = styled(Heading)` position: relative; &:hover { - ${Anchor} { - visibility: visible; - } + ${Anchor} { visibility: visible; } } `; -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/components/Minimap.js b/frontend/components/Editor/components/Minimap.js new file mode 100644 index 000000000..3d4e096e4 --- /dev/null +++ b/frontend/components/Editor/components/Minimap.js @@ -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 { + const { state } = this.props; + + return state.document.nodes.filter((node: Block) => { + if (!node.text) return false; + return node.type.match(/^heading/); + }); + } + + render() { + return ( + + + {this.headings.map(heading => ( +
  • + + {heading.text} + +
  • + ))} +
    +
    + ); + } +} + +const Headings = styled.ol` + margin: 0; + padding: 0; +`; + +const Wrapper = styled.div` + position: fixed; + left: 0; + top: 50%; +`; + +export default Minimap; diff --git a/frontend/components/Editor/headingToSlug.js b/frontend/components/Editor/headingToSlug.js new file mode 100644 index 000000000..1eee75dcd --- /dev/null +++ b/frontend/components/Editor/headingToSlug.js @@ -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)}`); +} 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, From 3d446347f261747b3785864e67a1fcce5b254129 Mon Sep 17 00:00:00 2001 From: Tom Moor Date: Sun, 15 Oct 2017 22:26:23 -0700 Subject: [PATCH 2/6] TOC now has active heading highlighted --- .../components/Editor/components/Minimap.js | 91 ++++++++++++++++--- 1 file changed, 77 insertions(+), 14 deletions(-) diff --git a/frontend/components/Editor/components/Minimap.js b/frontend/components/Editor/components/Minimap.js index 3d4e096e4..9178468d6 100644 --- a/frontend/components/Editor/components/Minimap.js +++ b/frontend/components/Editor/components/Minimap.js @@ -1,6 +1,9 @@ // @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'; @@ -9,44 +12,104 @@ type Props = { state: State, }; -class Minimap extends Component { +@observer class Minimap extends Component { props: Props; + @observable activeHeading: ?string; + + componentDidMount() { + window.addEventListener('scroll', this.updateActiveHeading); + this.updateActiveHeading(); + } + + componentWillUnmount() { + window.removeEventListener('scroll', this.updateActiveHeading); + } + + updateActiveHeading = () => { + let activeHeading = this.headingElements[0].id; + + for (const element of this.headingElements) { + const bounds = element.getBoundingClientRect(); + if (bounds.top <= 0) activeHeading = element.id; + } + + this.activeHeading = activeHeading; + }; + + get headingElements(): HTMLElement[] { + const elements = []; + const tagNames = ['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; + if (node.type === 'heading1') 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 => ( -
  • - - {heading.text} - -
  • - ))} -
    + + {this.headings.map(heading => { + const slug = headingToSlug(heading.type, heading.text); + + return ( + + + {heading.text} + + + ); + })} +
    ); } } -const Headings = styled.ol` - margin: 0; +const Anchor = styled.a` + color: ${props => (props.active ? color.primary : color.slate)}; + font-weight: ${props => (props.active ? 500 : 400)}; +`; + +const Sections = styled.ol` + margin: 0 0 0 -8px; padding: 0; + list-style: none; + font-size: 13px; + border-right: 1px solid ${color.slate}; +`; + +const ListItem = styled.li` + position: relative; + margin-left: ${props => (props.type === 'heading2' ? '8px' : '16px')}; + text-align: right; + color: ${color.slate}; + padding-right: 10px; `; const Wrapper = styled.div` position: fixed; - left: 0; - top: 50%; + right: 0; + top: 160px; + padding-right: 20px; + background: ${color.white}; `; export default Minimap; From 1adc983a120648760dab4a9fc217b6c759786772 Mon Sep 17 00:00:00 2001 From: Tom Moor Date: Mon, 16 Oct 2017 19:30:17 -0700 Subject: [PATCH 3/6] Styling --- .../components/Editor/components/Minimap.js | 24 ++++++++++++------- 1 file changed, 16 insertions(+), 8 deletions(-) diff --git a/frontend/components/Editor/components/Minimap.js b/frontend/components/Editor/components/Minimap.js index 9178468d6..12adb4f49 100644 --- a/frontend/components/Editor/components/Minimap.js +++ b/frontend/components/Editor/components/Minimap.js @@ -88,20 +88,27 @@ const Anchor = styled.a` font-weight: ${props => (props.active ? 500 : 400)}; `; -const Sections = styled.ol` - margin: 0 0 0 -8px; - padding: 0; - list-style: none; - font-size: 13px; - border-right: 1px solid ${color.slate}; -`; - const ListItem = styled.li` position: relative; margin-left: ${props => (props.type === 'heading2' ? '8px' : '16px')}; text-align: right; color: ${color.slate}; padding-right: 10px; + opacity: 0; +`; + +const Sections = styled.ol` + margin: 0 0 0 -8px; + padding: 0; + list-style: none; + font-size: 13px; + border-right: 1px solid ${color.slate}; + + &:hover { + ${ListItem} { + opacity: 1; + } + } `; const Wrapper = styled.div` @@ -110,6 +117,7 @@ const Wrapper = styled.div` top: 160px; padding-right: 20px; background: ${color.white}; + z-index: 100; `; export default Minimap; From aa5b3baf27fe62fc95d2b0751c0bffafd3b78fe2 Mon Sep 17 00:00:00 2001 From: Tom Moor Date: Mon, 16 Oct 2017 22:05:55 -0700 Subject: [PATCH 4/6] Subtle --- frontend/components/Editor/Editor.js | 4 +-- .../components/{Minimap.js => Contents.js} | 36 +++++++++++++------ 2 files changed, 27 insertions(+), 13 deletions(-) rename frontend/components/Editor/components/{Minimap.js => Contents.js} (77%) diff --git a/frontend/components/Editor/Editor.js b/frontend/components/Editor/Editor.js index 7c4d27a32..4a016763b 100644 --- a/frontend/components/Editor/Editor.js +++ b/frontend/components/Editor/Editor.js @@ -10,7 +10,7 @@ import Flex from 'components/Flex'; import ClickablePadding from './components/ClickablePadding'; import Toolbar from './components/Toolbar'; import Placeholder from './components/Placeholder'; -import Minimap from './components/Minimap'; +import Contents from './components/Contents'; import Markdown from './serializer'; import createSchema from './schema'; import createPlugins from './plugins'; @@ -184,7 +184,7 @@ type KeyData = {
    - + (this.editor = ref)} placeholder="Start with a title…" diff --git a/frontend/components/Editor/components/Minimap.js b/frontend/components/Editor/components/Contents.js similarity index 77% rename from frontend/components/Editor/components/Minimap.js rename to frontend/components/Editor/components/Contents.js index 12adb4f49..31382cbc7 100644 --- a/frontend/components/Editor/components/Minimap.js +++ b/frontend/components/Editor/components/Contents.js @@ -12,7 +12,7 @@ type Props = { state: State, }; -@observer class Minimap extends Component { +@observer class Contents extends Component { props: Props; @observable activeHeading: ?string; @@ -68,10 +68,11 @@ type Props = { {this.headings.map(heading => { const slug = headingToSlug(heading.type, heading.text); + const active = this.activeHeading === slug; return ( - - + + {heading.text} @@ -84,8 +85,16 @@ type Props = { } const Anchor = styled.a` - color: ${props => (props.active ? color.primary : color.slate)}; + 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; + + &:hover { + color: ${color.primary}; + } `; const ListItem = styled.li` @@ -93,8 +102,14 @@ const ListItem = styled.li` margin-left: ${props => (props.type === 'heading2' ? '8px' : '16px')}; text-align: right; color: ${color.slate}; - padding-right: 10px; - opacity: 0; + padding-right: 16px; + + &:after { + color: ${props => (props.active ? color.slateDark : color.slate)}; + content: "${props => (props.type === 'heading2' ? '—' : '–')}"; + position: absolute; + right: 0; + } `; const Sections = styled.ol` @@ -102,11 +117,12 @@ const Sections = styled.ol` padding: 0; list-style: none; font-size: 13px; - border-right: 1px solid ${color.slate}; &:hover { - ${ListItem} { + ${Anchor} { opacity: 1; + margin-right: 0; + background: ${color.white}; } } `; @@ -115,9 +131,7 @@ const Wrapper = styled.div` position: fixed; right: 0; top: 160px; - padding-right: 20px; - background: ${color.white}; z-index: 100; `; -export default Minimap; +export default Contents; From 23c95f4d56dc416a7ac83686002cc1862d423c1e Mon Sep 17 00:00:00 2001 From: Tom Moor Date: Tue, 24 Oct 2017 08:43:35 -0700 Subject: [PATCH 5/6] Fixes: JS error when no heading Fixes: Reduce hover zone Fixes: Add H1s into TOC --- .../components/Editor/components/Contents.js | 40 ++++++++++++------- .../components/Editor/components/Heading.js | 2 +- frontend/components/Editor/headingToSlug.js | 7 ++-- 3 files changed, 31 insertions(+), 18 deletions(-) diff --git a/frontend/components/Editor/components/Contents.js b/frontend/components/Editor/components/Contents.js index 31382cbc7..7a1f19a44 100644 --- a/frontend/components/Editor/components/Contents.js +++ b/frontend/components/Editor/components/Contents.js @@ -26,9 +26,12 @@ type Props = { } updateActiveHeading = () => { - let activeHeading = this.headingElements[0].id; + const elements = this.headingElements; + if (!elements.length) return; - for (const element of this.headingElements) { + let activeHeading = elements[0].id; + + for (const element of elements) { const bounds = element.getBoundingClientRect(); if (bounds.top <= 0) activeHeading = element.id; } @@ -38,7 +41,7 @@ type Props = { get headingElements(): HTMLElement[] { const elements = []; - const tagNames = ['h2', 'h3', 'h4', 'h5', 'h6']; + const tagNames = ['h1', 'h2', 'h3', 'h4', 'h5', 'h6']; for (const tagName of tagNames) { for (const ele of document.getElementsByTagName(tagName)) { @@ -54,7 +57,6 @@ type Props = { return state.document.nodes.filter((node: Block) => { if (!node.text) return false; - if (node.type === 'heading1') return false; return node.type.match(/^heading/); }); } @@ -67,7 +69,7 @@ type Props = { {this.headings.map(heading => { - const slug = headingToSlug(heading.type, heading.text); + const slug = headingToSlug(heading); const active = this.activeHeading === slug; return ( @@ -84,6 +86,13 @@ type Props = { } } +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)}; @@ -91,6 +100,8 @@ const Anchor = styled.a` transition: all 100ms ease-in-out; margin-right: -5px; padding: 2px 0; + pointer-events: none; + text-overflow: ellipsis; &:hover { color: ${color.primary}; @@ -99,14 +110,15 @@ const Anchor = styled.a` const ListItem = styled.li` position: relative; - margin-left: ${props => (props.type === 'heading2' ? '8px' : '16px')}; + 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 === 'heading2' ? '—' : '–')}"; + content: "${props => (props.type.match(/heading[12]/) ? '—' : '–')}"; position: absolute; right: 0; } @@ -117,21 +129,21 @@ const Sections = styled.ol` 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; } } `; -const Wrapper = styled.div` - position: fixed; - right: 0; - top: 160px; - z-index: 100; -`; - export default Contents; diff --git a/frontend/components/Editor/components/Heading.js b/frontend/components/Editor/components/Heading.js index 0a9891d93..4c3ce8130 100644 --- a/frontend/components/Editor/components/Heading.js +++ b/frontend/components/Editor/components/Heading.js @@ -30,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 = headingToSlug(node.type, node.text); + const slugish = headingToSlug(node); const showHash = readOnly && !!slugish; const Component = component; const emoji = editor.props.emoji || ''; diff --git a/frontend/components/Editor/headingToSlug.js b/frontend/components/Editor/headingToSlug.js index 1eee75dcd..04b9d6273 100644 --- a/frontend/components/Editor/headingToSlug.js +++ b/frontend/components/Editor/headingToSlug.js @@ -1,8 +1,9 @@ // @flow import { escape } from 'lodash'; +import type { Node } from './types'; import slug from 'slug'; -export default function headingToSlug(heading: string, title: string) { - const level = heading.replace('heading', 'h'); - return escape(`${level}-${slug(title)}`); +export default function headingToSlug(node: Node) { + const level = node.type.replace('heading', 'h'); + return escape(`${level}-${slug(node.text)}-${node.key}`); } From 8a3b4429d47d544c4a852d9dfb44e5ed468fd47c Mon Sep 17 00:00:00 2001 From: Tom Moor Date: Tue, 24 Oct 2017 08:49:33 -0700 Subject: [PATCH 6/6] Dont merge on github --- frontend/components/Editor/Editor.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/frontend/components/Editor/Editor.js b/frontend/components/Editor/Editor.js index 7e814dca4..0f3768812 100644 --- a/frontend/components/Editor/Editor.js +++ b/frontend/components/Editor/Editor.js @@ -73,9 +73,9 @@ type KeyData = { } } - onChange = (state: State) => { - if (this.editorState !== state) { - this.props.onChange(Markdown.serialize(state)); + onChange = (editorState: State) => { + if (this.editorState !== editorState) { + this.props.onChange(Markdown.serialize(editorState)); } this.editorState = editorState;