1use std::fmt;
2use std::path::{Path, PathBuf};
3use std::sync::Arc;
4
5use zng_ext_l10n::l10n;
6use zng_wgt::{prelude::*, *};
7
8use zng_ext_clipboard::{CLIPBOARD, COPY_CMD};
9use zng_ext_image::ImageSource;
10use zng_ext_input::focus::WidgetInfoFocusExt as _;
11use zng_ext_input::{focus::FOCUS, gesture::ClickArgs};
12use zng_wgt_button::Button;
13use zng_wgt_container::Container;
14use zng_wgt_fill::*;
15use zng_wgt_filter::*;
16use zng_wgt_input::focus::on_focus_leave;
17use zng_wgt_layer::{AnchorMode, AnchorOffset, LAYERS, LayerIndex};
18use zng_wgt_scroll::cmd::ScrollToMode;
19use zng_wgt_size_offset::*;
20use zng_wgt_text::{self as text, Text};
21
22use super::Markdown;
23
24use path_absolutize::*;
25
26use http::Uri;
27
28context_var! {
29 pub static IMAGE_RESOLVER_VAR: ImageResolver = ImageResolver::Default;
31
32 pub static LINK_RESOLVER_VAR: LinkResolver = LinkResolver::Default;
34
35 pub static LINK_SCROLL_MODE_VAR: ScrollToMode = ScrollToMode::minimal(10);
37}
38
39#[property(CONTEXT, default(IMAGE_RESOLVER_VAR), widget_impl(Markdown))]
50pub fn image_resolver(child: impl IntoUiNode, resolver: impl IntoVar<ImageResolver>) -> UiNode {
51 with_context_var(child, IMAGE_RESOLVER_VAR, resolver)
52}
53
54#[property(CONTEXT, default(LINK_RESOLVER_VAR), widget_impl(Markdown))]
60pub fn link_resolver(child: impl IntoUiNode, resolver: impl IntoVar<LinkResolver>) -> UiNode {
61 with_context_var(child, LINK_RESOLVER_VAR, resolver)
62}
63
64#[property(CONTEXT, default(LINK_SCROLL_MODE_VAR), widget_impl(Markdown))]
66pub fn link_scroll_mode(child: impl IntoUiNode, mode: impl IntoVar<ScrollToMode>) -> UiNode {
67 with_context_var(child, LINK_SCROLL_MODE_VAR, mode)
68}
69
70#[derive(Clone, Default)]
74pub enum ImageResolver {
75 #[default]
79 Default,
80 Resolve(Arc<dyn Fn(&str) -> ImageSource + Send + Sync>),
82}
83impl ImageResolver {
84 pub fn resolve(&self, img: &str) -> ImageSource {
86 match self {
87 ImageResolver::Default => img.into(),
88 ImageResolver::Resolve(r) => r(img),
89 }
90 }
91
92 pub fn new(fn_: impl Fn(&str) -> ImageSource + Send + Sync + 'static) -> Self {
94 ImageResolver::Resolve(Arc::new(fn_))
95 }
96}
97impl fmt::Debug for ImageResolver {
98 fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
99 if f.alternate() {
100 write!(f, "ImgSourceResolver::")?;
101 }
102 match self {
103 ImageResolver::Default => write!(f, "Default"),
104 ImageResolver::Resolve(_) => write!(f, "Resolve(_)"),
105 }
106 }
107}
108impl PartialEq for ImageResolver {
109 fn eq(&self, other: &Self) -> bool {
110 match (self, other) {
111 (Self::Resolve(l0), Self::Resolve(r0)) => Arc::ptr_eq(l0, r0),
112 _ => core::mem::discriminant(self) == core::mem::discriminant(other),
113 }
114 }
115}
116
117#[derive(Clone, Default)]
121pub enum LinkResolver {
122 #[default]
124 Default,
125 Resolve(Arc<dyn Fn(&str) -> Txt + Send + Sync>),
127}
128impl LinkResolver {
129 pub fn resolve(&self, url: &str) -> Txt {
131 match self {
132 Self::Default => url.to_txt(),
133 Self::Resolve(r) => r(url),
134 }
135 }
136
137 pub fn new(fn_: impl Fn(&str) -> Txt + Send + Sync + 'static) -> Self {
139 Self::Resolve(Arc::new(fn_))
140 }
141
142 pub fn base_dir(base: impl Into<PathBuf>) -> Self {
146 let base = base.into();
147 Self::new(move |url| {
148 if !url.starts_with('#') {
149 let is_not_uri = url.parse::<Uri>().is_err();
150
151 if is_not_uri {
152 let path = Path::new(url);
153 if let Ok(path) = base.join(path).absolutize() {
154 return path.display().to_txt();
155 }
156 }
157 }
158 url.to_txt()
159 })
160 }
161}
162impl fmt::Debug for LinkResolver {
163 fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
164 if f.alternate() {
165 write!(f, "LinkResolver::")?;
166 }
167 match self {
168 Self::Default => write!(f, "Default"),
169 Self::Resolve(_) => write!(f, "Resolve(_)"),
170 }
171 }
172}
173impl PartialEq for LinkResolver {
174 fn eq(&self, other: &Self) -> bool {
175 match (self, other) {
176 (Self::Resolve(l0), Self::Resolve(r0)) => Arc::ptr_eq(l0, r0),
181 _ => core::mem::discriminant(self) == core::mem::discriminant(other),
182 }
183 }
184}
185
186event! {
187 pub static LINK_EVENT: LinkArgs;
189}
190
191event_property! {
192 #[property(EVENT)]
194 pub fn on_link<on_pre_link>(child: impl IntoUiNode, handler: Handler<LinkArgs>) -> UiNode {
195 const PRE: bool;
196 EventNodeBuilder::new(LINK_EVENT).build::<PRE>(child, handler)
197 }
198}
199
200event_args! {
201 pub struct LinkArgs {
203 pub url: Txt,
205
206 pub link: InteractionPath,
208
209 ..
210
211 fn is_in_target(&self, id: WidgetId) -> bool {
212 self.link.contains(id)
213 }
214 }
215}
216
217pub fn try_default_link_action(args: &LinkArgs) -> bool {
221 try_scroll_link(args) || try_open_link(args)
222}
223
224pub fn try_scroll_link(args: &LinkArgs) -> bool {
231 if args.propagation.is_stopped() {
232 return false;
233 }
234 if let Some(anchor) = args.url.strip_prefix('#') {
236 let tree = WINDOW.info();
237 if let Some(md) = tree.get(WIDGET.id()).and_then(|w| w.self_and_ancestors().find(|w| w.is_markdown()))
238 && let Some(target) = md.find_anchor(anchor)
239 {
240 zng_wgt_scroll::cmd::scroll_to(target.clone(), LINK_SCROLL_MODE_VAR.get());
242
243 if let Some(focus) = target.into_focus_info(true, true).self_and_descendants().find(|w| w.is_focusable()) {
245 FOCUS.focus_widget(focus.info().id(), false);
246 }
247 }
248 args.propagation.stop();
249 return true;
250 }
251
252 false
253}
254
255pub fn try_open_link(args: &LinkArgs) -> bool {
257 if args.propagation.is_stopped() {
258 return false;
259 }
260
261 #[derive(Clone)]
262 enum Link {
263 Url(Uri),
264 Path(PathBuf),
265 }
266
267 let link = if let Ok(url) = args.url.parse() {
268 Link::Url(url)
269 } else {
270 Link::Path(PathBuf::from(args.url.as_str()))
271 };
272
273 let popup_id = WidgetId::new_unique();
274
275 #[derive(Clone, Debug, PartialEq)]
276 enum Status {
277 Pending,
278 Ok,
279 Err,
280 Cancel,
281 }
282 let status = var(Status::Pending);
283
284 let open_time = INSTANT.now();
285
286 let popup = Container! {
287 id = popup_id;
288
289 padding = (2, 4);
290 corner_radius = 2;
291 drop_shadow = (2, 2), 2, colors::BLACK.with_alpha(50.pct());
292 align = Align::TOP_LEFT;
293
294 #[easing(200.ms())]
295 opacity = 0.pct();
296 #[easing(200.ms())]
297 offset = (0, -10);
298
299 background_color = light_dark(colors::WHITE.with_alpha(90.pct()), colors::BLACK.with_alpha(90.pct()));
300
301 when *#{status.clone()} == Status::Pending {
302 opacity = 100.pct();
303 offset = (0, 0);
304 }
305 when *#{status.clone()} == Status::Err {
306 background_color = light_dark(
307 web_colors::PINK.with_alpha(90.pct()),
308 web_colors::DARK_RED.with_alpha(90.pct()),
309 );
310 }
311
312 on_focus_leave = async_hn_once!(status, |_| {
313 if status.get() != Status::Pending {
314 return;
315 }
316
317 status.set(Status::Cancel);
318 task::deadline(200.ms()).await;
319
320 LAYERS.remove(popup_id);
321 });
322
323 child = Button! {
324 style_fn = zng_wgt_button::LightStyle!();
325
326 focus_on_init = true;
327
328 child = Text!(match &link {
329 Link::Url(_) => l10n!("try_open_link.open-url", "Open in Browser"),
330 Link::Path(_) => match std::env::consts::OS {
331 "windows" => l10n!("try_open_link.reveal-path-windows", "Reveal in File Explorer"),
332 "macos" => l10n!("try_open_link.reveal-path-macos", "Reveal in Finder"),
333 _ => l10n!("try_open_link.reveal-path", "Reveal in File Manager"),
334 },
335 });
336 child_spacing = 3;
337 child_end = ICONS.get_or("arrow-outward", || Text!("🡵"));
338
339 text::underline_skip = text::UnderlineSkip::SPACES;
340
341 on_click = async_hn_once!(status, link, |args: &ClickArgs| {
342 if status.get() != Status::Pending || args.timestamp.duration_since(open_time) < 300.ms() {
343 return;
344 }
345
346 args.propagation.stop();
347
348 let (uri, kind) = match link {
349 Link::Url(u) => (u.to_string(), "url"),
350 Link::Path(p) => match dunce::canonicalize(&p) {
351 Ok(p) => {
352 let p = p.display().to_string();
353 #[cfg(windows)]
354 let p = p.replace('/', "\\");
355
356 #[cfg(target_arch = "wasm32")]
357 let p = format!("file:///{p}");
358
359 (p, "path")
360 }
361 Err(e) => {
362 tracing::error!("error canonicalizing \"{}\", {e}", p.display());
363 return;
364 }
365 },
366 };
367
368 #[cfg(not(target_arch = "wasm32"))]
369 {
370 let r = task::wait(|| open::that_detached(uri)).await;
371 if let Err(e) = &r {
372 tracing::error!("error opening {kind}, {e}");
373 }
374
375 status.set(if r.is_ok() { Status::Ok } else { Status::Err });
376 }
377 #[cfg(target_arch = "wasm32")]
378 {
379 match web_sys::window() {
380 Some(w) => match w.open_with_url_and_target(uri.as_str(), "_blank") {
381 Ok(w) => match w {
382 Some(w) => {
383 let _ = w.focus();
384 status.set(Status::Ok);
385 }
386 None => {
387 tracing::error!("error opening {kind}, no new tab/window");
388 status.set(Status::Err);
389 }
390 },
391 Err(e) => {
392 tracing::error!("error opening {kind}, {e:?}");
393 status.set(Status::Err);
394 }
395 },
396 None => {
397 tracing::error!("error opening {kind}, no window");
398 status.set(Status::Err);
399 }
400 }
401 }
402
403 task::deadline(200.ms()).await;
404
405 LAYERS.remove(popup_id);
406 });
407 };
408 child_end = Button! {
409 style_fn = zng_wgt_button::LightStyle!();
410 padding = 3;
411 child_spacing = 3;
412 child = Text!(match &link {
413 Link::Url(_) => l10n!("try_open_link.copy-url", "Copy Url"),
414 Link::Path(_) => l10n!("try_open_link.copy-path", "Copy Path"),
415 });
416 child_end = COPY_CMD.icon().present_data(());
417 on_click = async_hn_once!(status, |args: &ClickArgs| {
418 if status.get() != Status::Pending || args.timestamp.duration_since(open_time) < 300.ms() {
419 return;
420 }
421
422 args.propagation.stop();
423
424 let txt = match link {
425 Link::Url(u) => u.to_txt(),
426 Link::Path(p) => p.display().to_txt(),
427 };
428
429 let r = CLIPBOARD.set_text(txt.clone()).wait_rsp().await;
430 if let Err(e) = &r {
431 tracing::error!("error copying uri, {e}");
432 }
433
434 status.set(if r.is_ok() { Status::Ok } else { Status::Err });
435 task::deadline(200.ms()).await;
436
437 LAYERS.remove(popup_id);
438 });
439 };
440 };
441
442 LAYERS.insert_anchored(
443 LayerIndex::ADORNER,
444 args.link.widget_id(),
445 AnchorMode::popup(AnchorOffset::out_bottom()),
446 popup,
447 );
448
449 true
450}
451
452static_id! {
453 static ref ANCHOR_ID: StateId<Txt>;
454 pub(super) static ref MARKDOWN_INFO_ID: StateId<()>;
455}
456
457#[property(CONTEXT, default(""))]
462pub fn anchor(child: impl IntoUiNode, anchor: impl IntoVar<Txt>) -> UiNode {
463 let anchor = anchor.into_var();
464 match_node(child, move |_, op| match op {
465 UiNodeOp::Init => {
466 WIDGET.sub_var_info(&anchor);
467 }
468 UiNodeOp::Info { info } => {
469 info.set_meta(*ANCHOR_ID, anchor.get());
470 }
471 _ => {}
472 })
473}
474
475pub trait WidgetInfoExt {
477 fn anchor(&self) -> Option<&Txt>;
481
482 fn is_markdown(&self) -> bool;
486
487 fn find_anchor(&self, anchor: &str) -> Option<WidgetInfo>;
489}
490impl WidgetInfoExt for WidgetInfo {
491 fn anchor(&self) -> Option<&Txt> {
492 self.meta().get(*ANCHOR_ID)
493 }
494
495 fn is_markdown(&self) -> bool {
496 self.meta().contains(*MARKDOWN_INFO_ID)
497 }
498
499 fn find_anchor(&self, anchor: &str) -> Option<WidgetInfo> {
500 self.descendants().find(|d| d.anchor().map(|a| a == anchor).unwrap_or(false))
501 }
502}
503
504pub fn heading_anchor(header: &str) -> Txt {
506 header.chars().filter_map(slugify).collect::<String>().into()
507}
508fn slugify(c: char) -> Option<char> {
509 if c.is_alphanumeric() || c == '-' || c == '_' {
510 if c.is_ascii() { Some(c.to_ascii_lowercase()) } else { Some(c) }
511 } else if c.is_whitespace() && c.is_ascii() {
512 Some('-')
513 } else {
514 None
515 }
516}