@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.
Files changed (56) hide show
  1. package/README.md +153 -1
  2. package/UPSTREAM.md +12 -0
  3. package/package.json +77 -9
  4. package/src/KeyboardAwareScrollView.mobile.tsrx +54 -0
  5. package/src/KeyboardAwareScrollView.tsrx +53 -0
  6. package/src/KeyboardStickyView.mobile.tsrx +66 -0
  7. package/src/KeyboardStickyView.tsrx +9 -0
  8. package/src/Provider.tsrx +11 -0
  9. package/src/binding-types.ts +16 -0
  10. package/src/controller.ts +243 -0
  11. package/src/focus-clock.macos.ts +13 -0
  12. package/src/focus-geometry.ts +34 -0
  13. package/src/focus-publish.ts +14 -0
  14. package/src/focus-runtime-types.ts +9 -0
  15. package/src/focus-runtime.macos.ts +62 -0
  16. package/src/focus-runtime.mobile.ts +57 -0
  17. package/src/focus-runtime.ts +14 -0
  18. package/src/focus-runtime.web.ts +139 -0
  19. package/src/focus-source.android.ts +173 -0
  20. package/src/focus-source.ios.ts +202 -0
  21. package/src/focus-source.macos.ts +113 -0
  22. package/src/focus-source.ts +12 -0
  23. package/src/focus-source.web.ts +157 -0
  24. package/src/focus-types.ts +19 -0
  25. package/src/focused-input.ts +43 -0
  26. package/src/index.ts +12 -0
  27. package/src/motion.ts +82 -0
  28. package/src/native-scope.ts +28 -0
  29. package/src/prediction.ts +32 -0
  30. package/src/props.ts +37 -0
  31. package/src/runtime-types.ts +10 -0
  32. package/src/runtime.mobile.ts +62 -0
  33. package/src/runtime.ts +16 -0
  34. package/src/scroll-controller.ts +136 -0
  35. package/src/scroll-geometry.ts +72 -0
  36. package/src/scroll-runtime-types.ts +6 -0
  37. package/src/scroll-runtime.macos.ts +41 -0
  38. package/src/scroll-runtime.mobile.ts +46 -0
  39. package/src/scroll-runtime.ts +7 -0
  40. package/src/scroll-runtime.web.ts +25 -0
  41. package/src/scroll-types.ts +24 -0
  42. package/src/source.android.ts +179 -0
  43. package/src/source.ios.ts +283 -0
  44. package/src/source.ts +5 -0
  45. package/src/tsconfig.json +1 -0
  46. package/src/types.ts +45 -0
  47. package/src/viewport-types.ts +7 -0
  48. package/src/viewport.android.ts +238 -0
  49. package/src/viewport.ios.ts +6 -0
  50. package/src/viewport.ts +6 -0
  51. package/types/focus-types.d.ts +17 -0
  52. package/types/index.d.ts +20 -0
  53. package/types/props.d.ts +35 -0
  54. package/types/scroll-types.d.ts +22 -0
  55. package/types/types.d.ts +40 -0
  56. 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
+ }