mirror of
https://github.com/storytold/figcraft.git
synced 2026-11-03 00:39:44 -06:00
M4: Alt+Enter while editing text opens the link at the caret (text.openLink) (#1860)
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com> Co-authored-by: Brandon Thomas <echelon@gmail.com>
This commit is contained in:
co-authored by
Claude Fable 5.1
Brandon Thomas
parent
f9d507f7b0
commit
1f0acd2bbd
@@ -521,6 +521,32 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
textedit::end(s)?;
|
||||
ok()
|
||||
}),
|
||||
spec!(
|
||||
"text.openLink",
|
||||
"Open link",
|
||||
"{} while editing: the link at the caret (the selection's start) goes to the UI to open, as Alt+Enter does (\"press Option Return / Alt Enter to activate a focused link\"); only a web or mail link opens → {url} ({url: null}: no link there)",
|
||||
false,
|
||||
|s, _| {
|
||||
let Some(te) = s.ds()?.text_edit.clone() else { return Err(bad("text.openLink", "no text is being edited")) };
|
||||
let doc = s.doc()?;
|
||||
let t = doc.node(te.id).and_then(|n| n.kind.text()).ok_or_else(|| bad("text.openLink", format!("{} is not text", te.id)))?;
|
||||
let n = t.char_count();
|
||||
let at = te.caret.min(te.anchor);
|
||||
// The caret sits between two chars: the link of the one after it, else before it.
|
||||
let link = (at < n)
|
||||
.then(|| t.style_at(at).link.clone())
|
||||
.flatten()
|
||||
.or_else(|| (at > 0 && at <= n).then(|| t.style_at(at - 1).link.clone()).flatten());
|
||||
match link {
|
||||
Some(url) if crate::doc_urls::openable(&url) => {
|
||||
s.request_ui("open.url", json!({"url": url}));
|
||||
Ok(json!({"url": url}))
|
||||
}
|
||||
Some(url) => Err(bad("text.openLink", format!("`{url}` is not a web or mail link"))),
|
||||
None => Ok(json!({"url": Value::Null})),
|
||||
}
|
||||
}
|
||||
),
|
||||
spec!(
|
||||
"text.fonts",
|
||||
"List fonts",
|
||||
|
||||
@@ -247,6 +247,7 @@ pub static KEYS: &[Binding] = &[
|
||||
// Text (on selected text layers, and on the selected characters while editing)
|
||||
k(Text, "text.bold", &["Cmd+B"]).both(),
|
||||
k(Text, "text.italic", &["Cmd+I"]).both(),
|
||||
k(Text, "text.openLink", &["Alt+Enter"]).both(),
|
||||
k(Text, "text.underline", &["Cmd+U"]).both(),
|
||||
k(Text, "text.strikethrough", &["Shift+Cmd+X"]).both(),
|
||||
k(Text, "text.link", &["Shift+Cmd+U", "Cmd+K"]).text(),
|
||||
|
||||
@@ -565,6 +565,8 @@ mod text_behaviour_tests;
|
||||
#[cfg(test)]
|
||||
mod text_fill_tests;
|
||||
#[cfg(test)]
|
||||
mod text_link_tests;
|
||||
#[cfg(test)]
|
||||
mod text_metrics_local_tests;
|
||||
#[cfg(test)]
|
||||
mod text_motion_tests;
|
||||
|
||||
@@ -181,6 +181,7 @@ static ROWS: &[Row] = &[
|
||||
pin(Zoom, "view.nextFrame", &["N", "End"], &[]),
|
||||
// Text
|
||||
doc(Text, "Bold", &["Cmd+B"], &[], Bound("text.bold"), ""),
|
||||
doc(Text, "Activate a focused link", &["Alt+Enter"], &[], Bound("text.openLink"), "while editing: the link at the caret; web and mail links only"),
|
||||
doc(Text, "Italic", &["Cmd+I"], &[], Bound("text.italic"), ""),
|
||||
doc(Text, "Underline", &["Cmd+U"], &[], Bound("text.underline"), ""),
|
||||
doc(Text, "Strikethrough", &["Shift+Cmd+X"], &[], Bound("text.strikethrough"), ""),
|
||||
@@ -412,7 +413,7 @@ fn counts_match_the_docs() {
|
||||
let reserved = documented.iter().filter(|r| !r.reserved.is_empty()).count();
|
||||
let pinned = ROWS.len() - documented.len();
|
||||
// docs/ui-parity.md quotes these.
|
||||
assert_eq!((documented.len(), bound, moved, missing, reserved, pinned), (133, 132, 1, 0, 2, 57));
|
||||
assert_eq!((documented.len(), bound, moved, missing, reserved, pinned), (134, 133, 1, 0, 2, 57));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
//! Alt+Enter while editing text activates the link at the caret ("When editing text, press
|
||||
//! ⌥Option Return/Alt Enter to activate a focused link", Accessibility at Figma): `text.openLink`
|
||||
//! hands a web or mail link at the caret to the UI, refuses other schemes, and does nothing where
|
||||
//! there is no link.
|
||||
|
||||
use serde_json::json;
|
||||
|
||||
use crate::Session;
|
||||
use crate::params::id_of;
|
||||
|
||||
#[test]
|
||||
fn the_link_at_the_caret_goes_to_the_ui_and_only_a_web_or_mail_link() {
|
||||
let mut s = Session::new();
|
||||
let t = id_of(&s.execute("text.create", &json!({"text": "See the docs now", "x": 0, "y": 0})).unwrap()["id"]).unwrap();
|
||||
s.execute("text.style", &json!({"id": t, "start": 4, "end": 12, "hyperlink": "https://example.com/docs"})).unwrap();
|
||||
assert!(s.execute("text.openLink", &json!({})).is_err(), "nothing to activate while no text is edited");
|
||||
s.execute("text.edit.begin", &json!({"id": t, "caret": 6})).unwrap();
|
||||
let r = s.execute("text.openLink", &json!({})).unwrap();
|
||||
assert_eq!(r["url"], "https://example.com/docs");
|
||||
let req = s.ui_requests.iter().find(|r| r.kind == "open.url").expect("a UI request to open it");
|
||||
assert_eq!(req.data["url"], "https://example.com/docs");
|
||||
assert!(s.ds().unwrap().text_edit.is_some(), "still editing");
|
||||
// Right after the link's last char the caret still counts as on it; before its start it doesn't.
|
||||
s.execute("text.edit.caret", &json!({"index": 12})).unwrap();
|
||||
assert_eq!(s.execute("text.openLink", &json!({})).unwrap()["url"], "https://example.com/docs");
|
||||
s.execute("text.edit.caret", &json!({"index": 2})).unwrap();
|
||||
assert!(s.execute("text.openLink", &json!({})).unwrap()["url"].is_null(), "no link there");
|
||||
// A selection: its start decides.
|
||||
s.execute("text.edit.caret", &json!({"index": 14})).unwrap();
|
||||
s.execute("text.edit.caret", &json!({"index": 8, "extend": true})).unwrap();
|
||||
assert_eq!(s.execute("text.openLink", &json!({})).unwrap()["url"], "https://example.com/docs");
|
||||
// Mail links open; anything that isn't a web or mail link is refused and nothing is requested.
|
||||
s.execute("text.style", &json!({"id": t, "start": 4, "end": 12, "hyperlink": "mailto:hello@example.com"})).unwrap();
|
||||
s.execute("text.edit.caret", &json!({"index": 6})).unwrap();
|
||||
assert_eq!(s.execute("text.openLink", &json!({})).unwrap()["url"], "mailto:hello@example.com");
|
||||
let before = s.ui_requests.len();
|
||||
for bad in ["javascript:alert(1)", "file:///etc/passwd", "example.com/docs"] {
|
||||
s.execute("text.style", &json!({"id": t, "start": 4, "end": 12, "hyperlink": bad})).unwrap();
|
||||
assert!(s.execute("text.openLink", &json!({})).is_err(), "{bad} is refused");
|
||||
}
|
||||
assert_eq!(s.ui_requests.len(), before, "nothing requested for a refused link");
|
||||
}
|
||||
@@ -228,6 +228,13 @@ impl FigApp {
|
||||
}
|
||||
"preview.close" => self.ui.preview = None,
|
||||
"open.help" => self.services.open_url("https://getartcraft.com/apps/figcraft"),
|
||||
// A link in text activated while editing (`text.openLink`): the engine checked it
|
||||
// is a web or mail link.
|
||||
"open.url" => {
|
||||
if let Some(url) = r.data["url"].as_str() {
|
||||
self.services.open_url(url);
|
||||
}
|
||||
}
|
||||
"layers.collapseAll" => layers::collapse_all(self),
|
||||
"contextMenu" => self.ui.key_menu = true,
|
||||
"selectArea" => self.start_key_box(),
|
||||
|
||||
@@ -243,6 +243,8 @@ mod table_paste_tests;
|
||||
#[cfg(test)]
|
||||
mod text_enter_tests;
|
||||
#[cfg(test)]
|
||||
mod text_link_tests;
|
||||
#[cfg(test)]
|
||||
mod titlebar_tests;
|
||||
#[cfg(test)]
|
||||
mod ui_params_tests;
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
//! Alt+Enter while editing text opens the link at the caret through the platform ("When editing
|
||||
//! text, press ⌥Option Return/Alt Enter to activate a focused link", Accessibility at Figma),
|
||||
//! driven through egui headless with a recording opener; editing goes on.
|
||||
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use egui::{Event, Key, Modifiers};
|
||||
use figcraft_doc::NodeId;
|
||||
use figcraft_engine::Session;
|
||||
use serde_json::json;
|
||||
|
||||
use crate::Services;
|
||||
use crate::layers_drag_tests::Ui;
|
||||
|
||||
/// Records every URL the app asks the platform to open.
|
||||
#[derive(Default, Clone)]
|
||||
struct Opened(Arc<Mutex<Vec<String>>>);
|
||||
|
||||
impl Services for Opened {
|
||||
fn open_url(&mut self, url: &str) {
|
||||
if let Ok(mut v) = self.0.lock() {
|
||||
v.push(url.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn alt_enter(u: &mut Ui) {
|
||||
let modifiers = Modifiers::ALT;
|
||||
u.frame_with(
|
||||
vec![
|
||||
Event::Key { key: Key::Enter, physical_key: None, pressed: true, repeat: false, modifiers },
|
||||
Event::Key { key: Key::Enter, physical_key: None, pressed: false, repeat: false, modifiers },
|
||||
],
|
||||
modifiers,
|
||||
);
|
||||
u.frame(vec![]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn alt_enter_opens_the_link_at_the_caret_while_editing() {
|
||||
let mut s = Session::new();
|
||||
let t = NodeId(s.execute("text.create", &json!({"text": "See the docs now", "x": 20, "y": 20})).unwrap()["id"].as_u64().unwrap());
|
||||
s.execute("text.style", &json!({"id": t, "start": 4, "end": 12, "hyperlink": "https://example.com/docs"})).unwrap();
|
||||
s.execute("text.edit.begin", &json!({"id": t, "caret": 2})).unwrap();
|
||||
let opened = Opened::default();
|
||||
let mut u = Ui::new(s);
|
||||
u.app.services = Box::new(opened.clone());
|
||||
u.frame(vec![]);
|
||||
alt_enter(&mut u);
|
||||
assert!(opened.0.lock().unwrap().is_empty(), "no link at the caret: nothing opens");
|
||||
assert!(u.app.session.ds().unwrap().text_edit.is_some(), "still editing");
|
||||
u.app.session.execute("text.edit.caret", &json!({"index": 6})).unwrap();
|
||||
alt_enter(&mut u);
|
||||
assert_eq!(*opened.0.lock().unwrap(), vec!["https://example.com/docs".to_string()]);
|
||||
assert!(u.app.session.ds().unwrap().text_edit.is_some(), "still editing");
|
||||
// Not a web or mail link: nothing opens.
|
||||
u.app.session.execute("text.style", &json!({"id": t, "start": 4, "end": 12, "hyperlink": "file:///etc/passwd"})).unwrap();
|
||||
alt_enter(&mut u);
|
||||
assert_eq!(opened.0.lock().unwrap().len(), 1);
|
||||
}
|
||||
+5
-4
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user