feat: allow user to set TOC display preference (#6943)

Co-authored-by: Tom Moor <tom.moor@gmail.com>
This commit is contained in:
Hemachandar
2024-06-16 21:51:08 +05:30
committed by GitHub
parent 3d0160463c
commit 05c1bee412
12 changed files with 222 additions and 150 deletions

View File

@@ -3,15 +3,14 @@ import * as React from "react";
import { useTranslation } from "react-i18next";
import styled from "styled-components";
import breakpoint from "styled-components-breakpoint";
import { s } from "@shared/styles";
import { EditorStyleHelper } from "@shared/editor/styles/EditorStyleHelper";
import { depths, s } from "@shared/styles";
import Text from "~/components/Text";
import useWindowScrollPosition from "~/hooks/useWindowScrollPosition";
const HEADING_OFFSET = 20;
type Props = {
/** Whether the document is rendering full width or not. */
isFullWidth: boolean;
/** The headings to render in the contents. */
headings: {
title: string;
@@ -20,9 +19,9 @@ type Props = {
}[];
};
export default function Contents({ headings, isFullWidth }: Props) {
export default function Contents({ headings }: Props) {
const [activeSlug, setActiveSlug] = React.useState<string>();
const position = useWindowScrollPosition({
const scrollPosition = useWindowScrollPosition({
throttle: 100,
});
@@ -43,7 +42,7 @@ export default function Contents({ headings, isFullWidth }: Props) {
}
}
}
}, [position, headings]);
}, [scrollPosition, headings]);
// calculate the minimum heading level and adjust all the headings to make
// that the top-most. This prevents the contents from being weirdly indented
@@ -56,70 +55,53 @@ export default function Contents({ headings, isFullWidth }: Props) {
const { t } = useTranslation();
return (
<Wrapper isFullWidth={isFullWidth}>
<Sticky>
<Heading>{t("Contents")}</Heading>
{headings.length ? (
<List>
{headings
.filter((heading) => heading.level < 4)
.map((heading) => (
<ListItem
key={heading.id}
level={heading.level - headingAdjustment}
active={activeSlug === heading.id}
>
<Link href={`#${heading.id}`}>{heading.title}</Link>
</ListItem>
))}
</List>
) : (
<Empty>
{t("Headings you add to the document will appear here")}
</Empty>
)}
</Sticky>
</Wrapper>
<StickyWrapper>
<Heading>{t("Contents")}</Heading>
{headings.length ? (
<List>
{headings
.filter((heading) => heading.level < 4)
.map((heading) => (
<ListItem
key={heading.id}
level={heading.level - headingAdjustment}
active={activeSlug === heading.id}
>
<Link href={`#${heading.id}`}>{heading.title}</Link>
</ListItem>
))}
</List>
) : (
<Empty>{t("Headings you add to the document will appear here")}</Empty>
)}
</StickyWrapper>
);
}
const Wrapper = styled.div<{ isFullWidth: boolean }>`
width: 256px;
const StickyWrapper = styled.div`
display: none;
${breakpoint("tablet")`
display: block;
`};
${(props) =>
!props.isFullWidth &&
breakpoint("desktopLarge")`
transform: translateX(-256px);
width: 0;
`}
`;
const Sticky = styled.div`
position: sticky;
top: 80px;
max-height: calc(100vh - 80px);
top: 90px;
max-height: calc(100vh - 90px);
width: ${EditorStyleHelper.tocWidth}px;
padding: 0 16px;
overflow-y: auto;
border-radius: 8px;
background: ${s("background")};
transition: ${s("backgroundTransition")};
margin-top: calc(50px + 6vh);
margin-right: 52px;
min-width: 204px;
width: 228px;
min-height: 40px;
overflow-y: auto;
padding: 0 16px;
border-radius: 8px;
@supports (backdrop-filter: blur(20px)) {
backdrop-filter: blur(20px);
background: ${(props) => transparentize(0.2, props.theme.background)};
}
${breakpoint("tablet")`
display: block;
z-index: ${depths.toc};
`};
`;
const Heading = styled.h3`
@@ -131,15 +113,12 @@ const Heading = styled.h3`
`;
const Empty = styled(Text)`
margin: 1em 0 4em;
padding-right: 2em;
font-size: 14px;
`;
const ListItem = styled.li<{ level: number; active?: boolean }>`
margin-left: ${(props) => (props.level - 1) * 10}px;
margin-bottom: 8px;
padding-right: 2em;
line-height: 1.3;
word-break: break-word;

View File

@@ -17,8 +17,9 @@ import {
import { toast } from "sonner";
import styled from "styled-components";
import breakpoint from "styled-components-breakpoint";
import { EditorStyleHelper } from "@shared/editor/styles/EditorStyleHelper";
import { s } from "@shared/styles";
import { NavigationNode } from "@shared/types";
import { NavigationNode, TOCPosition, TeamPreference } from "@shared/types";
import { ProsemirrorHelper, Heading } from "@shared/utils/ProsemirrorHelper";
import { parseDomain } from "@shared/utils/domains";
import RootStore from "~/stores/RootStore";
@@ -403,6 +404,9 @@ class DocumentScene extends React.Component<Props> {
const hasHeadings = this.headings.length > 0;
const showContents =
ui.tocVisible && ((readOnly && hasHeadings) || !readOnly);
const tocPosition =
(team?.getPreference(TeamPreference.TocPosition) as TOCPosition) ||
TOCPosition.Left;
const multiplayerEditor =
!document.isArchived && !document.isDeleted && !revision && !isShare;
@@ -449,7 +453,7 @@ class DocumentScene extends React.Component<Props> {
favicon={document.emoji ? emojiToUrl(document.emoji) : undefined}
/>
{(this.isUploading || this.isSaving) && <LoadingIndicator />}
<Container justify="center" column auto>
<Container column>
{!readOnly && (
<Prompt
when={this.isUploading && !this.isEditorDirty}
@@ -476,27 +480,39 @@ class DocumentScene extends React.Component<Props> {
onSave={this.onSave}
headings={this.headings}
/>
<MeasuredContainer
as={MaxWidth}
name="document"
archived={document.isArchived}
showContents={showContents}
isEditing={!readOnly}
isFullWidth={document.fullWidth}
column
auto
>
<Flex justify="center">
<Notices document={document} readOnly={readOnly} />
</Flex>
<MeasuredContainer
as={Main}
name="document"
fullWidth={document.fullWidth}
tocPosition={tocPosition}
>
<React.Suspense fallback={<PlaceholderDocument />}>
<Flex auto={!readOnly} reverse>
{revision ? (
{revision ? (
<RevisionContainer docFullWidth={document.fullWidth}>
<RevisionViewer
document={document}
revision={revision}
id={revision.id}
/>
) : (
<>
</RevisionContainer>
) : (
<>
{showContents && (
<ContentsContainer
docFullWidth={document.fullWidth}
position={tocPosition}
>
<Contents headings={this.headings} />
</ContentsContainer>
)}
<EditorContainer
docFullWidth={document.fullWidth}
showContents={showContents}
tocPosition={tocPosition}
>
<Editor
id={document.id}
key={embedsDisabled ? "disabled" : "enabled"}
@@ -543,16 +559,9 @@ class DocumentScene extends React.Component<Props> {
</>
)}
</Editor>
{showContents && (
<Contents
headings={this.headings}
isFullWidth={document.fullWidth}
/>
)}
</>
)}
</Flex>
</EditorContainer>
</>
)}
</React.Suspense>
</MeasuredContainer>
{isShare &&
@@ -573,6 +582,95 @@ class DocumentScene extends React.Component<Props> {
}
}
type MainProps = {
fullWidth: boolean;
tocPosition: TOCPosition;
};
const Main = styled.div<MainProps>`
margin-top: 4px;
${breakpoint("tablet")`
display: grid;
grid-template-columns: ${({ fullWidth, tocPosition }: MainProps) =>
fullWidth
? tocPosition === TOCPosition.Left
? `${EditorStyleHelper.tocWidth}px minmax(0, 1fr)`
: `minmax(0, 1fr) ${EditorStyleHelper.tocWidth}px`
: `1fr minmax(0, ${`calc(46em + 76px)`}) 1fr`};
`};
${breakpoint("desktopLarge")`
grid-template-columns: ${({ fullWidth, tocPosition }: MainProps) =>
fullWidth
? tocPosition === TOCPosition.Left
? `${EditorStyleHelper.tocWidth}px minmax(0, 1fr)`
: `minmax(0, 1fr) ${EditorStyleHelper.tocWidth}px`
: `1fr minmax(0, ${`calc(52em + 76px)`}) 1fr`};
`};
`;
type ContentsContainerProps = {
docFullWidth: boolean;
position: TOCPosition;
};
const ContentsContainer = styled.div<ContentsContainerProps>`
margin-top: calc(44px + 6vh);
${breakpoint("tablet")`
grid-row: 1;
grid-column: ${({ docFullWidth, position }: ContentsContainerProps) =>
position === TOCPosition.Left ? 1 : docFullWidth ? 2 : 3};
justify-self: ${({ position }: ContentsContainerProps) =>
position === TOCPosition.Left ? "end" : "start"};
`};
`;
type EditorContainerProps = {
docFullWidth: boolean;
showContents: boolean;
tocPosition: TOCPosition;
};
const EditorContainer = styled.div<EditorContainerProps>`
// Adds space to the gutter to make room for icon & heading annotations
padding: 0 44px;
${breakpoint("tablet")`
grid-row: 1;
// Decides the editor column position & span
grid-column: ${({
docFullWidth,
showContents,
tocPosition,
}: EditorContainerProps) =>
docFullWidth
? showContents
? tocPosition === TOCPosition.Left
? 2
: 1
: "1 / -1"
: 2};
`};
`;
type RevisionContainerProps = {
docFullWidth: boolean;
};
const RevisionContainer = styled.div<RevisionContainerProps>`
// Adds space to the gutter to make room for icon
padding: 0 44px;
${breakpoint("tablet")`
grid-row: 1;
grid-column: ${({ docFullWidth }: RevisionContainerProps) =>
docFullWidth ? "1 / -1" : 2};
`}
`;
const Footer = styled.div`
position: absolute;
width: 100%;
@@ -595,34 +693,4 @@ const ReferencesWrapper = styled.div`
}
`;
type MaxWidthProps = {
isEditing?: boolean;
isFullWidth?: boolean;
archived?: boolean;
showContents?: boolean;
};
const MaxWidth = styled(Flex)<MaxWidthProps>`
// Adds space to the gutter to make room for heading annotations
padding: 0 32px;
transition: padding 100ms;
max-width: 100vw;
width: 100%;
padding-bottom: 16px;
${breakpoint("tablet")`
margin: 4px auto 12px;
max-width: ${(props: MaxWidthProps) =>
props.isFullWidth
? "100vw"
: `calc(64px + 46em + ${props.showContents ? "256px" : "0px"});`}
`};
${breakpoint("desktopLarge")`
max-width: ${(props: MaxWidthProps) =>
props.isFullWidth ? "100vw" : `calc(64px + 52em);`}
`};
`;
export default withTranslation()(withStores(withRouter(DocumentScene)));

