From 233536314f9d8794594f58b42d13645099f05691 Mon Sep 17 00:00:00 2001 From: sanua356 <51795446+sanua356@users.noreply.github.com> Date: Sat, 5 Apr 2025 21:03:42 +0300 Subject: [PATCH] feat: add Table of contents (#981) * chore: add table of contents module * refactor * lint * null check --------- Co-authored-by: Philipinho <16838612+Philipinho@users.noreply.github.com> --- .../public/locales/en-US/translation.json | 4 +- .../public/locales/zh-CN/translation.json | 4 + .../src/components/layouts/global/aside.tsx | 8 + .../global/hooks/atoms/sidebar-atom.ts | 2 +- .../table-of-contents.module.css | 54 ++++++ .../table-of-contents/table-of-contents.tsx | 165 ++++++++++++++++++ .../features/editor/extensions/extensions.ts | 2 +- .../components/header/page-header-menu.tsx | 13 +- .../user/components/page-width-pref.tsx | 7 +- .../src/features/user/types/user.types.ts | 2 +- .../settings/account/account-preferences.tsx | 2 +- .../src/core/user/dto/update-user.dto.ts | 2 +- apps/server/src/core/user/user.service.ts | 13 +- packages/editor-ext/src/index.ts | 2 +- 14 files changed, 259 insertions(+), 21 deletions(-) create mode 100644 apps/client/src/features/editor/components/table-of-contents/table-of-contents.module.css create mode 100644 apps/client/src/features/editor/components/table-of-contents/table-of-contents.tsx diff --git a/apps/client/public/locales/en-US/translation.json b/apps/client/public/locales/en-US/translation.json index 135fc9ea..71102b73 100644 --- a/apps/client/public/locales/en-US/translation.json +++ b/apps/client/public/locales/en-US/translation.json @@ -357,5 +357,7 @@ "Move": "Move", "Move page": "Move page", "Move page to a different space.": "Move page to a different space.", - "Real-time editor connection lost. Retrying...": "Real-time editor connection lost. Retrying..." + "Real-time editor connection lost. Retrying...": "Real-time editor connection lost. Retrying...", + "Table of contents": "Table of contents", + "Add headings (H1, H2, H3) to generate a table of contents.": "Add headings (H1, H2, H3) to generate a table of contents." } diff --git a/apps/client/public/locales/zh-CN/translation.json b/apps/client/public/locales/zh-CN/translation.json index 31788ae2..0c45cd50 100644 --- a/apps/client/public/locales/zh-CN/translation.json +++ b/apps/client/public/locales/zh-CN/translation.json @@ -75,6 +75,8 @@ "Full access": "完全访问", "Full page width": "全页宽度", "Full width": "全宽", + "View headings": "查看标题", + "Show article title menu.": "显示文章标题菜单", "General": "常规", "Group": "群组", "Group description": "群组描述", @@ -170,8 +172,10 @@ "Successfully restored": "恢复成功", "System settings": "系统设置", "Theme": "主题", + "On this page": "他是这个页面", "To change your email, you have to enter your password and new email.": "要更改您的电子邮箱,您需要输入密码和新的电子邮箱地址。", "Toggle full page width": "切换全页宽度", + "Toggle view headings menu": "切换查看广告菜单", "Unable to import pages. Please try again.": "无法导入页面。请重试。", "untitled": "无标题", "Untitled": "无标题", diff --git a/apps/client/src/components/layouts/global/aside.tsx b/apps/client/src/components/layouts/global/aside.tsx index 4d13f9c5..a590aabc 100644 --- a/apps/client/src/components/layouts/global/aside.tsx +++ b/apps/client/src/components/layouts/global/aside.tsx @@ -4,10 +4,14 @@ import { useAtom } from "jotai"; import { asideStateAtom } from "@/components/layouts/global/hooks/atoms/sidebar-atom.ts"; import React, { ReactNode } from "react"; import { useTranslation } from "react-i18next"; +import { TableOfContents } from "@/features/editor/components/table-of-contents/table-of-contents.tsx"; +import { useAtomValue } from "jotai"; +import { pageEditorAtom } from "@/features/editor/atoms/editor-atoms.ts"; export default function Aside() { const [{ tab }] = useAtom(asideStateAtom); const { t } = useTranslation(); + const pageEditor = useAtomValue(pageEditorAtom); let title: string; let component: ReactNode; @@ -17,6 +21,10 @@ export default function Aside() { component = ; title = "Comments"; break; + case "toc": + component = ; + title = "Table of contents"; + break; default: component = null; title = null; diff --git a/apps/client/src/components/layouts/global/hooks/atoms/sidebar-atom.ts b/apps/client/src/components/layouts/global/hooks/atoms/sidebar-atom.ts index f71fc6f5..0e8b78a0 100644 --- a/apps/client/src/components/layouts/global/hooks/atoms/sidebar-atom.ts +++ b/apps/client/src/components/layouts/global/hooks/atoms/sidebar-atom.ts @@ -20,4 +20,4 @@ export const asideStateAtom = atom({ isAsideOpen: false, }); -export const sidebarWidthAtom = atomWithWebStorage('sidebarWidth', 300); +export const sidebarWidthAtom = atomWithWebStorage('sidebarWidth', 300); \ No newline at end of file diff --git a/apps/client/src/features/editor/components/table-of-contents/table-of-contents.module.css b/apps/client/src/features/editor/components/table-of-contents/table-of-contents.module.css new file mode 100644 index 00000000..9554a84d --- /dev/null +++ b/apps/client/src/features/editor/components/table-of-contents/table-of-contents.module.css @@ -0,0 +1,54 @@ +.headerPadding { + display: none; + top: calc( + var(--app-shell-header-offset, 0rem) + var(--app-shell-header-height, 0rem) + ); +} + +.link { + outline: none; + cursor: pointer; + display: block; + width: 100%; + text-align: start; + word-wrap: break-word; + background-color: transparent; + color: var(--mantine-color-text); + font-size: var(--mantine-font-size-sm); + line-height: var(--mantine-line-height-sm); + padding: 6px; + border-top-right-radius: var(--mantine-radius-sm); + border-bottom-right-radius: var(--mantine-radius-sm); + border: none; + + @mixin hover { + background-color: light-dark( + var(--mantine-color-gray-2), + var(--mantine-color-dark-6) + ); + } + + @media (max-width: $mantine-breakpoint-sm) { + & { + border: none !important; + padding-left: 0px; + } + } +} + +.linkActive { + font-weight: 500; + border-left-color: light-dark( + var(--mantine-color-grey-5), + var(--mantine-color-grey-3) + ); + color: light-dark(var(--mantine-color-gray-7), var(--mantine-color-dark-1)); + + &, + &:hover { + background-color: light-dark( + var(--mantine-color-gray-3), + var(--mantine-color-dark-5) + ) !important; + } +} diff --git a/apps/client/src/features/editor/components/table-of-contents/table-of-contents.tsx b/apps/client/src/features/editor/components/table-of-contents/table-of-contents.tsx new file mode 100644 index 00000000..6945a29b --- /dev/null +++ b/apps/client/src/features/editor/components/table-of-contents/table-of-contents.tsx @@ -0,0 +1,165 @@ +import { NodePos, useEditor } from "@tiptap/react"; +import { TextSelection } from "@tiptap/pm/state"; +import React, { FC, useEffect, useRef, useState } from "react"; +import classes from "./table-of-contents.module.css"; +import clsx from "clsx"; +import { Box, Text } from "@mantine/core"; +import { useTranslation } from "react-i18next"; + +type TableOfContentsProps = { + editor: ReturnType; +}; + +export type HeadingLink = { + label: string; + level: number; + element: HTMLElement; + position: number; +}; + +const recalculateLinks = (nodePos: NodePos[]) => { + const nodes: HTMLElement[] = []; + + const links: HeadingLink[] = Array.from(nodePos).reduce( + (acc, item) => { + const label = item.node.textContent; + const level = Number(item.node.attrs.level); + if (label.length && level <= 3) { + acc.push({ + label, + level, + element: item.element, + //@ts-ignore + position: item.resolvedPos.pos, + }); + nodes.push(item.element); + } + return acc; + }, + [], + ); + return { links, nodes }; +}; + +export const TableOfContents: FC = (props) => { + const { t } = useTranslation(); + const [links, setLinks] = useState([]); + const [headingDOMNodes, setHeadingDOMNodes] = useState([]); + const [activeElement, setActiveElement] = useState(null); + const headerPaddingRef = useRef(null); + + const handleScrollToHeading = (position: number) => { + const { view } = props.editor; + + const headerOffset = parseInt( + window.getComputedStyle(headerPaddingRef.current).getPropertyValue("top"), + ); + + const { node } = view.domAtPos(position); + const element = node as HTMLElement; + const scrollPosition = + element.getBoundingClientRect().top + window.scrollY - headerOffset; + + window.scrollTo({ + top: scrollPosition, + behavior: "smooth", + }); + + const tr = view.state.tr; + tr.setSelection(new TextSelection(tr.doc.resolve(position))); + view.dispatch(tr); + view.focus(); + }; + + const handleUpdate = () => { + const result = recalculateLinks(props.editor?.$nodes("heading")); + setLinks(result.links); + setHeadingDOMNodes(result.nodes); + }; + + useEffect(() => { + props.editor?.on("update", handleUpdate); + + return () => { + props.editor?.off("update", handleUpdate); + }; + }, [props.editor]); + + useEffect(() => { + handleUpdate(); + }, []); + + useEffect(() => { + try { + const observeHandler = (entries: IntersectionObserverEntry[]) => { + entries.forEach((entry) => { + if (entry.isIntersecting) { + setActiveElement(entry.target as HTMLElement); + } + }); + }; + + let headerOffset = 0; + if (headerPaddingRef.current) { + headerOffset = parseInt( + window + .getComputedStyle(headerPaddingRef.current) + .getPropertyValue("top"), + ); + } + const observerOptions: IntersectionObserverInit = { + rootMargin: `-${headerOffset}px 0px -85% 0px`, + threshold: 0, + root: null, + }; + const observer = new IntersectionObserver( + observeHandler, + observerOptions, + ); + + headingDOMNodes.forEach((heading) => { + observer.observe(heading); + }); + return () => { + headingDOMNodes.forEach((heading) => { + observer.unobserve(heading); + }); + }; + } catch (err) { + console.log(err); + } + }, [headingDOMNodes, props.editor]); + + if (!links.length) { + return ( + <> + + {t("Add headings (H1, H2, H3) to generate a table of contents.")} + + + ); + } + + return ( + <> +
+ {links.map((item, idx) => ( + + component="button" + onClick={() => handleScrollToHeading(item.position)} + key={idx} + className={clsx(classes.link, { + [classes.linkActive]: item.element === activeElement, + })} + style={{ + paddingLeft: `calc(${item.level} * var(--mantine-spacing-md))`, + }} + > + {item.label} + + ))} +
+
+ + ); +}; diff --git a/apps/client/src/features/editor/extensions/extensions.ts b/apps/client/src/features/editor/extensions/extensions.ts index ecdac0c1..c131ad70 100644 --- a/apps/client/src/features/editor/extensions/extensions.ts +++ b/apps/client/src/features/editor/extensions/extensions.ts @@ -228,4 +228,4 @@ export const collabExtensions: CollabExtensions = (provider, user) => [ color: randomElement(userColors), }, }), -]; +]; \ No newline at end of file diff --git a/apps/client/src/features/page/components/header/page-header-menu.tsx b/apps/client/src/features/page/components/header/page-header-menu.tsx index 4883b52b..fefd3e28 100644 --- a/apps/client/src/features/page/components/header/page-header-menu.tsx +++ b/apps/client/src/features/page/components/header/page-header-menu.tsx @@ -6,6 +6,7 @@ import { IconFileExport, IconHistory, IconLink, + IconList, IconMessage, IconPrinter, IconTrash, @@ -56,7 +57,7 @@ export default function PageHeaderMenu({ readOnly }: PageHeaderMenuProps) { )} - + + + toggleAside("toc")} + > + + + + ); diff --git a/apps/client/src/features/user/components/page-width-pref.tsx b/apps/client/src/features/user/components/page-width-pref.tsx index b9a43248..6ad66062 100644 --- a/apps/client/src/features/user/components/page-width-pref.tsx +++ b/apps/client/src/features/user/components/page-width-pref.tsx @@ -1,7 +1,7 @@ -import { Group, Text, Switch, MantineSize } from "@mantine/core"; -import { useAtom } from "jotai/index"; import { userAtom } from "@/features/user/atoms/current-user-atom.ts"; import { updateUser } from "@/features/user/services/user-service.ts"; +import { Group, MantineSize, Switch, Text } from "@mantine/core"; +import { useAtom } from "jotai/index"; import React, { useState } from "react"; import { useTranslation } from "react-i18next"; @@ -26,6 +26,7 @@ interface PageWidthToggleProps { size?: MantineSize; label?: string; } + export function PageWidthToggle({ size, label }: PageWidthToggleProps) { const { t } = useTranslation(); const [user, setUser] = useAtom(userAtom); @@ -50,4 +51,4 @@ export function PageWidthToggle({ size, label }: PageWidthToggleProps) { aria-label={t("Toggle full page width")} /> ); -} +} \ No newline at end of file diff --git a/apps/client/src/features/user/types/user.types.ts b/apps/client/src/features/user/types/user.types.ts index e9bf1220..5439580f 100644 --- a/apps/client/src/features/user/types/user.types.ts +++ b/apps/client/src/features/user/types/user.types.ts @@ -30,4 +30,4 @@ export interface IUserSettings { preferences: { fullPageWidth: boolean; }; -} +} \ No newline at end of file diff --git a/apps/client/src/pages/settings/account/account-preferences.tsx b/apps/client/src/pages/settings/account/account-preferences.tsx index 06d62af7..26daa488 100644 --- a/apps/client/src/pages/settings/account/account-preferences.tsx +++ b/apps/client/src/pages/settings/account/account-preferences.tsx @@ -2,8 +2,8 @@ import SettingsTitle from "@/components/settings/settings-title.tsx"; import AccountLanguage from "@/features/user/components/account-language.tsx"; import AccountTheme from "@/features/user/components/account-theme.tsx"; import PageWidthPref from "@/features/user/components/page-width-pref.tsx"; -import { Divider } from "@mantine/core"; import { getAppName } from "@/lib/config.ts"; +import { Divider } from "@mantine/core"; import { Helmet } from "react-helmet-async"; import { useTranslation } from "react-i18next"; diff --git a/apps/server/src/core/user/dto/update-user.dto.ts b/apps/server/src/core/user/dto/update-user.dto.ts index ff3201c5..cdf085bb 100644 --- a/apps/server/src/core/user/dto/update-user.dto.ts +++ b/apps/server/src/core/user/dto/update-user.dto.ts @@ -1,6 +1,6 @@ import { OmitType, PartialType } from '@nestjs/mapped-types'; -import { CreateUserDto } from '../../auth/dto/create-user.dto'; import { IsBoolean, IsOptional, IsString } from 'class-validator'; +import { CreateUserDto } from '../../auth/dto/create-user.dto'; export class UpdateUserDto extends PartialType( OmitType(CreateUserDto, ['password'] as const), diff --git a/apps/server/src/core/user/user.service.ts b/apps/server/src/core/user/user.service.ts index 7909b548..434f4cac 100644 --- a/apps/server/src/core/user/user.service.ts +++ b/apps/server/src/core/user/user.service.ts @@ -1,10 +1,10 @@ +import { UserRepo } from '@docmost/db/repos/user/user.repo'; import { BadRequestException, Injectable, NotFoundException, } from '@nestjs/common'; import { UpdateUserDto } from './dto/update-user.dto'; -import { UserRepo } from '@docmost/db/repos/user/user.repo'; @Injectable() export class UserService { @@ -27,8 +27,9 @@ export class UserService { // preference update if (typeof updateUserDto.fullPageWidth !== 'undefined') { - return this.updateUserPageWidthPreference( + return this.userRepo.updatePreference( userId, + 'fullPageWidth', updateUserDto.fullPageWidth, ); } @@ -55,12 +56,4 @@ export class UserService { await this.userRepo.updateUser(updateUserDto, userId, workspaceId); return user; } - - async updateUserPageWidthPreference(userId: string, fullPageWidth: boolean) { - return this.userRepo.updatePreference( - userId, - 'fullPageWidth', - fullPageWidth, - ); - } } diff --git a/packages/editor-ext/src/index.ts b/packages/editor-ext/src/index.ts index 9e211778..e9915e78 100644 --- a/packages/editor-ext/src/index.ts +++ b/packages/editor-ext/src/index.ts @@ -16,4 +16,4 @@ export * from "./lib/drawio"; export * from "./lib/excalidraw"; export * from "./lib/embed"; export * from "./lib/mention"; -export * from "./lib/markdown"; +export * from "./lib/markdown"; \ No newline at end of file