From 17ba445b1ad77128ca908dabfa4bc9892ef7949f Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 24 Oct 2026 10:37:46 -0700 Subject: [PATCH] Tables: Split Table fixes the vertical merges and row content controls it splits Split Table moved the rows below the caret into a new table and left their cells and controls as they were. A cell merged down across the split left the second table starting with a cell that continues a merge from no cell above, and the first table ending with a merge that no row continues. A row content control around rows on both sides stayed open in the first table, and its close went to the second, so a saved file lost the control around the rows below the split. Table::split_off now does the split: a merge that the split cuts ends in the first table and starts again in the second, and a row content control that the split cuts closes after the first table and opens again, without its id, before the second. --- crates/doc/src/table.rs | 48 +++++++++++++++++++++++++++ crates/engine/src/cmd/table.rs | 6 +--- crates/engine/src/tests.rs | 59 ++++++++++++++++++++++++++++++++++ 3 files changed, 108 insertions(+), 5 deletions(-) diff --git a/crates/doc/src/table.rs b/crates/doc/src/table.rs index 3031f7420..d3c028067 100644 --- a/crates/doc/src/table.rs +++ b/crates/doc/src/table.rs @@ -165,6 +165,54 @@ impl Table { self.rows.remove(r); } + /// Split the table before row `at`: the rows from `at` on move to the returned table, which + /// has this table's properties and grid. A vertical merge that the split cuts ends in this + /// table's last row and starts again in the new table's first row. A row content control + /// around rows on both sides closes after this table's last row and opens again (without its + /// `w:id`) before the new table's first row. Content controls that close after this table + /// close after the new one. + pub fn split_off(&mut self, at: usize) -> Table { + let at = at.min(self.rows.len()); + let mut open = Vec::new(); + for row in self.rows.iter().take(at) { + open.extend(row.controls.open.iter().cloned()); + let close = usize::try_from(row.controls.close).unwrap_or(usize::MAX).min(open.len()); + open.truncate(open.len() - close); + } + let mut tail = Table { + props: self.props.clone(), + grid: self.grid.clone(), + rows: self.rows.split_off(at), + controls: ControlWrap { open: Vec::new(), close: std::mem::take(&mut self.controls.close) }, + }; + if let Some(last) = self.rows.last_mut() { + last.controls.close = last.controls.close.saturating_add(u32::try_from(open.len()).unwrap_or(u32::MAX)); + for c in &mut last.cells { + if c.props.vmerge == VMerge::Restart { + c.props.vmerge = VMerge::None; + } + } + } + for c in &mut open { + c.id = None; + } + if let Some(first) = tail.rows.first_mut() { + let own = std::mem::replace(&mut first.controls.open, open); + first.controls.open.extend(own); + } + let starts: Vec<(usize, usize)> = (0..tail.rows.first().map_or(0, |row| row.cells.len())) + .filter(|&c| tail.rows.first().and_then(|row| row.cells.get(c)).is_some_and(|x| x.props.vmerge == VMerge::Continue)) + .map(|c| (c, tail.grid_col(0, c))) + .collect(); + for (c, g) in starts { + let more = tail.cell_at_grid(1, g).and_then(|i| tail.rows.get(1)?.cells.get(i)).is_some_and(|x| x.props.vmerge == VMerge::Continue); + if let Some(x) = tail.rows.first_mut().and_then(|r| r.cells.get_mut(c)) { + x.props.vmerge = if more { VMerge::Restart } else { VMerge::None }; + } + } + tail + } + /// Insert a grid column at grid index `g` (cells spanning it widen instead). pub fn insert_col(&mut self, g: usize) { if self.grid.len() >= MAX_COLS { diff --git a/crates/engine/src/cmd/table.rs b/crates/engine/src/cmd/table.rs index 3087b991e..09d9af939 100644 --- a/crates/engine/src/cmd/table.rs +++ b/crates/engine/src/cmd/table.rs @@ -840,11 +840,7 @@ fn split_table(s: &mut Session, _: &Value) -> CmdResult { } let story = s.sel.focus.story; let t = s.doc.table_mut(story, &tp)?; - let tail_rows = t.rows.split_off(r); - // Content controls closing after the table now close after its second part. - let close = std::mem::take(&mut t.controls.close); - let controls = wordcraft_doc::ControlWrap { open: Vec::new(), close }; - let tail = Table { props: t.props.clone(), grid: t.grid.clone(), rows: tail_rows, controls }; + let tail = t.split_off(r); let after = tp.with_last(tp.last() + 1); s.doc.insert_block(story, &after, Block::Para(Paragraph::new()))?; s.doc.insert_block(story, &after.with_last(after.last() + 1), Block::Table(tail))?; diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 75bb875b9..6411c4200 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -1772,6 +1772,65 @@ fn delete_row_on_a_merge_start_promotes_the_cell_below() { assert_eq!(vm(&s, 1), VMerge::Continue); } +/// Split Table under a vertically merged cell: the merge ends in the first table and starts +/// again in the second. +#[test] +fn split_table_ends_and_restarts_a_merge_it_cuts() { + use wordcraft_doc::props::VMerge::{self, Continue, None as No, Restart}; + let cell = |r: u32, c: u32| json!({"story": "body", "path": [0, r, c, 0], "off": 0}); + let tables = |s: &Session| -> Vec>> { + let rows = |t: &wordcraft_doc::Table| t.rows.iter().map(|r| r.cells.iter().map(|c| c.props.vmerge).collect()).collect(); + s.doc.body.iter().filter_map(|b| b.as_table()).map(rows).collect() + }; + let merged = |last: u32| { + let mut s = table_from("A\tB\nC\tD\nE\tF\nG\tH"); + run(&mut s, "select.range", json!({"anchor": cell(0, 0), "focus": cell(last, 0)})); + run(&mut s, "table.merge", json!({})); + s + }; + // Rows 0 to 2 merged, split at row 1: the second table's merge covers rows 1 and 2. + let mut s = merged(2); + let before = tables(&s); + run(&mut s, "caret.set", json!({"pos": cell(1, 1)})); + run(&mut s, "table.splitTable", json!({})); + assert_eq!(tables(&s), [vec![vec![No, No]], vec![vec![Restart, No], vec![Continue, No], vec![No, No]]]); + run(&mut s, "edit.undo", json!({})); + assert_eq!(tables(&s), before); + // Rows 0 and 1 merged, split at row 1: neither part keeps a merge. + let mut s = merged(1); + run(&mut s, "caret.set", json!({"pos": cell(1, 1)})); + run(&mut s, "table.splitTable", json!({})); + assert_eq!(tables(&s), [vec![vec![No, No]], vec![vec![No, No], vec![No, No], vec![No, No]]]); +} + +/// Split Table inside a row content control (a repeating section) around several rows: the +/// control closes after the first table and opens again before the second. +#[test] +fn split_table_keeps_a_row_control_on_both_parts() { + let cell = |r: u32, c: u32| json!({"story": "body", "path": [0, r, c, 0], "off": 0}); + let control = |tag: &str| wordcraft_doc::ContentControl { id: Some(7), tag: tag.into(), ..Default::default() }; + let mut s = table_from("A\tB\nC\tD\nE\tF\nG\tH"); + if let Some(b) = s.doc.body.iter_mut().find(|b| b.as_table().is_some()) + && let wordcraft_doc::Block::Table(t) = std::sync::Arc::make_mut(b) + { + // "outer" goes around every row, and "inner" goes around row 1 alone. + t.rows[0].controls.open = vec![control("outer")]; + t.rows[1].controls.open = vec![control("inner")]; + t.rows[1].controls.close = 1; + t.rows[3].controls.close = 1; + } + run(&mut s, "caret.set", json!({"pos": cell(2, 0)})); + run(&mut s, "table.splitTable", json!({})); + let wraps = |t: &wordcraft_doc::Table| -> Vec<(Vec, u32)> { + t.rows.iter().map(|r| (r.controls.open.iter().map(|c| c.tag.clone()).collect(), r.controls.close)).collect() + }; + let parts: Vec<&wordcraft_doc::Table> = s.doc.body.iter().filter_map(|b| b.as_table()).collect(); + assert_eq!(parts.len(), 2); + assert_eq!(wraps(parts[0]), [(vec!["outer".to_string()], 0), (vec!["inner".to_string()], 2)], "the first table closes the control"); + assert_eq!(wraps(parts[1]), [(vec!["outer".to_string()], 0), (vec![], 1)], "the second table opens it again"); + assert_eq!(parts[1].rows[0].controls.open[0].id, None, "the copy doesn't repeat the control's id"); +} + fn cells(s: &Session) -> Option<((usize, usize), (usize, usize))> { s.cell_range().map(|x| (x.rows, x.cols)) }