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
This commit is contained in:
Tom Moor 2020-04-05 12:22:26 -07:00 committed by GitHub
parent 0deecfac44
commit d0606a72c3
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
17 changed files with 370 additions and 55 deletions

View File

@ -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<any>,
disclosure?: boolean,
borderOnHover?: boolean,
};
function Button({
@ -136,7 +145,7 @@ function Button({
return (
<RealButton type={type} ref={innerRef} {...rest}>
<Inner hasIcon={hasIcon} disclosure={disclosure}>
<Inner hasIcon={hasIcon} hasText={hasText} disclosure={disclosure}>
{hasIcon && icon}
{hasText && <Label hasIcon={hasIcon}>{children || value}</Label>}
{disclosure && <ExpandedIcon />}

View File

@ -92,6 +92,7 @@ class Editor extends React.Component<Props> {
onShowToast={this.onShowToast}
getLinkComponent={this.getLinkComponent}
tooltip={EditorTooltip}
toc={false}
{...this.props}
/>
</React.Fragment>

View File

@ -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;
`;

View File

@ -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);

View File

@ -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;

View File

@ -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 (
<div>
<Wrapper>
<Heading>Contents</Heading>
{headings.length ? (
<List>
{headings.map(heading => (
<ListItem
level={heading.level}
active={activeSlug === heading.slug}
>
<Link href={`#${heading.slug}`}>{heading.title}</Link>
</ListItem>
))}
</List>
) : (
<Empty>Headings you add to the document will appear here</Empty>
)}
</Wrapper>
</div>
);
}
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;
`;

View File

@ -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<Props> {
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<Props> {
};
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<Props> {
onSave={this.onSave}
/>
)}
<MaxWidth archived={document.isArchived} column auto>
<MaxWidth
archived={document.isArchived}
tocVisible={ui.tocVisible}
column
auto
>
{document.archivedAt &&
!document.deletedAt && (
<Notice muted>
@ -301,24 +329,28 @@ class DocumentScene extends React.Component<Props> {
)}
</Notice>
)}
<Editor
id={document.id}
key={disableEmbeds ? 'embeds-disabled' : 'embeds-enabled'}
defaultValue={revision ? revision.text : document.text}
pretitle={document.emoji}
disableEmbeds={disableEmbeds}
onImageUploadStart={this.onImageUploadStart}
onImageUploadStop={this.onImageUploadStop}
onSearchLink={this.props.onSearchLink}
onChange={this.onChange}
onSave={this.onSave}
onPublish={this.onPublish}
onCancel={this.goBack}
readOnly={readOnly || document.isArchived}
toc={!revision}
ui={this.props.ui}
schema={schema}
/>
<Flex>
{ui.tocVisible &&
readOnly && <Contents document={revision || document} />}
<Editor
id={document.id}
key={disableEmbeds ? 'embeds-disabled' : 'embeds-enabled'}
defaultValue={revision ? revision.text : document.text}
pretitle={document.emoji}
disableEmbeds={disableEmbeds}
onImageUploadStart={this.onImageUploadStart}
onImageUploadStop={this.onImageUploadStop}
onSearchLink={this.props.onSearchLink}
onChange={this.onChange}
onSave={this.onSave}
onPublish={this.onPublish}
onCancel={this.goBack}
readOnly={readOnly || document.isArchived}
ui={this.props.ui}
schema={schema}
/>
</Flex>
{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;
`};
`;

View File

@ -39,7 +39,7 @@ class DocumentEditor extends React.Component<Props> {
ref={ref => (this.editor = ref)}
autoFocus={!this.props.defaultValue}
plugins={plugins}
grow={!readOnly}
grow
{...this.props}
/>
{!readOnly && <ClickablePadding onClick={this.focusAtEnd} grow />}

View File

@ -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<Props> {
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<Props> {
isSaving,
savingIsDisabled,
publishingIsDisabled,
ui,
auth,
} = this.props;
@ -134,7 +139,33 @@ class Header extends React.Component<Props> {
onSubmit={this.handleCloseShareModal}
/>
</Modal>
<Breadcrumb document={document} />
<BreadcrumbAndContents align="center" justify="flex-start">
<Breadcrumb document={document} />
{!isEditing && (
<React.Fragment>
<Slash />
<Tooltip
tooltip={ui.tocVisible ? 'Hide contents' : 'Show contents'}
shortcut={`ctrl+${meta}+h`}
delay={250}
placement="bottom"
>
<Button
onClick={
ui.tocVisible
? ui.hideTableOfContents
: ui.showTableOfContents
}
icon={<TableOfContentsIcon />}
iconColor="currentColor"
borderOnHover
neutral
small
/>
</Tooltip>
</React.Fragment>
)}
</BreadcrumbAndContents>
{this.isScrolled && (
<Title onClick={this.handleClickTitle}>
<Fade>
@ -273,6 +304,14 @@ const Status = styled.div`
color: ${props => props.theme.slate};
`;
const BreadcrumbAndContents = styled(Flex)`
display: none;
${breakpoint('tablet')`
display: flex;
`};
`;
const Wrapper = styled(Flex)`
width: 100%;
align-self: flex-end;
@ -289,7 +328,7 @@ const Actions = styled(Flex)`
right: 0;
left: 0;
z-index: 1;
background: ${props => transparentize(0.1, props.theme.background)};
background: ${props => transparentize(0.2, props.theme.background)};
box-shadow: 0 1px 0
${props =>
props.isCompact
@ -298,7 +337,7 @@ const Actions = styled(Flex)`
padding: 12px;
transition: all 100ms ease-out;
transform: translate3d(0, 0, 0);
-webkit-backdrop-filter: blur(20px);
backdrop-filter: blur(20px);
@media print {
display: none;
@ -327,4 +366,4 @@ const Title = styled.div`
`};
`;
export default inject('auth', 'policies')(Header);
export default inject('auth', 'ui', 'policies')(Header);

View File

@ -37,14 +37,13 @@ export default class RevisionsStore extends BaseStore<Revision> {
revisionId: id,
});
invariant(res && res.data, 'Revision not available');
const { data } = res;
this.add(res.data);
runInAction('RevisionsStore#fetch', () => {
this.data.set(data.id, data);
this.isLoaded = true;
});
return data;
return this.data.get(res.data.id);
} finally {
this.isFetching = false;
}
@ -58,9 +57,7 @@ export default class RevisionsStore extends BaseStore<Revision> {
const res = await client.post('/documents.revisions', options);
invariant(res && res.data, 'Document revisions not available');
runInAction('RevisionsStore#fetchPage', () => {
res.data.forEach(revision => {
this.data.set(revision.id, revision);
});
res.data.forEach(revision => this.add(revision));
this.isLoaded = true;
});
return res.data;

View File

@ -1,25 +1,47 @@
// @flow
import { v4 } from 'uuid';
import { orderBy } from 'lodash';
import { observable, action, computed } from 'mobx';
import { observable, action, autorun, computed } from 'mobx';
import Document from 'models/Document';
import Collection from 'models/Collection';
import type { Toast } from '../types';
const UI_STORE = 'UI_STORE';
class UiStore {
@observable
theme: 'light' | 'dark' = (window.localStorage &&
window.localStorage.getItem('theme')) ||
'light';
@observable theme: 'light' | 'dark';
@observable activeModalName: ?string;
@observable activeModalProps: ?Object;
@observable activeDocumentId: ?string;
@observable activeCollectionId: ?string;
@observable progressBarVisible: boolean = false;
@observable editMode: boolean = false;
@observable tocVisible: boolean = false;
@observable mobileSidebarVisible: boolean = false;
@observable toasts: Map<string, Toast> = new Map();
constructor() {
// Rehydrate
let data = {};
try {
data = JSON.parse(localStorage.getItem(UI_STORE) || '{}');
} catch (_) {
// no-op Safari private mode
}
// persisted keys
this.tocVisible = data.tocVisible;
this.theme = data.theme || 'light';
autorun(() => {
try {
localStorage.setItem(UI_STORE, this.asJson);
} catch (_) {
// no-op Safari private mode
}
});
}
@action
toggleDarkMode = () => {
this.theme = this.theme === 'dark' ? 'light' : 'dark';
@ -66,6 +88,16 @@ class UiStore {
this.activeCollectionId = undefined;
};
@action
showTableOfContents = () => {
this.tocVisible = true;
};
@action
hideTableOfContents = () => {
this.tocVisible = false;
};
@action
enableEditMode() {
this.editMode = true;
@ -125,6 +157,14 @@ class UiStore {
get orderedToasts(): Toast[] {
return orderBy(Array.from(this.toasts.values()), 'createdAt', 'desc');
}
@computed
get asJson(): string {
return JSON.stringify({
tocVisible: this.tocVisible,
theme: this.theme,
});
}
}
export default UiStore;

View File

@ -55,6 +55,7 @@
"url": "git+ssh://git@github.com/outline/outline.git"
},
"dependencies": {
"@rehooks/window-scroll-position": "^1.0.1",
"@sentry/node": "^5.12.2",
"@tippy.js/react": "^2.2.2",
"@tommoor/remove-markdown": "0.3.1",
@ -119,7 +120,7 @@
"mobx-react": "^5.4.2",
"natural-sort": "^1.0.0",
"nodemailer": "^4.4.0",
"outline-icons": "^1.13.0",
"outline-icons": "^1.14.0",
"oy-vey": "^0.10.0",
"pg": "^6.1.5",
"pg-hstore": "2.3.2",

View File

@ -81,7 +81,6 @@ const Breadcrumb = observer(({ document, collections, onlyText }: Props) => {
});
const Wrapper = styled(Flex)`
width: 33.3%;
display: none;
${breakpoint('tablet')`
@ -101,7 +100,7 @@ const SmallSlash = styled(GoToIcon)`
opacity: 0.25;
`;
const Slash = styled(GoToIcon)`
export const Slash = styled(GoToIcon)`
flex-shrink: 0;
opacity: 0.25;
`;

View File

@ -53,6 +53,13 @@ export const base = {
selected: colors.primary,
buttonBackground: colors.primary,
buttonText: colors.white,
breakpoints: {
mobile: 0, // targeting all devices
tablet: 737, // targeting devices that are larger than the iPhone 6 Plus (which is 736px in landscape mode)
desktop: 1025, // targeting devices that are larger than the iPad (which is 1024px in landscape mode)
desktopLarge: 1550,
},
};
export const light = {

View File

@ -0,0 +1,27 @@
// @flow
import { filter } from 'lodash';
import slugify from 'shared/utils/slugify';
export default function getHeadingsForText(
text: string
): { level: number, title: string, slug: string }[] {
const regex = /^(#{1,6})\s(.*)$/gm;
let match;
let output = [];
while ((match = regex.exec(text)) !== null) {
if (!match) continue;
const level = match[1].length;
const title = match[2];
let slug = slugify(title);
const existing = filter(output, { slug });
if (existing.length) {
slug = `${slug}-${existing.length}`;
}
output.push({ level, title, slug });
}
return output;
}

8
shared/utils/slugify.js Normal file
View File

@ -0,0 +1,8 @@
// @flow
import slugify from 'slugify';
// Slugify, escape, and remove periods from headings so that they are
// compatible with url hashes AND dom selectors
export default function safeSlugify(text: string) {
return `h-${escape(slugify(text, { lower: true }).replace('.', '-'))}`;
}

View File

@ -208,6 +208,13 @@
dependencies:
"@types/node" "^12.11.1"
"@rehooks/window-scroll-position@^1.0.1":
version "1.0.1"
resolved "https://registry.yarnpkg.com/@rehooks/window-scroll-position/-/window-scroll-position-1.0.1.tgz#3cb80f22cbf9cdbd2041b5236ae1fce8245b2f1c"
integrity sha512-+7uUcU2DBzXW4ygKTCqjCrtT4Nq0f+hNxQvAw69pXSBc7DbqmzfpxrYu27dT4tXrUKSQPFPpo5AdMv2oUJVM7g==
dependencies:
lodash.throttle "^4.1.1"
"@sentry/apm@5.12.3":
version "5.12.3"
resolved "https://registry.yarnpkg.com/@sentry/apm/-/apm-5.12.3.tgz#23a5e9c771a8748f59426a1d0f8b1fbb9b72a717"
@ -6252,6 +6259,11 @@ lodash.sortby@^4.7.0:
resolved "https://registry.yarnpkg.com/lodash.sortby/-/lodash.sortby-4.7.0.tgz#edd14c824e2cc9c1e0b0a1b42bb5210516a42438"
integrity sha1-7dFMgk4sycHgsKG0K7UhBRakJDg=
lodash.throttle@^4.1.1:
version "4.1.1"
resolved "https://registry.yarnpkg.com/lodash.throttle/-/lodash.throttle-4.1.1.tgz#c23e91b710242ac70c37f1e1cda9274cc39bf2f4"
integrity sha1-wj6RtxAkKscMN/HhzaknTMOb8vQ=
lodash.uniq@^4.5.0:
version "4.5.0"
resolved "https://registry.yarnpkg.com/lodash.uniq/-/lodash.uniq-4.5.0.tgz#d0225373aeb652adc1bc82e4945339a842754773"
@ -7160,10 +7172,10 @@ outline-icons@^1.10.0:
resolved "https://registry.yarnpkg.com/outline-icons/-/outline-icons-1.10.0.tgz#3c8e6957429e2b04c9d0fc72fe72e473813ce5bd"
integrity sha512-1o3SnjzawEIh+QkZ6GHxPckuV+Tk5m5R2tjGY0CtosF3YA7JbgQ2jQrZdQsrqLzLa1j07f1bTEbAjGdbnunLpg==
outline-icons@^1.13.0:
version "1.13.0"
resolved "https://registry.yarnpkg.com/outline-icons/-/outline-icons-1.13.0.tgz#61ea3824a2ec23ea91bb636aa7e1ba6ebf0c2da6"
integrity sha512-kG/3ugK8lqAz0b4n8yiuw3XENqoIlTguYQ/NiU5A4ccbOV16HESBVau6ftwIoLbHbio6vEMdRNRwD4GQFtUDFw==
outline-icons@^1.14.0:
version "1.14.0"
resolved "https://registry.yarnpkg.com/outline-icons/-/outline-icons-1.14.0.tgz#1f737fa6d58e8ccf0f1d1f4287832e0c4d6656ad"
integrity sha512-LaFgl5i8wBm7Ud7aXH+VR/3NNJy7UvUih+gu1S2vyYawFHnGkfh1/EwQ2LcrNOLUXr/pH+I5g9UhSeUnCDkCFg==
oy-vey@^0.10.0:
version "0.10.0"