Merge PR #870 (with #869)

This commit is contained in:
Brandon Thomas committed 2026-10-14 12:40:07 -04:00
commit 4f193834bd
11 files changed
+419 -50

No files matched your search

+2
View File
@@ -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
+57 -15
View File
@@ -135,21 +135,63 @@ pub fn specs() -> Vec<CommandSpec> {
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…",
+54
View File
@@ -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::<Option<Vec<_>>>().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]);
}
+38
View File
@@ -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…", "新建工作区…"]),
+8
View File
@@ -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", "سريع"),
+8
View File
@@ -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…"),
+7
View File
@@ -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…", "新規ワークスペース…"),
+16
View File
@@ -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"),
+8
View File
@@ -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…", "Нове робоче середовище…"),
+1 -1
View File
@@ -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));
+220 -34
View File
@@ -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::<PageDrag>() {
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::<Vec<_>>()}));
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::<bool>(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::<bool>(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<f32> = ["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");
}
}