@adea-ai/ui 0.68.2 → 0.69.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.
Files changed (46) hide show
  1. package/README.md +79 -15
  2. package/dist/NOTICE +55 -24
  3. package/dist/components/conversation/atomic/chat-composer.d.ts +20 -0
  4. package/dist/components/conversation/atomic/chat-composer.d.ts.map +1 -0
  5. package/dist/components/conversation/atomic/chat-composer.js +69 -0
  6. package/dist/components/conversation/atomic/chat-composer.js.map +1 -0
  7. package/dist/components/conversation/atomic/index.d.ts +3 -0
  8. package/dist/components/conversation/atomic/index.d.ts.map +1 -0
  9. package/dist/components/conversation/atomic/index.js +2 -0
  10. package/dist/components/conversation/chat-composer.d.ts +12 -21
  11. package/dist/components/conversation/chat-composer.d.ts.map +1 -1
  12. package/dist/components/conversation/chat-composer.js +33 -403
  13. package/dist/components/conversation/chat-composer.js.map +1 -1
  14. package/dist/components/conversation/internal/chat-composer-shell.d.ts +38 -0
  15. package/dist/components/conversation/internal/chat-composer-shell.d.ts.map +1 -0
  16. package/dist/components/conversation/internal/chat-composer-shell.js +423 -0
  17. package/dist/components/conversation/internal/chat-composer-shell.js.map +1 -0
  18. package/dist/components/conversation/paste-preview.d.ts +11 -0
  19. package/dist/components/conversation/paste-preview.d.ts.map +1 -0
  20. package/dist/components/conversation/paste-preview.js +28 -0
  21. package/dist/components/conversation/paste-preview.js.map +1 -0
  22. package/dist/components/conversation/paste-token-editor.d.ts +26 -0
  23. package/dist/components/conversation/paste-token-editor.d.ts.map +1 -0
  24. package/dist/components/conversation/paste-token-editor.js +570 -0
  25. package/dist/components/conversation/paste-token-editor.js.map +1 -0
  26. package/dist/components/conversation/paste-tokens.d.ts.map +1 -1
  27. package/dist/components/conversation/paste-tokens.js +11 -2
  28. package/dist/components/conversation/paste-tokens.js.map +1 -1
  29. package/dist/r/conversation.json +26 -0
  30. package/dist/r/registry.json +26 -0
  31. package/dist/r/src/components/conversation/atomic/chat-composer.tsx +57 -0
  32. package/dist/r/src/components/conversation/atomic/index.ts +6 -0
  33. package/dist/r/src/components/conversation/chat-composer.tsx +39 -331
  34. package/dist/r/src/components/conversation/internal/chat-composer-shell.tsx +390 -0
  35. package/dist/r/src/components/conversation/paste-preview.ts +35 -0
  36. package/dist/r/src/components/conversation/paste-token-editor.tsx +672 -0
  37. package/dist/r/src/components/conversation/paste-tokens.ts +15 -2
  38. package/package.json +1 -1
  39. package/registry.json +26 -0
  40. package/src/components/conversation/atomic/chat-composer.tsx +57 -0
  41. package/src/components/conversation/atomic/index.ts +6 -0
  42. package/src/components/conversation/chat-composer.tsx +39 -331
  43. package/src/components/conversation/internal/chat-composer-shell.tsx +390 -0
  44. package/src/components/conversation/paste-preview.ts +35 -0
  45. package/src/components/conversation/paste-token-editor.tsx +672 -0
  46. package/src/components/conversation/paste-tokens.ts +15 -2
