Review: compare with a revised copy and accept its slides

review.compare {path | name, data} reads a revised presentation (native or
PPTX) and lists slide-level revisions against the open one: inserted,
deleted and changed slides, with what changed (text, text formatting,
moved, resized, rotated, formatting, picture, objects inserted/deleted,
layout, notes, transition, animations, background, hidden). Slides match by
id, or by position when the decks share none (fixed for the session so
accepting doesn't switch the matching half way). Shapes match by id, then
name.

review.accept {index | all} applies revisions as one undo step: a revised
slide's media is copied (remapped on an id clash), its shape ids kept unless
another slide uses them (animations follow), its layout matched by id, then
name. review.revisions and review.endCompare complete the set.

Review tab: Compare (file picker), Accept All, End Review; the Revisions pane
lists changes with Go-to and Accept. Parity row: Compare presentations M → P.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Nikita Rakitin
2026-10-13 22:49:55 +03:00
co-authored by Cursor
parent 457406a7a5
commit 5acf9eeebf
10 changed files with 685 additions and 5 deletions
+1 -1
View File
@@ -48,7 +48,7 @@ fn slides_scope(s: &Session, p: &Value) -> bool {
|| s.active().is_some_and(|d| d.selection.shapes.is_empty() && d.selection.text.is_none() && !d.selection.slides.is_empty())
}
fn media_of_shapes(shapes: &[Shape], out: &mut Vec<MediaId>) {
pub(crate) fn media_of_shapes(shapes: &[Shape], out: &mut Vec<MediaId>) {
deckcraft_model::walk(shapes, &mut |s, _| {
match &s.kind {
deckcraft_model::ShapeKind::Picture { fill } => out.push(fill.media),
+81
View File
@@ -16,6 +16,10 @@ pub fn specs() -> Vec<CommandSpec> {
cmd!("comment.resolve", "Resolve Thread", ["Comments"], None, "{index, resolved?: bool}", has_slide, resolve),
cmd!(query "comment.list", "Comments", [], None, "{slide?} → comments", has_slide, list),
cmd!(query "review.accessibility", "Check Accessibility", ["Review", "Accessibility"], None, "{} → [{slide, shape, issue}]", has_doc, accessibility),
cmd!(noundo "review.compare", "Compare", ["Review", "Compare"], None, "{path} | {name, data: base64} — compare with a revised copy → {name, changes}", has_doc, compare),
cmd!(query "review.revisions", "Revisions", [], None, "{} → {name, changes: [{kind: inserted|deleted|changed, slide, revised, details}]}", has_doc, revisions),
cmd!("review.accept", "Accept Revision", ["Review", "Compare"], None, "{index} | {all: true}", comparing, accept),
cmd!(noundo "review.endCompare", "End Review", ["Review", "Compare"], None, "{}", comparing, end_compare),
cmd!(query "review.spelling", "Spelling", ["Review", "Proofing"], Some("F7"), "{} → [{slide, shape, word}] (words not in the built-in list)", has_doc, spelling),
cmd!(noundo "show.fromStart", "Play from Start", ["Slide Show", "Start Slide Show"], Some("Cmd+Shift+Enter"), "{}", has_slide, |s, _| start(s, 0)),
cmd!(noundo "show.fromCurrent", "Play from Current Slide", ["Slide Show", "Start Slide Show"], Some("Cmd+Enter"), "{}", has_slide, |s, _| {
@@ -264,3 +268,80 @@ fn custom_show(s: &mut Session, p: &Value) -> Result<Value> {
})?;
ok()
}
fn comparing(s: &Session) -> std::result::Result<(), String> {
if s.active().is_some_and(|d| d.compare.is_some()) { Ok(()) } else { Err("not comparing".into()) }
}
fn revised_bytes(p: &Value) -> Result<(String, Vec<u8>)> {
if let Some(data) = str_param(p, "data") {
let bytes = base64_decode(data).ok_or_else(|| bad("review.compare", "invalid base64 `data`"))?;
return Ok((str_param(p, "name").unwrap_or("Revised").to_string(), bytes));
}
#[cfg(not(target_arch = "wasm32"))]
if let Some(path) = str_param(p, "path") {
let bytes = std::fs::read(path).map_err(|e| crate::EngineError::Other(format!("{path}: {e}")))?;
return Ok((path.to_string(), bytes));
}
Err(bad("review.compare", "give `path` or `data`"))
}
fn revisions_json(st: &crate::DocState) -> Result<Value> {
let c = st.compare.as_ref().ok_or_else(|| bad("review.revisions", "not comparing"))?;
let name = std::path::Path::new(&c.name).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| c.name.clone());
Ok(json!({"name": name, "changes": crate::compare::diff(&st.doc, &c.revised, c.by_position), "byPosition": c.by_position}))
}
fn compare(s: &mut Session, p: &Value) -> Result<Value> {
let (name, bytes) = revised_bytes(p)?;
let revised = super::file::open_presentation(&name, &bytes)?;
let st = s.doc_mut()?;
let by_position = crate::compare::unrelated(&st.doc, &revised);
st.compare = Some(crate::compare::Compare { name, revised: Arc::new(revised), by_position });
s.ui_requests.push(UiRequest::Pane { id: "revisions".into() });
revisions_json(s.doc()?)
}
fn revisions(s: &mut Session, _: &Value) -> Result<Value> {
revisions_json(s.doc()?)
}
fn accept(s: &mut Session, p: &Value) -> Result<Value> {
let (rev, by_pos) = s.doc()?.compare.as_ref().map(|c| (c.revised.clone(), c.by_position)).ok_or_else(|| bad("review.accept", "not comparing"))?;
let all = bool_param(p, "all").unwrap_or(false);
let index = usize_param(p, "index");
if !all && index.is_none() {
return Err(bad("review.accept", "give `index` or `all`"));
}
s.edit(|doc, sel| {
let mut accepted = 0usize;
// Each accept shifts indices, so the list is recomputed; bounded by the slide counts.
let limit = doc.slides.len().saturating_add(rev.slides.len()).saturating_add(1);
for _ in 0..limit {
let changes = crate::compare::diff(doc, &rev, by_pos);
let c = if all {
changes.into_iter().find(|c| !(c.kind == crate::compare::ChangeKind::Deleted && doc.slides.len() <= 1))
} else {
changes.into_iter().nth(index.unwrap_or(0))
};
let Some(c) = c else { break };
let Some(at) = crate::compare::accept(doc, &rev, &c) else { break };
accepted += 1;
sel.slide = at;
sel.shapes.clear();
sel.text = None;
if !all {
break;
}
}
if accepted == 0 {
return Err(bad("review.accept", "no such revision"));
}
Ok(json!({"accepted": accepted}))
})
}
fn end_compare(s: &mut Session, _: &Value) -> Result<Value> {
s.doc_mut()?.compare = None;
ok()
}
+449
View File
@@ -0,0 +1,449 @@
//! Review › Compare: the differences between the open presentation and a revised copy of it, slide
//! by slide, and accepting a revised slide into the open one.
//!
//! Slides match by id (a revised copy of the same deck keeps them); when the two decks share no
//! slide id at all, by position. Shapes match by id, then by name.
use std::collections::{HashMap, HashSet};
use std::sync::Arc;
use deckcraft_model::{Fill, MediaId, Presentation, Shape, ShapeId, ShapeKind, Slide, SlideId};
use serde::Serialize;
/// The revised presentation a document is being compared with.
#[derive(Clone, Debug)]
pub struct Compare {
pub name: String,
pub revised: Arc<Presentation>,
/// Slides match by position (the decks shared no slide id when the compare started); fixed
/// for the session so accepting slides doesn't switch the matching half way.
pub by_position: bool,
}
/// Whether `cur` and `rev` share no slide id (then slides match by position).
pub fn unrelated(cur: &Presentation, rev: &Presentation) -> bool {
let ids: HashSet<SlideId> = rev.slides.iter().map(|s| s.id).collect();
!cur.slides.iter().any(|s| ids.contains(&s.id))
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
#[serde(rename_all = "camelCase")]
pub enum ChangeKind {
/// Only in the revised deck.
Inserted,
/// Only in the open deck.
Deleted,
/// In both, different.
Changed,
}
/// One slide-level revision.
#[derive(Clone, Debug, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct Change {
pub kind: ChangeKind,
/// Index in the open deck (for inserted: where it would go).
pub slide: usize,
/// Index in the revised deck (none for deleted).
pub revised: Option<usize>,
/// What differs, readable: `Title 1: text`, `Picture 3 inserted`, `notes`…
pub details: Vec<String>,
}
const MAX_DETAILS: usize = 50;
/// Slide pairs (open index, revised index).
fn pairs(cur: &Presentation, rev: &Presentation, by_position: bool) -> Vec<(usize, usize)> {
if by_position {
return (0..cur.slides.len().min(rev.slides.len())).map(|i| (i, i)).collect();
}
let rev_ids: HashMap<SlideId, usize> = rev.slides.iter().enumerate().map(|(i, s)| (s.id, i)).collect();
cur.slides.iter().enumerate().filter_map(|(i, s)| rev_ids.get(&s.id).map(|j| (i, *j))).collect()
}
pub fn diff(cur: &Presentation, rev: &Presentation, by_position: bool) -> Vec<Change> {
let pairs = pairs(cur, rev, by_position);
let cur_matched: HashSet<usize> = pairs.iter().map(|p| p.0).collect();
let rev_matched: HashSet<usize> = pairs.iter().map(|p| p.1).collect();
let mut out = vec![];
for (i, j) in &pairs {
let (Some(a), Some(b)) = (cur.slides.get(*i), rev.slides.get(*j)) else { continue };
let details = slide_details(cur, a, rev, b);
if !details.is_empty() {
out.push(Change { kind: ChangeKind::Changed, slide: *i, revised: Some(*j), details });
}
}
for (i, s) in cur.slides.iter().enumerate() {
if !cur_matched.contains(&i) {
out.push(Change { kind: ChangeKind::Deleted, slide: i, revised: None, details: vec![slide_title(s)] });
}
}
for (j, s) in rev.slides.iter().enumerate() {
if !rev_matched.contains(&j) {
// After the open slide that matches the revised slide before it.
let at = (0..j).rev().find_map(|k| pairs.iter().find(|p| p.1 == k).map(|p| p.0 + 1)).unwrap_or(0);
out.push(Change { kind: ChangeKind::Inserted, slide: at, revised: Some(j), details: vec![slide_title(s)] });
}
}
out.sort_by_key(|c| (c.slide, c.revised.unwrap_or(0)));
out
}
fn slide_title(s: &Slide) -> String {
let t = s
.shapes
.iter()
.find(|x| x.ph.as_ref().is_some_and(|p| matches!(p.kind, deckcraft_model::PhType::Title | deckcraft_model::PhType::CtrTitle)))
.and_then(|x| x.text.as_ref())
.map(|b| b.text())
.unwrap_or_default();
let t = t.trim();
if t.is_empty() { "Untitled slide".into() } else { t.chars().take(60).collect() }
}
fn shape_label(s: &Shape) -> String {
if s.name.trim().is_empty() { format!("Shape {}", s.id.0) } else { s.name.clone() }
}
fn media_bytes(p: &Presentation, id: MediaId) -> Option<&[u8]> {
p.media(id).map(|m| m.data.as_slice())
}
/// A shape without its ids (shape and media), for comparing across decks.
fn plain_shape(s: &Shape) -> Shape {
let mut c = s.clone();
fn rec(shapes: &mut [Shape]) {
for s in shapes {
s.id = ShapeId(0);
if let Some(ch) = s.children_mut() {
rec(ch);
}
}
}
rec(std::slice::from_mut(&mut c));
c
}
fn shape_details(cp: &Presentation, a: &Shape, rp: &Presentation, b: &Shape, out: &mut Vec<String>) {
let label = shape_label(b);
let text = |s: &Shape| s.text.as_ref().map(|t| t.text()).unwrap_or_default();
let mut what = vec![];
if text(a) != text(b) {
what.push("text");
} else if a.text != b.text {
what.push("text formatting");
}
match (&a.xfrm, &b.xfrm) {
(Some(x), Some(y)) if (x.x, x.y) != (y.x, y.y) && (x.w, x.h) == (y.w, y.h) => what.push("moved"),
(Some(x), Some(y)) if (x.w, x.h) != (y.w, y.h) => what.push("resized"),
(x, y) if x.as_ref().map(|x| x.rot) != y.as_ref().map(|y| y.rot) => what.push("rotated"),
_ => {}
}
if a.fill != b.fill || a.line != b.line || a.style != b.style || a.effects != b.effects {
what.push("formatting");
}
let pic = |p: &Presentation, s: &Shape| match &s.kind {
ShapeKind::Picture { fill } => media_bytes(p, fill.media).map(|b| b.to_vec()),
_ => None,
};
if pic(cp, a) != pic(rp, b) {
what.push("picture");
}
if what.is_empty() {
let (mut x, mut y) = (plain_shape(a), plain_shape(b));
x.text = None;
y.text = None;
if let (ShapeKind::Picture { .. }, ShapeKind::Picture { .. }) = (&x.kind, &y.kind) {
x.kind = ShapeKind::Shape;
y.kind = ShapeKind::Shape;
}
if x != y {
what.push("properties");
}
}
if !what.is_empty() {
out.push(format!("{label}: {}", what.join(", ")));
}
}
fn slide_details(cp: &Presentation, a: &Slide, rp: &Presentation, b: &Slide) -> Vec<String> {
let mut out = vec![];
let mut used: HashSet<usize> = HashSet::new();
for sa in &a.shapes {
let k = b
.shapes
.iter()
.position(|x| x.id == sa.id)
.or_else(|| b.shapes.iter().enumerate().position(|(k, x)| !used.contains(&k) && x.name == sa.name && !x.name.is_empty()));
match k {
Some(k) if !used.contains(&k) => {
used.insert(k);
if let Some(sb) = b.shapes.get(k) {
shape_details(cp, sa, rp, sb, &mut out);
}
}
_ => out.push(format!("{} deleted", shape_label(sa))),
}
}
for (k, sb) in b.shapes.iter().enumerate() {
if !used.contains(&k) {
out.push(format!("{} inserted", shape_label(sb)));
}
}
let order = |s: &Slide| s.shapes.iter().map(|x| x.name.clone()).collect::<Vec<_>>();
if out.is_empty() && order(a) != order(b) {
out.push("order of objects".into());
}
let layout_name = |p: &Presentation, s: &Slide| p.layout(s.layout).map(|(_, l)| l.name.clone());
if layout_name(cp, a) != layout_name(rp, b) {
out.push("layout".into());
}
if a.notes.text() != b.notes.text() {
out.push("notes".into());
}
if a.transition != b.transition {
out.push("transition".into());
}
if a.animations.len() != b.animations.len()
|| a.animations
.iter()
.zip(&b.animations)
.any(|(x, y)| (&x.effect, x.class, x.start, x.duration_ms, x.delay_ms) != (&y.effect, y.class, y.start, y.duration_ms, y.delay_ms))
{
out.push("animations".into());
}
if a.background != b.background {
out.push("background".into());
}
if a.hidden != b.hidden {
out.push(if b.hidden { "hidden" } else { "shown" }.into());
}
out.truncate(MAX_DETAILS);
out
}
fn max_shape_id(shapes: &[Shape], m: &mut u32) {
deckcraft_model::walk(shapes, &mut |s, _| *m = (*m).max(s.id.0));
}
/// The revised slide made to live in `doc`: its media copied over (remapped on an id clash), shape
/// ids kept unless another slide of `doc` uses them (then fresh, animations following), and its
/// layout matched by id, then by name, then the first layout.
pub fn adopt(doc: &mut Presentation, rev: &Presentation, slide: &Slide, replacing: Option<SlideId>) -> Slide {
let mut sl = slide.clone();
// Media.
let mut mids = vec![];
crate::cmd::edit::media_of_shapes(&sl.shapes, &mut mids);
let mut media_map: HashMap<MediaId, MediaId> = HashMap::new();
for id in mids {
let Some(m) = rev.media(id) else { continue };
match doc.media(id) {
Some(x) if x.data == m.data => {}
None => doc.media.push(m.clone()),
Some(_) => {
let new = doc.add_media(&m.name, &m.content_type, m.data.as_ref().clone());
media_map.insert(id, new);
}
}
}
// Shape ids used elsewhere in the open deck.
let mut taken: HashSet<u32> = HashSet::new();
for s in doc.slides.iter().filter(|s| Some(s.id) != replacing) {
deckcraft_model::walk(&s.shapes, &mut |x, _| {
taken.insert(x.id.0);
});
}
let mut id_map: HashMap<ShapeId, ShapeId> = HashMap::new();
fn rec(
doc: &mut Presentation,
shapes: &mut [Shape],
taken: &HashSet<u32>,
ids: &mut HashMap<ShapeId, ShapeId>,
media: &HashMap<MediaId, MediaId>,
depth: usize,
) {
if depth > 64 {
return;
}
for s in shapes {
if taken.contains(&s.id.0) {
let new = ShapeId(doc.alloc_id());
ids.insert(s.id, new);
s.id = new;
}
let remap = |m: &mut MediaId| {
if let Some(n) = media.get(m) {
*m = *n;
}
};
match &mut s.kind {
ShapeKind::Picture { fill } => remap(&mut fill.media),
ShapeKind::Media(m) => {
remap(&mut m.media);
if let Some(p) = m.poster.as_mut() {
remap(p);
}
}
_ => {}
}
if let Some(Fill::Picture(pf)) = &mut s.fill {
remap(&mut pf.media);
}
if let Some(ch) = s.children_mut() {
rec(doc, ch, taken, ids, media, depth + 1);
}
}
}
rec(doc, &mut sl.shapes, &taken, &mut id_map, &media_map, 0);
for a in &mut sl.animations {
if let Some(n) = id_map.get(&a.shape) {
a.shape = *n;
}
if let Some(t) = a.trigger.as_mut()
&& let Some(n) = id_map.get(t)
{
*t = *n;
}
}
let mut max = 0;
max_shape_id(&sl.shapes, &mut max);
doc.next_id = doc.next_id.max(max);
// Layout.
if doc.layout(sl.layout).is_none() {
let name = rev.layout(slide.layout).map(|(_, l)| l.name.clone());
let by_name = name.and_then(|n| doc.masters.iter().flat_map(|m| &m.layouts).find(|l| l.name == n).map(|l| l.id));
sl.layout = by_name.or_else(|| doc.masters.first().and_then(|m| m.layouts.first()).map(|l| l.id)).unwrap_or_default();
}
match replacing {
Some(id) => sl.id = id,
None => {
if doc.slides.iter().any(|s| s.id == sl.id) || sl.id.0 == 0 {
sl.id = SlideId(doc.alloc_id());
}
doc.next_id = doc.next_id.max(sl.id.0);
}
}
sl
}
/// Apply `change` (from [`diff`] of `doc` against `rev`) to `doc`. Returns the slide index to show.
pub fn accept(doc: &mut Presentation, rev: &Presentation, change: &Change) -> Option<usize> {
match change.kind {
ChangeKind::Changed => {
let b = rev.slides.get(change.revised?)?.clone();
let id = doc.slides.get(change.slide)?.id;
let sl = adopt(doc, rev, &b, Some(id));
*doc.slides.get_mut(change.slide)? = Arc::new(sl);
Some(change.slide)
}
ChangeKind::Inserted => {
let b = rev.slides.get(change.revised?)?.clone();
let sl = adopt(doc, rev, &b, None);
let at = change.slide.min(doc.slides.len());
doc.slides.insert(at, Arc::new(sl));
Some(at)
}
ChangeKind::Deleted => {
if change.slide >= doc.slides.len() || doc.slides.len() <= 1 {
return None;
}
doc.slides.remove(change.slide);
Some(change.slide.min(doc.slides.len().saturating_sub(1)))
}
}
}
#[cfg(test)]
mod tests {
use serde_json::{Value, json};
use crate::Session;
/// The sample deck, and a revised copy of it as base64 `.deckcraft` bytes: slide 1's title
/// retyped, slide 2 deleted, a new slide at the end, a shape moved on slide 3.
fn decks() -> (Session, String, usize) {
let mut s = Session::new();
crate::sample::open_sample(&mut s).unwrap();
let n = s.doc().unwrap().doc.slides.len();
let orig = s.execute("file.saveBytes", &json!({})).unwrap()["data"].as_str().unwrap().to_string();
// Make the revision in a second document.
s.execute("file.openBytes", &json!({"name": "rev.deckcraft", "data": orig})).unwrap();
let first = s.doc().unwrap().doc.slides[0].shapes.iter().find(|x| x.text.is_some()).unwrap().id.0;
s.execute("edit.select", &json!({"ids": [first]})).unwrap();
s.execute("text.edit", &json!({"id": first})).unwrap();
s.execute("edit.selectAll", &json!({})).unwrap();
s.execute("text.insert", &json!({"text": "Revised title"})).unwrap();
s.execute("text.exit", &json!({})).unwrap();
s.execute("slide.delete", &json!({"index": 1})).unwrap();
s.execute("slide.go", &json!({"index": 1})).ok();
let moved = s.doc().unwrap().doc.slides[1].shapes[0].id.0;
s.execute("shape.move", &json!({"ids": [moved], "dx": 30, "dy": 0})).ok();
s.execute("slide.go", &json!({"index": n - 2})).ok();
s.execute("slide.new", &json!({"layout": "titleAndContent", "title": "Brand new", "body": "x"})).unwrap();
let rev = s.execute("file.saveBytes", &json!({})).unwrap()["data"].as_str().unwrap().to_string();
s.close_document(1);
s.set_active(0);
(s, rev, n)
}
fn kinds(v: &Value) -> Vec<String> {
v["changes"].as_array().unwrap().iter().map(|c| c["kind"].as_str().unwrap().to_string()).collect()
}
#[test]
fn lists_inserted_deleted_and_changed_slides() {
let (mut s, rev, n) = decks();
let v = s.execute("review.compare", &json!({"name": "rev.deckcraft", "data": rev})).unwrap();
let k = kinds(&v);
assert!(k.contains(&"deleted".into()) && k.contains(&"inserted".into()) && k.contains(&"changed".into()), "{v}");
let ch = &v["changes"].as_array().unwrap()[0];
assert_eq!(ch["slide"], 0);
assert!(ch["details"].to_string().contains("text"), "{ch}");
let ins = v["changes"].as_array().unwrap().iter().find(|c| c["kind"] == "inserted").unwrap();
assert_eq!(ins["details"][0], "Brand new");
let at = ins["slide"].as_u64().unwrap() as usize;
assert_eq!(at, n, "after the open slide matching the revised one before it");
assert_eq!(s.doc().unwrap().doc.slides.len(), n, "comparing alone changes nothing");
}
#[test]
fn accept_one_then_all_makes_the_decks_equal_and_undoes() {
let (mut s, rev, n) = decks();
s.execute("review.compare", &json!({"name": "rev.deckcraft", "data": rev})).unwrap();
let before = s.execute("review.revisions", &json!({})).unwrap()["changes"].as_array().unwrap().len();
s.execute("review.accept", &json!({"index": 0})).unwrap();
let after = s.execute("review.revisions", &json!({})).unwrap()["changes"].as_array().unwrap().len();
assert_eq!(after, before - 1);
assert!(s.doc().unwrap().doc.slides[0].shapes.iter().any(|x| x.text.as_ref().is_some_and(|t| t.text().contains("Revised title"))));
s.execute("review.accept", &json!({"all": true})).unwrap();
let left = s.execute("review.revisions", &json!({})).unwrap();
assert!(left["changes"].as_array().unwrap().is_empty(), "{left}");
assert!(s.doc().unwrap().doc.validate().is_empty(), "{:?}", s.doc().unwrap().doc.validate());
assert_eq!(s.doc().unwrap().doc.slides.len(), n);
s.execute("edit.undo", &json!({})).unwrap();
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(s.execute("review.revisions", &json!({})).unwrap()["changes"].as_array().unwrap().len(), before);
s.execute("review.endCompare", &json!({})).unwrap();
assert!(s.execute("review.revisions", &json!({})).is_err());
assert!(s.execute("review.accept", &json!({"all": true})).is_err());
}
#[test]
fn unrelated_decks_match_by_position_and_bad_input_fails() {
let mut s = Session::with_new();
s.execute("slide.new", &json!({"layout": "title", "title": "A"})).unwrap();
let mut other = Session::with_new();
other.execute("slide.new", &json!({"layout": "title", "title": "B"})).unwrap();
other.execute("slide.new", &json!({"layout": "title", "title": "C"})).unwrap();
let data = other.execute("file.saveBytes", &json!({"format": "pptx"})).unwrap()["data"].as_str().unwrap().to_string();
let v = s.execute("review.compare", &json!({"name": "o.pptx", "data": data})).unwrap();
assert!(kinds(&v).contains(&"inserted".into()), "{v}");
s.execute("review.accept", &json!({"all": true})).unwrap();
assert!(s.execute("review.revisions", &json!({})).unwrap()["changes"].as_array().unwrap().is_empty());
assert!(s.doc().unwrap().doc.validate().is_empty());
assert!(s.execute("review.compare", &json!({"data": "!!"})).is_err());
assert!(s.execute("review.compare", &json!({"name": "x.bin", "data": "AAAA"})).is_err());
assert!(s.execute("review.compare", &json!({})).is_err());
assert!(s.execute("review.accept", &json!({"index": 999})).is_err());
}
}
+4
View File
@@ -8,6 +8,7 @@
#![forbid(unsafe_code)]
pub mod cmd;
pub mod compare;
pub mod connect;
pub mod fonts;
pub mod guard;
@@ -173,6 +174,8 @@ pub struct DocState {
pub missing_fonts: Vec<String>,
/// An input method's composition in progress ([`Composition`]).
pub composition: Option<Composition>,
/// Review › Compare in progress: the revised copy.
pub compare: Option<compare::Compare>,
}
static NEXT_UID: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1);
@@ -192,6 +195,7 @@ impl DocState {
title,
missing_fonts: Vec::new(),
composition: None,
compare: None,
}
}
pub fn is_dirty(&self) -> bool {
+61
View File
@@ -0,0 +1,61 @@
//! Review › Compare: the Revisions pane lists a revised copy's changes and accepts them.
use deckcraft_engine::Session;
use egui_kittest::Harness;
use egui_kittest::kittest::Queryable;
use serde_json::json;
use crate::{Services, SlideApp};
/// The sample deck compared with a copy whose slide 2 is gone and that has a new last slide.
fn harness() -> (Harness<'static, SlideApp>, usize) {
let mut s = Session::new();
deckcraft_engine::sample::open_sample(&mut s).unwrap();
let n = s.doc().unwrap().doc.slides.len();
let data = s.execute("file.saveBytes", &json!({})).unwrap()["data"].as_str().unwrap().to_string();
s.execute("file.openBytes", &json!({"name": "rev.deckcraft", "data": data})).unwrap();
s.execute("slide.delete", &json!({"index": 1})).unwrap();
s.execute("slide.go", &json!({"index": n - 2})).unwrap();
s.execute("slide.new", &json!({"layout": "titleAndContent", "title": "From the reviewer", "body": "x"})).unwrap();
let rev = s.execute("file.saveBytes", &json!({})).unwrap()["data"].as_str().unwrap().to_string();
s.close_document(1);
s.set_active(0);
s.execute("review.compare", &json!({"name": "Q3 review.deckcraft", "data": rev})).unwrap();
let app = SlideApp::new(s, Services::default());
let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).with_step_dt(1.0 / 60.0).build_ui_state(
|ui, app: &mut SlideApp| {
app.logic(ui.ctx());
app.ui(ui);
},
app,
);
h.run_steps(4);
(h, n)
}
#[test]
fn pane_opens_lists_changes_and_accept_all_applies_them() {
let (mut h, n) = harness();
assert_eq!(h.state().ui.pane.as_deref(), Some("revisions"), "compare opens the Revisions pane");
assert!(h.query_by_label("Compared with Q3 review.deckcraft").is_some());
assert!(h.query_by_label("Slide 2 deleted").is_some());
assert!(h.query_by_label_contains("Slide inserted after").is_some());
h.get_by_label_contains("Accept All").click();
h.run_steps(3);
let st = h.state().session.active().unwrap();
assert_eq!(st.doc.slides.len(), n, "one slide gone, one slide added");
assert!(h.query_by_label("No differences left.").is_some());
h.get_by_label("End Review").click();
h.run_steps(3);
assert!(h.state().session.active().unwrap().compare.is_none());
}
#[test]
#[ignore = "writes a screenshot for review"]
fn screenshot() {
let (mut h, _) = harness();
// Review tab.
h.state_mut().ui.tab = "Review".into();
h.run_steps(4);
h.render().unwrap().save("/tmp/compare.png").unwrap();
}
+14
View File
@@ -167,6 +167,7 @@ pub const UI_COMMANDS: &[(&str, &str, Option<&str>, &str)] = &[
("app.insertVideoDialog", "Video from File…", None, "{}"),
("app.saveAsDialog", "Save As…", None, "{}"),
("app.exportDialog", "Export…", None, "{}"),
("app.compareDialog", "Compare…", None, "{}"),
("app.dialog", "Open Dialog", None, "{id}"),
("app.links", "Community Links", None, "{}"),
];
@@ -541,6 +542,17 @@ impl SlideApp {
"app.insertVideoDialog" => self.pick_and_insert("video", "insert.video"),
"app.saveAsDialog" => self.save_as_dialog("deckcraft"),
"app.exportDialog" => self.dialog = Some(dialogs::Dialog::new("export")),
"app.compareDialog" => {
if self.session.active().is_none() {
return Err("open the presentation to compare first".into());
}
if self.services.pick_open.is_some() {
self.pick_and_insert("open", "review.compare");
} else {
// The browser's asynchronous picker would open the file as a new document.
self.set_status("Compare needs the desktop app for now".to_string());
}
}
"app.dialog" => {
let d = p.get("id").and_then(Value::as_str).ok_or("missing `id`")?;
self.dialog = Some(dialogs::Dialog::new(d));
@@ -1127,6 +1139,8 @@ fn js_now() -> f64 {
js_sys::Date::now()
}
#[cfg(test)]
mod compare_tests;
#[cfg(test)]
mod ime_tests;
+57
View File
@@ -27,6 +27,7 @@ pub fn show(app: &mut SlideApp, ui: &mut Ui) {
"selection" => "Selection",
"comments" => "Comments",
"designer" => "Design Ideas",
"revisions" => "Revisions",
_ => "Pane",
};
ui.horizontal(|ui| {
@@ -50,6 +51,7 @@ pub fn show(app: &mut SlideApp, ui: &mut Ui) {
"selection" => crate::selpane::show(app, ui),
"comments" => comments(app, ui),
"designer" => designer(app, ui),
"revisions" => revisions(app, ui),
_ => {}
});
});
@@ -549,6 +551,61 @@ fn animation(app: &mut SlideApp, ui: &mut Ui) {
let _ = Stroke::NONE;
}
/// Review › Compare: the revised copy's slide-level changes; click to go there, Accept to take it.
fn revisions(app: &mut SlideApp, ui: &mut Ui) {
let t = Tokens::get(ui.ctx());
let Ok(v) = app.session.execute("review.revisions", &json!({})) else {
ui.label(egui::RichText::new("Compare this presentation with a revised copy from Review › Compare.").color(t.text_dim));
return;
};
let changes = v["changes"].as_array().cloned().unwrap_or_default();
ui.label(egui::RichText::new(format!("Compared with {}", v["name"].as_str().unwrap_or("?"))).color(t.text_dim));
if v["byPosition"].as_bool() == Some(true) {
ui.label(egui::RichText::new("Not a copy of this presentation: slides are matched by position.").size(11.0).color(t.text_faint));
}
ui.add_space(4.0);
if changes.is_empty() {
ui.label("No differences left.");
if ui.button("End Review").clicked() {
run(app, "review.endCompare", json!({}));
app.ui.pane = None;
}
return;
}
if ui.button(format!("Accept All ({})", changes.len())).clicked() {
run(app, "review.accept", json!({"all": true}));
}
ui.separator();
for (i, c) in changes.iter().enumerate() {
let slide = c["slide"].as_u64().unwrap_or(0);
let (head, col) = match c["kind"].as_str().unwrap_or("") {
"inserted" => (format!("Slide inserted after {slide}"), Color32::from_rgb(0x2E, 0xA3, 0x6F)),
"deleted" => (format!("Slide {} deleted", slide.saturating_add(1)), Color32::from_rgb(0xD8, 0x3F, 0x6B)),
_ => (format!("Slide {} changed", slide.saturating_add(1)), Color32::from_rgb(0x2E, 0x6F, 0xD8)),
};
egui::Frame::NONE.fill(t.chrome).corner_radius(CornerRadius::same(6)).inner_margin(egui::Margin::same(8)).show(ui, |ui| {
ui.set_width(ui.available_width());
ui.horizontal(|ui| {
let (r, _) = ui.allocate_exact_size(vec2(4.0, 16.0), Sense::hover());
ui.painter().rect_filled(r, CornerRadius::same(2), col);
if ui.link(egui::RichText::new(&head).font(theme::bold(12.5))).clicked() {
let max = app.session.active().map(|d| d.doc.slides.len().saturating_sub(1)).unwrap_or(0);
run(app, "slide.go", json!({"index": (slide as usize).min(max)}));
}
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
if ui.small_button("Accept").clicked() {
run(app, "review.accept", json!({"index": i}));
}
});
});
for d in c["details"].as_array().into_iter().flatten().filter_map(Value::as_str).take(8) {
ui.label(egui::RichText::new(format!("• {d}")).size(11.5).color(t.text_dim));
}
});
ui.add_space(4.0);
}
}
fn comments(app: &mut SlideApp, ui: &mut Ui) {
let t = Tokens::get(ui.ctx());
if ui.button("+ New Comment").clicked() {
+13
View File
@@ -1791,6 +1791,19 @@ fn review(app: &mut SlideApp, ui: &mut Ui) {
let _ = r.on_hover_text("Translation needs a language model (not bundled)");
big(app, ui, Icon::Language, "Language", "app.dialog", json!({"id": "language"}));
});
group(ui, |ui| {
big(app, ui, Icon::Compare, "Compare", "app.compareDialog", json!({}));
let comparing = enabled(app, "review.endCompare");
if big_button(ui, Icon::Check, "Accept\nAll", comparing).clicked() {
run(app, "review.accept", json!({"all": true}));
}
if big_button(ui, Icon::Close, "End\nReview", comparing).clicked() {
run(app, "review.endCompare", json!({}));
if app.ui.pane.as_deref() == Some("revisions") {
app.ui.pane = None;
}
}
});
group(ui, |ui| {
big(app, ui, Icon::NewComment, "New\nComment", "app.dialog", json!({"id": "comment"}));
big(app, ui, Icon::DeleteComment, "Delete", "comment.delete", json!({}));
+2 -1
View File
@@ -187,7 +187,8 @@ Detail in [animation-parity.md](animation-parity.md). 10–16 h; the pane is a *
Zoom (summary/section/slide, 6–10 h), reuse slides (2–4 h), laser pointer (1–2 h), transition
sounds (1–2 h), artistic effects (6–10 h), remove background (6–10 h; needs a model or a classical
matte), compress pictures (2–3 h), insert screenshot (2–3 h), record audio/screen and record slide
show (8–12 h), compare presentations (6–10 h), password protection (4–6 h, ECMA-376 agile
show (8–12 h), compare presentations beyond slide-level accept (reject, per-object accept,
markers on the slide: 3–5 h), password protection (4–6 h, ECMA-376 agile
encryption), ink to shape/text (6–10 h), icons and 3-D models (8–14 h, needs an open icon set).
## UI/UX
+3 -3
View File
@@ -9,7 +9,7 @@ The summary between `<!-- SUMMARY -->
|---|---|---|---|---|---|
| P0 | 72 | 61 | 11 | 0 | 92% |
| P1 | 65 | 38 | 21 | 6 | 75% |
| P2 | 54 | 13 | 15 | 26 | 38% |
| P2 | 54 | 13 | 16 | 25 | 39% |
| Area | Parity |
|---|---|
@@ -30,7 +30,7 @@ The summary between `<!-- SUMMARY -->
| Transitions | 92% |
| Animations | 79% |
| Slide show | 66% |
| Review | 60% |
| Review | 70% |
| Draw | 33% |
| Undo & clipboard | 88% |
| Automation (✱) | 100% |
@@ -221,7 +221,7 @@ Open P0 items: Contextual tabs (Shape Format, Picture Format, Table Design/Layou
| Slide show | Multi-monitor presenting | P1 | M |
| Review | Comments (add, reply, resolve, delete) | P1 | D |
| Review | Accessibility checker | P2 | D |
| Review | Compare presentations | P2 | M |
| Review | Compare presentations | P2 | P |
| Review | Track changes / version history | P2 | M |
| Draw | Ink pens, highlighter, eraser | P1 | P |
| Draw | Ink to shape / text | P2 | M |