mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-08-15 10:13:22 -03:00
fix(vue-widgets): make text widget clear button undoable via Ctrl+Z (#1056)
This commit is contained in:
@@ -78,7 +78,17 @@ const updateHasTextState = () => {
|
|||||||
hasText.value = textareaRef.value ? textareaRef.value.value.length > 0 : false
|
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
|
// Update hasText state
|
||||||
updateHasTextState()
|
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 = () => {
|
const clearText = () => {
|
||||||
if (textareaRef.value) {
|
const ta = textareaRef.value
|
||||||
textareaRef.value.value = ''
|
if (!ta || ta.value.length === 0) return
|
||||||
hasText.value = false
|
|
||||||
textareaRef.value.focus()
|
// Select all + replace via a trusted edit command so the browser pushes an
|
||||||
|
// undo entry that restores the full previous content.
|
||||||
// Trigger callback with empty value
|
ta.focus()
|
||||||
if (typeof props.widget.callback === 'function') {
|
ta.setSelectionRange(0, ta.value.length)
|
||||||
props.widget.callback('')
|
let ok = false
|
||||||
}
|
try {
|
||||||
|
// Guarded for engines without execCommand (jsdom); some engines also
|
||||||
// Dispatch input event to ensure autocomplete handles the change
|
// throw instead of returning false for unsupported commands.
|
||||||
textareaRef.value.dispatchEvent(new Event('input'))
|
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(() => {
|
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')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -2118,14 +2118,14 @@ to { transform: rotate(360deg);
|
|||||||
padding: 20px 0;
|
padding: 20px 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.autocomplete-text-widget[data-v-55e3316e] {
|
.autocomplete-text-widget[data-v-4e322fec] {
|
||||||
background: transparent;
|
background: transparent;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
.input-wrapper[data-v-55e3316e] {
|
.input-wrapper[data-v-4e322fec] {
|
||||||
position: relative;
|
position: relative;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -2133,7 +2133,7 @@ to { transform: rotate(360deg);
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* Canvas mode styles (default) - matches built-in comfy-multiline-input */
|
/* Canvas mode styles (default) - matches built-in comfy-multiline-input */
|
||||||
.text-input[data-v-55e3316e] {
|
.text-input[data-v-4e322fec] {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
background-color: var(--comfy-input-bg, #222);
|
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 */
|
/* 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);
|
background-color: var(--color-charcoal-400, #313235);
|
||||||
color: #fff;
|
color: #fff;
|
||||||
padding: 8px 12px 30px 12px; /* Reserve bottom space for clear button */
|
padding: 8px 12px 30px 12px; /* Reserve bottom space for clear button */
|
||||||
@@ -2161,12 +2161,12 @@ to { transform: rotate(360deg);
|
|||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
font-family: inherit;
|
font-family: inherit;
|
||||||
}
|
}
|
||||||
.text-input[data-v-55e3316e]:focus {
|
.text-input[data-v-4e322fec]:focus {
|
||||||
outline: none;
|
outline: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Clear button styles */
|
/* Clear button styles */
|
||||||
.clear-button[data-v-55e3316e] {
|
.clear-button[data-v-4e322fec] {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
right: 6px;
|
right: 6px;
|
||||||
bottom: 6px; /* Changed from top to bottom */
|
bottom: 6px; /* Changed from top to bottom */
|
||||||
@@ -2189,31 +2189,31 @@ to { transform: rotate(360deg);
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* Show clear button when hovering over input wrapper */
|
/* 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;
|
opacity: 0.7;
|
||||||
pointer-events: auto;
|
pointer-events: auto;
|
||||||
}
|
}
|
||||||
.clear-button[data-v-55e3316e]:hover {
|
.clear-button[data-v-4e322fec]:hover {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
background: rgba(255, 100, 100, 0.8);
|
background: rgba(255, 100, 100, 0.8);
|
||||||
}
|
}
|
||||||
.clear-button svg[data-v-55e3316e] {
|
.clear-button svg[data-v-4e322fec] {
|
||||||
width: 12px;
|
width: 12px;
|
||||||
height: 12px;
|
height: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Vue DOM mode adjustments for clear button */
|
/* 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;
|
right: 8px;
|
||||||
bottom: 10px; /* Changed from top to bottom, adjusted for Vue DOM padding */
|
bottom: 10px; /* Changed from top to bottom, adjusted for Vue DOM padding */
|
||||||
width: 20px;
|
width: 20px;
|
||||||
height: 20px;
|
height: 20px;
|
||||||
background: rgba(107, 114, 128, 0.6);
|
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);
|
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;
|
width: 14px;
|
||||||
height: 14px;
|
height: 14px;
|
||||||
}
|
}
|
||||||
@@ -15168,7 +15168,13 @@ const _sfc_main$1 = /* @__PURE__ */ defineComponent({
|
|||||||
const updateHasTextState = () => {
|
const updateHasTextState = () => {
|
||||||
hasText.value = textareaRef.value ? textareaRef.value.value.length > 0 : false;
|
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();
|
updateHasTextState();
|
||||||
if (textareaRef.value && typeof props.widget.callback === "function") {
|
if (textareaRef.value && typeof props.widget.callback === "function") {
|
||||||
props.widget.callback(textareaRef.value.value);
|
props.widget.callback(textareaRef.value.value);
|
||||||
@@ -15215,15 +15221,22 @@ const _sfc_main$1 = /* @__PURE__ */ defineComponent({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
const clearText = () => {
|
const clearText = () => {
|
||||||
if (textareaRef.value) {
|
const ta = textareaRef.value;
|
||||||
textareaRef.value.value = "";
|
if (!ta || ta.value.length === 0) return;
|
||||||
hasText.value = false;
|
ta.focus();
|
||||||
textareaRef.value.focus();
|
ta.setSelectionRange(0, ta.value.length);
|
||||||
if (typeof props.widget.callback === "function") {
|
let ok = false;
|
||||||
props.widget.callback("");
|
try {
|
||||||
}
|
ok = typeof document.execCommand === "function" && document.execCommand("insertText", false, "");
|
||||||
textareaRef.value.dispatchEvent(new Event("input"));
|
} catch {
|
||||||
|
ok = false;
|
||||||
}
|
}
|
||||||
|
if (ok) {
|
||||||
|
hasText.value = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
ta.value = "";
|
||||||
|
ta.dispatchEvent(new Event("input"));
|
||||||
};
|
};
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
if (textareaRef.value) {
|
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_1 = { class: "lora-info-tabs" };
|
||||||
const _hoisted_2 = { class: "tab-content notes-tab" };
|
const _hoisted_2 = { class: "tab-content notes-tab" };
|
||||||
const _hoisted_3 = { class: "info-field" };
|
const _hoisted_3 = { class: "info-field" };
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user