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%;