From 5ab0abaf6f6b29829bf22e9a8ba86e9acc32f4c9 Mon Sep 17 00:00:00 2001 From: moppeeer Date: Thu, 15 Oct 2026 18:02:04 +0200 Subject: [PATCH] Pages: deleting pages that contain objects asks first, with Don't show again Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 1 + crates/doc/src/build.rs | 8 ++++ crates/ui-egui/src/dialogs.rs | 36 +++++++++++++- crates/ui-egui/src/i18n.rs | 22 +++++++++ crates/ui-egui/src/i18n/ar.rs | 4 ++ crates/ui-egui/src/i18n/de.rs | 4 ++ crates/ui-egui/src/i18n/it.rs | 4 ++ crates/ui-egui/src/i18n/ja.rs | 4 ++ crates/ui-egui/src/i18n/ka.rs | 4 ++ crates/ui-egui/src/i18n/pt_br.rs | 4 ++ crates/ui-egui/src/i18n/uk.rs | 4 ++ crates/ui-egui/src/lib.rs | 3 ++ crates/ui-egui/src/menus.rs | 2 +- crates/ui-egui/src/panels/pages.rs | 62 +++++++++++++++++++++++-- crates/ui-egui/src/panels/properties.rs | 2 +- 15 files changed, 158 insertions(+), 6 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index d60a6bf49..a9102718e 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -347,6 +347,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m Newest first. +- 2026-10-15: Deleting pages that contain objects asks first, as InDesign does ("The affected page contains objects. Delete the page anyway?", with Don't show again), from the Pages panel, Layout › Pages › Delete Page and Properties. - **2026-10-14** · The Type tool draws a new text frame over a locked empty frame, as InDesign does, instead of turning the locked frame into a text frame. No readiness percentage change. - **2026-10-14** · 100% black shows as pure black on screen by default, as InDesign's Appearance of Black does out of the box (Display All Blacks as Rich Black); Preferences › Appearance of Black › Display All Blacks Accurately still shows it as the dark grey it prints, and a saved choice is kept. No readiness percentage change. - **2026-10-14** · Pathfinder keeps the frontmost object, as InDesign does: a photo placed over shapes and intersected with them shows through the shapes (it was dropped, and the shapes kept their black fill); Add, Exclude Overlap and Minus Back take the front object's fill too, Subtract still keeps the back one. No readiness percentage change. diff --git a/crates/doc/src/build.rs b/crates/doc/src/build.rs index 9fb075553..c673d68bb 100644 --- a/crates/doc/src/build.rs +++ b/crates/doc/src/build.rs @@ -442,6 +442,14 @@ impl Document { } /// Delete absolute pages (items whose centre is on a deleted page are deleted too). + /// Whether absolute page `abs` holds page items: the ones Delete Pages removes with it + /// (an item belongs to the page its centre is over). + pub fn page_has_items(&self, abs: usize) -> bool { + let Some((si, pi)) = self.page_loc(abs) else { return false }; + let Some(sp) = self.spreads.get(si) else { return false }; + sp.items.iter().any(|it| sp.page_at_x(it.bounds().center().x) == Some(pi)) + } + pub fn delete_pages(&mut self, pages: &[usize]) -> Result<()> { if pages.len() >= self.page_count() { return Err(DocError::Invalid("a document needs at least one page".into())); diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 7d02776a2..01c78f96c 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -1512,6 +1512,7 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) { "userDictionary" => crate::i18n::tr(&app.ui.language, "User Dictionary"), "newWorkspace" => crate::i18n::tr(&app.ui.language, "New Workspace"), "closeDocument" => crate::i18n::tr(&app.ui.language, "Unsaved Changes"), + "deletePages" => crate::i18n::tr(&app.ui.language, "Warning"), "updateLink" => crate::i18n::tr(&app.ui.language, "Update Link"), "menus" => crate::i18n::tr(&app.ui.language, "Menu Customization"), "importOptions" => crate::i18n::tr(&app.ui.language, "Import Options"), @@ -1537,7 +1538,7 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) { let available = (ctx.content_rect().size() - frame_margin - egui::vec2(16.0, 16.0)).max(egui::Vec2::splat(1.0)); let preferred_width: f32 = match d.id.as_str() { "alert" => 440.0, - "closeDocument" | "updateLink" => 380.0, + "closeDocument" | "updateLink" | "deletePages" => 380.0, "newDocument" if crate::i18n::is_rtl(&app.ui.language) => 380.0, _ => 640.0, }; @@ -2023,6 +2024,22 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) { .replace("{}", &d.s("title")); crate::rtl::label(ui, text); } + "deletePages" => { + let several = d.fields.get("pages").and_then(Value::as_array).is_some_and(|p| p.len() > 1); + crate::rtl::label( + ui, + crate::i18n::tr( + &app.ui.language, + if several { + "The affected pages contain objects. Delete the pages anyway?" + } else { + "The affected page contains objects. Delete the page anyway?" + }, + ), + ); + ui.add_space(8.0); + check(ui, &mut d, "dontShowAgain", crate::i18n::tr(&app.ui.language, "Don't show again")); + } "updateLink" => { let text = crate::i18n::tr( &app.ui.language, @@ -2244,6 +2261,17 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) { } } +/// Delete absolute pages `pages` as the Pages panel and Layout › Pages › Delete Page do: when +/// they hold objects, InDesign's warning asks first, unless it was told not to. +pub fn delete_pages(app: &mut DesignApp, pages: Vec) -> Result { + let has_items = app.session.active().is_some_and(|st| pages.iter().any(|&abs| st.doc.page_has_items(abs))); + if has_items && !app.ui.skip_delete_pages_warning { + app.ui.dialog = Some(Dialog::new("deletePages", json!({"pages": pages}))); + return Ok(Value::Null); + } + app.run("layout.pages.delete", json!({"pages": pages})) +} + /// Close the open dialog without applying it (Cancel, Esc, the control channel): a previewing /// dialog puts the document back as it was. pub fn cancel(app: &mut DesignApp) { @@ -2636,6 +2664,12 @@ pub fn confirm(app: &mut DesignApp) -> Result { app.run("document.preferences", doc) } "newWorkspace" => app.run("window.newWorkspace", json!({"name": d.s("name")})), + "deletePages" => { + if d.b("dontShowAgain") { + app.ui.skip_delete_pages_warning = true; + } + app.run("layout.pages.delete", json!({"pages": d.fields.get("pages").cloned().unwrap_or_else(|| json!([]))})) + } "updateLink" => crate::panels::properties::run_update_links(app, d.fields.get("target").cloned().unwrap_or_else(|| json!({}))), "closeDocument" => { // The document may have moved in the tab row (or gone) while the dialog was open. diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index 245a6e287..518900126 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -58,6 +58,28 @@ const TABLE: &[(&str, [&str; 5])] = &[ ("Cut", ["Ausschneiden", "Couper", "Cortar", "カット", "剪切"]), ("Copy", ["Kopieren", "Copier", "Copiar", "コピー", "复制"]), ("Paste", ["Einfügen", "Coller", "Pegar", "ペースト", "粘贴"]), + ("Warning", ["Warnung", "Avertissement", "Advertencia", "警告", "警告"]), + ("Don't show again", ["Nicht mehr anzeigen", "Ne plus afficher", "No volver a mostrar", "今後表示しない", "不再显示"]), + ( + "The affected page contains objects. Delete the page anyway?", + [ + "Die betroffene Seite enthält Objekte. Seite trotzdem löschen?", + "La page concernée contient des objets. Supprimer quand même la page ?", + "La página afectada contiene objetos. ¿Desea eliminarla de todos modos?", + "対象のページにはオブジェクトが含まれています。ページを削除しますか?", + "受影响的页面包含对象。仍要删除该页面吗?", + ], + ), + ( + "The affected pages contain objects. Delete the pages anyway?", + [ + "Die betroffenen Seiten enthalten Objekte. Seiten trotzdem löschen?", + "Les pages concernées contiennent des objets. Supprimer quand même les pages ?", + "Las páginas afectadas contienen objetos. ¿Desea eliminarlas de todos modos?", + "対象のページにはオブジェクトが含まれています。ページを削除しますか?", + "受影响的页面包含对象。仍要删除这些页面吗?", + ], + ), ( "Bulleted & Numbered Lists", [ diff --git a/crates/ui-egui/src/i18n/ar.rs b/crates/ui-egui/src/i18n/ar.rs index d1dcb7acf..30f449d1f 100644 --- a/crates/ui-egui/src/i18n/ar.rs +++ b/crates/ui-egui/src/i18n/ar.rs @@ -30,6 +30,10 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "قص"), ("Copy", "نسخ"), ("Paste", "لصق"), + ("Warning", "تحذير"), + ("Don't show again", "عدم الإظهار مرة أخرى"), + ("The affected page contains objects. Delete the page anyway?", "تحتوي الصفحة المعنية على كائنات. هل تريد حذف الصفحة على أي حال؟"), + ("The affected pages contain objects. Delete the pages anyway?", "تحتوي الصفحات المعنية على كائنات. هل تريد حذف الصفحات على أي حال؟"), ("Bulleted & Numbered Lists", "القوائم ذات التعداد النقطي والرقمي"), ("Current Page Number", "رقم الصفحة الحالية"), ("Apply Color (,)", "تطبيق اللون (,)"), diff --git a/crates/ui-egui/src/i18n/de.rs b/crates/ui-egui/src/i18n/de.rs index 0f55578b5..973aa2029 100644 --- a/crates/ui-egui/src/i18n/de.rs +++ b/crates/ui-egui/src/i18n/de.rs @@ -30,6 +30,10 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Ausschneiden"), ("Copy", "Kopieren"), ("Paste", "Einfügen"), + ("Warning", "Warnung"), + ("Don't show again", "Nicht mehr anzeigen"), + ("The affected page contains objects. Delete the page anyway?", "Die betroffene Seite enthält Objekte. Seite trotzdem löschen?"), + ("The affected pages contain objects. Delete the pages anyway?", "Die betroffenen Seiten enthalten Objekte. Seiten trotzdem löschen?"), ("Clear", "Löschen"), ("Duplicate", "Duplizieren"), ("Select All", "Alles auswählen"), diff --git a/crates/ui-egui/src/i18n/it.rs b/crates/ui-egui/src/i18n/it.rs index 00328ac9f..19c89f419 100644 --- a/crates/ui-egui/src/i18n/it.rs +++ b/crates/ui-egui/src/i18n/it.rs @@ -28,6 +28,10 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Taglia"), ("Copy", "Copia"), ("Paste", "Incolla"), + ("Warning", "Avviso"), + ("Don't show again", "Non mostrare più"), + ("The affected page contains objects. Delete the page anyway?", "La pagina interessata contiene oggetti. Eliminarla comunque?"), + ("The affected pages contain objects. Delete the pages anyway?", "Le pagine interessate contengono oggetti. Eliminarle comunque?"), ("Bulleted & Numbered Lists", "Elenchi puntati e numerati"), ("Current Page Number", "Numero di pagina corrente"), ("Apply Color (,)", "Applica colore (,)"), diff --git a/crates/ui-egui/src/i18n/ja.rs b/crates/ui-egui/src/i18n/ja.rs index fe9e213e7..0f6ee87d6 100644 --- a/crates/ui-egui/src/i18n/ja.rs +++ b/crates/ui-egui/src/i18n/ja.rs @@ -30,6 +30,10 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "カット"), ("Copy", "コピー"), ("Paste", "ペースト"), + ("Warning", "警告"), + ("Don't show again", "今後表示しない"), + ("The affected page contains objects. Delete the page anyway?", "対象のページにはオブジェクトが含まれています。ページを削除しますか?"), + ("The affected pages contain objects. Delete the pages anyway?", "対象のページにはオブジェクトが含まれています。ページを削除しますか?"), ("Bulleted & Numbered Lists", "箇条書き"), ("Apply Color (,)", "カラーを適用 (,)"), ("Apply Gradient (.)", "グラデーションを適用 (.)"), diff --git a/crates/ui-egui/src/i18n/ka.rs b/crates/ui-egui/src/i18n/ka.rs index b3379c6d7..6bac0e4fa 100644 --- a/crates/ui-egui/src/i18n/ka.rs +++ b/crates/ui-egui/src/i18n/ka.rs @@ -31,6 +31,10 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "ამოჭრა"), ("Copy", "კოპირება"), ("Paste", "ჩასმა"), + ("Warning", "გაფრთხილება"), + ("Don't show again", "აღარ აჩვენო"), + ("The affected page contains objects. Delete the page anyway?", "გვერდი შეიცავს ობიექტებს. მაინც წავშალო გვერდი?"), + ("The affected pages contain objects. Delete the pages anyway?", "გვერდები შეიცავს ობიექტებს. მაინც წავშალო გვერდები?"), ("Reverse Path", "ტრაექტორიის შებრუნება"), ("Set Anchor", "საყრდენი წერტილის დაყენება"), ("Clear", "გასუფთავება"), diff --git a/crates/ui-egui/src/i18n/pt_br.rs b/crates/ui-egui/src/i18n/pt_br.rs index 0675a2f66..639a811ad 100644 --- a/crates/ui-egui/src/i18n/pt_br.rs +++ b/crates/ui-egui/src/i18n/pt_br.rs @@ -31,6 +31,10 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Recortar"), ("Copy", "Copiar"), ("Paste", "Colar"), + ("Warning", "Aviso"), + ("Don't show again", "Não mostrar novamente"), + ("The affected page contains objects. Delete the page anyway?", "A página afetada contém objetos. Excluir a página mesmo assim?"), + ("The affected pages contain objects. Delete the pages anyway?", "As páginas afetadas contêm objetos. Excluir as páginas mesmo assim?"), ("Bulleted & Numbered Lists", "Listas com marcadores e numeradas"), ("Show Hidden Characters", "Mostrar caracteres ocultos"), ("Current Page Number", "Número da página atual"), diff --git a/crates/ui-egui/src/i18n/uk.rs b/crates/ui-egui/src/i18n/uk.rs index 8f1d27f84..ef80e1f2f 100644 --- a/crates/ui-egui/src/i18n/uk.rs +++ b/crates/ui-egui/src/i18n/uk.rs @@ -31,6 +31,10 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Вирізати"), ("Copy", "Копіювати"), ("Paste", "Вставити"), + ("Warning", "Попередження"), + ("Don't show again", "Більше не показувати"), + ("The affected page contains objects. Delete the page anyway?", "Сторінка містить об’єкти. Усе одно видалити сторінку?"), + ("The affected pages contain objects. Delete the pages anyway?", "Сторінки містять об’єкти. Усе одно видалити сторінки?"), ("Bulleted & Numbered Lists", "Марковані й нумеровані списки"), ("Current Page Number", "Номер поточної сторінки"), ("Apply Color (,)", "Застосувати колір (,)"), diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 59c7ebb59..36d946950 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -195,6 +195,8 @@ pub struct UiState { pub flattener_preview: bool, /// Tab / Shift+Tab: 1 = every panel hidden, 2 = all but the Tools panel (0 = shown). pub hidden_panels: u8, + /// "Don't show again" in the warning before deleting pages that contain objects. + pub skip_delete_pages_warning: bool, /// Edit › Color Settings (re-applied at start). pub color_settings: Option, /// View › Proof Colors and View › Proof Setup. @@ -339,6 +341,7 @@ impl Default for UiState { tag_markers: false, flattener_preview: false, hidden_panels: 0, + skip_delete_pages_warning: false, color_settings: None, proof_colors: false, proof_setup: Default::default(), diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index a35261d59..2e8260765 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -1123,7 +1123,7 @@ fn run_ui_inner(app: &mut DesignApp, id: &str, p: &Value) -> Option 1 && item(ui, "Delete Spread") { let first = doc.first_page_of_spread(si); - let _ = app.run("layout.pages.delete", json!({"pages": (first..first.saturating_add(pages_in_spread)).collect::>()})); + let _ = crate::dialogs::delete_pages(app, (first..first.saturating_add(pages_in_spread)).collect()); ui.close(); } ui.separator(); @@ -438,6 +438,62 @@ mod tests { assert_eq!(h.state().session.doc().unwrap().doc.page_count(), 4, "only the clicked page"); } + /// As in InDesign: deleting a page with objects asks first; an empty page goes at once, and + /// "Don't show again" stops the question. + #[test] + fn deleting_a_page_with_objects_asks_first() { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"pages": 5, "facingPages": true})).unwrap(); + let doc = app.session.doc().unwrap().doc.clone(); + let (si, pi) = doc.page_loc(2).unwrap(); + let x = doc.spreads[si].pages[pi].x; + app.run("frame.create", json!({"spread": si, "rect": [x + 36.0, 36.0, x + 200.0, 200.0]})).unwrap(); + let count = |h: &Harness<'_, DesignApp>| h.state().session.doc().unwrap().doc.page_count(); + let mut h = menu_harness(app, 2, si); + h.get_by_label("Delete Page").click(); + h.run_steps(3); + assert_eq!(count(&h), 5, "nothing deleted yet"); + let d = h.state().ui.dialog.clone().unwrap(); + assert_eq!((d.id.as_str(), d.fields["pages"].clone()), ("deletePages", json!([2]))); + // Cancel keeps the page. + crate::dialogs::cancel(h.state_mut()); + assert_eq!(count(&h), 5); + // The whole spread: one question for both pages. + h.get_by_label("Delete Spread").click(); + h.run_steps(3); + assert_eq!(h.state().ui.dialog.as_ref().unwrap().fields["pages"], json!([1, 2])); + crate::dialogs::cancel(h.state_mut()); + // OK deletes it. + h.get_by_label("Delete Page").click(); + h.run_steps(3); + crate::dialogs::confirm(h.state_mut()).unwrap(); + assert_eq!(count(&h), 4); + assert!(!h.state().ui.skip_delete_pages_warning); + // An empty page goes without a question. + let app = h.state_mut(); + crate::dialogs::delete_pages(app, vec![0]).unwrap(); + assert!(app.ui.dialog.is_none()); + assert_eq!(app.session.doc().unwrap().doc.page_count(), 3); + // "Don't show again". + let doc = app.session.doc().unwrap().doc.clone(); + let (si, pi) = doc.page_loc(1).unwrap(); + let x = doc.spreads[si].pages[pi].x; + app.run("frame.create", json!({"spread": si, "rect": [x + 36.0, 36.0, x + 200.0, 200.0]})).unwrap(); + app.run("frame.create", json!({"spread": si, "rect": [x + 36.0, 300.0, x + 200.0, 400.0]})).unwrap(); + crate::dialogs::delete_pages(app, vec![1]).unwrap(); + app.ui.dialog.as_mut().unwrap().fields.insert("dontShowAgain".into(), json!(true)); + crate::dialogs::confirm(app).unwrap(); + assert!(app.ui.skip_delete_pages_warning); + assert_eq!(app.session.doc().unwrap().doc.page_count(), 2); + let doc = app.session.doc().unwrap().doc.clone(); + let (si, pi) = doc.page_loc(0).unwrap(); + let x = doc.spreads[si].pages[pi].x; + app.run("frame.create", json!({"spread": si, "rect": [x + 36.0, 36.0, x + 200.0, 200.0]})).unwrap(); + crate::dialogs::delete_pages(app, vec![0]).unwrap(); + assert!(app.ui.dialog.is_none(), "no more questions"); + assert_eq!(app.session.doc().unwrap().doc.page_count(), 1); + } + #[test] fn the_panel_menu_follows_indesigns_order() { let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); diff --git a/crates/ui-egui/src/panels/properties.rs b/crates/ui-egui/src/panels/properties.rs index 52ce7e688..e37d4eb20 100644 --- a/crates/ui-egui/src/panels/properties.rs +++ b/crates/ui-egui/src/panels/properties.rs @@ -350,7 +350,7 @@ fn document_sections(app: &mut DesignApp, ui: &mut Ui) { let _ = app.run("layout.pages.insert", json!({"after": cur, "count": 1})); } if del { - let _ = app.run("layout.pages.delete", json!({"pages": [cur]})); + let _ = crate::dialogs::delete_pages(app, vec![cur]); } ui.add_space(3.0); if outline_button(ui, crate::i18n::tr(&app.ui.language, "Edit Page"), fw).clicked() {