1use std::future::Future;
6
7use leptos::prelude::*;
8
9use crate::components::mascot::Mascot;
10use crate::components::ui::{Icon, IconName};
11
12const DEBOUNCE_MS: u32 = 150;
14const PROGRESS_DELAY_MS: u32 = 300;
15const EXIT_MS: u32 = 120;
17
18#[derive(Clone, Copy, PartialEq)]
21pub struct SearchPaletteHandle {
22 pub(crate) open: RwSignal<bool>,
23}
24
25impl SearchPaletteHandle {
26 pub fn open(self) {
29 #[cfg(target_arch = "wasm32")]
30 crate::bridges::globals::skip_view_transition();
31 self.open.set(true);
32 }
33}
34
35pub fn use_search_palette_provider() -> SearchPaletteHandle {
37 let handle = SearchPaletteHandle {
38 open: RwSignal::new(false),
39 };
40 provide_context(handle);
41 handle
42}
43
44pub fn use_search_palette() -> Option<SearchPaletteHandle> {
46 use_context::<SearchPaletteHandle>()
47}
48
49pub fn contains_ci(text: &str, query: &str) -> bool {
51 let q = query.trim();
52 q.is_empty() || text.to_lowercase().contains(&q.to_lowercase())
53}
54
55pub fn highlight(text: &str, query: &str) -> Vec<(String, bool)> {
57 let q: Vec<char> = query.trim().to_lowercase().chars().collect();
58 let chars: Vec<char> = text.chars().collect();
59 if q.is_empty() {
60 return vec![(text.to_string(), false)];
61 }
62 let lower: Vec<char> = chars
63 .iter()
64 .map(|c| c.to_lowercase().next().unwrap_or(*c))
65 .collect();
66 let mut out = Vec::new();
67 let mut plain = String::new();
68 let mut i = 0;
69 while i < chars.len() {
70 if i + q.len() <= chars.len() && lower[i..i + q.len()] == q[..] {
71 if !plain.is_empty() {
72 out.push((std::mem::take(&mut plain), false));
73 }
74 out.push((chars[i..i + q.len()].iter().collect(), true));
75 i += q.len();
76 } else {
77 plain.push(chars[i]);
78 i += 1;
79 }
80 }
81 if !plain.is_empty() {
82 out.push((plain, false));
83 }
84 out
85}
86
87pub fn snippet(text: &str, query: &str, radius: usize) -> String {
90 let chars: Vec<char> = text.chars().collect();
91 let q: Vec<char> = query.trim().to_lowercase().chars().collect();
92 let lower: Vec<char> = chars
93 .iter()
94 .map(|c| c.to_lowercase().next().unwrap_or(*c))
95 .collect();
96 let hit = (!q.is_empty())
97 .then(|| {
98 (0..chars.len()).find(|&i| i + q.len() <= chars.len() && lower[i..i + q.len()] == q[..])
99 })
100 .flatten()
101 .unwrap_or(0);
102 let start = hit.saturating_sub(radius);
103 let end = (hit + q.len() + radius).min(chars.len());
104 let mut out = String::new();
105 if start > 0 {
106 out.push('…');
107 }
108 out.extend(&chars[start..end]);
109 if end < chars.len() {
110 out.push('…');
111 }
112 out
113}
114
115pub fn viewport_center() -> (f64, f64) {
117 #[cfg(target_arch = "wasm32")]
118 {
119 web_sys::window()
120 .map(|w| {
121 let width = w.inner_width().ok().and_then(|v| v.as_f64()).unwrap_or(0.0);
122 let height = w
123 .inner_height()
124 .ok()
125 .and_then(|v| v.as_f64())
126 .unwrap_or(0.0);
127 (width / 2.0, height / 2.0)
128 })
129 .unwrap_or((0.0, 0.0))
130 }
131 #[cfg(not(target_arch = "wasm32"))]
132 (0.0, 0.0)
133}
134
135#[cfg(target_arch = "wasm32")]
137pub fn focus_by_id(element_id: &str) {
138 use wasm_bindgen::JsCast;
139 if let Some(el) = web_sys::window()
140 .and_then(|w| w.document())
141 .and_then(|d| d.get_element_by_id(element_id))
142 .and_then(|el| el.dyn_into::<web_sys::HtmlElement>().ok())
143 {
144 let _ = el.focus();
145 }
146}
147
148#[cfg_attr(not(target_arch = "wasm32"), allow(unused_variables))]
153pub fn use_palette_shortcut(handle: SearchPaletteHandle, enabled: bool) {
154 #[cfg(target_arch = "wasm32")]
155 {
156 use wasm_bindgen::JsCast;
157 let listener = window_event_listener(
158 leptos::ev::keydown,
159 move |event: leptos::ev::KeyboardEvent| {
160 if !enabled
161 || !(event.meta_key() || event.ctrl_key())
162 || event.key().to_lowercase() != "k"
163 {
164 return;
165 }
166 let editable = event
167 .target()
168 .and_then(|t| t.dyn_into::<web_sys::HtmlElement>().ok())
169 .is_some_and(|el| {
170 el.is_content_editable()
171 || matches!(el.tag_name().as_str(), "INPUT" | "TEXTAREA" | "SELECT")
172 });
173 let palette_open = handle.open.get_untracked();
174 if editable && !palette_open {
175 return;
176 }
177 event.prevent_default();
178 if !palette_open {
179 handle.open();
180 }
181 },
182 );
183 on_cleanup(move || listener.remove());
184 }
185}
186
187#[derive(Clone, Copy, PartialEq)]
190pub struct PaletteState {
191 pub open: RwSignal<bool>,
192 pub query: RwSignal<String>,
193 pub active: RwSignal<usize>,
194 loading: RwSignal<bool>,
195 show_progress: RwSignal<bool>,
196 composing: RwSignal<bool>,
197 seq: RwSignal<u64>,
198 closing: RwSignal<bool>,
200 id_prefix: &'static str,
202 #[cfg(target_arch = "wasm32")]
204 previous_focus: StoredValue<Option<web_sys::HtmlElement>, LocalStorage>,
205}
206
207pub fn use_palette_state(id_prefix: &'static str) -> PaletteState {
212 let handle = expect_context::<SearchPaletteHandle>();
213 let state = PaletteState {
214 open: handle.open,
215 query: RwSignal::new(String::new()),
216 active: RwSignal::new(0_usize),
217 loading: RwSignal::new(false),
218 show_progress: RwSignal::new(false),
219 composing: RwSignal::new(false),
220 seq: RwSignal::new(0_u64),
221 closing: RwSignal::new(false),
222 id_prefix,
223 #[cfg(target_arch = "wasm32")]
224 previous_focus: StoredValue::new_local(None),
225 };
226
227 Effect::new(move |_| {
228 if !state.open.get() {
229 return;
230 }
231 state.closing.set(false);
232 #[cfg(target_arch = "wasm32")]
233 {
234 use wasm_bindgen::JsCast;
235 state.previous_focus.set_value(
236 web_sys::window()
237 .and_then(|w| w.document())
238 .and_then(|d| d.active_element())
239 .and_then(|el| el.dyn_into::<web_sys::HtmlElement>().ok()),
240 );
241 }
242 leptos::task::spawn_local(async move {
243 crate::utils::time::sleep_ms(0).await;
244 state.focus_input();
245 });
246 });
247 state
248}
249
250impl PaletteState {
251 pub fn focus_input(self) {
253 #[cfg(target_arch = "wasm32")]
254 focus_by_id(&format!("{}-input", self.id_prefix));
255 }
256
257 pub fn visible(self) -> bool {
259 self.open.get() || self.closing.get()
260 }
261
262 pub fn close(self, reset: impl FnOnce() + 'static) {
266 if !self.open.get_untracked() {
267 return;
268 }
269 self.seq.update(|seq| *seq += 1);
270 self.loading.set(false);
271 self.show_progress.set(false);
272 self.open.set(false);
273 self.closing.set(true);
274 #[cfg(target_arch = "wasm32")]
275 if let Some(Some(el)) = self.previous_focus.try_update_value(Option::take) {
276 let _ = el.focus();
277 }
278 leptos::task::spawn_local(async move {
279 crate::utils::time::sleep_ms(EXIT_MS).await;
280 if self.open.try_get_untracked().unwrap_or(true) {
282 return;
283 }
284 self.query.set(String::new());
285 self.active.set(0);
286 reset();
287 self.closing.set(false);
288 });
289 }
290
291 pub fn schedule_fetch<T, F, Fut>(
294 self,
295 results: RwSignal<Vec<T>>,
296 results_query: RwSignal<String>,
297 fetch: F,
298 ) where
299 T: Send + Sync + 'static,
300 F: FnOnce(String) -> Fut + 'static,
301 Fut: Future<Output = Option<Vec<T>>> + 'static,
302 {
303 self.seq.update(|seq| *seq += 1);
304 let id = self.seq.get_untracked();
305 let q = self.query.get_untracked().trim().to_string();
306 if q.is_empty() {
307 results.set(Vec::new());
308 results_query.set(String::new());
309 self.loading.set(false);
310 self.show_progress.set(false);
311 return;
312 }
313 leptos::task::spawn_local(async move {
314 crate::utils::time::sleep_ms(DEBOUNCE_MS).await;
315 if self.seq.try_get_untracked() != Some(id)
316 || self.composing.try_get_untracked().unwrap_or(true)
317 {
318 return;
319 }
320 self.loading.set(true);
321 leptos::task::spawn_local(async move {
322 crate::utils::time::sleep_ms(PROGRESS_DELAY_MS).await;
323 if self.seq.try_get_untracked() == Some(id)
324 && self.loading.try_get_untracked().unwrap_or(false)
325 {
326 self.show_progress.set(true);
327 }
328 });
329 let res = fetch(q.clone()).await;
330 if self.seq.try_get_untracked() != Some(id) {
331 return;
332 }
333 if let Some(items) = res {
334 results.set(items);
335 results_query.set(q);
336 }
337 self.loading.set(false);
338 self.show_progress.set(false);
339 });
340 }
341}
342
343#[derive(Clone, Debug, PartialEq)]
345pub struct PaletteEntry {
346 pub icon: IconName,
347 pub title: String,
348 pub summary: Option<String>,
350 pub meta: String,
352 pub mono_meta: bool,
353 pub highlight_hits: bool,
355}
356
357#[derive(Clone, Debug, PartialEq)]
359pub struct PaletteGroup {
360 pub label: &'static str,
361 pub entries: Vec<PaletteEntry>,
362}
363
364fn highlighted(text: &str, query: &str) -> impl IntoView {
366 highlight(text, query)
367 .into_iter()
368 .map(|(text, hit)| {
369 if hit {
370 view! { <mark>{text}</mark> }.into_any()
371 } else {
372 text.into_any()
373 }
374 })
375 .collect_view()
376}
377
378#[component]
380fn PaletteRow(
381 id: String,
382 entry: PaletteEntry,
383 query: String,
384 #[prop(into)] selected: Signal<bool>,
385 #[prop(into)] on_hover: Callback<()>,
386 #[prop(into)] on_pick: Callback<bool>,
387) -> impl IntoView {
388 let highlight_query = if entry.highlight_hits {
389 query
390 } else {
391 String::new()
392 };
393 let meta_class = if entry.mono_meta {
394 "palette-option-meta font-mono"
395 } else {
396 "palette-option-meta"
397 };
398 let title = highlighted(&entry.title, &highlight_query);
399 let summary = entry.summary.map(|summary| {
400 view! { <span class="palette-option-summary">{highlighted(&summary, &highlight_query)}</span> }
401 });
402 let meta = entry.meta;
403 view! {
404 <div
405 id=id
406 class="palette-option"
407 role="option"
408 aria-selected=move || selected.get().to_string()
409 on:mousemove=move |_| {
411 if !selected.get_untracked() {
412 on_hover.run(());
413 }
414 }
415 on:mousedown=move |e| e.prevent_default()
416 on:click=move |e| on_pick.run(e.meta_key() || e.ctrl_key())
417 >
418 <span class="palette-option-tile">
419 <Icon name=entry.icon size=15/>
420 </span>
421 <span class="palette-option-text">
422 <span class="palette-option-title">{title}</span>
423 {summary}
424 </span>
425 {move || {
426 if selected.get() {
427 view! { <kbd class="palette-enter">"↵"</kbd> }.into_any()
428 } else if !meta.is_empty() {
429 view! { <span class=meta_class>{meta.clone()}</span> }.into_any()
430 } else {
431 ().into_any()
432 }
433 }}
434 </div>
435 }
436}
437
438#[component]
458pub(crate) fn PaletteShell(
459 state: PaletteState,
460 label: &'static str,
461 input_label: &'static str,
462 placeholder: &'static str,
463 list_label: &'static str,
464 enter_hint: &'static str,
465 empty_hint: &'static str,
466 #[prop(into)] groups: Signal<Vec<PaletteGroup>>,
467 #[prop(into)] waiting: Signal<bool>,
468 #[prop(optional)] clearable: bool,
469 #[prop(optional, into)] toolbar: ViewFn,
470 #[prop(optional, into)] empty_action: ViewFn,
471 #[prop(optional, into)] footer_action: ViewFn,
472 #[prop(into)] on_query_change: Callback<()>,
473 #[prop(into)] on_pick: Callback<(usize, bool)>,
474 #[prop(into)] on_close: Callback<()>,
475) -> impl IntoView {
476 let query = state.query;
477 let active = state.active;
478 let composing = state.composing;
479 let prefix = state.id_prefix;
480 let closing = move || !state.open.get();
481
482 let has_query = move || !query.read().trim().is_empty();
483 let total =
484 Memo::new(move |_| groups.with(|g| g.iter().map(|g| g.entries.len()).sum::<usize>()));
485 let active_idx = move || {
486 let total = total.get();
487 if total == 0 {
488 0
489 } else {
490 active.get().min(total - 1)
491 }
492 };
493 let no_results =
494 move || has_query() && total.get() == 0 && !(waiting.get() && state.loading.get());
495
496 let reset_query = move || {
498 query.set(String::new());
499 active.set(0);
500 on_query_change.run(());
501 };
502
503 let input_ref = NodeRef::<leptos::html::Input>::new();
507 Effect::new(move |_| {
508 let input = input_ref.get();
509 #[cfg(target_arch = "wasm32")]
510 if let Some(input) = input {
511 request_animation_frame(move || {
512 if state.open.try_get_untracked().unwrap_or(false) {
513 let _ = input.focus();
514 }
515 });
516 }
517 #[cfg(not(target_arch = "wasm32"))]
518 let _ = input;
519 });
520
521 view! {
522 <div
523 class=move || if closing() { "palette-scrim is-closing" } else { "palette-scrim" }
524 on:click=move |_| on_close.run(())
525 ></div>
526 <div
527 class=move || if closing() { "palette is-closing" } else { "palette" }
528 role="dialog"
529 aria-modal="true"
530 aria-label=label
531 data-ygg-modal-panel="true"
533 data-ygg-modal-open=move || if closing() { "false" } else { "true" }
534 inert=move || closing().then_some("true")
535 on:keydown=move |e| {
536 if e.key() == "Tab" {
537 #[cfg(target_arch = "wasm32")]
538 crate::components::ui::trap_tab(&e, label);
539 }
540 }
541 >
542 <div class="palette-input-row">
543 <Icon name=IconName::Search size=18 class="palette-input-icon"/>
544 <input
545 id=format!("{prefix}-input")
546 node_ref=input_ref
547 class="palette-input"
548 type="text"
549 role="combobox"
550 aria-label=input_label
551 aria-expanded="true"
552 aria-controls=format!("{prefix}-list")
553 aria-activedescendant=move || {
554 (total.get() > 0).then(|| format!("{prefix}-opt-{}", active_idx()))
555 }
556 aria-autocomplete="list"
557 autocomplete="off"
558 spellcheck="false"
559 enterkeyhint="go"
560 maxlength="100"
561 placeholder=placeholder
562 prop:value=move || query.get()
563 on:input=move |e| {
564 query.set(event_target_value(&e));
565 active.set(0);
566 if !composing.get_untracked() {
567 on_query_change.run(());
568 }
569 }
570 on:compositionstart=move |_| composing.set(true)
571 on:compositionend=move |_| {
572 composing.set(false);
573 on_query_change.run(());
574 }
575 on:keydown=move |e| {
576 if crate::components::ui::ime_composing(composing.get_untracked(), &e) {
578 return;
579 }
580 match e.key().as_str() {
581 key @ ("ArrowDown" | "ArrowUp") => {
582 e.prevent_default();
583 let total = total.get_untracked();
584 if total > 0 {
585 let delta: i32 = if key == "ArrowDown" { 1 } else { -1 };
586 active.set(
587 (active_idx() as i32 + delta).rem_euclid(total as i32) as usize,
588 );
589 }
590 }
591 "Enter" => {
592 e.prevent_default();
593 if total.get_untracked() > 0 {
594 on_pick.run((active_idx(), e.meta_key() || e.ctrl_key()));
595 }
596 }
597 "Escape" => {
598 e.prevent_default();
599 if query.read_untracked().is_empty() {
600 on_close.run(());
601 } else {
602 reset_query();
603 }
604 }
605 _ => {}
606 }
607 }
608 />
609 {move || {
610 state
611 .show_progress
612 .get()
613 .then(|| {
614 view! {
615 <span class="palette-progress" role="status" aria-label="正在搜索">
616 <span></span>
617 <span></span>
618 <span></span>
619 </span>
620 }
621 })
622 }}
623 {move || {
624 (clearable && has_query())
625 .then(|| {
626 view! {
627 <button
628 class="btn btn-xs btn-icon btn-ghost"
629 type="button"
630 aria-label="清除输入"
631 on:click=move |_| {
632 reset_query();
633 state.focus_input();
634 }
635 >
636 <Icon name=IconName::X size=14/>
637 </button>
638 }
639 })
640 }}
641 <button class="palette-esc" type="button" on:click=move |_| on_close.run(())>
642 <span class="palette-esc-key">"Esc"</span>
643 <span class="palette-esc-cancel">"取消"</span>
644 </button>
645 </div>
646 {toolbar.run()}
647 <div
648 id=format!("{prefix}-list")
649 class="palette-list"
650 role="listbox"
651 aria-label=list_label
652 >
653 {move || {
654 let q = query.get();
655 let mut index = 0_usize;
656 groups
657 .get()
658 .into_iter()
659 .map(|group| {
660 let rows = group
661 .entries
662 .into_iter()
663 .map(|entry| {
664 let i = index;
665 index += 1;
666 view! {
667 <PaletteRow
668 id=format!("{prefix}-opt-{i}")
669 entry=entry
670 query=q.clone()
671 selected=Signal::derive(move || i == active_idx())
672 on_hover=move |_| active.set(i)
673 on_pick=move |new_tab: bool| on_pick.run((i, new_tab))
674 />
675 }
676 })
677 .collect_view();
678 view! {
679 <div role="group" aria-label=group.label>
680 <p class="palette-group-label">{group.label}</p>
681 {rows}
682 </div>
683 }
684 })
685 .collect_view()
686 }}
687 {move || {
688 no_results()
689 .then(|| {
690 view! {
691 <div class="palette-empty">
692 {Mascot::Panic.img("mascot palette-empty-mascot")}
693 <p class="palette-empty-title">
694 {move || format!("没有找到「{}」", query.read().trim())}
695 </p>
696 <p class="palette-empty-hint">{empty_hint}</p>
697 {empty_action.run()}
698 </div>
699 }
700 })
701 }}
702 </div>
703 <div class="palette-footer">
704 <div class="palette-hints">
705 <span>
706 <kbd>"↑"</kbd>
707 <kbd>"↓"</kbd>
708 "选择"
709 </span>
710 <span>
711 <kbd>"↵"</kbd>
712 {enter_hint}
713 </span>
714 <span>
715 <kbd>"Esc"</kbd>
716 "关闭"
717 </span>
718 </div>
719 {footer_action.run()}
720 </div>
721 </div>
722 }
723}
724
725#[cfg(test)]
726mod tests {
727 use super::*;
728
729 #[test]
730 fn highlight_marks_every_case_insensitive_hit() {
731 assert_eq!(
732 highlight("Rust 与 rust", "RUST"),
733 vec![
734 ("Rust".into(), true),
735 (" 与 ".into(), false),
736 ("rust".into(), true)
737 ]
738 );
739 assert_eq!(
740 highlight("源码剖析", "剖析"),
741 vec![("源码".into(), false), ("剖析".into(), true)]
742 );
743 assert_eq!(highlight("无命中", ""), vec![("无命中".into(), false)]);
744 }
745
746 #[test]
747 fn snippet_centers_on_the_first_hit_with_ellipses() {
748 let text = "一二三四五六七八九十命中词十一十二十三十四十五";
749 assert_eq!(snippet(text, "命中", 3), "…八九十命中词十一…");
750 assert_eq!(snippet("命中在开头", "命中", 2), "命中在开…");
751 assert_eq!(snippet("没有命中的一段话", "zz", 2), "没有命中…");
753 }
754
755 fn entry(title: &str) -> PaletteEntry {
756 PaletteEntry {
757 icon: IconName::FileText,
758 title: title.to_string(),
759 summary: None,
760 meta: "/x".to_string(),
761 mono_meta: true,
762 highlight_hits: true,
763 }
764 }
765
766 #[test]
767 fn shell_renders_dialog_combobox_and_grouped_options() {
768 let html = Owner::new().with(|| {
769 let handle = use_search_palette_provider();
770 handle.open.set(true);
771 let state = use_palette_state("palette");
772 state.query.set("ru".to_string());
773 view! {
774 <PaletteShell
775 state=state
776 label="搜索"
777 input_label="搜索文章、标签或页面"
778 placeholder="搜索…"
779 list_label="搜索结果"
780 enter_hint="打开"
781 empty_hint="无命中"
782 groups=vec![PaletteGroup { label: "文章", entries: vec![entry("Rust 笔记"), entry("Other")] }]
783 waiting=false
784 on_query_change=|_| {}
785 on_pick=|_| {}
786 on_close=|_| {}
787 />
788 }
789 .to_html()
790 });
791 let html = html.replace("<!>", "");
793 assert!(html.contains("role=\"dialog\""), "{html}");
794 assert!(html.contains("data-ygg-modal-open=\"true\""), "{html}");
795 assert!(html.contains("id=\"palette-input\""), "{html}");
796 assert!(html.contains("role=\"combobox\""), "{html}");
797 assert!(
798 html.contains("aria-activedescendant=\"palette-opt-0\""),
799 "{html}"
800 );
801 assert!(html.contains("id=\"palette-opt-0\""), "{html}");
802 assert!(html.contains("id=\"palette-opt-1\""), "{html}");
803 assert!(
805 html.contains("<kbd class=\"palette-enter\">↵</kbd>"),
806 "{html}"
807 );
808 assert!(html.contains("<mark>Ru</mark>"), "{html}");
809 }
810}