Browse / github.com/storytold/wordcraft / patch
feat(web): expose correlated native snapshots
Web host: request complete native snapshots without acknowledging Save Same-origin iframe hosts can open native JSON, but the existing save bridge follows Save/Save As semantics, including save metadata and clearing dirty on delivery, and only reports boolean dirty transitions. A host cannot request a full native snapshot without acknowledging Save or detect another edit while dirty is already true. Add correlated native snapshot requests to the existing opt-in host bridge and an opaque revision change notification. A snapshot should include pending comment/reply text through the existing Save preparation, validate its serialization with the native Document pars
- Status
- open
- Score
- 0.00
- Adopters
- 0 other operators
- Author
- Srbino (imported from pull request #4573, open)
- Base
2cf1d6c7ae94723a45bd6e7e8c884fe375148750- Size
- +280 −10 in 7 files
- Review
- pending
- Shared
- 2026-10-28
- Id
0b00f4a2fecafe6c61da85a6
How it went
No other operator has reported using it yet.
Diff
raw · apps/wordcraft-web/src/host.rs apps/wordcraft-web/src/main.rs apps/wordcraft-web/src/snapshot.rs apps/wordcraft-web/src/web.rs crates/engine/src/tests.rs crates/ui-egui/src/app/frame.rs crates/ui-egui/src/panes/comments.rs
@@ -18,12 +18,23 @@ //! - Save As or Export to a format that doesn't (PDF, Markdown, plain text), Export as PDF and //! Export Page as PNG: `false`. They are copies; the document's file stays the same. //! - `wordcraft:dirty` (to the parent): `{ dirty }` whenever unsaved changes appear or go away.+//! - `wordcraft:changed` (to the parent): `{ revision }` after every native document revision,+//! including further edits while `dirty` stays true. Revision is an opaque string that also+//! distinguishes replacing the document.+//! - `wordcraft:snapshot` (from the parent): `{ requestId }`, a nonempty string of at most 128+//! UTF-8 bytes. Requests the complete native JSON. Pending comment/reply text is committed as+//! for File Save; dirty state is never cleared and storage is not acknowledged.+//! - `wordcraft:snapshot-result` (to the parent): `{ requestId, revision, bytes }` (a transferred+//! `ArrayBuffer` of native JSON), or `{ requestId, error }`. At most one request is pending;+//! another receives an error. The native Document parser checks the serialized snapshot.+//! Neither snapshot nor changed grants durable Saved. Reopen native bytes with `wordcraft:open`+//! and a `.json` name. The host guards unsaved work before replacing a document. //! - `wordcraft:failed` (to the parent): `{ error }` when the app couldn't start. //! //! Only the parent window on the page's own origin is heard or answered. In host mode the parent //! guards leaving the page, and `?author=` names the user for comments and tracked changes. -use std::cell::Cell;+use std::cell::{Cell, RefCell}; use std::rc::Rc; use wasm_bindgen::JsCast as _;@@ -39,6 +50,8 @@ pub struct Bridge { window: web_sys::Window, parent: web_sys::Window, origin: String,+ snapshot: Rc<RefCell<Option<String>>>,+ observed: Rc<RefCell<crate::snapshot::RevisionObserver>>, } impl Bridge {@@ -60,7 +73,7 @@ impl Bridge { if parent.location().origin().ok()? != origin { return None; }- Some(Self { window, parent, origin })+ Some(Self { window, parent, origin, snapshot: Rc::default(), observed: Rc::default() }) } /// Hand saved and exported files to the parent instead of downloading them, report unsaved@@ -119,11 +132,27 @@ impl Bridge { fn listen(&self, inbox: Inbox, ctx: egui::Context) { let parent = self.parent.clone(); let origin = self.origin.clone();+ let snapshot = self.snapshot.clone();+ let bridge = self.clone(); let on_message = Closure::<dyn FnMut(web_sys::MessageEvent)>::new(move |event: web_sys::MessageEvent| { if event.origin() != origin || !event.source().is_some_and(|s| js_sys::Object::is(&s, &parent)) { return; } let data = event.data();+ if string(&data, "type").as_deref() == Some("wordcraft:snapshot") {+ let Some(id) = string(&data, "requestId").filter(|id| !id.is_empty() && id.len() <= 128) else { return };+ if snapshot.borrow().is_some() {+ bridge.send(&[+ ("type", "wordcraft:snapshot-result".into()),+ ("requestId", id.into()),+ ("error", "snapshot already pending".into()),+ ]);+ return;+ }+ *snapshot.borrow_mut() = Some(id);+ ctx.request_repaint();+ return;+ } if string(&data, "type").as_deref() != Some("wordcraft:open") { return; }@@ -138,6 +167,38 @@ impl Bridge { } } + /// Report native revisions independently of dirty state, then answer a pending native+ /// snapshot request after the app has processed its inbox and commands. Pending comment/reply+ /// drafts are committed through the same commands as Save. Neither is a save+ /// acknowledgement; the host must confirm its own durable storage separately.+ pub fn observe(&self, app: &mut WordApp) {+ let pending = self.snapshot.borrow_mut().take();+ let prepared = if pending.is_some() { app.prepare_native_snapshot() } else { Ok(()) };+ let session = &app.session;+ let draft_revision = app.native_draft_revision();+ let revision = crate::snapshot::revision(session, draft_revision);+ if self.observed.borrow_mut().changed(session, draft_revision) {+ self.send(&[("type", "wordcraft:changed".into()), ("revision", revision.as_str().into())]);+ }+ let Some(id) = pending else { return };+ let result = prepared.and_then(|()| crate::snapshot::native_snapshot(session)).and_then(|bytes| {+ let buffer = js_sys::Uint8Array::from(bytes.as_slice()).buffer();+ let value = message(&[+ ("type", "wordcraft:snapshot-result".into()),+ ("requestId", id.as_str().into()),+ ("revision", revision.as_str().into()),+ ("bytes", buffer.clone().into()),+ ])+ .ok_or_else(|| "couldn't build the snapshot message".to_string())?;+ self.parent+ .post_message_with_transfer(&value, &self.origin, &js_sys::Array::of1(&buffer))+ .map_err(|e| e.as_string().unwrap_or_else(|| format!("{e:?}")))+ });+ if let Err(error) = result {+ self.send(&[("type", "wordcraft:snapshot-result".into()), ("requestId", id.as_str().into()), ("error", error.as_str().into())]);+ }+ }+ /// Post a message to the parent; false if it couldn't be sent. fn send(&self, fields: &[(&str, JsValue)]) -> bool { message(fields).is_some_and(|m| self.parent.post_message(&m, &self.origin).is_ok())@@ -18,6 +18,9 @@ mod prefs; #[cfg(any(target_arch = "wasm32", test))] mod save_fallback; +#[cfg(any(target_arch = "wasm32", test))]+mod snapshot;+ #[cfg(target_arch = "wasm32")] mod host; #[cfg(target_arch = "wasm32")]new file mode 100644@@ -0,0 +1,106 @@+//! Native JSON snapshots for a host that stores complete editor versions.++use wordcraft_engine::Session;++/// Serialize only the native model, without changing dirty state, undo or file identity.+/// Parsing our own output also rejects values (such as a non-finite required float) that+/// serialize but cannot be opened as a native Document.+pub fn native_snapshot(session: &Session) -> Result<Vec<u8>, String> {+ let bytes = wordcraft_engine::io::save_bytes("snapshot.json", &session.doc)?;+ let _: wordcraft_doc::Document = serde_json::from_slice(&bytes).map_err(|e| e.to_string())?;+ Ok(bytes)+}++/// An opaque token: include document identity, and keep u64 values out of JS numbers.+pub fn revision(session: &Session, draft_revision: u64) -> String {+ format!("{}:{}:{}", session.document_id(), session.rev(), draft_revision)+}++#[derive(Default)]+pub struct RevisionObserver(Option<(u64, u64, u64)>);++impl RevisionObserver {+ pub fn changed(&mut self, session: &Session, draft_revision: u64) -> bool {+ let current = (session.document_id(), session.rev(), draft_revision);+ if self.0 == Some(current) {+ return false;+ }+ self.0 = Some(current);+ true+ }+}++#[cfg(test)]+mod tests {+ use super::*;+ use wordcraft_doc::{InlineObject, Part, PartKind};+ use wordcraft_engine::Session;++ #[test]+ fn native_snapshot_preserves_complete_model_and_does_not_mark_saved() {+ let mut session = Session::new(wordcraft_engine::sample::sample_document());+ session.doc.parts.insert(+ 600,+ Part {+ kind: PartKind::Comment,+ blocks: vec![wordcraft_doc::para_block(wordcraft_doc::Paragraph::with_text("Snapshot comment", Default::default()))],+ ..Default::default()+ },+ );+ session.doc.comments.insert(1, wordcraft_doc::Comment { author: "Tester".into(), part: 600, ..Default::default() });+ let image = session.doc.add_media(vec![71; 3_200_000], "png");+ let mut para = wordcraft_doc::Paragraph::with_text("\u{fffc}", Default::default());+ para.objects.push(InlineObject::Image {+ media: image,+ w: 10.,+ h: 10.,+ alt: "Large image".into(),+ float: Default::default(),+ crop: [0.; 4],+ ole: None,+ });+ session.doc.body.push(wordcraft_doc::para_block(para));+ session.run("text.insert", &serde_json::json!({"text":"Edited "})).unwrap();+ let document_id = session.document_id();+ let revision = session.rev();+ let undo_depth = session.undo_depth();+ let before = serde_json::to_value(&session.doc).unwrap();+ let bytes = native_snapshot(&session).unwrap();+ assert!(bytes.len() > 4 * 1024 * 1024);+ let reopened = wordcraft_engine::io::open_bytes("version.json", &bytes).unwrap();+ assert_eq!(serde_json::to_value(&reopened).unwrap(), before);+ assert_eq!(serde_json::to_value(&session.doc).unwrap(), before);+ assert!(session.dirty);+ assert_eq!(session.document_id(), document_id);+ assert_eq!(session.rev(), revision);+ assert_eq!(session.undo_depth(), undo_depth);+ }+ #[test]+ fn changes_report_every_revision_even_while_the_document_stays_dirty() {+ let mut session = Session::new(wordcraft_doc::Document::new());+ let mut observer = RevisionObserver::default();+ assert!(observer.changed(&session, 0));+ assert!(!observer.changed(&session, 0));+ session.run("text.insert", &serde_json::json!({"text":"first"})).unwrap();+ assert!(session.dirty);+ assert!(observer.changed(&session, 0));+ let first = revision(&session, 0);+ session.run("text.insert", &serde_json::json!({"text":"second"})).unwrap();+ assert!(session.dirty);+ assert!(observer.changed(&session, 0));+ assert_ne!(revision(&session, 0), first);+ assert!(!observer.changed(&session, 0));+ session.set_document(wordcraft_doc::Document::new());+ assert!(observer.changed(&session, 0));+ }+ #[test]+ fn pending_draft_changes_are_reported_without_serializing_the_document() {+ let session = Session::new(wordcraft_doc::Document::new());+ let mut observer = RevisionObserver::default();+ assert!(observer.changed(&session, 0));+ assert!(observer.changed(&session, 1));+ assert!(observer.changed(&session, 2));+ assert!(!observer.changed(&session, 2));+ assert_ne!(revision(&session, 1), revision(&session, 2));+ }+}@@ -99,7 +99,7 @@ pub fn start() { crate::host::Bridge::set_author(&mut app, &author); prefs.keep_own_author(&app.session.author, &own); }- Ok(Box::new(WebShell { app, inbox, messages, prefs }))+ Ok(Box::new(WebShell { app, inbox, messages, prefs, bridge: app_bridge })) }), ) .await;@@ -134,6 +134,7 @@ struct WebShell { inbox: Inbox, messages: Rc<RefCell<Vec<String>>>, prefs: crate::prefs::PrefsSync,+ bridge: Option<crate::host::Bridge>, } fn local_storage() -> Option<web_sys::Storage> {@@ -155,6 +156,9 @@ impl eframe::App for WebShell { self.app.status(msg); } self.app.logic(ctx);+ if let Some(bridge) = &self.bridge {+ bridge.observe(&mut self.app);+ } // Store changed preferences straight away: a reload or closed tab runs no exit code (#2132). if let Some(json) = self.prefs.changed(&self.app) { match local_storage() {@@ -10865,10 +10865,11 @@ fn ime_body_epoch_marks_body_replacements_not_edits() { run(&mut s, "text.insert", json!({"text": " two"})); assert_eq!(s.ime_body_epoch(), first, "typing is not a replacement"); + let document_id = s.document_id(); run(&mut s, "document.setText", json!({"text": "same text"})); let replaced = s.ime_body_epoch(); assert!(replaced > first, "setText replaces the body under the same document id");- assert_eq!(s.document_id(), 1, "the document id itself is unchanged");+ assert_eq!(s.document_id(), document_id, "the document id itself is unchanged"); // The same text again still replaces the body: a composition must not survive on content // equality.@@ -20,6 +20,17 @@ impl WordApp { } } + /// Commit pending comment/reply text as a normal save does, without acknowledging any+ /// storage or clearing dirty state. A refused draft stays local and returns an error.+ pub fn prepare_native_snapshot(&mut self) -> Result<(), String> {+ panes::flush_comment_drafts(self)+ }++ /// Changes to pending comment/reply text, independent of the document's dirty flag.+ pub fn native_draft_revision(&self) -> u64 {+ self.comment_drafts.revision_for(self.session.document_id())+ }+ /// Tell the host whether the document has unsaved changes ([`Services::on_dirty`]). pub(super) fn report_dirty(&self) { if let Some(f) = &self.services.on_dirty {@@ -72,6 +72,7 @@ pub(crate) struct CommentDrafts { document: u64, edits: BTreeMap<u32, String>, replies: BTreeMap<u32, String>,+ revision: u64, } impl CommentDrafts {@@ -85,6 +86,37 @@ impl CommentDrafts { self } + /// A cheap change token for unsaved text that is not in the native model yet.+ pub(crate) fn revision_for(&self, document: u64) -> u64 {+ if self.document == document { self.revision } else { 0 }+ }++ fn record_edit(&mut self, id: u32, text: String) {+ if self.edits.get(&id) != Some(&text) {+ self.edits.insert(id, text);+ self.revision = self.revision.wrapping_add(1);+ }+ }++ fn record_reply(&mut self, id: u32, text: String) {+ if self.replies.get(&id) != Some(&text) {+ self.replies.insert(id, text);+ self.revision = self.revision.wrapping_add(1);+ }+ }++ fn forget_edit(&mut self, id: u32) {+ if self.edits.remove(&id).is_some() {+ self.revision = self.revision.wrapping_add(1);+ }+ }++ fn forget_reply(&mut self, id: u32) {+ if self.replies.remove(&id).is_some() {+ self.revision = self.revision.wrapping_add(1);+ }+ }+ /// Nothing typed and not yet written. pub(crate) fn is_empty(&self) -> bool { self.edits.is_empty() && self.replies.is_empty()@@ -133,12 +165,12 @@ pub(crate) fn comment_editor(app: &mut WordApp, ui: &mut Ui, id: u32, font: Opti r.request_focus(); } if r.changed() {- drafts(app).edits.insert(id, text);+ drafts(app).record_edit(id, text); } if r.lost_focus() { if ui.input(|i| i.key_pressed(egui::Key::Escape)) { // Escape throws the draft away: the field shows the comment's text again.- drafts(app).edits.remove(&id);+ drafts(app).forget_edit(id); } else { // A refusal is on the status line, and the draft stays in the field. let _ = commit_comment(app, id);@@ -161,7 +193,7 @@ pub(crate) fn commit_comment(app: &mut WordApp, id: u32) -> Result<(), String> { { app.run("review.editComment", json!({"id": id, "text": text}))?; }- drafts(app).edits.remove(&id);+ drafts(app).forget_edit(id); Ok(()) } @@ -193,11 +225,11 @@ pub(crate) fn reply_editor(app: &mut WordApp, ui: &mut Ui, id: u32, font: Option r.request_focus(); } if r.changed() {- drafts(app).replies.insert(id, text);+ drafts(app).record_reply(id, text); } if r.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Escape)) { // Escape throws the reply away and closes the field.- drafts(app).replies.remove(&id);+ drafts(app).forget_reply(id); return true; } r.lost_focus() && commit_reply(app, id).is_ok()@@ -211,7 +243,7 @@ pub(crate) fn commit_reply(app: &mut WordApp, id: u32) -> Result<(), String> { let root = thread_root(app, id); app.run("review.reply", json!({"id": root, "text": text.trim_end()}))?; }- drafts(app).replies.remove(&id);+ drafts(app).forget_reply(id); Ok(()) } @@ -238,3 +270,55 @@ pub(crate) fn flush_comment_drafts(app: &mut WordApp) -> Result<(), String> { } first }++#[cfg(test)]+mod native_snapshot_tests {+ use super::*;+ use crate::Services;+ use wordcraft_engine::Session;++ #[test]+ fn native_snapshot_commits_pending_comment_and_keeps_it_unsaved() {+ let mut app = WordApp::new(Session::new(wordcraft_doc::Document::new()), Services::default());+ app.run("review.newComment", json!({"text":"original"})).unwrap();+ let id = *app.session.doc.comments.keys().next().unwrap();+ let part = app.session.doc.comments[&id].part;+ let before = app.session.rev();+ drafts(&mut app).record_edit(id, "pending native comment".into());+ assert_eq!(app.session.doc.plain_text(StoryRef::Part(part)), "original");+ app.prepare_native_snapshot().unwrap();+ assert_eq!(app.session.doc.plain_text(StoryRef::Part(part)), "pending native comment");+ assert!(app.session.dirty, "export is not a storage acknowledgement");+ assert!(app.session.rev() > before);+ assert!(!has_comment_drafts(&app));+ }++ #[test]+ fn every_pending_comment_and_reply_edit_changes_the_native_draft_token() {+ let mut app = WordApp::new(Session::new(wordcraft_doc::Document::new()), Services::default());+ app.session.dirty = true;+ let first = app.native_draft_revision();+ drafts(&mut app).record_edit(0, "first".into());+ let second = app.native_draft_revision();+ drafts(&mut app).record_edit(0, "second".into());+ let third = app.native_draft_revision();+ drafts(&mut app).record_reply(0, "reply draft".into());+ let fourth = app.native_draft_revision();+ assert_ne!(first, second);+ assert_ne!(second, third);+ assert_ne!(third, fourth);+ assert!(app.session.dirty);+ }+ #[test]+ fn refused_pending_comment_stays_local_and_snapshot_preparation_fails() {+ let mut app = WordApp::new(Session::new(wordcraft_doc::Document::new()), Services::default());+ app.run("review.newComment", json!({"text":"original"})).unwrap();+ let id = *app.session.doc.comments.keys().next().unwrap();+ drafts(&mut app).record_edit(id, "pending retained".into());+ app.session.run("review.restrict", &json!({"mode":"readOnly"})).unwrap();+ let before = serde_json::to_value(&app.session.doc).unwrap();+ assert!(app.prepare_native_snapshot().is_err());+ assert_eq!(serde_json::to_value(&app.session.doc).unwrap(), before);+ assert_eq!(comment_draft(&app, id).as_deref(), Some("pending retained"));+ }+}