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:
@@ -23,7 +23,7 @@ const RealButton = styled.button`
|
|||||||
user-select: none;
|
user-select: none;
|
||||||
|
|
||||||
svg {
|
svg {
|
||||||
fill: ${props => props.theme.buttonText};
|
fill: ${props => props.iconColor || props.theme.buttonText};
|
||||||
}
|
}
|
||||||
|
|
||||||
&::-moz-focus-inner {
|
&::-moz-focus-inner {
|
||||||
@@ -53,11 +53,17 @@ const RealButton = styled.button`
|
|||||||
`
|
`
|
||||||
background: ${props.theme.buttonNeutralBackground};
|
background: ${props.theme.buttonNeutralBackground};
|
||||||
color: ${props.theme.buttonNeutralText};
|
color: ${props.theme.buttonNeutralText};
|
||||||
box-shadow: rgba(0, 0, 0, 0.07) 0px 1px 2px;
|
box-shadow: ${
|
||||||
border: 1px solid ${darken(0.1, props.theme.buttonNeutralBackground)};
|
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 {
|
svg {
|
||||||
fill: ${props.theme.buttonNeutralText};
|
fill: ${props.iconColor || props.theme.buttonNeutralText};
|
||||||
}
|
}
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
@@ -109,17 +115,20 @@ export const Inner = styled.span`
|
|||||||
justify-content: center;
|
justify-content: center;
|
||||||
align-items: 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 = {
|
export type Props = {
|
||||||
type?: string,
|
type?: string,
|
||||||
value?: string,
|
value?: string,
|
||||||
icon?: React.Node,
|
icon?: React.Node,
|
||||||
|
iconColor?: string,
|
||||||
className?: string,
|
className?: string,
|
||||||
children?: React.Node,
|
children?: React.Node,
|
||||||
innerRef?: React.ElementRef<any>,
|
innerRef?: React.ElementRef<any>,
|
||||||
disclosure?: boolean,
|
disclosure?: boolean,
|
||||||
|
borderOnHover?: boolean,
|
||||||
};
|
};
|
||||||
|
|
||||||
function Button({
|
function Button({
|
||||||
@@ -136,7 +145,7 @@ function Button({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<RealButton type={type} ref={innerRef} {...rest}>
|
<RealButton type={type} ref={innerRef} {...rest}>
|
||||||
<Inner hasIcon={hasIcon} disclosure={disclosure}>
|
<Inner hasIcon={hasIcon} hasText={hasText} disclosure={disclosure}>
|
||||||
{hasIcon && icon}
|
{hasIcon && icon}
|
||||||
{hasText && <Label hasIcon={hasIcon}>{children || value}</Label>}
|
{hasText && <Label hasIcon={hasIcon}>{children || value}</Label>}
|
||||||
{disclosure && <ExpandedIcon />}
|
{disclosure && <ExpandedIcon />}
|
||||||
|
|||||||
@@ -92,6 +92,7 @@ class Editor extends React.Component<Props> {
|
|||||||
onShowToast={this.onShowToast}
|
onShowToast={this.onShowToast}
|
||||||
getLinkComponent={this.getLinkComponent}
|
getLinkComponent={this.getLinkComponent}
|
||||||
tooltip={EditorTooltip}
|
tooltip={EditorTooltip}
|
||||||
|
toc={false}
|
||||||
{...this.props}
|
{...this.props}
|
||||||
/>
|
/>
|
||||||
</React.Fragment>
|
</React.Fragment>
|
||||||
|
|||||||
@@ -157,7 +157,7 @@ const Wrapper = styled(Flex)`
|
|||||||
const Label = styled.div`
|
const Label = styled.div`
|
||||||
position: relative;
|
position: relative;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
max-height: 4.4em;
|
max-height: 4.8em;
|
||||||
line-height: 1.6;
|
line-height: 1.6;
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import pkg from 'rich-markdown-editor/package.json';
|
|||||||
import addDays from 'date-fns/add_days';
|
import addDays from 'date-fns/add_days';
|
||||||
import invariant from 'invariant';
|
import invariant from 'invariant';
|
||||||
import { client } from 'utils/ApiClient';
|
import { client } from 'utils/ApiClient';
|
||||||
|
import getHeadingsForText from 'shared/utils/getHeadingsForText';
|
||||||
import parseTitle from 'shared/utils/parseTitle';
|
import parseTitle from 'shared/utils/parseTitle';
|
||||||
import unescape from 'shared/utils/unescape';
|
import unescape from 'shared/utils/unescape';
|
||||||
import BaseModel from 'models/BaseModel';
|
import BaseModel from 'models/BaseModel';
|
||||||
@@ -53,6 +54,11 @@ export default class Document extends BaseModel {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@computed
|
||||||
|
get headings() {
|
||||||
|
return getHeadingsForText(this.text);
|
||||||
|
}
|
||||||
|
|
||||||
@computed
|
@computed
|
||||||
get isOnlyTitle(): boolean {
|
get isOnlyTitle(): boolean {
|
||||||
const { title } = parseTitle(this.text);
|
const { title } = parseTitle(this.text);
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
// @flow
|
// @flow
|
||||||
|
import { computed } from 'mobx';
|
||||||
|
import getHeadingsForText from 'shared/utils/getHeadingsForText';
|
||||||
import BaseModel from './BaseModel';
|
import BaseModel from './BaseModel';
|
||||||
import User from './User';
|
import User from './User';
|
||||||
|
|
||||||
@@ -9,6 +11,11 @@ class Revision extends BaseModel {
|
|||||||
text: string;
|
text: string;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
createdBy: User;
|
createdBy: User;
|
||||||
|
|
||||||
|
@computed
|
||||||
|
get headings() {
|
||||||
|
return getHeadingsForText(this.text);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default Revision;
|
export default Revision;
|
||||||
|
|||||||
127
app/scenes/Document/components/Contents.js
Normal file
127
app/scenes/Document/components/Contents.js
Normal 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;
|
||||||
|
`;
|
||||||
@@ -23,6 +23,7 @@ import KeyboardShortcuts from './KeyboardShortcuts';
|
|||||||
import References from './References';
|
import References from './References';
|
||||||
import Loading from './Loading';
|
import Loading from './Loading';
|
||||||
import Container from './Container';
|
import Container from './Container';
|
||||||
|
import Contents from './Contents';
|
||||||
import MarkAsViewed from './MarkAsViewed';
|
import MarkAsViewed from './MarkAsViewed';
|
||||||
import ErrorBoundary from 'components/ErrorBoundary';
|
import ErrorBoundary from 'components/ErrorBoundary';
|
||||||
import LoadingIndicator from 'components/LoadingIndicator';
|
import LoadingIndicator from 'components/LoadingIndicator';
|
||||||
@@ -132,6 +133,20 @@ class DocumentScene extends React.Component<Props> {
|
|||||||
this.onSave({ publish: true, done: true });
|
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 () => {
|
loadEditor = async () => {
|
||||||
if (this.editorComponent) return;
|
if (this.editorComponent) return;
|
||||||
|
|
||||||
@@ -219,7 +234,15 @@ class DocumentScene extends React.Component<Props> {
|
|||||||
};
|
};
|
||||||
|
|
||||||
render() {
|
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 team = auth.team;
|
||||||
const Editor = this.editorComponent;
|
const Editor = this.editorComponent;
|
||||||
const isShare = match.params.shareId;
|
const isShare = match.params.shareId;
|
||||||
@@ -279,7 +302,12 @@ class DocumentScene extends React.Component<Props> {
|
|||||||
onSave={this.onSave}
|
onSave={this.onSave}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<MaxWidth archived={document.isArchived} column auto>
|
<MaxWidth
|
||||||
|
archived={document.isArchived}
|
||||||
|
tocVisible={ui.tocVisible}
|
||||||
|
column
|
||||||
|
auto
|
||||||
|
>
|
||||||
{document.archivedAt &&
|
{document.archivedAt &&
|
||||||
!document.deletedAt && (
|
!document.deletedAt && (
|
||||||
<Notice muted>
|
<Notice muted>
|
||||||
@@ -301,24 +329,28 @@ class DocumentScene extends React.Component<Props> {
|
|||||||
)}
|
)}
|
||||||
</Notice>
|
</Notice>
|
||||||
)}
|
)}
|
||||||
<Editor
|
<Flex>
|
||||||
id={document.id}
|
{ui.tocVisible &&
|
||||||
key={disableEmbeds ? 'embeds-disabled' : 'embeds-enabled'}
|
readOnly && <Contents document={revision || document} />}
|
||||||
defaultValue={revision ? revision.text : document.text}
|
<Editor
|
||||||
pretitle={document.emoji}
|
id={document.id}
|
||||||
disableEmbeds={disableEmbeds}
|
key={disableEmbeds ? 'embeds-disabled' : 'embeds-enabled'}
|
||||||
onImageUploadStart={this.onImageUploadStart}
|
defaultValue={revision ? revision.text : document.text}
|
||||||
onImageUploadStop={this.onImageUploadStop}
|
pretitle={document.emoji}
|
||||||
onSearchLink={this.props.onSearchLink}
|
disableEmbeds={disableEmbeds}
|
||||||
onChange={this.onChange}
|
onImageUploadStart={this.onImageUploadStart}
|
||||||
onSave={this.onSave}
|
onImageUploadStop={this.onImageUploadStop}
|
||||||
onPublish={this.onPublish}
|
onSearchLink={this.props.onSearchLink}
|
||||||
onCancel={this.goBack}
|
onChange={this.onChange}
|
||||||
readOnly={readOnly || document.isArchived}
|
onSave={this.onSave}
|
||||||
toc={!revision}
|
onPublish={this.onPublish}
|
||||||
ui={this.props.ui}
|
onCancel={this.goBack}
|
||||||
schema={schema}
|
readOnly={readOnly || document.isArchived}
|
||||||
/>
|
ui={this.props.ui}
|
||||||
|
schema={schema}
|
||||||
|
/>
|
||||||
|
</Flex>
|
||||||
|
|
||||||
{readOnly &&
|
{readOnly &&
|
||||||
!isShare &&
|
!isShare &&
|
||||||
!revision && (
|
!revision && (
|
||||||
@@ -352,8 +384,11 @@ const MaxWidth = styled(Flex)`
|
|||||||
${breakpoint('tablet')`
|
${breakpoint('tablet')`
|
||||||
padding: 0 24px;
|
padding: 0 24px;
|
||||||
margin: 4px auto 12px;
|
margin: 4px auto 12px;
|
||||||
|
max-width: ${props => (props.tocVisible ? '64em' : '46em')};
|
||||||
|
`};
|
||||||
|
|
||||||
|
${breakpoint('desktopLarge')`
|
||||||
max-width: 46em;
|
max-width: 46em;
|
||||||
box-sizing: content-box;
|
|
||||||
`};
|
`};
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ class DocumentEditor extends React.Component<Props> {
|
|||||||
ref={ref => (this.editor = ref)}
|
ref={ref => (this.editor = ref)}
|
||||||
autoFocus={!this.props.defaultValue}
|
autoFocus={!this.props.defaultValue}
|
||||||
plugins={plugins}
|
plugins={plugins}
|
||||||
grow={!readOnly}
|
grow
|
||||||
{...this.props}
|
{...this.props}
|
||||||
/>
|
/>
|
||||||
{!readOnly && <ClickablePadding onClick={this.focusAtEnd} grow />}
|
{!readOnly && <ClickablePadding onClick={this.focusAtEnd} grow />}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { observer, inject } from 'mobx-react';
|
|||||||
import { Redirect } from 'react-router-dom';
|
import { Redirect } from 'react-router-dom';
|
||||||
import styled from 'styled-components';
|
import styled from 'styled-components';
|
||||||
import breakpoint from 'styled-components-breakpoint';
|
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 { transparentize, darken } from 'polished';
|
||||||
import Document from 'models/Document';
|
import Document from 'models/Document';
|
||||||
import AuthStore from 'stores/AuthStore';
|
import AuthStore from 'stores/AuthStore';
|
||||||
@@ -14,7 +14,7 @@ import { documentEditUrl } from 'utils/routeHelpers';
|
|||||||
import { meta } from 'utils/keyboard';
|
import { meta } from 'utils/keyboard';
|
||||||
|
|
||||||
import Flex from 'shared/components/Flex';
|
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 DocumentMenu from 'menus/DocumentMenu';
|
||||||
import NewChildDocumentMenu from 'menus/NewChildDocumentMenu';
|
import NewChildDocumentMenu from 'menus/NewChildDocumentMenu';
|
||||||
import DocumentShare from 'scenes/DocumentShare';
|
import DocumentShare from 'scenes/DocumentShare';
|
||||||
@@ -26,8 +26,11 @@ import Badge from 'components/Badge';
|
|||||||
import Collaborators from 'components/Collaborators';
|
import Collaborators from 'components/Collaborators';
|
||||||
import { Action, Separator } from 'components/Actions';
|
import { Action, Separator } from 'components/Actions';
|
||||||
import PoliciesStore from 'stores/PoliciesStore';
|
import PoliciesStore from 'stores/PoliciesStore';
|
||||||
|
import UiStore from 'stores/UiStore';
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
|
auth: AuthStore,
|
||||||
|
ui: UiStore,
|
||||||
policies: PoliciesStore,
|
policies: PoliciesStore,
|
||||||
document: Document,
|
document: Document,
|
||||||
isDraft: boolean,
|
isDraft: boolean,
|
||||||
@@ -43,7 +46,6 @@ type Props = {
|
|||||||
publish?: boolean,
|
publish?: boolean,
|
||||||
autosave?: boolean,
|
autosave?: boolean,
|
||||||
}) => void,
|
}) => void,
|
||||||
auth: AuthStore,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
@observer
|
@observer
|
||||||
@@ -80,7 +82,9 @@ class Header extends React.Component<Props> {
|
|||||||
|
|
||||||
handleShareLink = async (ev: SyntheticEvent<>) => {
|
handleShareLink = async (ev: SyntheticEvent<>) => {
|
||||||
const { document } = this.props;
|
const { document } = this.props;
|
||||||
if (!document.shareUrl) await document.share();
|
if (!document.shareUrl) {
|
||||||
|
await document.share();
|
||||||
|
}
|
||||||
this.showShareModal = true;
|
this.showShareModal = true;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -108,6 +112,7 @@ class Header extends React.Component<Props> {
|
|||||||
isSaving,
|
isSaving,
|
||||||
savingIsDisabled,
|
savingIsDisabled,
|
||||||
publishingIsDisabled,
|
publishingIsDisabled,
|
||||||
|
ui,
|
||||||
auth,
|
auth,
|
||||||
} = this.props;
|
} = this.props;
|
||||||
|
|
||||||
@@ -134,7 +139,33 @@ class Header extends React.Component<Props> {
|
|||||||
onSubmit={this.handleCloseShareModal}
|
onSubmit={this.handleCloseShareModal}
|
||||||
/>
|
/>
|
||||||
</Modal>
|
</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 && (
|
{this.isScrolled && (
|
||||||
<Title onClick={this.handleClickTitle}>
|
<Title onClick={this.handleClickTitle}>
|
||||||
<Fade>
|
<Fade>
|
||||||
@@ -273,6 +304,14 @@ const Status = styled.div`
|
|||||||
color: ${props => props.theme.slate};
|
color: ${props => props.theme.slate};
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
const BreadcrumbAndContents = styled(Flex)`
|
||||||
|
display: none;
|
||||||
|
|
||||||
|
${breakpoint('tablet')`
|
||||||
|
display: flex;
|
||||||
|
`};
|
||||||
|
`;
|
||||||
|
|
||||||
const Wrapper = styled(Flex)`
|
const Wrapper = styled(Flex)`
|
||||||
width: 100%;
|
width: 100%;
|
||||||
align-self: flex-end;
|
align-self: flex-end;
|
||||||
@@ -289,7 +328,7 @@ const Actions = styled(Flex)`
|
|||||||
right: 0;
|
right: 0;
|
||||||
left: 0;
|
left: 0;
|
||||||
z-index: 1;
|
z-index: 1;
|
||||||
background: ${props => transparentize(0.1, props.theme.background)};
|
background: ${props => transparentize(0.2, props.theme.background)};
|
||||||
box-shadow: 0 1px 0
|
box-shadow: 0 1px 0
|
||||||
${props =>
|
${props =>
|
||||||
props.isCompact
|
props.isCompact
|
||||||
@@ -298,7 +337,7 @@ const Actions = styled(Flex)`
|
|||||||
padding: 12px;
|
padding: 12px;
|
||||||
transition: all 100ms ease-out;
|
transition: all 100ms ease-out;
|
||||||
transform: translate3d(0, 0, 0);
|
transform: translate3d(0, 0, 0);
|
||||||
-webkit-backdrop-filter: blur(20px);
|
backdrop-filter: blur(20px);
|
||||||
|
|
||||||
@media print {
|
@media print {
|
||||||
display: none;
|
display: none;
|
||||||
@@ -327,4 +366,4 @@ const Title = styled.div`
|
|||||||
`};
|
`};
|
||||||
`;
|
`;
|
||||||
|
|
||||||
export default inject('auth', 'policies')(Header);
|
export default inject('auth', 'ui', 'policies')(Header);
|
||||||
|
|||||||
@@ -37,14 +37,13 @@ export default class RevisionsStore extends BaseStore<Revision> {
|
|||||||
revisionId: id,
|
revisionId: id,
|
||||||
});
|
});
|
||||||
invariant(res && res.data, 'Revision not available');
|
invariant(res && res.data, 'Revision not available');
|
||||||
const { data } = res;
|
this.add(res.data);
|
||||||
|
|
||||||
runInAction('RevisionsStore#fetch', () => {
|
runInAction('RevisionsStore#fetch', () => {
|
||||||
this.data.set(data.id, data);
|
|
||||||
this.isLoaded = true;
|
this.isLoaded = true;
|
||||||
});
|
});
|
||||||
|
|
||||||
return data;
|
return this.data.get(res.data.id);
|
||||||
} finally {
|
} finally {
|
||||||
this.isFetching = false;
|
this.isFetching = false;
|
||||||
}
|
}
|
||||||
@@ -58,9 +57,7 @@ export default class RevisionsStore extends BaseStore<Revision> {
|
|||||||
const res = await client.post('/documents.revisions', options);
|
const res = await client.post('/documents.revisions', options);
|
||||||
invariant(res && res.data, 'Document revisions not available');
|
invariant(res && res.data, 'Document revisions not available');
|
||||||
runInAction('RevisionsStore#fetchPage', () => {
|
runInAction('RevisionsStore#fetchPage', () => {
|
||||||
res.data.forEach(revision => {
|
res.data.forEach(revision => this.add(revision));
|
||||||
this.data.set(revision.id, revision);
|
|
||||||
});
|
|
||||||
this.isLoaded = true;
|
this.isLoaded = true;
|
||||||
});
|
});
|
||||||
return res.data;
|
return res.data;
|
||||||
|
|||||||
@@ -1,25 +1,47 @@
|
|||||||
// @flow
|
// @flow
|
||||||
import { v4 } from 'uuid';
|
import { v4 } from 'uuid';
|
||||||
import { orderBy } from 'lodash';
|
import { orderBy } from 'lodash';
|
||||||
import { observable, action, computed } from 'mobx';
|
import { observable, action, autorun, computed } from 'mobx';
|
||||||
import Document from 'models/Document';
|
import Document from 'models/Document';
|
||||||
import Collection from 'models/Collection';
|
import Collection from 'models/Collection';
|
||||||
import type { Toast } from '../types';
|
import type { Toast } from '../types';
|
||||||
|
|
||||||
|
const UI_STORE = 'UI_STORE';
|
||||||
|
|
||||||
class UiStore {
|
class UiStore {
|
||||||
@observable
|
@observable theme: 'light' | 'dark';
|
||||||
theme: 'light' | 'dark' = (window.localStorage &&
|
|
||||||
window.localStorage.getItem('theme')) ||
|
|
||||||
'light';
|
|
||||||
@observable activeModalName: ?string;
|
@observable activeModalName: ?string;
|
||||||
@observable activeModalProps: ?Object;
|
@observable activeModalProps: ?Object;
|
||||||
@observable activeDocumentId: ?string;
|
@observable activeDocumentId: ?string;
|
||||||
@observable activeCollectionId: ?string;
|
@observable activeCollectionId: ?string;
|
||||||
@observable progressBarVisible: boolean = false;
|
@observable progressBarVisible: boolean = false;
|
||||||
@observable editMode: boolean = false;
|
@observable editMode: boolean = false;
|
||||||
|
@observable tocVisible: boolean = false;
|
||||||
@observable mobileSidebarVisible: boolean = false;
|
@observable mobileSidebarVisible: boolean = false;
|
||||||
@observable toasts: Map<string, Toast> = new Map();
|
@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
|
@action
|
||||||
toggleDarkMode = () => {
|
toggleDarkMode = () => {
|
||||||
this.theme = this.theme === 'dark' ? 'light' : 'dark';
|
this.theme = this.theme === 'dark' ? 'light' : 'dark';
|
||||||
@@ -66,6 +88,16 @@ class UiStore {
|
|||||||
this.activeCollectionId = undefined;
|
this.activeCollectionId = undefined;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@action
|
||||||
|
showTableOfContents = () => {
|
||||||
|
this.tocVisible = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
@action
|
||||||
|
hideTableOfContents = () => {
|
||||||
|
this.tocVisible = false;
|
||||||
|
};
|
||||||
|
|
||||||
@action
|
@action
|
||||||
enableEditMode() {
|
enableEditMode() {
|
||||||
this.editMode = true;
|
this.editMode = true;
|
||||||
@@ -125,6 +157,14 @@ class UiStore {
|
|||||||
get orderedToasts(): Toast[] {
|
get orderedToasts(): Toast[] {
|
||||||
return orderBy(Array.from(this.toasts.values()), 'createdAt', 'desc');
|
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;
|
export default UiStore;
|
||||||
|
|||||||
@@ -55,6 +55,7 @@
|
|||||||
"url": "git+ssh://git@github.com/outline/outline.git"
|
"url": "git+ssh://git@github.com/outline/outline.git"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@rehooks/window-scroll-position": "^1.0.1",
|
||||||
"@sentry/node": "^5.12.2",
|
"@sentry/node": "^5.12.2",
|
||||||
"@tippy.js/react": "^2.2.2",
|
"@tippy.js/react": "^2.2.2",
|
||||||
"@tommoor/remove-markdown": "0.3.1",
|
"@tommoor/remove-markdown": "0.3.1",
|
||||||
@@ -119,7 +120,7 @@
|
|||||||
"mobx-react": "^5.4.2",
|
"mobx-react": "^5.4.2",
|
||||||
"natural-sort": "^1.0.0",
|
"natural-sort": "^1.0.0",
|
||||||
"nodemailer": "^4.4.0",
|
"nodemailer": "^4.4.0",
|
||||||
"outline-icons": "^1.13.0",
|
"outline-icons": "^1.14.0",
|
||||||
"oy-vey": "^0.10.0",
|
"oy-vey": "^0.10.0",
|
||||||
"pg": "^6.1.5",
|
"pg": "^6.1.5",
|
||||||
"pg-hstore": "2.3.2",
|
"pg-hstore": "2.3.2",
|
||||||
|
|||||||
@@ -81,7 +81,6 @@ const Breadcrumb = observer(({ document, collections, onlyText }: Props) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const Wrapper = styled(Flex)`
|
const Wrapper = styled(Flex)`
|
||||||
width: 33.3%;
|
|
||||||
display: none;
|
display: none;
|
||||||
|
|
||||||
${breakpoint('tablet')`
|
${breakpoint('tablet')`
|
||||||
@@ -101,7 +100,7 @@ const SmallSlash = styled(GoToIcon)`
|
|||||||
opacity: 0.25;
|
opacity: 0.25;
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const Slash = styled(GoToIcon)`
|
export const Slash = styled(GoToIcon)`
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
opacity: 0.25;
|
opacity: 0.25;
|
||||||
`;
|
`;
|
||||||
|
|||||||
@@ -53,6 +53,13 @@ export const base = {
|
|||||||
selected: colors.primary,
|
selected: colors.primary,
|
||||||
buttonBackground: colors.primary,
|
buttonBackground: colors.primary,
|
||||||
buttonText: colors.white,
|
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 = {
|
export const light = {
|
||||||
|
|||||||
27
shared/utils/getHeadingsForText.js
Normal file
27
shared/utils/getHeadingsForText.js
Normal 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
8
shared/utils/slugify.js
Normal 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('.', '-'))}`;
|
||||||
|
}
|
||||||
20
yarn.lock
20
yarn.lock
@@ -208,6 +208,13 @@
|
|||||||
dependencies:
|
dependencies:
|
||||||
"@types/node" "^12.11.1"
|
"@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":
|
"@sentry/apm@5.12.3":
|
||||||
version "5.12.3"
|
version "5.12.3"
|
||||||
resolved "https://registry.yarnpkg.com/@sentry/apm/-/apm-5.12.3.tgz#23a5e9c771a8748f59426a1d0f8b1fbb9b72a717"
|
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"
|
resolved "https://registry.yarnpkg.com/lodash.sortby/-/lodash.sortby-4.7.0.tgz#edd14c824e2cc9c1e0b0a1b42bb5210516a42438"
|
||||||
integrity sha1-7dFMgk4sycHgsKG0K7UhBRakJDg=
|
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:
|
lodash.uniq@^4.5.0:
|
||||||
version "4.5.0"
|
version "4.5.0"
|
||||||
resolved "https://registry.yarnpkg.com/lodash.uniq/-/lodash.uniq-4.5.0.tgz#d0225373aeb652adc1bc82e4945339a842754773"
|
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"
|
resolved "https://registry.yarnpkg.com/outline-icons/-/outline-icons-1.10.0.tgz#3c8e6957429e2b04c9d0fc72fe72e473813ce5bd"
|
||||||
integrity sha512-1o3SnjzawEIh+QkZ6GHxPckuV+Tk5m5R2tjGY0CtosF3YA7JbgQ2jQrZdQsrqLzLa1j07f1bTEbAjGdbnunLpg==
|
integrity sha512-1o3SnjzawEIh+QkZ6GHxPckuV+Tk5m5R2tjGY0CtosF3YA7JbgQ2jQrZdQsrqLzLa1j07f1bTEbAjGdbnunLpg==
|
||||||
|
|
||||||
outline-icons@^1.13.0:
|
outline-icons@^1.14.0:
|
||||||
version "1.13.0"
|
version "1.14.0"
|
||||||
resolved "https://registry.yarnpkg.com/outline-icons/-/outline-icons-1.13.0.tgz#61ea3824a2ec23ea91bb636aa7e1ba6ebf0c2da6"
|
resolved "https://registry.yarnpkg.com/outline-icons/-/outline-icons-1.14.0.tgz#1f737fa6d58e8ccf0f1d1f4287832e0c4d6656ad"
|
||||||
integrity sha512-kG/3ugK8lqAz0b4n8yiuw3XENqoIlTguYQ/NiU5A4ccbOV16HESBVau6ftwIoLbHbio6vEMdRNRwD4GQFtUDFw==
|
integrity sha512-LaFgl5i8wBm7Ud7aXH+VR/3NNJy7UvUih+gu1S2vyYawFHnGkfh1/EwQ2LcrNOLUXr/pH+I5g9UhSeUnCDkCFg==
|
||||||
|
|
||||||
oy-vey@^0.10.0:
|
oy-vey@^0.10.0:
|
||||||
version "0.10.0"
|
version "0.10.0"
|
||||||
|
|||||||
Reference in New Issue
Block a user