diff --git a/ROADMAP.md b/ROADMAP.md index 667827691..0dbb60372 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -141,6 +141,8 @@ Newest first. - **2026-10-14** · With the Page tool, the Control panel shows the page as InDesign's does: W/H from a reference point, page-size preset (US Business Card added), orientation, Liquid Page Rule and Show Parent Page Overlay, which draws the parent page's size over a resized page. No readiness percentage change. - **2026-10-14** · The Page tool works as InDesign's: a click selects a page and shows its handles and liquid page rule; dragging a handle previews the rule and springs back, Alt-drag resizes the page from the opposite edge, Shift keeps proportions, with a W/H readout. No readiness percentage change. - **2026-10-14** · With the Page tool, a guide dragged from a ruler is a liquid guide (dashed), as in InDesign; guide-based pages stretch the objects it crosses. No readiness percentage change. +- **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** · Text threads by hand: click a selected text frame's in or out port to load the text cursor, then click a frame to thread it, click empty space for a column-wide frame or drag one (it snaps like frame drawing); Shift-click autoflows, Alt-click stays loaded on the diff --git a/crates/engine/src/cmd/layout.rs b/crates/engine/src/cmd/layout.rs index 1650d389c..6d6e06ee1 100644 --- a/crates/engine/src/cmd/layout.rs +++ b/crates/engine/src/cmd/layout.rs @@ -135,21 +135,63 @@ 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), 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)?; + // 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)?; + 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})) + }) + } + ), cmd!( "layout.pages.applyParent", "Apply Parent to Pages…", diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index e74485df3..712dc06bc 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -724,6 +724,32 @@ fn page_tool_pins_and_locks_objects_on_object_based_pages() { assert!(s.overlays(v).iter().any(|o| matches!(o, designcraft_tools::Overlay::Tip { text, .. } if text.starts_with("Object is pinned")))); } +#[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)); +} + /// Load the text from a frame's out port with the Selection tool and click on the pasteboard: /// a new frame is threaded there and the overset text flows into it. #[test] @@ -823,3 +849,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/i18n.rs b/crates/ui-egui/src/i18n.rs index 22cbffe1e..04ee153c0 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"]), @@ -612,6 +634,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 258da1f63..f68e7587e 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", "أدلة ذكية"), @@ -899,6 +903,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 d8932bebd..e91307f18 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…"), @@ -363,6 +367,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 a60a51097..f45ee094b 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 を書き出し…"), @@ -365,6 +368,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 f9cc0c852..09e639e5c 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"), @@ -893,6 +897,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 6e0101af8..eab4159d0 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…"), @@ -366,6 +370,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/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 ae323225b..ad8a8ab86 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,17 +202,57 @@ 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 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 from != to => { - let _ = app.run("layout.pages.move", json!({"from": from, "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::Parent(prefix), to)) => { let _ = app.run("layout.pages.applyParent", json!({"pages": [to], "parent": prefix})); @@ -226,47 +271,43 @@ 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, "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.saturating_add(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(); } @@ -278,6 +319,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. @@ -289,3 +369,109 @@ 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); + } + + /// 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( + 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(), ""); + ui.data_mut(|d| d.insert_temp(key, true)); + return; + } + let doc = app.session.doc().unwrap().doc.clone(); + 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() + .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". + 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"); + } +}