diff --git a/CHANGELOG.md b/CHANGELOG.md index ab61388..ef2cca5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- `useDetectedEntities(text, options?)` hook — reactive, debounced detection of a + changing string (as-you-type). Returns `{ entities, isDetecting, status, error }`, + is cancellation-safe (latest text wins), and manages model readiness internally. - `useDataDetector` hook — tracks model readiness (`status`/`isReady`/`error`), exposes `detect`, and auto-downloads the model on Android (opt out with `autoPrepare: false`). diff --git a/README.md b/README.md index 89609fa..c8904c5 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ Cross-platform text data detection for React Native. Uses **NSDataDetector** on - **Addresses** — Detect street addresses with parsed components (iOS) - **Dates** — Detect dates and times with ISO 8601 output - **Native accuracy** — Uses battle-tested platform APIs instead of regex -- **React hook** — [`useDataDetector`](#usedatadetectoroptions) tracks model readiness and auto-downloads on Android +- **React hooks** — `useDataDetector` (imperative) and `useDetectedEntities` (reactive, as-you-type); both track model readiness and auto-download on Android - **Multiple languages** — Choose from 15 ML Kit language models on Android - **Expo Modules API** — Built with the modern Expo native module system @@ -65,10 +65,17 @@ const phones = await detect('Call 555-1234 or visit https://example.com', { const fr = await detect('Appelez-moi au 01 23 45 67 89', { language: 'fr' }); ``` -### Hook +### Hooks -The `useDataDetector` hook tracks model readiness and, on Android, downloads the -model automatically on mount. On iOS the model is always ready. +There are two hooks for two situations: + +- **`useDataDetector`** — *imperative*. Tracks model readiness and hands you a + `detect` function you call yourself (e.g. once per chat message). +- **`useDetectedEntities`** — *reactive*. Pass it a (changing) string and it returns + the detected entities, debounced and recomputed as the text changes — ideal for + as-you-type input. + +Both download the model automatically on Android and are no-ops on iOS (always ready). ```tsx import { useDataDetector } from 'react-native-data-detector'; @@ -87,6 +94,22 @@ function MyComponent() { } ``` +```tsx +import { useDetectedEntities } from 'react-native-data-detector'; + +function LiveInput() { + const [text, setText] = useState(''); + const { entities, isDetecting } = useDetectedEntities(text, { debounceMs: 250 }); + + return ( + <> + + {entities.length} detected{isDetecting ? '…' : ''} + + ); +} +``` + ## API ### `prepareModel(options?)` @@ -145,6 +168,30 @@ React hook that tracks model availability and, on Android, downloads the languag | `isReady` | `boolean` | `true` when `status === 'ready'`. | | `error` | `Error \| null` | The last preparation error, or `null`. | +### `useDetectedEntities(text, options?)` + +Reactive hook: pass a (changing) string and get back the detected entities, recomputed as the text changes. Debounced and cancellation-safe (the latest text wins), so it suits as-you-type input. Manages model readiness internally. + +**Parameters:** + +| Property | Type | Default | Description | +|----------|------|---------|-------------| +| `text` | `string` | — | The text to detect entities in. | +| `options.debounceMs` | `number` | `300` | Debounce applied to `text` before detecting. | +| `options.types` | `DetectionType[]` | All types | Which entity types to detect. | +| `options.language` | `ModelLanguage` | `'en'` | Which language model to use (Android only). | +| `options.enabled` | `boolean` | `true` | When `false`, detection pauses and the last result is kept. | +| `options.autoPrepare` | `boolean` | `true` | Download the model on mount if needed (Android). | + +**Returns:** `UseDetectedEntitiesResult` + +| Property | Type | Description | +|----------|------|-------------| +| `entities` | `DetectedEntity[]` | Entities detected in the debounced `text`. | +| `isDetecting` | `boolean` | `true` while a detection for the latest text is in flight. | +| `status` | `ModelStatus` | Current model download state. | +| `error` | `Error \| null` | The last detection or model error, or `null`. | + ### `downloadModel()` > **Deprecated since 0.3.0** — use [`prepareModel()`](#preparemodeloptions) instead. Kept as an alias (always targets the default `'en'` model) and will be removed in a future major version. diff --git a/example/App.tsx b/example/App.tsx index d53c981..4152289 100644 --- a/example/App.tsx +++ b/example/App.tsx @@ -1,377 +1,11 @@ -import { StatusBar } from 'expo-status-bar'; -import { useState } from 'react'; -import { - ActivityIndicator, - Platform, - Pressable, - SafeAreaView, - ScrollView, - StyleSheet, - Text, - TextInput, - View, -} from 'react-native'; -import { - useDataDetector, - type DetectedEntity, - type DetectionType, - type ModelLanguage, -} from 'react-native-data-detector'; +import { SafeAreaProvider } from 'react-native-safe-area-context'; -const SAMPLE_TEXT = - 'Call me at (555) 123-4567 or email john@example.com.\n' + - 'Visit https://reactnative.dev for docs.\n' + - 'Meet me at 1 Infinite Loop, Cupertino, CA 95014 on March 15, 2025.'; - -const ALL_TYPES: DetectionType[] = ['phoneNumber', 'link', 'email', 'address', 'date']; - -const TYPE_COLORS: Record = { - phoneNumber: '#5856D6', - link: '#007AFF', - email: '#34C759', - address: '#FF9500', - date: '#FF2D55', -}; - -const TYPE_LABELS: Record = { - phoneNumber: 'Phone', - link: 'Link', - email: 'Email', - address: 'Address', - date: 'Date', -}; - -const LANGUAGES: ModelLanguage[] = ['en', 'fr', 'es', 'de', 'ja', 'zh']; - -const STATUS_LABELS: Record = { - notDownloaded: 'Model not downloaded', - downloading: 'Downloading model…', - ready: 'Model ready', - error: 'Model error', -}; +import DataDetector from './screens/DataDetector'; export default function App() { - const [text, setText] = useState(SAMPLE_TEXT); - const [selectedTypes, setSelectedTypes] = useState>(new Set(ALL_TYPES)); - const [results, setResults] = useState([]); - const [loading, setLoading] = useState(false); - const [detectError, setDetectError] = useState(null); - const [language, setLanguage] = useState('en'); - - // The hook tracks model readiness and auto-downloads on Android. - const { detect, status, isReady, prepare, error } = useDataDetector({ language }); - - const toggleType = (type: DetectionType) => { - setSelectedTypes((prev) => { - const next = new Set(prev); - if (next.has(type)) { - next.delete(type); - } else { - next.add(type); - } - return next; - }); - }; - - const handleDetect = async () => { - if (!text.trim()) return; - setLoading(true); - setDetectError(null); - try { - const types = Array.from(selectedTypes); - const entities = await detect(text, types.length < ALL_TYPES.length ? { types } : undefined); - setResults(entities); - } catch (e: any) { - setResults([]); - setDetectError(`Detection error: ${e.message}`); - } finally { - setLoading(false); - } - }; - return ( - - - - Data Detector - - {Platform.OS === 'ios' ? 'NSDataDetector' : 'ML Kit Entity Extraction'} - - - {Platform.OS === 'android' && ( - - Language Model - - {LANGUAGES.map((lang) => { - const active = language === lang; - return ( - setLanguage(lang)} - > - - {lang.toUpperCase()} - - - ); - })} - - - {status === 'downloading' && } - - {error ? `Error: ${error.message}` : (STATUS_LABELS[status] ?? status)} - - - {status === 'error' && ( - prepare().catch(() => {})}> - Retry Download - - )} - - )} - - - Input Text - - - - - Entity Types - - {ALL_TYPES.map((type) => { - const active = selectedTypes.has(type); - return ( - toggleType(type)} - > - - {TYPE_LABELS[type]} - - - ); - })} - - - - - {loading ? ( - - ) : ( - - {isReady ? 'Detect Entities' : 'Preparing model…'} - - )} - - - {detectError && {detectError}} - - {results.length > 0 && ( - - - Results ({results.length} {results.length === 1 ? 'entity' : 'entities'}) - - {results.map((entity, index) => ( - - - - {TYPE_LABELS[entity.type]} - - - [{entity.start}–{entity.end}] - - - "{entity.text}" - {entity.data && Object.keys(entity.data).length > 0 && ( - - {Object.entries(entity.data).map(([key, value]) => ( - - {key}: {value} - - ))} - - )} - - ))} - - )} - - + + + ); } - -const styles = StyleSheet.create({ - container: { - flex: 1, - backgroundColor: '#F2F2F7', - }, - content: { - padding: 20, - paddingTop: Platform.OS === 'android' ? 48 : 20, - }, - title: { - fontSize: 28, - fontWeight: '700', - color: '#000', - }, - subtitle: { - fontSize: 14, - color: '#8E8E93', - marginBottom: 20, - }, - section: { - marginBottom: 20, - }, - label: { - fontSize: 13, - fontWeight: '600', - color: '#8E8E93', - textTransform: 'uppercase', - marginBottom: 8, - }, - textInput: { - backgroundColor: '#fff', - borderRadius: 12, - padding: 14, - fontSize: 15, - minHeight: 100, - textAlignVertical: 'top', - color: '#000', - }, - chips: { - flexDirection: 'row', - flexWrap: 'wrap', - gap: 8, - }, - chip: { - paddingHorizontal: 14, - paddingVertical: 7, - borderRadius: 20, - borderWidth: 1.5, - backgroundColor: '#fff', - }, - chipText: { - fontSize: 14, - fontWeight: '500', - color: '#333', - }, - chipTextActive: { - color: '#fff', - }, - detectButton: { - backgroundColor: '#007AFF', - borderRadius: 12, - paddingVertical: 14, - alignItems: 'center', - marginBottom: 20, - }, - detectButtonDisabled: { - opacity: 0.6, - }, - detectButtonText: { - color: '#fff', - fontSize: 17, - fontWeight: '600', - }, - downloadButton: { - backgroundColor: '#34C759', - borderRadius: 12, - paddingVertical: 12, - alignItems: 'center', - }, - downloadButtonText: { - color: '#fff', - fontSize: 15, - fontWeight: '600', - }, - statusRow: { - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'center', - gap: 8, - marginTop: 10, - }, - statusText: { - fontSize: 13, - color: '#8E8E93', - textAlign: 'center', - }, - errorText: { - fontSize: 13, - color: '#FF3B30', - textAlign: 'center', - marginBottom: 16, - }, - langChip: { - borderColor: '#C7C7CC', - }, - langChipActive: { - backgroundColor: '#007AFF', - borderColor: '#007AFF', - }, - card: { - backgroundColor: '#fff', - borderRadius: 12, - padding: 14, - marginBottom: 10, - borderLeftWidth: 4, - }, - cardHeader: { - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'space-between', - marginBottom: 6, - }, - badge: { - paddingHorizontal: 10, - paddingVertical: 3, - borderRadius: 10, - }, - badgeText: { - color: '#fff', - fontSize: 12, - fontWeight: '600', - }, - range: { - fontSize: 12, - color: '#8E8E93', - fontFamily: Platform.OS === 'ios' ? 'Menlo' : 'monospace', - }, - matchedText: { - fontSize: 15, - color: '#000', - fontWeight: '500', - }, - dataContainer: { - marginTop: 6, - paddingTop: 6, - borderTopWidth: StyleSheet.hairlineWidth, - borderTopColor: '#E5E5EA', - }, - dataText: { - fontSize: 13, - color: '#636366', - fontFamily: Platform.OS === 'ios' ? 'Menlo' : 'monospace', - }, -}); diff --git a/example/app.json b/example/app.json index 79e3c7e..d62ed69 100644 --- a/example/app.json +++ b/example/app.json @@ -5,7 +5,6 @@ "version": "1.0.0", "orientation": "portrait", "icon": "./assets/icon.png", - "userInterfaceStyle": "light", "newArchEnabled": true, "splash": { "image": "./assets/splash-icon.png", @@ -21,7 +20,6 @@ "foregroundImage": "./assets/adaptive-icon.png", "backgroundColor": "#ffffff" }, - "edgeToEdgeEnabled": true, "package": "com.example.datadetector" }, "web": { @@ -35,7 +33,8 @@ "minSdkVersion": 26 } } - ] + ], + "expo-status-bar" ] } } diff --git a/example/components/DetectButton.tsx b/example/components/DetectButton.tsx new file mode 100644 index 0000000..bbfa796 --- /dev/null +++ b/example/components/DetectButton.tsx @@ -0,0 +1,45 @@ +import { ActivityIndicator, Pressable, StyleSheet, Text } from 'react-native'; + +import { C } from '../theme'; + +interface Props { + detecting: boolean; + isReady: boolean; + onPress: () => void; +} + +export function DetectButton({ detecting, isReady, onPress }: Props) { + const disabled = detecting || !isReady; + + return ( + + {detecting ? ( + + ) : ( + {isReady ? 'Detect' : 'Preparing model…'} + )} + + ); +} + +const styles = StyleSheet.create({ + button: { + backgroundColor: C.accent, + borderRadius: 14, + paddingVertical: 15, + alignItems: 'center', + marginTop: 12, + }, + disabled: { + opacity: 0.5, + }, + text: { + color: '#fff', + fontSize: 16, + fontWeight: '700', + }, +}); diff --git a/example/components/DetectInput.tsx b/example/components/DetectInput.tsx new file mode 100644 index 0000000..e30d2bd --- /dev/null +++ b/example/components/DetectInput.tsx @@ -0,0 +1,43 @@ +import { StyleSheet, TextInput, View } from 'react-native'; + +import { C } from '../theme'; + +interface Props { + value: string; + onChangeText: (text: string) => void; + placeholder?: string; +} + +export function DetectInput({ value, onChangeText, placeholder }: Props) { + return ( + + + + ); +} + +const styles = StyleSheet.create({ + wrap: { + backgroundColor: C.surface, + borderRadius: 16, + borderWidth: 1, + borderColor: C.border, + marginTop: 16, + }, + input: { + color: C.text, + fontSize: 16, + lineHeight: 22, + padding: 16, + minHeight: 56, + maxHeight: 130, + textAlignVertical: 'top', + }, +}); diff --git a/example/components/DetectedList.tsx b/example/components/DetectedList.tsx new file mode 100644 index 0000000..20f14dc --- /dev/null +++ b/example/components/DetectedList.tsx @@ -0,0 +1,83 @@ +import { ActivityIndicator, Platform, ScrollView, StyleSheet, Text, View } from 'react-native'; +import type { DetectedEntity, ModelStatus } from 'react-native-data-detector'; + +import { C, SCREEN_PADDING } from '../theme'; +import { EntityChip } from './EntityChip'; +import type { Mode } from './ModeToggle'; + +interface Props { + entities: DetectedEntity[]; + busy: boolean; + status: ModelStatus; + mode: Mode; +} + +export function DetectedList({ entities, busy, status, mode }: Props) { + const preparing = Platform.OS === 'android' && status !== 'ready'; + + return ( + + + Detected · {entities.length} + {busy && } + + + {entities.length === 0 ? ( + + + {preparing + ? 'Preparing model…' + : mode === 'reactive' + ? 'Start typing to detect…' + : 'Tap Detect to analyze.'} + + + ) : ( + + {entities.map((entity, index) => ( + + ))} + + )} + + ); +} + +const styles = StyleSheet.create({ + header: { + flexDirection: 'row', + alignItems: 'center', + gap: 8, + marginBottom: 10, + }, + label: { + fontSize: 12, + fontWeight: '700', + color: C.muted, + textTransform: 'uppercase', + letterSpacing: 1, + }, + empty: { + height: 76, + borderRadius: 14, + borderWidth: 1, + borderColor: C.border, + borderStyle: 'dashed', + alignItems: 'center', + justifyContent: 'center', + }, + emptyText: { color: C.muted, fontSize: 13 }, + scroll: { + marginHorizontal: -SCREEN_PADDING, + }, + strip: { + gap: 10, + paddingHorizontal: SCREEN_PADDING, + }, +}); diff --git a/example/components/EntityChip.tsx b/example/components/EntityChip.tsx new file mode 100644 index 0000000..a85b82e --- /dev/null +++ b/example/components/EntityChip.tsx @@ -0,0 +1,62 @@ +import { StyleSheet, Text, View } from 'react-native'; +import type { DetectedEntity } from 'react-native-data-detector'; + +import { TYPE_COLORS, TYPE_LABELS } from '../constants'; +import { C } from '../theme'; + +interface Props { + entity: DetectedEntity; +} + +export function EntityChip({ entity }: Props) { + const color = TYPE_COLORS[entity.type]; + + return ( + + + + {TYPE_LABELS[entity.type]} + + + {entity.text} + + + ); +} + +const styles = StyleSheet.create({ + chip: { + backgroundColor: C.surface, + borderWidth: 1, + borderColor: C.border, + borderRadius: 14, + paddingVertical: 11, + paddingHorizontal: 14, + minWidth: 124, + maxWidth: 220, + height: 76, + justifyContent: 'center', + gap: 7, + }, + head: { + flexDirection: 'row', + alignItems: 'center', + gap: 7, + }, + dot: { + width: 7, + height: 7, + borderRadius: 4, + }, + type: { + fontSize: 11, + fontWeight: '700', + textTransform: 'uppercase', + letterSpacing: 0.6, + }, + text: { + color: C.text, + fontSize: 15, + fontWeight: '600', + }, +}); diff --git a/example/components/Header.tsx b/example/components/Header.tsx new file mode 100644 index 0000000..abb20b2 --- /dev/null +++ b/example/components/Header.tsx @@ -0,0 +1,32 @@ +import { Platform, StyleSheet, Text, View } from 'react-native'; + +import { C } from '../theme'; + +export function Header() { + return ( + + Data Detector + + {Platform.OS === 'ios' ? 'NSDataDetector' : 'ML Kit Entity Extraction'} + + + ); +} + +const styles = StyleSheet.create({ + header: { + marginBottom: 20, + }, + title: { + fontSize: 30, + fontWeight: '800', + color: C.text, + letterSpacing: -0.5, + }, + subtitle: { + fontSize: 13, + color: C.muted, + marginTop: 2, + fontFamily: Platform.OS === 'ios' ? 'Menlo' : 'monospace', + }, +}); diff --git a/example/components/LanguageButton.tsx b/example/components/LanguageButton.tsx new file mode 100644 index 0000000..ae7ace7 --- /dev/null +++ b/example/components/LanguageButton.tsx @@ -0,0 +1,45 @@ +import { Pressable, StyleSheet, Text } from 'react-native'; +import type { ModelLanguage } from 'react-native-data-detector'; + +import { LANGUAGES } from '../constants'; +import { C } from '../theme'; + +interface Props { + language: ModelLanguage; + onPress: () => void; +} + +export function LanguageButton({ language, onPress }: Props) { + const name = LANGUAGES.find((l) => l.code === language)?.name ?? language; + + return ( + + {name} + + + ); +} + +const styles = StyleSheet.create({ + dropdown: { + flexDirection: 'row', + alignItems: 'center', + gap: 6, + backgroundColor: C.surface, + borderWidth: 1, + borderColor: C.border, + borderRadius: 999, + paddingVertical: 8, + paddingHorizontal: 14, + }, + text: { + color: C.text, + fontSize: 14, + fontWeight: '600', + }, + caret: { + color: C.muted, + fontSize: 11, + marginTop: 1, + }, +}); diff --git a/example/components/LanguageSheet.tsx b/example/components/LanguageSheet.tsx new file mode 100644 index 0000000..8ddff70 --- /dev/null +++ b/example/components/LanguageSheet.tsx @@ -0,0 +1,92 @@ +import { Modal, Pressable, ScrollView, StyleSheet, Text } from 'react-native'; +import type { ModelLanguage } from 'react-native-data-detector'; + +import { LANGUAGES } from '../constants'; +import { C } from '../theme'; + +interface Props { + visible: boolean; + selected: ModelLanguage; + onSelect: (code: ModelLanguage) => void; + onClose: () => void; +} + +export function LanguageSheet({ visible, selected, onSelect, onClose }: Props) { + return ( + + + + Language model + + {LANGUAGES.map((l) => { + const active = l.code === selected; + + return ( + onSelect(l.code)}> + {l.name} + {active && } + + ); + })} + + + + + ); +} + +const styles = StyleSheet.create({ + backdrop: { + flex: 1, + backgroundColor: 'rgba(0,0,0,0.55)', + justifyContent: 'flex-end', + }, + sheet: { + backgroundColor: C.surfaceHi, + borderTopLeftRadius: 22, + borderTopRightRadius: 22, + borderWidth: 1, + borderColor: C.border, + paddingHorizontal: 4, + paddingTop: 18, + paddingBottom: 32, + }, + title: { + color: C.muted, + fontSize: 12, + fontWeight: '700', + textTransform: 'uppercase', + letterSpacing: 1, + marginBottom: 6, + paddingHorizontal: 6, + }, + list: { + maxHeight: 360, + }, + listContent: { + paddingHorizontal: 12, + }, + row: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + paddingVertical: 14, + paddingHorizontal: 6, + borderBottomWidth: StyleSheet.hairlineWidth, + borderBottomColor: C.border, + }, + rowText: { + color: C.text, + fontSize: 16, + fontWeight: '700', + }, + rowActive: { + color: C.accent, + fontWeight: '700', + }, + check: { + color: C.accent, + fontSize: 16, + fontWeight: '700', + }, +}); diff --git a/example/components/ModeToggle.tsx b/example/components/ModeToggle.tsx new file mode 100644 index 0000000..b8f45a2 --- /dev/null +++ b/example/components/ModeToggle.tsx @@ -0,0 +1,62 @@ +import { Pressable, StyleSheet, Text, View } from 'react-native'; + +import { C } from '../theme'; + +export type Mode = 'reactive' | 'imperative'; + +const OPTIONS: [Mode, string][] = [ + ['reactive', 'Live'], + ['imperative', 'On tap'], +]; + +interface Props { + mode: Mode; + onChange: (mode: Mode) => void; +} + +export function ModeToggle({ mode, onChange }: Props) { + return ( + + {OPTIONS.map(([value, label]) => { + const active = mode === value; + + return ( + onChange(value)} + > + {label} + + ); + })} + + ); +} + +const styles = StyleSheet.create({ + toggle: { + flexDirection: 'row', + backgroundColor: C.surface, + borderRadius: 999, + borderWidth: 1, + borderColor: C.border, + padding: 3, + }, + item: { + paddingVertical: 7, + paddingHorizontal: 16, + borderRadius: 999, + }, + itemActive: { + backgroundColor: C.accent, + }, + text: { + color: C.muted, + fontSize: 13, + fontWeight: '600', + }, + textActive: { + color: '#fff', + }, +}); diff --git a/example/components/ModelStatus.tsx b/example/components/ModelStatus.tsx new file mode 100644 index 0000000..fc67e93 --- /dev/null +++ b/example/components/ModelStatus.tsx @@ -0,0 +1,40 @@ +import { ActivityIndicator, StyleSheet, Text, View } from 'react-native'; +import type { ModelStatus as Status } from 'react-native-data-detector'; + +import { C } from '../theme'; + +const LABELS: Record = { + downloading: 'Downloading model…', + error: 'Model error', + notDownloaded: 'Model not downloaded', +}; + +interface Props { + status: Status; +} + +export function ModelStatus({ status }: Props) { + if (status === 'ready') { + return null; + } + + return ( + + {status === 'downloading' && } + {LABELS[status] ?? status} + + ); +} + +const styles = StyleSheet.create({ + row: { + flexDirection: 'row', + alignItems: 'center', + gap: 8, + marginTop: 12, + }, + text: { + color: C.muted, + fontSize: 13, + }, +}); diff --git a/example/constants.ts b/example/constants.ts new file mode 100644 index 0000000..c785756 --- /dev/null +++ b/example/constants.ts @@ -0,0 +1,39 @@ +import type { DetectionType, ModelLanguage } from 'react-native-data-detector'; + +export const SAMPLE_TEXT = + 'Call me at (555) 123-4567 or email john@example.com tomorrow at 9:30pm.'; + +export const TYPE_COLORS: Record = { + phoneNumber: '#A78BFA', + link: '#60A5FA', + email: '#34D399', + address: '#FBBF24', + date: '#F472B6', +}; + +export const TYPE_LABELS: Record = { + phoneNumber: 'Phone', + link: 'Link', + email: 'Email', + address: 'Address', + date: 'Date', +}; + +/** The 15 language models supported by ML Kit Entity Extraction (Android). */ +export const LANGUAGES: { code: ModelLanguage; name: string }[] = [ + { code: 'ar', name: 'Arabic' }, + { code: 'nl', name: 'Dutch' }, + { code: 'en', name: 'English' }, + { code: 'fr', name: 'French' }, + { code: 'de', name: 'German' }, + { code: 'it', name: 'Italian' }, + { code: 'ja', name: 'Japanese' }, + { code: 'ko', name: 'Korean' }, + { code: 'pl', name: 'Polish' }, + { code: 'pt', name: 'Portuguese' }, + { code: 'ru', name: 'Russian' }, + { code: 'es', name: 'Spanish' }, + { code: 'th', name: 'Thai' }, + { code: 'tr', name: 'Turkish' }, + { code: 'zh', name: 'Chinese' }, +]; diff --git a/example/metro.config.js b/example/metro.config.js index 6e70bda..4f596ec 100644 --- a/example/metro.config.js +++ b/example/metro.config.js @@ -6,20 +6,20 @@ const monorepoRoot = path.resolve(projectRoot, '..'); const config = getDefaultConfig(projectRoot); -// Watch the parent directory so Metro can resolve the file:.. dependency +// Watch the repo root so Metro picks up changes to the linked +// `react-native-data-detector` package (a `file:..` dependency). config.watchFolders = [monorepoRoot]; -// Ensure Metro resolves from the example's node_modules first +// Resolve from the example's node_modules first, then the repo root. config.resolver.nodeModulesPaths = [ path.resolve(projectRoot, 'node_modules'), path.resolve(monorepoRoot, 'node_modules'), ]; -// The monorepo root also carries its own (newer) react / react-native, pulled in -// by npm auto-installing the library's peerDependencies. Pin the singletons to the -// example's copies so Metro and React Native codegen never mix versions — mixing -// breaks native component codegen (e.g. "Unable to determine event arguments" in -// VirtualViewNativeComponent). +// The repo root carries its own, newer react / react-native (pulled in by the +// library's peer + test dependencies). Pin the singletons to the example's copies +// and block the root ones so Metro never mixes versions — mixing causes crashes +// like "Property 'MessageQueue' doesn't exist". config.resolver.extraNodeModules = { react: path.resolve(projectRoot, 'node_modules/react'), 'react-native': path.resolve(projectRoot, 'node_modules/react-native'), diff --git a/example/package.json b/example/package.json index 840faa2..3c0dab6 100644 --- a/example/package.json +++ b/example/package.json @@ -9,16 +9,17 @@ "web": "expo start --web" }, "dependencies": { - "expo": "~54.0.33", - "expo-build-properties": "~1.0.10", - "expo-status-bar": "~3.0.9", - "react": "19.1.0", - "react-native": "0.81.5", - "react-native-data-detector": "file:.." + "expo": "^56.0.9", + "expo-build-properties": "~56.0.17", + "expo-status-bar": "~56.0.4", + "react": "19.2.3", + "react-native": "0.85.3", + "react-native-data-detector": "file:..", + "react-native-safe-area-context": "~5.7.0" }, "devDependencies": { - "@types/react": "~19.1.0", - "typescript": "~5.9.2" + "@types/react": "~19.2.14", + "typescript": "~6.0.3" }, "private": true } diff --git a/example/screens/DataDetector.tsx b/example/screens/DataDetector.tsx new file mode 100644 index 0000000..afd179a --- /dev/null +++ b/example/screens/DataDetector.tsx @@ -0,0 +1,124 @@ +import { StatusBar } from 'expo-status-bar'; +import { useState } from 'react'; +import { KeyboardAvoidingView, Platform, StyleSheet, View } from 'react-native'; +import { + useDataDetector, + useDetectedEntities, + type DetectedEntity, + type ModelLanguage, +} from 'react-native-data-detector'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; + +import { DetectButton } from '../components/DetectButton'; +import { DetectInput } from '../components/DetectInput'; +import { DetectedList } from '../components/DetectedList'; +import { Header } from '../components/Header'; +import { LanguageButton } from '../components/LanguageButton'; +import { LanguageSheet } from '../components/LanguageSheet'; +import { ModeToggle, type Mode } from '../components/ModeToggle'; +import { ModelStatus } from '../components/ModelStatus'; +import { SAMPLE_TEXT } from '../constants'; +import { C, SCREEN_PADDING } from '../theme'; + +export default function DataDetector() { + const insets = useSafeAreaInsets(); + + const [text, setText] = useState(SAMPLE_TEXT); + const [language, setLanguage] = useState('en'); + const [mode, setMode] = useState('reactive'); + const [langOpen, setLangOpen] = useState(false); + + // Imperative hook: model lifecycle + a detect() you call yourself. + const { detect, status, isReady } = useDataDetector({ language }); + + // Reactive hook: debounced detection as `text` changes (paused in "On tap" mode). + const { entities: liveEntities, isDetecting } = useDetectedEntities(text, { + language, + debounceMs: 200, + enabled: mode === 'reactive', + }); + + const [tappedEntities, setTappedEntities] = useState([]); + const [detecting, setDetecting] = useState(false); + + const entities = mode === 'reactive' ? liveEntities : tappedEntities; + const busy = mode === 'reactive' ? isDetecting : detecting; + const isAndroid = Platform.OS === 'android'; + + const handleDetect = async () => { + setDetecting(true); + detect(text) + .then(setTappedEntities) + .catch(() => setTappedEntities([])) + .finally(() => setDetecting(false)); + }; + + return ( + + + + +
+ + + {isAndroid ? ( + setLangOpen(true)} /> + ) : ( + + )} + + + + {isAndroid && } + + + + + + {mode === 'imperative' && ( + + )} + + + + { + setLanguage(code); + setLangOpen(false); + }} + onClose={() => setLangOpen(false)} + /> + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: C.bg, + }, + flex: { + flex: 1, + }, + body: { + flex: 1, + paddingHorizontal: SCREEN_PADDING, + }, + controls: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + }, + spacer: { + flex: 1, + minHeight: 16, + }, +}); diff --git a/example/theme.ts b/example/theme.ts new file mode 100644 index 0000000..7a58156 --- /dev/null +++ b/example/theme.ts @@ -0,0 +1,13 @@ +/** Shared visual tokens for the example app. */ +export const C = { + bg: '#0B0C10', + surface: '#16181D', + surfaceHi: '#1E2128', + border: 'rgba(255,255,255,0.08)', + text: '#F4F4F6', + muted: '#8B8D98', + accent: '#6366F1', +} as const; + +/** Horizontal screen inset. Shared so full-bleed rows can cancel it precisely. */ +export const SCREEN_PADDING = 20; diff --git a/src/index.ts b/src/index.ts index afe8ba1..4d84863 100644 --- a/src/index.ts +++ b/src/index.ts @@ -7,6 +7,8 @@ export { } from './ReactNativeDataDetector'; export { useDataDetector } from './useDataDetector'; export type { UseDataDetectorOptions, UseDataDetectorResult } from './useDataDetector'; +export { useDetectedEntities } from './useDetectedEntities'; +export type { UseDetectedEntitiesOptions, UseDetectedEntitiesResult } from './useDetectedEntities'; export type { DetectedEntity, DetectionType, diff --git a/src/useDataDetector.ts b/src/useDataDetector.ts index dc73bba..306ab04 100644 --- a/src/useDataDetector.ts +++ b/src/useDataDetector.ts @@ -1,14 +1,13 @@ -import { useCallback, useEffect, useRef, useState } from 'react'; +import { useCallback } from 'react'; -import { detect as detectFn, getModelStatus, prepareModel } from './ReactNativeDataDetector'; +import { detect as detectFn } from './ReactNativeDataDetector'; import type { DetectedEntity, DetectionType, ModelLanguage, ModelStatus, } from './ReactNativeDataDetector.types'; - -const DEFAULT_LANGUAGE: ModelLanguage = 'en'; +import { DEFAULT_LANGUAGE, useModelLifecycle } from './useModelLifecycle'; export interface UseDataDetectorOptions { /** @@ -42,8 +41,12 @@ export interface UseDataDetectorResult { } /** - * React hook for data detection that tracks model availability and, on Android, - * downloads the language model automatically. + * React hook for **imperative** data detection: it tracks model availability and, + * on Android, downloads the language model automatically, then hands you a `detect` + * function to call when you want (e.g. once per chat message). + * + * For **reactive** detection of a changing string (as-you-type), use + * {@link useDetectedEntities} instead. * * On iOS the model is always available, so `status` settles on `'ready'` and * `autoPrepare` has no effect. @@ -56,61 +59,7 @@ export function useDataDetector(options?: UseDataDetectorOptions): UseDataDetect const language = options?.language ?? DEFAULT_LANGUAGE; const autoPrepare = options?.autoPrepare ?? true; - const [status, setStatus] = useState('notDownloaded'); - const [error, setError] = useState(null); - - // The language the hook currently cares about. Lets late-resolving promises - // from a previous language (after a switch or unmount) be ignored. - const activeLanguage = useRef(language); - - const prepare = useCallback(async () => { - setError(null); - setStatus('downloading'); - try { - await prepareModel({ language }); - if (activeLanguage.current === language) setStatus('ready'); - } catch (e) { - const err = e instanceof Error ? e : new Error(String(e)); - if (activeLanguage.current === language) { - setError(err); - setStatus('error'); - } - throw err; - } - }, [language]); - - useEffect(() => { - activeLanguage.current = language; - let active = true; - - (async () => { - setError(null); - try { - const current = await getModelStatus({ language }); - if (!active) return; - if (current === 'ready') { - setStatus('ready'); - return; - } - if (!autoPrepare) { - setStatus(current); - return; - } - setStatus('downloading'); - await prepareModel({ language }); - if (active) setStatus('ready'); - } catch (e) { - if (active) { - setError(e instanceof Error ? e : new Error(String(e))); - setStatus('error'); - } - } - })(); - - return () => { - active = false; - }; - }, [language, autoPrepare]); + const { status, isReady, prepare, error } = useModelLifecycle(language, autoPrepare); const detect = useCallback( (text: string, opts?: { types?: DetectionType[] }): Promise => @@ -118,5 +67,5 @@ export function useDataDetector(options?: UseDataDetectorOptions): UseDataDetect [language], ); - return { detect, prepare, status, isReady: status === 'ready', error }; + return { detect, prepare, status, isReady, error }; } diff --git a/src/useDetectedEntities.ts b/src/useDetectedEntities.ts new file mode 100644 index 0000000..2e60356 --- /dev/null +++ b/src/useDetectedEntities.ts @@ -0,0 +1,122 @@ +import { useEffect, useRef, useState } from 'react'; + +import { detect as detectFn } from './ReactNativeDataDetector'; +import type { + DetectedEntity, + DetectionType, + ModelLanguage, + ModelStatus, +} from './ReactNativeDataDetector.types'; +import { DEFAULT_LANGUAGE, useModelLifecycle } from './useModelLifecycle'; + +export interface UseDetectedEntitiesOptions { + /** Debounce applied to `text` before detecting, in ms. Defaults to `300`. */ + debounceMs?: number; + /** Which entity types to detect. Defaults to all types. */ + types?: DetectionType[]; + /** + * Which language model to use (Android only, defaults to `'en'`). Ignored on iOS. + */ + language?: ModelLanguage; + /** When `false`, detection is paused and the last result is kept. Defaults to `true`. */ + enabled?: boolean; + /** + * When `true` (default), download the model on mount if needed (Android). + * No effect on iOS. + */ + autoPrepare?: boolean; +} + +export interface UseDetectedEntitiesResult { + /** Entities detected in the (debounced) `text`. Empty until the first result. */ + entities: DetectedEntity[]; + /** `true` while a detection for the latest text is in flight. */ + isDetecting: boolean; + /** Current model download state. */ + status: ModelStatus; + /** The last detection or model error, or `null`. */ + error: Error | null; +} + +/** + * React hook for **reactive** data detection: pass a (possibly changing) string and + * get back the detected entities, recomputed as the text changes. Debounced and + * cancellation-safe (last write wins), so it is suited to as-you-type input. + * + * Manages model readiness internally (auto-downloads on Android). For **imperative** + * detection where you call `detect` yourself, use {@link useDataDetector} instead. + * + * @example + * const { entities, isDetecting } = useDetectedEntities(text, { debounceMs: 250 }); + */ +export function useDetectedEntities( + text: string, + options?: UseDetectedEntitiesOptions, +): UseDetectedEntitiesResult { + const [entities, setEntities] = useState([]); + const [isDetecting, setIsDetecting] = useState(false); + const [detectError, setDetectError] = useState(null); + const [debouncedText, setDebouncedText] = useState(text); + + const debounceMs = options?.debounceMs ?? 300; + const language = options?.language ?? DEFAULT_LANGUAGE; + const enabled = options?.enabled ?? true; + const autoPrepare = options?.autoPrepare ?? true; + + const types = options?.types; + const typesKey = types?.join(',') ?? ''; + + const { status, isReady, error: modelError } = useModelLifecycle(language, autoPrepare); + + const runId = useRef(0); + + useEffect(() => { + if (!enabled) { + return; + } + const id = setTimeout(() => setDebouncedText(text), debounceMs); + + return () => clearTimeout(id); + }, [text, debounceMs, enabled]); + + useEffect(() => { + if (!enabled || !isReady) { + return; + } + + if (!debouncedText) { + runId.current += 1; + setEntities([]); + setIsDetecting(false); + setDetectError(null); + return; + } + + const currentRunId = ++runId.current; + + setIsDetecting(true); + setDetectError(null); + detectFn(debouncedText, { types, language }) + .then((res) => { + if (currentRunId !== runId.current) { + return; + } + setEntities(res); + setIsDetecting(false); + }) + .catch((e) => { + if (currentRunId !== runId.current) { + return; + } + setDetectError(e instanceof Error ? e : new Error(String(e))); + setIsDetecting(false); + }); + }, [debouncedText, isReady, enabled, language, typesKey]); + + return { + entities, + isDetecting, + status, + error: detectError ?? modelError, + }; +} diff --git a/src/useModelLifecycle.ts b/src/useModelLifecycle.ts new file mode 100644 index 0000000..cdecb21 --- /dev/null +++ b/src/useModelLifecycle.ts @@ -0,0 +1,85 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; + +import { getModelStatus, prepareModel } from './ReactNativeDataDetector'; +import type { ModelLanguage, ModelStatus } from './ReactNativeDataDetector.types'; + +export const DEFAULT_LANGUAGE: ModelLanguage = 'en'; + +export interface ModelLifecycle { + /** Current model download state. */ + status: ModelStatus; + /** `true` when the model is available and `detect()` can run offline. */ + isReady: boolean; + /** Manually (re)download the model for the configured language. */ + prepare: () => Promise; + /** The last preparation error, or `null`. */ + error: Error | null; +} + +/** + * Internal hook shared by {@link useDataDetector} and {@link useDetectedEntities}. + * Tracks model availability for a language and, on Android, downloads it + * automatically on mount when `autoPrepare` is set. On iOS the model is always + * available, so `status` settles on `'ready'`. + * + * Not part of the public API. + */ +export function useModelLifecycle(language: ModelLanguage, autoPrepare: boolean): ModelLifecycle { + const [status, setStatus] = useState('notDownloaded'); + const [error, setError] = useState(null); + + // The language the hook currently cares about. Lets late-resolving promises + // from a previous language (after a switch or unmount) be ignored. + const activeLanguage = useRef(language); + + const prepare = useCallback(async () => { + setError(null); + setStatus('downloading'); + try { + await prepareModel({ language }); + if (activeLanguage.current === language) setStatus('ready'); + } catch (e) { + const err = e instanceof Error ? e : new Error(String(e)); + if (activeLanguage.current === language) { + setError(err); + setStatus('error'); + } + throw err; + } + }, [language]); + + useEffect(() => { + activeLanguage.current = language; + let active = true; + + (async () => { + setError(null); + try { + const current = await getModelStatus({ language }); + if (!active) return; + if (current === 'ready') { + setStatus('ready'); + return; + } + if (!autoPrepare) { + setStatus(current); + return; + } + setStatus('downloading'); + await prepareModel({ language }); + if (active) setStatus('ready'); + } catch (e) { + if (active) { + setError(e instanceof Error ? e : new Error(String(e))); + setStatus('error'); + } + } + })(); + + return () => { + active = false; + }; + }, [language, autoPrepare]); + + return { status, isReady: status === 'ready', prepare, error }; +}