@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,43 @@
|
|
|
1
|
+
import { focusRuntime } from './focus-runtime'
|
|
2
|
+
import type { FocusedInputService } from './focus-types'
|
|
3
|
+
|
|
4
|
+
/** Observe input/caret layout without collecting text or owning editing delegates.
|
|
5
|
+
* @example
|
|
6
|
+
* ```ts
|
|
7
|
+
* import { FocusedInputController } from '@octane-xplat/keyboard'
|
|
8
|
+
* function observe(host: object) {
|
|
9
|
+
* return FocusedInputController.subscribe(host, state => console.log(state.inputRect))
|
|
10
|
+
* }
|
|
11
|
+
* ```
|
|
12
|
+
*/
|
|
13
|
+
export const FocusedInputController: FocusedInputService = {
|
|
14
|
+
getSnapshot: (host) => focusRuntime.getSnapshot(host),
|
|
15
|
+
recheck: (host) => focusRuntime.recheck(host),
|
|
16
|
+
subscribe(host, listener) {
|
|
17
|
+
const deactivate = focusRuntime.activate()
|
|
18
|
+
let active = true
|
|
19
|
+
let off: () => void
|
|
20
|
+
try {
|
|
21
|
+
off = focusRuntime.subscribe(host, (state) => {
|
|
22
|
+
if (active) {
|
|
23
|
+
listener(state)
|
|
24
|
+
}
|
|
25
|
+
})
|
|
26
|
+
} catch (error) {
|
|
27
|
+
active = false
|
|
28
|
+
deactivate()
|
|
29
|
+
throw error
|
|
30
|
+
}
|
|
31
|
+
return () => {
|
|
32
|
+
if (!active) {
|
|
33
|
+
return
|
|
34
|
+
}
|
|
35
|
+
active = false
|
|
36
|
+
try {
|
|
37
|
+
off()
|
|
38
|
+
} finally {
|
|
39
|
+
deactivate()
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
},
|
|
43
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export { KeyboardController } from './controller'
|
|
2
|
+
export { FocusedInputController } from './focused-input'
|
|
3
|
+
export type * from './focus-types'
|
|
4
|
+
export { KeyboardProvider } from './Provider.tsrx'
|
|
5
|
+
export type * from './types'
|
|
6
|
+
export type * from './props'
|
|
7
|
+
|
|
8
|
+
export { KeyboardScrollController } from './scroll-controller'
|
|
9
|
+
export { KeyboardAwareScrollView } from './KeyboardAwareScrollView'
|
|
10
|
+
export type * from './scroll-types'
|
|
11
|
+
|
|
12
|
+
export { KeyboardStickyView } from './KeyboardStickyView'
|
package/src/motion.ts
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { keyboardBottomOverlap } from '@octane-xplat/platform/keyboard-geometry'
|
|
2
|
+
import type { KeyboardRect } from '@octane-xplat/platform/keyboard-geometry'
|
|
3
|
+
import type { KeyboardWindowState } from '@octane-xplat/platform/keyboard-state'
|
|
4
|
+
|
|
5
|
+
/** Native adapters supply measurements; this layer never interpolates geometry. */
|
|
6
|
+
export function createKeyboardMotion(
|
|
7
|
+
publish: (state: KeyboardWindowState) => void,
|
|
8
|
+
capabilities: { interactive: boolean; floating: boolean },
|
|
9
|
+
) {
|
|
10
|
+
let state: KeyboardWindowState | undefined
|
|
11
|
+
let transitionId = 0
|
|
12
|
+
let moving = false
|
|
13
|
+
let durationMs: number | null = null
|
|
14
|
+
const emit = (phase: NonNullable<KeyboardWindowState['motion']>['phase']) => {
|
|
15
|
+
if (!state) {
|
|
16
|
+
return
|
|
17
|
+
}
|
|
18
|
+
state = {
|
|
19
|
+
...state,
|
|
20
|
+
motion: {
|
|
21
|
+
phase,
|
|
22
|
+
transitionId,
|
|
23
|
+
timestampMs: Date.now(),
|
|
24
|
+
durationMs,
|
|
25
|
+
nativeFrames: true,
|
|
26
|
+
...capabilities,
|
|
27
|
+
},
|
|
28
|
+
}
|
|
29
|
+
publish(state)
|
|
30
|
+
}
|
|
31
|
+
return {
|
|
32
|
+
begin(target: KeyboardRect | null, duration: number | null = null) {
|
|
33
|
+
if (moving) {
|
|
34
|
+
emit('cancel')
|
|
35
|
+
}
|
|
36
|
+
transitionId++
|
|
37
|
+
moving = true
|
|
38
|
+
durationMs = duration !== null && Number.isFinite(duration) ? Math.max(0, duration) : null
|
|
39
|
+
if (state) {
|
|
40
|
+
state = {
|
|
41
|
+
...state,
|
|
42
|
+
targetFrame: target,
|
|
43
|
+
inset: keyboardBottomOverlap(state.windowRect, target),
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
emit('start')
|
|
47
|
+
},
|
|
48
|
+
measure(
|
|
49
|
+
windowRect: KeyboardRect,
|
|
50
|
+
currentFrame: KeyboardRect,
|
|
51
|
+
targetFrame?: KeyboardRect | null,
|
|
52
|
+
bindingFrame?: KeyboardRect,
|
|
53
|
+
) {
|
|
54
|
+
state = {
|
|
55
|
+
windowRect,
|
|
56
|
+
currentFrame,
|
|
57
|
+
bindingFrame,
|
|
58
|
+
targetFrame: targetFrame === undefined ? (state?.targetFrame ?? currentFrame) : targetFrame,
|
|
59
|
+
inset: keyboardBottomOverlap(
|
|
60
|
+
windowRect,
|
|
61
|
+
targetFrame === undefined ? (state?.targetFrame ?? currentFrame) : targetFrame,
|
|
62
|
+
),
|
|
63
|
+
source: 'native-frames',
|
|
64
|
+
}
|
|
65
|
+
emit(moving ? 'move' : 'idle')
|
|
66
|
+
},
|
|
67
|
+
end(confirmed = false) {
|
|
68
|
+
if (!moving && !confirmed) {
|
|
69
|
+
return
|
|
70
|
+
}
|
|
71
|
+
moving = false
|
|
72
|
+
emit('end')
|
|
73
|
+
},
|
|
74
|
+
cancel() {
|
|
75
|
+
if (!moving) {
|
|
76
|
+
return
|
|
77
|
+
}
|
|
78
|
+
moving = false
|
|
79
|
+
emit('cancel')
|
|
80
|
+
},
|
|
81
|
+
}
|
|
82
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/** @internal Release every owned native resource, including failed attachment. */
|
|
2
|
+
export function keyboardNativeScope() {
|
|
3
|
+
let active = true
|
|
4
|
+
const releases: (() => void)[] = []
|
|
5
|
+
return {
|
|
6
|
+
defer(release: () => void) {
|
|
7
|
+
releases.push(release)
|
|
8
|
+
},
|
|
9
|
+
release() {
|
|
10
|
+
if (!active) {
|
|
11
|
+
return
|
|
12
|
+
}
|
|
13
|
+
active = false
|
|
14
|
+
const errors: unknown[] = []
|
|
15
|
+
for (const release of releases.reverse()) {
|
|
16
|
+
try {
|
|
17
|
+
release()
|
|
18
|
+
} catch (error) {
|
|
19
|
+
errors.push(error)
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
releases.length = 0
|
|
23
|
+
if (errors.length) {
|
|
24
|
+
throw new AggregateError(errors, 'Keyboard native cleanup failed')
|
|
25
|
+
}
|
|
26
|
+
},
|
|
27
|
+
}
|
|
28
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/** UIKit presentation measurements describe the displayed frame. A property
|
|
2
|
+
* commit made in that display callback appears on the next refresh. Estimate
|
|
3
|
+
* only that bounded interval; current geometry stays a separate measurement.
|
|
4
|
+
* @internal */
|
|
5
|
+
export function nextKeyboardPosition(options: {
|
|
6
|
+
current: number
|
|
7
|
+
previous: number
|
|
8
|
+
elapsedMs: number
|
|
9
|
+
nextMs: number
|
|
10
|
+
target: number
|
|
11
|
+
top: number
|
|
12
|
+
bottom: number
|
|
13
|
+
}): number {
|
|
14
|
+
const { current, previous, elapsedMs, nextMs, target, top, bottom } = options
|
|
15
|
+
if (
|
|
16
|
+
![current, previous, elapsedMs, nextMs, target, top, bottom].every(Number.isFinite) ||
|
|
17
|
+
elapsedMs <= 0 ||
|
|
18
|
+
elapsedMs > 50 ||
|
|
19
|
+
nextMs <= 0 ||
|
|
20
|
+
nextMs > 34
|
|
21
|
+
) {
|
|
22
|
+
return current
|
|
23
|
+
}
|
|
24
|
+
const velocity = (current - previous) / elapsedMs
|
|
25
|
+
let estimate = current + velocity * nextMs
|
|
26
|
+
// A reversal may continue in the old direction before turning. Preserve
|
|
27
|
+
// that measured direction, but do not overshoot a target being approached.
|
|
28
|
+
if ((target - current) * velocity > 0) {
|
|
29
|
+
estimate = velocity > 0 ? Math.min(target, estimate) : Math.max(target, estimate)
|
|
30
|
+
}
|
|
31
|
+
return Math.max(top, Math.min(bottom, estimate))
|
|
32
|
+
}
|
package/src/props.ts
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import type { KeyboardAvoidingProps, ViewProps } from '@octane-xplat/ui'
|
|
2
|
+
/** Shared tracking for existing keyboard consumers. Native observers start with
|
|
3
|
+
* the first window reader and stop with the last. Web/desktop are neutral.
|
|
4
|
+
* @example
|
|
5
|
+
* ```tsx
|
|
6
|
+
* import { KeyboardProvider } from '@octane-xplat/keyboard'
|
|
7
|
+
* import { KeyboardAvoiding, TextInput } from '@octane-xplat/ui'
|
|
8
|
+
* <KeyboardProvider><KeyboardAvoiding><TextInput /></KeyboardAvoiding></KeyboardProvider>
|
|
9
|
+
* ```
|
|
10
|
+
*/
|
|
11
|
+
export interface KeyboardProviderProps {
|
|
12
|
+
children?: unknown
|
|
13
|
+
/** Activate native tracking; false retains children and fallback behavior. @default true */
|
|
14
|
+
enabled?: boolean
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** A vertical form that exposes the focused field/caret while the keyboard moves. */
|
|
18
|
+
export interface KeyboardAwareScrollViewProps extends Omit<KeyboardAvoidingProps, 'ref'> {
|
|
19
|
+
/** Minimum visible gap; does not create scroll range. @default 8 */
|
|
20
|
+
clearance?: number
|
|
21
|
+
/** Additional bottom scroll range while the keyboard is open. @default 0 */
|
|
22
|
+
extraScrollSpace?: number
|
|
23
|
+
manualScroll?: 'pause' | 'follow'
|
|
24
|
+
keyboardClose?: 'preserve' | 'restore'
|
|
25
|
+
/** Mounted visibility binding; null on cleanup. */
|
|
26
|
+
ref?: (binding: import('./scroll-types').KeyboardScrollBinding | null) => void
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Translate a control independently of surrounding layout as the native keyboard moves. */
|
|
30
|
+
export interface KeyboardStickyViewProps extends ViewProps {
|
|
31
|
+
/** Disabled keeps its ordinary layout and releases native movement. @default true */
|
|
32
|
+
enabled?: boolean
|
|
33
|
+
/** Additional upward offset while open, in dips. @default 0 */
|
|
34
|
+
openOffset?: number
|
|
35
|
+
/** Upward offset while closed, in dips. @default 0 */
|
|
36
|
+
closedOffset?: number
|
|
37
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { KeyboardWindowState } from '@octane-xplat/platform/keyboard-state'
|
|
2
|
+
|
|
3
|
+
/** @internal Platform adapter; shared policy never reads SDK or DOM objects. */
|
|
4
|
+
export interface KeyboardRuntime {
|
|
5
|
+
activate(): () => void
|
|
6
|
+
subscribe(host: object, listener: (state: KeyboardWindowState) => void): () => void
|
|
7
|
+
getSnapshot(host: object): KeyboardWindowState
|
|
8
|
+
property(host: object): { read(): number; write(value: number): void } | null
|
|
9
|
+
overlap(host: object, state: KeyboardWindowState, translationY: number): number
|
|
10
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { keyboardWindows } from '@octane-xplat/platform/keyboard-state'
|
|
2
|
+
import { installKeyboardViewportAdapter } from '@octane-xplat/platform/keyboard-viewport'
|
|
3
|
+
import {
|
|
4
|
+
keyboardHostDisplacement,
|
|
5
|
+
keyboardSnapshotForHost,
|
|
6
|
+
observeKeyboardDestination,
|
|
7
|
+
subscribeKeyboardState,
|
|
8
|
+
} from '@octane-xplat/ui/nativescript/keyboard'
|
|
9
|
+
|
|
10
|
+
import { observeKeyboardMotion } from './source'
|
|
11
|
+
import type { KeyboardRuntime } from './runtime-types'
|
|
12
|
+
import { leaseKeyboardViewport } from './viewport'
|
|
13
|
+
import { focusRuntime } from './focus-runtime'
|
|
14
|
+
|
|
15
|
+
export const keyboardRuntime: KeyboardRuntime = {
|
|
16
|
+
activate() {
|
|
17
|
+
const offFocus = focusRuntime.activate()
|
|
18
|
+
let offViewport = () => {}
|
|
19
|
+
let offSource: () => void
|
|
20
|
+
try {
|
|
21
|
+
offViewport = installKeyboardViewportAdapter(leaseKeyboardViewport)
|
|
22
|
+
offSource = keyboardWindows.install(observeKeyboardMotion ?? observeKeyboardDestination)
|
|
23
|
+
} catch (error) {
|
|
24
|
+
try {
|
|
25
|
+
offViewport()
|
|
26
|
+
} finally {
|
|
27
|
+
offFocus()
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
throw error
|
|
31
|
+
}
|
|
32
|
+
let active = true
|
|
33
|
+
return () => {
|
|
34
|
+
if (!active) {
|
|
35
|
+
return
|
|
36
|
+
}
|
|
37
|
+
active = false
|
|
38
|
+
try {
|
|
39
|
+
offSource()
|
|
40
|
+
} finally {
|
|
41
|
+
try {
|
|
42
|
+
offViewport()
|
|
43
|
+
} finally {
|
|
44
|
+
offFocus()
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
},
|
|
49
|
+
subscribe: (host, listener) => subscribeKeyboardState(listener, host, false),
|
|
50
|
+
getSnapshot: keyboardSnapshotForHost,
|
|
51
|
+
property(host: any) {
|
|
52
|
+
return host?.nativeViewProtected
|
|
53
|
+
? {
|
|
54
|
+
read: () => Number(host.translateY) || 0,
|
|
55
|
+
write: (value) => {
|
|
56
|
+
host.translateY = value
|
|
57
|
+
},
|
|
58
|
+
}
|
|
59
|
+
: null
|
|
60
|
+
},
|
|
61
|
+
overlap: keyboardHostDisplacement,
|
|
62
|
+
}
|
package/src/runtime.ts
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { unknownKeyboardState } from '@octane-xplat/platform/keyboard-state'
|
|
2
|
+
import type { KeyboardRuntime } from './runtime-types'
|
|
3
|
+
|
|
4
|
+
const state = unknownKeyboardState()
|
|
5
|
+
|
|
6
|
+
/** Web and desktop retain neutral software-keyboard behavior. */
|
|
7
|
+
export const keyboardRuntime: KeyboardRuntime = {
|
|
8
|
+
activate: () => () => {},
|
|
9
|
+
subscribe(_host, listener) {
|
|
10
|
+
listener(state)
|
|
11
|
+
return () => {}
|
|
12
|
+
},
|
|
13
|
+
getSnapshot: () => state,
|
|
14
|
+
property: () => null,
|
|
15
|
+
overlap: () => 0,
|
|
16
|
+
}
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import { keyboardNativeScope } from './native-scope'
|
|
2
|
+
import { KeyboardController } from './controller'
|
|
3
|
+
import { FocusedInputController } from './focused-input'
|
|
4
|
+
import { focusScrollPlan } from './scroll-geometry'
|
|
5
|
+
import { scrollRuntime } from './scroll-runtime'
|
|
6
|
+
import type { KeyboardScrollOptions, KeyboardScrollService } from './scroll-types'
|
|
7
|
+
|
|
8
|
+
const checked = (options: KeyboardScrollOptions) => {
|
|
9
|
+
if (!Number.isFinite(options.clearance ?? 8) || (options.clearance ?? 8) < 0) {
|
|
10
|
+
throw new RangeError('Keyboard scroll clearance must be finite and nonnegative')
|
|
11
|
+
}
|
|
12
|
+
return { ...options }
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** Focus visibility without per-frame component rendering. */
|
|
16
|
+
export const KeyboardScrollController: KeyboardScrollService = {
|
|
17
|
+
bind(host, initial = {}) {
|
|
18
|
+
let options = checked(initial),
|
|
19
|
+
active = true,
|
|
20
|
+
paused = false,
|
|
21
|
+
applying = false
|
|
22
|
+
let focus = FocusedInputController.getSnapshot(host)
|
|
23
|
+
let keyboard = KeyboardController.getSnapshot(host)
|
|
24
|
+
let open = false
|
|
25
|
+
const restoration = new Map<object, { before: number; expected: number }>()
|
|
26
|
+
const scope = keyboardNativeScope()
|
|
27
|
+
const apply = () => {
|
|
28
|
+
if (!active || applying) {
|
|
29
|
+
return
|
|
30
|
+
}
|
|
31
|
+
applying = true
|
|
32
|
+
try {
|
|
33
|
+
const root = scrollRuntime.owner(host)
|
|
34
|
+
const frame = keyboard.currentFrame ?? keyboard.targetFrame
|
|
35
|
+
const bottom = keyboard.windowRect && keyboard.windowRect.y + keyboard.windowRect.height
|
|
36
|
+
const nowOpen =
|
|
37
|
+
keyboard.inset > 0 || !!(frame && bottom && frame.height > 0 && frame.y < bottom)
|
|
38
|
+
|
|
39
|
+
if (open && !nowOpen) {
|
|
40
|
+
if (options.keyboardClose === 'restore') {
|
|
41
|
+
for (const [owner, saved] of restoration) {
|
|
42
|
+
const current = scrollRuntime.read(owner)
|
|
43
|
+
if (current !== null && Math.abs(current - saved.expected) < 1) {
|
|
44
|
+
scrollRuntime.write(owner, saved.before)
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
restoration.clear()
|
|
50
|
+
open = false
|
|
51
|
+
return
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
open = nowOpen
|
|
55
|
+
if (!root || paused) {
|
|
56
|
+
return
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
for (const operation of focusScrollPlan(focus, keyboard, root, options.clearance ?? 8)) {
|
|
60
|
+
if (nowOpen) {
|
|
61
|
+
const previous = restoration.get(operation.id)
|
|
62
|
+
restoration.set(operation.id, {
|
|
63
|
+
before: previous?.before ?? operation.from,
|
|
64
|
+
expected: operation.to,
|
|
65
|
+
})
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
scrollRuntime.write(operation.id, operation.to)
|
|
69
|
+
}
|
|
70
|
+
} finally {
|
|
71
|
+
applying = false
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
const release = () => {
|
|
75
|
+
if (!active) {
|
|
76
|
+
return
|
|
77
|
+
}
|
|
78
|
+
active = false
|
|
79
|
+
try {
|
|
80
|
+
scope.release()
|
|
81
|
+
} finally {
|
|
82
|
+
restoration.clear()
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
try {
|
|
86
|
+
scope.defer(
|
|
87
|
+
scrollRuntime.watchManual(host, () => {
|
|
88
|
+
paused = options.manualScroll !== 'follow'
|
|
89
|
+
restoration.clear()
|
|
90
|
+
}),
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
scope.defer(
|
|
94
|
+
KeyboardController.onMove(host, (state) => {
|
|
95
|
+
keyboard = state
|
|
96
|
+
apply()
|
|
97
|
+
}),
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
scope.defer(
|
|
101
|
+
FocusedInputController.subscribe(host, (state) => {
|
|
102
|
+
if (
|
|
103
|
+
state.input !== focus.input ||
|
|
104
|
+
state.selection?.start !== focus.selection?.start ||
|
|
105
|
+
state.selection?.end !== focus.selection?.end
|
|
106
|
+
) {
|
|
107
|
+
paused = false
|
|
108
|
+
}
|
|
109
|
+
focus = state
|
|
110
|
+
apply()
|
|
111
|
+
}),
|
|
112
|
+
)
|
|
113
|
+
} catch (error) {
|
|
114
|
+
release()
|
|
115
|
+
throw error
|
|
116
|
+
}
|
|
117
|
+
return {
|
|
118
|
+
release,
|
|
119
|
+
update(next) {
|
|
120
|
+
options = checked(next)
|
|
121
|
+
if (active) {
|
|
122
|
+
apply()
|
|
123
|
+
}
|
|
124
|
+
},
|
|
125
|
+
recheck() {
|
|
126
|
+
if (!active) {
|
|
127
|
+
return
|
|
128
|
+
}
|
|
129
|
+
paused = false
|
|
130
|
+
FocusedInputController.recheck(host)
|
|
131
|
+
focus = FocusedInputController.getSnapshot(host)
|
|
132
|
+
apply()
|
|
133
|
+
},
|
|
134
|
+
}
|
|
135
|
+
},
|
|
136
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import type { KeyboardRect } from '@octane-xplat/platform/keyboard-geometry'
|
|
2
|
+
import type { FocusedInputSnapshot } from './focus-types'
|
|
3
|
+
import type { KeyboardSnapshot } from './types'
|
|
4
|
+
|
|
5
|
+
export interface FocusScrollOperation {
|
|
6
|
+
id: object
|
|
7
|
+
from: number
|
|
8
|
+
to: number
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/** Plan nearest-first minimal scrolling; SDK writes live in platform leaves. */
|
|
12
|
+
export function focusScrollPlan(
|
|
13
|
+
focus: FocusedInputSnapshot,
|
|
14
|
+
keyboard: KeyboardSnapshot,
|
|
15
|
+
root: object,
|
|
16
|
+
clearance: number,
|
|
17
|
+
): FocusScrollOperation[] {
|
|
18
|
+
const end = focus.scrollOwners.findIndex((owner) => owner.id === root)
|
|
19
|
+
if (end < 0 || !focus.inputRect || !focus.windowRect) {
|
|
20
|
+
return []
|
|
21
|
+
}
|
|
22
|
+
const owners = focus.scrollOwners.slice(0, end + 1)
|
|
23
|
+
let target: KeyboardRect = { ...focus.inputRect }
|
|
24
|
+
let windowBottom = focus.windowRect.y + focus.windowRect.height
|
|
25
|
+
const frame = keyboard.currentFrame ?? keyboard.targetFrame
|
|
26
|
+
if (
|
|
27
|
+
frame &&
|
|
28
|
+
frame.y + frame.height >= windowBottom - 1 &&
|
|
29
|
+
frame.height > 0 &&
|
|
30
|
+
frame.x < target.x + target.width &&
|
|
31
|
+
frame.x + frame.width > target.x
|
|
32
|
+
) {
|
|
33
|
+
windowBottom = Math.min(windowBottom, frame.y)
|
|
34
|
+
}
|
|
35
|
+
const available =
|
|
36
|
+
Math.min(
|
|
37
|
+
...owners.map(
|
|
38
|
+
(owner) =>
|
|
39
|
+
Math.min(owner.rect.y + owner.rect.height, windowBottom) -
|
|
40
|
+
Math.max(owner.rect.y, focus.windowRect!.y),
|
|
41
|
+
),
|
|
42
|
+
) -
|
|
43
|
+
clearance * 2
|
|
44
|
+
if (target.height > available && focus.caretRect) {
|
|
45
|
+
target = { ...focus.caretRect }
|
|
46
|
+
}
|
|
47
|
+
const result: FocusScrollOperation[] = []
|
|
48
|
+
for (const owner of owners) {
|
|
49
|
+
const top = Math.max(owner.rect.y, focus.windowRect.y) + clearance
|
|
50
|
+
const bottom = Math.min(owner.rect.y + owner.rect.height, windowBottom) - clearance
|
|
51
|
+
if (bottom <= top) {
|
|
52
|
+
continue
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const delta =
|
|
56
|
+
target.height > bottom - top
|
|
57
|
+
? target.y - top
|
|
58
|
+
: target.y < top
|
|
59
|
+
? target.y - top
|
|
60
|
+
: target.y + target.height > bottom
|
|
61
|
+
? target.y + target.height - bottom
|
|
62
|
+
: 0
|
|
63
|
+
|
|
64
|
+
const to = Math.max(owner.minOffsetY, Math.min(owner.maxOffsetY, owner.offsetY + delta))
|
|
65
|
+
const moved = to - owner.offsetY
|
|
66
|
+
if (Math.abs(moved) > 0.5) {
|
|
67
|
+
result.push({ id: owner.id, from: owner.offsetY, to })
|
|
68
|
+
target = { ...target, y: target.y - moved }
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
return result
|
|
72
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { NSNotificationCenter, NSOperationQueue } from '@octane-xplat/platform/macos'
|
|
2
|
+
import type { FocusScrollRuntime } from './scroll-runtime-types'
|
|
3
|
+
export const scrollRuntime: FocusScrollRuntime = {
|
|
4
|
+
owner: (host) => (host as any).native ?? host,
|
|
5
|
+
read(owner: any) {
|
|
6
|
+
const clip = owner.contentView,
|
|
7
|
+
content = owner.documentView
|
|
8
|
+
if (!clip || !content) {
|
|
9
|
+
return null
|
|
10
|
+
}
|
|
11
|
+
const flipped =
|
|
12
|
+
typeof content.isFlipped === 'function' ? content.isFlipped() : content.isFlipped
|
|
13
|
+
const max = Math.max(0, content.bounds.size.height - clip.bounds.size.height)
|
|
14
|
+
return flipped ? clip.bounds.origin.y : max - clip.bounds.origin.y
|
|
15
|
+
},
|
|
16
|
+
write(owner: any, offset) {
|
|
17
|
+
const clip = owner.contentView,
|
|
18
|
+
content = owner.documentView
|
|
19
|
+
if (!clip || !content) {
|
|
20
|
+
return
|
|
21
|
+
}
|
|
22
|
+
const flipped =
|
|
23
|
+
typeof content.isFlipped === 'function' ? content.isFlipped() : content.isFlipped
|
|
24
|
+
const max = Math.max(0, content.bounds.size.height - clip.bounds.size.height)
|
|
25
|
+
clip.scrollToPoint({
|
|
26
|
+
x: clip.bounds.origin.x,
|
|
27
|
+
y: flipped ? Math.max(0, Math.min(max, offset)) : max - Math.max(0, Math.min(max, offset)),
|
|
28
|
+
})
|
|
29
|
+
owner.reflectScrolledClipView(clip)
|
|
30
|
+
},
|
|
31
|
+
watchManual(host: any, listener) {
|
|
32
|
+
const center = NSNotificationCenter.defaultCenter
|
|
33
|
+
const token = center.addObserverForNameObjectQueueUsingBlock(
|
|
34
|
+
'NSScrollViewWillStartLiveScrollNotification',
|
|
35
|
+
host.native ?? host,
|
|
36
|
+
NSOperationQueue.mainQueue,
|
|
37
|
+
listener,
|
|
38
|
+
)
|
|
39
|
+
return () => center.removeObserver(token)
|
|
40
|
+
},
|
|
41
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { Utils } from '@nativescript/core'
|
|
2
|
+
import type { FocusScrollRuntime } from './scroll-runtime-types'
|
|
3
|
+
const native = (host: any) => host.nativeViewProtected ?? host.ios ?? host.android
|
|
4
|
+
export const scrollRuntime: FocusScrollRuntime = {
|
|
5
|
+
owner: (host) => native((host as any).native ?? host) ?? null,
|
|
6
|
+
read: (owner: any) =>
|
|
7
|
+
owner.setContentOffsetAnimated || typeof owner.mason_scrollTo !== 'function'
|
|
8
|
+
? (owner.contentOffset?.y ?? Utils.layout.toDeviceIndependentPixels(owner.getScrollY()))
|
|
9
|
+
: owner.mason_scrollTop,
|
|
10
|
+
write(owner: any, offset) {
|
|
11
|
+
if (owner.setContentOffsetAnimated) {
|
|
12
|
+
owner.setContentOffsetAnimated(
|
|
13
|
+
{
|
|
14
|
+
x: owner.contentOffset.x,
|
|
15
|
+
y: Math.max(
|
|
16
|
+
-(owner.adjustedContentInset?.top ?? 0),
|
|
17
|
+
Math.min(
|
|
18
|
+
offset,
|
|
19
|
+
Math.max(
|
|
20
|
+
-(owner.adjustedContentInset?.top ?? 0),
|
|
21
|
+
owner.contentSize.height -
|
|
22
|
+
owner.bounds.size.height +
|
|
23
|
+
(owner.adjustedContentInset?.bottom ?? 0),
|
|
24
|
+
),
|
|
25
|
+
),
|
|
26
|
+
),
|
|
27
|
+
},
|
|
28
|
+
false,
|
|
29
|
+
)
|
|
30
|
+
} else if (typeof owner.mason_scrollTo === 'function') {
|
|
31
|
+
owner.mason_scrollTo(NaN, offset, false)
|
|
32
|
+
} else {
|
|
33
|
+
owner.scrollTo(owner.getScrollX(), Utils.layout.toDevicePixels(offset))
|
|
34
|
+
}
|
|
35
|
+
},
|
|
36
|
+
watchManual(host: any, listener) {
|
|
37
|
+
const view = host.native ?? host
|
|
38
|
+
const touch = (event: any) => {
|
|
39
|
+
if (event.action === 'move') {
|
|
40
|
+
listener()
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
view.on('touch', touch)
|
|
44
|
+
return () => view.off('touch', touch)
|
|
45
|
+
},
|
|
46
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { FocusScrollRuntime } from './scroll-runtime-types'
|
|
2
|
+
export const scrollRuntime: FocusScrollRuntime = {
|
|
3
|
+
owner: (host) => (host as any).native ?? host,
|
|
4
|
+
read: (owner) => (owner as HTMLElement).scrollTop,
|
|
5
|
+
write(owner, offset) {
|
|
6
|
+
;(owner as HTMLElement).scrollTop = offset
|
|
7
|
+
},
|
|
8
|
+
watchManual(host, listener) {
|
|
9
|
+
const view = ((host as any).native ?? host) as HTMLElement
|
|
10
|
+
const events = ['wheel', 'touchmove', 'pointermove']
|
|
11
|
+
const manual = (event: Event) => {
|
|
12
|
+
if (event.type !== 'pointermove' || (event as PointerEvent).buttons) {
|
|
13
|
+
listener()
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
for (const event of events) {
|
|
17
|
+
view.addEventListener(event, manual, { passive: true })
|
|
18
|
+
}
|
|
19
|
+
return () => {
|
|
20
|
+
for (const event of events) {
|
|
21
|
+
view.removeEventListener(event, manual)
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
},
|
|
25
|
+
}
|