@refraction-ui/astro 0.15.2 → 0.16.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/dist/astro-composer/Composer.astro +66 -0
- package/dist/astro-composer/index.ts +1 -0
- package/dist/composer/composer.ts +799 -0
- package/dist/composer/graphemes.ts +92 -0
- package/dist/composer/index.ts +8 -0
- package/dist/composer/rules.ts +63 -0
- package/dist/composer/styles.ts +102 -0
- package/dist/composer/suggestion.ts +261 -0
- package/dist/composer/tokens.ts +288 -0
- package/dist/composer/trigger-engine.ts +205 -0
- package/dist/composer/types.ts +337 -0
- package/dist/index.ts +1 -0
- package/dist/rich-editor/index.ts +4 -0
- package/dist/rich-editor/mentions.ts +3 -17
- package/dist/rich-editor/slash-commands.ts +3 -17
- package/dist/rich-editor/trigger.ts +40 -0
- package/package.json +2 -1
|
@@ -0,0 +1,337 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Public types for the headless chat composer core.
|
|
3
|
+
*
|
|
4
|
+
* The composer models a single growing message buffer (flat string + selection)
|
|
5
|
+
* with inline triggers (@mention, /command, :emoji:, #tag, custom), committed
|
|
6
|
+
* atomic tokens, staged attachments, and one optimistic submit. Framework
|
|
7
|
+
* adapters (React/Astro/Flutter) wrap this contract without re-implementing
|
|
8
|
+
* any behavior.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import type { MessageAttachment } from '../conversation/index.ts'
|
|
12
|
+
|
|
13
|
+
// ---------------------------------------------------------------------------
|
|
14
|
+
// Triggers & suggestions
|
|
15
|
+
// ---------------------------------------------------------------------------
|
|
16
|
+
|
|
17
|
+
/** Where a trigger symbol is allowed to arm. */
|
|
18
|
+
export type ComposerTriggerScope = 'anywhere' | 'startOfLine' | 'startOfMessage'
|
|
19
|
+
|
|
20
|
+
/** A candidate shown in the suggestion menu for an armed trigger. */
|
|
21
|
+
export interface ComposerCandidate {
|
|
22
|
+
id: string
|
|
23
|
+
display: string
|
|
24
|
+
subtitle?: string
|
|
25
|
+
metadata?: Record<string, unknown>
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export type ComposerTriggerResolver = (
|
|
29
|
+
query: string,
|
|
30
|
+
) => ComposerCandidate[] | Promise<ComposerCandidate[]>
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* A trigger is configuration, not code — the engine is symbol-agnostic and
|
|
34
|
+
* never special-cases '@' / '/' / ':' in control flow.
|
|
35
|
+
*/
|
|
36
|
+
export interface ComposerTrigger {
|
|
37
|
+
id: string
|
|
38
|
+
/** Trigger symbol; any length ('@', '/', '#', '!!'). */
|
|
39
|
+
symbol: string
|
|
40
|
+
/** Default 'anywhere'. Slash-style commands use 'startOfMessage'. */
|
|
41
|
+
scope?: ComposerTriggerScope
|
|
42
|
+
/** Extra validation for the query; a violating query closes the trigger. */
|
|
43
|
+
queryPattern?: RegExp
|
|
44
|
+
/** Queries longer than this silently cancel the trigger. Default 40. */
|
|
45
|
+
maxQueryLength?: number
|
|
46
|
+
/** Default true. '#'-style triggers set false so '#weekend trip' stays armed. */
|
|
47
|
+
closeOnSpace?: boolean
|
|
48
|
+
/** Default false. Trades away email/URL protection — must be justified. */
|
|
49
|
+
allowMidWord?: boolean
|
|
50
|
+
/** Additional boundary characters allowed before the symbol (e.g. '(' or '"'). */
|
|
51
|
+
extraBoundaryChars?: string[]
|
|
52
|
+
/** Resolver debounce in ms. Default 0 (sync/local resolvers). */
|
|
53
|
+
debounceMs?: number
|
|
54
|
+
/** Visible slice size for the adapter's menu. Default 6. */
|
|
55
|
+
maxVisibleResults?: number
|
|
56
|
+
/** Whether ArrowUp/Down wrap around the ends. Default true. */
|
|
57
|
+
wrapNavigation?: boolean
|
|
58
|
+
/**
|
|
59
|
+
* Builds the committed token's display text. Defaults to `symbol + display`
|
|
60
|
+
* ('@Jordan Lee'); the emoji recipe returns the unicode itself.
|
|
61
|
+
*/
|
|
62
|
+
toDisplay?: (candidate: ComposerCandidate) => string
|
|
63
|
+
resolve: ComposerTriggerResolver
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** A committed, atomic inline unit (identity + frozen display). */
|
|
67
|
+
export interface ComposerToken {
|
|
68
|
+
triggerId: string
|
|
69
|
+
symbol: string
|
|
70
|
+
id: string
|
|
71
|
+
label: string
|
|
72
|
+
display: string
|
|
73
|
+
metadata?: Record<string, unknown>
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** A token placed in the value, with its live UTF-16 range. */
|
|
77
|
+
export interface PlacedToken extends ComposerToken {
|
|
78
|
+
start: number
|
|
79
|
+
end: number
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Structured output token with derived UTF-16 offsets into plainText. */
|
|
83
|
+
export interface ResolvedToken {
|
|
84
|
+
type: string
|
|
85
|
+
id: string
|
|
86
|
+
display: string
|
|
87
|
+
start: number
|
|
88
|
+
end: number
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// ---------------------------------------------------------------------------
|
|
92
|
+
// Attachments
|
|
93
|
+
// ---------------------------------------------------------------------------
|
|
94
|
+
|
|
95
|
+
export type ComposerAttachmentKind = 'text' | 'image' | 'video' | 'audio' | 'file'
|
|
96
|
+
export type ComposerAttachmentStatus = 'pending' | 'uploading' | 'ready' | 'error'
|
|
97
|
+
|
|
98
|
+
export interface ComposerAttachment {
|
|
99
|
+
id: string
|
|
100
|
+
kind: ComposerAttachmentKind
|
|
101
|
+
name: string
|
|
102
|
+
mimeType?: string
|
|
103
|
+
sizeBytes?: number
|
|
104
|
+
previewUrl?: string
|
|
105
|
+
status: ComposerAttachmentStatus
|
|
106
|
+
/** 0..1 while uploading. */
|
|
107
|
+
progress?: number
|
|
108
|
+
errorMessage?: string
|
|
109
|
+
metadata?: Record<string, unknown>
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Input to `addAttachment` — id/status are core-owned defaults. */
|
|
113
|
+
export type ComposerAttachmentDraft = Omit<ComposerAttachment, 'id' | 'status'> & {
|
|
114
|
+
id?: string
|
|
115
|
+
status?: ComposerAttachmentStatus
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// ---------------------------------------------------------------------------
|
|
119
|
+
// Submission & serialization
|
|
120
|
+
// ---------------------------------------------------------------------------
|
|
121
|
+
|
|
122
|
+
export interface ComposerSubmission {
|
|
123
|
+
/** Trimmed message text with token displays inlined. */
|
|
124
|
+
plainText: string
|
|
125
|
+
tokens: ResolvedToken[]
|
|
126
|
+
attachments: ComposerAttachment[]
|
|
127
|
+
replyToMessageId?: string
|
|
128
|
+
/** Present when submitting from edit mode. */
|
|
129
|
+
editingMessageId?: string
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export interface ComposerOutput {
|
|
133
|
+
plainText: string
|
|
134
|
+
tokens: ResolvedToken[]
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// ---------------------------------------------------------------------------
|
|
138
|
+
// State
|
|
139
|
+
// ---------------------------------------------------------------------------
|
|
140
|
+
|
|
141
|
+
export interface ComposerSelection {
|
|
142
|
+
start: number
|
|
143
|
+
end: number
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export interface ActiveTriggerState {
|
|
147
|
+
triggerId: string
|
|
148
|
+
symbol: string
|
|
149
|
+
symbolStart: number
|
|
150
|
+
caret: number
|
|
151
|
+
query: string
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
export interface SuggestionState {
|
|
155
|
+
isOpen: boolean
|
|
156
|
+
/** Full result list (overflow intact). */
|
|
157
|
+
items: ComposerCandidate[]
|
|
158
|
+
/** `items` sliced to the trigger's maxVisibleResults for the adapter. */
|
|
159
|
+
visibleItems: ComposerCandidate[]
|
|
160
|
+
activeIndex: number
|
|
161
|
+
loading: boolean
|
|
162
|
+
error: string | null
|
|
163
|
+
/** Monotonic id of the latest resolve request (staleness guard). */
|
|
164
|
+
requestToken: number
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export interface CounterState {
|
|
168
|
+
/** Visible once remaining budget is within 20% of maxLength. */
|
|
169
|
+
visible: boolean
|
|
170
|
+
/** Remaining grapheme budget; null without a maxLength. */
|
|
171
|
+
remaining: number | null
|
|
172
|
+
overLimit: boolean
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export type ComposerMode = 'compose' | 'edit'
|
|
176
|
+
|
|
177
|
+
export interface ComposerState {
|
|
178
|
+
value: string
|
|
179
|
+
selection: ComposerSelection
|
|
180
|
+
isComposing: boolean
|
|
181
|
+
isBusy: boolean
|
|
182
|
+
disabled: boolean
|
|
183
|
+
readOnly: boolean
|
|
184
|
+
isEmpty: boolean
|
|
185
|
+
canSend: boolean
|
|
186
|
+
error: string | null
|
|
187
|
+
attachments: ComposerAttachment[]
|
|
188
|
+
tokens: PlacedToken[]
|
|
189
|
+
activeTrigger: ActiveTriggerState | null
|
|
190
|
+
suggestion: SuggestionState
|
|
191
|
+
counter: CounterState
|
|
192
|
+
mode: ComposerMode
|
|
193
|
+
editingMessageId?: string
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// ---------------------------------------------------------------------------
|
|
197
|
+
// Validation, drafts, events, config
|
|
198
|
+
// ---------------------------------------------------------------------------
|
|
199
|
+
|
|
200
|
+
export interface ComposerValidationResult {
|
|
201
|
+
isValid: boolean
|
|
202
|
+
reason?: string
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
export type ComposerValidator = (
|
|
206
|
+
plainText: string,
|
|
207
|
+
tokens: ResolvedToken[],
|
|
208
|
+
) => ComposerValidationResult
|
|
209
|
+
|
|
210
|
+
/** Persisted draft snapshot (attachments by id only — blobs are host-owned). */
|
|
211
|
+
export interface ComposerDraft {
|
|
212
|
+
value: string
|
|
213
|
+
tokens: PlacedToken[]
|
|
214
|
+
attachmentIds: string[]
|
|
215
|
+
updatedAt: number
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/** Injected persistence seam; the core ships only an in-memory default. */
|
|
219
|
+
export interface ComposerDraftStore {
|
|
220
|
+
read(key: string): ComposerDraft | null
|
|
221
|
+
write(key: string, draft: ComposerDraft): void
|
|
222
|
+
clear(key: string): void
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
export type ComposerEvent =
|
|
226
|
+
| { type: 'paste-trimmed' }
|
|
227
|
+
| { type: 'insert-rejected'; reason: 'max-length' | 'composing' | 'disabled' | 'read-only' }
|
|
228
|
+
| { type: 'edit-rejected'; reason: 'inside-token' }
|
|
229
|
+
| { type: 'attachment-rejected'; reason: 'max-attachments' | 'max-size' | 'not-accepted'; name: string; detail?: string }
|
|
230
|
+
| { type: 'typing' }
|
|
231
|
+
|
|
232
|
+
export interface ComposerConfig {
|
|
233
|
+
initialValue?: string
|
|
234
|
+
/** Tokens present in initialValue (e.g. when re-opening a draft the host owns). */
|
|
235
|
+
initialTokens?: PlacedToken[]
|
|
236
|
+
/** Grapheme-cluster budget (not UTF-16 units). */
|
|
237
|
+
maxLength?: number
|
|
238
|
+
maxAttachments?: number
|
|
239
|
+
/** Per-attachment size gate; larger drafts are rejected with an event. */
|
|
240
|
+
maxAttachmentSizeBytes?: number
|
|
241
|
+
/** Predicate gate; return false or a reason string to reject a draft. */
|
|
242
|
+
acceptAttachment?: (draft: ComposerAttachmentDraft) => boolean | string
|
|
243
|
+
minLines?: number
|
|
244
|
+
maxLines?: number
|
|
245
|
+
triggers?: ComposerTrigger[]
|
|
246
|
+
validator?: ComposerValidator
|
|
247
|
+
draftStore?: ComposerDraftStore
|
|
248
|
+
draftKey?: string
|
|
249
|
+
/** Draft autosave debounce. Default 400ms. */
|
|
250
|
+
draftDebounceMs?: number
|
|
251
|
+
/** Leading-edge throttle for the 'typing' event. Default 3000ms. */
|
|
252
|
+
typingSignalIntervalMs?: number
|
|
253
|
+
replyToMessageId?: string
|
|
254
|
+
/** Injected clock — the core never calls Date.now() in logic paths. */
|
|
255
|
+
now?: () => number
|
|
256
|
+
/** Injected id factory — the core never calls Math.random(). */
|
|
257
|
+
generateId?: (prefix?: string) => string
|
|
258
|
+
/** Notice channel ('paste-trimmed', 'attachment-rejected', 'typing', …). */
|
|
259
|
+
onEvent?: (event: ComposerEvent) => void
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// ---------------------------------------------------------------------------
|
|
263
|
+
// API
|
|
264
|
+
// ---------------------------------------------------------------------------
|
|
265
|
+
|
|
266
|
+
/** Outcome of a physical Enter press routed through the core. */
|
|
267
|
+
export type EnterResult = 'submitted' | 'newline' | 'committed-suggestion' | 'noop'
|
|
268
|
+
|
|
269
|
+
export interface ComposerAPI {
|
|
270
|
+
getState(): ComposerState
|
|
271
|
+
subscribe(listener: (state: ComposerState) => void): () => void
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* The adapter's input path: full new text + selection after a user edit.
|
|
275
|
+
* Pass `{ programmatic: true }` for host-driven writes (suppresses the
|
|
276
|
+
* typing signal).
|
|
277
|
+
*/
|
|
278
|
+
setValue(text: string, selection?: ComposerSelection, opts?: { programmatic?: boolean }): void
|
|
279
|
+
/** Caret/selection move without a text change (allowed while readOnly). */
|
|
280
|
+
setSelection(selection: ComposerSelection): void
|
|
281
|
+
insertTextAtCursor(text: string): void
|
|
282
|
+
setComposing(isComposing: boolean): void
|
|
283
|
+
|
|
284
|
+
/** Routes a physical Enter; the return value tells the adapter whether to preventDefault. */
|
|
285
|
+
applyEnter(opts: { shiftPressed: boolean }): EnterResult
|
|
286
|
+
|
|
287
|
+
moveSuggestionNext(): void
|
|
288
|
+
moveSuggestionPrevious(): void
|
|
289
|
+
/** Pointer hover — last-input-wins with the keyboard. */
|
|
290
|
+
setSuggestionActiveIndex(index: number): void
|
|
291
|
+
applySuggestion(index?: number): void
|
|
292
|
+
/** Escape-style dismissal: closes now and marks the occurrence dismissed. */
|
|
293
|
+
dismissSuggestion(): void
|
|
294
|
+
/** Blur-style dismissal: closes after a short grace so a click can land. */
|
|
295
|
+
dismissSuggestionDeferred(): void
|
|
296
|
+
retrySuggestions(): void
|
|
297
|
+
|
|
298
|
+
addAttachment(draft: ComposerAttachmentDraft): string | null
|
|
299
|
+
updateAttachment(id: string, patch: Partial<Omit<ComposerAttachment, 'id'>>): void
|
|
300
|
+
removeAttachment(id: string): void
|
|
301
|
+
|
|
302
|
+
setBusy(busy: boolean): void
|
|
303
|
+
setError(error: string | null): void
|
|
304
|
+
setDisabled(disabled: boolean): void
|
|
305
|
+
setReadOnly(readOnly: boolean): void
|
|
306
|
+
|
|
307
|
+
beginEdit(args: { value: string; tokens?: PlacedToken[]; messageId: string }): void
|
|
308
|
+
cancelEdit(): void
|
|
309
|
+
|
|
310
|
+
/** Clipboard seam: returns the selected display text. */
|
|
311
|
+
copySelection(): string
|
|
312
|
+
/** Clipboard seam: removes the selection, retaining token identity for same-instance paste. */
|
|
313
|
+
cutSelection(): string
|
|
314
|
+
/** Clipboard seam: plain-text insert at caret (clamped); restores tokens only for a same-instance cut. */
|
|
315
|
+
pasteText(text: string): void
|
|
316
|
+
|
|
317
|
+
submit(): ComposerSubmission | null
|
|
318
|
+
reset(): void
|
|
319
|
+
serialize(): ComposerOutput
|
|
320
|
+
destroy(): void
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/**
|
|
324
|
+
* Bridge to the conversation layer: a ready composer attachment mapped onto
|
|
325
|
+
* `@refraction-ui/conversation`'s wire shape.
|
|
326
|
+
*/
|
|
327
|
+
export function toMessageAttachment(attachment: ComposerAttachment): MessageAttachment {
|
|
328
|
+
return {
|
|
329
|
+
id: attachment.id,
|
|
330
|
+
name: attachment.name,
|
|
331
|
+
url: attachment.previewUrl ?? '',
|
|
332
|
+
type: attachment.mimeType ?? attachment.kind,
|
|
333
|
+
size: attachment.sizeBytes,
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
export type { MessageAttachment }
|
package/dist/index.ts
CHANGED
|
@@ -30,6 +30,7 @@ export * from './astro-code-editor/index.ts';
|
|
|
30
30
|
export * from './astro-collapsible/index.ts';
|
|
31
31
|
export * from './astro-command/index.ts';
|
|
32
32
|
export * from './astro-command-input/index.ts';
|
|
33
|
+
export * from './astro-composer/index.ts';
|
|
33
34
|
export * from './astro-content-protection/index.ts';
|
|
34
35
|
export * from './astro-conversation/index.ts';
|
|
35
36
|
export * from './astro-cookie-consent/index.ts';
|
|
@@ -83,6 +83,10 @@ export {
|
|
|
83
83
|
// Markdown shortcuts
|
|
84
84
|
export { processMarkdownShortcut } from './markdown-shortcuts.js'
|
|
85
85
|
|
|
86
|
+
// Trigger detection (generalized — mentions/slash delegate to this)
|
|
87
|
+
export type { TriggerHit } from './trigger.js'
|
|
88
|
+
export { detectTriggerInText } from './trigger.js'
|
|
89
|
+
|
|
86
90
|
// Slash commands
|
|
87
91
|
export type { SlashCommand, SlashCommandMenu } from './slash-commands.js'
|
|
88
92
|
export { BUILT_IN_COMMANDS, createSlashCommandMenu, detectSlashTrigger } from './slash-commands.js'
|
|
@@ -6,6 +6,7 @@ import type { Document } from './model.js'
|
|
|
6
6
|
import type { Selection } from './selection.js'
|
|
7
7
|
import type { MentionSegment } from './model.js'
|
|
8
8
|
import { findBlockById, getBlockText, createMentionSegment } from './model.js'
|
|
9
|
+
import { detectTriggerInText } from './trigger.js'
|
|
9
10
|
|
|
10
11
|
// ---------------------------------------------------------------------------
|
|
11
12
|
// Types
|
|
@@ -142,21 +143,6 @@ export function detectMentionTrigger(
|
|
|
142
143
|
const block = findBlockById(doc, sel.anchor.blockId)
|
|
143
144
|
if (!block) return { triggered: false, query: '' }
|
|
144
145
|
|
|
145
|
-
const
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
// Look for "@" preceded by start-of-text or a space
|
|
149
|
-
const atIdx = textBeforeCursor.lastIndexOf('@')
|
|
150
|
-
if (atIdx === -1) return { triggered: false, query: '' }
|
|
151
|
-
|
|
152
|
-
// The @ must be at position 0 or preceded by a space
|
|
153
|
-
if (atIdx > 0 && textBeforeCursor[atIdx - 1] !== ' ') {
|
|
154
|
-
return { triggered: false, query: '' }
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
const query = textBeforeCursor.slice(atIdx + 1)
|
|
158
|
-
// If query contains spaces, the mention trigger is no longer active
|
|
159
|
-
if (query.includes(' ')) return { triggered: false, query: '' }
|
|
160
|
-
|
|
161
|
-
return { triggered: true, query }
|
|
146
|
+
const hit = detectTriggerInText(getBlockText(block), sel.anchor.offset, '@')
|
|
147
|
+
return hit ? { triggered: true, query: hit.query } : { triggered: false, query: '' }
|
|
162
148
|
}
|
|
@@ -10,6 +10,7 @@ import type { EditorState } from './operations.js'
|
|
|
10
10
|
import { changeBlockType, insertBlock, deleteSelection } from './operations.js'
|
|
11
11
|
import { createCollapsedSelection, createSelection, createPosition } from './selection.js'
|
|
12
12
|
import { findBlockById, getBlockText, cloneDocument, createTextSegment } from './model.js'
|
|
13
|
+
import { detectTriggerInText } from './trigger.js'
|
|
13
14
|
|
|
14
15
|
// ---------------------------------------------------------------------------
|
|
15
16
|
// Types
|
|
@@ -192,21 +193,6 @@ export function detectSlashTrigger(
|
|
|
192
193
|
const block = findBlockById(doc, sel.anchor.blockId)
|
|
193
194
|
if (!block) return { triggered: false, query: '' }
|
|
194
195
|
|
|
195
|
-
const
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
// Look for "/" preceded by start-of-text or a space
|
|
199
|
-
const slashIdx = textBeforeCursor.lastIndexOf('/')
|
|
200
|
-
if (slashIdx === -1) return { triggered: false, query: '' }
|
|
201
|
-
|
|
202
|
-
// The slash must be at position 0 or preceded by a space
|
|
203
|
-
if (slashIdx > 0 && textBeforeCursor[slashIdx - 1] !== ' ') {
|
|
204
|
-
return { triggered: false, query: '' }
|
|
205
|
-
}
|
|
206
|
-
|
|
207
|
-
const query = textBeforeCursor.slice(slashIdx + 1)
|
|
208
|
-
// If query contains spaces, the slash command is no longer active
|
|
209
|
-
if (query.includes(' ')) return { triggered: false, query: '' }
|
|
210
|
-
|
|
211
|
-
return { triggered: true, query }
|
|
196
|
+
const hit = detectTriggerInText(getBlockText(block), sel.anchor.offset, '/')
|
|
197
|
+
return hit ? { triggered: true, query: hit.query } : { triggered: false, query: '' }
|
|
212
198
|
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Generalized trigger detection shared by mentions, slash commands, and any
|
|
3
|
+
* symbol-prefixed autocomplete (e.g. the composer's trigger engine).
|
|
4
|
+
*
|
|
5
|
+
* One implementation exists so the boundary rules ("alice@example.com" never
|
|
6
|
+
* triggers, a query never spans whitespace) cannot drift between features.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
export interface TriggerHit {
|
|
10
|
+
triggered: boolean
|
|
11
|
+
query: string
|
|
12
|
+
/** Index of the trigger symbol's first character in `text`. */
|
|
13
|
+
start: number
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Scan backward from `caret` for the nearest occurrence of `triggerChar` that
|
|
18
|
+
* is preceded by start-of-text or whitespace and followed by a whitespace-free
|
|
19
|
+
* query running up to the caret. Returns `null` when no live trigger exists.
|
|
20
|
+
*
|
|
21
|
+
* `triggerChar` may be longer than one character (e.g. `!!`); the boundary is
|
|
22
|
+
* checked against the character before its first character.
|
|
23
|
+
*/
|
|
24
|
+
export function detectTriggerInText(
|
|
25
|
+
text: string,
|
|
26
|
+
caret: number,
|
|
27
|
+
triggerChar: string,
|
|
28
|
+
): TriggerHit | null {
|
|
29
|
+
if (triggerChar.length === 0) return null
|
|
30
|
+
const before = text.slice(0, caret)
|
|
31
|
+
const idx = before.lastIndexOf(triggerChar)
|
|
32
|
+
if (idx === -1) return null
|
|
33
|
+
// Boundary rule: only start-of-text or whitespace may precede the symbol,
|
|
34
|
+
// so mid-word occurrences (emails, "and/or") never arm.
|
|
35
|
+
if (idx > 0 && !/\s/.test(before[idx - 1])) return null
|
|
36
|
+
const query = before.slice(idx + triggerChar.length)
|
|
37
|
+
// A query never spans whitespace — a space closes the trigger.
|
|
38
|
+
if (/\s/.test(query)) return null
|
|
39
|
+
return { triggered: true, query, start: idx }
|
|
40
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@refraction-ui/astro",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.16.0",
|
|
4
4
|
"description": "All Refraction UI Astro components in one package",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
@@ -50,6 +50,7 @@
|
|
|
50
50
|
"@refraction-ui/astro-collapsible": "workspace:*",
|
|
51
51
|
"@refraction-ui/astro-command": "workspace:*",
|
|
52
52
|
"@refraction-ui/astro-command-input": "workspace:*",
|
|
53
|
+
"@refraction-ui/astro-composer": "workspace:*",
|
|
53
54
|
"@refraction-ui/astro-content-protection": "workspace:*",
|
|
54
55
|
"@refraction-ui/astro-conversation": "workspace:*",
|
|
55
56
|
"@refraction-ui/astro-cookie-consent": "workspace:*",
|