diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index d1bea65d92..df7ba3e90d 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -3866,8 +3866,9 @@ impl Session { guard(|| run_edit(&mut next, &edit, &mut cx)) .unwrap_or_else(|m| Err(EditError::Invalid(format!("{} failed unexpectedly ({m}); the document was not changed", edit.label()))))?; // A field's enter or exit scripts that changed nothing leave no edit behind (no undo step, - // nothing unsaved); what they showed or asked for still goes out. - if cx.xfa_unchanged { + // nothing unsaved); what they showed or asked for still goes out. Only an event on its own: + // a batch keeps the other edits it holds. + if cx.xfa_unchanged && matches!(edit, Edit::XfaEvent { .. }) { doc.js_output.append(cx.xfa_out); if cx.xfa_ran_away { doc.xfa_template = None; @@ -4037,6 +4038,7 @@ impl Session { Ok(()) } + /// Undo the last edit of document `id`, keeping it for [`Session::redo`]. Returns its label. pub fn undo(&mut self, id: DocId) -> Result { let doc = self.doc_mut(id)?; let editor = doc.editor.as_mut().ok_or(EditError::NothingToUndo)?; @@ -4068,6 +4070,7 @@ impl Session { Ok(label) } + /// Redo the last edit of document `id` that [`Session::undo`] took back. Returns its label. pub fn redo(&mut self, id: DocId) -> Result { let doc = self.doc_mut(id)?; let editor = doc.editor.as_mut().ok_or(EditError::NothingToRedo)?; diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 346c7dfb08..6a9574ed5b 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -4741,6 +4741,50 @@ fn an_exit_skip_does_not_outlive_its_change() { assert_eq!(field_text(&s, id, "exits"), (before.parse::().unwrap() + 1).to_string(), "after another edit, the exit runs"); } +/// A focus event that changes nothing, batched with a form edit, doesn't cost the batch its edit. +#[test] +fn a_batch_keeps_its_edits_when_its_focus_event_changes_nothing() { + let mut s = Session::new(); + let id = s.open("form.pdf", None, focus_form(), None).expect("opens"); + let som = { + let doc = s.get(id).unwrap(); + let quiet = doc.form.iter().find(|f| f.name == "quiet").unwrap(); + crate::xfa::widget_som(&doc.editor.as_ref().unwrap().cos, quiet, 0).unwrap() + }; + let batch = Edit::Batch { + label: "Fill and leave".into(), + edits: vec![ + Edit::SetFieldValue { name: "quiet".into(), value: FieldValue::Text("z".into()) }, + Edit::XfaEvent { som, activity: "exit".into() }, + ], + }; + s.apply(id, batch).unwrap(); + assert_eq!(field_text(&s, id, "quiet"), "z", "the value is kept"); + assert!(s.get(id).unwrap().dirty); +} + +/// A field's exit is noted as run (for the focus loss to skip) only when an exit script ran: +/// with every script skipped, as when the event's budget is spent or a script ran away, nothing +/// is noted and the next focus loss runs the exit. +#[test] +fn on_changes_notes_only_the_exits_that_ran() { + let mut s = Session::new(); + let id = s.open("form.pdf", None, focus_form(), None).expect("opens"); + let doc = s.get(id).unwrap(); + let mut cos = doc.editor.as_ref().unwrap().cos.clone(); + let tpl = doc.xfa_template.clone().unwrap(); + let names = vec!["probe".to_string()]; + let mut exited = Vec::new(); + let mut out = crate::js::JsOutput::default(); + let mut stopped = crate::xfa::XfaRun { page_count: 1, out: &mut out, datasets: None, ran_away: true }; + crate::xfa::on_changes(&mut cos, &tpl, &names, &mut stopped, &mut exited).unwrap(); + assert!(exited.is_empty(), "no exit ran: {exited:?}"); + let mut out = crate::js::JsOutput::default(); + let mut running = crate::xfa::XfaRun { page_count: 1, out: &mut out, datasets: None, ran_away: false }; + crate::xfa::on_changes(&mut cos, &tpl, &names, &mut running, &mut exited).unwrap(); + assert_eq!(exited.len(), 1, "the exit ran: {exited:?}"); +} + /// Scripts that change nothing leave no edit: the document stays saved, with nothing to undo, /// and what they showed still reaches the viewer. A field without such scripts runs nothing. #[test] diff --git a/crates/engine/src/xfa.rs b/crates/engine/src/xfa.rs index 8743ac94ed..b2e1abc25c 100644 --- a/crates/engine/src/xfa.rs +++ b/crates/engine/src/xfa.rs @@ -839,13 +839,18 @@ pub(crate) fn on_changes( let som = r.by_som().iter().find(|(_, f)| *f == name).map(|(s, _)| s.clone()); let Some(som) = som else { continue }; let value = r.fields().get(name).map(|f| f.value.join("\n")).unwrap_or_default(); - let exits = r.events_of(&som, "exit"); - if !exits.is_empty() { - exited.push(som.clone()); - } - for ev in r.events_of(&som, "change").into_iter().chain(exits) { + for ev in r.events_of(&som, "change") { r.run(&ev, &value)?; } + // Noted only when an exit script ran: one the event's budget skipped, or one an error + // before it stopped, is still to run when the field loses focus. + let mut ran_exit = false; + for ev in r.events_of(&som, "exit") { + ran_exit |= r.run(&ev, &value)?.is_some(); + } + if ran_exit { + exited.push(som.clone()); + } for ev in r.events_of(&som, "validate") { let Some(o) = r.run(&ev, &value)? else { continue }; if (o.result_bool == Some(false) || o.error.is_some()) && !value.trim().is_empty() {