mirror of
https://github.com/docmost/docmost.git
synced 2026-05-20 08:34:04 +08:00
Support anchor links in page mentions
This commit is contained in:
@@ -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);
|
||||||
|
|||||||
@@ -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)}`;
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
Generated
+21
@@ -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: {}
|
||||||
|
|||||||
Reference in New Issue
Block a user