diff --git a/src/state/mod.rs b/src/state/mod.rs index ab56ae0..fb98fd1 100644 --- a/src/state/mod.rs +++ b/src/state/mod.rs @@ -3,7 +3,8 @@ //! The whole dashboard lives in one `reactive_stores::Store` //! (alias `Context`), created in `App` and shared through Leptos context. //! Persistence (localStorage autosave, JSON export/import) lives in -//! [`persist`]. +//! [`persist`]; undo/redo over the same store lives in [`undo`]. pub (crate) mod persist; +pub (crate) mod undo; diff --git a/src/state/persist.rs b/src/state/persist.rs index 51caad1..8006e55 100644 --- a/src/state/persist.rs +++ b/src/state/persist.rs @@ -77,34 +77,40 @@ pub fn replace_state(store: &Context, dashboard: Dashboard) { /// Parses `text` and applies it: replaces the state and saves it back. /// -/// Returns the error for the UI to show; nothing is applied on error. +/// The current state becomes an undo step first, so the import is +/// undoable. Returns the error for the UI to show; nothing is applied +/// on error. pub fn apply_import(store: &Context, text: &str) -> std::result::Result<(), ImportError> { let dashboard = parse_dashboard(text)?; + crate::state::undo::before_replace(); replace_state(store, dashboard); #[cfg(feature = "hydrate")] save_to_storage(store); Ok(()) } -/// Client-only startup: load the saved state after mount, start autosave. +/// Client-only startup: load the saved state after mount, start autosave +/// and undo tracking. /// /// Called from `App`. The load is deferred out of the hydration render so /// the SSR markup stays authoritative; the swap happens before the browser -/// draws the first frame. +/// draws the first frame. Undo starts after the load (see the callback). #[cfg(feature = "hydrate")] -pub fn init_hydrated(store: &Context) { +pub fn init_hydrated(store: &Context, history: &crate::state::undo::HistoryHandle) { let store = store.clone(); let pending: Rc>> = Rc::new(Cell::new(None)); - // Load the saved state (or keep mock) once hydration has settled. - let store_load = store.clone(); + // Load the saved state (or keep mock) once hydration has settled, + // then start undo tracking: its first captured state must be the + // saved one, not the mock the SSR pass rendered. + let history = history.clone(); let _ = set_timeout_with_handle( move || { - load_from_storage(&store_load); + load_from_storage(&store); + crate::state::undo::start_tracking(&history); }, Duration::ZERO, ); - // Debounced autosave: any store change restarts the timer. let store_save = store.clone(); Effect::new(move |_| { @@ -166,10 +172,12 @@ pub fn load_from_storage(store: &Context) -> bool { /// Wipes the saved state and replaces the dashboard with the blank slate. /// +/// The current state becomes an undo step first, so Clear is undoable. /// The blank dashboard is written back to storage so it survives a reload; /// mock data stays the fallback for the very first visit only. #[cfg(feature = "hydrate")] pub fn clear_all (store: &Context) { + crate::state::undo::before_replace(); replace_state(store, Dashboard::blank()); save_to_storage(store); } diff --git a/src/state/undo.rs b/src/state/undo.rs new file mode 100644 index 0000000..3ccabf7 --- /dev/null +++ b/src/state/undo.rs @@ -0,0 +1,486 @@ +//! Undo/redo for the whole dashboard (ROADMAP Phase 1.5). +//! +//! Snapshot-based: [`History`] keeps whole `Dashboard` snapshots — the +//! state as it was *before* each edit burst. Snapshots are plain clones +//! (the dashboard is small; the 5e.tools item data lives in `statics`, +//! not in the state), and a cap bounds memory. +//! +//! The tracking effect (started in [`start_tracking`], hydrate only) +//! subscribes to the store root trigger — the same one the autosave +//! effect in `persist` uses — and remembers the previous state on every +//! change. When the burst goes quiet for [`SETTLE_WINDOW`], the pre-burst +//! state becomes a real undo step. Undo before the window fires reverts +//! the whole burst straight away. +//! +//! Wholesale replacements (Clear, import — see `persist`) call +//! [`before_replace`] first, so the replacement itself is undoable. +//! Undo/redo restore via `persist::replace_state`, which fires the +//! autosave effect too — an undone state survives a reload. +//! +//! The `ssr` build provides the same [`HistoryHandle`] so the header +//! renders identically, but both stacks stay empty: the buttons render +//! disabled and clicks are no-ops. + +use std::time::Duration; + +use leptos::prelude::*; + +use crate::prelude::*; +use crate::state::persist::replace_state; + +/// Maximum number of undo steps kept per direction. +const HISTORY_CAP: usize = 100; + +/// A burst of edits becomes one undo step this long after the last change. +/// +/// (Only the hydrate tracking effect arms this timer; the `ssr` build +/// keeps the constant for the shared unit tests.) +#[cfg_attr(not(feature = "hydrate"), allow(dead_code))] +const SETTLE_WINDOW: Duration = Duration::from_secs(1); + +/// Pure undo/redo stack pair over whole dashboards. +/// +/// The undo stack holds states *before* an edit burst; `pending` holds the +/// pre-burst state until the burst settles. The settle window is enforced +/// by the tracking effect's timer — the clock lives in the browser, not +/// here (the wasm target has no `Instant`). +pub struct History { + undo: Vec, + redo: Vec, + cap: usize, + /// The pre-burst state, promoted to a real undo step on [`Self::settle`]. + pending: Option, + /// Set right before a wholesale store replacement (undo/redo/Clear/ + /// import); the tracking effect consumes it and starts a fresh burst + /// from the new state. + suppress: bool, + /// Reactive stack depths, for the buttons' disabled states. + undo_depth: RwSignal, + redo_depth: RwSignal, +} + +impl History { + /// Creates an empty history with the given cap. + /// + /// The settle window is not stored here: the tracking effect's + /// timer enforces it. + pub fn new(cap: usize) -> Self { + History { + undo: Vec::new(), + redo: Vec::new(), + cap, + pending: None, + suppress: false, + undo_depth: RwSignal::new(0), + redo_depth: RwSignal::new(0), + } + } + + /// Records that the store changed from `before` to something else. + /// + /// The first change of a burst sets `pending` to `before`; later + /// changes of the same burst keep it, so one undo step covers the + /// whole burst. + /// + /// (Only the hydrate tracking effect calls this; the `ssr` build + /// keeps the method for the shared unit tests.) + #[cfg_attr(not(feature = "hydrate"), allow(dead_code))] + pub fn note_change(&mut self, before: Dashboard) { + if self.pending.is_none() { + self.pending = Some(before); + // Undo works before the burst settles (it consumes the + // pending state), so the button must enable right away. + self.undo_depth.set(self.undo.len() + 1); + } + } + + /// The burst has been quiet for `window`: promote `pending` to a real + /// undo step. No-op when there is no pending burst. + pub fn settle(&mut self) { + if let Some(pending) = self.pending.take() { + self.push_undo(pending); + } + } + + /// Undo one step: reverts the current burst if it has not settled, + /// otherwise pops the most recent settled step. `current` moves to + /// the redo stack. + pub fn undo(&mut self, current: Dashboard) -> Option { + let snapshot = self.pending.take().or_else(|| self.undo.pop())?; + push_capped(&mut self.redo, self.cap, current); + self.undo_depth.set(self.undo.len()); + self.redo_depth.set(self.redo.len()); + Some(snapshot) + } + + /// Redo: pops the redo stack; `current` moves back to the undo stack. + pub fn redo(&mut self, current: Dashboard) -> Option { + let snapshot = self.redo.pop()?; + push_capped(&mut self.undo, self.cap, current); + self.undo_depth.set(self.undo.len()); + self.redo_depth.set(self.redo.len()); + Some(snapshot) + } + + /// Flush: promotes the pending burst, then marks `current` as an undo + /// step. Called before wholesale replacements (Clear, import) so the + /// replacement itself is undoable — first the replacement, then the + /// burst it interrupted. + pub fn flush(&mut self, current: Dashboard) { + self.settle(); + self.push_undo(current); + } + + /// Drops the pending burst. Called after a suppressed replacement: + /// the next edit starts a fresh burst from the new state. + /// + /// (Only the hydrate tracking effect calls this; the `ssr` build + /// keeps the method for the shared unit tests.) + #[cfg_attr(not(feature = "hydrate"), allow(dead_code))] + pub fn reset(&mut self) { + if self.pending.take().is_some() { + self.undo_depth.set(self.undo.len()); + } + } + + /// Flags the next change as a wholesale replacement (no snapshot). + pub fn suppress(&mut self) { + self.suppress = true; + } + + /// Reads and clears the replacement flag (the tracking effect's check). + /// + /// (Only the hydrate tracking effect calls this; the `ssr` build + /// keeps the method for the shared unit tests.) + #[cfg_attr(not(feature = "hydrate"), allow(dead_code))] + pub fn consume_suppress(&mut self) -> bool { + std::mem::take(&mut self.suppress) + } + + /// Reactive depth of the undo stack. + pub fn undo_depth(&self) -> RwSignal { + self.undo_depth + } + + /// Reactive depth of the redo stack. + pub fn redo_depth(&self) -> RwSignal { + self.redo_depth + } + + /// Pushes a pre-edit snapshot onto the undo stack (capped). A new + /// edit invalidates the redo branch. + fn push_undo(&mut self, snapshot: Dashboard) { + push_capped(&mut self.undo, self.cap, snapshot); + self.undo_depth.set(self.undo.len()); + self.redo.clear(); + self.redo_depth.set(0); + } +} + +/// Pushes `value`, dropping the oldest entry when the stack is at `cap`. +fn push_capped(stack: &mut Vec, cap: usize, value: Dashboard) { + if stack.len() == cap { + stack.remove(0); + } + stack.push(value); +} + +/// The shared undo/redo state, plus the store it operates on. +/// +/// All fields are `Copy` handles, so the handle itself is `Copy`: the +/// header buttons and the tracking effect can capture it freely. Created +/// in `App` and injected through context in both build modes (on the +/// server the stacks simply stay empty). +#[derive(Clone, Copy)] +pub struct HistoryHandle { + history: RwSignal, + store: Context, +} + +impl HistoryHandle { + /// Creates an empty history around `store`. + pub fn new(store: Context) -> Self { + HistoryHandle { + history: RwSignal::new(History::new(HISTORY_CAP)), + store, + } + } + + /// Undoes the last edit burst (or the last settled step). + pub fn undo(&self) { + let Some(current) = current_state(&self.store) else { return }; + let mut snapshot = None; + self.history.update(|history| snapshot = history.undo(current)); + self.apply(snapshot); + } + + /// Re-applies the most recently undone state. + pub fn redo(&self) { + let Some(current) = current_state(&self.store) else { return }; + let mut snapshot = None; + self.history.update(|history| snapshot = history.redo(current)); + self.apply(snapshot); + } + + /// Reactive depth of the undo stack, for the button's disabled state. + pub fn undo_depth(&self) -> RwSignal { + self.history.with_untracked(|history| history.undo_depth()) + } + + /// Reactive depth of the redo stack, for the button's disabled state. + pub fn redo_depth(&self) -> RwSignal { + self.history.with_untracked(|history| history.redo_depth()) + } + + /// Applies a popped snapshot: suppresses history capture for the + /// resulting store write, then restores. + fn apply(&self, snapshot: Option) { + if let Some(snapshot) = snapshot { + self.history.update(|history| history.suppress()); + replace_state(&self.store, snapshot); + } + } +} + +/// Marks the current state as an undo step and suppresses history capture +/// for the next store write. +/// +/// Called by `persist` right before a wholesale replacement (Clear, +/// import) so the replacement is undoable. No-op without a handle in +/// context (unit tests, SSR stubs). +pub fn before_replace() { + if let Some(handle) = use_context::() { + flush_for_replace(&handle); + } +} + +/// The core of [`before_replace`]: promotes the pending burst, marks the +/// current state as an undo step, and suppresses capture for the next write. +fn flush_for_replace(handle: &HistoryHandle) { + handle.history.update(|history| { + if let Some(current) = current_state(&handle.store) { + history.flush(current); + } + history.suppress(); + }); +} + +/// Clones the current dashboard out of the store, when readable. +fn current_state(store: &Context) -> Option { + store.try_read_untracked().map(|guard| (*guard).clone()) +} + +/// Client-only: starts the tracking effect and installs the shortcuts. +/// +/// Call after the saved state has been loaded (`persist::init_hydrated` +/// does this), so the first captured state is the saved one, not the mock +/// the SSR pass rendered. +#[cfg(feature = "hydrate")] +pub fn start_tracking(handle: &HistoryHandle) { + use std::{ + cell::{ Cell, RefCell }, + rc::Rc, + }; + + let handle = handle.clone(); + // The state as of the previous store trigger; `None` until the first change. + let last: Rc>> = Rc::new(RefCell::new(None)); + // The settle timer for the current burst, re-armed on every change. + let settle_timer: Rc>> = Rc::new(Cell::new(None)); + + Effect::new(move |_| { + handle.store.track(); + let Some(current) = current_state(&handle.store) else { return }; + + let mut arm = false; + handle.history.update(|history| { + if history.consume_suppress() { + // The store was replaced wholesale (undo/redo/Clear/ + // import): start a fresh burst from the new state. + history.reset(); + last.borrow_mut().replace(current); + } else if let Some(prev) = last.borrow_mut().replace(current.clone()) { + history.note_change(prev); + arm = true; + } + }); + + if arm { + // (Re)arm the settle timer: one undo step per quiet burst. + if let Some(timer) = settle_timer.take() { + timer.clear(); + } + let armed = handle; + let Ok(timer) = set_timeout_with_handle( + move || armed.history.update(|history| history.settle()), + SETTLE_WINDOW, + ) else { + return; + }; + settle_timer.set(Some(timer)); + } + }); + + install_shortcuts(&handle); +} + +/// Installs Ctrl+Z / Ctrl+Shift+Z / Ctrl+Y (and the ⌘ variants) on the +/// window. Key combos inside editable elements are left to the browser's +/// native undo, so text fields keep their own history. +#[cfg(feature = "hydrate")] +fn install_shortcuts(handle: &HistoryHandle) { + use leptos::ev::keydown; + + + let handle = handle.clone(); + window_event_listener(keydown, move |event: leptos::ev::KeyboardEvent| { + if !(event.ctrl_key() || event.meta_key()) { + return; + } + let key = event.key(); + let is_undo = key == "z" || key == "Z"; + let is_redo = key == "y" || key == "Y" || (is_undo && event.shift_key()); + if !(is_undo || is_redo) { + return; + } + if target_is_editable(&event) { + return; + } + event.prevent_default(); + if is_undo { + handle.undo(); + } else { + handle.redo(); + } + }); +} + +/// True when the event target is a text-editing element (input, textarea, +/// select, contenteditable): the browser owns Ctrl+Z there. +#[cfg(feature = "hydrate")] +fn target_is_editable(event: &web_sys::KeyboardEvent) -> bool { + use wasm_bindgen::JsCast; + + let Some(target) = event.target().and_then(|t| t.dyn_into::().ok()) else { + return false; + }; + matches!(target.tag_name().as_str(), "INPUT" | "TEXTAREA" | "SELECT") + || target + .dyn_into::() + .is_ok_and(|element| element.is_content_editable()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn named(name: &str) -> Dashboard { + let mut dashboard = Dashboard::mock(); + dashboard.campaign = name.to_string(); + dashboard + } + + fn campaign(snapshot: Option) -> Option { + snapshot.map(|d| d.campaign) + } + + #[test] + fn one_undo_step_per_settled_burst() { + let mut history = History::new(10); + history.note_change(named("s0")); + history.note_change(named("s1")); // same burst: the first pre-state wins + history.settle(); + assert_eq!(campaign(history.undo(named("s2"))), Some("s0".to_string())); + } + + #[test] + fn undo_before_settle_reverts_the_whole_burst() { + let mut history = History::new(10); + history.note_change(named("s0")); + // No settle: the burst is still in flight, undo consumes it. + assert_eq!(campaign(history.undo(named("s2"))), Some("s0".to_string())); + // The undone state lands on the redo stack. + assert_eq!(history.redo_depth().get(), 1); + assert_eq!(history.undo_depth().get(), 0); + } + + #[test] + fn redo_restores_and_can_be_undone_again() { + let mut history = History::new(10); + history.note_change(named("s0")); + history.undo(named("s2")); + assert_eq!(campaign(history.redo(named("s0"))), Some("s2".to_string())); + assert_eq!(campaign(history.undo(named("s2"))), Some("s0".to_string())); + } + + #[test] + fn new_edit_clears_the_redo_stack() { + let mut history = History::new(10); + history.note_change(named("s0")); + history.undo(named("s2")); + assert_eq!(history.redo_depth().get(), 1); + // A fresh burst after the undo invalidates the redo branch. + history.note_change(named("s2")); + history.settle(); + assert_eq!(history.redo_depth().get(), 0); + assert_eq!(campaign(history.undo(named("s3"))), Some("s2".to_string())); + } + + #[test] + fn cap_drops_the_oldest_snapshot() { + let mut history = History::new(2); + for state in ["s0", "s1", "s2"] { + history.note_change(named(state)); + history.settle(); + } + assert_eq!(history.undo_depth().get(), 2); + assert_eq!(campaign(history.undo(named("s3"))), Some("s2".to_string())); + assert_eq!(campaign(history.undo(named("s2"))), Some("s1".to_string())); + assert_eq!(campaign(history.undo(named("s1"))), None); // "s0" was dropped + } + + #[test] + fn undo_on_empty_history_is_none() { + let mut history = History::new(10); + assert_eq!(campaign(history.undo(named("s0"))), None); + assert_eq!(campaign(history.redo(named("s0"))), None); + } + + #[test] + fn flush_promotes_pending_then_current() { + let mut history = History::new(10); + // A burst in flight: pre-state "s0", current "s2". + history.note_change(named("s0")); + // Wholesale replacement prep: the replacement becomes the first + // undo target, the interrupted burst the second. + history.flush(named("s2")); + assert_eq!(campaign(history.undo(named("s3"))), Some("s2".to_string())); + assert_eq!(campaign(history.undo(named("s2"))), Some("s0".to_string())); + } + + #[test] + fn suppress_flag_is_consumed_once() { + let mut history = History::new(10); + history.suppress(); + assert!(history.consume_suppress()); + assert!(!history.consume_suppress()); + } + + #[test] + fn handle_flush_makes_replacements_undoable() { + let store = Store::new(named("base")); + let handle = HistoryHandle::new(store.clone()); + + flush_for_replace(&handle); + assert_eq!(handle.undo_depth().get(), 1); + replace_state(&store, named("blank")); + + handle.undo(); + let current = store.try_read_untracked().map(|g| (*g).clone()).unwrap(); + assert_eq!(campaign(Some(current)), Some("base".to_string())); + + handle.redo(); + let current = store.try_read_untracked().map(|g| (*g).clone()).unwrap(); + assert_eq!(campaign(Some(current)), Some("blank".to_string())); + } +}