mirror of
https://github.com/docmost/docmost.git
synced 2026-05-07 06:23:06 +08:00
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:
@@ -357,5 +357,7 @@
|
|||||||
"Move": "Move",
|
"Move": "Move",
|
||||||
"Move page": "Move page",
|
"Move page": "Move page",
|
||||||
"Move page to a different space.": "Move page to a different space.",
|
"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."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -75,6 +75,8 @@
|
|||||||
"Full access": "完全访问",
|
"Full access": "完全访问",
|
||||||
"Full page width": "全页宽度",
|
"Full page width": "全页宽度",
|
||||||
"Full width": "全宽",
|
"Full width": "全宽",
|
||||||
|
"View headings": "查看标题",
|
||||||
|
"Show article title menu.": "显示文章标题菜单",
|
||||||
"General": "常规",
|
"General": "常规",
|
||||||
"Group": "群组",
|
"Group": "群组",
|
||||||
"Group description": "群组描述",
|
"Group description": "群组描述",
|
||||||
@@ -170,8 +172,10 @@
|
|||||||
"Successfully restored": "恢复成功",
|
"Successfully restored": "恢复成功",
|
||||||
"System settings": "系统设置",
|
"System settings": "系统设置",
|
||||||
"Theme": "主题",
|
"Theme": "主题",
|
||||||
|
"On this page": "他是这个页面",
|
||||||
"To change your email, you have to enter your password and new email.": "要更改您的电子邮箱,您需要输入密码和新的电子邮箱地址。",
|
"To change your email, you have to enter your password and new email.": "要更改您的电子邮箱,您需要输入密码和新的电子邮箱地址。",
|
||||||
"Toggle full page width": "切换全页宽度",
|
"Toggle full page width": "切换全页宽度",
|
||||||
|
"Toggle view headings menu": "切换查看广告菜单",
|
||||||
"Unable to import pages. Please try again.": "无法导入页面。请重试。",
|
"Unable to import pages. Please try again.": "无法导入页面。请重试。",
|
||||||
"untitled": "无标题",
|
"untitled": "无标题",
|
||||||
"Untitled": "无标题",
|
"Untitled": "无标题",
|
||||||
|
|||||||
@@ -4,10 +4,14 @@ import { useAtom } from "jotai";
|
|||||||
import { asideStateAtom } from "@/components/layouts/global/hooks/atoms/sidebar-atom.ts";
|
import { asideStateAtom } from "@/components/layouts/global/hooks/atoms/sidebar-atom.ts";
|
||||||
import React, { ReactNode } from "react";
|
import React, { ReactNode } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
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() {
|
export default function Aside() {
|
||||||
const [{ tab }] = useAtom(asideStateAtom);
|
const [{ tab }] = useAtom(asideStateAtom);
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const pageEditor = useAtomValue(pageEditorAtom);
|
||||||
|
|
||||||
let title: string;
|
let title: string;
|
||||||
let component: ReactNode;
|
let component: ReactNode;
|
||||||
@@ -17,6 +21,10 @@ export default function Aside() {
|
|||||||
component = <CommentList />;
|
component = <CommentList />;
|
||||||
title = "Comments";
|
title = "Comments";
|
||||||
break;
|
break;
|
||||||
|
case "toc":
|
||||||
|
component = <TableOfContents editor={pageEditor} />;
|
||||||
|
title = "Table of contents";
|
||||||
|
break;
|
||||||
default:
|
default:
|
||||||
component = null;
|
component = null;
|
||||||
title = null;
|
title = null;
|
||||||
|
|||||||
+54
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
IconFileExport,
|
IconFileExport,
|
||||||
IconHistory,
|
IconHistory,
|
||||||
IconLink,
|
IconLink,
|
||||||
|
IconList,
|
||||||
IconMessage,
|
IconMessage,
|
||||||
IconPrinter,
|
IconPrinter,
|
||||||
IconTrash,
|
IconTrash,
|
||||||
@@ -56,7 +57,7 @@ export default function PageHeaderMenu({ readOnly }: PageHeaderMenuProps) {
|
|||||||
</Tooltip>
|
</Tooltip>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Tooltip label="Comments" openDelay={250} withArrow>
|
<Tooltip label={t("Comments")} openDelay={250} withArrow>
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
variant="default"
|
variant="default"
|
||||||
style={{ border: "none" }}
|
style={{ border: "none" }}
|
||||||
@@ -66,6 +67,16 @@ export default function PageHeaderMenu({ readOnly }: PageHeaderMenuProps) {
|
|||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
</Tooltip>
|
</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} />
|
<PageActionMenu readOnly={readOnly} />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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 { userAtom } from "@/features/user/atoms/current-user-atom.ts";
|
||||||
import { updateUser } from "@/features/user/services/user-service.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 React, { useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
@@ -26,6 +26,7 @@ interface PageWidthToggleProps {
|
|||||||
size?: MantineSize;
|
size?: MantineSize;
|
||||||
label?: string;
|
label?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function PageWidthToggle({ size, label }: PageWidthToggleProps) {
|
export function PageWidthToggle({ size, label }: PageWidthToggleProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [user, setUser] = useAtom(userAtom);
|
const [user, setUser] = useAtom(userAtom);
|
||||||
|
|||||||
@@ -2,8 +2,8 @@ import SettingsTitle from "@/components/settings/settings-title.tsx";
|
|||||||
import AccountLanguage from "@/features/user/components/account-language.tsx";
|
import AccountLanguage from "@/features/user/components/account-language.tsx";
|
||||||
import AccountTheme from "@/features/user/components/account-theme.tsx";
|
import AccountTheme from "@/features/user/components/account-theme.tsx";
|
||||||
import PageWidthPref from "@/features/user/components/page-width-pref.tsx";
|
import PageWidthPref from "@/features/user/components/page-width-pref.tsx";
|
||||||
import { Divider } from "@mantine/core";
|
|
||||||
import { getAppName } from "@/lib/config.ts";
|
import { getAppName } from "@/lib/config.ts";
|
||||||
|
import { Divider } from "@mantine/core";
|
||||||
import { Helmet } from "react-helmet-async";
|
import { Helmet } from "react-helmet-async";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { OmitType, PartialType } from '@nestjs/mapped-types';
|
import { OmitType, PartialType } from '@nestjs/mapped-types';
|
||||||
import { CreateUserDto } from '../../auth/dto/create-user.dto';
|
|
||||||
import { IsBoolean, IsOptional, IsString } from 'class-validator';
|
import { IsBoolean, IsOptional, IsString } from 'class-validator';
|
||||||
|
import { CreateUserDto } from '../../auth/dto/create-user.dto';
|
||||||
|
|
||||||
export class UpdateUserDto extends PartialType(
|
export class UpdateUserDto extends PartialType(
|
||||||
OmitType(CreateUserDto, ['password'] as const),
|
OmitType(CreateUserDto, ['password'] as const),
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
|
import { UserRepo } from '@docmost/db/repos/user/user.repo';
|
||||||
import {
|
import {
|
||||||
BadRequestException,
|
BadRequestException,
|
||||||
Injectable,
|
Injectable,
|
||||||
NotFoundException,
|
NotFoundException,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { UpdateUserDto } from './dto/update-user.dto';
|
import { UpdateUserDto } from './dto/update-user.dto';
|
||||||
import { UserRepo } from '@docmost/db/repos/user/user.repo';
|
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class UserService {
|
export class UserService {
|
||||||
@@ -27,8 +27,9 @@ export class UserService {
|
|||||||
|
|
||||||
// preference update
|
// preference update
|
||||||
if (typeof updateUserDto.fullPageWidth !== 'undefined') {
|
if (typeof updateUserDto.fullPageWidth !== 'undefined') {
|
||||||
return this.updateUserPageWidthPreference(
|
return this.userRepo.updatePreference(
|
||||||
userId,
|
userId,
|
||||||
|
'fullPageWidth',
|
||||||
updateUserDto.fullPageWidth,
|
updateUserDto.fullPageWidth,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -55,12 +56,4 @@ export class UserService {
|
|||||||
await this.userRepo.updateUser(updateUserDto, userId, workspaceId);
|
await this.userRepo.updateUser(updateUserDto, userId, workspaceId);
|
||||||
return user;
|
return user;
|
||||||
}
|
}
|
||||||
|
|
||||||
async updateUserPageWidthPreference(userId: string, fullPageWidth: boolean) {
|
|
||||||
return this.userRepo.updatePreference(
|
|
||||||
userId,
|
|
||||||
'fullPageWidth',
|
|
||||||
fullPageWidth,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user