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_app::widget::info::TransformChangedArgs;
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 UiNode, resolver: impl IntoVar<ImageResolver>) -> impl 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 UiNode, resolver: impl IntoVar<LinkResolver>) -> impl 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 UiNode, mode: impl IntoVar<ScrollToMode>) -> impl 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)]
74pub enum ImageResolver {
75    /// No extra resolution, just convert into [`ImageSource`].
76    ///
77    /// [`ImageSource`]: zng_ext_image::ImageSource
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 Default for ImageResolver {
97    fn default() -> Self {
98        Self::Default
99    }
100}
101impl fmt::Debug for ImageResolver {
102    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
103        if f.alternate() {
104            write!(f, "ImgSourceResolver::")?;
105        }
106        match self {
107            ImageResolver::Default => write!(f, "Default"),
108            ImageResolver::Resolve(_) => write!(f, "Resolve(_)"),
109        }
110    }
111}
112impl PartialEq for ImageResolver {
113    fn eq(&self, other: &Self) -> bool {
114        match (self, other) {
115            (Self::Resolve(l0), Self::Resolve(r0)) => Arc::ptr_eq(l0, r0),
116            _ => core::mem::discriminant(self) == core::mem::discriminant(other),
117        }
118    }
119}
120
121/// Markdown link resolver.
122///
123/// See [`LINK_RESOLVER_VAR`] for more details.
124#[derive(Clone)]
125pub enum LinkResolver {
126    /// No extra resolution, just pass the link provided.
127    Default,
128    /// Custom resolution.
129    Resolve(Arc<dyn Fn(&str) -> Txt + Send + Sync>),
130}
131impl LinkResolver {
132    /// Resolve the link.
133    pub fn resolve(&self, url: &str) -> Txt {
134        match self {
135            Self::Default => url.to_txt(),
136            Self::Resolve(r) => r(url),
137        }
138    }
139
140    /// New [`Resolve`](Self::Resolve).
141    pub fn new(fn_: impl Fn(&str) -> Txt + Send + Sync + 'static) -> Self {
142        Self::Resolve(Arc::new(fn_))
143    }
144
145    /// Resolve file links relative to `base`.
146    ///
147    /// The path is also absolutized, but not canonicalized.
148    pub fn base_dir(base: impl Into<PathBuf>) -> Self {
149        let base = base.into();
150        Self::new(move |url| {
151            if !url.starts_with('#') {
152                let is_not_uri = url.parse::<Uri>().is_err();
153
154                if is_not_uri {
155                    let path = Path::new(url);
156                    if let Ok(path) = base.join(path).absolutize() {
157                        return path.display().to_txt();
158                    }
159                }
160            }
161            url.to_txt()
162        })
163    }
164}
165impl Default for LinkResolver {
166    fn default() -> Self {
167        Self::Default
168    }
169}
170impl fmt::Debug for LinkResolver {
171    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
172        if f.alternate() {
173            write!(f, "LinkResolver::")?;
174        }
175        match self {
176            Self::Default => write!(f, "Default"),
177            Self::Resolve(_) => write!(f, "Resolve(_)"),
178        }
179    }
180}
181impl PartialEq for LinkResolver {
182    fn eq(&self, other: &Self) -> bool {
183        match (self, other) {
184            // can only fail by returning `false` in some cases where the value pointer is actually equal.
185            // see: https://github.com/rust-lang/rust/issues/103763
186            //
187            // we are fine with this, worst case is just an extra var update
188            (Self::Resolve(l0), Self::Resolve(r0)) => Arc::ptr_eq(l0, r0),
189            _ => core::mem::discriminant(self) == core::mem::discriminant(other),
190        }
191    }
192}
193
194event! {
195    /// Event raised by markdown links when clicked.
196    pub static LINK_EVENT: LinkArgs;
197}
198
199event_property! {
200    /// Markdown link click.
201    pub fn link {
202        event: LINK_EVENT,
203        args: LinkArgs,
204    }
205}
206
207event_args! {
208    /// Arguments for the [`LINK_EVENT`].
209    pub struct LinkArgs {
210        /// Raw URL.
211        pub url: Txt,
212
213        /// Link widget.
214        pub link: InteractionPath,
215
216        ..
217
218        fn delivery_list(&self, delivery_list: &mut UpdateDeliveryList) {
219            delivery_list.insert_wgt(self.link.as_path())
220        }
221    }
222}
223
224/// Default markdown link action.
225///
226/// Does [`try_scroll_link`] or [`try_open_link`].
227pub fn try_default_link_action(args: &LinkArgs) -> bool {
228    try_scroll_link(args) || try_open_link(args)
229}
230
231/// Handle `url` in the format `#anchor`, by scrolling and focusing the anchor.
232///
233/// If the anchor is found scrolls to it and moves focus to the `#anchor` widget,
234/// or the first focusable descendant of it, or the markdown widget or the first focusable ancestor of it.
235///
236/// Note that the request is handled even if the anchor is not found.
237pub fn try_scroll_link(args: &LinkArgs) -> bool {
238    if args.propagation().is_stopped() {
239        return false;
240    }
241    // Note: file names can start with #, but we are choosing to always interpret URLs with this prefix as an anchor.
242    if let Some(anchor) = args.url.strip_prefix('#') {
243        let tree = WINDOW.info();
244        if let Some(md) = tree.get(WIDGET.id()).and_then(|w| w.self_and_ancestors().find(|w| w.is_markdown())) {
245            if let Some(target) = md.find_anchor(anchor) {
246                // scroll-to
247                zng_wgt_scroll::cmd::scroll_to(target.clone(), LINK_SCROLL_MODE_VAR.get());
248
249                // focus if target if focusable
250                if let Some(focus) = target.into_focus_info(true, true).self_and_descendants().find(|w| w.is_focusable()) {
251                    FOCUS.focus_widget(focus.info().id(), false);
252                }
253            }
254        }
255        args.propagation().stop();
256        return true;
257    }
258
259    false
260}
261
262/// Try open link, only works if the `url` is valid or a file path, returns if the confirm tooltip is visible.
263pub fn try_open_link(args: &LinkArgs) -> bool {
264    if args.propagation().is_stopped() {
265        return false;
266    }
267
268    #[derive(Clone)]
269    enum Link {
270        Url(Uri),
271        Path(PathBuf),
272    }
273
274    let link = if let Ok(url) = args.url.parse() {
275        Link::Url(url)
276    } else {
277        Link::Path(PathBuf::from(args.url.as_str()))
278    };
279
280    let popup_id = WidgetId::new_unique();
281
282    let url = args.url.clone();
283
284    #[derive(Clone, Debug, PartialEq)]
285    enum Status {
286        Pending,
287        Ok,
288        Err,
289        Cancel,
290    }
291    let status = var(Status::Pending);
292
293    let open_time = INSTANT.now();
294
295    let popup = Container! {
296        id = popup_id;
297
298        padding = (2, 4);
299        corner_radius = 2;
300        drop_shadow = (2, 2), 2, colors::BLACK.with_alpha(50.pct());
301        align = Align::TOP_LEFT;
302
303        #[easing(200.ms())]
304        opacity = 0.pct();
305        #[easing(200.ms())]
306        offset = (0, -10);
307
308        background_color = light_dark(colors::WHITE.with_alpha(90.pct()), colors::BLACK.with_alpha(90.pct()));
309
310        when *#{status.clone()} == Status::Pending {
311            opacity = 100.pct();
312            offset = (0, 0);
313        }
314        when *#{status.clone()} == Status::Err {
315            background_color = light_dark(web_colors::PINK.with_alpha(90.pct()), web_colors::DARK_RED.with_alpha(90.pct()));
316        }
317
318        on_focus_leave = async_hn_once!(status, |_| {
319            if status.get() != Status::Pending {
320                return;
321            }
322
323            status.set(Status::Cancel);
324            task::deadline(200.ms()).await;
325
326            LAYERS.remove(popup_id);
327        });
328        on_move = async_hn!(status, |args: TransformChangedArgs| {
329            if status.get() != Status::Pending || args.timestamp().duration_since(open_time) < 300.ms() {
330                return;
331            }
332
333            status.set(Status::Cancel);
334            task::deadline(200.ms()).await;
335
336            LAYERS.remove(popup_id);
337        });
338
339        child = Button! {
340            style_fn = zng_wgt_button::LinkStyle!();
341
342            focus_on_init = true;
343
344            child = Text!(url);
345            child_end = ICONS.get_or("arrow-outward", || Text!("🡵")), 2;
346
347            text::underline_skip = text::UnderlineSkip::SPACES;
348
349            on_click = async_hn_once!(status, link, |args: ClickArgs| {
350                if status.get() != Status::Pending || args.timestamp().duration_since(open_time) < 300.ms() {
351                    return;
352                }
353
354                args.propagation().stop();
355
356                let (uri, kind) = match link {
357                    Link::Url(u) => (u.to_string(), "url"),
358                    Link::Path(p) => {
359                        match dunce::canonicalize(&p) {
360                            Ok(p) => {
361                                let p = p.display().to_string();
362                                #[cfg(windows)]
363                                let p = p.replace('/', "\\");
364
365                                #[cfg(target_arch = "wasm32")]
366                                let p = format!("file:///{p}");
367
368                                (p, "path")
369                            },
370                            Err(e) => {
371                                tracing::error!("error canonicalizing \"{}\", {e}", p.display());
372                                return;
373                            }
374                        }
375                    }
376                };
377
378                #[cfg(not(target_arch = "wasm32"))]
379                {
380                    let r = task::wait( || open::that_detached(uri)).await;
381                    if let Err(e) = &r {
382                        tracing::error!("error opening {kind}, {e}");
383                    }
384
385                    status.set(if r.is_ok() { Status::Ok } else { Status::Err });
386                }
387                #[cfg(target_arch = "wasm32")]
388                {
389                    match web_sys::window() {
390                        Some(w) => {
391                            match w.open_with_url_and_target(uri.as_str(), "_blank") {
392                                Ok(w) => match w {
393                                    Some(w) => {
394                                        let _ = w.focus();
395                                        status.set(Status::Ok);
396                                    },
397                                    None => {
398                                        tracing::error!("error opening {kind}, no new tab/window");
399                                    status.set(Status::Err);
400                                    }
401                                },
402                                Err(e) => {
403                                    tracing::error!("error opening {kind}, {e:?}");
404                                    status.set(Status::Err);
405                                }
406                            }
407                        },
408                        None => {
409                            tracing::error!("error opening {kind}, no window");
410                            status.set(Status::Err);
411                        }
412                    }
413                }
414
415                task::deadline(200.ms()).await;
416
417                LAYERS.remove(popup_id);
418            });
419        };
420        child_end = Button! {
421            style_fn = zng_wgt_button::LightStyle!();
422            padding = 3;
423            child = presenter((), COPY_CMD.icon());
424            on_click = async_hn_once!(status, |args: ClickArgs| {
425                if status.get() != Status::Pending || args.timestamp().duration_since(open_time) < 300.ms() {
426                    return;
427                }
428
429                args.propagation().stop();
430
431                let txt = match link {
432                    Link::Url(u) => u.to_txt(),
433                    Link::Path(p) => p.display().to_txt(),
434                };
435
436                let r = CLIPBOARD.set_text(txt.clone()).wait_into_rsp().await;
437                if let Err(e) = &r {
438                    tracing::error!("error copying uri, {e}");
439                }
440
441                status.set(if r.is_ok() { Status::Ok } else { Status::Err });
442                task::deadline(200.ms()).await;
443
444                LAYERS.remove(popup_id);
445            });
446        }, 0;
447    };
448
449    LAYERS.insert_anchored(
450        LayerIndex::ADORNER,
451        args.link.widget_id(),
452        AnchorMode::popup(AnchorOffset::out_bottom()),
453        popup,
454    );
455
456    true
457}
458
459static_id! {
460    static ref ANCHOR_ID: StateId<Txt>;
461    pub(super) static ref MARKDOWN_INFO_ID: StateId<()>;
462}
463
464/// Set a label that identifies the widget in the context of the parent markdown.
465///
466/// The anchor can be retried in the widget info using [`WidgetInfoExt::anchor`]. It is mostly used
467/// by markdown links to find scroll targets.
468#[property(CONTEXT, default(""))]
469pub fn anchor(child: impl UiNode, anchor: impl IntoVar<Txt>) -> impl UiNode {
470    let anchor = anchor.into_var();
471    match_node(child, move |_, op| match op {
472        UiNodeOp::Init => {
473            WIDGET.sub_var_info(&anchor);
474        }
475        UiNodeOp::Info { info } => {
476            info.set_meta(*ANCHOR_ID, anchor.get());
477        }
478        _ => {}
479    })
480}
481
482/// Markdown extension methods for widget info.
483pub trait WidgetInfoExt {
484    /// Gets the [`anchor`].
485    ///
486    /// [`anchor`]: fn@anchor
487    fn anchor(&self) -> Option<&Txt>;
488
489    /// If this widget is a [`Markdown!`].
490    ///
491    /// [`Markdown!`]: struct@crate::Markdown
492    fn is_markdown(&self) -> bool;
493
494    /// Find descendant tagged by the given anchor.
495    fn find_anchor(&self, anchor: &str) -> Option<WidgetInfo>;
496}
497impl WidgetInfoExt for WidgetInfo {
498    fn anchor(&self) -> Option<&Txt> {
499        self.meta().get(*ANCHOR_ID)
500    }
501
502    fn is_markdown(&self) -> bool {
503        self.meta().contains(*MARKDOWN_INFO_ID)
504    }
505
506    fn find_anchor(&self, anchor: &str) -> Option<WidgetInfo> {
507        self.descendants().find(|d| d.anchor().map(|a| a == anchor).unwrap_or(false))
508    }
509}
510
511/// Generate an anchor label for a header.
512pub fn heading_anchor(header: &str) -> Txt {
513    header.chars().filter_map(slugify).collect::<String>().into()
514}
515fn slugify(c: char) -> Option<char> {
516    if c.is_alphanumeric() || c == '-' || c == '_' {
517        if c.is_ascii() { Some(c.to_ascii_lowercase()) } else { Some(c) }
518    } else if c.is_whitespace() && c.is_ascii() {
519        Some('-')
520    } else {
521        None
522    }
523}