From e585f9495d46e9b309b6aa2dd5938ef89f464cb6 Mon Sep 17 00:00:00 2001 From: skylar taylor-barrick Date: Sat, 10 Oct 2026 00:02:57 -0600 Subject: [PATCH] Preserve browser import intent and document targets (#119) * #89: preserve browser import context and PDF options * #91: reject PDF pages before narrowing on Wasm --------- Co-authored-by: Brandon Thomas Co-authored-by: Claude Opus 5.5 --- Cargo.lock | 1 + apps/designcraft-web/src/web.rs | 13 +- crates/engine/src/cmd/file.rs | 9 +- crates/ui-egui/Cargo.toml | 1 + crates/ui-egui/src/control.rs | 1 + crates/ui-egui/src/dialogs.rs | 16 +- crates/ui-egui/src/lib.rs | 371 ++++++++++++++++++++++++++++++-- crates/ui-egui/src/menus.rs | 3 +- 8 files changed, 383 insertions(+), 32 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 6473b84..c683910 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1114,6 +1114,7 @@ dependencies = [ "designcraft-engine", "designcraft-fonts", "designcraft-geom", + "designcraft-idml", "designcraft-render", "designcraft-tools", "egui", diff --git a/apps/designcraft-web/src/web.rs b/apps/designcraft-web/src/web.rs index f565986..fe7cca4 100644 --- a/apps/designcraft-web/src/web.rs +++ b/apps/designcraft-web/src/web.rs @@ -2,7 +2,7 @@ use super::browser_policy::{has_query_flag, mime_for}; use designcraft_engine::Session; -use designcraft_ui_egui::{DesignApp, Inbox, Services}; +use designcraft_ui_egui::{DesignApp, ImportRequest, ImportedFile, Inbox, Services}; use wasm_bindgen::JsCast as _; const DOC_EXTS: &[&str] = &["designcraft", "idml"]; @@ -88,13 +88,14 @@ impl eframe::App for WebShell { fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { let dropped = ctx.input_mut(|i| std::mem::take(&mut i.raw.dropped_files)); for f in dropped { + let request = self.app.import_request("drop"); let inbox = self.inbox.clone(); let ctx = ctx.clone(); wasm_bindgen_futures::spawn_local(async move { let name = f.path().file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| "dropped".into()); match f.bytes_async().await { Ok(bytes) => { - inbox.lock().unwrap_or_else(|e| e.into_inner()).push((name, bytes)); + inbox.lock().unwrap_or_else(|e| e.into_inner()).push(ImportedFile { request, name, bytes }); ctx.request_repaint(); } Err(e) => log::error!("couldn't read dropped file {name}: {e}"), @@ -117,12 +118,12 @@ impl eframe::App for WebShell { fn services(inbox: Inbox, ctx: egui::Context) -> Services { let open_inbox = inbox.clone(); Services { - open_async: Some(Box::new(move |purpose: &str| { + open_async: Some(Box::new(move |request: ImportRequest| { let inbox = open_inbox.clone(); let ctx = ctx.clone(); - let dialog = if purpose == "swatches" { + let dialog = if request.purpose == "swatches" { rfd::AsyncFileDialog::new().add_filter("Swatch Exchange (ASE)", &["ase"]) - } else if purpose == "place" { + } else if request.purpose == "place" { rfd::AsyncFileDialog::new().add_filter("Graphics", IMAGE_EXTS) } else { rfd::AsyncFileDialog::new().add_filter("DesignCraft", DOC_EXTS) @@ -132,7 +133,7 @@ fn services(inbox: Inbox, ctx: egui::Context) -> Services { return; }; let bytes = file.read().await; - inbox.lock().unwrap_or_else(|e| e.into_inner()).push((file.file_name(), bytes)); + inbox.lock().unwrap_or_else(|e| e.into_inner()).push(ImportedFile { request, name: file.file_name(), bytes }); ctx.request_repaint(); }); })), diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index d475caf..f871a43 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -297,10 +297,13 @@ fn file_place(s: &mut Session, p: &Value) -> Result { let pdf_page = match p.get("pdfPage").and_then(Value::as_u64) { Some(n) if designcraft_render::is_pdf(&bytes) => { let count = designcraft_render::pdf_page_count(&bytes).unwrap_or(1); - if n == 0 || n as usize > count { - return Err(bad("file.place", format!("the PDF has {count} page(s)"))); + let invalid_page = || bad("file.place", format!("the PDF has {count} page(s)")); + let one_based = usize::try_from(n).map_err(|_| invalid_page())?; + if one_based == 0 || one_based > count { + return Err(invalid_page()); } - n as u32 - 1 + let zero_based = one_based.checked_sub(1).ok_or_else(invalid_page)?; + u32::try_from(zero_based).map_err(|_| invalid_page())? } _ => 0, }; diff --git a/crates/ui-egui/Cargo.toml b/crates/ui-egui/Cargo.toml index 369b101..52cd054 100644 --- a/crates/ui-egui/Cargo.toml +++ b/crates/ui-egui/Cargo.toml @@ -27,6 +27,7 @@ serde_json = { workspace = true } log = { workspace = true } [dev-dependencies] +designcraft-idml = { workspace = true } # Headless (offscreen wgpu) rendering of the whole window for UI screenshots: `examples/ui_shot.rs`. egui_kittest = { workspace = true, features = ["wgpu", "snapshot"] } designcraft-fonts = { workspace = true, features = ["testing"] } diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index e81e4c1..33e7c1b 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -302,6 +302,7 @@ pub fn handle(app: &mut DesignApp, ctx: &egui::Context, req: &ControlRequest) -> } "ui.dialog.confirm" => wrap(crate::dialogs::confirm(app)), "ui.dialog.cancel" => { + app.cancel_pdf_import(); app.ui.dialog = None; ok(Value::Null) } diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 7af1a32..503c2c9 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -1072,7 +1072,7 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) { "preferences" => preferences(app, ui, &mut d), "print" => print_dialog(app, ui, &mut d), "pdfImport" => { - ui.label(egui::RichText::new(d.s("path")).size(11.0)); + ui.label(egui::RichText::new(if d.b("async") { d.s("name") } else { d.s("path") }).size(11.0)); ui.horizontal(|ui| { crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Page (1–{count}):").replace("{count}", &d.n("pages").unwrap_or(1.0).to_string())); text_field(ui, &mut d, "page", 60.0); @@ -1414,7 +1414,10 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) { Some(true) => { let _ = confirm(app); } - Some(false) => app.ui.dialog = None, + Some(false) => { + app.ui.dialog = None; + app.cancel_pdf_import(); + } None => {} } } @@ -1422,6 +1425,9 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) { /// Apply the open dialog. pub fn confirm(app: &mut DesignApp) -> Result { let Some(d) = app.ui.dialog.take() else { return Err("no dialog open".into()) }; + if d.id != "pdfImport" || !d.b("async") { + app.cancel_pdf_import(); + } match d.id.as_str() { "newDocument" => { let margins = json!({"top": d.m("marginTop").unwrap_or(36.0), "bottom": d.m("marginBottom").unwrap_or(36.0), "inside": d.m("marginInside").unwrap_or(36.0), "outside": d.m("marginOutside").unwrap_or(36.0)}); @@ -1520,6 +1526,12 @@ pub fn confirm(app: &mut DesignApp) -> Result { } "pdfImport" => { let crop = d.s("crop"); + if d.b("async") { + return app.confirm_pdf_import(d.n("page").unwrap_or(1.0).max(1.0) as u64, if crop.is_empty() { "crop".into() } else { crop }); + } + if let Some(target) = d.fields.get("target").and_then(Value::as_u64) { + app.activate_import_target(&crate::ImportRequest { purpose: "place".into(), target: Some(target) })?; + } app.open_file("file.place", json!({"path": d.s("path"), "pdfPage": d.n("page").unwrap_or(1.0).max(1.0) as u64, "pdfCrop": if crop.is_empty() { "crop".to_string() } else { crop }})) } "print" => { diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 16c4e1e..704a52e 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -38,10 +38,21 @@ pub use control::{ControlRequest, ControlResponse}; pub type ReadFn = Box Result, String>>; pub type WriteFn = Box Result<(), String>>; pub type PickFn = Box Option>; -pub type OpenAsyncFn = Box; +/// Context captured before any host file-picker or read await. +#[derive(Clone, Debug)] +pub struct ImportRequest { + pub purpose: String, + pub target: Option, +} +pub struct ImportedFile { + pub request: ImportRequest, + pub name: String, + pub bytes: Vec, +} +pub type OpenAsyncFn = Box; pub type DownloadFn = Box; -/// Files `(name, bytes)` delivered asynchronously by the host (web file picker, dropped files). -pub type Inbox = std::sync::Arc)>>>; +/// Files delivered asynchronously with their original operation and document identity. +pub type Inbox = std::sync::Arc>>; /// Platform services injected by the host (desktop or web). #[derive(Default)] @@ -58,8 +69,7 @@ pub struct Services { /// Hand bytes to the user as a named file (browser download). When set, Save uses it /// instead of writing to a path. pub download: Option, - /// Files delivered asynchronously, drained every frame: documents ([`opens_as_document`]) → - /// `file.openBytes`, `.ase` → `swatch.load`, anything else → `file.place`. + /// Files delivered asynchronously, drained every frame with their captured request context. pub inbox: Option, } @@ -386,6 +396,7 @@ pub struct Perf { } pub struct DesignApp { + pending_pdf: Option, pub session: Session, pub ui: UiState, pub services: Services, @@ -434,6 +445,7 @@ impl DesignApp { // The font menus and the first file opened need the installed fonts: catalog them now. designcraft_fonts::FontDb::global().scan_in_background(); DesignApp { + pending_pdf: None, session, ui: UiState::default(), services, @@ -537,6 +549,7 @@ impl DesignApp { /// Ask the host for a file to open (`open`) or place (`place`). Synchronous pickers run the /// command right away; asynchronous ones (web) deliver the file through the inbox. pub fn pick_and_open(&mut self, purpose: &str) -> Result { + let target = self.session.active().map(|d| d.uid); let cmd = if purpose == "place" { "file.place" } else { "file.open" }; if let Some(pick) = self.services.pick_open.as_mut() { return match pick(purpose) { @@ -545,7 +558,8 @@ impl DesignApp { let pages = self.services.read.as_mut().and_then(|r| r(&path).ok()).and_then(|b| designcraft_render::pdf_page_count(&b)).unwrap_or(1); if pages > 1 { - self.ui.dialog = Some(dialogs::Dialog::new("pdfImport", json!({"path": path, "page": "1", "pages": pages}))); + self.ui.dialog = + Some(dialogs::Dialog::new("pdfImport", json!({"path": path, "page": "1", "pages": pages, "target": target}))); Ok(Value::Null) } else { self.open_file(cmd, json!({"path": path})) @@ -555,33 +569,99 @@ impl DesignApp { None => Ok(Value::Null), }; } + let request = self.import_request(purpose); if let Some(open) = self.services.open_async.as_mut() { - open(purpose); + open(request); } Ok(Value::Null) } - /// Open or place files delivered through the inbox. + pub fn import_request(&self, purpose: &str) -> ImportRequest { + ImportRequest { purpose: purpose.into(), target: self.session.active().map(|d| d.uid) } + } + + pub(crate) fn activate_import_target(&mut self, request: &ImportRequest) -> Result<(), String> { + let Some(index) = request.target.and_then(|uid| self.session.documents().iter().position(|d| d.uid == uid)) else { + let message = "Import cancelled: the originating document has closed"; + self.status(message); + return Err(message.into()); + }; + self.run("file.activate", json!({"index": index}))?; + Ok(()) + } + + pub(crate) fn cancel_pdf_import(&mut self) { + self.pending_pdf = None; + } + + pub(crate) fn confirm_pdf_import(&mut self, page: u64, crop: String) -> Result { + let file = self.pending_pdf.take().ok_or("PDF import cancelled: no pending file")?; + self.activate_import_target(&file.request)?; + self.open_file( + "file.place", + json!({"name": file.name, "base64": designcraft_engine::cmd::base64_encode(&file.bytes), "pdfPage": page, "pdfCrop": crop}), + ) + } + + /// A single unresolved PDF owns its bytes. Later deliveries cancel explicitly rather than + /// replacing its options. Cancellation, replacement and target closure release its payload. fn drain_inbox(&mut self) { + if let Some(file) = &self.pending_pdf { + let target_exists = self.session.documents().iter().any(|d| Some(d.uid) == file.request.target); + let owns_dialog = + self.ui.dialog.as_ref().is_some_and(|d| d.id == "pdfImport" && d.fields.get("async").and_then(Value::as_bool) == Some(true)); + if !target_exists || !owns_dialog { + self.pending_pdf = None; + if !target_exists { + if owns_dialog { + self.ui.dialog = None; + } + self.status("PDF import cancelled: the originating document has closed"); + } + } + } let Some(inbox) = self.services.inbox.clone() else { return }; let files = std::mem::take(&mut *inbox.lock().unwrap_or_else(|e| e.into_inner())); - for (name, bytes) in files { - let b64 = designcraft_engine::cmd::base64_encode(&bytes); - let lower = name.to_ascii_lowercase(); - let r = if lower.ends_with(".ase") { - self.run("swatch.load", json!({"base64": b64})) - } else if opens_as_document(&name) { - let title = name.rsplit_once('.').map_or(name.as_str(), |(stem, _)| stem); - self.open_file("file.openBytes", json!({"name": title, "base64": b64})) - } else { - self.open_file("file.place", json!({"name": name, "base64": b64})) - }; - if let Err(e) = r { + for file in files { + if self.ui.dialog.is_some() { + self.status(format!("{}: import cancelled while a dialog is open; choose the file again", file.name)); + continue; + } + let name = file.name.clone(); + if let Err(e) = self.import_file(file) { self.status(format!("{name}: {e}")); } } } + fn import_file(&mut self, file: ImportedFile) -> Result { + let lower = file.name.to_ascii_lowercase(); + let layout = opens_as_document(&file.name); + let place = + file.request.purpose == "place" || (matches!(file.request.purpose.as_str(), "drop" | "open") && !layout && !lower.ends_with(".ase")); + if place || lower.ends_with(".ase") || file.request.purpose == "swatches" { + self.activate_import_target(&file.request)?; + } + if place && lower.ends_with(".pdf") { + let pages = designcraft_render::pdf_page_count(&file.bytes).unwrap_or(1); + if pages > 1 { + self.ui.dialog = + Some(dialogs::Dialog::new("pdfImport", json!({"async": true, "name": file.name.clone(), "page": "1", "pages": pages}))); + self.pending_pdf = Some(file); + return Ok(Value::Null); + } + } + let b64 = designcraft_engine::cmd::base64_encode(&file.bytes); + if lower.ends_with(".ase") || file.request.purpose == "swatches" { + self.run("swatch.load", json!({"base64": b64})) + } else if place { + self.open_file("file.place", json!({"name": file.name, "base64": b64})) + } else { + let title = file.name.rsplit_once('.').map_or(file.name.as_str(), |(stem, _)| stem); + self.open_file("file.openBytes", json!({"name": title, "base64": b64})) + } + } + pub fn select_tool(&mut self, id: &str) { let _ = self.run("tool.select", json!({"tool": id})); self.ui.flyout = None; @@ -1108,3 +1188,254 @@ pub(crate) mod test_window { h.run_steps(4); } } + +#[cfg(test)] +mod browser_import_tests { + use super::*; + + fn app() -> DesignApp { + let mut app = DesignApp::new(Session::new(), Services { inbox: Some(Inbox::default()), ..Default::default() }); + app.run("file.new", json!({"title": "A"})).unwrap(); + app + } + fn deliver(app: &mut DesignApp, request: ImportRequest, name: &str, bytes: Vec) { + app.services.inbox.as_ref().unwrap().lock().unwrap().push(ImportedFile { request, name: name.into(), bytes }); + app.drain_inbox(); + } + fn image() -> Vec { + br##""##.to_vec() + } + fn pdf_pages(count: usize) -> Vec { + let kids = (0..count).map(|i| format!("{} 0 R", 3 + i * 2)).collect::>().join(" "); + let mut objects = vec!["<< /Type /Catalog /Pages 2 0 R >>".to_string(), format!("<< /Type /Pages /Kids [{kids}] /Count {count} >>")]; + for (i, color) in ["1 0 0", "0 1 0", "0 0 1"].iter().take(count).enumerate() { + let content = format!("{color} rg 0 0 200 200 re f\n"); + objects.push(format!("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Resources << >> /Contents {} 0 R >>", 4 + i * 2)); + objects.push(format!("<< /Length {} >>\nstream\n{content}endstream", content.len())); + } + let mut result = "%PDF-1.4\n".to_string(); + let mut offsets = Vec::new(); + for (i, object) in objects.iter().enumerate() { + offsets.push(result.len()); + result.push_str(&format!("{} 0 obj\n{object}\nendobj\n", i + 1)); + } + let xref = result.len(); + result.push_str(&format!("xref\n0 {}\n0000000000 65535 f \n", offsets.len() + 1)); + for offset in offsets { + result.push_str(&format!("{offset:010} 00000 n \n")); + } + result.push_str(&format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objects.len() + 1)); + result.into_bytes() + } + fn pdf() -> Vec { + pdf_pages(3) + } + + #[test] + fn picker_receives_context_before_returning_and_cancel_does_not_edit() { + let captured = std::sync::Arc::new(std::sync::Mutex::new(None)); + let copy = captured.clone(); + let mut app = app(); + app.services.open_async = Some(Box::new(move |request| *copy.lock().unwrap() = Some(request))); + let uid = app.session.doc().unwrap().uid; + app.pick_and_open("place").unwrap(); + app.run("file.new", json!({"title": "B"})).unwrap(); + let request = captured.lock().unwrap().take().unwrap(); + assert_eq!(request.purpose, "place"); + assert_eq!(request.target, Some(uid)); + assert!(app.session.documents().iter().all(|d| d.doc.assets.is_empty())); + deliver(&mut app, request, "broken.pdf", b"not a PDF".to_vec()); + assert!(app.session.documents().iter().all(|d| d.doc.assets.is_empty())); + assert!(app.pending_pdf.is_none()); + } + + #[test] + fn delayed_place_activates_original_document_and_undo_belongs_to_it() { + let mut app = app(); + let request = app.import_request("place"); + app.run("file.new", json!({"title": "B"})).unwrap(); + deliver(&mut app, request, "red.svg", image()); + assert_eq!(app.session.active_index(), Some(0)); + assert_eq!(app.session.documents()[0].doc.assets.len(), 1); + assert!(app.session.documents()[1].doc.assets.is_empty()); + app.run("edit.undo", json!({})).unwrap(); + assert!(app.session.doc().unwrap().doc.assets.is_empty()); + app.run("edit.redo", json!({})).unwrap(); + assert_eq!(app.session.doc().unwrap().doc.assets.len(), 1); + } + + #[test] + fn closed_target_is_not_replaced_by_a_reused_tab_index() { + let mut app = app(); + let request = app.import_request("place"); + app.run("file.close", json!({})).unwrap(); + app.run("file.new", json!({"title": "replacement"})).unwrap(); + deliver(&mut app, request, "red.svg", image()); + assert!(app.session.doc().unwrap().doc.assets.is_empty()); + } + + #[test] + fn closing_earlier_tab_does_not_shift_import_target() { + let mut app = app(); + app.run("file.new", json!({"title": "target"})).unwrap(); + let request = app.import_request("place"); + app.run("file.new", json!({"title": "other"})).unwrap(); + app.run("file.close", json!({"index": 0})).unwrap(); + deliver(&mut app, request, "red.svg", image()); + assert_eq!(app.session.doc().unwrap().doc.title, "target"); + assert_eq!(app.session.documents()[0].doc.assets.len(), 1); + assert!(app.session.documents()[1].doc.assets.is_empty()); + } + + #[test] + fn layout_place_and_open_preserve_explicit_intent() { + let mut source = Session::new(); + source.execute("file.new", &json!({"title": "source"})).unwrap(); + source.execute("frame.create", &json!({"rect": [10, 10, 50, 50], "content": "text", "text": "placed"})).unwrap(); + let native = designcraft_engine::cmd::to_bytes(&source.doc().unwrap().doc); + let idml = designcraft_idml::export_idml(&source.doc().unwrap().doc); + for (name, bytes) in [("source.designcraft", native), ("source.idml", idml)] { + let mut app = app(); + let request = app.import_request("place"); + deliver(&mut app, request, name, bytes.clone()); + assert_eq!(app.session.documents().len(), 1); + assert_eq!(app.session.doc().unwrap().doc.title, "A"); + assert!(app.session.doc().unwrap().doc.stories.values().any(|s| s.text == "placed")); + app.run("edit.undo", json!({})).unwrap(); + assert!(app.session.doc().unwrap().doc.stories.is_empty()); + app.run("edit.redo", json!({})).unwrap(); + assert!(!app.session.doc().unwrap().doc.stories.is_empty()); + let request = app.import_request("open"); + deliver(&mut app, request, name, bytes); + assert_eq!(app.session.documents().len(), 2); + } + } + + #[test] + fn pdf_options_keep_bytes_target_and_selected_page() { + let mut app = app(); + let bytes = pdf(); + let request = app.import_request("place"); + deliver(&mut app, request, "three.pdf", bytes.clone()); + assert!(app.session.doc().unwrap().doc.assets.is_empty()); + assert_eq!(app.ui.dialog.as_ref().unwrap().id, "pdfImport"); + app.run("file.new", json!({"title": "B"})).unwrap(); + app.ui.dialog.as_mut().unwrap().fields.insert("page".into(), json!("2")); + dialogs::confirm(&mut app).unwrap(); + assert_eq!(app.session.active_index(), Some(0)); + let asset = app.session.doc().unwrap().doc.assets.values().next().unwrap(); + assert_eq!(asset.page, 1); + assert_eq!(*asset.data, bytes); + assert!(app.pending_pdf.is_none()); + assert!(app.session.documents()[1].doc.assets.is_empty()); + let exported = app.run("file.exportPdf", json!({})).unwrap(); + let exported = designcraft_engine::cmd::base64_decode(exported["base64"].as_str().unwrap()); + let pixels = designcraft_render::render_pdf_page(&exported, 0, 400).unwrap(); + assert!(pixels.data().iter().filter(|p| p.g > 240 && p.r < 10 && p.b < 10).count() > 1000); + assert_eq!(pixels.data().iter().filter(|p| p.r > 240 && p.g < 10 && p.b < 10).count(), 0); + } + + #[test] + fn pdf_options_reject_oversized_page_without_editing() { + let mut app = app(); + for page in [0_u64, 4, 4_294_967_296, 4_294_967_297, u64::MAX] { + let request = app.import_request("place"); + deliver(&mut app, request, "three.pdf", pdf()); + app.ui.dialog.as_mut().unwrap().fields.insert("page".into(), json!(page.to_string())); + // The native dialog clamps zero to one; positive invalid pages must fail. + if page == 0 { + assert!(app.confirm_pdf_import(page, "crop".into()).is_err()); + } else { + assert!(dialogs::confirm(&mut app).is_err()); + } + app.ui.dialog = None; + assert!(app.session.doc().unwrap().doc.assets.is_empty()); + assert!(app.pending_pdf.is_none()); + } + let request = app.import_request("place"); + deliver(&mut app, request, "three.pdf", pdf()); + app.ui.dialog.as_mut().unwrap().fields.insert("page".into(), json!("2")); + dialogs::confirm(&mut app).unwrap(); + assert_eq!(app.session.doc().unwrap().doc.assets.values().next().unwrap().page, 1); + } + + #[test] + fn drop_routing_and_nonlayout_open_remain_compatible() { + let mut app = app(); + let native = designcraft_engine::cmd::to_bytes(&app.session.doc().unwrap().doc); + let request = app.import_request("drop"); + deliver(&mut app, request, "document.designcraft", native); + assert_eq!(app.session.documents().len(), 2); + let request = app.import_request("drop"); + app.run("file.activate", json!({"index": 0})).unwrap(); + deliver(&mut app, request, "red.svg", image()); + assert_eq!(app.session.active_index(), Some(1)); + assert_eq!(app.session.doc().unwrap().doc.assets.len(), 1); + let request = app.import_request("open"); + deliver(&mut app, request, "single.pdf", pdf_pages(1)); + assert_eq!(app.session.documents().len(), 2); + assert!(app.pending_pdf.is_none()); + assert!(app.ui.dialog.is_none()); + } + + #[test] + fn ase_swatches_keep_purpose_and_target() { + let mut source = Session::new(); + source.execute("file.new", &json!({})).unwrap(); + source.execute("swatch.create", &json!({"name": "Import Teal", "color": "#108080"})).unwrap(); + let data = source.execute("swatch.save", &json!({"names": ["Import Teal"]})).unwrap(); + let bytes = designcraft_engine::cmd::base64_decode(data["base64"].as_str().unwrap()); + for (purpose, name) in [("swatches", "picker-file"), ("drop", "palette.ase")] { + let mut app = app(); + let request = app.import_request(purpose); + app.run("file.new", json!({"title": "B"})).unwrap(); + deliver(&mut app, request, name, bytes.clone()); + assert_eq!(app.session.active_index(), Some(0)); + assert!(app.session.documents()[0].doc.swatches.iter().any(|s| s.name == "Import Teal")); + assert!(!app.session.documents()[1].doc.swatches.iter().any(|s| s.name == "Import Teal")); + } + } + + #[test] + fn native_pdf_dialog_keeps_path_and_uid_at_confirmation() { + let mut app = app(); + let path = std::env::temp_dir().join(format!("designcraft-native-import-{}-{}.pdf", std::process::id(), app.session.doc().unwrap().uid)); + std::fs::write(&path, pdf()).unwrap(); + let pick_path = path.to_string_lossy().to_string(); + app.services.pick_open = Some(Box::new(move |_| Some(pick_path.clone()))); + app.services.read = Some(Box::new(|path| std::fs::read(path).map_err(|e| e.to_string()))); + app.pick_and_open("place").unwrap(); + assert_eq!(app.ui.dialog.as_ref().unwrap().id, "pdfImport"); + app.run("file.new", json!({"title": "B"})).unwrap(); + app.ui.dialog.as_mut().unwrap().fields.insert("page".into(), json!("2")); + dialogs::confirm(&mut app).unwrap(); + assert_eq!(app.session.active_index(), Some(0)); + assert_eq!(app.session.doc().unwrap().doc.assets.values().next().unwrap().page, 1); + assert!(app.session.documents()[1].doc.assets.is_empty()); + std::fs::remove_file(path).unwrap(); + } + + #[test] + fn pending_pdf_cancel_close_supersession_and_second_delivery_release_bytes() { + let mut app = app(); + let request = app.import_request("place"); + deliver(&mut app, request.clone(), "first.pdf", pdf()); + deliver(&mut app, request.clone(), "second.svg", image()); + assert_eq!(app.pending_pdf.as_ref().unwrap().name, "first.pdf"); + assert!(app.session.doc().unwrap().doc.assets.is_empty()); + app.ui.dialog = None; + app.drain_inbox(); + assert!(app.pending_pdf.is_none()); + deliver(&mut app, request.clone(), "first.pdf", pdf()); + app.ui.dialog = Some(dialogs::Dialog::new("newDocument", json!({}))); + app.drain_inbox(); + assert!(app.pending_pdf.is_none()); + app.ui.dialog = None; + deliver(&mut app, request, "first.pdf", pdf()); + app.run("file.close", json!({})).unwrap(); + app.run("file.new", json!({})).unwrap(); + assert!(dialogs::confirm(&mut app).is_err()); + assert!(app.pending_pdf.is_none()); + assert!(app.session.doc().unwrap().doc.assets.is_empty()); + } +} diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 53bee42..72c3abe 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -858,8 +858,9 @@ pub fn run_ui(app: &mut DesignApp, id: &str, p: &Value) -> Option Ok(Value::Null), }); } + let request = app.import_request("swatches"); if let Some(open) = app.services.open_async.as_mut() { - open("swatches"); + open(request); } Ok(Value::Null) }