mirror of
https://github.com/storytold/deckcraft.git
synced 2026-10-31 22:16:59 -06:00
Merge PR #244 from pierrevbc: Canvas: right click selects the shape under the pointer
# Conflicts: # crates/engine/src/tests.rs
This commit is contained in:
@@ -958,3 +958,80 @@ fn plain_outlines_are_unchanged() {
|
||||
let p = crate::cmd::file::open_presentation("outline.txt", MD_OUTLINE.as_bytes()).unwrap();
|
||||
assert_eq!(p.slides.first().map(|s| s.title()).as_deref(), Some("# Intro"));
|
||||
}
|
||||
|
||||
fn text_box_xfrm(s: &Session, id: u64) -> deckcraft_geom::Xfrm {
|
||||
let st = s.doc().unwrap();
|
||||
cmd::xfrm_of(&st.doc, &st.selection, st.shape(deckcraft_model::ShapeId(id as u32)).unwrap())
|
||||
}
|
||||
|
||||
fn right_click(s: &mut Session, x: f64, y: f64) -> serde_json::Value {
|
||||
s.pointer(PointerEvent { kind: PointerKind::ContextClick, x, y, mods: Mods::default(), tol: 3.0 }).unwrap()
|
||||
}
|
||||
|
||||
/// A blank slide with a rectangle A (100,100 200×100) and a text box B (400,100 200 wide),
|
||||
/// nothing selected.
|
||||
fn two_shapes() -> (Session, deckcraft_model::ShapeId, deckcraft_model::ShapeId) {
|
||||
let mut s = session();
|
||||
s.execute("slide.new", &json!({"layout": "blank"})).unwrap();
|
||||
let a = s.execute("shape.insert", &json!({"preset": "rect", "rect": [100.0, 100.0, 200.0, 100.0], "text": "Shape A"})).unwrap()["id"]
|
||||
.as_u64()
|
||||
.unwrap();
|
||||
let b = s.execute("insert.textBox", &json!({"rect": [400.0, 100.0, 200.0, 40.0], "text": "Box B"})).unwrap()["id"].as_u64().unwrap();
|
||||
s.select(|_, sel| {
|
||||
sel.shapes.clear();
|
||||
sel.text = None;
|
||||
})
|
||||
.unwrap();
|
||||
(s, deckcraft_model::ShapeId(a as u32), deckcraft_model::ShapeId(b as u32))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn right_click_selects_the_shape_under_the_pointer() {
|
||||
let (mut s, a, b) = two_shapes();
|
||||
let undo_before = s.doc().unwrap().history.undo.len();
|
||||
// An unselected shape: selected alone, no text editing, no drag.
|
||||
let r = right_click(&mut s, 150.0, 150.0);
|
||||
assert_eq!(r["selected"], json!([a]));
|
||||
let st = s.doc().unwrap();
|
||||
assert_eq!(st.selection.shapes, vec![a]);
|
||||
assert!(st.selection.text.is_none());
|
||||
assert!(!s.tool.dragging());
|
||||
s.pointer(PointerEvent { kind: PointerKind::Drag, x: 190.0, y: 190.0, mods: Mods::default(), tol: 3.0 }).unwrap();
|
||||
assert_eq!(text_box_xfrm(&s, a.0 as u64).x, 100.0);
|
||||
// A text box, which a left click would start editing: only selected.
|
||||
right_click(&mut s, 450.0, 115.0);
|
||||
let st = s.doc().unwrap();
|
||||
assert_eq!(st.selection.shapes, vec![b]);
|
||||
assert!(st.selection.text.is_none());
|
||||
// A multiple selection stays when the right click lands on one of its shapes.
|
||||
s.execute("edit.select", &json!({"ids": [a, b]})).unwrap();
|
||||
right_click(&mut s, 450.0, 115.0);
|
||||
assert_eq!(s.doc().unwrap().selection.shapes, vec![a, b]);
|
||||
// Empty slide: nothing selected (the slide menu).
|
||||
let r = right_click(&mut s, 700.0, 450.0);
|
||||
assert!(r["hit"].is_null(), "{r}");
|
||||
assert!(s.doc().unwrap().selection.shapes.is_empty());
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), undo_before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn right_click_in_edited_text_keeps_the_text_selection() {
|
||||
let (mut s, a, b) = two_shapes();
|
||||
s.execute("text.edit", &json!({"id": a, "at": [0, 0]})).unwrap();
|
||||
s.execute("text.move", &json!({"to": "wordRight", "extend": true})).unwrap();
|
||||
let before = s.doc().unwrap().selection.text.clone().unwrap();
|
||||
assert!(before.is_range());
|
||||
// Anywhere in the box, even away from the selected word.
|
||||
let r = right_click(&mut s, 280.0, 185.0);
|
||||
assert_eq!(r["editing"], true);
|
||||
assert_eq!(s.doc().unwrap().selection.text.as_ref(), Some(&before));
|
||||
// The context menu's Copy and Cut then act on the selected text.
|
||||
assert_eq!(s.execute("edit.copy", &json!({})).unwrap()["text"].as_str().unwrap().trim(), "Shape");
|
||||
s.execute("edit.cut", &json!({})).unwrap();
|
||||
assert_eq!(s.execute("text.get", &json!({"id": a})).unwrap()["text"].as_str().unwrap().trim(), "A");
|
||||
// A right click on another shape leaves editing and selects that shape.
|
||||
right_click(&mut s, 450.0, 115.0);
|
||||
let st = s.doc().unwrap();
|
||||
assert!(st.selection.text.is_none());
|
||||
assert_eq!(st.selection.shapes, vec![b]);
|
||||
}
|
||||
|
||||
@@ -21,6 +21,9 @@ pub enum PointerKind {
|
||||
Move,
|
||||
DoubleClick,
|
||||
TripleClick,
|
||||
/// Secondary-button press (right click), before the context menu opens: selects what's under
|
||||
/// the pointer the way PowerPoint does, without editing text or starting a drag.
|
||||
ContextClick,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
@@ -299,6 +302,7 @@ impl Session {
|
||||
PointerKind::Drag => self.drag(p, ev.mods, tol),
|
||||
PointerKind::Up => self.up(p, ev.mods),
|
||||
PointerKind::DoubleClick => self.double_click(p, tol),
|
||||
PointerKind::ContextClick => self.context_click(p, tol),
|
||||
PointerKind::TripleClick => {
|
||||
if self.active().is_some_and(|d| d.selection.text.is_some()) {
|
||||
self.execute("text.selectParagraph", &json!({}))
|
||||
@@ -837,6 +841,45 @@ impl Session {
|
||||
}
|
||||
}
|
||||
|
||||
/// Right-button press: the selection the context menu will act on, as in PowerPoint. In the
|
||||
/// box being edited, the caret and text selection stay (the menu then acts on the text). On a
|
||||
/// selected shape (or its handles) the selection stays, multiple selection included; on
|
||||
/// another shape, that shape alone is selected; on empty slide, nothing is. Never edits text,
|
||||
/// starts a drag or adds an undo step.
|
||||
fn context_click(&mut self, p: Point, tol: f64) -> Result<Value> {
|
||||
if !matches!(self.tool.kind, ToolKind::Select) || self.tool.drag.is_some() {
|
||||
return Ok(Value::Null);
|
||||
}
|
||||
let st = self.doc()?;
|
||||
// The same zone in which a left press places the caret (`down`).
|
||||
if let Some(t) = &st.selection.text
|
||||
&& !t.notes
|
||||
&& t.cell.is_none()
|
||||
&& let Some(sh) = st.shape(t.shape)
|
||||
&& xfrm_of(&st.doc, &st.selection, sh).bounds().inflate(tol, tol).contains(p)
|
||||
{
|
||||
return Ok(json!({"hit": {"kind": "text", "id": t.shape}, "selected": [t.shape], "editing": true}));
|
||||
}
|
||||
let hit = self.hit_test(p, tol);
|
||||
let id = match &hit {
|
||||
Some(Hit::Shape { id, .. } | Hit::Resize { id, .. } | Hit::Rotate { id } | Hit::Adjust { id, .. } | Hit::LineEnd { id, .. }) => Some(*id),
|
||||
None => None,
|
||||
};
|
||||
if !id.is_some_and(|id| st.selection.shapes.contains(&id)) {
|
||||
self.select(|_, sel| {
|
||||
sel.text = None;
|
||||
sel.cells = None;
|
||||
sel.shapes = id.into_iter().collect();
|
||||
})?;
|
||||
}
|
||||
let st = self.doc()?;
|
||||
Ok(json!({
|
||||
"hit": serde_json::to_value(&hit).unwrap_or_default(),
|
||||
"selected": st.selection.shapes,
|
||||
"editing": st.selection.text.is_some(),
|
||||
}))
|
||||
}
|
||||
|
||||
fn is_picture(&self, id: ShapeId) -> bool {
|
||||
self.active().and_then(|d| d.shape(id)).is_some_and(|s| matches!(s.kind, ShapeKind::Picture { .. } | ShapeKind::Media(_)))
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user