Merge pull request #211 from Zarvinx/feat/drag-selected-columns
Reorder selected columns by dragging headers
This commit is contained in:
commit
ce0483fc17
|
|
@ -35,6 +35,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
p,
|
||||
Axis::Cols
|
||||
)),
|
||||
cmd!("sheet.moveColumns", "Move Columns", [], None, "{cols?: \"B:C\", before: zero-based column index}", has_doc, move_columns),
|
||||
cmd!("home.insertCells", "Insert Cells…", ["Home", "Cells", "Insert"], None, "{range?, shift: right|down|row|column}", has_doc, insert_cells),
|
||||
cmd!(
|
||||
"home.insertCopiedCells",
|
||||
|
|
@ -334,6 +335,105 @@ fn delete_lines(s: &mut Session, p: &Json, axis: Axis) -> Result<Json> {
|
|||
Ok(out)
|
||||
}
|
||||
|
||||
/// Reorders a contiguous block of whole columns without replacing the destination columns.
|
||||
/// `before` is a boundary in the original sheet: moving B:C before E produces A,D,B,C,E.
|
||||
fn move_columns(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let (from, count) = lines(s, p, Axis::Cols)?;
|
||||
let before = u32_param(p, "before").ok_or_else(|| bad("sheet.moveColumns", "missing `before`"))?;
|
||||
let end =
|
||||
from.checked_add(count).filter(|end| *end <= MAX_COLS).ok_or_else(|| bad("sheet.moveColumns", "source columns are outside the worksheet"))?;
|
||||
if before >= MAX_COLS || before.checked_add(count).is_none_or(|end| end > MAX_COLS) {
|
||||
return Err(bad("sheet.moveColumns", "destination columns are outside the worksheet"));
|
||||
}
|
||||
if (from..=end).contains(&before) {
|
||||
return Ok(json!({"cols": RangeRef::cols(from, end.saturating_sub(1)).a1()}));
|
||||
}
|
||||
let sheet = target_sheet(s, p)?;
|
||||
if let Some(u) = s.doc()?.wb.sheet(sheet).and_then(|sh| sh.used_range())
|
||||
&& u.end.col >= before
|
||||
&& u.end.col as u64 + count as u64 >= MAX_COLS as u64
|
||||
{
|
||||
return Err(EngineError::Other("To prevent possible loss of data, columns cannot be shifted off of the worksheet.".into()));
|
||||
}
|
||||
check_lines_protection(s, &json!({"cols": RangeRef::cols(before, before).a1()}), Axis::Cols, before, count, true)?;
|
||||
check_lines_protection(s, &json!({"cols": RangeRef::cols(from, end - 1).a1()}), Axis::Cols, from, count, false)?;
|
||||
|
||||
let moved_from = if before <= from { from + count } else { from };
|
||||
if moved_from.checked_add(count).is_none_or(|end| end > MAX_COLS) {
|
||||
return Err(bad("sheet.moveColumns", "the move is too close to the worksheet boundary"));
|
||||
}
|
||||
let destination = before;
|
||||
let delete_at = moved_from;
|
||||
let final_start = if before > from { before - count } else { before };
|
||||
let active_row = s.doc()?.selection.active.row;
|
||||
let source = RangeRef::cols(moved_from, moved_from + count - 1);
|
||||
let out = edit(s, |cx| {
|
||||
let name = cx.wb.sheet(sheet).map(|sh| sh.name.clone()).unwrap_or_default();
|
||||
cx.protection_checked = true;
|
||||
|
||||
{
|
||||
let sh = cx.sheet_mut(sheet)?;
|
||||
sh.cells.shift_cols(before, count as i64);
|
||||
shift_sheet_features(sh, Axis::Cols, before, count, true);
|
||||
}
|
||||
rewrite(&mut cx.wb, &name, &Edit::Insert { axis: Axis::Cols, at: before, count });
|
||||
|
||||
let mut touched = Vec::new();
|
||||
{
|
||||
let sh = cx.sheet_mut(sheet)?;
|
||||
let pictures: Vec<_> =
|
||||
sh.cell_pictures.iter().filter(|(cell, _)| source.contains(**cell)).map(|(cell, picture)| (*cell, picture.clone())).collect();
|
||||
let cells = sh.take_cells(source);
|
||||
let moved_lines: Vec<_> = (0..count).filter_map(|offset| sh.cols.remove(&(moved_from + offset)).map(|info| (offset, info))).collect();
|
||||
let movement = Edit::Move { from: source, to_row: 0, to_col: destination };
|
||||
shift_cell_features(sh, &movement);
|
||||
for object in sh
|
||||
.charts
|
||||
.iter_mut()
|
||||
.map(|o| &mut o.anchor)
|
||||
.chain(sh.images.iter_mut().map(|o| &mut o.anchor))
|
||||
.chain(sh.shapes.iter_mut().map(|o| &mut o.anchor))
|
||||
{
|
||||
if object.mode != AnchorMode::Absolute && (moved_from..moved_from + count).contains(&object.cell.col) {
|
||||
object.cell.col = destination + (object.cell.col - moved_from);
|
||||
}
|
||||
}
|
||||
for (cell, value) in cells {
|
||||
let dest = CellRef::new(cell.row, destination + (cell.col - moved_from));
|
||||
sh.cells.set(dest, value);
|
||||
touched.extend([cell, dest]);
|
||||
}
|
||||
for (cell, picture) in pictures {
|
||||
let dest = CellRef::new(cell.row, destination + (cell.col - moved_from));
|
||||
sh.cell_pictures.insert(dest, picture);
|
||||
}
|
||||
for (offset, info) in moved_lines {
|
||||
sh.cols.insert(destination + offset, info);
|
||||
}
|
||||
}
|
||||
for cell in touched {
|
||||
cx.touch(sheet, cell);
|
||||
}
|
||||
rewrite(&mut cx.wb, &name, &Edit::Move { from: source, to_row: 0, to_col: destination });
|
||||
|
||||
{
|
||||
let sh = cx.sheet_mut(sheet)?;
|
||||
sh.cells.shift_cols(delete_at + count, -(count as i64));
|
||||
shift_sheet_features(sh, Axis::Cols, delete_at, count, false);
|
||||
}
|
||||
rewrite(&mut cx.wb, &name, &Edit::Delete { axis: Axis::Cols, at: delete_at, count });
|
||||
*cx.sel = crate::Selection {
|
||||
active: CellRef::new(active_row, final_start),
|
||||
anchor: CellRef::new(active_row, final_start),
|
||||
ranges: vec![RangeRef::cols(final_start, final_start + count - 1)],
|
||||
};
|
||||
cx.structural = true;
|
||||
Ok(json!({"cols": RangeRef::cols(final_start, final_start + count - 1).a1()}))
|
||||
})?;
|
||||
cancel_copy_mode(s);
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn insert_cells(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let shift = str_param(p, "shift").unwrap_or("down");
|
||||
let r = target_range(s, p)?;
|
||||
|
|
|
|||
|
|
@ -521,6 +521,58 @@ fn insert_delete_rows_adjust() {
|
|||
assert_eq!(v(&s, "A1"), Value::Number(10.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn move_columns_reorders_without_overwriting_and_undoes_once() {
|
||||
let mut s = s();
|
||||
s.execute("range.setValues", json!({"range": "A1", "values": [[1, 2, 3, 4, 5, 6, 7, 8]]})).unwrap();
|
||||
s.execute("cell.set", json!({"cell": "H2", "input": "=B1+D1"})).unwrap();
|
||||
s.execute("cell.set", json!({"cell": "H3", "input": "=SUM(B:B)"})).unwrap();
|
||||
s.execute("home.columnWidth", json!({"cols": "B:B", "width": 101})).unwrap();
|
||||
s.execute("home.columnWidth", json!({"cols": "C:C", "width": 102})).unwrap();
|
||||
let before = s.doc().unwrap().wb.clone();
|
||||
s.doc_mut().unwrap().undo.clear();
|
||||
|
||||
let result = s.execute("sheet.moveColumns", json!({"cols": "B:C", "before": 4})).unwrap();
|
||||
assert_eq!(result["cols"], "C:D");
|
||||
assert_eq!(
|
||||
(0..8).map(|col| v(&s, &CellRef::new(0, col).a1())).collect::<Vec<_>>(),
|
||||
vec![
|
||||
Value::Number(1.0),
|
||||
Value::Number(4.0),
|
||||
Value::Number(2.0),
|
||||
Value::Number(3.0),
|
||||
Value::Number(5.0),
|
||||
Value::Number(6.0),
|
||||
Value::Number(7.0),
|
||||
Value::Number(8.0),
|
||||
]
|
||||
);
|
||||
assert_eq!(s.execute("cell.get", json!({"cell": "H2"})).unwrap()["formula"], "=C1+B1");
|
||||
assert_eq!(s.execute("cell.get", json!({"cell": "H3"})).unwrap()["formula"], "=SUM(C:C)");
|
||||
let sh = s.doc().unwrap().wb.active().unwrap();
|
||||
assert_eq!(sh.col_width(2), 101.0);
|
||||
assert_eq!(sh.col_width(3), 102.0);
|
||||
assert_eq!(s.doc().unwrap().selection.current().a1(), "C:D");
|
||||
assert_eq!(s.doc().unwrap().undo.len(), 1);
|
||||
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(*s.doc().unwrap().wb, *before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn move_columns_can_reorder_left_and_ignores_drops_inside_the_selection() {
|
||||
let mut s = s();
|
||||
s.execute("range.setValues", json!({"range": "A1", "values": [["A", "B", "C", "D", "E"]]})).unwrap();
|
||||
s.execute("sheet.moveColumns", json!({"cols": "D:E", "before": 1})).unwrap();
|
||||
assert_eq!(
|
||||
(0..5).map(|col| v(&s, &CellRef::new(0, col).a1())).collect::<Vec<_>>(),
|
||||
vec!["A".into(), "D".into(), "E".into(), "B".into(), "C".into()]
|
||||
);
|
||||
let undo = s.doc().unwrap().undo.len();
|
||||
s.execute("sheet.moveColumns", json!({"cols": "B:C", "before": 2})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().undo.len(), undo);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn insert_copied_cells_inserts_rows_columns_and_blocks() {
|
||||
// Issue #21: Insert Copied Cells.
|
||||
|
|
|
|||
|
|
@ -195,17 +195,6 @@ fn adjust_ref(r: &Reference, edit: &Edit) -> Option<Reference> {
|
|||
let kind = match &r.kind {
|
||||
RefKind::Cell(a) => RefKind::Cell(mv(a)?),
|
||||
RefKind::Range(a, b) => RefKind::Range(mv(a)?, mv(b)?),
|
||||
k => k.clone(),
|
||||
};
|
||||
Some(Reference { sheet: r.sheet.clone(), kind })
|
||||
}
|
||||
Edit::MoveToSheet { from, to_sheet, to_row, to_col } => {
|
||||
if !from.contains_range(&r.range()) {
|
||||
return Some(r.clone());
|
||||
}
|
||||
let mut moved = adjust_ref(r, &Edit::Move { from: *from, to_row: *to_row, to_col: *to_col })?;
|
||||
// Whole-row/column references have no anchors, but their indices must move too.
|
||||
moved.kind = match &r.kind {
|
||||
RefKind::Rows(a, aa, b, ba) => {
|
||||
let dr = *to_row as i64 - from.start.row as i64;
|
||||
RefKind::Rows(shift_line(*a, false, dr, MAX_ROWS)?, *aa, shift_line(*b, false, dr, MAX_ROWS)?, *ba)
|
||||
|
|
@ -214,8 +203,14 @@ fn adjust_ref(r: &Reference, edit: &Edit) -> Option<Reference> {
|
|||
let dc = *to_col as i64 - from.start.col as i64;
|
||||
RefKind::Cols(shift_line(*a, false, dc, MAX_COLS)?, *aa, shift_line(*b, false, dc, MAX_COLS)?, *ba)
|
||||
}
|
||||
_ => moved.kind,
|
||||
};
|
||||
Some(Reference { sheet: r.sheet.clone(), kind })
|
||||
}
|
||||
Edit::MoveToSheet { from, to_sheet, to_row, to_col } => {
|
||||
if !from.contains_range(&r.range()) {
|
||||
return Some(r.clone());
|
||||
}
|
||||
let mut moved = adjust_ref(r, &Edit::Move { from: *from, to_row: *to_row, to_col: *to_col })?;
|
||||
moved.sheet = SheetSel::Named(to_sheet.clone());
|
||||
Some(moved)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -171,6 +171,8 @@ fn moves_and_renames() {
|
|||
let mv = Edit::Move { from: gridcraft_core::RangeRef::parse("A1:B2").unwrap(), to_row: 9, to_col: 3 };
|
||||
let e = parse("A1+B2+C3+SUM(A1:B2)").unwrap();
|
||||
assert_eq!(print(&adjust(e, "S", "S", &mv)), "D10+E11+C3+SUM(D10:E11)");
|
||||
let mv = Edit::Move { from: gridcraft_core::RangeRef::parse("B:C").unwrap(), to_row: 0, to_col: 4 };
|
||||
assert_eq!(print(&adjust(parse("SUM(B:B)+SUM($C:$C)+D1").unwrap(), "S", "S", &mv)), "SUM(E:E)+SUM($F:$F)+D1");
|
||||
let e = parse("Old!A1+'Old'!B2").unwrap();
|
||||
assert_eq!(print(&rename_sheet(e, "Old", "New Name")), "'New Name'!A1+'New Name'!B2");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -27,6 +27,11 @@ pub enum Drag {
|
|||
Select,
|
||||
Rows(u32),
|
||||
Cols(u32),
|
||||
/// Reordering one contiguous whole-column selection by its header.
|
||||
MoveCols {
|
||||
source: RangeRef,
|
||||
before: Option<u32>,
|
||||
},
|
||||
ResizeCol {
|
||||
col: u32,
|
||||
start: f32,
|
||||
|
|
@ -1299,6 +1304,10 @@ pub(crate) fn paint_selection(
|
|||
p.rect_stroke(rr, 0.0, Stroke::new(2.0, Color32::from_gray(110)), StrokeKind::Middle);
|
||||
}
|
||||
}
|
||||
if let crate::grid::Drag::MoveCols { before: Some(before), .. } = &app.grid.drag {
|
||||
let x = geo.x(sh, *before);
|
||||
p.line_segment([pos2(x, geo.rect.top()), pos2(x, geo.rect.bottom())], Stroke::new(3.0, t.accent));
|
||||
}
|
||||
// Marching ants around the copied range.
|
||||
if let Some(clip) = &app.session.clipboard
|
||||
&& app.session.active().is_some_and(|d| d.uid == clip.doc_uid && d.wb.active_sheet == clip.sheet)
|
||||
|
|
@ -1464,7 +1473,9 @@ fn interact(app: &mut SheetApp, ui: &mut egui::Ui, resp: &egui::Response, geo: &
|
|||
} else if in_cells {
|
||||
ctx.set_cursor_icon(CursorIcon::Cell);
|
||||
} else if in_col_header {
|
||||
ctx.set_cursor_icon(CursorIcon::Default);
|
||||
let col = geo.col_at(sh, p.x);
|
||||
let selected = sel.ranges.len() == 1 && sel.current().is_full_cols() && sel.current().contains(CellRef::new(0, col));
|
||||
ctx.set_cursor_icon(if selected { CursorIcon::Move } else { CursorIcon::Default });
|
||||
}
|
||||
app.grid.hover_cell = in_cells.then(|| geo.cell_at(sh, p));
|
||||
}
|
||||
|
|
@ -1590,6 +1601,15 @@ fn interact(app: &mut SheetApp, ui: &mut egui::Ui, resp: &egui::Response, geo: &
|
|||
return;
|
||||
}
|
||||
let c = geo.col_at(sh, p.x);
|
||||
if resp.drag_started()
|
||||
&& !mods.any()
|
||||
&& sel.ranges.len() == 1
|
||||
&& sel.current().is_full_cols()
|
||||
&& sel.current().contains(CellRef::new(0, c))
|
||||
{
|
||||
app.grid.drag = Drag::MoveCols { source: sel.current(), before: None };
|
||||
return;
|
||||
}
|
||||
let range = if mods.shift { RangeRef::cols(sel.anchor.col.min(c), sel.anchor.col.max(c)) } else { RangeRef::cols(c, c) };
|
||||
select_ranges(app, range, mods.command, CellRef::new(geo.row_at(sh, geo.cells.top() + 1.0), c));
|
||||
app.grid.drag = Drag::Cols(if mods.shift { sel.anchor.col } else { c });
|
||||
|
|
@ -1741,6 +1761,14 @@ fn interact(app: &mut SheetApp, ui: &mut egui::Ui, resp: &egui::Response, geo: &
|
|||
app.session.run("selection.set", json!({"range": RangeRef::cols(anchor.min(c), anchor.max(c)).a1(), "active": sel.active.a1()}));
|
||||
scroll_by(app, vec2(auto.x, 0.0));
|
||||
}
|
||||
Drag::MoveCols { source, .. } => {
|
||||
let col = geo.col_at(sh, p.x.clamp(geo.cells.left() + 1.0, geo.cells.right() - 1.0));
|
||||
let midpoint = geo.x(sh, col) + sh.col_width(col) * geo.z * 0.5;
|
||||
let before = (col + u32::from(p.x >= midpoint)).min(MAX_COLS - 1);
|
||||
let before = (!(source.start.col..=source.end.col + 1).contains(&before)).then_some(before);
|
||||
app.grid.drag = Drag::MoveCols { source, before };
|
||||
scroll_by(app, vec2(auto.x, 0.0));
|
||||
}
|
||||
Drag::ResizeCol { col, start, orig } => {
|
||||
let w = (orig + (p.x - start) / geo.z).max(0.0);
|
||||
app.toast =
|
||||
|
|
@ -1839,6 +1867,11 @@ fn interact(app: &mut SheetApp, ui: &mut egui::Ui, resp: &egui::Response, geo: &
|
|||
let _ = app.run("edit.paste", json!({"at": to.a1()}));
|
||||
}
|
||||
}
|
||||
Drag::MoveCols { source, before: Some(before) } => {
|
||||
if let Err(e) = app.run("sheet.moveColumns", json!({"cols": source.a1(), "before": before})) {
|
||||
app.message = Some(("GridCraft".into(), crate::clean_error(&e)));
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
app.grid.drag_select = false;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,65 @@
|
|||
//! Whole-column reordering driven through real egui pointer events.
|
||||
|
||||
use egui::{Event, Modifiers, PointerButton, Pos2, pos2, vec2};
|
||||
use gridcraft_engine::{Session, core::CellRef};
|
||||
use gridcraft_ui_egui::{SheetApp, grid::Geo};
|
||||
use serde_json::json;
|
||||
|
||||
fn harness() -> egui_kittest::Harness<'static, SheetApp> {
|
||||
let mut session = Session::new();
|
||||
session.new_workbook();
|
||||
session.execute("range.setValues", json!({"range": "A1", "values": [["A", "B", "C", "D", "E"]]})).unwrap();
|
||||
session.execute("selection.set", json!({"range": "B:C"})).unwrap();
|
||||
let mut harness = egui_kittest::Harness::builder().with_step_dt(1.0 / 60.0).with_size(vec2(1200.0, 800.0)).build_ui_state(
|
||||
|ui, app: &mut SheetApp| {
|
||||
app.logic(ui.ctx());
|
||||
app.ui(ui);
|
||||
},
|
||||
SheetApp::new(session, Default::default()),
|
||||
);
|
||||
harness.run_steps(4);
|
||||
harness.state_mut().session.active_mut().unwrap().undo.clear();
|
||||
harness.state_mut().session.journal.clear();
|
||||
harness
|
||||
}
|
||||
|
||||
fn header_center(harness: &egui_kittest::Harness<'_, SheetApp>, col: u32) -> Pos2 {
|
||||
let app = harness.state();
|
||||
let sheet = app.session.active().unwrap().wb.active().unwrap();
|
||||
let geo = Geo::new(sheet, app.grid.rect.unwrap(), app.view().scroll);
|
||||
pos2(geo.x(sheet, col) + sheet.col_width(col) * geo.z * 0.5, geo.rect.top() + geo.header_h * 0.5)
|
||||
}
|
||||
|
||||
fn pointer_button(harness: &mut egui_kittest::Harness<'_, SheetApp>, pos: Pos2, pressed: bool) {
|
||||
harness
|
||||
.input_mut()
|
||||
.events
|
||||
.extend([Event::PointerMoved(pos), Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE }]);
|
||||
harness.step();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dragging_selected_column_headers_reorders_the_block() {
|
||||
let mut harness = harness();
|
||||
let start = header_center(&harness, 1);
|
||||
let target = {
|
||||
let center = header_center(&harness, 4);
|
||||
pos2(center.x - 20.0, center.y)
|
||||
};
|
||||
|
||||
pointer_button(&mut harness, start, true);
|
||||
harness.input_mut().events.push(Event::PointerMoved(target));
|
||||
harness.step();
|
||||
harness.input_mut().events.push(Event::PointerMoved(target + vec2(1.0, 0.0)));
|
||||
harness.step();
|
||||
pointer_button(&mut harness, target + vec2(1.0, 0.0), false);
|
||||
harness.run_steps(2);
|
||||
|
||||
let app = harness.state();
|
||||
let sheet = app.session.active().unwrap().wb.active().unwrap();
|
||||
let values: Vec<_> = (0..5).map(|col| sheet.value(CellRef::new(0, col))).collect();
|
||||
assert_eq!(values, vec!["A".into(), "D".into(), "B".into(), "C".into(), "E".into()]);
|
||||
assert_eq!(app.session.active().unwrap().selection.current().a1(), "C:D");
|
||||
assert_eq!(app.session.journal.iter().filter(|(id, _)| id == "sheet.moveColumns").count(), 1);
|
||||
assert_eq!(app.session.active().unwrap().undo.len(), 1);
|
||||
}
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
# UI parity
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (Forecast Sheet dialog) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (selected columns can be reordered by dragging their headers) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4
|
||||
|
||||
Interaction, controls and feel, against Excel. Part of [target-app-parity.md](target-app-parity.md).
|
||||
**UI/UX fidelity: ~55% ready, 50–80 h** (estimated). Ribbon command coverage is measured
|
||||
|
|
@ -43,6 +43,7 @@ Sources: `crates/ui-egui/src` (ribbon, grid, editor, formula bar, dialogs, panes
|
|||
| Point mode (click/drag/arrow references), reference colouring, F4 | done |
|
||||
| AutoComplete in columns, function autocomplete and argument tips | done; cell helpers clip to the worksheet and hide when the editor scrolls out (#200) |
|
||||
| Header resize and double-click AutoFit | done (#42, #88 for header dividers) |
|
||||
| Reorder selected whole columns by dragging their headers | done (10-11) |
|
||||
| Freeze panes, split panes | freeze done; split to verify |
|
||||
| Copy as HTML table for other apps | done (#36, 10-10) |
|
||||
| Context menu on cells | 16 items (Cut, Copy, Paste, Paste Special, Insert, Delete, Clear Contents, Filter by value, Sort, New Comment, New Note, Format Cells, Pick From Drop-down List, Define Name, Link). Insert Copied Cells (Insert Cut Cells for whole rows/columns) while the clipboard holds a copy (#21). Missing: Insert Cut Cells for a cell block, paste-option icons, Quick Analysis, Get Data from Table, Show Changes, Smart Lookup |
|
||||
|
|
@ -149,6 +150,7 @@ scrollbar bugs 2–4 · pixel/feel pass against observed Excel 7–12.
|
|||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-11 | minor | Selected whole columns can be reordered by dragging their headers to an insertion point |
|
||||
| 2026-10-11 | minor | Page Layout view with rulers and in-place headers and footers (#329) |
|
||||
| 2026-10-11 | minor | Forecast Sheet dialog (#322) |
|
||||
| 2026-10-11 | minor | Delete and Backspace now remove the selected chart, picture or shape (#213) |
|
||||
|
|
|
|||
Loading…
Reference in New Issue