@@ -0,0 +1,672 @@
1
+ /*
2
+ * Substantially translated from KiroCrew website/src/components/ChatInput.tsx
3
+ * and PasteHighlightLayer.tsx at 283e136c0f902e965a535a7c9548c57c7504fed0.
4
+ * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
5
+ * Licensed under Apache-2.0; see LICENSE and NOTICE.
6
+ */
7
+ import {
8
+ For,
9
+ Show,
10
+ createEffect,
11
+ createMemo,
12
+ createSignal,
13
+ createUniqueId,
14
+ on,
15
+ onCleanup,
16
+ onMount,
17
+ } from 'solid-js'
18
+ import {
19
+ countLines,
20
+ findTokenRanges,
21
+ formatToken,
22
+ isPasteBlock,
23
+ nextSeq,
24
+ pruneBlocks,
25
+ shouldCollapse,
26
+ stripTrailingBlankLines,
27
+ tokenRangeAt,
28
+ type PasteBlock,
29
+ } from './paste-tokens'
30
+ import { summarizePastePreview } from './paste-preview'
31
+ import { Tooltip as KobalteTooltip } from '@kobalte/core/tooltip'
32
+
33
+ export type PasteTokenDraft = { text: string; blocks: PasteBlock[] }
34
+ type TokenRange = { start: number; end: number; block: PasteBlock }
35
+ type Segment = { text: string; block?: PasteBlock }
36
+
37
+ export type PasteTokenEditorProps = {
38
+ value: string
39
+ resetKey?: unknown
40
+ blocks: readonly PasteBlock[]
41
+ ref: (element: HTMLTextAreaElement | undefined) => void
42
+ createBlockId: () => string
43
+ onChange: (draft: PasteTokenDraft) => void
44
+ inputLabel: string
45
+ placeholder: string
46
+ disabled?: boolean
47
+ readOnly?: boolean
48
+ composing: () => boolean
49
+ onCompositionStart: () => void
50
+ onCompositionEnd: () => void
51
+ onFocus: () => void
52
+ onBlur: () => void
53
+ onKeyDown: (event: KeyboardEvent) => void
54
+ }
55
+
56
+ const PREVIEW_OPEN_DELAY_MS = 300
57
+
58
+ function isTouchDevice(): boolean {
59
+ return (
60
+ typeof window !== 'undefined' &&
61
+ typeof window.matchMedia === 'function' &&
62
+ (window.matchMedia('(pointer: coarse)').matches || window.matchMedia('(hover: none)').matches)
63
+ )
64
+ }
65
+
66
+ function isBlockquotePrefix(linePrefix: string): boolean {
67
+ let sawMarker = false
68
+ for (let index = 0; index < linePrefix.length; index += 1) {
69
+ const code = linePrefix.charCodeAt(index)
70
+ if (code === 62) sawMarker = true
71
+ else if (code !== 32 && code !== 9) return false
72
+ }
73
+ return sawMarker
74
+ }
75
+
76
+ /** Controlled plain-text editor that treats known paste markers as atomic ranges. */
77
+ export function PasteTokenEditor(props: PasteTokenEditorProps) {
78
+ const [preview, setPreview] = createSignal<PasteBlock | null>(null)
79
+ const previewSummary = createMemo(() => {
80
+ const block = preview()
81
+ return block ? summarizePastePreview(block.content, block.lines) : null
82
+ })
83
+ const descriptionId = `paste-preview-${createUniqueId()}`
84
+ const keyboardHintId = `paste-keyboard-hint-${createUniqueId()}`
85
+ let field: HTMLTextAreaElement | undefined
86
+ let mirror: HTMLDivElement | undefined
87
+ let previewTimer: ReturnType<typeof setTimeout> | undefined
88
+ let scheduledBlockId: string | null = null
89
+ let rawPasteNext = false
90
+ let suppressPasteInput = false
91
+ let nativePasteInputSeen = false
92
+ let keyboardNavigationPending = false
93
+ let acceptingTextDrop = false
94
+ let snappedCaretPreview: { blockId: string; position: number } | null = null
95
+
96
+ const ranges = () => findTokenRanges(props.value, props.blocks)
97
+ const clearTimer = () => {
98
+ clearTimeout(previewTimer)
99
+ previewTimer = undefined
100
+ }
101
+ const closePreview = () => {
102
+ clearTimer()
103
+ scheduledBlockId = null
104
+ snappedCaretPreview = null
105
+ setPreview(null)
106
+ }
107
+ const schedulePreview = (block: PasteBlock) => {
108
+ if (scheduledBlockId === block.id) return
109
+ clearTimer()
110
+ scheduledBlockId = block.id
111
+ setPreview(null)
112
+ previewTimer = setTimeout(() => {
113
+ previewTimer = undefined
114
+ const current = ranges().find((range) => range.block.id === block.id)
115
+ if (current) setPreview(current.block)
116
+ else closePreview()
117
+ }, PREVIEW_OPEN_DELAY_MS)
118
+ }
119
+ const change = (text: string, blocks: readonly PasteBlock[] = props.blocks) => {
120
+ props.onChange({ text, blocks: pruneBlocks(text, [...blocks]) })
121
+ }
122
+ const snapToTokenEdge = (
123
+ position: number,
124
+ tokenRanges: readonly TokenRange[] = ranges(),
125
+ edge?: 'start' | 'end'
126
+ ) => {
127
+ for (const range of tokenRanges) {
128
+ if (position > range.start && position < range.end) {
129
+ if (edge === 'start') return range.start
130
+ if (edge === 'end') return range.end
131
+ return position - range.start <= range.end - position ? range.start : range.end
132
+ }
133
+ }
134
+ return position
135
+ }
136
+ const setCaret = (position: number, focus = false) => {
137
+ requestAnimationFrame(() => {
138
+ if (!field) return
139
+ if (focus) field.focus()
140
+ field.setSelectionRange(position, position)
141
+ })
142
+ }
143
+ const replaceRange = (start: number, end: number) => {
144
+ if (!field) return
145
+ const removed = ranges().filter((range) => range.start < end && range.end > start)
146
+ if (!removed.length) return
147
+ const nextStart = Math.min(start, ...removed.map((range) => range.start))
148
+ const nextEnd = Math.max(end, ...removed.map((range) => range.end))
149
+ const next = props.value.slice(0, nextStart) + props.value.slice(nextEnd)
150
+ change(
151
+ next,
152
+ props.blocks.filter((block) => !removed.some((range) => range.block.id === block.id))
153
+ )
154
+ setCaret(nextStart)
155
+ }
156
+ const expandToken = (range: TokenRange) => {
157
+ const next =
158
+ props.value.slice(0, range.start) + range.block.content + props.value.slice(range.end)
159
+ change(
160
+ next,
161
+ props.blocks.filter((block) => block.id !== range.block.id)
162
+ )
163
+ closePreview()
164
+ setCaret(range.start + range.block.content.length, true)
165
+ }
166
+
167
+ const segments = (): Segment[] => {
168
+ const result: Segment[] = []
169
+ let cursor = 0
170
+ for (const range of ranges()) {
171
+ if (range.start > cursor) result.push({ text: props.value.slice(cursor, range.start) })
172
+ result.push({ text: props.value.slice(range.start, range.end), block: range.block })
173
+ cursor = range.end
174
+ }
175
+ if (cursor < props.value.length) result.push({ text: props.value.slice(cursor) })
176
+ if (!result.length) result.push({ text: props.value })
177
+ return result
178
+ }
179
+
180
+ const scheduleCaretPreview = () => {
181
+ if (isTouchDevice() || props.composing() || !field) {
182
+ closePreview()
183
+ return
184
+ }
185
+ if (field.selectionStart !== field.selectionEnd) {
186
+ closePreview()
187
+ return
188
+ }
189
+ const caret = field.selectionStart
190
+ const range = ranges().find((candidate) => caret > candidate.start && caret < candidate.end)
191
+ if (range) {
192
+ schedulePreview(range.block)
193
+ return
194
+ }
195
+ const snappedRange =
196
+ snappedCaretPreview?.position === caret
197
+ ? ranges().find((candidate) => candidate.block.id === snappedCaretPreview?.blockId)
198
+ : undefined
199
+ if (snappedRange) schedulePreview(snappedRange.block)
200
+ else closePreview()
201
+ }
202
+
203
+ const openKeyboardPreview = (): boolean => {
204
+ if (!field) return false
205
+ const start = field.selectionStart
206
+ const end = field.selectionEnd
207
+ const range = ranges().find((candidate) =>
208
+ start === end
209
+ ? start >= candidate.start && start <= candidate.end
210
+ : candidate.start < end && candidate.end > start
211
+ )
212
+ if (!range) return false
213
+ clearTimer()
214
+ scheduledBlockId = range.block.id
215
+ setPreview(range.block)
216
+ return true
217
+ }
218
+
219
+ const schedulePointerPreview = (event: MouseEvent) => {
220
+ if (isTouchDevice() || props.composing() || event.buttons !== 0 || !mirror) {
221
+ closePreview()
222
+ return
223
+ }
224
+ for (const span of mirror.querySelectorAll<HTMLElement>('[data-paste-seq]')) {
225
+ const rect = span.getBoundingClientRect()
226
+ if (
227
+ event.clientX >= rect.left &&
228
+ event.clientX <= rect.right &&
229
+ event.clientY >= rect.top &&
230
+ event.clientY <= rect.bottom
231
+ ) {
232
+ const sequence = Number(span.dataset.pasteSeq)
233
+ const range = ranges().find((candidate) => candidate.block.seq === sequence)
234
+ if (range) schedulePreview(range.block)
235
+ else closePreview()
236
+ return
237
+ }
238
+ }
239
+ closePreview()
240
+ }
241
+
242
+ const snapSelection = () => {
243
+ if (!field || props.composing() || acceptingTextDrop) return
244
+ const start = field.selectionStart
245
+ const end = field.selectionEnd
246
+ const direction = field.selectionDirection
247
+ const tokenRanges = ranges()
248
+ if (start === end) {
249
+ if (keyboardNavigationPending) return
250
+ scheduleCaretPreview()
251
+ const next = snapToTokenEdge(start, tokenRanges)
252
+ if (next !== start) {
253
+ const range = tokenRanges.find(
254
+ (candidate) => start > candidate.start && start < candidate.end
255
+ )
256
+ if (range) snappedCaretPreview = { blockId: range.block.id, position: next }
257
+ field.setSelectionRange(next, next, direction)
258
+ }
259
+ return
260
+ }
261
+ scheduleCaretPreview()
262
+ const nextStart = snapToTokenEdge(start, tokenRanges)
263
+ const nextEnd = snapToTokenEdge(end, tokenRanges)
264
+ if (nextStart !== start || nextEnd !== end) {
265
+ field.setSelectionRange(Math.min(nextStart, nextEnd), Math.max(nextStart, nextEnd), direction)
266
+ }
267
+ }
268
+
269
+ const handleKeyDown = (event: KeyboardEvent) => {
270
+ snappedCaretPreview = null
271
+ rawPasteNext =
272
+ (event.metaKey || event.ctrlKey) &&
273
+ event.shiftKey &&
274
+ !event.altKey &&
275
+ event.key.toLowerCase() === 'v'
276
+ if (props.composing() || event.isComposing || event.keyCode === 229 || !field) {
277
+ props.onKeyDown(event)
278
+ return
279
+ }
280
+ if (
281
+ event.key === 'ArrowDown' &&
282
+ event.altKey &&
283
+ !event.metaKey &&
284
+ !event.ctrlKey &&
285
+ !event.shiftKey &&
286
+ openKeyboardPreview()
287
+ ) {
288
+ event.preventDefault()
289
+ props.onKeyDown(event)
290
+ return
291
+ }
292
+ if (event.key === 'Escape' && preview()) closePreview()
293
+
294
+ const value = props.value
295
+ const start = field.selectionStart
296
+ const end = field.selectionEnd
297
+ const collapsed = start === end
298
+ const tokenRanges = ranges()
299
+ const plainModifier = !event.metaKey && !event.ctrlKey && !event.altKey
300
+ const backspace = event.key === 'Backspace'
301
+ const deletion = backspace || event.key === 'Delete'
302
+ let remove: readonly [number, number] | undefined
303
+
304
+ if (deletion && !collapsed) {
305
+ if (tokenRanges.some((range) => range.start < end && range.end > start)) remove = [start, end]
306
+ } else if (deletion) {
307
+ const adjacent = tokenRanges.find((range) =>
308
+ backspace ? range.end === start : range.start === start
309
+ )
310
+ if (adjacent && (plainModifier || event.altKey || event.ctrlKey)) {
311
+ remove = [adjacent.start, adjacent.end]
312
+ } else if (event.metaKey) {
313
+ const boundary = backspace
314
+ ? value.lastIndexOf('\n', start - 1) + 1
315
+ : value.indexOf('\n', start)
316
+ const from = backspace ? boundary : start
317
+ const to = backspace ? start : boundary < 0 ? value.length : boundary
318
+ if (tokenRanges.some((range) => range.start < to && range.end > from)) remove = [from, to]
319
+ }
320
+ }
321
+ if (remove) {
322
+ event.preventDefault()
323
+ replaceRange(remove[0], remove[1])
324
+ props.onKeyDown(event)
325
+ return
326
+ }
327
+
328
+ if (plainModifier && collapsed && !event.shiftKey) {
329
+ const left = event.key === 'ArrowLeft'
330
+ const right = event.key === 'ArrowRight'
331
+ const adjacent = left
332
+ ? tokenRanges.find((range) => range.end === start)
333
+ : right
334
+ ? tokenRanges.find((range) => range.start === start)
335
+ : undefined
336
+ if (adjacent) {
337
+ event.preventDefault()
338
+ setCaret(left ? adjacent.start : adjacent.end)
339
+ props.onKeyDown(event)
340
+ return
341
+ }
342
+ }
343
+ if (!event.metaKey && !event.ctrlKey && !event.altKey && event.shiftKey) {
344
+ const direction = field.selectionDirection === 'backward' ? 'backward' : 'forward'
345
+ const active = direction === 'backward' ? start : end
346
+ const adjacent =
347
+ event.key === 'ArrowLeft'
348
+ ? tokenRanges.find((range) => range.end === active)
349
+ : event.key === 'ArrowRight'
350
+ ? tokenRanges.find((range) => range.start === active)
351
+ : undefined
352
+ if (adjacent) {
353
+ event.preventDefault()
354
+ requestAnimationFrame(() => {
355
+ if (!field) return
356
+ if (event.key === 'ArrowLeft' && direction === 'backward')
357
+ field.setSelectionRange(adjacent.start, end, 'backward')
358
+ else if (event.key === 'ArrowLeft')
359
+ field.setSelectionRange(
360
+ start,
361
+ adjacent.start,
362
+ start <= adjacent.start ? 'forward' : 'backward'
363
+ )
364
+ else if (direction === 'backward')
365
+ field.setSelectionRange(adjacent.end, end, adjacent.end <= end ? 'backward' : 'forward')
366
+ else field.setSelectionRange(start, adjacent.end, 'forward')
367
+ })
368
+ props.onKeyDown(event)
369
+ return
370
+ }
371
+ }
372
+
373
+ const navigation =
374
+ event.key === 'Home' ||
375
+ event.key === 'End' ||
376
+ ((event.key === 'ArrowLeft' || event.key === 'ArrowRight') &&
377
+ (event.metaKey || event.ctrlKey || event.altKey))
378
+ if (navigation) {
379
+ const leftward = event.key === 'ArrowLeft' || event.key === 'Home'
380
+ keyboardNavigationPending = true
381
+ requestAnimationFrame(() => {
382
+ if (field) {
383
+ const nextRanges = findTokenRanges(field.value, props.blocks)
384
+ const edge = leftward ? 'start' : 'end'
385
+ const nextStart = snapToTokenEdge(field.selectionStart, nextRanges, edge)
386
+ const nextEnd = snapToTokenEdge(field.selectionEnd, nextRanges, edge)
387
+ if (nextStart !== field.selectionStart || nextEnd !== field.selectionEnd) {
388
+ field.setSelectionRange(
389
+ Math.min(nextStart, nextEnd),
390
+ Math.max(nextStart, nextEnd),
391
+ field.selectionDirection
392
+ )
393
+ }
394
+ }
395
+ keyboardNavigationPending = false
396
+ })
397
+ }
398
+ props.onKeyDown(event)
399
+ }
400
+
401
+ const expandSelectionForClipboard = (start: number, end: number): string | null => {
402
+ if (start === end) return null
403
+ const covered = ranges().filter((range) => range.start >= start && range.end <= end)
404
+ if (!covered.length) return null
405
+ let result = ''
406
+ let cursor = start
407
+ for (const range of covered) {
408
+ result += props.value.slice(cursor, range.start) + range.block.content
409
+ cursor = range.end
410
+ }
411
+ return result + props.value.slice(cursor, end)
412
+ }
413
+
414
+ const handlePaste = (event: ClipboardEvent) => {
415
+ const forceRaw = rawPasteNext
416
+ rawPasteNext = false
417
+ const pasted = event.clipboardData?.getData('text/plain') ?? ''
418
+ if (!pasted || !field || props.readOnly || props.disabled) return
419
+ const cleaned = forceRaw ? pasted : stripTrailingBlankLines(pasted)
420
+ const start = field.selectionStart
421
+ const end = field.selectionEnd
422
+ const before = props.value.slice(0, start)
423
+ const after = props.value.slice(end)
424
+
425
+ if (!forceRaw && shouldCollapse(cleaned)) {
426
+ const id = props.createBlockId()
427
+ if (props.blocks.some((block) => block.id === id)) return
428
+ const block: PasteBlock = {
429
+ id,
430
+ seq: nextSeq([...props.blocks]),
431
+ lines: countLines(cleaned),
432
+ content: cleaned,
433
+ }
434
+ if (!isPasteBlock(block)) return
435
+ const linePrefix = before.slice(before.lastIndexOf('\n') + 1)
436
+ const leading =
437
+ before && !before.endsWith('\n') && !isBlockquotePrefix(linePrefix) ? '\n' : ''
438
+ const trailing = after && !after.startsWith('\n') ? '\n' : ''
439
+ const insert = leading + formatToken(block) + trailing
440
+ event.preventDefault()
441
+ change(before + insert + after, [...props.blocks, block])
442
+ closePreview()
443
+ setCaret(before.length + insert.length)
444
+ return
445
+ }
446
+ if (forceRaw) return
447
+ if (cleaned !== pasted && cleaned !== '') {
448
+ event.preventDefault()
449
+ const next = before + cleaned + after
450
+ nativePasteInputSeen = false
451
+ suppressPasteInput = true
452
+ let inserted = false
453
+ try {
454
+ inserted =
455
+ typeof document.execCommand === 'function' &&
456
+ document.execCommand('insertText', false, cleaned)
457
+ } catch {
458
+ inserted = false
459
+ }
460
+ if (!nativePasteInputSeen) suppressPasteInput = false
461
+ change(next)
462
+ if (inserted && field.value === next) return
463
+ setCaret(before.length + cleaned.length)
464
+ }
465
+ }
466
+
467
+ const handleDragOver = (event: DragEvent) => {
468
+ if (!field || props.readOnly || props.disabled || props.composing()) return
469
+ if (event.dataTransfer?.types.includes('text/plain')) {
470
+ acceptingTextDrop = true
471
+ closePreview()
472
+ event.preventDefault()
473
+ }
474
+ }
475
+
476
+ const handleDragLeave = () => {
477
+ acceptingTextDrop = false
478
+ }
479
+
480
+ const handleDrop = (event: DragEvent) => {
481
+ if (!field || props.readOnly || props.disabled || props.composing()) {
482
+ acceptingTextDrop = false
483
+ return
484
+ }
485
+ if (!event.dataTransfer?.types.includes('text/plain')) {
486
+ acceptingTextDrop = false
487
+ return
488
+ }
489
+ const dropped = event.dataTransfer.getData('text/plain')
490
+ event.preventDefault()
491
+ acceptingTextDrop = false
492
+ if (!dropped) return
493
+
494
+ const tokenRanges = ranges()
495
+ let start = field.selectionStart
496
+ let end = field.selectionEnd
497
+ const removed = tokenRanges.filter((range) =>
498
+ start === end
499
+ ? start > range.start && start < range.end
500
+ : range.start < end && range.end > start
501
+ )
502
+ if (removed.length) {
503
+ start = Math.min(start, ...removed.map((range) => range.start))
504
+ end = Math.max(end, ...removed.map((range) => range.end))
505
+ }
506
+
507
+ change(props.value.slice(0, start) + dropped + props.value.slice(end))
508
+ closePreview()
509
+ setCaret(start + dropped.length)
510
+ }
511
+
512
+ const handleClipboard = (event: ClipboardEvent) => {
513
+ if (!field || !event.clipboardData) return
514
+ const start = field.selectionStart
515
+ const end = field.selectionEnd
516
+ const expanded = expandSelectionForClipboard(start, end)
517
+ if (expanded === null) return
518
+ event.clipboardData.setData('text/plain', expanded)
519
+ event.preventDefault()
520
+ if (event.type === 'cut') {
521
+ change(props.value.slice(0, start) + props.value.slice(end))
522
+ setCaret(start)
523
+ }
524
+ }
525
+
526
+ const handleClick = (event: MouseEvent) => {
527
+ if (!field || !props.blocks.length) return
528
+ const range = tokenRangeAt(props.value, props.blocks, field.selectionStart)
529
+ if (!range) return
530
+ if (isTouchDevice() || event.detail >= 2) expandToken(range)
531
+ else {
532
+ requestAnimationFrame(() => field?.setSelectionRange(range.start, range.end))
533
+ }
534
+ }
535
+
536
+ createEffect(on(() => props.value, closePreview))
537
+ createEffect(on(() => props.blocks, closePreview))
538
+ createEffect(
539
+ on(
540
+ () => props.resetKey,
541
+ () => {
542
+ rawPasteNext = false
543
+ closePreview()
544
+ }
545
+ )
546
+ )
547
+ onMount(() => {
548
+ window.addEventListener('scroll', closePreview, true)
549
+ window.addEventListener('resize', closePreview)
550
+ onCleanup(() => {
551
+ window.removeEventListener('scroll', closePreview, true)
552
+ window.removeEventListener('resize', closePreview)
553
+ })
554
+ })
555
+ onCleanup(() => clearTimer())
556
+
557
+ return (
558
+ <KobalteTooltip
559
+ open={!!preview()}
560
+ onOpenChange={(open) => {
561
+ if (!open) closePreview()
562
+ }}
563
+ openDelay={PREVIEW_OPEN_DELAY_MS}
564
+ >
565
+ <KobalteTooltip.Trigger as="div" class="relative">
566
+ <div
567
+ ref={(element) => (mirror = element)}
568
+ data-slot="paste-token-mirror"
569
+ aria-hidden="true"
570
+ class="pointer-events-none absolute inset-0 overflow-hidden select-none whitespace-pre-wrap break-words px-4 py-3 text-sm text-transparent"
571
+ >
572
+ <For each={segments()}>
573
+ {(segment) =>
574
+ segment.block ? (
575
+ <span
576
+ class="box-decoration-clone rounded-md bg-primary-subtle"
577
+ data-paste-seq={segment.block.seq}
578
+ >
579
+ {segment.text}
580
+ </span>
581
+ ) : (
582
+ <span>{segment.text}</span>
583
+ )
584
+ }
585
+ </For>
586
+ {props.value.endsWith('\n') ? '\u200b' : ''}
587
+ </div>
588
+ <textarea
589
+ ref={(element) => {
590
+ field = element
591
+ props.ref(element)
592
+ onCleanup(() => {
593
+ if (field === element) field = undefined
594
+ props.ref(undefined)
595
+ })
596
+ }}
597
+ data-slot="composer-input"
598
+ aria-label={props.inputLabel}
599
+ aria-describedby={preview() ? `${keyboardHintId} ${descriptionId}` : keyboardHintId}
600
+ aria-keyshortcuts="Alt+ArrowDown"
601
+ class="relative field-sizing-content text-foreground placeholder:text-muted-foreground min-h-11 max-h-36 w-full resize-none border-0 bg-transparent px-4 py-3 text-sm outline-none disabled:opacity-50"
602
+ value={props.value}
603
+ disabled={props.disabled}
604
+ readOnly={props.readOnly}
605
+ placeholder={props.placeholder}
606
+ rows={1}
607
+ onInput={(event) => {
608
+ if (suppressPasteInput) {
609
+ suppressPasteInput = false
610
+ nativePasteInputSeen = true
611
+ return
612
+ }
613
+ change(event.currentTarget.value)
614
+ }}
615
+ onPaste={handlePaste}
616
+ onDragOver={handleDragOver}
617
+ onDragLeave={handleDragLeave}
618
+ onDrop={handleDrop}
619
+ onCopy={handleClipboard}
620
+ onCut={handleClipboard}
621
+ onClick={handleClick}
622
+ onSelect={snapSelection}
623
+ onMouseMove={schedulePointerPreview}
624
+ onMouseLeave={closePreview}
625
+ onScroll={(event) => {
626
+ if (mirror) {
627
+ mirror.scrollTop = event.currentTarget.scrollTop
628
+ mirror.scrollLeft = event.currentTarget.scrollLeft
629
+ }
630
+ closePreview()
631
+ }}
632
+ onCompositionStart={props.onCompositionStart}
633
+ onCompositionEnd={props.onCompositionEnd}
634
+ onFocus={props.onFocus}
635
+ onBlur={() => {
636
+ rawPasteNext = false
637
+ acceptingTextDrop = false
638
+ props.onBlur()
639
+ closePreview()
640
+ }}
641
+ onKeyDown={handleKeyDown}
642
+ />
643
+ <span id={keyboardHintId} class="visually-hidden">
644
+ When the caret is next to a paste token or a token is selected, press Alt+Down Arrow to
645
+ preview its text. Press Escape to close the preview.
646
+ </span>
647
+ </KobalteTooltip.Trigger>
648
+ <Show when={previewSummary()}>
649
+ {(summary) => (
650
+ <KobalteTooltip.Portal>
651
+ <KobalteTooltip.Content
652
+ id={descriptionId}
653
+ data-slot="paste-token-preview"
654
+ class="bg-scrim text-scrim-foreground z-(--z-tooltip) w-fit max-w-64 rounded-md px-2 py-1 text-xs origin-(--kb-tooltip-content-transform-origin) text-balance data-expanded:animate-in data-expanded:fade-in-0 data-expanded:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95 data-expanded:duration-150 data-closed:duration-100"
655
+ >
656
+ <pre class="m-0 max-h-48 max-w-64 overflow-y-auto whitespace-pre-wrap break-words font-mono">
657
+ {summary().text}
658
+ </pre>
659
+ <Show when={summary().hiddenLines > 0}>
660
+ <span>+{summary().hiddenLines} more lines</span>
661
+ </Show>
662
+ <Show when={summary().hiddenLines === 0 && summary().hiddenCharacters > 0}>
663
+ <span>+{summary().hiddenCharacters} more characters</span>
664
+ </Show>
665
+ <KobalteTooltip.Arrow aria-hidden="true" />
666
+ </KobalteTooltip.Content>
667
+ </KobalteTooltip.Portal>
668
+ )}
669
+ </Show>
670
+ </KobalteTooltip>
671
+ )
672
+ }
@@ -63,12 +63,25 @@ export function formatToken(block: PasteBlock): string {
63
63
 
64
64
  export function shouldCollapse(text: string): boolean {
65
65
  if (!text) return false
66
- return countLines(text) >= PASTE_THRESHOLD_LINES || text.length >= PASTE_THRESHOLD_CHARS
66
+ if (text.length >= PASTE_THRESHOLD_CHARS) return true
67
+
68
+ let lines = 1
69
+ for (let index = 0; index < text.length; index += 1) {
70
+ if (text.charCodeAt(index) !== 10) continue
71
+ lines += 1
72
+ if (lines >= PASTE_THRESHOLD_LINES) return true
73
+ }
74
+ return false
67
75
  }
68
76
 
69
77
  export function countLines(text: string): number {
70
78
  if (!text) return 0
71
- return text.split('\n').length
79
+
80
+ let lines = 1
81
+ for (let index = 0; index < text.length; index += 1) {
82
+ if (text.charCodeAt(index) === 10) lines += 1
83
+ }
84
+ return lines
72
85
  }
73
86
 
74
87
  /** Next sequence for a new paste = max existing + 1, starting at 1. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adea-ai/ui",
3
- "version": "0.68.2",
3
+ "version": "0.69.1",
4
4
  "description": "The Adea design system: Kobalte-backed Solid components, semantic OKLCH tokens, and the app-shell layout primitives shared by Adea and Cortana.",
5
5
  "keywords": [
6
6
  "adea",