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 <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
This commit is contained in:
authored and GitHub committed 2026-10-10 02:02:57 -04:00
1 parent b61715889a
commit e585f9495d
8 files changed
+383 -32

No files matched your search

Generated
+1
View File
@@ -1114,6 +1114,7 @@ dependencies = [
"designcraft-engine",
"designcraft-fonts",
"designcraft-geom",
"designcraft-idml",
"designcraft-render",
"designcraft-tools",
"egui",
+7 -6
View File
@@ -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();
});
})),
+6 -3
View File
@@ -297,10 +297,13 @@ fn file_place(s: &mut Session, p: &Value) -> Result<Value> {
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,
};
+1
View File
@@ -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"] }
+1
View File
@@ -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)
}
+14 -2
View File
@@ -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<Value, String> {
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<Value, String> {
}
"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" => {
+351 -20
View File
@@ -38,10 +38,21 @@ pub use control::{ControlRequest, ControlResponse};
pub type ReadFn = Box<dyn Fn(&str) -> Result<Vec<u8>, String>>;
pub type WriteFn = Box<dyn FnMut(&str, &[u8]) -> Result<(), String>>;
pub type PickFn = Box<dyn FnMut(&str) -> Option<String>>;
pub type OpenAsyncFn = Box<dyn FnMut(&str)>;
/// Context captured before any host file-picker or read await.
#[derive(Clone, Debug)]
pub struct ImportRequest {
pub purpose: String,
pub target: Option<u64>,
}
pub struct ImportedFile {
pub request: ImportRequest,
pub name: String,
pub bytes: Vec<u8>,
}
pub type OpenAsyncFn = Box<dyn FnMut(ImportRequest)>;
pub type DownloadFn = Box<dyn FnMut(&str, &[u8])>;
/// Files `(name, bytes)` delivered asynchronously by the host (web file picker, dropped files).
pub type Inbox = std::sync::Arc<std::sync::Mutex<Vec<(String, Vec<u8>)>>>;
/// Files delivered asynchronously with their original operation and document identity.
pub type Inbox = std::sync::Arc<std::sync::Mutex<Vec<ImportedFile>>>;
/// 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<DownloadFn>,
/// 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<Inbox>,
}
@@ -386,6 +396,7 @@ pub struct Perf {
}
pub struct DesignApp {
pending_pdf: Option<ImportedFile>,
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<Value, String> {
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<Value, String> {
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<Value, String> {
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<u8>) {
app.services.inbox.as_ref().unwrap().lock().unwrap().push(ImportedFile { request, name: name.into(), bytes });
app.drain_inbox();
}
fn image() -> Vec<u8> {
br##"<svg xmlns="http://www.w3.org/2000/svg" width="10" height="10"><rect width="10" height="10" fill="#ff0000"/></svg>"##.to_vec()
}
fn pdf_pages(count: usize) -> Vec<u8> {
let kids = (0..count).map(|i| format!("{} 0 R", 3 + i * 2)).collect::<Vec<_>>().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<u8> {
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());
}
}
+2 -1
View File
@@ -858,8 +858,9 @@ pub fn run_ui(app: &mut DesignApp, id: &str, p: &Value) -> Option<Result<Value,
None => 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)
}