diff --git a/dashboard/package.json b/dashboard/package.json index 7b4a7f071a..15d39d9b7d 100644 --- a/dashboard/package.json +++ b/dashboard/package.json @@ -23,6 +23,7 @@ "date-fns": "2.30.0", "dompurify": "^3.3.1", "event-source-polyfill": "^1.0.31", + "github-slugger": "^2.0.0", "highlight.js": "^11.11.1", "js-md5": "^0.8.3", "katex": "^0.16.27", diff --git a/dashboard/pnpm-lock.yaml b/dashboard/pnpm-lock.yaml index ea8636c615..349dda4e9c 100644 --- a/dashboard/pnpm-lock.yaml +++ b/dashboard/pnpm-lock.yaml @@ -38,6 +38,9 @@ importers: event-source-polyfill: specifier: ^1.0.31 version: 1.0.31 + github-slugger: + specifier: ^2.0.0 + version: 2.0.0 highlight.js: specifier: ^11.11.1 version: 11.11.1 @@ -1607,6 +1610,9 @@ packages: resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} engines: {node: '>= 0.4'} + github-slugger@2.0.0: + resolution: {integrity: sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw==} + glob-parent@5.1.2: resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} engines: {node: '>= 6'} @@ -4359,6 +4365,8 @@ snapshots: dunder-proto: 1.0.1 es-object-atoms: 1.1.1 + github-slugger@2.0.0: {} + glob-parent@5.1.2: dependencies: is-glob: 4.0.3 diff --git a/dashboard/src/components/shared/ReadmeDialog.vue b/dashboard/src/components/shared/ReadmeDialog.vue index ddc27cd900..4aeb145ce6 100644 --- a/dashboard/src/components/shared/ReadmeDialog.vue +++ b/dashboard/src/components/shared/ReadmeDialog.vue @@ -4,6 +4,7 @@ import MarkdownIt from "markdown-it"; import hljs from "highlight.js"; import axios from "axios"; import DOMPurify from "dompurify"; +import GithubSlugger from "github-slugger"; import "highlight.js/styles/github-dark.css"; import { useI18n } from "@/i18n/composables"; @@ -18,6 +19,16 @@ const md = new MarkdownIt({ md.enable(["table", "strikethrough"]); md.renderer.rules.table_open = () => '
'; md.renderer.rules.table_close = () => "
"; +md.renderer.rules.heading_open = (tokens, idx, options, env, self) => { + const headingToken = tokens[idx + 1]; + const headingText = headingToken?.content || ""; + const slugger = env?.slugger; + if (slugger && typeof slugger.slug === "function") { + const headingId = slugger.slug(headingText); + tokens[idx].attrSet("id", headingId || slugger.slug("section")); + } + return self.renderToken(tokens, idx, options); +}; // 2. 复制按钮的 SVG 图标常量 const ICONS = { @@ -28,6 +39,41 @@ const ICONS = { COPY: '', }; +function isInDialogAnchor(href) { + return href?.startsWith("#") && href.length > 1 && !href.startsWith("#/"); +} + +function safeDecodeAnchorId(anchorId) { + try { + return decodeURIComponent(anchorId); + } catch { + return anchorId; + } +} + +function scrollToHeadingInDialog(anchorId, markdownContainer, scrollContainer) { + if (!anchorId || !markdownContainer) return; + const decodedId = safeDecodeAnchorId(anchorId); + const escapedId = + typeof CSS !== "undefined" && CSS.escape + ? CSS.escape(decodedId) + : decodedId.replace(/["\\]/g, "\\$&"); + const target = markdownContainer.querySelector(`#${escapedId}`); + if (!target) return; + + if (!scrollContainer) { + target.scrollIntoView({ behavior: "smooth", block: "start" }); + return; + } + + const offsetTop = + target.getBoundingClientRect().top - scrollContainer.getBoundingClientRect().top; + scrollContainer.scrollTo({ + top: scrollContainer.scrollTop + offsetTop - 8, + behavior: "smooth", + }); +} + const props = defineProps({ show: { type: Boolean, default: false }, pluginName: { type: String, default: "" }, @@ -46,6 +92,7 @@ const content = ref(null); const error = ref(null); const loading = ref(false); const isEmpty = ref(false); +const contentContainer = ref(null); const copyFeedbackTimer = ref(null); const lastRequestId = ref(0); @@ -79,7 +126,7 @@ const renderedHtml = computed(() => { `; }; - const rawHtml = md.render(content.value); + const rawHtml = md.render(content.value, { slugger: new GithubSlugger() }); const cleanHtml = DOMPurify.sanitize(rawHtml, { ALLOWED_TAGS: [ @@ -251,17 +298,34 @@ watch( function handleContainerClick(event) { const btn = event.target.closest(".copy-code-btn"); - if (!btn) return; - const code = btn.closest(".code-block-wrapper")?.querySelector("code"); - if (code) { - if (navigator.clipboard?.writeText) { - navigator.clipboard - .writeText(code.textContent) - .then(() => showCopyFeedback(btn, true)) - .catch(() => tryFallbackCopy(code.textContent, btn)); - } else { - tryFallbackCopy(code.textContent, btn); + if (btn) { + const code = btn.closest(".code-block-wrapper")?.querySelector("code"); + if (code) { + if (navigator.clipboard?.writeText) { + navigator.clipboard + .writeText(code.textContent) + .then(() => showCopyFeedback(btn, true)) + .catch(() => tryFallbackCopy(code.textContent, btn)); + } else { + tryFallbackCopy(code.textContent, btn); + } } + return; + } + + const link = event.target.closest("a"); + if (!link) return; + const href = link.getAttribute("href")?.trim(); + if (!isInDialogAnchor(href)) return; + + event.preventDefault(); + const markdownContainer = event.currentTarget; + if (markdownContainer) { + scrollToHeadingInDialog( + href.slice(1), + markdownContainer, + contentContainer.value, + ); } } @@ -326,7 +390,7 @@ const showActionArea = computed(() => { mdi-close - +