diff --git a/ROADMAP.md b/ROADMAP.md index 8c14dec92..daf3eb257 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -147,6 +147,7 @@ Newest first. - **2026-10-14** · Pages panel: Alt-drag copies a page, a page dropped on Create new page is duplicated at the end, Edit page size offers the presets and Custom Page Size…; duplicated pages keep their own size, rule and guides; thumbnails of pages of their own size stay in their rows. No readiness percentage change. - **2026-10-12 (PDFW)** · PDF, booklet and book composition share guarded writing, metadata and font preparation through craft-pdf. Layout, shaping, placed graphics, colour policy, tagging and PDF/X postprocessing remain in DesignCraft. Failed native drawing returns an export error; failed writer cleanup cannot serialize an incomplete document. No readiness percentage change. +- **2026-10-13** · Copy with a placed graphic selected (Direct Selection) copies just the graphic, uncropped, where it sits on the page and without the frame's stroke, so Paste Into puts the whole image in place; Cut and Delete then remove only the graphic and leave its frame empty. Nested selections copy in their spread coordinates. No readiness percentage change. - **2026-10-12** · Text threads by hand: click a selected text frame's in or out port to load the text cursor, then click a frame to thread it, click empty space for a column-wide frame or drag one (it snaps like frame drawing); Shift-click autoflows, Alt-click stays loaded on the diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index e3c985406..57de36146 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -257,11 +257,20 @@ pub fn specs() -> Vec { return super::text::delete_selection(s); } let ids = super::targets(s, p)?; + // With a placed graphic itself selected (Direct Selection), only the graphic goes: its + // frame stays, empty and selected (as in InDesign). Other items are deleted. + let content = s.doc()?.selection.content && p.get("ids").is_none(); s.edit(|d, sel| { + let mut kept = Vec::new(); for id in &ids { - let _ = d.remove_item(*id); + if let Some(it) = d.item_mut(*id).filter(|it| content && matches!(it.content, designcraft_doc::Content::Graphic(_))) { + it.content = designcraft_doc::Content::Unassigned; + kept.push(*id); + } else { + let _ = d.remove_item(*id); + } } - *sel = Selection::default(); + *sel = Selection::items(kept); Ok(json!({"deleted": ids.len()})) }) }), @@ -341,12 +350,15 @@ pub(crate) fn clip_doc(s: &Session) -> Result { let st = s.doc()?; let mut d = (*st.doc).clone(); let keep: Vec = st.selection.items.clone(); - // Gather the selected items (keep their spread coordinates) into spread 0. + // Gather the selected items (in their spread coordinates, nested ones too) into spread 0. let mut items = Vec::new(); for id in &keep { - if let Some(it) = st.doc.item(*id) { - items.push(Arc::new(it.clone())); - } + let (Some(it), Some(loc)) = (st.doc.item(*id), st.doc.find(*id)) else { continue }; + let parent = st.doc.parent_xf(&loc); + items.push(Arc::new(match &it.content { + designcraft_doc::Content::Graphic(g) if st.selection.content => graphic_alone(it, g, parent), + _ => designcraft_doc::Item { xf: parent * it.xf, ..it.clone() }, + })); } for sp in d.spreads.iter_mut() { Arc::make_mut(sp).items.clear(); @@ -382,6 +394,22 @@ pub(crate) fn clip_doc(s: &Session) -> Result { Ok(d) } +/// A placed graphic on its own, as InDesign copies one selected with Direct Selection: a frame of +/// the whole graphic's bounds (uncropped), where the graphic sits on the spread (`parent` is the +/// frame's parent transform), without the frame's fill, stroke or effects. +fn graphic_alone(frame: &designcraft_doc::Item, g: &designcraft_doc::Graphic, parent: designcraft_geom::Affine) -> designcraft_doc::Item { + let r = designcraft_geom::Rect::new(0.0, 0.0, g.size.0, g.size.1); + let mut it = designcraft_doc::Item::new(frame.id, frame.layer, designcraft_doc::Shape::Rectangle, designcraft_geom::shapes::rectangle(r)); + it.xf = parent * frame.xf * g.xf; + it.content = designcraft_doc::Content::Graphic(designcraft_doc::Graphic { + xf: designcraft_geom::Affine::IDENTITY, + auto_fit: designcraft_doc::Fitting::None, + crop: [0.0; 4], + ..g.clone() + }); + it +} + #[cfg(test)] mod text_clipboard_tests { use super::*; @@ -728,4 +756,44 @@ mod paste_into_tests { assert!(img.pixel(120, 120)[0] > 200, "{:?}", img.pixel(120, 120)); s.doc().unwrap().doc.check().unwrap(); } + + #[test] + fn a_selected_graphic_copies_and_clears_on_its_own() { + let dir = std::env::temp_dir().join(format!("dc-graphic-alone-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let png = dir.join("photo.png"); + std::fs::write(&png, designcraft_render::Rendered { width: 200, height: 100, pixels: vec![40u8; 200 * 100 * 4] }.to_png()).unwrap(); + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + // A square frame showing the middle of a wide photo (cropped left and right). + let frame = s.execute("frame.create", &json!({"rect": [100, 100, 200, 200]})).unwrap()["id"].as_u64().unwrap(); + s.execute("file.place", &json!({"path": png.to_string_lossy(), "frame": frame})).unwrap(); + s.execute("object.fit", &json!({"mode": "fillProportionally", "ids": [frame]})).unwrap(); + s.execute("object.stroke", &json!({"ids": [frame], "swatch": "[Black]", "weight": 4})).unwrap(); + // Direct Selection on the photo, Copy: the whole photo, where it is, without the frame's stroke. + s.execute("selection.set", &json!({"ids": [frame], "content": true})).unwrap(); + s.execute("edit.copy", &json!({})).unwrap(); + let clip = s.clipboard.clone().unwrap(); + let it = &clip.spreads[0].items[0]; + assert!(matches!(&it.content, designcraft_doc::Content::Graphic(g) if g.crop == [0.0; 4])); + assert_eq!(it.bounds(), designcraft_geom::Rect::new(50.0, 100.0, 250.0, 200.0)); + assert!(it.stroke.weight == 0.0 || it.stroke.swatch == "[None]", "{:?}", it.stroke); + // Paste Into a shape over it: the photo lands in place, inside the shape. + let shape = s.execute("frame.create", &json!({"rect": [60, 90, 240, 210], "shape": "ellipse"})).unwrap()["id"].as_u64().unwrap(); + s.execute("selection.set", &json!({"ids": [shape]})).unwrap(); + s.execute("edit.pasteInto", &json!({})).unwrap(); + let d = s.doc().unwrap().doc.clone(); + let kid = d.item(ItemId(shape)).unwrap().children()[0].clone(); + let at = d.parent_xf(&d.find(kid.id).unwrap()) * kid.xf; + assert!((at * designcraft_geom::Point::new(0.0, 0.0) - designcraft_geom::Point::new(50.0, 100.0)).hypot() < 1e-6); + assert!((at * designcraft_geom::Point::new(200.0, 100.0) - designcraft_geom::Point::new(250.0, 200.0)).hypot() < 1e-6); + // Delete with the photo selected: the frame stays, empty. + s.execute("selection.set", &json!({"ids": [frame], "content": true})).unwrap(); + assert_eq!(s.execute("edit.clear", &json!({})).unwrap()["deleted"], 1); + let d = s.doc().unwrap().doc.clone(); + assert!(matches!(d.item(ItemId(frame)).unwrap().content, designcraft_doc::Content::Unassigned)); + assert_eq!(s.doc().unwrap().selection, Selection::items(vec![ItemId(frame)])); + d.check().unwrap(); + let _ = std::fs::remove_dir_all(&dir); + } }