V: Fit Selection in Window; Find/Change Object

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas 2026-10-03 00:32:00 -04:00
parent c9785ae1ed
commit 614eee2e1d
6 changed files with 193 additions and 4 deletions

View File

@ -62,6 +62,7 @@ DesignCraft aims at full Adobe InDesign parity — and to be better: faster, ope
- Paragraph borders (per-side weights, colour, tint, offsets) and shading offsets, drawn per column part of split paragraphs (also when overset); Paragraph panel controls; IDML ParagraphBorder*/ParagraphShading*Offset.
- Font menu: search, favourites (stars, Show Favorites Only; `favoriteFonts` preference) and each family previewed in its own face.
- Application preferences (engine `Prefs`) now persist across launches (desktop: prefs.json beside ui.json).
- View ▸ Fit Selection in Window (⌥⌘=) and Find/Change ▸ Object (`find.objects`, `find.changeObjects`: by fill, stroke, weight, opacity, kind, layer, label).
- Drag and drop text editing: drag selected text to move it (Alt copies) with its formatting (`text.release`).
- Image Import Options: place any page of a multi-page PDF (`file.place {pdfPage}`; the app asks which page), shown on screen and embedded as that page in PDF export.
- Define Lists: named numbered lists that continue across stories in page order (`list.define`, paragraph `listName` / `startAt`), round-tripped through IDML; `null` in `type.para` / `type.char` now removes an override.

View File

@ -9,6 +9,50 @@ use crate::{Result, Session};
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!(noundo "find.objects", "Find Object", [], None,
"{fill?, stroke?: swatch, strokeWeight?, opacity? (0–1), kind?: text|graphic|shape|line|group, layer?, label?, select?: bool (default true)} → {ids} — Find/Change › Object, through the document",
has_doc, |s, p| {
let ids = find_objects(&s.doc()?.doc, p);
if p.get("select").and_then(Value::as_bool).unwrap_or(true) && !ids.is_empty() {
let st = s.doc_mut()?;
st.selection = designcraft_doc::Selection::items(ids.clone());
st.revision += 1;
}
Ok(json!({"ids": ids.iter().map(|i| i.0).collect::<Vec<_>>()}))
}),
cmd!(
"find.changeObjects",
"Change All (Object)",
[],
None,
"{…find.objects criteria, change: {fill?, stroke?, strokeWeight?, opacity?}} → {changed}",
has_doc,
|s, p| {
let ids = find_objects(&s.doc()?.doc, p);
let ch = p.get("change").cloned().unwrap_or(Value::Null);
if !ch.is_object() {
return Err(bad("find.changeObjects", "`change` required"));
}
s.edit(|d, _| {
for id in &ids {
let Some(it) = d.item_mut(*id) else { continue };
if let Some(f) = ch.get("fill").and_then(Value::as_str) {
it.fill = designcraft_doc::Fill::swatch(f);
}
if let Some(c) = ch.get("stroke").and_then(Value::as_str) {
it.stroke.swatch = c.to_string();
}
if let Some(w) = ch.get("strokeWeight").and_then(Value::as_f64) {
it.stroke.weight = w.max(0.0);
}
if let Some(o) = ch.get("opacity").and_then(Value::as_f64) {
it.opacity = o.clamp(0.0, 1.0) as f32;
}
}
Ok(json!({"changed": ids.len()}))
})
}
),
cmd!(query "find.find", "Find", ["Edit", "Find/Change"], None,
"{find, grep?: bool, caseSensitive?: bool, wholeWord?: bool, scope?: document|story|selection, story?} → matches [{story, start, end, text}]",
has_doc, find),
@ -232,3 +276,67 @@ mod tests {
assert!(s.doc().unwrap().selection.text.is_some());
}
}
/// Objects matching Find/Change › Object criteria, in document order (nested ones included).
fn find_objects(d: &designcraft_doc::Document, p: &Value) -> Vec<designcraft_doc::ItemId> {
use designcraft_doc::{Content, Shape};
let fill = str_param(p, "fill");
let stroke = str_param(p, "stroke");
let weight = p.get("strokeWeight").and_then(Value::as_f64);
let opacity = p.get("opacity").and_then(Value::as_f64);
let kind = str_param(p, "kind");
let layer = str_param(p, "layer").and_then(|n| d.layers.iter().find(|l| l.name == n).map(|l| l.id));
if str_param(p, "layer").is_some() && layer.is_none() {
return vec![];
}
let label = str_param(p, "label");
let mut out = Vec::new();
for sp in d.spreads.iter().chain(d.parents.iter()) {
for top in &sp.items {
top.walk(&mut |it| {
let k = match (&it.content, it.shape) {
(_, Shape::Group) => "group",
(Content::Text(_), _) => "text",
(Content::Graphic(_), _) => "graphic",
(_, Shape::GraphicLine) => "line",
_ => "shape",
};
let ok = fill.is_none_or(|f| it.fill.swatch == f)
&& stroke.is_none_or(|c| it.stroke.swatch == c && it.stroke.weight > 0.0)
&& weight.is_none_or(|w| (it.stroke.weight - w).abs() < 1e-6)
&& opacity.is_none_or(|o| (it.opacity as f64 - o).abs() < 1e-3)
&& kind.is_none_or(|kk| kk == k)
&& layer.is_none_or(|l| it.layer == l)
&& label.is_none_or(|lb| it.label == lb);
if ok {
out.push(it.id);
}
});
}
}
out
}
#[cfg(test)]
mod object_find_tests {
use serde_json::json;
use crate::Session;
#[test]
fn find_and_change_objects_by_attributes() {
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
let a = s.execute("frame.create", &json!({"rect": [10, 10, 50, 50], "content": "unassigned"})).unwrap()["id"].clone();
let _b = s.execute("frame.create", &json!({"rect": [60, 10, 90, 50], "content": "unassigned"})).unwrap()["id"].clone();
s.execute("object.fill", &json!({"swatch": "[Black]", "ids": [a]})).unwrap();
s.execute("frame.create", &json!({"rect": [10, 60, 90, 90], "content": "text", "text": "t"})).unwrap();
let r = s.execute("find.objects", &json!({"fill": "[Black]"})).unwrap();
assert_eq!(r["ids"], json!([a]));
assert_eq!(s.doc().unwrap().selection.items.len(), 1, "found objects are selected");
assert_eq!(s.execute("find.objects", &json!({"kind": "text", "select": false})).unwrap()["ids"].as_array().unwrap().len(), 1);
let c = s.execute("find.changeObjects", &json!({"kind": "shape", "change": {"opacity": 0.5}})).unwrap();
assert_eq!(c["changed"], 2);
assert_eq!(s.execute("find.objects", &json!({"opacity": 0.5, "select": false})).unwrap()["ids"].as_array().unwrap().len(), 2);
}
}

