fix(tables): keep each table anchor in its own paragraph

Backspace at the start of the second of two consecutive tables merged their
anchor paragraphs: Story::delete kept one table id, the other table was
dropped and the paragraph held two anchors. Pasting a table next to an anchor
replaced the existing table the same way, and pasting several paragraphs
right before an anchor left its table unreferenced. Saving succeeded;
reopening failed with "paragraph must hold exactly one anchor".

fix_tables now splits a table paragraph that holds further anchors, so every
anchor gets its own paragraph again. delete and insert_story tell it which
table each surviving anchor belongs to (fix_tables_with), and insert_story
fixes tables up once after the whole paste instead of after every run.

Regression tests in doc (Backspace between tables, deleting the middle of
three, paste before/after an anchor, paragraphs pasted before an anchor) and
engine (text.delete and edit.paste through commands, then a file round-trip);
all failed before the fix.

Claude-Session: https://claude.ai/code/session_01La6SDgsmdZfgfDDmx3wiGU
This commit is contained in:
Noah Scholz
2026-10-13 08:17:51 +00:00
parent 64f0ca8fa6
commit 65bb4e3f66
5 changed files with 168 additions and 27 deletions
+37
View File
@@ -177,6 +177,43 @@ fn file_roundtrip_keeps_tables() {
assert_eq!(st.tables.values().next().unwrap().cells[0].text.text, "A1");
}
#[test]
fn backspace_and_paste_between_tables_keep_both_and_the_file_reopens() {
let n = designcraft_doc::TABLE_ANCHOR.len_utf8();
let anchor_of = |s: &Session, sid: u64, tid: u64| s.doc().unwrap().doc.story(doc::StoryId(sid)).unwrap().table_anchor(tid).unwrap();
let reopens_with = |s: &Session, sid: u64, ids: &[u64]| {
let back = crate::cmd::file_from(&crate::cmd::file_bytes(&s.doc().unwrap().doc)).unwrap();
let st = back.story(doc::StoryId(sid)).unwrap();
assert!(ids.iter().all(|id| st.table_anchor(*id).is_some()), "{:?}", st.tables.keys());
assert_eq!(st.tables.len(), ids.len());
};
// Backspace at the start of the second of two consecutive tables.
let (mut s, sid, _) = session_with_frame();
let t1 = s.execute("table.insert", &json!({"rows": 2, "cols": 2})).unwrap()["table"].as_u64().unwrap();
let len = s.doc().unwrap().doc.story(doc::StoryId(sid)).unwrap().len();
s.execute("text.select", &json!({"story": sid, "anchor": len})).unwrap();
let t2 = s.execute("table.insert", &json!({"rows": 2, "cols": 2})).unwrap()["table"].as_u64().unwrap();
let a2 = anchor_of(&s, sid, t2);
s.execute("text.select", &json!({"story": sid, "anchor": a2})).unwrap();
s.execute("text.delete", &json!({})).unwrap();
check(&s);
reopens_with(&s, sid, &[t1, t2]);
// Pasting a copy of a table right after its anchor.
let (mut s, sid, _) = session_with_frame();
let t1 = s.execute("table.insert", &json!({"rows": 2, "cols": 2})).unwrap()["table"].as_u64().unwrap();
let a1 = anchor_of(&s, sid, t1);
s.execute("text.select", &json!({"story": sid, "anchor": a1, "focus": a1 + n})).unwrap();
s.execute("edit.copy", &json!({})).unwrap();
s.execute("text.select", &json!({"story": sid, "anchor": a1 + n})).unwrap();
let pasted = s.execute("edit.paste", &json!({})).unwrap();
check(&s);
assert_eq!(pasted["pos"], a1 + 2 * n + 1);
let st = s.doc().unwrap().doc.story(doc::StoryId(sid)).unwrap().clone();
let copy = st.tables.keys().copied().find(|id| *id != t1).unwrap();
assert_eq!((st.table_anchor(t1), st.table_anchor(copy)), (Some(a1), Some(a1 + n + 1)));
reopens_with(&s, sid, &[t1, copy]);
}
fn table_for(s: &Session, sid: u64, tid: u64) -> &doc::Table {
s.doc().unwrap().doc.story(doc::StoryId(sid)).unwrap().tables.get(&tid).unwrap()
}