mirror of
https://github.com/simstudioai/sim.git
synced 2026-09-24 15:45:35 +08:00
fix(user-input): atomic chip selection, modifier-key handling, and stale overlay ghost (#4902)
* fix(user-input): atomic chip selection, modifier-key handling, and stale overlay ghost * fix(user-input): track raw selection in ref and sync mention menus on ranged selections * fix(user-input): translate overlay content instead of scrolling the overlay box * fix(user-input): read selection direction at apply time, document edge heuristic * fix(user-input): reconcile textarea DOM with state on selection events * fix(user-input): keep Cmd+Backspace native instead of single-chip delete * fix(user-input): record observed selection before reconcile early-return * refactor(user-input): extract adoptDomValue; drop flushSync pre-paint reconcile * refactor(user-input): native co-scroll for textarea + overlay, remove JS scroll-sync * refactor(user-input): extract pure snapSelectionToChips with unit tests * fix(user-input): track prev selection via selectionchange; make chip deletion undoable * docs(user-input): trim verbose comments in selection logic * fix(user-input): fall back to setRangeText when execCommand delete is unsupported (Firefox)
This commit is contained in:
+113
@@ -0,0 +1,113 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
type ChipBound,
|
||||
type Selection,
|
||||
snapSelectionToChips,
|
||||
} from '@/app/workspace/[workspaceId]/home/components/user-input/chip-selection'
|
||||
|
||||
// A chip occupying value indices [5, 12): e.g. " @Gmail " sitting at offset 5.
|
||||
const CHIP: ChipBound = { start: 5, end: 12 }
|
||||
|
||||
const at = (pos: number): Selection => ({ start: pos, end: pos })
|
||||
|
||||
describe('snapSelectionToChips', () => {
|
||||
describe('collapsed caret', () => {
|
||||
it('leaves a caret outside any chip untouched', () => {
|
||||
expect(snapSelectionToChips(at(3), at(3), undefined, undefined)).toEqual(at(3))
|
||||
})
|
||||
|
||||
it('snaps a caret in the chip near the start to the start edge', () => {
|
||||
expect(snapSelectionToChips(at(6), at(6), CHIP, CHIP)).toEqual(at(5))
|
||||
})
|
||||
|
||||
it('snaps a caret in the chip near the end to the end edge', () => {
|
||||
expect(snapSelectionToChips(at(11), at(11), CHIP, CHIP)).toEqual(at(12))
|
||||
})
|
||||
|
||||
it('snaps the exact midpoint to the start edge (ties favor start)', () => {
|
||||
// distance to start (8.5-5=3.5) equals distance to end (12-8.5=3.5) only at
|
||||
// 8.5; integer midpoint 8 is closer to start.
|
||||
expect(snapSelectionToChips(at(8), at(8), CHIP, CHIP)).toEqual(at(5))
|
||||
})
|
||||
|
||||
it('does not snap a caret resting exactly on an edge (edge is not "inside")', () => {
|
||||
// findRangeContaining is strict, so an edge caret has no containing chip.
|
||||
expect(snapSelectionToChips(at(5), at(5), undefined, undefined)).toEqual(at(5))
|
||||
expect(snapSelectionToChips(at(12), at(12), undefined, undefined)).toEqual(at(12))
|
||||
})
|
||||
})
|
||||
|
||||
describe('ranged — fresh selection (both edges differ from prev)', () => {
|
||||
it('expands a start edge inside a chip outward to the chip start', () => {
|
||||
// select-all-like: prev was a caret at 20, new selection 8..30 grew both edges.
|
||||
const out = snapSelectionToChips({ start: 8, end: 30 }, at(20), CHIP, undefined)
|
||||
expect(out).toEqual({ start: 5, end: 30 })
|
||||
})
|
||||
|
||||
it('expands an end edge inside a chip outward to the chip end', () => {
|
||||
const out = snapSelectionToChips({ start: 0, end: 9 }, at(20), undefined, CHIP)
|
||||
expect(out).toEqual({ start: 0, end: 12 })
|
||||
})
|
||||
|
||||
it('expands both edges when each lands in a (different) chip', () => {
|
||||
const chipB: ChipBound = { start: 20, end: 27 }
|
||||
const out = snapSelectionToChips({ start: 8, end: 23 }, at(40), CHIP, chipB)
|
||||
expect(out).toEqual({ start: 5, end: 27 })
|
||||
})
|
||||
})
|
||||
|
||||
describe('ranged — single moved edge (keyboard extend / shrink)', () => {
|
||||
it('growing the end edge into a chip absorbs the whole chip', () => {
|
||||
// prev 0..6, end moved 6 -> 9 (grew); start unchanged.
|
||||
const out = snapSelectionToChips({ start: 0, end: 9 }, { start: 0, end: 6 }, undefined, CHIP)
|
||||
expect(out).toEqual({ start: 0, end: 12 })
|
||||
})
|
||||
|
||||
it('shrinking the end edge out of a chip releases the whole chip', () => {
|
||||
// prev 0..14, end moved 14 -> 9 (shrank) into the chip; release to chip start.
|
||||
const out = snapSelectionToChips({ start: 0, end: 9 }, { start: 0, end: 14 }, undefined, CHIP)
|
||||
expect(out).toEqual({ start: 0, end: 5 })
|
||||
})
|
||||
|
||||
it('growing the start edge leftward into a chip absorbs the whole chip', () => {
|
||||
// prev 9..20, start moved 9 -> 6 (grew leftward, start < prev.start).
|
||||
const out = snapSelectionToChips(
|
||||
{ start: 6, end: 20 },
|
||||
{ start: 9, end: 20 },
|
||||
CHIP,
|
||||
undefined
|
||||
)
|
||||
expect(out).toEqual({ start: 5, end: 20 })
|
||||
})
|
||||
|
||||
it('shrinking the start edge rightward into a chip releases the whole chip', () => {
|
||||
// prev 6..20, start moved 6 -> 9 (shrank rightward, start > prev.start) → chip end.
|
||||
const out = snapSelectionToChips(
|
||||
{ start: 9, end: 20 },
|
||||
{ start: 6, end: 20 },
|
||||
CHIP,
|
||||
undefined
|
||||
)
|
||||
expect(out).toEqual({ start: 12, end: 20 })
|
||||
})
|
||||
})
|
||||
|
||||
describe('selection contained within one chip', () => {
|
||||
it('clamps to a collapsed caret rather than inverting', () => {
|
||||
// Both edges inside CHIP via a fresh selection: start→5, end→12 stays ordered.
|
||||
// Construct an inverting case: a shrink where start snaps to 12 and end to 5.
|
||||
const out = snapSelectionToChips({ start: 7, end: 9 }, { start: 5, end: 9 }, CHIP, CHIP)
|
||||
expect(out.start).toBeLessThanOrEqual(out.end)
|
||||
})
|
||||
})
|
||||
|
||||
describe('no chips', () => {
|
||||
it('returns the selection unchanged', () => {
|
||||
const sel = { start: 2, end: 18 }
|
||||
expect(snapSelectionToChips(sel, at(0), undefined, undefined)).toEqual(sel)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,69 @@
|
||||
/**
|
||||
* Pure selection-snapping math for the mention-chip textarea, extracted from the
|
||||
* `onSelect` handler so it can be unit-tested in isolation from DOM I/O.
|
||||
*
|
||||
* A mention chip occupies a contiguous `[start, end)` span of the textarea
|
||||
* value. The UI treats each chip as atomic: a selection edge may never land
|
||||
* strictly inside a chip. This function maps an observed selection to the
|
||||
* nearest valid one.
|
||||
*/
|
||||
|
||||
/** A half-open chip span `[start, end)` in textarea-value coordinates. */
|
||||
export interface ChipBound {
|
||||
start: number
|
||||
end: number
|
||||
}
|
||||
|
||||
/** A selection or caret as reported by `selectionStart`/`selectionEnd`. */
|
||||
export interface Selection {
|
||||
start: number
|
||||
end: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Snaps a selection so no edge sits inside a chip.
|
||||
*
|
||||
* - **Collapsed caret inside a chip** → nearest chip edge.
|
||||
* - **Ranged selection** → each edge inside a chip snaps to a boundary without
|
||||
* collapsing the range. A lone moved edge (keyboard extend/shrink, drag) snaps
|
||||
* in its direction of travel — growing absorbs the chip, shrinking releases
|
||||
* it; a fresh selection (double-click, select-all) expands outward. The two
|
||||
* paths differ only for a shrinking edge, so the gesture inference is safe
|
||||
* even when a fresh selection happens to share an edge with `prev`.
|
||||
*
|
||||
* @param sel - The observed selection.
|
||||
* @param prev - The previously observed selection, used to infer which edge moved.
|
||||
* @param startChip - The chip containing `sel.start`, if any.
|
||||
* @param endChip - The chip containing `sel.end`, if any.
|
||||
* @returns The snapped selection (equal to `sel` when no edge is inside a chip).
|
||||
*/
|
||||
export function snapSelectionToChips(
|
||||
sel: Selection,
|
||||
prev: Selection,
|
||||
startChip: ChipBound | undefined,
|
||||
endChip: ChipBound | undefined
|
||||
): Selection {
|
||||
const { start, end } = sel
|
||||
|
||||
if (start === end) {
|
||||
if (!startChip) return sel
|
||||
const nearest =
|
||||
start - startChip.start < startChip.end - start ? startChip.start : startChip.end
|
||||
return { start: nearest, end: nearest }
|
||||
}
|
||||
|
||||
const singleEdgeMoved = (start !== prev.start) !== (end !== prev.end)
|
||||
|
||||
let newStart = startChip
|
||||
? singleEdgeMoved && start > prev.start
|
||||
? startChip.end
|
||||
: startChip.start
|
||||
: start
|
||||
const newEnd = endChip ? (singleEdgeMoved && end < prev.end ? endChip.start : endChip.end) : end
|
||||
|
||||
// A selection contained within a single chip snaps both edges; clamp so it
|
||||
// collapses to a caret rather than inverting.
|
||||
if (newStart > newEnd) newStart = newEnd
|
||||
|
||||
return { start: newStart, end: newEnd }
|
||||
}
|
||||
+34
-19
@@ -43,21 +43,43 @@ export interface PlusMenuHandle {
|
||||
selectActive: () => boolean
|
||||
}
|
||||
|
||||
export const TEXTAREA_BASE_CLASSES = cn(
|
||||
'm-0 box-border h-auto min-h-[24px] w-full resize-none',
|
||||
'overflow-y-auto overflow-x-hidden break-words [overflow-wrap:anywhere] border-0 bg-transparent',
|
||||
'px-1 py-1 font-body text-[15px] leading-[24px] tracking-[-0.015em]',
|
||||
'text-transparent caret-[var(--text-primary)] outline-none',
|
||||
'placeholder:font-[380] placeholder:text-[var(--text-subtle)]',
|
||||
'focus-visible:ring-0 focus-visible:ring-offset-0',
|
||||
'[-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden'
|
||||
/**
|
||||
* Box and typography shared by the textarea and its mirror overlay — both must
|
||||
* produce identical line wrapping so the overlay text sits exactly over the
|
||||
* (transparent) textarea text.
|
||||
*/
|
||||
const FIELD_MIRROR_CLASSES = cn(
|
||||
'm-0 box-border min-h-[24px] w-full break-words [overflow-wrap:anywhere] border-0 bg-transparent',
|
||||
'px-1 py-1 font-body text-[15px] leading-[24px] tracking-[-0.015em]'
|
||||
)
|
||||
|
||||
/**
|
||||
* The textarea grows to its full content height (`h-auto`, no internal scroll);
|
||||
* the shared scroller clips and scrolls it. Its text is transparent so the
|
||||
* mirror overlay shows through; only the caret paints.
|
||||
*/
|
||||
export const TEXTAREA_BASE_CLASSES = cn(
|
||||
FIELD_MIRROR_CLASSES,
|
||||
'block h-auto resize-none overflow-hidden',
|
||||
'text-transparent caret-[var(--text-primary)] outline-none',
|
||||
'placeholder:font-[380] placeholder:text-[var(--text-subtle)]',
|
||||
'focus-visible:ring-0 focus-visible:ring-offset-0'
|
||||
)
|
||||
|
||||
/**
|
||||
* Pinned over the full-height textarea (`inset-0` of the sizer). Both are flow
|
||||
* children of the same scroller, so they scroll together natively — no JS
|
||||
* scroll-sync, so the caret and mirrored text never drift apart.
|
||||
*/
|
||||
export const OVERLAY_CLASSES = cn(
|
||||
'pointer-events-none absolute top-0 left-0 m-0 box-border h-auto w-full resize-none',
|
||||
'overflow-y-auto overflow-x-hidden whitespace-pre-wrap break-words [overflow-wrap:anywhere] border-0 bg-transparent',
|
||||
'px-1 py-1 font-body text-[15px] leading-[24px] tracking-[-0.015em]',
|
||||
'text-[var(--text-primary)] outline-none',
|
||||
FIELD_MIRROR_CLASSES,
|
||||
'pointer-events-none absolute inset-0 whitespace-pre-wrap',
|
||||
'text-[var(--text-primary)]'
|
||||
)
|
||||
|
||||
/** Single scroll container for the textarea + overlay; caps height and hides its scrollbar. */
|
||||
export const SCROLLER_CLASSES = cn(
|
||||
'relative overflow-y-auto overflow-x-hidden',
|
||||
'[-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden'
|
||||
)
|
||||
|
||||
@@ -66,15 +88,8 @@ export const SEND_BUTTON_ACTIVE =
|
||||
'bg-[#383838] hover:bg-[#575757] dark:bg-[#E0E0E0] dark:hover:bg-[#CFCFCF]'
|
||||
export const SEND_BUTTON_DISABLED = 'bg-[#808080] dark:bg-[#808080]'
|
||||
|
||||
export const MAX_CHAT_TEXTAREA_HEIGHT = 200
|
||||
export const SPEECH_RECOGNITION_LANG = 'en-US'
|
||||
|
||||
export function autoResizeTextarea(e: React.FormEvent<HTMLTextAreaElement>, maxHeight: number) {
|
||||
const target = e.target as HTMLTextAreaElement
|
||||
target.style.height = 'auto'
|
||||
target.style.height = `${Math.min(target.scrollHeight, maxHeight)}px`
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps a {@link MothershipResource} (resource-picker domain) to a
|
||||
* {@link ChatContext} (chat-input domain). Keyed by `MothershipResourceType`
|
||||
|
||||
@@ -15,10 +15,9 @@ export type {
|
||||
WindowWithSpeech,
|
||||
} from './constants'
|
||||
export {
|
||||
autoResizeTextarea,
|
||||
MAX_CHAT_TEXTAREA_HEIGHT,
|
||||
mapResourceToContext,
|
||||
OVERLAY_CLASSES,
|
||||
SCROLLER_CLASSES,
|
||||
SPEECH_RECOGNITION_LANG,
|
||||
TEXTAREA_BASE_CLASSES,
|
||||
} from './constants'
|
||||
|
||||
@@ -20,6 +20,7 @@ import { cn } from '@/lib/core/utils/cn'
|
||||
import { CHAT_ACCEPT_ATTRIBUTE } from '@/lib/uploads/utils/validation'
|
||||
import { ContextMentionIcon } from '@/app/workspace/[workspaceId]/home/components/context-mention-icon'
|
||||
import { useAvailableResources } from '@/app/workspace/[workspaceId]/home/components/mothership-view/components/add-resource-dropdown'
|
||||
import { snapSelectionToChips } from '@/app/workspace/[workspaceId]/home/components/user-input/chip-selection'
|
||||
import type {
|
||||
PlusMenuHandle,
|
||||
SkillsMenuHandle,
|
||||
@@ -27,16 +28,15 @@ import type {
|
||||
import {
|
||||
AnimatedPlaceholderEffect,
|
||||
AttachedFilesList,
|
||||
autoResizeTextarea,
|
||||
chipDisplayToken,
|
||||
chipLinkToContext,
|
||||
DropOverlay,
|
||||
MAX_CHAT_TEXTAREA_HEIGHT,
|
||||
MicButton,
|
||||
mapResourceToContext,
|
||||
OVERLAY_CLASSES,
|
||||
PlusMenuDropdown,
|
||||
parseChipLinks,
|
||||
SCROLLER_CLASSES,
|
||||
SendButton,
|
||||
SkillsMenuDropdown,
|
||||
serializeSelectionForClipboard,
|
||||
@@ -171,7 +171,6 @@ export const UserInput = forwardRef<UserInputHandle, UserInputProps>(function Us
|
||||
})
|
||||
const valueRef = useRef(value)
|
||||
valueRef.current = value
|
||||
const overlayRef = useRef<HTMLDivElement>(null)
|
||||
const plusMenuRef = useRef<PlusMenuHandle>(null)
|
||||
const skillsMenuRef = useRef<SkillsMenuHandle>(null)
|
||||
|
||||
@@ -480,13 +479,11 @@ export const UserInput = forwardRef<UserInputHandle, UserInputProps>(function Us
|
||||
useLayoutEffect(() => {
|
||||
const textarea = textareaRef.current
|
||||
if (!textarea) return
|
||||
const maxHeight = isInitialView ? window.innerHeight * 0.3 : MAX_CHAT_TEXTAREA_HEIGHT
|
||||
// Grow the textarea to its full content height; the scroller caps the
|
||||
// visible height and scrolls textarea + overlay together natively.
|
||||
textarea.style.height = 'auto'
|
||||
textarea.style.height = `${Math.min(textarea.scrollHeight, maxHeight)}px`
|
||||
if (overlayRef.current) {
|
||||
overlayRef.current.scrollTop = textarea.scrollTop
|
||||
}
|
||||
}, [value, isInitialView, textareaRef])
|
||||
textarea.style.height = `${textarea.scrollHeight}px`
|
||||
}, [value, textareaRef])
|
||||
|
||||
const handleResourceSelect = useCallback(
|
||||
(resource: MothershipResource) => {
|
||||
@@ -742,6 +739,16 @@ export const UserInput = forwardRef<UserInputHandle, UserInputProps>(function Us
|
||||
}
|
||||
}, [onSubmit, textareaRef, resetTranscript])
|
||||
|
||||
/**
|
||||
* Adopts the textarea's DOM value into state. State and DOM can only drift
|
||||
* when an edit's input event is lost (programmatic edits pair setValue
|
||||
* synchronously) — the DOM is the user's intent.
|
||||
*/
|
||||
const adoptDomValue = useCallback((textarea: HTMLTextAreaElement) => {
|
||||
valueRef.current = textarea.value
|
||||
setValue(textarea.value)
|
||||
}, [])
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (mentionRangeRef.current && !e.nativeEvent.isComposing) {
|
||||
@@ -819,8 +826,9 @@ export const UserInput = forwardRef<UserInputHandle, UserInputProps>(function Us
|
||||
|
||||
// Single-chip delete: remove the token's text atomically. A selection
|
||||
// delete falls through to the native edit; either way the context-sync
|
||||
// effect prunes contexts whose last token is gone.
|
||||
if ((e.key === 'Backspace' || e.key === 'Delete') && selectionLength === 0) {
|
||||
// effect prunes contexts whose last token is gone. Cmd+Backspace
|
||||
// (delete to line start) stays native — it spans more than one chip.
|
||||
if ((e.key === 'Backspace' || e.key === 'Delete') && selectionLength === 0 && !e.metaKey) {
|
||||
const ranges = mentionTokensWithContext.mentionRanges
|
||||
const target =
|
||||
e.key === 'Backspace'
|
||||
@@ -834,7 +842,18 @@ export const UserInput = forwardRef<UserInputHandle, UserInputProps>(function Us
|
||||
}
|
||||
}
|
||||
|
||||
if (selectionLength === 0 && (e.key === 'ArrowLeft' || e.key === 'ArrowRight')) {
|
||||
// Hop chips on plain arrows only: Shift/Cmd/Alt/Ctrl variants and IME
|
||||
// composition keep native handling; the select handler snaps any
|
||||
// resulting edge inside a chip to a chip boundary.
|
||||
if (
|
||||
selectionLength === 0 &&
|
||||
(e.key === 'ArrowLeft' || e.key === 'ArrowRight') &&
|
||||
!e.shiftKey &&
|
||||
!e.metaKey &&
|
||||
!e.altKey &&
|
||||
!e.ctrlKey &&
|
||||
!e.nativeEvent.isComposing
|
||||
) {
|
||||
if (textarea) {
|
||||
if (e.key === 'ArrowLeft') {
|
||||
const nextPos = Math.max(0, selStart - 1)
|
||||
@@ -858,7 +877,9 @@ export const UserInput = forwardRef<UserInputHandle, UserInputProps>(function Us
|
||||
}
|
||||
}
|
||||
|
||||
if (e.key.length === 1 || e.key === 'Space') {
|
||||
// Block typing inside a chip (snap to its end instead). Cmd/Ctrl
|
||||
// shortcuts (Cmd+A, Cmd+C, ...) don't insert text and must pass through.
|
||||
if (e.key.length === 1 && !e.metaKey && !e.ctrlKey) {
|
||||
const blocked =
|
||||
selectionLength === 0 && !!mentionTokensWithContext.findRangeContaining(selStart)
|
||||
if (blocked) {
|
||||
@@ -1017,33 +1038,70 @@ export const UserInput = forwardRef<UserInputHandle, UserInputProps>(function Us
|
||||
]
|
||||
)
|
||||
|
||||
// Selection one change ago, used to infer which edge of a range moved. Kept
|
||||
// current by the `selectionchange` listener below — which fires on EVERY
|
||||
// caret/selection change (typing, arrows, clicks, programmatic), unlike
|
||||
// `select`/`mouseup` — so the inference is never fed a stale `prev`.
|
||||
const prevSelectionRef = useRef<{ start: number; end: number }>({ start: 0, end: 0 })
|
||||
|
||||
useEffect(() => {
|
||||
const textarea = textareaRef.current
|
||||
if (!textarea) return
|
||||
let last = { start: 0, end: 0 }
|
||||
const onSelectionChange = () => {
|
||||
if (document.activeElement !== textarea) return
|
||||
prevSelectionRef.current = last
|
||||
last = { start: textarea.selectionStart ?? 0, end: textarea.selectionEnd ?? 0 }
|
||||
}
|
||||
document.addEventListener('selectionchange', onSelectionChange)
|
||||
return () => document.removeEventListener('selectionchange', onSelectionChange)
|
||||
}, [textareaRef])
|
||||
|
||||
/**
|
||||
* Keeps mention chips atomic under every selection gesture. A collapsed
|
||||
* caret inside a chip snaps to the nearest edge; a ranged selection edge
|
||||
* inside a chip snaps to a chip boundary — never collapsed — so select-all,
|
||||
* Shift+arrows, drag, and double-click all select chips whole.
|
||||
*/
|
||||
const handleSelectAdjust = useCallback(() => {
|
||||
const textarea = textareaRef.current
|
||||
if (!textarea) return
|
||||
const pos = textarea.selectionStart ?? 0
|
||||
const r = mentionTokensWithContext.findRangeContaining(pos)
|
||||
if (r) {
|
||||
const snapPos = pos - r.start < r.end - pos ? r.start : r.end
|
||||
const start = textarea.selectionStart ?? 0
|
||||
const end = textarea.selectionEnd ?? 0
|
||||
const prev = prevSelectionRef.current
|
||||
|
||||
// Adopt value changes that bypassed React's change tracking (browser
|
||||
// autofill, password managers, grammar extensions — see facebook/react#2125)
|
||||
// so state never drifts from the DOM. The render rebuilds the overlay and
|
||||
// selection logic resumes on the next event.
|
||||
if (textarea.value !== valueRef.current) {
|
||||
adoptDomValue(textarea)
|
||||
return
|
||||
}
|
||||
|
||||
const startChip = mentionTokensWithContext.findRangeContaining(start)
|
||||
const endChip = start === end ? startChip : mentionTokensWithContext.findRangeContaining(end)
|
||||
const snapped = snapSelectionToChips({ start, end }, prev, startChip, endChip)
|
||||
|
||||
if (snapped.start !== start || snapped.end !== end) {
|
||||
// Deferred so in-flight click/drag processing can't override the write,
|
||||
// and bailed if the selection moved again first. The write re-fires this
|
||||
// handler, which then syncs the menus.
|
||||
setTimeout(() => {
|
||||
textarea.setSelectionRange(snapPos, snapPos)
|
||||
if (textarea.selectionStart !== start || textarea.selectionEnd !== end) return
|
||||
textarea.setSelectionRange(
|
||||
snapped.start,
|
||||
snapped.end,
|
||||
textarea.selectionDirection ?? undefined
|
||||
)
|
||||
}, 0)
|
||||
return
|
||||
}
|
||||
syncMentionState(textarea, textarea.value, pos)
|
||||
syncSlashState(textarea, textarea.value, pos)
|
||||
}, [textareaRef, mentionTokensWithContext, syncMentionState, syncSlashState])
|
||||
|
||||
const handleInput = useCallback(
|
||||
(e: React.FormEvent<HTMLTextAreaElement>) => {
|
||||
const maxHeight = isInitialView ? window.innerHeight * 0.3 : MAX_CHAT_TEXTAREA_HEIGHT
|
||||
autoResizeTextarea(e, maxHeight)
|
||||
|
||||
if (overlayRef.current) {
|
||||
overlayRef.current.scrollTop = (e.target as HTMLTextAreaElement).scrollTop
|
||||
}
|
||||
},
|
||||
[isInitialView]
|
||||
)
|
||||
const focusPos = textarea.selectionDirection === 'backward' ? start : end
|
||||
syncMentionState(textarea, textarea.value, focusPos)
|
||||
syncSlashState(textarea, textarea.value, focusPos)
|
||||
}, [textareaRef, mentionTokensWithContext, adoptDomValue, syncMentionState, syncSlashState])
|
||||
|
||||
const handlePaste = useCallback((e: React.ClipboardEvent<HTMLTextAreaElement>) => {
|
||||
const textarea = e.currentTarget
|
||||
@@ -1129,12 +1187,6 @@ export const UserInput = forwardRef<UserInputHandle, UserInputProps>(function Us
|
||||
filesRef.current.processFiles(dt.files)
|
||||
}, [])
|
||||
|
||||
const handleScroll = useCallback((e: React.UIEvent<HTMLTextAreaElement>) => {
|
||||
if (overlayRef.current) {
|
||||
overlayRef.current.scrollTop = e.currentTarget.scrollTop
|
||||
}
|
||||
}, [])
|
||||
|
||||
/**
|
||||
* On copy/cut, write a portable representation of the selection to the
|
||||
* clipboard. Portable resource chips (table/file/folder/etc.) become
|
||||
@@ -1230,12 +1282,8 @@ export const UserInput = forwardRef<UserInputHandle, UserInputProps>(function Us
|
||||
elements.push(
|
||||
<span key={`mention-${i}-${range.start}-${range.end}`}>
|
||||
<span className='relative'>
|
||||
{/* Spacer reserves the real trigger glyph's width so the overlay's
|
||||
advance matches the transparent textarea char-for-char —
|
||||
hardcoding a width here would drift the text. For '@' the glyph is
|
||||
~1em; skill chips store an EM SPACE sentinel (SKILL_CHIP_TRIGGER)
|
||||
in place of the narrow '/' so the centered 12px icon fits its slot
|
||||
exactly like '@' does. */}
|
||||
{/* Invisible trigger glyph keeps the overlay's advance identical to
|
||||
the transparent textarea; the icon centers over its slot. */}
|
||||
<span className='invisible'>{range.token.charAt(0)}</span>
|
||||
{mentionIconNode}
|
||||
</span>
|
||||
@@ -1274,35 +1322,30 @@ export const UserInput = forwardRef<UserInputHandle, UserInputProps>(function Us
|
||||
onRemoveFile={handleRemoveFile}
|
||||
/>
|
||||
|
||||
{/* Clip the absolutely-positioned mirror overlay to the textarea's box.
|
||||
The overlay is `h-auto`, so its content (e.g. the trailing-newline
|
||||
sentinel on Shift+Enter) can exceed the textarea height and would
|
||||
otherwise paint over the toolbar below. */}
|
||||
<div className='relative overflow-hidden'>
|
||||
<div
|
||||
ref={overlayRef}
|
||||
className={cn(OVERLAY_CLASSES, isInitialView ? 'max-h-[30vh]' : 'max-h-[200px]')}
|
||||
aria-hidden='true'
|
||||
>
|
||||
{overlayContent}
|
||||
</div>
|
||||
{/* Single scroller for textarea + overlay so they co-scroll natively;
|
||||
the sizer is sized by the full-height textarea, and the overlay fills
|
||||
it via `inset-0`. */}
|
||||
<div className={cn(SCROLLER_CLASSES, isInitialView ? 'max-h-[30vh]' : 'max-h-[200px]')}>
|
||||
<div className='relative'>
|
||||
<div className={OVERLAY_CLASSES} aria-hidden='true'>
|
||||
{overlayContent}
|
||||
</div>
|
||||
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={value}
|
||||
onChange={handleInputChange}
|
||||
onKeyDown={handleKeyDown}
|
||||
onInput={handleInput}
|
||||
onPaste={handlePaste}
|
||||
onCopy={handleCopy}
|
||||
onCut={handleCut}
|
||||
onSelect={handleSelectAdjust}
|
||||
onMouseUp={handleSelectAdjust}
|
||||
onScroll={handleScroll}
|
||||
placeholder='Ask Sim to '
|
||||
rows={1}
|
||||
className={cn(TEXTAREA_BASE_CLASSES, isInitialView ? 'max-h-[30vh]' : 'max-h-[200px]')}
|
||||
/>
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={value}
|
||||
onChange={handleInputChange}
|
||||
onKeyDown={handleKeyDown}
|
||||
onPaste={handlePaste}
|
||||
onCopy={handleCopy}
|
||||
onCut={handleCut}
|
||||
onSelect={handleSelectAdjust}
|
||||
onMouseUp={handleSelectAdjust}
|
||||
placeholder='Ask Sim to '
|
||||
rows={1}
|
||||
className={TEXTAREA_BASE_CLASSES}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='flex items-center justify-between'>
|
||||
|
||||
+21
-6
@@ -126,14 +126,29 @@ export function useMentionTokens({
|
||||
const before = message.slice(0, range.start)
|
||||
const after = message.slice(range.end)
|
||||
// Collapse only the space seam the removal creates (a leading + trailing
|
||||
// space meeting), never unrelated double-spaces elsewhere in the message.
|
||||
const next =
|
||||
// space meeting), never unrelated double-spaces elsewhere in the message —
|
||||
// by extending the deleted range over those leading spaces so the whole
|
||||
// removal is a single edit.
|
||||
const deleteEnd =
|
||||
before.endsWith(' ') && after.startsWith(' ')
|
||||
? `${before}${after.replace(/^ +/, '')}`
|
||||
: `${before}${after}`
|
||||
setMessage(next)
|
||||
? range.end + (after.length - after.replace(/^ +/, '').length)
|
||||
: range.end
|
||||
|
||||
// Prefer `execCommand` (deprecated, but the only primitive that lands the
|
||||
// removal on the native undo stack, so Cmd+Z restores the chip). It's a
|
||||
// no-op on Firefox textareas and returns false there, so fall back to a
|
||||
// direct edit — correct deletion, just no native undo on that browser.
|
||||
textarea.focus()
|
||||
textarea.setSelectionRange(range.start, deleteEnd)
|
||||
const valueBeforeDelete = textarea.value
|
||||
if (!document.execCommand('delete') || textarea.value === valueBeforeDelete) {
|
||||
textarea.setRangeText('', range.start, deleteEnd, 'start')
|
||||
}
|
||||
|
||||
// The edit fires `input`, but sync state explicitly so this hook doesn't
|
||||
// depend on the consumer's onChange wiring.
|
||||
setMessage(textarea.value)
|
||||
|
||||
// Set cursor position immediately after state update
|
||||
setTimeout(() => {
|
||||
textarea.setSelectionRange(range.start, range.start)
|
||||
textarea.focus()
|
||||
|
||||
Reference in New Issue
Block a user