Alt+drag detaches placeholders; Slide Master view pastes them as placeholders

Review follow-up on #1658:

- The drag duplicate cleared `ph` without detaching, so a title or body
  dragged off with Alt lost its size and bullets. Copy, Duplicate and the
  drag now share `detach_on_slide`.
- Detaching at copy time turned a slide placeholder pasted in Slide Master
  view into a regular shape. The clipboard keeps the originals, and a paste
  there takes them, so it stays a placeholder as before this PR. Keep
  Source Formatting bakes them too.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Stephan Schott-VerdugoandClaude Opus 5.5 committed 2026-11-04 21:43:33 +01:00
1 parent 077cd36e9f
commit d6cc51e07a
5 files changed
+89 -17

No files matched your search

+1 -1
View File
@@ -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 |
+27 -16
View File
@@ -107,7 +107,7 @@ fn copy_inner(s: &mut Session, p: &Value) -> Result<String> {
s.clipboard = crate::Clipboard { text: Some(TextBody::from_text(&plain)), plain: plain.clone(), ..Default::default() };
return Ok(plain);
}
let shapes: Vec<Shape> = st
let mut shapes: Vec<Shape> = st
.selection
.shapes
.iter()
@@ -120,21 +120,28 @@ fn copy_inner(s: &mut Session, p: &Value) -> Result<String> {
})
})
.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<Shape> = 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::<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() };
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<Value> {
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<Value> {
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<Value> {
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<Value> {
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();
// 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
+3
View File
@@ -457,6 +457,9 @@ pub struct MediaStatus {
#[derive(Clone, Debug, Default)]
pub struct Clipboard {
pub shapes: Vec<Shape>,
/// 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<Shape>,
pub slides: Vec<Slide>,
/// Media the copied shapes/slides reference.
pub media: Vec<deckcraft_model::MediaItem>,
+57
View File
@@ -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<ShapeId> = 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;
+1
View File
@@ -101,6 +101,7 @@ impl Session {
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::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);