Skip to main content

yggdrasil/pages/components_showcase/
browser_previews.rs

1//! 图鉴中的浏览器实例。每个预览拥有独立 DOM 宿主,正式页面与这里复用同一 JS 库。
2
3use dioxus::prelude::*;
4
5#[cfg(target_arch = "wasm32")]
6use crate::bridges::library::{library_ready, use_browser_library, LibraryLoadError};
7
8#[cfg(target_arch = "wasm32")]
9const TIPTAP_SAMPLE: &str = "# 一页正在生长的笔记\n\n选中这句话,可以试试加粗或链接。\n\n- 编辑正文\n- 切换 Markdown 源码\n\n> 图鉴里的修改只留在本页。";
10#[cfg(target_arch = "wasm32")]
11const TERMINAL_STDOUT: &str = "\x1b[32m✓\x1b[0m 正在整理文章样例…\n已读取 3 个本地条目\n这是一段较长的输出,用于观察终端在窄屏下如何换行和滚动:Yggdrasil showcase terminal preview output line.\n";
12#[cfg(target_arch = "wasm32")]
13const TERMINAL_STDERR: &str = "提示:这只是固定输出示例。\n";
14
15fn host_id(slug: &str, detail: bool) -> String {
16    format!(
17        "showcase-browser-{slug}-{}",
18        if detail { "detail" } else { "card" }
19    )
20}
21
22#[cfg(any(target_arch = "wasm32", test))]
23fn code_sample(language: &str) -> &'static str {
24    match language {
25        "rust" => "fn main() {\n    println!(\"让想法生根\");\n}\n",
26        "sql" => "SELECT title, published_at\nFROM posts\nORDER BY published_at DESC\nLIMIT 3;\n",
27        _ => "def greet(name):\n    return f'你好,{name}'\n\nprint(greet('Yggdrasil'))\n",
28    }
29}
30
31/// 按稳定 slug 分派;卡片上的重型库只在首次进入视口后加载。
32pub(super) fn preview(slug: &str, detail: bool) -> Option<Element> {
33    match slug {
34        "tiptap-editor" | "code-mirror-editor" | "xterm-terminal" | "lightbox" => {
35            Some(rsx! { BrowserPreview { slug: slug.to_string(), detail } })
36        }
37        _ => None,
38    }
39}
40
41#[component]
42fn BrowserPreview(slug: String, detail: bool) -> Element {
43    let lightbox = slug == "lightbox";
44    #[cfg_attr(not(target_arch = "wasm32"), allow(unused_mut))]
45    let mut visible = use_signal(move || lightbox);
46    let visibility_id = host_id(&slug, detail);
47
48    #[cfg(target_arch = "wasm32")]
49    {
50        use std::{cell::RefCell, rc::Rc};
51        use wasm_bindgen::{closure::Closure, JsCast};
52
53        type Watch = (
54            web_sys::IntersectionObserver,
55            Closure<dyn FnMut(js_sys::Array, web_sys::IntersectionObserver)>,
56        );
57        let watcher = use_hook(|| Rc::new(RefCell::new(None::<Watch>)));
58        let watcher_for_effect = watcher.clone();
59        let target_id = visibility_id.clone();
60        use_effect(move || {
61            if let Some((observer, _callback)) = watcher_for_effect.borrow_mut().take() {
62                observer.disconnect();
63            }
64            if visible() {
65                return;
66            }
67            if detail {
68                visible.set(true);
69                return;
70            }
71            let Some(target) = web_sys::window()
72                .and_then(|window| window.document())
73                .and_then(|document| document.get_element_by_id(&target_id))
74            else {
75                return;
76            };
77            let callback = Closure::new(
78                move |entries: js_sys::Array, observer: web_sys::IntersectionObserver| {
79                    if entries.iter().any(|value| {
80                        value
81                            .unchecked_into::<web_sys::IntersectionObserverEntry>()
82                            .is_intersecting()
83                    }) {
84                        observer.disconnect();
85                        visible.set(true);
86                    }
87                },
88            );
89            if let Ok(observer) =
90                web_sys::IntersectionObserver::new(callback.as_ref().unchecked_ref())
91            {
92                observer.observe(&target);
93                watcher_for_effect
94                    .borrow_mut()
95                    .replace((observer, callback));
96            } else {
97                // 老浏览器没有 IntersectionObserver 时仍可打开预览。
98                visible.set(true);
99            }
100        });
101        use_drop(move || {
102            if let Some((observer, _callback)) = watcher.borrow_mut().take() {
103                observer.disconnect();
104            }
105        });
106    }
107
108    rsx! {
109        div {
110            class: if detail { "showcase-browser-preview showcase-browser-preview--detail" } else { "showcase-browser-preview" },
111            id: visibility_id,
112            if visible() {
113                match slug.as_str() {
114                    "tiptap-editor" => rsx! { TiptapPreview { detail } },
115                    "code-mirror-editor" => rsx! { CodeMirrorPreview { detail } },
116                    "xterm-terminal" => rsx! { XtermPreview { detail } },
117                    _ => rsx! { LightboxPreview { detail } },
118                }
119            } else {
120                div { class: "showcase-browser-placeholder", role: "status",
121                    if detail { "正在加载真实组件…" } else { "滚动到这里后加载真实组件…" }
122                }
123            }
124        }
125    }
126}
127
128#[component]
129fn TiptapPreview(detail: bool) -> Element {
130    let container_id = host_id("tiptap-host", detail);
131    #[cfg(target_arch = "wasm32")]
132    let mut error = use_signal(|| None::<String>);
133    #[cfg(target_arch = "wasm32")]
134    let mut ready = use_signal(|| false);
135    #[cfg(target_arch = "wasm32")]
136    let mut changed = use_signal(|| false);
137    #[cfg(target_arch = "wasm32")]
138    let mut retry = use_signal(|| 0_u32);
139
140    #[cfg(target_arch = "wasm32")]
141    {
142        use crate::bridges::tiptap::{self, EditorHandle};
143        use wasm_bindgen::{closure::Closure, JsValue};
144
145        let library = use_browser_library("tiptap", || true);
146        let mut handle: Signal<Option<EditorHandle>> = use_signal(|| None);
147        let mount_id = container_id.clone();
148        use_effect(move || {
149            let _attempt = retry();
150            if handle.read().is_some() || !library_ready(library) {
151                return;
152            }
153            let on_update = Closure::new(move |_markdown: String| changed.set(true));
154            let on_ready = Closure::new(|| {});
155            // 未注入上传和素材库回调:相关命令不会进入正式业务路径。
156            let on_upload = Closure::new(|_file: web_sys::File| {
157                js_sys::Promise::reject(&JsValue::from_str("图鉴不上传图片"))
158            });
159            let on_upload_event = Closure::new(|_event: tiptap::UploadEventJs| {});
160            let on_run = Closure::new(|_opts: tiptap::wasm::RunCodeOptsJs| {
161                js_sys::Promise::resolve(&JsValue::from_str("输出示例:图鉴不会执行代码。"))
162            });
163            let on_pick = Closure::new(|| {});
164            let opts = tiptap::EditorOptions::new();
165            opts.set_placeholder("在这里写一小段内容…");
166            opts.set_on_update(&on_update);
167            opts.set_on_ready(&on_ready);
168            opts.set_on_run_code(&on_run);
169            match tiptap::get_module().create(&mount_id, &opts) {
170                Ok(Some(instance)) => {
171                    instance.set_markdown(TIPTAP_SAMPLE);
172                    handle.set(Some(EditorHandle::new(
173                        instance,
174                        on_update,
175                        on_upload,
176                        on_ready,
177                        on_upload_event,
178                        on_run,
179                        on_pick,
180                    )));
181                    ready.set(true);
182                    error.set(None);
183                }
184                Ok(None) => error.set(Some("编辑器宿主未就绪".to_string())),
185                Err(reason) => error.set(Some(format!("编辑器初始化失败:{reason:?}"))),
186            }
187        });
188        use_drop(move || handle.set(None));
189
190        return rsx! {
191            div { class: "showcase-browser-toolbar",
192                span { "Tiptap · Markdown" }
193                if detail { small { "本地演示,内容不会保存" } }
194            }
195            div { class: "showcase-browser-host showcase-browser-host--tiptap", id: container_id }
196            if !ready() && error().is_none() && library_ready(library) { div { class: "showcase-browser-placeholder", "编辑器正在初始化…" } }
197            if !ready() && library.read().is_none() { div { class: "showcase-browser-placeholder", "编辑器库加载中…" } }
198            LibraryLoadError { library }
199            if let Some(message) = error() {
200                div { role: "alert", class: "showcase-browser-error", "{message}"
201                    button { r#type: "button", onclick: move |_| retry += 1, "重试" }
202                }
203            }
204            if detail && changed() { p { class: "showcase-browser-note", "内容已在本地修改;可用编辑器右上角按钮切换 Markdown 源码。" } }
205        };
206    }
207
208    #[cfg(not(target_arch = "wasm32"))]
209    rsx! {
210        div { class: "showcase-browser-toolbar",
211            span { "Tiptap · Markdown" }
212            if detail { small { "本地演示,内容不会保存" } }
213        }
214        div { class: "showcase-browser-host showcase-browser-host--tiptap", id: container_id }
215        div { class: "showcase-browser-placeholder", "编辑器库加载中…" }
216    }
217}
218
219#[component]
220fn CodeMirrorPreview(detail: bool) -> Element {
221    let container_id = host_id("code-mirror-host", detail);
222    #[cfg(target_arch = "wasm32")]
223    let mut language = use_signal(|| "python".to_string());
224    #[cfg(target_arch = "wasm32")]
225    let mut error = use_signal(|| None::<String>);
226    #[cfg(target_arch = "wasm32")]
227    let mut ready = use_signal(|| false);
228    #[cfg(target_arch = "wasm32")]
229    let mut shortcut_note = use_signal(|| false);
230    #[cfg(target_arch = "wasm32")]
231    let mut retry = use_signal(|| 0_u32);
232
233    #[cfg(target_arch = "wasm32")]
234    {
235        use crate::bridges::codemirror::{self, EditorHandle};
236        use crate::theme::{use_resolved_theme, ResolvedTheme};
237        use wasm_bindgen::closure::Closure;
238
239        let library = use_browser_library("codemirror", || true);
240        let resolved = use_resolved_theme();
241        let mut handle: Signal<Option<EditorHandle>> = use_signal(|| None);
242        let mount_id = container_id.clone();
243        use_effect(move || {
244            let _attempt = retry();
245            if handle.read().is_some() || !library_ready(library) {
246                return;
247            }
248            let on_change = Closure::new(|_value: String| {});
249            let on_ready = Closure::new(|| {});
250            let on_shortcut = Closure::new(move || shortcut_note.set(true));
251            let opts = codemirror::EditorOptions::new();
252            opts.set_language(&language());
253            opts.set_theme(if resolved() == ResolvedTheme::Dark {
254                "dark"
255            } else {
256                "light"
257            });
258            opts.set_value(code_sample(&language()));
259            opts.set_on_change(&on_change);
260            opts.set_on_ready(&on_ready);
261            opts.set_on_run_shortcut(&on_shortcut);
262            match codemirror::get_module().create(&mount_id, &opts) {
263                Ok(Some(instance)) => {
264                    handle.set(Some(EditorHandle::new(
265                        instance,
266                        on_change,
267                        on_ready,
268                        on_shortcut,
269                    )));
270                    ready.set(true);
271                    error.set(None);
272                }
273                Ok(None) => error.set(Some("代码编辑器宿主未就绪".to_string())),
274                Err(reason) => error.set(Some(format!("代码编辑器初始化失败:{reason:?}"))),
275            }
276        });
277        use_effect(move || {
278            let theme = if resolved() == ResolvedTheme::Dark {
279                "dark"
280            } else {
281                "light"
282            };
283            if let Some(editor) = handle.read().as_ref() {
284                editor.instance().set_theme(theme);
285            }
286        });
287        use_drop(move || handle.set(None));
288
289        return rsx! {
290            div { class: "showcase-browser-toolbar",
291                span { "CodeMirror · {language()}" }
292                if detail {
293                    label { "语言 "
294                        select { value: language(), onchange: move |event| {
295                            let next = event.value();
296                            language.set(next.clone());
297                            if let Some(editor) = handle.read().as_ref() {
298                                editor.instance().set_language(&next);
299                                editor.instance().set_value(code_sample(&next));
300                            }
301                        },
302                            option { value: "python", "Python" }
303                            option { value: "rust", "Rust" }
304                            option { value: "sql", "SQL" }
305                        }
306                    }
307                }
308            }
309            div { class: "showcase-browser-host showcase-browser-host--code", id: container_id }
310            if !ready() && error().is_none() && library_ready(library) { div { class: "showcase-browser-placeholder", "代码编辑器正在初始化…" } }
311            if !ready() && library.read().is_none() { div { class: "showcase-browser-placeholder", "代码编辑器库加载中…" } }
312            LibraryLoadError { library }
313            if let Some(message) = error() {
314                div { role: "alert", class: "showcase-browser-error", "{message}"
315                    button { r#type: "button", onclick: move |_| retry += 1, "重试" }
316                }
317            }
318            if detail {
319                p { class: "showcase-browser-note", "可编辑、撤销并切换语法高亮;代码只保存在当前预览。" }
320                if shortcut_note() { p { role: "status", class: "showcase-browser-note", "Ctrl/Cmd+Enter 在图鉴中不会执行代码。" } }
321            }
322        };
323    }
324
325    #[cfg(not(target_arch = "wasm32"))]
326    rsx! {
327        div { class: "showcase-browser-toolbar",
328            span { "CodeMirror · python" }
329            if detail {
330                label { "语言 "
331                    select { value: "python",
332                        option { value: "python", "Python" }
333                        option { value: "rust", "Rust" }
334                        option { value: "sql", "SQL" }
335                    }
336                }
337            }
338        }
339        div { class: "showcase-browser-host showcase-browser-host--code", id: container_id }
340        div { class: "showcase-browser-placeholder", "代码编辑器库加载中…" }
341        if detail { p { class: "showcase-browser-note", "可编辑、撤销并切换语法高亮;代码只保存在当前预览。" } }
342    }
343}
344
345#[component]
346fn XtermPreview(detail: bool) -> Element {
347    let container_id = host_id("xterm-host", detail);
348    #[cfg(target_arch = "wasm32")]
349    let mut error = use_signal(|| None::<String>);
350    #[cfg(target_arch = "wasm32")]
351    let mut ready = use_signal(|| false);
352    #[cfg(target_arch = "wasm32")]
353    let mut status = use_signal(|| "输出示例已就绪".to_string());
354    #[cfg(target_arch = "wasm32")]
355    let mut retry = use_signal(|| 0_u32);
356
357    #[cfg(target_arch = "wasm32")]
358    {
359        use crate::bridges::xterm::{self, TerminalHandle};
360        use crate::theme::{use_resolved_theme, ResolvedTheme};
361        use std::{cell::Cell, rc::Rc};
362        use wasm_bindgen::closure::Closure;
363
364        let library = use_browser_library("xterm", || true);
365        let resolved = use_resolved_theme();
366        let mut handle: Signal<Option<TerminalHandle>> = use_signal(|| None);
367        let generation = use_hook(|| Rc::new(Cell::new(0_u64)));
368        let mount_id = container_id.clone();
369        use_effect(move || {
370            let _attempt = retry();
371            if handle.read().is_some() || !library_ready(library) {
372                return;
373            }
374            let on_ready = Closure::new(|| {});
375            let opts = xterm::XtermOptions::new();
376            opts.set_theme(if resolved() == ResolvedTheme::Dark {
377                "dark"
378            } else {
379                "light"
380            });
381            opts.set_font_size(if detail { 13 } else { 11 });
382            opts.set_on_ready(&on_ready);
383            match xterm::get_module().create(&mount_id, &opts) {
384                Ok(Some(instance)) => {
385                    instance.write_all(TERMINAL_STDOUT, TERMINAL_STDERR);
386                    handle.set(Some(TerminalHandle::new(instance, on_ready)));
387                    ready.set(true);
388                    error.set(None);
389                }
390                Ok(None) => error.set(Some("终端宿主未就绪".to_string())),
391                Err(reason) => error.set(Some(format!("终端初始化失败:{reason:?}"))),
392            }
393        });
394        use_effect(move || {
395            let theme = if resolved() == ResolvedTheme::Dark {
396                "dark"
397            } else {
398                "light"
399            };
400            if let Some(terminal) = handle.read().as_ref() {
401                terminal.instance().set_theme(theme);
402            }
403        });
404        let drop_generation = generation.clone();
405        use_drop(move || {
406            drop_generation.set(drop_generation.get().wrapping_add(1));
407            handle.set(None);
408        });
409
410        let play_generation = generation.clone();
411        let pause_generation = generation.clone();
412        let clear_generation = generation.clone();
413        return rsx! {
414            div { class: "showcase-browser-toolbar",
415                span { "Xterm · 输出示例" }
416                if detail {
417                    div { class: "showcase-browser-actions",
418                        button { r#type: "button", disabled: !ready(), onclick: move |_| {
419                            let ticket = play_generation.get().wrapping_add(1);
420                            play_generation.set(ticket);
421                            if let Some(terminal) = handle.read().as_ref() {
422                                terminal.instance().clear();
423                            }
424                            status.set("播放中".to_string());
425                            let sequence = play_generation.clone();
426                            spawn(async move {
427                                for (stderr, text) in [
428                                    (false, "\x1b[32m✓\x1b[0m 正在整理文章样例…\n"),
429                                    (false, "已读取 3 个本地条目\n"),
430                                    (true, "提示:这只是固定输出示例。\n"),
431                                    (false, "这是一段较长的输出,用于观察终端在窄屏下如何换行和滚动:Yggdrasil showcase terminal preview output line.\n"),
432                                ] {
433                                    if sequence.get() != ticket { return; }
434                                    if let Some(terminal) = handle.read().as_ref() {
435                                        if stderr { terminal.instance().write_stderr(text); }
436                                        else { terminal.instance().write_stdout(text); }
437                                    }
438                                    crate::utils::time::sleep_ms(330).await;
439                                }
440                                if sequence.get() == ticket { status.set("播放完成,可重放".to_string()); }
441                            });
442                        }, "播放 / 重放" }
443                        button { r#type: "button", disabled: !ready(), onclick: move |_| {
444                            pause_generation.set(pause_generation.get().wrapping_add(1));
445                            status.set("已暂停".to_string());
446                        }, "暂停" }
447                        button { r#type: "button", disabled: !ready(), onclick: move |_| {
448                            clear_generation.set(clear_generation.get().wrapping_add(1));
449                            if let Some(terminal) = handle.read().as_ref() { terminal.instance().clear(); }
450                            status.set("已清屏".to_string());
451                        }, "清屏" }
452                    }
453                }
454            }
455            div { class: "showcase-browser-host showcase-browser-host--xterm", id: container_id }
456            if !ready() && error().is_none() && library_ready(library) { div { class: "showcase-browser-placeholder", "终端正在初始化…" } }
457            if !ready() && library.read().is_none() { div { class: "showcase-browser-placeholder", "终端库加载中…" } }
458            LibraryLoadError { library }
459            if let Some(message) = error() {
460                div { role: "alert", class: "showcase-browser-error", "{message}"
461                    button { r#type: "button", onclick: move |_| retry += 1, "重试" }
462                }
463            }
464            if detail { p { class: "showcase-browser-note", role: "status", "{status()}。固定数据,与编辑的代码无关。" } }
465        };
466    }
467
468    #[cfg(not(target_arch = "wasm32"))]
469    rsx! {
470        div { class: "showcase-browser-toolbar",
471            span { "Xterm · 输出示例" }
472            if detail {
473                div { class: "showcase-browser-actions",
474                    button { r#type: "button", disabled: true, "播放 / 重放" }
475                    button { r#type: "button", disabled: true, "暂停" }
476                    button { r#type: "button", disabled: true, "清屏" }
477                }
478            }
479        }
480        div { class: "showcase-browser-host showcase-browser-host--xterm", id: container_id }
481        div { class: "showcase-browser-placeholder", "终端库加载中…" }
482        if detail { p { class: "showcase-browser-note", role: "status", "输出示例已就绪。固定数据,与编辑的代码无关。" } }
483    }
484}
485
486#[component]
487fn LightboxPreview(detail: bool) -> Element {
488    let owner = host_id("lightbox-gallery", detail);
489    #[cfg(target_arch = "wasm32")]
490    {
491        let init_owner = owner.clone();
492        use_effect(move || {
493            if !detail {
494                return;
495            }
496            if let Some(window) = web_sys::window() {
497                crate::utils::js::invoke_optional_global(
498                    &window,
499                    "__initLightbox",
500                    &[format!("#{init_owner}").into()],
501                );
502            }
503        });
504        let cleanup_owner = owner.clone();
505        use_drop(move || {
506            if let Some(window) = web_sys::window() {
507                crate::utils::js::invoke_optional_global(
508                    &window,
509                    "__closeLightboxFor",
510                    &[cleanup_owner.into()],
511                );
512            }
513        });
514    }
515    rsx! {
516        div { class: "showcase-browser-lightbox-grid", id: owner.clone(), "data-showcase-lightbox-owner": owner,
517            img { src: "/images/empty-state/dog-camera.webp", alt: "相机里的两只小狗", loading: "lazy" }
518            img { src: "/images/xiantiaoxiaogou_02.webp", alt: "小狗的第二张照片", loading: "lazy" }
519            img { src: "/images/xiantiaoxiaogou_03.webp", alt: "小狗的第三张照片", loading: "lazy" }
520        }
521        if detail { p { class: "showcase-browser-note", "点击图片打开灯箱,可切换、缩放、旋转,并用 Esc 关闭。" } }
522    }
523}
524
525#[cfg(test)]
526mod tests {
527    use super::*;
528
529    #[test]
530    fn distinct_card_and_detail_hosts() {
531        for slug in [
532            "tiptap-host",
533            "code-mirror-host",
534            "xterm-host",
535            "lightbox-gallery",
536        ] {
537            assert_ne!(host_id(slug, false), host_id(slug, true));
538        }
539    }
540
541    #[test]
542    fn language_samples_are_real_documents() {
543        assert!(code_sample("python").contains("def greet"));
544        assert!(code_sample("rust").contains("fn main"));
545        assert!(code_sample("sql").contains("SELECT"));
546    }
547}