Placed documents: Place's Link tooltip, Break Link in the Links panel

This commit is contained in:
haven 2026-10-07 23:58:11 -07:00
parent 2418921760
commit 5e7af3f83c
13 changed files with 168 additions and 18 deletions

File diff suppressed because one or more lines are too long

View File

@ -3,7 +3,9 @@
//! object) or loads the place cursor with several ([`crate::place::confirm`]).
//!
//! Fields: `files` (`[{path} | {name}]`), `link`, `template`, `replace`, and `__replace` (one file
//! and one selected object: Replace applies), `__info` (a line per file).
//! and one selected object: Replace applies), `__documents` and `__others` (VectorCraft documents
//! from files are picked, other files are: what Link's tooltip explains), `__info` (a line per
//! file).
use serde_json::{Value, json};
@ -72,12 +74,32 @@ fn body(_: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
for (key, label, tip) in OPTIONS {
let on = d.bool(key);
let enabled = key != "replace" || d.bool("__replace");
let resp = ui.scope(|ui| widgets::check(ui, tl!(label), on && enabled, enabled));
if resp.inner {
let tip = if key == "link" { link_tip(d) } else { tl!(tip).to_string() };
if widgets::check_tip(ui, tl!(label), on && enabled, enabled, &tip) {
d.fields.insert(key.into(), json!(!on));
}
resp.response.on_hover_text(tl!(tip));
}
});
false
}
/// Link's tooltip: what it does for the files picked (images, VectorCraft documents, or both), a
/// line each.
pub(crate) fn link_tip(d: &Dialog) -> String {
let lines = match (d.bool("__documents"), d.bool("__others")) {
(true, false) => {
vec![
tl!("On: one locked object showing the document, updated when it changes."),
tl!("Off: an editable copy of its art, embedded in this document."),
]
}
(true, true) => {
vec![
tl!("Images: link to the file instead of only embedding it."),
tl!("VectorCraft documents: one locked object, updated when the file changes."),
]
}
_ => vec![tl!("Keep a link to an image file instead of only embedding it")],
};
lines.join("\n")
}

View File

@ -3593,3 +3593,8 @@ axis Both Ambos
Zoom Zoom
Bring All to Front Traer todo al frente
Settings Ajustes
Break Link Romper vínculo
On: one locked object showing the document, updated when it changes. Activado: un objeto bloqueado que muestra el documento y se actualiza cuando este cambia.
Off: an editable copy of its art, embedded in this document. Desactivado: una copia editable de su ilustración, incrustada en este documento.
Images: link to the file instead of only embedding it. Imágenes: vincular al archivo en lugar de solo incrustarlo.
VectorCraft documents: one locked object, updated when the file changes. Documentos de VectorCraft: un objeto bloqueado que se actualiza cuando cambia el archivo.

Can't render this file because it contains an unexpected character in line 2 and column 73.

View File

@ -3593,3 +3593,8 @@ axis Both Entrambi
Zoom Zoom
Bring All to Front Porta tutto in primo piano
Settings Impostazioni
Break Link Interrompi collegamento
On: one locked object showing the document, updated when it changes. Attivato: un unico oggetto bloccato che mostra il documento, aggiornato quando cambia.
Off: an editable copy of its art, embedded in this document. Disattivato: una copia modificabile della sua grafica, incorporata in questo documento.
Images: link to the file instead of only embedding it. Immagini: collega il file invece di incorporarlo soltanto.
VectorCraft documents: one locked object, updated when the file changes. Documenti VectorCraft: un unico oggetto bloccato, aggiornato quando il file cambia.

Can't render this file because it contains an unexpected character in line 2 and column 73.

View File

