Compare commits

..
Author SHA1 Message Date
Vito 1a7e0dfcd7 Merge branch 'main' into chore/deprecate-gemini 2026-10-16 16:36:20 -07:00
Vito 0b85600d38 Merge pull request #102 from ComfyAssets/feat/shared-custom-colors
feat(custom_colors): adopt shared ComfyAssets picker
2026-10-16 16:34:34 -07:00
Vito Sansevero ca65cc7961 test(loader): await initial gallery before keyboard controls 2026-10-16 16:25:33 -07:00
Vito Sansevero 6adba639bb feat(custom_colors): adopt shared ComfyAssets picker 2026-10-16 16:17:28 -07:00
Vito Sansevero 1e86b73168 chore(gemini): soft-deprecate legacy prompt node 2026-10-16 15:44:31 -07:00
Vito 0fa595a9b7 Merge pull request #98 from ComfyAssets/feat/shared-embedding-autocomplete
feat(autocomplete): unify UI and restore tag management
2026-10-16 15:38:45 -07:00
Vito Sansevero f8ce4cb982 fix(autocomplete): address review findings and WebKit readiness 2026-10-16 15:31:46 -07:00
Vito Sansevero 42b6009f1b feat(autocomplete): restore tag imports and advanced settings 2026-10-16 15:11:56 -07:00
Vito Sansevero 963ac0a4af docs(autocomplete): document versioned Danbooru tag pack and terms 2026-10-16 14:45:29 -07:00
Vito Sansevero b4bd8b98b1 feat(ui): unify Embedding Autocomplete controls and suggestions 2026-10-16 13:49:11 -07:00
Vito ed6675721d Merge pull request #95 from ComfyAssets/feat/shared-display-views
feat(ui): unify Display Text and Display Any output views
2026-10-16 13:25:23 -07:00
Vito Sansevero b0c0d53676 feat(ui): unify Display Text and Display Any output views 2026-10-16 13:20:28 -07:00
Vito b9103ad9c4 Merge pull request #93 from ComfyAssets/feat/shared-node-dimensions-dialog
feat(ui): migrate Node Dimensions to shared dialog controls
2026-10-16 13:09:13 -07:00
Vito Sansevero b5694ecfe5 feat(ui): migrate Node Dimensions to shared dialog controls 2026-10-16 13:00:36 -07:00
Vito 1255c4e9cc Merge pull request #91 from ComfyAssets/feat/shared-dimension-swap
feat(ui): unify dimension swap controls for both nodes
2026-10-16 12:45:22 -07:00
Vito Sansevero 26557a99b6 feat(ui): unify dimension swap controls for both nodes 2026-10-16 12:35:23 -07:00
Vito 848674d474 Merge pull request #89 from ComfyAssets/feat/shared-workflow-timer
feat(ui): migrate Workflow Timer to shared controls
2026-10-16 12:23:27 -07:00
Vito Sansevero 0c47dc6085 feat(ui): migrate Workflow Timer to shared controls 2026-10-16 12:18:02 -07:00
Vito ee7d1e9011 Merge pull request #87 from ComfyAssets/feat/shared-seed-history
feat(ui): migrate Seed History to shared controls
2026-10-16 12:00:07 -07:00
Vito Sansevero b7a29a1731 feat(ui): migrate Seed History to shared controls 2026-10-16 11:55:57 -07:00
Vito 3cabbffb40 Merge pull request #85 from ComfyAssets/feat/shared-image-viewer
feat(ui): migrate Save Image viewer to shared window controls
2026-10-16 11:37:46 -07:00
Vito Sansevero 47dc177517 feat(ui): migrate saved-image viewer to shared window controls 2026-10-16 11:30:11 -07:00
Vito ddb356fe17 Merge pull request #83 from ComfyAssets/feat/live-image-loader-ui
feat(ui): ship shared ComfyAssets controls in Local Image Loader
2026-10-16 11:08:36 -07:00
Vito Sansevero 9e2b271824 fix(ui): keep gallery thumbnails large and adjustable 2026-10-16 11:03:33 -07:00
Vito Sansevero 2e7fac4b70 docs(ui): capture the loader in both ComfyUI renderers 2026-10-16 10:53:07 -07:00
Vito Sansevero 740eccec27 feat(ui): migrate Local Image Loader to shared ComfyAssets controls 2026-10-16 10:52:28 -07:00
Vito 55bce1004b Merge pull request #79 from ComfyAssets/dependabot/github_actions/actions/setup-node-7
build(deps): bump actions/setup-node from 6 to 7
2026-10-16 09:18:54 -07:00
Vito 63a8596b81 Merge branch 'main' into dependabot/github_actions/actions/setup-node-7 2026-10-16 09:14:46 -07:00
Vito 11494c747c Merge pull request #78 from ComfyAssets/dependabot/github_actions/actions/setup-python-7
build(deps): bump actions/setup-python from 6 to 7
2026-10-16 09:14:32 -07:00
Vito 88a9238ee2 Merge branch 'main' into dependabot/github_actions/actions/setup-python-7 2026-10-16 09:10:46 -07:00
Vito 3879f18d79 Merge pull request #72 from ComfyAssets/feat/checkpoint-folder-iterator
feat(xyz_helpers): Checkpoint Folder Iterator and Model Compare Sheet
2026-10-16 09:10:28 -07:00
dependabot[bot] 46999c8d90 build(deps): bump actions/setup-node from 6 to 7
Bumps [actions/setup-node](https://github.com/actions/setup-node) from 6 to 7.
- [Release notes](https://github.com/actions/setup-node/releases)
- [Commits](https://github.com/actions/setup-node/compare/v6...v7)

---
updated-dependencies:
- dependency-name: actions/setup-node
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-10-16 15:47:59 +00:00
dependabot[bot] 852864a270 build(deps): bump actions/setup-python from 6 to 7
Bumps [actions/setup-python](https://github.com/actions/setup-python) from 6 to 7.
- [Release notes](https://github.com/actions/setup-python/releases)
- [Commits](https://github.com/actions/setup-python/compare/v6...v7)

---
updated-dependencies:
- dependency-name: actions/setup-python
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-10-16 15:47:47 +00:00
89 changed files with 6091 additions and 4314 deletions
+3 -3
View File
@@ -16,7 +16,7 @@ jobs:
- uses: actions/checkout@v7
- name: Set up Python 3.10
uses: actions/setup-python@v6
uses: actions/setup-python@v7
with:
python-version: '3.10'
@@ -145,7 +145,7 @@ jobs:
- uses: actions/checkout@v7
- name: Set up Python 3.10
uses: actions/setup-python@v6
uses: actions/setup-python@v7
with:
python-version: '3.10'
@@ -176,7 +176,7 @@ jobs:
- uses: actions/checkout@v7
- name: Set up Python 3.10
uses: actions/setup-python@v6
uses: actions/setup-python@v7
with:
python-version: '3.10'
+1 -1
View File
@@ -20,7 +20,7 @@ jobs:
- uses: actions/checkout@v7
- name: Set up Python 3.10
uses: actions/setup-python@v6
uses: actions/setup-python@v7
with:
python-version: '3.10'
+8 -7
View File
@@ -23,7 +23,7 @@ jobs:
- uses: actions/checkout@v7
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v6
uses: actions/setup-python@v7
with:
python-version: ${{ matrix.python-version }}
cache: pip
@@ -60,7 +60,7 @@ jobs:
steps:
- uses: actions/checkout@v7
- uses: actions/setup-python@v6
- uses: actions/setup-python@v7
with:
python-version: "3.12"
cache: pip
@@ -93,14 +93,15 @@ jobs:
steps:
- uses: actions/checkout@v7
- uses: actions/setup-node@v6
- uses: actions/setup-node@v7
with:
node-version: "24"
# Check every shipped module, including files without behavioral tests.
- name: Check web/*.js parse as ES modules
- name: Check all shipped JavaScript modules parse
run: |
for f in web/*.js; do
shopt -s globstar nullglob
for f in web/**/*.js web/**/*.mjs; do
node --input-type=module --check < "$f" || { echo "Syntax error in $f"; exit 1; }
done
@@ -110,7 +111,7 @@ jobs:
timeout-minutes: 15
steps:
- uses: actions/checkout@v7
- uses: actions/setup-python@v6
- uses: actions/setup-python@v7
with:
python-version: "3.12"
cache: pip
@@ -120,7 +121,7 @@ jobs:
python -m pip install --upgrade pip
pip install torch --index-url https://download.pytorch.org/whl/cpu
pip install -r requirements-dev.txt
- uses: actions/setup-node@v6
- uses: actions/setup-node@v7
with:
node-version: "24"
cache: npm
+41 -28
View File
@@ -17,6 +17,13 @@ I tried forking, patching, and submitting merge requests, but eventually decided
I’m sharing them here with the community, and I hope you find them as useful as I do.
## ComfyUI Classic and Nodes 2.0
KikoTools supports both ComfyUI Classic and Nodes 2.0. Our shared ComfyAssets
controls follow ComfyUI's dark/light theme, with automated tests covering renderer
switching, workflow persistence and cleanup. See the retained
[UI screenshots](docs/screenshots/) for examples from the migrations.
## 🚀 Features
### ✨ Current Tools
@@ -30,7 +37,7 @@ I’m sharing them here with the community, and I hope you find them as useful a
| [📦 Empty Latent Batch](#-empty-latent-batch) | Create empty latent batches with preset support | 📦 Latents |
| [💾 Kiko Save Image](#-kiko-save-image) | Enhanced image saving with popup viewer | 💾 Images |
| [📋 Display Text](#-display-text) | Smart text display with prompt detection | 👁️ Display |
| [🤖 Gemini Prompt Engineer](#-gemini-prompt-engineer) | AI-powered image analysis and prompt generation | 🧠 Prompts |
| [🤖 Gemini Prompt Engineer](#-gemini-prompt-engineer) | Deprecated; retained for existing workflows | 🧠 Prompts |
| [🔍 Display Any](#-display-any) | Universal debugging tool for any data type | 👁️ Display |
| [🖼️ Image to Multiple Of](#️-image-to-multiple-of) | Adjust dimensions to multiples for compatibility | 🖼️ Resolution |
| [📉 Image Scale Down By](#-image-scale-down-by) | Scale images down by a factor with quality preservation | 🖼️ Resolution |
@@ -176,28 +183,17 @@ Advanced text display node with intelligent formatting and enhanced user interac
![Display Text Example](examples/workflows/display_text_example.png)
#### 🤖 Gemini Prompt Engineer
AI-powered image analysis using Google's Gemini to generate optimized prompts for various models.
- **Multi-Model Support**: Generate prompts for FLUX, SDXL, Danbooru, and Video generation
- **Smart Analysis**: Gemini analyzes composition, style, lighting, colors, and details
- **Format-Specific Output**: FLUX artistic prompts, SDXL positive/negative pairs, Danbooru tags, Video motion descriptions
- **Custom System Prompts**: Override templates with your own analysis instructions
- **Flexible API Key Management**: Environment variable, config file, or direct input
- **Visual Status Feedback**: Real-time processing indicators and error states
- **Help Integration**: Built-in setup guide and documentation
- **Dynamic Model Refresh**: Fetch latest Gemini models with refresh button
- **Model Caching**: Persistent model list storage for offline access
- **Enhanced SDXL Prompts**: Improved formatting with layered structure and quality boosters
**Deprecated — retained for existing workflows only.** ComfyUI hides this node
from normal new-node discovery using its deprecated-node metadata. The
`GeminiPrompt` identifier, inputs, outputs and execution remain available so
saved workflows can still load.
**Use Cases:**
- Reverse-engineer prompts from reference images
- Convert artistic descriptions between different AI model formats
- Generate consistent style descriptions across workflows
- Create detailed scene breakdowns for complex compositions
- Analyze and replicate lighting/mood from existing artwork
- Access latest Gemini models including 2.0 and 2.5 versions
![Gemini Prompt Example](examples/workflows/gemini_prompt_example.png)
Google API/model compatibility updates and UI migration have ended. Existing
workflows still depend on Google's service and the optional `google-genai`
package; deprecation does not guarantee future API compatibility. See the
[legacy Gemini documentation](examples/documentation/gemini_prompt.md) for the
existing configuration and behavior.
#### 🔍 Display Any
Universal debugging node that displays any type of input value or tensor information.
@@ -451,6 +447,20 @@ Real-time execution timer that displays workflow duration with millisecond preci
- **Workflow Timer: Color** - Custom color picker for timer display
- **Workflow Timer: Enable Glow** - Toggle pulsing glow effect on/off
#### 🎨 Custom Colors
Right-click a node and choose **🫶 Custom Colors** to change its full color,
title or background. The shared picker supports hex entry, HSV sliders and
presets, and follows ComfyUI's dark/light theme. Changes apply immediately;
**Done** or **Escape** closes the picker. Multiple selected nodes receive the
same change. Right-click a group for its color option.
Existing Custom Colors settings control the available menu options and automatic
title shading. Node and group colors are saved with the workflow.
Screenshots: [Dark](docs/screenshots/custom-colors/dark.png) ·
[Light](docs/screenshots/custom-colors/light.png).
#### 🗂️ Project File Path
Builds an organized save prefix to wire into a save node's `filename_prefix`.
@@ -477,8 +487,8 @@ This feature is an enhanced fork of the autocomplete functionality from [ComfyUI
**Key Features:**
- **Smart Triggers**: Type `embedding:` for embeddings, `<lora:` for LoRAs, or just start typing for tags
- **Custom Word Lists**: Load tag databases (like Danbooru tags) from any URL
- **Security First**: Comprehensive input validation prevents code injection and XSS attacks
- **Custom Word Lists**: Import CSV/TXT files or browser-accessible URLs, including [versioned Danbooru tag packs](docs/tag-packs/README.md), with local offline caching
- **Safe Rendering**: Tag data stays plain text, with validated CSV fields and file-size limits
- **Flexible Settings**: Customize triggers, auto-insert commas, replace underscores, and more
- **Performance Optimized**: Handles 100,000+ tags smoothly with frequency-based sorting
- **Visual Polish**: Clean UI with proper scrolling, keyboard navigation, and type indicators
@@ -492,9 +502,9 @@ This feature is an enhanced fork of the autocomplete functionality from [ComfyUI
- Load custom word lists from URLs with security validation
**Security Features:**
- Validates all loaded content to prevent script injection
- Blocks dangerous patterns (eval, innerHTML, script tags, etc.)
- Safe character whitelist for tags
- Validates CSV structure, tag names, categories, counts, and file size
- Renders tag data as plain text rather than executable HTML or JavaScript
- Rejects control characters and HTML error documents
- File size limits to prevent memory exhaustion
- Clear error messages for rejected content
@@ -679,6 +689,9 @@ Gemini Prompt → Display Text → Copy to Clipboard
**Smart Detection:** Automatically formats SDXL-style prompts
### Gemini Prompt Engineer Example
**Legacy example for the deprecated node; existing workflows only.**
```
Load Image → Gemini Prompt → Display Text → Text Generation Model
🖼️ reference ↘ type: SDXL ↘ split view ↘ "detailed portrait..."
@@ -773,7 +786,7 @@ Example workflow available: [xyz_helpers_lora_testing.json](examples/workflows/x
| **Empty Latent Batch** | Create empty latent batches with preset support | ✅ Complete | [Docs](examples/documentation/empty_latent_batch.md) |
| **Kiko Save Image** | Enhanced image saving with popup viewer and multi-format support | ✅ Complete | [Docs](examples/documentation/kiko_save_image.md) |
| **Display Text** | Advanced text display with smart prompt detection and split view | ✅ Complete | [Docs](examples/documentation/display_text.md) |
| **Gemini Prompt Engineer** | AI-powered image analysis with dynamic model refresh | ✅ Complete | [Docs](examples/documentation/gemini_prompt.md) |
| **Gemini Prompt Engineer** | Legacy image analysis and prompt generation | Deprecated | [Docs](examples/documentation/gemini_prompt.md) |
| **Display Any** | Universal debugging tool for any data type or tensor shapes | ✅ Complete | [Docs](examples/documentation/display_any.md) |
| **Image to Multiple Of** | Adjust image dimensions to multiples for model compatibility | ✅ Complete | [Docs](examples/documentation/image_to_multiple_of.md) |
| **Image Scale Down By** | Efficiently scale images down by a specified factor | ✅ Complete | [Docs](examples/documentation/image_scale_down_by.md) |
@@ -1087,7 +1100,7 @@ MIT License - see [LICENSE](LICENSE) file for details.
- **Format Support**: 3 (PNG, JPEG, WebP with advanced controls)
- **Presets**: 26 curated resolution presets
- **Interactive Features**: 8+ (swap buttons, history UI, popup viewers, parameter visualization)
- **AI Integration**: Gemini API with 40+ model support
- **Legacy AI Integration**: Deprecated Gemini node retained for saved workflows
- **Test Coverage**: Enforced 85% Python statement/branch coverage and per-file frontend coverage floors
- **Python Version**: 3.8+
- **ComfyUI Compatibility**: Latest
+37
View File
@@ -73,6 +73,43 @@ The browser host is a test double; these workflows do not run the real ComfyUI
frontend or certify every node's UI. Type checking and security jobs retain their
existing advisory behavior.
### Actual ComfyUI frontend smoke test
The Local Image Loader also has an opt-in suite using the actual ComfyUI frontend,
graph, DOM widgets, renderer switch and palette settings. Start a local ComfyUI
0.39.0 with frontend 1.55.14 and KikoTools installed, then run:
```sh
KIKO_COMFY_URL=http://127.0.0.1:8188/ node \
tools/testing/node_modules/@playwright/test/cli.js test \
--config tools/testing/comfyui.config.mjs
```
The suite requires those exact versions and records them in its artifacts. It
serves the current checkout's extensions to a separate browser context and loads
only KikoTools extensions. Media requests use the existing real Python routes
with disposable fixture data on port 8771. Other HTTP writes are intercepted,
so user settings, saved workflows and selections are not written to the live
server. It does not execute a workflow or download models.
Checks cover Classic/Nodes 2.0/live switching, theme changes, workflow save/reload
and removal for the loader. Save Image additionally checks real image output,
window close/reopen through the node control across renderer switches, theme
changes, exclusion of the launcher from serialized values and owner removal.
Its `/view` requests also use disposable fixture output rather than live files.
Seed History checks keyboard-operable rows through renderer switches, list growth
and scrolling on resize, palettes, saved history/node dimensions, serialization
and polling cleanup on removal.
Workflow Timer covers execution events, Classic/Nodes 2.0 switching, themes,
custom node colors, persisted time/dimensions and the actual settings picker.
Both dimension swap nodes cover keyboard swaps across renderers and zoom,
responsive button sizing, palettes, native workflow values/saved sizes and
removal cleanup. Latent batch size is preserved.
Screenshots/traces appear in
`tools/testing/test-results/comfyui/`. This opt-in smoke suite is separate from
the required fixture CI job; fully provisioned, pinned ComfyUI CI and testing
alongside other node packs remain follow-up work in issue #81.
## Required merge checks
The manifest in `.github/branch-protection.json` requires all Python matrix jobs,
+21
View File
@@ -0,0 +1,21 @@
# ComfyAssets UI screenshots
Retained captures from the KikoTools UI migrations. Real-ComfyUI tests cover
Classic and Nodes 2.0, renderer switching, saved workflows and cleanup.
| Interface | Captures |
| --- | --- |
| Local Image Loader | [Classic](live-image-loader/classic.png), [Nodes 2.0](live-image-loader/nodes2.png) |
| Saved-image viewer | [Dark](save-image-viewer/dark.png), [Light](save-image-viewer/light.png) |
| Seed History | [Dark](seed-history/dark.png), [Light](seed-history/light.png) |
| Workflow Timer | [Dark](workflow-timer/dark.png), [Light](workflow-timer/light.png), [Color picker](workflow-timer/color-picker.png) |
| Dimension swap | [Selector dark](dimension-swap/selector-dark.png), [Selector light](dimension-swap/selector-light.png), [Latent dark](dimension-swap/latent-dark.png), [Latent light](dimension-swap/latent-light.png) |
| Node Dimensions | [Dark](node-dimensions/dark.png), [Light](node-dimensions/light.png) |
| Display Text | [Dark](display-output/text-dark.png), [Light](display-output/text-light.png) |
| Display Any | [Dark](display-output/any-dark.png), [Light](display-output/any-light.png) |
| Embedding Autocomplete | [Suggestions dark](embedding-autocomplete/suggestions-dark.png), [Suggestions light](embedding-autocomplete/suggestions-light.png), [Settings dark](embedding-autocomplete/settings-dark.png), [Settings light](embedding-autocomplete/settings-light.png) |
| Custom Colors | [Dark](custom-colors/dark.png), [Light](custom-colors/light.png) |
Screenshots record the tested appearance at capture time. Updated Playwright
captures are temporary test artifacts until copied into this directory and
committed for review.
Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 77 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 81 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 103 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 103 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 107 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 111 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 94 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 97 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 81 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 89 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 89 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 73 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

+48
View File
@@ -0,0 +1,48 @@
# Autocomplete tag packs
ComfyAssets prepares filtered autocomplete data from Danbooru's public
`tags.json` and `tag_aliases.json` APIs. Dataset releases are versioned separately
from KikoTools and stored as release assets, keeping bulk data out of Git history.
## Danbooru — Illustrious / NoobAI, 2026-10-16
[Download the CSV and manifest](https://github.com/ComfyAssets/ComfyUI-KikoTools/releases/tag/tags-2026-10-16).
This snapshot contains 165,158 canonical tags, their categories, post counts, and
aliases. The adjacent manifest records the retrieval date, model targets,
selection filters, schema, byte size, and SHA-256 checksum.
The UTF-8 CSV has no header. Columns are `tag,category,post_count,aliases`.
Aliases share a comma-separated field; use a CSV parser that respects quoting.
An empty alias field means no aliases. Category codes are 0 (general), 1
(artist), 3 (copyright), 4 (character), and 5 (meta).
In ComfyUI Settings, search **Embedding Autocomplete**, open its settings,
then download the pack and choose the CSV under **Tag list file**. Enable
**Show Custom Words** and select **Save Settings**. Importing stages the list;
Cancel leaves the active list unchanged. The browser caches the saved file in
IndexedDB for offline use and restores it after a refresh. Clearing browser site
data removes this cache. There is no server-side autocomplete folder to populate.
Alternatively, enter a CSV/TXT URL and select **Load URL**, then save. Remote
servers must allow browser cross-origin requests; for GitHub release assets,
download and import the file locally. Existing one-column word lists and
`tag,frequency` lists remain supported. The default URL is unchanged.
Parsing runs in a Web Worker with a 32 MB UTF-8 limit. Search checks canonical
names and aliases, inserts the canonical tag, and ranks exact matches ahead of
prefixes and substrings, then by descending post count. The number of displayed
results is bounded by Maximum suggestions. Categories are retained in records;
category-specific controls are not currently exposed.
## Source and terms
Source data: [Danbooru](https://danbooru.donmai.us/), collected 2026-10-16.
Selection and packaging: ComfyAssets. This is an independent filtered snapshot,
not an official Danbooru release or a guarantee of model compatibility.
Refer to [Danbooru's Terms of Service](https://danbooru.donmai.us/terms) and the
[official terms template](https://github.com/danbooru/danbooru/blob/master/app/views/static/terms_of_service.html.erb).
The template's Copyright Infringement section identifies tags as factual
information that is generally not copyrightable. Its API Usage section requires
compliance with API rate limits. This reference does not claim an explicit
dataset license or extend KikoTools' software license to upstream data.
@@ -0,0 +1,35 @@
{
"source": "danbooru.donmai.us tags.json + tag_aliases.json",
"pulled": "2026-10-16",
"target_models": "Illustrious 1.0-3.x, NoobAI-XL",
"filters": {
"created_before": "2025-01-01",
"min_post_count": {
"general": 50,
"artist": 30,
"copyright": 20,
"character": 20
},
"artists_kept": true,
"deprecated_removed": true,
"meta_tags": "removed except small allowlist"
},
"tag_count": 165158,
"schema_version": 1,
"file": "danbooru-illustrious-noobai-2026-10-16.csv",
"encoding": "UTF-8",
"header_row": false,
"columns": [
"tag",
"category",
"post_count",
"aliases"
],
"aliases_format": "Comma-separated inside a CSV-quoted field when necessary",
"size_bytes": 3979593,
"sha256": "733531a60f2e2d38925f53381b18cff21513ef14550d1f2ca670a0f55b86b798",
"prepared_by": "ComfyAssets",
"terms_url": "https://danbooru.donmai.us/terms",
"terms_source_url": "https://github.com/danbooru/danbooru/blob/master/app/views/static/terms_of_service.html.erb",
"release_tag": "tags-2026-10-16"
}
+12 -1
View File
@@ -1,4 +1,15 @@
# Gemini Prompt Engineer
# Gemini Prompt Engineer (Deprecated)
**Legacy documentation for existing workflows.** This node is soft-deprecated
and hidden from normal new-node discovery through ComfyUI's deprecated-node
metadata. Users who enable deprecated-node visibility may still find it.
The `GeminiPrompt` registration, display name, input/output schema and execution
remain available for saved workflows. Existing configuration and model-refresh
behavior remain in place. Google API/model compatibility updates and UI migration
have ended; the historical model and setup details below may no longer match
Google's current service. No replacement or automatic workflow conversion is
provided.
The Gemini Prompt Engineer node uses Google's Gemini AI to analyze images and generate optimized prompts for various AI image generation models.
+5
View File
@@ -12,6 +12,8 @@ from .models import get_available_models
class GeminiPromptNode(ComfyAssetsBaseNode):
"""Analyzes images using Gemini AI to generate optimized prompts for various AI models."""
DEPRECATED = True
@classmethod
def INPUT_TYPES(cls):
"""Define input types for the node."""
@@ -54,6 +56,9 @@ class GeminiPromptNode(ComfyAssetsBaseNode):
CATEGORY = "🫶 ComfyAssets/🧠 Prompts"
DESCRIPTION = """
Deprecated: retained for existing workflows only. This node no longer receives
Google API/model compatibility updates.
Analyzes images using Google's Gemini AI to generate optimized prompts.
Supports multiple prompt formats:
+4 -2
View File
@@ -1,6 +1,7 @@
// A test double for the ComfyUI host; extension implementations load unchanged.
const extensions = [];
const settings = new Map();
const settingDefinitions = new Map();
const nodes = new Map();
let nextId = 1;
function el(tag, props = {}, children = []) {
@@ -19,8 +20,9 @@ const app = {
extensions.push(extension);
},
ui: { settings: {
addSetting(setting) { if (!settings.has(setting.id)) settings.set(setting.id, setting.defaultValue); },
addSetting(setting) { settingDefinitions.set(setting.id, setting); if (!settings.has(setting.id)) settings.set(setting.id, setting.defaultValue); },
getSettingValue: id => settings.get(id),
setSettingValue(id, value) { settings.set(id, value); settingDefinitions.get(id)?.onChange?.(value); },
}, dialog: { show: text => { const dialog = el('div', { className: 'host-dialog', textContent: text }); document.body.append(dialog); } } },
graph: {
_nodes: [],
@@ -106,7 +108,7 @@ function removeNode(id) {
app.graph._nodes = app.graph._nodes.filter(item => item !== node);
return node;
}
window.kikoHost = { app, api, widgets, el, extensions, settings, nodes, createNode, removeNode,
window.kikoHost = { app, api, widgets, el, extensions, settings, settingDefinitions, nodes, createNode, removeNode,
emit: (name, detail) => api.dispatchEvent(new CustomEvent(name, { detail })) };
const manifest = await (await fetch('/__test__/state')).json();
for (const name of manifest.extensions) await import(`/extensions/kikotools/${name}`);
File diff suppressed because it is too large Load Diff
+779
View File
@@ -0,0 +1,779 @@
import { test, expect } from '../../tools/testing/node_modules/@playwright/test/index.mjs';
import { readFile } from 'node:fs/promises';
import { fileURLToPath } from 'node:url';
import { resolve, sep } from 'node:path';
const fixture = 'http://127.0.0.1:8771';
const web = resolve(fileURLToPath(new URL('../../web/', import.meta.url)));
test.beforeEach(async ({ page, context, request, baseURL }, testInfo) => {
const info = await (await request.get(`${baseURL}system_stats`)).json();
expect(info.system.comfyui_version).toBe('0.39.0');
expect(info.system.required_frontend_version).toBe('1.55.14');
expect(info.system.comfy_package_versions.find(item => item.name === 'comfyui-frontend-package')?.installed).toBe('1.55.14');
await testInfo.attach('host-versions.json', { body: JSON.stringify(info.system, null, 2), contentType: 'application/json' });
await expect(await request.post(`${fixture}/__test__/reset`)).toBeOK();
await context.route('**/*', async route => {
const request = route.request();
const url = new URL(request.url());
const path = url.pathname.replace(/^\/api\//, '/');
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 });
}
if (!['GET', 'HEAD', 'OPTIONS'].includes(request.method())) return route.fulfill({ json: {} });
if (path === '/extensions') {
const response = await route.fetch();
const extensions = (await response.json()).filter(path => path.startsWith('/extensions/kikotools/'));
return route.fulfill({ json: extensions });
}
if (path.startsWith('/extensions/kikotools/')) {
const source = resolve(web, decodeURIComponent(path.slice('/extensions/kikotools/'.length)));
if (!source.startsWith(web + sep)) return route.abort();
if (!/\.(js|css)$/.test(source)) return route.abort();
return route.fulfill({ body: await readFile(source), contentType: source.endsWith('.css') ? 'text/css' : 'text/javascript' });
}
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 () => {
const { app } = await import('/scripts/app.js');
return !!app.graph && !!globalThis.LiteGraph?.registered_node_types?.KikoLocalImageLoader;
});
await page.evaluate(async () => {
const { app } = await import('/scripts/app.js');
app.graph.clear();
});
});
for (const name of ['WidthHeightSelector', 'EmptyLatentBatch']) {
test(`${name}: shared swap survives renderers, zoom, themes and workflow reload`, async ({ page }, testInfo) => {
await page.evaluate(async name => {
const { app } = await import('/scripts/app.js');
const node = LiteGraph.createNode(name);
app.graph.add(node);
const preset = node.widgets.find(widget => widget.name === 'preset');
const options = typeof preset.options.values === 'function' ? preset.options.values() : preset.options.values;
preset.value = options.find(option => option.startsWith('832×1216'));
preset.callback(preset.value, app.canvas, node, [0, 0], null);
const batch = node.widgets.find(widget => widget.name === 'batch_size');
if (batch) batch.value = 3;
app.canvas.centerOnNode(node);
}, name);
const panel = page.locator('.ca-dimension-swap');
const button = panel.getByRole('button', { name: 'Swap width and height', exact: true });
await expect(button).toBeVisible();
for (const renderer of [false, true, false]) {
const before = await page.evaluate(async ({ name, renderer }) => {
const { app } = await import('/scripts/app.js');
await app.ui.settings.setSettingValue('Comfy.VueNodes.Enabled', renderer);
const node = app.graph._nodes.find(node => node.type === name);
app.canvas.ds.scale = .7;
app.canvas.centerOnNode(node);
return node.widgets.filter(widget => ['width', 'height'].includes(widget.name)).map(widget => widget.value);
}, { name, renderer });
await expect.poll(() => page.evaluate(() => LiteGraph.vueNodesMode)).toBe(renderer);
await expect(panel).toHaveCount(1);
await button.focus();
await page.keyboard.press('Enter');
expect(await page.evaluate(async name => {
const { app } = await import('/scripts/app.js');
return app.graph._nodes.find(node => node.type === name).widgets.filter(widget => ['width', 'height'].includes(widget.name)).map(widget => widget.value);
}, name)).toEqual([...before].reverse());
}
await page.evaluate(async name => {
const { app } = await import('/scripts/app.js');
const node = app.graph._nodes.find(node => node.type === name);
node.setSize([220, 280]);
app.canvas.ds.scale = 1;
app.canvas.centerOnNode(node);
}, name);
expect(await panel.evaluate(element => element.scrollWidth <= element.clientWidth)).toBe(true);
const narrow = await button.boundingBox();
await page.evaluate(async name => {
const { app } = await import('/scripts/app.js');
app.graph._nodes.find(node => node.type === name).setSize([500, 300]);
}, name);
await expect.poll(async () => (await button.boundingBox()).width).toBeGreaterThan(narrow.width);
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(panel).toHaveAttribute('data-ca-theme', theme);
await page.screenshot({ path: testInfo.outputPath(`${name}-${theme}.png`) });
}
const restored = await page.evaluate(async name => {
const { app } = await import('/scripts/app.js');
const workflow = app.graph.serialize();
const saved = workflow.nodes.find(node => node.type === name);
const nativeCount = name === 'EmptyLatentBatch' ? 4 : 3;
if (saved.widgets_values.slice(nativeCount).some(value => value !== '' && value != null)) throw new Error('Swap UI leaked into workflow values');
await app.loadGraphData(workflow);
const node = app.graph._nodes.find(node => node.type === name);
app.canvas.centerOnNode(node);
return { saved: saved.widgets_values.slice(0, nativeCount), restored: node.widgets.filter(widget => ['preset', 'width', 'height', 'batch_size'].includes(widget.name)).map(widget => widget.value), size: Array.from(node.size), savedSize: Array.from(saved.size) };
}, name);
expect(restored.restored).toEqual(restored.saved);
expect(restored.size).toEqual(restored.savedSize);
await expect(panel).toHaveCount(1);
await page.evaluate(async name => {
const { app } = await import('/scripts/app.js');
const node = app.graph._nodes.find(node => node.type === name);
window.removedSwap = node;
app.graph.remove(node);
}, name);
await expect(panel).toHaveCount(0);
expect(await page.evaluate(() => removedSwap.swapScope.signal.aborted)).toBe(true);
});
}
test('workflow timer preserves appearance and elapsed time through renderer switching and settings', async ({ page }, testInfo) => {
await page.evaluate(async () => {
const { app } = await import('/scripts/app.js');
const { api } = await import('/scripts/api.js');
await app.ui.settings.setSettingValue('kikotools.workflow_timer.color', '#123456');
const node = LiteGraph.createNode('KikoWorkflowTimer');
app.graph.add(node);
node.color = '#334455';
node.bgcolor = '#556677';
node.setSize([440, 240]);
node.properties.elapsed_time_str = '01:02:345';
node.onConfigure({ properties: node.properties });
app.canvas.centerOnNode(node);
api.dispatchEvent(new CustomEvent('execution_start'));
});
const panel = page.locator('.ca-workflow-timer');
const timer = panel.getByRole('timer', { name: 'Elapsed workflow time' });
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);
}, renderer);
await expect.poll(() => page.evaluate(() => LiteGraph.vueNodesMode)).toBe(renderer);
await expect(panel).toHaveCount(1);
await expect(timer).toHaveClass(/running/);
await expect(timer).toHaveCSS('color', 'rgb(18, 52, 86)');
}
await page.evaluate(async () => {
const { api } = await import('/scripts/api.js');
api.dispatchEvent(new CustomEvent('execution_interrupted'));
});
const stopped = await timer.textContent();
const initialFontSize = await timer.evaluate(element => parseFloat(getComputedStyle(element).fontSize));
await page.evaluate(async () => {
const { app } = await import('/scripts/app.js');
app.graph._nodes.find(node => node.type === 'KikoWorkflowTimer').setSize([220, 160]);
});
await expect.poll(() => timer.evaluate(element => parseFloat(getComputedStyle(element).fontSize))).toBeLessThan(initialFontSize);
expect(await timer.evaluate(element => element.scrollWidth <= element.clientWidth)).toBe(true);
await page.evaluate(async () => {
const { app } = await import('/scripts/app.js');
app.graph._nodes.find(node => node.type === 'KikoWorkflowTimer').setSize([600, 320]);
});
await expect.poll(() => timer.evaluate(element => parseFloat(getComputedStyle(element).fontSize))).toBeGreaterThan(initialFontSize);
await page.evaluate(async () => {
const { app } = await import('/scripts/app.js');
app.graph._nodes.find(node => node.type === 'KikoWorkflowTimer').setSize([440, 240]);
});
await page.evaluate(async () => {
const { app } = await import('/scripts/app.js');
await app.ui.settings.setSettingValue('kikotools.workflow_timer.color', '#0877bb');
});
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(panel).toHaveAttribute('data-ca-theme', theme);
await page.screenshot({ path: testInfo.outputPath(`timer-${theme}.png`) });
}
await page.evaluate(async () => {
const { app } = await import('/scripts/app.js');
const workflow = app.graph.serialize();
await app.loadGraphData(workflow);
const node = app.graph._nodes.find(node => node.type === 'KikoWorkflowTimer');
if (node.color !== '#334455' || node.bgcolor !== '#556677') throw new Error('Custom node colors changed');
if (node.size[0] !== 440 || node.size[1] !== 240) throw new Error('Saved timer dimensions changed');
app.canvas.centerOnNode(node);
});
await expect(timer).toHaveText(stopped);
await page.getByRole('button', { name: 'Settings (Ctrl + ,)', exact: true }).click();
const search = page.getByPlaceholder(/search/i);
await expect(search).toBeVisible();
await search.fill('Workflow Timer');
await page.getByRole('button', { name: 'Choose timer color', exact: true }).click();
const dialog = page.getByRole('dialog', { name: 'Timer color', exact: true });
await expect(dialog).toBeVisible();
await dialog.getByLabel('Hex color', { exact: true }).fill('#4a90d9');
await page.keyboard.press('Tab');
await expect(timer).toHaveCSS('color', 'rgb(74, 144, 217)');
await page.screenshot({ path: testInfo.outputPath('timer-color-picker.png') });
await page.keyboard.press('Escape');
await expect(dialog).toBeHidden();
await expect(page.getByRole('button', { name: 'Choose timer color', exact: true })).toBeFocused();
await page.evaluate(async () => {
const { app } = await import('/scripts/app.js');
const node = app.graph._nodes.find(node => node.type === 'KikoWorkflowTimer');
window.removedTimer = node;
app.graph.remove(node);
});
await expect(panel).toHaveCount(0);
expect(await page.evaluate(() => removedTimer.timerScope.signal.aborted)).toBe(true);
});
test('seed history survives renderer switching, resizing and workflow reload', async ({ page }, testInfo) => {
await page.evaluate(async () => {
const { app } = await import('/scripts/app.js');
const node = LiteGraph.createNode('SeedHistory');
app.graph.add(node);
node.setSize([380, 560]);
for (let seed = 100; seed < 110; seed++) node.addSeedToHistory(seed);
app.canvas.centerOnNode(node);
});
const panel = page.locator('.ca-seed-history');
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);
}, renderer);
await expect.poll(() => page.evaluate(() => LiteGraph.vueNodesMode)).toBe(renderer);
await expect(panel).toHaveCount(1);
await panel.getByRole('button', { name: 'Use seed 105', exact: true }).focus();
await page.keyboard.press('Enter');
await expect(panel.getByRole('button', { name: 'Use seed 105', exact: true })).toHaveAttribute('aria-pressed', 'true');
await expect(panel.getByRole('status')).toHaveText('Loaded: 105');
expect(await panel.evaluate(element => element.scrollWidth <= element.clientWidth + 1)).toBe(true);
}
await page.evaluate(async () => {
const { app } = await import('/scripts/app.js');
app.graph._nodes.find(node => node.type === 'SeedHistory').setSize([280, 350]);
});
const list = panel.getByRole('list', { name: 'Recent seeds' });
await expect.poll(() => list.evaluate(element => element.scrollHeight > element.clientHeight)).toBe(true);
const smallHeight = await list.evaluate(element => element.clientHeight);
await page.evaluate(async () => {
const { app } = await import('/scripts/app.js');
app.graph._nodes.find(node => node.type === 'SeedHistory').setSize([420, 650]);
});
await expect.poll(() => list.evaluate(element => element.clientHeight)).toBeGreaterThan(smallHeight);
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(panel).toHaveAttribute('data-ca-theme', theme);
await page.screenshot({ path: testInfo.outputPath(`seed-${theme}.png`) });
}
await page.evaluate(async () => {
const { app } = await import('/scripts/app.js');
const workflow = app.graph.serialize();
const saved = workflow.nodes.find(node => node.type === 'SeedHistory');
if (saved.widgets_values[0] !== 105 || saved.widgets_values.slice(2).some(value => value !== '' && value != null)) throw new Error('History UI leaked into serialized widget values');
await app.loadGraphData(workflow);
const node = app.graph._nodes.find(node => node.type === 'SeedHistory');
if (node.size[0] !== saved.size[0] || node.size[1] !== saved.size[1]) throw new Error('Saved node size changed');
app.canvas.centerOnNode(node);
});
await expect(panel).toHaveCount(1);
await expect(panel.getByRole('button', { name: 'Use seed 105', exact: true })).toBeVisible();
await page.evaluate(async () => {
const { app } = await import('/scripts/app.js');
const node = app.graph._nodes.find(node => node.type === 'SeedHistory');
window.removedSeedNode = node;
app.graph.remove(node);
});
await expect(panel).toHaveCount(0);
expect(await page.evaluate(() => removedSeedNode.seedScope.signal.aborted && removedSeedNode.seedValueWatcher === null)).toBe(true);
});
test('saved-image window survives real renderer switching and is removed with its owner', async ({ page, request }, testInfo) => {
const result = await request.post(`${fixture}/__test__/execute/KikoSaveImage`, { data: { filename_prefix: 'Real host viewer' } });
await expect(result).toBeOK();
const message = (await result.json()).ui;
const id = await page.evaluate(async message => {
const { app } = await import('/scripts/app.js');
const node = LiteGraph.createNode('KikoSaveImage');
app.graph.add(node);
node.onExecuted(message);
app.canvas.centerOnNode(node);
return node.id;
}, message);
const viewer = page.getByRole('dialog', { name: 'Saved images', exact: true });
await expect(viewer).toBeVisible();
await expect.poll(() => viewer.locator('img').evaluate(image => image.complete && image.naturalWidth > 0)).toBe(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);
}, renderer);
await expect.poll(() => page.evaluate(() => LiteGraph.vueNodesMode)).toBe(renderer);
await expect(viewer).toHaveCount(1);
await viewer.getByTitle('Close', { exact: true }).click();
await page.getByRole('button', { name: 'Show images', exact: true }).click();
await expect(viewer).toBeVisible();
expect(await page.evaluate(async id => {
const { app } = await import('/scripts/app.js');
return app.graph.serialize().nodes.find(node => String(node.id) === String(id)).widgets_values.includes('Show images');
}, id)).toBe(false);
}
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(viewer).toHaveAttribute('data-ca-theme', theme);
const info = await viewer.locator('.kiko-image-info').boundingBox();
const content = await viewer.locator('.kiko-viewer-container').boundingBox();
expect(info.y + info.height).toBeLessThanOrEqual(content.y + content.height);
await page.screenshot({ path: testInfo.outputPath(`viewer-${theme}.png`) });
}
await page.evaluate(async id => {
const { app } = await import('/scripts/app.js');
app.graph.remove(app.graph.getNodeById(id));
}, id);
await expect(viewer).toHaveCount(0);
});
test('actual host preserves the widget through renderer changes, theme changes and save/reload', async ({ page, request }, testInfo) => {
await expect(await request.post(`${fixture}/__test__/reset`, { data: { extra_images: 52 } })).toBeOK();
await page.evaluate(async () => {
const { app } = await import('/scripts/app.js');
await app.ui.settings.setSettingValue('Comfy.VueNodes.Enabled', false);
const node = LiteGraph.createNode('KikoLocalImageLoader');
node.pos = [250, 140];
app.graph.add(node);
app.canvas.centerOnNode(node);
});
const loader = page.locator('.ca-image-browser');
await expect(loader).toBeVisible();
await expect(loader.getByAltText('cat & dog.png', { exact: true })).toBeVisible();
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);
}, renderer);
await expect.poll(() => page.evaluate(() => LiteGraph.vueNodesMode)).toBe(renderer);
await expect(loader).toHaveCount(1);
await expect(loader).toBeVisible();
await loader.getByRole('button', { name: 'Select image cat & dog.png', exact: true }).click();
await expect(loader.locator('[data-selection]')).toHaveText('cat & dog.png');
const node = await page.evaluate(async () => {
const { app } = await import('/scripts/app.js');
return app.graph.serialize().nodes.find(node => node.type === 'KikoLocalImageLoader');
});
expect(node).toBeTruthy();
expect(node.widgets_values || []).not.toContainEqual(expect.objectContaining({ className: 'ca-image-browser' }));
await loader.getByRole('button', { name: 'Open folder bulk', exact: true }).click();
await expect(loader.locator('.kiko-card-info-panel')).toHaveCount(50);
const media = await loader.locator('.kiko-card-media-wrapper').nth(1).boundingBox();
expect(media.height / media.width).toBeCloseTo(1, 1);
expect(media.height).toBeGreaterThan(150);
expect(await loader.locator('.kiko-cardholder').evaluate(element => element.clientHeight)).toBeGreaterThanOrEqual(await loader.locator('.kiko-card-media-wrapper').nth(1).evaluate(element => element.offsetHeight));
expect(await loader.locator('.kiko-cardholder').evaluate(element => element.scrollHeight > element.clientHeight)).toBe(true);
await page.screenshot({ path: testInfo.outputPath(renderer ? 'nodes2.png' : 'classic.png') });
await loader.getByRole('button', { name: 'Open folder ..', exact: true }).click();
await expect(loader.getByAltText('cat & dog.png', { exact: true })).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(loader).toHaveAttribute('data-ca-theme', theme);
}
await page.evaluate(async () => {
const { app } = await import('/scripts/app.js');
const workflow = app.graph.serialize();
await app.loadGraphData(workflow);
app.canvas.centerOnNode(app.graph._nodes[0]);
});
await expect(loader).toHaveCount(1);
await expect(loader.getByAltText('cat & dog.png', { exact: true })).toBeVisible();
await page.evaluate(async () => {
const { app } = await import('/scripts/app.js');
app.graph.remove(app.graph._nodes[0]);
});
await expect(loader).toHaveCount(0);
});
test('Node Dimensions applies and persists across Classic and Nodes 2.0', async ({ page }, testInfo) => {
await page.evaluate(async () => {
const { app } = await import('/scripts/app.js');
const node = LiteGraph.createNode('WidthHeightSelector');
app.graph.add(node);
node.pos = [300, 250];
app.canvas.centerOnNode(node);
});
const dialog = page.getByRole('dialog', { name: 'Node Dimensions', 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);
const point = await page.evaluate(async () => {
const { app } = await import('/scripts/app.js');
const node = app.graph._nodes[0];
const canvas = app.canvas.canvas.getBoundingClientRect();
return { x: canvas.left + (node.pos[0] + app.canvas.ds.offset[0] + 60) * app.canvas.ds.scale, y: canvas.top + (node.pos[1] + app.canvas.ds.offset[1] - 15) * app.canvas.ds.scale };
});
await page.mouse.click(point.x, point.y, { button: 'right' });
await page.getByText('🎨 Node Dimensions', { exact: true }).click();
await expect(dialog).toBeVisible();
await dialog.getByLabel('Width (px)', { exact: true }).fill('900');
await dialog.getByLabel('Height (px)', { exact: true }).fill('600');
await page.keyboard.press('Enter');
await expect(dialog).toHaveCount(0);
expect(await page.evaluate(async () => {
const { app } = await import('/scripts/app.js');
return Array.from(app.graph._nodes[0].size);
})).toEqual([900, 600]);
await page.evaluate(async () => {
const { app } = await import('/scripts/app.js');
window.KikoToolsExtensions.showNodeDimensionsDialog(app.graph._nodes[0]);
});
await dialog.getByLabel('Width (px)', { exact: true }).fill('1200');
await page.keyboard.press('Escape');
expect(await page.evaluate(async () => {
const { app } = await import('/scripts/app.js');
return Array.from(app.graph._nodes[0].size);
})).toEqual([900, 600]);
}
const size = await page.evaluate(async () => {
const { app } = await import('/scripts/app.js');
const workflow = app.graph.serialize();
await app.loadGraphData(workflow);
app.canvas.centerOnNode(app.graph._nodes[0]);
window.KikoToolsExtensions.showNodeDimensionsDialog(app.graph._nodes[0]);
return Array.from(app.graph._nodes[0].size);
});
expect(size).toEqual([900, 600]);
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(`node-dimensions-${theme}.png`) });
}
await page.evaluate(async () => {
const { app } = await import('/scripts/app.js');
app.graph.remove(app.graph._nodes[0]);
});
await expect(dialog).toHaveCount(0);
});
for (const name of ['DisplayText', 'DisplayAny']) {
test(`${name}: shared output persists through renderers, themes and legacy reload`, async ({ page }, testInfo) => {
const output = name === 'DisplayText'
? 'Positive prompt: A blue cabin beside a quiet lake, soft morning light, detailed reflections.\nNegative prompt: blur, noise, watermark'
: Array.from({ length: 45 }, (_, index) => `Line ${index}: tensor / diagnostic output`).join('\n');
await page.evaluate(async ({ name, output }) => {
const { app } = await import('/scripts/app.js');
const node = LiteGraph.createNode(name);
app.graph.add(node);
node.setSize([560, 520]);
const mode = node.widgets.find(widget => widget.name === 'mode');
if (mode) mode.value = 'tensor shape';
node.onExecuted({ text: [output] });
app.canvas.centerOnNode(node);
window.displayCopies = [];
Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText: async text => window.displayCopies.push(text) } });
}, { name, output });
const panel = page.locator('.ca-text-output');
await expect(panel).toHaveCount(1);
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.ds.scale = .85;
app.canvas.centerOnNode(app.graph._nodes[0]);
}, renderer);
await expect.poll(() => page.evaluate(() => LiteGraph.vueNodesMode)).toBe(renderer);
await expect(panel).toBeVisible();
await expect(panel).toHaveCount(1);
await panel.getByRole('button', { name: name === 'DisplayText' ? 'Copy negative' : 'Copy value', exact: true }).focus();
await page.keyboard.press('Enter');
await expect(panel.getByRole('status')).toHaveText('Copied.');
}
expect(await page.evaluate(() => window.displayCopies)).toEqual(Array(3).fill(name === 'DisplayText' ? 'blur, noise, watermark' : output));
const restored = await page.evaluate(async () => {
const { app } = await import('/scripts/app.js');
const workflow = app.graph.serialize();
const saved = workflow.nodes[0];
await app.loadGraphData(workflow);
app.canvas.centerOnNode(app.graph._nodes[0]);
return { snapshot: saved.kikoDisplay, size: Array.from(app.graph._nodes[0].size), mode: app.graph._nodes[0].widgets.find(widget => widget.name === 'mode')?.value };
});
expect(restored.snapshot).toEqual({ version: 1, text: output });
expect(restored.size).toEqual([560, 520]);
if (name === 'DisplayAny') {
expect(restored.mode).toBe('tensor shape');
await expect(panel.getByLabel('Value output', { exact: true })).toHaveValue(output);
} else {
await expect(panel.getByLabel('Positive prompt', { exact: true })).toHaveValue('A blue cabin beside a quiet lake, soft morning light, detailed reflections.');
await expect(panel.getByLabel('Negative prompt', { exact: true })).toHaveValue('blur, noise, watermark');
}
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(panel).toHaveAttribute('data-ca-theme', theme);
await page.screenshot({ path: testInfo.outputPath(`${name}-${theme}.png`) });
}
await page.evaluate(async () => {
const { app } = await import('/scripts/app.js');
app.graph._nodes[0].setSize([280, 520]);
app.canvas.centerOnNode(app.graph._nodes[0]);
});
expect(await panel.evaluate(element => element.scrollWidth <= element.clientWidth)).toBe(true);
await page.evaluate(async name => {
const { app } = await import('/scripts/app.js');
const workflow = app.graph.serialize();
delete workflow.nodes[0].kikoDisplay;
workflow.nodes[0].widgets_values = name === 'DisplayText'
? ['✓ Positive Prompt', 'legacy positive', null, '✗ Negative Prompt', 'legacy negative', null]
: ['tensor shape', 'legacy first', 'legacy second', null];
await app.loadGraphData(workflow);
app.canvas.centerOnNode(app.graph._nodes[0]);
}, name);
await expect(panel).toHaveCount(1);
if (name === 'DisplayText') {
await expect(panel.getByLabel('Positive prompt', { exact: true })).toHaveValue('legacy positive');
await expect(panel.getByLabel('Negative prompt', { exact: true })).toHaveValue('legacy negative');
} else {
await expect(panel.getByLabel('Value output', { exact: true })).toHaveValue('legacy first\nlegacy second');
expect(await page.evaluate(async () => {
const { app } = await import('/scripts/app.js');
return app.graph._nodes[0].widgets.find(widget => widget.name === 'mode').value;
})).toBe('tensor shape');
}
await page.evaluate(async () => {
const { app } = await import('/scripts/app.js');
const node = app.graph._nodes[0];
window.removedDisplay = node;
app.graph.remove(node);
});
await expect(panel).toHaveCount(0);
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('<lora:in');
await list.getByRole('option').click();
await expect(prompt).toHaveValue('<lora:styles/ink:1.0>, ');
}
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('<lora:styles/ink:1.0>, ');
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);
}
}
});
test('Custom Colors preserves node colors across renderers and workflow reload', async ({ page }, testInfo) => {
await page.evaluate(async () => {
const { app } = await import('/scripts/app.js');
const node = LiteGraph.createNode('EmptyLatentImage');
app.graph.add(node);
node.bgcolor = '#123456';
app.canvas.centerOnNode(node);
});
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);
}, renderer);
await expect.poll(() => page.evaluate(() => LiteGraph.vueNodesMode)).toBe(renderer);
await page.evaluate(async () => {
const { app } = await import('/scripts/app.js');
const node = app.graph._nodes[0];
const menu = app.collectNodeMenuItems(node).find(item => item?.content === '🫶 Custom Colors');
menu.submenu.options.find(item => item.content === '🫶 Custom Full').callback();
});
const dialog = page.getByRole('dialog', { name: 'Custom full color', exact: true });
await expect(dialog).toBeVisible();
await dialog.getByLabel('Hex color', { exact: true }).fill('#112233');
await dialog.getByLabel('Hex color', { exact: true }).press('Tab');
if (renderer) {
for (const [color, theme] of [['#fff', '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.screenshot({ path: testInfo.outputPath(`custom-colors-${theme}.png`) });
}
}
await page.keyboard.press('Escape');
await expect(page.locator('ca-color-picker')).toHaveCount(0);
expect(await page.evaluate(async () => {
const { app } = await import('/scripts/app.js');
const workflow = app.graph.serialize();
await app.loadGraphData(workflow);
const node = app.graph._nodes[0];
return { color: node.color, bgcolor: node.bgcolor };
})).toEqual({ color: '#253647', bgcolor: '#112233' });
}
await page.evaluate(async () => {
const { app } = await import('/scripts/app.js');
const node = app.graph._nodes[0];
app.collectNodeMenuItems(node).find(item => item?.content === '🫶 Custom Colors').submenu.options[0].callback();
app.graph.remove(node);
});
await expect(page.locator('dialog.ca-color-dialog')).toHaveCount(0);
});
+54
View File
@@ -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);
});
+9
View File
@@ -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 });
});
+39
View File
@@ -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; }
});
+76
View File
@@ -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 ['', '<!doctype html><html>error</html>', '<script>alert(1)</script>', '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);
});
+14 -1
View File
@@ -2,7 +2,9 @@ import test from 'node:test';
import assert from 'node:assert/strict';
import { join } from 'node:path';
const sourceRoot = join(process.cwd(), 'web');
import { checkCoverage } from '../../tools/testing/frontend-coverage.mjs';
import { checkCoverage, discoverSources } from '../../tools/testing/frontend-coverage.mjs';
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
const metrics = value => Object.fromEntries(['lines', 'branches', 'functions', 'statements'].map(key => [key, { pct: value }]));
const summary = { total: metrics(90), [join(sourceRoot, 'a.js')]: metrics(85) };
const baseline = { 'a.js': { lines: 80, branches: 80, functions: 80, statements: 80 } };
@@ -20,3 +22,14 @@ test('coverage gate rejects invalid baselines and missing metrics', () => {
assert.throws(() => checkCoverage(summary, { 'a.js': { lines: -1 } }, sourceRoot, ['a.js']), /baseline/i);
assert.match(checkCoverage({ total: metrics(100), [join(sourceRoot, 'a.js')]: {} }, baseline, sourceRoot, ['a.js']).join('\n'), /lines/);
});
test('source discovery includes nested UI modules in the coverage gate', () => {
const root = mkdtempSync(join(tmpdir(), 'kiko-coverage-'));
try {
mkdirSync(join(root, 'ui'));
writeFileSync(join(root, 'entry.js'), '');
writeFileSync(join(root, 'ui/core.js'), '');
writeFileSync(join(root, 'ui/theme.css'), '');
assert.deepEqual(discoverSources(root), ['entry.js', 'ui/core.js']);
} finally { rmSync(root, { recursive: true, force: true }); }
});
+60
View File
@@ -0,0 +1,60 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { bindDimensionPresets, dimensionsFromPreset, swapDimensions } from '../../web/ui/dimensions.js';
const app = { canvas: {} };
test('resolution parsing accepts supplied formats and rejects invalid dimensions', () => {
assert.deepEqual(dimensionsFromPreset(' 928x1664 - Qwen'), [928, 1664]);
assert.deepEqual(dimensionsFromPreset('1664×928'), [1664, 928]);
for (const value of [null, {}, 'custom', '0x100', '12.5x100', 'x100', '999999999999999999999x100']) assert.equal(dimensionsFromPreset(value), null);
});
test('preset callbacks preserve returns and default host context without changing custom dimensions', () => {
const calls = [];
const node = { widgets: [
{ name: 'preset', callback() { return 'original'; } },
{ name: 'width', value: 64, callback(value, canvas, owner) { calls.push([this.name, value, canvas, owner]); } },
{ name: 'height', value: 128 },
] };
bindDimensionPresets(node, app);
assert.equal(node.widgets[0].callback('custom'), 'original');
assert.equal(node.widgets[0].callback('928×1664 - Qwen'), 'original');
assert.deepEqual(calls, [['width', 928, app.canvas, node]]);
assert.equal(node.widgets[2].value, 1664);
});
test('incomplete widgets and unavailable reverse options are safe', () => {
bindDimensionPresets({ widgets: [] }, app);
swapDimensions({ widgets: [] }, app);
const node = { widgets: [{ name: 'preset', value: '640×960' }, { name: 'width', value: 1 }, { name: 'height', value: 2 }] };
bindDimensionPresets(node, app);
swapDimensions(node, app);
assert.deepEqual(node.widgets.map(widget => widget.value), ['custom', 960, 640]);
node.widgets.pop();
node.widgets[0].callback('640×960');
swapDimensions(node, app);
assert.equal(node.widgets[1].value, 960);
});
test('dynamic and array option lists match reverse dimensions', () => {
for (const options of [['custom', '960x640'], { values: () => ['custom', '960×640'] }]) {
const node = { widgets: [{ name: 'preset', value: '640×960', options }, { name: 'width', value: 1 }, { name: 'height', value: 2 }] };
bindDimensionPresets(node, app);
swapDimensions(node, app);
assert.equal(node.widgets[0].value, Array.isArray(options) ? '960x640' : '960×640');
}
});
test('a throwing or recursive preset callback does not leave swapping locked', () => {
let fail = true;
const node = { widgets: [
{ name: 'preset', value: '640×960', options: { values: ['640×960', '960×640'] }, callback() { swapDimensions(node, app); if (fail) throw new Error('callback failed'); } },
{ name: 'width', value: 640 }, { name: 'height', value: 960 },
] };
bindDimensionPresets(node, app);
assert.throws(() => swapDimensions(node, app), /callback failed/);
fail = false;
swapDimensions(node, app);
assert.deepEqual(node.widgets.map(widget => widget.value), ['640×960', 640, 960]);
});
+38
View File
@@ -0,0 +1,38 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { outputSections } from '../../web/views/text-output.js';
import { restoreDisplayOutput } from '../../web/controllers/display-node.js';
test('display text recognizes paired prompts and preserves ordinary text verbatim', () => {
assert.deepEqual(outputSections('text', 'Positive prompt: bright ** metadata\nNegative prompt: blur ** metadata').map(section => section.text), ['bright', 'blur']);
assert.deepEqual(outputSections('text', 'POSITIVE PROMPT: sun\nNEGATIVE PROMPT: rain').map(section => section.text), ['sun', 'rain']);
for (const text of ['', 'Positive prompt: alone', '<script>hello</script>\nnext']) {
assert.deepEqual(outputSections('text', text).map(section => section.text), [text]);
}
const long = Array.from({ length: 45 }, (_, index) => `Line ${index}`).join('\n');
assert.deepEqual(outputSections('any', long).map(section => section.text), [long]);
});
test('versioned display snapshots preserve complete output including empty text', () => {
for (const kind of ['text', 'any']) {
for (const text of ['', 'mode\nfull\noutput']) {
assert.equal(restoreDisplayOutput({ kikoDisplay: { version: 1, text }, widgets_values: ['stale'] }, kind, false), text);
}
assert.equal(restoreDisplayOutput({}, kind, false), null);
assert.equal(restoreDisplayOutput({ widgets_values: [] }, kind, false), null);
}
});
test('legacy display workflows restore prompt pairs, converted inputs and every chunk', () => {
assert.equal(restoreDisplayOutput({ widgets_values: ['✓ Positive Prompt', 'sun', null, '✗ Negative Prompt', 'rain', null] }, 'text', false), 'Positive prompt: sun\nNegative prompt: rain');
assert.equal(restoreDisplayOutput({ widgets_values: ['converted', 'saved text', null] }, 'text', true), 'saved text');
assert.equal(restoreDisplayOutput({ widgets_values: ['raw value', 'first\nchunk', 'second\nchunk', null] }, 'any', false), 'first\nchunk\nsecond\nchunk');
assert.equal(restoreDisplayOutput({ widgets_values: ['converted', 'tensor shape', 'shape', null] }, 'any', true), 'shape');
assert.equal(restoreDisplayOutput({ widgets_values: ['tensor shape'] }, 'any', false), null);
assert.equal(restoreDisplayOutput({ widgets_values: [''] }, 'text', false), '');
assert.equal(restoreDisplayOutput({ kikoDisplay: { version: 99, text: 'unknown' }, widgets_values: ['legacy'] }, 'text', false), 'legacy');
});
test('prompt detection retains leading markdown markers accepted by existing workflows', () => {
assert.equal(outputSections('text', 'Positive prompt: **sun\nNegative prompt: rain')[0].text, '**sun');
});
+65 -13
View File
@@ -3,6 +3,7 @@ import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import vm from 'node:vm';
import { bindDimensionPresets, extractResolutionFromPreset, swapDimensions } from '../../web/ui/dimensions.js';
function load(file, extra = {}) {
const extensions = [];
@@ -19,8 +20,8 @@ function load(file, extra = {}) {
return { extension: extensions[0], app, timers, context };
}
async function dimensionNode(preset, options = ['custom', '832×1216 - portrait', '1216×832 - landscape']) {
const { extension, app } = load('width_height_swap.js');
async function dimensionNode(preset, options = ['custom', '832×1216 - portrait', '1216×832 - landscape'], file = 'width_height_swap.js') {
const { extension, app } = load(file, { bindDimensionPresets, extractResolutionFromPreset, swapDimensions, mountDimensionSwap() {} });
let originalCreated = 0;
class Node {
constructor() {
@@ -33,15 +34,17 @@ async function dimensionNode(preset, options = ['custom', '832×1216 - portrait'
}
onNodeCreated() { originalCreated++; }
}
await extension.beforeRegisterNodeDef(Node, { name: 'WidthHeightSelector' }, app);
await extension.beforeRegisterNodeDef(Node, { name: file === 'width_height_swap.js' ? 'WidthHeightSelector' : 'EmptyLatentBatch' }, app);
Node.prototype.createSwapButton = () => {};
const node = new Node();
node.onNodeCreated();
assert.equal(originalCreated, 1);
return node;
}
test('custom dimensions swap and callbacks receive the actual node', async () => {
const node = await dimensionNode('custom');
for (const file of ['width_height_swap.js', 'empty_latent_batch_swap.js']) {
test(`${file}: custom dimensions swap and callbacks receive the actual node`, async () => {
const node = await dimensionNode('custom', undefined, file);
const calls = [];
for (const widget of node.widgets.slice(1)) widget.callback = (value, canvas, owner) => calls.push({ value, owner });
node.swapDimensions();
@@ -50,34 +53,83 @@ test('custom dimensions swap and callbacks receive the actual node', async () =>
for (const call of calls) assert.equal(call.owner, node);
});
test('formatted preset swaps to its matching landscape preset without throwing', async () => {
const node = await dimensionNode('832×1216 - portrait');
test(`${file}: formatted preset swaps to its matching landscape preset without throwing`, async () => {
const node = await dimensionNode('832×1216 - portrait', undefined, file);
node.swapDimensions();
assert.deepEqual(node.widgets.map(widget => widget.value), ['1216×832 - landscape', 1216, 832]);
});
test('a preset without a reverse switches to custom dimensions', async () => {
const node = await dimensionNode('832×1216 - portrait', ['custom', '832×1216 - portrait']);
test(`${file}: a preset without a reverse switches to custom dimensions`, async () => {
const node = await dimensionNode('832×1216 - portrait', ['custom', '832×1216 - portrait'], file);
node.swapDimensions();
assert.deepEqual(node.widgets.map(widget => widget.value), ['custom', 1216, 832]);
});
test('preset changes preserve original callbacks and update both dimensions', async () => {
const node = await dimensionNode('custom');
test(`${file}: preset changes preserve original callbacks and update both dimensions`, async () => {
const node = await dimensionNode('custom', undefined, file);
node.widgets[0].callback('1216×832 - landscape', {}, node);
assert.deepEqual(node.widgets.slice(1).map(widget => widget.value), [1216, 832]);
assert.equal(node.extractResolutionFromPreset('custom'), null);
assert.equal(node.originalPresetCalls, 1);
});
test('unrelated node definitions retain their lifecycle methods', async () => {
const { extension, app } = load('width_height_swap.js');
test(`${file}: a preset swap notifies each dimension callback exactly once`, async () => {
const node = await dimensionNode('832×1216 - portrait', undefined, file);
const calls = [];
for (const widget of node.widgets.slice(1)) widget.callback = function (value, canvas, owner) { calls.push({ name: this.name, value, owner }); };
node.swapDimensions();
assert.deepEqual(calls.map(call => [call.name, call.value]), [['width', 1216], ['height', 832]]);
assert.equal(node.originalPresetCalls, 1);
for (const call of calls) assert.equal(call.owner, node);
});
test(`${file}: ASCII resolutions match reversed presets and unfamiliar formatted presets update dimensions`, async () => {
const node = await dimensionNode('832x1216 - portrait', ['custom', '832x1216 - portrait', '1216x832 - landscape'], file);
node.swapDimensions();
assert.deepEqual(node.widgets.map(widget => widget.value), ['1216x832 - landscape', 1216, 832]);
node.widgets[0].callback('2040×1080 - future preset', {}, node);
assert.deepEqual(node.widgets.slice(1).map(widget => widget.value), [2040, 1080]);
});
test(`${file}: malformed presets do not write invalid dimensions`, async () => {
const node = await dimensionNode('invalidx960', undefined, file);
node.swapDimensions();
assert.deepEqual(node.widgets.map(widget => widget.value), ['invalidx960', 832, 1216]);
});
test(`${file}: unrelated node definitions retain their lifecycle methods`, async () => {
const { extension, app } = load(file);
class Node { onNodeCreated() {} }
const original = Node.prototype.onNodeCreated;
await extension.beforeRegisterNodeDef(Node, { name: 'OtherNode' }, app);
assert.equal(Node.prototype.onNodeCreated, original);
});
test(`${file}: creation preserves hook arguments, results and existing canvas hooks`, async () => {
const { extension, app } = load(file, { bindDimensionPresets, extractResolutionFromPreset, swapDimensions, mountDimensionSwap() {} });
class Node {
constructor() { this.widgets = []; }
onNodeCreated(value) { this.received = value; return 'original result'; }
onDrawForeground() {}
onMouseDown() {}
onMouseMove() {}
}
const hooks = [Node.prototype.onDrawForeground, Node.prototype.onMouseDown, Node.prototype.onMouseMove];
await extension.beforeRegisterNodeDef(Node, { name: file === 'width_height_swap.js' ? 'WidthHeightSelector' : 'EmptyLatentBatch' }, app);
const node = new Node();
assert.equal(node.onNodeCreated('original argument'), 'original result');
assert.equal(node.received, 'original argument');
assert.deepEqual([Node.prototype.onDrawForeground, Node.prototype.onMouseDown, Node.prototype.onMouseMove], hooks);
});
test(`${file}: nodes without an existing creation hook initialize safely`, async () => {
const { extension, app } = load(file, { bindDimensionPresets, extractResolutionFromPreset, swapDimensions, mountDimensionSwap() {} });
class Node { constructor() { this.widgets = []; } }
await extension.beforeRegisterNodeDef(Node, { name: file === 'width_height_swap.js' ? 'WidthHeightSelector' : 'EmptyLatentBatch' }, app);
assert.equal(new Node().onNodeCreated(), undefined);
});
}
test('Gemini repeated execution restores the user color and removal cancels its timer', async () => {
const { extension, app, timers } = load('gemini_prompt_ui.js');
let removed = 0;
+20
View File
@@ -0,0 +1,20 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { fitWindowRect } from '../../web/ui/floating-window.js';
test('window bounds keep its controls reachable after moving outside the viewport', () => {
const rect = fitWindowRect({ x: -100, y: 900, width: 420, height: 400 }, { width: 1000, height: 700 });
assert.equal(rect.x, 8);
assert.equal(rect.y + rect.height, 692);
});
test('window fitting respects minimum sizes and small viewports', () => {
const small = fitWindowRect({ x: 0, y: 0, width: 10, height: 10 }, { width: 1000, height: 700 });
assert.equal(small.width, 280);
assert.equal(small.height, 200);
const narrow = fitWindowRect({ x: 800, y: 600, width: 900, height: 800 }, { width: 260, height: 180 });
assert.equal(narrow.width, 244);
assert.equal(narrow.height, 164);
assert.equal(narrow.x, 8);
assert.equal(narrow.y, 8);
});
+15
View File
@@ -0,0 +1,15 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { resolveNodeSize } from '../../web/views/node-dimensions.js';
test('node dimensions accept whole pixels and respect content minimums', () => {
assert.deepEqual(resolveNodeSize(['640', '480'], [180, 120]), [640, 480]);
assert.deepEqual(resolveNodeSize(['1', '10'], [180, 120]), [180, 120]);
});
test('node dimensions reject missing, fractional, unsafe and nonpositive values', () => {
for (const value of ['', ' ', '0', '-1', '1.5', 'Infinity', 'NaN', '9007199254740992']) {
assert.equal(resolveNodeSize([value, '480'], [180, 120]), null, value);
assert.equal(resolveNodeSize(['640', value], [180, 120]), null, value);
}
});
+42
View File
@@ -0,0 +1,42 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { createScope, themeFromColor } from '../../web/ui/core.js';
test('host background luminance determines mode without assuming palette names', () => {
for (const color of ['#fff', '#F4F4F6', 'rgb(240, 240, 240)']) assert.equal(themeFromColor(color), 'light');
for (const color of ['#222', '#202020', 'rgb(30, 32, 34)']) assert.equal(themeFromColor(color), 'dark');
assert.equal(themeFromColor(''), null);
assert.equal(themeFromColor('transparent'), null);
});
test('scope cancels requests and callbacks, removes listeners and disposes once', () => {
const scope = createScope();
const target = new EventTarget();
let events = 0;
let cleanups = 0;
scope.listen(target, 'change', () => events++);
scope.cleanup(() => cleanups++);
target.dispatchEvent(new Event('change'));
assert.equal(events, 1);
scope.dispose();
scope.dispose();
target.dispatchEvent(new Event('change'));
assert.equal(events, 1);
assert.equal(cleanups, 1);
assert.equal(scope.signal.aborted, true);
});
test('debounced work runs once with the latest value and stops after disposal', async () => {
const scope = createScope();
const values = [];
const change = scope.debounce(value => values.push(value), 1);
change('old');
change('new');
await new Promise(resolve => setTimeout(resolve, 20));
assert.deepEqual(values, ['new']);
change('cancelled');
scope.dispose();
change('after removal');
await new Promise(resolve => setTimeout(resolve, 20));
assert.deepEqual(values, ['new']);
});
+105
View File
@@ -0,0 +1,105 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { WorkflowClock, formatElapsed } from '../../web/ui/workflow-clock.js';
import { normalizeHex, hexToHsv, hsvToHex } from '../../web/ui/color.js';
function fixture() {
let now = 0;
const intervals = new Map();
const settings = new Map();
const clock = new WorkflowClock({ getSettingValue: id => settings.get(id) }, {
Date: { now: () => now },
setInterval(callback) { const id = Symbol(); intervals.set(id, callback); return id; },
clearInterval(id) { intervals.delete(id); },
});
const node = () => ({ properties: {}, timerDisplay: {
textContent: '',
style: { setProperty(name, value) { this[name] = value; } },
classList: { values: new Set(), toggle(name, enabled) { enabled ? this.values.add(name) : this.values.delete(name); } },
} });
return { clock, settings, intervals, node, advance(ms) { now += ms; for (const callback of intervals.values()) callback(); } };
}
test('elapsed formatting handles minutes, milliseconds and invalid durations', () => {
assert.equal(formatElapsed(0), '00:00:000');
assert.equal(formatElapsed(-1), '00:00:000');
assert.equal(formatElapsed(NaN), '00:00:000');
assert.equal(formatElapsed(61234.7), '01:01:234');
assert.equal(formatElapsed(3600000), '60:00:000');
});
test('one clock synchronizes late joining nodes and persists the final duration', () => {
const f = fixture();
const first = f.node();
f.clock.registerNode(first);
f.clock.start();
f.advance(1200);
f.clock.start();
const second = f.node();
f.clock.registerNode(second);
assert.equal(f.intervals.size, 1);
assert.equal(first.timerDisplay.textContent, '00:01:200');
assert.equal(second.timerDisplay.textContent, first.timerDisplay.textContent);
assert.equal(second.timerDisplay.classList.values.has('running'), true);
f.advance(34);
f.clock.stop();
f.clock.stop();
assert.equal(f.intervals.size, 0);
assert.equal(f.clock.intervalId, null);
for (const node of [first, second]) {
assert.equal(node.properties.elapsed_time_str, '00:01:234');
assert.equal(node.timerDisplay.classList.values.has('running'), false);
}
});
test('removing the final node releases polling while late nodes can rejoin an active run', () => {
const f = fixture();
f.clock.start();
assert.equal(f.intervals.size, 0);
f.advance(1000);
const first = f.node();
f.clock.registerNode(first);
assert.equal(first.timerDisplay.textContent, '00:01:000');
assert.equal(f.intervals.size, 1);
f.clock.unregisterNode(first);
assert.equal(f.intervals.size, 0);
f.advance(1000);
const next = f.node();
f.clock.registerNode(next);
assert.equal(next.timerDisplay.textContent, '00:02:000');
f.clock.stop();
f.clock.start();
assert.equal(next.timerDisplay.textContent, '00:00:000');
f.clock.unregisterNode(next);
assert.equal(f.intervals.size, 0);
});
test('configured colors and glow update during a run and saved durations restore', () => {
const f = fixture();
const node = f.node();
node.properties.elapsed_time_str = '02:03:456';
f.clock.registerNode(node);
assert.equal(node.timerDisplay.textContent, '02:03:456');
assert.equal(node.timerDisplay.style['--ca-timer-color'], '#0877bb');
f.clock.start();
f.settings.set('kikotools.workflow_timer.color', '#Ff9966');
f.settings.set('kikotools.workflow_timer.glow', false);
f.clock.refresh();
assert.equal(node.timerDisplay.style['--ca-timer-color'], '#ff9966');
assert.equal(node.timerDisplay.classList.values.has('no-glow'), true);
f.settings.set('kikotools.workflow_timer.color', 'invalid');
f.clock.refresh();
assert.equal(node.timerDisplay.style['--ca-timer-color'], '#0877bb');
});
test('hex colors normalize strictly and HSV conversion round trips representative colors', () => {
assert.equal(normalizeHex('Ff9966'), '#ff9966');
for (const invalid of ['', '#xyzxyz', '#12345', null, {}, '#ffffff; color:red']) assert.equal(normalizeHex(invalid), null);
for (const hex of ['#000000', '#ffffff', '#ff0000', '#00ff00', '#0000ff', '#ffff00', '#00ffff', '#ff00ff', '#ff9966', '#0877bb']) {
const { h, s, v } = hexToHsv(hex);
assert.equal(hsvToHex(h, s, v), hex);
}
assert.equal(hsvToHex(360, 100, 100), '#ff0000');
assert.equal(hsvToHex(-120, 100, 100), '#0000ff');
assert.equal(hsvToHex(0, 120, -1), '#000000');
});
+23
View File
@@ -24,6 +24,29 @@ from kikotools.tools.gemini_prompt.prompts import (
class TestGeminiPromptNode:
"""Test cases for GeminiPromptNode."""
def test_deprecated_registration_preserves_legacy_workflow_lookup(self) -> None:
from kikotools import NODE_CLASS_MAPPINGS, NODE_DISPLAY_NAME_MAPPINGS
cls = NODE_CLASS_MAPPINGS["GeminiPrompt"]
assert cls is GeminiPromptNode
assert NODE_DISPLAY_NAME_MAPPINGS["GeminiPrompt"] == "Gemini Prompt Engineer"
assert getattr(cls, "DEPRECATED", False) is True
assert "existing workflows" in cls.DESCRIPTION.lower()
assert "no longer receives" in cls.DESCRIPTION.lower()
def test_legacy_workflow_schema_is_retained(self) -> None:
with patch(
"kikotools.tools.gemini_prompt.node.get_available_models",
return_value=(["legacy-model"], None),
):
inputs = GeminiPromptNode.INPUT_TYPES()
assert set(inputs["required"]) == {"image", "prompt_type", "model"}
assert set(inputs["optional"]) == {"api_key", "custom_prompt", "refresh_models"}
assert inputs["required"]["model"][0] == ["legacy-model"]
assert callable(getattr(GeminiPromptNode(), GeminiPromptNode.FUNCTION))
assert GeminiPromptNode.RETURN_TYPES == ("STRING", "STRING")
assert GeminiPromptNode.RETURN_NAMES == ("prompt", "negative_prompt")
def test_node_properties(self):
"""Test node has correct properties."""
assert GeminiPromptNode.CATEGORY == "🫶 ComfyAssets/🧠 Prompts"
+1 -1
View File
@@ -15,7 +15,7 @@ export async function saveCoverage(page, browserName) {
const entries = await page.coverage.stopJSCoverage();
const result = [];
for (const entry of entries) {
const match = entry.url.match(/\/extensions\/kikotools\/([a-z0-9_-]+\.js)(?:\?.*)?$/i);
const match = entry.url.match(/\/extensions\/kikotools\/((?:[a-z0-9_-]+\/)*[a-z0-9_-]+\.(?:js|mjs))(?:\?.*)?$/i);
if (match) result.push({ ...entry, url: pathToFileURL(join(repositoryRoot, 'web', match[1])).href });
}
mkdirSync(process.env.KIKO_JS_COVERAGE, { recursive: true });
+23
View File
@@ -0,0 +1,23 @@
import { defineConfig } from './node_modules/@playwright/test/index.mjs';
import { fileURLToPath } from 'node:url';
if (!process.env.KIKO_COMFY_URL) throw new Error('Set KIKO_COMFY_URL to a local ComfyUI instance');
const host = new URL(process.env.KIKO_COMFY_URL);
if (!['127.0.0.1', 'localhost', '[::1]'].includes(host.hostname)) throw new Error('The ComfyUI smoke suite only accepts a loopback host');
export default defineConfig({
testDir: '../../tests/comfyui',
outputDir: './test-results/comfyui',
timeout: 60000,
workers: 1,
forbidOnly: Boolean(process.env.CI),
retries: 0,
use: { baseURL: host.href, browserName: 'chromium', viewport: { width: 1600, height: 1100 }, trace: 'retain-on-failure', screenshot: 'only-on-failure' },
webServer: {
command: 'node tools/testing/start-browser-server.mjs --port 8771',
cwd: fileURLToPath(new URL('../../', import.meta.url)),
url: 'http://127.0.0.1:8771/__test__/',
reuseExistingServer: false,
gracefulShutdown: { signal: 'SIGTERM', timeout: 5000 },
},
});
+159 -39
View File
@@ -1,21 +1,51 @@
{
"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,
"functions": 86,
"statements": 98
},
"display_any.js": {
"lines": 84,
"branches": 66,
"functions": 83,
"statements": 84
"lines": 98,
"branches": 98,
"functions": 98,
"statements": 98
},
"display_text.js": {
"lines": 71,
"branches": 74,
"functions": 42,
"statements": 71
"lines": 98,
"branches": 98,
"functions": 98,
"statements": 98
},
"empty_latent_batch_swap.js": {
"lines": 41,
"branches": 91,
"functions": 64,
"statements": 41
"lines": 98,
"branches": 98,
"functions": 98,
"statements": 98
},
"gemini_prompt_ui.js": {
"lines": 52,
@@ -24,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,
@@ -42,39 +72,129 @@
"statements": 60
},
"kiko_local_image_loader.js": {
"lines": 72,
"branches": 69,
"functions": 40,
"statements": 72
"lines": 77,
"branches": 73,
"functions": 70,
"statements": 77
},
"kiko_save_image_ui.js": {
"lines": 66,
"branches": 49,
"functions": 57,
"statements": 66
"lines": 84,
"branches": 74,
"functions": 78,
"statements": 84
},
"kiko_workflow_timer_ui.js": {
"lines": 44,
"branches": 73,
"functions": 34,
"statements": 44
"lines": 98,
"branches": 78,
"functions": 88,
"statements": 98
},
"kikotools_extensions.js": {
"lines": 12,
"lines": 98,
"branches": 98,
"functions": 0,
"statements": 12
"functions": 98,
"statements": 98
},
"seed_history_ui.js": {
"lines": 63,
"branches": 58,
"functions": 56,
"statements": 63
"lines": 95,
"branches": 85,
"functions": 88,
"statements": 95
},
"ui/autocomplete-suggestions.js": {
"lines": 98,
"branches": 98,
"functions": 98,
"statements": 98
},
"ui/color-picker.js": {
"lines": 98,
"branches": 94,
"functions": 98,
"statements": 98
},
"ui/color.js": {
"lines": 98,
"branches": 98,
"functions": 98,
"statements": 98
},
"ui/core.js": {
"lines": 98,
"branches": 92,
"functions": 98,
"statements": 98
},
"ui/dialog.js": {
"lines": 98,
"branches": 88,
"functions": 98,
"statements": 98
},
"ui/dimension-swap.js": {
"lines": 98,
"branches": 98,
"functions": 48,
"statements": 98
},
"ui/dimensions.js": {
"lines": 98,
"branches": 98,
"functions": 98,
"statements": 98
},
"ui/floating-window.js": {
"lines": 93,
"branches": 80,
"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,
"functions": 98,
"statements": 98
},
"views/node-dimensions.js": {
"lines": 98,
"branches": 98,
"functions": 98,
"statements": 98
},
"views/seed-history.js": {
"lines": 98,
"branches": 98,
"functions": 98,
"statements": 98
},
"views/text-output.js": {
"lines": 98,
"branches": 94,
"functions": 98,
"statements": 98
},
"width_height_swap.js": {
"lines": 60,
"branches": 79,
"functions": 73,
"statements": 60
"lines": 98,
"branches": 98,
"functions": 98,
"statements": 98
}
}
+8 -2
View File
@@ -5,6 +5,12 @@ import { spawnSync } from 'node:child_process';
const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), '../..');
const metrics = ['lines', 'branches', 'functions', 'statements'];
export function discoverSources(root) {
return readdirSync(root, { recursive: true, withFileTypes: true })
.filter(entry => entry.isFile() && /\.(?:js|mjs)$/.test(entry.name))
.map(entry => relative(root, join(entry.parentPath, entry.name)).replaceAll('\\', '/'))
.sort();
}
export function checkCoverage(summary, baseline, sourceRoot, files) {
const failures = [];
for (const file of files) {
@@ -35,7 +41,7 @@ async function main() {
const reportRoot = join(repositoryRoot, 'coverage/js');
const raw = join(reportRoot, 'raw');
const sourceRoot = join(repositoryRoot, 'web');
const files = readdirSync(sourceRoot).filter(file => file.endsWith('.js')).sort();
const files = discoverSources(sourceRoot);
rmSync(reportRoot, { recursive: true, force: true });
mkdirSync(raw, { recursive: true });
const tests = readdirSync(join(repositoryRoot, 'tests/js')).filter(file => file.endsWith('.test.mjs')).map(file => `tests/js/${file}`);
@@ -44,7 +50,7 @@ async function main() {
run([playwright, 'test', '--config', join(tools, 'playwright.config.mjs'),
'--project', 'chromium', '--project', 'touch'], { KIKO_JS_COVERAGE: raw });
run([join(tools, 'node_modules/c8/bin/c8.js'), 'report', '--all', '--src', sourceRoot,
'--include', 'web/*.js', '--temp-directory', raw, '--reports-dir', reportRoot,
'--include', 'web/**/*.js', '--include', 'web/**/*.mjs', '--temp-directory', raw, '--reports-dir', reportRoot,
'--reporter', 'text', '--reporter', 'json-summary', '--reporter', 'html']);
const summary = JSON.parse(readFileSync(join(reportRoot, 'coverage-summary.json'), 'utf8'));
const baselinePath = join(tools, 'frontend-coverage-baseline.json');
+39
View File
@@ -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();
}
}
+36
View File
@@ -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);
});
}
+5
View File
@@ -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 }); }
};
+86
View File
@@ -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;
}
+63
View File
@@ -0,0 +1,63 @@
import { createScope } from '../ui/core.js';
import { createTextOutputView } from '../views/text-output.js';
export function restoreDisplayOutput(data, kind, hasInputWidget) {
if (data.kikoDisplay?.version === 1 && typeof data.kikoDisplay.text === 'string') return data.kikoDisplay.text;
if (!Array.isArray(data.widgets_values)) return null;
const values = data.widgets_values.slice(hasInputWidget && data.widgets_values.length > 1 ? 1 : 0);
if (kind === 'any' && ['raw value', 'tensor shape'].includes(values[0])) values.shift();
const strings = values.filter(value => typeof value === 'string');
if (!strings.length) return null;
if (kind === 'text' && strings[0] === '✓ Positive Prompt' && strings[2] === '✗ Negative Prompt') {
return `Positive prompt: ${strings[1]}\nNegative prompt: ${strings[3]}`;
}
return kind === 'any' ? strings.join('\n') : strings[0];
}
function updateOutput(node, text) {
if (node.displayOutput.scope.signal.aborted) return;
node.displayOutput.text = text;
node.displayOutput.view.update(text);
node.setDirtyCanvas(true, true);
}
export function registerDisplayNode(nodeType, app, kind) {
const onNodeCreated = nodeType.prototype.onNodeCreated;
nodeType.prototype.onNodeCreated = function () {
const result = onNodeCreated?.apply(this, arguments);
const scope = createScope();
const view = createTextOutputView(scope, kind, app.ui.settings);
this.displayOutput = { scope, view, text: '' };
this.addDOMWidget('kiko_display_output', 'div', view.element, {
serialize: false, hideOnZoom: false,
getMinHeight: () => kind === 'text' ? 240 : 180,
});
this.setSize([Math.max(this.size[0], kind === 'text' ? 400 : 450), Math.max(this.size[1], kind === 'text' ? 300 : 250)]);
updateOutput(this, kind === 'text' ? 'Text will appear here after execution...' : 'Value will appear here...');
return result;
};
const onExecuted = nodeType.prototype.onExecuted;
nodeType.prototype.onExecuted = function (message) {
const result = onExecuted?.apply(this, arguments);
if (typeof message?.text?.[0] === 'string') updateOutput(this, message.text[0]);
return result;
};
const configure = nodeType.prototype.configure;
nodeType.prototype.configure = function (data) {
const text = restoreDisplayOutput(data, kind, Boolean(this.inputs?.[0]?.widget));
const result = configure?.apply(this, arguments);
if (text !== null) updateOutput(this, text);
return result;
};
const onSerialize = nodeType.prototype.onSerialize;
nodeType.prototype.onSerialize = function (data) {
const result = onSerialize?.apply(this, arguments);
data.kikoDisplay = { version: 1, text: this.displayOutput.text };
return result;
};
const onRemoved = nodeType.prototype.onRemoved;
nodeType.prototype.onRemoved = function () {
this.displayOutput.scope.dispose();
return onRemoved?.apply(this, arguments);
};
}
+5 -122
View File
@@ -1,126 +1,9 @@
import { app } from "../../scripts/app.js";
import { ComfyWidgets } from "../../scripts/widgets.js";
import { app } from '../../scripts/app.js';
import { registerDisplayNode } from './controllers/display-node.js';
app.registerExtension({
name: "ComfyAssets.DisplayAny",
name: 'ComfyAssets.DisplayAny',
async beforeRegisterNodeDef(nodeType, nodeData, app) {
if (nodeData.name === "DisplayAny") {
function populate(text) {
// Remove existing display widgets
if (this.widgets) {
const toRemove = [];
for (let i = 0; i < this.widgets.length; i++) {
if (this.widgets[i].name?.startsWith("display_") || this.widgets[i].name === "copy_value") {
this.widgets[i].onRemove?.();
toRemove.push(i);
}
}
// Remove in reverse order to maintain indices
for (let i = toRemove.length - 1; i >= 0; i--) {
this.widgets.splice(toRemove[i], 1);
}
}
// Split text into multiple widgets if it's too long or has multiple lines
const lines = text ? text.split('\n') : [''];
const maxLinesPerWidget = 20;
const chunks = [];
for (let i = 0; i < lines.length; i += maxLinesPerWidget) {
chunks.push(lines.slice(i, i + maxLinesPerWidget).join('\n'));
}
// Create a widget for each chunk
chunks.forEach((chunk, index) => {
const w = ComfyWidgets["STRING"](this, `display_${index}`, ["STRING", { multiline: true }], app).widget;
w.inputEl.readOnly = true;
w.inputEl.style.opacity = 0.8;
w.inputEl.style.fontFamily = "monospace";
w.value = chunk;
});
// Add copy button widget
const copyWidget = {
type: "button",
name: "copy_value",
text: "📋 Copy Value",
callback: () => {
navigator.clipboard.writeText(text || '').then(() => {
copyWidget.text = "✓ Copied!";
this.setDirtyCanvas(true);
setTimeout(() => {
copyWidget.text = "📋 Copy Value";
this.setDirtyCanvas(true);
}, 1500);
}).catch(err => {
console.error('Failed to copy:', err);
});
}
};
this.addCustomWidget(copyWidget);
requestAnimationFrame(() => {
const sz = this.computeSize();
if (sz[0] < this.size[0]) {
sz[0] = this.size[0];
}
if (sz[1] < this.size[1]) {
sz[1] = this.size[1];
}
this.onResize?.(sz);
app.graph.setDirtyCanvas(true, false);
});
}
// When the node is executed we will be sent the display text
const onExecuted = nodeType.prototype.onExecuted;
nodeType.prototype.onExecuted = function (message) {
onExecuted?.apply(this, arguments);
console.log("DisplayAny onExecuted message:", message);
if (message?.text && message.text.length > 0) {
const displayText = message.text[0];
console.log("DisplayAny displayText:", displayText);
populate.call(this, displayText);
} else {
console.log("DisplayAny no text in message");
}
};
const VALUES = Symbol();
const configure = nodeType.prototype.configure;
nodeType.prototype.configure = function () {
this[VALUES] = arguments[0]?.widgets_values;
return configure?.apply(this, arguments);
};
const onConfigure = nodeType.prototype.onConfigure;
nodeType.prototype.onConfigure = function () {
onConfigure?.apply(this, arguments);
const widgets_values = this[VALUES];
if (widgets_values?.length) {
requestAnimationFrame(() => {
const startIdx = widgets_values.length > 1 && this.inputs?.[0]?.widget ? 1 : 0;
if (widgets_values[startIdx]) {
populate.call(this, widgets_values[startIdx]);
}
});
}
};
// Initialize on node creation
const onNodeCreated = nodeType.prototype.onNodeCreated;
nodeType.prototype.onNodeCreated = function() {
onNodeCreated?.apply(this, arguments);
// Set minimum size - make it wider for better JSON display
this.size[0] = Math.max(this.size[0], 450);
this.size[1] = Math.max(this.size[1], 250);
// Add placeholder text
populate.call(this, "Value will appear here...");
};
}
}
if (nodeData.name === 'DisplayAny') registerDisplayNode(nodeType, app, 'any');
},
});
+5 -195
View File
@@ -1,199 +1,9 @@
import { app } from "../../scripts/app.js";
import { ComfyWidgets } from "../../scripts/widgets.js";
import { app } from '../../scripts/app.js';
import { registerDisplayNode } from './controllers/display-node.js';
app.registerExtension({
name: "ComfyAssets.DisplayText",
name: 'ComfyAssets.DisplayText',
async beforeRegisterNodeDef(nodeType, nodeData, app) {
if (nodeData.name === "DisplayText") {
function populate(text) {
// Remove existing widgets
if (this.widgets) {
const toRemove = [];
for (let i = 0; i < this.widgets.length; i++) {
if (this.widgets[i].name?.startsWith("text_") || this.widgets[i].name?.startsWith("copy_")) {
this.widgets[i].onRemove?.();
toRemove.push(i);
}
}
// Remove in reverse order to maintain indices
for (let i = toRemove.length - 1; i >= 0; i--) {
this.widgets.splice(toRemove[i], 1);
}
}
// Parse the text to detect positive/negative prompt format
const posMatch = text.match(/Positive prompt:\s*([\s\S]*?)(?=Negative prompt:|$)/i);
const negMatch = text.match(/Negative prompt:\s*([\s\S]*?)(?=\*\*|$)/i);
if (posMatch && negMatch) {
// Extract prompt content
let positiveText = posMatch[1].trim();
let negativeText = negMatch[1].trim();
// Remove any trailing ** markers
const posEndIndex = positiveText.indexOf('**');
if (posEndIndex > 0) {
positiveText = positiveText.substring(0, posEndIndex).trim();
}
const negEndIndex = negativeText.indexOf('**');
if (negEndIndex > 0) {
negativeText = negativeText.substring(0, negEndIndex).trim();
}
// Create header widget for positive prompt
const posHeader = ComfyWidgets["STRING"](this, "text_pos_header", ["STRING", { multiline: false }], app).widget;
posHeader.inputEl.readOnly = true;
posHeader.inputEl.style.opacity = 0.7;
posHeader.inputEl.style.backgroundColor = "#2a4a2a";
posHeader.inputEl.style.color = "#8f8";
posHeader.inputEl.style.fontWeight = "bold";
posHeader.value = "✓ Positive Prompt";
// Create positive prompt widget
const posWidget = ComfyWidgets["STRING"](this, "text_positive", ["STRING", { multiline: true }], app).widget;
posWidget.inputEl.readOnly = true;
posWidget.inputEl.style.opacity = 0.9;
posWidget.value = positiveText;
// Create copy button widget for positive prompt
const posCopyWidget = {
type: "button",
name: "copy_positive",
text: "📋 Copy Positive",
callback: () => {
navigator.clipboard.writeText(positiveText).then(() => {
posCopyWidget.text = "✓ Copied!";
this.setDirtyCanvas(true);
setTimeout(() => {
posCopyWidget.text = "📋 Copy Positive";
this.setDirtyCanvas(true);
}, 1500);
}).catch(err => {
console.error('Failed to copy:', err);
});
}
};
this.addCustomWidget(posCopyWidget);
// Create header widget for negative prompt
const negHeader = ComfyWidgets["STRING"](this, "text_neg_header", ["STRING", { multiline: false }], app).widget;
negHeader.inputEl.readOnly = true;
negHeader.inputEl.style.opacity = 0.7;
negHeader.inputEl.style.backgroundColor = "#4a2a2a";
negHeader.inputEl.style.color = "#f88";
negHeader.inputEl.style.fontWeight = "bold";
negHeader.value = "✗ Negative Prompt";
// Create negative prompt widget
const negWidget = ComfyWidgets["STRING"](this, "text_negative", ["STRING", { multiline: true }], app).widget;
negWidget.inputEl.readOnly = true;
negWidget.inputEl.style.opacity = 0.9;
negWidget.value = negativeText;
// Create copy button widget for negative prompt
const negCopyWidget = {
type: "button",
name: "copy_negative",
text: "📋 Copy Negative",
callback: () => {
navigator.clipboard.writeText(negativeText).then(() => {
negCopyWidget.text = "✓ Copied!";
this.setDirtyCanvas(true);
setTimeout(() => {
negCopyWidget.text = "📋 Copy Negative";
this.setDirtyCanvas(true);
}, 1500);
}).catch(err => {
console.error('Failed to copy:', err);
});
}
};
this.addCustomWidget(negCopyWidget);
} else {
// Single text display with ComfyUI's standard STRING widget
const w = ComfyWidgets["STRING"](this, "text_display", ["STRING", { multiline: true }], app).widget;
w.inputEl.readOnly = true;
w.inputEl.style.opacity = 0.9;
w.value = text;
// Create copy button widget
const copyWidget = {
type: "button",
name: "copy_text",
text: "📋 Copy",
callback: () => {
navigator.clipboard.writeText(text).then(() => {
copyWidget.text = "✓ Copied!";
this.setDirtyCanvas(true);
setTimeout(() => {
copyWidget.text = "📋 Copy";
this.setDirtyCanvas(true);
}, 1500);
}).catch(err => {
console.error('Failed to copy:', err);
});
}
};
this.addCustomWidget(copyWidget);
}
requestAnimationFrame(() => {
const sz = this.computeSize();
if (sz[0] < this.size[0]) {
sz[0] = this.size[0];
}
if (sz[1] < this.size[1]) {
sz[1] = this.size[1];
}
this.onResize?.(sz);
app.graph.setDirtyCanvas(true, false);
});
}
// When the node is executed we will be sent the input text
const onExecuted = nodeType.prototype.onExecuted;
nodeType.prototype.onExecuted = function (message) {
onExecuted?.apply(this, arguments);
if (message?.text) {
populate.call(this, message.text[0]);
}
};
const VALUES = Symbol();
const configure = nodeType.prototype.configure;
nodeType.prototype.configure = function () {
this[VALUES] = arguments[0]?.widgets_values;
return configure?.apply(this, arguments);
};
const onConfigure = nodeType.prototype.onConfigure;
nodeType.prototype.onConfigure = function () {
onConfigure?.apply(this, arguments);
const widgets_values = this[VALUES];
if (widgets_values?.length) {
requestAnimationFrame(() => {
const startIdx = widgets_values.length > 1 && this.inputs?.[0]?.widget ? 1 : 0;
if (widgets_values[startIdx]) {
populate.call(this, widgets_values[startIdx]);
}
});
}
};
// Initialize on node creation
const onNodeCreated = nodeType.prototype.onNodeCreated;
nodeType.prototype.onNodeCreated = function() {
onNodeCreated?.apply(this, arguments);
// Set default size
this.size[0] = Math.max(this.size[0], 400);
this.size[1] = Math.max(this.size[1], 300);
// Add placeholder text
populate.call(this, "Text will appear here after execution...");
};
}
}
if (nodeData.name === 'DisplayText') registerDisplayNode(nodeType, app, 'text');
},
});
+16 -275
View File
@@ -1,279 +1,20 @@
// ComfyUI-KikoTools - Empty Latent Batch with Swap Button
import { app } from "../../scripts/app.js";
import { app } from '../../scripts/app.js';
import { bindDimensionPresets, extractResolutionFromPreset, swapDimensions } from './ui/dimensions.js';
import { mountDimensionSwap } from './ui/dimension-swap.js';
app.registerExtension({
name: "comfyassets.EmptyLatentBatch",
async beforeRegisterNodeDef(nodeType, nodeData, _app) {
if (nodeData.name === "EmptyLatentBatch") {
const onNodeCreated = nodeType.prototype.onNodeCreated;
nodeType.prototype.onNodeCreated = function () {
if (onNodeCreated) onNodeCreated.apply(this, []);
// Helper function to extract resolution from formatted preset string
this.extractResolutionFromPreset = function (presetValue) {
if (presetValue === "custom") return null;
// If it contains formatting metadata, extract the resolution part
if (presetValue.includes(" - ")) {
// Format is: "1024×1024 - 1:1 (1.0MP) - SDXL"
return presetValue.split(" - ")[0];
}
// Otherwise assume it's already a raw resolution
return presetValue;
name: 'comfyassets.EmptyLatentBatch',
async beforeRegisterNodeDef(nodeType, nodeData) {
if (nodeData.name !== 'EmptyLatentBatch') return;
nodeType.prototype.createSwapButton = function () { return mountDimensionSwap(this, app); };
const onNodeCreated = nodeType.prototype.onNodeCreated;
nodeType.prototype.onNodeCreated = function () {
const result = onNodeCreated?.apply(this, arguments);
this.extractResolutionFromPreset = extractResolutionFromPreset;
this.swapDimensions = () => swapDimensions(this, app);
bindDimensionPresets(this, app);
this.createSwapButton();
return result;
};
// Create swap button as DOM widget
this.createSwapButton();
// Override preset callback to update width/height widgets when preset changes
const presetWidget = this.widgets.find((w) => w.name === "preset");
if (presetWidget) {
const originalCallback = presetWidget.callback;
presetWidget.callback = function (
value,
graphcanvas,
node,
pos,
event,
) {
// Call original callback first
if (originalCallback) {
originalCallback.call(this, value, graphcanvas, node, pos, event);
}
// Update width/height widgets based on preset
const widthWidget = node.widgets.find((w) => w.name === "width");
const heightWidget = node.widgets.find((w) => w.name === "height");
if (widthWidget && heightWidget && value !== "custom") {
// Extract raw resolution from formatted preset
const rawResolution = node.extractResolutionFromPreset(value);
// Define all available presets from our preset system
const presetDimensions = {
// SDXL Presets
"1024×1024": [1024, 1024],
"896×1152": [896, 1152],
"832×1216": [832, 1216],
"768×1344": [768, 1344],
"640×1536": [640, 1536],
"1152×896": [1152, 896],
"1216×832": [1216, 832],
"1344×768": [1344, 768],
"1536×640": [1536, 640],
// FLUX Presets
"1920×1080": [1920, 1080],
"1536×1536": [1536, 1536],
"1280×768": [1280, 768],
"768×1280": [768, 1280],
"1440×1080": [1440, 1080],
"1080×1440": [1080, 1440],
"1728×1152": [1728, 1152],
"1152×1728": [1152, 1728],
// Ultra-Wide Presets
"2560×1080": [2560, 1080],
"2048×768": [2048, 768],
"1792×768": [1792, 768],
"2304×768": [2304, 768],
"1080×2560": [1080, 2560],
"768×2048": [768, 2048],
"768×1792": [768, 1792],
"768×2304": [768, 2304],
// Qwen Presets
"1328×1328": [1328, 1328],
"1664×928": [1664, 928],
"928×1664": [928, 1664],
"1472×1104": [1472, 1104],
"1104×1472": [1104, 1472],
"1584×1056": [1584, 1056],
"1056×1584": [1056, 1584],
"2080×688": [2080, 688],
"688×2080": [688, 2080],
};
if (rawResolution && presetDimensions[rawResolution]) {
const [w, h] = presetDimensions[rawResolution];
widthWidget.value = w;
heightWidget.value = h;
// Trigger widget callbacks to update the UI
if (widthWidget.callback) {
widthWidget.callback(w, graphcanvas, node, pos, event);
}
if (heightWidget.callback) {
heightWidget.callback(h, graphcanvas, node, pos, event);
}
}
}
};
}
// Add swap functionality
this.swapDimensions = function () {
const widthWidget = this.widgets.find((w) => w.name === "width");
const heightWidget = this.widgets.find((w) => w.name === "height");
const presetWidget = this.widgets.find((w) => w.name === "preset");
if (widthWidget && heightWidget && presetWidget) {
// Handle preset swapping first
if (presetWidget.value !== "custom") {
const currentPreset = presetWidget.value;
// Extract raw resolution from formatted preset
const rawResolution =
this.extractResolutionFromPreset(currentPreset);
if (!rawResolution) return;
// Parse current preset dimensions (handle both × and x separators)
let w, h;
if (rawResolution.includes("×")) {
[w, h] = rawResolution.split("×").map((v) => parseInt(v));
} else if (rawResolution.includes("x")) {
[w, h] = rawResolution.split("x").map((v) => parseInt(v));
} else {
return; // Invalid preset format
}
const swappedRawPreset = `${h}×${w}`;
// Find the formatted version of the swapped preset from available options
const availablePresets =
presetWidget.options.values || presetWidget.options;
let swappedFormattedPreset = null;
for (const option of availablePresets) {
if (option === "custom") continue;
const extractedRes = this.extractResolutionFromPreset(option);
if (extractedRes === swappedRawPreset) {
swappedFormattedPreset = option;
break;
}
}
if (swappedFormattedPreset) {
// Swapped preset exists, use the formatted version
presetWidget.value = swappedFormattedPreset;
widthWidget.value = h;
heightWidget.value = w;
if (presetWidget.callback) {
presetWidget.callback(
swappedFormattedPreset,
app.canvas,
this,
[0, 0],
null
);
}
if (widthWidget.callback) {
widthWidget.callback(widthWidget.value, app.canvas, this, [0, 0], null);
}
if (heightWidget.callback) {
heightWidget.callback(heightWidget.value, app.canvas, this, [0, 0], null);
}
} else {
// Swapped preset doesn't exist, switch to custom and swap manual values
presetWidget.value = "custom";
widthWidget.value = h;
heightWidget.value = w;
if (presetWidget.callback) {
presetWidget.callback("custom", app.canvas, this, [0, 0], null);
}
if (widthWidget.callback) {
widthWidget.callback(widthWidget.value, app.canvas, this, [0, 0], null);
}
if (heightWidget.callback) {
heightWidget.callback(heightWidget.value, app.canvas, this, [0, 0], null);
}
}
} else {
// Custom preset - just swap the width and height values
const tempWidth = widthWidget.value;
widthWidget.value = heightWidget.value;
heightWidget.value = tempWidth;
// Trigger widget change events
if (widthWidget.callback) {
widthWidget.callback(widthWidget.value, app.canvas, this, [0, 0], null);
}
if (heightWidget.callback) {
heightWidget.callback(heightWidget.value, app.canvas, this, [0, 0], null);
}
}
// Mark the graph as changed
this.graph?.setDirtyCanvas(true, true);
}
};
};
// Create swap button as DOM widget
nodeType.prototype.createSwapButton = function () {
// Create button container
const buttonContainer = document.createElement("div");
buttonContainer.style.cssText = `
padding: 4px;
text-align: center;
`;
// Create swap button
const swapButton = document.createElement("button");
swapButton.innerHTML = "↔️ Swap W×H";
swapButton.style.cssText = `
background: #4A90E2;
color: white;
border: none;
border-radius: 4px;
padding: 6px 12px;
cursor: pointer;
font-size: 11px;
font-weight: bold;
transition: background 0.2s;
box-shadow: 0 2px 4px rgba(0,0,0,0.2);
`;
// Button hover effects
swapButton.addEventListener("mouseenter", () => {
swapButton.style.background = "#5BA0F2";
swapButton.style.transform = "translateY(-1px)";
swapButton.style.boxShadow = "0 3px 6px rgba(0,0,0,0.3)";
});
swapButton.addEventListener("mouseleave", () => {
swapButton.style.background = "#4A90E2";
swapButton.style.transform = "translateY(0)";
swapButton.style.boxShadow = "0 2px 4px rgba(0,0,0,0.2)";
});
// Button click effect and functionality
swapButton.addEventListener("mousedown", () => {
swapButton.style.background = "#3A80D2";
swapButton.style.transform = "translateY(1px)";
swapButton.style.boxShadow = "0 1px 2px rgba(0,0,0,0.2)";
});
swapButton.addEventListener("mouseup", () => {
swapButton.style.background = "#5BA0F2";
swapButton.style.transform = "translateY(-1px)";
swapButton.style.boxShadow = "0 3px 6px rgba(0,0,0,0.3)";
});
// Main click functionality
swapButton.addEventListener("click", () => {
this.swapDimensions();
});
buttonContainer.appendChild(swapButton);
// Add as DOM widget
this.swapButtonWidget = this.addDOMWidget(
"swap_button",
"div",
buttonContainer
);
};
}
},
},
});
File diff suppressed because it is too large Load Diff
+40 -41
View File
@@ -1,31 +1,15 @@
import { app } from "../../scripts/app.js";
import { $el } from "../../scripts/ui.js";
import { createColorPicker } from "./ui/color-picker.js";
// Custom colors feature with extended options based on PR #433
// Adds custom color pickers for nodes with full, title, and background options
const colorShade = (col, amt) => {
col = col.replace(/^#/, "");
if (col.length === 3) col = col[0] + col[0] + col[1] + col[1] + col[2] + col[2];
const colorShade = (color, amount) => '#' + color.slice(1).match(/../g)
.map(channel => Math.min(255, parseInt(channel, 16) + amount).toString(16).padStart(2, '0'))
.join('');
let [r, g, b] = col.match(/.{2}/g);
[r, g, b] = [parseInt(r, 16) + amt, parseInt(g, 16) + amt, parseInt(b, 16) + amt];
r = Math.max(Math.min(255, r), 0).toString(16);
g = Math.max(Math.min(255, g), 0).toString(16);
b = Math.max(Math.min(255, b), 0).toString(16);
const rr = (r.length < 2 ? "0" : "") + r;
const gg = (g.length < 2 ? "0" : "") + g;
const bb = (b.length < 2 ? "0" : "") + b;
return `#${rr}${gg}${bb}`;
};
// One lazily-created hidden color input, shared by every menu entry.
// applyPicked holds what the currently open picker should do on change.
let picker;
let applyPicked;
let activePicker;
let pickerNode;
const isEnabled = () => {
const setting = app.ui.settings.getSettingValue("kikotools.custom_colors.enabled");
@@ -42,9 +26,7 @@ const getSettings = () => ({
// Helper function to apply color to node(s)
// Uses setColorOption like the built-in colors when possible
const applyColorToNodes = (colorValue, colorType, node) => {
if (!colorValue) return;
const graphcanvas = LGraphCanvas.active_canvas;
const graphcanvas = globalThis.LGraphCanvas?.active_canvas || app.canvas;
const nodes = (!graphcanvas.selected_nodes || Object.keys(graphcanvas.selected_nodes).length <= 1)
? [node]
: Object.values(graphcanvas.selected_nodes);
@@ -74,7 +56,6 @@ const applyColorToNodes = (colorValue, colorType, node) => {
// Groups have a single color
const applyColorToGroup = (colorValue, group) => {
if (!colorValue) return;
if (group.setColorOption) {
group.setColorOption({ groupcolor: colorValue });
} else {
@@ -83,19 +64,26 @@ const applyColorToGroup = (colorValue, group) => {
app.canvas.setDirty(true, true);
};
// Open the shared hidden color input; apply(color) runs when a color is picked
const openPicker = (initialColor, apply) => {
if (!picker) {
picker = $el("input", {
type: "color",
parent: document.body,
style: { display: "none" },
});
picker.onchange = () => applyPicked?.(picker.value);
}
applyPicked = apply;
picker.value = initialColor || "#000000";
picker.click();
const openPicker = (initialColor, apply, node, label) => {
activePicker?.remove();
const previousFocus = document.activeElement;
const expanded = /^#[0-9a-f]{3}$/i.test(initialColor)
? '#' + [...initialColor.slice(1)].map(value => value + value).join('') : initialColor;
const picker = createColorPicker(expanded, apply, app.ui.settings, label);
picker.hidden = true;
activePicker = picker;
pickerNode = node;
document.body.append(picker);
picker.dialog.addEventListener('close', () => {
picker.remove();
if (activePicker === picker) {
activePicker = null;
pickerNode = null;
if (previousFocus?.isConnected) previousFocus.focus();
else app.canvas.canvas?.focus();
}
}, { once: true });
picker.dialog.showModal();
};
// Wrap menu options in the "Custom Colors" submenu (or nothing if empty)
@@ -148,6 +136,14 @@ app.registerExtension({
});
},
beforeRegisterNodeDef(nodeType) {
const onRemoved = nodeType.prototype.onRemoved;
nodeType.prototype.onRemoved = function () {
if (pickerNode === this) activePicker?.remove();
return onRemoved?.apply(this, arguments);
};
},
// New API: return items to append to node right-click menu
getNodeMenuItems(node) {
if (!isEnabled()) return [];
@@ -157,7 +153,10 @@ app.registerExtension({
const addOption = (label, type, initialColor) => options.push({
content: label,
callback: () => openPicker(initialColor, (color) => applyColorToNodes(color, type, node)),
callback: () => openPicker(
initialColor, (color) => applyColorToNodes(color, type, node),
node, `Custom ${type === 'bg' ? 'background' : type} color`,
),
});
if (settings.showFull) addOption("🫶 Custom Full", 'full', node.bgcolor);
@@ -181,7 +180,7 @@ app.registerExtension({
return customColorsMenu([{
content: "🫶 Custom Group Color",
callback: () => openPicker(group.color, (color) => applyColorToGroup(color, group)),
callback: () => openPicker(group.color, (color) => applyColorToGroup(color, group), null, 'Group color'),
}]);
},
});
+78 -506
View File
@@ -1,5 +1,7 @@
import { app } from "/scripts/app.js";
import { api } from "/scripts/api.js";
import { createScope } from "./ui/core.js";
import { createImageBrowser } from "./views/image-browser.js";
// Escape a value for safe interpolation into HTML text or attribute contexts
function escapeHtml(value) {
@@ -29,131 +31,6 @@ async function describeHttpError(response) {
return detail || `Request failed (HTTP ${response.status})`;
}
// Setup global lightbox for image preview
function setupGlobalLightbox() {
if (document.getElementById('kiko-image-lightbox')) return;
const lightboxId = 'kiko-image-lightbox';
const lightboxHTML = `
<div id="${lightboxId}" class="lightbox-overlay">
<button class="lightbox-close">&times;</button>
<button class="lightbox-prev">&lt;</button>
<button class="lightbox-next">&gt;</button>
<div class="lightbox-content">
<img src="" alt="Preview" style="display: none;">
<video src="" controls autoplay style="display: none;"></video>
<audio src="" controls autoplay style="display: none;"></audio>
</div>
<div class="lightbox-dimensions"></div>
</div>
`;
const lightboxCSS = `
#${lightboxId} {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.85);
display: none;
align-items: center;
justify-content: center;
z-index: 10000;
box-sizing: border-box;
-webkit-user-select: none;
user-select: none;
}
#${lightboxId} .lightbox-content {
position: relative;
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
}
#${lightboxId} img, #${lightboxId} video {
max-width: 95%;
max-height: 95%;
object-fit: contain;
transition: transform 0.1s ease-out;
transform: scale(1) translate(0, 0);
}
#${lightboxId} audio {
width: 80%;
max-width: 600px;
}
#${lightboxId} img {
cursor: grab;
}
#${lightboxId} img.panning {
cursor: grabbing;
}
#${lightboxId} .lightbox-close {
position: absolute;
top: 15px;
right: 20px;
width: 35px;
height: 35px;
background-color: rgba(0,0,0,0.5);
color: #fff;
border-radius: 50%;
border: 2px solid #fff;
font-size: 24px;
line-height: 30px;
text-align: center;
cursor: pointer;
z-index: 10002;
}
#${lightboxId} .lightbox-prev, #${lightboxId} .lightbox-next {
position: absolute;
top: 50%;
transform: translateY(-50%);
width: 45px;
height: 60px;
background-color: rgba(0,0,0,0.4);
color: #fff;
border: none;
font-size: 30px;
cursor: pointer;
z-index: 10001;
transition: background-color 0.2s;
}
#${lightboxId} .lightbox-prev:hover, #${lightboxId} .lightbox-next:hover {
background-color: rgba(0,0,0,0.7);
}
#${lightboxId} .lightbox-prev {
left: 15px;
}
#${lightboxId} .lightbox-next {
right: 15px;
}
#${lightboxId} [disabled] {
display: none;
}
#${lightboxId} .lightbox-dimensions {
position: absolute;
bottom: 0px;
left: 50%;
transform: translateX(-50%);
background-color: rgba(0, 0, 0, 0.7);
color: #fff;
padding: 2px 4px;
border-radius: 5px;
font-size: 14px;
z-index: 10001;
}
`;
document.body.insertAdjacentHTML('beforeend', lightboxHTML);
const styleEl = document.createElement('style');
styleEl.textContent = lightboxCSS;
document.head.appendChild(styleEl);
}
setupGlobalLightbox();
app.registerExtension({
name: "KikoTools.LocalImageLoader",
async beforeRegisterNodeDef(nodeType, nodeData, app) {
@@ -162,314 +39,30 @@ app.registerExtension({
nodeType.prototype.onNodeCreated = function () {
const r = onNodeCreated?.apply(this, arguments);
const galleryContainer = document.createElement("div");
const scope = createScope();
const uniqueId = `kiko-gallery-${Math.random().toString(36).substring(2, 9)}`;
const galleryContainer = createImageBrowser(uniqueId, app.ui.settings, scope);
galleryContainer.id = uniqueId;
const folderSVG = `<svg viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" width="100%" height="100%"><path d="M928 320H488L416 232c-15.1-18.9-38.3-29.9-63.1-29.9H128c-35.3 0-64 28.7-64 64v512c0 35.3 28.7 64 64 64h800c35.3 0 64-28.7 64-64V384c0-35.3-28.7-64-64-64z" fill="#F4D03F"></path></svg>`;
const videoSVG = `<svg viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" width="100%" height="100%"><path d="M895.9 203.4H128.1c-35.3 0-64 28.7-64 64v489.2c0 35.3 28.7 64 64 64h767.8c35.3 0 64-28.7 64-64V267.4c0-35.3-28.7-64-64-64zM384 691.2V332.8L668.1 512 384 691.2z" fill="#AED6F1"></path></svg>`;
const audioSVG = `<svg viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" width="100%" height="100%"><path d="M768 256H256c-35.3 0-64 28.7-64 64v384c0 35.3 28.7 64 64 64h512c35.3 0 64-28.7 64-64V320c0-35.3-28.7-64-64-64zM512 665.6c-84.8 0-153.6-68.8-153.6-153.6S427.2 358.4 512 358.4s153.6 68.8 153.6 153.6-68.8 153.6-153.6 153.6z" fill="#A9DFBF"></path><path d="M512 409.6c-56.5 0-102.4 45.9-102.4 102.4s45.9 102.4 102.4 102.4 102.4-45.9 102.4-102.4-45.9-102.4-102.4-102.4z" fill="#A9DFBF"></path></svg>`;
galleryContainer.innerHTML = `
<style>
#${uniqueId} {
width: 100%;
height: 100%;
overflow: hidden;
box-sizing: border-box;
}
#${uniqueId} .kiko-container-wrapper {
width: 100%;
height: 100%;
font-family: sans-serif;
color: #ccc;
box-sizing: border-box;
display: flex;
flex-direction: column;
padding: 5px;
overflow: hidden;
}
#${uniqueId} .kiko-controls {
display: flex;
flex-wrap: wrap;
gap: 5px;
margin-bottom: 5px;
align-items: center;
flex-shrink: 0;
}
#${uniqueId} .kiko-controls label {
margin-left: 0px;
font-size: 11px;
white-space: nowrap;
}
#${uniqueId} .kiko-controls input,
#${uniqueId} .kiko-controls select,
#${uniqueId} .kiko-controls button {
background-color: #333;
color: #ccc;
border: 1px solid #555;
border-radius: 4px;
padding: 2px 4px;
font-size: 11px;
}
#${uniqueId} .kiko-controls input[type=text] {
flex-grow: 1;
min-width: 100px;
}
#${uniqueId} .kiko-path-controls {
flex-grow: 1;
display: flex;
gap: 3px;
}
#${uniqueId} .kiko-path-presets {
flex-grow: 1;
}
#${uniqueId} .kiko-controls button {
cursor: pointer;
}
#${uniqueId} .kiko-controls button:hover {
background-color: #444;
}
#${uniqueId} .kiko-controls button:disabled {
background-color: #222;
cursor: not-allowed;
}
#${uniqueId} .kiko-cardholder {
position: relative;
overflow-y: auto;
overflow-x: hidden;
background: #222;
padding: 3px;
border-radius: 5px;
flex-grow: 1;
flex-shrink: 1;
min-height: 200px;
width: 100%;
transition: opacity 0.2s ease-in-out;
}
#${uniqueId} .kiko-gallery-card {
position: absolute;
border: 3px solid transparent;
border-radius: 8px;
box-sizing: border-box;
transition: all 0.3s ease;
cursor: pointer;
display: flex;
flex-direction: column;
background-color: #2a2a2a;
}
#${uniqueId} .kiko-gallery-card.kiko-selected {
border-color: #00FFC9;
}
#${uniqueId} .kiko-card-media-wrapper {
flex-grow: 1;
position: relative;
display: flex;
align-items: center;
justify-content: center;
min-height: 100px;
}
#${uniqueId} .kiko-gallery-card img,
#${uniqueId} .kiko-gallery-card video {
width: 100%;
height: auto;
border-top-left-radius: 5px;
border-top-right-radius: 5px;
display: block;
}
#${uniqueId} .kiko-folder-card,
#${uniqueId} .kiko-audio-card {
background-color: transparent;
flex-grow: 1;
padding: 10px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
text-align: center;
}
#${uniqueId} .kiko-folder-card:hover,
#${uniqueId} .kiko-audio-card:hover {
background-color: #444;
}
#${uniqueId} .kiko-parent-folder-card {
background-color: transparent;
}
#${uniqueId} .kiko-parent-folder-card:hover {
background-color: #555;
}
#${uniqueId} .kiko-folder-icon,
#${uniqueId} .kiko-audio-icon {
width: 60%;
height: 60%;
margin-bottom: 8px;
}
#${uniqueId} .kiko-folder-name,
#${uniqueId} .kiko-audio-name {
font-size: 12px;
word-break: break-all;
user-select: none;
}
#${uniqueId} .kiko-video-card-overlay {
position: absolute;
top: 5px;
left: 5px;
width: 24px;
height: 24px;
opacity: 0.8;
pointer-events: none;
}
#${uniqueId} .kiko-card-info-panel {
flex-shrink: 0;
background-color: #353535;
padding: 4px;
border-bottom-left-radius: 5px;
border-bottom-right-radius: 5px;
min-height: 24px;
font-size: 10px;
text-align: center;
color: #aaa;
}
#${uniqueId} .kiko-pagination {
display: flex;
justify-content: center;
gap: 3px;
margin-top: 3px;
flex-shrink: 0;
padding-bottom: 2px;
}
#${uniqueId} .kiko-status-message {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
background-color: rgba(0, 0, 0, 0.8);
color: white;
padding: 10px;
border-radius: 5px;
display: none;
z-index: 1000;
}
#${uniqueId} .kiko-path-input-wrapper {
position: relative;
flex-grow: 1;
min-width: 100px;
}
#${uniqueId} .kiko-path-input-wrapper input {
width: 100%;
box-sizing: border-box;
}
#${uniqueId} .kiko-autocomplete-dropdown {
position: absolute;
top: 100%;
left: 0;
right: 0;
max-height: 250px;
overflow-y: auto;
background-color: #2a2a2a;
border: 1px solid #555;
border-top: none;
border-radius: 0 0 4px 4px;
z-index: 2000;
display: none;
}
#${uniqueId} .kiko-autocomplete-dropdown.visible {
display: block;
}
#${uniqueId} .kiko-autocomplete-item {
padding: 6px 8px;
cursor: pointer;
display: flex;
align-items: center;
gap: 8px;
font-size: 12px;
border-bottom: 1px solid #333;
}
#${uniqueId} .kiko-autocomplete-item:last-child {
border-bottom: none;
}
#${uniqueId} .kiko-autocomplete-item:hover,
#${uniqueId} .kiko-autocomplete-item.selected {
background-color: #3a3a3a;
}
#${uniqueId} .kiko-autocomplete-item .folder-icon {
width: 16px;
height: 16px;
flex-shrink: 0;
}
#${uniqueId} .kiko-autocomplete-item .path-text {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
#${uniqueId} .kiko-autocomplete-item .folder-name {
color: #fff;
}
#${uniqueId} .kiko-autocomplete-item .parent-path {
color: #888;
font-size: 10px;
}
</style>
<div class="kiko-container-wrapper">
<!-- Path controls -->
<div class="kiko-controls">
<div class="kiko-path-controls">
<select class="kiko-path-presets" title="Saved paths">
<option value="">-- Saved Paths --</option>
</select>
<button class="kiko-save-path" title="Save current path to favorites">💾</button>
<button class="kiko-delete-path" title="Remove selected path from favorites">✕</button>
</div>
</div>
<!-- Directory input -->
<div class="kiko-controls">
<button class="kiko-up-folder" title="Navigate to parent folder">⬆️</button>
<div class="kiko-path-input-wrapper">
<input type="text" class="kiko-path-input" placeholder="Enter directory path... (type / to browse)" />
<div class="kiko-autocomplete-dropdown"></div>
</div>
<button class="kiko-browse" title="Refresh folder">🔄</button>
</div>
<!-- View options -->
<div class="kiko-controls">
<label>
<input type="checkbox" class="kiko-hide-dot-folders" checked /> Hide .folders
</label>
<select class="kiko-sort-by">
<option value="name">Name</option>
<option value="date">Date</option>
<option value="size">Size</option>
</select>
<select class="kiko-sort-order">
<option value="asc">↑</option>
<option value="desc">↓</option>
</select>
<button class="kiko-refresh">🔄</button>
</div>
<!-- Gallery -->
<div class="kiko-status-message">Loading...</div>
<div class="kiko-cardholder"></div>
<!-- Pagination -->
<div class="kiko-pagination"></div>
</div>
`;
// Add the widget to the node
this.galleryWidget = this.addDOMWidget("kiko_local_image_gallery", "div", galleryContainer, {
serialize: false,
hideOnZoom: false,
getMinHeight: () => 350,
});
// Set initial size for the node
this.size = [800, 600];
this.size = [800, 900];
this.setSize(this.size);
// Initialize gallery functionality
const node = this;
const fetchApi = (path, options = {}) => api.fetchApi(path, { ...options, signal: scope.signal });
let loadVersion = 0;
const container = galleryContainer.querySelector('.kiko-container-wrapper');
const pathInput = container.querySelector('.kiko-path-input');
const pathPresets = container.querySelector('.kiko-path-presets');
@@ -497,61 +90,19 @@ app.registerExtension({
audio: null
};
// Utility: Debounce function
const debounce = (func, delay) => {
let timeoutId;
return (...args) => {
clearTimeout(timeoutId);
timeoutId = setTimeout(() => func.apply(this, args), delay);
};
};
// Apply responsive masonry layout
const applyMasonryLayout = () => {
const minCardWidth = 120;
const gap = 5;
const containerWidth = cardHolder.clientWidth - 10; // Account for padding
if (containerWidth <= 0) return;
const columnCount = Math.max(1, Math.floor(containerWidth / (minCardWidth + gap)));
const totalGapSpace = (columnCount - 1) * gap;
const actualCardWidth = Math.floor((containerWidth - totalGapSpace) / columnCount);
const columnHeights = new Array(columnCount).fill(0);
const cards = cardHolder.querySelectorAll('.kiko-gallery-card');
cards.forEach(card => {
card.style.width = `${actualCardWidth}px`;
const minHeight = Math.min(...columnHeights);
const columnIndex = columnHeights.indexOf(minHeight);
card.style.left = `${columnIndex * (actualCardWidth + gap)}px`;
card.style.top = `${minHeight}px`;
columnHeights[columnIndex] += card.offsetHeight + gap;
});
// Set container height - let scrollbar handle overflow
const maxHeight = Math.max(...columnHeights);
if (maxHeight > 0) {
cardHolder.style.minHeight = `${Math.min(200, maxHeight)}px`;
}
};
const debouncedLayout = debounce(applyMasonryLayout, 20);
// Set up ResizeObserver for responsive layout
this.kikoResizeObserver = new ResizeObserver(debouncedLayout);
this.kikoResizeObserver.observe(cardHolder);
const debounce = scope.debounce;
const originalOnRemoved = this.onRemoved;
this.onRemoved = function () {
if (this.kikoResizeObserver) {
this.kikoResizeObserver.disconnect();
this.kikoResizeObserver = null;
}
scope.dispose();
return originalOnRemoved?.apply(this, arguments);
};
scope.cleanup(() => { galleryContainer.remove(); });
scope.listen(container.querySelector('.ca-file-filter'), 'input', renderGallery);
const showStatus = (message) => {
if (scope.signal.aborted) return;
statusMessage.style.display = 'block';
statusMessage.dataset.state = 'error';
statusMessage.textContent = message;
};
@@ -562,7 +113,7 @@ app.registerExtension({
async function fetchDirectorySuggestions(path) {
try {
const response = await api.fetchApi(`/kiko_local_image_loader/list_directories?path=${encodeURIComponent(path)}`);
const response = await fetchApi(`/kiko_local_image_loader/list_directories?path=${encodeURIComponent(path)}`);
if (!response.ok) {
// Disallowed or invalid paths simply yield no suggestions
return [];
@@ -570,7 +121,7 @@ app.registerExtension({
const data = await response.json();
return data.directories || [];
} catch (error) {
console.error('Error fetching directories:', error);
if (!scope.signal.aborted) console.error('Error fetching directories:', error);
return [];
}
}
@@ -639,10 +190,13 @@ app.registerExtension({
});
}
let suggestionVersion = 0;
const debouncedAutocomplete = debounce(async (value) => {
const version = ++suggestionVersion;
const suggestions = await fetchDirectorySuggestions(value);
renderAutocompleteSuggestions(suggestions);
if (!scope.signal.aborted && version === suggestionVersion && pathInput.value === value && document.activeElement === pathInput) renderAutocompleteSuggestions(suggestions);
}, 150);
const delayedHideAutocomplete = debounce(hideAutocomplete, 150);
// Path input event handlers for autocomplete
pathInput.addEventListener('input', (e) => {
@@ -663,7 +217,7 @@ app.registerExtension({
pathInput.addEventListener('blur', () => {
// Delay hiding to allow click events to register
setTimeout(hideAutocomplete, 150);
delayedHideAutocomplete();
});
pathInput.addEventListener('keydown', (e) => {
@@ -713,11 +267,12 @@ app.registerExtension({
// Load saved paths
async function loadSavedPaths() {
try {
const response = await api.fetchApi('/kiko_local_image_loader/get_saved_paths');
const response = await fetchApi('/kiko_local_image_loader/get_saved_paths');
if (!response.ok) {
throw new Error(await describeHttpError(response));
}
const data = await response.json();
if (scope.signal.aborted) return;
pathPresets.innerHTML = '<option value="">-- Saved Paths --</option>';
data.saved_paths?.forEach(path => {
@@ -728,7 +283,7 @@ app.registerExtension({
pathPresets.appendChild(option);
});
} catch (error) {
console.error('Error loading saved paths:', error);
if (!scope.signal.aborted) console.error('Error loading saved paths:', error);
}
}
@@ -737,7 +292,7 @@ app.registerExtension({
// Returns true on success.
async function addSavedPath(directory) {
try {
const response = await api.fetchApi('/kiko_local_image_loader/get_saved_paths');
const response = await fetchApi('/kiko_local_image_loader/get_saved_paths');
if (!response.ok) {
throw new Error(await describeHttpError(response));
}
@@ -745,7 +300,7 @@ app.registerExtension({
const savedPaths = data.saved_paths || [];
if (!savedPaths.includes(directory)) {
const saveResponse = await api.fetchApi('/kiko_local_image_loader/save_paths', {
const saveResponse = await fetchApi('/kiko_local_image_loader/save_paths', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ paths: [...savedPaths, directory] })
@@ -757,6 +312,7 @@ app.registerExtension({
}
return true;
} catch (error) {
if (scope.signal.aborted) return false;
console.error('Error saving path:', error);
showStatus(`Error saving path: ${error.message}`);
return false;
@@ -775,7 +331,7 @@ app.registerExtension({
if (!selectedPath) return;
try {
const response = await api.fetchApi('/kiko_local_image_loader/get_saved_paths');
const response = await fetchApi('/kiko_local_image_loader/get_saved_paths');
if (!response.ok) {
throw new Error(await describeHttpError(response));
}
@@ -784,7 +340,7 @@ app.registerExtension({
const updatedPaths = savedPaths.filter(p => p !== selectedPath);
if (updatedPaths.length !== savedPaths.length) {
const saveResponse = await api.fetchApi('/kiko_local_image_loader/save_paths', {
const saveResponse = await fetchApi('/kiko_local_image_loader/save_paths', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ paths: updatedPaths })
@@ -795,6 +351,7 @@ app.registerExtension({
await loadSavedPaths();
}
} catch (error) {
if (scope.signal.aborted) return;
console.error('Error deleting path:', error);
showStatus(`Error deleting path: ${error.message}`);
}
@@ -818,7 +375,9 @@ app.registerExtension({
pathLabel.textContent = directory;
const addBtn = document.createElement('button');
addBtn.textContent = 'Add folder & open';
addBtn.style.marginLeft = '8px';
addBtn.type = 'button';
addBtn.className = 'ca-primary';
statusMessage.dataset.state = 'error';
addBtn.onclick = async () => {
addBtn.disabled = true;
// On failure addSavedPath shows the server's reason
@@ -831,7 +390,9 @@ app.registerExtension({
// Load images from directory
async function loadImages(directory, page = 1) {
if (!directory) return;
if (!directory || scope.signal.aborted) return;
const version = ++loadVersion;
statusMessage.dataset.state = 'loading';
statusMessage.style.display = 'block';
statusMessage.textContent = 'Loading...';
@@ -848,19 +409,22 @@ app.registerExtension({
});
try {
const response = await api.fetchApi(`/kiko_local_image_loader/images?${params}`);
const response = await fetchApi(`/kiko_local_image_loader/images?${params}`);
if (scope.signal.aborted || version !== loadVersion) return;
if (response.status === 403) {
handleFolderNotAllowed(directory, page);
return;
}
if (!response.ok) {
statusMessage.textContent = await describeHttpError(response);
const message = await describeHttpError(response);
if (version === loadVersion) showStatus(message);
return;
}
const data = await response.json();
if (scope.signal.aborted || version !== loadVersion) return;
if (data.error) {
statusMessage.textContent = data.error;
showStatus(data.error);
return;
}
@@ -878,8 +442,9 @@ app.registerExtension({
statusMessage.style.display = 'none';
} catch (error) {
if (scope.signal.aborted || version !== loadVersion) return;
console.error('Error loading images:', error);
statusMessage.textContent = 'Error loading directory';
showStatus('Error loading directory');
}
}
@@ -887,18 +452,24 @@ app.registerExtension({
function renderGallery() {
cardHolder.innerHTML = '';
if (currentItems.length === 0 && !parentDirectory) {
cardHolder.innerHTML = '<div style="text-align: center; padding: 20px; color: #666;">No items found</div>';
return;
}
// Create items array with parent folder at the beginning if it exists
const query = container.querySelector('.ca-file-filter').value.toLowerCase();
const visibleItems = currentItems.filter(item => item.name.toLowerCase().includes(query));
if (visibleItems.length === 0) {
const empty = document.createElement('div');
empty.className = 'ca-empty';
empty.textContent = query ? 'No matching items on this page' : 'No items found';
cardHolder.append(empty);
}
const itemsToRender = parentDirectory
? [{ type: 'parent_dir', name: '..', path: parentDirectory }, ...currentItems]
: currentItems;
? [{ type: 'parent_dir', name: '..', path: parentDirectory }, ...visibleItems]
: visibleItems;
itemsToRender.forEach((item, index) => {
const card = document.createElement('div');
const card = document.createElement('button');
card.type = 'button';
card.setAttribute('aria-label', item.type === 'dir' || item.type === 'parent_dir' ? `Open folder ${item.name}` : `Select ${item.type} ${item.name}`);
if (!['dir', 'parent_dir'].includes(item.type)) card.setAttribute('aria-pressed', String(selectedPaths[item.type] === item.path));
card.className = 'kiko-gallery-card';
if (item.type === 'parent_dir') {
@@ -937,10 +508,6 @@ app.registerExtension({
`;
// Trigger layout when image loads
const img = card.querySelector('img');
if (img) {
img.onload = debouncedLayout;
}
// Single click to select
card.onclick = () => selectMedia(item, 'image', card);
@@ -962,10 +529,6 @@ app.registerExtension({
<div class="kiko-card-info-panel">${escapeHtml(item.name)}</div>
`;
const img = card.querySelector('img');
if (img) {
img.onload = debouncedLayout;
}
// Single click to select
card.onclick = () => selectMedia(item, 'video', card);
@@ -1007,24 +570,27 @@ app.registerExtension({
cardHolder.appendChild(card);
});
// Apply layout after all cards are added
requestAnimationFrame(debouncedLayout);
container.querySelector('[data-file-count]').textContent = `${visibleItems.length} of ${currentItems.length} items`;
}
// Select media
async function selectMedia(item, type, cardElement) {
// Update selection
const previousPath = selectedPaths[type];
selectedPaths[type] = item.path;
// Update UI
cardHolder.querySelectorAll('.kiko-gallery-card').forEach(card => {
card.classList.remove('kiko-selected');
if (card.hasAttribute('aria-pressed')) card.setAttribute('aria-pressed', 'false');
});
cardElement.classList.add('kiko-selected');
cardElement.setAttribute('aria-pressed', 'true');
// Send to backend
try {
const response = await api.fetchApi('/kiko_local_image_loader/set_node_selection', {
const response = await fetchApi('/kiko_local_image_loader/set_node_selection', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
@@ -1034,13 +600,19 @@ app.registerExtension({
})
});
if (!response.ok) {
selectedPaths[type] = previousPath;
renderGallery();
showStatus(`Selection rejected: ${await describeHttpError(response)}`);
return;
}
// Trigger node update
if (scope.signal.aborted) return;
container.querySelector('[data-selection]').textContent = item.name;
node.setDirtyCanvas(true);
} catch (error) {
if (scope.signal.aborted) return;
selectedPaths[type] = previousPath;
renderGallery();
console.error('Error setting selection:', error);
showStatus('Error setting selection');
}
@@ -1055,9 +627,9 @@ app.registerExtension({
const createButton = (text, page) => {
const btn = document.createElement('button');
btn.textContent = text;
btn.style.cssText = 'background: #333; color: #ccc; border: 1px solid #555; padding: 1px 6px; cursor: pointer; font-size: 11px;';
btn.type = 'button';
if (page === currentPage) {
btn.style.background = '#555';
btn.setAttribute('aria-current', 'page');
}
btn.onclick = () => loadImages(currentDirectory, page);
return btn;
@@ -1073,7 +645,7 @@ app.registerExtension({
} else if (i === currentPage - 3 || i === currentPage + 3) {
const span = document.createElement('span');
span.textContent = '...';
span.style.padding = '0 5px';
pagination.appendChild(span);
}
}
@@ -1101,14 +673,14 @@ app.registerExtension({
loadSavedPaths();
// Try to load last path
api.fetchApi('/kiko_local_image_loader/get_last_path').then(async response => {
fetchApi('/kiko_local_image_loader/get_last_path').then(async response => {
if (!response.ok) return;
const data = await response.json();
if (data.last_path) {
if (data.last_path && loadVersion === 0 && !scope.signal.aborted) {
loadImages(data.last_path);
}
}).catch(error => {
console.error('Error loading last path:', error);
if (!scope.signal.aborted) console.error('Error loading last path:', error);
});
return r;
+252 -919
View File
File diff suppressed because it is too large Load Diff
+80 -525
View File
@@ -1,541 +1,96 @@
import { app } from "../../scripts/app.js";
import { api } from "../../scripts/api.js";
import { $el } from "../../scripts/ui.js";
import { app } from '../../scripts/app.js';
import { api } from '../../scripts/api.js';
import { createScope, mountTheme } from './ui/core.js';
import { createColorPicker } from './ui/color-picker.js';
import { WorkflowClock } from './ui/workflow-clock.js';
/**
* Sketch-style Color Picker for Settings
*/
class SketchColorPicker {
constructor(initialColor = '#ff9966', onChange = () => {}) {
this.color = this.hexToHsv(initialColor);
this.hex = initialColor;
this.onChange = onChange;
this.isOpen = false;
this.presetColors = [
'#D0021B', '#F5A623', '#F8E71C', '#8B572A', '#7ED321',
'#417505', '#BD10E0', '#9013FE', '#4A90D9', '#50E3C2',
'#B8E986', '#000000', '#4A4A4A', '#9B9B9B', '#FFFFFF',
];
this.createElements();
}
createElements() {
// Swatch button
this.swatch = document.createElement('div');
this.swatch.style.cssText = `
width: 50px; height: 28px; border-radius: 4px;
background: ${this.hex}; cursor: pointer;
box-shadow: 0 0 0 1px rgba(0,0,0,.2), inset 0 0 0 1px rgba(0,0,0,.1);
display: inline-block;
`;
this.swatch.addEventListener('click', (e) => {
e.stopPropagation();
this.toggle();
});
// Popup container
this.popup = document.createElement('div');
this.popup.style.cssText = `
position: absolute; z-index: 10000;
background: #2a2a2a; border-radius: 6px;
box-shadow: 0 0 0 1px rgba(255,255,255,.1), 0 8px 24px rgba(0,0,0,.4);
padding: 12px; display: none; width: 220px;
bottom: 100%; margin-bottom: 8px; right: 0;
`;
// Saturation/Brightness picker
this.satBright = document.createElement('div');
this.satBright.style.cssText = `
width: 196px; height: 140px; position: relative;
border-radius: 4px; cursor: crosshair; margin-bottom: 12px;
`;
this.updateSatBrightBackground();
this.satBrightPointer = document.createElement('div');
this.satBrightPointer.style.cssText = `
width: 14px; height: 14px; border-radius: 50%;
border: 2px solid #fff; box-shadow: 0 0 0 1px rgba(0,0,0,.3), 0 2px 4px rgba(0,0,0,.3);
position: absolute; transform: translate(-50%, -50%);
pointer-events: none;
`;
this.satBright.appendChild(this.satBrightPointer);
// Hue slider
this.hueSlider = document.createElement('div');
this.hueSlider.style.cssText = `
width: 196px; height: 14px; border-radius: 4px;
background: linear-gradient(to right, #f00 0%, #ff0 17%, #0f0 33%, #0ff 50%, #00f 67%, #f0f 83%, #f00 100%);
position: relative; cursor: pointer; margin-bottom: 12px;
`;
this.huePointer = document.createElement('div');
this.huePointer.style.cssText = `
width: 8px; height: 18px; border-radius: 3px;
background: #fff; border: 1px solid rgba(0,0,0,.3);
position: absolute; top: -2px; transform: translateX(-50%);
pointer-events: none; box-shadow: 0 1px 3px rgba(0,0,0,.3);
`;
this.hueSlider.appendChild(this.huePointer);
// Hex input row
this.hexRow = document.createElement('div');
this.hexRow.style.cssText = 'display: flex; align-items: center; margin-bottom: 12px; gap: 8px;';
this.hexInput = document.createElement('input');
this.hexInput.type = 'text';
this.hexInput.value = this.hex;
this.hexInput.style.cssText = `
flex: 1; padding: 6px 8px; border: 1px solid #444;
border-radius: 4px; font-size: 13px; font-family: monospace;
text-transform: uppercase; background: #1a1a1a; color: #eee;
`;
this.hexInput.addEventListener('change', () => this.setHex(this.hexInput.value));
const hexLabel = document.createElement('span');
hexLabel.textContent = 'Hex';
hexLabel.style.cssText = 'font-size: 12px; color: #888; min-width: 28px;';
this.hexRow.appendChild(this.hexInput);
this.hexRow.appendChild(hexLabel);
// Preset swatches
this.presetsContainer = document.createElement('div');
this.presetsContainer.style.cssText = `
display: flex; flex-wrap: wrap; gap: 6px;
border-top: 1px solid #444; padding-top: 12px;
`;
this.presetColors.forEach(color => {
const preset = document.createElement('div');
preset.style.cssText = `
width: 18px; height: 18px; border-radius: 3px;
background: ${color}; cursor: pointer;
box-shadow: inset 0 0 0 1px rgba(0,0,0,.2);
transition: transform 0.1s;
`;
preset.addEventListener('mouseenter', () => preset.style.transform = 'scale(1.15)');
preset.addEventListener('mouseleave', () => preset.style.transform = 'scale(1)');
preset.addEventListener('click', () => this.setHex(color));
this.presetsContainer.appendChild(preset);
});
// Assemble popup
this.popup.appendChild(this.satBright);
this.popup.appendChild(this.hueSlider);
this.popup.appendChild(this.hexRow);
this.popup.appendChild(this.presetsContainer);
// Event handlers
this.setupDrag(this.satBright, this.handleSatBrightChange.bind(this));
this.setupDrag(this.hueSlider, this.handleHueChange.bind(this));
// Close on outside click
this.closeHandler = (e) => {
if (!this.popup.contains(e.target) && e.target !== this.swatch) {
this.close();
}
};
}
setupDrag(element, handler) {
const onMove = (e) => {
e.preventDefault();
const rect = element.getBoundingClientRect();
const x = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width));
const y = Math.max(0, Math.min(1, (e.clientY - rect.top) / rect.height));
handler(x, y);
};
const onUp = () => {
document.removeEventListener('mousemove', onMove);
document.removeEventListener('mouseup', onUp);
};
// Click and drag behavior
element.addEventListener('mousedown', (e) => {
e.preventDefault();
onMove(e);
document.addEventListener('mousemove', onMove);
document.addEventListener('mouseup', onUp);
});
// Hover preview - color follows mouse pointer
element.addEventListener('mousemove', (e) => {
if (e.buttons === 0) { // Only on hover, not during drag
const rect = element.getBoundingClientRect();
const x = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width));
const y = Math.max(0, Math.min(1, (e.clientY - rect.top) / rect.height));
handler(x, y);
}
});
}
handleSatBrightChange(x, y) {
this.color.s = x * 100;
this.color.v = (1 - y) * 100;
this.updateFromHsv();
}
handleHueChange(x) {
this.color.h = x * 360;
this.updateFromHsv();
}
updateFromHsv() {
this.hex = this.hsvToHex(this.color.h, this.color.s, this.color.v);
this.updateUI();
this.onChange(this.hex);
}
updateUI() {
this.swatch.style.background = this.hex;
this.updateSatBrightBackground();
this.satBrightPointer.style.left = `${this.color.s}%`;
this.satBrightPointer.style.top = `${100 - this.color.v}%`;
this.huePointer.style.left = `${(this.color.h / 360) * 100}%`;
this.hexInput.value = this.hex.toUpperCase();
}
updateSatBrightBackground() {
const hueColor = this.hsvToHex(this.color.h, 100, 100);
this.satBright.style.background = `
linear-gradient(to top, #000, transparent),
linear-gradient(to right, #fff, ${hueColor})
`;
}
setHex(hex) {
if (!/^#?[0-9A-Fa-f]{6}$/.test(hex)) return;
if (!hex.startsWith('#')) hex = '#' + hex;
this.hex = hex;
this.color = this.hexToHsv(hex);
this.updateUI();
this.onChange(this.hex);
}
toggle() { this.isOpen ? this.close() : this.open(); }
open() {
this.isOpen = true;
this.popup.style.display = 'block';
this.updateUI();
setTimeout(() => document.addEventListener('click', this.closeHandler), 0);
}
close() {
this.isOpen = false;
this.popup.style.display = 'none';
document.removeEventListener('click', this.closeHandler);
}
getElement() {
const container = document.createElement('div');
container.style.cssText = 'position: relative; display: inline-block;';
container.appendChild(this.swatch);
container.appendChild(this.popup);
return container;
}
hexToHsv(hex) {
const r = parseInt(hex.slice(1, 3), 16) / 255;
const g = parseInt(hex.slice(3, 5), 16) / 255;
const b = parseInt(hex.slice(5, 7), 16) / 255;
const max = Math.max(r, g, b), min = Math.min(r, g, b);
const v = max * 100, d = max - min;
const s = max === 0 ? 0 : (d / max) * 100;
let h = 0;
if (d !== 0) {
switch (max) {
case r: h = ((g - b) / d + (g < b ? 6 : 0)) * 60; break;
case g: h = ((b - r) / d + 2) * 60; break;
case b: h = ((r - g) / d + 4) * 60; break;
}
}
return { h, s, v };
}
hsvToHex(h, s, v) {
s /= 100; v /= 100;
const c = v * s, x = c * (1 - Math.abs(((h / 60) % 2) - 1)), m = v - c;
let r = 0, g = 0, b = 0;
if (h < 60) { r = c; g = x; }
else if (h < 120) { r = x; g = c; }
else if (h < 180) { g = c; b = x; }
else if (h < 240) { g = x; b = c; }
else if (h < 300) { r = x; b = c; }
else { r = c; b = x; }
const toHex = (n) => Math.round((n + m) * 255).toString(16).padStart(2, '0');
return `#${toHex(r)}${toHex(g)}${toHex(b)}`;
}
}
/**
* Global Timer Manager
*/
const KikoGlobalTimer = {
startTime: 0,
intervalId: null,
isRunning: false,
activeNodes: new Set(),
formatTime(ms) {
if (ms < 0) ms = 0;
const minutes = String(Math.floor(ms / 60000)).padStart(2, '0');
const seconds = String(Math.floor((ms % 60000) / 1000)).padStart(2, '0');
const milliseconds = String(ms % 1000).padStart(3, '0');
return `${minutes}:${seconds}:${milliseconds}`;
},
getTimerColor() {
return app.ui.settings.getSettingValue("kikotools.workflow_timer.color") || '#ff9966';
},
isGlowEnabled() {
const val = app.ui.settings.getSettingValue("kikotools.workflow_timer.glow");
return val !== undefined ? val : true;
},
start() {
if (this.isRunning) return;
this.isRunning = true;
this.startTime = Date.now();
const timerColor = this.getTimerColor();
this.activeNodes.forEach(node => {
if (node.timerDisplay) {
node.timerDisplay.style.color = timerColor;
node.timerDisplay.classList.add('running');
}
});
this.intervalId = setInterval(() => {
const elapsed = Date.now() - this.startTime;
const timeString = this.formatTime(elapsed);
this.activeNodes.forEach(node => {
if (node.timerDisplay) {
node.timerDisplay.textContent = timeString;
}
});
}, 33);
},
stop() {
if (!this.isRunning) return;
this.isRunning = false;
clearInterval(this.intervalId);
const finalTime = Date.now() - this.startTime;
const finalTimeString = this.formatTime(finalTime);
const timerColor = this.getTimerColor();
this.activeNodes.forEach(node => {
if (node.timerDisplay) {
node.timerDisplay.textContent = finalTimeString;
node.timerDisplay.style.color = timerColor;
node.timerDisplay.classList.remove('running');
}
node.properties.elapsed_time_str = finalTimeString;
});
},
updateColor(color) {
if (this.isRunning) return;
this.activeNodes.forEach(node => {
if (node.timerDisplay) {
node.timerDisplay.style.color = color;
}
});
},
updateGlow(enabled) {
this.activeNodes.forEach(node => {
if (node.timerDisplay) {
node.timerDisplay.classList.toggle('no-glow', !enabled);
}
});
},
registerNode(node) { this.activeNodes.add(node); },
unregisterNode(node) { this.activeNodes.delete(node); },
};
/**
* ComfyUI Extension for KikoWorkflow Timer
*/
const KikoWorkflowTimerExtension = {
name: "ComfyAssets.KikoWorkflowTimer",
const clock = new WorkflowClock(app.ui.settings);
let colorPicker;
app.registerExtension({
name: 'ComfyAssets.KikoWorkflowTimer',
async init() {
// Color picker setting with custom render
app.ui.settings.addSetting({
id: "kikotools.workflow_timer.color",
name: "🫶 Workflow Timer: Color",
id: 'kikotools.workflow_timer.color',
name: '🫶 Workflow Timer: Color',
type: (name, setter, value) => {
const container = $el("div", {
style: { display: "flex", alignItems: "center", gap: "10px" }
});
const picker = new SketchColorPicker(value || '#ff9966', (newColor) => {
setter(newColor);
KikoGlobalTimer.updateColor(newColor);
});
container.appendChild(picker.getElement());
// Also show hex value as text
const hexDisplay = $el("span", {
textContent: value || '#ff9966',
style: { color: "#888", fontFamily: "monospace", fontSize: "12px" }
});
container.appendChild(hexDisplay);
// Update hex display when color changes
const origOnChange = picker.onChange;
picker.onChange = (newColor) => {
origOnChange(newColor);
hexDisplay.textContent = newColor.toUpperCase();
};
return container;
if (!colorPicker || colorPicker.scope?.signal.aborted) {
colorPicker = createColorPicker(value, setter, app.ui.settings, 'Timer color');
}
colorPicker.onChange = setter;
return colorPicker;
},
defaultValue: "#ff9966",
tooltip: "Color of the timer display when not running",
defaultValue: '#0877bb',
tooltip: 'Color of the workflow timer display',
onChange: () => clock.refresh(),
});
// Glow toggle
app.ui.settings.addSetting({
id: "kikotools.workflow_timer.glow",
name: "🫶 Workflow Timer: Enable Glow",
type: "boolean",
id: 'kikotools.workflow_timer.glow',
name: '🫶 Workflow Timer: Enable Glow',
type: 'boolean',
defaultValue: true,
tooltip: "Enable pulsing glow effect on the timer",
onChange: (value) => {
KikoGlobalTimer.updateGlow(value);
},
tooltip: 'Enable the pulsing glow unless reduced motion is preferred',
onChange: () => clock.refresh(),
});
},
async beforeRegisterNodeDef(nodeType, nodeData, app) {
if (nodeData.name === "KikoWorkflowTimer") {
const origOnNodeCreated = nodeType.prototype.onNodeCreated;
nodeType.prototype.onNodeCreated = function() {
origOnNodeCreated?.apply(this, arguments);
this.bgcolor = "#1a1a2e";
this.color = "#16213e";
this.title = "Workflow Timer";
this.properties = this.properties || {};
this.size = [300, 100];
const container = document.createElement("div");
container.className = "kiko-timer-container";
this.timerDisplay = document.createElement("div");
this.timerDisplay.className = "kiko-timer-display";
this.timerDisplay.textContent = this.properties.elapsed_time_str || "00:00:000";
const timerColor = KikoGlobalTimer.getTimerColor();
this.timerDisplay.style.color = timerColor;
if (!KikoGlobalTimer.isGlowEnabled()) {
this.timerDisplay.classList.add('no-glow');
}
container.appendChild(this.timerDisplay);
this.addDOMWidget("kikoTimer", "Kiko Timer", container, { serialize: false });
KikoGlobalTimer.registerNode(this);
};
const origOnRemoved = nodeType.prototype.onRemoved;
nodeType.prototype.onRemoved = function() {
KikoGlobalTimer.unregisterNode(this);
return origOnRemoved?.apply(this, arguments);
};
const origOnSerialize = nodeType.prototype.onSerialize;
nodeType.prototype.onSerialize = function(o) {
origOnSerialize?.apply(this, arguments);
o.properties = this.properties;
};
const origOnConfigure = nodeType.prototype.onConfigure;
nodeType.prototype.onConfigure = function(info) {
origOnConfigure?.apply(this, arguments);
this.properties = { ...(this.properties || {}), ...(info?.properties || {}) };
if (this.timerDisplay) {
this.timerDisplay.textContent = this.properties.elapsed_time_str || "00:00:000";
const timerColor = KikoGlobalTimer.getTimerColor();
this.timerDisplay.style.color = timerColor;
if (!KikoGlobalTimer.isGlowEnabled()) {
this.timerDisplay.classList.add('no-glow');
}
}
};
}
if (nodeData.name !== 'KikoWorkflowTimer') return;
const onNodeCreated = nodeType.prototype.onNodeCreated;
nodeType.prototype.onNodeCreated = function () {
const result = onNodeCreated?.apply(this, arguments);
this.timerScope = createScope();
this.properties = this.properties || {};
const container = document.createElement('div');
container.className = 'ca-tools ca-workflow-timer kiko-timer-container';
const header = document.createElement('header');
header.className = 'ca-timer-header';
const title = document.createElement('h3');
title.textContent = 'Workflow Timer';
const brand = document.createElement('span');
brand.textContent = 'ComfyAssets';
header.append(title, brand);
this.timerDisplay = document.createElement('div');
this.timerDisplay.className = 'kiko-timer-display';
this.timerDisplay.setAttribute('role', 'timer');
this.timerDisplay.setAttribute('aria-label', 'Elapsed workflow time');
this.timerDisplay.setAttribute('aria-live', 'off');
container.append(header, this.timerDisplay);
mountTheme(container, app.ui.settings, this.timerScope);
this.addDOMWidget('kikoTimer', 'Kiko Timer', container, {
serialize: false, hideOnZoom: false, getMinHeight: () => 90,
});
this.setSize([340, 180]);
clock.registerNode(this);
this.timerScope.cleanup(() => {
clock.unregisterNode(this);
container.remove();
});
return result;
};
const onRemoved = nodeType.prototype.onRemoved;
nodeType.prototype.onRemoved = function () {
this.timerScope.dispose();
return onRemoved?.apply(this, arguments);
};
const onSerialize = nodeType.prototype.onSerialize;
nodeType.prototype.onSerialize = function (data) {
const result = onSerialize?.apply(this, arguments);
data.properties = { ...data.properties, elapsed_time_str: this.properties.elapsed_time_str };
return result;
};
const onConfigure = nodeType.prototype.onConfigure;
nodeType.prototype.onConfigure = function (data) {
const result = onConfigure?.apply(this, arguments);
this.properties = { ...(this.properties || {}), ...(data?.properties || {}) };
if (this.timerDisplay) clock.renderNode(this);
return result;
};
},
setup() {
const style = document.createElement("style");
style.textContent = `
@keyframes kiko-timer-pulse {
0%, 100% { text-shadow: 0 0 15px currentColor; opacity: 0.9; }
50% { text-shadow: 0 0 25px currentColor; opacity: 1; }
}
@keyframes kiko-timer-running-pulse {
0%, 100% { text-shadow: 0 0 15px currentColor; opacity: 0.9; }
50% { text-shadow: 0 0 25px currentColor; opacity: 1; }
}
.kiko-timer-container {
width: 100%; height: 100%;
position: relative; display: flex;
align-items: center; justify-content: center;
}
.kiko-timer-display {
text-align: center; width: 100%; height: 100%;
position: absolute; top: 0; left: 0;
background: transparent; border: none;
font-family: 'JetBrains Mono', 'Fira Code', 'Consolas', 'Monaco', monospace;
box-sizing: border-box; outline: none; margin: 0;
overflow: hidden; display: flex;
justify-content: center; align-items: center;
font-size: 42px;
animation: kiko-timer-pulse 4s infinite ease-in-out;
transition: color 0.3s ease-in-out;
font-variant-numeric: tabular-nums;
letter-spacing: 0.08em;
white-space: nowrap; font-weight: 600;
}
.kiko-timer-display.no-glow {
animation: none;
text-shadow: none;
}
.kiko-timer-display.running {
animation: kiko-timer-running-pulse 2s infinite ease-in-out;
}
.kiko-timer-display.running.no-glow {
animation: none;
text-shadow: none;
}
`;
document.head.appendChild(style);
api.addEventListener("execution_start", () => KikoGlobalTimer.start());
api.addEventListener("executing", ({ detail }) => {
if (detail === null) KikoGlobalTimer.stop();
});
api.addEventListener("execution_error", () => KikoGlobalTimer.stop());
api.addEventListener("execution_interrupted", () => KikoGlobalTimer.stop());
}
};
app.registerExtension(KikoWorkflowTimerExtension);
api.addEventListener('execution_start', () => clock.start());
api.addEventListener('executing', ({ detail }) => { if (detail === null) clock.stop(); });
for (const event of ['execution_error', 'execution_interrupted']) api.addEventListener(event, () => clock.stop());
},
});
+12 -183
View File
@@ -1,197 +1,26 @@
import { app } from "../../scripts/app.js";
import { app } from '../../scripts/app.js';
import { createLegacyDialog } from './ui/dialog.js';
import { showNodeDimensionsDialog } from './views/node-dimensions.js';
/**
* KikoTools Extensions - Adds utility features to all ComfyAssets nodes
*/
app.registerExtension({
name: "ComfyAssets.Extensions",
// New API: return items to append to node right-click menu
name: 'ComfyAssets.Extensions',
getNodeMenuItems(node) {
// Only add our menu items to ComfyAssets nodes
if (!node.constructor.category || !node.constructor.category.includes("ComfyAssets")) {
return [];
}
return [
null, // separator
{
content: "🎨 Node Dimensions",
callback: () => {
KikoToolsExtensions.showNodeDimensionsDialog(node);
}
}
];
}
if (!node.constructor.category || !node.constructor.category.includes('ComfyAssets')) return [];
return [null, {
content: '🎨 Node Dimensions',
callback: () => KikoToolsExtensions.showNodeDimensionsDialog(node),
}];
},
});
/**
* KikoTools Extensions utilities
*/
class KikoToolsExtensions {
/**
* Create a dialog for settings
*/
static createDialog(htmlContent, onOK, onCancel) {
const dialog = document.createElement("div");
dialog.className = "kikotools-dialog";
dialog.style.cssText = `
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
background: #202020;
border: 2px solid #444;
border-radius: 8px;
padding: 20px;
z-index: 10000;
font-family: Arial, sans-serif;
box-shadow: 0 4px 20px rgba(0,0,0,0.5);
`;
// innerHTML used here with hardcoded templates only (numeric node dimensions)
dialog.innerHTML = htmlContent;
// Create button container
const buttonContainer = document.createElement("div");
buttonContainer.style.cssText = `
display: flex;
justify-content: flex-end;
gap: 10px;
margin-top: 15px;
`;
// Create OK button
const okButton = document.createElement("button");
okButton.textContent = "OK";
okButton.style.cssText = `
padding: 8px 20px;
background: #4A90E2;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
`;
okButton.onmouseover = () => okButton.style.background = "#5BA0F2";
okButton.onmouseout = () => okButton.style.background = "#4A90E2";
// Create Cancel button
const cancelButton = document.createElement("button");
cancelButton.textContent = "Cancel";
cancelButton.style.cssText = `
padding: 8px 20px;
background: #666;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
`;
cancelButton.onmouseover = () => cancelButton.style.background = "#777";
cancelButton.onmouseout = () => cancelButton.style.background = "#666";
buttonContainer.appendChild(cancelButton);
buttonContainer.appendChild(okButton);
dialog.appendChild(buttonContainer);
// Dialog close function
dialog.close = function() {
if (dialog.parentNode) {
dialog.parentNode.removeChild(dialog);
}
};
// Get all inputs
const inputs = Array.from(dialog.querySelectorAll("input, select"));
// Handle keyboard events
inputs.forEach(input => {
input.addEventListener("keydown", function(e) {
if (e.keyCode === 27) { // ESC
onCancel && onCancel();
dialog.close();
} else if (e.keyCode === 13) { // Enter
onOK && onOK(dialog, inputs.map(input => input.value));
dialog.close();
}
e.stopPropagation();
});
});
// Button click handlers
okButton.onclick = () => {
onOK && onOK(dialog, inputs.map(input => input.value));
dialog.close();
};
cancelButton.onclick = () => {
onCancel && onCancel();
dialog.close();
};
// Add to document
document.body.appendChild(dialog);
// Focus first input
if (inputs.length > 0) {
inputs[0].focus();
inputs[0].select();
}
return dialog;
return createLegacyDialog(htmlContent, onOK, onCancel, app.ui.settings, app.canvas.canvas);
}
/**
* Show node dimensions dialog
*/
static showNodeDimensionsDialog(node) {
const nodeWidth = Math.round(node.size[0]);
const nodeHeight = Math.round(node.size[1]);
const htmlContent = `
<div style="color: #ddd; margin-bottom: 15px;">
<h3 style="margin: 0 0 15px 0; color: #4A90E2;">Node Dimensions</h3>
<div style="display: flex; gap: 20px; align-items: center;">
<div>
<label style="display: block; margin-bottom: 5px; font-size: 12px; color: #aaa;">Width:</label>
<input type="number" class="width" value="${nodeWidth}"
style="width: 100px; padding: 5px; background: #333; color: white; border: 1px solid #555; border-radius: 4px;">
</div>
<div>
<label style="display: block; margin-bottom: 5px; font-size: 12px; color: #aaa;">Height:</label>
<input type="number" class="height" value="${nodeHeight}"
style="width: 100px; padding: 5px; background: #333; color: white; border: 1px solid #555; border-radius: 4px;">
</div>
</div>
<div style="margin-top: 10px; font-size: 11px; color: #888;">
Tip: Minimum size will be enforced based on node content
</div>
</div>
`;
this.createDialog(
htmlContent,
function(dialog, values) {
const widthValue = Number(values[0]) || nodeWidth;
const heightValue = Number(values[1]) || nodeHeight;
// Calculate minimum size based on node content
const minSize = node.computeSize();
// Apply new size (respecting minimums)
node.setSize([
Math.max(minSize[0], widthValue),
Math.max(minSize[1], heightValue)
]);
// Mark canvas as dirty to trigger redraw
node.setDirtyCanvas(true, true);
},
null
);
return showNodeDimensionsDialog(node, app);
}
}
// Export for global access
window.KikoToolsExtensions = KikoToolsExtensions;
+225 -512
View File
@@ -1,525 +1,238 @@
// ComfyUI-KikoTools - Seed History with Tracking UI
import { app } from "../../scripts/app.js";
import { app } from '../../scripts/app.js';
import { createScope, mountTheme } from './ui/core.js';
import { createSeedHistoryView } from './views/seed-history.js';
const storageKey = 'comfyui_kikotools_seed_history';
function seedValue(value) {
if (typeof value !== 'number' && (typeof value !== 'string' || !value.trim())) return null;
const seed = Number(value);
return Number.isInteger(seed) && seed >= 0 && seed <= 0xFFFFFFFF ? seed : null;
}
function historyValue(value) {
if (!Array.isArray(value)) return [];
const seeds = new Set();
return value.flatMap(item => {
const seed = seedValue(item?.seed);
if (seed === null || seeds.has(seed)) return [];
seeds.add(seed);
return [{ ...item, seed, timestamp: Number.isFinite(item.timestamp) ? item.timestamp : Date.now() }];
}).slice(0, 10);
}
app.registerExtension({
name: "comfyassets.SeedHistory",
async setup() {
// Store reference to all SeedHistory nodes
window.seedHistoryNodes = window.seedHistoryNodes || [];
},
async beforeRegisterNodeDef(nodeType, nodeData, app) {
if (nodeData.name === "SeedHistory") {
const onNodeCreated = nodeType.prototype.onNodeCreated;
nodeType.prototype.onNodeCreated = function () {
if (onNodeCreated) {
onNodeCreated.apply(this, arguments);
}
// Initialize seed history
this.seedHistory = this.loadSeedHistory();
this.hideTimer = null;
this.mouseOverHistory = false;
// Register this node in global registry
name: 'comfyassets.SeedHistory',
async setup() {
window.seedHistoryNodes = window.seedHistoryNodes || [];
window.seedHistoryNodes.push(this);
// Create UI container
const uiContainer = document.createElement("div");
uiContainer.style.padding = "8px";
uiContainer.style.backgroundColor = "#1e1e1e";
uiContainer.style.borderRadius = "6px";
uiContainer.style.marginTop = "6px";
uiContainer.style.border = "1px solid #404040";
this.buildSeedInterface(uiContainer);
// Add as widget
this.seedWidget = this.addDOMWidget(
"seed_history_ui",
"div",
uiContainer,
);
// Set node size
if (!this.hasBeenResized) {
this.size = [280, 320];
}
// Track manual resizing
const originalResize = this.onResize;
this.onResize = function (size) {
this.hasBeenResized = true;
if (originalResize) {
originalResize.call(this, size);
}
};
// Hook into seed widget callbacks to track all changes
setTimeout(() => {
this.setupSeedWidgetCallbacks();
}, 100);
// Hook directly into widget value changes
const originalOnWidgetChange = this.onWidgetChange;
this.onWidgetChange = function(name, value, oldValue, widget) {
if (name === "seed" && value !== oldValue) {
this.addSeedToHistory(value);
}
if (originalOnWidgetChange) {
return originalOnWidgetChange.call(this, name, value, oldValue, widget);
}
};
// Save/load data
const originalSerialize = this.serialize;
this.serialize = function () {
const data = originalSerialize ? originalSerialize.call(this) : {};
data.hasBeenResized = this.hasBeenResized;
data.seedHistory = this.seedHistory;
return data;
};
const originalConfigure = this.configure;
this.configure = function (data) {
if (originalConfigure) {
originalConfigure.call(this, data);
}
if (data.hasBeenResized) {
this.hasBeenResized = data.hasBeenResized;
}
if (data.seedHistory) {
this.seedHistory = data.seedHistory;
this.refreshHistoryDisplay();
}
};
// Cleanup interval on node removal
const originalOnRemoved = this.onRemoved;
this.onRemoved = function () {
if (this.seedValueWatcher) {
clearInterval(this.seedValueWatcher);
},
async beforeRegisterNodeDef(nodeType, nodeData, app) {
if (nodeData.name !== 'SeedHistory') return;
const onNodeCreated = nodeType.prototype.onNodeCreated;
nodeType.prototype.onNodeCreated = function () {
const result = onNodeCreated?.apply(this, arguments);
this.seedScope = createScope();
this.seedHistory = this.loadSeedHistory();
this.hideTimer = null;
this.seedSetupTimer = null;
this.seedValueWatcher = null;
}
// Clean up deduplication tracking
if (this.lastAddedSeed) {
this.lastAddedSeed = null;
}
// Remove from global registry
if (window.seedHistoryNodes) {
const index = window.seedHistoryNodes.indexOf(this);
if (index !== -1) {
window.seedHistoryNodes.splice(index, 1);
}
}
if (originalOnRemoved) {
originalOnRemoved.call(this);
}
this.mouseOverHistory = false;
window.seedHistoryNodes = window.seedHistoryNodes || [];
window.seedHistoryNodes.push(this);
this.buildSeedInterface();
this.seedWidget = this.addDOMWidget('seed_history_ui', 'div', this.seedView.element, {
serialize: false, hideOnZoom: false,
getMinHeight: () => 200,
});
if (!this.hasBeenResized) this.setSize([320, 420]);
const originalResize = this.onResize;
this.onResize = function () {
this.hasBeenResized = true;
return originalResize?.apply(this, arguments);
};
this.seedSetupTimer = setTimeout(() => {
this.seedSetupTimer = null;
this.setupSeedWidgetCallbacks();
}, 100);
const originalOnWidgetChange = this.onWidgetChange;
this.onWidgetChange = function (name, value, oldValue) {
if (name === 'seed' && value !== oldValue) this.addSeedToHistory(value);
return originalOnWidgetChange?.apply(this, arguments);
};
const originalSerialize = this.serialize;
this.serialize = function () {
const data = originalSerialize?.apply(this, arguments) || {};
data.hasBeenResized = this.hasBeenResized;
data.seedHistory = this.seedHistory;
return data;
};
const originalConfigure = this.configure;
this.configure = function (data) {
const result = originalConfigure?.apply(this, arguments);
if (data.hasBeenResized !== undefined) this.hasBeenResized = data.hasBeenResized;
if (data.seedHistory !== undefined) {
this.seedHistory = historyValue(data.seedHistory);
this.refreshHistoryDisplay();
}
return result;
};
this.seedScope.cleanup(() => {
clearTimeout(this.seedSetupTimer);
clearInterval(this.seedValueWatcher);
this.seedSetupTimer = null;
this.seedValueWatcher = null;
this.cancelAutoHide();
this.lastAddedSeed = null;
window.seedHistoryNodes = window.seedHistoryNodes.filter(node => node !== this);
this.seedView.element.remove();
});
const originalOnRemoved = this.onRemoved;
this.onRemoved = function () {
this.seedScope.dispose();
return originalOnRemoved?.apply(this, arguments);
};
this.setDirtyCanvas(true, true);
return result;
};
this.setDirtyCanvas(true, true);
};
// Setup seed widget callbacks to track increment/decrement/randomize
nodeType.prototype.setupSeedWidgetCallbacks = function () {
// Find the seed widget
const seedWidget = this.widgets?.find(w => w.name === "seed");
if (!seedWidget) {
setTimeout(() => this.setupSeedWidgetCallbacks(), 500);
return;
}
// Store the last known seed value to detect changes
this.lastSeedValue = seedWidget.value;
// Monitor for value changes that might not trigger callback
this.seedValueWatcher = setInterval(() => {
if (seedWidget.value !== this.lastSeedValue) {
nodeType.prototype.setupSeedWidgetCallbacks = function () {
if (this.seedScope.signal.aborted || this.seedValueWatcher !== null) return;
const seedWidget = this.widgets?.find(widget => widget.name === 'seed');
if (!seedWidget) {
this.seedSetupTimer = setTimeout(() => {
this.seedSetupTimer = null;
this.setupSeedWidgetCallbacks();
}, 500);
return;
}
this.lastSeedValue = seedWidget.value;
this.addSeedToHistory(seedWidget.value);
}
}, 1000);
};
this.seedValueWatcher = setInterval(() => {
if (seedWidget.value !== this.lastSeedValue) {
this.lastSeedValue = seedWidget.value;
this.addSeedToHistory(seedWidget.value);
}
}, 1000);
};
// Build the seed interface
nodeType.prototype.buildSeedInterface = function (container) {
container.innerHTML = "";
// Header
const headerDiv = document.createElement("div");
headerDiv.style.marginBottom = "8px";
const titleDiv = document.createElement("div");
titleDiv.style.fontWeight = "bold";
titleDiv.style.color = "#00d4ff";
titleDiv.style.textAlign = "center";
titleDiv.style.padding = "4px";
titleDiv.style.backgroundColor = "rgba(0, 212, 255, 0.1)";
titleDiv.style.borderRadius = "4px";
titleDiv.style.fontSize = "11px";
titleDiv.innerHTML = "🎲 Seed History";
headerDiv.appendChild(titleDiv);
// Action buttons
const buttonDiv = document.createElement("div");
buttonDiv.style.display = "flex";
buttonDiv.style.gap = "4px";
buttonDiv.style.marginTop = "6px";
// Generate button
const generateBtn = document.createElement("button");
generateBtn.textContent = "🎲 Generate";
generateBtn.style.flex = "1";
generateBtn.style.padding = "3px 6px";
generateBtn.style.backgroundColor = "#0088cc";
generateBtn.style.color = "white";
generateBtn.style.border = "none";
generateBtn.style.borderRadius = "3px";
generateBtn.style.cursor = "pointer";
generateBtn.style.fontSize = "10px";
generateBtn.addEventListener("click", () => this.generateRandomSeed());
buttonDiv.appendChild(generateBtn);
// Clear button
const clearBtn = document.createElement("button");
clearBtn.textContent = "🗑️ Clear";
clearBtn.style.flex = "1";
clearBtn.style.padding = "3px 6px";
clearBtn.style.backgroundColor = "#cc4444";
clearBtn.style.color = "white";
clearBtn.style.border = "none";
clearBtn.style.borderRadius = "3px";
clearBtn.style.cursor = "pointer";
clearBtn.style.fontSize = "10px";
clearBtn.addEventListener("click", () => this.clearSeedHistory());
buttonDiv.appendChild(clearBtn);
headerDiv.appendChild(buttonDiv);
container.appendChild(headerDiv);
// History display
const historyDiv = document.createElement("div");
historyDiv.style.maxHeight = "180px";
historyDiv.style.overflowY = "auto";
historyDiv.style.border = "1px solid #333";
historyDiv.style.borderRadius = "4px";
historyDiv.style.backgroundColor = "#2a2a2a";
historyDiv.style.padding = "6px";
historyDiv.style.fontSize = "10px";
historyDiv.style.fontFamily = "monospace";
// Mouse events for auto-hide
historyDiv.addEventListener("mouseenter", () => {
this.mouseOverHistory = true;
this.cancelAutoHide();
});
historyDiv.addEventListener("mouseleave", () => {
this.mouseOverHistory = false;
this.startAutoHide();
});
this.historyDisplay = historyDiv;
container.appendChild(historyDiv);
this.refreshHistoryDisplay();
};
// Load history from storage
nodeType.prototype.loadSeedHistory = function () {
try {
const stored = localStorage.getItem('comfyui_kikotools_seed_history');
return stored ? JSON.parse(stored) : [];
} catch (error) {
return [];
}
};
// Save history to storage
nodeType.prototype.saveSeedHistory = function () {
try {
localStorage.setItem('comfyui_kikotools_seed_history', JSON.stringify(this.seedHistory));
} catch (error) {
// Silently handle storage errors
}
};
// Add seed to history
nodeType.prototype.addSeedToHistory = function (seed) {
if (!seed || seed === 0) return;
const numSeed = typeof seed === 'string' ? parseInt(seed) : seed;
const now = Date.now();
// Deduplication: prevent adding the same seed within 500ms window
if (!this.lastAddedSeed) {
this.lastAddedSeed = { seed: null, timestamp: 0 };
}
const timeSinceLastAdd = now - this.lastAddedSeed.timestamp;
const isSameSeed = this.lastAddedSeed.seed === numSeed;
const isWithinDupeWindow = timeSinceLastAdd < 500; // 500ms window
if (isSameSeed && isWithinDupeWindow) {
return;
}
// Update deduplication tracking
this.lastAddedSeed = { seed: numSeed, timestamp: now };
// Remove if already exists in history
this.seedHistory = this.seedHistory.filter(item => item.seed !== numSeed);
// Add to front
this.seedHistory.unshift({
seed: numSeed,
timestamp: now,
dateString: new Date().toLocaleString()
});
// Keep only last 10
if (this.seedHistory.length > 10) {
this.seedHistory = this.seedHistory.slice(0, 10);
}
this.saveSeedHistory();
this.refreshHistoryDisplay();
this.startAutoHide();
};
// Generate new random seed
nodeType.prototype.generateRandomSeed = function () {
const newSeed = Math.floor(Math.random() * 0xFFFFFFFF); // 2**32 - 1
const seedWidget = this.widgets?.find(w => w.name === "seed");
if (seedWidget) {
seedWidget.value = newSeed;
if (seedWidget.callback) {
seedWidget.callback(newSeed, this, seedWidget);
}
}
this.addSeedToHistory(newSeed);
this.setDirtyCanvas(true, true);
this.showMessage(`Generated: ${newSeed}`, "success");
};
// Use seed from history
nodeType.prototype.useSeedFromHistory = function (historyItem, index) {
const seedWidget = this.widgets?.find(w => w.name === "seed");
if (seedWidget) {
seedWidget.value = historyItem.seed;
if (seedWidget.callback) {
seedWidget.callback(historyItem.seed, this, seedWidget);
}
}
this.highlightHistoryEntry(index);
this.setDirtyCanvas(true, true);
this.startAutoHide();
this.showMessage(`Loaded: ${historyItem.seed}`, "info");
};
// Clear history
nodeType.prototype.clearSeedHistory = function () {
this.seedHistory = [];
this.saveSeedHistory();
this.refreshHistoryDisplay();
this.showMessage("History cleared", "info");
};
// Refresh history display
nodeType.prototype.refreshHistoryDisplay = function () {
if (!this.historyDisplay) return;
if (!this.seedHistory || this.seedHistory.length === 0) {
this.historyDisplay.innerHTML =
'<div style="color: #888; text-align: center; padding: 15px;">No seeds tracked<br><small>Generate seeds to build history</small></div>';
return;
}
this.historyDisplay.innerHTML = "";
this.seedHistory.forEach((item, index) => {
const entryDiv = document.createElement("div");
entryDiv.style.padding = "4px";
entryDiv.style.marginBottom = "3px";
entryDiv.style.backgroundColor = "#333";
entryDiv.style.borderRadius = "2px";
entryDiv.style.cursor = "pointer";
entryDiv.style.border = "1px solid transparent";
entryDiv.style.lineHeight = "1.2";
entryDiv.addEventListener("mouseenter", () => {
entryDiv.style.backgroundColor = "#444";
entryDiv.style.border = "1px solid #555";
});
entryDiv.addEventListener("mouseleave", () => {
entryDiv.style.backgroundColor = "#333";
entryDiv.style.border = "1px solid transparent";
});
entryDiv.addEventListener("click", () => {
this.useSeedFromHistory(item, index);
});
const timeAgo = this.formatTimeAgo(item.timestamp);
// Build with textContent: history comes from localStorage and is untrusted
const seedDiv = document.createElement("div");
seedDiv.style.cssText = "color: #fff; font-weight: bold; margin-bottom: 1px;";
seedDiv.textContent = `🎲 ${Number(item.seed)}`;
const timeDiv = document.createElement("div");
timeDiv.style.cssText = "color: #999; font-size: 8px;";
timeDiv.textContent = `⏰ ${timeAgo}`;
entryDiv.appendChild(seedDiv);
entryDiv.appendChild(timeDiv);
this.historyDisplay.appendChild(entryDiv);
});
this.startAutoHide();
};
// Highlight selected entry
nodeType.prototype.highlightHistoryEntry = function (index) {
const entries = this.historyDisplay.querySelectorAll('div[style*="cursor: pointer"]');
entries.forEach((entry, i) => {
if (i === index) {
entry.style.backgroundColor = "#006600";
entry.style.border = "1px solid #00aa00";
} else {
entry.style.backgroundColor = "#333";
entry.style.border = "1px solid transparent";
}
});
};
// Auto-hide functionality
nodeType.prototype.startAutoHide = function () {
this.cancelAutoHide();
if (!this.mouseOverHistory) {
this.hideTimer = setTimeout(() => {
this.hideHistorySection();
}, 2500);
}
};
nodeType.prototype.cancelAutoHide = function () {
if (this.hideTimer) {
clearTimeout(this.hideTimer);
this.hideTimer = null;
}
};
nodeType.prototype.hideHistorySection = function () {
if (this.historyDisplay && !this.mouseOverHistory) {
this.historyDisplay.style.display = "none";
if (!this.restoreButton) {
const restoreDiv = document.createElement("div");
restoreDiv.style.padding = "10px";
restoreDiv.style.backgroundColor = "#2a2a2a";
restoreDiv.style.border = "1px solid #333";
restoreDiv.style.borderRadius = "4px";
restoreDiv.style.textAlign = "center";
restoreDiv.style.cursor = "pointer";
restoreDiv.style.color = "#888";
restoreDiv.style.fontSize = "10px";
restoreDiv.innerHTML = "🎲 History auto-hidden<br><small>Click to show</small>";
restoreDiv.addEventListener("mouseenter", () => {
restoreDiv.style.backgroundColor = "#333";
restoreDiv.style.color = "#bbb";
nodeType.prototype.buildSeedInterface = function () {
this.seedView = createSeedHistoryView(this.seedScope, {
generate: () => this.generateRandomSeed(),
clear: () => this.clearSeedHistory(),
select: index => this.useSeedFromHistory(this.seedHistory[index], index),
expand: expanded => expanded ? this.showHistorySection() : this.hideHistorySection(true),
});
restoreDiv.addEventListener("mouseleave", () => {
restoreDiv.style.backgroundColor = "#2a2a2a";
restoreDiv.style.color = "#888";
const { element, list, disclosure } = this.seedView;
this.historyDisplay = list;
this.restoreButton = disclosure;
mountTheme(element, app.ui.settings, this.seedScope);
this.seedScope.listen(list, 'mouseenter', () => {
this.mouseOverHistory = true;
this.cancelAutoHide();
});
restoreDiv.addEventListener("click", () => {
this.showHistorySection();
this.seedScope.listen(list, 'mouseleave', () => {
this.mouseOverHistory = false;
this.startAutoHide();
});
this.seedScope.listen(list, 'focusin', () => this.cancelAutoHide());
this.seedScope.listen(list, 'focusout', () => this.startAutoHide());
this.refreshHistoryDisplay();
};
this.restoreButton = restoreDiv;
this.historyDisplay.parentNode.insertBefore(
restoreDiv,
this.historyDisplay.nextSibling
);
}
}
};
nodeType.prototype.showHistorySection = function () {
if (this.historyDisplay) {
this.historyDisplay.style.display = "block";
if (this.restoreButton && this.restoreButton.parentNode) {
this.restoreButton.parentNode.removeChild(this.restoreButton);
this.restoreButton = null;
}
this.startAutoHide();
}
};
// Format time ago
nodeType.prototype.formatTimeAgo = function (timestamp) {
const now = Date.now();
const diff = now - timestamp;
const seconds = Math.floor(diff / 1000);
const minutes = Math.floor(seconds / 60);
const hours = Math.floor(minutes / 60);
const days = Math.floor(hours / 24);
if (days > 0) return `${days}d ago`;
if (hours > 0) return `${hours}h ago`;
if (minutes > 0) return `${minutes}m ago`;
return `${seconds}s ago`;
};
// Show messages
nodeType.prototype.showMessage = function (message, type = "info") {
const notification = document.createElement("div");
notification.style.position = "fixed";
notification.style.top = "15px";
notification.style.right = "15px";
notification.style.padding = "6px 10px";
notification.style.borderRadius = "3px";
notification.style.color = "white";
notification.style.fontSize = "10px";
notification.style.zIndex = "10000";
notification.style.maxWidth = "200px";
notification.textContent = message;
switch (type) {
case "success":
notification.style.backgroundColor = "#28a745";
break;
case "error":
notification.style.backgroundColor = "#dc3545";
break;
case "warning":
notification.style.backgroundColor = "#ffc107";
break;
default:
notification.style.backgroundColor = "#17a2b8";
}
document.body.appendChild(notification);
setTimeout(() => {
if (notification.parentNode) {
document.body.removeChild(notification);
}
}, 1800);
};
}
},
nodeType.prototype.loadSeedHistory = function () {
try { return historyValue(JSON.parse(localStorage.getItem(storageKey))); }
catch { return []; }
};
nodeType.prototype.saveSeedHistory = function () {
try { localStorage.setItem(storageKey, JSON.stringify(this.seedHistory)); }
catch { /* History remains available in the workflow if browser storage is unavailable. */ }
};
nodeType.prototype.addSeedToHistory = function (value) {
const seed = seedValue(value);
if (seed === null || this.seedScope.signal.aborted) return;
const now = Date.now();
if (this.lastAddedSeed?.seed === seed && now - this.lastAddedSeed.timestamp < 500) return;
this.lastAddedSeed = { seed, timestamp: now };
this.seedHistory = [{ seed, timestamp: now, dateString: new Date(now).toLocaleString() },
...this.seedHistory.filter(item => item.seed !== seed)].slice(0, 10);
this.saveSeedHistory();
this.refreshHistoryDisplay();
};
nodeType.prototype.generateRandomSeed = function () {
const seed = Math.floor(Math.random() * 0xFFFFFFFF);
const widget = this.widgets?.find(widget => widget.name === 'seed');
if (widget) {
widget.value = seed;
widget.callback?.(seed, app.canvas, this, [0, 0], null);
this.lastSeedValue = widget.value;
}
this.addSeedToHistory(seed);
this.setDirtyCanvas(true, true);
this.showMessage(`Generated: ${seed}`, 'success');
};
nodeType.prototype.useSeedFromHistory = function (item, index) {
const widget = this.widgets?.find(widget => widget.name === 'seed');
if (widget) {
widget.value = item.seed;
widget.callback?.(item.seed, app.canvas, this, [0, 0], null);
this.lastSeedValue = widget.value;
}
this.highlightHistoryEntry(index);
this.setDirtyCanvas(true, true);
this.startAutoHide();
this.showMessage(`Loaded: ${item.seed}`);
};
nodeType.prototype.clearSeedHistory = function () {
this.seedHistory = [];
this.lastAddedSeed = null;
this.lastSeedValue = this.widgets?.find(widget => widget.name === 'seed')?.value;
this.saveSeedHistory();
this.showHistorySection();
this.refreshHistoryDisplay();
this.showMessage('History cleared');
};
nodeType.prototype.refreshHistoryDisplay = function () {
if (!this.seedView) return;
const selected = seedValue(this.widgets?.find(widget => widget.name === 'seed')?.value);
this.seedView.render(this.seedHistory, selected, timestamp => this.formatTimeAgo(timestamp));
this.startAutoHide();
};
nodeType.prototype.highlightHistoryEntry = function (index) {
this.historyDisplay.querySelectorAll('button[data-seed-index]').forEach((entry, i) => {
entry.setAttribute('aria-pressed', String(i === index));
});
};
nodeType.prototype.startAutoHide = function () {
this.cancelAutoHide();
if (!this.seedScope.signal.aborted && !this.mouseOverHistory && this.seedHistory.length && !this.historyDisplay.hidden) {
this.hideTimer = setTimeout(() => {
this.hideTimer = null;
this.hideHistorySection();
}, 2500);
}
};
nodeType.prototype.cancelAutoHide = function () {
clearTimeout(this.hideTimer);
this.hideTimer = null;
};
nodeType.prototype.hideHistorySection = function (explicit = false) {
if (this.seedScope.signal.aborted || (!explicit && (this.mouseOverHistory || this.historyDisplay.contains(document.activeElement)))) return;
this.cancelAutoHide();
this.seedView.expand(false);
};
nodeType.prototype.showHistorySection = function () {
this.seedView.expand(true);
this.startAutoHide();
};
nodeType.prototype.formatTimeAgo = function (timestamp) {
const seconds = Math.max(0, Math.floor((Date.now() - timestamp) / 1000));
if (seconds >= 86400) return `${Math.floor(seconds / 86400)}d ago`;
if (seconds >= 3600) return `${Math.floor(seconds / 3600)}h ago`;
if (seconds >= 60) return `${Math.floor(seconds / 60)}m ago`;
return `${seconds}s ago`;
};
nodeType.prototype.showMessage = function (message, type = 'info') {
this.seedView.status.textContent = message;
this.seedView.status.dataset.kind = type;
};
},
});
+202
View File
@@ -0,0 +1,202 @@
# ComfyAssets controls: first production consumer
The Local Image Loader uses `core.js` and `controls.css` for the approved shared
blue controls and host-following light/dark themes. `../views/image-browser.js`
composes these primitives; `../kiko_local_image_loader.js` retains the controller,
API calls and existing selection persistence.
Grid thumbnails default to 224 px with square preview areas and preserve the
entire image. The Thumbnail size slider adjusts the grid from 128–384 px. A full
page scrolls within the gallery instead of compressing the rows to fit the node.
Thumbnail size is a per-node view setting for the current session.
New nodes start at 800 × 900 to leave room for the larger previews. Existing
workflows retain their saved node dimensions and can be resized normally.
`createScope()` owns listeners, delayed work and a fetch cancellation signal.
Dispose it on node removal. `mountTheme()` observes host palette changes and
removes its observers/listeners with that scope. The module has no ComfyUI imports;
the extension passes the host settings interface explicitly.
Both projects will use the same theme and controls. This is the first consumer,
not a published package yet. Keep routes, node definitions and business state out
of `ui/`; add abstractions only as demonstrated by the next consumer.
Architecture and control organization were inspired by
[Pixaroma](https://github.com/pixaroma/ComfyUI-Pixaroma). This implementation and
styling are original; none of his toolkit code or assets are vendored.
## Floating windows
`floating-window.js` owns pointer capture, keyboard movement/resizing, viewport
bounds and observer cleanup. `window.css` consumes the same control tokens as the
loader. Kiko Save Image is its first consumer; the controller has no node, API or
gallery dependencies.
Drag the title or bottom-right resize handle. Focus either handle and use arrow
keys (Shift for larger steps). Escape closes the focused window. The viewer stays
nonmodal so the canvas remains usable. Window position and size survive new
results and minimize/reopen within the session; they are not written into a
workflow or persisted across browser reloads.
The owning node controls removal. Another node may take over the shared viewer,
but deleting the previous owner must not remove it. Closing or deleting a viewer,
or replacing its results, cancels delayed bulk-open actions.
## Seed History
`../views/seed-history.js` composes the shared buttons and theme tokens. The
extension keeps seed generation, callbacks, history and workflow persistence.
History entries are native buttons: Tab to an entry and press Enter or Space to
reuse its seed. The selected entry has a visible outline and pressed state.
The list fills the available node space and scrolls as needed. It automatically
hides after 2.5 seconds of inactivity, except while hovered or keyboard-focused.
Use Show history / Hide history to control it explicitly. Feedback appears in
the node's status area instead of temporary overlays on the canvas.
The existing browser storage key and serialized `seedHistory` field are retained.
Up to ten unique valid seeds are kept, including zero; malformed entries are
ignored. Browser storage failures leave in-memory/workflow history usable.
New nodes start at 320 × 420; saved workflows retain their node dimensions.
Node removal disposes theme observers, event listeners, polling and setup/hide
timers through `createScope()`.
## Workflow Timer and color controls
Workflow Timer uses the shared shell and theme without overriding node title or
background colors. New nodes start at 340 × 180; saved dimensions and the
`elapsed_time_str` property are restored with the workflow. Serialization saves
a plain snapshot instead of a reference to live node properties.
`workflow-clock.js` coordinates one polling interval for all visible timer
nodes. New timers join the current execution; the interval stops when the last
node is removed. Completion, interruption and errors freeze the same elapsed
time across nodes. Color/glow changes apply immediately during execution.
The existing `kikotools.workflow_timer.color` and `.glow` settings are retained.
Configured colors are preserved; the default is shared blue. Glow also respects
the system's reduced-motion preference.
`createColorPicker()` provides native Hue/Saturation/Brightness sliders, validated
six-digit hex input and keyboard-operable presets inside a native modal dialog.
Changes apply immediately on input; hovering does not change the saved color.
Done or Escape closes the picker and returns focus to its trigger. Disconnecting
the settings control closes the dialog and disposes its listeners/theme scope.
The setting renderer reuses its control across value updates. Its dialog lives
in the document body so those re-renders preserve focus and native slider
interaction. Cleanup waits one microtask to distinguish synchronous reparenting
from actually closing/removing the settings control.
## Dimension swapping
Width Height Selector and Empty Latent Batch share `dimension-swap.js` and
`dimensions.js`. Both use the same native blue Swap W×H button with keyboard and
touch behavior. The selector no longer adds canvas drawing or mouse hit areas.
Repeated mounting reuses one control; node removal disposes its theme/listeners.
The supplied preset labels determine dimensions, including Unicode `×`, ASCII
`x` and formatted metadata. A matching reversed preset is retained; otherwise
the node switches to custom dimensions. Custom values swap directly. Malformed
preset dimensions leave the node unchanged. Each width/height callback receives
the host context exactly once, and existing preset callback results are retained.
The control does not change node dimensions, batch size, native input order or
backend interfaces. Workflow persistence remains with ComfyUI's native widgets;
the DOM control does not serialize its UI state.
## Node Dimensions and modal dialogs
The ComfyAssets-only Node Dimensions context menu uses `dialog.js` for a native
modal shell, shared theme tokens, focus restoration and scoped cleanup. Native
modality contains keyboard focus; Escape and Cancel close without applying.
`../views/node-dimensions.js` owns the form and node lifecycle binding.
Enter or Apply accepts positive whole pixel values and clamps them to the node's
computed content minimum. Invalid values keep the form open with an associated
error. Opening the same node again reuses its dialog; node removal closes it and
preserves the previous removal hook's context, arguments and return value.
ComfyUI continues to serialize the resulting node size.
The global `KikoToolsExtensions.createDialog(htmlContent, onOK, onCancel)` helper
remains available for trusted HTML templates. It returns the native dialog with
an immediate `close()` method. OK/Enter supplies the dialog and input/select
values; Cancel/Escape invokes cancellation once. Manual `close()` dismisses
without invoking cancellation. New views should build DOM through shared
factories rather than pass HTML to this compatibility helper.
## Display Text and Display Any
Both nodes use `../views/text-output.js` for labelled, selectable read-only text
areas and shared Copy buttons. Display Text detects paired positive/negative
prompts and offers a copy action for each. Display Any preserves the complete
formatted output in one scrollable monospace field instead of separate chunks.
The view fills the available node area; updating output preserves node size.
Copy success and failure appear in a polite status area. Replacement and removal
cancel delayed feedback and ignore pending clipboard results from the old view.
`../controllers/display-node.js` preserves native input/mode widgets and existing
lifecycle hook arguments, context and return values. Complete output, including
empty text, is saved in a `kikoDisplay: { version: 1, text }` workflow snapshot.
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.
### Custom Colors
`../kiko_custom_colors.js` opens the same shared color picker as Workflow Timer
from the host's node and group context menus. Hex entry, HSV sliders and presets
apply immediately. Full/Title/Background options, selected-node behavior, title
auto-shading and existing setting IDs are retained. Closing or replacing the
picker removes its dialog and theme observers; removing its target node also
closes it without replacing other node removal behavior. Keyboard focus returns
to the available opener, with the canvas as a fallback.
+73
View File
@@ -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`;
},
};
}
+126
View File
@@ -0,0 +1,126 @@
import { createButton, createField, createScope, createSlider, mountTheme } from './core.js';
import { hexToHsv, hsvToHex, normalizeHex } from './color.js';
let nextPickerId = 0;
const presets = ['#0877BB', '#D0021B', '#F5A623', '#F8E71C', '#8B572A', '#7ED321',
'#417505', '#BD10E0', '#9013FE', '#4A90D9', '#50E3C2', '#B8E986', '#000000', '#4A4A4A', '#9B9B9B', '#FFFFFF'];
class ColorPicker extends HTMLElement {
connectedCallback() {
if (this.scope && !this.scope.signal.aborted) return;
this.scope = createScope();
this.classList.add('ca-tools', 'ca-color-picker');
mountTheme(this, this.settings, this.scope);
const id = `ca-color-${++nextPickerId}`;
this.trigger = createButton(`Choose ${this.label.toLowerCase()}`, 'ca-color-trigger');
const swatch = document.createElement('span');
swatch.className = 'ca-color-swatch';
swatch.setAttribute('aria-hidden', 'true');
this.trigger.prepend(swatch);
const value = document.createElement('span');
value.className = 'ca-color-value';
this.dialog = document.createElement('dialog');
this.dialog.className = 'ca-tools ca-color-dialog';
mountTheme(this.dialog, this.settings, this.scope);
this.dialog.setAttribute('aria-labelledby', `${id}-title`);
const heading = document.createElement('h2');
heading.id = `${id}-title`;
heading.textContent = this.label;
const hint = document.createElement('p');
hint.textContent = 'Changes apply immediately.';
hint.className = 'ca-color-hint';
this.hexInput = document.createElement('input');
Object.assign(this.hexInput, { type: 'text', maxLength: 7, autocomplete: 'off', spellcheck: false, pattern: '#?[0-9a-fA-F]{6}' });
this.hexInput.setAttribute('aria-invalid', 'false');
this.error = document.createElement('p');
this.error.id = `${id}-error`;
this.error.className = 'ca-color-error';
this.error.setAttribute('role', 'alert');
this.hexInput.setAttribute('aria-describedby', this.error.id);
this.dialog.append(heading, hint, createField('Hex color', this.hexInput, `${id}-hex`), this.error);
this.sliders = [];
for (const [key, label, max, unit] of [['h', 'Hue', 360, 'degrees'], ['s', 'Saturation', 100, '%'], ['v', 'Brightness', 100, '%']]) {
const field = createSlider(label, { id: `${id}-${key}`, min: 0, max, step: 1, value: 0, unit }, this.scope, next => {
this.hsv[key] = next;
this.hex = hsvToHex(this.hsv.h, this.hsv.s, this.hsv.v);
this.sync();
this.onChange(this.hex);
});
this.sliders.push({ field, key, unit });
this.dialog.append(field);
}
const palette = document.createElement('div');
palette.className = 'ca-color-presets';
for (const color of presets) {
const button = createButton('', 'ca-color-preset');
button.setAttribute('aria-label', `Use color ${color}`);
button.style.setProperty('--ca-picked-color', color);
button.dataset.color = color.toLowerCase();
this.scope.listen(button, 'click', () => this.applyHex(color));
palette.append(button);
}
const done = createButton('Done', 'ca-primary');
done.classList.add('ca-color-done');
this.dialog.append(palette, done);
this.replaceChildren(this.trigger, value);
document.body.append(this.dialog);
this.valueDisplay = value;
this.scope.listen(this.trigger, 'click', () => this.dialog.showModal());
this.scope.listen(done, 'click', () => this.dialog.close());
this.scope.listen(this.dialog, 'keydown', event => event.stopPropagation());
this.scope.listen(this.dialog, 'close', () => { if (this.isConnected) this.trigger.focus(); });
this.scope.listen(this.hexInput, 'change', () => this.applyHex(this.hexInput.value));
this.scope.cleanup(() => {
if (this.dialog.open) this.dialog.close();
this.dialog.remove();
this.replaceChildren();
});
this.sync();
}
disconnectedCallback() {
const scope = this.scope;
queueMicrotask(() => { if (!this.isConnected) scope.dispose(); });
}
applyHex(value) {
const hex = normalizeHex(value);
if (!hex) {
this.hexInput.setAttribute('aria-invalid', 'true');
this.error.textContent = 'Use six hexadecimal digits, for example #0877BB.';
return;
}
this.hex = hex;
this.hsv = hexToHsv(hex);
this.sync();
this.onChange(hex);
}
sync() {
this.style.setProperty('--ca-picked-color', this.hex);
this.hexInput.value = this.hex.toUpperCase();
this.hexInput.setAttribute('aria-invalid', 'false');
this.error.textContent = '';
this.valueDisplay.textContent = this.hex.toUpperCase();
for (const { field, key, unit } of this.sliders) {
const value = Math.round(this.hsv[key]);
const input = field.querySelector('input');
input.value = value;
input.setAttribute('aria-valuetext', `${value} ${unit}`);
field.querySelector('output').value = key === 'h' ? `${value}°` : `${value} %`;
}
this.dialog.querySelectorAll('button[data-color]').forEach(button => button.setAttribute('aria-pressed', String(button.dataset.color === this.hex)));
}
}
if (!customElements.get('ca-color-picker')) customElements.define('ca-color-picker', ColorPicker);
export function createColorPicker(value, onChange, settings, label = 'Color') {
const picker = document.createElement('ca-color-picker');
picker.hex = normalizeHex(value) || '#0877bb';
picker.hsv = hexToHsv(picker.hex);
picker.onChange = onChange;
picker.settings = settings;
picker.label = label;
return picker;
}
+27
View File
@@ -0,0 +1,27 @@
export function normalizeHex(value) {
return typeof value === 'string' && /^#?[\da-f]{6}$/i.test(value)
? `#${value.replace(/^#/, '').toLowerCase()}` : null;
}
export function hexToHsv(hex) {
const [r, g, b] = hex.slice(1).match(/../g).map(value => parseInt(value, 16) / 255);
const max = Math.max(r, g, b), min = Math.min(r, g, b);
const delta = max - min;
let h = 0;
if (delta) {
if (max === r) h = (g - b) / delta + (g < b ? 6 : 0);
else if (max === g) h = (b - r) / delta + 2;
else h = (r - g) / delta + 4;
}
return { h: h * 60, s: max ? delta / max * 100 : 0, v: max * 100 };
}
export function hsvToHex(h, s, v) {
h = ((h % 360) + 360) % 360;
s = Math.max(0, Math.min(100, s)) / 100;
v = Math.max(0, Math.min(100, v)) / 100;
const c = v * s, x = c * (1 - Math.abs((h / 60) % 2 - 1)), m = v - c;
const channels = h < 60 ? [c, x, 0] : h < 120 ? [x, c, 0] : h < 180 ? [0, c, x]
: h < 240 ? [0, x, c] : h < 300 ? [x, 0, c] : [c, 0, x];
return '#' + channels.map(value => Math.round((value + m) * 255).toString(16).padStart(2, '0')).join('');
}
+231
View File
@@ -0,0 +1,231 @@
.ca-tools {
color-scheme: dark;
--ca-surface: #2b2b2e;
--ca-field: #202023;
--ca-control: #37373b;
--ca-hover: #45454b;
--ca-text: #eeeef1;
--ca-muted: #b5b5bd;
--ca-line: #48484f;
--ca-border: #7d7d87;
--ca-accent: #0877bb;
--ca-focus: #5bc2ff;
--ca-error: #d52430;
--ca-error-text: #ffadb3;
--ca-error-bg: #391d23;
font: 13px/1.5 Inter, ui-sans-serif, system-ui, sans-serif;
background: var(--ca-surface);
color: var(--ca-text);
}
.ca-tools[data-ca-theme="light"] {
color-scheme: light;
--ca-surface: #ffffff;
--ca-field: #f5f5f7;
--ca-control: #eeeef1;
--ca-hover: #e2e2e8;
--ca-text: #23232b;
--ca-muted: #5c5c68;
--ca-line: #d4d4dc;
--ca-border: #85858f;
--ca-focus: #0066a6;
--ca-error-text: #a20d20;
--ca-error-bg: #fff0f1;
}
.ca-tools :where(div, span, label, input, textarea, button, select, fieldset) { box-sizing: border-box; }
.ca-tools :where(button, input, select, textarea) { font: inherit; color: inherit; accent-color: var(--ca-accent); }
.ca-tools :where(button, select, input:not([type="radio"], [role="switch"]), textarea) { border: 1px solid var(--ca-border); border-radius: 5px; min-block-size: 30px; }
.ca-tools button { display: inline-flex; align-items: center; justify-content: center; gap: 6px; padding: 4px 10px; background: var(--ca-control); cursor: pointer; white-space: nowrap; }
.ca-tools button:hover:not(:disabled) { background: var(--ca-hover); }
.ca-tools button:disabled { opacity: .42; cursor: default; }
.ca-tools :where(input:not([type="radio"], [role="switch"]), select, textarea) { background: var(--ca-field); min-inline-size: 0; padding: 4px 8px; }
.ca-tools :where(button, input, select, textarea):focus-visible { outline: 2px solid var(--ca-focus); outline-offset: 2px; }
.ca-tools :where(input, textarea)::placeholder { color: var(--ca-muted); opacity: 1; }
.ca-tools .ca-primary, .ca-tools button[aria-current="page"] { background: var(--ca-accent); border-color: var(--ca-accent); color: #fff; }
.ca-tools .ca-primary:hover:not(:disabled) { background: #096ba6; }
.ca-tools .ca-danger { background: var(--ca-error); border-color: var(--ca-error); color: #fff; }
.ca-tools .ca-danger:hover:not(:disabled) { background: #b51c28; }
.ca-tools .ca-icon-button { min-inline-size: 30px; padding-inline: 6px; }
.ca-tools .ca-field-label { display: block; font-size: 11px; color: var(--ca-muted); }
.ca-tools .ca-field { display: inline-flex; align-items: center; gap: 6px; min-inline-size: 0; }
.ca-tools .ca-switch { display: inline-flex; align-items: center; gap: 8px; cursor: pointer; min-block-size: 30px; font-size: 12px; }
.ca-tools .ca-switch input { appearance: none; position: relative; inline-size: 36px; min-block-size: 24px; border: 1px solid var(--ca-border); border-radius: 20px; background: var(--ca-control); margin: 0; cursor: pointer; flex-shrink: 0; }
.ca-tools .ca-switch input::before { content: ""; position: absolute; inline-size: 16px; block-size: 16px; background: var(--ca-muted); inset-block-start: 3px; inset-inline-start: 3px; border-radius: 50%; }
.ca-tools .ca-switch input:checked { background: var(--ca-accent); border-color: var(--ca-accent); }
.ca-tools .ca-switch input:checked::before { background: #fff; inset-inline-start: 15px; }
.ca-tools .ca-segments { border: 0; padding: 0; margin: 0; display: inline-flex; gap: 2px; min-inline-size: 0; }
.ca-tools .ca-segments label { position: relative; display: inline-flex; min-inline-size: 24px; min-block-size: 30px; cursor: pointer; }
.ca-tools .ca-segments input { position: absolute; inset: 0; margin: 0; opacity: 0; inline-size: 100%; block-size: 100%; cursor: pointer; }
.ca-tools .ca-segments label > span { padding: 4px 12px; background: var(--ca-field); border: 1px solid var(--ca-border); border-radius: 4px; color: var(--ca-text); font-size: 13px; }
.ca-tools .ca-segments input:checked + span { background: var(--ca-accent); border-color: var(--ca-accent); color: #fff; }
.ca-tools .ca-segments input:focus-visible + span { outline: 2px solid var(--ca-focus); outline-offset: 2px; }
.ca-tools .ca-visually-hidden { position: absolute; inline-size: 1px; block-size: 1px; clip-path: inset(50%); overflow: hidden; white-space: nowrap; }
.ca-image-browser { inline-size: 100%; block-size: 100%; min-block-size: 350px; overflow: hidden; border-radius: 6px; container-type: inline-size; contain: size; }
.ca-image-browser .kiko-container-wrapper { display: flex; flex-direction: column; gap: 12px; padding: 12px; block-size: 100%; min-block-size: 350px; overflow: auto; }
.ca-image-browser .ca-browser-header { display: flex; justify-content: space-between; align-items: center; gap: 8px; border-block-end: 1px solid var(--ca-line); padding-block-end: 8px; }
.ca-image-browser .ca-browser-header strong { font-size: 13px; font-weight: 550; }
.ca-image-browser .ca-browser-header span { font-size: 10px; color: var(--ca-muted); }
.ca-image-browser :where(.kiko-controls, .ca-sort, .ca-gallery-tools) { display: flex; align-items: center; gap: 7px; min-inline-size: 0; flex-shrink: 0; }
.ca-image-browser .kiko-path-presets { flex: 1; inline-size: 60px; }
.ca-image-browser .ca-path-row { display: grid; grid-template-columns: 30px minmax(0, 1fr) auto; margin-block-start: 4px; }
.ca-image-browser .kiko-path-input-wrapper { position: relative; min-inline-size: 0; }
.ca-image-browser .kiko-path-input { inline-size: 100%; }
.ca-image-browser .ca-options { justify-content: space-between; flex-wrap: wrap; }
.ca-image-browser .ca-sort { flex-wrap: wrap; }
.ca-image-browser .ca-sort select { font-size: 12px; }
.ca-image-browser .ca-gallery-tools { flex-wrap: wrap; }
.ca-image-browser .ca-file-filter { flex: 1; inline-size: 100px; }
.ca-image-browser .kiko-cardholder { display: grid; grid-template-columns: repeat(auto-fill, minmax(min(100%, var(--ca-thumbnail-size, 224px)), 1fr)); grid-auto-rows: max-content; gap: 8px; align-items: start; align-content: start; overflow: auto; min-block-size: 100px; flex: 1; padding: 3px; }
.ca-image-browser .ca-empty { grid-column: 1 / -1; text-align: center; padding: 24px 8px; color: var(--ca-muted); font-size: 12px; }
.ca-image-browser button.kiko-gallery-card { display: flex; flex-direction: column; justify-content: start; min-inline-size: 0; overflow: hidden; border: 1px solid var(--ca-border); padding: 4px; white-space: normal; background: var(--ca-field); }
.ca-image-browser button.kiko-gallery-card.kiko-selected { border: 2px solid var(--ca-focus); padding: 3px; }
.ca-image-browser .kiko-card-media-wrapper { position: relative; inline-size: 100%; aspect-ratio: 1; flex-shrink: 0; overflow: hidden; border-radius: 3px; }
.ca-image-browser .kiko-card-media-wrapper img { display: block; inline-size: 100%; block-size: 100%; object-fit: contain; }
.ca-image-browser .kiko-card-info-panel { inline-size: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; text-align: start; padding: 6px 3px 3px; font-size: 11px; }
.ca-image-browser :where(.kiko-folder-card, .kiko-audio-card) { display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 4px; block-size: 100%; inline-size: 100%; background: var(--ca-field); }
.ca-image-browser :where(.kiko-folder-icon, .kiko-audio-icon) { inline-size: 40px; block-size: 40px; }
.ca-image-browser :where(.kiko-folder-name, .kiko-audio-name) { max-inline-size: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 11px; }
.ca-image-browser .kiko-video-card-overlay { position: absolute; inset: 0; display: grid; place-items: center; pointer-events: none; }
.ca-image-browser .kiko-video-card-overlay svg { inline-size: 36px; block-size: 36px; }
.ca-image-browser .kiko-cardholder[data-view="list"] { grid-template-columns: 1fr; }
.ca-image-browser .kiko-cardholder[data-view="list"] button.kiko-gallery-card { display: flex; flex-direction: row; align-items: center; gap: 10px; }
.ca-image-browser .kiko-cardholder[data-view="list"] .kiko-card-media-wrapper { inline-size: 64px; flex-shrink: 0; }
.ca-image-browser .ca-browser-footer { display: flex; align-items: center; justify-content: space-between; gap: 6px; flex-wrap: wrap; font-size: 11px; color: var(--ca-muted); }
.ca-image-browser .kiko-pagination { display: flex; flex-wrap: wrap; gap: 4px; }
.ca-image-browser .kiko-pagination button { font-size: 11px; min-inline-size: 30px; padding-inline: 5px; }
.ca-image-browser .ca-selection { border-block-start: 1px solid var(--ca-line); padding-block-start: 8px; }
.ca-image-browser .ca-selection strong { font-size: 12px; font-weight: 500; overflow-wrap: anywhere; }
.ca-tools .ca-range { display: flex; align-items: center; gap: 10px; flex-shrink: 0; color: var(--ca-muted); font-size: 11px; }
.ca-tools .ca-range input[type="range"] { flex: 1; inline-size: 60px; min-inline-size: 0; margin: 0; padding: 0; border: 0; min-block-size: 30px; cursor: pointer; }
.ca-tools .ca-range output { min-inline-size: 44px; font-variant-numeric: tabular-nums; }
.ca-image-browser .kiko-status-message { padding: 10px; color: var(--ca-muted); border: 1px solid var(--ca-line); border-radius: 5px; font-size: 12px; }
.ca-image-browser .kiko-status-message[data-state="error"] { color: var(--ca-error-text); background: var(--ca-error-bg); border-color: var(--ca-error); }
.ca-image-browser .kiko-status-message code { overflow-wrap: anywhere; }
.ca-image-browser .kiko-status-message button { margin-block-start: 6px; }
.ca-image-browser .kiko-autocomplete-dropdown { display: none; position: absolute; inset-block-start: 100%; inset-inline: 0; max-block-size: 180px; overflow: auto; background: var(--ca-field); border: 1px solid var(--ca-border); border-radius: 5px; z-index: 10; }
.ca-image-browser .kiko-autocomplete-dropdown.visible { display: block; }
.ca-image-browser .kiko-autocomplete-item { display: flex; align-items: center; gap: 7px; padding: 7px; cursor: pointer; }
.ca-image-browser .kiko-autocomplete-item:is(:hover, .selected) { background: var(--ca-hover); }
.ca-image-browser .kiko-autocomplete-item .folder-icon { inline-size: 16px; block-size: 16px; flex-shrink: 0; }
.ca-image-browser .kiko-autocomplete-item .path-text { min-inline-size: 0; overflow-wrap: anywhere; font-size: 12px; }
.ca-image-browser .kiko-autocomplete-item .parent-path { color: var(--ca-muted); }
@container (max-width: 440px) {
.ca-image-browser .ca-options > .ca-switch { flex-basis: 100%; }
.ca-image-browser .ca-sort { inline-size: 100%; gap: 6px; }
.ca-image-browser .ca-sort .ca-field { flex: 1; }
.ca-image-browser .ca-sort .ca-field-label { position: absolute; clip-path: inset(50%); }
.ca-image-browser .ca-sort select { inline-size: 100%; }
}
@media (pointer: coarse) { .ca-tools :where(button, select), .ca-tools .ca-segments label > span, .ca-tools .ca-switch { min-block-size: 40px; } }
.ca-seed-history { inline-size: 100%; block-size: 100%; min-block-size: 200px; display: flex; flex-direction: column; gap: 8px; padding: 10px; border: 1px solid var(--ca-line); border-radius: 6px; overflow: auto; contain: size; }
.ca-seed-history [hidden] { display: none; }
.ca-seed-header { display: flex; align-items: center; justify-content: space-between; gap: 8px; border-block-end: 1px solid var(--ca-line); padding-block-end: 6px; }
.ca-seed-header h3 { margin: 0; font-size: 13px; font-weight: 550; }
.ca-seed-header > span { font-size: 10px; color: var(--ca-muted); }
.ca-seed-actions { display: flex; gap: 7px; flex-wrap: wrap; }
.ca-seed-actions button { flex: 1; }
.ca-seed-history .ca-seed-disclosure { inline-size: 100%; flex-shrink: 0; font-size: 12px; }
.ca-seed-list { flex: 1; min-block-size: 60px; overflow: auto; padding: 3px; margin: 0; list-style: none; border: 1px solid var(--ca-line); border-radius: 5px; background: var(--ca-field); }
.ca-seed-list li + li { margin-block-start: 5px; }
.ca-seed-history button.ca-seed-entry { inline-size: 100%; display: flex; flex-wrap: wrap; justify-content: space-between; gap: 3px 10px; white-space: normal; text-align: start; }
.ca-seed-entry[aria-pressed="true"] { border-color: var(--ca-focus); outline: 1px solid var(--ca-focus); outline-offset: -2px; }
.ca-seed-value { font-variant-numeric: tabular-nums; overflow-wrap: anywhere; }
.ca-seed-time { font-size: 11px; color: var(--ca-muted); }
.ca-seed-empty { margin: 0; padding: 12px 4px; color: var(--ca-muted); font-size: 12px; text-align: center; }
.ca-seed-status { min-block-size: 20px; font-size: 11px; overflow-wrap: anywhere; color: var(--ca-muted); flex-shrink: 0; }
.ca-seed-status[data-kind="error"] { color: var(--ca-error-text); background: var(--ca-error-bg); }
.ca-workflow-timer { box-sizing: border-box; inline-size: 100%; block-size: 100%; min-block-size: 90px; padding: 10px; border: 1px solid var(--ca-line); border-radius: 6px; display: flex; flex-direction: column; overflow: hidden; container-type: inline-size; contain: size; }
.ca-timer-header { display: flex; justify-content: space-between; align-items: center; gap: 8px; border-block-end: 1px solid var(--ca-line); padding-block-end: 6px; flex-shrink: 0; }
.ca-timer-header h3 { font-size: 13px; font-weight: 550; margin: 0; }
.ca-timer-header > span { color: var(--ca-muted); font-size: 10px; }
.ca-workflow-timer .kiko-timer-display { flex: 1; min-block-size: 0; display: flex; align-items: center; justify-content: center; color: var(--ca-timer-color, var(--ca-accent)); font: 600 clamp(16px, 10cqi, 64px)/1.3 ui-monospace, monospace; font-variant-numeric: tabular-nums; white-space: nowrap; animation: ca-timer-glow 4s ease-in-out infinite; }
.ca-workflow-timer .kiko-timer-display.running { animation-duration: 2s; }
.ca-workflow-timer .kiko-timer-display.no-glow { animation: none; text-shadow: none; }
@keyframes ca-timer-glow { 0%, 100% { text-shadow: 0 0 8px currentColor; } 50% { text-shadow: 0 0 16px currentColor; } }
@media (prefers-reduced-motion: reduce) { .ca-workflow-timer .kiko-timer-display { animation: none; text-shadow: none; } }
.ca-color-picker { display: inline-flex; align-items: center; flex-wrap: wrap; gap: 8px; }
.ca-color-picker[hidden] { display: none; }
.ca-color-value { font-size: 12px; color: var(--ca-muted); font-family: ui-monospace, monospace; }
.ca-color-swatch { inline-size: 24px; block-size: 20px; border: 1px solid var(--ca-border); border-radius: 3px; background: var(--ca-picked-color); }
.ca-color-dialog { box-sizing: border-box; inline-size: 360px; max-inline-size: calc(100dvw - 32px); max-block-size: calc(100dvh - 32px); overflow: auto; padding: 16px; border: 1px solid var(--ca-border); border-radius: 8px; background: var(--ca-surface); color: var(--ca-text); box-shadow: 0 8px 28px #00000040; }
.ca-color-dialog[open] { display: flex; flex-direction: column; gap: 12px; }
.ca-color-dialog::backdrop { background: #0008; }
.ca-color-dialog h2 { margin: 0; font-size: 16px; }
.ca-color-dialog :where(.ca-color-hint, .ca-color-error) { margin: 0; font-size: 12px; }
.ca-color-hint { color: var(--ca-muted); }
.ca-color-error { color: var(--ca-error-text); }
.ca-color-error:empty { display: none; }
.ca-color-dialog .ca-field { justify-content: space-between; }
.ca-color-dialog input[type="text"] { inline-size: 130px; font-family: ui-monospace, monospace; }
.ca-color-dialog input[aria-invalid="true"] { border-color: var(--ca-error); }
.ca-color-dialog .ca-range { display: grid; grid-template-columns: 75px minmax(0, 1fr) 65px; gap: 8px; align-items: center; }
.ca-color-dialog .ca-range :where(label, output) { font-size: 11px; color: var(--ca-muted); }
.ca-color-dialog .ca-range input[type="range"] { inline-size: 100%; padding: 0; border: 0; background: transparent; }
.ca-color-presets { display: grid; grid-template-columns: repeat(8, minmax(0, 1fr)); gap: 6px; padding: 3px; }
.ca-color-dialog button.ca-color-preset { min-inline-size: 24px; min-block-size: 30px; padding: 0; background: var(--ca-picked-color); border-color: var(--ca-border); }
.ca-color-dialog button.ca-color-preset[aria-pressed="true"] { outline: 2px solid var(--ca-focus); outline-offset: 2px; }
.ca-color-done { align-self: end; }
@media (pointer: coarse) { .ca-color-dialog button.ca-color-preset { min-block-size: 40px; } }
.ca-dimension-swap { box-sizing: border-box; inline-size: 100%; block-size: 100%; display: flex; align-items: center; padding-block: 3px; }
.ca-dimension-swap button { inline-size: 100%; }
@media (forced-colors: active) {
.ca-tools { --ca-accent: Highlight; --ca-focus: Highlight; --ca-border: ButtonText; --ca-line: CanvasText; }
.ca-tools .ca-switch input:checked::before { background: HighlightText; }
.ca-tools button.kiko-selected { outline: 2px solid Highlight; }
}
.ca-dialog { box-sizing: border-box; inline-size: 400px; max-inline-size: calc(100dvw - 24px); max-block-size: calc(100dvh - 24px); padding: 0; border: 1px solid var(--ca-border); border-radius: 8px; overflow: auto; box-shadow: 0 12px 40px #0006; }
.ca-dialog[open] { display: flex; flex-direction: column; }
.ca-dialog::backdrop { background: #0007; }
.ca-dialog > header { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 12px 16px; border-block-end: 1px solid var(--ca-line); }
.ca-dialog h2 { margin: 0; font-size: 14px; font-weight: 550; }
.ca-dialog > header span { color: var(--ca-muted); font-size: 10px; }
.ca-dialog-body { padding: 16px; min-inline-size: 0; }
.ca-dialog > footer { display: flex; justify-content: flex-end; flex-wrap: wrap; gap: 8px; padding: 12px 16px; border-block-start: 1px solid var(--ca-line); }
.ca-dialog-hint { color: var(--ca-muted); margin: 0 0 12px; font-size: 12px; }
.ca-dimension-fields { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; }
.ca-dimension-fields .ca-field { display: flex; flex-direction: column; align-items: stretch; }
.ca-dimension-fields input { inline-size: 100%; }
.ca-dialog-error { margin: 12px 0 0; color: var(--ca-error-text); }
.ca-dialog-error:empty { display: none; }
.ca-dialog input[aria-invalid="true"] { border-color: var(--ca-error); }
@media (max-width: 360px) { .ca-dimension-fields { grid-template-columns: minmax(0, 1fr); } }
.ca-text-output { display: flex; flex-direction: column; gap: 10px; inline-size: 100%; block-size: 100%; min-block-size: 180px; padding: 12px; border-radius: 6px; contain: size; overflow: auto; box-sizing: border-box; }
.ca-text-output > header { display: flex; align-items: center; justify-content: space-between; gap: 8px; padding-block-end: 8px; border-block-end: 1px solid var(--ca-line); }
.ca-text-output h2 { font-size: 13px; font-weight: 550; margin: 0; }
.ca-text-output > header span { color: var(--ca-muted); font-size: 10px; }
.ca-output-content { display: flex; flex-direction: column; flex: 1; gap: 12px; min-block-size: 0; overflow: auto; }
.ca-output-section { display: flex; flex-direction: column; flex: 1 0 110px; gap: 6px; min-inline-size: 0; }
.ca-output-toolbar { display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 6px; }
.ca-output-toolbar label { font-size: 12px; font-weight: 550; }
.ca-text-output textarea { inline-size: 100%; flex: 1; min-block-size: 64px; resize: none; opacity: 1; }
.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; }
+138
View File
@@ -0,0 +1,138 @@
export function createScope() {
const controller = new AbortController();
const cleanups = [];
return {
signal: controller.signal,
listen(target, event, callback) {
target.addEventListener(event, callback, { signal: controller.signal });
},
cleanup(callback) { cleanups.push(callback); },
debounce(callback, delay) {
let timer;
cleanups.push(() => clearTimeout(timer));
return (...args) => {
clearTimeout(timer);
if (!controller.signal.aborted) timer = setTimeout(() => callback(...args), delay);
};
},
dispose() {
if (controller.signal.aborted) return;
controller.abort();
cleanups.splice(0).forEach(callback => callback());
},
};
}
export function themeFromColor(color) {
let values;
const hex = color.match(/^#([0-9a-f]{3}|[0-9a-f]{6})$/i)?.[1];
if (hex) {
const expanded = hex.length === 3 ? [...hex].map(value => value + value).join('') : hex;
values = expanded.match(/../g).map(value => parseInt(value, 16));
} else if (/^rgb\(/.test(color)) {
values = color.match(/[\d.]+/g)?.slice(0, 3).map(Number);
}
if (!values || values.length !== 3) return null;
const linear = values.map(value => value / 255).map(value => value <= .04045 ? value / 12.92 : ((value + .055) / 1.055) ** 2.4);
return linear[0] * .2126 + linear[1] * .7152 + linear[2] * .0722 > .35 ? 'light' : 'dark';
}
export function mountTheme(element, settings, scope) {
const stylesheet = new URL('./controls.css', import.meta.url).href;
if (![...document.querySelectorAll('link[rel="stylesheet"]')].some(link => link.href === stylesheet)) {
const link = document.createElement('link');
link.rel = 'stylesheet';
link.href = stylesheet;
document.head.append(link);
}
const preference = matchMedia('(prefers-color-scheme: dark)');
const update = () => {
const background = getComputedStyle(document.body).getPropertyValue('--bg-color').trim();
element.dataset.caTheme = themeFromColor(background) || (preference.matches ? 'dark' : 'light');
};
const observer = new MutationObserver(update);
for (const target of [document.documentElement, document.body]) observer.observe(target, { attributes: true, attributeFilter: ['style', 'class', 'data-theme'] });
scope.cleanup(() => observer.disconnect());
scope.listen(preference, 'change', update);
if (settings?.addEventListener) scope.listen(settings, 'Comfy.ColorPalette.change', update);
update();
}
export function createButton(text, className = '') {
const element = document.createElement('button');
element.type = 'button';
element.className = className;
element.textContent = text;
return element;
}
export function createSlider(label, { id, min, max, step, value, unit }, scope, onInput) {
const field = document.createElement('div');
field.className = 'ca-range';
const caption = document.createElement('label');
caption.htmlFor = id;
caption.textContent = label;
const input = document.createElement('input');
Object.assign(input, { type: 'range', id, min, max, step, value });
const output = document.createElement('output');
output.setAttribute('for', id);
output.value = `${value} ${unit}`;
scope.listen(input, 'input', () => {
output.value = `${input.value} ${unit}`;
onInput(Number(input.value));
});
field.append(caption, input, output);
return field;
}
export function createField(label, input, id) {
const field = document.createElement('label');
field.className = 'ca-field';
field.htmlFor = id;
const caption = document.createElement('span');
caption.className = 'ca-field-label';
caption.textContent = label;
input.id = id;
field.append(caption, input);
return field;
}
export function createSwitch(label, className, id) {
const input = document.createElement('input');
input.type = 'checkbox';
input.role = 'switch';
input.checked = true;
input.className = className;
const field = createField(label, input, id);
field.className = 'ca-switch';
field.replaceChildren(input, document.createTextNode(label));
return field;
}
export function createSelect(options, className) {
const select = document.createElement('select');
select.className = className;
for (const [value, text] of options) select.add(new Option(text, value));
return select;
}
export function createSegments(label, options, name, scope, onChange) {
const fieldset = document.createElement('fieldset');
fieldset.className = 'ca-segments';
const legend = document.createElement('legend');
legend.className = 'ca-visually-hidden';
legend.textContent = label;
fieldset.append(legend);
options.forEach(([value, text], index) => {
const input = document.createElement('input');
input.type = 'radio';
input.name = name;
input.value = value;
input.checked = index === 0;
const field = createField(text, input, `${name}-${value}`);
field.replaceChildren(input, field.firstChild);
scope.listen(input, 'change', () => onChange(value));
fieldset.append(field);
});
return fieldset;
}
+86
View File
@@ -0,0 +1,86 @@
import { createScope, mountTheme, createButton } from './core.js';
let nextDialog = 0;
export function createDialog({ title, settings, fallbackFocus, onCancel }) {
const scope = createScope();
const element = document.createElement('dialog');
element.id = `ca-dialog-${++nextDialog}`;
element.className = 'ca-tools ca-dialog kikotools-dialog';
const header = document.createElement('header');
const heading = document.createElement('h2');
heading.id = `${element.id}-title`;
heading.textContent = title;
element.setAttribute('aria-labelledby', heading.id);
const brand = document.createElement('span');
brand.textContent = 'ComfyAssets';
header.append(heading, brand);
const body = document.createElement('div');
body.className = 'ca-dialog-body';
const actions = document.createElement('footer');
element.append(header, body, actions);
mountTheme(element, settings, scope);
let opener;
const nativeClose = element.close.bind(element);
const finish = reason => {
if (scope.signal.aborted) return;
scope.dispose();
if (opener?.isConnected && opener !== document.body) opener.focus();
else fallbackFocus?.focus();
if (reason === 'cancel') onCancel?.();
};
element.close = (reason = 'dismiss') => {
if (scope.signal.aborted) return;
nativeClose(reason);
finish(reason);
};
scope.cleanup(() => element.remove());
scope.listen(element, 'close', () => finish(element.returnValue));
scope.listen(element, 'cancel', event => {
event.preventDefault();
element.close('cancel');
});
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,
open(initialFocus) {
opener = document.activeElement;
document.body.append(element);
element.showModal();
initialFocus?.focus();
},
};
}
export function createLegacyDialog(htmlContent, onOK, onCancel, settings, fallbackFocus) {
const dialog = createDialog({ title: 'KikoTools', settings, fallbackFocus, onCancel });
const form = document.createElement('form');
form.id = `${dialog.element.id}-form`;
form.innerHTML = htmlContent;
dialog.body.append(form);
const cancel = createButton('Cancel');
const ok = createButton('OK', 'ca-primary');
ok.type = 'submit';
ok.setAttribute('form', form.id);
dialog.actions.append(cancel, ok);
dialog.scope.listen(cancel, 'click', () => dialog.close('cancel'));
dialog.scope.listen(form, 'keydown', event => {
if (event.key === 'Enter' && !event.isComposing && event.target.matches('select')) {
event.preventDefault();
event.stopPropagation();
form.requestSubmit();
}
});
dialog.scope.listen(form, 'submit', event => {
event.preventDefault();
onOK?.(dialog.element, [...form.querySelectorAll('input, select')].map(input => input.value));
dialog.close('apply');
});
const firstInput = form.querySelector('input, select');
dialog.open(firstInput);
if (firstInput?.tagName === 'INPUT') firstInput.select();
return dialog.element;
}
+25
View File
@@ -0,0 +1,25 @@
import { createButton, createScope, mountTheme } from './core.js';
export function mountDimensionSwap(node, app) {
if (node.swapScope && !node.swapScope.signal.aborted) return node.swapButtonWidget;
const scope = createScope();
node.swapScope = scope;
const container = document.createElement('div');
container.className = 'ca-tools ca-dimension-swap';
const button = createButton('Swap W×H', 'ca-primary');
button.setAttribute('aria-label', 'Swap width and height');
button.title = 'Swap width and height';
container.append(button);
mountTheme(container, app.ui.settings, scope);
scope.listen(button, 'click', () => node.swapDimensions());
node.swapButtonWidget = node.addDOMWidget('swap_button', 'div', container, {
serialize: false, hideOnZoom: false, getMinHeight: () => 46, getMaxHeight: () => 46,
});
scope.cleanup(() => container.remove());
const onRemoved = node.onRemoved;
node.onRemoved = function () {
scope.dispose();
return onRemoved?.apply(this, arguments);
};
return node.swapButtonWidget;
}
+67
View File
@@ -0,0 +1,67 @@
const swapping = new WeakSet();
export function extractResolutionFromPreset(value) {
return typeof value === 'string' && value !== 'custom' ? value.split(' - ')[0].trim() : null;
}
export function dimensionsFromPreset(value) {
const match = extractResolutionFromPreset(value)?.match(/^(\d+)\s*[×x]\s*(\d+)$/);
if (!match) return null;
const dimensions = match.slice(1).map(Number);
return dimensions.every(value => Number.isSafeInteger(value) && value > 0) ? dimensions : null;
}
function widgets(node) {
return ['preset', 'width', 'height'].map(name => node.widgets.find(widget => widget.name === name));
}
function notifyDimensions(width, height, canvas, node, pos, event) {
for (const widget of [width, height]) widget.callback?.(widget.value, canvas, node, pos, event);
}
export function bindDimensionPresets(node, app) {
const [preset] = widgets(node);
if (!preset) return;
const callback = preset.callback;
preset.callback = function (value, canvas = app.canvas, owner = node, pos = [0, 0], event = null) {
const result = callback?.apply(this, arguments);
if (swapping.has(node)) return result;
const dimensions = dimensionsFromPreset(value);
const [, width, height] = widgets(node);
if (dimensions && width && height) {
[width.value, height.value] = dimensions;
notifyDimensions(width, height, canvas, owner, pos, event);
}
return result;
};
}
export function swapDimensions(node, app) {
const [preset, width, height] = widgets(node);
if (!preset || !width || !height || swapping.has(node)) return;
const custom = preset.value === 'custom';
const dimensions = custom ? [width.value, height.value] : dimensionsFromPreset(preset.value);
if (!dimensions) return;
const [w, h] = dimensions;
let match;
if (!custom) {
let options = Array.isArray(preset.options) ? preset.options : preset.options?.values;
if (typeof options === 'function') options = options.call(preset);
match = (Array.isArray(options) ? options : []).find(value => {
const parsed = dimensionsFromPreset(value);
return parsed?.[0] === h && parsed?.[1] === w;
});
}
swapping.add(node);
try {
[width.value, height.value] = [h, w];
if (!custom) {
preset.value = match || 'custom';
preset.callback?.(preset.value, app.canvas, node, [0, 0], null);
}
notifyDimensions(width, height, app.canvas, node, [0, 0], null);
node.graph?.setDirtyCanvas?.(true, true);
} finally {
swapping.delete(node);
}
}
+80
View File
@@ -0,0 +1,80 @@
export function fitWindowRect(rect, viewport) {
const width = Math.min(Math.max(280, rect.width), Math.max(1, viewport.width - 16));
const height = Math.min(Math.max(200, rect.height), Math.max(1, viewport.height - 16));
return {
width, height,
x: Math.max(8, Math.min(rect.x, viewport.width - width - 8)),
y: Math.max(8, Math.min(rect.y, viewport.height - height - 8)),
};
}
export function mountFloatingWindow(element, scope, onClose) {
const stylesheet = new URL('./window.css', import.meta.url).href;
if (![...document.querySelectorAll('link[rel="stylesheet"]')].some(link => link.href === stylesheet)) {
const link = document.createElement('link');
Object.assign(link, { rel: 'stylesheet', href: stylesheet });
document.head.append(link);
}
let gesture;
const viewport = () => ({ width: innerWidth, height: innerHeight });
const move = (x, y) => {
const rect = element.getBoundingClientRect();
const next = fitWindowRect({ x, y, width: rect.width, height: rect.height }, viewport());
element.style.left = `${next.x}px`;
element.style.top = `${next.y}px`;
element.style.right = 'auto';
};
const resize = (width, height) => {
const rect = element.getBoundingClientRect();
const next = fitWindowRect({ x: rect.x, y: rect.y, width, height }, viewport());
element.style.setProperty('--ca-window-width', `${next.width}px`);
element.style.setProperty('--ca-window-height', `${next.height}px`);
move(next.x, next.y);
};
const end = () => {
if (gesture && element.hasPointerCapture(gesture.id)) element.releasePointerCapture(gesture.id);
gesture = null;
element.classList.remove('ca-dragging');
};
scope.listen(element, 'pointerdown', event => {
const handle = event.target.closest('[data-window-move], [data-window-resize]');
if (!handle || event.button !== 0) return;
const rect = element.getBoundingClientRect();
gesture = { id: event.pointerId, kind: handle.hasAttribute('data-window-resize') ? 'resize' : 'move', rect, x: event.clientX, y: event.clientY };
handle.focus();
element.setPointerCapture(event.pointerId);
element.classList.add('ca-dragging');
event.preventDefault();
});
scope.listen(element, 'pointermove', event => {
if (!gesture || event.pointerId !== gesture.id) return;
const dx = event.clientX - gesture.x;
const dy = event.clientY - gesture.y;
if (gesture.kind === 'resize') resize(gesture.rect.width + dx, gesture.rect.height + dy);
else move(gesture.rect.x + dx, gesture.rect.y + dy);
});
for (const event of ['pointerup', 'pointercancel', 'lostpointercapture']) scope.listen(element, event, end);
scope.listen(element, 'keydown', event => {
if (event.key === 'Escape') { event.preventDefault(); event.stopPropagation(); onClose(); return; }
const handle = event.target.closest('[data-window-move], [data-window-resize]');
if (!handle || !['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown'].includes(event.key)) return;
event.preventDefault();
event.stopPropagation();
const delta = event.shiftKey ? 40 : 16;
const dx = event.key === 'ArrowLeft' ? -delta : event.key === 'ArrowRight' ? delta : 0;
const dy = event.key === 'ArrowUp' ? -delta : event.key === 'ArrowDown' ? delta : 0;
const rect = element.getBoundingClientRect();
if (handle.hasAttribute('data-window-resize')) resize(rect.width + dx, rect.height + dy);
else move(rect.x + dx, rect.y + dy);
});
const keepReachable = () => {
if (element.hidden) return;
const rect = element.getBoundingClientRect();
move(rect.x, rect.y);
};
scope.listen(window, 'resize', keepReachable);
const observer = new ResizeObserver(keepReachable);
observer.observe(element);
scope.cleanup(() => { observer.disconnect(); end(); });
return { fit: keepReachable };
}
+33
View File
@@ -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();
});
}
+51
View File
@@ -0,0 +1,51 @@
.ca-floating-window {
box-sizing: border-box;
position: fixed;
inset-block-start: 100px;
right: 20px;
inline-size: min(var(--ca-window-width, 600px), calc(100dvw - 16px));
block-size: min(var(--ca-window-height, 560px), calc(100dvh - 16px));
min-inline-size: min(280px, calc(100dvw - 16px));
min-block-size: min(200px, calc(100dvh - 16px));
display: flex;
flex-direction: column;
overflow: hidden;
border: 1px solid var(--ca-border);
border-radius: 8px;
box-shadow: 0 8px 28px #00000040;
z-index: 10000;
}
.ca-floating-window[hidden], .ca-floating-window [hidden] { display: none; }
.ca-floating-window.ca-minimized { block-size: auto; min-block-size: 0; }
.ca-window-header { display: flex; align-items: center; gap: 8px; padding: 7px 10px; border-block-end: 1px solid var(--ca-line); flex-shrink: 0; }
.ca-window-header .ca-window-handle { min-inline-size: 0; flex: 1; justify-content: start; border: 0; background: transparent; padding-inline: 0; overflow: hidden; text-overflow: ellipsis; cursor: grab; touch-action: none; -webkit-user-select: none; user-select: none; }
.ca-floating-window.ca-dragging [data-window-move] { cursor: grabbing; }
.ca-window-header .kiko-viewer-controls { display: flex; align-items: center; gap: 6px; }
.ca-window-header button.kiko-control-btn { min-inline-size: 30px; padding-inline: 6px; }
.ca-window-header .ca-window-brand { color: var(--ca-muted); font-size: 10px; }
.ca-floating-window .ca-window-resize { position: absolute; inset-inline-end: 2px; inset-block-end: 2px; min-inline-size: 24px; min-block-size: 24px; border: 0; background: var(--ca-surface); color: var(--ca-muted); cursor: nwse-resize; touch-action: none; padding: 0; }
.ca-floating-window .kiko-viewer-container { padding: 12px 12px 28px; overflow: auto; min-block-size: 0; flex: 1; }
.ca-floating-window .kiko-image-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 224px), 1fr)); gap: 10px; align-items: start; }
.ca-floating-window .kiko-image-item { min-inline-size: 0; border: 1px solid var(--ca-border); border-radius: 5px; background: var(--ca-field); padding: 6px; }
.ca-floating-window .kiko-image-open { padding: 0; border: 0; background: var(--ca-field); inline-size: 100%; display: block; }
.ca-floating-window .kiko-image-open img { display: block; inline-size: 100%; block-size: 230px; object-fit: contain; }
.ca-floating-window.ca-single-image .kiko-viewer-container:not([hidden]) { display: flex; flex-direction: column; }
.ca-floating-window.ca-single-image .kiko-image-grid { display: flex; flex: 1; min-block-size: 0; align-items: stretch; }
.ca-floating-window.ca-single-image .kiko-image-item { display: flex; flex-direction: column; flex: 1; min-block-size: 0; }
.ca-floating-window.ca-single-image .kiko-image-open { position: relative; flex: 1; min-block-size: 100px; overflow: hidden; }
.ca-floating-window.ca-single-image .kiko-image-open img { position: absolute; inset: 0; block-size: 100%; }
.ca-floating-window .kiko-image-actions { display: flex; align-items: center; justify-content: space-between; gap: 8px; padding-block-start: 8px; flex-wrap: wrap; }
.ca-floating-window .kiko-image-selector { display: inline-flex; gap: 6px; align-items: center; cursor: pointer; font-size: 12px; min-block-size: 30px; }
.ca-floating-window .kiko-image-selector input { inline-size: 18px; block-size: 18px; min-block-size: 18px; }
.ca-floating-window .kiko-image-info { padding-block-start: 6px; font-size: 11px; }
.ca-floating-window .kiko-image-filename { overflow-wrap: anywhere; }
.ca-floating-window .kiko-image-stats { color: var(--ca-muted); margin-block-start: 3px; }
.ca-floating-window .kiko-format-badge { display: inline-block; border: 1px solid var(--ca-border); border-radius: 3px; padding: 1px 4px; margin-inline-end: 3px; }
.ca-floating-window .kiko-bulk-actions { border-block-start: 1px solid var(--ca-line); margin-block-start: 12px; padding-block-start: 10px; font-size: 12px; color: var(--ca-muted); }
.ca-floating-window .kiko-bulk-buttons { display: flex; flex-wrap: wrap; gap: 7px; margin-block-start: 7px; }
.ca-floating-window .kiko-bulk-buttons button { font-size: 12px; }
.ca-floating-window .ca-window-status { color: var(--ca-muted); font-size: 11px; padding: 6px 30px 6px 12px; border-block-start: 1px solid var(--ca-line); flex-shrink: 0; }
.ca-viewer-launcher { padding: 3px 0; }
.ca-viewer-launcher button { inline-size: 100%; }
@media (pointer: coarse) { .ca-floating-window .ca-window-header button, .ca-floating-window .ca-window-resize { min-inline-size: 40px; min-block-size: 40px; } }
@media (forced-colors: active) { .ca-floating-window { box-shadow: none; border-color: CanvasText; } }
+75
View File
@@ -0,0 +1,75 @@
import { normalizeHex } from './color.js';
export function formatElapsed(ms) {
ms = Number.isFinite(ms) ? Math.max(0, Math.floor(ms)) : 0;
return `${String(Math.floor(ms / 60000)).padStart(2, '0')}:${String(Math.floor(ms / 1000) % 60).padStart(2, '0')}:${String(ms % 1000).padStart(3, '0')}`;
}
export class WorkflowClock {
constructor(settings, environment = globalThis) {
this.settings = settings;
this.environment = environment;
this.activeNodes = new Set();
this.isRunning = false;
this.startTime = 0;
this.intervalId = null;
}
getTimerColor() {
return normalizeHex(this.settings.getSettingValue('kikotools.workflow_timer.color')) || '#0877bb';
}
renderNode(node) {
node.timerDisplay.textContent = this.isRunning
? formatElapsed(this.environment.Date.now() - this.startTime)
: node.properties.elapsed_time_str || '00:00:000';
node.timerDisplay.style.setProperty('--ca-timer-color', this.getTimerColor());
node.timerDisplay.classList.toggle('running', this.isRunning);
node.timerDisplay.classList.toggle('no-glow', this.settings.getSettingValue('kikotools.workflow_timer.glow') === false);
}
refresh() {
this.activeNodes.forEach(node => this.renderNode(node));
}
ensureInterval() {
if (!this.isRunning || !this.activeNodes.size || this.intervalId !== null) return;
this.intervalId = this.environment.setInterval(() => {
const elapsed = formatElapsed(this.environment.Date.now() - this.startTime);
this.activeNodes.forEach(node => { node.timerDisplay.textContent = elapsed; });
}, 33);
}
clearInterval() {
this.environment.clearInterval(this.intervalId);
this.intervalId = null;
}
start() {
if (this.isRunning) return;
this.isRunning = true;
this.startTime = this.environment.Date.now();
this.refresh();
this.ensureInterval();
}
stop() {
if (!this.isRunning) return;
this.isRunning = false;
this.clearInterval();
const elapsed = formatElapsed(this.environment.Date.now() - this.startTime);
this.activeNodes.forEach(node => { node.properties.elapsed_time_str = elapsed; });
this.refresh();
}
registerNode(node) {
this.activeNodes.add(node);
this.refresh();
this.ensureInterval();
}
unregisterNode(node) {
this.activeNodes.delete(node);
if (!this.activeNodes.size) this.clearInterval();
}
}
+185
View File
@@ -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 ||= '<lora:';
next.tagSource = pending?.source || (next.customWordsUrl !== autocomplete.settings.customWordsUrl ? next.customWordsUrl : autocomplete.settings.tagSource);
save.disabled = true;
try {
await autocomplete.saveTagSettings(next, pending);
dialog.close('save');
} catch (error) {
status.dataset.error = 'true';
status.textContent = error.message;
save.disabled = false;
}
});
dialog.scope.cleanup(() => { 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;
}
+45
View File
@@ -0,0 +1,45 @@
import { createButton, createField, createSelect, createSegments, createSlider, createSwitch, mountTheme } from '../ui/core.js';
export function createImageBrowser(id, settings, scope) {
const element = document.createElement('div');
element.className = 'ca-tools ca-image-browser';
element.innerHTML = `<div class="kiko-container-wrapper">
<div class="ca-browser-header"><strong>Local Image Loader</strong><span>ComfyAssets</span></div>
<div class="kiko-controls kiko-path-controls"></div>
<div class="ca-directory"><label class="ca-field-label" for="${id}-path">Directory</label><div class="kiko-controls ca-path-row"><div class="kiko-path-input-wrapper"><input id="${id}-path" class="kiko-path-input" type="text" spellcheck="false" placeholder="Enter a directory…"><div class="kiko-autocomplete-dropdown"></div></div></div></div>
<div class="kiko-controls ca-options"><div class="ca-sort"></div></div>
<div class="ca-gallery-tools"></div>
<div class="kiko-status-message" role="status">Loading…</div>
<div class="kiko-cardholder" data-view="grid"></div>
<div class="ca-browser-footer"><span data-file-count></span><div class="kiko-pagination"></div></div>
<div class="ca-selection"><span class="ca-field-label">Selected media</span><strong data-selection>No media selected</strong></div>
</div>`;
const saved = createSelect([['', '— Saved paths —']], 'kiko-path-presets');
saved.setAttribute('aria-label', 'Saved paths');
const remove = createButton('×', 'kiko-delete-path ca-danger');
remove.setAttribute('aria-label', 'Remove saved path');
element.querySelector('.kiko-path-controls').append(saved, createButton('☆ Save path', 'kiko-save-path'), remove);
const up = createButton('↑', 'kiko-up-folder ca-icon-button');
up.setAttribute('aria-label', 'Parent folder');
const pathRow = element.querySelector('.ca-path-row');
pathRow.prepend(up);
pathRow.append(createButton('Browse', 'kiko-browse ca-primary'));
element.querySelector('.ca-options').prepend(createSwitch('Hide dot folders', 'kiko-hide-dot-folders', `${id}-hidden`));
const refresh = createButton('↻', 'kiko-refresh ca-icon-button');
refresh.setAttribute('aria-label', 'Refresh folder');
element.querySelector('.ca-sort').append(
createField('Sort', createSelect([['name', 'Name'], ['date', 'Date'], ['size', 'Size']], 'kiko-sort-by'), `${id}-sort`),
createField('Order', createSelect([['asc', '↑ Ascending'], ['desc', '↓ Descending']], 'kiko-sort-order'), `${id}-order`), refresh,
);
const filter = document.createElement('input');
filter.type = 'search';
filter.placeholder = 'Filter this page…';
filter.className = 'ca-file-filter';
filter.setAttribute('aria-label', 'Filter this page');
element.querySelector('.ca-gallery-tools').append(filter, createSegments('Gallery layout', [['grid', 'Grid'], ['list', 'List']], `${id}-view`, scope, value => { element.querySelector('.kiko-cardholder').dataset.view = value; }));
element.querySelector('.ca-browser-footer').after(createSlider('Thumbnail size', { id: `${id}-thumbnail-size`, min: 128, max: 384, step: 16, value: 224, unit: 'px' }, scope, value => {
element.style.setProperty('--ca-thumbnail-size', `${value}px`);
}));
mountTheme(element, settings, scope);
return element;
}
+74
View File
@@ -0,0 +1,74 @@
import { createButton, createField } from '../ui/core.js';
import { createDialog } from '../ui/dialog.js';
const activeDialogs = new WeakMap();
export function resolveNodeSize(values, minimum) {
const size = values.map(Number);
if (values.some(value => String(value).trim() === '') || size.some(value => !Number.isSafeInteger(value) || value <= 0)) return null;
return size.map((value, index) => Math.max(value, minimum[index]));
}
export function showNodeDimensionsDialog(node, app) {
const existing = activeDialogs.get(node);
if (existing) {
existing.querySelector('.width').focus();
return existing;
}
const dialog = createDialog({ title: 'Node Dimensions', settings: app.ui.settings, fallbackFocus: app.canvas.canvas });
const form = document.createElement('form');
form.id = `${dialog.element.id}-form`;
form.noValidate = true;
const hint = document.createElement('p');
hint.id = `${form.id}-hint`;
hint.textContent = 'Minimum size is enforced based on node content.';
hint.className = 'ca-dialog-hint';
const fields = document.createElement('div');
fields.className = 'ca-dimension-fields';
const error = document.createElement('p');
error.id = `${form.id}-error`;
error.className = 'ca-dialog-error';
error.setAttribute('aria-live', 'polite');
const inputs = ['Width', 'Height'].map((name, index) => {
const input = document.createElement('input');
Object.assign(input, { type: 'number', min: '1', step: '1', required: true, value: String(Math.round(node.size[index])), className: name.toLowerCase() });
input.setAttribute('aria-describedby', `${hint.id} ${error.id}`);
fields.append(createField(`${name} (px)`, input, `${form.id}-${name.toLowerCase()}`));
return input;
});
form.append(hint, fields, error);
dialog.body.append(form);
const cancel = createButton('Cancel');
const apply = createButton('Apply', 'ca-primary');
apply.type = 'submit';
apply.setAttribute('form', form.id);
dialog.actions.append(cancel, apply);
dialog.scope.listen(cancel, 'click', () => dialog.close('cancel'));
dialog.scope.listen(form, 'submit', event => {
event.preventDefault();
const size = resolveNodeSize(inputs.map(input => input.value), node.computeSize());
if (!size) {
error.textContent = 'Enter positive whole numbers for width and height.';
for (const input of inputs) input.setAttribute('aria-invalid', String(!resolveNodeSize([input.value], [1])));
inputs.find(input => input.getAttribute('aria-invalid') === 'true').focus();
return;
}
node.setSize(size);
node.setDirtyCanvas(true, true);
dialog.close('apply');
});
const original = node.onRemoved;
const onRemoved = function (...args) {
dialog.close('removed');
return original?.apply(this, args);
};
node.onRemoved = onRemoved;
activeDialogs.set(node, dialog.element);
dialog.scope.cleanup(() => {
activeDialogs.delete(node);
if (node.onRemoved === onRemoved) node.onRemoved = original;
});
dialog.open(inputs[0]);
inputs[0].select();
return dialog.element;
}
+73
View File
@@ -0,0 +1,73 @@
import { createButton } from '../ui/core.js';
let nextHistoryId = 0;
export function createSeedHistoryView(scope, actions) {
const element = document.createElement('div');
element.className = 'ca-tools ca-seed-history';
const header = document.createElement('header');
header.className = 'ca-seed-header';
const title = document.createElement('h3');
title.textContent = 'Seed History';
const brand = document.createElement('span');
brand.textContent = 'ComfyAssets';
header.append(title, brand);
const toolbar = document.createElement('div');
toolbar.className = 'ca-seed-actions';
const generate = createButton('Generate', 'ca-primary');
const clear = createButton('Clear', 'ca-danger');
const disclosure = createButton('Hide history', 'ca-seed-disclosure');
disclosure.setAttribute('aria-expanded', 'true');
toolbar.append(generate, clear);
const list = document.createElement('ol');
list.className = 'ca-seed-list';
list.id = `ca-seed-list-${++nextHistoryId}`;
list.setAttribute('role', 'list');
list.setAttribute('aria-label', 'Recent seeds');
disclosure.setAttribute('aria-controls', list.id);
const empty = document.createElement('p');
empty.className = 'ca-seed-empty';
empty.textContent = 'No seeds tracked';
const status = document.createElement('div');
status.className = 'ca-seed-status';
status.setAttribute('role', 'status');
element.append(header, toolbar, disclosure, list, empty, status);
scope.listen(generate, 'click', actions.generate);
scope.listen(clear, 'click', actions.clear);
scope.listen(disclosure, 'click', () => actions.expand(list.hidden));
scope.listen(list, 'click', event => {
const button = event.target.closest('button[data-seed-index]');
if (button) actions.select(Number(button.dataset.seedIndex));
});
return {
element, list, disclosure, status,
expand(expanded) {
list.hidden = !expanded;
disclosure.textContent = expanded ? 'Hide history' : 'Show history';
disclosure.setAttribute('aria-expanded', String(expanded));
empty.hidden = !expanded || list.childElementCount > 0;
},
render(history, selectedSeed, formatTime) {
const focused = list.contains(document.activeElement) ? document.activeElement.dataset.seed : null;
list.replaceChildren(...history.map((item, index) => {
const row = document.createElement('li');
const button = createButton('', 'ca-seed-entry');
button.dataset.seedIndex = index;
button.dataset.seed = item.seed;
button.setAttribute('aria-label', `Use seed ${item.seed}`);
button.setAttribute('aria-pressed', String(item.seed === selectedSeed));
const seed = document.createElement('span');
seed.className = 'ca-seed-value';
seed.textContent = item.seed;
const time = document.createElement('span');
time.className = 'ca-seed-time';
time.textContent = formatTime(item.timestamp);
button.append(seed, time);
row.append(button);
return row;
}));
empty.hidden = list.hidden || history.length > 0;
if (focused !== null) list.querySelector(`button[data-seed="${focused}"]`)?.focus();
},
};
}
+86
View File
@@ -0,0 +1,86 @@
import { createButton, createScope, mountTheme } from '../ui/core.js';
let nextOutput = 0;
export function outputSections(kind, text) {
if (kind === 'text') {
const positive = text.match(/Positive prompt:\s*([\s\S]*?)(?=Negative prompt:|$)/i);
const negative = text.match(/Negative prompt:\s*([\s\S]*?)(?=\*\*|$)/i);
if (positive && negative) {
let positiveText = positive[1].trim();
const marker = positiveText.indexOf('**');
if (marker > 0) positiveText = positiveText.slice(0, marker).trim();
return [
{ label: 'Positive prompt', copy: 'Copy positive', text: positiveText },
{ label: 'Negative prompt', copy: 'Copy negative', text: negative[1].trim() },
];
}
}
return [{ label: kind === 'text' ? 'Text output' : 'Value output', copy: kind === 'text' ? 'Copy text' : 'Copy value', text }];
}
export function createTextOutputView(scope, kind, settings) {
const element = document.createElement('div');
element.className = 'ca-tools ca-text-output';
element.dataset.outputKind = kind;
const header = document.createElement('header');
const title = document.createElement('h2');
title.textContent = kind === 'text' ? 'Display Text' : 'Display Any';
const brand = document.createElement('span');
brand.textContent = 'ComfyAssets';
header.append(title, brand);
const content = document.createElement('div');
content.className = 'ca-output-content';
const status = document.createElement('p');
status.role = 'status';
element.append(header, content, status);
mountTheme(element, settings, scope);
let renderScope;
scope.cleanup(() => {
renderScope?.dispose();
element.remove();
});
return {
element,
update(text) {
renderScope?.dispose();
const current = createScope();
renderScope = current;
content.replaceChildren();
status.textContent = '';
delete status.dataset.error;
const clearStatus = current.debounce(() => { status.textContent = ''; }, 1500);
for (const section of outputSections(kind, text)) {
const row = document.createElement('div');
row.className = 'ca-output-section';
const toolbar = document.createElement('div');
toolbar.className = 'ca-output-toolbar';
const input = document.createElement('textarea');
input.id = `ca-output-${++nextOutput}`;
input.readOnly = true;
input.spellcheck = false;
input.value = section.text;
const label = document.createElement('label');
label.htmlFor = input.id;
label.textContent = section.label;
const copy = createButton(section.copy, 'ca-primary');
toolbar.append(label, copy);
row.append(toolbar, input);
content.append(row);
current.listen(copy, 'click', async () => {
try {
await navigator.clipboard.writeText(section.text);
if (current.signal.aborted) return;
delete status.dataset.error;
status.textContent = 'Copied.';
clearStatus();
} catch {
if (current.signal.aborted) return;
status.dataset.error = 'true';
status.textContent = 'Could not copy. Select the text and copy it manually.';
}
});
}
},
};
}
+16 -320
View File
@@ -1,324 +1,20 @@
// ComfyUI-KikoTools - Width Height Selector with Swap Button
import { app } from "../../scripts/app.js";
import { app } from '../../scripts/app.js';
import { bindDimensionPresets, extractResolutionFromPreset, swapDimensions } from './ui/dimensions.js';
import { mountDimensionSwap } from './ui/dimension-swap.js';
app.registerExtension({
name: "comfyassets.WidthHeightSelector",
async beforeRegisterNodeDef(nodeType, nodeData, _app) {
if (nodeData.name === "WidthHeightSelector") {
const onNodeCreated = nodeType.prototype.onNodeCreated;
nodeType.prototype.onNodeCreated = function () {
if (onNodeCreated) onNodeCreated.apply(this, []);
// Track button click state for visual feedback
this.swapButtonPressed = false;
// Helper function to extract resolution from formatted preset string
this.extractResolutionFromPreset = function(presetValue) {
if (presetValue === "custom") return null;
// If it contains formatting metadata, extract the resolution part
if (presetValue.includes(" - ")) {
// Format is: "1024×1024 - 1:1 (1.0MP) - SDXL"
return presetValue.split(" - ")[0];
}
// Otherwise assume it's already a raw resolution
return presetValue;
name: 'comfyassets.WidthHeightSelector',
async beforeRegisterNodeDef(nodeType, nodeData) {
if (nodeData.name !== 'WidthHeightSelector') return;
nodeType.prototype.createSwapButton = function () { return mountDimensionSwap(this, app); };
const onNodeCreated = nodeType.prototype.onNodeCreated;
nodeType.prototype.onNodeCreated = function () {
const result = onNodeCreated?.apply(this, arguments);
this.extractResolutionFromPreset = extractResolutionFromPreset;
this.swapDimensions = () => swapDimensions(this, app);
bindDimensionPresets(this, app);
this.createSwapButton();
return result;
};
// Override preset callback to update width/height widgets when preset changes
const presetWidget = this.widgets.find(w => w.name === "preset");
if (presetWidget) {
const originalCallback = presetWidget.callback;
presetWidget.callback = function(value, graphcanvas, node, pos, event) {
// Call original callback first
if (originalCallback) {
originalCallback.call(this, value, graphcanvas, node, pos, event);
}
// Update width/height widgets based on preset
const widthWidget = node.widgets.find(w => w.name === "width");
const heightWidget = node.widgets.find(w => w.name === "height");
if (widthWidget && heightWidget && value !== "custom") {
// Extract raw resolution from formatted preset
const rawResolution = node.extractResolutionFromPreset(value);
// Define all available presets from our preset system
const presetDimensions = {
// SDXL Presets
"1024×1024": [1024, 1024], "896×1152": [896, 1152], "832×1216": [832, 1216],
"768×1344": [768, 1344], "640×1536": [640, 1536], "1152×896": [1152, 896],
"1216×832": [1216, 832], "1344×768": [1344, 768], "1536×640": [1536, 640],
// FLUX Presets
"1920×1080": [1920, 1080], "1536×1536": [1536, 1536], "1280×768": [1280, 768],
"768×1280": [768, 1280], "1440×1080": [1440, 1080], "1080×1440": [1080, 1440],
"1728×1152": [1728, 1152], "1152×1728": [1152, 1728],
// Ultra-Wide Presets
"2560×1080": [2560, 1080], "2048×768": [2048, 768], "1792×768": [1792, 768],
"2304×768": [2304, 768], "1080×2560": [1080, 2560], "768×2048": [768, 2048],
"768×1792": [768, 1792], "768×2304": [768, 2304]
};
if (rawResolution && presetDimensions[rawResolution]) {
const [w, h] = presetDimensions[rawResolution];
widthWidget.value = w;
heightWidget.value = h;
// Trigger widget callbacks to update the UI
if (widthWidget.callback) {
widthWidget.callback(w, graphcanvas, node, pos, event);
}
if (heightWidget.callback) {
heightWidget.callback(h, graphcanvas, node, pos, event);
}
}
}
};
}
// Add swap functionality
this.swapDimensions = function() {
const widthWidget = this.widgets.find(w => w.name === "width");
const heightWidget = this.widgets.find(w => w.name === "height");
const presetWidget = this.widgets.find(w => w.name === "preset");
if (widthWidget && heightWidget && presetWidget) {
// Handle preset swapping first
if (presetWidget.value !== "custom") {
const currentPreset = presetWidget.value;
// Extract raw resolution from formatted preset
const rawResolution = this.extractResolutionFromPreset(currentPreset);
if (!rawResolution) return;
// Parse current preset dimensions (handle both × and x separators)
let w, h;
if (rawResolution.includes('×')) {
[w, h] = rawResolution.split('×').map(v => parseInt(v));
} else if (rawResolution.includes('x')) {
[w, h] = rawResolution.split('x').map(v => parseInt(v));
} else {
return; // Invalid preset format
}
const swappedRawPreset = `${h}×${w}`;
// Find the formatted version of the swapped preset from available options
const availablePresets = presetWidget.options.values || presetWidget.options;
let swappedFormattedPreset = null;
for (const option of availablePresets) {
if (option === "custom") continue;
const extractedRes = this.extractResolutionFromPreset(option);
if (extractedRes === swappedRawPreset) {
swappedFormattedPreset = option;
break;
}
}
if (swappedFormattedPreset) {
// Swapped preset exists, use the formatted version
presetWidget.value = swappedFormattedPreset;
widthWidget.value = h;
heightWidget.value = w;
if (presetWidget.callback) {
presetWidget.callback(swappedFormattedPreset, app.canvas, this, [0, 0], null);
}
if (widthWidget.callback) {
widthWidget.callback(h, app.canvas, this, [0, 0], null);
}
if (heightWidget.callback) {
heightWidget.callback(w, app.canvas, this, [0, 0], null);
}
} else {
// Swapped preset doesn't exist, switch to custom and swap manual values
presetWidget.value = "custom";
widthWidget.value = h;
heightWidget.value = w;
if (presetWidget.callback) {
presetWidget.callback("custom", app.canvas, this, [0, 0], null);
}
if (widthWidget.callback) {
widthWidget.callback(h, app.canvas, this, [0, 0], null);
}
if (heightWidget.callback) {
heightWidget.callback(w, app.canvas, this, [0, 0], null);
}
}
} else {
// Custom preset - just swap the width and height values
const tempWidth = widthWidget.value;
widthWidget.value = heightWidget.value;
heightWidget.value = tempWidth;
// Trigger widget change events
if (widthWidget.callback) {
widthWidget.callback(widthWidget.value, app.canvas, this, [0, 0], null);
}
if (heightWidget.callback) {
heightWidget.callback(heightWidget.value, app.canvas, this, [0, 0], null);
}
}
// Mark the graph as changed
this.graph?.setDirtyCanvas(true, true);
}
};
};
const onDrawForeground = nodeType.prototype.onDrawForeground;
nodeType.prototype.onDrawForeground = function (ctx) {
if (onDrawForeground) {
onDrawForeground.apply(this, arguments);
}
if (this.flags.collapsed) return;
// Draw swap button with consistent spacing from widgets
const swapButtonSize = 24;
const margin = 6;
const swapButtonX = this.size[0] - swapButtonSize - margin;
// Calculate button position based on widget spacing rather than bottom margin
// Estimate widget area height and add consistent spacing
const estimatedWidgetHeight = 90; // Approximate height for 3 widgets
const topMargin = 35; // Space from top to first widget
const buttonSpacing = 10; // Space between last widget and button
const swapButtonY = topMargin + estimatedWidgetHeight + buttonSpacing;
// Button background - change color based on pressed state
if (this.swapButtonPressed) {
// Darker when pressed
ctx.fillStyle = "rgba(30, 120, 200, 0.9)"; // Darker blue when clicked
} else {
// Normal state
ctx.fillStyle = "rgba(66, 165, 245, 0.8)"; // Material blue
}
ctx.beginPath();
ctx.roundRect(swapButtonX, swapButtonY, swapButtonSize, swapButtonSize, 4);
ctx.fill();
// Button border with subtle highlight
ctx.strokeStyle = this.swapButtonPressed ? "rgba(20, 100, 180, 1.0)" : "rgba(33, 150, 243, 0.9)";
ctx.lineWidth = 1;
ctx.stroke();
// Draw swap icon - modern double arrow design
ctx.strokeStyle = "rgba(255, 255, 255, 0.95)";
ctx.lineWidth = 2;
ctx.lineCap = "round";
const centerX = swapButtonX + 12;
const centerY = swapButtonY + 12;
// Top arrow (pointing right) - width to height
ctx.beginPath();
ctx.moveTo(centerX - 7, centerY - 3);
ctx.lineTo(centerX + 5, centerY - 3);
ctx.stroke();
// Top arrow head
ctx.beginPath();
ctx.moveTo(centerX + 5, centerY - 3);
ctx.lineTo(centerX + 2, centerY - 5);
ctx.moveTo(centerX + 5, centerY - 3);
ctx.lineTo(centerX + 2, centerY - 1);
ctx.stroke();
// Bottom arrow (pointing left) - height to width
ctx.beginPath();
ctx.moveTo(centerX + 5, centerY + 3);
ctx.lineTo(centerX - 7, centerY + 3);
ctx.stroke();
// Bottom arrow head
ctx.beginPath();
ctx.moveTo(centerX - 7, centerY + 3);
ctx.lineTo(centerX - 4, centerY + 1);
ctx.moveTo(centerX - 7, centerY + 3);
ctx.lineTo(centerX - 4, centerY + 5);
ctx.stroke();
// Add subtle tooltip text when hovering (if we had hover state)
// This could be extended with hover detection for better UX
};
const onMouseDown = nodeType.prototype.onMouseDown;
nodeType.prototype.onMouseDown = function (e) {
// Check if click is on swap button
const swapButtonSize = 24;
const margin = 6;
const swapButtonX = this.pos[0] + this.size[0] - swapButtonSize - margin;
// Use same positioning logic as drawing
const estimatedWidgetHeight = 90;
const topMargin = 35;
const buttonSpacing = 10;
const swapButtonY = this.pos[1] + topMargin + estimatedWidgetHeight + buttonSpacing;
if (
e.canvasX >= swapButtonX &&
e.canvasX <= swapButtonX + swapButtonSize &&
e.canvasY >= swapButtonY &&
e.canvasY <= swapButtonY + swapButtonSize
) {
// Visual feedback - set button as pressed
this.swapButtonPressed = true;
this.setDirtyCanvas(true, true);
// Execute swap
this.swapDimensions();
// Reset button state after a short delay for visual feedback
setTimeout(() => {
this.swapButtonPressed = false;
this.setDirtyCanvas(true, true);
}, 150);
return true; // Consume the event
}
// Call original onMouseDown if not clicking swap button
if (onMouseDown) {
return onMouseDown.apply(this, arguments);
}
};
// Optional: Add hover effect for better user feedback
const onMouseMove = nodeType.prototype.onMouseMove;
nodeType.prototype.onMouseMove = function (e) {
// Check if hovering over swap button
const swapButtonSize = 24;
const margin = 6;
const swapButtonX = this.pos[0] + this.size[0] - swapButtonSize - margin;
// Use same positioning logic as drawing
const estimatedWidgetHeight = 90;
const topMargin = 35;
const buttonSpacing = 10;
const swapButtonY = this.pos[1] + topMargin + estimatedWidgetHeight + buttonSpacing;
const isHovering = (
e.canvasX >= swapButtonX &&
e.canvasX <= swapButtonX + swapButtonSize &&
e.canvasY >= swapButtonY &&
e.canvasY <= swapButtonY + swapButtonSize
);
// Update cursor style for better UX (safely)
if (isHovering && this.graph && this.graph.canvas && this.graph.canvas.canvas) {
this.graph.canvas.canvas.style.cursor = "pointer";
} else if (this.graph && this.graph.canvas && this.graph.canvas.canvas) {
this.graph.canvas.canvas.style.cursor = "default";
}
// Call original onMouseMove
if (onMouseMove) {
return onMouseMove.apply(this, arguments);
}
};
}
},
},
});