Files

1511 lines
56 KiB
JavaScript

import { app } from "../../scripts/app.js";
const API_ROOT = "/visual_prompt_library";
const STYLE_ID = "visual-prompt-library-manager-style";
const STATE_STORAGE_KEY = "visual_prompt_library_manager_state";
function ensureStyle() {
if (document.getElementById(STYLE_ID)) return;
const style = document.createElement("style");
style.id = STYLE_ID;
style.textContent = `
.vplm-root {
box-sizing: border-box;
height: 100%;
min-height: 0;
display: flex;
flex-direction: column;
gap: 10px;
padding: 10px;
color: #f3f4f6;
background: #101113;
font-family: Arial, sans-serif;
overflow-y: auto;
}
.vplm-row {
display: flex;
flex-wrap: wrap;
gap: 6px;
align-items: center;
}
.vplm-db-row {
align-items: flex-end;
}
.vplm-title {
font-size: 14px;
font-weight: 700;
}
.vplm-section-title {
display: flex;
justify-content: space-between;
align-items: center;
gap: 8px;
margin-bottom: 7px;
color: #dce7f5;
font-size: 12px;
font-weight: 700;
}
.vplm-title-actions {
display: flex;
align-items: center;
gap: 6px;
}
.vplm-help {
color: #9fb0c3;
font-size: 11px;
line-height: 1.35;
}
.vplm-field {
display: grid;
gap: 4px;
}
.vplm-field label {
color: #9fb0c3;
font-size: 11px;
}
.vplm-badge {
border: 1px solid #3b4048;
border-radius: 999px;
padding: 2px 7px;
color: #b8d9ff;
background: #162435;
font-size: 10px;
white-space: nowrap;
}
.vplm-btn,
.vplm-input,
.vplm-select,
.vplm-textarea {
box-sizing: border-box;
border: 1px solid #3b4048;
border-radius: 5px;
color: #f3f4f6;
background: #171a1f;
font-size: 12px;
outline: none;
}
.vplm-btn {
padding: 6px 8px;
cursor: pointer;
white-space: nowrap;
}
.vplm-btn:hover {
background: #22262d;
}
.vplm-btn:disabled {
opacity: 0.45;
cursor: not-allowed;
}
.vplm-btn:disabled:hover {
background: #171a1f;
border-color: #3b4048;
}
.vplm-btn-danger:hover {
background: #542526;
border-color: #8f4447;
}
.vplm-btn-add {
border-color: #df5f9a;
color: #fff5fb;
background: #9f2f66;
font-weight: 700;
}
.vplm-btn-add:hover {
border-color: #f384b6;
background: #bd3b7a;
}
.vplm-input,
.vplm-select {
height: 30px;
padding: 5px 8px;
min-width: 0;
}
.vplm-textarea {
width: 100%;
min-height: 66px;
padding: 7px 8px;
resize: vertical;
}
.vplm-grow {
flex: 1 1 140px;
min-width: 0;
}
.vplm-panel {
border: 1px solid #2c2f34;
border-radius: 7px;
background: #12151a;
padding: 8px;
}
.vplm-section {
display: grid;
gap: 8px;
}
.vplm-main {
min-height: 0;
display: grid;
grid-template-columns: minmax(130px, 0.85fr) minmax(180px, 1.2fr);
gap: 8px;
flex: 1 1 auto;
overflow: hidden;
}
.vplm-list {
min-height: 0;
overflow-y: auto;
display: grid;
align-content: start;
gap: 6px;
}
.vplm-panel-list {
min-height: 0;
display: flex;
flex-direction: column;
gap: 7px;
}
.vplm-scroll {
min-height: 0;
overflow-y: auto;
display: grid;
align-content: start;
gap: 6px;
}
.vplm-item {
border: 1px solid #333943;
border-radius: 6px;
padding: 7px;
background: #171a1f;
cursor: pointer;
}
.vplm-item-head {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 8px;
align-items: start;
}
.vplm-item-actions {
display: flex;
gap: 4px;
}
.vplm-icon-btn {
width: 28px;
height: 28px;
display: inline-flex;
align-items: center;
justify-content: center;
border: 1px solid #3b4048;
border-radius: 5px;
color: #f3f4f6;
background: #171a1f;
cursor: pointer;
font-size: 14px;
line-height: 1;
padding: 0;
}
.vplm-icon-svg {
width: 15px;
height: 15px;
display: block;
stroke: currentColor;
stroke-width: 2.3;
stroke-linecap: round;
stroke-linejoin: round;
fill: none;
pointer-events: none;
}
.vplm-icon-btn:hover {
background: #22262d;
}
.vplm-icon-btn-danger:hover {
background: #542526;
border-color: #8f4447;
}
.vplm-item[data-active="true"] {
border-color: #62a8ed;
background: #1c2733;
}
.vplm-item-title {
font-size: 12px;
font-weight: 700;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.vplm-item-sub {
margin-top: 3px;
color: #9fb0c3;
font-size: 11px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.vplm-editor {
min-height: 0;
overflow-y: auto;
display: grid;
gap: 8px;
}
.vplm-thumb-row {
display: grid;
grid-template-columns: 1fr 64px;
gap: 8px;
align-items: center;
}
.vplm-thumb-preview {
display: grid;
gap: 5px;
}
.vplm-thumb {
width: 64px;
height: 64px;
border: 1px solid #3b4048;
border-radius: 6px;
object-fit: contain;
background: #0d1014;
}
.vplm-thumb-clear {
width: 64px;
padding-inline: 0;
text-align: center;
}
.vplm-output-panel {
display: grid;
gap: 8px;
}
.vplm-output-grid {
max-height: 260px;
overflow-y: auto;
display: grid;
grid-template-columns: repeat(auto-fill, minmax(86px, 1fr));
gap: 7px;
padding: 3px;
border: 1px solid #2c2f34;
border-radius: 6px;
background: #0d1014;
}
.vplm-output-item {
min-width: 0;
border: 1px solid #333943;
border-radius: 6px;
padding: 4px;
background: #171a1f;
color: #f3f4f6;
cursor: pointer;
display: grid;
gap: 4px;
}
.vplm-output-item:hover {
border-color: #62a8ed;
background: #1c2733;
}
.vplm-output-item img {
width: 100%;
aspect-ratio: 1 / 1;
object-fit: contain;
background: #101318;
border-radius: 4px;
}
.vplm-output-name {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 10px;
color: #9fb0c3;
}
.vplm-prompt-list {
min-height: 0;
overflow-y: auto;
display: grid;
align-content: start;
gap: 8px;
}
.vplm-prompt-list[data-view-mode="large"] {
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
}
.vplm-prompt-list[data-view-mode="small"] {
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
}
.vplm-prompt-list[data-view-mode="text"] {
grid-template-columns: 1fr;
gap: 6px;
}
.vplm-prompt-card {
border: 1px solid #333943;
border-radius: 6px;
background: #171a1f;
cursor: pointer;
overflow: hidden;
}
.vplm-prompt-card[data-active="true"] {
border-color: #62a8ed;
background: #1c2733;
}
.vplm-prompt-thumb {
width: 100%;
aspect-ratio: 1 / 1;
display: grid;
place-items: center;
background: #20252d;
overflow: hidden;
}
.vplm-prompt-thumb img {
width: 100%;
height: 100%;
object-fit: contain;
background: #0d1014;
}
.vplm-prompt-thumb span {
color: #9fb0c3;
font-size: 11px;
}
.vplm-prompt-body {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 8px;
align-items: start;
padding: 7px;
}
.vplm-prompt-list[data-view-mode="large"] .vplm-prompt-thumb {
aspect-ratio: 1 / 1;
}
.vplm-prompt-list[data-view-mode="small"] .vplm-prompt-thumb {
aspect-ratio: 1 / 1;
}
.vplm-prompt-list[data-view-mode="text"] .vplm-prompt-card {
padding: 0;
}
.vplm-prompt-list[data-view-mode="text"] .vplm-prompt-thumb {
display: none;
}
.vplm-status {
color: #9fb0c3;
font-size: 11px;
min-height: 16px;
}
.vplm-empty {
border: 1px dashed #333943;
border-radius: 6px;
padding: 10px;
color: #9fb0c3;
font-size: 11px;
text-align: center;
}
.vplm-hidden {
display: none;
}
.vplm-overlay {
position: fixed;
inset: 0;
z-index: 10000;
display: grid;
place-items: center;
padding: 18px;
background: rgba(0, 0, 0, 0.58);
}
.vplm-modal {
width: min(520px, calc(100vw - 36px));
max-height: calc(100vh - 36px);
overflow-y: auto;
border: 1px solid #3b4048;
border-radius: 8px;
background: #111418;
box-shadow: 0 18px 60px rgba(0, 0, 0, 0.5);
padding: 12px;
display: grid;
gap: 10px;
}
.vplm-modal-actions {
display: flex;
justify-content: flex-end;
gap: 8px;
}
`;
document.head.appendChild(style);
}
function slugify(value) {
const text = String(value || "item")
.normalize("NFKC")
.trim()
.toLowerCase();
let result = "";
for (const char of text) {
if (/^[a-z0-9_-]$/.test(char) || (char.charCodeAt(0) > 127 && !/[\\/:*?"<>|#%&{}$!`'@+=,;[\]\s]/.test(char))) {
result += char;
} else {
result += "_";
}
}
return result.replace(/^_+|_+$/g, "").replace(/_+/g, "_") || "item";
}
function uniqueId(base, exists) {
const root = slugify(base);
let id = root;
let index = 2;
while (exists(id)) {
id = `${root}_${index}`;
index += 1;
}
return id;
}
async function fetchJson(url, options = {}) {
const method = String(options.method || "GET").toUpperCase();
let requestUrl = url;
if (method === "GET") {
const parsed = new URL(url, window.location.origin);
parsed.searchParams.set("_", String(Date.now()));
requestUrl = `${parsed.pathname}${parsed.search}`;
}
const response = await fetch(requestUrl, { cache: "no-store", ...options });
const text = await response.text();
const trimmed = text.trim();
let payload = {};
if (trimmed) {
const contentType = response.headers.get("content-type") || "";
const looksJson = contentType.includes("application/json") || trimmed.startsWith("{") || trimmed.startsWith("[");
if (looksJson) {
try {
payload = JSON.parse(trimmed);
} catch (error) {
payload = { error: trimmed };
}
} else {
payload = { error: trimmed };
}
}
if (!response.ok) {
if (response.status === 404 && String(payload.error || "").includes("404")) {
throw new Error("APIが見つかりません。ComfyUI本体を再起動してください。");
}
throw new Error(payload.error || `HTTP ${response.status}`);
}
return payload;
}
function imageViewUrl(image) {
const params = new URLSearchParams({
filename: image.filename || "",
type: image.type || "output",
});
if (image.subfolder) params.set("subfolder", image.subfolder);
return `/view?${params.toString()}`;
}
async function getRecentOutputsFromHistory(limit = 80) {
const payload = await fetchJson(`/history?max_items=${limit}`);
const entries = Object.values(payload || {});
const images = [];
entries.forEach((entry) => {
const outputs = entry?.outputs || {};
Object.values(outputs).forEach((output) => {
(output?.images || []).forEach((image) => {
if (!image?.filename) return;
const normalized = {
filename: image.filename,
subfolder: image.subfolder || "",
type: image.type || "output",
};
images.push({
...normalized,
url: imageViewUrl(normalized),
});
});
});
});
return images.slice(0, limit);
}
function resizeImageBlobToDataUrl(blob, maxSize = 320) {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onerror = () => reject(reader.error);
reader.onload = () => {
const image = new Image();
image.onerror = () => reject(new Error("Failed to load selected image."));
image.onload = () => {
const scale = Math.min(1, maxSize / Math.max(image.width, image.height));
const width = Math.max(1, Math.round(image.width * scale));
const height = Math.max(1, Math.round(image.height * scale));
const canvas = document.createElement("canvas");
canvas.width = width;
canvas.height = height;
canvas.getContext("2d").drawImage(image, 0, 0, width, height);
resolve(canvas.toDataURL("image/webp", 0.82));
};
image.src = reader.result;
};
reader.readAsDataURL(blob);
});
}
function resizeImageFileToDataUrl(file, maxSize = 320) {
return resizeImageBlobToDataUrl(file, maxSize);
}
async function resizeImageUrlToDataUrl(url, maxSize = 320) {
const response = await fetch(url, { cache: "no-store" });
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const blob = await response.blob();
return resizeImageBlobToDataUrl(blob, maxSize);
}
function normalizeLibrary(raw) {
const categories = Array.isArray(raw?.categories) ? raw.categories : [];
return {
version: raw?.version || 1,
meta: raw?.meta || {},
categories: categories.map((category) => ({
id: category.id || slugify(category.label),
label: category.label || category.id || "カテゴリ",
parts: Array.isArray(category.parts) ? category.parts.map((part) => ({
id: part.id || slugify(part.label || part.prompt),
label: part.label || part.id || part.prompt || "プロンプト",
prompt: part.prompt || "",
negative: part.negative || "",
thumbnail: part.thumbnail || "",
})) : [],
})),
};
}
function mergeLibrary(target, incoming) {
const source = normalizeLibrary(incoming);
let addedCategories = 0;
let addedParts = 0;
source.categories.forEach((category) => {
let dest = target.categories.find((item) => item.id === category.id || item.label === category.label);
if (!dest) {
const id = uniqueId(category.id || category.label, (candidate) => target.categories.some((item) => item.id === candidate));
dest = { id, label: category.label, parts: [] };
target.categories.push(dest);
addedCategories += 1;
}
category.parts.forEach((part) => {
const duplicate = dest.parts.some((item) => item.prompt === part.prompt || item.label === part.label);
if (duplicate) return;
const id = uniqueId(part.id || part.label || part.prompt, (candidate) => dest.parts.some((item) => item.id === candidate));
dest.parts.push({ ...part, id });
addedParts += 1;
});
});
return { addedCategories, addedParts };
}
function createIconSvg(type) {
const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
svg.setAttribute("class", "vplm-icon-svg");
svg.setAttribute("viewBox", "0 0 24 24");
const addPath = (d) => {
const path = document.createElementNS("http://www.w3.org/2000/svg", "path");
path.setAttribute("d", d);
svg.appendChild(path);
};
if (type === "edit") {
addPath("M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.1-3.1a5 5 0 0 1-6.6 6.6L7.8 19.2a2 2 0 1 1-3-3l6.4-6.4a5 5 0 0 1 6.6-6.6z");
} else {
addPath("M3 6h18");
addPath("M8 6V4h8v2");
addPath("M19 6l-1 14H6L5 6");
addPath("M10 11v5");
addPath("M14 11v5");
}
return svg;
}
function keepEventInsideModal(event) {
event.stopPropagation();
}
function protectTextEntry(element) {
["keydown", "keyup", "keypress", "paste"].forEach((eventType) => {
element.addEventListener(eventType, keepEventInsideModal);
});
}
function loadUiState() {
try {
return JSON.parse(localStorage.getItem(STATE_STORAGE_KEY) || "{}");
} catch {
return {};
}
}
function saveUiState(state) {
try {
localStorage.setItem(STATE_STORAGE_KEY, JSON.stringify(state));
} catch {
// Ignore storage failures; the picker still works without persistence.
}
}
class VisualPromptLibraryManager {
constructor() {
ensureStyle();
const savedState = loadUiState();
this.element = document.createElement("div");
this.element.className = "vplm-root";
this.libraries = [];
this.libraryName = savedState.libraryName || "default";
this.library = { version: 1, meta: {}, categories: [] };
this.selectedCategoryId = savedState.categoryByLibrary?.[this.libraryName] || "";
this.selectedPartId = "";
this.search = "";
this.status = "";
this.viewMode = savedState.viewMode || "large";
this.uiState = savedState;
this.dirty = false;
this.autosaveTimer = null;
this.initialized = false;
this.render();
this.refreshLibraries({ loadSelected: true });
}
rememberUiState() {
this.uiState = {
...this.uiState,
libraryName: this.libraryName,
viewMode: this.viewMode,
categoryByLibrary: {
...(this.uiState.categoryByLibrary || {}),
[this.libraryName]: this.selectedCategoryId,
},
};
saveUiState(this.uiState);
}
setStatus(message) {
this.status = message || "";
this.render();
}
markDirty() {
this.dirty = true;
this.scheduleAutoSave();
}
scheduleAutoSave() {
window.clearTimeout(this.autosaveTimer);
this.autosaveTimer = window.setTimeout(() => this.saveLibrary(true), 650);
}
async refreshLibraries(options = {}) {
try {
const payload = await fetchJson(`${API_ROOT}/libraries`);
this.libraries = payload.libraries || [];
if (!this.libraries.some((item) => item.name === this.libraryName) && this.libraries[0]) {
this.libraryName = this.libraries[0].name;
}
this.rememberUiState();
if (options.loadSelected) {
await this.loadLibrary({ silent: true });
this.initialized = true;
this.setStatus(`復元済み: ${this.libraryName}`);
} else {
this.render();
}
} catch (error) {
this.setStatus(`DB一覧の取得に失敗: ${error.message}`);
}
}
async loadLibrary(options = {}) {
try {
const payload = await fetchJson(`${API_ROOT}/libraries/${encodeURIComponent(this.libraryName)}`);
this.libraryName = payload.name || this.libraryName;
this.library = normalizeLibrary(payload.library);
const rememberedCategoryId = this.uiState.categoryByLibrary?.[this.libraryName] || "";
this.selectedCategoryId = this.library.categories.some((category) => category.id === rememberedCategoryId)
? rememberedCategoryId
: (this.library.categories[0]?.id || "");
this.selectedPartId = this.currentCategory()?.parts?.[0]?.id || "";
this.dirty = false;
this.rememberUiState();
if (!options.silent) {
this.setStatus(`読み込み済み: ${this.libraryName}`);
} else {
this.render();
}
} catch (error) {
this.setStatus(`読み込み失敗: ${error.message}`);
}
}
async saveLibrary(auto = false) {
window.clearTimeout(this.autosaveTimer);
try {
const payload = await fetchJson(`${API_ROOT}/libraries/${encodeURIComponent(slugify(this.libraryName))}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ library: this.library }),
});
this.libraryName = payload.name || this.libraryName;
this.dirty = false;
await this.refreshLibraries();
this.setStatus(auto ? `自動保存済み: ${this.libraryName}` : `保存済み: ${this.libraryName}`);
} catch (error) {
this.setStatus(`保存失敗: ${error.message}`);
}
}
async createLibrary(name) {
const libraryName = slugify(name);
if (!libraryName) return;
try {
const payload = await fetchJson(`${API_ROOT}/libraries/${encodeURIComponent(libraryName)}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ library: { version: 1, meta: {}, categories: [] } }),
});
this.libraryName = payload.name || libraryName;
this.library = normalizeLibrary(payload.library);
this.selectedCategoryId = "";
this.selectedPartId = "";
this.dirty = false;
this.rememberUiState();
await this.refreshLibraries();
this.setStatus(`作成済み: ${this.libraryName}`);
} catch (error) {
this.setStatus(`DB作成失敗: ${error.message}`);
}
}
async deleteLibrary() {
const name = this.libraryName;
if (!name) return;
if (!confirm(`DB「${name}」を削除しますか?`)) return;
try {
await fetchJson(`${API_ROOT}/libraries/${encodeURIComponent(name)}`, { method: "DELETE" });
await this.refreshLibraries();
this.libraryName = this.libraries[0]?.name || "default";
this.selectedCategoryId = this.uiState.categoryByLibrary?.[this.libraryName] || "";
this.rememberUiState();
await this.loadLibrary();
} catch (error) {
this.setStatus(`DB削除失敗: ${error.message}`);
}
}
currentCategory() {
return this.library.categories.find((category) => category.id === this.selectedCategoryId) || null;
}
currentPart() {
const category = this.currentCategory();
return category?.parts?.find((part) => part.id === this.selectedPartId) || null;
}
openCategoryModal(category = null) {
this.showCategoryModal(category);
}
openLibraryModal() {
this.showLibraryModal();
}
openPromptModal(part = null) {
this.showPromptModal(part);
}
deleteCategory(categoryId) {
const category = this.library.categories.find((item) => item.id === categoryId);
if (!category) return;
if (!confirm(`カテゴリ「${category.label}」を削除しますか?`)) return;
this.library.categories = this.library.categories.filter((item) => item.id !== category.id);
this.selectedCategoryId = this.library.categories[0]?.id || "";
this.selectedPartId = this.currentCategory()?.parts?.[0]?.id || "";
this.rememberUiState();
this.markDirty();
this.render();
}
deletePart(partId) {
const category = this.currentCategory();
const part = category?.parts?.find((item) => item.id === partId);
if (!category || !part) return;
if (!confirm(`プロンプト「${part.label}」を削除しますか?`)) return;
category.parts = category.parts.filter((item) => item.id !== part.id);
this.selectedPartId = category.parts[0]?.id || "";
this.markDirty();
this.render();
}
exportLibrary() {
const blob = new Blob([JSON.stringify(this.library, null, 2)], { type: "application/json" });
const url = URL.createObjectURL(blob);
const anchor = document.createElement("a");
anchor.href = url;
anchor.download = `visual_prompt_library_${this.libraryName}.json`;
document.body.appendChild(anchor);
anchor.click();
anchor.remove();
URL.revokeObjectURL(url);
}
importLibraryFile(file) {
if (!file) return;
const reader = new FileReader();
reader.onerror = () => this.setStatus("インポートファイルの読み込みに失敗");
reader.onload = () => {
try {
const incoming = JSON.parse(String(reader.result || ""));
const result = mergeLibrary(this.library, incoming);
this.markDirty();
this.setStatus(`インポート済み: プロンプト ${result.addedParts} 件 / カテゴリ ${result.addedCategories} 件`);
} catch (error) {
this.setStatus(`インポート失敗: ${error.message}`);
}
};
reader.readAsText(file);
}
closeModal(overlay) {
overlay?.remove();
}
showCategoryModal(category = null) {
const isEdit = Boolean(category);
const draft = {
label: category?.label || "",
};
const overlay = this.createModal(isEdit ? "カテゴリタグを編集" : "カテゴリタグを追加");
const modal = overlay.firstElementChild;
const field = this.createField("カテゴリタグ", "input", draft.label, "カテゴリタグ");
field.input.oninput = () => { draft.label = field.input.value; };
modal.appendChild(field.wrap);
modal.appendChild(this.createModalActions(overlay, () => {
const label = draft.label.trim() || "新規カテゴリタグ";
if (isEdit) {
category.label = label;
this.selectedCategoryId = category.id;
} else {
const id = uniqueId(label, (candidate) => this.library.categories.some((item) => item.id === candidate));
this.library.categories.push({ id, label, parts: [] });
this.selectedCategoryId = id;
this.selectedPartId = "";
}
this.rememberUiState();
this.markDirty();
this.closeModal(overlay);
this.render();
}));
document.body.appendChild(overlay);
field.input.focus();
}
showLibraryModal() {
const draft = { name: "" };
const overlay = this.createModal("DBを追加");
const modal = overlay.firstElementChild;
const field = this.createField("DB名", "input", "", "my_prompt_library");
field.input.oninput = () => { draft.name = field.input.value; };
modal.appendChild(field.wrap);
modal.appendChild(this.createModalActions(overlay, () => {
const name = draft.name.trim();
if (!name) return;
this.closeModal(overlay);
this.createLibrary(name);
}));
document.body.appendChild(overlay);
field.input.focus();
}
showPromptModal(part = null) {
const category = this.currentCategory();
if (!category) return;
const isEdit = Boolean(part);
const draft = {
label: part?.label || "",
prompt: part?.prompt || "",
thumbnail: part?.thumbnail || "",
};
const overlay = this.createModal(isEdit ? "プロンプトタグを編集" : "プロンプトタグを追加");
const modal = overlay.firstElementChild;
const labelField = this.createField("ラベル", "input", draft.label, "ラベル");
labelField.input.oninput = () => { draft.label = labelField.input.value; };
modal.appendChild(labelField.wrap);
const promptField = this.createField("プロンプト", "textarea", draft.prompt, "プロンプト");
promptField.input.oninput = () => { draft.prompt = promptField.input.value; };
modal.appendChild(promptField.wrap);
const thumbRow = document.createElement("div");
thumbRow.className = "vplm-thumb-row";
const thumbField = this.createField("サムネイルパスまたはdata URL", "input", draft.thumbnail, "サムネイルパスまたはdata URL");
const thumb = document.createElement("img");
thumb.className = "vplm-thumb";
if (draft.thumbnail) thumb.src = draft.thumbnail;
const thumbPreview = document.createElement("div");
thumbPreview.className = "vplm-thumb-preview";
const clearThumb = document.createElement("button");
clearThumb.className = "vplm-btn vplm-thumb-clear";
clearThumb.type = "button";
clearThumb.textContent = "削除";
clearThumb.title = "サムネイルを削除";
clearThumb.disabled = !draft.thumbnail;
thumbField.input.oninput = () => {
draft.thumbnail = thumbField.input.value;
thumb.src = draft.thumbnail || "";
clearThumb.disabled = !draft.thumbnail;
};
clearThumb.onclick = (event) => {
event.stopPropagation();
draft.thumbnail = "";
thumbField.input.value = "";
thumb.removeAttribute("src");
clearThumb.disabled = true;
};
thumbRow.appendChild(thumbField.wrap);
thumbPreview.appendChild(thumb);
thumbPreview.appendChild(clearThumb);
thumbRow.appendChild(thumbPreview);
modal.appendChild(thumbRow);
const fileRow = document.createElement("div");
fileRow.className = "vplm-row";
const file = document.createElement("input");
file.type = "file";
file.accept = "image/*";
file.onchange = async () => {
const imageFile = file.files?.[0];
if (!imageFile) return;
draft.thumbnail = await resizeImageFileToDataUrl(imageFile);
thumbField.input.value = draft.thumbnail;
thumb.src = draft.thumbnail;
clearThumb.disabled = false;
};
fileRow.appendChild(file);
modal.appendChild(fileRow);
const outputPanel = document.createElement("div");
outputPanel.className = "vplm-output-panel";
const outputActions = document.createElement("div");
outputActions.className = "vplm-row";
const recentButton = document.createElement("button");
recentButton.className = "vplm-btn";
recentButton.type = "button";
recentButton.textContent = "最近の出力から選択";
const openOutputButton = document.createElement("button");
openOutputButton.className = "vplm-btn";
openOutputButton.type = "button";
openOutputButton.textContent = "出力フォルダを開く";
const outputGrid = document.createElement("div");
outputGrid.className = "vplm-output-grid";
outputGrid.style.display = "none";
recentButton.onclick = async (event) => {
event.stopPropagation();
await this.loadRecentOutputs(outputGrid, async (item) => {
draft.thumbnail = await resizeImageUrlToDataUrl(item.url);
thumbField.input.value = draft.thumbnail;
thumb.src = draft.thumbnail;
clearThumb.disabled = false;
});
};
openOutputButton.onclick = async (event) => {
event.stopPropagation();
try {
await fetchJson(`${API_ROOT}/open_output_folder`, { method: "POST" });
} catch (error) {
outputGrid.style.display = "";
outputGrid.textContent = `出力フォルダを開けません: ${error.message}\nこの機能はComfyUI本体の再起動後に有効になります。`;
}
};
outputActions.appendChild(recentButton);
outputActions.appendChild(openOutputButton);
outputPanel.appendChild(outputActions);
outputPanel.appendChild(outputGrid);
modal.appendChild(outputPanel);
modal.appendChild(this.createModalActions(overlay, () => {
const label = draft.label.trim() || "新規プロンプト";
if (isEdit) {
part.label = label;
part.prompt = draft.prompt;
part.thumbnail = draft.thumbnail;
this.selectedPartId = part.id;
} else {
const id = uniqueId(label || draft.prompt || "prompt", (candidate) => category.parts.some((item) => item.id === candidate));
category.parts.push({ id, label, prompt: draft.prompt, negative: "", thumbnail: draft.thumbnail });
this.selectedPartId = id;
}
this.markDirty();
this.closeModal(overlay);
this.render();
}));
document.body.appendChild(overlay);
labelField.input.focus();
}
async loadRecentOutputs(container, onSelect) {
container.style.display = "";
container.textContent = "読み込み中...";
try {
let images = [];
let apiError = null;
try {
const payload = await fetchJson(`${API_ROOT}/recent_outputs?limit=80`);
images = Array.isArray(payload.images) ? payload.images : [];
} catch (error) {
apiError = error;
try {
images = await getRecentOutputsFromHistory(80);
} catch (historyError) {
throw new Error(`${apiError.message} / 履歴APIも取得できません: ${historyError.message}`);
}
}
container.innerHTML = "";
if (!images.length) {
container.textContent = apiError
? `最近の出力画像が見つかりません。出力フォルダAPIは未使用です: ${apiError.message}`
: "最近の出力画像が見つかりません。";
return;
}
images.forEach((item) => {
const button = document.createElement("button");
button.type = "button";
button.className = "vplm-output-item";
button.title = item.subfolder ? `${item.subfolder}/${item.filename}` : item.filename;
const image = document.createElement("img");
image.src = item.url;
image.alt = item.filename || "";
const name = document.createElement("div");
name.className = "vplm-output-name";
name.textContent = item.filename || "";
button.appendChild(image);
button.appendChild(name);
button.onclick = async (event) => {
event.stopPropagation();
button.disabled = true;
try {
await onSelect(item);
} finally {
button.disabled = false;
}
};
container.appendChild(button);
});
} catch (error) {
container.textContent = `最近の出力を取得できません: ${error.message}`;
}
}
createModal(titleText) {
const overlay = document.createElement("div");
overlay.className = "vplm-overlay";
["pointerdown", "pointerup", "mousedown", "mouseup", "click", "dblclick"].forEach((eventType) => {
overlay.addEventListener(eventType, (event) => event.stopPropagation());
});
overlay.onclick = (event) => {
if (event.target === overlay) this.closeModal(overlay);
};
const modal = document.createElement("div");
modal.className = "vplm-modal";
const title = document.createElement("div");
title.className = "vplm-title";
title.textContent = titleText;
modal.appendChild(title);
overlay.appendChild(modal);
return overlay;
}
createField(labelText, type, value, placeholder) {
const wrap = document.createElement("div");
wrap.className = "vplm-field";
const label = document.createElement("label");
label.textContent = labelText;
const input = type === "textarea" ? document.createElement("textarea") : document.createElement("input");
input.className = type === "textarea" ? "vplm-textarea" : "vplm-input";
input.value = value || "";
input.placeholder = placeholder || "";
protectTextEntry(input);
wrap.appendChild(label);
wrap.appendChild(input);
return { wrap, input };
}
createModalActions(overlay, onSave) {
const actions = document.createElement("div");
actions.className = "vplm-modal-actions";
const cancel = document.createElement("button");
cancel.className = "vplm-btn";
cancel.textContent = "キャンセル";
cancel.onclick = (event) => {
keepEventInsideModal(event);
this.closeModal(overlay);
};
const save = document.createElement("button");
save.className = "vplm-btn";
save.textContent = "保存";
save.onclick = (event) => {
keepEventInsideModal(event);
onSave();
};
actions.appendChild(cancel);
actions.appendChild(save);
return actions;
}
render() {
const el = this.element;
el.innerHTML = "";
const header = document.createElement("div");
header.className = "vplm-row";
const title = document.createElement("div");
title.className = "vplm-title";
title.textContent = "ビジュアルプロンプトライブラリ";
header.appendChild(title);
if (this.dirty) {
const badge = document.createElement("span");
badge.className = "vplm-badge";
badge.textContent = "未保存";
header.appendChild(badge);
}
el.appendChild(header);
const dbPanel = document.createElement("div");
dbPanel.className = "vplm-panel vplm-section";
const dbTitle = document.createElement("div");
dbTitle.className = "vplm-section-title";
const dbTitleText = document.createElement("span");
dbTitleText.textContent = "1. DB";
const dbTitleActions = document.createElement("div");
dbTitleActions.className = "vplm-title-actions";
const addDb = document.createElement("button");
addDb.className = "vplm-btn vplm-btn-add";
addDb.textContent = "+ 追加";
addDb.onclick = () => this.openLibraryModal();
dbTitleActions.appendChild(addDb);
dbTitle.appendChild(dbTitleText);
dbTitle.appendChild(dbTitleActions);
dbPanel.appendChild(dbTitle);
const dbHelp = document.createElement("div");
dbHelp.className = "vplm-help";
dbHelp.textContent = "DBを選ぶとすぐ読み込みます。編集内容は自動保存されます。";
dbPanel.appendChild(dbHelp);
const dbRow = document.createElement("div");
dbRow.className = "vplm-row vplm-db-row";
const selectField = document.createElement("div");
selectField.className = "vplm-field vplm-grow";
const selectLabel = document.createElement("label");
selectLabel.textContent = "DB";
const select = document.createElement("select");
select.className = "vplm-select";
this.libraries.forEach((library) => {
const option = document.createElement("option");
option.value = library.name;
option.textContent = `${library.label || library.name} (${library.categories}/${library.parts})`;
option.selected = library.name === this.libraryName;
select.appendChild(option);
});
if (!this.libraries.length) {
const option = document.createElement("option");
option.value = this.libraryName;
option.textContent = this.libraryName;
select.appendChild(option);
}
select.onchange = () => {
this.libraryName = select.value || "default";
this.selectedCategoryId = this.uiState.categoryByLibrary?.[this.libraryName] || "";
this.selectedPartId = "";
this.rememberUiState();
this.loadLibrary();
};
selectField.appendChild(selectLabel);
selectField.appendChild(select);
dbRow.appendChild(selectField);
const deleteDb = document.createElement("button");
deleteDb.className = "vplm-icon-btn vplm-icon-delete vplm-icon-btn-danger";
deleteDb.appendChild(createIconSvg("delete"));
deleteDb.title = "DBを削除";
deleteDb.disabled = this.libraryName === "default";
deleteDb.onclick = () => this.deleteLibrary();
dbRow.appendChild(deleteDb);
dbPanel.appendChild(dbRow);
const dbButtons = document.createElement("div");
dbButtons.className = "vplm-row";
[
["エクスポート", () => this.exportLibrary()],
].forEach(([label, onClick]) => {
const button = document.createElement("button");
button.className = "vplm-btn";
button.textContent = label;
button.onclick = onClick;
dbButtons.appendChild(button);
});
const importInput = document.createElement("input");
importInput.type = "file";
importInput.accept = "application/json,.json";
importInput.className = "vplm-hidden";
importInput.onchange = () => {
this.importLibraryFile(importInput.files?.[0]);
importInput.value = "";
};
const importButton = document.createElement("button");
importButton.className = "vplm-btn";
importButton.textContent = "インポート";
importButton.onclick = () => importInput.click();
dbButtons.appendChild(importButton);
dbButtons.appendChild(importInput);
dbPanel.appendChild(dbButtons);
const status = document.createElement("div");
status.className = "vplm-status";
status.textContent = this.status;
dbPanel.appendChild(status);
el.appendChild(dbPanel);
const categoryPanel = document.createElement("div");
categoryPanel.className = "vplm-panel vplm-section";
const categoryTitle = document.createElement("div");
categoryTitle.className = "vplm-section-title";
const categoryTitleText = document.createElement("span");
categoryTitleText.textContent = "2. カテゴリタグ";
const categoryTitleActions = document.createElement("div");
categoryTitleActions.className = "vplm-title-actions";
const categoryCount = document.createElement("span");
categoryCount.className = "vplm-badge";
categoryCount.textContent = String(this.library.categories.length);
const addCategory = document.createElement("button");
addCategory.className = "vplm-btn vplm-btn-add";
addCategory.textContent = "+ 追加";
addCategory.onclick = () => this.openCategoryModal();
categoryTitleActions.appendChild(categoryCount);
categoryTitleActions.appendChild(addCategory);
categoryTitle.appendChild(categoryTitleText);
categoryTitle.appendChild(categoryTitleActions);
categoryPanel.appendChild(categoryTitle);
const categoryHelp = document.createElement("div");
categoryHelp.className = "vplm-help";
categoryHelp.textContent = "カテゴリタグを選んで、配下のプロンプトタグを管理します。";
categoryPanel.appendChild(categoryHelp);
const categoryScroll = document.createElement("div");
categoryScroll.className = "vplm-list";
this.library.categories.forEach((category) => {
const item = document.createElement("div");
item.className = "vplm-item";
item.dataset.active = String(category.id === this.selectedCategoryId);
item.onclick = () => {
this.selectedCategoryId = category.id;
this.selectedPartId = category.parts[0]?.id || "";
this.rememberUiState();
this.render();
};
const head = document.createElement("div");
head.className = "vplm-item-head";
const text = document.createElement("div");
const label = document.createElement("div");
label.className = "vplm-item-title";
label.textContent = category.label;
const sub = document.createElement("div");
sub.className = "vplm-item-sub";
sub.textContent = `${category.parts?.length || 0} 件`;
text.appendChild(label);
text.appendChild(sub);
const actions = document.createElement("div");
actions.className = "vplm-item-actions";
const edit = document.createElement("button");
edit.className = "vplm-icon-btn vplm-icon-edit";
edit.appendChild(createIconSvg("edit"));
edit.title = "カテゴリを編集";
edit.onclick = (event) => {
event.stopPropagation();
this.openCategoryModal(category);
};
const del = document.createElement("button");
del.className = "vplm-icon-btn vplm-icon-delete vplm-icon-btn-danger";
del.appendChild(createIconSvg("delete"));
del.title = "カテゴリを削除";
del.onclick = (event) => {
event.stopPropagation();
this.deleteCategory(category.id);
};
actions.appendChild(edit);
actions.appendChild(del);
head.appendChild(text);
head.appendChild(actions);
item.appendChild(head);
categoryScroll.appendChild(item);
});
if (!this.library.categories.length) {
const empty = document.createElement("div");
empty.className = "vplm-empty";
empty.textContent = "カテゴリはまだありません。";
categoryScroll.appendChild(empty);
}
categoryPanel.appendChild(categoryScroll);
el.appendChild(categoryPanel);
const promptPanel = document.createElement("div");
promptPanel.className = "vplm-panel vplm-section";
const promptTitle = document.createElement("div");
promptTitle.className = "vplm-section-title";
const category = this.currentCategory();
const categoryLabel = category?.label || "カテゴリ未選択";
const promptTitleText = document.createElement("span");
promptTitleText.textContent = "3. プロンプトタグ";
const promptTitleActions = document.createElement("div");
promptTitleActions.className = "vplm-title-actions";
const promptBadge = document.createElement("span");
promptBadge.className = "vplm-badge";
promptBadge.textContent = categoryLabel;
promptTitleActions.appendChild(promptBadge);
if (category) {
const viewButton = document.createElement("button");
viewButton.className = "vplm-btn";
viewButton.textContent = `表示: ${this.viewMode === "large" ? "大" : (this.viewMode === "small" ? "小" : "テキスト")}`;
viewButton.title = "表示切替: 大 / 小 / テキスト";
viewButton.onclick = () => {
this.viewMode = this.viewMode === "large" ? "small" : (this.viewMode === "small" ? "text" : "large");
this.rememberUiState();
this.render();
};
promptTitleActions.appendChild(viewButton);
const addPart = document.createElement("button");
addPart.className = "vplm-btn vplm-btn-add";
addPart.textContent = "+ 追加";
addPart.onclick = () => this.openPromptModal();
promptTitleActions.appendChild(addPart);
}
promptTitle.appendChild(promptTitleText);
promptTitle.appendChild(promptTitleActions);
promptPanel.appendChild(promptTitle);
if (category) {
const searchInput = document.createElement("input");
searchInput.className = "vplm-input";
searchInput.placeholder = "選択中カテゴリ内を検索";
searchInput.value = this.search;
searchInput.oninput = () => {
this.search = searchInput.value;
this.render();
};
promptPanel.appendChild(searchInput);
const partScroll = document.createElement("div");
partScroll.className = "vplm-prompt-list";
partScroll.dataset.viewMode = this.viewMode;
category.parts
.filter((part) => {
const q = this.search.trim().toLowerCase();
if (!q) return true;
return [part.label, part.prompt, part.id].join(" ").toLowerCase().includes(q);
})
.forEach((part) => {
const item = document.createElement("div");
item.className = "vplm-prompt-card";
item.dataset.active = String(part.id === this.selectedPartId);
item.onclick = () => {
this.selectedPartId = part.id;
this.render();
};
const thumbWrap = document.createElement("div");
thumbWrap.className = "vplm-prompt-thumb";
if (this.viewMode !== "text") {
if (part.thumbnail) {
const img = document.createElement("img");
img.src = part.thumbnail;
img.alt = part.label || "";
thumbWrap.appendChild(img);
} else {
const fallback = document.createElement("span");
fallback.textContent = "サムネなし";
thumbWrap.appendChild(fallback);
}
item.appendChild(thumbWrap);
}
const body = document.createElement("div");
body.className = "vplm-prompt-body";
const text = document.createElement("div");
const label = document.createElement("div");
label.className = "vplm-item-title";
label.textContent = part.label;
const sub = document.createElement("div");
sub.className = "vplm-item-sub";
sub.textContent = part.prompt;
text.appendChild(label);
text.appendChild(sub);
const actions = document.createElement("div");
actions.className = "vplm-item-actions";
const edit = document.createElement("button");
edit.className = "vplm-icon-btn vplm-icon-edit";
edit.appendChild(createIconSvg("edit"));
edit.title = "プロンプトを編集";
edit.onclick = (event) => {
event.stopPropagation();
this.selectedPartId = part.id;
this.openPromptModal(part);
};
const del = document.createElement("button");
del.className = "vplm-icon-btn vplm-icon-delete vplm-icon-btn-danger";
del.appendChild(createIconSvg("delete"));
del.title = "プロンプトを削除";
del.onclick = (event) => {
event.stopPropagation();
this.deletePart(part.id);
};
actions.appendChild(edit);
actions.appendChild(del);
body.appendChild(text);
body.appendChild(actions);
item.appendChild(body);
partScroll.appendChild(item);
});
if (!partScroll.childElementCount) {
const empty = document.createElement("div");
empty.className = "vplm-empty";
empty.textContent = this.search ? "一致するプロンプトはありません。" : "プロンプトはまだありません。";
partScroll.appendChild(empty);
}
promptPanel.appendChild(partScroll);
} else {
const empty = document.createElement("div");
empty.className = "vplm-empty";
empty.textContent = "先にカテゴリを作成または選択してください。";
promptPanel.appendChild(empty);
}
el.appendChild(promptPanel);
}
}
app.registerExtension({
name: "ComfyUI.VisualPromptLibrary.Manager",
setup() {
const manager = new VisualPromptLibraryManager();
app.VisualPromptLibraryManager = manager;
if (app.extensionManager?.registerSidebarTab) {
app.extensionManager.registerSidebarTab({
id: "VisualPromptLibrary.Manager",
icon: "pi pi-images",
title: "ビジュアルプロンプトライブラリ",
tooltip: "ビジュアルプロンプトライブラリ",
type: "custom",
render: (el) => {
el.appendChild(manager.element);
manager.refreshLibraries();
},
});
} else {
console.warn("VisualPromptLibrary: registerSidebarTab is not available in this ComfyUI build.");
}
},
});