From a4e76ed620e9ba8d881a1b256f7e0b4a5543b3a3 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Sat, 3 Oct 2026 03:57:22 -0400 Subject: [PATCH] =?UTF-8?q?T:=20Track=20Changes=20=E2=80=94=20marked=20ins?= =?UTF-8?q?ertions=20and=20deletions,=20accept/reject=20all?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 3 +- crates/compose/src/lib.rs | 2 + crates/compose/src/shape.rs | 4 +- crates/doc/src/attrs.rs | 12 +++ crates/doc/src/lib.rs | 3 + crates/engine/src/cmd/changes.rs | 159 +++++++++++++++++++++++++++++++ crates/engine/src/cmd/mod.rs | 2 + crates/engine/src/cmd/text.rs | 26 ++++- crates/epub/src/lib.rs | 2 +- crates/render/src/lib.rs | 3 + crates/render/src/text.rs | 9 ++ crates/textimport/src/export.rs | 11 ++- crates/ui-egui/src/canvas.rs | 2 + crates/ui-egui/src/menus.rs | 7 ++ docs/parity.md | 4 +- 15 files changed, 241 insertions(+), 8 deletions(-) create mode 100644 crates/engine/src/cmd/changes.rs diff --git a/ROADMAP.md b/ROADMAP.md index dad247ad..853cb4fc 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -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%**. diff --git a/crates/compose/src/lib.rs b/crates/compose/src/lib.rs index 42046d2b..fa28a3b9 100644 --- a/crates/compose/src/lib.rs +++ b/crates/compose/src/lib.rs @@ -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, + /// Added while tracking changes (marked on screen). + pub inserted: bool, } /// An underline or strikethrough bar: its top edge `offset` below the baseline (negative = diff --git a/crates/compose/src/shape.rs b/crates/compose/src/shape.rs index af176258..f0fe37a1 100644 --- a/crates/compose/src/shape.rs +++ b/crates/compose/src/shape.rs @@ -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); diff --git a/crates/doc/src/attrs.rs b/crates/doc/src/attrs.rs index 8a956e9a..98b37096 100644 --- a/crates/doc/src/attrs.rs +++ b/crates/doc/src/attrs.rs @@ -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 = Vec::new(), /// Conditional text: the conditions applied (Window › Type & Tables › Conditional Text). conditions: Vec = Vec::new(), + /// Track Changes: text added or deleted while tracking (deleted text isn't laid out). + change: ChangeMark = ChangeMark::None, } } diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index d4b99760..37922c14 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -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, } } } diff --git a/crates/engine/src/cmd/changes.rs b/crates/engine/src/cmd/changes.rs new file mode 100644 index 00000000..0da66d1a --- /dev/null +++ b/crates/engine/src/cmd/changes.rs @@ -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 { + if r.is_empty() { + return r.start; + } + // Inserted pieces inside the range go away; the rest is marked deleted. + let mut ins: Vec> = 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> { + let mut v: Vec> = 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 { + 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 { + let only = p.get("story").and_then(Value::as_u64).map(StoryId); + s.edit(|d, sel| { + let ids: Vec = 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()); + } +} diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index 5d1e9b6f..0208ec0b 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -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()); diff --git a/crates/engine/src/cmd/text.rs b/crates/engine/src/cmd/text.rs index 43101f16..fe03dde9 100644 --- a/crates/engine/src/cmd/text.rs +++ b/crates/engine/src/cmd/text.rs @@ -286,6 +286,7 @@ fn insert(s: &mut Session, p: &Value) -> Result { } 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 { 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 { + 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 { fn delete(s: &mut Session, p: &Value) -> Result { 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 { 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() diff --git a/crates/epub/src/lib.rs b/crates/epub/src/lib.rs index c4953cac..dda615da 100644 --- a/crates/epub/src/lib.rs +++ b/crates/epub/src/lib.rs @@ -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]; diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 6e67fd54..47af008c 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -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, diff --git a/crates/render/src/text.rs b/crates/render/src/text.rs index 8baf9ee3..47cad1ba 100644 --- a/crates/render/src/text.rs +++ b/crates/render/src/text.rs @@ -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()) { diff --git a/crates/textimport/src/export.rs b/crates/textimport/src/export.rs index 49c2092d..8262e469 100644 --- a/crates/textimport/src/export.rs +++ b/crates/textimport/src/export.rs @@ -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> = + 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) { diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 0a5890ab..35b5804f 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -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, diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 3d447ad2..5dcffd90 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -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 { "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, diff --git a/docs/parity.md b/docs/parity.md index 04a2cfba..15ade916 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -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 |