From 9b22c27b48936a3398c02ec02865aacb97876c07 Mon Sep 17 00:00:00 2001 From: newtextdoc1111 Date: Thu, 1 May 2025 00:03:14 +0900 Subject: [PATCH] Added basic function of autocomplete --- .gitignore | 4 + README.md | 3 + __init__.py | 8 + modules/api.py | 97 +++++++++ nodes.py | 152 ++++++++++++++ pyproject.toml | 15 ++ web/css/autocomplete.css | 49 +++++ web/js/autocomplete.js | 379 +++++++++++++++++++++++++++++++++ web/js/main.js | 443 +++++++++++++++++++++++++++++++++++++++ 9 files changed, 1150 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 __init__.py create mode 100644 modules/api.py create mode 100644 nodes.py create mode 100644 pyproject.toml create mode 100644 web/css/autocomplete.css create mode 100644 web/js/autocomplete.js create mode 100644 web/js/main.js diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f722e62 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +/.idea/ +/venv/ +/__pycache__/ +**/__pycache__/*.pyc diff --git a/README.md b/README.md new file mode 100644 index 0000000..704a072 --- /dev/null +++ b/README.md @@ -0,0 +1,3 @@ +# ComfyUI-Autocomplete-Plus + +WIP \ No newline at end of file diff --git a/__init__.py b/__init__.py new file mode 100644 index 0000000..dfe447d --- /dev/null +++ b/__init__.py @@ -0,0 +1,8 @@ +from .modules.api import * + +# --- Original Node Mappings (Keep or modify as needed) --- +WEB_DIRECTORY = "./web" +NODE_CLASS_MAPPINGS = {} +__all__ = [] + +print("### Loading: ComfyUI-Autocomplete-Plus ###") \ No newline at end of file diff --git a/modules/api.py b/modules/api.py new file mode 100644 index 0000000..d8558f5 --- /dev/null +++ b/modules/api.py @@ -0,0 +1,97 @@ + +import os +import csv +import json +from aiohttp import web +import server # ComfyUIのサーバーインスタンス + +# --- Helper Function for CSV Parsing --- + +def parse_aliases(alias_str): + """ + Parses a comma-separated string. + Example: 'alias1,alias 2, alias3' + """ + aliases = [] + if not alias_str: + return aliases + + return [word.strip() for word in alias_str.split(',')] + +# --- API Endpoints --- + +@server.PromptServer.instance.routes.get('/autocomplete-plus/tags') +async def get_tags(request): + csv_path = os.path.join(os.path.dirname(__file__), '../', 'user', 'danbooru_tags.csv') + tags_data = [] + try: + with open(csv_path, 'r', encoding='utf-8') as f: + reader = csv.reader(f) + header = next(reader) # Skip header + try: + tag_index = header.index('tag') + alias_index = header.index('alias') + count_index = header.index('count') + except ValueError: + print(f"[Autocomplete-Plus] Error: Invalid header in {csv_path}: {header}") + return web.Response(status=500, text=f"Invalid CSV header in {csv_path}") + + for row in reader: + try: + tag = row[tag_index] + aliases = parse_aliases(row[alias_index]) + count = int(row[count_index]) + if tag: # Ensure tag is not empty + tags_data.append({'tag': tag, 'alias': aliases, 'count': count}) + except (IndexError, ValueError) as e: + print(f"[Autocomplete-Plus] Warning: Skipping invalid row in {csv_path}: {row} - Error: {e}") + continue # Skip malformed rows + + # Sort by count descending before sending + tags_data.sort(key=lambda x: x['count'], reverse=True) + return web.json_response(tags_data) + except FileNotFoundError: + print(f"[Autocomplete-Plus] Error: Tags file not found at {csv_path}") + return web.Response(status=404, text="Tags file not found.") + except Exception as e: + print(f"[Autocomplete-Plus] Error reading tags file: {e}") + return web.Response(status=500, text=f"Error reading tags file: {e}") + +@server.PromptServer.instance.routes.get('/autocomplete-plus/cooccurrence') +async def get_cooccurrence(request): + csv_path = os.path.join(os.path.dirname(__file__), '../', 'user', 'danbooru_tags_cooccurrence.csv') + cooccurrence_data = {} + try: + with open(csv_path, 'r', encoding='utf-8') as f: + reader = csv.reader(f) + header = next(reader) # Skip header + try: + tag_a_index = header.index('tag_a') + tag_b_index = header.index('tag_b') + count_index = header.index('count') + except ValueError: + print(f"[Autocomplete-Plus] Error: Invalid header in {csv_path}: {header}") + return web.Response(status=500, text=f"Invalid CSV header in {csv_path}") + + processed_count = 0 + for row in reader: + try: + tag_a = row[tag_a_index] + tag_b = row[tag_b_index] + count = int(row[count_index]) + if tag_a and tag_b: # Ensure tags are not empty + if tag_a not in cooccurrence_data: + cooccurrence_data[tag_a] = {} + cooccurrence_data[tag_a][tag_b] = count + processed_count += 1 + except (IndexError, ValueError) as e: + print(f"[Autocomplete-Plus] Warning: Skipping invalid row in {csv_path}: {row} - Error: {e}") + continue # Skip malformed rows + print(f"[Autocomplete-Plus] Processed {processed_count} cooccurrence pairs for API.") + return web.json_response(cooccurrence_data) + except FileNotFoundError: + print(f"[Autocomplete-Plus] Error: Cooccurrence file not found at {csv_path}") + return web.Response(status=404, text="Cooccurrence file not found.") + except Exception as e: + print(f"[Autocomplete-Plus] Error reading cooccurrence file: {e}") + return web.Response(status=500, text=f"Error reading cooccurrence file: {e}") diff --git a/nodes.py b/nodes.py new file mode 100644 index 0000000..9f507e2 --- /dev/null +++ b/nodes.py @@ -0,0 +1,152 @@ +# Package Modules +import os +from typing import Union, BinaryIO, Dict, List, Tuple, Optional +import time + +# ComfyUI Modules +import folder_paths +from comfy.utils import ProgressBar + +# Your Modules +from .modules.calculator import CalculatorModel + + +# Basic practice to get paths from ComfyUI +custom_nodes_script_dir = os.path.dirname(os.path.abspath(__file__)) +custom_nodes_model_dir = os.path.join(folder_paths.models_dir, "my-custom-nodes") +custom_nodes_output_dir = os.path.join(folder_paths.get_output_directory(), "my-custom-nodes") + + +# These are example nodes that only contains basic functionalities with some comments. +# If you need detailed explanation, please refer to : https://docs.comfy.org/essentials/custom_node_walkthrough +# First Node: +class MyModelLoader: + # Define the input parameters of the node here. + @classmethod + def INPUT_TYPES(s): + my_models = ["Model A", "Model B", "Model C"] + + return { + # If the key is "required", the value must be filled. + "required": { + # `my_models` is the list, so it will be shown as a dropdown menu in the node. ( So that user can select one of them. ) + # You must provide the value in the tuple format. e.g. ("value",) or (3,) or ([1, 2],) etc. + "model": (my_models,), + "device": (['cuda', 'cpu', 'auto'],), + }, + # If the key is "optional", the value is optional. + "optional": { + "compute_type": (['float32', 'float16'],), + } + } + + # Define these constants inside the node. + # `RETURN_TYPES` is important, as it limits the parameter types that can be passed to the next node, in `INPUT_TYPES()` above. + RETURN_TYPES = ("MY_MODEL",) + RETURN_NAMES = ("my_model",) + # `FUNCTION` is the function name that will be called in the node. + FUNCTION = "load_model" + # `CATEGORY` is the category name that will be used when user searches the node. + CATEGORY = "CustomNodesTemplate" + + # In the function, use same parameter names as you specified in `INPUT_TYPES()` + def load_model(self, + model: str, + device: str, + compute_type: Optional[str] = None, + ) -> Tuple[CalculatorModel]: + calculator_model = CalculatorModel() + calculator_model.load_model(model, device, compute_type) + + # You can use `comfy.utils.ProgressBar` to show the progress of the process. + # First, initialize the total amount of the process. + total_steps = 5 + comfy_pbar = ProgressBar(total_steps) + # Then, update the progress. + for i in range(1, total_steps): + time.sleep(1) + comfy_pbar.update(i) # Alternatively, you can use `comfy_pbar.update_absolute(value)` to update the progress with absolute value. + + # Return the model as a tuple. + return (calculator_model, ) + + +# Second Node +class CalculatePlus: + @classmethod + def INPUT_TYPES(s): + return { + "required": { + "model": ("MY_MODEL", ), + }, + # Specify the parameters with type and default value. + "optional": { + "a": ("INT", {"default": 5}), + "b": ("INT", {"default": 10}), + } + } + + RETURN_TYPES = ("INT",) + RETURN_NAMES = ("plus_value",) + FUNCTION = "plus" + CATEGORY = "CustomNodesTemplate" + + def plus(self, + model: CalculatorModel, + a: Optional[int], + b: Optional[int], + ) -> Tuple[int]: + result = model.plus(a, b) + return (result, ) + + + +# Third Node +class CalculateMinus: + @classmethod + def INPUT_TYPES(s): + return { + "required": { + "model": ("MY_MODEL", ), + "a": ("INT", ), + }, + "optional": { + "b": ("INT", {"default": 10}), + } + } + + RETURN_TYPES = ("INT",) + RETURN_NAMES = ("minus_value",) + FUNCTION = "minus" + CATEGORY = "CustomNodesTemplate" + + def minus(self, + model: CalculatorModel, + a: Optional[int], + b: Optional[int], + ) -> Tuple[int]: + result = model.minus(a, b) + return (result, ) + + + +# Output Node +class ExampleOutputNode: + @classmethod + def INPUT_TYPES(s): + return { + "required": { + "value": ("INT", ), + }, + } + + # If the node is output node, set this to True. + OUTPUT_NODE = True + RETURN_TYPES = ("INT",) + RETURN_NAMES = ("int",) + FUNCTION = "result" + CATEGORY = "CustomNodesTemplate" + + def result(self, + value: int,) -> Tuple[int]: + return (value, ) diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..12f81fd --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,15 @@ +[project] +name = "comfyui-autocomplete-plus" +description = "Custom node to add autocomplete functionality [ComfyUI-Autocomplete-Plus](https://github.com/newtextdoc1111/ComfyUI-Autocomplete-Plus)." +version = "1.0.0" +license = {file = "LICENSE"} +dependencies = ["",] + +[project.urls] +Repository = "https://github.com/newtextdoc1111/ComfyUI-Autocomplete-Plus" +# Used by Comfy Registry https://comfyregistry.org + +[tool.comfy] +PublisherId = "newtextdoc1111" +DisplayName = "ComfyUI-Autocomplete-Plus" +Icon = "" diff --git a/web/css/autocomplete.css b/web/css/autocomplete.css new file mode 100644 index 0000000..017701d --- /dev/null +++ b/web/css/autocomplete.css @@ -0,0 +1,49 @@ +/* Basic styles for autocomplete - will be refined later */ + +#autocomplete-plus-list { + background-color: var(--comfy-input-bg); + border: 1px solid var(--border-color); + border-radius: 4px; + box-shadow: 0 2px 5px rgba(0, 0, 0, 0.2); + min-width: 200px; /* Increased min width */ + overflow-y: auto; + position: absolute; /* Ensure position is absolute */ + z-index: 10000; /* High z-index */ + color: var(--input-text); + font-size: 0.9em; /* Slightly smaller font */ +} + +.autocomplete-plus-item { + padding: 5px 10px; /* Adjusted padding */ + cursor: pointer; + white-space: nowrap; /* Prevent wrapping */ + overflow: hidden; + text-overflow: ellipsis; /* Show ellipsis if too long */ + border-bottom: 1px solid var(--border-color); /* Separator line */ +} + +.autocomplete-plus-item:last-child { + border-bottom: none; /* No border for the last item */ +} + +/* Alternating row colors */ +.autocomplete-plus-item:nth-child(even) { + background-color: var(--comfy-menu-bg); /* Slightly different background for even rows */ +} + +.autocomplete-plus-item:nth-child(odd) { + background-color: var(--comfy-input-bg); +} + +/* Hover effect */ +.autocomplete-plus-item:hover { + background-color: var(--comfy-hover-bg); /* Use a hover variable if available, or adjust */ + color: var(--desc-text); /* Adjust text color on hover if needed */ +} + +/* Selected item style */ +.autocomplete-plus-item.selected { + background-color: var(--comfy-menu-bg-selected, var(--comfy-button-bg)); /* Use selection color, fallback to button */ + color: var(--comfy-menu-text-selected, var(--button-text)); /* Use selected text color, fallback */ + font-weight: bold; +} diff --git a/web/js/autocomplete.js b/web/js/autocomplete.js new file mode 100644 index 0000000..533332a --- /dev/null +++ b/web/js/autocomplete.js @@ -0,0 +1,379 @@ + +// --- Autocomplete UI Class --- + +export class AutocompleteUI { + constructor() { + this.element = document.createElement('div'); + this.element.id = 'autocomplete-plus-list'; + this.element.style.display = 'none'; // Initially hidden + this.element.style.position = 'absolute'; // Position near the input + this.element.style.zIndex = '10000'; // Ensure it's on top + this.activeInput = null; + this.selectedIndex = -1; + this.candidates = []; + + document.body.appendChild(this.element); + + // Add event listener for clicks on items + this.element.addEventListener('mousedown', (e) => { + if (e.target.tagName === 'DIV') { + const index = parseInt(e.target.dataset.index, 10); + if (!isNaN(index)) { + this.#selectItem(index); + e.preventDefault(); // Prevent focus loss from input + e.stopPropagation(); + } + } + }); + } + + show(textareaElement, candidates) { + this.#update(candidates); + if (candidates.length > 0) { + this.#show(textareaElement); + } else { + this.#hide(); + } + } + + hide() { + this.#hide(); + } + + isVisible() { + return this.element.style.display !== 'none'; + } + + /** Moves the selection up or down */ + navigate(direction) { + if (this.candidates.length === 0) return; + this.selectedIndex += direction; + + if (this.selectedIndex < 0) { + this.selectedIndex = this.candidates.length - 1; // Wrap around to bottom + } else if (this.selectedIndex >= this.candidates.length) { + this.selectedIndex = 0; // Wrap around to top + } + this.#highlightItem(this.selectedIndex); + } + + /** Selects the currently highlighted item */ + getSelectedTag() { + if (this.selectedIndex >= 0 && this.selectedIndex < this.candidates.length) { + return this.candidates[this.selectedIndex].tag; + } + + return null; // No valid selection + } + + /** + * Shows the autocomplete list near the target input element. + * Position calculation logic inspired by: + * https://github.com/pythongosssss/ComfyUI-Custom-Scripts/blob/main/web/js/common/autocomplete.js + * License: MIT License (assumed based on repository root LICENSE file) + * Considers ComfyUI canvas scale. + * Adjusts max-height and enables scrolling if the list exceeds viewport bounds. + * @param {HTMLTextAreaElement} inputElement The textarea being typed into. + */ + #show(inputElement) { + this.activeInput = inputElement; + this.selectedIndex = 0; + + // Calculate caret position using the helper function (returns viewport-relative coordinates) + this.#updateCoordinate(); + + this.element.style.overflowY = 'auto'; + this.element.style.display = 'block'; // Make it visible + + // Highlight the first item + this.#highlightItem(this.selectedIndex); + } + + #updateCoordinate() { + const { top: caretTop, left: caretLeft, lineHeight: caretLineHeight } = this.#getCaretCoordinates(this.activeInput); + + // Reset scroll position and max-height before calculating position + this.element.scrollTop = 0; + this.element.style.maxHeight = ''; // Reset max-height for accurate measurement + + + // Get ComfyUI canvas scale if available, otherwise default to 1 + const scale = window.app?.canvas?.ds?.scale ?? 1.0; + + // Initial desired position: below the current text line where the caret is. + let topPosition = caretTop + (caretLineHeight * scale); + let leftPosition = caretLeft; + + // Make the list visible *before* getting its dimensions to ensure they are accurate + // Use visibility instead of display to measure without affecting layout yet + this.element.style.visibility = 'hidden'; + this.element.style.display = 'block'; + const listRect = this.element.getBoundingClientRect(); // Dimensions without max-height constraint + const naturalHeight = listRect.height; + this.element.style.display = 'none'; // Hide again until final position is set + this.element.style.visibility = 'visible'; + + const viewportWidth = window.innerWidth; + const viewportHeight = window.innerHeight; + const margin = 5; // Small margin from viewport edges + + + // --- Horizontal Collision Detection and Adjustment --- + if (leftPosition + listRect.width > viewportWidth - margin) { + leftPosition = viewportWidth - listRect.width - margin; + } + if (leftPosition < margin) { + leftPosition = margin; + } + + // --- Vertical Collision Detection and Adjustment --- + const availableSpaceBelow = viewportHeight - topPosition - margin; + const availableSpaceAbove = caretTop - margin; // Space above the caret line + + + // Reset max-height before deciding + this.element.style.maxHeight = ''; + + if (naturalHeight <= availableSpaceBelow) { + // Fits perfectly below the caret + // topPosition remains as calculated initially + } else { + // Doesn't fit below, check if it fits perfectly above + const topAboveCaret = caretTop - naturalHeight - margin; // Position list bottom just above caret line + if (naturalHeight <= availableSpaceAbove) { + // Fits perfectly above + topPosition = topAboveCaret; + } else { + // Doesn't fit perfectly either below or above, needs scrolling. + // Choose the position (above or below) that offers more space. + if (availableSpaceBelow >= availableSpaceAbove) { + // Scroll below: topPosition remains as initially calculated + this.element.style.maxHeight = `${availableSpaceBelow}px`; + } else { + // Scroll above: Position near the top edge and set max-height + topPosition = margin; + this.element.style.maxHeight = `${availableSpaceAbove}px`; + } + } + } + + // Final check to prevent going off the top edge + if (topPosition < margin) { + topPosition = margin; + // If pushed down, recalculate max-height if it was set based on top alignment + if (this.element.style.maxHeight && availableSpaceBelow < availableSpaceAbove) { + // Recalculate max-height based on space from the top margin + this.element.style.maxHeight = `${viewportHeight - margin - margin}px`; + } + } + + // Apply the calculated position and display the element + this.element.style.left = `${leftPosition}px`; + this.element.style.top = `${topPosition}px`; + } + + /** + * Hides the autocomplete list. + */ + #hide() { + this.element.style.display = 'none'; + this.selectedIndex = -1; + this.activeInput = null; + this.candidates = []; + } + + /** + * Updates the list with new candidates. + * @param {Array<{tag: string, count: number, alias?: string}>} candidates List of candidate tags. + */ + #update(candidates) { + this.candidates = candidates; + this.element.innerHTML = ''; // Clear previous items + if (candidates.length === 0) { + this.#hide(); + return; + } + + candidates.forEach((candidate, index) => { + const item = document.createElement('div'); + item.classList.add('autocomplete-plus-item'); + item.dataset.index = index; + // Display tag and count (alias if matched by alias) + item.textContent = `${candidate.tag} (${candidate.count})${candidate.alias ? ` [${candidate.alias}]` : ''}`; + item.style.padding = '4px 8px'; + item.style.cursor = 'pointer'; + this.element.appendChild(item); + }); + } + + /** Highlights the item at the given index */ + #highlightItem() { + const items = this.element.children; + for (let i = 0; i < items.length; i++) { + if (i === this.selectedIndex) { + items[i].classList.add('selected'); // Use CSS class for selection + items[i].scrollIntoView({ block: 'nearest' }); + } else { + items[i].classList.remove('selected'); + } + } + } + + /** + * Handles the selection of an item (e.g., inserts into input). + * @param {number} index The index of the selected candidate. + */ + #selectItem(index) { + if (!this.activeInput || index < 0 || index >= this.candidates.length) { + this.#hide(); + return; + } + this.#hide(); + } + + /** + * Gets the pixel coordinates of the caret in the input element. + * Uses a temporary div to calculate the position accurately. + * Based on https://github.com/component/textarea-caret-position + * @param {HTMLTextAreaElement} element The textarea element. + * @returns {{ top: number, left: number, lineHeight: number }} + */ + #getCaretCoordinates(element) { + const properties = [ + 'direction', // RTL support + 'boxSizing', + 'width', // on Chrome and IE, exclude the scrollbar, so the mirror div wraps exactly as the textarea does + 'height', + 'overflowX', + 'overflowY', // copy the scrollbar for IE + + 'borderTopWidth', + 'borderRightWidth', + 'borderBottomWidth', + 'borderLeftWidth', + 'borderStyle', + + 'paddingTop', + 'paddingRight', + 'paddingBottom', + 'paddingLeft', + + // https://developer.mozilla.org/en-US/docs/Web/CSS/font + 'fontStyle', + 'fontVariant', + 'fontWeight', + 'fontStretch', + 'fontSize', + 'fontSizeAdjust', + 'lineHeight', + 'fontFamily', + + 'textAlign', + 'textTransform', + 'textIndent', + 'textDecoration', // might not make a difference, but better be safe + + 'letterSpacing', + 'wordSpacing', + + 'tabSize', + 'MozTabSize' // Firefox + ]; + + const isBrowser = typeof window !== 'undefined'; + const isFirefox = isBrowser && window.mozInnerScreenX != null; + + // The mirror div will replicate the textarea's style + const div = document.createElement('div'); + div.id = 'input-textarea-caret-position-mirror-div'; + document.body.appendChild(div); + + const style = div.style; + const computed = window.getComputedStyle(element); + const isInput = element.nodeName === 'INPUT'; + + // Default textarea styles + style.whiteSpace = 'pre-wrap'; + if (!isInput) style.wordWrap = 'break-word'; // only for textarea-s + + // Position off-screen + style.position = 'absolute'; // required to return coordinates properly + style.visibility = 'hidden'; // not 'display: none' because we want rendering + + // Transfer the element's properties to the div + properties.forEach(prop => { + if (isInput && prop === "lineHeight") { + // Special case for s because text is rendered centered and line height may be != height + if (computed.boxSizing === "border-box") { + var height = parseInt(computed.height); + var outerHeight = + parseInt(computed.paddingTop) + + parseInt(computed.paddingBottom) + + parseInt(computed.borderTopWidth) + + parseInt(computed.borderBottomWidth); + var targetHeight = outerHeight + parseInt(computed.lineHeight); + if (height > targetHeight) { + style.lineHeight = height - outerHeight + "px"; + } else if (height === targetHeight) { + style.lineHeight = computed.lineHeight; + } else { + style.lineHeight = 0; + } + } else { + style.lineHeight = computed.height; + } + } else { + style[prop] = computed[prop]; + } + }); + + // Calculate lineHeight more robustly + let computedLineHeight = computed.lineHeight; + let numericLineHeight; + if (computedLineHeight === 'normal') { + // Calculate fallback based on font size + const fontSize = parseFloat(computed.fontSize); + numericLineHeight = Math.round(fontSize * 1.2); // Common approximation + } else { + numericLineHeight = parseFloat(computedLineHeight); // Use parseFloat for pixel values like "16px" + } + // Ensure we have a valid number, fallback if somehow still NaN + if (isNaN(numericLineHeight)) { + const fontSize = parseFloat(computed.fontSize); + numericLineHeight = Math.round(fontSize * 1.2) || 16; // Final fallback + } + + if (isFirefox) { + // Firefox lies about the overflow property for textareas: https://bugzilla.mozilla.org/show_bug.cgi?id=984275 + if (element.scrollHeight > parseInt(computed.height)) style.overflowY = 'scroll'; + } else { + style.overflow = 'hidden'; // for Chrome to not render a scrollbar; IE keeps overflowY = 'scroll' + } + + div.textContent = element.value.substring(0, element.selectionStart); + // The second special handling for input type=text doesn't need to be copied: + // If isInput then usage is https://github.com/component/textarea-caret-position#usage-input-typetext + + const span = document.createElement('span'); + // Wrapping must be replicated *exactly*, including whitespace spaces and carriage returns + span.textContent = element.value.substring(element.selectionStart) || '.'; // || '.' because a completely empty faux span doesn't render at all + div.appendChild(span); + + const coordinates = { + top: span.offsetTop + (parseInt(computed['borderTopWidth']) || 0), + left: span.offsetLeft + (parseInt(computed['borderLeftWidth']) || 0), + lineHeight: numericLineHeight // Use the calculated numeric lineHeight + }; + + // Calculate the bounding rect of the input element relative to the viewport + const rect = element.getBoundingClientRect(); + + // Adjust the coordinates to be relative to the viewport + coordinates.top = rect.top + element.scrollTop + coordinates.top; + coordinates.left = rect.left + element.scrollLeft + coordinates.left; + + document.body.removeChild(div); + + return coordinates; + } +} diff --git a/web/js/main.js b/web/js/main.js new file mode 100644 index 0000000..ba4242c --- /dev/null +++ b/web/js/main.js @@ -0,0 +1,443 @@ +import { app } from "../../../scripts/app.js"; +import { AutocompleteUI } from "./autocomplete.js"; + +(function() { + // Function to load a CSS file + function loadCSS(href) { + const link = document.createElement('link'); + link.rel = 'stylesheet'; + link.type = 'text/css'; + link.href = href; + // Ensure the CSS is loaded before other scripts might rely on its styles + // by adding it to the head. + document.head.appendChild(link); + console.log(`Loaded CSS: ${href}`); // Optional: Log loading + } + + // Construct the path to the CSS file + // Assumes your custom node directory is 'ComfyUI-Autocomplete-Plus' + const cssPath = `/extensions/ComfyUI-Autocomplete-Plus/css/autocomplete.css`; + + // Load the CSS file + loadCSS(cssPath); + + // --- ここから下に既存の main.js のコードを追加 --- + // 例: + // import { app } from "/scripts/app.js"; + // app.registerExtension({...}); + +})(); // IIFE to avoid polluting the global scope + +// Data storage +let tagMap = new Map(); +let aliasMap = new Map(); +let sortedTags = []; // Now populated directly from sorted API response +let cooccurrenceMap = new Map(); // Structure remains Map> + +let tagsLoaded = false; +let cooccurrenceLoaded = false; + +// --- Data Loading Functions --- + +/** + * Loads and processes tag data from the Python API endpoint. + */ +async function loadTags() { + const url = '/autocomplete-plus/tags'; + try { + const response = await fetch(url); + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + const data = await response.json(); // Data is already sorted by count + + sortedTags = data; // Assign directly as it's pre-sorted + + data.forEach(tagData => { + tagMap.set(tagData.tag, tagData); + if (tagData.alias && Array.isArray(tagData.alias)) { + tagData.alias.forEach(alias => { + if (!aliasMap.has(alias)) { + aliasMap.set(alias, tagData.tag); // Map alias back to the main tag + } + }); + } + }); + + tagsLoaded = true; + console.log(`[Autocomplete-Plus] Processed ${sortedTags.length} tags from API.`); + + } catch (error) { + console.error(`[Autocomplete-Plus] Failed to fetch or process tags from ${url}:`, error); + tagsLoaded = false; + } +} + +/** + * Loads and processes cooccurrence data from the Python API endpoint. + */ +async function loadCooccurrence() { + const url = '/autocomplete-plus/cooccurrence'; + try { + const response = await fetch(url); + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + const data = await response.json(); // Data is in { tag_a: { tag_b: count, ... }, ... } format + + let primaryTagCount = 0; + let pairCount = 0; + for (const tagA in data) { + if (Object.hasOwnProperty.call(data, tagA)) { + const innerMap = new Map(); + const pairs = data[tagA]; + for (const tagB in pairs) { + if (Object.hasOwnProperty.call(pairs, tagB)) { + innerMap.set(tagB, pairs[tagB]); + pairCount++; + } + } + if (innerMap.size > 0) { + cooccurrenceMap.set(tagA, innerMap); + primaryTagCount++; + } + } + } + + cooccurrenceLoaded = true; + console.log(`[Autocomplete-Plus] Processed ${pairCount} cooccurrence pairs for ${primaryTagCount} primary tags from API.`); + + } catch (error) { + console.error(`[Autocomplete-Plus] Failed to fetch or process cooccurrence data from ${url}:`, error); + cooccurrenceLoaded = false; + } +} + +// --- End Data Loading Functions --- + +// --- Helper Functions --- + +/** + * Converts Hiragana to Katakana. + * @param {string} str Input string. + * @returns {string} Katakana string. + */ +function hiraToKata(str) { + return str.replace(/[\u3041-\u3096]/g, function(match) { + const chr = match.charCodeAt(0) + 0x60; + return String.fromCharCode(chr); + }); +} + +/** + * Converts Katakana to Hiragana. + * @param {string} str Input string. + * @returns {string} Hiragana string. + */ +function kataToHira(str) { + return str.replace(/[\u30a1-\u30f6]/g, function(match) { + const chr = match.charCodeAt(0) - 0x60; + return String.fromCharCode(chr); + }); +} + +// --- End Helper Functions --- + +// --- Autocomplete Logic --- + +const MAX_CANDIDATES = 50; // Max number of suggestions to show +let autocompleteUI = null; // Singleton UI instance + +/** + * Finds tag completion candidates based on the input query. + * Handles Hiragana/Katakana conversion for matching. + * @param {string} query The partial tag input. + * @returns {Array<{tag: string, count: number, alias?: string}>} + */ +function findCompletionCandidates(query) { + const startTime = performance.now(); // 検索開始時間を記録 + + if (!query || !tagsLoaded) { + return []; + } + + const lowerQuery = query.toLowerCase(); + const candidates = []; + const addedTags = new Set(); // Keep track of added tags to avoid duplicates + + // Generate Hiragana/Katakana variations if applicable + const queryVariations = new Set([lowerQuery]); + const kataQuery = hiraToKata(lowerQuery); + if (kataQuery !== lowerQuery) { + queryVariations.add(kataQuery); + } + const hiraQuery = kataToHira(lowerQuery); + if (hiraQuery !== lowerQuery) { + queryVariations.add(hiraQuery); + } + + // Search in sortedTags (already sorted by count) + for (const tagData of sortedTags) { + let matched = false; + let matchedAlias = null; + + // Check primary tag against all variations + for (const variation of queryVariations) { + // Ensure tagData.tag is treated as lowercase for comparison + if (tagData.tag.toLowerCase().includes(variation)) { + matched = true; + break; + } + } + + // If primary tag didn't match, check aliases against all variations + if (!matched && tagData.alias && Array.isArray(tagData.alias) && tagData.alias.length > 0) { + for (const alias of tagData.alias) { + const lowerAlias = alias.toLowerCase(); + for (const variation of queryVariations) { + if (lowerAlias.includes(variation)) { + matched = true; + matchedAlias = alias; // Store the alias that matched + break; + } + } + if (matched) break; // Stop checking aliases for this tag if one matched + } + } + + // Add candidate if matched and not already added + if (matched && !addedTags.has(tagData.tag)) { + candidates.push({ + tag: tagData.tag, + count: tagData.count, + ...(matchedAlias && { alias: matchedAlias }) // Add alias property only if matched via alias + }); + addedTags.add(tagData.tag); + if (candidates.length >= MAX_CANDIDATES) { + // 早期リターンする場合もログを出力 + const endTime = performance.now(); + const duration = endTime - startTime; + console.log(`[Autocomplete-Plus] Search for "${query}" took ${duration.toFixed(2)}ms. Found ${candidates.length} candidates (max reached).`); + return candidates; // Early exit + } + } + } + + // 検索終了時間を記録し、コンソールに出力 + const endTime = performance.now(); + const duration = endTime - startTime; + console.log(`[Autocomplete-Plus] Search for "${query}" took ${duration.toFixed(2)}ms. Found ${candidates.length} candidates.`); + + return candidates; +} + +/** + * Extracts the current tag being typed before the cursor. + * Assumes tags are separated by commas. + * @param {HTMLTextAreaElement} inputElement + * @returns {string} The partial tag or empty string. + */ +function getCurrentPartialTag(inputElement) { + const text = inputElement.value; + const cursorPos = inputElement.selectionStart; + // Find the last comma before the cursor + const lastComma = text.lastIndexOf(',', cursorPos - 1); + const start = lastComma === -1 ? 0 : lastComma + 1; + // Extract the text between the last comma (or start) and the cursor + const partial = text.substring(start, cursorPos).trimStart(); + return partial; +} + +/** + * Inserts the selected tag into the textarea, replacing the partial tag. + * @param {HTMLTextAreaElement} inputElement + * @param {string} tagToInsert + */ +function insertTag(inputElement, tagToInsert) { + const text = inputElement.value; + const cursorPos = inputElement.selectionStart; + const lastComma = text.lastIndexOf(',', cursorPos - 1); + const start = lastComma === -1 ? 0 : lastComma + 1; + const actualTag = tagToInsert.replace("_", " "); + + // Find the end of the word/tag at the cursor (if any) + // This basic version assumes we replace up to the next comma or end of string + let end = text.indexOf(',', cursorPos); + if (end === -1) { + end = text.length; + } + // More precise: find word boundary if not comma + const nextSpace = text.indexOf(' ', cursorPos); + if (nextSpace !== -1 && nextSpace < end) { + // This logic might need refinement depending on desired behavior + // For now, replace up to the original cursor position if inserting mid-word + end = cursorPos; + } + + // Ensure space after comma if inserting after a comma + const prefix = text.substring(0, start).trimEnd(); + const needsSpaceBefore = start > 0 && prefix[prefix.length - 1] === ','; + + const textBefore = text.substring(0, start) + (needsSpaceBefore ? ' ' : ''); + const textAfter = text.substring(cursorPos); // Use cursorPos to replace only typed part + + // Add comma and space if needed after insertion + const suffix = ', '; // Standard separator + + inputElement.value = textBefore + actualTag + suffix + textAfter; + + // Set cursor position after the inserted tag and the following comma+space + const newCursorPos = textBefore.length + actualTag.length + suffix.length; + inputElement.selectionStart = inputElement.selectionEnd = newCursorPos; + + // Trigger input event for ComfyUI to recognize change + inputElement.dispatchEvent(new Event('input', { bubbles: true })); +} + +// --- Event Handlers --- + +function handleInput(event) { + if (!autocompleteUI) return; + const textareaElement = event.target; + const partialTag = getCurrentPartialTag(textareaElement); + + if (partialTag.length > 0) { + const candidates = findCompletionCandidates(partialTag); + autocompleteUI.show(textareaElement, candidates); + } else { + autocompleteUI.hide(); + } +} + +function handleFocus(event) { + // Potentially show suggestions immediately on focus? + // For now, only show on input + if (!autocompleteUI) { + autocompleteUI = new AutocompleteUI(); + } + // Maybe check if there's already text and show suggestions? + // handleInput(event); // Trigger check immediately +} + +function handleBlur(event) { + // Need a slight delay because clicking the autocomplete list causes blur + setTimeout(() => { + if (autocompleteUI && !autocompleteUI.element.contains(document.activeElement)) { + autocompleteUI.hide(); + } + }, 150); +} + +function handleKeyDown(event) { + if (!autocompleteUI || !autocompleteUI.isVisible()) return; + const textareaElement = event.target; + + switch (event.key) { + case 'ArrowDown': + event.preventDefault(); + autocompleteUI.navigate(1); + break; + case 'ArrowUp': + event.preventDefault(); + autocompleteUI.navigate(-1); + break; + case 'Enter': + case 'Tab': + if (autocompleteUI.getSelectedTag() !== null) { + event.preventDefault(); + + insertTag(textareaElement, autocompleteUI.getSelectedTag()); + } else { + // Allow default Tab/Enter if no item is selected + autocompleteUI.hide(); + } + break; + case 'Escape': + event.preventDefault(); + autocompleteUI.hide(); + break; + } +} + +// --- Initialization --- + +function initializeAutocomplete() { + if (!tagsLoaded) { + console.warn("[Autocomplete-Plus] Tags not loaded, cannot initialize autocomplete."); + return; + } + console.log("[Autocomplete-Plus] Initializing autocomplete features..."); + autocompleteUI = new AutocompleteUI(); + + // Find relevant textareas (e.g., prompt inputs) + // This selector might need adjustment based on ComfyUI's structure + const targetSelectors = [ + '.comfy-multiline-input', + // Add other selectors if needed + ]; + + // Use MutationObserver to detect dynamically added textareas + const observer = new MutationObserver((mutations) => { + mutations.forEach((mutation) => { + mutation.addedNodes.forEach((node) => { + if (node.nodeType === Node.ELEMENT_NODE) { + targetSelectors.forEach(selector => { + // Check if the added node itself matches or contains matching elements + if (node.matches(selector)) { + attachListeners(node); + } else { + node.querySelectorAll(selector).forEach(attachListeners); + } + }); + } + }); + }); + }); + + // Function to attach listeners + function attachListeners(element) { + if (element.dataset.autocompleteAttached) return; // Prevent double attachment + console.log("[Autocomplete-Plus] Attaching listeners to:", element); + element.addEventListener('input', handleInput); + element.addEventListener('focus', handleFocus); + element.addEventListener('blur', handleBlur); + element.addEventListener('keydown', handleKeyDown); + element.dataset.autocompleteAttached = 'true'; + } + + // Initial scan for existing elements + targetSelectors.forEach(selector => { + document.querySelectorAll(selector).forEach(attachListeners); + }); + + // Start observing the document body for changes + observer.observe(document.body, { childList: true, subtree: true }); + + console.log("[Autocomplete-Plus] Autocomplete initialized and observer started."); +} + +app.registerExtension({ + name: "comfyui-autocomplete-plus", + async setup() { + console.log("[Autocomplete-Plus] Starting setup..."); + + // Load data asynchronously from API endpoints + Promise.all([loadTags(), loadCooccurrence()]) + .then(() => { + if (tagsLoaded) { // Only need tags for autocomplete + console.log("[Autocomplete-Plus] Tag data loaded successfully via API."); + initializeAutocomplete(); // Initialize after tags are loaded + } else { + console.error("[Autocomplete-Plus] Failed to load tag data via API. Autocomplete disabled."); + } + if (!cooccurrenceLoaded) { + console.warn("[Autocomplete-Plus] Failed to load cooccurrence data via API. Similar tags disabled."); + } + }) + .catch(error => { + console.error("[Autocomplete-Plus] Error during API data loading:", error); + }); + + console.log("[Autocomplete-Plus] Setup complete (API data loading initiated)!"); + }, +}); \ No newline at end of file