mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-10-24 10:08:00 -06:00
This commit is contained in:
@@ -200,6 +200,11 @@ impl crate::Document {
|
||||
self.editor.as_ref().and_then(|e| pdfcraft_forms::field_actions(&e.cos, name).ok()).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Field Properties ▸ Actions: how many actions are chained after each trigger's first one.
|
||||
pub fn field_action_chains(&self, name: &str) -> Vec<(pdfcraft_forms::Trigger, usize)> {
|
||||
self.editor.as_ref().and_then(|e| pdfcraft_forms::field_action_chains(&e.cos, name).ok()).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Document JavaScripts (name, source), in name order.
|
||||
pub fn document_scripts(&self) -> Vec<(String, String)> {
|
||||
self.editor.as_ref().map(|e| document_scripts(&e.cos)).unwrap_or_default()
|
||||
|
||||
@@ -159,6 +159,60 @@ pub fn field_actions(doc: &Document, name: &str) -> Result<Vec<(Trigger, FieldAc
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Most chained actions [`field_action_chains`] walks per trigger.
|
||||
const MAX_CHAIN: usize = 1000;
|
||||
|
||||
/// How many actions follow `action` through `/Next` (a dictionary or an array of them, each with
|
||||
/// its own `/Next`), counting each object once.
|
||||
fn chained(doc: &Document, action: &Object) -> usize {
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
if let Object::Ref(r) = action {
|
||||
seen.insert(*r);
|
||||
}
|
||||
let mut stack: Vec<Object> = doc.resolve(action).as_dict().and_then(|d| d.get(b"Next").cloned()).into_iter().collect();
|
||||
let mut count = 0;
|
||||
while let Some(o) = stack.pop() {
|
||||
if count >= MAX_CHAIN {
|
||||
break;
|
||||
}
|
||||
if let Object::Ref(r) = &o
|
||||
&& !seen.insert(*r)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
match &*doc.resolve(&o) {
|
||||
Object::Array(items) => stack.extend(items.iter().cloned()),
|
||||
Object::Dict(d) => {
|
||||
count += 1;
|
||||
stack.extend(d.get(b"Next").cloned());
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
count
|
||||
}
|
||||
|
||||
/// For each trigger of field `name` (its first widget) whose action has others chained after it,
|
||||
/// how many there are.
|
||||
pub fn field_action_chains(doc: &Document, name: &str) -> Result<Vec<(Trigger, usize)>, FormError> {
|
||||
let all = fields(doc);
|
||||
let f = all.iter().find(|f| f.name == name).ok_or_else(|| FormError::NoSuchField(name.into()))?;
|
||||
let Some(w) = f.widgets.first() else { return Ok(Vec::new()) };
|
||||
let wd = doc.get(w.obj).as_dict().cloned().unwrap_or_default();
|
||||
let aa = wd.get(b"AA").map(|a| doc.resolve(a)).and_then(|a| a.as_dict().cloned()).unwrap_or_default();
|
||||
Ok(Trigger::ALL
|
||||
.into_iter()
|
||||
.filter_map(|t| {
|
||||
let o = match t.key() {
|
||||
None => wd.get(b"A").cloned().or_else(|| aa.get(b"U").cloned()),
|
||||
Some(k) => aa.get(k).cloned(),
|
||||
}?;
|
||||
let n = chained(doc, &o);
|
||||
(n > 0).then_some((t, n))
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
fn write(doc: &mut Document, a: &FieldAction, pages: &[pdfcraft_cos::ObjRef]) -> Result<Option<Object>, FormError> {
|
||||
let mut d = Dict::new();
|
||||
d.set(b"Type".to_vec(), Object::name("Action"));
|
||||
@@ -207,11 +261,13 @@ fn write(doc: &mut Document, a: &FieldAction, pages: &[pdfcraft_cos::ObjRef]) ->
|
||||
}
|
||||
|
||||
/// Replace field `name`'s actions (on every widget) with `actions`; triggers not listed lose
|
||||
/// theirs. `Other` actions are left as they are.
|
||||
/// theirs. `Other` actions, and actions that read the same as before, are left as they are (with
|
||||
/// their chain and the keys this module doesn't read); a replaced action keeps its chain.
|
||||
pub fn set_field_actions(doc: &mut Document, name: &str, actions: &[(Trigger, FieldAction)]) -> Result<(), FormError> {
|
||||
let all = fields(doc);
|
||||
let f = all.iter().find(|f| f.name == name).ok_or_else(|| FormError::NoSuchField(name.into()))?.clone();
|
||||
let pages = crate::page_refs(doc);
|
||||
let mut scripts = crate::script_budget();
|
||||
for w in &f.widgets {
|
||||
let wd = doc.get(w.obj).as_dict().cloned().unwrap_or_default();
|
||||
let mut aa = wd.get(b"AA").map(|a| doc.resolve(a)).and_then(|a| a.as_dict().cloned()).unwrap_or_default();
|
||||
@@ -221,10 +277,23 @@ pub fn set_field_actions(doc: &mut Document, name: &str, actions: &[(Trigger, Fi
|
||||
if matches!(new, Some(FieldAction::Other(_))) {
|
||||
continue;
|
||||
}
|
||||
let old = match t.key() {
|
||||
None => wd.get(b"A").cloned().or_else(|| aa.get(b"U").cloned()),
|
||||
Some(k) => aa.get(k).cloned(),
|
||||
};
|
||||
let old_action = old.as_ref().and_then(|o| read(doc, o, &pages, &mut scripts));
|
||||
if new.is_some() && new == old_action.as_ref() {
|
||||
continue;
|
||||
}
|
||||
// The list shows a trigger's first action: replacing it keeps the ones chained after it.
|
||||
let next = new.and(old).and_then(|o| doc.resolve(&o).as_dict().and_then(|d| d.get(b"Next").cloned()));
|
||||
let obj = match new {
|
||||
Some(a) => write(doc, a, &pages)?,
|
||||
None => None,
|
||||
};
|
||||
if let (Some(Object::Ref(r)), Some(next)) = (&obj, next) {
|
||||
doc.update_dict(*r, |d| d.set(b"Next".to_vec(), next))?;
|
||||
}
|
||||
match t.key() {
|
||||
None => {
|
||||
a_entry = obj;
|
||||
|
||||
@@ -24,7 +24,7 @@ mod calc_order;
|
||||
pub mod detect;
|
||||
mod scripting;
|
||||
pub mod table;
|
||||
pub use actions::{FieldAction, Trigger, field_actions, set_field_actions};
|
||||
pub use actions::{FieldAction, Trigger, field_action_chains, field_actions, set_field_actions};
|
||||
pub use author::{
|
||||
BorderStyle, CheckStyle, FieldFont, FieldProps, Look, LookPatch, MIN_FIELD_SIDE, NewField, add_field, check_style, delete_field,
|
||||
delete_field_object, duplicate_field, look, redraw_field, set_button_icon, set_props,
|
||||
|
||||
@@ -1976,6 +1976,80 @@ fn field_actions_round_trip_on_every_trigger() {
|
||||
assert_eq!(fields(&doc).iter().find(|f| f.name == "go").unwrap().button, Some(af::ButtonAction::Named("Print".into())));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn editing_one_script_keeps_the_other_actions_and_the_chain() {
|
||||
let mut doc = fixture();
|
||||
let go = field(&fields(&doc), "go").obj;
|
||||
let action = |doc: &mut Document, entries: Vec<(&[u8], Object)>| {
|
||||
let mut a = Dict::new();
|
||||
for (k, v) in entries {
|
||||
a.set(k.to_vec(), v);
|
||||
}
|
||||
doc.add(Object::Dict(a))
|
||||
};
|
||||
let next = action(&mut doc, vec![(b"S", Object::name("URI")), (b"URI", Object::String(PdfString::literal(b"https://example.org".to_vec())))]);
|
||||
let up = action(
|
||||
&mut doc,
|
||||
vec![(b"S", Object::name("JavaScript")), (b"JS", Object::String(PdfString::text("app.alert(1);"))), (b"Next", Object::Ref(next))],
|
||||
);
|
||||
let blur = action(
|
||||
&mut doc,
|
||||
vec![(b"S", Object::name("JavaScript")), (b"JS", Object::String(PdfString::text("this.dirty = true;"))), (b"Foo", Object::Int(7))],
|
||||
);
|
||||
let mut aa = Dict::new();
|
||||
aa.set(b"Bl".to_vec(), Object::Ref(blur));
|
||||
doc.update_dict(go, |d| {
|
||||
d.set(b"A".to_vec(), Object::Ref(up));
|
||||
d.set(b"AA".to_vec(), Object::Dict(aa));
|
||||
})
|
||||
.unwrap();
|
||||
let mut acts = field_actions(&doc, "go").unwrap();
|
||||
assert_eq!(acts[0], (Trigger::MouseUp, FieldAction::JavaScript("app.alert(1);".into())));
|
||||
acts[0].1 = FieldAction::JavaScript("app.alert(2);".into());
|
||||
set_field_actions(&mut doc, "go", &acts).unwrap();
|
||||
let w = doc.get(go).as_dict().cloned().unwrap();
|
||||
// The action that did not change is the same object, with its keys this crate doesn't read.
|
||||
let aa = w.get(b"AA").unwrap().as_dict().unwrap().clone();
|
||||
assert_eq!(aa.get(b"Bl"), Some(&Object::Ref(blur)));
|
||||
// The edited script keeps the actions chained after it.
|
||||
let a = doc.resolve(w.get(b"A").unwrap()).as_dict().cloned().unwrap();
|
||||
assert_eq!(a.get(b"Next"), Some(&Object::Ref(next)));
|
||||
assert_eq!(field_actions(&doc, "go").unwrap()[0], (Trigger::MouseUp, FieldAction::JavaScript("app.alert(2);".into())));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replacing_a_chained_action_with_another_type_keeps_the_chain() {
|
||||
let mut doc = fixture();
|
||||
let go = field(&fields(&doc), "go").obj;
|
||||
let s = |t: &str| Object::String(PdfString::text(t));
|
||||
let mut named = Dict::new();
|
||||
named.set(b"S".to_vec(), Object::name("Named"));
|
||||
named.set(b"N".to_vec(), Object::name("NextPage"));
|
||||
let named = doc.add(Object::Dict(named));
|
||||
// A chain as an array: a link, then a menu item that points back at the link (a loop).
|
||||
let mut uri = Dict::new();
|
||||
uri.set(b"S".to_vec(), Object::name("URI"));
|
||||
uri.set(b"URI".to_vec(), s("https://example.org"));
|
||||
let uri = doc.add(Object::Dict(uri));
|
||||
doc.update_dict(named, |d| d.set(b"Next".to_vec(), Object::Ref(uri))).unwrap();
|
||||
let chain = Object::Array(vec![Object::Ref(uri), Object::Ref(named)]);
|
||||
let mut up = Dict::new();
|
||||
up.set(b"S".to_vec(), Object::name("JavaScript"));
|
||||
up.set(b"JS".to_vec(), s("app.alert(1);"));
|
||||
up.set(b"Next".to_vec(), chain.clone());
|
||||
doc.update_dict(go, |d| d.set(b"A".to_vec(), Object::Dict(up))).unwrap();
|
||||
assert_eq!(field_action_chains(&doc, "go").unwrap(), [(Trigger::MouseUp, 2)]);
|
||||
set_field_actions(&mut doc, "go", &[(Trigger::MouseUp, FieldAction::Uri("https://example.com".into()))]).unwrap();
|
||||
let w = doc.get(go).as_dict().cloned().unwrap();
|
||||
let a = doc.resolve(w.get(b"A").unwrap()).as_dict().cloned().unwrap();
|
||||
assert_eq!(a.get(b"Next"), Some(&chain));
|
||||
assert_eq!(field_actions(&doc, "go").unwrap(), [(Trigger::MouseUp, FieldAction::Uri("https://example.com".into()))]);
|
||||
assert_eq!(field_action_chains(&doc, "go").unwrap(), [(Trigger::MouseUp, 2)]);
|
||||
// Deleting the trigger's action deletes its chain with it.
|
||||
set_field_actions(&mut doc, "go", &[]).unwrap();
|
||||
assert_eq!(field_action_chains(&doc, "go").unwrap(), []);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn push_buttons_read_hide_actions() {
|
||||
let mut doc = fixture();
|
||||
|
||||
@@ -741,6 +741,7 @@ impl crate::PdfCraftApp {
|
||||
self.session.get(id).map(|doc| doc.form.iter().filter(|x| x.name != name).map(|x| x.name.clone()).collect()).unwrap_or_default();
|
||||
d.others = others.clone();
|
||||
d.actions = self.session.get(id).map(|doc| doc.field_actions(name)).unwrap_or_default();
|
||||
d.chained = self.session.get(id).map(|doc| doc.field_action_chains(name)).unwrap_or_default();
|
||||
if let Some(o) = d.original.as_mut() {
|
||||
o.others = others;
|
||||
o.actions = d.actions.clone();
|
||||
@@ -853,6 +854,11 @@ pub struct FieldDraft {
|
||||
/// Actions tab: each trigger's action, and the one being added.
|
||||
pub actions: Vec<(pdfcraft_engine::FieldTrigger, pdfcraft_engine::FieldAction)>,
|
||||
pub new_action: ActionDraft,
|
||||
/// Actions tab: the trigger whose script `new_action` is editing (Edit), if any.
|
||||
pub editing: Option<pdfcraft_engine::FieldTrigger>,
|
||||
/// Actions tab: how many actions the document chains after each trigger's first one. They are
|
||||
/// kept when that first action is replaced.
|
||||
pub chained: Vec<(pdfcraft_engine::FieldTrigger, usize)>,
|
||||
original: Box<Option<FieldDraft>>,
|
||||
}
|
||||
|
||||
@@ -915,6 +921,8 @@ impl FieldDraft {
|
||||
others: Vec::new(),
|
||||
actions: Vec::new(),
|
||||
new_action: ActionDraft::default(),
|
||||
editing: None,
|
||||
chained: Vec::new(),
|
||||
original: Box::new(None),
|
||||
};
|
||||
d.original = Box::new(Some(d.clone()));
|
||||
@@ -1535,21 +1543,27 @@ fn validate_tab(ui: &mut egui::Ui, d: &mut FieldDraft) {
|
||||
|
||||
fn actions_tab(ui: &mut egui::Ui, d: &mut FieldDraft, t: &crate::theme::Tokens) {
|
||||
use pdfcraft_engine::FieldTrigger as T;
|
||||
ui.label(egui::RichText::new(tl!("Add an Action")).font(theme::semibold(13.0)));
|
||||
let editing = d.editing.is_some();
|
||||
ui.label(egui::RichText::new(if editing { tl!("Edit Action") } else { tl!("Add an Action") }).font(theme::semibold(13.0)));
|
||||
let a = &mut d.new_action;
|
||||
egui::Grid::new("field-actions-add").num_columns(2).spacing([12.0, 8.0]).show(ui, |ui| {
|
||||
ui.label(tl!("Select Trigger:"));
|
||||
egui::ComboBox::from_id_salt("action-trigger").selected_text(tl!(a.trigger.label())).width(220.0).show_ui(ui, |ui| {
|
||||
for tr in T::ALL {
|
||||
ui.selectable_value(&mut a.trigger, tr, tl!(tr.label()));
|
||||
}
|
||||
// Editing changes the script of one trigger's action: its trigger and kind stay.
|
||||
ui.add_enabled_ui(!editing, |ui| {
|
||||
egui::ComboBox::from_id_salt("action-trigger").selected_text(tl!(a.trigger.label())).width(220.0).show_ui(ui, |ui| {
|
||||
for tr in T::ALL {
|
||||
ui.selectable_value(&mut a.trigger, tr, tl!(tr.label()));
|
||||
}
|
||||
});
|
||||
});
|
||||
ui.end_row();
|
||||
ui.label(tl!("Select Action:"));
|
||||
egui::ComboBox::from_id_salt("action-kind").selected_text(tl!(ACTION_KINDS[a.kind])).width(220.0).show_ui(ui, |ui| {
|
||||
for (i, k) in ACTION_KINDS.iter().enumerate() {
|
||||
ui.selectable_value(&mut a.kind, i, tl!(k));
|
||||
}
|
||||
ui.add_enabled_ui(!editing, |ui| {
|
||||
egui::ComboBox::from_id_salt("action-kind").selected_text(tl!(ACTION_KINDS[a.kind])).width(220.0).show_ui(ui, |ui| {
|
||||
for (i, k) in ACTION_KINDS.iter().enumerate() {
|
||||
ui.selectable_value(&mut a.kind, i, tl!(k));
|
||||
}
|
||||
});
|
||||
});
|
||||
ui.end_row();
|
||||
let hint = match a.kind {
|
||||
@@ -1570,7 +1584,12 @@ fn actions_tab(ui: &mut egui::Ui, d: &mut FieldDraft, t: &crate::theme::Tokens)
|
||||
ui.end_row();
|
||||
});
|
||||
let mut error = None;
|
||||
if ui.button(tl!("Add")).clicked() {
|
||||
let (mut commit, mut discard) = (false, false);
|
||||
ui.horizontal(|ui| {
|
||||
commit = ui.button(if editing { tl!("Update") } else { tl!("Add") }).clicked();
|
||||
discard = editing && ui.button(tl!("Discard")).clicked();
|
||||
});
|
||||
if commit {
|
||||
match d.new_action.action() {
|
||||
Ok(act) => {
|
||||
let tr = d.new_action.trigger;
|
||||
@@ -1578,10 +1597,15 @@ fn actions_tab(ui: &mut egui::Ui, d: &mut FieldDraft, t: &crate::theme::Tokens)
|
||||
d.actions.push((tr, act));
|
||||
d.actions.sort_by_key(|(x, _)| T::ALL.iter().position(|y| y == x));
|
||||
d.new_action.text.clear();
|
||||
d.editing = None;
|
||||
}
|
||||
Err(e) => error = Some(e),
|
||||
}
|
||||
}
|
||||
if discard {
|
||||
d.new_action = ActionDraft::default();
|
||||
d.editing = None;
|
||||
}
|
||||
if let Some(e) = error {
|
||||
ui.label(egui::RichText::new(tl!(&e)).small().color(t.text_muted));
|
||||
}
|
||||
@@ -1592,19 +1616,40 @@ fn actions_tab(ui: &mut egui::Ui, d: &mut FieldDraft, t: &crate::theme::Tokens)
|
||||
if d.actions.is_empty() {
|
||||
ui.label(egui::RichText::new(tl!("No actions")).color(t.text_muted));
|
||||
}
|
||||
let mut remove = None;
|
||||
let (mut remove, mut edit) = (None, None);
|
||||
for (i, (tr, act)) in d.actions.iter().enumerate() {
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(egui::RichText::new(tl!(tr.label())).strong());
|
||||
ui.label(act.describe());
|
||||
let more = d.chained.iter().find(|(x, _)| x == tr).map_or(0, |(_, n)| *n);
|
||||
if more == 0 {
|
||||
ui.label(act.describe());
|
||||
} else {
|
||||
let then = crate::i18n::trn(crate::i18n::current(), more as u64, "then {n} more action", "then {n} more actions");
|
||||
ui.label(format!("{}, {then}", act.describe()));
|
||||
}
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
if ui.push_id(i, |ui| ui.button(tl!("Delete"))).inner.clicked() {
|
||||
remove = Some(i);
|
||||
}
|
||||
ui.push_id(i, |ui| {
|
||||
if ui.button(tl!("Delete")).clicked() {
|
||||
remove = Some(i);
|
||||
}
|
||||
if let pdfcraft_engine::FieldAction::JavaScript(js) = act
|
||||
&& ui.add_enabled(d.editing.is_none(), egui::Button::new(tl!("Edit"))).clicked()
|
||||
{
|
||||
edit = Some((*tr, js.clone()));
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
if let Some((trigger, text)) = edit {
|
||||
d.new_action = ActionDraft { trigger, kind: 0, text };
|
||||
d.editing = Some(trigger);
|
||||
}
|
||||
if let Some(i) = remove {
|
||||
if d.actions.get(i).map(|(x, _)| Some(*x)) == Some(d.editing) {
|
||||
d.new_action = ActionDraft::default();
|
||||
d.editing = None;
|
||||
}
|
||||
d.actions.remove(i);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -9,11 +9,15 @@ use pdfcraft_engine::field_table::TableColumn;
|
||||
use pdfcraft_ui_egui::{Dialog, PdfCraftApp, QuickTool};
|
||||
|
||||
fn harness() -> Harness<'static, PdfCraftApp> {
|
||||
let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(|cc| {
|
||||
harness_with("form.pdf", include_bytes!("../data/form.pdf").to_vec())
|
||||
}
|
||||
|
||||
fn harness_with(name: &'static str, bytes: Vec<u8>) -> Harness<'static, PdfCraftApp> {
|
||||
let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(move |cc| {
|
||||
cc.egui_ctx.options_mut(|options| options.warn_on_id_clash = true);
|
||||
let mut app = PdfCraftApp::new();
|
||||
app.set_option("language", "en").unwrap();
|
||||
app.open_bytes("form.pdf", None, include_bytes!("../data/form.pdf").to_vec()).unwrap();
|
||||
app.open_bytes(name, None, bytes).unwrap();
|
||||
app.set_option("zoom", "150").unwrap();
|
||||
app
|
||||
});
|
||||
@@ -712,6 +716,134 @@ fn actions_tab_adds_and_removes_actions() {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn actions_tab_edits_the_script_of_an_existing_action() {
|
||||
use pdfcraft_engine::{FieldAction, FieldTrigger};
|
||||
let mut h = harness();
|
||||
h.state_mut().execute("form.prepare");
|
||||
h.run_steps(2);
|
||||
let script = "if (event.value > 10) {\n app.alert('Too many');\n}";
|
||||
let actions = |h: &Harness<'static, PdfCraftApp>| {
|
||||
let s = h.state();
|
||||
s.session.get(s.views[0].id).unwrap().field_actions("city")
|
||||
};
|
||||
let open = |h: &mut Harness<'static, PdfCraftApp>| {
|
||||
h.state_mut().open_field_props("city", 0);
|
||||
h.run_steps(2);
|
||||
h.get_by_label("Actions").click();
|
||||
h.run_steps(2);
|
||||
};
|
||||
// A script on Mouse Up and a link on Blur, saved with the field.
|
||||
open(&mut h);
|
||||
for (trigger, kind, text) in [(FieldTrigger::MouseUp, 0, script), (FieldTrigger::OnBlur, 1, "https://example.org")] {
|
||||
let d = h.state_mut().field_props.as_mut().unwrap();
|
||||
d.new_action = pdfcraft_ui_egui::prepare::ActionDraft { trigger, kind, text: text.into() };
|
||||
h.run_steps(3);
|
||||
h.get_by_label("Add").click();
|
||||
h.run_steps(2);
|
||||
}
|
||||
h.get_by_label("OK").click();
|
||||
h.run_steps(3);
|
||||
let saved = actions(&h);
|
||||
// Edit shows the script exactly as stored; Discard leaves everything as it was.
|
||||
open(&mut h);
|
||||
h.get_all_by_label("Edit").last().unwrap().click();
|
||||
h.run_steps(2);
|
||||
h.get_by_label("Edit Action");
|
||||
assert_eq!(h.state().field_props.as_ref().unwrap().new_action.text, script);
|
||||
h.state_mut().field_props.as_mut().unwrap().new_action.text = "app.alert('changed');".into();
|
||||
h.run_steps(2);
|
||||
h.get_by_label("Discard").click();
|
||||
h.run_steps(2);
|
||||
h.get_by_label("Add an Action");
|
||||
h.get_by_label("OK").click();
|
||||
h.run_steps(3);
|
||||
assert_eq!(actions(&h), saved);
|
||||
// Update replaces that script only: same trigger, the link untouched.
|
||||
open(&mut h);
|
||||
h.get_all_by_label("Edit").last().unwrap().click();
|
||||
h.run_steps(2);
|
||||
h.state_mut().field_props.as_mut().unwrap().new_action.text = "app.alert('changed');".into();
|
||||
h.run_steps(2);
|
||||
h.get_by_label("Update").click();
|
||||
h.run_steps(2);
|
||||
h.get_by_label("OK").click();
|
||||
h.run_steps(3);
|
||||
assert_eq!(
|
||||
actions(&h),
|
||||
[
|
||||
(FieldTrigger::MouseUp, FieldAction::JavaScript("app.alert('changed');".into())),
|
||||
(FieldTrigger::OnBlur, FieldAction::Uri("https://example.org".into()))
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
/// One page with a push button "go": Mouse Up runs a script with a link chained after it, On Blur
|
||||
/// runs another script.
|
||||
fn chained_action_fixture() -> Vec<u8> {
|
||||
let objs = [
|
||||
"<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [4 0 R] /DA (/Helv 0 Tf 0 g) >> >>",
|
||||
"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 300 400] >>",
|
||||
"<< /Type /Page /Parent 2 0 R /Annots [4 0 R] >>",
|
||||
"<< /Type /Annot /Subtype /Widget /FT /Btn /Ff 65536 /T (go) /Rect [20 300 100 320] /P 3 0 R /A 5 0 R /AA << /Bl << /S /JavaScript /JS (this.dirty = true;) >> >> >>",
|
||||
"<< /S /JavaScript /JS (app.alert\\(1\\);) /Next 6 0 R >>",
|
||||
"<< /S /URI /URI (https://example.org) >>",
|
||||
];
|
||||
let mut out = b"%PDF-1.7\n".to_vec();
|
||||
let mut offs = Vec::new();
|
||||
for (i, o) in objs.iter().enumerate() {
|
||||
offs.push(out.len());
|
||||
out.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes());
|
||||
}
|
||||
let x = out.len();
|
||||
out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes());
|
||||
for o in offs {
|
||||
out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes());
|
||||
}
|
||||
out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{x}\n%%EOF\n", objs.len() + 1).as_bytes());
|
||||
out
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn actions_tab_shows_a_chain_and_keeps_it_when_the_first_action_is_replaced() {
|
||||
use egui_kittest::kittest::NodeT;
|
||||
use pdfcraft_engine::FieldTrigger;
|
||||
let mut h = harness_with("chain.pdf", chained_action_fixture());
|
||||
h.state_mut().execute("form.prepare");
|
||||
h.run_steps(2);
|
||||
let open = |h: &mut Harness<'static, PdfCraftApp>| {
|
||||
h.state_mut().open_field_props("go", 0);
|
||||
h.run_steps(2);
|
||||
h.get_by_label("Actions").click();
|
||||
h.run_steps(2);
|
||||
};
|
||||
open(&mut h);
|
||||
h.get_by_label("Run a JavaScript, then 1 more action");
|
||||
// The first Edit is the menu bar's; the rows' follow (Mouse Up, On Blur).
|
||||
assert_eq!(h.get_all_by_label("Edit").count(), 3);
|
||||
h.get_all_by_label("Edit").nth(1).unwrap().click();
|
||||
h.run_steps(2);
|
||||
assert_eq!(h.state().field_props.as_ref().unwrap().editing, Some(FieldTrigger::MouseUp));
|
||||
// While one script is open, no other Edit can overwrite it.
|
||||
assert!(h.get_all_by_label("Edit").skip(1).all(|n| n.accesskit_node().is_disabled()));
|
||||
h.get_by_label("Discard").click();
|
||||
h.run_steps(2);
|
||||
assert!(h.get_all_by_label("Edit").skip(1).all(|n| !n.accesskit_node().is_disabled()));
|
||||
// Replace the Mouse Up script with a link: the chained link stays after it.
|
||||
{
|
||||
let d = h.state_mut().field_props.as_mut().unwrap();
|
||||
d.actions.retain(|(t, _)| *t != FieldTrigger::MouseUp);
|
||||
d.new_action = pdfcraft_ui_egui::prepare::ActionDraft { trigger: FieldTrigger::MouseUp, kind: 1, text: "https://example.com".into() };
|
||||
}
|
||||
h.run_steps(3);
|
||||
h.get_by_label("Add").click();
|
||||
h.run_steps(2);
|
||||
h.get_by_label("OK").click();
|
||||
h.run_steps(3);
|
||||
open(&mut h);
|
||||
h.get_by_label("Open a web link: https://example.com, then 1 more action");
|
||||
}
|
||||
|
||||
/// Text field table: pick the tool and drag out an area in the empty lower part of page 1, 200 by
|
||||
/// 120 points from (40, 240) as displayed. The table's window opens for it.
|
||||
fn draw_table(h: &mut Harness<'static, PdfCraftApp>) {
|
||||
|
||||
@@ -4956,8 +4956,8 @@ milestone = "M6"
|
||||
status = "shipped"
|
||||
commands = ["form.prepare"]
|
||||
tools = ["form_actions", "form_set_actions"]
|
||||
evidence = ["crates/forms/src/tests.rs::field_actions_round_trip_on_every_trigger", "crates/automation/tests/it/automation.rs::field_actions_through_tools", "crates/ui-egui/tests/it/prepare.rs::actions_tab_adds_and_removes_actions"]
|
||||
notes = "Six triggers; Run a JavaScript, Open a web link, Reset a form, Execute a menu item, Go to a page view, Show/hide a field, Submit a form. Other action types are kept."
|
||||
evidence = ["crates/forms/src/tests.rs::field_actions_round_trip_on_every_trigger", "crates/automation/tests/it/automation.rs::field_actions_through_tools", "crates/ui-egui/tests/it/prepare.rs::actions_tab_adds_and_removes_actions", "crates/ui-egui/tests/it/prepare.rs::actions_tab_edits_the_script_of_an_existing_action", "crates/forms/src/tests.rs::editing_one_script_keeps_the_other_actions_and_the_chain", "crates/forms/src/tests.rs::replacing_a_chained_action_with_another_type_keeps_the_chain", "crates/ui-egui/tests/it/prepare.rs::actions_tab_shows_a_chain_and_keeps_it_when_the_first_action_is_replaced"]
|
||||
notes = "Six triggers; Run a JavaScript, Open a web link, Reset a form, Execute a menu item, Go to a page view, Show/hide a field, Submit a form. Other action types are kept. Edit opens the script of an existing Run a JavaScript action; unchanged actions are kept as they are, and replacing a trigger's first action keeps the actions chained after it, which the row counts. Edit for the other action types, and editing the chained actions, are missing."
|
||||
|
||||
[[feature]]
|
||||
id = "form.props-format"
|
||||
|
||||
Reference in New Issue
Block a user