1use 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
31pub(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 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 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}