1511 lines
56 KiB
JavaScript
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.");
|
|
}
|
|
},
|
|
});
|