fix(vue-widgets): make text widget clear button undoable via Ctrl+Z (#1056)

This commit is contained in:
Will Miao
2026-08-14 23:16:12 +08:00
parent 280181f92e
commit d43ab6e32f
4 changed files with 219 additions and 36 deletions
@@ -78,7 +78,17 @@ const updateHasTextState = () => {
hasText.value = textareaRef.value ? textareaRef.value.value.length > 0 : false
}
const onInput = () => {
const onInput = (event: Event) => {
// A clear via execCommand captures the full-text selection in the browser's
// undo entry; Ctrl+Z restores the content together with that selection.
// Collapse the caret so the restored text is not left selected.
if ((event as InputEvent).inputType === 'historyUndo') {
const ta = textareaRef.value
if (ta && ta.selectionStart === 0 && ta.selectionEnd === ta.value.length) {
ta.setSelectionRange(ta.value.length, ta.value.length)
}
}
// Update hasText state
updateHasTextState()
@@ -156,20 +166,44 @@ const setupWidgetOnSetValue = () => {
}
}
/**
* Clear the textarea contents.
*
* Uses a trusted editing command (execCommand: select all + replace with
* empty string) so the browser records the clear as an undoable edit —
* Ctrl+Z with focus in the textarea restores the cleared text. Falls back
* to a plain programmatic clear when execCommand is unavailable (e.g. jsdom
* test environment), which is not undoable via native Ctrl+Z.
*/
const clearText = () => {
if (textareaRef.value) {
textareaRef.value.value = ''
hasText.value = false
textareaRef.value.focus()
// Trigger callback with empty value
if (typeof props.widget.callback === 'function') {
props.widget.callback('')
}
// Dispatch input event to ensure autocomplete handles the change
textareaRef.value.dispatchEvent(new Event('input'))
const ta = textareaRef.value
if (!ta || ta.value.length === 0) return
// Select all + replace via a trusted edit command so the browser pushes an
// undo entry that restores the full previous content.
ta.focus()
ta.setSelectionRange(0, ta.value.length)
let ok = false
try {
// Guarded for engines without execCommand (jsdom); some engines also
// throw instead of returning false for unsupported commands.
ok = typeof document.execCommand === 'function' && document.execCommand('insertText', false, '')
} catch {
ok = false
}
if (ok) {
// execCommand fired a trusted 'input' event → onInput already synced
// hasText, called the widget callback, and notified the autocomplete.
hasText.value = false
return
}
// Fallback: execCommand unavailable (jsdom / unsupported browser) — plain
// programmatic clear. The dispatched input event keeps onInput, the widget
// callback, and the autocomplete in sync.
ta.value = ''
ta.dispatchEvent(new Event('input'))
}
onMounted(() => {
@@ -0,0 +1,136 @@
/**
* Tests for AutocompleteTextWidget — clear button behavior.
*
* The clear button must clear the textarea through a trusted editing command
* (execCommand) so the browser records an undo entry and Ctrl+Z (with focus
* in the textarea) can restore the cleared content. jsdom's execCommand is a
* no-op that returns false, so the fallback manual-clear path is exercised by
* default; the execCommand path is covered by emulating the browser edit.
*/
import { nextTick } from 'vue'
import { shallowMount } from '@vue/test-utils'
import { describe, expect, it, vi, afterEach } from 'vitest'
import AutocompleteTextWidget from '@/components/AutocompleteTextWidget.vue'
function createMockWidget() {
return {
callback: vi.fn(),
onSetValue: undefined,
inputEl: undefined,
metadataWidget: undefined,
name: 'text',
}
}
function mountWidget() {
const widget = createMockWidget()
const node = { id: 1 }
const wrapper = shallowMount(AutocompleteTextWidget, {
props: { widget, node, modelType: 'prompt' },
// Attach to the document so jsdom implements real focus behavior
attachTo: document.body,
})
return { wrapper, widget }
}
afterEach(() => {
document.body.innerHTML = ''
delete (document as unknown as { execCommand?: unknown }).execCommand
})
describe('AutocompleteTextWidget clear button', () => {
it('is hidden when the textarea is empty and appears once text is entered', async () => {
const { wrapper } = mountWidget()
expect(wrapper.find('.clear-button').exists()).toBe(false)
await wrapper.find('textarea').setValue('hello <lora:foo:1>')
expect(wrapper.find('.clear-button').exists()).toBe(true)
})
it('clears the textarea via the fallback path when execCommand is unavailable', async () => {
const { wrapper, widget } = mountWidget()
const textarea = wrapper.find('textarea')
await textarea.setValue('hello <lora:foo:1>')
expect(widget.callback).toHaveBeenLastCalledWith('hello <lora:foo:1>')
// jsdom does not define document.execCommand at all, so the availability
// guard fails and the fallback manual clear runs: value reset + synthetic
// input event.
await wrapper.find('.clear-button').trigger('click')
expect((textarea.element as HTMLTextAreaElement).value).toBe('')
expect(wrapper.find('.clear-button').exists()).toBe(false)
expect(widget.callback).toHaveBeenLastCalledWith('')
// Focus returns to the textarea so Ctrl+Z can trigger native undo
expect(document.activeElement).toBe(textarea.element)
})
it('clears through a trusted execCommand edit so the browser records an undo entry', async () => {
const { wrapper, widget } = mountWidget()
const textarea = wrapper.find('textarea')
await textarea.setValue('hello world')
widget.callback.mockClear()
// Emulate Chromium: replace the selection with the given text, then fire
// a trusted input event that Vue and the autocomplete listeners observe.
// jsdom has no document.execCommand, so define it for this test.
const execMock = vi.fn((_cmd: string, _showUI: boolean, value: string) => {
const ta = document.activeElement as HTMLTextAreaElement | null
if (!ta || ta.tagName !== 'TEXTAREA') return false
ta.value = String(value ?? '')
ta.dispatchEvent(new Event('input', { bubbles: true }))
return true
})
Object.defineProperty(document, 'execCommand', { configurable: true, value: execMock })
await wrapper.find('.clear-button').trigger('click')
expect(execMock).toHaveBeenCalledWith('insertText', false, '')
expect((textarea.element as HTMLTextAreaElement).value).toBe('')
// Callback is driven by the trusted input event (exactly once, no double call)
expect(widget.callback).toHaveBeenCalledTimes(1)
expect(widget.callback).toHaveBeenCalledWith('')
expect(wrapper.find('.clear-button').exists()).toBe(false)
})
it('collapses the selection when Ctrl+Z restores the cleared text', async () => {
const { wrapper, widget } = mountWidget()
const textarea = wrapper.find('textarea')
const ta = textarea.element as HTMLTextAreaElement
await textarea.setValue('hello world')
widget.callback.mockClear()
// Clear via the trusted edit path (emulated Chromium)
Object.defineProperty(document, 'execCommand', {
configurable: true,
value: vi.fn(() => {
ta.value = ''
ta.dispatchEvent(new Event('input', { bubbles: true }))
return true
}),
})
await wrapper.find('.clear-button').trigger('click')
// Simulate the browser's undo: restore the text and the captured
// full-text selection, then fire the historyUndo input event
ta.value = 'hello world'
ta.setSelectionRange(0, ta.value.length)
ta.dispatchEvent(
Object.assign(new Event('input', { bubbles: true }), { inputType: 'historyUndo' })
)
await nextTick()
expect(ta.value).toBe('hello world')
// The restored text must not remain selected — caret collapsed to the end
expect(ta.selectionStart).toBe(ta.value.length)
expect(ta.selectionEnd).toBe(ta.value.length)
expect(wrapper.find('.clear-button').exists()).toBe(true)
expect(widget.callback).toHaveBeenLastCalledWith('hello world')
})
})