feat: anchor links (#1765)

* feat: add heading extension with unique ID support and scroll functionality
* Added unique id for heading
* remove baseUrl heading storage
* move heading to extensions package
* WIP
* support anchors in mentions
* enhance scrolling functionality
* nodeId function
* fix nanoid import
* Bring unique-id extension local
* fixes
* fix internal link scroll in public pages
* add unique id server side
* rename mention anchor to anchorId
* capture first anchorId on paste

---------

Co-authored-by: Romik <40670677+RomikMakavana@users.noreply.github.com>
This commit is contained in:
Philip Okugbe
2025-12-06 14:46:54 +00:00
committed by GitHub
parent 9139d393ef
commit d2629afff2
24 changed files with 802 additions and 27 deletions
@@ -34,7 +34,9 @@ export const handlePaste = (
return false;
}
createMentionAction(url, view, pos, creatorId);
const anchorId = match[6] ? match[6].split('#')[0] : undefined;
const urlWithoutAnchor = anchorId ? url.substring(0, url.indexOf("#")) : url;
createMentionAction(urlWithoutAnchor, view, pos, creatorId, anchorId);
return true;
}
@@ -9,6 +9,7 @@ export type LinkFn = (
view: EditorView,
pos: number,
creatorId: string,
anchorId?: string,
) => 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, anchorId) => {
const validated = validateFn(url, view);
if (!validated) return;
@@ -35,6 +36,7 @@ export const handleInternalLink =
entityId: page.id,
slugId: page.slugId,
creatorId: creatorId,
anchorId: anchorId,
});
if (!node) return;
@@ -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, anchorId } = node.attrs;
const { spaceSlug } = useParams();
const { shareId } = useParams();
const {
@@ -27,6 +27,7 @@ export default function MentionView(props: NodeViewProps) {
shareId,
pageSlugId: slugId,
pageTitle: label,
anchorId,
});
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, anchorId)
}
underline="never"
className={classes.pageMentionLink}
@@ -14,7 +14,7 @@ import { Color } from "@tiptap/extension-color";
import GlobalDragHandle from "tiptap-extension-global-drag-handle";
import { Youtube } from "@tiptap/extension-youtube";
import SlashCommand from "@/features/editor/extensions/slash-command";
import { Collaboration } from "@tiptap/extension-collaboration";
import { Collaboration, isChangeOrigin } from "@tiptap/extension-collaboration";
import { CollaborationCursor } from "@tiptap/extension-collaboration-cursor";
import { HocuspocusProvider } from "@hocuspocus/provider";
import {
@@ -43,7 +43,9 @@ import {
Mention,
Subpages,
TableDndExtension,
Highlight
Heading,
Highlight,
UniqueID,
} from "@docmost/editor-ext";
import {
randomElement,
@@ -94,6 +96,7 @@ lowlight.register("scala", scala);
export const mainExtensions = [
StarterKit.configure({
heading: false,
history: false,
dropcursor: {
width: 3,
@@ -106,6 +109,11 @@ export const mainExtensions = [
},
},
}),
Heading,
UniqueID.configure({
types: ["heading", "paragraph"],
filterTransaction: (transaction) => !isChangeOrigin(transaction),
}),
Placeholder.configure({
placeholder: ({ node }) => {
if (node.type.name === "heading") {
@@ -230,17 +238,17 @@ export const mainExtensions = [
SearchAndReplace.extend({
addKeyboardShortcuts() {
return {
'Mod-f': () => {
"Mod-f": () => {
const event = new CustomEvent("openFindDialogFromEditor", {});
document.dispatchEvent(event);
return true;
},
'Escape': () => {
Escape: () => {
const event = new CustomEvent("closeFindDialogFromEditor", {});
document.dispatchEvent(event);
return true;
},
}
};
},
}).configure(),
] as any;
@@ -0,0 +1,58 @@
import { Editor } from "@tiptap/react";
import { useCallback, useEffect, useState } from "react";
function waitForState(checkFn: () => boolean): Promise<void> {
return new Promise((resolve) => {
const interval = setInterval(() => {
if (checkFn()) {
clearInterval(interval);
resolve();
}
}, 800);
});
}
export const useEditorScroll = ({
canScroll,
initialScrollTo,
}: {
canScroll: () => boolean;
initialScrollTo?: string;
}) => {
const [scrollTo, setScrollTo] = useState<string>(initialScrollTo || "");
useEffect(() => {
if (!initialScrollTo) {
setScrollTo(window.location.hash ? window.location.hash.slice(1) : "");
}
}, [initialScrollTo]);
const handleScrollTo = useCallback(async (editor: Editor, _scrollTo: string | null = null, tryCount: number = 0) => {
await waitForState(() => canScroll());
return new Promise((resolve) => {
const MAX_TRY_COUNT = 10;
if (tryCount >= MAX_TRY_COUNT) {
resolve(false);
return;
}
const targetId = _scrollTo || scrollTo;
if (!targetId) {
resolve(false);
return;
}
const dom = editor.view.dom.querySelector(`[id="${targetId}"]`);
if (dom) {
dom.scrollIntoView({ behavior: 'smooth', block: 'start' });
resolve(true);
} else {
setTimeout(async () => {
resolve(await handleScrollTo(editor, targetId, tryCount + 1));
}, 200);
}
});
}, [scrollTo, canScroll]);
return { scrollTo, handleScrollTo };
};
@@ -1,5 +1,5 @@
import "@/features/editor/styles/index.css";
import React, { useEffect, useMemo, useRef, useState } from "react";
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { IndexeddbPersistence } from "y-indexeddb";
import * as Y from "yjs";
import {
@@ -56,6 +56,7 @@ import { FIVE_MINUTES } from "@/lib/constants.ts";
import { PageEditMode } from "@/features/user/types/user.types.ts";
import { jwtDecode } from "jwt-decode";
import { searchSpotlight } from "@/features/search/constants.ts";
import { useEditorScroll } from "./hooks/use-editor-scroll";
interface PageEditorProps {
pageId: string;
@@ -68,7 +69,16 @@ export default function PageEditor({
editable,
content,
}: PageEditorProps) {
const collaborationURL = useCollaborationUrl();
const isComponentMounted = useRef(false);
const editorCreated = useRef(false);
useEffect(() => {
isComponentMounted.current = true;
}, []);
const [currentUser] = useAtom(currentUserAtom);
const [, setEditor] = useAtom(pageEditorAtom);
const [, setAsideState] = useAtom(asideStateAtom);
@@ -94,7 +104,9 @@ export default function PageEditor({
const slugId = extractPageSlugId(pageSlug);
const userPageEditMode =
currentUser?.user?.settings?.preferences?.pageEditMode ?? PageEditMode.Edit;
const canScroll = useCallback(() => isComponentMounted.current && editorCreated.current, [isComponentMounted, editorCreated]);
const { handleScrollTo } = useEditorScroll({ canScroll });
// Providers only created once per pageId
const providersRef = useRef<{
local: IndexeddbPersistence;
@@ -264,6 +276,8 @@ export default function PageEditor({
// @ts-ignore
setEditor(editor);
editor.storage.pageId = pageId;
handleScrollTo(editor);
editorCreated.current = true;
}
},
onUpdate({ editor }) {
@@ -1,13 +1,14 @@
import "@/features/editor/styles/index.css";
import React, { useMemo } from "react";
import React, { useCallback, useEffect, useMemo, useRef } from "react";
import { EditorProvider } from "@tiptap/react";
import { mainExtensions } from "@/features/editor/extensions/extensions";
import { Document } from "@tiptap/extension-document";
import { Heading } from "@tiptap/extension-heading";
import { Heading, generateNodeId, UniqueID } from "@docmost/editor-ext";
import { Text } from "@tiptap/extension-text";
import { Placeholder } from "@tiptap/extension-placeholder";
import { useAtom } from "jotai";
import { readOnlyEditorAtom } from "@/features/editor/atoms/editor-atoms.ts";
import { useEditorScroll } from "./hooks/use-editor-scroll";
interface PageEditorProps {
title: string;
@@ -21,9 +22,34 @@ export default function ReadonlyPageEditor({
pageId,
}: PageEditorProps) {
const [, setReadOnlyEditor] = useAtom(readOnlyEditorAtom);
const isComponentMounted = useRef(false);
const editorCreated = useRef(false);
const canScroll = useCallback(
() => isComponentMounted.current && editorCreated.current,
[isComponentMounted, editorCreated],
);
const initialScrollTo = window.location.hash
? window.location.hash.slice(1)
: "";
const { handleScrollTo } = useEditorScroll({ canScroll, initialScrollTo });
useEffect(() => {
isComponentMounted.current = true;
}, []);
const extensions = useMemo(() => {
return [...mainExtensions];
const filteredExtensions = mainExtensions.filter(
(ext) => ext.name !== "uniqueID",
);
return [
...filteredExtensions,
UniqueID.configure({
types: ["heading", "paragraph"],
updateDocument: false,
}),
];
}, []);
const titleExtensions = [
@@ -59,6 +85,9 @@ export default function ReadonlyPageEditor({
}
// @ts-ignore
setReadOnlyEditor(editor);
handleScrollTo(editor);
editorCreated.current = true;
}
}}
></EditorProvider>
@@ -186,6 +186,39 @@
margin-left: auto;
margin-right: auto;
}
}
.ProseMirror > h1,
.ProseMirror > h2,
.ProseMirror > h3,
.ProseMirror > h4,
.ProseMirror > h5,
.ProseMirror > h6 {
> .link-btn {
cursor: pointer;
position: relative;
}
> .link-btn > .link-btn-content {
opacity: 0;
position: absolute;
left: 5px;
top: 0;
height: 100%;
transition: opacity 0.15s ease;
display: inline-flex;
justify-content: center;
flex-direction: column;
}
&:hover > .link-btn > .link-btn-content {
opacity: 1;
}
scroll-margin-top: 80px; /* match your header height */
}
.ProseMirror-icon {
@@ -104,7 +104,10 @@ export function TitleEditor({
});
useEffect(() => {
const pageSlug = buildPageUrl(spaceSlug, slugId, title);
const anchorId = window.location.hash
? window.location.hash.substring(1)
: undefined;
const pageSlug = buildPageUrl(spaceSlug, slugId, title, anchorId);
navigate(pageSlug, { replace: true });
}, [title]);
+13 -6
View File
@@ -15,22 +15,29 @@ export const buildPageUrl = (
spaceName: string,
pageSlugId: string,
pageTitle?: string,
anchorId?: 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 anchorId ? `${url}#${anchorId}` : url;
};
export const buildSharedPageUrl = (opts: {
shareId: string;
pageSlugId: string;
pageTitle?: string;
anchorId?: string;
}): string => {
const { shareId, pageSlugId, pageTitle } = opts;
const { shareId, pageSlugId, pageTitle, anchorId } = 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 anchorId ? `${url}#${anchorId}` : url;
};
+2 -2
View File
@@ -1,4 +1,4 @@
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;
+1
View File
@@ -21,6 +21,7 @@ const MemoizedHistoryModal = React.memo(HistoryModal);
export default function Page() {
const { t } = useTranslation();
const { pageSlug } = useParams();
const {
data: page,
isLoading,
@@ -10,6 +10,7 @@ import { TextStyle } from '@tiptap/extension-text-style';
import { Color } from '@tiptap/extension-color';
import { Youtube } from '@tiptap/extension-youtube';
import {
Heading,
Callout,
Comment,
CustomCodeBlock,
@@ -32,7 +33,9 @@ import {
Embed,
Mention,
Subpages,
Highlight
Highlight,
UniqueID,
addUniqueIdsToDoc,
} from '@docmost/editor-ext';
import { generateText, getSchema, JSONContent } from '@tiptap/core';
import { generateHTML, generateJSON } from '../common/helpers/prosemirror/html';
@@ -44,6 +47,11 @@ import { Node } from '@tiptap/pm/model';
export const tiptapExtensions = [
StarterKit.configure({
codeBlock: false,
heading: false,
}),
Heading,
UniqueID.configure({
types: ['heading', 'paragraph'],
}),
Comment,
TextAlign.configure({ types: ['heading', 'paragraph'] }),
@@ -87,7 +95,14 @@ export function jsonToHtml(tiptapJson: any) {
}
export function htmlToJson(html: string) {
return generateJSON(html, tiptapExtensions);
const pmJson = generateJSON(html, tiptapExtensions);
try {
return addUniqueIdsToDoc(pmJson, tiptapExtensions);
} catch (error) {
console.warn('failed to add unique ids to doc', error);
return pmJson;
}
}
export function jsonToText(tiptapJson: JSONContent) {