@dotvoid/textbit
v1.5.2
Published
Textbit is an editable component created as part of the Elephant project, based on Slate.
Readme
Textbit
An unstyled, plugin-based rich text editor component for React applications. Built on Slate with support for collaborative editing via Yjs.
This is the public npm distribution of
ttab/textbit, published as@dotvoid/textbit. The two packages track the same codebase.
Installation
npm install @dotvoid/textbitDevelopment
npm install
npm run devBuild ESM and CJS modules:
npm run buildThis produces ESM and CJS modules along with TypeScript definitions in dist/.
Quick Start
import { Textbit } from '@dotvoid/textbit'
import type { TBElement } from '@dotvoid/textbit'
const initialValue: TBElement[] = [
{
type: 'core/text',
id: crypto.randomUUID(),
class: 'text',
children: [{ text: 'Hello world!' }]
}
]
function MyEditor() {
const [value, setValue] = useState(initialValue)
return (
<Textbit.Root
value={value}
onChange={setValue}
placeholder="Start typing..."
>
<Textbit.Editable className="editor" />
</Textbit.Root>
)
}Table of Contents
- Core Components
- Menu Components
- Toolbar Components
- Context Menu Components
- Hooks
- Styling
- Collaborative Editing
- Plugin Development
- Utilities
- TypeScript
Core Components
Textbit.Root
The root component that provides context for the editor. All other Textbit components must be descendants of Textbit.Root.
Props
| Name | Type | Default | Description |
|------|------|---------|-------------|
| value | string \| Descendant[] \| Y.XmlText | - | Required. Editor content. Can be a string, Slate Descendant array, or Yjs XmlText for collaboration. |
| onChange | (value: string \| Descendant[]) => void | - | Called when content changes. Will serve Descendant[] when used with Y.XmlText. |
| awareness | Awareness \| null | - | Yjs awareness instance for collaborative cursors. Only valid when value is Y.XmlText. |
| cursor | CursorConfig | - | Cursor configuration for collaboration. See Collaborative Editing. |
| plugins | TBPluginDefinition[] | - | Array of plugin definitions. |
| placeholder | string | '' | Placeholder text when editor is empty. |
| placeholders | 'none' \| 'single' \| 'multiple' | 'none' | Controls placeholder display mode. When using multiple text plugins displays their own placeholders per text object. |
| readOnly | boolean | false | Makes editor read-only. |
| debounce | number | 1250 | Debounce time for onChange in milliseconds. |
| spellcheckDebounce | number | 1250 | Debounce time for spellcheck in milliseconds. |
| onSpellcheck | SpellcheckFunction | - | Async function to handle spellchecking. |
| verbose | boolean | false | Enables console logging for debugging. |
| className | string | - | CSS class for root container. |
| style | React.CSSProperties | - | Inline styles for root container. |
| dir | 'ltr' \| 'rtl' | 'ltr' | Text direction. |
| lang | string | 'en' | Language code (e.g., 'en', 'sv'). |
Spellcheck Function Type
A spellcheck function will receive an array of texts (with language code and the actual text). The function is expected to resolve with an array of spelling issues. Each spelling issue defines the identified string, start position of the string, an array of suggested substitutions and severity level.
When loading the editor the first time the whole text will be spellchecked. After that only the text object changed will be checked.
type SpellcheckFunction = (
texts: Array<{ lang: string; text: string }>
) => Promise<Array<Array<Omit<SpellingError, 'id'>>>>
interface SpellingError {
str: string // The misspelled text
pos: number // Position in the text
sub: string[] // Suggested replacements
level?: 'error' | 'suggestion' // Severity level
}Examples
String Mode
function SimpleEditor() {
const [text, setText] = useState('')
return (
<Textbit.Root value={text} onChange={setText}>
<Textbit.Editable />
</Textbit.Root>
)
}Structured Mode
import { Bold, Italic, Heading } from './plugins'
const initialValue = Descendant[] = [
{
type: 'core/text',
id: '538345e5-bacc-48f9-8ef1-a219891b6011',
class: 'text',
properties: {
role: 'heading-1'
},
children: [
{ text: 'The Baltic Sea' }
]
},
{
type: 'core/text',
id: '538345e5-bacc-48f9-8ef0-1219891b6024',
class: 'text',
children: [
{ text: 'This text editor was built on an island in the ' },
{
text: 'Baltic Sea',
'core/bold': true
},
{
text: '.'
}
]
}
]
function RichTextEditor() {
const [value, setValue] = useState(initialValue)
return (
<Textbit.Root
value={value}
onChange={setValue}
plugins={[Bold(), Italic(), Heading()]}
>
<Textbit.Editable />
</Textbit.Root>
)
}With Spellcheck
function EditorWithSpellcheck() {
const [value, setValue] = useState(initialValue)
const handleSpellcheck = async (texts) => {
return texts.map(({ text, lang }) => {
// Return array of spelling errors for each text
return [
{ str: 'teh', pos: 0, sub: ['the', 'tea'], level: 'error' },
{ str: 'recieve', pos: 10, sub: ['receive'], level: 'error' }
]
})
}
return (
<Textbit.Root
value={value}
onChange={setValue}
onSpellcheck={handleSpellcheck}
>
<Textbit.Editable />
</Textbit.Root>
)
}Textbit.Editable
The editable content area. Must be a child of Textbit.Root.
Props
| Name | Type | Default | Description |
|------|------|---------|-------------|
| autoFocus | boolean \| 'start' \| 'end' | false | Auto-focus behavior. true/'start' focuses at start, 'end' focuses at end. |
| onFocus | React.FocusEventHandler<HTMLDivElement> | - | Called when editor receives focus. |
| onBlur | React.FocusEventHandler<HTMLDivElement> | - | Called when editor loses focus. |
| className | string | - | CSS class for editable container. |
| style | React.CSSProperties | - | Inline styles for editable container. |
| children | React.ReactNode | - | Additional components (Toolbar, Gutter, etc.). |
Data Attributes
| Attribute | Values | Description |
|-----------|--------|-------------|
| data-state | "focused" \| "" | Indicates whether editor has focus. |
Example
<Textbit.Editable
autoFocus="end"
className="prose dark:prose-invert"
onFocus={() => console.log('Editor focused')}
onBlur={() => console.log('Editor blurred')}
>
<Textbit.Gutter>
<Menu.Root>{/* ... */}</Menu.Root>
</Textbit.Gutter>
<Toolbar.Root>{/* ... */}</Toolbar.Root>
</Textbit.Editable>Textbit.Gutter
Provides a gutter area for content tools (like a menu). Automatically positions itself relative to the active block.
Props
| Name | Type | Description |
|------|------|-------------|
| children | React.ReactNode | Content to display in gutter (typically Menu.Root). |
Example
<div style={{ display: 'grid', gridTemplateColumns: '50px 1fr' }}>
<Textbit.Gutter>
<Menu.Root>
<Menu.Trigger>⋮</Menu.Trigger>
<Menu.Content>
{/* Menu items */}
</Menu.Content>
</Menu.Root>
</Textbit.Gutter>
<Textbit.Editable />
</div>Textbit.DropMarker
Visual indicator for drag-and-drop operations. Automatically handles positioning and visibility.
Props
| Name | Type | Description |
|------|------|-------------|
| className | string | CSS class for styling the drop marker. |
Data Attributes
| Attribute | Values | Description |
|-----------|--------|-------------|
| data-dragover | "none" \| "between" \| "around" | Indicates drag state. "between" shows line between elements, "around" encompasses droppable element. |
Example
<Textbit.Editable>
<Textbit.DropMarker className="drop-marker" />
{/* Other children */}
</Textbit.Editable>CSS Styling
.drop-marker[data-dragover="between"] {
height: 2px;
background: #3b82f6;
}
.drop-marker[data-dragover="around"] {
outline: 2px solid #3b82f6;
outline-offset: 2px;
}Textbit.Plugins
Array of standard plugins included with Textbit.
import { Textbit } from '@dotvoid/textbit'
// Use default plugins
<Textbit.Root plugins={Textbit.Plugins}>
<Textbit.Editable />
</Textbit.Root>
// Use custom plugins
<Textbit.Root plugins={[...Textbit.Plugins, MyCustomPlugin()]}>
<Textbit.Editable />
</Textbit.Root>Menu Components
Components for building a content menu (block-level tools). Typically used in the gutter.
Menu.Root
Root component for the menu structure.
Props
| Name | Type | Description |
|------|------|-------------|
| className | string | CSS class for menu root. |
| children | React.ReactNode | Menu content. |
Data Attributes
| Attribute | Values | Description |
|-----------|--------|-------------|
| data-state | "open" \| "closed" | Indicates menu open state. |
Menu.Trigger
Button that toggles the menu.
Props
| Name | Type | Description |
|------|------|-------------|
| className | string | CSS class for trigger button. |
| children | React.ReactNode | Trigger content (text, icon). |
Menu.Content
Container for menu items.
Props
| Name | Type | Description |
|------|------|-------------|
| className | string | CSS class for menu content. |
| children | React.ReactNode | Menu groups and items. |
Menu.Group
Groups related menu items.
Props
| Name | Type | Description |
|------|------|-------------|
| className | string | CSS class for group. |
| children | React.ReactNode | Menu items. |
Menu.Item
Individual menu item that triggers a plugin action.
Props
| Name | Type | Description |
|------|------|-------------|
| action | string \| TBPluginRegistryAction | Required. Action name or action object from plugin registry. |
| className | string | CSS class for item. |
| children | React.ReactNode | Item content (icon, label, hotkey). |
Data Attributes
| Attribute | Values | Description |
|-----------|--------|-------------|
| data-state | "active" \| "inactive" | Indicates if item's plugin is active in current selection. |
Example
import { usePluginRegistry } from '@dotvoid/textbit'
function ContentMenu() {
const { actions } = usePluginRegistry()
return (
<Menu.Root className="menu">
<Menu.Trigger className="menu-trigger">⋮</Menu.Trigger>
<Menu.Content className="menu-content">
<Menu.Group className="menu-group">
{actions
.filter(a => a.plugin.class === 'text')
.map(action => (
<Menu.Item
key={action.name}
action={action.name}
className="menu-item"
>
<Menu.Icon className="menu-icon" />
<Menu.Label className="menu-label" />
<Menu.Hotkey className="menu-hotkey" />
</Menu.Item>
))
}
</Menu.Group>
</Menu.Content>
</Menu.Root>
)
}Menu.Icon
Displays the action's icon. Auto-populated from plugin or can be overridden.
Props
| Name | Type | Description |
|------|------|-------------|
| className | string | CSS class for icon. |
| children | React.ReactNode | Optional. Override default icon. |
Menu.Label
Displays the action's label. Auto-populated from plugin or can be overridden.
Props
| Name | Type | Description |
|------|------|-------------|
| className | string | CSS class for label. |
| children | React.ReactNode | Optional. Override default label. |
Menu.Hotkey
Displays the action's keyboard shortcut. Automatically formats platform-specific shortcuts (e.g., mod+b becomes ⌘B on Mac, Ctrl+B on Windows).
Props
| Name | Type | Description |
|------|------|-------------|
| className | string | CSS class for hotkey. |
| children | React.ReactNode | Optional. Override default hotkey. |
Toolbar Components
Components for building a context toolbar (inline tools like bold, italic). The toolbar automatically positions itself near the current selection.
Toolbar.Root
Root component for context toolbar.
Props
| Name | Type | Description |
|------|------|-------------|
| className | string | CSS class for toolbar. |
| children | React.ReactNode | Toolbar groups and items. |
Toolbar.Group
Groups related toolbar items.
Props
| Name | Type | Description |
|------|------|-------------|
| className | string | CSS class for group. |
| children | React.ReactNode | Toolbar items. |
Toolbar.Item
Individual toolbar button that triggers a plugin action.
Props
| Name | Type | Description |
|------|------|-------------|
| action | string \| TBPluginRegistryAction | Required. Action name or action object from plugin registry. |
| className | string | CSS class for item. |
Data Attributes
| Attribute | Values | Description |
|-----------|--------|-------------|
| data-state | "active" \| "inactive" | Indicates if item's plugin is active in current selection. |
Example
import { usePluginRegistry } from '@dotvoid/textbit'
function ContextToolbar() {
const { actions } = usePluginRegistry()
return (
<Toolbar.Root className="toolbar">
<Toolbar.Group className="toolbar-group">
{actions
.filter(a => a.plugin.class === 'leaf')
.map(action => (
<Toolbar.Item
key={action.name}
action={action}
className="toolbar-item"
/>
))
}
</Toolbar.Group>
<Toolbar.Group className="toolbar-group">
{actions
.filter(a => a.plugin.class === 'inline')
.map(action => (
<Toolbar.Item
key={action.name}
action={action}
className="toolbar-item"
/>
))
}
</Toolbar.Group>
</Toolbar.Root>
)
}Context Menu Components
Components for building a context menu (right-click menu), primarily for spelling suggestions and custom actions.
Textbit.ContextMenu.Root
Root component for context menu. Automatically positions based on right-click location.
Props
| Name | Type | Description |
|------|------|-------------|
| className | string | CSS class for context menu. |
| children | React.ReactNode | Menu groups and items. |
Textbit.ContextMenu.Group
Groups related context menu items.
Props
| Name | Type | Description |
|------|------|-------------|
| className | string | CSS class for group. |
| children | React.ReactNode | Context menu items. |
Textbit.ContextMenu.Item
Individual context menu item.
Props
| Name | Type | Description |
|------|------|-------------|
| func | () => void | Callback function executed on click. Optional if only displaying static content. |
| className | string | CSS class for item. |
| children | React.ReactNode | Item content. |
Example
import { useContextMenuHints } from '@dotvoid/textbit'
function SpellingContextMenu() {
const { spelling } = useContextMenuHints()
return (
<Textbit.ContextMenu.Root className="context-menu">
<Textbit.ContextMenu.Group className="context-menu-group">
{spelling?.suggestions.length === 0 && (
<Textbit.ContextMenu.Item className="context-menu-item">
No spelling suggestions
</Textbit.ContextMenu.Item>
)}
{spelling?.suggestions.map(({ text, description }) => (
<Textbit.ContextMenu.Item
key={text}
className="context-menu-item"
func={() => spelling.apply(text)}
>
{text}
{description && <em> - {description}</em>}
</Textbit.ContextMenu.Item>
))}
</Textbit.ContextMenu.Group>
</Textbit.ContextMenu.Root>
)
}
// Use it in Textbit.Editable
<Textbit.Editable>
<SpellingContextMenu />
</Textbit.Editable>Hooks
useTextbit()
Access Textbit context and editor state.
const {
stats, // TextbitStats
verbose, // boolean
readOnly, // boolean
collaborative, // boolean
placeholders, // PlaceholdersVisibility
placeholder, // string
dir, // 'ltr' | 'rtl'
lang, // string
dispatch // Dispatch<PluginRegistryReducerAction>
} = useTextbit()
interface TextbitStats {
full: { words: number; characters: number }
short: { words: number; characters: number }
}Statistics
Full statistics includes all nodes of class 'text' regardless of level. Short statistics only include top nodes of type 'core/text'.
Example
function EditorStats() {
const { stats } = useTextbit()
return (
<div>
<div>Words: {stats.full.words}</div>
<div>Characters: {stats.full.characters}</div>
{stats.short.words > 0 && (
<div>Short: {stats.short.words} words</div>
)}
</div>
)
}usePluginRegistry()
Access registered plugins and actions.
const {
plugins, // TBPluginDefinition[]
components, // Map<string, PluginRegistryComponent>
actions // TBPluginRegistryAction[]
} = usePluginRegistry()Example
function PluginList() {
const { plugins, actions } = usePluginRegistry()
return (
<div>
<h3>Registered Plugins: {plugins.length}</h3>
<ul>
{actions.map(action => (
<li key={action.name}>{action.title}</li>
))}
</ul>
</div>
)
}useAction(pluginName, actionName)
Get a specific action function from a plugin. Useful for programmatic control.
const myAction = useAction('core/image', 'upload-image')
// Call it with optional arguments
myAction({ file: imageFile, url: 'https://...' })Example
function ImageUploader() {
const uploadImage = useAction('core/image', 'insert-image')
const handleFileSelect = async (file: File) => {
const url = await uploadToServer(file)
uploadImage({ url, alt: file.name })
}
return <input type="file" onChange={e => handleFileSelect(e.target.files[0])} />
}useContextMenuHints()
Access context menu state and spelling information.
const {
isOpen, // boolean
position, // { x: number; y: number } | undefined
target, // HTMLElement | undefined
event, // MouseEvent | undefined
nodeEntry, // NodeEntry | undefined
spelling // SpellingInfo | undefined
} = useContextMenuHints()
interface SpellingInfo {
text: string
level?: 'error' | 'suggestion'
suggestions: Array<{
text: string
description?: string
}>
range?: Range
apply: (replacement: string) => void
}Example
function ContextMenu() {
const { isOpen, spelling, position } = useContextMenuHints()
if (!isOpen || !spelling) {
return null
}
return (
<div style={{ position: 'fixed', left: position?.x, top: position?.y }}>
{spelling.suggestions.map(({ text }) => (
<button key={text} onClick={() => spelling.apply(text)}>
{text}
</button>
))}
</div>
)
}useBlockSelection()
Access the current block-level selection state. Returns null when no block selection is active.
const blockSelection = useBlockSelection()
// Returns: { anchorIndex: number, focusIndex: number } | nullThe selected range spans from Math.min(anchorIndex, focusIndex) to Math.max(anchorIndex, focusIndex) inclusive, referencing top-level block indices in the editor. See Block Selection for details on how block selection is initiated and controlled.
useSelectionBounds()
Get the current selection's bounding rectangle.
const bounds = useSelectionBounds()
// Returns: DOMRect | nullExample
function SelectionHighlight() {
const bounds = useSelectionBounds()
if (!bounds) return null
return (
<div
style={{
position: 'fixed',
left: bounds.left,
top: bounds.top,
width: bounds.width,
height: bounds.height,
border: '2px solid blue',
pointerEvents: 'none'
}}
/>
)
}Styling
Textbit provides minimal default styling, allowing you to fully customize the appearance.
CSS Custom Properties
Textbit exposes CSS custom properties for theming injected styles.
| Property | Default | Description |
|----------|---------|-------------|
| --tb-focus-ring-radius | 2px | Border radius of the focus ring shown around block and void elements when the cursor is inside them. |
Set the property on the editor root or any ancestor:
/* Globally */
:root {
--tb-focus-ring-radius: 6px;
}
/* Scoped to a specific editor */
.my-editor {
--tb-focus-ring-radius: 0px;
}Data Attributes for Styling
Editor State
/* When editor has focus */
[data-state="focused"] {
outline: 2px solid #3b82f6;
}Menu and Toolbar Items
/* Active plugin */
[data-state="active"] {
background: #dbeafe;
color: #1e40af;
}
/* Inactive plugin */
[data-state="inactive"] {
opacity: 0.6;
}Placeholder
The empty-leaf placeholder (shown when placeholder is set and the editor/text node is empty) renders with data-slate-placeholder="true", matching slate-react's convention.
| Attribute | Values | Description |
|-----------|--------|-------------|
| data-slate-placeholder | "true" | Present on the placeholder element when an empty editor/text node displays placeholder text. |
[data-slate-placeholder] {
font-style: italic;
color: #94a3b8;
}Drag and Drop
/* Line between elements */
[data-dragover="between"] {
height: 2px;
background: #3b82f6;
margin: 4px 0;
}
/* Highlight around droppable element */
[data-dragover="around"] {
outline: 2px dashed #3b82f6;
outline-offset: 2px;
}Element Type and Role
Every rendered element wrapper carries the element's type as a data attribute, enabling consumers to target a specific block or any of its named child slots from CSS without parsing classnames. Top-level text blocks additionally carry their role (when set) so variants like heading-1, preamble, etc. can be styled directly.
| Attribute | Values | Description |
|-----------|--------|-------------|
| data-type | string | The element's type (e.g. core/image, core/image/caption). Present on both top-level block wrappers and child element wrappers. |
| data-role | string | Optional. The element's properties.role (e.g. heading-1, preamble). Emitted on text-class element wrappers (both top-level blocks and named child slots) when properties.role is a non-empty string. An absent data-role means the element is regular text. asOwnElement plugin components receive it via spreadable attributes under the same condition. |
/* Style the byline field of a TTVisual block */
[data-type="tt/visual/byline"] {
font-style: italic;
}
/* Combine with slate-react's marker to target only the editable region */
[data-type="tt/visual/byline"] [data-slate-leaf] {
background: #fafafa;
}
/* Style a text block by its role */
[data-type="core/text"][data-role="heading-1"] {
font-size: 2rem;
font-weight: 700;
}Spelling Errors
Spelling errors are rendered with data attributes for custom styling:
| Attribute | Values | Description |
|-----------|--------|-------------|
| data-spelling-error | string | Unique ID of spelling error. |
| data-spelling-level | "error" \| "suggestion" | Severity level. |
CSS Example
[data-spelling-error] {
text-decoration: underline dotted;
}
[data-spelling-level="error"] {
text-decoration-color: #ef4444;
}
[data-spelling-level="suggestion"] {
text-decoration-color: #3b82f6;
}Tailwind Example
<Textbit.Editable
className="
[&_[data-spelling-error]]:underline
[&_[data-spelling-error]]:decoration-dotted
[&_[data-spelling-level='error']]:decoration-red-500
[&_[data-spelling-level='suggestion']]:decoration-blue-500
"
/>Collaborative Editing
Textbit supports real-time collaboration using Yjs.
Basic Setup
import * as Y from 'yjs'
import { WebrtcProvider } from 'y-webrtc'
import { Textbit } from '@dotvoid/textbit'
function CollaborativeEditor() {
const ydoc = useMemo(() => new Y.Doc(), [])
const provider = useMemo(
() => new WebrtcProvider('my-room-name', ydoc),
[ydoc]
)
const sharedContent = useMemo(
() => ydoc.get('content', Y.XmlText),
[ydoc]
)
return (
<Textbit.Root
value={sharedContent}
awareness={provider.awareness}
cursor={{
data: {
name: 'John Doe',
color: 'rgb(59, 130, 246)',
initials: 'JD'
}
}}
>
<Textbit.Editable />
</Textbit.Root>
)
}Cursor Configuration
When using collaborative editing, configure how cursors are displayed:
interface CursorConfig {
stateField?: string // Awareness field name for cursor state
dataField?: string // Awareness field name for cursor data
autoSend?: boolean // Auto-send cursor updates (default: true)
data: {
name: string // User's display name
color: string // User's cursor color (rgb/hex)
initials: string // User's initials
avatar?: string // Optional avatar URL
[key: string]: unknown // Additional custom data
}
}Full Collaborative Example
import { useMemo, useEffect } from 'react'
import * as Y from 'yjs'
import { WebrtcProvider } from 'y-webrtc'
import { Textbit } from '@dotvoid/textbit'
import { slateNodesToInsertDelta } from '@slate-yjs/core'
function CollaborativeEditor() {
const ydoc = useMemo(() => new Y.Doc(), [])
const provider = useMemo(
() => new WebrtcProvider('room-' + roomId, ydoc),
[ydoc, roomId]
)
const content = useMemo(() => ydoc.get('content', Y.XmlText), [ydoc])
// Initialize with existing content
useEffect(() => {
if (content.length === 0 && initialContent.length > 0) {
content.applyDelta(slateNodesToInsertDelta(initialContent))
}
}, [content, initialContent])
return (
<Textbit.Root
value={content}
awareness={provider.awareness}
cursor={{
autoSend: true,
data: {
name: currentUser.name,
color: currentUser.color,
initials: currentUser.initials,
avatar: currentUser.avatarUrl
}
}}
plugins={[/* your plugins */]}
>
<Textbit.Editable>
{/* Other components */}
</Textbit.Editable>
</Textbit.Root>
)
}Plugin Development
Plugins extend Textbit with custom content types and behaviors.
Plugin Types
| Class | Description | Examples |
|-------|-------------|----------|
| leaf | Inline formatting | Bold, italic, underline |
| inline | Inline blocks | Links, mentions |
| text | Text blocks | Paragraphs, headings, blockquotes |
| block | Block elements | Images, videos, embeds |
| void | Non-editable elements | Loaders, a child image element in a block element |
| generic | Non-visual plugins | Input transformers, validators |
Drag'n drop
Block and void class elements are automatically draggable in all parts not occupied by a child text element.
Any DOM element with the attribute draggable set to true will act as a "drag handle" for the entire top level ancestor block. This is useful if one need to make a text element draggable. Usually these DOM elements also need to have contentEditable set to false as well.
Plugin Structure
import type { TBPluginInitFunction } from '@dotvoid/textbit'
const MyPlugin: TBPluginInitFunction = (options) => {
return {
class: 'block',
name: 'namespace/image',
actions: [{
name: 'toggle-image',
title: 'Image',
hotkey: 'mod+i',
tool: () => <ImageIcon />,
handler: ({ editor, options }) => {
// Custom logic here
// Return true to also use default behavior
// Return false if you handled everything
return true
}
}],
componentEntry: {
class: 'block',
component: Figure,
children: [
{ type: 'image', class: 'void', component: ImageContent,
constraints: { min: 1, max: 1 } },
{ type: 'caption', class: 'text', component: Caption,
constraints: { allowBreak: false, min: 1, max: 1 } }
]
},
// Optional: Plugin options
options: options || {}
}
}Component Props
Plugin components receive these props:
interface TBComponentProps {
element: TBElement // Current element being rendered
children: React.ReactNode // Child elements to render
rootNode?: TBElement // Root element if this is a descendant component
options?: Record<string, unknown> // Plugin options
}Constraints
Constraints control editing behavior within a component. They are set in the constraints property of a componentEntry and can be applied to both the top-level component entry and child component entries.
| Name | Type | Default | Description |
|------|------|---------|-------------|
| allowBreak | boolean | true | When false, prevents Enter from creating new nodes inside the component |
| allowSoftBreak | boolean | true | When false, prevents soft breaks (Shift+Enter) within a text element |
| allowExitBreak | boolean | true | When false, prevents Enter at the last position from exiting the top-level block to create a new text block after it |
| allowEdgeWhitespace | boolean | true | When false, prevents Space from adding a whitespace character at the first position. If one or more whitespace characters are left trailing at the last position or after, these will be removed on the following blur event |
| normalizeNode | (editor, nodeEntry) => boolean \| void | — | Custom normalization function; return true to signal the normalization was handled |
Child count constraints (min / max)
A child component entry (an item in a parent's children array) can declare how many instances of its type are allowed inside the parent. These fields sit inside constraints alongside the behavior-level fields above:
| Name | Type | Default | Description |
|------|------|---------|-------------|
| min | number | — | Minimum number of instances required in the parent. If the count ever drops below min, Textbit inserts a minimal placeholder to restore the invariant. |
| max | number | — | Maximum number of instances allowed in the parent. Enter is blocked when the count is already at max; excess siblings are removed by the normalizer. |
min and max are only valid on child entries. TypeScript will reject them on a top-level componentEntry — the ComponentEntry type forbids them, while entries inside a children array are typed as ChildComponentEntry and accept them.
Form-field semantics: set both to 1 to model a permanent sub-element that always exists exactly once (e.g., an image caption). The user cannot delete it — if they try, it is re-inserted — and Enter cannot split it into a second one. Parent blocks are never removed because of child constraints.
Textbit enforces these automatically:
- Normalization: on every change,
min <= count <= maxis restored by inserting placeholders or removing excess siblings, in the order defined by the parent'schildrenarray. - Enter: blocked inside a child whose type is already at
maxin the parent. - Paste: pasting a block-level fragment inside any child text element (e.g., a caption — whether or not it has
min/maxset) is flattened to plain text so it cannot corrupt the parent block's structure. - Delete / Backspace: handled reactively by the normalizer — a
min: 1child is re-inserted synchronously if removed.
This replaces the boilerplate normalizer that plugins used to need for "exactly one of X, at most one of Y" rules. Plugins can still define a custom normalizeNode alongside these for anything more exotic; it runs after the declarative enforcement.
See the Image Block Plugin example below for a practical demonstration of allowBreak, min, and max.
Example: Bold Plugin
import { BoldIcon } from 'lucide-react'
import type { TBPluginInitFunction, TBComponentProps } from '@dotvoid/textbit'
const Bold: TBPluginInitFunction = () => {
return {
class: 'leaf',
name: 'core/bold',
actions: [{
name: 'toggle-bold',
title: 'Bold',
hotkey: 'mod+b',
tool: () => <BoldIcon size={16} />,
handler: () => true // Use default toggle behavior
}],
getStyle: () => {
// Leaf CSS styling
return {
fontWeight: 'bold'
}
}
}
}
export { Bold }Example: Link Plugin
import { LinkIcon } from 'lucide-react'
import type { TBPluginInitFunction, TBComponentProps } from '@dotvoid/textbit'
import { Editor, Transforms } from 'slate'
const Link: TBPluginInitFunction = () => {
return {
class: 'inline',
name: 'core/link',
actions: [{
name: 'insert-link',
title: 'Link',
hotkey: 'mod+k',
tool: () => <LinkIcon size={16} />,
handler: ({ editor }) => {
const url = prompt('Enter URL:')
if (!url) return false
const link = {
type: 'core/link',
url,
children: [{ text: url }]
}
if (editor.selection) {
Transforms.wrapNodes(editor, link, { split: true })
} else {
Transforms.insertNodes(editor, link)
}
return false // We handled everything
}
}],
componentEntry: {
class: 'inline',
component: LinkComponent
}
}
}
function LinkComponent({ element, children }: TBComponentProps) {
return (
<a
href={element.url}
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 underline"
>
{children}
</a>
)
}
export { Link }Example: Image Block Plugin
import { ImageIcon } from 'lucide-react'
import type { TBPluginInitFunction, TBComponentProps } from '@dotvoid/textbit'
import { Transforms } from '@dotvoid/textbit'
const Image: TBPluginInitFunction = () => {
return {
class: 'block',
name: 'core/image',
actions: [{
name: 'insert-image',
title: 'Image',
hotkey: 'mod+shift+i',
tool: () => <ImageIcon size={16} />,
handler: ({ editor }) => {
const url = prompt('Enter image URL:')
if (!url) return false
const image = {
type: 'core/image',
class: 'block',
id: crypto.randomUUID(),
properties: { src: url },
children: [
{
type: 'core/image/caption',
class: 'text',
children: [{ text: '' }]
}
]
}
Transforms.insertNodes(editor, image)
return false
}
}],
componentEntry: {
class: 'block',
component: FigureComponent,
children: [
{
type: 'image',
class: 'void',
component: ImageComponent,
constraints: { min: 1, max: 1 }
},
{
type: 'text',
class: 'text',
component: CaptionComponent,
constraints: {
allowBreak: false,
min: 1,
max: 1
}
}
]
}
}
}
function FigureComponent({ element, children }: TBComponentProps) {
return (
<figure className="my-4">
{children}
</figure>
)
}
function ImageComponent({ element, children }: TBComponentProps) {
return (
<img
src={element.properties.src}
alt=""
className="w-full rounded"
/>
)
}
function CaptionComponent({ children }: TBComponentProps) {
return (
<div className="p-2 flex rounded rounded-xs text-sm bg-slate-200 dark:bg-slate-800">
<label className="grow-0 w-16 opacity-70" contentEditable={false}>Text:</label >
<figcaption className="grow">
{children}
</figcaption>
</div >
)
}
export { Image }Using Actions in Components
Use the useAction hook to call plugin actions from within components:
import { useAction } from '@dotvoid/textbit'
import type { TBComponentProps } from '@dotvoid/textbit'
function ImageComponent({ element }: TBComponentProps) {
const deleteImage = useAction('core/image', 'delete-image')
return (
<figure contentEditable={false}>
<img src={element.properties.src} alt="" />
<button onClick={() => deleteImage({ id: element.id })}>
Delete
</button>
</figure>
)
}Custom Root Elements (asOwnElement)
By default, Textbit wraps each plugin component in a <div> so framework attributes (data-id, data-type, slate-react's data-slate-node, etc.) reliably reach the DOM. When the structural HTML tag matters — e.g. a <tr> inside a <table>, or an <li> inside a <ul> — set asOwnElement: true on the componentEntry and spread attributes onto the component's root node.
import type { TBComponentProps } from '@dotvoid/textbit'
export const TableRow = ({ children, attributes }: TBComponentProps<HTMLTableRowElement>) => (
<tr {...attributes}>{children}</tr>
)
TableRow.displayName = 'TableRow'
// In the plugin definition:
{
type: 'row',
class: 'block',
component: TableRow,
asOwnElement: true
}When asOwnElement is true, the component is responsible for spreading attributes onto its root DOM element. attributes carries the framework's data-id, data-type, lang, an optional data-role (text-class elements with properties.role set; absent means regular text), plus slate-react's data-slate-node and ref. Failing to spread them will break selection mapping for that element.
Utilities
File Handling
Process dropped files or file input changes:
import {
consumeFileDropEvent,
consumeFileInputChangeEvent
} from '@dotvoid/textbit'
// Handle drop events
const handleDrop = async (event: DragEvent) => {
const files = await consumeFileDropEvent(event)
files.forEach(file => {
console.log(file.name, file.type, file.size)
})
}
// Handle file input
const handleChange = async (event: ChangeEvent<HTMLInputElement>) => {
const files = await consumeFileInputChangeEvent(event)
files.forEach(file => {
console.log(file.name, file.type, file.size)
})
}Calculate Statistics
Calculate word and character counts:
import { useTextbit } from '@dotvoid/textbit'
function EditorStats() {
const { stats } = useTextbit()
return (
<div>
<div>Words: {stats.full.words}</div>
<div>Characters: {stats.full.characters}</div>
{stats.short.words > 0 && (
<div>Selected: {stats.short.words} words</div>
)}
</div>
)
}Editor Utilities
Helper utilities for working with the editor:
import { TextbitEditor, TextbitElement, TextbitPlugin } from '@dotvoid/textbit'
// Check if element is a certain type
if (TextbitElement.isOfType(element, 'core/text')) {
console.log('This is a core text element')
}
// Get plugin by type
const plugin = TextbitPlugin.get(plugins, 'core/bold')
TypeScript
Textbit is written in TypeScript and provides comprehensive type definitions.
Exported Types
import type {
// Element and editor types
TBElement, // Textbit element
TBText, // Text node
TBEditor, // Extended Slate editor
TBRange, // Range type
// Plugin types
TBPluginDefinition, // Plugin definition
TBPluginInitFunction, // Plugin initialization function
TBComponentProps, // Component props
TBAction, // Action definition
TBPluginOptions, // Plugin options
TBPluginRegistryAction, // Registry action
// Resource and component types
TBResource, // Resource definition
TBComponentEntry, // Component entry
TBComponent, // Component type
TBToolComponent, // Tool component
TBToolComponentProps, // Tool component props
// Other types
TBSpellingError, // Spelling error structure
TBConsumeFunction, // Consume function type
TBConsumesFunction // Consumes function type
} from '@dotvoid/textbit'Re-exported Slate Types
Textbit re-exports Slate types with the correct type augmentation:
import {
// Utilities
Editor, // Slate Editor utilities
Element, // Slate Element utilities
Text, // Slate Text utilities
Transforms, // Slate transform operations
Node, // Slate Node utilities
Range // Slate Range utilities
} from '@dotvoid/textbit'
import type {
// Base types
Descendant, // Slate content node
Ancestor, // Slate ancestor node
BaseEditor, // Base editor type
BaseElement, // Base element type
BaseText, // Base text type
BaseRange // Base range type
} from '@dotvoid/textbit'Type Augmentation
Textbit uses TypeScript declaration merging to extend Slate's types. When you import from @dotvoid/textbit, you get the augmented types automatically:
import { Editor, Element } from '@dotvoid/textbit'
// These now use Textbit's augmented types
const editor: Editor // Actually TBEditor
const element: Element // Actually TBElementCustom Plugin Types
When creating plugins, use the type helpers:
import type {
TBPluginInitFunction,
TBComponentProps,
TBElement
} from '@dotvoid/textbit'
// Plugin initialization
const MyPlugin: TBPluginInitFunction = (options) => {
return {
// Plugin definition
}
}
// Component
function MyComponent({ element, children }: TBComponentProps) {
// Component implementation
}
// Type guard
function isMyPlugin(element: TBElement): element is TBElement & {
type: 'namespace/my-plugin'
} {
return element.type === 'namespace/my-plugin'
}Block Navigation
Textbit handles keyboard navigation in and around non-text blocks (class: 'block' / class: 'void') using a virtual block-level caret. The real Slate selection stays inside the block while a visual indicator appears beside it, showing whether the caret is logically before or after the block.
Horizontal (Left/Right)
Arrowing into a non-text block from a neighbouring text block activates the indicator on the near side (before when arriving from the left, after from the right). Continuing in the same direction either enters the block's editable content (non-void) or advances the indicator to the far side (void), then exits into the next block. The same steps apply in reverse.
Vertical (Up/Down)
Up/Down moves the indicator to the neighbouring top-level block, preserving the current indicator direction (before stays left, after stays right). If the neighbour is a text block the indicator is cleared and normal text editing resumes.
Editing Keys
| Key | Behavior | |-----|----------| | Enter | Inserts a new empty text block adjacent to the indicator | | Backspace | Removes the block or character behind the caret position | | Delete | Removes the block ahead of the caret position | | Printable character | Inserts a new text block and types the character into it |
Block Selection (Shift+Arrow)
While the block caret is active, holding Shift and pressing any arrow key initiates block-level selection. Entire top-level blocks are selected rather than individual text ranges.
Entry from Block Caret
| Caret position | Key | Selects | |----------------|-----|---------| | Before block X | Shift+Up/Left | Block above (X−1) | | Before block X | Shift+Down/Right | Current block (X) | | After block X | Shift+Up/Left | Current block (X) | | After block X | Shift+Down/Right | Block below (X+1) |
While Block Selection Is Active
| Key | Behavior | |-----|----------| | Shift+Arrow | Extends or contracts the selection by one block | | Arrow (no shift) | Collapses selection and places cursor at the edge of the first/last selected block | | Backspace / Delete | Removes all selected blocks | | Cmd/Ctrl+C | Copies selected blocks | | Cmd/Ctrl+X | Cuts selected blocks | | Mouse click | Clears block selection |
Selected blocks receive a data-block-selected attribute and a .tb-block-selected overlay element for styling. Text content within the selection also shows the native browser highlight. The useBlockSelection() hook exposes the current selection state (anchorIndex / focusIndex) to consumer components.
Element Structure
Textbit elements are based on Slate elements with additional conventions:
Text Element
{
type: 'core/text',
id: '538345e5-bacc-48f9-8ef1-a219891b60eb',
class: 'text',
properties: {
role: 'heading-1' // Optional sub-type
// Additional properties can be defined
},
children: [
{ text: 'Better music?' }
]
}Formatted Text
{
type: 'core/text',
id: '538345e5-bacc-48f9-8ef0-1219891b60ef',
class: 'text',
children: [
{ text: 'An example paragraph with ' },
{
text: 'stronger',
'core/bold': true,
'core/italic': true
},
{ text: ' text.' }
]
}Block Element (Image Example)
{
id: '538345e5-bacc-48f9-8ef0-1219891b60ef',
class: 'block',
type: 'core/image',
properties: {
src: 'https://example.com/image.png',
alt: 'Description',
width: 1024,
height: 768
},
children: [
{
type: 'core/image/caption',
class: 'text',
children: [{ text: 'An image of people taken 2001' }]
}
]
}Complete Example
See ./src for several complete examples.
License
MIT
