mirror of
https://github.com/docmost/docmost.git
synced 2026-08-28 17:27:06 +08:00
anchor link init
This commit is contained in:
@@ -222,6 +222,7 @@
|
|||||||
"Anyone with this link can join this workspace.": "Anyone with this link can join this workspace.",
|
"Anyone with this link can join this workspace.": "Anyone with this link can join this workspace.",
|
||||||
"Invite link": "Invite link",
|
"Invite link": "Invite link",
|
||||||
"Copy": "Copy",
|
"Copy": "Copy",
|
||||||
|
"Copy anchor link": "Copy anchor link",
|
||||||
"Copied": "Copied",
|
"Copied": "Copied",
|
||||||
"Select a user": "Select a user",
|
"Select a user": "Select a user",
|
||||||
"Select a group": "Select a group",
|
"Select a group": "Select a group",
|
||||||
|
|||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import { ActionIcon, CopyButton, Flex, Group, Tooltip } from "@mantine/core";
|
||||||
|
import { IconAnchor, IconCheck, IconCopy } from "@tabler/icons-react";
|
||||||
|
import { NodeViewContent, NodeViewProps, NodeViewWrapper } from "@tiptap/react";
|
||||||
|
import { ElementType, useEffect, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import classes from "./heading.module.css";
|
||||||
|
|
||||||
|
const generateSlug = (text: string) =>
|
||||||
|
text
|
||||||
|
.normalize("NFD")
|
||||||
|
.replace(/[\u0300-\u036f]/g, "")
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^\w\s-]/g, "")
|
||||||
|
.trim()
|
||||||
|
.replace(/\s+/g, "-");
|
||||||
|
|
||||||
|
export default function HeadingView({ node }: NodeViewProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [slug, setSlug] = useState("");
|
||||||
|
const [url, setUrl] = useState("");
|
||||||
|
const [showAnchorButton, setShowAnchorButton] = useState(false);
|
||||||
|
|
||||||
|
const tag: ElementType = `h${node.attrs.level}` as ElementType;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const text = node.textContent || "";
|
||||||
|
const generatedSlug = generateSlug(text);
|
||||||
|
setSlug(generatedSlug);
|
||||||
|
|
||||||
|
const baseUrl = window.location.href.split("#")[0];
|
||||||
|
setUrl(`${baseUrl}#${generatedSlug}`);
|
||||||
|
}, [node.content]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<NodeViewWrapper
|
||||||
|
as={tag}
|
||||||
|
id={slug}
|
||||||
|
className={classes.anchorScrollMargin}
|
||||||
|
onMouseEnter={() => setShowAnchorButton(true)}
|
||||||
|
onMouseLeave={() => setShowAnchorButton(false)}
|
||||||
|
>
|
||||||
|
<Flex gap="sm" justify="flex-start" align="center">
|
||||||
|
<NodeViewContent as="span" />
|
||||||
|
{showAnchorButton && node.textContent && (
|
||||||
|
<CopyButton value={url} timeout={2000}>
|
||||||
|
{({ copied, copy }) => (
|
||||||
|
<Tooltip
|
||||||
|
label={copied ? t("Copied") : t("Copy anchor link")}
|
||||||
|
withArrow
|
||||||
|
position="right"
|
||||||
|
>
|
||||||
|
<ActionIcon
|
||||||
|
color={copied ? "teal" : "gray"}
|
||||||
|
variant="subtle"
|
||||||
|
size="sm"
|
||||||
|
onClick={copy}
|
||||||
|
>
|
||||||
|
{copied ? <IconCheck size={16} /> : <IconAnchor size={16} />}
|
||||||
|
</ActionIcon>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
</CopyButton>
|
||||||
|
)}
|
||||||
|
</Flex>
|
||||||
|
</NodeViewWrapper>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
.anchorScrollMargin {
|
||||||
|
scroll-margin-top: 95px;
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import { useEffect, useRef } from "react";
|
||||||
|
import { useLocation } from "react-router-dom";
|
||||||
|
|
||||||
|
export function useAnchorScroll(offset = 95, maxRetries = 10, retryDelay = 500) {
|
||||||
|
const location = useLocation();
|
||||||
|
const lastHash = useRef("");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let retries = maxRetries;
|
||||||
|
|
||||||
|
const tryScroll = () => {
|
||||||
|
const el = document.getElementById(lastHash.current);
|
||||||
|
if (el) {
|
||||||
|
const y = el.getBoundingClientRect().top + window.scrollY - offset;
|
||||||
|
window.scrollTo({ top: y, behavior: "smooth" });
|
||||||
|
window.history.replaceState(null, "", `#${lastHash.current}`);
|
||||||
|
} else if (retries > 0) {
|
||||||
|
retries--;
|
||||||
|
setTimeout(tryScroll, retryDelay);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (location.hash) {
|
||||||
|
lastHash.current = location.hash.slice(1);
|
||||||
|
tryScroll();
|
||||||
|
}
|
||||||
|
}, [location, offset, maxRetries, retryDelay]);
|
||||||
|
}
|
||||||
@@ -73,6 +73,8 @@ import i18n from "@/i18n.ts";
|
|||||||
import { MarkdownClipboard } from "@/features/editor/extensions/markdown-clipboard.ts";
|
import { MarkdownClipboard } from "@/features/editor/extensions/markdown-clipboard.ts";
|
||||||
import EmojiCommand from "./emoji-command";
|
import EmojiCommand from "./emoji-command";
|
||||||
import { CharacterCount } from "@tiptap/extension-character-count";
|
import { CharacterCount } from "@tiptap/extension-character-count";
|
||||||
|
import Heading from "@tiptap/extension-heading";
|
||||||
|
import HeadingView from "../components/heading/heading-view";
|
||||||
|
|
||||||
const lowlight = createLowlight(common);
|
const lowlight = createLowlight(common);
|
||||||
lowlight.register("mermaid", plaintext);
|
lowlight.register("mermaid", plaintext);
|
||||||
@@ -89,6 +91,7 @@ lowlight.register("scala", scala);
|
|||||||
export const mainExtensions = [
|
export const mainExtensions = [
|
||||||
StarterKit.configure({
|
StarterKit.configure({
|
||||||
history: false,
|
history: false,
|
||||||
|
heading: false,
|
||||||
dropcursor: {
|
dropcursor: {
|
||||||
width: 3,
|
width: 3,
|
||||||
color: "#70CFF8",
|
color: "#70CFF8",
|
||||||
@@ -100,6 +103,11 @@ export const mainExtensions = [
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
|
Heading.extend({
|
||||||
|
addNodeView() {
|
||||||
|
return ReactNodeViewRenderer(HeadingView);
|
||||||
|
}
|
||||||
|
}),
|
||||||
Placeholder.configure({
|
Placeholder.configure({
|
||||||
placeholder: ({ node }) => {
|
placeholder: ({ node }) => {
|
||||||
if (node.type.name === "heading") {
|
if (node.type.name === "heading") {
|
||||||
|
|||||||
@@ -53,6 +53,7 @@ import { useParams } from "react-router-dom";
|
|||||||
import { extractPageSlugId } from "@/lib";
|
import { extractPageSlugId } from "@/lib";
|
||||||
import { FIVE_MINUTES } from "@/lib/constants.ts";
|
import { FIVE_MINUTES } from "@/lib/constants.ts";
|
||||||
import { jwtDecode } from "jwt-decode";
|
import { jwtDecode } from "jwt-decode";
|
||||||
|
import { useAnchorScroll } from "./components/heading/use-anchor-scroll";
|
||||||
|
|
||||||
interface PageEditorProps {
|
interface PageEditorProps {
|
||||||
pageId: string;
|
pageId: string;
|
||||||
@@ -85,6 +86,7 @@ export default function PageEditor({
|
|||||||
const [isCollabReady, setIsCollabReady] = useState(false);
|
const [isCollabReady, setIsCollabReady] = useState(false);
|
||||||
const { pageSlug } = useParams();
|
const { pageSlug } = useParams();
|
||||||
const slugId = extractPageSlugId(pageSlug);
|
const slugId = extractPageSlugId(pageSlug);
|
||||||
|
useAnchorScroll();
|
||||||
|
|
||||||
const localProvider = useMemo(() => {
|
const localProvider = useMemo(() => {
|
||||||
const provider = new IndexeddbPersistence(documentName, ydoc);
|
const provider = new IndexeddbPersistence(documentName, ydoc);
|
||||||
|
|||||||
@@ -8,12 +8,14 @@ import ReadonlyPageEditor from "@/features/editor/readonly-page-editor.tsx";
|
|||||||
import { extractPageSlugId } from "@/lib";
|
import { extractPageSlugId } from "@/lib";
|
||||||
import { Error404 } from "@/components/ui/error-404.tsx";
|
import { Error404 } from "@/components/ui/error-404.tsx";
|
||||||
import ShareBranding from "@/features/share/components/share-branding.tsx";
|
import ShareBranding from "@/features/share/components/share-branding.tsx";
|
||||||
|
import { useAnchorScroll } from "@/features/editor/components/heading/use-anchor-scroll";
|
||||||
|
|
||||||
export default function SharedPage() {
|
export default function SharedPage() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { pageSlug } = useParams();
|
const { pageSlug } = useParams();
|
||||||
const { shareId } = useParams();
|
const { shareId } = useParams();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
useAnchorScroll();
|
||||||
|
|
||||||
const { data, isLoading, isError, error } = useSharePageQuery({
|
const { data, isLoading, isError, error } = useSharePageQuery({
|
||||||
pageId: extractPageSlugId(pageSlug),
|
pageId: extractPageSlugId(pageSlug),
|
||||||
|
|||||||
Reference in New Issue
Block a user