diff --git a/README.md b/README.md index 3677716..86a7461 100644 --- a/README.md +++ b/README.md @@ -43,6 +43,7 @@ When you type in a text input area, tags that partially match the text are displ - Tags that have already been entered are displayed grayed out. - You can display Danbooru and e621 tags at the same time. You can also change the priority from the settings. - Supports autocomplete for Lora and Embedding inputs. You can enable/disable this feature in the settings. +- Clicking the 📖 icon opens the tag's Wiki page. If a tag is selected via keyboard, you can open it with the `F1` key. ## Related Tags @@ -53,6 +54,7 @@ When you select any tag in a text input area, a list of highly related tags is d - The display position is primarily at the bottom of the text area and automatically adjusts vertically based on available space. - You can switch between vertical and horizontal display positions using the "↕️|↔️" button in the header. - You can toggle the pinned state of the displayed related tags using the "📌|🎯" button in the header. To close the UI when pinned, press the Esc key. +- Clicking the tag in the header opens the tag's Wiki page. - Tags that have already been entered are displayed grayed out. If you try to insert a grayed-out tag, the already entered tag will instead be selected. - You can display related tags for the cursor position by pressing `Ctrl+Shift+Space`. @@ -187,6 +189,7 @@ For example, by preparing the following CSV, you can quickly insert correspondin - **Auto Format Trigger**: Choose when formatting is applied. - **Auto**: Format automatically when leaving text field - **Manual**: Format only via keyboard shortcut (default: `Alt+Shift+F`) +- **Use Trailing Comma**: If enabled, ensures all lines end with a trailing comma when formatting. If disabled, removes trailing commas. ## Advanced Settings diff --git a/docs/README_jp.md b/docs/README_jp.md index 33e4c58..02efd46 100644 --- a/docs/README_jp.md +++ b/docs/README_jp.md @@ -41,6 +41,7 @@ - 入力済みのタグはグレーアウトで表示されます - Danbooruとe621のタグを同時に表示出来ます。設定から優先順位を変更できます - LoraとEmbeddingの入力補完に対応しています。設定から有効・無効を切り替えられます +- 「📖」アイコンをクリックするとタグのWikiページを開きます。キーボードで選択中の場合は `F1` キーで開くことが出来ます ## 関連タグ @@ -51,6 +52,7 @@ - 表示位置は、テキストエリアの下部を基本とし、空きスペースに応じて上下に自動調整されます - ヘッダーの「↕️|↔️」ボタンで上下と左右の表示位置に切り替えられます - ヘッダーの「📌|🎯」ボタンで表示する関連タグの固定状態を切り替えられます。固定状態で閉じたい場合はEscキーを押します +- ヘッダーのタグをクリックするとタグのWikiページを開きます - 入力済みのタグはグレーアウトで表示されます。グレーアウトしたタグを挿入しようとした場合、代わりに入力済みのタグを選択状態にします - `Ctrl+Shift+Space` キーでカーソル位置の関連タグを表示できます @@ -185,6 +187,7 @@ worst_quality,5,9999999, - **Auto Format Trigger**: フォーマットを適用するタイミングを選択します - **自動**: テキスト欄からフォーカスが外れた際に自動でフォーマットします - **手動**: キーボードショートカットでのみフォーマットします(デフォルト: `Alt+Shift+F`) +- **行末にカンマを使用**: 有効にすると、フォーマット時にすべての行末がカンマで終わるようになります。\n無効にすると行末のカンマが削除されます ## 上級者向け設定 diff --git a/locales/en/settings.json b/locales/en/settings.json index 49597ba..f7ea594 100644 --- a/locales/en/settings.json +++ b/locales/en/settings.json @@ -79,6 +79,10 @@ "manual": "Manual" } }, + "AutocompletePlus_AutoFormatter_UseTrailingComma": { + "name": "Use Trailing Comma", + "tooltip": "When enabled, ensures all lines end with a trailing comma.\nWhen disabled, removes trailing commas." + }, "AutocompletePlus_AutoFormatter_EnableAutoFormat": { "name": "Enable Auto Format" } diff --git a/locales/ja/settings.json b/locales/ja/settings.json index d853d1e..2739ede 100644 --- a/locales/ja/settings.json +++ b/locales/ja/settings.json @@ -79,6 +79,10 @@ "manual": "手動" } }, + "AutocompletePlus_AutoFormatter_UseTrailingComma": { + "name": "行末にカンマを使用", + "tooltip": "有効にすると、すべての行末がカンマで終わるようになります。\n無効にすると行末のカンマが削除されます。" + }, "AutocompletePlus_AutoFormatter_EnableAutoFormat": { "name": "自動フォーマット機能の有効化" } diff --git a/locales/zh-TW/settings.json b/locales/zh-TW/settings.json index 5f18774..b0d930c 100644 --- a/locales/zh-TW/settings.json +++ b/locales/zh-TW/settings.json @@ -79,6 +79,10 @@ "manual": "手動" } }, + "AutocompletePlus_AutoFormatter_UseTrailingComma": { + "name": "在行末添加逗號", + "tooltip": "啟用時,確保所有行都以逗號結尾。\n停用時,移除結尾逗號。" + }, "AutocompletePlus_AutoFormatter_EnableAutoFormat": { "name": "啟用自動格式化" } diff --git a/locales/zh/settings.json b/locales/zh/settings.json index e9b9b58..7cb56b7 100644 --- a/locales/zh/settings.json +++ b/locales/zh/settings.json @@ -79,6 +79,10 @@ "manual": "手动" } }, + "AutocompletePlus_AutoFormatter_UseTrailingComma": { + "name": "在行末添加逗号", + "tooltip": "启用时,确保所有行都以逗号结尾。\n禁用时,移除结尾逗号。" + }, "AutocompletePlus_AutoFormatter_EnableAutoFormat": { "name": "启用自动格式化" } diff --git a/tests/js/auto-formatter.test.js b/tests/js/auto-formatter.test.js index a4beff4..02cda10 100644 --- a/tests/js/auto-formatter.test.js +++ b/tests/js/auto-formatter.test.js @@ -2,6 +2,7 @@ import { formatPromptText, __test__ } from "../../web/js/auto-formatter.js"; +import { settingValues } from "../../web/js/settings.js"; const { shouldAutoFormat @@ -44,37 +45,102 @@ describe('AutoFormatter Functions', () => { }); describe('formatPromptText', () => { - test('should format text by adding comma and space after tags', () => { - const input = 'tag1,tag2,tag3'; - const expected = 'tag1, tag2, tag3, '; - expect(formatPromptText(input)).toBe(expected); - }); + // Store original setting value to restore after tests + const originalUseTrailingComma = settingValues.useTrailingComma; - test('should remove extra spaces around tags', () => { - const input = ' tag1 , tag2 '; - const expected = 'tag1, tag2, '; - expect(formatPromptText(input)).toBe(expected); + afterEach(() => { + // Restore original setting after each test + settingValues.useTrailingComma = originalUseTrailingComma; }); - test('should preserve special syntax like weights', () => { - const input = '(tag1:1.2), [tag2]'; - // Note: The current implementation splits by comma. - // If the input is "(tag1:1.2), [tag2]", it splits into "(tag1:1.2)" and "[tag2]". - // Then joins with ", ". - const expected = '(tag1:1.2), [tag2], '; - expect(formatPromptText(input)).toBe(expected); - }); + describe('with useTrailingComma enabled', () => { + beforeEach(() => { + settingValues.useTrailingComma = true; + }); + + test('should format text by adding comma and space after tags', () => { + const input = 'tag1,tag2,tag3'; + const expected = 'tag1, tag2, tag3, '; + expect(formatPromptText(input)).toBe(expected); + }); + + test('should remove extra spaces around tags', () => { + const input = ' tag1 , tag2 '; + const expected = 'tag1, tag2, '; + expect(formatPromptText(input)).toBe(expected); + }); + + test('should preserve special syntax like weights', () => { + const input = '(tag1:1.2), [tag2]'; + // Note: The current implementation splits by comma. + // If the input is "(tag1:1.2), [tag2]", it splits into "(tag1:1.2)" and "[tag2]". + // Then joins with ", ". + const expected = '(tag1:1.2), [tag2], '; + expect(formatPromptText(input)).toBe(expected); + }); + + test('should handle multiple lines', () => { + const input = 'tag1, tag2\ntag3, tag4'; + const expected = 'tag1, tag2, \ntag3, tag4, '; + expect(formatPromptText(input)).toBe(expected); + }); + + test('should keep empty lines unchanged', () => { + const input = 'tag1, tag2\n\ntag3, tag4'; + const expected = 'tag1, tag2, \n\ntag3, tag4, '; + expect(formatPromptText(input)).toBe(expected); + }); - test('should handle multiple lines', () => { - const input = 'tag1, tag2\ntag3, tag4'; - const expected = 'tag1, tag2, \ntag3, tag4, '; - expect(formatPromptText(input)).toBe(expected); + test('should not modify text that already has trailing comma', () => { + const input = 'tag1, tag2, '; + const expected = 'tag1, tag2, '; + expect(formatPromptText(input)).toBe(expected); + }); }); - test('should keep empty lines unchanged', () => { - const input = 'tag1, tag2\n\ntag3, tag4'; - const expected = 'tag1, tag2, \n\ntag3, tag4, '; - expect(formatPromptText(input)).toBe(expected); + describe('with useTrailingComma disabled', () => { + beforeEach(() => { + settingValues.useTrailingComma = false; + }); + + test('should format text by adding comma and space after tags without trailing comma', () => { + const input = 'tag1,tag2,tag3'; + const expected = 'tag1, tag2, tag3'; + expect(formatPromptText(input)).toBe(expected); + }); + + test('should remove extra spaces around tags without trailing comma', () => { + const input = ' tag1 , tag2 '; + const expected = 'tag1, tag2'; + expect(formatPromptText(input)).toBe(expected); + }); + + test('should preserve special syntax like weights without trailing comma', () => { + const input = '(tag1:1.2), [tag2]'; + // Note: The current implementation splits by comma. + // If the input is "(tag1:1.2), [tag2]", it splits into "(tag1:1.2)" and "[tag2]". + // Then joins with ", ". + const expected = '(tag1:1.2), [tag2]'; + expect(formatPromptText(input)).toBe(expected); + }); + + test('should handle multiple lines without trailing comma', () => { + const input = 'tag1, tag2\ntag3, tag4'; + const expected = 'tag1, tag2\ntag3, tag4'; + expect(formatPromptText(input)).toBe(expected); + }); + + test('should keep empty lines unchanged without trailing comma', () => { + const input = 'tag1, tag2\n\ntag3, tag4'; + const expected = 'tag1, tag2\n\ntag3, tag4'; + expect(formatPromptText(input)).toBe(expected); + }); + + test('should remove existing trailing comma when disabled', () => { + const input = 'tag1, tag2, '; + const expected = 'tag1, tag2'; + expect(formatPromptText(input)).toBe(expected); + }); }); test('should handle empty input', () => { diff --git a/web/css/autocomplete-plus.css b/web/css/autocomplete-plus.css index f0536a7..dea32a3 100644 --- a/web/css/autocomplete-plus.css +++ b/web/css/autocomplete-plus.css @@ -1,15 +1,21 @@ /* Light Theme */ body { - --autocomplete-plus-text-color-cat-blue: var(--p-blue-600); + --autocomplete-plus-text-color-cat-blue: var(--p-blue-700); --autocomplete-plus-text-color-cat-red: var(--p-red-600); --autocomplete-plus-text-color-cat-purple: var(--p-purple-600); - --autocomplete-plus-text-color-cat-green: var(--p-green-600); + --autocomplete-plus-text-color-cat-green: var(--p-green-700); --autocomplete-plus-text-color-cat-yellow: var(--p-yellow-600); --autocomplete-plus-text-color-cat-gray: var(--p-gray-600); - --autocomplete-plus-text-color-cat-sky: var(--p-sky-500); + --autocomplete-plus-text-color-cat-sky: var(--p-sky-600); --autocomplete-plus-text-color-cat-orange: var(--p-orange-600); --autocomplete-plus-text-color-cat-white: var(--p-neutral-700); --autocomplete-plus-text-color-disabled: var(--p-gray-400); + --autocomplete-plus-list-color-even: var(--p-neutral-300); + --autocomplete-plus-list-color-odd: var(--p-neutral-400); + --wiki-icon-text-color: var(--input-text); + --wiki-icon-opacity-normal: 0.7; + --wiki-icon-opacity-hover: 1.0; + --wiki-icon-opacity-disabled: 0; } /* Dark Theme */ @@ -24,6 +30,12 @@ body.dark-theme { --autocomplete-plus-text-color-cat-orange: var(--p-orange-400); --autocomplete-plus-text-color-cat-white: var(--p-neutral-200); --autocomplete-plus-text-color-disabled: var(--p-gray-500); + --autocomplete-plus-list-color-even: var(--p-neutral-800); + --autocomplete-plus-list-color-odd: var(--p-neutral-900); + --wiki-icon-text-color: var(--input-text); + --wiki-icon-opacity-normal: 0.5; + --wiki-icon-opacity-hover: 0.9; + --wiki-icon-opacity-disabled: 0; } #autocomplete-plus-root { @@ -97,16 +109,17 @@ body.dark-theme { display: grid; box-shadow: 0 2px 8px rgb(0 0 0 / 30%); grid-auto-rows: auto; - grid-template-columns: max-content 1fr auto; + grid-template-columns: max-content max-content 1fr auto; overflow-y: auto; } #autocomplete-plus-list.no-alias { - grid-template-columns: max-content auto; + grid-template-columns: max-content max-content auto; } .autocomplete-plus-item { display: grid; + border-bottom: 1px solid var(--border-color); cursor: pointer; grid-column: 1 / -1; grid-template-columns: subgrid; @@ -115,23 +128,22 @@ body.dark-theme { .autocomplete-plus-item span { align-content: center; padding: 4px 8px; - border-bottom: 1px solid var(--border-color); } /* Alternating row colors */ -.autocomplete-plus-item:nth-child(even) span { - background-color: var(--comfy-menu-bg); +.autocomplete-plus-item:nth-child(even) { + background-color: var(--autocomplete-plus-list-color-even); } -.autocomplete-plus-item:nth-child(odd) span { - background-color: var(--comfy-menu-secondary-bg); +.autocomplete-plus-item:nth-child(odd) { + background-color: var(--autocomplete-plus-list-color-odd); } -.autocomplete-plus-item.selected span { +.autocomplete-plus-item.selected { background-color: var(--border-color); } -.autocomplete-plus-item:hover span { +.autocomplete-plus-item:hover { background-color: var(--comfy-hover-bg); } @@ -160,6 +172,28 @@ body.dark-theme { text-align: right; } +.autocomplete-plus-item .autocomplete-plus-wiki-icon { + padding: 0 8px; + color: var(--wiki-icon-text-color); + cursor: pointer; + font-size: 1.2em; + opacity: var(--wiki-icon-opacity-normal); + text-align: center; + vertical-align: middle; +} + +.autocomplete-plus-item .autocomplete-plus-wiki-icon:hover { + filter: brightness(1.1); + opacity: var(--wiki-icon-opacity-hover); +} + +/* Hide wiki icon for tags without wiki pages */ +.autocomplete-plus-item .autocomplete-plus-wiki-icon.disabled { + cursor: default; + opacity: var(--wiki-icon-opacity-disabled); + pointer-events: none; +} + /* Related Tags UI Styles */ #related-tags-root { position: absolute; @@ -236,7 +270,7 @@ body.dark-theme { align-items: center; padding: 8px; border-bottom: 1px solid var(--border-color); - background-color: var(--comfy-menu-bg); + background-color: var(--autocomplete-plus-list-color-even); color: var(--descrip-text); gap: 8px; } @@ -255,10 +289,16 @@ body.dark-theme { } .related-tags-header-tag-name { + cursor: pointer; font-weight: bold; word-break: break-all; } +.related-tags-header-tag-name.disabled { + cursor: default; + text-decoration: none; +} + .related-tags-header-tag-name .autocomplete-plus-tag-icon-svg { width: 1em; height: 1em; @@ -296,16 +336,17 @@ body.dark-theme { display: grid; box-shadow: 0 2px 8px rgb(0 0 0 / 30%); grid-auto-rows: auto; - grid-template-columns: max-content 1fr auto; + grid-template-columns: max-content max-content 1fr auto; overflow-y: auto; } #related-tags-list.no-alias { - grid-template-columns: max-content auto; + grid-template-columns: max-content max-content auto; } .related-tag-item { display: grid; + border-bottom: 1px solid var(--border-color); cursor: pointer; grid-column: 1 / -1; grid-template-columns: subgrid; @@ -315,22 +356,21 @@ body.dark-theme { .related-tag-item span { align-content: center; padding: 4px 8px; - border-bottom: 1px solid var(--border-color); } -.related-tag-item:nth-child(even) span { - background-color: var(--comfy-menu-bg); +.related-tag-item:nth-child(even) { + background-color: var(--autocomplete-plus-list-color-even); } -.related-tag-item:nth-child(odd) span { - background-color: var(--comfy-menu-secondary-bg); +.related-tag-item:nth-child(odd) { + background-color: var(--autocomplete-plus-list-color-odd); } -.related-tag-item:hover span { +.related-tag-item:hover { background-color: var(--p-form-field-filled-hover-background); } -.related-tag-item.selected span { +.related-tag-item.selected { background-color: var(--border-color); } @@ -355,6 +395,29 @@ body.dark-theme { white-space: nowrap; } +.related-tag-item .related-tag-wiki-icon { + padding: 0 8px; + color: var(--wiki-icon-text-color); + cursor: pointer; + font-size: 1.2em; + opacity: var(--wiki-icon-opacity-normal); + text-align: center; + transition: opacity 0.1s ease; + vertical-align: middle; +} + +.related-tag-item .related-tag-wiki-icon:hover { + filter: brightness(1.1); + opacity: var(--wiki-icon-opacity-hover); +} + +/* Hide wiki icon for tags without wiki pages */ +.related-tag-item .related-tag-wiki-icon.disabled { + cursor: default; + opacity: var(--wiki-icon-opacity-disabled); + pointer-events: none; +} + .related-tags-message { width: 100%; padding: 12px; diff --git a/web/js/auto-formatter.js b/web/js/auto-formatter.js index a47fc01..44dca74 100644 --- a/web/js/auto-formatter.js +++ b/web/js/auto-formatter.js @@ -100,8 +100,8 @@ export function formatPromptText(text) { // Rejoin cleaned tags with ", " let formattedLine = cleanedTags.join(', '); - // Add a trailing comma and space if the line contains valid tags - if (formattedLine.length > 0) { + // Add a trailing comma and space if the setting is enabled and line contains valid tags + if (formattedLine.length > 0 && settingValues.useTrailingComma) { formattedLine += ', '; } diff --git a/web/js/autocomplete.js b/web/js/autocomplete.js index b1ab86b..5c86a20 100644 --- a/web/js/autocomplete.js +++ b/web/js/autocomplete.js @@ -1,6 +1,5 @@ import { ModelTagSource, - TagCategory, TagData, autoCompleteData, getEnabledTagSourceInPriorityOrder @@ -19,7 +18,8 @@ import { getViewportMargin, getScrollbarWidth, IconSvgHtmlString, - addWeightToLora + addWeightToLora, + openTagWikiUrl } from './utils.js'; import { settingValues } from './settings.js'; @@ -263,7 +263,7 @@ function getCurrentPartialTag(inputElement) { if (!inputElement) { return ""; } - + const text = inputElement.value; const cursorPos = inputElement.selectionStart; @@ -312,13 +312,13 @@ function insertTagToTextArea(inputElement, tagDataToInsert) { if (!inputElement || !tagDataToInsert) { return; } - + const text = inputElement.value; const cursorPos = inputElement.selectionStart; const tagRange = getCurrentTagRange(text, cursorPos); let tagStart, tagEnd, currentTag; - + if (!tagRange) { // Fallback: insert at cursor position tagStart = cursorPos; @@ -327,7 +327,7 @@ function insertTagToTextArea(inputElement, tagDataToInsert) { } else { ({ start: tagStart, end: tagEnd, tag: currentTag } = tagRange); } - + const replaceStart = Math.min(cursorPos, tagStart); let replaceEnd = cursorPos; @@ -407,6 +407,16 @@ class AutocompleteUI { // Add event listener for clicks on items this.tagsList.addEventListener('mousedown', (e) => { + // Check if wiki icon was clicked first + const wikiIcon = e.target.closest('.autocomplete-plus-wiki-icon'); + if (wikiIcon && !wikiIcon.classList.contains('disabled')) { + openTagWikiUrl(wikiIcon.dataset.tagSource, wikiIcon.dataset.tagName); + e.preventDefault(); + e.stopPropagation(); + return; + } + + // Check if row was clicked (existing behavior) const row = e.target.closest('.autocomplete-plus-item'); if (row && row.dataset.index !== undefined) { const tagData = this.candidates[parseInt(row.dataset.index, 10)]; @@ -480,7 +490,7 @@ class AutocompleteUI { /** Selects the currently highlighted item * @returns {TagData|null} The selected tag data. */ - getSelectedTag() { + getSelectedTagData() { if (this.selectedIndex >= 0 && this.selectedIndex < this.candidates.length) { return this.candidates[this.selectedIndex]; } @@ -517,7 +527,7 @@ class AutocompleteUI { * @param {boolean} isExisting */ #createTagElement(tagData, tagDataIndex, isExisting) { - const categoryText = TagCategory[tagData.source][tagData.category] || "unknown"; + const categoryText = tagData.categoryText; const aliasText = tagData.alias.join(', '); const tagRow = document.createElement('div'); @@ -543,6 +553,18 @@ class AutocompleteUI { tagName.classList.add('autocomplete-plus-already-exists'); } + // Wiki icon + const wikiIcon = document.createElement('span'); + wikiIcon.className = 'autocomplete-plus-wiki-icon'; + if (tagData.hasWikiPage) { + wikiIcon.dataset.tagName = tagData.tag; + wikiIcon.dataset.tagSource = tagData.source; + wikiIcon.textContent = '📖' + wikiIcon.title = 'Open wiki page'; + } else { + wikiIcon.classList.add('disabled'); + } + // Alias const alias = document.createElement('span'); alias.className = 'autocomplete-plus-alias'; @@ -558,7 +580,7 @@ class AutocompleteUI { tagCount.className = `autocomplete-plus-tag-count`; tagCount.textContent = formatCountHumanReadable(tagData.count); - // Create tooltip with more info + // Create tooltip with more info let tooltipText = `Count: ${tagData.count}\nCategory: ${categoryText}`; if (aliasText.length > 0) { tooltipText += `\nAlias: ${aliasText}`; @@ -566,6 +588,7 @@ class AutocompleteUI { tagRow.title = tooltipText; tagRow.appendChild(tagName); + tagRow.appendChild(wikiIcon); if (!settingValues.hideAlias) { tagRow.appendChild(alias); @@ -672,7 +695,7 @@ class AutocompleteUI { /** Highlights the item (row) at the given index */ #highlightItem() { - if (!this.getSelectedTag()) return; // No valid selection + if (!this.getSelectedTagData()) return; // No valid selection const items = this.tagsList.children; // Get rows from tbody for (let i = 0; i < items.length; i++) { @@ -973,12 +996,19 @@ export class AutocompleteEventHandler { case 'Enter': case 'Tab': const modifierKeyPressed = event.shiftKey || event.ctrlKey || event.altKey || event.metaKey; - if (!modifierKeyPressed && this.autocompleteUI.getSelectedTag() !== null) { + if (!modifierKeyPressed && this.autocompleteUI.getSelectedTagData() !== null) { event.preventDefault(); - insertTagToTextArea(event.target, this.autocompleteUI.getSelectedTag()); + insertTagToTextArea(event.target, this.autocompleteUI.getSelectedTagData()); } this.autocompleteUI.hide(); break; + case 'F1': + event.preventDefault(); + const tagData = this.autocompleteUI.getSelectedTagData(); + if (tagData && tagData.hasWikiPage) { + openTagWikiUrl(tagData.source, tagData.tag); + } + break; case 'Escape': event.preventDefault(); this.autocompleteUI.hide(); diff --git a/web/js/data.js b/web/js/data.js index 8be165b..90d9688 100644 --- a/web/js/data.js +++ b/web/js/data.js @@ -72,6 +72,23 @@ export class TagData { this.source = source; } + + /** + * Get the category text + * @returns {string} + */ + get categoryText() { + return TagCategory[this.source][this.category] || "unknown"; + } + + /** + * Check if the tag has a wiki page + * @returns {boolean} + */ + get hasWikiPage() { + return Object.values(TagSource).includes(this.source) + && ['general', 'artist', 'copyright', 'character', 'species', 'lore'].includes(this.categoryText); + } } class AutocompleteData { diff --git a/web/js/main.js b/web/js/main.js index ad444a6..3d49c86 100644 --- a/web/js/main.js +++ b/web/js/main.js @@ -490,6 +490,17 @@ app.registerExtension({ }, // --- Auto format settings --- + { + id: id + '.AutoFormatter.UseTrailingComma', + name: 'Use Trailing Comma', + tooltip: 'When enabled, ensures all lines end with a trailing comma.\nWhen disabled, removes trailing commas.', + type: 'boolean', + defaultValue: false, + category: [name, 'AutoFormatter', 'Use Trailing Comma'], + onChange: (newVal, oldVal) => { + settingValues.useTrailingComma = newVal; + }, + }, { id: id + '.AutoFormatter.Trigger', name: 'Auto Format Trigger', diff --git a/web/js/related-tags.js b/web/js/related-tags.js index 4a00418..2ca7edb 100644 --- a/web/js/related-tags.js +++ b/web/js/related-tags.js @@ -1,4 +1,4 @@ -import { TagCategory, TagData, TagSource, autoCompleteData } from './data.js'; +import { TagCategory, TagData, TagSource, autoCompleteData, getEnabledTagSourceInPriorityOrder } from './data.js'; import { settingValues } from './settings.js'; import { extractTagsFromTextArea, @@ -10,6 +10,7 @@ import { isValidTag, normalizeTagToInsert, normalizeTagToSearch, + openTagWikiUrl } from './utils.js'; // --- RelatedTags Logic --- @@ -95,6 +96,8 @@ function searchRelatedTags(tag) { category: tagData.category, source: tagData.source, count: tagData.count, + categoryText: tagData.categoryText, + hasWikiPage: tagData.hasWikiPage }); }); @@ -278,11 +281,31 @@ class RelatedTagsUI { // Timer ID for auto-refresh this.autoRefreshTimerId = null; + // Add click handler for wiki link in header tag name + this.headerText.addEventListener('mousedown', (e) => { + const tagNameEl = e.target.closest('.related-tags-header-tag-name'); + if (tagNameEl && !tagNameEl.classList.contains('disabled')) { + openTagWikiUrl(tagNameEl.dataset.tagSource, tagNameEl.dataset.tagName); + e.preventDefault(); + e.stopPropagation(); + return; + } + }); + // Add click handler for tag selection this.tagsContainer.addEventListener('mousedown', (e) => { + // Check if wiki icon was clicked first + const wikiIcon = e.target.closest('.related-tag-wiki-icon'); + if (wikiIcon && !wikiIcon.classList.contains('disabled')) { + openTagWikiUrl(wikiIcon.dataset.tagSource, wikiIcon.dataset.tagName); + e.preventDefault(); + e.stopPropagation(); + return; + } + const row = e.target.closest('.related-tag-item'); - if (row && row.dataset.tag) { - this.#insertTag(row.dataset.tag); + if (row && row.dataset.tagName) { + this.#insertTag(row.dataset.tagName); e.preventDefault(); e.stopPropagation(); } @@ -322,9 +345,6 @@ class RelatedTagsUI { this.target = textareaElement; this.relatedTags = searchRelatedTags(this.currentTag); - if (this.selectedIndex == -1) { - this.selectedIndex = 0; // Reset selection to the first item - } this.#updateHeader(); this.#updateContent(); @@ -366,10 +386,18 @@ class RelatedTagsUI { } } - /** Moves the selection up or down */ + /** Moves the selection up or down + * @param {direction} 1 for down, -1 for up + */ navigate(direction) { if (this.relatedTags.length === 0) return; - this.selectedIndex += direction; + + if (this.selectedIndex == -1) { + // Initialize selection based on navigation direction + this.selectedIndex = direction == 1 ? 0 : this.relatedTags.length - 1; + } else { + this.selectedIndex += direction; + } if (this.selectedIndex < 0) { this.selectedIndex = this.relatedTags.length - 1; // Wrap around to bottom @@ -379,10 +407,27 @@ class RelatedTagsUI { this.#highlightItem(); } - /** Selects the currently highlighted item */ - getSelectedTag() { + /** + * Get TagData of the current tag + * @returns {TagData|null} + */ + getCurrentTagData() { + for (const source of getEnabledTagSourceInPriorityOrder()) { + if (source in autoCompleteData && autoCompleteData[source].tagMap.has(this.currentTag)) { + return autoCompleteData[source].tagMap.get(this.currentTag); + } + } + + return null; + } + + /** + * Selects the currently highlighted item + * @return {TagData|null} + */ + getSelectedTagData() { if (this.selectedIndex >= 0 && this.selectedIndex < this.relatedTags.length) { - return this.relatedTags[this.selectedIndex].tag; + return this.relatedTags[this.selectedIndex]; } return null; // No valid selection @@ -401,14 +446,7 @@ class RelatedTagsUI { * Updates header content */ #updateHeader() { - // Find the tag data for the current tag - let tagData = Object.values(TagSource) - .map((source) => { - if (source in autoCompleteData && autoCompleteData[source].tagMap.has(this.currentTag)) { - return autoCompleteData[source].tagMap.get(this.currentTag); - } - }) - .find((tagData) => tagData !== undefined); + let tagData = this.getCurrentTagData(); if (!tagData) { // Create a dummy TagData if not found @@ -426,6 +464,8 @@ class RelatedTagsUI { tagName.classList.add('related-tags-header-tag-name', tagData.source); tagName.title = `Count: ${tagData.count}\nCategory: ${categoryText}\nAlias: ${aliasText}`; tagName.dataset.tagCategory = categoryText; + tagName.dataset.tagSource = tagData.source; + tagName.dataset.tagName = tagData.tag; if (tagData.source && ['left', 'right'].includes(settingValues.tagSourceIconPosition)) { const tagSourceIconHtml = ``; tagName.innerHTML = settingValues.tagSourceIconPosition == 'left' @@ -435,6 +475,9 @@ class RelatedTagsUI { tagName.textContent += tagData.tag; } + if (!tagData.hasWikiPage) { + tagName.classList.add('disabled'); + } this.headerText.appendChild(tagName); @@ -501,12 +544,12 @@ class RelatedTagsUI { * @returns {HTMLTableRowElement} The tag row element */ #createTagElement(tagData, isExisting) { - const categoryText = TagCategory[tagData.source][tagData.category] || "unknown"; + const categoryText = tagData.categoryText; const aliasText = tagData.alias.join(', '); const tagRow = document.createElement('div'); tagRow.classList.add('related-tag-item', tagData.source); - tagRow.dataset.tag = tagData.tag; + tagRow.dataset.tagName = tagData.tag; tagRow.dataset.tagCategory = categoryText; // Tag name @@ -519,6 +562,18 @@ class RelatedTagsUI { tagName.classList.add('related-tag-already-exists'); } + // Wiki icon + const wikiIcon = document.createElement('span'); + wikiIcon.className = 'related-tag-wiki-icon'; + if (tagData.hasWikiPage) { + wikiIcon.dataset.tagName = tagData.tag; + wikiIcon.dataset.tagSource = tagData.source; + wikiIcon.textContent = '📖' + wikiIcon.title = 'Open wiki page'; + } else { + wikiIcon.classList.add('disabled'); + } + // Alias const alias = document.createElement('span'); alias.className = 'related-tag-alias'; @@ -543,6 +598,7 @@ class RelatedTagsUI { // Add cells to row tagRow.appendChild(tagName); + tagRow.appendChild(wikiIcon); if (!settingValues.hideAlias) { tagRow.appendChild(alias); @@ -588,7 +644,7 @@ class RelatedTagsUI { /** Highlights the item (row) at the given index */ #highlightItem() { - if (this.getSelectedTag() === null) return; // No valid selection + if (this.getSelectedTagData() === null) return; // No valid selection const items = this.tagsContainer.children; // Get rows for (let i = 0; i < items.length; i++) { @@ -621,9 +677,9 @@ class RelatedTagsUI { } insertSelectedTag() { - const selectedTag = this.getSelectedTag(); + const selectedTag = this.getSelectedTagData(); if (selectedTag) { - this.#insertTag(selectedTag); + this.#insertTag(selectedTag.tag); } } @@ -769,13 +825,20 @@ export class RelatedTagsEventHandler { break; case 'Enter': case 'Tab': - if (this.relatedTagsUI.getSelectedTag() !== null) { + if (this.relatedTagsUI.getSelectedTagData() !== null) { event.preventDefault(); // Prevent Tab from changing focus this.relatedTagsUI.insertSelectedTag(); } else if (!this.relatedTagsUI.isPinned) { // If nothing selected and not pinned, hide the panel this.relatedTagsUI.hide(); } break; + case 'F1': + event.preventDefault(); + const tagData = this.relatedTagsUI.getSelectedTagData() || this.relatedTagsUI.getCurrentTagData(); + if (tagData && tagData.hasWikiPage) { + openTagWikiUrl(tagData.source, tagData.tag); + } + break; case 'Escape': event.preventDefault(); this.relatedTagsUI.hide(); diff --git a/web/js/settings.js b/web/js/settings.js index 0cbc7b3..d02830f 100644 --- a/web/js/settings.js +++ b/web/js/settings.js @@ -24,6 +24,7 @@ export const settingValues = { // Auto format settings enableAutoFormat: true, autoFormatTrigger: 'auto', // Options: 'auto' (format on blur + shortcut), 'manual' (shortcut only) + useTrailingComma: false, // Whether to add comma at the end of each line // Internal logic settings diff --git a/web/js/utils.js b/web/js/utils.js index ed59fc8..126918c 100644 --- a/web/js/utils.js +++ b/web/js/utils.js @@ -654,3 +654,47 @@ export function getScrollbarWidth() { return _cachedScrollbarWidth; } + +/** + * Opens a wiki URL in a new browser tab for the given tag. + * @param {string} tagSource - The source of the tag ('danbooru', 'e621', 'embeddings', 'lora') + * @param {string} tagName - The name of the tag + * @returns {boolean} - True if URL was opened, false if not applicable + */ +export function openTagWikiUrl(tagSource, tagName) { + if (!tagSource || !tagName) { + console.warn('[Autocomplete-Plus] Cannot open wiki: missing tagSource or tagName'); + return false; + } + + let wikiUrl = null; + + switch (tagSource) { + case 'danbooru': + const danbooruTag = encodeURIComponent(tagName.replace(/ /g, '_')); + wikiUrl = `https://danbooru.donmai.us/wiki_pages/${danbooruTag}`; + break; + + case 'e621': + const e621Tag = encodeURIComponent(tagName.replace(/ /g, '_')); + wikiUrl = `https://e621.net/wiki_pages/${e621Tag}`; + break; + + case 'embeddings': + case 'lora': + // console.debug('[Autocomplete-Plus] Model tags do not have wiki pages'); + return false; + + default: + console.warn(`[Autocomplete-Plus] Unknown tag source: ${tagSource}`); + return false; + } + + if (wikiUrl) { + // console.debug(`[Autocomplete-Plus] Opening wiki URL: ${wikiUrl}`); + window.open(wikiUrl, '_blank', 'noopener,noreferrer'); + return true; + } + + return false; +}