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:
Waleed
2026-06-08 14:56:19 -07:00
committed by GitHub
parent 25771358d0
commit 27fc6ddcc3
6 changed files with 353 additions and 99 deletions
@@ -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 }
}
@@ -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'>
@@ -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()