mirror of
https://github.com/storytold/designcraft.git
synced 2026-11-06 18:42:15 +00:00
Merge pull request #1654 from makesomethingshit/fix/1620-parent-page-resize
Fix parent page resizing in panels and Page Tool
This commit is contained in:
23 files changed
+1122
-222
No files matched your search
+2
-1
@@ -4,7 +4,7 @@
|
||||
- 2026-10-23: placed-artwork layer options survive IDML and native round trips per placement, independently of shared assets. Unsupported visibility overrides are explicit import/preflight/PDF warnings; format 4 prevents older native readers from silently discarding them. Rendering parity is still open; see [graphic-layer-options.md](docs/graphic-layer-options.md).
|
||||
- FID1: PDF export normalizes mixed emitted gradient color spaces to RGB with an explicit warning; homogeneous device-space gradients retain their components. Linear/radial, midpoint, archival and print policies have synthetic regression coverage.
|
||||
|
||||
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-23 · **Change:** minor (placed graphic layer-option preservation and explicit rendering warnings; File ▸ Open Recent; .indd and .indt open through indd-utils; text threads by hand; the alpha gate waits on Primary Text Frame page fixes; Japanese print, import and table controls localized; Live corners (#186); Optical kerning from outlines (#181); window and menu quit confirmation; failed writes preserve live save and collection state; spring-loaded Zoom navigation; orderly native exit recovery; Pages panel parents; cancelable macOS Dock Quit; text stroke behind fill; Based on Parent; object styles carry effects; table anchors keep their own paragraphs; grouped HTML/EPUB reading order; Links source paths; XLSX cached cell values retain numeric precision and text) · **Target:** Adobe InDesign 2026
|
||||
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-11-01 · **Change:** minor (placed graphic layer-option preservation and explicit rendering warnings; File ▸ Open Recent; .indd and .indt open through indd-utils; text threads by hand; the alpha gate waits on Primary Text Frame page fixes; Japanese print, import and table controls localized; Live corners (#186); Optical kerning from outlines (#181); window and menu quit confirmation; failed writes preserve live save and collection state; spring-loaded Zoom navigation; orderly native exit recovery; Pages panel parents; cancelable macOS Dock Quit; text stroke behind fill; Based on Parent; object styles carry effects; table anchors keep their own paragraphs; grouped HTML/EPUB reading order; Links source paths; XLSX cached cell values retain numeric precision and text) · **Target:** Adobe InDesign 2026
|
||||
- AR1.1: Kashida justification tests verify space fallback without Tatweel fonts using an isolated font database, while retaining elongation assertions when fonts support it.
|
||||
|
||||
- AR1: Arabic IDML controls now retain character direction, kashida switches, diacritic offsets/forms, paragraph policy values and independent story/table directions. Shaping preserves joining context and script/language selection; paragraph-level bidi, contextual digit conversion, mark-safe elongation, RTL columns/tables and physical column insertion have regression coverage. Vendor-specific justification and diacritic presets remain explicit preflight warnings; see docs/arabic-typography.md.
|
||||
@@ -389,6 +389,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m
|
||||
|
||||
Newest first.
|
||||
|
||||
- **2026-11-01** · Parent page resizing (#1620): Properties, the Page Tool Control panel and on-page handles resize the selected parent page independently of document pages. Stable parent IDs, repeated resizing, anchors and liquid rules are preserved on the current command modules. Inline parent margin editing targets only the selected parent page and preserves opposite-page margins and document overrides; document commands retain their first-page default before the canvas is initialized. No readiness percentage change.
|
||||
- **2026-10-31** · `font.list` reports the embedding licence of the face that sets each font's text: its OS/2 `fsType` and what the bits mean (`restrictedLicence`, `bitmapOnly`, `noSubsetting`, `editable`, `previewPrint`; null when the font is missing or has no OS/2 table). A script or host that makes PDFs can check every face, bundled and installed ones included, without reading font files. Export behaviour is unchanged. No readiness percentage change.
|
||||
- **2026-10-31** · A `.designcraft` file opened from bytes (`file.openBytes` with its file name, as a browser or host passes it) is titled without the extension, as an IDML file opened that way is. Saving it in the browser downloaded `Brochure.designcraft.designcraft`, and the File Name variable showed the doubled name. Browser Open and Drop pass the full file name, so only the engine removes the extension and a repeated suffix is kept in the title. No readiness percentage change.
|
||||
- **2026-10-30** · Import warnings about a story have a Show button: it closes the warnings (the status line keeps them) and selects the story's first frame, fitted in the window. `file.open` returns `warningItems`, the frame each IDML or InDesign warning is about (or null), matching the converter's INDD story numbers (`story 30892`, written to IDML as `u78ac`) and IDML `Self` names to the imported stories. No readiness percentage change.
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::edit::SpreadRef;
|
||||
use crate::ids::{ItemId, StoryId};
|
||||
use crate::ids::{ItemId, SpreadId, StoryId};
|
||||
use crate::table::CellRange;
|
||||
|
||||
/// A selected ruler guide: index `index` in the guides of page `page` of spread `spread`.
|
||||
@@ -93,6 +93,9 @@ pub struct Selection {
|
||||
/// The page selected with the Page tool (absolute index).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub page: Option<usize>,
|
||||
/// Parent selected with the Page tool: stable spread id and page index within it.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub parent_page: Option<(SpreadId, usize)>,
|
||||
/// Selected ruler guides. Never together with items, text or cells: selecting one clears
|
||||
/// the other.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
|
||||
@@ -20,3 +20,7 @@ pub use sections::chapter_numbering;
|
||||
#[cfg(test)]
|
||||
#[path = "parent_delete_tests.rs"]
|
||||
mod parent_delete_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "parent_resize_tests.rs"]
|
||||
mod parent_resize_tests;
|
||||
@@ -61,84 +61,93 @@ pub(crate) fn resize_pages(d: &mut designcraft_doc::Document, pages: &[usize], w
|
||||
/// [`resize_pages`] keeping reference point `anchor` (0–8, row-major from the top-left) in place:
|
||||
/// the page grows away from it, so objects keep their place relative to it.
|
||||
pub(crate) fn resize_pages_from(d: &mut designcraft_doc::Document, pages: &[usize], w: Option<f64>, h: Option<f64>, anchor: usize) {
|
||||
for page in pages {
|
||||
let Some((si, pi)) = page.checked_sub(1).and_then(|i| d.page_loc(i)) else { continue };
|
||||
resize_spread_page(d, designcraft_doc::SpreadRef::Doc(si), pi, w.map(|v| v.max(1.0)), h.map(|v| v.max(1.0)), anchor);
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn resize_spread_page(
|
||||
d: &mut designcraft_doc::Document,
|
||||
sr: designcraft_doc::SpreadRef,
|
||||
pi: usize,
|
||||
w: Option<f64>,
|
||||
h: Option<f64>,
|
||||
anchor: usize,
|
||||
) {
|
||||
let (fx, fy) = ((anchor % 3) as f64 / 2.0, (anchor / 3).min(2) as f64 / 2.0);
|
||||
{
|
||||
for page in pages {
|
||||
let Some((si, pi)) = d.page_loc(page - 1) else { continue };
|
||||
let sp = std::sync::Arc::make_mut(&mut d.spreads[si]);
|
||||
let old: Vec<f64> = sp.pages.iter().map(|x| x.x).collect();
|
||||
let old_bounds = sp.pages[pi].bounds();
|
||||
// The page's own objects (outer pages take the pasteboard beside them).
|
||||
let last = sp.pages.len() - 1;
|
||||
let (lo, hi) = (if pi == 0 { f64::NEG_INFINITY } else { old_bounds.x0 }, if pi == last { f64::INFINITY } else { old_bounds.x1 });
|
||||
let own: Vec<usize> = sp
|
||||
.items
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, it)| {
|
||||
let c = it.bounds().center().x;
|
||||
c >= lo && c < hi
|
||||
})
|
||||
.map(|(i, _)| i)
|
||||
.collect();
|
||||
let pg = &mut sp.pages[pi];
|
||||
let dw = w.map_or(0.0, |w| w.max(1.0) - pg.width);
|
||||
let dh = h.map_or(0.0, |h| h.max(1.0) - pg.height);
|
||||
if let Some(w) = w {
|
||||
set_page_width(pg, w.max(1.0));
|
||||
}
|
||||
if let Some(h) = h {
|
||||
pg.height = h.max(1.0);
|
||||
}
|
||||
let left = pg.side == designcraft_doc::PageSide::Left;
|
||||
sp.relayout();
|
||||
// Keep the spine: shift the whole spread back when a left page grows.
|
||||
if left && dw != 0.0 {
|
||||
for q in &mut sp.pages {
|
||||
q.x -= dw;
|
||||
}
|
||||
}
|
||||
let new: Vec<f64> = sp.pages.iter().map(|x| x.x).collect();
|
||||
// Objects follow their page.
|
||||
let shifts: Vec<(f64, f64, f64)> =
|
||||
sp.pages.iter().enumerate().map(|(k, q)| (old[k], old[k] + q.width - if k == pi { dw } else { 0.0 }, new[k] - old[k])).collect();
|
||||
for it in &mut sp.items {
|
||||
let cx = it.bounds().center().x;
|
||||
if let Some((_, _, dx)) = shifts.iter().find(|(a, b, _)| cx >= *a && cx < *b)
|
||||
&& *dx != 0.0
|
||||
{
|
||||
let it = std::sync::Arc::make_mut(it);
|
||||
it.xf = designcraft_geom::Affine::translate((*dx, 0.0)) * it.xf;
|
||||
}
|
||||
}
|
||||
// Page guides move with their page.
|
||||
for (k, q) in sp.pages.iter_mut().enumerate() {
|
||||
let dx = new[k] - old[k];
|
||||
if dx != 0.0 {
|
||||
for g in q.guides.iter_mut().filter(|g| g.orientation == designcraft_doc::Orientation::Vertical) {
|
||||
g.position += dx;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Growing away from the anchor: the page's objects and guides keep their place
|
||||
// relative to it.
|
||||
let shift = designcraft_geom::Vec2::new(fx * dw, fy * dh);
|
||||
if shift != designcraft_geom::Vec2::ZERO {
|
||||
for i in &own {
|
||||
if let Some(it) = sp.items.get_mut(*i) {
|
||||
let it = std::sync::Arc::make_mut(it);
|
||||
it.xf = designcraft_geom::Affine::translate(shift) * it.xf;
|
||||
}
|
||||
}
|
||||
for g in &mut sp.pages[pi].guides {
|
||||
g.position += if g.orientation == designcraft_doc::Orientation::Vertical { shift.x } else { shift.y };
|
||||
}
|
||||
}
|
||||
// Liquid rules: objects were carried along with the page's left edge (and the anchor).
|
||||
let moved = old_bounds + designcraft_geom::Vec2::new(new[pi] - old[pi], 0.0) + shift;
|
||||
crate::cmd::liquid::apply(d, designcraft_doc::SpreadRef::Doc(si), pi, moved);
|
||||
let Some(sp) = d.spread_mut(sr) else { return };
|
||||
let origin = sp.bounds().x0;
|
||||
let old: Vec<f64> = sp.pages.iter().map(|x| x.x).collect();
|
||||
let old_bounds = sp.pages[pi].bounds();
|
||||
// The page's own objects (outer pages take the pasteboard beside them).
|
||||
let Some((leftmost, rightmost)) = sp.outer_page_indices() else { return };
|
||||
let (lo, hi) = (if pi == leftmost { f64::NEG_INFINITY } else { old_bounds.x0 }, if pi == rightmost { f64::INFINITY } else { old_bounds.x1 });
|
||||
let own: Vec<usize> = sp
|
||||
.items
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, it)| {
|
||||
let c = it.bounds().center().x;
|
||||
c >= lo && c < hi
|
||||
})
|
||||
.map(|(i, _)| i)
|
||||
.collect();
|
||||
let pg = &mut sp.pages[pi];
|
||||
let dw = w.map_or(0.0, |w| w - pg.width);
|
||||
let dh = h.map_or(0.0, |h| h - pg.height);
|
||||
if let Some(w) = w {
|
||||
set_page_width(pg, w);
|
||||
}
|
||||
if let Some(h) = h {
|
||||
pg.height = h;
|
||||
}
|
||||
let left = pg.side == designcraft_doc::PageSide::Left;
|
||||
sp.relayout();
|
||||
// Relayout resets the origin; retain prior edits and grow left pages away from the spine.
|
||||
let offset = origin - if left { dw } else { 0.0 };
|
||||
for q in &mut sp.pages {
|
||||
q.x += offset;
|
||||
}
|
||||
let new: Vec<f64> = sp.pages.iter().map(|x| x.x).collect();
|
||||
// Objects follow their page.
|
||||
let shifts: Vec<(f64, f64, f64)> =
|
||||
sp.pages.iter().enumerate().map(|(k, q)| (old[k], old[k] + q.width - if k == pi { dw } else { 0.0 }, new[k] - old[k])).collect();
|
||||
for it in &mut sp.items {
|
||||
let cx = it.bounds().center().x;
|
||||
if let Some((_, _, dx)) = shifts.iter().find(|(a, b, _)| cx >= *a && cx < *b)
|
||||
&& *dx != 0.0
|
||||
{
|
||||
let it = std::sync::Arc::make_mut(it);
|
||||
it.xf = designcraft_geom::Affine::translate((*dx, 0.0)) * it.xf;
|
||||
}
|
||||
}
|
||||
// Page guides move with their page.
|
||||
for (k, q) in sp.pages.iter_mut().enumerate() {
|
||||
let dx = new[k] - old[k];
|
||||
if dx != 0.0 {
|
||||
for g in q.guides.iter_mut().filter(|g| g.orientation == designcraft_doc::Orientation::Vertical) {
|
||||
g.position += dx;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Growing away from the anchor: the page's objects and guides keep their place
|
||||
// relative to it.
|
||||
let shift = designcraft_geom::Vec2::new(fx * dw, fy * dh);
|
||||
if shift != designcraft_geom::Vec2::ZERO {
|
||||
for i in &own {
|
||||
if let Some(it) = sp.items.get_mut(*i) {
|
||||
let it = std::sync::Arc::make_mut(it);
|
||||
it.xf = designcraft_geom::Affine::translate(shift) * it.xf;
|
||||
}
|
||||
}
|
||||
for g in &mut sp.pages[pi].guides {
|
||||
g.position += if g.orientation == designcraft_doc::Orientation::Vertical { shift.x } else { shift.y };
|
||||
}
|
||||
}
|
||||
// Liquid rules: objects were carried along with the page's left edge (and the anchor).
|
||||
let moved = old_bounds + designcraft_geom::Vec2::new(new[pi] - old[pi], 0.0) + shift;
|
||||
crate::cmd::liquid::apply(d, sr, pi, moved);
|
||||
}
|
||||
|
||||
/// File › Document Setup values.
|
||||
@@ -322,6 +331,51 @@ mod setup_tests {
|
||||
mod page_size_tests {
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn document_page_resize_preserves_shifted_spread_objects_and_guides() {
|
||||
for binding in ["leftToRight", "rightToLeft"] {
|
||||
let mut s = crate::Session::new();
|
||||
s.execute("file.new", &json!({"pages": 3, "facingPages": true, "width": 400, "height": 400})).unwrap();
|
||||
s.execute("layout.documentSetup", &json!({"binding": binding})).unwrap();
|
||||
let before = s.doc().unwrap().doc.clone();
|
||||
let (si, _) = before.page_loc(1).unwrap();
|
||||
let (left, right) = before.spreads[si].outer_page_indices().unwrap();
|
||||
let mut items = Vec::new();
|
||||
for pi in [left, right] {
|
||||
let x = before.spreads[si].pages[pi].x;
|
||||
let id = s.execute("frame.create", &json!({"spread": si, "rect": [x + 80.0, 80, x + 180.0, 180]})).unwrap()["id"].as_u64().unwrap();
|
||||
items.push(designcraft_doc::ItemId(id));
|
||||
s.execute("guide.add", &json!({"spread": si, "page": pi, "orientation": "vertical", "position": x + 120.0})).unwrap();
|
||||
}
|
||||
let first = before.first_page_of_spread(si) + 1;
|
||||
s.execute("layout.pageSize", &json!({"pages": [first + left], "width": 300})).unwrap();
|
||||
let shifted = s.doc().unwrap().doc.clone();
|
||||
let sp = &shifted.spreads[si];
|
||||
assert_eq!(sp.bounds().x0, 100.0);
|
||||
assert_eq!(sp.pages[right].x, 400.0);
|
||||
assert_eq!(sp.pages[left].guides[0].position, 220.0);
|
||||
assert_eq!(sp.pages[right].guides[0].position, 520.0);
|
||||
assert_eq!(shifted.item(items[0]).unwrap().bounds().x0, 180.0);
|
||||
assert_eq!(shifted.item(items[1]).unwrap().bounds().x0, 480.0);
|
||||
for width in [500, 450, 520] {
|
||||
s.execute("layout.pageSize", &json!({"pages": [first + right], "width": width})).unwrap();
|
||||
let d = &s.doc().unwrap().doc;
|
||||
let sp = &d.spreads[si];
|
||||
assert_eq!(sp.bounds().x0, 100.0, "{binding}, width {width}");
|
||||
assert_eq!(sp.pages[right].x, 400.0);
|
||||
for pi in [left, right] {
|
||||
assert_eq!(sp.pages[pi].guides, shifted.spreads[si].pages[pi].guides);
|
||||
}
|
||||
for id in &items {
|
||||
assert_eq!(d.item(*id).unwrap().bounds(), shifted.item(*id).unwrap().bounds());
|
||||
}
|
||||
assert_eq!(d.parents, before.parents);
|
||||
assert_eq!(d.settings, before.settings);
|
||||
d.check().unwrap();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn page_size_per_page_keeps_objects_on_their_page() {
|
||||
let mut s = crate::Session::new();
|
||||
|
||||
@@ -65,6 +65,43 @@ pub(super) fn parent_options(s: &mut crate::Session, p: &Value) -> Result<Value>
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn parent_page_target(d: &designcraft_doc::Document, p: &Value, cmd: &str) -> Result<(usize, usize)> {
|
||||
let parent = p.get("parent").and_then(Value::as_u64).map(SpreadId).ok_or_else(|| bad(cmd, "give a parent spread id"))?;
|
||||
let si = d.parent_index(parent).ok_or_else(|| bad(cmd, "no such parent"))?;
|
||||
let pi = p
|
||||
.get("page")
|
||||
.and_then(Value::as_u64)
|
||||
.and_then(|n| usize::try_from(n).ok())
|
||||
.filter(|i| *i < d.parents[si].pages.len())
|
||||
.ok_or_else(|| bad(cmd, "no such parent page"))?;
|
||||
Ok((si, pi))
|
||||
}
|
||||
|
||||
pub(super) fn parent_page_size(s: &mut crate::Session, p: &Value) -> Result<Value> {
|
||||
const ID: &str = "layout.parentPageSize";
|
||||
let (si, pi) = parent_page_target(&s.doc()?.doc, p, ID)?;
|
||||
let size = |key| {
|
||||
p.get(key)
|
||||
.map(|v| {
|
||||
v.as_f64().filter(|v| v.is_finite() && *v > 0.0 && *v <= 15552.0).ok_or_else(|| bad(ID, "page size out of range (0 < size ≤ 216 in)"))
|
||||
})
|
||||
.transpose()
|
||||
};
|
||||
let (w, h) = (size("width")?, size("height")?);
|
||||
if w.is_none() && h.is_none() {
|
||||
return Err(bad(ID, "give width or height"));
|
||||
}
|
||||
let anchor = match p.get("anchor") {
|
||||
None => 0,
|
||||
Some(v) => v.as_u64().filter(|n| *n <= 8).ok_or_else(|| bad(ID, "anchor: 0–8"))? as usize,
|
||||
};
|
||||
s.edit(|d, _| {
|
||||
super::pages::resize_spread_page(d, designcraft_doc::SpreadRef::Parent(si), pi, w, h, anchor);
|
||||
d.sync_parent_margins();
|
||||
Ok(json!({"parent": d.parents[si].id.0, "page": pi}))
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod parent_margin_tests {
|
||||
use designcraft_doc::{Document, Margins};
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use super::layers::set_layer;
|
||||
use super::margins::{adjust_layout, column_guides_unlocked, margin_boxes, move_column_guide, set_margins_columns, set_page_width};
|
||||
use super::pages::{document_setup, page_options, page_size};
|
||||
use super::parents::{parent_options, parent_ref, parent_spread_index};
|
||||
use super::parents::{parent_options, parent_page_size, parent_page_target, parent_ref, parent_spread_index};
|
||||
use super::sections::{ChapterEdit, SectionEdit, chapter_numbering, start_number};
|
||||
use super::{CommandSpec, LAYER_COLORS, Layer, LayerId, Margins, SpreadId, Value, bad, cmd, has_doc, json, ok, str_param};
|
||||
|
||||
@@ -502,6 +502,30 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
has_doc,
|
||||
page_size
|
||||
),
|
||||
cmd!(
|
||||
"layout.parentPageSize",
|
||||
"Parent Page Size",
|
||||
[],
|
||||
None,
|
||||
"{parent: spread id, page: 0-based parent page index, width?, height?, anchor?: 0–8} — resize only this parent page, keeping document page sizes independent",
|
||||
has_doc,
|
||||
parent_page_size
|
||||
),
|
||||
cmd!(
|
||||
noundo "layout.selectParentPage", "Select Parent Page", [], None,
|
||||
"{parent: spread id|null, page?: 0-based parent page index} — select a parent page with the Page tool; null clears the selection",
|
||||
has_doc, |s, p| {
|
||||
let target = match p.get("parent") {
|
||||
Some(Value::Null) => None,
|
||||
_ => Some(parent_page_target(&s.doc()?.doc, p, "layout.selectParentPage")?),
|
||||
};
|
||||
let st = s.doc_mut()?;
|
||||
st.selection = Default::default();
|
||||
st.selection.parent_page = target.map(|(si, pi)| (st.doc.parents[si].id, pi));
|
||||
st.bump_revision();
|
||||
ok()
|
||||
}
|
||||
),
|
||||
cmd!(
|
||||
"layout.pageOptions",
|
||||
"Page Options",
|
||||
@@ -536,6 +560,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
st.selection = designcraft_doc::Selection::items(ids);
|
||||
}
|
||||
st.selection.page = page;
|
||||
st.selection.parent_page = None;
|
||||
st.bump_revision();
|
||||
Ok(json!({"page": page.map(|p| p + 1)}))
|
||||
}
|
||||
|
||||
@@ -70,8 +70,8 @@ pub(crate) fn apply(d: &mut Document, sr: SpreadRef, pi: usize, old: Rect) {
|
||||
let liquid_guides = |o: Orientation| pg.guides.iter().filter(|g| g.liquid && g.orientation == o).map(|g| g.position).collect::<Vec<_>>();
|
||||
let (vg, hg) = (liquid_guides(Orientation::Vertical), liquid_guides(Orientation::Horizontal));
|
||||
// The page's objects: centres within its old horizontal extent (outer pages take the pasteboard).
|
||||
let last = sp.pages.len() - 1;
|
||||
let (lo, hi) = (if pi == 0 { f64::NEG_INFINITY } else { old.x0 }, if pi == last { f64::INFINITY } else { old.x1 });
|
||||
let Some((leftmost, rightmost)) = sp.outer_page_indices() else { return };
|
||||
let (lo, hi) = (if pi == leftmost { f64::NEG_INFINITY } else { old.x0 }, if pi == rightmost { f64::INFINITY } else { old.x1 });
|
||||
let strokes = false;
|
||||
let Some(sp) = d.spread_mut(sr) else { return };
|
||||
for it in &mut sp.items {
|
||||
|
||||
@@ -0,0 +1,299 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use designcraft_doc::{Columns, LiquidRule, PageSide, SpreadId};
|
||||
use designcraft_geom::Rect;
|
||||
use serde_json::json;
|
||||
|
||||
use crate::Session;
|
||||
|
||||
fn parent_id(s: &Session, index: usize) -> u64 {
|
||||
s.doc().unwrap().doc.parents[index].id.0
|
||||
}
|
||||
|
||||
fn parent_page(s: &Session, index: usize, page: usize) -> designcraft_doc::Page {
|
||||
s.doc().unwrap().doc.parents[index].pages[page].clone()
|
||||
}
|
||||
|
||||
fn parent_frame(s: &mut Session, x: f64) -> u64 {
|
||||
s.execute(
|
||||
"frame.create",
|
||||
&json!({
|
||||
"spread": {"kind": "parent", "index": 0},
|
||||
"rect": [x, 80.0, x + 100.0, 180.0]
|
||||
}),
|
||||
)
|
||||
.unwrap()["id"]
|
||||
.as_u64()
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn bounds(s: &Session, id: u64) -> Rect {
|
||||
s.doc().unwrap().doc.item(designcraft_doc::ItemId(id)).unwrap().bounds()
|
||||
}
|
||||
|
||||
fn guide_positions(s: &Session, page: usize) -> Vec<f64> {
|
||||
s.doc().unwrap().doc.parents[0].pages[page].guides.iter().map(|g| g.position).collect()
|
||||
}
|
||||
|
||||
fn prepare_parent_pages(s: &mut Session, binding: &str, rule: LiquidRule) -> (u64, u64, u64, usize, usize) {
|
||||
s.execute("file.new", &json!({"pages": 2, "facingPages": true, "width": 400.0, "height": 400.0})).unwrap();
|
||||
s.execute("layout.documentSetup", &json!({"binding": binding})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().doc.settings.right_to_left_binding, binding == "rightToLeft");
|
||||
assert_eq!(s.doc().unwrap().doc.parents[0].pages[0].side, if binding == "rightToLeft" { PageSide::Right } else { PageSide::Left });
|
||||
let target = s.doc().unwrap().doc.parents[0].pages.iter().position(|p| p.side == PageSide::Right).unwrap();
|
||||
let other = 1 - target;
|
||||
let target_page = parent_page(s, 0, target);
|
||||
let other_page = parent_page(s, 0, other);
|
||||
let target_item = parent_frame(s, target_page.x + 80.0);
|
||||
let other_item = parent_frame(s, other_page.x + 80.0);
|
||||
s.execute(
|
||||
"guide.add",
|
||||
&json!({"spread": {"kind": "parent", "index": 0}, "page": target, "orientation": "vertical", "position": target_page.x + 120.0}),
|
||||
)
|
||||
.unwrap();
|
||||
s.execute(
|
||||
"guide.add",
|
||||
&json!({"spread": {"kind": "parent", "index": 0}, "page": target, "orientation": "horizontal", "position": 120.0, "at": target_page.x + 10.0}),
|
||||
)
|
||||
.unwrap();
|
||||
s.execute(
|
||||
"guide.add",
|
||||
&json!({"spread": {"kind": "parent", "index": 0}, "page": other, "orientation": "vertical", "position": other_page.x + 120.0}),
|
||||
)
|
||||
.unwrap();
|
||||
s.execute(
|
||||
"guide.add",
|
||||
&json!({"spread": {"kind": "parent", "index": 0}, "page": other, "orientation": "horizontal", "position": 120.0, "at": other_page.x + 10.0}),
|
||||
)
|
||||
.unwrap();
|
||||
s.edit(|d, _| {
|
||||
let sp = Arc::make_mut(&mut d.parents[0]);
|
||||
let positions = |page: &designcraft_doc::Page| {
|
||||
let w = page.margin_rect().width();
|
||||
vec![0.0, 100.0, 120.0, w]
|
||||
};
|
||||
for page in &mut sp.pages {
|
||||
page.columns = Columns { count: 2, gutter: 20.0, positions: Some(positions(page)) };
|
||||
}
|
||||
sp.pages[target].liquid = rule;
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
(parent_id(s, 0), target_item, other_item, target, other)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parent_resize_changes_only_the_target_page_in_ltr_and_rtl() {
|
||||
for binding in ["leftToRight", "rightToLeft"] {
|
||||
for rule in [
|
||||
LiquidRule::Off,
|
||||
LiquidRule::Scale,
|
||||
LiquidRule::ReCenter,
|
||||
LiquidRule::ObjectBased,
|
||||
LiquidRule::GuideBased,
|
||||
LiquidRule::ControlledByParent,
|
||||
] {
|
||||
let mut s = Session::new();
|
||||
let (parent, target_item, other_item, target, other) = prepare_parent_pages(&mut s, binding, rule);
|
||||
let before_target = parent_page(&s, 0, target);
|
||||
let before_other = parent_page(&s, 0, other);
|
||||
let before_target_item = bounds(&s, target_item);
|
||||
let before_other_item = bounds(&s, other_item);
|
||||
let before_target_guides = guide_positions(&s, target);
|
||||
let before_other_guides = guide_positions(&s, other);
|
||||
let before_target_columns = before_target.columns.positions.clone();
|
||||
let before_other_columns = before_other.columns.positions.clone();
|
||||
|
||||
s.execute("layout.selectParentPage", &json!({"parent": parent, "page": target})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().selection.parent_page, Some((SpreadId(parent), target)));
|
||||
assert!(s.doc().unwrap().selection.items.is_empty());
|
||||
s.execute("layout.parentPageSize", &json!({"parent": parent, "page": target, "width": 500.0, "height": 500.0, "anchor": 8})).unwrap();
|
||||
|
||||
let after_target = parent_page(&s, 0, target);
|
||||
let after_other = parent_page(&s, 0, other);
|
||||
assert_eq!((after_target.width, after_target.height), (500.0, 500.0), "{binding} {rule:?}");
|
||||
assert_eq!((after_other.width, after_other.height), (before_other.width, before_other.height), "{binding} {rule:?}");
|
||||
assert_ne!(bounds(&s, target_item), before_target_item, "target object did not follow the resize: {binding} {rule:?}");
|
||||
assert_eq!(bounds(&s, other_item), before_other_item, "other page object moved: {binding} {rule:?}");
|
||||
assert_ne!(guide_positions(&s, target), before_target_guides, "target guides did not follow the resize: {binding} {rule:?}");
|
||||
assert_eq!(guide_positions(&s, other), before_other_guides, "other page guides moved: {binding} {rule:?}");
|
||||
assert_ne!(after_target.columns.positions, before_target_columns, "target columns did not scale: {binding} {rule:?}");
|
||||
assert_eq!(after_other.columns.positions, before_other_columns, "other page columns changed: {binding} {rule:?}");
|
||||
assert_eq!(s.doc().unwrap().doc.settings.page_width, 400.0);
|
||||
assert_eq!(s.doc().unwrap().doc.settings.page_height, 400.0);
|
||||
s.doc().unwrap().doc.check().unwrap();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parent_resize_is_independent_of_applied_and_based_on_parents_and_undoable() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"pages": 1, "facingPages": false, "width": 400.0, "height": 400.0})).unwrap();
|
||||
let a = parent_id(&s, 0);
|
||||
let b = s.execute("layout.parents.new", &json!({"prefix": "B", "name": "Child"})).unwrap()["id"].as_u64().unwrap();
|
||||
s.execute("layout.parents.options", &json!({"parent": b, "basedOn": a})).unwrap();
|
||||
s.execute("layout.pages.applyParent", &json!({"pages": [0], "parent": b})).unwrap();
|
||||
let before = s.doc().unwrap().doc.as_ref().clone();
|
||||
let undo_before = s.doc().unwrap().history.undo.len();
|
||||
|
||||
s.execute("layout.parentPageSize", &json!({"parent": a, "page": 0, "width": 0.5, "anchor": 0})).unwrap();
|
||||
let d = &s.doc().unwrap().doc;
|
||||
assert_eq!(d.parents[0].pages[0].width, 0.5);
|
||||
assert_eq!(d.parents[1].pages[0].width, before.parents[1].pages[0].width);
|
||||
assert_eq!(d.spreads[0].pages[0].width, before.spreads[0].pages[0].width);
|
||||
assert_eq!(d.parents[1].parent.as_ref().unwrap().based_on, Some(SpreadId(a)));
|
||||
assert_eq!(d.settings.page_width, before.settings.page_width);
|
||||
d.check().unwrap();
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), undo_before + 1);
|
||||
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().doc.parents[0].pages[0].width, before.parents[0].pages[0].width);
|
||||
s.execute("edit.redo", &json!({})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().doc.parents[0].pages[0].width, 0.5);
|
||||
s.doc().unwrap().doc.check().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parent_resize_survives_native_and_idml_round_trips() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"pages": 1, "facingPages": false, "width": 400.0, "height": 400.0})).unwrap();
|
||||
let a = parent_id(&s, 0);
|
||||
s.execute("layout.parentPageSize", &json!({"parent": a, "page": 0, "width": 0.5, "height": 0.75})).unwrap();
|
||||
let d = s.doc().unwrap().doc.as_ref().clone();
|
||||
d.check().unwrap();
|
||||
|
||||
let native = crate::cmd::from_bytes(&crate::cmd::to_bytes(&d).unwrap()).unwrap();
|
||||
assert_eq!((native.parents[0].pages[0].width, native.parents[0].pages[0].height), (0.5, 0.75));
|
||||
native.check().unwrap();
|
||||
|
||||
let idml = designcraft_idml::export_idml(&d);
|
||||
let reopened = designcraft_idml::import_idml(&idml).unwrap();
|
||||
assert_eq!((reopened.parents[0].pages[0].width, reopened.parents[0].pages[0].height), (0.5, 0.75));
|
||||
reopened.check().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parent_resize_rejects_invalid_inputs_atomically() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"pages": 1, "facingPages": false})).unwrap();
|
||||
let parent = parent_id(&s, 0);
|
||||
let before = s.doc().unwrap().doc.as_ref().clone();
|
||||
let undo_before = s.doc().unwrap().history.undo.len();
|
||||
let invalid = [
|
||||
json!({"parent": parent, "page": 0, "width": null}),
|
||||
json!({"parent": parent, "page": 0, "width": "400"}),
|
||||
json!({"parent": parent, "page": 0, "width": "NaN"}),
|
||||
json!({"parent": parent, "page": 0, "width": "Infinity"}),
|
||||
json!({"parent": parent, "page": 0, "width": 0}),
|
||||
json!({"parent": parent, "page": 0, "width": -1}),
|
||||
json!({"parent": parent, "page": 0, "width": 15552.1}),
|
||||
json!({"parent": parent, "page": 0, "width": 400, "anchor": 9}),
|
||||
json!({"parent": parent, "page": 1, "width": 400}),
|
||||
json!({"parent": 999_999, "page": 0, "width": 400}),
|
||||
];
|
||||
for params in invalid {
|
||||
assert!(s.execute("layout.parentPageSize", ¶ms).is_err(), "accepted {params}");
|
||||
assert_eq!(s.doc().unwrap().doc.as_ref(), &before, "mutated after {params}");
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), undo_before, "added undo after {params}");
|
||||
}
|
||||
assert!(s.execute("layout.parentPageSize", &json!({"parent": parent, "page": 0})).is_err());
|
||||
assert_eq!(s.doc().unwrap().doc.as_ref(), &before);
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), undo_before);
|
||||
s.doc().unwrap().doc.check().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parent_and_document_page_selection_validate_and_clear_each_other() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"pages": 1, "facingPages": false})).unwrap();
|
||||
let parent = parent_id(&s, 0);
|
||||
let item = s.execute("frame.create", &json!({"rect": [40, 40, 100, 100]})).unwrap()["id"].as_u64().unwrap();
|
||||
assert!(!s.doc().unwrap().selection.items.is_empty());
|
||||
s.execute("layout.selectParentPage", &json!({"parent": parent, "page": 0})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().selection.parent_page, Some((SpreadId(parent), 0)));
|
||||
assert!(s.doc().unwrap().selection.items.is_empty());
|
||||
assert!(s.execute("layout.selectParentPage", &json!({"parent": parent, "page": 1})).is_err());
|
||||
assert_eq!(s.doc().unwrap().selection.parent_page, Some((SpreadId(parent), 0)));
|
||||
s.execute("layout.selectPage", &json!({"page": 1, "ids": [item]})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().selection.page, Some(0));
|
||||
assert_eq!(s.doc().unwrap().selection.parent_page, None);
|
||||
s.execute("layout.selectParentPage", &json!({"parent": null})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().selection, Default::default());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parent_resize_repeated_edits_keep_the_spine_and_other_page_objects() {
|
||||
for binding in ["leftToRight", "rightToLeft"] {
|
||||
for pi in 0..2 {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width":400,"height":600,"pages":3})).unwrap();
|
||||
s.execute("layout.documentSetup", &json!({"binding":binding})).unwrap();
|
||||
let id = parent_id(&s, 0);
|
||||
let before = s.doc().unwrap().doc.clone();
|
||||
let spine = before.parents[0].spine_x();
|
||||
let other = 1 - pi;
|
||||
let ox = before.parents[0].pages[other].x + 80.0;
|
||||
let object = parent_frame(&mut s, ox);
|
||||
let old_object = bounds(&s, object);
|
||||
for params in
|
||||
[json!({"parent":id,"page":pi,"width":500}), json!({"parent":id,"page":pi,"height":700}), json!({"parent":id,"page":pi,"width":550})]
|
||||
{
|
||||
s.execute("layout.parentPageSize", ¶ms).unwrap();
|
||||
assert_eq!(s.doc().unwrap().doc.parents[0].spine_x(), spine, "spine moved: {binding}, page {pi}, {params}");
|
||||
assert_eq!(bounds(&s, object), old_object, "other-page object moved: {binding}, page {pi}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parent_resize_keeps_margin_and_column_inheritance_without_resizing_applied_pages() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width":400,"height":600,"pages":3})).unwrap();
|
||||
let id = parent_id(&s, 0);
|
||||
s.edit(|d, _| {
|
||||
let pg = &mut Arc::make_mut(&mut d.parents[0]).pages[1];
|
||||
pg.columns = Columns { count: 2, gutter: 20.0, positions: Some(vec![0.0, 100.0, 120.0, 328.0]) };
|
||||
d.sync_parent_margins();
|
||||
Arc::make_mut(&mut d.spreads[1]).pages[1].local_margins = true;
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
let before = s.doc().unwrap().doc.clone();
|
||||
s.execute("layout.parentPageSize", &json!({"parent":id,"page":1,"width":500})).unwrap();
|
||||
let d = &s.doc().unwrap().doc;
|
||||
assert_eq!(d.page(0).unwrap().columns, d.parents[0].pages[1].columns);
|
||||
assert_eq!(d.page(0).unwrap().margins, d.parents[0].pages[1].margins);
|
||||
assert_eq!((d.page(0).unwrap().width, d.page(0).unwrap().height), (400.0, 600.0));
|
||||
assert_eq!(d.page(2).unwrap(), before.page(2).unwrap());
|
||||
d.check().unwrap();
|
||||
s.execute("layout.pages.applyParent", &json!({"pages":[0],"parent":null})).unwrap();
|
||||
s.execute("layout.pages.applyParent", &json!({"pages":[0],"parent":id})).unwrap();
|
||||
assert_eq!((s.doc().unwrap().doc.page(0).unwrap().width, s.doc().unwrap().doc.page(0).unwrap().height), (400.0, 600.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parent_resize_facing_sizes_survive_idml_in_both_bindings() {
|
||||
for binding in ["leftToRight", "rightToLeft"] {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width":400,"height":600,"pages":3,"facingPages":true})).unwrap();
|
||||
s.execute("layout.documentSetup", &json!({"binding":binding})).unwrap();
|
||||
let id = parent_id(&s, 0);
|
||||
for (pi, w, h) in [(0, 500, 700), (1, 300, 550)] {
|
||||
s.execute("layout.parentPageSize", &json!({"parent":id,"page":pi,"width":w,"height":h})).unwrap();
|
||||
}
|
||||
let d = &s.doc().unwrap().doc;
|
||||
let reopened = designcraft_idml::import_idml(&designcraft_idml::export_idml(d)).unwrap();
|
||||
assert_eq!(reopened.settings.right_to_left_binding, d.settings.right_to_left_binding);
|
||||
for page in &d.parents[0].pages {
|
||||
let restored = reopened.parents[0].pages.iter().find(|p| p.side == page.side).unwrap();
|
||||
assert_eq!((restored.width, restored.height), (page.width, page.height), "{binding}, {:?}", page.side);
|
||||
}
|
||||
for abs in 0..d.page_count() {
|
||||
let (old, new) = (d.page(abs).unwrap(), reopened.page(abs).unwrap());
|
||||
assert_eq!((new.width, new.height), (old.width, old.height));
|
||||
}
|
||||
reopened.check().unwrap();
|
||||
}
|
||||
}
|
||||
@@ -1721,6 +1721,62 @@ fn a_line_fits_when_its_baseline_does() {
|
||||
assert!(overset(&mut s, 50.0), "a baseline below the frame still oversets the line");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parent_resize_tool_previews_cancels_commits_and_undoes_on_the_selected_spread() {
|
||||
use designcraft_tools::{Mods, Overlay, PointerEvent, PointerKind};
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width":400,"height":600,"pages":3,"facingPages":true})).unwrap();
|
||||
s.execute("layout.parents.new", &json!({"prefix":"B"})).unwrap();
|
||||
s.execute("layout.parents.edit", &json!({"on":true})).unwrap();
|
||||
s.set_tool("page");
|
||||
let v = ViewInfo::at_zoom(1.0);
|
||||
let before = s.doc().unwrap().doc.clone();
|
||||
for si in 0..2 {
|
||||
for pi in 0..2 {
|
||||
let sr = designcraft_doc::SpreadRef::Parent(si);
|
||||
let rect = |s: &Session| s.layout().spread_page_rect(&s.doc().unwrap().doc, sr, pi).unwrap();
|
||||
let c = rect(&s).center();
|
||||
s.pointer(&PointerEvent::new(PointerKind::Down, c.x, c.y), v).unwrap();
|
||||
s.pointer(&PointerEvent::new(PointerKind::Up, c.x, c.y), v).unwrap();
|
||||
assert_eq!(s.doc().unwrap().selection.parent_page, Some((before.parents[si].id, pi)));
|
||||
assert_eq!(s.doc().unwrap().selection.page, None);
|
||||
assert!(s.overlays(v).iter().any(|o| matches!(o,Overlay::PageHandles(r) if *r==rect(&s))));
|
||||
for alt in [false, true] {
|
||||
let original = s.doc().unwrap().doc.clone();
|
||||
let undo = s.doc().unwrap().history.undo.len();
|
||||
let r = rect(&s);
|
||||
let p = designcraft_geom::Point::new(r.x1, r.center().y);
|
||||
let ev = |kind, x| PointerEvent::new(kind, x, p.y).with_mods(Mods { alt, ..Default::default() });
|
||||
s.pointer(&ev(PointerKind::Down, p.x), v).unwrap();
|
||||
s.pointer(&ev(PointerKind::Drag, p.x + 80.0), v).unwrap();
|
||||
assert_eq!(s.doc().unwrap().doc.parents[si].pages[pi].width, 480.0);
|
||||
assert_eq!(s.doc().unwrap().doc.spreads, before.spreads);
|
||||
assert_eq!(s.doc().unwrap().doc.parents[1 - si], before.parents[1 - si]);
|
||||
s.pointer(&ev(PointerKind::Up, p.x + 80.0), v).unwrap();
|
||||
if alt {
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1);
|
||||
assert_eq!(s.doc().unwrap().doc.parents[si].pages[pi].width, 480.0);
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().doc.parents, original.parents);
|
||||
s.execute("edit.redo", &json!({})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().doc.parents[si].pages[pi].width, 480.0);
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
} else {
|
||||
assert!(std::sync::Arc::ptr_eq(&s.doc().unwrap().doc, &original));
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), undo);
|
||||
}
|
||||
}
|
||||
let c = rect(&s).center();
|
||||
s.pointer(&PointerEvent::new(PointerKind::DoubleClick, c.x, c.y), v).unwrap();
|
||||
let req = std::mem::take(&mut s.ui_requests);
|
||||
assert!(
|
||||
matches!(req.last(),Some(crate::UiRequest::Dialog{id,params}) if id=="cmd:layout.parentPageSize" && params["parent"]==before.parents[si].id.0 && params["page"]==pi)
|
||||
);
|
||||
s.doc().unwrap().doc.check().unwrap();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Reset To Base with a new Based On: a partial rule edit resolves over the new parent, not the
|
||||
/// old one.
|
||||
#[test]
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
//! Where spreads sit on the canvas (the pasteboard): stacked vertically, spines aligned at x = 0,
|
||||
//! parents shown on their own canvas when a parent spread is being edited.
|
||||
|
||||
use designcraft_doc::{Document, SpreadRef};
|
||||
use designcraft_doc::{Document, Selection, SpreadRef};
|
||||
use designcraft_geom::{Affine, Point, Rect, Vec2};
|
||||
use serde::Serialize;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Serialize)]
|
||||
pub struct SpreadSlot {
|
||||
@@ -100,6 +101,31 @@ impl CanvasLayout {
|
||||
(m * Point::new(d.x, d.y)) - (m * Point::ORIGIN)
|
||||
}
|
||||
|
||||
/// The Page tool selection on the canvas currently shown.
|
||||
pub fn selected_page(&self, doc: &Document, selection: &Selection) -> Option<(SpreadRef, usize)> {
|
||||
let (sr, pi) = if self.slots.first().is_some_and(|s| matches!(s.spread, SpreadRef::Parent(_))) {
|
||||
let (id, pi) = selection.parent_page?;
|
||||
(SpreadRef::Parent(doc.parent_index(id)?), pi)
|
||||
} else {
|
||||
let (si, pi) = doc.page_loc(selection.page?)?;
|
||||
(SpreadRef::Doc(si), pi)
|
||||
};
|
||||
self.slot(sr)?;
|
||||
doc.spread(sr)?.pages.get(pi)?;
|
||||
Some((sr, pi))
|
||||
}
|
||||
|
||||
/// A page on the visible canvas, including parent pages.
|
||||
pub fn page_at(&self, doc: &Document, p: Point) -> Option<(SpreadRef, usize)> {
|
||||
self.slots
|
||||
.iter()
|
||||
.find_map(|slot| doc.spread(slot.spread)?.pages.iter().position(|pg| pg.bounds().contains(slot.to_spread(p))).map(|pi| (slot.spread, pi)))
|
||||
}
|
||||
|
||||
pub fn spread_page_rect(&self, doc: &Document, sr: SpreadRef, pi: usize) -> Option<Rect> {
|
||||
Some(self.slot(sr)?.xf.transform_rect_bbox(doc.spread(sr)?.pages.get(pi)?.bounds()))
|
||||
}
|
||||
|
||||
/// Canvas rect of a document page.
|
||||
pub fn page_rect(&self, doc: &Document, abs: usize) -> Option<Rect> {
|
||||
let (si, pi) = doc.page_loc(abs)?;
|
||||
@@ -107,6 +133,24 @@ impl CanvasLayout {
|
||||
}
|
||||
}
|
||||
|
||||
/// The same resize contract for panels, handles and the exact-size dialog.
|
||||
pub fn page_size_command(doc: &Document, sr: SpreadRef, page: usize, mut params: Value) -> Option<(&'static str, Value)> {
|
||||
let sp = doc.spread(sr)?;
|
||||
sp.pages.get(page)?;
|
||||
let command = match sr {
|
||||
SpreadRef::Doc(si) => {
|
||||
params["pages"] = json!([doc.first_page_of_spread(si) + page + 1]);
|
||||
"layout.pageSize"
|
||||
}
|
||||
SpreadRef::Parent(_) => {
|
||||
params["parent"] = json!(sp.id.0);
|
||||
params["page"] = json!(page);
|
||||
"layout.parentPageSize"
|
||||
}
|
||||
};
|
||||
Some((command, params))
|
||||
}
|
||||
|
||||
fn dist_y(r: Rect, y: f64) -> f64 {
|
||||
if y < r.y0 - SPREAD_GAP / 2.0 {
|
||||
r.y0 - y
|
||||
|
||||
+60
-23
@@ -305,7 +305,9 @@ fn liquid_control_points(cx: &ToolContext, r: designcraft_geom::Rect) -> [(Liqui
|
||||
/// A page handle being dragged.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
struct PageDrag {
|
||||
/// Absolute page index.
|
||||
spread: designcraft_doc::SpreadRef,
|
||||
parent: Option<designcraft_doc::SpreadId>,
|
||||
/// Page index within the spread.
|
||||
page: usize,
|
||||
/// Reference-point index of the handle (0–8, row-major; 4 is the centre and has none).
|
||||
handle: usize,
|
||||
@@ -353,6 +355,9 @@ impl PageTool {
|
||||
fn liquid_target(
|
||||
cx: &ToolContext,
|
||||
) -> Option<(designcraft_doc::ItemId, designcraft_doc::ObjectLiquid, designcraft_geom::Rect, designcraft_geom::Rect)> {
|
||||
if cx.layout.slots.first().is_some_and(|s| matches!(s.spread, designcraft_doc::SpreadRef::Parent(_))) {
|
||||
return None;
|
||||
}
|
||||
let page = cx.selection.page?;
|
||||
let [id] = cx.selection.items.as_slice() else { return None };
|
||||
let (si, _) = cx.doc.page_loc(page)?;
|
||||
@@ -372,17 +377,16 @@ impl PageTool {
|
||||
}
|
||||
|
||||
/// The selected page's handle under `p`.
|
||||
fn handle_at(&self, cx: &ToolContext, p: Point) -> Option<(usize, usize)> {
|
||||
let page = cx.selection.page?;
|
||||
let r = cx.layout.page_rect(cx.doc, page)?;
|
||||
page_handles(r).find(|(_, h)| (h.x - p.x).abs() <= cx.tol(5.0) && (h.y - p.y).abs() <= cx.tol(5.0)).map(|(i, _)| (page, i))
|
||||
fn handle_at(&self, cx: &ToolContext, p: Point) -> Option<(designcraft_doc::SpreadRef, usize, usize)> {
|
||||
let (sr, page) = cx.layout.selected_page(cx.doc, cx.selection)?;
|
||||
let r = cx.layout.spread_page_rect(cx.doc, sr, page)?;
|
||||
page_handles(r).find(|(_, h)| (h.x - p.x).abs() <= cx.tol(5.0) && (h.y - p.y).abs() <= cx.tol(5.0)).map(|(i, _)| (sr, page, i))
|
||||
}
|
||||
|
||||
/// The new page size for the drag at `p`: the handle's edges follow the pointer (Shift: in
|
||||
/// proportion).
|
||||
fn size_at(cx: &ToolContext, d: &PageDrag, p: Point, shift: bool) -> (f64, f64) {
|
||||
let si = cx.doc.page_loc(d.page).map_or(0, |l| l.0);
|
||||
let v = cx.layout.delta_to_spread(designcraft_doc::SpreadRef::Doc(si), p - d.start);
|
||||
let v = cx.layout.delta_to_spread(d.spread, p - d.start);
|
||||
let (col, row) = (d.handle % 3, d.handle / 3);
|
||||
let dx = match col {
|
||||
0 => -v.x,
|
||||
@@ -417,10 +421,21 @@ impl Tool for PageTool {
|
||||
fn pointer(&mut self, cx: &ToolContext, ev: &PointerEvent) -> Vec<Action> {
|
||||
match ev.kind {
|
||||
PointerKind::Down => {
|
||||
if let Some((page, handle)) = self.handle_at(cx, ev.pos) {
|
||||
let Some((si, pi)) = cx.doc.page_loc(page) else { return vec![] };
|
||||
let pg = &cx.doc.spreads[si].pages[pi];
|
||||
self.drag = Some(PageDrag { page, handle, start: ev.pos, cur: ev.pos, size: (pg.width, pg.height), alt: ev.mods.alt });
|
||||
if let Some((spread, page, handle)) = self.handle_at(cx, ev.pos) {
|
||||
let Some(pg) = cx.doc.spread(spread).and_then(|sp| sp.pages.get(page)) else { return vec![] };
|
||||
self.drag = Some(PageDrag {
|
||||
spread,
|
||||
parent: match spread {
|
||||
designcraft_doc::SpreadRef::Parent(si) => cx.doc.parents.get(si).map(|sp| sp.id),
|
||||
_ => None,
|
||||
},
|
||||
page,
|
||||
handle,
|
||||
start: ev.pos,
|
||||
cur: ev.pos,
|
||||
size: (pg.width, pg.height),
|
||||
alt: ev.mods.alt,
|
||||
});
|
||||
return vec![Action::Begin("Resize Page".into())];
|
||||
}
|
||||
// An object-based page's object: a click on a pin or lock toggles it.
|
||||
@@ -429,7 +444,18 @@ impl Tool for PageTool {
|
||||
}
|
||||
// A click selects the page under the pointer (none on the pasteboard), and on an
|
||||
// object-based page the object clicked too.
|
||||
let page = (0..cx.doc.page_count()).find(|p| cx.layout.page_rect(cx.doc, *p).is_some_and(|r| r.contains(ev.pos)));
|
||||
let target = cx.layout.page_at(cx.doc, ev.pos);
|
||||
if let Some((designcraft_doc::SpreadRef::Parent(si), page)) = target {
|
||||
let Some(sp) = cx.doc.parents.get(si) else { return vec![] };
|
||||
return vec![Action::Exec("layout.selectParentPage".into(), json!({"parent": sp.id.0, "page": page}))];
|
||||
}
|
||||
if cx.layout.slots.first().is_some_and(|s| matches!(s.spread, designcraft_doc::SpreadRef::Parent(_))) {
|
||||
return vec![Action::Exec("layout.selectParentPage".into(), json!({"parent": null}))];
|
||||
}
|
||||
let page = target.and_then(|(sr, pi)| match sr {
|
||||
designcraft_doc::SpreadRef::Doc(si) => Some(cx.doc.first_page_of_spread(si) + pi),
|
||||
_ => None,
|
||||
});
|
||||
let object = page
|
||||
.filter(|p| object_based(cx.doc, *p))
|
||||
.and_then(|p| cx.hit(ev.pos).map(|(_, id)| id).filter(|id| cx.doc.page_of_item(*id) == Some(p)));
|
||||
@@ -443,9 +469,21 @@ impl Tool for PageTool {
|
||||
let Some(d) = self.drag.as_mut() else { return vec![] };
|
||||
d.cur = ev.pos;
|
||||
d.alt |= ev.mods.alt;
|
||||
let d = *d;
|
||||
let mut d = *d;
|
||||
if let Some(id) = d.parent {
|
||||
let Some(si) = cx.doc.parent_index(id) else {
|
||||
self.drag = None;
|
||||
return vec![Action::Cancel];
|
||||
};
|
||||
d.spread = designcraft_doc::SpreadRef::Parent(si);
|
||||
}
|
||||
let (w, h) = Self::size_at(cx, &d, ev.pos, ev.mods.shift);
|
||||
vec![Action::Preview("layout.pageSize".into(), json!({"pages": [d.page + 1], "width": w, "height": h, "anchor": 8 - d.handle}))]
|
||||
let Some((cmd, params)) =
|
||||
crate::layout::page_size_command(cx.doc, d.spread, d.page, json!({"width": w, "height": h, "anchor": 8 - d.handle}))
|
||||
else {
|
||||
return vec![];
|
||||
};
|
||||
vec![Action::Preview(cmd.into(), params)]
|
||||
}
|
||||
PointerKind::Up => match self.drag.take() {
|
||||
// Alt-drag resizes; a plain drag previews the liquid rule and springs back.
|
||||
@@ -455,12 +493,12 @@ impl Tool for PageTool {
|
||||
},
|
||||
PointerKind::DoubleClick => {
|
||||
// Exact sizes: the Page Size dialog for the page.
|
||||
let Some(page) = (0..cx.doc.page_count()).find(|p| cx.layout.page_rect(cx.doc, *p).is_some_and(|r| r.contains(ev.pos))) else {
|
||||
let Some((sr, pi)) = cx.layout.page_at(cx.doc, ev.pos) else { return vec![] };
|
||||
let Some(pg) = cx.doc.spread(sr).and_then(|sp| sp.pages.get(pi)) else { return vec![] };
|
||||
let Some((cmd, params)) = crate::layout::page_size_command(cx.doc, sr, pi, json!({"width": pg.width, "height": pg.height})) else {
|
||||
return vec![];
|
||||
};
|
||||
let Some((si, pi)) = cx.doc.page_loc(page) else { return vec![] };
|
||||
let pg = &cx.doc.spreads[si].pages[pi];
|
||||
vec![Action::Dialog("cmd:layout.pageSize".into(), json!({"pages": [page + 1], "width": pg.width, "height": pg.height}))]
|
||||
vec![Action::Dialog(format!("cmd:{cmd}"), params)]
|
||||
}
|
||||
PointerKind::Move => {
|
||||
self.hover = Self::control_at(cx, ev.pos).map(|(_, _, c)| (c, ev.pos));
|
||||
@@ -469,10 +507,9 @@ impl Tool for PageTool {
|
||||
}
|
||||
}
|
||||
fn overlays(&self, cx: &ToolContext) -> Vec<Overlay> {
|
||||
let Some(page) = cx.selection.page else { return vec![] };
|
||||
let Some(r) = cx.layout.page_rect(cx.doc, page) else { return vec![] };
|
||||
let Some((si, pi)) = cx.doc.page_loc(page) else { return vec![] };
|
||||
let pg = &cx.doc.spreads[si].pages[pi];
|
||||
let Some((sr, pi)) = cx.layout.selected_page(cx.doc, cx.selection) else { return vec![] };
|
||||
let Some(r) = cx.layout.spread_page_rect(cx.doc, sr, pi) else { return vec![] };
|
||||
let Some(pg) = cx.doc.spread(sr).and_then(|sp| sp.pages.get(pi)) else { return vec![] };
|
||||
let mut v = vec![Overlay::PageHandles(r), Overlay::Tag { p: Point::new(r.x0, r.y0 - cx.tol(6.0)), text: rule_label(pg.liquid).into() }];
|
||||
if let Some((_, l, object, page)) = Self::liquid_target(cx) {
|
||||
v.push(Overlay::LiquidPins {
|
||||
@@ -501,7 +538,7 @@ impl Tool for PageTool {
|
||||
if self.drag.is_none() && Self::control_at(cx, p).is_some() {
|
||||
return Cursor::Hand;
|
||||
}
|
||||
let handle = self.drag.map(|d| d.handle).or_else(|| self.handle_at(cx, p).map(|(_, h)| h));
|
||||
let handle = self.drag.map(|d| d.handle).or_else(|| self.handle_at(cx, p).map(|(_, _, h)| h));
|
||||
match handle {
|
||||
Some(1 | 7) => Cursor::ResizeV,
|
||||
Some(3 | 5) => Cursor::ResizeH,
|
||||
|
||||
@@ -2077,6 +2077,41 @@ fn loaded_text_cursor_threads_into_a_frame_inside_a_group() {
|
||||
assert_eq!(t2.cursor(&cx2, q, Mods::default()), Cursor::NotAllowed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parent_resize_page_tool_targets_the_visible_parent() {
|
||||
let d = Document::new(&NewDocument { width: 400.0, height: 600.0, pages: 3, facing_pages: true, ..Default::default() });
|
||||
let (s, c, l) = (Selection::default(), Cache::new(), CanvasLayout::new(&d, true));
|
||||
let cx = ctx(&d, &s, &c, &l);
|
||||
let p = l.to_canvas(SpreadRef::Parent(0), d.parents[0].pages[0].bounds().center());
|
||||
let mut t = create("page");
|
||||
let a = t.pointer(&cx, &PointerEvent::new(PointerKind::DoubleClick, p.x, p.y));
|
||||
assert_eq!(
|
||||
a,
|
||||
vec![Action::Dialog(
|
||||
"cmd:layout.parentPageSize".into(),
|
||||
serde_json::json!({"parent": d.parents[0].id.0, "page": 0, "width": 400.0, "height": 600.0})
|
||||
)],
|
||||
"double click must target the visible parent instead of document page zero"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parent_resize_selection_tracks_ids_and_never_crosses_canvas_modes() {
|
||||
let mut d = Document::new(&NewDocument::default());
|
||||
let second = d.add_parent("B", "Parent", 1, 12.0, designcraft_doc::Margins::uniform(36.0));
|
||||
let selection = Selection { page: Some(0), parent_page: Some((second, 1)), ..Default::default() };
|
||||
let l = CanvasLayout::new(&d, true);
|
||||
assert_eq!(l.selected_page(&d, &selection), Some((SpreadRef::Parent(1), 1)));
|
||||
d.parents.swap(0, 1);
|
||||
let l = CanvasLayout::new(&d, true);
|
||||
assert_eq!(l.selected_page(&d, &selection), Some((SpreadRef::Parent(0), 1)));
|
||||
assert_eq!(CanvasLayout::new(&d, false).selected_page(&d, &selection), Some((SpreadRef::Doc(0), 0)));
|
||||
d.parents.remove(0);
|
||||
assert_eq!(CanvasLayout::new(&d, true).selected_page(&d, &selection), None);
|
||||
let selection = Selection { page: Some(0), ..Default::default() };
|
||||
assert_eq!(CanvasLayout::new(&d, true).selected_page(&d, &selection), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn direct_selection_click_on_one_of_several_selected_frames_selects_only_it() {
|
||||
let mut d = Document::new(&NewDocument::default());
|
||||
|
||||
@@ -62,7 +62,7 @@ pub(crate) use view::page_in_view;
|
||||
use view::present_fit;
|
||||
pub use view::{
|
||||
PageBox, apply_view_request, can_go_through_history, current_page, current_parent_page, current_slot, current_spread, current_spread_page,
|
||||
drop_point, fit, go_through_history, go_to_page, page_box, page_box_go, set_zoom, show_spread, step_spread, view_centre, zoom_at,
|
||||
drop_point, fit, go_through_history, go_to_page, page_box, page_box_go, page_target, set_zoom, show_spread, step_spread, view_centre, zoom_at,
|
||||
};
|
||||
|
||||
pub const RULER: f32 = 15.0;
|
||||
|
||||
@@ -113,7 +113,7 @@ pub fn current_page(app: &DesignApp) -> Option<usize> {
|
||||
let layout = CanvasLayout::new(&st.doc, st.editing_parents);
|
||||
let i = current_slot(app, &layout)?;
|
||||
let slot = layout.slots.get(i)?;
|
||||
let SpreadRef::Doc(si) = slot.spread else { return Some(0) };
|
||||
let SpreadRef::Doc(si) = slot.spread else { return None };
|
||||
let v = app.view()?;
|
||||
let r = app.canvas_rect?;
|
||||
let c = slot.to_spread(Point::new(v.origin.x + r.width() as f64 / 2.0 / v.zoom, slot.bounds.center().y));
|
||||
@@ -121,6 +121,16 @@ pub fn current_page(app: &DesignApp) -> Option<usize> {
|
||||
Some(st.doc.first_page_of_spread(si) + pi)
|
||||
}
|
||||
|
||||
/// The Page tool selection, or the page in view, without crossing canvas modes.
|
||||
pub fn page_target(app: &DesignApp) -> Option<(SpreadRef, usize)> {
|
||||
let st = app.session.active()?;
|
||||
let selected = CanvasLayout::new(&st.doc, st.editing_parents).selected_page(&st.doc, &st.selection);
|
||||
if st.editing_parents && st.selection.parent_page.is_some() {
|
||||
return selected;
|
||||
}
|
||||
selected.or_else(|| current_spread_page(app))
|
||||
}
|
||||
|
||||
/// The spread (document or parent) under the view centre and the page index within it: where
|
||||
/// commands that act "on the page I am working on" should go.
|
||||
pub fn current_spread_page(app: &DesignApp) -> Option<(SpreadRef, usize)> {
|
||||
@@ -135,22 +145,10 @@ pub fn current_spread_page(app: &DesignApp) -> Option<(SpreadRef, usize)> {
|
||||
Some((slot.spread, pg))
|
||||
}
|
||||
|
||||
/// While parent spreads are being edited: (parent spread index, page index within it) under the
|
||||
/// view centre. `None` when document pages are shown.
|
||||
/// The selected parent page, or the parent page under the view centre.
|
||||
pub fn current_parent_page(app: &DesignApp) -> Option<(usize, usize)> {
|
||||
let st = app.session.active()?;
|
||||
if !st.editing_parents {
|
||||
return None;
|
||||
}
|
||||
let layout = CanvasLayout::new(&st.doc, true);
|
||||
let i = current_slot(app, &layout)?;
|
||||
let slot = layout.slots.get(i)?;
|
||||
let SpreadRef::Parent(pi) = slot.spread else { return None };
|
||||
let v = app.view()?;
|
||||
let r = app.canvas_rect?;
|
||||
let c = slot.to_spread(Point::new(v.origin.x + r.width() as f64 / 2.0 / v.zoom, slot.bounds.center().y));
|
||||
let qi = st.doc.parents.get(pi)?.page_at_x(c.x).unwrap_or(0);
|
||||
Some((pi, qi))
|
||||
let (SpreadRef::Parent(si), pi) = page_target(app)? else { return None };
|
||||
Some((si, pi))
|
||||
}
|
||||
|
||||
/// The page box's entries (the status bar's, and Properties › Page), as InDesign's: the
|
||||
|
||||
@@ -34,25 +34,34 @@ pub fn control_bar(app: &mut DesignApp, ui: &mut egui::Ui) {
|
||||
}
|
||||
|
||||
/// Page-size preset menu and the portrait / landscape toggles of the Control panel.
|
||||
fn page_preset_and_orientation(app: &mut DesignApp, ui: &mut egui::Ui, lang: &str, size: (f64, f64), n: usize, anchor: u8) {
|
||||
fn page_preset_and_orientation(
|
||||
app: &mut DesignApp,
|
||||
ui: &mut egui::Ui,
|
||||
lang: &str,
|
||||
size: (f64, f64),
|
||||
anchor: u8,
|
||||
resize: impl Fn(&mut DesignApp, serde_json::Value),
|
||||
) {
|
||||
let landscape = size.0 > size.1;
|
||||
ui.vertical(|ui| {
|
||||
let cur = panels::properties::preset_name(size.0, size.1);
|
||||
craft_ui::ComboBox::from_id_salt("ctl_page_preset").selected_text(crate::rtl::widget(ui, crate::i18n::tr(lang, cur))).width(130.0).show_ui(
|
||||
ui,
|
||||
|ui| {
|
||||
craft_ui::ComboBox::from_id_salt("ctl_page_preset")
|
||||
.selected_text(crate::rtl::widget(ui, crate::i18n::tr(lang, cur)))
|
||||
.width(130.0)
|
||||
.show_ui(ui, |ui| {
|
||||
for (name, w, h) in panels::properties::PAGE_PRESETS {
|
||||
if ui.selectable_label(cur == *name, crate::rtl::widget(ui, crate::i18n::tr(lang, name))).clicked() {
|
||||
let (w, h) = if landscape { (h, w) } else { (w, h) };
|
||||
let _ = app.run("layout.pageSize", json!({"pages": [n], "width": w, "height": h, "anchor": anchor}));
|
||||
resize(app, json!({"width": w, "height": h, "anchor": anchor}));
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
})
|
||||
.response
|
||||
.accessible_name(crate::i18n::tr(lang, "Page Size"));
|
||||
ui.horizontal(|ui| {
|
||||
for (icon, tip, want) in [("orient-portrait", "Portrait", false), ("orient-landscape", "Landscape", true)] {
|
||||
if crate::widgets::icon_toggle(ui, icon, landscape == want, crate::i18n::tr(lang, tip)).clicked() && landscape != want {
|
||||
let _ = app.run("layout.pageSize", json!({"pages": [n], "width": size.1, "height": size.0, "anchor": anchor}));
|
||||
resize(app, json!({"width": size.1, "height": size.0, "anchor": anchor}));
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -62,16 +71,24 @@ fn page_preset_and_orientation(app: &mut DesignApp, ui: &mut egui::Ui, lang: &st
|
||||
/// The Control panel with the Page tool, as InDesign's: reference point, X/Y, W/H, page-size
|
||||
/// preset, orientation, Liquid Page Rule and Show Parent Page Overlay, for
|
||||
/// the page the Page tool selected (else the page in view). W/H keep the reference point in place.
|
||||
fn control_page(app: &mut DesignApp, ui: &mut egui::Ui) {
|
||||
pub(super) fn control_page(app: &mut DesignApp, ui: &mut egui::Ui) {
|
||||
let lang = app.ui.language.clone();
|
||||
let Some(st) = app.session.active() else { return };
|
||||
let units = st.doc.settings.horizontal_units;
|
||||
let Some(page) = st.selection.page.or_else(|| crate::canvas::current_page(app)) else { return };
|
||||
let Some((spread, page)) = crate::canvas::page_target(app) else { return };
|
||||
let Some(st) = app.session.active() else { return };
|
||||
let d = st.doc.clone();
|
||||
let Some((si, pi)) = d.page_loc(page) else { return };
|
||||
let pg = d.spreads[si].pages[pi].clone();
|
||||
let n = page + 1;
|
||||
let Some(sp) = d.spread(spread) else { return };
|
||||
let Some(pg) = sp.pages.get(page).cloned() else { return };
|
||||
let n = match spread {
|
||||
designcraft_doc::SpreadRef::Doc(si) => Some(d.first_page_of_spread(si) + page + 1),
|
||||
_ => None,
|
||||
};
|
||||
let resize = |app: &mut DesignApp, params| {
|
||||
if let Some((cmd, params)) = designcraft_tools::layout::page_size_command(&d, spread, page, params) {
|
||||
let _ = app.run(cmd, params);
|
||||
}
|
||||
};
|
||||
// Reference point: W/H edits keep it in place.
|
||||
let (r, _) = ui.allocate_exact_size(vec2(30.0, 44.0), Sense::hover());
|
||||
if let Some(p) = panels::properties::ref_point_proxy(ui, egui::Rect::from_center_size(r.center(), vec2(27.0, 27.0)), app.ui.ref_point) {
|
||||
@@ -80,46 +97,53 @@ fn control_page(app: &mut DesignApp, ui: &mut egui::Ui) {
|
||||
let anchor = app.ui.ref_point.min(8);
|
||||
let (fx, fy) = ((anchor % 3) as f64 / 2.0, (anchor / 3) as f64 / 2.0);
|
||||
// The reference point's position: from the spine across, from the top down.
|
||||
let (x, y) = (pg.x - d.spreads[si].spine_x() + fx * pg.width, fy * pg.height);
|
||||
let (x, y) = (pg.x - sp.spine_x() + fx * pg.width, fy * pg.height);
|
||||
egui::Grid::new("ctl_page_xywh").num_columns(4).spacing(vec2(4.0, 4.0)).show(ui, |ui| {
|
||||
caption(ui, crate::i18n::tr(&lang, "X:"));
|
||||
// Pages don't move within their spread yet.
|
||||
ui.add_enabled_ui(false, |ui| measure(ui, "cpx", Some(x), units, 64.0));
|
||||
caption(ui, crate::i18n::tr(&lang, "W:"));
|
||||
if let Some(v) = measure(ui, "cpw", Some(pg.width), units, 64.0) {
|
||||
let _ = app.run("layout.pageSize", json!({"pages": [n], "width": v, "anchor": anchor}));
|
||||
resize(app, json!({"width": v, "anchor": anchor}));
|
||||
}
|
||||
ui.end_row();
|
||||
caption(ui, crate::i18n::tr(&lang, "Y:"));
|
||||
ui.add_enabled_ui(false, |ui| measure(ui, "cpy", Some(y), units, 64.0));
|
||||
caption(ui, crate::i18n::tr(&lang, "H:"));
|
||||
if let Some(v) = measure(ui, "cph", Some(pg.height), units, 64.0) {
|
||||
let _ = app.run("layout.pageSize", json!({"pages": [n], "height": v, "anchor": anchor}));
|
||||
resize(app, json!({"height": v, "anchor": anchor}));
|
||||
}
|
||||
ui.end_row();
|
||||
});
|
||||
vsep(ui);
|
||||
// Page-size preset and orientation.
|
||||
page_preset_and_orientation(app, ui, &lang, (pg.width, pg.height), n, anchor);
|
||||
page_preset_and_orientation(app, ui, &lang, (pg.width, pg.height), anchor, resize);
|
||||
vsep(ui);
|
||||
ui.vertical(|ui| {
|
||||
ui.add_enabled_ui(n.is_some(), |ui| {
|
||||
caption(ui, crate::i18n::tr(&lang, "Liquid Page Rule:"));
|
||||
let label = panels::interactive::liquid_rule_label(pg.liquid);
|
||||
craft_ui::ComboBox::from_id_salt("ctl_page_rule").selected_text(crate::rtl::widget(ui, crate::i18n::tr(&lang, label))).width(150.0).show_ui(
|
||||
ui,
|
||||
|ui| {
|
||||
craft_ui::ComboBox::from_id_salt("ctl_page_rule")
|
||||
.selected_text(crate::rtl::widget(ui, crate::i18n::tr(&lang, label)))
|
||||
.width(150.0)
|
||||
.show_ui(ui, |ui| {
|
||||
for (r, l) in panels::interactive::LIQUID_RULES {
|
||||
if ui.selectable_label(pg.liquid == r, crate::rtl::widget(ui, crate::i18n::tr(&lang, l))).clicked() && pg.liquid != r {
|
||||
if ui.selectable_label(pg.liquid == r, crate::rtl::widget(ui, crate::i18n::tr(&lang, l))).clicked()
|
||||
&& pg.liquid != r
|
||||
&& let Some(n) = n
|
||||
{
|
||||
let _ = app.run("liquid.pageRule", json!({"rule": r, "pages": [n]}));
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
})
|
||||
.response
|
||||
.accessible_name(crate::i18n::tr(&lang, "Liquid Page Rule:"));
|
||||
});
|
||||
vsep(ui);
|
||||
ui.vertical(|ui| {
|
||||
ui.add_enabled_ui(n.is_some(), |ui| {
|
||||
let mut overlay = pg.show_parent_overlay;
|
||||
if ui.checkbox(&mut overlay, crate::rtl::widget(ui, crate::i18n::tr(&lang, "Show Parent Page Overlay"))).changed() {
|
||||
if ui.checkbox(&mut overlay, crate::rtl::widget(ui, crate::i18n::tr(&lang, "Show Parent Page Overlay"))).changed()
|
||||
&& let Some(n) = n
|
||||
{
|
||||
let _ = app.run("layout.pageOptions", json!({"pages": [n], "showParentOverlay": overlay}));
|
||||
}
|
||||
});
|
||||
|
||||
@@ -515,7 +515,7 @@ fn the_page_tool_has_its_own_control_panel() {
|
||||
app.run("file.new", serde_json::json!({"width": 252, "height": 144, "facingPages": false})).unwrap();
|
||||
app.session.set_tool("page");
|
||||
let mut h = test_window::open(app, vec2(1800.0, 600.0));
|
||||
let _ = h.get_by_label("Liquid Page Rule:");
|
||||
let _ = h.get_by_role_and_label(egui::accesskit::Role::ComboBox, "Liquid Page Rule:");
|
||||
// Pages don't move within their spread, so there is no "Objects Move with Page" option, and
|
||||
// no "Controlled by Parent" rule until parent pages have rules of their own.
|
||||
assert!(h.query_by_label("Objects Move with Page").is_none());
|
||||
@@ -911,3 +911,144 @@ fn caption_buttons_fit_the_minimum_window_width() {
|
||||
assert!(!h.click(p).is_empty(), "{id} remains clickable");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parent_resize_properties_keep_size_and_inline_parent_margins() {
|
||||
parent_resize_panel(true);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parent_resize_control_changes_the_parent_not_document_page_zero() {
|
||||
parent_resize_panel(false);
|
||||
}
|
||||
|
||||
fn parent_resize_panel(properties: bool) {
|
||||
use egui_kittest::Harness;
|
||||
{
|
||||
let mut app = app();
|
||||
app.run("file.new", serde_json::json!({"width": 400, "height": 600, "pages": 3, "facingPages": true})).unwrap();
|
||||
app.run("layout.parents.new", serde_json::json!({"prefix": "B"})).unwrap();
|
||||
app.run("layout.pages.applyParent", serde_json::json!({"pages": [0], "parent": "A"})).unwrap();
|
||||
if properties {
|
||||
app.run("layout.marginsAndColumns", json!({"parent": 1, "pages": [0], "margins": 24})).unwrap();
|
||||
app.run(
|
||||
"layout.marginsAndColumns",
|
||||
json!({"parent": 1, "pages": [1], "margins": {"top": 36, "bottom": 48, "inside": 60, "outside": 72}}),
|
||||
)
|
||||
.unwrap();
|
||||
app.run("layout.pages.applyParent", json!({"pages": [0, 1, 2], "parent": "B"})).unwrap();
|
||||
app.run("layout.marginsAndColumns", json!({"pages": [0], "margins": 90})).unwrap();
|
||||
}
|
||||
app.run("layout.parents.edit", serde_json::json!({"on": true, "parent": "A"})).unwrap();
|
||||
app.session.set_tool("page");
|
||||
app.canvas_rect = Some(egui::Rect::from_min_size(egui::Pos2::ZERO, vec2(800.0, 600.0)));
|
||||
*app.view_mut().unwrap() = crate::View { zoom: 1.0, origin: designcraft_geom::Point::new(-600.0, 0.0), fitted: true, rotation: 0 };
|
||||
let before = app.session.doc().unwrap().doc.clone();
|
||||
let mut h = Harness::builder().with_size(vec2(1600.0, 900.0)).build_ui_state(
|
||||
move |ui, app: &mut DesignApp| {
|
||||
let id = egui::Id::unique("parent_resize_fonts");
|
||||
if ui.data(|d| d.get_temp::<bool>(id)).is_none() {
|
||||
crate::theme::install_fonts(ui.ctx(), "");
|
||||
ui.data_mut(|d| d.insert_temp(id, true));
|
||||
return;
|
||||
}
|
||||
if properties {
|
||||
panels::properties::show(app, ui);
|
||||
} else {
|
||||
super::control_bar::control_page(app, ui);
|
||||
}
|
||||
},
|
||||
app,
|
||||
);
|
||||
h.run_steps(3);
|
||||
edit_parent_resize_field(&mut h, "33p4", "500 pt");
|
||||
let d = &h.state().session.doc().unwrap().doc;
|
||||
assert_eq!(d.parents[0].pages[0].width, 500.0, "the parent page in view must change");
|
||||
assert_eq!(d.spreads, before.spreads);
|
||||
assert_eq!(d.parents[1], before.parents[1]);
|
||||
let b = before.parents[1].id.0;
|
||||
h.state_mut().run("layout.selectParentPage", json!({"parent": b, "page": 1})).unwrap();
|
||||
h.run_steps(3);
|
||||
assert_eq!(crate::canvas::current_parent_page(h.state()), Some((1, 1)));
|
||||
if properties {
|
||||
h.state_mut().session.set_tool("selection");
|
||||
}
|
||||
h.run_steps(3);
|
||||
edit_parent_resize_field(&mut h, "50p0", "700 pt");
|
||||
edit_parent_resize_field(&mut h, "33p4", "560 pt");
|
||||
let d = &h.state().session.doc().unwrap().doc;
|
||||
assert_eq!((d.parents[1].pages[1].width, d.parents[1].pages[1].height), (560.0, 700.0));
|
||||
assert_eq!(d.parents[1].pages[0].width, 400.0);
|
||||
assert_eq!(d.parents[0].pages[0].width, 500.0);
|
||||
assert_eq!(d.spreads, before.spreads);
|
||||
let parents = d.parents.clone();
|
||||
if properties {
|
||||
let saved = d.clone();
|
||||
check_parent_margin_edits(&mut h);
|
||||
h.get_by_label("Back").click();
|
||||
h.run_steps(3);
|
||||
assert!(!h.state().session.doc().unwrap().editing_parents, "Back returns to document properties");
|
||||
assert_eq!(h.state().session.tool_id(), "selection");
|
||||
assert!(h.query_by_label("Back").is_none(), "the next frame must leave the page properties screen");
|
||||
assert!(std::sync::Arc::ptr_eq(&saved, &h.state().session.doc().unwrap().doc));
|
||||
}
|
||||
h.state_mut().run("layout.parents.edit", json!({"on": false})).unwrap();
|
||||
h.state_mut().session.set_tool("page");
|
||||
h.state_mut().run("layout.selectPage", json!({"page": 1})).unwrap();
|
||||
h.run_steps(3);
|
||||
edit_parent_resize_field(&mut h, "33p4", "530 pt");
|
||||
let d = &h.state().session.doc().unwrap().doc;
|
||||
assert_eq!(d.spreads[0].pages[0].width, 530.0);
|
||||
assert_eq!(d.parents, parents);
|
||||
if properties {
|
||||
let saved = d.clone();
|
||||
h.get_by_label("Back").click();
|
||||
h.run_steps(3);
|
||||
assert_eq!(h.state().session.tool_id(), "selection");
|
||||
assert!(h.query_by_label("Back").is_none());
|
||||
assert_eq!(h.state().session.doc().unwrap().selection.page, None);
|
||||
assert!(std::sync::Arc::ptr_eq(&saved, &h.state().session.doc().unwrap().doc));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn check_parent_margin_edits(h: &mut egui_kittest::Harness<'_, DesignApp>) {
|
||||
let saved = h.state().session.doc().unwrap().doc.clone();
|
||||
assert_eq!(h.get_by_role_and_label(egui::accesskit::Role::TextInput, "Top").value().as_deref(), Some("3p0"));
|
||||
edit_parent_resize_field(h, "3p0", "72 pt");
|
||||
let edited = &h.state().session.doc().unwrap().doc;
|
||||
assert_eq!(edited.parents[1].pages[1].margins, designcraft_doc::Margins::uniform(72.0));
|
||||
assert_eq!(edited.parents[1].pages[0], saved.parents[1].pages[0]);
|
||||
assert_eq!(edited.parents[0], saved.parents[0]);
|
||||
assert_eq!(edited.page(0), saved.page(0), "local document margins stay unchanged");
|
||||
assert_eq!(edited.page(1), saved.page(1), "the other parent page's inheritor stays unchanged");
|
||||
assert_eq!(edited.page(2).unwrap().margins, designcraft_doc::Margins::uniform(72.0));
|
||||
assert_eq!(edited.settings, saved.settings);
|
||||
h.state_mut().run("edit.undo", json!({})).unwrap();
|
||||
h.run_steps(3);
|
||||
assert_eq!(h.state().session.doc().unwrap().doc, saved);
|
||||
h.get_by_label("Make all settings the same").click();
|
||||
h.run_steps(3);
|
||||
edit_parent_resize_field(h, "3p0", "84 pt");
|
||||
let edited = &h.state().session.doc().unwrap().doc;
|
||||
let margins = designcraft_doc::Margins { top: 84.0, bottom: 48.0, inside: 60.0, outside: 72.0 };
|
||||
assert_eq!(edited.parents[1].pages[1].margins, margins);
|
||||
assert_eq!(edited.parents[1].pages[0], saved.parents[1].pages[0]);
|
||||
assert_eq!(edited.parents[0], saved.parents[0]);
|
||||
assert_eq!(edited.page(0), saved.page(0));
|
||||
assert_eq!(edited.page(1), saved.page(1));
|
||||
assert_eq!(edited.page(2).unwrap().margins, margins);
|
||||
assert_eq!(edited.settings, saved.settings);
|
||||
h.state_mut().run("edit.undo", json!({})).unwrap();
|
||||
h.run_steps(3);
|
||||
assert_eq!(h.state().session.doc().unwrap().doc, saved);
|
||||
}
|
||||
|
||||
fn edit_parent_resize_field(h: &mut egui_kittest::Harness<'_, DesignApp>, value: &str, input: &str) {
|
||||
h.query_all_by(|n| n.role() == egui::accesskit::Role::TextInput && n.value().as_deref() == Some(value)).next().expect("page size field").click();
|
||||
h.run_steps(2);
|
||||
h.event(egui::Event::Key { key: egui::Key::A, physical_key: None, pressed: true, repeat: false, modifiers: egui::Modifiers::COMMAND });
|
||||
h.event(egui::Event::Text(input.into()));
|
||||
h.key_press(egui::Key::Enter);
|
||||
h.run_steps(3);
|
||||
}
|
||||
@@ -419,7 +419,12 @@ fn handle_request(app: &mut DesignApp, ctx: &egui::Context, req: &ControlRequest
|
||||
}
|
||||
"ui.render" => {
|
||||
let Some(st) = app.session.active() else { return err("no document") };
|
||||
let page = match page_index(p, crate::canvas::current_page(app).unwrap_or(0), st.doc.page_count()) {
|
||||
let current = match crate::canvas::current_page(app) {
|
||||
Some(page) => page,
|
||||
None if !st.editing_parents || p.get("page").is_some() => 0,
|
||||
None => return err("select a document page or give an explicit page"),
|
||||
};
|
||||
let page = match page_index(p, current, st.doc.page_count()) {
|
||||
Ok(page) => page,
|
||||
Err(e) => return err(e),
|
||||
};
|
||||
@@ -689,6 +694,83 @@ mod automation_contract_tests {
|
||||
assert_eq!(app.ui.palette.as_deref(), Some("field"));
|
||||
assert_eq!(app.session.execute("story.get", &json!({"story":f["story"]})).unwrap()["text"], "Hello");
|
||||
}
|
||||
#[test]
|
||||
fn parent_resize_no_canvas_render_keeps_document_page_zero_default() {
|
||||
let mut app = app();
|
||||
let ctx = egui::Context::default();
|
||||
app.run("layout.pageSize", json!({"pages": [2], "width": 700})).unwrap();
|
||||
assert!(app.canvas_rect.is_none());
|
||||
let before = app.session.doc().unwrap().doc.clone();
|
||||
let omitted = call(&mut app, &ctx, "ui.render", json!({"scale": 0.1}));
|
||||
assert_eq!(omitted["ok"], true, "{omitted}");
|
||||
let first = call(&mut app, &ctx, "ui.render", json!({"page": 0, "scale": 0.1}));
|
||||
let second = call(&mut app, &ctx, "ui.render", json!({"page": 1, "scale": 0.1}));
|
||||
assert_eq!(omitted, first);
|
||||
assert_ne!(omitted, second);
|
||||
assert_eq!(app.session.doc().unwrap().doc, before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parent_resize_no_canvas_delete_uses_first_document_page() {
|
||||
let mut app = app();
|
||||
assert!(app.canvas_rect.is_none());
|
||||
let before = app.session.doc().unwrap().doc.clone();
|
||||
app.run("app.deletePage", json!({})).unwrap();
|
||||
let after = &app.session.doc().unwrap().doc;
|
||||
assert_eq!(after.page_count(), 1);
|
||||
assert_eq!(after.page(0).unwrap().id, before.page(1).unwrap().id);
|
||||
assert_eq!(after.parents, before.parents);
|
||||
assert!(app.ui.dialog.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parent_resize_no_canvas_delete_keeps_the_object_warning() {
|
||||
let mut app = app();
|
||||
app.run("frame.create", json!({"rect": [36, 36, 100, 100]})).unwrap();
|
||||
assert!(app.canvas_rect.is_none());
|
||||
let before = app.session.doc().unwrap().doc.clone();
|
||||
app.run("app.deletePage", json!({})).unwrap();
|
||||
let dialog = app.ui.dialog.as_ref().unwrap();
|
||||
assert_eq!(dialog.id, "deletePages");
|
||||
assert_eq!(dialog.fields["pages"], json!([0]));
|
||||
assert_eq!(app.session.doc().unwrap().doc, before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parent_resize_no_canvas_duplicate_uses_first_document_spread() {
|
||||
let mut app = app();
|
||||
assert!(app.canvas_rect.is_none());
|
||||
let before = app.session.doc().unwrap().doc.clone();
|
||||
let ids: Vec<_> = before.spreads.iter().flat_map(|sp| sp.pages.iter().map(|p| p.id)).collect();
|
||||
let result = app.run("app.duplicateSpread", json!({})).unwrap();
|
||||
let after = &app.session.doc().unwrap().doc;
|
||||
let first = result["page"].as_u64().unwrap() as usize;
|
||||
assert_eq!(first, before.spreads[0].pages.len());
|
||||
assert_eq!(after.page_count(), before.page_count() + first);
|
||||
for i in first..first * 2 {
|
||||
assert!(!ids.contains(&after.page(i).unwrap().id));
|
||||
}
|
||||
let retained: Vec<_> = after.spreads.iter().flat_map(|sp| sp.pages.iter().map(|p| p.id)).filter(|id| ids.contains(id)).collect();
|
||||
assert_eq!(retained, ids);
|
||||
assert_eq!(after.parents, before.parents);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parent_resize_mode_does_not_default_document_actions_to_page_zero() {
|
||||
for canvas in [None, Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1400.0, 900.0)))] {
|
||||
let mut app = app();
|
||||
let ctx = egui::Context::default();
|
||||
app.canvas_rect = canvas;
|
||||
app.run("layout.parents.edit", json!({"on":true,"parent":"A"})).unwrap();
|
||||
let before = app.session.doc().unwrap().doc.clone();
|
||||
assert!(app.run("app.deletePage", json!({})).is_err());
|
||||
assert!(app.run("app.duplicateSpread", json!({})).is_err());
|
||||
assert_eq!(call(&mut app, &ctx, "ui.render", json!({"scale":0.1}))["ok"], false);
|
||||
assert_eq!(call(&mut app, &ctx, "ui.render", json!({"page":0,"scale":0.1}))["ok"], true);
|
||||
assert_eq!(app.session.doc().unwrap().doc, before);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn control_pages_validate_before_writer_and_preserve_omission() {
|
||||
let mut app = app();
|
||||
|
||||
@@ -4931,3 +4931,33 @@ fn frame_size_dialog_keeps_its_units_while_open() {
|
||||
let b = added[0].bounds();
|
||||
assert!((b.width() - 144.0).abs() < 0.01 && (b.height() - 216.0).abs() < 0.01, "{b:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parent_resize_exact_dialog_keeps_its_target_and_rejects_invalid_input() {
|
||||
use designcraft_tools::{PointerEvent, PointerKind};
|
||||
let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default());
|
||||
app.run("file.new", json!({"width":400,"height":600,"pages":3,"facingPages":true})).unwrap();
|
||||
app.run("layout.parents.new", json!({"prefix":"B"})).unwrap();
|
||||
app.run("layout.parents.edit", json!({"on":true})).unwrap();
|
||||
app.session.set_tool("page");
|
||||
let before = app.session.doc().unwrap().doc.clone();
|
||||
let sr = designcraft_doc::SpreadRef::Parent(1);
|
||||
let p = app.session.layout().spread_page_rect(&before, sr, 1).unwrap().center();
|
||||
app.session.pointer(&PointerEvent::new(PointerKind::DoubleClick, p.x, p.y), designcraft_engine::ViewInfo::at_zoom(1.0)).unwrap();
|
||||
let request = app.session.ui_requests.pop().unwrap();
|
||||
let designcraft_engine::UiRequest::Dialog { id, params } = request else { panic!("expected exact-size dialog") };
|
||||
app.ui.dialog = Some(Dialog::new(&id, params));
|
||||
let d = app.ui.dialog.as_mut().unwrap();
|
||||
d.fields.insert("width".into(), json!("530"));
|
||||
d.fields.insert("height".into(), json!("710"));
|
||||
confirm(&mut app).unwrap();
|
||||
let doc = &app.session.doc().unwrap().doc;
|
||||
assert_eq!((doc.parents[1].pages[1].width, doc.parents[1].pages[1].height), (530.0, 710.0));
|
||||
assert_eq!(doc.parents[0], before.parents[0]);
|
||||
assert_eq!(doc.spreads, before.spreads);
|
||||
let saved = doc.clone();
|
||||
app.ui.dialog = Some(Dialog::new("cmd:layout.parentPageSize", json!({"parent":saved.parents[1].id.0,"page":1,"width":"NaN"})));
|
||||
assert!(confirm(&mut app).is_err());
|
||||
assert!(app.ui.dialog.is_some());
|
||||
assert!(std::sync::Arc::ptr_eq(&saved, &app.session.doc().unwrap().doc));
|
||||
}
|
||||
@@ -462,7 +462,11 @@ fn run_ui_part2(app: &mut DesignApp, id: &str, p: &Value) -> Option<Result<Value
|
||||
}
|
||||
"app.deletePage" | "app.duplicateSpread" => {
|
||||
let Some(st) = app.session.active() else { return Some(Err("no document open".into())) };
|
||||
let page = crate::canvas::current_page(app).unwrap_or(0);
|
||||
let page = match crate::canvas::current_page(app) {
|
||||
Some(page) => page,
|
||||
None if !st.editing_parents => 0,
|
||||
None => return Some(Err("select a document page".into())),
|
||||
};
|
||||
let r = if id == "app.deletePage" {
|
||||
crate::dialogs::delete_pages(app, vec![page])
|
||||
} else {
|
||||
|
||||
@@ -147,12 +147,11 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) {
|
||||
return;
|
||||
}
|
||||
// The Page tool with no objects selected: the page's own settings, as InDesign's.
|
||||
if info.is_none() && app.session.tool_id() == "page" {
|
||||
let page = app.session.active().and_then(|st| st.selection.page).or_else(|| crate::canvas::current_page(app));
|
||||
if let Some(page) = page {
|
||||
page_sections(app, ui, page);
|
||||
return;
|
||||
if info.is_none() && (app.session.tool_id() == "page" || app.session.active().is_some_and(|st| st.editing_parents)) {
|
||||
if let Some((spread, page)) = crate::canvas::page_target(app) {
|
||||
page_sections(app, ui, spread, page);
|
||||
}
|
||||
return;
|
||||
}
|
||||
let Some(i) = info else {
|
||||
document_sections(app, ui);
|
||||
|
||||
@@ -59,15 +59,14 @@ fn page_preset_row(ui: &mut Ui, lang: &str, w: f64, h: f64) -> Option<(f64, f64)
|
||||
|
||||
/// Properties with the Page tool (Properties › Edit Page), as InDesign's: a "Page" band with Back,
|
||||
/// Page Size for this page alone (which page, preset, orientation, W/H), Margins and Columns and
|
||||
/// Quick Actions. Back returns to the Selection tool and the document's properties.
|
||||
pub(super) fn page_sections(app: &mut DesignApp, ui: &mut Ui, page: usize) {
|
||||
/// Quick Actions. Back leaves parent editing and returns to the Selection tool and document properties.
|
||||
pub(super) fn page_sections(app: &mut DesignApp, ui: &mut Ui, spread: designcraft_doc::SpreadRef, page: usize) {
|
||||
let u = units(app);
|
||||
let lang = app.ui.language.clone();
|
||||
let Some(st) = app.session.active() else { return };
|
||||
let d = st.doc.clone();
|
||||
let Some((si, pi)) = d.page_loc(page) else { return };
|
||||
let Some(pg) = d.spreads.get(si).and_then(|s| s.pages.get(pi)).cloned() else { return };
|
||||
let n = page + 1;
|
||||
let Some(sp) = d.spread(spread) else { return };
|
||||
let Some(pg) = sp.pages.get(page).cloned() else { return };
|
||||
let t = Tokens::get(ui.ctx());
|
||||
// The band: "Page", and Back at its end.
|
||||
let band = block(ui, 33.0);
|
||||
@@ -84,6 +83,9 @@ pub(super) fn page_sections(app: &mut DesignApp, ui: &mut Ui, page: usize) {
|
||||
});
|
||||
rule(ui);
|
||||
if back {
|
||||
if matches!(spread, designcraft_doc::SpreadRef::Parent(_)) {
|
||||
let _ = app.run("layout.parents.edit", json!({"on": false}));
|
||||
}
|
||||
let _ = app.run("layout.selectPage", json!({"page": null}));
|
||||
app.select_tool("selection");
|
||||
return;
|
||||
@@ -91,13 +93,25 @@ pub(super) fn page_sections(app: &mut DesignApp, ui: &mut Ui, page: usize) {
|
||||
section(ui, crate::i18n::tr(&lang, "Page Size"), true);
|
||||
// Which page.
|
||||
let b = block(ui, FIELD_H);
|
||||
let names: Vec<String> = (0..d.page_count()).map(|i| app.session.page_label(i)).collect();
|
||||
let (names, selected): (Vec<String>, usize) = match spread {
|
||||
designcraft_doc::SpreadRef::Doc(si) => ((0..d.page_count()).map(|i| app.session.page_label(i)).collect(), d.first_page_of_spread(si) + page),
|
||||
designcraft_doc::SpreadRef::Parent(_) => {
|
||||
((0..sp.pages.len()).map(|i| format!("{} · {}", sp.parent.as_ref().map(|p| p.label()).unwrap_or_default(), i + 1)).collect(), page)
|
||||
}
|
||||
};
|
||||
let picked = place(ui, sub(b, 0.0, 0.0, 93.0, FIELD_H), |ui| {
|
||||
widgets::dropdown_list(ui, names.get(page).map_or("", String::as_str), 93.0, &names, Some(page))
|
||||
widgets::dropdown_list(ui, names.get(selected).map_or("", String::as_str), 93.0, &names, Some(selected))
|
||||
});
|
||||
if let Some(p) = picked {
|
||||
let _ = app.run("layout.selectPage", json!({"page": p + 1}));
|
||||
crate::canvas::go_to_page(app, p);
|
||||
match spread {
|
||||
designcraft_doc::SpreadRef::Doc(_) => {
|
||||
let _ = app.run("layout.selectPage", json!({"page": p + 1}));
|
||||
crate::canvas::go_to_page(app, p);
|
||||
}
|
||||
designcraft_doc::SpreadRef::Parent(_) => {
|
||||
let _ = app.run("layout.selectParentPage", json!({"parent": sp.id.0, "page": p}));
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
ui.add_space(3.0);
|
||||
@@ -116,13 +130,22 @@ pub(super) fn page_sections(app: &mut DesignApp, ui: &mut Ui, page: usize) {
|
||||
size = Some(if key == "width" { (nv, h) } else { (w, nv) });
|
||||
}
|
||||
}
|
||||
if let Some((nw, nh)) = size {
|
||||
let _ = app.run("layout.pageSize", json!({"pages": [n], "width": nw, "height": nh, "anchor": app.ui.ref_point.min(8)}));
|
||||
if let Some((nw, nh)) = size
|
||||
&& let Some((cmd, params)) =
|
||||
designcraft_tools::layout::page_size_command(&d, spread, page, json!({"width": nw, "height": nh, "anchor": app.ui.ref_point.min(8)}))
|
||||
{
|
||||
let _ = app.run(cmd, params);
|
||||
}
|
||||
if matches!(spread, designcraft_doc::SpreadRef::Parent(_)) {
|
||||
margin_section(app, ui);
|
||||
}
|
||||
ui.add_space(6.0);
|
||||
let fw = full_width(ui);
|
||||
if outline_button(ui, crate::i18n::tr(&lang, "Margins and Columns"), fw).clicked() {
|
||||
crate::canvas::go_to_page(app, page);
|
||||
match spread {
|
||||
designcraft_doc::SpreadRef::Doc(si) => crate::canvas::go_to_page(app, d.first_page_of_spread(si) + page),
|
||||
designcraft_doc::SpreadRef::Parent(_) => crate::canvas::show_spread(app, spread),
|
||||
}
|
||||
crate::menus::activate(app, "layout.marginsAndColumns", &Value::Null);
|
||||
}
|
||||
divider(ui);
|
||||
@@ -181,6 +204,61 @@ fn page_box_field(app: &mut DesignApp, ui: &mut Ui, b: Rect) {
|
||||
}
|
||||
}
|
||||
|
||||
fn margin_section(app: &mut DesignApp, ui: &mut Ui) {
|
||||
let u = units(app);
|
||||
let Some(st) = app.session.active() else { return };
|
||||
let d = st.doc.clone();
|
||||
let t = Tokens::get(ui.ctx());
|
||||
section(ui, crate::i18n::tr(&app.ui.language, "Margins"), true);
|
||||
let cur = crate::canvas::current_page(app).unwrap_or(0);
|
||||
// While parent spreads are edited the fields belong to the parent page in view; they used to
|
||||
// show (and change) the document pages instead, so nothing changed on the parent.
|
||||
let parent_target = crate::canvas::current_parent_page(app);
|
||||
let shown = match parent_target {
|
||||
Some((pi, qi)) => d.parents.get(pi).and_then(|s| s.pages.get(qi)).cloned(),
|
||||
None => d.page(cur).cloned(),
|
||||
};
|
||||
if let Some(p) = shown {
|
||||
let linked = temp_bool(ui, "margins_linked", true);
|
||||
let b = block(ui, 2.0 * FIELD_H + 5.0);
|
||||
let mut set: Option<designcraft_doc::Margins> = None;
|
||||
let fields = [
|
||||
("margin-top", "Top", p.margins.top, 0.0, 0.0),
|
||||
("margin-bottom", "Bottom", p.margins.bottom, 0.0, 26.0),
|
||||
("margin-outside", if d.settings.facing_pages { "Outside" } else { "Right" }, p.margins.outside, 127.0, 0.0),
|
||||
("margin-inside", if d.settings.facing_pages { "Inside" } else { "Left" }, p.margins.inside, 127.0, 26.0),
|
||||
];
|
||||
for (k, (icon, tip, v, x, y)) in fields.into_iter().enumerate() {
|
||||
let ir = sub(b, x, y + 2.0, 17.0, 17.0);
|
||||
icons::paint(ui.painter(), ir, icon, t.icon);
|
||||
ui.interact(ir, ui.scope_id().with(("micon", k)), Sense::hover()).on_hover_ui(|ui| {
|
||||
crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, tip));
|
||||
});
|
||||
let nv = place(ui, sub(b, x + 20.0, y, 73.0, FIELD_H), |ui| {
|
||||
NumField::measure(tip, Some(v), u).width(73.0).spinner().range(0.0, 8000.0).show(ui)
|
||||
});
|
||||
if let Some(nv) = nv {
|
||||
set = Some(edited_margins(p.margins, linked, k, nv));
|
||||
}
|
||||
}
|
||||
let lr = sub(b, 99.0, 12.5, 22.0, FIELD_H);
|
||||
let clicked = place(ui, lr, |ui| {
|
||||
widgets::icon_toggle_sized(ui, if linked { "link" } else { "link-broken" }, linked, "Make all settings the same", vec2(22.0, 21.0))
|
||||
.clicked()
|
||||
});
|
||||
if clicked {
|
||||
set_temp_bool(ui, "margins_linked", !linked);
|
||||
}
|
||||
if let Some(m) = set {
|
||||
let params = match parent_target {
|
||||
Some((pi, qi)) => json!({"margins": m, "parent": pi, "pages": [qi]}),
|
||||
None => json!({"margins": m}),
|
||||
};
|
||||
let _ = app.run("layout.marginsAndColumns", params);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn document_sections(app: &mut DesignApp, ui: &mut Ui) {
|
||||
let u = units(app);
|
||||
let Some(st) = app.session.active() else { return };
|
||||
@@ -234,59 +312,8 @@ pub(super) fn document_sections(app: &mut DesignApp, ui: &mut Ui) {
|
||||
if changed {
|
||||
let _ = app.run("layout.documentSetup", json!({"facingPages": facing}));
|
||||
}
|
||||
// Margins.
|
||||
section(ui, crate::i18n::tr(&app.ui.language, "Margins"), true);
|
||||
margin_section(app, ui);
|
||||
let cur = crate::canvas::current_page(app).unwrap_or(0);
|
||||
// While parent spreads are edited the fields belong to the parent page in view; they used to
|
||||
// show (and change) the document pages instead, so nothing changed on the parent.
|
||||
let parent_target = crate::canvas::current_parent_page(app);
|
||||
let shown = match parent_target {
|
||||
Some((pi, qi)) => d.parents.get(pi).and_then(|s| s.pages.get(qi)).cloned(),
|
||||
None => d.page(cur).cloned(),
|
||||
};
|
||||
if let Some(p) = shown {
|
||||
let linked = temp_bool(ui, "margins_linked", true);
|
||||
let b = block(ui, 2.0 * FIELD_H + 5.0);
|
||||
let mut set: Option<designcraft_doc::Margins> = None;
|
||||
let fields = [
|
||||
("margin-top", "Top", p.margins.top, 0.0, 0.0),
|
||||
("margin-bottom", "Bottom", p.margins.bottom, 0.0, 26.0),
|
||||
("margin-outside", if d.settings.facing_pages { "Outside" } else { "Right" }, p.margins.outside, 127.0, 0.0),
|
||||
("margin-inside", if d.settings.facing_pages { "Inside" } else { "Left" }, p.margins.inside, 127.0, 26.0),
|
||||
];
|
||||
for (k, (icon, tip, v, x, y)) in fields.into_iter().enumerate() {
|
||||
let ir = sub(b, x, y + 2.0, 17.0, 17.0);
|
||||
icons::paint(ui.painter(), ir, icon, t.icon);
|
||||
ui.interact(ir, ui.scope_id().with(("micon", k)), Sense::hover()).on_hover_ui(|ui| {
|
||||
crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, tip));
|
||||
});
|
||||
let nv = place(ui, sub(b, x + 20.0, y, 73.0, FIELD_H), |ui| {
|
||||
NumField::measure(tip, Some(v), u).width(73.0).spinner().range(0.0, 8000.0).show(ui)
|
||||
});
|
||||
if let Some(nv) = nv {
|
||||
set = Some(edited_margins(p.margins, linked, k, nv));
|
||||
}
|
||||
}
|
||||
let lr = sub(b, 99.0, 12.5, 22.0, FIELD_H);
|
||||
let clicked = place(ui, lr, |ui| {
|
||||
widgets::icon_toggle_sized(ui, if linked { "link" } else { "link-broken" }, linked, "Make all settings the same", vec2(22.0, 21.0))
|
||||
.clicked()
|
||||
});
|
||||
if clicked {
|
||||
set_temp_bool(ui, "margins_linked", !linked);
|
||||
}
|
||||
if let Some(m) = set {
|
||||
let params = match parent_target {
|
||||
// Both pages of a facing parent spread, so the margins show wherever the parent is used.
|
||||
Some((pi, _)) => {
|
||||
let n = app.session.active().and_then(|st| st.doc.parents.get(pi)).map_or(1, |sp| sp.pages.len());
|
||||
json!({"margins": m, "parent": pi, "pages": (0..n).collect::<Vec<_>>()})
|
||||
}
|
||||
None => json!({"margins": m}),
|
||||
};
|
||||
let _ = app.run("layout.marginsAndColumns", params);
|
||||
}
|
||||
}
|
||||
ui.add_space(6.0);
|
||||
let fw = full_width(ui);
|
||||
if outline_button(ui, crate::i18n::tr(&app.ui.language, "Adjust Layout"), fw).clicked() {
|
||||
|
||||
@@ -49,7 +49,7 @@ so only enable it while you use it. Transport: `apps/designcraft/src/control_ser
|
||||
| `ui.dialog.set` / `ui.dialog.confirm` / `ui.dialog.cancel` | `{field, value}` | Fill and confirm the open dialog |
|
||||
| `ui.resize`, `ui.focus` | | Window control |
|
||||
| `ui.screenshot` | `{path?}` | PNG of the whole window |
|
||||
| `ui.render` | `{path?, page?, scale?, bleed?}` | Render a page headlessly (PNG; base64 if no path) |
|
||||
| `ui.render` | `{path?, page?, scale?, bleed?}` | Render a document page headlessly (PNG; base64 if no path); without a canvas, document mode defaults to page 0; parent editing mode requires an explicit 0-based `page` |
|
||||
| `app.open` / `app.save` / `app.export` | | Files |
|
||||
| `app.quit` | | Quit without asking about unsaved changes (the `app.quit` command, like ⌘Q, asks) |
|
||||
|
||||
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
# DesignCraft roadmap (detail)
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (.indd and .indt open through indd-utils; text threads by hand; the gate waits on the Primary Text Frame page fixes) · **Target:** Adobe InDesign 2026 (21.6)
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-01 · **Change:** minor (independent parent page resizing, #1620; .indd and .indt open through indd-utils; text threads by hand; the gate waits on the Primary Text Frame page fixes) · **Target:** Adobe InDesign 2026 (21.6)
|
||||
|
||||
Forward-looking plan. The one-page summary is [ROADMAP.md](../ROADMAP.md); numbers are in
|
||||
[target-app-parity.md](target-app-parity.md); the work list is [gaps.md](gaps.md). Hours are
|
||||
@@ -17,7 +17,7 @@ but are next in line.
|
||||
|
||||
| Core workflow | Works end to end? | Evidence | Hours to pass |
|
||||
|---|---|---|---|
|
||||
| Set up a document (size, margins, columns, parent pages, page numbers, sections); save and reopen | yes | New Document, Document Setup, parent pages and overrides, Numbering & Section Options; `.designcraft` round-trip tests; recovery | 0 |
|
||||
| Set up a document (size, margins, columns, parent pages, page numbers, sections); save and reopen | yes | New Document, Document Setup, independently sized parent pages (Properties and Page Tool, #1620), parent overrides, Numbering & Section Options; `.designcraft` round-trip tests; recovery | 0 |
|
||||
| Flow a long story across frames and pages: thread, unthread, autoflow | **no** | threading by hand works (ports, loaded text cursor, `text.thread`/`text.unthread`/`text.removeFromThread`); Primary Text Frame autoflow adds pages users can't delete (#165) | 5–7 |
|
||||
| Format text with paragraph and character styles | partial (not blocking) | Paragraph/Character Style Options, Quick Apply, based-on, nested/GREP styles work; style edit/delete from the panel reported (#169); formatting set at a caret is discarded (#289) | 6–12 |
|
||||
| Place, crop and fit images; wrap text around them | yes | place gun, frame fitting and Fitting Options, content grabber, text wrap, Links panel | 0 |
|
||||
|
||||
Reference in new issue
Block a user