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/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,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
- 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 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
- undoStack.push({ label, timestamp: Date.now(), primitives })
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
- handleRecords(observer.takeRecords())
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. 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).
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 || pauseDepth > 0 || !primitive) return
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
- function undo() {
238
- if (scopeDisconnected()) { stop(); return }
239
- flush() // close any pending typing first
240
- const c = undoStack.pop()
241
- if (!c) return
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
- // 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) }
248
- }
249
- } finally { resume() }
250
- redoStack.push(c)
251
- log('undo', c.label)
252
- emitter.emit('undo')
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 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) }
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
- } finally { resume() }
267
- undoStack.push(c)
268
- log('redo', c.label)
269
- emitter.emit('redo')
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 => ({ label: c.label, timestamp: c.timestamp }))
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
  }