Merge #1282: Tables: dragging across cells selects a range of cells

This commit is contained in:
aiongg
2026-10-25 02:22:37 +00:00
9 changed files with 294 additions and 12 deletions
+2
View File
@@ -135,6 +135,7 @@ app's JSON control channel (`deckcraft --control PORT`).
| Date | What landed |
|---|---|
| 2026-10-24 | AutoFormat as you type: a paragraph that starts with `*`, `-`, `--`, `>`, `<>`, `1.`, `1)`, `I.`, `i.`, `A.`, `a.` or `a)` and a space or tab becomes a bulleted or numbered list in that style; Backspace right after puts the typed text back, and Tools ▸ AutoCorrect and `text.autocorrect {lists}` turn it off |
| 2026-10-24 | Tables: while the caret is in a cell, dragging into another cell selects that range of cells (shaded, merged cells taken in whole), and Shift+arrow grows or shrinks it; formatting applies to the selected cells only, and Delete clears their text and keeps the table |
| 2026-10-23 | Chart text colour: `chart.options {textColor}` and Font Color on a selected chart colour its title, axes, legend and data labels; the colour is written to the chart part (`c:txPr` on the axes, legend, labels and chart, the title's run defaults) so an imported chart keeps its `txPr` colours through an edit and a save; text without a colour of its own takes PowerPoint's default, the theme's text colour at 65 % plus 35 % (595959 on a light theme, light grey on a dark one) |
| 2026-10-23 | Format Background ▸ Apply to All also sets the background of the slide masters the slides use (and clears their layouts' overrides), so new slides keep it (#891) |
| 2026-10-22 | Table text takes the master's other-text style (`otherStyle`), shapes and text boxes the presentation's default text style, as PowerPoint for Mac draws them (synthetic probe: 14 pt cells under a 14 pt `otherStyle` and a 26 pt `defaultTextStyle`); a private deck's table had been drawn 20 pt instead of 18 |
@@ -279,6 +280,7 @@ app's JSON control channel (`deckcraft --control PORT`).
| Date | Change | Summary |
|---|---|---|
| 2026-10-24 | trivial | Progress log: selecting a range of table cells by dragging. Scores and milestone names unchanged |
| 2026-10-22 | trivial | Progress log: Animation Pane ▸ Play From. Scores unchanged |
| 2026-10-23 | trivial | Progress log: scroll bars on the zoomed slide pane (#1157). Scores and milestone names unchanged |
| 2026-10-20 | trivial | Explicit empty chart number-format sections hide their values; blank whole-code General fallback is retained. Synthetic formatter regressions; chart-element scores and readiness estimates unchanged |
+10
View File
@@ -123,6 +123,11 @@ fn copy_inner(s: &mut Session, p: &Value) -> Result<String> {
s.clipboard = crate::Clipboard { slides, media, plain: plain.clone(), ..Default::default() };
return Ok(plain);
}
// Selected table cells copy as their text: a tab between cells and a paragraph per row.
if let Some(plain) = super::table::selected_cells_text(st).filter(|_| p.get("id").is_none() && p.get("ids").is_none()) {
s.clipboard = crate::Clipboard { text: Some(TextBody::from_text(&plain)), plain: plain.clone(), ..Default::default() };
return Ok(plain);
}
let shapes: Vec<Shape> = st
.selection
.shapes
@@ -587,6 +592,11 @@ fn delete(s: &mut Session, p: &Value) -> Result<Value> {
if slides_scope(s, p) && ids_param(p, "ids").is_none() {
return s.execute("slide.delete", &json!({}));
}
// With table cells selected, Delete clears their text and keeps the table, as in PowerPoint.
let sel = &s.doc()?.selection;
if ids_param(p, "ids").is_none() && p.get("id").is_none() && sel.cells.is_some_and(|c| sel.shapes == [c.0]) {
return super::table::clear_selected_cells(s);
}
let ids = targets(s, p)?;
if ids.is_empty() {
return ok();
+108 -2
View File
@@ -363,10 +363,12 @@ fn target_cells(s: &Session, p: &Value) -> Result<impl Fn(&Table) -> Vec<(usize,
.and_then(Value::as_array)
.map(|a| a.iter().take(100_000).filter_map(|c| Some((c.get(0)?.as_u64()? as usize, c.get(1)?.as_u64()? as usize))).collect());
let st = s.doc()?;
let sel = st.selection.cells.map(|(_, a, b)| (a, b)).or_else(|| st.selection.text.as_ref().and_then(|t| t.cell).map(|c| (c, c)));
// A selected range grows over the merged cells it touches; with only a caret, the caret's cell is used.
let sel = st.selection.cells.map(|(_, a, b)| (a, b, true)).or_else(|| st.selection.text.as_ref().and_then(|t| t.cell).map(|c| (c, c, false)));
Ok(move |t: &Table| match (&cells, sel) {
(Some(c), _) => c.clone(),
(None, Some((a, b))) => {
(None, Some((a, b, range))) => {
let (a, b) = if range { cell_block(t, a, b) } else { (a, b) };
// The selection can name cells outside the table; only walk the ones that exist.
let (r1, c1) = (a.0.max(b.0).min(t.n_rows().saturating_sub(1)), a.1.max(b.1).min(t.n_cols().saturating_sub(1)));
(a.0.min(b.0)..=r1).flat_map(|r| (a.1.min(b.1)..=c1).map(move |c| (r, c))).collect()
@@ -585,6 +587,110 @@ fn select_cells(s: &mut Session, p: &Value) -> Result<Value> {
ok()
}
/// The cell range from `a` to `b` in `t`, grown until every merged cell it touches lies inside
/// it, as (the corner on `a`'s side, the corner on `b`'s side). Cells past the table's edge are
/// clamped to it.
pub fn cell_block(t: &Table, a: (usize, usize), b: (usize, usize)) -> ((usize, usize), (usize, usize)) {
let (nr, nc) = (t.n_rows().max(1), t.n_cols().max(1));
let (a, b) = ((a.0.min(nr - 1), a.1.min(nc - 1)), (b.0.min(nr - 1), b.1.min(nc - 1)));
let (mut r0, mut r1, mut c0, mut c1) = (a.0.min(b.0), a.0.max(b.0), a.1.min(b.1), a.1.max(b.1));
// Each pass takes in the merged cells that overlap the block; stop when one adds nothing.
for _ in 0..nr + nc {
let mut grown = false;
for (r, row) in t.rows.iter().enumerate() {
for (c, cell) in row.cells.iter().enumerate() {
if cell.is_covered() {
continue;
}
// Spans come from the file; the additions saturate so that they cannot overflow on 32-bit targets.
let end = |at: usize, span: u32, n: usize| at.saturating_add((span.max(1) - 1) as usize).min(n - 1);
let (er, ec) = (end(r, cell.row_span, nr), end(c, cell.grid_span, nc));
let overlaps = r <= r1 && er >= r0 && c <= c1 && ec >= c0;
if overlaps && (r < r0 || er > r1 || c < c0 || ec > c1) {
(r0, r1, c0, c1) = (r0.min(r), r1.max(er), c0.min(c), c1.max(ec));
grown = true;
}
}
}
if !grown {
break;
}
}
let pick = |lo: usize, hi: usize, first: bool| if first { lo } else { hi };
let from = (pick(r0, r1, a.0 <= b.0), pick(c0, c1, a.1 <= b.1));
let to = (pick(r0, r1, a.0 > b.0), pick(c0, c1, a.1 > b.1));
(from, to)
}
/// The selected cell range of table `id` (`t`), grown over merged cells, as its top-left and
/// bottom-right cells. `None` when no range of that table is selected.
pub fn selected_range(
cells: Option<(deckcraft_model::ShapeId, (usize, usize), (usize, usize))>,
id: deckcraft_model::ShapeId,
t: &Table,
) -> Option<((usize, usize), (usize, usize))> {
let (_, a, b) = cells.filter(|c| c.0 == id)?;
let (x, y) = cell_block(t, a, b);
Some(((x.0.min(y.0), x.1.min(y.1)), (x.0.max(y.0), x.1.max(y.1))))
}
/// Whether cell (`r`, `c`) lies in `range` (from [`selected_range`]); without a range every cell does.
pub fn in_range(range: Option<((usize, usize), (usize, usize))>, r: usize, c: usize) -> bool {
range.is_none_or(|((r0, c0), (r1, c1))| (r0..=r1).contains(&r) && (c0..=c1).contains(&c))
}
/// Shift+arrow with a cell range selected: the cell the range was extended to moves by (`dr`,
/// `dc`), on past a merged cell until the range changes. The cell it started from stays. Returns
/// false when no cell range of the selected table is selected.
pub(crate) fn extend_selected_cells(s: &mut Session, dr: isize, dc: isize) -> Result<bool> {
let st = s.doc()?;
let Some((id, from, to)) = st.selection.cells.filter(|c| st.selection.shapes == [c.0]) else { return Ok(false) };
let Some(ShapeKind::Table(t)) = st.shape(id).map(|sh| &sh.kind) else { return Ok(false) };
let (nr, nc) = (t.n_rows(), t.n_cols());
let old = cell_block(t, from, to);
let mut end = to;
while cell_block(t, from, end) == old {
let r = end.0.checked_add_signed(dr).filter(|r| *r < nr);
let c = end.1.checked_add_signed(dc).filter(|c| *c < nc);
let (Some(r), Some(c)) = (r, c) else { break };
end = (r, c);
}
s.select(|_, sel| sel.cells = Some((id, from, end)))?;
Ok(true)
}
/// The text of the selected range of cells, for Copy and Cut: a tab between the cells of a row
/// and a line per row (a merged cell once, where it starts). `None` without a cell range of the
/// selected table.
pub(crate) fn selected_cells_text(st: &DocState) -> Option<String> {
let id = st.selection.cells.filter(|c| st.selection.shapes == [c.0])?.0;
let ShapeKind::Table(t) = &st.shape(id)?.kind else { return None };
let ((r0, c0), (r1, c1)) = selected_range(st.selection.cells, id, t)?;
let rows = (r0..=r1).map(|r| {
let cells = (c0..=c1).filter_map(|c| t.cell(r, c).filter(|x| !x.is_covered()).map(|x| x.text.text()));
cells.collect::<Vec<_>>().join("\t")
});
Some(rows.collect::<Vec<_>>().join("\n"))
}
/// Delete with a cell range selected: the text in those cells goes and the cells stay, each
/// keeping its first paragraph's formatting.
pub(crate) fn clear_selected_cells(s: &mut Session) -> Result<Value> {
let Some((id, ..)) = s.doc()?.selection.cells else { return ok() };
let cells = s.doc()?.selection.cells;
with_table(s, &json!({"id": id}), "edit.delete", move |t| {
let range = selected_range(cells, id, t);
for (r, row) in t.rows.iter_mut().enumerate() {
for (c, cell) in row.cells.iter_mut().enumerate() {
if in_range(range, r, c) {
deckcraft_model::edit::delete(&mut cell.text, (0, 0), (usize::MAX, usize::MAX));
}
}
}
Ok(())
})
}
fn point_param(p: &Value, key: &str) -> Option<(f64, f64)> {
let a = p.get(key)?;
let (x, y) = (a.get(0)?.as_f64()?, a.get(1)?.as_f64()?);
+17 -6
View File
@@ -1387,12 +1387,18 @@ pub(crate) fn format_run(s: &mut Session, f: &(dyn Fn(&mut RunProps) + Send + Sy
if ids.is_empty() {
return Err(bad("format", SELECT_TEXT_OR_SHAPE));
}
let cells = st.selection.cells;
edit_shapes(s, &json!({"ids": ids}), "format", |sh| {
let id = sh.id;
match &mut sh.kind {
ShapeKind::Table(t) => {
for row in &mut t.rows {
for c in &mut row.cells {
ed::format_all(&mut c.text, f);
// Only the selected cells, when a range of them is selected.
let range = super::table::selected_range(cells, id, t);
for (r, row) in t.rows.iter_mut().enumerate() {
for (c, cell) in row.cells.iter_mut().enumerate() {
if super::table::in_range(range, r, c) {
ed::format_all(&mut cell.text, f);
}
}
}
}
@@ -1478,12 +1484,17 @@ pub(crate) fn format_para(s: &mut Session, f: &(dyn Fn(&mut deckcraft_model::tex
if ids.is_empty() {
return Err(bad("format", SELECT_TEXT_OR_SHAPE));
}
let cells = st.selection.cells;
edit_shapes(s, &json!({"ids": ids}), "format", |sh| {
let id = sh.id;
match &mut sh.kind {
ShapeKind::Table(t) => {
for row in &mut t.rows {
for c in &mut row.cells {
c.text.paragraphs.iter_mut().for_each(f);
let range = super::table::selected_range(cells, id, t);
for (r, row) in t.rows.iter_mut().enumerate() {
for (c, cell) in row.cells.iter_mut().enumerate() {
if super::table::in_range(range, r, c) {
cell.text.paragraphs.iter_mut().for_each(f);
}
}
}
}
+15
View File
@@ -90,6 +90,21 @@ impl Session {
return Ok(json!({"handled": true}));
}
}
if !editing && has_shapes && !mods.cmd && mods.shift && !mods.alt {
// Shift+arrow grows or shrinks a selected range of table cells.
let d = match k {
"left" => Some((0, -1)),
"right" => Some((0, 1)),
"up" => Some((-1, 0)),
"down" => Some((1, 0)),
_ => None,
};
if let Some((dr, dc)) = d
&& crate::cmd::table::extend_selected_cells(self, dr, dc)?
{
return Ok(json!({"handled": true}));
}
}
if !editing && has_shapes && !mods.cmd {
let step = if mods.alt { 1.0 } else { 6.0 };
let d = match k {
+86
View File
@@ -842,6 +842,92 @@ fn clicks_while_editing_a_cell_move_the_caret_between_cells() {
assert!(text_sel(&s).is_none());
}
#[test]
fn dragging_across_cells_selects_a_range_of_cells() {
let mut s = session();
let id = table_at(&mut s, &[100.0, 100.0, 100.0], json!([["a", "b", "c"], ["d", "e", "f"], ["g", "h", "i"]]));
// The middle of cell (r, c): the table's top-left is (100, 100), rows are 40 pt high.
let mid = |r: usize, c: usize| (150.0 + 100.0 * c as f64, 120.0 + 40.0 * r as f64);
let cells = |s: &Session| s.doc().unwrap().selection.cells;
let drag = |s: &mut Session, kind, (x, y): (f64, f64)| s.pointer(tev(kind, x, y)).unwrap();
s.execute("text.edit", &json!({"id": id, "cell": [0, 0]})).unwrap();
drag(&mut s, PointerKind::Down, mid(0, 0));
drag(&mut s, PointerKind::Drag, mid(1, 1));
assert_eq!(cells(&s), Some((id, (0, 0), (1, 1))), "leaving the cell selects whole cells");
assert!(text_sel(&s).is_none());
assert_eq!(s.doc().unwrap().selection.shapes, vec![id]);
// Back in the cell the drag started in, it selects text there again.
drag(&mut s, PointerKind::Drag, mid(0, 0));
assert_eq!((cells(&s), text_sel(&s).and_then(|t| t.cell)), (None, Some((0, 0))));
drag(&mut s, PointerKind::Drag, mid(2, 1));
drag(&mut s, PointerKind::Up, mid(2, 1));
assert_eq!(cells(&s), Some((id, (0, 0), (2, 1))));
// Shift+arrow moves the range's far corner.
let shift = Mods { shift: true, ..Default::default() };
s.key("ArrowRight", shift).unwrap();
assert_eq!(cells(&s), Some((id, (0, 0), (2, 2))));
s.key("ArrowUp", shift).unwrap();
assert_eq!(cells(&s), Some((id, (0, 0), (1, 2))));
let x = s.doc().unwrap().shape(id).unwrap().xfrm.unwrap();
assert_eq!((x.x, x.y), (100.0, 100.0), "Shift+arrow doesn't move the table");
// Formatting applies to the selected cells only.
s.execute("format.bold", &json!({})).unwrap();
s.execute("format.alignCenter", &json!({})).unwrap();
let t = table_of(&s, id);
let bold = |r: usize, c: usize| t.cell(r, c).unwrap().text.paragraphs[0].runs[0].props.bold == Some(true);
assert!(bold(0, 0) && bold(1, 2) && !bold(2, 0) && !bold(2, 2), "{t:?}");
let centered = |r: usize, c: usize| t.cell(r, c).unwrap().text.paragraphs[0].props.align.is_some();
assert!(centered(1, 1) && !centered(2, 1));
// Delete clears their text and keeps the table and the other cells.
s.key("Delete", Mods::default()).unwrap();
let t = table_of(&s, id);
let texts: Vec<String> = (0..3).flat_map(|r| (0..3).map(move |c| (r, c))).map(|(r, c)| t.cell(r, c).unwrap().text.text()).collect();
assert_eq!(texts, ["", "", "", "", "", "", "g", "h", "i"]);
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(table_of(&s, id).cell(0, 0).unwrap().text.text(), "a");
// Cut puts the cells' text on the clipboard, a tab between cells and a line per row, and
// clears them.
let r = s.execute("edit.cut", &json!({})).unwrap();
assert_eq!(r["text"], "a\tb\tc\nd\te\tf");
assert!(s.clipboard.shapes.is_empty());
assert_eq!(s.clipboard.text.as_ref().map(|b| b.paragraphs.len()), Some(2));
assert_eq!(table_of(&s, id).cell(1, 1).unwrap().text.text(), "");
assert_eq!(table_of(&s, id).cell(2, 1).unwrap().text.text(), "h");
s.execute("edit.undo", &json!({})).unwrap();
// A click in the table puts the caret in the clicked cell and ends the range.
drag(&mut s, PointerKind::Down, mid(2, 2));
drag(&mut s, PointerKind::Up, mid(2, 2));
assert_eq!((cells(&s), text_sel(&s).and_then(|t| t.cell)), (None, Some((2, 2))));
// A range that reaches into a merged cell takes all of it.
s.execute("table.merge", &json!({"id": id, "from": [0, 1], "to": [0, 2]})).unwrap();
s.execute("text.edit", &json!({"id": id, "cell": [1, 0]})).unwrap();
drag(&mut s, PointerKind::Down, mid(1, 0));
drag(&mut s, PointerKind::Drag, mid(0, 1));
drag(&mut s, PointerKind::Up, mid(0, 1));
assert_eq!(cells(&s), Some((id, (1, 0), (0, 1))));
let range = |s: &Session| cmd::table::selected_range(cells(s), id, &table_of(s, id));
assert_eq!(range(&s), Some(((0, 0), (1, 2))));
// Shift+arrow keeps the cell the range started from: Shift+Down moves the end to row 1, so the
// range no longer includes the merged cell in row 0.
s.key("ArrowDown", shift).unwrap();
assert_eq!(range(&s), Some(((1, 0), (1, 1))));
// Cut of a cell a merge covers takes the whole merged cell, and clears what it copied.
let merged = table_of(&s, id).cell(0, 1).unwrap().text.text();
assert!(!merged.is_empty());
s.execute("table.selectCells", &json!({"id": id, "from": [0, 2], "to": [0, 2]})).unwrap();
let r = s.execute("edit.cut", &json!({})).unwrap();
assert_eq!(r["text"].as_str(), Some(merged.as_str()));
assert_eq!(table_of(&s, id).cell(0, 1).unwrap().text.text(), "");
// Delete naming a shape deletes that shape, cells selected or not.
s.execute("table.selectCells", &json!({"id": id, "from": [1, 1], "to": [2, 2]})).unwrap();
s.execute("edit.delete", &json!({"id": id})).unwrap();
assert!(s.doc().unwrap().shape(id).is_none());
// A span past the table's edge (from a file) stops at the edge.
let mut t = deckcraft_model::Table::new(3, 3, 300.0, 40.0);
(t.rows[0].cells[0].row_span, t.rows[0].cells[0].grid_span) = (u32::MAX, u32::MAX);
assert_eq!(cmd::table::cell_block(&t, (1, 1), (2, 2)), ((0, 0), (2, 2)));
}
#[test]
fn a_click_in_a_selected_table_enters_the_clicked_cell() {
let mut s = session();
+40 -3
View File
@@ -196,6 +196,9 @@ enum Drag {
shape: ShapeId,
/// The text selection before the press, to tell whether the drag changed it.
before: Option<TextTarget>,
/// The text selection the press made. In a table, a drag that leaves its cell selects
/// whole cells, and coming back to the cell restores this.
press: Option<TextSel>,
},
/// Dragging a table border: the right edge of column `index`, or the bottom edge of row
/// `index`. `start` is the table-local press point, `size` the column's width or the row's
@@ -589,7 +592,8 @@ impl Session {
// Clicking inside the text being edited moves the caret / starts a text selection.
let before = self.text_selection();
if let Some((shape, pos)) = self.place_caret(p, tol, mods.shift)? {
self.tool.drag = Some(Drag::TextSelect { shape, before });
let press = self.doc()?.selection.text.clone();
self.tool.drag = Some(Drag::TextSelect { shape, before, press });
return Ok(json!({"caret": [pos.0, pos.1]}));
}
let hit = self.hit_test(p, tol);
@@ -674,6 +678,18 @@ impl Session {
self.tool.drag = Some(Drag::Warp { id, index, name, base, orig });
}
}
Some(Hit::Shape { id, .. })
if !(mods.shift || mods.cmd) && st.selection.cells.is_some_and(|c| c.0 == id && st.selection.shapes == [id]) =>
{
// A press in a table whose cells are selected starts again in the cell under the
// pointer: a click puts the caret there and a drag selects another range.
let before = self.text_selection();
if self.edit_text_at(id, p)? {
self.select(|_, sel| sel.cells = None)?;
let press = self.doc()?.selection.text.clone();
self.tool.drag = Some(Drag::TextSelect { shape: id, before, press });
}
}
Some(Hit::Shape { id, text }) => {
let selected = st.selection.shapes.contains(&id);
let toggle = mods.shift || mods.cmd;
@@ -950,8 +966,29 @@ impl Session {
};
self.tool.preview = Some(x);
}
Drag::TextSelect { shape, .. } => {
// The selection stays in the shape or table cell where it started.
Drag::TextSelect { shape, press, .. } => {
// In a table, leaving the cell the drag started in selects whole cells, as in
// PowerPoint; coming back to that cell selects text in it again.
let st = self.doc()?;
if let Some(start) = press.as_ref().and_then(|t| t.cell)
&& let Some(f) = st.shape(shape).and_then(|sh| cmd::table::table_frame(st, sh))
&& let Some(cell) = f.cell_at(f.local(p))
{
if cell != start {
self.select(|_, sel| {
sel.text = None;
sel.shapes = vec![shape];
sel.cells = Some((shape, start, cell));
})?;
return Ok(Value::Null);
}
if st.selection.text.is_none() {
self.select(|_, sel| {
sel.cells = None;
sel.text = press;
})?;
}
}
let st = self.doc()?;
if let Some(t) = st.selection.text.as_ref().filter(|t| t.shape == shape) {
let pos = cmd::text::text_pos_at(st, t, p);
+14
View File
@@ -1061,6 +1061,20 @@ fn overlays(app: &mut SlideApp, ui: &Ui, painter: &egui::Painter, xf: Xf, t: &To
.map(|q| xf.to_screen(f.affine * q));
poly(painter, pts, Stroke::new(2.0, t.accent));
}
// Shade the selected table cells in the text-selection color, each merged cell as it is drawn.
if let Some((id, ..)) = st.selection.cells
&& st.selection.text.is_none()
&& let Some(sh) = st.shape(id)
&& let deckcraft_model::ShapeKind::Table(table) = &sh.kind
&& let Some(f) = deckcraft_engine::cmd::table::table_frame(st, sh)
{
use deckcraft_engine::cmd::table::{in_range, selected_range};
let range = selected_range(st.selection.cells, id, table);
for (_, _, cr) in f.cells.iter().filter(|(r, c, _)| range.is_some() && in_range(range, *r, *c)) {
let pts = [Point::new(cr.x0, cr.y0), Point::new(cr.x1, cr.y0), Point::new(cr.x1, cr.y1), Point::new(cr.x0, cr.y1)];
painter.add(egui::Shape::convex_polygon(pts.iter().map(|q| xf.to_screen(f.affine * *q)).collect(), t.text_selection, Stroke::NONE));
}
}
// Smart guides, marquee, previews.
for g in &app.session.tool.guides {
let (a, b) =
+2 -1
View File
@@ -95,7 +95,7 @@ menu colours taken from the theme tokens (menu `chrome`, page `card`).
| Exact size/position, lock aspect | yes | yes — Size and Position dialog (height, width, rotation, scale %, lock aspect, position from the top-left), one undo step; ribbon spinners still resize one side at a time |
| Drag the frame of a text box being edited to move it | yes | yes |
| Click the frame of a text box or placeholder to select it whole (then move, duplicate or delete it) | yes | yes; a click inside takes the caret |
| Table cells: caret and selection in the cell, click into another cell, drag row and column borders | yes | yes; no cell-range selection by dragging across cells |
| Table cells: caret and selection in the cell, click into another cell, drag row and column borders, drag across cells to select a range | yes | yes: a drag from the cell being edited into another cell selects the range (merged cells whole), Shift+arrow grows or shrinks it, formatting and Delete act on the selected cells |
| Context menus on shapes, slides, text, thumbnails | yes | yes, shorter; a right click selects the shape under the pointer first |
| Group fill and outline | formats the members | Shape Fill and Shape Outline reach nested members in one undo step; a transparency-only fill edit retains each member's colour (#1079) |
| WordArt transform | Transform gallery | Shape Format ▸ Text Effects ▸ Transform (`format.textWarp`): No Transform, Follow Path and the other 36 presets. Adjust handles: see WordArt adjust handles |
@@ -169,6 +169,7 @@ consistency is open (#73, #56).
| Date | Change | Summary |
|---|---|---|
| 2026-10-24 | trivial | Table cells: dragging across cells selects a range of cells |
| 2026-10-23 | trivial | Scroll bars on the zoomed slide pane (#1157) |
| 2026-10-23 | minor | Shape Fill and Shape Outline (Home and Shape Format): the button applies the last colour picked from its menu, Accent 1 and Text 1 until one is picked. Line weight and dashes are not changed by the button; Font Color and Highlight stay as they were (#1080) |
| 2026-10-22 | trivial | Group fill and outline commands reach members, including nested groups; selection and single-step undo are preserved (#1079). The existing Arrange / Group checklist score stays D |