1522 lines
41 KiB
JavaScript
1522 lines
41 KiB
JavaScript
"use strict";
|
||
|
||
const MAX_FILE_SIZE = 25 * 1024 * 1024;
|
||
const ACCEPTED_TYPES = new Set([
|
||
"image/jpeg",
|
||
"image/jpg",
|
||
"image/png",
|
||
"image/webp",
|
||
"image/heic",
|
||
"image/heif"
|
||
]);
|
||
|
||
const healthStatus = document.querySelector("#health-status");
|
||
const healthText = document.querySelector("#health-text");
|
||
|
||
const dropZone = document.querySelector("#drop-zone");
|
||
const photoInput = document.querySelector("#photo-input");
|
||
const dropTitle = document.querySelector("#drop-title");
|
||
const fileMeta = document.querySelector("#file-meta");
|
||
|
||
const promptInput = document.querySelector("#prompt");
|
||
const promptCounter = document.querySelector("#prompt-counter");
|
||
|
||
const editButton = document.querySelector("#edit-button");
|
||
const upscaleButton = document.querySelector("#upscale-button");
|
||
|
||
const workspace = document.querySelector("#workspace");
|
||
const sourceImage = document.querySelector("#source-image");
|
||
const sourcePlaceholder = document.querySelector("#source-placeholder");
|
||
const resultImage = document.querySelector("#result-image");
|
||
const resultPlaceholder = document.querySelector("#result-placeholder");
|
||
const resultLabel = document.querySelector("#result-label");
|
||
|
||
const jobStatus = document.querySelector("#job-status");
|
||
const jobStatusText = document.querySelector("#job-status-text");
|
||
const downloadLink = document.querySelector("#download-link");
|
||
const downloadCurrent = document.querySelector("#download-current");
|
||
|
||
const authOverlay = document.querySelector("#auth-overlay");
|
||
const loginForm = document.querySelector("#login-form");
|
||
const loginUsername = document.querySelector("#login-username");
|
||
const loginPassword = document.querySelector("#login-password");
|
||
const loginError = document.querySelector("#login-error");
|
||
const loginSubmit = document.querySelector("#login-submit");
|
||
const logoutButton = document.querySelector("#logout-button");
|
||
|
||
const referenceUploadButton = document.querySelector("#reference-upload-button");
|
||
const referenceHistorySelect = document.querySelector("#reference-history-select");
|
||
const referenceClearButton = document.querySelector("#reference-clear-button");
|
||
const referenceFileInput = document.querySelector("#reference-file-input");
|
||
const referencePreview = document.querySelector("#reference-preview");
|
||
const referenceCanvas = document.querySelector("#reference-canvas");
|
||
const referenceRegionInfo = document.querySelector("#reference-region-info");
|
||
const referenceRegionReset = document.querySelector("#reference-region-reset");
|
||
const referenceNote = document.querySelector("#reference-note");
|
||
|
||
const suggestButton = document.querySelector("#suggest-button");
|
||
const suggestPanel = document.querySelector("#suggest-panel");
|
||
const suggestHistoryChips = document.querySelector("#suggest-history-chips");
|
||
const suggestCodexButton = document.querySelector("#suggest-codex-button");
|
||
const suggestCodexStatus = document.querySelector("#suggest-codex-status");
|
||
const suggestCodexChips = document.querySelector("#suggest-codex-chips");
|
||
|
||
let currentVersionId = null;
|
||
let historyVersions = [];
|
||
let sourceObjectUrl = null;
|
||
let isBusy = false;
|
||
let collapsedGroups = new Map(); // groupId (id корня) -> true (свёрнута)
|
||
let referenceState = null; // { source: {type:'file', file, objectUrl} | {type:'history', versionId, url}, region: {x,y,w,h} | null }
|
||
let suggestRunning = false;
|
||
let suggestPollFailures = 0;
|
||
const REFERENCE_MAX_DIMENSION = 1600;
|
||
|
||
function isHeicFile(file) {
|
||
const type = String(file?.type || "").toLowerCase();
|
||
const name = String(file?.name || "").toLowerCase();
|
||
|
||
return (
|
||
type === "image/heic" ||
|
||
type === "image/heif" ||
|
||
name.endsWith(".heic") ||
|
||
name.endsWith(".heif")
|
||
);
|
||
}
|
||
|
||
function formatFileSize(bytes) {
|
||
if (bytes < 1024 * 1024) {
|
||
return `${Math.max(1, Math.round(bytes / 1024))} КБ`;
|
||
}
|
||
|
||
return `${(bytes / (1024 * 1024)).toFixed(1)} МБ`;
|
||
}
|
||
|
||
function versionFileName(version) {
|
||
const kindName = {
|
||
original: "original",
|
||
edit: "edited",
|
||
upscale: "upscaled"
|
||
}[version.kind] || "photo";
|
||
|
||
let stamp = "";
|
||
try {
|
||
const d = new Date(version.createdAt);
|
||
stamp = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
|
||
} catch {
|
||
stamp = "photo";
|
||
}
|
||
|
||
return `${kindName}-${stamp}.${version.ext || "jpg"}`;
|
||
}
|
||
|
||
function updateDownloadCurrent(version) {
|
||
if (!version) {
|
||
downloadCurrent.hidden = true;
|
||
downloadCurrent.removeAttribute("href");
|
||
downloadCurrent.removeAttribute("download");
|
||
return;
|
||
}
|
||
|
||
downloadCurrent.href = version.url;
|
||
downloadCurrent.download = versionFileName(version);
|
||
downloadCurrent.hidden = false;
|
||
}
|
||
|
||
function setHealthState(state, message, title = "") {
|
||
healthStatus.className = `health-status health-${state}`;
|
||
healthText.textContent = message;
|
||
healthStatus.title = title;
|
||
}
|
||
|
||
function setJobStatus(kind, message) {
|
||
if (!message) {
|
||
jobStatus.hidden = true;
|
||
jobStatus.removeAttribute("data-kind");
|
||
jobStatusText.textContent = "";
|
||
return;
|
||
}
|
||
|
||
jobStatus.hidden = false;
|
||
jobStatus.dataset.kind = kind;
|
||
jobStatusText.textContent = message;
|
||
}
|
||
|
||
function setBusy(busy) {
|
||
isBusy = busy;
|
||
workspace.setAttribute("aria-busy", String(busy));
|
||
|
||
editButton.disabled = busy || !currentVersionId;
|
||
upscaleButton.disabled = busy || !currentVersionId;
|
||
promptInput.disabled = busy;
|
||
photoInput.disabled = busy;
|
||
|
||
suggestButton.disabled = busy;
|
||
suggestCodexButton.disabled = busy || suggestRunning || !currentVersionId;
|
||
|
||
referenceUploadButton.disabled = busy;
|
||
referenceHistorySelect.disabled = busy;
|
||
referenceClearButton.disabled = busy;
|
||
referenceRegionReset.disabled = busy;
|
||
|
||
dropZone.setAttribute("aria-disabled", String(busy));
|
||
}
|
||
|
||
function updateSuggestCodexButton() {
|
||
suggestCodexButton.disabled = isBusy || suggestRunning || !currentVersionId;
|
||
}
|
||
|
||
function resetResult() {
|
||
resultImage.hidden = true;
|
||
resultImage.removeAttribute("src");
|
||
resultPlaceholder.hidden = false;
|
||
resultLabel.textContent = "";
|
||
downloadLink.hidden = true;
|
||
downloadLink.removeAttribute("href");
|
||
downloadLink.removeAttribute("download");
|
||
}
|
||
|
||
function clearEditorState() {
|
||
historyVersions = [];
|
||
currentVersionId = null;
|
||
clearSourcePreview();
|
||
resetResult();
|
||
renderHistory();
|
||
updateDownloadCurrent(null);
|
||
}
|
||
|
||
function clearSourcePreview() {
|
||
if (sourceObjectUrl) {
|
||
URL.revokeObjectURL(sourceObjectUrl);
|
||
sourceObjectUrl = null;
|
||
}
|
||
|
||
sourceImage.hidden = true;
|
||
sourceImage.removeAttribute("src");
|
||
sourcePlaceholder.hidden = false;
|
||
}
|
||
|
||
function showSourcePreview(url) {
|
||
if (sourceObjectUrl) {
|
||
URL.revokeObjectURL(sourceObjectUrl);
|
||
sourceObjectUrl = null;
|
||
}
|
||
|
||
sourceImage.src = url;
|
||
sourceImage.hidden = false;
|
||
sourcePlaceholder.hidden = true;
|
||
}
|
||
|
||
function showSourcePreviewFromUrl(url, title, meta) {
|
||
showSourcePreview(url);
|
||
|
||
dropZone.classList.add("has-file");
|
||
dropTitle.textContent = title || "Текущая фотография";
|
||
fileMeta.textContent = meta || "Готово к обработке";
|
||
}
|
||
|
||
async function importPhoto(file) {
|
||
const form = new FormData();
|
||
form.append("photo", file, file.name);
|
||
|
||
const response = await apiFetch("/api/history/import", {
|
||
method: "POST",
|
||
body: form,
|
||
cache: "no-store"
|
||
});
|
||
|
||
if (!response.ok) {
|
||
let message = `Не удалось загрузить фотографию. HTTP ${response.status}.`;
|
||
const contentType = response.headers.get("content-type") || "";
|
||
|
||
if (contentType.includes("application/json")) {
|
||
const data = await response.json().catch(() => null);
|
||
|
||
if (data?.error) {
|
||
message = data.error;
|
||
}
|
||
}
|
||
|
||
throw new Error(message);
|
||
}
|
||
|
||
const data = await response.json();
|
||
return data.version;
|
||
}
|
||
|
||
async function handleFiles(fileList) {
|
||
const files = Array.from(fileList || []).filter((file) => {
|
||
const fileType = String(file.type || "").toLowerCase();
|
||
|
||
if (!ACCEPTED_TYPES.has(fileType) && !isHeicFile(file)) {
|
||
setJobStatus(
|
||
"error",
|
||
"Поддерживаются только фотографии JPEG, PNG, WebP или HEIC."
|
||
);
|
||
return false;
|
||
}
|
||
|
||
if (file.size > MAX_FILE_SIZE) {
|
||
setJobStatus(
|
||
"error",
|
||
"Файл слишком большой. Максимальный размер — 25 МБ."
|
||
);
|
||
return false;
|
||
}
|
||
|
||
return true;
|
||
});
|
||
|
||
if (files.length === 0) {
|
||
return;
|
||
}
|
||
|
||
setBusy(true);
|
||
|
||
for (let i = 0; i < files.length; i++) {
|
||
const file = files[i];
|
||
|
||
setJobStatus(
|
||
"loading",
|
||
files.length > 1
|
||
? `Загрузка ${i + 1} из ${files.length}…`
|
||
: "Загружаем фотографию в историю…"
|
||
);
|
||
|
||
try {
|
||
await importPhoto(file);
|
||
} catch (error) {
|
||
setBusy(false);
|
||
setJobStatus(
|
||
"error",
|
||
error instanceof Error
|
||
? error.message
|
||
: "Не удалось загрузить фотографию."
|
||
);
|
||
return;
|
||
}
|
||
}
|
||
|
||
await refreshHistory(true);
|
||
setJobStatus("", "");
|
||
setBusy(false);
|
||
}
|
||
|
||
async function selectFile(file) {
|
||
return handleFiles(file ? [file] : []);
|
||
}
|
||
|
||
function openFilePicker() {
|
||
if (!isBusy) {
|
||
photoInput.click();
|
||
}
|
||
}
|
||
|
||
function preventDragDefaults(event) {
|
||
event.preventDefault();
|
||
event.stopPropagation();
|
||
}
|
||
|
||
async function readJsonResponse(response) {
|
||
const contentType = response.headers.get("content-type") || "";
|
||
|
||
if (!contentType.includes("application/json")) {
|
||
throw new Error(
|
||
`Сервер вернул неожиданный ответ HTTP ${response.status}.`
|
||
);
|
||
}
|
||
|
||
return response.json();
|
||
}
|
||
|
||
async function apiFetch(url, options = {}) {
|
||
const response = await fetch(url, {
|
||
...options,
|
||
credentials: "same-origin"
|
||
});
|
||
|
||
if (response.status === 401) {
|
||
clearEditorState();
|
||
showAuthOverlay();
|
||
throw new Error("Требуется авторизация.");
|
||
}
|
||
|
||
return response;
|
||
}
|
||
|
||
async function runJob(kind) {
|
||
if (isBusy) {
|
||
return;
|
||
}
|
||
|
||
if (!currentVersionId) {
|
||
setJobStatus("error", "Сначала выберите фотографию.");
|
||
return;
|
||
}
|
||
|
||
const prompt = promptInput.value.trim();
|
||
|
||
if (kind === "edit" && !prompt) {
|
||
setJobStatus(
|
||
"error",
|
||
"Введите промпт — опишите, как нужно изменить фотографию."
|
||
);
|
||
promptInput.focus();
|
||
return;
|
||
}
|
||
|
||
const isEdit = kind === "edit";
|
||
const endpoint = isEdit ? "/api/edit" : "/api/upscale";
|
||
const form = new FormData();
|
||
|
||
form.append("sourceId", currentVersionId);
|
||
|
||
if (isEdit) {
|
||
form.append("prompt", prompt);
|
||
|
||
if (referenceState) {
|
||
if (referenceState.source.type === "history") {
|
||
form.append("referenceId", referenceState.source.versionId);
|
||
}
|
||
|
||
const blob = await new Promise((resolve) =>
|
||
referenceCanvas.toBlob(resolve, "image/jpeg", 0.92)
|
||
);
|
||
|
||
if (blob) {
|
||
form.append("reference", blob, "reference_annotated.jpg");
|
||
}
|
||
|
||
if (referenceState.region) {
|
||
form.append("region", JSON.stringify(referenceState.region));
|
||
}
|
||
}
|
||
}
|
||
|
||
resetResult();
|
||
setBusy(true);
|
||
setJobStatus(
|
||
"loading",
|
||
isEdit
|
||
? "Codex редактирует фотографию… Обычно 1–3 минуты. Страницу можно закрыть — результат появится в истории."
|
||
: "Увеличиваем разрешение фотографии… Страницу можно закрыть — результат появится в истории."
|
||
);
|
||
|
||
try {
|
||
const response = await apiFetch(endpoint, {
|
||
method: "POST",
|
||
body: form,
|
||
cache: "no-store"
|
||
});
|
||
|
||
const data = await readJsonResponse(response);
|
||
|
||
if (!response.ok || !data.ok) {
|
||
throw new Error(data.error || "Не удалось обработать фотографию.");
|
||
}
|
||
|
||
if (!data.jobId) {
|
||
throw new Error("Сервер не вернул идентификатор задания.");
|
||
}
|
||
|
||
pollJob(data.jobId, isEdit);
|
||
} catch (error) {
|
||
setBusy(false);
|
||
setJobStatus(
|
||
"error",
|
||
error instanceof Error
|
||
? error.message
|
||
: "Не удалось обработать фотографию."
|
||
);
|
||
}
|
||
}
|
||
|
||
let jobPollFailures = 0;
|
||
|
||
async function pollJob(jobId, isEdit) {
|
||
try {
|
||
const response = await apiFetch(`/api/jobs/${jobId}/status`, {
|
||
cache: "no-store"
|
||
});
|
||
const data = await readJsonResponse(response);
|
||
|
||
if (!response.ok || !data.ok) {
|
||
throw new Error(data.error || "Не удалось получить статус задания.");
|
||
}
|
||
|
||
jobPollFailures = 0;
|
||
|
||
if (data.status === "running") {
|
||
setTimeout(() => pollJob(jobId, isEdit), 3000);
|
||
return;
|
||
}
|
||
|
||
if (data.status === "done") {
|
||
renderJobSuccess(data, isEdit);
|
||
setBusy(false);
|
||
return;
|
||
}
|
||
|
||
if (data.status === "error") {
|
||
throw new Error(data.error || "Задание завершилось с ошибкой.");
|
||
}
|
||
|
||
throw new Error("Неизвестный статус задания.");
|
||
} catch (error) {
|
||
if (error instanceof Error && error.message === "Требуется авторизация.") {
|
||
setBusy(false);
|
||
return;
|
||
}
|
||
|
||
jobPollFailures += 1;
|
||
|
||
if (jobPollFailures >= 5) {
|
||
setBusy(false);
|
||
setJobStatus(
|
||
"error",
|
||
error instanceof Error
|
||
? error.message
|
||
: "Ошибка связи с сервером. Проверьте результат в истории."
|
||
);
|
||
return;
|
||
}
|
||
|
||
setTimeout(() => pollJob(jobId, isEdit), 3000);
|
||
}
|
||
}
|
||
|
||
function renderJobSuccess(data, isEdit) {
|
||
const version = data.version;
|
||
|
||
if (version) {
|
||
historyVersions.push(version);
|
||
currentVersionId = version.id;
|
||
updateDownloadCurrent(version);
|
||
renderHistory();
|
||
}
|
||
|
||
const url = version?.url || data.result?.url;
|
||
|
||
resultImage.src = url;
|
||
resultImage.hidden = false;
|
||
resultPlaceholder.hidden = true;
|
||
|
||
resultLabel.textContent = isEdit ? "Редактирование · Codex" : "4× upscale";
|
||
|
||
downloadLink.href = url;
|
||
downloadLink.download =
|
||
data.result?.filename ||
|
||
(isEdit ? "edited-photo.jpg" : "upscaled-photo.png");
|
||
downloadLink.hidden = false;
|
||
|
||
const summary = isEdit && data.summary
|
||
? String(data.summary).replace(/\s+/g, " ").trim().slice(0, 180)
|
||
: "";
|
||
|
||
setJobStatus(
|
||
"success",
|
||
isEdit
|
||
? summary
|
||
? `Готово — фотография отредактирована. Codex: ${summary}`
|
||
: "Готово — фотография отредактирована."
|
||
: `Готово — разрешение увеличено с помощью ${data.job?.model || "upscale-модели"}.`
|
||
);
|
||
|
||
if (window.matchMedia("(max-width: 1040px)").matches) {
|
||
resultImage.scrollIntoView({
|
||
behavior: "smooth",
|
||
block: "center"
|
||
});
|
||
}
|
||
}
|
||
|
||
function formatHistoryDate(iso) {
|
||
try {
|
||
return new Date(iso).toLocaleString("ru-RU", {
|
||
day: "2-digit",
|
||
month: "2-digit",
|
||
hour: "2-digit",
|
||
minute: "2-digit"
|
||
});
|
||
} catch {
|
||
return "";
|
||
}
|
||
}
|
||
|
||
function historyItemBadge(version) {
|
||
if (version.kind === "original") {
|
||
return "Исходник";
|
||
}
|
||
|
||
if (version.kind === "upscale") {
|
||
return "Апскейл";
|
||
}
|
||
|
||
return "Правка";
|
||
}
|
||
|
||
function buildChildrenMap(versions) {
|
||
const map = new Map(); // parentId -> [versions] в порядке истории
|
||
|
||
for (const version of versions) {
|
||
const key = version.parentId || "";
|
||
|
||
if (!map.has(key)) {
|
||
map.set(key, []);
|
||
}
|
||
|
||
map.get(key).push(version);
|
||
}
|
||
|
||
return map;
|
||
}
|
||
|
||
function countSubtreeSize(id, childrenMap) {
|
||
let count = 1;
|
||
const children = childrenMap.get(id);
|
||
|
||
if (children) {
|
||
for (const child of children) {
|
||
count += countSubtreeSize(child.id, childrenMap);
|
||
}
|
||
}
|
||
|
||
return count;
|
||
}
|
||
|
||
function renderTreeNode(version, depth, childrenMap, container) {
|
||
const node = document.createElement("div");
|
||
node.className = "history-node";
|
||
node.style.setProperty("--depth", depth);
|
||
|
||
if (version.id === currentVersionId) {
|
||
node.classList.add("is-current");
|
||
}
|
||
|
||
const main = document.createElement("button");
|
||
main.type = "button";
|
||
main.className = "history-node-main";
|
||
main.setAttribute("aria-label", version.label || "Версия");
|
||
main.addEventListener("click", () => selectHistoryVersion(version));
|
||
|
||
const thumb = document.createElement("img");
|
||
thumb.className = "history-node-thumb";
|
||
thumb.src = version.url;
|
||
thumb.alt = "";
|
||
thumb.loading = "lazy";
|
||
|
||
const meta = document.createElement("span");
|
||
meta.className = "history-node-meta";
|
||
|
||
const title = document.createElement("strong");
|
||
title.className = "history-node-title";
|
||
title.textContent = version.label || "Версия";
|
||
|
||
const badge = document.createElement("span");
|
||
badge.className = "history-node-badge";
|
||
badge.textContent = historyItemBadge(version);
|
||
|
||
const date = document.createElement("span");
|
||
date.className = "history-node-date";
|
||
date.textContent = formatHistoryDate(version.createdAt);
|
||
|
||
meta.append(title, badge, date);
|
||
|
||
if (version.kind === "edit" && version.prompt) {
|
||
const prompt = document.createElement("div");
|
||
prompt.className = "history-node-prompt";
|
||
prompt.textContent = version.prompt;
|
||
prompt.title = version.prompt;
|
||
meta.appendChild(prompt);
|
||
}
|
||
|
||
main.append(thumb, meta);
|
||
|
||
const actions = document.createElement("div");
|
||
actions.className = "history-node-actions";
|
||
|
||
const download = document.createElement("a");
|
||
download.className = "history-node-action history-download";
|
||
download.href = version.url;
|
||
download.download = versionFileName(version);
|
||
download.textContent = "Скачать";
|
||
download.addEventListener("click", (event) => event.stopPropagation());
|
||
|
||
const remove = document.createElement("button");
|
||
remove.type = "button";
|
||
remove.className = "history-node-action history-delete";
|
||
remove.textContent = "Удалить";
|
||
remove.addEventListener("click", (event) => {
|
||
event.stopPropagation();
|
||
deleteVersion(version);
|
||
});
|
||
|
||
actions.append(download, remove);
|
||
|
||
node.append(main, actions);
|
||
|
||
if (childrenMap.has(version.id)) {
|
||
const body = document.createElement("div");
|
||
body.className = "history-node-children";
|
||
|
||
for (const child of childrenMap.get(version.id)) {
|
||
renderTreeNode(child, depth + 1, childrenMap, body);
|
||
}
|
||
|
||
node.appendChild(body);
|
||
}
|
||
|
||
container.appendChild(node);
|
||
}
|
||
|
||
function rebuildReferenceSelect() {
|
||
const current = referenceState?.source?.type === "history"
|
||
? referenceState.source.versionId
|
||
: "";
|
||
|
||
referenceHistorySelect.innerHTML = "";
|
||
|
||
const placeholder = document.createElement("option");
|
||
placeholder.value = "";
|
||
placeholder.textContent = "— из истории —";
|
||
referenceHistorySelect.appendChild(placeholder);
|
||
|
||
for (const version of historyVersions) {
|
||
const option = document.createElement("option");
|
||
option.value = version.id;
|
||
option.textContent = `${version.label || "Версия"} · ${formatHistoryDate(version.createdAt)}`;
|
||
referenceHistorySelect.appendChild(option);
|
||
}
|
||
|
||
referenceHistorySelect.value = current;
|
||
}
|
||
|
||
function renderHistory() {
|
||
const list = document.querySelector("#history-list");
|
||
const empty = document.querySelector("#history-empty");
|
||
|
||
list.querySelectorAll(".history-group").forEach((group) => group.remove());
|
||
empty.hidden = historyVersions.length > 0;
|
||
|
||
const childrenMap = buildChildrenMap(historyVersions);
|
||
const roots = historyVersions.filter(
|
||
(version) =>
|
||
version.kind === "original" ||
|
||
!version.parentId ||
|
||
!childrenMap.has(version.parentId)
|
||
);
|
||
|
||
rebuildReferenceSelect();
|
||
|
||
for (const root of roots) {
|
||
const group = document.createElement("div");
|
||
group.className = "history-group";
|
||
group.dataset.group = root.id;
|
||
|
||
const rootNode = document.createElement("div");
|
||
rootNode.className = "history-node is-root";
|
||
rootNode.style.setProperty("--depth", 0);
|
||
|
||
if (root.id === currentVersionId) {
|
||
rootNode.classList.add("is-current");
|
||
}
|
||
|
||
const toggle = document.createElement("button");
|
||
toggle.type = "button";
|
||
toggle.className = "history-node-toggle";
|
||
toggle.setAttribute("aria-label", "Свернуть ветку");
|
||
const collapsed = collapsedGroups.has(root.id);
|
||
toggle.setAttribute("aria-expanded", String(!collapsed));
|
||
toggle.textContent = collapsed ? "▸" : "▾";
|
||
toggle.addEventListener("click", (event) => {
|
||
event.stopPropagation();
|
||
|
||
if (collapsedGroups.has(root.id)) {
|
||
collapsedGroups.delete(root.id);
|
||
} else {
|
||
collapsedGroups.set(root.id, true);
|
||
}
|
||
|
||
renderHistory();
|
||
});
|
||
|
||
const rootMain = document.createElement("button");
|
||
rootMain.type = "button";
|
||
rootMain.className = "history-node-main";
|
||
rootMain.setAttribute("aria-label", root.label || "Версия");
|
||
rootMain.addEventListener("click", () => selectHistoryVersion(root));
|
||
|
||
const thumb = document.createElement("img");
|
||
thumb.className = "history-node-thumb";
|
||
thumb.src = root.url;
|
||
thumb.alt = "";
|
||
thumb.loading = "lazy";
|
||
|
||
const meta = document.createElement("span");
|
||
meta.className = "history-node-meta";
|
||
|
||
const title = document.createElement("strong");
|
||
title.className = "history-node-title";
|
||
title.textContent = root.label || "Версия";
|
||
|
||
const badge = document.createElement("span");
|
||
badge.className = "history-node-badge";
|
||
badge.textContent = historyItemBadge(root);
|
||
|
||
const date = document.createElement("span");
|
||
date.className = "history-node-date";
|
||
date.textContent = formatHistoryDate(root.createdAt);
|
||
|
||
meta.append(title, badge, date);
|
||
|
||
if (root.kind === "edit" && root.prompt) {
|
||
const prompt = document.createElement("div");
|
||
prompt.className = "history-node-prompt";
|
||
prompt.textContent = root.prompt;
|
||
prompt.title = root.prompt;
|
||
meta.appendChild(prompt);
|
||
}
|
||
|
||
rootMain.append(thumb, meta);
|
||
|
||
const actions = document.createElement("div");
|
||
actions.className = "history-node-actions";
|
||
|
||
const download = document.createElement("a");
|
||
download.className = "history-node-action history-download";
|
||
download.href = root.url;
|
||
download.download = versionFileName(root);
|
||
download.textContent = "Скачать";
|
||
download.addEventListener("click", (event) => event.stopPropagation());
|
||
|
||
const remove = document.createElement("button");
|
||
remove.type = "button";
|
||
remove.className = "history-node-action history-delete";
|
||
remove.textContent = "Удалить";
|
||
remove.addEventListener("click", (event) => {
|
||
event.stopPropagation();
|
||
deleteVersion(root);
|
||
});
|
||
|
||
actions.append(download, remove);
|
||
|
||
rootNode.append(toggle, rootMain, actions);
|
||
group.appendChild(rootNode);
|
||
|
||
const count = document.createElement("span");
|
||
count.className = "history-group-count";
|
||
const subtreeSize = countSubtreeSize(root.id, childrenMap);
|
||
count.textContent = subtreeSize === 1 ? "1 версия" : `${subtreeSize} версий`;
|
||
group.appendChild(count);
|
||
|
||
if (!collapsed) {
|
||
const body = document.createElement("div");
|
||
body.className = "history-group-body";
|
||
const children = childrenMap.get(root.id);
|
||
|
||
if (children) {
|
||
for (const child of children) {
|
||
renderTreeNode(child, 1, childrenMap, body);
|
||
}
|
||
}
|
||
|
||
group.appendChild(body);
|
||
}
|
||
|
||
list.appendChild(group);
|
||
}
|
||
}
|
||
|
||
function selectHistoryVersion(version) {
|
||
if (isBusy) {
|
||
return;
|
||
}
|
||
|
||
currentVersionId = version.id;
|
||
|
||
resetResult();
|
||
updateDownloadCurrent(version);
|
||
showSourcePreviewFromUrl(
|
||
version.url,
|
||
version.label || "Версия",
|
||
version.width && version.height
|
||
? `${version.width}×${version.height}`
|
||
: "Готово к обработке"
|
||
);
|
||
renderHistory();
|
||
updateSuggestCodexButton();
|
||
setJobStatus("", "");
|
||
}
|
||
|
||
async function refreshHistory(selectLatest) {
|
||
const response = await apiFetch("/api/history", {
|
||
cache: "no-store"
|
||
});
|
||
|
||
if (!response.ok) {
|
||
throw new Error(`HTTP ${response.status}`);
|
||
}
|
||
|
||
const data = await response.json();
|
||
historyVersions = Array.isArray(data.versions) ? data.versions : [];
|
||
|
||
if (selectLatest && historyVersions.length > 0) {
|
||
const latest = historyVersions[historyVersions.length - 1];
|
||
currentVersionId = latest.id;
|
||
updateDownloadCurrent(latest);
|
||
showSourcePreviewFromUrl(
|
||
latest.url,
|
||
latest.label || "Текущая фотография",
|
||
latest.width && latest.height
|
||
? `${latest.width}×${latest.height}`
|
||
: "Готово к обработке"
|
||
);
|
||
}
|
||
|
||
renderHistory();
|
||
updateSuggestCodexButton();
|
||
}
|
||
|
||
async function loadHistoryOnStart() {
|
||
try {
|
||
await refreshHistory(true);
|
||
} catch {
|
||
historyVersions = [];
|
||
} finally {
|
||
renderHistory();
|
||
setBusy(false);
|
||
}
|
||
}
|
||
|
||
async function deleteVersion(version) {
|
||
if (!confirm("Удалить версию и все её ответвления?")) return;
|
||
|
||
try {
|
||
const response = await apiFetch(`/api/history/${version.id}`, {
|
||
method: "DELETE",
|
||
cache: "no-store"
|
||
});
|
||
const data = await readJsonResponse(response);
|
||
|
||
if (!response.ok || !data.ok) {
|
||
throw new Error(data.error || "Не удалось удалить версию.");
|
||
}
|
||
|
||
historyVersions = Array.isArray(data.versions) ? data.versions : [];
|
||
collapsedGroups.delete(version.id);
|
||
|
||
const currentSurvives = historyVersions.some((v) => v.id === currentVersionId);
|
||
|
||
if (!currentSurvives) {
|
||
const parent = version.parentId
|
||
? historyVersions.find((v) => v.id === version.parentId)
|
||
: null;
|
||
|
||
if (parent) {
|
||
selectHistoryVersion(parent);
|
||
} else if (historyVersions.length > 0) {
|
||
currentVersionId = null;
|
||
resetResult();
|
||
updateDownloadCurrent(null);
|
||
clearSourcePreview();
|
||
renderHistory();
|
||
} else {
|
||
clearEditorState();
|
||
}
|
||
} else {
|
||
renderHistory();
|
||
}
|
||
|
||
if (
|
||
referenceState?.source?.type === "history" &&
|
||
referenceState.source.versionId === version.id
|
||
) {
|
||
clearReference();
|
||
}
|
||
} catch (error) {
|
||
setJobStatus(
|
||
"error",
|
||
error instanceof Error ? error.message : "Не удалось удалить версию."
|
||
);
|
||
}
|
||
}
|
||
|
||
function showAuthOverlay() {
|
||
authOverlay.hidden = false;
|
||
logoutButton.hidden = true;
|
||
loginError.hidden = true;
|
||
loginPassword.value = "";
|
||
loginUsername.focus();
|
||
}
|
||
|
||
function hideAuthOverlay() {
|
||
authOverlay.hidden = true;
|
||
logoutButton.hidden = false;
|
||
}
|
||
|
||
async function checkAuth() {
|
||
try {
|
||
const response = await apiFetch("/api/me", { cache: "no-store" });
|
||
const data = await readJsonResponse(response);
|
||
|
||
if (data.ok) {
|
||
hideAuthOverlay();
|
||
loadHistoryOnStart();
|
||
return;
|
||
}
|
||
} catch {
|
||
// Сеть недоступна или 401 — считаем пользователя не вошедшим.
|
||
}
|
||
|
||
showAuthOverlay();
|
||
}
|
||
|
||
async function submitLogin(event) {
|
||
event.preventDefault();
|
||
|
||
loginError.hidden = true;
|
||
loginSubmit.disabled = true;
|
||
|
||
try {
|
||
const response = await fetch("/api/login", {
|
||
method: "POST",
|
||
credentials: "same-origin",
|
||
headers: { "content-type": "application/json" },
|
||
body: JSON.stringify({
|
||
username: loginUsername.value.trim(),
|
||
password: loginPassword.value
|
||
})
|
||
});
|
||
|
||
const data = await readJsonResponse(response);
|
||
|
||
if (!response.ok || !data.ok) {
|
||
throw new Error(data.error || "Неверный логин или пароль.");
|
||
}
|
||
|
||
loginPassword.value = "";
|
||
hideAuthOverlay();
|
||
loadHistoryOnStart();
|
||
} catch (error) {
|
||
loginError.textContent =
|
||
error instanceof Error ? error.message : "Неверный логин или пароль.";
|
||
loginError.hidden = false;
|
||
loginPassword.focus();
|
||
} finally {
|
||
loginSubmit.disabled = false;
|
||
}
|
||
}
|
||
|
||
async function logout() {
|
||
try {
|
||
await fetch("/api/logout", { method: "POST", credentials: "same-origin" });
|
||
} catch {
|
||
// Выходим локально даже при ошибке сети.
|
||
}
|
||
|
||
clearEditorState();
|
||
showAuthOverlay();
|
||
}
|
||
|
||
async function checkHealth() {
|
||
setHealthState("loading", "Проверяем сервер обработки…");
|
||
|
||
try {
|
||
const response = await fetch("/api/health", {
|
||
cache: "no-store",
|
||
credentials: "same-origin"
|
||
});
|
||
const data = await readJsonResponse(response);
|
||
|
||
if (data.ok) {
|
||
const modelTitle = data.selectedUpscaleModel
|
||
? `Upscale: ${data.selectedUpscaleModel}`
|
||
: "";
|
||
|
||
setHealthState(
|
||
"ok",
|
||
"Сервер обработки: доступен",
|
||
modelTitle
|
||
);
|
||
return;
|
||
}
|
||
|
||
if (data.comfyAvailable) {
|
||
const missing = Array.isArray(data.missingNodes)
|
||
? data.missingNodes.join(", ")
|
||
: "";
|
||
|
||
setHealthState(
|
||
"warning",
|
||
data.message || "Сервер настроен не полностью",
|
||
missing ? `Отсутствуют узлы: ${missing}` : ""
|
||
);
|
||
return;
|
||
}
|
||
|
||
setHealthState(
|
||
"error",
|
||
"Сервер обработки: недоступен",
|
||
data.message || ""
|
||
);
|
||
} catch (error) {
|
||
setHealthState(
|
||
"error",
|
||
"Сервер обработки: недоступен",
|
||
error instanceof Error ? error.message : ""
|
||
);
|
||
}
|
||
}
|
||
|
||
function clearReference() {
|
||
if (referenceState?.source?.type === "file" && referenceState.source.objectUrl) {
|
||
URL.revokeObjectURL(referenceState.source.objectUrl);
|
||
}
|
||
|
||
referenceState = null;
|
||
referencePreview.hidden = true;
|
||
referenceCanvas.width = 0;
|
||
referenceCanvas.height = 0;
|
||
referenceNote.hidden = true;
|
||
referenceRegionInfo.textContent = "Область не выбрана — потяните по превью";
|
||
referenceHistorySelect.value = "";
|
||
}
|
||
|
||
function drawRegion(ctx) {
|
||
const region = referenceState?.region;
|
||
|
||
if (!region) {
|
||
return;
|
||
}
|
||
|
||
ctx.strokeStyle = "rgba(220,38,38,0.95)";
|
||
ctx.lineWidth = 3;
|
||
ctx.fillStyle = "rgba(220,38,38,0.18)";
|
||
ctx.fillRect(region.x, region.y, region.w, region.h);
|
||
ctx.strokeRect(region.x, region.y, region.w, region.h);
|
||
}
|
||
|
||
function drawReferenceToCanvas(image) {
|
||
const naturalWidth = image.naturalWidth || image.width;
|
||
const naturalHeight = image.naturalHeight || image.height;
|
||
|
||
if (!naturalWidth || !naturalHeight) {
|
||
return;
|
||
}
|
||
|
||
const scale = Math.min(
|
||
1,
|
||
REFERENCE_MAX_DIMENSION / Math.max(naturalWidth, naturalHeight)
|
||
);
|
||
|
||
referenceCanvas.width = Math.round(naturalWidth * scale);
|
||
referenceCanvas.height = Math.round(naturalHeight * scale);
|
||
|
||
const ctx = referenceCanvas.getContext("2d");
|
||
ctx.clearRect(0, 0, referenceCanvas.width, referenceCanvas.height);
|
||
ctx.drawImage(image, 0, 0, referenceCanvas.width, referenceCanvas.height);
|
||
drawRegion(ctx);
|
||
}
|
||
|
||
function redrawReferenceCanvas() {
|
||
const image = referenceState?.source?.image;
|
||
|
||
if (!image || !referenceCanvas.width) {
|
||
return;
|
||
}
|
||
|
||
const ctx = referenceCanvas.getContext("2d");
|
||
ctx.clearRect(0, 0, referenceCanvas.width, referenceCanvas.height);
|
||
ctx.drawImage(image, 0, 0, referenceCanvas.width, referenceCanvas.height);
|
||
drawRegion(ctx);
|
||
updateReferenceNote();
|
||
}
|
||
|
||
function updateReferenceNote() {
|
||
if (!referenceState) {
|
||
referenceNote.hidden = true;
|
||
referenceRegionInfo.textContent = "Область не выбрана — потяните по превью";
|
||
return;
|
||
}
|
||
|
||
if (referenceState.region) {
|
||
const region = referenceState.region;
|
||
referenceNote.hidden = true;
|
||
referenceRegionInfo.textContent =
|
||
`Область: ${region.x},${region.y} ${region.w}×${region.h}`;
|
||
} else {
|
||
referenceNote.textContent = "Область не выбрана — Codex использует референс как ориентир";
|
||
referenceNote.hidden = false;
|
||
referenceRegionInfo.textContent = "Область не выбрана — потяните по превью";
|
||
}
|
||
}
|
||
|
||
function canvasPointFromEvent(event) {
|
||
const rect = referenceCanvas.getBoundingClientRect();
|
||
const x = (event.clientX - rect.left) * (referenceCanvas.width / rect.width);
|
||
const y = (event.clientY - rect.top) * (referenceCanvas.height / rect.height);
|
||
|
||
return {
|
||
x: Math.min(Math.max(0, x), referenceCanvas.width),
|
||
y: Math.min(Math.max(0, y), referenceCanvas.height)
|
||
};
|
||
}
|
||
|
||
function loadReferenceFromFile(file) {
|
||
if (!file) {
|
||
return;
|
||
}
|
||
|
||
const fileType = String(file.type || "").toLowerCase();
|
||
|
||
if (!["image/jpeg", "image/jpg", "image/png", "image/webp"].includes(fileType)) {
|
||
if (isHeicFile(file)) {
|
||
referenceNote.textContent = "HEIC не поддерживается как референс — выберите JPEG, PNG или WebP";
|
||
referenceNote.hidden = false;
|
||
} else {
|
||
setJobStatus("error", "Референс должен быть JPEG, PNG или WebP.");
|
||
}
|
||
|
||
return;
|
||
}
|
||
|
||
if (file.size > MAX_FILE_SIZE) {
|
||
setJobStatus("error", "Файл референса слишком большой. Максимальный размер — 25 МБ.");
|
||
return;
|
||
}
|
||
|
||
if (referenceState?.source?.type === "file" && referenceState.source.objectUrl) {
|
||
URL.revokeObjectURL(referenceState.source.objectUrl);
|
||
}
|
||
|
||
const objectUrl = URL.createObjectURL(file);
|
||
referenceState = { source: { type: "file", file, objectUrl }, region: null };
|
||
|
||
const image = new Image();
|
||
image.onload = () => {
|
||
referenceState.source.image = image;
|
||
drawReferenceToCanvas(image);
|
||
referencePreview.hidden = false;
|
||
updateReferenceNote();
|
||
};
|
||
image.onerror = () => {
|
||
URL.revokeObjectURL(objectUrl);
|
||
referenceState = null;
|
||
setJobStatus("error", "Не удалось прочитать изображение референса.");
|
||
};
|
||
image.src = objectUrl;
|
||
}
|
||
|
||
function loadReferenceFromHistory(version) {
|
||
if (!version) {
|
||
return;
|
||
}
|
||
|
||
if (referenceState?.source?.type === "file" && referenceState.source.objectUrl) {
|
||
URL.revokeObjectURL(referenceState.source.objectUrl);
|
||
}
|
||
|
||
referenceState = {
|
||
source: { type: "history", versionId: version.id, url: version.url },
|
||
region: null
|
||
};
|
||
|
||
const image = new Image();
|
||
image.onload = () => {
|
||
referenceState.source.image = image;
|
||
drawReferenceToCanvas(image);
|
||
referencePreview.hidden = false;
|
||
updateReferenceNote();
|
||
};
|
||
image.onerror = () => {
|
||
referenceState = null;
|
||
setJobStatus("error", "Не удалось загрузить референс из истории.");
|
||
};
|
||
image.src = version.url;
|
||
}
|
||
|
||
let regionDragStart = null;
|
||
|
||
referenceCanvas.addEventListener("pointerdown", (event) => {
|
||
if (isBusy || !referenceState) {
|
||
return;
|
||
}
|
||
|
||
event.preventDefault();
|
||
referenceCanvas.setPointerCapture(event.pointerId);
|
||
|
||
const pos = canvasPointFromEvent(event);
|
||
regionDragStart = pos;
|
||
referenceState.region = { x: pos.x, y: pos.y, w: 0, h: 0 };
|
||
redrawReferenceCanvas();
|
||
updateReferenceNote();
|
||
});
|
||
|
||
referenceCanvas.addEventListener("pointermove", (event) => {
|
||
if (isBusy || !referenceState || !regionDragStart) {
|
||
return;
|
||
}
|
||
|
||
const pos = canvasPointFromEvent(event);
|
||
const x = Math.min(regionDragStart.x, pos.x);
|
||
const y = Math.min(regionDragStart.y, pos.y);
|
||
const w = Math.max(regionDragStart.x, pos.x) - x;
|
||
const h = Math.max(regionDragStart.y, pos.y) - y;
|
||
|
||
referenceState.region = { x, y, w, h };
|
||
redrawReferenceCanvas();
|
||
updateReferenceNote();
|
||
});
|
||
|
||
referenceCanvas.addEventListener("pointerup", () => {
|
||
if (regionDragStart) {
|
||
regionDragStart = null;
|
||
|
||
if (
|
||
referenceState?.region &&
|
||
(referenceState.region.w < 3 || referenceState.region.h < 3)
|
||
) {
|
||
referenceState.region = null;
|
||
redrawReferenceCanvas();
|
||
updateReferenceNote();
|
||
}
|
||
}
|
||
});
|
||
|
||
referenceCanvas.addEventListener("pointercancel", () => {
|
||
regionDragStart = null;
|
||
});
|
||
|
||
function renderSuggestChips(container, items) {
|
||
container.innerHTML = "";
|
||
|
||
for (const item of items) {
|
||
const chip = document.createElement("button");
|
||
chip.type = "button";
|
||
chip.className = "suggest-chip";
|
||
chip.textContent = item;
|
||
chip.title = item;
|
||
chip.addEventListener("click", () => {
|
||
promptInput.value = item;
|
||
promptInput.dispatchEvent(new Event("input"));
|
||
});
|
||
container.appendChild(chip);
|
||
}
|
||
}
|
||
|
||
async function loadSuggestHistoryChips() {
|
||
try {
|
||
const response = await apiFetch("/api/suggest-prompts", {
|
||
cache: "no-store"
|
||
});
|
||
const data = await readJsonResponse(response);
|
||
|
||
if (!response.ok || !data.ok) {
|
||
throw new Error(data.error || "Не удалось получить подсказки.");
|
||
}
|
||
|
||
renderSuggestChips(suggestHistoryChips, data.prompts || []);
|
||
} catch (error) {
|
||
if (error instanceof Error && error.message === "Требуется авторизация.") {
|
||
return;
|
||
}
|
||
|
||
suggestCodexStatus.textContent = "Не удалось загрузить подсказки из истории.";
|
||
}
|
||
}
|
||
|
||
async function runSuggest() {
|
||
if (isBusy || suggestRunning || !currentVersionId) {
|
||
return;
|
||
}
|
||
|
||
suggestRunning = true;
|
||
suggestCodexButton.disabled = true;
|
||
suggestCodexStatus.textContent = "Codex думает…";
|
||
|
||
const form = new FormData();
|
||
form.append("sourceId", currentVersionId);
|
||
|
||
try {
|
||
const response = await apiFetch("/api/suggest", {
|
||
method: "POST",
|
||
body: form,
|
||
cache: "no-store"
|
||
});
|
||
const data = await readJsonResponse(response);
|
||
|
||
if (!response.ok || !data.ok) {
|
||
throw new Error(data.error || "Не удалось запросить подсказку.");
|
||
}
|
||
|
||
if (!data.jobId) {
|
||
throw new Error("Сервер не вернул идентификатор задания.");
|
||
}
|
||
|
||
pollSuggestJob(data.jobId);
|
||
} catch (error) {
|
||
suggestRunning = false;
|
||
updateSuggestCodexButton();
|
||
suggestCodexStatus.textContent =
|
||
error instanceof Error ? error.message : "Не удалось запросить подсказку.";
|
||
}
|
||
}
|
||
|
||
async function pollSuggestJob(jobId) {
|
||
try {
|
||
const response = await apiFetch(`/api/jobs/${jobId}/status`, {
|
||
cache: "no-store"
|
||
});
|
||
const data = await readJsonResponse(response);
|
||
|
||
if (!response.ok || !data.ok) {
|
||
throw new Error(data.error || "Не удалось получить статус задания.");
|
||
}
|
||
|
||
suggestPollFailures = 0;
|
||
|
||
if (data.status === "running") {
|
||
setTimeout(() => pollSuggestJob(jobId), 3000);
|
||
return;
|
||
}
|
||
|
||
if (data.status === "done") {
|
||
renderSuggestChips(suggestCodexChips, data.suggestions || []);
|
||
suggestCodexStatus.textContent = "Варианты готовы — нажмите, чтобы вставить";
|
||
suggestRunning = false;
|
||
updateSuggestCodexButton();
|
||
return;
|
||
}
|
||
|
||
if (data.status === "error") {
|
||
throw new Error(data.error || "Задание завершилось с ошибкой.");
|
||
}
|
||
|
||
throw new Error("Неизвестный статус задания.");
|
||
} catch (error) {
|
||
if (error instanceof Error && error.message === "Требуется авторизация.") {
|
||
setBusy(false);
|
||
suggestRunning = false;
|
||
suggestCodexButton.disabled = true;
|
||
return;
|
||
}
|
||
|
||
suggestPollFailures += 1;
|
||
|
||
if (suggestPollFailures >= 5) {
|
||
suggestRunning = false;
|
||
updateSuggestCodexButton();
|
||
suggestCodexStatus.textContent =
|
||
error instanceof Error
|
||
? error.message
|
||
: "Ошибка связи с сервером. Попробуйте ещё раз.";
|
||
return;
|
||
}
|
||
|
||
setTimeout(() => pollSuggestJob(jobId), 3000);
|
||
}
|
||
}
|
||
|
||
dropZone.addEventListener("click", openFilePicker);
|
||
|
||
dropZone.addEventListener("keydown", (event) => {
|
||
if (event.key === "Enter" || event.key === " ") {
|
||
event.preventDefault();
|
||
openFilePicker();
|
||
}
|
||
});
|
||
|
||
photoInput.addEventListener("change", () => {
|
||
handleFiles(photoInput.files);
|
||
photoInput.value = "";
|
||
});
|
||
|
||
["dragenter", "dragover", "dragleave", "drop"].forEach((eventName) => {
|
||
dropZone.addEventListener(eventName, preventDragDefaults);
|
||
});
|
||
|
||
["dragenter", "dragover"].forEach((eventName) => {
|
||
dropZone.addEventListener(eventName, () => {
|
||
if (!isBusy) {
|
||
dropZone.classList.add("is-dragging");
|
||
}
|
||
});
|
||
});
|
||
|
||
["dragleave", "drop"].forEach((eventName) => {
|
||
dropZone.addEventListener(eventName, () => {
|
||
dropZone.classList.remove("is-dragging");
|
||
});
|
||
});
|
||
|
||
dropZone.addEventListener("drop", (event) => {
|
||
if (!isBusy) {
|
||
handleFiles(event.dataTransfer?.files);
|
||
}
|
||
});
|
||
|
||
promptInput.addEventListener("input", () => {
|
||
promptCounter.textContent = `${promptInput.value.length} / 1000`;
|
||
});
|
||
|
||
editButton.addEventListener("click", () => runJob("edit"));
|
||
upscaleButton.addEventListener("click", () => runJob("upscale"));
|
||
|
||
referenceUploadButton.addEventListener("click", () => {
|
||
if (!isBusy) {
|
||
referenceFileInput.click();
|
||
}
|
||
});
|
||
|
||
referenceFileInput.addEventListener("change", () => {
|
||
loadReferenceFromFile(referenceFileInput.files?.[0]);
|
||
referenceFileInput.value = "";
|
||
});
|
||
|
||
referenceHistorySelect.addEventListener("change", () => {
|
||
const version = historyVersions.find(
|
||
(entry) => entry.id === referenceHistorySelect.value
|
||
);
|
||
|
||
if (version) {
|
||
loadReferenceFromHistory(version);
|
||
}
|
||
});
|
||
|
||
referenceClearButton.addEventListener("click", clearReference);
|
||
|
||
referenceRegionReset.addEventListener("click", () => {
|
||
if (!referenceState) {
|
||
return;
|
||
}
|
||
|
||
referenceState.region = null;
|
||
redrawReferenceCanvas();
|
||
updateReferenceNote();
|
||
});
|
||
|
||
suggestButton.addEventListener("click", () => {
|
||
suggestPanel.hidden = !suggestPanel.hidden;
|
||
|
||
if (!suggestPanel.hidden) {
|
||
loadSuggestHistoryChips();
|
||
}
|
||
});
|
||
|
||
suggestCodexButton.addEventListener("click", runSuggest);
|
||
|
||
loginForm.addEventListener("submit", submitLogin);
|
||
logoutButton.addEventListener("click", logout);
|
||
|
||
window.addEventListener("beforeunload", () => {
|
||
if (sourceObjectUrl) {
|
||
URL.revokeObjectURL(sourceObjectUrl);
|
||
}
|
||
|
||
if (referenceState?.source?.type === "file" && referenceState.source.objectUrl) {
|
||
URL.revokeObjectURL(referenceState.source.objectUrl);
|
||
}
|
||
});
|
||
|
||
checkHealth();
|
||
checkAuth();
|