- T4 reader:html.reader 单 class(去装饰非去内容:隐辉光/图标 钮/pv 行,ICP 合规底线永留;正文字号+1px 行宽 56ch;入场动效 关);命令面板 reader 命令+r 键+localStorage;ThemeScript 预绘 防闪 - T7 基底:--flow-* 五流距变量双轨应用;暗色 hairline 降档; 页面标题 700→600(裁定 #6 减字重不减号);focus-visible ring 统一;列表行距 +2px;--paper/--radius-img/--ease-out 入库 - T8:首页 IdCard slot 落 tagline「写作者 · 读者 · 快门按压者, 拥抱开源。」;图库暗色 4% 轻衬底+圆角统一(裁定 #4 双模式分判) 验证: E2E 33/33;reader r 键触发+辉光 none+刷新保持实测;流距 computed 0.6em 实算;tagline 渲染实测 Spec: 2026-10-26_blog-5f Co-authored-by: GLM-5.3
379 lines
13 KiB
Plaintext
379 lines
13 KiB
Plaintext
---
|
||
// 命令面板:动作层(主题/语言/gitea/RSS)+ 导航层 + Pagefind 搜索层
|
||
// 由 SearchDialog 薄壳转发而来(挂载点名与 DOM id 保持不变,BaseLayout 无需感知)
|
||
import { t } from '../i18n/ui';
|
||
import { localePath } from '../lib/locale';
|
||
import type { Locale } from '../lib/locale';
|
||
|
||
interface Props {
|
||
locale?: Locale;
|
||
}
|
||
const { locale = 'zh' } = Astro.props;
|
||
const other: Locale = locale === 'zh' ? 'en' : 'zh';
|
||
|
||
// 静态站每页渲染时 Astro.url 即本页路径:语言跳转目标构建期算死,运行时零计算
|
||
const langHref = localePath(Astro.url.pathname.replace(/\/index\.html$/, ''), other);
|
||
const rssPath = locale === 'zh' ? '/rss.xml' : '/en/rss.xml';
|
||
const prefix = locale === 'zh' ? '' : '/en';
|
||
const navItems = [
|
||
{ href: `${prefix}/`, label: t('nav_home', locale) },
|
||
{ href: `${prefix}/archives/`, label: t('nav_posts', locale) },
|
||
{ href: `${prefix}/gallery/`, label: t('nav_gallery', locale) },
|
||
{ href: `${prefix}/moments/`, label: t('nav_moments', locale) },
|
||
{ href: `${prefix}/about/`, label: t('nav_about', locale) },
|
||
];
|
||
---
|
||
|
||
<dialog id="search-dialog" class="search-dialog command-palette" data-locale={locale}>
|
||
<input
|
||
id="search-input"
|
||
type="search"
|
||
placeholder={t('search_placeholder', locale)}
|
||
autocomplete="off"
|
||
role="combobox"
|
||
aria-expanded="false"
|
||
aria-controls="cmd-list"
|
||
aria-autocomplete="list"
|
||
/>
|
||
<div id="cmd-list" class="cmd-list" role="listbox" aria-label={t('cmd_palette_label', locale)}>
|
||
<div id="cmd-commands" class="cmd-commands">
|
||
<p class="cmd-group" role="presentation" id="cmd-h-actions">{t('cmd_group_actions', locale)}</p>
|
||
<ul class="cmd-ul" role="group" aria-labelledby="cmd-h-actions">
|
||
<li class="cmd-item" role="option" aria-selected="false" data-cmd="reader">
|
||
<span class="cmd-label">{t('cmd_reader', locale)}</span>
|
||
<span class="cmd-kbd" aria-hidden="true">r</span>
|
||
</li>
|
||
<li class="cmd-item" role="option" aria-selected="false" data-cmd="theme">
|
||
<span class="cmd-label">{t('cmd_toggle_theme', locale)}</span>
|
||
<span class="cmd-kbd" aria-hidden="true">t</span>
|
||
</li>
|
||
<li class="cmd-item" role="option" aria-selected="false" data-cmd="lang" data-href={langHref}>
|
||
<span class="cmd-label">{t('cmd_switch_lang', locale)}</span>
|
||
<span class="cmd-kbd" aria-hidden="true">{other === 'en' ? 'EN' : '中'}</span>
|
||
</li>
|
||
<li class="cmd-item" role="option" aria-selected="false">
|
||
<a href="https://git.wait4x.online/wait4x/blog" target="_blank" rel="noopener">
|
||
<span class="cmd-label">{t('cmd_open_gitea', locale)}</span>
|
||
<span class="cmd-kbd" aria-hidden="true">↗</span>
|
||
</a>
|
||
</li>
|
||
<li class="cmd-item" role="option" aria-selected="false" data-cmd="rss" data-rss={rssPath}>
|
||
<span class="cmd-label">{t('cmd_copy_rss', locale)}</span>
|
||
</li>
|
||
</ul>
|
||
<p class="cmd-group" role="presentation" id="cmd-h-nav">{t('cmd_group_nav', locale)}</p>
|
||
<ul class="cmd-ul" role="group" aria-labelledby="cmd-h-nav">
|
||
{navItems.map((n) => (
|
||
<li class="cmd-item" role="option" aria-selected="false">
|
||
<a href={n.href}>{n.label}</a>
|
||
</li>
|
||
))}
|
||
</ul>
|
||
</div>
|
||
<p class="cmd-group" role="presentation" id="cmd-h-search" hidden>{t('cmd_group_search', locale)}</p>
|
||
<ul id="search-results" class="results cmd-ul" role="group" aria-labelledby="cmd-h-search" aria-live="polite"></ul>
|
||
</div>
|
||
<p class="search-hint">{t('search_hint', locale)}</p>
|
||
<p class="search-hint">{t('cmd_hint', locale)}</p>
|
||
</dialog>
|
||
|
||
<script>
|
||
import { t } from '../i18n/ui';
|
||
|
||
type PagefindLike = {
|
||
search: (q: string) => Promise<{ results: { data: () => Promise<{ url: string; excerpt: string; meta: { title: string } }> }[] }>;
|
||
};
|
||
let pf: PagefindLike | null = null;
|
||
let loadStarted = false;
|
||
|
||
// 首开才拉索引(原 SearchDialog 行为原样保留)
|
||
async function ensureIndex() {
|
||
if (loadStarted) return;
|
||
loadStarted = true;
|
||
pf = (await import(/* @vite-ignore */ '/pagefind/pagefind.js')) as unknown as PagefindLike;
|
||
}
|
||
|
||
// ClientRouter 换页会重建 DOM:元素引用一律事件时现取,模块级不缓存旧节点
|
||
const dlg = () => document.getElementById('search-dialog') as HTMLDialogElement | null;
|
||
const inputEl = () => document.getElementById('search-input') as HTMLInputElement | null;
|
||
const resultsEl = () => document.getElementById('search-results');
|
||
const commandsEl = () => document.getElementById('cmd-commands');
|
||
const searchHeadEl = () => document.getElementById('cmd-h-search');
|
||
const curLocale = () => ((dlg()?.dataset.locale ?? 'zh') as 'zh' | 'en');
|
||
|
||
let activeIdx = -1;
|
||
|
||
// 可见项 = 未被 [hidden] 祖先遮蔽的 cmd-item:命令模式与搜索模式共用一套 ↑↓ 导航
|
||
const visibleItems = () =>
|
||
[...document.querySelectorAll<HTMLElement>('#cmd-list .cmd-item')].filter(
|
||
(el) => el.closest('[hidden]') === null,
|
||
);
|
||
|
||
function setActive(idx: number) {
|
||
const items = visibleItems();
|
||
if (items.length === 0) {
|
||
activeIdx = -1;
|
||
return;
|
||
}
|
||
activeIdx = (idx + items.length) % items.length;
|
||
items.forEach((el, i) => {
|
||
el.setAttribute('aria-selected', i === activeIdx ? 'true' : 'false');
|
||
el.classList.toggle('active', i === activeIdx);
|
||
});
|
||
items[activeIdx]?.scrollIntoView({ block: 'nearest' });
|
||
}
|
||
|
||
// 输入空 → 命令层;输入即查 → 搜索层(原 Pagefind 逻辑不动,只是套了模式切换)
|
||
function showCommands(show: boolean) {
|
||
commandsEl()?.toggleAttribute('hidden', !show);
|
||
if (show) searchHeadEl()?.setAttribute('hidden', '');
|
||
else searchHeadEl()?.removeAttribute('hidden');
|
||
}
|
||
|
||
function activate(item: HTMLElement) {
|
||
const link = item.querySelector('a');
|
||
if (link) {
|
||
link.click();
|
||
return;
|
||
}
|
||
switch (item.dataset.cmd) {
|
||
// 复用 Header 主题钮的现成切换(同一 localStorage key),不另起第二套实现
|
||
case 'reader': {
|
||
const root = document.documentElement;
|
||
root.classList.toggle('reader');
|
||
localStorage.setItem('reader', root.classList.contains('reader') ? '1' : '0');
|
||
break;
|
||
}
|
||
case 'theme': {
|
||
const btn = document.querySelector('[data-theme-toggle]') as HTMLElement | null;
|
||
if (btn) {
|
||
btn.click();
|
||
} else {
|
||
const root = document.documentElement;
|
||
const next = root.dataset.theme === 'dark' ? 'light' : 'dark';
|
||
root.dataset.theme = next;
|
||
localStorage.setItem('theme', next);
|
||
}
|
||
dlg()?.close();
|
||
break;
|
||
}
|
||
case 'lang':
|
||
if (item.dataset.href) location.href = item.dataset.href;
|
||
break;
|
||
case 'rss': {
|
||
const url = new URL(item.dataset.rss ?? '/rss.xml', location.origin).href;
|
||
const label = item.querySelector('.cmd-label');
|
||
void navigator.clipboard
|
||
?.writeText(url)
|
||
.then(() => {
|
||
// 复制是唯一给反馈的动作:标签短暂换成"已复制"再关面板
|
||
if (label) label.textContent = t('cmd_copied', curLocale());
|
||
window.setTimeout(() => dlg()?.close(), 900);
|
||
})
|
||
.catch(() => {});
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
|
||
function open() {
|
||
const d = dlg();
|
||
if (!d) return;
|
||
d.showModal();
|
||
const inp = inputEl();
|
||
if (inp) inp.value = '';
|
||
const list = resultsEl();
|
||
if (list) list.innerHTML = '';
|
||
showCommands(true);
|
||
setActive(0);
|
||
inp?.setAttribute('aria-expanded', 'true');
|
||
void ensureIndex();
|
||
inp?.focus();
|
||
}
|
||
|
||
// 触发钮 / 面板内点击 / 快捷键全部挂 document 委托:换页后自动作用于新 DOM
|
||
document.addEventListener('click', (e) => {
|
||
const el = e.target as Element;
|
||
if (el?.closest?.('[data-search-trigger]')) {
|
||
open();
|
||
return;
|
||
}
|
||
const item = el?.closest?.('#cmd-list .cmd-item') as HTMLElement | null;
|
||
if (!item) return;
|
||
// 命中链接时交给原生导航,避免再 click() 触发两次(_blank 会开两个页)
|
||
if (el.closest('a')) return;
|
||
e.preventDefault();
|
||
activate(item);
|
||
});
|
||
document.addEventListener('keydown', (e) => {
|
||
if ((e.ctrlKey || e.metaKey) && (e.key === 'k' || e.key === 'K')) {
|
||
e.preventDefault();
|
||
open();
|
||
return;
|
||
}
|
||
const inField = document.activeElement instanceof HTMLElement &&
|
||
(document.activeElement.tagName === 'INPUT' || document.activeElement.tagName === 'TEXTAREA');
|
||
if (e.key === '/' && !inField) {
|
||
e.preventDefault();
|
||
open();
|
||
}
|
||
});
|
||
|
||
// 输入域监听随换页重装(dataset 防同一元素重复绑)
|
||
function bindInput() {
|
||
const inp = inputEl();
|
||
const d = dlg();
|
||
if (!inp || !d || inp.dataset.cmdBound) return;
|
||
inp.dataset.cmdBound = '1';
|
||
d.addEventListener('close', () => inp.setAttribute('aria-expanded', 'false'));
|
||
|
||
let timer: number | undefined;
|
||
inp.addEventListener('input', () => {
|
||
clearTimeout(timer);
|
||
timer = window.setTimeout(async () => {
|
||
if (!pf) return;
|
||
const q = inp.value.trim();
|
||
if (!q) {
|
||
const list = resultsEl();
|
||
if (list) list.innerHTML = '';
|
||
showCommands(true);
|
||
setActive(0);
|
||
return;
|
||
}
|
||
showCommands(false);
|
||
const res = await pf.search(q);
|
||
if (inputEl()?.value.trim() !== q) return; // 过期响应丢弃
|
||
const list = resultsEl();
|
||
if (!list) return;
|
||
list.innerHTML = '';
|
||
for (const r of res.results.slice(0, 8)) {
|
||
const d = await r.data();
|
||
const li = document.createElement('li');
|
||
li.className = 'cmd-item';
|
||
li.setAttribute('role', 'option');
|
||
li.setAttribute('aria-selected', 'false');
|
||
const a = document.createElement('a');
|
||
a.href = d.url;
|
||
a.className = 'tap';
|
||
a.innerHTML = `<strong>${d.meta.title}</strong><span>${d.excerpt}</span>`;
|
||
li.appendChild(a);
|
||
list.appendChild(li);
|
||
}
|
||
if (!list.children.length) {
|
||
list.innerHTML = `<li class="empty">${t('search_no_results', curLocale())}</li>`;
|
||
}
|
||
setActive(0);
|
||
}, 200);
|
||
});
|
||
|
||
inp.addEventListener('keydown', (e) => {
|
||
if (e.key === 'ArrowDown') {
|
||
e.preventDefault();
|
||
setActive(activeIdx + 1);
|
||
} else if (e.key === 'ArrowUp') {
|
||
e.preventDefault();
|
||
setActive(activeIdx - 1);
|
||
} else if (e.key === 'Enter') {
|
||
e.preventDefault();
|
||
const item = visibleItems()[activeIdx];
|
||
if (item) activate(item);
|
||
}
|
||
});
|
||
}
|
||
document.addEventListener('astro:page-load', bindInput);
|
||
bindInput();
|
||
</script>
|
||
|
||
<style>
|
||
.search-dialog {
|
||
margin: 12vh auto auto;
|
||
margin-top: 12vh;
|
||
width: min(560px, calc(100vw - 32px));
|
||
border: 1px solid var(--line);
|
||
border-radius: 10px;
|
||
background: var(--bg);
|
||
color: var(--ink-2);
|
||
padding: var(--space-3);
|
||
}
|
||
.search-dialog::backdrop { background: rgba(0, 0, 0, 0.45); }
|
||
input[type='search'] {
|
||
width: 100%;
|
||
padding: var(--space-2) var(--space-3);
|
||
border: 1px solid var(--line);
|
||
border-radius: 8px;
|
||
background: var(--bg-soft);
|
||
font-size: 15px;
|
||
}
|
||
.cmd-list {
|
||
max-height: 50vh;
|
||
overflow-y: auto;
|
||
margin-top: var(--space-2);
|
||
}
|
||
.cmd-group {
|
||
font-size: 11px;
|
||
font-family: var(--font-mono);
|
||
color: var(--ink-4);
|
||
letter-spacing: 0.08em;
|
||
margin: var(--space-2) 0 var(--space-1);
|
||
}
|
||
.cmd-ul { list-style: none; padding: 0; margin: 0; }
|
||
.cmd-item {
|
||
display: flex;
|
||
align-items: center;
|
||
border-radius: 8px;
|
||
}
|
||
.cmd-item:not(:has(> a)) {
|
||
padding: var(--space-2) var(--space-3);
|
||
font-size: 14px;
|
||
color: var(--ink-2);
|
||
cursor: pointer;
|
||
}
|
||
.cmd-item > a {
|
||
flex: 1;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
gap: var(--space-2);
|
||
padding: var(--space-2) var(--space-3);
|
||
border-radius: 8px;
|
||
color: var(--ink-2);
|
||
text-decoration: none;
|
||
font-size: 14px;
|
||
}
|
||
.cmd-kbd {
|
||
font-family: var(--font-mono);
|
||
font-size: 10px;
|
||
border: 1px solid var(--line);
|
||
border-bottom-width: 2px;
|
||
border-radius: 4px;
|
||
padding: 0 5px;
|
||
color: var(--ink-4);
|
||
}
|
||
.cmd-item.active,
|
||
:global(.results .cmd-item.active) {
|
||
background: var(--bg-soft);
|
||
}
|
||
.search-hint {
|
||
margin-top: var(--space-2);
|
||
font-size: 11px;
|
||
color: var(--ink-4);
|
||
text-align: center;
|
||
font-family: var(--font-mono);
|
||
}
|
||
.results { list-style: none; padding: 0; margin: 0; }
|
||
/* JS 运行时生成的节点不带 scope 类:结果区样式一律 :global(.results 类名本组件私有) */
|
||
:global(.results a) {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 2px;
|
||
padding: var(--space-2) var(--space-3);
|
||
border-radius: 8px;
|
||
text-decoration: none;
|
||
}
|
||
:global(.results a:hover) { background: var(--bg-soft); }
|
||
:global(.results strong) { color: var(--ink-1); font-size: 14px; }
|
||
:global(.results span) { font-size: 12px; color: var(--ink-3); }
|
||
:global(.results mark) { background: var(--accent-soft); }
|
||
:global(.results .empty) { padding: var(--space-3); font-size: 13px; color: var(--ink-3); }
|
||
</style>
|