1
0
mirror of https://github.com/sendevia/website.git synced 2026-03-08 08:44:15 +08:00

6 Commits

8 changed files with 542 additions and 299 deletions

View File

@@ -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>

View File

@@ -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"

View File

@@ -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),
};

View File

@@ -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"

View File

@@ -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));
}
}
}

View File

@@ -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;
}
}
}

View File

@@ -831,10 +831,6 @@
&.date-update::before {
content: "update";
}
&.id::before {
content: "flag";
}
}
}
}

View File

@@ -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",