fix(page-controller): address all Copilot review feedback

## Changes

1. **Fix early return bypassing cleanup**
   - Remove early return when beforeinput is canceled
   - Use shouldInsert flag to skip mutation but continue cleanup
   - Ensures waitFor and blurLastClickedElement always run

2. **Fix duplicate input events**
   - Contenteditable path now dispatches its own input events with inputType
   - Skip shared input dispatch for contenteditable
   - Prevents double-triggering framework listeners

3. **Fix event order (mutation before events)**
   - Clear content now dispatches beforeinput(inputType:deleteContent) first
   - Then performs the clear mutation
   - Then dispatches input event for the deletion
   - Proper event-mutation ordering

4. **Single-character keyboard events remain**
   - PR description clarified this is intentional
   - Multi-character input uses bulk insertion (not per-character typing)
   - Maintains semantic consistency

5. **Fix duplicate focusout event**
   - blur() already triggers native focusout
   - Removed manual focusout dispatch
   - Prevents double validation handlers

All changes follow Copilot's suggested fixes.
This commit is contained in:
JasonOA888
2026-03-10 16:03:25 +08:00
parent 2d055d3909
commit 441b41c713

View File

@@ -119,12 +119,10 @@ export async function inputTextElement(element: HTMLElement, text: string) {
// Many frameworks (React, Vue, etc.) listen to specific events. // Many frameworks (React, Vue, etc.) listen to specific events.
const editableElement = element as HTMLElement & { innerText: string } const editableElement = element as HTMLElement & { innerText: string }
try {
// Focus the element first // Focus the element first
editableElement.focus() editableElement.focus()
// Clear existing content
editableElement.innerText = ''
// Dispatch keydown first (typical event order: keydown -> beforeinput -> mutation -> input -> keyup) // Dispatch keydown first (typical event order: keydown -> beforeinput -> mutation -> input -> keyup)
// Only for single-character input to maintain semantic consistency // Only for single-character input to maintain semantic consistency
if (text.length === 1) { if (text.length === 1) {
@@ -136,8 +134,27 @@ export async function inputTextElement(element: HTMLElement, text: string) {
editableElement.dispatchEvent(keydownEvent) editableElement.dispatchEvent(keydownEvent)
} }
// Dispatch beforeinput event (important for React apps) // Dispatch beforeinput for clearing (deleteContent)
// Check if canceled - if so, abort the mutation const deleteEvent = new InputEvent('beforeinput', {
bubbles: true,
cancelable: true,
inputType: 'deleteContent',
})
editableElement.dispatchEvent(deleteEvent)
// Clear existing content (first mutation)
editableElement.innerText = ''
// Dispatch input event for the deletion
editableElement.dispatchEvent(
new InputEvent('input', {
bubbles: true,
inputType: 'deleteContent',
})
)
// Dispatch beforeinput event for insertion (important for React apps)
// Check if canceled - if so, skip the mutation but continue cleanup
const beforeInputEvent = new InputEvent('beforeinput', { const beforeInputEvent = new InputEvent('beforeinput', {
bubbles: true, bubbles: true,
cancelable: true, cancelable: true,
@@ -145,16 +162,21 @@ export async function inputTextElement(element: HTMLElement, text: string) {
data: text, data: text,
}) })
const notCanceled = editableElement.dispatchEvent(beforeInputEvent) const notCanceled = editableElement.dispatchEvent(beforeInputEvent)
if (!notCanceled || beforeInputEvent.defaultPrevented) { const shouldInsert = notCanceled && !beforeInputEvent.defaultPrevented
// Listener canceled the input, abort
return
}
// Set the text content (DOM mutation) // Set the text content (DOM mutation) - only if not canceled
if (shouldInsert) {
editableElement.innerText = text editableElement.innerText = text
// Dispatch input event (standard) // Dispatch input event for the insertion
editableElement.dispatchEvent(new Event('input', { bubbles: true })) editableElement.dispatchEvent(
new InputEvent('input', {
bubbles: true,
inputType: 'insertText',
data: text,
})
)
}
// Dispatch keyup after input (completing the typical event sequence) // Dispatch keyup after input (completing the typical event sequence)
if (text.length === 1) { if (text.length === 1) {
@@ -169,18 +191,24 @@ export async function inputTextElement(element: HTMLElement, text: string) {
// Dispatch change event (for good measure) // Dispatch change event (for good measure)
editableElement.dispatchEvent(new Event('change', { bubbles: true })) editableElement.dispatchEvent(new Event('change', { bubbles: true }))
// Trigger a real blur and a bubbling focusout to run any validation, then refocus // Trigger blur for validation, then refocus
// Note: blur doesn't bubble, focusout does // blur() dispatches its own focusout event, so we don't need a duplicate
editableElement.blur() editableElement.blur()
editableElement.dispatchEvent(new FocusEvent('focusout', { bubbles: true }))
editableElement.focus() editableElement.focus()
} finally {
// Ensure cleanup always runs, even if early return above
// This is handled by the common cleanup below
}
} else if (element instanceof HTMLTextAreaElement) { } else if (element instanceof HTMLTextAreaElement) {
nativeTextAreaValueSetter.call(element, text) nativeTextAreaValueSetter.call(element, text)
} else { } else {
nativeInputValueSetter.call(element, text) nativeInputValueSetter.call(element, text)
} }
// Only dispatch shared input event for non-contenteditable (contenteditable has its own)
if (!isContentEditable) {
element.dispatchEvent(new Event('input', { bubbles: true })) element.dispatchEvent(new Event('input', { bubbles: true }))
}
await waitFor(0.1) await waitFor(0.1)