M1: drag layers onto a page in the Pages list (#46)

While dragging layer rows, another page in the Pages list highlights as a
drop target; dropping moves the dragged layers (the selection when the
dragged row is in it) to that page through object.moveToPage.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
BFlat
2026-10-10 06:32:01 +07:00
committed by GitHub
co-authored by Claude Opus 5.5
parent 0a6d06f1e2
commit 5c6fb08b49
3 changed files with 67 additions and 8 deletions
+1 -1
View File
@@ -152,7 +152,7 @@ Scores by behaviour, 0–100%. Hours are what remains (one agent).
|---|---|---|---|---|
| Canvas & navigation | 6 | 72% | frame guides, 2x pixel preview | 3–5 |
| Selection & transform | 8 | 64% | scale tool polish, measure hover polish | 8–12 |
| Layers panel | 5 | 76% | keyboard row navigation, drag between pages | 2–4 |
| Layers panel | 5 | 79% | keyboard row navigation | 1–2 |
| Shapes & vector | 8 | 63% | variable width, multi-point transform box, point snapping, live boolean editing, arc handles on canvas, shape builder on strokes and open paths | 10–15 |
| Paint | 7 | 65% | colour profiles (Display P3), paint style picker polish, image adjustment presets | 6–10 |
| Strokes | 3 | 70% | stroke styles, variable width, arrowhead previews in the menus | 3–5 |
+37 -7
View File
@@ -185,14 +185,29 @@ fn pages(app: &mut FigApp, ui: &mut Ui) {
let Ok(ds) = app.session.ds() else { return };
let pages: Vec<(NodeId, String)> = ds.doc.pages.iter().filter_map(|p| ds.doc.node(*p).map(|n| (*p, n.name.clone()))).collect();
let cur = ds.page;
let sel = ds.selection.clone();
let mut select = None;
let mut rename_done: Option<(NodeId, String)> = None;
// Layers dragged from the list onto another page move there.
let dragging: Option<NodeId> = ui.data(|d| d.get_temp(layer_drag_id()));
let pointer = ui.input(|i| i.pointer.hover_pos());
let mut move_to: Option<(NodeId, String)> = None;
for (id, name) in pages {
let (r, resp) = ui.allocate_exact_size(vec2(w, 32.0), Sense::click());
let r = r.shrink2(vec2(8.0, 0.0));
if resp.hovered() || id == cur {
ui.painter().rect_filled(r, CornerRadius::same(5), t.hover);
}
if dragging.is_some()
&& id != cur
// The gap between rows counts too.
&& pointer.is_some_and(|p| resp.interact_rect.expand2(vec2(0.0, 3.0)).contains(p))
{
ui.painter().rect_stroke(r, CornerRadius::same(5), Stroke::new(2.0, ACCENT), egui::StrokeKind::Inside);
if ui.input(|i| i.pointer.any_released()) {
move_to = Some((id, name.clone()));
}
}
if app.ui.rename_page.as_ref().is_some_and(|(i, _)| *i == id) {
let tr = Rect::from_min_max(pos2(r.min.x + 8.0, r.min.y + 4.0), pos2(r.max.x - 4.0, r.max.y - 4.0));
if let Some(new) =
@@ -235,6 +250,12 @@ fn pages(app: &mut FigApp, ui: &mut Ui) {
app.ui.rename_page = None;
app.run("page.rename", json!({"id": id, "name": new}));
}
if let Some((page, name)) = move_to {
let ids = dragged_layers(dragging, &sel);
if app.run("object.moveToPage", json!({"ids": ids, "page": page})).is_some() {
app.toast(format!("Moved to {name}"));
}
}
if let Some(p) = select
&& p != cur
{
@@ -243,6 +264,20 @@ fn pages(app: &mut FigApp, ui: &mut Ui) {
crate::widgets::separator(ui);
}
/// The layer row being dragged in the panel (temp data).
fn layer_drag_id() -> Id {
Id::new("layer_drag")
}
/// What a drag of row `dragging` moves: the selection when the row is in it, else the row.
fn dragged_layers(dragging: Option<NodeId>, sel: &[NodeId]) -> Vec<NodeId> {
match dragging {
Some(dg) if sel.contains(&dg) => sel.to_vec(),
Some(dg) => vec![dg],
None => Vec::new(),
}
}
#[derive(Clone)]
pub(crate) struct Row {
pub(crate) id: NodeId,
@@ -407,18 +442,13 @@ fn layers(app: &mut FigApp, ui: &mut Ui) {
let mut lock: Option<NodeId> = None;
let mut rename_done: Option<(NodeId, String)> = None;
let mut hovered_row: Option<NodeId> = None;
let drag_id = Id::new("layer_drag");
let drag_id = layer_drag_id();
// Escape cancels a drag.
if ui.input(|i| i.key_pressed(egui::Key::Escape)) {
ui.data_mut(|d| d.remove::<NodeId>(drag_id));
}
let dragging: Option<NodeId> = ui.data(|d| d.get_temp(drag_id));
// What a drag moves: the selection when the dragged row is in it.
let moving: Vec<NodeId> = match dragging {
Some(dg) if sel.contains(&dg) => sel.clone(),
Some(dg) => vec![dg],
None => Vec::new(),
};
let moving = dragged_layers(dragging, &sel);
// A layer can't go into itself or its own descendants.
let blocked = |id: NodeId| moving.contains(&id) || doc.ancestors(id).iter().any(|a| moving.contains(a));
let mut drop: Option<(NodeId, i8)> = None;
+29
View File
@@ -118,3 +118,32 @@ fn a_layer_cant_drop_into_its_own_child_and_escape_cancels() {
u.button(f, false);
assert_ne!(u.app.session.doc().unwrap().node(rect).unwrap().parent, Some(frame));
}
#[test]
fn dropping_layers_on_another_page_moves_them_there() {
let (mut u, _frame, _child, rect) = setup();
let p1 = u.app.session.page().unwrap();
let p2 = id(u.app.session.execute("page.add", &json!({})).unwrap());
u.app.session.execute("page.select", &json!({"id": p1})).unwrap();
u.frame(vec![]);
// Find Page 2's row in the Pages list: the click that switches to it.
let mut at = None;
for y in (90..400).step_by(4) {
let p = Pos2::new(150.0, y as f32);
u.button(p, true);
u.button(p, false);
if u.app.session.page().unwrap() == p2 {
// Rows are 32 high: aim at the middle, past the hit tolerance at its edge.
at = Some(p + vec2(0.0, 12.0));
u.app.session.execute("page.select", &json!({"id": p1})).unwrap();
u.frame(vec![]);
break;
}
}
let page_row = at.expect("page 2 row");
let r = u.row_at(rect);
u.drag_to(r, page_row);
u.button(page_row, false);
let doc = u.app.session.doc().unwrap();
assert_eq!(doc.ancestors(rect).last().copied(), Some(p2), "moved to page 2");
}