1615 lines
57 KiB
JavaScript
1615 lines
57 KiB
JavaScript
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;
|
||
};
|
||
},
|
||
});
|