Files

1615 lines
57 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { app } from "../../scripts/app.js";
const STYLE_ID = "visual-prompt-picker-style";
const VPL_API_ROOT = "/visual_prompt_library";
function ensureStyle() {
if (document.getElementById(STYLE_ID)) return;
const style = document.createElement("style");
style.id = STYLE_ID;
style.textContent = `
.vpl-root {
position: relative;
box-sizing: border-box;
width: 100%;
max-width: 100%;
min-width: 0;
height: 620px;
display: flex;
flex-direction: column;
gap: 10px;
padding: 10px;
color: #f5f5f5;
background: #101113;
border: 1px solid #2c2f34;
border-radius: 8px;
font-family: Arial, sans-serif;
overflow: hidden;
}
.vpl-root[data-compact="true"] .vpl-header {
display: grid;
grid-template-columns: 1fr;
}
.vpl-root[data-compact="true"] .vpl-actions {
justify-content: flex-start;
}
.vpl-root[data-compact="true"] .vpl-search-row {
grid-template-columns: 1fr auto;
}
.vpl-root[data-compact="true"] .vpl-grid {
grid-template-columns: 1fr;
}
.vpl-root[data-compact="true"] .vpl-card {
display: grid;
grid-template-columns: 58px minmax(0, 1fr);
min-height: 58px;
}
.vpl-root[data-compact="true"] .vpl-thumb {
height: 58px;
min-width: 58px;
}
.vpl-root[data-compact="true"] .vpl-card-body {
min-height: 58px;
border-top: none;
justify-content: center;
}
.vpl-root[data-compact="true"] .vpl-placeholder {
width: 42px;
height: 42px;
font-size: 0;
}
.vpl-root[data-view-mode="small"] .vpl-grid {
grid-template-columns: repeat(auto-fill, minmax(min(180px, 100%), 1fr));
}
.vpl-root[data-view-mode="small"] .vpl-card {
min-height: 236px;
}
.vpl-root[data-view-mode="small"] .vpl-thumb {
height: clamp(156px, 22vw, 196px);
}
.vpl-root[data-view-mode="small"] .vpl-card-body {
min-height: 42px;
}
.vpl-root[data-view-mode="small"] .vpl-placeholder {
width: 76px;
height: 76px;
}
.vpl-root[data-view-mode="text"] .vpl-thumb {
display: none;
}
.vpl-root[data-view-mode="text"] .vpl-grid {
grid-template-columns: 1fr;
}
.vpl-root[data-view-mode="text"] .vpl-card {
min-height: 34px;
display: block;
}
.vpl-root[data-view-mode="text"] .vpl-card-body {
min-height: 34px;
padding: 0 10px;
border-top: none;
justify-content: center;
}
.vpl-root[data-view-mode="text"] .vpl-label {
line-height: 34px;
}
.vpl-root[data-view-mode="text"] .vpl-card-meta {
display: none;
}
.vpl-header {
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
gap: 8px;
padding-bottom: 8px;
border-bottom: 1px solid #2c2f34;
}
.vpl-title {
font-size: 13px;
font-weight: 700;
min-width: 0;
}
.vpl-clear {
border: 1px solid #3b4048;
background: #1c1f24;
color: #f5f5f5;
border-radius: 5px;
padding: 5px 8px;
font-size: 11px;
cursor: pointer;
white-space: nowrap;
max-width: 100%;
}
.vpl-root[style*="width: 1"] .vpl-clear {
padding-left: 5px;
padding-right: 5px;
}
.vpl-clear:hover {
background: #252a31;
}
.vpl-clear:disabled {
opacity: 0.45;
cursor: not-allowed;
}
.vpl-actions {
display: flex;
flex-wrap: wrap;
gap: 6px;
align-items: center;
justify-content: flex-end;
min-width: 0;
}
.vpl-action-group {
display: flex;
flex-wrap: wrap;
gap: 6px;
align-items: center;
padding-left: 7px;
border-left: 1px solid #2c2f34;
}
.vpl-action-group:first-child {
padding-left: 0;
border-left: none;
}
.vpl-tool-panel {
display: flex;
flex-wrap: wrap;
gap: 6px;
align-items: center;
padding: 7px;
border: 1px solid #2c2f34;
border-radius: 6px;
background: #15181d;
flex: 0 0 auto;
}
.vpl-library-select,
.vpl-library-name {
box-sizing: border-box;
height: 26px;
min-width: 0;
border: 1px solid #3b4048;
border-radius: 5px;
padding: 4px 7px;
color: #f5f5f5;
background: #101318;
font-size: 11px;
outline: none;
}
.vpl-library-select {
width: min(180px, 100%);
}
.vpl-library-name {
width: 110px;
}
.vpl-add-panel {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 6px;
padding: 8px;
border: 1px solid #2c2f34;
border-radius: 7px;
background: #15181d;
}
.vpl-add-panel textarea,
.vpl-add-panel input,
.vpl-add-panel select {
box-sizing: border-box;
width: 100%;
border: 1px solid #3b4048;
border-radius: 5px;
padding: 5px 7px;
color: #f5f5f5;
background: #101318;
font-size: 11px;
outline: none;
}
.vpl-add-panel textarea {
min-height: 44px;
resize: vertical;
grid-column: 1 / -1;
}
.vpl-add-panel .vpl-add-buttons {
grid-column: 1 / -1;
display: flex;
justify-content: flex-end;
gap: 6px;
}
.vpl-add-panel input[name="category_label"] {
grid-column: 1 / -1;
}
.vpl-add-panel .vpl-wide {
grid-column: 1 / -1;
}
.vpl-file-row {
grid-column: 1 / -1;
display: grid;
grid-template-columns: 1fr auto;
gap: 6px;
align-items: center;
}
.vpl-file-preview {
width: 42px;
height: 42px;
border: 1px solid #3b4048;
border-radius: 5px;
background: #101318;
object-fit: contain;
}
.vpl-overlay {
position: absolute;
inset: 10px;
z-index: 20;
display: flex;
align-items: flex-start;
justify-content: center;
padding-top: 38px;
background: rgba(0,0,0,0.54);
border-radius: 8px;
}
.vpl-modal {
width: min(560px, calc(100% - 24px));
max-height: calc(100% - 48px);
overflow-y: auto;
border: 1px solid #3b4048;
border-radius: 8px;
background: #15181d;
box-shadow: 0 14px 34px rgba(0,0,0,0.45);
padding: 10px;
}
.vpl-modal-title {
font-size: 13px;
font-weight: 700;
margin-bottom: 8px;
}
.vpl-categories {
display: flex;
flex-wrap: wrap;
gap: 6px;
min-width: 0;
flex: 1 1 auto;
max-height: 54px;
overflow-y: auto;
padding-right: 3px;
}
.vpl-category-row {
display: flex;
align-items: center;
gap: 6px;
min-width: 0;
flex: 0 0 auto;
}
.vpl-search-row {
display: grid;
grid-template-columns: minmax(0, 1fr) auto auto auto;
gap: 6px;
align-items: center;
flex: 0 0 auto;
}
.vpl-search-input {
box-sizing: border-box;
min-width: 0;
width: 100%;
height: 28px;
border: 1px solid #3b4048;
border-radius: 5px;
padding: 5px 8px;
color: #f5f5f5;
background: #101318;
font-size: 11px;
outline: none;
}
.vpl-search-input:focus {
border-color: #4e8cc4;
box-shadow: 0 0 0 1px rgba(78, 140, 196, 0.45);
}
.vpl-tab {
flex: 0 0 auto;
border: 1px solid #3b4048;
background: #191c20;
color: #d8dbe0;
border-radius: 5px;
padding: 4px 6px;
font-size: 11px;
cursor: default;
display: inline-flex;
align-items: center;
gap: 5px;
max-width: 100%;
}
.vpl-tab[data-active="true"] {
background: #2e5f8f;
border-color: #4e8cc4;
color: #ffffff;
}
.vpl-tab input {
margin: 0;
}
.vpl-tab-label {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.vpl-tab-delete {
width: 18px;
height: 18px;
border: 1px solid rgba(255,255,255,0.18);
border-radius: 4px;
background: rgba(10,12,14,0.42);
color: #ffffff;
cursor: pointer;
font-size: 14px;
line-height: 1;
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0;
}
.vpl-tab-delete:hover {
background: rgba(92,34,34,0.75);
}
.vpl-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(min(240px, 100%), 1fr));
grid-auto-rows: max-content;
gap: 8px;
overflow-y: auto;
padding-right: 4px;
flex: 1 1 auto;
min-height: 0;
align-content: start;
}
.vpl-card {
position: relative;
min-width: 0;
border: 1px solid #333943;
background: #171a1f;
border-radius: 7px;
overflow: hidden;
cursor: pointer;
user-select: none;
display: flex;
flex-direction: column;
min-height: 300px;
}
.vpl-card-tool {
position: absolute;
top: 6px;
width: 24px;
height: 24px;
border: 1px solid rgba(255,255,255,0.22);
border-radius: 5px;
background: rgba(10,12,14,0.76);
color: #ffffff;
cursor: pointer;
font-size: 13px;
line-height: 1;
opacity: 0;
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0;
}
.vpl-edit-card {
right: 36px;
}
.vpl-delete-card {
right: 7px;
font-size: 17px;
}
.vpl-card:hover .vpl-card-tool {
opacity: 1;
}
.vpl-card[data-selected="true"] {
border-color: #68b3ff;
box-shadow: 0 0 0 2px rgba(104, 179, 255, 0.25);
background: #1d2732;
}
.vpl-thumb {
height: clamp(220px, 28vw, 260px);
flex: 0 0 auto;
display: flex;
align-items: center;
justify-content: center;
background:
linear-gradient(135deg, rgba(255,255,255,0.10), rgba(255,255,255,0.01)),
#22262d;
overflow: hidden;
}
.vpl-thumb img {
width: 100%;
height: 100%;
object-fit: contain;
display: block;
}
.vpl-placeholder {
width: 76px;
height: 76px;
border-radius: 50%;
border: 2px solid rgba(255,255,255,0.35);
display: flex;
align-items: center;
justify-content: center;
text-align: center;
font-size: 11px;
line-height: 1.25;
color: rgba(255,255,255,0.82);
padding: 6px;
box-sizing: border-box;
}
.vpl-card-body {
padding: 7px 8px 8px;
display: flex;
flex-direction: column;
gap: 4px;
min-height: 34px;
background: #11151b;
border-top: 1px solid rgba(255,255,255,0.05);
flex: 0 0 auto;
}
.vpl-label {
font-size: 12px;
font-weight: 700;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.vpl-card-meta {
font-size: 10px;
color: #9fb0c3;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.vpl-empty {
border: 1px dashed #333943;
border-radius: 7px;
padding: 16px;
color: #9fb0c3;
font-size: 11px;
text-align: center;
background: #11151b;
}
`;
document.head.appendChild(style);
}
function parseLibrary(nodeData, node) {
const candidates = [
node.widgets?.find((w) => w.name === "library_json")?.value,
nodeData?.input?.hidden?.library_json?.[1]?.default,
];
for (const raw of candidates) {
if (!raw) continue;
try {
const parsed = JSON.parse(raw);
if (Array.isArray(parsed?.categories)) return parsed;
} catch (error) {
console.error("VisualPromptPicker: failed to parse library_json", error);
}
}
return { version: 1, categories: [] };
}
function resizeImageFileToDataUrl(file, 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;
const ctx = canvas.getContext("2d");
ctx.drawImage(image, 0, 0, width, height);
resolve(canvas.toDataURL("image/webp", 0.82));
};
image.src = reader.result;
};
reader.readAsDataURL(file);
});
}
function normalizePart(category, part) {
return {
id: part.id || part.label || part.prompt,
category: category.id || category.label || "default",
categoryLabel: category.label || category.id || "Default",
label: part.label || part.id || part.prompt || "Untitled",
prompt: part.prompt || "",
negative: part.negative || "",
thumbnail: part.thumbnail || "",
};
}
function slugify(value) {
const text = String(value || "")
.normalize("NFKC")
.trim()
.toLowerCase();
let normalized = "";
for (const char of text) {
if (/^[a-z0-9_-]$/.test(char) || (char.charCodeAt(0) > 127 && !/[\\/:*?"<>|#%&{}$!`'@+=,;[\]\s]/.test(char))) {
normalized += char;
} else {
normalized += "_";
}
}
normalized = normalized.replace(/^_+|_+$/g, "").replace(/_+/g, "_");
if (normalized) return normalized;
let hash = 0;
const hashText = String(value || "part");
for (let i = 0; i < hashText.length; i += 1) {
hash = ((hash << 5) - hash + hashText.charCodeAt(i)) | 0;
}
return `part_${Math.abs(hash)}`;
}
function ensureUniqueId(baseId, exists) {
const base = slugify(baseId || "item");
let id = base;
let index = 2;
while (exists(id)) {
id = `${base}_${index}`;
index += 1;
}
return id;
}
function sanitizeLibraryName(value) {
return slugify(value || "default");
}
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 payload = text ? JSON.parse(text) : {};
if (!response.ok) {
throw new Error(payload.error || `HTTP ${response.status}`);
}
return payload;
}
function createPickerWidget(node, nodeData) {
ensureStyle();
let library = parseLibrary(nodeData, node);
let activeCategory = library.categories[0]?.id || library.categories[0]?.label || "";
let selected = [];
let showPromptPanel = false;
let showCategoryPanel = false;
let editingPart = null;
let viewMode = "large";
let searchQuery = "";
let visibleCategoryIds = new Set(library.categories.map((category) => category.id || category.label));
let categoryFilterTouched = false;
let refocusSearchAfterRender = false;
let showToolMenu = false;
let libraryList = [];
let sharedLibraryName = "default";
let libraryStatus = "";
const root = document.createElement("div");
root.className = "vpl-root";
function syncRootWidth() {
const width = Math.max(1, Math.floor((node.size?.[0] || root.parentElement?.clientWidth || 320) - 24));
root.style.width = `${width}px`;
root.style.maxWidth = `${width}px`;
root.dataset.compact = String(width < 260);
}
let selectedPartsWidget = node.widgets.find((w) => w.name === "selected_parts");
if (!selectedPartsWidget) {
selectedPartsWidget = node.addWidget("text", "selected_parts", "", () => {});
}
selectedPartsWidget.type = "hidden";
selectedPartsWidget.hidden = true;
selectedPartsWidget.serializeValue = function() { return this.value || ""; };
let libraryWidget = node.widgets.find((w) => w.name === "library_json");
if (!libraryWidget) {
const defaultLibrary = nodeData?.input?.hidden?.library_json?.[1]?.default || JSON.stringify({ version: 1, categories: [] });
libraryWidget = node.addWidget("text", "library_json", defaultLibrary, () => {});
}
libraryWidget.type = "hidden";
libraryWidget.hidden = true;
libraryWidget.serializeValue = function() { return this.value || ""; };
const previewWidget = node.widgets?.find((w) => w.name === "preview_prompt");
if (previewWidget) {
previewWidget.serializeValue = function() { return this.value || ""; };
setTimeout(() => {
if (previewWidget.inputEl) {
previewWidget.inputEl.readOnly = true;
previewWidget.inputEl.placeholder = "選択中のプロンプト断片";
}
}, 0);
}
function persistLibrary() {
const payload = JSON.stringify(library);
if (libraryWidget.value !== payload) {
libraryWidget.value = payload;
libraryWidget.callback?.call(libraryWidget, payload);
}
node.setDirtyCanvas(true, false);
}
function syncHidden() {
if (!selectedPartsWidget) return;
const payload = JSON.stringify({ selected });
if (selectedPartsWidget.value !== payload) {
selectedPartsWidget.value = payload;
selectedPartsWidget.callback?.call(selectedPartsWidget, payload);
}
node.setDirtyCanvas(true, false);
}
function restoreFromHidden() {
if (!selectedPartsWidget?.value) return;
try {
const state = JSON.parse(selectedPartsWidget.value);
if (Array.isArray(state.selected)) selected = state.selected;
} catch (error) {
console.error("VisualPromptPicker: failed to restore selected parts", error);
}
}
function isSelected(part) {
return selected.some((item) => item.id === part.id && item.category === part.category);
}
function getCategoryId(category) {
return category?.id || category?.label || "";
}
function syncVisibleCategories() {
const categoryIds = library.categories.map((category) => getCategoryId(category)).filter(Boolean);
const known = new Set(categoryIds);
visibleCategoryIds = new Set([...visibleCategoryIds].filter((id) => known.has(id)));
}
function showAllCategories() {
visibleCategoryIds = new Set(library.categories.map((category) => getCategoryId(category)).filter(Boolean));
}
function replaceLibrary(nextLibrary, { clearSelection = false } = {}) {
if (!nextLibrary?.categories || !Array.isArray(nextLibrary.categories)) return;
library = {
version: nextLibrary.version || 1,
meta: nextLibrary.meta || {},
categories: nextLibrary.categories,
};
if (clearSelection) {
selected = [];
searchQuery = "";
}
categoryFilterTouched = false;
showAllCategories();
persistLibrary();
syncHidden();
syncPreviewWidget();
render();
}
async function refreshLibraryList() {
try {
const payload = await fetchJson(`${VPL_API_ROOT}/libraries`);
libraryList = Array.isArray(payload.libraries) ? payload.libraries : [];
if (!libraryList.some((item) => item.name === sharedLibraryName) && libraryList[0]?.name) {
sharedLibraryName = libraryList[0].name;
}
render();
} catch (error) {
console.error("VisualPromptPicker: failed to list shared libraries", error);
libraryStatus = "DB一覧取得失敗";
render();
}
}
async function loadSharedLibrary(name = sharedLibraryName) {
const safeName = sanitizeLibraryName(name);
try {
const payload = await fetchJson(`${VPL_API_ROOT}/libraries/${encodeURIComponent(safeName)}`);
sharedLibraryName = payload.name || safeName;
libraryStatus = `読み込み済み: ${sharedLibraryName}`;
replaceLibrary(payload.library, { clearSelection: true });
} catch (error) {
console.error("VisualPromptPicker: failed to load shared library", error);
alert(`ライブラリの読み込みに失敗しました: ${error.message}`);
}
}
async function saveSharedLibrary(name = sharedLibraryName) {
const safeName = sanitizeLibraryName(name);
try {
const payload = await fetchJson(`${VPL_API_ROOT}/libraries/${encodeURIComponent(safeName)}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ library }),
});
sharedLibraryName = payload.name || safeName;
libraryStatus = `Saved: ${sharedLibraryName}`;
await refreshLibraryList();
} catch (error) {
console.error("VisualPromptPicker: failed to save shared library", error);
alert(`Failed to save library: ${error.message}`);
}
}
function normalizeSearchText(value) {
return String(value || "").toLowerCase().replace(/[_-]+/g, " ").trim();
}
function partMatchesSearch(part, query) {
if (!query) return true;
const haystack = normalizeSearchText([
part.label,
part.prompt,
part.id,
part.categoryLabel,
].join(" "));
return query.split(/\s+/).every((token) => haystack.includes(token));
}
function getVisibleParts() {
syncVisibleCategories();
const query = normalizeSearchText(searchQuery);
const sourceCategories = library.categories.filter((category) => visibleCategoryIds.has(getCategoryId(category)));
const parts = [];
sourceCategories.forEach((category) => {
(category?.parts || []).forEach((part) => {
const normalized = normalizePart(category, part);
if (partMatchesSearch(normalized, query)) parts.push(normalized);
});
});
return parts;
}
function getDefaultCategoryId() {
syncVisibleCategories();
const visible = library.categories.find((category) => visibleCategoryIds.has(getCategoryId(category)));
return getCategoryId(visible || library.categories[0]);
}
function getDelimiter() {
return node.widgets?.find((w) => w.name === "delimiter")?.value ?? ", ";
}
function buildPromptPreview() {
return selected
.map((item) => item.prompt || "")
.filter(Boolean)
.join(getDelimiter());
}
function syncPreviewWidget() {
const previewWidget = node.widgets?.find((w) => w.name === "preview_prompt");
if (!previewWidget) return;
const text = buildPromptPreview();
if (previewWidget.value !== text) {
previewWidget.value = text;
previewWidget.callback?.call(previewWidget, text);
}
}
function togglePart(part) {
if (isSelected(part)) {
selected = selected.filter((item) => !(item.id === part.id && item.category === part.category));
} else {
selected = [...selected, part];
}
syncHidden();
syncPreviewWidget();
render();
}
function deletePart(part) {
const category = library.categories.find((item) => (item.id || item.label) === part.category);
if (!category || !Array.isArray(category.parts)) return;
category.parts = category.parts.filter((item) => item.id !== part.id);
selected = selected.filter((item) => !(item.id === part.id && item.category === part.category));
persistLibrary();
syncHidden();
syncPreviewWidget();
render();
}
function openPromptEditor(part = null) {
editingPart = part ? { ...part } : null;
showPromptPanel = true;
showCategoryPanel = false;
render();
}
function addCategoryFromForm(form) {
const data = new FormData(form);
const label = String(data.get("category_label") || "").trim();
if (!label) return;
const id = ensureUniqueId(label, (candidate) => library.categories.some((category) => (category.id || category.label) === candidate));
library.categories.push({ id, label, select_mode: "multi", parts: [] });
activeCategory = id;
visibleCategoryIds.add(id);
persistLibrary();
showCategoryPanel = false;
render();
}
function deleteCategory(categoryId) {
const category = library.categories.find((item) => getCategoryId(item) === categoryId);
if (!category) return;
const label = category.label || category.id || "this category";
if (!confirm(`Delete category "${label}" and all prompt parts in it?`)) return;
library.categories = library.categories.filter((item) => getCategoryId(item) !== categoryId);
selected = selected.filter((item) => item.category !== categoryId);
visibleCategoryIds.delete(categoryId);
categoryFilterTouched = true;
activeCategory = library.categories[0]?.id || library.categories[0]?.label || "";
syncVisibleCategories();
persistLibrary();
syncHidden();
syncPreviewWidget();
render();
}
function addPartFromForm(form) {
const data = new FormData(form);
const existingCategoryId = data.get("category") || getDefaultCategoryId();
const label = String(data.get("label") || "").trim();
const prompt = String(data.get("prompt") || "").trim();
const thumbnailFileData = String(data.get("thumbnail_file_data") || "").trim();
const thumbnailManual = String(data.get("thumbnail_manual") || "").trim();
const thumbnail = thumbnailFileData || thumbnailManual;
if (!label || !prompt) return;
let category = null;
category = library.categories.find((item) => (item.id || item.label) === existingCategoryId)
|| library.categories[0];
if (!category) return;
if (!Array.isArray(category.parts)) category.parts = [];
if (editingPart) {
const oldCategory = library.categories.find((item) => (item.id || item.label) === editingPart.category);
const oldPart = oldCategory?.parts?.find((part) => part.id === editingPart.id);
const thumbnail = thumbnailFileData || thumbnailManual || oldPart?.thumbnail || "";
if (oldCategory && oldPart && oldCategory !== category) {
oldCategory.parts = oldCategory.parts.filter((part) => part.id !== editingPart.id);
category.parts.push({ ...oldPart, category: undefined });
}
const target = category.parts.find((part) => part.id === editingPart.id) || oldPart;
if (target) {
target.label = label;
target.prompt = prompt;
target.thumbnail = thumbnail;
}
selected = selected.map((item) => {
if (item.id !== editingPart.id || item.category !== editingPart.category) return item;
return normalizePart(category, target || { id: editingPart.id, label, prompt, thumbnail });
});
activeCategory = category.id || category.label || activeCategory;
persistLibrary();
syncHidden();
syncPreviewWidget();
showPromptPanel = false;
editingPart = null;
render();
return;
}
const idBase = slugify(label);
let id = idBase;
let index = 2;
while (category.parts.some((part) => part.id === id)) {
id = `${idBase}_${index}`;
index += 1;
}
category.parts.push({ id, label, prompt, thumbnail });
persistLibrary();
showPromptPanel = false;
editingPart = null;
render();
}
function normalizeImportedLibrary(raw) {
const categories = Array.isArray(raw?.categories) ? raw.categories : (Array.isArray(raw) ? raw : []);
return {
version: 1,
categories: categories
.map((category) => {
const label = String(category?.label || category?.id || "").trim();
const id = slugify(category?.id || label);
const parts = Array.isArray(category?.parts) ? category.parts : [];
if (!label || !id) return null;
return {
id,
label,
select_mode: category?.select_mode || "multi",
parts: parts
.map((part) => {
const prompt = String(part?.prompt || "").trim();
const label = String(part?.label || part?.id || prompt || "").trim();
if (!label || !prompt) return null;
return {
id: slugify(part?.id || label || prompt),
label,
prompt,
negative: part?.negative || "",
thumbnail: part?.thumbnail || "",
};
})
.filter(Boolean),
};
})
.filter((category) => category && category.parts.length),
};
}
function mergeImportedLibrary(imported) {
const importLibrary = normalizeImportedLibrary(imported);
let addedCategories = 0;
let addedParts = 0;
importLibrary.categories.forEach((incomingCategory) => {
let targetCategory = library.categories.find((category) => (
(category.id || category.label) === incomingCategory.id
|| String(category.label || "").toLowerCase() === incomingCategory.label.toLowerCase()
));
if (!targetCategory) {
const categoryId = ensureUniqueId(incomingCategory.id || incomingCategory.label, (candidate) => (
library.categories.some((category) => (category.id || category.label) === candidate)
));
targetCategory = {
id: categoryId,
label: incomingCategory.label,
select_mode: incomingCategory.select_mode || "multi",
parts: [],
};
library.categories.push(targetCategory);
visibleCategoryIds.add(categoryId);
addedCategories += 1;
}
if (!Array.isArray(targetCategory.parts)) targetCategory.parts = [];
incomingCategory.parts.forEach((incomingPart) => {
const duplicate = targetCategory.parts.some((part) => (
String(part.prompt || "").trim() === incomingPart.prompt
|| String(part.label || "").trim() === incomingPart.label
));
if (duplicate) return;
const partId = ensureUniqueId(incomingPart.id || incomingPart.label || incomingPart.prompt, (candidate) => (
targetCategory.parts.some((part) => part.id === candidate)
));
targetCategory.parts.push({ ...incomingPart, id: partId });
addedParts += 1;
});
});
return { addedCategories, addedParts };
}
function exportLibraryJson() {
const payload = {
...library,
version: library.version || 1,
exported_at: new Date().toISOString(),
};
const blob = new Blob([JSON.stringify(payload, null, 2)], { type: "application/json" });
const url = URL.createObjectURL(blob);
const anchor = document.createElement("a");
const stamp = new Date().toISOString().replace(/[:.]/g, "-");
anchor.href = url;
anchor.download = `visual_prompt_library_${stamp}.json`;
document.body.appendChild(anchor);
anchor.click();
anchor.remove();
URL.revokeObjectURL(url);
}
function importLibraryJsonFile(file) {
if (!file) return;
const reader = new FileReader();
reader.onerror = () => alert("Failed to read JSON file.");
reader.onload = () => {
try {
const parsed = JSON.parse(String(reader.result || ""));
const result = mergeImportedLibrary(parsed);
if (!result.addedCategories && !result.addedParts) {
alert("新しく追加できるプロンプトはありませんでした。");
return;
}
persistLibrary();
activeCategory = library.categories[0]?.id || library.categories[0]?.label || activeCategory;
render();
alert(`インポートしました: プロンプト ${result.addedParts} 件 / カテゴリ ${result.addedCategories} 件`);
} catch (error) {
console.error("VisualPromptPicker: failed to import library JSON", error);
alert("Visual Prompt Library JSONとして読み込めません。");
}
};
reader.readAsText(file);
}
function renderUnsafe() {
if (libraryWidget?.value) {
try {
const parsed = JSON.parse(libraryWidget.value);
if (Array.isArray(parsed?.categories)) {
const beforeIds = library.categories.map((category) => getCategoryId(category)).join("\n");
const afterIds = parsed.categories.map((category) => getCategoryId(category)).join("\n");
library = parsed;
if (!categoryFilterTouched && beforeIds !== afterIds) {
showAllCategories();
}
}
} catch (_) {
// Keep the last valid library.
}
}
root.innerHTML = "";
root.dataset.viewMode = viewMode;
const header = document.createElement("div");
header.className = "vpl-header";
const title = document.createElement("div");
title.className = "vpl-title";
title.textContent = "ビジュアルプロンプト選択";
const clear = document.createElement("button");
clear.className = "vpl-clear";
clear.textContent = "選択クリア";
clear.onclick = (event) => {
event.stopPropagation();
selected = [];
syncHidden();
syncPreviewWidget();
render();
};
const thumbToggle = document.createElement("button");
thumbToggle.className = "vpl-clear";
thumbToggle.textContent = `表示: ${viewMode === "large" ? "大" : (viewMode === "small" ? "小" : "テキスト")}`;
thumbToggle.title = "表示切替: 大 / 小 / テキスト";
thumbToggle.onclick = (event) => {
event.stopPropagation();
viewMode = viewMode === "large" ? "small" : (viewMode === "small" ? "text" : "large");
render();
};
header.appendChild(title);
const actions = document.createElement("div");
actions.className = "vpl-actions";
const fileActions = document.createElement("div");
fileActions.className = "vpl-action-group";
const toolButton = document.createElement("button");
toolButton.className = "vpl-clear";
toolButton.textContent = "\u{1F527}";
toolButton.title = "DB選択";
toolButton.onclick = (event) => {
event.stopPropagation();
showToolMenu = !showToolMenu;
if (showToolMenu) refreshLibraryList();
render();
};
fileActions.appendChild(toolButton);
actions.appendChild(fileActions);
header.appendChild(actions);
root.appendChild(header);
if (showToolMenu) {
const toolPanel = document.createElement("div");
toolPanel.className = "vpl-tool-panel";
toolPanel.onclick = (event) => event.stopPropagation();
const librarySelect = document.createElement("select");
librarySelect.className = "vpl-library-select";
librarySelect.title = "共有DB";
if (!libraryList.length) {
const option = document.createElement("option");
option.value = sharedLibraryName;
option.textContent = `${sharedLibraryName} (local)`;
librarySelect.appendChild(option);
} else {
libraryList.forEach((item) => {
const option = document.createElement("option");
option.value = item.name;
option.textContent = `${item.label || item.name} (${item.categories}/${item.parts})`;
option.selected = item.name === sharedLibraryName;
librarySelect.appendChild(option);
});
}
librarySelect.onchange = (event) => {
event.stopPropagation();
sharedLibraryName = librarySelect.value || "default";
loadSharedLibrary(sharedLibraryName);
};
toolPanel.appendChild(librarySelect);
if (libraryStatus) {
const status = document.createElement("span");
status.className = "vpl-card-meta";
status.textContent = libraryStatus;
toolPanel.appendChild(status);
}
root.appendChild(toolPanel);
}
if (showCategoryPanel) {
const overlay = document.createElement("div");
overlay.className = "vpl-overlay";
overlay.onclick = (event) => {
if (event.target === overlay) {
showCategoryPanel = false;
render();
}
};
const modal = document.createElement("div");
modal.className = "vpl-modal";
const modalTitle = document.createElement("div");
modalTitle.className = "vpl-modal-title";
modalTitle.textContent = "カテゴリ";
const form = document.createElement("form");
form.className = "vpl-add-panel";
form.onsubmit = (event) => {
event.preventDefault();
event.stopPropagation();
addCategoryFromForm(form);
};
const labelInput = document.createElement("input");
labelInput.name = "category_label";
labelInput.placeholder = "カテゴリ名";
const buttons = document.createElement("div");
buttons.className = "vpl-add-buttons";
const save = document.createElement("button");
save.type = "submit";
save.className = "vpl-clear";
save.textContent = "カテゴリを保存";
buttons.appendChild(save);
form.appendChild(labelInput);
form.appendChild(buttons);
modal.appendChild(modalTitle);
modal.appendChild(form);
overlay.appendChild(modal);
root.appendChild(overlay);
}
if (showPromptPanel) {
const overlay = document.createElement("div");
overlay.className = "vpl-overlay";
overlay.onclick = (event) => {
if (event.target === overlay) {
showPromptPanel = false;
editingPart = null;
render();
}
};
const modal = document.createElement("div");
modal.className = "vpl-modal";
const modalTitle = document.createElement("div");
modalTitle.className = "vpl-modal-title";
modalTitle.textContent = editingPart ? "プロンプトを編集" : "プロンプトを追加";
const form = document.createElement("form");
form.className = "vpl-add-panel";
form.onsubmit = (event) => {
event.preventDefault();
event.stopPropagation();
addPartFromForm(form);
};
const categorySelect = document.createElement("select");
categorySelect.name = "category";
const defaultCategoryId = editingPart?.category || getDefaultCategoryId();
library.categories.forEach((category) => {
const option = document.createElement("option");
option.value = category.id || category.label;
option.textContent = category.label || category.id;
option.selected = option.value === defaultCategoryId;
categorySelect.appendChild(option);
});
const labelInput = document.createElement("input");
labelInput.name = "label";
labelInput.placeholder = "Label";
labelInput.value = editingPart?.label || "";
const fileRow = document.createElement("div");
fileRow.className = "vpl-file-row";
const thumbnailFile = document.createElement("input");
thumbnailFile.type = "file";
thumbnailFile.accept = "image/*";
thumbnailFile.title = "ローカル画像を選択";
const thumbnailFileData = document.createElement("input");
thumbnailFileData.type = "hidden";
thumbnailFileData.name = "thumbnail_file_data";
const thumbnailPreview = document.createElement("img");
thumbnailPreview.className = "vpl-file-preview";
thumbnailPreview.alt = "";
if (editingPart?.thumbnail) {
thumbnailPreview.src = editingPart.thumbnail;
}
thumbnailFile.onchange = async () => {
const file = thumbnailFile.files?.[0];
if (!file) {
thumbnailFileData.value = "";
thumbnailPreview.removeAttribute("src");
return;
}
try {
const dataUrl = await resizeImageFileToDataUrl(file);
thumbnailFileData.value = dataUrl;
thumbnailPreview.src = dataUrl;
} catch (error) {
console.error("VisualPromptPicker: failed to read thumbnail file", error);
thumbnailFileData.value = "";
thumbnailPreview.removeAttribute("src");
}
};
fileRow.appendChild(thumbnailFile);
fileRow.appendChild(thumbnailPreview);
const thumbnailManual = document.createElement("input");
thumbnailManual.name = "thumbnail_manual";
thumbnailManual.className = "vpl-wide";
thumbnailManual.placeholder = "任意のサムネイルパス";
thumbnailManual.value = editingPart?.thumbnail?.startsWith("data:") ? "" : (editingPart?.thumbnail || "");
const promptInput = document.createElement("textarea");
promptInput.name = "prompt";
promptInput.placeholder = "プロンプト断片";
promptInput.value = editingPart?.prompt || "";
const buttons = document.createElement("div");
buttons.className = "vpl-add-buttons";
const cancel = document.createElement("button");
cancel.type = "button";
cancel.className = "vpl-clear";
cancel.textContent = "キャンセル";
cancel.onclick = (event) => {
event.stopPropagation();
showPromptPanel = false;
editingPart = null;
render();
};
const save = document.createElement("button");
save.type = "submit";
save.className = "vpl-clear";
save.textContent = editingPart ? "更新" : "保存";
buttons.appendChild(cancel);
buttons.appendChild(save);
form.appendChild(categorySelect);
form.appendChild(labelInput);
form.appendChild(fileRow);
form.appendChild(thumbnailFileData);
form.appendChild(thumbnailManual);
form.appendChild(promptInput);
form.appendChild(buttons);
modal.appendChild(modalTitle);
modal.appendChild(form);
overlay.appendChild(modal);
root.appendChild(overlay);
}
const categoryRow = document.createElement("div");
categoryRow.className = "vpl-category-row";
const tabs = document.createElement("div");
tabs.className = "vpl-categories";
syncVisibleCategories();
library.categories.forEach((category) => {
const categoryId = getCategoryId(category);
const tab = document.createElement("label");
tab.className = "vpl-tab";
tab.dataset.active = String(visibleCategoryIds.has(categoryId));
tab.title = category.label || category.id;
const checkbox = document.createElement("input");
checkbox.type = "checkbox";
checkbox.checked = visibleCategoryIds.has(categoryId);
checkbox.onchange = (event) => {
event.stopPropagation();
categoryFilterTouched = true;
if (checkbox.checked) {
visibleCategoryIds.add(categoryId);
} else {
visibleCategoryIds.delete(categoryId);
}
render();
};
const label = document.createElement("span");
label.className = "vpl-tab-label";
label.textContent = category.label || category.id;
tab.appendChild(checkbox);
tab.appendChild(label);
tabs.appendChild(tab);
});
categoryRow.appendChild(tabs);
root.appendChild(categoryRow);
const searchRow = document.createElement("div");
searchRow.className = "vpl-search-row";
const searchInput = document.createElement("input");
searchInput.className = "vpl-search-input";
searchInput.type = "search";
searchInput.placeholder = "チェック済みカテゴリを検索";
searchInput.value = searchQuery;
searchInput.oninput = (event) => {
event.stopPropagation();
searchQuery = searchInput.value;
refocusSearchAfterRender = true;
render();
};
searchInput.onclick = (event) => event.stopPropagation();
searchInput.onkeydown = (event) => event.stopPropagation();
const clearSearch = document.createElement("button");
clearSearch.className = "vpl-clear";
clearSearch.textContent = "×";
clearSearch.title = "検索をクリア";
clearSearch.disabled = !searchQuery;
clearSearch.onclick = (event) => {
event.stopPropagation();
searchQuery = "";
refocusSearchAfterRender = true;
render();
};
searchRow.appendChild(searchInput);
searchRow.appendChild(clearSearch);
searchRow.appendChild(thumbToggle);
searchRow.appendChild(clear);
root.appendChild(searchRow);
const grid = document.createElement("div");
grid.className = "vpl-grid";
const visibleParts = getVisibleParts();
const showCategoryMeta = visibleCategoryIds.size > 1;
visibleParts.forEach((part) => {
const card = document.createElement("div");
card.className = "vpl-card";
card.dataset.selected = String(isSelected(part));
card.title = `${part.label}\n${part.prompt}\n${part.categoryLabel}`;
card.onclick = (event) => {
event.stopPropagation();
togglePart(part);
};
const thumb = document.createElement("div");
thumb.className = "vpl-thumb";
if (part.thumbnail) {
const img = document.createElement("img");
img.src = part.thumbnail;
img.alt = part.label;
img.onerror = () => {
thumb.innerHTML = "";
const placeholder = document.createElement("div");
placeholder.className = "vpl-placeholder";
placeholder.textContent = part.label;
thumb.appendChild(placeholder);
};
thumb.appendChild(img);
} else {
const placeholder = document.createElement("div");
placeholder.className = "vpl-placeholder";
placeholder.textContent = part.label;
thumb.appendChild(placeholder);
}
const body = document.createElement("div");
body.className = "vpl-card-body";
const label = document.createElement("div");
label.className = "vpl-label";
label.textContent = part.label;
body.appendChild(label);
if (showCategoryMeta) {
const meta = document.createElement("div");
meta.className = "vpl-card-meta";
meta.textContent = part.categoryLabel;
body.appendChild(meta);
}
card.appendChild(thumb);
card.appendChild(body);
grid.appendChild(card);
});
if (!visibleParts.length) {
const empty = document.createElement("div");
empty.className = "vpl-empty";
empty.textContent = searchQuery ? "一致するプロンプトはありません。" : "カテゴリにチェックを入れるとプロンプトが表示されます。";
grid.appendChild(empty);
}
root.appendChild(grid);
syncRootWidth();
if (refocusSearchAfterRender) {
refocusSearchAfterRender = false;
setTimeout(() => {
const nextSearch = root.querySelector(".vpl-search-input");
if (!nextSearch) return;
nextSearch.focus();
const length = nextSearch.value.length;
nextSearch.setSelectionRange?.(length, length);
}, 0);
}
}
function render() {
try {
renderUnsafe();
} catch (error) {
console.error("VisualPromptPicker: render failed", error);
root.innerHTML = "";
const errorBox = document.createElement("div");
errorBox.className = "vpl-empty";
errorBox.textContent = `表示エラー: ${error?.message || error}`;
root.appendChild(errorBox);
}
}
restoreFromHidden();
syncPreviewWidget();
render();
setTimeout(() => refreshLibraryList(), 0);
const delimiterWidget = node.widgets?.find((w) => w.name === "delimiter");
if (delimiterWidget && !delimiterWidget._vplWrapped) {
const originalCallback = delimiterWidget.callback;
delimiterWidget.callback = function() {
originalCallback?.apply(this, arguments);
syncPreviewWidget();
render();
syncHidden();
};
delimiterWidget._vplWrapped = true;
}
const widget = node.addDOMWidget("visual_prompt_picker", "visual_prompt_picker", root, {
getValue() {
return { selected };
},
setValue(value) {
if (value?.selected && Array.isArray(value.selected)) {
selected = value.selected;
syncHidden();
render();
}
},
getMinHeight() {
return 630;
},
hideOnZoom: false,
});
widget.computeSize = function(width) {
syncRootWidth();
return [Math.max(1, width || node.size?.[0] || 640), 630];
};
const resizeObserver = new ResizeObserver(() => syncRootWidth());
if (root.parentElement) {
resizeObserver.observe(root.parentElement);
} else {
setTimeout(() => {
if (root.parentElement) resizeObserver.observe(root.parentElement);
syncRootWidth();
}, 0);
}
const previousOnResize = node.onResize;
node.onResize = function() {
const result = previousOnResize?.apply(this, arguments);
syncRootWidth();
return result;
};
widget.onRemove = () => resizeObserver.disconnect();
syncRootWidth();
return widget;
}
function clampPickerNodeSize(node) {
const currentWidth = node.size?.[0] || 0;
const currentHeight = node.size?.[1] || 0;
const width = Math.max(180, Math.min(currentWidth || 700, 980));
const height = Math.max(820, Math.min(currentHeight || 880, 1100));
if (currentWidth !== width || currentHeight !== height) {
node.setSize([width, height]);
}
}
app.registerExtension({
name: "ComfyUI.VisualPromptLibrary",
async beforeRegisterNodeDef(nodeType, nodeData) {
if (nodeData.name !== "VisualPromptPicker") return;
const onNodeCreated = nodeType.prototype.onNodeCreated;
nodeType.prototype.onNodeCreated = function() {
const result = onNodeCreated?.apply(this, arguments);
const node = this;
const hiddenNames = new Set(["selected_parts", "library_json", "input_images_json", "include_negative", "prefix", "suffix"]);
node.widgets?.forEach((widget) => {
if (hiddenNames.has(widget.name)) {
widget.type = "hidden";
widget.hidden = true;
widget.options = widget.options || {};
widget.options.visible = false;
}
});
createPickerWidget(node, nodeData);
clampPickerNodeSize(node);
return result;
};
const onConfigure = nodeType.prototype.onConfigure;
nodeType.prototype.onConfigure = function() {
const result = onConfigure?.apply(this, arguments);
setTimeout(() => clampPickerNodeSize(this), 0);
setTimeout(() => clampPickerNodeSize(this), 100);
return result;
};
},
});