Files
photocraft/apps/photocraft-web/src/web.rs
T
2026-09-30 05:02:08 -04:00

230 lines
8.4 KiB
Rust

//! The browser shell: web `Services`, drag-and-drop, and the eframe web runner.
use std::sync::{Arc, Mutex};
use photocraft_codecs::{ChannelLayout, EncodeOptions, Image};
use photocraft_doc::Document;
use photocraft_engine::Session;
use photocraft_ui_egui::theme::ThemeKind;
use photocraft_ui_egui::{PhotocraftApp, Services};
use wasm_bindgen::JsCast as _;
type Inbox = Arc<Mutex<Vec<(String, Vec<u8>)>>>;
const IMAGE_EXTS: &[&str] = &[
"psd", "psb", "png", "jpg", "jpeg", "tif", "tiff", "webp", "gif", "bmp", "tga", "ico", "qoi",
"exr", "hdr", "pbm", "pgm", "ppm", "pam", "pfm",
];
const CANVAS_ID: &str = "photocraft_canvas";
pub fn start() {
eframe::WebLogger::init(log::LevelFilter::Info).ok();
wasm_bindgen_futures::spawn_local(async {
let Some(document) = web_sys::window().and_then(|w| w.document()) else {
log::error!("no document");
return;
};
let Some(canvas) = document
.get_element_by_id(CANVAS_ID)
.and_then(|e| e.dyn_into::<web_sys::HtmlCanvasElement>().ok())
else {
log::error!("missing <canvas id=\"{CANVAS_ID}\">");
return;
};
let q = query();
let force_cpu = q.contains("cpu");
let mut options = eframe::WebOptions::default();
if q.contains("webgl")
&& let eframe::egui_wgpu::WgpuSetup::CreateNew(create) =
&mut options.wgpu_options.wgpu_setup
{
create.instance_descriptor.backends = eframe::wgpu::Backends::GL;
}
let result = eframe::WebRunner::new()
.start(
canvas,
options,
Box::new(move |cc| {
PhotocraftApp::setup_context(&cc.egui_ctx, ThemeKind::Pro);
let inbox: Inbox = Arc::default();
let mut app = PhotocraftApp::new(
Session::new(),
services(inbox.clone(), cc.egui_ctx.clone()),
);
app.set_theme(&cc.egui_ctx, ThemeKind::Pro);
if let Some(rs) = cc.wgpu_render_state.clone()
&& !force_cpu
{
log::info!(
"photocraft-web: wgpu backend {:?}",
rs.adapter.get_info().backend
);
app.set_wgpu(rs);
}
Ok(Box::new(WebShell { app, inbox }))
}),
)
.await;
if let Some(el) = document.get_element_by_id("photocraft_loading") {
match result {
Ok(()) => el.remove(),
Err(e) => el.set_inner_html(&format!("<p>Photocraft failed to start: {e:?}</p><p>A browser with WebGPU or WebGL2 is required.</p>")),
}
}
});
}
fn query() -> String {
web_sys::window()
.and_then(|w| w.location().search().ok())
.unwrap_or_default()
}
/// Wraps the app to read dropped files asynchronously (browsers can't read them synchronously,
/// so the app's own drop path can't handle them) and feed them through the inbox.
struct WebShell {
app: PhotocraftApp,
inbox: Inbox,
}
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 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));
ctx.request_repaint();
}
Err(e) => log::error!("couldn't read dropped file {name}: {e}"),
}
});
}
self.app.logic(ctx, frame);
}
fn ui(&mut self, ui: &mut egui::Ui, frame: &mut eframe::Frame) {
self.app.ui(ui, frame);
}
}
fn services(inbox: Inbox, ctx: egui::Context) -> Services {
let open_inbox = inbox.clone();
Services {
import: Some(Box::new(|name: &str, bytes: &[u8]| {
photocraft_io::import(name, bytes)
.map(|r| r.document)
.map_err(|e| e.to_string())
})),
export: Some(Box::new(|doc: &Document, path: &str| {
photocraft_io::export(doc, path, &Default::default())
.map(|r| r.bytes)
.map_err(|e| e.to_string())
})),
pick_open: Some(Box::new(move || {
let inbox = open_inbox.clone();
let ctx = ctx.clone();
wasm_bindgen_futures::spawn_local(async move {
let Some(file) = rfd::AsyncFileDialog::new()
.add_filter("Images", IMAGE_EXTS)
.pick_file()
.await
else {
return;
};
let bytes = file.read().await;
inbox
.lock()
.unwrap_or_else(|e| e.into_inner())
.push((file.file_name(), bytes));
ctx.request_repaint();
});
None
})),
// No save dialog on the web: the suggested name becomes the download name.
pick_save: Some(Box::new(|suggested: &str| {
let name = std::path::Path::new(suggested)
.file_name()
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_else(|| suggested.to_string());
Some(name)
})),
write: Some(Box::new(|path: &str, bytes: &[u8]| download(path, bytes))),
encode_png: Some(Box::new(|w, h, rgba| {
let img = Image::from_u8(w, h, ChannelLayout::Rgba, rgba.to_vec())
.map_err(|e| e.to_string())?;
photocraft_codecs::encode(
&img,
photocraft_codecs::Format::Png,
&EncodeOptions::default(),
)
.map_err(|e| e.to_string())
})),
inbox: Some(inbox),
}
}
/// Trigger a browser download of `bytes` named after the last component of `path`.
fn download(path: &str, bytes: &[u8]) -> Result<(), String> {
let js = |e: wasm_bindgen::JsValue| format!("{e:?}");
let name = std::path::Path::new(path)
.file_name()
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_else(|| "photocraft".into());
let window = web_sys::window().ok_or("no window")?;
let document = window.document().ok_or("no document")?;
let parts = js_sys::Array::of1(&js_sys::Uint8Array::from(bytes));
let opts = web_sys::BlobPropertyBag::new();
opts.set_type(mime_for(&name));
let blob = web_sys::Blob::new_with_u8_array_sequence_and_options(&parts, &opts).map_err(js)?;
let url = web_sys::Url::create_object_url_with_blob(&blob).map_err(js)?;
let a: web_sys::HtmlAnchorElement = document
.create_element("a")
.map_err(js)?
.dyn_into()
.map_err(|_| "not an anchor")?;
a.set_href(&url);
a.set_download(&name);
a.style().set_property("display", "none").map_err(js)?;
let body = document.body().ok_or("no body")?;
body.append_child(&a).map_err(js)?;
a.click();
a.remove();
// Revoke after the click has been dispatched; the download keeps its own reference.
let revoke = wasm_bindgen::closure::Closure::once_into_js(move || {
web_sys::Url::revoke_object_url(&url).ok();
});
window
.set_timeout_with_callback_and_timeout_and_arguments_0(revoke.unchecked_ref(), 10_000)
.map_err(js)?;
Ok(())
}
fn mime_for(name: &str) -> &'static str {
match name
.rsplit('.')
.next()
.map(str::to_ascii_lowercase)
.as_deref()
{
Some("png") => "image/png",
Some("jpg" | "jpeg") => "image/jpeg",
Some("tif" | "tiff") => "image/tiff",
Some("webp") => "image/webp",
Some("gif") => "image/gif",
Some("psd" | "psb") => "image/vnd.adobe.photoshop",
_ => "application/octet-stream",
}
}