yggdrasil/pages/components_showcase/
browser_previews.rs1use 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
36pub(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
46fn 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
65fn 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 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 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 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 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(¤t_language);
300 opts.set_theme(if resolved.get_untracked() == ResolvedTheme::Dark {
301 "dark"
302 } else {
303 "light"
304 });
305 opts.set_value(code_sample(¤t_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 #[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 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}