hyper-undo 0.6.4 → 0.7.1

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/src/scope.js CHANGED
@@ -1,4 +1,5 @@
1
- import { recordsToPrimitives, replayForward, replayReverse } from './primitives.js'
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,67 @@ 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
- function convertRecords(records) {
101
- const kept = records.filter((r) => !shouldIgnore(r.target, config.ignoreAttribute, r))
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 removedHere = removedFromScope(records)
158
+ const kept = records.filter((r) => (observedHere(r.target) || removedHere(r.target) || (bracketed && inHead(r.target))) && !shouldIgnore(r.target, config.ignoreAttribute, r))
102
159
  return recordsToPrimitives(kept, (node) => shouldIgnore(node, config.ignoreAttribute))
103
160
  }
104
161
 
@@ -122,28 +179,63 @@ export function createScope(opts) {
122
179
  // --- Commit machinery ---
123
180
 
124
181
  function pushCommit(label, primitives) {
125
- undoStack.push({ label, timestamp: Date.now(), primitives })
182
+ const certificate = compileCertificate(primitives, { isIgnored: isIgnoredNode, ignoreAttribute: isIgnoredAttribute })
183
+ if (certificate.steps.length === 0) {
184
+ log('commit', label, 'with nothing to record')
185
+ emitter.emit('commit')
186
+ return null
187
+ }
188
+ const entry = { id: `${entryPrefix}-${++entryCount}`, label, timestamp: Date.now(), primitives, certificate }
189
+ undoStack.push(entry)
126
190
  redoStack.length = 0
191
+ refusedTop = null
127
192
  while (undoStack.length > config.maxHistory) undoStack.shift()
128
193
  log('commit', label, 'primitives:', primitives.length)
129
194
  emitter.emit('commit')
195
+ return entry
196
+ }
197
+
198
+ function isIgnoredNode(node) {
199
+ return shouldIgnore(node, config.ignoreAttribute)
200
+ }
201
+
202
+ // The same predicate the recorder uses, asked about an attribute the
203
+ // certificate is projecting rather than about a live record.
204
+ // `host` is the parent a removed subtree belongs under (see durableProjection).
205
+ function isIgnoredAttribute(name, node, host = null) {
206
+ if (typeof config.ignoreAttribute !== 'function') return false
207
+ try { return !!config.ignoreAttribute(name, node, host) } catch (_) { return false }
208
+ }
209
+
210
+ function isProtectedNode(node) {
211
+ if (typeof config.isProtected !== 'function') return false
212
+ try { return !!config.isProtected(node) } catch (_) { return false }
130
213
  }
131
214
 
132
215
  // Explicit commit: caller-driven, synchronous fn.
133
216
  function commit(label, fn) {
134
217
  if (!started) throw new Error('hyper-undo: scope not started')
218
+ // Only automatic IDLE collection is off in commands mode: commit stays an
219
+ // explicit boundary, and an outer exclusion pause still excludes it.
220
+ if (recordingMode === 'commands') return runCapture(label, fn, { honorPause: true, receipt: false })
135
221
  // Drain anything already buffered from this same synchronous tick (e.g.
136
222
  // typing whose observer callback hasn't fired yet) into pending, THEN close
137
223
  // it as its own batch — so the explicit commit starts clean and prior work
138
224
  // isn't swallowed under this label.
139
225
  handleRecords(observer.takeRecords())
140
226
  flush()
227
+ // A commit brackets fn the way a command does, so the document title it
228
+ // changes is one value step of this entry (and head's <title> text is left to
229
+ // that capture, never a second record beside it).
230
+ const titleBefore = pauseDepth === 0 ? titleValue() : null
141
231
  const result = fn()
142
232
  if (result && typeof result.then === 'function') {
143
233
  throw new Error('hyper-undo: commit() fn must be synchronous; returned a Promise')
144
234
  }
145
- // Drain records the observer hasn't fired callback for yet.
146
- handleRecords(observer.takeRecords())
235
+ // Drain records the observer hasn't fired callback for yet. They are this
236
+ // commit's own bracket, so what fn wrote in the document's head joins it.
237
+ collectRecords(observer.takeRecords(), true)
238
+ if (titleBefore != null) recordTitleChange(titleBefore)
147
239
  if (pendingPrimitives.length === 0) {
148
240
  emitter.emit('commit')
149
241
  return
@@ -168,7 +260,7 @@ export function createScope(opts) {
168
260
  // push lands AFTER it in history. Without this, type-then-immediately-add
169
261
  // would record the structural op before the typing, inverting undo order.
170
262
  flush()
171
- const primitives = convertRecords(observer.takeRecords())
263
+ const primitives = convertRecords(observer.takeRecords(), true)
172
264
  if (primitives.length === 0) return
173
265
  clearIdleTimer()
174
266
  pushCommit(label, primitives)
@@ -178,16 +270,161 @@ export function createScope(opts) {
178
270
  if (observer) observer.takeRecords()
179
271
  }
180
272
 
273
+ // --- Explicit commands ---
274
+
275
+ // document.title lives in <head>, outside the body the observer watches, so a
276
+ // command records it from its own bracket and addresses head's <title> element
277
+ // directly. The document is the SCOPE's, never the module-global one: a scope
278
+ // bound to another realm must not read or write this page's title.
279
+ function scopeDocument() {
280
+ const root = config.scope
281
+ return (root && root.ownerDocument) || (typeof document !== 'undefined' ? document : null)
282
+ }
283
+
284
+ function titleNode() {
285
+ const doc = scopeDocument()
286
+ if (!doc || !doc.head || typeof doc.head.querySelector !== 'function') return null
287
+ return doc.head.querySelector('title')
288
+ }
289
+
290
+ function titleValue() {
291
+ const node = titleNode()
292
+ return node ? node.textContent : null
293
+ }
294
+
295
+ function recordTitleChange(before) {
296
+ const node = titleNode()
297
+ if (!node || before == null) return
298
+ const after = node.textContent
299
+ if (after === before) return
300
+ capturePrimitive({ kind: 'value', target: node, prop: 'textContent', oldValue: before, newValue: after })
301
+ }
302
+
303
+ // A caller records document.title explicitly (recordValue(document, { prop: 'title' })).
304
+ // Redirect that write onto the live <title> element so it shares one target and
305
+ // one certificate entry with the command's own title capture.
306
+ function normalizeRecordedPrimitive(primitive) {
307
+ if (!primitive) return null
308
+ const doc = scopeDocument()
309
+ if (!doc || primitive.target !== doc || primitive.prop !== 'title') return primitive
310
+ const node = titleNode()
311
+ if (!node) return null
312
+ return { kind: 'value', target: node, prop: 'textContent', oldValue: primitive.oldValue, newValue: primitive.newValue }
313
+ }
314
+
315
+ function inEffectContext() {
316
+ if (typeof window === 'undefined') return false
317
+ const effect = window.clay?.effect || window.hyperclay?.effect
318
+ return !!(effect && effect.running)
319
+ }
320
+
321
+ function openCapture() {
322
+ // Everything that happened before the command goes through its ordinary
323
+ // classification first: automatic work becomes its own idle entry, and in
324
+ // commands mode an unbracketed record is dropped as usual. Then preceding
325
+ // local work is closed, so the command starts from a fresh buffer.
326
+ if (observer) handleRecords(observer.takeRecords())
327
+ flush()
328
+ capture = { records: [], primitives: [] }
329
+ }
330
+
331
+ function closeCapture() {
332
+ const held = capture
333
+ capture = null
334
+ if (!held) return []
335
+ const primitives = convertRecords(held.records, true)
336
+ for (const p of held.primitives) primitives.push(p)
337
+ return primitives
338
+ }
339
+
340
+ // One synchronous bracket around a caller's authored writes. Deliberately
341
+ // records only what happens inside the callback, so a local command during a
342
+ // long sync pause is captured while the surrounding merge writes are not. The
343
+ // outer pause depth is never touched: no part of the hub is resumed.
344
+ function runCapture(label, fn, { honorPause, receipt }) {
345
+ if (typeof fn !== 'function') throw new Error('hyper-undo: command() needs a synchronous function')
346
+ if (capture) throw new Error('hyper-undo: command() cannot nest inside another command')
347
+ if (replaying) throw new Error('hyper-undo: command() cannot run during undo or redo')
348
+ if (inEffectContext()) throw new Error('hyper-undo: command() cannot run inside a derived-effect context')
349
+ if (fn.constructor && fn.constructor.name === 'AsyncFunction') {
350
+ throw new Error('hyper-undo: command() fn must be synchronous; it was declared async')
351
+ }
352
+ const discarded = honorPause && pauseDepth > 0
353
+ const titleBefore = discarded ? null : titleValue()
354
+ openCapture()
355
+ let primitives = []
356
+ let failure = null
357
+ let result
358
+ try {
359
+ try {
360
+ result = fn()
361
+ } catch (error) {
362
+ failure = error
363
+ }
364
+ if (observer) handleRecords(observer.takeRecords())
365
+ if (result && typeof result.then === 'function') {
366
+ // Never extend the capture across the await; just keep the rejection
367
+ // from surfacing as an unhandled one.
368
+ try { result.then(undefined, () => {}) } catch (_) {}
369
+ if (!failure) failure = new Error('hyper-undo: command() fn must be synchronous; returned a Promise')
370
+ }
371
+ if (!discarded) recordTitleChange(titleBefore)
372
+ } finally {
373
+ primitives = closeCapture()
374
+ }
375
+ // A callback that wrote and then failed keeps its observed writes as an
376
+ // interrupted entry: the DOM already changed and dropping it would leave
377
+ // unexplained content with no way back.
378
+ const entry = discarded || primitives.length === 0 ? null : pushCommit(label, primitives)
379
+ if (failure) throw failure
380
+ if (!receipt) return entry
381
+ return entry ? { id: entry.id, label: entry.label } : null
382
+ }
383
+
384
+ // Explicit local command: one label, one entry, only its own synchronous
385
+ // writes. Returns null for a no-op and { id, label } otherwise.
386
+ function command(label, fn) {
387
+ if (!started) throw new Error('hyper-undo: scope not started')
388
+ return runCapture(label, fn, { honorPause: false, receipt: true })
389
+ }
390
+
391
+ // Automatic (default) keeps idle batches for every consumer that records by
392
+ // watching the document; commands mode records only explicit commands.
393
+ function setRecording(mode) {
394
+ if (mode !== 'automatic' && mode !== 'commands') {
395
+ throw new Error("hyper-undo: setRecording() takes 'automatic' or 'commands'")
396
+ }
397
+ if (capture) throw new Error('hyper-undo: setRecording() cannot change while a command is running')
398
+ if (recordingMode === mode) return recordingMode
399
+ // Work already recorded keeps its own entry: the switch is a commit
400
+ // boundary, never a silent merge into the next command.
401
+ flush()
402
+ recordingMode = mode
403
+ log('recording', mode)
404
+ return recordingMode
405
+ }
406
+
181
407
  // --- Manual recording (for changes the observer can't see) ---
182
408
 
183
409
  // Record a primitive the MutationObserver never delivers — an element PROPERTY
184
410
  // write (input.value, checkbox.checked) fires no MutationRecord. The primitive
185
411
  // joins the same idle batch as observed edits, so rapid same-field property
186
- // writes coalesce into one commit exactly like text edits do. No-op while
187
- // stopped or paused (a paused caller is inside an undo/redo replay or an
188
- // exclusion frame, where injected primitives would be wrong).
412
+ // writes coalesce into one commit exactly like text edits do. Inside a command
413
+ // the write joins that command's entry; outside one it is a no-op while
414
+ // stopped, paused or in commands mode.
189
415
  function record(primitive) {
190
- if (!started || pauseDepth > 0 || !primitive) return
416
+ if (!started || !primitive) return
417
+ // Inside a command the capture owns the write, even under an outer pause.
418
+ // Outside one, commands mode records nothing and an exclusion pause still
419
+ // excludes.
420
+ if (!capture && (pauseDepth > 0 || recordingMode === 'commands')) return
421
+ const normalized = normalizeRecordedPrimitive(primitive)
422
+ if (!normalized) return
423
+ capturePrimitive(normalized)
424
+ }
425
+
426
+ function capturePrimitive(primitive) {
427
+ if (capture) { capture.primitives.push(primitive); return }
191
428
  pendingPrimitives.push(primitive)
192
429
  restartIdleTimer()
193
430
  }
@@ -234,39 +471,206 @@ export function createScope(opts) {
234
471
 
235
472
  // --- Undo/redo ---
236
473
 
237
- function undo() {
238
- if (scopeDisconnected()) { stop(); return }
239
- flush() // close any pending typing first
240
- const c = undoStack.pop()
241
- if (!c) return
474
+ // One entry is applied whole or not at all. Preflight compares the certificate
475
+ // against the live document without writing; only a fully validated plan
476
+ // reaches the DOM, and a native failure rolls it back and keeps both stacks.
477
+ function replay(direction, requestedId) {
478
+ if (scopeDisconnected()) { stop(); return { ok: false, code: 'disconnected' } }
479
+ flush()
480
+ const source = direction === 'undo' ? undoStack : redoStack
481
+ const destination = direction === 'undo' ? redoStack : undoStack
482
+ const entry = source.at(-1)
483
+ if (!entry) return { ok: false, code: 'empty' }
484
+ if (requestedId && requestedId !== entry.id) {
485
+ const requested = findEntry(requestedId) || entry
486
+ return refuse(requested, direction, 'stale-entry')
487
+ }
488
+ let checked
489
+ replaying = true
490
+ try {
491
+ checked = preflight(entry.certificate, direction, {
492
+ isIgnored: isIgnoredNode,
493
+ ignoreAttribute: isIgnoredAttribute,
494
+ isProtected: isProtectedNode,
495
+ inScope,
496
+ liveNodesWithIds,
497
+ })
498
+ } finally {
499
+ replaying = false
500
+ }
501
+ if (!checked.ok) return refuse(entry, direction, checked.code, checked.targets)
242
502
  pause()
503
+ let applied
504
+ replaying = true
243
505
  try {
244
- // Reverse order so a child restore happens before its parent restore.
245
- for (let i = c.primitives.length - 1; i >= 0; i--) {
246
- try { replayReverse(c.primitives[i]) }
247
- catch (err) { log('undo primitive failed (continuing)', err) }
506
+ applied = applyOperations(checked.operations)
507
+ } finally {
508
+ replaying = false
509
+ resume()
510
+ }
511
+ if (!applied.ok) {
512
+ log(`${direction} failed and was rolled back`, applied.error)
513
+ return refuse(entry, direction, 'replay-error')
514
+ }
515
+ source.pop()
516
+ destination.push(entry)
517
+ refusedTop = null
518
+ const receipt = { ok: true, entryId: entry.id, label: entry.label, direction }
519
+ log(direction, entry.label)
520
+ emitter.emit(direction, receipt)
521
+ return receipt
522
+ }
523
+
524
+ function undo(opts) { return replay('undo', opts && opts.entryId) }
525
+ function redo(opts) { return replay('redo', opts && opts.entryId) }
526
+
527
+ function findEntry(entryId) {
528
+ return undoStack.find((entry) => entry.id === entryId) || redoStack.find((entry) => entry.id === entryId) || null
529
+ }
530
+
531
+ function refuse(entry, direction, code, targets = []) {
532
+ refusedTop = { entryId: entry.id, direction, code, targets }
533
+ log('refused', direction, code)
534
+ emitter.emit('refused', { entryId: entry.id, label: entry.label, direction, code, targets })
535
+ return { ok: false, code, entryId: entry.id, label: entry.label, direction }
536
+ }
537
+
538
+ // Explicit dismissal of the refused top entry only: the document and every
539
+ // other entry stay exactly as they are.
540
+ function discard(entryId) {
541
+ if (!refusedTop || refusedTop.entryId !== entryId) return { ok: false, code: 'not-refused', entryId }
542
+ const direction = refusedTop.direction
543
+ const stack = direction === 'undo' ? undoStack : redoStack
544
+ const entry = stack.at(-1)
545
+ if (!entry || entry.id !== entryId) return { ok: false, code: 'stale-entry', entryId }
546
+ stack.pop()
547
+ refusedTop = null
548
+ log('discarded', entry.label)
549
+ emitter.emit('discard', { entryId, label: entry.label, direction })
550
+ return { ok: true, entryId, direction }
551
+ }
552
+
553
+ // Plain-data description of what an entry changes, built only from its
554
+ // certificate: the recovery store accepts serializable values, and the entry
555
+ // itself holds live DOM nodes. `null` for an unknown id or an entry whose
556
+ // certificate could not be compiled, so a caller can store the result as-is.
557
+ function describe(entryId) {
558
+ const entry = findEntry(entryId)
559
+ if (!entry || !entry.certificate || !entry.certificate.replayable) return null
560
+ const refused = refusedTop && refusedTop.entryId === entry.id
561
+ ? {
562
+ direction: refusedTop.direction,
563
+ code: refusedTop.code,
564
+ targets: (refusedTop.targets || []).map((target) => ({ id: target.id ?? null, kind: target.kind ?? null })),
565
+ }
566
+ : null
567
+ return {
568
+ id: entry.id,
569
+ label: entry.label,
570
+ timestamp: entry.timestamp,
571
+ refused,
572
+ steps: entry.certificate.steps.map(describeStep),
573
+ }
574
+ }
575
+
576
+ function describeStep(step) {
577
+ if (step.type === 'move') {
578
+ return {
579
+ kind: 'move',
580
+ node: { id: described(step.node).id },
581
+ from: { id: described(step.sourceParent).id },
582
+ to: { id: described(step.destParent).id },
583
+ }
584
+ }
585
+ // A removal replays as a restore; from the entry's own point of view it is
586
+ // still a removal, and that is the name the description uses.
587
+ if (step.type === 'add' || step.type === 'restore') {
588
+ return {
589
+ kind: step.type === 'add' ? 'add' : 'remove',
590
+ parent: { id: described(step.parent).id },
591
+ nodes: step.nodes.map((node, i) => ({ id: described(node).id, html: step.content[i] ?? null })),
248
592
  }
249
- } finally { resume() }
250
- redoStack.push(c)
251
- log('undo', c.label)
252
- emitter.emit('undo')
593
+ }
594
+ const primitive = step.primitive
595
+ const target = { id: described(primitive.target).id }
596
+ const before = primitive.oldValue ?? null
597
+ const after = primitive.newValue ?? null
598
+ if (step.type === 'value') return { kind: 'value', target, prop: primitive.prop ?? 'value', before, after }
599
+ if (step.type === 'text') return { kind: 'text', target, before, after }
600
+ if (step.type === 'attr-set' || step.type === 'attr-add' || step.type === 'attr-remove') {
601
+ return { kind: 'attribute', target, name: primitive.name ?? null, before, after }
602
+ }
603
+ // A step kind this engine does not name yet keeps its own name and the same
604
+ // JSON-safe endpoint shape rather than being dropped.
605
+ return { kind: step.type, target, before, after }
253
606
  }
254
607
 
255
- function redo() {
256
- if (scopeDisconnected()) { stop(); return }
257
- flush()
258
- const c = redoStack.pop()
259
- if (!c) return
260
- pause()
261
- try {
262
- for (const p of c.primitives) {
263
- try { replayForward(p) }
264
- catch (err) { log('redo primitive failed (continuing)', err) }
608
+ // A subtree removed from the scope in this batch is detached by the time the
609
+ // batch is read, yet what happened inside it before the removal (a child moved
610
+ // out of it, say) belongs to the same edit: dropping it would restore the
611
+ // subtree as it was left, not as it was.
612
+ function removedFromScope(records) {
613
+ const removed = new Set()
614
+ let grew = true
615
+ while (grew) {
616
+ grew = false
617
+ for (const r of records) {
618
+ if (r.type !== 'childList' || !r.removedNodes?.length) continue
619
+ if (!observedHere(r.target) && !insideRemoved(r.target, removed)) continue
620
+ for (const node of r.removedNodes) if (!removed.has(node)) { removed.add(node); grew = true }
265
621
  }
266
- } finally { resume() }
267
- undoStack.push(c)
268
- log('redo', c.label)
269
- emitter.emit('redo')
622
+ }
623
+ return (node) => !observedHere(node) && insideRemoved(node, removed)
624
+ }
625
+
626
+ function insideRemoved(node, removed) {
627
+ for (let n = node; n; n = n.parentNode) if (removed.has(n)) return true
628
+ return false
629
+ }
630
+
631
+ function observedHere(node) {
632
+ const root = config.scope
633
+ if (!root || !node) return false
634
+ return node === root || (typeof root.contains === 'function' && root.contains(node))
635
+ }
636
+
637
+ // A node the scope document's head holds, other than the <title> and what it
638
+ // contains: the fields a page saves with its document, which a bracket records.
639
+ function inHead(node) {
640
+ const doc = scopeDocument()
641
+ const head = doc && doc.head
642
+ if (!head || !node) return false
643
+ let el = node.nodeType === 1 ? node : node.parentElement
644
+ for (; el && el !== head; el = el.parentElement) {
645
+ if (el.localName === 'title') return false
646
+ }
647
+ return el === head
648
+ }
649
+
650
+ function inScope(node) {
651
+ const root = config.scope
652
+ if (!node || !root) return false
653
+ if (node === root) return true
654
+ // A manually recorded document.title value addresses head's <title>: the node
655
+ // outside the observed scope an entry's title step owns.
656
+ if (titleNode() === node) return true
657
+ // A bracket may record the head's own fields (see convertRecords), so their
658
+ // replay is judged by the head they still sit in.
659
+ if (inHead(node)) return true
660
+ return typeof root.contains === 'function' ? root.contains(node) : false
661
+ }
662
+
663
+ function liveNodesWithIds(ids) {
664
+ const root = config.scope
665
+ if (!ids || ids.length === 0 || typeof root?.querySelectorAll !== 'function') return []
666
+ const wanted = new Set(ids)
667
+ const found = []
668
+ for (const el of root.querySelectorAll('[data-id]')) {
669
+ if (!wanted.has(el.getAttribute('data-id'))) continue
670
+ if (isIgnoredNode(el)) continue
671
+ found.push(el)
672
+ }
673
+ return found
270
674
  }
271
675
 
272
676
  function clear() {
@@ -277,6 +681,7 @@ export function createScope(opts) {
277
681
  undoStack.length = 0
278
682
  redoStack.length = 0
279
683
  pendingPrimitives = []
684
+ refusedTop = null
280
685
  clearIdleTimer()
281
686
  log('cleared')
282
687
  emitter.emit('clear')
@@ -288,15 +693,32 @@ export function createScope(opts) {
288
693
  _config: config, // private: used by keys.js to read shadowKeydownIn
289
694
  start, stop,
290
695
  commit, commitCaptured, discardCaptured, flush,
696
+ command, setRecording,
291
697
  record, recordValue,
292
- undo, redo, clear,
698
+ undo, redo, clear, discard, describe,
293
699
  pause, resume,
294
700
  on: emitter.on, off: emitter.off,
295
701
  get canUndo() { return undoStack.length > 0 },
296
702
  get canRedo() { return redoStack.length > 0 },
297
703
  get isPaused() { return pauseDepth > 0 },
298
704
  get history() {
299
- return undoStack.map(c => ({ label: c.label, timestamp: c.timestamp }))
705
+ return undoStack.map(c => {
706
+ const entry = { id: c.id, label: c.label, timestamp: c.timestamp }
707
+ if (refusedTop && refusedTop.entryId === c.id) {
708
+ entry.refused = { direction: refusedTop.direction, code: refusedTop.code, targets: refusedTop.targets }
709
+ }
710
+ return entry
711
+ })
712
+ },
713
+ // The redo stack, next first, in history's shape: what a Redo control names.
714
+ get future() {
715
+ return redoStack.map(c => {
716
+ const entry = { id: c.id, label: c.label, timestamp: c.timestamp }
717
+ if (refusedTop && refusedTop.entryId === c.id) {
718
+ entry.refused = { direction: refusedTop.direction, code: refusedTop.code, targets: refusedTop.targets }
719
+ }
720
+ return entry
721
+ }).reverse()
300
722
  },
301
723
  get scope() { return config.scope },
302
724
  }