diff --git a/src/app/control/mod.rs b/src/app/control/mod.rs index d75c85bc3..a5524e652 100644 --- a/src/app/control/mod.rs +++ b/src/app/control/mod.rs @@ -131,6 +131,12 @@ pub(super) struct State { /// Whether the parked capture actually waited for a redraw (vs. the /// anti-hang timer). Reported in the capture result as `waited_for_frame`. pub(super) capture_after_redraw: bool, + /// Web: when a parked capture gives up. Only a presented frame can be + /// read back on wasm, and a hidden or idle tab may never present one, so + /// the timer tick fails the capture past this instant instead of letting + /// it hold the control slot forever. + #[cfg(target_arch = "wasm32")] + pub(super) capture_deadline: Option, } /// The pending half of an interactive `user_select` operation (`interactive` @@ -1212,6 +1218,11 @@ impl OpenCADStudio { // rendered frame (or the timer fallback) so geometry changes are // visible rather than returning a stale frame. self.control.capture_frame_pending = true; + #[cfg(target_arch = "wasm32")] + { + self.control.capture_deadline = + Some(iced::time::Instant::now() + std::time::Duration::from_secs(5)); + } Task::none() } "stop" => { @@ -1648,30 +1659,45 @@ impl OpenCADStudio { // browser clears the WebGL buffer — so wait for a real frame. #[cfg(target_arch = "wasm32")] { - if !redrew { + let expired = self + .control + .capture_deadline + .is_some_and(|deadline| iced::time::Instant::now() >= deadline); + if !redrew && !expired && crate::sys::page_visible() { return Task::none(); } self.control.capture_frame_pending = false; - self.control.capture_after_redraw = true; - return Task::done(Message::ControlScreenshot(path, crate::sys::capture_canvas())); - } - self.control.capture_frame_pending = false; - self.control.capture_after_redraw = redrew; - let Some(window) = self.main_window else { - return Task::done(Message::ControlScreenshot(path, None)); - }; - // A minimized window has a 0x0 surface and the renderer panics reading - // it back, so report instead of capturing. - iced::window::size(window).then(move |size| { - let path = path.clone(); - if size.width <= 0.0 || size.height <= 0.0 { - Task::done(Message::ControlScreenshot(path, None)) + self.control.capture_deadline = None; + self.control.capture_after_redraw = redrew; + // No frame to read (hidden tab or nothing redrawing): report the + // capture as failed rather than hand back a blank canvas. + let screenshot = if redrew { + crate::sys::capture_canvas() } else { - iced::window::screenshot(window).map(move |screenshot| { - Message::ControlScreenshot(path.clone(), Some(screenshot)) - }) - } - }) + None + }; + Task::done(Message::ControlScreenshot(path, screenshot)) + } + #[cfg(not(target_arch = "wasm32"))] + { + self.control.capture_frame_pending = false; + self.control.capture_after_redraw = redrew; + let Some(window) = self.main_window else { + return Task::done(Message::ControlScreenshot(path, None)); + }; + // A minimized window has a 0x0 surface and the renderer panics + // reading it back, so report instead of capturing. + iced::window::size(window).then(move |size| { + let path = path.clone(); + if size.width <= 0.0 || size.height <= 0.0 { + Task::done(Message::ControlScreenshot(path, None)) + } else { + iced::window::screenshot(window).map(move |screenshot| { + Message::ControlScreenshot(path.clone(), Some(screenshot)) + }) + } + }) + } } } mod actions; diff --git a/src/app/update/dialog.rs b/src/app/update/dialog.rs index 8a2f6c965..5d04c72fd 100644 --- a/src/app/update/dialog.rs +++ b/src/app/update/dialog.rs @@ -198,6 +198,14 @@ impl OpenCADStudio { let Some(idx) = self.tabs.iter().position(|t| t.id == tab_id) else { return Task::batch([close_win, self.continue_tab_close_queue()]); }; + // …and, on the web, its retained in-place save handle — the + // same hook on_tab_close runs: the user declined the save, + // so the picked file must never receive a later write, and + // the next tab to reuse this id must start with a fresh + // pick instead of silently writing over the discarded + // drawing's file. + #[cfg(target_arch = "wasm32")] + crate::io::fs_access::drop_handle(tab_id); // Discarded — drop this tab's autosave recovery copy. #[cfg(not(target_arch = "wasm32"))] let _ = std::fs::remove_file(self.autosave_target(idx)); @@ -319,6 +327,21 @@ impl OpenCADStudio { #[cfg(target_arch = "wasm32")] { + // Same guard the native half runs against active_save_jobs + // above: while a web save is between capture and completion, + // refuse a second one (the window is seconds — refuse, don't + // queue) instead of letting two serializer rounds race for the + // same tab's bookkeeping. + if let Some(busy_tab) = crate::io::fs_access::web_save_in_flight() { + if self.tabs.iter().any(|tab| tab.id == busy_tab) { + self.command_line + .push_info(crate::t!("Save already running for this drawing.").as_ref()); + return Task::none(); + } + // Stale marker (its tab closed before settling): clear it + // rather than block this save forever. + crate::io::fs_access::end_web_save(busy_tab); + } match self.pending_close.take() { Some(crate::app::PendingClose::Tab(tab_id)) => { let Some(idx) = self.tabs.iter().position(|t| t.id == tab_id) else { diff --git a/src/app/update/file.rs b/src/app/update/file.rs index 49753b1d0..6032c3b2d 100644 --- a/src/app/update/file.rs +++ b/src/app/update/file.rs @@ -2718,6 +2718,9 @@ impl OpenCADStudio { ) -> Task { self.thumbnail_capture_clean = false; let Some(i) = self.tabs.iter().position(|tab| tab.id == tab_id) else { + // The tab closed while its capture was queued: the save is over + // (no serialization follows), so release the in-flight slot. + crate::io::fs_access::end_web_save(tab_id); return Task::none(); }; let preview = screenshot.as_ref().and_then(|screenshot| { @@ -2765,11 +2768,12 @@ impl OpenCADStudio { } /// Web SAVE completion: the serializer worker (or inline fallback) has the - /// drawing bytes. When the tab holds a File System Access handle the - /// bytes go to it in place (createWritable needs no user gesture, so the + /// drawing bytes. When the tab still exists AND holds a File System + /// Access handle picked for the format these bytes were serialized as, + /// they go to it in place (createWritable needs no user gesture, so the /// write may happen long after the pick); completion continues in - /// `on_web_save_written`. Otherwise the legacy download fires — the only - /// artifact of the save, launched even if the tab was closed while + /// `on_web_save_written`. Anything else takes the legacy download — the + /// only artifact of the save, launched even if the tab was closed while /// serialization was in flight. #[cfg(target_arch = "wasm32")] pub(super) fn on_web_save_serialized( @@ -2788,15 +2792,37 @@ impl OpenCADStudio { Err(error) => { self.command_line .push_error(crate::tf!("Save failed: {error}").as_ref()); + crate::io::fs_access::end_web_save(tab_id); return self.finish_web_save(false); } }; - // In-place write through the handle picked at the confirm gesture - // (or an earlier one). The bookkeeping runs at write completion - // (WebSaveWritten) so a failed write cannot clear the dirty flag; - // the bytes ride back with the completion so the OPFS recent store - // gets the same copy the download path uses. - if let Some(picked_name) = crate::io::fs_access::handle_name(tab_id) { + // In-place write eligibility, all three required: + // * the tab still exists — a tab that closed while serialization ran + // means the user declined this save (discard/close), and the + // legacy contract for closed tabs is download-only; + // * a handle is held AND it was picked for the format these bytes + // were serialized for (the pending ext recorded at capture time): + // a Save As whose re-pick failed falls back to the download, and + // the stale handle from the previous format must not receive the + // fallback bytes (a .dwg file must never silently hold DXF). + let tab_exists = self.tabs.iter().position(|tab| tab.id == tab_id).is_some(); + let target_ext = crate::io::fs_access::take_pending_save_ext(tab_id); + let write_in_place = tab_exists + && crate::io::fs_access::write_or_download( + crate::io::fs_access::handle_ext(tab_id).as_deref(), + target_ext.as_deref(), + ); + if !write_in_place { + // Whatever handle is held is stale for these bytes (wrong + // format, unverifiable format, or a tab that declined the + // save): drop it so the next save re-picks instead of hitting + // the same mismatch. No handle held: the drop is a no-op. + crate::io::fs_access::drop_handle(tab_id); + } else if let Some(picked_name) = crate::io::fs_access::handle_name(tab_id) { + // The bookkeeping runs at write completion (WebSaveWritten) so a + // failed write cannot clear the dirty flag; the bytes ride back + // with the completion message so the OPFS recent store gets the + // same copy the download path uses. let download_name = picked_name.clone(); return Task::perform( async move { @@ -2896,6 +2922,9 @@ impl OpenCADStudio { camera_generation: u64, bytes: Vec, ) -> Task { + // The save settled (download or in-place write): release the + // in-flight slot the on_save_file guard refuses against. + crate::io::fs_access::end_web_save(tab_id); let cache_name = std::path::Path::new(&filename) .file_name() .map(|name| name.to_string_lossy().into_owned()) @@ -3682,6 +3711,20 @@ impl OpenCADStudio { // once after the destination/version is known. #[cfg(target_arch = "wasm32")] { + // The web counterpart of native's active_save_jobs guard: one + // capture → serialize → write/download chain at a time. The + // window is seconds, so refuse rather than queue — same + // localized message the native flow shows. + if let Some(busy_tab) = crate::io::fs_access::web_save_in_flight() { + if self.tabs.iter().any(|tab| tab.id == busy_tab) { + self.command_line + .push_info(crate::t!("Save already running for this drawing.").as_ref()); + return Task::none(); + } + // Stale marker (its tab closed before the save could + // settle): clear it instead of blocking every later save. + crate::io::fs_access::end_web_save(busy_tab); + } self.sync_view_state_for_save(i); self.stamp_header_sysvars(i); } @@ -3911,6 +3954,12 @@ impl OpenCADStudio { ext: String, version: codec::DxfVersion, ) -> Task { + // The save is now committed to this tab and this format: mark it + // in flight (the web counterpart of native's active_save_jobs — + // on_save_file refuses a second save while the slot is held) and + // record the intended ext, which the completion half re-checks + // against the retained handle before writing. + crate::io::fs_access::begin_web_save(tab_id, &ext); let bounds = crate::ui::wrap_bar::dropdown_bounds(crate::app::view::VIEWPORT_CAPTURE_BOUNDS_ID); let Some(bounds) = bounds else { return Task::done(Message::WebSaveScreenshot { @@ -3939,7 +3988,9 @@ impl OpenCADStudio { /// any previous one — Save As re-picks); `Ok(None)` (dismissed picker) /// cancels the save like a native save-dialog cancel; `Err` keeps the /// save alive through the legacy download, with a note that the copy - /// was downloaded. + /// was downloaded — after dropping any previously retained handle, + /// which was picked for the old format and must not receive the + /// fallback bytes. #[cfg(target_arch = "wasm32")] pub(super) fn on_web_save_handle_picked( &mut self, @@ -3965,6 +4016,13 @@ impl OpenCADStudio { Task::none() } Err(error) => { + // The picker failed and the download fallback is about to + // run with bytes serialized for `ext` — but any handle still + // held was picked for (possibly) the OTHER format (Save As + // to DXF on a .dwg handle, …). Drop it now so the stale + // handle cannot receive the fallback bytes when they land; + // the next save re-picks. + crate::io::fs_access::drop_handle(tab_id); self.command_line.push_error( crate::tf!( "Save destination picker failed: {error}; downloading a copy instead" diff --git a/src/io/fs_access.rs b/src/io/fs_access.rs index 4d710df75..15673a842 100644 --- a/src/io/fs_access.rs +++ b/src/io/fs_access.rs @@ -82,6 +82,20 @@ fn exts_match(a: &str, b: &str) -> bool { strip(a) == strip(b) } +/// Whether bytes serialized for `target_ext` may be written through a +/// handle picked for `handle_ext` (`None` = no handle held; a `None` +/// target = the intended format is unknown). This is the completion-half +/// counterpart of `save_route`: routing happens at the confirm gesture, +/// but the bytes arrive later — possibly serialized for a *different* +/// format than the retained handle (a Save As whose re-pick failed falls +/// back to the download while the old handle stays in the registry), and +/// a `.dwg` handle must never receive DXF bytes (or vice versa) just +/// because it is still held. +#[cfg_attr(not(target_arch = "wasm32"), allow(dead_code))] // unit-tested on native +pub fn write_or_download(handle_ext: Option<&str>, target_ext: Option<&str>) -> bool { + matches!((handle_ext, target_ext), (Some(handle), Some(target)) if exts_match(handle, target)) +} + /// `showSaveFilePicker` rejects with a `DOMException` named "AbortError" /// when the user dismisses the dialog. That name check is the whole cancel /// classification — kept pure so it compiles and tests on every target. @@ -128,10 +142,25 @@ mod browser { /// not an App field or a Message payload — owns the handles because /// iced requires `Message: Send` even on wasm, and a JsValue-backed /// handle is not Send. (Same pattern as web_recent's OPEN_STORES.) - /// Closing a tab, and a failed write, drop its entry; Save As - /// replaces it. + /// Closing a tab, a failed write, a failed re-pick and a format + /// mismatch drop its entry; Save As replaces it. static SAVE_HANDLES: RefCell> = RefCell::new(HashMap::new()); + + /// The web save currently between `web_save_capture` and its + /// completion (serialized → written/downloaded) — the web + /// counterpart of native's `active_save_jobs` guard, kept here for + /// the same Send reason as the handles. One slot: a second save + /// while one is in flight is refused, not queued (the window is + /// seconds). + static WEB_SAVE_IN_FLIGHT: RefCell> = RefCell::new(None); + + /// The format each tab's in-flight save is being serialized for — + /// the `target_ext` input of `write_or_download`, recorded at + /// capture time because the completion messages cannot carry it + /// (the bytes may outlive several rounds of tab state). + static PENDING_SAVE_EXTS: RefCell> = + RefCell::new(HashMap::new()); } /// The extension the tab's retained handle was picked for, if any — @@ -161,6 +190,49 @@ mod browser { }); } + /// Mark `tab_id`'s save as in flight and remember the format its bytes + /// will be serialized for. Called at `web_save_capture` — the moment a + /// save is committed to a tab and a format — so `on_save_file` can + /// refuse a second concurrent save and the completion half can check + /// the bytes against the retained handle (`write_or_download`). + pub fn begin_web_save(tab_id: u64, ext: &str) { + PENDING_SAVE_EXTS.with(|exts| { + exts.borrow_mut().insert( + tab_id, + ext.trim_start_matches('.').to_ascii_lowercase(), + ); + }); + WEB_SAVE_IN_FLIGHT.with(|in_flight| in_flight.borrow_mut().replace(tab_id)); + } + + /// End `tab_id`'s in-flight save — completion, error or cancel. The + /// single slot is only released when it still names this tab: a newer + /// save that took the slot over must not have its lifecycle cut short. + pub fn end_web_save(tab_id: u64) { + WEB_SAVE_IN_FLIGHT.with(|in_flight| { + let mut slot = in_flight.borrow_mut(); + if *slot == Some(tab_id) { + *slot = None; + } + }); + PENDING_SAVE_EXTS.with(|exts| { + exts.borrow_mut().remove(&tab_id); + }); + } + + /// The tab whose web save is between capture and completion, if any — + /// what the save entry points refuse against while it is set. + pub fn web_save_in_flight() -> Option { + WEB_SAVE_IN_FLIGHT.with(|in_flight| *in_flight.borrow()) + } + + /// Take the format `tab_id`'s serialized bytes were intended for — + /// consumed (once) by the completion handler to decide + /// write-through-handle vs download via `write_or_download`. + pub fn take_pending_save_ext(tab_id: u64) -> Option { + PENDING_SAVE_EXTS.with(|exts| exts.borrow_mut().remove(&tab_id)) + } + /// Feature-detect `showSaveFilePicker` — never assume it: Firefox and /// Safari keep the download flow precisely because this returns false. pub fn supported() -> bool { @@ -314,12 +386,13 @@ mod browser { #[cfg(target_arch = "wasm32")] pub use browser::{ - drop_handle, handle_ext, handle_name, pick_save_handle, supported, write_handle, + begin_web_save, drop_handle, end_web_save, handle_ext, handle_name, pick_save_handle, + supported, take_pending_save_ext, web_save_in_flight, write_handle, }; #[cfg(test)] mod tests { - use super::{is_cancel_error_name, save_route, SaveRoute}; + use super::{is_cancel_error_name, save_route, write_or_download, SaveRoute}; /// Firefox/Safari: no showSaveFilePicker — every save stays a download, /// whatever handle state the tab happens to hold. @@ -374,4 +447,39 @@ mod tests { assert!(!is_cancel_error_name("TypeError")); assert!(!is_cancel_error_name("")); } + + /// The completion-half decision: bytes serialized for the same format + /// the retained handle was picked for may be written through it. + #[test] + fn matching_serialized_ext_writes_in_place() { + assert!(write_or_download(Some("dwg"), Some("dwg"))); + assert!(write_or_download(Some("dxf"), Some("dxf"))); + assert!(write_or_download(Some(".DWG"), Some("dwg"))); + assert!(write_or_download(Some("dxf"), Some(".DXF"))); + } + + /// The BLOCKER scenario: a Save As whose re-pick failed continues as a + /// download, but the OLD handle (picked for the other format) is still + /// in the registry — those bytes must not be written through it. + #[test] + fn stale_handle_of_other_format_downloads() { + assert!(!write_or_download(Some("dwg"), Some("dxf"))); + assert!(!write_or_download(Some("dxf"), Some("dwg"))); + } + + /// No handle held (Firefox/Safari, first save, dropped handle) — the + /// legacy download carries the bytes. + #[test] + fn no_handle_downloads() { + assert!(!write_or_download(None, Some("dwg"))); + assert!(!write_or_download(None, Some("dxf"))); + } + + /// Unknown intended format (no pending ext recorded for the save): an + /// unverifiable handle must not receive the bytes — download. + #[test] + fn unknown_target_ext_downloads() { + assert!(!write_or_download(Some("dwg"), None)); + assert!(!write_or_download(None, None)); + } } diff --git a/src/io/xref.rs b/src/io/xref.rs index c010674d7..e464a3b12 100644 --- a/src/io/xref.rs +++ b/src/io/xref.rs @@ -1405,12 +1405,25 @@ pub fn collect_entries_with_prev( /// The filesystem stat is desktop-only by `cfg` (not a runtime check): the /// wasm binary contains no `std::fs::metadata` call at all, so a web /// session can never block on — or fail from — a host-filesystem probe. - /// Path-based web references resolve through the file-handle loader and - /// simply stay `NotFound` when unresolvable. + /// Path-based web references resolve through the file-handle loader: a + /// source present in that registry reports Loaded here, and only truly + /// unregistered ones stay `NotFound`. fn stat_into(entry: &mut ReferenceEntry, raw: &str, base_dir: &Path) { if raw.is_empty() { return; } + // Web (and the native tests exercising it): `resolve_path` probes the + // filesystem, which the browser does not have — consult the source + // registry instead. A registered drawing IS loaded; its byte length + // comes free from the in-memory bytes (no read), and there is no + // mtime to report. + #[cfg(any(target_arch = "wasm32", test))] + if let Some(key) = find_registered_source(raw) { + entry.size_bytes = registered_bytes(&key).map(|b| b.len() as u64); + entry.found_at = Some(key); + entry.status = RefStatus::Loaded; + return; + } let Some(found) = resolve_path(raw, base_dir) else { return; }; @@ -2005,6 +2018,8 @@ fn bind_dwg( .find(|b| b.handle == br_handle) .map(|b| b.xref_path.clone()) .unwrap_or_default(); + // Native-only path: `resolve_path` reads the real filesystem, so DWG + // bind stays desktop-only (the web has no fs to load a bind source from). let Some(found) = resolve_path(&saved, base_dir) else { return Err(bind_failed(name, "file not found")); }; @@ -2181,6 +2196,8 @@ fn collect_bind_images( let mut planned: HashMap = HashMap::default(); let mut ops: Vec<(Handle, PathBuf, String)> = Vec::new(); for (handle, saved) in &defs { + // Native-only path: `resolve_path` stats the real filesystem (and + // the copy below reads it), so image COLLECT stays desktop-only. let Some(resolved) = resolve_path(saved, source_dir) else { return Err(bind_failed(ref_name, format!("image not found: {saved}"))); }; diff --git a/src/scene/model/image_model.rs b/src/scene/model/image_model.rs index 1b03ba9ed..270346ba0 100644 --- a/src/scene/model/image_model.rs +++ b/src/scene/model/image_model.rs @@ -574,6 +574,18 @@ pub fn register_source(path: &str, bytes: Arc>) { } } +/// Is a source registered under exactly this reference? The web paste path +/// uses it to keep its generated `pasted-…` names unique: the registry is +/// last-wins, so a reused name would silently re-point every earlier paste's +/// entity at the newest pixels. Exact key match — generated names embed their +/// own extension and are all lower-case digits. +pub fn has_source(path: &str) -> bool { + source_cache() + .lock() + .unwrap_or_else(|error| error.into_inner()) + .contains_key(path) +} + /// Every reference with bytes currently registered (picked or dropped). /// Used after a web open settles to catch images registered while the load — /// which builds the raster models — was already running. diff --git a/src/sys.rs b/src/sys.rs index 904a26c8d..1ca7afae7 100644 --- a/src/sys.rs +++ b/src/sys.rs @@ -297,6 +297,17 @@ pub fn capture_canvas() -> Option { )) } +/// Web: is the page visible? `requestAnimationFrame` — and therefore the +/// frames subscription and any canvas readback — never runs while +/// `document.hidden`, so the control `capture` op refuses to wait for a frame +/// that could never come. +#[cfg(target_arch = "wasm32")] +pub fn page_visible() -> bool { + !web_sys::window() + .and_then(|window| window.document()) + .is_some_and(|document| document.hidden()) +} + /// Web: read text from the system clipboard via the async Clipboard API. /// iced's own `clipboard::read` is a no-op on the web (the browser clipboard is /// async + permission-gated), so the editor paste paths use this instead. The @@ -340,7 +351,8 @@ fn clipboard_image_extensions_cover_the_paste_flavors() { /// Web: read an image from the system clipboard via the async Clipboard API /// (`navigator.clipboard.read()`), used when a paste found no text. Returns -/// the registry name (`pasted-YYYYMMDD-HHMMSS.`) plus the image bytes, +/// the registry name (`pasted-YYYYMMDD-HHMMSS-mmm[-N].`, unique against +/// the registry) plus the image bytes, /// or `None` when the clipboard holds no image item or the read is denied. /// Like `read_clipboard_text`, the Ctrl+V keypress that reaches it is a user /// gesture, which is what makes the permission-gated read allowed at all. @@ -380,29 +392,103 @@ pub async fn read_clipboard_image() -> Option<(String, Vec)> { if bytes.is_empty() { return None; } - return Some((pasted_image_name(&mime_type), bytes)); + let name = pasted_image_name(&mime_type); + // Register under the chosen name BEFORE returning: no other wasm + // code can run between the uniqueness check and this insert (one + // thread), so a second paste landing in the same millisecond sees + // this name as taken and takes a `-2` suffix instead of overwriting + // these bytes. The paste handler's own `register_source` call with + // the same name is then an idempotent overwrite of identical bytes. + crate::scene::model::image_model::register_source( + &name, + std::sync::Arc::new(bytes.clone()), + ); + return Some((name, bytes)); } None } -/// Registry name for a pasted clipboard image: `pasted-YYYYMMDD-HHMMSS.ext` -/// stamped from the wall clock (local time, the same clock a screenshot file -/// name uses) with the extension derived from the MIME type. +/// Registry name for a pasted clipboard image: +/// `pasted-YYYYMMDD-HHMMSS-mmm.ext` stamped from the wall clock (local time, +/// the same clock a screenshot file name uses) at millisecond precision, with +/// the extension derived from the MIME type. Milliseconds alone don't +/// guarantee uniqueness (two pastes inside one ms), so the name is pushed +/// through [`unique_pasted_image_name`] against the live registry — a reused +/// key would overwrite the earlier paste's bytes (the registry is last-wins) +/// and make its entity display the newer pixels. #[cfg(target_arch = "wasm32")] fn pasted_image_name(mime_type: &str) -> String { - let now = js_sys::Date::new(&wasm_bindgen::JsValue::from_f64(0.0)); - format!( - "pasted-{:04}{:02}{:02}-{:02}{:02}{:02}.{}", - now.get_full_year(), - now.get_month() + 1, - now.get_date(), - now.get_hours(), - now.get_minutes(), - now.get_seconds(), - clipboard_image_extension(mime_type) + let now = js_sys::Date::new_0(); + unique_pasted_image_name( + &format!( + "pasted-{:04}{:02}{:02}-{:02}{:02}{:02}-{:03}", + now.get_full_year(), + now.get_month() + 1, + now.get_date(), + now.get_hours(), + now.get_minutes(), + now.get_seconds(), + (now.get_time() as u64) % 1000, + ), + clipboard_image_extension(mime_type), + crate::scene::model::image_model::has_source, ) } +/// Turn `base` + `ext` into a registry name not taken yet: the bare +/// `base.ext` first, then `-2`, `-3`, … inserted before the extension while +/// the candidate already names a registered source. The taken-check is +/// injectable so this — the part worth locking with tests — runs natively; +/// wasm passes [`crate::scene::model::image_model::has_source`]. +#[cfg_attr(not(target_arch = "wasm32"), allow(dead_code))] // tested everywhere, used on wasm +pub(crate) fn unique_pasted_image_name( + base: &str, + ext: &str, + taken: impl Fn(&str) -> bool, +) -> String { + let first = format!("{base}.{ext}"); + if !taken(&first) { + return first; + } + for suffix in 2u64.. { + let candidate = format!("{base}-{suffix}.{ext}"); + if !taken(&candidate) { + return candidate; + } + } + unreachable!("an unbounded suffix space always yields a free name") +} + +#[test] +fn pasted_image_names_never_reuse_a_taken_registry_key() { + let mut taken: Vec = Vec::new(); + let claim = |taken: &mut Vec| { + let name = unique_pasted_image_name("pasted-20260915-101500-123", "png", |candidate| { + taken.iter().any(|used| used == candidate) + }); + taken.push(name.clone()); + name + }; + // Fresh registry: the bare millisecond-stamped name is used as-is. + assert_eq!(claim(&mut taken), "pasted-20260915-101500-123.png"); + // A second paste inside the same millisecond suffixes instead of + // overwriting — each name below must differ from every earlier one. + assert_eq!(claim(&mut taken), "pasted-20260915-101500-123-2.png"); + assert_eq!(claim(&mut taken), "pasted-20260915-101500-123-3.png"); + // The extension is part of the key: a jpg landing in the same + // millisecond does not collide with the pngs above. + assert_eq!( + unique_pasted_image_name("pasted-20260915-101500-123", "jpg", |candidate| { + taken.iter().any(|used| used == candidate) + }), + "pasted-20260915-101500-123.jpg" + ); + // Freed suffixes are reused in order: with `-2` unregistered again, the + // next paste takes `-2` back before walking further. + taken.remove(1); + assert_eq!(claim(&mut taken), "pasted-20260915-101500-123-2.png"); +} + #[cfg(target_arch = "wasm32")] #[wasm_bindgen::prelude::wasm_bindgen(module = "/web/clipboard.js")] extern "C" { diff --git a/web-app.html b/web-app.html index 049a1e75e..3c380d6df 100644 --- a/web-app.html +++ b/web-app.html @@ -38,16 +38,6 @@ animation: ocs-spin 0.9s linear infinite; } @keyframes ocs-spin { to { transform: rotate(360deg); } } - /* Browser-menu print support: hidden on screen, the only thing shown on - paper. beforeprint snapshots the WebGL canvas into it (below), so the - printout is the drawing instead of a blank page — WebGL canvases do - not composite into the print surface on their own. */ - #print-snapshot { display: none; } - @media print { - body > *:not(#print-snapshot) { display: none !important; } - html, body { overflow: visible; height: auto; background: #fff; } - #print-snapshot { display: block; max-width: 100%; height: auto; } - } @@ -66,8 +56,6 @@
Open CAD Studio
Loading…
- - -