feat(ui): Pages panel duplicates pages by Alt-drag and the New Page button; Edit page size

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 <[email protected]>
This commit is contained in:
moppeeerandClaude Opus 5.5 committed 2026-10-14 02:02:16 +02:00
1 parent e862e0a613
commit 3939b825d3
11 files changed
+205 -21

No files matched your search

+1
View File
@@ -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.
+38 -15
View File
@@ -135,21 +135,44 @@ 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)} → {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…",
+26
View File
@@ -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));
}
+16
View File
@@ -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…", "新建工作区…"]),
+4
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", "أدلة ذكية"),
+4
View File
@@ -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…"),
+4
View File
@@ -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…", "新規ワークスペース…"),
+4
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"),
+4
View File
@@ -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…", "Нове робоче середовище…"),
+102 -4
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,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::<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 (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::<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);
}
#[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");
}
}
+2 -2
View File
@@ -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")
}