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:
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user