Objects: anchor modes — images/charts/shapes follow cells on sort (#5)

Excel anchors a floating object to the grid in one of three ways, stored in the
file as the drawing anchor's `editAs`: "move and size with cells" (twoCellAnchor,
the default), "move but don't size with cells" (oneCellAnchor / editAs="oneCell")
and "don't move or size with cells" (editAs="absolute"). GridCraft had no such
concept: xlsx import dropped `editAs`, export always wrote a plain twoCellAnchor,
and sorting/filtering never moved an object — so pictures stayed put when their
rows were reordered.

Model: add `AnchorMode` on `Anchor` (serde default = MoveAndSize, so existing
documents round-trip unchanged).

xlsx: read the anchor kind and `editAs` into the mode; write it back out
(editAs="oneCell"/"absolute" as Excel does).

Engine: sorting now moves objects anchored inside the sorted body with their
lines, re-fitting "move and size" objects, keeping "move but don't size"
objects' size, and leaving absolute ones pinned. Structural insert/delete
honours the same rule (previously it moved every object's cell unconditionally
and ignored shapes). New `object.setAnchorMode` command sets an object's mode;
`arrange.selectionPane` reports it.

Tests: sort moves images with their rows; absolute objects stay put across a
sort and a row delete; move-only keeps its size; the mode survives an xlsx
round-trip; the drawing fixture parses all three anchor kinds.
This commit is contained in:
justinjohn0306
2026-10-08 17:58:53 +05:30
parent c6b6f4177c
commit cbc2430f4f
12 changed files with 379 additions and 32 deletions
+77
View File
@@ -283,6 +283,33 @@ fn do_sort(s: &mut Session, r: RangeRef, header: bool, keys: Vec<SortKey>, by_co
// Row heights travel with rows.
let heights: Vec<Option<LineInfo>> =
if by_cols { vec![] } else { order.iter().map(|&i| sh.rows.get(&(body.start.row + i)).copied()).collect() };
// Floating objects anchored inside the sorted body travel with their lines, unless they
// are pinned absolutely ("Don't move or size with cells", `editAs="absolute"`). Reordering
// is the inverse of `order`: `dst_of[src] = dst`.
let dst_of: Vec<u32> = {
let mut v = vec![0u32; order.len()];
for (dst_i, &src_i) in order.iter().enumerate() {
if let Some(slot) = v.get_mut(src_i as usize) {
*slot = dst_i as u32;
}
}
v
};
// The object's own geometry (its top-left anchor cell, offset and size) is read from the
// pre-sort `sh`; a two-cell object's bottom-right cell is derived from its size here so it
// can be re-fitted to the lines it spans after the sort.
let objs: Vec<Anchor> =
sh.images.iter().map(|o| o.anchor).chain(sh.charts.iter().map(|o| o.anchor)).chain(sh.shapes.iter().map(|o| o.anchor)).collect();
let descs: Vec<(u32, u32, f32, f32)> = objs
.iter()
.map(|a| {
let x2 = sh.col_left(a.cell.col) + a.dx as f64 + a.width.max(0.0) as f64;
let y2 = sh.row_top(a.cell.row) + a.dy as f64 + a.height.max(0.0) as f64;
let tc = sh.col_at(x2);
let tr = sh.row_at(y2);
(tc, tr, (x2 - sh.col_left(tc)) as f32, (y2 - sh.row_top(tr)) as f32)
})
.collect();
let shm = cx.sheet_mut(sheet)?;
for (c, cell) in &moved {
match cell {
@@ -305,6 +332,27 @@ fn do_sort(s: &mut Session, r: RangeRef, header: bool, keys: Vec<SortKey>, by_co
}
}
}
// Re-anchor floating objects now that the row heights are final. `objs` was collected as
// images ++ charts ++ shapes, so the same index maps straight onto each vec.
let (b0, b1) = if by_cols { (body.start.col, body.end.col) } else { (body.start.row, body.end.row) };
let ni = shm.images.len();
let nch = shm.charts.len();
for (i, a) in objs.into_iter().enumerate() {
let na = sorted_anchor(shm, a, descs[i], by_cols, b0, b1, &dst_of);
if na != a {
if i < ni {
if let Some(o) = shm.images.get_mut(i) {
o.anchor = na;
}
} else if i < ni + nch {
if let Some(o) = shm.charts.get_mut(i - ni) {
o.anchor = na;
}
} else if let Some(o) = shm.shapes.get_mut(i - ni - nch) {
o.anchor = na;
}
}
}
for (c, _) in moved {
cx.changed.push((sheet, c));
}
@@ -314,6 +362,35 @@ fn do_sort(s: &mut Session, r: RangeRef, header: bool, keys: Vec<SortKey>, by_co
Ok(json!({"sorted": body.a1()}))
}
/// Re-anchor one floating object for a sort. `bottom` is the object's bottom-right line and the
/// offset of that corner inside it, measured before the sort. `map` (via `dst_of`) translates a
/// line index on the moving axis and is the identity outside `b0..=b1`. `sh` is the post-sort
/// grid (row heights already reordered), so re-fitting uses the heights the object lands on.
/// Objects pinned absolutely ("Don't move or size with cells") never move; "move but don't size"
/// keeps its size; "move and size" is re-fitted when its whole extent stays inside the body.
fn sorted_anchor(sh: &Sheet, a: Anchor, bottom: (u32, u32, f32, f32), by_cols: bool, b0: u32, b1: u32, dst_of: &[u32]) -> Anchor {
if a.mode == AnchorMode::Absolute {
return a;
}
let (tc, tr, tdx, tdy) = bottom;
let map = |v: u32| -> u32 { if v >= b0 && v <= b1 { dst_of.get((v - b0) as usize).map_or(v, |&d| b0 + d) } else { v } };
let (axis_start, axis_end) = if by_cols { (a.cell.col, tc) } else { (a.cell.row, tr) };
if axis_start < b0 || axis_start > b1 {
return a;
}
let (nr, nc) = if by_cols { (a.cell.row, map(a.cell.col)) } else { (map(a.cell.row), a.cell.col) };
if a.mode == AnchorMode::MoveAndSize && axis_end >= b0 && axis_end <= b1 {
let (nbr, nbc) = if by_cols { (tr, map(tc)) } else { (map(tr), tc) };
let x1 = sh.col_left(nc) + a.dx as f64;
let y1 = sh.row_top(nr) + a.dy as f64;
let x2 = sh.col_left(nbc) + tdx as f64;
let y2 = sh.row_top(nbr) + tdy as f64;
Anchor { cell: CellRef::new(nr, nc), dx: a.dx, dy: a.dy, width: (x2 - x1).max(0.0) as f32, height: (y2 - y1).max(0.0) as f32, mode: a.mode }
} else {
Anchor { cell: CellRef::new(nr, nc), ..a }
}
}
// ---------------------------------------------------------------- filter
fn toggle_filter(s: &mut Session, p: &Json) -> Result<Json> {
+9 -1
View File
@@ -82,7 +82,15 @@ fn stroke(s: &mut Session, p: &Json) -> Result<Json> {
let shape = Shape {
id,
kind: ShapeKind::Ink,
anchor: Anchor { cell, dx, dy, width: (maxx - minx).max(1.0) as f32, height: (maxy - miny).max(1.0) as f32 },
anchor: Anchor {
cell,
dx,
dy,
width: (maxx - minx).max(1.0) as f32,
height: (maxy - miny).max(1.0) as f32,
// Ink is a freehand mark: pinned where it was drawn, it does not follow cells.
mode: gridcraft_model::AnchorMode::Absolute,
},
fill: Color::from_hex(&format!("#{:02X}0000", width.round().clamp(0.0, 255.0) as u8)).unwrap_or_default(),
line: Color::from_hex(&color).unwrap_or(Color::rgb(0x1F, 0x5F, 0xC9)),
text,
+3 -3
View File
@@ -468,13 +468,13 @@ fn selection_pane(s: &mut Session, _: &Json) -> Result<Json> {
let sh = d.wb.active().ok_or(EngineError::NoDocument)?;
let mut v = Vec::new();
for c in &sh.shapes {
v.push(json!({"kind": "shape", "id": c.id, "name": format!("{:?} {}", c.kind, c.id), "at": c.anchor.cell.a1()}));
v.push(json!({"kind": "shape", "id": c.id, "name": format!("{:?} {}", c.kind, c.id), "at": c.anchor.cell.a1(), "anchorMode": anchor_mode_name(c.anchor.mode)}));
}
for c in &sh.images {
v.push(json!({"kind": "image", "id": c.id, "name": format!("Picture {}", c.id), "at": c.anchor.cell.a1()}));
v.push(json!({"kind": "image", "id": c.id, "name": format!("Picture {}", c.id), "at": c.anchor.cell.a1(), "anchorMode": anchor_mode_name(c.anchor.mode)}));
}
for c in &sh.charts {
v.push(json!({"kind": "chart", "id": c.id, "name": format!("Chart {}", c.id), "title": c.title, "at": c.anchor.cell.a1()}));
v.push(json!({"kind": "chart", "id": c.id, "name": format!("Chart {}", c.id), "title": c.title, "at": c.anchor.cell.a1(), "anchorMode": anchor_mode_name(c.anchor.mode)}));
}
Ok(Json::Array(v))
}
+34 -1
View File
@@ -124,6 +124,15 @@ pub fn specs() -> Vec<CommandSpec> {
)),
cmd!("object.delete", "Delete Object", [], None, "{kind: chart|image|shape, id}", has_doc, delete_object),
cmd!("object.move", "Move Object", [], None, "{kind: chart|image|shape, id, at?: \"C3\", dx?, dy?, width?, height?}", has_doc, move_object),
cmd!(
"object.setAnchorMode",
"Object Properties",
[],
None,
"{kind: chart|image|shape, id, mode: moveAndSize|moveOnly|absolute}",
has_doc,
set_anchor_mode
),
cmd!(
"insert.link",
"Link",
@@ -566,6 +575,7 @@ fn insert_chart(s: &mut Session, p: &Json) -> Result<Json> {
dy: 0.0,
width: f64_param(p, "width").unwrap_or(480.0) as f32,
height: f64_param(p, "height").unwrap_or(288.0) as f32,
mode: anchor_mode_param(p).unwrap_or_default(),
},
title,
series,
@@ -723,6 +733,7 @@ fn insert_picture(s: &mut Session, p: &Json) -> Result<Json> {
dy: 0.0,
width: f64_param(p, "width").map(|v| v as f32).unwrap_or(w as f32 * scale),
height: f64_param(p, "height").map(|v| v as f32).unwrap_or(h as f32 * scale),
mode: anchor_mode_param(p).unwrap_or_default(),
},
data,
mime,
@@ -764,6 +775,7 @@ fn insert_shape(s: &mut Session, p: &Json) -> Result<Json> {
dy: 0.0,
width: f64_param(p, "width").unwrap_or(144.0) as f32,
height: f64_param(p, "height").unwrap_or(if kind == ShapeKind::Line { 0.0 } else { 96.0 }) as f32,
mode: anchor_mode_param(p).unwrap_or_default(),
},
fill,
line,
@@ -822,6 +834,27 @@ fn move_object(s: &mut Session, p: &Json) -> Result<Json> {
})
}
/// Sets an object's anchor mode (Excel's "Move and size with cells" / "Move but don't size with
/// cells" / "Don't move or size with cells"), so it follows — or ignores — its rows and columns
/// when the sheet is sorted or edited structurally.
fn set_anchor_mode(s: &mut Session, p: &Json) -> Result<Json> {
let id = u32_param(p, "id").ok_or_else(|| bad("object.setAnchorMode", "missing `id`"))?;
let kind = str_param(p, "kind").unwrap_or("").to_string();
let mode = anchor_mode_param(p).ok_or_else(|| bad("object.setAnchorMode", "mode must be moveAndSize, moveOnly or absolute"))?;
let sheet = s.doc()?.wb.active_sheet;
edit(s, |cx| {
let sh = cx.sheet_mut(sheet)?;
let anchor = match kind.as_str() {
"chart" => sh.charts.iter_mut().find(|c| c.id == id).map(|c| &mut c.anchor),
"image" => sh.images.iter_mut().find(|c| c.id == id).map(|c| &mut c.anchor),
_ => sh.shapes.iter_mut().find(|c| c.id == id).map(|c| &mut c.anchor),
};
let Some(a) = anchor else { return Err(bad("object.setAnchorMode", "no such object")) };
a.mode = mode;
Ok(json!({"mode": anchor_mode_name(mode)}))
})
}
// ---------------------------------------------------------------- links & comments
fn insert_link(s: &mut Session, p: &Json) -> Result<Json> {
@@ -1134,7 +1167,7 @@ fn insert_icon(s: &mut Session, p: &Json) -> Result<Json> {
let shape = Shape {
id,
kind: ShapeKind::Icon,
anchor: Anchor { cell: at, dx: 4.0, dy: 4.0, width: size, height: size },
anchor: Anchor { cell: at, dx: 4.0, dy: 4.0, width: size, height: size, mode: anchor_mode_param(p).unwrap_or_default() },
fill: color,
line: Color::Auto,
text: name,
+24
View File
@@ -146,6 +146,30 @@ pub(crate) fn bool_param(p: &Json, key: &str) -> Option<bool> {
pub(crate) fn u32_param(p: &Json, key: &str) -> Option<u32> {
p.get(key).and_then(Json::as_u64).map(|v| v.min(u32::MAX as u64) as u32)
}
/// An object anchor mode from Excel's wording or the model's: `"moveAndSize"`/`"twoCell"`,
/// `"moveOnly"`/`"oneCell"`, `"absolute"`/`"don't move or size"` (case-insensitive).
pub(crate) fn anchor_mode_param(p: &Json) -> Option<gridcraft_model::AnchorMode> {
parse_anchor_mode(str_param(p, "mode").or_else(|| str_param(p, "anchorMode"))?)
}
pub(crate) fn parse_anchor_mode(s: &str) -> Option<gridcraft_model::AnchorMode> {
match s.to_ascii_lowercase().replace([' ', '-', '_', '\''], "").as_str() {
"moveandsize" | "moveandsizeandcells" | "twocell" => Some(gridcraft_model::AnchorMode::MoveAndSize),
"moveonly" | "movebutdontsize" | "movebutdontsizewithcells" | "onecell" => Some(gridcraft_model::AnchorMode::MoveOnly),
"absolute" | "dontmoveorsize" | "dontmoveorsizewithcells" | "none" => Some(gridcraft_model::AnchorMode::Absolute),
_ => None,
}
}
/// The wire name of an anchor mode (matches the `mode` param of [`anchor_mode_param`]).
pub(crate) fn anchor_mode_name(m: gridcraft_model::AnchorMode) -> &'static str {
match m {
gridcraft_model::AnchorMode::MoveAndSize => "moveAndSize",
gridcraft_model::AnchorMode::MoveOnly => "moveOnly",
gridcraft_model::AnchorMode::Absolute => "absolute",
}
}
pub(crate) fn ok() -> Result<Json> {
Ok(Json::Null)
}
+1 -1
View File
@@ -1673,7 +1673,7 @@ mod tests {
let id = wb.next_object_id();
wb.sheet_mut(0).unwrap().images.push(Image {
id,
anchor: Anchor { cell: CellRef::new(5, 5), dx: 0.0, dy: 0.0, width: 40.0, height: 40.0 },
anchor: Anchor { cell: CellRef::new(5, 5), dx: 0.0, dy: 0.0, width: 40.0, height: 40.0, mode: gridcraft_model::AnchorMode::MoveAndSize },
data: png,
mime: "image/png".into(),
alt: String::new(),
+66 -9
View File
@@ -4,7 +4,7 @@ use std::sync::Arc;
use gridcraft_core::{CellRef, MAX_COLS, MAX_ROWS, RangeRef};
use gridcraft_formula::adjust::{Axis, Edit};
use gridcraft_model::{Sheet, Visibility};
use gridcraft_model::{Anchor, AnchorMode, Sheet, Visibility};
use serde_json::{Value as Json, json};
use super::*;
@@ -61,6 +61,46 @@ fn lines(s: &Session, p: &Json, axis: Axis) -> Result<(u32, u32)> {
Ok((a, count))
}
/// Bottom-right line of an anchor (the `to` marker of a two-cell anchor) and the offset of that
/// corner inside it, in points. Measured against `sh`'s current geometry.
fn anchor_corner(sh: &Sheet, a: &Anchor) -> (u32, u32, f32, f32) {
let x2 = sh.col_left(a.cell.col) + a.dx as f64 + a.width.max(0.0) as f64;
let y2 = sh.row_top(a.cell.row) + a.dy as f64 + a.height.max(0.0) as f64;
let tc = sh.col_at(x2);
let tr = sh.row_at(y2);
(tc, tr, (x2 - sh.col_left(tc)) as f32, (y2 - sh.row_top(tr)) as f32)
}
/// Repositions one floating object's anchor for a structural insert/delete, honouring its
/// [`AnchorMode`]: absolute objects ("Don't move or size with cells") stay put; "move but don't
/// size" follows its cell and keeps its size; "move and size" is re-fitted to the lines it lands
/// on. `corner` is the pre-edit bottom-right line/offset (from [`anchor_corner`]); a line deleted
/// out from under an edge collapses that edge onto `at`. `sh` is the post-edit grid, used to
/// measure the re-fitted size.
fn shift_anchor(sh: &Sheet, a: Anchor, corner: (u32, u32, f32, f32), axis: Axis, at: u32, map_line: &impl Fn(u32) -> Option<u32>) -> Anchor {
if a.mode == AnchorMode::Absolute {
return a;
}
let main = if axis == Axis::Rows { a.cell.row } else { a.cell.col };
// A deleted anchor line collapses onto the line that takes its place.
let nmain = map_line(main).unwrap_or(at);
let (nr, nc) = if axis == Axis::Rows { (nmain, a.cell.col) } else { (a.cell.row, nmain) };
if a.mode == AnchorMode::MoveOnly {
return Anchor { cell: CellRef::new(nr, nc), ..a };
}
// Move and size: re-fit to the lines the object lands on. A far edge whose line was deleted
// collapses onto the near cell, shrinking the object.
let (tc, tr, tdx, tdy) = corner;
let far = if axis == Axis::Rows { tr } else { tc };
let nfar = map_line(far).unwrap_or(nmain);
let (nbr, nbc) = if axis == Axis::Rows { (nfar, tc) } else { (tr, nfar) };
let x1 = sh.col_left(nc) + a.dx as f64;
let y1 = sh.row_top(nr) + a.dy as f64;
let xn = sh.col_left(nbc) + tdx as f64;
let yn = sh.row_top(nbr) + tdy as f64;
Anchor { cell: CellRef::new(nr, nc), dx: a.dx, dy: a.dy, width: (xn - x1).max(0.0) as f32, height: (yn - y1).max(0.0) as f32, mode: a.mode }
}
/// Shifts everything that has a position on the sheet for an insert/delete.
fn shift_sheet_features(sh: &mut Sheet, axis: Axis, at: u32, count: u32, insert: bool) {
let map_line = |v: u32| -> Option<u32> {
@@ -94,6 +134,16 @@ fn shift_sheet_features(sh: &mut Sheet, axis: Axis, at: u32, count: u32, insert:
Axis::Cols => map_line(c.col).map(|x| CellRef::new(c.row, x)),
}
};
// Floating objects: snapshot their bottom-right corners against the pre-shift geometry, so a
// re-fit after the shift measures against the grid the object actually lands on.
let obj_corners: Vec<(u32, u32, f32, f32)> = sh
.charts
.iter()
.map(|o| &o.anchor)
.chain(sh.images.iter().map(|o| &o.anchor))
.chain(sh.shapes.iter().map(|o| &o.anchor))
.map(|a| anchor_corner(sh, a))
.collect();
// Line info.
let lines = if axis == Axis::Rows { &mut sh.rows } else { &mut sh.cols };
let old = std::mem::take(lines);
@@ -151,15 +201,22 @@ fn shift_sheet_features(sh: &mut Sheet, axis: Axis, at: u32, count: u32, insert:
}
sh.comments = std::mem::take(&mut sh.comments).into_iter().filter_map(|(c, v)| map_cell(c).map(|c| (c, v))).collect();
sh.hyperlinks = std::mem::take(&mut sh.hyperlinks).into_iter().filter_map(|(c, v)| map_cell(c).map(|c| (c, v))).collect();
for ch in sh.charts.iter_mut() {
if let Some(c) = map_cell(ch.anchor.cell) {
ch.anchor.cell = c;
}
// Floating objects follow their anchor lines per their [`AnchorMode`]. Corners were snapshotted
// as charts ++ images ++ shapes, matching the three slices below. New anchors are computed
// against the post-shift grid first (the re-fit needs its sizes), then written back.
let anchored = |ui: usize, a: Anchor| -> Anchor { obj_corners.get(ui).map_or(a, |&corner| shift_anchor(sh, a, corner, axis, at, &map_line)) };
let new_charts: Vec<Anchor> = sh.charts.iter().enumerate().map(|(i, o)| anchored(i, o.anchor)).collect();
let new_images: Vec<Anchor> = sh.images.iter().enumerate().map(|(i, o)| anchored(new_charts.len() + i, o.anchor)).collect();
let base = new_charts.len() + new_images.len();
let new_shapes: Vec<Anchor> = sh.shapes.iter().enumerate().map(|(i, o)| anchored(base + i, o.anchor)).collect();
for (o, a) in sh.charts.iter_mut().zip(new_charts) {
o.anchor = a;
}
for im in sh.images.iter_mut() {
if let Some(c) = map_cell(im.anchor.cell) {
im.anchor.cell = c;
}
for (o, a) in sh.images.iter_mut().zip(new_images) {
o.anchor = a;
}
for (o, a) in sh.shapes.iter_mut().zip(new_shapes) {
o.anchor = a;
}
for sp in sh.sparklines.iter_mut() {
if let Some(c) = map_cell(sp.cell) {