タグ単位編集MVP: 既存入力欄をチップ化(個別編集・重み+/-・D&D並べ替え・削除)
既存ノードのmultiline STRING入力欄をフロントエンド拡張でその場チップ化する。 追加ノードは増やさず、ネイティブtextareaを真実の値として温存し双方向同期する。 - prompt_parser.js: 深度カウンタ方式のトークナイズ/シリアライズ、重み調整(0.05刻み・1.0近傍unwrap)、LoRA/BREAK/エスケープ/括弧内カンマ対応、未対応構文はraw温存で無損失 - input_watcher.js: customtext widget発見(element優先)、addDOMWidgetでチップエリア隣接配置、定期スキャンで読込タイミング非依存に、3行同期(textarea.value/input/callback) - tag_editor.js + style.css: チップ描画・inline編集・+/-/×・ドラッグ並べ替え・型別色分け - main.js/__init__.py: 拡張登録・有効化トグル(WEB_DIRECTORY、ノード追加なし) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,4 @@
|
||||
__pycache__/
|
||||
*.pyc
|
||||
.DS_Store
|
||||
node_modules/
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 4thCafe
|
||||
|
||||
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,34 @@
|
||||
# ComfyUI-PromptTagEditor
|
||||
|
||||
既存ノードのプロンプト入力欄(multiline STRING)を、**タグ(チップ)単位で編集**できるように強化する ComfyUI フロントエンド拡張です。SD-WebUI の `sd-webui-prompt-all-in-one` のタグ編集体験を、**追加ノードを増やさずに** ComfyUI 上で再現します。
|
||||
|
||||
## 特徴
|
||||
|
||||
- **タグ編集エリアを分離表示**: 既存の `<textarea>`(生プロンプト)はそのまま残し、その下にチップ(タグ)エリアを追加します。両者は双方向に同期します。
|
||||
- **タグ単位の操作**:
|
||||
- 各タグをダブルクリックで**個別編集**
|
||||
- `+` / `−` で**重み調整**(`(word:1.2)` 構文、0.05刻み、1.0付近で括弧自動除去)
|
||||
- **ドラッグ&ドロップで並べ替え**
|
||||
- `×` で**削除**
|
||||
- **既存ワークフローと非破壊**: 値はネイティブのテキストウィジェットが保持するため、ワークフロー保存・実行時プロンプトに確実に反映されます。LoRA `<lora:...>`・`BREAK`・エスケープ・括弧内カンマも壊しません。
|
||||
|
||||
## 対象ノード
|
||||
|
||||
`CLIP Text Encode` 系をはじめ、**multiline STRING 入力欄(`customtext`)を持つ全ノード**に自動適用されます。設定 → `Prompt Tag Editor: タグ編集エリアを表示` でON/OFFできます。
|
||||
|
||||
## インストール
|
||||
|
||||
```
|
||||
git clone https://github.com/4thCafe/ComfyUI-PromptTagEditor
|
||||
```
|
||||
を ComfyUI の `custom_nodes` に配置して ComfyUI を再起動してください。追加のPython依存はありません。
|
||||
|
||||
## ロードマップ
|
||||
|
||||
- [x] タグ編集コア(チップ化・個別編集・重み・並べ替え・削除)
|
||||
- [ ] 各タグの翻訳表示(オフライン: argostranslate)
|
||||
- [ ] 入力欄の構文ハイライト
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
"""ComfyUI-PromptTagEditor / 4thCafe
|
||||
|
||||
既存ノードの multiline STRING 入力欄を、フロントエンド拡張でタグ(チップ)単位編集できるように強化する。
|
||||
Pythonノードは追加しない(WEB_DIRECTORY の JS 拡張のみ)。
|
||||
"""
|
||||
|
||||
NODE_CLASS_MAPPINGS = {}
|
||||
NODE_DISPLAY_NAME_MAPPINGS = {}
|
||||
WEB_DIRECTORY = "./js"
|
||||
|
||||
__all__ = ["NODE_CLASS_MAPPINGS", "NODE_DISPLAY_NAME_MAPPINGS", "WEB_DIRECTORY"]
|
||||
@@ -0,0 +1,112 @@
|
||||
// input_watcher.js — customtext(multiline STRING)ウィジェットを発見し、
|
||||
// 直下にチップエリアを載せて双方向同期する。
|
||||
// ComfyUI-PromptTagEditor / 4thCafe
|
||||
|
||||
import { TagEditor } from "./tag_editor.js";
|
||||
|
||||
export class Watcher {
|
||||
constructor(app, getEnabled) {
|
||||
this.app = app;
|
||||
this.getEnabled = getEnabled || (() => true);
|
||||
}
|
||||
|
||||
// 定期スキャンで、読込タイミングに関わらず未アタッチのcustomtextへ適用(堅牢化)
|
||||
start() {
|
||||
this._scan();
|
||||
this._timer = setInterval(() => this._scan(), 1000);
|
||||
}
|
||||
|
||||
_scan() {
|
||||
try {
|
||||
const g = this.app.graph;
|
||||
if (!g || !g._nodes) return;
|
||||
for (const node of g._nodes) this.attachToNode(node);
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
attachToNode(node) {
|
||||
if (!this.getEnabled()) return;
|
||||
if (!node || !node.widgets) return;
|
||||
for (const w of node.widgets) {
|
||||
if (w.type !== "customtext") continue; // multiline STRING のみ
|
||||
if (w.__pteAttached) continue;
|
||||
try {
|
||||
this._attachWidget(node, w);
|
||||
} catch (e) {
|
||||
console.warn("[PromptTagEditor] attach failed:", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_attachWidget(node, w) {
|
||||
const ta = w.element || w.inputEl; // 実体の <textarea>(新frontendは element)
|
||||
if (!ta || ta.tagName !== "TEXTAREA") return;
|
||||
w.__pteAttached = true;
|
||||
|
||||
// チップエリアのラッパDOM
|
||||
const wrap = document.createElement("div");
|
||||
wrap.className = "pte-widget-wrap";
|
||||
const header = document.createElement("div");
|
||||
header.className = "pte-widget-header";
|
||||
const title = document.createElement("span");
|
||||
title.textContent = "Tags";
|
||||
header.appendChild(title);
|
||||
const chipsEl = document.createElement("div");
|
||||
wrap.appendChild(header);
|
||||
wrap.appendChild(chipsEl);
|
||||
|
||||
const editor = new TagEditor(chipsEl, (str) => this._syncToTextarea(w, ta, str));
|
||||
w.__pteEditor = editor;
|
||||
|
||||
// DOMウィジェットとして隣接配置(serialize:false=値はネイティブtextareaが保持)
|
||||
const dom = node.addDOMWidget(`pte_${w.name}`, "pte_chips", wrap, {
|
||||
serialize: false,
|
||||
hideOnZoom: false,
|
||||
getValue: () => "",
|
||||
setValue: () => {},
|
||||
getHeight: () => {
|
||||
const h = chipsEl.scrollHeight + 22;
|
||||
return Math.max(56, Math.min(220, h));
|
||||
},
|
||||
});
|
||||
w.__pteDom = dom;
|
||||
|
||||
// 初期描画
|
||||
editor.setFromString(w.value != null ? String(w.value) : ta.value || "");
|
||||
|
||||
// 逆同期: textarea手動編集 -> チップ再構築(自前同期中/編集中は抑制)
|
||||
const onInput = () => {
|
||||
if (w.__pteSyncing) return;
|
||||
if (editor.editing) return;
|
||||
clearTimeout(w.__pteDebounce);
|
||||
w.__pteDebounce = setTimeout(() => {
|
||||
if (!editor.editing && !w.__pteSyncing) editor.setFromString(ta.value);
|
||||
}, 150);
|
||||
};
|
||||
ta.addEventListener("input", onInput);
|
||||
|
||||
// ノードサイズを再計算してチップエリアを見えるように
|
||||
requestAnimationFrame(() => {
|
||||
try {
|
||||
const sz = node.computeSize();
|
||||
node.setSize([Math.max(node.size[0], sz[0]), Math.max(node.size[1], sz[1])]);
|
||||
node.setDirtyCanvas(true, true);
|
||||
} catch (_) {}
|
||||
});
|
||||
}
|
||||
|
||||
// チップ編集結果を textarea(=真実の値)へ3行同期
|
||||
_syncToTextarea(w, ta, str) {
|
||||
w.__pteSyncing = true;
|
||||
try {
|
||||
if (ta) {
|
||||
ta.value = str;
|
||||
ta.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
}
|
||||
w.value = str;
|
||||
try { w.callback?.(str); } catch (_) {}
|
||||
} finally {
|
||||
w.__pteSyncing = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
+51
@@ -0,0 +1,51 @@
|
||||
// main.js — ComfyUI拡張エントリ。Watcher起動 + 有効化トグル。
|
||||
// ComfyUI-PromptTagEditor / 4thCafe
|
||||
|
||||
import { app } from "../../scripts/app.js";
|
||||
import { Watcher } from "./input_watcher.js";
|
||||
|
||||
const SETTING_ID = "4thCafe.PromptTagEditor.enabled";
|
||||
let enabled = true;
|
||||
let watcher = null;
|
||||
|
||||
app.registerExtension({
|
||||
name: "4thCafe.PromptTagEditor",
|
||||
|
||||
async setup() {
|
||||
// CSS読み込み
|
||||
const link = document.createElement("link");
|
||||
link.rel = "stylesheet";
|
||||
link.href = new URL("./style.css", import.meta.url).href;
|
||||
document.head.appendChild(link);
|
||||
|
||||
// 有効化トグル
|
||||
try {
|
||||
app.ui.settings.addSetting({
|
||||
id: SETTING_ID,
|
||||
name: "Prompt Tag Editor: タグ編集エリアを表示",
|
||||
type: "boolean",
|
||||
defaultValue: true,
|
||||
onChange: (v) => { enabled = !!v; },
|
||||
});
|
||||
const cur = app.ui.settings.getSettingValue?.(SETTING_ID, true);
|
||||
enabled = cur == null ? true : !!cur;
|
||||
} catch (e) {
|
||||
console.warn("[PromptTagEditor] setting init failed:", e);
|
||||
}
|
||||
|
||||
watcher = new Watcher(app, () => enabled);
|
||||
watcher.start();
|
||||
},
|
||||
|
||||
// 新規ノード生成時
|
||||
async nodeCreated(node) {
|
||||
if (!watcher) return;
|
||||
requestAnimationFrame(() => watcher.attachToNode(node));
|
||||
},
|
||||
|
||||
// ワークフロー読込後の既存ノード
|
||||
async loadedGraphNode(node) {
|
||||
if (!watcher) return;
|
||||
requestAnimationFrame(() => watcher.attachToNode(node));
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,196 @@
|
||||
// prompt_parser.js — プロンプト文字列のトークナイズ/シリアライズ/重み調整(純ロジック)
|
||||
// ComfyUI-PromptTagEditor / 4thCafe
|
||||
//
|
||||
// 方針:
|
||||
// - カンマ単純splitは禁止。エスケープ `\` と括弧深度を見て、深度0のカンマ/改行のみで分割する。
|
||||
// - 各トークンは raw(元の文字列)を保持。未編集トークンは serialize で raw をそのまま出力し無損失。
|
||||
// 重み/テキストを編集したトークンのみ dirty=true とし {text,weight} から再構築する。
|
||||
|
||||
let _idCounter = 0;
|
||||
function nextId() {
|
||||
return `pte_${(++_idCounter).toString(36)}`;
|
||||
}
|
||||
|
||||
// 重みを小数2桁へ丸める
|
||||
function round2(n) {
|
||||
return Math.round(n * 100) / 100;
|
||||
}
|
||||
|
||||
const WEIGHT_EPS = 0.001;
|
||||
|
||||
// 単一トークン文字列を構造化する
|
||||
export function parseToken(raw) {
|
||||
const text = raw.trim();
|
||||
const token = {
|
||||
id: nextId(),
|
||||
text,
|
||||
weight: 1.0,
|
||||
type: "tag",
|
||||
raw: text,
|
||||
dirty: false,
|
||||
};
|
||||
if (text === "") return token;
|
||||
|
||||
// BREAK(独立トークン)
|
||||
if (text === "BREAK") {
|
||||
token.type = "break";
|
||||
return token;
|
||||
}
|
||||
// コメント
|
||||
if (text.startsWith("#")) {
|
||||
token.type = "comment";
|
||||
return token;
|
||||
}
|
||||
// ネットワーク系 <lora:name:1.0> / <lyco:...> など
|
||||
const net = text.match(/^<([a-zA-Z_]+):([^:>]+)(?::([-\d.]+))?>$/);
|
||||
if (net) {
|
||||
token.type = "lora";
|
||||
if (net[3] !== undefined) {
|
||||
const w = parseFloat(net[3]);
|
||||
if (!isNaN(w)) token.weight = w;
|
||||
}
|
||||
return token;
|
||||
}
|
||||
// 明示重み (content:weight) — 最外殻が括弧で閉じ、末尾が :数値) の形
|
||||
const weighted = matchOuterWeighted(text);
|
||||
if (weighted) {
|
||||
token.text = weighted.text;
|
||||
token.weight = weighted.weight;
|
||||
token.type = "tag";
|
||||
return token;
|
||||
}
|
||||
// 暗黙強調 (word)=1.1^n / [word]=(1/1.1)^n
|
||||
const emph = matchImplicitEmphasis(text);
|
||||
if (emph) {
|
||||
token.text = emph.text;
|
||||
token.weight = round2(emph.weight);
|
||||
token.type = "tag";
|
||||
return token;
|
||||
}
|
||||
// 素のタグ
|
||||
token.text = text;
|
||||
return token;
|
||||
}
|
||||
|
||||
// `(...:number)` が全体を1つの重み括弧で包んでいるか判定(内部の括弧バランスも確認)
|
||||
function matchOuterWeighted(text) {
|
||||
if (text[0] !== "(" || text[text.length - 1] !== ")") return null;
|
||||
// 末尾の :number) を探す
|
||||
const m = text.match(/:\s*([-+]?\d*\.?\d+)\s*\)$/);
|
||||
if (!m) return null;
|
||||
const inner = text.slice(1, text.length - m[0].length); // ( と :num) を除いた中身
|
||||
// 最外殻の括弧が全体を包んでいる(途中で深度が0に戻らない)ことを確認
|
||||
let depth = 0;
|
||||
for (let i = 0; i < text.length; i++) {
|
||||
const ch = text[i];
|
||||
if (ch === "\\") { i++; continue; }
|
||||
if (ch === "(") depth++;
|
||||
else if (ch === ")") {
|
||||
depth--;
|
||||
if (depth === 0 && i !== text.length - 1) return null; // 途中で閉じている→最外殻ではない
|
||||
}
|
||||
}
|
||||
const weight = parseFloat(m[1]);
|
||||
if (isNaN(weight)) return null;
|
||||
return { text: inner.trim(), weight: round2(weight) };
|
||||
}
|
||||
|
||||
// (((word))) / [[word]] のような暗黙強調
|
||||
function matchImplicitEmphasis(text) {
|
||||
let open = 0, close = 0;
|
||||
let mult = 1.0;
|
||||
// 先頭・末尾の対応する括弧の層数を数える
|
||||
let s = text;
|
||||
// 括弧の種類が混在する複雑ケースは扱わず null(raw温存)
|
||||
const first = s[0];
|
||||
if (first !== "(" && first !== "[") return null;
|
||||
const bracket = first === "(" ? { o: "(", c: ")", w: 1.1 } : { o: "[", c: "]", w: 1 / 1.1 };
|
||||
let n = 0;
|
||||
while (s[n] === bracket.o) n++;
|
||||
let m = 0;
|
||||
while (s[s.length - 1 - m] === bracket.c) m++;
|
||||
const layers = Math.min(n, m);
|
||||
if (layers === 0) return null;
|
||||
const inner = s.slice(layers, s.length - layers).trim();
|
||||
if (inner === "" || inner.includes(bracket.o) || inner.includes(bracket.c)) return null;
|
||||
// 中身にコロン重みが混ざる場合は扱わない
|
||||
if (/[:()\[\]<>]/.test(inner)) return null;
|
||||
mult = Math.pow(bracket.w, layers);
|
||||
return { text: inner, weight: mult };
|
||||
}
|
||||
|
||||
// プロンプト文字列 -> Token[](深度0のカンマ/改行のみで分割)
|
||||
export function tokenize(str) {
|
||||
const tokens = [];
|
||||
if (!str) return tokens;
|
||||
let buf = "";
|
||||
let depth = 0;
|
||||
const flush = () => {
|
||||
if (buf.trim() !== "") tokens.push(parseToken(buf));
|
||||
buf = "";
|
||||
};
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
const ch = str[i];
|
||||
if (ch === "\\") {
|
||||
buf += ch;
|
||||
if (i + 1 < str.length) { buf += str[i + 1]; i++; }
|
||||
continue;
|
||||
}
|
||||
if (ch === "(" || ch === "[" || ch === "<") { depth++; buf += ch; continue; }
|
||||
if (ch === ")" || ch === "]" || ch === ">") { depth = Math.max(0, depth - 1); buf += ch; continue; }
|
||||
if ((ch === "," || ch === "\n") && depth === 0) { flush(); continue; }
|
||||
buf += ch;
|
||||
}
|
||||
flush();
|
||||
return tokens;
|
||||
}
|
||||
|
||||
// 単一トークンを文字列化
|
||||
export function serializeToken(t) {
|
||||
if (t.type === "break") return "BREAK";
|
||||
// 未編集トークンは raw をそのまま(無損失)
|
||||
if (!t.dirty) return t.raw;
|
||||
// 編集済み: 構造から再構築
|
||||
if (t.type === "lora") {
|
||||
return t.raw; // MVPではlora本体は編集対象外
|
||||
}
|
||||
if (t.type === "comment") return t.raw;
|
||||
const w = t.weight;
|
||||
if (Math.abs(w - 1.0) < WEIGHT_EPS) return t.text;
|
||||
return `(${t.text}:${round2(w)})`;
|
||||
}
|
||||
|
||||
// Token[] -> プロンプト文字列
|
||||
export function serialize(tokens) {
|
||||
return tokens
|
||||
.map(serializeToken)
|
||||
.filter((s) => s !== "" && s != null)
|
||||
.join(", ");
|
||||
}
|
||||
|
||||
// 重みを delta だけ増減(0.05刻み想定)。tagのみ対象。1.0近傍で括弧自動除去。
|
||||
export function adjustWeight(token, delta) {
|
||||
if (token.type !== "tag") return token;
|
||||
let w = round2(token.weight + delta);
|
||||
if (Math.abs(w - 1.0) < 0.03) w = 1.0; // 1.0近傍でスナップ(unwrap)
|
||||
w = Math.max(0, Math.min(5, w));
|
||||
token.weight = w;
|
||||
token.dirty = true;
|
||||
return token;
|
||||
}
|
||||
|
||||
// テキスト編集を反映
|
||||
export function setTokenText(token, newText) {
|
||||
token.text = newText;
|
||||
token.dirty = true;
|
||||
return token;
|
||||
}
|
||||
|
||||
// 表示用ラベル(チップに出す文字列)
|
||||
export function displayLabel(t) {
|
||||
if (t.type === "break") return "BREAK";
|
||||
if (t.type === "lora") return t.raw;
|
||||
if (t.type === "comment") return t.raw;
|
||||
if (Math.abs(t.weight - 1.0) < WEIGHT_EPS) return t.text;
|
||||
return `${t.text}:${round2(t.weight)}`;
|
||||
}
|
||||
+137
@@ -0,0 +1,137 @@
|
||||
/* ComfyUI-PromptTagEditor / 4thCafe — チップUIスタイル */
|
||||
|
||||
.pte-chips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
align-content: flex-start;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
padding: 4px;
|
||||
overflow-y: auto;
|
||||
background: var(--comfy-input-bg, #222);
|
||||
border: 1px solid var(--border-color, #444);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.pte-empty {
|
||||
color: var(--descrip-text, #888);
|
||||
font-size: 11px;
|
||||
font-style: italic;
|
||||
padding: 2px 4px;
|
||||
}
|
||||
|
||||
.pte-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
padding: 2px 4px 2px 7px;
|
||||
background: var(--comfy-menu-bg, #353535);
|
||||
border: 1px solid var(--border-color, #4a4a4a);
|
||||
border-radius: 5px;
|
||||
font-size: 12px;
|
||||
line-height: 1.4;
|
||||
color: var(--input-text, #ddd);
|
||||
cursor: grab;
|
||||
user-select: none;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.pte-chip.pte-dragging {
|
||||
opacity: 0.45;
|
||||
}
|
||||
|
||||
.pte-chip.pte-weighted {
|
||||
border-color: #c0662e;
|
||||
color: #ffb27a;
|
||||
}
|
||||
|
||||
.pte-chip[data-type="lora"] {
|
||||
border-color: #3b7dc0;
|
||||
color: #8fc0ff;
|
||||
}
|
||||
|
||||
.pte-chip[data-type="break"] {
|
||||
border-color: #888;
|
||||
color: #bbb;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.pte-chip[data-type="comment"] {
|
||||
border-color: #666;
|
||||
color: #999;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.pte-chip-label {
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
max-width: 220px;
|
||||
cursor: text;
|
||||
}
|
||||
|
||||
.pte-chip-btns {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 1px;
|
||||
}
|
||||
|
||||
.pte-btn {
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
opacity: 0.65;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
line-height: 1;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
padding: 0;
|
||||
border-radius: 3px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.pte-btn:hover {
|
||||
opacity: 1;
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
}
|
||||
|
||||
.pte-del:hover {
|
||||
background: rgba(220, 60, 60, 0.35);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.pte-w-inc,
|
||||
.pte-w-dec {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.pte-chip-edit {
|
||||
font-size: 12px;
|
||||
background: var(--comfy-input-bg, #1a1a1a);
|
||||
color: var(--input-text, #fff);
|
||||
border: 1px solid #5a8fd0;
|
||||
border-radius: 3px;
|
||||
padding: 1px 4px;
|
||||
min-width: 60px;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
/* チップエリアを載せるDOMウィジェットのラッパ */
|
||||
.pte-widget-wrap {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.pte-widget-header {
|
||||
font-size: 10px;
|
||||
color: var(--descrip-text, #888);
|
||||
margin: 2px 2px 3px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
// tag_editor.js — チップ(タグ)エリアの描画と操作(編集 / +/- / × / ドラッグ並べ替え)
|
||||
// ComfyUI-PromptTagEditor / 4thCafe
|
||||
|
||||
import {
|
||||
tokenize, serialize, adjustWeight, setTokenText, displayLabel,
|
||||
} from "./prompt_parser.js";
|
||||
|
||||
const WEIGHT_STEP = 0.05;
|
||||
|
||||
export class TagEditor {
|
||||
/**
|
||||
* @param {HTMLElement} container チップを描画するコンテナ
|
||||
* @param {(promptStr:string)=>void} onChange チップ変更時に呼ばれる(文字列を同期)
|
||||
*/
|
||||
constructor(container, onChange) {
|
||||
this.container = container;
|
||||
this.onChange = onChange || (() => {});
|
||||
this.tokens = [];
|
||||
this.editing = false; // inline編集中(逆同期抑制用)
|
||||
this._dragIndex = -1;
|
||||
this.container.classList.add("pte-chips");
|
||||
}
|
||||
|
||||
// 外部の文字列からチップを再構築(逆同期時)
|
||||
setFromString(str) {
|
||||
if (this.editing) return; // 編集中は再構築しない
|
||||
this.tokens = tokenize(str);
|
||||
this.render();
|
||||
}
|
||||
|
||||
_emit() {
|
||||
this.onChange(serialize(this.tokens));
|
||||
}
|
||||
|
||||
_emitAndRender() {
|
||||
this._emit();
|
||||
this.render();
|
||||
}
|
||||
|
||||
render() {
|
||||
const c = this.container;
|
||||
c.innerHTML = "";
|
||||
this.tokens.forEach((tok, idx) => {
|
||||
c.appendChild(this._buildChip(tok, idx));
|
||||
});
|
||||
if (this.tokens.length === 0) {
|
||||
const empty = document.createElement("span");
|
||||
empty.className = "pte-empty";
|
||||
empty.textContent = "(no tags)";
|
||||
c.appendChild(empty);
|
||||
}
|
||||
}
|
||||
|
||||
_buildChip(tok, idx) {
|
||||
const chip = document.createElement("div");
|
||||
chip.className = "pte-chip";
|
||||
chip.dataset.type = tok.type;
|
||||
if (tok.type === "tag" && Math.abs(tok.weight - 1.0) > 0.001) {
|
||||
chip.classList.add("pte-weighted");
|
||||
}
|
||||
chip.draggable = true;
|
||||
|
||||
// ドラッグ並べ替え
|
||||
chip.addEventListener("dragstart", (e) => {
|
||||
this._dragIndex = idx;
|
||||
chip.classList.add("pte-dragging");
|
||||
e.dataTransfer.effectAllowed = "move";
|
||||
try { e.dataTransfer.setData("text/plain", String(idx)); } catch (_) {}
|
||||
});
|
||||
chip.addEventListener("dragend", () => {
|
||||
chip.classList.remove("pte-dragging");
|
||||
this._dragIndex = -1;
|
||||
});
|
||||
chip.addEventListener("dragover", (e) => {
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = "move";
|
||||
});
|
||||
chip.addEventListener("drop", (e) => {
|
||||
e.preventDefault();
|
||||
const from = this._dragIndex;
|
||||
if (from < 0 || from === idx) return;
|
||||
const [moved] = this.tokens.splice(from, 1);
|
||||
// from < idx の場合、削除で1つ詰まるため補正
|
||||
const to = from < idx ? idx - 1 : idx;
|
||||
this.tokens.splice(to, 0, moved);
|
||||
this._emitAndRender();
|
||||
});
|
||||
|
||||
// ラベル(ダブルクリックでinline編集)
|
||||
const label = document.createElement("span");
|
||||
label.className = "pte-chip-label";
|
||||
label.textContent = displayLabel(tok);
|
||||
label.title = tok.raw;
|
||||
label.addEventListener("dblclick", () => this._startEdit(chip, label, tok));
|
||||
chip.appendChild(label);
|
||||
|
||||
// 操作ボタン
|
||||
const btns = document.createElement("span");
|
||||
btns.className = "pte-chip-btns";
|
||||
if (tok.type === "tag") {
|
||||
btns.appendChild(this._btn("pte-w-dec", "−", "重みを下げる", (e) => {
|
||||
e.stopPropagation();
|
||||
adjustWeight(tok, -WEIGHT_STEP);
|
||||
this._emitAndRender();
|
||||
}));
|
||||
btns.appendChild(this._btn("pte-w-inc", "+", "重みを上げる", (e) => {
|
||||
e.stopPropagation();
|
||||
adjustWeight(tok, WEIGHT_STEP);
|
||||
this._emitAndRender();
|
||||
}));
|
||||
}
|
||||
btns.appendChild(this._btn("pte-del", "×", "削除", (e) => {
|
||||
e.stopPropagation();
|
||||
this.tokens.splice(idx, 1);
|
||||
this._emitAndRender();
|
||||
}));
|
||||
chip.appendChild(btns);
|
||||
return chip;
|
||||
}
|
||||
|
||||
_btn(cls, text, title, onClick) {
|
||||
const b = document.createElement("button");
|
||||
b.className = "pte-btn " + cls;
|
||||
b.type = "button";
|
||||
b.textContent = text;
|
||||
b.title = title;
|
||||
b.tabIndex = -1;
|
||||
b.addEventListener("click", onClick);
|
||||
// ドラッグがボタンから始まらないように
|
||||
b.draggable = false;
|
||||
b.addEventListener("mousedown", (e) => e.stopPropagation());
|
||||
return b;
|
||||
}
|
||||
|
||||
_startEdit(chip, label, tok) {
|
||||
this.editing = true;
|
||||
const input = document.createElement("input");
|
||||
input.className = "pte-chip-edit";
|
||||
input.value = tok.text;
|
||||
chip.draggable = false;
|
||||
label.replaceWith(input);
|
||||
input.focus();
|
||||
input.select();
|
||||
|
||||
const commit = (save) => {
|
||||
if (this._committing) return;
|
||||
this._committing = true;
|
||||
this.editing = false;
|
||||
if (save) {
|
||||
const v = input.value.trim();
|
||||
if (v === "") {
|
||||
// 空にしたら削除
|
||||
const i = this.tokens.indexOf(tok);
|
||||
if (i >= 0) this.tokens.splice(i, 1);
|
||||
} else {
|
||||
setTokenText(tok, v);
|
||||
}
|
||||
this._emit();
|
||||
}
|
||||
this.render();
|
||||
this._committing = false;
|
||||
};
|
||||
input.addEventListener("keydown", (e) => {
|
||||
if (e.key === "Enter") { e.preventDefault(); commit(true); }
|
||||
else if (e.key === "Escape") { e.preventDefault(); commit(false); }
|
||||
e.stopPropagation(); // ComfyUIのショートカット抑制
|
||||
});
|
||||
input.addEventListener("blur", () => commit(true));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user