mirror of
https://github.com/storytold/deckcraft.git
synced 2026-11-03 09:08:59 +00:00
Flips mirror a shape's geometry, never its text. The text was drawn with the shape's full transform, so every label on a flipped arrow came out mirrored (flipH) or mirrored upside down (flipV). As in PowerPoint, the text rect keeps its flipped place and the text is mirrored back about the rect's centre: flipH text stays upright, flipV (or both flips) turns it 180°, rotation still applies, and the same holds for shapes inside flipped groups. render::text_transform is that transform (plus vertical-text rotation). Drawing, the PDF text layer and link boxes, morph text, the editing caret and selection, and click-to-place-the-caret all use it, so a click lands on the character drawn under it. Co-authored-by: lloyd <lloyd@stackworx.io> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: Brandon Thomas <echelon@gmail.com>
1330 lines
55 KiB
Rust
1330 lines
55 KiB
Rust
//! Pointer tools. Gestures arrive in slide coordinates (points) through [`Session::pointer`] from
|
|
//! the UI, the control channel or MCP alike; each finished gesture is one undo step.
|
|
|
|
use std::sync::Arc;
|
|
|
|
use deckcraft_geom::{Affine, Point, Rect, Vec2, Xfrm};
|
|
use deckcraft_model::{Shape, ShapeId, ShapeKind};
|
|
use serde::{Deserialize, Serialize};
|
|
use serde_json::{Value, json};
|
|
|
|
use crate::cmd::{self, xfrm_of};
|
|
use crate::{HistoryEntry, Result, Session, TextSel};
|
|
|
|
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub enum PointerKind {
|
|
#[default]
|
|
Down,
|
|
Drag,
|
|
Up,
|
|
Move,
|
|
DoubleClick,
|
|
TripleClick,
|
|
}
|
|
|
|
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
|
#[serde(default, rename_all = "camelCase")]
|
|
pub struct Mods {
|
|
pub shift: bool,
|
|
pub alt: bool,
|
|
pub cmd: bool,
|
|
}
|
|
|
|
#[derive(Clone, Copy, Debug, Default, Serialize, Deserialize)]
|
|
#[serde(default, rename_all = "camelCase")]
|
|
pub struct PointerEvent {
|
|
pub kind: PointerKind,
|
|
pub x: f64,
|
|
pub y: f64,
|
|
pub mods: Mods,
|
|
/// Hit tolerance in points (≈ 4 screen px at the current zoom).
|
|
pub tol: f64,
|
|
}
|
|
|
|
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
|
#[serde(rename_all = "camelCase", tag = "kind")]
|
|
pub enum ToolKind {
|
|
#[default]
|
|
Select,
|
|
/// Draw a preset shape.
|
|
Shape {
|
|
preset: String,
|
|
},
|
|
TextBox,
|
|
/// Ink: pen (`pen`, `highlighter`) or `eraser`.
|
|
Ink {
|
|
mode: String,
|
|
color: deckcraft_color::Rgba,
|
|
width: f64,
|
|
},
|
|
}
|
|
|
|
/// What a point hits.
|
|
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
|
#[serde(rename_all = "camelCase", tag = "kind")]
|
|
pub enum Hit {
|
|
/// Resize handle 0..8: NW, N, NE, E, SE, S, SW, W.
|
|
Resize {
|
|
id: ShapeId,
|
|
handle: u8,
|
|
},
|
|
Rotate {
|
|
id: ShapeId,
|
|
},
|
|
Adjust {
|
|
id: ShapeId,
|
|
index: usize,
|
|
},
|
|
/// Line end 0/1 of a line-like shape.
|
|
LineEnd {
|
|
id: ShapeId,
|
|
end: u8,
|
|
},
|
|
Shape {
|
|
id: ShapeId,
|
|
text: bool,
|
|
},
|
|
}
|
|
|
|
#[derive(Clone, Debug)]
|
|
enum Drag {
|
|
Move {
|
|
ids: Vec<ShapeId>,
|
|
start: Point,
|
|
origs: Vec<(ShapeId, Xfrm)>,
|
|
moved: bool,
|
|
duplicate: bool,
|
|
enter_text: Option<(ShapeId, Point)>,
|
|
},
|
|
Resize {
|
|
id: ShapeId,
|
|
handle: u8,
|
|
orig: Xfrm,
|
|
start: Point,
|
|
},
|
|
LineEnd {
|
|
id: ShapeId,
|
|
end: u8,
|
|
orig: Xfrm,
|
|
},
|
|
Rotate {
|
|
ids: Vec<ShapeId>,
|
|
center: Point,
|
|
start: f64,
|
|
origs: Vec<(ShapeId, Xfrm)>,
|
|
},
|
|
Adjust {
|
|
id: ShapeId,
|
|
index: usize,
|
|
orig: Xfrm,
|
|
},
|
|
Marquee {
|
|
start: Point,
|
|
add: bool,
|
|
},
|
|
Create {
|
|
start: Point,
|
|
},
|
|
TextSelect {
|
|
shape: ShapeId,
|
|
},
|
|
Ink {
|
|
points: Vec<(f64, f64, f32)>,
|
|
},
|
|
/// Freeform drawing (`freeform` polygon, `curve`, `scribble`): placed points; `pressed` while
|
|
/// the button is down (dragging adds scribbled points).
|
|
Freeform {
|
|
mode: String,
|
|
points: Vec<Point>,
|
|
pressed: bool,
|
|
},
|
|
}
|
|
|
|
/// Shape-gallery names that draw a freeform path instead of a preset.
|
|
pub const FREEFORM_TOOLS: [(&str, &str); 3] = [("curve", "Curve"), ("freeform", "Freeform: Shape"), ("scribble", "Freeform: Scribble")];
|
|
|
|
pub fn is_freeform_tool(name: &str) -> bool {
|
|
FREEFORM_TOOLS.iter().any(|f| f.0 == name)
|
|
}
|
|
|
|
/// A snapping guide line to draw (slide coordinates): vertical (x) or horizontal (y).
|
|
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct Guide {
|
|
pub vertical: bool,
|
|
pub pos: f64,
|
|
pub from: f64,
|
|
pub to: f64,
|
|
}
|
|
|
|
#[derive(Clone, Debug, Default)]
|
|
pub struct ToolState {
|
|
pub kind: ToolKind,
|
|
/// Keep the tool after one use (double-click on a shape in the gallery: lock drawing mode).
|
|
pub sticky: bool,
|
|
drag: Option<Drag>,
|
|
pub marquee: Option<Rect>,
|
|
pub preview: Option<Xfrm>,
|
|
pub guides: Vec<Guide>,
|
|
pub hover: Option<Hit>,
|
|
pub ink_preview: Vec<(f64, f64, f32)>,
|
|
/// Connection sites to show (slide coordinates) while drawing or dragging a line end, and the
|
|
/// site the end would glue to.
|
|
pub sites: Vec<Point>,
|
|
pub glue: Option<Point>,
|
|
/// Freeform being drawn: points so far (plus the rubber-band point), smoothed, and whether
|
|
/// the pointer is over the start (closing the shape).
|
|
pub path_preview: Vec<Point>,
|
|
pub path_smooth: bool,
|
|
pub path_closing: bool,
|
|
}
|
|
|
|
impl ToolState {
|
|
pub fn dragging(&self) -> bool {
|
|
self.drag.is_some()
|
|
}
|
|
|
|
/// A freeform path is being drawn (Enter/Escape/double-click finish it).
|
|
pub fn drawing_freeform(&self) -> bool {
|
|
matches!(self.drag, Some(Drag::Freeform { .. }))
|
|
}
|
|
}
|
|
|
|
/// Handle positions in slide coordinates for a box (8 resize + rotate).
|
|
pub fn box_handles(x: &Xfrm) -> ([Point; 8], Point) {
|
|
let a = x.affine();
|
|
let (w, h) = (x.w, x.h);
|
|
let local = [(0.0, 0.0), (w / 2.0, 0.0), (w, 0.0), (w, h / 2.0), (w, h), (w / 2.0, h), (0.0, h), (0.0, h / 2.0)];
|
|
let pts = local.map(|(px, py)| a * Point::new(px, py));
|
|
// The rotation handle sits above the top edge (in the shape's rotated frame).
|
|
let up = if x.flip_v { 1.0 } else { -1.0 };
|
|
let rot =
|
|
Affine::translate(Vec2::new(x.x + w / 2.0, x.y + h / 2.0)) * Affine::rotate(x.rot.to_radians()) * Point::new(0.0, up * (h / 2.0 + 20.0));
|
|
(pts, rot)
|
|
}
|
|
|
|
impl Session {
|
|
pub fn set_tool(&mut self, kind: ToolKind) {
|
|
self.tool.kind = kind;
|
|
self.tool.drag = None;
|
|
self.tool.preview = None;
|
|
self.tool.marquee = None;
|
|
}
|
|
|
|
/// What's under `p` on the current slide.
|
|
pub fn hit_test(&self, p: Point, tol: f64) -> Option<Hit> {
|
|
let st = self.active()?;
|
|
// Handles of selected shapes first.
|
|
if st.selection.text.is_none() || st.selection.shapes.len() == 1 {
|
|
for id in st.selection.shapes.iter().rev() {
|
|
let Some(sh) = st.shape(*id) else { continue };
|
|
let x = xfrm_of(&st.doc, &st.selection, sh);
|
|
if sh.is_line() {
|
|
let a = x.affine();
|
|
for (end, q) in [(0u8, a * Point::new(0.0, 0.0)), (1u8, a * Point::new(x.w, x.h))] {
|
|
if (q - p).hypot() <= tol * 1.6 {
|
|
return Some(Hit::LineEnd { id: *id, end });
|
|
}
|
|
}
|
|
continue;
|
|
}
|
|
let geo = deckcraft_render::shape_geometry(sh, x.w, x.h);
|
|
for hd in &geo.handles {
|
|
if (x.affine() * hd.pos - p).hypot() <= tol * 1.6 {
|
|
return Some(Hit::Adjust { id: *id, index: hd.index });
|
|
}
|
|
}
|
|
let (pts, rot) = box_handles(&x);
|
|
if (rot - p).hypot() <= tol * 1.8 && st.selection.text.is_none() {
|
|
return Some(Hit::Rotate { id: *id });
|
|
}
|
|
for (i, q) in pts.iter().enumerate() {
|
|
if (*q - p).hypot() <= tol * 1.6 {
|
|
return Some(Hit::Resize { id: *id, handle: i as u8 });
|
|
}
|
|
}
|
|
}
|
|
}
|
|
// Shapes, topmost first.
|
|
for sh in st.shapes().iter().rev() {
|
|
if sh.hidden || sh.locked {
|
|
continue;
|
|
}
|
|
if let Some(h) = hit_shape(st, sh, p, tol) {
|
|
return Some(h);
|
|
}
|
|
}
|
|
None
|
|
}
|
|
|
|
/// Feed a pointer event to the active tool. Returns what happened (for agents).
|
|
pub fn pointer(&mut self, ev: PointerEvent) -> Result<Value> {
|
|
let tol = if ev.tol > 0.0 { ev.tol } else { 4.0 };
|
|
let p = Point::new(ev.x, ev.y);
|
|
if !(p.x.is_finite() && p.y.is_finite()) {
|
|
return Ok(Value::Null);
|
|
}
|
|
match ev.kind {
|
|
PointerKind::Move => {
|
|
if let Some(Drag::Freeform { points, mode, .. }) = &self.tool.drag {
|
|
let closing = points.len() > 2 && points.first().is_some_and(|s| (*s - p).hypot() <= tol * 2.0);
|
|
let mut v = points.clone();
|
|
v.push(p);
|
|
self.tool.path_preview = v;
|
|
self.tool.path_smooth = mode == "curve";
|
|
self.tool.path_closing = closing;
|
|
return Ok(Value::Null);
|
|
}
|
|
self.tool.hover = self.hit_test(p, tol);
|
|
let line_tool = matches!(&self.tool.kind, ToolKind::Shape { preset } if deckcraft_geom::preset::is_line_like(preset));
|
|
if line_tool {
|
|
self.update_sites(p, tol, None);
|
|
} else {
|
|
self.tool.sites.clear();
|
|
self.tool.glue = None;
|
|
}
|
|
Ok(Value::Null)
|
|
}
|
|
PointerKind::Down => self.down(p, ev.mods, tol),
|
|
PointerKind::Drag => self.drag(p, ev.mods, tol),
|
|
PointerKind::Up => self.up(p, ev.mods),
|
|
PointerKind::DoubleClick => self.double_click(p, tol),
|
|
PointerKind::TripleClick => {
|
|
if self.active().is_some_and(|d| d.selection.text.is_some()) {
|
|
self.execute("text.selectParagraph", &json!({}))
|
|
} else {
|
|
Ok(Value::Null)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
fn begin(&mut self, label: &str) {
|
|
if let Some(st) = self.active_mut()
|
|
&& st.interaction.is_none()
|
|
{
|
|
st.interaction = Some(crate::Interaction { label: label.into(), doc: st.doc.clone(), selection: st.selection.clone() });
|
|
}
|
|
}
|
|
|
|
fn commit(&mut self) {
|
|
if let Some(st) = self.active_mut()
|
|
&& let Some(i) = st.interaction.take()
|
|
&& !Arc::ptr_eq(&i.doc, &st.doc)
|
|
{
|
|
crate::push_undo(st, HistoryEntry { label: i.label, doc: i.doc, selection: i.selection });
|
|
}
|
|
}
|
|
|
|
fn down(&mut self, p: Point, mods: Mods, tol: f64) -> Result<Value> {
|
|
if let Some(Drag::Freeform { mode, mut points, .. }) = self.tool.drag.clone() {
|
|
// Next click of a freeform: close on the start point, else add a vertex.
|
|
if points.len() > 2 && points.first().is_some_and(|s| (*s - p).hypot() <= tol * 2.0) {
|
|
self.tool.drag = Some(Drag::Freeform { mode, points, pressed: false });
|
|
return self.finish_freeform(true);
|
|
}
|
|
points.push(p);
|
|
self.tool.path_preview = points.clone();
|
|
self.tool.drag = Some(Drag::Freeform { mode, points, pressed: true });
|
|
return Ok(Value::Null);
|
|
}
|
|
match self.tool.kind.clone() {
|
|
ToolKind::Shape { preset } if is_freeform_tool(&preset) => {
|
|
self.tool.drag = Some(Drag::Freeform { mode: preset.clone(), points: vec![p], pressed: true });
|
|
self.tool.path_preview = vec![p];
|
|
self.tool.path_smooth = preset == "curve";
|
|
self.tool.path_closing = false;
|
|
return Ok(Value::Null);
|
|
}
|
|
ToolKind::Shape { .. } | ToolKind::TextBox => {
|
|
self.tool.drag = Some(Drag::Create { start: p });
|
|
self.tool.preview = Some(Xfrm::new(p.x, p.y, 0.0, 0.0));
|
|
return Ok(Value::Null);
|
|
}
|
|
ToolKind::Ink { mode, .. } => {
|
|
if mode == "eraser" {
|
|
return self.erase_at(p, tol);
|
|
}
|
|
self.tool.drag = Some(Drag::Ink { points: vec![(p.x, p.y, 0.5)] });
|
|
self.tool.ink_preview = vec![(p.x, p.y, 0.5)];
|
|
return Ok(Value::Null);
|
|
}
|
|
ToolKind::Select => {}
|
|
}
|
|
let st = self.doc()?;
|
|
// Clicking inside the text being edited moves the caret / starts a text selection.
|
|
if let Some(t) = st.selection.text.clone()
|
|
&& !t.notes
|
|
&& t.cell.is_none()
|
|
&& let Some(sh) = st.shape(t.shape)
|
|
{
|
|
let x = xfrm_of(&st.doc, &st.selection, sh);
|
|
if x.bounds().inflate(tol, tol).contains(p)
|
|
&& !matches!(self.hit_test(p, tol), Some(Hit::Resize { .. } | Hit::Rotate { .. } | Hit::Adjust { .. }))
|
|
{
|
|
let pos = text_pos(st, sh, p);
|
|
let shift = mods.shift;
|
|
self.select(|_, sel| {
|
|
if let Some(tt) = sel.text.as_mut() {
|
|
tt.caret = pos;
|
|
if !shift {
|
|
tt.anchor = pos;
|
|
}
|
|
}
|
|
})?;
|
|
self.tool.drag = Some(Drag::TextSelect { shape: t.shape });
|
|
return Ok(json!({"caret": [pos.0, pos.1]}));
|
|
}
|
|
}
|
|
let hit = self.hit_test(p, tol);
|
|
let st = self.doc()?;
|
|
match hit {
|
|
Some(Hit::Resize { id, handle }) => {
|
|
let orig = st.shape(id).map(|s| xfrm_of(&st.doc, &st.selection, s)).unwrap_or_default();
|
|
self.begin("Size");
|
|
self.tool.drag = Some(Drag::Resize { id, handle, orig, start: p });
|
|
}
|
|
Some(Hit::LineEnd { id, end }) => {
|
|
let orig = st.shape(id).map(|s| xfrm_of(&st.doc, &st.selection, s)).unwrap_or_default();
|
|
self.begin("Size");
|
|
self.tool.drag = Some(Drag::LineEnd { id, end, orig });
|
|
}
|
|
Some(Hit::Rotate { id }) => {
|
|
let ids: Vec<ShapeId> = if st.selection.shapes.contains(&id) { st.selection.shapes.clone() } else { vec![id] };
|
|
let origs: Vec<(ShapeId, Xfrm)> = ids.iter().filter_map(|i| st.shape(*i).map(|s| (*i, xfrm_of(&st.doc, &st.selection, s)))).collect();
|
|
let center = st.shape(id).map(|s| xfrm_of(&st.doc, &st.selection, s).center()).unwrap_or(p);
|
|
let start = (p.y - center.y).atan2(p.x - center.x);
|
|
self.begin("Rotate");
|
|
self.tool.drag = Some(Drag::Rotate { ids, center, start, origs });
|
|
}
|
|
Some(Hit::Adjust { id, index }) => {
|
|
let orig = st.shape(id).map(|s| xfrm_of(&st.doc, &st.selection, s)).unwrap_or_default();
|
|
self.begin("Adjust Shape");
|
|
self.tool.drag = Some(Drag::Adjust { id, index, orig });
|
|
}
|
|
Some(Hit::Shape { id, text }) => {
|
|
let selected = st.selection.shapes.contains(&id);
|
|
let toggle = mods.shift || mods.cmd;
|
|
let ids: Vec<ShapeId> = if toggle {
|
|
let mut v = st.selection.shapes.clone();
|
|
if selected {
|
|
v.retain(|x| *x != id);
|
|
} else {
|
|
v.push(id);
|
|
}
|
|
v
|
|
} else if selected {
|
|
st.selection.shapes.clone()
|
|
} else {
|
|
vec![id]
|
|
};
|
|
let origs: Vec<(ShapeId, Xfrm)> = ids.iter().filter_map(|i| st.shape(*i).map(|s| (*i, xfrm_of(&st.doc, &st.selection, s)))).collect();
|
|
// A click (no drag) on text puts the caret there; unselected text boxes and
|
|
// placeholders take the caret straight away like PowerPoint.
|
|
let enter_text = (text && !toggle && (selected || is_text_first(st, id))).then_some((id, p));
|
|
let ids2 = ids.clone();
|
|
self.select(|_, sel| {
|
|
sel.shapes = ids2;
|
|
sel.text = None;
|
|
sel.cells = None;
|
|
})?;
|
|
self.tool.drag = Some(Drag::Move { ids, start: p, origs, moved: false, duplicate: mods.alt, enter_text });
|
|
// Format Painter applies on click.
|
|
if self.painter.is_some() && !toggle {
|
|
let _ = self.execute("format.painterApply", &json!({"ids": [id]}));
|
|
}
|
|
}
|
|
None => {
|
|
let add = mods.shift || mods.cmd;
|
|
self.select(|_, sel| {
|
|
sel.text = None;
|
|
sel.cells = None;
|
|
if !add {
|
|
sel.shapes.clear();
|
|
}
|
|
})?;
|
|
self.tool.drag = Some(Drag::Marquee { start: p, add });
|
|
self.tool.marquee = Some(Rect::from_points(p, p));
|
|
}
|
|
}
|
|
Ok(json!({"hit": serde_json::to_value(&hit).unwrap_or_default()}))
|
|
}
|
|
|
|
fn drag(&mut self, p: Point, mods: Mods, tol: f64) -> Result<Value> {
|
|
let Some(drag) = self.tool.drag.clone() else { return Ok(Value::Null) };
|
|
match drag {
|
|
Drag::Move { ids, start, origs, moved, duplicate, enter_text } => {
|
|
let mut d = p - start;
|
|
if !moved && d.hypot() < tol.max(1.0) {
|
|
return Ok(Value::Null);
|
|
}
|
|
if !moved {
|
|
self.begin(if duplicate { "Duplicate" } else { "Move" });
|
|
if duplicate {
|
|
// Alt-drag: drag a copy, leave the original.
|
|
self.execute_dup_in_place(&ids)?;
|
|
}
|
|
}
|
|
let ids_now = if duplicate { self.doc()?.selection.shapes.clone() } else { ids.clone() };
|
|
if mods.shift {
|
|
if d.x.abs() > d.y.abs() {
|
|
d.y = 0.0;
|
|
} else {
|
|
d.x = 0.0;
|
|
}
|
|
}
|
|
// Smart guides on the moved group's bounds.
|
|
let bounds = origs.iter().map(|(_, x)| x.bounds()).reduce(|a, b| a.union(b)).unwrap_or_default();
|
|
let moved_b = bounds + d;
|
|
let (snap, guides) = self.snap_rect(moved_b, &ids_now, tol * 1.5, !mods.cmd);
|
|
d += snap;
|
|
self.tool.guides = guides;
|
|
let origs2: Vec<(ShapeId, Xfrm)> =
|
|
if duplicate { ids_now.iter().copied().zip(origs.iter().map(|o| o.1)).collect() } else { origs.clone() };
|
|
self.edit(|doc, sel| {
|
|
let list = crate::shapes_mut(doc, sel).ok_or_else(|| cmd::bad("move", "no slide"))?;
|
|
for (id, o) in &origs2 {
|
|
if let Some(sh) = deckcraft_model::find_shape_mut(list, *id) {
|
|
sh.xfrm = Some(Xfrm { x: o.x + d.x, y: o.y + d.y, ..*o });
|
|
}
|
|
}
|
|
// A connector dragged away from shapes that stay put comes unglued at those ends.
|
|
let moved: Vec<ShapeId> = origs2.iter().map(|o| o.0).collect();
|
|
for sh in list.iter_mut().filter(|s| moved.contains(&s.id)) {
|
|
if let ShapeKind::Connector { start, end } = &mut sh.kind {
|
|
for c in [start, end] {
|
|
if c.is_some_and(|(to, _)| !moved.contains(&to)) {
|
|
*c = None;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
Ok(())
|
|
})?;
|
|
self.tool.drag =
|
|
Some(Drag::Move { ids: ids_now, start, origs: origs2, moved: true, duplicate: false, enter_text: enter_text.filter(|_| false) });
|
|
}
|
|
Drag::Resize { id, handle, orig, start } => {
|
|
let nx = resize_box(&orig, handle, p - start, mods.shift || self.is_picture(id), mods.alt);
|
|
let nx = self.snap_resize(nx, &orig, handle, id, tol);
|
|
self.set_xfrm(id, nx)?;
|
|
}
|
|
Drag::LineEnd { id, end, orig } => {
|
|
let a = orig.affine();
|
|
let (p0, p1) = (a * Point::new(0.0, 0.0), a * Point::new(orig.w, orig.h));
|
|
let mut q = p;
|
|
let other = if end == 0 { p1 } else { p0 };
|
|
if mods.shift {
|
|
q = constrain_45(other, q);
|
|
}
|
|
let glue = self.update_sites(q, tol, Some(id));
|
|
if let Some((_, _, at)) = glue {
|
|
q = at;
|
|
}
|
|
let (s0, s1) = if end == 0 { (q, p1) } else { (p0, q) };
|
|
self.set_xfrm(id, line_xfrm(s0, s1, orig.rot))?;
|
|
self.edit(|doc, sel| {
|
|
if let Some(list) = crate::shapes_mut(doc, sel) {
|
|
crate::connect::set_glue(list, id, end, glue.map(|g| (g.0, g.1)));
|
|
}
|
|
Ok(())
|
|
})?;
|
|
}
|
|
Drag::Rotate { ids, center, start, origs } => {
|
|
let ang = (p.y - center.y).atan2(p.x - center.x);
|
|
let mut delta = (ang - start).to_degrees();
|
|
for (id, o) in &origs {
|
|
let mut r = o.rot + delta;
|
|
if mods.shift {
|
|
r = (r / 15.0).round() * 15.0;
|
|
delta = r - o.rot;
|
|
}
|
|
let mut nx = *o;
|
|
nx.rot = ((r % 360.0) + 360.0) % 360.0;
|
|
if ids.len() > 1 {
|
|
// Rotate positions around the common centre too.
|
|
let c = o.center();
|
|
let rc = Affine::translate(center.to_vec2()) * Affine::rotate(delta.to_radians()) * Affine::translate(-center.to_vec2()) * c;
|
|
nx.x = rc.x - o.w / 2.0;
|
|
nx.y = rc.y - o.h / 2.0;
|
|
}
|
|
self.set_xfrm(*id, nx)?;
|
|
}
|
|
}
|
|
Drag::Adjust { id, index, orig } => {
|
|
let st = self.doc()?;
|
|
let Some(sh) = st.shape(id).cloned() else { return Ok(Value::Null) };
|
|
let local = orig.affine().inverse() * p;
|
|
let geo = deckcraft_render::shape_geometry(&sh, orig.w, orig.h);
|
|
let Some(h) = geo.handles.iter().find(|h| h.index == index) else { return Ok(Value::Null) };
|
|
let free2d = matches!(sh.geom.preset_name(), Some(n) if n.contains("Callout") && !n.contains("Arrow"));
|
|
let vals: Vec<(usize, f64)> = if free2d {
|
|
let dx = (local.x - orig.w / 2.0) / orig.w.max(1e-6) * 1e5;
|
|
let dy = (local.y - orig.h / 2.0) / orig.h.max(1e-6) * 1e5;
|
|
vec![(0, dx.round()), (1, dy.round())]
|
|
} else {
|
|
vec![(index, h.value_at(local))]
|
|
};
|
|
self.edit(|doc, sel| {
|
|
let list = crate::shapes_mut(doc, sel).ok_or_else(|| cmd::bad("adjust", "no slide"))?;
|
|
if let Some(sh) = deckcraft_model::find_shape_mut(list, id)
|
|
&& let deckcraft_model::Geom::Preset { name, adj } = &mut sh.geom
|
|
{
|
|
let defaults = deckcraft_geom::preset::info(name).map(|x| x.defaults).unwrap_or(&[]);
|
|
for (i, v) in &vals {
|
|
while adj.len() <= *i {
|
|
let k = adj.len();
|
|
adj.push(defaults.get(k).copied().unwrap_or(0.0));
|
|
}
|
|
if let Some(slot) = adj.get_mut(*i) {
|
|
*slot = *v;
|
|
}
|
|
}
|
|
}
|
|
Ok(())
|
|
})?;
|
|
}
|
|
Drag::Marquee { start, .. } => {
|
|
self.tool.marquee = Some(Rect::from_points(start, p));
|
|
}
|
|
Drag::Create { start } => {
|
|
let line = matches!(&self.tool.kind, ToolKind::Shape { preset } if deckcraft_geom::preset::is_line_like(preset));
|
|
let x = if line {
|
|
let mut q = if mods.shift { constrain_45(start, p) } else { p };
|
|
if let Some((_, _, at)) = self.update_sites(q, tol, None) {
|
|
q = at;
|
|
}
|
|
let s0 = self.glue_at(start, tol).map(|g| g.2).unwrap_or(start);
|
|
line_xfrm(s0, q, 0.0)
|
|
} else {
|
|
let mut r = Rect::from_points(start, p);
|
|
if mods.shift {
|
|
let s = r.width().max(r.height());
|
|
let sx = if p.x < start.x { -s } else { s };
|
|
let sy = if p.y < start.y { -s } else { s };
|
|
r = Rect::from_points(start, start + Vec2::new(sx, sy));
|
|
}
|
|
if mods.alt {
|
|
let d = p - start;
|
|
r = Rect::new(start.x - d.x.abs(), start.y - d.y.abs(), start.x + d.x.abs(), start.y + d.y.abs());
|
|
}
|
|
Xfrm::new(r.x0, r.y0, r.width(), r.height())
|
|
};
|
|
self.tool.preview = Some(x);
|
|
}
|
|
Drag::TextSelect { shape } => {
|
|
let st = self.doc()?;
|
|
if let Some(sh) = st.shape(shape) {
|
|
let pos = text_pos(st, sh, p);
|
|
self.select(|_, sel| {
|
|
if let Some(t) = sel.text.as_mut() {
|
|
t.caret = pos;
|
|
}
|
|
})?;
|
|
}
|
|
}
|
|
Drag::Freeform { mode, mut points, pressed } => {
|
|
// Dragging scribbles (except for curves, which only take clicked points).
|
|
if pressed && mode != "curve" && points.last().is_none_or(|l| (*l - p).hypot() >= 1.5) {
|
|
points.push(p);
|
|
}
|
|
let mut v = points.clone();
|
|
if mode == "curve" {
|
|
v.push(p);
|
|
}
|
|
self.tool.path_preview = v;
|
|
self.tool.drag = Some(Drag::Freeform { mode, points, pressed });
|
|
}
|
|
Drag::Ink { mut points } => {
|
|
points.push((p.x, p.y, 0.5));
|
|
self.tool.ink_preview = points.clone();
|
|
self.tool.drag = Some(Drag::Ink { points });
|
|
}
|
|
}
|
|
Ok(Value::Null)
|
|
}
|
|
|
|
fn up(&mut self, p: Point, mods: Mods) -> Result<Value> {
|
|
let drag = self.tool.drag.take();
|
|
self.tool.guides.clear();
|
|
self.tool.sites.clear();
|
|
self.tool.glue = None;
|
|
let mut out = Value::Null;
|
|
match drag {
|
|
Some(Drag::Move { moved: false, enter_text: Some((id, at)), .. }) => {
|
|
// Plain click on text: caret at the click.
|
|
let st = self.doc()?;
|
|
if let Some(sh) = st.shape(id) {
|
|
let pos = text_pos(st, sh, at);
|
|
self.execute("text.edit", &json!({"id": id, "at": [pos.0, pos.1]}))?;
|
|
out = json!({"editing": id});
|
|
}
|
|
}
|
|
Some(Drag::Marquee { start, add }) => {
|
|
let r = Rect::from_points(start, p);
|
|
self.tool.marquee = None;
|
|
if r.width() > 1.0 || r.height() > 1.0 {
|
|
let st = self.doc()?;
|
|
let ids: Vec<ShapeId> = st
|
|
.shapes()
|
|
.iter()
|
|
.filter(|s| !s.hidden && !s.locked)
|
|
.filter(|s| contains_rect(r, xfrm_of(&st.doc, &st.selection, s).bounds()))
|
|
.map(|s| s.id)
|
|
.collect();
|
|
self.select(|_, sel| {
|
|
if add {
|
|
for i in &ids {
|
|
if !sel.shapes.contains(i) {
|
|
sel.shapes.push(*i);
|
|
}
|
|
}
|
|
} else {
|
|
sel.shapes = ids.clone();
|
|
}
|
|
})?;
|
|
out = json!({"selected": ids});
|
|
}
|
|
}
|
|
Some(Drag::Create { start }) => {
|
|
let kind = self.tool.kind.clone();
|
|
let preview = self.tool.preview.take();
|
|
let dragged = (p - start).hypot() > 2.0;
|
|
let res = match &kind {
|
|
ToolKind::Shape { preset } => {
|
|
let line = deckcraft_geom::preset::is_line_like(preset);
|
|
let rect = match preview.filter(|_| dragged) {
|
|
Some(x) => x,
|
|
None if line => Xfrm::new(start.x, start.y, 72.0, 0.0),
|
|
None => Xfrm::new(start.x, start.y, 72.0, 72.0),
|
|
};
|
|
let r = self.execute("shape.insert", &json!({"preset": preset, "rect": [rect.x, rect.y, rect.w, rect.h], "flip": format!("{}{}", if rect.flip_h { "h" } else { "" }, if rect.flip_v { "v" } else { "" })}))?;
|
|
if let (Some(id), Some(x)) = (r.get("id").and_then(Value::as_u64), preview.filter(|_| dragged && line)) {
|
|
let id = ShapeId(id as u32);
|
|
let _ = self.set_xfrm_cmd(id, x);
|
|
// Glue the ends that landed on connection sites.
|
|
let (p0, p1) = crate::connect::endpoints(&x);
|
|
let (g0, g1) = (self.glue_at(p0, 4.0), self.glue_at(p1, 4.0));
|
|
if g0.is_some() || g1.is_some() {
|
|
let _ = self.edit(|doc, sel| {
|
|
if let Some(list) = crate::shapes_mut(doc, sel) {
|
|
crate::connect::set_glue(list, id, 0, g0.map(|g| (g.0, g.1)));
|
|
crate::connect::set_glue(list, id, 1, g1.map(|g| (g.0, g.1)));
|
|
}
|
|
Ok(())
|
|
});
|
|
}
|
|
}
|
|
r
|
|
}
|
|
ToolKind::TextBox => {
|
|
let w = preview.filter(|_| dragged).map(|x| x.w).unwrap_or(0.0);
|
|
let at = preview.filter(|_| dragged).map(|x| Point::new(x.x, x.y)).unwrap_or(start);
|
|
self.execute("insert.textBox", &json!({"rect": [at.x, at.y, w, 30.0]}))?
|
|
}
|
|
_ => Value::Null,
|
|
};
|
|
if !self.tool.sticky {
|
|
self.tool.kind = ToolKind::Select;
|
|
}
|
|
out = res;
|
|
}
|
|
Some(Drag::Freeform { mode, points, .. }) => {
|
|
if mode == "scribble" {
|
|
// A scribble ending back at its start closes into a filled shape.
|
|
let closed = points.len() > 3 && points.first().zip(points.last()).is_some_and(|(a, b)| (*a - *b).hypot() <= 6.0);
|
|
self.tool.drag = Some(Drag::Freeform { mode, points, pressed: false });
|
|
return self.finish_freeform(closed);
|
|
}
|
|
// Polygon / curve: keep going until double-click, Enter or a click on the start.
|
|
self.tool.drag = Some(Drag::Freeform { mode, points, pressed: false });
|
|
return Ok(Value::Null);
|
|
}
|
|
Some(Drag::Ink { points }) => {
|
|
self.tool.ink_preview.clear();
|
|
if points.len() > 1 {
|
|
self.begin("Ink");
|
|
out = self.add_ink(points)?;
|
|
}
|
|
}
|
|
Some(Drag::Resize { id, .. }) => {
|
|
self.commit();
|
|
let _ = cmd::text::fit_text_box(self, id);
|
|
self.refit_after_resize(id)?;
|
|
}
|
|
Some(_) => self.commit(),
|
|
None => {}
|
|
}
|
|
// Any open interaction (move etc.) ends here.
|
|
self.commit();
|
|
let _ = mods;
|
|
Ok(out)
|
|
}
|
|
|
|
fn double_click(&mut self, p: Point, tol: f64) -> Result<Value> {
|
|
if let Some(Drag::Freeform { mode, mut points, .. }) = self.tool.drag.clone() {
|
|
// The double-click's own click added a duplicate vertex.
|
|
while points.len() > 2 && points.last().zip(points.get(points.len() - 2)).is_some_and(|(a, b)| (*a - *b).hypot() <= tol) {
|
|
points.pop();
|
|
}
|
|
self.tool.drag = Some(Drag::Freeform { mode, points, pressed: false });
|
|
return self.finish_freeform(false);
|
|
}
|
|
if !matches!(self.tool.kind, ToolKind::Select) {
|
|
// Double-click while drawing: lock drawing mode off.
|
|
self.tool.sticky = false;
|
|
return Ok(Value::Null);
|
|
}
|
|
let st = self.doc()?;
|
|
if st.selection.text.is_some() {
|
|
return self.execute("text.selectWord", &json!({}));
|
|
}
|
|
match self.hit_test(p, tol) {
|
|
Some(Hit::Shape { id, .. }) => {
|
|
let st = self.doc()?;
|
|
let Some(sh) = st.shape(id).cloned() else { return Ok(Value::Null) };
|
|
match &sh.kind {
|
|
ShapeKind::Table(_) => {
|
|
let x = xfrm_of(&st.doc, &st.selection, &sh);
|
|
let local = x.affine().inverse() * p;
|
|
if let ShapeKind::Table(t) = &sh.kind {
|
|
let cell = deckcraft_render_cell(t, &table_rows(st, t), local);
|
|
return self.execute("text.edit", &json!({"id": id, "cell": [cell.0, cell.1]}));
|
|
}
|
|
Ok(Value::Null)
|
|
}
|
|
ShapeKind::Shape | ShapeKind::Connector { .. } if !sh.is_line() => {
|
|
let pos = text_pos(st, &sh, p);
|
|
self.execute("text.edit", &json!({"id": id, "at": [pos.0, pos.1]}))?;
|
|
self.execute("text.selectWord", &json!({}))
|
|
}
|
|
ShapeKind::Group { .. } => Ok(Value::Null),
|
|
_ => {
|
|
self.ui_requests.push(crate::UiRequest::Pane { id: "format".into() });
|
|
Ok(Value::Null)
|
|
}
|
|
}
|
|
}
|
|
_ => Ok(Value::Null),
|
|
}
|
|
}
|
|
|
|
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(_)))
|
|
}
|
|
|
|
/// End the freeform being drawn: create the shape (if it has at least two points).
|
|
pub fn finish_freeform(&mut self, closed: bool) -> Result<Value> {
|
|
let Some(Drag::Freeform { mode, points, .. }) = self.tool.drag.take() else { return Ok(Value::Null) };
|
|
self.tool.path_preview.clear();
|
|
self.tool.path_closing = false;
|
|
if !self.tool.sticky {
|
|
self.tool.kind = ToolKind::Select;
|
|
}
|
|
if points.len() < 2 {
|
|
return Ok(Value::Null);
|
|
}
|
|
let pts: Vec<[f64; 2]> = points.iter().map(|q| [q.x, q.y]).collect();
|
|
self.execute("shape.freeform", &json!({"points": pts, "closed": closed, "smooth": mode == "curve"}))
|
|
}
|
|
|
|
/// The connection site within glue range of `p` (excluding shape `exclude`).
|
|
fn glue_at(&self, p: Point, tol: f64) -> Option<(ShapeId, u32, Point)> {
|
|
let st = self.active()?;
|
|
crate::connect::nearest_site(&st.doc, &st.selection, st.shapes(), p, (tol * 2.0).max(6.0), None)
|
|
}
|
|
|
|
/// Show the sites of the shape near `p` and return the site an end at `p` would glue to.
|
|
fn update_sites(&mut self, p: Point, tol: f64, exclude: Option<ShapeId>) -> Option<(ShapeId, u32, Point)> {
|
|
let (sites, glue) = match self.active() {
|
|
Some(st) => {
|
|
let shapes = st.shapes();
|
|
let near = crate::connect::site_shape_at(&st.doc, &st.selection, shapes, p, tol * 3.0, exclude);
|
|
let sites = near
|
|
.and_then(|id| shapes.iter().find(|s| s.id == id))
|
|
.map(|s| crate::connect::sites(&st.doc, &st.selection, s))
|
|
.unwrap_or_default();
|
|
(sites, crate::connect::nearest_site(&st.doc, &st.selection, shapes, p, (tol * 2.0).max(6.0), exclude))
|
|
}
|
|
None => (vec![], None),
|
|
};
|
|
self.tool.sites = sites;
|
|
self.tool.glue = glue.map(|g| g.2);
|
|
glue
|
|
}
|
|
|
|
fn set_xfrm(&mut self, id: ShapeId, x: Xfrm) -> Result<()> {
|
|
// Rows scale from their drawn heights: a stored height is only a minimum (often 0).
|
|
let drawn = self.active().and_then(|st| match &st.shape(id)?.kind {
|
|
ShapeKind::Table(t) => Some(table_rows(st, t)),
|
|
_ => None,
|
|
});
|
|
self.edit(|doc, sel| {
|
|
let list = crate::shapes_mut(doc, sel).ok_or_else(|| cmd::bad("tool", "no slide"))?;
|
|
if let Some(sh) = deckcraft_model::find_shape_mut(list, id) {
|
|
sh.xfrm = Some(x);
|
|
if let ShapeKind::Table(t) = &mut sh.kind {
|
|
// Tables resize their columns and rows proportionally.
|
|
let heights: Vec<f64> = match &drawn {
|
|
Some(d) if d.len() == t.rows.len() => d.clone(),
|
|
_ => t.rows.iter().map(|r| r.height).collect(),
|
|
};
|
|
let (tw, th) = (t.width().max(1e-6), heights.iter().map(|h| h.max(0.0)).sum::<f64>());
|
|
let kx = x.w / tw;
|
|
t.cols.iter_mut().for_each(|c| *c *= kx);
|
|
if th > 1e-6 {
|
|
let ky = x.h / th;
|
|
t.rows.iter_mut().zip(&heights).for_each(|(r, h)| r.height = h.max(0.0) * ky);
|
|
}
|
|
}
|
|
}
|
|
Ok(())
|
|
})
|
|
}
|
|
|
|
fn set_xfrm_cmd(&mut self, id: ShapeId, x: Xfrm) -> Result<Value> {
|
|
self.execute("shape.setBounds", &json!({"id": id, "x": x.x, "y": x.y, "w": x.w, "h": x.h}))?;
|
|
if x.flip_h || x.flip_v {
|
|
self.edit(|doc, sel| {
|
|
if let Some(sh) = crate::shapes_mut(doc, sel).and_then(|l| deckcraft_model::find_shape_mut(l, id))
|
|
&& let Some(xx) = sh.xfrm.as_mut()
|
|
{
|
|
xx.flip_h = x.flip_h;
|
|
xx.flip_v = x.flip_v;
|
|
}
|
|
Ok(())
|
|
})?;
|
|
}
|
|
Ok(Value::Null)
|
|
}
|
|
|
|
fn refit_after_resize(&mut self, id: ShapeId) -> Result<()> {
|
|
// A text box resized by hand keeps wrapping at its new width.
|
|
let st = self.doc()?;
|
|
if st.shape(id).is_some_and(|s| s.text_box) {
|
|
let _ = cmd::text::fit_text_box(self, id);
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn execute_dup_in_place(&mut self, ids: &[ShapeId]) -> Result<()> {
|
|
self.edit(|doc, sel| {
|
|
let list = cmd::current_shapes(doc, sel).cloned().unwrap_or_default();
|
|
let mut copies: Vec<Shape> = list.iter().filter(|x| ids.contains(&x.id)).cloned().collect();
|
|
let boxes: Vec<Xfrm> = copies.iter().map(|c| xfrm_of(doc, sel, c)).collect();
|
|
cmd::edit::reid(doc, &mut copies);
|
|
for (c, b) in copies.iter_mut().zip(boxes) {
|
|
c.xfrm = Some(b);
|
|
c.ph = None;
|
|
}
|
|
let new_ids: Vec<ShapeId> = copies.iter().map(|c| c.id).collect();
|
|
let shapes = crate::shapes_mut(doc, sel).ok_or_else(|| cmd::bad("duplicate", "no slide"))?;
|
|
shapes.extend(copies);
|
|
sel.shapes = new_ids;
|
|
Ok(())
|
|
})
|
|
}
|
|
|
|
/// Smart guides: snap `r` edges/centres to the slide and other shapes. Returns the offset to
|
|
/// apply and the guides to draw.
|
|
fn snap_rect(&self, r: Rect, exclude: &[ShapeId], tol: f64, enabled: bool) -> (Vec2, Vec<Guide>) {
|
|
let Some(st) = self.active() else { return (Vec2::ZERO, vec![]) };
|
|
if !enabled || !self.prefs.smart_guides {
|
|
return (Vec2::ZERO, vec![]);
|
|
}
|
|
let size = st.doc.slide_size;
|
|
let mut xs: Vec<(f64, Rect)> = vec![
|
|
(0.0, Rect::new(0.0, 0.0, 0.0, size.height)),
|
|
(size.width / 2.0, Rect::new(size.width / 2.0, 0.0, size.width / 2.0, size.height)),
|
|
(size.width, Rect::new(size.width, 0.0, size.width, size.height)),
|
|
];
|
|
let mut ys: Vec<(f64, Rect)> = vec![
|
|
(0.0, Rect::new(0.0, 0.0, size.width, 0.0)),
|
|
(size.height / 2.0, Rect::new(0.0, size.height / 2.0, size.width, size.height / 2.0)),
|
|
(size.height, Rect::new(0.0, size.height, size.width, size.height)),
|
|
];
|
|
for sh in st.shapes() {
|
|
if exclude.contains(&sh.id) || sh.hidden {
|
|
continue;
|
|
}
|
|
let b = xfrm_of(&st.doc, &st.selection, sh).bounds();
|
|
for x in [b.x0, b.center().x, b.x1] {
|
|
xs.push((x, b));
|
|
}
|
|
for y in [b.y0, b.center().y, b.y1] {
|
|
ys.push((y, b));
|
|
}
|
|
}
|
|
let mut best_x: Option<(f64, f64, Rect)> = None;
|
|
for edge in [r.x0, r.center().x, r.x1] {
|
|
for (x, b) in &xs {
|
|
let d = x - edge;
|
|
if d.abs() <= tol && best_x.is_none_or(|(bd, _, _)| d.abs() < bd.abs()) {
|
|
best_x = Some((d, *x, *b));
|
|
}
|
|
}
|
|
}
|
|
let mut best_y: Option<(f64, f64, Rect)> = None;
|
|
for edge in [r.y0, r.center().y, r.y1] {
|
|
for (y, b) in &ys {
|
|
let d = y - edge;
|
|
if d.abs() <= tol && best_y.is_none_or(|(bd, _, _)| d.abs() < bd.abs()) {
|
|
best_y = Some((d, *y, *b));
|
|
}
|
|
}
|
|
}
|
|
let mut off = Vec2::ZERO;
|
|
let mut guides = vec![];
|
|
if let Some((d, x, b)) = best_x {
|
|
off.x = d;
|
|
let rr = r + Vec2::new(d, 0.0);
|
|
guides.push(Guide { vertical: true, pos: x, from: b.y0.min(rr.y0), to: b.y1.max(rr.y1) });
|
|
} else if self.prefs.snap_to_grid {
|
|
off.x = deckcraft_geom::snap(r.x0, self.prefs.grid_spacing) - r.x0;
|
|
}
|
|
if let Some((d, y, b)) = best_y {
|
|
off.y = d;
|
|
let rr = r + Vec2::new(0.0, d);
|
|
guides.push(Guide { vertical: false, pos: y, from: b.x0.min(rr.x0), to: b.x1.max(rr.x1) });
|
|
} else if self.prefs.snap_to_grid {
|
|
off.y = deckcraft_geom::snap(r.y0, self.prefs.grid_spacing) - r.y0;
|
|
}
|
|
(off, guides)
|
|
}
|
|
|
|
fn snap_resize(&mut self, x: Xfrm, _orig: &Xfrm, handle: u8, id: ShapeId, tol: f64) -> Xfrm {
|
|
if x.rot != 0.0 {
|
|
self.tool.guides.clear();
|
|
return x;
|
|
}
|
|
let r = x.rect();
|
|
let (off, guides) = self.snap_rect(r, &[id], tol * 1.5, true);
|
|
let mut nx = x;
|
|
// Only the dragged edges move.
|
|
let moves_left = matches!(handle, 0 | 6 | 7);
|
|
let moves_right = matches!(handle, 2..=4);
|
|
let moves_top = matches!(handle, 0..=2);
|
|
let moves_bottom = matches!(handle, 4..=6);
|
|
if moves_left {
|
|
nx.x += off.x;
|
|
nx.w -= off.x;
|
|
} else if moves_right {
|
|
nx.w += off.x;
|
|
}
|
|
if moves_top {
|
|
nx.y += off.y;
|
|
nx.h -= off.y;
|
|
} else if moves_bottom {
|
|
nx.h += off.y;
|
|
}
|
|
nx.w = nx.w.max(0.0);
|
|
nx.h = nx.h.max(0.0);
|
|
self.tool.guides = guides;
|
|
nx
|
|
}
|
|
|
|
fn add_ink(&mut self, points: Vec<(f64, f64, f32)>) -> Result<Value> {
|
|
let ToolKind::Ink { mode, color, width } = self.tool.kind.clone() else { return Ok(Value::Null) };
|
|
let stroke = deckcraft_model::InkStroke { points, color, width, highlighter: mode == "highlighter" };
|
|
// Strokes join the slide's ink layer (one ink shape per slide).
|
|
let existing = self.doc()?.shapes().iter().rev().find(|s| matches!(s.kind, ShapeKind::Ink { .. })).map(|s| s.id);
|
|
self.edit(|doc, sel| {
|
|
let id = match existing {
|
|
Some(id) => id,
|
|
None => {
|
|
let id = ShapeId(doc.alloc_id());
|
|
let size = doc.slide_size;
|
|
let list = crate::shapes_mut(doc, sel).ok_or_else(|| cmd::bad("ink", "no slide"))?;
|
|
list.push(Shape {
|
|
id,
|
|
name: "Ink".into(),
|
|
xfrm: Some(Xfrm::new(0.0, 0.0, size.width, size.height)),
|
|
kind: ShapeKind::Ink { strokes: vec![] },
|
|
..Default::default()
|
|
});
|
|
id
|
|
}
|
|
};
|
|
let list = crate::shapes_mut(doc, sel).ok_or_else(|| cmd::bad("ink", "no slide"))?;
|
|
if let Some(sh) = deckcraft_model::find_shape_mut(list, id)
|
|
&& let ShapeKind::Ink { strokes } = &mut sh.kind
|
|
{
|
|
strokes.push(stroke);
|
|
}
|
|
Ok(json!({"ink": id}))
|
|
})
|
|
}
|
|
|
|
fn erase_at(&mut self, p: Point, tol: f64) -> Result<Value> {
|
|
let st = self.doc()?;
|
|
let Some(ink) = st.shapes().iter().rev().find(|s| matches!(s.kind, ShapeKind::Ink { .. })).map(|s| s.id) else { return Ok(Value::Null) };
|
|
let before = st.doc.clone();
|
|
let sel_before = st.selection.clone();
|
|
self.edit(|doc, sel| {
|
|
let list = crate::shapes_mut(doc, sel).ok_or_else(|| cmd::bad("ink", "no slide"))?;
|
|
if let Some(sh) = deckcraft_model::find_shape_mut(list, ink)
|
|
&& let ShapeKind::Ink { strokes } = &mut sh.kind
|
|
{
|
|
strokes.retain(|s| !s.points.iter().any(|(x, y, _)| (Point::new(*x, *y) - p).hypot() <= tol * 2.0 + s.width));
|
|
}
|
|
Ok(())
|
|
})?;
|
|
if let Some(st) = self.active_mut() {
|
|
crate::push_undo(st, HistoryEntry { label: "Erase".into(), doc: before, selection: sel_before });
|
|
}
|
|
Ok(Value::Null)
|
|
}
|
|
}
|
|
|
|
fn is_text_first(st: &crate::DocState, id: ShapeId) -> bool {
|
|
st.shape(id).is_some_and(|s| s.ph.is_some() || s.text_box)
|
|
}
|
|
|
|
fn contains_rect(outer: Rect, inner: Rect) -> bool {
|
|
inner.x0 >= outer.x0 && inner.y0 >= outer.y0 && inner.x1 <= outer.x1 && inner.y1 <= outer.y1
|
|
}
|
|
|
|
fn constrain_45(a: Point, b: Point) -> Point {
|
|
let d = b - a;
|
|
let ang = d.y.atan2(d.x);
|
|
let snapped = (ang / std::f64::consts::FRAC_PI_4).round() * std::f64::consts::FRAC_PI_4;
|
|
let len = d.hypot();
|
|
a + Vec2::new(snapped.cos(), snapped.sin()) * len
|
|
}
|
|
|
|
/// A line-like shape's box for endpoints `a` → `b` (flips encode direction).
|
|
pub fn line_xfrm(a: Point, b: Point, _rot: f64) -> Xfrm {
|
|
let r = Rect::from_points(a, b);
|
|
Xfrm { x: r.x0, y: r.y0, w: r.width(), h: r.height(), rot: 0.0, flip_h: b.x < a.x, flip_v: b.y < a.y }
|
|
}
|
|
|
|
/// New box when dragging resize handle `h` by `d` (slide space), keeping the shape's rotation.
|
|
pub fn resize_box(o: &Xfrm, h: u8, d: Vec2, keep_aspect: bool, from_center: bool) -> Xfrm {
|
|
// Work in the shape's rotated frame.
|
|
let rot = Affine::rotate(-o.rot.to_radians());
|
|
let ld = rot * Point::new(d.x, d.y);
|
|
let (mut l, mut t, mut r, mut b) = (0.0, 0.0, o.w, o.h);
|
|
let (sx, sy) = (if o.flip_h { -1.0 } else { 1.0 }, if o.flip_v { -1.0 } else { 1.0 });
|
|
let (dx, dy) = (ld.x * sx, ld.y * sy);
|
|
match h {
|
|
0 => {
|
|
l += dx;
|
|
t += dy;
|
|
}
|
|
1 => t += dy,
|
|
2 => {
|
|
r += dx;
|
|
t += dy;
|
|
}
|
|
3 => r += dx,
|
|
4 => {
|
|
r += dx;
|
|
b += dy;
|
|
}
|
|
5 => b += dy,
|
|
6 => {
|
|
l += dx;
|
|
b += dy;
|
|
}
|
|
_ => l += dx,
|
|
}
|
|
if from_center {
|
|
// Symmetric growth about the centre.
|
|
let gw = match h {
|
|
0 | 6 | 7 => -dx,
|
|
2..=4 => dx,
|
|
_ => 0.0,
|
|
};
|
|
let gh = match h {
|
|
0..=2 => -dy,
|
|
4..=6 => dy,
|
|
_ => 0.0,
|
|
};
|
|
l = -gw;
|
|
r = o.w + gw;
|
|
t = -gh;
|
|
b = o.h + gh;
|
|
}
|
|
let mut w = r - l;
|
|
let mut hh = b - t;
|
|
if keep_aspect && o.w > 1e-9 && o.h > 1e-9 && !matches!(h, 1 | 3 | 5 | 7) {
|
|
let ar = o.w / o.h;
|
|
if (w / ar).abs() > hh.abs() {
|
|
hh = w.abs() / ar * hh.signum().max(if hh == 0.0 { 1.0 } else { hh.signum() });
|
|
} else {
|
|
w = hh.abs() * ar * w.signum().max(if w == 0.0 { 1.0 } else { w.signum() });
|
|
}
|
|
if matches!(h, 0 | 6) {
|
|
l = r - w;
|
|
} else {
|
|
r = l + w;
|
|
}
|
|
if matches!(h, 0 | 2) {
|
|
t = b - hh;
|
|
} else {
|
|
b = t + hh;
|
|
}
|
|
if from_center {
|
|
let (cx, cy) = (o.w / 2.0, o.h / 2.0);
|
|
l = cx - w / 2.0;
|
|
r = cx + w / 2.0;
|
|
t = cy - hh / 2.0;
|
|
b = cy + hh / 2.0;
|
|
}
|
|
}
|
|
// Dragging past the opposite edge flips.
|
|
let (mut fh, mut fv) = (o.flip_h, o.flip_v);
|
|
if r < l {
|
|
std::mem::swap(&mut l, &mut r);
|
|
fh = !fh;
|
|
}
|
|
if b < t {
|
|
std::mem::swap(&mut t, &mut b);
|
|
fv = !fv;
|
|
}
|
|
let (nw, nh) = (r - l, b - t);
|
|
// New centre in slide space: old centre + rotated offset of the local centre change.
|
|
let local_c = Point::new((l + r) / 2.0 * sx + if sx < 0.0 { o.w } else { 0.0 }, (t + b) / 2.0 * sy + if sy < 0.0 { o.h } else { 0.0 });
|
|
let c = o.affine() * Point::new(if o.flip_h { o.w - local_c.x } else { local_c.x }, if o.flip_v { o.h - local_c.y } else { local_c.y });
|
|
Xfrm { x: c.x - nw / 2.0, y: c.y - nh / 2.0, w: nw, h: nh, rot: o.rot, flip_h: fh, flip_v: fv }
|
|
}
|
|
|
|
fn hit_shape(st: &crate::DocState, sh: &Shape, p: Point, tol: f64) -> Option<Hit> {
|
|
let x = xfrm_of(&st.doc, &st.selection, sh);
|
|
let inv = x.affine().inverse();
|
|
let local = inv * p;
|
|
match &sh.kind {
|
|
ShapeKind::Group { children, child } => {
|
|
// A group is hit when any child is hit; the group is what gets selected.
|
|
let ch = Rect::new(child.x, child.y, child.x + child.w, child.y + child.h);
|
|
let m = deckcraft_geom::group_child_affine(&x, ch);
|
|
let q = m.inverse() * p;
|
|
for c in children.iter().rev() {
|
|
if let Some(cx) = c.xfrm {
|
|
let cl = cx.affine().inverse() * q;
|
|
if cl.x >= -tol && cl.y >= -tol && cl.x <= cx.w + tol && cl.y <= cx.h + tol {
|
|
return Some(Hit::Shape { id: sh.id, text: false });
|
|
}
|
|
}
|
|
}
|
|
None
|
|
}
|
|
ShapeKind::Ink { strokes } => {
|
|
for s in strokes {
|
|
if s.points.iter().any(|(px, py, _)| (Point::new(*px, *py) - p).hypot() <= tol + s.width) {
|
|
return Some(Hit::Shape { id: sh.id, text: false });
|
|
}
|
|
}
|
|
None
|
|
}
|
|
_ => {
|
|
if sh.is_line() {
|
|
let geo = deckcraft_render::shape_geometry(sh, x.w, x.h);
|
|
let lw = sh.line.as_ref().and_then(|l| l.width).unwrap_or(1.0);
|
|
if deckcraft_geom::near_path(&geo.outline(), local, tol.max(lw)) {
|
|
return Some(Hit::Shape { id: sh.id, text: false });
|
|
}
|
|
return None;
|
|
}
|
|
let inside = local.x >= -tol && local.y >= -tol && local.x <= x.w + tol && local.y <= x.h + tol;
|
|
if !inside {
|
|
return None;
|
|
}
|
|
let has_text = matches!(sh.kind, ShapeKind::Shape) && (sh.text.as_ref().is_some_and(|t| !t.is_empty()) || sh.ph.is_some() || sh.text_box);
|
|
// Empty interiors of unfilled, text-less shapes don't catch clicks.
|
|
let filled = !matches!(sh.fill, Some(deckcraft_model::Fill::None)) || sh.style.is_some();
|
|
let geo = deckcraft_render::shape_geometry(sh, x.w, x.h);
|
|
let in_geo = {
|
|
use deckcraft_geom::KurboShape;
|
|
geo.outline().winding(local) != 0 || deckcraft_geom::near_path(&geo.outline(), local, tol)
|
|
};
|
|
if has_text
|
|
|| sh.ph.is_some()
|
|
|| matches!(
|
|
sh.kind,
|
|
ShapeKind::Picture { .. } | ShapeKind::Table(_) | ShapeKind::Chart(_) | ShapeKind::Media(_) | ShapeKind::Opaque { .. }
|
|
)
|
|
|| (filled && in_geo)
|
|
|| deckcraft_geom::near_path(&geo.outline(), local, tol)
|
|
{
|
|
return Some(Hit::Shape { id: sh.id, text: has_text || matches!(sh.kind, ShapeKind::Shape) });
|
|
}
|
|
None
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Text position under slide point `p` in shape `sh`.
|
|
pub fn text_pos(st: &crate::DocState, sh: &Shape, p: Point) -> (usize, usize) {
|
|
let x = xfrm_of(&st.doc, &st.selection, sh);
|
|
let t = TextSel { shape: sh.id, ..Default::default() };
|
|
match cmd::text::layout_for(st, &t) {
|
|
Some(l) => {
|
|
// The text's own transform: flips don't mirror text, vertical text is turned.
|
|
let tr = deckcraft_render::shape_geometry(sh, x.w, x.h).text_rect;
|
|
let local = deckcraft_render::text_transform(x.affine(), tr, l.rotation).inverse() * p;
|
|
let h = l.hit(local);
|
|
(h.para, h.ch)
|
|
}
|
|
None => (0, 0),
|
|
}
|
|
}
|
|
|
|
/// Row heights of table `t` as drawn: rows grow to fit their text.
|
|
pub fn table_rows(st: &crate::DocState, t: &deckcraft_model::Table) -> Vec<f64> {
|
|
cmd::table_drawn_heights(&st.doc, &st.selection, t)
|
|
}
|
|
|
|
/// Which table cell contains a table-local point (`heights` from [`table_rows`]).
|
|
fn deckcraft_render_cell(t: &deckcraft_model::Table, heights: &[f64], p: Point) -> (usize, usize) {
|
|
let mut y = 0.0;
|
|
let mut row = heights.len().saturating_sub(1);
|
|
for (i, h) in heights.iter().enumerate() {
|
|
if p.y < y + h {
|
|
row = i;
|
|
break;
|
|
}
|
|
y += h;
|
|
}
|
|
let mut x = 0.0;
|
|
let mut col = t.cols.len().saturating_sub(1);
|
|
for (i, w) in t.cols.iter().enumerate() {
|
|
if p.x < x + w {
|
|
col = i;
|
|
break;
|
|
}
|
|
x += w;
|
|
}
|
|
(row, col)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn resize_corner_and_flip() {
|
|
let o = Xfrm::new(100.0, 100.0, 100.0, 50.0);
|
|
let n = resize_box(&o, 4, Vec2::new(20.0, 10.0), false, false);
|
|
assert!((n.x - 100.0).abs() < 1e-9 && (n.w - 120.0).abs() < 1e-9 && (n.h - 60.0).abs() < 1e-9);
|
|
let n = resize_box(&o, 0, Vec2::new(10.0, 10.0), false, false);
|
|
assert!((n.x - 110.0).abs() < 1e-9 && (n.w - 90.0).abs() < 1e-9);
|
|
let n = resize_box(&o, 3, Vec2::new(-150.0, 0.0), false, false);
|
|
assert!(n.flip_h && (n.w - 50.0).abs() < 1e-9 && (n.x - 50.0).abs() < 1e-9);
|
|
let n = resize_box(&o, 4, Vec2::new(100.0, 0.0), true, false);
|
|
assert!((n.w / n.h - 2.0).abs() < 1e-9);
|
|
let n = resize_box(&o, 3, Vec2::new(10.0, 0.0), false, true);
|
|
assert!((n.w - 120.0).abs() < 1e-9 && (n.x - 90.0).abs() < 1e-9);
|
|
}
|
|
|
|
#[test]
|
|
fn resize_rotated_keeps_opposite_corner() {
|
|
let o = Xfrm { x: 100.0, y: 100.0, w: 100.0, h: 50.0, rot: 90.0, ..Default::default() };
|
|
let anchor = o.affine() * Point::new(0.0, 0.0);
|
|
let n = resize_box(&o, 4, Vec2::new(-10.0, 30.0), false, false);
|
|
let anchor2 = n.affine() * Point::new(0.0, 0.0);
|
|
assert!((anchor - anchor2).hypot() < 1e-6, "{anchor:?} {anchor2:?}");
|
|
}
|
|
}
|