hyper-undo 0.7.0 → 0.8.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.
@@ -53,9 +53,18 @@ function childIdentities(parent, isIgnored) {
53
53
  // `host` is the element a removed subtree belongs under. A detached node has no
54
54
  // ancestors left, so a predicate that reads them (is this attribute painted by an
55
55
  // app above it?) receives the host and answers as it did while attached.
56
- export function durableProjection(node, ignoredAttribute, host = null, ignoredNode = null) {
56
+ function ownForm(node, form, host) {
57
+ if (typeof form !== 'function' || !isNode(node) || node.nodeType !== 1) return null
58
+ let own = null
59
+ try { own = form(node, host) } catch (_) { own = null }
60
+ return typeof own === 'string' ? `#form:${JSON.stringify(own)}` : null
61
+ }
62
+
63
+ export function durableProjection(node, ignoredAttribute, host = null, ignoredNode = null, form = null, texts = null) {
57
64
  if (!isNode(node)) return null
58
- if (node.nodeType === 3) return `#text:${JSON.stringify(node.data)}`
65
+ const own = ownForm(node, form, host)
66
+ if (own != null) return own
67
+ if (node.nodeType === 3) return `#text:${JSON.stringify(texts && texts.has(node) ? texts.get(node) : node.data)}`
59
68
  if (node.nodeType === 8) return `#comment:${JSON.stringify(node.data)}`
60
69
  if (node.nodeType !== 1) return `#node:${node.nodeType}`
61
70
  const attributes = []
@@ -69,8 +78,10 @@ export function durableProjection(node, ignoredAttribute, host = null, ignoredNo
69
78
  const children = []
70
79
  if (node.childNodes) {
71
80
  for (const child of node.childNodes) {
81
+ const ownChild = ownForm(child, form, host)
82
+ if (ownChild != null) { children.push(ownChild); continue }
72
83
  if (isIgnoredNode(child, ignoredNode)) continue
73
- children.push(durableProjection(child, ignoredAttribute, host, ignoredNode))
84
+ children.push(durableProjection(child, ignoredAttribute, host, ignoredNode, form, texts))
74
85
  }
75
86
  }
76
87
  const tag = String(node.tagName || '').toLowerCase()
@@ -153,7 +164,8 @@ function slotIndex(list, primitive) {
153
164
  export function compileCertificate(primitives, env = {}) {
154
165
  const isIgnored = env.isIgnored || null
155
166
  const ignoredAttribute = env.ignoreAttribute || null
156
- const normalized = normalizePrimitives(primitives)
167
+ const durableForm = env.durableForm || null
168
+ const normalized = normalizePrimitives(primitives, { sameContent })
157
169
 
158
170
  const structural = []
159
171
  for (const p of normalized) {
@@ -183,7 +195,7 @@ export function compileCertificate(primitives, env = {}) {
183
195
  if (move.at === item) steps.push(move.step)
184
196
  continue
185
197
  }
186
- steps.push(structuralStep(item, states.get(item), ignoredAttribute, isIgnored))
198
+ steps.push(structuralStep(item, states.get(item), ignoredAttribute, isIgnored, durableForm))
187
199
  }
188
200
  return { replayable: true, reason: null, steps }
189
201
  }
@@ -276,7 +288,7 @@ function pairMoves(structural, states) {
276
288
  return moves
277
289
  }
278
290
 
279
- function structuralStep(item, state, ignoredAttribute, ignoredNode) {
291
+ function structuralStep(item, state, ignoredAttribute, ignoredNode, durableForm = null) {
280
292
  const kind = item.primitive.kind
281
293
  return {
282
294
  type: kind === 'add' ? 'add' : 'restore',
@@ -285,7 +297,7 @@ function structuralStep(item, state, ignoredAttribute, ignoredNode) {
285
297
  seqBefore: state.before,
286
298
  seqAfter: state.after,
287
299
  slot: item.primitive,
288
- content: item.nodes.map((node) => durableProjection(node, ignoredAttribute, item.parent, ignoredNode)),
300
+ content: item.nodes.map((node) => durableProjection(node, ignoredAttribute, item.parent, ignoredNode, durableForm)),
289
301
  ids: kind === 'remove' ? item.nodes.flatMap((node) => durableIdsIn(node)) : [],
290
302
  targets: targetsOf(item.nodes, kind === 'add' ? 'added' : 'removed', item.parent),
291
303
  }
@@ -305,11 +317,13 @@ export function preflight(certificate, direction, env = {}) {
305
317
  const context = {
306
318
  isIgnored: env.isIgnored || null,
307
319
  ignoreAttribute: env.ignoreAttribute || null,
320
+ durableForm: env.durableForm || null,
308
321
  isProtected: env.isProtected || null,
309
322
  inScope: env.inScope || (() => true),
310
323
  liveNodesWithIds: env.liveNodesWithIds || (() => []),
311
324
  sim: new Map(),
312
325
  planned: new Map(),
326
+ texts: new Map(),
313
327
  operations: [],
314
328
  }
315
329
 
@@ -344,6 +358,12 @@ function refuseStep(code, targets) {
344
358
  return { code, targets: targets || [] }
345
359
  }
346
360
 
361
+ export function sameContent(a, b) {
362
+ if (a === b) return true
363
+ if (typeof a !== 'string' || typeof b !== 'string') return false
364
+ return a.replace(/\u00A0| /g, ' ') === b.replace(/\u00A0| /g, ' ')
365
+ }
366
+
347
367
  function liveValue(primitive) {
348
368
  if (primitive.kind === 'text') return primitive.target.data
349
369
  if (primitive.kind === 'value') {
@@ -369,11 +389,13 @@ function checkScalar(step, undoing, context) {
369
389
  if (primitive.kind === 'value' && typeof primitive.write === 'function' && typeof primitive.read !== 'function') {
370
390
  return refuseStep('unsupported', step.targets)
371
391
  }
372
- if (liveValue(primitive) !== expected) return refuseStep('content-changed', step.targets)
392
+ const live = liveValue(primitive)
393
+ if (primitive.content === true ? !sameContent(live, expected) : live !== expected) return refuseStep('content-changed', step.targets)
373
394
  if (primitive.kind === 'value') {
374
- context.operations.push({ type: 'value', target: primitive.target, prop: primitive.prop || 'value', value: next, read: primitive.read || null, write: primitive.write || null })
395
+ context.operations.push({ type: 'value', target: primitive.target, prop: primitive.prop || 'value', value: next, read: primitive.read || null, write: primitive.write || null, content: primitive.content === true })
375
396
  } else if (primitive.kind === 'text') {
376
397
  context.operations.push({ type: 'text', target: primitive.target, data: next })
398
+ context.texts.set(primitive.target, next)
377
399
  } else {
378
400
  context.operations.push({ type: 'attr', target: primitive.target, name: primitive.name, value: next == null ? null : next })
379
401
  }
@@ -381,13 +403,13 @@ function checkScalar(step, undoing, context) {
381
403
  }
382
404
 
383
405
  function checkAdd(step, undoing, context) {
384
- if (!isNode(step.parent) || !context.inScope(step.parent)) return refuseStep('target-detached', step.targets)
406
+ if (!isNode(step.parent) || !inPlannedScope(step.parent, context)) return refuseStep('target-detached', step.targets)
385
407
  if (undoing) {
386
408
  if (!sameSequence(listFor(context, step.parent), step.seqAfter)) return refuseStep('structure-changed', step.targets)
387
409
  const protectedTargets = protectedWithin(step.nodes, context.isProtected)
388
410
  if (protectedTargets.length > 0) return refuseStep('protected', protectedTargets)
389
411
  for (let i = 0; i < step.nodes.length; i++) {
390
- if (durableProjection(step.nodes[i], context.ignoreAttribute, step.parent, context.isIgnored) !== step.content[i]) return refuseStep('content-changed', step.targets)
412
+ if (durableProjection(step.nodes[i], context.ignoreAttribute, step.parent, context.isIgnored, context.durableForm) !== step.content[i]) return refuseStep('content-changed', step.targets)
391
413
  }
392
414
  for (const node of step.nodes) {
393
415
  context.operations.push({ type: 'remove', node })
@@ -417,12 +439,12 @@ function checkAdd(step, undoing, context) {
417
439
  }
418
440
 
419
441
  function checkRestore(step, undoing, context) {
420
- if (!isNode(step.parent) || !context.inScope(step.parent)) return refuseStep('target-detached', step.targets)
442
+ if (!isNode(step.parent) || !inPlannedScope(step.parent, context)) return refuseStep('target-detached', step.targets)
421
443
  if (undoing) {
422
444
  if (!sameSequence(listFor(context, step.parent), step.seqAfter)) return refuseStep('structure-changed', step.targets)
423
445
  for (let i = 0; i < step.nodes.length; i++) {
424
446
  const node = step.nodes[i]
425
- if (node.parentNode || durableProjection(node, context.ignoreAttribute, step.parent, context.isIgnored) !== step.content[i]) return refuseStep('content-changed', step.targets)
447
+ if (node.parentNode || durableProjection(node, context.ignoreAttribute, step.parent, context.isIgnored, context.durableForm) !== step.content[i]) return refuseStep('content-changed', step.targets)
426
448
  }
427
449
  if (step.ids.length > 0 && liveDuplicates(step.ids, context)) return refuseStep('duplicate-id', step.targets)
428
450
  const at = slotIndex(step.seqAfter, step.slot)
@@ -441,9 +463,15 @@ function checkRestore(step, undoing, context) {
441
463
  if (!sameSequence(listFor(context, step.parent), step.seqBefore)) return refuseStep('structure-changed', step.targets)
442
464
  const protectedTargets = protectedWithin(step.nodes, context.isProtected)
443
465
  if (protectedTargets.length > 0) return refuseStep('protected', protectedTargets)
466
+ // A child an earlier step of this replay moves out (a card moved to another
467
+ // column before its column is removed) is leaving, so the removed subtree is
468
+ // compared without it, as it was when it was recorded.
469
+ const leaving = (node) => context.planned.get(node) === true || (typeof context.isIgnored === 'function' && context.isIgnored(node))
470
+ // Text an earlier step of this replay rewrites (a field emptied before its text node
471
+ // is taken out) is compared as that step leaves it, as it was when it was recorded.
444
472
  for (let i = 0; i < step.nodes.length; i++) {
445
473
  const node = step.nodes[i]
446
- if (node.parentNode !== step.parent || durableProjection(node, context.ignoreAttribute, step.parent, context.isIgnored) !== step.content[i]) return refuseStep('content-changed', step.targets)
474
+ if (node.parentNode !== step.parent || durableProjection(node, context.ignoreAttribute, step.parent, leaving, context.durableForm, context.texts) !== step.content[i]) return refuseStep('content-changed', step.targets)
447
475
  }
448
476
  for (const node of step.nodes) {
449
477
  context.operations.push({ type: 'remove', node })
@@ -469,7 +497,7 @@ function checkMove(step, undoing, context) {
469
497
  const gap = undoing ? step.source.after : step.dest.before
470
498
  const slot = undoing ? step.sourceSlot : step.destSlot
471
499
  if (!isNode(fromParent) || !isNode(toParent)) return refuseStep('target-detached', step.targets)
472
- if (!context.inScope(fromParent) || !context.inScope(toParent)) return refuseStep('target-detached', step.targets)
500
+ if (!inPlannedScope(fromParent, context) || !inPlannedScope(toParent, context)) return refuseStep('target-detached', step.targets)
473
501
  if (!sameSequence(fromList, expectedFrom)) return refuseStep('structure-changed', step.targets)
474
502
  const toGap = fromParent === toParent ? withoutIdentity(toList, nodeId) : toList
475
503
  if (!sameSequence(toGap, gap)) return refuseStep('structure-changed', step.targets)
@@ -501,6 +529,15 @@ function findChildByIdentity(parent, id) {
501
529
  return null
502
530
  }
503
531
 
532
+ function changedEnd(before, after) {
533
+ const max = Math.min(before.length, after.length)
534
+ let prefix = 0
535
+ while (prefix < max && before[prefix] === after[prefix]) prefix++
536
+ let suffix = 0
537
+ while (suffix < max - prefix && before[before.length - 1 - suffix] === after[after.length - 1 - suffix]) suffix++
538
+ return after.length - suffix
539
+ }
540
+
504
541
  function writeValue(op, value) {
505
542
  if (typeof op.write === 'function') op.write(op.target, value)
506
543
  else op.target[op.prop] = value
@@ -509,8 +546,9 @@ function writeValue(op, value) {
509
546
  export function applyOperations(operations) {
510
547
  const journal = []
511
548
  try {
512
- for (const op of operations) applyOne(op, journal)
513
- return { ok: true }
549
+ let caret = null
550
+ for (const op of operations) caret = applyOne(op, journal) || caret
551
+ return { ok: true, caret }
514
552
  } catch (error) {
515
553
  for (let i = journal.length - 1; i >= 0; i--) {
516
554
  try { journal[i]() } catch (_) { /* the rollback keeps trying the rest */ }
@@ -534,13 +572,16 @@ function applyOne(op, journal) {
534
572
  const previous = op.target.data
535
573
  op.target.data = op.data
536
574
  journal.push(() => { op.target.data = previous })
537
- return
575
+ return { node: op.target, offset: changedEnd(previous, op.data) }
538
576
  }
539
577
  if (op.type === 'value') {
540
578
  const previous = typeof op.read === 'function' ? op.read(op.target) : op.target[op.prop]
579
+ const textBefore = op.content ? op.target.textContent : null
541
580
  writeValue(op, op.value)
542
581
  journal.push(() => writeValue(op, previous))
543
- return
582
+ if (!op.content) return
583
+ const textAfter = op.target.textContent
584
+ return textAfter === textBefore ? undefined : { root: op.target, textOffset: changedEnd(textBefore, textAfter) }
544
585
  }
545
586
  if (op.type === 'insert') {
546
587
  const before = op.beforeId == null ? null : findChildByIdentity(op.parent, op.beforeId)
@@ -552,13 +593,17 @@ function applyOne(op, journal) {
552
593
  if (fromParent) fromParent.insertBefore(op.node, fromNext && fromNext.parentNode === fromParent ? fromNext : null)
553
594
  else if (op.node.parentNode === op.parent) op.parent.removeChild(op.node)
554
595
  })
555
- return
596
+ if (op.node.nodeType !== 3) return
597
+ return { node: op.node, offset: op.node.data.length }
556
598
  }
557
599
  if (op.type === 'remove') {
558
600
  const parent = op.node.parentNode
559
601
  if (!parent) throw new Error('hyper-undo: replay target is no longer attached')
560
602
  const next = op.node.nextSibling
603
+ const index = Array.prototype.indexOf.call(parent.childNodes, op.node)
561
604
  parent.removeChild(op.node)
562
605
  journal.push(() => parent.insertBefore(op.node, next))
606
+ if (op.node.nodeType !== 3) return
607
+ return { node: parent, offset: index }
563
608
  }
564
609
  }
package/src/scope.js CHANGED
@@ -1,7 +1,8 @@
1
1
  import { recordsToPrimitives } from './primitives.js'
2
- import { compileCertificate, preflight, applyOperations, described } from './replay-plan.js'
2
+ import { compileCertificate, preflight, applyOperations, described, sameContent } from './replay-plan.js'
3
3
  import { shouldIgnore } from './filter.js'
4
4
  import { createEmitter } from './emitter.js'
5
+ import { watchTyping } from './typing.js'
5
6
 
6
7
  const DEFAULTS = {
7
8
  maxHistory: 100,
@@ -11,6 +12,9 @@ const DEFAULTS = {
11
12
  ignoreAttribute: null,
12
13
  isProtected: null,
13
14
  ignoreNode: null,
15
+ durableForm: null,
16
+ typingBreak: null,
17
+ refused: null,
14
18
  debug: false,
15
19
  }
16
20
 
@@ -32,6 +36,10 @@ export function createScope(opts) {
32
36
  let recordingMode = config.recording === 'commands' ? 'commands' : 'automatic'
33
37
  let capture = null // { records, primitives } while an explicit command runs
34
38
  let replaying = false // true only inside an undo/redo preflight and apply
39
+ let tracked = new WeakMap() // root element -> { prop, read, write, baseline }
40
+ let composing = false // an input method composition is open
41
+ let movedUnderStep = false // a paused writer changed what the open step changed
42
+ let typingCleanup = null // watchTyping's cleanup, kept for stop()
35
43
 
36
44
  function log(...args) { if (config.debug) console.log('[hyper-undo]', ...args) }
37
45
 
@@ -49,6 +57,15 @@ export function createScope(opts) {
49
57
  attributeOldValue: true,
50
58
  characterDataOldValue: true,
51
59
  })
60
+ typingCleanup = watchTyping(config.scope, {
61
+ sentence: config.typingBreak === 'sentence',
62
+ stepOpen: () => {
63
+ if (pauseDepth === 0 && observer) handleRecords(observer.takeRecords())
64
+ return pendingPrimitives.length > 0
65
+ },
66
+ endStep: flush,
67
+ composing: setComposing,
68
+ })
52
69
  log('started')
53
70
  }
54
71
 
@@ -74,6 +91,8 @@ export function createScope(opts) {
74
91
  if (!started) return
75
92
  started = false
76
93
  if (observer) { observer.disconnect(); observer = null }
94
+ if (typingCleanup) { typingCleanup(); typingCleanup = null }
95
+ composing = false
77
96
  clearIdleTimer()
78
97
  undoStack.length = 0
79
98
  redoStack.length = 0
@@ -82,6 +101,7 @@ export function createScope(opts) {
82
101
  capture = null
83
102
  replaying = false
84
103
  pauseDepth = 0 // reset so a reused (created) scope doesn't restart paused
104
+ tracked = new WeakMap()
85
105
  log('stopped')
86
106
  }
87
107
 
@@ -93,6 +113,70 @@ export function createScope(opts) {
93
113
  return !!(el && el.nodeType === 1 && el.isConnected === false)
94
114
  }
95
115
 
116
+ // --- Tracked content ---
117
+
118
+ function readContent(root, read) {
119
+ try { const value = read(root); return typeof value === 'string' ? value : null } catch (_) { return null }
120
+ }
121
+
122
+ function trackContent(root, { read, write, prop = 'content' } = {}) {
123
+ if (!root || typeof read !== 'function' || typeof write !== 'function') return () => {}
124
+ const state = { prop, read, write, baseline: readContent(root, read) }
125
+ tracked.set(root, state)
126
+ return () => { if (tracked.get(root) === state) tracked.delete(root) }
127
+ }
128
+
129
+ function trackedRootOf(node) {
130
+ for (let n = node; n; n = n.parentNode) if (tracked.has(n)) return n
131
+ return null
132
+ }
133
+
134
+ function isTrackedContent(record) {
135
+ const root = trackedRootOf(record.target)
136
+ return !!root && !(record.type === 'attributes' && record.target === root)
137
+ }
138
+
139
+ function contentRootsIn(records) {
140
+ const roots = new Set()
141
+ for (const record of records) if (isTrackedContent(record)) roots.add(trackedRootOf(record.target))
142
+ return roots
143
+ }
144
+
145
+ function sampleContent(roots) {
146
+ const out = []
147
+ for (const root of roots) {
148
+ const state = tracked.get(root)
149
+ const now = readContent(root, state.read)
150
+ if (now == null) continue
151
+ if (sameContent(now, state.baseline)) { state.baseline = now; continue }
152
+ if (state.baseline != null) out.push({ kind: 'value', target: root, prop: state.prop, oldValue: state.baseline, newValue: now, read: state.read, write: state.write, content: true })
153
+ state.baseline = now
154
+ }
155
+ return out
156
+ }
157
+
158
+ function rebaseContent(roots) {
159
+ for (const root of roots) {
160
+ const state = tracked.get(root)
161
+ state.baseline = readContent(root, state.read)
162
+ }
163
+ }
164
+
165
+ // Records another writer made while this scope was paused are dropped. When one of
166
+ // them changed a text node, an attribute or a tracked field the open step has already
167
+ // changed, the step has to close before this tab types again: joined with that typing
168
+ // it would run from before the other writer's change to after it, and its undo would
169
+ // take their change back.
170
+ function dropPaused(records) {
171
+ const roots = contentRootsIn(records)
172
+ if (!movedUnderStep && pendingPrimitives.some((p) => p.kind === 'value'
173
+ ? roots.has(p.target)
174
+ : records.some((r) => r.target === p.target && (r.type === 'characterData' ? p.kind === 'text' : r.type === 'attributes' && r.attributeName === p.name)))) {
175
+ movedUnderStep = true
176
+ }
177
+ rebaseContent(roots)
178
+ }
179
+
96
180
  // --- Observer callback ---
97
181
 
98
182
  // The record source's callback. It hands over its own instance as a second
@@ -115,9 +199,10 @@ export function createScope(opts) {
115
199
  // Commands mode: an unbracketed record is some other writer's (a merge, a
116
200
  // sap paint, a sortable gesture). It changes the document, so later guards
117
201
  // see it, but it is not a local action and earns no entry.
118
- if (recordingMode === 'commands') return
119
- if (pauseDepth > 0) return
202
+ if (recordingMode === 'commands') { rebaseContent(contentRootsIn(records)); return }
203
+ if (pauseDepth > 0) { dropPaused(records); return }
120
204
  const prims = convertRecords(dropIgnoredRecords(records), bracketed)
205
+ for (const p of sampleContent(contentRootsIn(records))) prims.push(p)
121
206
  if (prims.length > 0) {
122
207
  for (const p of prims) pendingPrimitives.push(p)
123
208
  restartIdleTimer()
@@ -154,15 +239,28 @@ export function createScope(opts) {
154
239
  // what it wrote there is one entry, so it may own the scope document's head
155
240
  // fields too; head's <title> stays with the bracket's own title capture.
156
241
  function convertRecords(records, bracketed = false) {
157
- const kept = records.filter((r) => (observedHere(r.target) || (bracketed && inHead(r.target))) && !shouldIgnore(r.target, config.ignoreAttribute, r))
242
+ const removedHere = removedFromScope(records)
243
+ const kept = records.filter((r) => (observedHere(r.target) || removedHere(r.target) || (bracketed && inHead(r.target))) && !isTrackedContent(r) && !shouldIgnore(r.target, config.ignoreAttribute, r))
158
244
  return recordsToPrimitives(kept, (node) => shouldIgnore(node, config.ignoreAttribute))
159
245
  }
160
246
 
161
247
  // --- Idle batching ---
162
248
 
249
+ function setComposing(on) {
250
+ composing = !!on
251
+ if (!composing && pendingPrimitives.length > 0) restartIdleTimer()
252
+ }
253
+
163
254
  function restartIdleTimer() {
164
255
  clearIdleTimer()
165
- idleTimer = setTimeout(closeIdleBatch, config.idleWindowMs)
256
+ idleTimer = setTimeout(onIdle, config.idleWindowMs)
257
+ }
258
+ // A step never closes while a composition is open: the timer waits it out and
259
+ // the composed text lands in one entry.
260
+ function onIdle() {
261
+ idleTimer = null
262
+ if (composing) { idleTimer = setTimeout(onIdle, config.idleWindowMs); return }
263
+ closeIdleBatch()
166
264
  }
167
265
  function clearIdleTimer() {
168
266
  if (idleTimer != null) { clearTimeout(idleTimer); idleTimer = null }
@@ -178,7 +276,7 @@ export function createScope(opts) {
178
276
  // --- Commit machinery ---
179
277
 
180
278
  function pushCommit(label, primitives) {
181
- const certificate = compileCertificate(primitives, { isIgnored: isIgnoredNode, ignoreAttribute: isIgnoredAttribute })
279
+ const certificate = compileCertificate(primitives, { isIgnored: isIgnoredNode, ignoreAttribute: isIgnoredAttribute, durableForm: config.durableForm })
182
280
  if (certificate.steps.length === 0) {
183
281
  log('commit', label, 'with nothing to record')
184
282
  emitter.emit('commit')
@@ -253,20 +351,22 @@ export function createScope(opts) {
253
351
  // If an OUTER pause is still active (this capture frame is nested inside an
254
352
  // exclusion pause), honor the outer "exclude everything" contract: discard
255
353
  // the captured records instead of committing through the outer pause.
256
- if (pauseDepth > 1) { observer.takeRecords(); return }
354
+ if (pauseDepth > 1) { rebaseContent(contentRootsIn(observer.takeRecords())); return }
257
355
  // Close any pending idle batch (e.g. in-progress typing captured BEFORE the
258
356
  // pause) as its own commit FIRST, so the structural commit we're about to
259
357
  // push lands AFTER it in history. Without this, type-then-immediately-add
260
358
  // would record the structural op before the typing, inverting undo order.
261
359
  flush()
262
- const primitives = convertRecords(observer.takeRecords(), true)
360
+ const records = observer.takeRecords()
361
+ const primitives = convertRecords(records, true)
362
+ for (const p of sampleContent(contentRootsIn(records))) primitives.push(p)
263
363
  if (primitives.length === 0) return
264
364
  clearIdleTimer()
265
365
  pushCommit(label, primitives)
266
366
  }
267
367
 
268
368
  function discardCaptured() {
269
- if (observer) observer.takeRecords()
369
+ if (observer) rebaseContent(contentRootsIn(observer.takeRecords()))
270
370
  }
271
371
 
272
372
  // --- Explicit commands ---
@@ -332,6 +432,7 @@ export function createScope(opts) {
332
432
  capture = null
333
433
  if (!held) return []
334
434
  const primitives = convertRecords(held.records, true)
435
+ for (const p of sampleContent(contentRootsIn(held.records))) primitives.push(p)
335
436
  for (const p of held.primitives) primitives.push(p)
336
437
  return primitives
337
438
  }
@@ -464,7 +565,14 @@ export function createScope(opts) {
464
565
  if (pauseDepth === 0) return // underflow guard
465
566
  pauseDepth--
466
567
  // Discard records captured during the pause only on the outermost release.
467
- if (pauseDepth === 0 && observer) observer.takeRecords()
568
+ if (pauseDepth === 0 && observer) {
569
+ dropPaused(observer.takeRecords())
570
+ if (movedUnderStep) {
571
+ movedUnderStep = false
572
+ clearIdleTimer()
573
+ closeIdleBatch()
574
+ }
575
+ }
468
576
  log('resumed', pauseDepth)
469
577
  }
470
578
 
@@ -473,51 +581,88 @@ export function createScope(opts) {
473
581
  // One entry is applied whole or not at all. Preflight compares the certificate
474
582
  // against the live document without writing; only a fully validated plan
475
583
  // reaches the DOM, and a native failure rolls it back and keeps both stacks.
584
+ // With `refused: 'skip'` a refused entry is dropped and the call goes on to the
585
+ // next one, so a step that can no longer be replayed never blocks older ones.
476
586
  function replay(direction, requestedId) {
477
587
  if (scopeDisconnected()) { stop(); return { ok: false, code: 'disconnected' } }
588
+ // A consumer can hold replay (an open gesture, a pending save) without
589
+ // refusing the entry: the stacks and the document stay as they are.
590
+ if (typeof config.holdReplay === 'function' && config.holdReplay()) return { ok: false, code: 'held' }
478
591
  flush()
479
592
  const source = direction === 'undo' ? undoStack : redoStack
480
593
  const destination = direction === 'undo' ? redoStack : undoStack
481
- const entry = source.at(-1)
594
+ const skipped = []
595
+ let entry = source.at(-1)
482
596
  if (!entry) return { ok: false, code: 'empty' }
483
597
  if (requestedId && requestedId !== entry.id) {
484
598
  const requested = findEntry(requestedId) || entry
485
599
  return refuse(requested, direction, 'stale-entry')
486
600
  }
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)
501
- pause()
502
- let applied
503
- replaying = true
504
- try {
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')
601
+ const canSkip = config.refused === 'skip' && !requestedId
602
+ while (entry) {
603
+ let checked
604
+ replaying = true
605
+ try {
606
+ checked = preflight(entry.certificate, direction, {
607
+ isIgnored: isIgnoredNode,
608
+ ignoreAttribute: isIgnoredAttribute,
609
+ durableForm: config.durableForm,
610
+ isProtected: isProtectedNode,
611
+ inScope,
612
+ liveNodesWithIds,
613
+ })
614
+ } finally {
615
+ replaying = false
616
+ }
617
+ if (!checked.ok) {
618
+ if (!canSkip) return refuse(entry, direction, checked.code, checked.targets)
619
+ skipEntry(entry, direction, checked.code, checked.targets, skipped)
620
+ entry = source.at(-1)
621
+ continue
622
+ }
623
+ pause()
624
+ let applied
625
+ replaying = true
626
+ try {
627
+ applied = applyOperations(checked.operations)
628
+ } finally {
629
+ replaying = false
630
+ resume()
631
+ }
632
+ if (!applied.ok) {
633
+ log(`${direction} failed and was rolled back`, applied.error)
634
+ if (!canSkip) return refuse(entry, direction, 'replay-error')
635
+ skipEntry(entry, direction, 'replay-error', [], skipped)
636
+ entry = source.at(-1)
637
+ continue
638
+ }
639
+ rebaseContent(new Set(checked.operations.filter((op) => op.type === 'value' && tracked.has(op.target)).map((op) => op.target)))
640
+ source.pop()
641
+ destination.push(entry)
642
+ refusedTop = null
643
+ const receipt = { ok: true, entryId: entry.id, label: entry.label, direction }
644
+ if (skipped.length) receipt.skipped = skipped
645
+ if (applied.caret) receipt.caret = applied.caret
646
+ log(direction, entry.label)
647
+ emitter.emit(direction, receipt)
648
+ return receipt
513
649
  }
514
- source.pop()
515
- destination.push(entry)
650
+ return { ok: false, code: 'empty', direction, skipped }
651
+ }
652
+
653
+ // Drop a refused entry in the same press. Its description is built while it is
654
+ // still on its stack (describe() reads refusedTop), so a consumer can keep a
655
+ // recovery copy of what the removed entry would have restored.
656
+ function skipEntry(entry, direction, code, targets, skipped) {
657
+ refusedTop = { entryId: entry.id, direction, code, targets }
658
+ const description = describe(entry.id)
659
+ const stack = direction === 'undo' ? undoStack : redoStack
660
+ stack.pop()
516
661
  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
662
+ const record = { entryId: entry.id, label: entry.label, direction, code, targets, description }
663
+ log('skipped', direction, code)
664
+ emitter.emit('skipped', record)
665
+ skipped.push(record)
521
666
  }
522
667
 
523
668
  function undo(opts) { return replay('undo', opts && opts.entryId) }
@@ -572,6 +717,15 @@ export function createScope(opts) {
572
717
  }
573
718
  }
574
719
 
720
+ // A host's durable form is stored as #form:"<json string>"; a description shows the
721
+ // string itself, not the token.
722
+ function readableForm(content) {
723
+ if (typeof content !== 'string') return null
724
+ return content.replace(/#form:("(?:[^"\\]|\\.)*")/g, (_, json) => {
725
+ try { return JSON.parse(json) } catch (_) { return '' }
726
+ })
727
+ }
728
+
575
729
  function describeStep(step) {
576
730
  if (step.type === 'move') {
577
731
  return {
@@ -587,7 +741,7 @@ export function createScope(opts) {
587
741
  return {
588
742
  kind: step.type === 'add' ? 'add' : 'remove',
589
743
  parent: { id: described(step.parent).id },
590
- nodes: step.nodes.map((node, i) => ({ id: described(node).id, html: step.content[i] ?? null })),
744
+ nodes: step.nodes.map((node, i) => ({ id: described(node).id, html: readableForm(step.content[i]) })),
591
745
  }
592
746
  }
593
747
  const primitive = step.primitive
@@ -604,9 +758,34 @@ export function createScope(opts) {
604
758
  return { kind: step.type, target, before, after }
605
759
  }
606
760
 
761
+ // A subtree removed from the scope in this batch is detached by the time the
762
+ // batch is read, yet what happened inside it before the removal (a child moved
763
+ // out of it, say) belongs to the same edit: dropping it would restore the
764
+ // subtree as it was left, not as it was.
765
+ function removedFromScope(records) {
766
+ const removed = new Set()
767
+ let grew = true
768
+ while (grew) {
769
+ grew = false
770
+ for (const r of records) {
771
+ if (r.type !== 'childList' || !r.removedNodes?.length) continue
772
+ if (isTrackedContent(r)) continue
773
+ if (!observedHere(r.target) && !insideRemoved(r.target, removed)) continue
774
+ if (shouldIgnore(r.target, config.ignoreAttribute, r)) continue
775
+ for (const node of r.removedNodes) if (!removed.has(node)) { removed.add(node); grew = true }
776
+ }
777
+ }
778
+ return (node) => !observedHere(node) && insideRemoved(node, removed)
779
+ }
780
+
781
+ function insideRemoved(node, removed) {
782
+ for (let n = node; n; n = n.parentNode) if (removed.has(n)) return true
783
+ return false
784
+ }
785
+
607
786
  function observedHere(node) {
608
787
  const root = config.scope
609
- if (!root || !node) return false
788
+ if (!root || !node || typeof node.nodeType !== 'number') return false
610
789
  return node === root || (typeof root.contains === 'function' && root.contains(node))
611
790
  }
612
791
 
@@ -653,7 +832,7 @@ export function createScope(opts) {
653
832
  // Discard any same-tick buffered records too, so a bootstrap mutation
654
833
  // followed by clear() in the same turn doesn't resurface as a commit once
655
834
  // the observer callback fires.
656
- if (observer) observer.takeRecords()
835
+ if (observer) rebaseContent(contentRootsIn(observer.takeRecords()))
657
836
  undoStack.length = 0
658
837
  redoStack.length = 0
659
838
  pendingPrimitives = []
@@ -667,10 +846,12 @@ export function createScope(opts) {
667
846
 
668
847
  return {
669
848
  _config: config, // private: used by keys.js to read shadowKeydownIn
849
+ _tracksContentAt: (node) => recordingMode !== 'commands' && !!trackedRootOf(node), // private: keys.js rule 4
850
+ _recordsAt: (node) => recordingMode !== 'commands' && !isIgnoredNode(node), // private: keys.js rule 4
670
851
  start, stop,
671
852
  commit, commitCaptured, discardCaptured, flush,
672
853
  command, setRecording,
673
- record, recordValue,
854
+ record, recordValue, trackContent,
674
855
  undo, redo, clear, discard, describe,
675
856
  pause, resume,
676
857
  on: emitter.on, off: emitter.off,