mirror of
https://github.com/storytold/wordcraft.git
synced 2026-10-28 20:37:10 -06:00
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:
co-authored by
Brandon Thomas
parent
d1064bbaad
commit
0652e7f72f
@@ -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]
|
||||
|
||||
Reference in New Issue
Block a user