@bsky.app/tapper
v0.5.3
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 {state, input, inputProps, on} = useTapper({
facets: {
mention: /@([a-zA-Z0-9._]+)/g,
emoji: /:([a-zA-Z0-9_]+):/g,
},
})
// Re-focus after an autocomplete replacement
useEffect(() => {
return on('afterInsert', () => input.focus())
}, [on, input.focus])
return (
<View style={{position: 'relative'}}>
{/* Preview overlay — renders colored facets */}
<View
style={{position: 'absolute', top: 0, left: 0, right: 0, bottom: 0}}>
<Text>
{state.nodes.map(node => (
<Text
key={node.id}
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 |
| ------------ | ----------------------------------------------------- |
| state | Snapshot: text, selection, nodes, activeFacet |
| inputProps | Spread onto your TextInput |
| on | Subscribe to events |
| input | {element, focus(), blur()} |
| insert | Insert text at the current cursor position |
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:
id— stable numeric ID for use as a Reactkeyraw— full matched text including trigger (use for display)value— content only, trigger stripped
// Typing "@eric" produces:
{id: 1, 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,
state.activeFacet is non-null:
activeFacet: {
type: 'mention', // the facet type
value: 'er', // content after trigger (no @)
range: {start: 0, end: 3},
replace: (value, options?) => void,
}Call activeFacet.replace('@eric') to replace the in-progress facet with a
final value. A trailing space is appended by default; pass
{noTrailingSpace: true} to suppress it.
insert(text)
Insert text at the current cursor position. This is a method on the useTapper
return value (not on activeFacet).
const {insert} = useTapper()
insert('👋') // inserts at cursorEmoji auto-replace 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 {state, input, on} = 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.replace(emoji, {noTrailingSpace: true})
}
}
})
}, [on])
// Re-focus after replacement
useEffect(() => {
return on('afterInsert', () => input.focus())
}, [on, input.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 replaced) |
| afterInsert | TapperFacet | After replace() replaces text |
input.focus()
After replace() replaces text, the input may lose focus on some platforms.
Listen for afterInsert and call input.focus() to restore it:
useEffect(() => {
return on('afterInsert', () => input.focus())
}, [on, input.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 {state} = useTapper({
facets: {mention: /@([a-zA-Z0-9._]+)/g},
initialText: 'Hello @eric',
})Default facets
If no facets config is provided, tapper uses built-in patterns for mentions,
emoji, tags, and URLs. These are also exported individually:
import {mention, emoji, tag, url} from '@bsky.app/tapper'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.
