From 8a765f494694375464d8521a435f038cb8811f29 Mon Sep 17 00:00:00 2001 From: 4thCafe Date: Fri, 17 Jul 2026 06:33:25 +0900 Subject: [PATCH] =?UTF-8?q?=E6=A7=8B=E6=96=87=E3=83=8F=E3=82=A4=E3=83=A9?= =?UTF-8?q?=E3=82=A4=E3=83=88=E3=82=92=E8=BF=BD=E5=8A=A0(textarea=E3=83=90?= =?UTF-8?q?=E3=83=83=E3=82=AF=E3=83=89=E3=83=AD=E3=83=83=E3=83=97=E6=96=B9?= =?UTF-8?q?=E5=BC=8F)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 入力欄(textarea)のキーワードを種別で色分け表示。素のtextareaは色付け 不可のため、同じ親内に色付きバックドロップdivを敷き、textareaの文字を 透明化(キャレットのみ表示)して重ねる。位置はtextareaのoffsetボックスに 合わせ、ResizeObserverで追従(ズーム/リサイズ非依存)。 - js/highlighter.js: highlightHTML(深度カウンタで種別判定・全文字保持)+ Highlighterクラス(バックドロップ生成・スクロール/位置同期) - 色分け: 重み(橙)/LoRA(青)/BREAK(灰太字)/コメント(灰斜体)、通常タグは既定色 - input_watcher.js: 対象ノードのtextareaにHighlighterを適用 - style.css: バックドロップと色クラス Co-Authored-By: Claude Fable 5 --- README.md | 4 +- js/highlighter.js | 128 ++++++++++++++++++++++++++++++++++++++++++++ js/input_watcher.js | 4 ++ js/style.css | 10 ++++ 4 files changed, 144 insertions(+), 2 deletions(-) create mode 100644 js/highlighter.js diff --git a/README.md b/README.md index 79f6804..aa99443 100644 --- a/README.md +++ b/README.md @@ -33,8 +33,8 @@ git clone https://github.com/4thCafe/ComfyUI-PromptTagEditor ## ロードマップ - [x] タグ編集コア(チップ化・個別編集・重み・並べ替え・削除) -- [ ] 各タグの翻訳表示(オフライン: argostranslate) -- [ ] 入力欄の構文ハイライト +- [x] タグ翻訳(日本語→英語は置換、英語→日本語は表示) +- [x] 入力欄の構文ハイライト(重み/LoRA/BREAK/コメントを色分け) ## License diff --git a/js/highlighter.js b/js/highlighter.js new file mode 100644 index 0000000..79dd640 --- /dev/null +++ b/js/highlighter.js @@ -0,0 +1,128 @@ +// highlighter.js — textarea の構文ハイライト +// 素の textarea は色付け不可のため、同じ親内に色付きバックドロップdivを敷き、 +// textarea の文字を透明化(キャレットのみ表示)して重ねる方式。座標追従はComfyUIに委譲。 +// ComfyUI-PromptTagEditor / 4thCafe + +import { parseToken } from "./prompt_parser.js"; + +function escapeHtml(s) { + return s.replace(/&/g, "&").replace(//g, ">"); +} + +// セグメント(カンマ区切りの1タグ)の種別からCSSクラスを決める +function classOf(seg) { + const t = seg.trim(); + if (!t) return null; + const tok = parseToken(t); + if (tok.type === "lora") return "pte-hl-lora"; + if (tok.type === "break") return "pte-hl-break"; + if (tok.type === "comment") return "pte-hl-comment"; + if (tok.type === "tag" && Math.abs(tok.weight - 1) > 0.001) return "pte-hl-weighted"; + return null; // 通常タグ(既定色) +} + +// プロンプト文字列 → 色付きHTML(全文字を保持。深度0のカンマ/改行で区切る) +export function highlightHTML(text) { + if (!text) return ""; + let html = ""; + let buf = ""; + let depth = 0; + const flush = () => { + if (buf === "") return; + const cls = classOf(buf); + html += cls ? `${escapeHtml(buf)}` : escapeHtml(buf); + buf = ""; + }; + for (let i = 0; i < text.length; i++) { + const ch = text[i]; + if (ch === "\\") { buf += ch; if (i + 1 < text.length) { buf += text[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(); html += escapeHtml(ch); continue; } + buf += ch; + } + flush(); + return html; +} + +export class Highlighter { + constructor(textarea) { + this.ta = textarea; + try { + this._build(); + this._bind(); + this.update(); + } catch (e) { + console.warn("[PromptTagEditor] highlighter init failed:", e); + } + } + + _build() { + const ta = this.ta; + const parent = ta.parentElement; + const cs = getComputedStyle(ta); + this._origColor = cs.color; + + const bd = document.createElement("div"); + bd.className = "pte-hl-backdrop"; + const s = bd.style; + s.position = "absolute"; + s.margin = "0"; + s.borderWidth = cs.borderWidth; + s.borderStyle = "solid"; + s.borderColor = "transparent"; + s.padding = cs.padding; + s.font = cs.font; + s.lineHeight = cs.lineHeight; + s.letterSpacing = cs.letterSpacing; + s.whiteSpace = "pre-wrap"; + s.overflowWrap = "break-word"; + s.wordBreak = cs.wordBreak; + s.boxSizing = cs.boxSizing; + s.overflow = "hidden"; + s.pointerEvents = "none"; + s.color = cs.color; + parent.insertBefore(bd, ta); + this.bd = bd; + + // バックドロップ位置・サイズを textarea の offset ボックスに合わせる(ズーム非依存) + this._syncBox = () => { + s.top = ta.offsetTop + "px"; + s.left = ta.offsetLeft + "px"; + s.width = ta.offsetWidth + "px"; + s.height = ta.offsetHeight + "px"; + }; + this._syncBox(); + try { + this._ro = new ResizeObserver(() => this._syncBox()); + this._ro.observe(ta); + } catch (_) {} + + // textarea の文字を透明化(キャレットは元色で表示) + ta.style.background = "transparent"; + ta.style.color = "transparent"; + ta.style.caretColor = this._origColor || "#fff"; + ta.style.position = "relative"; + ta.style.zIndex = "1"; + } + + _bind() { + this._onInput = () => this.update(); + this._onScroll = () => this._syncScroll(); + this.ta.addEventListener("input", this._onInput); + this.ta.addEventListener("scroll", this._onScroll); + } + + _syncScroll() { + if (!this.bd) return; + this.bd.scrollTop = this.ta.scrollTop; + this.bd.scrollLeft = this.ta.scrollLeft; + } + + update() { + if (!this.bd) return; + if (this._syncBox) this._syncBox(); + this.bd.innerHTML = highlightHTML(this.ta.value); + this._syncScroll(); + } +} diff --git a/js/input_watcher.js b/js/input_watcher.js index 232fe9d..6362d11 100644 --- a/js/input_watcher.js +++ b/js/input_watcher.js @@ -3,6 +3,7 @@ // ComfyUI-PromptTagEditor / 4thCafe import { TagEditor } from "./tag_editor.js"; +import { Highlighter } from "./highlighter.js"; // このノード型のtextウィジェットにのみチップUIを適用(既存ノードには一切触れない) const TARGET_NODE_TYPE = "PromptTagEditor"; @@ -63,6 +64,9 @@ export class Watcher { const editor = new TagEditor(chipsEl, (str) => this._syncToTextarea(w, ta, str)); w.__pteEditor = editor; + // textarea の構文ハイライト(バックドロップ方式) + w.__pteHighlighter = new Highlighter(ta); + // DOMウィジェットとして隣接配置(serialize:false=値はネイティブtextareaが保持) const dom = node.addDOMWidget(`pte_${w.name}`, "pte_chips", wrap, { serialize: false, diff --git a/js/style.css b/js/style.css index 84b690f..d141bfe 100644 --- a/js/style.css +++ b/js/style.css @@ -151,6 +151,16 @@ outline: none; } +/* === textarea 構文ハイライト用バックドロップ === */ +.pte-hl-backdrop { + z-index: 0; + user-select: none; +} +.pte-hl-backdrop .pte-hl-lora { color: #8fc0ff; } +.pte-hl-backdrop .pte-hl-weighted { color: #ffb27a; } +.pte-hl-backdrop .pte-hl-break { color: #bbb; font-weight: 600; } +.pte-hl-backdrop .pte-hl-comment { color: #999; font-style: italic; } + /* チップエリアを載せるDOMウィジェットのラッパ */ .pte-widget-wrap { width: 100%;