mirror of
https://github.com/storytold/figcraft.git
synced 2026-10-24 10:27:00 -06:00
Merge #368: Variables export as a download in the browser (@mateusvillain)
# Conflicts: # crates/ui-egui/src/variables_panel.rs
This commit is contained in:
Generated
+1
@@ -1721,6 +1721,7 @@ dependencies = [
|
||||
"figcraft-collab-net",
|
||||
"figcraft-engine",
|
||||
"figcraft-ui-egui",
|
||||
"js-sys",
|
||||
"log",
|
||||
"wasm-bindgen",
|
||||
"wasm-bindgen-futures",
|
||||
|
||||
@@ -24,5 +24,6 @@ log = { workspace = true }
|
||||
# `wgpu` pulls egui-wgpu's defaults: WebGPU plus the WebGL2 fallback.
|
||||
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", "Location", "MediaQueryList", "Navigator", "Gamepad", "GamepadButton", "GamepadMappingType"] }
|
||||
web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlCanvasElement", "HtmlElement", "HtmlAnchorElement", "Node", "Blob", "BlobPropertyBag", "Url", "Location", "MediaQueryList", "Navigator", "Gamepad", "GamepadButton", "GamepadMappingType"] }
|
||||
|
||||
@@ -69,9 +69,9 @@ impl eframe::App for WebShell {
|
||||
}
|
||||
}
|
||||
|
||||
/// Browser services. File dialogs return nothing for now (the trait's defaults). 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. 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).
|
||||
struct WebServices {
|
||||
/// `prefers-reduced-motion: reduce` (it follows the setting while the page is open).
|
||||
reduce_motion: Option<web_sys::MediaQueryList>,
|
||||
@@ -79,6 +79,19 @@ struct WebServices {
|
||||
}
|
||||
|
||||
impl Services for WebServices {
|
||||
fn downloads(&self) -> bool {
|
||||
true
|
||||
}
|
||||
/// A Blob behind a temporary `<a download>` link, clicked and removed.
|
||||
fn download(&mut self, name: &str, bytes: &[u8]) -> bool {
|
||||
match download(name, bytes) {
|
||||
Ok(()) => true,
|
||||
Err(e) => {
|
||||
log::error!("couldn't download {name}: {e:?}");
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
fn clipboard_set_text(&mut self, s: &str) {
|
||||
figcraft_ui_egui::egui_copy_text(&self.ctx, s);
|
||||
}
|
||||
@@ -116,3 +129,32 @@ impl Services for WebServices {
|
||||
held
|
||||
}
|
||||
}
|
||||
|
||||
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")?;
|
||||
let body = document.body().ok_or("no body")?;
|
||||
let parts = js_sys::Array::of1(&js_sys::Uint8Array::from(bytes));
|
||||
let blob = web_sys::Blob::new_with_u8_array_sequence_and_options(&parts, &{
|
||||
let o = web_sys::BlobPropertyBag::new();
|
||||
o.set_type("application/octet-stream");
|
||||
o
|
||||
})?;
|
||||
let url = web_sys::Url::create_object_url_with_blob(&blob)?;
|
||||
let a: web_sys::HtmlAnchorElement = document.create_element("a")?.dyn_into()?;
|
||||
a.set_href(&url);
|
||||
a.set_download(name);
|
||||
body.append_child(&a)?;
|
||||
a.click();
|
||||
a.remove();
|
||||
// Revoked later, not at once: some browsers cancel a download whose URL goes before it starts.
|
||||
let revoke = wasm_bindgen::closure::Closure::once_into_js(move || {
|
||||
let _ = web_sys::Url::revoke_object_url(&url);
|
||||
});
|
||||
// Without the timer the URL just stays until the page closes.
|
||||
let _ = window.set_timeout_with_callback_and_timeout_and_arguments_0(revoke.unchecked_ref(), REVOKE_DOWNLOAD_MS);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// How long a download's object URL is kept (milliseconds).
|
||||
const REVOKE_DOWNLOAD_MS: i32 = 40_000;
|
||||
|
||||
@@ -301,6 +301,7 @@ status = "draft — needs native review"
|
||||
"Copy {value}" = "{value} kopieren"
|
||||
"Corner radius" = "Eckenradius"
|
||||
"Corner smoothing" = "Eckenglättung"
|
||||
"Couldn't download the variables" = "Die Variablen konnten nicht heruntergeladen werden"
|
||||
"Couldn't reach {server}: showing what this computer remembers." = "{server} war nicht erreichbar: Es wird angezeigt, was dieser Computer gespeichert hat."
|
||||
"Couldn't save automatically: {error}" = "Automatisches Speichern fehlgeschlagen: {error}"
|
||||
"Count" = "Anzahl"
|
||||
|
||||
@@ -310,6 +310,7 @@ name = "English"
|
||||
"Copy {value}" = "Copy {value}"
|
||||
"Corner radius" = "Corner radius"
|
||||
"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 save automatically: {error}" = "Couldn't save automatically: {error}"
|
||||
"Count" = "Count"
|
||||
|
||||
@@ -8,6 +8,7 @@ name = "Español"
|
||||
status = "draft — needs native review"
|
||||
|
||||
[ui]
|
||||
"Couldn't download the variables" = "No se pudieron descargar las variables"
|
||||
|
||||
"% of all commits" = "% del total de commits"
|
||||
"(no longer published)" = "(ya no publicado)"
|
||||
|
||||
@@ -301,6 +301,7 @@ status = "draft — needs native review"
|
||||
"Copy {value}" = "Copier {value}"
|
||||
"Corner radius" = "Rayon d’angle"
|
||||
"Corner smoothing" = "Lissage des angles"
|
||||
"Couldn't download the variables" = "Impossible de télécharger les variables"
|
||||
"Couldn't reach {server}: showing what this computer remembers." = "Impossible de joindre {server} : affichage de ce dont cet ordinateur se souvient."
|
||||
"Couldn't save automatically: {error}" = "Impossible d’enregistrer automatiquement : {error}"
|
||||
"Count" = "Nombre"
|
||||
|
||||
@@ -300,6 +300,7 @@ status = "draft — needs native review"
|
||||
"Copy {value}" = "Copiar {value}"
|
||||
"Corner radius" = "Raio do canto"
|
||||
"Corner smoothing" = "Suavização de cantos"
|
||||
"Couldn't download the variables" = "Não foi possível baixar as variáveis"
|
||||
"Couldn't reach {server}: showing what this computer remembers." = "Não foi possível alcançar {server}: mostrando o que este computador lembra."
|
||||
"Couldn't save automatically: {error}" = "Não foi possível salvar automaticamente: {error}"
|
||||
"Count" = "Quantidade"
|
||||
|
||||
@@ -159,6 +159,8 @@ mod text_enter_tests;
|
||||
#[cfg(test)]
|
||||
mod titlebar_tests;
|
||||
#[cfg(test)]
|
||||
mod variables_export_ui_tests;
|
||||
#[cfg(test)]
|
||||
mod variables_rename_tests;
|
||||
#[cfg(test)]
|
||||
mod vector_tail_ui_tests;
|
||||
@@ -190,6 +192,15 @@ pub trait Services {
|
||||
fn pick_save(&mut self, _title: &str, _name: &str, _exts: &[&str]) -> DialogFuture {
|
||||
Box::pin(std::future::ready(None))
|
||||
}
|
||||
/// The platform saves files by handing them over as downloads (the browser), not through
|
||||
/// save dialogs.
|
||||
fn downloads(&self) -> bool {
|
||||
false
|
||||
}
|
||||
/// Hand `bytes` over as a download named `name`; false when nothing was delivered.
|
||||
fn download(&mut self, _name: &str, _bytes: &[u8]) -> bool {
|
||||
false
|
||||
}
|
||||
/// A folder to write several files into.
|
||||
fn pick_folder(&mut self, _title: &str) -> DialogFuture {
|
||||
Box::pin(std::future::ready(None))
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
//! Variables ▸ Export… where the platform saves by downloads (the browser, which has no save
|
||||
//! dialogs): the export arrives as a download instead of doing nothing.
|
||||
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use figcraft_engine::Session;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::variables_panel::export_dialog;
|
||||
use crate::{FigApp, NoServices, Services};
|
||||
|
||||
type Downloads = Arc<Mutex<Vec<(String, Vec<u8>)>>>;
|
||||
|
||||
/// Services that save by downloads and keep them (`deliver` false: the browser refused).
|
||||
struct Browser {
|
||||
got: Downloads,
|
||||
deliver: bool,
|
||||
}
|
||||
|
||||
impl Services for Browser {
|
||||
fn downloads(&self) -> bool {
|
||||
true
|
||||
}
|
||||
fn download(&mut self, name: &str, bytes: &[u8]) -> bool {
|
||||
self.got.lock().unwrap().push((name.to_string(), bytes.to_vec()));
|
||||
self.deliver
|
||||
}
|
||||
}
|
||||
|
||||
fn session() -> Session {
|
||||
let mut s = Session::new();
|
||||
for (cmd, p) in [
|
||||
("variable.createCollection", json!({"name": "Theme", "modes": ["Light", "Dark"]})),
|
||||
(
|
||||
"variable.create",
|
||||
json!({"collection": "Theme", "name": "color/primary", "type": "color", "values": {"Light": "#0000ff", "Dark": "#ffffff"}}),
|
||||
),
|
||||
("variable.create", json!({"collection": "Theme", "name": "radius", "type": "number", "value": 4})),
|
||||
] {
|
||||
s.execute(cmd, &p).unwrap();
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
fn browser(deliver: bool) -> (FigApp, Downloads) {
|
||||
let got = Downloads::default();
|
||||
(FigApp::new(session(), Box::new(Browser { got: got.clone(), deliver })), got)
|
||||
}
|
||||
|
||||
fn toast(app: &FigApp) -> String {
|
||||
app.ui.toast.as_ref().map(|t| t.0.clone()).unwrap_or_default()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn json_export_downloads_what_variable_export_writes() {
|
||||
let (mut app, got) = browser(true);
|
||||
let expected = app.session.execute("variable.export", &json!({"format": "json"})).unwrap()["text"].as_str().unwrap().to_string();
|
||||
export_dialog(&mut app, "json");
|
||||
let got = got.lock().unwrap();
|
||||
let [(name, bytes)] = got.as_slice() else { panic!("one download, got {}", got.len()) };
|
||||
assert!(name.ends_with(".variables.json"), "{name}");
|
||||
assert_eq!(String::from_utf8(bytes.clone()).unwrap(), expected);
|
||||
let parsed: Value = serde_json::from_slice(bytes).unwrap();
|
||||
assert!(parsed.is_object());
|
||||
assert!(toast(&app).contains(name.as_str()), "{}", toast(&app));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn css_export_downloads_custom_properties() {
|
||||
let (mut app, got) = browser(true);
|
||||
export_dialog(&mut app, "css");
|
||||
let got = got.lock().unwrap();
|
||||
let [(name, bytes)] = got.as_slice() else { panic!("one download, got {}", got.len()) };
|
||||
assert!(name.ends_with(".variables.css"), "{name}");
|
||||
let css = String::from_utf8(bytes.clone()).unwrap();
|
||||
assert!(css.contains(":root") && css.contains("--"), "{css}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_refused_download_says_so() {
|
||||
let (mut app, got) = browser(false);
|
||||
export_dialog(&mut app, "json");
|
||||
assert_eq!(got.lock().unwrap().len(), 1);
|
||||
assert_eq!(toast(&app), "Couldn't download the variables");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn platforms_with_save_dialogs_keep_them() {
|
||||
// No dialog answer (cancelled) and no downloads: nothing happens, as before.
|
||||
let mut app = FigApp::new(session(), Box::new(NoServices));
|
||||
export_dialog(&mut app, "json");
|
||||
assert!(app.ui.toast.is_none());
|
||||
}
|
||||
@@ -204,9 +204,20 @@ fn header(app: &mut FigApp, ui: &mut Ui, open: &mut bool) {
|
||||
ui.painter().line_segment([r.left_bottom(), r.right_bottom()], Stroke::new(1.0, t.border));
|
||||
}
|
||||
|
||||
/// Export this file's collections through a save dialog.
|
||||
fn export_dialog(app: &mut FigApp, format: &str) {
|
||||
/// Export this file's collections through a save dialog, or as a download where the platform has
|
||||
/// no save dialogs (the browser).
|
||||
pub(crate) fn export_dialog(app: &mut FigApp, format: &str) {
|
||||
let name = app.session.doc().map(|d| format!("{}.variables.{format}", d.name)).unwrap_or_else(|_| format!("variables.{format}"));
|
||||
if app.services.downloads() {
|
||||
let Some(r) = app.run("variable.export", json!({"format": format})) else { return };
|
||||
let text = r["text"].as_str().unwrap_or_default();
|
||||
if app.services.download(&name, text.as_bytes()) {
|
||||
app.toast(trf("Exported {file}", &[("file", &name)]));
|
||||
} else {
|
||||
app.toast(tr("Couldn't download the variables"));
|
||||
}
|
||||
return;
|
||||
}
|
||||
let format = format.to_owned();
|
||||
let ext = format.clone();
|
||||
app.pick_save(tr("Export variables"), &name, &[&ext], move |app, p| {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Components, variants, styles and variables parity
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (core editor leftovers: component playground, variables inside styles; layers and assets panels behaviour suite and fixes; extended collections tail; styles and variables behaviour suite and fixes; components behaviour suite and fixes; team libraries on the server, library usage and swapping a library; extended collections, variables import/export, gradient stops and variant properties bound to variables) · **Target:** Figma Design (desktop app 126.10.7)
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (variables export in the browser as a download; core editor leftovers: component playground, variables inside styles; layers and assets panels behaviour suite and fixes; extended collections tail; styles and variables behaviour suite and fixes; components behaviour suite and fixes; team libraries on the server, library usage and swapping a library; extended collections, variables import/export, gradient stops and variant properties bound to variables) · **Target:** Figma Design (desktop app 126.10.7)
|
||||
|
||||
Design systems are why teams adopt Figma. **Components: breadth ~96%, ready ~72%. Styles and
|
||||
variables: breadth ~95%, ready ~76% (estimated).** QA'd with a synthetic design system of ~300
|
||||
@@ -69,13 +69,14 @@ updates with overrides kept: `library_tests.rs`, `components_scale_tests.rs`.
|
||||
| Explicit modes per frame, library collections' modes | done | |
|
||||
| Gradient stops and variant properties bound to variables | done | a stop's binding follows it when earlier stops are removed |
|
||||
| Extended collections | done | library collections, including a library's own extended collections (chains through libraries); overrides per mode with reset; follow library updates, variables new to the library collection being an update of their own; modes inherited. Checked with synthetic libraries only ([gaps](gaps.md) #14) |
|
||||
| Variables import/export (JSON) | done | the REST local variables shape (round-trips; merges by collection, mode and variable name); CSS custom properties export |
|
||||
| Variables import/export (JSON) | done | the REST local variables shape (round-trips; merges by collection, mode and variable name); CSS custom properties export; in the browser the export arrives as a download (import there still needs a file picker) |
|
||||
| Expressions in prototype set-variable actions | done | see [prototyping-parity](prototyping-parity.md) |
|
||||
|
||||
## Revision history
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-14 | minor | Variables export in the browser: Export… ▸ Variables JSON / CSS custom properties did nothing in the web build, which has no save dialogs; it now hands the file over as a download (`Services::downloads` / `download`, `variables_export_ui_tests`). Import in the browser still needs a file picker, as do the other exports there. Numbers unchanged. |
|
||||
| 2026-10-13 | minor | Component playground (M3): with an instance, a component or a component set selected, the Inspect tab shows a Playground: a preview of the component and a control for each variant, boolean and text property. What is tried there changes the preview and the code in the Code section, never the file (no undo step, the canvas instance stays as it is); Reset goes back to the component's or the instance's own values. `component.playground {id?, properties?, language?, path?}` gives the same to scripts: the properties with their values, the code, and a PNG on request (`playground_tests`). Left: instance-swap and slot properties in the playground (the command takes an instance swap; the panel has no control for it), a code connect template's snippet with the tried values. Numbers unchanged. |
|
||||
| 2026-10-13 | minor | Variables inside styles (M3): a shared style can bind variables to fields of its value (`style.bind {style, field, variable?}`: a paint style's colours and gradient stops, an effect style's colour, radius, spread and offsets, a grid style's size, count, gutter and offset, a text style's font size, line height, letter spacing, family and style). Every layer using the style shows those fields resolved in its own modes and stays linked when a variable or a mode changes; the style keeps them resolved in the default modes. A style made from a layer keeps that layer's bindings; a new value drops bindings to what is gone. The style editor binds from its property rows (paint colour, shadow colour, blur radius, grid size) and detaches from the variable's chip; the style picker marks styles that use variables. `.fig` export and import carry the bindings on the style, REST layers hand theirs to the style they use. Left: binding text style fields and the other effect and grid fields from the editor (commands only), library variables in the picker, a style's library copy. Numbers unchanged. |
|
||||
| 2026-10-12 | minor | Layers and Assets panels behaviour suite: Assets folders by page, frame and slash names; a leading `.` or `_` keeps a component out of the library even after it was published, and removing it brings the component back |
|
||||
|
||||
Reference in New Issue
Block a user