@workerdeck/ui 0.6.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/LICENSE +21 -0
- package/README.md +74 -0
- package/build/index.d.mts +927 -0
- package/build/index.mjs +5236 -0
- package/build/index.mjs.map +1 -0
- package/package.json +73 -0
- package/src/components/agent/Composer.tsx +139 -0
- package/src/components/agent/Conversation.tsx +59 -0
- package/src/components/agent/FileCard.tsx +50 -0
- package/src/components/agent/Loader.tsx +21 -0
- package/src/components/agent/Message.tsx +44 -0
- package/src/components/agent/ModelSelect.tsx +87 -0
- package/src/components/agent/PermissionModeSelect.tsx +94 -0
- package/src/components/agent/PermissionPrompt.tsx +52 -0
- package/src/components/agent/QuestionPrompt.tsx +193 -0
- package/src/components/agent/Reasoning.tsx +58 -0
- package/src/components/agent/Response.tsx +31 -0
- package/src/components/agent/SessionList.tsx +93 -0
- package/src/components/agent/SessionPanel.tsx +149 -0
- package/src/components/agent/StatusBar.tsx +140 -0
- package/src/components/agent/ToolCallCard.tsx +94 -0
- package/src/components/agent/Transcript.tsx +114 -0
- package/src/components/agent/status.ts +16 -0
- package/src/components/prompt-area/animated-placeholder.tsx +42 -0
- package/src/components/prompt-area/clipboard-helpers.ts +206 -0
- package/src/components/prompt-area/cursor-helpers.ts +244 -0
- package/src/components/prompt-area/dom-helpers.ts +721 -0
- package/src/components/prompt-area/file-strip.tsx +250 -0
- package/src/components/prompt-area/html-to-markdown.ts +278 -0
- package/src/components/prompt-area/image-strip.tsx +49 -0
- package/src/components/prompt-area/index.ts +23 -0
- package/src/components/prompt-area/prompt-area-engine.ts +705 -0
- package/src/components/prompt-area/prompt-area-list-ops.ts +499 -0
- package/src/components/prompt-area/prompt-area.tsx +375 -0
- package/src/components/prompt-area/remove-button.tsx +37 -0
- package/src/components/prompt-area/segment-helpers.ts +62 -0
- package/src/components/prompt-area/trigger-popover.tsx +139 -0
- package/src/components/prompt-area/trigger-presets.ts +143 -0
- package/src/components/prompt-area/types.ts +360 -0
- package/src/components/prompt-area/use-markdown-mode.ts +113 -0
- package/src/components/prompt-area/use-prompt-area-events.ts +470 -0
- package/src/components/prompt-area/use-prompt-area-state.ts +131 -0
- package/src/components/prompt-area/use-prompt-area.ts +1507 -0
- package/src/components/prompt-area/use-trigger-search.ts +115 -0
- package/src/components/ui/AlertDialog.tsx +56 -0
- package/src/components/ui/Badge.tsx +42 -0
- package/src/components/ui/Button.tsx +47 -0
- package/src/components/ui/Card.tsx +29 -0
- package/src/components/ui/CodeBlock.tsx +31 -0
- package/src/components/ui/CopyButton.tsx +28 -0
- package/src/components/ui/Input.tsx +20 -0
- package/src/components/ui/ProgressRing.tsx +49 -0
- package/src/components/ui/Select.tsx +80 -0
- package/src/components/ui/Sonner.tsx +22 -0
- package/src/components/ui/Spinner.tsx +6 -0
- package/src/components/ui/Textarea.tsx +21 -0
- package/src/components/ui/Tooltip.tsx +34 -0
- package/src/index.ts +99 -0
- package/src/lib/format.ts +67 -0
- package/src/lib/utils.ts +33 -0
- package/src/styles/theme.css +413 -0
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pre-built trigger configuration factories for common AI chat patterns.
|
|
3
|
+
*
|
|
4
|
+
* Each factory returns a full `TriggerConfig` with sensible defaults.
|
|
5
|
+
* Pass only what you need to override.
|
|
6
|
+
*
|
|
7
|
+
* @example
|
|
8
|
+
* ```tsx
|
|
9
|
+
* <PromptArea
|
|
10
|
+
* triggers={[
|
|
11
|
+
* mentionTrigger({ onSearch: searchUsers }),
|
|
12
|
+
* commandTrigger({ onSearch: searchCommands }),
|
|
13
|
+
* hashtagTrigger(),
|
|
14
|
+
* ]}
|
|
15
|
+
* />
|
|
16
|
+
* ```
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import type { TriggerConfig, TriggerPosition } from './types.ts'
|
|
20
|
+
|
|
21
|
+
// ---------------------------------------------------------------------------
|
|
22
|
+
// Shared option type — everything in TriggerConfig except the keys each
|
|
23
|
+
// factory sets by default.
|
|
24
|
+
// ---------------------------------------------------------------------------
|
|
25
|
+
|
|
26
|
+
type TriggerPresetOptions = Omit<Partial<TriggerConfig>, 'char' | 'position' | 'mode'>
|
|
27
|
+
|
|
28
|
+
// ---------------------------------------------------------------------------
|
|
29
|
+
// @mention — dropdown at any position
|
|
30
|
+
// ---------------------------------------------------------------------------
|
|
31
|
+
|
|
32
|
+
export type MentionTriggerOptions = TriggerPresetOptions & {
|
|
33
|
+
/** Override the trigger character. Defaults to `'@'`. */
|
|
34
|
+
char?: string
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Creates a **mention** trigger (`@`).
|
|
39
|
+
*
|
|
40
|
+
* Defaults: `position: 'any'`, `mode: 'dropdown'`, `chipStyle: 'pill'`,
|
|
41
|
+
* accessible label `"mention"`.
|
|
42
|
+
*/
|
|
43
|
+
export function mentionTrigger(opts: MentionTriggerOptions = {}): TriggerConfig {
|
|
44
|
+
const { char = '@', ...rest } = opts
|
|
45
|
+
return {
|
|
46
|
+
char,
|
|
47
|
+
position: 'any',
|
|
48
|
+
mode: 'dropdown',
|
|
49
|
+
chipStyle: 'pill',
|
|
50
|
+
accessibilityLabel: 'mention',
|
|
51
|
+
...rest,
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// ---------------------------------------------------------------------------
|
|
56
|
+
// /command — dropdown anywhere (opt into line-start-only with `position`)
|
|
57
|
+
// ---------------------------------------------------------------------------
|
|
58
|
+
|
|
59
|
+
export type CommandTriggerOptions = TriggerPresetOptions & {
|
|
60
|
+
/** Override the trigger character. Defaults to `'/'`. */
|
|
61
|
+
char?: string
|
|
62
|
+
/**
|
|
63
|
+
* Where the command trigger is valid. Defaults to `'any'`, so commands fire
|
|
64
|
+
* anywhere a `/` follows whitespace — not just at the start of a line.
|
|
65
|
+
* Set to `'start'` to restrict the dropdown to the very start of the input
|
|
66
|
+
* or immediately after a newline (the classic slash-command behavior).
|
|
67
|
+
*/
|
|
68
|
+
position?: TriggerPosition
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Creates a **command** trigger (`/`).
|
|
73
|
+
*
|
|
74
|
+
* Defaults: `position: 'any'`, `mode: 'dropdown'`, `chipStyle: 'inline'`,
|
|
75
|
+
* accessible label `"command"`.
|
|
76
|
+
*
|
|
77
|
+
* By default commands work everywhere in the input. Pass `position: 'start'`
|
|
78
|
+
* to limit them to the start of a line.
|
|
79
|
+
*/
|
|
80
|
+
export function commandTrigger(opts: CommandTriggerOptions = {}): TriggerConfig {
|
|
81
|
+
const { char = '/', position = 'any', ...rest } = opts
|
|
82
|
+
return {
|
|
83
|
+
char,
|
|
84
|
+
position,
|
|
85
|
+
mode: 'dropdown',
|
|
86
|
+
chipStyle: 'inline',
|
|
87
|
+
accessibilityLabel: 'command',
|
|
88
|
+
...rest,
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// ---------------------------------------------------------------------------
|
|
93
|
+
// #hashtag — dropdown at any position, auto-resolve on space
|
|
94
|
+
// ---------------------------------------------------------------------------
|
|
95
|
+
|
|
96
|
+
export type HashtagTriggerOptions = TriggerPresetOptions & {
|
|
97
|
+
/** Override the trigger character. Defaults to `'#'`. */
|
|
98
|
+
char?: string
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Creates a **hashtag / tag** trigger (`#`).
|
|
103
|
+
*
|
|
104
|
+
* Defaults: `position: 'any'`, `mode: 'dropdown'`, `chipStyle: 'pill'`,
|
|
105
|
+
* `resolveOnSpace: true`, accessible label `"tag"`.
|
|
106
|
+
*/
|
|
107
|
+
export function hashtagTrigger(opts: HashtagTriggerOptions = {}): TriggerConfig {
|
|
108
|
+
const { char = '#', ...rest } = opts
|
|
109
|
+
return {
|
|
110
|
+
char,
|
|
111
|
+
position: 'any',
|
|
112
|
+
mode: 'dropdown',
|
|
113
|
+
chipStyle: 'pill',
|
|
114
|
+
resolveOnSpace: true,
|
|
115
|
+
accessibilityLabel: 'tag',
|
|
116
|
+
...rest,
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// ---------------------------------------------------------------------------
|
|
121
|
+
// Generic callback trigger (e.g., for file pickers, model selectors)
|
|
122
|
+
// ---------------------------------------------------------------------------
|
|
123
|
+
|
|
124
|
+
export type CallbackTriggerOptions = Omit<Partial<TriggerConfig>, 'mode'> & {
|
|
125
|
+
/** The trigger character. Required. */
|
|
126
|
+
char: string
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Creates a **callback** trigger that fires `onActivate` instead of showing
|
|
131
|
+
* a dropdown. Useful for opening file pickers, model selectors, etc.
|
|
132
|
+
*
|
|
133
|
+
* Defaults: `position: 'start'`, `mode: 'callback'`.
|
|
134
|
+
*/
|
|
135
|
+
export function callbackTrigger(opts: CallbackTriggerOptions): TriggerConfig {
|
|
136
|
+
const { char, ...rest } = opts
|
|
137
|
+
return {
|
|
138
|
+
char,
|
|
139
|
+
position: 'start',
|
|
140
|
+
mode: 'callback',
|
|
141
|
+
...rest,
|
|
142
|
+
}
|
|
143
|
+
}
|
|
@@ -0,0 +1,360 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PromptArea component types
|
|
3
|
+
*
|
|
4
|
+
* A lightweight contentEditable-based text input that supports:
|
|
5
|
+
* - Trigger characters (/, @, #) that activate handlers
|
|
6
|
+
* - Immutable chips for resolved mentions/commands
|
|
7
|
+
* - Configurable trigger behavior (dropdown vs callback)
|
|
8
|
+
* - Simple inline markdown rendering
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* A segment of content within the editable text.
|
|
13
|
+
* The document model is an ordered array of these segments.
|
|
14
|
+
*/
|
|
15
|
+
export type TextSegment = {
|
|
16
|
+
type: 'text'
|
|
17
|
+
text: string
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export type ChipSegment = {
|
|
21
|
+
type: 'chip'
|
|
22
|
+
/** The trigger character that created this chip (e.g., '@', '#') */
|
|
23
|
+
trigger: string
|
|
24
|
+
/** The resolved value/ID (e.g., user ID, file ID) */
|
|
25
|
+
value: string
|
|
26
|
+
/** The display text shown in the chip */
|
|
27
|
+
displayText: string
|
|
28
|
+
/** Optional data payload attached to the chip */
|
|
29
|
+
data?: unknown
|
|
30
|
+
/**
|
|
31
|
+
* True when this chip was auto-created by pressing space (resolveOnSpace).
|
|
32
|
+
* Backspace on an auto-resolved chip reverts it to plain text instead of deleting.
|
|
33
|
+
*/
|
|
34
|
+
autoResolved?: boolean
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export type Segment = TextSegment | ChipSegment
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Determines where a trigger character is valid.
|
|
41
|
+
* - 'start': Only valid at the very start of input or after a newline (e.g., slash commands)
|
|
42
|
+
* - 'any': Valid after any whitespace boundary (e.g., @mentions)
|
|
43
|
+
*/
|
|
44
|
+
export type TriggerPosition = 'start' | 'any'
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Defines how a trigger behaves when activated.
|
|
48
|
+
* - 'dropdown': Shows a popover with suggestions from `onSearch`
|
|
49
|
+
* - 'callback': Inserts the char, then fires `onActivate` with the typed query
|
|
50
|
+
* - 'launch': Fires `onActivate` on keydown and SUPPRESSES the char (it never
|
|
51
|
+
* enters the editor) — for opening an external surface (dialog, palette) where
|
|
52
|
+
* no in-editor text should appear. Honors `position` like the other modes.
|
|
53
|
+
*/
|
|
54
|
+
export type TriggerMode = 'dropdown' | 'callback' | 'launch'
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Visual style for rendered chips.
|
|
58
|
+
* - 'pill': Button-like pill with background color, padding, border-radius (default)
|
|
59
|
+
* - 'inline': Bold inline text that flows naturally with surrounding content
|
|
60
|
+
*/
|
|
61
|
+
export type ChipStyle = 'pill' | 'inline'
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* A suggestion item shown in the trigger dropdown.
|
|
65
|
+
*/
|
|
66
|
+
export type TriggerSuggestion = {
|
|
67
|
+
/** Unique value/ID for this suggestion */
|
|
68
|
+
value: string
|
|
69
|
+
/** Display label shown in the dropdown */
|
|
70
|
+
label: string
|
|
71
|
+
/** Optional description shown below the label */
|
|
72
|
+
description?: string
|
|
73
|
+
/** Optional icon element rendered before the label */
|
|
74
|
+
icon?: React.ReactNode
|
|
75
|
+
/** Optional arbitrary data passed through on selection */
|
|
76
|
+
data?: unknown
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Configuration for a trigger character.
|
|
81
|
+
*/
|
|
82
|
+
export type TriggerConfig = {
|
|
83
|
+
/** The trigger character (e.g., '/', '@', '#') */
|
|
84
|
+
char: string
|
|
85
|
+
/** Where this trigger is valid */
|
|
86
|
+
position: TriggerPosition
|
|
87
|
+
/** How this trigger behaves */
|
|
88
|
+
mode: TriggerMode
|
|
89
|
+
/**
|
|
90
|
+
* For 'dropdown' mode: called with the current query to fetch suggestions.
|
|
91
|
+
* Should return a list of suggestions to display.
|
|
92
|
+
*
|
|
93
|
+
* Receives an options object with an `AbortSignal` that is aborted when a
|
|
94
|
+
* newer search supersedes this one. Pass it to `fetch()` or other async
|
|
95
|
+
* APIs to cancel in-flight work automatically.
|
|
96
|
+
*/
|
|
97
|
+
onSearch?: (
|
|
98
|
+
query: string,
|
|
99
|
+
options: { signal: AbortSignal },
|
|
100
|
+
) => TriggerSuggestion[] | Promise<TriggerSuggestion[]>
|
|
101
|
+
/**
|
|
102
|
+
* For 'dropdown' mode: called when a suggestion is selected.
|
|
103
|
+
* Return the display text for the chip, or void to use `suggestion.label`.
|
|
104
|
+
*/
|
|
105
|
+
onSelect?: (suggestion: TriggerSuggestion) => string | void
|
|
106
|
+
/**
|
|
107
|
+
* For 'callback' and 'launch' modes: called when the trigger is activated.
|
|
108
|
+
* Receives the full input text and cursor position. For 'launch' it fires on
|
|
109
|
+
* keydown (before the char would insert); for 'callback' it fires after.
|
|
110
|
+
*/
|
|
111
|
+
onActivate?: (context: TriggerActivateContext) => void
|
|
112
|
+
/**
|
|
113
|
+
* When true, pressing space while this trigger is active (with a non-empty query)
|
|
114
|
+
* auto-resolves the typed text into a chip without selecting from the dropdown.
|
|
115
|
+
* The auto-resolved chip can be reverted to plain text with backspace.
|
|
116
|
+
* Useful for free-form tags (e.g., #hashtag).
|
|
117
|
+
*/
|
|
118
|
+
resolveOnSpace?: boolean
|
|
119
|
+
/**
|
|
120
|
+
* For 'dropdown' mode: when true, clicking a chip created by this trigger
|
|
121
|
+
* reopens the suggestion dropdown anchored to the chip, and selecting a
|
|
122
|
+
* suggestion replaces the chip in place. The empty-query suggestions are
|
|
123
|
+
* shown with the chip's current value preselected. `onChipClick` still
|
|
124
|
+
* fires, so side effects (analytics, etc.) keep working.
|
|
125
|
+
*/
|
|
126
|
+
reopenOnChipClick?: boolean
|
|
127
|
+
/**
|
|
128
|
+
* Visual style for chips created by this trigger.
|
|
129
|
+
* - 'pill' (default): Button-like pill with background, padding, border-radius
|
|
130
|
+
* - 'inline': Bold inline text without pill styling
|
|
131
|
+
*/
|
|
132
|
+
chipStyle?: ChipStyle
|
|
133
|
+
/** CSS class name(s) applied to chips created by this trigger */
|
|
134
|
+
chipClassName?: string
|
|
135
|
+
/** Label used for accessibility (e.g., "mention", "command") */
|
|
136
|
+
accessibilityLabel?: string
|
|
137
|
+
/**
|
|
138
|
+
* Debounce delay in milliseconds before calling `onSearch`.
|
|
139
|
+
* Defaults to 0 (immediate). The initial empty-query search always fires
|
|
140
|
+
* immediately regardless of this setting so the dropdown appears instantly.
|
|
141
|
+
*/
|
|
142
|
+
searchDebounceMs?: number
|
|
143
|
+
/**
|
|
144
|
+
* Called when `onSearch` rejects or throws (non-abort errors only).
|
|
145
|
+
* Use this to log errors or show toast notifications.
|
|
146
|
+
*/
|
|
147
|
+
onSearchError?: (error: unknown) => void
|
|
148
|
+
/**
|
|
149
|
+
* Message shown in the dropdown when `onSearch` returns an empty array.
|
|
150
|
+
* If omitted, the popover hides when there are no results (current behavior).
|
|
151
|
+
*/
|
|
152
|
+
emptyMessage?: string
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Context passed to callback-mode trigger handlers.
|
|
157
|
+
*/
|
|
158
|
+
export type TriggerActivateContext = {
|
|
159
|
+
/** The full plain text content at the time of activation */
|
|
160
|
+
text: string
|
|
161
|
+
/** The cursor offset position */
|
|
162
|
+
cursorPosition: number
|
|
163
|
+
/** Function to insert a chip at the current cursor position */
|
|
164
|
+
insertChip: (chip: Omit<ChipSegment, 'type'>) => void
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Represents an active trigger being typed by the user.
|
|
169
|
+
*/
|
|
170
|
+
export type ActiveTrigger = {
|
|
171
|
+
/** The trigger config that was activated */
|
|
172
|
+
config: TriggerConfig
|
|
173
|
+
/** Position (character offset) where the trigger character was typed */
|
|
174
|
+
startOffset: number
|
|
175
|
+
/** The text typed after the trigger character so far */
|
|
176
|
+
query: string
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* An image attachment displayed in the prompt area.
|
|
181
|
+
* State is managed externally by the parent component.
|
|
182
|
+
*/
|
|
183
|
+
export type PromptAreaImage = {
|
|
184
|
+
/** Unique identifier for this image */
|
|
185
|
+
id: string
|
|
186
|
+
/** URL to display (CDN URL or temporary blob URL for preview) */
|
|
187
|
+
url: string
|
|
188
|
+
/** Optional alt text for accessibility */
|
|
189
|
+
alt?: string
|
|
190
|
+
/** When true, shows a loading indicator over the thumbnail */
|
|
191
|
+
loading?: boolean
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* A file attachment displayed in the prompt area.
|
|
196
|
+
* State is managed externally by the parent component.
|
|
197
|
+
*/
|
|
198
|
+
export type PromptAreaFile = {
|
|
199
|
+
/** Unique identifier for this file */
|
|
200
|
+
id: string
|
|
201
|
+
/** Display filename (e.g., "report.pdf") */
|
|
202
|
+
name: string
|
|
203
|
+
/** File size in bytes */
|
|
204
|
+
size?: number
|
|
205
|
+
/** MIME type (used for icon selection, e.g., "application/pdf") */
|
|
206
|
+
type?: string
|
|
207
|
+
/** When true, shows a loading indicator over the file card */
|
|
208
|
+
loading?: boolean
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Props for the PromptArea component.
|
|
213
|
+
*/
|
|
214
|
+
export type PromptAreaProps = {
|
|
215
|
+
/** The document segments (controlled) */
|
|
216
|
+
value: Segment[]
|
|
217
|
+
/** Called when the content changes */
|
|
218
|
+
onChange: (segments: Segment[]) => void
|
|
219
|
+
/** Trigger configurations */
|
|
220
|
+
triggers?: TriggerConfig[]
|
|
221
|
+
/** Placeholder text when empty. Pass an array of strings to animate between them. */
|
|
222
|
+
placeholder?: string | string[]
|
|
223
|
+
/** Additional CSS class for the container */
|
|
224
|
+
className?: string
|
|
225
|
+
/** Whether the input is disabled */
|
|
226
|
+
disabled?: boolean
|
|
227
|
+
/** Whether to render simple inline markdown (bold, italic, URLs, lists) */
|
|
228
|
+
markdown?: boolean
|
|
229
|
+
/**
|
|
230
|
+
* When markdown is on, the editor rewrites typed list markers (`- ` / `* `)
|
|
231
|
+
* to a `•` bullet glyph in the model. Set to `false` to keep the original
|
|
232
|
+
* marker in the value/`onChange` text — needed when a host renders the output
|
|
233
|
+
* as real markdown, where `•` is not a valid list marker. Default `true`.
|
|
234
|
+
*/
|
|
235
|
+
normalizeBullets?: boolean
|
|
236
|
+
/** Called when Enter is pressed (without Shift) */
|
|
237
|
+
onSubmit?: (segments: Segment[]) => void
|
|
238
|
+
/** Called when Escape is pressed */
|
|
239
|
+
onEscape?: () => void
|
|
240
|
+
/** Called when a chip element is clicked. Receives the chip's segment data. */
|
|
241
|
+
onChipClick?: (chip: ChipSegment) => void
|
|
242
|
+
/** Called when a new chip is added (dropdown selection, auto-resolve, paste, or imperative insert) */
|
|
243
|
+
onChipAdd?: (chip: ChipSegment) => void
|
|
244
|
+
/** Called when a chip is deleted (backspace or forward delete) */
|
|
245
|
+
onChipDelete?: (chip: ChipSegment) => void
|
|
246
|
+
/** Called when a URL link is clicked. Receives the URL string. */
|
|
247
|
+
onLinkClick?: (url: string) => void
|
|
248
|
+
/** Called after content is pasted. Receives the resulting segments and the paste source. */
|
|
249
|
+
onPaste?: (data: { segments: Segment[]; source: 'internal' | 'external' }) => void
|
|
250
|
+
/** Called after an undo operation. Receives the restored segments. */
|
|
251
|
+
onUndo?: (segments: Segment[]) => void
|
|
252
|
+
/** Called after a redo operation. Receives the restored segments. */
|
|
253
|
+
onRedo?: (segments: Segment[]) => void
|
|
254
|
+
/** Minimum height in pixels */
|
|
255
|
+
minHeight?: number
|
|
256
|
+
/** Maximum height in pixels */
|
|
257
|
+
maxHeight?: number
|
|
258
|
+
/**
|
|
259
|
+
* Maximum number of plain-text characters allowed, enforced on typed input:
|
|
260
|
+
* once the editor exceeds the cap it is truncated back to this length, with
|
|
261
|
+
* the caret kept where the edit happened. Chips count as their
|
|
262
|
+
* `trigger + displayText` length.
|
|
263
|
+
*
|
|
264
|
+
* The cap applies to typing only. Paste is not capped — divert it via
|
|
265
|
+
* `onRawPaste` if needed — and the imperative `setText` / `appendText` also
|
|
266
|
+
* bypass it, so a programmatic write can exceed the cap until the next
|
|
267
|
+
* keystroke truncates.
|
|
268
|
+
*/
|
|
269
|
+
maxLength?: number
|
|
270
|
+
/** Auto-focus on mount */
|
|
271
|
+
autoFocus?: boolean
|
|
272
|
+
/** When true, the area auto-grows to fit content on focus and shrinks on blur */
|
|
273
|
+
autoGrow?: boolean
|
|
274
|
+
/** Accessible label for the input */
|
|
275
|
+
'aria-label'?: string
|
|
276
|
+
/** data-test-id for e2e testing */
|
|
277
|
+
'data-test-id'?: string
|
|
278
|
+
/** Array of image attachments to display */
|
|
279
|
+
images?: PromptAreaImage[]
|
|
280
|
+
/** Where to render the image strip relative to the text area. Defaults to 'above'. */
|
|
281
|
+
imagePosition?: 'above' | 'below'
|
|
282
|
+
/** Called when the user pastes an image from clipboard. Receives the File object. */
|
|
283
|
+
onImagePaste?: (file: File) => void
|
|
284
|
+
/** Called when the user clicks the remove button on an image */
|
|
285
|
+
onImageRemove?: (image: PromptAreaImage) => void
|
|
286
|
+
/** Called when the user clicks an image thumbnail */
|
|
287
|
+
onImageClick?: (image: PromptAreaImage) => void
|
|
288
|
+
/** Array of file attachments to display */
|
|
289
|
+
files?: PromptAreaFile[]
|
|
290
|
+
/** Where to render the file strip relative to the text area. Defaults to 'above'. */
|
|
291
|
+
filePosition?: 'above' | 'below'
|
|
292
|
+
/** Called when the user clicks the remove button on a file */
|
|
293
|
+
onFileRemove?: (file: PromptAreaFile) => void
|
|
294
|
+
/** Called when the user clicks a file attachment */
|
|
295
|
+
onFileClick?: (file: PromptAreaFile) => void
|
|
296
|
+
/**
|
|
297
|
+
* Called on keydown before PromptArea's own handling. Call `preventDefault()`
|
|
298
|
+
* to suppress the built-in behaviour (submit, trigger navigation, etc.) for
|
|
299
|
+
* that key and take over entirely.
|
|
300
|
+
*/
|
|
301
|
+
onKeyDown?: (e: React.KeyboardEvent<HTMLDivElement>) => void
|
|
302
|
+
/**
|
|
303
|
+
* Called on blur with the native FocusEvent, so consumers can inspect
|
|
304
|
+
* `relatedTarget` (e.g. to retain focus when a composer toolbar is clicked).
|
|
305
|
+
*/
|
|
306
|
+
onBlur?: (e: React.FocusEvent<HTMLDivElement>) => void
|
|
307
|
+
/**
|
|
308
|
+
* Called at the start of a paste, before PromptArea reads the clipboard. Call
|
|
309
|
+
* `preventDefault()` to take over the paste completely — e.g. to divert large
|
|
310
|
+
* text or non-image files to an upload pipeline. The built-in segment/image
|
|
311
|
+
* paste handling is skipped when the event's default is prevented.
|
|
312
|
+
*/
|
|
313
|
+
onRawPaste?: (e: React.ClipboardEvent<HTMLDivElement>) => void
|
|
314
|
+
/**
|
|
315
|
+
* Whether pressing Enter (without Shift) submits. Defaults to true. Set false
|
|
316
|
+
* to make Enter insert a newline instead (e.g. on touch devices where submit
|
|
317
|
+
* is a dedicated button).
|
|
318
|
+
*/
|
|
319
|
+
submitOnEnter?: boolean
|
|
320
|
+
/** Forwarded to the editable element. */
|
|
321
|
+
spellCheck?: boolean
|
|
322
|
+
/** Forwarded to the editable element as `aria-describedby`. */
|
|
323
|
+
'aria-describedby'?: string
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/**
|
|
327
|
+
* Ref handle exposed by PromptArea via useImperativeHandle.
|
|
328
|
+
*/
|
|
329
|
+
export type PromptAreaHandle = {
|
|
330
|
+
/** Focus the editable area */
|
|
331
|
+
focus: () => void
|
|
332
|
+
/** Blur the editable area */
|
|
333
|
+
blur: () => void
|
|
334
|
+
/** Insert a chip at the current cursor position */
|
|
335
|
+
insertChip: (chip: Omit<ChipSegment, 'type'>) => void
|
|
336
|
+
/** Get the current plain text (without chip markup) */
|
|
337
|
+
getPlainText: () => string
|
|
338
|
+
/** Clear all content */
|
|
339
|
+
clear: () => void
|
|
340
|
+
/**
|
|
341
|
+
* Replace all content with plain text (chips dropped), caret moved to the
|
|
342
|
+
* end. Not capped by `maxLength` (see its docs); undoable.
|
|
343
|
+
*/
|
|
344
|
+
setText: (text: string) => void
|
|
345
|
+
/**
|
|
346
|
+
* Append plain text at the end (existing chips preserved), caret moved to the
|
|
347
|
+
* end. Not capped by `maxLength` (see its docs); undoable.
|
|
348
|
+
*/
|
|
349
|
+
appendText: (text: string) => void
|
|
350
|
+
/** Caret offset in plain-text characters, or null when unavailable. */
|
|
351
|
+
getCursorPosition: () => number | null
|
|
352
|
+
/** Move the caret to a plain-text offset. */
|
|
353
|
+
setCursorPosition: (offset: number) => void
|
|
354
|
+
/** Move the caret to the end of the content. */
|
|
355
|
+
setCursorToEnd: () => void
|
|
356
|
+
/** Current selection as plain-text offsets, or null when there is none. */
|
|
357
|
+
getSelection: () => { start: number; end: number } | null
|
|
358
|
+
/** Set the selection between two plain-text offsets. */
|
|
359
|
+
setSelection: (start: number, end: number) => void
|
|
360
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Hook that toggles a PromptArea between its markdown and plain-text variants.
|
|
3
|
+
*
|
|
4
|
+
* PromptArea's `markdown` prop controls whether inline markdown (bold, italic,
|
|
5
|
+
* URLs, and list bullets) is rendered and whether typed list markers normalize
|
|
6
|
+
* to `•`. Flipping it at runtime is non-destructive: the segment value is kept,
|
|
7
|
+
* only its rendering changes (bullets convert `•` ↔ `-` when `normalizeBullets`
|
|
8
|
+
* is on). This hook owns that boolean as a named "mode" and hands you a `toggle`
|
|
9
|
+
* plus a `markdown` value ready to spread onto the component.
|
|
10
|
+
*
|
|
11
|
+
* @example
|
|
12
|
+
* ```tsx
|
|
13
|
+
* function Composer() {
|
|
14
|
+
* const { bind } = usePromptAreaState()
|
|
15
|
+
* const { markdown, mode, toggle } = useMarkdownMode()
|
|
16
|
+
*
|
|
17
|
+
* return (
|
|
18
|
+
* <>
|
|
19
|
+
* <PromptArea {...bind} markdown={markdown} />
|
|
20
|
+
* <button onClick={toggle} aria-pressed={markdown}>
|
|
21
|
+
* {mode === 'markdown' ? 'Markdown' : 'Plain text'}
|
|
22
|
+
* </button>
|
|
23
|
+
* </>
|
|
24
|
+
* )
|
|
25
|
+
* }
|
|
26
|
+
* ```
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
'use client'
|
|
30
|
+
|
|
31
|
+
import { useCallback, useMemo, useState } from 'react'
|
|
32
|
+
|
|
33
|
+
// ---------------------------------------------------------------------------
|
|
34
|
+
// Mode
|
|
35
|
+
// ---------------------------------------------------------------------------
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* The two rendering variants of a PromptArea.
|
|
39
|
+
* - `'markdown'`: inline markdown is decorated and list markers normalize to `•`.
|
|
40
|
+
* - `'plain'`: raw source text is shown verbatim with no decoration.
|
|
41
|
+
*/
|
|
42
|
+
export type PromptAreaMode = 'markdown' | 'plain'
|
|
43
|
+
|
|
44
|
+
/** Returns the other mode. Pure — handy for building custom toggles. */
|
|
45
|
+
export function oppositeMode(mode: PromptAreaMode): PromptAreaMode {
|
|
46
|
+
return mode === 'markdown' ? 'plain' : 'markdown'
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// ---------------------------------------------------------------------------
|
|
50
|
+
// Options / return type
|
|
51
|
+
// ---------------------------------------------------------------------------
|
|
52
|
+
|
|
53
|
+
export type UseMarkdownModeOptions = {
|
|
54
|
+
/** Starting mode for the uncontrolled hook. Defaults to `'markdown'`. */
|
|
55
|
+
initialMode?: PromptAreaMode
|
|
56
|
+
/**
|
|
57
|
+
* Controlled mode. When provided, the hook mirrors this value and never owns
|
|
58
|
+
* its own state — drive changes through `onModeChange`.
|
|
59
|
+
*/
|
|
60
|
+
mode?: PromptAreaMode
|
|
61
|
+
/** Called with the next mode whenever `toggle`/`setMode` change it. */
|
|
62
|
+
onModeChange?: (mode: PromptAreaMode) => void
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export type MarkdownModeState = {
|
|
66
|
+
/** The active mode. */
|
|
67
|
+
mode: PromptAreaMode
|
|
68
|
+
/** `true` in markdown mode — spread onto `<PromptArea markdown={markdown} />`. */
|
|
69
|
+
markdown: boolean
|
|
70
|
+
/** `true` in plain-text mode (the inverse of `markdown`). */
|
|
71
|
+
isPlainText: boolean
|
|
72
|
+
/** Switch to an explicit mode. No-op if already in it. */
|
|
73
|
+
setMode: (mode: PromptAreaMode) => void
|
|
74
|
+
/** Flip between markdown and plain text. */
|
|
75
|
+
toggle: () => void
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// ---------------------------------------------------------------------------
|
|
79
|
+
// Hook
|
|
80
|
+
// ---------------------------------------------------------------------------
|
|
81
|
+
|
|
82
|
+
export function useMarkdownMode(options: UseMarkdownModeOptions = {}): MarkdownModeState {
|
|
83
|
+
const { initialMode = 'markdown', mode: controlledMode, onModeChange } = options
|
|
84
|
+
const isControlled = controlledMode !== undefined
|
|
85
|
+
|
|
86
|
+
const [internalMode, setInternalMode] = useState<PromptAreaMode>(
|
|
87
|
+
() => controlledMode ?? initialMode,
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
const mode = isControlled ? controlledMode : internalMode
|
|
91
|
+
|
|
92
|
+
const setMode = useCallback(
|
|
93
|
+
(next: PromptAreaMode) => {
|
|
94
|
+
if (next === mode) return
|
|
95
|
+
if (!isControlled) setInternalMode(next)
|
|
96
|
+
onModeChange?.(next)
|
|
97
|
+
},
|
|
98
|
+
[mode, isControlled, onModeChange],
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
const toggle = useCallback(() => setMode(oppositeMode(mode)), [mode, setMode])
|
|
102
|
+
|
|
103
|
+
return useMemo(
|
|
104
|
+
() => ({
|
|
105
|
+
mode,
|
|
106
|
+
markdown: mode === 'markdown',
|
|
107
|
+
isPlainText: mode === 'plain',
|
|
108
|
+
setMode,
|
|
109
|
+
toggle,
|
|
110
|
+
}),
|
|
111
|
+
[mode, setMode, toggle],
|
|
112
|
+
)
|
|
113
|
+
}
|