From 302528d67ed043a01a6a63e35ab9913aabdf2a05 Mon Sep 17 00:00:00 2001 From: Dmitrii Khlapov Date: Sat, 10 Oct 2026 18:20:56 +0200 Subject: [PATCH] =?UTF-8?q?Web:=20use=20the=20browser=20clock=20everywhere?= =?UTF-8?q?=20on=20wasm=20(insert=20weekday,=20PDF=20dates,=20layout=20tim?= =?UTF-8?q?ing,=20UI=20timers)=20=E2=80=94=20follow-up=20to=20#214=20(#230?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #214 gave now_iso the browser's clock on wasm; the other clock helpers were still stubs there: Insert Date's weekday was always Thursday, the PDF creation date was left out, and the layout and UI millisecond clocks returned 0, so browser AutoSave's timer never fired, the caret didn't blink and status messages never cleared. The engine's clock is now one now_unix() (std on native, js_sys::Date in the browser, as #214 does) that now_iso and the weekday share; the pdf, layout and ui-egui clocks read js_sys::Date the same way on wasm. Refs #188. Co-authored-by: Dmitrii Khlapov <19991457+dkhlapov@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 --- Cargo.lock | 3 +++ crates/engine/src/cmd/insert.rs | 16 ++-------------- crates/engine/src/cmd/mod.rs | 16 ++++++++++------ crates/engine/src/tests.rs | 14 ++++++++++++++ crates/layout/Cargo.toml | 4 ++++ crates/layout/src/lib.rs | 6 ++++-- crates/pdf/Cargo.toml | 4 ++++ crates/pdf/src/lib.rs | 21 +++++++++++++-------- crates/ui-egui/Cargo.toml | 4 ++++ crates/ui-egui/src/lib.rs | 5 ++++- 10 files changed, 62 insertions(+), 31 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index ada579fc3..dd944696f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4994,6 +4994,7 @@ dependencies = [ name = "wordcraft-layout" version = "0.4.0" dependencies = [ + "js-sys", "log", "serde", "serde_json", @@ -5026,6 +5027,7 @@ dependencies = [ "hayro-interpret", "hayro-syntax", "image", + "js-sys", "krilla", "kurbo", "log", @@ -5065,6 +5067,7 @@ dependencies = [ "egui", "egui_kittest", "image", + "js-sys", "log", "serde", "serde_json", diff --git a/crates/engine/src/cmd/insert.rs b/crates/engine/src/cmd/insert.rs index e99d75a8e..0be2dd516 100644 --- a/crates/engine/src/cmd/insert.rs +++ b/crates/engine/src/cmd/insert.rs @@ -449,20 +449,8 @@ pub fn format_date(fmt: &str) -> String { const MONTHS: [&str; 12] = ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"]; const DAYS: [&str; 7] = ["Thursday", "Friday", "Saturday", "Sunday", "Monday", "Tuesday", "Wednesday"]; - let days_since = { - // Days since epoch for weekday. - let secs = { - #[cfg(not(target_arch = "wasm32"))] - { - std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0) - } - #[cfg(target_arch = "wasm32")] - { - 0u64 - } - }; - (secs / 86_400) as usize - }; + // Days since the epoch, for the weekday. + let days_since = (super::now_unix() / 86_400) as usize; let month = MONTHS.get(m.saturating_sub(1)).copied().unwrap_or("January"); let day = DAYS.get(days_since % 7).copied().unwrap_or("Monday"); let mut out = String::new(); diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index d457db913..bd1962a55 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -254,28 +254,32 @@ pub fn split_para(s: &mut Session, at: &Pos) -> Result { } /// ISO-8601 timestamp (UTC, seconds) for `secs` since the Unix epoch. -fn iso_from_unix_secs(secs: u64) -> String { +pub fn iso_from_unix_secs(secs: u64) -> String { let (y, m, d) = civil_from_days((secs / 86_400) as i64); let t = secs % 86_400; format!("{y:04}-{m:02}-{d:02}T{:02}:{:02}:{:02}Z", t / 3600, t / 60 % 60, t % 60) } -/// ISO-8601 timestamp (UTC, seconds). -pub fn now_iso() -> String { +/// Seconds since the Unix epoch (UTC): the system clock, or the browser's on the web. +pub fn now_unix() -> u64 { #[cfg(not(target_arch = "wasm32"))] { - let secs = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0); - iso_from_unix_secs(secs) + std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0) } // `SystemTime::now()` panics on wasm32-unknown-unknown, so ask the browser's clock. #[cfg(target_arch = "wasm32")] { let ms = js_sys::Date::now(); // Clamp a NaN or pre-epoch clock to 0 rather than fail. - iso_from_unix_secs(if ms.is_finite() && ms > 0.0 { (ms / 1000.0) as u64 } else { 0 }) + if ms.is_finite() && ms > 0.0 { (ms / 1000.0) as u64 } else { 0 } } } +/// ISO-8601 timestamp (UTC, seconds). +pub fn now_iso() -> String { + iso_from_unix_secs(now_unix()) +} + /// Days since 1970-01-01 → (year, month, day) (Howard Hinnant's algorithm). pub fn civil_from_days(z: i64) -> (i64, u32, u32) { let z = z + 719_468; diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index d19627da2..88b46f9bd 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -1638,3 +1638,17 @@ fn styles_apply_rejected_on_protected_document() { assert_eq!(para_style(&s, 0), None); assert_eq!(s.undo_labels(), undo); } + +/// Comments, revisions and dates read the real clock (#188: the web stamped 2026-01-01). +#[test] +fn timestamps_come_from_the_clock() { + let before = cmd::now_unix(); + assert!(before > 1_760_000_000, "{before}"); + let mut s = s(); + run(&mut s, "text.insert", json!({"text": "Dated"})); + run(&mut s, "select.text", json!({"text": "Dated"})); + run(&mut s, "review.newComment", json!({"text": "When?"})); + let date = run(&mut s, "review.comments", json!({}))[0]["date"].as_str().unwrap_or_default().to_string(); + // Fixed-width ISO strings sort by time. + assert!(date >= cmd::iso_from_unix_secs(before) && date <= cmd::iso_from_unix_secs(cmd::now_unix()), "{date}"); +} diff --git a/crates/layout/Cargo.toml b/crates/layout/Cargo.toml index ac2401885..a97175fe9 100644 --- a/crates/layout/Cargo.toml +++ b/crates/layout/Cargo.toml @@ -19,3 +19,7 @@ unicode-segmentation = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } log = { workspace = true } + +[target.'cfg(target_arch = "wasm32")'.dependencies] +# The browser clock (`SystemTime::now()`/`Instant::now()` panic on wasm32). +js-sys = "0.3" diff --git a/crates/layout/src/lib.rs b/crates/layout/src/lib.rs index 5e3984de1..944f06c1a 100644 --- a/crates/layout/src/lib.rs +++ b/crates/layout/src/lib.rs @@ -1617,16 +1617,18 @@ pub fn is_floating(o: &InlineObject) -> bool { } } -/// Milliseconds since an arbitrary epoch (monotonic on native; 0 on wasm). +/// Milliseconds since an arbitrary epoch (monotonic on native; the browser's clock on the web). pub fn now_ms() -> f64 { #[cfg(not(target_arch = "wasm32"))] { static T0: std::sync::OnceLock = std::sync::OnceLock::new(); T0.get_or_init(std::time::Instant::now).elapsed().as_secs_f64() * 1000.0 } + // `Instant::now()` panics on wasm32-unknown-unknown, so ask the browser's clock. #[cfg(target_arch = "wasm32")] { - 0.0 + let ms = js_sys::Date::now(); + if ms.is_finite() { ms } else { 0.0 } } } diff --git a/crates/pdf/Cargo.toml b/crates/pdf/Cargo.toml index b64c6c526..2126f28cf 100644 --- a/crates/pdf/Cargo.toml +++ b/crates/pdf/Cargo.toml @@ -19,6 +19,10 @@ image = { workspace = true } thiserror = { workspace = true } log = { workspace = true } +[target.'cfg(target_arch = "wasm32")'.dependencies] +# The browser clock (`SystemTime::now()` panics on wasm32). +js-sys = "0.3" + [dev-dependencies] hayro-syntax = "0.7.2" hayro-interpret = { version = "0.7.0", default-features = false, features = ["embed-fonts"] } diff --git a/crates/pdf/src/lib.rs b/crates/pdf/src/lib.rs index 23ea5c3e8..b348bfd7f 100644 --- a/crates/pdf/src/lib.rs +++ b/crates/pdf/src/lib.rs @@ -259,15 +259,20 @@ fn parse_iso(s: &str) -> Option { Some((y, mo as u8, d as u8, h as u8, mi as u8, sec as u8)) } -#[cfg(not(target_arch = "wasm32"))] +/// The system clock, or the browser's on the web. fn now_unix() -> Option { - use std::time::{SystemTime, UNIX_EPOCH}; - SystemTime::now().duration_since(UNIX_EPOCH).ok().map(|d| d.as_secs() as i64) -} - -#[cfg(target_arch = "wasm32")] -fn now_unix() -> Option { - None + #[cfg(not(target_arch = "wasm32"))] + { + use std::time::{SystemTime, UNIX_EPOCH}; + let secs = SystemTime::now().duration_since(UNIX_EPOCH).ok()?.as_secs(); + i64::try_from(secs).ok() + } + // `SystemTime::now()` panics on wasm32-unknown-unknown, so ask the browser's clock. + #[cfg(target_arch = "wasm32")] + { + let ms = js_sys::Date::now(); + (ms.is_finite() && ms > 0.0).then(|| (ms / 1000.0) as i64) + } } /// Unix seconds (UTC) → civil time (proleptic Gregorian). diff --git a/crates/ui-egui/Cargo.toml b/crates/ui-egui/Cargo.toml index d06751531..b46af1ef2 100644 --- a/crates/ui-egui/Cargo.toml +++ b/crates/ui-egui/Cargo.toml @@ -23,6 +23,10 @@ log = { workspace = true } # The system's preferred interface languages (#8). Native: OS APIs; wasm: none (English). sys-locale = "0.3" +[target.'cfg(target_arch = "wasm32")'.dependencies] +# The browser clock (`SystemTime::now()`/`Instant::now()` panic on wasm32). +js-sys = "0.3" + [dev-dependencies] egui_kittest = { workspace = true, features = ["wgpu", "snapshot"] } image = { workspace = true } diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index d9a44fbff..449f5c3be 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -890,15 +890,18 @@ fn discards_document(id: &str, params: &Value) -> bool { } } +/// Wall-clock milliseconds since the Unix epoch: the system clock, or the browser's on the web. pub fn now_ms() -> f64 { #[cfg(not(target_arch = "wasm32"))] { use std::time::{SystemTime, UNIX_EPOCH}; SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_secs_f64() * 1000.0).unwrap_or(0.0) } + // `SystemTime::now()` panics on wasm32-unknown-unknown, so ask the browser's clock. #[cfg(target_arch = "wasm32")] { - 0.0 + let ms = js_sys::Date::now(); + if ms.is_finite() && ms > 0.0 { ms } else { 0.0 } } }