@refraction-ui/astro 0.15.2 → 0.16.1

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.
@@ -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
+ }