@ -3055,3 +3055,8 @@ axis Both 両方
Bring All to Front すべてを手前に移動
Settings 環境設定
theme Appearance 外観
Break Link リンクを解除
On: one locked object showing the document, updated when it changes. オン:ドキュメントを表示するロックされた 1 つのオブジェクト。ドキュメントの変更時に更新されます。
Off: an editable copy of its art, embedded in this document. オフ:アートの編集可能なコピーをこのドキュメントに埋め込みます。
Images: link to the file instead of only embedding it. 画像:埋め込むだけでなく、ファイルにリンクします。
VectorCraft documents: one locked object, updated when the file changes. VectorCraft ドキュメント:ロックされた 1 つのオブジェクト。ファイルの変更時に更新されます。

Can't render this file because it contains an unexpected character in line 2 and column 73.

View File

@ -2643,3 +2643,8 @@
Zoom Zoom
Bring All to Front Trazer Todas para a Frente
Settings Configurações
Break Link Quebrar Vínculo
On: one locked object showing the document, updated when it changes. Ativado: um objeto bloqueado que mostra o documento, atualizado quando ele muda.
Off: an editable copy of its art, embedded in this document. Desativado: uma cópia editável da arte, incorporada neste documento.
Images: link to the file instead of only embedding it. Imagens: vincular ao arquivo em vez de apenas incorporá-lo.
VectorCraft documents: one locked object, updated when the file changes. Documentos do VectorCraft: um objeto bloqueado, atualizado quando o arquivo muda.

Can't render this file because it contains an unexpected character in line 6 and column 47.

View File

@ -3310,3 +3310,8 @@ axis Both 两者
Hide VectorCraft 隐藏 VectorCraft
Bring All to Front 前置全部窗口
Settings 设置
Break Link 断开链接
On: one locked object showing the document, updated when it changes. 开启:以一个锁定对象显示文档,文档更改时随之更新。
Off: an editable copy of its art, embedded in this document. 关闭:将其图稿的可编辑副本嵌入此文档。
Images: link to the file instead of only embedding it. 图像:链接到文件,而不只是嵌入。
VectorCraft documents: one locked object, updated when the file changes. VectorCraft 文档:一个锁定对象,文件更改时随之更新。

Can't render this file because it contains an unexpected character in line 2 and column 73.

View File

@ -3085,3 +3085,8 @@ axis Both 兩者
Zoom 縮放
Bring All to Front 將全部移至最前
Settings 設定
Break Link 中斷連結
On: one locked object showing the document, updated when it changes. 開啟:以單一鎖定物件顯示文件,文件變更時隨之更新。
Off: an editable copy of its art, embedded in this document. 關閉:將其圖稿的可編輯副本內嵌在此文件中。
Images: link to the file instead of only embedding it. 影像:連結至檔案,而不只是嵌入。
VectorCraft documents: one locked object, updated when the file changes. VectorCraft 文件:單一鎖定物件,檔案變更時隨之更新。

Can't render this file because it contains an unexpected character in line 6 and column 47.

View File

