forked from 1b-agent/opencadstudio-upstream
web: render plot preview in-page instead of a new tab
AutoCAD's plot preview renders the plotted result on screen instead of producing a file. The web build now does the same: the plotted PDF bytes are rasterised with the hayro pipeline already used for PDF underlays (150 DPI, first 12 pages) and shown in an in-page overlay with Download-PDF and Close actions. The previous strategy opened a new tab via window.open from the async plot task; browsers withhold popups without a live user gesture, and embedded hosts ship no PDF viewer plugin at all — so the fallback download fired and preview behaved like an export. Plain PNG <img> elements need neither permission.
This commit is contained in:
1 parent
e3c86eca29
commit
8722d84910
3 files changed
+277
-102
No files matched your search
+52
-30
@@ -8,8 +8,9 @@
|
||||
// The function is async so the UI remains responsive while the job is queued.
|
||||
//
|
||||
// The web build has no system printers: `preview_page_pdf` /
|
||||
// `preview_pages_pdf` render the same PDF bytes and open them in a browser
|
||||
// tab, where the browser's own print dialog takes over.
|
||||
// `preview_pages_pdf` render the same PDF bytes, rasterise them, and show
|
||||
// the pages in an in-page overlay — the way AutoCAD previews plot output on
|
||||
// screen rather than handing a file to an external viewer.
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
use crate::io::pdf_export;
|
||||
@@ -238,9 +239,7 @@ mod printer_buffer_tests {
|
||||
/// Build the platform printer-properties command.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn printer_properties_command(printer: Option<&str>) -> (&'static str, Vec<String>) {
|
||||
let named = printer
|
||||
.map(str::trim)
|
||||
.filter(|name| !name.is_empty());
|
||||
let named = printer.map(str::trim).filter(|name| !name.is_empty());
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
let command = match named {
|
||||
@@ -337,7 +336,7 @@ pub fn open_in_viewer(path: &std::path::Path) -> Result<(), String> {
|
||||
}
|
||||
|
||||
/// Render a page and show it the way each platform previews: the OS PDF
|
||||
/// viewer on desktop, a new browser tab on the web.
|
||||
/// viewer on desktop, an in-page rasterised overlay on the web.
|
||||
pub fn preview_page_pdf(page: &crate::io::pdf_export::PdfPageInput) -> Result<(), String> {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
@@ -348,34 +347,59 @@ pub fn preview_page_pdf(page: &crate::io::pdf_export::PdfPageInput) -> Result<()
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
{
|
||||
let bytes = crate::io::pdf_export::render_pdf_bytes(std::slice::from_ref(page), None)?;
|
||||
crate::sys::open_bytes_in_new_tab("plot_preview.pdf", &bytes);
|
||||
Ok(())
|
||||
web_preview_overlay(&bytes)
|
||||
}
|
||||
}
|
||||
|
||||
/// Web-only multi-page variant used by print-all: render every layout into
|
||||
/// one PDF and open it in a browser tab.
|
||||
/// one PDF and preview the first pages in the overlay.
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub fn preview_pages_pdf(pages: &[crate::io::pdf_export::PdfPageInput]) -> Result<(), String> {
|
||||
let bytes = crate::io::pdf_export::render_pdf_bytes(pages, None)?;
|
||||
crate::sys::open_bytes_in_new_tab("plot_preview.pdf", &bytes);
|
||||
web_preview_overlay(&bytes)
|
||||
}
|
||||
|
||||
/// Web preview: rasterise the plotted PDF (the same hayro pipeline that draws
|
||||
/// PDF underlays) and show the pages in an in-page overlay. The browser gets
|
||||
/// plain PNGs, so neither popup permission nor a PDF viewer plugin is needed.
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
fn web_preview_overlay(bytes: &[u8]) -> Result<(), String> {
|
||||
const PREVIEW_DPI: f32 = 150.0;
|
||||
const MAX_PREVIEW_PAGES: usize = 12;
|
||||
let pages = crate::scene::model::pdf_raster::rasterize_bytes_pages(
|
||||
std::sync::Arc::new(bytes.to_vec()),
|
||||
PREVIEW_DPI,
|
||||
MAX_PREVIEW_PAGES,
|
||||
);
|
||||
let mut pngs = Vec::with_capacity(pages.len());
|
||||
for page in &pages {
|
||||
let encoded =
|
||||
image::RgbaImage::from_raw(page.width, page.height, page.pixels.as_ref().clone())
|
||||
.and_then(|img| {
|
||||
let mut out = Vec::new();
|
||||
img.write_to(&mut std::io::Cursor::new(&mut out), image::ImageFormat::Png)
|
||||
.ok()?;
|
||||
Some(out)
|
||||
});
|
||||
if let Some(png) = encoded {
|
||||
pngs.push(png);
|
||||
}
|
||||
}
|
||||
crate::sys::show_pdf_preview("plot_preview.pdf", bytes, &pngs);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Dispatch a PDF to a specific printer with [`PrintOptions`].
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn dispatch_to_printer_opts(
|
||||
path: &std::path::Path,
|
||||
opts: &PrintOptions,
|
||||
) -> Result<String, String> {
|
||||
fn dispatch_to_printer_opts(path: &std::path::Path, opts: &PrintOptions) -> Result<String, String> {
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
use std::ffi::OsStr;
|
||||
use std::os::windows::ffi::OsStrExt;
|
||||
use windows_sys::Win32::Foundation::{GetLastError, ERROR_NO_ASSOCIATION};
|
||||
use windows_sys::Win32::UI::Shell::{
|
||||
ShellExecuteExW, SHELLEXECUTEINFOW, SEE_MASK_FLAG_NO_UI, SEE_MASK_NOASYNC,
|
||||
SE_ERR_NOASSOC,
|
||||
ShellExecuteExW, SEE_MASK_FLAG_NO_UI, SEE_MASK_NOASYNC, SE_ERR_NOASSOC,
|
||||
SHELLEXECUTEINFOW,
|
||||
};
|
||||
use windows_sys::Win32::UI::WindowsAndMessaging::SW_HIDE;
|
||||
|
||||
@@ -385,7 +409,10 @@ fn dispatch_to_printer_opts(
|
||||
Some(p) if !p.is_empty() => (wide("printto"), Some(wide(p)), p.to_string()),
|
||||
_ => (wide("print"), None, "default printer".to_string()),
|
||||
};
|
||||
let params_ptr = params.as_ref().map(|v| v.as_ptr()).unwrap_or(std::ptr::null());
|
||||
let params_ptr = params
|
||||
.as_ref()
|
||||
.map(|v| v.as_ptr())
|
||||
.unwrap_or(std::ptr::null());
|
||||
for _ in 0..opts.copies.max(1) {
|
||||
let mut info = SHELLEXECUTEINFOW {
|
||||
cbSize: std::mem::size_of::<SHELLEXECUTEINFOW>() as u32,
|
||||
@@ -436,20 +463,17 @@ fn dispatch_to_printer_opts(
|
||||
};
|
||||
cmd.arg("-o").arg(format!("print-quality={pq}"));
|
||||
}
|
||||
let lp_result = cmd
|
||||
.arg("--")
|
||||
.arg(path_str.as_ref())
|
||||
.output();
|
||||
let lp_result = cmd.arg("--").arg(path_str.as_ref()).output();
|
||||
if let Ok(out) = &lp_result {
|
||||
if !out.status.success() {
|
||||
// Continue to the lpr fallback below.
|
||||
} else {
|
||||
let msg = String::from_utf8_lossy(&out.stdout);
|
||||
let printer = msg
|
||||
.split_whitespace()
|
||||
.find(|w| w.contains('-'))
|
||||
.unwrap_or("printer")
|
||||
.to_string();
|
||||
let msg = String::from_utf8_lossy(&out.stdout);
|
||||
let printer = msg
|
||||
.split_whitespace()
|
||||
.find(|w| w.contains('-'))
|
||||
.unwrap_or("printer")
|
||||
.to_string();
|
||||
return Ok(printer);
|
||||
}
|
||||
}
|
||||
@@ -515,9 +539,7 @@ mod printer_properties_tests {
|
||||
fn macos_opens_print_settings() {
|
||||
let expected = (
|
||||
"open",
|
||||
vec![
|
||||
"x-apple.systempreferences:com.apple.Print-Scan-Settings.extension".to_string(),
|
||||
],
|
||||
vec!["x-apple.systempreferences:com.apple.Print-Scan-Settings.extension".to_string()],
|
||||
);
|
||||
assert_eq!(
|
||||
printer_properties_command(Some(" Office LaserJet ")),
|
||||
|
||||
@@ -67,8 +67,30 @@ fn rasterize_uncached(path: &str, page: &str) -> Option<Arc<PdfPage>> {
|
||||
let bytes = source_bytes(path)?;
|
||||
let pdf = Pdf::new(bytes).ok()?;
|
||||
let page_no = page.trim().parse::<usize>().unwrap_or(1).max(1);
|
||||
let page = pdf.pages().get(page_no - 1)?;
|
||||
let scale = RASTER_DPI / 72.0;
|
||||
let pixmap = raster_page(&pdf, page_no - 1, RASTER_DPI)?;
|
||||
let RasterizedPage {
|
||||
pixels,
|
||||
width,
|
||||
height,
|
||||
} = pixmap;
|
||||
Some(Arc::new(PdfPage {
|
||||
pixels,
|
||||
width,
|
||||
height,
|
||||
dpi: RASTER_DPI,
|
||||
}))
|
||||
}
|
||||
|
||||
struct RasterizedPage {
|
||||
pixels: Arc<Vec<u8>>,
|
||||
width: u32,
|
||||
height: u32,
|
||||
}
|
||||
|
||||
/// Rasterise one 0-based page index at `dpi` onto a white background.
|
||||
fn raster_page(pdf: &Pdf, index: usize, dpi: f32) -> Option<RasterizedPage> {
|
||||
let page = pdf.pages().get(index)?;
|
||||
let scale = dpi / 72.0;
|
||||
let pixmap = hayro::render(
|
||||
page,
|
||||
&RenderCache::new(),
|
||||
@@ -80,14 +102,32 @@ fn rasterize_uncached(path: &str, page: &str) -> Option<Arc<PdfPage>> {
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
let width = u32::from(pixmap.width());
|
||||
let height = u32::from(pixmap.height());
|
||||
Some(Arc::new(PdfPage {
|
||||
Some(RasterizedPage {
|
||||
pixels: Arc::new(pixmap.data_as_u8_slice().to_vec()),
|
||||
width,
|
||||
height,
|
||||
dpi: RASTER_DPI,
|
||||
}))
|
||||
width: u32::from(pixmap.width()),
|
||||
height: u32::from(pixmap.height()),
|
||||
})
|
||||
}
|
||||
|
||||
/// One-shot rasterisation of freshly generated PDF bytes (plot preview).
|
||||
/// Uncached: the bytes differ on every plot, so a cache would only grow.
|
||||
/// Returns at most `max_pages` pages; larger plots preview their first pages.
|
||||
pub fn rasterize_bytes_pages(bytes: Arc<Vec<u8>>, dpi: f32, max_pages: usize) -> Vec<Arc<PdfPage>> {
|
||||
let Ok(pdf) = Pdf::new(bytes) else {
|
||||
return Vec::new();
|
||||
};
|
||||
let count = pdf.pages().len().min(max_pages);
|
||||
(0..count)
|
||||
.filter_map(|index| {
|
||||
let page = raster_page(&pdf, index, dpi)?;
|
||||
Some(Arc::new(PdfPage {
|
||||
pixels: page.pixels,
|
||||
width: page.width,
|
||||
height: page.height,
|
||||
dpi,
|
||||
}))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn source_bytes(path: &str) -> Option<Arc<Vec<u8>>> {
|
||||
@@ -131,4 +171,23 @@ mod tests {
|
||||
assert_eq!((page.width, page.height), (150, 150));
|
||||
assert_eq!(page.pixels.len(), 150 * 150 * 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bytes_pages_render_every_page_up_to_the_cap() {
|
||||
let mut document = PdfDocument::new("multi-page");
|
||||
for _ in 0..3 {
|
||||
document
|
||||
.pages
|
||||
.push(OutputPage::new(Mm(50.8), Mm(25.4), Vec::new()));
|
||||
}
|
||||
let bytes = Arc::new(document.save(&PdfSaveOptions::default(), &mut Vec::new()));
|
||||
|
||||
let all = rasterize_bytes_pages(bytes.clone(), 72.0, 10);
|
||||
assert_eq!(all.len(), 3);
|
||||
assert_eq!((all[0].width, all[0].height), (144, 72));
|
||||
assert_eq!(all[0].dpi, 72.0);
|
||||
|
||||
let capped = rasterize_bytes_pages(bytes, 72.0, 2);
|
||||
assert_eq!(capped.len(), 2);
|
||||
}
|
||||
}
|
||||
+157
-63
@@ -7,8 +7,7 @@ pub(crate) fn web_hyperlink(value: &str) -> Option<String> {
|
||||
return None;
|
||||
}
|
||||
let url = url::Url::parse(value.trim()).ok()?;
|
||||
(matches!(url.scheme(), "http" | "https") && url.host_str().is_some())
|
||||
.then(|| url.into())
|
||||
(matches!(url.scheme(), "http" | "https") && url.host_str().is_some()).then(|| url.into())
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -17,9 +16,15 @@ fn drawing_hyperlinks_only_open_web_pages() {
|
||||
assert!(web_hyperlink(value).is_some(), "{value}");
|
||||
}
|
||||
for value in [
|
||||
"", "https://", "javascript:alert(1)", "data:text/html,example",
|
||||
"file:///tmp/program.desktop", "/tmp/program.desktop", "custom:run",
|
||||
"mailto:[email protected]", "https://example.com/\npath",
|
||||
"",
|
||||
"https://",
|
||||
"javascript:alert(1)",
|
||||
"data:text/html,example",
|
||||
"file:///tmp/program.desktop",
|
||||
"/tmp/program.desktop",
|
||||
"custom:run",
|
||||
"mailto:[email protected]",
|
||||
"https://example.com/\npath",
|
||||
] {
|
||||
assert!(web_hyperlink(value).is_none(), "{value}");
|
||||
}
|
||||
@@ -52,14 +57,10 @@ pub fn open_url<Message: Send + 'static>(
|
||||
fn linux_activation_token(window: &dyn iced::window::Window) -> Option<String> {
|
||||
use iced::window::raw_window_handle::{RawDisplayHandle, RawWindowHandle};
|
||||
|
||||
let RawWindowHandle::Wayland(window_handle) =
|
||||
window.window_handle().ok()?.as_raw()
|
||||
else {
|
||||
let RawWindowHandle::Wayland(window_handle) = window.window_handle().ok()?.as_raw() else {
|
||||
return None;
|
||||
};
|
||||
let RawDisplayHandle::Wayland(display_handle) =
|
||||
window.display_handle().ok()?.as_raw()
|
||||
else {
|
||||
let RawDisplayHandle::Wayland(display_handle) = window.display_handle().ok()?.as_raw() else {
|
||||
return None;
|
||||
};
|
||||
|
||||
@@ -67,13 +68,11 @@ fn linux_activation_token(window: &dyn iced::window::Window) -> Option<String> {
|
||||
// The returned token is an owned string and is immediately handed to the
|
||||
// child process.
|
||||
unsafe {
|
||||
iced::futures::executor::block_on(
|
||||
ashpd::ActivationToken::from_wayland_raw(
|
||||
None,
|
||||
window_handle.surface.as_ptr(),
|
||||
display_handle.display.as_ptr(),
|
||||
),
|
||||
)
|
||||
iced::futures::executor::block_on(ashpd::ActivationToken::from_wayland_raw(
|
||||
None,
|
||||
window_handle.surface.as_ptr(),
|
||||
display_handle.display.as_ptr(),
|
||||
))
|
||||
}
|
||||
.map(String::from)
|
||||
}
|
||||
@@ -101,10 +100,7 @@ fn open_url_linux(url: &str, activation_token: Option<String>) {
|
||||
}
|
||||
|
||||
#[cfg(all(not(target_arch = "wasm32"), not(target_os = "linux")))]
|
||||
pub fn open_url<Message>(
|
||||
url: &str,
|
||||
_parent: Option<iced::window::Id>,
|
||||
) -> iced::Task<Message> {
|
||||
pub fn open_url<Message>(url: &str, _parent: Option<iced::window::Id>) -> iced::Task<Message> {
|
||||
let _ = open::that_detached(url);
|
||||
iced::Task::none()
|
||||
}
|
||||
@@ -112,10 +108,7 @@ pub fn open_url<Message>(
|
||||
/// Web opens the tab synchronously so the browser still sees the click as a
|
||||
/// user gesture and does not block it as a pop-up.
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub fn open_url<Message>(
|
||||
url: &str,
|
||||
_parent: Option<iced::window::Id>,
|
||||
) -> iced::Task<Message> {
|
||||
pub fn open_url<Message>(url: &str, _parent: Option<iced::window::Id>) -> iced::Task<Message> {
|
||||
if let Some(window) = web_sys::window() {
|
||||
let _ = window.open_with_url_and_target_and_features(url, "_blank", "noopener,noreferrer");
|
||||
}
|
||||
@@ -148,12 +141,11 @@ pub fn set_unsaved_changes_warning(active: bool) {
|
||||
return;
|
||||
}
|
||||
if active {
|
||||
let callback = wasm_bindgen::closure::Closure::new(
|
||||
move |event: web_sys::BeforeUnloadEvent| {
|
||||
let callback =
|
||||
wasm_bindgen::closure::Closure::new(move |event: web_sys::BeforeUnloadEvent| {
|
||||
event.prevent_default();
|
||||
event.set_return_value("");
|
||||
},
|
||||
);
|
||||
});
|
||||
window.set_onbeforeunload(Some(callback.as_ref().unchecked_ref()));
|
||||
*warning = Some(callback);
|
||||
} else {
|
||||
@@ -254,7 +246,11 @@ pub async fn read_clipboard_text() -> Option<String> {
|
||||
#[wasm_bindgen::prelude::wasm_bindgen(module = "/web/clipboard.js")]
|
||||
extern "C" {
|
||||
#[wasm_bindgen::prelude::wasm_bindgen(js_name = copyHistory)]
|
||||
pub fn copy_history_text(text: &str, fallback_label: &str, close_label: &str) -> js_sys::Promise;
|
||||
pub fn copy_history_text(
|
||||
text: &str,
|
||||
fallback_label: &str,
|
||||
close_label: &str,
|
||||
) -> js_sys::Promise;
|
||||
}
|
||||
|
||||
/// Web: write text to the system clipboard (fire-and-forget). Backs Ctrl+C in
|
||||
@@ -286,9 +282,7 @@ pub fn synthesize_typing(text: &str) {
|
||||
init.set_bubbles(true);
|
||||
init.set_cancelable(true);
|
||||
for kind in ["keydown", "keyup"] {
|
||||
if let Ok(ev) =
|
||||
web_sys::KeyboardEvent::new_with_keyboard_event_init_dict(kind, &init)
|
||||
{
|
||||
if let Ok(ev) = web_sys::KeyboardEvent::new_with_keyboard_event_init_dict(kind, &init) {
|
||||
let _ = canvas.dispatch_event(&ev);
|
||||
}
|
||||
}
|
||||
@@ -353,8 +347,12 @@ pub fn file_dialog() -> rfd::AsyncFileDialog {
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub fn download_bytes(name: &str, bytes: &[u8]) {
|
||||
use wasm_bindgen::JsCast;
|
||||
let Some(window) = web_sys::window() else { return };
|
||||
let Some(document) = window.document() else { return };
|
||||
let Some(window) = web_sys::window() else {
|
||||
return;
|
||||
};
|
||||
let Some(document) = window.document() else {
|
||||
return;
|
||||
};
|
||||
let array = js_sys::Uint8Array::from(bytes);
|
||||
let parts = js_sys::Array::new();
|
||||
parts.push(&array.buffer());
|
||||
@@ -373,40 +371,133 @@ pub fn download_bytes(name: &str, bytes: &[u8]) {
|
||||
let _ = web_sys::Url::revoke_object_url(&url);
|
||||
}
|
||||
|
||||
/// Open `bytes` (e.g. a generated PDF) in a new browser tab. Web only.
|
||||
/// Falls back to a download when the popup is blocked, so the output is
|
||||
/// never lost. Used for plot preview and as the web stand-in for printing.
|
||||
/// Show a plot preview as an in-page overlay. The plotted PDF arrives already
|
||||
/// rasterised to PNG pages: inline `<img>` elements render in every browser,
|
||||
/// with no popup permission and no PDF viewer plugin — both of which embedded
|
||||
/// browser hosts routinely withhold. A Download button still hands out the
|
||||
/// vector PDF, Close drops the overlay. Web only.
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub fn open_bytes_in_new_tab(name: &str, bytes: &[u8]) {
|
||||
pub fn show_pdf_preview(name: &str, pdf_bytes: &[u8], page_pngs: &[Vec<u8>]) {
|
||||
use wasm_bindgen::JsCast;
|
||||
let Some(window) = web_sys::window() else { return };
|
||||
let array = js_sys::Uint8Array::from(bytes);
|
||||
let parts = js_sys::Array::new();
|
||||
parts.push(&array.buffer());
|
||||
let Ok(blob) = web_sys::Blob::new_with_u8_array_sequence(&parts) else {
|
||||
let Some(window) = web_sys::window() else {
|
||||
return;
|
||||
};
|
||||
let Ok(url) = web_sys::Url::create_object_url_with_blob(&blob) else {
|
||||
let Some(document) = window.document() else {
|
||||
return;
|
||||
};
|
||||
let tab = window.open_with_url_and_target(&url, "_blank").ok().flatten();
|
||||
if tab.is_none() {
|
||||
// Popup blocked: free the URL right away and fall back to a plain
|
||||
// download so the rendered output is never lost.
|
||||
let _ = web_sys::Url::revoke_object_url(&url);
|
||||
download_bytes(name, bytes);
|
||||
if page_pngs.is_empty() {
|
||||
// Rasterisation produced nothing: deliver the PDF itself rather than
|
||||
// show an empty frame.
|
||||
download_bytes(name, pdf_bytes);
|
||||
return;
|
||||
}
|
||||
// The new tab loads the blob asynchronously, so the URL must outlive this
|
||||
// call; revoke it a minute later, once the viewer has the content. The
|
||||
// one-shot closure is owned by the timer, not stored Rust-side.
|
||||
let revoke = wasm_bindgen::closure::Closure::once_into_js(move || {
|
||||
let _ = web_sys::Url::revoke_object_url(&url);
|
||||
});
|
||||
let _ = window.set_timeout_with_callback_and_timeout_and_arguments_0(
|
||||
revoke.unchecked_ref(),
|
||||
60_000,
|
||||
);
|
||||
let Ok(el) = document.create_element("div") else {
|
||||
download_bytes(name, pdf_bytes);
|
||||
return;
|
||||
};
|
||||
let backdrop: web_sys::HtmlElement = el.unchecked_into();
|
||||
backdrop
|
||||
.set_attribute(
|
||||
"style",
|
||||
"position:fixed;inset:0;background:rgba(0,0,0,.88);z-index:30000;overflow:auto;\
|
||||
display:flex;flex-direction:column;align-items:center;gap:14px;padding:24px;\
|
||||
font-family:system-ui,sans-serif;color:#ddd",
|
||||
)
|
||||
.ok();
|
||||
|
||||
let mut urls = Vec::new();
|
||||
for png in page_pngs {
|
||||
let array = js_sys::Uint8Array::from(png.as_slice());
|
||||
let parts = js_sys::Array::new();
|
||||
parts.push(&array.buffer());
|
||||
let Ok(blob) = web_sys::Blob::new_with_u8_array_sequence(&parts) else {
|
||||
continue;
|
||||
};
|
||||
let Ok(url) = web_sys::Url::create_object_url_with_blob(&blob) else {
|
||||
continue;
|
||||
};
|
||||
if let Ok(el) = document.create_element("img") {
|
||||
let img: web_sys::HtmlImageElement = el.unchecked_into();
|
||||
img.set_src(&url);
|
||||
img.set_attribute(
|
||||
"style",
|
||||
"max-width:min(92vw,1000px);height:auto;background:#fff;\
|
||||
box-shadow:0 2px 14px rgba(0,0,0,.6)",
|
||||
)
|
||||
.ok();
|
||||
backdrop.append_child(&img).ok();
|
||||
urls.push(url);
|
||||
}
|
||||
}
|
||||
if urls.is_empty() {
|
||||
download_bytes(name, pdf_bytes);
|
||||
return;
|
||||
}
|
||||
|
||||
// Sticky action bar so Download/Close stay reachable while pages scroll.
|
||||
let bar: web_sys::HtmlElement = document
|
||||
.create_element("div")
|
||||
.ok()
|
||||
.and_then(|el| {
|
||||
let bar: web_sys::HtmlElement = el.unchecked_into();
|
||||
bar.set_attribute(
|
||||
"style",
|
||||
"position:sticky;top:0;display:flex;gap:12px;align-items:center;\
|
||||
background:rgba(0,0,0,.88);padding:4px 8px",
|
||||
)
|
||||
.ok()?;
|
||||
Some(bar)
|
||||
})
|
||||
.unwrap_or_else(|| backdrop.clone());
|
||||
if let Ok(el) = document.create_element("span") {
|
||||
let label: web_sys::HtmlElement = el.unchecked_into();
|
||||
label.set_inner_text(&format!(
|
||||
"Plot preview — {} page{}",
|
||||
urls.len(),
|
||||
if urls.len() == 1 { "" } else { "s" }
|
||||
));
|
||||
bar.append_child(&label).ok();
|
||||
}
|
||||
let button = |text: &str| -> Option<web_sys::HtmlElement> {
|
||||
let el = document.create_element("button").ok()?;
|
||||
let btn: web_sys::HtmlElement = el.unchecked_into();
|
||||
btn.set_inner_text(text);
|
||||
btn.set_attribute(
|
||||
"style",
|
||||
"padding:8px 18px;border:0;border-radius:4px;cursor:pointer;\
|
||||
background:#3399e6;color:#fff;font-size:14px",
|
||||
)
|
||||
.ok()?;
|
||||
Some(btn)
|
||||
};
|
||||
if let Some(dl) = button("Download PDF") {
|
||||
let name = name.to_string();
|
||||
let bytes = pdf_bytes.to_vec();
|
||||
let click = wasm_bindgen::closure::Closure::once_into_js(move || {
|
||||
download_bytes(&name, &bytes);
|
||||
});
|
||||
let click: js_sys::Function = click.unchecked_into();
|
||||
dl.set_onclick(Some(&click));
|
||||
bar.append_child(&dl).ok();
|
||||
}
|
||||
if let Some(close) = button("Close") {
|
||||
let backdrop = backdrop.clone();
|
||||
let click = wasm_bindgen::closure::Closure::once_into_js(move || {
|
||||
for url in &urls {
|
||||
let _ = web_sys::Url::revoke_object_url(url);
|
||||
}
|
||||
backdrop.remove();
|
||||
});
|
||||
let click: js_sys::Function = click.unchecked_into();
|
||||
close.set_onclick(Some(&click));
|
||||
bar.append_child(&close).ok();
|
||||
}
|
||||
if let Some(first) = backdrop.first_child() {
|
||||
let _ = backdrop.insert_before(&bar, Some(&first));
|
||||
}
|
||||
if let Some(body) = document.body() {
|
||||
body.append_child(&backdrop).ok();
|
||||
}
|
||||
}
|
||||
|
||||
/// Short platform string for bug reports: OS + architecture on the desktop,
|
||||
@@ -519,7 +610,10 @@ pub mod web_diag {
|
||||
);
|
||||
let _ = body.append_child(&overlay);
|
||||
for (id, label) in [
|
||||
("ocs-err-title", crate::t!("OpenCADStudio renderer error — copy this into a bug report:")),
|
||||
(
|
||||
"ocs-err-title",
|
||||
crate::t!("OpenCADStudio renderer error — copy this into a bug report:"),
|
||||
),
|
||||
("ocs-err-copy", crate::t!("Copy")),
|
||||
("ocs-err-dismiss", crate::t!("Dismiss")),
|
||||
] {
|
||||
|
||||
Reference in new issue
Block a user