forked from 1b-agent/opencadstudio-upstream
feat: restore-all session recovery, ?file= deep links, offline PWA
Session recovery: more than one pending snapshot adds a 'Restore all' button to the Recover Drawing modal — every snapshot goes through the exact same guarded path as a single restore (deleted only after its own open succeeds). The open-tab list and active tab persist to localStorage on web (open/save/close) and the remembered tab is re-selected once a restore batch settles; the modal notes how many drawings the session had. Nothing is auto-opened. Deep links: ?file=<url|name> opens a drawing at boot. http(s) URLs must end in .dwg/.dxf and are fetched with the same streaming, 64 MB-capped, 8 s-deadline reader the remote images use, then go through the guarded open queue; cross-origin opens announce the origin first. Bare names resolve from the browser's recent storage, with a hint when absent. Everything else (file:, javascript:, drive letters, non-drawing tails) is refused with a console note. Offline PWA: the app was installable (manifest) but downloaded its 55 MB bundle on every visit. A cache-first service worker now serves the whole bundle from Cache Storage after one online visit — navigations network-first so updates land, hashed assets stale-while-revalidate, non-GET/cross-origin passthrough, old caches cleaned on activate, and the waiting-worker update flow documented. The build wrapper stamps the cache version from the bundle hash and hard-fails if sw.js is missing or unstamped. Also fixes a locale group mismatch on the DATALINK pick notice (catalog said a display group that does not exist in the ftl sources).
This commit is contained in:
1 parent
d719fb7778
commit
5f9b345ee6
38 files changed
+1211
-50
No files matched your search
@@ -736,6 +736,25 @@ pub(super) struct OpenCADStudio {
|
||||
/// prompt still has to offer, one at a time.
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
web_autosave_pending: Vec<crate::io::web_autosave::WebAutosaveEntry>,
|
||||
/// Snapshots a "Restore all" click still expects to settle (opened,
|
||||
/// failed, cancelled, unreadable, or rejected by the open queue). Each
|
||||
/// settle funnels through `queue_next_web_autosave_prompt`, which counts
|
||||
/// this down; reaching zero arms `web_session_focus_pending`.
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
web_autosave_restore_all_remaining: usize,
|
||||
/// A Restore-all batch finished settling — apply the remembered
|
||||
/// session's active tab at the next message boundary, after the last
|
||||
/// restored open has actually inserted its tab (`web_autosave_opened`
|
||||
/// runs before that insertion, so applying there would pick against a
|
||||
/// tab list still missing the last restore).
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
web_session_focus_pending: bool,
|
||||
/// The tab list the previous session remembered (localStorage), loaded
|
||||
/// after the boot autosave scan. Advisory only: it feeds the restore
|
||||
/// modal's session line and the post-restore tab selection — nothing is
|
||||
/// ever auto-opened from it.
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
web_session: Option<crate::io::web_session::WebSession>,
|
||||
/// The snapshot whose recovered open is in flight: `(autosave key,
|
||||
/// open id)`. The OPFS copy is the only one, so it is deleted only
|
||||
/// once the open with exactly that id succeeds — identity, never the
|
||||
@@ -2045,6 +2064,11 @@ pub enum Message {
|
||||
/// Web: an asynchronous OPFS copy written after Save is ready for recents.
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
WebRecentStored(Result<PathBuf, String>),
|
||||
/// Web: the `?file=` boot deep link settled — its bytes entered the
|
||||
/// guarded open queue, or a notice/error text for the command line
|
||||
/// (recent copy missing, refused or failed fetch).
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
WebLinkSettled(crate::io::web_link::LinkOutcome),
|
||||
/// Web: the boot scan finished — these autosave snapshots are newer than
|
||||
/// the drawings' last saved copies (newest first).
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
@@ -2052,6 +2076,12 @@ pub enum Message {
|
||||
/// Web: restore the offered autosave snapshot into a recovered tab.
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
WebAutosaveRestore,
|
||||
/// Web: restore EVERY pending autosave snapshot — each is read and
|
||||
/// enqueued through the guarded open queue with its own key (the poll
|
||||
/// opens them sequentially); a snapshot is still consumed only after its
|
||||
/// own open succeeds.
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
WebAutosaveRestoreAll,
|
||||
/// Web: delete the offered autosave snapshot (and offer the next, if any).
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
WebAutosaveDiscard,
|
||||
@@ -3895,6 +3925,12 @@ impl OpenCADStudio {
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
web_autosave_pending: Vec::new(),
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
web_autosave_restore_all_remaining: 0,
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
web_session_focus_pending: false,
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
web_session: None,
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
web_autosave_consumed: None,
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
web_autosave_clear_after: Vec::new(),
|
||||
@@ -4443,6 +4479,46 @@ impl OpenCADStudio {
|
||||
crate::io::web_autosave::scan(),
|
||||
Message::WebAutosavesScanned,
|
||||
);
|
||||
// URL deep link (`?file=…`): a shareable/embeddable link that opens a
|
||||
// drawing at boot. An http(s) URL is fetched with the same capped
|
||||
// streaming reader the remote-image fetch uses and handed to the
|
||||
// guarded drop-open queue; a bare name reads the OPFS recent copy the
|
||||
// same way `open_recent_web` does. Everything else (other schemes,
|
||||
// non-.dwg/.dxf URL tails) is refused with a console warning — the
|
||||
// same scheme gate the image fetch applies. Pure parsing/validation
|
||||
// lives in `io::web_link` and is unit-tested there.
|
||||
let deep_link: Task<Message> = {
|
||||
let search = web_sys::window()
|
||||
.and_then(|window| window.location().search().ok())
|
||||
.unwrap_or_default();
|
||||
match crate::io::web_link::parse_link(&search) {
|
||||
crate::io::web_link::ParsedLink::Target(target) => {
|
||||
// Opening a URL from the query string is an implicit trust
|
||||
// decision: name the origin before the fetch starts (info
|
||||
// only — the bytes still go through the parse + recovery
|
||||
// machinery like any other open). Same-origin links stay
|
||||
// quiet: that drawing already comes from this site.
|
||||
if let crate::io::web_link::LinkTarget::Url { url, name } = &target {
|
||||
if !crate::io::web_link::same_origin(url) {
|
||||
let origin = crate::io::web_link::display_origin(url)
|
||||
.unwrap_or_else(|| url.clone());
|
||||
s.command_line
|
||||
.push_info(&format!("Opening {name} from {origin}…"));
|
||||
}
|
||||
}
|
||||
Task::perform(crate::io::web_link::open_link(target), |outcome| {
|
||||
Message::WebLinkSettled(outcome)
|
||||
})
|
||||
}
|
||||
crate::io::web_link::ParsedLink::Ignored(refusal, value) => {
|
||||
web_sys::console::warn_1(&wasm_bindgen::JsValue::from_str(
|
||||
&crate::io::web_link::refusal_warning(refusal, &value),
|
||||
));
|
||||
Task::none()
|
||||
}
|
||||
crate::io::web_link::ParsedLink::Absent => Task::none(),
|
||||
}
|
||||
};
|
||||
(
|
||||
s,
|
||||
Task::batch([
|
||||
@@ -4453,6 +4529,7 @@ impl OpenCADStudio {
|
||||
discussions,
|
||||
thumbs_fetch,
|
||||
autosave_scan,
|
||||
deep_link,
|
||||
]),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -141,6 +141,10 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task<Message> {
|
||||
if let Some(tab_id) = tab_id {
|
||||
v4_support::on_tab_closed(tab_id);
|
||||
}
|
||||
// The tab list just shrank: refresh the remembered session
|
||||
// list (web session memory).
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
self.persist_web_session();
|
||||
Task::none()
|
||||
}
|
||||
|
||||
|
||||
@@ -240,6 +240,10 @@ impl OpenCADStudio {
|
||||
// last selection. #21.
|
||||
self.sync_ribbon_layers();
|
||||
self.sync_ribbon_from_selection();
|
||||
// The tab list just shrank: refresh the remembered
|
||||
// session list (web session memory).
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
self.persist_web_session();
|
||||
return Task::batch([close_win, self.continue_tab_close_queue()]);
|
||||
}
|
||||
Some(crate::app::PendingClose::Quit) => {
|
||||
|
||||
+116
-2
@@ -1698,6 +1698,10 @@ impl OpenCADStudio {
|
||||
.set(crate::app::OPEN_PHASE_FINALIZING, 10000, 1, 1);
|
||||
}
|
||||
self.opening.take();
|
||||
// The tab list just changed (and the active tab with it): remember
|
||||
// it for the next boot's restore flow (web session memory).
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
self.persist_web_session();
|
||||
let pending_open_task = if let Some(report) = recovery_report {
|
||||
self.recovery_report = Some(report);
|
||||
self.active_modal = Some(crate::app::ModalKind::Recovery);
|
||||
@@ -2231,6 +2235,9 @@ impl OpenCADStudio {
|
||||
);
|
||||
self.tabs[i].scene.document.version = version;
|
||||
self.tabs[i].recovery_save_as_required = false;
|
||||
// A save may have renamed the tab (Save As): refresh the remembered
|
||||
// session list (web session memory).
|
||||
self.persist_web_session();
|
||||
let snapshot_is_current = self.tabs[i].scene.geometry_epoch == geometry_epoch
|
||||
&& self.tabs[i].edit_revision == edit_revision
|
||||
&& self.tabs[i].scene.camera_generation == camera_generation;
|
||||
@@ -3342,12 +3349,16 @@ impl OpenCADStudio {
|
||||
/// restore prompt (behind any already-queued startup modal). An empty
|
||||
/// scan — a fresh profile, or everything already cleaned up — must not
|
||||
/// surface an empty "Recover Drawing" modal, so the prompt is only
|
||||
/// queued when there is something to offer.
|
||||
/// queued when there is something to offer. The remembered session list
|
||||
/// is loaded right after the scan: it never opens anything itself, it
|
||||
/// only names what the session had (a line in the restore modal) and
|
||||
/// picks the active tab once restores complete.
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub(super) fn on_web_autosaves_scanned(
|
||||
&mut self,
|
||||
result: Result<Vec<crate::io::web_autosave::WebAutosaveEntry>, String>,
|
||||
) -> Task<Message> {
|
||||
self.web_session = crate::io::web_session::load();
|
||||
let Ok(mut entries) = result else {
|
||||
// Best-effort feature: no storage access, no prompt.
|
||||
return Task::none();
|
||||
@@ -3384,6 +3395,44 @@ impl OpenCADStudio {
|
||||
)
|
||||
}
|
||||
|
||||
/// "Restore all": the whole pending list goes through the exact same
|
||||
/// guarded path as a single restore — one read per snapshot, then
|
||||
/// `enqueue_restore` with its own key, and the 50 ms poll opens them
|
||||
/// sequentially. Nothing about consumption changes: each snapshot is
|
||||
/// still deleted only when its own open succeeds (matched by open id),
|
||||
/// and any failure (unreadable, parse error, cancel) keeps that
|
||||
/// snapshot in OPFS for the next boot. The batch counts its settles in
|
||||
/// `web_autosave_restore_all_remaining`; every settle path funnels
|
||||
/// through `queue_next_web_autosave_prompt`, so the countdown — and the
|
||||
/// remembered session's tab selection when it hits zero — needs no
|
||||
/// extra bookkeeping sites.
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub(super) fn on_web_autosave_restore_all(&mut self) -> Task<Message> {
|
||||
// Fewer than two pending: this is just Restore.
|
||||
if self.web_autosave_pending.len() < 2 {
|
||||
return self.on_web_autosave_restore();
|
||||
}
|
||||
let entries = std::mem::take(&mut self.web_autosave_pending);
|
||||
self.web_autosave_restore_all_remaining = entries.len();
|
||||
self.close_active_modal();
|
||||
self.command_line.push_info(
|
||||
crate::tf!("Restoring {} drawing(s)…", entries.len()).as_ref(),
|
||||
);
|
||||
let reads = crate::io::web_autosave::restore_all_plan(&entries)
|
||||
.into_iter()
|
||||
.map(|(open_name, key)| {
|
||||
// Same contract as the single restore: the key travels with
|
||||
// the bytes so the deletion marker is recorded by open id.
|
||||
let read_key = key.clone();
|
||||
Task::perform(
|
||||
async move { crate::io::web_autosave::read(&read_key).await },
|
||||
move |result| Message::WebAutosaveBytes(open_name, key, result),
|
||||
)
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
Task::batch(reads)
|
||||
}
|
||||
|
||||
/// The recovered drawing opened: its autosave snapshot — held back until
|
||||
/// now — can finally be deleted, and the next queued snapshot can be
|
||||
/// offered. The caller already matched the marker to this open's id.
|
||||
@@ -3477,14 +3526,79 @@ impl OpenCADStudio {
|
||||
}
|
||||
|
||||
/// Re-offer the remaining boot snapshots once the current one settled.
|
||||
/// No-op when none are left (or the modal is already queued).
|
||||
/// No-op when none are left (or the modal is already queued). This is
|
||||
/// also the single funnel every restore settle runs through (opened,
|
||||
/// failed, cancelled, unreadable, or queue-rejected), so a Restore-all
|
||||
/// batch counts down here — and when its last snapshot settles, the
|
||||
/// remembered session decides which tab ends up active.
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub(super) fn queue_next_web_autosave_prompt(&mut self) {
|
||||
if self.web_autosave_restore_all_remaining > 0 {
|
||||
self.web_autosave_restore_all_remaining -= 1;
|
||||
if self.web_autosave_restore_all_remaining == 0 {
|
||||
// Applied at the next message boundary: the last restore's
|
||||
// settle (`web_autosave_opened`) runs BEFORE its own open
|
||||
// inserts the tab, so focusing here could not see it yet.
|
||||
self.web_session_focus_pending = true;
|
||||
}
|
||||
}
|
||||
if !self.web_autosave_pending.is_empty() {
|
||||
self.queue_autosave_restore_prompt();
|
||||
}
|
||||
}
|
||||
|
||||
/// A Restore-all batch has fully settled: focus the tab the crashed
|
||||
/// session remembered as active, if it actually came back (or is open
|
||||
/// under its own name). Restored tabs carry the "(recovered)" marker —
|
||||
/// `recovered_name` maps the remembered name onto it idempotently. A
|
||||
/// no-op without remembered memory, or when the remembered tab did not
|
||||
/// return; the last-opened tab then stays active, which is fine. Called
|
||||
/// from the after-message hook (`web_session_focus_pending`), so the
|
||||
/// whole settle batch — including the last tab insertion — is done.
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub(super) fn select_remembered_session_tab(&mut self) {
|
||||
let Some(session) = self.web_session.as_ref() else {
|
||||
return;
|
||||
};
|
||||
let Some(remembered) = session.tabs.get(session.active) else {
|
||||
return;
|
||||
};
|
||||
let recovered = crate::io::web_autosave::recovered_name(remembered);
|
||||
let Some(index) = self.tabs.iter().position(|tab| {
|
||||
let name = tab.tab_display_name();
|
||||
name == remembered.as_str() || name == recovered
|
||||
}) else {
|
||||
return;
|
||||
};
|
||||
self.active_tab = index;
|
||||
// The active tab changed under the ribbon; re-sync its chips like
|
||||
// every other tab switch does (#21).
|
||||
self.sync_ribbon_layers();
|
||||
self.sync_ribbon_styles();
|
||||
self.sync_ribbon_from_selection();
|
||||
}
|
||||
|
||||
/// Remember the open-tab list (names + active index) in localStorage —
|
||||
/// the web session memory the next boot's restore flow consults. Cheap
|
||||
/// by design: a small `Vec<String>` + index, rewritten in full. Called
|
||||
/// on every save/open/close; the Start tab is not a drawing and is
|
||||
/// skipped, with the active index remapped onto the filtered list.
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub(super) fn persist_web_session(&self) {
|
||||
let mut names: Vec<String> = Vec::new();
|
||||
let mut active = 0;
|
||||
for (index, tab) in self.tabs.iter().enumerate() {
|
||||
if tab.is_start {
|
||||
continue;
|
||||
}
|
||||
if index == self.active_tab {
|
||||
active = names.len();
|
||||
}
|
||||
names.push(tab.tab_display_name());
|
||||
}
|
||||
crate::io::web_session::persist(&names, active);
|
||||
}
|
||||
|
||||
/// Delete the `.sv$` autosave recovery files for all open drawings. They
|
||||
/// exist only to survive a crash, so a clean save or exit removes them.
|
||||
pub(in crate::app) fn cleanup_autosaves(&mut self) {
|
||||
|
||||
@@ -291,6 +291,14 @@ impl OpenCADStudio {
|
||||
let task = self.update_inner(msg);
|
||||
self.refresh_gpu_status();
|
||||
self.show_next_startup_modal();
|
||||
// A Restore-all batch finished settling on this message: now that
|
||||
// the last restored open has inserted its tab, focus the tab the
|
||||
// remembered session had active (web session memory).
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
if self.web_session_focus_pending {
|
||||
self.web_session_focus_pending = false;
|
||||
self.select_remembered_session_tab();
|
||||
}
|
||||
self.sync_open_command_history();
|
||||
// Close the document-level first-touch transaction started by
|
||||
// push_undo_snapshot at this message boundary.
|
||||
@@ -1135,11 +1143,27 @@ impl OpenCADStudio {
|
||||
}
|
||||
},
|
||||
|
||||
// The `?file=` boot deep link settled. Queued bytes are drained by
|
||||
// the control poll's drop queue like any drop — nothing to say
|
||||
// until that open starts (or its parse error surfaces there).
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
Message::WebLinkSettled(outcome) => {
|
||||
use crate::io::web_link::LinkOutcome;
|
||||
match outcome {
|
||||
LinkOutcome::Queued => {}
|
||||
LinkOutcome::Info(text) => self.command_line.push_info(&text),
|
||||
LinkOutcome::Error(text) => self.command_line.push_error(&text),
|
||||
}
|
||||
Task::none()
|
||||
},
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
Message::WebAutosavesScanned(result) => self.on_web_autosaves_scanned(result),
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
Message::WebAutosaveRestore => self.on_web_autosave_restore(),
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
Message::WebAutosaveRestoreAll => self.on_web_autosave_restore_all(),
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
Message::WebAutosaveDiscard => self.on_web_autosave_discard(),
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
Message::WebAutosaveDefer => self.on_web_autosave_defer(),
|
||||
|
||||
+62
-23
@@ -1599,8 +1599,15 @@ impl OpenCADStudio {
|
||||
.first()
|
||||
.map(|entry| entry.name.clone())
|
||||
.unwrap_or_default();
|
||||
let pending = self.web_autosave_pending.len();
|
||||
// What the crashed session had open, when it remembered.
|
||||
let session_drawings = self
|
||||
.web_session
|
||||
.as_ref()
|
||||
.map(|session| session.tabs.len())
|
||||
.filter(|count| *count > 0);
|
||||
automatic_flow(ex, |flow| {
|
||||
autosave_restore_dialog_window(&name, flow)
|
||||
autosave_restore_dialog_window(&name, pending, session_drawings, flow)
|
||||
})
|
||||
}
|
||||
})
|
||||
@@ -1777,36 +1784,68 @@ fn unsaved_changes_dialog_window(
|
||||
|
||||
/// Web crash-recovery prompt: an autosave snapshot of `name` is newer than
|
||||
/// the drawing's last save. Restore opens it as a recovered tab; Discard
|
||||
/// deletes the snapshot. Mirrors the unsaved-changes dialog's layout.
|
||||
/// deletes the snapshot. With more than one snapshot still pending, "Restore
|
||||
/// all" puts the whole remaining list through the same guarded open queue.
|
||||
/// When the crashed session left a tab list behind, a muted line says how
|
||||
/// much it had open. Mirrors the unsaved-changes dialog's layout.
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
fn autosave_restore_dialog_window(
|
||||
name: &str,
|
||||
pending: usize,
|
||||
session_drawings: Option<usize>,
|
||||
sizing: crate::ui::modal::ModalSizing,
|
||||
) -> Element<'static, Message> {
|
||||
container(
|
||||
column![
|
||||
text(crate::tf!(
|
||||
"\"{name}\" has an autosave copy newer than the last save."
|
||||
))
|
||||
.size(14),
|
||||
iced::widget::Space::new().height(4),
|
||||
let mut body = column![text(crate::tf!(
|
||||
"\"{name}\" has an autosave copy newer than the last save."
|
||||
))
|
||||
.size(14)]
|
||||
.spacing(0);
|
||||
if let Some(count) = session_drawings {
|
||||
body = body
|
||||
.push(iced::widget::Space::new().height(4))
|
||||
.push(
|
||||
text(crate::tf!("Session had {count} open drawing(s).", count = count))
|
||||
.size(12)
|
||||
.style(dialog_muted_text_style),
|
||||
);
|
||||
}
|
||||
body = body
|
||||
.push(iced::widget::Space::new().height(4))
|
||||
.push(
|
||||
text(t!("Restore it as a recovered tab, or discard the copy."))
|
||||
.size(12)
|
||||
.style(dialog_muted_text_style),
|
||||
iced::widget::Space::new().height(20),
|
||||
row![
|
||||
dialog_button(t!("Restore"), Message::WebAutosaveRestore, button::primary),
|
||||
iced::widget::Space::new().width(8),
|
||||
dialog_button(t!("Discard"), Message::WebAutosaveDiscard, button::danger),
|
||||
],
|
||||
]
|
||||
.spacing(0),
|
||||
)
|
||||
.style(dialog_body_style)
|
||||
.center_x(sizing.width)
|
||||
.center_y(sizing.height)
|
||||
.padding([24, 28])
|
||||
.into()
|
||||
)
|
||||
.push(iced::widget::Space::new().height(20));
|
||||
let mut buttons = row![dialog_button(
|
||||
t!("Restore"),
|
||||
Message::WebAutosaveRestore,
|
||||
button::primary
|
||||
)];
|
||||
// Only worth its width once there is a batch to take in one click.
|
||||
if pending > 1 {
|
||||
buttons = buttons
|
||||
.push(iced::widget::Space::new().width(8))
|
||||
.push(dialog_button(
|
||||
t!("Restore all"),
|
||||
Message::WebAutosaveRestoreAll,
|
||||
button::secondary,
|
||||
));
|
||||
}
|
||||
buttons = buttons
|
||||
.push(iced::widget::Space::new().width(8))
|
||||
.push(dialog_button(
|
||||
t!("Discard"),
|
||||
Message::WebAutosaveDiscard,
|
||||
button::danger,
|
||||
));
|
||||
body = body.push(buttons);
|
||||
container(body)
|
||||
.style(dialog_body_style)
|
||||
.center_x(sizing.width)
|
||||
.center_y(sizing.height)
|
||||
.padding([24, 28])
|
||||
.into()
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
|
||||
Reference in new issue
Block a user