タグ単位編集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:
4thCafe
2026-07-17 02:05:31 +09:00
co-authored by Claude Fable 5
commit 3db4c76c90
9 changed files with 736 additions and 0 deletions
+4
View File
@@ -0,0 +1,4 @@
__pycache__/
*.pyc
.DS_Store
node_modules/
+21
View File
@@ -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.
+34
View File
@@ -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
View File
@@ -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"]
+112
View File
@@ -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
View File
@@ -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));
},
});
+196
View File
@@ -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
View File
@@ -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;
}
+170
View File
@@ -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));
}
}