Files
blog/src/components/CommandPalette.astro
T
wait4xandGLM-5.3 8ec1712135 feat(5f): reader 模式+版式基底+首屏 tagline+图库质感(T4/T7/T8)
- 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
2026-11-01 09:09:27 +08:00

379 lines
13 KiB
Plaintext
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
// 命令面板:动作层(主题/语言/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>