Merge pull request #2081 from fabian-co/feat/image-replace

Replace a selected image from the Control bar, with its own aspect ratio
This commit is contained in:
BFlat
2026-10-16 04:31:45 +07:00
committed by GitHub
5 changed files with 105 additions and 19 deletions
+21 -16
View File
@@ -66,7 +66,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Relink",
[],
None,
"{ids?: [image or placed document ids] (default: the selected ones; with folder and nothing selected, every missing link), path | folder, copy?: false (with path, for images of a saved document: copy the file into the document's folder and link to the copy; the names Name.png, Name 2.png … Name 99.png are tried in turn until one is free, which gets the copy, or is a file with the same contents, which is used)} link images (or placed documents, each showing the same artboard of the new file) to the file at path, or each to the file of its link's name in folder; embedded images become linked; each keeps its bounds. One undo step → {relinked: [ids], notFound: [file names], copied?: [{from, to}], kept?: [{from, to}] (with copy)}",
"{ids?: [image or placed document ids] (default: the selected ones; with folder and nothing selected, every missing link), path | folder, copy?: false (with path, for images of a saved document: copy the file into the document's folder and link to the copy; the names Name.png, Name 2.png … Name 99.png are tried in turn until one is free, which gets the copy, or is a file with the same contents, which is used), preserve?: transforms|bounds|fileDimensions|fit|fill (how the new file takes each image's place this time, as links.placementOptions describes; default: each image's Placement Options; fit keeps the new file's aspect ratio inside the old bounds: Replace)} link images (or placed documents, each showing the same artboard of the new file) to the file at path, or each to the file of its link's name in folder; embedded images become linked; each keeps its bounds. One undo step → {relinked: [ids], notFound: [file names], copied?: [{from, to}], kept?: [{from, to}] (with copy)}",
has_doc,
relink
),
@@ -371,21 +371,21 @@ fn read_for(f: &mut Found, g: &Group, cmd: &str) -> Result<Read> {
}
/// Objects `ids` show what was read (see [`install`], [`install_document`]).
fn install_read(d: &mut Document, ids: &[NodeId], read: Read, keep_bounds: bool) -> Result<()> {
fn install_read(d: &mut Document, ids: &[NodeId], read: Read, force: Option<Preserve>) -> Result<()> {
match read {
Read::Image(r) => install(d, ids, r, keep_bounds),
Read::Document(link, src) => install_document(d, ids, &link, &src, keep_bounds),
Read::Image(r) => install(d, ids, r, force),
Read::Document(link, src) => install_document(d, ids, &link, &src, force),
}
}
/// Placed documents `ids` show `src`, read from the file `link` points at, each placed as its
/// Placement Options say (`keep_bounds`: in its bounds, whatever they say).
fn install_document(d: &mut Document, ids: &[NodeId], link: &LinkInfo, src: &super::place::document::Source, keep_bounds: bool) -> Result<()> {
/// Placement Options say (`force`: as that Preserve choice says, whatever they say).
fn install_document(d: &mut Document, ids: &[NodeId], link: &LinkInfo, src: &super::place::document::Source, force: Option<Preserve>) -> Result<()> {
let mut clips = vec![];
let (w, h) = (src.frame.width(), src.frame.height());
for id in ids {
let Some(NodeKind::PlacedDocument(p)) = d.node(*id).map(|n| &n.kind) else { continue };
let o = if keep_bounds { PlacementOptions { preserve: Preserve::Bounds, ..p.placement } } else { p.placement };
let o = PlacementOptions { preserve: force.unwrap_or(p.placement.preserve), ..p.placement };
let (xf, clip) = placed_xf(p.xf, (p.width, p.height), (1.0, 1.0), (w, h), (1.0, 1.0), o);
if let Some(n) = d.node_mut(*id)
&& let NodeKind::PlacedDocument(p) = &mut n.kind
@@ -480,13 +480,13 @@ fn clip_image(d: &mut Document, id: NodeId, (frame, room): Clip) -> Result<()> {
}
/// Image objects `ids` show the file read for `link` (`img`), each placed as its Placement
/// Options say (`keep_bounds`: in its bounds, whatever they say).
fn install(d: &mut Document, ids: &[NodeId], (link, img): FileRead, keep_bounds: bool) -> Result<()> {
/// Options say (`force`: as that Preserve choice says, whatever they say).
fn install(d: &mut Document, ids: &[NodeId], (link, img): FileRead, force: Option<Preserve>) -> Result<()> {
let new_pt = super::place::pt_per_px(img.ppi);
let mut clips = vec![];
for id in ids {
let Some(NodeKind::Image(im)) = d.node(*id).map(|n| &n.kind) else { continue };
let o = if keep_bounds { PlacementOptions { preserve: Preserve::Bounds, ..im.placement } } else { im.placement };
let o = PlacementOptions { preserve: force.unwrap_or(im.placement.preserve), ..im.placement };
let size = |w: u32, h: u32| (w as f64, h as f64);
let (xf, clip) = placed_xf(im.xf, size(im.width, im.height), file_pt(d, &im.key), size(img.width, img.height), new_pt, o);
if let Some(n) = d.node_mut(*id)
@@ -540,7 +540,7 @@ pub fn resolve(d: &mut Document, doc_path: Option<&str>, update: bool) -> Resolv
match probe(&g, folder(doc_path)) {
(Status::Missing, _) => out.missing.extend(row),
(Status::Modified, Some(mut f)) if update || unread => {
match read_for(&mut f, &g, "document.open").and_then(|read| install_read(d, &g.ids, read, unread)) {
match read_for(&mut f, &g, "document.open").and_then(|read| install_read(d, &g.ids, read, unread.then_some(Preserve::Bounds))) {
Ok(()) if unread => {}
Ok(()) => out.updated.extend(row),
Err(_) => out.missing.extend(row),
@@ -552,7 +552,7 @@ pub fn resolve(d: &mut Document, doc_path: Option<&str>, update: bool) -> Resolv
let read = if stale(d, &g) { Some(read_for(&mut f, &g, "document.open")) } else { None };
match read {
Some(Ok(read)) => {
if install_read(d, &g.ids, read, true).is_err() {
if install_read(d, &g.ids, read, Some(Preserve::Bounds)).is_err() {
out.missing.extend(row);
}
}
@@ -737,13 +737,18 @@ fn update(s: &mut Session, p: &Value) -> Result<Value> {
}
let updated: Vec<u64> = reads.iter().flat_map(|(ids, _)| ids_json(ids)).collect();
if !reads.is_empty() {
s.edit("Update Links", |d, _| reads.into_iter().try_for_each(|(ids, read)| install_read(d, &ids, read, false)))?;
s.edit("Update Links", |d, _| reads.into_iter().try_for_each(|(ids, read)| install_read(d, &ids, read, None)))?;
}
Ok(json!({ "updated": updated, "missing": missing }))
}
fn relink(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "links.relink";
// How the new file takes the image's place this time (default: its Placement Options).
let preserve = match str_param(p, "preserve") {
None => None,
Some(v) => Some(Preserve::from_id(v).ok_or_else(|| bad(C, format!("preserve `{v}` is not transforms|bounds|fileDimensions|fit|fill")))?),
};
let st = s.doc()?;
let folder_param = str_param(p, "folder");
// With copy: the saved document's folder, which the file is copied into.
@@ -852,7 +857,7 @@ fn relink(s: &mut Session, p: &Value) -> Result<Value> {
}
let relinked: Vec<u64> = reads.iter().flat_map(|(ids, _)| ids_json(ids)).collect();
if !reads.is_empty() {
s.edit("Relink", |d, _| reads.into_iter().try_for_each(|(ids, read)| install_read(d, &ids, read, false)))?;
s.edit("Relink", |d, _| reads.into_iter().try_for_each(|(ids, read)| install_read(d, &ids, read, preserve)))?;
}
not_found.sort();
not_found.dedup();
@@ -1109,7 +1114,7 @@ fn embed(s: &mut Session, p: &Value) -> Result<Value> {
if !done.is_empty() || !documents.is_empty() {
s.edit("Embed", |d, _| {
for (ids, read) in reads {
install(d, &ids, read, true)?;
install(d, &ids, read, Some(Preserve::Bounds))?;
}
for id in &done {
if let Some(Node { kind: NodeKind::Image(im), .. }) = d.node_mut(*id) {
@@ -1164,7 +1169,7 @@ fn unembed(s: &mut Session, p: &Value) -> Result<Value> {
let path = absolute_path(&if fileio::extension(path).is_empty() { format!("{path}.{ext}") } else { path.to_string() });
write_file(&path, &bytes)?;
let read = (link_info(&path, &bytes), fileio::raster_image(&bytes)?);
s.edit("Unembed", |d, _| install(d, &[id], read, true))?;
s.edit("Unembed", |d, _| install(d, &[id], read, Some(Preserve::Bounds)))?;
Ok(json!({ "id": id.0, "path": path }))
}
+12
View File
@@ -127,6 +127,18 @@ fn relink_picked(app: &mut VectorcraftApp, ids: Vec<u64>) {
report(app, r, |v| format!("Relinked {} image(s)", v["relinked"].as_array().map_or(0, Vec::len)));
}
/// Replace images `ids` with a picked file (Control bar, Properties): it keeps its own aspect
/// ratio, fitted inside the old bounds (Placement Options: Fit), whatever their Placement Options
/// say.
pub(crate) fn replace(app: &mut VectorcraftApp, ids: Vec<u64>) {
crate::picks::button(app, move |app| {
let pick = crate::FilePick { filters: vectorcraft_engine::cmd::fileio::place_filters().collect(), ..Default::default() };
let Some(path) = crate::picks::open(app, &pick) else { return };
let r = app.run("links.relink", json!({ "ids": ids.clone(), "path": path, "preserve": "fit" }));
report(app, r, |v| format!("Replaced {} image(s)", v["relinked"].as_array().map_or(0, Vec::len)));
});
}
/// Relink images `ids` (none: every missing one) to the files of their names in a picked folder.
fn relink_to_folder(app: &mut VectorcraftApp, ids: Vec<u64>) {
crate::picks::button(app, move |app| relink_to_picked_folder(app, ids.clone()));
+2 -2
View File
@@ -153,8 +153,8 @@ fn image_section(app: &mut VectorcraftApp, ui: &mut Ui) {
let links = crate::panels::links::ID;
ui.horizontal(|ui| crate::place::link_buttons(app, ui, id, linked, &name, Some(w)));
ui.horizontal(|ui| {
if app.services.pick_open.is_some() && widgets::flat_button(ui, tl!("Relink…"), w).clicked() {
crate::panels::links::relink(app, vec![id]);
if app.services.pick_open.is_some() && widgets::flat_button(ui, tl!("Replace…"), w).clicked() {
crate::panels::links::replace(app, vec![id]);
}
if widgets::flat_button(ui, tl!("Links"), w).clicked() {
app.ui.open_panel = Some(links.into());
+32 -1
View File
@@ -442,7 +442,12 @@ pub fn control_bar_details(app: &mut VectorcraftApp, ui: &mut egui::Ui) -> bool
let (name, details) = image_summary(i);
let (Some(id), linked) = (i["id"].as_u64(), i["linked"] == true) else { return false };
let t = Tokens::get(ui.ctx());
ui.label(egui::RichText::new(&name).size(12.0).color(t.text_strong));
// The file name is a link: its menu relinks (replaces) the image and reaches the Links
// panel's other actions for it.
let resp = ui
.add(egui::Label::new(egui::RichText::new(&name).size(12.0).color(t.accent).underline()).sense(egui::Sense::click()))
.on_hover_cursor(egui::CursorIcon::PointingHand);
egui::Popup::menu(&resp).show(|ui| link_menu(app, ui, id, linked));
ui.separator();
ui.label(egui::RichText::new(details).size(12.0).color(t.text_dim));
ui.separator();
@@ -457,6 +462,32 @@ pub fn control_bar_details(app: &mut VectorcraftApp, ui: &mut egui::Ui) -> bool
true
}
/// The menu of the Control bar's file name for image `id`: Replace… (another file takes the
/// image's place with its own aspect ratio; an embedded image becomes linked), Go To Link, Edit
/// Original, Update Link and Placement Options….
fn link_menu(app: &mut VectorcraftApp, ui: &mut egui::Ui, id: u64, linked: bool) {
ui.set_min_width(200.0);
if widgets::menu_item(ui, tl!("Replace…"), app.services.pick_open.is_some(), false) {
crate::panels::links::replace(app, vec![id]);
}
if widgets::menu_item(ui, tl!("Go To Link"), true, false) {
crate::panels::links::go_to(app, id);
}
if widgets::menu_item(ui, tl!("Edit Original"), linked, false) {
crate::menus::invoke(app, "links.editOriginal", json!({ "id": id }));
}
if widgets::menu_item(ui, tl!("Update Link"), linked, false) {
let msg = match app.run("links.update", json!({ "ids": [id] })) {
Ok(v) => format!("Updated {} image(s)", v["updated"].as_array().map_or(0, Vec::len)),
Err(e) => e,
};
app.status(msg);
}
if widgets::menu_item(ui, tl!("Placement Options…"), true, false) {
crate::menus::invoke(app, "ui.placementOptionsDialog", json!({ "ids": [id] }));
}
}
/// Embed (and Edit Original) for linked image `id`, Unembed… for an embedded one named `name`
/// (Control bar, Properties). The buttons are `width` wide, or fit their labels.
pub fn link_buttons(app: &mut VectorcraftApp, ui: &mut egui::Ui, id: u64, linked: bool, name: &str, width: Option<f32>) {
+38
View File
@@ -494,6 +494,44 @@ fn click_label(app: &mut VectorcraftApp, ctx: &egui::Context, label: &str) {
click_control(app, ctx, p);
}
/// The Control bar's file name opens the link menu: Replace… puts the picked file in the image's
/// place (an embedded image becomes linked) with the new file's own aspect ratio, fitted inside the
/// old bounds; Placement Options… opens its dialog.
#[test]
fn the_control_bars_file_name_relinks_the_image() {
use crate::tests_removeanchors::{click_control, control_frame};
// Menu items are drawn after the space a check mark takes.
let item = |app: &mut VectorcraftApp, ctx: &egui::Context, label: &str| {
let texts = control_frame(app, ctx, vec![]);
texts.iter().find(|(t, _)| t.trim() == label).map(|(_, r)| r.center()).unwrap_or_else(|| panic!("no `{label}` in {texts:?}"))
};
let mut app = app();
let data = vectorcraft_format::base64_encode(&png(30, 30, 72.0));
app.run("file.place", json!({"name": "red.png", "dataBase64": data, "link": false})).unwrap();
let before = selected_image(&app).geometric_bounds().unwrap();
let ctx = egui::Context::default();
crate::theme::install_fonts(&ctx);
click_label(&mut app, &ctx, "red.png");
for s in ["Replace…", "Go To Link", "Edit Original", "Update Link", "Placement Options…"] {
item(&mut app, &ctx, s);
}
let other = temp_file("blue.png", &png(60, 30, 72.0));
let picked = other.clone();
app.services.pick_open = Some(Box::new(move |_: &crate::FilePick| Some(picked.clone())));
let p = item(&mut app, &ctx, "Replace…");
click_control(&mut app, &ctx, p);
let image = selected_image(&app);
let NodeKind::Image(im) = &image.kind else { panic!("an image") };
assert!(im.link.as_ref().is_some_and(|l| l.name() == "blue.png"), "relinked to the picked file");
let after = image.geometric_bounds().unwrap();
assert!((after.width() - before.width()).abs() < 1e-6 && (after.height() - before.height() / 2.0).abs() < 1e-6, "2:1, fitted: {after:?}");
assert!((after.center() - before.center()).hypot() < 1e-6, "centred where the old image was");
click_label(&mut app, &ctx, "blue.png");
let p = item(&mut app, &ctx, "Placement Options…");
click_control(&mut app, &ctx, p);
assert!(app.ui.dialog.is_some(), "Placement Options opens");
}
#[test]
fn the_control_bar_and_properties_trace_a_selected_image() {
use crate::panels::image_trace::{TRACE_BUTTON_W, selected_trace};