View File

@ -63,6 +63,21 @@ pub fn fit(app: &mut DesignApp, rect: Rect, what: &str) {
if what == "all" {
b = layout.slots.iter().map(|s| s.bounds).reduce(|a, b| a.union(b)).unwrap_or(b);
}
if what == "selection" {
// Fit Selection in Window: the selected objects (on their spread's slot).
let sel: Vec<designcraft_geom::Rect> = st
.selection
.items
.iter()
.filter_map(|id| {
let loc = st.doc.find(*id)?;
let slot = layout.slots.iter().find(|s| s.spread == loc.spread)?;
Some(st.doc.item(*id)?.bounds() + slot.offset)
})
.collect();
let Some(u) = sel.into_iter().reduce(|a, b| a.union(b)) else { return };
b = u.inflate(u.width().max(u.height()) * 0.05 + 1.0, u.width().max(u.height()) * 0.05 + 1.0);
}
let pad = 40.0;
let zoom = ((rect.width() as f64 - 2.0 * pad) / b.width()).min((rect.height() as f64 - 2.0 * pad) / b.height()).clamp(0.05, 40.0);
let origin = Point::new(b.center().x - rect.width() as f64 / 2.0 / zoom, b.center().y - rect.height() as f64 / 2.0 / zoom);

View File

@ -720,11 +720,66 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) {
"findChange" => {
ui.horizontal(|ui| {
for (label, grep) in [("Text", false), ("GREP", true)] {
if ui.selectable_label(d.b("grep") == grep, label).clicked() {
if ui.selectable_label(!d.b("objectMode") && d.b("grep") == grep, label).clicked() {
d.fields.insert("grep".into(), json!(grep));
d.fields.insert("objectMode".into(), json!(false));
}
}
if ui.selectable_label(d.b("objectMode"), "Object").clicked() {
d.fields.insert("objectMode".into(), json!(true));
}
});
if d.b("objectMode") {
// Find/Change › Object: objects by fill and kind; change their fill or opacity.
let swatches: Vec<String> = app.session.active().map(|st| st.doc.swatches.iter().filter(|w| !w.hidden).map(|w| w.name.clone()).collect()).unwrap_or_default();
egui::Grid::new("fco").num_columns(2).spacing([8.0, 6.0]).show(ui, |ui| {
for (key, label, any) in [("objFill", "Find fill:", "(any)"), ("objChangeFill", "Change fill to:", "(unchanged)")] {
ui.label(label);
let cur = d.s(key);
egui::ComboBox::from_id_salt(key).selected_text(if cur.is_empty() { any } else { cur.as_str() }).width(180.0).show_ui(ui, |ui| {
if ui.selectable_label(cur.is_empty(), any).clicked() {
d.fields.insert(key.into(), json!(""));
}
for w in &swatches {
if ui.selectable_label(*w == cur, w).clicked() {
d.fields.insert(key.into(), json!(w));
}
}
});
ui.end_row();
}
ui.label("Kind:");
let cur = d.s("objKind");
egui::ComboBox::from_id_salt("objKind").selected_text(if cur.is_empty() { "(any)" } else { cur.as_str() }).show_ui(ui, |ui| {
for v in ["", "text", "graphic", "shape", "line", "group"] {
if ui.selectable_label(cur == v, if v.is_empty() { "(any)" } else { v }).clicked() {
d.fields.insert("objKind".into(), json!(v));
}
}
});
ui.end_row();
});
let mut crit = json!({});
if !d.s("objFill").is_empty() {
crit["fill"] = json!(d.s("objFill"));
}
if !d.s("objKind").is_empty() {
crit["kind"] = json!(d.s("objKind"));
}
ui.horizontal(|ui| {
if ui.button("Find All").clicked() {
let n = app.run("find.objects", crit.clone()).ok().and_then(|r| r["ids"].as_array().map(Vec::len)).unwrap_or(0);
d.fields.insert("status".into(), json!(format!("{n} found")));
}
if ui.button("Change All").clicked() && !d.s("objChangeFill").is_empty() {
let mut p = crit.clone();
p["change"] = json!({"fill": d.s("objChangeFill")});
let n = app.run("find.changeObjects", p).ok().and_then(|r| r["changed"].as_u64()).unwrap_or(0);
d.fields.insert("status".into(), json!(format!("{n} changed")));
}
});
ui.label(d.s("status"));
} else {
egui::Grid::new("fc").num_columns(2).spacing([8.0, 6.0]).show(ui, |ui| {
ui.label("Find what:");
text_field(ui, &mut d, "find", 260.0);
@ -777,6 +832,7 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) {
if let Some(st) = d.fields.get("status").and_then(Value::as_str) {
ui.label(egui::RichText::new(st).color(crate::theme::Tokens::get(ui.ctx()).text_dim));
}
}
}
"paragraphStyleOptions" => paragraph_style_options(app, ui, &mut d),
"footnoteOptions" => footnote_options(app, ui, &mut d),
@ -1200,6 +1256,7 @@ pub fn confirm(app: &mut DesignApp) -> Result<Value, String> {
}
app.run("footnote.options", p)
}
"findChange" if d.b("objectMode") => Ok(Value::Null),
"findChange" => app.run("find.change", json!({"find": d.s("find"), "change": d.s("change"), "grep": d.b("grep"), "caseSensitive": d.b("caseSensitive"), "wholeWord": d.b("wholeWord"), "scope": d.s("scope")})),
id if id.starts_with("cmd:") => {
let cid = &id[4..];

View File

@ -45,6 +45,7 @@ pub const UI_COMMANDS: &[(&str, &str, Option<&str>, &str)] = &[
("view.zoomIn", "Zoom In", Some("Cmd+="), "{}"),
("view.zoomOut", "Zoom Out", Some("Cmd+-"), "{}"),
("view.fitPage", "Fit Page in Window", Some("Cmd+0"), "{}"),
("view.fitSelection", "Fit Selection in Window", Some("Cmd+Alt+="), "{}"),
("view.fitSpread", "Fit Spread in Window", Some("Cmd+Alt+0"), "{}"),
("view.actualSize", "Actual Size", Some("Cmd+1"), "{}"),
("view.entirePasteboard", "Entire Pasteboard", Some("Cmd+Alt+Shift+0"), "{}"),
@ -440,6 +441,7 @@ pub const MENUS: &[(&str, &[&str])] = &[
"ui:view.zoomIn",
"ui:view.zoomOut",
"ui:view.fitPage",
"ui:view.fitSelection",
"ui:view.fitSpread",
"ui:view.actualSize",
"ui:view.entirePasteboard",
@ -739,6 +741,12 @@ pub fn run_ui(app: &mut DesignApp, id: &str, p: &Value) -> Option<Result<Value,
}
Ok(Value::Null)
}
"view.fitSelection" => {
if let Some(r) = rect {
crate::canvas::fit(app, r, "selection");
}
Ok(Value::Null)
}
"view.fitPage" | "view.fitSpread" | "view.entirePasteboard" => {
if let Some(r) = rect {
crate::canvas::fit(

View File

@ -10,7 +10,7 @@ Each row of the InDesign feature catalogue is scored **D** (done), **P** (partia
| Priority | Features | Done | Partial | Missing | Parity |
|---|---|---|---|---|---|
| P0 | 102 | 99 | 3 | 0 | 99% |
| P1 | 97 | 84 | 12 | 1 | 93% |
| P1 | 97 | 85 | 11 | 1 | 93% |
| P2 | 76 | 23 | 13 | 40 | 39% |
| Area | Parity |
@ -31,7 +31,7 @@ Each row of the InDesign feature catalogue is scored **D** (done), **P** (partia
| Interactivity & digital | 65% |
| Output & production | 75% |
| XML & automation | 61% |
| View & navigation | 82% |
| View & navigation | 86% |
| Undo, history, saving | 100% |
| Accessibility | 70% |
@ -308,7 +308,7 @@ Open P0 items: Preferences, Vector/page formats (PDF/AI/SVG/EPS place), Export t
| View & navigation | Navigate pages | P0 | D |
| View & navigation | Split window | P2 | M |
| View & navigation | Rotate spread view | P2 | M |
| View & navigation | Find/zoom to object | P1 | P |
| View & navigation | Find/zoom to object | P1 | D |
| Undo, history, saving | Unlimited undo/redo | P0 | D |
| Undo, history, saving | Revert | P0 | D |
| Undo, history, saving | Save / Save As / Save a Copy | P0 | D |