1
0
mirror of https://github.com/sendevia/website.git synced 2026-03-05 23:32:45 +08:00

feat(PageIndicator): enhance indicator styles and improve accessibility

This commit is contained in:
2026-01-14 00:48:34 +08:00
parent 53b7479bea
commit 8df0464354
2 changed files with 166 additions and 261 deletions

View File

@@ -1,281 +1,180 @@
<script setup lang="ts">
import { ref, computed, onMounted, onBeforeUnmount, nextTick, watch } from "vue";
import { useClipboard } from "@vueuse/core";
import { ref, computed, onMounted, nextTick, watch } from "vue";
import { useClipboard, useIntersectionObserver, useResizeObserver, useEventListener, useTimeoutFn } from "@vueuse/core";
import { useGlobalData } from "../composables/useGlobalData";
import { usePostStore } from "../stores/posts";
import { useScreenWidthStore } from "../stores/screenWidth";
import { isClient } from "../utils/env";
/** 全局数据与状态 */
const { page, frontmatter } = useGlobalData();
const { copy: copyToClipboard, copied: isCopied } = useClipboard();
const screenWidthStore = useScreenWidthStore();
const pageIndicator = ref<HTMLElement | null>(null);
const indicator = ref({ top: "0px", left: "0px", width: "100%", height: "0px", opacity: 0 });
const headings = ref<Array<{ id: string; text: string; level: number }>>([]);
const headingsActiveId = ref<string>("");
const postStore = usePostStore();
let ro: ResizeObserver | null = null;
let mo: MutationObserver | null = null;
let pageIndicatorObserver: IntersectionObserver | null = null;
let pageIndicatorLockedId: string | null = null;
let pageIndicatorUnlockTimer: number | null = null;
/** 响应式引用 */
const pageIndicator = ref<HTMLElement | null>(null);
const headings = ref<Array<{ id: string; text: string; level: number }>>([]);
const headingsActiveId = ref<string>("");
const indicator = ref({ top: "0px", left: "0px", width: "100%", height: "0px", opacity: 0 });
const grouped = computed(() => headings.value || []);
/** 点击导航时临时禁用滚动监听更新 */
const isLocked = ref(false);
const { start: lockTimer } = useTimeoutFn(
() => {
isLocked.value = false;
},
1200,
{ immediate: false }
);
function scrollToId(id: string) {
const el = document.getElementById(id);
if (el) {
el.scrollIntoView({ behavior: "smooth", block: "start" });
history.replaceState(null, "", `#${id}`);
}
}
function navigateTo(id: string) {
onNavigate(id);
scrollToId(id);
}
function onNavigate(id: string) {
pageIndicatorLockedId = id;
headingsActiveId.value = id;
if (pageIndicatorUnlockTimer) {
window.clearTimeout(pageIndicatorUnlockTimer);
}
pageIndicatorUnlockTimer = window.setTimeout(() => {
pageIndicatorLockedId = null;
pageIndicatorUnlockTimer = null;
}, 1200);
}
function collectHeadings() {
const nodes = Array.from(document.querySelectorAll("h1[id], h2[id]")) as HTMLElement[];
headings.value = nodes.map((n) => ({ id: n.id, text: n.textContent?.trim() || n.id, level: +n.tagName.replace("H", "") }));
}
function createObserver() {
if (pageIndicatorObserver) pageIndicatorObserver.disconnect();
const visible = new Map<string, IntersectionObserverEntry>();
pageIndicatorObserver = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
const id = (entry.target as HTMLElement).id;
if (entry.isIntersecting) {
visible.set(id, entry);
} else {
visible.delete(id);
}
});
if (visible.size === 0) return;
if (pageIndicatorLockedId) {
headingsActiveId.value = pageIndicatorLockedId;
return;
}
let bestId: string | null = null;
let bestScore = -Infinity;
visible.forEach((entry, id) => {
const ratio = entry.intersectionRatio || 0;
const top = entry.boundingClientRect.top;
const score = ratio * 10000 - top;
if (score > bestScore) {
bestScore = score;
bestId = id;
}
});
if (bestId) headingsActiveId.value = bestId;
},
{ root: null, rootMargin: "-20% 0px -60% 0px", threshold: [0, 0.1, 0.5, 1] }
);
headings.value.forEach((h) => {
const el = document.getElementById(h.id);
if (el) pageIndicatorObserver?.observe(el);
});
}
function updateIndicator() {
const nav = pageIndicator.value;
if (!nav) return;
const id = headingsActiveId.value;
if (!id) {
indicator.value.opacity = 0;
return;
}
const spanBlock = nav.querySelector(`span[data-id="${CSS.escape(id)}"]`) as HTMLElement | null;
if (!spanBlock || !spanBlock.offsetParent) {
indicator.value.opacity = 0;
return;
}
const conRect = nav.getBoundingClientRect();
const spbRect = spanBlock.getBoundingClientRect();
const left = `${spbRect.left - conRect.left}px`;
const top = `${spbRect.top - conRect.top}px`;
const width = `${spbRect.width}px`;
const height = `${spbRect.height}px`;
indicator.value = { top, left, width, height, opacity: 0.5 };
}
function toggleMonitoring(shouldMonitor: boolean) {
if (shouldMonitor) {
collectHeadings();
createObserver();
nextTick(() => updateIndicator());
if ((window as any).ResizeObserver && pageIndicator.value) {
ro = new ResizeObserver(() => updateIndicator());
ro.observe(pageIndicator.value);
pageIndicator.value.querySelectorAll("[data-id]").forEach((el) => ro!.observe(el as Element));
}
if ((window as any).MutationObserver && pageIndicator.value) {
mo = new MutationObserver(() => {
nextTick(() => {
updateIndicator();
if (ro && pageIndicator.value) {
pageIndicator.value.querySelectorAll("[data-id]").forEach((el) => ro!.observe(el as Element));
}
});
});
mo.observe(pageIndicator.value, { childList: true, subtree: true });
}
} else {
pageIndicatorObserver?.disconnect();
pageIndicatorObserver = null;
if (ro) {
ro.disconnect();
ro = null;
}
if (mo) {
mo.disconnect();
mo = null;
}
indicator.value.opacity = 0;
}
}
const resizeHandler = () => {
if (screenWidthStore.isAboveBreakpoint) {
collectHeadings();
createObserver();
}
};
// 计算文章ID
/** 计算文章 ID 与短链 */
const articleId = computed(() => {
const relativePath = page.value?.relativePath;
if (!relativePath) return "";
const path = relativePath.replace(/\.md$/, "");
const path = page.value?.relativePath?.replace(/\.md$/, "");
if (!path) return "";
const lookupUrl = path.startsWith("/") ? path : `/${path}`;
const post = postStore.getPostByUrl(lookupUrl);
return post?.id || "";
return postStore.getPostByUrl(lookupUrl)?.id || "";
});
const shortLink = computed(() => {
if (!articleId.value) return "";
return `/p/${articleId.value}`;
});
const shortLink = computed(() => (articleId.value ? `/p/${articleId.value}` : ""));
/**
* 复制短链到剪贴板
*/
const copyShortLink = async () => {
if (!shortLink.value) return;
await copyToClipboard(`${window.location.origin}${shortLink.value}`);
};
/**
* 收集页面中的 h1 和 h2 标题
*/
const collectHeadings = () => {
if (!isClient()) return;
const nodes = Array.from(document.querySelectorAll("h1[id], h2[id]")) as HTMLElement[];
headings.value = nodes.map((n) => ({
id: n.id,
text: n.textContent?.trim() || n.id,
level: +n.tagName.replace("H", ""),
}));
};
/**
* 更新指示器(高亮块)的位置和尺寸
*/
const updateIndicator = () => {
const container = pageIndicator.value;
const id = headingsActiveId.value;
if (!container || !id) {
indicator.value.opacity = 0;
return;
}
const activeElement = container.querySelector(`span[data-id="${CSS.escape(id)}"]`) as HTMLElement | null;
if (!activeElement || !activeElement.offsetParent) {
indicator.value.opacity = 0;
return;
}
const conRect = container.getBoundingClientRect();
const elRect = activeElement.getBoundingClientRect();
indicator.value = {
top: `${elRect.top - conRect.top}px`,
left: `${elRect.left - conRect.left}px`,
width: `${elRect.width}px`,
height: `${elRect.height}px`,
opacity: 0.5,
};
};
/**
* 导航到指定标题并锁定监听
* @param id 标题 ID
*/
const navigateTo = (id: string) => {
isLocked.value = true;
headingsActiveId.value = id;
lockTimer(); // 启动/重置计时器
const el = document.getElementById(id);
if (el) {
el.scrollIntoView({ behavior: "smooth", block: "start" });
history.replaceState(null, "", `#${id}`);
}
};
/** 多标题可见性观察 */
const visibleMap = new Map<string, number>();
if (isClient()) {
onMounted(() => {
screenWidthStore.init();
// 监听所有标题元素的进入/退出
watch(
headings,
(newHeadings) => {
newHeadings.forEach((h) => {
const el = document.getElementById(h.id);
if (!el) return;
toggleMonitoring(screenWidthStore.isAboveBreakpoint);
useIntersectionObserver(
el,
([entry]) => {
if (entry.isIntersecting) {
// 存储分数:交集比例 * 权重 - 距离顶部距离
const score = entry.intersectionRatio * 10000 - entry.boundingClientRect.top;
visibleMap.set(h.id, score);
} else {
visibleMap.delete(h.id);
}
window.addEventListener("resize", resizeHandler);
window.addEventListener("resize", updateIndicator, { passive: true });
window.addEventListener("hashchange", () => {
if (screenWidthStore.isAboveBreakpoint) {
collectHeadings();
createObserver();
}
});
window.addEventListener("popstate", () => {
if (screenWidthStore.isAboveBreakpoint) {
collectHeadings();
createObserver();
}
});
// 非锁定状态下更新活动 ID
if (!isLocked.value && visibleMap.size > 0) {
const bestId = [...visibleMap.entries()].reduce((a, b) => (a[1] > b[1] ? a : b))[0];
headingsActiveId.value = bestId;
}
},
{ rootMargin: "-20% 0px -60% 0px", threshold: [0, 0.1, 0.5, 1] }
);
});
},
{ immediate: true }
);
nextTick(() => updateIndicator());
/** 监听容器及其子元素尺寸变化 */
useResizeObserver(pageIndicator, updateIndicator);
/** 窗口事件监听 */
useEventListener("resize", updateIndicator);
useEventListener(["hashchange", "popstate"], () => {
if (screenWidthStore.isAboveBreakpoint) collectHeadings();
});
onBeforeUnmount(() => {
pageIndicatorObserver?.disconnect();
pageIndicatorObserver = null;
window.removeEventListener("resize", resizeHandler);
window.removeEventListener("resize", updateIndicator);
window.removeEventListener("hashchange", () => {
collectHeadings();
createObserver();
});
window.removeEventListener("popstate", () => {
collectHeadings();
createObserver();
});
if (pageIndicatorUnlockTimer) {
window.clearTimeout(pageIndicatorUnlockTimer);
pageIndicatorUnlockTimer = null;
}
if (ro) {
ro.disconnect();
ro = null;
}
if (mo) {
mo.disconnect();
mo = null;
}
});
watch(
() => screenWidthStore.isAboveBreakpoint,
(newValue) => {
toggleMonitoring(newValue);
}
);
watch(
() => headingsActiveId.value,
() => {
if (screenWidthStore.isAboveBreakpoint) {
nextTick(() => updateIndicator());
}
}
);
watch(
() => grouped.value,
() => {
if (screenWidthStore.isAboveBreakpoint) {
nextTick(() => updateIndicator());
}
}
);
}
/** 状态同步监听 */
watch(
() => screenWidthStore.isAboveBreakpoint,
(val) => {
if (val) {
collectHeadings();
nextTick(updateIndicator);
} else {
indicator.value.opacity = 0;
}
}
);
watch(headingsActiveId, () => {
if (screenWidthStore.isAboveBreakpoint) nextTick(updateIndicator);
});
onMounted(() => {
if (isClient()) {
screenWidthStore.init();
if (screenWidthStore.isAboveBreakpoint) {
collectHeadings();
nextTick(updateIndicator);
}
}
});
</script>
<template>
@@ -283,13 +182,11 @@ if (isClient()) {
<div class="label">
<p class="text">在此页上</p>
<p class="icon">link</p>
<p class="article-id" title="复制短链" v-if="articleId" @click="copyShortLink">
{{ isCopied ? `已复制` : articleId }}
<p class="article-id" :title="isCopied ? '已复制' : '复制短链'" v-if="articleId" @click="copyShortLink">
{{ isCopied ? "已复制" : articleId }}
</p>
</div>
<h3 class="article-title">{{ frontmatter.title ? frontmatter.title : page.title }}</h3>
<h3 class="article-title">{{ frontmatter.title || page.title }}</h3>
<div
class="indicator"
:style="{
@@ -301,16 +198,15 @@ if (isClient()) {
}"
aria-hidden="true"
></div>
<div class="indicator-container">
<span v-for="h in grouped" :key="h.id" :data-id="h.id" :class="[{ active: h.id === headingsActiveId }]">
<span v-for="h in headings" :key="h.id" :data-id="h.id" :class="{ active: h.id === headingsActiveId }">
<a
:href="`#${h.id}`"
@click.prevent="navigateTo(h.id)"
role="link"
:aria-current="h.id === headingsActiveId ? 'true' : undefined"
>{{ h.text }}</a
>
{{ h.text }}
</a>
</span>
</div>
</div>

View File

@@ -41,11 +41,11 @@
.indicator {
position: absolute;
outline: 1px solid var(--md-sys-color-primary);
outline: 2px solid var(--md-sys-color-primary);
border-radius: var(--md-sys-shape-corner-extra-large);
pointer-events: none;
transition: var(--md-sys-motion-spring-fast-spatial-duration) var(--md-sys-motion-spring-fast-spatial);
transition: var(--md-sys-motion-spring-default-spatial-duration) var(--md-sys-motion-spring-default-spatial);
z-index: 1;
}
@@ -93,13 +93,20 @@
padding-inline: 18px;
color: var(--md-sys-color-on-surface);
font-variation-settings: "wght" 200;
font-variation-settings: "wght" 400;
text-decoration: none;
border-radius: var(--md-sys-shape-corner-extra-large);
opacity: 0.5;
transition: font-variation-settings var(--md-sys-motion-spring-fast-effect-duration)
var(--md-sys-motion-spring-fast-effect-duration),
opacity var(--md-sys-motion-spring-fast-effect-duration) var(--md-sys-motion-spring-fast-effect-duration);
&:focus-visible {
background-color: var(--md-sys-color-surface-container);
opacity: 1;
}
}
@@ -108,6 +115,8 @@
color: var(--md-sys-color-primary);
font-variation-settings: "wght" 700;
opacity: 1;
&:focus-visible {
color: var(--md-sys-color-on-primary);