Files
deckcraft/crates/engine/src/cmd/edit.rs
T
8fe168c07b Animation Painter: copy and apply animation sequences between shapes (#54)
- engine: add animation.painter (Alt+Shift+C) and animation.painterApply (Alt+Shift+V) supporting single-use and sticky modes
- tools: apply animation painter on canvas shape click
- edit: disarm animation painter on deselect (Escape)
- ui-egui: add Animation Painter button in Advanced Animation ribbon group
- tests: unit tests for single and sticky animation painting
- docs/parity: mark Animation Painter as done (breadth parity to 80%)
- contributors: add Johannesgauss to contributors/people.toml

Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 06:24:23 -04:00

383 lines
14 KiB
Rust

//! Edit menu: undo/redo, clipboard, duplicate, delete, select all.
use std::sync::Arc;
use deckcraft_model::{MediaId, Shape, ShapeId, Slide, SlideId};
use serde_json::{Value, json};
use super::*;
use crate::{HistoryEntry, Result, Session};
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!(noundo "edit.undo", "Undo", ["Edit"], Some("Cmd+Z"), "{}", can_undo, undo),
cmd!(noundo "edit.redo", "Redo", ["Edit"], Some("Cmd+Y"), "{}", can_redo, redo),
cmd!("edit.cut", "Cut", ["Home", "Clipboard"], Some("Cmd+X"), "{scope?: slides}", has_doc, cut),
cmd!(noundo "edit.copy", "Copy", ["Home", "Clipboard"], Some("Cmd+C"), "{scope?: slides} → {text}", has_doc, copy),
cmd!("edit.paste", "Paste", ["Home", "Clipboard"], Some("Cmd+V"), "{text?: plain text to paste, scope?: slides}", has_doc, paste),
cmd!("edit.pasteText", "Paste and Match Formatting", ["Edit"], Some("Cmd+Shift+V"), "{text?}", has_doc, paste_text),
cmd!("edit.duplicate", "Duplicate", ["Edit"], Some("Cmd+D"), "{ids?}", has_selection, duplicate),
cmd!("edit.delete", "Delete", ["Edit"], Some("Delete"), "{ids?, scope?: slides}", has_doc, delete),
cmd!(noundo "edit.selectAll", "Select All", ["Edit"], Some("Cmd+A"), "{}", has_slide, select_all),
cmd!(noundo "edit.deselect", "Deselect", [], Some("Escape"), "{}", has_doc, deselect),
cmd!(noundo "edit.select", "Select Objects", [], None, "{ids: [id], add?: bool, toggle?: bool}", has_slide, select),
]
}
fn restore(s: &mut Session, redo: bool) -> Result<Value> {
let st = s.doc_mut()?;
let (from, to) = if redo { (&mut st.history.redo, &mut st.history.undo) } else { (&mut st.history.undo, &mut st.history.redo) };
let Some(e) = from.pop() else { return ok() };
to.push(HistoryEntry { label: e.label.clone(), doc: st.doc.clone(), selection: st.selection.clone() });
st.doc = e.doc;
st.selection = e.selection;
st.interaction = None;
st.revision += 1;
Ok(json!({"label": e.label}))
}
fn undo(s: &mut Session, _p: &Value) -> Result<Value> {
restore(s, false)
}
fn redo(s: &mut Session, _p: &Value) -> Result<Value> {
restore(s, true)
}
fn slides_scope(s: &Session, p: &Value) -> bool {
str_param(p, "scope") == Some("slides")
|| s.active().is_some_and(|d| d.selection.shapes.is_empty() && d.selection.text.is_none() && !d.selection.slides.is_empty())
}
fn media_of_shapes(shapes: &[Shape], out: &mut Vec<MediaId>) {
deckcraft_model::walk(shapes, &mut |s, _| {
match &s.kind {
deckcraft_model::ShapeKind::Picture { fill } => out.push(fill.media),
deckcraft_model::ShapeKind::Media(m) => {
out.push(m.media);
if let Some(p) = m.poster {
out.push(p);
}
}
_ => {}
}
if let Some(deckcraft_model::Fill::Picture(pf)) = &s.fill {
out.push(pf.media);
}
});
}
fn copy_inner(s: &mut Session, p: &Value) -> Result<String> {
let st = s.doc()?;
// Text selection.
if let Some(t) = &st.selection.text {
if t.is_range() {
let body = super::text::body_of(st, t).cloned().unwrap_or_default();
let (a, b) = t.ordered();
let plain = deckcraft_model::edit::text_range(&body, a, b);
let sliced = deckcraft_model::edit::slice(&body, a, b);
s.clipboard = crate::Clipboard { text: Some(sliced), plain: plain.clone(), ..Default::default() };
return Ok(plain);
}
return Ok(String::new());
}
if slides_scope(s, p) {
let ids: Vec<SlideId> =
if st.selection.slides.is_empty() { st.current_slide().map(|x| vec![x.id]).unwrap_or_default() } else { st.selection.slides.clone() };
let slides: Vec<Slide> = ids.iter().filter_map(|id| st.doc.slide(*id).cloned()).collect();
let mut mids = vec![];
for sl in &slides {
media_of_shapes(&sl.shapes, &mut mids);
}
let media = st.doc.media.iter().filter(|m| mids.contains(&m.id)).cloned().collect();
let plain = slides.iter().map(|x| x.title()).collect::<Vec<_>>().join("\n");
s.clipboard = crate::Clipboard { slides, media, plain: plain.clone(), ..Default::default() };
return Ok(plain);
}
let shapes: Vec<Shape> = st
.selection
.shapes
.iter()
.filter_map(|id| {
st.shape(*id).cloned().map(|mut sh| {
if sh.xfrm.is_none() {
sh.xfrm = Some(xfrm_of(&st.doc, &st.selection, &sh));
}
sh
})
})
.collect();
let mut mids = vec![];
media_of_shapes(&shapes, &mut mids);
let media = st.doc.media.iter().filter(|m| mids.contains(&m.id)).cloned().collect();
let plain = shapes.iter().filter_map(|x| x.text.as_ref().map(|t| t.text())).collect::<Vec<_>>().join("\n");
s.clipboard = crate::Clipboard { shapes, media, plain: plain.clone(), ..Default::default() };
Ok(plain)
}
fn copy(s: &mut Session, p: &Value) -> Result<Value> {
let t = copy_inner(s, p)?;
Ok(json!({"text": t}))
}
fn cut(s: &mut Session, p: &Value) -> Result<Value> {
let t = copy_inner(s, p)?;
delete(s, p)?;
Ok(json!({"text": t}))
}
/// Give pasted shapes fresh ids (groups included) and map connector ends.
pub(crate) fn reid(doc: &mut deckcraft_model::Presentation, shapes: &mut [Shape]) {
fn rec(doc: &mut deckcraft_model::Presentation, shapes: &mut [Shape], depth: usize) {
if depth > 64 {
return;
}
for s in shapes {
s.id = ShapeId(doc.alloc_id());
if let Some(ch) = s.children_mut() {
rec(doc, ch, depth + 1);
}
}
}
rec(doc, shapes, 0);
}
fn bring_media(doc: &mut deckcraft_model::Presentation, media: &[deckcraft_model::MediaItem]) {
for m in media {
if doc.media(m.id).is_none_or(|x| x.data != m.data) {
if doc.media(m.id).is_none() {
doc.media.push(m.clone());
} else {
// Id clash with different bytes: unusual (other deck); keep ours, add theirs as new.
let id = doc.add_media(&m.name, &m.content_type, m.data.as_ref().clone());
let _ = id;
}
}
}
}
fn paste(s: &mut Session, p: &Value) -> Result<Value> {
// External plain text (from the system clipboard) wins when given and differs from ours.
if let Some(t) = str_param(p, "text")
&& t != s.clipboard.plain
{
return paste_plain(s, t);
}
let editing = s.doc()?.selection.text.is_some();
if editing {
if let Some(body) = s.clipboard.text.clone() {
return super::text::paste_body(s, &body);
}
let plain = s.clipboard.plain.clone();
return paste_plain(s, &plain);
}
let clip = s.clipboard.clone();
if !clip.slides.is_empty() {
let at = s.doc()?.selection.slide + 1;
return s.edit(|doc, sel| {
bring_media(doc, &clip.media);
let mut new_ids = vec![];
for (k, sl) in clip.slides.iter().enumerate() {
let mut sl = sl.clone();
sl.id = SlideId(doc.alloc_id());
reid(doc, &mut sl.shapes);
if doc.layout(sl.layout).is_none() {
sl.layout = doc.masters.first().and_then(|m| m.layouts.first()).map(|l| l.id).unwrap_or_default();
}
new_ids.push(sl.id);
let i = (at + k).min(doc.slides.len());
doc.slides.insert(i, Arc::new(sl));
}
sel.slide = (at + new_ids.len()).saturating_sub(1).min(doc.slides.len().saturating_sub(1));
sel.slides = new_ids.clone();
sel.shapes.clear();
Ok(json!({"slides": new_ids.len()}))
});
}
if !clip.shapes.is_empty() {
s.clipboard.pastes += 1;
let off = s.prefs.duplicate_offset * s.clipboard.pastes as f64;
// Pasting on another slide keeps the position; on the same slide it offsets.
let same_slide = s.doc().ok().is_some_and(|d| clip.shapes.iter().any(|c| d.shape(c.id).is_some()));
let off = if same_slide { off } else { 0.0 };
return s.edit(|doc, sel| {
bring_media(doc, &clip.media);
let mut shapes = clip.shapes.clone();
reid(doc, &mut shapes);
for sh in &mut shapes {
if let Some(x) = sh.xfrm.as_mut() {
x.x += off;
x.y += off;
}
// A pasted placeholder becomes a regular shape keeping its look.
if sh.ph.is_some() && sel.target == crate::Target::Slides {
sh.ph = None;
}
}
let ids: Vec<ShapeId> = shapes.iter().map(|x| x.id).collect();
let list = crate::shapes_mut(doc, sel).ok_or_else(|| bad("edit.paste", "no slide"))?;
list.extend(shapes);
sel.shapes = ids.clone();
sel.text = None;
Ok(json!({"ids": ids}))
});
}
if !clip.plain.is_empty() {
let t = clip.plain.clone();
return paste_plain(s, &t);
}
ok()
}
fn paste_plain(s: &mut Session, text: &str) -> Result<Value> {
if s.doc()?.selection.text.is_some() {
return super::text::insert_text(s, text, None);
}
// No text box being edited: a new text box with the text.
let st = s.doc()?;
let size = st.doc.slide_size;
let rect = [size.width * 0.25, size.height * 0.4, size.width * 0.5, 40.0];
s.execute("insert.textBox", &json!({"rect": rect, "text": text}))
}
fn paste_text(s: &mut Session, p: &Value) -> Result<Value> {
let t = str_param(p, "text").map(String::from).unwrap_or_else(|| s.clipboard.plain.clone());
paste_plain(s, &t)
}
fn duplicate(s: &mut Session, p: &Value) -> Result<Value> {
let ids = targets(s, p)?;
let off = s.prefs.duplicate_offset;
s.edit(|doc, sel| {
let list = super::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<_> = copies.iter().map(|c| xfrm_of(doc, sel, c)).collect();
reid(doc, &mut copies);
for (c, b) in copies.iter_mut().zip(boxes) {
let mut x = b;
x.x += off;
x.y += off;
c.xfrm = Some(x);
if sel.target == crate::Target::Slides {
c.ph = None;
}
if !c.name.is_empty() {
c.name = format!("{} copy", c.name.trim_end_matches(" copy"));
}
}
let new_ids: Vec<ShapeId> = copies.iter().map(|c| c.id).collect();
let shapes = crate::shapes_mut(doc, sel).ok_or_else(|| bad("edit.duplicate", "no slide"))?;
shapes.extend(copies);
sel.shapes = new_ids.clone();
Ok(json!({"ids": new_ids}))
})
}
fn delete(s: &mut Session, p: &Value) -> Result<Value> {
if s.doc()?.selection.text.is_some() && ids_param(p, "ids").is_none() {
return super::text::delete_dir(s, "forward");
}
if slides_scope(s, p) && ids_param(p, "ids").is_none() {
return s.execute("slide.delete", &json!({}));
}
let ids = targets(s, p)?;
if ids.is_empty() {
return ok();
}
s.edit(|doc, sel| {
let shapes = crate::shapes_mut(doc, sel).ok_or_else(|| bad("edit.delete", "no slide"))?;
let n = remove_shapes(shapes, &ids);
// Animations of deleted shapes go too.
if sel.target == crate::Target::Slides
&& let Some(sl) = doc.slides.get_mut(sel.slide)
{
Arc::make_mut(sl).animations.retain(|a| !ids.contains(&a.shape));
}
sel.shapes.retain(|i| !ids.contains(i));
sel.text = None;
Ok(json!({"deleted": n}))
})
}
/// Remove shapes by id anywhere in the tree; empty groups go too.
pub(crate) fn remove_shapes(shapes: &mut Vec<Shape>, ids: &[ShapeId]) -> usize {
let before = count(shapes);
fn rec(v: &mut Vec<Shape>, ids: &[ShapeId], depth: usize) {
v.retain(|s| !ids.contains(&s.id));
if depth > 64 {
return;
}
for s in v.iter_mut() {
if let Some(ch) = s.children_mut() {
rec(ch, ids, depth + 1);
}
}
v.retain(|s| !s.is_group() || !s.children().is_empty());
}
rec(shapes, ids, 0);
before.saturating_sub(count(shapes))
}
fn count(v: &[Shape]) -> usize {
let mut n = 0;
deckcraft_model::walk(v, &mut |_, _| n += 1);
n
}
fn select_all(s: &mut Session, _p: &Value) -> Result<Value> {
if s.doc()?.selection.text.is_some() {
return s.execute("text.selectAll", &json!({}));
}
s.select(|_, sel| {
sel.text = None;
})?;
let ids: Vec<ShapeId> = s.doc()?.shapes().iter().filter(|x| !x.hidden && !x.locked).map(|x| x.id).collect();
s.select(|_, sel| sel.shapes = ids.clone())?;
Ok(json!({"ids": ids}))
}
fn deselect(s: &mut Session, _p: &Value) -> Result<Value> {
s.painter = None;
s.anim_painter = None;
s.select(|_, sel| {
if let Some(t) = sel.text.take() {
// Esc while editing text selects the shape.
if !t.notes {
sel.shapes = vec![t.shape];
}
} else {
sel.shapes.clear();
sel.cells = None;
}
})?;
ok()
}
fn select(s: &mut Session, p: &Value) -> Result<Value> {
let ids = ids_param(p, "ids").or_else(|| id_param(p, "id").map(|i| vec![i])).unwrap_or_default();
let add = bool_or(p, "add", false);
let toggle = bool_or(p, "toggle", false);
let valid: Vec<ShapeId> = ids.into_iter().filter(|i| s.active().is_some_and(|d| d.shape(*i).is_some())).collect();
s.select(|_, sel| {
sel.text = None;
sel.cells = None;
if toggle {
for i in &valid {
if let Some(k) = sel.shapes.iter().position(|x| x == i) {
sel.shapes.remove(k);
} else {
sel.shapes.push(*i);
}
}
} else if add {
for i in &valid {
if !sel.shapes.contains(i) {
sel.shapes.push(*i);
}
}
} else {
sel.shapes = valid.clone();
}
})?;
Ok(json!({"selected": s.doc()?.selection.shapes}))
}