Compare commits

...

9 Commits

Author SHA1 Message Date
Philipinho a7f90d6474 Merge branch 'main' into anchor-link 2025-09-21 01:58:39 +01:00
Philipinho 29d3a8cee2 Merge branch 'main' into anchor-link 2025-09-17 02:44:38 +01:00
Philipinho 66099f4657 use prosemirror decorations 2025-08-11 22:10:30 -07:00
Philipinho cefabc8683 Merge branch 'main' into anchor-link 2025-08-11 13:49:57 -07:00
Philipinho 703bfad424 Support anchor links in page mentions 2025-07-08 21:06:33 -07:00
fuscodev b82171c24c fix: uid in shared pages 2025-06-28 13:07:54 +02:00
fuscodev 1baff07e4e add nanoid by Vito0912 2025-06-27 20:27:54 +02:00
fuscodev f689291a99 Merge branch 'main' into anchor-link 2025-06-18 23:41:03 +02:00
fuscodev 0e4af65935 anchor link init 2025-06-04 20:43:47 +02:00
22 changed files with 419 additions and 14 deletions
@@ -234,6 +234,7 @@
"Anyone with this link can join this workspace.": "Anyone with this link can join this workspace.", "Anyone with this link can join this workspace.": "Anyone with this link can join this workspace.",
"Invite link": "Invite link", "Invite link": "Invite link",
"Copy": "Copy", "Copy": "Copy",
"Copy anchor link": "Copy anchor link",
"Copy to space": "Copy to space", "Copy to space": "Copy to space",
"Copied": "Copied", "Copied": "Copied",
"Duplicate": "Duplicate", "Duplicate": "Duplicate",
@@ -404,6 +405,7 @@
"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",
"Page duplicated successfully": "Page duplicated successfully", "Page duplicated successfully": "Page duplicated successfully",
"Find": "Find", "Find": "Find",
"Not found": "Not found", "Not found": "Not found",
@@ -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;
} }
@@ -0,0 +1,3 @@
.anchorScrollMargin {
scroll-margin-top: 95px;
}
@@ -0,0 +1,56 @@
import { useEffect, useRef } from "react";
import { useLocation } from "react-router-dom";
export function useAnchorScroll(offset = 95, maxRetries = 10, retryDelay = 500) {
const location = useLocation();
const lastHash = useRef("");
useEffect(() => {
let retries = maxRetries;
const tryScroll = () => {
let el = document.getElementById(lastHash.current);
if (!el) {
const hash = lastHash.current;
if (hash.includes('-')) {
const parts = hash.split('-');
const possibleUid = parts[parts.length - 1];
const elements = document.querySelectorAll('[id]');
for (const element of elements) {
if (element.id.endsWith(`-${possibleUid}`)) {
el = element as HTMLElement;
break;
}
}
}
if (!el) {
const elements = document.querySelectorAll('[id]');
for (const element of elements) {
if (element.id.endsWith(`-${hash}`) || element.id === hash) {
el = element as HTMLElement;
break;
}
}
}
}
if (el) {
const y = el.getBoundingClientRect().top + window.scrollY - offset;
window.scrollTo({ top: y, behavior: "smooth" });
window.history.replaceState(null, "", `#${el.id}`);
} else if (retries > 0) {
retries--;
setTimeout(tryScroll, retryDelay);
}
};
if (location.hash) {
lastHash.current = location.hash.slice(1);
tryScroll();
}
}, [location, offset, maxRetries, retryDelay]);
}
@@ -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}
@@ -11,7 +11,7 @@ import { Typography } from "@tiptap/extension-typography";
import { TextStyle } from "@tiptap/extension-text-style"; import { TextStyle } from "@tiptap/extension-text-style";
import { Color } from "@tiptap/extension-color"; import { Color } from "@tiptap/extension-color";
import SlashCommand from "@/features/editor/extensions/slash-command"; 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 { CollaborationCursor } from "@tiptap/extension-collaboration-cursor";
import { HocuspocusProvider } from "@hocuspocus/provider"; import { HocuspocusProvider } from "@hocuspocus/provider";
import { import {
@@ -40,6 +40,7 @@ import {
Mention, Mention,
Subpages, Subpages,
TableDndExtension, TableDndExtension,
HeadingAnchors,
} from "@docmost/editor-ext"; } from "@docmost/editor-ext";
import { import {
randomElement, randomElement,
@@ -78,6 +79,8 @@ import { MarkdownClipboard } from "@/features/editor/extensions/markdown-clipboa
import EmojiCommand from "./emoji-command"; import EmojiCommand from "./emoji-command";
import { CharacterCount } from "@tiptap/extension-character-count"; import { CharacterCount } from "@tiptap/extension-character-count";
import { countWords } from "alfaaz"; import { countWords } from "alfaaz";
import UniqueID from "@tiptap/extension-unique-id";
import { generateEditorNodeId } from "../utils/nanoid";
const lowlight = createLowlight(common); const lowlight = createLowlight(common);
lowlight.register("mermaid", plaintext); lowlight.register("mermaid", plaintext);
@@ -94,6 +97,7 @@ lowlight.register("scala", scala);
export const mainExtensions = [ export const mainExtensions = [
StarterKit.configure({ StarterKit.configure({
history: false, history: false,
heading: false,
dropcursor: { dropcursor: {
width: 3, width: 3,
color: "#70CFF8", color: "#70CFF8",
@@ -105,6 +109,7 @@ export const mainExtensions = [
}, },
}, },
}), }),
HeadingAnchors,
Placeholder.configure({ Placeholder.configure({
placeholder: ({ node }) => { placeholder: ({ node }) => {
if (node.type.name === "heading") { if (node.type.name === "heading") {
@@ -241,6 +246,12 @@ export const mainExtensions = [
} }
}, },
}).configure(), }).configure(),
UniqueID.configure({
types: ["heading"],
attributeName: "nodeId",
generateID: () => generateEditorNodeId(),
filterTransaction: (transaction) => !isChangeOrigin(transaction),
}),
] as any; ] as any;
type CollabExtensions = (provider: HocuspocusProvider, user: IUser) => any[]; type CollabExtensions = (provider: HocuspocusProvider, user: IUser) => any[];
@@ -51,6 +51,7 @@ import { FIVE_MINUTES } from "@/lib/constants.ts";
import { PageEditMode } from "@/features/user/types/user.types.ts"; import { PageEditMode } from "@/features/user/types/user.types.ts";
import { jwtDecode } from "jwt-decode"; import { jwtDecode } from "jwt-decode";
import { searchSpotlight } from '@/features/search/constants.ts'; import { searchSpotlight } from '@/features/search/constants.ts';
import { useAnchorScroll } from "./components/heading/use-anchor-scroll";
interface PageEditorProps { interface PageEditorProps {
pageId: string; pageId: string;
@@ -87,6 +88,7 @@ export default function PageEditor({
const [isCollabReady, setIsCollabReady] = useState(false); const [isCollabReady, setIsCollabReady] = useState(false);
const { pageSlug } = useParams(); const { pageSlug } = useParams();
const slugId = extractPageSlugId(pageSlug); const slugId = extractPageSlugId(pageSlug);
// useAnchorScroll();
const userPageEditMode = const userPageEditMode =
currentUser?.user?.settings?.preferences?.pageEditMode ?? PageEditMode.Edit; currentUser?.user?.settings?.preferences?.pageEditMode ?? PageEditMode.Edit;
@@ -0,0 +1,79 @@
.heading-block {
position: relative;
scroll-margin-top: 80px;
}
.has-anchor {
position: relative;
}
.heading-anchor-wrapper {
display: inline-block;
margin-left: 8px;
vertical-align: middle;
}
.heading-anchor-button {
display: inline-flex;
align-items: center;
justify-content: center;
width: 20px;
height: 20px;
padding: 0;
border: none;
background: transparent;
color: var(--mantine-color-gray-5);
cursor: pointer;
opacity: 0;
transition: opacity 0.2s ease, color 0.2s ease;
outline: none;
}
.has-anchor:hover .heading-anchor-button {
opacity: 1;
}
.heading-anchor-button:hover {
color: var(--mantine-color-blue-6);
}
.heading-anchor-button.copied {
color: var(--mantine-color-green-6);
opacity: 1;
}
.heading-anchor-button svg {
width: 16px;
height: 16px;
}
@media (max-width: 768px) {
.heading-anchor-button {
opacity: 0.3;
}
.has-anchor:hover .heading-anchor-button {
opacity: 0.7;
}
}
@media print {
.heading-anchor-wrapper {
display: none !important;
}
}
.ProseMirror .heading-anchor-button {
pointer-events: all;
}
/* Hide button when cursor is in the same heading */
.ProseMirror-focused .has-anchor.ProseMirror-selectednode .heading-anchor-button {
opacity: 0;
}
/* Always show on hover, regardless of focus state */
.has-anchor:hover .heading-anchor-button {
opacity: 1;
pointer-events: all;
}
@@ -12,3 +12,4 @@
@import "./find.css"; @import "./find.css";
@import "./mention.css"; @import "./mention.css";
@import "./ordered-list.css"; @import "./ordered-list.css";
@import "./heading-anchors.css";
@@ -0,0 +1,5 @@
import { customAlphabet } from "nanoid";
const slugIdAlphabet =
"0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
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-]+)\/?$/;
@@ -8,12 +8,14 @@ import ReadonlyPageEditor from "@/features/editor/readonly-page-editor.tsx";
import { extractPageSlugId } from "@/lib"; import { extractPageSlugId } from "@/lib";
import { Error404 } from "@/components/ui/error-404.tsx"; import { Error404 } from "@/components/ui/error-404.tsx";
import ShareBranding from "@/features/share/components/share-branding.tsx"; import ShareBranding from "@/features/share/components/share-branding.tsx";
import { useAnchorScroll } from "@/features/editor/components/heading/use-anchor-scroll";
export default function SharedPage() { export default function SharedPage() {
const { t } = useTranslation(); const { t } = useTranslation();
const { pageSlug } = useParams(); const { pageSlug } = useParams();
const { shareId } = useParams(); const { shareId } = useParams();
const navigate = useNavigate(); const navigate = useNavigate();
useAnchorScroll();
const { data, isLoading, isError, error } = useSharePageQuery({ const { data, isLoading, isError, error } = useSharePageQuery({
pageId: extractPageSlugId(pageSlug), pageId: extractPageSlugId(pageSlug),
@@ -10,6 +10,7 @@ import { Typography } from '@tiptap/extension-typography';
import { TextStyle } from '@tiptap/extension-text-style'; import { TextStyle } from '@tiptap/extension-text-style';
import { Color } from '@tiptap/extension-color'; import { Color } from '@tiptap/extension-color';
import { Youtube } from '@tiptap/extension-youtube'; import { Youtube } from '@tiptap/extension-youtube';
import UniqueID from '@tiptap/extension-unique-id';
import { import {
Callout, Callout,
Comment, Comment,
@@ -33,6 +34,7 @@ import {
Embed, Embed,
Mention, Mention,
Subpages, Subpages,
HeadingAnchors
} 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';
@@ -45,7 +47,9 @@ import { Node } from '@tiptap/pm/model';
export const tiptapExtensions = [ export const tiptapExtensions = [
StarterKit.configure({ StarterKit.configure({
codeBlock: false, codeBlock: false,
heading: false,
}), }),
HeadingAnchors,
Comment, Comment,
TextAlign.configure({ types: ['heading', 'paragraph'] }), TextAlign.configure({ types: ['heading', 'paragraph'] }),
TaskList, TaskList,
@@ -81,6 +85,10 @@ export const tiptapExtensions = [
Embed, Embed,
Mention, Mention,
Subpages, Subpages,
UniqueID.configure({
types: ['heading'],
attributeName: 'nodeId',
}),
] as any; ] as any;
export function jsonToHtml(tiptapJson: any) { export function jsonToHtml(tiptapJson: any) {
+1
View File
@@ -56,6 +56,7 @@
"@tiptap/extension-text-style": "^2.10.3", "@tiptap/extension-text-style": "^2.10.3",
"@tiptap/extension-typography": "^2.10.3", "@tiptap/extension-typography": "^2.10.3",
"@tiptap/extension-underline": "^2.10.3", "@tiptap/extension-underline": "^2.10.3",
"@tiptap/extension-unique-id": "^2.23.0",
"@tiptap/extension-youtube": "^2.10.3", "@tiptap/extension-youtube": "^2.10.3",
"@tiptap/html": "^2.10.3", "@tiptap/html": "^2.10.3",
"@tiptap/pm": "^2.10.3", "@tiptap/pm": "^2.10.3",
+1
View File
@@ -20,3 +20,4 @@ export * from "./lib/markdown";
export * from "./lib/search-and-replace"; export * from "./lib/search-and-replace";
export * from "./lib/embed-provider"; export * from "./lib/embed-provider";
export * from "./lib/subpages"; export * from "./lib/subpages";
export * from "./lib/heading";
@@ -0,0 +1,80 @@
import Heading from "@tiptap/extension-heading";
import { Plugin, PluginKey } from "@tiptap/pm/state";
import { Node as ProseMirrorNode } from "@tiptap/pm/model";
import { mergeAttributes } from "@tiptap/core";
import { buildAnchorDecorations } from './utils';
const HEADING_ANCHORS_PLUGIN_KEY = new PluginKey("heading-anchors");
export const HeadingAnchors = Heading.extend({
renderHTML({ node, HTMLAttributes }) {
const hasLevel = this.options.levels.includes(node.attrs.level);
const level = hasLevel ? node.attrs.level : this.options.levels[0];
return [
`h${level}`,
mergeAttributes(HTMLAttributes, {
class: "heading-block",
}),
0,
];
},
addProseMirrorPlugins() {
return [
...(this.parent?.() || []),
new Plugin({
key: HEADING_ANCHORS_PLUGIN_KEY,
state: {
init(_, { doc }) {
return buildAnchorDecorations(doc);
},
apply(tr, oldState, _, newState) {
if (!tr.docChanged) {
return oldState.map(tr.mapping, tr.doc);
}
let headingsChanged = false;
tr.steps.forEach((step) => {
step.getMap().forEach((oldStart, oldEnd, newStart, newEnd) => {
// Check both old and new document ranges for headings
const checkRange = (
doc: ProseMirrorNode,
from: number,
to: number,
) => {
doc.nodesBetween(from, to, (node) => {
if (node.type.name === 'heading') {
headingsChanged = true;
return false;
}
});
};
if (tr.docs[0]) {
checkRange(tr.docs[0], oldStart, oldEnd);
}
checkRange(newState.doc, newStart, newEnd);
});
});
if (headingsChanged) {
return buildAnchorDecorations(newState.doc);
}
return oldState.map(tr.mapping, tr.doc);
},
},
props: {
decorations(state) {
return this.getState(state);
},
},
}),
];
},
});
export default HeadingAnchors;
@@ -0,0 +1 @@
export { HeadingAnchors } from "./heading-anchors";
@@ -0,0 +1,100 @@
import { Node as ProseMirrorNode } from "prosemirror-model";
import { Decoration, DecorationSet } from "@tiptap/pm/view";
import slugify from "@sindresorhus/slugify";
const textToSlug = (text: string): string => {
return slugify(text?.substring(0, 20));
};
function buildAnchorId(node: ProseMirrorNode): string {
const text = node.textContent;
const nodeId = node.attrs.nodeId;
if (!text) return "";
if (nodeId) {
const slug = textToSlug(text);
return slug ? `${slug}-${nodeId}` : nodeId;
}
return textToSlug(text);
}
function createAnchorLink(id: string): HTMLElement {
const wrapper = document.createElement("span");
wrapper.className = "heading-anchor-wrapper";
const button = document.createElement("button");
button.className = "heading-anchor-button";
button.setAttribute("aria-label", "Copy link to this section");
button.setAttribute("contenteditable", "false");
button.innerHTML = `
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
<path d="M7.775 3.275a.75.75 0 001.06 1.06l1.25-1.25a2 2 0 112.83 2.83l-2.5 2.5a2 2 0 01-2.83 0 .75.75 0 00-1.06 1.06 3.5 3.5 0 004.95 0l2.5-2.5a3.5 3.5 0 00-4.95-4.95l-1.25 1.25zm-4.69 9.64a2 2 0 010-2.83l2.5-2.5a2 2 0 012.83 0 .75.75 0 001.06-1.06 3.5 3.5 0 00-4.95 0l-2.5 2.5a3.5 3.5 0 004.95 4.95l1.25-1.25a.75.75 0 00-1.06-1.06l-1.25 1.25a2 2 0 01-2.83 0z"/>
</svg>
`;
button.addEventListener("mousedown", (e) => {
e.preventDefault();
e.stopPropagation();
});
button.addEventListener("click", (e) => {
e.preventDefault();
e.stopPropagation();
const url = new URL(window.location.href);
url.hash = id;
navigator.clipboard.writeText(url.toString()).then(() => {
const originalHTML = button.innerHTML;
button.innerHTML = `
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
<path d="M13.78 4.22a.75.75 0 010 1.06l-7.25 7.25a.75.75 0 01-1.06 0L2.22 9.28a.75.75 0 011.06-1.06L6 10.94l6.72-6.72a.75.75 0 011.06 0z"/>
</svg>
`;
button.classList.add("copied");
setTimeout(() => {
button.innerHTML = originalHTML;
button.classList.remove("copied");
}, 2000);
});
});
wrapper.appendChild(button);
return wrapper;
}
export function buildAnchorDecorations(doc: ProseMirrorNode): DecorationSet {
const decorations: Decoration[] = [];
doc.descendants((node, pos) => {
if (node.type.name !== "heading" || !node.textContent) {
return;
}
const anchorId = buildAnchorId(node);
if (!anchorId) return;
decorations.push(
Decoration.node(pos, pos + node.nodeSize, {
id: anchorId,
class: "has-anchor",
"data-anchor-id": anchorId,
}),
);
if (node.content.size > 0) {
const lastChildEnd = pos + 1 + node.content.size;
decorations.push(
Decoration.widget(lastChildEnd, createAnchorLink(anchorId), {
side: 0,
key: `anchor-${anchorId}`,
}),
);
}
});
return DecorationSet.create(doc, decorations);
}
+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
@@ -130,6 +130,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))
@@ -4257,6 +4260,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:
@@ -9581,6 +9590,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
@@ -14287,6 +14300,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)
@@ -20591,6 +20610,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: {}