mirror of
https://github.com/storytold/figcraft.git
synced 2026-11-01 14:26:49 -06:00
M7: the web build opens files: File ▸ Open, Import and dropped files (#759)
File ▸ Open, Import and Place image did nothing in the browser (the pickers return a path, which a browser never has) and dropped files were ignored (eframe hands their contents over asynchronously). - Services::pick_open_contents: a platform can pick a file's name and contents; the web build uses an <input type="file">. Open and Import pass them to file.open / file.import as data + name. - A dropped file without a path waits behind the opening card until its contents are read (bytes_async on wasm), or says it couldn't be read. - Canvas drops still import, as on the desktop. Closes #759. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
co-authored by
Cursor
parent
d8ff1441b1
commit
309bac6185
@@ -26,4 +26,4 @@ eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit"] }
|
||||
wasm-bindgen = "0.2"
|
||||
js-sys = "0.3"
|
||||
wasm-bindgen-futures = "0.4"
|
||||
web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlCanvasElement", "HtmlElement", "HtmlAnchorElement", "Node", "Blob", "BlobPropertyBag", "Url", "Location", "MediaQueryList", "Navigator", "Gamepad", "GamepadButton", "GamepadMappingType"] }
|
||||
web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlCanvasElement", "HtmlElement", "HtmlAnchorElement", "Node", "Blob", "BlobPropertyBag", "File", "FileList", "HtmlInputElement", "EventTarget", "Url", "Location", "MediaQueryList", "Navigator", "Gamepad", "GamepadButton", "GamepadMappingType"] }
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
//! The browser shell: web `Services` and the eframe web runner.
|
||||
|
||||
use figcraft_engine::Session;
|
||||
use figcraft_ui_egui::{FigApp, Services};
|
||||
use figcraft_ui_egui::{ContentsFuture, FigApp, Services};
|
||||
use wasm_bindgen::JsCast as _;
|
||||
|
||||
const CANVAS_ID: &str = "figcraft_canvas";
|
||||
@@ -75,9 +75,10 @@ impl eframe::App for WebShell {
|
||||
}
|
||||
}
|
||||
|
||||
/// Browser services. Open dialogs return nothing for now (the trait's defaults); files are saved
|
||||
/// as downloads. Copies go to the system clipboard through egui's output; pastes arrive as paste
|
||||
/// events with their text (a page can't read the clipboard when it likes).
|
||||
/// Browser services. Files are opened and imported through the browser's file picker, which hands
|
||||
/// over their contents (pickers that need a path return nothing, the trait's defaults); files are
|
||||
/// saved as downloads. Copies go to the system clipboard through egui's output; pastes arrive as
|
||||
/// paste events with their text (a page can't read the clipboard when it likes).
|
||||
struct WebServices {
|
||||
/// `prefers-reduced-motion: reduce` (it follows the setting while the page is open).
|
||||
reduce_motion: Option<web_sys::MediaQueryList>,
|
||||
@@ -85,6 +86,16 @@ struct WebServices {
|
||||
}
|
||||
|
||||
impl Services for WebServices {
|
||||
/// An `<input type="file">` taking `exts`, clicked; its file is read once picked.
|
||||
fn pick_open_contents(&mut self, _title: &str, exts: &[&str]) -> Option<ContentsFuture> {
|
||||
let accept = exts.iter().map(|e| format!(".{e}")).collect::<Vec<_>>().join(",");
|
||||
Some(Box::pin(async move {
|
||||
pick_file(&accept).await.unwrap_or_else(|e| {
|
||||
log::error!("couldn't open a file: {e:?}");
|
||||
None
|
||||
})
|
||||
}))
|
||||
}
|
||||
fn downloads(&self) -> bool {
|
||||
true
|
||||
}
|
||||
@@ -136,6 +147,43 @@ impl Services for WebServices {
|
||||
}
|
||||
}
|
||||
|
||||
/// The largest file read from the picker (browsers read at most 4 GiB into one buffer).
|
||||
const MAX_PICKED_BYTES: f64 = u32::MAX as f64;
|
||||
|
||||
/// The file picked in the browser's file picker (`accept`: the extensions it offers), with its
|
||||
/// name; `None` when the picker is cancelled.
|
||||
async fn pick_file(accept: &str) -> Result<Option<(String, Vec<u8>)>, wasm_bindgen::JsValue> {
|
||||
let window = web_sys::window().ok_or("no window")?;
|
||||
let document = window.document().ok_or("no document")?;
|
||||
let body = document.body().ok_or("no body")?;
|
||||
let input: web_sys::HtmlInputElement = document.create_element("input")?.dyn_into()?;
|
||||
input.set_type("file");
|
||||
input.set_accept(accept);
|
||||
input.set_hidden(true);
|
||||
// Some browsers (older Safari) open the picker only for an input in the page.
|
||||
body.append_child(&input)?;
|
||||
// Settles on `change` (a file was picked) or `cancel` (the picker was closed without one).
|
||||
let mut settle = |resolve: js_sys::Function, _reject: js_sys::Function| {
|
||||
let done = wasm_bindgen::closure::Closure::once_into_js(move || {
|
||||
let _ = resolve.call0(&wasm_bindgen::JsValue::NULL);
|
||||
});
|
||||
let _ = input.add_event_listener_with_callback("change", done.unchecked_ref());
|
||||
let _ = input.add_event_listener_with_callback("cancel", done.unchecked_ref());
|
||||
};
|
||||
let closed = js_sys::Promise::new(&mut settle);
|
||||
input.click();
|
||||
let result = wasm_bindgen_futures::JsFuture::from(closed).await;
|
||||
let file = input.files().and_then(|files| files.get(0));
|
||||
input.remove();
|
||||
result?;
|
||||
let Some(file) = file else { return Ok(None) };
|
||||
if file.size() > MAX_PICKED_BYTES {
|
||||
return Err(format!("{} is too large to read in the browser", file.name()).into());
|
||||
}
|
||||
let buffer = wasm_bindgen_futures::JsFuture::from(file.array_buffer()).await?;
|
||||
Ok(Some((file.name(), js_sys::Uint8Array::new(&buffer).to_vec())))
|
||||
}
|
||||
|
||||
fn download(name: &str, bytes: &[u8]) -> Result<(), wasm_bindgen::JsValue> {
|
||||
let window = web_sys::window().ok_or("no window")?;
|
||||
let document = window.document().ok_or("no document")?;
|
||||
|
||||
@@ -319,6 +319,7 @@ name = "English"
|
||||
"Corner smoothing" = "Corner smoothing"
|
||||
"Couldn't download the variables" = "Couldn't download the variables"
|
||||
"Couldn't reach {server}: showing what this computer remembers." = "Couldn't reach {server}: showing what this computer remembers."
|
||||
"Couldn't read {name}." = "Couldn't read {name}."
|
||||
"Couldn't save automatically: {error}" = "Couldn't save automatically: {error}"
|
||||
"Count" = "Count"
|
||||
"Create" = "Create"
|
||||
|
||||
@@ -320,6 +320,7 @@ status = "draft — needs native review"
|
||||
"Corner radius" = "Radio de esquina"
|
||||
"Corner smoothing" = "Suavizado de esquinas"
|
||||
"Couldn't reach {server}: showing what this computer remembers." = "No se pudo contactar con {server}: se muestra lo que este ordenador recuerda."
|
||||
"Couldn't read {name}." = "No se pudo leer {name}."
|
||||
"Couldn't save automatically: {error}" = "No se pudo guardar automáticamente: {error}"
|
||||
"Count" = "Número"
|
||||
"Create" = "Crear"
|
||||
|
||||
@@ -2,7 +2,7 @@ use std::path::PathBuf;
|
||||
|
||||
use figcraft_engine::Session;
|
||||
|
||||
use crate::{DialogFuture, FigApp, Services};
|
||||
use crate::{ContentsFuture, DialogFuture, FigApp, Services};
|
||||
|
||||
struct OpenFile(PathBuf);
|
||||
|
||||
@@ -79,6 +79,44 @@ fn save_as_saves_the_file_it_was_opened_for_after_another_tab_comes_to_the_front
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
/// The browser: the picker hands over a file's name and contents, never a path.
|
||||
struct Contents(&'static str, Vec<u8>);
|
||||
|
||||
impl Services for Contents {
|
||||
fn pick_open(&mut self, _title: &str, _exts: &[&str]) -> DialogFuture {
|
||||
panic!("a platform that picks contents is never asked for a path")
|
||||
}
|
||||
fn pick_open_contents(&mut self, _title: &str, _exts: &[&str]) -> Option<ContentsFuture> {
|
||||
Some(Box::pin(std::future::ready(Some((self.0.to_string(), self.1.clone())))))
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn open_dialog_opens_picked_contents_as_a_new_unsaved_document() {
|
||||
let bytes = figcraft_format::save_bytes(&figcraft_doc::Document::new("Picked"), None).unwrap();
|
||||
let mut app = FigApp::new(Session::empty(), Box::new(Contents("Picked.figcraft", bytes)));
|
||||
app.ui.home = true;
|
||||
app.open_dialog();
|
||||
assert!(app.session.docs.is_empty(), "opening waits for the picker result");
|
||||
app.poll_dialog(&egui::Context::default());
|
||||
assert_eq!(app.session.docs.len(), 1);
|
||||
assert_eq!(app.session.docs[0].path, None, "no path to save over");
|
||||
assert_eq!(app.session.docs[0].doc.name, "Picked");
|
||||
assert!(!app.ui.home, "and the editor shows it");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn import_dialog_imports_picked_contents_into_the_file_in_front() {
|
||||
let svg = br#"<svg xmlns="http://www.w3.org/2000/svg" width="40" height="20"><rect width="40" height="20" fill="red"/></svg>"#.to_vec();
|
||||
let mut app = FigApp::new(Session::new(), Box::new(Contents("Logo.svg", svg)));
|
||||
let rectangles = |app: &FigApp| app.session.doc().unwrap().nodes().filter(|n| matches!(n.kind, figcraft_doc::NodeKind::Rectangle)).count();
|
||||
assert_eq!(rectangles(&app), 0);
|
||||
app.import_dialog(false);
|
||||
app.poll_dialog(&egui::Context::default());
|
||||
assert_eq!(app.session.docs.len(), 1, "imported, not opened");
|
||||
assert_eq!(rectangles(&app), 1, "the SVG's content");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_dialog_whose_file_closed_meanwhile_does_nothing() {
|
||||
let (mut app, dir, path) = two_files("closed");
|
||||
|
||||
@@ -507,21 +507,17 @@ fn input(app: &mut FigApp, ui: &mut Ui, resp: &egui::Response) -> bool {
|
||||
let path = f.path().to_path_buf();
|
||||
let pt = hover_pos.map(|h| app.to_page(h)).unwrap_or_default();
|
||||
let name = path.file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| "dropped.png".into());
|
||||
let params = if path.exists() {
|
||||
json!({"path": path.to_string_lossy(), "x": pt.x, "y": pt.y})
|
||||
} else if let Some(bytes) = crate::dropped_bytes(&f) {
|
||||
use base64::Engine as _;
|
||||
let b64 = base64::engine::general_purpose::STANDARD.encode(&bytes);
|
||||
json!({"data": b64, "name": name, "x": pt.x, "y": pt.y})
|
||||
let (params, read) = if path.exists() {
|
||||
(json!({"path": path.to_string_lossy(), "x": pt.x, "y": pt.y}), None)
|
||||
} else {
|
||||
continue;
|
||||
(json!({"name": name, "x": pt.x, "y": pt.y}), Some(crate::dropped_bytes(&f)))
|
||||
};
|
||||
let Some(target) =
|
||||
app.session.ds().ok().map(|doc| crate::pending_open::ImportTarget { uid: doc.uid, page: doc.page, name: doc.doc.name.clone() })
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
app.ui.pending_opens.push(crate::pending_open::PendingOpen { label: name, params, command: "file.import", target: Some(target) });
|
||||
app.ui.pending_opens.push(crate::pending_open::PendingOpen { label: name, params, command: "file.import", target: Some(target), read });
|
||||
}
|
||||
let Some(pos) = hover_pos.filter(|p| app.canvas.rect.contains(*p)) else {
|
||||
// A drag released off the canvas, or off the window, ends where the pointer was last seen.
|
||||
|
||||
@@ -187,16 +187,9 @@ fn dropped(app: &mut FigApp, ctx: &egui::Context) {
|
||||
}
|
||||
// `opens` saw an extension, so there is a file name to tell the format by.
|
||||
let name = path.file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default();
|
||||
let params = if path.exists() {
|
||||
json!({"path": path.to_string_lossy()})
|
||||
} else if let Some(bytes) = crate::dropped_bytes(&f) {
|
||||
use base64::Engine as _;
|
||||
let data = base64::engine::general_purpose::STANDARD.encode(&bytes);
|
||||
json!({"data": data, "name": name})
|
||||
} else {
|
||||
continue;
|
||||
};
|
||||
app.ui.pending_opens.push(crate::pending_open::PendingOpen { label: name, params, command: "file.open", target: None });
|
||||
let (params, read) =
|
||||
if path.exists() { (json!({"path": path.to_string_lossy()}), None) } else { (json!({"name": name}), Some(crate::dropped_bytes(&f))) };
|
||||
app.ui.pending_opens.push(crate::pending_open::PendingOpen { label: name, params, command: "file.open", target: None, read });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -573,6 +573,68 @@ fn dropping_a_file_without_one_on_disk_opens_it_as_a_new_unsaved_document() {
|
||||
assert!(!path.exists(), "nothing is written");
|
||||
}
|
||||
|
||||
/// A drop whose contents arrive later, as every drop in the browser does: `None` until `arrived`
|
||||
/// holds them.
|
||||
fn later(arrived: &std::rc::Rc<std::cell::RefCell<Option<Option<Vec<u8>>>>>) -> crate::ReadFuture {
|
||||
let arrived = arrived.clone();
|
||||
Box::pin(std::future::poll_fn(move |_| match arrived.borrow_mut().take() {
|
||||
Some(bytes) => std::task::Poll::Ready(bytes),
|
||||
None => std::task::Poll::Pending,
|
||||
}))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_dropped_file_whose_contents_arrive_later_opens_once_they_do() {
|
||||
let svg = br#"<svg xmlns="http://www.w3.org/2000/svg" width="40" height="20"><rect width="40" height="20" fill="red"/></svg>"#.to_vec();
|
||||
let arrived = std::rc::Rc::new(std::cell::RefCell::new(None));
|
||||
let mut u = first_run();
|
||||
u.app.ui.home = true;
|
||||
u.app.ui.pending_opens.push(crate::pending_open::PendingOpen {
|
||||
label: "Browser drop.svg".into(),
|
||||
params: json!({"name": "Browser drop.svg"}),
|
||||
command: "file.open",
|
||||
target: None,
|
||||
read: Some(later(&arrived)),
|
||||
});
|
||||
// egui lays a new card out invisibly on its first frame.
|
||||
u.frame(vec![]);
|
||||
for _ in 0..5 {
|
||||
let s = flat(&u.frame_output(vec![]).shapes);
|
||||
assert!(has_text(&s, "Browser drop.svg"), "the card shows while the contents are read");
|
||||
}
|
||||
assert!(u.app.session.docs.is_empty(), "nothing opens before the contents arrive");
|
||||
*arrived.borrow_mut() = Some(Some(svg));
|
||||
for _ in 0..3 {
|
||||
u.frame(vec![]);
|
||||
}
|
||||
assert!(u.app.ui.pending_opens.is_empty());
|
||||
assert_eq!(u.app.ui.last_error, None);
|
||||
assert_eq!(u.app.session.docs.len(), 1, "the contents open");
|
||||
assert_eq!(u.app.session.docs[0].path, None);
|
||||
assert!(u.app.session.docs[0].doc.nodes().any(|n| matches!(n.kind, figcraft_doc::NodeKind::Rectangle)), "the SVG's content");
|
||||
assert!(!u.app.ui.home);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_dropped_file_that_cannot_be_read_says_so_and_opens_nothing() {
|
||||
let arrived = std::rc::Rc::new(std::cell::RefCell::new(Some(None)));
|
||||
let mut u = first_run();
|
||||
u.app.ui.home = true;
|
||||
u.app.ui.pending_opens.push(crate::pending_open::PendingOpen {
|
||||
label: "Unreadable.figcraft".into(),
|
||||
params: json!({"name": "Unreadable.figcraft"}),
|
||||
command: "file.open",
|
||||
target: None,
|
||||
read: Some(later(&arrived)),
|
||||
});
|
||||
for _ in 0..3 {
|
||||
u.frame(vec![]);
|
||||
}
|
||||
assert!(u.app.ui.pending_opens.is_empty());
|
||||
assert!(u.app.session.docs.is_empty());
|
||||
assert_eq!(u.app.ui.last_error.as_deref(), Some("Couldn't read Unreadable.figcraft."));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dropping_a_native_file_on_the_home_screen_opens_it() {
|
||||
let dir = Temp::new();
|
||||
|
||||
@@ -177,6 +177,7 @@ fn a_dropped_file_shows_its_report_once_it_opens() {
|
||||
params: json!({"path": path.to_string_lossy()}),
|
||||
command: "file.open",
|
||||
target: None,
|
||||
read: None,
|
||||
});
|
||||
for _ in 0..6 {
|
||||
u.frame(vec![]);
|
||||
|
||||
+89
-31
@@ -236,6 +236,11 @@ pub trait Services {
|
||||
fn pick_open(&mut self, _title: &str, _exts: &[&str]) -> DialogFuture {
|
||||
Box::pin(std::future::ready(None))
|
||||
}
|
||||
/// Pick a file and read it, where the platform hands over a file's name and contents rather
|
||||
/// than a path to it (the browser). `None`: the platform picks paths ([`Services::pick_open`]).
|
||||
fn pick_open_contents(&mut self, _title: &str, _exts: &[&str]) -> Option<ContentsFuture> {
|
||||
None
|
||||
}
|
||||
fn pick_save(&mut self, _title: &str, _name: &str, _exts: &[&str]) -> DialogFuture {
|
||||
Box::pin(std::future::ready(None))
|
||||
}
|
||||
@@ -290,14 +295,38 @@ pub trait Services {
|
||||
}
|
||||
|
||||
pub type DialogFuture = Pin<Box<dyn Future<Output = Option<PathBuf>>>>;
|
||||
type DialogDone = Box<dyn FnOnce(&mut FigApp, PathBuf)>;
|
||||
/// A picked file's name and contents ([`Services::pick_open_contents`]).
|
||||
pub type ContentsFuture = Pin<Box<dyn Future<Output = Option<(String, Vec<u8>)>>>>;
|
||||
/// A dropped file's contents, read once they arrive (`None`: they couldn't be read).
|
||||
pub type ReadFuture = Pin<Box<dyn Future<Output = Option<Vec<u8>>>>>;
|
||||
type PickFuture = Pin<Box<dyn Future<Output = Option<Picked>>>>;
|
||||
type DialogDone = Box<dyn FnOnce(&mut FigApp, Picked)>;
|
||||
|
||||
/// What a dialog picked: a path, or a file's name and contents where there are no paths.
|
||||
enum Picked {
|
||||
Path(PathBuf),
|
||||
Contents(String, Vec<u8>),
|
||||
}
|
||||
|
||||
impl Picked {
|
||||
/// The file as `file.open` and `file.import` take it.
|
||||
fn file_params(self) -> Value {
|
||||
match self {
|
||||
Picked::Path(p) => json!({"path": p.to_string_lossy()}),
|
||||
Picked::Contents(name, bytes) => {
|
||||
use base64::Engine as _;
|
||||
json!({"data": base64::engine::general_purpose::STANDARD.encode(&bytes), "name": name})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A native dialog in flight. The app keeps working while it is open, so its result goes to the
|
||||
/// file and window it was opened for, not to whichever is in front when it closes.
|
||||
struct PendingDialog {
|
||||
future: DialogFuture,
|
||||
/// The path picked, kept until the window that holds `doc` draws.
|
||||
path: Option<PathBuf>,
|
||||
future: PickFuture,
|
||||
/// What was picked, kept until the window that holds `doc` draws.
|
||||
picked: Option<Picked>,
|
||||
done: DialogDone,
|
||||
/// The file it acts on (`None`: none, such as Open).
|
||||
doc: Option<u64>,
|
||||
@@ -1099,16 +1128,11 @@ impl FigApp {
|
||||
|
||||
pub fn open_dialog(&mut self) {
|
||||
// Opening needs no file in front: it goes ahead even if that file closed meanwhile.
|
||||
self.start_dialog(
|
||||
|services| services.pick_open(i18n::tr("Open"), OPEN_EXTENSIONS),
|
||||
false,
|
||||
|app, p| {
|
||||
let ps = p.to_string_lossy().to_string();
|
||||
if app.run_user("file.open", json!({"path": ps})).is_some() {
|
||||
app.ui.home = false;
|
||||
}
|
||||
},
|
||||
);
|
||||
self.pick_file(i18n::tr("Open"), OPEN_EXTENSIONS, false, |app, file| {
|
||||
if app.run_user("file.open", file).is_some() {
|
||||
app.ui.home = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
pub fn import_dialog(&mut self, place: bool) {
|
||||
@@ -1118,12 +1142,30 @@ impl FigApp {
|
||||
&["svg", "png", "jpg", "jpeg", "webp", "gif", "tif", "tiff", "figcraft", "fig"]
|
||||
};
|
||||
let title = if place { i18n::tr("Place image") } else { i18n::tr("Import") };
|
||||
self.pick_open(title, exts, move |app, p| {
|
||||
let ps = p.to_string_lossy().to_string();
|
||||
app.run_user("file.import", json!({"path": ps}));
|
||||
self.pick_file(title, exts, true, |app, file| {
|
||||
app.run_user("file.import", file);
|
||||
});
|
||||
}
|
||||
|
||||
/// Pick a file to open or import; `done` gets it as `file.open` and `file.import` take it (a
|
||||
/// path, or the name and contents where the platform has no paths). `pin`: as
|
||||
/// [`FigApp::pick_open`]'s file in front.
|
||||
fn pick_file(&mut self, title: &str, exts: &[&str], pin: bool, done: impl FnOnce(&mut FigApp, Value) + 'static) {
|
||||
self.start_pick(
|
||||
|services| -> PickFuture {
|
||||
match services.pick_open_contents(title, exts) {
|
||||
Some(read) => Box::pin(async move { read.await.map(|(name, bytes)| Picked::Contents(name, bytes)) }),
|
||||
None => {
|
||||
let pick = services.pick_open(title, exts);
|
||||
Box::pin(async move { pick.await.map(Picked::Path) })
|
||||
}
|
||||
}
|
||||
},
|
||||
pin,
|
||||
move |app, picked| done(app, picked.file_params()),
|
||||
);
|
||||
}
|
||||
|
||||
/// Pick a file to open. `done` runs with the file in front now back in front (and not at all
|
||||
/// if that file closed meanwhile).
|
||||
pub fn pick_open(&mut self, title: &str, exts: &[&str], done: impl FnOnce(&mut FigApp, PathBuf) + 'static) {
|
||||
@@ -1142,12 +1184,28 @@ impl FigApp {
|
||||
|
||||
/// Open a native dialog from the window drawing now, for the file in front (`pin`).
|
||||
fn start_dialog(&mut self, start: impl FnOnce(&mut dyn Services) -> DialogFuture, pin: bool, done: impl FnOnce(&mut FigApp, PathBuf) + 'static) {
|
||||
self.start_pick(
|
||||
|services| -> PickFuture {
|
||||
let pick = start(services);
|
||||
Box::pin(async move { pick.await.map(Picked::Path) })
|
||||
},
|
||||
pin,
|
||||
move |app, picked| {
|
||||
if let Picked::Path(p) = picked {
|
||||
done(app, p);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// [`FigApp::start_dialog`] for any kind of pick.
|
||||
fn start_pick(&mut self, start: impl FnOnce(&mut dyn Services) -> PickFuture, pin: bool, done: impl FnOnce(&mut FigApp, Picked) + 'static) {
|
||||
if self.pending_dialog.is_some() {
|
||||
return;
|
||||
}
|
||||
let doc = if pin { self.session.ds().ok().filter(|d| self.shows(d.uid)).map(|d| d.uid) } else { None };
|
||||
let future = start(self.services.as_mut());
|
||||
self.pending_dialog = Some(PendingDialog { future, path: None, done: Box::new(done), doc, window: self.windows.current });
|
||||
self.pending_dialog = Some(PendingDialog { future, picked: None, done: Box::new(done), doc, window: self.windows.current });
|
||||
if let Some(ctx) = &self.ui_context {
|
||||
ctx.request_repaint();
|
||||
}
|
||||
@@ -1157,12 +1215,12 @@ impl FigApp {
|
||||
/// that file in front; then the tab in front before comes back (unless the dialog opened a file).
|
||||
fn poll_dialog(&mut self, ctx: &egui::Context) {
|
||||
let Some(d) = &mut self.pending_dialog else { return };
|
||||
if d.path.is_none() {
|
||||
if d.picked.is_none() {
|
||||
let waker = Waker::from(Arc::new(RepaintWaker(ctx.clone())));
|
||||
let Poll::Ready(path) = d.future.as_mut().poll(&mut TaskContext::from_waker(&waker)) else { return };
|
||||
d.path = path;
|
||||
let Poll::Ready(picked) = d.future.as_mut().poll(&mut TaskContext::from_waker(&waker)) else { return };
|
||||
d.picked = picked;
|
||||
}
|
||||
let (doc, origin, picked) = (d.doc, d.window, d.path.is_some());
|
||||
let (doc, origin, picked) = (d.doc, d.window, d.picked.is_some());
|
||||
let index = doc.map(|u| self.session.docs.iter().position(|ds| ds.uid == u));
|
||||
// Cancelled, or its file closed meanwhile.
|
||||
if !picked || index == Some(None) {
|
||||
@@ -1178,12 +1236,12 @@ impl FigApp {
|
||||
ctx.request_repaint_of(windows::viewport(window));
|
||||
return;
|
||||
}
|
||||
let Some(PendingDialog { path: Some(path), done, .. }) = self.pending_dialog.take() else { return };
|
||||
let Some(PendingDialog { picked: Some(picked), done, .. }) = self.pending_dialog.take() else { return };
|
||||
let before = self.session.ds().ok().map(|d| d.uid);
|
||||
if let Some(Some(i)) = index {
|
||||
self.session.active = i;
|
||||
}
|
||||
done(self, path);
|
||||
done(self, picked);
|
||||
if doc.is_some()
|
||||
&& self.session.ds().ok().map(|d| d.uid) == doc
|
||||
&& let Some(i) = before.and_then(|u| self.session.docs.iter().position(|d| d.uid == u))
|
||||
@@ -1693,17 +1751,17 @@ pub(crate) fn opens(path: &Path) -> bool {
|
||||
path.extension().and_then(|e| e.to_str()).is_some_and(|e| OPEN_EXTENSIONS.iter().any(|x| x.eq_ignore_ascii_case(e)))
|
||||
}
|
||||
|
||||
/// The contents of a dropped file that has no file on disk to read by path.
|
||||
/// Reads the contents of a dropped file that has no file on disk to read by path.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub(crate) fn dropped_bytes(f: &egui::DroppedFileHandle) -> Option<Vec<u8>> {
|
||||
f.bytes().ok()
|
||||
pub(crate) fn dropped_bytes(f: &egui::DroppedFileHandle) -> ReadFuture {
|
||||
Box::pin(std::future::ready(f.bytes().ok()))
|
||||
}
|
||||
|
||||
/// The contents of a dropped file that has no file on disk to read by path.
|
||||
/// Reads the contents of a dropped file; the browser hands them over asynchronously.
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub(crate) fn dropped_bytes(_f: &egui::DroppedFileHandle) -> Option<Vec<u8>> {
|
||||
// Browser drops arrive asynchronously; not supported yet.
|
||||
None
|
||||
pub(crate) fn dropped_bytes(f: &egui::DroppedFileHandle) -> ReadFuture {
|
||||
let f = f.clone();
|
||||
Box::pin(async move { f.bytes_async().await.ok() })
|
||||
}
|
||||
|
||||
/// While files are dragged over the window: a tinted, outlined `rect` with `label` in its middle,
|
||||
|
||||
@@ -1,13 +1,17 @@
|
||||
//! Files dropped on the home screen or the canvas open a frame later, behind a card that says what
|
||||
//! is happening: opening a large `.fig` blocks the UI thread for a while, and the card is on screen
|
||||
//! before it does (rather than a window that freezes without a word).
|
||||
//! before it does (rather than a window that freezes without a word). A file with no path to read
|
||||
//! (every drop in the browser) waits behind the card until its contents arrive.
|
||||
|
||||
use std::sync::Arc;
|
||||
use std::task::{Context as TaskContext, Poll, Waker};
|
||||
|
||||
use figcraft_doc::NodeId;
|
||||
use serde_json::Value;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::FigApp;
|
||||
use crate::i18n::{tr, trf, trn};
|
||||
use crate::theme::{Tokens, semibold};
|
||||
use crate::{FigApp, ReadFuture, RepaintWaker};
|
||||
|
||||
/// A dropped file waiting to be opened (`file.open`, a document of its own) or imported
|
||||
/// (`file.import`, into the current document).
|
||||
@@ -18,6 +22,8 @@ pub struct PendingOpen {
|
||||
pub command: &'static str,
|
||||
/// Canvas imports stay with the document and page that received the drop.
|
||||
pub target: Option<ImportTarget>,
|
||||
/// The file's contents still on their way; they go into `params` as `data` once read.
|
||||
pub read: Option<ReadFuture>,
|
||||
}
|
||||
|
||||
pub struct ImportTarget {
|
||||
@@ -74,15 +80,48 @@ fn cancel(app: &mut FigApp, message: String) {
|
||||
/// frame, so the second one is the first the window shows.
|
||||
const FRAMES_BEFORE_OPENING: u8 = 2;
|
||||
|
||||
/// The card while files wait, then (once it has been on screen) open them. Called before the import
|
||||
/// report, so a report for these files shows after them.
|
||||
/// Polls the contents still being read: those that arrived go into their file's parameters, files
|
||||
/// that couldn't be read are dropped with a message. Whether any are still on their way.
|
||||
fn read_contents(app: &mut FigApp, ctx: &egui::Context) -> bool {
|
||||
let waker = Waker::from(Arc::new(RepaintWaker(ctx.clone())));
|
||||
let mut cx = TaskContext::from_waker(&waker);
|
||||
let mut reading = false;
|
||||
let mut unread = Vec::new();
|
||||
app.ui.pending_opens.retain_mut(|pending| {
|
||||
let Some(read) = pending.read.as_mut() else { return true };
|
||||
match read.as_mut().poll(&mut cx) {
|
||||
Poll::Pending => {
|
||||
reading = true;
|
||||
true
|
||||
}
|
||||
Poll::Ready(Some(bytes)) => {
|
||||
use base64::Engine as _;
|
||||
pending.params["data"] = json!(base64::engine::general_purpose::STANDARD.encode(&bytes));
|
||||
pending.read = None;
|
||||
true
|
||||
}
|
||||
Poll::Ready(None) => {
|
||||
unread.push(pending.label.clone());
|
||||
false
|
||||
}
|
||||
}
|
||||
});
|
||||
for name in unread {
|
||||
cancel(app, trf("Couldn't read {name}.", &[("name", &name)]));
|
||||
}
|
||||
reading
|
||||
}
|
||||
|
||||
/// The card while files wait, then (once it has been on screen and their contents are read) open
|
||||
/// them. Called before the import report, so a report for these files shows after them.
|
||||
pub fn pending_opens(app: &mut FigApp, ctx: &egui::Context) {
|
||||
let reading = read_contents(app, ctx);
|
||||
if app.ui.pending_opens.is_empty() {
|
||||
app.ui.pending_open_frames = 0;
|
||||
return;
|
||||
}
|
||||
ctx.request_repaint();
|
||||
if app.ui.pending_open_frames >= FRAMES_BEFORE_OPENING {
|
||||
if app.ui.pending_open_frames >= FRAMES_BEFORE_OPENING && !reading {
|
||||
app.ui.pending_open_frames = 0;
|
||||
for pending in std::mem::take(&mut app.ui.pending_opens) {
|
||||
let command = pending.command;
|
||||
@@ -92,7 +131,7 @@ pub fn pending_opens(app: &mut FigApp, ctx: &egui::Context) {
|
||||
}
|
||||
return;
|
||||
}
|
||||
app.ui.pending_open_frames += 1;
|
||||
app.ui.pending_open_frames = app.ui.pending_open_frames.saturating_add(1);
|
||||
let title = match app.ui.pending_opens.first().map(|p| p.command) {
|
||||
Some("file.import") => {
|
||||
let name = app
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Hardware and platform parity
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-18 · **Change:** minor (macOS: quitting ends the process once everything is kept, avoiding the Touch Bar teardown crash (#781); Linux and BSD clipboard: images under Wayland, reads time out; minimised windows under Wayland; window controls on Wayland without server decorations; canvas textures on device pixels; copy and paste in the browser; Windows QA pass; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; pen pressure for the Pencil and brushes on Windows and from browser styluses) · **Target:** Figma Design (desktop app 126.10.7)
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-18 · **Change:** minor (the web build opens files: File ▸ Open, Import and Place image through the browser's file picker, dropped files read (#759); macOS: quitting ends the process once everything is kept, avoiding the Touch Bar teardown crash (#781); Linux and BSD clipboard: images under Wayland, reads time out; minimised windows under Wayland; window controls on Wayland without server decorations; canvas textures on device pixels; copy and paste in the browser; Windows QA pass; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; pen pressure for the Pencil and brushes on Windows and from browser styluses) · **Target:** Figma Design (desktop app 126.10.7)
|
||||
|
||||
How FigCraft uses hardware and which platforms it runs on, against Figma. Figma's desktop app is
|
||||
Electron with a GPU helper process; its canvas renders on the GPU (WebGL, with WebGPU rolling out
|
||||
@@ -43,13 +43,14 @@ Nothing has been timed against Figma on the same document (needs `.fig` import).
|
||||
| Windows x64 / arm64 | desktop app | `.msi` and portable zip for x64, x86 and arm64 | built in CI; Windows: QA pass done 2026-10-11, findings in PR |
|
||||
| Linux | none (browser only) | AppImage, `.deb`, `.rpm`, Flatpak, tarball, x86_64 and aarch64; riscv64 tarball | CI tests run here. Clipboard (also the BSDs): reads run on a helper thread that gives up after 2 s, and images copied in other apps paste under Wayland through the data-control protocol (#693). Under Wayland the app can't tell its window is minimised, so control requests that need a drawn frame may still wait there (#720). None of this run on a Linux desktop here. On a Wayland compositor without server-side decorations (GNOME) the window gets winit's Adwaita controls (#592: checked by its author in a GNOME Wayland session; KDE not run) |
|
||||
| FreeBSD | none | tarball | |
|
||||
| Web | primary (figma.com) | `figcraft-web` (WASM, WebGPU/WebGL2), web zip | no accessibility tree; file access through the browser; copy and paste through the system clipboard (copies written from the copy event, pastes read from the paste event; no pasting images from other apps) |
|
||||
| Web | primary (figma.com) | `figcraft-web` (WASM, WebGPU/WebGL2), web zip | no accessibility tree; file access through the browser: File ▸ Open, Import and Place image use its file picker, dropped files are read (a drop on the home screen opens, a drop on the canvas imports, as on the desktop), saving and exports are downloads; pickers for libraries, variables, plugin manifests and image fills still need a path and do nothing there; copy and paste through the system clipboard (copies written from the copy event, pastes read from the paste event; no pasting images from other apps) |
|
||||
| iOS / Android | apps: view, comment, mirror prototypes | none (the web build is not touch-first) | 20–40 h for a viewer |
|
||||
|
||||
## Revision history
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-18 | minor | Opening files in the web build (#759): File ▸ Open, Import and Place image did nothing there (the pickers return a path, which a browser never has) and dropped files were ignored (eframe hands their contents over asynchronously). A platform can now pick a file's name and contents (`Services::pick_open_contents`; the browser's `<input type="file">`), which `file.open` and `file.import` take as `data` and `name`, and a dropped file without a path waits behind the opening card until its contents arrive, or says it couldn't be read (`async_dialog_tests`, `home_tests`; the browser half built for wasm, not run in a browser). Numbers unchanged |
|
||||
| 2026-10-18 | minor | macOS quit crash (#781): on Macs with a Touch Bar the app could crash as it quit (an exception in AppKit's Touch Bar support while the window's content view, which the accessibility adapter subclasses, was torn down). Quitting now keeps everything (preferences, this run's recovery copies, live sessions ended) and then ends the process before that teardown. Quit from the menu bar ends the app without the unsaved-changes question, so unsaved files' recovery copies now stay and reopen at the next start instead of being removed. Numbers unchanged |
|
||||
| 2026-10-17 | minor | Linux row: clipboard reads on Linux and the BSDs time out after 2 s on a helper thread, images copied in other apps paste under Wayland (arboard's data-control feature, #693), a minimised window can't be detected under Wayland, so frame-only control requests may still wait there (#720), and Wayland compositors without server-side decorations give the window Adwaita controls (#592, checked by its author on GNOME, not by the maintainers; KDE not run); compiled in CI, not run on a Linux desktop. HiDPI row: native-density canvas images placed on whole device pixels (#359, geometry tests; not judged by eye). Numbers unchanged |
|
||||
| 2026-10-14 | minor | Copy and paste in the browser: the web build never wrote to the system clipboard, so Cmd+V after Cmd+C pasted whatever text the clipboard held before (as a text layer) instead of the copied layers. Copies (layers, Copy as SVG/CSS/text/PNG, links) now go out through egui's output, which eframe writes during the copy event (`web_clipboard_tests`). Numbers unchanged. |
|
||||
|
||||
Reference in New Issue
Block a user