@minbong/keyboard-inset 0.1.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 ADDED
@@ -0,0 +1,143 @@
1
+ # @react-native-utils/keyboard-inset
2
+
3
+ A set of React Native hooks for handling keyboard insets, overlays, and stable cursor behavior across iOS and Android.
4
+
5
+ This library solves common but tricky issues such as:
6
+ - Incorrect keyboard height on Android
7
+ - Floating toolbars hidden behind the keyboard
8
+ - Cursor jumping on iOS during undo/redo
9
+ - Stable selection handling for TextInput
10
+
11
+ ---
12
+
13
+ ## ✨ Features
14
+
15
+ - πŸ“ Accurate keyboard inset calculation (iOS & Android)
16
+ - 🧩 Overlay registration system (toolbars, accessories, etc.)
17
+ - πŸ”„ Undo / Redo with stable cursor restoration
18
+ - 🍎 iOS cursor offset fixes
19
+ - πŸ€– Android keyboard behavior normalization
20
+ - βš™οΈ Fully typed (TypeScript)
21
+
22
+ ---
23
+
24
+ ## πŸ“¦ Installation
25
+
26
+ ```bash
27
+ npm install @react-native-utils/keyboard-inset
28
+ # or
29
+ yarn add @react-native-utils/keyboard-inset
30
+ ```
31
+
32
+ ---
33
+
34
+ ## πŸš€ Basic Usage
35
+
36
+ ```ts
37
+ import { useKeyboardInset } from '@react-native-utils/keyboard-inset';
38
+
39
+ const { keyboardInset, keyboardVisible } = useKeyboardInset();
40
+ ```
41
+
42
+ ### API
43
+
44
+ #### `useKeyboardInset()`
45
+
46
+ ```ts
47
+ const {
48
+ keyboardInset, // number: bottom inset to apply
49
+ keyboardVisible, // boolean: whether the keyboard is visible
50
+ registerOverlay, // (height: number) => () => void
51
+ } = useKeyboardInset();
52
+ ```
53
+
54
+ - `keyboardInset`: Calculated bottom inset that accounts for the keyboard and any registered overlays.
55
+ - `keyboardVisible`: Whether the keyboard is currently visible.
56
+ - `registerOverlay(height)`: Registers an overlay (e.g. toolbar).
57
+ Returns a cleanup function that unregisters it on unmount.
58
+
59
+ ```tsx
60
+ <View style={{ position: 'absolute', bottom: keyboardInset }}>
61
+ ...
62
+ </View>
63
+ ```
64
+
65
+ ---
66
+
67
+ ## 🧩 Registering an Overlay (Toolbar, Accessory, etc.)
68
+
69
+ If your screen has a floating toolbar that should sit above the keyboard:
70
+
71
+ ```ts
72
+ const { registerOverlay } = useKeyboardInset();
73
+
74
+ useEffect(() => {
75
+ return registerOverlay(44); // overlay height
76
+ }, []);
77
+ ```
78
+ The inset will automatically adjust while the overlay is mounted.
79
+
80
+ ---
81
+
82
+ ## πŸ”„ Undo / Redo with Stable Cursor
83
+
84
+ ```ts
85
+ import { useUndoRedoWithSelection } from '@react-native-utils/keyboard-inset';
86
+
87
+ const {
88
+ currentText,
89
+ commitHistory,
90
+ undoStep,
91
+ redoStep,
92
+ registerSelection,
93
+ } = useUndoRedoWithSelection({
94
+ initialValue: '',
95
+ inputRef,
96
+ });
97
+ ```
98
+
99
+ ```tsx
100
+ <TextInput
101
+ ref={inputRef}
102
+ value={currentText}
103
+ onChangeText={commitHistory}
104
+ onSelectionChange={e =>
105
+ registerSelection(e.nativeEvent.selection)
106
+ }
107
+ />
108
+ ```
109
+ ### βœ… Cursor behavior
110
+ - Normal typing: cursor is untouched
111
+ - Undo / Redo: cursor is restored accurately
112
+ - iOS mid-text edits: no jumping
113
+
114
+ ---
115
+
116
+ ## ⚠️ iOS Notes
117
+
118
+ On iOS, `TextInput` selection can be off by one after undo/redo operations.
119
+ This library applies a platform-specific correction **only during history navigation**.
120
+ Normal typing and mid-text edits are never interfered with.
121
+
122
+ ---
123
+
124
+ ## 🧠 Design Philosophy
125
+
126
+ - Hooks report facts, not assumptions
127
+ - UI components declare their presence
128
+ - Cursor control is applied only when necessary
129
+
130
+ ``Cursor position should only be restored during undo/redo, never during normal typing.``
131
+
132
+ ---
133
+
134
+ ## ❓ Why this library exists
135
+
136
+ React Native does not provide a reliable, cross-platform way to:
137
+
138
+ - Measure the real keyboard inset on Android
139
+ - Keep floating toolbars above the keyboard
140
+ - Preserve cursor position during undo/redo on iOS
141
+
142
+ This package addresses those gaps with a small, focused set of hooks that follow
143
+ React's rules and native platform behaviors.
@@ -0,0 +1,3 @@
1
+ export { useKeyboardInset } from './useKeyboardInset';
2
+ export { useUndoRedoWithSelection } from './useUndoRedoWithSelection';
3
+ export type { Selection } from './useUndoRedoWithSelection';
package/dist/index.js ADDED
@@ -0,0 +1,2 @@
1
+ export { useKeyboardInset } from './useKeyboardInset';
2
+ export { useUndoRedoWithSelection } from './useUndoRedoWithSelection';
@@ -0,0 +1,5 @@
1
+ export declare function useKeyboardInset(): {
2
+ keyboardVisible: boolean;
3
+ keyboardInset: number;
4
+ registerOverlay: (height: number) => () => void;
5
+ };
@@ -0,0 +1,47 @@
1
+ import { useEffect, useRef, useState, useCallback } from 'react';
2
+ import { Keyboard, Platform } from 'react-native';
3
+ export function useKeyboardInset() {
4
+ const keyboardHeightRef = useRef(0);
5
+ const overlayHeightsRef = useRef(new Map());
6
+ const [visible, setVisible] = useState(false);
7
+ const [, forceRender] = useState(0);
8
+ useEffect(() => {
9
+ const showSub = Keyboard.addListener('keyboardDidShow', e => {
10
+ keyboardHeightRef.current = e.endCoordinates.height;
11
+ setVisible(true);
12
+ forceRender(v => v + 1);
13
+ });
14
+ const hideSub = Keyboard.addListener('keyboardDidHide', () => {
15
+ keyboardHeightRef.current = 0;
16
+ setVisible(false);
17
+ forceRender(v => v + 1);
18
+ });
19
+ return () => {
20
+ showSub.remove();
21
+ hideSub.remove();
22
+ };
23
+ }, []);
24
+ /**
25
+ * πŸ”‘ Overlay 등둝 (Toolbar, Accessory λ“±)
26
+ * mount μ‹œ 호좜, unmount μ‹œ μžλ™ ν•΄μ œ
27
+ */
28
+ const registerOverlay = useCallback((height) => {
29
+ const id = Date.now() + Math.random();
30
+ overlayHeightsRef.current.set(id, height);
31
+ forceRender(v => v + 1);
32
+ return () => {
33
+ overlayHeightsRef.current.delete(id);
34
+ forceRender(v => v + 1);
35
+ };
36
+ }, []);
37
+ const keyboardHeight = keyboardHeightRef.current;
38
+ const overlayHeight = Array.from(overlayHeightsRef.current.values()).reduce((sum, h) => sum + h, 0);
39
+ const keyboardInset = Platform.OS === 'android'
40
+ ? keyboardHeight + overlayHeight
41
+ : keyboardHeight;
42
+ return {
43
+ keyboardVisible: visible,
44
+ keyboardInset,
45
+ registerOverlay,
46
+ };
47
+ }
@@ -0,0 +1,15 @@
1
+ export type UndoRedoState<T> = {
2
+ past: T[];
3
+ present: T;
4
+ future: T[];
5
+ };
6
+ export declare function useUndoRedo<T>(initialState: T): {
7
+ state: T;
8
+ set: (next: T) => void;
9
+ undo: () => void;
10
+ redo: () => void;
11
+ reset: (next: T) => void;
12
+ canUndo: boolean;
13
+ canRedo: boolean;
14
+ history: UndoRedoState<T>;
15
+ };
@@ -0,0 +1,56 @@
1
+ import { useCallback, useState } from 'react';
2
+ export function useUndoRedo(initialState) {
3
+ const [state, setState] = useState({
4
+ past: [],
5
+ present: initialState,
6
+ future: [],
7
+ });
8
+ const set = useCallback((next) => {
9
+ setState(prev => {
10
+ if (Object.is(prev.present, next))
11
+ return prev;
12
+ return {
13
+ past: [...prev.past, prev.present],
14
+ present: next,
15
+ future: [],
16
+ };
17
+ });
18
+ }, []);
19
+ const undo = useCallback(() => {
20
+ setState(prev => {
21
+ if (!prev.past.length)
22
+ return prev;
23
+ const previous = prev.past[prev.past.length - 1];
24
+ return {
25
+ past: prev.past.slice(0, -1),
26
+ present: previous,
27
+ future: [prev.present, ...prev.future],
28
+ };
29
+ });
30
+ }, []);
31
+ const redo = useCallback(() => {
32
+ setState(prev => {
33
+ if (!prev.future.length)
34
+ return prev;
35
+ const next = prev.future[0];
36
+ return {
37
+ past: [...prev.past, prev.present],
38
+ present: next,
39
+ future: prev.future.slice(1),
40
+ };
41
+ });
42
+ }, []);
43
+ const reset = useCallback((next) => {
44
+ setState({ past: [], present: next, future: [] });
45
+ }, []);
46
+ return {
47
+ state: state.present,
48
+ set,
49
+ undo,
50
+ redo,
51
+ reset,
52
+ canUndo: state.past.length > 0,
53
+ canRedo: state.future.length > 0,
54
+ history: state,
55
+ };
56
+ }
@@ -0,0 +1,17 @@
1
+ export type Selection = {
2
+ start: number;
3
+ end: number;
4
+ };
5
+ export declare function useUndoRedoWithSelection({ initialValue, inputRef, }: {
6
+ initialValue: string;
7
+ inputRef?: React.RefObject<any>;
8
+ }): {
9
+ currentText: string;
10
+ commitHistory: (text: string) => void;
11
+ undoStep: () => void;
12
+ redoStep: () => void;
13
+ canUndo: boolean;
14
+ canRedo: boolean;
15
+ registerSelection: (selection: Selection) => void;
16
+ resetHistory: (text: string) => void;
17
+ };
@@ -0,0 +1,78 @@
1
+ import { useEffect, useRef } from 'react';
2
+ import { Platform } from 'react-native';
3
+ import { useUndoRedo } from './useUndoRedo';
4
+ export function useUndoRedoWithSelection({ initialValue, inputRef, }) {
5
+ const lastSelectionRef = useRef({
6
+ start: initialValue.length,
7
+ end: initialValue.length,
8
+ });
9
+ const undoCore = useUndoRedo({
10
+ text: initialValue,
11
+ selection: {
12
+ start: initialValue.length,
13
+ end: initialValue.length,
14
+ },
15
+ });
16
+ const isHistoryNavigationRef = useRef(false);
17
+ const undoStep = () => {
18
+ isHistoryNavigationRef.current = true;
19
+ undoCore.undo();
20
+ };
21
+ const redoStep = () => {
22
+ isHistoryNavigationRef.current = true;
23
+ undoCore.redo();
24
+ };
25
+ /**
26
+ * βœ… selection changeλŠ” "참고용"
27
+ * (undo 볡원 μ‹œ μ‚¬μš©)
28
+ */
29
+ const registerSelection = (selection) => {
30
+ lastSelectionRef.current = selection;
31
+ };
32
+ /**
33
+ * βœ… commit μ‹œμ μ— selection을 "ν™•μ •"ν•΄μ„œ λ§Œλ“ λ‹€
34
+ */
35
+ const commitHistory = (text) => {
36
+ undoCore.set({
37
+ text,
38
+ selection: lastSelectionRef.current,
39
+ });
40
+ };
41
+ /**
42
+ * βœ… undo / redo 이후 μ»€μ„œ 볡원
43
+ */
44
+ useEffect(() => {
45
+ if (!isHistoryNavigationRef.current)
46
+ return;
47
+ if (!(inputRef === null || inputRef === void 0 ? void 0 : inputRef.current))
48
+ return;
49
+ requestAnimationFrame(() => {
50
+ var _a, _b;
51
+ let selectionToRestore = undoCore.state.selection;
52
+ const textLength = undoCore.state.text.length;
53
+ if (Platform.OS === 'ios' && selectionToRestore.start < textLength) {
54
+ selectionToRestore = {
55
+ start: selectionToRestore.start + 1,
56
+ end: selectionToRestore.end + 1,
57
+ };
58
+ }
59
+ (_b = (_a = inputRef.current).setNativeProps) === null || _b === void 0 ? void 0 : _b.call(_a, {
60
+ selection: selectionToRestore,
61
+ });
62
+ isHistoryNavigationRef.current = false;
63
+ });
64
+ }, [undoCore.state, inputRef]);
65
+ return {
66
+ currentText: undoCore.state.text,
67
+ commitHistory,
68
+ undoStep,
69
+ redoStep,
70
+ canUndo: undoCore.canUndo,
71
+ canRedo: undoCore.canRedo,
72
+ registerSelection,
73
+ resetHistory: (text) => undoCore.reset({
74
+ text,
75
+ selection: { start: text.length, end: text.length },
76
+ }),
77
+ };
78
+ }
package/package.json ADDED
@@ -0,0 +1,30 @@
1
+ {
2
+ "name" : "@minbong/keyboard-inset",
3
+ "version" : "0.1.0",
4
+ "description" : "React Native hooks for accurate keyboard insets, overlay registration, and stable cursor behavior (iOS & Android).",
5
+ "main" : "dist/index.js",
6
+ "types" : "dist/index.d.ts",
7
+ "exports" : {
8
+ "." : {
9
+ "types" : "./dist/index.d.ts",
10
+ "default" : "./dist/index.js"
11
+ }
12
+ },
13
+ "files" : [
14
+ "dist"
15
+ ],
16
+ "scripts" : {
17
+ "build" : "tsc",
18
+ "prepublishOnly" : "npm run build"
19
+ },
20
+ "peerDependencies" : {
21
+ "react" : ">=17",
22
+ "react-native" : ">=0.68"
23
+ },
24
+ "license" : "MIT",
25
+ "devDependencies" : {
26
+ "@types/react" : "^18.2.0",
27
+ "@types/react-native" : "^0.72.8",
28
+ "typescript" : "^5.9.3"
29
+ }
30
+ }