Skip to main content

yggdrasil/components/
search_palette.rs

1//! 搜索面板(⌘K):前台全站的即时跳转。
2//!
3//! 页头「搜索 ⌘K」与全局快捷键打开同一个面板。文章来自 `suggest_posts`(标题、
4//! 摘要、标签,最多 8 条),标签与页面在浏览器内过滤。空查询显示最近搜索与常用
5//! 跳转;有查询时按「文章 · 标签 · 跳转」分组,命中词用 `<mark>` 标出。
6//! 样式见 styles/components/palette.css;行为细节见 docs/v1.0-重设计方案.md「搜索面板」。
7//! 公共状态、输入与对话框结构见 [crate::components::palette]。
8//!
9//! 分包:[`SearchPalette`][crate::components::search_palette::SearchPalette] 外壳(打开状态、快捷键、最近搜索与标签的预取)留在主包;
10//! 面板主体(分组构建、条目映射、选中动作与 `PaletteShell` 视图)在
11//! `search_palette_body`(`#[leptos::lazy]`)里,首次打开面板时才加载分包。
12
13use leptos::prelude::*;
14
15use crate::api::posts::{list_tags, suggest_posts, PostSuggestion};
16use crate::components::palette::{
17    contains_ci, snippet, use_palette_shortcut, use_palette_state, viewport_center, PaletteEntry,
18    PaletteGroup, PaletteShell, PaletteState, SearchPaletteHandle,
19};
20use crate::components::ui::{Icon, IconName};
21use crate::routes::Route;
22use crate::theme::{choose_theme, use_resolved_theme, use_theme, ResolvedTheme, Theme};
23
24/// 搜索范围。
25#[derive(Clone, Copy, Debug, PartialEq, Eq)]
26enum Scope {
27    All,
28    Posts,
29    Tags,
30    Pages,
31}
32
33impl Scope {
34    const ALL: [Scope; 4] = [Scope::All, Scope::Posts, Scope::Tags, Scope::Pages];
35
36    fn label(self) -> &'static str {
37        match self {
38            Scope::All => "全部",
39            Scope::Posts => "文章",
40            Scope::Tags => "标签",
41            Scope::Pages => "页面",
42        }
43    }
44}
45
46/// 面板中的一项。
47#[derive(Clone, Debug, PartialEq)]
48enum Item {
49    Post(PostSuggestion),
50    Tag {
51        name: String,
52        count: i64,
53    },
54    Page {
55        label: &'static str,
56        path: &'static str,
57    },
58    Recent(String),
59    Theme {
60        to: Theme,
61        label: &'static str,
62    },
63}
64
65#[derive(Clone, Debug, PartialEq)]
66struct Group {
67    label: &'static str,
68    items: Vec<Item>,
69}
70
71/// 可跳转的页面:标签与路径。
72const PAGES: [(&str, &str); 8] = [
73    ("首页", "/"),
74    ("笔记", "/notes"),
75    ("归档", "/archives"),
76    ("友链", "/friends"),
77    ("关于", "/about"),
78    ("写作指南", "/about/writing"),
79    ("更新日志", "/changelog"),
80    ("组件图鉴", "/about/components"),
81];
82
83/// 最近搜索在 localStorage 中的键与条数上限。
84#[cfg(target_arch = "wasm32")]
85const RECENT_KEY: &str = "yggdrasil-search-recent";
86const RECENT_LIMIT: usize = 5;
87
88/// 跳转页面的站内地址(`PAGES` 里的路径对应的 `Route`)。
89fn page_href(path: &str) -> String {
90    let route = match path {
91        "/notes" => Route::Notes,
92        "/archives" => Route::Archives,
93        "/friends" => Route::Friends,
94        "/about" => Route::About,
95        "/about/writing" => Route::WritingGuide,
96        "/changelog" => Route::Changelog,
97        "/about/components" => Route::ComponentShowcase,
98        _ => Route::Home,
99    };
100    route.to_string()
101}
102
103/// 各组的条数上限:全部范围下文章 5、标签 3、跳转 3;单一范围 8。
104fn group_limit(scope: Scope, group: Scope) -> usize {
105    match (scope, group) {
106        (Scope::All, Scope::Posts) => 5,
107        (Scope::All, _) => 3,
108        _ => 8,
109    }
110}
111
112/// 按查询与范围组织面板内容。`posts` 为建议接口对当前查询的结果。
113fn build_groups(
114    query: &str,
115    scope: Scope,
116    posts: &[PostSuggestion],
117    tags: &[(String, i64)],
118    recents: Option<&[String]>,
119    dark: bool,
120) -> Vec<Group> {
121    let q = query.trim();
122    let pages = |limit: usize| -> Vec<Item> {
123        PAGES
124            .iter()
125            .filter(|(label, path)| contains_ci(label, q) || contains_ci(path, q))
126            .take(limit)
127            .map(|(label, path)| Item::Page { label, path })
128            .collect()
129    };
130    let mut groups = Vec::new();
131    if q.is_empty() {
132        if let Some(recents) = recents.filter(|r| !r.is_empty()) {
133            groups.push(Group {
134                label: "最近搜索",
135                items: recents.iter().cloned().map(Item::Recent).collect(),
136            });
137        }
138        let mut jump = pages(4);
139        jump.push(if dark {
140            Item::Theme {
141                to: Theme::Light,
142                label: "切换到浅色",
143            }
144        } else {
145            Item::Theme {
146                to: Theme::Dark,
147                label: "切换到深色",
148            }
149        });
150        groups.push(Group {
151            label: "跳转",
152            items: jump,
153        });
154        return groups;
155    }
156    if matches!(scope, Scope::All | Scope::Posts) {
157        let items: Vec<Item> = posts
158            .iter()
159            .take(group_limit(scope, Scope::Posts))
160            .cloned()
161            .map(Item::Post)
162            .collect();
163        if !items.is_empty() {
164            groups.push(Group {
165                label: "文章",
166                items,
167            });
168        }
169    }
170    if matches!(scope, Scope::All | Scope::Tags) {
171        let items: Vec<Item> = tags
172            .iter()
173            .filter(|(name, _)| contains_ci(name, q))
174            .take(group_limit(scope, Scope::Tags))
175            .map(|(name, count)| Item::Tag {
176                name: name.clone(),
177                count: *count,
178            })
179            .collect();
180        if !items.is_empty() {
181            groups.push(Group {
182                label: "标签",
183                items,
184            });
185        }
186    }
187    if matches!(scope, Scope::All | Scope::Pages) {
188        let items = pages(group_limit(scope, Scope::Pages));
189        if !items.is_empty() {
190            groups.push(Group {
191                label: "跳转",
192                items,
193            });
194        }
195    }
196    groups
197}
198
199/// 有查询时各范围的命中数(用于范围标签)。
200fn scope_count(
201    scope: Scope,
202    query: &str,
203    posts: &[PostSuggestion],
204    tags: &[(String, i64)],
205) -> usize {
206    let q = query.trim();
207    match scope {
208        Scope::All => {
209            scope_count(Scope::Posts, q, posts, tags)
210                + scope_count(Scope::Tags, q, posts, tags)
211                + scope_count(Scope::Pages, q, posts, tags)
212        }
213        Scope::Posts => posts.len(),
214        Scope::Tags => tags.iter().filter(|(name, _)| contains_ci(name, q)).count(),
215        Scope::Pages => PAGES
216            .iter()
217            .filter(|(label, path)| contains_ci(label, q) || contains_ci(path, q))
218            .count(),
219    }
220}
221
222/// 读取最近搜索;存储不可用或内容损坏时返回 `None`(该组不显示)。
223fn read_recents() -> Option<Vec<String>> {
224    #[cfg(target_arch = "wasm32")]
225    {
226        let storage = web_sys::window()?.local_storage().ok()??;
227        let raw = storage.get_item(RECENT_KEY).ok()?;
228        match raw {
229            None => Some(Vec::new()),
230            Some(raw) => serde_json::from_slice::<Vec<String>>(raw.as_bytes()).ok(),
231        }
232    }
233    #[cfg(not(target_arch = "wasm32"))]
234    None
235}
236
237/// 把查询记入最近搜索:去重、置顶、最多保留 5 条。
238fn remember(recents: &[String], query: &str) -> Vec<String> {
239    let q = query.trim();
240    let mut next: Vec<String> = Vec::with_capacity(RECENT_LIMIT);
241    if !q.is_empty() {
242        next.push(q.to_string());
243    }
244    next.extend(recents.iter().filter(|r| r.as_str() != q).cloned());
245    next.truncate(RECENT_LIMIT);
246    next
247}
248
249#[cfg_attr(not(target_arch = "wasm32"), allow(unused_variables))]
250fn write_recents(recents: &[String]) {
251    #[cfg(target_arch = "wasm32")]
252    if let Some(storage) = web_sys::window().and_then(|w| w.local_storage().ok().flatten()) {
253        if let Ok(raw) = serde_json::to_string(recents) {
254            let _ = storage.set_item(RECENT_KEY, &raw);
255        }
256    }
257}
258
259/// 面板中的一行的展示内容。
260fn entry(item: &Item, query: &str) -> PaletteEntry {
261    match item {
262        Item::Post(post) => PaletteEntry {
263            icon: IconName::FileText,
264            title: post.title.clone(),
265            summary: post
266                .summary
267                .as_deref()
268                .filter(|s| !s.trim().is_empty())
269                .map(|s| snippet(s, query, 18)),
270            meta: post
271                .published_at
272                .map(|d| crate::utils::time::ymd(&d, '.'))
273                .unwrap_or_default(),
274            mono_meta: true,
275            highlight_hits: true,
276        },
277        Item::Tag { name, count } => PaletteEntry {
278            icon: IconName::Tag,
279            title: name.clone(),
280            summary: None,
281            meta: format!("{count} 篇"),
282            mono_meta: false,
283            highlight_hits: true,
284        },
285        Item::Page { label, path } => PaletteEntry {
286            icon: IconName::CornerDownLeft,
287            title: label.to_string(),
288            summary: None,
289            meta: path.to_string(),
290            mono_meta: true,
291            highlight_hits: true,
292        },
293        Item::Recent(term) => PaletteEntry {
294            icon: IconName::RotateCcwClock,
295            title: term.clone(),
296            summary: None,
297            meta: String::new(),
298            mono_meta: false,
299            highlight_hits: false,
300        },
301        Item::Theme { label, .. } => PaletteEntry {
302            icon: IconName::Moon,
303            title: label.to_string(),
304            summary: None,
305            meta: "外观".to_string(),
306            mono_meta: false,
307            highlight_hits: false,
308        },
309    }
310}
311
312/// 在新标签页打开站内地址(仅 wasm)。
313#[cfg_attr(not(target_arch = "wasm32"), allow(unused_variables))]
314fn open_in_new_tab(href: &str) {
315    #[cfg(target_arch = "wasm32")]
316    if let Some(window) = web_sys::window() {
317        let _ = window.open_with_url_and_target(href, "_blank");
318    }
319}
320
321/// 外壳与分包主体共享的面板数据信号(全部是 `Copy` 句柄)。
322#[derive(Clone, Copy)]
323struct SearchData {
324    scope: RwSignal<Scope>,
325    posts: RwSignal<Vec<PostSuggestion>>,
326    /// `posts` 对应的查询。
327    posts_query: RwSignal<String>,
328    /// 全部标签(名称、已发布文章数);尚未加载为 `None`。
329    tags: RwSignal<Option<Vec<(String, i64)>>>,
330    /// 最近搜索;存储不可用为 `None`。
331    recents: RwSignal<Option<Vec<String>>>,
332}
333
334/// 面板主体:分组、条目、选中动作与对话框视图。分包函数,首次调用时加载分包。
335///
336/// 参数全部是 `Copy` 句柄,由 [`SearchPalette`] 外壳在打开时传入。
337#[leptos::lazy]
338fn search_palette_body(
339    palette: PaletteState,
340    data: SearchData,
341    theme: RwSignal<Theme>,
342    resolved: Memo<ResolvedTheme>,
343) -> AnyView {
344    let SearchData {
345        scope,
346        posts,
347        posts_query,
348        tags,
349        recents,
350    } = data;
351    let query = palette.query;
352    let active = palette.active;
353
354    let close = move || {
355        // 淡出结束后复位,下次打开从空白开始(显示最近搜索与跳转)。
356        palette.close(move || {
357            scope.set(Scope::All);
358            posts.set(Vec::new());
359            posts_query.set(String::new());
360        });
361    };
362
363    let schedule_fetch = move || {
364        // 失败或被限流时保留上一次结果,不闪空列表。
365        palette.schedule_fetch(posts, posts_query, |q| async move {
366            suggest_posts(q).await.ok().map(|res| res.posts)
367        })
368    };
369
370    let has_query = move || !query.read().trim().is_empty();
371    // 建议结果属于更早的查询时仍先显示(加载中保留上一次结果)。
372    let groups = Memo::new(move |_| {
373        let q = query.get();
374        let scope = scope.get();
375        let dark = resolved.get() == ResolvedTheme::Dark;
376        posts.with(|posts| {
377            tags.with(|tags| {
378                recents.with(|recents| {
379                    build_groups(
380                        &q,
381                        scope,
382                        posts,
383                        tags.as_deref().unwrap_or_default(),
384                        recents.as_deref(),
385                        dark,
386                    )
387                })
388            })
389        })
390    });
391    let view_groups = Signal::derive(move || {
392        let q = query.get();
393        groups.with(|groups| {
394            groups
395                .iter()
396                .map(|g| PaletteGroup {
397                    label: g.label,
398                    entries: g.items.iter().map(|item| entry(item, &q)).collect(),
399                })
400                .collect::<Vec<_>>()
401        })
402    });
403    let waiting = Signal::derive(move || posts_query.read().as_str() != query.read().trim());
404
405    let remember_query = move |current: &str| {
406        let next = recents.with_untracked(|r| remember(r.as_deref().unwrap_or_default(), current));
407        write_recents(&next);
408    };
409
410    let activate = move |item: Item, new_tab: bool| {
411        let current = query.get_untracked();
412        match item {
413            Item::Recent(term) => {
414                query.set(term);
415                active.set(0);
416                schedule_fetch();
417            }
418            Item::Theme { to, .. } => {
419                close();
420                choose_theme(theme, to, viewport_center());
421            }
422            Item::Post(post) => {
423                remember_query(&current);
424                close();
425                let href = Route::PostDetail { slug: post.slug }.to_string();
426                if new_tab {
427                    open_in_new_tab(&href);
428                } else {
429                    crate::navigation::push(&href);
430                }
431            }
432            Item::Tag { name, .. } => {
433                remember_query(&current);
434                close();
435                crate::navigation::push(&Route::TagDetail { tag: name }.to_string());
436            }
437            Item::Page { path, .. } => {
438                close();
439                crate::navigation::push(&page_href(path));
440            }
441        }
442    };
443
444    let full_text_search = move || {
445        let q = query.get_untracked().trim().to_string();
446        close();
447        crate::navigation::push(&Route::Search { q }.to_string());
448    };
449
450    view! {
451        <PaletteShell
452            state=palette
453            label="搜索"
454            input_label="搜索文章、标签或页面"
455            placeholder="搜索文章、标签,或跳转到页面…"
456            list_label="搜索结果"
457            enter_hint="打开"
458            empty_hint="标题、摘要与标签里都没有命中,试试更短的关键词,或去全文搜索。"
459            groups=view_groups
460            waiting=waiting
461            clearable=true
462            on_query_change=move |_| schedule_fetch()
463            on_pick=move |(index, new_tab): (usize, bool)| {
464                let item = groups
465                    .with_untracked(|gs| gs.iter().flat_map(|g| g.items.iter()).nth(index).cloned());
466                if let Some(item) = item {
467                    activate(item, new_tab);
468                }
469            }
470            on_close=move |_| close()
471            toolbar=move || {
472                view! {
473                    <div class="palette-scopes" role="tablist" aria-label="搜索范围">
474                        {Scope::ALL
475                            .into_iter()
476                            .map(|s| {
477                                view! {
478                                    <button
479                                        class="palette-scope"
480                                        type="button"
481                                        role="tab"
482                                        aria-selected=move || (scope.get() == s).to_string()
483                                        on:click=move |_| {
484                                            scope.set(s);
485                                            active.set(0);
486                                            palette.focus_input();
487                                        }
488                                    >
489                                        {s.label()}
490                                        {move || {
491                                            has_query()
492                                                .then(|| {
493                                                    view! {
494                                                        <span class="palette-scope-count">
495                                                            {move || {
496                                                                let q = query.get();
497                                                                posts
498                                                                    .with(|posts| {
499                                                                        tags.with(|tags| {
500                                                                            scope_count(
501                                                                                s,
502                                                                                &q,
503                                                                                posts,
504                                                                                tags.as_deref().unwrap_or_default(),
505                                                                            )
506                                                                        })
507                                                                    })
508                                                            }}
509                                                        </span>
510                                                    }
511                                                })
512                                        }}
513                                    </button>
514                                }
515                            })
516                            .collect_view()}
517                    </div>
518                }
519            }
520            empty_action=move || {
521                view! {
522                    <button
523                        class="btn btn-sm btn-secondary mt-4"
524                        type="button"
525                        on:click=move |_| full_text_search()
526                    >
527                        "在全文中搜索"
528                        <Icon name=IconName::ArrowRight size=14/>
529                    </button>
530                }
531            }
532            footer_action=move || {
533                // ViewFn 只渲染一次:用内层闭包让按钮随查询词出现/消失。
534                move || {
535                    has_query()
536                        .then(|| {
537                            view! {
538                                <button
539                                    class="palette-full-text"
540                                    type="button"
541                                    on:click=move |_| full_text_search()
542                                >
543                                    {move || format!("全文搜索「{}」", query.read().trim())}
544                                    <Icon name=IconName::ArrowRight size=13/>
545                                </button>
546                            }
547                        })
548                }
549            }
550        />
551    }
552    .into_any()
553}
554
555/// 搜索面板。挂在前台布局里一次,由 [`SearchPaletteHandle`] 打开。
556///
557/// 外壳只维护打开状态、最近搜索与标签预取;面板内容首次打开时才从分包加载
558/// ([`search_palette_body`]),关闭淡出结束后卸载。
559///
560/// Props:
561/// - `shortcut`:是否注册全局 ⌘K / Ctrl+K(图鉴演示实例关闭,避免与正式面板同时打开)
562#[component]
563pub fn SearchPalette(#[prop(default = true)] shortcut: bool) -> impl IntoView {
564    use_palette_shortcut(expect_context::<SearchPaletteHandle>(), shortcut);
565    let palette = use_palette_state("palette");
566    let open = palette.open;
567
568    let data = SearchData {
569        scope: RwSignal::new(Scope::All),
570        posts: RwSignal::new(Vec::new()),
571        posts_query: RwSignal::new(String::new()),
572        tags: RwSignal::new(None),
573        recents: RwSignal::new(None),
574    };
575    let theme = use_theme();
576    let resolved = use_resolved_theme();
577
578    // 打开:读最近搜索、首次加载标签。
579    Effect::new(move |_| {
580        if !open.get() {
581            return;
582        }
583        data.recents.set(read_recents());
584        if data.tags.read_untracked().is_none() {
585            leptos::task::spawn_local(async move {
586                if let Ok(res) = list_tags().await {
587                    let _ = data.tags.try_set(Some(
588                        res.tags
589                            .into_iter()
590                            .filter(|t| t.post_count > 0)
591                            .map(|t| (t.name, t.post_count))
592                            .collect(),
593                    ));
594                }
595            });
596        }
597    });
598
599    view! {
600        <Suspense fallback=|| ()>
601            {move || {
602                palette
603                    .visible()
604                    .then(|| {
605                        Suspend::new(async move {
606                            search_palette_body(palette, data, theme, resolved).await
607                        })
608                    })
609            }}
610        </Suspense>
611    }
612}
613
614#[cfg(test)]
615mod tests {
616    use super::*;
617
618    fn post(slug: &str, title: &str) -> PostSuggestion {
619        PostSuggestion {
620            slug: slug.into(),
621            title: title.into(),
622            summary: None,
623            published_at: None,
624            tags: Vec::new(),
625        }
626    }
627
628    #[test]
629    fn empty_query_shows_recents_and_jumps() {
630        let recents = vec!["waker".to_string()];
631        let groups = build_groups("", Scope::All, &[], &[], Some(&recents), false);
632        assert_eq!(groups[0].label, "最近搜索");
633        assert_eq!(groups[1].label, "跳转");
634        assert_eq!(
635            groups[1].items.last(),
636            Some(&Item::Theme {
637                to: Theme::Dark,
638                label: "切换到深色"
639            })
640        );
641        // 存储不可用时不显示最近搜索。
642        assert_eq!(
643            build_groups("", Scope::All, &[], &[], None, true)[0].label,
644            "跳转"
645        );
646    }
647
648    #[test]
649    fn groups_respect_scope_limits() {
650        let posts: Vec<_> = (0..8).map(|i| post(&format!("p{i}"), "关于源码")).collect();
651        let tags: Vec<_> = (0..5).map(|i| (format!("关于{i}"), 1)).collect();
652        let all = build_groups("关于", Scope::All, &posts, &tags, None, false);
653        assert_eq!(
654            all.iter()
655                .map(|g| (g.label, g.items.len()))
656                .collect::<Vec<_>>(),
657            [("文章", 5), ("标签", 3), ("跳转", 1)]
658        );
659        let only_posts = build_groups("关于", Scope::Posts, &posts, &tags, None, false);
660        assert_eq!(only_posts.len(), 1);
661        assert_eq!(only_posts[0].items.len(), 8);
662        assert_eq!(scope_count(Scope::All, "关于", &posts, &tags), 8 + 5 + 1);
663    }
664
665    #[test]
666    fn remember_dedupes_and_caps_recents() {
667        let recents: Vec<String> = ["a", "b", "c", "d", "e"]
668            .iter()
669            .map(|s| s.to_string())
670            .collect();
671        assert_eq!(remember(&recents, " c "), ["c", "a", "b", "d", "e"]);
672        assert_eq!(remember(&recents, "f"), ["f", "a", "b", "c", "d"]);
673        assert_eq!(remember(&recents, " "), ["a", "b", "c", "d", "e"]);
674    }
675
676    #[test]
677    fn page_routes_cover_every_jump_target() {
678        for (_, path) in PAGES {
679            assert_eq!(page_href(path), *path, "{path}");
680        }
681    }
682}