@bsky.app/tapper
v0.3.0
Published
A minimal rich text editor for React Native and web.
Readme
@bsky.app/tapper
A facet-aware text input engine for React Native (including web). Parses
configurable patterns (mentions, emoji, tags, URLs) from text as the user types,
producing a node list you render as a colored preview overlay on top of a
transparent TextInput.
Installation
pnpm add @bsky.app/tapperPeer dependencies: react, react-native.
Basic example
import {useEffect} from 'react'
import {View, Text, TextInput} from 'react-native'
import {useTapper} from '@bsky.app/tapper'
function Composer() {
const {text, nodes, inputProps, focus, on} = useTapper({
facets: {
mention: /@([a-zA-Z0-9._]+)/g,
emoji: /:([a-zA-Z0-9_]+):/g,
},
})
// Re-focus after an autocomplete insertion
useEffect(() => {
return on('afterInsert', () => focus())
}, [on, focus])
return (
<View style={{position: 'relative'}}>
{/* Preview overlay — renders colored facets */}
<View
style={{position: 'absolute', top: 0, left: 0, right: 0, bottom: 0}}>
<Text>
{nodes.map((node, i) => (
<Text
key={i}
style={{
color:
node.type === 'facet'
? '#2e7de9'
: node.type === 'trigger'
? '#999'
: '#000',
}}>
{node.raw}
</Text>
))}
</Text>
</View>
{/* Actual input — transparent text, visible caret */}
<TextInput
{...inputProps}
multiline
style={{
color: 'transparent',
caretColor: '#000',
}}
placeholder="Type here..."
/>
</View>
)
}The preview overlay and TextInput must share the same text styles (font
family, size, line height, padding) so they align exactly.
How it works
useTapper returns:
| Property | Description |
| ------------- | --------------------------------------------------- |
| text | Current text value |
| nodes | Parsed node list for rendering |
| activeFacet | The facet the cursor is currently inside, or null |
| inputProps | Spread onto your TextInput |
| on | Subscribe to events |
| focus | Re-focus the input (useful after commit) |
Nodes
Each node has a type discriminant:
'text'— plain text, render as-is.'trigger'— the user just typed a trigger character (e.g.@) but hasn't typed any content yet. HasfacetTypeso you know which kind.'facet'— a matched pattern. HasfacetType(e.g.'mention','emoji').
All nodes have raw (the full matched text including trigger) and value
(content only, trigger stripped). For display, use node.raw.
// Typing "@eric" produces:
{type: 'facet', facetType: 'mention', raw: '@eric', value: 'eric', start: 0, end: 5}Active facet
When the cursor is inside a facet or right after a trigger character,
activeFacet is non-null:
activeFacet: {
type: 'mention', // the facet type
value: 'er', // content after trigger (no @)
range: {start: 0, end: 3},
insert: (value, options?) => void,
}Call activeFacet.insert('@eric') to replace the in-progress facet with a
final value. A trailing space is appended by default; pass
{noTrailingSpace: true} to suppress it.
Emoji auto-commit example
A common pattern: when the user types a closing : on an emoji shortcode
(e.g. :wave:), immediately replace it with the emoji character.
const {on, activeFacet, focus} = useTapper({
facets: {
emoji: /:([a-zA-Z0-9_]+):/g,
},
})
const EMOJI: Record<string, string> = {
wave: '👋',
tada: '🎉',
v: '✌️',
}
useEffect(() => {
return on('activeFacet', facet => {
if (facet?.type === 'emoji' && facet.value.endsWith(':')) {
const name = facet.value.slice(0, -1) // strip closing ":"
const emoji = EMOJI[name]
if (emoji) {
facet.insert(emoji, {noTrailingSpace: true})
}
}
})
}, [on])
// Re-focus after insertion
useEffect(() => {
return on('afterInsert', () => focus())
}, [on, focus])Events
Subscribe with on(event, callback). Returns an unsubscribe function — use it
in a useEffect cleanup.
| Event | Data | When |
| ---------------- | --------------------------- | ----------------------------------------------- |
| activeFacet | TapperActiveFacet \| null | Cursor enters or leaves a facet |
| facetCommitted | TapperFacet | A facet is finalized (cursor left or committed) |
| afterInsert | TapperFacet | After insert() replaces text |
focus()
After insert() replaces text, the input may lose focus on some platforms.
Listen for afterInsert and call focus() to restore it:
useEffect(() => {
return on('afterInsert', () => focus())
}, [on, focus])Atomic deletion
Committed facets are deleted atomically — backspacing into a committed facet from outside removes the entire facet in one keystroke, rather than deleting character by character.
initialText
Pre-populate the input with text. Any matched facets are automatically marked as committed.
const {nodes} = useTapper({
facets: {mention: /@([a-zA-Z0-9._]+)/g},
initialText: 'Hello @eric',
})replaceText
The Tapper class (used internally by useTapper) exposes replaceText(text,
cursor?) for programmatic text replacement. All matched facets in the new text
are marked as committed.
