@contember/bindx 0.1.46 → 0.1.47
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/core/ActionDispatcher.d.ts +4 -1
- package/dist/core/ActionDispatcher.d.ts.map +1 -1
- package/dist/core/actions.d.ts +7 -1
- package/dist/core/actions.d.ts.map +1 -1
- package/dist/events/EventEmitter.d.ts +13 -1
- package/dist/events/EventEmitter.d.ts.map +1 -1
- package/dist/handles/BaseHandle.d.ts +14 -3
- package/dist/handles/BaseHandle.d.ts.map +1 -1
- package/dist/handles/EntityHandle.d.ts +14 -5
- package/dist/handles/EntityHandle.d.ts.map +1 -1
- package/dist/handles/FieldHandle.d.ts +11 -4
- package/dist/handles/FieldHandle.d.ts.map +1 -1
- package/dist/handles/HasManyListHandle.d.ts +36 -2
- package/dist/handles/HasManyListHandle.d.ts.map +1 -1
- package/dist/handles/HasOneHandle.d.ts +52 -9
- package/dist/handles/HasOneHandle.d.ts.map +1 -1
- package/dist/handles/PlaceholderHandle.d.ts +25 -13
- package/dist/handles/PlaceholderHandle.d.ts.map +1 -1
- package/dist/handles/index.d.ts +1 -1
- package/dist/handles/index.d.ts.map +1 -1
- package/dist/handles/proxyFactory.d.ts +7 -1
- package/dist/handles/proxyFactory.d.ts.map +1 -1
- package/dist/handles/types.d.ts +33 -0
- package/dist/handles/types.d.ts.map +1 -1
- package/dist/index.d.ts +3 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/persistence/BatchPersister.d.ts +53 -37
- package/dist/persistence/BatchPersister.d.ts.map +1 -1
- package/dist/persistence/ChangeRegistry.d.ts +15 -0
- package/dist/persistence/ChangeRegistry.d.ts.map +1 -1
- package/dist/persistence/MutationCollector.d.ts +106 -3
- package/dist/persistence/MutationCollector.d.ts.map +1 -1
- package/dist/persistence/PersistExecution.d.ts +26 -0
- package/dist/persistence/PersistExecution.d.ts.map +1 -0
- package/dist/persistence/types.d.ts +8 -2
- package/dist/persistence/types.d.ts.map +1 -1
- package/dist/store/EntityMetaStore.d.ts +32 -10
- package/dist/store/EntityMetaStore.d.ts.map +1 -1
- package/dist/store/EntitySnapshotStore.d.ts +50 -3
- package/dist/store/EntitySnapshotStore.d.ts.map +1 -1
- package/dist/store/ErrorStore.d.ts +3 -2
- package/dist/store/ErrorStore.d.ts.map +1 -1
- package/dist/store/HasManyStore.d.ts +214 -0
- package/dist/store/HasManyStore.d.ts.map +1 -0
- package/dist/store/HasOneStore.d.ts +159 -0
- package/dist/store/HasOneStore.d.ts.map +1 -0
- package/dist/store/ReachabilityAnalyzer.d.ts +19 -0
- package/dist/store/ReachabilityAnalyzer.d.ts.map +1 -1
- package/dist/store/RekeyOrchestrator.d.ts +76 -0
- package/dist/store/RekeyOrchestrator.d.ts.map +1 -0
- package/dist/store/RelationEdgeIndex.d.ts +34 -0
- package/dist/store/RelationEdgeIndex.d.ts.map +1 -0
- package/dist/store/RelationStore.d.ts +74 -137
- package/dist/store/RelationStore.d.ts.map +1 -1
- package/dist/store/RootRegistry.d.ts +25 -4
- package/dist/store/RootRegistry.d.ts.map +1 -1
- package/dist/store/SnapshotStore.d.ts +137 -13
- package/dist/store/SnapshotStore.d.ts.map +1 -1
- package/dist/store/SubscriptionManager.d.ts +63 -14
- package/dist/store/SubscriptionManager.d.ts.map +1 -1
- package/dist/store/TouchedStore.d.ts +3 -2
- package/dist/store/TouchedStore.d.ts.map +1 -1
- package/dist/store/relationKey.d.ts +15 -0
- package/dist/store/relationKey.d.ts.map +1 -0
- package/dist/undo/UndoJournal.d.ts +133 -0
- package/dist/undo/UndoJournal.d.ts.map +1 -0
- package/dist/undo/UndoManager.d.ts +25 -82
- package/dist/undo/UndoManager.d.ts.map +1 -1
- package/dist/undo/UnrecordedWriteError.d.ts +16 -0
- package/dist/undo/UnrecordedWriteError.d.ts.map +1 -0
- package/dist/undo/index.d.ts +3 -1
- package/dist/undo/index.d.ts.map +1 -1
- package/dist/undo/rekeyJournalEntry.d.ts +20 -0
- package/dist/undo/rekeyJournalEntry.d.ts.map +1 -0
- package/dist/undo/types.d.ts +0 -48
- package/dist/undo/types.d.ts.map +1 -1
- package/package.json +2 -2
- package/src/core/ActionDispatcher.ts +56 -36
- package/src/core/actions.ts +8 -1
- package/src/events/EventEmitter.ts +78 -24
- package/src/handles/BaseHandle.ts +19 -2
- package/src/handles/EntityHandle.ts +48 -17
- package/src/handles/FieldHandle.ts +36 -10
- package/src/handles/HasManyListHandle.ts +120 -16
- package/src/handles/HasOneHandle.ts +177 -44
- package/src/handles/PlaceholderHandle.ts +184 -36
- package/src/handles/index.ts +3 -0
- package/src/handles/proxyFactory.ts +27 -0
- package/src/handles/types.ts +39 -0
- package/src/index.ts +4 -4
- package/src/persistence/BatchPersister.ts +810 -446
- package/src/persistence/ChangeRegistry.ts +27 -3
- package/src/persistence/MutationCollector.ts +265 -31
- package/src/persistence/PersistExecution.ts +217 -0
- package/src/persistence/types.ts +8 -2
- package/src/store/EntityMetaStore.ts +89 -45
- package/src/store/EntitySnapshotStore.ts +102 -19
- package/src/store/ErrorStore.ts +6 -5
- package/src/store/HasManyStore.ts +810 -0
- package/src/store/HasOneStore.ts +454 -0
- package/src/store/ReachabilityAnalyzer.ts +37 -0
- package/src/store/RekeyOrchestrator.ts +123 -0
- package/src/store/RelationEdgeIndex.ts +79 -0
- package/src/store/RelationStore.ts +176 -725
- package/src/store/RootRegistry.ts +50 -8
- package/src/store/SnapshotStore.ts +572 -101
- package/src/store/SubscriptionManager.ts +122 -75
- package/src/store/TouchedStore.ts +5 -2
- package/src/store/relationKey.ts +20 -0
- package/src/undo/UndoJournal.ts +258 -0
- package/src/undo/UndoManager.ts +151 -281
- package/src/undo/UnrecordedWriteError.ts +23 -0
- package/src/undo/index.ts +8 -7
- package/src/undo/rekeyJournalEntry.ts +145 -0
- package/src/undo/types.ts +0 -58
- package/dist/core/actionClassification.d.ts +0 -48
- package/dist/core/actionClassification.d.ts.map +0 -1
- package/src/core/actionClassification.ts +0 -199
package/src/undo/UndoManager.ts
CHANGED
|
@@ -1,48 +1,41 @@
|
|
|
1
1
|
import type { ActionMiddleware } from '../core/ActionDispatcher.js'
|
|
2
|
-
import type { Action } from '../core/actions.js'
|
|
3
|
-
import {
|
|
4
|
-
isTrackableAction,
|
|
5
|
-
getAffectedKeys,
|
|
6
|
-
mergeAffectedKeys,
|
|
7
|
-
createEmptyAffectedKeys,
|
|
8
|
-
type StoreAffectedKeys,
|
|
9
|
-
} from '../core/actionClassification.js'
|
|
10
2
|
import type { SnapshotStore } from '../store/SnapshotStore.js'
|
|
11
|
-
import type {
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
UndoManagerConfig,
|
|
16
|
-
UndoState,
|
|
17
|
-
} from './types.js'
|
|
3
|
+
import type { RekeyContext } from '../store/RekeyOrchestrator.js'
|
|
4
|
+
import { UndoJournal, cellRefKey, type JournalEntry, type JournalCellImage } from './UndoJournal.js'
|
|
5
|
+
import { rekeyJournalEntry } from './rekeyJournalEntry.js'
|
|
6
|
+
import type { UndoManagerConfig, UndoState } from './types.js'
|
|
18
7
|
|
|
19
8
|
type Subscriber = () => void
|
|
20
9
|
|
|
21
10
|
/**
|
|
22
|
-
* Generates a unique
|
|
11
|
+
* Generates a unique id for manual group handles.
|
|
23
12
|
*/
|
|
24
13
|
function generateId(): string {
|
|
25
14
|
return `undo-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`
|
|
26
15
|
}
|
|
27
16
|
|
|
28
17
|
/**
|
|
29
|
-
* UndoManager
|
|
18
|
+
* UndoManager — the policy layer over the {@link UndoJournal}.
|
|
30
19
|
*
|
|
31
|
-
*
|
|
32
|
-
* -
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
20
|
+
* The journal records each gesture (one dispatch / one handle transaction) as a
|
|
21
|
+
* {@link JournalEntry} of editable-layer pre-images. UndoManager owns the undo /
|
|
22
|
+
* redo stacks and the grouping policy (debounced auto-grouping + manual
|
|
23
|
+
* begin/endGroup), blocks during persist, and keeps the stacks aligned across a
|
|
24
|
+
* temp→persisted rekey. Restore goes straight through the store's write paths, so
|
|
25
|
+
* the edge index and reachability cache rebuild themselves; the event/interceptor
|
|
26
|
+
* pipeline is NOT replayed.
|
|
37
27
|
*/
|
|
38
28
|
export class UndoManager {
|
|
39
|
-
private
|
|
40
|
-
private
|
|
41
|
-
private
|
|
29
|
+
private readonly journal: UndoJournal
|
|
30
|
+
private undoStack: JournalEntry[] = []
|
|
31
|
+
private redoStack: JournalEntry[] = []
|
|
32
|
+
|
|
33
|
+
/** Cells accumulated while a debounce window or manual group is open (first-writer-wins). */
|
|
34
|
+
private pending: Map<string, JournalCellImage> | null = null
|
|
42
35
|
private debounceTimer: ReturnType<typeof setTimeout> | null = null
|
|
43
|
-
private isBlocked = false
|
|
44
36
|
private manualGroupId: string | null = null
|
|
45
|
-
|
|
37
|
+
|
|
38
|
+
private blockDepth = 0
|
|
46
39
|
private subscribers = new Set<Subscriber>()
|
|
47
40
|
private cachedState: UndoState | null = null
|
|
48
41
|
|
|
@@ -55,333 +48,216 @@ export class UndoManager {
|
|
|
55
48
|
) {
|
|
56
49
|
this.maxHistorySize = config.maxHistorySize ?? 100
|
|
57
50
|
this.debounceMs = config.debounceMs ?? 300
|
|
51
|
+
this.journal = new UndoJournal(
|
|
52
|
+
store,
|
|
53
|
+
entry => this.onEntry(entry),
|
|
54
|
+
ctx => this.rekeyStacks(ctx),
|
|
55
|
+
() => this.clear(),
|
|
56
|
+
)
|
|
58
57
|
}
|
|
59
58
|
|
|
60
59
|
/**
|
|
61
|
-
*
|
|
62
|
-
*
|
|
60
|
+
* Wires the journal into the store so its write paths record gestures. The
|
|
61
|
+
* returned middleware is a backward-compatible pass-through (recording is native
|
|
62
|
+
* to the store transaction, not the middleware).
|
|
63
63
|
*/
|
|
64
64
|
createMiddleware(): ActionMiddleware {
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
65
|
+
this.store.setJournal(this.journal)
|
|
66
|
+
const middleware: ActionMiddleware = () => true
|
|
67
|
+
middleware.dispose = () => {
|
|
68
|
+
this.store.clearJournal(this.journal)
|
|
69
69
|
}
|
|
70
|
+
return middleware
|
|
70
71
|
}
|
|
71
72
|
|
|
72
|
-
|
|
73
|
-
* Handles an action, capturing state for undo if trackable.
|
|
74
|
-
*/
|
|
75
|
-
private handleAction(action: Action): void {
|
|
76
|
-
// Skip if blocked or not trackable
|
|
77
|
-
if (this.isBlocked || !isTrackableAction(action)) {
|
|
78
|
-
return
|
|
79
|
-
}
|
|
73
|
+
// ==================== Recording (journal commit sink) ====================
|
|
80
74
|
|
|
81
|
-
|
|
75
|
+
private onEntry(entry: JournalEntry): void {
|
|
76
|
+
if (this.blockDepth > 0 || entry.cells.length === 0) return
|
|
82
77
|
|
|
83
|
-
//
|
|
78
|
+
// A fresh user action invalidates the redo stack.
|
|
84
79
|
if (this.redoStack.length > 0) {
|
|
85
80
|
this.redoStack = []
|
|
86
|
-
this.notifySubscribers()
|
|
87
81
|
}
|
|
88
82
|
|
|
89
83
|
if (this.manualGroupId !== null) {
|
|
90
|
-
|
|
91
|
-
this.handleManualGroupAction(keys)
|
|
92
|
-
} else {
|
|
93
|
-
// Auto-debounce mode
|
|
94
|
-
this.handleDebouncedAction(keys)
|
|
95
|
-
}
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
/**
|
|
99
|
-
* Handles action in manual group mode.
|
|
100
|
-
*/
|
|
101
|
-
private handleManualGroupAction(keys: StoreAffectedKeys): void {
|
|
102
|
-
if (!this.pendingEntry) {
|
|
103
|
-
// First action in group - capture snapshot
|
|
104
|
-
const snapshot = this.store.exportPartialSnapshot(keys)
|
|
105
|
-
this.pendingEntry = {
|
|
106
|
-
beforeSnapshot: snapshot,
|
|
107
|
-
affectedKeys: { ...keys, entityKeys: [...keys.entityKeys], relationKeys: [...keys.relationKeys], hasManyKeys: [...keys.hasManyKeys] },
|
|
108
|
-
timestamp: Date.now(),
|
|
109
|
-
label: this.manualGroupLabel,
|
|
110
|
-
}
|
|
111
|
-
} else {
|
|
112
|
-
// Merge keys and expand snapshot if needed
|
|
113
|
-
const newKeys = this.getNewKeys(this.pendingEntry.affectedKeys, keys)
|
|
114
|
-
if (!this.isEmptyKeys(newKeys)) {
|
|
115
|
-
// Capture additional state for new keys
|
|
116
|
-
const additionalSnapshot = this.store.exportPartialSnapshot(newKeys)
|
|
117
|
-
this.mergeSnapshots(this.pendingEntry.beforeSnapshot, additionalSnapshot)
|
|
118
|
-
mergeAffectedKeys(this.pendingEntry.affectedKeys, keys)
|
|
119
|
-
}
|
|
120
|
-
}
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
/**
|
|
124
|
-
* Handles action in debounced auto-group mode.
|
|
125
|
-
*/
|
|
126
|
-
private handleDebouncedAction(keys: StoreAffectedKeys): void {
|
|
127
|
-
// If debounceMs is 0, immediately create individual entries (no grouping)
|
|
128
|
-
if (this.debounceMs === 0) {
|
|
129
|
-
const snapshot = this.store.exportPartialSnapshot(keys)
|
|
130
|
-
this.undoStack.push({
|
|
131
|
-
id: generateId(),
|
|
132
|
-
beforeSnapshot: snapshot,
|
|
133
|
-
affectedKeys: { ...keys, entityKeys: [...keys.entityKeys], relationKeys: [...keys.relationKeys], hasManyKeys: [...keys.hasManyKeys] },
|
|
134
|
-
timestamp: Date.now(),
|
|
135
|
-
})
|
|
136
|
-
this.trimHistory()
|
|
84
|
+
this.mergeIntoPending(entry)
|
|
137
85
|
this.notifySubscribers()
|
|
138
86
|
return
|
|
139
87
|
}
|
|
140
88
|
|
|
141
|
-
if (this.
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
if (!this.isEmptyKeys(newKeys)) {
|
|
145
|
-
const additionalSnapshot = this.store.exportPartialSnapshot(newKeys)
|
|
146
|
-
this.mergeSnapshots(this.pendingEntry.beforeSnapshot, additionalSnapshot)
|
|
147
|
-
mergeAffectedKeys(this.pendingEntry.affectedKeys, keys)
|
|
148
|
-
}
|
|
149
|
-
this.resetDebounceTimer()
|
|
150
|
-
} else {
|
|
151
|
-
// First action - capture snapshot and start timer
|
|
152
|
-
const snapshot = this.store.exportPartialSnapshot(keys)
|
|
153
|
-
this.pendingEntry = {
|
|
154
|
-
beforeSnapshot: snapshot,
|
|
155
|
-
affectedKeys: { ...keys, entityKeys: [...keys.entityKeys], relationKeys: [...keys.relationKeys], hasManyKeys: [...keys.hasManyKeys] },
|
|
156
|
-
timestamp: Date.now(),
|
|
157
|
-
}
|
|
158
|
-
this.startDebounceTimer()
|
|
159
|
-
this.notifySubscribers()
|
|
89
|
+
if (this.debounceMs === 0) {
|
|
90
|
+
this.pushEntry(entry)
|
|
91
|
+
return
|
|
160
92
|
}
|
|
161
|
-
}
|
|
162
93
|
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
private getNewKeys(target: StoreAffectedKeys, source: StoreAffectedKeys): StoreAffectedKeys {
|
|
167
|
-
return {
|
|
168
|
-
entityKeys: source.entityKeys.filter(k => !target.entityKeys.includes(k)),
|
|
169
|
-
relationKeys: source.relationKeys.filter(k => !target.relationKeys.includes(k)),
|
|
170
|
-
hasManyKeys: source.hasManyKeys.filter(k => !target.hasManyKeys.includes(k)),
|
|
171
|
-
}
|
|
172
|
-
}
|
|
173
|
-
|
|
174
|
-
/**
|
|
175
|
-
* Checks if keys object is empty.
|
|
176
|
-
*/
|
|
177
|
-
private isEmptyKeys(keys: StoreAffectedKeys): boolean {
|
|
178
|
-
return keys.entityKeys.length === 0 && keys.relationKeys.length === 0 && keys.hasManyKeys.length === 0
|
|
94
|
+
this.mergeIntoPending(entry)
|
|
95
|
+
this.resetDebounceTimer()
|
|
96
|
+
this.notifySubscribers()
|
|
179
97
|
}
|
|
180
98
|
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
private mergeSnapshots(target: PartialStoreSnapshot, source: PartialStoreSnapshot): void {
|
|
185
|
-
for (const [key, value] of source.entitySnapshots) {
|
|
186
|
-
if (!target.entitySnapshots.has(key)) {
|
|
187
|
-
target.entitySnapshots.set(key, value)
|
|
188
|
-
}
|
|
99
|
+
private mergeIntoPending(entry: JournalEntry): void {
|
|
100
|
+
if (!this.pending) {
|
|
101
|
+
this.pending = new Map()
|
|
189
102
|
}
|
|
190
|
-
for (const
|
|
191
|
-
|
|
192
|
-
|
|
103
|
+
for (const cell of entry.cells) {
|
|
104
|
+
const key = cellRefKey(cell)
|
|
105
|
+
// First-writer-wins: keep the state from before the group began.
|
|
106
|
+
if (!this.pending.has(key)) {
|
|
107
|
+
this.pending.set(key, cell)
|
|
193
108
|
}
|
|
194
109
|
}
|
|
195
|
-
for (const [key, value] of source.hasManyStates) {
|
|
196
|
-
if (!target.hasManyStates.has(key)) {
|
|
197
|
-
target.hasManyStates.set(key, value)
|
|
198
|
-
}
|
|
199
|
-
}
|
|
200
|
-
for (const [key, value] of source.entityMetas) {
|
|
201
|
-
if (!target.entityMetas.has(key)) {
|
|
202
|
-
target.entityMetas.set(key, value)
|
|
203
|
-
}
|
|
204
|
-
}
|
|
205
|
-
}
|
|
206
|
-
|
|
207
|
-
/**
|
|
208
|
-
* Starts the debounce timer.
|
|
209
|
-
*/
|
|
210
|
-
private startDebounceTimer(): void {
|
|
211
|
-
this.debounceTimer = setTimeout(() => {
|
|
212
|
-
this.flushPendingEntry()
|
|
213
|
-
}, this.debounceMs)
|
|
214
110
|
}
|
|
215
111
|
|
|
216
|
-
|
|
217
|
-
* Resets the debounce timer.
|
|
218
|
-
*/
|
|
219
|
-
private resetDebounceTimer(): void {
|
|
112
|
+
private flushPending(shouldNotify = true): void {
|
|
220
113
|
if (this.debounceTimer) {
|
|
221
114
|
clearTimeout(this.debounceTimer)
|
|
115
|
+
this.debounceTimer = null
|
|
116
|
+
}
|
|
117
|
+
const pending = this.pending
|
|
118
|
+
this.pending = null
|
|
119
|
+
if (pending && pending.size > 0) {
|
|
120
|
+
this.pushEntry({ cells: [...pending.values()] }, shouldNotify)
|
|
222
121
|
}
|
|
223
|
-
this.startDebounceTimer()
|
|
224
122
|
}
|
|
225
123
|
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
124
|
+
private pushEntry(entry: JournalEntry, shouldNotify = true): void {
|
|
125
|
+
this.undoStack.push(entry)
|
|
126
|
+
while (this.undoStack.length > this.maxHistorySize) {
|
|
127
|
+
this.undoStack.shift()
|
|
128
|
+
}
|
|
129
|
+
if (shouldNotify) {
|
|
130
|
+
this.notifySubscribers()
|
|
131
|
+
}
|
|
132
|
+
}
|
|
231
133
|
|
|
134
|
+
private resetDebounceTimer(): void {
|
|
232
135
|
if (this.debounceTimer) {
|
|
233
136
|
clearTimeout(this.debounceTimer)
|
|
234
|
-
this.debounceTimer = null
|
|
235
137
|
}
|
|
236
|
-
|
|
237
|
-
this.undoStack.push({
|
|
238
|
-
id: generateId(),
|
|
239
|
-
label: this.pendingEntry.label,
|
|
240
|
-
beforeSnapshot: this.pendingEntry.beforeSnapshot,
|
|
241
|
-
affectedKeys: this.pendingEntry.affectedKeys,
|
|
242
|
-
timestamp: this.pendingEntry.timestamp,
|
|
243
|
-
})
|
|
244
|
-
|
|
245
|
-
this.pendingEntry = null
|
|
246
|
-
this.trimHistory()
|
|
247
|
-
this.notifySubscribers()
|
|
138
|
+
this.debounceTimer = setTimeout(() => this.flushPending(), this.debounceMs)
|
|
248
139
|
}
|
|
249
140
|
|
|
250
|
-
|
|
251
|
-
* Trims history to max size.
|
|
252
|
-
*/
|
|
253
|
-
private trimHistory(): void {
|
|
254
|
-
while (this.undoStack.length > this.maxHistorySize) {
|
|
255
|
-
this.undoStack.shift()
|
|
256
|
-
}
|
|
257
|
-
}
|
|
141
|
+
// ==================== Grouping ====================
|
|
258
142
|
|
|
259
143
|
/**
|
|
260
|
-
* Starts a manual group
|
|
261
|
-
* Returns
|
|
144
|
+
* Starts a manual group: every gesture until {@link endGroup} folds into one
|
|
145
|
+
* undo entry. Returns a handle that must be passed back to endGroup.
|
|
262
146
|
*/
|
|
263
|
-
beginGroup(
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
this.manualGroupId = groupId
|
|
269
|
-
this.manualGroupLabel = label
|
|
270
|
-
return groupId
|
|
147
|
+
beginGroup(_label?: string): string {
|
|
148
|
+
this.flushPending()
|
|
149
|
+
const id = generateId()
|
|
150
|
+
this.manualGroupId = id
|
|
151
|
+
return id
|
|
271
152
|
}
|
|
272
153
|
|
|
273
|
-
/**
|
|
274
|
-
* Ends a manual group and pushes to undo stack.
|
|
275
|
-
*/
|
|
276
154
|
endGroup(groupId: string): void {
|
|
277
155
|
if (this.manualGroupId !== groupId) {
|
|
278
156
|
console.warn(`UndoManager: endGroup called with wrong groupId. Expected ${this.manualGroupId}, got ${groupId}`)
|
|
279
157
|
return
|
|
280
158
|
}
|
|
281
|
-
|
|
282
159
|
this.manualGroupId = null
|
|
283
|
-
this.
|
|
284
|
-
this.flushPendingEntry()
|
|
160
|
+
this.flushPending()
|
|
285
161
|
}
|
|
286
162
|
|
|
287
|
-
|
|
288
|
-
* Undoes the last entry.
|
|
289
|
-
*/
|
|
290
|
-
undo(): void {
|
|
291
|
-
// Flush any pending entry first
|
|
292
|
-
this.flushPendingEntry()
|
|
163
|
+
// ==================== Undo / Redo ====================
|
|
293
164
|
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
165
|
+
undo(): void {
|
|
166
|
+
this.flushPending()
|
|
167
|
+
if (this.blockDepth > 0 || this.undoStack.length === 0) return
|
|
297
168
|
|
|
298
169
|
const entry = this.undoStack.pop()!
|
|
299
|
-
|
|
300
|
-
//
|
|
301
|
-
const
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
this.redoStack.push({
|
|
305
|
-
id: entry.id,
|
|
306
|
-
label: entry.label,
|
|
307
|
-
beforeSnapshot: currentSnapshot,
|
|
308
|
-
affectedKeys: entry.affectedKeys,
|
|
309
|
-
timestamp: Date.now(),
|
|
310
|
-
})
|
|
311
|
-
|
|
312
|
-
// Restore the before snapshot
|
|
313
|
-
this.store.importPartialSnapshot(entry.beforeSnapshot)
|
|
170
|
+
// Capture the current state of the same cells as the inverse (redo) entry,
|
|
171
|
+
// then restore the pre-images.
|
|
172
|
+
const inverse = this.journal.captureCurrent(entry.cells)
|
|
173
|
+
this.journal.apply(entry.cells)
|
|
174
|
+
this.redoStack.push({ cells: inverse })
|
|
314
175
|
|
|
315
176
|
this.notifySubscribers()
|
|
316
177
|
}
|
|
317
178
|
|
|
318
|
-
/**
|
|
319
|
-
* Redoes the last undone entry.
|
|
320
|
-
*/
|
|
321
179
|
redo(): void {
|
|
322
|
-
if (this.
|
|
323
|
-
return
|
|
324
|
-
}
|
|
180
|
+
if (this.blockDepth > 0 || this.redoStack.length === 0) return
|
|
325
181
|
|
|
326
182
|
const entry = this.redoStack.pop()!
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
// Push back to undo stack
|
|
332
|
-
this.undoStack.push({
|
|
333
|
-
id: entry.id,
|
|
334
|
-
label: entry.label,
|
|
335
|
-
beforeSnapshot: currentSnapshot,
|
|
336
|
-
affectedKeys: entry.affectedKeys,
|
|
337
|
-
timestamp: Date.now(),
|
|
338
|
-
})
|
|
339
|
-
|
|
340
|
-
// Restore the redo snapshot
|
|
341
|
-
this.store.importPartialSnapshot(entry.beforeSnapshot)
|
|
183
|
+
const inverse = this.journal.captureCurrent(entry.cells)
|
|
184
|
+
this.journal.apply(entry.cells)
|
|
185
|
+
this.undoStack.push({ cells: inverse })
|
|
342
186
|
|
|
343
187
|
this.notifySubscribers()
|
|
344
188
|
}
|
|
345
189
|
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
* Use during persist to prevent inconsistent state.
|
|
349
|
-
*/
|
|
190
|
+
// ==================== Blocking (during persist) ====================
|
|
191
|
+
|
|
350
192
|
block(): void {
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
193
|
+
if (this.blockDepth === 0) {
|
|
194
|
+
this.blockDepth++
|
|
195
|
+
this.flushPending(false)
|
|
196
|
+
this.notifySubscribers()
|
|
197
|
+
return
|
|
198
|
+
}
|
|
199
|
+
this.blockDepth++
|
|
355
200
|
}
|
|
356
201
|
|
|
357
|
-
/**
|
|
358
|
-
* Unblocks undo/redo operations.
|
|
359
|
-
*/
|
|
360
202
|
unblock(): void {
|
|
361
|
-
this.
|
|
362
|
-
this.
|
|
203
|
+
if (this.blockDepth === 0) return
|
|
204
|
+
this.blockDepth--
|
|
205
|
+
if (this.blockDepth === 0) {
|
|
206
|
+
this.notifySubscribers()
|
|
207
|
+
}
|
|
363
208
|
}
|
|
364
209
|
|
|
210
|
+
// ==================== Persist rekey ====================
|
|
211
|
+
|
|
365
212
|
/**
|
|
366
|
-
*
|
|
213
|
+
* Rewrites every stacked entry when a temp id is replaced by its persisted id,
|
|
214
|
+
* so stored cells keep valid keys / id references (and sealed creates drop out).
|
|
367
215
|
*/
|
|
216
|
+
private rekeyStacks(ctx: RekeyContext): void {
|
|
217
|
+
const liveServerIds = (key: string): Set<string> => this.store.getLiveHasManyServerIds(key)
|
|
218
|
+
const undoCount = this.undoStack.length
|
|
219
|
+
const redoCount = this.redoStack.length
|
|
220
|
+
const hadPending = this.pending !== null && this.pending.size > 0
|
|
221
|
+
|
|
222
|
+
this.undoStack = this.undoStack
|
|
223
|
+
.map(entry => rekeyJournalEntry(entry, ctx, liveServerIds))
|
|
224
|
+
.filter(entry => entry.cells.length > 0)
|
|
225
|
+
this.redoStack = this.redoStack
|
|
226
|
+
.map(entry => rekeyJournalEntry(entry, ctx, liveServerIds))
|
|
227
|
+
.filter(entry => entry.cells.length > 0)
|
|
228
|
+
if (this.pending) {
|
|
229
|
+
const rekeyed = rekeyJournalEntry({ cells: [...this.pending.values()] }, ctx, liveServerIds)
|
|
230
|
+
if (rekeyed.cells.length > 0) {
|
|
231
|
+
this.pending = new Map(rekeyed.cells.map(cell => [cellRefKey(cell), cell]))
|
|
232
|
+
} else {
|
|
233
|
+
this.pending = null
|
|
234
|
+
if (this.debounceTimer) {
|
|
235
|
+
clearTimeout(this.debounceTimer)
|
|
236
|
+
this.debounceTimer = null
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
const hasPending = this.pending !== null && this.pending.size > 0
|
|
242
|
+
if (this.undoStack.length !== undoCount || this.redoStack.length !== redoCount || hasPending !== hadPending) {
|
|
243
|
+
this.notifySubscribers()
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// ==================== History / State ====================
|
|
248
|
+
|
|
368
249
|
clear(): void {
|
|
369
250
|
if (this.debounceTimer) {
|
|
370
251
|
clearTimeout(this.debounceTimer)
|
|
371
252
|
this.debounceTimer = null
|
|
372
253
|
}
|
|
373
|
-
this.
|
|
254
|
+
this.pending = null
|
|
255
|
+
this.manualGroupId = null
|
|
374
256
|
this.undoStack = []
|
|
375
257
|
this.redoStack = []
|
|
376
|
-
this.manualGroupId = null
|
|
377
|
-
this.manualGroupLabel = undefined
|
|
378
258
|
this.notifySubscribers()
|
|
379
259
|
}
|
|
380
260
|
|
|
381
|
-
/**
|
|
382
|
-
* Subscribes to state changes.
|
|
383
|
-
* Returns unsubscribe function.
|
|
384
|
-
*/
|
|
385
261
|
subscribe(callback: Subscriber): () => void {
|
|
386
262
|
this.subscribers.add(callback)
|
|
387
263
|
return () => {
|
|
@@ -389,26 +265,20 @@ export class UndoManager {
|
|
|
389
265
|
}
|
|
390
266
|
}
|
|
391
267
|
|
|
392
|
-
/**
|
|
393
|
-
* Gets current state for React integration.
|
|
394
|
-
* Returns a cached object to satisfy useSyncExternalStore's referential equality requirement.
|
|
395
|
-
*/
|
|
396
268
|
getState(): UndoState {
|
|
397
269
|
if (!this.cachedState) {
|
|
270
|
+
const hasPending = this.pending !== null && this.pending.size > 0
|
|
398
271
|
this.cachedState = {
|
|
399
|
-
canUndo: this.undoStack.length > 0 ||
|
|
272
|
+
canUndo: this.undoStack.length > 0 || hasPending,
|
|
400
273
|
canRedo: this.redoStack.length > 0,
|
|
401
|
-
isBlocked: this.
|
|
402
|
-
undoCount: this.undoStack.length + (
|
|
274
|
+
isBlocked: this.blockDepth > 0,
|
|
275
|
+
undoCount: this.undoStack.length + (hasPending ? 1 : 0),
|
|
403
276
|
redoCount: this.redoStack.length,
|
|
404
277
|
}
|
|
405
278
|
}
|
|
406
279
|
return this.cachedState
|
|
407
280
|
}
|
|
408
281
|
|
|
409
|
-
/**
|
|
410
|
-
* Notifies all subscribers of state change.
|
|
411
|
-
*/
|
|
412
282
|
private notifySubscribers(): void {
|
|
413
283
|
this.cachedState = null
|
|
414
284
|
for (const sub of this.subscribers) {
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { JournalCellKind } from './UndoJournal.js'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Thrown when a journal transaction closes after an editable-layer store write
|
|
5
|
+
* that was NOT preceded by a matching `journal.record*` call — i.e. a mutating
|
|
6
|
+
* SnapshotStore method wrote primary state a gesture can no longer undo.
|
|
7
|
+
*
|
|
8
|
+
* This turns the "every mutating method must record before writing" convention
|
|
9
|
+
* into a checked invariant: the sub-store editable-write counters advanced for a
|
|
10
|
+
* kind the transaction never recorded. The named kinds pinpoint which record call
|
|
11
|
+
* is missing (recordEntity / recordRelation / recordHasMany).
|
|
12
|
+
*/
|
|
13
|
+
export class UnrecordedWriteError extends Error {
|
|
14
|
+
constructor(public readonly kinds: ReadonlyArray<JournalCellKind>) {
|
|
15
|
+
super(
|
|
16
|
+
`Undo journal invariant violated: editable-layer ${kinds.join(', ')} write(s) happened ` +
|
|
17
|
+
`inside a journal transaction without a preceding record call. Every mutating SnapshotStore ` +
|
|
18
|
+
`method must call journal.recordEntity/recordRelation/recordHasMany before writing so the ` +
|
|
19
|
+
`gesture can be undone. Unrecorded kind(s): ${kinds.join(', ')}.`,
|
|
20
|
+
)
|
|
21
|
+
this.name = 'UnrecordedWriteError'
|
|
22
|
+
}
|
|
23
|
+
}
|
package/src/undo/index.ts
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
export { UndoManager } from './UndoManager.js'
|
|
2
|
+
export { UnrecordedWriteError } from './UnrecordedWriteError.js'
|
|
3
|
+
export type { UndoManagerConfig, UndoState } from './types.js'
|
|
2
4
|
export type {
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
} from './types.js'
|
|
5
|
+
JournalEntry,
|
|
6
|
+
JournalCellImage,
|
|
7
|
+
EntityCellImage,
|
|
8
|
+
RelationCellImage,
|
|
9
|
+
HasManyCellImage,
|
|
10
|
+
} from './UndoJournal.js'
|