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 pub static IMAGE_RESOLVER_VAR: ImageResolver = ImageResolver::Default;
30
31 pub static LINK_RESOLVER_VAR: LinkResolver = LinkResolver::Default;
33
34 pub static LINK_SCROLL_MODE_VAR: ScrollToMode = ScrollToMode::minimal(10);
36}
37
38#[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#[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#[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#[derive(Clone, Default)]
73pub enum ImageResolver {
74 #[default]
78 Default,
79 Resolve(Arc<dyn Fn(&str) -> ImageSource + Send + Sync>),
81}
82impl ImageResolver {
83 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 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#[derive(Clone, Default)]
120pub enum LinkResolver {
121 #[default]
123 Default,
124 Resolve(Arc<dyn Fn(&str) -> Txt + Send + Sync>),
126}
127impl LinkResolver {
128 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 pub fn new(fn_: impl Fn(&str) -> Txt + Send + Sync + 'static) -> Self {
138 Self::Resolve(Arc::new(fn_))
139 }
140
141 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 (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 pub static LINK_EVENT: LinkArgs;
188}
189
190event_property! {
191 #[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 pub struct LinkArgs {
202 pub url: Txt,
204
205 pub link: InteractionPath,
207
208 ..
209
210 fn is_in_target(&self, id: WidgetId) -> bool {
211 self.link.contains(id)
212 }
213 }
214}
215
216pub fn try_default_link_action(args: &LinkArgs) -> bool {
220 try_scroll_link(args) || try_open_link(args)
221}
222
223pub fn try_scroll_link(args: &LinkArgs) -> bool {
230 if args.propagation.is_stopped() {
231 return false;
232 }
233 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 zng_wgt_scroll::cmd::scroll_to(target.clone(), LINK_SCROLL_MODE_VAR.get());
241
242 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
254pub 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#[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
464pub trait WidgetInfoExt {
466 fn anchor(&self) -> Option<&Txt>;
470
471 fn is_markdown(&self) -> bool;
475
476 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
493pub 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}