use prosemirror decorations

This commit is contained in:
Philipinho
2025-08-11 22:10:30 -07:00
parent cefabc8683
commit 66099f4657
10 changed files with 274 additions and 81 deletions
@@ -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 (
<NodeViewWrapper
as={tag}
id={combinedId}
className={classes.anchorScrollMargin}
onMouseEnter={() => setShowAnchorButton(true)}
onMouseLeave={() => setShowAnchorButton(false)}
>
<Flex gap="sm" justify="flex-start" align="center">
<NodeViewContent as="span" />
{showAnchorButton && nodeId && combinedId && node.textContent && (
<CopyButton value={url} timeout={2000}>
{({ copied, copy }) => (
<Tooltip disabled={!copied} label={t("Anchor link copied")} openDelay={300} withArrow position="bottom">
<ActionIcon
color={copied ? "teal" : "gray"}
variant="subtle"
size="sm"
onClick={copy}
>
{copied ? <IconCheck size={16} /> : <IconAnchor size={16} />}
</ActionIcon>
</Tooltip>
)}
</CopyButton>
)}
</Flex>
</NodeViewWrapper>
);
}
@@ -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),
}),
@@ -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;
@@ -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 "./mention.css";
@import "./ordered-list.css";
@import "./heading-anchors.css";
@@ -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,
+1
View File
@@ -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";
@@ -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);
}