bsky-richtext-react 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +101 -0
- package/README.md +589 -0
- package/dist/index.cjs +5676 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +761 -0
- package/dist/index.d.ts +761 -0
- package/dist/index.js +5654 -0
- package/dist/index.js.map +1 -0
- package/dist/styles.css +106 -0
- package/dist/styles.css.map +1 -0
- package/dist/styles.d.cts +2 -0
- package/dist/styles.d.ts +2 -0
- package/package.json +126 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project will be documented in this file.
|
|
4
|
+
|
|
5
|
+
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
6
|
+
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
|
+
|
|
8
|
+
---
|
|
9
|
+
|
|
10
|
+
## [1.0.0] — 2026-02-17
|
|
11
|
+
|
|
12
|
+
First stable release. 🎉
|
|
13
|
+
|
|
14
|
+
### Added
|
|
15
|
+
|
|
16
|
+
#### Components
|
|
17
|
+
|
|
18
|
+
- **`<RichTextDisplay>`** — renders AT Protocol `{ text, facets }` records as interactive HTML
|
|
19
|
+
- Supports `app.bsky.richtext.facet#mention`, `#link`, and `#tag` facet types
|
|
20
|
+
- Default renderers link mentions to `bsky.app/profile`, hashtags to `bsky.app/hashtag`
|
|
21
|
+
- `renderMention`, `renderLink`, `renderTag` render props for fully custom output
|
|
22
|
+
- `mentionUrl`, `tagUrl`, `linkUrl` props for custom URL generation without replacing the full renderer
|
|
23
|
+
- `classNames` prop for targeted CSS class overrides (works with `generateClassNames()`)
|
|
24
|
+
- `disableLinks` prop to render all facets as plain text
|
|
25
|
+
- `linkProps` forwarded to every default `<a>` element
|
|
26
|
+
- Handles UTF-8 byte-offset arithmetic for multi-byte and emoji text
|
|
27
|
+
|
|
28
|
+
- **`<RichTextEditor>`** — TipTap-based WYSIWYG editor for composing AT Protocol richtext
|
|
29
|
+
- Built-in @mention autocomplete powered by the **Bluesky public API** (no auth required)
|
|
30
|
+
- Mention search is debounced (default 300 ms, configurable via `mentionSearchDebounceMs`)
|
|
31
|
+
- `onMentionQuery` prop to supply a custom search function (overrides the default)
|
|
32
|
+
- `disableDefaultMentionSearch` prop to disable the built-in search entirely
|
|
33
|
+
- URL decoration using a **stateless ProseMirror `DecorationSet`** (no stateful marks — URLs end cleanly when followed by a space)
|
|
34
|
+
- Hard-break (Shift+Enter) support
|
|
35
|
+
- Undo / redo history
|
|
36
|
+
- `onChange` emits a `RichTextRecord` with facets populated via `detectFacetsWithoutResolution()`
|
|
37
|
+
- `classNames` prop for deep-customisable styling (use `generateClassNames()`)
|
|
38
|
+
- Imperative `editorRef` API: `focus()`, `blur()`, `clear()`, `getText()`
|
|
39
|
+
- `editable` prop to toggle read-only mode
|
|
40
|
+
- Paste handler strips HTML formatting and inserts plain text
|
|
41
|
+
|
|
42
|
+
- **`<MentionSuggestionList>`** — default @mention autocomplete dropdown
|
|
43
|
+
- Avatar image support with initial-letter fallback
|
|
44
|
+
- `classNames` prop for full visual customisation
|
|
45
|
+
- `showAvatars` and `noResultsText` convenience props
|
|
46
|
+
- Keyboard navigation: ↑ / ↓ to move, Enter / Tab to select, Escape to dismiss
|
|
47
|
+
- Exported for use as a reference or reuse in custom popup implementations
|
|
48
|
+
|
|
49
|
+
#### Utilities
|
|
50
|
+
|
|
51
|
+
- **`generateClassNames(classNamesArray, cn?)`** — deep-merge utility for classNames objects
|
|
52
|
+
- Array API: pass multiple partial classNames objects merged left-to-right
|
|
53
|
+
- Later entries override earlier ones; strings at the same key are combined
|
|
54
|
+
- Recursively merges nested objects (e.g. `EditorClassNames.suggestion`)
|
|
55
|
+
- Falsy array entries (`undefined`, `null`, `false`) are silently skipped — enables clean conditional overrides
|
|
56
|
+
- Optional `cn` parameter accepts any class utility (`clsx`, `tailwind-merge`, etc.)
|
|
57
|
+
|
|
58
|
+
- **`searchBskyActors(query, limit?)`** — query the Bluesky public search API
|
|
59
|
+
- Unauthenticated, no API key required
|
|
60
|
+
- Returns an array of `MentionSuggestion` objects
|
|
61
|
+
- Fails gracefully (returns `[]`) on network errors or empty queries
|
|
62
|
+
|
|
63
|
+
- **`createDebouncedSearch(delayMs?)`** — create a debounced wrapper around `searchBskyActors`
|
|
64
|
+
- All pending callers during the debounce window receive the same result
|
|
65
|
+
- Default delay: 300 ms
|
|
66
|
+
|
|
67
|
+
- **`useRichText(record)`** — low-level hook that parses `RichTextRecord` into `RichTextSegment[]`
|
|
68
|
+
|
|
69
|
+
- **`parseRichText(record)`** — synchronous parser (non-hook variant)
|
|
70
|
+
|
|
71
|
+
- **`toShortUrl(url, maxLength?)`** — display-friendly URL shortener (strips protocol, truncates)
|
|
72
|
+
|
|
73
|
+
- **`isValidUrl(url)`** — URL validation utility
|
|
74
|
+
|
|
75
|
+
#### Types
|
|
76
|
+
|
|
77
|
+
- `RichTextRecord`, `Facet`, `ByteSlice`
|
|
78
|
+
- `MentionFeature`, `LinkFeature`, `TagFeature`, `FacetFeature`
|
|
79
|
+
- `RichTextSegment`
|
|
80
|
+
- `DisplayClassNames`, `EditorClassNames`, `SuggestionClassNames`
|
|
81
|
+
- `MentionSuggestion`, `RichTextEditorRef`
|
|
82
|
+
- `ClassNameFn`
|
|
83
|
+
|
|
84
|
+
#### Type guards
|
|
85
|
+
|
|
86
|
+
- `isMentionFeature()`, `isLinkFeature()`, `isTagFeature()`
|
|
87
|
+
|
|
88
|
+
#### Default classNames objects
|
|
89
|
+
|
|
90
|
+
- `defaultDisplayClassNames` — starting point for `<RichTextDisplay>` styling
|
|
91
|
+
- `defaultEditorClassNames` — starting point for `<RichTextEditor>` styling
|
|
92
|
+
- `defaultSuggestionClassNames` — starting point for `<MentionSuggestionList>` styling
|
|
93
|
+
|
|
94
|
+
#### CSS
|
|
95
|
+
|
|
96
|
+
- `styles.css` — structural-only (layout, box-sizing, word-break) — no colours or fonts
|
|
97
|
+
- Class-based selectors: `.bsky-richtext`, `.bsky-mention`, `.bsky-editor`, `.bsky-suggestions`, etc.
|
|
98
|
+
|
|
99
|
+
---
|
|
100
|
+
|
|
101
|
+
[1.0.0]: https://github.com/satyam-mishra-pce/bsky-richtext-react/releases/tag/v1.0.0
|
package/README.md
ADDED
|
@@ -0,0 +1,589 @@
|
|
|
1
|
+
# bsky-richtext-react
|
|
2
|
+
|
|
3
|
+
> React components for rendering and editing [Bluesky](https://bsky.app) richtext content — the [`app.bsky.richtext.facet`](https://atproto.com/lexicons/app-bsky-richtext) AT Protocol lexicon.
|
|
4
|
+
|
|
5
|
+
[](https://www.npmjs.com/package/bsky-richtext-react)
|
|
6
|
+
[](https://github.com/satyam-mishra-pce/bsky-richtext-react/actions/workflows/ci.yml)
|
|
7
|
+
[](https://opensource.org/licenses/MIT)
|
|
8
|
+
[](https://bundlephobia.com/package/bsky-richtext-react)
|
|
9
|
+
|
|
10
|
+
---
|
|
11
|
+
|
|
12
|
+
## Features
|
|
13
|
+
|
|
14
|
+
- **`<RichTextDisplay>`** — Render AT Protocol richtext records (`text` + `facets`) as interactive HTML. Handles @mentions, links, and #hashtags with fully customisable renderers and URL resolvers.
|
|
15
|
+
- **`<RichTextEditor>`** — TipTap-based editor with real-time @mention autocomplete (powered by the **Bluesky public API** by default — no auth required), stateless URL decoration, undo/redo, and an imperative ref API.
|
|
16
|
+
- **`generateClassNames()`** — Deep-merge utility for the `classNames` prop system. Pass an array of partial classNames objects and get one merged result, optionally using your own `cn()` / `clsx` / `tailwind-merge` utility.
|
|
17
|
+
- **Headless by design** — Ships with layout-only CSS. Bring your own colours, fonts, and borders.
|
|
18
|
+
- **Fully typed** — TypeScript-first with complete type definitions for all AT Protocol facet types.
|
|
19
|
+
- **Tree-shakeable** — ESM + CJS dual build via `tsup`.
|
|
20
|
+
|
|
21
|
+
---
|
|
22
|
+
|
|
23
|
+
## Installation
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
# bun (recommended)
|
|
27
|
+
bun add bsky-richtext-react
|
|
28
|
+
|
|
29
|
+
# npm
|
|
30
|
+
npm install bsky-richtext-react
|
|
31
|
+
|
|
32
|
+
# pnpm
|
|
33
|
+
pnpm add bsky-richtext-react
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
Peer dependencies (if not already installed):
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
bun add react react-dom
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
---
|
|
43
|
+
|
|
44
|
+
## Quick Start
|
|
45
|
+
|
|
46
|
+
### Rendering a post
|
|
47
|
+
|
|
48
|
+
```tsx
|
|
49
|
+
import { RichTextDisplay } from 'bsky-richtext-react'
|
|
50
|
+
import 'bsky-richtext-react/styles.css'
|
|
51
|
+
|
|
52
|
+
// Pass raw fields from an app.bsky.feed.post record
|
|
53
|
+
export function Post({ post }) {
|
|
54
|
+
return <RichTextDisplay value={{ text: post.text, facets: post.facets }} />
|
|
55
|
+
}
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
### Composing a post
|
|
59
|
+
|
|
60
|
+
```tsx
|
|
61
|
+
import { RichTextEditor } from 'bsky-richtext-react'
|
|
62
|
+
import 'bsky-richtext-react/styles.css'
|
|
63
|
+
|
|
64
|
+
export function Composer() {
|
|
65
|
+
return (
|
|
66
|
+
<RichTextEditor
|
|
67
|
+
placeholder="What's on your mind?"
|
|
68
|
+
onChange={(record) => {
|
|
69
|
+
// record.text — plain UTF-8 text
|
|
70
|
+
// record.facets — mentions (as handles), links, hashtags
|
|
71
|
+
console.log(record)
|
|
72
|
+
}}
|
|
73
|
+
/>
|
|
74
|
+
)
|
|
75
|
+
}
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
> **The editor uses the Bluesky public API for @mention search by default.** Type `@` followed by a handle to see live suggestions — no API key or authentication required. See [Mention Search](#mention-search) to customise or disable this.
|
|
79
|
+
|
|
80
|
+
---
|
|
81
|
+
|
|
82
|
+
## Styling
|
|
83
|
+
|
|
84
|
+
### 1. Import the structural CSS
|
|
85
|
+
|
|
86
|
+
```ts
|
|
87
|
+
import 'bsky-richtext-react/styles.css'
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
This only sets `display`, `word-break`, `box-sizing`, and flex layout rules. **No colours, fonts, or borders are applied.** You control all visual styling.
|
|
91
|
+
|
|
92
|
+
### 2. Target the default class names
|
|
93
|
+
|
|
94
|
+
Every element rendered by the components carries a predictable CSS class that you can target directly:
|
|
95
|
+
|
|
96
|
+
#### `<RichTextDisplay>`
|
|
97
|
+
|
|
98
|
+
| Class | Element |
|
|
99
|
+
|-------|---------|
|
|
100
|
+
| `.bsky-richtext` | Root `<span>` |
|
|
101
|
+
| `.bsky-mention` | @mention `<a>` |
|
|
102
|
+
| `.bsky-link` | Link `<a>` |
|
|
103
|
+
| `.bsky-tag` | #hashtag `<a>` |
|
|
104
|
+
|
|
105
|
+
#### `<RichTextEditor>`
|
|
106
|
+
|
|
107
|
+
| Class | Element |
|
|
108
|
+
|-------|---------|
|
|
109
|
+
| `.bsky-editor` | Root wrapper `<div>` |
|
|
110
|
+
| `.bsky-editor-content` | ProseMirror wrapper |
|
|
111
|
+
| `.bsky-editor-mention` | Mention chip inside editor |
|
|
112
|
+
| `.autolink` | URL decoration span inside editor |
|
|
113
|
+
|
|
114
|
+
#### `<MentionSuggestionList>` (dropdown)
|
|
115
|
+
|
|
116
|
+
| Class | Element |
|
|
117
|
+
|-------|---------|
|
|
118
|
+
| `.bsky-suggestions` | Outer container |
|
|
119
|
+
| `.bsky-suggestion-item` | Each suggestion row (`<button>`) |
|
|
120
|
+
| `.bsky-suggestion-item-selected` | Currently highlighted row |
|
|
121
|
+
| `.bsky-suggestion-avatar` | Avatar wrapper |
|
|
122
|
+
| `.bsky-suggestion-avatar-img` | `<img>` element |
|
|
123
|
+
| `.bsky-suggestion-avatar-placeholder` | Initial-letter fallback |
|
|
124
|
+
| `.bsky-suggestion-text` | Text column (name + handle) |
|
|
125
|
+
| `.bsky-suggestion-name` | Display name |
|
|
126
|
+
| `.bsky-suggestion-handle` | `@handle` |
|
|
127
|
+
| `.bsky-suggestion-empty` | "No results" message |
|
|
128
|
+
|
|
129
|
+
```css
|
|
130
|
+
/* Example: style the editor */
|
|
131
|
+
.bsky-editor .ProseMirror {
|
|
132
|
+
padding: 12px 16px;
|
|
133
|
+
border: 1px solid #e1e4e8;
|
|
134
|
+
border-radius: 8px;
|
|
135
|
+
font-size: 16px;
|
|
136
|
+
line-height: 1.5;
|
|
137
|
+
min-height: 120px;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/* Example: style mentions in a post */
|
|
141
|
+
.bsky-mention {
|
|
142
|
+
color: #0085ff;
|
|
143
|
+
font-weight: 600;
|
|
144
|
+
text-decoration: none;
|
|
145
|
+
}
|
|
146
|
+
.bsky-mention:hover { text-decoration: underline; }
|
|
147
|
+
|
|
148
|
+
/* Example: style the suggestion dropdown */
|
|
149
|
+
.bsky-suggestions {
|
|
150
|
+
background: #fff;
|
|
151
|
+
border: 1px solid #e1e4e8;
|
|
152
|
+
border-radius: 8px;
|
|
153
|
+
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.12);
|
|
154
|
+
min-width: 240px;
|
|
155
|
+
padding: 4px;
|
|
156
|
+
}
|
|
157
|
+
.bsky-suggestion-item { padding: 8px 10px; border-radius: 6px; }
|
|
158
|
+
.bsky-suggestion-item-selected { background: #f0f4ff; }
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
### 3. Use `generateClassNames()` for targeted overrides
|
|
162
|
+
|
|
163
|
+
The `classNames` prop on each component accepts a nested object. Use `generateClassNames()` to cleanly layer your classes on top of the defaults without rewriting them from scratch:
|
|
164
|
+
|
|
165
|
+
```tsx
|
|
166
|
+
import {
|
|
167
|
+
RichTextEditor,
|
|
168
|
+
generateClassNames,
|
|
169
|
+
defaultEditorClassNames,
|
|
170
|
+
} from 'bsky-richtext-react'
|
|
171
|
+
|
|
172
|
+
// Works with any class utility — clsx, tailwind-merge, your own cn()
|
|
173
|
+
import { cn } from '@/lib/utils'
|
|
174
|
+
|
|
175
|
+
<RichTextEditor
|
|
176
|
+
classNames={generateClassNames([
|
|
177
|
+
defaultEditorClassNames,
|
|
178
|
+
{
|
|
179
|
+
root: 'border rounded-lg p-3 focus-within:ring-2',
|
|
180
|
+
mention: 'text-blue-500 font-semibold',
|
|
181
|
+
suggestion: {
|
|
182
|
+
item: 'px-3 py-2 rounded-md',
|
|
183
|
+
itemSelected: 'bg-blue-50',
|
|
184
|
+
},
|
|
185
|
+
},
|
|
186
|
+
], cn)}
|
|
187
|
+
/>
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
`generateClassNames()` accepts any number of partial classNames objects in the array. Entries are merged left-to-right; strings at the same key are combined using `cn()`. **Falsy entries are skipped**, so conditional overrides work naturally:
|
|
191
|
+
|
|
192
|
+
```tsx
|
|
193
|
+
classNames={generateClassNames([
|
|
194
|
+
defaultEditorClassNames,
|
|
195
|
+
isCompact && { root: 'text-sm p-2' },
|
|
196
|
+
isDark && darkThemeClassNames,
|
|
197
|
+
], cn)}
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
#### Opting out of defaults
|
|
201
|
+
|
|
202
|
+
Pass a plain object to skip the defaults entirely:
|
|
203
|
+
|
|
204
|
+
```tsx
|
|
205
|
+
<RichTextDisplay classNames={{ root: 'my-text', mention: 'my-mention' }} />
|
|
206
|
+
```
|
|
207
|
+
|
|
208
|
+
### 4. Tailwind integration
|
|
209
|
+
|
|
210
|
+
```tsx
|
|
211
|
+
import { twMerge } from 'tailwind-merge'
|
|
212
|
+
import { clsx } from 'clsx'
|
|
213
|
+
|
|
214
|
+
const cn = (...inputs) => twMerge(clsx(inputs))
|
|
215
|
+
|
|
216
|
+
<RichTextEditor
|
|
217
|
+
classNames={generateClassNames([
|
|
218
|
+
defaultEditorClassNames,
|
|
219
|
+
{ root: 'rounded-xl border border-gray-200 p-4' },
|
|
220
|
+
], cn)}
|
|
221
|
+
/>
|
|
222
|
+
```
|
|
223
|
+
|
|
224
|
+
---
|
|
225
|
+
|
|
226
|
+
## API Reference
|
|
227
|
+
|
|
228
|
+
### `<RichTextDisplay>`
|
|
229
|
+
|
|
230
|
+
```tsx
|
|
231
|
+
import { RichTextDisplay } from 'bsky-richtext-react'
|
|
232
|
+
|
|
233
|
+
<RichTextDisplay value={post} />
|
|
234
|
+
```
|
|
235
|
+
|
|
236
|
+
| Prop | Type | Default | Description |
|
|
237
|
+
|------|------|---------|-------------|
|
|
238
|
+
| `value` | `RichTextRecord \| string` | — | The richtext to render |
|
|
239
|
+
| `classNames` | `Partial<DisplayClassNames>` | defaults | CSS class names for styling (use `generateClassNames()`) |
|
|
240
|
+
| `renderMention` | `(props: MentionProps) => ReactNode` | `<a>` to bsky.app | Custom @mention renderer |
|
|
241
|
+
| `renderLink` | `(props: LinkProps) => ReactNode` | `<a>` with short URL | Custom link renderer |
|
|
242
|
+
| `renderTag` | `(props: TagProps) => ReactNode` | `<a>` to bsky.app | Custom #hashtag renderer |
|
|
243
|
+
| `mentionUrl` | `(did: string) => string` | `https://bsky.app/profile/${did}` | Generate @mention `href` |
|
|
244
|
+
| `tagUrl` | `(tag: string) => string` | `https://bsky.app/hashtag/${tag}` | Generate #hashtag `href` |
|
|
245
|
+
| `linkUrl` | `(uri: string) => string` | identity | Transform link `href` (e.g. proxy URLs) |
|
|
246
|
+
| `disableLinks` | `boolean` | `false` | Render all facets as plain text |
|
|
247
|
+
| `linkProps` | `AnchorHTMLAttributes` | — | Forwarded to every default `<a>` |
|
|
248
|
+
| `...spanProps` | `HTMLAttributes<HTMLSpanElement>` | — | Forwarded to root `<span>` |
|
|
249
|
+
|
|
250
|
+
#### Custom routing example
|
|
251
|
+
|
|
252
|
+
```tsx
|
|
253
|
+
// Point mentions and hashtags to your own app routes
|
|
254
|
+
<RichTextDisplay
|
|
255
|
+
value={post}
|
|
256
|
+
mentionUrl={(did) => `/profile/${did}`}
|
|
257
|
+
tagUrl={(tag) => `/search?tag=${encodeURIComponent(tag)}`}
|
|
258
|
+
/>
|
|
259
|
+
```
|
|
260
|
+
|
|
261
|
+
#### Custom renderer example
|
|
262
|
+
|
|
263
|
+
```tsx
|
|
264
|
+
import { Link } from 'react-router-dom'
|
|
265
|
+
|
|
266
|
+
<RichTextDisplay
|
|
267
|
+
value={post}
|
|
268
|
+
renderMention={({ text, did }) => (
|
|
269
|
+
<Link to={`/profile/${did}`} className="mention">{text}</Link>
|
|
270
|
+
)}
|
|
271
|
+
/>
|
|
272
|
+
```
|
|
273
|
+
|
|
274
|
+
---
|
|
275
|
+
|
|
276
|
+
### `<RichTextEditor>`
|
|
277
|
+
|
|
278
|
+
```tsx
|
|
279
|
+
import { RichTextEditor } from 'bsky-richtext-react'
|
|
280
|
+
|
|
281
|
+
<RichTextEditor
|
|
282
|
+
placeholder="What's on your mind?"
|
|
283
|
+
onChange={(record) => setPost(record)}
|
|
284
|
+
/>
|
|
285
|
+
```
|
|
286
|
+
|
|
287
|
+
| Prop | Type | Default | Description |
|
|
288
|
+
|------|------|---------|-------------|
|
|
289
|
+
| `initialValue` | `RichTextRecord \| string` | — | Initial content (uncontrolled) |
|
|
290
|
+
| `onChange` | `(record: RichTextRecord) => void` | — | Called on every content change |
|
|
291
|
+
| `placeholder` | `string` | — | Placeholder text when empty |
|
|
292
|
+
| `onFocus` | `() => void` | — | Called when editor gains focus |
|
|
293
|
+
| `onBlur` | `() => void` | — | Called when editor loses focus |
|
|
294
|
+
| `classNames` | `Partial<EditorClassNames>` | defaults | CSS class names for styling (use `generateClassNames()`) |
|
|
295
|
+
| `onMentionQuery` | `(query: string) => Promise<MentionSuggestion[]>` | **Bluesky public API** | Custom mention search. Overrides the built-in search. |
|
|
296
|
+
| `mentionSearchDebounceMs` | `number` | `300` | Debounce delay (ms) for the built-in search. No effect when `onMentionQuery` is set. |
|
|
297
|
+
| `disableDefaultMentionSearch` | `boolean` | `false` | Disable the built-in Bluesky API search entirely |
|
|
298
|
+
| `renderMentionSuggestion` | `SuggestionOptions['render']` | tippy.js popup | Custom TipTap suggestion renderer factory |
|
|
299
|
+
| `mentionSuggestionOptions` | `DefaultSuggestionRendererOptions` | — | Options forwarded to the default renderer |
|
|
300
|
+
| `editorRef` | `Ref<RichTextEditorRef>` | — | Imperative ref |
|
|
301
|
+
| `editable` | `boolean` | `true` | Toggle read-only mode |
|
|
302
|
+
| `...divProps` | `HTMLAttributes<HTMLDivElement>` | — | Forwarded to root `<div>` |
|
|
303
|
+
|
|
304
|
+
#### `RichTextEditorRef`
|
|
305
|
+
|
|
306
|
+
```ts
|
|
307
|
+
interface RichTextEditorRef {
|
|
308
|
+
focus(): void
|
|
309
|
+
blur(): void
|
|
310
|
+
clear(): void
|
|
311
|
+
getText(): string
|
|
312
|
+
}
|
|
313
|
+
```
|
|
314
|
+
|
|
315
|
+
```tsx
|
|
316
|
+
const editorRef = useRef<RichTextEditorRef>(null)
|
|
317
|
+
|
|
318
|
+
editorRef.current?.focus()
|
|
319
|
+
editorRef.current?.clear()
|
|
320
|
+
const text = editorRef.current?.getText()
|
|
321
|
+
```
|
|
322
|
+
|
|
323
|
+
#### Mention Search
|
|
324
|
+
|
|
325
|
+
The editor searches Bluesky actors **by default** when the user types `@`:
|
|
326
|
+
|
|
327
|
+
```tsx
|
|
328
|
+
// Built-in: uses https://public.api.bsky.app, debounced 300ms
|
|
329
|
+
<RichTextEditor placeholder="Type @ to search…" />
|
|
330
|
+
|
|
331
|
+
// Custom debounce
|
|
332
|
+
<RichTextEditor mentionSearchDebounceMs={500} />
|
|
333
|
+
|
|
334
|
+
// Your own search (e.g. from an authenticated agent)
|
|
335
|
+
<RichTextEditor
|
|
336
|
+
onMentionQuery={async (query) => {
|
|
337
|
+
const res = await agent.searchActors({ term: query, limit: 8 })
|
|
338
|
+
return res.data.actors.map((a) => ({
|
|
339
|
+
did: a.did,
|
|
340
|
+
handle: a.handle,
|
|
341
|
+
displayName: a.displayName,
|
|
342
|
+
avatarUrl: a.avatar,
|
|
343
|
+
}))
|
|
344
|
+
}}
|
|
345
|
+
/>
|
|
346
|
+
|
|
347
|
+
// Disable built-in search (no suggestions unless you set onMentionQuery)
|
|
348
|
+
<RichTextEditor disableDefaultMentionSearch />
|
|
349
|
+
```
|
|
350
|
+
|
|
351
|
+
---
|
|
352
|
+
|
|
353
|
+
### `<MentionSuggestionList>`
|
|
354
|
+
|
|
355
|
+
The default suggestion dropdown rendered inside the tippy.js popup. Exported so you can reuse or reference it in your own popup implementation.
|
|
356
|
+
|
|
357
|
+
```tsx
|
|
358
|
+
import { MentionSuggestionList } from 'bsky-richtext-react'
|
|
359
|
+
```
|
|
360
|
+
|
|
361
|
+
| Prop | Type | Default | Description |
|
|
362
|
+
|------|------|---------|-------------|
|
|
363
|
+
| `items` | `MentionSuggestion[]` | — | Suggestion items (from TipTap) |
|
|
364
|
+
| `command` | `SuggestionCommand` | — | TipTap command to insert selected item |
|
|
365
|
+
| `classNames` | `Partial<SuggestionClassNames>` | defaults | CSS class names for styling |
|
|
366
|
+
| `showAvatars` | `boolean` | `true` | Show / hide avatar images |
|
|
367
|
+
| `noResultsText` | `string` | `"No results"` | Empty-state message |
|
|
368
|
+
|
|
369
|
+
---
|
|
370
|
+
|
|
371
|
+
### `useRichText(record)`
|
|
372
|
+
|
|
373
|
+
Low-level hook. Parses a `RichTextRecord` into an array of typed segments.
|
|
374
|
+
|
|
375
|
+
```ts
|
|
376
|
+
import { useRichText } from 'bsky-richtext-react'
|
|
377
|
+
|
|
378
|
+
const segments = useRichText({ text: post.text, facets: post.facets })
|
|
379
|
+
// => [{ text: 'Hello ', feature: undefined }, { text: '@alice', feature: MentionFeature }, ...]
|
|
380
|
+
```
|
|
381
|
+
|
|
382
|
+
```ts
|
|
383
|
+
interface RichTextSegment {
|
|
384
|
+
text: string
|
|
385
|
+
feature?: MentionFeature | LinkFeature | TagFeature
|
|
386
|
+
}
|
|
387
|
+
```
|
|
388
|
+
|
|
389
|
+
---
|
|
390
|
+
|
|
391
|
+
## Utilities
|
|
392
|
+
|
|
393
|
+
### `generateClassNames(classNamesArray, cn?)`
|
|
394
|
+
|
|
395
|
+
Deep-merge an array of partial classNames objects into one. String values at the same key are combined (space-joined or via `cn()`). Nested objects are merged recursively. Falsy entries are skipped.
|
|
396
|
+
|
|
397
|
+
```ts
|
|
398
|
+
import {
|
|
399
|
+
generateClassNames,
|
|
400
|
+
defaultEditorClassNames,
|
|
401
|
+
defaultDisplayClassNames,
|
|
402
|
+
defaultSuggestionClassNames,
|
|
403
|
+
} from 'bsky-richtext-react'
|
|
404
|
+
```
|
|
405
|
+
|
|
406
|
+
```ts
|
|
407
|
+
// Merge defaults with overrides
|
|
408
|
+
const classNames = generateClassNames([
|
|
409
|
+
defaultEditorClassNames,
|
|
410
|
+
{ root: 'my-editor', mention: 'text-blue-500' },
|
|
411
|
+
])
|
|
412
|
+
|
|
413
|
+
// Deep nested override
|
|
414
|
+
const classNames = generateClassNames([
|
|
415
|
+
defaultEditorClassNames,
|
|
416
|
+
{ suggestion: { item: 'px-3 py-2', itemSelected: 'bg-blue-50' } },
|
|
417
|
+
])
|
|
418
|
+
|
|
419
|
+
// Conditional entries (falsy values are ignored)
|
|
420
|
+
const classNames = generateClassNames([
|
|
421
|
+
defaultEditorClassNames,
|
|
422
|
+
isCompact && { root: 'text-sm' },
|
|
423
|
+
isDark && darkThemeClassNames,
|
|
424
|
+
])
|
|
425
|
+
|
|
426
|
+
// With a class utility for deduplication
|
|
427
|
+
import { cn } from '@/lib/utils'
|
|
428
|
+
const classNames = generateClassNames([defaultEditorClassNames, overrides], cn)
|
|
429
|
+
```
|
|
430
|
+
|
|
431
|
+
**Signature:**
|
|
432
|
+
```ts
|
|
433
|
+
function generateClassNames<T extends object>(
|
|
434
|
+
classNamesArray: (Partial<T> | undefined | null | false)[],
|
|
435
|
+
cn?: (...inputs: (string | undefined | null | false)[]) => string,
|
|
436
|
+
): T
|
|
437
|
+
```
|
|
438
|
+
|
|
439
|
+
---
|
|
440
|
+
|
|
441
|
+
### `searchBskyActors(query, limit?)`
|
|
442
|
+
|
|
443
|
+
Fetch actor suggestions from the Bluesky public API. No authentication required.
|
|
444
|
+
|
|
445
|
+
```ts
|
|
446
|
+
import { searchBskyActors } from 'bsky-richtext-react'
|
|
447
|
+
|
|
448
|
+
const suggestions = await searchBskyActors('alice', 8)
|
|
449
|
+
// => [{ did, handle, displayName?, avatarUrl? }, ...]
|
|
450
|
+
```
|
|
451
|
+
|
|
452
|
+
Returns `[]` on empty query, network error, or non-OK response.
|
|
453
|
+
|
|
454
|
+
---
|
|
455
|
+
|
|
456
|
+
### `createDebouncedSearch(delayMs?)`
|
|
457
|
+
|
|
458
|
+
Create a debounced wrapper around `searchBskyActors`. Rapid calls within the window are coalesced — only the last query fires a network request, but all pending callers receive the result.
|
|
459
|
+
|
|
460
|
+
```ts
|
|
461
|
+
import { createDebouncedSearch } from 'bsky-richtext-react'
|
|
462
|
+
|
|
463
|
+
const debouncedSearch = createDebouncedSearch(400)
|
|
464
|
+
|
|
465
|
+
// Use as onMentionQuery
|
|
466
|
+
<RichTextEditor onMentionQuery={debouncedSearch} />
|
|
467
|
+
```
|
|
468
|
+
|
|
469
|
+
---
|
|
470
|
+
|
|
471
|
+
### Other utilities
|
|
472
|
+
|
|
473
|
+
```ts
|
|
474
|
+
import { toShortUrl, isValidUrl, parseRichText } from 'bsky-richtext-react'
|
|
475
|
+
|
|
476
|
+
toShortUrl('https://example.com/some/long/path?q=1')
|
|
477
|
+
// => 'example.com/some/long/path?q=1'
|
|
478
|
+
|
|
479
|
+
isValidUrl('not a url') // => false
|
|
480
|
+
|
|
481
|
+
parseRichText({ text, facets }) // => RichTextSegment[]
|
|
482
|
+
```
|
|
483
|
+
|
|
484
|
+
---
|
|
485
|
+
|
|
486
|
+
## Types
|
|
487
|
+
|
|
488
|
+
All AT Protocol facet types are exported:
|
|
489
|
+
|
|
490
|
+
```ts
|
|
491
|
+
import type {
|
|
492
|
+
RichTextRecord, // { text: string; facets?: Facet[] }
|
|
493
|
+
Facet, // { index: ByteSlice; features: FacetFeature[] }
|
|
494
|
+
ByteSlice, // { byteStart: number; byteEnd: number }
|
|
495
|
+
MentionFeature, // { $type: 'app.bsky.richtext.facet#mention'; did: string }
|
|
496
|
+
LinkFeature, // { $type: 'app.bsky.richtext.facet#link'; uri: string }
|
|
497
|
+
TagFeature, // { $type: 'app.bsky.richtext.facet#tag'; tag: string }
|
|
498
|
+
FacetFeature, // MentionFeature | LinkFeature | TagFeature
|
|
499
|
+
RichTextSegment, // { text: string; feature?: FacetFeature }
|
|
500
|
+
MentionSuggestion, // { did, handle, displayName?, avatarUrl? }
|
|
501
|
+
RichTextEditorRef, // { focus, blur, clear, getText }
|
|
502
|
+
} from 'bsky-richtext-react'
|
|
503
|
+
```
|
|
504
|
+
|
|
505
|
+
ClassNames types:
|
|
506
|
+
|
|
507
|
+
```ts
|
|
508
|
+
import type {
|
|
509
|
+
DisplayClassNames, // { root?, mention?, link?, tag? }
|
|
510
|
+
EditorClassNames, // { root?, content?, mention?, link?, suggestion?, ... }
|
|
511
|
+
SuggestionClassNames, // { root?, item?, itemSelected?, avatar?, name?, handle?, ... }
|
|
512
|
+
ClassNameFn, // (...inputs) => string — compatible with clsx/tailwind-merge
|
|
513
|
+
} from 'bsky-richtext-react'
|
|
514
|
+
```
|
|
515
|
+
|
|
516
|
+
Type guards:
|
|
517
|
+
|
|
518
|
+
```ts
|
|
519
|
+
import { isMentionFeature, isLinkFeature, isTagFeature } from 'bsky-richtext-react'
|
|
520
|
+
|
|
521
|
+
for (const { feature } of segments) {
|
|
522
|
+
if (isMentionFeature(feature)) { /* feature.did */ }
|
|
523
|
+
if (isLinkFeature(feature)) { /* feature.uri */ }
|
|
524
|
+
if (isTagFeature(feature)) { /* feature.tag */ }
|
|
525
|
+
}
|
|
526
|
+
```
|
|
527
|
+
|
|
528
|
+
Default classNames objects (starting points for `generateClassNames()`):
|
|
529
|
+
|
|
530
|
+
```ts
|
|
531
|
+
import {
|
|
532
|
+
defaultDisplayClassNames,
|
|
533
|
+
defaultEditorClassNames,
|
|
534
|
+
defaultSuggestionClassNames,
|
|
535
|
+
} from 'bsky-richtext-react'
|
|
536
|
+
```
|
|
537
|
+
|
|
538
|
+
---
|
|
539
|
+
|
|
540
|
+
## AT Protocol Background
|
|
541
|
+
|
|
542
|
+
Richtext in AT Protocol is represented as a plain UTF-8 string (`text`) paired with an array of `facets`. Each facet maps a **byte range** (not a character range!) of the text to a semantic feature:
|
|
543
|
+
|
|
544
|
+
| Feature | `$type` | Description |
|
|
545
|
+
|---------|---------|-------------|
|
|
546
|
+
| Mention | `app.bsky.richtext.facet#mention` | Reference to another account (DID) |
|
|
547
|
+
| Link | `app.bsky.richtext.facet#link` | Hyperlink (full URI) |
|
|
548
|
+
| Tag | `app.bsky.richtext.facet#tag` | Hashtag (without `#`) |
|
|
549
|
+
|
|
550
|
+
> ⚠️ Byte offsets are **UTF-8**, but JavaScript strings are **UTF-16**. This library handles the conversion automatically via the `sliceByByteOffset` utility.
|
|
551
|
+
|
|
552
|
+
Full lexicon: [`lexicons/app/richtext/facet.json`](./lexicons/app/richtext/facet.json)
|
|
553
|
+
AT Protocol docs: [atproto.com/lexicons/app-bsky-richtext](https://atproto.com/lexicons/app-bsky-richtext)
|
|
554
|
+
|
|
555
|
+
---
|
|
556
|
+
|
|
557
|
+
## Development
|
|
558
|
+
|
|
559
|
+
```bash
|
|
560
|
+
# Start Storybook (component playground)
|
|
561
|
+
bun run dev
|
|
562
|
+
|
|
563
|
+
# Build the library
|
|
564
|
+
bun run build
|
|
565
|
+
|
|
566
|
+
# Run tests
|
|
567
|
+
bun run test
|
|
568
|
+
|
|
569
|
+
# Type-check
|
|
570
|
+
bun run typecheck
|
|
571
|
+
|
|
572
|
+
# Lint
|
|
573
|
+
bun run lint
|
|
574
|
+
|
|
575
|
+
# Format
|
|
576
|
+
bun run format
|
|
577
|
+
```
|
|
578
|
+
|
|
579
|
+
---
|
|
580
|
+
|
|
581
|
+
## Changelog
|
|
582
|
+
|
|
583
|
+
See [CHANGELOG.md](./CHANGELOG.md).
|
|
584
|
+
|
|
585
|
+
---
|
|
586
|
+
|
|
587
|
+
## License
|
|
588
|
+
|
|
589
|
+
MIT © 2026 [satyam-mishra-pce](https://github.com/satyam-mishra-pce)
|