@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,288 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Token bookkeeping over the flat string (R15–R18).
|
|
3
|
+
*
|
|
4
|
+
* Committed tokens are atomic: an edit touching part of a token removes the
|
|
5
|
+
* whole token; an insertion strictly inside a token is rejected; the caret
|
|
6
|
+
* never rests strictly inside a token. Every function here is pure — the
|
|
7
|
+
* composer owns the state and feeds it through these rules on each mutation.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { detectEmojiShortcode } from '../rich-editor/index.ts'
|
|
11
|
+
import { clampGraphemes, graphemeLength } from './graphemes.js'
|
|
12
|
+
import type {
|
|
13
|
+
ComposerOutput,
|
|
14
|
+
ComposerSelection,
|
|
15
|
+
ComposerToken,
|
|
16
|
+
PlacedToken,
|
|
17
|
+
ResolvedToken,
|
|
18
|
+
} from './types.js'
|
|
19
|
+
|
|
20
|
+
// ---------------------------------------------------------------------------
|
|
21
|
+
// Diffing
|
|
22
|
+
// ---------------------------------------------------------------------------
|
|
23
|
+
|
|
24
|
+
export interface TextEdit {
|
|
25
|
+
/** Offset where old and new text diverge. */
|
|
26
|
+
start: number
|
|
27
|
+
/** End of the replaced range in the OLD text. */
|
|
28
|
+
oldEnd: number
|
|
29
|
+
/** End of the inserted range in the NEW text. */
|
|
30
|
+
newEnd: number
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Minimal single-span diff via common prefix/suffix. Null when texts are equal. */
|
|
34
|
+
export function diffEdit(oldText: string, newText: string): TextEdit | null {
|
|
35
|
+
if (oldText === newText) return null
|
|
36
|
+
const minLen = Math.min(oldText.length, newText.length)
|
|
37
|
+
let prefix = 0
|
|
38
|
+
while (prefix < minLen && oldText[prefix] === newText[prefix]) prefix++
|
|
39
|
+
let suffix = 0
|
|
40
|
+
while (
|
|
41
|
+
suffix < minLen - prefix &&
|
|
42
|
+
oldText[oldText.length - 1 - suffix] === newText[newText.length - 1 - suffix]
|
|
43
|
+
) {
|
|
44
|
+
suffix++
|
|
45
|
+
}
|
|
46
|
+
return { start: prefix, oldEnd: oldText.length - suffix, newEnd: newText.length - suffix }
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// ---------------------------------------------------------------------------
|
|
50
|
+
// Edit application with token atomicity
|
|
51
|
+
// ---------------------------------------------------------------------------
|
|
52
|
+
|
|
53
|
+
export interface AppliedEdit {
|
|
54
|
+
value: string
|
|
55
|
+
tokens: PlacedToken[]
|
|
56
|
+
selection: ComposerSelection
|
|
57
|
+
/** Set when an insertion strictly inside a token was rejected (value restored). */
|
|
58
|
+
rejected: boolean
|
|
59
|
+
/** True when maxLength clamping dropped part of the inserted text. */
|
|
60
|
+
trimmed: boolean
|
|
61
|
+
removedTokens: PlacedToken[]
|
|
62
|
+
/** The edit as actually applied to the old value (post token expansion), or null. */
|
|
63
|
+
edit: TextEdit | null
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export interface ApplyValueEditArgs {
|
|
67
|
+
oldValue: string
|
|
68
|
+
newValue: string
|
|
69
|
+
tokens: readonly PlacedToken[]
|
|
70
|
+
newSelection: ComposerSelection
|
|
71
|
+
/** Grapheme budget; the INSERTED slice is clamped to fit, never bisecting a cluster. */
|
|
72
|
+
maxLength?: number
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function nearestBoundary(offset: number, token: PlacedToken): number {
|
|
76
|
+
return offset - token.start <= token.end - offset ? token.start : token.end
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Reconcile a raw text edit (as reported by the adapter's input) with the
|
|
81
|
+
* committed token list. Deletions touching part of a token expand to the whole
|
|
82
|
+
* token; insertions strictly inside a token are rejected outright.
|
|
83
|
+
*/
|
|
84
|
+
export function applyValueEdit({
|
|
85
|
+
oldValue,
|
|
86
|
+
newValue,
|
|
87
|
+
tokens,
|
|
88
|
+
newSelection,
|
|
89
|
+
maxLength,
|
|
90
|
+
}: ApplyValueEditArgs): AppliedEdit {
|
|
91
|
+
const noChange: AppliedEdit = {
|
|
92
|
+
value: oldValue,
|
|
93
|
+
tokens: [...tokens],
|
|
94
|
+
selection: newSelection,
|
|
95
|
+
rejected: false,
|
|
96
|
+
trimmed: false,
|
|
97
|
+
removedTokens: [],
|
|
98
|
+
edit: null,
|
|
99
|
+
}
|
|
100
|
+
const diff = diffEdit(oldValue, newValue)
|
|
101
|
+
if (!diff) return noChange
|
|
102
|
+
|
|
103
|
+
const insertedRaw = newValue.slice(diff.start, diff.newEnd)
|
|
104
|
+
|
|
105
|
+
// Pure insertion strictly inside a token → reject, snap caret to a boundary.
|
|
106
|
+
if (diff.oldEnd === diff.start) {
|
|
107
|
+
const host = tokens.find((t) => t.start < diff.start && diff.start < t.end)
|
|
108
|
+
if (host) {
|
|
109
|
+
const caret = nearestBoundary(diff.start, host)
|
|
110
|
+
return { ...noChange, rejected: true, selection: { start: caret, end: caret } }
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Expand the replaced range over any partially/fully covered token (atomic delete).
|
|
115
|
+
let expStart = diff.start
|
|
116
|
+
let expOldEnd = diff.oldEnd
|
|
117
|
+
const removedTokens: PlacedToken[] = []
|
|
118
|
+
for (const token of tokens) {
|
|
119
|
+
const overlaps = token.start < expOldEnd && token.end > expStart
|
|
120
|
+
if (!overlaps) continue
|
|
121
|
+
removedTokens.push(token)
|
|
122
|
+
expStart = Math.min(expStart, token.start)
|
|
123
|
+
expOldEnd = Math.max(expOldEnd, token.end)
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Clamp the inserted slice against the grapheme budget of what remains.
|
|
127
|
+
let inserted = insertedRaw
|
|
128
|
+
let trimmed = false
|
|
129
|
+
if (maxLength !== undefined && inserted.length > 0) {
|
|
130
|
+
const baseLength =
|
|
131
|
+
graphemeLength(oldValue.slice(0, expStart)) + graphemeLength(oldValue.slice(expOldEnd))
|
|
132
|
+
const budget = Math.max(0, maxLength - baseLength)
|
|
133
|
+
if (graphemeLength(inserted) > budget) {
|
|
134
|
+
inserted = clampGraphemes(inserted, budget)
|
|
135
|
+
trimmed = true
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const value = oldValue.slice(0, expStart) + inserted + oldValue.slice(expOldEnd)
|
|
140
|
+
const delta = expStart + inserted.length - expOldEnd
|
|
141
|
+
const nextTokens = tokens
|
|
142
|
+
.filter((t) => !removedTokens.includes(t))
|
|
143
|
+
.map((t) => (t.start >= expOldEnd ? { ...t, start: t.start + delta, end: t.end + delta } : t))
|
|
144
|
+
|
|
145
|
+
// When the applied edit differs from the raw input (expansion/clamp), the
|
|
146
|
+
// reported selection can point past reality — collapse to end of insertion.
|
|
147
|
+
const appliedMatchesRaw = expStart === diff.start && expOldEnd === diff.oldEnd && !trimmed
|
|
148
|
+
const caret = expStart + inserted.length
|
|
149
|
+
const selection = appliedMatchesRaw ? newSelection : { start: caret, end: caret }
|
|
150
|
+
|
|
151
|
+
return {
|
|
152
|
+
value,
|
|
153
|
+
tokens: nextTokens,
|
|
154
|
+
selection: clampSelectionToValue(selection, value),
|
|
155
|
+
rejected: false,
|
|
156
|
+
trimmed,
|
|
157
|
+
removedTokens,
|
|
158
|
+
edit: { start: expStart, oldEnd: expOldEnd, newEnd: expStart + inserted.length },
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function clampSelectionToValue(selection: ComposerSelection, value: string): ComposerSelection {
|
|
163
|
+
const clamp = (n: number) => Math.max(0, Math.min(n, value.length))
|
|
164
|
+
return { start: clamp(selection.start), end: clamp(selection.end) }
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// ---------------------------------------------------------------------------
|
|
168
|
+
// Selection snapping
|
|
169
|
+
// ---------------------------------------------------------------------------
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Snap a selection so no endpoint rests strictly inside a token. A collapsed
|
|
173
|
+
* caret snaps in the direction of travel (so arrow keys skip a token as one
|
|
174
|
+
* unit); a range expands outward to the full token bounds.
|
|
175
|
+
*/
|
|
176
|
+
export function snapSelectionToTokens(
|
|
177
|
+
tokens: readonly PlacedToken[],
|
|
178
|
+
selection: ComposerSelection,
|
|
179
|
+
previous?: ComposerSelection,
|
|
180
|
+
): ComposerSelection {
|
|
181
|
+
const inside = (offset: number) =>
|
|
182
|
+
tokens.find((t) => t.start < offset && offset < t.end)
|
|
183
|
+
|
|
184
|
+
if (selection.start === selection.end) {
|
|
185
|
+
const host = inside(selection.start)
|
|
186
|
+
if (!host) return selection
|
|
187
|
+
let caret: number
|
|
188
|
+
if (previous && previous.start === previous.end && previous.start !== selection.start) {
|
|
189
|
+
caret = selection.start > previous.start ? host.end : host.start
|
|
190
|
+
} else {
|
|
191
|
+
caret = nearestBoundary(selection.start, host)
|
|
192
|
+
}
|
|
193
|
+
return { start: caret, end: caret }
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const startHost = inside(selection.start)
|
|
197
|
+
const endHost = inside(selection.end)
|
|
198
|
+
return {
|
|
199
|
+
start: startHost ? startHost.start : selection.start,
|
|
200
|
+
end: endHost ? endHost.end : selection.end,
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/** Expand a range outward so it covers any partially-included token whole. */
|
|
205
|
+
export function expandRangeOverTokens(
|
|
206
|
+
tokens: readonly PlacedToken[],
|
|
207
|
+
range: ComposerSelection,
|
|
208
|
+
): ComposerSelection {
|
|
209
|
+
let { start, end } = range
|
|
210
|
+
for (const token of tokens) {
|
|
211
|
+
if (token.start < end && token.end > start) {
|
|
212
|
+
start = Math.min(start, token.start)
|
|
213
|
+
end = Math.max(end, token.end)
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
return { start, end }
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// ---------------------------------------------------------------------------
|
|
220
|
+
// Commit / serialize
|
|
221
|
+
// ---------------------------------------------------------------------------
|
|
222
|
+
|
|
223
|
+
export interface CommitTokenArgs {
|
|
224
|
+
value: string
|
|
225
|
+
tokens: readonly PlacedToken[]
|
|
226
|
+
/** Replaced range: `[start, end)` — symbol + query for a trigger commit. */
|
|
227
|
+
start: number
|
|
228
|
+
end: number
|
|
229
|
+
token: ComposerToken
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
export interface CommitTokenResult {
|
|
233
|
+
value: string
|
|
234
|
+
tokens: PlacedToken[]
|
|
235
|
+
selection: ComposerSelection
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/** Replace `[start, end)` with the token's display in one atomic transaction. */
|
|
239
|
+
export function commitTokenAt({ value, tokens, start, end, token }: CommitTokenArgs): CommitTokenResult {
|
|
240
|
+
const display = token.display
|
|
241
|
+
const nextValue = value.slice(0, start) + display + value.slice(end)
|
|
242
|
+
const delta = display.length - (end - start)
|
|
243
|
+
const placed: PlacedToken = { ...token, start, end: start + display.length }
|
|
244
|
+
const shifted = tokens.map((t) =>
|
|
245
|
+
t.start >= end ? { ...t, start: t.start + delta, end: t.end + delta } : t,
|
|
246
|
+
)
|
|
247
|
+
const nextTokens = [...shifted, placed].sort((a, b) => a.start - b.start)
|
|
248
|
+
const caret = placed.end
|
|
249
|
+
return { value: nextValue, tokens: nextTokens, selection: { start: caret, end: caret } }
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* Structured output: plainText inlines every display; ranges are derived and
|
|
254
|
+
* guaranteed in sync (`plainText.substring(start, end) === display`).
|
|
255
|
+
*/
|
|
256
|
+
export function serializeTokens(value: string, tokens: readonly PlacedToken[]): ComposerOutput {
|
|
257
|
+
const resolved: ResolvedToken[] = tokens.map((t) => ({
|
|
258
|
+
type: t.triggerId,
|
|
259
|
+
id: t.id,
|
|
260
|
+
display: t.display,
|
|
261
|
+
start: t.start,
|
|
262
|
+
end: t.end,
|
|
263
|
+
}))
|
|
264
|
+
return { plainText: value, tokens: resolved }
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// ---------------------------------------------------------------------------
|
|
268
|
+
// Direct-typed emoji commit (R18)
|
|
269
|
+
// ---------------------------------------------------------------------------
|
|
270
|
+
|
|
271
|
+
export interface TypedEmojiHit {
|
|
272
|
+
shortcode: string
|
|
273
|
+
unicode: string
|
|
274
|
+
/** Offset of the opening colon. */
|
|
275
|
+
start: number
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/**
|
|
279
|
+
* Detect a just-completed `:shortcode:` immediately before the caret using
|
|
280
|
+
* rich-editor's EMOJI_MAP (via detectEmojiShortcode). Unknown shortcodes stay
|
|
281
|
+
* literal; the composer converts a hit into a committed token without the
|
|
282
|
+
* menu ever opening.
|
|
283
|
+
*/
|
|
284
|
+
export function detectTypedEmoji(value: string, caret: number): TypedEmojiHit | null {
|
|
285
|
+
const hit = detectEmojiShortcode(value.slice(0, caret))
|
|
286
|
+
if (!hit) return null
|
|
287
|
+
return { shortcode: hit.shortcode, unicode: hit.unicode, start: caret - hit.shortcode.length }
|
|
288
|
+
}
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Symbol-agnostic trigger detection (R10–R12).
|
|
3
|
+
*
|
|
4
|
+
* Detection runs once per mutation, scanning backward from the caret within a
|
|
5
|
+
* bounded window — cost is independent of how much text precedes the trigger.
|
|
6
|
+
* The simple single-char/anywhere case delegates to rich-editor's shared
|
|
7
|
+
* `detectTriggerInText` so the boundary rules live in exactly one place; the
|
|
8
|
+
* general path (multi-char symbols, scopes, extra boundary chars,
|
|
9
|
+
* closeOnSpace=false) layers on top of the same candidate/validation split.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { detectTriggerInText } from '../rich-editor/index.ts'
|
|
13
|
+
import { graphemeLength } from './graphemes.js'
|
|
14
|
+
import type { ComposerTrigger, ComposerTriggerScope, ComposerCandidate } from './types.js'
|
|
15
|
+
|
|
16
|
+
// ---------------------------------------------------------------------------
|
|
17
|
+
// Resolved config (defaults applied once)
|
|
18
|
+
// ---------------------------------------------------------------------------
|
|
19
|
+
|
|
20
|
+
export const DEFAULT_MAX_QUERY_LENGTH = 40
|
|
21
|
+
export const DEFAULT_MAX_VISIBLE_RESULTS = 6
|
|
22
|
+
/**
|
|
23
|
+
* UTF-16 budget per grapheme when sizing the backward-scan window. The widest
|
|
24
|
+
* common cluster (ZWJ family emoji) is 11 units; 16 leaves headroom without
|
|
25
|
+
* unbounding the scan.
|
|
26
|
+
*/
|
|
27
|
+
export const SCAN_UNITS_PER_GRAPHEME = 16
|
|
28
|
+
|
|
29
|
+
export interface ResolvedTrigger {
|
|
30
|
+
id: string
|
|
31
|
+
symbol: string
|
|
32
|
+
scope: ComposerTriggerScope
|
|
33
|
+
queryPattern: RegExp | null
|
|
34
|
+
maxQueryLength: number
|
|
35
|
+
closeOnSpace: boolean
|
|
36
|
+
allowMidWord: boolean
|
|
37
|
+
extraBoundaryChars: readonly string[]
|
|
38
|
+
debounceMs: number
|
|
39
|
+
maxVisibleResults: number
|
|
40
|
+
wrapNavigation: boolean
|
|
41
|
+
toDisplay: (candidate: ComposerCandidate) => string
|
|
42
|
+
resolve: ComposerTrigger['resolve']
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function resolveTriggerConfig(trigger: ComposerTrigger): ResolvedTrigger {
|
|
46
|
+
const symbol = trigger.symbol
|
|
47
|
+
return {
|
|
48
|
+
id: trigger.id,
|
|
49
|
+
symbol,
|
|
50
|
+
scope: trigger.scope ?? 'anywhere',
|
|
51
|
+
queryPattern: trigger.queryPattern ?? null,
|
|
52
|
+
maxQueryLength: trigger.maxQueryLength ?? DEFAULT_MAX_QUERY_LENGTH,
|
|
53
|
+
closeOnSpace: trigger.closeOnSpace ?? true,
|
|
54
|
+
allowMidWord: trigger.allowMidWord ?? false,
|
|
55
|
+
extraBoundaryChars: trigger.extraBoundaryChars ?? [],
|
|
56
|
+
debounceMs: trigger.debounceMs ?? 0,
|
|
57
|
+
maxVisibleResults: trigger.maxVisibleResults ?? DEFAULT_MAX_VISIBLE_RESULTS,
|
|
58
|
+
wrapNavigation: trigger.wrapNavigation ?? true,
|
|
59
|
+
toDisplay: trigger.toDisplay ?? ((candidate) => `${symbol}${candidate.display}`),
|
|
60
|
+
resolve: trigger.resolve,
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** UTF-16 size of the backward-scan window for a trigger (exported for tests). */
|
|
65
|
+
export function scanWindowFor(trigger: ResolvedTrigger): number {
|
|
66
|
+
return trigger.maxQueryLength * SCAN_UNITS_PER_GRAPHEME + trigger.symbol.length
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// ---------------------------------------------------------------------------
|
|
70
|
+
// Detection
|
|
71
|
+
// ---------------------------------------------------------------------------
|
|
72
|
+
|
|
73
|
+
export interface TriggerMatch {
|
|
74
|
+
trigger: ResolvedTrigger
|
|
75
|
+
symbolStart: number
|
|
76
|
+
caret: number
|
|
77
|
+
query: string
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** An occurrence dismissed via Escape — never re-arms until the symbol is retyped. */
|
|
81
|
+
export interface DismissedOccurrence {
|
|
82
|
+
triggerId: string
|
|
83
|
+
symbolStart: number
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export interface TokenRange {
|
|
87
|
+
start: number
|
|
88
|
+
end: number
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export interface DetectTriggerArgs {
|
|
92
|
+
text: string
|
|
93
|
+
caret: number
|
|
94
|
+
triggers: readonly ResolvedTrigger[]
|
|
95
|
+
isComposing?: boolean
|
|
96
|
+
dismissed?: readonly DismissedOccurrence[]
|
|
97
|
+
tokenRanges?: readonly TokenRange[]
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function isWhitespace(ch: string): boolean {
|
|
101
|
+
return /\s/.test(ch)
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function boundaryOk(text: string, symbolStart: number, trigger: ResolvedTrigger): boolean {
|
|
105
|
+
if (trigger.allowMidWord) return true
|
|
106
|
+
if (symbolStart === 0) return true
|
|
107
|
+
const prev = text[symbolStart - 1]
|
|
108
|
+
return isWhitespace(prev) || trigger.extraBoundaryChars.includes(prev)
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function scopeOk(text: string, symbolStart: number, scope: ComposerTriggerScope): boolean {
|
|
112
|
+
if (scope === 'anywhere') return true
|
|
113
|
+
if (scope === 'startOfMessage') return symbolStart === 0
|
|
114
|
+
// startOfLine
|
|
115
|
+
return symbolStart === 0 || text[symbolStart - 1] === '\n'
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** Nearest-occurrence candidate within the bounded scan window, or null. */
|
|
119
|
+
function findCandidate(
|
|
120
|
+
text: string,
|
|
121
|
+
caret: number,
|
|
122
|
+
trigger: ResolvedTrigger,
|
|
123
|
+
): { symbolStart: number; query: string } | null {
|
|
124
|
+
const window = scanWindowFor(trigger)
|
|
125
|
+
const isSimple =
|
|
126
|
+
trigger.symbol.length === 1 &&
|
|
127
|
+
trigger.scope === 'anywhere' &&
|
|
128
|
+
trigger.closeOnSpace &&
|
|
129
|
+
!trigger.allowMidWord &&
|
|
130
|
+
trigger.extraBoundaryChars.length === 0
|
|
131
|
+
|
|
132
|
+
if (isSimple) {
|
|
133
|
+
// Include one context char so the shared detector sees the boundary; a
|
|
134
|
+
// symbol landing exactly on the slice edge is re-validated below anyway.
|
|
135
|
+
const sliceStart = Math.max(0, caret - window - 1)
|
|
136
|
+
const slice = text.slice(sliceStart, caret)
|
|
137
|
+
const hit = detectTriggerInText(slice, slice.length, trigger.symbol)
|
|
138
|
+
if (!hit) return null
|
|
139
|
+
return { symbolStart: sliceStart + hit.start, query: hit.query }
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const sliceStart = Math.max(0, caret - window)
|
|
143
|
+
const rel = text.slice(sliceStart, caret).lastIndexOf(trigger.symbol)
|
|
144
|
+
if (rel === -1) return null
|
|
145
|
+
const symbolStart = sliceStart + rel
|
|
146
|
+
// The symbol must be fully typed before the caret (multi-char symbols arm
|
|
147
|
+
// only once complete — C13).
|
|
148
|
+
if (symbolStart + trigger.symbol.length > caret) return null
|
|
149
|
+
return { symbolStart, query: text.slice(symbolStart + trigger.symbol.length, caret) }
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function validateCandidate(
|
|
153
|
+
text: string,
|
|
154
|
+
caret: number,
|
|
155
|
+
trigger: ResolvedTrigger,
|
|
156
|
+
candidate: { symbolStart: number; query: string },
|
|
157
|
+
dismissed: readonly DismissedOccurrence[],
|
|
158
|
+
tokenRanges: readonly TokenRange[],
|
|
159
|
+
): TriggerMatch | null {
|
|
160
|
+
const { symbolStart, query } = candidate
|
|
161
|
+
if (!boundaryOk(text, symbolStart, trigger)) return null
|
|
162
|
+
if (!scopeOk(text, symbolStart, trigger.scope)) return null
|
|
163
|
+
// Never arm inside a committed token's range (its display may contain the symbol).
|
|
164
|
+
for (const range of tokenRanges) {
|
|
165
|
+
if (symbolStart >= range.start && symbolStart < range.end) return null
|
|
166
|
+
}
|
|
167
|
+
if (dismissed.some((d) => d.triggerId === trigger.id && d.symbolStart === symbolStart)) return null
|
|
168
|
+
if (trigger.closeOnSpace && /\s/.test(query)) return null
|
|
169
|
+
// Queries are line-local even when spaces are allowed ('#weekend trip').
|
|
170
|
+
if (!trigger.closeOnSpace && query.includes('\n')) return null
|
|
171
|
+
if (graphemeLength(query) > trigger.maxQueryLength) return null
|
|
172
|
+
if (trigger.queryPattern && !trigger.queryPattern.test(query)) return null
|
|
173
|
+
return { trigger, symbolStart, caret, query }
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Backward-scan detection from the caret. Returns the nearest live trigger
|
|
178
|
+
* (only the last unescaped occurrence before the caret can be live), or null.
|
|
179
|
+
* Suspended entirely while an IME composition is active.
|
|
180
|
+
*/
|
|
181
|
+
export function detectActiveTrigger({
|
|
182
|
+
text,
|
|
183
|
+
caret,
|
|
184
|
+
triggers,
|
|
185
|
+
isComposing = false,
|
|
186
|
+
dismissed = [],
|
|
187
|
+
tokenRanges = [],
|
|
188
|
+
}: DetectTriggerArgs): TriggerMatch | null {
|
|
189
|
+
if (isComposing) return null
|
|
190
|
+
let best: TriggerMatch | null = null
|
|
191
|
+
for (const trigger of triggers) {
|
|
192
|
+
const candidate = findCandidate(text, caret, trigger)
|
|
193
|
+
if (!candidate) continue
|
|
194
|
+
const match = validateCandidate(text, caret, trigger, candidate, dismissed, tokenRanges)
|
|
195
|
+
if (!match) continue
|
|
196
|
+
if (
|
|
197
|
+
!best ||
|
|
198
|
+
match.symbolStart > best.symbolStart ||
|
|
199
|
+
(match.symbolStart === best.symbolStart && match.trigger.symbol.length > best.trigger.symbol.length)
|
|
200
|
+
) {
|
|
201
|
+
best = match
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
return best
|
|
205
|
+
}
|