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:
parent
0deecfac44
commit
d0606a72c3
@ -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 />}
|
||||
|
@ -92,6 +92,7 @@ class Editor extends React.Component<Props> {
|
||||
onShowToast={this.onShowToast}
|
||||
getLinkComponent={this.getLinkComponent}
|
||||
tooltip={EditorTooltip}
|
||||
toc={false}
|
||||
{...this.props}
|
||||
/>
|
||||
</React.Fragment>
|
||||
|
@ -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;
|
||||
`;
|
||||
|
||||
|
@ -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);
|
||||
|
@ -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;
|
||||
|
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 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;
|
||||
`};
|
||||
`;
|
||||
|
||||
|
@ -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 />}
|
||||
|
@ -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);
|
||||
|
@ -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;
|
||||
|
@ -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;
|
||||
|
@ -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",
|
||||
|
@ -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;
|
||||
`;
|
||||
|
@ -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 = {
|
||||
|
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:
|
||||
"@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"
|
||||
|
Reference in New Issue
Block a user