mirror of
https://github.com/storytold/deckcraft.git
synced 2026-10-26 11:36:58 -06:00
Tables: dragging across cells selects a range of cells
While the caret is in a table cell, a drag into another cell of the table now selects the cells between them. The range takes in every merged cell it touches, and the canvas shades the selected cells. Moving back into the first cell returns to a text selection there. Shift+arrow moves the cell the range was extended to, and the cell it started from stays. With a range selected, run and paragraph formatting apply to those cells only. Delete clears their text and keeps the cells and the table, and Copy and Cut take their text, with a tab between cells and a line per row. Shading, Borders, Cell Margins and Align take in the merged cells the range touches. A press in the table starts again in the cell under the pointer.
This commit is contained in:
@@ -134,6 +134,7 @@ app's JSON control channel (`deckcraft --control PORT`).
|
||||
|
||||
| Date | What landed |
|
||||
|---|---|
|
||||
| 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 | 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 |
|
||||
| 2026-10-18 | Find (Ctrl+F): "Find what:" has the keyboard as soon as the dialog opens, and keeps it after Enter finds a match, so the search can be typed and changed without clicking the field (#951) |
|
||||
@@ -262,6 +263,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 |
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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()?);
|
||||
|
||||
@@ -1293,12 +1293,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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1383,12 +1389,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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -89,6 +89,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 {
|
||||
|
||||
@@ -799,6 +799,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();
|
||||
|
||||
@@ -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
|
||||
@@ -586,7 +589,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);
|
||||
@@ -671,6 +675,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;
|
||||
@@ -942,8 +958,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);
|
||||
|
||||
@@ -1060,6 +1060,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
@@ -94,7 +94,7 @@ menu colours taken from the theme tokens (menu `chrome`, page `card`).
|
||||
| Text caret, selection, double/triple click, drag-select | yes | yes |
|
||||
| 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 |
|
||||
| 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 |
|
||||
@@ -168,6 +168,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 |
|
||||
|
||||
Reference in New Issue
Block a user