diff --git a/Cargo.lock b/Cargo.lock index d373ce9..6f3e1ee 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -881,6 +881,23 @@ dependencies = [ "serde_json", ] +[[package]] +name = "designcraft-web" +version = "0.1.0" +dependencies = [ + "designcraft-engine", + "designcraft-ui-egui", + "eframe", + "egui", + "js-sys", + "log", + "rfd", + "serde_json", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + [[package]] name = "dispatch" version = "0.2.0" diff --git a/Cargo.toml b/Cargo.toml index 50220d7..967cb6d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,7 +1,7 @@ [workspace] resolver = "3" members = ["crates/*", "apps/*", "xtask"] -default-members = ["crates/*", "apps/*", "xtask"] +default-members = ["crates/*", "apps/designcraft", "apps/designcraft-cli", "xtask"] [workspace.package] version = "0.1.0" diff --git a/README.md b/README.md index 79cb6a3..3a1d52c 100644 --- a/README.md +++ b/README.md @@ -43,6 +43,15 @@ cargo xtask ci # fmt, clippy, tests, Drive a running app: send JSON lines to `127.0.0.1:7979` — see [`docs/control-protocol.md`](docs/control-protocol.md). +### Web + +```sh +cd apps/designcraft-web && trunk build --release # → dist/web (serve it with any static server) +cd apps/designcraft-web && trunk serve --release # http://127.0.0.1:8767 +``` + +Needs [trunk](https://trunkrs.dev) and the `wasm32-unknown-unknown` target. The same app runs through eframe's web runner on WebGPU, falling back to WebGL2 (`?webgl` forces it; `?sample` opens the sample magazine). Open and Place use the browser's file picker (dropping files works too); Save and Export download the file. The web build has no control channel. + ## Architecture An engine-first Cargo workspace with enforced layering (`cargo xtask layers`); the egui frontend is a separate crate, so the UI can be swapped without touching the engine. @@ -56,7 +65,7 @@ An engine-first Cargo workspace with enforced layering (`cargo xtask layers`); t | L4 | `tools` (pointer events → commands + overlays) | | L5 | `engine` (session, history, command registry) | | L6 | `ui-egui` (InDesign-style UI, control channel) | -| L7 | `apps/designcraft`, `apps/designcraft-cli` | +| L7 | `apps/designcraft`, `apps/designcraft-cli`, `apps/designcraft-web` | Status and milestones: **[ROADMAP.md](ROADMAP.md)**. Contributor and agent rules (clean-room, asset policy, quality gates): [`AGENTS.md`](AGENTS.md). Every bundled asset is listed with its licence in [`ASSETS.md`](ASSETS.md). diff --git a/ROADMAP.md b/ROADMAP.md index 1420695..6366c59 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -11,8 +11,9 @@ DesignCraft aims at full Adobe InDesign parity — and to be better: faster, ope - UI (egui): application bar + menus, Control panel (object and text modes), Tools panel with flyouts, document tabs, rulers, pasteboard, guides (margins, columns, bleed, baseline grid), frame edges, selection handles, text ports and threads, Properties / Pages / Layers dock, Swatches, Styles, Character, Paragraph, Stroke, Text Wrap panels, New Document dialog, ⌘K palette, 4 brightness themes. - Tools: Selection (click, marquee, move, Alt-duplicate, resize handles), Direct Selection (basic), Type (draw frame, click caret, select, type), Rectangle/Ellipse/Polygon (+ frame variants), Line, Hand, Zoom. - ~100 commands, all reachable through the JSON control channel; headless CLI rendering to PNG. +- Web build (`apps/designcraft-web`, trunk): the same UI on WebGPU with a WebGL2 fallback; open/place via the browser file picker or drag-and-drop, save/export as downloads. -**Next (in order):** MCP server · PDF export (krilla, PDF/X-4) · IDML import/export · native format as zip · web build · Pen tool & Direct Selection anchors · Story Editor · Find/Change (+GREP) · tables · Liang hyphenation · smart guides · Links panel + relink · Preflight · TOC/index/footnotes · EPUB. +**Next (in order):** MCP server · PDF export (krilla, PDF/X-4) · IDML import/export · native format as zip · Pen tool & Direct Selection anchors · Story Editor · Find/Change (+GREP) · tables · Liang hyphenation · smart guides · Links panel + relink · Preflight · TOC/index/footnotes · EPUB. ## Milestones diff --git a/apps/designcraft-web/Cargo.toml b/apps/designcraft-web/Cargo.toml new file mode 100644 index 0000000..d27b3c4 --- /dev/null +++ b/apps/designcraft-web/Cargo.toml @@ -0,0 +1,30 @@ +[package] +name = "designcraft-web" +description = "DesignCraft in the browser (eframe web runner, WebGPU with WebGL2 fallback)" +version.workspace = true +edition.workspace = true +license.workspace = true +publish = false + +[lints] +workspace = true + +[[bin]] +name = "designcraft-web" +path = "src/main.rs" + +[dependencies] +designcraft-engine = { workspace = true } +designcraft-ui-egui = { workspace = true } +egui = { workspace = true } +log = { workspace = true } +serde_json = { workspace = true } + +[target.'cfg(target_arch = "wasm32")'.dependencies] +# `wgpu` pulls egui-wgpu's defaults: WebGPU plus the WebGL2 fallback. +eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit"] } +rfd = { workspace = true } +wasm-bindgen = "0.2" +wasm-bindgen-futures = "0.4" +js-sys = "0.3" +web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "HtmlAnchorElement", "Blob", "BlobPropertyBag", "Url", "Location", "CssStyleDeclaration"] } diff --git a/apps/designcraft-web/Trunk.toml b/apps/designcraft-web/Trunk.toml new file mode 100644 index 0000000..19126e5 --- /dev/null +++ b/apps/designcraft-web/Trunk.toml @@ -0,0 +1,10 @@ +# Build with: trunk build --release (from apps/designcraft-web) +[build] +target = "index.html" +dist = "../../dist/web" +public_url = "./" +filehash = true + +[serve] +addresses = ["127.0.0.1"] +port = 8767 diff --git a/apps/designcraft-web/index.html b/apps/designcraft-web/index.html new file mode 100644 index 0000000..b05be39 --- /dev/null +++ b/apps/designcraft-web/index.html @@ -0,0 +1,19 @@ + + + + + + DesignCraft + + + + + +
Loading DesignCraft…
+ + diff --git a/apps/designcraft-web/src/main.rs b/apps/designcraft-web/src/main.rs new file mode 100644 index 0000000..5ccd73a --- /dev/null +++ b/apps/designcraft-web/src/main.rs @@ -0,0 +1,28 @@ +//! DesignCraft in the browser. +//! +//! Runs the same [`designcraft_ui_egui::DesignApp`] as the desktop app through eframe's web +//! runner (wgpu: WebGPU where available, WebGL2 otherwise). Build with `trunk build --release` +//! from this directory (output in `dist/web`). +//! +//! Differences from the desktop app: +//! - no TCP control channel (browsers can't listen on sockets); +//! - File → Open / Place use the browser file picker; bytes arrive asynchronously through +//! `Services::inbox` (`.designcraft` → `file.openBytes`, images → `file.place`); +//! - Save and Export trigger a browser download; +//! - dropped files are read asynchronously by `web::WebShell` and delivered through the inbox. +//! +//! URL query flags: `?webgl` forces the WebGL2 backend instead of WebGPU; `?sample` opens the +//! sample magazine on start. + +#[cfg(target_arch = "wasm32")] +mod web; + +#[cfg(target_arch = "wasm32")] +fn main() { + web::start(); +} + +#[cfg(not(target_arch = "wasm32"))] +fn main() { + eprintln!("designcraft-web only runs in the browser: build it with `trunk build --release` in apps/designcraft-web"); +} diff --git a/apps/designcraft-web/src/web.rs b/apps/designcraft-web/src/web.rs new file mode 100644 index 0000000..b440ef3 --- /dev/null +++ b/apps/designcraft-web/src/web.rs @@ -0,0 +1,163 @@ +//! The browser shell: web `Services`, drag-and-drop, and the eframe web runner. + +use designcraft_engine::Session; +use designcraft_ui_egui::{DesignApp, Inbox, Services}; +use wasm_bindgen::JsCast as _; + +const DOC_EXTS: &[&str] = &["designcraft"]; +const IMAGE_EXTS: &[&str] = &["png", "jpg", "jpeg", "gif", "webp", "tif", "tiff"]; +const CANVAS_ID: &str = "designcraft_canvas"; +const LOADING_ID: &str = "designcraft_loading"; + +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::().ok()) else { + log::error!("missing "); + return; + }; + let mut options = eframe::WebOptions::default(); + if query().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| { + if let Some(rs) = &cc.wgpu_render_state { + log::info!("designcraft-web: wgpu backend {:?}", rs.adapter.get_info().backend); + } + let inbox: Inbox = Inbox::default(); + let mut app = DesignApp::new(Session::new(), services(inbox.clone(), cc.egui_ctx.clone())); + if query().contains("sample") { + let _ = app.run("file.newSample", serde_json::json!({})); + } + Ok(Box::new(WebShell { app, inbox })) + }), + ) + .await; + if let Some(el) = document.get_element_by_id(LOADING_ID) { + match result { + Ok(()) => el.remove(), + Err(e) => el.set_inner_html(&format!("

DesignCraft failed to start: {e:?}

A browser with WebGPU or WebGL2 is required.

")), + } + } + }); +} + +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) +/// and feed them through the inbox. +struct WebShell { + app: DesignApp, + 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); + } + + fn raw_input_hook(&mut self, _ctx: &egui::Context, raw: &mut egui::RawInput) { + self.app.raw_input_hook(raw); + } + + fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) { + self.app.ui(ui); + } +} + +fn services(inbox: Inbox, ctx: egui::Context) -> Services { + let open_inbox = inbox.clone(); + Services { + open_async: Some(Box::new(move |purpose: &str| { + let inbox = open_inbox.clone(); + let ctx = ctx.clone(); + let dialog = if purpose == "place" { + rfd::AsyncFileDialog::new().add_filter("Graphics", IMAGE_EXTS) + } else { + rfd::AsyncFileDialog::new().add_filter("DesignCraft", DOC_EXTS) + }; + wasm_bindgen_futures::spawn_local(async move { + let Some(file) = dialog.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(); + }); + })), + // Exports ask for a name; the browser decides where the download goes. + pick_save: Some(Box::new(|name: &str| Some(name.to_string()))), + write: Some(Box::new(|name: &str, bytes: &[u8]| download(name, bytes))), + download: Some(Box::new(|name: &str, bytes: &[u8]| { + if let Err(e) = download(name, bytes) { + log::error!("download of {name} failed: {e}"); + } + })), + inbox: Some(inbox), + ..Default::default() + } +} + +/// 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(|| "designcraft".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("pdf") => "application/pdf", + Some("designcraft") => "application/json", + _ => "application/octet-stream", + } +} diff --git a/apps/designcraft/src/main.rs b/apps/designcraft/src/main.rs index e1c2508..f6e38df 100644 --- a/apps/designcraft/src/main.rs +++ b/apps/designcraft/src/main.rs @@ -80,6 +80,7 @@ fn services() -> Services { pick_save: Some(Box::new(|name: &str| rfd::FileDialog::new().set_file_name(name).save_file().map(|p| p.to_string_lossy().to_string()))), read: Some(Box::new(|p: &str| std::fs::read(p).map_err(|e| e.to_string()))), write: Some(Box::new(|p: &str, b: &[u8]| std::fs::write(p, b).map_err(|e| e.to_string()))), + ..Default::default() } } diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index bec685b..a1a2280 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -30,6 +30,10 @@ pub use control::{ControlRequest, ControlResponse}; pub type ReadFn = Box Result, String>>; pub type WriteFn = Box Result<(), String>>; pub type PickFn = Box Option>; +pub type OpenAsyncFn = Box; +pub type DownloadFn = Box; +/// Files `(name, bytes)` delivered asynchronously by the host (web file picker, dropped files). +pub type Inbox = std::sync::Arc)>>>; /// Platform services injected by the host (desktop or web). #[derive(Default)] @@ -40,6 +44,15 @@ pub struct Services { pub pick_save: Option, pub read: Option, pub write: Option, + /// Asynchronous open dialog for a purpose (`open`, `place`); the chosen file arrives later + /// through [`Services::inbox`]. Used when `pick_open` is unset (web). + pub open_async: Option, + /// 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, + /// Files delivered asynchronously, drained every frame: `.designcraft` → `file.openBytes`, + /// anything else → `file.place`. + pub inbox: Option, } #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] @@ -227,10 +240,7 @@ impl DesignApp { UiRequest::View { params } => canvas::apply_view_request(self, ¶ms), UiRequest::Pick { purpose, params } => { let _ = params; - if let Some(path) = self.services.pick_open.as_mut().and_then(|f| f(&purpose)) { - let cmd = if purpose == "place" { "file.place" } else { "file.open" }; - let _ = self.run(cmd, json!({"path": path})); - } + let _ = self.pick_and_open(&purpose); } } } @@ -241,6 +251,40 @@ 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 { + let cmd = if purpose == "place" { "file.place" } else { "file.open" }; + if let Some(pick) = self.services.pick_open.as_mut() { + return match pick(purpose) { + Some(path) => self.run(cmd, json!({"path": path})), + None => Ok(Value::Null), + }; + } + if let Some(open) = self.services.open_async.as_mut() { + open(purpose); + } + Ok(Value::Null) + } + + /// Open or place files delivered through the inbox. + fn drain_inbox(&mut self) { + 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 r = if name.to_ascii_lowercase().ends_with(".designcraft") { + let title = name.rsplit_once('.').map_or(name.as_str(), |(stem, _)| stem); + self.run("file.openBytes", json!({"name": title, "base64": b64})) + } else { + self.run("file.place", json!({"name": name, "base64": b64})) + }; + if let Err(e) = r { + self.status(format!("{name}: {e}")); + } + } + } + pub fn select_tool(&mut self, id: &str) { let _ = self.run("tool.select", json!({"tool": id})); self.ui.flyout = None; @@ -266,6 +310,9 @@ impl DesignApp { } self.last_time = now; self.drain_control(ctx); + if self.fonts_ready { + self.drain_inbox(); + } if !self.synthetic.is_empty() { ctx.request_repaint(); } diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index e78678a..91bb064 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -171,18 +171,9 @@ pub fn run_ui(app: &mut DesignApp, id: &str, p: &Value) -> Option { - if let Some(path) = app.services.pick_open.as_mut().and_then(|f| f("open")) { - return Some(app.run("file.open", json!({"path": path}))); - } - Ok(Value::Null) - } - "app.placeDialog" => { - if let Some(path) = app.services.pick_open.as_mut().and_then(|f| f("place")) { - return Some(app.run("file.place", json!({"path": path}))); - } - Ok(Value::Null) - } + "app.openDialog" => app.pick_and_open("open"), + "app.placeDialog" => app.pick_and_open("place"), + "app.save" | "app.saveDialog" if app.services.download.is_some() => download_document(app), "app.save" => { if app.session.active().is_some_and(|d| d.path.is_some()) { return Some(app.run("file.save", json!({}))); @@ -281,6 +272,18 @@ pub fn run_ui(app: &mut DesignApp, id: &str, p: &Value) -> Option Result { + let name = app.session.active().map(|d| format!("{}.designcraft", d.doc.title)).ok_or("no document")?; + let ser = app.run("file.serialize", json!({}))?; + let bytes = ser.get("json").and_then(Value::as_str).unwrap_or_default().as_bytes().to_vec(); + if let Some(download) = app.services.download.as_mut() { + download(&name, &bytes); + } + // Marks the document saved; on the web the engine doesn't touch a file system. + app.run("file.saveAs", json!({"path": name})) +} + fn export_png(app: &mut DesignApp, p: &Value) -> Result { let st = app.session.active().ok_or("no document")?; let page = p.get("page").and_then(Value::as_u64).map(|v| v as usize).or_else(|| crate::canvas::current_page(app)).unwrap_or(0);