forked from 1b-agent/opencadstudio-upstream
Merge pull request #1234 with safe hyperlink routing
This commit is contained in:
commit
84836b0f0c
7 files changed
+159
-28
No files matched your search
Generated
+1
@@ -54,6 +54,7 @@ dependencies = [
|
||||
"ttf-parser",
|
||||
"unicode-bidi",
|
||||
"ureq",
|
||||
"url",
|
||||
"wasm-bindgen",
|
||||
"wasm-bindgen-futures",
|
||||
"web-sys",
|
||||
|
||||
@@ -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" }
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Vendored
+1
-11
@@ -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(),
|
||||
|
||||
+1
-1
@@ -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 {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+51
-15
@@ -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]
|
||||
|
||||
+25
-1
@@ -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<String> {
|
||||
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<Message>(
|
||||
_parent: Option<iced::window::Id>,
|
||||
) -> iced::Task<Message> {
|
||||
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()
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user