mirror of
https://github.com/StepanovPlaton/AboutMe.git
synced 2026-04-04 04:40:51 +04:00
Initial commit
This commit is contained in:
83
src/plugins/expressive-code/collapse-button.ts
Normal file
83
src/plugins/expressive-code/collapse-button.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
import { definePlugin } from "@expressive-code/core";
|
||||
import type { Element } from "hast";
|
||||
|
||||
export function pluginCollapseButton() {
|
||||
return definePlugin({
|
||||
name: "Collapse Button",
|
||||
hooks: {
|
||||
postprocessRenderedBlock: (context) => {
|
||||
// If the code block has a title, we don't add the collapse button
|
||||
// as it might conflict with the title bar layout
|
||||
const classNames = (context.renderData.blockAst.properties?.className as string[]) || [];
|
||||
if (classNames.includes("has-title")) {
|
||||
return;
|
||||
}
|
||||
|
||||
function processCodeBlock(node: Element) {
|
||||
// Add classes to the root node to indicate it's collapsible and expanded by default
|
||||
if (!node.properties) node.properties = {};
|
||||
const classNames = (node.properties.className as string[]) || [];
|
||||
if (!classNames.includes("collapsible")) {
|
||||
classNames.push("collapsible");
|
||||
}
|
||||
if (!classNames.includes("expanded")) {
|
||||
classNames.push("expanded");
|
||||
}
|
||||
node.properties.className = classNames;
|
||||
|
||||
const collapseButton = {
|
||||
type: "element" as const,
|
||||
tagName: "button",
|
||||
properties: {
|
||||
className: [
|
||||
"collapse-btn"
|
||||
],
|
||||
"aria-label": "Collapse code",
|
||||
},
|
||||
children: [
|
||||
{
|
||||
type: "element" as const,
|
||||
tagName: "div",
|
||||
properties: {
|
||||
className: [
|
||||
"collapse-btn-icon",
|
||||
],
|
||||
},
|
||||
children: [
|
||||
{
|
||||
type: "element" as const,
|
||||
tagName: "svg",
|
||||
properties: {
|
||||
viewBox: "0 0 24 24",
|
||||
xmlns: "http://www.w3.org/2000/svg",
|
||||
className: [
|
||||
"collapse-btn-icon",
|
||||
"collapse-icon",
|
||||
],
|
||||
},
|
||||
children: [
|
||||
{
|
||||
type: "element" as const,
|
||||
tagName: "path",
|
||||
properties: {
|
||||
d: "M11.9999 13.1714L16.9497 8.22168L18.3639 9.63589L11.9999 15.9999L5.63599 9.63589L7.0502 8.22168L11.9999 13.1714Z"
|
||||
},
|
||||
children: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
} as Element;
|
||||
if (!node.children) {
|
||||
node.children = [];
|
||||
}
|
||||
node.children.push(collapseButton);
|
||||
}
|
||||
|
||||
processCodeBlock(context.renderData.blockAst);
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
88
src/plugins/expressive-code/copy-button.ts
Normal file
88
src/plugins/expressive-code/copy-button.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
import { definePlugin } from "@expressive-code/core";
|
||||
import type { Element } from "hast";
|
||||
|
||||
|
||||
export function pluginCopyButton() {
|
||||
return definePlugin({
|
||||
name: "Copy Button",
|
||||
hooks: {
|
||||
postprocessRenderedBlock: (context) => {
|
||||
function processCodeBlock(node: Element) {
|
||||
const copyButton = {
|
||||
type: "element" as const,
|
||||
tagName: "button",
|
||||
properties: {
|
||||
className: [
|
||||
"copy-btn"
|
||||
],
|
||||
"aria-label": "Copy code",
|
||||
},
|
||||
children: [
|
||||
{
|
||||
type: "element" as const,
|
||||
tagName: "div",
|
||||
properties: {
|
||||
className: [
|
||||
"copy-btn-icon"
|
||||
],
|
||||
},
|
||||
children: [
|
||||
{
|
||||
type: "element" as const,
|
||||
tagName: "svg",
|
||||
properties: {
|
||||
viewBox: "0 -960 960 960",
|
||||
xmlns: "http://www.w3.org/2000/svg",
|
||||
className: [
|
||||
"copy-btn-icon",
|
||||
"copy-icon",
|
||||
],
|
||||
},
|
||||
children: [
|
||||
{
|
||||
type: "element" as const,
|
||||
tagName: "path",
|
||||
properties: {
|
||||
d: "M368.37-237.37q-34.48 0-58.74-24.26-24.26-24.26-24.26-58.74v-474.26q0-34.48 24.26-58.74 24.26-24.26 58.74-24.26h378.26q34.48 0 58.74 24.26 24.26 24.26 24.26 58.74v474.26q0 34.48-24.26 58.74-24.26 24.26-58.74 24.26H368.37Zm0-83h378.26v-474.26H368.37v474.26Zm-155 238q-34.48 0-58.74-24.26-24.26-24.26-24.26-58.74v-515.76q0-17.45 11.96-29.48 11.97-12.02 29.33-12.02t29.54 12.02q12.17 12.03 12.17 29.48v515.76h419.76q17.45 0 29.48 11.96 12.02 11.97 12.02 29.33t-12.02 29.54q-12.03 12.17-29.48 12.17H213.37Zm155-238v-474.26 474.26Z",
|
||||
},
|
||||
children: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: "element" as const,
|
||||
tagName: "svg",
|
||||
properties: {
|
||||
viewBox: "0 -960 960 960",
|
||||
xmlns: "http://www.w3.org/2000/svg",
|
||||
className: [
|
||||
"copy-btn-icon",
|
||||
"success-icon",
|
||||
],
|
||||
},
|
||||
children: [
|
||||
{
|
||||
type: "element" as const,
|
||||
tagName: "path",
|
||||
properties: {
|
||||
d: "m389-377.13 294.7-294.7q12.58-12.67 29.52-12.67 16.93 0 29.61 12.67 12.67 12.68 12.67 29.53 0 16.86-12.28 29.14L419.07-288.41q-12.59 12.67-29.52 12.67-16.94 0-29.62-12.67L217.41-430.93q-12.67-12.68-12.79-29.45-.12-16.77 12.55-29.45 12.68-12.67 29.62-12.67 16.93 0 29.28 12.67L389-377.13Z",
|
||||
},
|
||||
children: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
} as Element;
|
||||
if (!node.children) {
|
||||
node.children = [];
|
||||
}
|
||||
node.children.push(copyButton);
|
||||
}
|
||||
|
||||
processCodeBlock(context.renderData.blockAst);
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
52
src/plugins/expressive-code/language-badge.ts
Normal file
52
src/plugins/expressive-code/language-badge.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
/**
|
||||
* Based on the discussion at https://github.com/expressive-code/expressive-code/issues/153#issuecomment-2282218684
|
||||
*/
|
||||
import { definePlugin } from "@expressive-code/core";
|
||||
|
||||
|
||||
export function pluginLanguageBadge() {
|
||||
return definePlugin({
|
||||
name: "Language Badge",
|
||||
hooks: {
|
||||
postprocessRenderedBlock: ({ codeBlock, renderData }) => {
|
||||
const language = codeBlock.language;
|
||||
if (language && renderData.blockAst.properties) {
|
||||
renderData.blockAst.properties["data-language"] = language;
|
||||
}
|
||||
},
|
||||
},
|
||||
baseStyles: ({}) => `
|
||||
.frame[data-language]:not(.has-title):not(.is-terminal) {
|
||||
position: relative;
|
||||
|
||||
&::after {
|
||||
pointer-events: none;
|
||||
position: absolute;
|
||||
z-index: 2;
|
||||
right: 0.5rem;
|
||||
top: 0.5rem;
|
||||
content: attr(data-language);
|
||||
font-family: "JetBrains Mono Variable", ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
|
||||
font-size: 0.75rem;
|
||||
font-weight: bold;
|
||||
text-transform: uppercase;
|
||||
color: var(--btn-content);
|
||||
background: var(--btn-regular-bg);
|
||||
opacity: 0;
|
||||
transition: opacity 0.3s;
|
||||
padding: 0.1rem 0.5rem;
|
||||
border-radius: 0.5rem;
|
||||
}
|
||||
|
||||
@media (hover: hover) {
|
||||
&::after {
|
||||
opacity: 1;
|
||||
}
|
||||
&:hover::after {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
});
|
||||
}
|
||||
481
src/plugins/mermaid-render-script.js
Normal file
481
src/plugins/mermaid-render-script.js
Normal file
@@ -0,0 +1,481 @@
|
||||
(() => {
|
||||
// 单例模式:检查是否已经初始化过
|
||||
if (window.mermaidInitialized) {
|
||||
return;
|
||||
}
|
||||
|
||||
window.mermaidInitialized = true;
|
||||
|
||||
// 记录当前主题状态,避免不必要的重新渲染
|
||||
let currentTheme = null;
|
||||
let isRendering = false; // 防止并发渲染
|
||||
let retryCount = 0;
|
||||
const MAX_RETRIES = 3;
|
||||
const RETRY_DELAY = 1000; // 1秒
|
||||
|
||||
// 检查主题是否真的发生了变化
|
||||
function hasThemeChanged() {
|
||||
const isDark = document.documentElement.classList.contains("dark");
|
||||
const newTheme = isDark ? "dark" : "default";
|
||||
|
||||
if (currentTheme !== newTheme) {
|
||||
currentTheme = newTheme;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// 等待 Mermaid 库加载完成
|
||||
function waitForMermaid(timeout = 10000) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const startTime = Date.now();
|
||||
|
||||
function check() {
|
||||
if (window.mermaid && typeof window.mermaid.initialize === "function") {
|
||||
resolve(window.mermaid);
|
||||
} else if (Date.now() - startTime > timeout) {
|
||||
reject(new Error("Mermaid library failed to load within timeout"));
|
||||
} else {
|
||||
setTimeout(check, 100);
|
||||
}
|
||||
}
|
||||
|
||||
check();
|
||||
});
|
||||
}
|
||||
|
||||
// 设置 MutationObserver 监听 html 元素的 class 属性变化
|
||||
function setupMutationObserver() {
|
||||
const observer = new MutationObserver((mutations) => {
|
||||
mutations.forEach((mutation) => {
|
||||
if (
|
||||
mutation.type === "attributes" &&
|
||||
mutation.attributeName === "class"
|
||||
) {
|
||||
// 检查是否是 dark 类的变化
|
||||
const target = mutation.target;
|
||||
const wasDark = mutation.oldValue
|
||||
? mutation.oldValue.includes("dark")
|
||||
: false;
|
||||
const isDark = target.classList.contains("dark");
|
||||
|
||||
if (wasDark !== isDark) {
|
||||
if (hasThemeChanged()) {
|
||||
// 延迟渲染,避免主题切换时的闪烁
|
||||
setTimeout(() => renderMermaidDiagrams(), 150);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// 开始观察 html 元素的 class 属性变化
|
||||
observer.observe(document.documentElement, {
|
||||
attributes: true,
|
||||
attributeFilter: ["class"],
|
||||
attributeOldValue: true,
|
||||
});
|
||||
}
|
||||
|
||||
// 缩放平移
|
||||
function attachZoomControls(element, svgElement) {
|
||||
if (element.__zoomAttached) return;
|
||||
element.__zoomAttached = true;
|
||||
|
||||
const wrapper = document.createElement("div");
|
||||
wrapper.className = "mermaid-zoom-wrapper";
|
||||
|
||||
const svgParent = svgElement.parentNode;
|
||||
wrapper.appendChild(svgElement);
|
||||
svgParent.appendChild(wrapper);
|
||||
|
||||
let scale = 1;
|
||||
let tx = 0;
|
||||
let ty = 0;
|
||||
const MIN_SCALE = 0.2;
|
||||
const MAX_SCALE = 6;
|
||||
|
||||
function applyTransform() {
|
||||
wrapper.style.transform = `translate(${tx}px, ${ty}px) scale(${scale})`;
|
||||
}
|
||||
const controls = document.createElement("div");
|
||||
controls.className = "mermaid-zoom-controls";
|
||||
controls.innerHTML = `
|
||||
<button class="btn-regular rounded-lg h-10 w-10 active:scale-90" data-action="zoom-in" title="Zoom in">+</button>
|
||||
<button class="btn-regular rounded-lg h-10 w-10 active:scale-90" data-action="zoom-out" title="Zoom out">−</button>
|
||||
<button class="btn-regular rounded-lg h-10 w-10 active:scale-90" data-action="reset" title="Reset">⤾</button>
|
||||
`;
|
||||
|
||||
controls.addEventListener("click", (ev) => {
|
||||
const action =
|
||||
ev.target.getAttribute && ev.target.getAttribute("data-action");
|
||||
if (!action) return;
|
||||
switch (action) {
|
||||
case "zoom-in":
|
||||
scale = Math.min(MAX_SCALE, +(scale * 1.2).toFixed(3));
|
||||
applyTransform();
|
||||
break;
|
||||
case "zoom-out":
|
||||
scale = Math.max(MIN_SCALE, +(scale / 1.2).toFixed(3));
|
||||
applyTransform();
|
||||
break;
|
||||
case "reset":
|
||||
scale = 1;
|
||||
tx = 0;
|
||||
ty = 0;
|
||||
applyTransform();
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
element.appendChild(controls);
|
||||
|
||||
// 鼠标滚轮缩放
|
||||
element.addEventListener(
|
||||
"wheel",
|
||||
(ev) => {
|
||||
ev.preventDefault();
|
||||
const delta = -ev.deltaY;
|
||||
const zoomFactor = delta > 0 ? 1.12 : 1 / 1.12;
|
||||
const prevScale = scale;
|
||||
scale = Math.min(
|
||||
MAX_SCALE,
|
||||
Math.max(MIN_SCALE, +(scale * zoomFactor).toFixed(3)),
|
||||
);
|
||||
const rect = wrapper.getBoundingClientRect();
|
||||
const cx = ev.clientX - rect.left;
|
||||
const cy = ev.clientY - rect.top;
|
||||
const worldX = cx / prevScale - tx;
|
||||
const worldY = cy / prevScale - ty;
|
||||
tx = cx / scale - worldX;
|
||||
ty = cy / scale - worldY;
|
||||
applyTransform();
|
||||
},
|
||||
{ passive: false },
|
||||
);
|
||||
|
||||
let isPanning = false;
|
||||
let startX = 0;
|
||||
let startY = 0;
|
||||
let startTx = 0;
|
||||
let startTy = 0;
|
||||
|
||||
wrapper.style.touchAction = "none";
|
||||
|
||||
wrapper.addEventListener("pointerdown", (ev) => {
|
||||
if (ev.button !== 0) return; // 仅左键
|
||||
isPanning = true;
|
||||
wrapper.setPointerCapture(ev.pointerId);
|
||||
startX = ev.clientX;
|
||||
startY = ev.clientY;
|
||||
startTx = tx;
|
||||
startTy = ty;
|
||||
});
|
||||
wrapper.addEventListener("pointermove", (ev) => {
|
||||
if (!isPanning) return;
|
||||
const dx = ev.clientX - startX;
|
||||
const dy = ev.clientY - startY;
|
||||
tx = startTx + dx / scale; // 根据当前缩放调整灵敏度
|
||||
ty = startTy + dy / scale;
|
||||
applyTransform();
|
||||
});
|
||||
wrapper.addEventListener("pointerup", (ev) => {
|
||||
isPanning = false;
|
||||
try {
|
||||
wrapper.releasePointerCapture(ev.pointerId);
|
||||
} catch (e) { }
|
||||
});
|
||||
wrapper.addEventListener("pointercancel", () => {
|
||||
isPanning = false;
|
||||
});
|
||||
|
||||
// 双击重置
|
||||
wrapper.addEventListener("dblclick", () => {
|
||||
scale = 1;
|
||||
tx = 0;
|
||||
ty = 0;
|
||||
applyTransform();
|
||||
});
|
||||
applyTransform();
|
||||
let resizeTimer = null;
|
||||
window.addEventListener("resize", () => {
|
||||
clearTimeout(resizeTimer);
|
||||
resizeTimer = setTimeout(() => {
|
||||
applyTransform();
|
||||
}, 200);
|
||||
});
|
||||
}
|
||||
|
||||
// 设置其他事件监听器
|
||||
function setupEventListeners() {
|
||||
// 监听页面切换
|
||||
document.addEventListener("astro:page-load", () => {
|
||||
// 重新初始化主题状态
|
||||
currentTheme = null;
|
||||
retryCount = 0; // 重置重试计数
|
||||
if (hasThemeChanged()) {
|
||||
setTimeout(() => renderMermaidDiagrams(), 100);
|
||||
}
|
||||
});
|
||||
|
||||
// 监听页面可见性变化,页面重新可见时重新渲染
|
||||
document.addEventListener("visibilitychange", () => {
|
||||
if (!document.hidden) {
|
||||
setTimeout(() => renderMermaidDiagrams(), 200);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function initializeMermaid() {
|
||||
try {
|
||||
await waitForMermaid();
|
||||
|
||||
// 初始化 Mermaid 配置
|
||||
window.mermaid.initialize({
|
||||
startOnLoad: false,
|
||||
theme: "default",
|
||||
themeVariables: {
|
||||
fontFamily: "inherit",
|
||||
fontSize: "16px",
|
||||
},
|
||||
securityLevel: "loose",
|
||||
// 添加错误处理配置
|
||||
errorLevel: "warn",
|
||||
logLevel: "error",
|
||||
});
|
||||
|
||||
// 渲染所有 Mermaid 图表
|
||||
await renderMermaidDiagrams();
|
||||
} catch (error) {
|
||||
console.error("Failed to initialize Mermaid:", error);
|
||||
// 如果初始化失败,尝试重新加载
|
||||
if (retryCount < MAX_RETRIES) {
|
||||
retryCount++;
|
||||
setTimeout(() => initializeMermaid(), RETRY_DELAY * retryCount);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function renderMermaidDiagrams() {
|
||||
// 防止并发渲染
|
||||
if (isRendering) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 检查 Mermaid 是否可用
|
||||
if (!window.mermaid || typeof window.mermaid.render !== "function") {
|
||||
console.warn("Mermaid not available, skipping render");
|
||||
return;
|
||||
}
|
||||
|
||||
isRendering = true;
|
||||
|
||||
try {
|
||||
const mermaidElements = document.querySelectorAll(
|
||||
".mermaid[data-mermaid-code]",
|
||||
);
|
||||
|
||||
if (mermaidElements.length === 0) {
|
||||
isRendering = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// 延迟检测主题,确保 DOM 已经更新
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
|
||||
const htmlElement = document.documentElement;
|
||||
const isDark = htmlElement.classList.contains("dark");
|
||||
const theme = isDark ? "dark" : "default";
|
||||
|
||||
// 更新 Mermaid 主题(只需要更新一次)
|
||||
window.mermaid.initialize({
|
||||
startOnLoad: false,
|
||||
theme: theme,
|
||||
themeVariables: {
|
||||
fontFamily: "inherit",
|
||||
fontSize: "16px",
|
||||
// 强制应用主题变量
|
||||
primaryColor: isDark ? "#ffffff" : "#000000",
|
||||
primaryTextColor: isDark ? "#ffffff" : "#000000",
|
||||
primaryBorderColor: isDark ? "#ffffff" : "#000000",
|
||||
lineColor: isDark ? "#ffffff" : "#000000",
|
||||
secondaryColor: isDark ? "#333333" : "#f0f0f0",
|
||||
tertiaryColor: isDark ? "#555555" : "#e0e0e0",
|
||||
},
|
||||
securityLevel: "loose",
|
||||
errorLevel: "warn",
|
||||
logLevel: "error",
|
||||
});
|
||||
|
||||
// 批量渲染所有图表,添加重试机制
|
||||
const renderPromises = Array.from(mermaidElements).map(
|
||||
async (element, index) => {
|
||||
let attempts = 0;
|
||||
const maxAttempts = 3;
|
||||
|
||||
while (attempts < maxAttempts) {
|
||||
try {
|
||||
const code = element.getAttribute("data-mermaid-code");
|
||||
if (!code) {
|
||||
break;
|
||||
}
|
||||
// 渲染图表
|
||||
const { svg } = await window.mermaid.render(
|
||||
`mermaid-${Date.now()}-${index}-${attempts}`,
|
||||
code,
|
||||
);
|
||||
const parser = new DOMParser();
|
||||
const doc = parser.parseFromString(
|
||||
svg,
|
||||
"image/svg+xml",
|
||||
);
|
||||
const svgElement = doc.documentElement;
|
||||
element.innerHTML = "";
|
||||
element.__zoomAttached = false;
|
||||
element.appendChild(svgElement);
|
||||
// 添加响应式支持
|
||||
const insertedSvg = element.querySelector("svg");
|
||||
if (insertedSvg) {
|
||||
insertedSvg.setAttribute("width", "100%");
|
||||
insertedSvg.removeAttribute("height");
|
||||
insertedSvg.style.maxWidth = "100%";
|
||||
insertedSvg.style.height = "auto";
|
||||
//Todo 需要根据实际情况
|
||||
insertedSvg.style.minHeight = "300px";
|
||||
// 强制应用样式
|
||||
if (isDark) {
|
||||
svgElement.style.filter = "brightness(0.9) contrast(1.1)";
|
||||
} else {
|
||||
svgElement.style.filter = "none";
|
||||
}
|
||||
attachZoomControls(element, insertedSvg);
|
||||
}
|
||||
// 渲染成功,跳出重试循环
|
||||
break;
|
||||
} catch (error) {
|
||||
attempts++;
|
||||
console.warn(
|
||||
`Mermaid rendering attempt ${attempts} failed for element ${index}:`,
|
||||
error,
|
||||
);
|
||||
if (attempts >= maxAttempts) {
|
||||
console.error(
|
||||
`Failed to render Mermaid diagram after ${maxAttempts} attempts:`,
|
||||
error,
|
||||
);
|
||||
element.innerHTML = `
|
||||
<div class="mermaid-error">
|
||||
<p>Failed to render diagram after ${maxAttempts} attempts.</p>
|
||||
<button onclick="location.reload()" style="margin-top: 8px; padding: 4px 8px; background: var(--primary); color: white; border: none; border-radius: 4px; cursor: pointer;">
|
||||
Retry Page
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
} else {
|
||||
// 等待一段时间后重试
|
||||
await new Promise((resolve) =>
|
||||
setTimeout(resolve, 500 * attempts),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// 等待所有渲染完成
|
||||
await Promise.all(renderPromises);
|
||||
retryCount = 0; // 重置重试计数
|
||||
} catch (error) {
|
||||
console.error("Error in renderMermaidDiagrams:", error);
|
||||
|
||||
// 如果渲染失败,尝试重新渲染
|
||||
if (retryCount < MAX_RETRIES) {
|
||||
retryCount++;
|
||||
setTimeout(() => renderMermaidDiagrams(), RETRY_DELAY * retryCount);
|
||||
}
|
||||
} finally {
|
||||
isRendering = false;
|
||||
}
|
||||
}
|
||||
|
||||
// 初始化主题状态
|
||||
function initializeThemeState() {
|
||||
const isDark = document.documentElement.classList.contains("dark");
|
||||
currentTheme = isDark ? "dark" : "default";
|
||||
}
|
||||
|
||||
// 加载 Mermaid 库
|
||||
async function loadMermaid() {
|
||||
if (typeof window.mermaid !== "undefined") {
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const script = document.createElement("script");
|
||||
script.src =
|
||||
"https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.min.js";
|
||||
|
||||
script.onload = () => {
|
||||
console.log("Mermaid library loaded successfully");
|
||||
resolve();
|
||||
};
|
||||
|
||||
script.onerror = (error) => {
|
||||
console.error("Failed to load Mermaid library:", error);
|
||||
// 尝试备用 CDN
|
||||
const fallbackScript = document.createElement("script");
|
||||
fallbackScript.src = "https://unpkg.com/mermaid@11/dist/mermaid.min.js";
|
||||
|
||||
fallbackScript.onload = () => {
|
||||
console.log("Mermaid library loaded from fallback CDN");
|
||||
resolve();
|
||||
};
|
||||
|
||||
fallbackScript.onerror = () => {
|
||||
reject(
|
||||
new Error(
|
||||
"Failed to load Mermaid from both primary and fallback CDNs",
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
document.head.appendChild(fallbackScript);
|
||||
};
|
||||
|
||||
document.head.appendChild(script);
|
||||
});
|
||||
}
|
||||
|
||||
// 主初始化函数
|
||||
async function initialize() {
|
||||
try {
|
||||
// 首先检查是否有 Mermaid 图表
|
||||
const mermaidElements = document.querySelectorAll(
|
||||
".mermaid[data-mermaid-code]",
|
||||
);
|
||||
if (mermaidElements.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 设置监听器
|
||||
setupMutationObserver();
|
||||
setupEventListeners();
|
||||
|
||||
// 初始化主题状态
|
||||
initializeThemeState();
|
||||
|
||||
// 加载并初始化 Mermaid
|
||||
await loadMermaid();
|
||||
await initializeMermaid();
|
||||
} catch (error) {
|
||||
console.error("Failed to initialize Mermaid system:", error);
|
||||
}
|
||||
}
|
||||
|
||||
// 启动初始化
|
||||
if (document.readyState === "loading") {
|
||||
document.addEventListener("DOMContentLoaded", initialize);
|
||||
} else {
|
||||
initialize();
|
||||
}
|
||||
})();
|
||||
33
src/plugins/rehype-component-admonition.mjs
Normal file
33
src/plugins/rehype-component-admonition.mjs
Normal file
@@ -0,0 +1,33 @@
|
||||
/// <reference types="mdast" />
|
||||
import { h } from "hastscript";
|
||||
|
||||
/**
|
||||
* Creates an admonition component.
|
||||
*
|
||||
* @param {Object} properties - The properties of the component.
|
||||
* @param {string} [properties.title] - An optional title.
|
||||
* @param {('tip'|'note'|'important'|'caution'|'warning')} type - The admonition type.
|
||||
* @param {import('mdast').RootContent[]} children - The children elements of the component.
|
||||
* @returns {import('mdast').Parent} The created admonition component.
|
||||
*/
|
||||
export function AdmonitionComponent(properties, children, type) {
|
||||
if (!Array.isArray(children) || children.length === 0)
|
||||
return h(
|
||||
"div",
|
||||
{ class: "hidden" },
|
||||
'Invalid admonition directive. (Admonition directives must be of block type ":::note{name="name"} <content> :::")',
|
||||
);
|
||||
|
||||
let label = null;
|
||||
if (properties?.["has-directive-label"]) {
|
||||
label = children[0]; // The first child is the label
|
||||
// biome-ignore lint/style/noParameterAssign: <check later>
|
||||
children = children.slice(1);
|
||||
label.tagName = "div"; // Change the tag <p> to <div>
|
||||
}
|
||||
|
||||
return h("blockquote", { class: `admonition bdm-${type}` }, [
|
||||
h("span", { class: "bdm-title" }, label ? label : type.toUpperCase()),
|
||||
...children,
|
||||
]);
|
||||
}
|
||||
95
src/plugins/rehype-component-github-card.mjs
Normal file
95
src/plugins/rehype-component-github-card.mjs
Normal file
@@ -0,0 +1,95 @@
|
||||
/// <reference types="mdast" />
|
||||
import { h } from "hastscript";
|
||||
|
||||
/**
|
||||
* Creates a GitHub Card component.
|
||||
*
|
||||
* @param {Object} properties - The properties of the component.
|
||||
* @param {string} properties.repo - The GitHub repository in the format "owner/repo".
|
||||
* @param {import('mdast').RootContent[]} children - The children elements of the component.
|
||||
* @returns {import('mdast').Parent} The created GitHub Card component.
|
||||
*/
|
||||
export function GithubCardComponent(properties, children) {
|
||||
if (Array.isArray(children) && children.length !== 0)
|
||||
return h("div", { class: "hidden" }, [
|
||||
'Invalid directive. ("github" directive must be leaf type "::github{repo="owner/repo"}")',
|
||||
]);
|
||||
|
||||
if (!properties.repo || !properties.repo.includes("/"))
|
||||
return h(
|
||||
"div",
|
||||
{ class: "hidden" },
|
||||
'Invalid repository. ("repo" attributte must be in the format "owner/repo")',
|
||||
);
|
||||
|
||||
const repo = properties.repo;
|
||||
const cardUuid = `GC${Math.random().toString(36).slice(-6)}`; // Collisions are not important
|
||||
|
||||
const nAvatar = h(`div#${cardUuid}-avatar`, { class: "gc-avatar" });
|
||||
const nLanguage = h(
|
||||
`span#${cardUuid}-language`,
|
||||
{ class: "gc-language" },
|
||||
"Waiting...",
|
||||
);
|
||||
|
||||
const nTitle = h("div", { class: "gc-titlebar" }, [
|
||||
h("div", { class: "gc-titlebar-left" }, [
|
||||
h("div", { class: "gc-owner" }, [
|
||||
nAvatar,
|
||||
h("div", { class: "gc-user" }, repo.split("/")[0]),
|
||||
]),
|
||||
h("div", { class: "gc-divider" }, "/"),
|
||||
h("div", { class: "gc-repo" }, repo.split("/")[1]),
|
||||
]),
|
||||
h("div", { class: "github-logo" }),
|
||||
]);
|
||||
|
||||
const nDescription = h(
|
||||
`div#${cardUuid}-description`,
|
||||
{ class: "gc-description" },
|
||||
"Waiting for api.github.com...",
|
||||
);
|
||||
|
||||
const nStars = h(`div#${cardUuid}-stars`, { class: "gc-stars" }, "00K");
|
||||
const nForks = h(`div#${cardUuid}-forks`, { class: "gc-forks" }, "0K");
|
||||
const nLicense = h(`div#${cardUuid}-license`, { class: "gc-license" }, "0K");
|
||||
|
||||
const nScript = h(
|
||||
`script#${cardUuid}-script`,
|
||||
{ type: "text/javascript", defer: true },
|
||||
`
|
||||
fetch('https://api.github.com/repos/${repo}', { referrerPolicy: "no-referrer" }).then(response => response.json()).then(data => {
|
||||
document.getElementById('${cardUuid}-description').innerText = data.description?.replace(/:[a-zA-Z0-9_]+:/g, '') || "Description not set";
|
||||
document.getElementById('${cardUuid}-language').innerText = data.language;
|
||||
document.getElementById('${cardUuid}-forks').innerText = Intl.NumberFormat('en-us', { notation: "compact", maximumFractionDigits: 1 }).format(data.forks).replaceAll("\u202f", '');
|
||||
document.getElementById('${cardUuid}-stars').innerText = Intl.NumberFormat('en-us', { notation: "compact", maximumFractionDigits: 1 }).format(data.stargazers_count).replaceAll("\u202f", '');
|
||||
const avatarEl = document.getElementById('${cardUuid}-avatar');
|
||||
avatarEl.style.backgroundImage = 'url(' + data.owner.avatar_url + ')';
|
||||
avatarEl.style.backgroundColor = 'transparent';
|
||||
document.getElementById('${cardUuid}-license').innerText = data.license?.spdx_id || "no-license";
|
||||
document.getElementById('${cardUuid}-card').classList.remove("fetch-waiting");
|
||||
console.log("[GITHUB-CARD] Loaded card for ${repo} | ${cardUuid}.")
|
||||
}).catch(err => {
|
||||
const c = document.getElementById('${cardUuid}-card');
|
||||
c?.classList.add("fetch-error");
|
||||
console.warn("[GITHUB-CARD] (Error) Loading card for ${repo} | ${cardUuid}.")
|
||||
})
|
||||
`,
|
||||
);
|
||||
|
||||
return h(
|
||||
`a#${cardUuid}-card`,
|
||||
{
|
||||
class: "card-github fetch-waiting no-styling",
|
||||
href: `https://github.com/${repo}`,
|
||||
target: "_blank",
|
||||
repo,
|
||||
},
|
||||
[
|
||||
nTitle,
|
||||
nDescription,
|
||||
h("div", { class: "gc-infobar" }, [nStars, nForks, nLicense, nLanguage]),
|
||||
nScript,
|
||||
],
|
||||
);
|
||||
}
|
||||
53
src/plugins/rehype-mermaid.mjs
Normal file
53
src/plugins/rehype-mermaid.mjs
Normal file
@@ -0,0 +1,53 @@
|
||||
import { h } from "hastscript";
|
||||
import { visit } from "unist-util-visit";
|
||||
import mermaidRenderScript from "./mermaid-render-script.js?raw";
|
||||
|
||||
|
||||
export function rehypeMermaid() {
|
||||
return (tree) => {
|
||||
visit(tree, "element", (node) => {
|
||||
if (
|
||||
node.tagName === "div" &&
|
||||
node.properties &&
|
||||
node.properties.className &&
|
||||
node.properties.className.includes("mermaid-container")
|
||||
) {
|
||||
const mermaidCode = node.properties["data-mermaid-code"] || "";
|
||||
const mermaidId = `mermaid-${Math.random().toString(36).slice(-6)}`;
|
||||
|
||||
// 创建 Mermaid 容器
|
||||
const mermaidContainer = h(
|
||||
"div",
|
||||
{
|
||||
class: "mermaid-wrapper",
|
||||
id: mermaidId,
|
||||
},
|
||||
[
|
||||
h(
|
||||
"div",
|
||||
{
|
||||
class: "mermaid",
|
||||
"data-mermaid-code": mermaidCode,
|
||||
},
|
||||
mermaidCode,
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
// 创建客户端渲染脚本
|
||||
const renderScript = h(
|
||||
"script",
|
||||
{
|
||||
type: "text/javascript",
|
||||
},
|
||||
mermaidRenderScript,
|
||||
);
|
||||
|
||||
// 替换原始节点
|
||||
node.tagName = "div";
|
||||
node.properties = { class: "mermaid-diagram-container" };
|
||||
node.children = [mermaidContainer, renderScript];
|
||||
}
|
||||
});
|
||||
};
|
||||
}
|
||||
31
src/plugins/remark-directive-rehype.js
Normal file
31
src/plugins/remark-directive-rehype.js
Normal file
@@ -0,0 +1,31 @@
|
||||
import { h } from "hastscript";
|
||||
import { visit } from "unist-util-visit";
|
||||
|
||||
|
||||
export function parseDirectiveNode() {
|
||||
return (tree, { _data }) => {
|
||||
visit(tree, (node) => {
|
||||
if (
|
||||
node.type === "containerDirective" ||
|
||||
node.type === "leafDirective" ||
|
||||
node.type === "textDirective"
|
||||
) {
|
||||
// biome-ignore lint/suspicious/noAssignInExpressions: <check later>
|
||||
const data = node.data || (node.data = {});
|
||||
node.attributes = node.attributes || {};
|
||||
if (
|
||||
node.children.length > 0 &&
|
||||
node.children[0].data &&
|
||||
node.children[0].data.directiveLabel
|
||||
) {
|
||||
// Add a flag to the node to indicate that it has a directive label
|
||||
node.attributes["has-directive-label"] = true;
|
||||
}
|
||||
const hast = h(node.name, node.attributes);
|
||||
|
||||
data.hName = hast.tagName;
|
||||
data.hProperties = hast.properties;
|
||||
}
|
||||
});
|
||||
};
|
||||
}
|
||||
18
src/plugins/remark-excerpt.js
Normal file
18
src/plugins/remark-excerpt.js
Normal file
@@ -0,0 +1,18 @@
|
||||
// biome-ignore lint/suspicious/noShadowRestrictedNames: <toString from mdast-util-to-string>
|
||||
import { toString } from "mdast-util-to-string";
|
||||
|
||||
|
||||
/* Use the post's first paragraph as the excerpt */
|
||||
export function remarkExcerpt() {
|
||||
return (tree, { data }) => {
|
||||
let excerpt = "";
|
||||
for (const node of tree.children) {
|
||||
if (node.type !== "paragraph") {
|
||||
continue;
|
||||
}
|
||||
excerpt = toString(node);
|
||||
break;
|
||||
}
|
||||
data.astro.frontmatter.excerpt = excerpt;
|
||||
};
|
||||
}
|
||||
20
src/plugins/remark-mermaid.js
Normal file
20
src/plugins/remark-mermaid.js
Normal file
@@ -0,0 +1,20 @@
|
||||
import { visit } from "unist-util-visit";
|
||||
|
||||
|
||||
export function remarkMermaid() {
|
||||
return (tree) => {
|
||||
visit(tree, "code", (node) => {
|
||||
if (node.lang === "mermaid") {
|
||||
// 将 mermaid 代码块转换为自定义节点类型
|
||||
node.type = "mermaid";
|
||||
node.data = {
|
||||
hName: "div",
|
||||
hProperties: {
|
||||
className: ["mermaid-container"],
|
||||
"data-mermaid-code": node.value,
|
||||
},
|
||||
};
|
||||
}
|
||||
});
|
||||
};
|
||||
}
|
||||
16
src/plugins/remark-reading-time.mjs
Normal file
16
src/plugins/remark-reading-time.mjs
Normal file
@@ -0,0 +1,16 @@
|
||||
// biome-ignore lint/suspicious/noShadowRestrictedNames: <toString from mdast-util-to-string>
|
||||
import { toString } from "mdast-util-to-string";
|
||||
import getReadingTime from "reading-time";
|
||||
|
||||
|
||||
export function remarkReadingTime() {
|
||||
return (tree, { data }) => {
|
||||
const textOnPage = toString(tree);
|
||||
const readingTime = getReadingTime(textOnPage);
|
||||
data.astro.frontmatter.minutes = Math.max(
|
||||
1,
|
||||
Math.round(readingTime.minutes),
|
||||
);
|
||||
data.astro.frontmatter.words = readingTime.words;
|
||||
};
|
||||
}
|
||||
12365
src/plugins/translate.js
Normal file
12365
src/plugins/translate.js
Normal file
File diff suppressed because it is too large
Load Diff
350
src/plugins/twikoo-scroll-protection.js
Normal file
350
src/plugins/twikoo-scroll-protection.js
Normal file
@@ -0,0 +1,350 @@
|
||||
/**
|
||||
* 强力滚动保护脚本
|
||||
* 通过劫持 window.scrollTo 和相关滚动方法来阻止意外的滚动跳转
|
||||
* 专门解决 Twikoo 评论系统的滚动问题
|
||||
*/
|
||||
|
||||
(() => {
|
||||
// 保存原始的滚动方法
|
||||
const originalScrollTo = window.scrollTo;
|
||||
const originalScrollBy = window.scrollBy;
|
||||
const originalScrollIntoView = Element.prototype.scrollIntoView;
|
||||
|
||||
// 滚动保护状态
|
||||
const scrollProtection = {
|
||||
enabled: false,
|
||||
allowedY: null,
|
||||
startTime: 0,
|
||||
duration: 0,
|
||||
timeout: null,
|
||||
};
|
||||
|
||||
// 检测是否为TOC导航触发的滚动
|
||||
function checkIsTOCNavigation() {
|
||||
// 检查调用堆栈,看是否来自TOC组件
|
||||
const stack = new Error().stack;
|
||||
if (stack && (stack.includes('handleAnchorClick') || stack.includes('TOC.astro'))) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// 检查最近是否有TOC点击事件
|
||||
if (window.tocClickTimestamp && Date.now() - window.tocClickTimestamp < 1000) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// 检查是否在TOC元素上
|
||||
const activeElement = document.activeElement;
|
||||
if (activeElement && activeElement.closest('#toc, .table-of-contents')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
// 启动滚动保护
|
||||
function enableScrollProtection(duration = 3000, currentY = null) {
|
||||
scrollProtection.enabled = true;
|
||||
scrollProtection.allowedY =
|
||||
currentY !== null ? currentY : window.scrollY || window.pageYOffset;
|
||||
scrollProtection.startTime = Date.now();
|
||||
scrollProtection.duration = duration;
|
||||
|
||||
// 清除之前的定时器
|
||||
if (scrollProtection.timeout) {
|
||||
clearTimeout(scrollProtection.timeout);
|
||||
}
|
||||
|
||||
// 设置保护结束时间
|
||||
scrollProtection.timeout = setTimeout(() => {
|
||||
scrollProtection.enabled = false;
|
||||
console.log("[强力滚动保护] 保护期结束");
|
||||
}, duration);
|
||||
|
||||
console.log(
|
||||
`[强力滚动保护] 启动保护 ${duration}ms,允许Y位置:`,
|
||||
scrollProtection.allowedY,
|
||||
);
|
||||
}
|
||||
|
||||
// 检查滚动是否被允许
|
||||
function isScrollAllowed(x, y) {
|
||||
if (!scrollProtection.enabled) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// 检查是否是TOC或MD导航触发的滚动
|
||||
const isTOCNavigation = checkIsTOCNavigation();
|
||||
if (isTOCNavigation) {
|
||||
console.log('[强力滚动保护] 检测到TOC导航,允许滚动');
|
||||
return true;
|
||||
}
|
||||
|
||||
// 允许小幅度的滚动调整(±50像素)
|
||||
const tolerance = 50;
|
||||
const allowedY = scrollProtection.allowedY;
|
||||
|
||||
if (Math.abs(y - allowedY) <= tolerance) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// 如果尝试滚动到顶部(y < 100)而当前位置在更下方,阻止
|
||||
if (y < 100 && allowedY > 100) {
|
||||
console.log(
|
||||
"[强力滚动保护] 阻止滚动到顶部,目标Y:",
|
||||
y,
|
||||
"允许Y:",
|
||||
allowedY,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// 劫持 window.scrollTo
|
||||
window.scrollTo = (x, y) => {
|
||||
// 处理参数为对象的情况
|
||||
if (typeof x === "object") {
|
||||
const options = x;
|
||||
x = options.left || 0;
|
||||
y = options.top || 0;
|
||||
}
|
||||
|
||||
if (isScrollAllowed(x, y)) {
|
||||
originalScrollTo.call(window, x, y);
|
||||
} else {
|
||||
console.log("[强力滚动保护] 阻止 scrollTo:", x, y);
|
||||
// 如果被阻止,滚动到允许的位置
|
||||
originalScrollTo.call(window, x, scrollProtection.allowedY);
|
||||
}
|
||||
};
|
||||
|
||||
// 劫持 window.scrollBy
|
||||
window.scrollBy = (x, y) => {
|
||||
const currentY = window.scrollY || window.pageYOffset;
|
||||
const targetY = currentY + y;
|
||||
|
||||
if (typeof x === "object") {
|
||||
const options = x;
|
||||
x = options.left || 0;
|
||||
y = options.top || 0;
|
||||
}
|
||||
|
||||
if (isScrollAllowed(x, targetY)) {
|
||||
originalScrollBy.call(window, x, y);
|
||||
} else {
|
||||
console.log("[强力滚动保护] 阻止 scrollBy:", x, y);
|
||||
}
|
||||
};
|
||||
|
||||
// 劫持 Element.scrollIntoView
|
||||
Element.prototype.scrollIntoView = function (options) {
|
||||
if (!scrollProtection.enabled) {
|
||||
originalScrollIntoView.call(this, options);
|
||||
return;
|
||||
}
|
||||
|
||||
// 在保护期内,尝试阻止 scrollIntoView
|
||||
const rect = this.getBoundingClientRect();
|
||||
const currentY = window.scrollY || window.pageYOffset;
|
||||
const targetY = currentY + rect.top;
|
||||
|
||||
if (isScrollAllowed(0, targetY)) {
|
||||
originalScrollIntoView.call(this, options);
|
||||
} else {
|
||||
console.log("[强力滚动保护] 阻止 scrollIntoView");
|
||||
}
|
||||
};
|
||||
|
||||
// 监听 Twikoo 相关的交互事件
|
||||
document.addEventListener(
|
||||
"click",
|
||||
(event) => {
|
||||
const target = event.target;
|
||||
|
||||
// 检查是否点击了TOC导航
|
||||
if (target.closest('#toc, .table-of-contents') && target.closest('a[href^="#"]')) {
|
||||
window.tocClickTimestamp = Date.now();
|
||||
console.log('[强力滚动保护] 检测到TOC导航点击');
|
||||
return; // 不启动保护,允许TOC正常工作
|
||||
}
|
||||
|
||||
// 检查是否点击了 Twikoo 相关元素
|
||||
if (
|
||||
target.closest("#tcomment") ||
|
||||
target.matches(
|
||||
".tk-action-icon, .tk-submit, .tk-cancel, .tk-preview, .tk-owo, .tk-admin, .tk-edit, .tk-delete, .tk-reply, .tk-expand",
|
||||
) ||
|
||||
target.closest(
|
||||
".tk-action-icon, .tk-submit, .tk-cancel, .tk-preview, .tk-owo, .tk-admin, .tk-edit, .tk-delete, .tk-reply, .tk-expand",
|
||||
)
|
||||
) {
|
||||
// 立即启动保护
|
||||
enableScrollProtection(4000); // 增加保护时间到4秒
|
||||
console.log("[强力滚动保护] 检测到 Twikoo 交互,启动保护");
|
||||
}
|
||||
|
||||
// 特别检查管理面板相关操作(包括关闭操作)
|
||||
if (
|
||||
target.matches(
|
||||
".tk-admin-panel, .tk-admin-overlay, .tk-modal, .tk-dialog, .tk-admin-close, .tk-close",
|
||||
) ||
|
||||
target.closest(
|
||||
".tk-admin-panel, .tk-admin-overlay, .tk-modal, .tk-dialog, .tk-admin-close, .tk-close",
|
||||
) ||
|
||||
target.classList.contains("tk-admin") ||
|
||||
target.closest(".tk-admin")
|
||||
) {
|
||||
enableScrollProtection(6000); // 管理面板操作保护更长时间
|
||||
console.log("[强力滚动保护] 检测到 Twikoo 管理面板操作,启动长期保护");
|
||||
}
|
||||
|
||||
// 检查是否点击了遮罩层(通常用于关闭模态框)
|
||||
if (
|
||||
target.classList.contains("tk-overlay") ||
|
||||
target.classList.contains("tk-mask") ||
|
||||
target.matches('[class*="overlay"]') ||
|
||||
target.matches('[class*="mask"]') ||
|
||||
target.matches('[class*="backdrop"]')
|
||||
) {
|
||||
// 检查是否在 Twikoo 区域内
|
||||
const tcommentEl = document.querySelector("#tcomment");
|
||||
if (
|
||||
tcommentEl &&
|
||||
(target.closest("#tcomment") || tcommentEl.contains(target))
|
||||
) {
|
||||
enableScrollProtection(4000);
|
||||
console.log("[强力滚动保护] 检测到 Twikoo 遮罩层点击,启动保护");
|
||||
}
|
||||
}
|
||||
},
|
||||
true,
|
||||
); // 使用捕获阶段
|
||||
|
||||
// 监听表单提交
|
||||
document.addEventListener(
|
||||
"submit",
|
||||
(event) => {
|
||||
if (event.target.closest("#tcomment")) {
|
||||
enableScrollProtection(4000);
|
||||
console.log("[强力滚动保护] 检测到 Twikoo 表单提交,启动保护");
|
||||
}
|
||||
},
|
||||
true,
|
||||
);
|
||||
|
||||
// 监听键盘事件(特别是 ESC 键,用于关闭管理面板)
|
||||
document.addEventListener(
|
||||
"keydown",
|
||||
(event) => {
|
||||
if (event.key === "Escape" || event.keyCode === 27) {
|
||||
// 检查是否在 Twikoo 区域内有活动的管理面板
|
||||
const tcommentEl = document.querySelector("#tcomment");
|
||||
if (tcommentEl) {
|
||||
// 检查是否有可见的管理面板或模态框
|
||||
const adminPanel = tcommentEl.querySelector(
|
||||
".tk-admin-panel, .tk-modal, .tk-dialog, [class*='admin'], [class*='modal']",
|
||||
);
|
||||
if (adminPanel && adminPanel.offsetParent !== null) {
|
||||
// 面板可见,启动保护
|
||||
enableScrollProtection(3000);
|
||||
console.log(
|
||||
"[强力滚动保护] 检测到 ESC 键关闭 Twikoo 管理面板,启动保护",
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
true,
|
||||
);
|
||||
|
||||
// 监听 DOM 变化,检测管理面板的关闭
|
||||
const observer = new MutationObserver((mutations) => {
|
||||
mutations.forEach((mutation) => {
|
||||
if (mutation.type === "childList" || mutation.type === "attributes") {
|
||||
const target = mutation.target;
|
||||
|
||||
// 检查是否是 Twikoo 相关的 DOM 变化
|
||||
if (target.closest && target.closest("#tcomment")) {
|
||||
// 检查是否有元素被移除或隐藏(可能是面板关闭)
|
||||
if (
|
||||
mutation.removedNodes.length > 0 ||
|
||||
(mutation.type === "attributes" &&
|
||||
mutation.attributeName === "style")
|
||||
) {
|
||||
enableScrollProtection(2000);
|
||||
console.log(
|
||||
"[强力滚动保护] 检测到 Twikoo DOM 变化(可能是面板关闭),启动保护",
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// 开始监听 DOM 变化
|
||||
if (document.body) {
|
||||
observer.observe(document.body, {
|
||||
childList: true,
|
||||
subtree: true,
|
||||
attributes: true,
|
||||
attributeFilter: ["style", "class"],
|
||||
});
|
||||
} else {
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
observer.observe(document.body, {
|
||||
childList: true,
|
||||
subtree: true,
|
||||
attributes: true,
|
||||
attributeFilter: ["style", "class"],
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// 提供全局接口
|
||||
window.scrollProtectionManager = {
|
||||
enable: enableScrollProtection,
|
||||
disable: () => {
|
||||
scrollProtection.enabled = false;
|
||||
if (scrollProtection.timeout) {
|
||||
clearTimeout(scrollProtection.timeout);
|
||||
}
|
||||
console.log("[强力滚动保护] 手动停止保护");
|
||||
},
|
||||
isEnabled: () => scrollProtection.enabled,
|
||||
getStatus: () => ({ ...scrollProtection }),
|
||||
// 新增:强制保护模式(用于调试)
|
||||
forceProtect: (duration = 10000) => {
|
||||
enableScrollProtection(duration);
|
||||
console.log(`[强力滚动保护] 强制保护模式启动 ${duration}ms`);
|
||||
},
|
||||
// 新增:获取当前滚动位置
|
||||
getCurrentScroll: () => {
|
||||
return {
|
||||
x: window.scrollX || window.pageXOffset,
|
||||
y: window.scrollY || window.pageYOffset,
|
||||
};
|
||||
},
|
||||
// 新增:检测 Twikoo 状态
|
||||
checkTwikooStatus: () => {
|
||||
const tcomment = document.querySelector("#tcomment");
|
||||
if (!tcomment) return { exists: false };
|
||||
|
||||
const adminPanels = tcomment.querySelectorAll(
|
||||
".tk-admin-panel, .tk-modal, .tk-dialog, [class*='admin'], [class*='modal']",
|
||||
);
|
||||
const visiblePanels = Array.from(adminPanels).filter(
|
||||
(panel) => panel.offsetParent !== null,
|
||||
);
|
||||
|
||||
return {
|
||||
exists: true,
|
||||
adminPanelsCount: adminPanels.length,
|
||||
visiblePanelsCount: visiblePanels.length,
|
||||
hasVisiblePanels: visiblePanels.length > 0,
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
console.log("[强力滚动保护] 初始化完成");
|
||||
})();
|
||||
109
src/plugins/umami-share.js
Normal file
109
src/plugins/umami-share.js
Normal file
@@ -0,0 +1,109 @@
|
||||
(function (global) {
|
||||
const cacheKey = 'umami-share-cache';
|
||||
const cacheTTL = 3600_000; // 1h
|
||||
|
||||
/**
|
||||
* 获取网站统计数据
|
||||
* @param {string} baseUrl - Umami Cloud API基础URL
|
||||
* @param {string} apiKey - API密钥
|
||||
* @param {string} websiteId - 网站ID
|
||||
* @returns {Promise<object>} 网站统计数据
|
||||
*/
|
||||
async function fetchWebsiteStats(baseUrl, apiKey, websiteId) {
|
||||
// 检查缓存
|
||||
const cached = localStorage.getItem(cacheKey);
|
||||
if (cached) {
|
||||
try {
|
||||
const parsed = JSON.parse(cached);
|
||||
if (Date.now() - parsed.timestamp < cacheTTL) {
|
||||
return parsed.value;
|
||||
}
|
||||
} catch {
|
||||
localStorage.removeItem(cacheKey);
|
||||
}
|
||||
}
|
||||
|
||||
const currentTimestamp = Date.now();
|
||||
const statsUrl = `${baseUrl}/v1/websites/${websiteId}/stats?startAt=0&endAt=${currentTimestamp}`;
|
||||
|
||||
const res = await fetch(statsUrl, {
|
||||
headers: {
|
||||
'x-umami-api-key': apiKey
|
||||
}
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error('获取网站统计数据失败');
|
||||
}
|
||||
|
||||
const stats = await res.json();
|
||||
|
||||
// 缓存结果
|
||||
localStorage.setItem(cacheKey, JSON.stringify({ timestamp: Date.now(), value: stats }));
|
||||
|
||||
return stats;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取特定页面的统计数据
|
||||
* @param {string} baseUrl - Umami Cloud API基础URL
|
||||
* @param {string} apiKey - API密钥
|
||||
* @param {string} websiteId - 网站ID
|
||||
* @param {string} urlPath - 页面路径
|
||||
* @param {number} startAt - 开始时间戳
|
||||
* @param {number} endAt - 结束时间戳
|
||||
* @returns {Promise<object>} 页面统计数据
|
||||
*/
|
||||
async function fetchPageStats(baseUrl, apiKey, websiteId, urlPath, startAt = 0, endAt = Date.now()) {
|
||||
const statsUrl = `${baseUrl}/v1/websites/${websiteId}/stats?startAt=${startAt}&endAt=${endAt}&url=${encodeURIComponent(urlPath)}`;
|
||||
|
||||
const res = await fetch(statsUrl, {
|
||||
headers: {
|
||||
'x-umami-api-key': apiKey
|
||||
}
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error('获取页面统计数据失败');
|
||||
}
|
||||
|
||||
return await res.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 Umami 网站统计数据
|
||||
* @param {string} baseUrl - Umami Cloud API基础URL
|
||||
* @param {string} apiKey - API密钥
|
||||
* @param {string} websiteId - 网站ID
|
||||
* @returns {Promise<object>} 网站统计数据
|
||||
*/
|
||||
global.getUmamiWebsiteStats = async function (baseUrl, apiKey, websiteId) {
|
||||
try {
|
||||
return await fetchWebsiteStats(baseUrl, apiKey, websiteId);
|
||||
} catch (err) {
|
||||
throw new Error(`获取Umami统计数据失败: ${err.message}`);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取特定页面的 Umami 统计数据
|
||||
* @param {string} baseUrl - Umami Cloud API基础URL
|
||||
* @param {string} apiKey - API密钥
|
||||
* @param {string} websiteId - 网站ID
|
||||
* @param {string} urlPath - 页面路径
|
||||
* @param {number} startAt - 开始时间戳(可选)
|
||||
* @param {number} endAt - 结束时间戳(可选)
|
||||
* @returns {Promise<object>} 页面统计数据
|
||||
*/
|
||||
global.getUmamiPageStats = async function (baseUrl, apiKey, websiteId, urlPath, startAt, endAt) {
|
||||
try {
|
||||
return await fetchPageStats(baseUrl, apiKey, websiteId, urlPath, startAt, endAt);
|
||||
} catch (err) {
|
||||
throw new Error(`获取Umami页面统计数据失败: ${err.message}`);
|
||||
}
|
||||
};
|
||||
|
||||
global.clearUmamiShareCache = function () {
|
||||
localStorage.removeItem(cacheKey);
|
||||
};
|
||||
})(window);
|
||||
Reference in New Issue
Block a user