zng_wgt_markdown/
resolvers.rs

1use std::fmt;
2use std::path::{Path, PathBuf};
3use std::sync::Arc;
4
5use zng_wgt::{prelude::*, *};
6
7use zng_ext_clipboard::{CLIPBOARD, COPY_CMD};
8use zng_ext_image::ImageSource;
9use zng_ext_input::focus::WidgetInfoFocusExt as _;
10use zng_ext_input::{focus::FOCUS, gesture::ClickArgs};
11use zng_wgt_button::Button;
12use zng_wgt_container::Container;
13use zng_wgt_fill::*;
14use zng_wgt_filter::*;
15use zng_wgt_input::focus::on_focus_leave;
16use zng_wgt_layer::{AnchorMode, AnchorOffset, LAYERS, LayerIndex};
17use zng_wgt_scroll::cmd::ScrollToMode;
18use zng_wgt_size_offset::*;
19use zng_wgt_text::{self as text, Text};
20
21use super::Markdown;
22
23use path_absolutize::*;
24
25use http::Uri;
26
27context_var! {
28    /// Markdown image resolver.
29    pub static IMAGE_RESOLVER_VAR: ImageResolver = ImageResolver::Default;
30
31    /// Markdown link resolver.
32    pub static LINK_RESOLVER_VAR: LinkResolver = LinkResolver::Default;
33
34    /// Scroll mode used by anchor links.
35    pub static LINK_SCROLL_MODE_VAR: ScrollToMode = ScrollToMode::minimal(10);
36}
37
38/// Markdown image resolver.
39///
40/// This can be used to override image source resolution, by default the image URL or URI is passed as parsed to the [`image_fn`].
41///
42/// Note that image downloads are blocked by default, you can enable this by using the [`image::img_limits`] property.
43///
44/// Sets the [`IMAGE_RESOLVER_VAR`].
45///
46/// [`image_fn`]: fn@crate::image_fn
47/// [`image::img_limits`]: fn@zng_wgt_image::img_limits
48#[property(CONTEXT, default(IMAGE_RESOLVER_VAR), widget_impl(Markdown))]
49pub fn image_resolver(child: impl IntoUiNode, resolver: impl IntoVar<ImageResolver>) -> UiNode {
50    with_context_var(child, IMAGE_RESOLVER_VAR, resolver)
51}
52
53/// Markdown link resolver.
54///
55/// This can be used to expand or replace links.
56///
57/// Sets the [`LINK_RESOLVER_VAR`].
58#[property(CONTEXT, default(LINK_RESOLVER_VAR), widget_impl(Markdown))]
59pub fn link_resolver(child: impl IntoUiNode, resolver: impl IntoVar<LinkResolver>) -> UiNode {
60    with_context_var(child, LINK_RESOLVER_VAR, resolver)
61}
62
63/// Scroll-to mode used by anchor links.
64#[property(CONTEXT, default(LINK_SCROLL_MODE_VAR), widget_impl(Markdown))]
65pub fn link_scroll_mode(child: impl IntoUiNode, mode: impl IntoVar<ScrollToMode>) -> UiNode {
66    with_context_var(child, LINK_SCROLL_MODE_VAR, mode)
67}
68
69/// Markdown image resolver.
70///
71/// See [`IMAGE_RESOLVER_VAR`] for more details.
72#[derive(Clone, Default)]
73pub enum ImageResolver {
74    /// No extra resolution, just convert into [`ImageSource`].
75    ///
76    /// [`ImageSource`]: zng_ext_image::ImageSource
77    #[default]
78    Default,
79    /// Custom resolution.
80    Resolve(Arc<dyn Fn(&str) -> ImageSource + Send + Sync>),
81}
82impl ImageResolver {
83    /// Resolve the image.
84    pub fn resolve(&self, img: &str) -> ImageSource {
85        match self {
86            ImageResolver::Default => img.into(),
87            ImageResolver::Resolve(r) => r(img),
88        }
89    }
90
91    /// New [`Resolve`](Self::Resolve).
92    pub fn new(fn_: impl Fn(&str) -> ImageSource + Send + Sync + 'static) -> Self {
93        ImageResolver::Resolve(Arc::new(fn_))
94    }
95}
96impl fmt::Debug for ImageResolver {
97    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
98        if f.alternate() {
99            write!(f, "ImgSourceResolver::")?;
100        }
101        match self {
102            ImageResolver::Default => write!(f, "Default"),
103            ImageResolver::Resolve(_) => write!(f, "Resolve(_)"),
104        }
105    }
106}
107impl PartialEq for ImageResolver {
108    fn eq(&self, other: &Self) -> bool {
109        match (self, other) {
110            (Self::Resolve(l0), Self::Resolve(r0)) => Arc::ptr_eq(l0, r0),
111            _ => core::mem::discriminant(self) == core::mem::discriminant(other),
112        }
113    }
114}
115
116/// Markdown link resolver.
117///
118/// See [`LINK_RESOLVER_VAR`] for more details.
119#[derive(Clone, Default)]
120pub enum LinkResolver {
121    /// No extra resolution, just pass the link provided.
122    #[default]
123    Default,
124    /// Custom resolution.
125    Resolve(Arc<dyn Fn(&str) -> Txt + Send + Sync>),
126}
127impl LinkResolver {
128    /// Resolve the link.
129    pub fn resolve(&self, url: &str) -> Txt {
130        match self {
131            Self::Default => url.to_txt(),
132            Self::Resolve(r) => r(url),
133        }
134    }
135
136    /// New [`Resolve`](Self::Resolve).
137    pub fn new(fn_: impl Fn(&str) -> Txt + Send + Sync + 'static) -> Self {
138        Self::Resolve(Arc::new(fn_))
139    }
140
141    /// Resolve file links relative to `base`.
142    ///
143    /// The path is also absolutized, but not canonicalized.
144    pub fn base_dir(base: impl Into<PathBuf>) -> Self {
145        let base = base.into();
146        Self::new(move |url| {
147            if !url.starts_with('#') {
148                let is_not_uri = url.parse::<Uri>().is_err();
149
150                if is_not_uri {
151                    let path = Path::new(url);
152                    if let Ok(path) = base.join(path).absolutize() {
153                        return path.display().to_txt();
154                    }
155                }
156            }
157            url.to_txt()
158        })
159    }
160}
161impl fmt::Debug for LinkResolver {
162    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
163        if f.alternate() {
164            write!(f, "LinkResolver::")?;
165        }
166        match self {
167            Self::Default => write!(f, "Default"),
168            Self::Resolve(_) => write!(f, "Resolve(_)"),
169        }
170    }
171}
172impl PartialEq for LinkResolver {
173    fn eq(&self, other: &Self) -> bool {
174        match (self, other) {
175            // can only fail by returning `false` in some cases where the value pointer is actually equal.
176            // see: https://github.com/rust-lang/rust/issues/103763
177            //
178            // we are fine with this, worst case is just an extra var update
179            (Self::Resolve(l0), Self::Resolve(r0)) => Arc::ptr_eq(l0, r0),
180            _ => core::mem::discriminant(self) == core::mem::discriminant(other),
181        }
182    }
183}
184
185event! {
186    /// Event raised by markdown links when clicked.
187    pub static LINK_EVENT: LinkArgs;
188}
189
190event_property! {
191    /// Markdown link click.
192    pub fn link {
193        event: LINK_EVENT,
194        args: LinkArgs,
195    }
196}
197
198event_args! {
199    /// Arguments for the [`LINK_EVENT`].
200    pub struct LinkArgs {
201        /// Raw URL.
202        pub url: Txt,
203
204        /// Link widget.
205        pub link: InteractionPath,
206
207        ..
208
209        fn delivery_list(&self, delivery_list: &mut UpdateDeliveryList) {
210            delivery_list.insert_wgt(self.link.as_path())
211        }
212    }
213}
214
215/// Default markdown link action.
216///
217/// Does [`try_scroll_link`] or [`try_open_link`].
218pub fn try_default_link_action(args: &LinkArgs) -> bool {
219    try_scroll_link(args) || try_open_link(args)
220}
221
222/// Handle `url` in the format `#anchor`, by scrolling and focusing the anchor.
223///
224/// If the anchor is found scrolls to it and moves focus to the `#anchor` widget,
225/// or the first focusable descendant of it, or the markdown widget or the first focusable ancestor of it.
226///
227/// Note that the request is handled even if the anchor is not found.
228pub fn try_scroll_link(args: &LinkArgs) -> bool {
229    if args.propagation().is_stopped() {
230        return false;
231    }
232    // Note: file names can start with #, but we are choosing to always interpret URLs with this prefix as an anchor.
233    if let Some(anchor) = args.url.strip_prefix('#') {
234        let tree = WINDOW.info();
235        if let Some(md) = tree.get(WIDGET.id()).and_then(|w| w.self_and_ancestors().find(|w| w.is_markdown()))
236            && let Some(target) = md.find_anchor(anchor)
237        {
238            // scroll-to
239            zng_wgt_scroll::cmd::scroll_to(target.clone(), LINK_SCROLL_MODE_VAR.get());
240
241            // focus if target if focusable
242            if let Some(focus) = target.into_focus_info(true, true).self_and_descendants().find(|w| w.is_focusable()) {
243                FOCUS.focus_widget(focus.info().id(), false);
244            }
245        }
246        args.propagation().stop();
247        return true;
248    }
249
250    false
251}
252
253/// Try open link, only works if the `url` is valid or a file path, returns if the confirm tooltip is visible.
254pub fn try_open_link(args: &LinkArgs) -> bool {
255    if args.propagation().is_stopped() {
256        return false;
257    }
258
259    #[derive(Clone)]
260    enum Link {
261        Url(Uri),
262        Path(PathBuf),
263    }
264
265    let link = if let Ok(url) = args.url.parse() {
266        Link::Url(url)
267    } else {
268        Link::Path(PathBuf::from(args.url.as_str()))
269    };
270
271    let popup_id = WidgetId::new_unique();
272
273    let url = args.url.clone();
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        on_move = async_hn!(status, |args| {
323            if status.get() != Status::Pending || args.timestamp().duration_since(open_time) < 300.ms() {
324                return;
325            }
326
327            status.set(Status::Cancel);
328            task::deadline(200.ms()).await;
329
330            LAYERS.remove(popup_id);
331        });
332
333        child = Button! {
334            style_fn = zng_wgt_button::LinkStyle!();
335
336            focus_on_init = true;
337
338            child = Text!(url);
339            child_spacing = 2;
340            child_end = ICONS.get_or("arrow-outward", || Text!("🡵"));
341
342            text::underline_skip = text::UnderlineSkip::SPACES;
343
344            on_click = async_hn_once!(status, link, |args: &ClickArgs| {
345                if status.get() != Status::Pending || args.timestamp().duration_since(open_time) < 300.ms() {
346                    return;
347                }
348
349                args.propagation().stop();
350
351                let (uri, kind) = match link {
352                    Link::Url(u) => (u.to_string(), "url"),
353                    Link::Path(p) => match dunce::canonicalize(&p) {
354                        Ok(p) => {
355                            let p = p.display().to_string();
356                            #[cfg(windows)]
357                            let p = p.replace('/', "\\");
358
359                            #[cfg(target_arch = "wasm32")]
360                            let p = format!("file:///{p}");
361
362                            (p, "path")
363                        }
364                        Err(e) => {
365                            tracing::error!("error canonicalizing \"{}\", {e}", p.display());
366                            return;
367                        }
368                    },
369                };
370
371                #[cfg(not(target_arch = "wasm32"))]
372                {
373                    let r = task::wait(|| open::that_detached(uri)).await;
374                    if let Err(e) = &r {
375                        tracing::error!("error opening {kind}, {e}");
376                    }
377
378                    status.set(if r.is_ok() { Status::Ok } else { Status::Err });
379                }
380                #[cfg(target_arch = "wasm32")]
381                {
382                    match web_sys::window() {
383                        Some(w) => match w.open_with_url_and_target(uri.as_str(), "_blank") {
384                            Ok(w) => match w {
385                                Some(w) => {
386                                    let _ = w.focus();
387                                    status.set(Status::Ok);
388                                }
389                                None => {
390                                    tracing::error!("error opening {kind}, no new tab/window");
391                                    status.set(Status::Err);
392                                }
393                            },
394                            Err(e) => {
395                                tracing::error!("error opening {kind}, {e:?}");
396                                status.set(Status::Err);
397                            }
398                        },
399                        None => {
400                            tracing::error!("error opening {kind}, no window");
401                            status.set(Status::Err);
402                        }
403                    }
404                }
405
406                task::deadline(200.ms()).await;
407
408                LAYERS.remove(popup_id);
409            });
410        };
411        child_end = Button! {
412            style_fn = zng_wgt_button::LightStyle!();
413            padding = 3;
414            child = COPY_CMD.icon().present_data(());
415            on_click = async_hn_once!(status, |args: &ClickArgs| {
416                if status.get() != Status::Pending || args.timestamp().duration_since(open_time) < 300.ms() {
417                    return;
418                }
419
420                args.propagation().stop();
421
422                let txt = match link {
423                    Link::Url(u) => u.to_txt(),
424                    Link::Path(p) => p.display().to_txt(),
425                };
426
427                let r = CLIPBOARD.set_text(txt.clone()).wait_rsp().await;
428                if let Err(e) = &r {
429                    tracing::error!("error copying uri, {e}");
430                }
431
432                status.set(if r.is_ok() { Status::Ok } else { Status::Err });
433                task::deadline(200.ms()).await;
434
435                LAYERS.remove(popup_id);
436            });
437        };
438    };
439
440    LAYERS.insert_anchored(
441        LayerIndex::ADORNER,
442        args.link.widget_id(),
443        AnchorMode::popup(AnchorOffset::out_bottom()),
444        popup,
445    );
446
447    true
448}
449
450static_id! {
451    static ref ANCHOR_ID: StateId<Txt>;
452    pub(super) static ref MARKDOWN_INFO_ID: StateId<()>;
453}
454
455/// Set a label that identifies the widget in the context of the parent markdown.
456///
457/// The anchor can be retried in the widget info using [`WidgetInfoExt::anchor`]. It is mostly used
458/// by markdown links to find scroll targets.
459#[property(CONTEXT, default(""))]
460pub fn anchor(child: impl IntoUiNode, anchor: impl IntoVar<Txt>) -> UiNode {
461    let anchor = anchor.into_var();
462    match_node(child, move |_, op| match op {
463        UiNodeOp::Init => {
464            WIDGET.sub_var_info(&anchor);
465        }
466        UiNodeOp::Info { info } => {
467            info.set_meta(*ANCHOR_ID, anchor.get());
468        }
469        _ => {}
470    })
471}
472
473/// Markdown extension methods for widget info.
474pub trait WidgetInfoExt {
475    /// Gets the [`anchor`].
476    ///
477    /// [`anchor`]: fn@anchor
478    fn anchor(&self) -> Option<&Txt>;
479
480    /// If this widget is a [`Markdown!`].
481    ///
482    /// [`Markdown!`]: struct@crate::Markdown
483    fn is_markdown(&self) -> bool;
484
485    /// Find descendant tagged by the given anchor.
486    fn find_anchor(&self, anchor: &str) -> Option<WidgetInfo>;
487}
488impl WidgetInfoExt for WidgetInfo {
489    fn anchor(&self) -> Option<&Txt> {
490        self.meta().get(*ANCHOR_ID)
491    }
492
493    fn is_markdown(&self) -> bool {
494        self.meta().contains(*MARKDOWN_INFO_ID)
495    }
496
497    fn find_anchor(&self, anchor: &str) -> Option<WidgetInfo> {
498        self.descendants().find(|d| d.anchor().map(|a| a == anchor).unwrap_or(false))
499    }
500}
501
502/// Generate an anchor label for a header.
503pub fn heading_anchor(header: &str) -> Txt {
504    header.chars().filter_map(slugify).collect::<String>().into()
505}
506fn slugify(c: char) -> Option<char> {
507    if c.is_alphanumeric() || c == '-' || c == '_' {
508        if c.is_ascii() { Some(c.to_ascii_lowercase()) } else { Some(c) }
509    } else if c.is_whitespace() && c.is_ascii() {
510        Some('-')
511    } else {
512        None
513    }
514}