From 66099f465765edc3ed39822d64d1cfd8c9b1a88c Mon Sep 17 00:00:00 2001
From: Philipinho <16838612+Philipinho@users.noreply.github.com>
Date: Mon, 11 Aug 2025 22:10:30 -0700
Subject: [PATCH] use prosemirror decorations
---
.../components/heading/heading-view.tsx | 67 ------------
.../features/editor/extensions/extensions.ts | 21 ++--
.../src/features/editor/page-editor.tsx | 2 +-
.../editor/styles/heading-anchors.css | 79 ++++++++++++++
.../src/features/editor/styles/index.css | 1 +
.../src/collaboration/collaboration.util.ts | 3 +
packages/editor-ext/src/index.ts | 1 +
.../src/lib/heading/heading-anchors.ts | 80 ++++++++++++++
packages/editor-ext/src/lib/heading/index.ts | 1 +
packages/editor-ext/src/lib/heading/utils.ts | 100 ++++++++++++++++++
10 files changed, 274 insertions(+), 81 deletions(-)
delete mode 100644 apps/client/src/features/editor/components/heading/heading-view.tsx
create mode 100644 apps/client/src/features/editor/styles/heading-anchors.css
create mode 100644 packages/editor-ext/src/lib/heading/heading-anchors.ts
create mode 100644 packages/editor-ext/src/lib/heading/index.ts
create mode 100644 packages/editor-ext/src/lib/heading/utils.ts
diff --git a/apps/client/src/features/editor/components/heading/heading-view.tsx b/apps/client/src/features/editor/components/heading/heading-view.tsx
deleted file mode 100644
index ab7ab158..00000000
--- a/apps/client/src/features/editor/components/heading/heading-view.tsx
+++ /dev/null
@@ -1,67 +0,0 @@
-import { ActionIcon, CopyButton, Flex, Group, Tooltip } from "@mantine/core";
-import { IconAnchor, IconCheck, IconCopy } from "@tabler/icons-react";
-import { NodeViewContent, NodeViewProps, NodeViewWrapper } from "@tiptap/react";
-import { ElementType, useEffect, useState } from "react";
-import { useTranslation } from "react-i18next";
-import classes from "./heading.module.css";
-
-const generateSlug = (text: string) =>
- text
- .normalize("NFD")
- .replace(/[\u0300-\u036f]/g, "")
- .toLowerCase()
- .replace(/[^\w\s-]/g, "")
- .trim()
- .replace(/\s+/g, "-");
-
-export default function HeadingView({ node }: NodeViewProps) {
- const { t } = useTranslation();
- const [combinedId, setCombinedId] = useState("");
- const [url, setUrl] = useState("");
- const [showAnchorButton, setShowAnchorButton] = useState(false);
-
- const tag: ElementType = `h${node.attrs.level}` as ElementType;
- const nodeId = node.attrs.nodeId;
-
- useEffect(() => {
- if (nodeId) {
- const text = node.textContent || "";
- const textSlug = generateSlug(text);
- const combined = textSlug ? `${textSlug}-${nodeId}` : nodeId;
- setCombinedId(combined);
-
- const baseUrl = window.location.href.split("#")[0];
- setUrl(`${baseUrl}#${combined}`);
- }
- }, [nodeId, node.content]);
-
- return (
- setShowAnchorButton(true)}
- onMouseLeave={() => setShowAnchorButton(false)}
- >
-
-
- {showAnchorButton && nodeId && combinedId && node.textContent && (
-
- {({ copied, copy }) => (
-
-
- {copied ? : }
-
-
- )}
-
- )}
-
-
- );
-}
diff --git a/apps/client/src/features/editor/extensions/extensions.ts b/apps/client/src/features/editor/extensions/extensions.ts
index 548d5aeb..1421eea9 100644
--- a/apps/client/src/features/editor/extensions/extensions.ts
+++ b/apps/client/src/features/editor/extensions/extensions.ts
@@ -38,6 +38,7 @@ import {
Embed,
SearchAndReplace,
Mention,
+ HeadingAnchors,
} from "@docmost/editor-ext";
import {
randomElement,
@@ -74,10 +75,8 @@ import i18n from "@/i18n.ts";
import { MarkdownClipboard } from "@/features/editor/extensions/markdown-clipboard.ts";
import EmojiCommand from "./emoji-command";
import { CharacterCount } from "@tiptap/extension-character-count";
-import Heading from "@tiptap/extension-heading";
-import HeadingView from "../components/heading/heading-view";
import { countWords } from "alfaaz";
-import UniqueID from '@tiptap/extension-unique-id';
+import UniqueID from "@tiptap/extension-unique-id";
import { generateEditorNodeId } from "../utils/nanoid";
const lowlight = createLowlight(common);
@@ -107,11 +106,7 @@ export const mainExtensions = [
},
},
}),
- Heading.extend({
- addNodeView() {
- return ReactNodeViewRenderer(HeadingView);
- }
- }),
+ HeadingAnchors,
Placeholder.configure({
placeholder: ({ node }) => {
if (node.type.name === "heading") {
@@ -231,22 +226,22 @@ 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(),
UniqueID.configure({
- types: ['heading'],
- attributeName: 'nodeId',
+ types: ["heading"],
+ attributeName: "nodeId",
generateID: () => generateEditorNodeId(),
filterTransaction: (transaction) => !isChangeOrigin(transaction),
}),
diff --git a/apps/client/src/features/editor/page-editor.tsx b/apps/client/src/features/editor/page-editor.tsx
index 44353363..d388a714 100644
--- a/apps/client/src/features/editor/page-editor.tsx
+++ b/apps/client/src/features/editor/page-editor.tsx
@@ -86,7 +86,7 @@ export default function PageEditor({
const [isCollabReady, setIsCollabReady] = useState(false);
const { pageSlug } = useParams();
const slugId = extractPageSlugId(pageSlug);
- useAnchorScroll();
+ // useAnchorScroll();
const userPageEditMode =
currentUser?.user?.settings?.preferences?.pageEditMode ?? PageEditMode.Edit;
diff --git a/apps/client/src/features/editor/styles/heading-anchors.css b/apps/client/src/features/editor/styles/heading-anchors.css
new file mode 100644
index 00000000..3af8ffca
--- /dev/null
+++ b/apps/client/src/features/editor/styles/heading-anchors.css
@@ -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;
+}
\ No newline at end of file
diff --git a/apps/client/src/features/editor/styles/index.css b/apps/client/src/features/editor/styles/index.css
index e426e0ba..129ca13e 100644
--- a/apps/client/src/features/editor/styles/index.css
+++ b/apps/client/src/features/editor/styles/index.css
@@ -12,3 +12,4 @@
@import "./find.css";
@import "./mention.css";
@import "./ordered-list.css";
+@import "./heading-anchors.css";
diff --git a/apps/server/src/collaboration/collaboration.util.ts b/apps/server/src/collaboration/collaboration.util.ts
index 3b7f9f0a..3384e1fc 100644
--- a/apps/server/src/collaboration/collaboration.util.ts
+++ b/apps/server/src/collaboration/collaboration.util.ts
@@ -33,6 +33,7 @@ import {
Excalidraw,
Embed,
Mention,
+ HeadingAnchors
} from '@docmost/editor-ext';
import { generateText, getSchema, JSONContent } from '@tiptap/core';
import { generateHTML } from '../common/helpers/prosemirror/html';
@@ -45,7 +46,9 @@ import { Node } from '@tiptap/pm/model';
export const tiptapExtensions = [
StarterKit.configure({
codeBlock: false,
+ heading: false,
}),
+ HeadingAnchors,
Comment,
TextAlign.configure({ types: ['heading', 'paragraph'] }),
TaskList,
diff --git a/packages/editor-ext/src/index.ts b/packages/editor-ext/src/index.ts
index d3e1d53d..936ebb76 100644
--- a/packages/editor-ext/src/index.ts
+++ b/packages/editor-ext/src/index.ts
@@ -19,3 +19,4 @@ export * from "./lib/mention";
export * from "./lib/markdown";
export * from "./lib/search-and-replace";
export * from "./lib/embed-provider";
+export * from "./lib/heading";
diff --git a/packages/editor-ext/src/lib/heading/heading-anchors.ts b/packages/editor-ext/src/lib/heading/heading-anchors.ts
new file mode 100644
index 00000000..f70d7835
--- /dev/null
+++ b/packages/editor-ext/src/lib/heading/heading-anchors.ts
@@ -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;
diff --git a/packages/editor-ext/src/lib/heading/index.ts b/packages/editor-ext/src/lib/heading/index.ts
new file mode 100644
index 00000000..c4161fe7
--- /dev/null
+++ b/packages/editor-ext/src/lib/heading/index.ts
@@ -0,0 +1 @@
+export { HeadingAnchors } from "./heading-anchors";
diff --git a/packages/editor-ext/src/lib/heading/utils.ts b/packages/editor-ext/src/lib/heading/utils.ts
new file mode 100644
index 00000000..6b97a830
--- /dev/null
+++ b/packages/editor-ext/src/lib/heading/utils.ts
@@ -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 = `
+
+ `;
+
+ 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 = `
+
+ `;
+ 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);
+}