mirror of
https://github.com/docmost/docmost.git
synced 2026-05-07 06:23:06 +08:00
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:
@@ -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]);
|
||||
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user