From 3939b825d3a5ff6709079b6e6fcdf6a8605dd3d3 Mon Sep 17 00:00:00 2001 From: moppeeer Date: Wed, 14 Oct 2026 02:02:16 +0200 Subject: [PATCH 1/4] feat(ui): Pages panel duplicates pages by Alt-drag and the New Page button; Edit page size MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Chris Cicchino's "Adobe InDesign – Page Tool and Liquid Layout" (0:42, 2:06) holds Option and drags a page in the Pages panel to copy it, drags a page onto the New Page button to duplicate it ("click and drag onto my plus sign I can duplicate that"), and resizes pages; InDesign's panel also has an Edit page size button. DesignCraft's panel only moved pages on drag, had no Edit page size button, and duplicating a page gave the copy the size of the page before it (not its own) without its liquid rule or guides. A page of its own size also drew its thumbnail at the document's scale, over the panel's footer. - Pages panel: Alt-drag copies the page to where it's dropped; a page dropped on Create new page is duplicated at the end of the document; Edit page size lists the presets (in the page's orientation) and Custom Page Size…; InDesign's tooltips (Create new page, Delete selected pages). - Thumbnails share one scale (the document's page size, smaller when a taller page would pass 1.8 times the usual height); rows fit their spread's tallest page. - engine: layout.pages.duplicateSpread copies the pages' size, margins, columns, guides and liquid rule, takes `atEnd`, and returns the copy's first page. Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 1 + crates/engine/src/cmd/layout.rs | 53 ++++++++---- crates/engine/src/tests.rs | 26 ++++++ crates/ui-egui/src/i18n.rs | 16 ++++ crates/ui-egui/src/i18n/ar.rs | 4 + crates/ui-egui/src/i18n/it.rs | 4 + crates/ui-egui/src/i18n/ja.rs | 4 + crates/ui-egui/src/i18n/pt_br.rs | 4 + crates/ui-egui/src/i18n/uk.rs | 4 + crates/ui-egui/src/panels/pages.rs | 106 +++++++++++++++++++++++- crates/ui-egui/src/panels/properties.rs | 4 +- 11 files changed, 205 insertions(+), 21 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 8a0f82071..cf4d92cff 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -129,6 +129,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m Newest first. +- **2026-10-14** · Pages panel: Alt-drag copies a page, a page dropped on Create new page is duplicated at the end, Edit page size offers the presets and Custom Page Size…; duplicated pages keep their own size, rule and guides; thumbnails of pages of their own size stay in their rows. No readiness percentage change. - **2026-10-12** · macOS Dock Quit uses the same unsaved-document prompts as window and menu Quit. Repeated requests keep the current answers; Cancel retains every tab and unsaved recovery checkpoint, and completed Save/Don't Save answers exit through the ordinary event loop. No readiness percentage change. - **2026-10-12** · Orderly native exit writes the latest recovery state after finalizing current quit approvals; incomplete or stale plans keep their documents, and Undo to the saved state clears stale recovery. Unavailable or failed recovery is reported. Cancelable macOS Dock Quit remains open (#544). No readiness percentage change. - **2026-10-12** · Recovered documents keep a checkpoint until saved, closed or reverted, including a second crash before autosave. Restarted document IDs and explicit recovery folders preserve other snapshots; failed republishing retains the original. No readiness percentage change. diff --git a/crates/engine/src/cmd/layout.rs b/crates/engine/src/cmd/layout.rs index bd5320799..663e10791 100644 --- a/crates/engine/src/cmd/layout.rs +++ b/crates/engine/src/cmd/layout.rs @@ -135,21 +135,44 @@ pub fn specs() -> Vec { ok() }) }), - cmd!("layout.pages.duplicateSpread", "Duplicate Spread", ["Layout", "Pages"], None, "{spread}", has_doc, |s, p| { - let si = p.get("spread").and_then(Value::as_u64).unwrap_or(0) as usize; - s.edit(|d, _| { - let sp = d.spreads.get(si).cloned().ok_or_else(|| bad("layout.pages.duplicateSpread", "no such spread"))?; - let after = d.first_page_of_spread(si) + sp.pages.len() - 1; - let parent = sp.pages.first().and_then(|p| p.parent); - d.insert_pages(Some(after), sp.pages.len(), parent)?; - // Copy items onto the new spread(s) at the same positions. - let src = d.clone(); - let ids: Vec<_> = sp.items.iter().map(|i| i.id).collect(); - let (nsi, _) = d.page_loc(after + 1).ok_or_else(|| bad("layout.pages.duplicateSpread", "insert failed"))?; - super::object::duplicate_from(d, &src, &ids, designcraft_doc::SpreadRef::Doc(nsi), designcraft_geom::Vec2::ZERO)?; - ok() - }) - }), + cmd!( + "layout.pages.duplicateSpread", + "Duplicate Spread", + ["Layout", "Pages"], + None, + "{spread, atEnd?: bool (Duplicate Spread at End of Document; the Pages panel's New Page button takes a dropped page there)} → {page: the copy's first page (0-based)} — the spread's pages (size, margins, columns, guides, liquid rule) and items", + has_doc, + |s, p| { + let si = p.get("spread").and_then(Value::as_u64).unwrap_or(0) as usize; + let at_end = p.get("atEnd").and_then(Value::as_bool).unwrap_or(false); + s.edit(|d, _| { + let sp = d.spreads.get(si).cloned().ok_or_else(|| bad("layout.pages.duplicateSpread", "no such spread"))?; + let after = if at_end { d.page_count() - 1 } else { d.first_page_of_spread(si) + sp.pages.len() - 1 }; + let parent = sp.pages.first().and_then(|p| p.parent); + d.insert_pages(Some(after), sp.pages.len(), parent)?; + // The copies are the pages they copy: size, margins, columns, guides, liquid rule. + for (k, src) in sp.pages.iter().enumerate() { + let Some((nsi, npi)) = d.page_loc(after + 1 + k) else { continue }; + let nsp = std::sync::Arc::make_mut(&mut d.spreads[nsi]); + let pg = &mut nsp.pages[npi]; + let dx = pg.x - src.x; + (pg.width, pg.height, pg.margins, pg.columns, pg.liquid) = + (src.width, src.height, src.margins, src.columns.clone(), src.liquid); + pg.guides = src.guides.clone(); + for g in pg.guides.iter_mut().filter(|g| g.orientation == designcraft_doc::Orientation::Vertical) { + g.position += dx; + } + nsp.relayout(); + } + // Copy items onto the new spread(s) at the same positions. + let src = d.clone(); + let ids: Vec<_> = sp.items.iter().map(|i| i.id).collect(); + let (nsi, _) = d.page_loc(after + 1).ok_or_else(|| bad("layout.pages.duplicateSpread", "insert failed"))?; + super::object::duplicate_from(d, &src, &ids, designcraft_doc::SpreadRef::Doc(nsi), designcraft_geom::Vec2::ZERO)?; + Ok(json!({"page": after + 1})) + }) + } + ), cmd!( "layout.pages.applyParent", "Apply Parent to Pages…", diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 17d1ed098..f896ab3cd 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -569,3 +569,29 @@ fn commands_take_non_object_params_without_panicking() { assert!(r.is_ok(), "{p}: {r:?}"); } } + +#[test] +fn duplicating_a_page_keeps_its_own_size_rule_guides_and_objects() { + // Chris Cicchino's Page Tool video (2:06): the letterhead page is duplicated, then resized. + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 252, "height": 144, "facingPages": false})).unwrap(); + s.execute("layout.pages.insert", &json!({"count": 1})).unwrap(); + s.execute("layout.pageSize", &json!({"pages": [2], "width": 612, "height": 792})).unwrap(); + s.execute("liquid.pageRule", &json!({"rule": "scale", "pages": [2]})).unwrap(); + s.execute("guide.add", &json!({"spread": 1, "page": 0, "orientation": "vertical", "position": 300})).unwrap(); + s.execute("frame.create", &json!({"spread": 1, "rect": [100, 100, 200, 200]})).unwrap(); + let r = s.execute("layout.pages.duplicateSpread", &json!({"spread": 1})).unwrap(); + assert_eq!(r["page"], 2); + let d = s.doc().unwrap().doc.clone(); + let (si, pi) = d.page_loc(2).unwrap(); + let pg = &d.spreads[si].pages[pi]; + assert_eq!((pg.width, pg.height, pg.liquid), (612.0, 792.0, designcraft_doc::LiquidRule::Scale)); + assert_eq!(pg.guides.len(), 1); + assert_eq!(d.spreads[si].items.len(), 1); + // At the end of the document (the New Page button): the business card, not the last page's size. + let r = s.execute("layout.pages.duplicateSpread", &json!({"spread": 0, "atEnd": true})).unwrap(); + assert_eq!(r["page"], 3); + let d = s.doc().unwrap().doc.clone(); + let (si, pi) = d.page_loc(3).unwrap(); + assert_eq!((d.spreads[si].pages[pi].width, d.spreads[si].pages[pi].height), (252.0, 144.0)); +} diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index 5252c3881..93728dde5 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -612,6 +612,22 @@ const TABLE: &[(&str, [&str; 5])] = &[ ("Edit Page", ["Edit Page", "Edit Page", "Edit Page", "Edit Page", "编辑页面"]), ("Import File", ["Import File", "Import File", "Import File", "Import File", "导入文件"]), ("Insert Page", ["Insert Page", "Insert Page", "Insert Page", "Insert Page", "插入页面"]), + ( + "Delete selected pages", + ["Ausgewählte Seiten löschen", "Supprimer les pages sélectionnées", "Eliminar páginas seleccionadas", "選択したページを削除", "删除选定页面"], + ), + ("Create new page", ["Neue Seite erstellen", "Créer une page", "Crear página nueva", "新規ページを作成", "新建页面"]), + ("Edit page size", ["Seitenformat bearbeiten", "Modifier le format de page", "Editar tamaño de página", "ページサイズを編集", "编辑页面大小"]), + ( + "Custom Page Size…", + [ + "Benutzerdefiniertes Seitenformat …", + "Format de page personnalisé…", + "Tamaño de página personalizado…", + "カスタムページサイズ…", + "自定页面大小…", + ], + ), ("Hide Bar", ["Hide Bar", "Hide Bar", "Hide Bar", "Hide Bar", "隐藏栏"]), ("Properties Panel", ["Properties Panel", "Properties Panel", "Properties Panel", "Properties Panel", "属性面板"]), ("New Workspace…", ["New Workspace…", "New Workspace…", "New Workspace…", "New Workspace…", "新建工作区…"]), diff --git a/crates/ui-egui/src/i18n/ar.rs b/crates/ui-egui/src/i18n/ar.rs index a182d09ec..4373051b8 100644 --- a/crates/ui-egui/src/i18n/ar.rs +++ b/crates/ui-egui/src/i18n/ar.rs @@ -270,6 +270,10 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Quick Actions", "الإجراءات السريعة"), ("Import File", "استيراد ملف"), ("Insert Page", "إدراج صفحة"), + ("Delete selected pages", "حذف الصفحات المحددة"), + ("Create new page", "إنشاء صفحة جديدة"), + ("Edit page size", "تحرير حجم الصفحة"), + ("Custom Page Size…", "حجم صفحة مخصص…"), ("Edit Page", "تحرير الصفحة"), ("Adjust Layout", "ضبط التخطيط"), ("Smart Guides", "أدلة ذكية"), diff --git a/crates/ui-egui/src/i18n/it.rs b/crates/ui-egui/src/i18n/it.rs index 75ece03f6..f86e1b196 100644 --- a/crates/ui-egui/src/i18n/it.rs +++ b/crates/ui-egui/src/i18n/it.rs @@ -363,6 +363,10 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Edit Page", "Modifica pagina"), ("Import File", "Importa file"), ("Insert Page", "Inserisci pagina"), + ("Delete selected pages", "Elimina pagine selezionate"), + ("Create new page", "Crea nuova pagina"), + ("Edit page size", "Modifica dimensioni pagina"), + ("Custom Page Size…", "Dimensioni pagina personalizzate…"), ("Hide Bar", "Nascondi barra"), ("Properties Panel", "Pannello Proprietà"), ("New Workspace…", "Nuova area di lavoro…"), diff --git a/crates/ui-egui/src/i18n/ja.rs b/crates/ui-egui/src/i18n/ja.rs index 642b4a4c0..53833dd01 100644 --- a/crates/ui-egui/src/i18n/ja.rs +++ b/crates/ui-egui/src/i18n/ja.rs @@ -365,6 +365,10 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Edit Page", "ページを編集"), ("Import File", "ファイルを読み込み"), ("Insert Page", "ページを挿入"), + ("Delete selected pages", "選択したページを削除"), + ("Create new page", "新規ページを作成"), + ("Edit page size", "ページサイズを編集"), + ("Custom Page Size…", "カスタムページサイズ…"), ("Hide Bar", "バーを隠す"), ("Properties Panel", "プロパティパネル"), ("New Workspace…", "新規ワークスペース…"), diff --git a/crates/ui-egui/src/i18n/pt_br.rs b/crates/ui-egui/src/i18n/pt_br.rs index 982dfb20d..25229c806 100644 --- a/crates/ui-egui/src/i18n/pt_br.rs +++ b/crates/ui-egui/src/i18n/pt_br.rs @@ -271,6 +271,10 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Quick Actions", "Ações rápidas"), ("Import File", "Importar arquivo"), ("Insert Page", "Inserir página"), + ("Delete selected pages", "Excluir páginas selecionadas"), + ("Create new page", "Criar nova página"), + ("Edit page size", "Editar tamanho da página"), + ("Custom Page Size…", "Tamanho de página personalizado…"), ("Edit Page", "Editar página"), ("Adjust Layout", "Ajustar layout"), ("Smart Guides", "Guias inteligentes"), diff --git a/crates/ui-egui/src/i18n/uk.rs b/crates/ui-egui/src/i18n/uk.rs index f3bc8fb35..7adad1644 100644 --- a/crates/ui-egui/src/i18n/uk.rs +++ b/crates/ui-egui/src/i18n/uk.rs @@ -366,6 +366,10 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Edit Page", "Редагувати сторінку"), ("Import File", "Імпортувати файл"), ("Insert Page", "Вставити сторінку"), + ("Delete selected pages", "Видалити вибрані сторінки"), + ("Create new page", "Створити нову сторінку"), + ("Edit page size", "Змінити розмір сторінки"), + ("Custom Page Size…", "Власний розмір сторінки…"), ("Hide Bar", "Сховати панель"), ("Properties Panel", "Панель властивостей"), ("New Workspace…", "Нове робоче середовище…"), diff --git a/crates/ui-egui/src/panels/pages.rs b/crates/ui-egui/src/panels/pages.rs index ae323225b..d70b3427e 100644 --- a/crates/ui-egui/src/panels/pages.rs +++ b/crates/ui-egui/src/panels/pages.rs @@ -114,11 +114,16 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) { let cur_spread = doc.page_loc(cur).map(|l| l.0); let center = ui.available_width() / 2.0; let dragging = egui::DragAndDrop::has_any_payload(ui.ctx()); + let tallest = doc.spreads.iter().flat_map(|sp| sp.pages.iter().map(|p| p.height)).fold(doc.settings.page_height, f64::max).max(1.0); for (si, sp) in doc.spreads.iter().enumerate() { let first = doc.first_page_of_spread(si); - let scale = THUMB_H / doc.settings.page_height.max(1.0) as f32; + // One scale for the whole document (pages of their own size show in proportion): the + // document's page size at the usual height, unless a taller page would pass 1.8 times it. + // Each row is as tall as its spread's tallest page. + let scale = (THUMB_H / doc.settings.page_height.max(1.0) as f32).min(1.8 * THUMB_H / tallest as f32); + let row_h = sp.pages.iter().map(|p| p.height as f32 * scale).fold(0.0, f32::max).max(THUMB_H * 0.25); let spine = sp.spine_x() as f32; - let (row, _) = ui.allocate_exact_size(vec2(ui.available_width(), 8.0 + THUMB_H + 22.0), Sense::hover()); + let (row, _) = ui.allocate_exact_size(vec2(ui.available_width(), 8.0 + row_h + 22.0), Sense::hover()); let top = row.min.y + 8.0; // Marker above the current spread. if cur_spread == Some(si) && !editing_parents { @@ -197,15 +202,55 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) { .color(t.text_dim), ); ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - if icons::button(ui, "trash", 20.0, false, crate::i18n::tr(&app.ui.language, "Delete Page")).clicked() { + if icons::button(ui, "trash", 20.0, false, crate::i18n::tr(&app.ui.language, "Delete selected pages")).clicked() { let _ = app.run("layout.pages.delete", json!({"pages": [cur]})); } - if icons::button(ui, "plus", 20.0, false, crate::i18n::tr(&app.ui.language, "Insert Page")).clicked() { + // Create new page; a page dropped on it is duplicated at the end of the document. + let plus = icons::button(ui, "plus", 20.0, dragging, crate::i18n::tr(&app.ui.language, "Create new page")); + if let Some(payload) = plus.dnd_release_payload::() { + if let PageDrag::Page(from) = *payload + && let Some((si, _)) = doc.page_loc(from) + { + let _ = app.run("layout.pages.duplicateSpread", json!({"spread": si, "atEnd": true})); + } + } else if plus.clicked() { let _ = app.run("layout.pages.insert", json!({"after": cur, "count": 1})); } + // Edit page size: InDesign's presets for the current page, or Custom Page Size…. + let size = icons::button(ui, "panel-pages", 20.0, false, crate::i18n::tr(&app.ui.language, "Edit page size")); + egui::Popup::menu(&size).show(|ui| { + let Some((si, pi)) = doc.page_loc(cur) else { return }; + let pg = &doc.spreads[si].pages[pi]; + let landscape = pg.width > pg.height; + let current = crate::panels::properties::preset_name(pg.width, pg.height); + for (name, w, h) in crate::panels::properties::PAGE_PRESETS { + let (w, h) = if landscape { (h, w) } else { (w, h) }; + if ui.selectable_label(current == *name, crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, name))).clicked() { + let _ = app.run("layout.pageSize", json!({"pages": [cur + 1], "width": w, "height": h})); + ui.close(); + } + } + ui.separator(); + if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Custom Page Size…"))).clicked() { + app.ui.dialog = + Some(crate::dialogs::Dialog::new("cmd:layout.pageSize", json!({"pages": [cur + 1], "width": pg.width, "height": pg.height}))); + ui.close(); + } + }); }); }); + // Alt-drag (InDesign's Option-drag) copies the page to where it's dropped. + let copy = ui.input(|i| i.modifiers.alt); match drop { + Some((PageDrag::Page(from), to)) if copy => { + if let Some((si, _)) = doc.page_loc(from) + && let Ok(r) = app.run("layout.pages.duplicateSpread", json!({"spread": si})) + && let Some(new) = r["page"].as_u64().map(|p| p as usize) + && new != to + { + let _ = app.run("layout.pages.move", json!({"from": new, "to": to})); + } + } Some((PageDrag::Page(from), to)) if from != to => { let _ = app.run("layout.pages.move", json!({"from": from, "to": to})); } @@ -289,3 +334,56 @@ fn drag_ghost(ui: &egui::Ui, label: &str, t: &Tokens) { painter.rect(r, 2.0, t.panel_darker, Stroke::new(1.0, t.field_border), StrokeKind::Inside); painter.galley(r.min + vec2(5.0, 3.0), g, t.text_strong); } + +#[cfg(test)] +mod tests { + use egui_kittest::{Harness, kittest::Queryable}; + use serde_json::json; + + use super::*; + + fn harness(app: DesignApp) -> Harness<'static, DesignApp> { + let mut h = Harness::builder().with_size(vec2(280.0, 520.0)).build_ui_state( + |ui, app: &mut DesignApp| { + let key = egui::Id::new("test_fonts"); + if ui.data(|d| d.get_temp::(key)).is_none() { + crate::theme::install_fonts(ui.ctx(), ""); + ui.data_mut(|d| d.insert_temp(key, true)); + return; + } + show(app, ui); + }, + app, + ); + h.run_steps(3); + h + } + + /// Release a dragged page over `at`. + fn drop_page(h: &mut Harness<'_, DesignApp>, page: usize, at: egui::Pos2, alt: bool) { + let modifiers = egui::Modifiers { alt, ..Default::default() }; + h.event(egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed: true, modifiers }); + egui::DragAndDrop::set_payload(&h.ctx, PageDrag::Page(page)); + h.event(egui::Event::PointerMoved(at)); + h.run_steps(2); + h.event(egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed: false, modifiers }); + h.run_steps(3); + } + + #[test] + fn a_page_dropped_on_create_new_page_is_duplicated_at_the_end() { + // Chris Cicchino's Page Tool video (2:06): "click and drag onto my plus sign I can duplicate that". + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 252, "height": 144, "facingPages": false})).unwrap(); + app.run("layout.pages.insert", json!({"count": 1})).unwrap(); + app.run("layout.pageSize", json!({"pages": [2], "width": 612, "height": 792})).unwrap(); + let mut h = harness(app); + let plus = h.get_by_label("Create new page").rect().center(); + drop_page(&mut h, 1, plus, false); + let d = h.state().session.doc().unwrap().doc.clone(); + assert_eq!(d.page_count(), 3); + let (si, pi) = d.page_loc(2).unwrap(); + assert_eq!(d.spreads[si].pages[pi].width, 612.0, "a copy of the letterhead"); + let _ = h.get_by_label("Edit page size"); + } +} diff --git a/crates/ui-egui/src/panels/properties.rs b/crates/ui-egui/src/panels/properties.rs index d9c74e40d..89e12890c 100644 --- a/crates/ui-egui/src/panels/properties.rs +++ b/crates/ui-egui/src/panels/properties.rs @@ -160,7 +160,7 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) { // ---------------------------------------------------------------- No Selection /// Page-size presets for the Document dropdown (portrait, points). -const PAGE_PRESETS: &[(&str, f64, f64)] = &[ +pub(crate) const PAGE_PRESETS: &[(&str, f64, f64)] = &[ ("Letter", 612.0, 792.0), ("Legal", 612.0, 1008.0), ("Tabloid", 792.0, 1224.0), @@ -173,7 +173,7 @@ const PAGE_PRESETS: &[(&str, f64, f64)] = &[ ("B5", 498.898, 708.661), ]; -fn preset_name(w: f64, h: f64) -> &'static str { +pub(crate) fn preset_name(w: f64, h: f64) -> &'static str { let (a, b) = (w.min(h), w.max(h)); PAGE_PRESETS.iter().find(|p| (p.1 - a).abs() < 0.6 && (p.2 - b).abs() < 0.6).map(|p| p.0).unwrap_or("Custom") } From 8156e575bc1b403e6c62054fca132e4a35ff927e Mon Sep 17 00:00:00 2001 From: moppeeer Date: Wed, 14 Oct 2026 02:15:27 +0200 Subject: [PATCH 2/4] feat(ui): Pages panel menu in InDesign's order MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Pages panel menu in Chris Cicchino's "Adobe InDesign – Page Tool and Liquid Layout" (0:47) reads: Insert Pages…, Move Pages…, Duplicate Spread, Duplicate Spread at End of Document, Delete Spread, … New Parent…, Apply Parent to Pages…, Override All Parent Page Items, Parent Pages ▸, Create Alternate Layout…, Numbering & Section Options…, Allow Selected Spread to Shuffle. DesignCraft's menu (and the page thumbnails' context menu) had Insert Page After, Delete Page, Duplicate Spread, Add to Previous Spread, Allow Spread to Shuffle and the parent items in another order. - Pages panel menu and page context menu in InDesign's order and labels: Insert Pages… and Move Pages… open their dialogs for the page; Delete Spread for a spread of several pages; Parent Pages ▸ holds Remove All Local Overrides and Detach All Objects from Parent; Create Alternate Layout… and Numbering & Section Options… open theirs. Add to Previous Spread stays last (InDesign drags pages into spreads instead). - Translations (pt-BR gains the existing items it lacked). Builds on #869 (Duplicate Spread at End of Document). Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 1 + crates/ui-egui/src/i18n.rs | 22 ++++ crates/ui-egui/src/i18n/ar.rs | 4 + crates/ui-egui/src/i18n/it.rs | 4 + crates/ui-egui/src/i18n/ja.rs | 3 + crates/ui-egui/src/i18n/pt_br.rs | 12 +++ crates/ui-egui/src/i18n/uk.rs | 4 + crates/ui-egui/src/panel_docking_tests.rs | 2 +- crates/ui-egui/src/panels/pages.rs | 119 +++++++++++++++++----- 9 files changed, 142 insertions(+), 29 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index cf4d92cff..c091b6658 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -129,6 +129,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m Newest first. +- **2026-10-14** · The Pages panel menu follows InDesign's order: Insert Pages…, Move Pages…, Duplicate Spread (and at End of Document), Delete Page/Spread, New Parent…, Apply Parent to Pages, Override All Parent Page Items, Parent Pages, Create Alternate Layout…, Numbering & Section Options…, Allow Selected Spread to Shuffle. No readiness percentage change. - **2026-10-14** · Pages panel: Alt-drag copies a page, a page dropped on Create new page is duplicated at the end, Edit page size offers the presets and Custom Page Size…; duplicated pages keep their own size, rule and guides; thumbnails of pages of their own size stay in their rows. No readiness percentage change. - **2026-10-12** · macOS Dock Quit uses the same unsaved-document prompts as window and menu Quit. Repeated requests keep the current answers; Cancel retains every tab and unsaved recovery checkpoint, and completed Save/Don't Save answers exit through the ordinary event loop. No readiness percentage change. - **2026-10-12** · Orderly native exit writes the latest recovery state after finalizing current quit approvals; incomplete or stale plans keep their documents, and Undo to the saved state clears stale recovery. Unavailable or failed recovery is reported. Cancelable macOS Dock Quit remains open (#544). No readiness percentage change. diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index 93728dde5..7ab2e23f3 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -296,6 +296,28 @@ const TABLE: &[(&str, [&str; 5])] = &[ ("Document Setup", ["Document Setup", "Document Setup", "Document Setup", "Document Setup", "文档设置"]), ("Drop Shadow", ["Drop Shadow", "Drop Shadow", "Drop Shadow", "Drop Shadow", "投影"]), ("Duplicate Spread", ["Duplicate Spread", "Duplicate Spread", "Duplicate Spread", "Duplicate Spread", "复制跨页"]), + ( + "Duplicate Spread at End of Document", + [ + "Druckbogen am Dokumentende duplizieren", + "Dupliquer la planche à la fin du document", + "Duplicar pliego al final del documento", + "ドキュメントの最後にスプレッドを複製", + "在文档末尾复制跨页", + ], + ), + ("Delete Spread", ["Druckbogen löschen", "Supprimer la planche", "Eliminar pliego", "スプレッドを削除", "删除跨页"]), + ("Parent Pages", ["Übergeordnete Seiten", "Pages parents", "Páginas principales", "親ページ", "父页面"]), + ( + "Apply Parent to Pages", + [ + "Übergeordnete Seite auf Seiten anwenden", + "Appliquer le parent aux pages", + "Aplicar principal a páginas", + "親ページを適用", + "将父页面应用于页面", + ], + ), ("Email", ["Email", "Email", "Email", "Email", "电子邮件"]), ("Embed", ["Embed", "Embed", "Embed", "Embed", "嵌入"]), ("Export PDF", ["PDF exportieren", "Exporter en PDF", "Exportar PDF", "PDF を書き出し", "导出PDF"]), diff --git a/crates/ui-egui/src/i18n/ar.rs b/crates/ui-egui/src/i18n/ar.rs index 4373051b8..75dd0fea0 100644 --- a/crates/ui-egui/src/i18n/ar.rs +++ b/crates/ui-egui/src/i18n/ar.rs @@ -864,6 +864,10 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Document Grid", "شبكة المستند"), ("Document Recovery Data", "بيانات استرداد المستند"), ("Duplicate Spread", "تكرار الصفحات المتقابلة"), + ("Duplicate Spread at End of Document", "تكرار الانتشار في نهاية المستند"), + ("Delete Spread", "حذف الانتشار"), + ("Parent Pages", "الصفحات الأصل"), + ("Apply Parent to Pages", "تطبيق الأصل على الصفحات"), ("Email", "بريد إلكتروني"), ("Embed", "تضمين"), ("Fast", "سريع"), diff --git a/crates/ui-egui/src/i18n/it.rs b/crates/ui-egui/src/i18n/it.rs index f86e1b196..735bd38c5 100644 --- a/crates/ui-egui/src/i18n/it.rs +++ b/crates/ui-egui/src/i18n/it.rs @@ -177,6 +177,10 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Document Setup", "Impostazione documento"), ("Drop Shadow", "Ombra esterna"), ("Duplicate Spread", "Duplica doppia pagina"), + ("Duplicate Spread at End of Document", "Duplica pagine affiancate alla fine del documento"), + ("Delete Spread", "Elimina pagine affiancate"), + ("Parent Pages", "Pagine principali"), + ("Apply Parent to Pages", "Applica pagina principale alle pagine"), ("Email", "E-mail"), ("Embed", "Incorpora"), ("Export XML…", "Esporta XML…"), diff --git a/crates/ui-egui/src/i18n/ja.rs b/crates/ui-egui/src/i18n/ja.rs index 53833dd01..87c0aea64 100644 --- a/crates/ui-egui/src/i18n/ja.rs +++ b/crates/ui-egui/src/i18n/ja.rs @@ -179,6 +179,9 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Document Setup", "ドキュメント設定"), ("Drop Shadow", "ドロップシャドウ"), ("Duplicate Spread", "スプレッドを複製"), + ("Duplicate Spread at End of Document", "ドキュメントの最後にスプレッドを複製"), + ("Delete Spread", "スプレッドを削除"), + ("Parent Pages", "親ページ"), ("Email", "メール"), ("Embed", "埋め込み"), ("Export XML…", "XML を書き出し…"), diff --git a/crates/ui-egui/src/i18n/pt_br.rs b/crates/ui-egui/src/i18n/pt_br.rs index 25229c806..d4456e5e2 100644 --- a/crates/ui-egui/src/i18n/pt_br.rs +++ b/crates/ui-egui/src/i18n/pt_br.rs @@ -866,6 +866,18 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Document Grid", "Grade do documento"), ("Document Recovery Data", "Dados de recuperação do documento"), ("Duplicate Spread", "Duplicar página dupla"), + ("Insert Pages…", "Inserir páginas…"), + ("Move Pages…", "Mover páginas…"), + ("New Parent…", "Nova página principal…"), + ("Override All Parent Page Items", "Substituir todos os itens da página principal"), + ("Remove All Local Overrides", "Remover todas as substituições locais"), + ("Detach All Objects from Parent", "Desanexar todos os objetos da página principal"), + ("Create Alternate Layout…", "Criar layout alternativo…"), + ("Allow Selected Spread to Shuffle", "Permitir que a página espelhada selecionada seja reorganizada"), + ("Duplicate Spread at End of Document", "Duplicar página espelhada no final do documento"), + ("Delete Spread", "Excluir página espelhada"), + ("Parent Pages", "Páginas principais"), + ("Apply Parent to Pages", "Aplicar página principal às páginas"), ("Email", "E-mail"), ("Embed", "Embutir"), ("Fast", "Rápido"), diff --git a/crates/ui-egui/src/i18n/uk.rs b/crates/ui-egui/src/i18n/uk.rs index 7adad1644..4cc572cd9 100644 --- a/crates/ui-egui/src/i18n/uk.rs +++ b/crates/ui-egui/src/i18n/uk.rs @@ -180,6 +180,10 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Document Setup", "Параметри документа"), ("Drop Shadow", "Тінь"), ("Duplicate Spread", "Дублювати розворот"), + ("Duplicate Spread at End of Document", "Дублювати розворот у кінці документа"), + ("Delete Spread", "Видалити розворот"), + ("Parent Pages", "Батьківські сторінки"), + ("Apply Parent to Pages", "Застосувати батьківську до сторінок"), ("Email", "Електронна пошта"), ("Embed", "Вбудувати"), ("Export XML…", "Експортувати XML…"), diff --git a/crates/ui-egui/src/panel_docking_tests.rs b/crates/ui-egui/src/panel_docking_tests.rs index de08e3cf5..56b906211 100644 --- a/crates/ui-egui/src/panel_docking_tests.rs +++ b/crates/ui-egui/src/panel_docking_tests.rs @@ -617,7 +617,7 @@ fn legacy_dock_menu_button_opens_the_active_panel_menu() { } h.run_steps(2); assert!(egui::Popup::is_any_open(&h.ctx), "the three-line button must open a menu"); - h.get_by_label("Insert Page After").click(); + h.get_by_label("Duplicate Spread").click(); h.run_steps(3); assert_eq!(h.state().session.active().unwrap().doc.page_count(), pages + 1); assert!(!egui::Popup::is_any_open(&h.ctx)); diff --git a/crates/ui-egui/src/panels/pages.rs b/crates/ui-egui/src/panels/pages.rs index d70b3427e..dfc69dfff 100644 --- a/crates/ui-egui/src/panels/pages.rs +++ b/crates/ui-egui/src/panels/pages.rs @@ -271,47 +271,39 @@ pub fn panel_menu(app: &mut DesignApp, ui: &mut egui::Ui) { } fn page_menu(app: &mut DesignApp, ui: &mut egui::Ui, doc: &designcraft_doc::Document, abs: usize, si: usize) { - if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Insert Page After"))).clicked() { - let _ = app.run("layout.pages.insert", json!({"after": abs, "count": 1})); + let lang = app.ui.language.clone(); + let tr = |s: &str| crate::i18n::tr(&lang, s).to_owned(); + let pages_in_spread = doc.spreads.get(si).map_or(1, |sp| sp.pages.len()); + let item = |ui: &mut egui::Ui, label: &str| ui.button(crate::rtl::widget(ui, tr(label))).clicked(); + // InDesign's order: pages, parents, layouts and numbering, shuffling. + if item(ui, "Insert Pages…") { + app.ui.dialog = Some(crate::dialogs::Dialog::new("cmd:layout.pages.insert", json!({"after": abs, "count": 1}))); ui.close(); } - if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Delete Page"))).clicked() { - let _ = app.run("layout.pages.delete", json!({"pages": [abs]})); + if item(ui, "Move Pages…") { + app.ui.dialog = Some(crate::dialogs::Dialog::new("cmd:layout.pages.move", json!({"from": abs, "to": abs}))); ui.close(); } - if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Duplicate Spread"))).clicked() { + if item(ui, "Duplicate Spread") { let _ = app.run("layout.pages.duplicateSpread", json!({"spread": si})); ui.close(); } - if si > 0 - && ui - .button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Add to Previous Spread"))) - .on_hover_ui(|ui| { - crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Spreads of up to 10 pages")); - }) - .clicked() - { - let _ = app.run("layout.pages.toSpread", json!({"page": abs + 1, "spread": si - 1})); + if item(ui, "Duplicate Spread at End of Document") { + let _ = app.run("layout.pages.duplicateSpread", json!({"spread": si, "atEnd": true})); ui.close(); } - let mut shuffle = doc.spreads.get(si).is_none_or(|sp| sp.allow_shuffle); - if ui.checkbox(&mut shuffle, crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Allow Spread to Shuffle"))).clicked() { - let _ = app.run("layout.spreadShuffle", json!({"spread": si, "allow": shuffle})); + if item(ui, if pages_in_spread > 1 { "Delete Spread" } else { "Delete Page" }) { + let first = doc.first_page_of_spread(si); + let _ = app.run("layout.pages.delete", json!({"pages": (first..first + pages_in_spread).collect::>()})); ui.close(); } ui.separator(); - for (id, label) in [ - ("layout.overrideParentItems", "Override All Parent Page Items"), - ("layout.removeOverrides", "Remove All Local Overrides"), - ("layout.detachAll", "Detach All Objects from Parent"), - ] { - if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, label))).clicked() { - let _ = app.run(id, json!({"page": abs})); - ui.close(); - } + if item(ui, "New Parent…") { + crate::menus::activate(app, "layout.parents.new", &serde_json::Value::Null); + ui.close(); } - crate::menus::menu_button(ui, crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Apply Parent")), |ui| { - if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "[None]"))).clicked() { + crate::menus::menu_button(ui, crate::rtl::widget(ui, tr("Apply Parent to Pages")), |ui| { + if ui.button(crate::rtl::widget(ui, tr("[None]"))).clicked() { let _ = app.run("layout.pages.applyParent", json!({"pages": [abs], "parent": null})); ui.close(); } @@ -323,6 +315,45 @@ fn page_menu(app: &mut DesignApp, ui: &mut egui::Ui, doc: &designcraft_doc::Docu } } }); + if item(ui, "Override All Parent Page Items") { + let _ = app.run("layout.overrideParentItems", json!({"page": abs})); + ui.close(); + } + crate::menus::menu_button(ui, crate::rtl::widget(ui, tr("Parent Pages")), |ui| { + for (id, label) in [("layout.removeOverrides", "Remove All Local Overrides"), ("layout.detachAll", "Detach All Objects from Parent")] { + if ui.button(crate::rtl::widget(ui, tr(label))).clicked() { + let _ = app.run(id, json!({"page": abs})); + ui.close(); + } + } + }); + ui.separator(); + if item(ui, "Create Alternate Layout…") { + crate::menus::activate(app, "layout.createAlternate", &serde_json::Value::Null); + ui.close(); + } + if item(ui, "Numbering & Section Options…") { + crate::menus::activate(app, "layout.section", &serde_json::Value::Null); + ui.close(); + } + ui.separator(); + let mut shuffle = doc.spreads.get(si).is_none_or(|sp| sp.allow_shuffle); + if ui.checkbox(&mut shuffle, crate::rtl::widget(ui, tr("Allow Selected Spread to Shuffle"))).clicked() { + let _ = app.run("layout.spreadShuffle", json!({"spread": si, "allow": shuffle})); + ui.close(); + } + // Not in InDesign's menu (it drags pages into spreads): kept for spreads of up to 10 pages. + if si > 0 + && ui + .button(crate::rtl::widget(ui, tr("Add to Previous Spread"))) + .on_hover_ui(|ui| { + crate::rtl::label(ui, tr("Spreads of up to 10 pages")); + }) + .clicked() + { + let _ = app.run("layout.pages.toSpread", json!({"page": abs + 1, "spread": si - 1})); + ui.close(); + } } /// A small label following the pointer while dragging. @@ -370,6 +401,38 @@ mod tests { h.run_steps(3); } + #[test] + fn the_panel_menu_follows_indesigns_order() { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"pages": 2, "facingPages": false})).unwrap(); + let mut h = Harness::builder().with_size(vec2(320.0, 600.0)).build_ui_state( + |ui, app: &mut DesignApp| { + let key = egui::Id::new("test_fonts"); + if ui.data(|d| d.get_temp::(key)).is_none() { + crate::theme::install_fonts(ui.ctx(), ""); + ui.data_mut(|d| d.insert_temp(key, true)); + return; + } + let doc = app.session.doc().unwrap().doc.clone(); + page_menu(app, ui, &doc, 0, 0); + }, + app, + ); + h.run_steps(3); + // InDesign's items, top to bottom. + let ys: Vec = ["Insert Pages…", "Move Pages…", "Duplicate Spread", "Duplicate Spread at End of Document", "Delete Page", "New Parent…"] + .iter() + .map(|l| h.get_by_label(l).rect().center().y) + .collect(); + assert!(ys.windows(2).all(|w| w[0] < w[1]), "{ys:?}"); + h.get_by_label("Duplicate Spread at End of Document").click(); + h.run_steps(3); + assert_eq!(h.state().session.doc().unwrap().doc.page_count(), 3); + h.get_by_label("Insert Pages…").click(); + h.run_steps(3); + assert!(h.state().ui.dialog.as_ref().is_some_and(|d| d.id == "cmd:layout.pages.insert")); + } + #[test] fn a_page_dropped_on_create_new_page_is_duplicated_at_the_end() { // Chris Cicchino's Page Tool video (2:06): "click and drag onto my plus sign I can duplicate that". From f4efb7cc5cd363454141d108f184784e526b8cd5 Mon Sep 17 00:00:00 2001 From: aiongg Date: Wed, 14 Oct 2026 01:38:39 +0000 Subject: [PATCH 3/4] Integration: #869 Alt-drag copies a page in one command and one undo step (layout.pages.duplicateSpread takes to) (cherry picked from commit 7889b25a379fdf27dd128bc49512bc3c0cbfcbfd) --- crates/engine/src/cmd/layout.rs | 23 +++++++++++++++++++++-- crates/engine/src/tests.rs | 28 ++++++++++++++++++++++++++++ crates/ui-egui/src/panels/pages.rs | 24 ++++++++++++------------ 3 files changed, 61 insertions(+), 14 deletions(-) diff --git a/crates/engine/src/cmd/layout.rs b/crates/engine/src/cmd/layout.rs index 663e10791..7a6b05054 100644 --- a/crates/engine/src/cmd/layout.rs +++ b/crates/engine/src/cmd/layout.rs @@ -140,13 +140,18 @@ pub fn specs() -> Vec { "Duplicate Spread", ["Layout", "Pages"], None, - "{spread, atEnd?: bool (Duplicate Spread at End of Document; the Pages panel's New Page button takes a dropped page there)} → {page: the copy's first page (0-based)} — the spread's pages (size, margins, columns, guides, liquid rule) and items", + "{spread, atEnd?: bool (Duplicate Spread at End of Document; the Pages panel's New Page button takes a dropped page there), to?: page index the copied pages move to, in the same undo step (the Pages panel's Alt-drag)} → {page: the copy's first page (0-based)} — the spread's pages (size, margins, columns, guides, liquid rule) and items", has_doc, |s, p| { let si = p.get("spread").and_then(Value::as_u64).unwrap_or(0) as usize; let at_end = p.get("atEnd").and_then(Value::as_bool).unwrap_or(false); + let to = p.get("to").and_then(Value::as_u64).map(|t| t as usize); s.edit(|d, _| { let sp = d.spreads.get(si).cloned().ok_or_else(|| bad("layout.pages.duplicateSpread", "no such spread"))?; + // The copies land at `to`..`to + n` of the document with them in it. + if to.is_some_and(|t| t > d.page_count()) { + return Err(bad("layout.pages.duplicateSpread", "no such page")); + } let after = if at_end { d.page_count() - 1 } else { d.first_page_of_spread(si) + sp.pages.len() - 1 }; let parent = sp.pages.first().and_then(|p| p.parent); d.insert_pages(Some(after), sp.pages.len(), parent)?; @@ -169,7 +174,21 @@ pub fn specs() -> Vec { let ids: Vec<_> = sp.items.iter().map(|i| i.id).collect(); let (nsi, _) = d.page_loc(after + 1).ok_or_else(|| bad("layout.pages.duplicateSpread", "insert failed"))?; super::object::duplicate_from(d, &src, &ids, designcraft_doc::SpreadRef::Doc(nsi), designcraft_geom::Vec2::ZERO)?; - Ok(json!({"page": after + 1})) + let first = after + 1; + let Some(to) = to else { return Ok(json!({"page": first})) }; + // Moving a page shifts the ones between; order the moves so each copy keeps + // its place among the others. + let n = sp.pages.len(); + if first < to { + for k in (0..n).rev() { + d.move_page(first + k, to + k)?; + } + } else { + for k in 0..n { + d.move_page(first + k, to + k)?; + } + } + Ok(json!({"page": to})) }) } ), diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 0e7a011f7..962a0f69d 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -695,3 +695,31 @@ fn type_tool_frames_snap_to_the_margins() { assert!((r.x0 - margin.x0).abs() < 1e-6 && (r.y0 - margin.y0).abs() < 1e-6, "press point {r:?} vs {margin:?}"); assert!((r.x1 - margin.x1).abs() < 1e-6, "right edge {} vs {}", r.x1, margin.x1); } + +#[test] +fn duplicating_a_page_to_an_index_is_one_undo_step() { + let mut s = Session::new(); + s.execute("file.new", &json!({"facingPages": false})).unwrap(); + s.execute("layout.pages.insert", &json!({"count": 2})).unwrap(); + for (page, width) in [(1, 300), (2, 400), (3, 500)] { + s.execute("layout.pageSize", &json!({"pages": [page], "width": width, "height": 600})).unwrap(); + } + let widths = |s: &Session| { + let d = &s.doc().unwrap().doc; + (0..d.page_count()).map(|i| d.page_loc(i).map(|(si, pi)| d.spreads[si].pages[pi].width)).collect::>>().unwrap() + }; + // Backwards: the last page copied to the front. + let r = s.execute("layout.pages.duplicateSpread", &json!({"spread": 2, "to": 0})).unwrap(); + assert_eq!(r["page"], 0); + assert_eq!(widths(&s), [500.0, 300.0, 400.0, 500.0]); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(widths(&s), [300.0, 400.0, 500.0]); + // Forwards: the first page copied to the end. + s.execute("layout.pages.duplicateSpread", &json!({"spread": 0, "to": 3})).unwrap(); + assert_eq!(widths(&s), [300.0, 400.0, 500.0, 300.0]); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(widths(&s), [300.0, 400.0, 500.0]); + // Past the end: refused, nothing changes. + assert!(s.execute("layout.pages.duplicateSpread", &json!({"spread": 0, "to": 4})).is_err()); + assert_eq!(widths(&s), [300.0, 400.0, 500.0]); +} diff --git a/crates/ui-egui/src/panels/pages.rs b/crates/ui-egui/src/panels/pages.rs index d70b3427e..a9e4c65cc 100644 --- a/crates/ui-egui/src/panels/pages.rs +++ b/crates/ui-egui/src/panels/pages.rs @@ -239,21 +239,21 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) { }); }); }); - // Alt-drag (InDesign's Option-drag) copies the page to where it's dropped. - let copy = ui.input(|i| i.modifiers.alt); + // Alt-drag copies a page that is a spread of its own to where it's dropped, in one undo + // step; a page from a larger spread moves as usual. + let alt = ui.input(|i| i.modifiers.alt); + let copy = |from: usize| { + let (si, _) = doc.page_loc(from)?; + (alt && doc.spreads.get(si).is_some_and(|s| s.pages.len() == 1)).then_some(si) + }; match drop { - Some((PageDrag::Page(from), to)) if copy => { - if let Some((si, _)) = doc.page_loc(from) - && let Ok(r) = app.run("layout.pages.duplicateSpread", json!({"spread": si})) - && let Some(new) = r["page"].as_u64().map(|p| p as usize) - && new != to - { - let _ = app.run("layout.pages.move", json!({"from": new, "to": to})); + Some((PageDrag::Page(from), to)) => { + if let Some(si) = copy(from) { + let _ = app.run("layout.pages.duplicateSpread", json!({"spread": si, "to": to})); + } else if from != to { + let _ = app.run("layout.pages.move", json!({"from": from, "to": to})); } } - Some((PageDrag::Page(from), to)) if from != to => { - let _ = app.run("layout.pages.move", json!({"from": from, "to": to})); - } Some((PageDrag::Parent(prefix), to)) => { let _ = app.run("layout.pages.applyParent", json!({"pages": [to], "parent": prefix})); } From 1758642a96da8d0db6cf00b6126511ab3224c189 Mon Sep 17 00:00:00 2001 From: aiongg Date: Wed, 14 Oct 2026 01:39:48 +0000 Subject: [PATCH 4/4] Integration: #870 the page menu always offers Delete Page for the clicked page, Delete Spread separately for multi-page spreads (cherry picked from commit 9912a386a84baaf91f426482d7b9d88d3cd9994a) --- crates/ui-egui/src/panels/pages.rs | 41 ++++++++++++++++++++++++------ 1 file changed, 33 insertions(+), 8 deletions(-) diff --git a/crates/ui-egui/src/panels/pages.rs b/crates/ui-egui/src/panels/pages.rs index d708bfdee..ad8a8ab86 100644 --- a/crates/ui-egui/src/panels/pages.rs +++ b/crates/ui-egui/src/panels/pages.rs @@ -292,9 +292,13 @@ fn page_menu(app: &mut DesignApp, ui: &mut egui::Ui, doc: &designcraft_doc::Docu let _ = app.run("layout.pages.duplicateSpread", json!({"spread": si, "atEnd": true})); ui.close(); } - if item(ui, if pages_in_spread > 1 { "Delete Spread" } else { "Delete Page" }) { + if item(ui, "Delete Page") { + let _ = app.run("layout.pages.delete", json!({"pages": [abs]})); + ui.close(); + } + if pages_in_spread > 1 && item(ui, "Delete Spread") { let first = doc.first_page_of_spread(si); - let _ = app.run("layout.pages.delete", json!({"pages": (first..first + pages_in_spread).collect::>()})); + let _ = app.run("layout.pages.delete", json!({"pages": (first..first.saturating_add(pages_in_spread)).collect::>()})); ui.close(); } ui.separator(); @@ -401,12 +405,10 @@ mod tests { h.run_steps(3); } - #[test] - fn the_panel_menu_follows_indesigns_order() { - let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); - app.run("file.new", json!({"pages": 2, "facingPages": false})).unwrap(); + /// The page menu for page `abs` of spread `si`. + fn menu_harness(app: DesignApp, abs: usize, si: usize) -> Harness<'static, DesignApp> { let mut h = Harness::builder().with_size(vec2(320.0, 600.0)).build_ui_state( - |ui, app: &mut DesignApp| { + move |ui, app: &mut DesignApp| { let key = egui::Id::new("test_fonts"); if ui.data(|d| d.get_temp::(key)).is_none() { crate::theme::install_fonts(ui.ctx(), ""); @@ -414,11 +416,34 @@ mod tests { return; } let doc = app.session.doc().unwrap().doc.clone(); - page_menu(app, ui, &doc, 0, 0); + page_menu(app, ui, &doc, abs, si); }, app, ); h.run_steps(3); + h + } + + #[test] + fn a_page_in_a_spread_offers_delete_page_and_delete_spread() { + 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, _) = doc.page_loc(2).unwrap(); + assert_eq!(doc.spreads[si].pages.len(), 2); + let mut h = menu_harness(app, 2, si); + let _ = h.get_by_label("Delete Spread"); + h.get_by_label("Delete Page").click(); + h.run_steps(3); + assert_eq!(h.state().session.doc().unwrap().doc.page_count(), 4, "only the clicked page"); + } + + #[test] + fn the_panel_menu_follows_indesigns_order() { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"pages": 2, "facingPages": false})).unwrap(); + let mut h = menu_harness(app, 0, 0); + assert!(h.query_by_label("Delete Spread").is_none(), "a single-page spread has no Delete Spread"); // InDesign's items, top to bottom. let ys: Vec = ["Insert Pages…", "Move Pages…", "Duplicate Spread", "Duplicate Spread at End of Document", "Delete Page", "New Parent…"] .iter()