mirror of
https://github.com/sendevia/website.git
synced 2026-03-08 16:52:34 +08:00
Compare commits
6 Commits
26.1.9(234
...
26.1.13(24
| Author | SHA1 | Date | |
|---|---|---|---|
| b17fbaf443 | |||
| ba03ac1764 | |||
| e1943e94bd | |||
| 304eb88b85 | |||
| 1b74ac5964 | |||
| 651713067c |
@@ -1,55 +1,119 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, onUnmounted, watch, nextTick } from "vue";
|
||||
import { storeToRefs } from "pinia";
|
||||
import { useGlobalScroll } from "../composables/useGlobalScroll";
|
||||
import { storeToRefs } from "pinia";
|
||||
import { usePostStore, type PostData } from "../stores/posts";
|
||||
import { useSearchStateStore } from "../stores/searchState";
|
||||
import { useScreenWidthStore } from "../stores/screenWidth";
|
||||
import { handleTabNavigation } from "../utils/tabNavigation";
|
||||
import { useRoute } from "vitepress";
|
||||
|
||||
const { isScrolled } = useGlobalScroll({ threshold: 100 });
|
||||
|
||||
// 初始化 store 实例
|
||||
const searchStateStore = useSearchStateStore();
|
||||
const screenWidthStore = useScreenWidthStore();
|
||||
const postsStore = usePostStore();
|
||||
|
||||
// 从 posts store 中解构出 posts 响应式数据
|
||||
const { posts } = storeToRefs(postsStore);
|
||||
|
||||
const query = ref("");
|
||||
const appbar = ref<HTMLElement | null>(null);
|
||||
const searchInput = ref<HTMLInputElement | null>(null);
|
||||
const isTabFocusable = computed(() => !screenWidthStore.isAboveBreakpoint);
|
||||
// 初始化路由实例
|
||||
const route = useRoute();
|
||||
|
||||
// 计算过滤后的文章,使用 PostData 类型
|
||||
// DOM 元素引用
|
||||
const scrollTarget = ref<HTMLElement | null>(null); // 滚动容器的 DOM 引用
|
||||
const appbar = ref<HTMLElement | null>(null); // AppBar 自身的 DOM 引用
|
||||
const searchInput = ref<HTMLInputElement | null>(null); // 搜索输入框的 DOM 引用
|
||||
|
||||
// 本地响应式状态
|
||||
const query = ref(""); // 搜索输入框的绑定值
|
||||
const isHidden = ref(false); // 控制 AppBar 是否隐藏的状态
|
||||
|
||||
// 使用 useGlobalScroll
|
||||
const { scrollResult, isScrolled: globalIsScrolled } = useGlobalScroll({ threshold: 100 });
|
||||
const { y, directions } = scrollResult;
|
||||
|
||||
// 计算属性
|
||||
const isScrolled = computed(() => globalIsScrolled.value); // 使用 useGlobalScroll 的 isScrolled
|
||||
const isTabFocusable = computed(() => !screenWidthStore.isAboveBreakpoint); // 判断当前屏幕宽度下,元素是否应该可被 Tab 键聚焦
|
||||
|
||||
// 工具函数:获取滚动容器
|
||||
const getScrollContainer = () => document.querySelector<HTMLElement>(".content-flow");
|
||||
|
||||
/**
|
||||
* 监听滚动位置 y 的变化,控制 AppBar 的显示和隐藏。
|
||||
*/
|
||||
watch(y, (currentY) => {
|
||||
// 隐藏:当检测到向下滚动且滚动距离超过指定像素时
|
||||
if (directions.bottom && currentY > 300) {
|
||||
isHidden.value = true;
|
||||
}
|
||||
// 显示:当检测到向上滚动且当前 AppBar 处于隐藏状态时
|
||||
else if (directions.top && isHidden.value) {
|
||||
isHidden.value = false;
|
||||
}
|
||||
// 额外条件:如果滚动到非常靠近顶部,无论方向如何都确保显示 AppBar
|
||||
if (currentY <= 50) {
|
||||
isHidden.value = false;
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* 计算属性:根据当前搜索查询 `query` 过滤文章列表 `posts`。
|
||||
* 搜索范围包括文章标题、描述、日期、标签和分类。
|
||||
* @returns {PostData[]} 过滤后的文章列表。
|
||||
*/
|
||||
const filteredPosts = computed<PostData[]>(() => {
|
||||
const q = query.value.trim().toLowerCase();
|
||||
if (!q || !posts.value.length) return [];
|
||||
|
||||
return posts.value.filter((post) => {
|
||||
return (
|
||||
post.title.includes(q) ||
|
||||
post.description.includes(q) ||
|
||||
post.date.includes(q) ||
|
||||
post.tags.some((t) => t.includes(q)) ||
|
||||
post.categories.some((t) => t.includes(q))
|
||||
);
|
||||
});
|
||||
return posts.value.filter(
|
||||
(post) =>
|
||||
post.date.toLowerCase().includes(q) ||
|
||||
post.title.toLowerCase().includes(q) ||
|
||||
post.description.toLowerCase().includes(q) ||
|
||||
post.tags.some((t) => t.toLowerCase().includes(q)) ||
|
||||
post.categories.some((t) => t.toLowerCase().includes(q))
|
||||
);
|
||||
});
|
||||
|
||||
// 处理输入框焦点 (仅激活,不处理关闭)
|
||||
/**
|
||||
* 清除搜索状态:清空查询、取消打字状态、停用搜索模式并使输入框失焦。
|
||||
*/
|
||||
const clearSearchState = () => {
|
||||
query.value = "";
|
||||
searchStateStore.setTyping(false);
|
||||
searchStateStore.deactivate();
|
||||
searchInput.value?.blur();
|
||||
};
|
||||
|
||||
/**
|
||||
* 重置 AppBar 状态:隐藏状态和搜索状态。
|
||||
*/
|
||||
const resetAppBarState = () => {
|
||||
isHidden.value = false;
|
||||
clearSearchState();
|
||||
};
|
||||
|
||||
/**
|
||||
* 处理搜索输入框获得焦点事件。
|
||||
* 如果搜索未激活,则激活搜索模式并设置焦点状态。
|
||||
*/
|
||||
const handleFocus = () => {
|
||||
if (!searchStateStore.isSearchActive) {
|
||||
searchStateStore.activate();
|
||||
}
|
||||
if (!searchStateStore.isSearchActive) searchStateStore.activate();
|
||||
searchStateStore.setFocus(true);
|
||||
};
|
||||
|
||||
// 处理输入框失焦 (只改变焦点状态,不关闭搜索,解决双击问题)
|
||||
/**
|
||||
* 处理搜索输入框失去焦点事件。
|
||||
*/
|
||||
const handleBlur = () => {
|
||||
searchStateStore.setFocus(false);
|
||||
};
|
||||
|
||||
// 处理输入
|
||||
/**
|
||||
* 处理搜索输入框的输入事件。
|
||||
* 根据输入内容更新打字状态,如果输入框有内容且搜索未激活,则激活搜索模式。
|
||||
*/
|
||||
const handleInput = () => {
|
||||
const hasContent = query.value.trim().length > 0;
|
||||
searchStateStore.setTyping(hasContent);
|
||||
@@ -58,87 +122,155 @@ const handleInput = () => {
|
||||
}
|
||||
};
|
||||
|
||||
// 清除搜索状态
|
||||
const clearSearchState = () => {
|
||||
query.value = "";
|
||||
searchStateStore.setTyping(false);
|
||||
searchStateStore.deactivate();
|
||||
if (searchInput.value) {
|
||||
searchInput.value.blur();
|
||||
}
|
||||
};
|
||||
|
||||
// 点击搜索结果
|
||||
/**
|
||||
* 处理搜索结果点击事件。
|
||||
* 在短延迟后清除搜索状态,以允许导航发生。
|
||||
*/
|
||||
const handleResultClick = () => {
|
||||
setTimeout(() => {
|
||||
clearSearchState();
|
||||
}, 200);
|
||||
setTimeout(clearSearchState, 200);
|
||||
};
|
||||
|
||||
// 处理外部点击
|
||||
const handleDocumentClick = (event: Event) => {
|
||||
if (!searchStateStore.isSearchActive) return;
|
||||
|
||||
const target = event.target as HTMLElement;
|
||||
const isClickInsideInput = searchInput.value && searchInput.value.contains(target);
|
||||
const isClickInsideResults = target.closest(".searchResult");
|
||||
|
||||
if (!isClickInsideInput && !isClickInsideResults && query.value.trim() === "") {
|
||||
clearSearchState();
|
||||
}
|
||||
};
|
||||
|
||||
// 监听状态自动聚焦
|
||||
/**
|
||||
* 监听搜索激活状态的变化。
|
||||
* 如果搜索被激活,则在下一个 DOM 更新周期后,尝试使搜索输入框获得焦点。
|
||||
* 如果搜索被停用且 `query` 不为空,则清空 `query` 并重置打字状态。
|
||||
*/
|
||||
watch(
|
||||
() => searchStateStore.isSearchActive,
|
||||
async (isActive) => {
|
||||
if (isActive && searchInput.value) {
|
||||
await nextTick();
|
||||
setTimeout(() => {
|
||||
searchInput.value?.focus();
|
||||
}, 100);
|
||||
} else if (!isActive) {
|
||||
if (query.value !== "") {
|
||||
query.value = "";
|
||||
searchStateStore.setTyping(false);
|
||||
}
|
||||
if (isActive) {
|
||||
await nextTick(); // 确保 DOM 已更新
|
||||
setTimeout(() => searchInput.value?.focus(), 100); // 延迟聚焦以防其他 DOM 操作干扰
|
||||
} else if (query.value !== "") {
|
||||
query.value = "";
|
||||
searchStateStore.setTyping(false);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// 键盘事件
|
||||
// 检查是否在搜索激活状态
|
||||
const isSearchActive = computed(() => searchStateStore.isSearchActive);
|
||||
|
||||
/**
|
||||
* 检查点击是否在搜索区域内
|
||||
*/
|
||||
const isClickInsideSearchArea = (target: HTMLElement): boolean => {
|
||||
const isClickInsideInput = searchInput.value?.contains(target);
|
||||
const isClickResultArea = appbar.value?.closest(".result-area");
|
||||
return !!(isClickInsideInput || isClickResultArea);
|
||||
};
|
||||
|
||||
/**
|
||||
* 处理全局文档点击事件。
|
||||
* 如果点击发生在搜索输入框或搜索结果区域之外,且搜索查询为空,则关闭搜索状态。
|
||||
* @param {Event} event 点击事件对象。
|
||||
*/
|
||||
const handleDocumentClick = (event: Event) => {
|
||||
if (!isSearchActive.value) return;
|
||||
|
||||
const target = event.target as HTMLElement;
|
||||
|
||||
// 如果点击在搜索区域内或查询不为空,则不关闭搜索状态
|
||||
if (isClickInsideSearchArea(target) || query.value.trim() !== "") {
|
||||
return;
|
||||
}
|
||||
|
||||
clearSearchState();
|
||||
};
|
||||
|
||||
/**
|
||||
* 处理全局键盘按下事件。
|
||||
* 主要用于处理 Escape 键关闭搜索和 Tab 键导航。
|
||||
* @param {KeyboardEvent} event 键盘事件对象。
|
||||
*/
|
||||
const handleKeydown = (event: KeyboardEvent) => {
|
||||
if (!searchStateStore.isSearchActive) return;
|
||||
if (!isSearchActive.value) return;
|
||||
|
||||
const container = appbar.value;
|
||||
const items = container?.querySelectorAll(".searchInput, .item") || null;
|
||||
// 获取所有可聚焦的元素:搜索输入框和每个搜索结果项
|
||||
const items = container?.querySelectorAll<HTMLElement>(".search-input, .result-items") || null;
|
||||
|
||||
if (event.key === "Escape") {
|
||||
event.preventDefault();
|
||||
handleTabNavigation(container, items, true);
|
||||
handleTabNavigation(container, items, true); // 将焦点移回输入框或 Appbar
|
||||
clearSearchState();
|
||||
}
|
||||
|
||||
if (event.key === "Tab") {
|
||||
} else if (event.key === "Tab") {
|
||||
event.preventDefault();
|
||||
handleTabNavigation(container, items, event.shiftKey);
|
||||
|
||||
// 当搜索框没有内容时,Tab 键取消搜索激活状态
|
||||
handleTabNavigation(container, items, event.shiftKey); // 处理自定义 Tab 导航
|
||||
// 如果 Tab 键导致焦点离开搜索区域且查询为空,则关闭搜索
|
||||
if (query.value.trim() === "") {
|
||||
searchStateStore.deactivate();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
// 监听路由变化,处理页面切换后的状态重置和滚动绑定
|
||||
watch(
|
||||
() => route.path,
|
||||
async () => {
|
||||
// 重置 AppBar 状态
|
||||
resetAppBarState();
|
||||
|
||||
// 等待 DOM 更新,确保新的 .content-flow 元素已渲染
|
||||
await nextTick();
|
||||
|
||||
// 重新获取滚动容器并赋值给 scrollTarget
|
||||
const newTarget = getScrollContainer();
|
||||
if (newTarget) {
|
||||
scrollTarget.value = newTarget;
|
||||
newTarget.scrollTop = 0;
|
||||
} else {
|
||||
scrollTarget.value = null;
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// 事件处理函数引用(用于清理)
|
||||
const eventHandlers = {
|
||||
click: handleDocumentClick,
|
||||
keydown: handleKeydown,
|
||||
} as const;
|
||||
|
||||
// 添加事件监听器
|
||||
const addEventListeners = () => {
|
||||
document.addEventListener("click", eventHandlers.click);
|
||||
document.addEventListener("keydown", eventHandlers.keydown);
|
||||
};
|
||||
|
||||
// 移除事件监听器
|
||||
const removeEventListeners = () => {
|
||||
document.removeEventListener("click", eventHandlers.click);
|
||||
document.removeEventListener("keydown", eventHandlers.keydown);
|
||||
};
|
||||
|
||||
// 初始化函数
|
||||
const initializeAppBar = () => {
|
||||
screenWidthStore.init();
|
||||
|
||||
document.addEventListener("click", handleDocumentClick);
|
||||
document.addEventListener("keydown", handleKeydown);
|
||||
// 初始化滚动容器
|
||||
const newTarget = getScrollContainer();
|
||||
if (newTarget) {
|
||||
scrollTarget.value = newTarget;
|
||||
}
|
||||
|
||||
// 添加事件监听器
|
||||
addEventListeners();
|
||||
};
|
||||
|
||||
// 清理函数
|
||||
const cleanupAppBar = () => {
|
||||
// 移除事件监听器
|
||||
removeEventListeners();
|
||||
// 重置状态
|
||||
resetAppBarState();
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
initializeAppBar();
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener("click", handleDocumentClick);
|
||||
document.removeEventListener("keydown", handleKeydown);
|
||||
cleanupAppBar();
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -150,14 +282,11 @@ onUnmounted(() => {
|
||||
scroll: isScrolled,
|
||||
searching: searchStateStore.isSearchActive,
|
||||
typing: searchStateStore.isSearchTyping,
|
||||
hidden: isHidden,
|
||||
}"
|
||||
:tabindex="isTabFocusable ? 0 : -1"
|
||||
>
|
||||
<div class="action-area">
|
||||
<!-- <div class="leading-button">
|
||||
<MaterialButton color="text" icon="menu" size="xs" :tabindex="isTabFocusable ? 0 : -1" />
|
||||
</div> -->
|
||||
|
||||
<input
|
||||
ref="searchInput"
|
||||
v-model="query"
|
||||
@@ -169,29 +298,40 @@ onUnmounted(() => {
|
||||
@input="handleInput"
|
||||
/>
|
||||
|
||||
<div class="author-avatar" :tabindex="isTabFocusable ? 0 : -1">
|
||||
<img src="/assets/images/avatar.webp" alt="logo" />
|
||||
<div class="author-avatar" tabindex="-1">
|
||||
<img src="/assets/images/avatar.webp" alt="作者头像" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="filteredPosts.length > 0" class="result-area">
|
||||
<div class="result-area" v-if="searchStateStore.isSearchActive && filteredPosts.length > 0">
|
||||
<a
|
||||
v-for="(post, index) in filteredPosts"
|
||||
:key="post.url"
|
||||
:href="post.url"
|
||||
class="item"
|
||||
:tabindex="isTabFocusable ? 0 : -1"
|
||||
class="result-items"
|
||||
v-for="post in filteredPosts"
|
||||
@click="handleResultClick"
|
||||
>
|
||||
<div class="title">
|
||||
<h3>{{ post.title }}</h3>
|
||||
<p v-if="post.date" class="date">{{ post.date }}</p>
|
||||
<div class="chips">
|
||||
<p class="segments date item" v-if="post.date">{{ post.date }}</p>
|
||||
<div class="segments categories" v-if="post.categories.length > 0">
|
||||
<p class="item" v-for="item in post.categories">{{ item }}</p>
|
||||
</div>
|
||||
<div class="segments tags" v-if="post.tags.length > 0">
|
||||
<p class="item" v-for="item in post.tags">{{ item }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p v-if="post.description" class="description">{{ post.description }}</p>
|
||||
<!-- 只有不是最后一项时才显示分割线 -->
|
||||
<hr v-if="index !== filteredPosts.length - 1" />
|
||||
<!-- <p class="description" v-if="post.description">{{ post.description }}</p> -->
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="no-results" v-else-if="searchStateStore.isSearchActive && query.length > 0 && filteredPosts.length === 0">
|
||||
<span class="icon">search_off</span>
|
||||
<p class="label">未找到相关文章。</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -1,15 +1,19 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, onBeforeUnmount, nextTick, watch } from "vue";
|
||||
import { useClipboard } 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;
|
||||
@@ -172,6 +176,26 @@ const resizeHandler = () => {
|
||||
}
|
||||
};
|
||||
|
||||
// 计算文章ID
|
||||
const articleId = computed(() => {
|
||||
const relativePath = page.value?.relativePath;
|
||||
if (!relativePath) return "";
|
||||
const path = relativePath.replace(/\.md$/, "");
|
||||
const lookupUrl = path.startsWith("/") ? path : `/${path}`;
|
||||
const post = postStore.getPostByUrl(lookupUrl);
|
||||
return post?.id || "";
|
||||
});
|
||||
|
||||
const shortLink = computed(() => {
|
||||
if (!articleId.value) return "";
|
||||
return `/p/${articleId.value}`;
|
||||
});
|
||||
|
||||
const copyShortLink = async () => {
|
||||
if (!shortLink.value) return;
|
||||
await copyToClipboard(`${window.location.origin}${shortLink.value}`);
|
||||
};
|
||||
|
||||
if (isClient()) {
|
||||
onMounted(() => {
|
||||
screenWidthStore.init();
|
||||
@@ -255,9 +279,16 @@ if (isClient()) {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div ref="pageIndicator" class="PageIndicator" aria-label="页面目录">
|
||||
<p>在此页上</p>
|
||||
<h3>{{ frontmatter.title ? frontmatter.title : page.title }}</h3>
|
||||
<div ref="pageIndicator" class="PageIndicator">
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<h3 class="article-title">{{ frontmatter.title ? frontmatter.title : page.title }}</h3>
|
||||
|
||||
<div
|
||||
class="indicator"
|
||||
|
||||
@@ -1,100 +1,35 @@
|
||||
import { ref, computed, onMounted } from "vue";
|
||||
import { ref, computed, watch, onMounted, onUnmounted } from "vue";
|
||||
import { useScroll } from "@vueuse/core";
|
||||
import { isClient } from "../utils/env";
|
||||
|
||||
let container: HTMLElement | Window | null = null;
|
||||
let isInitialized = false;
|
||||
let isScrolled = ref(false);
|
||||
let precision = 1;
|
||||
let scrollPosition = ref(0);
|
||||
let targetScrollable: string = ".content-flow";
|
||||
let threshold = 80;
|
||||
|
||||
const componentCallbacks = new Map<symbol, { threshold: number; callback: (isScrolled: boolean) => void }>();
|
||||
// 全局状态
|
||||
const globalThreshold = ref(80);
|
||||
const globalPrecision = ref(1);
|
||||
const globalTargetScrollable = ref(".content-flow");
|
||||
const globalContainer = ref<HTMLElement | Window | null>(null);
|
||||
const globalIsScrolled = ref(false);
|
||||
const globalScrollPosition = ref(0);
|
||||
const globalScrollPercentage = ref(0);
|
||||
|
||||
// 检测可滚动容器
|
||||
function isScrollable(el: HTMLElement) {
|
||||
const style = window.getComputedStyle(el);
|
||||
const overflowY = style.overflowY;
|
||||
return overflowY === "auto" || overflowY === "scroll" || el.scrollHeight > el.clientHeight;
|
||||
}
|
||||
|
||||
function detectContainer() {
|
||||
// 检测容器
|
||||
function detectContainer(targetScrollable: string) {
|
||||
if (!isClient()) return window;
|
||||
|
||||
const el = document.querySelector(targetScrollable);
|
||||
if (el && el instanceof HTMLElement && isScrollable(el)) return el;
|
||||
return window;
|
||||
}
|
||||
|
||||
function handleGlobalScroll() {
|
||||
// 计算滚动百分比
|
||||
function calculatePercentage(scrollTop: number, scrollContainer: HTMLElement | Window, precision: number): number {
|
||||
try {
|
||||
const scrollTop = container === window ? window.scrollY || window.pageYOffset : (container as HTMLElement).scrollTop;
|
||||
|
||||
scrollPosition.value = scrollTop;
|
||||
isScrolled.value = scrollTop > threshold;
|
||||
|
||||
componentCallbacks.forEach(({ threshold: componentThreshold, callback }) => {
|
||||
callback(scrollTop > componentThreshold);
|
||||
});
|
||||
} catch (e) {
|
||||
scrollPosition.value = 0;
|
||||
isScrolled.value = false;
|
||||
componentCallbacks.forEach(({ callback }) => {
|
||||
callback(false);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function initGlobalScrollListener(initialThreshold: number = threshold, scrollContainer: string = targetScrollable) {
|
||||
if (isInitialized) return;
|
||||
|
||||
threshold = initialThreshold;
|
||||
targetScrollable = scrollContainer;
|
||||
|
||||
if (isClient()) {
|
||||
const updateContainer = () => {
|
||||
if (container) {
|
||||
const target: any = container;
|
||||
target.removeEventListener("scroll", handleGlobalScroll);
|
||||
}
|
||||
|
||||
container = detectContainer();
|
||||
|
||||
const target: any = container;
|
||||
target.addEventListener("scroll", handleGlobalScroll, { passive: true });
|
||||
|
||||
handleGlobalScroll();
|
||||
};
|
||||
|
||||
updateContainer();
|
||||
|
||||
const checkContainerInterval = setInterval(() => {
|
||||
const newContainer = detectContainer();
|
||||
if (newContainer !== container) {
|
||||
updateContainer();
|
||||
}
|
||||
}, 500);
|
||||
|
||||
isInitialized = true;
|
||||
|
||||
if ((window as any).__cleanup) {
|
||||
(window as any).__cleanup.push(() => {
|
||||
clearInterval(checkContainerInterval);
|
||||
if (container) {
|
||||
const target: any = container;
|
||||
target.removeEventListener("scroll", handleGlobalScroll);
|
||||
}
|
||||
componentCallbacks.clear();
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function calculatePercentage(precisionValue: number = precision): number {
|
||||
try {
|
||||
const el = document.querySelector(targetScrollable);
|
||||
const scrollContainer = el && el instanceof HTMLElement && isScrollable(el as HTMLElement) ? el : window;
|
||||
|
||||
const scrollTop =
|
||||
scrollContainer === window ? window.scrollY || window.pageYOffset : (scrollContainer as HTMLElement).scrollTop;
|
||||
|
||||
let scrollHeight: number, clientHeight: number;
|
||||
|
||||
if (scrollContainer === window) {
|
||||
@@ -114,62 +49,130 @@ function calculatePercentage(precisionValue: number = precision): number {
|
||||
if (maxScroll <= 0) return 0;
|
||||
|
||||
const percentage = Math.min(scrollTop / maxScroll, 1) * 100;
|
||||
return Number(percentage.toFixed(precisionValue));
|
||||
return Number(percentage.toFixed(precision));
|
||||
} catch (e) {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
// 更新全局状态
|
||||
function updateGlobalState(y: number, container: HTMLElement | Window, threshold: number, precision: number) {
|
||||
globalScrollPosition.value = y;
|
||||
globalIsScrolled.value = y > threshold;
|
||||
globalScrollPercentage.value = calculatePercentage(y, container, precision);
|
||||
}
|
||||
|
||||
export function useGlobalScroll(options?: { threshold?: number; container?: string; precision?: number }) {
|
||||
const localThreshold = options?.threshold ?? threshold;
|
||||
const localPrecision = options?.precision ?? precision;
|
||||
const localThreshold = options?.threshold ?? globalThreshold.value;
|
||||
const localPrecision = options?.precision ?? globalPrecision.value;
|
||||
const localTargetScrollable = options?.container ?? globalTargetScrollable.value;
|
||||
|
||||
const containerRef = ref<HTMLElement | Window | null>(null);
|
||||
const localIsScrolled = ref(false);
|
||||
const componentId = Symbol();
|
||||
const updateComponentState = (isScrolled: boolean) => {
|
||||
localIsScrolled.value = isScrolled;
|
||||
const localScrollPosition = ref(0);
|
||||
const localScrollPercentage = ref(0);
|
||||
|
||||
// 初始化容器
|
||||
const initContainer = () => {
|
||||
if (!isClient()) return;
|
||||
|
||||
const container = detectContainer(localTargetScrollable);
|
||||
containerRef.value = container;
|
||||
|
||||
// 更新全局容器引用(如果是第一个实例)
|
||||
if (!globalContainer.value) {
|
||||
globalContainer.value = container;
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
if (!isInitialized) {
|
||||
initGlobalScrollListener(80);
|
||||
}
|
||||
|
||||
componentCallbacks.set(componentId, {
|
||||
threshold: localThreshold,
|
||||
callback: updateComponentState,
|
||||
});
|
||||
|
||||
handleGlobalScroll();
|
||||
|
||||
return () => {
|
||||
componentCallbacks.delete(componentId);
|
||||
};
|
||||
const scrollResult = useScroll(containerRef, {
|
||||
throttle: 0,
|
||||
idle: 200,
|
||||
eventListenerOptions: { passive: true },
|
||||
});
|
||||
|
||||
return {
|
||||
isScrolled: computed(() => localIsScrolled.value),
|
||||
scrollPosition: computed(() => {
|
||||
if (!container) return 0;
|
||||
try {
|
||||
return container === window ? window.scrollY || window.pageYOffset : (container as HTMLElement).scrollTop;
|
||||
} catch (e) {
|
||||
return 0;
|
||||
// 监听滚动位置变化
|
||||
watch(
|
||||
() => scrollResult.y.value,
|
||||
(y) => {
|
||||
if (containerRef.value) {
|
||||
const yValue = y || 0;
|
||||
|
||||
// 更新本地状态
|
||||
localScrollPosition.value = yValue;
|
||||
localIsScrolled.value = yValue > localThreshold;
|
||||
localScrollPercentage.value = calculatePercentage(yValue, containerRef.value, localPrecision);
|
||||
|
||||
// 更新全局状态
|
||||
updateGlobalState(yValue, containerRef.value, globalThreshold.value, globalPrecision.value);
|
||||
}
|
||||
}),
|
||||
scrollPercentage: computed(() => {
|
||||
scrollPosition.value;
|
||||
return calculatePercentage(localPrecision);
|
||||
}),
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
// 容器检测和初始化
|
||||
onMounted(() => {
|
||||
if (isClient()) {
|
||||
initContainer();
|
||||
|
||||
// 定期检查容器是否变化
|
||||
const checkContainerInterval = setInterval(() => {
|
||||
const newContainer = detectContainer(localTargetScrollable);
|
||||
if (newContainer !== containerRef.value) {
|
||||
containerRef.value = newContainer;
|
||||
}
|
||||
}, 1000);
|
||||
|
||||
// 清理函数
|
||||
return () => {
|
||||
clearInterval(checkContainerInterval);
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
// 监听选项变化
|
||||
watch(
|
||||
() => options?.container,
|
||||
() => {
|
||||
if (isClient()) {
|
||||
initContainer();
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
watch(
|
||||
() => options?.threshold,
|
||||
(newThreshold) => {
|
||||
if (newThreshold !== undefined && containerRef.value) {
|
||||
localIsScrolled.value = localScrollPosition.value > newThreshold;
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
return {
|
||||
// 本地状态
|
||||
isScrolled: computed(() => localIsScrolled.value),
|
||||
scrollPosition: computed(() => localScrollPosition.value),
|
||||
scrollPercentage: computed(() => localScrollPercentage.value),
|
||||
|
||||
// 原始 useScroll 结果(用于高级用途)
|
||||
scrollResult,
|
||||
|
||||
// 容器引用
|
||||
container: containerRef,
|
||||
|
||||
// 阈值和精度
|
||||
threshold: localThreshold,
|
||||
precision: localPrecision,
|
||||
};
|
||||
}
|
||||
|
||||
// 全局滚动状态
|
||||
export const globalScrollState = {
|
||||
isScrolled: isScrolled,
|
||||
threshold: computed(() => threshold),
|
||||
scrollPosition: computed(() => scrollPosition.value),
|
||||
scrollPercentage: computed(() => {
|
||||
scrollPosition.value;
|
||||
return calculatePercentage(precision);
|
||||
}),
|
||||
precision: computed(() => precision),
|
||||
isScrolled: computed(() => globalIsScrolled.value),
|
||||
threshold: computed(() => globalThreshold.value),
|
||||
scrollPosition: computed(() => globalScrollPosition.value),
|
||||
scrollPercentage: computed(() => globalScrollPercentage.value),
|
||||
precision: computed(() => globalPrecision.value),
|
||||
container: computed(() => globalContainer.value),
|
||||
};
|
||||
|
||||
@@ -2,10 +2,8 @@
|
||||
import { ref, onMounted, onBeforeUnmount, computed } from "vue";
|
||||
import { useClipboard, useTimestamp, useDateFormat } from "@vueuse/core";
|
||||
import { useGlobalData } from "../composables/useGlobalData";
|
||||
import { usePostStore } from "../stores/posts";
|
||||
import { isClient } from "../utils/env";
|
||||
|
||||
const postStore = usePostStore();
|
||||
const { page, frontmatter } = useGlobalData();
|
||||
const { copy: copyToClipboard, copied: isCopied } = useClipboard();
|
||||
|
||||
@@ -65,26 +63,6 @@ const formattedLastUpdated = computed(() => {
|
||||
return `${formatTimeAgo(uDate)}编辑`;
|
||||
});
|
||||
|
||||
// 计算文章ID
|
||||
const articleId = computed(() => {
|
||||
const relativePath = page.value?.relativePath;
|
||||
if (!relativePath) return "";
|
||||
const path = relativePath.replace(/\.md$/, "");
|
||||
const lookupUrl = path.startsWith("/") ? path : `/${path}`;
|
||||
const post = postStore.getPostByUrl(lookupUrl);
|
||||
return post?.id || "";
|
||||
});
|
||||
|
||||
const shortLink = computed(() => {
|
||||
if (!articleId.value) return "";
|
||||
return `/p/${articleId.value}`;
|
||||
});
|
||||
|
||||
const copyShortLink = async () => {
|
||||
if (!shortLink.value) return;
|
||||
await copyToClipboard(`${window.location.origin}${shortLink.value}`);
|
||||
};
|
||||
|
||||
// 图片查看器相关逻辑
|
||||
const showImageViewer = ref(false);
|
||||
const currentImageIndex = ref(0);
|
||||
@@ -225,13 +203,9 @@ if (isClient()) {
|
||||
{{ formattedLastUpdated }}
|
||||
</p>
|
||||
</ClientOnly>
|
||||
<p class="id" v-if="articleId">文章ID {{ articleId }}</p>
|
||||
</div>
|
||||
<ButtonGroup v-if="frontmatter?.external_links" :links="frontmatter.external_links" size="m" layout="vertical" />
|
||||
<PageIndicator />
|
||||
<MaterialButton v-if="articleId" :color="'text'" :icon="'content_copy'" @click="copyShortLink">
|
||||
复制短链
|
||||
</MaterialButton>
|
||||
</div>
|
||||
<ImageViewer
|
||||
v-if="showImageViewer"
|
||||
|
||||
@@ -5,9 +5,8 @@
|
||||
align-items: center;
|
||||
flex-direction: column;
|
||||
flex-wrap: nowrap;
|
||||
gap: 8px;
|
||||
grid-column: 2 / 3;
|
||||
justify-content: space-between;
|
||||
justify-content: start;
|
||||
|
||||
position: absolute;
|
||||
right: 0px;
|
||||
@@ -16,7 +15,7 @@
|
||||
height: 64px;
|
||||
width: 100%;
|
||||
|
||||
padding-inline: 4px;
|
||||
padding-inline: 8px;
|
||||
|
||||
color: var(--md-sys-color-on-surface);
|
||||
|
||||
@@ -29,7 +28,6 @@
|
||||
.action-area {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
justify-content: center;
|
||||
|
||||
position: relative;
|
||||
@@ -37,34 +35,15 @@
|
||||
height: 64px;
|
||||
width: 100%;
|
||||
|
||||
.leading-button {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex-shrink: 0;
|
||||
justify-content: center;
|
||||
|
||||
position: absolute;
|
||||
left: 0px;
|
||||
|
||||
height: 48px;
|
||||
width: 48px;
|
||||
|
||||
margin-inline: 4px 8px;
|
||||
|
||||
opacity: 1;
|
||||
transition: opacity var(--md-sys-motion-spring-fast-effect-duration) var(--md-sys-motion-spring-fast-effect);
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.search-input {
|
||||
@include mixin.typescale-style("title-medium");
|
||||
|
||||
flex-grow: 1;
|
||||
|
||||
height: 56px;
|
||||
height: 52px;
|
||||
min-width: 0px;
|
||||
|
||||
margin-inline-start: 0px;
|
||||
margin-inline-end: 50px;
|
||||
padding-block: 0px;
|
||||
padding-inline: 24px;
|
||||
|
||||
@@ -88,15 +67,18 @@
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
|
||||
height: 48px;
|
||||
width: 48px;
|
||||
position: absolute;
|
||||
right: 0px;
|
||||
|
||||
height: 42px;
|
||||
width: 42px;
|
||||
|
||||
overflow: hidden;
|
||||
z-index: 0;
|
||||
|
||||
img {
|
||||
height: 32px;
|
||||
width: 32px;
|
||||
height: 42px;
|
||||
width: 42px;
|
||||
|
||||
object-fit: cover;
|
||||
-webkit-mask: var(--via-svg-mask) no-repeat 0 / 100%;
|
||||
@@ -109,25 +91,101 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex-wrap: nowrap;
|
||||
gap: 8px;
|
||||
gap: 24px;
|
||||
|
||||
position: absolute;
|
||||
top: 64px;
|
||||
top: 76px;
|
||||
|
||||
height: calc(100% - 76px);
|
||||
width: 100%;
|
||||
|
||||
padding-inline: 24px;
|
||||
padding-block-start: 12px;
|
||||
padding-block-start: 24px;
|
||||
padding-inline: 12px;
|
||||
|
||||
overflow: scroll;
|
||||
|
||||
.item {
|
||||
.result-items {
|
||||
padding-inline: 12px;
|
||||
padding-block: 12px;
|
||||
|
||||
width: 100%;
|
||||
|
||||
text-decoration: none;
|
||||
|
||||
border-radius: var(--md-sys-shape-corner-medium);
|
||||
|
||||
.title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
|
||||
h3 {
|
||||
color: var(--md-sys-color-on-surface);
|
||||
}
|
||||
|
||||
.chips {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
|
||||
.segments {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-direction: row;
|
||||
flex-wrap: nowrap;
|
||||
gap: 4px;
|
||||
|
||||
.item,
|
||||
&.item {
|
||||
padding-inline: 8px;
|
||||
|
||||
border-radius: var(--md-sys-shape-corner-medium);
|
||||
}
|
||||
|
||||
&::before {
|
||||
@include mixin.material-symbols($size: 16);
|
||||
}
|
||||
|
||||
&.date {
|
||||
color: var(--md-sys-color-on-tertiary-container);
|
||||
|
||||
background-color: var(--md-sys-color-tertiary-container);
|
||||
|
||||
&::before {
|
||||
content: "calendar_today";
|
||||
}
|
||||
}
|
||||
|
||||
&.categories {
|
||||
.item {
|
||||
color: var(--md-sys-color-on-purple-container);
|
||||
|
||||
background-color: var(--md-sys-color-purple-container);
|
||||
}
|
||||
|
||||
&::before {
|
||||
content: "category";
|
||||
}
|
||||
}
|
||||
|
||||
&.tags {
|
||||
.item {
|
||||
color: var(--md-sys-color-on-yellow-container);
|
||||
|
||||
background-color: var(--md-sys-color-yellow-container);
|
||||
}
|
||||
|
||||
&::before {
|
||||
content: "tag";
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.description {
|
||||
margin-block-end: 12px;
|
||||
}
|
||||
@@ -138,6 +196,25 @@
|
||||
}
|
||||
}
|
||||
|
||||
.no-results {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-direction: column;
|
||||
flex-wrap: nowrap;
|
||||
gap: 42px;
|
||||
justify-content: center;
|
||||
|
||||
height: calc(100% - 76px);
|
||||
width: 100%;
|
||||
|
||||
user-select: none;
|
||||
-moz-user-select: none;
|
||||
|
||||
.icon {
|
||||
@include mixin.material-symbols($size: 100);
|
||||
}
|
||||
}
|
||||
|
||||
&.searching {
|
||||
top: 0px;
|
||||
|
||||
@@ -146,16 +223,16 @@
|
||||
padding: 12px;
|
||||
|
||||
.action-area {
|
||||
.leading-button {
|
||||
opacity: 0;
|
||||
transition: opacity var(--md-sys-motion-spring-fast-effect-duration) var(--md-sys-motion-spring-fast-effect);
|
||||
}
|
||||
|
||||
.search-input {
|
||||
margin-inline-start: 0px;
|
||||
margin-inline-end: 0px;
|
||||
|
||||
transition: margin var(--md-sys-motion-spring-fast-spatial-duration) var(--md-sys-motion-spring-fast-spatial);
|
||||
}
|
||||
|
||||
.author-avatar {
|
||||
opacity: 0;
|
||||
transition: opacity var(--md-sys-motion-spring-fast-effect-duration) var(--md-sys-motion-spring-fast-effect);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -166,6 +243,10 @@
|
||||
background-color: var(--md-sys-color-surface-container-highest);
|
||||
}
|
||||
}
|
||||
|
||||
&.hidden {
|
||||
top: -64px;
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (max-width: 840px) {
|
||||
@@ -177,14 +258,12 @@
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
|
||||
// .action-area {
|
||||
// .search-input {
|
||||
// margin-inline-start: 56px;
|
||||
// }
|
||||
// }
|
||||
|
||||
.result-area {
|
||||
height: calc(100% - (80px + 64px));
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.no-results {
|
||||
height: calc(100% - (64px + 64px));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,15 +6,31 @@
|
||||
user-select: none;
|
||||
-moz-user-select: none;
|
||||
|
||||
p {
|
||||
@include mixin.typescale-style("label-small");
|
||||
.label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-direction: row;
|
||||
flex-wrap: nowrap;
|
||||
gap: 4px;
|
||||
|
||||
margin-inline-start: 18px;
|
||||
|
||||
z-index: 1;
|
||||
p {
|
||||
@include mixin.typescale-style("label-small");
|
||||
|
||||
&.icon {
|
||||
@include mixin.material-symbols($size: 16);
|
||||
}
|
||||
|
||||
&.article-id {
|
||||
text-transform: uppercase;
|
||||
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
h3 {
|
||||
.article-title {
|
||||
margin-inline-start: 18px;
|
||||
padding-block-end: 18px;
|
||||
|
||||
@@ -121,6 +137,10 @@
|
||||
|
||||
@media screen and (max-width: 840px) {
|
||||
.PageIndicator {
|
||||
display: none;
|
||||
.article-title,
|
||||
.indicator,
|
||||
.indicator-container {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -831,10 +831,6 @@
|
||||
&.date-update::before {
|
||||
content: "update";
|
||||
}
|
||||
|
||||
&.id::before {
|
||||
content: "flag";
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"version": "26.1.9(234)",
|
||||
"version": "26.1.13(240)",
|
||||
"scripts": {
|
||||
"update-version": "bash ./scripts/update-version.sh",
|
||||
"docs:dev": "vitepress dev",
|
||||
|
||||
Reference in New Issue
Block a user