mirror of
https://github.com/storytold/deckcraft.git
synced 2026-10-26 11:36:58 -06:00
Merge #1282: Tables: dragging across cells selects a range of cells
This commit is contained in:
@@ -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 |
|
||||
|
||||
@@ -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()?);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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 |
|
||||
|
||||
Reference in New Issue
Block a user