Support anchor links in page mentions

This commit is contained in:
Philipinho
2025-07-08 21:06:33 -07:00
parent b82171c24c
commit 703bfad424
12 changed files with 82 additions and 32 deletions
@@ -390,5 +390,6 @@
"Failed to share page": "Failed to share page", "Failed to share page": "Failed to share page",
"Copy page": "Copy page", "Copy page": "Copy page",
"Copy page to a different space.": "Copy page to a different space.", "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"
} }
@@ -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 { uploadVideoAction } from "@/features/editor/components/video/upload-video-action.tsx";
import { uploadAttachmentAction } from "../attachment/upload-attachment-action"; import { uploadAttachmentAction } from "../attachment/upload-attachment-action";
import { createMentionAction } from "@/features/editor/components/link/internal-link-paste.ts"; 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"; import { INTERNAL_LINK_REGEX } from "@/lib/constants.ts";
export const handlePaste = ( export const handlePaste = (
@@ -34,7 +33,9 @@ export const handlePaste = (
return false; 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; return true;
} }
@@ -21,19 +21,19 @@ export default function HeadingView({ node }: NodeViewProps) {
const [showAnchorButton, setShowAnchorButton] = useState(false); const [showAnchorButton, setShowAnchorButton] = useState(false);
const tag: ElementType = `h${node.attrs.level}` as ElementType; const tag: ElementType = `h${node.attrs.level}` as ElementType;
const uid = node.attrs.uid; const nodeId = node.attrs.nodeId;
useEffect(() => { useEffect(() => {
if (uid) { if (nodeId) {
const text = node.textContent || ""; const text = node.textContent || "";
const textSlug = generateSlug(text); const textSlug = generateSlug(text);
const combined = textSlug ? `${textSlug}-${uid}` : uid; const combined = textSlug ? `${textSlug}-${nodeId}` : nodeId;
setCombinedId(combined); setCombinedId(combined);
const baseUrl = window.location.href.split("#")[0]; const baseUrl = window.location.href.split("#")[0];
setUrl(`${baseUrl}#${combined}`); setUrl(`${baseUrl}#${combined}`);
} }
}, [uid, node.content]); }, [nodeId, node.content]);
return ( return (
<NodeViewWrapper <NodeViewWrapper
@@ -45,14 +45,10 @@ export default function HeadingView({ node }: NodeViewProps) {
> >
<Flex gap="sm" justify="flex-start" align="center"> <Flex gap="sm" justify="flex-start" align="center">
<NodeViewContent as="span" /> <NodeViewContent as="span" />
{showAnchorButton && uid && combinedId && node.textContent && ( {showAnchorButton && nodeId && combinedId && node.textContent && (
<CopyButton value={url} timeout={2000}> <CopyButton value={url} timeout={2000}>
{({ copied, copy }) => ( {({ copied, copy }) => (
<Tooltip <Tooltip disabled={!copied} label={t("Anchor link copied")} openDelay={300} withArrow position="bottom">
label={copied ? t("Copied") : t("Copy anchor link")}
withArrow
position="right"
>
<ActionIcon <ActionIcon
color={copied ? "teal" : "gray"} color={copied ? "teal" : "gray"}
variant="subtle" variant="subtle"
@@ -9,6 +9,7 @@ export type LinkFn = (
view: EditorView, view: EditorView,
pos: number, pos: number,
creatorId: string, creatorId: string,
anchor?: string,
) => void; ) => void;
export interface InternalLinkOptions { export interface InternalLinkOptions {
@@ -18,7 +19,7 @@ export interface InternalLinkOptions {
export const handleInternalLink = export const handleInternalLink =
({ validateFn, onResolveLink }: InternalLinkOptions): LinkFn => ({ validateFn, onResolveLink }: InternalLinkOptions): LinkFn =>
async (url: string, view, pos, creatorId) => { async (url: string, view, pos, creatorId, anchor) => {
const validated = validateFn(url, view); const validated = validateFn(url, view);
if (!validated) return; if (!validated) return;
@@ -35,6 +36,7 @@ export const handleInternalLink =
entityId: page.id, entityId: page.id,
slugId: page.slugId, slugId: page.slugId,
creatorId: creatorId, creatorId: creatorId,
anchor: anchor,
}); });
if (!node) return; if (!node) return;
@@ -11,7 +11,7 @@ import classes from "./mention.module.css";
export default function MentionView(props: NodeViewProps) { export default function MentionView(props: NodeViewProps) {
const { node } = props; const { node } = props;
const { label, entityType, entityId, slugId } = node.attrs; const { label, entityType, entityId, slugId, anchor } = node.attrs;
const { spaceSlug } = useParams(); const { spaceSlug } = useParams();
const { shareId } = useParams(); const { shareId } = useParams();
const { const {
@@ -27,6 +27,7 @@ export default function MentionView(props: NodeViewProps) {
shareId, shareId,
pageSlugId: slugId, pageSlugId: slugId,
pageTitle: label, pageTitle: label,
anchor,
}); });
return ( return (
@@ -42,7 +43,7 @@ export default function MentionView(props: NodeViewProps) {
component={Link} component={Link}
fw={500} fw={500}
to={ to={
isShareRoute ? shareSlugUrl : buildPageUrl(spaceSlug, slugId, label) isShareRoute ? shareSlugUrl : buildPageUrl(spaceSlug, slugId, label, anchor)
} }
underline="never" underline="never"
className={classes.pageMentionLink} className={classes.pageMentionLink}
@@ -77,7 +77,7 @@ import Heading from "@tiptap/extension-heading";
import HeadingView from "../components/heading/heading-view"; import HeadingView from "../components/heading/heading-view";
import { countWords } from "alfaaz"; import { countWords } from "alfaaz";
import UniqueID from '@tiptap/extension-unique-id'; import UniqueID from '@tiptap/extension-unique-id';
import { generateSlugId } from "../utils/nanoid"; import { generateEditorNodeId } from "../utils/nanoid";
const lowlight = createLowlight(common); const lowlight = createLowlight(common);
lowlight.register("mermaid", plaintext); lowlight.register("mermaid", plaintext);
@@ -229,8 +229,8 @@ export const mainExtensions = [
}), }),
UniqueID.configure({ UniqueID.configure({
types: ['heading'], types: ['heading'],
attributeName: 'uid', attributeName: 'nodeId',
generateID: () => generateSlugId(), generateID: () => generateEditorNodeId(),
filterTransaction: (transaction) => !isChangeOrigin(transaction), filterTransaction: (transaction) => !isChangeOrigin(transaction),
}), }),
] as any; ] as any;
@@ -2,4 +2,4 @@ import { customAlphabet } from "nanoid";
const slugIdAlphabet = const slugIdAlphabet =
"0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"; "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
export const generateSlugId = customAlphabet(slugIdAlphabet, 10); export const generateEditorNodeId = customAlphabet(slugIdAlphabet, 12);
+13 -6
View File
@@ -15,22 +15,29 @@ export const buildPageUrl = (
spaceName: string, spaceName: string,
pageSlugId: string, pageSlugId: string,
pageTitle?: string, pageTitle?: string,
anchor?: string,
): string => { ): string => {
let url: string;
if (spaceName === undefined) { 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: { export const buildSharedPageUrl = (opts: {
shareId: string; shareId: string;
pageSlugId: string; pageSlugId: string;
pageTitle?: string; pageTitle?: string;
anchor?: string;
}): string => { }): string => {
const { shareId, pageSlugId, pageTitle } = opts; const { shareId, pageSlugId, pageTitle, anchor } = opts;
let url: string;
if (!shareId) { if (!shareId) {
return `/share/p/${buildPageSlug(pageSlugId, pageTitle)}`; url = `/share/p/${buildPageSlug(pageSlugId, pageTitle)}`;
} else {
url = `/share/${shareId}/p/${buildPageSlug(pageSlugId, pageTitle)}`;
} }
return anchor ? `${url}#${anchor}` : url;
return `/share/${shareId}/p/${buildPageSlug(pageSlugId, pageTitle)}`;
}; };
+4 -2
View File
@@ -1,4 +1,6 @@
export const INTERNAL_LINK_REGEX = 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; export const FIVE_MINUTES = 5 * 60 * 1000;
//export const INTERNAL_LINK_REGEX =
// /^(https?:\/\/)?([^\/]+)?(\/s\/([^\/]+)\/)?p\/([a-zA-Z0-9-]+)\/?$/;
@@ -32,7 +32,7 @@ import {
Drawio, Drawio,
Excalidraw, Excalidraw,
Embed, Embed,
Mention Mention,
} from '@docmost/editor-ext'; } from '@docmost/editor-ext';
import { generateText, getSchema, JSONContent } from '@tiptap/core'; import { generateText, getSchema, JSONContent } from '@tiptap/core';
import { generateHTML } from '../common/helpers/prosemirror/html'; import { generateHTML } from '../common/helpers/prosemirror/html';
@@ -47,7 +47,7 @@ export const tiptapExtensions = [
codeBlock: false, codeBlock: false,
}), }),
Comment, Comment,
TextAlign.configure({ types: ["heading", "paragraph"] }), TextAlign.configure({ types: ['heading', 'paragraph'] }),
TaskList, TaskList,
TaskItem, TaskItem,
Underline, Underline,
@@ -80,7 +80,7 @@ export const tiptapExtensions = [
Mention, Mention,
UniqueID.configure({ UniqueID.configure({
types: ['heading'], types: ['heading'],
attributeName: 'uid', attributeName: 'nodeId',
}), }),
] as any; ] as any;
+19
View File
@@ -33,6 +33,11 @@ export interface MentionNodeAttrs {
* the id of the user who initiated the mention * the id of the user who initiated the mention
*/ */
creatorId?: string; creatorId?: string;
/**
* the anchor hash for page mentions (e.g., "heading-1")
*/
anchor?: string;
} }
export type MentionOptions< export type MentionOptions<
@@ -246,6 +251,20 @@ export const Mention = Node.create<MentionOptions>({
}; };
}, },
}, },
anchor: {
default: null,
parseHTML: (element) => element.getAttribute("data-anchor"),
renderHTML: (attributes) => {
if (!attributes.anchor) {
return {};
}
return {
"data-anchor": attributes.anchor,
};
},
},
}; };
}, },
+21
View File
@@ -124,6 +124,9 @@ importers:
'@tiptap/extension-underline': '@tiptap/extension-underline':
specifier: ^2.10.3 specifier: ^2.10.3
version: 2.14.0(@tiptap/core@2.14.0(@tiptap/pm@2.14.0)) 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': '@tiptap/extension-youtube':
specifier: ^2.10.3 specifier: ^2.10.3
version: 2.14.0(@tiptap/core@2.14.0(@tiptap/pm@2.14.0)) version: 2.14.0(@tiptap/core@2.14.0(@tiptap/pm@2.14.0))
@@ -4019,6 +4022,12 @@ packages:
peerDependencies: peerDependencies:
'@tiptap/core': ^2.7.0 '@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': '@tiptap/extension-youtube@2.14.0':
resolution: {integrity: sha512-kryHjsjlIV2B6rS0Mnv9AqAyCCaeNWE1XDAWyYfhWQSmQkfaxSZU3rMnh3BMvSsVsdv5mtyxyBqBTrQA2sBSaw==} resolution: {integrity: sha512-kryHjsjlIV2B6rS0Mnv9AqAyCCaeNWE1XDAWyYfhWQSmQkfaxSZU3rMnh3BMvSsVsdv5mtyxyBqBTrQA2sBSaw==}
peerDependencies: peerDependencies:
@@ -9184,6 +9193,10 @@ packages:
resolution: {integrity: sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==} resolution: {integrity: sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==}
engines: {node: '>= 0.4.0'} engines: {node: '>= 0.4.0'}
uuid@10.0.0:
resolution: {integrity: sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==}
hasBin: true
uuid@11.1.0: uuid@11.1.0:
resolution: {integrity: sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==} resolution: {integrity: sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==}
hasBin: true hasBin: true
@@ -13712,6 +13725,12 @@ snapshots:
dependencies: dependencies:
'@tiptap/core': 2.14.0(@tiptap/pm@2.14.0) '@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))': '@tiptap/extension-youtube@2.14.0(@tiptap/core@2.14.0(@tiptap/pm@2.14.0))':
dependencies: dependencies:
'@tiptap/core': 2.14.0(@tiptap/pm@2.14.0) '@tiptap/core': 2.14.0(@tiptap/pm@2.14.0)
@@ -19827,6 +19846,8 @@ snapshots:
utils-merge@1.0.1: {} utils-merge@1.0.1: {}
uuid@10.0.0: {}
uuid@11.1.0: {} uuid@11.1.0: {}
uuid@9.0.1: {} uuid@9.0.1: {}