mirror of
https://github.com/storytold/designcraft.git
synced 2026-11-05 18:19:57 +00:00
Frames holding pasted-in images are hit inside the frame, take a placed graphic; documents open on a usable layer
A frame with items pasted into it (how a document from InDesign keeps a cropped photo) was hit through its children like a group, so the whole uncropped image counted: a click on the empty page beside such a photo selected it, and nothing could be deselected there. It is now hit by its own area, as it is drawn clipped. Such a frame holding only graphics also takes a placed or dropped graphic (Item::takes_graphic), which fills it as an empty or graphic frame is filled. A document opened with new objects going to its top layer even when that layer was hidden and locked (a dieline), so a placed graphic showed as an empty frame outline. It now opens on the topmost layer that is shown and unlocked (Document::working_layer).
This commit is contained in:
1 parent
f1bdec7fee
commit
5364366074
6 files changed
+150
-9
No files matched your search
+65
-2
@@ -868,13 +868,20 @@ fn story_has_hit(st: &Story, depth: usize, hit: &impl Fn(&Item) -> bool) -> bool
|
||||
|
||||
/// Does `p` (in the item's parent space) hit `it`?
|
||||
pub fn item_hit(it: &Item, p: Point, tol: f64) -> bool {
|
||||
if let Content::Group { items } = &it.content {
|
||||
// A group is hit through its children. A frame with items pasted into it shows them clipped
|
||||
// to its path, so it is hit by its own area: not where a nested image runs past the frame.
|
||||
if let Content::Group { items } = &it.content
|
||||
&& !it.has_nested_items()
|
||||
{
|
||||
let inner = it.xf.inverse() * p;
|
||||
return items.iter().any(|c| item_hit(c, inner, tol));
|
||||
}
|
||||
let inner = it.xf.inverse() * p;
|
||||
let bp = it.path.to_bezpath();
|
||||
let area = !it.fill.is_none() || matches!(it.content, Content::Text(_) | Content::Graphic(_)) || it.path.is_closed() && it.shape != Shape::Path;
|
||||
let area = !it.fill.is_none()
|
||||
|| matches!(it.content, Content::Text(_) | Content::Graphic(_))
|
||||
|| it.has_nested_items()
|
||||
|| it.path.is_closed() && it.shape != Shape::Path;
|
||||
if area && it.path.is_closed() && designcraft_geom::hit::fill_contains(&bp, designcraft_geom::FillRule::NonZero, inner) {
|
||||
return true;
|
||||
}
|
||||
@@ -914,6 +921,62 @@ const PATH_TYPE_REACH: f64 = 12.0;
|
||||
mod placed_item_tests {
|
||||
use super::*;
|
||||
|
||||
/// A frame at (100, 100)–(200, 200) holding an image pasted into it that runs 150 pt past it on
|
||||
/// every side, as a document from InDesign keeps a cropped photo.
|
||||
fn frame_with_nested_image() -> Item {
|
||||
let layer = LayerId(1);
|
||||
let mut image = Item::new(ItemId(2), layer, Shape::Rectangle, shapes::rectangle(Rect::new(0.0, 0.0, 400.0, 400.0)));
|
||||
image.xf = Affine::translate((-150.0, -150.0));
|
||||
image.content = Content::Graphic(crate::Graphic {
|
||||
layer_options: None,
|
||||
asset: crate::AssetId(1),
|
||||
size: (400.0, 400.0),
|
||||
xf: Affine::IDENTITY,
|
||||
clip: None,
|
||||
auto_fit: Default::default(),
|
||||
fit_align: 4,
|
||||
crop: [0.0; 4],
|
||||
wrap: Default::default(),
|
||||
fill: Default::default(),
|
||||
});
|
||||
let mut frame = Item::new(ItemId(1), layer, Shape::Rectangle, shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)));
|
||||
frame.xf = Affine::translate((100.0, 100.0));
|
||||
frame.content = Content::Group { items: vec![Arc::new(image)] };
|
||||
frame
|
||||
}
|
||||
|
||||
/// The frame shows its nested image clipped, so a click where the image runs past the frame
|
||||
/// hits nothing: clicking the empty page beside such a photo used to select it, so it could
|
||||
/// not be deselected.
|
||||
#[test]
|
||||
fn a_frame_with_a_nested_image_is_hit_only_inside_the_frame() {
|
||||
let frame = frame_with_nested_image();
|
||||
assert!(frame.has_nested_items());
|
||||
assert!(item_hit(&frame, Point::new(150.0, 150.0), 1.0), "inside the frame");
|
||||
assert!(item_hit(&frame, Point::new(100.0, 150.0), 1.0), "on its edge");
|
||||
for p in [Point::new(60.0, 60.0), Point::new(300.0, 150.0), Point::new(150.0, 330.0)] {
|
||||
assert!(!item_hit(&frame, p, 1.0), "{p:?} is outside the frame, over the cropped image");
|
||||
}
|
||||
// A group is still hit through its children.
|
||||
let mut group = frame.clone();
|
||||
group.shape = Shape::Group;
|
||||
assert!(item_hit(&group, Point::new(60.0, 60.0), 1.0));
|
||||
}
|
||||
|
||||
/// Placing or dropping a graphic fills a frame holding only nested graphics, not one holding
|
||||
/// other objects.
|
||||
#[test]
|
||||
fn a_frame_with_only_nested_graphics_takes_a_graphic() {
|
||||
let frame = frame_with_nested_image();
|
||||
assert!(frame.takes_graphic());
|
||||
let mut other = frame.clone();
|
||||
other.content = Content::Group { items: vec![Arc::new(Item::new(ItemId(3), LayerId(1), Shape::Rectangle, Default::default()))] };
|
||||
assert!(!other.takes_graphic());
|
||||
let mut group = frame;
|
||||
group.shape = Shape::Group;
|
||||
assert!(!group.takes_graphic(), "a group of images is not a frame");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exhaustive_placements_keep_unselected_branches_shared() {
|
||||
let mut d = Document::new(&Default::default());
|
||||
|
||||
@@ -282,6 +282,16 @@ impl Item {
|
||||
self.shape != Shape::Group && matches!(&self.content, Content::Group { items } if !items.is_empty())
|
||||
}
|
||||
|
||||
/// A frame a placed or dropped graphic fills: an empty one, one holding a graphic, or one
|
||||
/// holding only graphics pasted into it (as a document from InDesign keeps a frame's image).
|
||||
pub fn takes_graphic(&self) -> bool {
|
||||
match &self.content {
|
||||
Content::Unassigned | Content::Graphic(_) => true,
|
||||
Content::Group { items } => self.has_nested_items() && items.iter().all(|c| matches!(c.content, Content::Graphic(_))),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn bounds(&self) -> Rect {
|
||||
self.bounds_under(Affine::IDENTITY).unwrap_or(Rect::ZERO)
|
||||
}
|
||||
|
||||
@@ -946,6 +946,12 @@ impl Document {
|
||||
pub fn default_layer(&self) -> LayerId {
|
||||
self.layers.first().map(|l| l.id).unwrap_or_default()
|
||||
}
|
||||
/// The layer new objects go to when a document opens: the topmost one shown and unlocked, so
|
||||
/// a document whose top layer is a hidden, locked dieline doesn't put what is placed or drawn
|
||||
/// where it can't be seen or selected. The top layer when every layer is hidden or locked.
|
||||
pub fn working_layer(&self) -> LayerId {
|
||||
self.layers.iter().find(|l| l.visible && !l.locked).map_or_else(|| self.default_layer(), |l| l.id)
|
||||
}
|
||||
|
||||
pub fn story(&self, id: StoryId) -> Option<&Story> {
|
||||
self.stories.get(&id).map(|s| &**s)
|
||||
|
||||
@@ -99,7 +99,7 @@ pub(super) fn file_place(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
return None;
|
||||
}
|
||||
let st = s.active()?;
|
||||
st.selection.items.iter().copied().find(|i| st.doc.item(*i).is_some_and(|it| matches!(it.content, Content::Unassigned | Content::Graphic(_))))
|
||||
st.selection.items.iter().copied().find(|i| st.doc.item(*i).is_some_and(|it| it.takes_graphic()))
|
||||
});
|
||||
// `"spread": null` counts as omitted, like the other placement parameters.
|
||||
let spread = match p.get("spread") {
|
||||
@@ -756,3 +756,68 @@ mod layout_place_tests {
|
||||
assert!(b.execute("file.place", &json!({"base64": b64, "name": "src.idml", "layoutPage": 2})).is_err(), "page 2 is empty");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod nested_frame_tests {
|
||||
use std::sync::Arc;
|
||||
|
||||
use designcraft_doc::{Content, Item, ItemId, Shape, SpreadRef};
|
||||
use designcraft_geom::{Affine, Rect, shapes};
|
||||
use serde_json::json;
|
||||
|
||||
use crate::Session;
|
||||
|
||||
fn png(red: u8) -> String {
|
||||
let png = designcraft_render::Rendered { width: 40, height: 20, pixels: [red, 90, 30, 255].repeat(800) }.to_png();
|
||||
super::super::base64_encode(&png)
|
||||
}
|
||||
|
||||
/// A graphic placed into a frame holding an image pasted into it (how a document from InDesign
|
||||
/// keeps a photo) replaces the image and fills the frame, which keeps its size.
|
||||
#[test]
|
||||
fn placing_into_a_frame_with_a_nested_image_replaces_it() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({})).unwrap();
|
||||
let first = s.execute("file.place", &json!({"base64": png(200), "name": "old.png", "x": 0, "y": 0})).unwrap();
|
||||
let old = ItemId(first["id"].as_u64().unwrap());
|
||||
let frame = s
|
||||
.edit(|d, _| {
|
||||
let mut image = (*d.item(old).unwrap()).clone();
|
||||
d.remove_item(old)?;
|
||||
image.xf = Affine::translate((-20.0, -20.0));
|
||||
let id = ItemId(d.alloc());
|
||||
let mut frame = Item::new(id, image.layer, Shape::Rectangle, shapes::rectangle(Rect::new(0.0, 0.0, 60.0, 90.0)));
|
||||
frame.xf = Affine::translate((100.0, 100.0));
|
||||
frame.content = Content::Group { items: vec![Arc::new(image)] };
|
||||
d.insert_item(SpreadRef::Doc(0), frame, None)?;
|
||||
Ok(id)
|
||||
})
|
||||
.unwrap();
|
||||
s.execute("selection.set", &json!({"ids": [frame.0]})).unwrap();
|
||||
s.execute("file.place", &json!({"base64": png(20), "name": "new.png"})).unwrap();
|
||||
let d = &s.doc().unwrap().doc;
|
||||
let it = d.item(frame).unwrap();
|
||||
let Content::Graphic(g) = &it.content else { panic!("the frame holds the new graphic: {:?}", it.content) };
|
||||
assert_eq!(d.assets[&g.asset].name, "new.png");
|
||||
assert_eq!(it.inner_bounds(), Rect::new(0.0, 0.0, 60.0, 90.0), "the frame keeps its size");
|
||||
assert_eq!(d.spreads[0].items.len(), 1, "no new object");
|
||||
}
|
||||
|
||||
/// A document whose top layer is hidden and locked (a dieline) opens with new objects going to
|
||||
/// the topmost layer that is shown and unlocked, not where they couldn't be seen or selected.
|
||||
#[test]
|
||||
fn a_document_opens_on_its_topmost_visible_unlocked_layer() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({})).unwrap();
|
||||
s.execute("layer.new", &json!({"name": "Artwork"})).unwrap();
|
||||
s.execute("layer.new", &json!({"name": "Dieline"})).unwrap();
|
||||
let mut d = (*s.doc().unwrap().doc).clone();
|
||||
let top = d.layers.first().unwrap().id;
|
||||
d.layers[0].visible = false;
|
||||
d.layers[0].locked = true;
|
||||
let artwork = d.layers[1].id;
|
||||
assert_eq!(d.default_layer(), top);
|
||||
let st = crate::DocState::new(d, None);
|
||||
assert_eq!(st.active_layer, artwork);
|
||||
}
|
||||
}
|
||||
@@ -147,7 +147,7 @@ fn next_revision() -> u64 {
|
||||
impl DocState {
|
||||
/// Initialize saved/live document identity, revision, epoch, active default layer, empty history with limit 1,000 and the initial path.
|
||||
pub fn new(doc: Document, path: Option<String>) -> Self {
|
||||
let active_layer = doc.default_layer();
|
||||
let active_layer = doc.working_layer();
|
||||
let doc = Arc::new(doc);
|
||||
let revision = next_revision();
|
||||
let identity_epoch = NEXT_UID.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
||||
@@ -639,7 +639,7 @@ impl Session {
|
||||
if let Some(st) = self.active_mut()
|
||||
&& st.doc.layer(st.active_layer).is_none()
|
||||
{
|
||||
st.active_layer = st.doc.default_layer();
|
||||
st.active_layer = st.doc.working_layer();
|
||||
}
|
||||
self.record_transform(id, params);
|
||||
if self.prefs.smart_text_reflow && spec.undoable {
|
||||
|
||||
@@ -136,10 +136,7 @@ impl Tool for PlaceGun {
|
||||
let click = (ev.pos - a).hypot() < cx.tol(3.0);
|
||||
if click
|
||||
&& let Some((_, id)) = cx.hit(a)
|
||||
&& cx
|
||||
.doc
|
||||
.item(id)
|
||||
.is_some_and(|i| matches!(i.content, designcraft_doc::Content::Unassigned | designcraft_doc::Content::Graphic(_)))
|
||||
&& cx.doc.item(id).is_some_and(designcraft_doc::Item::takes_graphic)
|
||||
{
|
||||
return vec![Action::Exec("place.drop".into(), json!({"frame": id.0}))];
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user