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.
- package/CHANGELOG.md +26 -1
- package/README.md +75 -9
- package/dist/hyper-undo.min.js +1 -1
- package/package.json +1 -1
- package/src/index.js +12 -2
- package/src/keys.js +37 -0
- package/src/primitives.js +58 -2
- package/src/replay-plan.js +564 -0
- package/src/scope.js +441 -43
|
@@ -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
|
+
}
|