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/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
+ }