diff --git a/apps/client/public/locales/en-US/translation.json b/apps/client/public/locales/en-US/translation.json index 127730d5..2d6b2303 100644 --- a/apps/client/public/locales/en-US/translation.json +++ b/apps/client/public/locales/en-US/translation.json @@ -222,6 +222,7 @@ "Anyone with this link can join this workspace.": "Anyone with this link can join this workspace.", "Invite link": "Invite link", "Copy": "Copy", + "Copy anchor link": "Copy anchor link", "Copied": "Copied", "Select a user": "Select a user", "Select a group": "Select a group", diff --git a/apps/client/src/features/editor/components/heading/heading-view.tsx b/apps/client/src/features/editor/components/heading/heading-view.tsx new file mode 100644 index 00000000..5592bebf --- /dev/null +++ b/apps/client/src/features/editor/components/heading/heading-view.tsx @@ -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 ( + setShowAnchorButton(true)} + onMouseLeave={() => setShowAnchorButton(false)} + > + + + {showAnchorButton && node.textContent && ( + + {({ copied, copy }) => ( + + + {copied ? : } + + + )} + + )} + + + ); +} diff --git a/apps/client/src/features/editor/components/heading/heading.module.css b/apps/client/src/features/editor/components/heading/heading.module.css new file mode 100644 index 00000000..a1b0e037 --- /dev/null +++ b/apps/client/src/features/editor/components/heading/heading.module.css @@ -0,0 +1,3 @@ +.anchorScrollMargin { + scroll-margin-top: 95px; +} \ No newline at end of file diff --git a/apps/client/src/features/editor/components/heading/use-anchor-scroll.ts b/apps/client/src/features/editor/components/heading/use-anchor-scroll.ts new file mode 100644 index 00000000..e52f98b5 --- /dev/null +++ b/apps/client/src/features/editor/components/heading/use-anchor-scroll.ts @@ -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]); +} diff --git a/apps/client/src/features/editor/extensions/extensions.ts b/apps/client/src/features/editor/extensions/extensions.ts index eb0e62f2..18bb7aa8 100644 --- a/apps/client/src/features/editor/extensions/extensions.ts +++ b/apps/client/src/features/editor/extensions/extensions.ts @@ -73,6 +73,8 @@ import i18n from "@/i18n.ts"; import { MarkdownClipboard } from "@/features/editor/extensions/markdown-clipboard.ts"; import EmojiCommand from "./emoji-command"; import { CharacterCount } from "@tiptap/extension-character-count"; +import Heading from "@tiptap/extension-heading"; +import HeadingView from "../components/heading/heading-view"; const lowlight = createLowlight(common); lowlight.register("mermaid", plaintext); @@ -89,6 +91,7 @@ lowlight.register("scala", scala); export const mainExtensions = [ StarterKit.configure({ history: false, + heading: false, dropcursor: { width: 3, color: "#70CFF8", @@ -100,6 +103,11 @@ export const mainExtensions = [ }, }, }), + Heading.extend({ + addNodeView() { + return ReactNodeViewRenderer(HeadingView); + } + }), Placeholder.configure({ placeholder: ({ node }) => { if (node.type.name === "heading") { diff --git a/apps/client/src/features/editor/page-editor.tsx b/apps/client/src/features/editor/page-editor.tsx index 07d9da74..793a9aa1 100644 --- a/apps/client/src/features/editor/page-editor.tsx +++ b/apps/client/src/features/editor/page-editor.tsx @@ -53,6 +53,7 @@ import { useParams } from "react-router-dom"; import { extractPageSlugId } from "@/lib"; import { FIVE_MINUTES } from "@/lib/constants.ts"; import { jwtDecode } from "jwt-decode"; +import { useAnchorScroll } from "./components/heading/use-anchor-scroll"; interface PageEditorProps { pageId: string; @@ -85,6 +86,7 @@ export default function PageEditor({ const [isCollabReady, setIsCollabReady] = useState(false); const { pageSlug } = useParams(); const slugId = extractPageSlugId(pageSlug); + useAnchorScroll(); const localProvider = useMemo(() => { const provider = new IndexeddbPersistence(documentName, ydoc); diff --git a/apps/client/src/pages/share/shared-page.tsx b/apps/client/src/pages/share/shared-page.tsx index 50e5837d..916f2bb2 100644 --- a/apps/client/src/pages/share/shared-page.tsx +++ b/apps/client/src/pages/share/shared-page.tsx @@ -8,12 +8,14 @@ import ReadonlyPageEditor from "@/features/editor/readonly-page-editor.tsx"; import { extractPageSlugId } from "@/lib"; import { Error404 } from "@/components/ui/error-404.tsx"; import ShareBranding from "@/features/share/components/share-branding.tsx"; +import { useAnchorScroll } from "@/features/editor/components/heading/use-anchor-scroll"; export default function SharedPage() { const { t } = useTranslation(); const { pageSlug } = useParams(); const { shareId } = useParams(); const navigate = useNavigate(); + useAnchorScroll(); const { data, isLoading, isError, error } = useSharePageQuery({ pageId: extractPageSlugId(pageSlug),