@ -560,7 +560,7 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[
"links.editOriginal",
"Edit Original",
"",
"{id?} open the file of linked image `id` (default: the selected linked image) in the system's default app for its type (Links panel, Edit › Edit Original) → {path}; edits saved there show after links.update",
"{id?} open the file of linked image `id` (default: the selected linked image) in the system's default app for its type (Links panel, Edit › Edit Original); a placed document's file opens here in a new tab, and saving it updates the documents placing it → {path}; edits saved elsewhere show after links.update",
),
(
"links.reveal",
@ -1837,8 +1837,8 @@ pub fn enabled(app: &VectorcraftApp, id: &str) -> bool {
"ui.spotColors" => app.session.active().is_some(),
"ui.menuDialog" => app.session.active().is_some_and(|d| !d.selection.is_empty()),
"ui.dxfOptionsDialog" => app.session.active().is_some(),
"links.editOriginal" | "links.reveal" => selected_image(app, |im| im.link.is_some()),
"ui.placementOptionsDialog" => selected_image(app, |_| true),
"links.editOriginal" | "links.reveal" => selected_image(app, |im| im.link.is_some()) || selected_placed(app),
"ui.placementOptionsDialog" => selected_image(app, |_| true) || selected_placed(app),
"ui.packageDialog" | "docInfo.save" => app.session.active().is_some(),
"ui.epsOptionsDialog" => app.session.active().is_some(),
"file.saveForWeb" | "file.saveForWeb.browser" => app.session.active().is_some(),
@ -1855,6 +1855,13 @@ pub fn enabled(app: &VectorcraftApp, id: &str) -> bool {
}
}
/// Is a placed document selected?
fn selected_placed(app: &VectorcraftApp) -> bool {
app.session.active().is_some_and(|st| {
st.selection.objects.iter().any(|id| st.doc.node(*id).is_some_and(|n| matches!(n.kind, vectorcraft_doc::NodeKind::PlacedDocument(_))))
})
}
/// Is an image `keep` accepts selected?
fn selected_image(app: &VectorcraftApp, keep: impl Fn(&vectorcraft_doc::ImageObject) -> bool) -> bool {
app.session.active().is_some_and(|st| {

View File

@ -83,10 +83,12 @@ fn cached(app: &mut VectorcraftApp, ctx: &egui::Context, o: &Options) -> Option<
Some(c)
}
/// The ids of the selected image objects.
/// The ids of the selected image objects and placed documents.
fn selected_images(app: &VectorcraftApp) -> Vec<u64> {
let Some(st) = app.session.active() else { return vec![] };
let image = |id: &NodeId| st.doc.node(*id).is_some_and(|n| matches!(n.kind, vectorcraft_doc::NodeKind::Image(_)));
let image = |id: &NodeId| {
st.doc.node(*id).is_some_and(|n| matches!(n.kind, vectorcraft_doc::NodeKind::Image(_) | vectorcraft_doc::NodeKind::PlacedDocument(_)))
};
st.selection.objects.iter().filter(|id| image(id)).map(|id| id.0).collect()
}
@ -170,6 +172,11 @@ pub(crate) fn open_file(app: &mut VectorcraftApp, p: &Value, reveal: bool) -> Re
if info["status"] == "missing" {
return Err(format!("{path} can't be found: relink it first"));
}
// A placed document's file opens here, in a new tab: saving it updates this document.
if !reveal && info["document"] == true {
app.run("document.open", json!({ "path": path }))?;
return Ok(json!({ "path": path }));
}
if reveal { crate::io::reveal_path(app, &path) } else { crate::io::open_in_app(app, &path) }?;
Ok(json!({ "path": path }))
}
@ -293,11 +300,13 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
None => {}
}
if o.info {
link_info(ui, cache.info.as_ref().map(|(_, i)| i));
let units = app.session.general_unit();
link_info(ui, cache.info.as_ref().map(|(_, i)| i), units);
}
let sel = selected_rows(&cache.list, &selected);
let modified = ids(&sel, |r| status_is(r, "modified") || r["preview"] == true && !status_is(r, "missing"));
let can_edit = sel.iter().any(|r| r["linked"] == true && !status_is(r, "missing"));
let linked = ids(&sel, |r| r["linked"] == true);
widgets::bottom_bar(ui, |ui| {
let info = !o.info;
if widgets::icon_button(ui, if o.info { "chevron-down" } else { "chevron-right" }, tl!("Show Link Info"), false, 24.0).clicked() {
@ -319,10 +328,24 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
if widgets::icon_button_enabled(ui, "link", tl!("Relink…"), false, !sel.is_empty() && can_pick(app), 24.0).clicked() {
relink(app, ids(&sel, |_| true));
}
if widgets::icon_button_enabled(ui, "link-2-off", tl!("Break Link"), false, !linked.is_empty(), 24.0).clicked() {
break_link(app, linked.clone());
}
});
});
}
/// Break Link (`links.embed`) of `ids`: images keep their files' pixels, placed documents become
/// editable copies of their art.
fn break_link(app: &mut VectorcraftApp, ids: Vec<u64>) {
let r = app.run("links.embed", json!({ "ids": ids }));
report(app, r, |v| {
let missing = v["missing"].as_array().map_or(0, Vec::len);
let n = v["embedded"].as_array().map_or(0, Vec::len);
if missing == 0 { format!("Broke {n} link(s)") } else { format!("Broke {n} link(s); {missing} can't be read: relink or update them first") }
});
}
/// Pick-a-file actions need a file dialog (the desktop app).
fn can_pick(app: &VectorcraftApp) -> bool {
app.services.pick_open.is_some()
@ -344,7 +367,7 @@ pub(crate) fn date_label(ms: u64) -> String {
}
/// The Link Info section: the details of `info` (`links.info`), or a hint.
fn link_info(ui: &mut Ui, info: Option<&Value>) {
fn link_info(ui: &mut Ui, info: Option<&Value>, units: vectorcraft_doc::Unit) {
widgets::divider(ui);
let Some(i) = info else {
widgets::dim_label(ui, tl!("Select an image to see its Link Info."));
@ -365,9 +388,19 @@ fn link_info(ui: &mut Ui, info: Option<&Value>) {
if let Some(l) = i["location"].as_str() {
row(ui, tl!("Location"), l.into());
}
row(ui, tl!("PPI"), pair("ppi"));
row(ui, tl!("Effective PPI"), pair("effectivePpi"));
row(ui, tl!("Dimensions"), format!("{} × {} px", i["pixelWidth"], i["pixelHeight"]));
// A placed document has no resolution: its rows are left out.
for (label, k) in [(tl!("PPI"), "ppi"), (tl!("Effective PPI"), "effectivePpi")] {
if i.get(k).is_some() {
row(ui, label, pair(k));
}
}
// Pixels, or a placed document's artboard size.
let dims = match (i["pixelWidth"].as_u64(), i["pageWidth"].as_f64(), i["pageHeight"].as_f64()) {
(Some(w), _, _) => format!("{w} × {} px", i["pixelHeight"].as_u64().unwrap_or(0)),
(None, Some(w), Some(h)) => format!("{} × {}", units.format(w), units.format(h)),
_ => String::new(),
};
row(ui, tl!("Dimensions"), dims);
let scale: Vec<f64> = i["scale"].as_array().into_iter().flatten().filter_map(Value::as_f64).collect();
if let [x, y] = scale[..] {
row(ui, tl!("Scale"), format!("H: {x:.1}% V: {y:.1}%"));

View File

@ -160,11 +160,20 @@ fn open_dialog(app: &mut VectorcraftApp, files: Vec<Value>) {
"template": false,
"replace": false,
"__replace": files.len() == 1 && one_object,
"__documents": files.iter().any(is_vectorcraft_file),
"__others": !files.iter().all(is_vectorcraft_file),
"__info": info,
}),
));
}
/// Is dialog file entry `file` a VectorCraft document on disk (what Link places as one locked
/// object)?
fn is_vectorcraft_file(file: &Value) -> bool {
let native = ["vectorcraft", vectorcraft_format::LEGACY_EXTENSION, "vctemplate"];
file.get("path").and_then(Value::as_str).is_some_and(|p| native.contains(&fileio::extension(p).as_str()))
}
/// The engine params naming `file` (a dialog file entry).
fn source(app: &VectorcraftApp, file: &Value) -> Option<Value> {
if let Some(path) = file.get("path").and_then(Value::as_str) {

View File

@ -361,3 +361,40 @@ fn the_control_bars_mask_clips_the_image_and_selects_the_clipping_path() {
let ids: Vec<u64> = group.children().unwrap().iter().map(|c| c.id.0).collect();
assert_eq!((ids[1], st.selection.objects.iter().map(|i| i.0).collect::<Vec<_>>()), (img, vec![ids[0]]));
}
#[test]
fn a_vectorcraft_document_places_linked_and_edit_original_opens_it() {
let mut app = app();
// A one-artboard document with a rectangle.
let mut src = Session::new();
src.execute("file.new", &json!({"width": 100, "height": 50})).unwrap();
src.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 100, "height": 50})).unwrap();
let bytes = vectorcraft_format::save_file(&src.active().unwrap().doc);
let path = temp_file("badge.vectorcraft", &bytes);
place_picked(&mut app, &path);
let d = app.ui.dialog.as_ref().expect("the Place dialog");
assert!(d.bool("link") && d.bool("__documents") && !d.bool("__others"), "Link on, for a document");
// Link's tooltip says what it does for a document.
assert!(crate::dialogs::place::link_tip(d).contains("editable copy"));
crate::dialogs::confirm(&mut app).unwrap();
let st = app.session.active().unwrap();
let id = st.selection.objects[0];
let vectorcraft_doc::NodeKind::PlacedDocument(p) = &st.doc.node(id).unwrap().kind else { panic!("not a placed document") };
assert_eq!(p.link.path, path);
// Edit Original opens the document in a new tab.
let tabs = app.session.documents().len();
app.run("links.editOriginal", json!({})).unwrap();
assert_eq!(app.session.documents().len(), tabs + 1);
assert_eq!(app.session.active().unwrap().path.as_deref(), Some(path.as_str()));
// A PNG keeps the image tooltip; a PNG and a document, both.
let pic = temp_file("pic.png", &png(10, 10, 72.0));
place_picked(&mut app, &pic);
let d = app.ui.dialog.as_ref().unwrap();
assert!(!d.bool("__documents") && crate::dialogs::place::link_tip(d).contains("image file"));
app.ui.dialog = None;
let both = vec![pic.clone(), path.clone()];
app.services.pick_open_multi = Some(Box::new(move || both.clone()));
app.run("file.place", json!({})).unwrap();
let d = app.ui.dialog.as_ref().unwrap();
assert!(crate::dialogs::place::link_tip(d).contains("VectorCraft documents"));
}

View File

@ -1424,8 +1424,11 @@ passed straight back to `file.new`. Print presets and sizes without `units` star
`file.place` puts another file's art into the active document as one undo step without touching the clipboard:
a raster image at 100% of its physical size (the resolution its file declares, else 72 ppi; linked to its `path`
unless `link: false`), an SVG as one group, a PDF/.ai page or a native document's artboard (`page`, `crop`) as one
clipped group, with the images, symbols, patterns and swatches it uses. `at` centres it, `rect` fits it, `replace`
unless `link: false`), an SVG as one group, a PDF/.ai page as one clipped group, with the images, symbols, patterns
and swatches it uses. A VectorCraft document read from `path` is a placed document: one locked object showing its
artboard `page` (or, with `crop: "bounding"`, its art's bounds), linked to the file and read again when it changes
(see Linked images), and vectors in every output; with `link: false` (or from `dataBase64`) it is an editable copy
of its art, as one clipped group. `at` centres it, `rect` fits it, `replace`
swaps the selected object (keeping its place and transform), `template` puts it on a new template layer.
`file.place.info` describes a file without placing it and `image.info` reports a placed image's link, colour mode and
effective ppi. `file.place.queue` loads the place cursor (the `place` tool) with several files: headless, drive it
@ -1534,6 +1537,14 @@ in `updatedLinks`), each as `{name, path, ids}`. `links.check` reports every lin
another file (or each at the file of its name in a folder); images keep their bounds, one undo step each. Without a
file system (the web), linked images show their previews.
Placed documents (a `.vectorcraft` file placed linked) work the same way: the document keeps the file's bytes and a
preview (JPEG or PNG, 1 px per point, at most 1024 px a side), and a save writes the preview (Include Linked Files:
the file); output reads the file again, or uses the preview with a warning when it is gone or changed.
`links.list` and `links.info` give them `document: true` and the artboard's `pageWidth` and `pageHeight` (pt).
Saving the file in the app updates the open documents that place it. `links.embed` (the Links panel's Break Link)
turns one into the editable copy placing without link gives, its symbols, patterns, swatches and images joining
the document; so does `object.expand`.
```json
{"name":"run_command","arguments":{"command":"links.check","params":{}}}
{"name":"run_command","arguments":{"command":"links.relink","params":{"ids":[12],"path":"/new/photo.png"}}}