Pictures in headers/footers can be moved, resized and rotated (#591)

* Pictures in headers/footers can be moved, resized and rotated

Hit testing (layout.object_at_in), arrange.bounds/position/nudge/group and the canvas selection, drag, resize and rotation handles used to be body-only, so pictures in headers and footers could not be moved (#82). They now work within their own header/footer story: drag to move, handles to resize, rotation handle to turn, arrows to nudge. Regression test: header_pictures_can_be_resized_and_dragged.

* Credit DevCoderMax (Gleyson do Nascimento Carvalho) in About

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
This commit is contained in:
DevCoderMax
2026-10-11 22:27:44 -04:00
committed by GitHub
co-authored by Brandon Thomas
parent d1064bbaad
commit 0652e7f72f
6 changed files with 162 additions and 50 deletions
+70 -25
View File
@@ -28,6 +28,16 @@ fn has_floating(s: &Session) -> Option<&'static str> {
_ => Some("select a floating picture or shape first"),
}
}
/// Stories whose objects can be moved, resized and arranged: the body and headers/footers.
/// Objects in table cells (deeper paths), notes and text boxes stay where their text is.
fn movable_story(s: &Session, story: StoryRef) -> bool {
match story {
StoryRef::Body => true,
StoryRef::Part(id) => {
s.doc.parts.get(&id).is_some_and(|p| matches!(p.kind, wordcraft_doc::PartKind::Header | wordcraft_doc::PartKind::Footer))
}
}
}
/// Charts and diagrams are not written back on save yet, so their size, position and wrapping stay
/// as imported: they can be selected and deleted, not moved, resized or arranged.
const FROZEN: &str = "charts and diagrams can't be moved or resized yet";
@@ -688,9 +698,10 @@ fn bounds(s: &mut Session, v: &Value) -> CmdResult {
/// position afterwards.
fn move_object(s: &mut Session, pos: Pos, page: Option<u64>, x: f32, y: f32, (w, h): (f32, f32)) -> Result<Pos, CmdError> {
use wordcraft_doc::para::Anchor;
if pos.story != StoryRef::Body || pos.path.depth() > 0 {
return Err(CmdError::Disabled("only objects in the body text can be moved".into()));
if pos.path.depth() > 0 || !movable_story(s, pos.story) {
return Err(CmdError::Disabled("only objects in the body text, headers and footers can be moved".into()));
}
let story = pos.story;
let layout = s.layout();
let cur = layout.object(&pos, s.page_hint).ok_or_else(|| CmdError::Failed("the object isn't laid out".into()))?;
let page = page.map_or(cur.page, |n| usize::try_from(n).unwrap_or(usize::MAX));
@@ -698,19 +709,19 @@ fn move_object(s: &mut Session, pos: Pos, page: Option<u64>, x: f32, y: f32, (w,
// Keep a corner of it on the page.
let x = x.max(12.0 - w).min(pg.w - 12.0);
let y = y.max(12.0 - h).min(pg.h.min(MAX_OFFSET) - 12.0);
let (anchor, on_page) = match anchor_paragraph(&layout, page, y) {
Some(path) => (Pos { story: StoryRef::Body, path, off: 0 }, false),
None => (nearest_line(&layout, page, y).ok_or_else(|| CmdError::Params("no text on that page to anchor to".into()))?, true),
let (anchor, on_page) = match anchor_paragraph(&layout, page, y, story) {
Some(path) => (Pos { story, path, off: 0 }, false),
None => (nearest_line(&layout, page, y, story).ok_or_else(|| CmdError::Params("no text on that page to anchor to".into()))?, true),
};
// Move its character to the anchor (unless it's already in that paragraph).
let at = if anchor.path == pos.path && !on_page {
let at = if anchor.story == pos.story && anchor.path == pos.path && !on_page {
pos
} else {
let obj = s.doc.para_at(&pos).and_then(|p| p.object_at(pos.off)).cloned().ok_or_else(|| CmdError::Failed("object vanished".into()))?;
let len = wordcraft_doc::para::OBJ.len_utf8();
s.doc.delete_range(&pos, &Pos { off: pos.off + len, ..pos.clone() })?;
let mut at = anchor;
if at.path == pos.path && at.off > pos.off {
if at.story == pos.story && at.path == pos.path && at.off > pos.off {
at.off = at.off.saturating_sub(len);
}
let at = s.doc.clamp(&at);
@@ -798,31 +809,33 @@ fn move_with_text(s: &mut Session, with_text: bool) -> CmdResult {
Ok(json!({"object": serde_json::to_value(o).unwrap_or(Value::Null)}))
}
/// The top-level body paragraph under `y` on `page`: the last one starting at or above it (else
/// the first starting on the page).
pub(crate) fn anchor_paragraph(layout: &wordcraft_layout::DocLayout, page: usize, y: f32) -> Option<wordcraft_doc::Path> {
let starts: Vec<(&wordcraft_doc::Path, f32)> = layout
.pages
.get(page)?
.items
.iter()
/// The top-level paragraph of `story` under `y` on `page`: the last one starting at or above
/// it (else the first starting on the page). Body paragraphs come from the page's items;
/// header/footer ones from its header and footer.
pub(crate) fn anchor_paragraph(layout: &wordcraft_layout::DocLayout, page: usize, y: f32, story: StoryRef) -> Option<wordcraft_doc::Path> {
let pg = layout.pages.get(page)?;
let items: Box<dyn Iterator<Item = &wordcraft_layout::Placed> + '_> = match story {
StoryRef::Body => Box::new(pg.items.iter()),
StoryRef::Part(_) => Box::new(pg.header.iter().chain(pg.footer.iter())),
};
let starts: Vec<(&wordcraft_doc::Path, f32)> = items
.filter_map(|it| match it {
wordcraft_layout::Placed::Lines { story: StoryRef::Body, path, l0: 0, y: top, .. } if path.depth() == 0 => Some((path, *top)),
wordcraft_layout::Placed::Lines { story: s, path, l0: 0, y: top, .. } if *s == story && path.depth() == 0 => Some((path, *top)),
_ => None,
})
.collect();
starts.iter().rev().find(|(_, top)| *top <= y + 0.5).or(starts.first()).map(|(p, _)| (*p).clone())
}
/// The start of the top-level body line on `page` nearest to `y`.
pub(crate) fn nearest_line(layout: &wordcraft_layout::DocLayout, page: usize, y: f32) -> Option<Pos> {
let lines = wordcraft_layout::hit::page_lines(layout.pages.get(page)?, StoryRef::Body);
/// The start of the top-level line of `story` on `page` nearest to `y`.
pub(crate) fn nearest_line(layout: &wordcraft_layout::DocLayout, page: usize, y: f32, story: StoryRef) -> Option<Pos> {
let lines = wordcraft_layout::hit::page_lines(layout.pages.get(page)?, story);
let best = lines.iter().filter(|l| l.path.depth() == 0).min_by(|a, b| {
let d = |l: &wordcraft_layout::hit::LineHit| if y < l.top { l.top - y } else { (y - l.bottom).max(0.0) };
d(a).total_cmp(&d(b))
})?;
let off = best.para.lines.get(best.li)?.start;
Some(Pos { story: StoryRef::Body, path: best.path.clone(), off })
Some(Pos { story, path: best.path.clone(), off })
}
fn obj_size(o: &InlineObject) -> (f32, f32) {
@@ -1103,7 +1116,7 @@ fn object_ref(s: &Session, v: &Value) -> Result<Pos, CmdError> {
Some(n) => all_objects(s).into_iter().nth(usize::try_from(n).unwrap_or(usize::MAX)).map(|(p, _)| p),
None => super::parse_pos(v),
};
pos.filter(|p| p.story == StoryRef::Body && s.doc.para_at(p).and_then(|q| q.object_at(p.off)).is_some_and(InlineObject::is_drawing))
pos.filter(|p| movable_story(s, p.story) && s.doc.para_at(p).and_then(|q| q.object_at(p.off)).is_some_and(InlineObject::is_drawing))
.ok_or_else(|| CmdError::Params("no picture or shape there".into()))
}
@@ -1155,9 +1168,13 @@ fn group(s: &mut Session, _: &Value) -> CmdResult {
picks.sort();
let layout = s.layout();
let mut found = Vec::new();
let story = picks.first().cloned().map(|p| p.story).ok_or_else(|| CmdError::Failed("nothing to group".into()))?;
if picks.iter().any(|p| p.story != story) {
return Err(CmdError::Disabled("objects in different stories (body, header, footer) can't be grouped".into()));
}
for pos in &picks {
if pos.story != StoryRef::Body || pos.path.depth() > 0 {
return Err(CmdError::Disabled("only objects in the body text (not in tables) can be grouped".into()));
if pos.path.depth() > 0 || !movable_story(s, pos.story) {
return Err(CmdError::Disabled("only objects in the body text, headers and footers (not in tables) can be grouped".into()));
}
let obj = s.doc.para_at(pos).and_then(|p| p.object_at(pos.off)).cloned().ok_or_else(|| CmdError::Failed("object vanished".into()))?;
if matches!(obj, InlineObject::Graphic { .. }) {
@@ -1241,8 +1258,8 @@ fn ungroup(s: &mut Session, _: &Value) -> CmdResult {
let InlineObject::Group { w, h, float, .. } = &obj else { return Err(CmdError::Disabled("select a group first".into())) };
// Its offsets, explicit: the members are placed from the same anchor.
let pos = if float.wrap == Wrap::Inline {
if pos.story != StoryRef::Body || pos.path.depth() > 0 {
return Err(CmdError::Disabled("only groups in the body text can be ungrouped in line with text".into()));
if pos.path.depth() > 0 || !movable_story(s, pos.story) {
return Err(CmdError::Disabled("only groups in the body text, headers and footers can be ungrouped in line with text".into()));
}
let hit = s.layout().object(&pos, s.page_hint).ok_or_else(|| CmdError::Failed("the object isn't laid out".into()))?;
move_object(s, pos, Some(hit.page as u64), hit.rect.x, hit.rect.y, (*w, *h))?
@@ -1698,6 +1715,34 @@ mod tests {
assert_eq!(crop, [0.1, 0.2, 0.05, 0.0]);
}
/// A header picture resizes and drags like a body one: the move stays in the header story
/// and lands where dropped; the header hit test finds it.
#[test]
fn header_pictures_can_be_resized_and_dragged() {
let mut s = Session::new(wordcraft_doc::Document::new());
s.run("insert.editHeader", &json!({})).unwrap();
let story = s.sel.focus.story;
assert!(matches!(story, StoryRef::Part(_)), "editing the header, got {story:?}");
let data = super::super::insert::base64_encode(&png(20, 10));
s.run("insert.picture", &json!({"data": data, "width": 100})).unwrap();
let (pos, _) = selected(&s).expect("a new header picture is selected");
assert_eq!(pos.story, story);
s.run("arrange.bounds", &json!({"width": 160, "height": 90})).unwrap();
let r = s.run("arrange.bounds", &json!({"page": 0, "x": 200, "y": 60})).unwrap();
assert_eq!(r["object"]["float"]["wrap"], "square");
let o = s.layout().object(&selected(&s).unwrap().0, 0).expect("laid out");
assert_eq!(o.story, story);
assert!((o.rect.x - 200.0).abs() < 1.0 && (o.rect.y - 60.0).abs() < 1.0, "{:?}", o.rect);
let hit = s.layout().object_at_in(o.page, o.rect.x + 5.0, o.rect.y + 5.0, 4.0, story).expect("header hit");
assert_eq!(hit.pos(), o.pos());
s.run("arrange.nudge", &json!({"dx": 6, "dy": 4})).unwrap();
let n = s.layout().object(&selected(&s).unwrap().0, 0).expect("laid out");
assert!((n.rect.x - 206.0).abs() < 1.0 && (n.rect.y - 64.0).abs() < 1.0, "{:?}", n.rect);
s.run("edit.undo", &json!({})).unwrap();
let u = s.layout().object(&selected(&s).unwrap().0, 0).expect("laid out");
assert!((u.rect.x - 200.0).abs() < 1.0 && (u.rect.y - 60.0).abs() < 1.0, "{:?}", u.rect);
}
/// Shape Format › Text (#369): direction and alignment on the box the caret is in, then
/// Create Link to an empty box and Break Link, each one undo step.
#[test]