Skip to main content

zng_wgt_markdown/
resolvers.rs

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    /// Markdown image resolver.
30    pub static IMAGE_RESOLVER_VAR: ImageResolver = ImageResolver::Default;
31
32    /// Markdown link resolver.
33    pub static LINK_RESOLVER_VAR: LinkResolver = LinkResolver::Default;
34
35    /// Scroll mode used by anchor links.
36    pub static LINK_SCROLL_MODE_VAR: ScrollToMode = ScrollToMode::minimal(10);
37}
38
39/// Markdown image resolver.
40///
41/// This can be used to override image source resolution, by default the image URL or URI is passed as parsed to the [`image_fn`].
42///
43/// Note that image downloads are blocked by default, you can enable this by using the [`image::img_limits`] property.
44///
45/// Sets the [`IMAGE_RESOLVER_VAR`].
46///
47/// [`image_fn`]: fn@crate::image_fn
48/// [`image::img_limits`]: fn@zng_wgt_image::img_limits
49#[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/// Markdown link resolver.
55///
56/// This can be used to expand or replace links.
57///
58/// Sets the [`LINK_RESOLVER_VAR`].
59#[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/// Scroll-to mode used by anchor links.
65#[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/// Markdown image resolver.
71///
72/// See [`IMAGE_RESOLVER_VAR`] for more details.
73#[derive(Clone, Default)]
74pub enum ImageResolver {
75    /// No extra resolution, just convert into [`ImageSource`].
76    ///
77    /// [`ImageSource`]: zng_ext_image::ImageSource
78    #[default]
79    Default,
80    /// Custom resolution.
81    Resolve(Arc<dyn Fn(&str) -> ImageSource + Send + Sync>),
82}
83impl ImageResolver {
84    /// Resolve the image.
85    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    /// New [`Resolve`](Self::Resolve).
93    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/// Markdown link resolver.
118///
119/// See [`LINK_RESOLVER_VAR`] for more details.
120#[derive(Clone, Default)]
121pub enum LinkResolver {
122    /// No extra resolution, just pass the link provided.
123    #[default]
124    Default,
125    /// Custom resolution.
126    Resolve(Arc<dyn Fn(&str) -> Txt + Send + Sync>),
127}
128impl LinkResolver {
129    /// Resolve the link.
130    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    /// New [`Resolve`](Self::Resolve).
138    pub fn new(fn_: impl Fn(&str) -> Txt + Send + Sync + 'static) -> Self {
139        Self::Resolve(Arc::new(fn_))
140    }
141
142    /// Resolve file links relative to `base`.
143    ///
144    /// The path is also absolutized, but not canonicalized.
145    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            // can only fail by returning `false` in some cases where the value pointer is actually equal.
177            // see: https://github.com/rust-lang/rust/issues/103763
178            //
179            // we are fine with this, worst case is just an extra var update
180            (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    /// Event raised by markdown links when clicked.
188    pub static LINK_EVENT: LinkArgs;
189}
190
191event_property! {
192    /// Markdown link click.
193    #[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    /// Arguments for the [`LINK_EVENT`].
202    pub struct LinkArgs {
203        /// Raw URL.
204        pub url: Txt,
205
206        /// Link widget.
207        pub link: InteractionPath,
208
209        ..
210
211        fn is_in_target(&self, id: WidgetId) -> bool {
212            self.link.contains(id)
213        }
214    }
215}
216
217/// Default markdown link action.
218///
219/// Does [`try_scroll_link`] or [`try_open_link`].
220pub fn try_default_link_action(args: &LinkArgs) -> bool {
221    try_scroll_link(args) || try_open_link(args)
222}
223
224/// Handle `url` in the format `#anchor`, by scrolling and focusing the anchor.
225///
226/// If the anchor is found scrolls to it and moves focus to the `#anchor` widget,
227/// or the first focusable descendant of it, or the markdown widget or the first focusable ancestor of it.
228///
229/// Note that the request is handled even if the anchor is not found.
230pub fn try_scroll_link(args: &LinkArgs) -> bool {
231    if args.propagation.is_stopped() {
232        return false;
233    }
234    // Note: file names can start with #, but we are choosing to always interpret URLs with this prefix as an anchor.
235    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            // scroll-to
241            zng_wgt_scroll::cmd::scroll_to(target.clone(), LINK_SCROLL_MODE_VAR.get());
242
243            // focus if target if focusable
244            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
255/// Try open link, only works if the `url` is valid or a file path, returns if the confirm tooltip is visible.
256pub 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/// Set a label that identifies the widget in the context of the parent markdown.
458///
459/// The anchor can be retried in the widget info using [`WidgetInfoExt::anchor`]. It is mostly used
460/// by markdown links to find scroll targets.
461#[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
475/// Markdown extension methods for widget info.
476pub trait WidgetInfoExt {
477    /// Gets the [`anchor`].
478    ///
479    /// [`anchor`]: fn@anchor
480    fn anchor(&self) -> Option<&Txt>;
481
482    /// If this widget is a [`Markdown!`].
483    ///
484    /// [`Markdown!`]: struct@crate::Markdown
485    fn is_markdown(&self) -> bool;
486
487    /// Find descendant tagged by the given anchor.
488    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
504/// Generate an anchor label for a header.
505pub 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}