mirror of
https://github.com/storytold/deckcraft.git
synced 2026-10-31 05:56:55 -06:00
UPDATE_APP (native builds; craft-update is a native-only dependency) carries our 128 px icon and accent (#f07a1e, its colour in ArtCraft Launcher). The updater, the self-update helper and craft-update's startup prompt all get them through crate::UPDATE_APP; the prompt draws the icon, the accent and "by ArtCraft" (craft-update's default developer). craft-update is pinned to storytold/craft-libs main at 1065653e7 (#434). Clippy fixes for Rust 1.99 in untouched code (same logic): - crates/engine/src/tools/click.rs - crates/engine/src/tools/pointer.rs - crates/metafile/src/emfplus.rs - crates/model/src/trendline.rs - crates/pptx/src/read/dml.rs - xtask/src/layers.rs
583 lines
29 KiB
Rust
583 lines
29 KiB
Rust
use super::hit::{EditZone, contains_rect, edit_zone, edited_box, hit_shape, in_table_cell, is_text_first, shape_in_front};
|
|
use super::{
|
|
Arc, DRAW_TABLE, Drag, HistoryEntry, Hit, Mods, Point, PointerEvent, PointerKind, Rect, Result, Session, ShapeId, ShapeKind, TABLE_ERASER,
|
|
TextTarget, ToolKind, Value, Xfrm, box_handles, cmd, is_freeform_tool, json, wordart_handles, xfrm_of,
|
|
};
|
|
|
|
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()?;
|
|
// In Edit Points, the edited shape's vertices come first.
|
|
if let Some(id) = self.tool.editing_points(st)
|
|
&& let Some(sh) = st.shape(id)
|
|
{
|
|
if let Some((path, index)) = self.tool.edit_vertex
|
|
&& let Some((hin, hout, _)) = cmd::points::slide_handles(&st.doc, &st.selection, sh, path, index)
|
|
{
|
|
for (h, out) in [(hout, true), (hin, false)] {
|
|
if h.is_some_and(|q| (q - p).hypot() <= tol * 1.6) {
|
|
return Some(Hit::Handle { id, path, index, out });
|
|
}
|
|
}
|
|
}
|
|
if let Some((path, index)) = cmd::points::vertex_at(&st.doc, &st.selection, sh, p, tol * 1.6) {
|
|
return Some(Hit::Vertex { id, path, index });
|
|
}
|
|
}
|
|
// 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 };
|
|
// A locked object shows no live handles: it can't be sized, rotated or reshaped.
|
|
if sh.locked {
|
|
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 });
|
|
}
|
|
}
|
|
for hd in wordart_handles(&st.doc, &st.selection, sh, x.w, x.h) {
|
|
if (x.affine() * hd.pos - p).hypot() <= tol * 1.6 {
|
|
return Some(Hit::Warp { 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 });
|
|
}
|
|
}
|
|
}
|
|
}
|
|
// The frame of the text being edited: grabbing it moves the box, unless a shape in front
|
|
// of the text covers that spot (#931).
|
|
if let Some(e) = edited_box(st)
|
|
&& e.movable
|
|
&& edit_zone(&e.xfrm, p, tol) == EditZone::Frame
|
|
&& !shape_in_front(st, st.shapes(), e.id, p, tol, 0)
|
|
{
|
|
return Some(Hit::Frame { id: e.id });
|
|
}
|
|
// Shapes, topmost first.
|
|
// Locked objects are still picked (to format them or type in them); they just don't move.
|
|
for sh in st.shapes().iter().rev() {
|
|
if sh.hidden {
|
|
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);
|
|
self.tool.pressure = ev.pressure.filter(|v| v.is_finite()).map(|v| v.clamp(0.0, 1.0));
|
|
if !(p.x.is_finite() && p.y.is_finite()) {
|
|
return Ok(Value::Null);
|
|
}
|
|
// A press (not a hover) settles an input method's composition, as clicking does in PowerPoint.
|
|
if !matches!(ev.kind, PointerKind::Move) {
|
|
self.settle_composition();
|
|
}
|
|
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);
|
|
self.tool.guide_hover =
|
|
if self.tool.hover.is_none() && matches!(self.tool.kind, ToolKind::Select) { self.guide_at(p, tol) } else { None };
|
|
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 => {
|
|
let before = self.text_selection();
|
|
let r = self.double_click(p, tol)?;
|
|
self.paint_if_selection_changed(before, r)
|
|
}
|
|
PointerKind::ContextClick => self.context_click(p, tol),
|
|
PointerKind::TripleClick => {
|
|
if self.active().is_some_and(|d| d.selection.text.is_some()) {
|
|
let before = self.text_selection();
|
|
let r = self.execute("text.selectParagraph", &json!({}))?;
|
|
self.paint_if_selection_changed(before, r)
|
|
} else {
|
|
Ok(Value::Null)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Format Painter applies once a text range is selected (drag, word or paragraph click).
|
|
fn paint_text_selection(&mut self) -> Result<Option<Value>> {
|
|
if self.painter.is_some() && self.doc()?.selection.text.as_ref().is_some_and(|t| t.is_range()) {
|
|
return self.execute("format.painterApply", &json!({})).map(Some);
|
|
}
|
|
Ok(None)
|
|
}
|
|
|
|
/// Where the text selection is: its shape, cell or notes, anchor and caret.
|
|
fn text_selection(&self) -> Option<TextTarget> {
|
|
self.active().and_then(|d| d.selection.text.as_ref()).map(|t| (t.shape, t.cell, t.notes, t.anchor, t.caret))
|
|
}
|
|
|
|
/// A double or triple click paints only the range it selected, not an older one it left alone.
|
|
fn paint_if_selection_changed(&mut self, before: Option<TextTarget>, r: Value) -> Result<Value> {
|
|
if self.text_selection() == before {
|
|
return Ok(r);
|
|
}
|
|
self.paint_text_selection().map(|a| a.unwrap_or(r))
|
|
}
|
|
|
|
pub(crate) fn begin(&mut self, label: &str) {
|
|
let vertex_kind = self.tool.vertex_kind.clone();
|
|
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(), vertex_kind });
|
|
}
|
|
}
|
|
|
|
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, vertex_kind: i.vertex_kind });
|
|
}
|
|
}
|
|
|
|
/// Cancels a point or handle drag in Edit Points when Esc is pressed: ends the drag, puts back the
|
|
/// document and selection saved at the press, and adds no undo step. Returns whether a drag was cancelled.
|
|
pub(crate) fn cancel_point_drag(&mut self) -> bool {
|
|
if !matches!(self.tool.drag, Some(Drag::Vertex { .. } | Drag::Handle { .. })) {
|
|
return false;
|
|
}
|
|
self.tool.drag = None;
|
|
if let Some(st) = self.active_mut()
|
|
&& st.interaction.as_ref().is_some_and(|i| i.label == "Edit Points")
|
|
&& let Some(i) = st.interaction.take()
|
|
{
|
|
// A modifier held during the drag may have set a point type; the drag is undone, so is that.
|
|
let kind = i.vertex_kind;
|
|
if !Arc::ptr_eq(&i.doc, &st.doc) {
|
|
st.doc = i.doc;
|
|
st.selection = i.selection;
|
|
st.revision += 1;
|
|
}
|
|
self.tool.vertex_kind = kind;
|
|
}
|
|
true
|
|
}
|
|
|
|
/// End the interaction only when it is the one `label` started. A twist must not commit a drag.
|
|
pub(crate) fn commit_named(&mut self, label: &str) {
|
|
let ours = self.active().and_then(|st| st.interaction.as_ref()).is_some_and(|i| i.label == label);
|
|
if ours {
|
|
self.commit();
|
|
}
|
|
}
|
|
|
|
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 { preset } if preset == TABLE_ERASER => {
|
|
// Click a border to merge the cells on either side; elsewhere does nothing.
|
|
return Ok(self.execute("table.eraseLine", &json!({"at": [p.x, p.y], "tol": tol.max(4.0)})).unwrap_or(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);
|
|
}
|
|
let sample = self.ink_sample(p);
|
|
self.tool.drag = Some(Drag::Ink { points: vec![sample] });
|
|
self.tool.ink_preview = vec![sample];
|
|
return Ok(Value::Null);
|
|
}
|
|
ToolKind::Select => {}
|
|
}
|
|
// Clicking inside the text being edited moves the caret / starts a text selection.
|
|
let before = self.text_selection();
|
|
if let Some((shape, pos)) = self.place_caret(p, tol, mods.shift)? {
|
|
let press = self.doc()?.selection.text.clone();
|
|
self.tool.drag = Some(Drag::TextSelect { shape, before, press });
|
|
return Ok(json!({"caret": [pos.0, pos.1]}));
|
|
}
|
|
let hit = self.hit_test(p, tol);
|
|
// Edit Points: drag a vertex, Ctrl-click one to delete it or the outline to add one;
|
|
// a click anywhere else leaves Edit Points.
|
|
if let Some(eid) = self.doc().ok().and_then(|st| self.tool.editing_points(st)) {
|
|
if let Some(Hit::Vertex { id, path, index }) = hit {
|
|
if mods.cmd {
|
|
return self.execute("shape.deletePoint", &json!({"id": id, "path": path, "index": index}));
|
|
}
|
|
self.tool.edit_vertex = Some((path, index));
|
|
self.begin("Edit Points");
|
|
self.tool.drag = Some(Drag::Vertex { id, path, index });
|
|
return Ok(json!({"vertex": [path, index]}));
|
|
}
|
|
if let Some(Hit::Handle { id, path, index, out }) = hit {
|
|
self.begin("Edit Points");
|
|
self.tool.drag = Some(Drag::Handle { id, path, index, out });
|
|
return Ok(json!({"handle": [path, index]}));
|
|
}
|
|
let st = self.doc()?;
|
|
if let Some(sh) = st.shape(eid)
|
|
&& let Some((path, seg, t)) = cmd::points::segment_at(&st.doc, &st.selection, sh, p, tol * 1.6)
|
|
{
|
|
if mods.cmd {
|
|
return self.execute("shape.addPoint", &json!({"id": eid, "path": path, "segment": seg, "t": t}));
|
|
}
|
|
return Ok(Value::Null);
|
|
}
|
|
self.tool.edit_points = None;
|
|
self.tool.edit_vertex = None;
|
|
}
|
|
let st = self.doc()?;
|
|
match hit {
|
|
Some(Hit::TableColumn { id, col }) => self.begin_table_border(id, true, col, p)?,
|
|
Some(Hit::TableRow { id, row }) => self.begin_table_border(id, false, row, p)?,
|
|
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 });
|
|
}
|
|
// Only reached outside Edit Points (handled above).
|
|
Some(Hit::Vertex { .. } | Hit::Handle { .. }) => {}
|
|
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::Frame { id }) => {
|
|
// Like PowerPoint: the box leaves text editing and is selected whole (a click
|
|
// without a drag stops there), and dragging moves it as one undo step.
|
|
let origs: Vec<(ShapeId, Xfrm)> = st.shape(id).map(|s| (id, xfrm_of(&st.doc, &st.selection, s))).into_iter().collect();
|
|
self.select(|_, sel| {
|
|
sel.shapes = vec![id];
|
|
sel.text = None;
|
|
sel.cells = None;
|
|
})?;
|
|
self.tool.drag = Some(Drag::Move { ids: vec![id], start: p, origs, moved: false, duplicate: mods.alt, enter_text: None });
|
|
}
|
|
Some(Hit::Warp { id, index }) => {
|
|
let orig = st.shape(id).map(|s| xfrm_of(&st.doc, &st.selection, s)).unwrap_or_default();
|
|
let found = st.shape(id).and_then(|sh| {
|
|
let ctx = crate::cmd::text::ctx_for(&st.doc, &st.selection)?;
|
|
let bp = deckcraft_model::resolve::body(&ctx, sh);
|
|
bp.warp.map(|n| (n, bp.warp_adj))
|
|
});
|
|
if let Some((name, base)) = found {
|
|
self.begin("Adjust WordArt");
|
|
self.tool.drag = Some(Drag::Warp { id, index, name, base, orig });
|
|
}
|
|
}
|
|
Some(Hit::Shape { id, .. })
|
|
if !(mods.shift || mods.cmd)
|
|
&& st.selection.cells.is_some_and(|c| c.0 == id && st.selection.shapes == [id])
|
|
&& in_table_cell(st, id, p) =>
|
|
{
|
|
// A press in a table whose cells are selected starts again in the cell under the
|
|
// pointer: a click puts the caret there and a drag selects another range. A press
|
|
// on the table outside its cells selects and moves the table like any shape.
|
|
let before = self.text_selection();
|
|
if self.edit_text_at(id, p)? {
|
|
self.select(|_, sel| sel.cells = None)?;
|
|
let press = self.doc()?.selection.text.clone();
|
|
self.tool.drag = Some(Drag::TextSelect { shape: id, before, press });
|
|
}
|
|
}
|
|
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]
|
|
};
|
|
// Locked objects stay put when the selection is dragged.
|
|
let origs: Vec<(ShapeId, Xfrm)> =
|
|
ids.iter().filter_map(|i| st.shape(*i).filter(|s| !s.locked).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. A click in a cell of a
|
|
// selected table puts the caret there; a click on its grab band keeps the table selected.
|
|
let table = st.shape(id).is_some_and(|s| matches!(s.kind, ShapeKind::Table(_)));
|
|
// A click on the frame band of a text box selects the box whole, as PowerPoint
|
|
// does, so it can be moved, duplicated or deleted; only the inside takes the caret.
|
|
let on_frame = text
|
|
&& is_text_first(st, id)
|
|
&& st.shape(id).is_some_and(|s| edit_zone(&xfrm_of(&st.doc, &st.selection, s), p, tol) == EditZone::Frame);
|
|
let enter_text =
|
|
(!toggle && !on_frame && ((text && (selected || is_text_first(st, id))) || (table && selected && in_table_cell(st, id, p))))
|
|
.then_some((id, p));
|
|
let ids2 = ids.clone();
|
|
self.select(|_, sel| {
|
|
sel.shapes = ids2;
|
|
sel.text = None;
|
|
sel.cells = None;
|
|
})?;
|
|
let duplicate = mods.alt && origs.len() == ids.len();
|
|
self.tool.drag = Some(Drag::Move { ids, start: p, origs, moved: false, duplicate, enter_text });
|
|
// Format Painter applies on click.
|
|
if self.painter.is_some() && !toggle {
|
|
let _ = self.execute("format.painterApply", &json!({"ids": [id]}));
|
|
}
|
|
// Animation Painter applies on click.
|
|
if self.anim_painter.is_some() && !toggle {
|
|
let _ = self.execute("animation.painterApply", &json!({"ids": [id]}));
|
|
}
|
|
}
|
|
None if self.guide_at(p, tol).is_some() => {
|
|
// Away from every shape a press on a guide picks the guide up.
|
|
if let Some(index) = self.guide_at(p, tol) {
|
|
let pos = st.doc.guides.get(index).map(|g| g.pos).unwrap_or(0.0);
|
|
self.tool.drag = Some(Drag::Guide { index });
|
|
self.tool.guide_drag = Some((index, pos));
|
|
}
|
|
}
|
|
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 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::Guide { index }) => {
|
|
// Dropped off the slide: the guide is deleted.
|
|
let (_, pos) = self.tool.guide_drag.take().unwrap_or((index, 0.0));
|
|
let size = self.doc()?.doc.slide_size;
|
|
let vertical = self.doc()?.doc.guides.get(index).is_some_and(|g| g.vertical);
|
|
let limit = if vertical { size.width } else { size.height };
|
|
out = if pos < 0.0 || pos > limit {
|
|
self.execute("guide.delete", &json!({"index": index}))?
|
|
} else {
|
|
self.execute("guide.move", &json!({"index": index, "pos": pos}))?
|
|
};
|
|
}
|
|
Some(Drag::Move { moved: false, enter_text: Some((id, at)), .. })
|
|
// Plain click on text: caret at the click (in a table, in the clicked cell).
|
|
if self.edit_text_at(id, at)? => {
|
|
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 } if preset == DRAW_TABLE => {
|
|
// Inside a table: split cells along the line. Elsewhere: a one-cell table.
|
|
if crate::cmd::table::table_at(self, &Value::Null, (start.x, start.y)).is_some() {
|
|
self.execute("table.drawLine", &json!({"from": [start.x, start.y], "to": [p.x, p.y]})).unwrap_or(Value::Null)
|
|
} else if let Some(x) = preview.filter(|x| dragged && x.w >= 8.0 && x.h >= 8.0) {
|
|
self.execute("insert.table", &json!({"rows": 1, "cols": 1, "rect": [x.x, x.y, x.w, x.h]}))?
|
|
} else {
|
|
Value::Null
|
|
}
|
|
}
|
|
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,
|
|
};
|
|
// The table pencil stays on until you pick another tool, as in PowerPoint.
|
|
if !self.tool.sticky && !matches!(&kind, ToolKind::Shape { preset } if preset == DRAW_TABLE) {
|
|
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(Drag::TextSelect { before, .. }) => {
|
|
out = self.paint_if_selection_changed(before, out)?;
|
|
self.commit();
|
|
}
|
|
Some(_) => self.commit(),
|
|
None => {}
|
|
}
|
|
// Any open interaction (move etc.) ends here.
|
|
self.commit();
|
|
let _ = mods;
|
|
Ok(out)
|
|
}
|
|
|
|
pub(super) 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(_)))
|
|
}
|
|
}
|