@octane-xplat/keyboard 0.0.1 → 0.17.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/README.md +153 -1
- package/UPSTREAM.md +12 -0
- package/package.json +77 -9
- package/src/KeyboardAwareScrollView.mobile.tsrx +54 -0
- package/src/KeyboardAwareScrollView.tsrx +53 -0
- package/src/KeyboardStickyView.mobile.tsrx +66 -0
- package/src/KeyboardStickyView.tsrx +9 -0
- package/src/Provider.tsrx +11 -0
- package/src/binding-types.ts +16 -0
- package/src/controller.ts +243 -0
- package/src/focus-clock.macos.ts +13 -0
- package/src/focus-geometry.ts +34 -0
- package/src/focus-publish.ts +14 -0
- package/src/focus-runtime-types.ts +9 -0
- package/src/focus-runtime.macos.ts +62 -0
- package/src/focus-runtime.mobile.ts +57 -0
- package/src/focus-runtime.ts +14 -0
- package/src/focus-runtime.web.ts +139 -0
- package/src/focus-source.android.ts +173 -0
- package/src/focus-source.ios.ts +202 -0
- package/src/focus-source.macos.ts +113 -0
- package/src/focus-source.ts +12 -0
- package/src/focus-source.web.ts +157 -0
- package/src/focus-types.ts +19 -0
- package/src/focused-input.ts +43 -0
- package/src/index.ts +12 -0
- package/src/motion.ts +82 -0
- package/src/native-scope.ts +28 -0
- package/src/prediction.ts +32 -0
- package/src/props.ts +37 -0
- package/src/runtime-types.ts +10 -0
- package/src/runtime.mobile.ts +62 -0
- package/src/runtime.ts +16 -0
- package/src/scroll-controller.ts +136 -0
- package/src/scroll-geometry.ts +72 -0
- package/src/scroll-runtime-types.ts +6 -0
- package/src/scroll-runtime.macos.ts +41 -0
- package/src/scroll-runtime.mobile.ts +46 -0
- package/src/scroll-runtime.ts +7 -0
- package/src/scroll-runtime.web.ts +25 -0
- package/src/scroll-types.ts +24 -0
- package/src/source.android.ts +179 -0
- package/src/source.ios.ts +283 -0
- package/src/source.ts +5 -0
- package/src/tsconfig.json +1 -0
- package/src/types.ts +45 -0
- package/src/viewport-types.ts +7 -0
- package/src/viewport.android.ts +238 -0
- package/src/viewport.ios.ts +6 -0
- package/src/viewport.ts +6 -0
- package/types/focus-types.d.ts +17 -0
- package/types/index.d.ts +20 -0
- package/types/props.d.ts +35 -0
- package/types/scroll-types.d.ts +22 -0
- package/types/types.d.ts +40 -0
- package/index.js +0 -1
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
import { Utils } from '@nativescript/core'
|
|
2
|
+
import { android } from '@octane-xplat/platform/android'
|
|
3
|
+
import type { KeyboardWindowSource } from '@octane-xplat/platform/keyboard-registry'
|
|
4
|
+
import type {
|
|
5
|
+
FocusedInputState,
|
|
6
|
+
FocusedInputScrollOwner,
|
|
7
|
+
} from '@octane-xplat/platform/focused-input-state'
|
|
8
|
+
|
|
9
|
+
import type { KeyboardRect } from '@octane-xplat/platform/keyboard-geometry'
|
|
10
|
+
import { emptyFocus, focusSelection } from './focus-geometry'
|
|
11
|
+
import { focusPublisher } from './focus-publish'
|
|
12
|
+
import { keyboardNativeScope } from './native-scope'
|
|
13
|
+
|
|
14
|
+
export const focusRechecks = new Map<object, () => void>()
|
|
15
|
+
const dip = (value: number) => Utils.layout.toDeviceIndependentPixels(value)
|
|
16
|
+
const location = (view: any) => {
|
|
17
|
+
const xy = (Array as any).create('int', 2)
|
|
18
|
+
view.getLocationOnScreen(xy)
|
|
19
|
+
return { x: xy[0], y: xy[1] }
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const bounds = (
|
|
23
|
+
view: any,
|
|
24
|
+
decor: any,
|
|
25
|
+
box = { x: 0, y: 0, width: view.getWidth(), height: view.getHeight() },
|
|
26
|
+
): KeyboardRect | null => {
|
|
27
|
+
const origin = location(decor)
|
|
28
|
+
if (typeof view.transformMatrixToGlobal === 'function') {
|
|
29
|
+
const matrix = new android.graphics.Matrix()
|
|
30
|
+
view.transformMatrixToGlobal(matrix)
|
|
31
|
+
const rect = new android.graphics.RectF(box.x, box.y, box.x + box.width, box.y + box.height)
|
|
32
|
+
matrix.mapRect(rect)
|
|
33
|
+
return {
|
|
34
|
+
x: dip(rect.left - origin.x),
|
|
35
|
+
y: dip(rect.top - origin.y),
|
|
36
|
+
width: dip(rect.width()),
|
|
37
|
+
height: dip(rect.height()),
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
for (let parent = view; parent?.getMatrix; parent = parent.getParent?.()) {
|
|
41
|
+
if (!parent.getMatrix().isIdentity()) {
|
|
42
|
+
return null
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
const at = location(view)
|
|
46
|
+
return {
|
|
47
|
+
x: dip(at.x - origin.x + box.x),
|
|
48
|
+
y: dip(at.y - origin.y + box.y),
|
|
49
|
+
width: dip(box.width),
|
|
50
|
+
height: dip(box.height),
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Own multicast draw listeners; selection/layout changes never require text reads. */
|
|
55
|
+
export const observeFocusedInput: KeyboardWindowSource<FocusedInputState> = (key, notify) => {
|
|
56
|
+
const decor = key as any,
|
|
57
|
+
scope = keyboardNativeScope(),
|
|
58
|
+
publish = focusPublisher(notify)
|
|
59
|
+
let active = true
|
|
60
|
+
scope.defer(() => {
|
|
61
|
+
active = false
|
|
62
|
+
})
|
|
63
|
+
try {
|
|
64
|
+
const read = () => {
|
|
65
|
+
if (!active) {
|
|
66
|
+
return
|
|
67
|
+
}
|
|
68
|
+
const state = emptyFocus(
|
|
69
|
+
{ x: 0, y: 0, width: dip(decor.getWidth()), height: dip(decor.getHeight()) },
|
|
70
|
+
'insertion-line',
|
|
71
|
+
)
|
|
72
|
+
const input = decor.findFocus?.()
|
|
73
|
+
if (
|
|
74
|
+
!input ||
|
|
75
|
+
!(input instanceof android.widget.EditText) ||
|
|
76
|
+
typeof input.getSelectionEnd !== 'function' ||
|
|
77
|
+
input.getRootView() !== decor ||
|
|
78
|
+
!input.isAttachedToWindow()
|
|
79
|
+
) {
|
|
80
|
+
publish(state)
|
|
81
|
+
return
|
|
82
|
+
}
|
|
83
|
+
const selection = focusSelection(input.getSelectionStart(), input.getSelectionEnd())
|
|
84
|
+
const layout = input.getLayout?.()
|
|
85
|
+
let caret: KeyboardRect | null = null
|
|
86
|
+
if (selection && layout?.getLineCount() > 0) {
|
|
87
|
+
const end = Math.min(selection.end, layout.getLineEnd(layout.getLineCount() - 1))
|
|
88
|
+
const line = layout.getLineForOffset(end)
|
|
89
|
+
caret = bounds(input, decor, {
|
|
90
|
+
x: layout.getPrimaryHorizontal(end) + input.getTotalPaddingLeft() - input.getScrollX(),
|
|
91
|
+
y: layout.getLineTop(line) + input.getTotalPaddingTop() - input.getScrollY(),
|
|
92
|
+
width: 1,
|
|
93
|
+
height: layout.getLineBottom(line) - layout.getLineTop(line),
|
|
94
|
+
})
|
|
95
|
+
}
|
|
96
|
+
const nested = (globalThis as any).androidx?.core?.widget?.NestedScrollView,
|
|
97
|
+
mason = (globalThis as any).org?.nativescript?.mason?.masonkit?.TwoDScrollView
|
|
98
|
+
const owners: FocusedInputScrollOwner[] = []
|
|
99
|
+
for (let parent: any = input.getParent(); parent?.getParent; parent = parent.getParent()) {
|
|
100
|
+
const masonScroll = !!mason && parent instanceof mason
|
|
101
|
+
if (
|
|
102
|
+
!(parent instanceof android.widget.ScrollView) &&
|
|
103
|
+
!(nested && parent instanceof nested) &&
|
|
104
|
+
!masonScroll
|
|
105
|
+
) {
|
|
106
|
+
continue
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const rect = bounds(parent, decor),
|
|
110
|
+
child = parent.getChildAt(0)
|
|
111
|
+
|
|
112
|
+
if (!rect || !child) {
|
|
113
|
+
continue
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
owners.push({
|
|
117
|
+
id: parent,
|
|
118
|
+
rect,
|
|
119
|
+
offsetY: dip(parent.getScrollY()),
|
|
120
|
+
minOffsetY: 0,
|
|
121
|
+
maxOffsetY: dip(
|
|
122
|
+
masonScroll
|
|
123
|
+
? Math.max(0, parent.computeVerticalScrollRange() - parent.getHeight())
|
|
124
|
+
: Math.max(
|
|
125
|
+
0,
|
|
126
|
+
child.getHeight() +
|
|
127
|
+
parent.getPaddingTop() +
|
|
128
|
+
parent.getPaddingBottom() -
|
|
129
|
+
parent.getHeight(),
|
|
130
|
+
),
|
|
131
|
+
),
|
|
132
|
+
})
|
|
133
|
+
}
|
|
134
|
+
const box = bounds(input, decor)
|
|
135
|
+
publish({
|
|
136
|
+
...state,
|
|
137
|
+
input,
|
|
138
|
+
inputRect: box,
|
|
139
|
+
caretRect: caret,
|
|
140
|
+
selection,
|
|
141
|
+
scrollOwners: owners,
|
|
142
|
+
capabilities: {
|
|
143
|
+
inputBounds: !!box,
|
|
144
|
+
caretBounds: caret ? 'insertion-line' : 'none',
|
|
145
|
+
selection: !!selection,
|
|
146
|
+
},
|
|
147
|
+
})
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const listener = new android.view.ViewTreeObserver.OnPreDrawListener({
|
|
151
|
+
onPreDraw() {
|
|
152
|
+
read()
|
|
153
|
+
return true
|
|
154
|
+
},
|
|
155
|
+
})
|
|
156
|
+
|
|
157
|
+
decor.getViewTreeObserver().addOnPreDrawListener(listener)
|
|
158
|
+
scope.defer(() => {
|
|
159
|
+
const tree = decor.getViewTreeObserver()
|
|
160
|
+
if (tree.isAlive()) {
|
|
161
|
+
tree.removeOnPreDrawListener(listener)
|
|
162
|
+
}
|
|
163
|
+
})
|
|
164
|
+
|
|
165
|
+
focusRechecks.set(key, read)
|
|
166
|
+
scope.defer(() => focusRechecks.delete(key))
|
|
167
|
+
read()
|
|
168
|
+
return () => scope.release()
|
|
169
|
+
} catch (error) {
|
|
170
|
+
scope.release()
|
|
171
|
+
throw error
|
|
172
|
+
}
|
|
173
|
+
}
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
import {
|
|
2
|
+
NSObject,
|
|
3
|
+
CADisplayLink,
|
|
4
|
+
NSRunLoop,
|
|
5
|
+
NSNotificationCenter,
|
|
6
|
+
NSOperationQueue,
|
|
7
|
+
UIScrollView,
|
|
8
|
+
} from '@octane-xplat/platform/ios'
|
|
9
|
+
|
|
10
|
+
import type { KeyboardWindowSource } from '@octane-xplat/platform/keyboard-registry'
|
|
11
|
+
import type {
|
|
12
|
+
FocusedInputState,
|
|
13
|
+
FocusedInputScrollOwner,
|
|
14
|
+
} from '@octane-xplat/platform/focused-input-state'
|
|
15
|
+
|
|
16
|
+
import { focusRect, focusSelection, emptyFocus } from './focus-geometry'
|
|
17
|
+
import { focusPublisher } from './focus-publish'
|
|
18
|
+
import { keyboardNativeScope } from './native-scope'
|
|
19
|
+
|
|
20
|
+
/** MasonKit scroll containers are MasonUIView, not UIScrollView; mirror
|
|
21
|
+
* MasonUIView.swift's vertical scrollability rule. */
|
|
22
|
+
const masonScrollsY = (view: any) => {
|
|
23
|
+
const Mason = (globalThis as any).MasonUIView
|
|
24
|
+
if (!Mason || !(view instanceof Mason)) {
|
|
25
|
+
return false
|
|
26
|
+
}
|
|
27
|
+
const range = () => view.contentSize.height > view.bounds.size.height
|
|
28
|
+
switch (view.overflowY) {
|
|
29
|
+
case 2:
|
|
30
|
+
return true
|
|
31
|
+
case 4:
|
|
32
|
+
return range()
|
|
33
|
+
case 0:
|
|
34
|
+
return !!view.isScrollContainer && range()
|
|
35
|
+
default:
|
|
36
|
+
return false
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export const focusRechecks = new Map<object, () => void>()
|
|
41
|
+
let Target: any
|
|
42
|
+
const targetFor = (read: () => void) => {
|
|
43
|
+
if (!Target) {
|
|
44
|
+
const interop = (globalThis as any).interop
|
|
45
|
+
Target = (NSObject as any).extend(
|
|
46
|
+
{
|
|
47
|
+
['tick:'](this: any) {
|
|
48
|
+
this.read?.()
|
|
49
|
+
},
|
|
50
|
+
},
|
|
51
|
+
{ exposedMethods: { 'tick:': { returns: interop.types.void, params: [CADisplayLink] } } },
|
|
52
|
+
)
|
|
53
|
+
}
|
|
54
|
+
const target = Target.new()
|
|
55
|
+
target.read = read
|
|
56
|
+
return target
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const editing = (view: any): boolean =>
|
|
60
|
+
!!view?.isFirstResponder && typeof view.caretRectForPosition === 'function'
|
|
61
|
+
|
|
62
|
+
const find = (view: any): any => {
|
|
63
|
+
if (editing(view)) {
|
|
64
|
+
return view
|
|
65
|
+
}
|
|
66
|
+
const children = view?.subviews
|
|
67
|
+
for (let index = 0; index < (children?.count ?? 0); index++) {
|
|
68
|
+
const found = find(children.objectAtIndex(index))
|
|
69
|
+
if (found) {
|
|
70
|
+
return found
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
return null
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Poll caret/layout while editing; standard UIKit notifications change identity.
|
|
77
|
+
* No delegate replacement and no input text reads. */
|
|
78
|
+
export const observeFocusedInput: KeyboardWindowSource<FocusedInputState> = (key, notify) => {
|
|
79
|
+
const window = key as any,
|
|
80
|
+
scope = keyboardNativeScope(),
|
|
81
|
+
publish = focusPublisher(notify)
|
|
82
|
+
let active = true,
|
|
83
|
+
input = find(window)
|
|
84
|
+
scope.defer(() => {
|
|
85
|
+
active = false
|
|
86
|
+
})
|
|
87
|
+
try {
|
|
88
|
+
const read = () => {
|
|
89
|
+
if (!active) {
|
|
90
|
+
return
|
|
91
|
+
}
|
|
92
|
+
if (input && (!editing(input) || input.window !== window)) {
|
|
93
|
+
input = null
|
|
94
|
+
}
|
|
95
|
+
const state = emptyFocus(focusRect(window.bounds), 'native-rect')
|
|
96
|
+
if (!input) {
|
|
97
|
+
publish(state)
|
|
98
|
+
return
|
|
99
|
+
}
|
|
100
|
+
const box = input.hidden ? null : focusRect(input.convertRectToView(input.bounds, window))
|
|
101
|
+
const range = input.selectedTextRange
|
|
102
|
+
const selection = range
|
|
103
|
+
? focusSelection(
|
|
104
|
+
Number(input.offsetFromPositionToPosition(input.beginningOfDocument, range.start)),
|
|
105
|
+
Number(input.offsetFromPositionToPosition(input.beginningOfDocument, range.end)),
|
|
106
|
+
)
|
|
107
|
+
: null
|
|
108
|
+
const caret = range
|
|
109
|
+
? focusRect(input.convertRectToView(input.caretRectForPosition(range.end), window))
|
|
110
|
+
: null
|
|
111
|
+
const owners: FocusedInputScrollOwner[] = []
|
|
112
|
+
for (let view = input; view; view = view.superview) {
|
|
113
|
+
const mason = masonScrollsY(view)
|
|
114
|
+
if (!(view instanceof UIScrollView) && !mason) {
|
|
115
|
+
continue
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const rect = focusRect(view.convertRectToView(view.bounds, window))
|
|
119
|
+
if (!rect) {
|
|
120
|
+
continue
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
if (mason) {
|
|
124
|
+
owners.push({
|
|
125
|
+
id: view,
|
|
126
|
+
rect,
|
|
127
|
+
offsetY: view.mason_scrollTop,
|
|
128
|
+
minOffsetY: 0,
|
|
129
|
+
maxOffsetY: Math.max(0, view.mason_scrollHeight - view.mason_clientHeight),
|
|
130
|
+
})
|
|
131
|
+
continue
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const inset = view.adjustedContentInset ?? view.contentInset
|
|
135
|
+
const min = -(inset?.top ?? 0)
|
|
136
|
+
owners.push({
|
|
137
|
+
id: view,
|
|
138
|
+
rect,
|
|
139
|
+
offsetY: view.contentOffset.y,
|
|
140
|
+
minOffsetY: min,
|
|
141
|
+
maxOffsetY: Math.max(
|
|
142
|
+
min,
|
|
143
|
+
view.contentSize.height - view.bounds.size.height + (inset?.bottom ?? 0),
|
|
144
|
+
),
|
|
145
|
+
})
|
|
146
|
+
}
|
|
147
|
+
publish({
|
|
148
|
+
...state,
|
|
149
|
+
input,
|
|
150
|
+
inputRect: box,
|
|
151
|
+
caretRect: caret?.height ? caret : null,
|
|
152
|
+
selection,
|
|
153
|
+
scrollOwners: owners,
|
|
154
|
+
capabilities: {
|
|
155
|
+
inputBounds: !!box,
|
|
156
|
+
caretBounds: caret?.height ? 'native-rect' : 'none',
|
|
157
|
+
selection: !!selection,
|
|
158
|
+
},
|
|
159
|
+
})
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const center = NSNotificationCenter.defaultCenter
|
|
163
|
+
for (const name of [
|
|
164
|
+
UITextFieldTextDidBeginEditingNotification,
|
|
165
|
+
UITextViewTextDidBeginEditingNotification,
|
|
166
|
+
UITextFieldTextDidEndEditingNotification,
|
|
167
|
+
UITextViewTextDidEndEditingNotification,
|
|
168
|
+
]) {
|
|
169
|
+
const token = center.addObserverForNameObjectQueueUsingBlock(
|
|
170
|
+
name,
|
|
171
|
+
null,
|
|
172
|
+
NSOperationQueue.mainQueue,
|
|
173
|
+
(note: any) => {
|
|
174
|
+
const candidate = note.object
|
|
175
|
+
if (candidate?.window === window && editing(candidate)) {
|
|
176
|
+
input = candidate
|
|
177
|
+
} else if (candidate === input) {
|
|
178
|
+
input = null
|
|
179
|
+
}
|
|
180
|
+
},
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
scope.defer(() => center.removeObserver(token))
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const target = targetFor(read)
|
|
187
|
+
const link = CADisplayLink.displayLinkWithTargetSelector(target, 'tick:')
|
|
188
|
+
scope.defer(() => {
|
|
189
|
+
target.read = undefined
|
|
190
|
+
link.invalidate()
|
|
191
|
+
})
|
|
192
|
+
|
|
193
|
+
link.addToRunLoopForMode(NSRunLoop.mainRunLoop, 'kCFRunLoopCommonModes')
|
|
194
|
+
focusRechecks.set(key, read)
|
|
195
|
+
scope.defer(() => focusRechecks.delete(key))
|
|
196
|
+
read()
|
|
197
|
+
return () => scope.release()
|
|
198
|
+
} catch (error) {
|
|
199
|
+
scope.release()
|
|
200
|
+
throw error
|
|
201
|
+
}
|
|
202
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { NSScrollView } from '@octane-xplat/platform/macos'
|
|
2
|
+
import type { KeyboardWindowSource } from '@octane-xplat/platform/keyboard-registry'
|
|
3
|
+
import type {
|
|
4
|
+
FocusedInputState,
|
|
5
|
+
FocusedInputScrollOwner,
|
|
6
|
+
} from '@octane-xplat/platform/focused-input-state'
|
|
7
|
+
|
|
8
|
+
import { emptyFocus, focusRect, focusSelection } from './focus-geometry'
|
|
9
|
+
import { focusPublisher } from './focus-publish'
|
|
10
|
+
import { keyboardNativeScope } from './native-scope'
|
|
11
|
+
import { startFocusClock } from './focus-clock'
|
|
12
|
+
|
|
13
|
+
export const focusRechecks = new Map<object, () => void>()
|
|
14
|
+
const flipped = (view: any): boolean =>
|
|
15
|
+
typeof view?.isFlipped === 'function' ? view.isFlipped() : !!view?.isFlipped
|
|
16
|
+
|
|
17
|
+
/** AppKit input/field-editor geometry, normalized to content-window top-left. */
|
|
18
|
+
export const observeFocusedInput: KeyboardWindowSource<FocusedInputState> = (key, notify) => {
|
|
19
|
+
const window = key as any,
|
|
20
|
+
scope = keyboardNativeScope(),
|
|
21
|
+
publish = focusPublisher(notify)
|
|
22
|
+
let active = true
|
|
23
|
+
scope.defer(() => {
|
|
24
|
+
active = false
|
|
25
|
+
})
|
|
26
|
+
try {
|
|
27
|
+
const read = () => {
|
|
28
|
+
if (!active) {
|
|
29
|
+
return
|
|
30
|
+
}
|
|
31
|
+
const root = window.contentView
|
|
32
|
+
const box = focusRect(root?.bounds)
|
|
33
|
+
const state = emptyFocus(box && { ...box, x: 0, y: 0 }, 'native-rect')
|
|
34
|
+
const editor = window.firstResponder
|
|
35
|
+
if (!root || !editor || typeof editor.firstRectForCharacterRangeActualRange !== 'function') {
|
|
36
|
+
publish(state)
|
|
37
|
+
return
|
|
38
|
+
}
|
|
39
|
+
const candidate = editor.delegate
|
|
40
|
+
const input =
|
|
41
|
+
candidate?.window === window && typeof candidate.convertRectToView === 'function'
|
|
42
|
+
? candidate
|
|
43
|
+
: editor
|
|
44
|
+
if (input.window !== window) {
|
|
45
|
+
publish(state)
|
|
46
|
+
return
|
|
47
|
+
}
|
|
48
|
+
const normalize = (value: any) => {
|
|
49
|
+
const rect = focusRect(value)
|
|
50
|
+
return (
|
|
51
|
+
rect &&
|
|
52
|
+
(flipped(root) ? rect : { ...rect, y: root.bounds.size.height - rect.y - rect.height })
|
|
53
|
+
)
|
|
54
|
+
}
|
|
55
|
+
const range = editor.selectedRange
|
|
56
|
+
const selection = range
|
|
57
|
+
? focusSelection(Number(range.location), Number(range.location) + Number(range.length))
|
|
58
|
+
: null
|
|
59
|
+
const caretScreen = selection
|
|
60
|
+
? editor.firstRectForCharacterRangeActualRange({ location: selection.end, length: 0 }, null)
|
|
61
|
+
: null
|
|
62
|
+
const caret = caretScreen
|
|
63
|
+
? normalize(root.convertRectFromView(window.convertRectFromScreen(caretScreen), null))
|
|
64
|
+
: null
|
|
65
|
+
const owners: FocusedInputScrollOwner[] = []
|
|
66
|
+
for (let parent = input; parent; parent = parent.superview) {
|
|
67
|
+
if (!(parent instanceof NSScrollView) || !parent.documentView) {
|
|
68
|
+
continue
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const clip = parent.contentView,
|
|
72
|
+
content = parent.documentView
|
|
73
|
+
|
|
74
|
+
const rect = normalize(clip.convertRectToView(clip.bounds, root))
|
|
75
|
+
if (!rect) {
|
|
76
|
+
continue
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const max = Math.max(0, content.bounds.size.height - clip.bounds.size.height)
|
|
80
|
+
owners.push({
|
|
81
|
+
id: parent,
|
|
82
|
+
rect,
|
|
83
|
+
offsetY: flipped(content) ? clip.bounds.origin.y : max - clip.bounds.origin.y,
|
|
84
|
+
minOffsetY: 0,
|
|
85
|
+
maxOffsetY: max,
|
|
86
|
+
})
|
|
87
|
+
}
|
|
88
|
+
const inputRect = normalize(input.convertRectToView(input.bounds, root))
|
|
89
|
+
publish({
|
|
90
|
+
...state,
|
|
91
|
+
input,
|
|
92
|
+
inputRect,
|
|
93
|
+
caretRect: caret?.height ? caret : null,
|
|
94
|
+
selection,
|
|
95
|
+
scrollOwners: owners,
|
|
96
|
+
capabilities: {
|
|
97
|
+
inputBounds: !!inputRect,
|
|
98
|
+
caretBounds: caret?.height ? 'native-rect' : 'none',
|
|
99
|
+
selection: !!selection,
|
|
100
|
+
},
|
|
101
|
+
})
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
scope.defer(startFocusClock(read))
|
|
105
|
+
focusRechecks.set(key, read)
|
|
106
|
+
scope.defer(() => focusRechecks.delete(key))
|
|
107
|
+
read()
|
|
108
|
+
return () => scope.release()
|
|
109
|
+
} catch (error) {
|
|
110
|
+
scope.release()
|
|
111
|
+
throw error
|
|
112
|
+
}
|
|
113
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { KeyboardWindowSource } from '@octane-xplat/platform/keyboard-registry'
|
|
2
|
+
import type { FocusedInputState } from '@octane-xplat/platform/focused-input-state'
|
|
3
|
+
import { unknownFocusedInputState } from '@octane-xplat/platform/focused-input-state'
|
|
4
|
+
|
|
5
|
+
/** @internal Default adapter is explicitly unsupported. */
|
|
6
|
+
export const observeFocusedInput: KeyboardWindowSource<FocusedInputState> = (_window, notify) => {
|
|
7
|
+
notify(unknownFocusedInputState())
|
|
8
|
+
return () => {}
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/** @internal Active readers install a measurement callback per actual window. */
|
|
12
|
+
export const focusRechecks = new Map<object, () => void>()
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import type { KeyboardWindowSource } from '@octane-xplat/platform/keyboard-registry'
|
|
2
|
+
import type {
|
|
3
|
+
FocusedInputState,
|
|
4
|
+
FocusedInputScrollOwner,
|
|
5
|
+
} from '@octane-xplat/platform/focused-input-state'
|
|
6
|
+
|
|
7
|
+
import { unknownFocusedInputState } from '@octane-xplat/platform/focused-input-state'
|
|
8
|
+
import { emptyFocus, focusSelection } from './focus-geometry'
|
|
9
|
+
import { focusPublisher } from './focus-publish'
|
|
10
|
+
import { keyboardNativeScope } from './native-scope'
|
|
11
|
+
|
|
12
|
+
export const focusRechecks = new Map<object, () => void>()
|
|
13
|
+
const parentOf = (view: HTMLElement): HTMLElement | null =>
|
|
14
|
+
view.assignedSlot ??
|
|
15
|
+
view.parentElement ??
|
|
16
|
+
((view.getRootNode() as ShadowRoot).host as HTMLElement | undefined) ??
|
|
17
|
+
null
|
|
18
|
+
|
|
19
|
+
const rect = (value: DOMRect) => ({
|
|
20
|
+
x: value.x,
|
|
21
|
+
y: value.y,
|
|
22
|
+
width: value.width,
|
|
23
|
+
height: value.height,
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
const focused = (document: Document): Element | null => {
|
|
27
|
+
let input = document.activeElement
|
|
28
|
+
while (input?.shadowRoot?.activeElement) {
|
|
29
|
+
input = input.shadowRoot.activeElement
|
|
30
|
+
}
|
|
31
|
+
return input
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** DOM geometry and selection metadata; value/textContent are never accessed. */
|
|
35
|
+
export const observeFocusedInput: KeyboardWindowSource<FocusedInputState> = (key, notify) => {
|
|
36
|
+
const document = key as Document,
|
|
37
|
+
window = document.defaultView!
|
|
38
|
+
const scope = keyboardNativeScope(),
|
|
39
|
+
publish = focusPublisher(notify)
|
|
40
|
+
const currentDocument = () => {
|
|
41
|
+
try {
|
|
42
|
+
return !window.closed && window.document === document
|
|
43
|
+
} catch {
|
|
44
|
+
return false
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
let active = true,
|
|
48
|
+
frame = 0
|
|
49
|
+
scope.defer(() => {
|
|
50
|
+
active = false
|
|
51
|
+
if (currentDocument()) {
|
|
52
|
+
window.cancelAnimationFrame(frame)
|
|
53
|
+
}
|
|
54
|
+
})
|
|
55
|
+
try {
|
|
56
|
+
const read = () => {
|
|
57
|
+
if (!active) {
|
|
58
|
+
return
|
|
59
|
+
}
|
|
60
|
+
if (!currentDocument()) {
|
|
61
|
+
scope.release()
|
|
62
|
+
publish(unknownFocusedInputState())
|
|
63
|
+
return
|
|
64
|
+
}
|
|
65
|
+
const state = emptyFocus(
|
|
66
|
+
{ x: 0, y: 0, width: window.innerWidth, height: window.innerHeight },
|
|
67
|
+
'none',
|
|
68
|
+
)
|
|
69
|
+
const input = focused(document) as HTMLElement | null
|
|
70
|
+
const textInput =
|
|
71
|
+
input?.tagName === 'INPUT' &&
|
|
72
|
+
['text', 'search', 'url', 'tel', 'email', 'password', 'number'].includes(
|
|
73
|
+
(input as HTMLInputElement).type,
|
|
74
|
+
)
|
|
75
|
+
if (
|
|
76
|
+
!input?.isConnected ||
|
|
77
|
+
!(textInput || input.tagName === 'TEXTAREA' || input.isContentEditable)
|
|
78
|
+
) {
|
|
79
|
+
publish(state)
|
|
80
|
+
return
|
|
81
|
+
}
|
|
82
|
+
const control = input as HTMLInputElement | HTMLTextAreaElement
|
|
83
|
+
const selection =
|
|
84
|
+
typeof control.selectionStart === 'number' && typeof control.selectionEnd === 'number'
|
|
85
|
+
? focusSelection(control.selectionStart, control.selectionEnd)
|
|
86
|
+
: null
|
|
87
|
+
let caret: FocusedInputState['caretRect'] = null
|
|
88
|
+
if (input.isContentEditable) {
|
|
89
|
+
const selected = document.getSelection()
|
|
90
|
+
if (selected?.rangeCount && input.contains(selected.focusNode)) {
|
|
91
|
+
const range = selected.getRangeAt(0).cloneRange()
|
|
92
|
+
range.collapse(false)
|
|
93
|
+
const value = range.getBoundingClientRect()
|
|
94
|
+
if (value.height > 0) {
|
|
95
|
+
caret = rect(value)
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
const owners: FocusedInputScrollOwner[] = []
|
|
100
|
+
for (let parent = parentOf(input); parent; parent = parentOf(parent)) {
|
|
101
|
+
const overflow = window.getComputedStyle(parent).overflowY
|
|
102
|
+
if (
|
|
103
|
+
!['auto', 'scroll', 'overlay'].includes(overflow) &&
|
|
104
|
+
parent !== document.scrollingElement
|
|
105
|
+
) {
|
|
106
|
+
continue
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const box = parent.getBoundingClientRect()
|
|
110
|
+
owners.push({
|
|
111
|
+
id: parent,
|
|
112
|
+
rect:
|
|
113
|
+
parent === document.scrollingElement
|
|
114
|
+
? { x: 0, y: 0, width: window.innerWidth, height: window.innerHeight }
|
|
115
|
+
: {
|
|
116
|
+
x: box.x + parent.clientLeft,
|
|
117
|
+
y: box.y + parent.clientTop,
|
|
118
|
+
width: parent.clientWidth,
|
|
119
|
+
height: parent.clientHeight,
|
|
120
|
+
},
|
|
121
|
+
offsetY: parent.scrollTop,
|
|
122
|
+
minOffsetY: 0,
|
|
123
|
+
maxOffsetY: Math.max(0, parent.scrollHeight - parent.clientHeight),
|
|
124
|
+
})
|
|
125
|
+
}
|
|
126
|
+
publish({
|
|
127
|
+
...state,
|
|
128
|
+
input,
|
|
129
|
+
inputRect: rect(input.getBoundingClientRect()),
|
|
130
|
+
caretRect: caret,
|
|
131
|
+
selection,
|
|
132
|
+
scrollOwners: owners,
|
|
133
|
+
capabilities: {
|
|
134
|
+
inputBounds: true,
|
|
135
|
+
caretBounds: caret ? 'native-rect' : 'none',
|
|
136
|
+
selection: !!selection,
|
|
137
|
+
},
|
|
138
|
+
})
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const tick = () => {
|
|
142
|
+
read()
|
|
143
|
+
if (active) {
|
|
144
|
+
frame = window.requestAnimationFrame(tick)
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
focusRechecks.set(key, read)
|
|
149
|
+
scope.defer(() => focusRechecks.delete(key))
|
|
150
|
+
read()
|
|
151
|
+
frame = window.requestAnimationFrame(tick)
|
|
152
|
+
return () => scope.release()
|
|
153
|
+
} catch (error) {
|
|
154
|
+
scope.release()
|
|
155
|
+
throw error
|
|
156
|
+
}
|
|
157
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { FocusedInputState } from '@octane-xplat/platform/focused-input-state'
|
|
2
|
+
|
|
3
|
+
/** Focused input geometry in device-independent window coordinates. Identity
|
|
4
|
+
* and scroll owners are opaque native/DOM handles. No text contents are read.
|
|
5
|
+
* Unsupported targets return null geometry and false capabilities. */
|
|
6
|
+
export type FocusedInputSnapshot = FocusedInputState
|
|
7
|
+
/** A vertical scroll viewport containing the input, nearest owner first. */
|
|
8
|
+
export type { FocusedInputScrollOwner } from '@octane-xplat/platform/focused-input-state'
|
|
9
|
+
|
|
10
|
+
/** Separate focus/selection geometry from software-keyboard movement. */
|
|
11
|
+
export interface FocusedInputService {
|
|
12
|
+
/** Cached read; does not start observation. */
|
|
13
|
+
getSnapshot(host: object): FocusedInputSnapshot
|
|
14
|
+
/** Observe identity, selection and layout changes; release on host removal.
|
|
15
|
+
* Geometry callbacks can occur each frame during native layout movement. */
|
|
16
|
+
subscribe(host: object, listener: (snapshot: FocusedInputSnapshot) => void): () => void
|
|
17
|
+
/** Refresh an actively observed host. No-op without a reader. */
|
|
18
|
+
recheck(host: object): void
|
|
19
|
+
}
|