From 703bfad424df9cd1d5ed349109194366d30b51ba Mon Sep 17 00:00:00 2001 From: Philipinho <16838612+Philipinho@users.noreply.github.com> Date: Tue, 8 Jul 2025 21:06:33 -0700 Subject: [PATCH] Support anchor links in page mentions --- .../public/locales/en-US/translation.json | 3 ++- .../common/editor-paste-handler.tsx | 5 +++-- .../components/heading/heading-view.tsx | 18 +++++++--------- .../components/link/internal-link-paste.ts | 4 +++- .../components/mention/mention-view.tsx | 5 +++-- .../features/editor/extensions/extensions.ts | 6 +++--- .../src/features/editor/utils/nanoid.ts | 2 +- apps/client/src/features/page/page.utils.ts | 19 +++++++++++------ apps/client/src/lib/constants.ts | 6 ++++-- .../src/collaboration/collaboration.util.ts | 6 +++--- packages/editor-ext/src/lib/mention.ts | 19 +++++++++++++++++ pnpm-lock.yaml | 21 +++++++++++++++++++ 12 files changed, 82 insertions(+), 32 deletions(-) diff --git a/apps/client/public/locales/en-US/translation.json b/apps/client/public/locales/en-US/translation.json index 17d37171..f4a83d73 100644 --- a/apps/client/public/locales/en-US/translation.json +++ b/apps/client/public/locales/en-US/translation.json @@ -390,5 +390,6 @@ "Failed to share page": "Failed to share page", "Copy page": "Copy page", "Copy page to a different space.": "Copy page to a different space.", - "Page copied successfully": "Page copied successfully" + "Page copied successfully": "Page copied successfully", + "Anchor link copied": "Anchor link copied" } diff --git a/apps/client/src/features/editor/components/common/editor-paste-handler.tsx b/apps/client/src/features/editor/components/common/editor-paste-handler.tsx index 3a15e95d..4f6e5b8f 100644 --- a/apps/client/src/features/editor/components/common/editor-paste-handler.tsx +++ b/apps/client/src/features/editor/components/common/editor-paste-handler.tsx @@ -3,7 +3,6 @@ import { uploadImageAction } from "@/features/editor/components/image/upload-ima import { uploadVideoAction } from "@/features/editor/components/video/upload-video-action.tsx"; import { uploadAttachmentAction } from "../attachment/upload-attachment-action"; import { createMentionAction } from "@/features/editor/components/link/internal-link-paste.ts"; -import { Slice } from "@tiptap/pm/model"; import { INTERNAL_LINK_REGEX } from "@/lib/constants.ts"; export const handlePaste = ( @@ -34,7 +33,9 @@ export const handlePaste = ( return false; } - createMentionAction(url, view, pos, creatorId); + const anchor = match[6]; // Extract anchor from the regex match + const urlWithoutAnchor = anchor ? url.substring(0, url.indexOf("#")) : url; + createMentionAction(urlWithoutAnchor, view, pos, creatorId, anchor); return true; } diff --git a/apps/client/src/features/editor/components/heading/heading-view.tsx b/apps/client/src/features/editor/components/heading/heading-view.tsx index 5aaedd09..ab7ab158 100644 --- a/apps/client/src/features/editor/components/heading/heading-view.tsx +++ b/apps/client/src/features/editor/components/heading/heading-view.tsx @@ -21,19 +21,19 @@ export default function HeadingView({ node }: NodeViewProps) { const [showAnchorButton, setShowAnchorButton] = useState(false); const tag: ElementType = `h${node.attrs.level}` as ElementType; - const uid = node.attrs.uid; + const nodeId = node.attrs.nodeId; useEffect(() => { - if (uid) { + if (nodeId) { const text = node.textContent || ""; const textSlug = generateSlug(text); - const combined = textSlug ? `${textSlug}-${uid}` : uid; + const combined = textSlug ? `${textSlug}-${nodeId}` : nodeId; setCombinedId(combined); - + const baseUrl = window.location.href.split("#")[0]; setUrl(`${baseUrl}#${combined}`); } - }, [uid, node.content]); + }, [nodeId, node.content]); return ( - {showAnchorButton && uid && combinedId && node.textContent && ( + {showAnchorButton && nodeId && combinedId && node.textContent && ( {({ copied, copy }) => ( - + void; export interface InternalLinkOptions { @@ -18,7 +19,7 @@ export interface InternalLinkOptions { export const handleInternalLink = ({ validateFn, onResolveLink }: InternalLinkOptions): LinkFn => - async (url: string, view, pos, creatorId) => { + async (url: string, view, pos, creatorId, anchor) => { const validated = validateFn(url, view); if (!validated) return; @@ -35,6 +36,7 @@ export const handleInternalLink = entityId: page.id, slugId: page.slugId, creatorId: creatorId, + anchor: anchor, }); if (!node) return; diff --git a/apps/client/src/features/editor/components/mention/mention-view.tsx b/apps/client/src/features/editor/components/mention/mention-view.tsx index d42e4a8c..06f109c9 100644 --- a/apps/client/src/features/editor/components/mention/mention-view.tsx +++ b/apps/client/src/features/editor/components/mention/mention-view.tsx @@ -11,7 +11,7 @@ import classes from "./mention.module.css"; export default function MentionView(props: NodeViewProps) { const { node } = props; - const { label, entityType, entityId, slugId } = node.attrs; + const { label, entityType, entityId, slugId, anchor } = node.attrs; const { spaceSlug } = useParams(); const { shareId } = useParams(); const { @@ -27,6 +27,7 @@ export default function MentionView(props: NodeViewProps) { shareId, pageSlugId: slugId, pageTitle: label, + anchor, }); return ( @@ -42,7 +43,7 @@ export default function MentionView(props: NodeViewProps) { component={Link} fw={500} to={ - isShareRoute ? shareSlugUrl : buildPageUrl(spaceSlug, slugId, label) + isShareRoute ? shareSlugUrl : buildPageUrl(spaceSlug, slugId, label, anchor) } underline="never" className={classes.pageMentionLink} diff --git a/apps/client/src/features/editor/extensions/extensions.ts b/apps/client/src/features/editor/extensions/extensions.ts index bd275a35..fb7d7f55 100644 --- a/apps/client/src/features/editor/extensions/extensions.ts +++ b/apps/client/src/features/editor/extensions/extensions.ts @@ -77,7 +77,7 @@ import Heading from "@tiptap/extension-heading"; import HeadingView from "../components/heading/heading-view"; import { countWords } from "alfaaz"; import UniqueID from '@tiptap/extension-unique-id'; -import { generateSlugId } from "../utils/nanoid"; +import { generateEditorNodeId } from "../utils/nanoid"; const lowlight = createLowlight(common); lowlight.register("mermaid", plaintext); @@ -229,8 +229,8 @@ export const mainExtensions = [ }), UniqueID.configure({ types: ['heading'], - attributeName: 'uid', - generateID: () => generateSlugId(), + attributeName: 'nodeId', + generateID: () => generateEditorNodeId(), filterTransaction: (transaction) => !isChangeOrigin(transaction), }), ] as any; diff --git a/apps/client/src/features/editor/utils/nanoid.ts b/apps/client/src/features/editor/utils/nanoid.ts index c3aeca0a..963b9eef 100644 --- a/apps/client/src/features/editor/utils/nanoid.ts +++ b/apps/client/src/features/editor/utils/nanoid.ts @@ -2,4 +2,4 @@ import { customAlphabet } from "nanoid"; const slugIdAlphabet = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"; -export const generateSlugId = customAlphabet(slugIdAlphabet, 10); +export const generateEditorNodeId = customAlphabet(slugIdAlphabet, 12); diff --git a/apps/client/src/features/page/page.utils.ts b/apps/client/src/features/page/page.utils.ts index 3ae65aab..bc1bb3c6 100644 --- a/apps/client/src/features/page/page.utils.ts +++ b/apps/client/src/features/page/page.utils.ts @@ -15,22 +15,29 @@ export const buildPageUrl = ( spaceName: string, pageSlugId: string, pageTitle?: string, + anchor?: string, ): string => { + let url: string; if (spaceName === undefined) { - return `/p/${buildPageSlug(pageSlugId, pageTitle)}`; + url = `/p/${buildPageSlug(pageSlugId, pageTitle)}`; + } else { + url = `/s/${spaceName}/p/${buildPageSlug(pageSlugId, pageTitle)}`; } - return `/s/${spaceName}/p/${buildPageSlug(pageSlugId, pageTitle)}`; + return anchor ? `${url}#${anchor}` : url; }; export const buildSharedPageUrl = (opts: { shareId: string; pageSlugId: string; pageTitle?: string; + anchor?: string; }): string => { - const { shareId, pageSlugId, pageTitle } = opts; + const { shareId, pageSlugId, pageTitle, anchor } = opts; + let url: string; if (!shareId) { - return `/share/p/${buildPageSlug(pageSlugId, pageTitle)}`; + url = `/share/p/${buildPageSlug(pageSlugId, pageTitle)}`; + } else { + url = `/share/${shareId}/p/${buildPageSlug(pageSlugId, pageTitle)}`; } - - return `/share/${shareId}/p/${buildPageSlug(pageSlugId, pageTitle)}`; + return anchor ? `${url}#${anchor}` : url; }; diff --git a/apps/client/src/lib/constants.ts b/apps/client/src/lib/constants.ts index eb8c9105..9631baa4 100644 --- a/apps/client/src/lib/constants.ts +++ b/apps/client/src/lib/constants.ts @@ -1,4 +1,6 @@ export const INTERNAL_LINK_REGEX = - /^(https?:\/\/)?([^\/]+)?(\/s\/([^\/]+)\/)?p\/([a-zA-Z0-9-]+)\/?$/; + /^(https?:\/\/)?([^\/]+)?(\/s\/([^\/]+)\/)?p\/([a-zA-Z0-9-]+)\/?(?:#(.*))?$/; -export const FIVE_MINUTES = 5 * 60 * 1000; \ No newline at end of file +export const FIVE_MINUTES = 5 * 60 * 1000; +//export const INTERNAL_LINK_REGEX = +// /^(https?:\/\/)?([^\/]+)?(\/s\/([^\/]+)\/)?p\/([a-zA-Z0-9-]+)\/?$/; \ No newline at end of file diff --git a/apps/server/src/collaboration/collaboration.util.ts b/apps/server/src/collaboration/collaboration.util.ts index 551f1d18..6493a7b2 100644 --- a/apps/server/src/collaboration/collaboration.util.ts +++ b/apps/server/src/collaboration/collaboration.util.ts @@ -32,7 +32,7 @@ import { Drawio, Excalidraw, Embed, - Mention + Mention, } from '@docmost/editor-ext'; import { generateText, getSchema, JSONContent } from '@tiptap/core'; import { generateHTML } from '../common/helpers/prosemirror/html'; @@ -47,7 +47,7 @@ export const tiptapExtensions = [ codeBlock: false, }), Comment, - TextAlign.configure({ types: ["heading", "paragraph"] }), + TextAlign.configure({ types: ['heading', 'paragraph'] }), TaskList, TaskItem, Underline, @@ -80,7 +80,7 @@ export const tiptapExtensions = [ Mention, UniqueID.configure({ types: ['heading'], - attributeName: 'uid', + attributeName: 'nodeId', }), ] as any; diff --git a/packages/editor-ext/src/lib/mention.ts b/packages/editor-ext/src/lib/mention.ts index edbf9c11..0aa22a2e 100644 --- a/packages/editor-ext/src/lib/mention.ts +++ b/packages/editor-ext/src/lib/mention.ts @@ -33,6 +33,11 @@ export interface MentionNodeAttrs { * the id of the user who initiated the mention */ creatorId?: string; + + /** + * the anchor hash for page mentions (e.g., "heading-1") + */ + anchor?: string; } export type MentionOptions< @@ -246,6 +251,20 @@ export const Mention = Node.create({ }; }, }, + + anchor: { + default: null, + parseHTML: (element) => element.getAttribute("data-anchor"), + renderHTML: (attributes) => { + if (!attributes.anchor) { + return {}; + } + + return { + "data-anchor": attributes.anchor, + }; + }, + }, }; }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b248afbe..f2986ded 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -124,6 +124,9 @@ importers: '@tiptap/extension-underline': specifier: ^2.10.3 version: 2.14.0(@tiptap/core@2.14.0(@tiptap/pm@2.14.0)) + '@tiptap/extension-unique-id': + specifier: ^2.23.0 + version: 2.25.0(@tiptap/core@2.14.0(@tiptap/pm@2.14.0))(@tiptap/pm@2.14.0) '@tiptap/extension-youtube': specifier: ^2.10.3 version: 2.14.0(@tiptap/core@2.14.0(@tiptap/pm@2.14.0)) @@ -4019,6 +4022,12 @@ packages: peerDependencies: '@tiptap/core': ^2.7.0 + '@tiptap/extension-unique-id@2.25.0': + resolution: {integrity: sha512-D45xSQ6H4v5agVCnv6l/TGQt4coDSo+Xbg2/CrP8UNYomVbPNFDmtDHL4Tyoq5HAa9HpMskVpWmJAmNJUH6f9A==} + peerDependencies: + '@tiptap/core': ^2.7.0 + '@tiptap/pm': ^2.7.0 + '@tiptap/extension-youtube@2.14.0': resolution: {integrity: sha512-kryHjsjlIV2B6rS0Mnv9AqAyCCaeNWE1XDAWyYfhWQSmQkfaxSZU3rMnh3BMvSsVsdv5mtyxyBqBTrQA2sBSaw==} peerDependencies: @@ -9184,6 +9193,10 @@ packages: resolution: {integrity: sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==} engines: {node: '>= 0.4.0'} + uuid@10.0.0: + resolution: {integrity: sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==} + hasBin: true + uuid@11.1.0: resolution: {integrity: sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==} hasBin: true @@ -13712,6 +13725,12 @@ snapshots: dependencies: '@tiptap/core': 2.14.0(@tiptap/pm@2.14.0) + '@tiptap/extension-unique-id@2.25.0(@tiptap/core@2.14.0(@tiptap/pm@2.14.0))(@tiptap/pm@2.14.0)': + dependencies: + '@tiptap/core': 2.14.0(@tiptap/pm@2.14.0) + '@tiptap/pm': 2.14.0 + uuid: 10.0.0 + '@tiptap/extension-youtube@2.14.0(@tiptap/core@2.14.0(@tiptap/pm@2.14.0))': dependencies: '@tiptap/core': 2.14.0(@tiptap/pm@2.14.0) @@ -19827,6 +19846,8 @@ snapshots: utils-merge@1.0.1: {} + uuid@10.0.0: {} + uuid@11.1.0: {} uuid@9.0.1: {}