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.
- package/CHANGELOG.md +23 -0
- package/README.md +68 -2
- package/dist/hyper-undo.min.js +1 -1
- package/package.json +1 -1
- package/src/index.js +12 -1
- package/src/keys.js +55 -17
- package/src/primitives.js +2 -1
- package/src/replay-plan.js +65 -20
- package/src/scope.js +228 -47
- package/src/typing.js +142 -0
package/src/replay-plan.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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) || !
|
|
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) || !
|
|
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.
|
|
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 (!
|
|
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
|
-
|
|
513
|
-
|
|
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
|
|
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(
|
|
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
|
|
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)
|
|
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
|
|
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
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
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
|
-
|
|
515
|
-
|
|
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
|
|
518
|
-
log(direction,
|
|
519
|
-
emitter.emit(
|
|
520
|
-
|
|
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]
|
|
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,
|