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>
This commit is contained in:
sanua356
2025-04-05 21:03:42 +03:00
committed by GitHub
parent 17ce3bab8a
commit 233536314f
14 changed files with 259 additions and 21 deletions

View File

@ -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."
}

View File

@ -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": "无标题",

View File

@ -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 = <CommentList />;
title = "Comments";
break;
case "toc":
component = <TableOfContents editor={pageEditor} />;
title = "Table of contents";
break;
default:
component = null;
title = null;

View File

@ -20,4 +20,4 @@ export const asideStateAtom = atom<AsideStateType>({
isAsideOpen: false,
});
export const sidebarWidthAtom = atomWithWebStorage<number>('sidebarWidth', 300);
export const sidebarWidthAtom = atomWithWebStorage<number>('sidebarWidth', 300);

View File

@ -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;
}
}

View File

@ -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<typeof useEditor>;
};
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<HeadingLink[]>(
(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<TableOfContentsProps> = (props) => {
const { t } = useTranslation();
const [links, setLinks] = useState<HeadingLink[]>([]);
const [headingDOMNodes, setHeadingDOMNodes] = useState<HTMLElement[]>([]);
const [activeElement, setActiveElement] = useState<HTMLElement | null>(null);
const headerPaddingRef = useRef<HTMLDivElement | null>(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 (
<>
<Text size="sm">
{t("Add headings (H1, H2, H3) to generate a table of contents.")}
</Text>
</>
);
}
return (
<>
<div>
{links.map((item, idx) => (
<Box<"button">
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}
</Box>
))}
</div>
<div ref={headerPaddingRef} className={classes.headerPadding} />
</>
);
};

View File

@ -228,4 +228,4 @@ export const collabExtensions: CollabExtensions = (provider, user) => [
color: randomElement(userColors),
},
}),
];
];

View File

@ -6,6 +6,7 @@ import {
IconFileExport,
IconHistory,
IconLink,
IconList,
IconMessage,
IconPrinter,
IconTrash,
@ -56,7 +57,7 @@ export default function PageHeaderMenu({ readOnly }: PageHeaderMenuProps) {
</Tooltip>
)}
<Tooltip label="Comments" openDelay={250} withArrow>
<Tooltip label={t("Comments")} openDelay={250} withArrow>
<ActionIcon
variant="default"
style={{ border: "none" }}
@ -66,6 +67,16 @@ export default function PageHeaderMenu({ readOnly }: PageHeaderMenuProps) {
</ActionIcon>
</Tooltip>
<Tooltip label={t("Table of contents")} openDelay={250} withArrow>
<ActionIcon
variant="default"
style={{ border: "none" }}
onClick={() => toggleAside("toc")}
>
<IconList size={20} stroke={2} />
</ActionIcon>
</Tooltip>
<PageActionMenu readOnly={readOnly} />
</>
);

View File

@ -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")}
/>
);
}
}

View File

@ -30,4 +30,4 @@ export interface IUserSettings {
preferences: {
fullPageWidth: boolean;
};
}
}

View File

@ -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";

View File

@ -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),

View File

@ -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,
);
}
}

View File

@ -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";