Files
deckcraft/crates/engine/src/cmd/edit.rs
T
2026-10-23 17:09:38 -04:00

727 lines
29 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Edit menu: undo/redo, clipboard, duplicate, delete, select all.
use std::collections::HashMap;
use std::sync::Arc;
use deckcraft_model::text::TextBody;
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} → {text, html?}", has_doc, cut),
cmd!(noundo "edit.copy", "Copy", ["Home", "Clipboard"], Some("Cmd+C"), "{scope?: slides} → {text, html?}", has_doc, copy),
cmd!(
"edit.paste",
"Paste",
["Home", "Clipboard"],
Some("Cmd+V"),
"{text?: plain text to paste, html?: rich text from another app, scope?: slides}",
has_doc,
paste
),
cmd!(
"edit.pasteSpecial",
"Paste Special",
["Home", "Clipboard"],
None,
"{as: destinationTheme|keepSource|picture|text} — Use Destination Theme, Keep Source Formatting, Picture (PNG), Keep Text Only",
has_doc,
paste_special
),
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"), "{scope?: slides}", 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;
// A later shape can get an undone copy's id: Duplicate starts over with the usual offset.
st.last_duplicate = 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())
}
pub(crate) 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);
}
}
deckcraft_model::ShapeKind::Model3d { media, poster } => {
out.push(*media);
if let Some(p) = 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);
if let Some(deckcraft_model::Background::Fill { fill: deckcraft_model::Fill::Picture(pf) }) = &sl.background {
mids.push(pf.media);
}
}
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");
let source_theme = st.current_slide().and_then(|sl| st.doc.master_for(sl)).map(|(m, _)| (m.scheme(), m.theme.fonts.clone()));
s.clipboard = crate::Clipboard { shapes, media, plain: plain.clone(), source_theme, ..Default::default() };
Ok(plain)
}
fn copying_caret(s: &Session) -> bool {
s.active().is_some_and(|d| d.selection.text.as_ref().is_some_and(|t| !t.is_range()))
}
fn copy(s: &mut Session, p: &Value) -> Result<Value> {
// A caret copies nothing; the previous clipboard must not leak into this answer.
let caret = copying_caret(s);
let t = copy_inner(s, p)?;
let html = if caret { String::new() } else { copied_html(s) };
Ok(json!({"text": t, "html": html}))
}
/// HTML for what was just copied, when it is text (a selection, or the text on the copied shapes).
fn copied_html(s: &Session) -> String {
if let Some(body) = &s.clipboard.text {
return richclip::body_to_html(body);
}
let mut paragraphs = Vec::new();
for sh in &s.clipboard.shapes {
if let Some(t) = &sh.text {
paragraphs.extend(t.paragraphs.clone());
}
}
if paragraphs.is_empty() {
return String::new();
}
richclip::body_to_html(&TextBody { paragraphs, ..Default::default() })
}
fn cut(s: &mut Session, p: &Value) -> Result<Value> {
let caret = copying_caret(s);
let t = copy_inner(s, p)?;
if caret {
// Nothing is selected, so there is nothing to cut; `delete` would remove the next character.
return Ok(json!({"text": t, "html": ""}));
}
let html = copied_html(s);
delete(s, p)?;
Ok(json!({"text": t, "html": html}))
}
/// Give pasted shapes fresh ids (groups included) and return the old-to-new map. Connector ends
/// attached to a shape that was copied too follow its copy; ends attached to a shape outside the
/// copied set are detached (the connector keeps its geometry), so a copy never stays glued to
/// the original or to an unrelated shape that happens to share an id in another deck.
pub(crate) fn reid(cmd: &str, doc: &mut deckcraft_model::Presentation, shapes: &mut [Shape]) -> Result<HashMap<ShapeId, ShapeId>> {
fn rec(
cmd: &str,
doc: &mut deckcraft_model::Presentation,
shapes: &mut [Shape],
map: &mut HashMap<ShapeId, ShapeId>,
depth: usize,
) -> Result<()> {
if depth > 64 {
return Ok(());
}
for s in shapes {
let id = ShapeId(doc.alloc_id().ok_or_else(|| bad(cmd, NO_ID_SPACE))?);
map.insert(s.id, id);
s.id = id;
if let Some(ch) = s.children_mut() {
rec(cmd, doc, ch, map, depth + 1)?;
}
}
Ok(())
}
let mut map = HashMap::new();
rec(cmd, doc, shapes, &mut map, 0)?;
walk_mut(shapes, &mut |s| {
if let deckcraft_model::ShapeKind::Connector { start, end } = &mut s.kind {
for e in [start, end] {
*e = e.and_then(|(id, site)| map.get(&id).map(|n| (*n, site)));
}
}
});
Ok(map)
}
/// Point copied animations at the copies: targets and triggers in `map` follow it; an animation
/// whose target wasn't copied is dropped, and a trigger that wasn't copied is cleared.
pub(crate) fn remap_animations(anims: &mut Vec<deckcraft_model::Animation>, map: &HashMap<ShapeId, ShapeId>) {
anims.retain_mut(|a| {
let Some(n) = map.get(&a.shape) else { return false };
a.shape = *n;
a.trigger = a.trigger.and_then(|t| map.get(&t).copied());
true
});
}
/// Visit every shape mutably, depth-first (groups included, depth-bounded like `walk`).
fn walk_mut(shapes: &mut [Shape], f: &mut dyn FnMut(&mut Shape)) {
fn rec(shapes: &mut [Shape], f: &mut dyn FnMut(&mut Shape), depth: usize) {
if depth > 64 {
return;
}
for s in shapes {
f(s);
if let Some(ch) = s.children_mut() {
rec(ch, f, depth + 1);
}
}
}
rec(shapes, f, 0);
}
/// Rewrite the media ids shapes use (`map`: clipboard id to destination id).
fn remap_media(shapes: &mut [Shape], map: &HashMap<MediaId, MediaId>) {
if map.is_empty() {
return;
}
let m = |id: &mut MediaId| {
if let Some(n) = map.get(id) {
*id = *n;
}
};
walk_mut(shapes, &mut |s| {
match &mut s.kind {
deckcraft_model::ShapeKind::Picture { fill } => m(&mut fill.media),
deckcraft_model::ShapeKind::Media(c) => {
m(&mut c.media);
if let Some(p) = c.poster.as_mut() {
m(p);
}
}
deckcraft_model::ShapeKind::Opaque { preview: Some(p), .. } => m(p),
_ => {}
}
if let Some(deckcraft_model::Fill::Picture(pf)) = &mut s.fill {
m(&mut pf.media);
}
});
}
/// Add the clipboard's media to `doc` and return the ids that changed: an id already used in
/// `doc` by other bytes (another deck) gets a fresh id, and the pasted shapes must use it.
fn bring_media(cmd: &str, doc: &mut deckcraft_model::Presentation, media: &[deckcraft_model::MediaItem]) -> Result<HashMap<MediaId, MediaId>> {
let mut map = HashMap::new();
for m in media {
match doc.media(m.id) {
None => doc.media.push(m.clone()),
Some(x) if x.data == m.data => {}
Some(_) => {
let id = doc.add_media(&m.name, &m.content_type, m.data.as_ref().clone()).ok_or_else(|| bad(cmd, NO_ID_SPACE))?;
map.insert(m.id, id);
}
}
}
Ok(map)
}
fn paste(s: &mut Session, p: &Value) -> Result<Value> {
// External plain text (from the system clipboard) wins when given and differs from ours.
// HTML from Pages or Keynote keeps bold, italic and underline when it says the same words.
if let Some(t) = str_param(p, "text")
&& t != s.clipboard.plain
{
if let Some(html) = str_param(p, "html")
&& let Some(body) = richclip::html_to_body(html)
&& richclip::same_words(&body.text(), t)
{
return paste_external_body(s, &body);
}
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| {
let media = bring_media("edit.paste", 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().ok_or_else(|| bad("edit.paste", NO_ID_SPACE))?);
let map = reid("edit.paste", doc, &mut sl.shapes)?;
remap_animations(&mut sl.animations, &map);
remap_media(&mut sl.shapes, &media);
if let Some(deckcraft_model::Background::Fill { fill: deckcraft_model::Fill::Picture(pf) }) = &mut sl.background
&& let Some(n) = media.get(&pf.media)
{
pf.media = *n;
}
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| {
let media = bring_media("edit.paste", doc, &clip.media)?;
let mut shapes = clip.shapes.clone();
reid("edit.paste", doc, &mut shapes)?;
remap_media(&mut shapes, &media);
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_external_body(s: &mut Session, body: &TextBody) -> Result<Value> {
if s.doc()?.selection.text.is_some() {
return super::text::paste_body(s, body);
}
let plain = body.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];
let made = s.execute("insert.textBox", &json!({"rect": rect, "text": plain}))?;
let raw = made.get("id").and_then(Value::as_u64).ok_or_else(|| bad("edit.paste", "no text box"))?;
let id = u32::try_from(raw).map_err(|_| bad("edit.paste", "no text box"))?;
let body = body.clone();
s.edit(|doc, sel| {
let list = crate::shapes_mut(doc, sel).ok_or_else(|| bad("edit.paste", "no slide"))?;
let sh = deckcraft_model::find_shape_mut(list, ShapeId(id)).ok_or_else(|| bad("edit.paste", "no text box"))?;
if let Some(t) = sh.text.as_mut() {
let (wrap, autofit) = (t.body.wrap, t.body.autofit);
*t = body.clone();
t.body.wrap = wrap;
t.body.autofit = autofit;
}
sel.shapes = vec![ShapeId(id)];
sel.text = None;
Ok(json!({"id": id}))
})
}
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_special(s: &mut Session, p: &Value) -> Result<Value> {
match str_param(p, "as").unwrap_or("destinationTheme") {
"destinationTheme" | "useDestinationTheme" => paste(s, &json!({})),
"text" | "keepTextOnly" => paste_text(s, &json!({})),
"keepSource" | "keepSourceFormatting" => {
let saved = s.clipboard.clone();
if let Some((scheme, fonts)) = &saved.source_theme {
bake_theme(&mut s.clipboard.shapes, scheme, fonts);
}
let r = paste(s, &json!({}));
let pastes = s.clipboard.pastes;
s.clipboard = saved;
s.clipboard.pastes = pastes;
r
}
"picture" => paste_picture(s),
other => Err(bad("edit.pasteSpecial", format!("unknown `as`: {other} (destinationTheme, keepSource, picture, text)"))),
}
}
/// Replace theme colours and theme fonts with the values they have in `scheme` / `fonts`, so the
/// shapes keep their look under another theme.
pub(crate) fn bake_theme(shapes: &mut [Shape], scheme: &deckcraft_color::ColorScheme, fonts: &deckcraft_model::theme::FontScheme) {
fn rec(v: &mut Value, scheme: &deckcraft_color::ColorScheme, fonts: &deckcraft_model::theme::FontScheme, depth: usize) {
if depth > 256 {
return;
}
match v {
Value::Object(o) => {
if o.get("kind").and_then(Value::as_str) == Some("scheme")
&& let Some(slot) = o.get("slot").and_then(|x| serde_json::from_value::<deckcraft_color::SchemeSlot>(x.clone()).ok())
&& slot != deckcraft_color::SchemeSlot::PhClr
&& let Ok(rgb) = serde_json::to_value(scheme.get(slot))
{
o.clear();
o.insert("kind".into(), json!("rgb"));
o.insert("rgb".into(), rgb);
return;
}
for x in o.values_mut() {
rec(x, scheme, fonts, depth + 1);
}
}
Value::Array(a) => a.iter_mut().for_each(|x| rec(x, scheme, fonts, depth + 1)),
Value::String(t) if t.starts_with('+') => {
let set = match t.get(1..3) {
Some("mj") => &fonts.major,
Some("mn") => &fonts.minor,
_ => return,
};
let face = match t.get(3..) {
Some("-lt") => &set.latin,
Some("-ea") => &set.ea,
Some("-cs") => &set.cs,
_ => return,
};
if !face.is_empty() {
*t = face.clone();
}
}
_ => {}
}
}
for sh in shapes.iter_mut() {
let Ok(mut v) = serde_json::to_value(&*sh) else { continue };
rec(&mut v, scheme, fonts, 0);
if let Ok(baked) = serde_json::from_value::<Shape>(v) {
*sh = baked;
}
}
}
/// Paste Special › Picture: the copied shapes drawn into one PNG picture.
fn paste_picture(s: &mut Session) -> Result<Value> {
let mut clip = s.clipboard.clone();
if clip.shapes.is_empty() {
return Err(bad("edit.pasteSpecial", "copy shapes first to paste them as a picture"));
}
if let Some((scheme, fonts)) = &clip.source_theme {
bake_theme(&mut clip.shapes, scheme, fonts);
}
let st = s.doc()?;
let mut doc = (*st.doc).clone();
bring_media("edit.pasteSpecial", &mut doc, &clip.media)?;
let mut slide = st.current_slide().cloned().ok_or_else(|| bad("edit.pasteSpecial", "no slide"))?;
let xs: Vec<deckcraft_geom::Xfrm> = clip.shapes.iter().filter_map(|x| x.xfrm).collect();
let b = super::arrange::union_bounds(&xs).ok_or_else(|| bad("edit.pasteSpecial", "nothing to draw"))?;
let gx = deckcraft_geom::Xfrm::new(b.x0, b.y0, b.width(), b.height());
let gid = ShapeId(u32::MAX - 1);
let one = if clip.shapes.len() == 1 {
let mut sh = clip.shapes[0].clone();
sh.id = gid;
sh
} else {
Shape { id: gid, xfrm: Some(gx), kind: deckcraft_model::ShapeKind::Group { children: clip.shapes.clone(), child: gx }, ..Default::default() }
};
slide.shapes = vec![one];
const SCALE: f64 = 2.0;
let img = deckcraft_render::render_shape(&doc, &slide, gid, SCALE).ok_or_else(|| bad("edit.pasteSpecial", "the shapes could not be drawn"))?;
// render_shape draws the bounds plus an 8 pt margin (room for shadows and outlines).
let (w, h) = (img.width as f64 / SCALE, img.height as f64 / SCALE);
let same_slide = clip.shapes.iter().any(|c| st.shape(c.id).is_some());
let off = if same_slide { s.prefs.duplicate_offset } else { 0.0 };
let png = img.to_png();
s.execute("insert.picture", &json!({"data": base64_encode(&png), "name": "Picture.png", "rect": [b.x0 - 8.0 + off, b.y0 - 8.0 + off, w, h]}))
}
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;
let last = s.doc()?.last_duplicate.clone();
let (new_ids, made) = 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 sources: Vec<ShapeId> = copies.iter().map(|c| c.id).collect();
let boxes: Vec<_> = copies.iter().map(|c| xfrm_of(doc, sel, c)).collect();
// Duplicating the copies of the last Duplicate again repeats how far they were moved
// since (#966); anything else gets the usual offset.
let (dx, dy) = last
.filter(|(slide, target, pairs)| {
*slide == sel.slide && *target == sel.target && pairs.len() == sources.len() && pairs.iter().all(|(_, c)| sources.contains(c))
})
.and_then(|(_, _, pairs)| {
let (src, copy) = pairs.first()?;
let a = xfrm_of(doc, sel, list.iter().find(|x| x.id == *src)?);
let b = xfrm_of(doc, sel, list.iter().find(|x| x.id == *copy)?);
let d = (b.x - a.x, b.y - a.y);
(d.0.is_finite() && d.1.is_finite()).then_some(d)
})
.unwrap_or((off, off));
reid("edit.duplicate", doc, &mut copies)?;
for (c, b) in copies.iter_mut().zip(boxes) {
let mut x = b;
x.x += dx;
x.y += dy;
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();
let made = (sel.slide, sel.target, sources.into_iter().zip(new_ids.iter().copied()).collect());
Ok((new_ids, made))
})?;
s.doc_mut()?.last_duplicate = Some(made);
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 mut before = Vec::new();
deckcraft_model::walk(shapes, &mut |s, _| before.push(s.id));
let n = remove_shapes(shapes, &ids);
// Everything that is gone: the given ids plus group descendants and emptied groups.
let mut left = Vec::new();
deckcraft_model::walk(shapes, &mut |s, _| left.push(s.id));
let mut gone = ids.clone();
gone.extend(before.into_iter().filter(|i| !left.contains(i)));
// Animations of deleted shapes go too.
if sel.target == crate::Target::Slides
&& let Some(sl) = doc.slides.get_mut(sel.slide)
{
let sl = Arc::make_mut(sl);
sl.animations.retain(|a| !gone.contains(&a.shape));
// Effects triggered by a deleted shape fall back to the main sequence.
for a in sl.animations.iter_mut().filter(|a| a.trigger.is_some_and(|t| gone.contains(&t))) {
a.trigger = None;
}
}
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> {
match p.get("scope") {
None | Some(Value::Null) => {}
Some(Value::String(v)) if v == "slides" => return select_all_slides(s),
Some(_) => return Err(bad("edit.selectAll", "scope must be \"slides\"")),
}
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}))
}
/// Thumbnail-pane / sorter select all: every slide, hidden ones included. The current
/// slide stays where it is (the canvas must not jump), so this is not `slide.selectSlides`.
fn select_all_slides(s: &mut Session) -> Result<Value> {
if s.doc()?.selection.target != crate::Target::Slides {
return Err(bad("edit.selectAll", "the slide list is not shown while editing a master"));
}
let ids: Vec<SlideId> = s.doc()?.doc.slides.iter().map(|x| x.id).collect();
s.select(|_, sel| {
sel.slides = ids.clone();
sel.shapes.clear();
sel.text = None;
sel.cells = None;
})?;
Ok(json!({"selected": 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 = match ids_param(p, "ids") {
Some(v) => v,
None => id_param(p, "id", "edit.select")?.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}))
}