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')
})
})
+35 -22
View File
@@ -2118,14 +2118,14 @@ to { transform: rotate(360deg);
padding: 20px 0;
}
.autocomplete-text-widget[data-v-55e3316e] {
.autocomplete-text-widget[data-v-4e322fec] {
background: transparent;
height: 100%;
display: flex;
flex-direction: column;
box-sizing: border-box;
}
.input-wrapper[data-v-55e3316e] {
.input-wrapper[data-v-4e322fec] {
position: relative;
flex: 1;
display: flex;
@@ -2133,7 +2133,7 @@ to { transform: rotate(360deg);
}
/* Canvas mode styles (default) - matches built-in comfy-multiline-input */
.text-input[data-v-55e3316e] {
.text-input[data-v-4e322fec] {
flex: 1;
width: 100%;
background-color: var(--comfy-input-bg, #222);
@@ -2152,7 +2152,7 @@ to { transform: rotate(360deg);
}
/* Vue DOM mode styles - matches built-in p-textarea in Vue DOM mode */
.text-input.vue-dom-mode[data-v-55e3316e] {
.text-input.vue-dom-mode[data-v-4e322fec] {
background-color: var(--color-charcoal-400, #313235);
color: #fff;
padding: 8px 12px 30px 12px; /* Reserve bottom space for clear button */
@@ -2161,12 +2161,12 @@ to { transform: rotate(360deg);
font-size: 12px;
font-family: inherit;
}
.text-input[data-v-55e3316e]:focus {
.text-input[data-v-4e322fec]:focus {
outline: none;
}
/* Clear button styles */
.clear-button[data-v-55e3316e] {
.clear-button[data-v-4e322fec] {
position: absolute;
right: 6px;
bottom: 6px; /* Changed from top to bottom */
@@ -2189,31 +2189,31 @@ to { transform: rotate(360deg);
}
/* Show clear button when hovering over input wrapper */
.input-wrapper:hover .clear-button[data-v-55e3316e] {
.input-wrapper:hover .clear-button[data-v-4e322fec] {
opacity: 0.7;
pointer-events: auto;
}
.clear-button[data-v-55e3316e]:hover {
.clear-button[data-v-4e322fec]:hover {
opacity: 1;
background: rgba(255, 100, 100, 0.8);
}
.clear-button svg[data-v-55e3316e] {
.clear-button svg[data-v-4e322fec] {
width: 12px;
height: 12px;
}
/* Vue DOM mode adjustments for clear button */
.text-input.vue-dom-mode ~ .clear-button[data-v-55e3316e] {
.text-input.vue-dom-mode ~ .clear-button[data-v-4e322fec] {
right: 8px;
bottom: 10px; /* Changed from top to bottom, adjusted for Vue DOM padding */
width: 20px;
height: 20px;
background: rgba(107, 114, 128, 0.6);
}
.text-input.vue-dom-mode ~ .clear-button[data-v-55e3316e]:hover {
.text-input.vue-dom-mode ~ .clear-button[data-v-4e322fec]:hover {
background: oklch(62% 0.18 25);
}
.text-input.vue-dom-mode ~ .clear-button svg[data-v-55e3316e] {
.text-input.vue-dom-mode ~ .clear-button svg[data-v-4e322fec] {
width: 14px;
height: 14px;
}
@@ -15168,7 +15168,13 @@ const _sfc_main$1 = /* @__PURE__ */ defineComponent({
const updateHasTextState = () => {
hasText.value = textareaRef.value ? textareaRef.value.value.length > 0 : false;
};
const onInput = () => {
const onInput = (event) => {
if (event.inputType === "historyUndo") {
const ta = textareaRef.value;
if (ta && ta.selectionStart === 0 && ta.selectionEnd === ta.value.length) {
ta.setSelectionRange(ta.value.length, ta.value.length);
}
}
updateHasTextState();
if (textareaRef.value && typeof props.widget.callback === "function") {
props.widget.callback(textareaRef.value.value);
@@ -15215,15 +15221,22 @@ const _sfc_main$1 = /* @__PURE__ */ defineComponent({
}
};
const clearText = () => {
if (textareaRef.value) {
textareaRef.value.value = "";
hasText.value = false;
textareaRef.value.focus();
if (typeof props.widget.callback === "function") {
props.widget.callback("");
}
textareaRef.value.dispatchEvent(new Event("input"));
const ta = textareaRef.value;
if (!ta || ta.value.length === 0) return;
ta.focus();
ta.setSelectionRange(0, ta.value.length);
let ok = false;
try {
ok = typeof document.execCommand === "function" && document.execCommand("insertText", false, "");
} catch {
ok = false;
}
if (ok) {
hasText.value = false;
return;
}
ta.value = "";
ta.dispatchEvent(new Event("input"));
};
onMounted(() => {
if (textareaRef.value) {
@@ -15316,7 +15329,7 @@ const _sfc_main$1 = /* @__PURE__ */ defineComponent({
};
}
});
const AutocompleteTextWidget = /* @__PURE__ */ _export_sfc(_sfc_main$1, [["__scopeId", "data-v-55e3316e"]]);
const AutocompleteTextWidget = /* @__PURE__ */ _export_sfc(_sfc_main$1, [["__scopeId", "data-v-4e322fec"]]);
const _hoisted_1 = { class: "lora-info-tabs" };
const _hoisted_2 = { class: "tab-content notes-tab" };
const _hoisted_3 = { class: "info-field" };
File diff suppressed because one or more lines are too long