T: Track Changes — marked insertions and deletions, accept/reject all

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas
2026-10-03 03:57:22 -04:00
co-authored by Claude Opus 5.5
parent 60a8fefe37
commit a4e76ed620
15 changed files with 241 additions and 8 deletions
+2 -1
View File
@@ -82,6 +82,7 @@ DesignCraft aims at full Adobe InDesign parity — and to be better: faster, ope
- Edit ▸ Menus (`window.hideMenuItem`): hide menu items; menus with hidden items end in Show All Menu Items.
- View ▸ Separations Preview (`view.separations`): one process plate as ink density, or areas over an ink limit in red (a GCR preview of the rendered view; spot plates and the flattener preview aren't there).
- Books (`book.new/open/add/remove/list/styleSource/paginate/syncStyles/exportPdf`, Book panel): `.dcbook` files, continuous page numbering, style sync from a style source, one merged PDF.
- Track Changes (`changes.track/list/acceptAll/rejectAll`, Type ▸ Track Changes): typing is marked added (highlighted on screen), deletions are kept but hidden from layout and exports until accepted or rejected. Per-change accept/reject and authors aren't there yet.
- Drag and drop text editing: drag selected text to move it (Alt copies) with its formatting (`text.release`).
- Image Import Options: place any page of a multi-page PDF (`file.place {pdfPage}`; the app asks which page), shown on screen and embedded as that page in PDF export.
- Define Lists: named numbered lists that continue across stories in page order (`list.define`, paragraph `listName` / `startAt`), round-tripped through IDML; `null` in `type.para` / `type.char` now removes an override.
@@ -130,7 +131,7 @@ DesignCraft aims at full Adobe InDesign parity — and to be better: faster, ope
## How far from full parity (estimate, 2026-10-02, updated)
**Breadth: ~89% weighted** (P0 core 99%, P1 95%, P2 39%) over the 275 features of the InDesign catalogue, scored
**Breadth: ~93% weighted** (P0 core 99%, P1 97%, P2 58%) over the 275 features of the InDesign catalogue, scored
row by row in [docs/parity.md](docs/parity.md) (`cargo xtask parity` recomputes it). Many features scored done still
lack some of InDesign's options or dialog details, so **overall parity including depth is about 72%**.
+2
View File
@@ -58,6 +58,8 @@ pub struct RunStyle {
pub custom_tracking: bool,
/// The first condition applied (its indicator colour underlines the text on screen).
pub condition: Option<String>,
/// Added while tracking changes (marked on screen).
pub inserted: bool,
}
/// An underline or strikethrough bar: its top edge `offset` below the baseline (negative =
+3 -1
View File
@@ -134,6 +134,7 @@ impl StyleTable<'_> {
.or_insert_with(|| !p.font_family.is_empty() && !designcraft_fonts::FontDb::global().has_family(&p.font_family)),
custom_tracking: p.tracking.abs() > 1e-9 || matches!(p.kerning, designcraft_doc::Kerning::Manual(_)),
condition: p.conditions.first().cloned(),
inserted: p.change == designcraft_doc::ChangeMark::Inserted,
};
if let Some(i) = self.styles.iter().rposition(|s| *s == rs) {
return i as u32;
@@ -199,7 +200,8 @@ pub(crate) fn shape_para(
};
let props = styles.resolve_char(para_chars, fmt);
let style = table.intern(&props);
if !props.conditions.is_empty() && props.conditions.iter().all(|c| sub.hidden_conditions.contains(c)) {
let deleted = props.change == designcraft_doc::ChangeMark::Deleted;
if deleted || (!props.conditions.is_empty() && props.conditions.iter().all(|c| sub.hidden_conditions.contains(c))) {
// Hidden conditional text: zero-width, unbreakable, undrawn place-holders keep every
// byte addressable (caret, selection) without taking space.
let face = db.face(&props.font_family, &props.font_style);
+12
View File
@@ -115,6 +115,16 @@ pub enum Leading {
Points(f64),
}
/// A tracked change on text.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum ChangeMark {
#[default]
None,
Inserted,
Deleted,
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", tag = "kind", content = "value")]
pub enum Kerning {
@@ -464,6 +474,8 @@ attr_set! {
otf_features: Vec<String> = Vec::new(),
/// Conditional text: the conditions applied (Window › Type & Tables › Conditional Text).
conditions: Vec<String> = Vec::new(),
/// Track Changes: text added or deleted while tracking (deleted text isn't laid out).
change: ChangeMark = ChangeMark::None,
}
}
+3
View File
@@ -325,6 +325,8 @@ pub struct DocSettings {
pub blend_space: BlendSpace,
/// Preferences › Appearance of Black: [Black] at 100% overprints (Overprint Preview, output).
pub overprint_black: bool,
/// Type › Track Changes: edits are recorded as inserted / deleted text.
pub track_changes: bool,
}
impl Default for DocSettings {
@@ -354,6 +356,7 @@ impl Default for DocSettings {
advanced_type: AdvancedType::default(),
blend_space: BlendSpace::Cmyk,
overprint_black: true,
track_changes: false,
}
}
}
+159
View File
@@ -0,0 +1,159 @@
//! Type › Track Changes: record edits as inserted / deleted text; accept or reject them.
use designcraft_doc::{ChangeMark, Story, StoryId};
use serde_json::{Value, json};
use super::{CommandSpec, cmd, has_doc};
use crate::{Result, Session};
/// Mark `r` deleted (text this session inserted while tracking is simply removed). Returns where
/// the text after the range now starts.
pub(crate) fn mark_deleted(st: &mut Story, r: std::ops::Range<usize>) -> usize {
if r.is_empty() {
return r.start;
}
// Inserted pieces inside the range go away; the rest is marked deleted.
let mut ins: Vec<std::ops::Range<usize>> = st
.runs()
.filter(|(rr, f)| f.over.change == Some(ChangeMark::Inserted) && rr.start < r.end && rr.end > r.start)
.map(|(rr, _)| rr.start.max(r.start)..rr.end.min(r.end))
.collect();
let mut end = r.end;
ins.sort_by_key(|x| std::cmp::Reverse(x.start));
for x in ins {
st.delete(x.clone());
end -= x.len();
}
if end > r.start {
st.format_chars(r.start..end, |f| f.over.change = Some(ChangeMark::Deleted));
}
end
}
/// Ranges of `mark` in `st`, last first.
fn ranges(st: &Story, mark: ChangeMark) -> Vec<std::ops::Range<usize>> {
let mut v: Vec<std::ops::Range<usize>> = st.runs().filter(|(_, f)| f.over.change == Some(mark)).map(|(r, _)| r).collect();
v.sort_by_key(|r| std::cmp::Reverse(r.start));
v
}
/// Accept (or reject) every change in `st`.
fn resolve(st: &mut Story, accept: bool) -> usize {
let (remove, keep) = if accept { (ChangeMark::Deleted, ChangeMark::Inserted) } else { (ChangeMark::Inserted, ChangeMark::Deleted) };
let removed = ranges(st, remove);
let n = removed.len() + ranges(st, keep).len();
for r in removed {
st.delete(r);
}
let len = st.len();
st.format_chars(0..len, |f| {
if f.over.change.is_some() {
f.over.change = None;
}
});
n
}
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!("changes.track", "Track Changes", ["Type", "Track Changes"], None, "{on?: bool (default: toggle)}", has_doc, |s, p| {
let cur = s.doc()?.doc.settings.track_changes;
let on = p.get("on").and_then(Value::as_bool).unwrap_or(!cur);
s.edit(|d, _| {
d.settings.track_changes = on;
Ok(json!({"on": on}))
})
}),
cmd!(query "changes.list", "Changes", [], None, "{} → [{story, start, end, kind: inserted|deleted, text}]", has_doc, |s, _| {
let d = &s.doc()?.doc;
let mut out = Vec::new();
for st in d.stories.values() {
for (r, f) in st.runs() {
let kind = match f.over.change {
Some(ChangeMark::Inserted) => "inserted",
Some(ChangeMark::Deleted) => "deleted",
_ => continue,
};
out.push(json!({"story": st.id.0, "start": r.start, "end": r.end, "kind": kind, "text": st.text[r].to_string()}));
}
}
Ok(Value::Array(out))
}),
cmd!(
"changes.acceptAll",
"Accept All Changes",
["Type", "Track Changes"],
None,
"{story?} — deleted text goes, added text stays",
has_doc,
|s, p| all(s, p, true)
),
cmd!(
"changes.rejectAll",
"Reject All Changes",
["Type", "Track Changes"],
None,
"{story?} — added text goes, deleted text comes back",
has_doc,
|s, p| all(s, p, false)
),
]
}
fn all(s: &mut Session, p: &Value, accept: bool) -> Result<Value> {
let only = p.get("story").and_then(Value::as_u64).map(StoryId);
s.edit(|d, sel| {
let ids: Vec<StoryId> = d.stories.keys().copied().filter(|id| only.is_none_or(|o| o == *id)).collect();
let mut n = 0;
for id in ids {
if let Some(st) = d.story_mut(id) {
n += resolve(st, accept);
}
}
// The caret may now be past the end.
if let Some(t) = &mut sel.text
&& let Some(st) = d.story(t.story)
{
t.anchor = t.anchor.min(st.len());
t.focus = t.focus.min(st.len());
}
Ok(json!({"changes": n}))
})
}
#[cfg(test)]
mod tests {
use serde_json::json;
use crate::Session;
#[test]
fn tracked_edits_accept_and_reject() {
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
let r = s.execute("frame.create", &json!({"rect": [72, 72, 400, 200], "content": "text", "text": "The cat sat"})).unwrap();
let sid = designcraft_doc::StoryId(r["story"].as_u64().unwrap());
s.execute("changes.track", &json!({"on": true})).unwrap();
// Replace "cat" with "dog", then type and erase a typo (gone without a trace).
s.execute("text.select", &json!({"story": sid.0, "anchor": 4, "focus": 7})).unwrap();
s.execute("text.insert", &json!({"text": "dog"})).unwrap();
s.execute("text.insert", &json!({"text": "x"})).unwrap();
s.execute("text.delete", &json!({})).unwrap();
let text = |s: &Session| s.doc().unwrap().doc.stories[&sid].text.clone();
assert_eq!(text(&s), "The catdog sat", "deleted text stays (marked)");
let l = s.execute("changes.list", &json!({})).unwrap();
assert_eq!(l.as_array().unwrap().len(), 2, "{l}");
// Deleted text takes no room in the layout.
let d = s.doc().unwrap().doc.clone();
let cs = s.cache.get(&d, sid, None);
assert!(cs.frames[0].lines[0].glyphs.iter().any(|g| !g.visible && g.byte >= 4 && g.byte < 7));
s.execute("changes.rejectAll", &json!({})).unwrap();
assert_eq!(text(&s), "The cat sat");
s.execute("edit.undo", &json!({})).unwrap();
let exported = s.execute("file.exportText", &json!({"story": sid.0})).unwrap()["text"].as_str().unwrap().to_string();
assert_eq!(exported, "The dog sat", "deleted text isn't exported");
s.execute("changes.acceptAll", &json!({})).unwrap();
assert_eq!(text(&s), "The dog sat");
assert!(s.execute("changes.list", &json!({})).unwrap().as_array().unwrap().is_empty());
}
}
+2
View File
@@ -5,6 +5,7 @@ pub mod anchors;
mod articles;
pub mod book;
mod captions;
mod changes;
mod conditions;
mod datamerge;
mod edit;
@@ -149,6 +150,7 @@ pub fn command_specs() -> &'static [CommandSpec] {
v.extend(paths::specs());
v.extend(book::specs());
v.extend(captions::specs());
v.extend(changes::specs());
v.extend(conditions::specs());
v.extend(library::specs());
v.extend(lists::specs());
+25 -1
View File
@@ -286,6 +286,7 @@ fn insert(s: &mut Session, p: &Value) -> Result<Value> {
}
let raw = p.get("raw").and_then(Value::as_bool).unwrap_or(false);
let text = if s.prefs.typographers_quotes && !raw { smart_quotes(s, &text) } else { text };
let tracking = s.doc()?.doc.settings.track_changes;
s.edit(|d, sel| {
let t = sel.text.ok_or_else(|| bad("text.insert", "no insertion point"))?;
let st = d.text_story_mut(t.story, t.cell).ok_or(designcraft_doc::DocError::NoStory(t.story))?;
@@ -307,6 +308,16 @@ fn insert(s: &mut Session, p: &Value) -> Result<Value> {
text.insert(0, '\n');
}
}
if tracking && t.cell.is_none() {
// Track Changes: the replaced text is marked deleted, the typing inserted after it.
let at = super::changes::mark_deleted(st, r.clone());
let mut fmt = st.char_format_at(at).clone();
fmt.over.change = Some(designcraft_doc::ChangeMark::Inserted);
st.insert_with(at, &text, fmt);
let pos = at + text.len() - trail;
sel.text = Some(TextSel { anchor: pos, focus: pos, ..t });
return Ok(json!({"pos": pos}));
}
st.replace(r.clone(), &text);
let pos = r.start + text.len() - trail;
sel.text = Some(TextSel { anchor: pos, focus: pos, ..t });
@@ -354,11 +365,16 @@ fn smart_quotes(s: &Session, text: &str) -> String {
}
pub(crate) fn delete_selection(s: &mut Session) -> Result<Value> {
let tracking = s.doc()?.doc.settings.track_changes;
s.edit(|d, sel| {
let t = sel.text.ok_or_else(|| bad("text.delete", "no text"))?;
let r = t.range();
if let Some(st) = d.text_story_mut(t.story, t.cell) {
st.delete(r.clone());
if tracking && t.cell.is_none() {
super::changes::mark_deleted(st, r.clone());
} else {
st.delete(r.clone());
}
}
sel.text = Some(TextSel { anchor: r.start, focus: r.start, ..t });
ok()
@@ -368,6 +384,7 @@ pub(crate) fn delete_selection(s: &mut Session) -> Result<Value> {
fn delete(s: &mut Session, p: &Value) -> Result<Value> {
let forward = bool_or(p, "forward", false);
let word = bool_or(p, "word", false);
let tracking = s.doc()?.doc.settings.track_changes;
s.edit(|d, sel| {
let t = sel.text.ok_or_else(|| bad("text.delete", "no text"))?;
let st = d.text_story_mut(t.story, t.cell).ok_or(designcraft_doc::DocError::NoStory(t.story))?;
@@ -381,6 +398,13 @@ fn delete(s: &mut Session, p: &Value) -> Result<Value> {
let start = if word { prev_word(&st.text, r.start) } else { prev_char(&st.text, r.start) };
start..r.start
};
if tracking && t.cell.is_none() {
// Marked, not removed: the caret steps over the deleted text.
let at = super::changes::mark_deleted(st, r.clone());
let pos = if forward { at.max(r.end.min(st.len())) } else { r.start.min(at) };
sel.text = Some(TextSel { anchor: pos, focus: pos, ..t });
return ok();
}
st.delete(r.clone());
sel.text = Some(TextSel { anchor: r.start, focus: r.start, ..t });
ok()
+1 -1
View File
@@ -222,7 +222,7 @@ pub fn story_html(doc: &Document, sid: StoryId) -> String {
continue;
}
// Hidden conditional text isn't exported.
if f.over.conditions.as_deref().is_some_and(|c| doc.conditions_hide(c)) {
if f.over.conditions.as_deref().is_some_and(|c| doc.conditions_hide(c)) || f.over.change == Some(designcraft_doc::ChangeMark::Deleted) {
continue;
}
let raw = &st.text[a..b];
+3
View File
@@ -109,6 +109,8 @@ pub struct RenderOptions {
pub condition_indicators: bool,
/// Editorial note anchors (screen view).
pub note_indicators: bool,
/// Track Changes: added text highlighted (screen view).
pub change_markup: bool,
/// View › Display Performance.
pub quality: DisplayQuality,
/// Preferences › Appearance of Black: show 100% K as rich (pure) black instead of the
@@ -146,6 +148,7 @@ impl Default for RenderOptions {
highlight_custom_tracking: false,
condition_indicators: false,
note_indicators: false,
change_markup: false,
quality: DisplayQuality::High,
rich_black: false,
overprint_preview: false,
+9
View File
@@ -229,6 +229,15 @@ impl Renderer {
}
}
}
if f.opts.change_markup && cs.styles.iter().any(|s| s.inserted) {
ctx.set_transform(m);
for l in &ft.lines {
for g in l.glyphs.iter().filter(|g| g.adv > 0.0 && cs.styles.get(g.style as usize).is_some_and(|s| s.inserted)) {
ctx.set_paint(peniko::Color::from_rgba8(120, 190, 255, 110));
ctx.fill_rect(&kurbo::Rect::new(g.x, l.baseline - l.ascent, g.x + g.adv, l.baseline + l.descent));
}
}
}
if f.opts.note_indicators
&& let Some(st) = f.doc.story(cs.story).filter(|st| !st.editorial.is_empty())
{
+9 -2
View File
@@ -30,12 +30,19 @@ fn table_text(story: &Story, id: u64, nl: &str) -> String {
/// Text Only: the story's text, CRLF between paragraphs.
pub fn plain_text(story: &Story) -> String {
// Tracked deletions aren't part of the text.
let deleted: Vec<std::ops::Range<usize>> =
story.runs().filter(|(_, f)| f.over.change == Some(designcraft_doc::ChangeMark::Deleted)).map(|(r, _)| r).collect();
let mut out = String::with_capacity(story.text.len() + 16);
for (i, r) in story.para_ranges().into_iter().enumerate() {
if i > 0 {
out.push_str("\r\n");
}
for c in story.text[r].chars() {
let start = r.start;
for (ci, c) in story.text[r].char_indices() {
if deleted.iter().any(|d| d.contains(&(start + ci))) {
continue;
}
match (c, plain_char(c)) {
(TABLE_ANCHOR, _) => {
if let Some(id) = story.paras.get(i).and_then(|p| p.table) {
@@ -93,7 +100,7 @@ pub fn rtf(doc: &Document, story: &Story) -> String {
}
let cp = styles.resolve_char(&base, f);
// Hidden conditional text isn't exported.
if doc.conditions_hide(&cp.conditions) {
if doc.conditions_hide(&cp.conditions) || cp.change == designcraft_doc::ChangeMark::Deleted {
continue;
}
if !fonts.contains(&cp.font_family) {
+2
View File
@@ -329,6 +329,7 @@ fn render_texture(app: &mut DesignApp, ctx: &egui::Context, rect: Rect, xf: &Xf,
highlight_hj: !preview && app.session.prefs.highlight_hj,
condition_indicators: !preview,
note_indicators: !preview,
change_markup: !preview,
highlight_keeps: !preview && app.session.prefs.highlight_keeps,
highlight_custom_tracking: !preview && app.session.prefs.highlight_custom_tracking,
quality: app.ui.display_quality,
@@ -439,6 +440,7 @@ fn patch_texture(app: &mut DesignApp, layout: &CanvasLayout, ppp: f64, doc_key:
highlight_hj: !preview && app.session.prefs.highlight_hj,
condition_indicators: !preview,
note_indicators: !preview,
change_markup: !preview,
highlight_keeps: !preview && app.session.prefs.highlight_keeps,
highlight_custom_tracking: !preview && app.session.prefs.highlight_custom_tracking,
quality: app.ui.display_quality,
+7
View File
@@ -318,6 +318,11 @@ pub const MENUS: &[(&str, &[&str])] = &[
"-",
"cmd:footnote.insert",
"cmd:endnote.insert",
">Track Changes",
"cmd:changes.track",
"cmd:changes.acceptAll",
"cmd:changes.rejectAll",
"<",
"ui:app.footnoteOptionsDialog",
"cmd:footnote.goToReference",
],
@@ -1349,6 +1354,8 @@ pub fn checked(app: &DesignApp, id: &str, params: &Value) -> Option<bool> {
"view.baselineGrid" => app.ui.baseline_grid,
"view.textThreads" => app.ui.text_threads,
"view.hiddenCharacters" => app.ui.hidden_characters,
"view.taggedFrames" => app.ui.tagged_frames,
"changes.track" => app.session.active().is_some_and(|d| d.doc.settings.track_changes),
"window.controlBar" => app.ui.control_bar,
"window.taskBar" => app.ui.task_bar,
"window.toolsDoubleColumn" => app.ui.tools_double_column,
+2 -2
View File
@@ -11,7 +11,7 @@ Each row of the InDesign feature catalogue is scored **D** (done), **P** (partia
|---|---|---|---|---|---|
| P0 | 102 | 99 | 3 | 0 | 99% |
| P1 | 97 | 92 | 4 | 1 | 97% |
| P2 | 76 | 34 | 20 | 22 | 58% |
| P2 | 76 | 34 | 21 | 21 | 59% |
| Area | Parity |
|---|---|
@@ -188,7 +188,7 @@ Open P0 items: Preferences, Vector/page formats (PDF/AI/SVG/EPS place), Export t
| Type & text frames | Type on a path | P1 | D |
| Type & text frames | Vertical type | P2 | M |
| Type & text frames | Story Editor | P1 | D |
| Type & text frames | Track Changes | P2 | M |
| Type & text frames | Track Changes | P2 | P |
| Type & text frames | Notes | P2 | D |
| Type & text frames | Find/Change | P0 | D |
| Type & text frames | Find Font | P0 | D |