undo: snapshot history engine (state::undo)
- History: capped undo/redo stacks of whole Dashboard clones; a pending pre-burst snapshot becomes a real undo step when the tracking effect's settle timer fires (1 s of quiet) — one undo step per edit burst - undo/redo consume the pending burst first, so a fresh edit is undoable before it settles; a new edit clears the redo branch - suppress flag: wholesale replacements (Clear, import) flush the pending burst, push the current state, and mark their own write as non-capturable — Clear and import are undoable themselves - HistoryHandle is Copy (RwSignal fields): context-friendly (Send+Sync) and safe to capture in view closures under edition-2024 rules - no std::time::Instant: it panics on wasm32-unknown-unknown; the browser timer is the only clock - 9 unit tests: burst coalescing, redo, cap, flush order, suppress
This commit is contained in:
parent
42b106c793
commit
99ccb0cdc7
@ -3,7 +3,8 @@
|
|||||||
//! The whole dashboard lives in one `reactive_stores::Store<Dashboard>`
|
//! The whole dashboard lives in one `reactive_stores::Store<Dashboard>`
|
||||||
//! (alias `Context`), created in `App` and shared through Leptos context.
|
//! (alias `Context`), created in `App` and shared through Leptos context.
|
||||||
//! Persistence (localStorage autosave, JSON export/import) lives in
|
//! 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 persist;
|
||||||
|
pub (crate) mod undo;
|
||||||
|
|
||||||
|
|||||||
@ -77,34 +77,40 @@ pub fn replace_state(store: &Context, dashboard: Dashboard) {
|
|||||||
|
|
||||||
/// Parses `text` and applies it: replaces the state and saves it back.
|
/// 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> {
|
pub fn apply_import(store: &Context, text: &str) -> std::result::Result<(), ImportError> {
|
||||||
let dashboard = parse_dashboard(text)?;
|
let dashboard = parse_dashboard(text)?;
|
||||||
|
crate::state::undo::before_replace();
|
||||||
replace_state(store, dashboard);
|
replace_state(store, dashboard);
|
||||||
#[cfg(feature = "hydrate")]
|
#[cfg(feature = "hydrate")]
|
||||||
save_to_storage(store);
|
save_to_storage(store);
|
||||||
Ok(())
|
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
|
/// Called from `App`. The load is deferred out of the hydration render so
|
||||||
/// the SSR markup stays authoritative; the swap happens before the browser
|
/// 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")]
|
#[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 store = store.clone();
|
||||||
let pending: Rc<Cell<Option<TimeoutHandle>>> = Rc::new(Cell::new(None));
|
let pending: Rc<Cell<Option<TimeoutHandle>>> = Rc::new(Cell::new(None));
|
||||||
|
|
||||||
// Load the saved state (or keep mock) once hydration has settled.
|
// Load the saved state (or keep mock) once hydration has settled,
|
||||||
let store_load = store.clone();
|
// 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(
|
let _ = set_timeout_with_handle(
|
||||||
move || {
|
move || {
|
||||||
load_from_storage(&store_load);
|
load_from_storage(&store);
|
||||||
|
crate::state::undo::start_tracking(&history);
|
||||||
},
|
},
|
||||||
Duration::ZERO,
|
Duration::ZERO,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Debounced autosave: any store change restarts the timer.
|
// Debounced autosave: any store change restarts the timer.
|
||||||
let store_save = store.clone();
|
let store_save = store.clone();
|
||||||
Effect::new(move |_| {
|
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.
|
/// 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;
|
/// The blank dashboard is written back to storage so it survives a reload;
|
||||||
/// mock data stays the fallback for the very first visit only.
|
/// mock data stays the fallback for the very first visit only.
|
||||||
#[cfg(feature = "hydrate")]
|
#[cfg(feature = "hydrate")]
|
||||||
pub fn clear_all (store: &Context) {
|
pub fn clear_all (store: &Context) {
|
||||||
|
crate::state::undo::before_replace();
|
||||||
replace_state(store, Dashboard::blank());
|
replace_state(store, Dashboard::blank());
|
||||||
save_to_storage(store);
|
save_to_storage(store);
|
||||||
}
|
}
|
||||||
|
|||||||
486
src/state/undo.rs
Normal file
486
src/state/undo.rs
Normal file
@ -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<Dashboard>,
|
||||||
|
redo: Vec<Dashboard>,
|
||||||
|
cap: usize,
|
||||||
|
/// The pre-burst state, promoted to a real undo step on [`Self::settle`].
|
||||||
|
pending: Option<Dashboard>,
|
||||||
|
/// 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<usize>,
|
||||||
|
redo_depth: RwSignal<usize>,
|
||||||
|
}
|
||||||
|
|
||||||
|
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<Dashboard> {
|
||||||
|
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<Dashboard> {
|
||||||
|
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<usize> {
|
||||||
|
self.undo_depth
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reactive depth of the redo stack.
|
||||||
|
pub fn redo_depth(&self) -> RwSignal<usize> {
|
||||||
|
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<Dashboard>, 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<History>,
|
||||||
|
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<usize> {
|
||||||
|
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<usize> {
|
||||||
|
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<Dashboard>) {
|
||||||
|
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::<HistoryHandle>() {
|
||||||
|
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<Dashboard> {
|
||||||
|
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<RefCell<Option<Dashboard>>> = Rc::new(RefCell::new(None));
|
||||||
|
// The settle timer for the current burst, re-armed on every change.
|
||||||
|
let settle_timer: Rc<Cell<Option<TimeoutHandle>>> = 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::<web_sys::Element>().ok()) else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
matches!(target.tag_name().as_str(), "INPUT" | "TEXTAREA" | "SELECT")
|
||||||
|
|| target
|
||||||
|
.dyn_into::<web_sys::HtmlElement>()
|
||||||
|
.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<Dashboard>) -> Option<String> {
|
||||||
|
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()));
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user