From 09b28f9b2d69051e2d77c259578167782deebb0c Mon Sep 17 00:00:00 2001 From: watchakorn-18k Date: Sat, 24 Oct 2026 22:34:35 +0700 Subject: [PATCH] M8: Copy link to selection in the layer context menu Edit > Copy as > Copy link to selection (edit.copyLink) was only in the main menu. Add it to the right-click menu on a layer, after Copy as PNG, labelled with the command's own translated label. The command now says "Link copied" when it copies, and copies nothing when no layer is selected (it used to put figcraft://node/0 on the clipboard). --- crates/ui-egui/src/context_menu_tests.rs | 55 ++++++++++++++++++++++++ crates/ui-egui/src/lib.rs | 2 + crates/ui-egui/src/menus.rs | 10 ++++- 3 files changed, 65 insertions(+), 2 deletions(-) create mode 100644 crates/ui-egui/src/context_menu_tests.rs diff --git a/crates/ui-egui/src/context_menu_tests.rs b/crates/ui-egui/src/context_menu_tests.rs new file mode 100644 index 00000000..eb594c69 --- /dev/null +++ b/crates/ui-egui/src/context_menu_tests.rs @@ -0,0 +1,55 @@ +//! The right-click menu on a layer: "Copy link to selection" puts `figcraft://node/` on the +//! clipboard and says so; with nothing selected the command copies nothing. + +use std::sync::{Arc, Mutex}; + +use egui::Pos2; +use figcraft_engine::Session; +use serde_json::json; + +use crate::layers_drag_tests::Ui; +use crate::live_tests::{flat, text_center}; +use crate::present_tests::id; + +/// A clipboard that keeps what was copied where the test can read it. +struct Copied(Arc>>); + +impl crate::Services for Copied { + fn clipboard_set_text(&mut self, s: &str) { + if let Ok(mut copied) = self.0.lock() { + copied.push(s.to_string()); + } + } +} + +#[test] +fn the_context_menu_copies_a_link_to_the_selected_frame() { + let mut s = Session::new(); + let f = id(&s.execute("frame.create", &json!({"x": 0, "y": 0, "width": 300, "height": 200, "name": "หน้าแรก"})).unwrap()); + let copied = Arc::new(Mutex::new(Vec::new())); + let mut u = Ui::new(s); + u.app.services = Box::new(Copied(copied.clone())); + u.frame(vec![]); + u.app.ui.context_menu = Some((Pos2::new(700.0, 300.0), vec![f])); + u.frame(vec![]); + let shapes = flat(&u.frame_output(vec![]).shapes); + let at = text_center(&shapes, "Copy link to selection"); + u.button(at, true); + u.button(at, false); + assert_eq!(*copied.lock().unwrap(), vec![format!("figcraft://node/{}", f.0)], "the link to the frame"); + assert_eq!(u.app.ui.toast.as_ref().map(|t| t.0.as_str()), Some("Link copied")); + assert!(u.app.ui.context_menu.is_none(), "the menu closes"); +} + +#[test] +fn copying_a_link_with_nothing_selected_copies_nothing() { + let mut s = Session::new(); + s.execute("frame.create", &json!({"x": 0, "y": 0, "width": 300, "height": 200})).unwrap(); + s.execute("select.none", &json!({})).unwrap(); + let copied = Arc::new(Mutex::new(Vec::new())); + let mut u = Ui::new(s); + u.app.services = Box::new(Copied(copied.clone())); + crate::menus::invoke(&mut u.app, "edit.copyLink"); + assert!(copied.lock().unwrap().is_empty(), "no link to node 0"); + assert!(u.app.ui.toast.is_none()); +} diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index da40bf0c..165ea5db 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -98,6 +98,8 @@ mod comments_tests; #[cfg(test)] mod component_props_reorder_tests; #[cfg(test)] +mod context_menu_tests; +#[cfg(test)] mod corner_individual_tests; #[cfg(test)] mod cursors_tests; diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index eeb25e3d..ef48719d 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -219,8 +219,10 @@ pub fn invoke(app: &mut FigApp, id: &str) { app.toast(tr("Copied as text")); } "edit.copyLink" => { - let ids = app.session.selection(); - app.services.clipboard_set_text(&format!("figcraft://node/{}", ids.first().map(|i| i.0).unwrap_or(0))); + if let Some(id) = app.session.selection().first() { + app.services.clipboard_set_text(&format!("figcraft://node/{}", id.0)); + app.toast(tr("Link copied")); + } } "edit.findReplace" => app.ui.find = Some((String::new(), String::new())), // The Actions palette invokes commands without parameters. These values are edited in @@ -895,6 +897,7 @@ pub fn popups(app: &mut FigApp, ctx: &egui::Context) { .ok() .filter(|doc| figcraft_engine::cmd::component::can_combine_variants(doc, &app.session.selection())) .map(|_| i18n::command("component.combineVariants", "Combine as variants")); + let copy_link = i18n::command("edit.copyLink", "Copy link to selection"); let mut entries: Vec<(&str, &str)> = if has_sel { vec![ (tr("Copy"), "edit.copy"), @@ -957,6 +960,9 @@ pub fn popups(app: &mut FigApp, ctx: &egui::Context) { { entries.insert(at + 1, (label, "component.combineVariants")); } + if let Some(at) = entries.iter().position(|e| e.1 == "edit.copyAsPng") { + entries.insert(at + 1, (©_link, "edit.copyLink")); + } // A right-clicked ruler guide (selected, with no layers) can be removed. if !has_sel && app.session.ds().is_ok_and(|d| d.selected_guide().is_some()) { entries.splice(0..0, [(tr("Remove guide"), "guide.remove"), ("-", "")]);