diff --git a/README.md b/README.md index 8bd9482..7b8548e 100644 --- a/README.md +++ b/README.md @@ -477,8 +477,8 @@ This feature is an enhanced fork of the autocomplete functionality from [ComfyUI **Key Features:** - **Smart Triggers**: Type `embedding:` for embeddings, ` { page.errors.push(`Unexpected external request: ${url.origin}`); return route.abort(); }); - await page.addInitScript(() => localStorage.setItem('kikotools.embedding_autocomplete.settings', JSON.stringify({ showCustomWords: false, customWordsUrl: '' }))); + await page.addInitScript(() => { if (!localStorage.getItem('kikotools.embedding_autocomplete.settings')) localStorage.setItem('kikotools.embedding_autocomplete.settings', JSON.stringify({ showCustomWords: false, customWordsUrl: '' })); }); }); test.afterEach(async ({ page, browserName }) => { await saveCoverage(page, browserName); @@ -1112,3 +1112,490 @@ test('display copy feedback expires and malformed execution messages retain the await expect(panel.getByRole('status')).toHaveText('Copied.'); await expect(panel.getByRole('status')).toBeEmpty({ timeout: 4000 }); }); + +async function mountAutocompleteSettings(page) { + await page.evaluate(() => { + const table = document.createElement('table'); + table.id = 'autocomplete-test-settings'; + const body = document.createElement('tbody'); + body.append(kikoHost.settingDefinitions.get('kikotools.embeddingAutocomplete').type()); + table.append(body); + document.body.append(table); + }); + await page.getByRole('button', { name: 'Open Settings', exact: true }).click(); + return page.getByRole('dialog', { name: 'Embedding Autocomplete Settings', exact: true }); +} + +test('autocomplete shared settings save preferences, cancel edits and follow themes', async ({ page }) => { + await openHost(page); + await page.evaluate(() => Object.assign(window.kikotools.autocomplete.settings, { quickTrigger: 'keep', maxSuggestions: 7, insertOnTab: false })); + const dialog = await mountAutocompleteSettings(page); + await expect(dialog).toBeVisible(); + await expect(dialog.getByRole('switch', { name: 'Enable Autocomplete', exact: true })).toBeChecked(); + await dialog.getByRole('switch', { name: 'Show LoRAs', exact: true }).uncheck(); + await page.keyboard.press('Escape'); + await expect(dialog).toHaveCount(0); + await expect(page.getByRole('button', { name: 'Open Settings', exact: true })).toBeFocused(); + expect(await page.evaluate(() => window.kikotools.autocomplete.settings.showLoras)).toBe(true); + await page.getByRole('button', { name: 'Open Settings', exact: true }).click(); + await page.evaluate(() => document.querySelector('#autocomplete-test-settings button').click()); + await expect(dialog).toHaveCount(1); + for (const [color, theme] of [['#fafafa', 'light'], ['#202020', 'dark']]) { + await page.evaluate(color => document.body.style.setProperty('--bg-color', color), color); + await expect(dialog).toHaveAttribute('data-ca-theme', theme); + } + await page.setViewportSize({ width: 320, height: 480 }); + expect(await dialog.evaluate(element => element.scrollWidth <= element.clientWidth)).toBe(true); + await dialog.getByRole('switch', { name: 'Show LoRAs', exact: true }).uncheck(); + await dialog.getByRole('button', { name: 'Save Settings', exact: true }).click(); + await expect(dialog).toHaveCount(0); + expect(await page.evaluate(() => JSON.parse(localStorage.getItem('kikotools.embedding_autocomplete.settings')))).toMatchObject({ showLoras: false, quickTrigger: 'keep', maxSuggestions: 7, insertOnTab: false }); + await page.getByRole('button', { name: 'Open Settings', exact: true }).click(); + await expect(dialog.getByRole('switch', { name: 'Show LoRAs', exact: true })).not.toBeChecked(); + await page.evaluate(() => document.getElementById('autocomplete-test-settings').remove()); + await expect(dialog).toHaveCount(0); +}); + +test('autocomplete settings report storage failure and keep the previous preferences', async ({ page }) => { + await openHost(page); + const dialog = await mountAutocompleteSettings(page); + await dialog.getByRole('switch', { name: 'Enable Autocomplete', exact: true }).uncheck(); + await page.evaluate(() => { window.restoreAutocompleteStorage = Storage.prototype.setItem; Storage.prototype.setItem = () => { throw new Error('blocked'); }; }); + await dialog.getByRole('button', { name: 'Save Settings', exact: true }).click(); + await expect(dialog.getByRole('status')).toContainText('Could not save'); + expect(await page.evaluate(() => window.kikotools.autocomplete.settings.enabled)).toBe(true); + await page.evaluate(() => Storage.prototype.setItem = window.restoreAutocompleteStorage); + await dialog.getByRole('button', { name: 'Cancel', exact: true }).click(); + await expect(dialog).toHaveCount(0); +}); + +test('autocomplete suggestions retain focus, expose selection, and survive unrelated removal', async ({ page }) => { + await openHost(page); + await createNode(page, 'TextInput', { text: '' }); + const other = await createNode(page, 'TextInput', { text: '' }); + await expect.poll(() => page.evaluate(() => window.kikotools.autocomplete.embeddings.length)).toBe(2); + const prompt = page.getByLabel('text', { exact: true }).first(); + await prompt.fill('em'); + const list = page.getByRole('listbox', { name: 'Prompt completions', exact: true }); + await expect(list.getByRole('option')).toHaveCount(2); + await prompt.press('ArrowDown'); + const selected = list.getByRole('option', { selected: true }); + await expect(selected).toHaveCount(1); + await expect(prompt).toHaveAttribute('aria-activedescendant', await selected.getAttribute('id')); + await expect(prompt).toBeFocused(); + await page.evaluate(other => kikoHost.removeNode(other), other); + await expect(list).toBeVisible(); + await prompt.press('Tab'); + await expect(prompt).toHaveValue(/embedding:/); + await expect(list).toBeHidden(); + await expect(prompt).not.toHaveAttribute('aria-activedescendant'); + await prompt.fill(', '); + await expect(prompt).toBeFocused(); +}); + +test('autocomplete respects composition, read-only fields, custom words and keyboard preferences', async ({ page }) => { + await openHost(page); + await createNode(page, 'TextInput', { text: '' }); + const prompt = page.getByLabel('text', { exact: true }); + await page.evaluate(() => { + Object.assign(window.kikotools.autocomplete.settings, { showEmbeddings: false, showLoras: false, showCustomWords: true, insertOnEnter: false }); + window.kikotools.autocomplete.customWords = [{ name: 'blue_sky', value: 'blue_sky', display: 'blue_sky', type: 'custom' }]; + }); + await expect(prompt).toHaveAttribute('aria-autocomplete', 'list'); + await prompt.fill('blue'); + const list = page.getByRole('listbox', { name: 'Prompt completions', exact: true }); + await expect(list).toBeVisible(); + await prompt.press('ArrowDown'); + await prompt.dispatchEvent('keydown', { key: 'Enter', isComposing: true }); + await expect(prompt).toHaveValue('blue'); + await prompt.press('Enter'); + await expect(prompt).not.toHaveValue('blue sky, '); + await prompt.fill('blue'); + await prompt.press('ArrowDown'); + await prompt.press('Tab'); + await expect(prompt).toHaveValue('blue sky, '); + await prompt.fill('blue'); + await prompt.press('Escape'); + await expect(list).toBeHidden(); + await prompt.evaluate(element => { element.readOnly = true; element.dispatchEvent(new Event('input', { bubbles: true })); }); + await expect(list).toBeHidden(); +}); + +test('autocomplete restores existing attributes and lifecycle hooks on detach', async ({ page }) => { + await openHost(page); + const result = await page.evaluate(() => { + const autocomplete = window.kikotools.autocomplete; + const input = document.createElement('textarea'); + input.setAttribute('aria-controls', 'original-controls'); + input.setAttribute('aria-autocomplete', 'none'); + input.readOnly = true; + const calls = []; + const widget = { inputEl: input, onRemoved(...args) { calls.push([this === widget, args]); return 'widget-result'; } }; + const node = { onRemoved(...args) { calls.push([this === node, args]); return 'node-result'; } }; + const original = widget.onRemoved; + autocomplete.attachToWidget(widget, node); + const ignored = !autocomplete.activeWidgets.has(widget); + input.readOnly = false; + autocomplete.attachToWidget(widget, node); + autocomplete.attachToWidget(widget, node); + const value = widget.onRemoved('widget-arg', 7); + const restored = widget.onRemoved === original; + autocomplete.attachToWidget(widget, node); + const removed = node.onRemoved('node-arg'); + autocomplete.detachFromWidget(widget); + autocomplete.detachFromWidget(widget); + return { ignored, value, removed, restored, calls, controls: input.getAttribute('aria-controls'), autocomplete: input.getAttribute('aria-autocomplete'), popup: input.hasAttribute('aria-haspopup'), active: autocomplete.activeWidgets.has(widget) }; + }); + expect(result).toEqual({ ignored: true, value: 'widget-result', removed: 'node-result', restored: true, calls: [[true, ['widget-arg', 7]], [true, ['node-arg']]], controls: 'original-controls', autocomplete: 'none', popup: false, active: false }); +}); + +test('autocomplete popup fits narrow viewports and cleanup cancels pending refresh', async ({ page }) => { + await openHost(page); + await createNode(page, 'TextInput', { text: '' }); + await expect.poll(() => page.evaluate(() => window.kikotools.autocomplete.embeddings.length)).toBe(2); + await page.setViewportSize({ width: 320, height: 480 }); + await page.evaluate(() => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)))); + const prompt = page.getByLabel('text', { exact: true }); + await prompt.fill('em'); + const list = page.getByRole('listbox', { name: 'Prompt completions', exact: true }); + const box = await list.boundingBox(); + expect(box.x).toBeGreaterThanOrEqual(0); + expect(box.x + box.width).toBeLessThanOrEqual(320); + expect(box.y + box.height).toBeLessThanOrEqual(480); + await prompt.dispatchEvent('blur'); + await page.waitForTimeout(40); + await expect(list).toBeVisible(); + await page.evaluate(() => { + const autocomplete = window.kikotools.autocomplete; + window.lateAutocompleteFetches = 0; + autocomplete.fetchResources = () => window.lateAutocompleteFetches++; + autocomplete.fetchResourcesDebounced(); + autocomplete.cleanup(); + }); + await expect(list).toHaveCount(0); + await page.waitForTimeout(550); + expect(await page.evaluate(() => window.lateAutocompleteFetches)).toBe(0); + await expect(prompt).not.toHaveAttribute('aria-haspopup'); +}); + +test('autocomplete binds replacement renderer fields without changing native widget ownership', async ({ page }) => { + await openHost(page); + const id = await createNode(page, 'TextInput', { text: '' }); + await expect.poll(() => page.evaluate(() => window.kikotools.autocomplete.embeddings.length)).toBe(2); + await page.evaluate(id => { + const wrapper = document.createElement('div'); + wrapper.setAttribute('node-id', String(id)); + const label = document.createElement('label'); + label.htmlFor = 'replacement-prompt'; + label.textContent = 'text'; + const textarea = document.createElement('textarea'); + textarea.id = 'replacement-prompt'; + wrapper.append(label, textarea); + document.body.append(wrapper); + }, id); + const prompt = page.locator('#replacement-prompt'); + await prompt.fill('embedding:ca'); + const list = page.getByRole('listbox', { name: 'Prompt completions', exact: true }); + await expect(list).toBeVisible(); + await prompt.press('ArrowDown'); + await prompt.press('Enter'); + await expect(prompt).toHaveValue('embedding:portraits/cat, '); + await prompt.fill('embedding:ca'); + await expect(list).toBeVisible(); + await page.evaluate(() => { + window.removedRendererBridge = window.kikotools.autocomplete.currentWidget; + document.getElementById('replacement-prompt').parentElement.remove(); + }); + await expect(prompt).toHaveCount(0); + await expect(list).toBeHidden(); + expect(await page.evaluate(() => window.kikotools.autocomplete.activeWidgets.has(window.removedRendererBridge))).toBe(false); + const native = page.getByLabel('text', { exact: true }); + await native.fill('embedding:ca'); + await expect(list).toBeVisible(); + await native.press('ArrowDown'); + await native.press('Tab'); + await expect(native).toHaveValue('embedding:portraits/cat, '); +}); + +test('autocomplete starts a new query without carrying over the previous selection', async ({ page }) => { + await openHost(page); + await createNode(page, 'TextInput', { text: '' }); + await expect.poll(() => page.evaluate(() => window.kikotools.autocomplete.embeddings.length)).toBe(2); + const prompt = page.getByLabel('text', { exact: true }); + const list = page.getByRole('listbox', { name: 'Prompt completions', exact: true }); + await prompt.fill('embedding:ca'); + await prompt.press('ArrowDown'); + await prompt.press('ArrowDown'); + await expect(list.getByRole('option', { selected: true })).toContainText('forest'); + await prompt.fill('embedding:ca'); + await prompt.press('ArrowDown'); + await expect(list.getByRole('option', { selected: true })).toContainText('cat'); +}); + +test('autocomplete keyboard selection ignores a stationary pointer over a new result list', async ({ page }) => { + await openHost(page); + await createNode(page, 'TextInput', { text: '' }); + await expect.poll(() => page.evaluate(() => window.kikotools.autocomplete.embeddings.length)).toBe(2); + const prompt = page.getByLabel('text', { exact: true }); + const list = page.getByRole('listbox', { name: 'Prompt completions', exact: true }); + await prompt.fill('embedding:ca'); + await list.getByRole('option').first().hover(); + await prompt.fill('embedding:ca'); + await page.waitForTimeout(50); + await expect(list.getByRole('option', { selected: true })).toHaveCount(0); + await prompt.press('ArrowDown'); + await prompt.press('Enter'); + await expect(prompt).toHaveValue('embedding:portraits/cat, '); +}); + +test('autocomplete delayed attachment cannot revive a removed node and teardown restores host hooks', async ({ page }) => { + await openHost(page); + await page.evaluate(async () => { + const id = await kikoHost.createNode('TextInput', { text: '' }); + window.quicklyRemovedPrompt = kikoHost.nodes.get(String(id)).widgets.find(widget => widget.inputEl); + kikoHost.removeNode(id); + }); + await page.waitForTimeout(70); + expect(await page.evaluate(() => window.kikotools.autocomplete.activeWidgets.has(window.quicklyRemovedPrompt))).toBe(false); + expect(await page.evaluate(() => { + const wrapped = kikoHost.app.graph.add; + const stringWidget = kikoHost.widgets.STRING; + window.kikotools.autocomplete.cleanup(); + return { graphRestored: kikoHost.app.graph.add !== wrapped, widgetRestored: kikoHost.widgets.STRING !== stringWidget }; + })).toEqual({ graphRestored: true, widgetRestored: true }); +}); + +test('autocomplete tag import preserves aliases and ranking across reload and failed imports', async ({ page }) => { + await openHost(page); + const dialog = await mountAutocompleteSettings(page); + await dialog.getByRole('switch', { name: 'Show Custom Words', exact: true }).check(); + await dialog.getByRole('switch', { name: 'Show Embeddings', exact: true }).uncheck(); + await dialog.getByRole('switch', { name: 'Show LoRAs', exact: true }).uncheck(); + await dialog.getByLabel('Tag list file', { exact: true }).setInputFiles({ name: 'tags.csv', mimeType: 'text/csv', buffer: Buffer.from('blue_sky,0,100,azure\nblue_eyes,0,900,"blue_eye,bright_eyes"') }); + await expect(dialog.getByRole('status')).toContainText('2 tags ready'); + await dialog.getByText('Triggers, matching and insertion', { exact: true }).click(); + await dialog.getByRole('switch', { name: 'Replace underscores with spaces', exact: true }).uncheck(); + await dialog.getByRole('button', { name: 'Save Settings', exact: true }).click(); + await expect(dialog).toHaveCount(0); + await createNode(page, 'TextInput', { text: '' }); + const prompt = page.getByLabel('text', { exact: true }); + await expect(prompt).toHaveAttribute('aria-autocomplete', 'list'); + await prompt.focus(); + await page.evaluate(() => new Promise(resolve => requestAnimationFrame(resolve))); + await prompt.fill('blue'); + const list = page.getByRole('listbox', { name: 'Prompt completions', exact: true }); + await expect(list.getByRole('option').first()).toContainText('blue_eyes'); + await prompt.fill('azure'); + await prompt.press('ArrowDown'); + await prompt.press('Tab'); + await expect(prompt).toHaveValue('blue_sky, '); + await page.reload(); + await expect.poll(() => page.evaluate(() => window.kikotools?.autocomplete.customWords.length)).toBe(2); + const reopened = await mountAutocompleteSettings(page); + await expect(reopened.getByText('2 tags loaded', { exact: true })).toBeVisible(); + await expect(reopened.getByText('Current source: tags.csv', { exact: true })).toBeVisible(); + await reopened.getByLabel('Tag list file', { exact: true }).setInputFiles({ name: 'bad.csv', mimeType: 'text/csv', buffer: Buffer.from('bad,0,not-a-count,') }); + await expect(reopened.getByRole('status')).toContainText('Could not load'); + await reopened.getByRole('button', { name: 'Cancel', exact: true }).click(); + expect(await page.evaluate(() => window.kikotools.autocomplete.customWords.length)).toBe(2); +}); + +test('autocomplete restores advanced preferences and stages URL tags until save', async ({ page }) => { + await openHost(page); + await page.route('**/__test__/tags.csv', route => route.fulfill({ body: 'tag,category,post_count,aliases\nblue_eyes,0,120,azure' })); + const dialog = await mountAutocompleteSettings(page); + await dialog.getByLabel('Tag list URL', { exact: true }).fill(new URL('/__test__/tags.csv', page.url()).href); + await dialog.getByRole('button', { name: 'Load URL', exact: true }).click(); + await expect(dialog.getByRole('status')).toContainText('1 tags ready'); + expect(await page.evaluate(() => window.kikotools.autocomplete.customWords.length)).toBe(0); + await dialog.getByText('Triggers, matching and insertion', { exact: true }).click(); + await dialog.getByLabel('Embedding trigger', { exact: true }).fill('emb:'); + await dialog.getByLabel('LoRA trigger', { exact: true }).fill('l:'); + await dialog.getByLabel('Quick embedding trigger', { exact: true }).fill('zz'); + await dialog.getByLabel('Maximum suggestions', { exact: true }).fill('8'); + await dialog.getByLabel('Minimum characters', { exact: true }).fill('3'); + await dialog.getByRole('switch', { name: 'Group by directory', exact: true }).uncheck(); + await dialog.getByRole('switch', { name: 'Insert on Enter', exact: true }).uncheck(); + await dialog.getByRole('button', { name: 'Save Settings', exact: true }).click(); + await expect(dialog).toHaveCount(0); + expect(await page.evaluate(() => ({ ...window.kikotools.autocomplete.settings }))).toMatchObject({ embeddingTrigger: 'emb:', loraTrigger: 'l:', quickTrigger: 'zz', maxSuggestions: 8, minChars: 3, sortByDirectory: false, insertOnEnter: false }); + expect(await page.evaluate(() => window.kikotools.autocomplete.customWords[0].frequency)).toBe(120); +}); + +test('autocomplete cancelling a staged import leaves the cached source unchanged', async ({ page }) => { + await openHost(page); + const dialog = await mountAutocompleteSettings(page); + await dialog.getByLabel('Tag list file', { exact: true }).setInputFiles({ name: 'staged.csv', mimeType: 'text/csv', buffer: Buffer.from('blue_eyes,0,10,azure') }); + await expect(dialog.getByRole('status')).toContainText('1 tags ready'); + await dialog.getByRole('button', { name: 'Cancel', exact: true }).click(); + expect(await page.evaluate(() => window.kikotools.autocomplete.customWords)).toEqual([]); + expect(await page.evaluate(async () => (await import('/extensions/kikotools/autocomplete/tag-storage.js')).tagCache())).toBeUndefined(); +}); + +test('autocomplete reports tag download and cache errors without losing the current settings', async ({ page }) => { + await openHost(page); + const dialog = await mountAutocompleteSettings(page); + await dialog.getByRole('button', { name: 'Load URL', exact: true }).click(); + await expect(dialog.getByRole('status')).toContainText('Enter a valid'); + await page.route('**/__test__/bad-tags.csv', route => route.fulfill({ status: 404, body: 'Missing' })); + await dialog.getByLabel('Tag list URL', { exact: true }).fill(new URL('/__test__/bad-tags.csv', page.url()).href); + await dialog.getByRole('button', { name: 'Load URL', exact: true }).click(); + await expect(dialog.getByRole('status')).toContainText('404'); + await dialog.getByLabel('Tag list file', { exact: true }).setInputFiles({ name: 'valid.csv', mimeType: 'text/csv', buffer: Buffer.from('blue_eyes,0,10,azure') }); + await expect(dialog.getByRole('status')).toContainText('1 tags ready'); + await dialog.getByRole('switch', { name: 'Enable Autocomplete', exact: true }).uncheck(); + await page.evaluate(() => { + window.originalTagDBOpen = indexedDB.open.bind(indexedDB); + indexedDB.open = () => { throw new DOMException('Storage denied', 'SecurityError'); }; + }); + await dialog.getByRole('button', { name: 'Save Settings', exact: true }).click(); + await expect(dialog.getByRole('status')).toContainText('Could not save tag list'); + expect(await page.evaluate(() => window.kikotools.autocomplete.settings.enabled)).toBe(true); + expect(await page.evaluate(() => window.kikotools.autocomplete.customWords.length)).toBe(0); + expect(await page.evaluate(() => window.kikotools.autocomplete.settings.tagSource)).toBeUndefined(); + expect(await page.evaluate(() => JSON.parse(localStorage.getItem('kikotools.embedding_autocomplete.settings')).tagSource)).toBeUndefined(); + expect(await page.evaluate(() => JSON.parse(localStorage.getItem('kikotools.embedding_autocomplete.settings')).enabled)).toBe(true); + await page.evaluate(() => indexedDB.open = window.originalTagDBOpen); + await dialog.getByRole('button', { name: 'Save Settings', exact: true }).click(); + await expect(dialog).toHaveCount(0); + expect(await page.evaluate(() => window.kikotools.autocomplete.customWords.length)).toBe(1); +}); + +test('autocomplete cancels pending tag downloads when its settings close', async ({ page }) => { + await openHost(page); + let held; + await page.route('**/__test__/slow-tags.csv', route => { held = route; }); + const dialog = await mountAutocompleteSettings(page); + await dialog.getByLabel('Tag list URL', { exact: true }).fill(new URL('/__test__/slow-tags.csv', page.url()).href); + await dialog.getByRole('button', { name: 'Load URL', exact: true }).click(); + await expect.poll(() => Boolean(held)).toBe(true); + await expect(dialog.getByRole('button', { name: 'Save Settings', exact: true })).toBeDisabled(); + await dialog.getByRole('button', { name: 'Cancel', exact: true }).click(); + await held.fulfill({ body: 'blue_eyes,0,10,azure' }); + expect(await page.evaluate(() => window.kikotools.autocomplete.customWords.length)).toBe(0); + expect(await page.evaluate(() => window.kikotools.autocomplete.pendingFetches.size)).toBe(0); +}); + +test('autocomplete imports 300000 tags off the UI thread and bounds broad matches', async ({ page }) => { + await openHost(page); + const dialog = await mountAutocompleteSettings(page); + const text = Array.from({ length: 300000 }, (_, i) => `tag_${i},0,${i},alias_${i}`).join('\n'); + await page.evaluate(() => { window.tagImportTicks = 0; window.tagImportTimer = setInterval(() => window.tagImportTicks++, 10); }); + await dialog.getByLabel('Tag list file', { exact: true }).setInputFiles({ name: 'large.csv', mimeType: 'text/csv', buffer: Buffer.from(text) }); + await expect(dialog.getByRole('status')).toContainText('300,000 tags ready', { timeout: 15000 }); + expect(await page.evaluate(() => { clearInterval(window.tagImportTimer); return window.tagImportTicks; })).toBeGreaterThan(1); + await dialog.getByRole('switch', { name: 'Show Custom Words', exact: true }).check(); + await dialog.getByRole('button', { name: 'Save Settings', exact: true }).click(); + await expect(dialog).toHaveCount(0); + await createNode(page, 'TextInput', { text: '' }); + const prompt = page.getByLabel('text', { exact: true }); + await expect(prompt).toHaveAttribute('aria-autocomplete', 'list'); + await prompt.fill('alias_299999'); + const list = page.getByRole('listbox', { name: 'Prompt completions', exact: true }); + await expect(list.getByRole('option')).toHaveCount(1); + await expect(list.getByRole('option')).toContainText('tag_299999'); + await prompt.fill('tag_'); + await expect(list.getByRole('option')).toHaveCount(20); +}); + +test('autocomplete legacy URL loads retain the newest valid list when older requests finish late', async ({ page }) => { + await openHost(page); + let held; + await page.route('**/__test__/old-tags.txt', route => { held = route; }); + await page.route('**/__test__/new-tags.txt', route => route.fulfill({ body: 'blue_sky,10\nblue_eyes,200' })); + await page.evaluate(() => { window.oldTagLoad = window.kikotools.autocomplete.loadCustomWords('/__test__/old-tags.txt'); }); + await expect.poll(() => Boolean(held)).toBe(true); + expect(await page.evaluate(() => window.kikotools.autocomplete.loadCustomWords('/__test__/new-tags.txt'))).toBe(2); + await held.fulfill({ body: 'obsolete,1000' }); + expect(await page.evaluate(() => window.oldTagLoad)).toBe(0); + expect(await page.evaluate(() => window.kikotools.autocomplete.customWords.map(tag => tag.name))).toEqual(['blue_eyes', 'blue_sky']); + expect(await page.evaluate(() => window.kikotools.autocomplete.loadCustomWords('file:///tmp/tags.csv'))).toBe(0); + expect(await page.evaluate(() => window.kikotools.autocomplete.customWords.length)).toBe(2); + expect(await page.evaluate(() => window.kikotools.autocomplete.customWordsError)).toContain('HTTP or HTTPS'); +}); + +test('autocomplete shares one removable node hook across widgets in any cleanup order', async ({ page }) => { + await openHost(page); + const result = await page.evaluate(() => { + const autocomplete = window.kikotools.autocomplete; + const restored = []; + const calls = []; + const make = () => { + const original = function (...args) { calls.push([this === node, args]); return 'original-result'; }; + const node = { onRemoved: original }; + const widgets = [0, 1, 2].map(() => ({ inputEl: document.createElement('textarea') })); + widgets.forEach(widget => autocomplete.attachToWidget(widget, node)); + return { node, original, widgets }; + }; + for (const order of [[0, 1, 2], [2, 0, 1]]) { + const { node, original, widgets } = make(); + const hook = node.onRemoved; + autocomplete.detachFromWidget(widgets[order[0]]); + restored.push(node.onRemoved === hook); + autocomplete.detachFromWidget(widgets[order[1]]); + autocomplete.detachFromWidget(widgets[order[2]]); + restored.push(node.onRemoved === original); + } + const active = make(); + const removal = active.node.onRemoved('removed', 7); + restored.push(active.node.onRemoved === active.original, active.widgets.every(widget => !autocomplete.activeWidgets.has(widget))); + const final = make(); + autocomplete.cleanup(); + restored.push(final.node.onRemoved === final.original); + return { restored, removal, calls }; + }); + expect(result).toEqual({ restored: Array(7).fill(true), removal: 'original-result', calls: [[true, ['removed', 7]]] }); +}); + +test('autocomplete inserts Unicode and punctuation aliases at the correct prompt boundary', async ({ page }) => { + await openHost(page); + await createNode(page, 'TextInput', { text: '' }); + const prompt = page.getByLabel('text', { exact: true }); + await expect(prompt).toHaveAttribute('aria-autocomplete', 'list'); + await page.evaluate(async () => { + const autocomplete = window.kikotools.autocomplete; + const { parseTagList } = await import('/extensions/kikotools/autocomplete/tags.js'); + autocomplete.customWords = parseTagList('日本語,0,10,にほんご\nheart,0,20,<3\nhatsune_miku_(vocaloid),4,100,miku/vocaloid\n🎨🎨,0,1,\n(o_o),0,1,'); + Object.assign(autocomplete.settings, { showCustomWords: true, showEmbeddings: false, showLoras: false, replaceUnderscores: false }); + }); + const list = page.getByRole('listbox', { name: 'Prompt completions', exact: true }); + for (const [query, label, value] of [ + ['日本', '日本語', '日本語, '], + ['にほん', '日本語', '日本語, '], + ['sky, <3', 'heart', 'sky, heart, '], + ['miku/vocal', 'hatsune_miku_(vocaloid)', 'hatsune_miku_(vocaloid), '], + ['sky, (日本:1.2)', '日本語', 'sky, (日本語:1.2)'], + ['🎨🎨', '🎨🎨', '🎨🎨, '], + ['(o_o)', '(o_o)', '(o_o), '], + ]) { + await prompt.fill(query); + if (query.includes(':1.2')) await prompt.evaluate(element => { element.setSelectionRange(element.value.indexOf(':1.2'), element.value.indexOf(':1.2')); element.dispatchEvent(new Event('input', { bubbles: true })); }); + await expect(list.getByRole('option').first()).toContainText(label); + await prompt.press('ArrowDown'); + await prompt.press('Tab'); + await expect(prompt).toHaveValue(value); + } +}); + +test('autocomplete stops an oversized URL stream before it reaches the parser', async ({ page }) => { + await openHost(page); + const result = await page.evaluate(async () => { + const autocomplete = window.kikotools.autocomplete; + let cancelled = false, sent = false; + const original = autocomplete.fetchWithAbort; + autocomplete.fetchWithAbort = async () => new Response(new ReadableStream({ + pull(controller) { if (!sent) { sent = true; controller.enqueue(new Uint8Array(32 * 1024 * 1024 + 1).fill(97)); } else controller.close(); }, + cancel() { cancelled = true; }, + }, { highWaterMark: 0 })); + let error; + try { await autocomplete.fetchTagList('/__test__/oversized-tags.csv'); } + catch (failure) { error = failure.message; } + finally { autocomplete.fetchWithAbort = original; } + return { cancelled, error }; + }); + expect(result.cancelled).toBe(true); + expect(result.error).toMatch(/large/i); +}); diff --git a/tests/comfyui/image-browser.spec.mjs b/tests/comfyui/image-browser.spec.mjs index fd8b01b..40383f4 100644 --- a/tests/comfyui/image-browser.spec.mjs +++ b/tests/comfyui/image-browser.spec.mjs @@ -17,7 +17,7 @@ test.beforeEach(async ({ page, context, request, baseURL }, testInfo) => { const request = route.request(); const url = new URL(request.url()); const path = url.pathname.replace(/^\/api\//, '/'); - if (path.startsWith('/kiko_local_image_loader/') || path === '/view') { + if (path.startsWith('/kiko_local_image_loader/') || path.startsWith('/kikotools/autocomplete/') || path === '/view') { const response = await route.fetch({ url: `${fixture}${path}${url.search}` }); return route.fulfill({ response }); } @@ -35,6 +35,7 @@ test.beforeEach(async ({ page, context, request, baseURL }, testInfo) => { } return route.continue(); }); + await page.addInitScript(() => localStorage.setItem('kikotools.embedding_autocomplete.settings', JSON.stringify({ showCustomWords: false, customWordsUrl: '' }))); await page.goto('/', { waitUntil: 'domcontentloaded' }); await expect(page.getByRole('status', { name: 'Loading ComfyUI' })).toBeHidden({ timeout: 30000 }); await page.waitForFunction(async () => { @@ -562,3 +563,167 @@ for (const name of ['DisplayText', 'DisplayAny']) { expect(await page.evaluate(() => window.removedDisplay.displayOutput.scope.signal.aborted)).toBe(true); }); } + + +test('embedding suggestions survive real renderers, zoom, themes and workflow reload', async ({ page }, testInfo) => { + await page.evaluate(async () => { + const { app } = await import('/scripts/app.js'); + const node = LiteGraph.createNode('TextInput'); + app.graph.add(node); + node.setSize([550, 350]); + app.canvas.centerOnNode(node); + }); + await expect.poll(() => page.evaluate(() => window.kikotools.autocomplete.embeddings.length)).toBe(2); + const prompt = page.locator('textarea:visible'); + const list = page.getByRole('listbox', { name: 'Prompt completions', exact: true }); + for (const [renderer, zoom] of [[false, .65], [true, 1.1], [false, .9]]) { + await page.evaluate(async ({ renderer, zoom }) => { + const { app } = await import('/scripts/app.js'); + await app.ui.settings.setSettingValue('Comfy.VueNodes.Enabled', renderer); + app.canvas.ds.scale = zoom; + app.canvas.centerOnNode(app.graph._nodes[0]); + }, { renderer, zoom }); + await expect.poll(() => page.evaluate(() => LiteGraph.vueNodesMode)).toBe(renderer); + await prompt.fill('embedding:ca'); + await expect(list).toBeVisible(); + await prompt.press('ArrowDown'); + await expect(list.getByRole('option', { selected: true })).toHaveCount(1); + await expect(prompt).toBeFocused(); + const box = await list.boundingBox(); + expect(box.x).toBeGreaterThanOrEqual(0); + expect(box.y).toBeGreaterThanOrEqual(0); + expect(box.x + box.width).toBeLessThanOrEqual(1600); + expect(box.y + box.height).toBeLessThanOrEqual(1100); + await prompt.press('Enter'); + await expect(prompt).toHaveValue('embedding:portraits/cat, '); + await prompt.fill(', '); + } + await page.evaluate(async () => { + const { app } = await import('/scripts/app.js'); + await app.loadGraphData(app.graph.serialize()); + app.canvas.centerOnNode(app.graph._nodes[0]); + }); + await expect(prompt).toHaveValue(', '); + for (const theme of ['light', 'dark']) { + await page.evaluate(async theme => { + const { app } = await import('/scripts/app.js'); + await app.ui.settings.setSettingValue('Comfy.ColorPalette', theme); + }, theme); + await prompt.fill('em'); + await prompt.press('ArrowDown'); + await expect(list).toHaveAttribute('data-ca-theme', theme); + await page.screenshot({ path: testInfo.outputPath(`suggestions-${theme}.png`) }); + await prompt.press('Escape'); + } + await page.evaluate(async () => { + const { app } = await import('/scripts/app.js'); + const node = app.graph._nodes[0]; + window.removedCompletionWidget = node.widgets.find(widget => widget.inputEl); + app.graph.remove(node); + }); + await expect(list).toBeHidden(); + expect(await page.evaluate(() => window.kikotools.autocomplete.activeWidgets.has(window.removedCompletionWidget))).toBe(false); +}); + +test('embedding settings use the real host launcher and preserve preferences', async ({ page }, testInfo) => { + await page.getByRole('button', { name: 'Settings (Ctrl + ,)', exact: true }).click(); + const search = page.getByPlaceholder(/search/i); + await search.fill('Embedding Autocomplete'); + const trigger = page.getByRole('button', { name: 'Open Settings', exact: true }); + await trigger.click(); + const dialog = page.getByRole('dialog', { name: 'Embedding Autocomplete Settings', exact: true }); + await expect(dialog).toBeVisible(); + for (const theme of ['light', 'dark']) { + await page.evaluate(async theme => { + const { app } = await import('/scripts/app.js'); + await app.ui.settings.setSettingValue('Comfy.ColorPalette', theme); + }, theme); + await expect(dialog).toHaveAttribute('data-ca-theme', theme); + await page.screenshot({ path: testInfo.outputPath(`settings-${theme}.png`) }); + } + await dialog.getByRole('switch', { name: 'Show LoRAs', exact: true }).uncheck(); + await dialog.getByRole('button', { name: 'Save Settings', exact: true }).click(); + await expect(dialog).toHaveCount(0); + await expect(trigger).toBeFocused(); + expect(await page.evaluate(() => JSON.parse(localStorage.getItem('kikotools.embedding_autocomplete.settings')).showLoras)).toBe(false); + await trigger.click(); + await expect(dialog.getByRole('switch', { name: 'Show LoRAs', exact: true })).not.toBeChecked(); + await dialog.getByRole('switch', { name: 'Show LoRAs', exact: true }).check(); + await page.keyboard.press('Escape'); + expect(await page.evaluate(() => window.kikotools.autocomplete.settings.showLoras)).toBe(false); +}); + +test('embedding tag files import through real settings and complete canonical tags in both renderers', async ({ page }, testInfo) => { + await page.getByRole('button', { name: 'Settings (Ctrl + ,)', exact: true }).click(); + await page.getByPlaceholder(/search/i).fill('Embedding Autocomplete'); + await page.getByRole('button', { name: 'Open Settings', exact: true }).click(); + const dialog = page.getByRole('dialog', { name: 'Embedding Autocomplete Settings', exact: true }); + const input = process.env.KIKO_TAG_CSV || { name: 'tags.csv', mimeType: 'text/csv', buffer: Buffer.from('highres,5,100,"high_res,high_resolution"\nblue_eyes,0,90,azure') }; + await dialog.getByLabel('Tag list file', { exact: true }).setInputFiles(input); + await expect(dialog.getByRole('status')).toContainText('tags ready', { timeout: 15000 }); + await testInfo.attach('import-status.txt', { body: await dialog.getByRole('status').innerText(), contentType: 'text/plain' }); + await dialog.getByRole('switch', { name: 'Show Custom Words', exact: true }).check(); + await dialog.getByText('Triggers, matching and insertion', { exact: true }).click(); + await dialog.getByRole('switch', { name: 'Replace underscores with spaces', exact: true }).uncheck(); + await page.screenshot({ path: testInfo.outputPath('tag-import.png') }); + await dialog.getByRole('button', { name: 'Save Settings', exact: true }).click(); + await expect(dialog).toHaveCount(0); + await page.keyboard.press('Escape'); + await page.evaluate(async () => { + const { app } = await import('/scripts/app.js'); + const node = LiteGraph.createNode('TextInput'); + app.graph.add(node); + node.setSize([550, 350]); + app.canvas.centerOnNode(node); + }); + const prompt = page.locator('textarea:visible'); + const list = page.getByRole('listbox', { name: 'Prompt completions', exact: true }); + for (const renderer of [false, true, false]) { + await page.evaluate(async renderer => { + const { app } = await import('/scripts/app.js'); + await app.ui.settings.setSettingValue('Comfy.VueNodes.Enabled', renderer); + app.canvas.centerOnNode(app.graph._nodes[0]); + }, renderer); + await expect.poll(() => page.evaluate(() => LiteGraph.vueNodesMode)).toBe(renderer); + await prompt.fill('high_resolution'); + await expect(list.getByRole('option').first()).toContainText('highres'); + await prompt.press('ArrowDown'); + await prompt.press('Tab'); + await expect(prompt).toHaveValue('highres, '); + } +}); + +test('embedding Unicode and emoticon completion preserves prompt boundaries in both renderers', async ({ page }) => { + await page.evaluate(async () => { + const { app } = await import('/scripts/app.js'); + const { parseTagList } = await import('/extensions/kikotools/autocomplete/tags.js'); + const autocomplete = window.kikotools.autocomplete; + autocomplete.customWords = parseTagList('日本語,0,10,にほんご\nheart,0,20,<3'); + Object.assign(autocomplete.settings, { showCustomWords: true, showEmbeddings: false, showLoras: false }); + const node = LiteGraph.createNode('TextInput'); + app.graph.add(node); + node.setSize([550, 350]); + app.canvas.centerOnNode(node); + }); + const prompt = page.locator('textarea:visible'); + const list = page.getByRole('listbox', { name: 'Prompt completions', exact: true }); + for (const renderer of [false, true, false]) { + await page.evaluate(async renderer => { + const { app } = await import('/scripts/app.js'); + await app.ui.settings.setSettingValue('Comfy.VueNodes.Enabled', renderer); + app.canvas.centerOnNode(app.graph._nodes[0]); + }, renderer); + await expect.poll(() => page.evaluate(() => LiteGraph.vueNodesMode)).toBe(renderer); + await prompt.focus(); + await expect(prompt).toHaveAttribute('aria-autocomplete', 'list'); + for (const [query, result] of [['sky, 日本', 'sky, 日本語, '], ['sky, <3', 'sky, heart, ']]) { + await prompt.fill(query); + await expect(list.getByRole('option')).toHaveCount(1); + await prompt.press('ArrowDown'); + await prompt.press('Tab'); + await expect(prompt).toHaveValue(result); + } + } +}); diff --git a/tests/js/autocomplete-download.test.mjs b/tests/js/autocomplete-download.test.mjs new file mode 100644 index 0000000..a0e817f --- /dev/null +++ b/tests/js/autocomplete-download.test.mjs @@ -0,0 +1,54 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { readTagResponse } from '../../web/autocomplete/tag-download.js'; + +function response(chunks, headers = {}) { + let index = 0, cancelled = false; + const body = new ReadableStream({ + pull(controller) { if (index < chunks.length) controller.enqueue(chunks[index++]); else controller.close(); }, + cancel() { cancelled = true; }, + }, { highWaterMark: 0 }); + return { value: new Response(body, { headers }), cancelled: () => cancelled, read: () => index }; +} +const encode = value => new TextEncoder().encode(value); + +test('tag downloads stop reading at the byte limit with missing or dishonest headers', async () => { + for (const headers of [{}, { 'content-length': '1' }]) { + const stream = response([encode('1234'), encode('56789'), encode('unread')], headers); + await assert.rejects(readTagResponse(stream.value, { maxBytes: 8 }), /large/i); + assert.equal(stream.cancelled(), true); + assert.equal(stream.read(), 2); + assert.equal(stream.value.body.locked, false); + } +}); +test('oversized declared downloads are cancelled without reading their contents', async () => { + const stream = response([encode('unread')], { 'content-length': '100' }); + await assert.rejects(readTagResponse(stream.value, { maxBytes: 8 }), /large/i); + assert.equal(stream.read(), 0); + assert.equal(stream.cancelled(), true); +}); +test('streamed UTF-8 survives split code points and an exact byte limit', async () => { + const bytes = encode('日本語'); + const stream = response([bytes.slice(0, 2), bytes.slice(2, 5), bytes.slice(5)]); + assert.equal(await readTagResponse(stream.value, { maxBytes: bytes.length }), '日本語'); + assert.equal(stream.value.body.locked, false); + assert.equal(await readTagResponse(new Response(null)), ''); +}); +test('aborted or failed downloads release their reader and preserve the error', { timeout: 2000 }, async () => { + const controller = new AbortController(); + let cancelled = false; + const stream = new ReadableStream({ cancel() { cancelled = true; } }); + const value = new Response(stream); + const pending = readTagResponse(value, { signal: controller.signal }); + controller.abort(); + await assert.rejects(pending, { name: 'AbortError' }); + assert.equal(cancelled, true); + assert.equal(value.body.locked, false); + const stopped = response([encode('unread')]); + await assert.rejects(readTagResponse(stopped.value, { signal: AbortSignal.abort() }), { name: 'AbortError' }); + assert.equal(stopped.cancelled(), true); + const error = new Error('Network interrupted'); + const failed = new Response(new ReadableStream({ start(stream) { stream.error(error); } })); + await assert.rejects(readTagResponse(failed), /Network interrupted/); + assert.equal(failed.body.locked, false); +}); diff --git a/tests/js/autocomplete-position.test.mjs b/tests/js/autocomplete-position.test.mjs new file mode 100644 index 0000000..0c35e10 --- /dev/null +++ b/tests/js/autocomplete-position.test.mjs @@ -0,0 +1,9 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { suggestionPosition } from '../../web/ui/autocomplete-suggestions.js'; + +test('completion popup stays within the viewport and flips above a low field', () => { + assert.deepEqual(suggestionPosition({ left: 20, top: 40, bottom: 80 }, { width: 220, height: 150 }, { width: 800, height: 600 }), { left: 20, top: 84 }); + assert.deepEqual(suggestionPosition({ left: 300, top: 400, bottom: 440 }, { width: 220, height: 150 }, { width: 320, height: 480 }), { left: 92, top: 246 }); + assert.deepEqual(suggestionPosition({ left: -30, top: 2, bottom: 10 }, { width: 300, height: 300 }, { width: 240, height: 200 }), { left: 8, top: 8 }); +}); diff --git a/tests/js/autocomplete-storage.test.mjs b/tests/js/autocomplete-storage.test.mjs new file mode 100644 index 0000000..12f2b03 --- /dev/null +++ b/tests/js/autocomplete-storage.test.mjs @@ -0,0 +1,39 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { tagCache, parseTagFile } from '../../web/autocomplete/tag-storage.js'; + +test('a blocked cache operation cannot write after its caller has received an error', async () => { + const request = {}; + let closed = false, wrote = false; + globalThis.indexedDB = { open: () => request }; + try { + const operation = tagCache({ source: 'new-file' }); + request.onblocked(); + await assert.rejects(operation, /blocked/); + request.result = { close: () => { closed = true; }, transaction: () => { wrote = true; throw new Error('Abandoned operation wrote data'); } }; + request.onsuccess(); + assert.equal(closed, true); + assert.equal(wrote, false); + } finally { delete globalThis.indexedDB; } +}); + +test('cancelled imports never start workers, and running workers terminate on cancellation or failure', async () => { + await assert.rejects(parseTagFile('tag', AbortSignal.abort()), { name: 'AbortError' }); + let current; + globalThis.Worker = class { + constructor() { current = this; this.terminated = false; } + postMessage() {} + terminate() { this.terminated = true; } + }; + try { + const controller = new AbortController(); + const aborted = parseTagFile('tag', controller.signal); + controller.abort(); + await assert.rejects(aborted, { name: 'AbortError' }); + assert.equal(current.terminated, true); + const failed = parseTagFile('tag', new AbortController().signal); + current.onerror(); + await assert.rejects(failed, /Could not parse/); + assert.equal(current.terminated, true); + } finally { delete globalThis.Worker; } +}); diff --git a/tests/js/autocomplete-tags.test.mjs b/tests/js/autocomplete-tags.test.mjs new file mode 100644 index 0000000..7ae092e --- /dev/null +++ b/tests/js/autocomplete-tags.test.mjs @@ -0,0 +1,76 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { parseTagList, searchTags, tagQuery } from '../../web/autocomplete/tags.js'; + +test('four-column packs preserve categories, popularity and quoted aliases', () => { + const tags = parseTagList('\ufefftag,category,post_count,aliases\r\nblue_eyes,0,900,"blue_eye,azure_eyes"\r\nartist_name,1,700,"artist,artist_alias"\r\n'); + assert.deepEqual(tags.map(({ name, category, frequency, aliases }) => ({ name, category, frequency, aliases })), [ + { name: 'blue_eyes', category: 0, frequency: 900, aliases: ['blue_eye', 'azure_eyes'] }, + { name: 'artist_name', category: 1, frequency: 700, aliases: ['artist', 'artist_alias'] }, + ]); + assert.equal(searchTags(tags, 'azure', 5)[0].value, 'blue_eyes'); +}); +test('legacy words, frequencies and headerless packs remain compatible', () => { + assert.deepEqual(parseTagList('low,2\nhigh,100\nplain\n').map(tag => [tag.name, tag.frequency]), [['high', 100], ['low', 2], ['plain', 0]]); + assert.equal(parseTagList('long_hair,0,6317335,')[0].frequency, 6317335); + assert.equal(parseTagList('"say""hello",0,12,"nickname"')[0].name, 'say"hello'); +}); +test('invalid data fails clearly instead of replacing a valid list with garbage', () => { + for (const text of ['', 'error', '', 'x,0,NaN,', 'x,2,12,', 'x,-1', '"unfinished', 'x,0,1,a,extra', 'x,0,1,"a"tail', 'name,category,post_count,aliases\n']) { + assert.throws(() => parseTagList(text), Error, text); + } + assert.throws(() => parseTagList('x'.repeat(32 * 1024 * 1024 + 1)), /large/i); +}); +test('duplicates merge aliases while retaining the highest frequency', () => { + const tags = parseTagList('blue_eyes,0,100,azure\nblue_eyes,0,200,blue_eye'); + assert.equal(tags.length, 1); + assert.equal(tags[0].frequency, 200); + assert.deepEqual(tags[0].aliases, ['azure', 'blue_eye']); +}); +test('search ranks exact canonical and alias matches, prefixes, then substrings by popularity', () => { + const tags = parseTagList('blue_sky,0,100,azure\nblue_eyes,0,900,\nlight_blue,0,500,\nblue,0,1,\n'); + assert.deepEqual(searchTags(tags, 'blue', 3).map(tag => tag.name), ['blue', 'blue_eyes', 'blue_sky']); + assert.equal(searchTags(tags, 'AZURE', 1)[0].name, 'blue_sky'); + assert.deepEqual(searchTags(tags, 'missing', 3), []); + assert.deepEqual(searchTags(tags, '', 0), []); + assert.deepEqual(searchTags(tags, '', 2).map(tag => tag.name), ['blue_eyes', 'light_blue']); +}); +test('search handles 300,000 tags with bounded results and canonical alias insertion', () => { + const text = Array.from({ length: 300000 }, (_, i) => `tag_${i},0,${i},alias_${i}`).join('\n'); + const tags = parseTagList(text); + assert.equal(tags.length, 300000); + const matches = searchTags(tags, 'alias_299999', 20); + assert.equal(matches.length, 1); + assert.equal(matches[0].value, 'tag_299999'); +}); + +test('tag data supports Unicode, emoticons and quoted commas as plain text', () => { + const tags = parseTagList('heart,0,50,<3\n>_<,0,20,\n日本語,3,10,"別名,にほんご"'); + assert.equal(searchTags(tags, '<3', 1)[0].value, 'heart'); + assert.equal(searchTags(tags, 'にほんご', 1)[0].value, '日本語'); + assert.equal(searchTags(tags, '>_<', 1)[0].value, '>_<'); +}); + +test('worker reports parsed tag records and errors through its message boundary', async () => { + const messages = []; + globalThis.self = { postMessage: message => messages.push(message) }; + try { + await import('../../web/autocomplete/tag-worker.js'); + self.onmessage({ data: 'blue_eyes,0,20,azure' }); + self.onmessage({ data: 'bad,0,invalid,' }); + assert.equal(messages[0].tags[0].frequency, 20); + assert.match(messages[1].error, /post count/); + } finally { delete globalThis.self; } +}); + +test('prompt query boundaries preserve Unicode, punctuation and surrounding weighting syntax', () => { + assert.deepEqual(tagQuery('sky, 日本'), { prefix: '日本', start: 5 }); + assert.deepEqual(tagQuery('(o_o)'), { prefix: '(o_o)', start: 0 }); + assert.deepEqual(tagQuery('<3'), { prefix: '<3', start: 0 }); + assert.deepEqual(tagQuery('sky, (日本', true), { prefix: '日本', start: 6 }); + assert.deepEqual(tagQuery('hatsune_miku_(vocaloid'), { prefix: 'hatsune_miku_(vocaloid', start: 0 }); + assert.deepEqual(tagQuery('foo\nmiku/vocal'), { prefix: 'miku/vocal', start: 4 }); + assert.deepEqual(tagQuery('🎨🎨'), { prefix: '🎨🎨', start: 0 }); + assert.equal(tagQuery('sky, '), null); + assert.equal(tagQuery('(', true), null); +}); diff --git a/tools/testing/frontend-coverage-baseline.json b/tools/testing/frontend-coverage-baseline.json index 42bb1b2..ae9e54b 100644 --- a/tools/testing/frontend-coverage-baseline.json +++ b/tools/testing/frontend-coverage-baseline.json @@ -1,4 +1,28 @@ { + "autocomplete/tag-download.js": { + "lines": 98, + "branches": 98, + "functions": 98, + "statements": 98 + }, + "autocomplete/tag-storage.js": { + "lines": 98, + "branches": 98, + "functions": 74, + "statements": 98 + }, + "autocomplete/tag-worker.js": { + "lines": 98, + "branches": 98, + "functions": 98, + "statements": 98 + }, + "autocomplete/tags.js": { + "lines": 98, + "branches": 95, + "functions": 98, + "statements": 98 + }, "controllers/display-node.js": { "lines": 98, "branches": 95, @@ -30,10 +54,10 @@ "statements": 52 }, "kikoEmbeddingAutocomplete.js": { - "lines": 44, - "branches": 43, - "functions": 59, - "statements": 44 + "lines": 82, + "branches": 74, + "functions": 92, + "statements": 82 }, "kiko_custom_colors.js": { "lines": 46, @@ -77,6 +101,12 @@ "functions": 88, "statements": 95 }, + "ui/autocomplete-suggestions.js": { + "lines": 98, + "branches": 98, + "functions": 98, + "statements": 98 + }, "ui/color-picker.js": { "lines": 98, "branches": 94, @@ -97,7 +127,7 @@ }, "ui/dialog.js": { "lines": 98, - "branches": 85, + "branches": 88, "functions": 98, "statements": 98 }, @@ -119,12 +149,24 @@ "functions": 98, "statements": 93 }, + "ui/text-widget-adapter.js": { + "lines": 98, + "branches": 89, + "functions": 98, + "statements": 98 + }, "ui/workflow-clock.js": { "lines": 98, "branches": 98, "functions": 98, "statements": 98 }, + "views/autocomplete-settings.js": { + "lines": 98, + "branches": 83, + "functions": 98, + "statements": 98 + }, "views/image-browser.js": { "lines": 98, "branches": 98, diff --git a/web/autocomplete/tag-download.js b/web/autocomplete/tag-download.js new file mode 100644 index 0000000..16a710b --- /dev/null +++ b/web/autocomplete/tag-download.js @@ -0,0 +1,39 @@ +import { MAX_TAG_BYTES } from './tags.js'; + +export async function readTagResponse(response, { maxBytes = MAX_TAG_BYTES, signal } = {}) { + const tooLarge = () => new Error(`Tag file too large. Maximum size is ${maxBytes === MAX_TAG_BYTES ? '32 MB' : `${maxBytes} bytes`}.`); + if (signal?.aborted) { + await response.body?.cancel(); + signal.throwIfAborted(); + } + if (Number(response.headers.get('content-length')) > maxBytes) { + await response.body?.cancel(); + throw tooLarge(); + } + if (!response.body) return ''; + const reader = response.body.getReader(); + const abort = () => { reader.cancel().catch(() => {}); }; + signal?.addEventListener('abort', abort, { once: true }); + const decoder = new TextDecoder(); + const parts = []; + let bytes = 0; + try { + while (true) { + signal?.throwIfAborted(); + const { done, value } = await reader.read(); + signal?.throwIfAborted(); + if (done) break; + bytes += value.byteLength; + if (bytes > maxBytes) { + await reader.cancel(); + throw tooLarge(); + } + parts.push(decoder.decode(value, { stream: true })); + } + parts.push(decoder.decode()); + return parts.join(''); + } finally { + signal?.removeEventListener('abort', abort); + reader.releaseLock(); + } +} diff --git a/web/autocomplete/tag-storage.js b/web/autocomplete/tag-storage.js new file mode 100644 index 0000000..2038586 --- /dev/null +++ b/web/autocomplete/tag-storage.js @@ -0,0 +1,36 @@ +export function tagCache(value) { + return new Promise((resolve, reject) => { + const request = indexedDB.open('kikotools-autocomplete', 1); + request.onupgradeneeded = () => request.result.createObjectStore('tags'); + request.onerror = () => reject(request.error); + let blocked = false; + request.onblocked = () => { blocked = true; reject(new Error('Tag storage is blocked by another browser tab.')); }; + request.onsuccess = () => { + const db = request.result; + if (blocked) { db.close(); return; } + db.onversionchange = () => db.close(); + const transaction = db.transaction('tags', value === undefined ? 'readonly' : 'readwrite'); + const store = transaction.objectStore('tags'); + const result = value === undefined ? store.get('active') : store.put(value, 'active'); + transaction.oncomplete = () => { db.close(); resolve(result.result); }; + transaction.onabort = transaction.onerror = () => { db.close(); reject(transaction.error || new Error('Could not access tag storage.')); }; + }; + }); +} + +export function parseTagFile(text, signal) { + return new Promise((resolve, reject) => { + if (signal.aborted) { reject(new DOMException('Import cancelled.', 'AbortError')); return; } + const worker = new Worker(new URL('./tag-worker.js', import.meta.url), { type: 'module' }); + const finish = (callback, value) => { + worker.terminate(); + signal.removeEventListener('abort', abort); + callback(value); + }; + const abort = () => finish(reject, new DOMException('Import cancelled.', 'AbortError')); + signal.addEventListener('abort', abort, { once: true }); + worker.onmessage = ({ data }) => data.error ? finish(reject, new Error(data.error)) : finish(resolve, data.tags); + worker.onerror = () => finish(reject, new Error('Could not parse tag file.')); + worker.postMessage(text); + }); +} diff --git a/web/autocomplete/tag-worker.js b/web/autocomplete/tag-worker.js new file mode 100644 index 0000000..90c9373 --- /dev/null +++ b/web/autocomplete/tag-worker.js @@ -0,0 +1,5 @@ +import { parseTagList } from './tags.js'; +self.onmessage = ({ data }) => { + try { self.postMessage({ tags: parseTagList(data) }); } + catch (error) { self.postMessage({ error: error.message }); } +}; diff --git a/web/autocomplete/tags.js b/web/autocomplete/tags.js new file mode 100644 index 0000000..169bea6 --- /dev/null +++ b/web/autocomplete/tags.js @@ -0,0 +1,86 @@ +export const MAX_TAG_BYTES = 32 * 1024 * 1024; + +function* csvRows(text) { + let row = [], field = '', quoted = false, closed = false; + for (let i = 0; i <= text.length; i++) { + const char = text[i]; + if (quoted) { + if (char === undefined) throw new Error('Unclosed CSV quote.'); + if (char === '"') { + if (text[i + 1] === '"') { field += '"'; i++; } + else { quoted = false; closed = true; } + } else field += char; + } else if (char === ',' || char === '\n' || char === undefined) { + row.push(field.trim()); + field = ''; closed = false; + if (char !== ',') { + if (row.some(value => value !== '')) yield row; + row = []; + } + } else if (char === '\r' && (text[i + 1] === '\n' || i === text.length - 1)) { + continue; + } else if (char === '"' && !field && !closed) quoted = true; + else { + if (closed && char.trim()) throw new Error('Unexpected text after CSV quote.'); + field += char; + } + } +} + +export function parseTagList(text) { + if (new TextEncoder().encode(text).byteLength > MAX_TAG_BYTES) throw new Error('Tag file too large. Maximum size is 32 MB.'); + if (/^\s*<(?:!doctype|html|script|body|iframe)\b/i.test(text)) throw new Error('Expected a tag list, received HTML or script content.'); + const unique = new Map(); + let first = true; + for (const row of csvRows(text.replace(/^\ufeff/, ''))) { + if (first && ['tag', 'name'].includes(row[0].toLowerCase()) && ['category', 'frequency', 'post_count'].includes(row[1]?.toLowerCase())) { + first = false; continue; + } + first = false; + if (![1, 2, 4].includes(row.length)) throw new Error('Expected tag,frequency or tag,category,post_count,aliases.'); + const name = row[0]; + if (!name || name.length > 500 || /[\u0000-\u001f\u007f]/.test(name)) throw new Error('Invalid tag name.'); + const count = row.length === 4 ? row[2] : row[1] || '0'; + if (!/^\d+$/.test(count) || !Number.isSafeInteger(Number(count))) throw new Error(`Invalid post count for ${name}.`); + const category = row.length === 4 ? Number(row[1]) : null; + if (row.length === 4 && (!/^[01345]$/.test(row[1]))) throw new Error(`Invalid category for ${name}.`); + const aliases = row.length === 4 ? [...new Set(row[3].split(',').map(value => value.trim()).filter(Boolean))] : []; + if (aliases.some(alias => alias.length > 500 || /[\u0000-\u001f\u007f]/.test(alias))) throw new Error(`Invalid alias for ${name}.`); + const existing = unique.get(name); + if (existing) { + existing.frequency = Math.max(existing.frequency, Number(count)); + existing.aliases = [...new Set([...existing.aliases, ...aliases])]; + } else unique.set(name, { name, category, frequency: Number(count), aliases }); + } + if (!unique.size) throw new Error('No tags found in the file.'); + for (const tag of unique.values()) tag.searchText = [tag.name, ...tag.aliases].join('\n').toLowerCase(); + return [...unique.values()].sort((a, b) => b.frequency - a.frequency || a.name.localeCompare(b.name)); +} + +export function searchTags(tags, prefix, limit) { + if (limit <= 0) return []; + prefix = prefix.toLowerCase(); + const buckets = [[], [], []]; + for (const tag of tags) { + if (tag.searchText && !tag.searchText.includes(prefix)) continue; + const names = [tag.name, ...(tag.aliases || [])]; + let tier = 3; + for (const name of names) { + const lower = name.toLowerCase(); + if (lower === prefix) { tier = 0; break; } + if (lower.startsWith(prefix)) tier = Math.min(tier, 1); + else if (lower.includes(prefix)) tier = Math.min(tier, 2); + } + if (tier < 3 && buckets[tier].length < limit) buckets[tier].push(tag); + if (buckets[0].length >= limit) break; + } + return buckets.flat().slice(0, limit).map(tag => ({ ...tag, fullPath: tag.name, type: 'custom', display: tag.name, value: tag.name })); +} + +export function tagQuery(text, weighted = false) { + const match = text.match(/[^\s,]+$/u); + if (!match) return null; + const leading = weighted ? match[0].match(/^[\[(]*/)[0].length : 0; + const prefix = match[0].slice(leading); + return prefix ? { prefix, start: match.index + leading } : null; +} diff --git a/web/kikoEmbeddingAutocomplete.js b/web/kikoEmbeddingAutocomplete.js index 03bf766..52c3a53 100644 --- a/web/kikoEmbeddingAutocomplete.js +++ b/web/kikoEmbeddingAutocomplete.js @@ -1,10 +1,21 @@ import { app } from "../../scripts/app.js"; import { api } from "../../scripts/api.js"; import { ComfyWidgets } from "../../scripts/widgets.js"; +import { createScope } from './ui/core.js'; +import { createSuggestionView } from './ui/autocomplete-suggestions.js'; +import { watchRendererTextareas } from './ui/text-widget-adapter.js'; +import { createAutocompleteLauncher } from './views/autocomplete-settings.js'; +import { searchTags, tagQuery } from './autocomplete/tags.js'; +import { parseTagFile, tagCache } from './autocomplete/tag-storage.js'; +import { readTagResponse } from './autocomplete/tag-download.js'; // KikoEmbeddingAutocomplete - Provides autocomplete for embeddings and LoRAs in text widgets class KikoEmbeddingAutocomplete { constructor() { + this.scope = createScope(); + this.widgetCleanups = new Set(); + this.nodeHooks = new WeakMap(); + this.pendingFrames = new Set(); this.embeddings = []; this.loras = []; this.customWords = []; @@ -41,8 +52,8 @@ class KikoEmbeddingAutocomplete { this.fetchResourcesDebounced(); // Load custom words if URL is set - if (this.settings.customWordsUrl && this.settings.showCustomWords) { - this.loadCustomWords(this.settings.customWordsUrl); + if ((this.settings.tagSource || this.settings.customWordsUrl) && this.settings.showCustomWords) { + this.restoreCustomWords(); } // Single suggestion container for all widgets @@ -50,39 +61,42 @@ class KikoEmbeddingAutocomplete { this.currentWidget = null; // Cleanup on page unload - window.addEventListener('beforeunload', () => this.cleanup()); + this.scope.listen(window, 'beforeunload', () => this.cleanup()); + this.scope.listen(window, 'resize', () => this.hideSuggestions()); + this.scope.listen(window, 'scroll', () => this.hideSuggestions()); + watchRendererTextareas(this.scope, app.graph, this); } debounce(func, wait) { let timeout; - return function executedFunction(...args) { - const later = () => { - clearTimeout(timeout); - func(...args); - }; + const debounced = (...args) => { clearTimeout(timeout); - timeout = setTimeout(later, wait); + timeout = setTimeout(() => { if (!this.scope.signal.aborted) func(...args); }, wait); }; + debounced.cancel = () => clearTimeout(timeout); + return debounced; + } + + nextFrame(callback) { + if (this.scope.signal.aborted) return; + const frame = requestAnimationFrame(() => { + this.pendingFrames.delete(frame); + if (!this.scope.signal.aborted) callback(); + }); + this.pendingFrames.add(frame); } cleanup() { - // Cancel all pending fetches - this.pendingFetches.forEach(controller => { - try { - controller.abort(); - } catch (e) { - // Ignore abort errors - } - }); + this.fetchResourcesDebounced.cancel(); + for (const frame of this.pendingFrames) cancelAnimationFrame(frame); + this.pendingFrames.clear(); + this.settingsDialog?.close('removed'); + for (const cleanup of [...this.widgetCleanups]) cleanup(); + for (const controller of this.pendingFetches) controller.abort(); this.pendingFetches.clear(); - - // Clean up suggestion container - if (this.suggestionContainer && this.suggestionContainer.parentNode) { - this.suggestionContainer.parentNode.removeChild(this.suggestionContainer); - this.suggestionContainer = null; - } - - // Note: WeakMap and WeakSet will automatically clean up when widgets are garbage collected + this.scope.dispose(); + this.suggestionContainer = null; + this.suggestionView = null; } loadSettings() { @@ -99,8 +113,10 @@ class KikoEmbeddingAutocomplete { saveSettings() { try { localStorage.setItem("kikotools.embedding_autocomplete.settings", JSON.stringify(this.settings)); + return true; } catch (error) { console.error("[KikoTools] Error saving autocomplete settings:", error); + return false; } } @@ -111,7 +127,7 @@ class KikoEmbeddingAutocomplete { try { const response = await fetch(url, { ...options, - signal: controller.signal + signal: AbortSignal.any([controller.signal, this.scope.signal, ...(options.signal ? [options.signal] : [])]) }); this.pendingFetches.delete(controller); return response; @@ -283,141 +299,107 @@ class KikoEmbeddingAutocomplete { } } - async loadCustomWords(url) { - if (!url) return 0; - + async restoreCustomWords() { + const revision = this.tagRevision = (this.tagRevision || 0) + 1; try { - const response = await this.fetchWithAbort(url); - if (!response || !response.ok) { - throw new Error(`Failed to load custom words: ${response?.status || 'aborted'}`); + const cached = await tagCache(); + const source = this.settings.tagSource || this.settings.customWordsUrl; + if (cached?.source === source) { + const tags = await parseTagFile(cached.text, this.scope.signal); + if (!this.scope.signal.aborted && revision === this.tagRevision) this.customWords = tags; + } else if (!this.scope.signal.aborted && revision === this.tagRevision) { + if (this.settings.tagSource?.startsWith('file:')) this.customWordsError = 'Saved tag file is missing. Import it again.'; + else await this.loadCustomWords(this.settings.customWordsUrl); } - - const text = await response.text(); - - // Security validation - detect potentially dangerous patterns - // Note: This is defense-in-depth for text files, not HTML rendering - const dangerousPatterns = [ - // Script tags - catch all variations including bypass attempts with whitespace - // Matches: , , etc. - /<\s*\/?script[^>]*>/gi, // Script tags (opening/closing with any attributes/whitespace) - // Other dangerous HTML tags - /<\s*iframe[^>]*>/gi, - /<\s*embed[^>]*>/gi, - /<\s*object[^>]*>/gi, - // JavaScript protocol and event handlers - /javascript:/gi, - /data:text\/html/gi, - /\bon\w+\s*=/gi, // Matches any event handler (onclick, onload, etc.) - // Code execution patterns - /import\s+/gi, - /require\s*\(/gi, - /eval\s*\(/gi, - /new\s+Function\s*\(/gi, - /\.innerHTML\s*=/gi, - // DOM manipulation - /document\./gi, - /window\./gi, - // Prototype pollution - /__proto__/gi, - /\.prototype\./gi, - /\.constructor\s*\(/gi - ]; - - for (const pattern of dangerousPatterns) { - if (pattern.test(text)) { - throw new Error("Security Warning: File contains potentially dangerous code patterns."); - } - } - - // Size check - if (text.length > 10 * 1024 * 1024) { - throw new Error("File too large. Maximum size is 10MB."); - } - - const lines = text.split('\n'); - this.customWords = []; - const maxLineLength = 500; - - for (const line of lines) { - const trimmed = line.trim(); - if (!trimmed || trimmed.length > maxLineLength) continue; - - // Security check for individual lines - if ((trimmed.includes('') || - trimmed.includes('eval(') || - trimmed.includes('document.') || - trimmed.includes('window.')) { - continue; - } - - const parts = trimmed.split(','); - const word = parts[0].trim(); - const frequency = parts[1] ? parseInt(parts[1]) : 0; - - const safeWordPattern = /^[a-zA-Z0-9_\s\-:()'\.\!\?,\/\+\*&@#;=\[\]]+$/; - if (word && safeWordPattern.test(word) && word.length > 0) { - this.customWords.push({ - name: word, - fullPath: word, - type: "custom", - display: word, - value: word, - frequency: frequency - }); - } - } - - // Sort by frequency and name - this.customWords.sort((a, b) => { - if (a.frequency !== b.frequency) { - return b.frequency - a.frequency; - } - return a.name.localeCompare(b.name); - }); - - return this.customWords.length; - } catch (error) { - console.error("[KikoTools] Error loading custom words:", error); + if (error.name !== 'AbortError' && !this.scope.signal.aborted && revision === this.tagRevision) { + if (this.settings.tagSource?.startsWith('file:')) this.customWordsError = 'Could not restore the saved tag file. Import it again.'; + else await this.loadCustomWords(this.settings.customWordsUrl); + } + } + } + + async fetchTagList(url, signal = this.scope.signal) { + const parsed = new URL(url, location.href); + if (!['http:', 'https:'].includes(parsed.protocol)) throw new Error('Use an HTTP or HTTPS tag-list URL.'); + const response = await this.fetchWithAbort(parsed.href, { signal }); + if (!response?.ok) throw new Error(`Tag download failed (${response?.status || 'cancelled'}).`); + const text = await readTagResponse(response, { signal }); + const tags = await parseTagFile(text, signal); + return { text, tags, source: parsed.href }; + } + + async loadCustomWords(url) { + if (!url || this.scope.signal.aborted) return 0; + const revision = this.tagRevision = (this.tagRevision || 0) + 1; + try { + const list = await this.fetchTagList(url); + if (revision !== this.tagRevision || this.scope.signal.aborted) return 0; + this.customWords = list.tags; + this.customWordsError = ''; + return this.customWords.length; + } catch (error) { + if (error.name !== 'AbortError') this.customWordsError = error.message; return 0; } } + async saveTagSettings(next, pending) { + const previous = { ...this.settings }; + Object.assign(this.settings, next); + if (!this.saveSettings()) { + this.settings = previous; + throw new Error('Could not save settings. Check browser storage and try again.'); + } + if (pending) { + try { await tagCache({ text: pending.text, source: pending.source }); } + catch (error) { + this.settings = previous; + this.saveSettings(); + throw new Error(`Could not save tag list: ${error.message}`); + } + this.tagRevision = (this.tagRevision || 0) + 1; + this.customWords = pending.tags; + this.customWordsError = ''; + } + if (this.settings.showEmbeddings || this.settings.showLoras) this.fetchResourcesDebounced(); + if (!pending && this.settings.showCustomWords && (!previous.showCustomWords || previous.customWordsUrl !== this.settings.customWordsUrl)) this.restoreCustomWords(); + } + createSuggestionContainer() { if (this.suggestionContainer) return; + this.suggestionView = createSuggestionView(this.scope, app.ui.settings); + this.suggestionContainer = this.suggestionView.element; + } - this.suggestionContainer = document.createElement("div"); - this.suggestionContainer.className = "kikotools-autocomplete-suggestions"; - this.suggestionContainer.style.cssText = ` - position: absolute; - z-index: 10000; - background: var(--comfy-menu-bg); - border: 1px solid var(--border-color); - border-radius: 4px; - max-height: 200px; - overflow-y: auto; - display: none; - min-width: 200px; - box-shadow: 0 2px 5px rgba(0,0,0,0.2); - `; - document.body.appendChild(this.suggestionContainer); + hideSuggestions() { + this.currentWidget?.inputEl.removeAttribute('aria-activedescendant'); + this.suggestionView?.hide(); + this.currentWidget = null; } attachToWidget(widget, node) { - if (!widget.inputEl || this.activeWidgets.has(widget)) { + if (this.scope.signal.aborted || !widget.inputEl || widget.inputEl.readOnly || widget.inputEl.disabled || this.activeWidgets.has(widget)) { return; } + if (node.graph === null || (node.graph?._nodes && !node.graph._nodes.includes(node))) return; this.activeWidgets.add(widget); const textarea = widget.inputEl; + const widgetScope = createScope(); + let blurFrame; + widgetScope.cleanup(() => cancelAnimationFrame(blurFrame)); + const attributes = new Map(['aria-controls', 'aria-autocomplete', 'aria-haspopup', 'aria-activedescendant'].map(name => [name, textarea.getAttribute(name)])); // Create suggestion container if it doesn't exist if (!this.suggestionContainer) { this.createSuggestionContainer(); } + textarea.setAttribute('aria-controls', this.suggestionContainer.id); + textarea.setAttribute('aria-autocomplete', 'list'); + textarea.setAttribute('aria-haspopup', 'listbox'); + // Store widget-specific state const widgetState = { currentSuggestions: [], @@ -429,118 +411,28 @@ class KikoEmbeddingAutocomplete { // Event handler functions const handlers = { hideSuggestions: () => { - if (this.suggestionContainer) { - this.suggestionContainer.style.display = "none"; - this.suggestionContainer.innerHTML = ""; - } + if (this.currentWidget === widget) this.hideSuggestions(); widgetState.selectedIndex = -1; widgetState.currentSuggestions = []; - this.currentWidget = null; }, - showSuggestions: (suggestions, x, y) => { - if (!suggestions.length) { - handlers.hideSuggestions(); - return; - } - - if (!this.suggestionContainer) { - this.createSuggestionContainer(); - } - + showSuggestions: (suggestions) => { + if (!suggestions.length) { handlers.hideSuggestions(); return; } + if (this.currentWidget !== widget) this.hideSuggestions(); this.currentWidget = widget; - this.suggestionContainer.innerHTML = ""; - - suggestions.forEach((item, index) => { - const div = document.createElement("div"); - div.className = "kikotools-autocomplete-item"; - div.style.cssText = ` - padding: 5px 10px; - cursor: pointer; - color: var(--fg-color); - ${index === widgetState.selectedIndex ? "background: var(--comfy-input-bg);" : ""} - `; - - // Add type indicator - const typeSpan = document.createElement("span"); - let typeColor = "#2196F3"; - let typeText = "[LORA]"; - - if (item.type === "embedding") { - typeColor = "#4CAF50"; - typeText = "[EMB]"; - } else if (item.type === "custom") { - typeColor = "#FFA500"; - typeText = "[TAG]"; - } - - typeSpan.style.cssText = ` - display: inline-block; - width: 60px; - color: ${typeColor}; - font-size: 0.9em; - `; - typeSpan.textContent = typeText; - - const nameSpan = document.createElement("span"); - if (item.type === "embedding" && item.fullPath && item.fullPath.includes('/')) { - const parts = item.fullPath.split('/'); - const dir = parts.slice(0, -1).join('/'); - const file = parts[parts.length - 1]; - - const dirSpan = document.createElement("span"); - dirSpan.style.cssText = "color: #888; font-size: 0.9em;"; - dirSpan.textContent = dir + "/"; - - const fileSpan = document.createElement("span"); - fileSpan.textContent = file; - - nameSpan.appendChild(dirSpan); - nameSpan.appendChild(fileSpan); - } else { - nameSpan.textContent = item.name; - } - - div.appendChild(typeSpan); - div.appendChild(nameSpan); - - div.addEventListener("mouseenter", () => { - widgetState.selectedIndex = index; - handlers.updateSelection(); - }); - - div.addEventListener("click", () => { - handlers.insertSuggestion(item); - }); - - this.suggestionContainer.appendChild(div); - }); - - // Position the suggestions - const rect = textarea.getBoundingClientRect(); - this.suggestionContainer.style.left = Math.min(x, rect.right - 200) + "px"; - this.suggestionContainer.style.top = y + "px"; - this.suggestionContainer.style.display = "block"; + if (widgetState.selectedIndex >= suggestions.length) widgetState.selectedIndex = -1; + this.suggestionView.render(suggestions, index => { + widgetState.selectedIndex = index; + handlers.updateSelection(); + }, handlers.insertSuggestion); + handlers.updateSelection(); + this.suggestionView.position(textarea.getBoundingClientRect()); }, updateSelection: () => { - const items = this.suggestionContainer.querySelectorAll(".kikotools-autocomplete-item"); - items.forEach((item, index) => { - item.style.background = index === widgetState.selectedIndex ? "var(--comfy-input-bg)" : ""; - }); - - // Auto-scroll to keep selected item visible - if (widgetState.selectedIndex >= 0 && items[widgetState.selectedIndex]) { - const selectedItem = items[widgetState.selectedIndex]; - const containerRect = this.suggestionContainer.getBoundingClientRect(); - const itemRect = selectedItem.getBoundingClientRect(); - - if (itemRect.bottom > containerRect.bottom) { - this.suggestionContainer.scrollTop += itemRect.bottom - containerRect.bottom + 5; - } else if (itemRect.top < containerRect.top) { - this.suggestionContainer.scrollTop -= containerRect.top - itemRect.top + 5; - } - } + const id = this.suggestionView.select(widgetState.selectedIndex); + if (id) textarea.setAttribute('aria-activedescendant', id); + else textarea.removeAttribute('aria-activedescendant'); }, insertSuggestion: (suggestion) => { @@ -549,7 +441,7 @@ class KikoEmbeddingAutocomplete { const textBefore = text.substring(0, cursor); let insertText = ""; - let replaceLength = widgetState.currentPrefix.length; + let replaceLength = cursor - widgetState.prefixStart; // Apply replace underscores setting for custom words if (suggestion.type === "custom" && this.settings.replaceUnderscores) { @@ -584,12 +476,7 @@ class KikoEmbeddingAutocomplete { replaceLength = match[1].length; } else { insertText = suggestion.value || suggestion.display; - let wordStart = cursor - widgetState.currentPrefix.length; - while (wordStart > 0 && /[a-zA-Z0-9_-]/.test(text[wordStart - 1])) { - wordStart--; - } - widgetState.prefixStart = wordStart; - replaceLength = cursor - wordStart; + replaceLength = cursor - widgetState.prefixStart; } const before = text.substring(0, widgetState.prefixStart); @@ -599,13 +486,12 @@ class KikoEmbeddingAutocomplete { let finalInsertText = insertText; if (this.settings.autoInsertComma && (suggestion.type === "custom" || suggestion.type === "embedding" || suggestion.type === "lora")) { const trimmedAfter = after.trim(); - const nextChar = trimmedAfter.charAt(0); - - if (nextChar !== ',' && nextChar !== '.' && nextChar !== ';') { + if (!/^(?:[,.;)\]]|:\s*[+-]?(?:\d+(?:\.\d*)?|\.\d+)\s*[)\]])/.test(trimmedAfter)) { finalInsertText += ', '; } } + textarea.focus({ preventScroll: true }); textarea.value = before + finalInsertText + after; textarea.selectionStart = textarea.selectionEnd = widgetState.prefixStart + finalInsertText.length; @@ -652,18 +538,11 @@ class KikoEmbeddingAutocomplete { return "lora"; } - // Check for general word - let start = cursor - 1; - while (start >= 0 && /[a-zA-Z0-9_-]/.test(text[start])) { - start--; - } - start++; - - const word = text.substring(start, cursor); - - if (word.length >= this.settings.minChars) { - widgetState.currentPrefix = word.toLowerCase(); - widgetState.prefixStart = start; + const query = tagQuery(textBefore); + if (query && [...query.prefix].length >= this.settings.minChars) { + widgetState.currentPrefix = query.prefix.toLowerCase(); + widgetState.prefixStart = query.start; + const word = query.prefix; const quickTrigger = this.settings.quickTrigger; if (quickTrigger && word.toLowerCase().startsWith(quickTrigger.toLowerCase())) { @@ -703,14 +582,13 @@ class KikoEmbeddingAutocomplete { } if (this.settings.showCustomWords) { - suggestions.push(...this.customWords.filter(w => - w.name.toLowerCase().includes(prefix) - )); + suggestions.push(...searchTags(this.customWords, prefix, this.settings.maxSuggestions)); } } // Sort suggestions suggestions.sort((a, b) => { + if (a.type === 'custom' && b.type === 'custom') return 0; const aName = a.name.toLowerCase(); const bName = b.name.toLowerCase(); const aPath = (a.fullPath || a.name).toLowerCase(); @@ -741,18 +619,26 @@ class KikoEmbeddingAutocomplete { }, onInput: (e) => { - if (!this.settings.enabled) { + if (!this.settings.enabled || textarea.readOnly || textarea.disabled || e.isComposing) { handlers.hideSuggestions(); return; } const triggerType = handlers.findPrefix(); + widgetState.selectedIndex = -1; if (triggerType) { widgetState.currentSuggestions = handlers.getSuggestions(widgetState.currentPrefix, triggerType); + if (!widgetState.currentSuggestions.length && triggerType === 'general') { + const query = tagQuery(textarea.value.substring(0, textarea.selectionStart), true); + if (query && query.start !== widgetState.prefixStart && [...query.prefix].length >= this.settings.minChars) { + widgetState.currentPrefix = query.prefix.toLowerCase(); + widgetState.prefixStart = query.start; + widgetState.currentSuggestions = handlers.getSuggestions(widgetState.currentPrefix, triggerType); + } + } if (widgetState.currentSuggestions.length > 0) { - const rect = textarea.getBoundingClientRect(); - handlers.showSuggestions(widgetState.currentSuggestions, rect.left, rect.bottom); + handlers.showSuggestions(widgetState.currentSuggestions); } else { handlers.hideSuggestions(); } @@ -762,38 +648,39 @@ class KikoEmbeddingAutocomplete { }, onKeydown: (e) => { - if (!this.suggestionContainer || this.suggestionContainer.style.display === "none" || this.currentWidget !== widget) { + if (e.isComposing || e.keyCode === 229 || !this.suggestionContainer || this.suggestionContainer.hidden || this.currentWidget !== widget) { return; } if (e.key === "ArrowDown") { e.preventDefault(); + e.stopPropagation(); widgetState.selectedIndex = Math.min(widgetState.selectedIndex + 1, widgetState.currentSuggestions.length - 1); handlers.updateSelection(); } else if (e.key === "ArrowUp") { e.preventDefault(); + e.stopPropagation(); widgetState.selectedIndex = Math.max(widgetState.selectedIndex - 1, 0); handlers.updateSelection(); } else if ((e.key === "Enter" && this.settings.insertOnEnter) || (e.key === "Tab" && this.settings.insertOnTab)) { if (widgetState.selectedIndex >= 0 && widgetState.selectedIndex < widgetState.currentSuggestions.length) { e.preventDefault(); + e.stopPropagation(); handlers.insertSuggestion(widgetState.currentSuggestions[widgetState.selectedIndex]); } } else if (e.key === "Escape") { e.preventDefault(); + e.stopPropagation(); handlers.hideSuggestions(); } }, onBlur: () => { - // Use requestAnimationFrame to allow click events to fire first - requestAnimationFrame(() => { - requestAnimationFrame(() => { - if (this.currentWidget === widget) { - handlers.hideSuggestions(); - } - }); + cancelAnimationFrame(blurFrame); + blurFrame = requestAnimationFrame(() => { + blurFrame = null; + if (!widgetScope.signal.aborted && document.activeElement !== textarea && this.currentWidget === widget) handlers.hideSuggestions(); }); }, @@ -805,35 +692,47 @@ class KikoEmbeddingAutocomplete { } }; - // Add event listeners - textarea.addEventListener("input", handlers.onInput); - textarea.addEventListener("keydown", handlers.onKeydown); - textarea.addEventListener("blur", handlers.onBlur); - textarea.addEventListener("scroll", handlers.onScroll); - - // Store cleanup function + for (const [event, handler] of [['input', handlers.onInput], ['keydown', handlers.onKeydown], ['blur', handlers.onBlur], ['scroll', handlers.onScroll]]) widgetScope.listen(textarea, event, handler); const cleanup = () => { - textarea.removeEventListener("input", handlers.onInput); - textarea.removeEventListener("keydown", handlers.onKeydown); - textarea.removeEventListener("blur", handlers.onBlur); - textarea.removeEventListener("scroll", handlers.onScroll); + if (widgetScope.signal.aborted) return; handlers.hideSuggestions(); + widgetScope.dispose(); + for (const [name, value] of attributes) { + if (value === null) textarea.removeAttribute(name); + else textarea.setAttribute(name, value); + } this.activeWidgets.delete(widget); this.widgetCleanupMap.delete(widget); + this.widgetCleanups.delete(cleanup); }; - - this.widgetCleanupMap.set(widget, cleanup); - - // Handle widget removal - if (widget.onRemoved) { - const originalOnRemoved = widget.onRemoved; - widget.onRemoved = () => { - cleanup(); - originalOnRemoved.call(widget); - }; - } else { - widget.onRemoved = cleanup; + for (const [owner, name] of [[widget, 'onRemoved'], [widget, 'onRemove']]) { + const original = owner[name]; + const wrapped = function (...args) { cleanup(); return original?.apply(this, args); }; + owner[name] = wrapped; + widgetScope.cleanup(() => { if (owner[name] === wrapped) owner[name] = original; }); } + let nodeHook = this.nodeHooks.get(node); + if (!nodeHook) { + const original = node.onRemoved; + const cleanups = new Set(); + const wrapped = function (...args) { + for (const cleanup of [...cleanups]) cleanup(); + return original?.apply(this, args); + }; + nodeHook = { original, wrapped, cleanups }; + this.nodeHooks.set(node, nodeHook); + node.onRemoved = wrapped; + } + nodeHook.cleanups.add(cleanup); + widgetScope.cleanup(() => { + nodeHook.cleanups.delete(cleanup); + if (!nodeHook.cleanups.size) { + if (node.onRemoved === nodeHook.wrapped) node.onRemoved = nodeHook.original; + this.nodeHooks.delete(node); + } + }); + this.widgetCleanupMap.set(widget, cleanup); + this.widgetCleanups.add(cleanup); } detachFromWidget(widget) { @@ -844,256 +743,6 @@ class KikoEmbeddingAutocomplete { } } -// Settings Dialog -class KikoEmbeddingAutocompleteDialog { - constructor(autocompleteInstance) { - this.autocomplete = autocompleteInstance; - this.element = null; - } - - createDialog() { - // Create dialog backdrop - const backdrop = document.createElement("div"); - backdrop.className = "kiko-dialog-backdrop"; - backdrop.style.cssText = ` - position: fixed; - top: 0; - left: 0; - right: 0; - bottom: 0; - background: rgba(0, 0, 0, 0.7); - z-index: 99998; - display: flex; - align-items: center; - justify-content: center; - `; - - // Create dialog container - const dialog = document.createElement("div"); - dialog.className = "kiko-dialog"; - dialog.style.cssText = ` - background: var(--comfy-menu-bg, #202020); - border: 1px solid var(--border-color, #4e4e4e); - border-radius: 8px; - width: 600px; - max-width: 90vw; - max-height: 80vh; - display: flex; - flex-direction: column; - box-shadow: 0 10px 40px rgba(0, 0, 0, 0.5); - color: var(--fg-color, #ffffff); - font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; - `; - - // Header - const header = document.createElement("div"); - header.style.cssText = ` - padding: 20px; - border-bottom: 1px solid var(--border-color, #4e4e4e); - display: flex; - align-items: center; - justify-content: space-between; - `; - - const title = document.createElement("h2"); - title.textContent = "🫶 Embedding Autocomplete Settings"; - title.style.cssText = ` - margin: 0; - font-size: 20px; - font-weight: 600; - color: var(--fg-color, #ffffff); - `; - - const closeBtn = document.createElement("button"); - closeBtn.innerHTML = "✕"; - closeBtn.style.cssText = ` - background: transparent; - border: none; - color: var(--fg-color, #ffffff); - font-size: 24px; - cursor: pointer; - padding: 0; - width: 30px; - height: 30px; - display: flex; - align-items: center; - justify-content: center; - opacity: 0.7; - transition: opacity 0.2s; - `; - closeBtn.onmouseover = () => closeBtn.style.opacity = "1"; - closeBtn.onmouseout = () => closeBtn.style.opacity = "0.7"; - - header.appendChild(title); - header.appendChild(closeBtn); - - // Content - const content = document.createElement("div"); - content.style.cssText = ` - padding: 20px; - overflow-y: auto; - flex: 1; - `; - - // Create settings sections (simplified for brevity - same as original) - content.innerHTML = ` -
-

General Settings

- -
- -
- -
- -
- -
- -
- -
- -
-
- `; - - // Footer - const footer = document.createElement("div"); - footer.style.cssText = ` - padding: 15px 20px; - border-top: 1px solid var(--border-color, #4e4e4e); - display: flex; - justify-content: flex-end; - gap: 10px; - `; - - const saveBtn = document.createElement("button"); - saveBtn.textContent = "Save Settings"; - saveBtn.style.cssText = ` - padding: 8px 20px; - background: #4CAF50; - color: white; - border: none; - border-radius: 4px; - cursor: pointer; - font-size: 14px; - font-weight: 500; - transition: background 0.2s; - `; - saveBtn.onmouseover = () => saveBtn.style.background = "#45a049"; - saveBtn.onmouseout = () => saveBtn.style.background = "#4CAF50"; - - const cancelBtn = document.createElement("button"); - cancelBtn.textContent = "Cancel"; - cancelBtn.style.cssText = ` - padding: 8px 20px; - background: var(--comfy-input-bg, #1a1a1a); - color: var(--fg-color, #ffffff); - border: 1px solid var(--border-color, #4e4e4e); - border-radius: 4px; - cursor: pointer; - font-size: 14px; - transition: background 0.2s; - `; - - footer.appendChild(cancelBtn); - footer.appendChild(saveBtn); - - // Assemble dialog - dialog.appendChild(header); - dialog.appendChild(content); - dialog.appendChild(footer); - backdrop.appendChild(dialog); - - // Event handlers - closeBtn.onclick = () => this.close(); - cancelBtn.onclick = () => this.close(); - backdrop.onclick = (e) => { - if (e.target === backdrop) this.close(); - }; - - saveBtn.onclick = () => this.save(); - - this.element = backdrop; - this.dialog = dialog; - - return backdrop; - } - - loadSettings() { - if (!this.autocomplete) return; - - const settings = this.autocomplete.settings; - - // Set values (simplified for brevity) - const elements = { - enabled: document.getElementById("kiko-enabled"), - showEmbeddings: document.getElementById("kiko-show-embeddings"), - showLoras: document.getElementById("kiko-show-loras"), - showCustom: document.getElementById("kiko-show-custom") - }; - - if (elements.enabled) elements.enabled.checked = settings.enabled !== false; - if (elements.showEmbeddings) elements.showEmbeddings.checked = settings.showEmbeddings !== false; - if (elements.showLoras) elements.showLoras.checked = settings.showLoras !== false; - if (elements.showCustom) elements.showCustom.checked = settings.showCustomWords !== false; - } - - save() { - if (!this.autocomplete) return; - - // Gather settings - const newSettings = { - ...this.autocomplete.settings, - enabled: document.getElementById("kiko-enabled").checked, - showEmbeddings: document.getElementById("kiko-show-embeddings").checked, - showLoras: document.getElementById("kiko-show-loras").checked, - showCustomWords: document.getElementById("kiko-show-custom").checked - }; - - // Update autocomplete settings - Object.assign(this.autocomplete.settings, newSettings); - this.autocomplete.saveSettings(); - - // Refresh resources if needed - if (newSettings.showEmbeddings || newSettings.showLoras) { - this.autocomplete.fetchResourcesDebounced(); - } - - // Close dialog - this.close(); - } - - show() { - if (!this.element) { - this.createDialog(); - } - document.body.appendChild(this.element); - - // Load settings after adding to DOM - setTimeout(() => this.loadSettings(), 10); - } - - close() { - if (this.element && this.element.parentNode) { - this.element.parentNode.removeChild(this.element); - } - } -} - // Initialize and register let autocompleteInstance = null; let originalStringWidget = null; @@ -1109,6 +758,7 @@ app.registerExtension({ // Create new autocomplete instance autocompleteInstance = new KikoEmbeddingAutocomplete(); + const instance = autocompleteInstance; // Store reference for debugging (but not in global window to avoid conflicts) if (!window.kikotools) { @@ -1123,44 +773,7 @@ app.registerExtension({ tooltip: "Configure autocomplete settings", category: ["kikotools", "🫶 Embedding Autocomplete Configuration"], defaultValue: null, - type: () => { - const tr = document.createElement("tr"); - tr.className = "kikotools-settings-row"; - - const tdLabel = document.createElement("td"); - tdLabel.innerHTML = `
- 🫶 - Embedding Autocomplete Configuration -
`; - - const tdButton = document.createElement("td"); - const button = document.createElement("button"); - button.textContent = "Open Settings"; - button.className = "kiko-settings-button"; - button.style.cssText = ` - padding: 8px 20px; - background: #4CAF50; - color: white; - border: none; - border-radius: 4px; - cursor: pointer; - font-size: 14px; - font-weight: 500; - transition: background 0.2s; - `; - button.onmouseover = () => button.style.background = "#45a049"; - button.onmouseout = () => button.style.background = "#4CAF50"; - button.onclick = () => { - const dialog = new KikoEmbeddingAutocompleteDialog(autocompleteInstance); - dialog.show(); - }; - - tdButton.appendChild(button); - tr.appendChild(tdLabel); - tr.appendChild(tdButton); - - return tr; - } + type: () => createAutocompleteLauncher(autocompleteInstance, app.ui.settings, app.canvas.canvas) }); // Override STRING widget creation @@ -1168,31 +781,31 @@ app.registerExtension({ originalStringWidget = ComfyWidgets.STRING; } - ComfyWidgets.STRING = function(node, inputName, inputData, app) { + const wrappedStringWidget = function(node, inputName, inputData, app) { const widget = originalStringWidget.apply(this, arguments); // Only attach to multiline text widgets (prompts) - if (inputData[1]?.multiline && widget.inputEl) { + const entry = widget.widget || widget; + if (inputData[1]?.multiline && entry.inputEl) { // Use requestAnimationFrame to ensure DOM is ready - requestAnimationFrame(() => { - if (autocompleteInstance) { - autocompleteInstance.attachToWidget(widget, node); - } - }); + instance.nextFrame(() => instance.attachToWidget(entry, node)); } return widget; }; + ComfyWidgets.STRING = wrappedStringWidget; + instance.scope.cleanup(() => { if (ComfyWidgets.STRING === wrappedStringWidget) ComfyWidgets.STRING = originalStringWidget; }); + // Attach to existing multiline widgets const attachToExistingWidgets = () => { - if (!autocompleteInstance) return; + if (instance.scope.signal.aborted) return; app.graph._nodes.forEach(node => { if (node.widgets) { node.widgets.forEach(widget => { - if (widget.inputEl && widget.inputEl.tagName === "TEXTAREA" && !autocompleteInstance.activeWidgets.has(widget)) { - autocompleteInstance.attachToWidget(widget, node); + if (widget.inputEl && widget.inputEl.tagName === "TEXTAREA" && !instance.activeWidgets.has(widget)) { + instance.attachToWidget(widget, node); } }); } @@ -1200,19 +813,17 @@ app.registerExtension({ }; // Wait for graph to be ready - requestAnimationFrame(() => { - requestAnimationFrame(() => { - attachToExistingWidgets(); - }); - }); + instance.nextFrame(() => instance.nextFrame(attachToExistingWidgets)); // Re-attach when new nodes are added const originalAddNode = app.graph.add; - app.graph.add = function(...args) { + const wrappedAddNode = function(...args) { const result = originalAddNode.apply(this, args); - requestAnimationFrame(() => attachToExistingWidgets()); + instance.nextFrame(attachToExistingWidgets); return result; }; + app.graph.add = wrappedAddNode; + instance.scope.cleanup(() => { if (app.graph.add === wrappedAddNode) app.graph.add = originalAddNode; }); }, async beforeUnload() { diff --git a/web/ui/README.md b/web/ui/README.md index 615cb7b..623e577 100644 --- a/web/ui/README.md +++ b/web/ui/README.md @@ -141,3 +141,52 @@ Legacy widget values remain readable: converted input prefixes, prompt header pairs, native Display Any modes and all saved output chunks are recognized. The new output DOM widget does not serialize presentation state into native widget values. Node removal disposes the view, its listeners and theme observers. + +## Embedding Autocomplete + +`autocomplete-suggestions.js` supplies the shared themed listbox, type badges, +selected state and viewport placement. The controller retains resource fetching, +matching, sorting, triggers and insertion preferences. Focus stays in the native +textarea; arrow keys select suggestions, configured Enter/Tab keys insert, and +Escape dismisses. New input clears the previous selection. Completion names are +rendered as text, including directory names, without interpreting markup. + +`text-widget-adapter.js` binds editable Nodes 2.0 replacement textareas through +their node ID and associated label. It leaves the original widget/input intact, +cleans up disconnected replacements, and preserves Classic behavior when switching +back. Read-only and disabled fields are excluded. Each widget owns its listeners, +blur work and temporary accessibility attributes; removing a different widget +does not dismiss the active list. Page cleanup cancels pending refresh work. + +`../views/autocomplete-settings.js` uses the shared native dialog and switches. +Only Save updates the existing `kikotools.embedding_autocomplete.settings` key; +Cancel/Escape discards edits. All advanced saved preferences are retained. +Storage failures keep the dialog open and restore the previous live preferences. +The settings launcher owns dialog cleanup, tolerates synchronous reparenting and +returns keyboard focus when the modal closes. The shared modal shell contains +pointer events so nested dialogs do not dismiss ComfyUI's parent settings panel. + +### Tag packs and autocomplete preferences + +The settings view stages CSV/TXT file and URL imports until Save Settings. +`autocomplete/tags.js` parses quoted four-column Danbooru data and legacy +word/frequency lists, validates counts/categories, deduplicates tags and aliases, +and limits search results without sorting every matching tag on each keystroke. +`autocomplete/tag-worker.js` performs parsing outside the UI thread; its owner +terminates it on completion, error, or dialog removal. Records expand to the +existing suggestion shape only for returned results. + +`autocomplete/tag-storage.js` persists the active source text in browser +IndexedDB. The controller restores matching cached sources before requesting a +URL, guards stale loads, and retains the active list when an import fails. +Advanced trigger, matching and insertion controls use the existing settings key. +For dataset provenance, format and installation, see +[Tag packs](../../docs/tag-packs/README.md). + +Node removal uses one hook per node, shared by its active text widgets. Detaching +widgets in any order restores the original hook after the last widget leaves. +General queries accept Unicode and punctuation between whitespace/comma +boundaries. Literal bracketed names are tried before interpreting leading +parentheses/brackets as prompt weighting; insertion preserves surrounding +weight syntax. URL downloads enforce their byte limit while reading the body, +cancel oversized/aborted streams, and decode UTF-8 across chunk boundaries. diff --git a/web/ui/autocomplete-suggestions.js b/web/ui/autocomplete-suggestions.js new file mode 100644 index 0000000..f26f0a7 --- /dev/null +++ b/web/ui/autocomplete-suggestions.js @@ -0,0 +1,73 @@ +import { createScope, mountTheme } from './core.js'; + +let nextList = 0; + +export function suggestionPosition(field, popup, viewport) { + const left = Math.max(8, Math.min(field.left, viewport.width - popup.width - 8)); + const below = field.bottom + 4; + const top = below + popup.height <= viewport.height - 8 ? below : field.top - popup.height - 4; + return { left, top: Math.max(8, Math.min(top, viewport.height - popup.height - 8)) }; +} + +export function createSuggestionView(scope, settings) { + const element = document.createElement('div'); + element.className = 'ca-tools ca-completions kikotools-autocomplete-suggestions'; + element.id = `kiko-completions-${++nextList}`; + element.role = 'listbox'; + element.setAttribute('aria-label', 'Prompt completions'); + element.hidden = true; + document.body.append(element); + mountTheme(element, settings, scope); + let optionsScope; + scope.cleanup(() => { optionsScope?.dispose(); element.remove(); }); + return { + element, + hide() { + optionsScope?.dispose(); + element.hidden = true; + element.replaceChildren(); + }, + render(suggestions, onSelect, onInsert) { + optionsScope?.dispose(); + optionsScope = createScope(); + element.replaceChildren(); + suggestions.forEach((item, index) => { + const option = document.createElement('div'); + option.className = 'kikotools-autocomplete-item'; + option.id = `${element.id}-${index}`; + option.role = 'option'; + option.setAttribute('aria-selected', 'false'); + const badge = document.createElement('span'); + badge.className = 'ca-completion-type'; + badge.textContent = item.type === 'embedding' ? '[EMB]' : item.type === 'lora' ? '[LORA]' : '[TAG]'; + const name = document.createElement('span'); + name.className = 'ca-completion-name'; + const path = item.type === 'embedding' ? item.fullPath : null; + if (path?.includes('/')) { + const directory = document.createElement('span'); + directory.className = 'ca-completion-directory'; + directory.textContent = path.slice(0, path.lastIndexOf('/') + 1); + name.append(directory, document.createTextNode(path.slice(path.lastIndexOf('/') + 1))); + } else name.textContent = item.name; + option.append(badge, name); + optionsScope.listen(option, 'pointerdown', event => event.preventDefault()); + optionsScope.listen(option, 'pointermove', () => onSelect(index)); + optionsScope.listen(option, 'click', () => onInsert(item)); + element.append(option); + }); + element.hidden = false; + }, + select(index) { + const options = [...element.children]; + options.forEach((option, position) => option.setAttribute('aria-selected', String(position === index))); + options[index]?.scrollIntoView({ block: 'nearest' }); + return options[index]?.id; + }, + position(field) { + const popup = element.getBoundingClientRect(); + const { left, top } = suggestionPosition(field, popup, { width: innerWidth, height: innerHeight }); + element.style.left = `${left}px`; + element.style.top = `${top}px`; + }, + }; +} diff --git a/web/ui/controls.css b/web/ui/controls.css index a6bda8e..0c8ec59 100644 --- a/web/ui/controls.css +++ b/web/ui/controls.css @@ -201,3 +201,30 @@ .ca-text-output[data-output-kind="any"] textarea { font-family: ui-monospace, SFMono-Regular, Consolas, monospace; } .ca-text-output > p { font-size: 11px; color: var(--ca-muted); min-block-size: 17px; margin: 0; } .ca-text-output > p[data-error="true"] { color: var(--ca-error-text); } + +.ca-autocomplete-launcher { display: inline-flex; background: transparent; } +.ca-autocomplete-settings form { display: flex; flex-direction: column; gap: 12px; } +.ca-autocomplete-settings .ca-switch { justify-content: flex-start; } +.ca-completions { position: fixed; z-index: 10000; min-inline-size: min(240px, calc(100dvw - 16px)); max-inline-size: min(420px, calc(100dvw - 16px)); max-block-size: min(240px, calc(100dvh - 16px)); overflow: auto; border: 1px solid var(--ca-border); border-radius: 6px; box-shadow: 0 6px 20px #0005; box-sizing: border-box; } +.ca-completions[hidden] { display: none; } +.ca-completions .kikotools-autocomplete-item { display: flex; align-items: flex-start; gap: 8px; padding: 7px 10px; min-block-size: 32px; cursor: pointer; } +.ca-completion-type { flex-shrink: 0; font-size: 11px; color: var(--ca-focus); min-inline-size: 44px; } +.ca-completion-name { min-inline-size: 0; overflow-wrap: anywhere; } +.ca-completion-directory { color: var(--ca-muted); } +.ca-completions [aria-selected="true"] { background: var(--ca-accent); color: #fff; outline: 2px solid var(--ca-focus); outline-offset: -2px; } +.ca-completions [aria-selected="true"] :where(.ca-completion-type, .ca-completion-directory) { color: inherit; } +@media (pointer: coarse) { .ca-completions .kikotools-autocomplete-item { min-block-size: 40px; } } +.ca-autocomplete-settings { inline-size: 520px; } +.ca-autocomplete-settings fieldset { min-inline-size: 0; margin: 0; padding: 12px; border: 1px solid var(--ca-line); border-radius: 6px; display: flex; flex-direction: column; gap: 10px; } +.ca-autocomplete-settings legend { padding-inline: 4px; font-size: 12px; font-weight: 550; } +.ca-autocomplete-settings .ca-field { display: flex; flex-direction: column; align-items: stretch; gap: 5px; } +.ca-autocomplete-settings input { min-inline-size: 0; max-inline-size: 100%; box-sizing: border-box; } +.ca-autocomplete-settings input[type="file"] { inline-size: 100%; } +.ca-autocomplete-settings .ca-dialog-hint { margin: 0; } +.ca-autocomplete-settings a { color: var(--ca-focus); font-size: 12px; } +.ca-autocomplete-settings summary { cursor: pointer; font-size: 12px; padding-block: 6px; } +.ca-autocomplete-settings details[open] { display: flex; flex-direction: column; gap: 12px; } +.ca-autocomplete-settings .ca-tag-status { margin: 0; min-block-size: 18px; font-size: 12px; overflow-wrap: anywhere; color: var(--ca-muted); } +.ca-autocomplete-settings .ca-tag-status[data-error="true"] { color: var(--ca-error-text); } + +.ca-autocomplete-settings .ca-tag-source { overflow-wrap: anywhere; } diff --git a/web/ui/dialog.js b/web/ui/dialog.js index 7270827..00800bc 100644 --- a/web/ui/dialog.js +++ b/web/ui/dialog.js @@ -42,6 +42,7 @@ export function createDialog({ title, settings, fallbackFocus, onCancel }) { }); scope.listen(element, 'keydown', event => event.stopPropagation()); scope.listen(element, 'keyup', event => event.stopPropagation()); + for (const event of ['pointerdown', 'mousedown', 'click']) scope.listen(element, event, event => event.stopPropagation()); return { element, scope, body, actions, close: element.close, diff --git a/web/ui/text-widget-adapter.js b/web/ui/text-widget-adapter.js new file mode 100644 index 0000000..23278b2 --- /dev/null +++ b/web/ui/text-widget-adapter.js @@ -0,0 +1,33 @@ +export function watchRendererTextareas(scope, graph, autocomplete) { + const bridges = new Map(); + const detachMissing = () => { + for (const [textarea, bridge] of bridges) { + if (textarea.isConnected) continue; + autocomplete.detachFromWidget(bridge); + bridges.delete(textarea); + } + }; + scope.listen(document, 'focusin', event => { + const textarea = event.target; + if (!(textarea instanceof HTMLTextAreaElement) || textarea.readOnly || textarea.disabled) return; + const owner = textarea.closest('[node-id]'); + const node = owner && graph.getNodeById(owner.getAttribute('node-id')); + if (!node) return; + const label = textarea.labels?.[0]?.textContent.trim(); + const widget = node.widgets?.find(widget => (widget.label || widget.name) === label && widget.inputEl?.tagName === 'TEXTAREA'); + if (!widget || widget.inputEl === textarea || widget.inputEl.readOnly) return; + let bridge = bridges.get(textarea); + if (!bridge) { + bridge = { inputEl: textarea }; + bridges.set(textarea, bridge); + } + autocomplete.attachToWidget(bridge, node); + }); + const observer = new MutationObserver(detachMissing); + observer.observe(document.body, { childList: true, subtree: true }); + scope.cleanup(() => { + observer.disconnect(); + for (const bridge of bridges.values()) autocomplete.detachFromWidget(bridge); + bridges.clear(); + }); +} diff --git a/web/views/autocomplete-settings.js b/web/views/autocomplete-settings.js new file mode 100644 index 0000000..d5a9d0c --- /dev/null +++ b/web/views/autocomplete-settings.js @@ -0,0 +1,185 @@ +import { createButton, createField, createScope, createSwitch, mountTheme } from '../ui/core.js'; +import { createDialog } from '../ui/dialog.js'; +import { MAX_TAG_BYTES } from '../autocomplete/tags.js'; +import { parseTagFile } from '../autocomplete/tag-storage.js'; + +export function openAutocompleteSettings(autocomplete, settings, fallbackFocus) { + const existing = autocomplete.settingsDialog; + if (existing && !existing.scope.signal.aborted) { + existing.element.querySelector('input').focus(); + return existing; + } + autocomplete.hideSuggestions(); + const dialog = createDialog({ title: 'Embedding Autocomplete Settings', settings, fallbackFocus }); + autocomplete.settingsDialog = dialog; + dialog.element.classList.add('ca-autocomplete-settings'); + const form = document.createElement('form'); + form.id = `${dialog.element.id}-form`; + const fields = new Map(); + const group = (title, parent = form) => { + const fieldset = document.createElement('fieldset'); + const legend = document.createElement('legend'); + legend.textContent = title; + fieldset.append(legend); + parent.append(fieldset); + return fieldset; + }; + const addSwitch = (parent, key, label) => { + const field = createSwitch(label, '', `${form.id}-${key}`); + const input = field.querySelector('input'); + input.checked = autocomplete.settings[key] !== false; + fields.set(key, input); + parent.append(field); + }; + const addInput = (parent, key, label, type = 'text', limits = {}) => { + const input = document.createElement('input'); + Object.assign(input, { type, value: autocomplete.settings[key] ?? '', ...limits }); + fields.set(key, input); + parent.append(createField(label, input, `${form.id}-${key}`)); + return input; + }; + const sources = group('Sources'); + for (const [key, label] of [['enabled', 'Enable Autocomplete'], ['showEmbeddings', 'Show Embeddings'], ['showLoras', 'Show LoRAs'], ['showCustomWords', 'Show Custom Words']]) addSwitch(sources, key, label); + const tags = group('Tags and custom words'); + const count = document.createElement('p'); + count.className = 'ca-dialog-hint'; + count.textContent = `${autocomplete.customWords.length.toLocaleString()} tags loaded`; + const hint = document.createElement('p'); + hint.className = 'ca-dialog-hint'; + hint.textContent = 'Import CSV or TXT, or load a URL. Lists replace the current tags when you save. Supports tag,frequency and tag,category,post_count,aliases. Maximum 32 MB.'; + const pack = document.createElement('a'); + pack.href = 'https://github.com/ComfyAssets/ComfyUI-KikoTools/releases/tag/tags-2026-10-16'; + pack.target = '_blank'; + pack.rel = 'noopener noreferrer'; + pack.textContent = 'Download the Danbooru tag pack'; + const source = document.createElement('p'); + source.className = 'ca-dialog-hint ca-tag-source'; + source.textContent = `Current source: ${(autocomplete.settings.tagSource || autocomplete.settings.customWordsUrl || 'None').replace(/^file:/, '')}`; + tags.append(count, source, hint, pack); + const url = addInput(tags, 'customWordsUrl', 'Tag list URL', 'url'); + const load = createButton('Load URL'); + const file = document.createElement('input'); + file.type = 'file'; + file.accept = '.csv,.txt,text/csv,text/plain'; + tags.append(load, createField('Tag list file', file, `${form.id}-file`)); + const advanced = document.createElement('details'); + const summary = document.createElement('summary'); + summary.textContent = 'Triggers, matching and insertion'; + advanced.append(summary); + form.append(advanced); + const matching = group('Triggers and matching', advanced); + addInput(matching, 'embeddingTrigger', 'Embedding trigger'); + addInput(matching, 'loraTrigger', 'LoRA trigger'); + addInput(matching, 'quickTrigger', 'Quick embedding trigger'); + addInput(matching, 'minChars', 'Minimum characters', 'number', { min: 1, max: 10, step: 1, required: true }); + addInput(matching, 'maxSuggestions', 'Maximum suggestions', 'number', { min: 1, max: 100, step: 1, required: true }); + addSwitch(matching, 'sortByDirectory', 'Group by directory'); + const insertion = group('Insertion', advanced); + for (const [key, label] of [['autoInsertComma', 'Auto-insert comma'], ['replaceUnderscores', 'Replace underscores with spaces'], ['insertOnTab', 'Insert on Tab'], ['insertOnEnter', 'Insert on Enter']]) addSwitch(insertion, key, label); + const status = document.createElement('p'); + status.role = 'status'; + status.className = 'ca-tag-status'; + status.textContent = autocomplete.customWordsError || ''; + tags.append(status); + dialog.body.append(form); + const cancel = createButton('Cancel'); + const save = createButton('Save Settings', 'ca-primary'); + save.type = 'submit'; + save.setAttribute('form', form.id); + dialog.actions.append(cancel, save); + let pending, busy = false; + const prepare = async callback => { + busy = true; + load.disabled = file.disabled = save.disabled = true; + status.dataset.error = 'false'; + status.textContent = 'Loading tags…'; + try { + const result = await callback(); + if (dialog.scope.signal.aborted) return; + pending = result; + status.textContent = `${pending.tags.length.toLocaleString()} tags ready. Save Settings to apply.`; + } catch (error) { + if (dialog.scope.signal.aborted) return; + status.dataset.error = 'true'; + status.textContent = `Could not load tags: ${error.message}`; + } finally { + busy = false; + load.disabled = file.disabled = save.disabled = false; + } + }; + dialog.scope.listen(load, 'click', () => { + if (!url.value.trim() || !url.reportValidity()) { + status.dataset.error = 'true'; + status.textContent = 'Enter a valid tag-list URL.'; + return; + } + prepare(() => autocomplete.fetchTagList(url.value.trim(), dialog.scope.signal)); + }); + dialog.scope.listen(file, 'change', () => { + const selected = file.files[0]; + if (!selected) return; + prepare(async () => { + if (selected.size > MAX_TAG_BYTES) throw new Error('Tag file too large. Maximum size is 32 MB.'); + const text = await selected.text(); + const tags = await parseTagFile(text, dialog.scope.signal); + return { text, tags, source: `file:${selected.name}` }; + }); + file.value = ''; + }); + dialog.scope.listen(cancel, 'click', () => dialog.close('cancel')); + dialog.scope.listen(form, 'submit', async event => { + event.preventDefault(); + if (busy) return; + const next = {}; + for (const [key, input] of fields) next[key] = input.type === 'checkbox' ? input.checked : input.type === 'number' ? Number(input.value) : input.value.trim(); + next.embeddingTrigger ||= 'embedding:'; + next.loraTrigger ||= ' { if (autocomplete.settingsDialog === dialog) autocomplete.settingsDialog = null; }); + dialog.open(fields.get('enabled')); + return dialog; +} + +export function createAutocompleteLauncher(autocomplete, settings, fallbackFocus) { + if (!customElements.get('kiko-autocomplete-launcher')) { + customElements.define('kiko-autocomplete-launcher', class extends HTMLElement { + connectedCallback() { + if (this.scope && !this.scope.signal.aborted) return; + this.scope = createScope(); + this.className = 'ca-tools ca-autocomplete-launcher'; + mountTheme(this, this.settings, this.scope); + const button = createButton('Open Settings', 'ca-primary'); + this.replaceChildren(button); + this.scope.listen(button, 'click', () => { + this.dialog = openAutocompleteSettings(this.autocomplete, this.settings, this.fallbackFocus); + this.dialog.owner = this; + }); + this.scope.cleanup(() => { if (this.dialog?.owner === this) this.dialog.close('removed'); }); + } + disconnectedCallback() { + const scope = this.scope; + queueMicrotask(() => { if (!this.isConnected) scope.dispose(); }); + } + }); + } + const row = document.createElement('tr'); + row.className = 'kikotools-settings-row'; + const label = document.createElement('td'); + label.textContent = 'Embedding Autocomplete Configuration'; + const value = document.createElement('td'); + const launcher = document.createElement('kiko-autocomplete-launcher'); + Object.assign(launcher, { autocomplete, settings, fallbackFocus }); + value.append(launcher); + row.append(label, value); + return row; +}