mirror of
https://github.com/storytold/designcraft.git
synced 2026-10-14 19:27:07 -04:00
feat(ui): Liquid Layout panel in InDesign's layout; Create Alternate Layout in the Layout menu
Chris Cicchino's "Adobe InDesign – Page Tool and Liquid Layout" (2:47,
7:24) uses the Liquid Layout panel: "Liquid Page Rule:" with "Page: n",
Content › Auto-Fit, and Object Constraints › Resize with Page (Height,
Width) and Pin (Top, Left, Bottom, Right), greyed until an object on an
object-based page is selected. DesignCraft's panel had a "Page 1" label,
the rule, six wrapped checkboxes ("Resize width", "Pin top"…) only once
an object was selected, and a Create Alternate Layout form.
- Liquid Layout panel: InDesign's layout and labels; the page is the one
the Page tool selected (else the page in view); Auto-Fit (graphic
frames) runs object.fittingOptions; the constraints apply on
object-based pages (also through Controlled by Parent) and stay
greyed otherwise. The page's liquid guides stay listed below for now.
- Layout menu: Create Alternate Layout… (as in InDesign).
- Translations for the new labels.
Builds on #864 (rule list, Controlled by Parent) and #862.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
8864307d7c
commit
9da44a042f
9 files changed
+160
-45
No files matched your search
@@ -129,6 +129,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m
|
||||
|
||||
Newest first.
|
||||
|
||||
- **2026-10-14** · The Liquid Layout panel follows InDesign's: Liquid Page Rule with the page number, Content › Auto-Fit, and Object Constraints › Resize with Page and Pin for the selected object on an object-based page; Create Alternate Layout moved to the Layout menu. No readiness percentage change.
|
||||
- **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 (now with Controlled by Parent) 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-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.
|
||||
|
||||
@@ -2498,6 +2498,14 @@ const TABLE: &[(&str, [&str; 5])] = &[
|
||||
("Added", ["Added", "Added", "Added", "Added", "已添加"]),
|
||||
("Deleted", ["Deleted", "Deleted", "Deleted", "Deleted", "已删除"]),
|
||||
("Guide-based", ["Guide-based", "Guide-based", "Guide-based", "Guide-based", "基于参考线"]),
|
||||
("Page:", ["Seite:", "Page :", "Página:", "ページ:", "页面:"]),
|
||||
("Content:", ["Inhalt:", "Contenu :", "Contenido:", "コンテンツ:", "内容:"]),
|
||||
("Object Constraints:", ["Objektbeschränkungen:", "Contraintes d’objet :", "Restricciones de objeto:", "オブジェクトの制約:", "对象约束:"]),
|
||||
(
|
||||
"Resize with Page:",
|
||||
["Mit Seite skalieren:", "Redimensionner avec la page :", "Cambiar tamaño con la página:", "ページと共にサイズ変更:", "随页面调整大小:"],
|
||||
),
|
||||
("Pin:", ["Fixieren:", "Épingler :", "Fijar:", "固定:", "固定:"]),
|
||||
("Object-Based", ["Object-Based", "Object-Based", "Object-Based", "Object-Based", "基于对象"]),
|
||||
("Guide-Based", ["Guide-Based", "Guide-Based", "Guide-Based", "Guide-Based", "基于参考线"]),
|
||||
("Resize Page", ["Seitengröße ändern", "Redimensionner la page", "Cambiar tamaño de página", "ページのサイズを変更", "调整页面大小"]),
|
||||
|
||||
@@ -709,6 +709,11 @@ pub(super) const TABLE: &[(&str, &str)] = &[
|
||||
("Added", "مضاف"),
|
||||
("Deleted", "محذوف"),
|
||||
("Guide-based", "استنادًا إلى الأدلة"),
|
||||
("Page:", "الصفحة:"),
|
||||
("Content:", "المحتوى:"),
|
||||
("Object Constraints:", "قيود الكائن:"),
|
||||
("Resize with Page:", "تغيير الحجم مع الصفحة:"),
|
||||
("Pin:", "تثبيت:"),
|
||||
("Object-Based", "استنادًا إلى الكائنات"),
|
||||
("Guide-Based", "استنادًا إلى الأدلة"),
|
||||
("Resize Page", "تغيير حجم الصفحة"),
|
||||
|
||||
@@ -1295,6 +1295,11 @@ pub(super) const TABLE: &[(&str, &str)] = &[
|
||||
("Added", "Aggiunto"),
|
||||
("Deleted", "Eliminato"),
|
||||
("Guide-based", "Basato su guide"),
|
||||
("Page:", "Pagina:"),
|
||||
("Content:", "Contenuto:"),
|
||||
("Object Constraints:", "Vincoli oggetto:"),
|
||||
("Resize with Page:", "Ridimensiona con la pagina:"),
|
||||
("Pin:", "Fissa:"),
|
||||
("Object-Based", "Basato su oggetti"),
|
||||
("Guide-Based", "Basato su guide"),
|
||||
("Resize Page", "Ridimensiona pagina"),
|
||||
|
||||
@@ -1291,6 +1291,11 @@ pub(super) const TABLE: &[(&str, &str)] = &[
|
||||
("Added", "追加済み"),
|
||||
("Deleted", "削除済み"),
|
||||
("Guide-based", "ガイド基準"),
|
||||
("Page:", "ページ:"),
|
||||
("Content:", "コンテンツ:"),
|
||||
("Object Constraints:", "オブジェクトの制約:"),
|
||||
("Resize with Page:", "ページと共にサイズ変更:"),
|
||||
("Pin:", "固定:"),
|
||||
("Object-Based", "オブジェクト基準"),
|
||||
("Guide-Based", "ガイド基準"),
|
||||
("Resize Page", "ページのサイズを変更"),
|
||||
|
||||
@@ -708,6 +708,11 @@ pub(super) const TABLE: &[(&str, &str)] = &[
|
||||
("Added", "Adicionado"),
|
||||
("Deleted", "Excluído"),
|
||||
("Guide-based", "Baseado em guias"),
|
||||
("Page:", "Página:"),
|
||||
("Content:", "Conteúdo:"),
|
||||
("Object Constraints:", "Restrições de objeto:"),
|
||||
("Resize with Page:", "Redimensionar com a página:"),
|
||||
("Pin:", "Fixar:"),
|
||||
("Object-Based", "Baseado em objetos"),
|
||||
("Guide-Based", "Baseado em guias"),
|
||||
("Resize Page", "Redimensionar página"),
|
||||
|
||||
@@ -1292,6 +1292,11 @@ pub(super) const TABLE: &[(&str, &str)] = &[
|
||||
("Added", "Додано"),
|
||||
("Deleted", "Видалено"),
|
||||
("Guide-based", "На основі напрямних"),
|
||||
("Page:", "Сторінка:"),
|
||||
("Content:", "Вміст:"),
|
||||
("Object Constraints:", "Обмеження об’єкта:"),
|
||||
("Resize with Page:", "Змінювати розмір зі сторінкою:"),
|
||||
("Pin:", "Закріпити:"),
|
||||
("Object-Based", "На основі об’єктів"),
|
||||
("Guide-Based", "На основі напрямних"),
|
||||
("Resize Page", "Змінити розмір сторінки"),
|
||||
|
||||
@@ -308,6 +308,7 @@ pub const MENUS: &[(&str, &[&str])] = &[
|
||||
"<",
|
||||
"cmd:layout.marginsAndColumns",
|
||||
"cmd:layout.createGuides",
|
||||
"cmd:layout.createAlternate",
|
||||
"-",
|
||||
"ui:view.goToPage",
|
||||
"-",
|
||||
|
||||
@@ -501,50 +501,104 @@ pub fn liquid_rule_label(rule: designcraft_doc::LiquidRule) -> &'static str {
|
||||
LIQUID_RULES.iter().find(|(r, _)| *r == rule).map_or("Off", |(_, l)| l)
|
||||
}
|
||||
|
||||
/// Liquid Layout panel: the current page's rule, the selection's pins (object-based), the page's
|
||||
/// liquid guides, and Create Alternate Layout.
|
||||
/// Whether page `abs` follows the object-based rule (its own, or its parent page's).
|
||||
fn object_based(d: &designcraft_doc::Document, abs: usize) -> bool {
|
||||
use designcraft_doc::LiquidRule as L;
|
||||
let Some((si, pi)) = d.page_loc(abs) else { return false };
|
||||
match d.spreads[si].pages[pi].liquid {
|
||||
L::ObjectBased => true,
|
||||
L::ControlledByParent => {
|
||||
d.parent_page_for(abs).and_then(|(ppi, ppg)| d.parents.get(ppi)?.pages.get(ppg).map(|p| p.liquid == L::ObjectBased)).unwrap_or(false)
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Liquid Layout panel, laid out as InDesign's: the page's Liquid Page Rule (with "Page: n"),
|
||||
/// Content › Auto-Fit, and Object Constraints › Resize with Page (Height, Width) and Pin (Top,
|
||||
/// Left, Bottom, Right) for the selected object on an object-based page. The page's liquid
|
||||
/// guides follow (Create Alternate Layout is in the Layout menu).
|
||||
pub fn liquid(app: &mut DesignApp, ui: &mut egui::Ui) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let Some(page) = crate::canvas::current_page(app) else {
|
||||
crate::rtl::label(ui, egui::RichText::new(crate::i18n::tr(&app.ui.language, "No document.")).size(11.0).color(t.text_dim));
|
||||
let lang = app.ui.language.clone();
|
||||
// The Page tool's page, the page in view, or (before the canvas lays out) the first.
|
||||
let Some(page) = app.session.active().map(|s| s.selection.page.or_else(|| crate::canvas::current_page(app)).unwrap_or(0)) else {
|
||||
crate::rtl::label(ui, egui::RichText::new(crate::i18n::tr(&lang, "No document.")).size(11.0).color(t.text_dim));
|
||||
return;
|
||||
};
|
||||
let Some(d) = app.session.active().map(|d| d.doc.clone()) else { return };
|
||||
let Some((si, pi)) = d.page_loc(page) else { return };
|
||||
let pg = d.spreads[si].pages[pi].clone();
|
||||
use designcraft_doc::LiquidRule as L;
|
||||
crate::rtl::label(ui, egui::RichText::new(format!("{} {}", crate::i18n::tr(&app.ui.language, "Page"), d.page_name(page))).strong());
|
||||
let mut rule = pg.liquid;
|
||||
ui.horizontal(|ui| {
|
||||
crate::widgets::caption(ui, crate::i18n::tr(&lang, "Liquid Page Rule:"));
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
crate::widgets::caption(ui, &format!("{} {}", crate::i18n::tr(&lang, "Page:"), d.page_name(page)));
|
||||
});
|
||||
});
|
||||
egui::ComboBox::from_id_salt("liquid_rule")
|
||||
.selected_text(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, liquid_rule_label(rule))))
|
||||
.selected_text(crate::rtl::widget(ui, crate::i18n::tr(&lang, liquid_rule_label(pg.liquid))))
|
||||
.width(190.0)
|
||||
.show_ui(ui, |ui| {
|
||||
for (r, label) in LIQUID_RULES {
|
||||
ui.selectable_value(&mut rule, r, crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, label)));
|
||||
}
|
||||
});
|
||||
if rule != pg.liquid {
|
||||
let _ = app.run("liquid.pageRule", json!({"rule": rule, "pages": [page + 1]}));
|
||||
}
|
||||
if rule == L::ObjectBased
|
||||
&& let Some(id) = app.session.active().and_then(|s| s.selection.items.first().copied())
|
||||
&& let Some(it) = d.item(id)
|
||||
{
|
||||
ui.separator();
|
||||
let l = it.liquid.unwrap_or(designcraft_doc::ObjectLiquid { resize_width: true, resize_height: true, ..Default::default() });
|
||||
let mut v = [l.resize_width, l.resize_height, l.pin_top, l.pin_bottom, l.pin_left, l.pin_right];
|
||||
let keys = ["resizeWidth", "resizeHeight", "pinTop", "pinBottom", "pinLeft", "pinRight"];
|
||||
let labels = ["Resize width", "Resize height", "Pin top", "Pin bottom", "Pin left", "Pin right"];
|
||||
ui.horizontal_wrapped(|ui| {
|
||||
for ((on, key), label) in v.iter_mut().zip(keys).zip(labels) {
|
||||
if ui.checkbox(on, crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, label))).changed() {
|
||||
let _ = app.run("liquid.object", json!({key: *on}));
|
||||
if ui.selectable_label(pg.liquid == r, crate::rtl::widget(ui, crate::i18n::tr(&lang, label))).clicked() && r != pg.liquid {
|
||||
let _ = app.run("liquid.pageRule", json!({"rule": r, "pages": [page + 1]}));
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
ui.separator();
|
||||
// The selected object (one on this page).
|
||||
let item = app
|
||||
.session
|
||||
.active()
|
||||
.and_then(|s| s.selection.items.first().copied())
|
||||
.and_then(|id| d.item(id))
|
||||
.filter(|it| d.page_of_item(it.id) == Some(page));
|
||||
// Content: Auto-Fit (graphic frames).
|
||||
crate::widgets::caption(ui, crate::i18n::tr(&lang, "Content:"));
|
||||
let graphic = item.and_then(|it| if let designcraft_doc::Content::Graphic(g) = &it.content { Some(g.auto_fit) } else { None });
|
||||
let mut auto = graphic.is_some_and(|f| f != designcraft_doc::Fitting::None);
|
||||
ui.indent("liquid_content", |ui| {
|
||||
let r = ui.add_enabled(graphic.is_some(), egui::Checkbox::new(&mut auto, crate::rtl::widget(ui, crate::i18n::tr(&lang, "Auto-Fit"))));
|
||||
if r.changed() {
|
||||
let _ = app.run("object.fittingOptions", json!({"autoFit": auto}));
|
||||
}
|
||||
});
|
||||
// Object Constraints: for an object on an object-based page.
|
||||
crate::widgets::caption(ui, crate::i18n::tr(&lang, "Object Constraints:"));
|
||||
let on = item.is_some() && object_based(&d, page);
|
||||
let l = item
|
||||
.filter(|_| on)
|
||||
.map(|it| it.liquid.unwrap_or(designcraft_doc::ObjectLiquid { resize_width: true, resize_height: true, ..Default::default() }))
|
||||
.unwrap_or_default();
|
||||
ui.indent("liquid_constraints", |ui| {
|
||||
ui.add_enabled_ui(on, |ui| {
|
||||
let rows: [(&str, [(&str, &str, bool); 2]); 2] = [
|
||||
("Resize with Page:", [("Height", "resizeHeight", l.resize_height), ("Width", "resizeWidth", l.resize_width)]),
|
||||
("Pin:", [("Top", "pinTop", l.pin_top), ("Left", "pinLeft", l.pin_left)]),
|
||||
];
|
||||
for (title, boxes) in rows {
|
||||
crate::widgets::caption(ui, crate::i18n::tr(&lang, title));
|
||||
let mut grid_boxes = vec![boxes.to_vec()];
|
||||
if title == "Pin:" {
|
||||
grid_boxes.push(vec![("Bottom", "pinBottom", l.pin_bottom), ("Right", "pinRight", l.pin_right)]);
|
||||
}
|
||||
egui::Grid::new(("liquid_grid", title)).num_columns(2).min_col_width(90.0).show(ui, |ui| {
|
||||
for row in grid_boxes {
|
||||
for (label, key, value) in row {
|
||||
let mut v = value;
|
||||
if ui.checkbox(&mut v, crate::rtl::widget(ui, crate::i18n::tr(&lang, label))).changed() {
|
||||
let _ = app.run("liquid.object", json!({key: v}));
|
||||
}
|
||||
}
|
||||
ui.end_row();
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
if !pg.guides.is_empty() {
|
||||
ui.separator();
|
||||
crate::rtl::label(ui, egui::RichText::new(crate::i18n::tr(&app.ui.language, "Liquid guides")).size(11.0).color(t.text_dim));
|
||||
crate::rtl::label(ui, egui::RichText::new(crate::i18n::tr(&lang, "Liquid guides")).size(11.0).color(t.text_dim));
|
||||
for (gi, g) in pg.guides.iter().enumerate() {
|
||||
let mut on = g.liquid;
|
||||
let label = format!("{:?} at {:.1}", g.orientation, g.position);
|
||||
@@ -553,22 +607,6 @@ pub fn liquid(app: &mut DesignApp, ui: &mut egui::Ui) {
|
||||
}
|
||||
}
|
||||
}
|
||||
ui.separator();
|
||||
let key = egui::Id::new("alt_layout");
|
||||
let (mut name, mut w, mut h): (String, f64, f64) = ui.data(|x| x.get_temp(key)).unwrap_or(("Tablet H".into(), 1024.0, 768.0));
|
||||
crate::rtl::label(ui, egui::RichText::new(crate::i18n::tr(&app.ui.language, "Create Alternate Layout")).size(11.0).color(t.text_dim));
|
||||
ui.horizontal(|ui| {
|
||||
ui.add(egui::TextEdit::singleline(&mut name).desired_width(90.0));
|
||||
ui.add(egui::DragValue::new(&mut w).range(1.0..=15552.0).suffix(" pt"));
|
||||
crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "×"));
|
||||
ui.add(egui::DragValue::new(&mut h).range(1.0..=15552.0).suffix(" pt"));
|
||||
});
|
||||
if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Create"))).clicked()
|
||||
&& let Err(e) = app.run("layout.createAlternate", json!({"name": name, "width": w, "height": h}))
|
||||
{
|
||||
app.status(format!("Alternate layout: {e}"));
|
||||
}
|
||||
ui.data_mut(|x| x.insert_temp(key, (name, w, h)));
|
||||
}
|
||||
|
||||
/// Media panel: the selected video or sound's playback options and poster.
|
||||
@@ -734,3 +772,45 @@ pub fn track_changes(app: &mut DesignApp, ui: &mut egui::Ui) {
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod liquid_panel_tests {
|
||||
use egui_kittest::{Harness, kittest::Queryable};
|
||||
use serde_json::json;
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn object_constraints_set_the_selected_objects_pins_and_resizing() {
|
||||
let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default());
|
||||
app.run("file.new", json!({})).unwrap();
|
||||
let id = app.run("frame.create", json!({"rect": [100, 100, 200, 200]})).unwrap()["id"].as_u64().unwrap();
|
||||
let mut h = Harness::builder().with_size(egui::vec2(260.0, 420.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;
|
||||
}
|
||||
liquid(app, ui);
|
||||
},
|
||||
app,
|
||||
);
|
||||
h.run_steps(3);
|
||||
let l = |h: &Harness<'_, DesignApp>| h.state().session.doc().unwrap().doc.item(designcraft_doc::ItemId(id)).unwrap().liquid;
|
||||
// With the rule off the constraints don't apply.
|
||||
h.get_by_label("Right").click();
|
||||
h.run_steps(2);
|
||||
assert_eq!(l(&h), None);
|
||||
h.state_mut().run("liquid.pageRule", json!({"rule": "objectBased"})).unwrap();
|
||||
h.run_steps(2);
|
||||
h.get_by_label("Right").click();
|
||||
h.run_steps(2);
|
||||
h.get_by_label("Width").click();
|
||||
h.run_steps(2);
|
||||
let v = l(&h).unwrap();
|
||||
assert!(v.pin_right && !v.resize_width && v.resize_height, "{v:?}");
|
||||
let _ = h.get_by_label("Auto-Fit");
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user