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    #[property(EVENT)]
193    pub fn on_link<on_pre_link>(child: impl IntoUiNode, handler: Handler<LinkArgs>) -> UiNode {
194        const PRE: bool;
195        EventNodeBuilder::new(LINK_EVENT).build::<PRE>(child, handler)
196    }
197}
198
199event_args! {
200    /// Arguments for the [`LINK_EVENT`].
201    pub struct LinkArgs {
202        /// Raw URL.
203        pub url: Txt,
204
205        /// Link widget.
206        pub link: InteractionPath,
207
208        ..
209
210        fn is_in_target(&self, id: WidgetId) -> bool {
211            self.link.contains(id)
212        }
213    }
214}
215
216/// Default markdown link action.
217///
218/// Does [`try_scroll_link`] or [`try_open_link`].
219pub fn try_default_link_action(args: &LinkArgs) -> bool {
220    try_scroll_link(args) || try_open_link(args)
221}
222
223/// Handle `url` in the format `#anchor`, by scrolling and focusing the anchor.
224///
225/// If the anchor is found scrolls to it and moves focus to the `#anchor` widget,
226/// or the first focusable descendant of it, or the markdown widget or the first focusable ancestor of it.
227///
228/// Note that the request is handled even if the anchor is not found.
229pub fn try_scroll_link(args: &LinkArgs) -> bool {
230    if args.propagation.is_stopped() {
231        return false;
232    }
233    // Note: file names can start with #, but we are choosing to always interpret URLs with this prefix as an anchor.
234    if let Some(anchor) = args.url.strip_prefix('#') {
235        let tree = WINDOW.info();
236        if let Some(md) = tree.get(WIDGET.id()).and_then(|w| w.self_and_ancestors().find(|w| w.is_markdown()))
237            && let Some(target) = md.find_anchor(anchor)
238        {
239            // scroll-to
240            zng_wgt_scroll::cmd::scroll_to(target.clone(), LINK_SCROLL_MODE_VAR.get());
241
242            // focus if target if focusable
243            if let Some(focus) = target.into_focus_info(true, true).self_and_descendants().find(|w| w.is_focusable()) {
244                FOCUS.focus_widget(focus.info().id(), false);
245            }
246        }
247        args.propagation.stop();
248        return true;
249    }
250
251    false
252}
253
254/// Try open link, only works if the `url` is valid or a file path, returns if the confirm tooltip is visible.
255pub fn try_open_link(args: &LinkArgs) -> bool {
256    if args.propagation.is_stopped() {
257        return false;
258    }
259
260    #[derive(Clone)]
261    enum Link {
262        Url(Uri),
263        Path(PathBuf),
264    }
265
266    let link = if let Ok(url) = args.url.parse() {
267        Link::Url(url)
268    } else {
269        Link::Path(PathBuf::from(args.url.as_str()))
270    };
271
272    let popup_id = WidgetId::new_unique();
273
274    let url = args.url.clone();
275
276    #[derive(Clone, Debug, PartialEq)]
277    enum Status {
278        Pending,
279        Ok,
280        Err,
281        Cancel,
282    }
283    let status = var(Status::Pending);
284
285    let open_time = INSTANT.now();
286
287    let popup = Container! {
288        id = popup_id;
289
290        padding = (2, 4);
291        corner_radius = 2;
292        drop_shadow = (2, 2), 2, colors::BLACK.with_alpha(50.pct());
293        align = Align::TOP_LEFT;
294
295        #[easing(200.ms())]
296        opacity = 0.pct();
297        #[easing(200.ms())]
298        offset = (0, -10);
299
300        background_color = light_dark(colors::WHITE.with_alpha(90.pct()), colors::BLACK.with_alpha(90.pct()));
301
302        when *#{status.clone()} == Status::Pending {
303            opacity = 100.pct();
304            offset = (0, 0);
305        }
306        when *#{status.clone()} == Status::Err {
307            background_color = light_dark(
308                web_colors::PINK.with_alpha(90.pct()),
309                web_colors::DARK_RED.with_alpha(90.pct()),
310            );
311        }
312
313        on_focus_leave = async_hn_once!(status, |_| {
314            if status.get() != Status::Pending {
315                return;
316            }
317
318            status.set(Status::Cancel);
319            task::deadline(200.ms()).await;
320
321            LAYERS.remove(popup_id);
322        });
323
324        child = Button! {
325            style_fn = zng_wgt_button::LinkStyle!();
326
327            focus_on_init = true;
328
329            child = Text!(url);
330            child_spacing = 2;
331            child_end = ICONS.get_or("arrow-outward", || Text!("🡵"));
332
333            text::underline_skip = text::UnderlineSkip::SPACES;
334
335            on_click = async_hn_once!(status, link, |args: &ClickArgs| {
336                if status.get() != Status::Pending || args.timestamp.duration_since(open_time) < 300.ms() {
337                    return;
338                }
339
340                args.propagation.stop();
341
342                let (uri, kind) = match link {
343                    Link::Url(u) => (u.to_string(), "url"),
344                    Link::Path(p) => match dunce::canonicalize(&p) {
345                        Ok(p) => {
346                            let p = p.display().to_string();
347                            #[cfg(windows)]
348                            let p = p.replace('/', "\\");
349
350                            #[cfg(target_arch = "wasm32")]
351                            let p = format!("file:///{p}");
352
353                            (p, "path")
354                        }
355                        Err(e) => {
356                            tracing::error!("error canonicalizing \"{}\", {e}", p.display());
357                            return;
358                        }
359                    },
360                };
361
362                #[cfg(not(target_arch = "wasm32"))]
363                {
364                    let r = task::wait(|| open::that_detached(uri)).await;
365                    if let Err(e) = &r {
366                        tracing::error!("error opening {kind}, {e}");
367                    }
368
369                    status.set(if r.is_ok() { Status::Ok } else { Status::Err });
370                }
371                #[cfg(target_arch = "wasm32")]
372                {
373                    match web_sys::window() {
374                        Some(w) => match w.open_with_url_and_target(uri.as_str(), "_blank") {
375                            Ok(w) => match w {
376                                Some(w) => {
377                                    let _ = w.focus();
378                                    status.set(Status::Ok);
379                                }
380                                None => {
381                                    tracing::error!("error opening {kind}, no new tab/window");
382                                    status.set(Status::Err);
383                                }
384                            },
385                            Err(e) => {
386                                tracing::error!("error opening {kind}, {e:?}");
387                                status.set(Status::Err);
388                            }
389                        },
390                        None => {
391                            tracing::error!("error opening {kind}, no window");
392                            status.set(Status::Err);
393                        }
394                    }
395                }
396
397                task::deadline(200.ms()).await;
398
399                LAYERS.remove(popup_id);
400            });
401        };
402        child_end = Button! {
403            style_fn = zng_wgt_button::LightStyle!();
404            padding = 3;
405            child = COPY_CMD.icon().present_data(());
406            on_click = async_hn_once!(status, |args: &ClickArgs| {
407                if status.get() != Status::Pending || args.timestamp.duration_since(open_time) < 300.ms() {
408                    return;
409                }
410
411                args.propagation.stop();
412
413                let txt = match link {
414                    Link::Url(u) => u.to_txt(),
415                    Link::Path(p) => p.display().to_txt(),
416                };
417
418                let r = CLIPBOARD.set_text(txt.clone()).wait_rsp().await;
419                if let Err(e) = &r {
420                    tracing::error!("error copying uri, {e}");
421                }
422
423                status.set(if r.is_ok() { Status::Ok } else { Status::Err });
424                task::deadline(200.ms()).await;
425
426                LAYERS.remove(popup_id);
427            });
428        };
429    };
430
431    LAYERS.insert_anchored(
432        LayerIndex::ADORNER,
433        args.link.widget_id(),
434        AnchorMode::popup(AnchorOffset::out_bottom()),
435        popup,
436    );
437
438    true
439}
440
441static_id! {
442    static ref ANCHOR_ID: StateId<Txt>;
443    pub(super) static ref MARKDOWN_INFO_ID: StateId<()>;
444}
445
446/// Set a label that identifies the widget in the context of the parent markdown.
447///
448/// The anchor can be retried in the widget info using [`WidgetInfoExt::anchor`]. It is mostly used
449/// by markdown links to find scroll targets.
450#[property(CONTEXT, default(""))]
451pub fn anchor(child: impl IntoUiNode, anchor: impl IntoVar<Txt>) -> UiNode {
452    let anchor = anchor.into_var();
453    match_node(child, move |_, op| match op {
454        UiNodeOp::Init => {
455            WIDGET.sub_var_info(&anchor);
456        }
457        UiNodeOp::Info { info } => {
458            info.set_meta(*ANCHOR_ID, anchor.get());
459        }
460        _ => {}
461    })
462}
463
464/// Markdown extension methods for widget info.
465pub trait WidgetInfoExt {
466    /// Gets the [`anchor`].
467    ///
468    /// [`anchor`]: fn@anchor
469    fn anchor(&self) -> Option<&Txt>;
470
471    /// If this widget is a [`Markdown!`].
472    ///
473    /// [`Markdown!`]: struct@crate::Markdown
474    fn is_markdown(&self) -> bool;
475
476    /// Find descendant tagged by the given anchor.
477    fn find_anchor(&self, anchor: &str) -> Option<WidgetInfo>;
478}
479impl WidgetInfoExt for WidgetInfo {
480    fn anchor(&self) -> Option<&Txt> {
481        self.meta().get(*ANCHOR_ID)
482    }
483
484    fn is_markdown(&self) -> bool {
485        self.meta().contains(*MARKDOWN_INFO_ID)
486    }
487
488    fn find_anchor(&self, anchor: &str) -> Option<WidgetInfo> {
489        self.descendants().find(|d| d.anchor().map(|a| a == anchor).unwrap_or(false))
490    }
491}
492
493/// Generate an anchor label for a header.
494pub fn heading_anchor(header: &str) -> Txt {
495    header.chars().filter_map(slugify).collect::<String>().into()
496}
497fn slugify(c: char) -> Option<char> {
498    if c.is_alphanumeric() || c == '-' || c == '_' {
499        if c.is_ascii() { Some(c.to_ascii_lowercase()) } else { Some(c) }
500    } else if c.is_whitespace() && c.is_ascii() {
501        Some('-')
502    } else {
503        None
504    }
505}