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