mirror of
https://github.com/storytold/designcraft.git
synced 2026-10-26 19:26:46 -06:00
Merge content-move: move a placed graphic inside its frame
Direct Selection selects and drags a frame's content, the Selection tool drags it by the content grabber, and double-clicking a frame with content toggles between frame and content, alongside #84's text threading and canvas click counting. Conflicts: - crates/tools/src/select.rs: module doc keeps both paragraphs (ports, content). The frame/content toggle runs on the second press of a double click (Down with clicks 2, or a DoubleClick from the control channel), Selection tool only, after threading ports and the text-frame double-click, so those keep priority; the DoubleClick arm stays empty. Overlays keep the Drag::Place marquee and add the content ghost. - crates/tools/src/tests.rs: both sets of tests and fixtures kept; the graphic fixture sets Graphic::wrap; the double-click toggle test drives the click-count sequence and the DoubleClick form. - crates/engine/src/tests.rs: both sets of tests kept. - crates/engine/src/cmd/object.rs: both test modules kept.
This commit is contained in:
@@ -26,7 +26,15 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
frame_create
|
||||
),
|
||||
cmd!("line.create", "Create Line", [], None, "{spread?, a: [x,y], b: [x,y]}", has_doc, line_create),
|
||||
cmd!("transform.move", "Move", ["Object", "Transform"], None, "{dx, dy, copy?: bool, ids?, toSpread?}", has_selection, transform_move),
|
||||
cmd!(
|
||||
"transform.move",
|
||||
"Move",
|
||||
["Object", "Transform"],
|
||||
None,
|
||||
"{dx, dy, copy?: bool, ids?, toSpread?, content?: bool (move the placed graphic or pasted-in items inside each frame; the frame stays. Default: whether the selection is content, when neither ids nor copy is given)}",
|
||||
has_selection,
|
||||
transform_move
|
||||
),
|
||||
cmd!(
|
||||
"transform.resize",
|
||||
"Resize",
|
||||
@@ -931,6 +939,12 @@ fn transform_move(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let ids = targets(s, p)?;
|
||||
let (dx, dy) = (f64_or(p, "dx", 0.0), f64_or(p, "dy", 0.0));
|
||||
let copy = bool_or(p, "copy", false);
|
||||
let explicit = p.get("ids").is_some() || p.get("id").is_some();
|
||||
// Duplicating copies the frame; moving a content selection moves what the frame holds.
|
||||
let content = p.get("content").and_then(Value::as_bool).unwrap_or(!explicit && !copy && s.doc()?.selection.content);
|
||||
if content {
|
||||
return move_content(s, &ids, Vec2::new(dx, dy));
|
||||
}
|
||||
let to: Option<SpreadRef> = p.get("toSpread").and_then(|v| serde_json::from_value(v.clone()).ok());
|
||||
s.edit(|d, sel| {
|
||||
let ids = if copy {
|
||||
@@ -955,16 +969,61 @@ fn transform_move(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let it = d.remove_item_keep_story(*id)?;
|
||||
d.insert_item(target, it, None)?;
|
||||
}
|
||||
// A nested item moves in its container's space.
|
||||
let v = d.find(*id).map_or(Vec2::new(dx, dy), |l| linear_inverse(d.parent_xf(&l), Vec2::new(dx, dy)));
|
||||
let it = d.item_mut(*id).ok_or(designcraft_doc::DocError::NoItem(*id))?;
|
||||
if it.locked {
|
||||
continue;
|
||||
}
|
||||
it.xf = Affine::translate((dx, dy)) * it.xf;
|
||||
it.xf = Affine::translate(v) * it.xf;
|
||||
}
|
||||
Ok(json!({"moved": ids.len()}))
|
||||
})
|
||||
}
|
||||
|
||||
/// `v` (a spread-space displacement) in the space `m` maps from. Degenerate maps leave it as is.
|
||||
fn linear_inverse(m: Affine, v: Vec2) -> Vec2 {
|
||||
let c = m.as_coeffs();
|
||||
let lin = Affine::new([c[0], c[1], c[2], c[3], 0.0, 0.0]);
|
||||
if lin.determinant().abs() < 1e-12 {
|
||||
return v;
|
||||
}
|
||||
let r = lin.inverse() * Point::new(v.x, v.y);
|
||||
if r.x.is_finite() && r.y.is_finite() { r.to_vec2() } else { v }
|
||||
}
|
||||
|
||||
/// Move what each frame holds — its placed graphic or the items pasted into it — by the
|
||||
/// spread-space displacement `d`; the frame stays, so the visible part of the content changes.
|
||||
/// Frames without such content (and groups) move as a whole. Auto-fit stays as it is: the next
|
||||
/// frame resize refits.
|
||||
fn move_content(s: &mut Session, ids: &[ItemId], d: Vec2) -> Result<Value> {
|
||||
s.edit(|doc, _| {
|
||||
let mut moved = 0;
|
||||
for id in ids {
|
||||
let loc = doc.find(*id).ok_or(designcraft_doc::DocError::NoItem(*id))?;
|
||||
let parent = doc.parent_xf(&loc);
|
||||
let it = doc.item_mut(*id).ok_or(designcraft_doc::DocError::NoItem(*id))?;
|
||||
if it.locked {
|
||||
continue;
|
||||
}
|
||||
let inner = linear_inverse(parent * it.xf, d);
|
||||
let nested = it.has_nested_items();
|
||||
match &mut it.content {
|
||||
Content::Graphic(g) => g.xf = Affine::translate(inner) * g.xf,
|
||||
Content::Group { items } if nested => {
|
||||
for c in items.iter_mut() {
|
||||
let c = Arc::make_mut(c);
|
||||
c.xf = Affine::translate(inner) * c.xf;
|
||||
}
|
||||
}
|
||||
_ => it.xf = Affine::translate(linear_inverse(parent, d)) * it.xf,
|
||||
}
|
||||
moved += 1;
|
||||
}
|
||||
Ok(json!({"moved": moved}))
|
||||
})
|
||||
}
|
||||
|
||||
fn transform_resize(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let ids = targets(s, p)?;
|
||||
let from = rect_param(p, "from").ok_or_else(|| bad("transform.resize", "missing from"))?;
|
||||
@@ -3026,3 +3085,75 @@ mod reorder_tests {
|
||||
assert!(s.execute("object.setLayer", &json!({"ids": [a], "layer": 999})).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod content_move_tests {
|
||||
use super::*;
|
||||
|
||||
/// A session with a 40×20 image placed in a frame at (100, 100)–(300, 300).
|
||||
fn placed() -> (Session, ItemId) {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({})).unwrap();
|
||||
let png = designcraft_render::Rendered { width: 40, height: 20, pixels: vec![200; 40 * 20 * 4] }.to_png();
|
||||
let f = s.execute("frame.create", &json!({"rect": [100, 100, 300, 300], "content": "graphic"})).unwrap()["id"].as_u64().unwrap();
|
||||
s.execute("place.load", &json!({"base64": super::super::base64_encode(&png)})).unwrap();
|
||||
s.execute("place.drop", &json!({"frame": f})).unwrap();
|
||||
(s, ItemId(f))
|
||||
}
|
||||
|
||||
/// Spread bounds of the frame and of its graphic.
|
||||
fn boxes(s: &Session, id: ItemId) -> (Rect, Rect) {
|
||||
let it = s.doc().unwrap().doc.item(id).unwrap().clone();
|
||||
let g = it.graphic().unwrap();
|
||||
(it.bounds(), (it.xf * g.xf).transform_rect_bbox(Rect::new(0.0, 0.0, g.size.0, g.size.1)))
|
||||
}
|
||||
|
||||
fn near(a: Rect, b: Rect) -> bool {
|
||||
[a.x0 - b.x0, a.y0 - b.y0, a.x1 - b.x1, a.y1 - b.y1].iter().all(|d| d.abs() < 1e-6)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn content_moves_inside_the_frame_in_one_undo_step() {
|
||||
let (mut s, f) = placed();
|
||||
let (frame0, g0) = boxes(&s, f);
|
||||
s.execute("selection.set", &json!({"ids": [f.0], "content": true})).unwrap();
|
||||
s.execute("transform.move", &json!({"dx": 10, "dy": -5})).unwrap();
|
||||
let (frame1, g1) = boxes(&s, f);
|
||||
assert!(near(frame1, frame0), "the frame stays");
|
||||
assert!(near(g1, g0 + Vec2::new(10.0, -5.0)), "{g1:?}");
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert!(near(boxes(&s, f).1, g0));
|
||||
// Explicit ids with `content` act on the content whatever the selection is.
|
||||
s.execute("selection.set", &json!({"ids": [f.0]})).unwrap();
|
||||
s.execute("transform.move", &json!({"ids": [f.0], "content": true, "dx": 3})).unwrap();
|
||||
assert!(near(boxes(&s, f).0, frame0) && near(boxes(&s, f).1, g0 + Vec2::new(3.0, 0.0)));
|
||||
// A frame selection moves frame and content together.
|
||||
s.execute("transform.move", &json!({"dx": 4})).unwrap();
|
||||
assert!(near(boxes(&s, f).0, frame0 + Vec2::new(4.0, 0.0)) && near(boxes(&s, f).1, g0 + Vec2::new(7.0, 0.0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn content_moves_on_the_page_axes_in_a_rotated_frame_and_pasted_items_move_too() {
|
||||
let (mut s, f) = placed();
|
||||
s.execute("transform.rotate", &json!({"ids": [f.0], "angle": 90})).unwrap();
|
||||
let (frame0, g0) = boxes(&s, f);
|
||||
s.execute("transform.move", &json!({"ids": [f.0], "content": true, "dx": 10})).unwrap();
|
||||
let (frame1, g1) = boxes(&s, f);
|
||||
assert!(near(frame1, frame0) && near(g1, g0 + Vec2::new(10.0, 0.0)), "{g0:?} → {g1:?}");
|
||||
// Items pasted into a frame are its content.
|
||||
let a = s.execute("frame.create", &json!({"rect": [400, 100, 420, 120], "content": "unassigned"})).unwrap()["id"].as_u64().unwrap();
|
||||
s.execute("selection.set", &json!({"ids": [a]})).unwrap();
|
||||
s.execute("edit.copy", &json!({})).unwrap();
|
||||
let b = s.execute("frame.create", &json!({"rect": [400, 300, 500, 400], "content": "unassigned"})).unwrap()["id"].as_u64().unwrap();
|
||||
s.execute("selection.set", &json!({"ids": [b]})).unwrap();
|
||||
s.execute("edit.pasteInto", &json!({})).unwrap();
|
||||
let kids = |s: &Session| {
|
||||
let it = s.doc().unwrap().doc.item(ItemId(b)).unwrap().clone();
|
||||
(it.bounds(), it.children().iter().map(|c| it.xf.transform_rect_bbox(c.bounds())).collect::<Vec<_>>())
|
||||
};
|
||||
let (fb0, k0) = kids(&s);
|
||||
s.execute("transform.move", &json!({"ids": [b], "content": true, "dx": 5, "dy": 6})).unwrap();
|
||||
let (fb1, k1) = kids(&s);
|
||||
assert!(near(fb1, fb0) && near(k1[0], k0[0] + Vec2::new(5.0, 6.0)), "{k0:?} → {k1:?}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -845,3 +845,38 @@ fn character_style_delete_rejects_builtin_styles() {
|
||||
let r = s.execute("style.character.delete", &json!({"name": designcraft_doc::NO_CHAR_STYLE}));
|
||||
assert!(r.is_err(), "built-in styles can't be deleted");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn direct_selection_drag_and_arrow_keys_move_placed_content() {
|
||||
use designcraft_tools::{Mods, PointerEvent, PointerKind, SnapView, ToolKey};
|
||||
let mut s = session();
|
||||
let png = designcraft_render::Rendered { width: 40, height: 20, pixels: vec![200; 40 * 20 * 4] }.to_png();
|
||||
let f = s.execute("frame.create", &json!({"rect": [100, 100, 300, 300], "content": "graphic"})).unwrap()["id"].as_u64().unwrap();
|
||||
s.execute("place.load", &json!({"base64": cmd::base64_encode(&png)})).unwrap();
|
||||
s.execute("place.drop", &json!({"frame": f})).unwrap();
|
||||
let xfs = |s: &Session| {
|
||||
let it = s.doc().unwrap().doc.item(designcraft_doc::ItemId(f)).unwrap().clone();
|
||||
(it.xf, it.graphic().unwrap().xf.translation())
|
||||
};
|
||||
let (frame0, g0) = xfs(&s);
|
||||
let v = ViewInfo { snap: SnapView::OFF, ..ViewInfo::at_zoom(1.0) };
|
||||
let at = s.layout().to_canvas(designcraft_doc::SpreadRef::Doc(0), designcraft_geom::Point::new(150.0, 150.0));
|
||||
s.set_tool("directSelection");
|
||||
s.pointer(&PointerEvent::new(PointerKind::Down, at.x, at.y), v).unwrap();
|
||||
s.pointer(&PointerEvent::new(PointerKind::Drag, at.x + 20.0, at.y + 10.0), v).unwrap();
|
||||
s.pointer(&PointerEvent::new(PointerKind::Drag, at.x + 30.0, at.y + 10.0), v).unwrap();
|
||||
s.pointer(&PointerEvent::new(PointerKind::Up, at.x + 30.0, at.y + 10.0), v).unwrap();
|
||||
assert!(s.doc().unwrap().selection.content);
|
||||
let (frame1, g1) = xfs(&s);
|
||||
assert_eq!(frame1, frame0, "the frame stays");
|
||||
assert_eq!(g1 - g0, designcraft_geom::Vec2::new(30.0, 10.0));
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(xfs(&s).1, g0, "one undo step");
|
||||
// Arrow keys nudge the selected content; Shift nudges ten times as far.
|
||||
s.set_tool("selection");
|
||||
s.tool_key(ToolKey::Right, Mods::default(), v).unwrap();
|
||||
s.tool_key(ToolKey::Down, Mods { shift: true, ..Default::default() }, v).unwrap();
|
||||
let (frame2, g2) = xfs(&s);
|
||||
assert_eq!(frame2, frame0);
|
||||
assert_eq!(g2 - g0, designcraft_geom::Vec2::new(1.0, 10.0));
|
||||
}
|
||||
|
||||
@@ -130,6 +130,15 @@ impl Session {
|
||||
self.with_ctx(view, |t, cx| t.cursor(cx, p, mods)).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Canvas centres of the content grabbers the Selection tool shows: the selected frame's and
|
||||
/// the one under `hover`.
|
||||
pub fn content_grabbers(&mut self, hover: Option<designcraft_geom::Point>, view: ViewInfo) -> Vec<designcraft_geom::Point> {
|
||||
if self.tool.id() != "selection" {
|
||||
return vec![];
|
||||
}
|
||||
self.with_ctx(view, |_, cx| designcraft_tools::select::grabbers(cx, hover).into_iter().map(|(_, c)| c).collect()).unwrap_or_default()
|
||||
}
|
||||
|
||||
pub fn tool_busy(&self) -> bool {
|
||||
self.tool.busy()
|
||||
}
|
||||
|
||||
@@ -322,6 +322,9 @@ pub enum Overlay {
|
||||
b: Point,
|
||||
label: String,
|
||||
},
|
||||
/// The whole content of this frame drawn faintly, the part outside the frame included (while
|
||||
/// the content moves inside it).
|
||||
ContentGhost(ItemId),
|
||||
}
|
||||
|
||||
/// Correction from a snap, in spread coordinates for `delta`.
|
||||
|
||||
+142
-13
@@ -4,8 +4,12 @@
|
||||
//! the 8 bounding-box handles resize (Shift keeps proportions, Alt from centre, Cmd scales content),
|
||||
//! empty-canvas drags make a marquee, double-clicking a text frame switches to the Type tool.
|
||||
//! A click on a selected text frame's in or out port loads the text cursor (see [`crate::thread`]).
|
||||
//!
|
||||
//! Content (a placed graphic, or items pasted into a frame) moves inside its frame: the Direct
|
||||
//! Selection tool selects and drags it, the Selection tool drags it by the content grabber (the
|
||||
//! circle at the frame's centre), and double-clicking a frame toggles between frame and content.
|
||||
|
||||
use designcraft_doc::{ItemId, SpreadRef};
|
||||
use designcraft_doc::{Content, Item, ItemId, SpreadRef};
|
||||
use designcraft_geom::{Affine, Point, Rect, Vec2};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
@@ -18,12 +22,15 @@ enum Drag {
|
||||
Pending {
|
||||
start: Point,
|
||||
hit: bool,
|
||||
/// The drag moves the selection's content inside its frames.
|
||||
content: bool,
|
||||
},
|
||||
Move {
|
||||
start: Point,
|
||||
origin_spread: SpreadRef,
|
||||
bounds0: Option<Rect>,
|
||||
exclude: Vec<ItemId>,
|
||||
content: bool,
|
||||
},
|
||||
Resize {
|
||||
handle: usize,
|
||||
@@ -162,6 +169,73 @@ impl SelectionTool {
|
||||
}
|
||||
}
|
||||
|
||||
/// A frame whose content moves on its own: a placed graphic or items pasted into it.
|
||||
pub fn holds_content(it: &Item) -> bool {
|
||||
!it.is_group() && (matches!(it.content, Content::Graphic(_)) || it.has_nested_items())
|
||||
}
|
||||
|
||||
/// Grab radius of the content grabber, in screen pixels.
|
||||
const GRABBER_PX: f64 = 9.0;
|
||||
|
||||
/// Canvas centre of the content grabber of `id` (the middle of its frame), when it has one. Frames
|
||||
/// too small to drag around the grabber have none.
|
||||
pub fn grabber_center(cx: &ToolContext, id: ItemId) -> Option<Point> {
|
||||
let it = cx.doc.item(id)?;
|
||||
if it.locked || !holds_content(it) {
|
||||
return None;
|
||||
}
|
||||
let b = cx.item_canvas_xf(id)?.transform_rect_bbox(it.bounds());
|
||||
let min = cx.tol(GRABBER_PX * 4.0);
|
||||
(b.width() >= min && b.height() >= min && b.center().x.is_finite() && b.center().y.is_finite()).then(|| b.center())
|
||||
}
|
||||
|
||||
/// Content grabbers on show: the single selected frame's and the hovered frame's.
|
||||
pub fn grabbers(cx: &ToolContext, hover: Option<Point>) -> Vec<(ItemId, Point)> {
|
||||
let mut ids = Vec::new();
|
||||
if let [one] = cx.selection.items.as_slice()
|
||||
&& cx.selection.text.is_none()
|
||||
{
|
||||
ids.push(*one);
|
||||
}
|
||||
if let Some((_, id)) = hover.and_then(|p| cx.hit(p)) {
|
||||
let id = cx.doc.top_level_of(id).unwrap_or(id);
|
||||
if !ids.contains(&id) {
|
||||
ids.push(id);
|
||||
}
|
||||
}
|
||||
ids.into_iter().filter_map(|id| grabber_center(cx, id).map(|c| (id, c))).collect()
|
||||
}
|
||||
|
||||
/// The frame whose content grabber is under `p` (canvas).
|
||||
pub fn grabber_at(cx: &ToolContext, p: Point) -> Option<ItemId> {
|
||||
let r = cx.tol(GRABBER_PX);
|
||||
grabbers(cx, Some(p)).into_iter().find(|(_, c)| (*c - p).hypot() <= r).map(|(id, _)| id)
|
||||
}
|
||||
|
||||
/// Canvas union of the content of the selected frames (an item without content counts whole).
|
||||
fn content_bounds(cx: &ToolContext) -> Option<Rect> {
|
||||
let mut acc: Option<Rect> = None;
|
||||
for id in &cx.selection.items {
|
||||
let (Some(it), Some(xf)) = (cx.doc.item(*id), cx.item_canvas_xf(*id)) else { continue };
|
||||
let b = match &it.content {
|
||||
Content::Graphic(g) => (xf * it.xf * g.xf).transform_rect_bbox(Rect::new(0.0, 0.0, g.size.0, g.size.1)),
|
||||
_ if it.has_nested_items() => {
|
||||
let Some(r) = it.children().iter().map(|c| (xf * it.xf).transform_rect_bbox(c.bounds())).reduce(|a, b| a.union(b)) else { continue };
|
||||
r
|
||||
}
|
||||
_ => {
|
||||
let Some(local) = crate::snap::moving_bounds(it) else { continue };
|
||||
xf.transform_rect_bbox(local)
|
||||
}
|
||||
};
|
||||
if !(b.x0.is_finite() && b.y0.is_finite() && b.x1.is_finite() && b.y1.is_finite()) {
|
||||
continue;
|
||||
}
|
||||
acc = Some(acc.map_or(b, |have| have.union(b)));
|
||||
}
|
||||
acc
|
||||
}
|
||||
|
||||
/// Handle positions (canvas) of a rect: 0 TL, 1 T, 2 TR, 3 R, 4 BR, 5 B, 6 BL, 7 L.
|
||||
pub fn handles(r: Rect) -> [Point; 8] {
|
||||
let c = r.center();
|
||||
@@ -438,6 +512,18 @@ impl Tool for SelectionTool {
|
||||
self.drag = Drag::None;
|
||||
return actions;
|
||||
}
|
||||
// The second press of a double click on a frame with content: toggle between the frame and
|
||||
// its content.
|
||||
if !self.direct
|
||||
&& matches!(ev.kind, PointerKind::Down | PointerKind::DoubleClick)
|
||||
&& ev.click_count() == 2
|
||||
&& let Some(id) = cx.hit(p).map(|(_, id)| cx.doc.top_level_of(id).unwrap_or(id))
|
||||
&& cx.doc.item(id).is_some_and(holds_content)
|
||||
{
|
||||
self.drag = Drag::None;
|
||||
let content = !(cx.selection.content && cx.selection.contains(id));
|
||||
return vec![Action::Exec("selection.set".into(), json!({"ids": [id.0], "content": content}))];
|
||||
}
|
||||
match ev.kind {
|
||||
PointerKind::Move => {
|
||||
self.hover_handle = handle_at(cx, p);
|
||||
@@ -470,13 +556,24 @@ impl Tool for SelectionTool {
|
||||
Drag::Resize { handle, start: p, from: cx.layout.xf(loc.spread).inverse().transform_rect_bbox(b), spread: loc.spread };
|
||||
return vec![Action::Begin("Resize".into())];
|
||||
}
|
||||
// The content grabber: select the frame's content; a drag moves it.
|
||||
if !self.direct
|
||||
&& !ev.mods.shift
|
||||
&& let Some(id) = grabber_at(cx, p)
|
||||
{
|
||||
self.drag = Drag::Pending { start: p, hit: true, content: true };
|
||||
if cx.selection.items == [id] && cx.selection.content {
|
||||
return vec![];
|
||||
}
|
||||
return vec![Action::Exec("selection.set".into(), json!({"ids": [id.0], "content": true}))];
|
||||
}
|
||||
// Cmd+Shift-click a parent item on a page: override it there and select the copy.
|
||||
if ev.mods.cmd
|
||||
&& ev.mods.shift
|
||||
&& cx.hit(p).is_none()
|
||||
&& let Some((page, id)) = cx.hit_parent_on_page(p)
|
||||
{
|
||||
self.drag = Drag::Pending { start: p, hit: true };
|
||||
self.drag = Drag::Pending { start: p, hit: true, content: false };
|
||||
return vec![Action::Exec("layout.overrideParentItems".into(), json!({"page": page, "ids": [id.0]}))];
|
||||
}
|
||||
match cx.hit(p) {
|
||||
@@ -490,31 +587,46 @@ impl Tool for SelectionTool {
|
||||
} else {
|
||||
out.push(Action::Exec("selection.toggle".into(), json!({"id": id.0})));
|
||||
}
|
||||
} else if !cx.selection.contains(id) {
|
||||
} else if !cx.selection.contains(id) || (self.direct && !cx.selection.content) {
|
||||
out.push(Action::Exec("selection.set".into(), json!({"ids": [id.0], "content": self.direct})));
|
||||
}
|
||||
self.drag = Drag::Pending { start: p, hit: true };
|
||||
self.drag = Drag::Pending { start: p, hit: true, content: self.direct };
|
||||
let _ = sr;
|
||||
out
|
||||
}
|
||||
None => {
|
||||
self.drag = Drag::Pending { start: p, hit: false };
|
||||
self.drag = Drag::Pending { start: p, hit: false, content: false };
|
||||
if ev.mods.shift { vec![] } else { vec![Action::Exec("selection.set".into(), json!({"ids": []}))] }
|
||||
}
|
||||
}
|
||||
}
|
||||
PointerKind::Drag => match self.drag.clone() {
|
||||
Drag::Pending { start, hit } => {
|
||||
Drag::Pending { start, hit, content } => {
|
||||
if (p - start).hypot() < cx.tol(3.0) {
|
||||
return vec![];
|
||||
}
|
||||
self.shift_release = None;
|
||||
if hit && !cx.selection.items.is_empty() {
|
||||
// Direct Selection on items without content moves (or duplicates) them whole.
|
||||
let content = content && cx.selection.items.iter().any(|i| cx.doc.item(*i).is_some_and(holds_content));
|
||||
let sr = cx.selection.items.first().and_then(|i| cx.doc.find(*i)).map(|l| l.spread).unwrap_or(SpreadRef::Doc(0));
|
||||
let exclude = cx.selection.items.clone();
|
||||
self.drag = Drag::Move { start, origin_spread: sr, bounds0: cx.selection_visible_bounds(), exclude };
|
||||
let label = if ev.mods.alt { "Duplicate" } else { "Move" };
|
||||
let mut v = vec![Action::Begin(label.into())];
|
||||
let bounds0 = if content { content_bounds(cx) } else { cx.selection_visible_bounds() };
|
||||
self.drag = Drag::Move { start, origin_spread: sr, bounds0, exclude: exclude.clone(), content };
|
||||
let mut v = vec![];
|
||||
// Dragging a content selection by its frame moves the frame: select it.
|
||||
if !content && cx.selection.content {
|
||||
let ids: Vec<u64> = exclude.iter().map(|i| i.0).collect();
|
||||
v.push(Action::Exec("selection.set".into(), json!({"ids": ids})));
|
||||
}
|
||||
let label = if content {
|
||||
"Move Content"
|
||||
} else if ev.mods.alt {
|
||||
"Duplicate"
|
||||
} else {
|
||||
"Move"
|
||||
};
|
||||
v.push(Action::Begin(label.into()));
|
||||
v.extend(self.pointer(cx, ev));
|
||||
v
|
||||
} else {
|
||||
@@ -522,7 +634,7 @@ impl Tool for SelectionTool {
|
||||
vec![]
|
||||
}
|
||||
}
|
||||
Drag::Move { start, origin_spread, bounds0, exclude } => {
|
||||
Drag::Move { start, origin_spread, bounds0, exclude, content } => {
|
||||
let mut d: Vec2 = p - start;
|
||||
let mut screen_x_locked = false;
|
||||
let mut screen_y_locked = false;
|
||||
@@ -536,7 +648,8 @@ impl Tool for SelectionTool {
|
||||
}
|
||||
}
|
||||
// Snap on the spread under the pointer. Shift uses that spread's view rotation.
|
||||
let target = cx.layout.spread_at(p).map(|(s, _)| s).unwrap_or(origin_spread);
|
||||
// Content stays in its frame, on the frame's spread.
|
||||
let target = if content { origin_spread } else { cx.layout.spread_at(p).map(|(s, _)| s).unwrap_or(origin_spread) };
|
||||
// 180 degrees keeps the screen axis. 90 and 270 swap it into the other spread axis.
|
||||
let odd_turn = cx.layout.slot(target).is_some_and(|slot| slot.rotation % 2 == 1);
|
||||
let spread_x_locked = if odd_turn { screen_y_locked } else { screen_x_locked };
|
||||
@@ -570,7 +683,7 @@ impl Tool for SelectionTool {
|
||||
x_edges,
|
||||
y_edges,
|
||||
exclude: &exclude,
|
||||
copying: ev.mods.alt,
|
||||
copying: ev.mods.alt && !content,
|
||||
lengths: [None, None],
|
||||
angle: None,
|
||||
radius: 0.0,
|
||||
@@ -602,6 +715,10 @@ impl Tool for SelectionTool {
|
||||
let dt = landed - q0;
|
||||
(dt.x, dt.y)
|
||||
};
|
||||
if content {
|
||||
let ids: Vec<u64> = exclude.iter().map(|i| i.0).collect();
|
||||
return vec![Action::Preview("transform.move".into(), json!({"dx": dx, "dy": dy, "ids": ids, "content": true}))];
|
||||
}
|
||||
let mut params = json!({"dx": dx, "dy": dy, "copy": ev.mods.alt});
|
||||
if target != origin_spread {
|
||||
params = json!({"dx": dx, "dy": dy, "copy": ev.mods.alt, "toSpread": spread_json(target)});
|
||||
@@ -803,14 +920,25 @@ impl Tool for SelectionTool {
|
||||
fn overlays(&self, _cx: &ToolContext) -> Vec<Overlay> {
|
||||
match &self.drag {
|
||||
Drag::Marquee { start, cur } | Drag::Place { start, cur } => vec![Overlay::Marquee(Rect::from_points(*start, *cur))],
|
||||
Drag::Move { content: true, exclude, .. } => {
|
||||
let mut v: Vec<Overlay> = exclude.iter().map(|id| Overlay::ContentGhost(*id)).collect();
|
||||
v.extend(self.guides.iter().cloned());
|
||||
v
|
||||
}
|
||||
Drag::Move { .. } | Drag::Resize { .. } | Drag::Rotate { .. } | Drag::Anchor { .. } => self.guides.clone(),
|
||||
_ => vec![],
|
||||
}
|
||||
}
|
||||
|
||||
fn cursor(&self, cx: &ToolContext, p: Point, _m: Mods) -> Cursor {
|
||||
if let Drag::Move { content: true, .. } = self.drag {
|
||||
return Cursor::HandGrab;
|
||||
}
|
||||
if self.direct {
|
||||
return Cursor::ArrowHollow;
|
||||
let over_content = matches!(self.drag, Drag::None)
|
||||
&& anchor_at(cx, p).is_none()
|
||||
&& cx.hit(p).is_some_and(|(_, id)| cx.doc.item(id).is_some_and(holds_content));
|
||||
return if over_content { Cursor::Hand } else { Cursor::ArrowHollow };
|
||||
}
|
||||
if let Some(l) = self.loaded.and_then(|l| l.resolve(cx.doc)) {
|
||||
return if crate::thread::port_at(cx, p).is_some() { Cursor::LoadedText } else { l.cursor(cx, p) };
|
||||
@@ -824,6 +952,7 @@ impl Tool for SelectionTool {
|
||||
match handle_at(cx, p) {
|
||||
Some(h) => handle_cursor(h),
|
||||
None if rotate_zone(cx, p).is_some() => Cursor::Rotate,
|
||||
None if grabber_at(cx, p).is_some() => Cursor::Hand,
|
||||
None => Cursor::Arrow,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -939,3 +939,117 @@ fn clicks_in_a_row_edit_and_select_text() {
|
||||
// Moving a little while pressed keeps the unit selected.
|
||||
assert!(t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, x + 2.0, y)).is_empty());
|
||||
}
|
||||
|
||||
/// A 100×100 frame at (100, 100) holding a 200×100 graphic that overhangs it.
|
||||
fn graphic_frame(doc: &mut Document) -> ItemId {
|
||||
let id = ItemId(doc.alloc());
|
||||
let mut item = Item::new(id, doc.default_layer(), Shape::Rectangle, shapes::rectangle(Rect::new(100.0, 100.0, 200.0, 200.0)));
|
||||
item.content = designcraft_doc::Content::Graphic(designcraft_doc::Graphic {
|
||||
asset: designcraft_doc::AssetId(1),
|
||||
size: (200.0, 100.0),
|
||||
xf: designcraft_geom::Affine::translate((50.0, 100.0)),
|
||||
auto_fit: Default::default(),
|
||||
fit_align: 4,
|
||||
crop: [0.0; 4],
|
||||
wrap: Default::default(),
|
||||
});
|
||||
doc.insert_item(SpreadRef::Doc(0), item, None).unwrap();
|
||||
id
|
||||
}
|
||||
|
||||
fn preview_params(a: &[Action]) -> serde_json::Value {
|
||||
match a.last() {
|
||||
Some(Action::Preview(cmd, p)) if cmd == "transform.move" => p.clone(),
|
||||
other => panic!("{other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn direct_selection_drags_content_inside_its_frame() {
|
||||
let mut d = Document::new(&NewDocument::default());
|
||||
let f = graphic_frame(&mut d);
|
||||
let (c, l) = (Cache::new(), CanvasLayout::new(&d, false));
|
||||
let none = Selection::default();
|
||||
let mut t = create("directSelection");
|
||||
let at = spread_pt(&l, 120.0, 130.0);
|
||||
let a = t.pointer(&ctx(&d, &none, &c, &l), &PointerEvent::new(PointerKind::Down, at.x, at.y));
|
||||
assert_eq!(a, vec![Action::Exec("selection.set".into(), serde_json::json!({"ids": [f.0], "content": true}))]);
|
||||
let sel = Selection { content: true, ..Selection::items(vec![f]) };
|
||||
let cx = ctx(&d, &sel, &c, &l);
|
||||
let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, at.x + 15.0, at.y - 4.0));
|
||||
assert_eq!(a.first(), Some(&Action::Begin("Move Content".into())));
|
||||
let p = preview_params(&a);
|
||||
assert_eq!(
|
||||
(p["dx"].as_f64(), p["dy"].as_f64(), &p["content"], &p["ids"]),
|
||||
(Some(15.0), Some(-4.0), &serde_json::json!(true), &serde_json::json!([f.0]))
|
||||
);
|
||||
assert_eq!(t.cursor(&cx, at, Mods::default()), Cursor::HandGrab);
|
||||
assert!(t.overlays(&cx).contains(&Overlay::ContentGhost(f)));
|
||||
// Shift keeps the drag on one axis.
|
||||
let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, at.x + 15.0, at.y - 4.0).with_mods(Mods { shift: true, ..Default::default() }));
|
||||
let p = preview_params(&a);
|
||||
assert_eq!((p["dx"].as_f64(), p["dy"].as_f64()), (Some(15.0), Some(0.0)));
|
||||
assert_eq!(t.pointer(&cx, &PointerEvent::new(PointerKind::Up, at.x + 15.0, at.y)), vec![Action::Commit]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selection_tool_grabber_moves_content_elsewhere_moves_the_frame() {
|
||||
let mut d = Document::new(&NewDocument::default());
|
||||
let f = graphic_frame(&mut d);
|
||||
let (c, l) = (Cache::new(), CanvasLayout::new(&d, false));
|
||||
let frame = Selection::items(vec![f]);
|
||||
let content = Selection { content: true, ..Selection::items(vec![f]) };
|
||||
let centre = spread_pt(&l, 150.0, 150.0);
|
||||
let mut t = create("selection");
|
||||
// Hovering the grabber shows the hand; it is there on hover before any click, too.
|
||||
assert_eq!(t.cursor(&ctx(&d, &Selection::default(), &c, &l), centre, Mods::default()), Cursor::Hand);
|
||||
let g = select::grabbers(&ctx(&d, &Selection::default(), &c, &l), Some(centre));
|
||||
assert!(matches!(g.as_slice(), [(id, p)] if *id == f && near(*p, centre)), "{g:?}");
|
||||
// Press on the grabber: the content is selected; the drag moves it.
|
||||
let a = t.pointer(&ctx(&d, &frame, &c, &l), &PointerEvent::new(PointerKind::Down, centre.x, centre.y));
|
||||
assert_eq!(a, vec![Action::Exec("selection.set".into(), serde_json::json!({"ids": [f.0], "content": true}))]);
|
||||
let a = t.pointer(&ctx(&d, &content, &c, &l), &PointerEvent::new(PointerKind::Drag, centre.x - 20.0, centre.y + 10.0));
|
||||
let p = preview_params(&a);
|
||||
assert_eq!((p["dx"].as_f64(), p["dy"].as_f64(), &p["content"]), (Some(-20.0), Some(10.0), &serde_json::json!(true)));
|
||||
assert_eq!(t.pointer(&ctx(&d, &content, &c, &l), &PointerEvent::new(PointerKind::Up, centre.x - 20.0, centre.y + 10.0)), vec![Action::Commit]);
|
||||
// A press elsewhere in the frame keeps the content selection; dragging selects and moves the frame.
|
||||
let edge = spread_pt(&l, 115.0, 185.0);
|
||||
assert!(t.pointer(&ctx(&d, &content, &c, &l), &PointerEvent::new(PointerKind::Down, edge.x, edge.y)).is_empty());
|
||||
let a = t.pointer(&ctx(&d, &content, &c, &l), &PointerEvent::new(PointerKind::Drag, edge.x + 30.0, edge.y));
|
||||
assert_eq!(a[0], Action::Exec("selection.set".into(), serde_json::json!({"ids": [f.0]})));
|
||||
assert_eq!(a[1], Action::Begin("Move".into()));
|
||||
let p = preview_params(&a);
|
||||
assert_eq!((p["dx"].as_f64(), p.get("content"), p.get("ids")), (Some(30.0), None, None));
|
||||
t.pointer(&ctx(&d, &frame, &c, &l), &PointerEvent::new(PointerKind::Up, edge.x + 30.0, edge.y));
|
||||
// With the frame selected, a drag away from the grabber moves the frame.
|
||||
t.pointer(&ctx(&d, &frame, &c, &l), &PointerEvent::new(PointerKind::Down, edge.x, edge.y));
|
||||
let a = t.pointer(&ctx(&d, &frame, &c, &l), &PointerEvent::new(PointerKind::Drag, edge.x, edge.y + 30.0));
|
||||
assert_eq!(a[0], Action::Begin("Move".into()));
|
||||
assert_eq!(preview_params(&a).get("content"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn double_click_toggles_frame_and_content() {
|
||||
let mut d = Document::new(&NewDocument::default());
|
||||
let f = graphic_frame(&mut d);
|
||||
let (c, l) = (Cache::new(), CanvasLayout::new(&d, false));
|
||||
let at = spread_pt(&l, 120.0, 180.0);
|
||||
let mut t = create("selection");
|
||||
let frame = Selection::items(vec![f]);
|
||||
let content = Selection { content: true, ..Selection::items(vec![f]) };
|
||||
let set = |content: bool| vec![Action::Exec("selection.set".into(), serde_json::json!({"ids": [f.0], "content": content}))];
|
||||
// The canvas's presses in a row: the first selects the frame, the second its content.
|
||||
let a = t.pointer(&ctx(&d, &Selection::default(), &c, &l), &PointerEvent::new(PointerKind::Down, at.x, at.y));
|
||||
assert_eq!(a, vec![Action::Exec("selection.set".into(), serde_json::json!({"ids": [f.0], "content": false}))]);
|
||||
t.pointer(&ctx(&d, &frame, &c, &l), &PointerEvent::new(PointerKind::Up, at.x, at.y));
|
||||
assert_eq!(t.pointer(&ctx(&d, &frame, &c, &l), &PointerEvent::new(PointerKind::Down, at.x, at.y).with_clicks(2)), set(true));
|
||||
assert!(t.pointer(&ctx(&d, &content, &c, &l), &PointerEvent::new(PointerKind::Up, at.x, at.y)).is_empty());
|
||||
// With the content selected, the first press keeps it and the second selects the frame.
|
||||
assert!(t.pointer(&ctx(&d, &content, &c, &l), &PointerEvent::new(PointerKind::Down, at.x, at.y)).is_empty());
|
||||
t.pointer(&ctx(&d, &content, &c, &l), &PointerEvent::new(PointerKind::Up, at.x, at.y));
|
||||
assert_eq!(t.pointer(&ctx(&d, &content, &c, &l), &PointerEvent::new(PointerKind::Down, at.x, at.y).with_clicks(2)), set(false));
|
||||
// A double click sent as such (control channel) toggles too.
|
||||
let dbl = PointerEvent::new(PointerKind::DoubleClick, at.x, at.y);
|
||||
assert_eq!(t.pointer(&ctx(&d, &frame, &c, &l), &dbl), set(true));
|
||||
assert_eq!(t.pointer(&ctx(&d, &content, &c, &l), &dbl), set(false));
|
||||
}
|
||||
|
||||
@@ -322,6 +322,16 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) {
|
||||
}
|
||||
}
|
||||
let sel_rect = draw_selection(app, &painter, &xf, &doc, &layout);
|
||||
if !preview {
|
||||
// Content grabbers: the selected frame's and the hovered one's (Selection tool).
|
||||
let hover = ui.ctx().pointer_hover_pos().filter(|p| screen.contains(*p)).map(|p| xf.to_canvas(p));
|
||||
let vi = app.view_info();
|
||||
for c in app.session.content_grabbers(hover, vi) {
|
||||
let c = xf.to_screen(c);
|
||||
painter.circle_stroke(c, 8.0, Stroke::new(1.5, Color32::from_white_alpha(170)));
|
||||
painter.circle_stroke(c, 4.0, Stroke::new(1.5, Color32::from_white_alpha(170)));
|
||||
}
|
||||
}
|
||||
draw_tool_overlays(app, &painter, &xf);
|
||||
ime_output(app, ui.ctx(), &xf);
|
||||
if rulers {
|
||||
@@ -905,11 +915,6 @@ fn draw_selection(app: &DesignApp, painter: &egui::Painter, xf: &Xf, doc: &Docum
|
||||
painter.rect_filled(r, 0.0, Color32::WHITE);
|
||||
painter.rect_stroke(r, 0.0, Stroke::new(hair(painter), color), StrokeKind::Inside);
|
||||
}
|
||||
// Content grabber on graphic frames.
|
||||
if sel.items.len() == 1 && doc.item(sel.items[0]).is_some_and(|i| matches!(i.content, Content::Graphic(_))) {
|
||||
painter.circle_stroke(u.center(), 8.0, Stroke::new(1.5, Color32::from_white_alpha(170)));
|
||||
painter.circle_stroke(u.center(), 4.0, Stroke::new(1.5, Color32::from_white_alpha(170)));
|
||||
}
|
||||
}
|
||||
// Threads.
|
||||
if app.ui.text_threads || sel.items.iter().any(|i| doc.item(*i).is_some_and(|x| x.is_text_frame())) {
|
||||
@@ -1219,6 +1224,8 @@ fn draw_tool_overlays(app: &mut DesignApp, painter: &egui::Painter, xf: &Xf) {
|
||||
}
|
||||
let tok = Tokens::get(painter.ctx());
|
||||
let ov = app.session.overlays(app.view_info());
|
||||
let doc = app.session.active().map(|st| st.doc.clone());
|
||||
let layout = app.session.layout();
|
||||
for o in ov {
|
||||
match o {
|
||||
Overlay::Marquee(r) => {
|
||||
@@ -1261,10 +1268,66 @@ fn draw_tool_overlays(app: &mut DesignApp, painter: &egui::Painter, xf: &Xf) {
|
||||
painter.galley(r.min + vec2(5.0, 3.0), g, tok.measure_text);
|
||||
}
|
||||
Overlay::Path { .. } => {}
|
||||
Overlay::ContentGhost(id) => {
|
||||
if let Some(doc) = &doc {
|
||||
draw_content_ghost(painter, xf, doc, &layout, id);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A frame's whole content drawn faintly over the canvas, the part outside the frame included,
|
||||
/// with its bounds outlined (while the content moves inside the frame).
|
||||
fn draw_content_ghost(painter: &egui::Painter, xf: &Xf, doc: &Document, layout: &CanvasLayout, id: designcraft_doc::ItemId) {
|
||||
let Some(it) = doc.item(id) else { return };
|
||||
let Some((a, _)) = item_canvas_xf(doc, layout, id) else { return };
|
||||
let outline = Stroke::new(1.0, Color32::from_rgb(196, 111, 43));
|
||||
let Content::Graphic(g) = &it.content else {
|
||||
for c in it.children() {
|
||||
let r = xf.rect((a * it.xf).transform_rect_bbox(c.bounds()));
|
||||
painter.rect_stroke(r, 0.0, outline, StrokeKind::Middle);
|
||||
}
|
||||
return;
|
||||
};
|
||||
let m = a * it.xf * g.xf;
|
||||
let (w, h) = g.size;
|
||||
let corners = [Point::new(0.0, 0.0), Point::new(w, 0.0), Point::new(w, h), Point::new(0.0, h)].map(|p| xf.to_screen(m * p));
|
||||
if corners.iter().any(|p| !p.x.is_finite() || !p.y.is_finite()) {
|
||||
return;
|
||||
}
|
||||
if let Some(tex) = ghost_texture(painter.ctx(), doc, g) {
|
||||
let mut mesh = egui::Mesh::with_texture(tex.id());
|
||||
let tint = Color32::from_white_alpha(90);
|
||||
for (p, uv) in corners.iter().zip([pos2(0.0, 0.0), pos2(1.0, 0.0), pos2(1.0, 1.0), pos2(0.0, 1.0)]) {
|
||||
mesh.vertices.push(egui::epaint::Vertex { pos: *p, uv, color: tint });
|
||||
}
|
||||
mesh.indices.extend([0, 1, 2, 0, 2, 3]);
|
||||
painter.add(egui::Shape::mesh(mesh));
|
||||
}
|
||||
painter.add(egui::Shape::closed_line(corners.to_vec(), outline));
|
||||
}
|
||||
|
||||
/// A screen-resolution proxy of a placed graphic for the ghost, cached per asset.
|
||||
fn ghost_texture(ctx: &egui::Context, doc: &Document, g: &designcraft_doc::Graphic) -> Option<egui::TextureHandle> {
|
||||
let asset = doc.assets.get(&g.asset)?;
|
||||
let key = egui::Id::new(("content_ghost", std::sync::Arc::as_ptr(&asset.data) as usize, asset.data.len(), asset.page));
|
||||
if let Some(t) = ctx.data(|d| d.get_temp::<egui::TextureHandle>(key)) {
|
||||
return Some(t);
|
||||
}
|
||||
// A 72 ppi proxy is enough for a faint preview.
|
||||
let pm = designcraft_render::images::mip(&asset.data, asset.page, g.size.0, 1.0)?;
|
||||
let (pw, ph) = (pm.width() as usize, pm.height() as usize);
|
||||
let max = ctx.input(|i| i.max_texture_side).min(4096);
|
||||
if pw == 0 || ph == 0 || pw > max || ph > max {
|
||||
return None;
|
||||
}
|
||||
let ci = egui::ColorImage::from_rgba_premultiplied([pw, ph], pm.data_as_u8_slice());
|
||||
let t = ctx.load_texture("content_ghost", ci, egui::TextureOptions::LINEAR);
|
||||
ctx.data_mut(|d| d.insert_temp(key, t.clone()));
|
||||
Some(t)
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn draw_rulers(app: &DesignApp, ui: &egui::Ui, full: Rect, rect: Rect, xf: &Xf, doc: &Document, layout: &CanvasLayout, t: &Tokens) {
|
||||
let painter = ui.painter_at(full);
|
||||
|
||||
Reference in New Issue
Block a user