Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f8ce4cb982 | ||
|
|
42b6009f1b | ||
|
|
963ac0a4af | ||
|
|
b4bd8b98b1 | ||
|
|
ed6675721d | ||
|
|
b0c0d53676 | ||
|
|
b9103ad9c4 | ||
|
|
b5694ecfe5 | ||
|
|
1255c4e9cc | ||
|
|
26557a99b6 | ||
|
|
848674d474 | ||
|
|
0c47dc6085 | ||
|
|
ee7d1e9011 | ||
|
|
b7a29a1731 | ||
|
|
3cabbffb40 | ||
|
|
47dc177517 | ||
|
|
ddb356fe17 | ||
|
|
9e2b271824 | ||
|
|
2e7fac4b70 | ||
|
|
740eccec27 | ||
|
|
55bce1004b | ||
|
|
63a8596b81 | ||
|
|
11494c747c | ||
|
|
88a9238ee2 | ||
|
|
3879f18d79 | ||
|
|
46999c8d90 | ||
|
|
852864a270 |
@@ -16,7 +16,7 @@ jobs:
|
|||||||
- uses: actions/checkout@v7
|
- uses: actions/checkout@v7
|
||||||
|
|
||||||
- name: Set up Python 3.10
|
- name: Set up Python 3.10
|
||||||
uses: actions/setup-python@v6
|
uses: actions/setup-python@v7
|
||||||
with:
|
with:
|
||||||
python-version: '3.10'
|
python-version: '3.10'
|
||||||
|
|
||||||
@@ -145,7 +145,7 @@ jobs:
|
|||||||
- uses: actions/checkout@v7
|
- uses: actions/checkout@v7
|
||||||
|
|
||||||
- name: Set up Python 3.10
|
- name: Set up Python 3.10
|
||||||
uses: actions/setup-python@v6
|
uses: actions/setup-python@v7
|
||||||
with:
|
with:
|
||||||
python-version: '3.10'
|
python-version: '3.10'
|
||||||
|
|
||||||
@@ -176,7 +176,7 @@ jobs:
|
|||||||
- uses: actions/checkout@v7
|
- uses: actions/checkout@v7
|
||||||
|
|
||||||
- name: Set up Python 3.10
|
- name: Set up Python 3.10
|
||||||
uses: actions/setup-python@v6
|
uses: actions/setup-python@v7
|
||||||
with:
|
with:
|
||||||
python-version: '3.10'
|
python-version: '3.10'
|
||||||
|
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ jobs:
|
|||||||
- uses: actions/checkout@v7
|
- uses: actions/checkout@v7
|
||||||
|
|
||||||
- name: Set up Python 3.10
|
- name: Set up Python 3.10
|
||||||
uses: actions/setup-python@v6
|
uses: actions/setup-python@v7
|
||||||
with:
|
with:
|
||||||
python-version: '3.10'
|
python-version: '3.10'
|
||||||
|
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ jobs:
|
|||||||
- uses: actions/checkout@v7
|
- uses: actions/checkout@v7
|
||||||
|
|
||||||
- name: Set up Python ${{ matrix.python-version }}
|
- name: Set up Python ${{ matrix.python-version }}
|
||||||
uses: actions/setup-python@v6
|
uses: actions/setup-python@v7
|
||||||
with:
|
with:
|
||||||
python-version: ${{ matrix.python-version }}
|
python-version: ${{ matrix.python-version }}
|
||||||
cache: pip
|
cache: pip
|
||||||
@@ -60,7 +60,7 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v7
|
- uses: actions/checkout@v7
|
||||||
|
|
||||||
- uses: actions/setup-python@v6
|
- uses: actions/setup-python@v7
|
||||||
with:
|
with:
|
||||||
python-version: "3.12"
|
python-version: "3.12"
|
||||||
cache: pip
|
cache: pip
|
||||||
@@ -93,14 +93,15 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v7
|
- uses: actions/checkout@v7
|
||||||
|
|
||||||
- uses: actions/setup-node@v6
|
- uses: actions/setup-node@v7
|
||||||
with:
|
with:
|
||||||
node-version: "24"
|
node-version: "24"
|
||||||
|
|
||||||
# Check every shipped module, including files without behavioral tests.
|
# 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: |
|
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; }
|
node --input-type=module --check < "$f" || { echo "Syntax error in $f"; exit 1; }
|
||||||
done
|
done
|
||||||
|
|
||||||
@@ -110,7 +111,7 @@ jobs:
|
|||||||
timeout-minutes: 15
|
timeout-minutes: 15
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v7
|
- uses: actions/checkout@v7
|
||||||
- uses: actions/setup-python@v6
|
- uses: actions/setup-python@v7
|
||||||
with:
|
with:
|
||||||
python-version: "3.12"
|
python-version: "3.12"
|
||||||
cache: pip
|
cache: pip
|
||||||
@@ -120,7 +121,7 @@ jobs:
|
|||||||
python -m pip install --upgrade pip
|
python -m pip install --upgrade pip
|
||||||
pip install torch --index-url https://download.pytorch.org/whl/cpu
|
pip install torch --index-url https://download.pytorch.org/whl/cpu
|
||||||
pip install -r requirements-dev.txt
|
pip install -r requirements-dev.txt
|
||||||
- uses: actions/setup-node@v6
|
- uses: actions/setup-node@v7
|
||||||
with:
|
with:
|
||||||
node-version: "24"
|
node-version: "24"
|
||||||
cache: npm
|
cache: npm
|
||||||
|
|||||||
@@ -477,8 +477,8 @@ This feature is an enhanced fork of the autocomplete functionality from [ComfyUI
|
|||||||
|
|
||||||
**Key Features:**
|
**Key Features:**
|
||||||
- **Smart Triggers**: Type `embedding:` for embeddings, `<lora:` for LoRAs, or just start typing for tags
|
- **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
|
- **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
|
||||||
- **Security First**: Comprehensive input validation prevents code injection and XSS attacks
|
- **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
|
- **Flexible Settings**: Customize triggers, auto-insert commas, replace underscores, and more
|
||||||
- **Performance Optimized**: Handles 100,000+ tags smoothly with frequency-based sorting
|
- **Performance Optimized**: Handles 100,000+ tags smoothly with frequency-based sorting
|
||||||
- **Visual Polish**: Clean UI with proper scrolling, keyboard navigation, and type indicators
|
- **Visual Polish**: Clean UI with proper scrolling, keyboard navigation, and type indicators
|
||||||
@@ -492,9 +492,9 @@ This feature is an enhanced fork of the autocomplete functionality from [ComfyUI
|
|||||||
- Load custom word lists from URLs with security validation
|
- Load custom word lists from URLs with security validation
|
||||||
|
|
||||||
**Security Features:**
|
**Security Features:**
|
||||||
- Validates all loaded content to prevent script injection
|
- Validates CSV structure, tag names, categories, counts, and file size
|
||||||
- Blocks dangerous patterns (eval, innerHTML, script tags, etc.)
|
- Renders tag data as plain text rather than executable HTML or JavaScript
|
||||||
- Safe character whitelist for tags
|
- Rejects control characters and HTML error documents
|
||||||
- File size limits to prevent memory exhaustion
|
- File size limits to prevent memory exhaustion
|
||||||
- Clear error messages for rejected content
|
- Clear error messages for rejected content
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
frontend or certify every node's UI. Type checking and security jobs retain their
|
||||||
existing advisory behavior.
|
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
|
## Required merge checks
|
||||||
|
|
||||||
The manifest in `.github/branch-protection.json` requires all Python matrix jobs,
|
The manifest in `.github/branch-protection.json` requires all Python matrix jobs,
|
||||||
|
|||||||
|
After Width: | Height: | Size: 77 KiB |
|
After Width: | Height: | Size: 81 KiB |
|
After Width: | Height: | Size: 74 KiB |
|
After Width: | Height: | Size: 78 KiB |
|
After Width: | Height: | Size: 103 KiB |
|
After Width: | Height: | Size: 103 KiB |
|
After Width: | Height: | Size: 69 KiB |
|
After Width: | Height: | Size: 71 KiB |
|
After Width: | Height: | Size: 107 KiB |
|
After Width: | Height: | Size: 111 KiB |
|
After Width: | Height: | Size: 57 KiB |
|
After Width: | Height: | Size: 58 KiB |
|
After Width: | Height: | Size: 94 KiB |
|
After Width: | Height: | Size: 97 KiB |
|
After Width: | Height: | Size: 76 KiB |
|
After Width: | Height: | Size: 81 KiB |
|
After Width: | Height: | Size: 84 KiB |
|
After Width: | Height: | Size: 88 KiB |
|
After Width: | Height: | Size: 89 KiB |
|
After Width: | Height: | Size: 90 KiB |
|
After Width: | Height: | Size: 89 KiB |
|
After Width: | Height: | Size: 73 KiB |
|
After Width: | Height: | Size: 74 KiB |
@@ -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"
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
// A test double for the ComfyUI host; extension implementations load unchanged.
|
// A test double for the ComfyUI host; extension implementations load unchanged.
|
||||||
const extensions = [];
|
const extensions = [];
|
||||||
const settings = new Map();
|
const settings = new Map();
|
||||||
|
const settingDefinitions = new Map();
|
||||||
const nodes = new Map();
|
const nodes = new Map();
|
||||||
let nextId = 1;
|
let nextId = 1;
|
||||||
function el(tag, props = {}, children = []) {
|
function el(tag, props = {}, children = []) {
|
||||||
@@ -19,8 +20,9 @@ const app = {
|
|||||||
extensions.push(extension);
|
extensions.push(extension);
|
||||||
},
|
},
|
||||||
ui: { settings: {
|
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),
|
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); } } },
|
}, dialog: { show: text => { const dialog = el('div', { className: 'host-dialog', textContent: text }); document.body.append(dialog); } } },
|
||||||
graph: {
|
graph: {
|
||||||
_nodes: [],
|
_nodes: [],
|
||||||
@@ -106,7 +108,7 @@ function removeNode(id) {
|
|||||||
app.graph._nodes = app.graph._nodes.filter(item => item !== node);
|
app.graph._nodes = app.graph._nodes.filter(item => item !== node);
|
||||||
return 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 })) };
|
emit: (name, detail) => api.dispatchEvent(new CustomEvent(name, { detail })) };
|
||||||
const manifest = await (await fetch('/__test__/state')).json();
|
const manifest = await (await fetch('/__test__/state')).json();
|
||||||
for (const name of manifest.extensions) await import(`/extensions/kikotools/${name}`);
|
for (const name of manifest.extensions) await import(`/extensions/kikotools/${name}`);
|
||||||
|
|||||||
@@ -0,0 +1,729 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -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 });
|
||||||
|
});
|
||||||
@@ -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; }
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -2,7 +2,9 @@ import test from 'node:test';
|
|||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import { join } from 'node:path';
|
import { join } from 'node:path';
|
||||||
const sourceRoot = join(process.cwd(), 'web');
|
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 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 summary = { total: metrics(90), [join(sourceRoot, 'a.js')]: metrics(85) };
|
||||||
const baseline = { 'a.js': { lines: 80, branches: 80, functions: 80, statements: 80 } };
|
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.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/);
|
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 }); }
|
||||||
|
});
|
||||||
|
|||||||
@@ -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]);
|
||||||
|
});
|
||||||
@@ -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');
|
||||||
|
});
|
||||||
@@ -3,6 +3,7 @@ import assert from 'node:assert/strict';
|
|||||||
import { readFileSync } from 'node:fs';
|
import { readFileSync } from 'node:fs';
|
||||||
import { fileURLToPath } from 'node:url';
|
import { fileURLToPath } from 'node:url';
|
||||||
import vm from 'node:vm';
|
import vm from 'node:vm';
|
||||||
|
import { bindDimensionPresets, extractResolutionFromPreset, swapDimensions } from '../../web/ui/dimensions.js';
|
||||||
|
|
||||||
function load(file, extra = {}) {
|
function load(file, extra = {}) {
|
||||||
const extensions = [];
|
const extensions = [];
|
||||||
@@ -19,8 +20,8 @@ function load(file, extra = {}) {
|
|||||||
return { extension: extensions[0], app, timers, context };
|
return { extension: extensions[0], app, timers, context };
|
||||||
}
|
}
|
||||||
|
|
||||||
async function dimensionNode(preset, options = ['custom', '832×1216 - portrait', '1216×832 - landscape']) {
|
async function dimensionNode(preset, options = ['custom', '832×1216 - portrait', '1216×832 - landscape'], file = 'width_height_swap.js') {
|
||||||
const { extension, app } = load('width_height_swap.js');
|
const { extension, app } = load(file, { bindDimensionPresets, extractResolutionFromPreset, swapDimensions, mountDimensionSwap() {} });
|
||||||
let originalCreated = 0;
|
let originalCreated = 0;
|
||||||
class Node {
|
class Node {
|
||||||
constructor() {
|
constructor() {
|
||||||
@@ -33,15 +34,17 @@ async function dimensionNode(preset, options = ['custom', '832×1216 - portrait'
|
|||||||
}
|
}
|
||||||
onNodeCreated() { originalCreated++; }
|
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();
|
const node = new Node();
|
||||||
node.onNodeCreated();
|
node.onNodeCreated();
|
||||||
assert.equal(originalCreated, 1);
|
assert.equal(originalCreated, 1);
|
||||||
return node;
|
return node;
|
||||||
}
|
}
|
||||||
|
|
||||||
test('custom dimensions swap and callbacks receive the actual node', async () => {
|
for (const file of ['width_height_swap.js', 'empty_latent_batch_swap.js']) {
|
||||||
const node = await dimensionNode('custom');
|
test(`${file}: custom dimensions swap and callbacks receive the actual node`, async () => {
|
||||||
|
const node = await dimensionNode('custom', undefined, file);
|
||||||
const calls = [];
|
const calls = [];
|
||||||
for (const widget of node.widgets.slice(1)) widget.callback = (value, canvas, owner) => calls.push({ value, owner });
|
for (const widget of node.widgets.slice(1)) widget.callback = (value, canvas, owner) => calls.push({ value, owner });
|
||||||
node.swapDimensions();
|
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);
|
for (const call of calls) assert.equal(call.owner, node);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('formatted preset swaps to its matching landscape preset without throwing', async () => {
|
test(`${file}: formatted preset swaps to its matching landscape preset without throwing`, async () => {
|
||||||
const node = await dimensionNode('832×1216 - portrait');
|
const node = await dimensionNode('832×1216 - portrait', undefined, file);
|
||||||
node.swapDimensions();
|
node.swapDimensions();
|
||||||
assert.deepEqual(node.widgets.map(widget => widget.value), ['1216×832 - landscape', 1216, 832]);
|
assert.deepEqual(node.widgets.map(widget => widget.value), ['1216×832 - landscape', 1216, 832]);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('a preset without a reverse switches to custom dimensions', async () => {
|
test(`${file}: a preset without a reverse switches to custom dimensions`, async () => {
|
||||||
const node = await dimensionNode('832×1216 - portrait', ['custom', '832×1216 - portrait']);
|
const node = await dimensionNode('832×1216 - portrait', ['custom', '832×1216 - portrait'], file);
|
||||||
node.swapDimensions();
|
node.swapDimensions();
|
||||||
assert.deepEqual(node.widgets.map(widget => widget.value), ['custom', 1216, 832]);
|
assert.deepEqual(node.widgets.map(widget => widget.value), ['custom', 1216, 832]);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('preset changes preserve original callbacks and update both dimensions', async () => {
|
test(`${file}: preset changes preserve original callbacks and update both dimensions`, async () => {
|
||||||
const node = await dimensionNode('custom');
|
const node = await dimensionNode('custom', undefined, file);
|
||||||
node.widgets[0].callback('1216×832 - landscape', {}, node);
|
node.widgets[0].callback('1216×832 - landscape', {}, node);
|
||||||
assert.deepEqual(node.widgets.slice(1).map(widget => widget.value), [1216, 832]);
|
assert.deepEqual(node.widgets.slice(1).map(widget => widget.value), [1216, 832]);
|
||||||
assert.equal(node.extractResolutionFromPreset('custom'), null);
|
assert.equal(node.extractResolutionFromPreset('custom'), null);
|
||||||
assert.equal(node.originalPresetCalls, 1);
|
assert.equal(node.originalPresetCalls, 1);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('unrelated node definitions retain their lifecycle methods', async () => {
|
test(`${file}: a preset swap notifies each dimension callback exactly once`, async () => {
|
||||||
const { extension, app } = load('width_height_swap.js');
|
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() {} }
|
class Node { onNodeCreated() {} }
|
||||||
const original = Node.prototype.onNodeCreated;
|
const original = Node.prototype.onNodeCreated;
|
||||||
await extension.beforeRegisterNodeDef(Node, { name: 'OtherNode' }, app);
|
await extension.beforeRegisterNodeDef(Node, { name: 'OtherNode' }, app);
|
||||||
assert.equal(Node.prototype.onNodeCreated, original);
|
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 () => {
|
test('Gemini repeated execution restores the user color and removal cancels its timer', async () => {
|
||||||
const { extension, app, timers } = load('gemini_prompt_ui.js');
|
const { extension, app, timers } = load('gemini_prompt_ui.js');
|
||||||
let removed = 0;
|
let removed = 0;
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -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']);
|
||||||
|
});
|
||||||
@@ -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');
|
||||||
|
});
|
||||||
@@ -15,7 +15,7 @@ export async function saveCoverage(page, browserName) {
|
|||||||
const entries = await page.coverage.stopJSCoverage();
|
const entries = await page.coverage.stopJSCoverage();
|
||||||
const result = [];
|
const result = [];
|
||||||
for (const entry of entries) {
|
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 });
|
if (match) result.push({ ...entry, url: pathToFileURL(join(repositoryRoot, 'web', match[1])).href });
|
||||||
}
|
}
|
||||||
mkdirSync(process.env.KIKO_JS_COVERAGE, { recursive: true });
|
mkdirSync(process.env.KIKO_JS_COVERAGE, { recursive: true });
|
||||||
|
|||||||
@@ -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 },
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -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": {
|
"display_any.js": {
|
||||||
"lines": 84,
|
"lines": 98,
|
||||||
"branches": 66,
|
"branches": 98,
|
||||||
"functions": 83,
|
"functions": 98,
|
||||||
"statements": 84
|
"statements": 98
|
||||||
},
|
},
|
||||||
"display_text.js": {
|
"display_text.js": {
|
||||||
"lines": 71,
|
"lines": 98,
|
||||||
"branches": 74,
|
"branches": 98,
|
||||||
"functions": 42,
|
"functions": 98,
|
||||||
"statements": 71
|
"statements": 98
|
||||||
},
|
},
|
||||||
"empty_latent_batch_swap.js": {
|
"empty_latent_batch_swap.js": {
|
||||||
"lines": 41,
|
"lines": 98,
|
||||||
"branches": 91,
|
"branches": 98,
|
||||||
"functions": 64,
|
"functions": 98,
|
||||||
"statements": 41
|
"statements": 98
|
||||||
},
|
},
|
||||||
"gemini_prompt_ui.js": {
|
"gemini_prompt_ui.js": {
|
||||||
"lines": 52,
|
"lines": 52,
|
||||||
@@ -24,10 +54,10 @@
|
|||||||
"statements": 52
|
"statements": 52
|
||||||
},
|
},
|
||||||
"kikoEmbeddingAutocomplete.js": {
|
"kikoEmbeddingAutocomplete.js": {
|
||||||
"lines": 44,
|
"lines": 82,
|
||||||
"branches": 43,
|
"branches": 74,
|
||||||
"functions": 59,
|
"functions": 92,
|
||||||
"statements": 44
|
"statements": 82
|
||||||
},
|
},
|
||||||
"kiko_custom_colors.js": {
|
"kiko_custom_colors.js": {
|
||||||
"lines": 46,
|
"lines": 46,
|
||||||
@@ -42,39 +72,129 @@
|
|||||||
"statements": 60
|
"statements": 60
|
||||||
},
|
},
|
||||||
"kiko_local_image_loader.js": {
|
"kiko_local_image_loader.js": {
|
||||||
"lines": 72,
|
"lines": 77,
|
||||||
"branches": 69,
|
"branches": 73,
|
||||||
"functions": 40,
|
"functions": 70,
|
||||||
"statements": 72
|
"statements": 77
|
||||||
},
|
},
|
||||||
"kiko_save_image_ui.js": {
|
"kiko_save_image_ui.js": {
|
||||||
"lines": 66,
|
"lines": 84,
|
||||||
"branches": 49,
|
"branches": 74,
|
||||||
"functions": 57,
|
"functions": 78,
|
||||||
"statements": 66
|
"statements": 84
|
||||||
},
|
},
|
||||||
"kiko_workflow_timer_ui.js": {
|
"kiko_workflow_timer_ui.js": {
|
||||||
"lines": 44,
|
"lines": 98,
|
||||||
"branches": 73,
|
"branches": 78,
|
||||||
"functions": 34,
|
"functions": 88,
|
||||||
"statements": 44
|
"statements": 98
|
||||||
},
|
},
|
||||||
"kikotools_extensions.js": {
|
"kikotools_extensions.js": {
|
||||||
"lines": 12,
|
"lines": 98,
|
||||||
"branches": 98,
|
"branches": 98,
|
||||||
"functions": 0,
|
"functions": 98,
|
||||||
"statements": 12
|
"statements": 98
|
||||||
},
|
},
|
||||||
"seed_history_ui.js": {
|
"seed_history_ui.js": {
|
||||||
"lines": 63,
|
"lines": 95,
|
||||||
"branches": 58,
|
"branches": 85,
|
||||||
"functions": 56,
|
"functions": 88,
|
||||||
"statements": 63
|
"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": {
|
"width_height_swap.js": {
|
||||||
"lines": 60,
|
"lines": 98,
|
||||||
"branches": 79,
|
"branches": 98,
|
||||||
"functions": 73,
|
"functions": 98,
|
||||||
"statements": 60
|
"statements": 98
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,12 @@ import { spawnSync } from 'node:child_process';
|
|||||||
const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), '../..');
|
const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), '../..');
|
||||||
|
|
||||||
const metrics = ['lines', 'branches', 'functions', 'statements'];
|
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) {
|
export function checkCoverage(summary, baseline, sourceRoot, files) {
|
||||||
const failures = [];
|
const failures = [];
|
||||||
for (const file of files) {
|
for (const file of files) {
|
||||||
@@ -35,7 +41,7 @@ async function main() {
|
|||||||
const reportRoot = join(repositoryRoot, 'coverage/js');
|
const reportRoot = join(repositoryRoot, 'coverage/js');
|
||||||
const raw = join(reportRoot, 'raw');
|
const raw = join(reportRoot, 'raw');
|
||||||
const sourceRoot = join(repositoryRoot, 'web');
|
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 });
|
rmSync(reportRoot, { recursive: true, force: true });
|
||||||
mkdirSync(raw, { recursive: true });
|
mkdirSync(raw, { recursive: true });
|
||||||
const tests = readdirSync(join(repositoryRoot, 'tests/js')).filter(file => file.endsWith('.test.mjs')).map(file => `tests/js/${file}`);
|
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'),
|
run([playwright, 'test', '--config', join(tools, 'playwright.config.mjs'),
|
||||||
'--project', 'chromium', '--project', 'touch'], { KIKO_JS_COVERAGE: raw });
|
'--project', 'chromium', '--project', 'touch'], { KIKO_JS_COVERAGE: raw });
|
||||||
run([join(tools, 'node_modules/c8/bin/c8.js'), 'report', '--all', '--src', sourceRoot,
|
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']);
|
'--reporter', 'text', '--reporter', 'json-summary', '--reporter', 'html']);
|
||||||
const summary = JSON.parse(readFileSync(join(reportRoot, 'coverage-summary.json'), 'utf8'));
|
const summary = JSON.parse(readFileSync(join(reportRoot, 'coverage-summary.json'), 'utf8'));
|
||||||
const baselinePath = join(tools, 'frontend-coverage-baseline.json');
|
const baselinePath = join(tools, 'frontend-coverage-baseline.json');
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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 }); }
|
||||||
|
};
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,126 +1,9 @@
|
|||||||
import { app } from "../../scripts/app.js";
|
import { app } from '../../scripts/app.js';
|
||||||
import { ComfyWidgets } from "../../scripts/widgets.js";
|
import { registerDisplayNode } from './controllers/display-node.js';
|
||||||
|
|
||||||
app.registerExtension({
|
app.registerExtension({
|
||||||
name: "ComfyAssets.DisplayAny",
|
name: 'ComfyAssets.DisplayAny',
|
||||||
async beforeRegisterNodeDef(nodeType, nodeData, app) {
|
async beforeRegisterNodeDef(nodeType, nodeData, app) {
|
||||||
if (nodeData.name === "DisplayAny") {
|
if (nodeData.name === 'DisplayAny') registerDisplayNode(nodeType, app, 'any');
|
||||||
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...");
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,199 +1,9 @@
|
|||||||
import { app } from "../../scripts/app.js";
|
import { app } from '../../scripts/app.js';
|
||||||
import { ComfyWidgets } from "../../scripts/widgets.js";
|
import { registerDisplayNode } from './controllers/display-node.js';
|
||||||
|
|
||||||
app.registerExtension({
|
app.registerExtension({
|
||||||
name: "ComfyAssets.DisplayText",
|
name: 'ComfyAssets.DisplayText',
|
||||||
async beforeRegisterNodeDef(nodeType, nodeData, app) {
|
async beforeRegisterNodeDef(nodeType, nodeData, app) {
|
||||||
if (nodeData.name === "DisplayText") {
|
if (nodeData.name === 'DisplayText') registerDisplayNode(nodeType, app, 'text');
|
||||||
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...");
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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({
|
app.registerExtension({
|
||||||
name: "comfyassets.EmptyLatentBatch",
|
name: 'comfyassets.EmptyLatentBatch',
|
||||||
async beforeRegisterNodeDef(nodeType, nodeData, _app) {
|
async beforeRegisterNodeDef(nodeType, nodeData) {
|
||||||
if (nodeData.name === "EmptyLatentBatch") {
|
if (nodeData.name !== 'EmptyLatentBatch') return;
|
||||||
const onNodeCreated = nodeType.prototype.onNodeCreated;
|
nodeType.prototype.createSwapButton = function () { return mountDimensionSwap(this, app); };
|
||||||
nodeType.prototype.onNodeCreated = function () {
|
const onNodeCreated = nodeType.prototype.onNodeCreated;
|
||||||
if (onNodeCreated) onNodeCreated.apply(this, []);
|
nodeType.prototype.onNodeCreated = function () {
|
||||||
|
const result = onNodeCreated?.apply(this, arguments);
|
||||||
// Helper function to extract resolution from formatted preset string
|
this.extractResolutionFromPreset = extractResolutionFromPreset;
|
||||||
this.extractResolutionFromPreset = function (presetValue) {
|
this.swapDimensions = () => swapDimensions(this, app);
|
||||||
if (presetValue === "custom") return null;
|
bindDimensionPresets(this, app);
|
||||||
|
this.createSwapButton();
|
||||||
// If it contains formatting metadata, extract the resolution part
|
return result;
|
||||||
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;
|
|
||||||
};
|
};
|
||||||
|
},
|
||||||
// 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
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
}
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import { app } from "/scripts/app.js";
|
import { app } from "/scripts/app.js";
|
||||||
import { api } from "/scripts/api.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
|
// Escape a value for safe interpolation into HTML text or attribute contexts
|
||||||
function escapeHtml(value) {
|
function escapeHtml(value) {
|
||||||
@@ -29,131 +31,6 @@ async function describeHttpError(response) {
|
|||||||
return detail || `Request failed (HTTP ${response.status})`;
|
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">×</button>
|
|
||||||
<button class="lightbox-prev"><</button>
|
|
||||||
<button class="lightbox-next">></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({
|
app.registerExtension({
|
||||||
name: "KikoTools.LocalImageLoader",
|
name: "KikoTools.LocalImageLoader",
|
||||||
async beforeRegisterNodeDef(nodeType, nodeData, app) {
|
async beforeRegisterNodeDef(nodeType, nodeData, app) {
|
||||||
@@ -162,314 +39,30 @@ app.registerExtension({
|
|||||||
nodeType.prototype.onNodeCreated = function () {
|
nodeType.prototype.onNodeCreated = function () {
|
||||||
const r = onNodeCreated?.apply(this, arguments);
|
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 uniqueId = `kiko-gallery-${Math.random().toString(36).substring(2, 9)}`;
|
||||||
|
const galleryContainer = createImageBrowser(uniqueId, app.ui.settings, scope);
|
||||||
galleryContainer.id = uniqueId;
|
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 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 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>`;
|
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
|
// Add the widget to the node
|
||||||
this.galleryWidget = this.addDOMWidget("kiko_local_image_gallery", "div", galleryContainer, {
|
this.galleryWidget = this.addDOMWidget("kiko_local_image_gallery", "div", galleryContainer, {
|
||||||
serialize: false,
|
serialize: false,
|
||||||
|
hideOnZoom: false,
|
||||||
|
getMinHeight: () => 350,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Set initial size for the node
|
// Set initial size for the node
|
||||||
this.size = [800, 600];
|
this.size = [800, 900];
|
||||||
this.setSize(this.size);
|
this.setSize(this.size);
|
||||||
|
|
||||||
// Initialize gallery functionality
|
// Initialize gallery functionality
|
||||||
const node = this;
|
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 container = galleryContainer.querySelector('.kiko-container-wrapper');
|
||||||
const pathInput = container.querySelector('.kiko-path-input');
|
const pathInput = container.querySelector('.kiko-path-input');
|
||||||
const pathPresets = container.querySelector('.kiko-path-presets');
|
const pathPresets = container.querySelector('.kiko-path-presets');
|
||||||
@@ -497,61 +90,19 @@ app.registerExtension({
|
|||||||
audio: null
|
audio: null
|
||||||
};
|
};
|
||||||
|
|
||||||
// Utility: Debounce function
|
const debounce = scope.debounce;
|
||||||
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 originalOnRemoved = this.onRemoved;
|
const originalOnRemoved = this.onRemoved;
|
||||||
this.onRemoved = function () {
|
this.onRemoved = function () {
|
||||||
if (this.kikoResizeObserver) {
|
scope.dispose();
|
||||||
this.kikoResizeObserver.disconnect();
|
|
||||||
this.kikoResizeObserver = null;
|
|
||||||
}
|
|
||||||
return originalOnRemoved?.apply(this, arguments);
|
return originalOnRemoved?.apply(this, arguments);
|
||||||
};
|
};
|
||||||
|
scope.cleanup(() => { galleryContainer.remove(); });
|
||||||
|
scope.listen(container.querySelector('.ca-file-filter'), 'input', renderGallery);
|
||||||
|
|
||||||
const showStatus = (message) => {
|
const showStatus = (message) => {
|
||||||
|
if (scope.signal.aborted) return;
|
||||||
statusMessage.style.display = 'block';
|
statusMessage.style.display = 'block';
|
||||||
|
statusMessage.dataset.state = 'error';
|
||||||
statusMessage.textContent = message;
|
statusMessage.textContent = message;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -562,7 +113,7 @@ app.registerExtension({
|
|||||||
|
|
||||||
async function fetchDirectorySuggestions(path) {
|
async function fetchDirectorySuggestions(path) {
|
||||||
try {
|
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) {
|
if (!response.ok) {
|
||||||
// Disallowed or invalid paths simply yield no suggestions
|
// Disallowed or invalid paths simply yield no suggestions
|
||||||
return [];
|
return [];
|
||||||
@@ -570,7 +121,7 @@ app.registerExtension({
|
|||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
return data.directories || [];
|
return data.directories || [];
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error fetching directories:', error);
|
if (!scope.signal.aborted) console.error('Error fetching directories:', error);
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -639,10 +190,13 @@ app.registerExtension({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let suggestionVersion = 0;
|
||||||
const debouncedAutocomplete = debounce(async (value) => {
|
const debouncedAutocomplete = debounce(async (value) => {
|
||||||
|
const version = ++suggestionVersion;
|
||||||
const suggestions = await fetchDirectorySuggestions(value);
|
const suggestions = await fetchDirectorySuggestions(value);
|
||||||
renderAutocompleteSuggestions(suggestions);
|
if (!scope.signal.aborted && version === suggestionVersion && pathInput.value === value && document.activeElement === pathInput) renderAutocompleteSuggestions(suggestions);
|
||||||
}, 150);
|
}, 150);
|
||||||
|
const delayedHideAutocomplete = debounce(hideAutocomplete, 150);
|
||||||
|
|
||||||
// Path input event handlers for autocomplete
|
// Path input event handlers for autocomplete
|
||||||
pathInput.addEventListener('input', (e) => {
|
pathInput.addEventListener('input', (e) => {
|
||||||
@@ -663,7 +217,7 @@ app.registerExtension({
|
|||||||
|
|
||||||
pathInput.addEventListener('blur', () => {
|
pathInput.addEventListener('blur', () => {
|
||||||
// Delay hiding to allow click events to register
|
// Delay hiding to allow click events to register
|
||||||
setTimeout(hideAutocomplete, 150);
|
delayedHideAutocomplete();
|
||||||
});
|
});
|
||||||
|
|
||||||
pathInput.addEventListener('keydown', (e) => {
|
pathInput.addEventListener('keydown', (e) => {
|
||||||
@@ -713,11 +267,12 @@ app.registerExtension({
|
|||||||
// Load saved paths
|
// Load saved paths
|
||||||
async function loadSavedPaths() {
|
async function loadSavedPaths() {
|
||||||
try {
|
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) {
|
if (!response.ok) {
|
||||||
throw new Error(await describeHttpError(response));
|
throw new Error(await describeHttpError(response));
|
||||||
}
|
}
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
|
if (scope.signal.aborted) return;
|
||||||
|
|
||||||
pathPresets.innerHTML = '<option value="">-- Saved Paths --</option>';
|
pathPresets.innerHTML = '<option value="">-- Saved Paths --</option>';
|
||||||
data.saved_paths?.forEach(path => {
|
data.saved_paths?.forEach(path => {
|
||||||
@@ -728,7 +283,7 @@ app.registerExtension({
|
|||||||
pathPresets.appendChild(option);
|
pathPresets.appendChild(option);
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} 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.
|
// Returns true on success.
|
||||||
async function addSavedPath(directory) {
|
async function addSavedPath(directory) {
|
||||||
try {
|
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) {
|
if (!response.ok) {
|
||||||
throw new Error(await describeHttpError(response));
|
throw new Error(await describeHttpError(response));
|
||||||
}
|
}
|
||||||
@@ -745,7 +300,7 @@ app.registerExtension({
|
|||||||
const savedPaths = data.saved_paths || [];
|
const savedPaths = data.saved_paths || [];
|
||||||
|
|
||||||
if (!savedPaths.includes(directory)) {
|
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',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ paths: [...savedPaths, directory] })
|
body: JSON.stringify({ paths: [...savedPaths, directory] })
|
||||||
@@ -757,6 +312,7 @@ app.registerExtension({
|
|||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (scope.signal.aborted) return false;
|
||||||
console.error('Error saving path:', error);
|
console.error('Error saving path:', error);
|
||||||
showStatus(`Error saving path: ${error.message}`);
|
showStatus(`Error saving path: ${error.message}`);
|
||||||
return false;
|
return false;
|
||||||
@@ -775,7 +331,7 @@ app.registerExtension({
|
|||||||
if (!selectedPath) return;
|
if (!selectedPath) return;
|
||||||
|
|
||||||
try {
|
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) {
|
if (!response.ok) {
|
||||||
throw new Error(await describeHttpError(response));
|
throw new Error(await describeHttpError(response));
|
||||||
}
|
}
|
||||||
@@ -784,7 +340,7 @@ app.registerExtension({
|
|||||||
|
|
||||||
const updatedPaths = savedPaths.filter(p => p !== selectedPath);
|
const updatedPaths = savedPaths.filter(p => p !== selectedPath);
|
||||||
if (updatedPaths.length !== savedPaths.length) {
|
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',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ paths: updatedPaths })
|
body: JSON.stringify({ paths: updatedPaths })
|
||||||
@@ -795,6 +351,7 @@ app.registerExtension({
|
|||||||
await loadSavedPaths();
|
await loadSavedPaths();
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (scope.signal.aborted) return;
|
||||||
console.error('Error deleting path:', error);
|
console.error('Error deleting path:', error);
|
||||||
showStatus(`Error deleting path: ${error.message}`);
|
showStatus(`Error deleting path: ${error.message}`);
|
||||||
}
|
}
|
||||||
@@ -818,7 +375,9 @@ app.registerExtension({
|
|||||||
pathLabel.textContent = directory;
|
pathLabel.textContent = directory;
|
||||||
const addBtn = document.createElement('button');
|
const addBtn = document.createElement('button');
|
||||||
addBtn.textContent = 'Add folder & open';
|
addBtn.textContent = 'Add folder & open';
|
||||||
addBtn.style.marginLeft = '8px';
|
addBtn.type = 'button';
|
||||||
|
addBtn.className = 'ca-primary';
|
||||||
|
statusMessage.dataset.state = 'error';
|
||||||
addBtn.onclick = async () => {
|
addBtn.onclick = async () => {
|
||||||
addBtn.disabled = true;
|
addBtn.disabled = true;
|
||||||
// On failure addSavedPath shows the server's reason
|
// On failure addSavedPath shows the server's reason
|
||||||
@@ -831,7 +390,9 @@ app.registerExtension({
|
|||||||
|
|
||||||
// Load images from directory
|
// Load images from directory
|
||||||
async function loadImages(directory, page = 1) {
|
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.style.display = 'block';
|
||||||
statusMessage.textContent = 'Loading...';
|
statusMessage.textContent = 'Loading...';
|
||||||
@@ -848,19 +409,22 @@ app.registerExtension({
|
|||||||
});
|
});
|
||||||
|
|
||||||
try {
|
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) {
|
if (response.status === 403) {
|
||||||
handleFolderNotAllowed(directory, page);
|
handleFolderNotAllowed(directory, page);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
statusMessage.textContent = await describeHttpError(response);
|
const message = await describeHttpError(response);
|
||||||
|
if (version === loadVersion) showStatus(message);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (scope.signal.aborted || version !== loadVersion) return;
|
||||||
if (data.error) {
|
if (data.error) {
|
||||||
statusMessage.textContent = data.error;
|
showStatus(data.error);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -878,8 +442,9 @@ app.registerExtension({
|
|||||||
|
|
||||||
statusMessage.style.display = 'none';
|
statusMessage.style.display = 'none';
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (scope.signal.aborted || version !== loadVersion) return;
|
||||||
console.error('Error loading images:', error);
|
console.error('Error loading images:', error);
|
||||||
statusMessage.textContent = 'Error loading directory';
|
showStatus('Error loading directory');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -887,18 +452,24 @@ app.registerExtension({
|
|||||||
function renderGallery() {
|
function renderGallery() {
|
||||||
cardHolder.innerHTML = '';
|
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
|
// 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
|
const itemsToRender = parentDirectory
|
||||||
? [{ type: 'parent_dir', name: '..', path: parentDirectory }, ...currentItems]
|
? [{ type: 'parent_dir', name: '..', path: parentDirectory }, ...visibleItems]
|
||||||
: currentItems;
|
: visibleItems;
|
||||||
|
|
||||||
itemsToRender.forEach((item, index) => {
|
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';
|
card.className = 'kiko-gallery-card';
|
||||||
|
|
||||||
if (item.type === 'parent_dir') {
|
if (item.type === 'parent_dir') {
|
||||||
@@ -937,10 +508,6 @@ app.registerExtension({
|
|||||||
`;
|
`;
|
||||||
|
|
||||||
// Trigger layout when image loads
|
// Trigger layout when image loads
|
||||||
const img = card.querySelector('img');
|
|
||||||
if (img) {
|
|
||||||
img.onload = debouncedLayout;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Single click to select
|
// Single click to select
|
||||||
card.onclick = () => selectMedia(item, 'image', card);
|
card.onclick = () => selectMedia(item, 'image', card);
|
||||||
@@ -962,10 +529,6 @@ app.registerExtension({
|
|||||||
<div class="kiko-card-info-panel">${escapeHtml(item.name)}</div>
|
<div class="kiko-card-info-panel">${escapeHtml(item.name)}</div>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const img = card.querySelector('img');
|
|
||||||
if (img) {
|
|
||||||
img.onload = debouncedLayout;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Single click to select
|
// Single click to select
|
||||||
card.onclick = () => selectMedia(item, 'video', card);
|
card.onclick = () => selectMedia(item, 'video', card);
|
||||||
@@ -1007,24 +570,27 @@ app.registerExtension({
|
|||||||
cardHolder.appendChild(card);
|
cardHolder.appendChild(card);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Apply layout after all cards are added
|
container.querySelector('[data-file-count]').textContent = `${visibleItems.length} of ${currentItems.length} items`;
|
||||||
requestAnimationFrame(debouncedLayout);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Select media
|
// Select media
|
||||||
async function selectMedia(item, type, cardElement) {
|
async function selectMedia(item, type, cardElement) {
|
||||||
// Update selection
|
// Update selection
|
||||||
|
const previousPath = selectedPaths[type];
|
||||||
selectedPaths[type] = item.path;
|
selectedPaths[type] = item.path;
|
||||||
|
|
||||||
// Update UI
|
// Update UI
|
||||||
cardHolder.querySelectorAll('.kiko-gallery-card').forEach(card => {
|
cardHolder.querySelectorAll('.kiko-gallery-card').forEach(card => {
|
||||||
card.classList.remove('kiko-selected');
|
card.classList.remove('kiko-selected');
|
||||||
|
if (card.hasAttribute('aria-pressed')) card.setAttribute('aria-pressed', 'false');
|
||||||
});
|
});
|
||||||
cardElement.classList.add('kiko-selected');
|
cardElement.classList.add('kiko-selected');
|
||||||
|
cardElement.setAttribute('aria-pressed', 'true');
|
||||||
|
|
||||||
// Send to backend
|
// Send to backend
|
||||||
try {
|
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',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
@@ -1034,13 +600,19 @@ app.registerExtension({
|
|||||||
})
|
})
|
||||||
});
|
});
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
|
selectedPaths[type] = previousPath;
|
||||||
|
renderGallery();
|
||||||
showStatus(`Selection rejected: ${await describeHttpError(response)}`);
|
showStatus(`Selection rejected: ${await describeHttpError(response)}`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Trigger node update
|
if (scope.signal.aborted) return;
|
||||||
|
container.querySelector('[data-selection]').textContent = item.name;
|
||||||
node.setDirtyCanvas(true);
|
node.setDirtyCanvas(true);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (scope.signal.aborted) return;
|
||||||
|
selectedPaths[type] = previousPath;
|
||||||
|
renderGallery();
|
||||||
console.error('Error setting selection:', error);
|
console.error('Error setting selection:', error);
|
||||||
showStatus('Error setting selection');
|
showStatus('Error setting selection');
|
||||||
}
|
}
|
||||||
@@ -1055,9 +627,9 @@ app.registerExtension({
|
|||||||
const createButton = (text, page) => {
|
const createButton = (text, page) => {
|
||||||
const btn = document.createElement('button');
|
const btn = document.createElement('button');
|
||||||
btn.textContent = text;
|
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) {
|
if (page === currentPage) {
|
||||||
btn.style.background = '#555';
|
btn.setAttribute('aria-current', 'page');
|
||||||
}
|
}
|
||||||
btn.onclick = () => loadImages(currentDirectory, page);
|
btn.onclick = () => loadImages(currentDirectory, page);
|
||||||
return btn;
|
return btn;
|
||||||
@@ -1073,7 +645,7 @@ app.registerExtension({
|
|||||||
} else if (i === currentPage - 3 || i === currentPage + 3) {
|
} else if (i === currentPage - 3 || i === currentPage + 3) {
|
||||||
const span = document.createElement('span');
|
const span = document.createElement('span');
|
||||||
span.textContent = '...';
|
span.textContent = '...';
|
||||||
span.style.padding = '0 5px';
|
|
||||||
pagination.appendChild(span);
|
pagination.appendChild(span);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1101,14 +673,14 @@ app.registerExtension({
|
|||||||
loadSavedPaths();
|
loadSavedPaths();
|
||||||
|
|
||||||
// Try to load last path
|
// 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;
|
if (!response.ok) return;
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
if (data.last_path) {
|
if (data.last_path && loadVersion === 0 && !scope.signal.aborted) {
|
||||||
loadImages(data.last_path);
|
loadImages(data.last_path);
|
||||||
}
|
}
|
||||||
}).catch(error => {
|
}).catch(error => {
|
||||||
console.error('Error loading last path:', error);
|
if (!scope.signal.aborted) console.error('Error loading last path:', error);
|
||||||
});
|
});
|
||||||
|
|
||||||
return r;
|
return r;
|
||||||
|
|||||||
@@ -1,541 +1,96 @@
|
|||||||
import { app } from "../../scripts/app.js";
|
import { app } from '../../scripts/app.js';
|
||||||
import { api } from "../../scripts/api.js";
|
import { api } from '../../scripts/api.js';
|
||||||
import { $el } from "../../scripts/ui.js";
|
import { createScope, mountTheme } from './ui/core.js';
|
||||||
|
import { createColorPicker } from './ui/color-picker.js';
|
||||||
|
import { WorkflowClock } from './ui/workflow-clock.js';
|
||||||
|
|
||||||
/**
|
const clock = new WorkflowClock(app.ui.settings);
|
||||||
* Sketch-style Color Picker for Settings
|
let colorPicker;
|
||||||
*/
|
|
||||||
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",
|
|
||||||
|
|
||||||
|
app.registerExtension({
|
||||||
|
name: 'ComfyAssets.KikoWorkflowTimer',
|
||||||
async init() {
|
async init() {
|
||||||
// Color picker setting with custom render
|
|
||||||
app.ui.settings.addSetting({
|
app.ui.settings.addSetting({
|
||||||
id: "kikotools.workflow_timer.color",
|
id: 'kikotools.workflow_timer.color',
|
||||||
name: "🫶 Workflow Timer: Color",
|
name: '🫶 Workflow Timer: Color',
|
||||||
type: (name, setter, value) => {
|
type: (name, setter, value) => {
|
||||||
const container = $el("div", {
|
if (!colorPicker || colorPicker.scope?.signal.aborted) {
|
||||||
style: { display: "flex", alignItems: "center", gap: "10px" }
|
colorPicker = createColorPicker(value, setter, app.ui.settings, 'Timer color');
|
||||||
});
|
}
|
||||||
|
colorPicker.onChange = setter;
|
||||||
const picker = new SketchColorPicker(value || '#ff9966', (newColor) => {
|
return colorPicker;
|
||||||
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;
|
|
||||||
},
|
},
|
||||||
defaultValue: "#ff9966",
|
defaultValue: '#0877bb',
|
||||||
tooltip: "Color of the timer display when not running",
|
tooltip: 'Color of the workflow timer display',
|
||||||
|
onChange: () => clock.refresh(),
|
||||||
});
|
});
|
||||||
|
|
||||||
// Glow toggle
|
|
||||||
app.ui.settings.addSetting({
|
app.ui.settings.addSetting({
|
||||||
id: "kikotools.workflow_timer.glow",
|
id: 'kikotools.workflow_timer.glow',
|
||||||
name: "🫶 Workflow Timer: Enable Glow",
|
name: '🫶 Workflow Timer: Enable Glow',
|
||||||
type: "boolean",
|
type: 'boolean',
|
||||||
defaultValue: true,
|
defaultValue: true,
|
||||||
tooltip: "Enable pulsing glow effect on the timer",
|
tooltip: 'Enable the pulsing glow unless reduced motion is preferred',
|
||||||
onChange: (value) => {
|
onChange: () => clock.refresh(),
|
||||||
KikoGlobalTimer.updateGlow(value);
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
async beforeRegisterNodeDef(nodeType, nodeData, app) {
|
async beforeRegisterNodeDef(nodeType, nodeData, app) {
|
||||||
if (nodeData.name === "KikoWorkflowTimer") {
|
if (nodeData.name !== 'KikoWorkflowTimer') return;
|
||||||
const origOnNodeCreated = nodeType.prototype.onNodeCreated;
|
const onNodeCreated = nodeType.prototype.onNodeCreated;
|
||||||
nodeType.prototype.onNodeCreated = function() {
|
nodeType.prototype.onNodeCreated = function () {
|
||||||
origOnNodeCreated?.apply(this, arguments);
|
const result = onNodeCreated?.apply(this, arguments);
|
||||||
|
this.timerScope = createScope();
|
||||||
this.bgcolor = "#1a1a2e";
|
this.properties = this.properties || {};
|
||||||
this.color = "#16213e";
|
const container = document.createElement('div');
|
||||||
this.title = "Workflow Timer";
|
container.className = 'ca-tools ca-workflow-timer kiko-timer-container';
|
||||||
this.properties = this.properties || {};
|
const header = document.createElement('header');
|
||||||
this.size = [300, 100];
|
header.className = 'ca-timer-header';
|
||||||
|
const title = document.createElement('h3');
|
||||||
const container = document.createElement("div");
|
title.textContent = 'Workflow Timer';
|
||||||
container.className = "kiko-timer-container";
|
const brand = document.createElement('span');
|
||||||
|
brand.textContent = 'ComfyAssets';
|
||||||
this.timerDisplay = document.createElement("div");
|
header.append(title, brand);
|
||||||
this.timerDisplay.className = "kiko-timer-display";
|
this.timerDisplay = document.createElement('div');
|
||||||
this.timerDisplay.textContent = this.properties.elapsed_time_str || "00:00:000";
|
this.timerDisplay.className = 'kiko-timer-display';
|
||||||
|
this.timerDisplay.setAttribute('role', 'timer');
|
||||||
const timerColor = KikoGlobalTimer.getTimerColor();
|
this.timerDisplay.setAttribute('aria-label', 'Elapsed workflow time');
|
||||||
this.timerDisplay.style.color = timerColor;
|
this.timerDisplay.setAttribute('aria-live', 'off');
|
||||||
|
container.append(header, this.timerDisplay);
|
||||||
if (!KikoGlobalTimer.isGlowEnabled()) {
|
mountTheme(container, app.ui.settings, this.timerScope);
|
||||||
this.timerDisplay.classList.add('no-glow');
|
this.addDOMWidget('kikoTimer', 'Kiko Timer', container, {
|
||||||
}
|
serialize: false, hideOnZoom: false, getMinHeight: () => 90,
|
||||||
|
});
|
||||||
container.appendChild(this.timerDisplay);
|
this.setSize([340, 180]);
|
||||||
this.addDOMWidget("kikoTimer", "Kiko Timer", container, { serialize: false });
|
clock.registerNode(this);
|
||||||
|
this.timerScope.cleanup(() => {
|
||||||
KikoGlobalTimer.registerNode(this);
|
clock.unregisterNode(this);
|
||||||
};
|
container.remove();
|
||||||
|
});
|
||||||
const origOnRemoved = nodeType.prototype.onRemoved;
|
return result;
|
||||||
nodeType.prototype.onRemoved = function() {
|
};
|
||||||
KikoGlobalTimer.unregisterNode(this);
|
const onRemoved = nodeType.prototype.onRemoved;
|
||||||
return origOnRemoved?.apply(this, arguments);
|
nodeType.prototype.onRemoved = function () {
|
||||||
};
|
this.timerScope.dispose();
|
||||||
|
return onRemoved?.apply(this, arguments);
|
||||||
const origOnSerialize = nodeType.prototype.onSerialize;
|
};
|
||||||
nodeType.prototype.onSerialize = function(o) {
|
const onSerialize = nodeType.prototype.onSerialize;
|
||||||
origOnSerialize?.apply(this, arguments);
|
nodeType.prototype.onSerialize = function (data) {
|
||||||
o.properties = this.properties;
|
const result = onSerialize?.apply(this, arguments);
|
||||||
};
|
data.properties = { ...data.properties, elapsed_time_str: this.properties.elapsed_time_str };
|
||||||
|
return result;
|
||||||
const origOnConfigure = nodeType.prototype.onConfigure;
|
};
|
||||||
nodeType.prototype.onConfigure = function(info) {
|
const onConfigure = nodeType.prototype.onConfigure;
|
||||||
origOnConfigure?.apply(this, arguments);
|
nodeType.prototype.onConfigure = function (data) {
|
||||||
this.properties = { ...(this.properties || {}), ...(info?.properties || {}) };
|
const result = onConfigure?.apply(this, arguments);
|
||||||
|
this.properties = { ...(this.properties || {}), ...(data?.properties || {}) };
|
||||||
if (this.timerDisplay) {
|
if (this.timerDisplay) clock.renderNode(this);
|
||||||
this.timerDisplay.textContent = this.properties.elapsed_time_str || "00:00:000";
|
return result;
|
||||||
const timerColor = KikoGlobalTimer.getTimerColor();
|
};
|
||||||
this.timerDisplay.style.color = timerColor;
|
|
||||||
|
|
||||||
if (!KikoGlobalTimer.isGlowEnabled()) {
|
|
||||||
this.timerDisplay.classList.add('no-glow');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
|
|
||||||
setup() {
|
setup() {
|
||||||
const style = document.createElement("style");
|
api.addEventListener('execution_start', () => clock.start());
|
||||||
style.textContent = `
|
api.addEventListener('executing', ({ detail }) => { if (detail === null) clock.stop(); });
|
||||||
@keyframes kiko-timer-pulse {
|
for (const event of ['execution_error', 'execution_interrupted']) api.addEventListener(event, () => clock.stop());
|
||||||
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);
|
|
||||||
|
|||||||
@@ -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({
|
app.registerExtension({
|
||||||
name: "ComfyAssets.Extensions",
|
name: 'ComfyAssets.Extensions',
|
||||||
|
|
||||||
// New API: return items to append to node right-click menu
|
|
||||||
getNodeMenuItems(node) {
|
getNodeMenuItems(node) {
|
||||||
// Only add our menu items to ComfyAssets nodes
|
if (!node.constructor.category || !node.constructor.category.includes('ComfyAssets')) return [];
|
||||||
if (!node.constructor.category || !node.constructor.category.includes("ComfyAssets")) {
|
return [null, {
|
||||||
return [];
|
content: '🎨 Node Dimensions',
|
||||||
}
|
callback: () => KikoToolsExtensions.showNodeDimensionsDialog(node),
|
||||||
|
}];
|
||||||
return [
|
},
|
||||||
null, // separator
|
|
||||||
{
|
|
||||||
content: "🎨 Node Dimensions",
|
|
||||||
callback: () => {
|
|
||||||
KikoToolsExtensions.showNodeDimensionsDialog(node);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
];
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
|
||||||
* KikoTools Extensions utilities
|
|
||||||
*/
|
|
||||||
class KikoToolsExtensions {
|
class KikoToolsExtensions {
|
||||||
/**
|
|
||||||
* Create a dialog for settings
|
|
||||||
*/
|
|
||||||
static createDialog(htmlContent, onOK, onCancel) {
|
static createDialog(htmlContent, onOK, onCancel) {
|
||||||
const dialog = document.createElement("div");
|
return createLegacyDialog(htmlContent, onOK, onCancel, app.ui.settings, app.canvas.canvas);
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Show node dimensions dialog
|
|
||||||
*/
|
|
||||||
static showNodeDimensionsDialog(node) {
|
static showNodeDimensionsDialog(node) {
|
||||||
const nodeWidth = Math.round(node.size[0]);
|
return showNodeDimensionsDialog(node, app);
|
||||||
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
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Export for global access
|
|
||||||
window.KikoToolsExtensions = KikoToolsExtensions;
|
window.KikoToolsExtensions = KikoToolsExtensions;
|
||||||
|
|||||||
@@ -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({
|
app.registerExtension({
|
||||||
name: "comfyassets.SeedHistory",
|
name: 'comfyassets.SeedHistory',
|
||||||
|
async setup() {
|
||||||
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
|
|
||||||
window.seedHistoryNodes = window.seedHistoryNodes || [];
|
window.seedHistoryNodes = window.seedHistoryNodes || [];
|
||||||
window.seedHistoryNodes.push(this);
|
},
|
||||||
|
async beforeRegisterNodeDef(nodeType, nodeData, app) {
|
||||||
// Create UI container
|
if (nodeData.name !== 'SeedHistory') return;
|
||||||
const uiContainer = document.createElement("div");
|
const onNodeCreated = nodeType.prototype.onNodeCreated;
|
||||||
uiContainer.style.padding = "8px";
|
nodeType.prototype.onNodeCreated = function () {
|
||||||
uiContainer.style.backgroundColor = "#1e1e1e";
|
const result = onNodeCreated?.apply(this, arguments);
|
||||||
uiContainer.style.borderRadius = "6px";
|
this.seedScope = createScope();
|
||||||
uiContainer.style.marginTop = "6px";
|
this.seedHistory = this.loadSeedHistory();
|
||||||
uiContainer.style.border = "1px solid #404040";
|
this.hideTimer = null;
|
||||||
|
this.seedSetupTimer = null;
|
||||||
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);
|
|
||||||
this.seedValueWatcher = null;
|
this.seedValueWatcher = null;
|
||||||
}
|
this.mouseOverHistory = false;
|
||||||
|
window.seedHistoryNodes = window.seedHistoryNodes || [];
|
||||||
// Clean up deduplication tracking
|
window.seedHistoryNodes.push(this);
|
||||||
if (this.lastAddedSeed) {
|
this.buildSeedInterface();
|
||||||
this.lastAddedSeed = null;
|
this.seedWidget = this.addDOMWidget('seed_history_ui', 'div', this.seedView.element, {
|
||||||
}
|
serialize: false, hideOnZoom: false,
|
||||||
|
getMinHeight: () => 200,
|
||||||
// Remove from global registry
|
});
|
||||||
if (window.seedHistoryNodes) {
|
if (!this.hasBeenResized) this.setSize([320, 420]);
|
||||||
const index = window.seedHistoryNodes.indexOf(this);
|
const originalResize = this.onResize;
|
||||||
if (index !== -1) {
|
this.onResize = function () {
|
||||||
window.seedHistoryNodes.splice(index, 1);
|
this.hasBeenResized = true;
|
||||||
}
|
return originalResize?.apply(this, arguments);
|
||||||
}
|
};
|
||||||
|
this.seedSetupTimer = setTimeout(() => {
|
||||||
if (originalOnRemoved) {
|
this.seedSetupTimer = null;
|
||||||
originalOnRemoved.call(this);
|
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);
|
nodeType.prototype.setupSeedWidgetCallbacks = function () {
|
||||||
};
|
if (this.seedScope.signal.aborted || this.seedValueWatcher !== null) return;
|
||||||
|
const seedWidget = this.widgets?.find(widget => widget.name === 'seed');
|
||||||
// Setup seed widget callbacks to track increment/decrement/randomize
|
if (!seedWidget) {
|
||||||
nodeType.prototype.setupSeedWidgetCallbacks = function () {
|
this.seedSetupTimer = setTimeout(() => {
|
||||||
// Find the seed widget
|
this.seedSetupTimer = null;
|
||||||
const seedWidget = this.widgets?.find(w => w.name === "seed");
|
this.setupSeedWidgetCallbacks();
|
||||||
if (!seedWidget) {
|
}, 500);
|
||||||
setTimeout(() => this.setupSeedWidgetCallbacks(), 500);
|
return;
|
||||||
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) {
|
|
||||||
this.lastSeedValue = seedWidget.value;
|
this.lastSeedValue = seedWidget.value;
|
||||||
this.addSeedToHistory(seedWidget.value);
|
this.seedValueWatcher = setInterval(() => {
|
||||||
}
|
if (seedWidget.value !== this.lastSeedValue) {
|
||||||
}, 1000);
|
this.lastSeedValue = seedWidget.value;
|
||||||
};
|
this.addSeedToHistory(seedWidget.value);
|
||||||
|
}
|
||||||
|
}, 1000);
|
||||||
|
};
|
||||||
|
|
||||||
// Build the seed interface
|
nodeType.prototype.buildSeedInterface = function () {
|
||||||
nodeType.prototype.buildSeedInterface = function (container) {
|
this.seedView = createSeedHistoryView(this.seedScope, {
|
||||||
container.innerHTML = "";
|
generate: () => this.generateRandomSeed(),
|
||||||
|
clear: () => this.clearSeedHistory(),
|
||||||
// Header
|
select: index => this.useSeedFromHistory(this.seedHistory[index], index),
|
||||||
const headerDiv = document.createElement("div");
|
expand: expanded => expanded ? this.showHistorySection() : this.hideHistorySection(true),
|
||||||
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";
|
|
||||||
});
|
});
|
||||||
restoreDiv.addEventListener("mouseleave", () => {
|
const { element, list, disclosure } = this.seedView;
|
||||||
restoreDiv.style.backgroundColor = "#2a2a2a";
|
this.historyDisplay = list;
|
||||||
restoreDiv.style.color = "#888";
|
this.restoreButton = disclosure;
|
||||||
|
mountTheme(element, app.ui.settings, this.seedScope);
|
||||||
|
this.seedScope.listen(list, 'mouseenter', () => {
|
||||||
|
this.mouseOverHistory = true;
|
||||||
|
this.cancelAutoHide();
|
||||||
});
|
});
|
||||||
|
this.seedScope.listen(list, 'mouseleave', () => {
|
||||||
restoreDiv.addEventListener("click", () => {
|
this.mouseOverHistory = false;
|
||||||
this.showHistorySection();
|
this.startAutoHide();
|
||||||
});
|
});
|
||||||
|
this.seedScope.listen(list, 'focusin', () => this.cancelAutoHide());
|
||||||
|
this.seedScope.listen(list, 'focusout', () => this.startAutoHide());
|
||||||
|
this.refreshHistoryDisplay();
|
||||||
|
};
|
||||||
|
|
||||||
this.restoreButton = restoreDiv;
|
nodeType.prototype.loadSeedHistory = function () {
|
||||||
this.historyDisplay.parentNode.insertBefore(
|
try { return historyValue(JSON.parse(localStorage.getItem(storageKey))); }
|
||||||
restoreDiv,
|
catch { return []; }
|
||||||
this.historyDisplay.nextSibling
|
};
|
||||||
);
|
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) {
|
||||||
nodeType.prototype.showHistorySection = function () {
|
const seed = seedValue(value);
|
||||||
if (this.historyDisplay) {
|
if (seed === null || this.seedScope.signal.aborted) return;
|
||||||
this.historyDisplay.style.display = "block";
|
const now = Date.now();
|
||||||
|
if (this.lastAddedSeed?.seed === seed && now - this.lastAddedSeed.timestamp < 500) return;
|
||||||
if (this.restoreButton && this.restoreButton.parentNode) {
|
this.lastAddedSeed = { seed, timestamp: now };
|
||||||
this.restoreButton.parentNode.removeChild(this.restoreButton);
|
this.seedHistory = [{ seed, timestamp: now, dateString: new Date(now).toLocaleString() },
|
||||||
this.restoreButton = null;
|
...this.seedHistory.filter(item => item.seed !== seed)].slice(0, 10);
|
||||||
}
|
this.saveSeedHistory();
|
||||||
|
this.refreshHistoryDisplay();
|
||||||
this.startAutoHide();
|
};
|
||||||
}
|
nodeType.prototype.generateRandomSeed = function () {
|
||||||
};
|
const seed = Math.floor(Math.random() * 0xFFFFFFFF);
|
||||||
|
const widget = this.widgets?.find(widget => widget.name === 'seed');
|
||||||
// Format time ago
|
if (widget) {
|
||||||
nodeType.prototype.formatTimeAgo = function (timestamp) {
|
widget.value = seed;
|
||||||
const now = Date.now();
|
widget.callback?.(seed, app.canvas, this, [0, 0], null);
|
||||||
const diff = now - timestamp;
|
this.lastSeedValue = widget.value;
|
||||||
const seconds = Math.floor(diff / 1000);
|
}
|
||||||
const minutes = Math.floor(seconds / 60);
|
this.addSeedToHistory(seed);
|
||||||
const hours = Math.floor(minutes / 60);
|
this.setDirtyCanvas(true, true);
|
||||||
const days = Math.floor(hours / 24);
|
this.showMessage(`Generated: ${seed}`, 'success');
|
||||||
|
};
|
||||||
if (days > 0) return `${days}d ago`;
|
nodeType.prototype.useSeedFromHistory = function (item, index) {
|
||||||
if (hours > 0) return `${hours}h ago`;
|
const widget = this.widgets?.find(widget => widget.name === 'seed');
|
||||||
if (minutes > 0) return `${minutes}m ago`;
|
if (widget) {
|
||||||
return `${seconds}s ago`;
|
widget.value = item.seed;
|
||||||
};
|
widget.callback?.(item.seed, app.canvas, this, [0, 0], null);
|
||||||
|
this.lastSeedValue = widget.value;
|
||||||
// Show messages
|
}
|
||||||
nodeType.prototype.showMessage = function (message, type = "info") {
|
this.highlightHistoryEntry(index);
|
||||||
const notification = document.createElement("div");
|
this.setDirtyCanvas(true, true);
|
||||||
notification.style.position = "fixed";
|
this.startAutoHide();
|
||||||
notification.style.top = "15px";
|
this.showMessage(`Loaded: ${item.seed}`);
|
||||||
notification.style.right = "15px";
|
};
|
||||||
notification.style.padding = "6px 10px";
|
nodeType.prototype.clearSeedHistory = function () {
|
||||||
notification.style.borderRadius = "3px";
|
this.seedHistory = [];
|
||||||
notification.style.color = "white";
|
this.lastAddedSeed = null;
|
||||||
notification.style.fontSize = "10px";
|
this.lastSeedValue = this.widgets?.find(widget => widget.name === 'seed')?.value;
|
||||||
notification.style.zIndex = "10000";
|
this.saveSeedHistory();
|
||||||
notification.style.maxWidth = "200px";
|
this.showHistorySection();
|
||||||
notification.textContent = message;
|
this.refreshHistoryDisplay();
|
||||||
|
this.showMessage('History cleared');
|
||||||
switch (type) {
|
};
|
||||||
case "success":
|
nodeType.prototype.refreshHistoryDisplay = function () {
|
||||||
notification.style.backgroundColor = "#28a745";
|
if (!this.seedView) return;
|
||||||
break;
|
const selected = seedValue(this.widgets?.find(widget => widget.name === 'seed')?.value);
|
||||||
case "error":
|
this.seedView.render(this.seedHistory, selected, timestamp => this.formatTimeAgo(timestamp));
|
||||||
notification.style.backgroundColor = "#dc3545";
|
this.startAutoHide();
|
||||||
break;
|
};
|
||||||
case "warning":
|
nodeType.prototype.highlightHistoryEntry = function (index) {
|
||||||
notification.style.backgroundColor = "#ffc107";
|
this.historyDisplay.querySelectorAll('button[data-seed-index]').forEach((entry, i) => {
|
||||||
break;
|
entry.setAttribute('aria-pressed', String(i === index));
|
||||||
default:
|
});
|
||||||
notification.style.backgroundColor = "#17a2b8";
|
};
|
||||||
}
|
nodeType.prototype.startAutoHide = function () {
|
||||||
|
this.cancelAutoHide();
|
||||||
document.body.appendChild(notification);
|
if (!this.seedScope.signal.aborted && !this.mouseOverHistory && this.seedHistory.length && !this.historyDisplay.hidden) {
|
||||||
|
this.hideTimer = setTimeout(() => {
|
||||||
setTimeout(() => {
|
this.hideTimer = null;
|
||||||
if (notification.parentNode) {
|
this.hideHistorySection();
|
||||||
document.body.removeChild(notification);
|
}, 2500);
|
||||||
}
|
}
|
||||||
}, 1800);
|
};
|
||||||
};
|
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;
|
||||||
|
};
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,192 @@
|
|||||||
|
# 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.
|
||||||
@@ -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`;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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('');
|
||||||
|
}
|
||||||
@@ -0,0 +1,230 @@
|
|||||||
|
.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-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; }
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 };
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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; } }
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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.';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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({
|
app.registerExtension({
|
||||||
name: "comfyassets.WidthHeightSelector",
|
name: 'comfyassets.WidthHeightSelector',
|
||||||
async beforeRegisterNodeDef(nodeType, nodeData, _app) {
|
async beforeRegisterNodeDef(nodeType, nodeData) {
|
||||||
if (nodeData.name === "WidthHeightSelector") {
|
if (nodeData.name !== 'WidthHeightSelector') return;
|
||||||
const onNodeCreated = nodeType.prototype.onNodeCreated;
|
nodeType.prototype.createSwapButton = function () { return mountDimensionSwap(this, app); };
|
||||||
nodeType.prototype.onNodeCreated = function () {
|
const onNodeCreated = nodeType.prototype.onNodeCreated;
|
||||||
if (onNodeCreated) onNodeCreated.apply(this, []);
|
nodeType.prototype.onNodeCreated = function () {
|
||||||
|
const result = onNodeCreated?.apply(this, arguments);
|
||||||
// Track button click state for visual feedback
|
this.extractResolutionFromPreset = extractResolutionFromPreset;
|
||||||
this.swapButtonPressed = false;
|
this.swapDimensions = () => swapDimensions(this, app);
|
||||||
|
bindDimensionPresets(this, app);
|
||||||
// Helper function to extract resolution from formatted preset string
|
this.createSwapButton();
|
||||||
this.extractResolutionFromPreset = function(presetValue) {
|
return result;
|
||||||
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;
|
|
||||||
};
|
};
|
||||||
|
},
|
||||||
// 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);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|||||||