M0.13: web build (trunk, WebGPU/WebGL2)
apps/designcraft-web runs DesignApp through eframe's web runner (wgpu: WebGPU with WebGL2 fallback, ?webgl forces GL, ?sample opens the sample). Services gain open_async, download and an inbox: browser-picked and dropped files are fed back as file.openBytes (.designcraft) or file.place (images, base64); Save/Export download via Blob + anchor. The web app is excluded from default-members. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
91ae2b3cc9
commit
157a3e23c0
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
11
README.md
11
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).
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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"] }
|
||||
|
|
@ -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
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=no">
|
||||
<title>DesignCraft</title>
|
||||
<link data-trunk rel="rust" data-bin="designcraft-web" data-wasm-opt="z" data-weak-refs />
|
||||
<style>
|
||||
html, body { margin: 0; padding: 0; width: 100%; height: 100%; overflow: hidden; background: #262626; }
|
||||
#designcraft_canvas { position: absolute; inset: 0; width: 100%; height: 100%; display: block; }
|
||||
#designcraft_loading { position: absolute; inset: 0; display: flex; flex-direction: column; align-items: center; justify-content: center;
|
||||
color: #b8b8b8; font: 14px system-ui, sans-serif; pointer-events: none; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<canvas id="designcraft_canvas"></canvas>
|
||||
<div id="designcraft_loading">Loading DesignCraft…</div>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -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");
|
||||
}
|
||||
|
|
@ -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::<web_sys::HtmlCanvasElement>().ok()) else {
|
||||
log::error!("missing <canvas id=\"{CANVAS_ID}\">");
|
||||
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!("<p>DesignCraft 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)
|
||||
/// 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",
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -30,6 +30,10 @@ pub use control::{ControlRequest, ControlResponse};
|
|||
pub type ReadFn = Box<dyn Fn(&str) -> Result<Vec<u8>, String>>;
|
||||
pub type WriteFn = Box<dyn FnMut(&str, &[u8]) -> Result<(), String>>;
|
||||
pub type PickFn = Box<dyn FnMut(&str) -> Option<String>>;
|
||||
pub type OpenAsyncFn = Box<dyn FnMut(&str)>;
|
||||
pub type DownloadFn = Box<dyn FnMut(&str, &[u8])>;
|
||||
/// Files `(name, bytes)` delivered asynchronously by the host (web file picker, dropped files).
|
||||
pub type Inbox = std::sync::Arc<std::sync::Mutex<Vec<(String, Vec<u8>)>>>;
|
||||
|
||||
/// Platform services injected by the host (desktop or web).
|
||||
#[derive(Default)]
|
||||
|
|
@ -40,6 +44,15 @@ pub struct Services {
|
|||
pub pick_save: Option<PickFn>,
|
||||
pub read: Option<ReadFn>,
|
||||
pub write: Option<WriteFn>,
|
||||
/// 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<OpenAsyncFn>,
|
||||
/// 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<DownloadFn>,
|
||||
/// Files delivered asynchronously, drained every frame: `.designcraft` → `file.openBytes`,
|
||||
/// anything else → `file.place`.
|
||||
pub inbox: Option<Inbox>,
|
||||
}
|
||||
|
||||
#[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<Value, String> {
|
||||
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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -171,18 +171,9 @@ pub fn run_ui(app: &mut DesignApp, id: &str, p: &Value) -> Option<Result<Value,
|
|||
app.ui.dialog = Some(crate::dialogs::Dialog::new("newDocument", json!({})));
|
||||
Ok(Value::Null)
|
||||
}
|
||||
"app.openDialog" => {
|
||||
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<Value,
|
|||
})
|
||||
}
|
||||
|
||||
/// Save by handing the serialized document to the host as a download (web).
|
||||
fn download_document(app: &mut DesignApp) -> Result<Value, String> {
|
||||
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<Value, String> {
|
||||
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);
|
||||
|
|
|
|||
Loading…
Reference in New Issue