diff --git a/ROADMAP.md b/ROADMAP.md index bb42939ac..db6929860 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -164,7 +164,7 @@ app's JSON control channel (`deckcraft --control PORT`). | 2026-11-03 | Reuse Slides brings along referenced custom table styles, including grouped tables, and remaps conflicting style ids without changing destination tables | | 2026-11-02 | Tiled picture fills in groups: a tile (`a:blipFill` with `a:tile`) keeps its size, offset and anchor in slide points whatever the group's child-coordinate scale, uniform or stretched unevenly, nested or flipped (also a group's own tile fill passed to its members, and a picture shape's own tile, with its shadow and reflection), and tiles with `rotWithShape="0"` keep their size in points at any zoom, as PowerPoint draws them (measured on its own PNG export of a synthetic probe deck: every grouped case within ~3/255 of the ungrouped control's own difference from PowerPoint, which is ~13/255 from resampling). Before, a group whose child space was ~1800 times smaller than its box showed one flat texel of the texture (a Venn diagram with paper-textured circles and a hatched intersection) | | 2026-11-01 | Ctrl+V of a picture copied in a browser (Copy Image) pastes the picture, not a text box with its link and alt text; the link still pastes as text when no picture came with it | -| 2026-11-01 | Copy, Cut, Paste and Duplicate of a title, body or other placeholder on a slide keep its look: the copy is a regular shape that takes the size, font, bullets, indents, autofit and box its layout and master gave it, instead of 18 pt unbulleted text | +| 2026-11-01 | Copy, Cut, Paste, Duplicate and Alt+drag of a title, body or other placeholder on a slide keep its look: the copy is a regular shape that takes the size, font, bullets, indents, autofit and box its layout and master gave it, instead of 18 pt unbulleted text | | 2026-11-01 | Clipboard: Ctrl+C, Ctrl+X and Ctrl+V use the system clipboard again, so text and pictures paste from other apps and in-app copies paste back. Since the egui 0.37 migration (#1350) the desktop build had a clipboard that only worked inside the app. On native Wayland, text goes through egui's Wayland clipboard; HTML stays on X11, macOS and Windows | | 2026-10-31 | No Fill shape interiors allow selection and editing of underlying objects, even when a quick style remains; hit-testing uses the renderer’s fill resolution. Outlines, rotation, text targets and actual fills keep their hit behaviour (#1621) | | 2026-10-30 | Print-preview tests use the URL emitted by egui and its exact file path; previews are explicitly removed before the macOS process exits | diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index 9674bb8ad..9ff5cbb71 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -107,7 +107,7 @@ fn copy_inner(s: &mut Session, p: &Value) -> Result { s.clipboard = crate::Clipboard { text: Some(TextBody::from_text(&plain)), plain: plain.clone(), ..Default::default() }; return Ok(plain); } - let shapes: Vec = st + let mut shapes: Vec = st .selection .shapes .iter() @@ -120,21 +120,28 @@ fn copy_inner(s: &mut Session, p: &Value) -> Result { }) }) .collect(); - // A placeholder pastes on a slide as a regular shape; it keeps the look it inherited here. - let mut shapes = shapes; - if st.selection.target == crate::Target::Slides - && let Some(ctx) = st.current_slide().and_then(|sl| deckcraft_model::resolve::Ctx::for_slide(&st.doc, sl)) - { - shapes.iter_mut().for_each(|sh| deckcraft_model::resolve::detach_placeholder(&ctx, sh)); - } + // Copying is the last chance to read what a placeholder inherits: a cut removes it, and the + // paste can be in another deck. Slide Master view pastes the originals instead. + let placeholders: Vec = shapes.iter().filter(|x| x.ph.is_some()).cloned().collect(); + detach_on_slide(&st.doc, &st.selection, &mut shapes); let mids = crate::media_refs::shapes_media(&shapes); 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::>().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() }; + s.clipboard = crate::Clipboard { shapes, placeholders, media, plain: plain.clone(), source_theme, ..Default::default() }; Ok(plain) } +/// On a slide, a copy of a placeholder is a regular shape: give `shapes` the look they inherit +/// there. In Slide Master view a copy stays a placeholder, so this changes nothing. +pub(crate) fn detach_on_slide(doc: &deckcraft_model::Presentation, sel: &crate::Selection, shapes: &mut [Shape]) { + if sel.target == crate::Target::Slides + && let Some(ctx) = super::text::ctx_for(doc, sel) + { + shapes.iter_mut().for_each(|sh| deckcraft_model::resolve::detach_placeholder(&ctx, sh)); + } +} + fn copying_caret(s: &Session) -> bool { s.active().is_some_and(|d| d.selection.text.as_ref().is_some_and(|t| !t.is_range())) } @@ -326,6 +333,13 @@ fn paste(s: &mut Session, p: &Value) -> Result { return s.edit(|doc, sel| { let media = bring_media("edit.paste", doc, &clip.media)?; let mut shapes = clip.shapes.clone(); + if sel.target != crate::Target::Slides { + for sh in &mut shapes { + if let Some(original) = clip.placeholders.iter().find(|p| p.id == sh.id) { + sh.clone_from(original); + } + } + } reid("edit.paste", doc, &mut shapes)?; crate::media_refs::visit_mut::shapes(&mut shapes, &mut remap_media(&media)); let (mut taken, mut count) = super::current_names(doc, sel); @@ -347,7 +361,8 @@ fn paste(s: &mut Session, p: &Value) -> Result { x.x += off; x.y += off; } - // A pasted placeholder becomes a regular shape; copying gave it its look. + // A placeholder pasted on a slide becomes a regular shape. One copied from a slide + // already has its look; see `copy_inner`. if sh.ph.is_some() && sel.target == crate::Target::Slides { sh.ph = None; } @@ -413,6 +428,7 @@ fn paste_special(s: &mut Session, p: &Value) -> Result { let saved = s.clipboard.clone(); if let Some((scheme, fonts)) = &saved.source_theme { bake_theme(&mut s.clipboard.shapes, scheme, fonts); + bake_theme(&mut s.clipboard.placeholders, scheme, fonts); } let r = paste(s, &json!({})); let pastes = s.clipboard.pastes; @@ -540,12 +556,7 @@ fn duplicate(s: &mut Session, p: &Value) -> Result { let mut copies: Vec = list.iter().filter(|x| ids.contains(&x.id)).cloned().collect(); let sources: Vec = copies.iter().map(|c| c.id).collect(); let boxes: Vec<_> = copies.iter().map(|c| xfrm_of(doc, sel, c)).collect(); - // A duplicated placeholder becomes a regular shape that keeps the look it inherited. - if sel.target == crate::Target::Slides - && let Some(ctx) = doc.slides.get(sel.slide).and_then(|sl| deckcraft_model::resolve::Ctx::for_slide(doc, sl)) - { - copies.iter_mut().for_each(|c| deckcraft_model::resolve::detach_placeholder(&ctx, c)); - } + detach_on_slide(doc, sel, &mut copies); // 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 diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index c6d9f4503..dbd2b68b6 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -457,6 +457,9 @@ pub struct MediaStatus { #[derive(Clone, Debug, Default)] pub struct Clipboard { pub shapes: Vec, + /// The copied placeholders as they were, by id. `shapes` holds a slide's placeholders as + /// regular shapes; a paste in Slide Master view takes these, which stay placeholders. + pub placeholders: Vec, pub slides: Vec, /// Media the copied shapes/slides reference. pub media: Vec, diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index fb7f2b039..05baa3ca3 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -267,6 +267,63 @@ fn a_pasted_or_duplicated_placeholder_keeps_its_inherited_look() { assert!(s.doc().unwrap().doc.validate().is_empty()); } +/// Dragging a copy of a placeholder off it keeps the look too: the title's size and font, the +/// body's bullets. `edit.duplicate` is not the only way to duplicate. +#[test] +fn a_placeholder_duplicated_by_dragging_keeps_its_look() { + let mut s = session(); + s.execute("slide.new", &json!({})).unwrap(); + let phs: Vec = s.doc().unwrap().current_slide().unwrap().shapes.iter().filter(|x| x.ph.is_some()).map(|x| x.id).collect(); + assert!(phs.len() >= 2, "a title and a body placeholder"); + for id in phs { + s.execute("text.set", &json!({"id": id.0, "text": "First\nSecond"})).unwrap(); + let want = resolved_look(&s, id); + s.execute("selection.select", &json!({"id": id.0})).unwrap(); + let st = s.doc().unwrap(); + let x = crate::cmd::xfrm_of(&st.doc, &st.selection, st.shape(id).unwrap()); + let (cx, cy) = (x.x + x.w / 2.0, x.y + x.h / 2.0); + let alt = |kind, x, y| PointerEvent { mods: Mods { alt: true, ..Default::default() }, ..tev(kind, x, y) }; + s.pointer(alt(PointerKind::Down, cx, cy)).unwrap(); + s.pointer(alt(PointerKind::Drag, cx + 30.0, cy + 30.0)).unwrap(); + s.pointer(alt(PointerKind::Up, cx + 30.0, cy + 30.0)).unwrap(); + let copy = s.doc().unwrap().selection.shapes[0]; + assert_ne!(copy, id, "the drag made a copy"); + assert!(s.doc().unwrap().shape(id).unwrap().ph.is_some(), "the original stays a placeholder"); + assert!(s.doc().unwrap().shape(copy).unwrap().ph.is_none(), "the copy is a regular shape"); + assert_eq!(resolved_look(&s, copy), want, "placeholder {}", id.0); + } +} + +/// A slide's placeholder pasted in Slide Master view stays a placeholder that inherits from where +/// it lands; only a paste on a slide turns it into a regular shape with the look it had. +#[test] +fn a_slide_placeholder_pasted_in_slide_master_view_stays_a_placeholder() { + let mut s = session(); + s.execute("slide.new", &json!({})).unwrap(); + let title = s.doc().unwrap().current_slide().unwrap().shapes.iter().find(|x| x.ph.is_some()).unwrap().id; + s.execute("text.set", &json!({"id": title.0, "text": "Title"})).unwrap(); + let want = resolved_look(&s, title); + let source = s.doc().unwrap().shape(title).unwrap().clone(); + s.execute("selection.select", &json!({"id": title.0})).unwrap(); + s.execute("edit.copy", &json!({})).unwrap(); + + s.execute("view.slideMaster", &json!({})).unwrap(); + for paste in ["edit.paste", "edit.pasteSpecial"] { + s.execute(paste, &json!({"as": "keepSource"})).unwrap(); + let st = s.doc().unwrap(); + let copy = st.shape(st.selection.shapes[0]).unwrap(); + assert_eq!(copy.ph.as_ref().map(|p| p.kind), source.ph.as_ref().map(|p| p.kind), "{paste} keeps the placeholder"); + let list = copy.text.as_ref().map(|t| t.list_style.clone()); + assert_eq!(list, source.text.as_ref().map(|t| t.list_style.clone()), "{paste} adds no baked styles"); + } + s.execute("view.closeMaster", &json!({})).unwrap(); + + s.execute("edit.paste", &json!({})).unwrap(); + let copy = s.doc().unwrap().selection.shapes[0]; + assert!(s.doc().unwrap().shape(copy).unwrap().ph.is_none(), "on a slide the copy is a regular shape"); + assert_eq!(resolved_look(&s, copy), want); +} + /// The resolved fill, line and effects of shape `id` on the current slide, with their style colours. fn resolved_box(s: &Session, id: ShapeId) -> String { use deckcraft_model::resolve; diff --git a/crates/engine/src/tools/geometry.rs b/crates/engine/src/tools/geometry.rs index cbbd01275..0da6aa60c 100644 --- a/crates/engine/src/tools/geometry.rs +++ b/crates/engine/src/tools/geometry.rs @@ -101,6 +101,7 @@ impl Session { let list = cmd::current_shapes(doc, sel).cloned().unwrap_or_default(); let mut copies: Vec = list.iter().filter(|x| ids.contains(&x.id)).cloned().collect(); let boxes: Vec = copies.iter().map(|c| xfrm_of(doc, sel, c)).collect(); + cmd::edit::detach_on_slide(doc, sel, &mut copies); cmd::edit::reid("edit.duplicate", doc, &mut copies)?; for (c, b) in copies.iter_mut().zip(boxes) { c.xfrm = Some(b);