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); +}