hyper-undo 0.6.4 → 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.
@@ -0,0 +1,564 @@
1
+ // Whole-entry certificates and symmetric atomic preflight.
2
+ //
3
+ // An entry (one command or one idle batch) is compiled into a private
4
+ // certificate when it closes: normalized scalar endpoints, structural steps
5
+ // with the ordered child identities of every affected container before and
6
+ // after the step, and an immutable durable projection of every subtree the
7
+ // entry would delete or restore.
8
+ //
9
+ // Undo and redo then run that certificate in the opposite direction against the
10
+ // live document BEFORE touching it. Preflight never writes; it returns the
11
+ // concrete native operations to apply, or the first reason the entry can no
12
+ // longer be applied safely. Apply runs those operations with a rollback
13
+ // journal, so an unexpected DOM failure restores what it already did.
14
+
15
+ import { normalizePrimitives } from './primitives.js'
16
+
17
+ const identities = new WeakMap()
18
+ let identityCount = 0
19
+
20
+ // Live-node identity. In-session replay always addresses the node the entry
21
+ // recorded; a same-data-id replacement is a different node and stays unequal.
22
+ export function identityOf(node) {
23
+ let id = identities.get(node)
24
+ if (!id) { id = `n${++identityCount}`; identities.set(node, id) }
25
+ return id
26
+ }
27
+
28
+ function isNode(value) {
29
+ return !!value && typeof value === 'object' && typeof value.nodeType === 'number'
30
+ }
31
+
32
+ function isStructural(p) {
33
+ return p.kind === 'add' || p.kind === 'remove'
34
+ }
35
+
36
+ function childIdentities(parent, isIgnored) {
37
+ const out = []
38
+ const children = parent.childNodes
39
+ if (!children) return out
40
+ for (const child of children) {
41
+ if (isIgnored && isIgnored(child)) continue
42
+ out.push(identityOf(child))
43
+ }
44
+ return out
45
+ }
46
+
47
+ // Immutable durable content of a subtree: every attribute, every text run and
48
+ // the exact child order. Deliberately strict — a field the recorder does not
49
+ // track is still part of what an undo would erase. The one exception is a
50
+ // predicate for attributes the scope never records, on any node of the subtree:
51
+ // a value the scope ignores must not make the entry refuse to replay. A subtree
52
+ // region the scope ignores is skipped whole, like an ignored attribute.
53
+ // `host` is the element a removed subtree belongs under. A detached node has no
54
+ // ancestors left, so a predicate that reads them (is this attribute painted by an
55
+ // app above it?) receives the host and answers as it did while attached.
56
+ export function durableProjection(node, ignoredAttribute, host = null, ignoredNode = null) {
57
+ if (!isNode(node)) return null
58
+ if (node.nodeType === 3) return `#text:${JSON.stringify(node.data)}`
59
+ if (node.nodeType === 8) return `#comment:${JSON.stringify(node.data)}`
60
+ if (node.nodeType !== 1) return `#node:${node.nodeType}`
61
+ const attributes = []
62
+ if (node.attributes) {
63
+ for (const attr of node.attributes) {
64
+ if (isIgnoredAttribute(attr.name, node, ignoredAttribute, host)) continue
65
+ attributes.push(`${attr.name}=${JSON.stringify(attr.value)}`)
66
+ }
67
+ attributes.sort()
68
+ }
69
+ const children = []
70
+ if (node.childNodes) {
71
+ for (const child of node.childNodes) {
72
+ if (isIgnoredNode(child, ignoredNode)) continue
73
+ children.push(durableProjection(child, ignoredAttribute, host, ignoredNode))
74
+ }
75
+ }
76
+ const tag = String(node.tagName || '').toLowerCase()
77
+ return `<${tag}${attributes.length ? ` ${attributes.join(' ')}` : ''}>${children.join('')}</>`
78
+ }
79
+
80
+ function durableIdsIn(node, out = []) {
81
+ if (!isNode(node)) return out
82
+ if (node.nodeType === 1) {
83
+ const id = node.getAttribute && node.getAttribute('data-id')
84
+ if (id) out.push(id)
85
+ }
86
+ if (node.childNodes) for (const child of node.childNodes) durableIdsIn(child, out)
87
+ return out
88
+ }
89
+
90
+ function isIgnoredAttribute(name, node, predicate, host = null) {
91
+ if (typeof predicate !== 'function') return false
92
+ try { return !!predicate(name, node, host) } catch (_) { return false }
93
+ }
94
+
95
+ function isIgnoredNode(node, predicate) {
96
+ if (typeof predicate !== 'function') return false
97
+ try { return !!predicate(node) } catch (_) { return false }
98
+ }
99
+
100
+ function protectedWithin(nodes, isProtected) {
101
+ if (typeof isProtected !== 'function') return []
102
+ const found = []
103
+ const visit = (node) => {
104
+ if (!isNode(node)) return
105
+ let hit = false
106
+ try { hit = !!isProtected(node) } catch (_) { hit = false }
107
+ if (hit) found.push(described(node, 'protected'))
108
+ if (node.childNodes) for (const child of node.childNodes) visit(child)
109
+ }
110
+ for (const node of nodes) visit(node)
111
+ return found
112
+ }
113
+
114
+ export function described(node, kind, parent = null) {
115
+ let host = node
116
+ if (host && host.nodeType !== 1) host = host.parentElement
117
+ let owner = host && typeof host.closest === 'function' ? host.closest('[data-id]') : null
118
+ if (!owner && parent && typeof parent.closest === 'function') owner = parent.closest('[data-id]')
119
+ const id = owner ? owner.getAttribute('data-id') : null
120
+ return { id: id || null, kind: kind || null }
121
+ }
122
+
123
+ function targetsOf(nodes, kind, parent = null) {
124
+ return (nodes || []).filter(isNode).map((node) => described(node, kind, parent))
125
+ }
126
+
127
+ function unsupported(reason) {
128
+ return { replayable: false, reason, steps: [] }
129
+ }
130
+
131
+ // Where a structural primitive's recorded slot sits in `list`, following the
132
+ // same anchor priority the primitive itself uses. null means the recorded slot
133
+ // cannot be located: the certificate must not guess a position.
134
+ function slotIndex(list, primitive) {
135
+ const candidates = [
136
+ [primitive.afterAuthored, (at) => at],
137
+ [primitive.beforeAuthored, (at) => at + 1],
138
+ [primitive.before, (at) => at],
139
+ ]
140
+ let recorded = false
141
+ for (const [anchor, place] of candidates) {
142
+ if (!isNode(anchor)) continue
143
+ recorded = true
144
+ const at = list.indexOf(identityOf(anchor))
145
+ if (at >= 0) return place(at)
146
+ }
147
+ if (recorded) return null
148
+ return list.length
149
+ }
150
+
151
+ // Compile one entry. `isIgnored` is the scope's own region filter, so runtime
152
+ // UI and no-undo shells never enter a container's identity sequence.
153
+ export function compileCertificate(primitives, env = {}) {
154
+ const isIgnored = env.isIgnored || null
155
+ const ignoredAttribute = env.ignoreAttribute || null
156
+ const normalized = normalizePrimitives(primitives)
157
+
158
+ const structural = []
159
+ for (const p of normalized) {
160
+ if (!isStructural(p)) {
161
+ if (!isNode(p.target)) return unsupported('non-dom-target')
162
+ continue
163
+ }
164
+ if (!isNode(p.parent)) return unsupported('non-dom-parent')
165
+ const nodes = Array.from(p.nodes || []).filter(isNode)
166
+ if (nodes.length === 0) continue
167
+ if (nodes.length !== Array.from(p.nodes || []).length) return unsupported('non-dom-node')
168
+ structural.push({ primitive: p, nodes, parent: p.parent })
169
+ }
170
+ if (ambiguousReorder(structural)) return unsupported('in-record-move')
171
+
172
+ const states = sequenceStates(structural, isIgnored)
173
+ if (!states) return unsupported('ambiguous-structure')
174
+
175
+ const paired = pairMoves(structural, states)
176
+ const byPrimitive = new Map(structural.map((item) => [item.primitive, item]))
177
+ const steps = []
178
+ for (const primitive of normalized) {
179
+ const item = byPrimitive.get(primitive)
180
+ if (!item) { steps.push(scalarStep(primitive)); continue }
181
+ const move = paired.get(item)
182
+ if (move) {
183
+ if (move.at === item) steps.push(move.step)
184
+ continue
185
+ }
186
+ steps.push(structuralStep(item, states.get(item), ignoredAttribute, isIgnored))
187
+ }
188
+ return { replayable: true, reason: null, steps }
189
+ }
190
+
191
+ // A node added and removed by the SAME childList record was reordered inside
192
+ // its parent. MutationRecord keeps only the final slot, so the entry's before
193
+ // boundary cannot be reconstructed: refuse the certificate instead of guessing.
194
+ function ambiguousReorder(structural) {
195
+ const seen = new Map() // record → Map(node → Set(kinds))
196
+ for (const item of structural) {
197
+ const record = item.primitive.record
198
+ if (!record) continue
199
+ let nodes = seen.get(record)
200
+ if (!nodes) { nodes = new Map(); seen.set(record, nodes) }
201
+ for (const node of item.nodes) {
202
+ let kinds = nodes.get(node)
203
+ if (!kinds) { kinds = new Set(); nodes.set(node, kinds) }
204
+ kinds.add(item.primitive.kind)
205
+ }
206
+ }
207
+ for (const nodes of seen.values()) {
208
+ for (const kinds of nodes.values()) if (kinds.size > 1) return true
209
+ }
210
+ return false
211
+ }
212
+
213
+ // Rebuild each container's ordered child identities backwards from the live
214
+ // (after) boundary, so every structural step knows both of its endpoints.
215
+ function sequenceStates(structural, isIgnored) {
216
+ const current = new Map()
217
+ for (const item of structural) {
218
+ if (!current.has(item.parent)) current.set(item.parent, childIdentities(item.parent, isIgnored))
219
+ }
220
+ const states = new Map()
221
+ for (let i = structural.length - 1; i >= 0; i--) {
222
+ const item = structural[i]
223
+ const list = current.get(item.parent)
224
+ if (!list) return null
225
+ const after = list.slice()
226
+ let before
227
+ if (item.primitive.kind === 'add') {
228
+ const ids = item.nodes.map(identityOf)
229
+ if (!ids.every((id) => list.includes(id))) return null
230
+ before = list.filter((id) => !ids.includes(id))
231
+ } else {
232
+ const at = slotIndex(after, item.primitive)
233
+ if (at == null) return null
234
+ before = after.slice()
235
+ before.splice(at, 0, ...item.nodes.map(identityOf))
236
+ }
237
+ states.set(item, { before, after })
238
+ current.set(item.parent, before)
239
+ }
240
+ return states
241
+ }
242
+
243
+ // A node removed from one slot and later inserted at another is a move, not a
244
+ // deletion plus an addition: undoing a move places the same live node back, so
245
+ // a peer's edit to that card does not have to be reverted to allow it.
246
+ function pairMoves(structural, states) {
247
+ const removes = new Map()
248
+ const adds = new Map()
249
+ structural.forEach((item, index) => {
250
+ for (const node of item.nodes) {
251
+ const key = identityOf(node)
252
+ if (item.primitive.kind === 'remove') removes.set(key, { item, index, node })
253
+ else adds.set(key, { item, index, node })
254
+ }
255
+ })
256
+
257
+ const moves = new Map()
258
+ for (const [key, removal] of removes) {
259
+ const addition = adds.get(key)
260
+ if (!addition || addition.index < removal.index) continue
261
+ if (removal.item.nodes.length !== 1 || addition.item.nodes.length !== 1) continue
262
+ const step = {
263
+ type: 'move',
264
+ node: removal.node,
265
+ sourceParent: removal.item.parent,
266
+ destParent: addition.item.parent,
267
+ source: states.get(removal.item),
268
+ dest: states.get(addition.item),
269
+ sourceSlot: removal.item.primitive,
270
+ destSlot: addition.item.primitive,
271
+ targets: [described(removal.node, 'move')],
272
+ }
273
+ moves.set(removal.item, { step, at: addition.item })
274
+ moves.set(addition.item, { step, at: addition.item })
275
+ }
276
+ return moves
277
+ }
278
+
279
+ function structuralStep(item, state, ignoredAttribute, ignoredNode) {
280
+ const kind = item.primitive.kind
281
+ return {
282
+ type: kind === 'add' ? 'add' : 'restore',
283
+ parent: item.parent,
284
+ nodes: item.nodes,
285
+ seqBefore: state.before,
286
+ seqAfter: state.after,
287
+ slot: item.primitive,
288
+ content: item.nodes.map((node) => durableProjection(node, ignoredAttribute, item.parent, ignoredNode)),
289
+ ids: kind === 'remove' ? item.nodes.flatMap((node) => durableIdsIn(node)) : [],
290
+ targets: targetsOf(item.nodes, kind === 'add' ? 'added' : 'removed', item.parent),
291
+ }
292
+ }
293
+
294
+ function scalarStep(primitive) {
295
+ return {
296
+ type: primitive.kind,
297
+ primitive,
298
+ targets: [described(primitive.target, primitive.name || primitive.prop || 'text')],
299
+ }
300
+ }
301
+
302
+ export function preflight(certificate, direction, env = {}) {
303
+ if (!certificate || !certificate.replayable) return { ok: false, code: 'unsupported', targets: [] }
304
+
305
+ const context = {
306
+ isIgnored: env.isIgnored || null,
307
+ ignoreAttribute: env.ignoreAttribute || null,
308
+ isProtected: env.isProtected || null,
309
+ inScope: env.inScope || (() => true),
310
+ liveNodesWithIds: env.liveNodesWithIds || (() => []),
311
+ sim: new Map(),
312
+ planned: new Map(),
313
+ operations: [],
314
+ }
315
+
316
+ for (let i = 0; i < certificate.steps.length; i++) {
317
+ const step = certificate.steps[direction === 'undo' ? certificate.steps.length - 1 - i : i]
318
+ const refusal = checkStep(step, direction, context)
319
+ if (refusal) return { ok: false, code: refusal.code, targets: refusal.targets }
320
+ }
321
+ return { ok: true, operations: context.operations }
322
+ }
323
+
324
+ function listFor(context, parent) {
325
+ if (!context.sim.has(parent)) context.sim.set(parent, childIdentities(parent, context.isIgnored))
326
+ return context.sim.get(parent)
327
+ }
328
+
329
+ function inPlannedScope(node, context) {
330
+ for (let n = node; n; n = n.parentNode) {
331
+ if (context.planned.has(n)) return context.planned.get(n)
332
+ }
333
+ return context.inScope(node)
334
+ }
335
+
336
+ // A duplicate identity is one that would still be live when this step inserts
337
+ // its own node: a node an earlier step of the same replay removes (or one under
338
+ // it) is leaving, so it does not collide.
339
+ function liveDuplicates(ids, context) {
340
+ return context.liveNodesWithIds(ids).filter((node) => inPlannedScope(node, context)).length > 0
341
+ }
342
+
343
+ function refuseStep(code, targets) {
344
+ return { code, targets: targets || [] }
345
+ }
346
+
347
+ function liveValue(primitive) {
348
+ if (primitive.kind === 'text') return primitive.target.data
349
+ if (primitive.kind === 'value') {
350
+ return typeof primitive.read === 'function' ? primitive.read(primitive.target) : primitive.target[primitive.prop || 'value']
351
+ }
352
+ return primitive.target.getAttribute(primitive.name)
353
+ }
354
+
355
+ function checkStep(step, direction, context) {
356
+ const undoing = direction === 'undo'
357
+ if (step.type === 'move') return checkMove(step, undoing, context)
358
+ if (step.type === 'add') return checkAdd(step, undoing, context)
359
+ if (step.type === 'restore') return checkRestore(step, undoing, context)
360
+ return checkScalar(step, undoing, context)
361
+ }
362
+
363
+ function checkScalar(step, undoing, context) {
364
+ const primitive = step.primitive
365
+ const expected = undoing ? primitive.newValue : primitive.oldValue
366
+ const next = undoing ? primitive.oldValue : primitive.newValue
367
+ if (!isNode(primitive.target)) return refuseStep('unsupported', step.targets)
368
+ if (!inPlannedScope(primitive.target, context)) return refuseStep('target-detached', step.targets)
369
+ if (primitive.kind === 'value' && typeof primitive.write === 'function' && typeof primitive.read !== 'function') {
370
+ return refuseStep('unsupported', step.targets)
371
+ }
372
+ if (liveValue(primitive) !== expected) return refuseStep('content-changed', step.targets)
373
+ 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 })
375
+ } else if (primitive.kind === 'text') {
376
+ context.operations.push({ type: 'text', target: primitive.target, data: next })
377
+ } else {
378
+ context.operations.push({ type: 'attr', target: primitive.target, name: primitive.name, value: next == null ? null : next })
379
+ }
380
+ return null
381
+ }
382
+
383
+ function checkAdd(step, undoing, context) {
384
+ if (!isNode(step.parent) || !context.inScope(step.parent)) return refuseStep('target-detached', step.targets)
385
+ if (undoing) {
386
+ if (!sameSequence(listFor(context, step.parent), step.seqAfter)) return refuseStep('structure-changed', step.targets)
387
+ const protectedTargets = protectedWithin(step.nodes, context.isProtected)
388
+ if (protectedTargets.length > 0) return refuseStep('protected', protectedTargets)
389
+ 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)
391
+ }
392
+ for (const node of step.nodes) {
393
+ context.operations.push({ type: 'remove', node })
394
+ context.planned.set(node, false)
395
+ }
396
+ context.sim.set(step.parent, step.seqBefore.slice())
397
+ return null
398
+ }
399
+ if (!sameSequence(listFor(context, step.parent), step.seqBefore)) return refuseStep('structure-changed', step.targets)
400
+ for (const node of step.nodes) {
401
+ if (node.parentNode) return refuseStep('structure-changed', step.targets)
402
+ }
403
+ const ids = step.nodes.flatMap((node) => durableIdsIn(node))
404
+ if (ids.length > 0 && liveDuplicates(ids, context)) return refuseStep('duplicate-id', step.targets)
405
+ const at = slotIndex(step.seqBefore, step.slot)
406
+ if (at == null) return refuseStep('unsupported', step.targets)
407
+ let inserted = step.seqBefore.slice()
408
+ let cursor = at
409
+ for (const node of step.nodes) {
410
+ context.operations.push({ type: 'insert', parent: step.parent, node, beforeId: inserted[cursor] ?? null })
411
+ inserted = inserted.slice(0, cursor).concat(identityOf(node), inserted.slice(cursor))
412
+ cursor += 1
413
+ context.planned.set(node, true)
414
+ }
415
+ context.sim.set(step.parent, step.seqAfter.slice())
416
+ return null
417
+ }
418
+
419
+ function checkRestore(step, undoing, context) {
420
+ if (!isNode(step.parent) || !context.inScope(step.parent)) return refuseStep('target-detached', step.targets)
421
+ if (undoing) {
422
+ if (!sameSequence(listFor(context, step.parent), step.seqAfter)) return refuseStep('structure-changed', step.targets)
423
+ for (let i = 0; i < step.nodes.length; i++) {
424
+ 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)
426
+ }
427
+ if (step.ids.length > 0 && liveDuplicates(step.ids, context)) return refuseStep('duplicate-id', step.targets)
428
+ const at = slotIndex(step.seqAfter, step.slot)
429
+ if (at == null) return refuseStep('unsupported', step.targets)
430
+ let restored = step.seqAfter.slice()
431
+ let cursor = at
432
+ for (const node of step.nodes) {
433
+ context.operations.push({ type: 'insert', parent: step.parent, node, beforeId: restored[cursor] ?? null })
434
+ restored = restored.slice(0, cursor).concat(identityOf(node), restored.slice(cursor))
435
+ cursor += 1
436
+ context.planned.set(node, true)
437
+ }
438
+ context.sim.set(step.parent, step.seqBefore.slice())
439
+ return null
440
+ }
441
+ if (!sameSequence(listFor(context, step.parent), step.seqBefore)) return refuseStep('structure-changed', step.targets)
442
+ const protectedTargets = protectedWithin(step.nodes, context.isProtected)
443
+ if (protectedTargets.length > 0) return refuseStep('protected', protectedTargets)
444
+ for (let i = 0; i < step.nodes.length; i++) {
445
+ 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)
447
+ }
448
+ for (const node of step.nodes) {
449
+ context.operations.push({ type: 'remove', node })
450
+ context.planned.set(node, false)
451
+ }
452
+ context.sim.set(step.parent, step.seqAfter.slice())
453
+ return null
454
+ }
455
+
456
+ function withoutIdentity(list, id) {
457
+ const at = list.indexOf(id)
458
+ if (at < 0) return null
459
+ return list.slice(0, at).concat(list.slice(at + 1))
460
+ }
461
+
462
+ function checkMove(step, undoing, context) {
463
+ const nodeId = identityOf(step.node)
464
+ const fromParent = undoing ? step.destParent : step.sourceParent
465
+ const toParent = undoing ? step.sourceParent : step.destParent
466
+ const fromList = listFor(context, fromParent)
467
+ const toList = listFor(context, toParent)
468
+ const expectedFrom = undoing ? step.dest.after : step.source.before
469
+ const gap = undoing ? step.source.after : step.dest.before
470
+ const slot = undoing ? step.sourceSlot : step.destSlot
471
+ 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)
473
+ if (!sameSequence(fromList, expectedFrom)) return refuseStep('structure-changed', step.targets)
474
+ const toGap = fromParent === toParent ? withoutIdentity(toList, nodeId) : toList
475
+ if (!sameSequence(toGap, gap)) return refuseStep('structure-changed', step.targets)
476
+
477
+ const at = slotIndex(gap, slot)
478
+ if (at == null) return refuseStep('unsupported', step.targets)
479
+ context.operations.push({ type: 'insert', parent: toParent, node: step.node, beforeId: gap[at] ?? null })
480
+ context.planned.set(step.node, true)
481
+ if (undoing) {
482
+ context.sim.set(toParent, step.source.before.slice())
483
+ if (fromParent !== toParent) context.sim.set(fromParent, step.dest.before.slice())
484
+ } else {
485
+ context.sim.set(toParent, step.dest.after.slice())
486
+ if (fromParent !== toParent) context.sim.set(fromParent, step.source.after.slice())
487
+ }
488
+ return null
489
+ }
490
+
491
+ function sameSequence(a, b) {
492
+ if (!a || !b || a.length !== b.length) return false
493
+ for (let i = 0; i < a.length; i++) if (a[i] !== b[i]) return false
494
+ return true
495
+ }
496
+
497
+ function findChildByIdentity(parent, id) {
498
+ for (const child of parent.childNodes || []) {
499
+ if (identityOf(child) === id) return child
500
+ }
501
+ return null
502
+ }
503
+
504
+ function writeValue(op, value) {
505
+ if (typeof op.write === 'function') op.write(op.target, value)
506
+ else op.target[op.prop] = value
507
+ }
508
+
509
+ export function applyOperations(operations) {
510
+ const journal = []
511
+ try {
512
+ for (const op of operations) applyOne(op, journal)
513
+ return { ok: true }
514
+ } catch (error) {
515
+ for (let i = journal.length - 1; i >= 0; i--) {
516
+ try { journal[i]() } catch (_) { /* the rollback keeps trying the rest */ }
517
+ }
518
+ return { ok: false, error }
519
+ }
520
+ }
521
+
522
+ function applyOne(op, journal) {
523
+ if (op.type === 'attr') {
524
+ const previous = op.target.getAttribute(op.name)
525
+ if (op.value == null) op.target.removeAttribute(op.name)
526
+ else op.target.setAttribute(op.name, op.value)
527
+ journal.push(() => {
528
+ if (previous == null) op.target.removeAttribute(op.name)
529
+ else op.target.setAttribute(op.name, previous)
530
+ })
531
+ return
532
+ }
533
+ if (op.type === 'text') {
534
+ const previous = op.target.data
535
+ op.target.data = op.data
536
+ journal.push(() => { op.target.data = previous })
537
+ return
538
+ }
539
+ if (op.type === 'value') {
540
+ const previous = typeof op.read === 'function' ? op.read(op.target) : op.target[op.prop]
541
+ writeValue(op, op.value)
542
+ journal.push(() => writeValue(op, previous))
543
+ return
544
+ }
545
+ if (op.type === 'insert') {
546
+ const before = op.beforeId == null ? null : findChildByIdentity(op.parent, op.beforeId)
547
+ if (op.beforeId != null && !before) throw new Error('hyper-undo: replay anchor vanished')
548
+ const fromParent = op.node.parentNode
549
+ const fromNext = op.node.nextSibling
550
+ op.parent.insertBefore(op.node, before)
551
+ journal.push(() => {
552
+ if (fromParent) fromParent.insertBefore(op.node, fromNext && fromNext.parentNode === fromParent ? fromNext : null)
553
+ else if (op.node.parentNode === op.parent) op.parent.removeChild(op.node)
554
+ })
555
+ return
556
+ }
557
+ if (op.type === 'remove') {
558
+ const parent = op.node.parentNode
559
+ if (!parent) throw new Error('hyper-undo: replay target is no longer attached')
560
+ const next = op.node.nextSibling
561
+ parent.removeChild(op.node)
562
+ journal.push(() => parent.insertBefore(op.node, next))
563
+ }
564
+ }