mirror of
https://github.com/storytold/figcraft.git
synced 2026-10-28 12:26:52 -06:00
Merge pull request #1384 from muamerETS/fix/1370-drag-unfilled-selection
M1: a selected unfilled shape drags over a filled layer stacked below it (#1370)
This commit is contained in:
@@ -391,6 +391,57 @@ fn a_drag_inside_the_selection_box_moves_it_where_nothing_is_hit() {
|
||||
assert!((d.x - 10.0).abs() < 2.0 && (d.y - 10.0).abs() < 2.0, "it moves with the pointer: {d:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_layer_stacked_below_the_selection_doesnt_take_a_drag_from_inside_it() {
|
||||
// #1370: inside a selected unfilled shape, a filled layer behind it doesn't take the press (the
|
||||
// selection drags); a filled layer above it still does.
|
||||
let mut s = Session::new();
|
||||
let at = |s: &Session, i: NodeId| s.doc().unwrap().world_bounds(i).unwrap().origin();
|
||||
let frame = id(&ex(&mut s, "frame.create", json!({"x": 300, "y": 0, "width": 200, "height": 200})));
|
||||
for (parent, dx, where_) in [(None, 0.0, "on the canvas"), (Some(frame), 300.0, "in a top-level frame")] {
|
||||
// `back` (0,0) filled, `front` (20,20) stroke only, `top` (60,60) filled; each 100×100, so
|
||||
// the presses are well clear of the stroke.
|
||||
let square = |s: &mut Session, x: f64| {
|
||||
let mut p = json!({"x": x, "y": x, "width": 100, "height": 100});
|
||||
if let Some(parent) = parent {
|
||||
p["parent"] = json!(parent);
|
||||
}
|
||||
id(&ex(s, "shape.rectangle", p))
|
||||
};
|
||||
let back = square(&mut s, 0.0);
|
||||
let front = square(&mut s, 20.0);
|
||||
ex(&mut s, "node.set", json!({"ids": [front], "props": {"fills": [], "strokes": ["#000000"], "strokeWeight": 1}}));
|
||||
let top = square(&mut s, 60.0);
|
||||
// (drag from, selection after (the layer that moves by (10, 10)), derivation)
|
||||
let rows = [
|
||||
((dx + 40.0, 40.0), front, "a filled layer behind the selected shape doesn't take the press: the selection drags"),
|
||||
((dx + 90.0, 90.0), top, "a filled layer above the selected shape still takes the press"),
|
||||
];
|
||||
for (from, moved, why) in rows {
|
||||
let home: Vec<Point> = [back, front, top].iter().map(|i| at(&s, *i)).collect();
|
||||
ex(&mut s, "select.set", json!({"ids": [front]}));
|
||||
drag(&mut s, from, (from.0 + 10.0, from.1 + 10.0), NONE);
|
||||
assert_eq!(s.selection(), vec![moved], "{why} ({where_})");
|
||||
for (i, h) in [back, front, top].iter().zip(&home) {
|
||||
let d = if *i == moved { 10.0 } else { 0.0 };
|
||||
assert_eq!(at(&s, *i), Point::new(h.x + d, h.y + d), "{why} ({where_})");
|
||||
}
|
||||
assert!(s.undo().unwrap(), "{why}: one undo step");
|
||||
}
|
||||
}
|
||||
// A filled layer below two selected shapes, in the gap between them (inside the selection's
|
||||
// box but neither shape's), is still hit there.
|
||||
let rect_at = |s: &mut Session, x: f64, w: f64| id(&ex(s, "shape.rectangle", json!({"x": x, "y": 400, "width": w, "height": 100})));
|
||||
let gap = rect_at(&mut s, 720.0, 60.0);
|
||||
let (a, b) = (rect_at(&mut s, 600.0, 100.0), rect_at(&mut s, 800.0, 100.0));
|
||||
ex(&mut s, "node.set", json!({"ids": [a, b], "props": {"fills": [], "strokes": ["#000000"], "strokeWeight": 1}}));
|
||||
ex(&mut s, "select.set", json!({"ids": [a, b]}));
|
||||
let home = at(&s, gap);
|
||||
drag(&mut s, (750.0, 450.0), (760.0, 460.0), NONE);
|
||||
assert_eq!(s.selection(), vec![gap], "a layer in the gap between selected layers takes the press");
|
||||
assert_eq!(at(&s, gap), Point::new(home.x + 10.0, home.y + 10.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn alt_shift_drag_on_a_selected_layer_duplicates_it() {
|
||||
let mut s = Session::new();
|
||||
|
||||
@@ -747,6 +747,26 @@ pub fn locked_in(doc: &Document, id: NodeId) -> bool {
|
||||
std::iter::once(id).chain(doc.ancestors(id)).any(|a| doc.node(a).is_some_and(|n| n.locked))
|
||||
}
|
||||
|
||||
/// Whether layer `t` paints below layer `s`: it holds `s`, or it is behind `s` in the stack of the
|
||||
/// nearest layer holding both.
|
||||
fn paints_below(doc: &Document, t: NodeId, s: NodeId) -> bool {
|
||||
let chain = |id: NodeId| {
|
||||
let mut c: Vec<NodeId> = std::iter::once(id).chain(doc.ancestors(id)).collect();
|
||||
c.reverse();
|
||||
c
|
||||
};
|
||||
let (ct, cs) = (chain(t), chain(s));
|
||||
let shared = ct.iter().zip(&cs).take_while(|(a, b)| a == b).count();
|
||||
match (ct.get(shared), cs.get(shared)) {
|
||||
(None, Some(_)) => true,
|
||||
(Some(a), Some(b)) => shared.checked_sub(1).and_then(|i| ct.get(i)).and_then(|p| doc.node(*p)).is_some_and(|p| {
|
||||
let order: Vec<NodeId> = p.paint_order().collect();
|
||||
order.iter().position(|c| c == a) < order.iter().position(|c| c == b)
|
||||
}),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Which handle (if any) is under `p`. A selection with a locked layer has none.
|
||||
pub fn handle_at(doc: &Document, sel: &[NodeId], p: Point, zoom: f64) -> Option<Handle> {
|
||||
if sel.is_empty() || sel.iter().any(|i| locked_in(doc, *i)) {
|
||||
@@ -1363,8 +1383,12 @@ impl Session {
|
||||
.map(|leaf| select_target(&doc, leaf, &sel, ev.cmd))
|
||||
});
|
||||
// A press inside the selection's box that reaches nothing above it (an unfilled shape's
|
||||
// inside, or the frame or canvas behind it) drags the selection.
|
||||
let behind_sel = |t: Option<NodeId>| t.is_none_or(|t| sel.iter().any(|s| *s != t && doc.is_ancestor(t, *s)));
|
||||
// inside, the frame or canvas behind it, or a layer stacked below the selected layer the
|
||||
// press is in) drags the selection. In a gap between selected layers, what's there is hit.
|
||||
let in_own_box = |s: NodeId| selection_box(&doc, &[s]).is_some_and(|q| in_quad(&q, ev.p()));
|
||||
let behind_sel = |t: Option<NodeId>| {
|
||||
t.is_none_or(|t| sel.iter().any(|s| *s != t && (doc.is_ancestor(t, *s) || (in_own_box(*s) && paints_below(&doc, t, *s)))))
|
||||
};
|
||||
let target = match sel.iter().copied().find(|s| !locked_in(&doc, *s)) {
|
||||
Some(first) if label.is_none() && !ev.shift && behind_sel(target) && selection_box(&doc, &sel).is_some_and(|q| in_quad(&q, ev.p())) => {
|
||||
Some(first)
|
||||
|
||||
+2
-1
@@ -1,6 +1,6 @@
|
||||
# FigCraft roadmap: milestones and what's next
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-26 · **Change:** minor (text truncation fills the last line, survives resizing, and auto height takes Max lines (#1375, #1376, #1377); M11 widget fields and property menus; M12 Boards: timer and voting; Installed weights of an embedded font family (#1246); `.sketch` import, best effort; Adobe Fonts found on macOS (#298); M12 Boards; community fixes in #507, #510, #529 and #530: auto-save, a text box by dragging, batch step references, removing someone from a live session, Russian and Turkish catalogs reviewed, Czech and Indonesian drafts, the Japanese draft completed, zoom previews, the web clipboard, Alt-drag snapping and Cmd+D repeat, recovery copies, crash and rollback fixes, colour emoji, `.fig` import fixes; earlier changes are in the revision history below) · **Target:** Figma Design (desktop app 126.10.7)
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-26 · **Change:** minor (a selected unfilled shape drags over a filled layer below it (#1370); text truncation fills the last line, survives resizing, and auto height takes Max lines (#1375, #1376, #1377); M11 widget fields and property menus; M12 Boards: timer and voting; Installed weights of an embedded font family (#1246); `.sketch` import, best effort; Adobe Fonts found on macOS (#298); M12 Boards; community fixes in #507, #510, #529 and #530: auto-save, a text box by dragging, batch step references, removing someone from a live session, Russian and Turkish catalogs reviewed, Czech and Indonesian drafts, the Japanese draft completed, zoom previews, the web clipboard, Alt-drag snapping and Cmd+D repeat, recovery copies, crash and rollback fixes, colour emoji, `.fig` import fixes; earlier changes are in the revision history below) · **Target:** Figma Design (desktop app 126.10.7)
|
||||
|
||||
Forward-looking plan. Where FigCraft stands today: [`ROADMAP.md`](../ROADMAP.md) and
|
||||
[`target-app-parity.md`](target-app-parity.md); the full ranked list: [`gaps.md`](gaps.md). Hours
|
||||
@@ -75,6 +75,7 @@ blockers.
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-26 | minor | Dragging a selected unfilled shape (M1, #1370): a press inside a selected stroke-only shape over a filled layer stacked below it moved that layer; the drag-from-inside fallback of #910 now treats any layer painted below the selection like the frame or canvas behind it, so the selection drags, while a layer painted above it still takes the press. Regression test in the selection behaviour suite. Numbers unchanged |
|
||||
| 2026-10-26 | minor | Text truncation (M4, #1375, #1376, #1377): the last line shown takes as much of the next word as fits before the ellipsis ("Lorem consec…", not "Lorem…"); an explicit height, `transform.resize`, a resize handle or fixed sizing on a truncated text keeps it truncating instead of turning it fixed size; Max lines on auto-height text hugs up to that many lines and ends in an ellipsis (`text.maxLines`, `node.set {maxLines}`, Type settings, `.fig` export and import), and `node.get` / `get_node` report `maxLines`. Regression tests in the text and engine behaviour suites. Numbers unchanged |
|
||||
| 2026-10-24 | minor | Installed weights of an embedded font family (M7, #1246): release builds embed some families with only a few styles (Poppins Regular, Italic and Bold), and the first font scan dropped every installed face of such a family, so an installed Poppins SemiBold or Medium was never offered (a rescan did offer them). The first scan now keeps the embedded faces, which still win for the styles they cover, and offers the installed styles they lack beside them. Regression test in the font database tests. Numbers unchanged |
|
||||
| 2026-10-24 | minor | M12: a shared board timer and voting sessions; 30–60 h → 26–54 h |
|
||||
|
||||
Reference in New Issue
Block a user