fix(web): preserve focus when restoring drafts
This commit is contained in:
@@ -124,6 +124,16 @@ export function InputBar({
|
||||
else if (rect.top + line < box.top) scrollEl.scrollTop -= box.top - rect.top - line
|
||||
}
|
||||
|
||||
// Reveal the focus end of the current selection. Today's entry paths leave a
|
||||
// collapsed selection, but honoring direction keeps a future range-preserving
|
||||
// path from revealing its anchor instead of its focus.
|
||||
const revealSelectionFocus = (el: HTMLTextAreaElement): void => {
|
||||
// selectionStart/End are number|null in lib.dom; the type-aware lint program narrows them.
|
||||
const caret = el.selectionDirection === 'backward' ? el.selectionStart : el.selectionEnd
|
||||
// oxlint-disable-next-line typescript/no-unnecessary-condition
|
||||
revealCaret(caret ?? el.value.length)
|
||||
}
|
||||
|
||||
// Unlock (mount / session switch) returns focus to the box, and owns the
|
||||
// reveal that comes with it. `preventScroll` because this focus is ours, not
|
||||
// a gesture: the textarea is as tall as the draft, so the browser's reveal
|
||||
@@ -133,22 +143,24 @@ export function InputBar({
|
||||
// offset while the value swap puts the caret at the new draft's end, which is
|
||||
// off screen (measured on all three engines: offset 0 with the caret 940px
|
||||
// down). Suppress the walk, then reveal in our own box.
|
||||
//
|
||||
// `draft !== ''` is the third dependency because a persisted draft arrives
|
||||
// AFTER this effect: ConversationSession adopts it in its own mount effect,
|
||||
// and a parent's mount effect runs after its children's. Without that
|
||||
// dependency the reveal would measure an empty mirror and never run again for
|
||||
// the draft that then appeared, leaving a restored long draft showing its head
|
||||
// with the caret at its end. Clearing on send and typing the first character
|
||||
// flip it too, where both the focus and the reveal are no-ops.
|
||||
useEffect(() => {
|
||||
const el = inputRef.current
|
||||
if (locked || el === null) return
|
||||
el.focus({ preventScroll: true })
|
||||
// selectionStart is number|null in lib.dom; the type-aware lint program narrows it.
|
||||
// oxlint-disable-next-line typescript/no-unnecessary-condition
|
||||
revealCaret(el.selectionStart ?? el.value.length)
|
||||
}, [locked, sessionId, draft !== ''])
|
||||
revealSelectionFocus(el)
|
||||
}, [locked, sessionId])
|
||||
|
||||
// A persisted draft arrives AFTER the unlock effect: ConversationSession
|
||||
// adopts it in its own mount effect, and a parent's mount effect runs after
|
||||
// its children's. Reveal when the draft becomes non-empty so a restored long
|
||||
// draft does not stay at its head with the caret at its end. This effect does
|
||||
// not focus: send-clear, failed-send restore, and first-character transitions
|
||||
// must not steal focus from another control the user moved to.
|
||||
useEffect(() => {
|
||||
const el = inputRef.current
|
||||
if (locked || draft === '' || el === null) return
|
||||
revealSelectionFocus(el)
|
||||
}, [draft !== ''])
|
||||
|
||||
// Caret restore after an edit the composer performs itself. The machine owns
|
||||
// the draft and the undo log, so paste and cut suppress the native edit and
|
||||
|
||||
@@ -506,7 +506,7 @@ describe('running and lock semantics (queue cut 1)', () => {
|
||||
it('a persisted draft adopted after mount gets its caret revealed too', () => {
|
||||
// ConversationSession seeds the stored draft in its own mount effect, which
|
||||
// runs after this component's: the first reveal measures an empty mirror,
|
||||
// so the draft's arrival has to run it again.
|
||||
// so the draft's arrival has to run it again without reclaiming focus.
|
||||
const { view, textarea, shell } = bench()
|
||||
const scroll = view.container.querySelector<HTMLElement>('[data-input-scroll]')!
|
||||
const mirror = view.container.querySelector<HTMLElement>('[data-input-mirror]')!
|
||||
@@ -519,8 +519,13 @@ describe('running and lock semantics (queue cut 1)', () => {
|
||||
Object.defineProperty(scroll, 'scrollHeight', { value: 964, configurable: true })
|
||||
Object.defineProperty(scroll, 'scrollTop', { value: 0, writable: true, configurable: true })
|
||||
Range.prototype.getBoundingClientRect = () => ({ top: 500, bottom: 524 }) as DOMRect
|
||||
const other = document.createElement('input')
|
||||
document.body.appendChild(other)
|
||||
onTestFinished(() => { other.remove() })
|
||||
other.focus()
|
||||
expect(scroll.scrollTop).toBe(0)
|
||||
act(() => { shell.setDraft('restored\n'.repeat(40)) })
|
||||
expect(document.activeElement).toBe(other)
|
||||
// The caret the machine left at the draft's end, revealed once the draft exists.
|
||||
expect(textarea.selectionStart).toBe(textarea.value.length)
|
||||
expect(scroll.scrollTop).toBe(112) // (524 + 24) - 436
|
||||
|
||||
Reference in New Issue
Block a user