mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-26 10:47:11 -06:00
Type Object Selection by Path Only: every baseline, Type tool and Eyedropper (review of #475)
- Type is picked on the baseline of any of its lines (point type and area type, besides area type's frame), as in Illustrator, not only the first one: the layout's baselines are cached with its bounds (`TextObject::cached_baselines`, from `TextLayout::baselines`), with the first one across the bounds as the fallback. Vertical type's runs down each column's centre line. - Type in perspective is picked where it is drawn (through its projection). - The bounds rule type out before its path is built, so hovering stays free of allocations away from type. - The Type tools still edit type clicked among its characters (they made new type there with the preference on), and the Eyedropper still samples it there, as it does inside fills with Object Selection by Path Only. - Tests for all of it; docs/mcp.md and ROADMAP say so. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
beea693ee1
commit
afb438367d
@@ -300,7 +300,7 @@ fn import_text(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
return Err(bad(C, "the text is empty"));
|
||||
}
|
||||
let mut t = TextObject::point(Point::ZERO, text, super::super::create::new_type_style(s, &Value::Null));
|
||||
t.cached_bounds = Some(vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &t).bounds);
|
||||
crate::cmd::typecmd::refresh_bounds(&mut t);
|
||||
let n = Node::new(NodeId(1), NodeKind::Text(Box::new(t)));
|
||||
let count = s.load_clipboard(Clipboard { nodes: vec![n], ..Default::default() }, p)?;
|
||||
Ok(json!({ "count": count }))
|
||||
|
||||
@@ -197,8 +197,7 @@ pub(super) fn area_text(text: String, frame: Rect) -> vectorcraft_doc::TextObjec
|
||||
run.text = text;
|
||||
}
|
||||
t.kind = vectorcraft_doc::TextKind::Area { frame: shapes::rectangle(Rect::from_origin_size(Point::ORIGIN, frame.size())) };
|
||||
let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &t);
|
||||
t.cached_bounds = Some(lay.bounds);
|
||||
crate::cmd::typecmd::refresh_bounds(&mut t);
|
||||
t
|
||||
}
|
||||
|
||||
|
||||
@@ -469,6 +469,7 @@ fn create_in_path(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
path_spacing: 0.0,
|
||||
wrap: Vec::new(),
|
||||
cached_bounds: None,
|
||||
cached_baselines: Vec::new(),
|
||||
};
|
||||
if bool_or(p, "placeholder", false) {
|
||||
super::typemenu::fill_with_placeholder(&mut t);
|
||||
@@ -601,6 +602,7 @@ fn headline_tracking(t: &TextObject, target: f64) -> Option<f64> {
|
||||
path_spacing: 0.0,
|
||||
wrap: Vec::new(),
|
||||
cached_bounds: None,
|
||||
cached_baselines: Vec::new(),
|
||||
};
|
||||
for r in &mut h.runs {
|
||||
r.style.tracking = tr;
|
||||
|
||||
@@ -97,10 +97,11 @@ fn orientation(s: &mut Session, p: &Value, vertical: bool) -> Result<Value> {
|
||||
Ok(json!({"vertical": vertical, "ids": ids.iter().map(|id| id.0).collect::<Vec<_>>()}))
|
||||
}
|
||||
|
||||
/// Recompute the layout bounds cache after a text edit.
|
||||
/// Recompute the layout caches (bounds and baselines) after a text edit.
|
||||
pub(crate) fn refresh_bounds(t: &mut TextObject) {
|
||||
let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t);
|
||||
t.cached_bounds = Some(lay.bounds);
|
||||
t.cached_baselines = lay.baselines();
|
||||
}
|
||||
|
||||
/// Text objects among `ids` and their descendants.
|
||||
|
||||
@@ -589,11 +589,7 @@ fn mesh_hit_test_inside() {
|
||||
sel(&mut s, &[a]);
|
||||
s.execute("object.mesh.create", &json!({"rows": 2, "cols": 2})).unwrap();
|
||||
let d = &s.doc().unwrap().doc;
|
||||
let h = vectorcraft_doc::hit::hit_test(
|
||||
d,
|
||||
Point::new(50.0, 30.0),
|
||||
vectorcraft_doc::hit::HitOptions { tol: 1.0, outline: false, path_only: false, type_path_only: false },
|
||||
);
|
||||
let h = vectorcraft_doc::hit::hit_test(d, Point::new(50.0, 30.0), vectorcraft_doc::hit::HitOptions { tol: 1.0, ..Default::default() });
|
||||
assert_eq!(h.map(|h| h.leaf), Some(a));
|
||||
}
|
||||
|
||||
|
||||
@@ -593,3 +593,50 @@ fn type_selection_by_path_only_picks_type_on_its_baseline() {
|
||||
set_pref(&mut s, "typeSelectionByPathOnly", json!(false));
|
||||
assert_eq!(click(&mut s, x, glyphs), vec![id], "off again: the glyphs select it");
|
||||
}
|
||||
|
||||
/// Type Object Selection by Path Only (#394) with real layout: every line's baseline picks point
|
||||
/// type, between the lines nothing does; the Type tool still edits type clicked among its
|
||||
/// characters, and the Eyedropper still samples it there.
|
||||
#[test]
|
||||
fn type_selection_by_path_only_takes_every_line_and_spares_the_type_tool_and_eyedropper() {
|
||||
use vectorcraft_tools::{PointerEvent, PointerKind};
|
||||
let v = ViewInfo::default();
|
||||
let mut s = new_doc();
|
||||
let created = s
|
||||
.execute(
|
||||
"text.create",
|
||||
&json!({"x": 100, "y": 100, "text": "Hello
|
||||
world", "size": 20}),
|
||||
)
|
||||
.unwrap();
|
||||
let src = NodeId(created["id"].as_u64().unwrap());
|
||||
s.execute("text.setStyle", &json!({"fill": "#00ff00"})).unwrap();
|
||||
let NodeKind::Text(t) = &s.doc().unwrap().doc.node(src).unwrap().kind else { panic!("not type") };
|
||||
let [(a, _), (b, _)] = t.cached_baselines[..] else { panic!("two baselines: {:?}", t.cached_baselines) };
|
||||
let (first, second) = (t.xf * a, t.xf * b);
|
||||
assert!(first.y == 100.0 && second.y > 115.0, "{first:?} {second:?}");
|
||||
let click = |s: &mut Session, tool: &str, x: f64, y: f64| {
|
||||
s.select_tool(tool, v).unwrap();
|
||||
for k in [PointerKind::Down, PointerKind::Up] {
|
||||
s.pointer(&PointerEvent::new(k, x, y), v).unwrap();
|
||||
}
|
||||
s.doc().unwrap().selection.objects.clone()
|
||||
};
|
||||
set_pref(&mut s, "typeSelectionByPathOnly", json!(true));
|
||||
let x = first.x + 15.0;
|
||||
assert!(click(&mut s, "selection", x, 300.0).is_empty());
|
||||
assert_eq!(click(&mut s, "selection", x, second.y + 1.0), vec![src], "the second line's baseline");
|
||||
assert!(click(&mut s, "selection", x, (first.y + second.y) / 2.0).is_empty(), "between the lines");
|
||||
// The Type tool: a click among the glyphs puts the caret in that type, it adds none.
|
||||
assert_eq!(click(&mut s, "type", x, first.y - 7.0), vec![src]);
|
||||
assert!(s.tool_wants_text(), "editing");
|
||||
let texts =
|
||||
|s: &Session| s.doc().unwrap().doc.layers[0].children().map_or(0, |c| c.iter().filter(|n| matches!(n.kind, NodeKind::Text(_))).count());
|
||||
assert_eq!(texts(&s), 1, "no new type");
|
||||
// The Eyedropper: a click among the glyphs samples the type into the selected type.
|
||||
s.select_tool("selection", v).unwrap();
|
||||
let dst = NodeId(s.execute("text.create", &json!({"x": 100, "y": 300, "text": "Target"})).unwrap()["id"].as_u64().unwrap());
|
||||
click(&mut s, "eyedropper", x, first.y - 7.0);
|
||||
let NodeKind::Text(t) = &s.doc().unwrap().doc.node(dst).unwrap().kind else { panic!("not type") };
|
||||
assert_eq!((t.first_style().size, t.first_style().fill.color().map(|c| c.to_hex())), (20.0, Some("#00ff00".into())));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user