@bsky.app/tapper 0.4.1 → 0.4.3
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/CHANGELOG.md +18 -0
- package/README.md +54 -33
- package/build/index.d.ts +5 -5
- package/build/index.d.ts.map +1 -1
- package/build/index.js +28 -72
- package/build/index.js.map +1 -1
- package/package.json +1 -1
- package/src/index.ts +35 -80
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,23 @@
|
|
|
1
1
|
# @bsky.app/tapper
|
|
2
2
|
|
|
3
|
+
## 0.4.3
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- [`047391d`](https://github.com/bluesky-social/toolbox/commit/047391df8efff5be9b176eae32ab1a879e7290cb) Thanks [@estrattonbailey](https://github.com/estrattonbailey)! - Handle fixes for web
|
|
8
|
+
|
|
9
|
+
- [`6ef6682`](https://github.com/bluesky-social/toolbox/commit/6ef6682a0d2c877a62bf0fc49452e8ce3b203cd2) Thanks [@estrattonbailey](https://github.com/estrattonbailey)! - Move to uncontrolled input for better old-arch support
|
|
10
|
+
|
|
11
|
+
- [`018d8d3`](https://github.com/bluesky-social/toolbox/commit/018d8d39e434d7f9b5d0e1b53e4f8528cb188665) Thanks [@estrattonbailey](https://github.com/estrattonbailey)! - Only use debounce hack if required by platform
|
|
12
|
+
|
|
13
|
+
## 0.4.2
|
|
14
|
+
|
|
15
|
+
### Patch Changes
|
|
16
|
+
|
|
17
|
+
- [`f9f7c41`](https://github.com/bluesky-social/toolbox/commit/f9f7c41b38ad8e33611effdedefc53271b666db4) Thanks [@estrattonbailey](https://github.com/estrattonbailey)! - Update tapper/sift docs
|
|
18
|
+
|
|
19
|
+
- [`b7bd0c4`](https://github.com/bluesky-social/toolbox/commit/b7bd0c4fa743157445336f2fc2c7d0583c00ccc5) Thanks [@estrattonbailey](https://github.com/estrattonbailey)! - Export facets as `facets`
|
|
20
|
+
|
|
3
21
|
## 0.4.1
|
|
4
22
|
|
|
5
23
|
### Patch Changes
|
package/README.md
CHANGED
|
@@ -21,17 +21,17 @@ import {View, Text, TextInput} from 'react-native'
|
|
|
21
21
|
import {useTapper} from '@bsky.app/tapper'
|
|
22
22
|
|
|
23
23
|
function Composer() {
|
|
24
|
-
const {
|
|
24
|
+
const {state, input, inputProps, on} = useTapper({
|
|
25
25
|
facets: {
|
|
26
26
|
mention: /@([a-zA-Z0-9._]+)/g,
|
|
27
27
|
emoji: /:([a-zA-Z0-9_]+):/g,
|
|
28
28
|
},
|
|
29
29
|
})
|
|
30
30
|
|
|
31
|
-
// Re-focus after an autocomplete
|
|
31
|
+
// Re-focus after an autocomplete replacement
|
|
32
32
|
useEffect(() => {
|
|
33
|
-
return on('afterInsert', () => focus())
|
|
34
|
-
}, [on, focus])
|
|
33
|
+
return on('afterInsert', () => input.focus())
|
|
34
|
+
}, [on, input.focus])
|
|
35
35
|
|
|
36
36
|
return (
|
|
37
37
|
<View style={{position: 'relative'}}>
|
|
@@ -39,9 +39,9 @@ function Composer() {
|
|
|
39
39
|
<View
|
|
40
40
|
style={{position: 'absolute', top: 0, left: 0, right: 0, bottom: 0}}>
|
|
41
41
|
<Text>
|
|
42
|
-
{nodes.map(
|
|
42
|
+
{state.nodes.map(node => (
|
|
43
43
|
<Text
|
|
44
|
-
key={
|
|
44
|
+
key={node.id}
|
|
45
45
|
style={{
|
|
46
46
|
color:
|
|
47
47
|
node.type === 'facet'
|
|
@@ -78,14 +78,13 @@ family, size, line height, padding) so they align exactly.
|
|
|
78
78
|
|
|
79
79
|
`useTapper` returns:
|
|
80
80
|
|
|
81
|
-
| Property
|
|
82
|
-
|
|
|
83
|
-
| `
|
|
84
|
-
| `
|
|
85
|
-
| `
|
|
86
|
-
| `
|
|
87
|
-
| `
|
|
88
|
-
| `focus` | Re-focus the input (useful after `commit`) |
|
|
81
|
+
| Property | Description |
|
|
82
|
+
| ------------ | ----------------------------------------------------- |
|
|
83
|
+
| `state` | Snapshot: `text`, `selection`, `nodes`, `activeFacet` |
|
|
84
|
+
| `inputProps` | Spread onto your `TextInput` |
|
|
85
|
+
| `on` | Subscribe to events |
|
|
86
|
+
| `input` | `{element, focus(), blur()}` |
|
|
87
|
+
| `insert` | Insert text at the current cursor position |
|
|
89
88
|
|
|
90
89
|
### Nodes
|
|
91
90
|
|
|
@@ -97,18 +96,21 @@ Each node has a `type` discriminant:
|
|
|
97
96
|
- **`'facet'`** — a matched pattern. Has `facetType` (e.g. `'mention'`,
|
|
98
97
|
`'emoji'`).
|
|
99
98
|
|
|
100
|
-
All nodes have
|
|
101
|
-
|
|
99
|
+
All nodes have:
|
|
100
|
+
|
|
101
|
+
- `id` — stable numeric ID for use as a React `key`
|
|
102
|
+
- `raw` — full matched text including trigger (use for display)
|
|
103
|
+
- `value` — content only, trigger stripped
|
|
102
104
|
|
|
103
105
|
```ts
|
|
104
106
|
// Typing "@eric" produces:
|
|
105
|
-
{type: 'facet', facetType: 'mention', raw: '@eric', value: 'eric', start: 0, end: 5}
|
|
107
|
+
{id: 1, type: 'facet', facetType: 'mention', raw: '@eric', value: 'eric', start: 0, end: 5}
|
|
106
108
|
```
|
|
107
109
|
|
|
108
110
|
### Active facet
|
|
109
111
|
|
|
110
112
|
When the cursor is inside a facet or right after a trigger character,
|
|
111
|
-
`activeFacet` is non-null:
|
|
113
|
+
`state.activeFacet` is non-null:
|
|
112
114
|
|
|
113
115
|
```ts
|
|
114
116
|
activeFacet: {
|
|
@@ -123,13 +125,23 @@ Call `activeFacet.replace('@eric')` to replace the in-progress facet with a
|
|
|
123
125
|
final value. A trailing space is appended by default; pass
|
|
124
126
|
`{noTrailingSpace: true}` to suppress it.
|
|
125
127
|
|
|
126
|
-
|
|
128
|
+
### `insert(text)`
|
|
129
|
+
|
|
130
|
+
Insert text at the current cursor position. This is a method on the `useTapper`
|
|
131
|
+
return value (not on `activeFacet`).
|
|
132
|
+
|
|
133
|
+
```tsx
|
|
134
|
+
const {insert} = useTapper()
|
|
135
|
+
insert('👋') // inserts at cursor
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
## Emoji auto-replace example
|
|
127
139
|
|
|
128
140
|
A common pattern: when the user types a closing `:` on an emoji shortcode
|
|
129
141
|
(e.g. `:wave:`), immediately replace it with the emoji character.
|
|
130
142
|
|
|
131
143
|
```tsx
|
|
132
|
-
const {
|
|
144
|
+
const {state, input, on} = useTapper({
|
|
133
145
|
facets: {
|
|
134
146
|
emoji: /:([a-zA-Z0-9_]+):/g,
|
|
135
147
|
},
|
|
@@ -153,10 +165,10 @@ useEffect(() => {
|
|
|
153
165
|
})
|
|
154
166
|
}, [on])
|
|
155
167
|
|
|
156
|
-
// Re-focus after
|
|
168
|
+
// Re-focus after replacement
|
|
157
169
|
useEffect(() => {
|
|
158
|
-
return on('afterInsert', () => focus())
|
|
159
|
-
}, [on, focus])
|
|
170
|
+
return on('afterInsert', () => input.focus())
|
|
171
|
+
}, [on, input.focus])
|
|
160
172
|
```
|
|
161
173
|
|
|
162
174
|
## Events
|
|
@@ -164,21 +176,21 @@ useEffect(() => {
|
|
|
164
176
|
Subscribe with `on(event, callback)`. Returns an unsubscribe function — use it
|
|
165
177
|
in a `useEffect` cleanup.
|
|
166
178
|
|
|
167
|
-
| Event | Data | When
|
|
168
|
-
| ---------------- | --------------------------- |
|
|
169
|
-
| `activeFacet` | `TapperActiveFacet \| null` | Cursor enters or leaves a facet
|
|
170
|
-
| `facetCommitted` | `TapperFacet` | A facet is finalized (cursor left or
|
|
171
|
-
| `afterInsert` | `TapperFacet` | After `replace()` replaces text
|
|
179
|
+
| Event | Data | When |
|
|
180
|
+
| ---------------- | --------------------------- | ---------------------------------------------- |
|
|
181
|
+
| `activeFacet` | `TapperActiveFacet \| null` | Cursor enters or leaves a facet |
|
|
182
|
+
| `facetCommitted` | `TapperFacet` | A facet is finalized (cursor left or replaced) |
|
|
183
|
+
| `afterInsert` | `TapperFacet` | After `replace()` replaces text |
|
|
172
184
|
|
|
173
|
-
### `focus()`
|
|
185
|
+
### `input.focus()`
|
|
174
186
|
|
|
175
187
|
After `replace()` replaces text, the input may lose focus on some platforms.
|
|
176
|
-
Listen for `afterInsert` and call `focus()` to restore it:
|
|
188
|
+
Listen for `afterInsert` and call `input.focus()` to restore it:
|
|
177
189
|
|
|
178
190
|
```tsx
|
|
179
191
|
useEffect(() => {
|
|
180
|
-
return on('afterInsert', () => focus())
|
|
181
|
-
}, [on, focus])
|
|
192
|
+
return on('afterInsert', () => input.focus())
|
|
193
|
+
}, [on, input.focus])
|
|
182
194
|
```
|
|
183
195
|
|
|
184
196
|
## Atomic deletion
|
|
@@ -193,12 +205,21 @@ Pre-populate the input with text. Any matched facets are automatically marked
|
|
|
193
205
|
as committed.
|
|
194
206
|
|
|
195
207
|
```tsx
|
|
196
|
-
const {
|
|
208
|
+
const {state} = useTapper({
|
|
197
209
|
facets: {mention: /@([a-zA-Z0-9._]+)/g},
|
|
198
210
|
initialText: 'Hello @eric',
|
|
199
211
|
})
|
|
200
212
|
```
|
|
201
213
|
|
|
214
|
+
## Default facets
|
|
215
|
+
|
|
216
|
+
If no `facets` config is provided, tapper uses built-in patterns for mentions,
|
|
217
|
+
emoji, tags, and URLs. These are also exported individually:
|
|
218
|
+
|
|
219
|
+
```tsx
|
|
220
|
+
import {mention, emoji, tag, url} from '@bsky.app/tapper'
|
|
221
|
+
```
|
|
222
|
+
|
|
202
223
|
## `replaceText`
|
|
203
224
|
|
|
204
225
|
The `Tapper` class (used internally by `useTapper`) exposes `replaceText(text,
|
package/build/index.d.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { TextInput } from 'react-native';
|
|
2
2
|
import { TapperConfig, TapperEvents, TapperNode, TapperActiveFacet, TapperSelection, TapperSnapshot } from './types';
|
|
3
3
|
export * from './types';
|
|
4
|
-
export * from './facets';
|
|
4
|
+
export * as facets from './facets';
|
|
5
5
|
export declare class Tapper {
|
|
6
6
|
private facetRegexes;
|
|
7
7
|
private triggers;
|
|
@@ -10,12 +10,13 @@ export declare class Tapper {
|
|
|
10
10
|
selection: TapperSelection;
|
|
11
11
|
nodes: TapperNode[];
|
|
12
12
|
activeFacet: TapperActiveFacet | null;
|
|
13
|
-
private
|
|
14
|
-
private static MUTATION_DEBOUNCE_MS;
|
|
13
|
+
private inputRef;
|
|
15
14
|
private updating;
|
|
16
15
|
private storeListeners;
|
|
17
16
|
private snapshot;
|
|
18
17
|
constructor(config?: TapperConfig);
|
|
18
|
+
setInputRef: (node: TextInput | null) => void;
|
|
19
|
+
private setInputSelection;
|
|
19
20
|
subscribe: (listener: () => void) => () => boolean;
|
|
20
21
|
getSnapshot: () => TapperSnapshot;
|
|
21
22
|
on: <K extends keyof TapperEvents>(event: K, cb: (data: TapperEvents[K]) => void) => () => void;
|
|
@@ -46,8 +47,7 @@ export declare function useTapper(config: TapperConfig): {
|
|
|
46
47
|
};
|
|
47
48
|
inputProps: {
|
|
48
49
|
ref: (node: TextInput | null) => void;
|
|
49
|
-
value: string;
|
|
50
|
-
selection: TapperSelection;
|
|
50
|
+
value: string | undefined;
|
|
51
51
|
onChangeText: (newText: string) => void;
|
|
52
52
|
onSelectionChange: (e: {
|
|
53
53
|
nativeEvent: {
|
package/build/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AACA,OAAO,
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AACA,OAAO,EAAW,SAAS,EAAC,MAAM,cAAc,CAAA;AAEhD,OAAO,EACL,YAAY,EACZ,YAAY,EACZ,UAAU,EACV,iBAAiB,EACjB,eAAe,EACf,cAAc,EACf,MAAM,SAAS,CAAA;AAWhB,cAAc,SAAS,CAAA;AACvB,OAAO,KAAK,MAAM,MAAM,UAAU,CAAA;AAIlC,qBAAa,MAAM;IACjB,OAAO,CAAC,YAAY,CAAsB;IAC1C,OAAO,CAAC,QAAQ,CAAqB;IAIrC,OAAO,CAAC,SAAS,CAA0D;IAG3E,IAAI,SAAK;IACT,SAAS,EAAE,eAAe,CAAqB;IAC/C,KAAK,EAAE,UAAU,EAAE,CAAK;IACxB,WAAW,EAAE,iBAAiB,GAAG,IAAI,CAAO;IAE5C,OAAO,CAAC,QAAQ,CAAyB;IACzC,OAAO,CAAC,QAAQ,CAAQ;IAGxB,OAAO,CAAC,cAAc,CAAwB;IAC9C,OAAO,CAAC,QAAQ,CAAgB;gBAEpB,MAAM,CAAC,EAAE,YAAY;IAejC,WAAW,GAAI,MAAM,SAAS,GAAG,IAAI,UAEpC;IAED,OAAO,CAAC,iBAAiB;IAUzB,SAAS,GAAI,UAAU,MAAM,IAAI,mBAGhC;IAED,WAAW,QAAO,cAAc,CAE/B;IAED,EAAE,GAAI,CAAC,SAAS,MAAM,YAAY,EAChC,OAAO,CAAC,EACR,IAAI,CAAC,IAAI,EAAE,YAAY,CAAC,CAAC,CAAC,KAAK,IAAI,gBAOpC;IAED,OAAO,CAAC,IAAI;IAIZ,OAAO,CAAC,MAAM;IAUd,OAAO,CAAC,MAAM;IAmEd,gBAAgB,GAAI,SAAS,MAAM,UA6BlC;IAED,qBAAqB,GAAI,GAAG;QAC1B,WAAW,EAAE;YAAC,SAAS,EAAE;gBAAC,KAAK,EAAE,MAAM,CAAC;gBAAC,GAAG,EAAE,MAAM,CAAA;aAAC,CAAA;SAAC,CAAA;KACvD,UA+BA;IAED,WAAW,GAAI,MAAM,MAAM,EAAE,SAAS,MAAM,UA+B3C;IAED,OAAO,CAAC,OAAO,CAsBd;IAED,MAAM,GAAI,MAAM,MAAM,UAMrB;CACF;AAED,wBAAgB,SAAS,CAAC,MAAM,EAAE,YAAY;;SAhOtC,CAAC,SAAS,MAAM,YAAY,2CAED,IAAI;mBAqNrB,MAAM;;;;;;;oBAgBb,SAAS,GAAG,IAAI;;gCA3II,MAAM;+BA+BP;YAC1B,WAAW,EAAE;gBAAC,SAAS,EAAE;oBAAC,KAAK,EAAE,MAAM,CAAC;oBAAC,GAAG,EAAE,MAAM,CAAA;iBAAC,CAAA;aAAC,CAAA;SACvD;;EAoIF"}
|
package/build/index.js
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import { useCallback, useRef, useState, useSyncExternalStore } from 'react';
|
|
2
|
+
import { Platform } from 'react-native';
|
|
2
3
|
import { parseNodesFromText, detectActiveFacet, deriveTriggers, nodeToFacet, compileFacetRegexes, } from './util';
|
|
3
4
|
import * as defaultFacets from './facets';
|
|
4
5
|
export * from './types';
|
|
5
|
-
export * from './facets';
|
|
6
|
+
export * as facets from './facets';
|
|
7
|
+
const IS_WEB = Platform.OS === 'web';
|
|
6
8
|
export class Tapper {
|
|
7
9
|
facetRegexes;
|
|
8
10
|
triggers;
|
|
@@ -14,30 +16,7 @@ export class Tapper {
|
|
|
14
16
|
selection = { start: 0, end: 0 };
|
|
15
17
|
nodes = [];
|
|
16
18
|
activeFacet = null;
|
|
17
|
-
|
|
18
|
-
* Guard against stale native events after programmatic text replacement.
|
|
19
|
-
*
|
|
20
|
-
* After insert() or atomic deletion, we update this.text and this.selection
|
|
21
|
-
* synchronously. However, iOS (especially on the old architecture bridge)
|
|
22
|
-
* may then fire stale onChangeText and onSelectionChange events reflecting
|
|
23
|
-
* the *previous* text/cursor state. These arrive within ~60ms.
|
|
24
|
-
*
|
|
25
|
-
* We record the expected textLength and a timestamp, then use two guards:
|
|
26
|
-
*
|
|
27
|
-
* 1. handleTextChange: if the incoming text length doesn't match the
|
|
28
|
-
* expected length AND we're within the debounce window, it's a stale
|
|
29
|
-
* event echoing the pre-replacement text. Skip it.
|
|
30
|
-
*
|
|
31
|
-
* 2. handleSelectionChange: if we're within the debounce window, skip
|
|
32
|
-
* ALL selection events. The correct selection was already set by
|
|
33
|
-
* update(), and any events within the window are either duplicates
|
|
34
|
-
* (no-ops) or stale positions from the old text.
|
|
35
|
-
*
|
|
36
|
-
* After the window passes, the guard clears itself and normal event
|
|
37
|
-
* handling resumes (cursor taps, typing, etc).
|
|
38
|
-
*/
|
|
39
|
-
pendingMutation = null;
|
|
40
|
-
static MUTATION_DEBOUNCE_MS = 200;
|
|
19
|
+
inputRef = null;
|
|
41
20
|
updating = false;
|
|
42
21
|
// useSyncExternalStore plumbing
|
|
43
22
|
storeListeners = new Set();
|
|
@@ -56,6 +35,20 @@ export class Tapper {
|
|
|
56
35
|
this.replaceText(config.initialText);
|
|
57
36
|
}
|
|
58
37
|
}
|
|
38
|
+
setInputRef = (node) => {
|
|
39
|
+
this.inputRef = node;
|
|
40
|
+
};
|
|
41
|
+
setInputSelection(start, end) {
|
|
42
|
+
const el = this.inputRef;
|
|
43
|
+
if (!el)
|
|
44
|
+
return;
|
|
45
|
+
if (typeof el.setSelection === 'function') {
|
|
46
|
+
el.setSelection(start, end);
|
|
47
|
+
}
|
|
48
|
+
else if (typeof el.setSelectionRange === 'function') {
|
|
49
|
+
el.setSelectionRange(start, end);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
59
52
|
subscribe = (listener) => {
|
|
60
53
|
this.storeListeners.add(listener);
|
|
61
54
|
return () => this.storeListeners.delete(listener);
|
|
@@ -136,22 +129,10 @@ export class Tapper {
|
|
|
136
129
|
handleTextChange = (newText) => {
|
|
137
130
|
if (newText === this.text)
|
|
138
131
|
return;
|
|
139
|
-
/*
|
|
140
|
-
* Guard 1: stale text. After insert/atomic deletion, iOS may echo
|
|
141
|
-
* onChangeText with the pre-replacement text. If the incoming length
|
|
142
|
-
* doesn't match what we expect and we're within the debounce window,
|
|
143
|
-
* this is a stale echo — skip it.
|
|
144
|
-
*/
|
|
145
|
-
if (this.pendingMutation !== null) {
|
|
146
|
-
const elapsed = Date.now() - this.pendingMutation.timestamp;
|
|
147
|
-
if (elapsed < Tapper.MUTATION_DEBOUNCE_MS &&
|
|
148
|
-
newText.length !== this.pendingMutation.textLength) {
|
|
149
|
-
return;
|
|
150
|
-
}
|
|
151
|
-
}
|
|
152
132
|
const diff = newText.length - this.text.length;
|
|
153
133
|
let newEnd = Math.max(this.selection.end + diff, 0);
|
|
154
134
|
// Atomic deletion: backspace into a facet from outside
|
|
135
|
+
let didAtomicDelete = false;
|
|
155
136
|
if (diff === -1 && newEnd < this.selection.end) {
|
|
156
137
|
for (const node of this.nodes) {
|
|
157
138
|
if (node.type === 'facet' &&
|
|
@@ -161,37 +142,18 @@ export class Tapper {
|
|
|
161
142
|
newText =
|
|
162
143
|
newText.slice(0, node.start) + newText.slice(node.start + remnant);
|
|
163
144
|
newEnd = node.start;
|
|
164
|
-
|
|
165
|
-
textLength: newText.length,
|
|
166
|
-
timestamp: Date.now(),
|
|
167
|
-
};
|
|
145
|
+
didAtomicDelete = true;
|
|
168
146
|
break;
|
|
169
147
|
}
|
|
170
148
|
}
|
|
171
149
|
}
|
|
172
150
|
this.update(newText, { start: newEnd, end: newEnd });
|
|
151
|
+
if (didAtomicDelete) {
|
|
152
|
+
this.setInputSelection(newEnd, newEnd);
|
|
153
|
+
}
|
|
173
154
|
};
|
|
174
155
|
handleSelectionChange = (e) => {
|
|
175
156
|
const { start, end } = e.nativeEvent.selection;
|
|
176
|
-
/*
|
|
177
|
-
* Guard 2: stale selection. After insert/atomic deletion, iOS may fire
|
|
178
|
-
* onSelectionChange with cursor positions from the old text state
|
|
179
|
-
* (e.g. a "deselection" event at the pre-insert cursor). Within the
|
|
180
|
-
* debounce window, skip ALL selection events — the correct selection
|
|
181
|
-
* was already set synchronously by update().
|
|
182
|
-
*/
|
|
183
|
-
if (this.pendingMutation !== null) {
|
|
184
|
-
const elapsed = Date.now() - this.pendingMutation.timestamp;
|
|
185
|
-
if (elapsed < Tapper.MUTATION_DEBOUNCE_MS)
|
|
186
|
-
return;
|
|
187
|
-
this.pendingMutation = null;
|
|
188
|
-
}
|
|
189
|
-
/*
|
|
190
|
-
* Guard 3: premature selection. iOS sometimes fires onSelectionChange
|
|
191
|
-
* before onChangeText for the same edit, reporting a cursor position
|
|
192
|
-
* that's beyond the current text length. Skip it — handleTextChange
|
|
193
|
-
* will set the correct position when it arrives.
|
|
194
|
-
*/
|
|
195
157
|
if (end > this.text.length)
|
|
196
158
|
return;
|
|
197
159
|
if (start === this.selection.start && end === this.selection.end)
|
|
@@ -263,21 +225,15 @@ export class Tapper {
|
|
|
263
225
|
};
|
|
264
226
|
this.emit('afterInsert', this.activeFacet);
|
|
265
227
|
}
|
|
266
|
-
this.pendingMutation = {
|
|
267
|
-
textLength: newText.length,
|
|
268
|
-
timestamp: Date.now(),
|
|
269
|
-
};
|
|
270
228
|
this.update(newText, { start: newEnd, end: newEnd });
|
|
229
|
+
this.setInputSelection(newEnd, newEnd);
|
|
271
230
|
};
|
|
272
231
|
insert = (text) => {
|
|
273
232
|
const pos = this.selection.end;
|
|
274
233
|
const newText = this.text.slice(0, pos) + text + this.text.slice(pos);
|
|
275
234
|
const newEnd = pos + text.length;
|
|
276
|
-
this.pendingMutation = {
|
|
277
|
-
textLength: newText.length,
|
|
278
|
-
timestamp: Date.now(),
|
|
279
|
-
};
|
|
280
235
|
this.update(newText, { start: newEnd, end: newEnd });
|
|
236
|
+
this.setInputSelection(newEnd, newEnd);
|
|
281
237
|
};
|
|
282
238
|
}
|
|
283
239
|
export function useTapper(config) {
|
|
@@ -287,8 +243,9 @@ export function useTapper(config) {
|
|
|
287
243
|
const inputRef = useRef(null);
|
|
288
244
|
const handleSetInput = useCallback((node) => {
|
|
289
245
|
inputRef.current = node;
|
|
246
|
+
store.setInputRef(node);
|
|
290
247
|
setInput(node);
|
|
291
|
-
}, []);
|
|
248
|
+
}, [store]);
|
|
292
249
|
const focus = useCallback(() => input?.focus(), [input]);
|
|
293
250
|
const blur = useCallback(() => input?.blur(), [input]);
|
|
294
251
|
return {
|
|
@@ -302,8 +259,7 @@ export function useTapper(config) {
|
|
|
302
259
|
},
|
|
303
260
|
inputProps: {
|
|
304
261
|
ref: handleSetInput,
|
|
305
|
-
value: state.text,
|
|
306
|
-
selection: state.selection,
|
|
262
|
+
value: IS_WEB ? state.text : undefined,
|
|
307
263
|
onChangeText: store.handleTextChange,
|
|
308
264
|
onSelectionChange: store.handleSelectionChange,
|
|
309
265
|
},
|
package/build/index.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"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","sourcesContent":["import {useCallback, useRef, useState, useSyncExternalStore} from 'react'\nimport {TextInput} from 'react-native'\n\nimport {\n TapperConfig,\n TapperEvents,\n TapperNode,\n TapperActiveFacet,\n TapperSelection,\n TapperSnapshot,\n} from './types'\nimport {\n parseNodesFromText,\n detectActiveFacet,\n deriveTriggers,\n nodeToFacet,\n compileFacetRegexes,\n type CompiledFacetRegexes,\n} from './util'\nimport * as defaultFacets from './facets'\n\nexport * from './types'\nexport * from './facets'\n\nexport class Tapper {\n private facetRegexes: CompiledFacetRegexes\n private triggers: Map<string, string>\n\n // Event emitters\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n private listeners = new Map<keyof TapperEvents, Set<(data: any) => void>>()\n\n // Public state\n text = ''\n selection: TapperSelection = {start: 0, end: 0}\n nodes: TapperNode[] = []\n activeFacet: TapperActiveFacet | null = null\n\n /*\n * Guard against stale native events after programmatic text replacement.\n *\n * After insert() or atomic deletion, we update this.text and this.selection\n * synchronously. However, iOS (especially on the old architecture bridge)\n * may then fire stale onChangeText and onSelectionChange events reflecting\n * the *previous* text/cursor state. These arrive within ~60ms.\n *\n * We record the expected textLength and a timestamp, then use two guards:\n *\n * 1. handleTextChange: if the incoming text length doesn't match the\n * expected length AND we're within the debounce window, it's a stale\n * event echoing the pre-replacement text. Skip it.\n *\n * 2. handleSelectionChange: if we're within the debounce window, skip\n * ALL selection events. The correct selection was already set by\n * update(), and any events within the window are either duplicates\n * (no-ops) or stale positions from the old text.\n *\n * After the window passes, the guard clears itself and normal event\n * handling resumes (cursor taps, typing, etc).\n */\n private pendingMutation: {textLength: number; timestamp: number} | null = null\n private static MUTATION_DEBOUNCE_MS = 200\n\n private updating = false\n\n // useSyncExternalStore plumbing\n private storeListeners = new Set<() => void>()\n private snapshot: TapperSnapshot\n\n constructor(config?: TapperConfig) {\n const facets = config?.facets || defaultFacets\n this.facetRegexes = compileFacetRegexes(facets)\n this.triggers = deriveTriggers(facets)\n this.snapshot = {\n text: this.text,\n selection: this.selection,\n nodes: this.nodes,\n activeFacet: this.activeFacet,\n }\n if (config?.initialText) {\n this.replaceText(config.initialText)\n }\n }\n\n subscribe = (listener: () => void) => {\n this.storeListeners.add(listener)\n return () => this.storeListeners.delete(listener)\n }\n\n getSnapshot = (): TapperSnapshot => {\n return this.snapshot\n }\n\n on = <K extends keyof TapperEvents>(\n event: K,\n cb: (data: TapperEvents[K]) => void,\n ) => {\n if (!this.listeners.has(event)) this.listeners.set(event, new Set())\n this.listeners.get(event)!.add(cb)\n return () => {\n this.listeners.get(event)?.delete(cb)\n }\n }\n\n private emit<K extends keyof TapperEvents>(event: K, data: TapperEvents[K]) {\n this.listeners.get(event)?.forEach(cb => cb(data))\n }\n\n private notify() {\n this.snapshot = {\n text: this.text,\n selection: this.selection,\n nodes: this.nodes,\n activeFacet: this.activeFacet,\n }\n this.storeListeners.forEach(l => l())\n }\n\n private update(newText: string, selection: TapperSelection) {\n // guard against circular updates when insert() is called during an update cycle\n if (this.updating) return\n this.updating = true\n\n const cursor = selection.end\n const isRange = selection.start !== selection.end\n\n const nodes =\n newText === this.text\n ? this.nodes\n : parseNodesFromText(\n newText,\n this.facetRegexes,\n this.nodes,\n cursor,\n this.triggers,\n )\n const prev = this.activeFacet\n const detected = isRange\n ? null\n : detectActiveFacet(nodes, newText, cursor, this.triggers)\n\n // When cursor leaves a facet, commit it\n let committedNode: TapperNode | null = null\n if (prev && (!detected || detected.type !== prev.type)) {\n for (const node of nodes) {\n if (\n node.type === 'facet' &&\n node.facetType === prev.type &&\n node.start === prev.range.start &&\n node.end === prev.range.end\n ) {\n node.committed = true\n committedNode = node\n break\n }\n }\n }\n\n // Build activeFacet with insert() baked in\n const active: TapperActiveFacet | null = detected\n ? {...detected, replace: this.replace}\n : null\n\n this.text = newText\n this.selection = selection\n this.nodes = nodes\n this.activeFacet = active\n\n this.updating = false\n this.notify()\n\n // Fire events after state is finalized\n const facetChanged =\n active?.type !== prev?.type ||\n active?.value !== prev?.value ||\n active?.range.start !== prev?.range.start ||\n active?.range.end !== prev?.range.end\n if (facetChanged) {\n this.emit('activeFacet', active)\n }\n if (committedNode) {\n this.emit('facetCommitted', nodeToFacet(committedNode))\n }\n }\n\n handleTextChange = (newText: string) => {\n if (newText === this.text) return\n\n /*\n * Guard 1: stale text. After insert/atomic deletion, iOS may echo\n * onChangeText with the pre-replacement text. If the incoming length\n * doesn't match what we expect and we're within the debounce window,\n * this is a stale echo — skip it.\n */\n if (this.pendingMutation !== null) {\n const elapsed = Date.now() - this.pendingMutation.timestamp\n if (\n elapsed < Tapper.MUTATION_DEBOUNCE_MS &&\n newText.length !== this.pendingMutation.textLength\n ) {\n return\n }\n }\n\n const diff = newText.length - this.text.length\n let newEnd = Math.max(this.selection.end + diff, 0)\n\n // Atomic deletion: backspace into a facet from outside\n if (diff === -1 && newEnd < this.selection.end) {\n for (const node of this.nodes) {\n if (\n node.type === 'facet' &&\n node.committed &&\n this.selection.end === node.end\n ) {\n const remnant = node.end - node.start - 1\n newText =\n newText.slice(0, node.start) + newText.slice(node.start + remnant)\n newEnd = node.start\n this.pendingMutation = {\n textLength: newText.length,\n timestamp: Date.now(),\n }\n break\n }\n }\n }\n\n this.update(newText, {start: newEnd, end: newEnd})\n }\n\n handleSelectionChange = (e: {\n nativeEvent: {selection: {start: number; end: number}}\n }) => {\n const {start, end} = e.nativeEvent.selection\n\n /*\n * Guard 2: stale selection. After insert/atomic deletion, iOS may fire\n * onSelectionChange with cursor positions from the old text state\n * (e.g. a \"deselection\" event at the pre-insert cursor). Within the\n * debounce window, skip ALL selection events — the correct selection\n * was already set synchronously by update().\n */\n if (this.pendingMutation !== null) {\n const elapsed = Date.now() - this.pendingMutation.timestamp\n if (elapsed < Tapper.MUTATION_DEBOUNCE_MS) return\n this.pendingMutation = null\n }\n\n /*\n * Guard 3: premature selection. iOS sometimes fires onSelectionChange\n * before onChangeText for the same edit, reporting a cursor position\n * that's beyond the current text length. Skip it — handleTextChange\n * will set the correct position when it arrives.\n */\n if (end > this.text.length) return\n\n if (start === this.selection.start && end === this.selection.end) return\n\n this.selection = {start, end}\n\n /*\n * For selection-only changes (no text change), check if the active facet\n * would change. If not, skip the full update() cycle — just update the\n * selection in the snapshot.\n */\n const isRange = start !== end\n const detected = isRange\n ? null\n : detectActiveFacet(this.nodes, this.text, end, this.triggers)\n const prev = this.activeFacet\n const facetChanged =\n detected?.type !== prev?.type ||\n detected?.value !== prev?.value ||\n detected?.range.start !== prev?.range.start ||\n detected?.range.end !== prev?.range.end\n if (!facetChanged) {\n this.snapshot = {...this.snapshot, selection: this.selection}\n this.storeListeners.forEach(l => l())\n return\n }\n\n this.update(this.text, {start, end})\n }\n\n replaceText = (text: string, cursor?: number) => {\n const prevActive = this.activeFacet\n const nodes = parseNodesFromText(text, this.facetRegexes)\n // Mark all non-text nodes as committed (pre-existing facets)\n const newlyCommitted: TapperNode[] = []\n for (const node of nodes) {\n if (node.type === 'facet') {\n node.committed = true\n newlyCommitted.push(node)\n }\n }\n\n const pos = cursor ?? text.length\n const detected = detectActiveFacet(nodes, text, pos, this.triggers)\n const active: TapperActiveFacet | null = detected\n ? {...detected, replace: this.replace}\n : null\n\n this.text = text\n this.selection = {start: pos, end: pos}\n this.nodes = nodes\n this.activeFacet = active\n\n this.notify()\n\n if (active !== prevActive) {\n this.emit('activeFacet', active)\n }\n for (const node of newlyCommitted) {\n this.emit('facetCommitted', nodeToFacet(node))\n }\n }\n\n private replace = (value: string, options?: {noTrailingSpace?: boolean}) => {\n if (!this.activeFacet) return\n\n const replacement = value + (options?.noTrailingSpace ? '' : ' ')\n const newText =\n this.text.slice(0, this.activeFacet.range.start) +\n replacement +\n this.text.slice(this.activeFacet.range.end)\n const newEnd = this.activeFacet.range.start + replacement.length\n\n if (this.activeFacet) {\n this.activeFacet.raw = value\n this.activeFacet.value = value\n this.activeFacet.range = {\n start: this.activeFacet.range.start,\n end: this.activeFacet.range.start + value.length,\n }\n this.emit('afterInsert', this.activeFacet)\n }\n\n this.pendingMutation = {\n textLength: newText.length,\n timestamp: Date.now(),\n }\n this.update(newText, {start: newEnd, end: newEnd})\n }\n\n insert = (text: string) => {\n const pos = this.selection.end\n const newText = this.text.slice(0, pos) + text + this.text.slice(pos)\n const newEnd = pos + text.length\n this.pendingMutation = {\n textLength: newText.length,\n timestamp: Date.now(),\n }\n this.update(newText, {start: newEnd, end: newEnd})\n }\n}\n\nexport function useTapper(config: TapperConfig) {\n const [store] = useState(() => new Tapper(config))\n const state = useSyncExternalStore(store.subscribe, store.getSnapshot)\n const [input, setInput] = useState<TextInput | null>(null)\n const inputRef = useRef<{focus(): void}>(null)\n\n const handleSetInput = useCallback((node: TextInput | null) => {\n inputRef.current = node\n setInput(node)\n }, [])\n const focus = useCallback(() => input?.focus(), [input])\n const blur = useCallback(() => input?.blur(), [input])\n\n return {\n state,\n on: store.on,\n insert: store.insert,\n input: {\n element: input,\n focus,\n blur,\n },\n inputProps: {\n ref: handleSetInput,\n value: state.text,\n selection: state.selection,\n onChangeText: store.handleTextChange,\n onSelectionChange: store.handleSelectionChange,\n },\n }\n}\n"]}
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"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","sourcesContent":["import {useCallback, useRef, useState, useSyncExternalStore} from 'react'\nimport {Platform, TextInput} from 'react-native'\n\nimport {\n TapperConfig,\n TapperEvents,\n TapperNode,\n TapperActiveFacet,\n TapperSelection,\n TapperSnapshot,\n} from './types'\nimport {\n parseNodesFromText,\n detectActiveFacet,\n deriveTriggers,\n nodeToFacet,\n compileFacetRegexes,\n type CompiledFacetRegexes,\n} from './util'\nimport * as defaultFacets from './facets'\n\nexport * from './types'\nexport * as facets from './facets'\n\nconst IS_WEB = Platform.OS === 'web'\n\nexport class Tapper {\n private facetRegexes: CompiledFacetRegexes\n private triggers: Map<string, string>\n\n // Event emitters\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n private listeners = new Map<keyof TapperEvents, Set<(data: any) => void>>()\n\n // Public state\n text = ''\n selection: TapperSelection = {start: 0, end: 0}\n nodes: TapperNode[] = []\n activeFacet: TapperActiveFacet | null = null\n\n private inputRef: TextInput | null = null\n private updating = false\n\n // useSyncExternalStore plumbing\n private storeListeners = new Set<() => void>()\n private snapshot: TapperSnapshot\n\n constructor(config?: TapperConfig) {\n const facets = config?.facets || defaultFacets\n this.facetRegexes = compileFacetRegexes(facets)\n this.triggers = deriveTriggers(facets)\n this.snapshot = {\n text: this.text,\n selection: this.selection,\n nodes: this.nodes,\n activeFacet: this.activeFacet,\n }\n if (config?.initialText) {\n this.replaceText(config.initialText)\n }\n }\n\n setInputRef = (node: TextInput | null) => {\n this.inputRef = node\n }\n\n private setInputSelection(start: number, end: number) {\n const el = this.inputRef as any\n if (!el) return\n if (typeof el.setSelection === 'function') {\n el.setSelection(start, end)\n } else if (typeof el.setSelectionRange === 'function') {\n el.setSelectionRange(start, end)\n }\n }\n\n subscribe = (listener: () => void) => {\n this.storeListeners.add(listener)\n return () => this.storeListeners.delete(listener)\n }\n\n getSnapshot = (): TapperSnapshot => {\n return this.snapshot\n }\n\n on = <K extends keyof TapperEvents>(\n event: K,\n cb: (data: TapperEvents[K]) => void,\n ) => {\n if (!this.listeners.has(event)) this.listeners.set(event, new Set())\n this.listeners.get(event)!.add(cb)\n return () => {\n this.listeners.get(event)?.delete(cb)\n }\n }\n\n private emit<K extends keyof TapperEvents>(event: K, data: TapperEvents[K]) {\n this.listeners.get(event)?.forEach(cb => cb(data))\n }\n\n private notify() {\n this.snapshot = {\n text: this.text,\n selection: this.selection,\n nodes: this.nodes,\n activeFacet: this.activeFacet,\n }\n this.storeListeners.forEach(l => l())\n }\n\n private update(newText: string, selection: TapperSelection) {\n // guard against circular updates when insert() is called during an update cycle\n if (this.updating) return\n this.updating = true\n\n const cursor = selection.end\n const isRange = selection.start !== selection.end\n\n const nodes =\n newText === this.text\n ? this.nodes\n : parseNodesFromText(\n newText,\n this.facetRegexes,\n this.nodes,\n cursor,\n this.triggers,\n )\n const prev = this.activeFacet\n const detected = isRange\n ? null\n : detectActiveFacet(nodes, newText, cursor, this.triggers)\n\n // When cursor leaves a facet, commit it\n let committedNode: TapperNode | null = null\n if (prev && (!detected || detected.type !== prev.type)) {\n for (const node of nodes) {\n if (\n node.type === 'facet' &&\n node.facetType === prev.type &&\n node.start === prev.range.start &&\n node.end === prev.range.end\n ) {\n node.committed = true\n committedNode = node\n break\n }\n }\n }\n\n // Build activeFacet with insert() baked in\n const active: TapperActiveFacet | null = detected\n ? {...detected, replace: this.replace}\n : null\n\n this.text = newText\n this.selection = selection\n this.nodes = nodes\n this.activeFacet = active\n\n this.updating = false\n this.notify()\n\n // Fire events after state is finalized\n const facetChanged =\n active?.type !== prev?.type ||\n active?.value !== prev?.value ||\n active?.range.start !== prev?.range.start ||\n active?.range.end !== prev?.range.end\n if (facetChanged) {\n this.emit('activeFacet', active)\n }\n if (committedNode) {\n this.emit('facetCommitted', nodeToFacet(committedNode))\n }\n }\n\n handleTextChange = (newText: string) => {\n if (newText === this.text) return\n\n const diff = newText.length - this.text.length\n let newEnd = Math.max(this.selection.end + diff, 0)\n\n // Atomic deletion: backspace into a facet from outside\n let didAtomicDelete = false\n if (diff === -1 && newEnd < this.selection.end) {\n for (const node of this.nodes) {\n if (\n node.type === 'facet' &&\n node.committed &&\n this.selection.end === node.end\n ) {\n const remnant = node.end - node.start - 1\n newText =\n newText.slice(0, node.start) + newText.slice(node.start + remnant)\n newEnd = node.start\n didAtomicDelete = true\n break\n }\n }\n }\n\n this.update(newText, {start: newEnd, end: newEnd})\n if (didAtomicDelete) {\n this.setInputSelection(newEnd, newEnd)\n }\n }\n\n handleSelectionChange = (e: {\n nativeEvent: {selection: {start: number; end: number}}\n }) => {\n const {start, end} = e.nativeEvent.selection\n\n if (end > this.text.length) return\n\n if (start === this.selection.start && end === this.selection.end) return\n\n this.selection = {start, end}\n\n /*\n * For selection-only changes (no text change), check if the active facet\n * would change. If not, skip the full update() cycle — just update the\n * selection in the snapshot.\n */\n const isRange = start !== end\n const detected = isRange\n ? null\n : detectActiveFacet(this.nodes, this.text, end, this.triggers)\n const prev = this.activeFacet\n const facetChanged =\n detected?.type !== prev?.type ||\n detected?.value !== prev?.value ||\n detected?.range.start !== prev?.range.start ||\n detected?.range.end !== prev?.range.end\n if (!facetChanged) {\n this.snapshot = {...this.snapshot, selection: this.selection}\n this.storeListeners.forEach(l => l())\n return\n }\n\n this.update(this.text, {start, end})\n }\n\n replaceText = (text: string, cursor?: number) => {\n const prevActive = this.activeFacet\n const nodes = parseNodesFromText(text, this.facetRegexes)\n // Mark all non-text nodes as committed (pre-existing facets)\n const newlyCommitted: TapperNode[] = []\n for (const node of nodes) {\n if (node.type === 'facet') {\n node.committed = true\n newlyCommitted.push(node)\n }\n }\n\n const pos = cursor ?? text.length\n const detected = detectActiveFacet(nodes, text, pos, this.triggers)\n const active: TapperActiveFacet | null = detected\n ? {...detected, replace: this.replace}\n : null\n\n this.text = text\n this.selection = {start: pos, end: pos}\n this.nodes = nodes\n this.activeFacet = active\n\n this.notify()\n\n if (active !== prevActive) {\n this.emit('activeFacet', active)\n }\n for (const node of newlyCommitted) {\n this.emit('facetCommitted', nodeToFacet(node))\n }\n }\n\n private replace = (value: string, options?: {noTrailingSpace?: boolean}) => {\n if (!this.activeFacet) return\n\n const replacement = value + (options?.noTrailingSpace ? '' : ' ')\n const newText =\n this.text.slice(0, this.activeFacet.range.start) +\n replacement +\n this.text.slice(this.activeFacet.range.end)\n const newEnd = this.activeFacet.range.start + replacement.length\n\n if (this.activeFacet) {\n this.activeFacet.raw = value\n this.activeFacet.value = value\n this.activeFacet.range = {\n start: this.activeFacet.range.start,\n end: this.activeFacet.range.start + value.length,\n }\n this.emit('afterInsert', this.activeFacet)\n }\n\n this.update(newText, {start: newEnd, end: newEnd})\n this.setInputSelection(newEnd, newEnd)\n }\n\n insert = (text: string) => {\n const pos = this.selection.end\n const newText = this.text.slice(0, pos) + text + this.text.slice(pos)\n const newEnd = pos + text.length\n this.update(newText, {start: newEnd, end: newEnd})\n this.setInputSelection(newEnd, newEnd)\n }\n}\n\nexport function useTapper(config: TapperConfig) {\n const [store] = useState(() => new Tapper(config))\n const state = useSyncExternalStore(store.subscribe, store.getSnapshot)\n const [input, setInput] = useState<TextInput | null>(null)\n const inputRef = useRef<{focus(): void}>(null)\n\n const handleSetInput = useCallback(\n (node: TextInput | null) => {\n inputRef.current = node\n store.setInputRef(node)\n setInput(node)\n },\n [store],\n )\n const focus = useCallback(() => input?.focus(), [input])\n const blur = useCallback(() => input?.blur(), [input])\n\n return {\n state,\n on: store.on,\n insert: store.insert,\n input: {\n element: input,\n focus,\n blur,\n },\n inputProps: {\n ref: handleSetInput,\n value: IS_WEB ? state.text : undefined,\n onChangeText: store.handleTextChange,\n onSelectionChange: store.handleSelectionChange,\n },\n }\n}\n"]}
|
package/package.json
CHANGED
package/src/index.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import {useCallback, useRef, useState, useSyncExternalStore} from 'react'
|
|
2
|
-
import {TextInput} from 'react-native'
|
|
2
|
+
import {Platform, TextInput} from 'react-native'
|
|
3
3
|
|
|
4
4
|
import {
|
|
5
5
|
TapperConfig,
|
|
@@ -20,7 +20,9 @@ import {
|
|
|
20
20
|
import * as defaultFacets from './facets'
|
|
21
21
|
|
|
22
22
|
export * from './types'
|
|
23
|
-
export * from './facets'
|
|
23
|
+
export * as facets from './facets'
|
|
24
|
+
|
|
25
|
+
const IS_WEB = Platform.OS === 'web'
|
|
24
26
|
|
|
25
27
|
export class Tapper {
|
|
26
28
|
private facetRegexes: CompiledFacetRegexes
|
|
@@ -36,31 +38,7 @@ export class Tapper {
|
|
|
36
38
|
nodes: TapperNode[] = []
|
|
37
39
|
activeFacet: TapperActiveFacet | null = null
|
|
38
40
|
|
|
39
|
-
|
|
40
|
-
* Guard against stale native events after programmatic text replacement.
|
|
41
|
-
*
|
|
42
|
-
* After insert() or atomic deletion, we update this.text and this.selection
|
|
43
|
-
* synchronously. However, iOS (especially on the old architecture bridge)
|
|
44
|
-
* may then fire stale onChangeText and onSelectionChange events reflecting
|
|
45
|
-
* the *previous* text/cursor state. These arrive within ~60ms.
|
|
46
|
-
*
|
|
47
|
-
* We record the expected textLength and a timestamp, then use two guards:
|
|
48
|
-
*
|
|
49
|
-
* 1. handleTextChange: if the incoming text length doesn't match the
|
|
50
|
-
* expected length AND we're within the debounce window, it's a stale
|
|
51
|
-
* event echoing the pre-replacement text. Skip it.
|
|
52
|
-
*
|
|
53
|
-
* 2. handleSelectionChange: if we're within the debounce window, skip
|
|
54
|
-
* ALL selection events. The correct selection was already set by
|
|
55
|
-
* update(), and any events within the window are either duplicates
|
|
56
|
-
* (no-ops) or stale positions from the old text.
|
|
57
|
-
*
|
|
58
|
-
* After the window passes, the guard clears itself and normal event
|
|
59
|
-
* handling resumes (cursor taps, typing, etc).
|
|
60
|
-
*/
|
|
61
|
-
private pendingMutation: {textLength: number; timestamp: number} | null = null
|
|
62
|
-
private static MUTATION_DEBOUNCE_MS = 200
|
|
63
|
-
|
|
41
|
+
private inputRef: TextInput | null = null
|
|
64
42
|
private updating = false
|
|
65
43
|
|
|
66
44
|
// useSyncExternalStore plumbing
|
|
@@ -82,6 +60,20 @@ export class Tapper {
|
|
|
82
60
|
}
|
|
83
61
|
}
|
|
84
62
|
|
|
63
|
+
setInputRef = (node: TextInput | null) => {
|
|
64
|
+
this.inputRef = node
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
private setInputSelection(start: number, end: number) {
|
|
68
|
+
const el = this.inputRef as any
|
|
69
|
+
if (!el) return
|
|
70
|
+
if (typeof el.setSelection === 'function') {
|
|
71
|
+
el.setSelection(start, end)
|
|
72
|
+
} else if (typeof el.setSelectionRange === 'function') {
|
|
73
|
+
el.setSelectionRange(start, end)
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
85
77
|
subscribe = (listener: () => void) => {
|
|
86
78
|
this.storeListeners.add(listener)
|
|
87
79
|
return () => this.storeListeners.delete(listener)
|
|
@@ -186,26 +178,11 @@ export class Tapper {
|
|
|
186
178
|
handleTextChange = (newText: string) => {
|
|
187
179
|
if (newText === this.text) return
|
|
188
180
|
|
|
189
|
-
/*
|
|
190
|
-
* Guard 1: stale text. After insert/atomic deletion, iOS may echo
|
|
191
|
-
* onChangeText with the pre-replacement text. If the incoming length
|
|
192
|
-
* doesn't match what we expect and we're within the debounce window,
|
|
193
|
-
* this is a stale echo — skip it.
|
|
194
|
-
*/
|
|
195
|
-
if (this.pendingMutation !== null) {
|
|
196
|
-
const elapsed = Date.now() - this.pendingMutation.timestamp
|
|
197
|
-
if (
|
|
198
|
-
elapsed < Tapper.MUTATION_DEBOUNCE_MS &&
|
|
199
|
-
newText.length !== this.pendingMutation.textLength
|
|
200
|
-
) {
|
|
201
|
-
return
|
|
202
|
-
}
|
|
203
|
-
}
|
|
204
|
-
|
|
205
181
|
const diff = newText.length - this.text.length
|
|
206
182
|
let newEnd = Math.max(this.selection.end + diff, 0)
|
|
207
183
|
|
|
208
184
|
// Atomic deletion: backspace into a facet from outside
|
|
185
|
+
let didAtomicDelete = false
|
|
209
186
|
if (diff === -1 && newEnd < this.selection.end) {
|
|
210
187
|
for (const node of this.nodes) {
|
|
211
188
|
if (
|
|
@@ -217,16 +194,16 @@ export class Tapper {
|
|
|
217
194
|
newText =
|
|
218
195
|
newText.slice(0, node.start) + newText.slice(node.start + remnant)
|
|
219
196
|
newEnd = node.start
|
|
220
|
-
|
|
221
|
-
textLength: newText.length,
|
|
222
|
-
timestamp: Date.now(),
|
|
223
|
-
}
|
|
197
|
+
didAtomicDelete = true
|
|
224
198
|
break
|
|
225
199
|
}
|
|
226
200
|
}
|
|
227
201
|
}
|
|
228
202
|
|
|
229
203
|
this.update(newText, {start: newEnd, end: newEnd})
|
|
204
|
+
if (didAtomicDelete) {
|
|
205
|
+
this.setInputSelection(newEnd, newEnd)
|
|
206
|
+
}
|
|
230
207
|
}
|
|
231
208
|
|
|
232
209
|
handleSelectionChange = (e: {
|
|
@@ -234,25 +211,6 @@ export class Tapper {
|
|
|
234
211
|
}) => {
|
|
235
212
|
const {start, end} = e.nativeEvent.selection
|
|
236
213
|
|
|
237
|
-
/*
|
|
238
|
-
* Guard 2: stale selection. After insert/atomic deletion, iOS may fire
|
|
239
|
-
* onSelectionChange with cursor positions from the old text state
|
|
240
|
-
* (e.g. a "deselection" event at the pre-insert cursor). Within the
|
|
241
|
-
* debounce window, skip ALL selection events — the correct selection
|
|
242
|
-
* was already set synchronously by update().
|
|
243
|
-
*/
|
|
244
|
-
if (this.pendingMutation !== null) {
|
|
245
|
-
const elapsed = Date.now() - this.pendingMutation.timestamp
|
|
246
|
-
if (elapsed < Tapper.MUTATION_DEBOUNCE_MS) return
|
|
247
|
-
this.pendingMutation = null
|
|
248
|
-
}
|
|
249
|
-
|
|
250
|
-
/*
|
|
251
|
-
* Guard 3: premature selection. iOS sometimes fires onSelectionChange
|
|
252
|
-
* before onChangeText for the same edit, reporting a cursor position
|
|
253
|
-
* that's beyond the current text length. Skip it — handleTextChange
|
|
254
|
-
* will set the correct position when it arrives.
|
|
255
|
-
*/
|
|
256
214
|
if (end > this.text.length) return
|
|
257
215
|
|
|
258
216
|
if (start === this.selection.start && end === this.selection.end) return
|
|
@@ -336,22 +294,16 @@ export class Tapper {
|
|
|
336
294
|
this.emit('afterInsert', this.activeFacet)
|
|
337
295
|
}
|
|
338
296
|
|
|
339
|
-
this.pendingMutation = {
|
|
340
|
-
textLength: newText.length,
|
|
341
|
-
timestamp: Date.now(),
|
|
342
|
-
}
|
|
343
297
|
this.update(newText, {start: newEnd, end: newEnd})
|
|
298
|
+
this.setInputSelection(newEnd, newEnd)
|
|
344
299
|
}
|
|
345
300
|
|
|
346
301
|
insert = (text: string) => {
|
|
347
302
|
const pos = this.selection.end
|
|
348
303
|
const newText = this.text.slice(0, pos) + text + this.text.slice(pos)
|
|
349
304
|
const newEnd = pos + text.length
|
|
350
|
-
this.pendingMutation = {
|
|
351
|
-
textLength: newText.length,
|
|
352
|
-
timestamp: Date.now(),
|
|
353
|
-
}
|
|
354
305
|
this.update(newText, {start: newEnd, end: newEnd})
|
|
306
|
+
this.setInputSelection(newEnd, newEnd)
|
|
355
307
|
}
|
|
356
308
|
}
|
|
357
309
|
|
|
@@ -361,10 +313,14 @@ export function useTapper(config: TapperConfig) {
|
|
|
361
313
|
const [input, setInput] = useState<TextInput | null>(null)
|
|
362
314
|
const inputRef = useRef<{focus(): void}>(null)
|
|
363
315
|
|
|
364
|
-
const handleSetInput = useCallback(
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
316
|
+
const handleSetInput = useCallback(
|
|
317
|
+
(node: TextInput | null) => {
|
|
318
|
+
inputRef.current = node
|
|
319
|
+
store.setInputRef(node)
|
|
320
|
+
setInput(node)
|
|
321
|
+
},
|
|
322
|
+
[store],
|
|
323
|
+
)
|
|
368
324
|
const focus = useCallback(() => input?.focus(), [input])
|
|
369
325
|
const blur = useCallback(() => input?.blur(), [input])
|
|
370
326
|
|
|
@@ -379,8 +335,7 @@ export function useTapper(config: TapperConfig) {
|
|
|
379
335
|
},
|
|
380
336
|
inputProps: {
|
|
381
337
|
ref: handleSetInput,
|
|
382
|
-
value: state.text,
|
|
383
|
-
selection: state.selection,
|
|
338
|
+
value: IS_WEB ? state.text : undefined,
|
|
384
339
|
onChangeText: store.handleTextChange,
|
|
385
340
|
onSelectionChange: store.handleSelectionChange,
|
|
386
341
|
},
|