feat: in-place save (File System Access), image paste, print wiring, web capture

The last high-severity web gap plus a small integration bundle.

In-place save: with the File System Access API (Chromium/Edge), Save
now works like desktop. Plain Save on a tab with a retained handle
writes straight through — no picker, no download, no "(1)" copies.
First save or format change opens showSaveFilePicker AT the confirm
gesture (the worker serialization that follows can outlast the
transient-activation window; retained handles need no activation to
write). Picker cancel cancels the save like desktop; picker or write
failure falls back to the legacy download with a note; Firefox/Safari
keep the download flow untouched (feature-detected). Handles are
keyed by tab id in a thread-local registry (Message must be Send —
JsValue cannot ride in messages or App state); closing a tab drops
its handle; Save As re-picks. Dirty clears only on successful
persistence, same race guard as before.

Image paste: Ctrl+V of a screenshot inserts it as an image reference
— the clipboard reader falls back to image/* items when neither the
entity clipboard nor text has anything (precedence preserved), the
bytes enter the image source registry as pasted-<timestamp>.<ext>,
and attach reuses the IMAGE command's model-creation path verbatim.

Browser print: beforeprint snapshots the WebGL canvas into a print-
only <img> (the capture_canvas technique), so the browser's own print
menu produces the drawing instead of a blank page.

Automation capture on web: the capture op no longer calls the
synchronous GPU-map screenshot (dead on wasm); main's frame-parked
capture waits for a presented frame and reads via capture_canvas — the
same workaround the save-thumbnail path uses — delivering the PNG as a
download.

(cherry picked from commit 6e89b36d)

Ported onto main (#1717 review): the web capture rides main's existing
frame-parked capture (one ControlCaptureFrame path, ignoring the timer
tick on wasm) instead of a second parked-capture state; closing a tab
drops its file handle inside main's on_tab_close.
This commit is contained in:
Afonsop authored and Hakan Seven committed 2026-10-12 12:02:05 +03:00
1 parent 0459f20129
commit 835e8d469f
33 files changed
+1084 -29

No files matched your search

+31
View File
@@ -1541,9 +1541,27 @@ impl OpenCADStudio {
}
}
#[cfg(not(target_arch = "wasm32"))]
image
.save_with_format(&path, image::ImageFormat::Png)
.map_err(|e| e.to_string())?;
// No filesystem behind a web "path": encode the PNG in memory and
// hand it to the browser as a download named after the request's
// file name — the delivery every other web output uses.
#[cfg(target_arch = "wasm32")]
{
let mut png = Vec::new();
image
.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png)
.map_err(|e| e.to_string())?;
let download = path
.rsplit(['/', '\\'])
.next()
.filter(|name| !name.is_empty())
.unwrap_or("capture.png")
.to_string();
crate::sys::download_bytes(&download, &png);
}
let doc_unit = crate::app::properties::insunits_name(
self.tabs[self.active_tab]
@@ -1624,6 +1642,19 @@ impl OpenCADStudio {
self.control.capture_frame_pending = false;
return Task::none();
};
// Web: iced's window screenshot needs a synchronous GPU map wasm
// cannot provide; the canvas readback (the save-thumbnail technique)
// only sees the drawing right after a presented frame, before the
// browser clears the WebGL buffer — so wait for a real frame.
#[cfg(target_arch = "wasm32")]
{
if !redrew {
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 {
+51
View File
@@ -915,6 +915,14 @@ pub(super) struct OpenCADStudio {
pending_native_thumbnail_save: Option<PendingNativeThumbnailSave>,
#[cfg(target_arch = "wasm32")]
pending_web_thumbnail_save: Option<PendingWebThumbnailSave>,
/// Web: whether the save in flight came from a plain Save
/// (Save/QSAVE or the save-before-close flow), so the tab's retained
/// File System Access handle (see io::fs_access) may be written
/// through directly; Save As opens the dialog modal and must re-pick.
/// Set true by `save_with_default_format`, false by
/// `open_save_dialog_window`.
#[cfg(target_arch = "wasm32")]
save_plain_reuse_fs_handle: bool,
/// One web autosave round is running; ticks that fire while it lasts are
/// dropped instead of queued (the next interval retries).
#[cfg(target_arch = "wasm32")]
@@ -2277,6 +2285,12 @@ pub enum Message {
PasteShortcutResolved(PasteFocus),
/// Completion of a system text clipboard read requested by PASTECLIP.
SystemClipboardPaste(SystemClipboardText),
/// Web: completion of the clipboard-image read the paste fallback starts
/// when no text was found — `Some((name, bytes))` attaches the image,
/// `None` reports the usual unsupported-content error. Native never
/// constructs it.
#[cfg(target_arch = "wasm32")]
SystemClipboardPasteImage(Option<(String, Vec<u8>)>),
/// Ctrl/Cmd+A — select all layer rows when the Layer Manager is open, or all
/// drawing objects otherwise (#236).
SelectAllShortcut,
@@ -2715,6 +2729,41 @@ pub enum Message {
camera_generation: u64,
result: Result<Vec<u8>, String>,
},
/// Web SAVE with the File System Access API: the showSaveFilePicker
/// promise settled. The picker ran in the save-confirm handler — the
/// user-gesture moment — because the serialization that follows may
/// outlast the transient-activation window; the retained handle (kept
/// in io::fs_access's tab-keyed registry, since a JsValue handle cannot
/// ride in a Send Message) is written to later without needing a
/// gesture. `Ok(Some(name))` = destination chosen (`name` is its real,
/// possibly renamed name); `Ok(None)` = the user dismissed the picker
/// (cancel the save, like desktop); `Err` = the picker itself failed
/// (fall back to the legacy download).
#[cfg(target_arch = "wasm32")]
WebSaveHandlePicked {
tab_id: u64,
filename: String,
ext: String,
version: codec::DxfVersion,
picked: Result<Option<String>, String>,
},
/// Web SAVE in-place completion: the retained FS Access handle's
/// createWritable write settled (on failure the task already fell back
/// to a download); run the shared save bookkeeping, including the OPFS
/// recent-copy store the download path performs. Same race-guard
/// markers as `WebSaveSerialized`.
#[cfg(target_arch = "wasm32")]
WebSaveWritten {
tab_id: u64,
filename: String,
autosave_key: String,
version: codec::DxfVersion,
geometry_epoch: u64,
edit_revision: u64,
camera_generation: u64,
result: Result<(), String>,
bytes: Vec<u8>,
},
/// Web WBLOCK: the serializer worker (or the inline fallback) produced
/// the block document bytes; download them under `filename`, then run
/// the same command-line bookkeeping as `WblockWriteFinished`.
@@ -4548,6 +4597,8 @@ impl OpenCADStudio {
#[cfg(target_arch = "wasm32")]
pending_web_thumbnail_save: None,
#[cfg(target_arch = "wasm32")]
save_plain_reuse_fs_handle: false,
#[cfg(target_arch = "wasm32")]
web_autosave_in_flight: false,
#[cfg(target_arch = "wasm32")]
web_autosave_warned: false,
+69
View File
@@ -195,6 +195,13 @@ impl OpenCADStudio {
// This tab is closing for good — drop its autosave recovery copy.
#[cfg(not(target_arch = "wasm32"))]
let _ = std::fs::remove_file(self.autosave_target(idx));
// …and, on the web, its retained in-place save handle: the next tab
// to reuse that id must start with a fresh pick instead of silently
// writing over the closed drawing's file.
#[cfg(target_arch = "wasm32")]
if let Some(closing_id) = closing_tab_id {
crate::io::fs_access::drop_handle(closing_id);
}
// Only-tab case: when the lone non-start tab closes, fall
// back to the Start tab if it exists; otherwise spawn a
// fresh blank drawing (legacy behaviour).
@@ -2095,6 +2102,68 @@ impl OpenCADStudio {
}
}
/// Attach a clipboard image read on the web (a pasted screenshot) the way
/// the IMAGE command's pick does: register the bytes name-keyed, decode
/// for the file's true pixel size, then commit the entity — but placed
/// directly at the cursor (0,0 before any pointer move) with the command's
/// default width, skipping the pick prompts a paste should not run.
#[cfg(target_arch = "wasm32")]
pub(super) fn paste_clipboard_image(&mut self, name: String, bytes: Vec<u8>) -> Task<Message> {
let i = self.active_tab;
if self.tabs[i].is_start {
self.command_line.push_error(
crate::tr!("common", "no-drawing-open-use-new-or-open-first").as_ref(),
);
return Task::none();
}
crate::scene::model::image_model::register_source(&name, std::sync::Arc::new(bytes));
let Some(decoded) = crate::scene::model::image_model::resolve_image(&name) else {
self.command_line.push_error(
crate::tr!("clipboard", "image-unsupported", name = name.clone()).as_ref(),
);
return Task::none();
};
let (pixels_w, pixels_h) = (decoded.source_width, decoded.source_height);
// Same default footprint as the IMAGE command's Enter placement: one
// world unit per 100 px (min 1), height from the true aspect ratio.
let width = (pixels_w as f64 / 100.0).max(1.0);
let aspect = if pixels_h == 0 {
1.0
} else {
pixels_w as f64 / pixels_h as f64
};
let height = width / aspect;
let origin = self.tabs[i].last_cursor_world;
let mut image = codec::entities::RasterImage::with_size(
&name,
codec::types::Vector3::new(origin.x, origin.y, origin.z),
pixels_w as f64,
pixels_h as f64,
width,
height,
);
image.flags = codec::entities::ImageDisplayFlags::SHOW_IMAGE
| codec::entities::ImageDisplayFlags::USE_CLIPPING_BOUNDARY;
let short = std::path::Path::new(&name)
.file_name()
.and_then(|n| n.to_str())
.unwrap_or(&name)
.to_string();
self.command_line.push_output(
crate::tr!(
"clipboard",
"pasted-image",
name = short,
width = pixels_w,
height = pixels_h
)
.as_ref(),
);
self.apply_cmd_result(crate::command::CmdResult::CommitAndExit(
codec::EntityType::RasterImage(image),
))
}
pub(super) fn on_qselect_open(&mut self) -> Task<Message> {
let i = self.active_tab;
self.tabs[i].scene.selection.borrow_mut().menu.open_at = None;
+9
View File
@@ -22,6 +22,15 @@ use iced::{mouse, Point, Task};
impl OpenCADStudio {
pub(in crate::app) fn open_save_dialog_window(&mut self, tab_idx: usize) -> Task<Message> {
// Every entry here is Save-As semantics (the SAVEAS command, the
// recovery save-as, the native failure redirects): the user is
// choosing a destination, so on the web the FS Access picker must
// open even when the tab already holds a handle (plain Save sets
// the flag back in save_with_default_format).
#[cfg(target_arch = "wasm32")]
{
self.save_plain_reuse_fs_handle = false;
}
// Default the format dropdown to the loaded file's own format — its
// DWG-vs-DXF kind (from the extension) and its version (from the parsed
// document) — so Save-As round-trips the format instead of silently
+257 -26
View File
@@ -2026,7 +2026,7 @@ impl OpenCADStudio {
#[cfg(target_arch = "wasm32")]
if self.web_session_focus_pending && self.web_autosave_restore_all_remaining == 0 {
self.web_session_focus_pending = false;
self.select_remembered_session_tab();
let _ = self.select_remembered_session_tab();
}
// Missing-on-open: neutral command-line notice plus a flag
@@ -2765,10 +2765,12 @@ impl OpenCADStudio {
}
/// Web SAVE completion: the serializer worker (or inline fallback) has the
/// drawing bytes. The download and tab bookkeeping that used to run
/// synchronously inside `on_web_save_screenshot`, unchanged in semantics —
/// except the dirty flag is only cleared when the serialized snapshot is
/// still the tab's current state.
/// 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
/// 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
/// serialization was in flight.
#[cfg(target_arch = "wasm32")]
pub(super) fn on_web_save_serialized(
&mut self,
@@ -2789,9 +2791,111 @@ impl OpenCADStudio {
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) {
let download_name = picked_name.clone();
return Task::perform(
async move {
let result = if let Err(error) =
crate::io::fs_access::write_handle(tab_id, &bytes).await
{
// Keep the save alive: the drawing still lands
// somewhere via the legacy download (which needs no
// activation), and the error travels with the
// completion message for the command-line note.
crate::sys::download_bytes(&download_name, &bytes);
Err(error)
} else {
Ok(())
};
(result, bytes)
},
move |(result, bytes)| Message::WebSaveWritten {
tab_id,
filename: picked_name,
autosave_key,
version,
geometry_epoch,
edit_revision,
camera_generation,
result,
bytes,
},
);
}
// The download is the only artifact of the save — fire it even if the
// tab was closed while serialization was in flight.
crate::sys::download_bytes(&filename, &bytes);
self.finalize_web_save(
tab_id,
filename,
autosave_key,
version,
geometry_epoch,
edit_revision,
camera_generation,
bytes,
)
}
/// Web SAVE in-place completion: the retained handle's write settled.
/// Success keeps the handle for the next plain Save; failure drops it
/// (the task already downloaded a copy — note that in the command line,
/// and re-pick on the next save instead of failing the same way again).
#[cfg(target_arch = "wasm32")]
pub(super) fn on_web_save_written(
&mut self,
tab_id: u64,
filename: String,
autosave_key: String,
version: codec::DxfVersion,
geometry_epoch: u64,
edit_revision: u64,
camera_generation: u64,
result: Result<(), String>,
bytes: Vec<u8>,
) -> Task<Message> {
if let Err(error) = result {
crate::io::fs_access::drop_handle(tab_id);
self.command_line.push_error(
crate::tf!(
"In-place save failed ({error}); a copy was downloaded instead"
)
.as_ref(),
);
}
self.finalize_web_save(
tab_id,
filename,
autosave_key,
version,
geometry_epoch,
edit_revision,
camera_generation,
bytes,
)
}
/// Shared completion half of the web save — download and in-place write
/// alike: recent-copy store task, tab bookkeeping under the race guard
/// (the dirty flag only clears when the persisted snapshot is still the
/// tab's current state), autosave-key retirement and the "Saved" line.
#[cfg(target_arch = "wasm32")]
fn finalize_web_save(
&mut self,
tab_id: u64,
filename: String,
autosave_key: String,
version: codec::DxfVersion,
geometry_epoch: u64,
edit_revision: u64,
camera_generation: u64,
bytes: Vec<u8>,
) -> Task<Message> {
let cache_name = std::path::Path::new(&filename)
.file_name()
.map(|name| name.to_string_lossy().into_owned())
@@ -3610,6 +3714,14 @@ impl OpenCADStudio {
/// save-before-close flow — the version picker is reserved for Save As.
pub(in crate::app) fn save_with_default_format(&mut self, tab_idx: usize) -> Task<Message> {
self.active_tab = tab_idx;
// Every caller of this entry (Save/QSAVE, the save-before-close
// flow) is plain-save semantics: on the web, the tab's retained
// FS Access handle may be written through directly. Save As opens
// the dialog modal instead (open_save_dialog_window resets this).
#[cfg(target_arch = "wasm32")]
{
self.save_plain_reuse_fs_handle = true;
}
self.save_dialog_format = if self.tabs[tab_idx].recovery_save_as_required {
let document = &self.tabs[tab_idx].scene.document;
let is_dxf =
@@ -3708,7 +3820,9 @@ impl OpenCADStudio {
);
Task::batch([close, pick])
}
// Web: no filesystem — serialize and hand the browser a download.
// Web: no filesystem path — the destination is either a retained
// File System Access handle (in-place write), a fresh
// showSaveFilePicker pick, or the legacy download.
#[cfg(target_arch = "wasm32")]
{
let close = self.close_save_dialog_window();
@@ -3718,30 +3832,147 @@ impl OpenCADStudio {
self.sync_solid_models_for_save(i);
self.tabs[i].scene.sync_native_parametric_graph();
let tab_id = self.tabs[i].id;
let bounds =
crate::ui::wrap_bar::dropdown_bounds(crate::app::view::VIEWPORT_CAPTURE_BOUNDS_ID);
let Some(bounds) = bounds else {
return Task::batch([
close,
Task::done(Message::WebSaveScreenshot {
tab_id,
filename,
ext: ext.to_string(),
version,
bounds: None,
screenshot: None,
}),
]);
};
self.pending_web_thumbnail_save = Some(crate::app::PendingWebThumbnailSave {
// File System Access routing, decided HERE: showSaveFilePicker
// may only run while the page holds the Save click's transient
// user activation, and the thumbnail capture + worker
// serialization that follow can outlast that window. So the
// handle is secured at this gesture moment (or reused from a
// previous pick — retained handles need no activation to write)
// and only the write happens later.
let route = crate::io::fs_access::save_route(
crate::io::fs_access::supported(),
self.save_plain_reuse_fs_handle,
crate::io::fs_access::handle_ext(tab_id).as_deref(),
ext,
);
match route {
crate::io::fs_access::SaveRoute::WriteInPlace => {
// Plain Save on a matching handle — THE point: Ctrl+S
// updates the picked file, no dialog at all. The real
// name (the user may have renamed it in the picker)
// drives the save bookkeeping.
let picked_name =
crate::io::fs_access::handle_name(tab_id).unwrap_or(filename);
Task::batch([
close,
self.web_save_capture(tab_id, picked_name, ext.to_string(), version),
])
}
crate::io::fs_access::SaveRoute::PickHandle => {
// The picker IS the save dialog here: it opens inside
// the click's activation window, before any serialize
// task starts. Cancelling it cancels the save (same as
// a native save-dialog cancel); a picker failure falls
// back to the legacy download.
let suggested = filename;
let pick_suggested = suggested.clone();
Task::batch([
close,
Task::perform(
async move {
crate::io::fs_access::pick_save_handle(
tab_id,
&pick_suggested,
ext,
)
.await
},
move |picked| Message::WebSaveHandlePicked {
tab_id,
filename: suggested,
ext: ext.to_string(),
version,
picked,
},
),
])
}
// Firefox/Safari (no showSaveFilePicker): untouched legacy
// download flow.
crate::io::fs_access::SaveRoute::Download => {
Task::batch([
close,
self.web_save_capture(tab_id, filename, ext.to_string(), version),
])
}
}
}
}
/// Web save, destination-known half shared by the legacy download path
/// and both File System Access paths: queue the viewport thumbnail
/// capture (or skip straight to the serializer when the bounds are
/// unavailable), which continues in `on_web_save_screenshot`.
#[cfg(target_arch = "wasm32")]
fn web_save_capture(
&mut self,
tab_id: u64,
filename: String,
ext: String,
version: codec::DxfVersion,
) -> Task<Message> {
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 {
tab_id,
filename,
ext: ext.to_string(),
ext,
version,
bounds,
bounds: None,
screenshot: None,
});
self.thumbnail_capture_clean = true;
close
};
self.pending_web_thumbnail_save = Some(crate::app::PendingWebThumbnailSave {
tab_id,
filename,
ext,
version,
bounds,
});
self.thumbnail_capture_clean = true;
Task::none()
}
/// Web SAVE: the showSaveFilePicker promise from the confirm handler
/// settled. `Ok(Some(name))` continues the save against the picked
/// file's real name (the registry already retains the handle, replacing
/// 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.
#[cfg(target_arch = "wasm32")]
pub(super) fn on_web_save_handle_picked(
&mut self,
tab_id: u64,
filename: String,
ext: String,
version: codec::DxfVersion,
picked: Result<Option<String>, String>,
) -> Task<Message> {
match picked {
Ok(Some(picked_name)) => {
self.web_save_capture(tab_id, picked_name, ext, version)
}
Ok(None) => {
// The picker was the save dialog: dismissing it means no
// save — the download fallback must NOT fire (that would
// surprise the user with a stray "drawing (1).dwg"). If
// this came from the unsaved-changes → save → close flow,
// re-show the confirmation so the tab isn't lost.
if self.save_dialog_for_unsaved && self.pending_close.is_some() {
return self.open_unsaved_dialog_window();
}
Task::none()
}
Err(error) => {
self.command_line.push_error(
crate::tf!(
"Save destination picker failed: {error}; downloading a copy instead"
)
.as_ref(),
);
self.web_save_capture(tab_id, filename, ext, version)
}
}
}
+63 -3
View File
@@ -6810,9 +6810,24 @@ impl OpenCADStudio {
self.focus_cmd_input()
}
Text::EmptyOrUnsupported => {
self.command_line
.push_error(crate::tr!("clipboard", "no-supported-content").as_ref());
Task::none()
// Web: no text — the clipboard may still hold an IMAGE
// (a pasted screenshot). Read it through the async
// Clipboard API and attach it the way the IMAGE
// command's pick does; only when that also finds
// nothing does the unsupported-content error surface.
// Native has no image route off the sync clipboard.
#[cfg(target_arch = "wasm32")]
return Task::perform(
crate::sys::read_clipboard_image(),
Message::SystemClipboardPasteImage,
);
#[cfg(not(target_arch = "wasm32"))]
{
self.command_line.push_error(
crate::tr!("clipboard", "no-supported-content").as_ref(),
);
Task::none()
}
}
Text::Unavailable => {
self.command_line
@@ -6832,6 +6847,19 @@ impl OpenCADStudio {
}
}
// Web: the clipboard-image fallback of a text-less paste either
// attaches the image or falls back to the same error the text
// path reports when nothing readable was on the clipboard.
#[cfg(target_arch = "wasm32")]
Message::SystemClipboardPasteImage(image) => match image {
Some((name, bytes)) => self.paste_clipboard_image(name, bytes),
None => {
self.command_line
.push_error(crate::tr!("clipboard", "no-supported-content").as_ref());
Task::none()
}
},
Message::SelectAllShortcut => {
let i = self.active_tab;
if self.mtext_editor.as_ref().is_some_and(|e| e.show_preview) {
@@ -10275,6 +10303,38 @@ impl OpenCADStudio {
result,
),
#[cfg(target_arch = "wasm32")]
Message::WebSaveHandlePicked {
tab_id,
filename,
ext,
version,
picked,
} => self.on_web_save_handle_picked(tab_id, filename, ext, version, picked),
#[cfg(target_arch = "wasm32")]
Message::WebSaveWritten {
tab_id,
filename,
autosave_key,
version,
geometry_epoch,
edit_revision,
camera_generation,
result,
bytes,
} => self.on_web_save_written(
tab_id,
filename,
autosave_key,
version,
geometry_epoch,
edit_revision,
camera_generation,
result,
bytes,
),
#[cfg(target_arch = "wasm32")]
Message::WebSaveallSerialized { skipped, results } => {
self.on_web_saveall_serialized(skipped, results)