Files
x0x0b 9ce6b53e79 Refactor: General codebase cleanup and storage optimization (#38)
* refactor: streamline metadata handling with serialization helpers

* refactor: optimize metadata serialization and cleanup unused tags query

* lint
2025-12-26 07:17:53 +09:00

725 lines
22 KiB
JavaScript

import { createHistoryApi } from "./historyApi.js";
import { buildImageSources } from "./imageSources.js";
import { clamp } from "./numberUtils.js";
import { getPreviewSettingsStore, DEFAULT_SETTINGS, clampPercent } from "./previewSettings.js";
const HOST_ATTR = "data-phg-preview-root";
const DEFAULT_DURATION_MS = 6000;
const DEFAULT_MAX_VISIBLE = 3;
const DEFAULT_MAX_IMAGES = Number.POSITIVE_INFINITY;
const DUPLICATE_WINDOW_MS = 800;
const HOST_MARGIN = "1.25rem";
const HOST_MARGIN_PX = 20;
const MIN_IMAGE_SIZE = 72;
const MAX_IMAGE_SIZE = 220;
const DEFAULT_IMAGE_SIZE = 110;
const MIN_DISPLAY_MS = 1500;
const MAX_DISPLAY_MS = 60000;
function ensureHostElement(hostId) {
if (typeof document === "undefined") return null;
let host = document.querySelector(`[${HOST_ATTR}="${hostId}"]`);
if (host) return host;
if (!document.body) return null;
host = document.createElement("div");
host.className = "phg-preview-host";
host.setAttribute(HOST_ATTR, hostId);
document.body.appendChild(host);
return host;
}
function sanitizeEntryId(value) {
if (value == null) return null;
if (typeof value === "string") {
const trimmed = value.trim();
return trimmed || null;
}
if (typeof value === "number" || typeof value === "bigint") {
return String(value);
}
if (typeof value === "object") {
return sanitizeEntryId(
value.id ?? value.entry_id ?? value.entryId ?? value.value ?? value.pk ?? null
);
}
return null;
}
export function extractEntryIds(payload) {
if (!payload) return [];
if (Array.isArray(payload)) {
return payload.map(sanitizeEntryId).filter(Boolean);
}
const detail = payload.detail ?? payload.data ?? payload.payload;
if (detail && detail !== payload) {
const nested = extractEntryIds(detail);
if (nested.length) {
return nested;
}
}
const candidate =
payload.entry_ids ?? payload.entryIds ?? payload.ids ?? payload.entries ?? payload.id ?? null;
if (Array.isArray(candidate)) {
return candidate.map(sanitizeEntryId).filter(Boolean);
}
const single = sanitizeEntryId(candidate);
return single ? [single] : [];
}
function computeRuntimeSettings(settings = {}, fallbackDuration = DEFAULT_DURATION_MS) {
const resolved = settings || {};
const useCustom = resolved.enabled !== false;
const source = useCustom ? resolved : {};
const displayDurationRaw = Number(
source.displayDuration ?? fallbackDuration ?? DEFAULT_DURATION_MS
);
const imageSizeRaw = Number(source.imageSize ?? DEFAULT_IMAGE_SIZE);
return {
displayDuration: clamp(
Number.isFinite(displayDurationRaw) ? displayDurationRaw : DEFAULT_DURATION_MS,
MIN_DISPLAY_MS,
MAX_DISPLAY_MS
),
imageSize: clamp(
Number.isFinite(imageSizeRaw) ? imageSizeRaw : DEFAULT_IMAGE_SIZE,
MIN_IMAGE_SIZE,
MAX_IMAGE_SIZE
),
landscapeViewportPercent: clampPercent(
Number(source.landscapeViewportPercent ?? DEFAULT_SETTINGS.landscapeViewportPercent),
DEFAULT_SETTINGS.landscapeViewportPercent
),
portraitViewportPercent: clampPercent(
Number(source.portraitViewportPercent ?? DEFAULT_SETTINGS.portraitViewportPercent),
DEFAULT_SETTINGS.portraitViewportPercent
),
position: typeof source.position === "string" ? source.position : "bottom-left",
enabled: useCustom,
};
}
function applyHostStyles(host, runtimeSettings) {
if (!host) return;
const position = runtimeSettings.position ?? "bottom-left";
host.style.top = "auto";
host.style.bottom = "auto";
host.style.left = "auto";
host.style.right = "auto";
if (position.startsWith("top")) {
host.style.top = HOST_MARGIN;
host.style.justifyContent = "flex-start";
} else {
host.style.bottom = HOST_MARGIN;
host.style.justifyContent = "flex-end";
}
if (position.endsWith("right")) {
host.style.right = HOST_MARGIN;
host.style.alignItems = "flex-end";
} else {
host.style.left = HOST_MARGIN;
host.style.alignItems = "flex-start";
}
host.style.display = runtimeSettings.enabled === false ? "none" : "flex";
}
function applyCardStyles(card, grid, runtimeSettings) {
const thumbSize = clamp(
Number(runtimeSettings.imageSize ?? DEFAULT_IMAGE_SIZE),
MIN_IMAGE_SIZE,
MAX_IMAGE_SIZE
);
if (grid) {
grid.style.display = "flex";
grid.style.flexWrap = "wrap";
grid.style.gap = "0.4rem";
grid.style.alignItems = "flex-start";
grid.style.justifyContent = "flex-start";
grid.style.width = "auto";
grid.style.maxWidth = "100%";
}
if (card) {
const viewportWidth = Math.max(
window.innerWidth || document.documentElement?.clientWidth || 1200,
480
);
const minWidth = Math.max(220, thumbSize * 2);
const maxWidth = Math.min(
viewportWidth - HOST_MARGIN_PX * 2,
Math.max(thumbSize * 4, minWidth + 140)
);
card.style.minWidth = `${Math.round(minWidth)}px`;
card.style.maxWidth = `${Math.round(maxWidth)}px`;
card.style.width = "fit-content";
card.style.alignSelf = "flex-start";
card.style.backgroundColor = "rgba(6, 6, 12, 0.92)";
card.style.borderColor = "rgba(255, 255, 255, 0.14)";
}
return thumbSize;
}
function restyleCards(host, runtimeSettings) {
if (!host) return;
const cards = host.querySelectorAll?.(".phg-preview-card");
cards?.forEach((card) => {
const grid = card.querySelector?.(".phg-preview-grid") ?? null;
const thumbSize = applyCardStyles(card, grid, runtimeSettings);
card.querySelectorAll?.(".phg-preview-image")?.forEach((button) => {
button.style.minHeight = `${Math.round(thumbSize)}px`;
const img = button.querySelector?.("img");
if (img) {
applyResponsiveSizing(button, img, card, runtimeSettings);
}
});
});
}
function clearHostCards(host, beforeRemove) {
if (!host) return;
const cards = Array.from(host.children);
cards.forEach((card) => {
if (typeof beforeRemove === "function") {
beforeRemove(card);
}
card.remove();
});
}
function viewportSize() {
if (typeof window === "undefined") {
return { width: 1280, height: 720 };
}
const width =
window.innerWidth ||
document.documentElement?.clientWidth ||
document.body?.clientWidth ||
1280;
const height =
window.innerHeight ||
document.documentElement?.clientHeight ||
document.body?.clientHeight ||
720;
return {
width: Math.max(width, 320),
height: Math.max(height, 320),
};
}
function computePreviewDimensions(img, runtimeSettings) {
if (!img?.naturalWidth || !img?.naturalHeight) return null;
const { width: viewportWidth, height: viewportHeight } = viewportSize();
const landscapePct = clampPercent(
Number(runtimeSettings?.landscapeViewportPercent ?? DEFAULT_SETTINGS.landscapeViewportPercent),
DEFAULT_SETTINGS.landscapeViewportPercent
);
const portraitPct = clampPercent(
Number(runtimeSettings?.portraitViewportPercent ?? DEFAULT_SETTINGS.portraitViewportPercent),
DEFAULT_SETTINGS.portraitViewportPercent
);
const maxLandscapeWidth = (viewportWidth * landscapePct) / 100;
const maxPortraitHeight = (viewportHeight * portraitPct) / 100;
const isPortrait = img.naturalHeight > img.naturalWidth;
const aspect = img.naturalWidth / Math.max(img.naturalHeight, 1);
if (isPortrait) {
const targetHeight = Math.min(
Math.max(maxPortraitHeight, MIN_IMAGE_SIZE),
viewportHeight - HOST_MARGIN_PX * 2
);
const targetWidth = Math.min(targetHeight * aspect, viewportWidth - HOST_MARGIN_PX * 2);
return {
width: Math.max(MIN_IMAGE_SIZE, Math.round(targetWidth)),
height: Math.max(MIN_IMAGE_SIZE, Math.round(targetHeight)),
isPortrait: true,
};
}
const targetWidth = Math.min(
Math.max(maxLandscapeWidth, MIN_IMAGE_SIZE),
viewportWidth - HOST_MARGIN_PX * 2
);
const targetHeight = Math.min(targetWidth / (aspect || 1), viewportHeight - HOST_MARGIN_PX * 2);
return {
width: Math.max(MIN_IMAGE_SIZE, Math.round(targetWidth)),
height: Math.max(MIN_IMAGE_SIZE, Math.round(targetHeight)),
isPortrait: false,
};
}
function updateCardWidthHint(card, desiredWidth) {
if (!card || !desiredWidth) return;
const current = Number(card.dataset.maxWidthHint || 0);
const next = Math.max(current, desiredWidth);
card.dataset.maxWidthHint = String(next);
const { width: viewportWidth } = viewportSize();
const target = Math.min(
viewportWidth - HOST_MARGIN_PX * 2,
Math.max(next + 32, Number.parseFloat(card.style.minWidth) || 0)
);
if (target > 0) {
card.style.maxWidth = `${Math.round(target)}px`;
}
}
function applyResponsiveSizing(button, img, card, runtimeSettings) {
const dims = computePreviewDimensions(img, runtimeSettings);
if (!dims) return;
img.style.objectFit = "contain";
img.style.width = `${dims.width}px`;
img.style.height = `${dims.height}px`;
img.style.maxWidth = `${dims.width}px`;
img.style.maxHeight = `${dims.height}px`;
if (button) {
button.style.width = `${dims.width}px`;
button.style.height = `${dims.height}px`;
button.style.display = "inline-flex";
button.style.alignItems = "center";
button.style.justifyContent = "center";
}
updateCardWidthHint(card, dims.width);
}
function normalizeGeneratedFile(candidate) {
if (!candidate) return null;
if (typeof candidate === "string") {
const trimmed = candidate.trim();
if (!trimmed) return null;
return { filename: trimmed };
}
if (typeof candidate !== "object") {
return null;
}
const filename = candidate.filename ?? candidate.name ?? candidate.path;
if (typeof filename !== "string") {
return null;
}
const record = { filename: filename.trim() };
if (!record.filename) {
return null;
}
if (candidate.subfolder) {
record.subfolder = String(candidate.subfolder);
}
const type = candidate.type ?? candidate.kind ?? candidate.folder ?? null;
if (type) {
record.type = String(type);
}
return record;
}
function extractGeneratedFiles(payload) {
if (!payload) return [];
const detail = payload.detail ?? payload.data ?? payload.payload;
if (detail && detail !== payload) {
const nested = extractGeneratedFiles(detail);
if (nested.length) {
return nested;
}
}
const candidate =
payload.files ??
payload.generated_files ??
payload.generatedFiles ??
payload.new_files ??
payload.newFiles ??
null;
if (!Array.isArray(candidate)) {
return [];
}
return candidate.map(normalizeGeneratedFile).filter((item) => !!item);
}
export function createPreviewNotifier({
api,
historyApi,
logger = console,
hostId = "phg-preview-root",
displayMs = DEFAULT_DURATION_MS,
maxVisible = DEFAULT_MAX_VISIBLE,
maxImages = DEFAULT_MAX_IMAGES,
openGallery = null,
} = {}) {
if (typeof window === "undefined") {
return null;
}
const settingsStore = getPreviewSettingsStore();
let runtimeSettings = computeRuntimeSettings(settingsStore?.getState?.() ?? {}, displayMs);
const cardTimers = new WeakMap();
const recentEntries = new Map();
function clearTimer(card) {
const timeoutId = cardTimers.get(card);
if (timeoutId) {
window.clearTimeout(timeoutId);
cardTimers.delete(card);
}
}
const updateHostStyles = () => {
const host = document.querySelector(`[${HOST_ATTR}="${hostId}"]`);
if (host) {
applyHostStyles(host, runtimeSettings);
restyleCards(host, runtimeSettings);
if (runtimeSettings.enabled === false) {
clearHostCards(host, clearTimer);
}
}
};
const syncRuntimeSettings = (nextState) => {
runtimeSettings = computeRuntimeSettings(nextState, displayMs);
updateHostStyles();
};
updateHostStyles();
const unsubscribeSettings = settingsStore?.subscribe
? settingsStore.subscribe((state) => syncRuntimeSettings(state))
: null;
const resolvedHistoryApi = historyApi ?? createHistoryApi(api);
const logError =
typeof logger?.error === "function"
? (...args) => logger.error("[PromptHistoryGallery]", ...args)
: () => {};
const previewsDisabled = () => runtimeSettings.enabled === false;
const getHost = () => {
if (previewsDisabled()) {
const existing = document.querySelector(`[${HOST_ATTR}="${hostId}"]`);
if (existing) {
applyHostStyles(existing, runtimeSettings);
clearHostCards(existing, clearTimer);
}
return null;
}
const host = ensureHostElement(hostId);
if (host) {
applyHostStyles(host, runtimeSettings);
}
return host;
};
const markEntryShown = (entryId) => {
if (!entryId) return false;
const now = Date.now();
const previous = recentEntries.get(entryId);
if (previous && now - previous < DUPLICATE_WINDOW_MS) {
return false;
}
recentEntries.set(entryId, now);
if (recentEntries.size > 200) {
const cutoff = now - 60_000;
for (const [key, value] of recentEntries.entries()) {
if (value < cutoff) {
recentEntries.delete(key);
}
}
}
return true;
};
const removeCard = (card) => {
if (!card || card.dataset.removing === "true") return;
card.dataset.removing = "true";
clearTimer(card);
card.classList.add("phg-preview-card--leaving");
window.setTimeout(() => {
card?.remove?.();
}, 220);
};
const resolveDisplayDuration = () =>
clamp(
Number(runtimeSettings.displayDuration ?? displayMs ?? DEFAULT_DURATION_MS),
MIN_DISPLAY_MS,
MAX_DISPLAY_MS
);
const scheduleRemoval = (card) => {
clearTimer(card);
const duration = resolveDisplayDuration();
if (!(duration > 0)) {
return;
}
const timeoutId = window.setTimeout(() => removeCard(card), duration);
cardTimers.set(card, timeoutId);
};
const enforceVisibleLimit = () => {
if (maxVisible <= 0) return;
const host = getHost();
if (!host) return;
const cards = Array.from(host.children);
const excess = cards.length - maxVisible;
if (excess > 0) {
cards.slice(0, excess).forEach((card) => removeCard(card));
}
};
const limitSources = (sources) => {
if (!Array.isArray(sources) || !sources.length) {
return [];
}
if (typeof maxImages === "number" && Number.isFinite(maxImages) && maxImages > 0) {
const safeLimit = Math.max(1, Math.floor(maxImages));
return sources.slice(0, safeLimit);
}
return sources;
};
const createPreviewCard = (entry, previewImages, allImages) => {
if (previewsDisabled()) {
return null;
}
const host = getHost();
if (!host) return null;
const card = document.createElement("article");
card.className = "phg-preview-card";
if (entry?.id) {
card.dataset.entryId = entry.id;
}
const closeButton = document.createElement("button");
closeButton.type = "button";
closeButton.className = "phg-preview-dismiss";
closeButton.title = "Dismiss preview";
closeButton.setAttribute("aria-label", "Dismiss preview");
closeButton.textContent = "x";
closeButton.addEventListener("click", (event) => {
event.preventDefault();
event.stopPropagation();
removeCard(card);
});
const grid = document.createElement("div");
grid.className = "phg-preview-grid";
const thumbSize = applyCardStyles(card, grid, runtimeSettings);
previewImages.forEach((source, index) => {
const button = document.createElement("button");
button.type = "button";
button.className = "phg-preview-image";
button.setAttribute("aria-label", source.title ? `Open ${source.title}` : "Open image");
const img = document.createElement("img");
img.src = source.thumb ?? source.url;
img.alt = source.title ?? "";
img.loading = "lazy";
img.decoding = "async";
img.addEventListener("error", () => {
button.remove();
if (!grid.children.length) {
removeCard(card);
}
});
const applySize = () => applyResponsiveSizing(button, img, card, runtimeSettings);
img.addEventListener("load", applySize, { once: false });
if (img.complete && img.naturalWidth && img.naturalHeight) {
applySize();
}
button.addEventListener("click", (event) => {
event.preventDefault();
const gallerySources =
Array.isArray(allImages) && allImages.length ? allImages : previewImages;
const matchedIndex = gallerySources.findIndex(
(item) => item?.url === source.url || item?.thumb === source.thumb
);
const startIndex = matchedIndex >= 0 ? matchedIndex : index;
const result =
typeof openGallery === "function" ? openGallery(entry, gallerySources, startIndex) : null;
if (result === false) {
window.open(source.url, "_blank", "noopener,noreferrer");
} else if (result && typeof result.then === "function") {
result.catch((error) => logError("preview openGallery error", error));
}
});
button.style.minHeight = `${Math.round(thumbSize)}px`;
button.appendChild(img);
grid.appendChild(button);
});
card.appendChild(closeButton);
card.appendChild(grid);
host.appendChild(card);
enforceVisibleLimit();
scheduleRemoval(card);
return card;
};
const showEntries = (entries) => {
if (previewsDisabled()) {
return;
}
entries.forEach((entry) => {
if (!entry || (entry.id && !markEntryShown(entry.id))) {
return;
}
const sources = buildImageSources(entry, api);
const previewSources = limitSources(sources);
if (!previewSources.length) {
return;
}
createPreviewCard(entry, previewSources, sources);
});
};
const claimEntrySlot = (entryIds) => {
if (!Array.isArray(entryIds) || entryIds.length === 0) {
return { show: true, entryId: null };
}
let fallback = null;
for (const entryId of entryIds) {
const sanitized = sanitizeEntryId(entryId);
if (!sanitized) continue;
fallback = fallback ?? sanitized;
if (markEntryShown(sanitized)) {
return { show: true, entryId: sanitized };
}
}
return { show: false, entryId: fallback };
};
async function fetchEntriesByIds(entryIds) {
const unique = Array.from(new Set(entryIds.map(sanitizeEntryId).filter(Boolean)));
if (!unique.length) {
return [];
}
try {
const limit = Math.max(10, Math.min(200, unique.length * 4));
const items = await resolvedHistoryApi.list(limit);
const map = new Map(items.map((entry) => [entry.id, entry]));
return unique.map((entryId) => map.get(entryId)).filter((entry) => !!entry);
} catch (error) {
logError("preview entry fetch failed", error);
return [];
}
}
const tryShowGeneratedFiles = async (entryIds, filesPayload) => {
if (previewsDisabled()) {
return false;
}
if (!Array.isArray(filesPayload) || !filesPayload.length) {
return false;
}
// Preview thumbnails come from the freshly generated files,
// but we prefer the full gallery from the matched history entry.
const previewSources = limitSources(
buildImageSources(
{
files: filesPayload,
},
api
)
);
const claim = claimEntrySlot(entryIds);
if (!claim.show) {
return false;
}
let entry = null;
let gallerySources = [];
if (claim.entryId) {
const fetched = await fetchEntriesByIds([claim.entryId]);
entry = fetched?.[0] ?? null;
if (entry) {
gallerySources = buildImageSources(entry, api);
}
}
if (!entry) {
entry = { id: claim.entryId ?? null };
}
if (!entry.id) {
try {
const recent = await resolvedHistoryApi.list(50);
const targetNames = new Set(
filesPayload
.map(normalizeGeneratedFile)
.map((item) => item?.filename ?? null)
.filter(Boolean)
);
const match = recent.find(
(item) =>
Array.isArray(item?.files) &&
item.files.some((file) => {
if (typeof file === "string") {
return targetNames.has(file);
}
if (file && typeof file === "object") {
const name = file.filename ?? file.name ?? null;
return name ? targetNames.has(String(name)) : false;
}
return false;
})
);
if (match) {
entry = match;
gallerySources = buildImageSources(entry, api);
}
} catch (error) {
logError("preview match recent entry failed", error);
}
}
if (!gallerySources.length && entry?.files) {
gallerySources = buildImageSources(entry, api);
}
const previews = previewSources.length ? previewSources : limitSources(gallerySources);
if (!previews.length) {
return false;
}
const gallery = gallerySources.length ? gallerySources : previews;
createPreviewCard(entry, previews, gallery);
return true;
};
return {
async handleHistoryEvent(event) {
if (previewsDisabled()) {
updateHostStyles();
return;
}
const entryIds = extractEntryIds(event);
const generatedFiles = extractGeneratedFiles(event);
const handledGenerated = await tryShowGeneratedFiles(entryIds, generatedFiles);
if (handledGenerated) {
return;
}
if (entryIds.length > 0) {
const entries = await fetchEntriesByIds(entryIds);
if (entries.length > 0) {
showEntries(entries);
}
}
},
async notifyEntryIds(entryIds) {
if (previewsDisabled()) {
return;
}
const entries = await fetchEntriesByIds(entryIds);
if (entries.length > 0) {
showEntries(entries);
}
},
notifyEntries(entries) {
if (previewsDisabled()) {
return;
}
if (Array.isArray(entries) && entries.length > 0) {
showEntries(entries);
}
},
dispose() {
if (typeof unsubscribeSettings === "function") {
unsubscribeSettings();
}
},
};
}