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 pub fn link {
193 event: LINK_EVENT,
194 args: LinkArgs,
195 }
196}
197
198event_args! {
199 pub struct LinkArgs {
201 pub url: Txt,
203
204 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
215pub fn try_default_link_action(args: &LinkArgs) -> bool {
219 try_scroll_link(args) || try_open_link(args)
220}
221
222pub fn try_scroll_link(args: &LinkArgs) -> bool {
229 if args.propagation().is_stopped() {
230 return false;
231 }
232 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 zng_wgt_scroll::cmd::scroll_to(target.clone(), LINK_SCROLL_MODE_VAR.get());
240
241 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
253pub 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#[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
473pub trait WidgetInfoExt {
475 fn anchor(&self) -> Option<&Txt>;
479
480 fn is_markdown(&self) -> bool;
484
485 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
502pub 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}