hyper-undo 0.6.3 → 0.7.0
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/CHANGELOG.md +36 -1
- package/README.md +75 -9
- package/dist/hyper-undo.min.js +1 -1
- package/package.json +1 -1
- package/src/extension-noise.js +11 -4
- package/src/index.js +12 -2
- package/src/keys.js +37 -0
- package/src/lib/content-dom.js +58 -28
- package/src/lib/region-capabilities.js +1 -0
- package/src/primitives.js +58 -2
- package/src/replay-plan.js +564 -0
- package/src/scope.js +441 -43
package/src/scope.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { recordsToPrimitives
|
|
1
|
+
import { recordsToPrimitives } from './primitives.js'
|
|
2
|
+
import { compileCertificate, preflight, applyOperations, described } from './replay-plan.js'
|
|
2
3
|
import { shouldIgnore } from './filter.js'
|
|
3
4
|
import { createEmitter } from './emitter.js'
|
|
4
5
|
|
|
@@ -6,7 +7,10 @@ const DEFAULTS = {
|
|
|
6
7
|
maxHistory: 100,
|
|
7
8
|
idleWindowMs: 500,
|
|
8
9
|
idleLabel: 'Edit',
|
|
10
|
+
recording: 'automatic',
|
|
9
11
|
ignoreAttribute: null,
|
|
12
|
+
isProtected: null,
|
|
13
|
+
ignoreNode: null,
|
|
10
14
|
debug: false,
|
|
11
15
|
}
|
|
12
16
|
|
|
@@ -15,13 +19,19 @@ export function createScope(opts) {
|
|
|
15
19
|
if (!config.scope) throw new Error('hyper-undo: scope is required')
|
|
16
20
|
|
|
17
21
|
const emitter = createEmitter()
|
|
18
|
-
const undoStack = [] // [{ label, timestamp, primitives }]
|
|
22
|
+
const undoStack = [] // [{ id, label, timestamp, primitives, certificate }]
|
|
19
23
|
const redoStack = [] // same shape
|
|
24
|
+
const entryPrefix = `u${Math.random().toString(36).slice(2, 8)}`
|
|
25
|
+
let entryCount = 0
|
|
26
|
+
let refusedTop = null // { entryId, direction, code, targets } for the top entry
|
|
20
27
|
let pendingPrimitives = [] // primitives collected since last commit
|
|
21
28
|
let idleTimer = null
|
|
22
29
|
let observer = null
|
|
23
30
|
let pauseDepth = 0 // reference-counted; recording is active only at 0
|
|
24
31
|
let started = false
|
|
32
|
+
let recordingMode = config.recording === 'commands' ? 'commands' : 'automatic'
|
|
33
|
+
let capture = null // { records, primitives } while an explicit command runs
|
|
34
|
+
let replaying = false // true only inside an undo/redo preflight and apply
|
|
25
35
|
|
|
26
36
|
function log(...args) { if (config.debug) console.log('[hyper-undo]', ...args) }
|
|
27
37
|
|
|
@@ -68,6 +78,9 @@ export function createScope(opts) {
|
|
|
68
78
|
undoStack.length = 0
|
|
69
79
|
redoStack.length = 0
|
|
70
80
|
pendingPrimitives = []
|
|
81
|
+
refusedTop = null
|
|
82
|
+
capture = null
|
|
83
|
+
replaying = false
|
|
71
84
|
pauseDepth = 0 // reset so a reused (created) scope doesn't restart paused
|
|
72
85
|
log('stopped')
|
|
73
86
|
}
|
|
@@ -82,23 +95,66 @@ export function createScope(opts) {
|
|
|
82
95
|
|
|
83
96
|
// --- Observer callback ---
|
|
84
97
|
|
|
98
|
+
// The record source's callback. It hands over its own instance as a second
|
|
99
|
+
// argument, so the bracket flag is passed through collectRecords instead.
|
|
85
100
|
function handleRecords(records) {
|
|
101
|
+
collectRecords(records, false)
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// `bracketed` marks records a commit's or a command's own callback wrote.
|
|
105
|
+
function collectRecords(records, bracketed) {
|
|
86
106
|
// While paused, these callback-delivered records are DROPPED — they have
|
|
87
107
|
// already left the observer's buffer, so they're gone. resume()'s
|
|
88
108
|
// takeRecords() separately purges only the remainder that was never
|
|
89
109
|
// delivered. Those two halves together are the pause contract, and the
|
|
90
110
|
// shimmed (paired) path reproduces both.
|
|
111
|
+
if (capture) {
|
|
112
|
+
for (const record of records) capture.records.push(record)
|
|
113
|
+
return
|
|
114
|
+
}
|
|
115
|
+
// Commands mode: an unbracketed record is some other writer's (a merge, a
|
|
116
|
+
// sap paint, a sortable gesture). It changes the document, so later guards
|
|
117
|
+
// see it, but it is not a local action and earns no entry.
|
|
118
|
+
if (recordingMode === 'commands') return
|
|
91
119
|
if (pauseDepth > 0) return
|
|
92
|
-
const prims = convertRecords(records)
|
|
120
|
+
const prims = convertRecords(dropIgnoredRecords(records), bracketed)
|
|
93
121
|
if (prims.length > 0) {
|
|
94
122
|
for (const p of prims) pendingPrimitives.push(p)
|
|
95
123
|
restartIdleTimer()
|
|
96
124
|
}
|
|
97
125
|
}
|
|
98
126
|
|
|
127
|
+
// A gesture can move its own node many times while a command is still open
|
|
128
|
+
// (dragover, touchmove). In automatic mode each of those childList records
|
|
129
|
+
// would join the idle batch and leave a generic entry beside the one semantic
|
|
130
|
+
// move the consumer records with command(). Drop a childList record whose
|
|
131
|
+
// every added and removed node the caller ignores, so it also never restarts
|
|
132
|
+
// the idle window; a record that also touches any other node is kept whole.
|
|
133
|
+
// Command brackets bypass this: they record what they bracket.
|
|
134
|
+
function dropIgnoredRecords(records) {
|
|
135
|
+
if (typeof config.ignoreNode !== 'function') return records
|
|
136
|
+
return records.filter((record) => {
|
|
137
|
+
if (record.type !== 'childList') return true
|
|
138
|
+
const touched = [...record.addedNodes, ...record.removedNodes]
|
|
139
|
+
if (touched.length === 0) return true
|
|
140
|
+
return !touched.every((node) => ignoresNode(node))
|
|
141
|
+
})
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function ignoresNode(node) {
|
|
145
|
+
try { return !!config.ignoreNode(node) } catch (_) { return false }
|
|
146
|
+
}
|
|
147
|
+
|
|
99
148
|
// Filter ignored records, then coalesce same-attribute repeats within the batch.
|
|
100
|
-
|
|
101
|
-
|
|
149
|
+
// A shared record source observes the whole document, so an idle record whose
|
|
150
|
+
// target sits outside this scope (a head write such as document.title's text) is
|
|
151
|
+
// some other region's change and never part of an entry here; a title an entry
|
|
152
|
+
// owns is recorded explicitly with recordValue. A bracketed record — one a
|
|
153
|
+
// commit's or a command's own callback wrote — is the caller's declaration that
|
|
154
|
+
// what it wrote there is one entry, so it may own the scope document's head
|
|
155
|
+
// fields too; head's <title> stays with the bracket's own title capture.
|
|
156
|
+
function convertRecords(records, bracketed = false) {
|
|
157
|
+
const kept = records.filter((r) => (observedHere(r.target) || (bracketed && inHead(r.target))) && !shouldIgnore(r.target, config.ignoreAttribute, r))
|
|
102
158
|
return recordsToPrimitives(kept, (node) => shouldIgnore(node, config.ignoreAttribute))
|
|
103
159
|
}
|
|
104
160
|
|
|
@@ -122,28 +178,63 @@ export function createScope(opts) {
|
|
|
122
178
|
// --- Commit machinery ---
|
|
123
179
|
|
|
124
180
|
function pushCommit(label, primitives) {
|
|
125
|
-
|
|
181
|
+
const certificate = compileCertificate(primitives, { isIgnored: isIgnoredNode, ignoreAttribute: isIgnoredAttribute })
|
|
182
|
+
if (certificate.steps.length === 0) {
|
|
183
|
+
log('commit', label, 'with nothing to record')
|
|
184
|
+
emitter.emit('commit')
|
|
185
|
+
return null
|
|
186
|
+
}
|
|
187
|
+
const entry = { id: `${entryPrefix}-${++entryCount}`, label, timestamp: Date.now(), primitives, certificate }
|
|
188
|
+
undoStack.push(entry)
|
|
126
189
|
redoStack.length = 0
|
|
190
|
+
refusedTop = null
|
|
127
191
|
while (undoStack.length > config.maxHistory) undoStack.shift()
|
|
128
192
|
log('commit', label, 'primitives:', primitives.length)
|
|
129
193
|
emitter.emit('commit')
|
|
194
|
+
return entry
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function isIgnoredNode(node) {
|
|
198
|
+
return shouldIgnore(node, config.ignoreAttribute)
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// The same predicate the recorder uses, asked about an attribute the
|
|
202
|
+
// certificate is projecting rather than about a live record.
|
|
203
|
+
// `host` is the parent a removed subtree belongs under (see durableProjection).
|
|
204
|
+
function isIgnoredAttribute(name, node, host = null) {
|
|
205
|
+
if (typeof config.ignoreAttribute !== 'function') return false
|
|
206
|
+
try { return !!config.ignoreAttribute(name, node, host) } catch (_) { return false }
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function isProtectedNode(node) {
|
|
210
|
+
if (typeof config.isProtected !== 'function') return false
|
|
211
|
+
try { return !!config.isProtected(node) } catch (_) { return false }
|
|
130
212
|
}
|
|
131
213
|
|
|
132
214
|
// Explicit commit: caller-driven, synchronous fn.
|
|
133
215
|
function commit(label, fn) {
|
|
134
216
|
if (!started) throw new Error('hyper-undo: scope not started')
|
|
217
|
+
// Only automatic IDLE collection is off in commands mode: commit stays an
|
|
218
|
+
// explicit boundary, and an outer exclusion pause still excludes it.
|
|
219
|
+
if (recordingMode === 'commands') return runCapture(label, fn, { honorPause: true, receipt: false })
|
|
135
220
|
// Drain anything already buffered from this same synchronous tick (e.g.
|
|
136
221
|
// typing whose observer callback hasn't fired yet) into pending, THEN close
|
|
137
222
|
// it as its own batch — so the explicit commit starts clean and prior work
|
|
138
223
|
// isn't swallowed under this label.
|
|
139
224
|
handleRecords(observer.takeRecords())
|
|
140
225
|
flush()
|
|
226
|
+
// A commit brackets fn the way a command does, so the document title it
|
|
227
|
+
// changes is one value step of this entry (and head's <title> text is left to
|
|
228
|
+
// that capture, never a second record beside it).
|
|
229
|
+
const titleBefore = pauseDepth === 0 ? titleValue() : null
|
|
141
230
|
const result = fn()
|
|
142
231
|
if (result && typeof result.then === 'function') {
|
|
143
232
|
throw new Error('hyper-undo: commit() fn must be synchronous; returned a Promise')
|
|
144
233
|
}
|
|
145
|
-
// Drain records the observer hasn't fired callback for yet.
|
|
146
|
-
|
|
234
|
+
// Drain records the observer hasn't fired callback for yet. They are this
|
|
235
|
+
// commit's own bracket, so what fn wrote in the document's head joins it.
|
|
236
|
+
collectRecords(observer.takeRecords(), true)
|
|
237
|
+
if (titleBefore != null) recordTitleChange(titleBefore)
|
|
147
238
|
if (pendingPrimitives.length === 0) {
|
|
148
239
|
emitter.emit('commit')
|
|
149
240
|
return
|
|
@@ -168,7 +259,7 @@ export function createScope(opts) {
|
|
|
168
259
|
// push lands AFTER it in history. Without this, type-then-immediately-add
|
|
169
260
|
// would record the structural op before the typing, inverting undo order.
|
|
170
261
|
flush()
|
|
171
|
-
const primitives = convertRecords(observer.takeRecords())
|
|
262
|
+
const primitives = convertRecords(observer.takeRecords(), true)
|
|
172
263
|
if (primitives.length === 0) return
|
|
173
264
|
clearIdleTimer()
|
|
174
265
|
pushCommit(label, primitives)
|
|
@@ -178,16 +269,161 @@ export function createScope(opts) {
|
|
|
178
269
|
if (observer) observer.takeRecords()
|
|
179
270
|
}
|
|
180
271
|
|
|
272
|
+
// --- Explicit commands ---
|
|
273
|
+
|
|
274
|
+
// document.title lives in <head>, outside the body the observer watches, so a
|
|
275
|
+
// command records it from its own bracket and addresses head's <title> element
|
|
276
|
+
// directly. The document is the SCOPE's, never the module-global one: a scope
|
|
277
|
+
// bound to another realm must not read or write this page's title.
|
|
278
|
+
function scopeDocument() {
|
|
279
|
+
const root = config.scope
|
|
280
|
+
return (root && root.ownerDocument) || (typeof document !== 'undefined' ? document : null)
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
function titleNode() {
|
|
284
|
+
const doc = scopeDocument()
|
|
285
|
+
if (!doc || !doc.head || typeof doc.head.querySelector !== 'function') return null
|
|
286
|
+
return doc.head.querySelector('title')
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
function titleValue() {
|
|
290
|
+
const node = titleNode()
|
|
291
|
+
return node ? node.textContent : null
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
function recordTitleChange(before) {
|
|
295
|
+
const node = titleNode()
|
|
296
|
+
if (!node || before == null) return
|
|
297
|
+
const after = node.textContent
|
|
298
|
+
if (after === before) return
|
|
299
|
+
capturePrimitive({ kind: 'value', target: node, prop: 'textContent', oldValue: before, newValue: after })
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
// A caller records document.title explicitly (recordValue(document, { prop: 'title' })).
|
|
303
|
+
// Redirect that write onto the live <title> element so it shares one target and
|
|
304
|
+
// one certificate entry with the command's own title capture.
|
|
305
|
+
function normalizeRecordedPrimitive(primitive) {
|
|
306
|
+
if (!primitive) return null
|
|
307
|
+
const doc = scopeDocument()
|
|
308
|
+
if (!doc || primitive.target !== doc || primitive.prop !== 'title') return primitive
|
|
309
|
+
const node = titleNode()
|
|
310
|
+
if (!node) return null
|
|
311
|
+
return { kind: 'value', target: node, prop: 'textContent', oldValue: primitive.oldValue, newValue: primitive.newValue }
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
function inEffectContext() {
|
|
315
|
+
if (typeof window === 'undefined') return false
|
|
316
|
+
const effect = window.clay?.effect || window.hyperclay?.effect
|
|
317
|
+
return !!(effect && effect.running)
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
function openCapture() {
|
|
321
|
+
// Everything that happened before the command goes through its ordinary
|
|
322
|
+
// classification first: automatic work becomes its own idle entry, and in
|
|
323
|
+
// commands mode an unbracketed record is dropped as usual. Then preceding
|
|
324
|
+
// local work is closed, so the command starts from a fresh buffer.
|
|
325
|
+
if (observer) handleRecords(observer.takeRecords())
|
|
326
|
+
flush()
|
|
327
|
+
capture = { records: [], primitives: [] }
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
function closeCapture() {
|
|
331
|
+
const held = capture
|
|
332
|
+
capture = null
|
|
333
|
+
if (!held) return []
|
|
334
|
+
const primitives = convertRecords(held.records, true)
|
|
335
|
+
for (const p of held.primitives) primitives.push(p)
|
|
336
|
+
return primitives
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
// One synchronous bracket around a caller's authored writes. Deliberately
|
|
340
|
+
// records only what happens inside the callback, so a local command during a
|
|
341
|
+
// long sync pause is captured while the surrounding merge writes are not. The
|
|
342
|
+
// outer pause depth is never touched: no part of the hub is resumed.
|
|
343
|
+
function runCapture(label, fn, { honorPause, receipt }) {
|
|
344
|
+
if (typeof fn !== 'function') throw new Error('hyper-undo: command() needs a synchronous function')
|
|
345
|
+
if (capture) throw new Error('hyper-undo: command() cannot nest inside another command')
|
|
346
|
+
if (replaying) throw new Error('hyper-undo: command() cannot run during undo or redo')
|
|
347
|
+
if (inEffectContext()) throw new Error('hyper-undo: command() cannot run inside a derived-effect context')
|
|
348
|
+
if (fn.constructor && fn.constructor.name === 'AsyncFunction') {
|
|
349
|
+
throw new Error('hyper-undo: command() fn must be synchronous; it was declared async')
|
|
350
|
+
}
|
|
351
|
+
const discarded = honorPause && pauseDepth > 0
|
|
352
|
+
const titleBefore = discarded ? null : titleValue()
|
|
353
|
+
openCapture()
|
|
354
|
+
let primitives = []
|
|
355
|
+
let failure = null
|
|
356
|
+
let result
|
|
357
|
+
try {
|
|
358
|
+
try {
|
|
359
|
+
result = fn()
|
|
360
|
+
} catch (error) {
|
|
361
|
+
failure = error
|
|
362
|
+
}
|
|
363
|
+
if (observer) handleRecords(observer.takeRecords())
|
|
364
|
+
if (result && typeof result.then === 'function') {
|
|
365
|
+
// Never extend the capture across the await; just keep the rejection
|
|
366
|
+
// from surfacing as an unhandled one.
|
|
367
|
+
try { result.then(undefined, () => {}) } catch (_) {}
|
|
368
|
+
if (!failure) failure = new Error('hyper-undo: command() fn must be synchronous; returned a Promise')
|
|
369
|
+
}
|
|
370
|
+
if (!discarded) recordTitleChange(titleBefore)
|
|
371
|
+
} finally {
|
|
372
|
+
primitives = closeCapture()
|
|
373
|
+
}
|
|
374
|
+
// A callback that wrote and then failed keeps its observed writes as an
|
|
375
|
+
// interrupted entry: the DOM already changed and dropping it would leave
|
|
376
|
+
// unexplained content with no way back.
|
|
377
|
+
const entry = discarded || primitives.length === 0 ? null : pushCommit(label, primitives)
|
|
378
|
+
if (failure) throw failure
|
|
379
|
+
if (!receipt) return entry
|
|
380
|
+
return entry ? { id: entry.id, label: entry.label } : null
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
// Explicit local command: one label, one entry, only its own synchronous
|
|
384
|
+
// writes. Returns null for a no-op and { id, label } otherwise.
|
|
385
|
+
function command(label, fn) {
|
|
386
|
+
if (!started) throw new Error('hyper-undo: scope not started')
|
|
387
|
+
return runCapture(label, fn, { honorPause: false, receipt: true })
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
// Automatic (default) keeps idle batches for every consumer that records by
|
|
391
|
+
// watching the document; commands mode records only explicit commands.
|
|
392
|
+
function setRecording(mode) {
|
|
393
|
+
if (mode !== 'automatic' && mode !== 'commands') {
|
|
394
|
+
throw new Error("hyper-undo: setRecording() takes 'automatic' or 'commands'")
|
|
395
|
+
}
|
|
396
|
+
if (capture) throw new Error('hyper-undo: setRecording() cannot change while a command is running')
|
|
397
|
+
if (recordingMode === mode) return recordingMode
|
|
398
|
+
// Work already recorded keeps its own entry: the switch is a commit
|
|
399
|
+
// boundary, never a silent merge into the next command.
|
|
400
|
+
flush()
|
|
401
|
+
recordingMode = mode
|
|
402
|
+
log('recording', mode)
|
|
403
|
+
return recordingMode
|
|
404
|
+
}
|
|
405
|
+
|
|
181
406
|
// --- Manual recording (for changes the observer can't see) ---
|
|
182
407
|
|
|
183
408
|
// Record a primitive the MutationObserver never delivers — an element PROPERTY
|
|
184
409
|
// write (input.value, checkbox.checked) fires no MutationRecord. The primitive
|
|
185
410
|
// joins the same idle batch as observed edits, so rapid same-field property
|
|
186
|
-
// writes coalesce into one commit exactly like text edits do.
|
|
187
|
-
//
|
|
188
|
-
//
|
|
411
|
+
// writes coalesce into one commit exactly like text edits do. Inside a command
|
|
412
|
+
// the write joins that command's entry; outside one it is a no-op while
|
|
413
|
+
// stopped, paused or in commands mode.
|
|
189
414
|
function record(primitive) {
|
|
190
|
-
if (!started ||
|
|
415
|
+
if (!started || !primitive) return
|
|
416
|
+
// Inside a command the capture owns the write, even under an outer pause.
|
|
417
|
+
// Outside one, commands mode records nothing and an exclusion pause still
|
|
418
|
+
// excludes.
|
|
419
|
+
if (!capture && (pauseDepth > 0 || recordingMode === 'commands')) return
|
|
420
|
+
const normalized = normalizeRecordedPrimitive(primitive)
|
|
421
|
+
if (!normalized) return
|
|
422
|
+
capturePrimitive(normalized)
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
function capturePrimitive(primitive) {
|
|
426
|
+
if (capture) { capture.primitives.push(primitive); return }
|
|
191
427
|
pendingPrimitives.push(primitive)
|
|
192
428
|
restartIdleTimer()
|
|
193
429
|
}
|
|
@@ -234,39 +470,183 @@ export function createScope(opts) {
|
|
|
234
470
|
|
|
235
471
|
// --- Undo/redo ---
|
|
236
472
|
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
if (
|
|
473
|
+
// One entry is applied whole or not at all. Preflight compares the certificate
|
|
474
|
+
// against the live document without writing; only a fully validated plan
|
|
475
|
+
// reaches the DOM, and a native failure rolls it back and keeps both stacks.
|
|
476
|
+
function replay(direction, requestedId) {
|
|
477
|
+
if (scopeDisconnected()) { stop(); return { ok: false, code: 'disconnected' } }
|
|
478
|
+
flush()
|
|
479
|
+
const source = direction === 'undo' ? undoStack : redoStack
|
|
480
|
+
const destination = direction === 'undo' ? redoStack : undoStack
|
|
481
|
+
const entry = source.at(-1)
|
|
482
|
+
if (!entry) return { ok: false, code: 'empty' }
|
|
483
|
+
if (requestedId && requestedId !== entry.id) {
|
|
484
|
+
const requested = findEntry(requestedId) || entry
|
|
485
|
+
return refuse(requested, direction, 'stale-entry')
|
|
486
|
+
}
|
|
487
|
+
let checked
|
|
488
|
+
replaying = true
|
|
489
|
+
try {
|
|
490
|
+
checked = preflight(entry.certificate, direction, {
|
|
491
|
+
isIgnored: isIgnoredNode,
|
|
492
|
+
ignoreAttribute: isIgnoredAttribute,
|
|
493
|
+
isProtected: isProtectedNode,
|
|
494
|
+
inScope,
|
|
495
|
+
liveNodesWithIds,
|
|
496
|
+
})
|
|
497
|
+
} finally {
|
|
498
|
+
replaying = false
|
|
499
|
+
}
|
|
500
|
+
if (!checked.ok) return refuse(entry, direction, checked.code, checked.targets)
|
|
242
501
|
pause()
|
|
502
|
+
let applied
|
|
503
|
+
replaying = true
|
|
243
504
|
try {
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
505
|
+
applied = applyOperations(checked.operations)
|
|
506
|
+
} finally {
|
|
507
|
+
replaying = false
|
|
508
|
+
resume()
|
|
509
|
+
}
|
|
510
|
+
if (!applied.ok) {
|
|
511
|
+
log(`${direction} failed and was rolled back`, applied.error)
|
|
512
|
+
return refuse(entry, direction, 'replay-error')
|
|
513
|
+
}
|
|
514
|
+
source.pop()
|
|
515
|
+
destination.push(entry)
|
|
516
|
+
refusedTop = null
|
|
517
|
+
const receipt = { ok: true, entryId: entry.id, label: entry.label, direction }
|
|
518
|
+
log(direction, entry.label)
|
|
519
|
+
emitter.emit(direction, receipt)
|
|
520
|
+
return receipt
|
|
253
521
|
}
|
|
254
522
|
|
|
255
|
-
function
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
523
|
+
function undo(opts) { return replay('undo', opts && opts.entryId) }
|
|
524
|
+
function redo(opts) { return replay('redo', opts && opts.entryId) }
|
|
525
|
+
|
|
526
|
+
function findEntry(entryId) {
|
|
527
|
+
return undoStack.find((entry) => entry.id === entryId) || redoStack.find((entry) => entry.id === entryId) || null
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
function refuse(entry, direction, code, targets = []) {
|
|
531
|
+
refusedTop = { entryId: entry.id, direction, code, targets }
|
|
532
|
+
log('refused', direction, code)
|
|
533
|
+
emitter.emit('refused', { entryId: entry.id, label: entry.label, direction, code, targets })
|
|
534
|
+
return { ok: false, code, entryId: entry.id, label: entry.label, direction }
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
// Explicit dismissal of the refused top entry only: the document and every
|
|
538
|
+
// other entry stay exactly as they are.
|
|
539
|
+
function discard(entryId) {
|
|
540
|
+
if (!refusedTop || refusedTop.entryId !== entryId) return { ok: false, code: 'not-refused', entryId }
|
|
541
|
+
const direction = refusedTop.direction
|
|
542
|
+
const stack = direction === 'undo' ? undoStack : redoStack
|
|
543
|
+
const entry = stack.at(-1)
|
|
544
|
+
if (!entry || entry.id !== entryId) return { ok: false, code: 'stale-entry', entryId }
|
|
545
|
+
stack.pop()
|
|
546
|
+
refusedTop = null
|
|
547
|
+
log('discarded', entry.label)
|
|
548
|
+
emitter.emit('discard', { entryId, label: entry.label, direction })
|
|
549
|
+
return { ok: true, entryId, direction }
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
// Plain-data description of what an entry changes, built only from its
|
|
553
|
+
// certificate: the recovery store accepts serializable values, and the entry
|
|
554
|
+
// itself holds live DOM nodes. `null` for an unknown id or an entry whose
|
|
555
|
+
// certificate could not be compiled, so a caller can store the result as-is.
|
|
556
|
+
function describe(entryId) {
|
|
557
|
+
const entry = findEntry(entryId)
|
|
558
|
+
if (!entry || !entry.certificate || !entry.certificate.replayable) return null
|
|
559
|
+
const refused = refusedTop && refusedTop.entryId === entry.id
|
|
560
|
+
? {
|
|
561
|
+
direction: refusedTop.direction,
|
|
562
|
+
code: refusedTop.code,
|
|
563
|
+
targets: (refusedTop.targets || []).map((target) => ({ id: target.id ?? null, kind: target.kind ?? null })),
|
|
564
|
+
}
|
|
565
|
+
: null
|
|
566
|
+
return {
|
|
567
|
+
id: entry.id,
|
|
568
|
+
label: entry.label,
|
|
569
|
+
timestamp: entry.timestamp,
|
|
570
|
+
refused,
|
|
571
|
+
steps: entry.certificate.steps.map(describeStep),
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
function describeStep(step) {
|
|
576
|
+
if (step.type === 'move') {
|
|
577
|
+
return {
|
|
578
|
+
kind: 'move',
|
|
579
|
+
node: { id: described(step.node).id },
|
|
580
|
+
from: { id: described(step.sourceParent).id },
|
|
581
|
+
to: { id: described(step.destParent).id },
|
|
265
582
|
}
|
|
266
|
-
}
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
583
|
+
}
|
|
584
|
+
// A removal replays as a restore; from the entry's own point of view it is
|
|
585
|
+
// still a removal, and that is the name the description uses.
|
|
586
|
+
if (step.type === 'add' || step.type === 'restore') {
|
|
587
|
+
return {
|
|
588
|
+
kind: step.type === 'add' ? 'add' : 'remove',
|
|
589
|
+
parent: { id: described(step.parent).id },
|
|
590
|
+
nodes: step.nodes.map((node, i) => ({ id: described(node).id, html: step.content[i] ?? null })),
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
const primitive = step.primitive
|
|
594
|
+
const target = { id: described(primitive.target).id }
|
|
595
|
+
const before = primitive.oldValue ?? null
|
|
596
|
+
const after = primitive.newValue ?? null
|
|
597
|
+
if (step.type === 'value') return { kind: 'value', target, prop: primitive.prop ?? 'value', before, after }
|
|
598
|
+
if (step.type === 'text') return { kind: 'text', target, before, after }
|
|
599
|
+
if (step.type === 'attr-set' || step.type === 'attr-add' || step.type === 'attr-remove') {
|
|
600
|
+
return { kind: 'attribute', target, name: primitive.name ?? null, before, after }
|
|
601
|
+
}
|
|
602
|
+
// A step kind this engine does not name yet keeps its own name and the same
|
|
603
|
+
// JSON-safe endpoint shape rather than being dropped.
|
|
604
|
+
return { kind: step.type, target, before, after }
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
function observedHere(node) {
|
|
608
|
+
const root = config.scope
|
|
609
|
+
if (!root || !node) return false
|
|
610
|
+
return node === root || (typeof root.contains === 'function' && root.contains(node))
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
// A node the scope document's head holds, other than the <title> and what it
|
|
614
|
+
// contains: the fields a page saves with its document, which a bracket records.
|
|
615
|
+
function inHead(node) {
|
|
616
|
+
const doc = scopeDocument()
|
|
617
|
+
const head = doc && doc.head
|
|
618
|
+
if (!head || !node) return false
|
|
619
|
+
let el = node.nodeType === 1 ? node : node.parentElement
|
|
620
|
+
for (; el && el !== head; el = el.parentElement) {
|
|
621
|
+
if (el.localName === 'title') return false
|
|
622
|
+
}
|
|
623
|
+
return el === head
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
function inScope(node) {
|
|
627
|
+
const root = config.scope
|
|
628
|
+
if (!node || !root) return false
|
|
629
|
+
if (node === root) return true
|
|
630
|
+
// A manually recorded document.title value addresses head's <title>: the node
|
|
631
|
+
// outside the observed scope an entry's title step owns.
|
|
632
|
+
if (titleNode() === node) return true
|
|
633
|
+
// A bracket may record the head's own fields (see convertRecords), so their
|
|
634
|
+
// replay is judged by the head they still sit in.
|
|
635
|
+
if (inHead(node)) return true
|
|
636
|
+
return typeof root.contains === 'function' ? root.contains(node) : false
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
function liveNodesWithIds(ids) {
|
|
640
|
+
const root = config.scope
|
|
641
|
+
if (!ids || ids.length === 0 || typeof root?.querySelectorAll !== 'function') return []
|
|
642
|
+
const wanted = new Set(ids)
|
|
643
|
+
const found = []
|
|
644
|
+
for (const el of root.querySelectorAll('[data-id]')) {
|
|
645
|
+
if (!wanted.has(el.getAttribute('data-id'))) continue
|
|
646
|
+
if (isIgnoredNode(el)) continue
|
|
647
|
+
found.push(el)
|
|
648
|
+
}
|
|
649
|
+
return found
|
|
270
650
|
}
|
|
271
651
|
|
|
272
652
|
function clear() {
|
|
@@ -277,6 +657,7 @@ export function createScope(opts) {
|
|
|
277
657
|
undoStack.length = 0
|
|
278
658
|
redoStack.length = 0
|
|
279
659
|
pendingPrimitives = []
|
|
660
|
+
refusedTop = null
|
|
280
661
|
clearIdleTimer()
|
|
281
662
|
log('cleared')
|
|
282
663
|
emitter.emit('clear')
|
|
@@ -288,15 +669,32 @@ export function createScope(opts) {
|
|
|
288
669
|
_config: config, // private: used by keys.js to read shadowKeydownIn
|
|
289
670
|
start, stop,
|
|
290
671
|
commit, commitCaptured, discardCaptured, flush,
|
|
672
|
+
command, setRecording,
|
|
291
673
|
record, recordValue,
|
|
292
|
-
undo, redo, clear,
|
|
674
|
+
undo, redo, clear, discard, describe,
|
|
293
675
|
pause, resume,
|
|
294
676
|
on: emitter.on, off: emitter.off,
|
|
295
677
|
get canUndo() { return undoStack.length > 0 },
|
|
296
678
|
get canRedo() { return redoStack.length > 0 },
|
|
297
679
|
get isPaused() { return pauseDepth > 0 },
|
|
298
680
|
get history() {
|
|
299
|
-
return undoStack.map(c =>
|
|
681
|
+
return undoStack.map(c => {
|
|
682
|
+
const entry = { id: c.id, label: c.label, timestamp: c.timestamp }
|
|
683
|
+
if (refusedTop && refusedTop.entryId === c.id) {
|
|
684
|
+
entry.refused = { direction: refusedTop.direction, code: refusedTop.code, targets: refusedTop.targets }
|
|
685
|
+
}
|
|
686
|
+
return entry
|
|
687
|
+
})
|
|
688
|
+
},
|
|
689
|
+
// The redo stack, next first, in history's shape: what a Redo control names.
|
|
690
|
+
get future() {
|
|
691
|
+
return redoStack.map(c => {
|
|
692
|
+
const entry = { id: c.id, label: c.label, timestamp: c.timestamp }
|
|
693
|
+
if (refusedTop && refusedTop.entryId === c.id) {
|
|
694
|
+
entry.refused = { direction: refusedTop.direction, code: refusedTop.code, targets: refusedTop.targets }
|
|
695
|
+
}
|
|
696
|
+
return entry
|
|
697
|
+
}).reverse()
|
|
300
698
|
},
|
|
301
699
|
get scope() { return config.scope },
|
|
302
700
|
}
|