View File

@@ -4,7 +4,7 @@ import { Selection } from "prosemirror-state";
import { __parseFromClipboard } from "prosemirror-view";
import * as React from "react";
import { mergeRefs } from "react-merge-refs";
import styled, { css } from "styled-components";
import styled from "styled-components";
import breakpoint from "styled-components-breakpoint";
import isMarkdown from "@shared/editor/lib/isMarkdown";
import normalizePastedMarkdown from "@shared/editor/lib/markdown/normalize";
@@ -33,8 +33,6 @@ type Props = {
title: string;
/** Emoji to display */
emoji?: string | null;
/** Position of the emoji relative to text */
emojiPosition: "side" | "top";
/** Placeholder to display when the document has no title */
placeholder?: string;
/** Should the title be editable, policies will also be considered separately */
@@ -59,7 +57,6 @@ const DocumentTitle = React.forwardRef(function _DocumentTitle(
documentId,
title,
emoji,
emojiPosition,
readOnly,
onChangeTitle,
onChangeEmoji,
@@ -247,12 +244,7 @@ const DocumentTitle = React.forwardRef(function _DocumentTitle(
ref={mergeRefs([ref, externalRef])}
>
{can.update && !readOnly ? (
<EmojiWrapper
align="center"
justify="center"
$position={emojiPosition}
dir={dir}
>
<EmojiWrapper align="center" justify="center" dir={dir}>
<React.Suspense fallback={emojiIcon}>
<StyledEmojiPicker
value={emoji}
@@ -265,12 +257,7 @@ const DocumentTitle = React.forwardRef(function _DocumentTitle(
</React.Suspense>
</EmojiWrapper>
) : emoji ? (
<EmojiWrapper
align="center"
justify="center"
$position={emojiPosition}
dir={dir}
>
<EmojiWrapper align="center" justify="center" dir={dir}>
{emojiIcon}
</EmojiWrapper>
) : null}
@@ -282,25 +269,17 @@ const StyledEmojiPicker = styled(EmojiPicker)`
${extraArea(8)}
`;
const EmojiWrapper = styled(Flex)<{ $position: "top" | "side"; dir?: string }>`
const EmojiWrapper = styled(Flex)<{ dir?: string }>`
position: absolute;
top: 8px;
height: 32px;
width: 32px;
// Always move above TOC
z-index: 1;
${(props) =>
props.$position === "top"
? css`
position: relative;
top: -8px;
`
: css`
position: absolute;
top: 8px;
${(props: { dir?: string }) =>
props.dir === "rtl" ? "right: -40px" : "left: -40px"};
`}
${(props: { dir?: string }) =>
props.dir === "rtl" ? "right: -40px" : "left: -40px"};
`;
type TitleProps = {

View File

@@ -187,7 +187,6 @@ function DocumentEditor(props: Props, ref: React.RefObject<any>) {
: document.title
}
emoji={document.emoji}
emojiPosition={document.fullWidth ? "top" : "side"}
onChangeTitle={onChangeTitle}
onChangeEmoji={onChangeEmoji}
onGoToNextInput={handleGoToNextInput}

View File

@@ -31,7 +31,6 @@ function RevisionViewer(props: Props) {
documentId={revision.documentId}
title={revision.title}
emoji={revision.emoji}
emojiPosition={document.fullWidth ? "top" : "side"}
readOnly
/>
<DocumentMeta

View File

@@ -8,7 +8,7 @@ import { useTranslation, Trans } from "react-i18next";
import { toast } from "sonner";
import { ThemeProvider, useTheme } from "styled-components";
import { buildDarkTheme, buildLightTheme } from "@shared/styles/theme";
import { CustomTheme, TeamPreference } from "@shared/types";
import { CustomTheme, TOCPosition, TeamPreference } from "@shared/types";
import { getBaseDomain } from "@shared/utils/domains";
import Button from "~/components/Button";
import ButtonLink from "~/components/ButtonLink";
@@ -16,6 +16,7 @@ import DefaultCollectionInputSelect from "~/components/DefaultCollectionInputSel
import Heading from "~/components/Heading";
import Input from "~/components/Input";
import InputColor from "~/components/InputColor";
import InputSelect from "~/components/InputSelect";
import Scene from "~/components/Scene";
import Switch from "~/components/Switch";
import Text from "~/components/Text";
@@ -58,6 +59,10 @@ function Details() {
isHexColor
);
const [tocPosition, setTocPosition] = useState(
team.getPreference(TeamPreference.TocPosition) as TOCPosition
);
const handleSubmit = React.useCallback(
async (event?: React.SyntheticEvent) => {
if (event) {
@@ -73,6 +78,7 @@ function Details() {
...team.preferences,
publicBranding,
customTheme,
tocPosition,
},
});
toast.success(t("Settings saved"));
@@ -174,7 +180,6 @@ function Details() {
/>
</SettingRow>
<SettingRow
border={false}
label={t("Theme")}
name="accent"
description={
@@ -212,7 +217,6 @@ function Details() {
</SettingRow>
{team.avatarUrl && (
<SettingRow
border={false}
name={TeamPreference.PublicBranding}
label={t("Public branding")}
description={t(
@@ -229,6 +233,30 @@ function Details() {
/>
</SettingRow>
)}
<SettingRow
border={false}
label={t("Table of contents position")}
name="tocPosition"
description={t(
"The side to display the table of contents in relation to the main content."
)}
>
<InputSelect
ariaLabel={t("Table of contents position")}
options={[
{
label: t("Left"),
value: TOCPosition.Left,
},
{
label: t("Right"),
value: TOCPosition.Right,
},
]}
value={tocPosition}
onChange={(p: TOCPosition) => setTocPosition(p)}
/>
</SettingRow>
<Heading as="h2">{t("Behavior")}</Heading>

View File

@@ -1,5 +1,5 @@
import { z } from "zod";
import { UserRole } from "@shared/types";
import { TOCPosition, UserRole } from "@shared/types";
import { BaseSchema } from "@server/routes/api/schema";
export const TeamsUpdateSchema = BaseSchema.extend({
@@ -50,6 +50,8 @@ export const TeamsUpdateSchema = BaseSchema.extend({
accentText: z.string().min(4).max(7).regex(/^#/).optional(),
})
.optional(),
/** Side to display the document's table of contents in relation to the main content. */
tocPosition: z.nativeEnum(TOCPosition).optional(),
})
.optional(),
}),

View File

@@ -1,4 +1,5 @@
import {
TOCPosition,
TeamPreference,
TeamPreferences,
UserPreference,
@@ -22,6 +23,7 @@ export const TeamPreferenceDefaults: TeamPreferences = {
[TeamPreference.PublicBranding]: false,
[TeamPreference.Commenting]: true,
[TeamPreference.CustomTheme]: undefined,
[TeamPreference.TocPosition]: TOCPosition.Left,
};
export const UserPreferenceDefaults: UserPreferences = {

View File

@@ -36,4 +36,7 @@ export class EditorStyleHelper {
/** Minimum padding around editor */
static readonly padding = 32;
/** Table of contents width */
static readonly tocWidth = 256;
}

View File

@@ -823,6 +823,10 @@
"Accent text color": "Accent text color",
"Public branding": "Public branding",
"Show your teams logo on public pages like login and shared documents.": "Show your teams logo on public pages like login and shared documents.",
"Table of contents position": "Table of contents position",
"The side to display the table of contents in relation to the main content.": "The side to display the table of contents in relation to the main content.",
"Left": "Left",
"Right": "Right",
"Behavior": "Behavior",
"Subdomain": "Subdomain",
"Your workspace will be accessible at": "Your workspace will be accessible at",

View File

@@ -1,4 +1,5 @@
const depths = {
toc: 100,
header: 800,
sidebar: 900,
editorToolbar: 925,

View File

@@ -184,6 +184,11 @@ export type PublicTeam = {
customTheme: Partial<CustomTheme>;
};
export enum TOCPosition {
Left = "left",
Right = "right",
}
export enum TeamPreference {
/** Whether documents have a separate edit mode instead of always editing. */
SeamlessEdit = "seamlessEdit",
@@ -199,6 +204,8 @@ export enum TeamPreference {
Commenting = "commenting",
/** The custom theme for the team. */
CustomTheme = "customTheme",
/** Side to display the document's table of contents in relation to the main content. */
TocPosition = "tocPosition",
}
export type TeamPreferences = {
@@ -209,6 +216,7 @@ export type TeamPreferences = {
[TeamPreference.MembersCanCreateApiKey]?: boolean;
[TeamPreference.Commenting]?: boolean;
[TeamPreference.CustomTheme]?: Partial<CustomTheme>;
[TeamPreference.TocPosition]?: TOCPosition;
};
export enum NavigationNodeType {