mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-03-28 08:28:53 -03:00
feat(autocomplete): support Tab accept and configurable suffix behavior (#863)
This commit is contained in:
@@ -15,6 +15,8 @@ const {
|
||||
}));
|
||||
|
||||
const fetchApiMock = vi.fn();
|
||||
const settingGetMock = vi.fn();
|
||||
const settingSetMock = vi.fn();
|
||||
const caretHelperInstance = {
|
||||
getBeforeCursor: vi.fn(() => ''),
|
||||
getCursorOffset: vi.fn(() => ({ left: 0, top: 0 })),
|
||||
@@ -37,6 +39,12 @@ vi.mock(APP_MODULE, () => ({
|
||||
canvas: {
|
||||
ds: { scale: 1 },
|
||||
},
|
||||
extensionManager: {
|
||||
setting: {
|
||||
get: settingGetMock,
|
||||
set: settingSetMock,
|
||||
},
|
||||
},
|
||||
registerExtension: vi.fn(),
|
||||
},
|
||||
}));
|
||||
@@ -55,6 +63,20 @@ describe('AutoComplete widget interactions', () => {
|
||||
document.head.querySelectorAll('style').forEach((styleEl) => styleEl.remove());
|
||||
Element.prototype.scrollIntoView = vi.fn();
|
||||
fetchApiMock.mockReset();
|
||||
settingGetMock.mockReset();
|
||||
settingSetMock.mockReset();
|
||||
settingGetMock.mockImplementation((key) => {
|
||||
if (key === 'loramanager.autocomplete_append_comma') {
|
||||
return true;
|
||||
}
|
||||
if (key === 'loramanager.prompt_tag_autocomplete') {
|
||||
return true;
|
||||
}
|
||||
if (key === 'loramanager.tag_space_replacement') {
|
||||
return false;
|
||||
}
|
||||
return undefined;
|
||||
});
|
||||
caretHelperInstance.getBeforeCursor.mockReset();
|
||||
caretHelperInstance.getCursorOffset.mockReset();
|
||||
caretHelperInstance.getBeforeCursor.mockReturnValue('');
|
||||
@@ -82,7 +104,7 @@ describe('AutoComplete widget interactions', () => {
|
||||
document.body.append(input);
|
||||
|
||||
const { AutoComplete } = await import(AUTOCOMPLETE_MODULE);
|
||||
const autoComplete = new AutoComplete(input, 'loras', { debounceDelay: 0, showPreview: false });
|
||||
const autoComplete = new AutoComplete(input,'loras', { debounceDelay: 0, showPreview: false });
|
||||
|
||||
input.value = 'example';
|
||||
input.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
@@ -125,25 +147,100 @@ describe('AutoComplete widget interactions', () => {
|
||||
document.body.append(input);
|
||||
|
||||
const { AutoComplete } = await import(AUTOCOMPLETE_MODULE);
|
||||
const autoComplete = new AutoComplete(input, 'loras', { debounceDelay: 0, showPreview: false });
|
||||
const autoComplete = new AutoComplete(input,'loras', { debounceDelay: 0, showPreview: false });
|
||||
|
||||
await autoComplete.insertSelection('models/example.safetensors');
|
||||
|
||||
expect(fetchApiMock).toHaveBeenCalledWith(
|
||||
'/lm/loras/usage-tips-by-path?relative_path=models%2Fexample.safetensors',
|
||||
);
|
||||
expect(input.value).toContain('<lora:example:1.5:0.9>, ');
|
||||
expect(input.value).toContain('<lora:example:1.5:0.9>,');
|
||||
expect(autoComplete.dropdown.style.display).toBe('none');
|
||||
expect(input.focus).toHaveBeenCalled();
|
||||
expect(input.setSelectionRange).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('accepts the selected suggestion with Tab', async () => {
|
||||
caretHelperInstance.getBeforeCursor.mockReturnValue('example');
|
||||
|
||||
const input = document.createElement('textarea');
|
||||
input.value = 'example';
|
||||
input.selectionStart = input.value.length;
|
||||
input.focus = vi.fn();
|
||||
input.setSelectionRange = vi.fn();
|
||||
document.body.append(input);
|
||||
|
||||
const { AutoComplete } = await import(AUTOCOMPLETE_MODULE);
|
||||
const autoComplete = new AutoComplete(input,'custom_words', { showPreview: false });
|
||||
|
||||
autoComplete.items = ['example_completion'];
|
||||
autoComplete.selectedIndex = 0;
|
||||
autoComplete.isVisible = true;
|
||||
const insertSelectionSpy = vi.spyOn(autoComplete,'insertSelection').mockResolvedValue();
|
||||
|
||||
const tabEvent = new KeyboardEvent('keydown', { key: 'Tab', bubbles: true, cancelable: true });
|
||||
input.dispatchEvent(tabEvent);
|
||||
|
||||
expect(tabEvent.defaultPrevented).toBe(true);
|
||||
expect(insertSelectionSpy).toHaveBeenCalledWith('example_completion');
|
||||
});
|
||||
|
||||
it('accepts the selected suggestion with Enter', async () => {
|
||||
caretHelperInstance.getBeforeCursor.mockReturnValue('example');
|
||||
|
||||
const input = document.createElement('textarea');
|
||||
input.value = 'example';
|
||||
input.selectionStart = input.value.length;
|
||||
input.focus = vi.fn();
|
||||
input.setSelectionRange = vi.fn();
|
||||
document.body.append(input);
|
||||
|
||||
const { AutoComplete } = await import(AUTOCOMPLETE_MODULE);
|
||||
const autoComplete = new AutoComplete(input,'custom_words', { showPreview: false });
|
||||
|
||||
autoComplete.items = ['example_completion'];
|
||||
autoComplete.selectedIndex = 0;
|
||||
autoComplete.isVisible = true;
|
||||
const insertSelectionSpy = vi.spyOn(autoComplete,'insertSelection').mockResolvedValue();
|
||||
|
||||
const enterEvent = new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true });
|
||||
input.dispatchEvent(enterEvent);
|
||||
|
||||
expect(enterEvent.defaultPrevented).toBe(true);
|
||||
expect(insertSelectionSpy).toHaveBeenCalledWith('example_completion');
|
||||
});
|
||||
|
||||
it('does not intercept Tab when the dropdown is not visible', async () => {
|
||||
caretHelperInstance.getBeforeCursor.mockReturnValue('example');
|
||||
|
||||
const input = document.createElement('textarea');
|
||||
input.value = 'example';
|
||||
input.selectionStart = input.value.length;
|
||||
input.focus = vi.fn();
|
||||
input.setSelectionRange = vi.fn();
|
||||
document.body.append(input);
|
||||
|
||||
const { AutoComplete } = await import(AUTOCOMPLETE_MODULE);
|
||||
const autoComplete = new AutoComplete(input,'custom_words', { showPreview: false });
|
||||
|
||||
autoComplete.items = ['example_completion'];
|
||||
autoComplete.selectedIndex = 0;
|
||||
autoComplete.isVisible = false;
|
||||
const insertSelectionSpy = vi.spyOn(autoComplete,'insertSelection').mockResolvedValue();
|
||||
|
||||
const tabEvent = new KeyboardEvent('keydown', { key: 'Tab', bubbles: true, cancelable: true });
|
||||
input.dispatchEvent(tabEvent);
|
||||
|
||||
expect(tabEvent.defaultPrevented).toBe(false);
|
||||
expect(insertSelectionSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('highlights multiple include tokens while ignoring excluded ones', async () => {
|
||||
const input = document.createElement('textarea');
|
||||
document.body.append(input);
|
||||
|
||||
const { AutoComplete } = await import(AUTOCOMPLETE_MODULE);
|
||||
const autoComplete = new AutoComplete(input, 'loras', { showPreview: false });
|
||||
const autoComplete = new AutoComplete(input,'loras', { showPreview: false });
|
||||
|
||||
const highlighted = autoComplete.highlightMatch(
|
||||
'models/flux/beta-detail.safetensors',
|
||||
@@ -160,7 +257,7 @@ describe('AutoComplete widget interactions', () => {
|
||||
it('handles arrow key navigation with virtual scrolling', async () => {
|
||||
vi.useFakeTimers();
|
||||
|
||||
const mockItems = Array.from({ length: 50 }, (_, i) => `model_${i.toString().padStart(2, '0')}.safetensors`);
|
||||
const mockItems = Array.from({ length: 50 }, (_, i) => `model_${i.toString().padStart(2,'0')}.safetensors`);
|
||||
|
||||
fetchApiMock.mockResolvedValue({
|
||||
json: () => Promise.resolve({ success: true, relative_paths: mockItems }),
|
||||
@@ -173,7 +270,7 @@ describe('AutoComplete widget interactions', () => {
|
||||
document.body.append(input);
|
||||
|
||||
const { AutoComplete } = await import(AUTOCOMPLETE_MODULE);
|
||||
const autoComplete = new AutoComplete(input, 'loras', {
|
||||
const autoComplete = new AutoComplete(input,'loras', {
|
||||
debounceDelay: 0,
|
||||
showPreview: false,
|
||||
enableVirtualScroll: true,
|
||||
@@ -216,7 +313,7 @@ describe('AutoComplete widget interactions', () => {
|
||||
it('maintains selection when scrolling to invisible items', async () => {
|
||||
vi.useFakeTimers();
|
||||
|
||||
const mockItems = Array.from({ length: 100 }, (_, i) => `item_${i.toString().padStart(3, '0')}.safetensors`);
|
||||
const mockItems = Array.from({ length: 100 }, (_, i) => `item_${i.toString().padStart(3,'0')}.safetensors`);
|
||||
|
||||
fetchApiMock.mockResolvedValue({
|
||||
json: () => Promise.resolve({ success: true, relative_paths: mockItems }),
|
||||
@@ -231,7 +328,7 @@ describe('AutoComplete widget interactions', () => {
|
||||
document.body.append(input);
|
||||
|
||||
const { AutoComplete } = await import(AUTOCOMPLETE_MODULE);
|
||||
const autoComplete = new AutoComplete(input, 'loras', {
|
||||
const autoComplete = new AutoComplete(input,'loras', {
|
||||
debounceDelay: 0,
|
||||
showPreview: false,
|
||||
enableVirtualScroll: true,
|
||||
@@ -289,7 +386,7 @@ describe('AutoComplete widget interactions', () => {
|
||||
document.body.append(input);
|
||||
|
||||
const { AutoComplete } = await import(AUTOCOMPLETE_MODULE);
|
||||
const autoComplete = new AutoComplete(input, 'prompt', {
|
||||
const autoComplete = new AutoComplete(input,'prompt', {
|
||||
debounceDelay: 0,
|
||||
showPreview: false,
|
||||
minChars: 1,
|
||||
@@ -302,7 +399,7 @@ describe('AutoComplete widget interactions', () => {
|
||||
|
||||
await autoComplete.insertSelection('looking_to_the_side');
|
||||
|
||||
expect(input.value).toBe('looking_to_the_side, ');
|
||||
expect(input.value).toBe('looking_to_the_side,');
|
||||
expect(autoComplete.dropdown.style.display).toBe('none');
|
||||
expect(input.focus).toHaveBeenCalled();
|
||||
});
|
||||
@@ -328,7 +425,7 @@ describe('AutoComplete widget interactions', () => {
|
||||
document.body.append(input);
|
||||
|
||||
const { AutoComplete } = await import(AUTOCOMPLETE_MODULE);
|
||||
const autoComplete = new AutoComplete(input, 'prompt', {
|
||||
const autoComplete = new AutoComplete(input,'prompt', {
|
||||
debounceDelay: 0,
|
||||
showPreview: false,
|
||||
minChars: 1,
|
||||
@@ -342,7 +439,7 @@ describe('AutoComplete widget interactions', () => {
|
||||
|
||||
await autoComplete.insertSelection('1girl');
|
||||
|
||||
expect(input.value).toBe('hello 1girl, ');
|
||||
expect(input.value).toBe('hello 1girl,');
|
||||
});
|
||||
|
||||
it('replaces entire phrase for underscore tag match (e.g., "blue hair" -> "blue_hair")', async () => {
|
||||
@@ -366,7 +463,7 @@ describe('AutoComplete widget interactions', () => {
|
||||
document.body.append(input);
|
||||
|
||||
const { AutoComplete } = await import(AUTOCOMPLETE_MODULE);
|
||||
const autoComplete = new AutoComplete(input, 'prompt', {
|
||||
const autoComplete = new AutoComplete(input,'prompt', {
|
||||
debounceDelay: 0,
|
||||
showPreview: false,
|
||||
minChars: 1,
|
||||
@@ -380,7 +477,7 @@ describe('AutoComplete widget interactions', () => {
|
||||
|
||||
await autoComplete.insertSelection('blue_hair');
|
||||
|
||||
expect(input.value).toBe('blue_hair, ');
|
||||
expect(input.value).toBe('blue_hair,');
|
||||
});
|
||||
|
||||
it('handles multi-word phrase with preceding text correctly', async () => {
|
||||
@@ -403,7 +500,7 @@ describe('AutoComplete widget interactions', () => {
|
||||
document.body.append(input);
|
||||
|
||||
const { AutoComplete } = await import(AUTOCOMPLETE_MODULE);
|
||||
const autoComplete = new AutoComplete(input, 'prompt', {
|
||||
const autoComplete = new AutoComplete(input,'prompt', {
|
||||
debounceDelay: 0,
|
||||
showPreview: false,
|
||||
minChars: 1,
|
||||
@@ -417,7 +514,7 @@ describe('AutoComplete widget interactions', () => {
|
||||
|
||||
await autoComplete.insertSelection('looking_to_the_side');
|
||||
|
||||
expect(input.value).toBe('1girl, looking_to_the_side, ');
|
||||
expect(input.value).toBe('1girl, looking_to_the_side,');
|
||||
});
|
||||
|
||||
it('replaces entire command and search term when using command mode with multi-word phrase', async () => {
|
||||
@@ -442,7 +539,7 @@ describe('AutoComplete widget interactions', () => {
|
||||
document.body.append(input);
|
||||
|
||||
const { AutoComplete } = await import(AUTOCOMPLETE_MODULE);
|
||||
const autoComplete = new AutoComplete(input, 'prompt', {
|
||||
const autoComplete = new AutoComplete(input,'prompt', {
|
||||
debounceDelay: 0,
|
||||
showPreview: false,
|
||||
minChars: 1,
|
||||
@@ -458,7 +555,7 @@ describe('AutoComplete widget interactions', () => {
|
||||
await autoComplete.insertSelection('looking_to_the_side');
|
||||
|
||||
// Command part should be replaced along with search term
|
||||
expect(input.value).toBe('looking_to_the_side, ');
|
||||
expect(input.value).toBe('looking_to_the_side,');
|
||||
});
|
||||
|
||||
it('replaces only last token when multi-word query does not exactly match selected tag', async () => {
|
||||
@@ -483,7 +580,7 @@ describe('AutoComplete widget interactions', () => {
|
||||
document.body.append(input);
|
||||
|
||||
const { AutoComplete } = await import(AUTOCOMPLETE_MODULE);
|
||||
const autoComplete = new AutoComplete(input, 'prompt', {
|
||||
const autoComplete = new AutoComplete(input,'prompt', {
|
||||
debounceDelay: 0,
|
||||
showPreview: false,
|
||||
minChars: 1,
|
||||
@@ -498,7 +595,7 @@ describe('AutoComplete widget interactions', () => {
|
||||
await autoComplete.insertSelection('blue_hair');
|
||||
|
||||
// Only "blue" should be replaced, not the entire phrase
|
||||
expect(input.value).toBe('looking to the blue_hair, ');
|
||||
expect(input.value).toBe('looking to the blue_hair,');
|
||||
});
|
||||
|
||||
it('handles multiple consecutive spaces in multi-word phrase correctly', async () => {
|
||||
@@ -522,7 +619,7 @@ describe('AutoComplete widget interactions', () => {
|
||||
document.body.append(input);
|
||||
|
||||
const { AutoComplete } = await import(AUTOCOMPLETE_MODULE);
|
||||
const autoComplete = new AutoComplete(input, 'prompt', {
|
||||
const autoComplete = new AutoComplete(input,'prompt', {
|
||||
debounceDelay: 0,
|
||||
showPreview: false,
|
||||
minChars: 1,
|
||||
@@ -537,7 +634,7 @@ describe('AutoComplete widget interactions', () => {
|
||||
await autoComplete.insertSelection('looking_to_the_side');
|
||||
|
||||
// Multiple spaces should be normalized to single underscores for matching
|
||||
expect(input.value).toBe('looking_to_the_side, ');
|
||||
expect(input.value).toBe('looking_to_the_side,');
|
||||
});
|
||||
|
||||
it('handles command mode with partial match replacing only last token', async () => {
|
||||
@@ -561,7 +658,7 @@ describe('AutoComplete widget interactions', () => {
|
||||
document.body.append(input);
|
||||
|
||||
const { AutoComplete } = await import(AUTOCOMPLETE_MODULE);
|
||||
const autoComplete = new AutoComplete(input, 'prompt', {
|
||||
const autoComplete = new AutoComplete(input,'prompt', {
|
||||
debounceDelay: 0,
|
||||
showPreview: false,
|
||||
minChars: 1,
|
||||
@@ -577,7 +674,7 @@ describe('AutoComplete widget interactions', () => {
|
||||
await autoComplete.insertSelection('blue_hair');
|
||||
|
||||
// In command mode, the entire command + search term should be replaced
|
||||
expect(input.value).toBe('blue_hair, ');
|
||||
expect(input.value).toBe('blue_hair,');
|
||||
});
|
||||
|
||||
it('replaces entire phrase when selected tag starts with underscore version of search term (prefix match)', async () => {
|
||||
@@ -601,7 +698,7 @@ describe('AutoComplete widget interactions', () => {
|
||||
document.body.append(input);
|
||||
|
||||
const { AutoComplete } = await import(AUTOCOMPLETE_MODULE);
|
||||
const autoComplete = new AutoComplete(input, 'prompt', {
|
||||
const autoComplete = new AutoComplete(input,'prompt', {
|
||||
debounceDelay: 0,
|
||||
showPreview: false,
|
||||
minChars: 1,
|
||||
@@ -616,7 +713,7 @@ describe('AutoComplete widget interactions', () => {
|
||||
await autoComplete.insertSelection('looking_to_the_side');
|
||||
|
||||
// Entire phrase should be replaced with selected tag (with underscores)
|
||||
expect(input.value).toBe('looking_to_the_side, ');
|
||||
expect(input.value).toBe('looking_to_the_side,');
|
||||
});
|
||||
|
||||
it('inserts tag with underscores regardless of space replacement setting', async () => {
|
||||
@@ -639,7 +736,7 @@ describe('AutoComplete widget interactions', () => {
|
||||
document.body.append(input);
|
||||
|
||||
const { AutoComplete } = await import(AUTOCOMPLETE_MODULE);
|
||||
const autoComplete = new AutoComplete(input, 'prompt', {
|
||||
const autoComplete = new AutoComplete(input,'prompt', {
|
||||
debounceDelay: 0,
|
||||
showPreview: false,
|
||||
minChars: 1,
|
||||
@@ -653,7 +750,249 @@ describe('AutoComplete widget interactions', () => {
|
||||
await autoComplete.insertSelection('blue_hair');
|
||||
|
||||
// Tag should be inserted with underscores, not spaces
|
||||
expect(input.value).toBe('blue_hair, ');
|
||||
expect(input.value).toBe('blue_hair,');
|
||||
});
|
||||
|
||||
it('omits the trailing comma when the append comma setting is disabled', async () => {
|
||||
settingGetMock.mockImplementation((key) => {
|
||||
if (key === 'loramanager.autocomplete_append_comma') {
|
||||
return false;
|
||||
}
|
||||
if (key === 'loramanager.prompt_tag_autocomplete') {
|
||||
return true;
|
||||
}
|
||||
if (key === 'loramanager.tag_space_replacement') {
|
||||
return false;
|
||||
}
|
||||
return undefined;
|
||||
});
|
||||
|
||||
const mockTags = [
|
||||
{ tag_name: 'blue_hair', category: 0, post_count: 45000 },
|
||||
];
|
||||
|
||||
caretHelperInstance.getBeforeCursor.mockReturnValue('blue hair');
|
||||
caretHelperInstance.getCursorOffset.mockReturnValue({ left: 15, top: 25 });
|
||||
|
||||
const input = document.createElement('textarea');
|
||||
input.value = 'blue hair';
|
||||
input.selectionStart = input.value.length;
|
||||
input.focus = vi.fn();
|
||||
input.setSelectionRange = vi.fn();
|
||||
document.body.append(input);
|
||||
|
||||
const { AutoComplete } = await import(AUTOCOMPLETE_MODULE);
|
||||
const autoComplete = new AutoComplete(input,'prompt', {
|
||||
debounceDelay: 0,
|
||||
showPreview: false,
|
||||
minChars: 1,
|
||||
});
|
||||
|
||||
autoComplete.searchType = 'custom_words';
|
||||
autoComplete.activeCommand = null;
|
||||
autoComplete.items = mockTags;
|
||||
autoComplete.selectedIndex = 0;
|
||||
autoComplete.currentSearchTerm = 'blue hair';
|
||||
|
||||
await autoComplete.insertSelection('blue_hair');
|
||||
|
||||
expect(input.value).toBe('blue_hair ');
|
||||
});
|
||||
|
||||
it('uses persisted autocomplete metadata as the next search start when comma append is disabled', async () => {
|
||||
vi.useFakeTimers();
|
||||
|
||||
settingGetMock.mockImplementation((key) => {
|
||||
if (key === 'loramanager.autocomplete_append_comma') {
|
||||
return false;
|
||||
}
|
||||
if (key === 'loramanager.prompt_tag_autocomplete') {
|
||||
return true;
|
||||
}
|
||||
if (key === 'loramanager.tag_space_replacement') {
|
||||
return false;
|
||||
}
|
||||
return undefined;
|
||||
});
|
||||
|
||||
fetchApiMock.mockResolvedValue({
|
||||
json: () => Promise.resolve({ success: true, words: [{ tag_name: 'cat_ears', category: 0, post_count: 1234 }] }),
|
||||
});
|
||||
|
||||
caretHelperInstance.getBeforeCursor.mockReturnValue('1girl cat');
|
||||
caretHelperInstance.getCursorOffset.mockReturnValue({ left: 15, top: 25 });
|
||||
|
||||
const input = document.createElement('textarea');
|
||||
input.value = '1girl cat';
|
||||
input.selectionStart = input.value.length;
|
||||
input.focus = vi.fn();
|
||||
input.setSelectionRange = vi.fn();
|
||||
input._autocompleteMetadataWidget = {
|
||||
value: {
|
||||
version: 1,
|
||||
textWidgetName: 'text',
|
||||
lastAccepted: {
|
||||
start: 0,
|
||||
end: 6,
|
||||
insertedText: '1girl ',
|
||||
textSnapshot: '1girl ',
|
||||
},
|
||||
},
|
||||
};
|
||||
document.body.append(input);
|
||||
|
||||
const { AutoComplete } = await import(AUTOCOMPLETE_MODULE);
|
||||
const autoComplete = new AutoComplete(input,'prompt', {
|
||||
debounceDelay: 0,
|
||||
showPreview: false,
|
||||
minChars: 1,
|
||||
});
|
||||
|
||||
expect(autoComplete.getSearchTerm(input.value)).toBe('cat');
|
||||
|
||||
input.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
await vi.runAllTimersAsync();
|
||||
await Promise.resolve();
|
||||
|
||||
expect(fetchApiMock).toHaveBeenCalledWith('/lm/custom-words/search?enriched=true&search=cat&limit=100');
|
||||
});
|
||||
|
||||
it('invalidates stale autocomplete metadata and falls back to delimiter-based matching', async () => {
|
||||
settingGetMock.mockImplementation((key) => {
|
||||
if (key === 'loramanager.autocomplete_append_comma') {
|
||||
return false;
|
||||
}
|
||||
if (key === 'loramanager.prompt_tag_autocomplete') {
|
||||
return true;
|
||||
}
|
||||
if (key === 'loramanager.tag_space_replacement') {
|
||||
return false;
|
||||
}
|
||||
return undefined;
|
||||
});
|
||||
|
||||
caretHelperInstance.getBeforeCursor.mockReturnValue('1boy cat');
|
||||
|
||||
const metadataWidget = {
|
||||
value: {
|
||||
version: 1,
|
||||
textWidgetName: 'text',
|
||||
lastAccepted: {
|
||||
start: 0,
|
||||
end: 6,
|
||||
insertedText: '1girl ',
|
||||
textSnapshot: '1girl ',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const input = document.createElement('textarea');
|
||||
input.value = '1boy cat';
|
||||
input.selectionStart = input.value.length;
|
||||
input._autocompleteMetadataWidget = metadataWidget;
|
||||
document.body.append(input);
|
||||
|
||||
const { AutoComplete } = await import(AUTOCOMPLETE_MODULE);
|
||||
const autoComplete = new AutoComplete(input,'prompt', {
|
||||
debounceDelay: 0,
|
||||
showPreview: false,
|
||||
minChars: 1,
|
||||
});
|
||||
|
||||
expect(autoComplete.getSearchTerm(input.value)).toBe('1boy cat');
|
||||
expect(metadataWidget.value.lastAccepted).toBeUndefined();
|
||||
});
|
||||
|
||||
it('does not duplicate the first character when accepting a suggestion after a trailing space', async () => {
|
||||
settingGetMock.mockImplementation((key) => {
|
||||
if (key === 'loramanager.autocomplete_append_comma') {
|
||||
return false;
|
||||
}
|
||||
if (key === 'loramanager.prompt_tag_autocomplete') {
|
||||
return true;
|
||||
}
|
||||
if (key === 'loramanager.tag_space_replacement') {
|
||||
return false;
|
||||
}
|
||||
return undefined;
|
||||
});
|
||||
|
||||
const mockTags = [
|
||||
{ tag_name: '1girl', category: 4, post_count: 500000 },
|
||||
];
|
||||
|
||||
caretHelperInstance.getBeforeCursor.mockReturnValue('1girl ');
|
||||
|
||||
const input = document.createElement('textarea');
|
||||
input.value = '1girl ';
|
||||
input.selectionStart = input.value.length;
|
||||
input.focus = vi.fn();
|
||||
input.setSelectionRange = vi.fn();
|
||||
document.body.append(input);
|
||||
|
||||
const { AutoComplete } = await import(AUTOCOMPLETE_MODULE);
|
||||
const autoComplete = new AutoComplete(input,'prompt', {
|
||||
debounceDelay: 0,
|
||||
showPreview: false,
|
||||
minChars: 1,
|
||||
});
|
||||
|
||||
autoComplete.searchType = 'custom_words';
|
||||
autoComplete.activeCommand = null;
|
||||
autoComplete.items = mockTags;
|
||||
autoComplete.selectedIndex = 0;
|
||||
|
||||
await autoComplete.insertSelection('1girl');
|
||||
|
||||
expect(input.value).toBe('1girl ');
|
||||
});
|
||||
|
||||
it('omits the trailing comma for LoRA insertions when the setting is disabled', async () => {
|
||||
settingGetMock.mockImplementation((key) => {
|
||||
if (key === 'loramanager.autocomplete_append_comma') {
|
||||
return false;
|
||||
}
|
||||
if (key === 'loramanager.prompt_tag_autocomplete') {
|
||||
return true;
|
||||
}
|
||||
if (key === 'loramanager.tag_space_replacement') {
|
||||
return false;
|
||||
}
|
||||
return undefined;
|
||||
});
|
||||
|
||||
fetchApiMock.mockImplementation((url) => {
|
||||
if (url.includes('usage-tips-by-path')) {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({
|
||||
success: true,
|
||||
usage_tips: JSON.stringify({ strength: '1.2' }),
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
return Promise.resolve({
|
||||
json: () => Promise.resolve({ success: true, relative_paths: ['models/example.safetensors'] }),
|
||||
});
|
||||
});
|
||||
|
||||
caretHelperInstance.getBeforeCursor.mockReturnValue('alpha, example');
|
||||
|
||||
const input = document.createElement('textarea');
|
||||
input.value = 'alpha, example';
|
||||
input.selectionStart = input.value.length;
|
||||
input.focus = vi.fn();
|
||||
input.setSelectionRange = vi.fn();
|
||||
document.body.append(input);
|
||||
|
||||
const { AutoComplete } = await import(AUTOCOMPLETE_MODULE);
|
||||
const autoComplete = new AutoComplete(input,'loras', { debounceDelay: 0, showPreview: false });
|
||||
|
||||
await autoComplete.insertSelection('models/example.safetensors');
|
||||
|
||||
expect(input.value).toContain('<lora:example:1.2>');
|
||||
expect(input.value).not.toContain('<lora:example:1.2>,');
|
||||
});
|
||||
|
||||
it('replaces entire phrase when selected tag ends with underscore version of search term (suffix match)', async () => {
|
||||
@@ -677,7 +1016,7 @@ describe('AutoComplete widget interactions', () => {
|
||||
document.body.append(input);
|
||||
|
||||
const { AutoComplete } = await import(AUTOCOMPLETE_MODULE);
|
||||
const autoComplete = new AutoComplete(input, 'prompt', {
|
||||
const autoComplete = new AutoComplete(input,'prompt', {
|
||||
debounceDelay: 0,
|
||||
showPreview: false,
|
||||
minChars: 1,
|
||||
@@ -692,6 +1031,6 @@ describe('AutoComplete widget interactions', () => {
|
||||
await autoComplete.insertSelection('looking_to_the_side');
|
||||
|
||||
// Entire phrase should be replaced with selected tag
|
||||
expect(input.value).toBe('looking_to_the_side, ');
|
||||
expect(input.value).toBe('looking_to_the_side,');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -36,6 +36,8 @@ export interface AutocompleteTextWidgetInterface {
|
||||
inputEl?: HTMLTextAreaElement
|
||||
callback?: (v: string) => void
|
||||
onSetValue?: (v: string) => void
|
||||
metadataWidget?: { value?: unknown }
|
||||
name?: string
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
@@ -171,6 +173,9 @@ onMounted(() => {
|
||||
// Register textarea reference with widget
|
||||
if (textareaRef.value) {
|
||||
props.widget.inputEl = textareaRef.value
|
||||
;(textareaRef.value as any)._autocompleteHostWidget = props.widget
|
||||
;(textareaRef.value as any)._autocompleteMetadataWidget = props.widget.metadataWidget
|
||||
;(textareaRef.value as any)._autocompleteTextWidgetName = props.widget.name ?? 'text'
|
||||
|
||||
// Also store on the container element for cloned widgets (subgraph promotion)
|
||||
// When widgets are promoted to subgraph nodes, the cloned widget shares the same
|
||||
@@ -208,6 +213,9 @@ onUnmounted(() => {
|
||||
|
||||
// Remove external value change event listener
|
||||
if (textareaRef.value) {
|
||||
delete (textareaRef.value as any)._autocompleteHostWidget
|
||||
delete (textareaRef.value as any)._autocompleteMetadataWidget
|
||||
delete (textareaRef.value as any)._autocompleteTextWidgetName
|
||||
textareaRef.value.removeEventListener('lora-manager:autocomplete-value-changed', onExternalValueChange as EventListener)
|
||||
}
|
||||
|
||||
|
||||
@@ -411,6 +411,7 @@ function createAutocompleteTextWidgetFactory(
|
||||
modelType: 'loras' | 'embeddings' | 'prompt',
|
||||
inputOptions: { placeholder?: string } = {}
|
||||
) {
|
||||
const metadataWidgetName = `__lm_autocomplete_meta_${widgetName}`
|
||||
const container = document.createElement('div')
|
||||
container.id = `autocomplete-text-widget-${node.id}-${widgetName}`
|
||||
container.style.width = '100%'
|
||||
@@ -427,6 +428,15 @@ function createAutocompleteTextWidgetFactory(
|
||||
const widgetElementRef = { inputEl: undefined as HTMLTextAreaElement | undefined }
|
||||
;(container as any).__widgetInputEl = widgetElementRef
|
||||
|
||||
const metadataWidget = node.addWidget('text', metadataWidgetName, {
|
||||
version: 1,
|
||||
textWidgetName: widgetName
|
||||
})
|
||||
metadataWidget.type = 'LORA_MANAGER_AUTOCOMPLETE_METADATA'
|
||||
metadataWidget.hidden = true
|
||||
metadataWidget.computeSize = () => [0, -4]
|
||||
metadataWidget.serializeValue = () => metadataWidget.value
|
||||
|
||||
const widget = node.addDOMWidget(
|
||||
widgetName,
|
||||
`AUTOCOMPLETE_TEXT_${modelType.toUpperCase()}`,
|
||||
@@ -463,6 +473,7 @@ function createAutocompleteTextWidgetFactory(
|
||||
})
|
||||
}
|
||||
)
|
||||
widget.metadataWidget = metadataWidget
|
||||
|
||||
// Get spellcheck setting from ComfyUI settings (default: false)
|
||||
const spellcheck = app.ui?.settings?.getSettingValue?.('Comfy.TextareaWidget.Spellcheck') ?? false
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import { api } from "../../scripts/api.js";
|
||||
import { app } from "../../scripts/app.js";
|
||||
import { TextAreaCaretHelper } from "./textarea_caret_helper.js";
|
||||
import { getPromptTagAutocompletePreference, getTagSpaceReplacementPreference } from "./settings.js";
|
||||
import {
|
||||
getAutocompleteAppendCommaPreference,
|
||||
getPromptTagAutocompletePreference,
|
||||
getTagSpaceReplacementPreference,
|
||||
} from "./settings.js";
|
||||
import { showToast } from "./utils.js";
|
||||
|
||||
// Command definitions for category filtering
|
||||
@@ -108,6 +112,24 @@ function parseSearchTokens(term = '') {
|
||||
return { include, exclude };
|
||||
}
|
||||
|
||||
function formatAutocompleteInsertion(text = '') {
|
||||
const trimmed = typeof text === 'string' ? text.trim() : '';
|
||||
if (!trimmed) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return getAutocompleteAppendCommaPreference() ? `${trimmed},` : `${trimmed} `;
|
||||
}
|
||||
|
||||
const AUTOCOMPLETE_METADATA_VERSION = 1;
|
||||
|
||||
function createAutocompleteMetadataBase(textWidgetName = 'text') {
|
||||
return {
|
||||
version: AUTOCOMPLETE_METADATA_VERSION,
|
||||
textWidgetName,
|
||||
};
|
||||
}
|
||||
|
||||
function createDefaultBehavior(modelType) {
|
||||
return {
|
||||
enablePreview: false,
|
||||
@@ -116,7 +138,7 @@ function createDefaultBehavior(modelType) {
|
||||
if (!trimmed) {
|
||||
return '';
|
||||
}
|
||||
return `${trimmed}, `;
|
||||
return formatAutocompleteInsertion(trimmed);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -185,9 +207,9 @@ const MODEL_BEHAVIORS = {
|
||||
}
|
||||
|
||||
if (clipStrength !== null) {
|
||||
return `<lora:${fileName}:${strength}:${clipStrength}>, `;
|
||||
return formatAutocompleteInsertion(`<lora:${fileName}:${strength}:${clipStrength}>`);
|
||||
}
|
||||
return `<lora:${fileName}:${strength}>, `;
|
||||
return formatAutocompleteInsertion(`<lora:${fileName}:${strength}>`);
|
||||
}
|
||||
},
|
||||
embeddings: {
|
||||
@@ -202,13 +224,13 @@ const MODEL_BEHAVIORS = {
|
||||
const { directories, fileName } = splitRelativePath(relativePath);
|
||||
const trimmedName = removeGeneralExtension(fileName);
|
||||
const folder = directories.length ? `${directories.join('/')}/` : '';
|
||||
return `embedding:${folder}${trimmedName}, `;
|
||||
return formatAutocompleteInsertion(`embedding:${folder}${trimmedName}`);
|
||||
},
|
||||
},
|
||||
custom_words: {
|
||||
enablePreview: false,
|
||||
async getInsertText(_instance, relativePath) {
|
||||
return `${relativePath}, `;
|
||||
return formatAutocompleteInsertion(relativePath);
|
||||
},
|
||||
},
|
||||
prompt: {
|
||||
@@ -245,7 +267,7 @@ const MODEL_BEHAVIORS = {
|
||||
const { directories, fileName } = splitRelativePath(relativePath);
|
||||
const trimmedName = removeGeneralExtension(fileName);
|
||||
const folder = directories.length ? `${directories.join('/')}/` : '';
|
||||
return `embedding:${folder}${trimmedName}, `;
|
||||
return formatAutocompleteInsertion(`embedding:${folder}${trimmedName}`);
|
||||
} else {
|
||||
let tagText = relativePath;
|
||||
|
||||
@@ -253,7 +275,7 @@ const MODEL_BEHAVIORS = {
|
||||
tagText = tagText.replace(/_/g, ' ');
|
||||
}
|
||||
|
||||
return `${tagText}, `;
|
||||
return formatAutocompleteInsertion(tagText);
|
||||
}
|
||||
},
|
||||
},
|
||||
@@ -620,18 +642,130 @@ class AutoComplete {
|
||||
}
|
||||
|
||||
getSearchTerm(value) {
|
||||
// Use helper to get text before cursor for more accurate positioning
|
||||
const beforeCursor = this.helper.getBeforeCursor();
|
||||
if (!beforeCursor) {
|
||||
return '';
|
||||
return this.getActiveSearchRange(value).text;
|
||||
}
|
||||
|
||||
// Split on comma and '>' delimiters only (do not split on spaces)
|
||||
const segments = beforeCursor.split(/[,\>]+/);
|
||||
getActiveSearchRange(value = null) {
|
||||
const currentValue = typeof value === 'string' ? value : this.inputElement.value;
|
||||
const caretPos = this.getCaretPosition();
|
||||
const beforeCursor = this.helper.getBeforeCursor() ?? currentValue.substring(0, caretPos);
|
||||
let start = this._getHardBoundaryStart(beforeCursor);
|
||||
|
||||
// Return the last non-empty segment as search term
|
||||
const lastSegment = segments[segments.length - 1] || '';
|
||||
return lastSegment.trim();
|
||||
if (!getAutocompleteAppendCommaPreference()) {
|
||||
const persistedBoundaryEnd = this._getPersistedBoundaryEnd(currentValue, caretPos);
|
||||
if (persistedBoundaryEnd !== null && persistedBoundaryEnd > start) {
|
||||
start = persistedBoundaryEnd;
|
||||
}
|
||||
}
|
||||
|
||||
const rawText = beforeCursor.substring(start);
|
||||
const text = rawText.trim();
|
||||
const leadingWhitespaceLength = rawText.length - rawText.trimStart().length;
|
||||
const trimmedStart = start + leadingWhitespaceLength;
|
||||
|
||||
return {
|
||||
start,
|
||||
trimmedStart,
|
||||
end: caretPos,
|
||||
beforeCursor,
|
||||
rawText,
|
||||
text,
|
||||
};
|
||||
}
|
||||
|
||||
_getHardBoundaryStart(beforeCursor = '') {
|
||||
const lastComma = beforeCursor.lastIndexOf(',');
|
||||
const lastAngle = beforeCursor.lastIndexOf('>');
|
||||
return Math.max(lastComma, lastAngle) + 1;
|
||||
}
|
||||
|
||||
_getMetadataWidget() {
|
||||
return this.inputElement?._autocompleteMetadataWidget
|
||||
?? this.inputElement?._autocompleteHostWidget?.metadataWidget
|
||||
?? null;
|
||||
}
|
||||
|
||||
_getMetadataBase() {
|
||||
return createAutocompleteMetadataBase(this.inputElement?._autocompleteTextWidgetName ?? 'text');
|
||||
}
|
||||
|
||||
_getAutocompleteMetadata() {
|
||||
const metadataWidget = this._getMetadataWidget();
|
||||
const value = metadataWidget?.value;
|
||||
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
return this._getMetadataBase();
|
||||
}
|
||||
|
||||
return {
|
||||
...this._getMetadataBase(),
|
||||
...value,
|
||||
};
|
||||
}
|
||||
|
||||
_setAutocompleteMetadata(metadata = {}) {
|
||||
const metadataWidget = this._getMetadataWidget();
|
||||
if (!metadataWidget) {
|
||||
return;
|
||||
}
|
||||
|
||||
metadataWidget.value = {
|
||||
...this._getMetadataBase(),
|
||||
...metadata,
|
||||
};
|
||||
}
|
||||
|
||||
_clearLastAcceptedBoundary() {
|
||||
const metadataWidget = this._getMetadataWidget();
|
||||
if (!metadataWidget) {
|
||||
return;
|
||||
}
|
||||
|
||||
const metadata = this._getAutocompleteMetadata();
|
||||
delete metadata.lastAccepted;
|
||||
metadataWidget.value = metadata;
|
||||
}
|
||||
|
||||
_storeLastAcceptedBoundary(boundary) {
|
||||
this._setAutocompleteMetadata({ lastAccepted: boundary });
|
||||
}
|
||||
|
||||
_getPersistedBoundaryEnd(currentValue, caretPos) {
|
||||
const metadata = this._getAutocompleteMetadata();
|
||||
const boundary = metadata?.lastAccepted;
|
||||
|
||||
if (!boundary || typeof boundary !== 'object') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { start, end, insertedText, textSnapshot } = boundary;
|
||||
|
||||
if (!Number.isInteger(start) || !Number.isInteger(end) || start < 0 || end < start) {
|
||||
this._clearLastAcceptedBoundary();
|
||||
return null;
|
||||
}
|
||||
|
||||
if (end > currentValue.length || end > caretPos) {
|
||||
this._clearLastAcceptedBoundary();
|
||||
return null;
|
||||
}
|
||||
|
||||
if (typeof insertedText !== 'string' || insertedText.length === 0) {
|
||||
this._clearLastAcceptedBoundary();
|
||||
return null;
|
||||
}
|
||||
|
||||
if (currentValue.slice(start, end) !== insertedText) {
|
||||
this._clearLastAcceptedBoundary();
|
||||
return null;
|
||||
}
|
||||
|
||||
if (typeof textSnapshot !== 'string' || currentValue.slice(0, end) !== textSnapshot) {
|
||||
this._clearLastAcceptedBoundary();
|
||||
return null;
|
||||
}
|
||||
|
||||
return end;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1017,25 +1151,12 @@ class AutoComplete {
|
||||
*/
|
||||
_insertCommand(command) {
|
||||
const currentValue = this.inputElement.value;
|
||||
const caretPos = this.getCaretPosition();
|
||||
|
||||
// Find the start of the current command being typed
|
||||
const beforeCursor = currentValue.substring(0, caretPos);
|
||||
const segments = beforeCursor.split(/[,\>]+/);
|
||||
const lastSegment = segments[segments.length - 1] || '';
|
||||
let commandStartPos = caretPos - lastSegment.length;
|
||||
|
||||
// Preserve leading space if the last segment starts with a space
|
||||
// This handles cases like "1girl, /character" where we want to keep the space
|
||||
// after the comma instead of replacing it
|
||||
if (lastSegment.length > 0 && lastSegment[0] === ' ') {
|
||||
// Move start position past the leading space to preserve it
|
||||
commandStartPos = commandStartPos + 1;
|
||||
}
|
||||
const activeRange = this.getActiveSearchRange(currentValue);
|
||||
const commandStartPos = activeRange.trimmedStart;
|
||||
|
||||
// Insert command with trailing space
|
||||
const insertText = command + ' ';
|
||||
const newValue = currentValue.substring(0, commandStartPos) + insertText + currentValue.substring(caretPos);
|
||||
const newValue = currentValue.substring(0, commandStartPos) + insertText + currentValue.substring(activeRange.end);
|
||||
const newCaretPos = commandStartPos + insertText.length;
|
||||
|
||||
this.inputElement.value = newValue;
|
||||
@@ -1866,6 +1987,7 @@ class AutoComplete {
|
||||
break;
|
||||
|
||||
case 'Enter':
|
||||
case 'Tab':
|
||||
e.preventDefault();
|
||||
if (this.selectedIndex >= 0 && this.selectedIndex < this.items.length) {
|
||||
if (this.showingCommands) {
|
||||
@@ -1897,11 +2019,10 @@ class AutoComplete {
|
||||
}
|
||||
|
||||
const currentValue = this.inputElement.value;
|
||||
const caretPos = this.getCaretPosition();
|
||||
|
||||
// Use getSearchTerm to get the current search term before cursor
|
||||
const beforeCursor = currentValue.substring(0, caretPos);
|
||||
const fullSearchTerm = this.getSearchTerm(beforeCursor);
|
||||
const activeRange = this.getActiveSearchRange(currentValue);
|
||||
const caretPos = activeRange.end;
|
||||
const fullSearchTerm = activeRange.text;
|
||||
let replaceStartPos = activeRange.trimmedStart;
|
||||
|
||||
// For regular tag autocomplete (no command), only replace the last space-separated token
|
||||
// This allows "hello 1gi" + selecting "1girl" to become "hello 1girl, "
|
||||
@@ -1934,18 +2055,26 @@ class AutoComplete {
|
||||
underscoreVersion === selectedTagLower
|
||||
)) {
|
||||
searchTerm = fullSearchTerm;
|
||||
replaceStartPos = activeRange.trimmedStart;
|
||||
} else {
|
||||
searchTerm = this._getLastSpaceToken(fullSearchTerm);
|
||||
replaceStartPos = searchTerm === fullSearchTerm
|
||||
? activeRange.trimmedStart
|
||||
: caretPos - searchTerm.length;
|
||||
}
|
||||
}
|
||||
|
||||
const searchStartPos = caretPos - searchTerm.length;
|
||||
|
||||
// Only replace the search term, not everything after the last comma
|
||||
const newValue = currentValue.substring(0, searchStartPos) + insertText + currentValue.substring(caretPos);
|
||||
const newCaretPos = searchStartPos + insertText.length;
|
||||
const newValue = currentValue.substring(0, replaceStartPos) + insertText + currentValue.substring(caretPos);
|
||||
const newCaretPos = replaceStartPos + insertText.length;
|
||||
|
||||
this.inputElement.value = newValue;
|
||||
this._storeLastAcceptedBoundary({
|
||||
start: replaceStartPos,
|
||||
end: newCaretPos,
|
||||
insertedText: insertText,
|
||||
textSnapshot: newValue.substring(0, newCaretPos),
|
||||
});
|
||||
|
||||
// Trigger input event to notify about the change
|
||||
const event = new Event('input', { bubbles: true });
|
||||
@@ -1974,7 +2103,7 @@ class AutoComplete {
|
||||
if (!trimmed) {
|
||||
return '';
|
||||
}
|
||||
return `${trimmed}, `;
|
||||
return formatAutocompleteInsertion(trimmed);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -2053,12 +2182,9 @@ class AutoComplete {
|
||||
*/
|
||||
_clearCurrentToken() {
|
||||
const currentValue = this.inputElement.value;
|
||||
const caretPos = this.inputElement.selectionStart;
|
||||
|
||||
// Find the command text before cursor
|
||||
const beforeCursor = currentValue.substring(0, caretPos);
|
||||
const segments = beforeCursor.split(/[,\>]+/);
|
||||
const lastSegment = segments[segments.length - 1] || '';
|
||||
const activeRange = this.getActiveSearchRange(currentValue);
|
||||
const caretPos = activeRange.end;
|
||||
const lastSegment = activeRange.rawText;
|
||||
|
||||
// Find the command start position, preserving leading spaces
|
||||
// lastSegment includes leading spaces (e.g., " /ac"), find where command actually starts
|
||||
@@ -2067,7 +2193,7 @@ class AutoComplete {
|
||||
// commandMatch[1] is leading spaces, commandMatch[2] is the command
|
||||
const leadingSpaces = commandMatch[1].length;
|
||||
// Keep the spaces by starting after them
|
||||
const commandStartPos = caretPos - lastSegment.length + leadingSpaces;
|
||||
const commandStartPos = activeRange.start + leadingSpaces;
|
||||
|
||||
// Skip trailing spaces when deleting
|
||||
let endPos = caretPos;
|
||||
@@ -2089,7 +2215,7 @@ class AutoComplete {
|
||||
this.inputElement.setSelectionRange(newCaretPos, newCaretPos);
|
||||
} else {
|
||||
// Fallback: delete the whole last segment (original behavior)
|
||||
const commandStartPos = caretPos - lastSegment.length;
|
||||
const commandStartPos = activeRange.start;
|
||||
|
||||
let endPos = caretPos;
|
||||
while (endPos < currentValue.length && currentValue[endPos] === ' ') {
|
||||
|
||||
@@ -13,6 +13,9 @@ const AUTO_PATH_CORRECTION_DEFAULT = true;
|
||||
const PROMPT_TAG_AUTOCOMPLETE_SETTING_ID = "loramanager.prompt_tag_autocomplete";
|
||||
const PROMPT_TAG_AUTOCOMPLETE_DEFAULT = true;
|
||||
|
||||
const AUTOCOMPLETE_APPEND_COMMA_SETTING_ID = "loramanager.autocomplete_append_comma";
|
||||
const AUTOCOMPLETE_APPEND_COMMA_DEFAULT = true;
|
||||
|
||||
const TAG_SPACE_REPLACEMENT_SETTING_ID = "loramanager.tag_space_replacement";
|
||||
const TAG_SPACE_REPLACEMENT_DEFAULT = false;
|
||||
|
||||
@@ -157,6 +160,32 @@ const getPromptTagAutocompletePreference = (() => {
|
||||
};
|
||||
})();
|
||||
|
||||
const getAutocompleteAppendCommaPreference = (() => {
|
||||
let settingsUnavailableLogged = false;
|
||||
|
||||
return () => {
|
||||
const settingManager = app?.extensionManager?.setting;
|
||||
if (!settingManager || typeof settingManager.get !== "function") {
|
||||
if (!settingsUnavailableLogged) {
|
||||
console.warn("LoRA Manager: settings API unavailable, using default append comma setting.");
|
||||
settingsUnavailableLogged = true;
|
||||
}
|
||||
return AUTOCOMPLETE_APPEND_COMMA_DEFAULT;
|
||||
}
|
||||
|
||||
try {
|
||||
const value = settingManager.get(AUTOCOMPLETE_APPEND_COMMA_SETTING_ID);
|
||||
return value ?? AUTOCOMPLETE_APPEND_COMMA_DEFAULT;
|
||||
} catch (error) {
|
||||
if (!settingsUnavailableLogged) {
|
||||
console.warn("LoRA Manager: unable to read append comma setting, using default.", error);
|
||||
settingsUnavailableLogged = true;
|
||||
}
|
||||
return AUTOCOMPLETE_APPEND_COMMA_DEFAULT;
|
||||
}
|
||||
};
|
||||
})();
|
||||
|
||||
const getTagSpaceReplacementPreference = (() => {
|
||||
let settingsUnavailableLogged = false;
|
||||
|
||||
@@ -297,6 +326,14 @@ app.registerExtension({
|
||||
tooltip: "When enabled, typing will trigger tag autocomplete suggestions. Commands (e.g., /character, /artist) always work regardless of this setting.",
|
||||
category: ["LoRA Manager", "Autocomplete", "Prompt"],
|
||||
},
|
||||
{
|
||||
id: AUTOCOMPLETE_APPEND_COMMA_SETTING_ID,
|
||||
name: "Append comma after autocomplete",
|
||||
type: "boolean",
|
||||
defaultValue: AUTOCOMPLETE_APPEND_COMMA_DEFAULT,
|
||||
tooltip: "When enabled, accepted autocomplete suggestions append ', ' to the inserted text.",
|
||||
category: ["LoRA Manager", "Autocomplete", "Behavior"],
|
||||
},
|
||||
{
|
||||
id: TAG_SPACE_REPLACEMENT_SETTING_ID,
|
||||
name: "Replace underscores with spaces in tags",
|
||||
@@ -413,6 +450,7 @@ app.registerExtension({
|
||||
export {
|
||||
getWheelSensitivity,
|
||||
getAutoPathCorrectionPreference,
|
||||
getAutocompleteAppendCommaPreference,
|
||||
getPromptTagAutocompletePreference,
|
||||
getTagSpaceReplacementPreference,
|
||||
getUsageStatisticsPreference,
|
||||
|
||||
@@ -2118,14 +2118,14 @@ to { transform: rotate(360deg);
|
||||
padding: 20px 0;
|
||||
}
|
||||
|
||||
.autocomplete-text-widget[data-v-b3b00fdd] {
|
||||
.autocomplete-text-widget[data-v-918e2bc5] {
|
||||
background: transparent;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.input-wrapper[data-v-b3b00fdd] {
|
||||
.input-wrapper[data-v-918e2bc5] {
|
||||
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-b3b00fdd] {
|
||||
.text-input[data-v-918e2bc5] {
|
||||
flex: 1;
|
||||
width: 100%;
|
||||
background-color: var(--comfy-input-bg, #222);
|
||||
@@ -2150,7 +2150,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-b3b00fdd] {
|
||||
.text-input.vue-dom-mode[data-v-918e2bc5] {
|
||||
background-color: var(--color-charcoal-400, #313235);
|
||||
color: #fff;
|
||||
padding: 8px 12px 30px 12px; /* Reserve bottom space for clear button */
|
||||
@@ -2159,12 +2159,12 @@ to { transform: rotate(360deg);
|
||||
font-size: 12px;
|
||||
font-family: inherit;
|
||||
}
|
||||
.text-input[data-v-b3b00fdd]:focus {
|
||||
.text-input[data-v-918e2bc5]:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
/* Clear button styles */
|
||||
.clear-button[data-v-b3b00fdd] {
|
||||
.clear-button[data-v-918e2bc5] {
|
||||
position: absolute;
|
||||
right: 6px;
|
||||
bottom: 6px; /* Changed from top to bottom */
|
||||
@@ -2187,31 +2187,31 @@ to { transform: rotate(360deg);
|
||||
}
|
||||
|
||||
/* Show clear button when hovering over input wrapper */
|
||||
.input-wrapper:hover .clear-button[data-v-b3b00fdd] {
|
||||
.input-wrapper:hover .clear-button[data-v-918e2bc5] {
|
||||
opacity: 0.7;
|
||||
pointer-events: auto;
|
||||
}
|
||||
.clear-button[data-v-b3b00fdd]:hover {
|
||||
.clear-button[data-v-918e2bc5]:hover {
|
||||
opacity: 1;
|
||||
background: rgba(255, 100, 100, 0.8);
|
||||
}
|
||||
.clear-button svg[data-v-b3b00fdd] {
|
||||
.clear-button svg[data-v-918e2bc5] {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
}
|
||||
|
||||
/* Vue DOM mode adjustments for clear button */
|
||||
.text-input.vue-dom-mode ~ .clear-button[data-v-b3b00fdd] {
|
||||
.text-input.vue-dom-mode ~ .clear-button[data-v-918e2bc5] {
|
||||
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-b3b00fdd]:hover {
|
||||
.text-input.vue-dom-mode ~ .clear-button[data-v-918e2bc5]:hover {
|
||||
background: oklch(62% 0.18 25);
|
||||
}
|
||||
.text-input.vue-dom-mode ~ .clear-button svg[data-v-b3b00fdd] {
|
||||
.text-input.vue-dom-mode ~ .clear-button svg[data-v-918e2bc5] {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}`));
|
||||
@@ -14679,6 +14679,9 @@ const _sfc_main = /* @__PURE__ */ defineComponent({
|
||||
onMounted(() => {
|
||||
if (textareaRef.value) {
|
||||
props.widget.inputEl = textareaRef.value;
|
||||
textareaRef.value._autocompleteHostWidget = props.widget;
|
||||
textareaRef.value._autocompleteMetadataWidget = props.widget.metadataWidget;
|
||||
textareaRef.value._autocompleteTextWidgetName = props.widget.name ?? "text";
|
||||
const container = textareaRef.value.closest('[id^="autocomplete-text-widget-"]');
|
||||
if (container && container.__widgetInputEl) {
|
||||
container.__widgetInputEl.inputEl = textareaRef.value;
|
||||
@@ -14697,6 +14700,9 @@ const _sfc_main = /* @__PURE__ */ defineComponent({
|
||||
props.widget.inputEl = void 0;
|
||||
}
|
||||
if (textareaRef.value) {
|
||||
delete textareaRef.value._autocompleteHostWidget;
|
||||
delete textareaRef.value._autocompleteMetadataWidget;
|
||||
delete textareaRef.value._autocompleteTextWidgetName;
|
||||
textareaRef.value.removeEventListener("lora-manager:autocomplete-value-changed", onExternalValueChange);
|
||||
}
|
||||
if (props.widget) {
|
||||
@@ -14748,7 +14754,7 @@ const _sfc_main = /* @__PURE__ */ defineComponent({
|
||||
};
|
||||
}
|
||||
});
|
||||
const AutocompleteTextWidget = /* @__PURE__ */ _export_sfc(_sfc_main, [["__scopeId", "data-v-b3b00fdd"]]);
|
||||
const AutocompleteTextWidget = /* @__PURE__ */ _export_sfc(_sfc_main, [["__scopeId", "data-v-918e2bc5"]]);
|
||||
const LORA_PROVIDER_NODE_TYPES$1 = [
|
||||
"Lora Stacker (LoraManager)",
|
||||
"Lora Randomizer (LoraManager)",
|
||||
@@ -15325,6 +15331,7 @@ if ((_a = app$1.ui) == null ? void 0 : _a.settings) {
|
||||
}
|
||||
function createAutocompleteTextWidgetFactory(node, widgetName, modelType, inputOptions = {}) {
|
||||
var _a2, _b, _c;
|
||||
const metadataWidgetName = `__lm_autocomplete_meta_${widgetName}`;
|
||||
const container = document.createElement("div");
|
||||
container.id = `autocomplete-text-widget-${node.id}-${widgetName}`;
|
||||
container.style.width = "100%";
|
||||
@@ -15335,6 +15342,14 @@ function createAutocompleteTextWidgetFactory(node, widgetName, modelType, inputO
|
||||
forwardMiddleMouseToCanvas(container);
|
||||
const widgetElementRef = { inputEl: void 0 };
|
||||
container.__widgetInputEl = widgetElementRef;
|
||||
const metadataWidget = node.addWidget("text", metadataWidgetName, {
|
||||
version: 1,
|
||||
textWidgetName: widgetName
|
||||
});
|
||||
metadataWidget.type = "LORA_MANAGER_AUTOCOMPLETE_METADATA";
|
||||
metadataWidget.hidden = true;
|
||||
metadataWidget.computeSize = () => [0, -4];
|
||||
metadataWidget.serializeValue = () => metadataWidget.value;
|
||||
const widget = node.addDOMWidget(
|
||||
widgetName,
|
||||
`AUTOCOMPLETE_TEXT_${modelType.toUpperCase()}`,
|
||||
@@ -15369,6 +15384,7 @@ function createAutocompleteTextWidgetFactory(node, widgetName, modelType, inputO
|
||||
}
|
||||
}
|
||||
);
|
||||
widget.metadataWidget = metadataWidget;
|
||||
const spellcheck = ((_c = (_b = (_a2 = app$1.ui) == null ? void 0 : _a2.settings) == null ? void 0 : _b.getSettingValue) == null ? void 0 : _c.call(_b, "Comfy.TextareaWidget.Spellcheck")) ?? false;
|
||||
const vueApp = createApp(AutocompleteTextWidget, {
|
||||
widget,
|
||||
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user