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/typing.js
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
// Every listener sits on the scope root, in the capture phase: typing only ever happens
|
|
2
|
+
// inside it, and the window keeps only the history key bindings.
|
|
3
|
+
// A sentence end, a field switch, a change of intent, a command, a replaced
|
|
4
|
+
// selection and a caret jump each close the open typing step, so one undo press
|
|
5
|
+
// undoes one sentence of typing. The idle timer never closes a step while an
|
|
6
|
+
// input method composition is open.
|
|
7
|
+
|
|
8
|
+
const SENTENCE_END = /[.?!]["'\u201D\u2019)\]]*$/
|
|
9
|
+
const LINE_BREAKS = new Set(['insertParagraph', 'insertLineBreak'])
|
|
10
|
+
const ALONE = new Set([
|
|
11
|
+
'insertFromPaste',
|
|
12
|
+
'insertFromDrop',
|
|
13
|
+
'insertFromYank',
|
|
14
|
+
'insertReplacementText',
|
|
15
|
+
'deleteByCut',
|
|
16
|
+
'deleteByDrag',
|
|
17
|
+
])
|
|
18
|
+
const TYPING_KEYS = new Set(['Enter', 'Backspace', 'Delete'])
|
|
19
|
+
const NAV_KEYS = new Set([
|
|
20
|
+
'ArrowLeft',
|
|
21
|
+
'ArrowRight',
|
|
22
|
+
'ArrowUp',
|
|
23
|
+
'ArrowDown',
|
|
24
|
+
'Home',
|
|
25
|
+
'End',
|
|
26
|
+
'PageUp',
|
|
27
|
+
'PageDown',
|
|
28
|
+
])
|
|
29
|
+
|
|
30
|
+
export function endsSentence(textBefore) {
|
|
31
|
+
return SENTENCE_END.test(textBefore)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function watchTyping(root, { sentence, endStep, stepOpen = () => true, composing }) {
|
|
35
|
+
const view = root.ownerDocument && root.ownerDocument.defaultView
|
|
36
|
+
if (!view) return () => {}
|
|
37
|
+
const inside = (node) => !!node && typeof node.nodeType === 'number' && (node === root || root.contains(node))
|
|
38
|
+
let breakAfter = false
|
|
39
|
+
let jumped = false
|
|
40
|
+
let early = false
|
|
41
|
+
let last = { kind: 'other', host: null }
|
|
42
|
+
|
|
43
|
+
const kindOf = (type) => ALONE.has(type) || type.startsWith('format') ? 'alone'
|
|
44
|
+
: type.startsWith('delete') ? 'delete'
|
|
45
|
+
: type.startsWith('insert') ? 'insert' : 'other'
|
|
46
|
+
|
|
47
|
+
const hostOf = (target) => {
|
|
48
|
+
if (!target) return null
|
|
49
|
+
const el = target.nodeType === 1 ? target : target.parentElement
|
|
50
|
+
const host = el && el.closest && el.closest('[contenteditable]:not([contenteditable="false"])')
|
|
51
|
+
return host || target
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// A rich editor may cancel the line break and insert the paragraph itself, so
|
|
55
|
+
// no input event follows: the timer closes the step either way, and the flush
|
|
56
|
+
// takes the pending records synchronously.
|
|
57
|
+
const closeAfter = () => {
|
|
58
|
+
if (!breakAfter) return
|
|
59
|
+
breakAfter = false
|
|
60
|
+
endStep()
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const onBeforeInput = (event) => {
|
|
64
|
+
if (!sentence || event.isComposing || !inside(event.target)) return
|
|
65
|
+
const type = event.inputType || ''
|
|
66
|
+
const kind = kindOf(type)
|
|
67
|
+
const host = hostOf(event.target)
|
|
68
|
+
const selection = view.getSelection && view.getSelection()
|
|
69
|
+
const collapsed = !selection || selection.isCollapsed
|
|
70
|
+
const wasJumped = jumped
|
|
71
|
+
jumped = false
|
|
72
|
+
const wasEarly = early
|
|
73
|
+
early = false
|
|
74
|
+
if (
|
|
75
|
+
kind === 'alone' ||
|
|
76
|
+
(!wasEarly && kind !== last.kind && kind !== 'other' && last.kind !== 'other') ||
|
|
77
|
+
(!wasEarly && ((last.host !== null && host !== last.host) || !collapsed || wasJumped))
|
|
78
|
+
) {
|
|
79
|
+
endStep()
|
|
80
|
+
}
|
|
81
|
+
last = { kind, host }
|
|
82
|
+
if (kind === 'alone') {
|
|
83
|
+
breakAfter = true
|
|
84
|
+
setTimeout(closeAfter, 0)
|
|
85
|
+
return
|
|
86
|
+
}
|
|
87
|
+
const space = type === 'insertText' && (event.data === ' ' || event.data === '\u00A0')
|
|
88
|
+
if (!space && !LINE_BREAKS.has(type)) return
|
|
89
|
+
if (space && !stepOpen()) return
|
|
90
|
+
if (!endsSentence(textBeforeCaret(view))) return
|
|
91
|
+
breakAfter = true
|
|
92
|
+
setTimeout(closeAfter, 0)
|
|
93
|
+
}
|
|
94
|
+
const onInput = (event) => { if (inside(event.target)) closeAfter() }
|
|
95
|
+
const onStart = (event) => { if (inside(event.target)) composing(true) }
|
|
96
|
+
const onEnd = (event) => { if (inside(event.target)) composing(false) }
|
|
97
|
+
const onPointerDown = (event) => { if (inside(event.target)) jumped = true }
|
|
98
|
+
// A rich editor may write in its own keydown, before beforeinput: it deletes the
|
|
99
|
+
// selection the key replaces, and it tidies its markup on the first key. A caret jump,
|
|
100
|
+
// a field switch and a replaced selection close the step here, so those writes belong
|
|
101
|
+
// to the step the key starts.
|
|
102
|
+
const onKeyDown = (event) => {
|
|
103
|
+
if (!inside(event.target)) return
|
|
104
|
+
if (NAV_KEYS.has(event.key)) { jumped = true; return }
|
|
105
|
+
if (!sentence || event.isComposing || event.keyCode === 229 || event.ctrlKey || event.metaKey) return
|
|
106
|
+
if (!event.key || (event.key.length !== 1 && !TYPING_KEYS.has(event.key))) return
|
|
107
|
+
const selection = view.getSelection && view.getSelection()
|
|
108
|
+
const switched = last.host !== null && hostOf(event.target) !== last.host
|
|
109
|
+
if (!jumped && !switched && (!selection || selection.isCollapsed)) return
|
|
110
|
+
endStep()
|
|
111
|
+
jumped = false
|
|
112
|
+
early = true
|
|
113
|
+
setTimeout(() => { early = false }, 0)
|
|
114
|
+
}
|
|
115
|
+
root.addEventListener('beforeinput', onBeforeInput, true)
|
|
116
|
+
root.addEventListener('input', onInput, true)
|
|
117
|
+
root.addEventListener('compositionstart', onStart, true)
|
|
118
|
+
root.addEventListener('compositionend', onEnd, true)
|
|
119
|
+
root.addEventListener('pointerdown', onPointerDown, true)
|
|
120
|
+
root.addEventListener('keydown', onKeyDown, true)
|
|
121
|
+
return () => {
|
|
122
|
+
root.removeEventListener('beforeinput', onBeforeInput, true)
|
|
123
|
+
root.removeEventListener('input', onInput, true)
|
|
124
|
+
root.removeEventListener('compositionstart', onStart, true)
|
|
125
|
+
root.removeEventListener('compositionend', onEnd, true)
|
|
126
|
+
root.removeEventListener('pointerdown', onPointerDown, true)
|
|
127
|
+
root.removeEventListener('keydown', onKeyDown, true)
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function textBeforeCaret(view) {
|
|
132
|
+
const selection = view.getSelection && view.getSelection()
|
|
133
|
+
if (!selection || !selection.isCollapsed || !selection.anchorNode) return ''
|
|
134
|
+
const anchor = selection.anchorNode
|
|
135
|
+
const element = anchor.nodeType === 1 ? anchor : anchor.parentElement
|
|
136
|
+
const host = element && element.closest('[contenteditable]:not([contenteditable="false"])')
|
|
137
|
+
if (!host) return ''
|
|
138
|
+
const range = host.ownerDocument.createRange()
|
|
139
|
+
range.setStart(host, 0)
|
|
140
|
+
range.setEnd(anchor, selection.anchorOffset)
|
|
141
|
+
return range.toString().replace(/\u200B/g, '').slice(-8)
|
|
142
|
+
}
|