feat: initial release v1.0.0 — CWK Prompt Composer
This commit is contained in:
+33
@@ -0,0 +1,33 @@
|
||||
# Python
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*.pyo
|
||||
*.pyd
|
||||
*.egg-info/
|
||||
dist/
|
||||
build/
|
||||
.eggs/
|
||||
*.egg
|
||||
|
||||
# Virtual environments
|
||||
.venv/
|
||||
venv/
|
||||
env/
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
desktop.ini
|
||||
|
||||
# Editors
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
|
||||
# Node / npm (if ever used)
|
||||
node_modules/
|
||||
@@ -0,0 +1,28 @@
|
||||
# Changelog
|
||||
|
||||
All notable changes to **CWK Prompt Composer** will be documented here.
|
||||
|
||||
The format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
|
||||
|
||||
---
|
||||
|
||||
## [1.0.0] - 2026-03-05
|
||||
|
||||
### Added
|
||||
- Initial release of CWK Prompt Composer custom node for ComfyUI
|
||||
- Four prompt panels: Quality, Main, Aesthetic, Negative
|
||||
- Pill canvas with drag-to-reorder, selection, weight control (right-click)
|
||||
- Tag browser: flat 2-level (Quality/Aesthetic/Negative) and 3-level nested (Main)
|
||||
- Live filter search in tag browser
|
||||
- Free-type custom tag input with Enter-to-add
|
||||
- Join / Split selected pills
|
||||
- Full undo / redo history (up to 100 steps)
|
||||
- Preset save / load / rename / delete / export / import (JSON)
|
||||
- 🎲 I Feel Lucky random prompt generator (Main panel)
|
||||
- 🔞 NSFW toggle for Lucky generator (opt-in, stored in localStorage)
|
||||
- 📌 Add-to-Tag-List: persist custom tags back to JSON files via `/cwk/add_tag` API
|
||||
- Live positive/negative preview drawn directly on the ComfyUI node canvas
|
||||
- Node serialisation: state saved and restored with workflow JSON
|
||||
- Keyboard shortcuts: Ctrl+Enter confirm, Escape cancel, Ctrl+Z/Y undo/redo
|
||||
- Curated tag libraries: quality.json, aesthetic.json, main.json, negative.json, lucky.json
|
||||
- Resizable & draggable panel windows
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 cowneko
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1,109 @@
|
||||
# CWK Prompt Composer
|
||||
|
||||
A custom node for [ComfyUI](https://github.com/comfyanonymous/ComfyUI) that lets you compose positive and negative prompts using a visual **pill-based editor** with tag browsing, presets, drag-to-reorder, weight control, and a lucky prompt generator.
|
||||
|
||||
---
|
||||
|
||||
## ✨ Features
|
||||
|
||||
- **4 prompt panels** — Quality, Main, Aesthetic, and Negative prompts, each with their own curated tag library
|
||||
- **Pill canvas** — Each tag is a draggable, reorderable pill; click to select, drag to rearrange
|
||||
- **Weight control** — Right-click any pill to set its emphasis weight (e.g. `(tag:1.3)`)
|
||||
- **Tag browser** — Browse tags organised by category and subcategory with a live filter
|
||||
- Flat 2-level browser for Quality / Aesthetic / Negative panels
|
||||
- 3-level nested browser for the Main panel
|
||||
- **Free-type input** — Type any custom tag and press Enter to add it instantly
|
||||
- **Join / Split** — Merge selected pills with `_` or split underscore-joined pills apart
|
||||
- **Undo / Redo** — Full history (Ctrl+Z / Ctrl+Shift+Z or Ctrl+Y)
|
||||
- **Presets** — Save, load, rename, export (JSON) and import presets per panel
|
||||
- **🎲 I Feel Lucky** — Generate a random coherent prompt from subject / clothing / expression / action / environment pools
|
||||
- **🔞 NSFW toggle** — Opt-in to include adult content in the Lucky generator
|
||||
- **📌 Add to Tag List** — Pin any custom or free-typed tag back into the persistent JSON tag library
|
||||
- **Live preview** — The node canvas shows live previews of the assembled positive and negative prompts
|
||||
- **Keyboard shortcuts** — `Esc` to cancel, `Ctrl+Enter` to confirm, `Ctrl+Z/Y` for undo/redo
|
||||
|
||||
---
|
||||
|
||||
## 📦 Installation
|
||||
|
||||
### Via ComfyUI Manager (recommended)
|
||||
Search for **CWK Prompt Composer** in the ComfyUI Manager and install directly.
|
||||
|
||||
### Manual installation
|
||||
```bash
|
||||
cd ComfyUI/custom_nodes
|
||||
git clone https://github.com/cowneko/CWK_Prompt_Composer.git
|
||||
```
|
||||
Then restart ComfyUI.
|
||||
|
||||
---
|
||||
|
||||
## 🗂️ File Structure
|
||||
|
||||
```
|
||||
CWK_Prompt_Composer/
|
||||
├── __init__.py ← Node registration + web directory
|
||||
├── nodes.py ← Python node + /cwk/add_tag API route
|
||||
└── web/
|
||||
├── index.js ← Extension entry, node draw & state
|
||||
├── pill_canvas.js ← PillCanvas widget + category colours
|
||||
├── tag_browser.js ← Flat & 3-level nested tag browsers
|
||||
├── panel_dialog.js ← Panel dialog, Lucky generator, NSFW toggle
|
||||
├── preset_manager.js ← Preset manager, window builder, storage helpers
|
||||
└── tags/
|
||||
├── quality.json ← Quality prompt tags
|
||||
├── aesthetic.json ← Aesthetic / style tags
|
||||
├── main.json ← Main subject / clothing / action / environment tags
|
||||
├── negative.json ← Negative prompt tags
|
||||
└── lucky.json ← Lucky prompt pools (SFW + NSFW)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🖱️ Usage
|
||||
|
||||
1. Add the **CWK Prompt Composer** node to your workflow.
|
||||
2. Click any of the four buttons on the node (⭐ Quality, 🖼️ Main, 🎨 Aesthetic, ❌ Negative) to open its panel.
|
||||
3. Browse or filter tags in the bottom browser and click to add them as pills.
|
||||
4. Type a custom tag in the input bar and press **Enter**.
|
||||
5. Drag pills to reorder. Right-click a pill to set its weight or add it to the tag library.
|
||||
6. Click **✅ Confirm** — the assembled prompt string is written back to the node widget.
|
||||
7. The node outputs `positive_prompt` (Quality + Main + Aesthetic joined) and `negative_prompt`.
|
||||
|
||||
### Keyboard shortcuts (inside a panel)
|
||||
| Shortcut | Action |
|
||||
|---|---|
|
||||
| `Ctrl + Enter` | Confirm |
|
||||
| `Escape` | Cancel |
|
||||
| `Ctrl + Z` | Undo |
|
||||
| `Ctrl + Shift + Z` / `Ctrl + Y` | Redo |
|
||||
|
||||
---
|
||||
|
||||
## 🎨 Tag Categories
|
||||
|
||||
| Panel | Categories |
|
||||
|---|---|
|
||||
| **Quality** | Resolution, Detail, Rendering, Award Winning |
|
||||
| **Aesthetic** | Framing, Art Style, Era, Mood, Color Palette, Lighting |
|
||||
| **Main** | Characters, Clothing, Action, Environment, NSFW |
|
||||
| **Negative** | Quality Issues, Anatomy, Faces, Hands, Composition |
|
||||
|
||||
---
|
||||
|
||||
## 🔧 Adding Tags Permanently
|
||||
|
||||
Right-click any pill in the canvas → **📌 Add to Tag List**, or click the **📌** button next to the free-type input. Choose the category and subcategory, then click **Add Tag**. The tag is written directly to the corresponding JSON file on disk.
|
||||
|
||||
---
|
||||
|
||||
## 📋 Presets
|
||||
|
||||
- **💾 Save Preset** — saves the current pill set under a name (stored in `localStorage`)
|
||||
- **📋 Manage Presets** — load, rename, delete, export or import presets as JSON files
|
||||
|
||||
---
|
||||
|
||||
## 🪪 License
|
||||
|
||||
[MIT License](LICENSE) — © 2026 cowneko
|
||||
@@ -0,0 +1,5 @@
|
||||
from .nodes import NODE_CLASS_MAPPINGS, NODE_DISPLAY_NAME_MAPPINGS
|
||||
|
||||
WEB_DIRECTORY = "./web"
|
||||
|
||||
__all__ = ["NODE_CLASS_MAPPINGS", "NODE_DISPLAY_NAME_MAPPINGS", "WEB_DIRECTORY"]
|
||||
@@ -0,0 +1,80 @@
|
||||
import json
|
||||
import os
|
||||
|
||||
import server
|
||||
from aiohttp import web
|
||||
|
||||
# ── Tag directory ─────────────────────────────────────────────────────────────
|
||||
TAG_DIR = os.path.join(os.path.dirname(__file__), "web", "tags")
|
||||
|
||||
# ── Route: add a tag to a JSON file ──────────────────────────────────────────
|
||||
@server.PromptServer.instance.routes.post("/cwk/add_tag")
|
||||
async def add_tag(request):
|
||||
try:
|
||||
data = await request.json()
|
||||
panel_key = data.get("panelKey")
|
||||
category = data.get("category")
|
||||
subcategory = data.get("subcategory")
|
||||
tag = data.get("tag", "").strip()
|
||||
|
||||
if not all([panel_key, category, subcategory, tag]):
|
||||
return web.json_response({"ok": False, "error": "Missing fields"}, status=400)
|
||||
|
||||
filepath = os.path.join(TAG_DIR, f"{panel_key}.json")
|
||||
if not os.path.exists(filepath):
|
||||
return web.json_response({"ok": False, "error": f"File not found: {panel_key}.json"}, status=404)
|
||||
|
||||
with open(filepath, "r", encoding="utf-8") as f:
|
||||
tag_data = json.load(f)
|
||||
|
||||
for group in tag_data:
|
||||
if group.get("category") == category:
|
||||
for sub in group.get("subcategories", []):
|
||||
if sub.get("name") == subcategory:
|
||||
if tag in sub["tags"]:
|
||||
return web.json_response({
|
||||
"ok": False,
|
||||
"duplicate": True,
|
||||
"where": f"{category} › {subcategory}"
|
||||
})
|
||||
sub["tags"].append(tag)
|
||||
sub["tags"].sort()
|
||||
with open(filepath, "w", encoding="utf-8") as f:
|
||||
json.dump(tag_data, f, indent=2, ensure_ascii=False)
|
||||
return web.json_response({"ok": True})
|
||||
|
||||
return web.json_response(
|
||||
{"ok": False, "error": "Category or subcategory not found"}, status=404
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
return web.json_response({"ok": False, "error": str(e)}, status=500)
|
||||
|
||||
|
||||
# ── Node ──────────────────────────────────────────────────────────────────────
|
||||
class CWKPromptComposerNode:
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"quality_prompt": ("STRING", {"default": "", "multiline": True}),
|
||||
"main_prompt": ("STRING", {"default": "", "multiline": True}),
|
||||
"aesthetic_prompt": ("STRING", {"default": "", "multiline": True}),
|
||||
"negative_prompt": ("STRING", {"default": "", "multiline": True}),
|
||||
}
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("STRING", "STRING")
|
||||
RETURN_NAMES = ("positive_prompt", "negative_prompt")
|
||||
FUNCTION = "compose"
|
||||
CATEGORY = "CWK"
|
||||
|
||||
def compose(self, quality_prompt, main_prompt, aesthetic_prompt, negative_prompt):
|
||||
positive = ", ".join(p for p in [quality_prompt, main_prompt, aesthetic_prompt] if p.strip())
|
||||
negative = negative_prompt.strip()
|
||||
return (positive, negative)
|
||||
|
||||
|
||||
# ── Mappings ──────────────────────────────────────────────────────────────────
|
||||
NODE_CLASS_MAPPINGS = {"CWKPromptComposerNode": CWKPromptComposerNode}
|
||||
NODE_DISPLAY_NAME_MAPPINGS = {"CWKPromptComposerNode": "CWK Prompt Composer"}
|
||||
+174
@@ -0,0 +1,174 @@
|
||||
import { app } from "../../scripts/app.js";
|
||||
import { TAG_DATA, LUCKY_DATA, panelDialog } from "./panel_dialog.js";
|
||||
|
||||
// ── Load tag + lucky data ────────────────────────────────────────────────────
|
||||
async function loadTagData() {
|
||||
const keys = ["quality", "aesthetic", "main", "negative"];
|
||||
await Promise.all(keys.map(async (key) => {
|
||||
try {
|
||||
const res = await fetch(`/extensions/CWK_Prompt_Composer/tags/${key}.json`);
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
TAG_DATA[key] = await res.json();
|
||||
} catch (e) {
|
||||
console.error(`[CWK] Failed to load tags/${key}.json:`, e);
|
||||
TAG_DATA[key] = [];
|
||||
}
|
||||
}));
|
||||
try {
|
||||
const res = await fetch(`/extensions/CWK_Prompt_Composer/tags/lucky.json`);
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
// LUCKY_DATA is exported as `let` so we mutate its keys
|
||||
const data = await res.json();
|
||||
Object.assign(LUCKY_DATA, data);
|
||||
} catch (e) {
|
||||
console.error(`[CWK] Failed to load tags/lucky.json:`, e);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Node State ───────────────────────────────────────────────────────────────
|
||||
const nodeState = {};
|
||||
function getState(nodeId) {
|
||||
if (!nodeState[nodeId]) nodeState[nodeId] = { quality: "", main: "", aesthetic: "", negative: "" };
|
||||
return nodeState[nodeId];
|
||||
}
|
||||
|
||||
const BUTTONS = [
|
||||
{ key: "quality", label: "⭐ Quality Prompt" },
|
||||
{ key: "main", label: "🖼️ Main Prompt" },
|
||||
{ key: "aesthetic", label: "🎨 Aesthetic Prompt" },
|
||||
{ key: "negative", label: "❌ Negative Prompt" },
|
||||
];
|
||||
|
||||
const BTN_H = 28, BTN_GAP = 6, BTN_PAD = 10;
|
||||
|
||||
function getTopOffset(node) {
|
||||
const slotH = LiteGraph.NODE_SLOT_HEIGHT ?? 20;
|
||||
const numSlots = Math.max(node.outputs?.length ?? 0, node.inputs?.length ?? 0);
|
||||
return numSlots > 0 ? Math.ceil((numSlots - 1 + 0.7) * slotH) + 18 : 8;
|
||||
}
|
||||
|
||||
function getButtonRects(node) {
|
||||
const w = node.size[0], top = getTopOffset(node);
|
||||
return BUTTONS.map((_, i) => ({ x: BTN_PAD, y: top + i * (BTN_H + BTN_GAP), w: w - BTN_PAD * 2, h: BTN_H }));
|
||||
}
|
||||
|
||||
function syncWidgets(node) {
|
||||
const s = getState(node.id);
|
||||
const map = { quality_prompt: s.quality, main_prompt: s.main, aesthetic_prompt: s.aesthetic, negative_prompt: s.negative };
|
||||
for (const w of (node.widgets ?? [])) { if (w.name in map) w.value = map[w.name]; }
|
||||
}
|
||||
|
||||
function getPositive(s) { return [s.quality, s.main, s.aesthetic].filter(Boolean).join(", "); }
|
||||
|
||||
async function openPanel(node, key) {
|
||||
const s = getState(node.id);
|
||||
const btn = BUTTONS.find(b => b.key === key);
|
||||
const result = await panelDialog.show({ title: btn.label, panelKey: key, defaultValue: s[key] ?? "" });
|
||||
if (result !== null) { s[key] = result; syncWidgets(node); app.graph.setDirtyCanvas(true, true); }
|
||||
}
|
||||
|
||||
// ── Draw ─────────────────────────────────────────────────────────────────────
|
||||
function drawNode(node, ctx) {
|
||||
const s = getState(node.id), rects = getButtonRects(node), w = node.size[0], top = getTopOffset(node);
|
||||
ctx.save();
|
||||
ctx.strokeStyle = "#313244"; ctx.lineWidth = 1;
|
||||
ctx.beginPath(); ctx.moveTo(BTN_PAD, top - 8); ctx.lineTo(w - BTN_PAD, top - 8); ctx.stroke();
|
||||
|
||||
for (let i = 0; i < BUTTONS.length; i++) {
|
||||
const { key, label } = BUTTONS[i], r = rects[i], hasValue = !!s[key];
|
||||
const grad = ctx.createLinearGradient(r.x, r.y, r.x, r.y + r.h);
|
||||
grad.addColorStop(0, hasValue ? "#2a3a2a" : "#313244");
|
||||
grad.addColorStop(1, hasValue ? "#1a2a1a" : "#252535");
|
||||
ctx.fillStyle = grad; ctx.strokeStyle = hasValue ? "#a6e3a1" : "#45475a"; ctx.lineWidth = 1;
|
||||
ctx.beginPath(); ctx.roundRect(r.x, r.y, r.w, r.h, 6); ctx.fill(); ctx.stroke();
|
||||
ctx.fillStyle = hasValue ? "#a6e3a1" : "#cdd6f4";
|
||||
ctx.font = "bold 13px sans-serif"; ctx.textAlign = "center"; ctx.textBaseline = "middle";
|
||||
ctx.fillText(label, r.x + r.w / 2, r.y + r.h / 2, r.w - 10);
|
||||
}
|
||||
|
||||
const lastRect = rects[rects.length - 1];
|
||||
const previewY = lastRect.y + lastRect.h + BTN_GAP * 2;
|
||||
const previewH = node.size[1] - previewY - 8;
|
||||
if (previewH < 30) { ctx.restore(); return; }
|
||||
const halfH = Math.floor((previewH - BTN_GAP) / 2);
|
||||
drawPreviewBox(ctx, { x: BTN_PAD, y: previewY, w: w - BTN_PAD * 2, h: halfH, label: "✅ POSITIVE", text: getPositive(s) || "(empty)", color: "#a6e3a1" });
|
||||
drawPreviewBox(ctx, { x: BTN_PAD, y: previewY + halfH + BTN_GAP, w: w - BTN_PAD * 2, h: halfH, label: "❌ NEGATIVE", text: s.negative || "(empty)", color: "#f38ba8" });
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
function drawPreviewBox(ctx, { x, y, w, h, label, text, color }) {
|
||||
ctx.fillStyle = "#11111b"; ctx.strokeStyle = "#313244"; ctx.lineWidth = 1;
|
||||
ctx.beginPath(); ctx.roundRect(x, y, w, h, 5); ctx.fill(); ctx.stroke();
|
||||
ctx.fillStyle = "#6c7086"; ctx.font = "bold 9px sans-serif"; ctx.textAlign = "left"; ctx.textBaseline = "top";
|
||||
ctx.fillText(label, x + 6, y + 5);
|
||||
ctx.save();
|
||||
ctx.beginPath(); ctx.roundRect(x + 1, y + 1, w - 2, h - 2, 5); ctx.clip();
|
||||
ctx.fillStyle = color; ctx.font = "11px sans-serif"; ctx.textBaseline = "top";
|
||||
const lineH = 15, maxW = w - 12, startY = y + 18, maxLines = Math.max(1, Math.floor((h - 22) / lineH));
|
||||
const words = text.split(/,\s*/);
|
||||
let line = "", lineN = 0;
|
||||
for (const word of words) {
|
||||
const test = line ? line + ", " + word : word;
|
||||
if (ctx.measureText(test).width > maxW && line) {
|
||||
if (lineN < maxLines) ctx.fillText(line + (lineN === maxLines - 1 ? "…" : ","), x + 6, startY + lineN * lineH);
|
||||
line = word; lineN++;
|
||||
} else line = test;
|
||||
}
|
||||
if (line && lineN < maxLines) ctx.fillText(line, x + 6, startY + lineN * lineH);
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
// ── Extension ────────────────────────────────────────────────────────────────
|
||||
app.registerExtension({
|
||||
name: "CWK.PromptComposer",
|
||||
|
||||
async setup() {
|
||||
await loadTagData();
|
||||
},
|
||||
|
||||
async beforeRegisterNodeDef(nodeType, nodeData) {
|
||||
if (nodeData.name !== "CWKPromptComposerNode") return;
|
||||
|
||||
const onNodeCreated = nodeType.prototype.onNodeCreated;
|
||||
nodeType.prototype.onNodeCreated = function () {
|
||||
onNodeCreated?.apply?.(this, arguments);
|
||||
this.size = [340, 420];
|
||||
getState(this.id);
|
||||
const HIDDEN = ["quality_prompt", "main_prompt", "aesthetic_prompt", "negative_prompt"];
|
||||
for (const w of (this.widgets ?? [])) {
|
||||
if (HIDDEN.includes(w.name)) {
|
||||
w.type = "converted-widget";
|
||||
w.hidden = true;
|
||||
w.computeSize = () => [0, -4];
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const onDrawForeground = nodeType.prototype.onDrawForeground;
|
||||
nodeType.prototype.onDrawForeground = function (ctx) {
|
||||
onDrawForeground?.apply?.(this, arguments);
|
||||
if (this.flags?.collapsed) return;
|
||||
drawNode(this, ctx);
|
||||
};
|
||||
|
||||
const onMouseDown = nodeType.prototype.onMouseDown;
|
||||
nodeType.prototype.onMouseDown = function (e, localPos) {
|
||||
if (onMouseDown?.apply?.(this, arguments)) return true;
|
||||
if (this.flags?.collapsed) return false;
|
||||
const [mx, my] = localPos, rects = getButtonRects(this);
|
||||
for (let i = 0; i < BUTTONS.length; i++) {
|
||||
const r = rects[i];
|
||||
if (mx >= r.x && mx <= r.x + r.w && my >= r.y && my <= r.y + r.h) {
|
||||
openPanel(this, BUTTONS[i].key);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
nodeType.prototype.onSerialize = function (o) { o.cwk_state = getState(this.id); };
|
||||
nodeType.prototype.onConfigure = function (o) {
|
||||
if (o.cwk_state) { nodeState[this.id] = { ...o.cwk_state }; syncWidgets(this); }
|
||||
};
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,225 @@
|
||||
import { PillCanvas } from "./pill_canvas.js";
|
||||
import { buildTagBrowser } from "./tag_browser.js";
|
||||
import { presetManager, loadPresets, savePresets, makeWindow } from "./preset_manager.js";
|
||||
import { tagEditor } from "./tag_editor.js";
|
||||
|
||||
// ── Tag data store (filled by index.js) ─────────────────────────────────────
|
||||
export const TAG_DATA = { quality: [], aesthetic: [], main: [], negative: [] };
|
||||
export let LUCKY_DATA = {};
|
||||
|
||||
// ── NSFW toggle ──────────────────────────────────────────────────────────────
|
||||
const NSFW_KEY = "cwk_nsfw_enabled";
|
||||
function getNsfw() { return localStorage.getItem(NSFW_KEY) === "true"; }
|
||||
function setNsfw(val) { localStorage.setItem(NSFW_KEY, val ? "true" : "false"); }
|
||||
|
||||
// ── Lucky prompt generator ───────────────────────────────────────────────────
|
||||
function pickRandom(arr) { return arr[Math.floor(Math.random() * arr.length)]; }
|
||||
|
||||
function generateLuckyPrompt(nsfw) {
|
||||
const d = LUCKY_DATA;
|
||||
const pick = (sfwKey, nsfwKey) => {
|
||||
const pool = (nsfw && nsfwKey && d[nsfwKey]?.length)
|
||||
? [...(d[sfwKey] || []), ...(d[nsfwKey] || [])]
|
||||
: (d[sfwKey] || []);
|
||||
return pool.length ? pickRandom(pool) : null;
|
||||
};
|
||||
return [
|
||||
pick("subject", "nsfw_subject"),
|
||||
pick("clothing", "nsfw_clothing"),
|
||||
pick("expression", null),
|
||||
pick("action", "nsfw_action"),
|
||||
pick("environment", null),
|
||||
].filter(Boolean).map(p => ({ id: Math.random().toString(36).slice(2), text: p, category: "custom", weight: 1.0 }));
|
||||
}
|
||||
|
||||
// ── Panel Dialog ─────────────────────────────────────────────────────────────
|
||||
export class PanelDialog {
|
||||
constructor() {
|
||||
this._resolve = null;
|
||||
this._canvas = null;
|
||||
this._panelKey = null;
|
||||
|
||||
const { win, backdrop, body, closeBtn, titleLabel } = makeWindow({
|
||||
title: "",
|
||||
width: "700px",
|
||||
height: "600px",
|
||||
minWidth: "480px",
|
||||
minHeight: "400px",
|
||||
zIndex: "9999",
|
||||
});
|
||||
this._win = win;
|
||||
this._backdrop = backdrop;
|
||||
this._body = body;
|
||||
this._titleLabel = titleLabel;
|
||||
closeBtn.addEventListener("click", () => this._cancel());
|
||||
}
|
||||
|
||||
show({ title, panelKey, defaultValue = "" }) {
|
||||
this._panelKey = panelKey;
|
||||
this._canvas = new PillCanvas(() => {});
|
||||
this._canvas.setValue(defaultValue);
|
||||
this._titleLabel.textContent = title;
|
||||
this._body.innerHTML = "";
|
||||
|
||||
// wire up "Add to Tag List" callback
|
||||
this._canvas.onAddToList = (tag) => {
|
||||
tagEditor.show(tag, TAG_DATA[panelKey], panelKey);
|
||||
};
|
||||
|
||||
const mkBtn = (text, bg, onClick) => {
|
||||
const btn = document.createElement("button");
|
||||
btn.textContent = text;
|
||||
Object.assign(btn.style, { padding: "5px 12px", background: bg, color: "#cdd6f4", border: "none", borderRadius: "6px", cursor: "pointer", fontSize: "12px", whiteSpace: "nowrap" });
|
||||
btn.addEventListener("click", onClick);
|
||||
return btn;
|
||||
};
|
||||
|
||||
// ── Free-type input ────────────────────────────────────────────────
|
||||
const freeInput = document.createElement("input");
|
||||
freeInput.type = "text";
|
||||
freeInput.placeholder = "✏️ Type a custom tag and press Enter…";
|
||||
Object.assign(freeInput.style, { flex: "1", padding: "5px 10px", background: "#1e1e2e", color: "#cdd6f4", border: "1px solid #45475a", borderRadius: "6px", fontSize: "12px", minWidth: "0" });
|
||||
freeInput.addEventListener("keydown", (e) => {
|
||||
if (e.key !== "Enter") return;
|
||||
e.stopPropagation();
|
||||
const val = freeInput.value.trim();
|
||||
if (val) { this._canvas.addTag(val, "custom"); freeInput.value = ""; }
|
||||
});
|
||||
|
||||
// 📌 add free-typed tag to list
|
||||
const pinBtn = mkBtn("📌", "#2a2a3b", () => {
|
||||
const val = freeInput.value.trim();
|
||||
if (!val) return;
|
||||
tagEditor.show(val, TAG_DATA[panelKey], panelKey);
|
||||
});
|
||||
pinBtn.title = "Add this tag to the JSON list";
|
||||
|
||||
const undoBtn = mkBtn("↩ Undo", "#2a2a3b", () => this._canvas.undo());
|
||||
const redoBtn = mkBtn("↪ Redo", "#2a2a3b", () => this._canvas.redo());
|
||||
|
||||
const freeRow = document.createElement("div");
|
||||
Object.assign(freeRow.style, { display: "flex", gap: "6px", alignItems: "center", flexShrink: "0" });
|
||||
freeRow.append(freeInput, pinBtn, undoBtn, redoBtn);
|
||||
|
||||
// ── Lucky row (main panel only) ────────────────────────────────────
|
||||
const extraRows = [];
|
||||
if (panelKey === "main") {
|
||||
let nsfwOn = getNsfw();
|
||||
const nsfwToggle = document.createElement("button");
|
||||
const updateNsfwBtn = () => {
|
||||
nsfwToggle.textContent = nsfwOn ? "🔞 NSFW: ON" : "🔒 NSFW: OFF";
|
||||
Object.assign(nsfwToggle.style, {
|
||||
padding: "5px 12px", borderRadius: "6px", cursor: "pointer",
|
||||
fontSize: "12px", fontWeight: "bold", whiteSpace: "nowrap", border: "1px solid",
|
||||
background: nsfwOn ? "#3b1a2a" : "#2a2a3b",
|
||||
color: nsfwOn ? "#f38ba8" : "#6c7086",
|
||||
borderColor: nsfwOn ? "#f38ba8" : "#45475a",
|
||||
transition: "all 0.15s",
|
||||
});
|
||||
};
|
||||
updateNsfwBtn();
|
||||
nsfwToggle.addEventListener("click", () => { nsfwOn = !nsfwOn; setNsfw(nsfwOn); updateNsfwBtn(); });
|
||||
|
||||
const luckyBtn = document.createElement("button");
|
||||
luckyBtn.textContent = "🎲 I Feel Lucky";
|
||||
Object.assign(luckyBtn.style, { padding: "5px 14px", background: "#2a1f3b", color: "#cba6f7", border: "1px solid #cba6f7", borderRadius: "6px", cursor: "pointer", fontSize: "12px", fontWeight: "bold", whiteSpace: "nowrap" });
|
||||
luckyBtn.addEventListener("click", () => this._canvas.setLucky(generateLuckyPrompt(getNsfw())));
|
||||
|
||||
const luckyRow = document.createElement("div");
|
||||
Object.assign(luckyRow.style, { display: "flex", gap: "8px", alignItems: "center", flexShrink: "0" });
|
||||
luckyRow.append(luckyBtn, nsfwToggle);
|
||||
extraRows.push(luckyRow);
|
||||
}
|
||||
|
||||
// ── Toolbar ────────────────────────────────────────────────────────
|
||||
const toolbar = document.createElement("div");
|
||||
Object.assign(toolbar.style, { display: "flex", gap: "6px", flexWrap: "wrap", alignItems: "center", flexShrink: "0" });
|
||||
toolbar.append(
|
||||
mkBtn("🗑 Clear", "#3b1f1f", () => this._canvas.clear()),
|
||||
mkBtn("🔗 Join", "#1f2d3b", () => this._canvas.joinSelected()),
|
||||
mkBtn("✂ Split", "#1f2d3b", () => this._canvas.splitSelected()),
|
||||
mkBtn("💾 Save Preset", "#1f3b2a", () => this._savePreset()),
|
||||
mkBtn("📋 Manage Presets", "#2a1f3b", () => presetManager.show(this._panelKey, this._canvas)),
|
||||
);
|
||||
|
||||
// ── Browser ────────────────────────────────────────────────────────
|
||||
const browser = buildTagBrowser(TAG_DATA[panelKey], panelKey, (tag, category) => this._canvas.addTag(tag, category));
|
||||
const browserScroll = document.createElement("div");
|
||||
Object.assign(browserScroll.style, { overflowY: "auto", flex: "1", minHeight: "0" });
|
||||
browserScroll.appendChild(browser);
|
||||
|
||||
// ── Bottom bar ─────────────────────────────────────────────────────
|
||||
const cancelBtn = document.createElement("button");
|
||||
cancelBtn.textContent = "Cancel";
|
||||
Object.assign(cancelBtn.style, { padding: "8px 20px", background: "#313244", color: "#cdd6f4", border: "none", borderRadius: "6px", cursor: "pointer", fontSize: "14px" });
|
||||
cancelBtn.addEventListener("click", () => this._cancel());
|
||||
|
||||
const confirmBtn = document.createElement("button");
|
||||
confirmBtn.textContent = "✅ Confirm";
|
||||
Object.assign(confirmBtn.style, { padding: "8px 24px", background: "#89b4fa", color: "#1e1e2e", border: "none", borderRadius: "6px", cursor: "pointer", fontWeight: "bold", fontSize: "14px" });
|
||||
confirmBtn.addEventListener("click", () => this._confirm());
|
||||
|
||||
const bottomBar = document.createElement("div");
|
||||
Object.assign(bottomBar.style, { display: "flex", justifyContent: "flex-end", gap: "8px", flexShrink: "0" });
|
||||
bottomBar.append(cancelBtn, confirmBtn);
|
||||
|
||||
const hr = document.createElement("hr");
|
||||
Object.assign(hr.style, { border: "none", borderTop: "1px solid #313244", margin: "0", flexShrink: "0" });
|
||||
|
||||
this._body.append(
|
||||
this._canvas.el,
|
||||
freeRow,
|
||||
...extraRows,
|
||||
toolbar,
|
||||
hr,
|
||||
browserScroll,
|
||||
bottomBar,
|
||||
);
|
||||
|
||||
// ── Keyboard shortcuts ─────────────────────────────────────────────
|
||||
this._keyHandler = (e) => {
|
||||
if (e.key === "Escape") { this._cancel(); return; }
|
||||
if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) { this._confirm(); return; }
|
||||
if (e.key === "z" && (e.ctrlKey || e.metaKey) && !e.shiftKey) { e.preventDefault(); this._canvas.undo(); return; }
|
||||
if ((e.key === "y" && (e.ctrlKey || e.metaKey)) ||
|
||||
(e.key === "z" && (e.ctrlKey || e.metaKey) && e.shiftKey)) { e.preventDefault(); this._canvas.redo(); return; }
|
||||
};
|
||||
document.addEventListener("keydown", this._keyHandler);
|
||||
|
||||
this._backdrop.style.display = "block";
|
||||
this._win.style.display = "flex";
|
||||
this._win.style.left = "50%";
|
||||
this._win.style.top = "60px";
|
||||
this._win.style.transform = "translateX(-50%)";
|
||||
|
||||
return new Promise((resolve) => { this._resolve = resolve; });
|
||||
}
|
||||
|
||||
_confirm() {
|
||||
document.removeEventListener("keydown", this._keyHandler);
|
||||
const value = this._canvas?.getValue() ?? "";
|
||||
this._backdrop.style.display = "none";
|
||||
this._win.style.display = "none";
|
||||
this._resolve?.(value);
|
||||
this._resolve = null;
|
||||
}
|
||||
|
||||
_cancel() {
|
||||
document.removeEventListener("keydown", this._keyHandler);
|
||||
this._backdrop.style.display = "none";
|
||||
this._win.style.display = "none";
|
||||
this._resolve?.(null);
|
||||
this._resolve = null;
|
||||
}
|
||||
|
||||
_savePreset() {
|
||||
const name = window.prompt("Preset name:");
|
||||
if (!name?.trim()) return;
|
||||
const presets = loadPresets(this._panelKey);
|
||||
presets[name.trim()] = this._canvas.getValue();
|
||||
savePresets(this._panelKey, presets);
|
||||
alert(`✅ Preset "${name.trim()}" saved!`);
|
||||
}
|
||||
}
|
||||
|
||||
export const panelDialog = new PanelDialog();
|
||||
@@ -0,0 +1,375 @@
|
||||
// ── Category → color map ─────────────────────────────────────────────────────
|
||||
export const CATEGORY_COLORS = {
|
||||
quality: "#f9e2af",
|
||||
aesthetic: "#cba6f7",
|
||||
main: "#89dceb",
|
||||
negative: "#fab387",
|
||||
custom: "#cdd6f4",
|
||||
};
|
||||
|
||||
export function categoryColor(category) {
|
||||
if (!category) return CATEGORY_COLORS.custom;
|
||||
const key = category.toLowerCase();
|
||||
for (const [k, v] of Object.entries(CATEGORY_COLORS)) {
|
||||
if (key.includes(k)) return v;
|
||||
}
|
||||
return CATEGORY_COLORS.custom;
|
||||
}
|
||||
|
||||
// ── Pill Canvas ──────────────────────────────────────────────────────────────
|
||||
export class PillCanvas {
|
||||
constructor(onChange) {
|
||||
this.pills = [];
|
||||
this.selected = new Set();
|
||||
this.onChange = onChange;
|
||||
this._dragIdx = null;
|
||||
this._dragOver = null;
|
||||
this._weightPopover = null;
|
||||
this._ctxMenu = null;
|
||||
this._undoStack = [];
|
||||
this._redoStack = [];
|
||||
|
||||
// callbacks set by PanelDialog
|
||||
this.onAddToList = null; // (tag) => void
|
||||
|
||||
this.el = document.createElement("div");
|
||||
Object.assign(this.el.style, {
|
||||
minHeight: "80px",
|
||||
maxHeight: "160px",
|
||||
overflowY: "auto",
|
||||
background: "#11111b",
|
||||
border: "1px solid #313244",
|
||||
borderRadius: "8px",
|
||||
padding: "8px",
|
||||
display: "flex",
|
||||
flexWrap: "wrap",
|
||||
gap: "6px",
|
||||
alignContent: "flex-start",
|
||||
flexShrink: "0",
|
||||
});
|
||||
|
||||
document.addEventListener("mousedown", (e) => {
|
||||
if (this._weightPopover && !this._weightPopover.contains(e.target)) this._closePopover();
|
||||
if (this._ctxMenu && !this._ctxMenu.contains(e.target)) this._closeCtxMenu();
|
||||
});
|
||||
}
|
||||
|
||||
// ── Snapshot helpers ───────────────────────────────────────────────────
|
||||
_snapshot() {
|
||||
this._undoStack.push(JSON.stringify(this.pills));
|
||||
if (this._undoStack.length > 100) this._undoStack.shift();
|
||||
this._redoStack = [];
|
||||
}
|
||||
|
||||
undo() {
|
||||
if (!this._undoStack.length) return;
|
||||
this._redoStack.push(JSON.stringify(this.pills));
|
||||
this.pills = JSON.parse(this._undoStack.pop());
|
||||
this.selected.clear(); this._closePopover(); this._closeCtxMenu();
|
||||
this._render(); this.onChange(this.getValue());
|
||||
}
|
||||
|
||||
redo() {
|
||||
if (!this._redoStack.length) return;
|
||||
this._undoStack.push(JSON.stringify(this.pills));
|
||||
this.pills = JSON.parse(this._redoStack.pop());
|
||||
this.selected.clear(); this._closePopover(); this._closeCtxMenu();
|
||||
this._render(); this.onChange(this.getValue());
|
||||
}
|
||||
|
||||
// ── Mutations ──────────────────────────────────────────────────────────
|
||||
_nextId() { return Math.random().toString(36).slice(2); }
|
||||
|
||||
addTag(text, category = "custom") {
|
||||
this._snapshot();
|
||||
this.pills.push({ id: this._nextId(), text, category, weight: 1.0 });
|
||||
this._render(); this.onChange(this.getValue());
|
||||
}
|
||||
|
||||
removeId(id) {
|
||||
this._snapshot();
|
||||
this.pills = this.pills.filter(p => p.id !== id);
|
||||
this.selected.delete(id);
|
||||
this._render(); this.onChange(this.getValue());
|
||||
}
|
||||
|
||||
clear() {
|
||||
this._snapshot();
|
||||
this.pills = []; this.selected.clear();
|
||||
this._closePopover(); this._closeCtxMenu();
|
||||
this._render(); this.onChange(this.getValue());
|
||||
}
|
||||
|
||||
toggleSelect(id) {
|
||||
if (this.selected.has(id)) this.selected.delete(id);
|
||||
else this.selected.add(id);
|
||||
this._render();
|
||||
}
|
||||
|
||||
joinSelected() {
|
||||
const ids = [...this.selected];
|
||||
if (ids.length < 2) return;
|
||||
this._snapshot();
|
||||
const indices = ids.map(id => this.pills.findIndex(p => p.id === id)).sort((a, b) => a - b);
|
||||
const texts = indices.map(i => this.pills[i].text);
|
||||
const joined = { id: this._nextId(), text: texts.join("_"), category: this.pills[indices[0]].category, weight: 1.0 };
|
||||
this.pills[indices[0]] = joined;
|
||||
for (let i = indices.length - 1; i >= 1; i--) this.pills.splice(indices[i], 1);
|
||||
this.selected.clear(); this._render(); this.onChange(this.getValue());
|
||||
}
|
||||
|
||||
splitSelected() {
|
||||
if (this.selected.size !== 1) return;
|
||||
const id = [...this.selected][0];
|
||||
const idx = this.pills.findIndex(p => p.id === id);
|
||||
if (idx === -1) return;
|
||||
const parts = this.pills[idx].text.split("_").filter(Boolean);
|
||||
if (parts.length < 2) return;
|
||||
this._snapshot();
|
||||
const cat = this.pills[idx].category;
|
||||
this.pills.splice(idx, 1, ...parts.map(t => ({ id: this._nextId(), text: t, category: cat, weight: 1.0 })));
|
||||
this.selected.clear(); this._render(); this.onChange(this.getValue());
|
||||
}
|
||||
|
||||
setLucky(pills) {
|
||||
this._snapshot();
|
||||
this.pills = pills; this.selected.clear();
|
||||
this._closePopover(); this._closeCtxMenu();
|
||||
this._render(); this.onChange(this.getValue());
|
||||
}
|
||||
|
||||
getValue() {
|
||||
return this.pills.map(p => {
|
||||
const w = Math.round(p.weight * 10) / 10;
|
||||
return w !== 1.0 ? `(${p.text}:${w.toFixed(1)})` : p.text;
|
||||
}).join(", ");
|
||||
}
|
||||
|
||||
setValue(str) {
|
||||
this._undoStack = []; this._redoStack = [];
|
||||
this.pills = str
|
||||
? str.split(/,\s*/).filter(Boolean).map(token => {
|
||||
const weighted = token.match(/^\((.+):(\d+(?:\.\d+)?)\)$/);
|
||||
if (weighted) return { id: this._nextId(), text: weighted[1], category: "custom", weight: parseFloat(weighted[2]) };
|
||||
return { id: this._nextId(), text: token.trim(), category: "custom", weight: 1.0 };
|
||||
})
|
||||
: [];
|
||||
this.selected.clear(); this._closePopover(); this._closeCtxMenu(); this._render();
|
||||
}
|
||||
|
||||
// ── Context menu ───────────────────────────────────────────────────────
|
||||
_closeCtxMenu() {
|
||||
if (this._ctxMenu) { this._ctxMenu.remove(); this._ctxMenu = null; }
|
||||
}
|
||||
|
||||
_openCtxMenu(pill, pillEl, e) {
|
||||
this._closeCtxMenu();
|
||||
this._closePopover();
|
||||
|
||||
const menu = document.createElement("div");
|
||||
this._ctxMenu = menu;
|
||||
Object.assign(menu.style, {
|
||||
position: "fixed",
|
||||
zIndex: "99999",
|
||||
background: "#1e1e2e",
|
||||
border: "1px solid #45475a",
|
||||
borderRadius: "8px",
|
||||
padding: "4px",
|
||||
boxShadow: "0 4px 20px rgba(0,0,0,0.6)",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: "2px",
|
||||
minWidth: "180px",
|
||||
});
|
||||
|
||||
const mkItem = (icon, label, onClick) => {
|
||||
const item = document.createElement("div");
|
||||
Object.assign(item.style, {
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "8px",
|
||||
padding: "7px 10px",
|
||||
borderRadius: "5px",
|
||||
cursor: "pointer",
|
||||
fontSize: "12px",
|
||||
color: "#cdd6f4",
|
||||
userSelect: "none",
|
||||
});
|
||||
const iconEl = document.createElement("span");
|
||||
iconEl.textContent = icon;
|
||||
const labelEl = document.createElement("span");
|
||||
labelEl.textContent = label;
|
||||
item.append(iconEl, labelEl);
|
||||
item.addEventListener("mouseenter", () => item.style.background = "#313244");
|
||||
item.addEventListener("mouseleave", () => item.style.background = "transparent");
|
||||
item.addEventListener("click", () => { this._closeCtxMenu(); onClick(); });
|
||||
return item;
|
||||
};
|
||||
|
||||
// divider
|
||||
const divider = document.createElement("hr");
|
||||
Object.assign(divider.style, { border: "none", borderTop: "1px solid #313244", margin: "2px 0" });
|
||||
|
||||
// pill name header
|
||||
const header = document.createElement("div");
|
||||
header.textContent = pill.text;
|
||||
Object.assign(header.style, {
|
||||
padding: "5px 10px 3px",
|
||||
fontSize: "11px",
|
||||
color: "#6c7086",
|
||||
fontStyle: "italic",
|
||||
userSelect: "none",
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
});
|
||||
|
||||
menu.appendChild(header);
|
||||
menu.appendChild(divider.cloneNode());
|
||||
|
||||
menu.appendChild(mkItem("⚖", "Set Weight", () => this._openWeightPopover(pill, pillEl)));
|
||||
|
||||
if (this.onAddToList) {
|
||||
menu.appendChild(mkItem("📌", "Add to Tag List", () => this.onAddToList(pill.text)));
|
||||
}
|
||||
|
||||
document.body.appendChild(menu);
|
||||
|
||||
// position near cursor
|
||||
const mw = 180;
|
||||
let left = e.clientX + 4, top = e.clientY + 4;
|
||||
if (left + mw > window.innerWidth - 8) left = e.clientX - mw - 4;
|
||||
if (top + 120 > window.innerHeight - 8) top = e.clientY - 120;
|
||||
menu.style.left = left + "px";
|
||||
menu.style.top = top + "px";
|
||||
}
|
||||
|
||||
// ── Weight popover ─────────────────────────────────────────────────────
|
||||
_closePopover() {
|
||||
if (this._weightPopover) { this._weightPopover.remove(); this._weightPopover = null; }
|
||||
}
|
||||
|
||||
_openWeightPopover(pill, pillEl) {
|
||||
this._closePopover();
|
||||
const popover = document.createElement("div");
|
||||
this._weightPopover = popover;
|
||||
Object.assign(popover.style, {
|
||||
position: "fixed", zIndex: "99999", background: "#1e1e2e",
|
||||
border: "1px solid #45475a", borderRadius: "8px", padding: "10px 14px",
|
||||
boxShadow: "0 4px 20px rgba(0,0,0,0.6)", display: "flex",
|
||||
flexDirection: "column", gap: "8px", minWidth: "200px",
|
||||
});
|
||||
|
||||
const label = document.createElement("div");
|
||||
Object.assign(label.style, { color: "#cdd6f4", fontSize: "12px", fontWeight: "bold" });
|
||||
label.textContent = `⚖ Weight: ${pill.text}`;
|
||||
|
||||
const row = document.createElement("div");
|
||||
Object.assign(row.style, { display: "flex", alignItems: "center", gap: "8px" });
|
||||
|
||||
const slider = document.createElement("input");
|
||||
slider.type = "range"; slider.min = "0.1"; slider.max = "2.0"; slider.step = "0.1";
|
||||
slider.value = pill.weight.toFixed(1);
|
||||
Object.assign(slider.style, { flex: "1", accentColor: "#89b4fa", cursor: "pointer" });
|
||||
|
||||
const valueLabel = document.createElement("span");
|
||||
Object.assign(valueLabel.style, { color: "#89b4fa", fontSize: "13px", fontWeight: "bold", minWidth: "28px", textAlign: "right" });
|
||||
valueLabel.textContent = pill.weight.toFixed(1);
|
||||
|
||||
let _snapDone = false;
|
||||
slider.addEventListener("mousedown", () => { _snapDone = false; });
|
||||
slider.addEventListener("input", () => {
|
||||
if (!_snapDone) { this._snapshot(); _snapDone = true; }
|
||||
const w = parseFloat(slider.value);
|
||||
pill.weight = w; valueLabel.textContent = w.toFixed(1);
|
||||
this._render(); this.onChange(this.getValue());
|
||||
});
|
||||
|
||||
const resetBtn = document.createElement("button");
|
||||
resetBtn.textContent = "Reset";
|
||||
Object.assign(resetBtn.style, { padding: "3px 8px", background: "#313244", color: "#cdd6f4", border: "none", borderRadius: "4px", cursor: "pointer", fontSize: "11px" });
|
||||
resetBtn.addEventListener("click", () => {
|
||||
this._snapshot();
|
||||
pill.weight = 1.0; slider.value = "1.0"; valueLabel.textContent = "1.0";
|
||||
this._render(); this.onChange(this.getValue());
|
||||
});
|
||||
|
||||
row.append(slider, valueLabel);
|
||||
popover.append(label, row, resetBtn);
|
||||
document.body.appendChild(popover);
|
||||
|
||||
const rect = pillEl.getBoundingClientRect();
|
||||
let left = rect.left, top = rect.bottom + 6;
|
||||
if (left + 200 > window.innerWidth - 10) left = window.innerWidth - 210;
|
||||
if (top + 110 > window.innerHeight - 10) top = rect.top - 116;
|
||||
popover.style.left = left + "px";
|
||||
popover.style.top = top + "px";
|
||||
}
|
||||
|
||||
// ── Render ─────────────────────────────────────────────────────────────
|
||||
_render() {
|
||||
this.el.innerHTML = "";
|
||||
if (this.pills.length === 0) {
|
||||
const placeholder = document.createElement("span");
|
||||
Object.assign(placeholder.style, { color: "#45475a", fontSize: "12px", padding: "4px" });
|
||||
placeholder.textContent = "Click tags below to add them here…";
|
||||
this.el.appendChild(placeholder);
|
||||
return;
|
||||
}
|
||||
|
||||
this.pills.forEach((pill, idx) => {
|
||||
const selected = this.selected.has(pill.id);
|
||||
const color = categoryColor(pill.category);
|
||||
const hasWeight = Math.round(pill.weight * 10) / 10 !== 1.0;
|
||||
|
||||
const pillEl = document.createElement("div");
|
||||
pillEl.draggable = true;
|
||||
Object.assign(pillEl.style, {
|
||||
display: "inline-flex", alignItems: "center", gap: "4px",
|
||||
padding: "3px 8px", borderRadius: "12px",
|
||||
background: selected ? `${color}22` : "#1e1e2e",
|
||||
border: `1px solid ${selected ? color : color + "88"}`,
|
||||
cursor: "grab", fontSize: "12px",
|
||||
color: selected ? color : color + "cc",
|
||||
userSelect: "none", transition: "all 0.1s",
|
||||
});
|
||||
|
||||
const labelEl = document.createElement("span");
|
||||
labelEl.textContent = pill.text;
|
||||
labelEl.addEventListener("click", (e) => { e.stopPropagation(); this.toggleSelect(pill.id); });
|
||||
|
||||
if (hasWeight) {
|
||||
const badge = document.createElement("span");
|
||||
badge.textContent = pill.weight.toFixed(1);
|
||||
Object.assign(badge.style, { fontSize: "9px", background: "#313244", color: "#89b4fa", borderRadius: "4px", padding: "1px 3px", fontWeight: "bold", lineHeight: "1.2" });
|
||||
pillEl.appendChild(badge);
|
||||
}
|
||||
|
||||
const xBtn = document.createElement("span");
|
||||
xBtn.textContent = "×";
|
||||
Object.assign(xBtn.style, { cursor: "pointer", color: "#f38ba8", fontWeight: "bold", fontSize: "14px", lineHeight: "1", padding: "0 2px" });
|
||||
xBtn.addEventListener("click", (e) => { e.stopPropagation(); this.removeId(pill.id); });
|
||||
|
||||
pillEl.append(labelEl, xBtn);
|
||||
|
||||
// right-click → context menu
|
||||
pillEl.addEventListener("contextmenu", (e) => {
|
||||
e.preventDefault(); e.stopPropagation();
|
||||
this._openCtxMenu(pill, pillEl, e);
|
||||
});
|
||||
|
||||
pillEl.addEventListener("dragstart", () => { this._dragIdx = idx; });
|
||||
pillEl.addEventListener("dragover", (e) => { e.preventDefault(); this._dragOver = idx; });
|
||||
pillEl.addEventListener("drop", () => {
|
||||
if (this._dragIdx === null || this._dragIdx === this._dragOver) return;
|
||||
this._snapshot();
|
||||
const moved = this.pills.splice(this._dragIdx, 1)[0];
|
||||
this.pills.splice(this._dragOver, 0, moved);
|
||||
this._dragIdx = this._dragOver = null;
|
||||
this._render(); this.onChange(this.getValue());
|
||||
});
|
||||
|
||||
this.el.appendChild(pillEl);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,236 @@
|
||||
// ── Preset Storage ───────────────────────────────────────────────────────────
|
||||
const PRESET_KEY = (k) => `cwk_presets_${k}`;
|
||||
export function loadPresets(k) { try { return JSON.parse(localStorage.getItem(PRESET_KEY(k)) || "{}"); } catch { return {}; } }
|
||||
export function savePresets(k, presets) { localStorage.setItem(PRESET_KEY(k), JSON.stringify(presets)); }
|
||||
|
||||
// ── Shared window builder helper ─────────────────────────────────────────────
|
||||
export function makeWindow({ title, width = "500px", height = "500px", minWidth = "380px", minHeight = "300px", zIndex = "10001" }) {
|
||||
const backdrop = document.createElement("div");
|
||||
Object.assign(backdrop.style, { position: "fixed", inset: "0", zIndex: String(parseInt(zIndex) - 1), display: "none" });
|
||||
|
||||
const win = document.createElement("div");
|
||||
Object.assign(win.style, {
|
||||
position: "fixed", top: "120px", left: "50%", transform: "translateX(-50%)",
|
||||
width, height, minWidth, minHeight,
|
||||
zIndex, display: "none", flexDirection: "column",
|
||||
background: "#181825", border: "1px solid #45475a", borderRadius: "10px",
|
||||
boxShadow: "0 8px 40px rgba(0,0,0,0.8)", overflow: "hidden", boxSizing: "border-box",
|
||||
});
|
||||
|
||||
// Title bar
|
||||
const titleBar = document.createElement("div");
|
||||
Object.assign(titleBar.style, {
|
||||
height: "36px", flexShrink: "0", cursor: "move", background: "#1e1e2e",
|
||||
borderBottom: "1px solid #313244", display: "flex", alignItems: "center",
|
||||
padding: "0 12px", userSelect: "none", borderRadius: "10px 10px 0 0", gap: "6px",
|
||||
});
|
||||
["#f38ba8","#f9e2af","#a6e3a1"].forEach(c => {
|
||||
const dot = document.createElement("div");
|
||||
Object.assign(dot.style, { width: "10px", height: "10px", borderRadius: "50%", background: c, flexShrink: "0" });
|
||||
titleBar.appendChild(dot);
|
||||
});
|
||||
const titleLabel = document.createElement("span");
|
||||
Object.assign(titleLabel.style, { color: "#cdd6f4", fontSize: "13px", fontWeight: "bold", marginLeft: "4px", flex: "1" });
|
||||
titleLabel.textContent = title;
|
||||
titleBar.appendChild(titleLabel);
|
||||
|
||||
const closeBtn = document.createElement("div");
|
||||
closeBtn.textContent = "✕";
|
||||
Object.assign(closeBtn.style, { cursor: "pointer", color: "#6c7086", fontSize: "16px", lineHeight: "1", padding: "2px 6px", borderRadius: "4px", userSelect: "none" });
|
||||
closeBtn.addEventListener("mouseenter", () => { closeBtn.style.color = "#cdd6f4"; closeBtn.style.background = "#f38ba820"; });
|
||||
closeBtn.addEventListener("mouseleave", () => { closeBtn.style.color = "#6c7086"; closeBtn.style.background = "transparent"; });
|
||||
titleBar.appendChild(closeBtn);
|
||||
|
||||
// Body
|
||||
const body = document.createElement("div");
|
||||
Object.assign(body.style, {
|
||||
display: "flex", flexDirection: "column", flex: "1", minHeight: "0",
|
||||
padding: "14px", gap: "10px", boxSizing: "border-box", overflowY: "auto",
|
||||
});
|
||||
|
||||
// SE resize handle
|
||||
const resizeHandle = document.createElement("div");
|
||||
Object.assign(resizeHandle.style, { position: "absolute", right: "0", bottom: "0", width: "0", height: "0", borderStyle: "solid", borderWidth: "0 0 18px 18px", borderColor: "transparent transparent #45475a transparent", cursor: "nwse-resize", zIndex: "10" });
|
||||
const resizeInner = document.createElement("div");
|
||||
Object.assign(resizeInner.style, { position: "absolute", right: "1px", bottom: "-17px", width: "0", height: "0", borderStyle: "solid", borderWidth: "0 0 12px 12px", borderColor: "transparent transparent #89b4fa transparent" });
|
||||
resizeHandle.appendChild(resizeInner);
|
||||
|
||||
win.appendChild(titleBar);
|
||||
win.appendChild(body);
|
||||
win.appendChild(resizeHandle);
|
||||
document.body.appendChild(backdrop);
|
||||
document.body.appendChild(win);
|
||||
|
||||
// Drag
|
||||
let startX, startY, startL, startT;
|
||||
const onDragMove = (e) => { win.style.left = (startL + e.clientX - startX) + "px"; win.style.top = (startT + e.clientY - startY) + "px"; win.style.transform = ""; };
|
||||
const onDragUp = () => { document.removeEventListener("mousemove", onDragMove); document.removeEventListener("mouseup", onDragUp); document.body.style.userSelect = ""; };
|
||||
titleBar.addEventListener("mousedown", (e) => {
|
||||
e.preventDefault();
|
||||
if (win.style.transform) { const r = win.getBoundingClientRect(); win.style.left = r.left + "px"; win.style.top = r.top + "px"; win.style.transform = ""; }
|
||||
startX = e.clientX; startY = e.clientY;
|
||||
startL = parseFloat(win.style.left) || 0; startT = parseFloat(win.style.top) || 0;
|
||||
document.body.style.userSelect = "none";
|
||||
document.addEventListener("mousemove", onDragMove); document.addEventListener("mouseup", onDragUp);
|
||||
});
|
||||
|
||||
// Resize
|
||||
let rStartX, rStartY, rStartW, rStartH;
|
||||
const onResizeMove = (e) => { win.style.width = Math.max(parseInt(minWidth), rStartW + e.clientX - rStartX) + "px"; win.style.height = Math.max(parseInt(minHeight), rStartH + e.clientY - rStartY) + "px"; };
|
||||
const onResizeUp = () => { document.removeEventListener("mousemove", onResizeMove); document.removeEventListener("mouseup", onResizeUp); document.body.style.userSelect = ""; };
|
||||
resizeHandle.addEventListener("mousedown", (e) => {
|
||||
e.preventDefault(); e.stopPropagation();
|
||||
const r = win.getBoundingClientRect();
|
||||
rStartX = e.clientX; rStartY = e.clientY; rStartW = r.width; rStartH = r.height;
|
||||
win.style.left = r.left + "px"; win.style.top = r.top + "px"; win.style.transform = "";
|
||||
document.body.style.userSelect = "none";
|
||||
document.addEventListener("mousemove", onResizeMove); document.addEventListener("mouseup", onResizeUp);
|
||||
});
|
||||
|
||||
return { win, backdrop, body, closeBtn, titleLabel };
|
||||
}
|
||||
|
||||
// ── Preset Manager ───────────────────────────────────────────────────────────
|
||||
export class PresetManager {
|
||||
constructor() {
|
||||
this._panelKey = null;
|
||||
this._canvas = null;
|
||||
const { win, backdrop, body, closeBtn } = makeWindow({ title: "📋 Manage Presets", zIndex: "10001" });
|
||||
this._win = win;
|
||||
this._backdrop = backdrop;
|
||||
this._body = body;
|
||||
closeBtn.addEventListener("click", () => this.hide());
|
||||
}
|
||||
|
||||
show(panelKey, canvas) {
|
||||
this._panelKey = panelKey;
|
||||
this._canvas = canvas;
|
||||
this._render();
|
||||
this._backdrop.style.display = "block";
|
||||
this._win.style.display = "flex";
|
||||
this._win.style.left = "50%";
|
||||
this._win.style.top = "120px";
|
||||
this._win.style.transform = "translateX(-50%)";
|
||||
}
|
||||
|
||||
hide() {
|
||||
this._backdrop.style.display = "none";
|
||||
this._win.style.display = "none";
|
||||
}
|
||||
|
||||
_render() {
|
||||
this._body.innerHTML = "";
|
||||
const presets = loadPresets(this._panelKey);
|
||||
const names = Object.keys(presets);
|
||||
|
||||
const topBar = document.createElement("div");
|
||||
Object.assign(topBar.style, { display: "flex", gap: "8px", flexShrink: "0", flexWrap: "wrap" });
|
||||
topBar.appendChild(this._mkBtn("📦 Export All", "#1f2d3b", () => this._exportJSON(presets, `cwk_presets_${this._panelKey}_all.json`)));
|
||||
topBar.appendChild(this._mkBtn("📂 Import from JSON", "#2a1f3b", () => this._importJSON()));
|
||||
this._body.appendChild(topBar);
|
||||
|
||||
const hr = document.createElement("hr");
|
||||
Object.assign(hr.style, { border: "none", borderTop: "1px solid #313244", margin: "0", flexShrink: "0" });
|
||||
this._body.appendChild(hr);
|
||||
|
||||
if (names.length === 0) {
|
||||
const empty = document.createElement("div");
|
||||
Object.assign(empty.style, { color: "#45475a", fontSize: "13px", textAlign: "center", padding: "20px 0" });
|
||||
empty.textContent = "No presets saved yet.";
|
||||
this._body.appendChild(empty);
|
||||
return;
|
||||
}
|
||||
|
||||
const list = document.createElement("div");
|
||||
Object.assign(list.style, { display: "flex", flexDirection: "column", gap: "8px" });
|
||||
|
||||
names.forEach(name => {
|
||||
const wrapper = document.createElement("div");
|
||||
Object.assign(wrapper.style, { background: "#1e1e2e", borderRadius: "6px", border: "1px solid #313244", overflow: "hidden" });
|
||||
|
||||
const row = document.createElement("div");
|
||||
Object.assign(row.style, { display: "flex", alignItems: "center", gap: "6px", padding: "8px 10px" });
|
||||
|
||||
let folded = true;
|
||||
const arrow = document.createElement("span");
|
||||
arrow.textContent = "▶";
|
||||
Object.assign(arrow.style, { fontSize: "10px", color: "#6c7086", cursor: "pointer", transition: "transform 0.15s", display: "inline-block", flexShrink: "0", userSelect: "none", padding: "2px 4px" });
|
||||
|
||||
const preview = document.createElement("div");
|
||||
Object.assign(preview.style, { display: "none", padding: "8px 12px", borderTop: "1px solid #313244", fontSize: "11px", color: "#a6adc8", lineHeight: "1.6", wordBreak: "break-word", whiteSpace: "pre-wrap", background: "#11111b", maxHeight: "120px", overflowY: "auto" });
|
||||
preview.textContent = presets[name] || "(empty)";
|
||||
|
||||
const toggleFold = () => { folded = !folded; preview.style.display = folded ? "none" : "block"; arrow.style.transform = folded ? "" : "rotate(90deg)"; arrow.style.color = folded ? "#6c7086" : "#89b4fa"; };
|
||||
arrow.addEventListener("click", toggleFold);
|
||||
|
||||
const nameEl = document.createElement("span");
|
||||
Object.assign(nameEl.style, { color: "#cdd6f4", fontSize: "13px", flex: "1", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap", cursor: "pointer" });
|
||||
nameEl.textContent = name;
|
||||
nameEl.addEventListener("click", toggleFold);
|
||||
|
||||
const loadBtn = this._mkBtn("Load", "#1f3b2a", () => { this._canvas.setValue(presets[name]); this.hide(); });
|
||||
const renameBtn = this._mkBtn("✏️ Rename", "#2a2a3b", () => {
|
||||
const newName = window.prompt("New name:", name);
|
||||
if (!newName?.trim() || newName.trim() === name) return;
|
||||
const fresh = loadPresets(this._panelKey);
|
||||
if (fresh[newName.trim()]) { alert("A preset with that name already exists."); return; }
|
||||
fresh[newName.trim()] = fresh[name]; delete fresh[name];
|
||||
savePresets(this._panelKey, fresh); this._render();
|
||||
});
|
||||
const exportBtn = this._mkBtn("💾 Export", "#1f2d3b", () => this._exportJSON({ [name]: presets[name] }, `cwk_preset_${name}.json`));
|
||||
const delBtn = this._mkBtn("🗑 Delete", "#3b1f1f", () => {
|
||||
if (!window.confirm(`Delete preset "${name}"?`)) return;
|
||||
const fresh = loadPresets(this._panelKey); delete fresh[name];
|
||||
savePresets(this._panelKey, fresh); this._render();
|
||||
});
|
||||
delBtn.style.color = "#f38ba8";
|
||||
|
||||
row.append(arrow, nameEl, loadBtn, renameBtn, exportBtn, delBtn);
|
||||
wrapper.appendChild(row);
|
||||
wrapper.appendChild(preview);
|
||||
list.appendChild(wrapper);
|
||||
});
|
||||
|
||||
this._body.appendChild(list);
|
||||
}
|
||||
|
||||
_mkBtn(text, bg, onClick) {
|
||||
const btn = document.createElement("button");
|
||||
btn.textContent = text;
|
||||
Object.assign(btn.style, { padding: "4px 10px", background: bg, color: "#cdd6f4", border: "none", borderRadius: "5px", cursor: "pointer", fontSize: "12px", whiteSpace: "nowrap", flexShrink: "0" });
|
||||
btn.addEventListener("click", onClick);
|
||||
return btn;
|
||||
}
|
||||
|
||||
_exportJSON(data, filename) {
|
||||
const blob = new Blob([JSON.stringify(data, null, 2)], { type: "application/json" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a"); a.href = url; a.download = filename; a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
_importJSON() {
|
||||
const input = document.createElement("input"); input.type = "file"; input.accept = ".json";
|
||||
input.addEventListener("change", async () => {
|
||||
const file = input.files[0]; if (!file) return;
|
||||
try {
|
||||
const text = await file.text();
|
||||
const imported = JSON.parse(text);
|
||||
if (typeof imported !== "object" || Array.isArray(imported)) throw new Error("Invalid format");
|
||||
const existing = loadPresets(this._panelKey);
|
||||
let imported_count = 0, skipped = 0;
|
||||
for (const [k, v] of Object.entries(imported)) {
|
||||
if (typeof v !== "string") continue;
|
||||
if (existing[k]) { skipped++; continue; }
|
||||
existing[k] = v; imported_count++;
|
||||
}
|
||||
savePresets(this._panelKey, existing);
|
||||
alert(`✅ Imported ${imported_count} preset(s).${skipped ? ` Skipped ${skipped} duplicate(s).` : ""}`);
|
||||
this._render();
|
||||
} catch (e) { alert("❌ Failed to import: " + e.message); }
|
||||
});
|
||||
input.click();
|
||||
}
|
||||
}
|
||||
|
||||
export const presetManager = new PresetManager();
|
||||
@@ -0,0 +1,261 @@
|
||||
import { categoryColor } from "./pill_canvas.js";
|
||||
|
||||
// ── Flat browser (quality / aesthetic / negative) ────────────────────────────
|
||||
function buildFlatBrowser(tagData, onTagClick) {
|
||||
const filterInput = document.createElement("input");
|
||||
filterInput.type = "text";
|
||||
filterInput.placeholder = "🔍 Filter tags…";
|
||||
Object.assign(filterInput.style, {
|
||||
width: "100%",
|
||||
padding: "6px 10px",
|
||||
background: "#1e1e2e",
|
||||
color: "#cdd6f4",
|
||||
border: "1px solid #45475a",
|
||||
borderRadius: "6px",
|
||||
fontSize: "13px",
|
||||
boxSizing: "border-box",
|
||||
marginBottom: "8px",
|
||||
});
|
||||
|
||||
const browserContent = document.createElement("div");
|
||||
Object.assign(browserContent.style, { display: "flex", flexDirection: "column", gap: "6px" });
|
||||
|
||||
function renderBrowser(filter = "") {
|
||||
browserContent.innerHTML = "";
|
||||
const q = filter.toLowerCase().trim();
|
||||
|
||||
for (const group of tagData) {
|
||||
const groupColor = categoryColor(group.category);
|
||||
for (const sub of group.subcategories) {
|
||||
const matchedTags = q ? sub.tags.filter(t => t.toLowerCase().includes(q)) : sub.tags;
|
||||
if (matchedTags.length === 0) continue;
|
||||
|
||||
let collapsed = !q;
|
||||
|
||||
const arrow = document.createElement("span");
|
||||
arrow.textContent = "▼";
|
||||
Object.assign(arrow.style, {
|
||||
fontSize: "10px", transition: "transform 0.15s",
|
||||
display: "inline-block", transform: collapsed ? "rotate(-90deg)" : "",
|
||||
color: groupColor,
|
||||
});
|
||||
|
||||
const header = document.createElement("div");
|
||||
Object.assign(header.style, {
|
||||
display: "flex", alignItems: "center", gap: "6px", cursor: "pointer",
|
||||
padding: "4px 6px", borderRadius: "4px", background: "#1e1e2e",
|
||||
fontSize: "12px", fontWeight: "bold", userSelect: "none",
|
||||
borderLeft: `3px solid ${groupColor}`,
|
||||
});
|
||||
|
||||
const catLabel = document.createElement("span");
|
||||
catLabel.textContent = `${group.category} › ${sub.name}`;
|
||||
catLabel.style.color = groupColor;
|
||||
|
||||
const countLabel = document.createElement("span");
|
||||
countLabel.textContent = `(${matchedTags.length})`;
|
||||
Object.assign(countLabel.style, { color: "#6c7086", fontWeight: "normal" });
|
||||
|
||||
header.append(arrow, catLabel, countLabel);
|
||||
|
||||
const tagsEl = document.createElement("div");
|
||||
Object.assign(tagsEl.style, {
|
||||
display: collapsed ? "none" : "flex",
|
||||
flexWrap: "wrap", gap: "4px", padding: "4px 4px 4px 16px",
|
||||
});
|
||||
|
||||
matchedTags.forEach(tag => {
|
||||
const chip = document.createElement("div");
|
||||
chip.textContent = tag;
|
||||
Object.assign(chip.style, {
|
||||
padding: "2px 8px", borderRadius: "10px", background: "#1e1e2e",
|
||||
border: `1px solid ${groupColor}88`, color: groupColor + "cc",
|
||||
fontSize: "11px", cursor: "pointer", userSelect: "none", transition: "all 0.1s",
|
||||
});
|
||||
chip.addEventListener("mouseenter", () => { chip.style.background = groupColor + "22"; chip.style.borderColor = groupColor; chip.style.color = groupColor; });
|
||||
chip.addEventListener("mouseleave", () => { chip.style.background = "#1e1e2e"; chip.style.borderColor = groupColor + "88"; chip.style.color = groupColor + "cc"; });
|
||||
chip.addEventListener("click", () => onTagClick(tag, group.category));
|
||||
tagsEl.appendChild(chip);
|
||||
});
|
||||
|
||||
header.addEventListener("click", () => {
|
||||
collapsed = !collapsed;
|
||||
tagsEl.style.display = collapsed ? "none" : "flex";
|
||||
arrow.style.transform = collapsed ? "rotate(-90deg)" : "";
|
||||
});
|
||||
|
||||
browserContent.appendChild(header);
|
||||
browserContent.appendChild(tagsEl);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
renderBrowser();
|
||||
filterInput.addEventListener("input", () => renderBrowser(filterInput.value));
|
||||
|
||||
const wrapper = document.createElement("div");
|
||||
Object.assign(wrapper.style, { display: "flex", flexDirection: "column" });
|
||||
wrapper.append(filterInput, browserContent);
|
||||
return wrapper;
|
||||
}
|
||||
|
||||
// ── 3-level nested browser (main panel) ──────────────────────────────────────
|
||||
function buildNestedBrowser(tagData, onTagClick) {
|
||||
const filterInput = document.createElement("input");
|
||||
filterInput.type = "text";
|
||||
filterInput.placeholder = "🔍 Filter tags…";
|
||||
Object.assign(filterInput.style, {
|
||||
width: "100%",
|
||||
padding: "6px 10px",
|
||||
background: "#1e1e2e",
|
||||
color: "#cdd6f4",
|
||||
border: "1px solid #45475a",
|
||||
borderRadius: "6px",
|
||||
fontSize: "13px",
|
||||
boxSizing: "border-box",
|
||||
marginBottom: "8px",
|
||||
});
|
||||
|
||||
const browserContent = document.createElement("div");
|
||||
Object.assign(browserContent.style, { display: "flex", flexDirection: "column", gap: "4px" });
|
||||
|
||||
function renderBrowser(filter = "") {
|
||||
browserContent.innerHTML = "";
|
||||
const q = filter.toLowerCase().trim();
|
||||
|
||||
for (const group of tagData) {
|
||||
const groupColor = categoryColor(group.category);
|
||||
|
||||
// ── count total matching tags across all subcategories ────────
|
||||
const totalMatch = group.subcategories.reduce((n, s) => {
|
||||
return n + (q ? s.tags.filter(t => t.toLowerCase().includes(q)).length : s.tags.length);
|
||||
}, 0);
|
||||
if (totalMatch === 0) continue;
|
||||
|
||||
// ── Level 1 : Category header ─────────────────────────────────
|
||||
let catCollapsed = !q;
|
||||
|
||||
const catArrow = document.createElement("span");
|
||||
catArrow.textContent = "▼";
|
||||
Object.assign(catArrow.style, {
|
||||
fontSize: "10px", transition: "transform 0.15s", display: "inline-block",
|
||||
transform: catCollapsed ? "rotate(-90deg)" : "", color: groupColor,
|
||||
});
|
||||
|
||||
const catLabel = document.createElement("span");
|
||||
catLabel.textContent = group.category;
|
||||
catLabel.style.color = groupColor;
|
||||
|
||||
const catCount = document.createElement("span");
|
||||
catCount.textContent = `(${totalMatch})`;
|
||||
Object.assign(catCount.style, { color: "#6c7086", fontWeight: "normal", fontSize: "11px" });
|
||||
|
||||
const catHeader = document.createElement("div");
|
||||
Object.assign(catHeader.style, {
|
||||
display: "flex", alignItems: "center", gap: "6px", cursor: "pointer",
|
||||
padding: "5px 8px", borderRadius: "6px", background: "#1e1e2e",
|
||||
fontSize: "12px", fontWeight: "bold", userSelect: "none",
|
||||
borderLeft: `3px solid ${groupColor}`,
|
||||
});
|
||||
catHeader.append(catArrow, catLabel, catCount);
|
||||
|
||||
// ─��� Level 1 body (holds all subcategories) ────────────────────
|
||||
const catBody = document.createElement("div");
|
||||
Object.assign(catBody.style, {
|
||||
display: catCollapsed ? "none" : "flex",
|
||||
flexDirection: "column",
|
||||
gap: "3px",
|
||||
paddingLeft: "12px",
|
||||
marginBottom: "2px",
|
||||
});
|
||||
|
||||
catHeader.addEventListener("click", () => {
|
||||
catCollapsed = !catCollapsed;
|
||||
catBody.style.display = catCollapsed ? "none" : "flex";
|
||||
catArrow.style.transform = catCollapsed ? "rotate(-90deg)" : "";
|
||||
});
|
||||
|
||||
// ── Level 2 : Subcategory headers ─────────────────────────────
|
||||
for (const sub of group.subcategories) {
|
||||
const matchedTags = q ? sub.tags.filter(t => t.toLowerCase().includes(q)) : sub.tags;
|
||||
if (matchedTags.length === 0) continue;
|
||||
|
||||
let subCollapsed = !q;
|
||||
|
||||
const subArrow = document.createElement("span");
|
||||
subArrow.textContent = "▼";
|
||||
Object.assign(subArrow.style, {
|
||||
fontSize: "9px", transition: "transform 0.15s", display: "inline-block",
|
||||
transform: subCollapsed ? "rotate(-90deg)" : "", color: groupColor + "aa",
|
||||
});
|
||||
|
||||
const subLabel = document.createElement("span");
|
||||
subLabel.textContent = sub.name;
|
||||
Object.assign(subLabel.style, { color: groupColor + "bb" });
|
||||
|
||||
const subCount = document.createElement("span");
|
||||
subCount.textContent = `(${matchedTags.length})`;
|
||||
Object.assign(subCount.style, { color: "#6c7086", fontWeight: "normal", fontSize: "10px" });
|
||||
|
||||
const subHeader = document.createElement("div");
|
||||
Object.assign(subHeader.style, {
|
||||
display: "flex", alignItems: "center", gap: "5px", cursor: "pointer",
|
||||
padding: "3px 6px", borderRadius: "4px", background: "#181825",
|
||||
fontSize: "11px", fontWeight: "bold", userSelect: "none",
|
||||
borderLeft: `2px solid ${groupColor}55`,
|
||||
});
|
||||
subHeader.append(subArrow, subLabel, subCount);
|
||||
|
||||
// ── Level 3 : Tags ─────────────────────────────────────────
|
||||
const tagsEl = document.createElement("div");
|
||||
Object.assign(tagsEl.style, {
|
||||
display: subCollapsed ? "none" : "flex",
|
||||
flexWrap: "wrap",
|
||||
gap: "4px",
|
||||
padding: "4px 4px 4px 12px",
|
||||
});
|
||||
|
||||
matchedTags.forEach(tag => {
|
||||
const chip = document.createElement("div");
|
||||
chip.textContent = tag;
|
||||
Object.assign(chip.style, {
|
||||
padding: "2px 8px", borderRadius: "10px", background: "#1e1e2e",
|
||||
border: `1px solid ${groupColor}66`, color: groupColor + "aa",
|
||||
fontSize: "11px", cursor: "pointer", userSelect: "none", transition: "all 0.1s",
|
||||
});
|
||||
chip.addEventListener("mouseenter", () => { chip.style.background = groupColor + "22"; chip.style.borderColor = groupColor; chip.style.color = groupColor; });
|
||||
chip.addEventListener("mouseleave", () => { chip.style.background = "#1e1e2e"; chip.style.borderColor = groupColor + "66"; chip.style.color = groupColor + "aa"; });
|
||||
chip.addEventListener("click", () => onTagClick(tag, group.category));
|
||||
tagsEl.appendChild(chip);
|
||||
});
|
||||
|
||||
subHeader.addEventListener("click", () => {
|
||||
subCollapsed = !subCollapsed;
|
||||
tagsEl.style.display = subCollapsed ? "none" : "flex";
|
||||
subArrow.style.transform = subCollapsed ? "rotate(-90deg)" : "";
|
||||
});
|
||||
|
||||
catBody.appendChild(subHeader);
|
||||
catBody.appendChild(tagsEl);
|
||||
}
|
||||
|
||||
browserContent.appendChild(catHeader);
|
||||
browserContent.appendChild(catBody);
|
||||
}
|
||||
}
|
||||
|
||||
renderBrowser();
|
||||
filterInput.addEventListener("input", () => renderBrowser(filterInput.value));
|
||||
|
||||
const wrapper = document.createElement("div");
|
||||
Object.assign(wrapper.style, { display: "flex", flexDirection: "column" });
|
||||
wrapper.append(filterInput, browserContent);
|
||||
return wrapper;
|
||||
}
|
||||
|
||||
// ── Public entry point ────���───────────────────────────────────────────────────
|
||||
export function buildTagBrowser(tagData, panelKey, onTagClick) {
|
||||
return panelKey === "main"
|
||||
? buildNestedBrowser(tagData, onTagClick)
|
||||
: buildFlatBrowser(tagData, onTagClick);
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
import { makeWindow } from "./preset_manager.js";
|
||||
|
||||
// ── Add-to-Tag-List panel ────────────────────────────────────────────────────
|
||||
class TagEditor {
|
||||
constructor() {
|
||||
const { win, backdrop, body, closeBtn } = makeWindow({
|
||||
title: "📌 Add Tag to List",
|
||||
width: "420px",
|
||||
height: "auto",
|
||||
minWidth: "340px",
|
||||
minHeight: "200px",
|
||||
zIndex: "10002",
|
||||
});
|
||||
this._win = win;
|
||||
this._backdrop = backdrop;
|
||||
this._body = body;
|
||||
|
||||
// auto height
|
||||
Object.assign(this._win.style, { height: "auto" });
|
||||
|
||||
closeBtn.addEventListener("click", () => this.hide());
|
||||
this._resolve = null;
|
||||
}
|
||||
|
||||
// tagData = TAG_DATA[panelKey], panelKey for the POST
|
||||
show(tag, tagData, panelKey) {
|
||||
this._body.innerHTML = "";
|
||||
this._panelKey = panelKey;
|
||||
|
||||
const mkLabel = (text) => {
|
||||
const l = document.createElement("label");
|
||||
l.textContent = text;
|
||||
Object.assign(l.style, { color: "#a6adc8", fontSize: "12px", fontWeight: "bold", marginBottom: "2px" });
|
||||
return l;
|
||||
};
|
||||
|
||||
const mkSelect = () => {
|
||||
const s = document.createElement("select");
|
||||
Object.assign(s.style, {
|
||||
padding: "6px 10px",
|
||||
background: "#1e1e2e",
|
||||
color: "#cdd6f4",
|
||||
border: "1px solid #45475a",
|
||||
borderRadius: "6px",
|
||||
fontSize: "13px",
|
||||
cursor: "pointer",
|
||||
width: "100%",
|
||||
});
|
||||
return s;
|
||||
};
|
||||
|
||||
// ── Tag name ───────────────────────────────────────────────────────
|
||||
const tagInput = document.createElement("input");
|
||||
tagInput.type = "text";
|
||||
tagInput.value = tag;
|
||||
Object.assign(tagInput.style, {
|
||||
padding: "6px 10px",
|
||||
background: "#1e1e2e",
|
||||
color: "#cdd6f4",
|
||||
border: "1px solid #45475a",
|
||||
borderRadius: "6px",
|
||||
fontSize: "13px",
|
||||
width: "100%",
|
||||
boxSizing: "border-box",
|
||||
});
|
||||
|
||||
// ── Category select ────────────────────────────────────────────────
|
||||
const catSelect = mkSelect();
|
||||
tagData.forEach(group => {
|
||||
const opt = document.createElement("option");
|
||||
opt.value = group.category;
|
||||
opt.textContent = group.category;
|
||||
catSelect.appendChild(opt);
|
||||
});
|
||||
|
||||
// ── Subcategory select ─────────────────────────────────────────────
|
||||
const subSelect = mkSelect();
|
||||
|
||||
const populateSubs = (categoryName) => {
|
||||
subSelect.innerHTML = "";
|
||||
const group = tagData.find(g => g.category === categoryName);
|
||||
if (!group) return;
|
||||
group.subcategories.forEach(sub => {
|
||||
const opt = document.createElement("option");
|
||||
opt.value = sub.name;
|
||||
opt.textContent = `${sub.name} (${sub.tags.length})`;
|
||||
subSelect.appendChild(opt);
|
||||
});
|
||||
};
|
||||
populateSubs(catSelect.value);
|
||||
catSelect.addEventListener("change", () => populateSubs(catSelect.value));
|
||||
|
||||
// ── Status message ─────────────────────────────────────────────────
|
||||
const status = document.createElement("div");
|
||||
Object.assign(status.style, { fontSize: "12px", minHeight: "18px", textAlign: "center" });
|
||||
|
||||
// ── Buttons ────────────────────────────────────────────────────────
|
||||
const confirmBtn = document.createElement("button");
|
||||
confirmBtn.textContent = "📌 Add Tag";
|
||||
Object.assign(confirmBtn.style, {
|
||||
padding: "8px 20px", background: "#89b4fa", color: "#1e1e2e",
|
||||
border: "none", borderRadius: "6px", cursor: "pointer",
|
||||
fontWeight: "bold", fontSize: "13px", flex: "1",
|
||||
});
|
||||
|
||||
const cancelBtn = document.createElement("button");
|
||||
cancelBtn.textContent = "Cancel";
|
||||
Object.assign(cancelBtn.style, {
|
||||
padding: "8px 20px", background: "#313244", color: "#cdd6f4",
|
||||
border: "none", borderRadius: "6px", cursor: "pointer",
|
||||
fontSize: "13px", flex: "1",
|
||||
});
|
||||
cancelBtn.addEventListener("click", () => this.hide());
|
||||
|
||||
confirmBtn.addEventListener("click", async () => {
|
||||
const tagVal = tagInput.value.trim();
|
||||
const catVal = catSelect.value;
|
||||
const subVal = subSelect.value;
|
||||
if (!tagVal) { status.textContent = "⚠️ Tag name cannot be empty."; status.style.color = "#f9e2af"; return; }
|
||||
|
||||
confirmBtn.disabled = true;
|
||||
confirmBtn.textContent = "Saving…";
|
||||
status.textContent = "";
|
||||
|
||||
try {
|
||||
const res = await fetch("/cwk/add_tag", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ panelKey: this._panelKey, category: catVal, subcategory: subVal, tag: tagVal }),
|
||||
});
|
||||
const json = await res.json();
|
||||
|
||||
if (json.ok) {
|
||||
status.style.color = "#a6e3a1";
|
||||
status.textContent = `✅ "${tagVal}" added to ${catVal} › ${subVal}`;
|
||||
confirmBtn.textContent = "📌 Add Tag";
|
||||
confirmBtn.disabled = false;
|
||||
// brief pause then close
|
||||
setTimeout(() => this.hide(), 1200);
|
||||
} else if (json.duplicate) {
|
||||
status.style.color = "#f9e2af";
|
||||
status.textContent = `⚠️ Already exists in ${json.where}`;
|
||||
confirmBtn.textContent = "📌 Add Tag";
|
||||
confirmBtn.disabled = false;
|
||||
} else {
|
||||
status.style.color = "#f38ba8";
|
||||
status.textContent = `❌ ${json.error}`;
|
||||
confirmBtn.textContent = "📌 Add Tag";
|
||||
confirmBtn.disabled = false;
|
||||
}
|
||||
} catch (e) {
|
||||
status.style.color = "#f38ba8";
|
||||
status.textContent = `❌ Network error: ${e.message}`;
|
||||
confirmBtn.textContent = "📌 Add Tag";
|
||||
confirmBtn.disabled = false;
|
||||
}
|
||||
});
|
||||
|
||||
const btnRow = document.createElement("div");
|
||||
Object.assign(btnRow.style, { display: "flex", gap: "8px" });
|
||||
btnRow.append(confirmBtn, cancelBtn);
|
||||
|
||||
// ── Layout ─────────────────────────────────────────────────────────
|
||||
const grid = document.createElement("div");
|
||||
Object.assign(grid.style, { display: "flex", flexDirection: "column", gap: "10px" });
|
||||
|
||||
const tagGroup = document.createElement("div");
|
||||
Object.assign(tagGroup.style, { display: "flex", flexDirection: "column", gap: "4px" });
|
||||
tagGroup.append(mkLabel("Tag"), tagInput);
|
||||
|
||||
const catGroup = document.createElement("div");
|
||||
Object.assign(catGroup.style, { display: "flex", flexDirection: "column", gap: "4px" });
|
||||
catGroup.append(mkLabel("Category"), catSelect);
|
||||
|
||||
const subGroup = document.createElement("div");
|
||||
Object.assign(subGroup.style, { display: "flex", flexDirection: "column", gap: "4px" });
|
||||
subGroup.append(mkLabel("Subcategory"), subSelect);
|
||||
|
||||
grid.append(tagGroup, catGroup, subGroup, status, btnRow);
|
||||
this._body.appendChild(grid);
|
||||
|
||||
this._backdrop.style.display = "block";
|
||||
this._win.style.display = "flex";
|
||||
this._win.style.left = "50%";
|
||||
this._win.style.top = "160px";
|
||||
this._win.style.transform = "translateX(-50%)";
|
||||
}
|
||||
|
||||
hide() {
|
||||
this._backdrop.style.display = "none";
|
||||
this._win.style.display = "none";
|
||||
}
|
||||
}
|
||||
|
||||
export const tagEditor = new TagEditor();
|
||||
@@ -0,0 +1,13 @@
|
||||
[
|
||||
{
|
||||
"category": "Aesthetic",
|
||||
"subcategories": [
|
||||
{ "name": "Framing", "tags": ["aerial","ass_focus","between_breasts","between_fingers","between_legs","bust","cinematic_angle","close-up","cowboy_shot","dutch_angle","eyes_focus","face_focus","fisheye","foot_focus","foreshortening","from_above","from_back","from_behind","from_below","from_outside","from_side","front_view","full-length_portrait","full_body","full_shot","half-length_portrait","head_and_shoulders_portrait","head_portrait","hip_focus","landscape","macro_shot","medium_shot","midriff_peek","mirror_selfie","multiple_views","panorama","panty_peek","pantyshot","peeking","phone_screen","portrait","profile","pussy_peek","solo_focus","three-quarter_view","upper_body","upskirt","vehicle_focus","wide_shot","x-ray"] },
|
||||
{ "name": "Art Style", "tags": ["16mm","35mm","3d_render","anime","cartoon","collage","digital_art","dreamworks","hyper_realism","illustration","imax","lomography","manga","oil_painting","panavision","photography","pixar","vector_art","watercolor"] },
|
||||
{ "name": "Era", "tags": ["ancient","ancient_china","ancient_japan","art_deco","cyberpunk","futuristic","medieval","modern_china","renaissance","retro","steampunk","vintage"] },
|
||||
{ "name": "Mood", "tags": ["dark","dreamy","ethereal","gritty","joyful","melancholic","romantic","surreal","whimsical"] },
|
||||
{ "name": "Color Palette", "tags": ["cool_colors","golden_hour","monochrome","neon_colors","pastel_colors","sepia","vibrant_colors","warm_colors"] },
|
||||
{ "name": "Lighting", "tags": ["backlighting","blue_light","cinematic_lighting","dramatic_lighting","golden_light","neon_light","rim_light","soft_lighting"] }
|
||||
]
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,48 @@
|
||||
{
|
||||
"subject": [
|
||||
"1girl", "1boy", "1woman", "1man", "young woman", "young man",
|
||||
"fantasy warrior", "cyberpunk hacker", "space explorer", "medieval knight",
|
||||
"witch", "wizard", "elf", "android", "detective", "ninja", "samurai",
|
||||
"pirate", "scientist", "artist", "musician", "dancer", "archer"
|
||||
],
|
||||
"clothing": [
|
||||
"school uniform", "armor", "casual outfit", "kimono", "sundress",
|
||||
"leather jacket", "hoodie", "elegant dress", "military uniform",
|
||||
"fantasy robe", "cyberpunk suit", "Victorian dress", "tracksuit",
|
||||
"trench coat", "crop top and jeans", "traditional hanbok", "wedding dress",
|
||||
"space suit", "cloak", "priest robes", "maid outfit", "business suit"
|
||||
],
|
||||
"expression": [
|
||||
"smiling", "serious", "surprised", "shy", "confident", "sad",
|
||||
"angry", "laughing", "blushing", "focused", "dreamy", "nervous",
|
||||
"curious", "determined", "melancholic", "joyful", "stoic", "winking"
|
||||
],
|
||||
"action": [
|
||||
"standing", "sitting", "running", "looking at viewer", "walking",
|
||||
"reading a book", "holding a sword", "casting a spell", "playing guitar",
|
||||
"leaning against a wall", "arms crossed", "reaching out", "jumping",
|
||||
"looking away", "lying down", "drinking coffee", "writing", "dancing",
|
||||
"meditating", "drawing", "looking out a window", "holding a flower"
|
||||
],
|
||||
"environment": [
|
||||
"forest", "cyberpunk city at night", "beach at sunset", "ancient library",
|
||||
"snowy mountain", "futuristic space station", "medieval castle", "coffee shop",
|
||||
"cherry blossom park", "underwater ruins", "desert oasis", "haunted mansion",
|
||||
"floating sky islands", "neon-lit alley", "cozy bedroom", "rooftop at night",
|
||||
"misty lake", "volcano crater", "crystal cave", "bamboo forest",
|
||||
"abandoned factory", "magical garden", "train station", "cathedral"
|
||||
],
|
||||
"nsfw_subject": [
|
||||
"1girl", "1boy", "1woman", "1man", "two girls", "two boys",
|
||||
"elf woman", "android", "succubus", "vampire woman", "catgirl", "foxgirl"
|
||||
],
|
||||
"nsfw_clothing": [
|
||||
"lingerie", "bikini", "partially undressed", "see-through dress",
|
||||
"revealing outfit", "open shirt", "stockings and garter belt",
|
||||
"naked", "topless", "underwear only"
|
||||
],
|
||||
"nsfw_action": [
|
||||
"posing seductively", "lying on bed", "looking at viewer seductively",
|
||||
"undressing", "embracing", "kissing", "sitting on lap"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
[
|
||||
{
|
||||
"category": "Characters",
|
||||
"subcategories": [
|
||||
{ "name": "Subject", "tags": ["1boy_solo","1girl","1girl_solo","2boys","2girls","3boys","3girls","multiple_boys","multiple_girls"] },
|
||||
{ "name": "Gender", "tags": ["androgynous","boy","feminine","girl","man","masculine","non-binary","woman"] },
|
||||
{ "name": "Age", "tags": ["adult","aged","child","elderly","loli","mature","old","teenager","young","young_adult"] },
|
||||
{ "name": "Ethnicity", "tags": ["Chinese","Japanese","Korean","african","asian","caucasian","diverse","indian","middle_eastern","mixed_race"] },
|
||||
{ "name": "Body Type", "tags": ["athletic","average_build","curvy","muscular","petite","slender","slim","tall","thick"] },
|
||||
{ "name": "Facial Features", "tags": ["Ears_that_stick_out","asian_eyes","beautiful_face","delicate_features","expressive_face","large_ears","large_eyes","large_mouth","sharp_features","small_ears","small_eyes","small_mouth","soft_features","symmetrical_face"] },
|
||||
{ "name": "Breasts & Nipples", "tags": ["asian_nipples","dark_nipples","flat_chest","hanging_breasts","huge_breasts","large_breasts","large_nipples","medium_breasts","nipples","perky_breasts","small_breasts","small_nipples"] },
|
||||
{ "name": "Hair Style", "tags": ["bangs","bob_cut","braid","chignon","cornrows","curly_bob","curly_frizzy_hair","double_buns_","dragon_phoenix_bun_","dreadlocks_","hanfu_hairstyle_","high_ponytail","hime_cut_","long_curly_hair","long_straight_hair","long_twintails","loose_curly_afro","low_ponytail","messy_bun_","pixie_cut","short_straight_hair","short_wavy_hair","side_braid","tomboy_cut","twin_braids","twintails","updo"] },
|
||||
{ "name": "Lips", "tags": ["black_lips","glossy_lips","pink_lips","pulpy_lips","purple_lips","red_lips","rose-red_lips","thick_lips","thin_lips"] },
|
||||
{ "name": "Eyes Color", "tags": ["albinos_eyes","black_eyes","blue_eyes","brown_eyes","chestnut_eyes","green_eyes","lavender_eyes","orange_eyes","red_eyes","silver_eyes"] },
|
||||
{ "name": "Profession", "tags": ["acrobat","assassin","astronaut","athletes","ballerina","barista","boxer","bride","cashier","cheerleader","chef","cleric","coser","dancer","detective","diver","doll","firefighter","flight_stewardess","goalkeeper","harem","idol","king","knight","lifeguard","magician","maid","mailman","makihitsuji","miko","motorcyclist","nun","nurse","office lady","pilot","police","priest","queen","race_queen","racer","samurai","scientist","soldier","student","teacher","wa_maid","waitress","witch"] }
|
||||
]
|
||||
},
|
||||
{
|
||||
"category": "Clothing",
|
||||
"subcategories": [
|
||||
{ "name": "Nudity", "tags": ["bottomless","completely_nude","nude_breasts","partial_nudity","topless","unclothed","unclothing"] },
|
||||
{ "name": "Casual", "tags": ["athleisure","casual_wear","hoodie","jeans","nightgown","nightie","pajamas","street_wear","sweater","t-shirt"] },
|
||||
{ "name": "Uniform / Sport / Profession", "tags": ["aviator_uniform","band_uniform","baseball_uniform","business_suit","cheerleader","chef_uniform","fireman_uniform","flight-stewardess_uniform","gym_uniform","military_uniform","nurse_uniform","police_uniform","school_uniform","space_suit","tennis_uniform","volleyball_uniform"] },
|
||||
{ "name": "Tops", "tags": ["aran_sweater","blouse","bomber_jacket","camisole","cardigan","casual_tshirt","collared_shirt","crop_top","cropped_shirt","dress_shirt","hawaiian_shirt","hoodie","jacket","lab_coat","polo_shirt","pullover","sailor_shirt","shirt","sportbra","sweater","sweatshirt","tank_top","taoist_coat","tshirt","undershirt","vest","windbreaker","zip-up_hoodie","pullover_hoodie","crewneck_sweatshirt"] },
|
||||
{ "name": "Bottoms", "tags": ["a-line_skirt","ankle-length_skirt","ankle_pants","asymmetrical_skirt","baggy_pants","ball_gown_skirt","bell_bottoms","biker_pants","bikini_panties","bootcut_pants","boyfriend_jeans","boyshorts","briefs","bubble_skirt","cargo_pants","cargo_skirt","carpenter_pants","cheeky_panties","cheongsam_skirt","chiffon_skirt","chinos","cigarette_pants","control_panties","corduroy_pants","cotton_panties","crop_pants","culottes","cycling_pants","dance_belt","dress_pants","flared_pants","flared_skirt","full_coverage_panties","g-string","gaucho_pants","godet_skirt","harem_pants","high-cut_panties","high-low_skirt","high-waisted_panties","high-waisted_pants","hiphugger_panties","hot_pants","jodhpurs","joggers","karate_pants","khakis","kilt","leggings","loincloth","long_johns","loose-fit_pants","maxi_skirt","mesh_panties","micro_panties","micro_skirt","midi_skirt","mini_skirt","mom_jeans","no-show_panties","palazzo_pants","pantyhose","paperbag_pants","parachute_pants","peg-leg_pants","pencil_pants","pencil_skirt","peplum_skirt","pleated_pants","pleated_skirt","riding_pants","sailor_pants","sarong_skirt","seamless_panties","shapewear","skater_skirt","skinny_jeans","slim-fit_pants","slit_skirt","snow_pants","sport_panties","straight_skirt","string_bikini","sweatpants","tanga_panties","tapered_pants","tartan_skirt","thong","tiered_skirt","track_pants","trousers","trunks","tulip_skirt","tummy_control_panties","tutu_skirt","underwear_shorts","v-string","wide-leg_pants","womens_boxers","wrap_skirt","yoga_pants"] },
|
||||
{ "name": "Clothes Style", "tags": ["asymmetric","backless_back_cutout","bell_sleeves","criss-cross_halter","frilled","fur-trimmed","high-low_hem","lace-trimmed","layered","off-shoulder","one-shoulder","peplum","pleated","puffed_sleeves","ribbon-trimmed","ruffled","see-through_transparent","short_sleeve","sleeveless","sling_strap_suspender","strapless","taut_tight","turtleneck","unbuttoned_open_clothes","wrapped"] },
|
||||
{ "name": "Ornamentation", "tags": ["Camouflage_pattern","Paisley_pattern","argyle_pattern","checked-pattern","chevron_pattern","cloud_pattern","coin_pattern","diamond_pattern","dotted_pattern","floral_pattern","geometric_pattern","golden_sun_pattern","gradient_pattern","herringbone_pattern","hundstooth_pattern","ikat_pattern","liberty_pattern","lighting_pattern","moon_pattern","multi-colored","pheonix_pattern","plaid","polka_dot_pattern","quatrefoil_pattern","ribbed","rose_pattern","silver_star_pattern","striped_pattern","swirl_pattern","tartan_pattern","tie-dye_pattern","triangle_pattern","wave_pattern","zigzag_pattern"] },
|
||||
{ "name": "Dress", "tags": ["armored_dress","ballgown","bohemian_dress","business_casual_dress","business_dress","business_formal_dress","business_professional_dress","casual_chic_dress","casual_dress","club_dress","cocktail_dress","edgy_dress","evening_gown","formal_dress","gothic_dress","gown","highleg_dress","hipster_dress","jumpsuit","long_dress","party_dress","pencil_dress","preppy_dress","punk_dress","retro_dress","sailor_dress","short_dress","short_sweater_dress","smart_casual_dress","sporty_dress","streetwear_dress","sundress","vintage_dress","wedding_dress"] },
|
||||
{ "name": "Swimsuits", "tags": ["bikini","japanese_school_swimsuit","monokini","one-piece_swimsuit","skirted_swimsuit","slingshot_swimsuit","swim_dress","tankini","wetsuit"] },
|
||||
{ "name": "Asian Clothes", "tags": ["aikidogi","ao_dai","cheongsam","gakuran","hadagi","hagoromo","hanbok","hanfu","japanese_school_uniform","jinbei","judogi","jujutsugi","karategi","kendogi","kimono","kimono_robe","nagajuban","nagajuban_robe","samue","yukata"] },
|
||||
{ "name": "Japanese Tops", "tags": ["Hanten","Haori","happi","michiyuki","tanzen","tombi_coat"] },
|
||||
{ "name": "Japanese Bottoms", "tags": ["Hakama","fundoshi","maekake","tobi_pants"] },
|
||||
{ "name": "Japanese Accessories & Shoes", "tags": ["geta","hachimaki","obi","obiage","obidome","obijime","setta","tabi","tenugui","zori"] }
|
||||
]
|
||||
},
|
||||
{
|
||||
"category": "Action",
|
||||
"subcategories": [
|
||||
{ "name": "Body Posture / Pose", "tags": ["attacking","bathing","battle_stance","bent_over","bowing","casting_spell","climbing","combat_ready","cooking","dancing","defending","drawing","drawing_bow","drinking","driving","dynamic_pose","exercise","fetal_position","fighting","firing","fishing","flying","high_five","holding_hands","hugging","jumping","kissing","leaning_back","leaning_forward","leaning_to_the_side","licking_lips","lip_biting","looking_back","lying_down","lying_on_back","lying_on_grass","lying_on_side","lying_on_stomach","playing_instrument","pointing","pole_dancing","reading","riding","running","showering_shower_caddy","singing","sitting","smoking","standing","stretching","swimming","talking","talking_on_phone","using_weapon","walking","waving","writing"] },
|
||||
{ "name": "Hand Movements", "tags": ["adjusting_eyewear","adjusting_hair","arm_above_head","arm_at_side","arm_behind_back","arm_behind_head","arm_up","arms_at_sides","arms_behind_back","arms_behind_head","arms_crossed","arms_up","beckoning","bunching_hair","caramelldansen","cheek_pinching","cheek_poking","chin_rest","chin_stroking","clapping","claw_pose","covering_eyes","covering_face","covering_mouth","crossed_arms","crossed_fingers","double_v","finger_biting","finger_gun","finger_in_mouth","finger_to_mouth","fingers_together","fist","hair_pull","hair_twirling","hand_in_hair","hand_in_pocket","hand_on_chest","hand_on_head","hand_on_hip","hand_on_own_cheek","hand_on_own_chin","hand_on_own_stomach","hand_to_mouth","hands_clasped","hands_on_hips","hands_on_lap","hands_on_own_cheeks","hands_on_own_face","hands_on_own_head","hands_on_own_knees","hands_on_own_stomach","hands_on_own_thighs","head_rest","heart_sig","index_finger_raised","middle_finger","ok_sign","outstretched_arms","palms_together","paw_pose","peace_sign","pointing_at_self","pointing_at_viewer","pointing_up","poking","praying","reaching_out","salute","shading_eyes","shushing","snapping_fingers","spread_fingers","thumbs_up","v","v_arms","w","w_arms","waving","wiping_tear"] },
|
||||
{ "name": "Leg Movements", "tags": ["all_fours","butterfly_sitting","crossed_ankles","crossed_legs","curtsy","folded","high_kick","hugging_own_legs","kicking","knee_up","kneeling","knees_to_chest","knees_together_feet_apart","leg_up","legs_apart","legs_over_head","legs_together","legs_up","lotus_position","m_legs","one_knee","open_stance","pigeon-toed","sidesaddle","sitting","sitting_backwards","sitting_in_tree","sitting_on_bed","sitting_on_chair","sitting_on_desk","sitting_on_railing","sitting_on_rock","sitting_on_stairs","sitting_on_table","sitting_on_water","spread_legs","squatting","squatting_open_legs","standing_split_leg_up","straddling","tiptoes","toe-point","upright_straddle","wariza","yokozuwari"] },
|
||||
{ "name": "Glance", "tags": ["head_down","head_tilt","looking_afar","looking_ahead","looking_at_viewer","looking_away","looking_down","looking_over_shoulder","looking_to_the_side","looking_up"] },
|
||||
{ "name": "Expression", "tags": ["angry","annoyed","blush","confused","contempt","crazy_smile","crying_with_eyes_open","evil_smile","flustered","frustrated","furrowed_brow","giggling","glaring","grin","happy_tears","laughing","moaning","neutral","sad","scared","screaming","seductive_smile","serious","shouting","shy","smile","smiling","smirk","stare","surprised","wink"] }
|
||||
]
|
||||
},
|
||||
{
|
||||
"category": "Environment",
|
||||
"subcategories": [
|
||||
{ "name": "Nature", "tags": ["a_lawn_covered_with_petals","beach","botanical_garden","desert","forest","garden","jungle","meadow","mountain","park","river","treehouse","valley","waterfall"] },
|
||||
{ "name": "Urban", "tags": ["airport","alley","amusement_park","arena","avenue","bridge","bus_stop","castle","cathedral","church","city","cityscape","cyberpunk_city","downtown","east_asian_architecture","ferris_wheel","office_building","fountain","graveyard","harbor","industrial_area","japanese_garden","playground","railroad_tracks","rooftop","ruins","skyscraper","stadium","street","subway","swimming_pool","train_station","tunnel","under_bridge","urban_setting","waterpark"] },
|
||||
{ "name": "Interior", "tags": ["airplane_interior","aquarium","art_gallery","art_room","auditorium","bakery","bar","basement","bathhouse","bathroom","bed","bedroom","bench","bench_seats_in_the_train","bowling_alley","cafe","casino","classroom","clubroom","coffee_house","concert_hall","convenience_store","courtroom","dining_hall","dormitory","dungeon","electrical_room","engine_room","greenhouse","gym","hospital","hotel","infirmary","intersection","izakaya","kitchen","laboratory","library","market_stall","metro_station","mosque","museum","onsen","pharmacy","planetarium","prison","reservoir","restaurant","school","server_room","shop","stage","supermarket","temple","theater","throne_room","warehouse","workshop"] },
|
||||
{ "name": "Fantasy", "tags": ["dragon_lair","enchanted_forest","fantasy_world","magical_realm","mystical_landscape","wizard_tower"] },
|
||||
{ "name": "Weather", "tags": ["aurora","cloudy","dawn","foggy","golden_hour","night","overcast","rainy","snowy","stormy","sunny","sunrise","sunset"] }
|
||||
]
|
||||
},
|
||||
{
|
||||
"category": "NSFW",
|
||||
"subcategories": [
|
||||
{ "name": "Stimulation", "tags": ["footjob","double_footjob","implied_footjob","cooperative_footjob","shoejob","bootjob","feet","licking_foot","foot_worship","smelling_feet","frottage","armpit_sex","grinding","backjob","buttjob","cooperative_grinding","kneepit_sex","paizuri","autopaizuri","cooperative_paizuri","handsfree_paizuri","naizuri","paizuri_over_clothes","paizuri_on_lap","paizuri_under_clothes","pecjob","perpendicular_paizuri","stealth_paizuri","straddling_paizuri","thigh_sex","glansjob","cloth_glansjob","groping","grabbing_another's_ass","grabbing_another's_breast","guided_breast_grab","pectoral_grab","guided_pectoral_grab","nipple_tweak","crotch_grab","guided_crotch_grab","grabbing_own_breast","torso_grab","hairjob","handjob","caressing_testicles","double_handjob","cooperative_handjob","nursing_handjob","cuddling_handjob","reverse_nursing_handjob","reach-around","two-handed_handjob","masturbation","clothed_masturbation","crotch_rub","building_sex","pillow_humping","teddy_bear_sex","table_humping","female_masturbation","futanari_masturbation","implied_masturbation","male_masturbation","mutual_masturbation","stealth_masturbation","tail_masturbation","tail","tail_insertion","tailjob","oral","anilingus","rusty_trombone","breast_sucking","cunnilingus","autocunnilingus","implied_cunnilingus","fellatio","autofellatio","cum_swap","deepthroat","implied_fellatio","irrumatio","cooperative_fellatio","multiple_penis_fellatio","hug_and_suck","licking_testicle","sitting_on_face","testicle_sucking"] },
|
||||
{ "name": "Group Sex", "tags": ["group_sex","bisexual","bisexual_female","bisexual_male","oral_sandwich","daisy_chain","gangbang","double_penetration","triple_penetration","love_train","cooperative_fellatio","cooperative_footjob","cooperative_breast_smother","orgy","reverse_spitroast","spitroast","teamwork","threesome","MMF_threesome","FFM_threesome","MMM_threesome","FFF_threesome"] },
|
||||
{ "name": "Penetration", "tags": ["animal_insertion","cervical_penetration","covered_penetration","deep_penetration","food_insertion","inflation","cum_inflation","big_belly","enema","large_insertion","stomach_bulge","male_penetrated","multiple_insertions","nipple_penetration","nosejob","object_insertion","vaginal_object_insertion","anal_object_insertion","urethral_insertion","sounding","fingering","anal_fingering","prostate_milking","fingering_through_clothes","fingering_through_panties","implied_fingering","fisting","anal_fisting","self_fisting","sex","after_sex","after_anal","after_buttjob","after_fellatio","after_fingering","after_frottage","after_insertion","after_masturbation","after_oral","after_paizuri","after_rape","after_urethral","after_vaginal","afterglow","clothed_after_sex","anal","double_anal","imminent_anal","pegging","triple_anal","clothed_sex","guided_penetration","happy_sex","imminent_penetration","implied_sex","navel_sex","rough_sex","sex_from_behind","skull_fucking","ear_sex","penis_in_eye","tentacle_sex","underwater_sex","vaginal","double_vaginal","imminent_vaginal","triple_vaginal"] },
|
||||
{ "name": "Same Sex Acts","tags": ["yaoi","bara","implied_yaoi","bulges_touching","penises_touching","testicles_touching","yuri","implied_yuri","tribadism"] },
|
||||
{ "name": "Cum", "tags": ["cum","bukkake","cumdump","cum_bath","cumdrip","cum_pool","cum_inflation","cum_in_ass","cum_in_clothes","cum_in_cup","cum_in_mouth","cum_in_throat","cum_on_tongue","cum_in_pussy","cum_in_urethra","cum_on_body","cum_on_armpits","cum_on_ass","cum_on_back","cum_on_breasts","cum_on_chest","cum_on_feet","cum_on_fingers","cum_on_hair","cum_on_pectorals","cum_on_pussy","cum_on_stomach","cum_in_navel","cum_on_clothes","cum_on_eyewear","cum_on_food","ejaculation","ejaculating_while_penetrated","pull_out","facial","autofacial","felching","gokkun"] },
|
||||
{ "name": "Exhibitionism","tags": ["exhibitionism","clothed_sex","public_indecency","public_nudity","zenra","public_vibrator","stealth_bondage","stealth_masturbation","stealth_paizuri","stealth_sex","cheating","netorare","reverse_netorare","prostitution","public_use","voyeurism","caught","peeking","walk-in"] },
|
||||
{ "name": "Expressions", "tags": [":>=","fucked_silly","ahegao","torogao","naughty_face"] }
|
||||
]
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,12 @@
|
||||
[
|
||||
{
|
||||
"category": "Negative",
|
||||
"subcategories": [
|
||||
{ "name": "Quality Issues", "tags": ["worst_quality","low_quality","blurry","distorted","artifacts","noise","pixelated","low_resolution"] },
|
||||
{ "name": "Anatomy", "tags": ["bad_anatomy","wrong_anatomy","malformed","deformed","twisted","contorted","broken_limbs","extra_limbs","missing_limbs"] },
|
||||
{ "name": "Faces", "tags": ["bad_face","ugly_face","deformed_face","bad_teeth","bad_eyes","crossed_eyes","asymmetrical_face"] },
|
||||
{ "name": "Hands", "tags": ["bad_hands","weird_hands","deformed_hands","extra_fingers","missing_fingers","twisted_fingers","fused_fingers"] },
|
||||
{ "name": "Composition", "tags": ["poorly_composed","cluttered","confusing","amateur","unbalanced","off-center"] }
|
||||
]
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,11 @@
|
||||
[
|
||||
{
|
||||
"category": "Quality",
|
||||
"subcategories": [
|
||||
{ "name": "Resolution", "tags": ["8k_uhd","4k","1080p","highres","ultra_highres","masterpiece","best_quality"] },
|
||||
{ "name": "Detail", "tags": ["hyper-detailed","intricate_details","extremely_detailed","fine_details","sharp_focus","crystal_clear","crisp_details"] },
|
||||
{ "name": "Rendering", "tags": ["raytracing","path_tracing","ambient_occlusion","photorealistic","realistic_shading","physically_accurate","global_illumination"] },
|
||||
{ "name": "Award Winning", "tags": ["award-winning","trending_on_artstation","featured_on_pixiv","museum_quality","professionally_done","gallery_quality"] }
|
||||
]
|
||||
}
|
||||
]
|
||||
Reference in New Issue
Block a user