mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-24 09:46:59 -06:00
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:
@@ -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 }))
|
||||
}
|
||||
|
||||
|
||||
@@ -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()));
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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>) {
|
||||
|
||||
@@ -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};
|
||||
|
||||
Reference in New Issue
Block a user