@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,243 @@
1
+ import { keyboardBottomOverlap } from '@octane-xplat/platform/keyboard-geometry'
2
+ import { leaseKeyboardProperty } from '@octane-xplat/platform/keyboard-property'
3
+ import type { KeyboardPropertyLease } from '@octane-xplat/platform/keyboard-property'
4
+ import { observeKeyboardViewport } from '@octane-xplat/platform/keyboard-viewport'
5
+ import type { KeyboardViewportLease } from '@octane-xplat/platform/keyboard-viewport'
6
+ import type { KeyboardWindowState } from '@octane-xplat/platform/keyboard-state'
7
+ import { keyboardRuntime } from './runtime'
8
+ import type { KeyboardBindingOptions, KeyboardHostBinding } from './binding-types'
9
+ import type { KeyboardSnapshot, SoftwareKeyboardController } from './types'
10
+
11
+ const snapshots = new WeakMap<KeyboardWindowState, KeyboardSnapshot>()
12
+ const snapshot = (state: KeyboardWindowState): KeyboardSnapshot => {
13
+ let value = snapshots.get(state)
14
+ if (!value) {
15
+ value = {
16
+ inset: state.inset,
17
+ windowRect: state.windowRect,
18
+ currentFrame: state.currentFrame,
19
+ targetFrame: state.targetFrame,
20
+ source: state.source,
21
+ capabilities: {
22
+ geometry: !!state.windowRect && state.source !== 'unknown',
23
+ nativeFrames: state.motion?.nativeFrames ?? state.source === 'native-frames',
24
+ interactive: state.motion?.interactive ?? false,
25
+ floating: state.motion?.floating ?? false,
26
+ },
27
+ phase: state.motion?.phase ?? 'idle',
28
+ transitionId: state.motion?.transitionId ?? 0,
29
+ timestampMs: state.motion?.timestampMs ?? 0,
30
+ durationMs: state.motion?.durationMs ?? null,
31
+ }
32
+
33
+ snapshots.set(state, value)
34
+ }
35
+ return value
36
+ }
37
+
38
+ const observeState = (host: object, listener: (state: KeyboardWindowState) => void) => {
39
+ const deactivate = keyboardRuntime.activate()
40
+ let active = true
41
+ let off: () => void
42
+ try {
43
+ off = keyboardRuntime.subscribe(host, (state) => {
44
+ if (active) {
45
+ listener(state)
46
+ }
47
+ })
48
+ } catch (error) {
49
+ active = false
50
+ deactivate()
51
+ throw error
52
+ }
53
+ return () => {
54
+ if (!active) {
55
+ return
56
+ }
57
+ active = false
58
+ try {
59
+ off()
60
+ } finally {
61
+ deactivate()
62
+ }
63
+ }
64
+ }
65
+
66
+ const observe = (host: object, listener: (state: KeyboardSnapshot) => void) =>
67
+ observeState(host, (state) => listener(snapshot(state)))
68
+
69
+ const validateBindingOptions = (options: KeyboardBindingOptions) => {
70
+ for (const value of [options.openOffset, options.closedOffset, options.consumedBottom]) {
71
+ if (value !== undefined && !Number.isFinite(value)) {
72
+ throw new RangeError('Keyboard offsets must be finite')
73
+ }
74
+ }
75
+ }
76
+
77
+ type Binding = {
78
+ lease: KeyboardPropertyLease
79
+ viewport: KeyboardViewportLease
80
+ owners: { read(): KeyboardBindingOptions }[]
81
+ state?: KeyboardWindowState
82
+ openExtent: number
83
+ off: () => void
84
+ }
85
+
86
+ const bindings = new WeakMap<object, Binding>()
87
+
88
+ const apply = (host: object, binding: Binding) => {
89
+ const measured = binding.state,
90
+ options = binding.owners.at(-1)?.read()
91
+ if (!measured || !options) {
92
+ return
93
+ }
94
+ validateBindingOptions(options)
95
+ const state = measured.bindingFrame
96
+ ? { ...measured, currentFrame: measured.bindingFrame }
97
+ : measured
98
+ binding.viewport.update()
99
+ const frame = state.currentFrame ?? state.targetFrame
100
+ const current = keyboardBottomOverlap(state.windowRect, frame)
101
+ const target = keyboardBottomOverlap(state.windowRect, state.targetFrame)
102
+ if (target > 0) {
103
+ binding.openExtent = target
104
+ } else if (!binding.openExtent && current > 0) {
105
+ binding.openExtent = current
106
+ }
107
+ const progress =
108
+ state.source === 'native-frames'
109
+ ? Math.min(1, Math.max(0, binding.openExtent ? current / binding.openExtent : 0))
110
+ : state.inset > 0
111
+ ? 1
112
+ : 0
113
+ const extra =
114
+ (options.closedOffset ?? 0) +
115
+ ((options.openOffset ?? 0) - (options.closedOffset ?? 0)) * progress
116
+ const overlap = keyboardRuntime.overlap(host, state, binding.lease.offset)
117
+ const budget = Math.max(
118
+ 0,
119
+ Math.max(state.inset, current, overlap) - Math.max(0, options.consumedBottom ?? 0),
120
+ )
121
+ binding.lease.set(-Math.min(overlap, budget) - extra)
122
+ if (current === 0 && target === 0) {
123
+ binding.openExtent = 0
124
+ }
125
+ }
126
+
127
+ /** Software-keyboard observation and movement; separate from hardware useKeyboard.
128
+ * @example
129
+ * ```ts
130
+ * import { KeyboardController } from '@octane-xplat/keyboard'
131
+ * function observe(host: object) {
132
+ * return KeyboardController.subscribe(host, state => console.log(state.phase))
133
+ * }
134
+ * ```
135
+ */
136
+ export const KeyboardController: SoftwareKeyboardController = {
137
+ activate: () => keyboardRuntime.activate(),
138
+ getSnapshot: (host) => snapshot(keyboardRuntime.getSnapshot(host)),
139
+ onMove: observe,
140
+ subscribe(host, listener) {
141
+ let previous: KeyboardSnapshot | undefined
142
+ return observe(host, (state) => {
143
+ const rect = state.targetFrame,
144
+ before = previous?.targetFrame
145
+ const changed =
146
+ !previous ||
147
+ state.source !== previous.source ||
148
+ state.inset !== previous.inset ||
149
+ rect?.x !== before?.x ||
150
+ rect?.y !== before?.y ||
151
+ rect?.width !== before?.width ||
152
+ rect?.height !== before?.height ||
153
+ state.windowRect?.width !== previous.windowRect?.width ||
154
+ state.windowRect?.height !== previous.windowRect?.height ||
155
+ state.capabilities.nativeFrames !== previous.capabilities.nativeFrames ||
156
+ state.capabilities.interactive !== previous.capabilities.interactive ||
157
+ state.capabilities.floating !== previous.capabilities.floating ||
158
+ (state.phase !== 'move' &&
159
+ (state.phase !== previous.phase || state.transitionId !== previous.transitionId))
160
+ previous = state
161
+ if (changed) {
162
+ listener(state)
163
+ }
164
+ })
165
+ },
166
+ }
167
+
168
+ /** @internal Bind only framework-owned native movement hosts. */
169
+ export function bindKeyboardHost(
170
+ host: object,
171
+ options: KeyboardBindingOptions | (() => KeyboardBindingOptions) = {},
172
+ ): KeyboardHostBinding {
173
+ const read = typeof options === 'function' ? options : () => options
174
+ const initial = read()
175
+ const inert = () => Object.assign(() => {}, { update() {} })
176
+ if (initial.enabled === false) {
177
+ return inert()
178
+ }
179
+ validateBindingOptions(initial)
180
+ let binding = bindings.get(host)
181
+ const owned = { read }
182
+ if (!binding) {
183
+ const property = keyboardRuntime.property(host)
184
+ if (!property) {
185
+ return inert()
186
+ }
187
+
188
+ binding = {
189
+ lease: leaseKeyboardProperty(property.read, property.write),
190
+ viewport: observeKeyboardViewport(host),
191
+ owners: [owned],
192
+ openExtent: 0,
193
+ off: () => {},
194
+ }
195
+
196
+ bindings.set(host, binding)
197
+ const entry = binding
198
+ try {
199
+ entry.off = observeState(host, (state) => {
200
+ entry.state = state
201
+ apply(host, entry)
202
+ })
203
+ } catch (error) {
204
+ bindings.delete(host)
205
+ entry.lease.release()
206
+ entry.viewport.release()
207
+ throw error
208
+ }
209
+ } else {
210
+ binding.owners.push(owned)
211
+ apply(host, binding)
212
+ }
213
+ const entry = binding
214
+ let active = true
215
+ const release = () => {
216
+ if (!active) {
217
+ return
218
+ }
219
+ active = false
220
+ entry.owners.splice(entry.owners.indexOf(owned), 1)
221
+ if (entry.owners.length) {
222
+ apply(host, entry)
223
+ return
224
+ }
225
+ bindings.delete(host)
226
+ try {
227
+ entry.off()
228
+ } finally {
229
+ try {
230
+ entry.lease.release()
231
+ } finally {
232
+ entry.viewport.release()
233
+ }
234
+ }
235
+ }
236
+ return Object.assign(release, {
237
+ update() {
238
+ if (active) {
239
+ apply(host, entry)
240
+ }
241
+ },
242
+ })
243
+ }
@@ -0,0 +1,13 @@
1
+ import { NSTimer, NSRunLoop } from '@octane-xplat/platform/macos'
2
+
3
+ /** @internal Owned metadata polling, including during AppKit tracking modes. */
4
+ export function startFocusClock(read: () => void): () => void {
5
+ const timer = NSTimer.timerWithTimeIntervalRepeatsBlock(1 / 60, true, read)
6
+ try {
7
+ NSRunLoop.mainRunLoop.addTimerForMode(timer, 'kCFRunLoopCommonModes')
8
+ } catch (error) {
9
+ timer.invalidate()
10
+ throw error
11
+ }
12
+ return () => timer.invalidate()
13
+ }
@@ -0,0 +1,34 @@
1
+ import { isKeyboardRect } from '@octane-xplat/platform/keyboard-geometry'
2
+ import type { KeyboardRect } from '@octane-xplat/platform/keyboard-geometry'
3
+ import { unknownFocusedInputState } from '@octane-xplat/platform/focused-input-state'
4
+ import type { FocusedInputState } from '@octane-xplat/platform/focused-input-state'
5
+
6
+ /** @internal Convert a plain CGRect/NSRect value, never an SDK object in shared policy. */
7
+ export function focusRect(value: any): KeyboardRect | null {
8
+ const rect = value && {
9
+ x: value.origin?.x,
10
+ y: value.origin?.y,
11
+ width: value.size?.width,
12
+ height: value.size?.height,
13
+ }
14
+ return isKeyboardRect(rect) ? rect : null
15
+ }
16
+
17
+ /** @internal Selection metadata only; invalid native ranges are unavailable. */
18
+ export function focusSelection(start: number, end: number): FocusedInputState['selection'] {
19
+ return Number.isSafeInteger(start) && Number.isSafeInteger(end) && start >= 0 && end >= 0
20
+ ? { start, end }
21
+ : null
22
+ }
23
+
24
+ /** @internal A supported window without an editing input. */
25
+ export function emptyFocus(
26
+ windowRect: KeyboardRect | null,
27
+ caretBounds: FocusedInputState['capabilities']['caretBounds'],
28
+ ): FocusedInputState {
29
+ return {
30
+ ...unknownFocusedInputState(),
31
+ windowRect,
32
+ capabilities: { inputBounds: true, caretBounds, selection: true },
33
+ }
34
+ }
@@ -0,0 +1,14 @@
1
+ import { sameFocusedInput } from '@octane-xplat/platform/focused-input-state'
2
+ import type { FocusedInputState } from '@octane-xplat/platform/focused-input-state'
3
+
4
+ /** @internal Stable snapshots across idle polling frames. */
5
+ export function focusPublisher(publish: (state: FocusedInputState) => void) {
6
+ let previous: FocusedInputState | undefined
7
+ return (next: FocusedInputState) => {
8
+ if (previous && sameFocusedInput(previous, next)) {
9
+ return
10
+ }
11
+ previous = { ...next, timestampMs: Date.now() }
12
+ publish(previous)
13
+ }
14
+ }
@@ -0,0 +1,9 @@
1
+ import type { FocusedInputState } from '@octane-xplat/platform/focused-input-state'
2
+
3
+ /** @internal Platform-owned window resolution and observation. */
4
+ export interface FocusRuntime {
5
+ activate(): () => void
6
+ getSnapshot(host: object): FocusedInputState
7
+ subscribe(host: object, listener: (state: FocusedInputState) => void): () => void
8
+ recheck(host: object): void
9
+ }
@@ -0,0 +1,62 @@
1
+ import {
2
+ focusedInputWindows,
3
+ unknownFocusedInputState,
4
+ } from '@octane-xplat/platform/focused-input-state'
5
+
6
+ import { observeFocusedInput, focusRechecks } from './focus-source'
7
+ import { startFocusClock } from './focus-clock'
8
+ import type { FocusRuntime } from './focus-runtime-types'
9
+
10
+ const inactive = unknownFocusedInputState()
11
+ const owner = (host: any): object | undefined => (host?.native ?? host)?.window ?? undefined
12
+
13
+ export const focusRuntime: FocusRuntime = {
14
+ activate: () => focusedInputWindows.install(observeFocusedInput),
15
+ getSnapshot(host) {
16
+ const key = owner(host)
17
+ return key ? focusedInputWindows.getSnapshot(key) : inactive
18
+ },
19
+ recheck(host) {
20
+ const key = owner(host)
21
+ if (key) {
22
+ focusRechecks.get(key)?.()
23
+ }
24
+ },
25
+ subscribe(host, listener) {
26
+ let active = true
27
+ let key: object | undefined,
28
+ initialized = false
29
+ let off = () => {}
30
+ const rebind = () => {
31
+ if (!active) {
32
+ return
33
+ }
34
+ const next = owner(host)
35
+ if (initialized && key === next) {
36
+ return
37
+ }
38
+ initialized = true
39
+ key = next
40
+ off()
41
+ off = () => {}
42
+ if (next) {
43
+ off = focusedInputWindows.subscribe(next, listener)
44
+ } else {
45
+ listener(inactive)
46
+ }
47
+ }
48
+ rebind()
49
+ let stop: () => void
50
+ try {
51
+ stop = startFocusClock(rebind)
52
+ } catch (error) {
53
+ off()
54
+ throw error
55
+ }
56
+ return () => {
57
+ active = false
58
+ stop()
59
+ off()
60
+ }
61
+ },
62
+ }
@@ -0,0 +1,57 @@
1
+ import {
2
+ focusedInputWindows,
3
+ unknownFocusedInputState,
4
+ } from '@octane-xplat/platform/focused-input-state'
5
+
6
+ import {
7
+ keyboardWindowForHost,
8
+ subscribeKeyboardState,
9
+ } from '@octane-xplat/ui/nativescript/keyboard'
10
+
11
+ import { observeFocusedInput, focusRechecks } from './focus-source'
12
+ import type { FocusRuntime } from './focus-runtime-types'
13
+
14
+ const inactive = unknownFocusedInputState()
15
+ const unwrap = (host: any) => host?.native ?? host
16
+
17
+ export const focusRuntime: FocusRuntime = {
18
+ activate: () => focusedInputWindows.install(observeFocusedInput),
19
+ getSnapshot(host) {
20
+ const key = keyboardWindowForHost(unwrap(host))
21
+ return key ? focusedInputWindows.getSnapshot(key) : inactive
22
+ },
23
+ recheck(host) {
24
+ const key = keyboardWindowForHost(unwrap(host))
25
+ if (key) {
26
+ focusRechecks.get(key)?.()
27
+ }
28
+ },
29
+ subscribe(host, listener) {
30
+ let key: object | undefined
31
+ let offFocus = () => {}
32
+ let initialized = false
33
+ const offWindow = subscribeKeyboardState(
34
+ (_state, next) => {
35
+ if (initialized && key === next) {
36
+ return
37
+ }
38
+ initialized = true
39
+ offFocus()
40
+ key = next
41
+ offFocus = next ? focusedInputWindows.subscribe(next, listener) : () => {}
42
+ if (!next) {
43
+ listener(inactive)
44
+ }
45
+ },
46
+ unwrap(host),
47
+ false,
48
+ )
49
+ return () => {
50
+ try {
51
+ offWindow()
52
+ } finally {
53
+ offFocus()
54
+ }
55
+ }
56
+ },
57
+ }
@@ -0,0 +1,14 @@
1
+ import { unknownFocusedInputState } from '@octane-xplat/platform/focused-input-state'
2
+ import type { FocusRuntime } from './focus-runtime-types'
3
+
4
+ const inactive = unknownFocusedInputState()
5
+ /** Neutral fallback for hosts without qualified input geometry adapters. */
6
+ export const focusRuntime: FocusRuntime = {
7
+ activate: () => () => {},
8
+ getSnapshot: () => inactive,
9
+ subscribe(_host, listener) {
10
+ listener(inactive)
11
+ return () => {}
12
+ },
13
+ recheck() {},
14
+ }
@@ -0,0 +1,139 @@
1
+ import {
2
+ focusedInputWindows,
3
+ unknownFocusedInputState,
4
+ } from '@octane-xplat/platform/focused-input-state'
5
+
6
+ import { observeFocusedInput, focusRechecks } from './focus-source'
7
+ import type { FocusRuntime } from './focus-runtime-types'
8
+
9
+ const inactive = unknownFocusedInputState()
10
+ const lifecycles = new WeakMap<Document, { readers: number; paused: boolean }>()
11
+ const view = (host: any): HTMLElement | undefined => host?.native ?? host
12
+ // A WindowProxy survives navigation; the document is its observation generation.
13
+ const owner = (host: object): Document | undefined => {
14
+ const element = view(host),
15
+ document = element?.ownerDocument,
16
+ window = document?.defaultView
17
+ try {
18
+ return element?.isConnected &&
19
+ window &&
20
+ !window.closed &&
21
+ window.document === document &&
22
+ !lifecycles.get(document!)?.paused
23
+ ? document
24
+ : undefined
25
+ } catch {
26
+ return undefined
27
+ }
28
+ }
29
+
30
+ const watchWindow = (document: Document, rebind: () => void) => {
31
+ const window = document.defaultView!
32
+ let state = lifecycles.get(document)
33
+ if (!state) {
34
+ state = { readers: 0, paused: false }
35
+ lifecycles.set(document, state)
36
+ }
37
+ const owned = state
38
+ owned.readers++
39
+ let active = true
40
+ const hide = () => {
41
+ if (active) {
42
+ owned.paused = true
43
+ rebind()
44
+ }
45
+ }
46
+ const show = () => {
47
+ if (active) {
48
+ owned.paused = false
49
+ rebind()
50
+ }
51
+ }
52
+ window.addEventListener('pagehide', hide)
53
+ window.addEventListener('pageshow', show)
54
+ return () => {
55
+ if (!active) {
56
+ return
57
+ }
58
+ active = false
59
+ window.removeEventListener('pagehide', hide)
60
+ window.removeEventListener('pageshow', show)
61
+ if (!--owned.readers) {
62
+ lifecycles.delete(document)
63
+ }
64
+ }
65
+ }
66
+
67
+ export const focusRuntime: FocusRuntime = {
68
+ activate: () => focusedInputWindows.install(observeFocusedInput),
69
+ getSnapshot(host) {
70
+ const key = owner(host)
71
+ return key ? focusedInputWindows.getSnapshot(key) : inactive
72
+ },
73
+ recheck(host) {
74
+ const key = owner(host)
75
+ if (key) {
76
+ focusRechecks.get(key)?.()
77
+ }
78
+ },
79
+ subscribe(host, listener) {
80
+ let active = true
81
+ let key: Document | undefined,
82
+ document: Document | undefined,
83
+ initialized = false
84
+ let observer: MutationObserver | undefined
85
+ let offWindow = () => {}
86
+ let off = () => {}
87
+ const rebind = () => {
88
+ if (!active) {
89
+ return
90
+ }
91
+ const nextDocument = view(host)?.ownerDocument
92
+ if (nextDocument !== document) {
93
+ observer?.disconnect()
94
+ offWindow()
95
+ offWindow = () => {}
96
+ document = nextDocument
97
+ const Observer = document?.defaultView?.MutationObserver
98
+ observer = Observer ? new Observer(rebind) : undefined
99
+ if (document) {
100
+ observer?.observe(document, { childList: true, subtree: true })
101
+ }
102
+
103
+ if (document?.defaultView) {
104
+ offWindow = watchWindow(document, rebind)
105
+ }
106
+ }
107
+ const next = owner(host)
108
+ if (initialized && key === next) {
109
+ return
110
+ }
111
+ initialized = true
112
+ off()
113
+ off = () => {}
114
+ key = next
115
+ if (next) {
116
+ off = focusedInputWindows.subscribe(next, listener)
117
+ } else {
118
+ listener(inactive)
119
+ }
120
+ }
121
+ try {
122
+ rebind()
123
+ } catch (error) {
124
+ observer?.disconnect()
125
+ offWindow()
126
+ off()
127
+ throw error
128
+ }
129
+ return () => {
130
+ if (!active) {
131
+ return
132
+ }
133
+ active = false
134
+ observer?.disconnect()
135
+ offWindow()
136
+ off()
137
+ }
138
+ },
139
+ }