From 05c1bee412bf156035d0f6bfd3f08d1bfdc34478 Mon Sep 17 00:00:00 2001 From: Hemachandar <132386067+hmacr@users.noreply.github.com> Date: Sun, 16 Jun 2024 21:51:08 +0530 Subject: [PATCH] feat: allow user to set TOC display preference (#6943) Co-authored-by: Tom Moor --- app/scenes/Document/components/Contents.tsx | 97 ++++------ app/scenes/Document/components/Document.tsx | 180 ++++++++++++------ .../Document/components/DocumentTitle.tsx | 37 +--- app/scenes/Document/components/Editor.tsx | 1 - .../Document/components/RevisionViewer.tsx | 1 - app/scenes/Settings/Details.tsx | 34 +++- server/routes/api/teams/schema.ts | 4 +- shared/constants.ts | 2 + shared/editor/styles/EditorStyleHelper.ts | 3 + shared/i18n/locales/en_US/translation.json | 4 + shared/styles/depths.ts | 1 + shared/types.ts | 8 + 12 files changed, 222 insertions(+), 150 deletions(-) diff --git a/app/scenes/Document/components/Contents.tsx b/app/scenes/Document/components/Contents.tsx index 82012e8220..216fd76fa6 100644 --- a/app/scenes/Document/components/Contents.tsx +++ b/app/scenes/Document/components/Contents.tsx @@ -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(); - 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 ( - - - {t("Contents")} - {headings.length ? ( - - {headings - .filter((heading) => heading.level < 4) - .map((heading) => ( - - {heading.title} - - ))} - - ) : ( - - {t("Headings you add to the document will appear here")} - - )} - - + + {t("Contents")} + {headings.length ? ( + + {headings + .filter((heading) => heading.level < 4) + .map((heading) => ( + + {heading.title} + + ))} + + ) : ( + {t("Headings you add to the document will appear here")} + )} + ); } -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; diff --git a/app/scenes/Document/components/Document.tsx b/app/scenes/Document/components/Document.tsx index 1ca29583a8..26d79acf83 100644 --- a/app/scenes/Document/components/Document.tsx +++ b/app/scenes/Document/components/Document.tsx @@ -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 { 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 { favicon={document.emoji ? emojiToUrl(document.emoji) : undefined} /> {(this.isUploading || this.isSaving) && } - + {!readOnly && ( { onSave={this.onSave} headings={this.headings} /> - + + + }> - - {revision ? ( + {revision ? ( + - ) : ( - <> + + ) : ( + <> + {showContents && ( + + + + )} + { )} - - {showContents && ( - - )} - - )} - + + + )} {isShare && @@ -573,6 +582,95 @@ class DocumentScene extends React.Component { } } +type MainProps = { + fullWidth: boolean; + tocPosition: TOCPosition; +}; + +const Main = styled.div` + 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` + 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` + // 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` + // 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)` - // 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))); diff --git a/app/scenes/Document/components/DocumentTitle.tsx b/app/scenes/Document/components/DocumentTitle.tsx index 40e5340346..7eadb76a5c 100644 --- a/app/scenes/Document/components/DocumentTitle.tsx +++ b/app/scenes/Document/components/DocumentTitle.tsx @@ -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 ? ( - + ) : emoji ? ( - + {emojiIcon} ) : 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 = { diff --git a/app/scenes/Document/components/Editor.tsx b/app/scenes/Document/components/Editor.tsx index 3a91445594..789bf1f6a2 100644 --- a/app/scenes/Document/components/Editor.tsx +++ b/app/scenes/Document/components/Editor.tsx @@ -187,7 +187,6 @@ function DocumentEditor(props: Props, ref: React.RefObject) { : document.title } emoji={document.emoji} - emojiPosition={document.fullWidth ? "top" : "side"} onChangeTitle={onChangeTitle} onChangeEmoji={onChangeEmoji} onGoToNextInput={handleGoToNextInput} diff --git a/app/scenes/Document/components/RevisionViewer.tsx b/app/scenes/Document/components/RevisionViewer.tsx index 424a30b617..549e3c1ca3 100644 --- a/app/scenes/Document/components/RevisionViewer.tsx +++ b/app/scenes/Document/components/RevisionViewer.tsx @@ -31,7 +31,6 @@ function RevisionViewer(props: Props) { documentId={revision.documentId} title={revision.title} emoji={revision.emoji} - emojiPosition={document.fullWidth ? "top" : "side"} readOnly /> { if (event) { @@ -73,6 +78,7 @@ function Details() { ...team.preferences, publicBranding, customTheme, + tocPosition, }, }); toast.success(t("Settings saved")); @@ -174,7 +180,6 @@ function Details() { /> {team.avatarUrl && ( )} + + setTocPosition(p)} + /> + {t("Behavior")} diff --git a/server/routes/api/teams/schema.ts b/server/routes/api/teams/schema.ts index 3d2e269564..476bd42475 100644 --- a/server/routes/api/teams/schema.ts +++ b/server/routes/api/teams/schema.ts @@ -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(), }), diff --git a/shared/constants.ts b/shared/constants.ts index 416157f1e8..b9826b4955 100644 --- a/shared/constants.ts +++ b/shared/constants.ts @@ -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 = { diff --git a/shared/editor/styles/EditorStyleHelper.ts b/shared/editor/styles/EditorStyleHelper.ts index f19ca1da43..f4fabb091d 100644 --- a/shared/editor/styles/EditorStyleHelper.ts +++ b/shared/editor/styles/EditorStyleHelper.ts @@ -36,4 +36,7 @@ export class EditorStyleHelper { /** Minimum padding around editor */ static readonly padding = 32; + + /** Table of contents width */ + static readonly tocWidth = 256; } diff --git a/shared/i18n/locales/en_US/translation.json b/shared/i18n/locales/en_US/translation.json index b078ca41d2..acd47a42cd 100644 --- a/shared/i18n/locales/en_US/translation.json +++ b/shared/i18n/locales/en_US/translation.json @@ -823,6 +823,10 @@ "Accent text color": "Accent text color", "Public branding": "Public branding", "Show your team’s logo on public pages like login and shared documents.": "Show your team’s 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", diff --git a/shared/styles/depths.ts b/shared/styles/depths.ts index 399a1d0a7e..1e0fc42bd9 100644 --- a/shared/styles/depths.ts +++ b/shared/styles/depths.ts @@ -1,4 +1,5 @@ const depths = { + toc: 100, header: 800, sidebar: 900, editorToolbar: 925, diff --git a/shared/types.ts b/shared/types.ts index e97cb8a79e..3a0a8a9a68 100644 --- a/shared/types.ts +++ b/shared/types.ts @@ -184,6 +184,11 @@ export type PublicTeam = { customTheme: Partial; }; +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; + [TeamPreference.TocPosition]?: TOCPosition; }; export enum NavigationNodeType {