V: Fit Selection in Window; Find/Change Object
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
c9785ae1ed
commit
614eee2e1d
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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..];
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
|
|
|||
Loading…
Reference in New Issue