mirror of
https://github.com/docmost/docmost.git
synced 2026-05-18 23:44:24 +08:00
use prosemirror decorations
This commit is contained in:
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user