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 pub static IMAGE_RESOLVER_VAR: ImageResolver = ImageResolver::Default;
31
32 pub static LINK_RESOLVER_VAR: LinkResolver = LinkResolver::Default;
34
35 pub static LINK_SCROLL_MODE_VAR: ScrollToMode = ScrollToMode::minimal(10);
37}
38
39#[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#[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#[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#[derive(Clone)]
74pub enum ImageResolver {
75 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 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#[derive(Clone)]
125pub enum LinkResolver {
126 Default,
128 Resolve(Arc<dyn Fn(&str) -> Txt + Send + Sync>),
130}
131impl LinkResolver {
132 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 pub fn new(fn_: impl Fn(&str) -> Txt + Send + Sync + 'static) -> Self {
142 Self::Resolve(Arc::new(fn_))
143 }
144
145 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 (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 pub static LINK_EVENT: LinkArgs;
197}
198
199event_property! {
200 pub fn link {
202 event: LINK_EVENT,
203 args: LinkArgs,
204 }
205}
206
207event_args! {
208 pub struct LinkArgs {
210 pub url: Txt,
212
213 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
224pub fn try_default_link_action(args: &LinkArgs) -> bool {
228 try_scroll_link(args) || try_open_link(args)
229}
230
231pub fn try_scroll_link(args: &LinkArgs) -> bool {
238 if args.propagation().is_stopped() {
239 return false;
240 }
241 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 zng_wgt_scroll::cmd::scroll_to(target.clone(), LINK_SCROLL_MODE_VAR.get());
248
249 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
262pub 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#[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
482pub trait WidgetInfoExt {
484 fn anchor(&self) -> Option<&Txt>;
488
489 fn is_markdown(&self) -> bool;
493
494 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
511pub 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}