From d0606a72c3f853c623c24fe44714b1fd66afaf33 Mon Sep 17 00:00:00 2001 From: Tom Moor Date: Sun, 5 Apr 2020 12:22:26 -0700 Subject: [PATCH] feat: Improved table of contents (#1223) * feat: New table of contents * fix: Hide TOC in edit mode * feat: Highlight follows scroll position * scroll tracking * UI * fix: Unrelated css fix with long doc titles * Improve responsiveness * feat: Add keyboard shortcut access to TOC * fix: Headings should reflect content correctly when viewing old document revision * flow * fix: Persist TOC choice between sessions --- app/components/Button.js | 21 ++- app/components/Editor/Editor.js | 1 + .../Sidebar/components/SidebarLink.js | 2 +- app/models/Document.js | 6 + app/models/Revision.js | 7 + app/scenes/Document/components/Contents.js | 127 ++++++++++++++++++ app/scenes/Document/components/Document.js | 77 ++++++++--- app/scenes/Document/components/Editor.js | 2 +- app/scenes/Document/components/Header.js | 55 ++++++-- app/stores/RevisionsStore.js | 9 +- app/stores/UiStore.js | 50 ++++++- package.json | 3 +- shared/components/Breadcrumb.js | 3 +- shared/styles/theme.js | 7 + shared/utils/getHeadingsForText.js | 27 ++++ shared/utils/slugify.js | 8 ++ yarn.lock | 20 ++- 17 files changed, 370 insertions(+), 55 deletions(-) create mode 100644 app/scenes/Document/components/Contents.js create mode 100644 shared/utils/getHeadingsForText.js create mode 100644 shared/utils/slugify.js diff --git a/app/components/Button.js b/app/components/Button.js index a7daa2ee5..569cc2184 100644 --- a/app/components/Button.js +++ b/app/components/Button.js @@ -23,7 +23,7 @@ const RealButton = styled.button` user-select: none; svg { - fill: ${props => props.theme.buttonText}; + fill: ${props => props.iconColor || props.theme.buttonText}; } &::-moz-focus-inner { @@ -53,11 +53,17 @@ const RealButton = styled.button` ` background: ${props.theme.buttonNeutralBackground}; color: ${props.theme.buttonNeutralText}; - box-shadow: rgba(0, 0, 0, 0.07) 0px 1px 2px; - border: 1px solid ${darken(0.1, props.theme.buttonNeutralBackground)}; + box-shadow: ${ + props.borderOnHover ? 'none' : 'rgba(0, 0, 0, 0.07) 0px 1px 2px' + }; + border: 1px solid ${ + props.borderOnHover + ? 'transparent' + : darken(0.1, props.theme.buttonNeutralBackground) + }; svg { - fill: ${props.theme.buttonNeutralText}; + fill: ${props.iconColor || props.theme.buttonNeutralText}; } &:hover { @@ -109,17 +115,20 @@ export const Inner = styled.span` justify-content: center; align-items: center; - ${props => props.hasIcon && 'padding-left: 4px;'}; + ${props => props.hasIcon && props.hasText && 'padding-left: 4px;'}; + ${props => props.hasIcon && !props.hasText && 'padding: 0 4px;'}; `; export type Props = { type?: string, value?: string, icon?: React.Node, + iconColor?: string, className?: string, children?: React.Node, innerRef?: React.ElementRef, disclosure?: boolean, + borderOnHover?: boolean, }; function Button({ @@ -136,7 +145,7 @@ function Button({ return ( - + {hasIcon && icon} {hasText && } {disclosure && } diff --git a/app/components/Editor/Editor.js b/app/components/Editor/Editor.js index 4f6210bfe..370c55c6d 100644 --- a/app/components/Editor/Editor.js +++ b/app/components/Editor/Editor.js @@ -92,6 +92,7 @@ class Editor extends React.Component { onShowToast={this.onShowToast} getLinkComponent={this.getLinkComponent} tooltip={EditorTooltip} + toc={false} {...this.props} /> diff --git a/app/components/Sidebar/components/SidebarLink.js b/app/components/Sidebar/components/SidebarLink.js index 5877cd88f..a70d9f035 100644 --- a/app/components/Sidebar/components/SidebarLink.js +++ b/app/components/Sidebar/components/SidebarLink.js @@ -157,7 +157,7 @@ const Wrapper = styled(Flex)` const Label = styled.div` position: relative; width: 100%; - max-height: 4.4em; + max-height: 4.8em; line-height: 1.6; `; diff --git a/app/models/Document.js b/app/models/Document.js index 03e65fe66..93b7d4f6c 100644 --- a/app/models/Document.js +++ b/app/models/Document.js @@ -4,6 +4,7 @@ import pkg from 'rich-markdown-editor/package.json'; import addDays from 'date-fns/add_days'; import invariant from 'invariant'; import { client } from 'utils/ApiClient'; +import getHeadingsForText from 'shared/utils/getHeadingsForText'; import parseTitle from 'shared/utils/parseTitle'; import unescape from 'shared/utils/unescape'; import BaseModel from 'models/BaseModel'; @@ -53,6 +54,11 @@ export default class Document extends BaseModel { } } + @computed + get headings() { + return getHeadingsForText(this.text); + } + @computed get isOnlyTitle(): boolean { const { title } = parseTitle(this.text); diff --git a/app/models/Revision.js b/app/models/Revision.js index ce1571f9c..2ed9ffa52 100644 --- a/app/models/Revision.js +++ b/app/models/Revision.js @@ -1,4 +1,6 @@ // @flow +import { computed } from 'mobx'; +import getHeadingsForText from 'shared/utils/getHeadingsForText'; import BaseModel from './BaseModel'; import User from './User'; @@ -9,6 +11,11 @@ class Revision extends BaseModel { text: string; createdAt: string; createdBy: User; + + @computed + get headings() { + return getHeadingsForText(this.text); + } } export default Revision; diff --git a/app/scenes/Document/components/Contents.js b/app/scenes/Document/components/Contents.js new file mode 100644 index 000000000..aef858dac --- /dev/null +++ b/app/scenes/Document/components/Contents.js @@ -0,0 +1,127 @@ +// @flow +import * as React from 'react'; +import { darken } from 'polished'; +import breakpoint from 'styled-components-breakpoint'; +import useWindowScrollPosition from '@rehooks/window-scroll-position'; +import HelpText from 'components/HelpText'; +import styled from 'styled-components'; +import Document from 'models/Document'; +import Revision from 'models/Revision'; + +const HEADING_OFFSET = 20; + +type Props = { + document: Revision | Document, +}; + +export default function Contents({ document }: Props) { + const headings = document.headings; + + // $FlowFixMe + const [activeSlug, setActiveSlug] = React.useState(); + const position = useWindowScrollPosition({ throttle: 100 }); + + // $FlowFixMe + React.useEffect( + () => { + for (let key = 0; key < headings.length; key++) { + const heading = headings[key]; + const element = window.document.getElementById( + decodeURIComponent(heading.slug) + ); + + if (element) { + const bounding = element.getBoundingClientRect(); + if (bounding.top > HEADING_OFFSET) { + const last = headings[Math.max(0, key - 1)]; + setActiveSlug(last.slug); + break; + } + } + } + }, + [position] + ); + + return ( +
+ + Contents + {headings.length ? ( + + {headings.map(heading => ( + + {heading.title} + + ))} + + ) : ( + Headings you add to the document will appear here + )} + +
+ ); +} + +const Wrapper = styled('div')` + display: none; + position: sticky; + top: 80px; + + box-shadow: 1px 0 0 ${props => darken(0.05, props.theme.sidebarBackground)}; + margin-top: 40px; + margin-right: 2em; + min-height: 40px; + + ${breakpoint('desktopLarge')` + margin-left: -16em; + `}; + + ${breakpoint('tablet')` + display: block; + `}; +`; + +const Heading = styled('h3')` + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + color: ${props => props.theme.sidebarText}; + letter-spacing: 0.04em; +`; + +const Empty = styled(HelpText)` + margin: 1em 0 4em; + padding-right: 2em; + min-width: 16em; + width: 16em; + font-size: 14px; +`; + +const ListItem = styled('li')` + margin-left: ${props => (props.level - 1) * 10}px; + margin-bottom: 8px; + padding-right: 2em; + line-height: 1.3; + border-right: 3px solid + ${props => (props.active ? props.theme.textSecondary : 'transparent')}; +`; + +const Link = styled('a')` + color: ${props => props.theme.text}; + font-size: 14px; + + &:hover { + color: ${props => props.theme.primary}; + } +`; + +const List = styled('ol')` + min-width: 14em; + width: 14em; + padding: 0; + list-style: none; +`; diff --git a/app/scenes/Document/components/Document.js b/app/scenes/Document/components/Document.js index fb2219a00..5147187d8 100644 --- a/app/scenes/Document/components/Document.js +++ b/app/scenes/Document/components/Document.js @@ -23,6 +23,7 @@ import KeyboardShortcuts from './KeyboardShortcuts'; import References from './References'; import Loading from './Loading'; import Container from './Container'; +import Contents from './Contents'; import MarkAsViewed from './MarkAsViewed'; import ErrorBoundary from 'components/ErrorBoundary'; import LoadingIndicator from 'components/LoadingIndicator'; @@ -132,6 +133,20 @@ class DocumentScene extends React.Component { this.onSave({ publish: true, done: true }); } + @keydown('meta+ctrl+h') + onToggleTableOfContents(ev) { + if (!this.props.readOnly) return; + + ev.preventDefault(); + const { ui } = this.props; + + if (ui.tocVisible) { + ui.hideTableOfContents(); + } else { + ui.showTableOfContents(); + } + } + loadEditor = async () => { if (this.editorComponent) return; @@ -219,7 +234,15 @@ class DocumentScene extends React.Component { }; render() { - const { document, revision, readOnly, location, auth, match } = this.props; + const { + document, + revision, + readOnly, + location, + auth, + ui, + match, + } = this.props; const team = auth.team; const Editor = this.editorComponent; const isShare = match.params.shareId; @@ -279,7 +302,12 @@ class DocumentScene extends React.Component { onSave={this.onSave} /> )} - + {document.archivedAt && !document.deletedAt && ( @@ -301,24 +329,28 @@ class DocumentScene extends React.Component { )} )} - + + {ui.tocVisible && + readOnly && } + + + {readOnly && !isShare && !revision && ( @@ -352,8 +384,11 @@ const MaxWidth = styled(Flex)` ${breakpoint('tablet')` padding: 0 24px; margin: 4px auto 12px; + max-width: ${props => (props.tocVisible ? '64em' : '46em')}; + `}; + + ${breakpoint('desktopLarge')` max-width: 46em; - box-sizing: content-box; `}; `; diff --git a/app/scenes/Document/components/Editor.js b/app/scenes/Document/components/Editor.js index 540b8a9a7..1a7b9ea78 100644 --- a/app/scenes/Document/components/Editor.js +++ b/app/scenes/Document/components/Editor.js @@ -39,7 +39,7 @@ class DocumentEditor extends React.Component { ref={ref => (this.editor = ref)} autoFocus={!this.props.defaultValue} plugins={plugins} - grow={!readOnly} + grow {...this.props} /> {!readOnly && } diff --git a/app/scenes/Document/components/Header.js b/app/scenes/Document/components/Header.js index 78b5f9004..0172c9089 100644 --- a/app/scenes/Document/components/Header.js +++ b/app/scenes/Document/components/Header.js @@ -6,7 +6,7 @@ import { observer, inject } from 'mobx-react'; import { Redirect } from 'react-router-dom'; import styled from 'styled-components'; import breakpoint from 'styled-components-breakpoint'; -import { EditIcon, PlusIcon } from 'outline-icons'; +import { TableOfContentsIcon, EditIcon, PlusIcon } from 'outline-icons'; import { transparentize, darken } from 'polished'; import Document from 'models/Document'; import AuthStore from 'stores/AuthStore'; @@ -14,7 +14,7 @@ import { documentEditUrl } from 'utils/routeHelpers'; import { meta } from 'utils/keyboard'; import Flex from 'shared/components/Flex'; -import Breadcrumb from 'shared/components/Breadcrumb'; +import Breadcrumb, { Slash } from 'shared/components/Breadcrumb'; import DocumentMenu from 'menus/DocumentMenu'; import NewChildDocumentMenu from 'menus/NewChildDocumentMenu'; import DocumentShare from 'scenes/DocumentShare'; @@ -26,8 +26,11 @@ import Badge from 'components/Badge'; import Collaborators from 'components/Collaborators'; import { Action, Separator } from 'components/Actions'; import PoliciesStore from 'stores/PoliciesStore'; +import UiStore from 'stores/UiStore'; type Props = { + auth: AuthStore, + ui: UiStore, policies: PoliciesStore, document: Document, isDraft: boolean, @@ -43,7 +46,6 @@ type Props = { publish?: boolean, autosave?: boolean, }) => void, - auth: AuthStore, }; @observer @@ -80,7 +82,9 @@ class Header extends React.Component { handleShareLink = async (ev: SyntheticEvent<>) => { const { document } = this.props; - if (!document.shareUrl) await document.share(); + if (!document.shareUrl) { + await document.share(); + } this.showShareModal = true; }; @@ -108,6 +112,7 @@ class Header extends React.Component { isSaving, savingIsDisabled, publishingIsDisabled, + ui, auth, } = this.props; @@ -134,7 +139,33 @@ class Header extends React.Component { onSubmit={this.handleCloseShareModal} /> - + + + {!isEditing && ( + + + +