# Prompt Area — Full Documentation > A production-grade contentEditable rich text input distributed as a shadcn registry component. Built with React and TypeScript. Zero extra dependencies beyond React + shadcn/tailwind. ## Website - [Homepage](https://prompt-area.com): Interactive demo, feature showcase, live inspector, examples, and comparison matrix - [About](https://prompt-area.com/about): Project information, team, and open source details - [Contact](https://prompt-area.com/contact): Bug reporting, feature requests, and business inquiries - [Press](https://prompt-area.com/press): Media resources, project overview, and press contact - [Partners](https://prompt-area.com/partners): Integration partnerships and technology partner opportunities - [GitHub](https://github.com/team-gpt/prompt-area): Source code, issue tracker, and contribution guidelines ## Installation ```bash npx shadcn@latest add https://prompt-area.com/r/prompt-area.json ``` ### Install with AI Coding Agents Give your AI coding agent (Claude Code, Codex, Cursor, etc.) this prompt: "Fetch https://prompt-area.com/llms-full.txt and read the full documentation. Install the prompt-area component by running: npx shadcn@latest add https://prompt-area.com/r/prompt-area.json — then add the required CSS classes from the documentation to globals.css and help me build a prompt input. If there are any existing chat or prompt textarea inputs in the project, replace them with PromptArea using the context from the documentation." ### Required CSS Add the following CSS classes to your `globals.css` after `@layer base`: ```css @layer components { .prompt-area-chip { display: inline-flex; align-items: center; padding: 1px 6px; border-radius: 4px; font-size: 0.875rem; font-weight: 500; cursor: pointer; user-select: none; vertical-align: baseline; margin: 0 1px; background-color: var(--secondary); color: var(--foreground); } .prompt-area-md-marker { font-size: 0; display: inline; } .prompt-area-chip--inline { padding: 0; border-radius: 0; margin: 0; font-weight: 700; } } ``` ## Quick Start ```tsx 'use client' import { useState } from 'react' import { PromptArea } from '@/registry/new-york/blocks/prompt-area/prompt-area' import type { Segment } from '@/registry/new-york/blocks/prompt-area/types' const USERS = [ { value: 'copywriter', label: 'Copywriter', description: 'Ad copy & content' }, { value: 'strategist', label: 'Strategist', description: 'Campaign planning' }, { value: 'analyst', label: 'Analyst', description: 'Performance insights' }, ] function MentionsExample() { const [segments, setSegments] = useState([]) return ( USERS.filter((u) => u.label.toLowerCase().includes(q.toLowerCase())), }, ]} placeholder="Type @ to mention an agent..." minHeight={48} /> ) } ``` ## Component API ### PromptAreaProps | Prop | Type | Default | Description | | -------------- | ------------------------------- | -------------- | ------------------------------------------- | | `value` | `Segment[]` | required | Controlled segment array | | `onChange` | `(segments: Segment[]) => void` | required | Called on content changes | | `triggers` | `TriggerConfig[]` | `[]` | Trigger character configurations | | `placeholder` | `string \| string[]` | - | Placeholder text when empty. Pass an array to animate between them. | | `className` | `string` | - | CSS class for the container | | `disabled` | `boolean` | `false` | Disable the input | | `markdown` | `boolean` | - | Enable inline markdown rendering | | `onSubmit` | `(segments: Segment[]) => void` | - | Called on Enter (without Shift) | | `onEscape` | `() => void` | - | Called on Escape | | `onChipClick` | `(chip: ChipSegment) => void` | - | Called when a chip is clicked | | `onChipAdd` | `(chip: ChipSegment) => void` | - | Called when a chip is added | | `onChipDelete` | `(chip: ChipSegment) => void` | - | Called when a chip is deleted | | `onLinkClick` | `(url: string) => void` | - | Called on Cmd/Ctrl+Click on a URL | | `onPaste` | `(data: { segments: Segment[]; source: 'internal' \| 'external' }) => void` | - | Called after paste with segments and source | | `onUndo` | `(segments: Segment[]) => void` | - | Called after undo | | `onRedo` | `(segments: Segment[]) => void` | - | Called after redo | | `minHeight` | `number` | `80` | Minimum height in pixels | | `maxHeight` | `number` | - | Maximum height in pixels | | `autoFocus` | `boolean` | `false` | Auto-focus on mount | | `autoGrow` | `boolean` | `false` | Expand on focus, shrink on blur | | `aria-label` | `string` | `'Text input'` | Accessible label | | `data-test-id` | `string` | - | Test ID for e2e testing | | `images` | `PromptAreaImage[]` | - | Array of image attachments to display | | `imagePosition`| `'above' \| 'below'` | `'above'` | Where to render images relative to text | | `onImagePaste` | `(file: File) => void` | - | Called when user pastes an image | | `onImageRemove`| `(image: PromptAreaImage) => void` | - | Called when user removes an image | | `onImageClick` | `(image: PromptAreaImage) => void` | - | Called when user clicks an image | | `files` | `PromptAreaFile[]` | - | Array of file attachments to display | | `filePosition` | `'above' \| 'below'` | `'above'` | Where to render files relative to text | | `onFileRemove` | `(file: PromptAreaFile) => void` | - | Called when user removes a file | | `onFileClick` | `(file: PromptAreaFile) => void` | - | Called when user clicks a file | ### PromptAreaHandle (ref methods) | Method | Description | | ------------------ | ---------------------------------- | | `focus()` | Focus the editor | | `blur()` | Blur the editor | | `insertChip(chip)` | Insert a chip at cursor position | | `getPlainText()` | Get plain text content | | `clear()` | Clear all content and undo history | ### TriggerConfig | Field | Type | Description | | -------------------- | ----------------------------------------------------------------- | --------------------------------------------- | | `char` | `string` | Trigger character (e.g., `'@'`, `'/'`, `'#'`) | | `position` | `'start' \| 'any'` | Where the trigger is valid | | `mode` | `'dropdown' \| 'callback'` | Show dropdown or fire callback | | `onSearch` | `(query: string, options: { signal: AbortSignal }) => TriggerSuggestion[] \| Promise` | Fetch suggestions (dropdown mode) | | `onSelect` | `(suggestion: TriggerSuggestion) => string \| void` | Customize chip display text | | `onActivate` | `(context: TriggerActivateContext) => void` | Handler for callback mode | | `resolveOnSpace` | `boolean` | Auto-resolve on space (e.g., `#tag`) | | `chipStyle` | `'pill' \| 'inline'` | Visual style for chips | | `chipClassName` | `string` | CSS class for chips | | `accessibilityLabel` | `string` | ARIA label for the trigger | | `searchDebounceMs` | `number` | Debounce delay before calling onSearch (default: 0) | | `onSearchError` | `(error: unknown) => void` | Called when onSearch throws (non-abort errors) | | `emptyMessage` | `string` | Message shown when onSearch returns empty array | ### Trigger Position Rules - `'start'`: only valid at the start of text or immediately after a newline (`\n`). Prevents false positives in the middle of lines. - `'any'`: valid after any whitespace (space, newline, tab) or at the start of text. Prevents false positives like email addresses (user@example.com). ### Segment Types ```ts type Segment = TextSegment | ChipSegment type TextSegment = { type: 'text'; text: string } type ChipSegment = { type: 'chip' trigger: string // e.g., '@' value: string // e.g., 'user-123' displayText: string // e.g., 'Alice' data?: unknown // arbitrary payload autoResolved?: boolean // true when created by resolveOnSpace } ``` ### TriggerSuggestion ```ts type TriggerSuggestion = { value: string // Unique value/ID label: string // Display label in dropdown description?: string // Optional description below label icon?: React.ReactNode // Optional icon before label data?: unknown // Arbitrary data passed through on selection } ``` ### TriggerActivateContext ```ts type TriggerActivateContext = { text: string // Full plain text at activation cursorPosition: number // Cursor offset position insertChip: (chip: Omit) => void // Insert chip at cursor } ``` ### ActiveTrigger ```ts type ActiveTrigger = { config: TriggerConfig // The matched trigger config startOffset: number // Position where trigger char was typed query: string // Text typed after trigger char } ``` ### Image & File Attachment Types ```ts type PromptAreaImage = { id: string // Unique identifier url: string // Display URL (CDN or blob URL) alt?: string // Alt text for accessibility loading?: boolean // Show loading indicator } type PromptAreaFile = { id: string // Unique identifier name: string // Display filename (e.g., "report.pdf") size?: number // File size in bytes type?: string // MIME type (e.g., "application/pdf") loading?: boolean // Show loading indicator } ``` ## Keyboard Shortcuts | Shortcut | Action | | ------------------- | ---------------------------------------- | | `Enter` | Submit (or continue list if in list) | | `Shift+Enter` | Insert newline | | `Escape` | Dismiss dropdown / fire onEscape | | `Cmd/Ctrl+B` | Toggle **bold** (requires active selection) | | `Cmd/Ctrl+I` | Toggle *italic* (requires active selection) | | `Cmd/Ctrl+Z` | Undo | | `Cmd/Ctrl+Shift+Z` | Redo | | `Tab` | Indent list item (markdown mode only) | | `Shift+Tab` | Outdent list item (markdown mode only) | | `ArrowUp/Down` | Navigate dropdown suggestions | | `Backspace` on chip | Delete chip (or revert if auto-resolved) | | `Space` | Auto-resolve trigger (if `resolveOnSpace: true` and query non-empty) | ## Trigger Presets Pre-built trigger configuration factories for common patterns. Import from `trigger-presets` (installed with prompt-area). ```tsx import { PromptArea } from '@/registry/new-york/blocks/prompt-area/prompt-area' import { usePromptAreaState } from '@/registry/new-york/blocks/prompt-area/use-prompt-area-state' import { mentionTrigger, commandTrigger, hashtagTrigger } from '@/registry/new-york/blocks/prompt-area/trigger-presets' import { getChipsByTrigger } from '@/registry/new-york/blocks/prompt-area/segment-helpers' function ChatInput() { const { bind, plainText, isEmpty, chips, clear, focus } = usePromptAreaState() const mentions = getChipsByTrigger(bind.value, '@') return ( <> { sendMessage(plainText, mentions) clear() }} /> ) } ``` ### mentionTrigger(opts?) Creates a **mention** trigger (`@`). Defaults: `position: 'any'`, `mode: 'dropdown'`, `chipStyle: 'pill'`, `accessibilityLabel: 'mention'`. ### commandTrigger(opts?) Creates a **command** trigger (`/`). Defaults: `position: 'start'`, `mode: 'dropdown'`, `chipStyle: 'inline'`, `accessibilityLabel: 'command'`. ### hashtagTrigger(opts?) Creates a **hashtag** trigger (`#`). Defaults: `position: 'any'`, `mode: 'dropdown'`, `chipStyle: 'pill'`, `resolveOnSpace: true`, `accessibilityLabel: 'tag'`. ### callbackTrigger(opts) Creates a **callback** trigger that fires `onActivate` instead of showing a dropdown. Requires `char`. Defaults: `position: 'start'`, `mode: 'callback'`. Useful for opening file pickers, model selectors, etc. All presets accept any additional `TriggerConfig` fields as overrides, including a custom `char` to change the trigger character. ## Segment Helpers Convenience helpers for creating and inspecting segments. Import from `segment-helpers` (installed with prompt-area). ```ts import { text, chip, isSegmentsEmpty, hasChips, getChips, getChipsByTrigger, segmentsToPlainText, plainTextToSegments, } from '@/registry/new-york/blocks/prompt-area/segment-helpers' ``` ### Factories | Function | Signature | Description | | --------- | -------------------------------------------- | ---------------------- | | `text` | `(value: string) => TextSegment` | Create a text segment | | `chip` | `(opts: Omit) => ChipSegment` | Create a chip segment | ### Predicates & Queries | Function | Signature | Description | | ------------------- | -------------------------------------------------------- | -------------------------------------- | | `isSegmentsEmpty` | `(segments: Segment[]) => boolean` | True when empty or whitespace-only | | `hasChips` | `(segments: Segment[]) => boolean` | True when at least one chip exists | | `getChips` | `(segments: Segment[]) => ChipSegment[]` | Extract all chip segments | | `getChipsByTrigger` | `(segments: Segment[], trigger: string) => ChipSegment[]`| Filter chips by trigger character | ### Serialization | Function | Signature | Description | | --------------------- | ------------------------------------ | ----------------------------------------------- | | `segmentsToPlainText` | `(segments: Segment[]) => string` | Converts segments to plain text. Chips render as `{trigger}{displayText}` (e.g., `@Alice`) | | `plainTextToSegments` | `(text: string) => Segment[]` | Converts plain text to a single text segment | ### Example ```ts const greeting = [text('Hello '), chip({ trigger: '@', value: 'u1', displayText: 'Alice' })] isSegmentsEmpty(greeting) // false segmentsToPlainText(greeting) // "Hello @Alice" getChipsByTrigger(greeting, '@') // [{ type: 'chip', trigger: '@', value: 'u1', displayText: 'Alice' }] ``` ## usePromptAreaState Hook A convenience hook that wires up all boilerplate state for PromptArea. Manages `useState`, `useRef`, and derived values. ```tsx import { PromptArea } from '@/registry/new-york/blocks/prompt-area/prompt-area' import { usePromptAreaState } from '@/registry/new-york/blocks/prompt-area/use-prompt-area-state' import { mentionTrigger, commandTrigger, hashtagTrigger } from '@/registry/new-york/blocks/prompt-area/trigger-presets' import { getChipsByTrigger } from '@/registry/new-york/blocks/prompt-area/segment-helpers' function ChatInput() { const { bind, plainText, isEmpty, chips, clear, focus } = usePromptAreaState() const mentions = getChipsByTrigger(bind.value, '@') return ( <> { sendMessage(plainText, mentions) clear() }} /> ) } ``` ### Options | Field | Type | Default | Description | | -------------- | ----------- | ------- | ------------------------ | | `initialValue` | `Segment[]` | `[]` | Initial segment value | ### Return Type: PromptAreaState | Field | Type | Description | | ----------- | ------------------------------------------- | --------------------------------------------------------- | | `bind` | `{ ref, value, onChange }` | Props to spread onto `` | | `plainText` | `string` | Derived plain text (memoized) | | `isEmpty` | `boolean` | True when empty or whitespace-only (memoized) | | `hasChips` | `boolean` | True when at least one chip exists (memoized) | | `chips` | `ChipSegment[]` | All chip segments (memoized) | | `clear()` | `() => void` | Clear all content (proxies to imperative handle) | | `focus()` | `() => void` | Focus the editor | | `blur()` | `() => void` | Blur the editor | | `insertChip`| `(chip: Omit) => void` | Insert a chip at the current cursor position | ## Chip Customization Style chips per-trigger using `chipClassName` and `chipStyle`: ```tsx const COMMANDS = [ { value: 'deep-research', label: 'deep-research', description: 'Research a topic in depth' }, { value: 'summarize', label: 'summarize', description: 'Summarize the conversation' }, { value: 'create-slides', label: 'create-slides', description: 'Generate a slide deck' }, ] const USERS = [ { value: 'copywriter', label: 'Copywriter', description: 'Ad copy & content' }, { value: 'strategist', label: 'Strategist', description: 'Campaign planning' }, { value: 'analyst', label: 'Analyst', description: 'Performance insights' }, ] const triggers: TriggerConfig[] = [ { char: '/', position: 'start', mode: 'dropdown', chipStyle: 'inline', // Bold inline text, no pill chipClassName: 'text-violet-700 dark:text-violet-400', onSearch: (q) => COMMANDS.filter((c) => c.label.toLowerCase().includes(q.toLowerCase())), }, { char: '@', position: 'any', mode: 'dropdown', chipClassName: 'bg-blue-100 text-blue-700 dark:bg-blue-900 dark:text-blue-300', onSearch: (q) => USERS.filter((u) => u.label.toLowerCase().includes(q.toLowerCase())), }, ] ``` ### Chip Rendering Details Chips render as `` with these data attributes: - `data-chip-trigger` — the trigger character - `data-chip-value` — the resolved value/ID - `data-chip-display` — the display text - `data-chip-data` — JSON-serialized arbitrary payload - `data-chip-auto-resolved` — `"true"` when auto-created via `resolveOnSpace` - `data-chip-style` — `"pill"` or `"inline"` **chipStyle: 'pill'** (default): Background color, padding, border-radius. Looks like a button/pill. **chipStyle: 'inline'**: No padding, no border-radius, no margin, `font-weight: 700`. Flows naturally with surrounding text as bold text. Chip data is preserved through copy/paste via a custom clipboard format (`text/prompt-area-segments`), allowing the `data` payload to survive internal paste operations. ## Companion Components ### Action Bar A horizontal toolbar with left and right slots that pairs with PromptArea for a complete chat input. ```bash npx shadcn@latest add https://prompt-area.com/r/action-bar.json ``` #### ActionBar Props | Prop | Type | Description | | ------- | ----------------- | ---------------------- | | `left` | `React.ReactNode` | Content for left slot | | `right` | `React.ReactNode` | Content for right slot | ### Status Bar A horizontal info bar with left and right slots, designed to sit above or below PromptArea. ```bash npx shadcn@latest add https://prompt-area.com/r/status-bar.json ``` #### StatusBar Props | Prop | Type | Default | Description | | -------------- | ----------------- | ---------------- | ----------------------------------------------- | | `left` | `React.ReactNode` | - | Content for left slot | | `right` | `React.ReactNode` | - | Content for right slot | | `className` | `string` | - | CSS class for the root element | | `disabled` | `boolean` | `false` | Visually dims and disables pointer events | | `aria-label` | `string` | `'Status bar'` | Accessible label | | `data-test-id` | `string` | - | Test ID for e2e testing | #### StatusBar Example ```tsx import { useState } from 'react' import { GitBranch, ChevronDown } from 'lucide-react' import { PromptArea } from '@/registry/new-york/blocks/prompt-area/prompt-area' import { StatusBar } from '@/registry/new-york/blocks/status-bar/status-bar' import type { Segment } from '@/registry/new-york/blocks/prompt-area/types' function StatusBarAboveExample() { const [segments, setSegments] = useState([]) return (
prompt-area main
} right={ } />
setSegments([])} minHeight={48} />
) } ``` ### Compact Prompt Area A pill-shaped prompt input that sits on a single row and expands downward on focus. Wraps PromptArea internally with `autoGrow` enabled. ```bash npx shadcn@latest add https://prompt-area.com/r/compact-prompt-area.json ``` #### CompactPromptArea Props Accepts all core PromptArea props (`value`, `onChange`, `triggers`, `placeholder`, `disabled`, `markdown`, `onSubmit`, `onEscape`, `onChipClick`, `onChipAdd`, `onChipDelete`, `onPaste`, `images`, `onImagePaste`, `onImageRemove`, `files`, `onFileRemove`), plus: | Prop | Type | Default | Description | | ------------------ | ----------------- | ------------ | --------------------------------------------------- | | `plusButtonIcon` | `React.ReactNode` | `` | Icon for the circular plus button | | `onPlusClick` | `() => void` | - | Click handler for the plus button | | `submitButtonIcon` | `React.ReactNode` | ``| Icon for the circular submit button | | `beforeSubmitSlot` | `React.ReactNode` | - | Slot rendered before the submit button (e.g., mic) | | `maxHeight` | `number` | `320` | Maximum height the expanded area can reach | | `className` | `string` | - | CSS class for the outer container | | `aria-label` | `string` | - | Accessible label | | `data-test-id` | `string` | - | Test ID for e2e testing | #### CompactPromptArea Behavior - **Collapsed**: pill-shaped (`rounded-full`) with the plus button on the left and submit on the right - **Expanded** (focused or has content): rounded rectangle (`rounded-2xl`) with the plus button at bottom-left and submit at bottom-right - Submit button is disabled when content is empty or `disabled={true}` - Internally uses `autoGrow` with `minHeight: 48` (expanded) or `24` (collapsed) #### CompactPromptArea Example ```tsx import { useCallback, useRef, useState } from 'react' import { Mic } from 'lucide-react' import { CompactPromptArea } from '@/registry/new-york/blocks/compact-prompt-area/compact-prompt-area' import type { Segment, TriggerConfig, PromptAreaHandle } from '@/registry/new-york/blocks/prompt-area/types' const triggers: TriggerConfig[] = [ { char: '@', position: 'any', mode: 'dropdown', onSearch: (q) => USERS.filter(...) }, { char: '/', position: 'start', mode: 'dropdown', chipStyle: 'inline', onSearch: (q) => COMMANDS.filter(...) }, ] function CompactPromptAreaExample() { const [segments, setSegments] = useState([]) const promptRef = useRef(null) const handleSubmit = useCallback((segs: Segment[]) => { promptRef.current?.clear() setSegments([]) }, []) return ( console.log('Plus clicked')} beforeSubmitSlot={ } /> ) } ``` ### Chat Prompt Layout A full-height chat layout with scrollable messages and a bottom-anchored prompt slot. Includes contextual scroll navigation buttons. ```bash npx shadcn@latest add https://prompt-area.com/r/chat-prompt-layout.json ``` **Note**: This component uses `framer-motion` for scroll button animations. #### ChatPromptLayout Props | Prop | Type | Default | Description | | -------------- | ----------------- | ----------------- | ------------------------------------------- | | `children` | `React.ReactNode` | required | Chat messages in the scrollable area | | `prompt` | `React.ReactNode` | required | Prompt area at the bottom (slot) | | `className` | `string` | - | CSS class for the root container | | `aria-label` | `string` | `'Chat layout'` | Accessible label for the layout region | | `data-test-id` | `string` | - | Test ID for e2e testing | #### Scroll Navigation Scroll buttons appear/hide with hysteresis to prevent flicker: - **"Go to top"** appears when scrolled > 300px from top, hides when < 100px from top - **"Go to bottom"** appears when scrolled > 300px from bottom, hides when < 100px from bottom - Buttons use `smooth` scrolling and `framer-motion` enter/exit animations #### ChatPromptLayout Example ```tsx import { useCallback, useRef, useState } from 'react' import { ArrowUp } from 'lucide-react' import { PromptArea } from '@/registry/new-york/blocks/prompt-area/prompt-area' import { ActionBar } from '@/registry/new-york/blocks/action-bar/action-bar' import { ChatPromptLayout } from '@/registry/new-york/blocks/chat-prompt-layout/chat-prompt-layout' import { segmentsToPlainText } from '@/registry/new-york/blocks/prompt-area/prompt-area-engine' import type { Segment, PromptAreaHandle } from '@/registry/new-york/blocks/prompt-area/types' type Message = { id: number; role: 'user' | 'assistant'; content: string } function ChatPromptLayoutExample() { const [segments, setSegments] = useState([]) const [messages, setMessages] = useState([]) const promptRef = useRef(null) const isEmpty = segments.length === 0 || (segments.length === 1 && segments[0].type === 'text' && segments[0].text === '') const handleSubmit = useCallback(() => { if (isEmpty) return const text = segmentsToPlainText(segments) setMessages(prev => [...prev, { id: Date.now(), role: 'user', content: text }]) promptRef.current?.clear() setSegments([]) }, [isEmpty, segments]) return (
} />
} >
{messages.map(msg => (
{msg.content}
))}
) } ``` ## Rotating Placeholder When `placeholder` is passed as a `string[]`, the placeholder animates between items: - Rotates through the array on a 3-second interval - Uses `framer-motion` for slide-up/slide-down transitions (300ms, easeInOut) - Hidden from screen readers (`aria-hidden="true"`) - Automatically stops cycling when only one string is provided ```tsx import { useState } from 'react' import { PromptArea } from '@/registry/new-york/blocks/prompt-area/prompt-area' import type { Segment } from '@/registry/new-york/blocks/prompt-area/types' function RotatingPlaceholdersExample() { const [segments, setSegments] = useState([]) return ( { setSegments([]) }} minHeight={48} /> ) } ``` ## Engine API (prompt-area-engine) Pure logic functions with no DOM dependencies. Fully testable in Node. ### Trigger Detection ```ts detectActiveTrigger(text: string, cursorPos: number, triggers: TriggerConfig[]): ActiveTrigger | null ``` Scans backwards from cursor to find if the user is currently typing a trigger word. ```ts isValidTriggerPosition(text: string, charIndex: number, position: TriggerPosition): boolean ``` Checks whether a trigger character at a given position satisfies the position rule. ### Chip Operations ```ts resolveChip(segments, activeTrigger, chip): { segments: Segment[]; cursorOffset: number } ``` Replaces trigger text with a chip segment. Adds a trailing space after the chip. Returns updated segments and new cursor position. ```ts removeChipAtIndex(segments, index): Segment[] ``` Removes a chip at the given segment index and merges adjacent text segments. ```ts revertChipAtIndex(segments, index): { segments: Segment[]; revertedText: string } | null ``` Reverts an auto-resolved chip back to plain text (e.g., `#readme` → text `#readme`). Returns null if the chip is not auto-resolved. ### Paste Resolution ```ts resolveTriggersInSegments(segments, triggers): Segment[] ``` Scans text segments for trigger patterns and auto-resolves them into chips. Only resolves triggers with `resolveOnSpace: true`. Respects word boundaries to avoid false positives. ### Text Manipulation ```ts replaceTextRange(segments, start, end, replacement): Segment[] ``` Replaces a range of plain text within segments. Handles segment boundaries correctly, preserving chip segments. ### Markdown Formatting ```ts toggleMarkdownWrap(segments, selectionStart, selectionEnd, marker): { segments; selectionStart; selectionEnd } | null ``` Toggles markdown wrap markers (`**` for bold, `*` for italic) around a selection. Returns null if selection is collapsed. Handles unwrapping if already wrapped. Correctly handles nested markers (won't match `*` inside `**`). ### List Auto-Formatting ```ts type ListContext = { lineStart: number // Offset where line begins prefix: string // Full prefix with indentation (e.g., " • ") indent: number // Indentation level (each = 2 spaces) listType: 'bullet' | 'numbered' number?: number // For numbered lists contentStart: number // Offset where content after prefix starts } getListContext(text, cursorPos): ListContext | null ``` Detects if the cursor is in a list line. Recognizes bullet (`•`, `-`, `*`) and numbered (`1.`) prefixes. ```ts autoFormatListPrefix(segments, cursorPos): { segments; cursorOffset } | null ``` Converts `- ` or `* ` to `• ` (bullet character) when typed at line start. ```ts insertListContinuation(segments, cursorPos): { segments; cursorOffset } | null ``` Enter key in a list: continues with next bullet/number, or exits list if line is empty. ```ts indentListItem(segments, cursorPos): { segments; cursorOffset } | null ``` Tab key: adds 2 spaces of indentation before the list prefix. ```ts outdentListItem(segments, cursorPos): { segments; cursorOffset } | null ``` Shift+Tab: removes 2 spaces of indentation. Returns null if already at indent level 0. ```ts removeListPrefix(segments, cursorPos): { segments; cursorOffset } | null ``` Backspace at or before content start: removes the list prefix. ### Normalization ```ts normalizeListPrefixes(segments, markdownEnabled): Segment[] ``` When markdown is enabled: converts `- ` to `• ` at line starts. When disabled: converts `• ` back to `- `. Returns the original array unchanged if no conversions needed. ```ts segmentsEqual(a: Segment[], b: Segment[]): boolean ``` Shallow equality check for two segment arrays. Avoids JSON.stringify overhead. ### Inline Markdown Parsing ```ts type MarkdownToken = | { type: 'plain'; text: string } | { type: 'bold'; text: string } | { type: 'italic'; text: string } | { type: 'bold-italic'; text: string } | { type: 'url'; text: string } parseInlineMarkdown(text: string): MarkdownToken[] ``` Parses text for `***bold-italic***`, `**bold**`, `*italic*`, and `https://` URLs. Does NOT handle block-level markdown. ## Markdown Mode Details When `markdown={true}`: - **Inline decoration**: `**bold**`, `*italic*`, `***bold-italic***` rendered with styled spans. Markers remain in the text wrapped in `prompt-area-md-marker` spans (font-size: 0, visually hidden but present in content). - **URL detection**: `https://` and `http://` URLs auto-linked, Cmd/Ctrl+Click to open. - **List auto-formatting**: `- ` and `* ` at line starts convert to `• `. Enter continues/exits lists. Tab/Shift+Tab indent/outdent. - **Markdown shortcuts**: Cmd+B wraps selection in `**`, Cmd+I wraps in `*`. Both toggle (unwrap if already wrapped). Require an active text selection. - **Bullet normalization**: When toggling markdown mode, existing `•` ↔ `-` characters convert automatically. **Limitations**: Only inline formatting. No headings, code blocks, images, blockquotes, or tables. `getPlainText()` returns text including markdown markers. ## Copy, Paste & Clipboard ### Copy Flow 1. Clone the selected range to a document fragment 2. Serialize fragment to plain text for the `text/plain` clipboard entry 3. If the fragment contains chips, also serialize as JSON in `text/prompt-area-segments` custom format 4. Internal paste uses the JSON format to preserve chip data (value, displayText, arbitrary payload) ### Paste Flow 1. Check for image files first (via `files` or `items` on the clipboard event) → calls `onImagePaste` 2. Check for internal segment JSON (`text/prompt-area-segments`) → restore chips with full data 3. Fall back to plain text (`text/plain`) 4. Multi-line paste: splits by `\n`, inserts BR elements between lines 5. Auto-resolves trigger patterns in pasted text (if `resolveOnSpace: true` on matching triggers) 6. Notifies with `onPaste({ segments, source: 'internal' | 'external' })` 7. Calls `onChipAdd` for each auto-resolved chip ## Undo/Redo - Undo/redo stacks store previous segment arrays - `MAX_UNDO_HISTORY = 100` entries per stack to prevent memory bloat - Debounced grouping: consecutive keystrokes within `300ms` are grouped into a single snapshot - On Ctrl+Z: pops from undo stack, pushes current state to redo stack, calls `onUndo` - On Ctrl+Shift+Z: pops from redo stack, pushes current state to undo stack, calls `onRedo` - Redo stack is cleared on any new edit (standard UX) - Pending debounce is flushed before Ctrl+Z so the latest checkpoint is used ## IME Composition (CJK Input) - Tracked via an `isComposing` ref - During IME composition: the segment model syncs but trigger detection is deferred - Trigger detection resumes on `compositionend` event - Prevents premature dropdown appearance while typing Chinese, Japanese, or Korean characters ## Auto-Grow Behavior When `autoGrow={true}`: - Measures `scrollHeight` and applies it as inline `height` - Transitions smoothly with `150ms ease-out` - Max height capped at `70dvh` (or the `maxHeight` prop if smaller) - Re-measures on: focus, input, value changes, markdown mode changes - Shrinking on blur is delayed by `150ms` so popover clicks register - Overflow gradient indicator appears when collapsed content is clipped ## File Strip Details The file attachment strip collapses when more than 3 files are attached: - Shows the first 3 files inline - Remaining files accessible via a "+N more" popover - File icons are selected by MIME type (PDF, spreadsheet, code, image, default) - File sizes formatted as B, KB, MB, GB ## Trigger Popover The dropdown suggestion popover: - Positioned below the trigger character, clamped to viewport edges - Max width: 320px, max height: 240px with overflow scroll - Shows loading state ("Loading suggestions...") while `onSearch` is pending - Shows error message if `onSearch` rejects - Shows `emptyMessage` when results are empty (or hides if not set) - Click outside dismisses the popover - Selected item auto-scrolls into view - Uses `role="listbox"` and `role="option"` with `aria-selected` for accessibility - `onMouseDown` with `preventDefault()` prevents editor blur when clicking suggestions ## Async Search (useTriggerSearch) The trigger search system handles async data fetching with: - **Debouncing**: configurable via `searchDebounceMs` on each trigger - **Immediate first search**: the initial empty-query search always fires immediately (no debounce) so the dropdown appears instantly - **AbortController**: each new search aborts the previous in-flight request. The `signal` is passed to `onSearch` so consumers can pass it to `fetch()` or other async APIs - **Race-condition prevention**: version counter ensures stale responses are discarded - **Error handling**: `AbortError` is silently ignored; other errors set error state and call `onSearchError` ## DOM Normalization On every input event, the editor DOM is normalized: - Browser-inserted wrapper elements (DIV, P, SPAN, B, I, U, STRONG, EM, A, FONT) are unwrapped - Only preserved elements: text nodes, BR elements, chip elements - Adjacent text nodes are merged - A sentinel BR element (with `data-sentinel="true"`) is appended when needed to preserve trailing newlines in contentEditable ## Registry Components | Component | Install Command | Dependencies | | -------------------- | --------------------------------------------------------------- | -------------------- | | `prompt-area` | `npx shadcn@latest add https://prompt-area.com/r/prompt-area.json` | React + shadcn/tailwind | | `action-bar` | `npx shadcn@latest add https://prompt-area.com/r/action-bar.json` | React + shadcn/tailwind | | `status-bar` | `npx shadcn@latest add https://prompt-area.com/r/status-bar.json` | React + shadcn/tailwind | | `compact-prompt-area`| `npx shadcn@latest add https://prompt-area.com/r/compact-prompt-area.json` | prompt-area | | `chat-prompt-layout` | `npx shadcn@latest add https://prompt-area.com/r/chat-prompt-layout.json` | framer-motion | ### Exported Modules When you install `prompt-area`, the following modules are available: - `prompt-area.tsx` — Main component - `types.ts` — All TypeScript types - `segment-helpers.ts` — Segment factories and predicates - `trigger-presets.ts` — Pre-built trigger configs - `use-prompt-area-state.ts` — Convenience state hook - `prompt-area-engine.ts` — Pure logic engine (no DOM) - `dom-helpers.ts` — DOM utilities (internal) - `use-prompt-area.ts` — Core editor hook (internal) - `use-prompt-area-events.ts` — Event handlers hook (internal) - `use-trigger-search.ts` — Async search hook (internal) - `trigger-popover.tsx` — Dropdown popover (internal) - `animated-placeholder.tsx` — Animated placeholder (internal) - `image-strip.tsx` — Image attachments strip (internal) - `file-strip.tsx` — File attachments strip (internal) - `remove-button.tsx` — Remove button for attachments (internal) ## Features - **Trigger-based chips**: Type `@`, `/`, `#` (or any character) to activate dropdowns or callbacks - **Immutable chip pills**: Resolved mentions, commands, and tags render as non-editable chips - **Inline markdown**: Live preview of **bold**, *italic*, and ***bold-italic*** - **URL detection**: Auto-links URLs with Cmd/Ctrl+Click to open - **List auto-formatting**: Type `- ` or `* ` to start bullet lists, with Tab/Shift+Tab indentation - **Undo/redo**: Full Ctrl+Z / Ctrl+Shift+Z with debounced snapshots (100-entry history) - **Copy/paste**: Preserves chip data on internal paste, auto-resolves triggers on external paste - **IME support**: Proper composition handling for CJK input - **Auto-grow**: Expands on focus, shrinks on blur with smooth transitions - **Rotating placeholders**: Pass `string[]` to cycle through placeholder texts with smooth slide animations - **Keyboard shortcuts**: Cmd+B bold, Cmd+I italic, Enter submit, Escape dismiss - **Imperative API**: `focus()`, `blur()`, `insertChip()`, `getPlainText()`, `clear()` - **Convenience hook**: `usePromptAreaState()` eliminates boilerplate - **Trigger presets**: `mentionTrigger()`, `commandTrigger()`, `hashtagTrigger()`, `callbackTrigger()` - **Segment helpers**: `text()`, `chip()`, `isSegmentsEmpty()`, `hasChips()`, `getChips()` - **Async search**: Debounced, cancellable trigger search with AbortController support - **File & image attachments**: Paste screenshots or attach files with thumbnails, loading states, and remove buttons - **Companion components**: ActionBar, StatusBar, CompactPromptArea, ChatPromptLayout - **Dark mode**: Full light/dark theme via CSS variables - **Accessible**: ARIA labels, keyboard navigation, screen reader announcements - **Zero extra dependencies**: Only React + your existing shadcn/tailwind setup ## Links - Website: https://prompt-area.com - About: https://prompt-area.com/about - Contact: https://prompt-area.com/contact - Press: https://prompt-area.com/press - Partners: https://prompt-area.com/partners - GitHub: https://github.com/team-gpt/prompt-area - shadcn Registry: https://ui.shadcn.com/docs/registry