diff --git a/Cargo.lock b/Cargo.lock index 68788dc10..6c0ac18c5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -54,6 +54,7 @@ dependencies = [ "ttf-parser", "unicode-bidi", "ureq", + "url", "wasm-bindgen", "wasm-bindgen-futures", "web-sys", diff --git a/Cargo.toml b/Cargo.toml index 863a8571f..6d559022b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -21,6 +21,7 @@ serde_json = "1" serde = { version = "1", features = ["derive"] } base64 = "0.22" semver = "1" +url = "2" iced = { git = "https://github.com/iced-rs/iced.git", rev = "23604ff22ab0aad9e00b9327cb7b8546ed84db39", features = ["image", "svg", "advanced", "canvas", "smol", "markdown"] } iced_runtime = { git = "https://github.com/iced-rs/iced.git", rev = "23604ff22ab0aad9e00b9327cb7b8546ed84db39" } iced_core = { git = "https://github.com/iced-rs/iced.git", rev = "23604ff22ab0aad9e00b9327cb7b8546ed84db39" } diff --git a/src/app/update/viewport.rs b/src/app/update/viewport.rs index f9a0ff386..cfcd763a5 100644 --- a/src/app/update/viewport.rs +++ b/src/app/update/viewport.rs @@ -3250,6 +3250,36 @@ impl OpenCADStudio { } } + // Navigation, overlays and grips retain priority over document hyperlinks. + if self.ctrl_down + && self.tabs[i].active_cmd.is_none() + && self.tabs[i].scene.current_layout == "Model" + { + let (view_rot, eye) = { + let cam = self.tabs[i].scene.camera.borrow(); + (cam.view_proj_rte(bounds), cam.eye()) + }; + let wires = self.tabs[i].scene.hit_test_wires(); + let url = scene::pick::hit_test::click_hit( + p, + &*wires, + view_rot, + eye, + bounds, + self.tabs[i].scene.document.header.lineweight_display, + crate::ui::overlay::pick_box_aperture_px(self.pick_box), + ) + .and_then(Scene::handle_from_wire_name) + .and_then(|handle| self.tabs[i].scene.document.get_entity(handle)) + .and_then(scene::pe_url_of) + .and_then(crate::sys::web_hyperlink); + if let Some(url) = url { + self.tabs[i].scene.selection.borrow_mut().clear_left_selection_gesture(); + self.command_line.push_info(&format!("{}: {url}", crate::t!("Hyperlink"))); + return crate::sys::open_url(&url, self.main_window); + } + } + let mut sel = self.tabs[i].scene.selection.borrow_mut(); sel.left_down = true; // Stored in full-canvas space (like ViewportMove's cursor and @@ -5934,6 +5964,55 @@ mod selection_preview_tests { use super::*; use crate::app::{HoverDwell, OpenCADStudio, HOVER_DWELL_MS}; + #[cfg(target_os = "linux")] + #[test] + fn hyperlink_click_respects_existing_pointer_owners() { + use acadrust::EntityType; + use acadrust::entities::Line; + use acadrust::types::Vector3; + use acadrust::xdata::{ExtendedDataRecord, XDataValue}; + use glam::DVec3; + + for mode in ["link", "plain", "pan", "orbit", "zoom", "grip", "command"] { + let mut app = OpenCADStudio::new_for_test(); + app.automation_op(r#"{"op":"new"}"#); + let i = app.active_tab; + app.show_ucs_icon = false; + // Keep the browser-opening window task deferred during this test. + app.main_window = Some(iced::window::Id::unique()); + let mut line = Line::from_points( + Vector3::new(-10.0, 0.0, 0.0), + Vector3::new(10.0, 0.0, 0.0), + ); + let mut record = ExtendedDataRecord::new("PE_URL"); + record.add_value(XDataValue::String("https://example.com/linked".into())); + line.common.extended_data.add_record(record); + let handle = app.tabs[i].scene.add_entity(EntityType::Line(line)); + app.tabs[i].scene.selection.borrow_mut().vp_size = (800.0, 600.0); + let _ = app.run_command_line("ZOOM EXTENTS"); + app.tabs[i].scene.selection.borrow_mut().last_move_pos = Some(Point::new(400.0, 300.0)); + app.ctrl_down = mode != "plain"; + match mode { + "pan" => app.tabs[i].pan_mode = true, + "orbit" => app.tabs[i].orbit_mode = true, + "zoom" => app.tabs[i].zoom_dynamic_mode = true, + "grip" => app.tabs[i].active_grip = Some(GripEdit::single(handle, 0, false, DVec3::ZERO)), + "command" => { let _ = app.run_command_line("LINE"); } + _ => {} + } + let task = app.on_viewport_left_press(); + let opened = app.command_line.history.iter().any(|entry| entry.text.contains("https://example.com/linked")); + assert_eq!(opened, mode == "link", "{mode}"); + if mode == "link" { + assert!(task.units() > 0); + let selection = app.tabs[i].scene.selection.borrow(); + assert!(!selection.left_down && selection.box_anchor.is_none()); + } else if matches!(mode, "pan" | "orbit" | "zoom") { + assert!(app.tabs[i].scene.selection.borrow().middle_down, "{mode}"); + } + } + } + /// Drive one settled rollover pick over a line and report what the scene /// ended up highlighting. fn rollover_hits(preview: u8) -> bool { diff --git a/src/scene/cache/properties.rs b/src/scene/cache/properties.rs index 146704d63..4a08c1571 100644 --- a/src/scene/cache/properties.rs +++ b/src/scene/cache/properties.rs @@ -30,17 +30,7 @@ pub fn general_section(entity: &EntityType) -> PropSection { }, ); - // Hyperlink is stored in XDATA under the "PE_URL" application. - let hyperlink = common - .extended_data - .get_record("PE_URL") - .and_then(|r| { - r.values.iter().find_map(|v| match v { - acadrust::xdata::XDataValue::String(s) if !s.is_empty() => Some(s.clone()), - _ => None, - }) - }) - .unwrap_or_default(); + let hyperlink = crate::scene::pe_url_of(entity).unwrap_or_default().to_owned(); let mut section = PropSection { title: t!("General").into_owned(), diff --git a/src/scene/mod.rs b/src/scene/mod.rs index 51262e058..f42a4562c 100644 --- a/src/scene/mod.rs +++ b/src/scene/mod.rs @@ -46,6 +46,7 @@ mod preview; mod project; mod scene_markers; mod selection; +pub(crate) use selection::pe_url_of; pub mod sketch_constraints; mod sketch_persist; mod sketch_solve; @@ -12614,4 +12615,3 @@ mod layout_cache_tests { ); } } - diff --git a/src/scene/selection.rs b/src/scene/selection.rs index a98c7e119..46fdc260f 100644 --- a/src/scene/selection.rs +++ b/src/scene/selection.rs @@ -1,5 +1,17 @@ use super::*; +/// The first PE_URL string is the URL; later strings describe the link. +pub(crate) fn pe_url_of(entity: &EntityType) -> Option<&str> { + entity.common().extended_data.get_record("PE_URL") + .and_then(|record| { + record.values.iter().find_map(|value| match value { + acadrust::xdata::XDataValue::String(text) => Some(text.trim()), + _ => None, + }) + }) + .filter(|text| !text.is_empty()) +} + impl Scene { // ── Selection ───────────────────────────────────────────────────────── /// Treat a classic LEADER and its attached annotation as one logical object. @@ -824,21 +836,7 @@ impl Scene { ((alpha as f64 / 255.0 * 100.0).round() as u32).to_string() } }), - "hyperlink" => Some( - entity - .common() - .extended_data - .get_record("PE_URL") - .and_then(|record| { - record.values.iter().find_map(|value| match value { - acadrust::xdata::XDataValue::String(text) if !text.is_empty() => { - Some(text.clone()) - } - _ => None, - }) - }) - .unwrap_or_default(), - ), + "hyperlink" => Some(pe_url_of(entity).unwrap_or_default().to_owned()), "material" => Some( match entity.common().material_flags { 0 => "ByLayer", @@ -1116,6 +1114,44 @@ impl Scene { #[cfg(test)] mod tests { + #[test] + fn pe_url_of_reads_standard_hyperlink_xdata() { + use acadrust::entities::Point; + use acadrust::xdata::{ExtendedDataRecord, XDataValue}; + + let mut doc = CadDocument::new(); + + // An entity carrying a PE_URL hyperlink resolves to that link. + let mut linked = Point::new(); + let mut rec = ExtendedDataRecord::new("PE_URL"); + rec.add_value(XDataValue::String("https://example.com/gui".to_string())); + linked.common.extended_data.add_record(rec); + let linked_handle = doc + .add_entity(EntityType::Point(linked)) + .expect("linked entity added"); + assert_eq!( + pe_url_of(doc.get_entity(linked_handle).unwrap()), + Some("https://example.com/gui") + ); + + // No PE_URL record -> None. + let plain_handle = doc + .add_entity(EntityType::Point(Point::new())) + .expect("plain entity added"); + assert_eq!(pe_url_of(doc.get_entity(plain_handle).unwrap()), None); + + // Empty string in the record -> None. + let mut empty = Point::new(); + let mut empty_rec = ExtendedDataRecord::new("PE_URL"); + empty_rec.add_value(XDataValue::String(String::new())); + empty_rec.add_value(XDataValue::String("https://description.invalid/".into())); + empty.common.extended_data.add_record(empty_rec); + let empty_handle = doc + .add_entity(EntityType::Point(empty)) + .expect("empty-link entity added"); + assert_eq!(pe_url_of(doc.get_entity(empty_handle).unwrap()), None); + } + use super::*; #[test] diff --git a/src/sys.rs b/src/sys.rs index f53671640..8fc8a05e4 100644 --- a/src/sys.rs +++ b/src/sys.rs @@ -1,6 +1,30 @@ // Small platform shims for things the desktop build does natively but the web // (wasm) build must handle differently or skip. +/// Drawing hyperlinks may open web pages, never local files or custom handlers. +pub(crate) fn web_hyperlink(value: &str) -> Option { + if value.chars().any(char::is_control) { + return None; + } + let url = url::Url::parse(value.trim()).ok()?; + (matches!(url.scheme(), "http" | "https") && url.host_str().is_some()) + .then(|| url.into()) +} + +#[test] +fn drawing_hyperlinks_only_open_web_pages() { + for value in ["https://example.com/path", " HTTP://example.com "] { + assert!(web_hyperlink(value).is_some(), "{value}"); + } + for value in [ + "", "https://", "javascript:alert(1)", "data:text/html,example", + "file:///tmp/program.desktop", "/tmp/program.desktop", "custom:run", + "mailto:user@example.com", "https://example.com/\npath", + ] { + assert!(web_hyperlink(value).is_none(), "{value}"); + } +} + /// Open a URL in the user's browser. /// /// Wayland requires an xdg-activation token from the source window before it @@ -93,7 +117,7 @@ pub fn open_url( _parent: Option, ) -> iced::Task { if let Some(window) = web_sys::window() { - let _ = window.open_with_url_and_target(url, "_blank"); + let _ = window.open_with_url_and_target_and_features(url, "_blank", "noopener,noreferrer"); } iced::Task::none() }