Skip to main content

yggdrasil/pages/components_showcase/
browser_previews.rs

1//! 图鉴中的浏览器实例。每个预览拥有独立 DOM 宿主,正式页面与这里复用同一 JS 库。
2//!
3//! 水合一致:SSR 与水合首帧输出同一棵视图(库加载中占位);浏览器专属逻辑
4//! 只出现在 Effect / 事件 / `on_cleanup` 里,句柄放在 `StoredValue::new_local`,
5//! 卸载时 take 出来丢弃即触发 `Drop` → `destroy`。
6
7use leptos::prelude::*;
8
9use crate::bridges::library::{
10    library_ready, use_browser_library, BrowserLibrary, LibraryLoadError, LibraryResource,
11};
12
13#[cfg(target_arch = "wasm32")]
14const TIPTAP_SAMPLE: &str = "# 一页正在生长的笔记\n\n选中这句话,可以试试加粗或链接。\n\n- 编辑正文\n- 切换 Markdown 源码\n\n> 图鉴里的修改只留在本页。";
15#[cfg(target_arch = "wasm32")]
16const TERMINAL_STDOUT: &str = "\x1b[32m✓\x1b[0m 正在整理文章样例…\n已读取 3 个本地条目\n这是一段较长的输出,用于观察终端在窄屏下如何换行和滚动:Yggdrasil showcase terminal preview output line.\n";
17#[cfg(target_arch = "wasm32")]
18const TERMINAL_STDERR: &str = "提示:这只是固定输出示例。\n";
19
20fn host_id(slug: &str, detail: bool) -> String {
21    format!(
22        "showcase-browser-{slug}-{}",
23        if detail { "detail" } else { "card" }
24    )
25}
26
27#[cfg(any(target_arch = "wasm32", test))]
28fn code_sample(language: &str) -> &'static str {
29    match language {
30        "rust" => "fn main() {\n    println!(\"让想法生根\");\n}\n",
31        "sql" => "SELECT title, published_at\nFROM posts\nORDER BY published_at DESC\nLIMIT 3;\n",
32        _ => "def greet(name):\n    return f'你好,{name}'\n\nprint(greet('Yggdrasil'))\n",
33    }
34}
35
36/// 按稳定 slug 分派;卡片上的重型库只在首次进入视口后加载。
37pub(super) fn preview(slug: &str, detail: bool) -> Option<AnyView> {
38    match slug {
39        "tiptap-editor" | "code-mirror-editor" | "xterm-terminal" | "lightbox" => {
40            Some(view! { <BrowserPreview slug=slug.to_string() detail/> }.into_any())
41        }
42        _ => None,
43    }
44}
45
46/// 初始化失败提示条 + 重试按钮。
47fn error_alert(error: RwSignal<Option<String>>, retry: RwSignal<u32>) -> impl IntoView {
48    move || {
49        error.get().map(|message| {
50            view! {
51                <div role="alert" class="showcase-browser-error">
52                    {message}
53                    <button
54                        type="button"
55                        on:click=move |_| retry.update(|value| *value = value.wrapping_add(1))
56                    >
57                        "重试"
58                    </button>
59                </div>
60            }
61        })
62    }
63}
64
65/// 实例初始化中 / 库加载中的两条占位。
66fn loading_placeholders(
67    library: LibraryResource,
68    ready: RwSignal<bool>,
69    error: RwSignal<Option<String>>,
70    initializing: &'static str,
71    loading: &'static str,
72) -> impl IntoView {
73    view! {
74        {move || {
75            (!ready.get() && error.read().is_none() && library_ready(library))
76                .then(|| view! { <div class="showcase-browser-placeholder">{initializing}</div> })
77        }}
78        {move || {
79            (!ready.get() && library.read_state().is_none())
80                .then(|| view! { <div class="showcase-browser-placeholder">{loading}</div> })
81        }}
82    }
83}
84
85#[component]
86fn BrowserPreview(slug: String, detail: bool) -> impl IntoView {
87    let lightbox = slug == "lightbox";
88    let visible = RwSignal::new(lightbox);
89    let visibility_id = host_id(&slug, detail);
90
91    #[cfg(target_arch = "wasm32")]
92    {
93        use wasm_bindgen::{closure::Closure, JsCast};
94
95        type Watch = (
96            web_sys::IntersectionObserver,
97            Closure<dyn FnMut(js_sys::Array, web_sys::IntersectionObserver)>,
98        );
99        let watcher = StoredValue::new_local(None::<Watch>);
100        let target_id = visibility_id.clone();
101        Effect::new(move |_| {
102            if let Some(Some((observer, _callback))) = watcher.try_update_value(Option::take) {
103                observer.disconnect();
104            }
105            if visible.get() {
106                return;
107            }
108            if detail {
109                visible.set(true);
110                return;
111            }
112            let Some(target) = web_sys::window()
113                .and_then(|window| window.document())
114                .and_then(|document| document.get_element_by_id(&target_id))
115            else {
116                return;
117            };
118            let callback = Closure::new(
119                move |entries: js_sys::Array, observer: web_sys::IntersectionObserver| {
120                    if entries.iter().any(|value| {
121                        value
122                            .unchecked_into::<web_sys::IntersectionObserverEntry>()
123                            .is_intersecting()
124                    }) {
125                        observer.disconnect();
126                        let _ = visible.try_set(true);
127                    }
128                },
129            );
130            if let Ok(observer) =
131                web_sys::IntersectionObserver::new(callback.as_ref().unchecked_ref())
132            {
133                observer.observe(&target);
134                watcher.set_value(Some((observer, callback)));
135            } else {
136                // 老浏览器没有 IntersectionObserver 时仍可打开预览。
137                visible.set(true);
138            }
139        });
140        on_cleanup(move || {
141            if let Some(Some((observer, _callback))) = watcher.try_update_value(Option::take) {
142                observer.disconnect();
143            }
144        });
145    }
146
147    let class = if detail {
148        "showcase-browser-preview showcase-browser-preview--detail"
149    } else {
150        "showcase-browser-preview"
151    };
152    view! {
153        <div class=class id=visibility_id>
154            {move || {
155                if visible.get() {
156                    match slug.as_str() {
157                        "tiptap-editor" => view! { <TiptapPreview detail/> }.into_any(),
158                        "code-mirror-editor" => view! { <CodeMirrorPreview detail/> }.into_any(),
159                        "xterm-terminal" => view! { <XtermPreview detail/> }.into_any(),
160                        _ => view! { <LightboxPreview detail/> }.into_any(),
161                    }
162                } else {
163                    view! {
164                        <div class="showcase-browser-placeholder" role="status">
165                            {if detail { "正在加载真实组件…" } else { "滚动到这里后加载真实组件…" }}
166                        </div>
167                    }
168                    .into_any()
169                }
170            }}
171        </div>
172    }
173}
174
175#[component]
176fn TiptapPreview(detail: bool) -> impl IntoView {
177    let host = NodeRef::<leptos::html::Div>::new();
178    let error = RwSignal::new(None::<String>);
179    let ready = RwSignal::new(false);
180    let changed = RwSignal::new(false);
181    let retry = RwSignal::new(0_u32);
182    let library = use_browser_library(BrowserLibrary::Tiptap, || true);
183
184    #[cfg(target_arch = "wasm32")]
185    {
186        use crate::bridges::tiptap::{self, EditorHandle};
187        use wasm_bindgen::{closure::Closure, JsValue};
188
189        let handle: StoredValue<Option<EditorHandle>, LocalStorage> = StoredValue::new_local(None);
190        Effect::new(move |_| {
191            retry.track();
192            if handle.with_value(|handle| handle.is_some()) || !library_ready(library) {
193                return;
194            }
195            // 跟踪读取:宿主挂载后本 Effect 会重跑。
196            let Some(container) = host.get() else {
197                return;
198            };
199            let on_update = Closure::new(move |_markdown: String| {
200                let _ = changed.try_set(true);
201            });
202            let on_ready = Closure::new(|| {});
203            // 未注入上传和素材库回调:相关命令不会进入正式业务路径。
204            let on_upload = Closure::new(|_file: web_sys::File| {
205                js_sys::Promise::reject(&JsValue::from_str("图鉴不上传图片"))
206            });
207            let on_upload_event = Closure::new(|_event: tiptap::UploadEventJs| {});
208            let on_run = Closure::new(|_opts: tiptap::wasm::RunCodeOptsJs| {
209                js_sys::Promise::resolve(&JsValue::from_str("输出示例:图鉴不会执行代码。"))
210            });
211            let on_pick = Closure::new(|| {});
212            let opts = tiptap::EditorOptions::default();
213            opts.set_placeholder("在这里写一小段内容…");
214            opts.set_on_update(&on_update);
215            opts.set_on_ready(&on_ready);
216            opts.set_on_run_code(&on_run);
217            match tiptap::create(&container, &opts) {
218                Ok(instance) => {
219                    let _ = instance.set_markdown(TIPTAP_SAMPLE);
220                    handle.set_value(Some(EditorHandle::new(
221                        instance,
222                        on_update,
223                        on_upload,
224                        on_ready,
225                        on_upload_event,
226                        on_run,
227                        on_pick,
228                    )));
229                    ready.set(true);
230                    error.set(None);
231                }
232                Err(_) => error.set(Some("编辑器初始化失败".to_string())),
233            }
234        });
235        on_cleanup(move || {
236            let _ = handle.try_update_value(Option::take);
237        });
238    }
239
240    view! {
241        <div class="showcase-browser-toolbar">
242            <span>"Tiptap · Markdown"</span>
243            {detail.then(|| view! { <small>"本地演示,内容不会保存"</small> })}
244        </div>
245        <div class="showcase-browser-host showcase-browser-host--tiptap" node_ref=host></div>
246        {loading_placeholders(library, ready, error, "编辑器正在初始化…", "编辑器库加载中…")}
247        <LibraryLoadError library/>
248        {error_alert(error, retry)}
249        {move || {
250            (detail && changed.get())
251                .then(|| {
252                    view! {
253                        <p class="showcase-browser-note">
254                            "内容已在本地修改;可用编辑器右上角按钮切换 Markdown 源码。"
255                        </p>
256                    }
257                })
258        }}
259    }
260}
261
262#[component]
263fn CodeMirrorPreview(detail: bool) -> impl IntoView {
264    let host = NodeRef::<leptos::html::Div>::new();
265    let language = RwSignal::new("python".to_string());
266    let error = RwSignal::new(None::<String>);
267    let ready = RwSignal::new(false);
268    let shortcut_note = RwSignal::new(false);
269    let retry = RwSignal::new(0_u32);
270    let library = use_browser_library(BrowserLibrary::CodeMirror, || true);
271
272    #[cfg(target_arch = "wasm32")]
273    let handle: StoredValue<Option<crate::bridges::codemirror::EditorHandle>, LocalStorage> =
274        StoredValue::new_local(None);
275
276    #[cfg(target_arch = "wasm32")]
277    {
278        use crate::bridges::codemirror::{self, EditorHandle};
279        use crate::theme::{use_resolved_theme, ResolvedTheme};
280        use wasm_bindgen::closure::Closure;
281
282        let resolved = use_resolved_theme();
283        Effect::new(move |_| {
284            retry.track();
285            if handle.with_value(|handle| handle.is_some()) || !library_ready(library) {
286                return;
287            }
288            // 跟踪读取:宿主挂载后本 Effect 会重跑。
289            let Some(container) = host.get() else {
290                return;
291            };
292            let on_change = Closure::new(|_value: String| {});
293            let on_ready = Closure::new(|| {});
294            let on_shortcut = Closure::new(move || {
295                let _ = shortcut_note.try_set(true);
296            });
297            let current_language = language.get_untracked();
298            let opts = codemirror::EditorOptions::default();
299            opts.set_language(&current_language);
300            opts.set_theme(if resolved.get_untracked() == ResolvedTheme::Dark {
301                "dark"
302            } else {
303                "light"
304            });
305            opts.set_value(code_sample(&current_language));
306            opts.set_on_change(&on_change);
307            opts.set_on_ready(&on_ready);
308            opts.set_on_run_shortcut(&on_shortcut);
309            match codemirror::create(&container, &opts) {
310                Ok(instance) => {
311                    handle.set_value(Some(EditorHandle::new(
312                        instance,
313                        on_change,
314                        on_ready,
315                        on_shortcut,
316                    )));
317                    ready.set(true);
318                    error.set(None);
319                }
320                Err(_) => error.set(Some("代码编辑器初始化失败".to_string())),
321            }
322        });
323        Effect::new(move |_| {
324            let theme = if resolved.get() == ResolvedTheme::Dark {
325                "dark"
326            } else {
327                "light"
328            };
329            handle.with_value(|editor| {
330                if let Some(editor) = editor {
331                    editor.instance().set_theme(theme);
332                }
333            });
334        });
335        on_cleanup(move || {
336            let _ = handle.try_update_value(Option::take);
337        });
338    }
339
340    let on_language_change = move |event: leptos::ev::Event| {
341        let next = event_target_value(&event);
342        #[cfg(target_arch = "wasm32")]
343        handle.with_value(|editor| {
344            if let Some(editor) = editor {
345                editor.instance().set_language(&next);
346                editor.instance().set_value(code_sample(&next));
347            }
348        });
349        language.set(next);
350    };
351
352    view! {
353        <div class="showcase-browser-toolbar">
354            <span>{move || format!("CodeMirror · {}", language.get())}</span>
355            {detail
356                .then(|| {
357                    view! {
358                        <label>
359                            "语言 "
360                            <select
361                                prop:value=move || language.get()
362                                on:change=on_language_change
363                            >
364                                <option value="python">"Python"</option>
365                                <option value="rust">"Rust"</option>
366                                <option value="sql">"SQL"</option>
367                            </select>
368                        </label>
369                    }
370                })}
371        </div>
372        <div class="showcase-browser-host showcase-browser-host--code" node_ref=host></div>
373        {loading_placeholders(
374            library,
375            ready,
376            error,
377            "代码编辑器正在初始化…",
378            "代码编辑器库加载中…",
379        )}
380        <LibraryLoadError library/>
381        {error_alert(error, retry)}
382        {detail
383            .then(|| {
384                view! {
385                    <p class="showcase-browser-note">
386                        "可编辑、撤销并切换语法高亮;代码只保存在当前预览。"
387                    </p>
388                    {move || {
389                        shortcut_note
390                            .get()
391                            .then(|| {
392                                view! {
393                                    <p role="status" class="showcase-browser-note">
394                                        "Ctrl/Cmd+Enter 在图鉴中不会执行代码。"
395                                    </p>
396                                }
397                            })
398                    }}
399                }
400            })}
401    }
402}
403
404#[component]
405fn XtermPreview(detail: bool) -> impl IntoView {
406    let host = NodeRef::<leptos::html::Div>::new();
407    let error = RwSignal::new(None::<String>);
408    let ready = RwSignal::new(false);
409    let status = RwSignal::new("输出示例已就绪".to_string());
410    let retry = RwSignal::new(0_u32);
411    let library = use_browser_library(BrowserLibrary::Xterm, || true);
412
413    #[cfg(target_arch = "wasm32")]
414    let handle: StoredValue<Option<crate::bridges::xterm::TerminalHandle>, LocalStorage> =
415        StoredValue::new_local(None);
416    // 播放序号:任何新的播放/暂停/清屏/卸载都会使旧的播放序列失效。
417    #[cfg(target_arch = "wasm32")]
418    let generation = StoredValue::new(0_u64);
419
420    #[cfg(target_arch = "wasm32")]
421    {
422        use crate::bridges::xterm::{self, TerminalHandle};
423        use crate::theme::{use_resolved_theme, ResolvedTheme};
424        use wasm_bindgen::closure::Closure;
425
426        let resolved = use_resolved_theme();
427        Effect::new(move |_| {
428            retry.track();
429            if handle.with_value(|handle| handle.is_some()) || !library_ready(library) {
430                return;
431            }
432            // 跟踪读取:宿主挂载后本 Effect 会重跑。
433            let Some(container) = host.get() else {
434                return;
435            };
436            let on_ready = Closure::new(|| {});
437            let opts = xterm::XtermOptions::default();
438            opts.set_theme(if resolved.get_untracked() == ResolvedTheme::Dark {
439                "dark"
440            } else {
441                "light"
442            });
443            opts.set_font_size(if detail { 13 } else { 11 });
444            opts.set_on_ready(&on_ready);
445            match xterm::create(&container, &opts) {
446                Ok(instance) => {
447                    instance.write_all(TERMINAL_STDOUT, TERMINAL_STDERR);
448                    handle.set_value(Some(TerminalHandle::new(instance, on_ready)));
449                    ready.set(true);
450                    error.set(None);
451                }
452                Err(_) => error.set(Some("终端初始化失败".to_string())),
453            }
454        });
455        Effect::new(move |_| {
456            let theme = if resolved.get() == ResolvedTheme::Dark {
457                "dark"
458            } else {
459                "light"
460            };
461            handle.with_value(|terminal| {
462                if let Some(terminal) = terminal {
463                    terminal.instance().set_theme(theme);
464                }
465            });
466        });
467        on_cleanup(move || {
468            let _ = generation.try_update_value(|value| *value = value.wrapping_add(1));
469            let _ = handle.try_update_value(Option::take);
470        });
471    }
472
473    let on_play = move |_| {
474        #[cfg(target_arch = "wasm32")]
475        {
476            let Some(ticket) = generation.try_update_value(|value| {
477                *value = value.wrapping_add(1);
478                *value
479            }) else {
480                return;
481            };
482            handle.with_value(|terminal| {
483                if let Some(terminal) = terminal {
484                    terminal.instance().clear();
485                }
486            });
487            status.set("播放中".to_string());
488            leptos::task::spawn_local(async move {
489                for (stderr, text) in [
490                    (false, "\x1b[32m✓\x1b[0m 正在整理文章样例…\n"),
491                    (false, "已读取 3 个本地条目\n"),
492                    (true, "提示:这只是固定输出示例。\n"),
493                    (
494                        false,
495                        "这是一段较长的输出,用于观察终端在窄屏下如何换行和滚动:Yggdrasil showcase terminal preview output line.\n",
496                    ),
497                ] {
498                    if generation.try_get_value() != Some(ticket) {
499                        return;
500                    }
501                    let _ = handle.try_with_value(|terminal| {
502                        if let Some(terminal) = terminal {
503                            if stderr {
504                                terminal.instance().write_stderr(text);
505                            } else {
506                                terminal.instance().write_stdout(text);
507                            }
508                        }
509                    });
510                    crate::utils::time::sleep_ms(330).await;
511                }
512                if generation.try_get_value() == Some(ticket) {
513                    let _ = status.try_set("播放完成,可重放".to_string());
514                }
515            });
516        }
517    };
518    let on_pause = move |_| {
519        #[cfg(target_arch = "wasm32")]
520        generation.update_value(|value| *value = value.wrapping_add(1));
521        status.set("已暂停".to_string());
522    };
523    let on_clear = move |_| {
524        #[cfg(target_arch = "wasm32")]
525        {
526            generation.update_value(|value| *value = value.wrapping_add(1));
527            handle.with_value(|terminal| {
528                if let Some(terminal) = terminal {
529                    terminal.instance().clear();
530                }
531            });
532        }
533        status.set("已清屏".to_string());
534    };
535
536    view! {
537        <div class="showcase-browser-toolbar">
538            <span>"Xterm · 输出示例"</span>
539            {detail
540                .then(|| {
541                    view! {
542                        <div class="showcase-browser-actions">
543                            <button
544                                type="button"
545                                disabled=move || !ready.get()
546                                on:click=on_play
547                            >
548                                "播放 / 重放"
549                            </button>
550                            <button
551                                type="button"
552                                disabled=move || !ready.get()
553                                on:click=on_pause
554                            >
555                                "暂停"
556                            </button>
557                            <button
558                                type="button"
559                                disabled=move || !ready.get()
560                                on:click=on_clear
561                            >
562                                "清屏"
563                            </button>
564                        </div>
565                    }
566                })}
567        </div>
568        <div class="showcase-browser-host showcase-browser-host--xterm" node_ref=host></div>
569        {loading_placeholders(library, ready, error, "终端正在初始化…", "终端库加载中…")}
570        <LibraryLoadError library/>
571        {error_alert(error, retry)}
572        {detail
573            .then(|| {
574                view! {
575                    <p class="showcase-browser-note" role="status">
576                        {move || format!("{}。固定数据,与编辑的代码无关。", status.get())}
577                    </p>
578                }
579            })}
580    }
581}
582
583#[component]
584fn LightboxPreview(detail: bool) -> impl IntoView {
585    let owner = host_id("lightbox-gallery", detail);
586
587    #[cfg(target_arch = "wasm32")]
588    {
589        let init_owner = owner.clone();
590        Effect::new(move |_| {
591            if !detail {
592                return;
593            }
594            crate::bridges::globals::init_lightbox(&[&format!("#{init_owner}")]);
595        });
596        let cleanup_owner = owner.clone();
597        on_cleanup(move || {
598            crate::bridges::globals::close_lightbox_for(&cleanup_owner);
599        });
600    }
601
602    view! {
603        <div
604            class="showcase-browser-lightbox-grid"
605            id=owner.clone()
606            data-showcase-lightbox-owner=owner
607        >
608            <img
609                src="/images/samples/clay-circle.webp"
610                alt="暖白纸面上的一轮陶土色圆"
611                loading="lazy"
612            />
613            <img
614                src="/images/samples/hairline-grid.webp"
615                alt="细线网格上连起的几个点"
616                loading="lazy"
617            />
618            <img
619                src="/images/samples/ink-dusk.webp"
620                alt="深色背景上层层展开的陶土色弧线"
621                loading="lazy"
622            />
623        </div>
624        {detail
625            .then(|| {
626                view! {
627                    <p class="showcase-browser-note">
628                        "点击图片打开灯箱,可切换、缩放、旋转,并用 Esc 关闭。"
629                    </p>
630                }
631            })}
632    }
633}
634
635#[cfg(test)]
636mod tests {
637    use super::*;
638    use leptos::reactive::owner::Owner;
639
640    #[test]
641    fn distinct_card_and_detail_hosts() {
642        for slug in [
643            "tiptap-host",
644            "code-mirror-host",
645            "xterm-host",
646            "lightbox-gallery",
647        ] {
648            assert_ne!(host_id(slug, false), host_id(slug, true));
649        }
650    }
651
652    #[test]
653    fn language_samples_are_real_documents() {
654        assert!(code_sample("python").contains("def greet"));
655        assert!(code_sample("rust").contains("fn main"));
656        assert!(code_sample("sql").contains("SELECT"));
657    }
658
659    #[test]
660    fn unknown_slug_has_no_preview() {
661        Owner::new().with(|| assert!(preview("alert-box", false).is_none()));
662    }
663
664    #[test]
665    fn lightbox_renders_owner_markers_on_server() {
666        Owner::new().with(|| {
667            let html = view! { <LightboxPreview detail=true/> }.to_html();
668            assert!(
669                html.contains(
670                    r#"data-showcase-lightbox-owner="showcase-browser-lightbox-gallery-detail""#
671                ),
672                "{html}"
673            );
674            assert!(html.contains("点击图片打开灯箱"), "{html}");
675        });
676    }
677}