undo: header buttons, shortcuts, wiring

- App provides HistoryHandle as context (both build modes; stacks stay
  empty on the server) and passes it to persist::init_hydrated
- persist: init_hydrated starts undo tracking after the localStorage
  load (first captured state = saved state, not mock); clear_all and
  apply_import call undo::before_replace so they are undoable
- header: Отменить/Вернуть buttons with disabled states driven by the
  reactive stack depths; Ctrl+Z / Ctrl+Shift+Z / Ctrl+Y via a window
  keydown listener that skips editable targets (browser owns those)
- SCSS: disabled control buttons dim out
This commit is contained in:
yaroslav k 2026-08-16 12:31:22 +03:00
parent 99ccb0cdc7
commit 1f3184baf6
3 changed files with 26 additions and 4 deletions

View File

@ -32,11 +32,12 @@ pub fn App() -> impl IntoView {
let store = Store::new(Dashboard::mock()); let store = Store::new(Dashboard::mock());
provide_context(store.clone()); provide_context(store.clone());
provide_context(ModalState::new()); provide_context(ModalState::new());
provide_context(crate::state::undo::HistoryHandle::new(store.clone()));
console_error_panic_hook::set_once(); console_error_panic_hook::set_once();
// Client-only: load the saved state after mount, then autosave. // Client-only: load the saved state after mount, then autosave + undo.
#[cfg(feature = "hydrate")] #[cfg(feature = "hydrate")]
crate::state::persist::init_hydrated(&store); crate::state::persist::init_hydrated(&store, &expect_context::<crate::state::undo::HistoryHandle>());
let modal = expect_context::<ModalState>(); let modal = expect_context::<ModalState>();

View File

@ -6,18 +6,24 @@ use wasm_bindgen::JsCast;
use crate::components::modal::ModalState; use crate::components::modal::ModalState;
use crate::prelude::*; use crate::prelude::*;
use crate::state::persist::{ apply_import, clear_all, export_download }; use crate::state::persist::{ apply_import, clear_all, export_download };
use crate::state::undo::HistoryHandle;
/// Campaign header: editable name, campaign image, in-game date and /// Campaign header: editable name, campaign image, in-game date and
/// session steppers, and the persistence controls. /// session steppers, the persistence controls, and undo/redo.
/// ///
/// Save downloads the dashboard as `dashboard.json`, Load imports one via /// Save downloads the dashboard as `dashboard.json`, Load imports one via
/// a hidden file picker (with a confirm), Clear wipes to the blank /// a hidden file picker (with a confirm), Clear wipes to the blank
/// campaign (with a confirm). Changes also autosave to localStorage /// campaign (with a confirm). Changes also autosave to localStorage
/// (see `crate::state::persist`). /// (see `crate::state::persist`) and feed the undo history (see
/// `crate::state::undo`); the undo/redo buttons and the Ctrl+Z /
/// Ctrl+Shift+Z / Ctrl+Y shortcuts revert the last edit bursts.
#[component] #[component]
pub fn Header () -> impl IntoView { pub fn Header () -> impl IntoView {
let state = expect_context::<Context>(); let state = expect_context::<Context>();
let modal = expect_context::<ModalState>(); let modal = expect_context::<ModalState>();
let history = expect_context::<HistoryHandle>();
let undo_depth = history.undo_depth();
let redo_depth = history.redo_depth();
let file_input = NodeRef::<leptos::html::Input>::new(); let file_input = NodeRef::<leptos::html::Input>::new();
let campaign = state.campaign(); let campaign = state.campaign();
let date = state.date(); let date = state.date();
@ -123,6 +129,16 @@ pub fn Header () -> impl IntoView {
<button on:click=on_save>"Сохранить"</button> <button on:click=on_save>"Сохранить"</button>
<button on:click=on_load>"Загрузить"</button> <button on:click=on_load>"Загрузить"</button>
<button on:click=on_clear>"Очистить"</button> <button on:click=on_clear>"Очистить"</button>
<button
on:click=move |_| history.undo()
disabled=move || undo_depth.get() == 0
title="Отменить (Ctrl+Z)"
>"Отменить"</button>
<button
on:click=move |_| history.redo()
disabled=move || redo_depth.get() == 0
title="Вернуть (Ctrl+Shift+Z или Ctrl+Y)"
>"Вернуть"</button>
</div> </div>
</section> </section>
<input type="file" accept=".json,application/json" style="display: none" node_ref=file_input on:change=on_file/> <input type="file" accept=".json,application/json" style="display: none" node_ref=file_input on:change=on_file/>

View File

@ -59,6 +59,11 @@ header {
line-height: 1em; line-height: 1em;
margin: 2px 5px; margin: 2px 5px;
@include mixins.button; @include mixins.button;
&:disabled {
opacity: 0.45;
cursor: default;
}
} }
} }