How Undo/Redo Is Implemented in the Luban H5 Editor State
The Luban H5 editor implements undo/redo through a Vuex plugin that records deep-cloned snapshots of the entire store after specific mutations, manages them in a linear history stack with a cursor index, and restores previous states using store.replaceState when users trigger undo or redo actions.
The Luban H5 visual editor relies on Vuex for centralized state management across its canvas, components, and page hierarchy. To provide a reliable editing experience, the ly525/luban-h5 repository implements a custom undo/redo system as a Vuex store plugin. This architecture intercepts specific mutations, captures immutable snapshots of the entire editor state, and manages a history stack that enables users to navigate through previous editing stages.
Architecture of the Undo/Redo System
The implementation consists of four coordinated components that work within the Vuex ecosystem:
| Component | File Path | Responsibility |
|---|---|---|
| Vuex Store | front-end/h5/src/components/core/store/index.js |
Holds the complete editor state and registers the undo-redo plugin |
| Undo-Redo Plugin | front-end/h5/src/components/core/store/plugins/undo-redo/index.js |
Subscribes to store mutations, filters relevant ones, and triggers snapshot recording |
| History Manager | front-end/h5/src/components/core/store/plugins/undo-redo/History.js |
Maintains an array of deep-cloned state objects and a pointer index |
| Toolbar Actions | front-end/h5/src/components/core/editor/fixed-tools/options.js |
UI hooks that invoke undo() and redo() methods |
Core Implementation Details
Store Initialization and Plugin Registration
The undo/redo functionality activates when the Vuex store initializes. The plugin receives the store instance and stores it inside UndoRedoHistory via undoRedoHistory.init(store):
// front-end/h5/src/components/core/store/index.js
import Vue from 'vue'
import Vuex from 'vuex'
import undoRedoPlugin from './plugins/undo-redo/index'
Vue.use(Vuex)
export default new Vuex.Store({
modules: { /* … */ },
plugins: [undoRedoPlugin] // ← registers the undo-redo system
})
Recording State Snapshots
Only a subset of mutations trigger history recording. The plugin defines recordHistoryMutationTypes and subscribes to every mutation, filtering for specific editor operations:
// front-end/h5/src/components/core/store/plugins/undo-redo/index.js
import undoRedoHistory from './History'
import { cloneDeep } from 'lodash'
const recordHistoryMutationTypes = [
'editor/recordRect',
'editor/elementManager',
'editor/setEditingPage'
]
export default function undoRedoPlugin(store) {
undoRedoHistory.init(store)
store.subscribe((mutation, state) => {
const { type } = mutation
if (!recordHistoryMutationTypes.includes(type)) return
// Capture deep clone after mutation completes
undoRedoHistory.addState(cloneDeep(state))
})
}
The History Stack Manager
The UndoRedoHistory class implements a linear stack with a cursor index. It maintains an array of immutable state objects and provides methods to navigate through them:
// front-end/h5/src/components/core/store/plugins/undo-redo/History.js
class UndoRedoHistory {
history = []
currentIndex = -1
store = null
init(store) {
this.store = store
}
get canUndo() {
return this.currentIndex > 0
}
get canRedo() {
return this.history.length > this.currentIndex + 1
}
addState(state) {
// Discard future states when new action occurs
if (this.currentIndex + 1 < this.history.length) {
this.history.splice(this.currentIndex + 1)
}
this.history.push(state)
this.currentIndex++
}
undo() {
if (!this.canUndo) return
const prevState = this.history[this.currentIndex - 1]
this.store.replaceState(cloneDeep(prevState))
this.currentIndex--
}
redo() {
if (!this.canRedo) return
const nextState = this.history[this.currentIndex + 1]
this.store.replaceState(cloneDeep(nextState))
this.currentIndex++
}
}
export default new UndoRedoHistory()
Restoring State with replaceState
When undo or redo is triggered, the system uses Vuex's store.replaceState() method to atomically swap the entire state tree. This approach ensures that all nested modules, computed properties, and UI components synchronize instantly without requiring manual patch operations or inverse mutation logic.
UI Integration
The toolbar options in the fixed-tools component expose undo and redo actions that invoke the history methods directly:
// front-end/h5/src/components/core/editor/fixed-tools/options.js
import undoRedoHistory from 'core/store/plugins/undo-redo/History'
export default [
{
i18nTooltip: 'editor.fixedTool.undo',
action: () => undoRedoHistory.undo(),
},
{
i18nTooltip: 'editor.fixedTool.redo',
action: () => undoRedoHistory.redo(),
},
{
i18nTooltip: 'editor.fixedTool.copy',
action: () => console.log('copy'),
},
{
i18nTooltip: 'editor.fixedTool.paste',
action: () => console.log('paste'),
}
]
When users press Ctrl+Z or click the undo button, undoRedoHistory.undo() executes, restoring the previous editor state. Ctrl+Shift+Z or the redo button triggers undoRedoHistory.redo().
Practical Code Examples
Manually Recording a Custom Snapshot
If you implement a custom mutation not included in the default filter list, you can manually push a state snapshot to maintain undo support:
import undoRedoHistory from '@/core/store/plugins/undo-redo/History'
import { cloneDeep } from 'lodash'
function performCustomOperation(store) {
// Execute your mutation
store.commit('editor/customChange', payload)
// Manually record for undo support
undoRedoHistory.addState(cloneDeep(store.state))
}
Checking Undo/Redo Availability
Disable toolbar buttons when no history exists by checking the getter properties:
import undoRedoHistory from '@/core/store/plugins/undo-redo/History'
const canUndo = undoRedoHistory.canUndo // true if previous state exists
const canRedo = undoRedoHistory.canRedo // true if redo steps exist
// In your Vue component template
<button :disabled="!canUndo" @click="undo">Undo</button>
<button :disabled="!canRedo" @click="redo">Redo</button>
Resetting History When Loading New Projects
Clear the stack when switching projects to prevent cross-contamination between different designs:
import undoRedoHistory from '@/core/store/plugins/undo-redo/History'
import { cloneDeep } from 'lodash'
function loadNewProject(store, projectData) {
// Clear history array and reset cursor
undoRedoHistory.history = []
undoRedoHistory.currentIndex = -1
// Load new state
store.replaceState(projectData)
// Record initial state as first snapshot
undoRedoHistory.addState(cloneDeep(store.state))
}
Key Files and Source Paths
| File | Role |
|---|---|
front-end/h5/src/components/core/store/index.js |
Vuex store definition and plugin registration |
front-end/h5/src/components/core/store/plugins/undo-redo/index.js |
Mutation subscription and filtering logic |
front-end/h5/src/components/core/store/plugins/undo-redo/History.js |
Core UndoRedoHistory class with stack management |
front-end/h5/src/components/core/editor/fixed-tools/options.js |
Toolbar button definitions linking to history methods |
Summary
- Vuex Plugin Architecture: Undo/redo is implemented as a Vuex plugin (
undoRedoPlugin) that subscribes to store mutations after initialization. - Selective Recording: Only specific mutation types (
editor/recordRect,editor/elementManager,editor/setEditingPage) trigger state snapshots, preventing history pollution from transient updates. - Linear Stack with Cursor: The
UndoRedoHistoryclass maintains an array of deep-cloned states and acurrentIndexpointer, supporting standard undo/redo semantics with automatic pruning of redo branches when new actions occur. - Atomic Restoration: State restoration uses Vuex's
store.replaceState()to swap the entire state tree instantly, ensuring all components synchronize without manual patching. - UI Decoupling: Toolbar buttons invoke
undoRedoHistory.undo()andundoRedoHistory.redo()directly, with availability checks viacanUndoandcanRedogetters.
Frequently Asked Questions
What mutations trigger undo/redo recording in Luban H5?
The system records history only for specific mutation types defined in recordHistoryMutationTypes: editor/recordRect (element resizing/moving), editor/elementManager (element CRUD operations), and editor/setEditingPage (page navigation). This selective approach prevents recording transient state changes like hover effects or selection highlights that would clutter the history stack.
How does the system handle memory management for large state trees?
The implementation uses lodash.cloneDeep to create immutable snapshots of the entire Vuex state before pushing them to the history array. While this provides reliable state isolation, it means each undo step duplicates the full state tree. For large projects with many pages and elements, this could consume significant memory; the current implementation does not implement a history limit, so long editing sessions may require periodic history resets.
Can I programmatically trigger undo/redo from custom components?
Yes, the UndoRedoHistory singleton exports a default instance that you can import into any Vue component or store module. Import undoRedoHistory from @/core/store/plugins/undo-redo/History and call undoRedoHistory.undo() or undoRedoHistory.redo() directly. You can also check undoRedoHistory.canUndo and undoRedoHistory.canRedo to disable UI elements when the respective operations are unavailable.
Why does the system use replaceState instead of committing inverse mutations?
The Luban H5 editor uses store.replaceState() rather than inverse mutations because the editor state is complex and deeply nested, involving pages, elements, styles, and metadata. Calculating inverse mutations for every possible operation (dragging, resizing, deleting, reordering) would require complex logic and risk state inconsistency. By snapshotting the entire state tree and replacing it atomically, the system guarantees perfect state restoration regardless of the operation complexity, at the cost of higher memory usage.
Have a question about this repo?
These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →