diff --git a/ROADMAP.md b/ROADMAP.md index 91a951616..2788ac9cf 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,7 @@ **Stage: pre-alpha** · next: alpha, ~0% and ~15–25 h away; beta ~28 points and ~420–750 h beyond that (the core-workflow gate, not the percentage: ready for real work is already ~47%) -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (cancelable macOS Dock Quit) · **Target:** Adobe InDesign 2026 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (table anchors keep their own paragraphs) · **Target:** Adobe InDesign 2026 DesignCraft aims at full Adobe InDesign parity, and to be better: faster, open (documented JSON format + IDML), scriptable by agents (MCP), and available on the web. @@ -129,6 +129,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m Newest first. +- **2026-10-13** · Backspace between two consecutive tables, pasting a table next to a table anchor, and pasting several paragraphs right before one keep every table on its own anchor paragraph. Previously a table was dropped or left without its anchor, and the saved file failed to reopen. No readiness percentage change. - **2026-10-12** · macOS Dock Quit uses the same unsaved-document prompts as window and menu Quit. Repeated requests keep the current answers; Cancel retains every tab and unsaved recovery checkpoint, and completed Save/Don't Save answers exit through the ordinary event loop. No readiness percentage change. - **2026-10-12** · Orderly native exit writes the latest recovery state after finalizing current quit approvals; incomplete or stale plans keep their documents, and Undo to the saved state clears stale recovery. Unavailable or failed recovery is reported. Cancelable macOS Dock Quit remains open (#544). No readiness percentage change. - **2026-10-12** · Recovered documents keep a checkpoint until saved, closed or reverted, including a second crash before autosave. Restarted document IDs and explicit recovery folders preserve other snapshots; failed republishing retains the original. No readiness percentage change. diff --git a/crates/doc/src/slice.rs b/crates/doc/src/slice.rs index f85ec4adc..cc320bd09 100644 --- a/crates/doc/src/slice.rs +++ b/crates/doc/src/slice.rs @@ -57,18 +57,25 @@ impl Story { if src.text.is_empty() { return pos; } - // Text first (creates empty marker entries), then formats and marker payloads. + // The table anchored in the paragraph at `pos` keeps its anchor wherever the paste moves it. + let p0 = self.para_at(pos); + let own = self.paras.get(p0).and_then(|p| p.table).and_then(|id| { + let r = self.para_ranges().get(p0)?.clone(); + Some((r.start + self.text.get(r)?.find(TABLE_ANCHOR)?, id)) + }); + // Text first (creates empty marker entries), then formats and marker payloads; tables are + // fixed up once at the end. let mut at = pos; for (r, f) in src.runs() { if r.is_empty() { continue; } - self.insert_with(at, &src.text[r.clone()], f.clone()); + self.insert_with_raw(at, &src.text[r.clone()], f.clone()); at += r.len(); } let end = at; + let mut claims: Vec<(usize, u64)> = own.map(|(a, id)| (if a >= pos { a + (end - pos) } else { a }, id)).into_iter().collect(); // Paragraph formats of the pasted paragraphs after the first. - let p0 = self.para_at(pos); for (k, pf) in src.paras.iter().enumerate().skip(1) { if let Some(p) = self.paras.get_mut(p0 + k) { *p = crate::story::ParaFormat { table: None, ..pf.clone() }; @@ -106,25 +113,25 @@ impl Story { *slot = Arc::new(crate::xref::TextAnchor { id, name: a.name.clone() }); } } - // Tables: re-attach by paragraph (fresh ids so a pasted copy never shares an id). + // Tables: re-attach at their anchors (fresh ids so a pasted copy never shares an id). if !src.tables.is_empty() { let mut next = self.tables.keys().max().copied().unwrap_or(0) + 1; - for (k, pf) in src.paras.iter().enumerate() { + let ranges = src.para_ranges(); + for (pf, r) in src.paras.iter().zip(ranges) { let Some(tid) = pf.table else { continue }; let Some(t) = src.tables.get(&tid) else { continue }; - let pi = p0 + k; + let Some(k) = src.text.get(r.clone()).and_then(|t| t.find(TABLE_ANCHOR)) else { continue }; let mut t = (**t).clone(); t.id = next; next += 1; + claims.push((pos + r.start + k, t.id)); self.tables.insert(t.id, Arc::new(t)); - if let Some(p) = self.paras.get_mut(pi) { - p.table = Some(next - 1); - } } - self.fix_tables(); } + let splits = self.fix_tables_with(&claims); self.rev += 1; - end + // Past any return the fix-up put inside the pasted text. + end + splits.iter().filter(|&&s| s < end).count() } } @@ -133,6 +140,32 @@ mod tests { use super::*; use crate::story::ParaFormat; + #[test] + fn pasting_a_table_next_to_another_gives_each_its_own_paragraph() { + let mut s = Story::with_text(StoryId(1), "x", ParaFormat::default()); + let a = s.insert_table(1, crate::Table::new(5, 1, 1, 0, 0, 10.0)); + let n = TABLE_ANCHOR.len_utf8(); + let copy = s.extract(a..a + n); + // After the anchor: the copy follows the original. + let mut after = s.clone(); + let end = after.insert_story(a + n, ©); + after.check().unwrap(); + assert_eq!(after.text, format!("x\n{TABLE_ANCHOR}\n{TABLE_ANCHOR}\n")); + assert_eq!((after.table_anchor(5), after.table_anchor(6)), (Some(a), Some(a + n + 1))); + assert_eq!(end, a + 2 * n + 1); + // Before the anchor: the copy comes first and the original keeps its table. + let mut before = s.clone(); + let end = before.insert_story(a, ©); + before.check().unwrap(); + assert_eq!((before.table_anchor(6), before.table_anchor(5)), (Some(a), Some(a + n + 1))); + assert_eq!(end, a + n); + // Paragraphs pasted before the anchor keep the table too. + let mut text = s.clone(); + text.insert_story(a, &Story::with_text(StoryId(2), "Hello\nWorld", ParaFormat::default())); + text.check().unwrap(); + assert_eq!(text.table_anchor(5), Some(a + "Hello\nWorld".len())); + } + #[test] fn copy_paste_keeps_formatting_and_notes() { let mut a = Story::with_text(StoryId(1), "Alpha beta\nGamma delta", ParaFormat::default()); diff --git a/crates/doc/src/story.rs b/crates/doc/src/story.rs index 6dbfa13c6..98d63eb82 100644 --- a/crates/doc/src/story.rs +++ b/crates/doc/src/story.rs @@ -255,7 +255,8 @@ impl Story { self.insert_with_raw(pos, text, fmt); } - fn insert_with_raw(&mut self, pos: usize, text: &str, fmt: CharFormat) { + /// [`Story::insert_with`] without the table fix-up. + pub(crate) fn insert_with_raw(&mut self, pos: usize, text: &str, fmt: CharFormat) { if text.is_empty() { return; } @@ -332,20 +333,25 @@ impl Story { self.marks_deleted(a, b); let pi = self.para_at(a); let removed = self.text[a..b].bytes().filter(|c| *c == b'\n').count(); - // Merged paragraphs keep a table whose anchor survives the deletion. + // Merged paragraphs keep the tables whose anchors survive the deletion (the fix-up gives + // each its own paragraph again). + let mut claims = Vec::new(); if removed > 0 && !self.tables.is_empty() { let ranges = self.para_ranges(); - let first = self.paras[pi].table.filter(|_| self.text[ranges[pi].start..a].contains(TABLE_ANCHOR)); - let last = self.paras[pi + removed] - .table - .filter(|_| self.text[b.min(ranges[pi + removed].end)..ranges[pi + removed].end].contains(TABLE_ANCHOR)); - self.paras[pi].table = first.or(last); + let anchor = |pi: usize, r: std::ops::Range| { + self.paras[pi].table.and_then(|id| self.text[r.clone()].find(TABLE_ANCHOR).map(|k| (r.start + k, id))) + }; + let first = anchor(pi, ranges[pi].start..a); + let end = ranges[pi + removed].end; + let last = anchor(pi + removed, b.min(end)..end).map(|(at, id)| (at.saturating_sub(b - a), id)); + self.paras[pi].table = first.or(last).map(|(_, id)| id); + claims.extend(first.into_iter().chain(last)); } self.paras.drain(pi + 1..pi + 1 + removed); self.text.replace_range(a..b, ""); self.remove_run_bytes(a, b - a); self.normalize(); - self.fix_tables(); + self.fix_tables_with(&claims); self.rev += 1; } diff --git a/crates/doc/src/table.rs b/crates/doc/src/table.rs index 7fe385e98..e51a9cbe3 100644 --- a/crates/doc/src/table.rs +++ b/crates/doc/src/table.rs @@ -1012,21 +1012,61 @@ impl Story { /// Keep paragraphs and tables consistent after an edit: a paragraph keeps its table only if it /// still contains an anchor character, every table is referenced once, and tables nobody - /// references are dropped. + /// references are dropped. An anchor that shares a table's paragraph with another anchor gets + /// a paragraph of its own again, so a table paragraph always holds exactly one anchor. pub(crate) fn fix_tables(&mut self) { + self.fix_tables_with(&[]); + } + + /// [`Story::fix_tables`] for an edit that knows which table an anchor belongs to: `claims` are + /// `(anchor byte offset, table id)` pairs that win over the paragraphs' table ids (a paragraph + /// names one table, so merging two anchor paragraphs would otherwise lose one). Every other + /// table takes the first unclaimed anchor of its paragraph. Returns the byte offsets, before + /// the fix-up, where it inserted paragraph returns. + pub(crate) fn fix_tables_with(&mut self, claims: &[(usize, u64)]) -> Vec { if self.tables.is_empty() && self.paras.iter().all(|p| p.table.is_none()) { - return; + return Vec::new(); } - let ranges = self.para_ranges(); + let mut owner = std::collections::BTreeMap::new(); let mut used = std::collections::HashSet::new(); - for (p, r) in self.paras.iter_mut().zip(ranges) { - if let Some(id) = p.table - && (!self.text[r].contains(TABLE_ANCHOR) || !self.tables.contains_key(&id) || !used.insert(id)) + for &(at, id) in claims { + if self.text.get(at..).is_some_and(|t| t.starts_with(TABLE_ANCHOR)) + && self.tables.contains_key(&id) + && !owner.contains_key(&at) + && used.insert(id) { - p.table = None; + owner.insert(at, id); } } + let ranges = self.para_ranges(); + for (p, r) in self.paras.iter().zip(&ranges) { + let Some(id) = p.table.filter(|id| self.tables.contains_key(id) && !used.contains(id)) else { continue }; + let free = self.text[r.clone()].match_indices(TABLE_ANCHOR).map(|(k, _)| r.start + k).find(|at| !owner.contains_key(at)); + if let Some(at) = free { + used.insert(id); + owner.insert(at, id); + } + } + // A paragraph holding a table's anchor and other anchors is split before each later anchor. + let mut splits = Vec::new(); + for r in &ranges { + let anchors: Vec = self.text[r.clone()].match_indices(TABLE_ANCHOR).map(|(k, _)| r.start + k).collect(); + if anchors.len() > 1 && anchors.iter().any(|at| owner.contains_key(at)) { + splits.extend(anchors.into_iter().skip(1)); + } + } + for &at in splits.iter().rev() { + self.insert_raw(at, "\n"); + } + // Each owned anchor, moved past the returns inserted before it, names its paragraph's table. + let owner: std::collections::BTreeMap = + owner.into_iter().map(|(at, id)| (at + splits.partition_point(|&s| s <= at), id)).collect(); + let ranges = self.para_ranges(); + for (p, r) in self.paras.iter_mut().zip(ranges) { + p.table = owner.range(r).next().map(|(_, &id)| id); + } self.tables.retain(|id, _| used.contains(id)); + splits } /// Check table invariants (part of [`Story::check`]). @@ -1097,6 +1137,30 @@ mod tests { assert!(s.tables.is_empty()); } + #[test] + fn deleting_the_return_between_two_tables_keeps_both() { + // Backspace at the start of the second table's paragraph. + let mut s = story("x"); + let a1 = s.insert_table(1, Table::new(1, 1, 1, 0, 0, 10.0)); + let a2 = s.insert_table(s.len(), Table::new(2, 1, 1, 0, 0, 10.0)); + let before = s.text.clone(); + s.delete(a2 - 1..a2); + s.check().unwrap(); + assert_eq!(s.text, before); + assert_eq!((s.table_anchor(1), s.table_anchor(2)), (Some(a1), Some(a2))); + // Deleting the table between two others keeps the outer ones on their own anchors. + let mut s = story("x"); + for id in 1..=3 { + s.insert_table(s.len(), Table::new(id, 1, 1, 0, 0, 10.0)); + } + let (a1, a3) = (s.table_anchor(1).unwrap(), s.table_anchor(3).unwrap()); + s.delete(a1 + TABLE_ANCHOR.len_utf8()..a3); + s.check().unwrap(); + assert_eq!(s.text, format!("x\n{TABLE_ANCHOR}\n{TABLE_ANCHOR}\n")); + assert_eq!(s.tables.keys().copied().collect::>(), [1, 3]); + assert_eq!(s.table_anchor(3), Some(a1 + TABLE_ANCHOR.len_utf8() + 1)); + } + #[test] fn edits_around_the_anchor_keep_the_table() { let mut s = story("ab\ncd"); diff --git a/crates/engine/src/tests_table.rs b/crates/engine/src/tests_table.rs index 45861347d..4ca02f889 100644 --- a/crates/engine/src/tests_table.rs +++ b/crates/engine/src/tests_table.rs @@ -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() }