@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 +143 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +2 -0
- package/dist/useKeyboardInset.d.ts +5 -0
- package/dist/useKeyboardInset.js +47 -0
- package/dist/useUndoRedo.d.ts +15 -0
- package/dist/useUndoRedo.js +56 -0
- package/dist/useUndoRedoWithSelection.d.ts +17 -0
- package/dist/useUndoRedoWithSelection.js +78 -0
- package/package.json +30 -0
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.
|
package/dist/index.d.ts
ADDED
package/dist/index.js
ADDED
|
@@ -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
|
+
}
|