3741 lines
130 KiB
JavaScript
3741 lines
130 KiB
JavaScript
(function () {
|
|
"use strict";
|
|
|
|
let overlayEl = null;
|
|
let backdropEl = null;
|
|
let toolbarEl = null;
|
|
let settingsButtonEl = null;
|
|
let settingsPanelEl = null;
|
|
let helpButtonEl = null;
|
|
let helpPanelEl = null;
|
|
let compareButtonEl = null;
|
|
let deckCompareButtonEl = null;
|
|
let mobileInfoButtonEl = null;
|
|
let mobileInfoPrimaryTabEl = null;
|
|
let mobileInfoSecondaryTabEl = null;
|
|
let deckComparePanelEl = null;
|
|
let deckCompareMessageEl = null;
|
|
let deckCompareDeckListEl = null;
|
|
let zoomControlEl = null;
|
|
let zoomSliderEl = null;
|
|
let zoomValueEl = null;
|
|
let opacityControlEl = null;
|
|
let opacitySliderEl = null;
|
|
let opacityValueEl = null;
|
|
let exportButtonEl = null;
|
|
let stageEl = null;
|
|
let frameEl = null;
|
|
let baseLayerEl = null;
|
|
let overlayLayerEl = null;
|
|
let compareGridEl = null;
|
|
let imageEl = null;
|
|
let overlayImageEl = null;
|
|
let primaryInfoEl = null;
|
|
let primaryTitleEl = null;
|
|
let primaryGroupsEl = null;
|
|
let primaryHintEl = null;
|
|
let secondaryInfoEl = null;
|
|
let secondaryTitleEl = null;
|
|
let secondaryGroupsEl = null;
|
|
let secondaryHintEl = null;
|
|
let mobileInfoPanelEl = null;
|
|
let mobileInfoTitleEl = null;
|
|
let mobileInfoGroupsEl = null;
|
|
let mobileInfoHintEl = null;
|
|
let mobilePrevButtonEl = null;
|
|
let mobileNextButtonEl = null;
|
|
let compareGridSlots = [];
|
|
let zoomed = false;
|
|
let previousFocusedEl = null;
|
|
let activePointerId = null;
|
|
let activePointerStartX = 0;
|
|
let activePointerStartY = 0;
|
|
let activePointerMoved = false;
|
|
let activePinchGesture = null;
|
|
let suppressNextCardClick = false;
|
|
let suppressDeckCompareToggleUntil = 0;
|
|
|
|
const LIGHTBOX_ZOOM_SCALE = 6.66;
|
|
const LIGHTBOX_ZOOM_STEP = 0.1;
|
|
const LIGHTBOX_PAN_STEP = 4;
|
|
const LIGHTBOX_COMPARE_DEFAULT_OVERLAY_OPACITY = 0.5;
|
|
const LIGHTBOX_COMPARE_SEQUENCE_STEP_KEYS = new Set(["ArrowLeft", "ArrowRight"]);
|
|
const LIGHTBOX_EXPORT_MIME_TYPE = "image/webp";
|
|
const LIGHTBOX_EXPORT_QUALITY = 0.96;
|
|
const LIGHTBOX_INFO_VISIBLE_STORAGE_KEY = "tarot-lightbox-info-visible-v1";
|
|
|
|
const lightboxState = {
|
|
isOpen: false,
|
|
compareMode: false,
|
|
deckCompareMode: false,
|
|
allowOverlayCompare: false,
|
|
allowDeckCompare: false,
|
|
primaryCard: null,
|
|
secondaryCard: null,
|
|
activeDeckId: "",
|
|
activeDeckLabel: "",
|
|
availableCompareDecks: [],
|
|
selectedCompareDeckIds: [],
|
|
deckCompareCards: [],
|
|
maxCompareDecks: 2,
|
|
deckComparePickerOpen: false,
|
|
deckCompareMessage: "",
|
|
sequenceIds: [],
|
|
resolveCardById: null,
|
|
resolveDeckCardById: null,
|
|
onSelectCardId: null,
|
|
overlayOpacity: LIGHTBOX_COMPARE_DEFAULT_OVERLAY_OPACITY,
|
|
zoomScale: LIGHTBOX_ZOOM_SCALE,
|
|
settingsMenuOpen: false,
|
|
helpOpen: false,
|
|
primaryRotated: false,
|
|
overlayRotated: false,
|
|
mobileInfoOpen: false,
|
|
mobileInfoView: "primary",
|
|
zoomOriginX: 50,
|
|
zoomOriginY: 50,
|
|
exportInProgress: false
|
|
};
|
|
|
|
function hasSecondaryCard() {
|
|
return Boolean(lightboxState.secondaryCard?.src);
|
|
}
|
|
|
|
function isCompactLightboxLayout() {
|
|
if (typeof window === "undefined") {
|
|
return false;
|
|
}
|
|
|
|
if (typeof window.matchMedia === "function") {
|
|
return window.matchMedia("(max-width: 900px)").matches;
|
|
}
|
|
|
|
return Number(window.innerWidth) <= 900;
|
|
}
|
|
|
|
function hasSequenceNavigation() {
|
|
return Array.isArray(lightboxState.sequenceIds)
|
|
&& lightboxState.sequenceIds.length > 1
|
|
&& typeof lightboxState.resolveCardById === "function";
|
|
}
|
|
|
|
function getActiveMobileInfoView() {
|
|
return lightboxState.compareMode && hasSecondaryCard() && lightboxState.mobileInfoView === "overlay"
|
|
? "overlay"
|
|
: "primary";
|
|
}
|
|
|
|
function getEffectiveMaxCompareDecks() {
|
|
return isCompactLightboxLayout()
|
|
? Math.min(1, lightboxState.maxCompareDecks)
|
|
: lightboxState.maxCompareDecks;
|
|
}
|
|
|
|
function getCompareDeckLimitMessage() {
|
|
const compareDeckLimit = getEffectiveMaxCompareDecks();
|
|
if (compareDeckLimit === 1 && isCompactLightboxLayout()) {
|
|
return "Choose 1 extra deck on mobile.";
|
|
}
|
|
|
|
return `Choose up to ${compareDeckLimit} extra decks.`;
|
|
}
|
|
|
|
function shouldHandleCompactPointerGesture(event) {
|
|
return Boolean(
|
|
lightboxState.isOpen
|
|
&& isCompactLightboxLayout()
|
|
&& event?.pointerType
|
|
&& event.pointerType !== "mouse"
|
|
);
|
|
}
|
|
|
|
function clearActivePointerGesture() {
|
|
activePointerId = null;
|
|
activePointerStartX = 0;
|
|
activePointerStartY = 0;
|
|
activePointerMoved = false;
|
|
}
|
|
|
|
function clearActivePinchGesture() {
|
|
activePinchGesture = null;
|
|
}
|
|
|
|
function getTouchMidpoint(touches) {
|
|
if (!touches || touches.length < 2) {
|
|
return null;
|
|
}
|
|
|
|
const first = touches[0];
|
|
const second = touches[1];
|
|
if (!first || !second) {
|
|
return null;
|
|
}
|
|
|
|
return {
|
|
x: (Number(first.clientX) + Number(second.clientX)) / 2,
|
|
y: (Number(first.clientY) + Number(second.clientY)) / 2
|
|
};
|
|
}
|
|
|
|
function getTouchDistance(touches) {
|
|
if (!touches || touches.length < 2) {
|
|
return 0;
|
|
}
|
|
|
|
const first = touches[0];
|
|
const second = touches[1];
|
|
if (!first || !second) {
|
|
return 0;
|
|
}
|
|
|
|
return Math.hypot(Number(first.clientX) - Number(second.clientX), Number(first.clientY) - Number(second.clientY));
|
|
}
|
|
|
|
function consumeSuppressedCardClick() {
|
|
if (!suppressNextCardClick) {
|
|
return false;
|
|
}
|
|
|
|
suppressNextCardClick = false;
|
|
return true;
|
|
}
|
|
|
|
function clampOverlayOpacity(value) {
|
|
const numericValue = Number(value);
|
|
if (!Number.isFinite(numericValue)) {
|
|
return LIGHTBOX_COMPARE_DEFAULT_OVERLAY_OPACITY;
|
|
}
|
|
|
|
return Math.min(1, Math.max(0.05, numericValue));
|
|
}
|
|
|
|
function clampZoomScale(value) {
|
|
const numericValue = Number(value);
|
|
if (!Number.isFinite(numericValue)) {
|
|
return 1;
|
|
}
|
|
|
|
return Math.min(LIGHTBOX_ZOOM_SCALE, Math.max(1, numericValue));
|
|
}
|
|
|
|
function readStorageValue(key) {
|
|
try {
|
|
return window.localStorage?.getItem?.(key) ?? "";
|
|
} catch (_error) {
|
|
return "";
|
|
}
|
|
}
|
|
|
|
function writeStorageValue(key, value) {
|
|
try {
|
|
window.localStorage?.setItem?.(key, value);
|
|
return true;
|
|
} catch (_error) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
function getPersistedInfoPanelVisibility() {
|
|
return String(readStorageValue(LIGHTBOX_INFO_VISIBLE_STORAGE_KEY) || "") === "1";
|
|
}
|
|
|
|
function setInfoPanelOpen(nextOpen, options = {}) {
|
|
const persist = options.persist !== false;
|
|
lightboxState.mobileInfoOpen = Boolean(nextOpen);
|
|
if (persist) {
|
|
writeStorageValue(LIGHTBOX_INFO_VISIBLE_STORAGE_KEY, lightboxState.mobileInfoOpen ? "1" : "0");
|
|
}
|
|
}
|
|
|
|
function sanitizeExportToken(value, fallback = "tarot") {
|
|
const normalized = String(value || "")
|
|
.trim()
|
|
.toLowerCase()
|
|
.replace(/[^a-z0-9]+/g, "-")
|
|
.replace(/(^-|-$)/g, "");
|
|
|
|
return normalized || fallback;
|
|
}
|
|
|
|
function canvasToBlobByFormat(canvas, mimeType, quality) {
|
|
return new Promise((resolve, reject) => {
|
|
canvas.toBlob((blob) => {
|
|
if (blob) {
|
|
resolve(blob);
|
|
return;
|
|
}
|
|
reject(new Error("Canvas export failed."));
|
|
}, mimeType, quality);
|
|
});
|
|
}
|
|
|
|
function getVisibleElementRect(element) {
|
|
if (!(element instanceof HTMLElement)) {
|
|
return null;
|
|
}
|
|
|
|
const computedStyle = window.getComputedStyle(element);
|
|
if (computedStyle.display === "none" || computedStyle.visibility === "hidden") {
|
|
return null;
|
|
}
|
|
|
|
const rect = element.getBoundingClientRect();
|
|
if (!rect.width || !rect.height) {
|
|
return null;
|
|
}
|
|
|
|
return {
|
|
left: rect.left,
|
|
top: rect.top,
|
|
right: rect.right,
|
|
bottom: rect.bottom,
|
|
width: rect.width,
|
|
height: rect.height
|
|
};
|
|
}
|
|
|
|
function getCssPixelNumber(value, fallback = 0) {
|
|
const numericValue = Number.parseFloat(String(value || ""));
|
|
return Number.isFinite(numericValue) ? numericValue : fallback;
|
|
}
|
|
|
|
function drawRoundedRectPath(context, x, y, width, height, radius) {
|
|
const safeRadius = Math.max(0, Math.min(radius, width / 2, height / 2));
|
|
context.beginPath();
|
|
context.moveTo(x + safeRadius, y);
|
|
context.arcTo(x + width, y, x + width, y + height, safeRadius);
|
|
context.arcTo(x + width, y + height, x, y + height, safeRadius);
|
|
context.arcTo(x, y + height, x, y, safeRadius);
|
|
context.arcTo(x, y, x + width, y, safeRadius);
|
|
context.closePath();
|
|
}
|
|
|
|
function wrapCanvasText(context, text, maxWidth) {
|
|
const normalized = String(text || "").replace(/\s+/g, " ").trim();
|
|
if (!normalized) {
|
|
return [];
|
|
}
|
|
|
|
const words = normalized.split(" ");
|
|
const lines = [];
|
|
let currentLine = words.shift() || "";
|
|
|
|
words.forEach((word) => {
|
|
const nextLine = currentLine ? `${currentLine} ${word}` : word;
|
|
if (context.measureText(nextLine).width <= maxWidth || !currentLine) {
|
|
currentLine = nextLine;
|
|
return;
|
|
}
|
|
|
|
lines.push(currentLine);
|
|
currentLine = word;
|
|
});
|
|
|
|
if (currentLine) {
|
|
lines.push(currentLine);
|
|
}
|
|
|
|
return lines;
|
|
}
|
|
|
|
function extractPanelSections(panelEl) {
|
|
if (!(panelEl instanceof HTMLElement)) {
|
|
return null;
|
|
}
|
|
|
|
const title = String(panelEl.children[0]?.textContent || "").trim();
|
|
const groupsRoot = panelEl.children[1] instanceof HTMLElement ? panelEl.children[1] : null;
|
|
const hint = String(panelEl.children[2]?.textContent || "").trim();
|
|
const groups = groupsRoot
|
|
? Array.from(groupsRoot.children).map((sectionEl) => {
|
|
const titleEl = sectionEl.children[0];
|
|
const valuesEl = sectionEl.children[1];
|
|
return {
|
|
title: String(titleEl?.textContent || "").trim(),
|
|
items: valuesEl instanceof HTMLElement
|
|
? Array.from(valuesEl.children).map((itemEl) => String(itemEl.textContent || "").trim()).filter(Boolean)
|
|
: []
|
|
};
|
|
}).filter((group) => group.title && group.items.length)
|
|
: [];
|
|
|
|
return { title, hint, groups };
|
|
}
|
|
|
|
async function loadExportImageAsset(source, cache) {
|
|
const normalizedSource = String(source || "").trim();
|
|
if (!normalizedSource) {
|
|
return null;
|
|
}
|
|
|
|
if (cache.has(normalizedSource)) {
|
|
return cache.get(normalizedSource);
|
|
}
|
|
|
|
const pending = (async () => {
|
|
const response = await fetch(normalizedSource);
|
|
if (!response.ok) {
|
|
throw new Error(`Failed to load export image: ${normalizedSource}`);
|
|
}
|
|
|
|
const blob = await response.blob();
|
|
if (typeof createImageBitmap === "function") {
|
|
try {
|
|
return await createImageBitmap(blob);
|
|
} catch (_error) {
|
|
}
|
|
}
|
|
|
|
const blobUrl = URL.createObjectURL(blob);
|
|
try {
|
|
return await new Promise((resolve, reject) => {
|
|
const image = new Image();
|
|
image.decoding = "async";
|
|
image.onload = () => resolve(image);
|
|
image.onerror = () => reject(new Error(`Failed to decode export image: ${normalizedSource}`));
|
|
image.src = blobUrl;
|
|
});
|
|
} finally {
|
|
URL.revokeObjectURL(blobUrl);
|
|
}
|
|
})();
|
|
|
|
cache.set(normalizedSource, pending);
|
|
return pending;
|
|
}
|
|
|
|
function buildLightboxExportLayout() {
|
|
const items = [];
|
|
const pushPanel = (panelEl) => {
|
|
const rect = getVisibleElementRect(panelEl);
|
|
if (!rect) {
|
|
return;
|
|
}
|
|
|
|
const sections = extractPanelSections(panelEl);
|
|
if (!sections?.title) {
|
|
return;
|
|
}
|
|
|
|
const computedStyle = window.getComputedStyle(panelEl);
|
|
items.push({
|
|
type: "panel",
|
|
rect,
|
|
title: sections.title,
|
|
hint: sections.hint,
|
|
groups: sections.groups,
|
|
backgroundColor: computedStyle.backgroundColor || "rgba(2, 6, 23, 0.86)",
|
|
borderColor: computedStyle.borderColor || "rgba(148, 163, 184, 0.16)",
|
|
borderRadius: getCssPixelNumber(computedStyle.borderTopLeftRadius, 18)
|
|
});
|
|
};
|
|
|
|
if (lightboxState.deckCompareMode) {
|
|
const visibleCards = [lightboxState.primaryCard, ...lightboxState.deckCompareCards].filter(Boolean);
|
|
compareGridSlots.forEach((slot, index) => {
|
|
const cardRequest = visibleCards[index] || null;
|
|
const rect = getVisibleElementRect(slot?.slotEl);
|
|
const headerRect = getVisibleElementRect(slot?.headerEl);
|
|
const mediaRect = getVisibleElementRect(slot?.mediaEl);
|
|
if (!cardRequest || !rect || !headerRect || !mediaRect) {
|
|
return;
|
|
}
|
|
|
|
items.push({
|
|
type: "deck-card",
|
|
rect,
|
|
headerRect,
|
|
mediaRect,
|
|
badge: String(slot.badgeEl?.textContent || cardRequest.deckLabel || "Deck").trim(),
|
|
label: String(slot.cardLabelEl?.textContent || cardRequest.label || "Tarot card").trim(),
|
|
src: String(cardRequest.src || "").trim(),
|
|
missingReason: String(cardRequest.missingReason || slot.fallbackEl?.textContent || "Card image unavailable.").trim(),
|
|
rotated: Boolean(lightboxState.primaryRotated)
|
|
});
|
|
});
|
|
} else {
|
|
const rect = getVisibleElementRect(frameEl);
|
|
if (rect) {
|
|
const computedStyle = window.getComputedStyle(frameEl);
|
|
items.push({
|
|
type: "frame",
|
|
rect,
|
|
backgroundColor: computedStyle.backgroundColor || "transparent",
|
|
borderRadius: getCssPixelNumber(computedStyle.borderTopLeftRadius, 0),
|
|
primarySrc: String(lightboxState.primaryCard?.src || "").trim(),
|
|
primaryMissingReason: String(lightboxState.primaryCard?.missingReason || "Card image unavailable.").trim(),
|
|
overlaySrc: hasSecondaryCard() && window.getComputedStyle(overlayImageEl).display !== "none"
|
|
? String(lightboxState.secondaryCard?.src || "").trim()
|
|
: "",
|
|
overlayMissingReason: String(lightboxState.secondaryCard?.missingReason || "Overlay image unavailable.").trim(),
|
|
primaryRotated: Boolean(isPrimaryRotationActive()),
|
|
overlayRotated: Boolean(isOverlayRotationActive()),
|
|
overlayOpacity: Number(lightboxState.overlayOpacity) || LIGHTBOX_COMPARE_DEFAULT_OVERLAY_OPACITY
|
|
});
|
|
}
|
|
}
|
|
|
|
pushPanel(primaryInfoEl);
|
|
pushPanel(secondaryInfoEl);
|
|
pushPanel(mobileInfoPanelEl);
|
|
|
|
if (!items.length) {
|
|
return null;
|
|
}
|
|
|
|
const padding = 16;
|
|
const minLeft = Math.min(...items.map((item) => item.rect.left));
|
|
const minTop = Math.min(...items.map((item) => item.rect.top));
|
|
const maxRight = Math.max(...items.map((item) => item.rect.right));
|
|
const maxBottom = Math.max(...items.map((item) => item.rect.bottom));
|
|
|
|
return {
|
|
padding,
|
|
minLeft,
|
|
minTop,
|
|
width: Math.max(1, Math.ceil((maxRight - minLeft) + (padding * 2))),
|
|
height: Math.max(1, Math.ceil((maxBottom - minTop) + (padding * 2))),
|
|
items
|
|
};
|
|
}
|
|
|
|
function toExportRect(layout, rect) {
|
|
return {
|
|
x: Math.round((rect.left - layout.minLeft) + layout.padding),
|
|
y: Math.round((rect.top - layout.minTop) + layout.padding),
|
|
width: Math.round(rect.width),
|
|
height: Math.round(rect.height)
|
|
};
|
|
}
|
|
|
|
function drawContainedVisual(context, asset, rect, options = {}) {
|
|
const inset = Number(options.inset) || 0;
|
|
const opacity = Number.isFinite(Number(options.opacity)) ? Number(options.opacity) : 1;
|
|
const rotation = options.rotation === 180 ? Math.PI : 0;
|
|
const innerWidth = Math.max(1, rect.width - (inset * 2));
|
|
const innerHeight = Math.max(1, rect.height - (inset * 2));
|
|
const sourceWidth = asset?.width || asset?.naturalWidth || 0;
|
|
const sourceHeight = asset?.height || asset?.naturalHeight || 0;
|
|
|
|
if (!sourceWidth || !sourceHeight) {
|
|
return;
|
|
}
|
|
|
|
const scale = Math.min(innerWidth / sourceWidth, innerHeight / sourceHeight);
|
|
const drawWidth = sourceWidth * scale;
|
|
const drawHeight = sourceHeight * scale;
|
|
const drawX = rect.x + inset + ((innerWidth - drawWidth) / 2);
|
|
const drawY = rect.y + inset + ((innerHeight - drawHeight) / 2);
|
|
|
|
context.save();
|
|
context.globalAlpha = opacity;
|
|
context.translate(drawX + (drawWidth / 2), drawY + (drawHeight / 2));
|
|
if (rotation) {
|
|
context.rotate(rotation);
|
|
}
|
|
context.drawImage(asset, -drawWidth / 2, -drawHeight / 2, drawWidth, drawHeight);
|
|
context.restore();
|
|
}
|
|
|
|
function drawFallbackText(context, rect, text) {
|
|
context.save();
|
|
drawRoundedRectPath(context, rect.x, rect.y, rect.width, rect.height, 16);
|
|
context.fillStyle = "rgba(15, 23, 42, 0.82)";
|
|
context.fill();
|
|
context.fillStyle = "rgba(226, 232, 240, 0.9)";
|
|
context.font = "600 14px sans-serif";
|
|
context.textAlign = "center";
|
|
context.textBaseline = "middle";
|
|
const lines = wrapCanvasText(context, text, Math.max(80, rect.width - 32));
|
|
const lineHeight = 18;
|
|
const startY = rect.y + (rect.height / 2) - (((lines.length - 1) * lineHeight) / 2);
|
|
lines.forEach((line, index) => {
|
|
context.fillText(line, rect.x + (rect.width / 2), startY + (index * lineHeight));
|
|
});
|
|
context.restore();
|
|
}
|
|
|
|
function drawPanel(context, item, layout) {
|
|
const rect = toExportRect(layout, item.rect);
|
|
context.save();
|
|
drawRoundedRectPath(context, rect.x, rect.y, rect.width, rect.height, item.borderRadius || 18);
|
|
context.fillStyle = item.backgroundColor || "rgba(2, 6, 23, 0.86)";
|
|
context.fill();
|
|
context.lineWidth = 1;
|
|
context.strokeStyle = item.borderColor || "rgba(148, 163, 184, 0.16)";
|
|
context.stroke();
|
|
|
|
const contentX = rect.x + 16;
|
|
const contentWidth = Math.max(80, rect.width - 32);
|
|
let cursorY = rect.y + 18;
|
|
|
|
context.fillStyle = "#f8fafc";
|
|
context.font = "700 13px sans-serif";
|
|
context.textBaseline = "top";
|
|
wrapCanvasText(context, item.title, contentWidth).forEach((line) => {
|
|
context.fillText(line, contentX, cursorY);
|
|
cursorY += 16;
|
|
});
|
|
cursorY += 6;
|
|
|
|
item.groups.forEach((group, groupIndex) => {
|
|
if (groupIndex > 0) {
|
|
context.strokeStyle = "rgba(148, 163, 184, 0.14)";
|
|
context.lineWidth = 1;
|
|
context.beginPath();
|
|
context.moveTo(contentX, cursorY + 2);
|
|
context.lineTo(contentX + contentWidth, cursorY + 2);
|
|
context.stroke();
|
|
cursorY += 10;
|
|
}
|
|
|
|
context.fillStyle = "rgba(148, 163, 184, 0.92)";
|
|
context.font = "600 10px sans-serif";
|
|
wrapCanvasText(context, String(group.title || "").toUpperCase(), contentWidth).forEach((line) => {
|
|
context.fillText(line, contentX, cursorY);
|
|
cursorY += 12;
|
|
});
|
|
cursorY += 4;
|
|
|
|
context.fillStyle = "#f8fafc";
|
|
context.font = "500 12px sans-serif";
|
|
group.items.forEach((entry) => {
|
|
wrapCanvasText(context, entry, contentWidth).forEach((line) => {
|
|
context.fillText(line, contentX, cursorY);
|
|
cursorY += 16;
|
|
});
|
|
});
|
|
cursorY += 2;
|
|
});
|
|
|
|
if (item.hint) {
|
|
cursorY += 4;
|
|
context.fillStyle = "rgba(226, 232, 240, 0.82)";
|
|
context.font = "500 11px sans-serif";
|
|
wrapCanvasText(context, item.hint, contentWidth).forEach((line) => {
|
|
context.fillText(line, contentX, cursorY);
|
|
cursorY += 14;
|
|
});
|
|
}
|
|
|
|
context.restore();
|
|
}
|
|
|
|
function drawDeckCompareCard(context, item, layout, asset) {
|
|
const slotRect = toExportRect(layout, item.rect);
|
|
const headerRect = toExportRect(layout, item.headerRect);
|
|
const mediaRect = toExportRect(layout, item.mediaRect);
|
|
|
|
context.save();
|
|
drawRoundedRectPath(context, slotRect.x, slotRect.y, slotRect.width, slotRect.height, 22);
|
|
context.fillStyle = "rgba(11, 15, 26, 0.76)";
|
|
context.fill();
|
|
|
|
drawRoundedRectPath(context, headerRect.x, headerRect.y, headerRect.width, headerRect.height, 0);
|
|
context.fillStyle = "rgba(15, 23, 42, 0.72)";
|
|
context.fill();
|
|
|
|
context.fillStyle = "#f8fafc";
|
|
context.font = "700 11px sans-serif";
|
|
context.textBaseline = "top";
|
|
context.fillText(item.badge, headerRect.x + 12, headerRect.y + 10);
|
|
|
|
context.fillStyle = "rgba(226, 232, 240, 0.84)";
|
|
context.font = "500 11px sans-serif";
|
|
const labelLines = wrapCanvasText(context, item.label, Math.max(80, headerRect.width - 24));
|
|
const labelText = labelLines.slice(0, 2).join(" ");
|
|
context.fillText(labelText, headerRect.x + 12, headerRect.y + 26);
|
|
|
|
if (asset) {
|
|
drawContainedVisual(context, asset, mediaRect, {
|
|
inset: 16,
|
|
rotation: item.rotated ? 180 : 0,
|
|
opacity: 1
|
|
});
|
|
} else {
|
|
drawFallbackText(context, {
|
|
x: mediaRect.x + 16,
|
|
y: mediaRect.y + 16,
|
|
width: Math.max(1, mediaRect.width - 32),
|
|
height: Math.max(1, mediaRect.height - 32)
|
|
}, item.missingReason);
|
|
}
|
|
|
|
context.restore();
|
|
}
|
|
|
|
function drawFrameVisual(context, item, layout, primaryAsset, overlayAsset) {
|
|
const rect = toExportRect(layout, item.rect);
|
|
context.save();
|
|
|
|
if (item.backgroundColor && item.backgroundColor !== "rgba(0, 0, 0, 0)" && item.backgroundColor !== "transparent") {
|
|
drawRoundedRectPath(context, rect.x, rect.y, rect.width, rect.height, item.borderRadius || 0);
|
|
context.fillStyle = item.backgroundColor;
|
|
context.fill();
|
|
}
|
|
|
|
if (primaryAsset) {
|
|
drawContainedVisual(context, primaryAsset, rect, {
|
|
inset: 0,
|
|
rotation: item.primaryRotated ? 180 : 0,
|
|
opacity: 1
|
|
});
|
|
} else {
|
|
drawFallbackText(context, rect, item.primaryMissingReason);
|
|
}
|
|
|
|
if (overlayAsset) {
|
|
drawContainedVisual(context, overlayAsset, rect, {
|
|
inset: 0,
|
|
rotation: item.overlayRotated ? 180 : 0,
|
|
opacity: item.overlayOpacity
|
|
});
|
|
}
|
|
|
|
context.restore();
|
|
}
|
|
|
|
function syncExportButton() {
|
|
if (!exportButtonEl) {
|
|
return;
|
|
}
|
|
|
|
const canShow = lightboxState.isOpen && !zoomed;
|
|
exportButtonEl.style.display = canShow ? "inline-flex" : "none";
|
|
exportButtonEl.disabled = !canShow || lightboxState.exportInProgress;
|
|
exportButtonEl.textContent = lightboxState.exportInProgress ? "Exporting..." : "Export WebP";
|
|
exportButtonEl.style.opacity = exportButtonEl.disabled ? "0.6" : "1";
|
|
exportButtonEl.style.cursor = exportButtonEl.disabled ? "progress" : "pointer";
|
|
}
|
|
|
|
async function exportCurrentLightboxView() {
|
|
if (!lightboxState.isOpen || lightboxState.exportInProgress) {
|
|
return;
|
|
}
|
|
|
|
lightboxState.exportInProgress = true;
|
|
syncExportButton();
|
|
|
|
try {
|
|
closeSettingsMenu();
|
|
applyComparePresentation();
|
|
await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
|
|
|
|
const layout = buildLightboxExportLayout();
|
|
if (!layout) {
|
|
throw new Error("Lightbox scene is not ready to export.");
|
|
}
|
|
|
|
const scale = Math.max(2, Math.min(3, Number(window.devicePixelRatio) || 1));
|
|
const canvas = document.createElement("canvas");
|
|
canvas.width = Math.max(1, Math.ceil(layout.width * scale));
|
|
canvas.height = Math.max(1, Math.ceil(layout.height * scale));
|
|
|
|
const context = canvas.getContext("2d");
|
|
if (!context) {
|
|
throw new Error("Canvas context is unavailable.");
|
|
}
|
|
|
|
context.scale(scale, scale);
|
|
context.imageSmoothingEnabled = true;
|
|
context.imageSmoothingQuality = "high";
|
|
context.fillStyle = lightboxState.deckCompareMode || lightboxState.compareMode
|
|
? "rgba(0, 0, 0, 0.88)"
|
|
: "rgba(0, 0, 0, 0.82)";
|
|
context.fillRect(0, 0, layout.width, layout.height);
|
|
|
|
const imageCache = new Map();
|
|
const assetEntries = await Promise.all(layout.items
|
|
.filter((item) => item.type === "frame" || item.type === "deck-card")
|
|
.flatMap((item) => {
|
|
const sources = item.type === "frame"
|
|
? [item.primarySrc, item.overlaySrc]
|
|
: [item.src];
|
|
return sources.filter(Boolean);
|
|
})
|
|
.map(async (source) => [source, await loadExportImageAsset(source, imageCache)]));
|
|
const assetsBySource = new Map(assetEntries);
|
|
|
|
layout.items.forEach((item) => {
|
|
if (item.type === "frame") {
|
|
drawFrameVisual(
|
|
context,
|
|
item,
|
|
layout,
|
|
item.primarySrc ? assetsBySource.get(item.primarySrc) || null : null,
|
|
item.overlaySrc ? assetsBySource.get(item.overlaySrc) || null : null
|
|
);
|
|
return;
|
|
}
|
|
|
|
if (item.type === "deck-card") {
|
|
drawDeckCompareCard(
|
|
context,
|
|
item,
|
|
layout,
|
|
item.src ? assetsBySource.get(item.src) || null : null
|
|
);
|
|
return;
|
|
}
|
|
|
|
if (item.type === "panel") {
|
|
drawPanel(context, item, layout);
|
|
}
|
|
});
|
|
|
|
const blob = await canvasToBlobByFormat(canvas, LIGHTBOX_EXPORT_MIME_TYPE, LIGHTBOX_EXPORT_QUALITY);
|
|
const blobUrl = URL.createObjectURL(blob);
|
|
const downloadLink = document.createElement("a");
|
|
const stamp = new Date().toISOString().slice(0, 19).replace(/[T:]/g, "-");
|
|
const baseCardToken = sanitizeExportToken(lightboxState.primaryCard?.label || lightboxState.primaryCard?.cardId || "tarot-lightbox", "tarot-lightbox");
|
|
downloadLink.href = blobUrl;
|
|
downloadLink.download = `${baseCardToken}-${stamp}.webp`;
|
|
document.body.appendChild(downloadLink);
|
|
downloadLink.click();
|
|
downloadLink.remove();
|
|
URL.revokeObjectURL(blobUrl);
|
|
} catch (error) {
|
|
window.alert(error?.message || "Lightbox export failed.");
|
|
} finally {
|
|
lightboxState.exportInProgress = false;
|
|
syncExportButton();
|
|
restoreLightboxFocus();
|
|
}
|
|
}
|
|
|
|
function normalizeCompareDetails(compareDetails) {
|
|
if (!Array.isArray(compareDetails)) {
|
|
return [];
|
|
}
|
|
|
|
return compareDetails
|
|
.map((group) => ({
|
|
title: String(group?.title || "").trim(),
|
|
items: Array.isArray(group?.items)
|
|
? [...new Set(group.items.map((item) => String(item || "").trim()).filter(Boolean))]
|
|
: []
|
|
}))
|
|
.filter((group) => group.title && group.items.length);
|
|
}
|
|
|
|
function normalizeOpenRequest(srcOrOptions, altText, extraOptions) {
|
|
if (srcOrOptions && typeof srcOrOptions === "object" && !Array.isArray(srcOrOptions)) {
|
|
return {
|
|
...srcOrOptions
|
|
};
|
|
}
|
|
|
|
return {
|
|
...(extraOptions || {}),
|
|
src: srcOrOptions,
|
|
altText
|
|
};
|
|
}
|
|
|
|
function normalizeCardRequest(request) {
|
|
const normalized = normalizeOpenRequest(request);
|
|
const label = String(normalized.label || normalized.altText || "Tarot card enlarged image").trim() || "Tarot card enlarged image";
|
|
return {
|
|
src: String(normalized.src || "").trim(),
|
|
altText: String(normalized.altText || label).trim() || label,
|
|
label,
|
|
cardId: String(normalized.cardId || "").trim(),
|
|
deckId: String(normalized.deckId || "").trim(),
|
|
deckLabel: String(normalized.deckLabel || normalized.deckId || "").trim(),
|
|
missingReason: String(normalized.missingReason || "").trim(),
|
|
compareDetails: normalizeCompareDetails(normalized.compareDetails)
|
|
};
|
|
}
|
|
|
|
function normalizeDeckOptions(deckOptions) {
|
|
if (!Array.isArray(deckOptions)) {
|
|
return [];
|
|
}
|
|
|
|
return deckOptions
|
|
.map((deck) => ({
|
|
id: String(deck?.id || "").trim(),
|
|
label: String(deck?.label || deck?.id || "").trim()
|
|
}))
|
|
.filter((deck) => deck.id);
|
|
}
|
|
|
|
function getDeckLabel(deckId) {
|
|
const normalizedDeckId = String(deckId || "").trim();
|
|
if (!normalizedDeckId) {
|
|
return "";
|
|
}
|
|
|
|
if (normalizedDeckId === lightboxState.activeDeckId && lightboxState.activeDeckLabel) {
|
|
return lightboxState.activeDeckLabel;
|
|
}
|
|
|
|
const match = lightboxState.availableCompareDecks.find((deck) => deck.id === normalizedDeckId);
|
|
return match?.label || normalizedDeckId;
|
|
}
|
|
|
|
function resolveDeckCardRequest(cardId, deckId) {
|
|
if (!cardId || !deckId || typeof lightboxState.resolveDeckCardById !== "function") {
|
|
return null;
|
|
}
|
|
|
|
const resolved = lightboxState.resolveDeckCardById(cardId, deckId);
|
|
if (!resolved) {
|
|
return normalizeCardRequest({
|
|
cardId,
|
|
deckId,
|
|
deckLabel: getDeckLabel(deckId),
|
|
label: lightboxState.primaryCard?.label || "Tarot card",
|
|
altText: lightboxState.primaryCard?.altText || "Tarot card",
|
|
missingReason: "Card image unavailable for this deck."
|
|
});
|
|
}
|
|
|
|
return normalizeCardRequest({
|
|
...resolved,
|
|
cardId,
|
|
deckId,
|
|
deckLabel: resolved.deckLabel || getDeckLabel(deckId)
|
|
});
|
|
}
|
|
|
|
function syncDeckCompareCards() {
|
|
if (!lightboxState.deckCompareMode || !lightboxState.primaryCard?.cardId) {
|
|
lightboxState.deckCompareCards = [];
|
|
return;
|
|
}
|
|
|
|
const compareDeckLimit = getEffectiveMaxCompareDecks();
|
|
lightboxState.deckCompareCards = lightboxState.selectedCompareDeckIds
|
|
.slice(0, compareDeckLimit)
|
|
.map((deckId) => resolveDeckCardRequest(lightboxState.primaryCard.cardId, deckId))
|
|
.filter(Boolean);
|
|
|
|
if (lightboxState.selectedCompareDeckIds.length > compareDeckLimit) {
|
|
lightboxState.selectedCompareDeckIds = lightboxState.selectedCompareDeckIds.slice(0, compareDeckLimit);
|
|
}
|
|
}
|
|
|
|
function hasDeckCompareCards() {
|
|
return lightboxState.deckCompareMode && lightboxState.deckCompareCards.length > 0;
|
|
}
|
|
|
|
function restoreLightboxFocus() {
|
|
if (!overlayEl || !lightboxState.isOpen) {
|
|
return;
|
|
}
|
|
|
|
requestAnimationFrame(() => {
|
|
if (overlayEl && lightboxState.isOpen) {
|
|
overlayEl.focus({ preventScroll: true });
|
|
}
|
|
});
|
|
}
|
|
|
|
function resolveCardRequestById(cardId) {
|
|
if (!cardId || typeof lightboxState.resolveCardById !== "function") {
|
|
return null;
|
|
}
|
|
|
|
const resolved = lightboxState.resolveCardById(cardId);
|
|
if (!resolved) {
|
|
return null;
|
|
}
|
|
|
|
return normalizeCardRequest({
|
|
...resolved,
|
|
cardId
|
|
});
|
|
}
|
|
|
|
function clearSecondaryCard() {
|
|
lightboxState.secondaryCard = null;
|
|
lightboxState.mobileInfoView = "primary";
|
|
if (overlayImageEl) {
|
|
overlayImageEl.removeAttribute("src");
|
|
overlayImageEl.alt = "";
|
|
overlayImageEl.style.display = "none";
|
|
}
|
|
|
|
syncComparePanels();
|
|
syncOpacityControl();
|
|
}
|
|
|
|
function clearDeckCompareState() {
|
|
lightboxState.deckCompareMode = false;
|
|
lightboxState.selectedCompareDeckIds = [];
|
|
lightboxState.deckCompareCards = [];
|
|
closeDeckComparePanel();
|
|
lightboxState.deckCompareMessage = "";
|
|
}
|
|
|
|
function closeDeckComparePanel() {
|
|
lightboxState.deckComparePickerOpen = false;
|
|
if (deckComparePanelEl) {
|
|
deckComparePanelEl.style.display = "none";
|
|
}
|
|
if (deckCompareDeckListEl) {
|
|
deckCompareDeckListEl.replaceChildren();
|
|
}
|
|
}
|
|
|
|
function closeSettingsMenu() {
|
|
lightboxState.settingsMenuOpen = false;
|
|
if (settingsPanelEl) {
|
|
settingsPanelEl.style.display = "none";
|
|
}
|
|
}
|
|
|
|
function toggleSettingsMenu() {
|
|
if (!lightboxState.isOpen || zoomed) {
|
|
return;
|
|
}
|
|
|
|
const nextOpen = !lightboxState.settingsMenuOpen;
|
|
lightboxState.settingsMenuOpen = nextOpen;
|
|
if (nextOpen) {
|
|
lightboxState.helpOpen = false;
|
|
closeDeckComparePanel();
|
|
}
|
|
applyComparePresentation();
|
|
}
|
|
|
|
function suppressDeckCompareToggle(durationMs = 400) {
|
|
suppressDeckCompareToggleUntil = Date.now() + Math.max(0, Number(durationMs) || 0);
|
|
}
|
|
|
|
function shouldSuppressDeckCompareToggle() {
|
|
return Date.now() < suppressDeckCompareToggleUntil;
|
|
}
|
|
|
|
function updateDeckCompareMode(deckIds, preservePanel = true) {
|
|
const compareDeckLimit = getEffectiveMaxCompareDecks();
|
|
const uniqueDeckIds = [...new Set((Array.isArray(deckIds) ? deckIds : []).map((deckId) => String(deckId || "").trim()).filter(Boolean))]
|
|
.filter((deckId) => deckId !== lightboxState.activeDeckId)
|
|
.slice(0, compareDeckLimit);
|
|
|
|
lightboxState.selectedCompareDeckIds = uniqueDeckIds;
|
|
lightboxState.deckCompareMode = uniqueDeckIds.length > 0;
|
|
lightboxState.deckCompareMessage = "";
|
|
setInfoPanelOpen(getPersistedInfoPanelVisibility(), { persist: false });
|
|
lightboxState.mobileInfoView = "primary";
|
|
|
|
if (!lightboxState.deckCompareMode) {
|
|
lightboxState.deckCompareCards = [];
|
|
if (!preservePanel) {
|
|
closeDeckComparePanel();
|
|
}
|
|
return;
|
|
}
|
|
|
|
lightboxState.compareMode = false;
|
|
clearSecondaryCard();
|
|
syncDeckCompareCards();
|
|
}
|
|
|
|
function toggleDeckCompareSelection(deckId) {
|
|
const normalizedDeckId = String(deckId || "").trim();
|
|
if (!normalizedDeckId) {
|
|
return;
|
|
}
|
|
|
|
const compareDeckLimit = getEffectiveMaxCompareDecks();
|
|
|
|
const nextSelection = [...lightboxState.selectedCompareDeckIds];
|
|
const existingIndex = nextSelection.indexOf(normalizedDeckId);
|
|
if (existingIndex >= 0) {
|
|
nextSelection.splice(existingIndex, 1);
|
|
updateDeckCompareMode(nextSelection);
|
|
suppressDeckCompareToggle();
|
|
closeDeckComparePanel();
|
|
applyComparePresentation();
|
|
return;
|
|
}
|
|
|
|
if (nextSelection.length >= compareDeckLimit) {
|
|
lightboxState.deckCompareMessage = compareDeckLimit === 1 && isCompactLightboxLayout()
|
|
? "Mobile compare supports one extra deck at a time."
|
|
: `You can compare up to ${compareDeckLimit} decks at once.`;
|
|
applyComparePresentation();
|
|
return;
|
|
}
|
|
|
|
nextSelection.push(normalizedDeckId);
|
|
updateDeckCompareMode(nextSelection);
|
|
suppressDeckCompareToggle();
|
|
closeDeckComparePanel();
|
|
applyComparePresentation();
|
|
}
|
|
|
|
function toggleDeckComparePanel() {
|
|
if (!lightboxState.allowDeckCompare) {
|
|
closeSettingsMenu();
|
|
lightboxState.deckComparePickerOpen = true;
|
|
lightboxState.deckCompareMessage = "Add another registered deck to use deck compare.";
|
|
applyComparePresentation();
|
|
return;
|
|
}
|
|
|
|
if (lightboxState.deckComparePickerOpen) {
|
|
closeDeckComparePanel();
|
|
} else {
|
|
closeSettingsMenu();
|
|
lightboxState.deckComparePickerOpen = true;
|
|
}
|
|
lightboxState.deckCompareMessage = lightboxState.availableCompareDecks.length
|
|
? ""
|
|
: "Add another registered deck to use deck compare.";
|
|
applyComparePresentation();
|
|
}
|
|
|
|
function setOverlayOpacity(value) {
|
|
const opacity = clampOverlayOpacity(value);
|
|
lightboxState.overlayOpacity = opacity;
|
|
|
|
if (overlayImageEl) {
|
|
overlayImageEl.style.opacity = String(opacity);
|
|
}
|
|
|
|
if (opacitySliderEl) {
|
|
opacitySliderEl.value = String(Math.round(opacity * 100));
|
|
opacitySliderEl.disabled = !lightboxState.compareMode || !hasSecondaryCard();
|
|
}
|
|
|
|
if (opacityValueEl) {
|
|
opacityValueEl.textContent = `${Math.round(opacity * 100)}%`;
|
|
}
|
|
}
|
|
|
|
function updateImageCursor() {
|
|
const nextCursor = zoomed ? "zoom-out" : "zoom-in";
|
|
if (lightboxState.deckCompareMode) {
|
|
compareGridSlots.forEach((slot) => {
|
|
if (slot?.imageEl) {
|
|
slot.imageEl.style.cursor = nextCursor;
|
|
}
|
|
});
|
|
return;
|
|
}
|
|
|
|
if (!imageEl) {
|
|
return;
|
|
}
|
|
|
|
imageEl.style.cursor = nextCursor;
|
|
}
|
|
|
|
function buildRotationTransform(rotated) {
|
|
return rotated ? "rotate(180deg)" : "rotate(0deg)";
|
|
}
|
|
|
|
function isPrimaryRotationActive() {
|
|
return !lightboxState.compareMode && lightboxState.primaryRotated;
|
|
}
|
|
|
|
function isOverlayRotationActive() {
|
|
return lightboxState.compareMode && hasSecondaryCard() && lightboxState.overlayRotated;
|
|
}
|
|
|
|
function applyTransformOrigins(originX = lightboxState.zoomOriginX, originY = lightboxState.zoomOriginY) {
|
|
const nextOrigin = `${originX}% ${originY}%`;
|
|
|
|
if (lightboxState.deckCompareMode) {
|
|
compareGridSlots.forEach((slot) => {
|
|
if (slot?.zoomLayerEl) {
|
|
slot.zoomLayerEl.style.transformOrigin = nextOrigin;
|
|
}
|
|
});
|
|
return;
|
|
}
|
|
|
|
if (baseLayerEl) {
|
|
baseLayerEl.style.transformOrigin = nextOrigin;
|
|
}
|
|
|
|
if (overlayLayerEl) {
|
|
overlayLayerEl.style.transformOrigin = nextOrigin;
|
|
}
|
|
}
|
|
|
|
function applyZoomTransform() {
|
|
const activeZoomScale = zoomed ? lightboxState.zoomScale : 1;
|
|
const showPrimaryRotation = isPrimaryRotationActive();
|
|
const showOverlayRotation = isOverlayRotationActive();
|
|
|
|
if (lightboxState.deckCompareMode) {
|
|
compareGridSlots.forEach((slot) => {
|
|
if (!slot?.imageEl || !slot?.zoomLayerEl) {
|
|
return;
|
|
}
|
|
|
|
slot.zoomLayerEl.style.transform = `scale(${activeZoomScale})`;
|
|
slot.imageEl.style.transform = buildRotationTransform(lightboxState.primaryRotated);
|
|
});
|
|
|
|
applyTransformOrigins();
|
|
updateImageCursor();
|
|
return;
|
|
}
|
|
|
|
if (baseLayerEl) {
|
|
baseLayerEl.style.transform = `scale(${activeZoomScale})`;
|
|
}
|
|
|
|
if (overlayLayerEl) {
|
|
overlayLayerEl.style.transform = `scale(${activeZoomScale})`;
|
|
}
|
|
|
|
if (imageEl) {
|
|
imageEl.style.transform = buildRotationTransform(showPrimaryRotation);
|
|
}
|
|
|
|
if (overlayImageEl) {
|
|
overlayImageEl.style.transform = buildRotationTransform(showOverlayRotation);
|
|
}
|
|
|
|
applyTransformOrigins();
|
|
|
|
updateImageCursor();
|
|
}
|
|
|
|
function setZoomScale(value) {
|
|
const zoomScale = clampZoomScale(value);
|
|
lightboxState.zoomScale = zoomScale;
|
|
|
|
if (zoomSliderEl) {
|
|
zoomSliderEl.value = String(Math.round(zoomScale * 100));
|
|
}
|
|
|
|
if (zoomValueEl) {
|
|
zoomValueEl.textContent = `${Math.round(zoomScale * 100)}%`;
|
|
}
|
|
|
|
if (lightboxState.isOpen) {
|
|
applyComparePresentation();
|
|
return;
|
|
}
|
|
|
|
applyZoomTransform();
|
|
}
|
|
|
|
function isZoomInKey(event) {
|
|
return event.key === "+"
|
|
|| event.key === "="
|
|
|| event.code === "NumpadAdd";
|
|
}
|
|
|
|
function isZoomOutKey(event) {
|
|
return event.key === "-"
|
|
|| event.key === "_"
|
|
|| event.code === "NumpadSubtract";
|
|
}
|
|
|
|
function isRotateKey(event) {
|
|
return event.code === "KeyR" || String(event.key || "").toLowerCase() === "r";
|
|
}
|
|
|
|
function stepZoom(direction) {
|
|
if (!lightboxState.isOpen) {
|
|
return;
|
|
}
|
|
|
|
const activeScale = zoomed ? lightboxState.zoomScale : 1;
|
|
const nextScale = clampZoomScale(activeScale + (direction * LIGHTBOX_ZOOM_STEP));
|
|
zoomed = nextScale > 1;
|
|
setZoomScale(nextScale);
|
|
|
|
if (!zoomed && imageEl) {
|
|
lightboxState.zoomOriginX = 50;
|
|
lightboxState.zoomOriginY = 50;
|
|
}
|
|
|
|
if (!zoomed && overlayImageEl) {
|
|
lightboxState.zoomOriginX = 50;
|
|
lightboxState.zoomOriginY = 50;
|
|
}
|
|
}
|
|
|
|
function isPanUpKey(event) {
|
|
return event.code === "KeyW" || String(event.key || "").toLowerCase() === "w";
|
|
}
|
|
|
|
function isPanLeftKey(event) {
|
|
return event.code === "KeyA" || String(event.key || "").toLowerCase() === "a";
|
|
}
|
|
|
|
function isPanDownKey(event) {
|
|
return event.code === "KeyS" || String(event.key || "").toLowerCase() === "s";
|
|
}
|
|
|
|
function isPanRightKey(event) {
|
|
return event.code === "KeyD" || String(event.key || "").toLowerCase() === "d";
|
|
}
|
|
|
|
function stepPan(deltaX, deltaY) {
|
|
if (!lightboxState.isOpen || !zoomed || !imageEl) {
|
|
return;
|
|
}
|
|
|
|
lightboxState.zoomOriginX = Math.min(100, Math.max(0, lightboxState.zoomOriginX + deltaX));
|
|
lightboxState.zoomOriginY = Math.min(100, Math.max(0, lightboxState.zoomOriginY + deltaY));
|
|
applyTransformOrigins();
|
|
}
|
|
|
|
function toggleRotation() {
|
|
if (!lightboxState.isOpen) {
|
|
return;
|
|
}
|
|
|
|
if (lightboxState.deckCompareMode) {
|
|
lightboxState.primaryRotated = !lightboxState.primaryRotated;
|
|
applyZoomTransform();
|
|
return;
|
|
}
|
|
|
|
if (lightboxState.compareMode && hasSecondaryCard()) {
|
|
lightboxState.overlayRotated = !lightboxState.overlayRotated;
|
|
} else {
|
|
lightboxState.primaryRotated = !lightboxState.primaryRotated;
|
|
}
|
|
|
|
applyZoomTransform();
|
|
}
|
|
|
|
function createCompareGroupElement(group) {
|
|
const sectionEl = document.createElement("section");
|
|
sectionEl.style.display = "flex";
|
|
sectionEl.style.flexDirection = "column";
|
|
sectionEl.style.gap = "5px";
|
|
sectionEl.style.paddingTop = "8px";
|
|
sectionEl.style.borderTop = "1px solid rgba(148, 163, 184, 0.14)";
|
|
|
|
const titleEl = document.createElement("div");
|
|
titleEl.textContent = group.title;
|
|
titleEl.style.font = "600 10px/1.2 sans-serif";
|
|
titleEl.style.letterSpacing = "0.1em";
|
|
titleEl.style.textTransform = "uppercase";
|
|
titleEl.style.color = "rgba(148, 163, 184, 0.92)";
|
|
|
|
const valuesEl = document.createElement("div");
|
|
valuesEl.style.display = "flex";
|
|
valuesEl.style.flexDirection = "column";
|
|
valuesEl.style.gap = "3px";
|
|
|
|
group.items.forEach((item) => {
|
|
const itemEl = document.createElement("div");
|
|
itemEl.textContent = item;
|
|
itemEl.style.font = "500 12px/1.35 sans-serif";
|
|
itemEl.style.color = "#f8fafc";
|
|
valuesEl.appendChild(itemEl);
|
|
});
|
|
|
|
sectionEl.append(titleEl, valuesEl);
|
|
return sectionEl;
|
|
}
|
|
|
|
function normalizeCompareGroupTitle(title) {
|
|
return String(title || "").trim().toUpperCase();
|
|
}
|
|
|
|
function getDeckCompareInfoGroups(cardRequest) {
|
|
const desiredLeadTitle = Array.isArray(cardRequest?.compareDetails)
|
|
&& cardRequest.compareDetails.some((group) => normalizeCompareGroupTitle(group?.title) === "DECANS")
|
|
? "DECANS"
|
|
: "SIGNS";
|
|
const desiredOrder = [desiredLeadTitle, "ELEMENT", "TETRAGRAMMATON"];
|
|
const groupsByTitle = new Map();
|
|
|
|
if (Array.isArray(cardRequest?.compareDetails)) {
|
|
cardRequest.compareDetails.forEach((group) => {
|
|
const title = normalizeCompareGroupTitle(group?.title);
|
|
if (desiredOrder.includes(title) && !groupsByTitle.has(title)) {
|
|
groupsByTitle.set(title, {
|
|
...group,
|
|
title
|
|
});
|
|
}
|
|
});
|
|
}
|
|
|
|
return desiredOrder.map((title) => groupsByTitle.get(title)).filter(Boolean);
|
|
}
|
|
|
|
function renderDeckCompareInfoPanel(panelEl, titleEl, groupsEl, hintEl, cardRequest, roleLabel, hintText, isVisible, horizontal = false) {
|
|
if (!panelEl || !titleEl || !groupsEl || !hintEl) {
|
|
return;
|
|
}
|
|
|
|
if (!isVisible || !cardRequest?.label) {
|
|
panelEl.style.display = "none";
|
|
titleEl.textContent = "";
|
|
hintEl.textContent = "";
|
|
groupsEl.replaceChildren();
|
|
return;
|
|
}
|
|
|
|
panelEl.style.display = "flex";
|
|
titleEl.textContent = roleLabel ? `${roleLabel}: ${cardRequest.label}` : cardRequest.label;
|
|
groupsEl.replaceChildren();
|
|
|
|
const compareGroups = getDeckCompareInfoGroups(cardRequest);
|
|
if (compareGroups.length) {
|
|
groupsEl.style.display = horizontal ? "grid" : "flex";
|
|
groupsEl.style.gridTemplateColumns = horizontal ? "repeat(2, minmax(0, 1fr))" : "none";
|
|
groupsEl.style.gridAutoFlow = horizontal ? "row" : "initial";
|
|
groupsEl.style.flexDirection = horizontal ? "row" : "column";
|
|
groupsEl.style.flexWrap = horizontal ? "wrap" : "nowrap";
|
|
groupsEl.style.gap = horizontal ? "10px 14px" : "0";
|
|
groupsEl.style.alignItems = horizontal ? "start" : "stretch";
|
|
|
|
compareGroups.forEach((group) => {
|
|
const sectionEl = createCompareGroupElement(group);
|
|
sectionEl.style.minWidth = "0";
|
|
sectionEl.style.width = "100%";
|
|
if (horizontal && normalizeCompareGroupTitle(group.title) === "TETRAGRAMMATON") {
|
|
sectionEl.style.gridColumn = "1 / -1";
|
|
}
|
|
groupsEl.appendChild(sectionEl);
|
|
});
|
|
} else {
|
|
groupsEl.style.display = "flex";
|
|
groupsEl.style.flexDirection = "column";
|
|
groupsEl.style.gap = "0";
|
|
const emptyEl = document.createElement("div");
|
|
emptyEl.textContent = "No compare metadata available.";
|
|
emptyEl.style.font = "500 12px/1.35 sans-serif";
|
|
emptyEl.style.color = "rgba(226, 232, 240, 0.8)";
|
|
groupsEl.appendChild(emptyEl);
|
|
}
|
|
|
|
hintEl.textContent = hintText;
|
|
hintEl.style.display = hintText ? "block" : "none";
|
|
}
|
|
|
|
function renderComparePanel(panelEl, titleEl, groupsEl, hintEl, cardRequest, roleLabel, hintText, isVisible) {
|
|
if (!panelEl || !titleEl || !groupsEl || !hintEl) {
|
|
return;
|
|
}
|
|
|
|
if (!isVisible || !cardRequest?.label) {
|
|
panelEl.style.display = "none";
|
|
titleEl.textContent = "";
|
|
hintEl.textContent = "";
|
|
groupsEl.replaceChildren();
|
|
return;
|
|
}
|
|
|
|
panelEl.style.display = "flex";
|
|
titleEl.textContent = roleLabel ? `${roleLabel}: ${cardRequest.label}` : cardRequest.label;
|
|
groupsEl.replaceChildren();
|
|
|
|
if (Array.isArray(cardRequest.compareDetails) && cardRequest.compareDetails.length) {
|
|
cardRequest.compareDetails.forEach((group) => {
|
|
groupsEl.appendChild(createCompareGroupElement(group));
|
|
});
|
|
} else {
|
|
const emptyEl = document.createElement("div");
|
|
emptyEl.textContent = "No compare metadata available.";
|
|
emptyEl.style.font = "500 12px/1.35 sans-serif";
|
|
emptyEl.style.color = "rgba(226, 232, 240, 0.8)";
|
|
groupsEl.appendChild(emptyEl);
|
|
}
|
|
|
|
hintEl.textContent = hintText;
|
|
hintEl.style.display = hintText ? "block" : "none";
|
|
}
|
|
|
|
function renderMobileInfoPanel(cardRequest, roleLabel, hintText, isVisible) {
|
|
renderComparePanel(
|
|
mobileInfoPanelEl,
|
|
mobileInfoTitleEl,
|
|
mobileInfoGroupsEl,
|
|
mobileInfoHintEl,
|
|
cardRequest,
|
|
roleLabel,
|
|
hintText,
|
|
isVisible
|
|
);
|
|
}
|
|
|
|
function syncInfoPanelContentLayout(panelEl, groupsEl, hintEl, options = {}) {
|
|
if (!panelEl || !groupsEl || !hintEl) {
|
|
return;
|
|
}
|
|
|
|
const horizontal = Boolean(options.horizontal);
|
|
groupsEl.style.display = horizontal ? "grid" : "flex";
|
|
groupsEl.style.gridTemplateColumns = horizontal ? "repeat(auto-fit, minmax(220px, 1fr))" : "none";
|
|
groupsEl.style.flexDirection = horizontal ? "row" : "column";
|
|
groupsEl.style.flexWrap = horizontal ? "wrap" : "nowrap";
|
|
groupsEl.style.gap = horizontal ? "10px 14px" : "0";
|
|
hintEl.style.marginTop = horizontal ? "2px" : "0";
|
|
|
|
Array.from(groupsEl.children).forEach((child) => {
|
|
if (!(child instanceof HTMLElement)) {
|
|
return;
|
|
}
|
|
|
|
if (child.tagName === "SECTION") {
|
|
child.style.flex = horizontal ? "1 1 auto" : "0 0 auto";
|
|
child.style.minWidth = horizontal ? "0" : "0";
|
|
child.style.paddingTop = horizontal ? "10px" : "8px";
|
|
return;
|
|
}
|
|
|
|
child.style.flex = horizontal ? "1 1 100%" : "0 0 auto";
|
|
child.style.minWidth = "0";
|
|
});
|
|
}
|
|
|
|
function syncMobileInfoControls() {
|
|
if (!mobileInfoButtonEl || !mobileInfoPrimaryTabEl || !mobileInfoSecondaryTabEl || !mobileInfoPanelEl) {
|
|
return;
|
|
}
|
|
|
|
const isCompact = isCompactLightboxLayout();
|
|
const canShowDeckCompareInfo = Boolean(
|
|
lightboxState.isOpen
|
|
&& !zoomed
|
|
&& lightboxState.deckCompareMode
|
|
&& lightboxState.primaryCard?.label
|
|
);
|
|
const canShowOverlayInfo = Boolean(
|
|
lightboxState.isOpen
|
|
&& isCompact
|
|
&& !zoomed
|
|
&& !lightboxState.deckCompareMode
|
|
&& lightboxState.allowOverlayCompare
|
|
&& lightboxState.primaryCard?.label
|
|
);
|
|
const canShowInfo = canShowDeckCompareInfo || canShowOverlayInfo;
|
|
const hasOverlayInfo = Boolean(lightboxState.compareMode && hasSecondaryCard() && lightboxState.secondaryCard?.label);
|
|
const activeView = getActiveMobileInfoView();
|
|
|
|
mobileInfoButtonEl.style.display = canShowInfo ? "inline-flex" : "none";
|
|
mobileInfoButtonEl.textContent = lightboxState.mobileInfoOpen ? "Hide Info" : "Info";
|
|
mobileInfoButtonEl.setAttribute("aria-pressed", lightboxState.mobileInfoOpen ? "true" : "false");
|
|
|
|
mobileInfoPrimaryTabEl.style.display = canShowOverlayInfo && lightboxState.mobileInfoOpen && hasOverlayInfo ? "inline-flex" : "none";
|
|
mobileInfoSecondaryTabEl.style.display = canShowOverlayInfo && lightboxState.mobileInfoOpen && hasOverlayInfo ? "inline-flex" : "none";
|
|
|
|
mobileInfoPrimaryTabEl.setAttribute("aria-pressed", activeView === "primary" ? "true" : "false");
|
|
mobileInfoSecondaryTabEl.setAttribute("aria-pressed", activeView === "overlay" ? "true" : "false");
|
|
|
|
mobileInfoPrimaryTabEl.style.background = activeView === "primary" ? "rgba(51, 65, 85, 0.96)" : "rgba(15, 23, 42, 0.84)";
|
|
mobileInfoSecondaryTabEl.style.background = activeView === "overlay" ? "rgba(51, 65, 85, 0.96)" : "rgba(15, 23, 42, 0.84)";
|
|
mobileInfoPanelEl.style.pointerEvents = canShowInfo && lightboxState.mobileInfoOpen ? "auto" : "none";
|
|
}
|
|
|
|
function syncMobileNavigationControls() {
|
|
if (!mobilePrevButtonEl || !mobileNextButtonEl) {
|
|
return;
|
|
}
|
|
|
|
if (mobilePrevButtonEl.parentElement !== overlayEl) {
|
|
overlayEl.appendChild(mobilePrevButtonEl);
|
|
}
|
|
|
|
if (mobileNextButtonEl.parentElement !== overlayEl) {
|
|
overlayEl.appendChild(mobileNextButtonEl);
|
|
}
|
|
|
|
const canNavigate = Boolean(
|
|
lightboxState.isOpen
|
|
&& isCompactLightboxLayout()
|
|
&& !zoomed
|
|
&& !lightboxState.deckComparePickerOpen
|
|
&& hasSequenceNavigation()
|
|
);
|
|
const previousLabel = lightboxState.compareMode
|
|
? "Previous overlay card"
|
|
: (lightboxState.deckCompareMode ? "Previous compared card" : "Previous card");
|
|
const nextLabel = lightboxState.compareMode
|
|
? "Next overlay card"
|
|
: (lightboxState.deckCompareMode ? "Next compared card" : "Next card");
|
|
|
|
mobilePrevButtonEl.style.display = canNavigate ? "inline-flex" : "none";
|
|
mobileNextButtonEl.style.display = canNavigate ? "inline-flex" : "none";
|
|
mobilePrevButtonEl.setAttribute("aria-label", previousLabel);
|
|
mobileNextButtonEl.setAttribute("aria-label", nextLabel);
|
|
|
|
if (!canNavigate) {
|
|
return;
|
|
}
|
|
|
|
const mobileInfoPanelVisible = Boolean(
|
|
lightboxState.mobileInfoOpen
|
|
&& mobileInfoPanelEl
|
|
&& mobileInfoPanelEl.style.display !== "none"
|
|
);
|
|
const settingsPanelVisible = Boolean(
|
|
lightboxState.settingsMenuOpen
|
|
&& settingsPanelEl
|
|
&& settingsPanelEl.style.display !== "none"
|
|
);
|
|
const helpPanelVisible = Boolean(
|
|
lightboxState.helpOpen
|
|
&& helpPanelEl
|
|
&& helpPanelEl.style.display !== "none"
|
|
);
|
|
const deckPickerVisible = Boolean(
|
|
lightboxState.deckComparePickerOpen
|
|
&& deckComparePanelEl
|
|
&& deckComparePanelEl.style.display !== "none"
|
|
);
|
|
const toolbarHeight = toolbarEl instanceof HTMLElement && toolbarEl.style.display !== "none"
|
|
? toolbarEl.offsetHeight
|
|
: 0;
|
|
const infoPanelHeight = mobileInfoPanelVisible && mobileInfoPanelEl instanceof HTMLElement
|
|
? mobileInfoPanelEl.offsetHeight
|
|
: 0;
|
|
const floatingPanelHeight = Math.max(
|
|
settingsPanelVisible && settingsPanelEl instanceof HTMLElement ? settingsPanelEl.offsetHeight + 12 : 0,
|
|
helpPanelVisible && helpPanelEl instanceof HTMLElement ? helpPanelEl.offsetHeight + 12 : 0,
|
|
deckPickerVisible && deckComparePanelEl instanceof HTMLElement ? deckComparePanelEl.offsetHeight + 12 : 0
|
|
);
|
|
const bottomOffset = toolbarHeight + floatingPanelHeight + (mobileInfoPanelVisible ? infoPanelHeight + 32 : 24);
|
|
|
|
mobilePrevButtonEl.style.top = "auto";
|
|
mobileNextButtonEl.style.top = "auto";
|
|
mobilePrevButtonEl.style.bottom = `${bottomOffset}px`;
|
|
mobileNextButtonEl.style.bottom = `${bottomOffset}px`;
|
|
mobilePrevButtonEl.style.transform = "none";
|
|
mobileNextButtonEl.style.transform = "none";
|
|
}
|
|
|
|
function syncComparePanels() {
|
|
if (lightboxState.deckCompareMode) {
|
|
const isCompact = isCompactLightboxLayout();
|
|
const sharedHint = isCompact
|
|
? "Use the side arrows to move through compared decks."
|
|
: "Shared card info for all compared decks.";
|
|
const showDesktopPanel = Boolean(
|
|
!isCompact
|
|
&& lightboxState.isOpen
|
|
&& lightboxState.mobileInfoOpen
|
|
&& lightboxState.primaryCard?.label
|
|
&& !zoomed
|
|
);
|
|
const showMobilePanel = Boolean(
|
|
isCompact
|
|
&& lightboxState.isOpen
|
|
&& lightboxState.mobileInfoOpen
|
|
&& lightboxState.primaryCard?.label
|
|
&& !zoomed
|
|
);
|
|
|
|
renderDeckCompareInfoPanel(
|
|
primaryInfoEl,
|
|
primaryTitleEl,
|
|
primaryGroupsEl,
|
|
primaryHintEl,
|
|
lightboxState.primaryCard,
|
|
"Card",
|
|
sharedHint,
|
|
showDesktopPanel,
|
|
true
|
|
);
|
|
renderComparePanel(secondaryInfoEl, secondaryTitleEl, secondaryGroupsEl, secondaryHintEl, null, "", "", false);
|
|
renderDeckCompareInfoPanel(
|
|
mobileInfoPanelEl,
|
|
mobileInfoTitleEl,
|
|
mobileInfoGroupsEl,
|
|
mobileInfoHintEl,
|
|
lightboxState.primaryCard,
|
|
"Card",
|
|
sharedHint,
|
|
showMobilePanel,
|
|
false
|
|
);
|
|
return;
|
|
}
|
|
|
|
syncInfoPanelContentLayout(primaryInfoEl, primaryGroupsEl, primaryHintEl, {
|
|
horizontal: false
|
|
});
|
|
syncInfoPanelContentLayout(secondaryInfoEl, secondaryGroupsEl, secondaryHintEl, {
|
|
horizontal: false
|
|
});
|
|
syncInfoPanelContentLayout(mobileInfoPanelEl, mobileInfoGroupsEl, mobileInfoHintEl, {
|
|
horizontal: false
|
|
});
|
|
|
|
const isCompact = isCompactLightboxLayout();
|
|
const isComparing = lightboxState.compareMode;
|
|
const overlaySelected = hasSecondaryCard();
|
|
const primaryHint = isComparing
|
|
? (overlaySelected
|
|
? "Use the side arrows to move through the overlaid card."
|
|
: "Use the side arrows to pick the first overlay card.")
|
|
: "Use the side arrows to move through cards. Tap Overlay to compare.";
|
|
const secondaryHint = overlaySelected ? "Use the side arrows to swap the overlay card." : "";
|
|
const showPrimaryPanel = Boolean(
|
|
!isCompact
|
|
&& lightboxState.isOpen
|
|
&& lightboxState.allowOverlayCompare
|
|
&& lightboxState.primaryCard?.label
|
|
&& !zoomed
|
|
);
|
|
const showSecondaryPanel = Boolean(
|
|
!isCompact
|
|
&& isComparing
|
|
&& overlaySelected
|
|
&& lightboxState.secondaryCard?.label
|
|
&& !zoomed
|
|
);
|
|
|
|
renderComparePanel(
|
|
primaryInfoEl,
|
|
primaryTitleEl,
|
|
primaryGroupsEl,
|
|
primaryHintEl,
|
|
lightboxState.primaryCard,
|
|
"Base",
|
|
primaryHint,
|
|
showPrimaryPanel
|
|
);
|
|
|
|
renderComparePanel(
|
|
secondaryInfoEl,
|
|
secondaryTitleEl,
|
|
secondaryGroupsEl,
|
|
secondaryHintEl,
|
|
lightboxState.secondaryCard,
|
|
"Overlay",
|
|
secondaryHint,
|
|
showSecondaryPanel
|
|
);
|
|
|
|
if (!isCompact) {
|
|
renderMobileInfoPanel(null, "", "", false);
|
|
return;
|
|
}
|
|
|
|
const activeView = getActiveMobileInfoView();
|
|
const mobileCard = activeView === "overlay" ? lightboxState.secondaryCard : lightboxState.primaryCard;
|
|
const mobileRole = activeView === "overlay" ? "Overlay" : (isComparing ? "Base" : "Card");
|
|
const mobileHint = activeView === "overlay" ? secondaryHint : primaryHint;
|
|
const showMobileInfo = Boolean(
|
|
lightboxState.isOpen
|
|
&& lightboxState.mobileInfoOpen
|
|
&& !zoomed
|
|
&& lightboxState.allowOverlayCompare
|
|
&& mobileCard?.label
|
|
);
|
|
|
|
renderMobileInfoPanel(mobileCard, mobileRole, mobileHint, showMobileInfo);
|
|
}
|
|
|
|
function syncOpacityControl() {
|
|
if (!opacityControlEl) {
|
|
return;
|
|
}
|
|
|
|
if (lightboxState.deckCompareMode) {
|
|
opacityControlEl.style.display = "none";
|
|
return;
|
|
}
|
|
|
|
opacityControlEl.style.display = lightboxState.compareMode && hasSecondaryCard() && !zoomed ? "flex" : "none";
|
|
setOverlayOpacity(lightboxState.overlayOpacity);
|
|
}
|
|
|
|
function syncSettingsUi() {
|
|
if (!settingsButtonEl || !settingsPanelEl) {
|
|
return;
|
|
}
|
|
|
|
const canShow = lightboxState.isOpen && !zoomed;
|
|
settingsButtonEl.style.display = canShow ? "inline-flex" : "none";
|
|
settingsButtonEl.textContent = lightboxState.settingsMenuOpen ? "Hide Settings" : "Settings";
|
|
settingsButtonEl.setAttribute("aria-expanded", canShow && lightboxState.settingsMenuOpen ? "true" : "false");
|
|
settingsPanelEl.style.display = canShow && lightboxState.settingsMenuOpen ? "flex" : "none";
|
|
settingsPanelEl.style.pointerEvents = canShow && lightboxState.settingsMenuOpen ? "auto" : "none";
|
|
}
|
|
|
|
function syncDeckComparePicker() {
|
|
if (!deckCompareButtonEl || !deckComparePanelEl || !deckCompareMessageEl || !deckCompareDeckListEl) {
|
|
return;
|
|
}
|
|
|
|
const canShowButton = lightboxState.isOpen && !zoomed && !lightboxState.compareMode;
|
|
deckCompareButtonEl.style.display = canShowButton && (lightboxState.allowDeckCompare || lightboxState.availableCompareDecks.length === 0)
|
|
? "inline-flex"
|
|
: "none";
|
|
deckCompareButtonEl.textContent = lightboxState.selectedCompareDeckIds.length
|
|
? `Compare (${lightboxState.selectedCompareDeckIds.length})`
|
|
: "Compare";
|
|
deckCompareButtonEl.setAttribute("aria-pressed", lightboxState.deckComparePickerOpen ? "true" : "false");
|
|
|
|
if (!lightboxState.deckComparePickerOpen || zoomed || lightboxState.compareMode) {
|
|
deckComparePanelEl.style.display = "none";
|
|
deckCompareDeckListEl.replaceChildren();
|
|
return;
|
|
}
|
|
|
|
deckComparePanelEl.style.display = "flex";
|
|
deckCompareMessageEl.textContent = lightboxState.deckCompareMessage
|
|
|| (lightboxState.availableCompareDecks.length
|
|
? getCompareDeckLimitMessage()
|
|
: "Add another registered deck to use deck compare.");
|
|
|
|
deckCompareDeckListEl.replaceChildren();
|
|
|
|
if (!lightboxState.availableCompareDecks.length) {
|
|
return;
|
|
}
|
|
|
|
lightboxState.availableCompareDecks.forEach((deck) => {
|
|
const isSelected = lightboxState.selectedCompareDeckIds.includes(deck.id);
|
|
const isDisabled = !isSelected && lightboxState.selectedCompareDeckIds.length >= getEffectiveMaxCompareDecks();
|
|
const deckButtonEl = document.createElement("button");
|
|
deckButtonEl.type = "button";
|
|
deckButtonEl.textContent = deck.label;
|
|
deckButtonEl.disabled = isDisabled;
|
|
deckButtonEl.setAttribute("aria-pressed", isSelected ? "true" : "false");
|
|
deckButtonEl.style.padding = "10px 12px";
|
|
deckButtonEl.style.borderRadius = "12px";
|
|
deckButtonEl.style.border = isSelected
|
|
? "1px solid rgba(148, 163, 184, 0.7)"
|
|
: "1px solid rgba(148, 163, 184, 0.22)";
|
|
deckButtonEl.style.background = isSelected ? "rgba(30, 41, 59, 0.92)" : "rgba(15, 23, 42, 0.58)";
|
|
deckButtonEl.style.color = isDisabled ? "rgba(148, 163, 184, 0.52)" : "#f8fafc";
|
|
deckButtonEl.style.cursor = isDisabled ? "not-allowed" : "pointer";
|
|
deckButtonEl.style.font = "600 12px/1.3 sans-serif";
|
|
deckButtonEl.addEventListener("click", (event) => {
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
toggleDeckCompareSelection(deck.id);
|
|
restoreLightboxFocus();
|
|
});
|
|
deckCompareDeckListEl.appendChild(deckButtonEl);
|
|
});
|
|
|
|
if (lightboxState.selectedCompareDeckIds.length) {
|
|
const clearButtonEl = document.createElement("button");
|
|
clearButtonEl.type = "button";
|
|
clearButtonEl.textContent = "Clear Compare";
|
|
clearButtonEl.style.marginTop = "4px";
|
|
clearButtonEl.style.padding = "9px 12px";
|
|
clearButtonEl.style.borderRadius = "12px";
|
|
clearButtonEl.style.border = "1px solid rgba(248, 250, 252, 0.16)";
|
|
clearButtonEl.style.background = "rgba(15, 23, 42, 0.44)";
|
|
clearButtonEl.style.color = "rgba(248, 250, 252, 0.92)";
|
|
clearButtonEl.style.cursor = "pointer";
|
|
clearButtonEl.style.font = "600 12px/1.3 sans-serif";
|
|
clearButtonEl.addEventListener("click", (event) => {
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
updateDeckCompareMode([]);
|
|
suppressDeckCompareToggle();
|
|
closeDeckComparePanel();
|
|
applyComparePresentation();
|
|
restoreLightboxFocus();
|
|
});
|
|
deckCompareDeckListEl.appendChild(clearButtonEl);
|
|
}
|
|
}
|
|
|
|
function renderDeckCompareGrid() {
|
|
if (!compareGridEl || !compareGridSlots.length) {
|
|
return;
|
|
}
|
|
|
|
const isCompact = isCompactLightboxLayout();
|
|
|
|
if (!lightboxState.deckCompareMode) {
|
|
compareGridEl.style.display = "none";
|
|
compareGridSlots.forEach((slot) => {
|
|
slot.slotEl.style.display = "none";
|
|
slot.imageEl.removeAttribute("src");
|
|
slot.imageEl.alt = "Tarot compare image";
|
|
if (slot.zoomLayerEl) {
|
|
slot.zoomLayerEl.style.display = "none";
|
|
}
|
|
slot.imageEl.style.display = "none";
|
|
slot.fallbackEl.style.display = "none";
|
|
});
|
|
return;
|
|
}
|
|
|
|
const visibleCards = [lightboxState.primaryCard, ...lightboxState.deckCompareCards].filter(Boolean);
|
|
compareGridEl.style.display = "grid";
|
|
compareGridEl.style.gap = isCompact ? "6px" : "14px";
|
|
compareGridEl.style.padding = isCompact
|
|
? (lightboxState.mobileInfoOpen ? "18px 12px 260px" : "8px 6px 88px")
|
|
: (lightboxState.mobileInfoOpen ? "clamp(210px, 30vh, 290px) 24px 24px" : "76px 24px 24px");
|
|
compareGridEl.style.gridTemplateColumns = `repeat(${Math.max(1, visibleCards.length)}, minmax(0, 1fr))`;
|
|
compareGridEl.style.alignItems = isCompact ? "center" : "stretch";
|
|
compareGridEl.style.alignContent = isCompact ? "center" : "stretch";
|
|
|
|
compareGridSlots.forEach((slot, index) => {
|
|
const cardRequest = visibleCards[index] || null;
|
|
if (!cardRequest) {
|
|
slot.slotEl.style.display = "none";
|
|
slot.imageEl.removeAttribute("src");
|
|
if (slot.zoomLayerEl) {
|
|
slot.zoomLayerEl.style.display = "none";
|
|
}
|
|
slot.imageEl.style.display = "none";
|
|
slot.fallbackEl.style.display = "none";
|
|
return;
|
|
}
|
|
|
|
slot.slotEl.style.display = "flex";
|
|
slot.slotEl.style.width = "100%";
|
|
slot.slotEl.style.height = isCompact ? "auto" : "100%";
|
|
slot.slotEl.style.maxWidth = isCompact ? "220px" : "none";
|
|
slot.slotEl.style.justifySelf = isCompact ? "center" : "stretch";
|
|
slot.slotEl.style.alignSelf = isCompact ? "center" : "stretch";
|
|
slot.slotEl.style.borderRadius = isCompact ? "12px" : "22px";
|
|
slot.slotEl.style.boxShadow = isCompact ? "0 8px 18px rgba(0, 0, 0, 0.22)" : "0 24px 64px rgba(0, 0, 0, 0.36)";
|
|
slot.headerEl.style.padding = isCompact ? "6px 8px" : "10px 12px";
|
|
slot.headerEl.style.gap = isCompact ? "4px" : "10px";
|
|
slot.badgeEl.style.font = isCompact ? "700 9px/1.15 sans-serif" : "700 11px/1.2 sans-serif";
|
|
slot.cardLabelEl.style.font = isCompact ? "500 9px/1.2 sans-serif" : "500 11px/1.3 sans-serif";
|
|
slot.mediaEl.style.flex = isCompact ? "0 0 auto" : "1 1 auto";
|
|
slot.mediaEl.style.aspectRatio = isCompact ? "2 / 3" : "auto";
|
|
slot.mediaEl.style.padding = isCompact ? "4px" : "16px";
|
|
slot.zoomLayerEl.style.inset = isCompact ? "4px" : "16px";
|
|
slot.fallbackEl.style.maxWidth = isCompact ? "100%" : "260px";
|
|
slot.fallbackEl.style.padding = isCompact ? "12px 8px" : "16px";
|
|
slot.badgeEl.textContent = cardRequest.deckLabel || (index === 0 ? "Active Deck" : "Compare Deck");
|
|
slot.cardLabelEl.textContent = cardRequest.label || "Tarot card";
|
|
|
|
if (cardRequest.src) {
|
|
slot.imageEl.src = cardRequest.src;
|
|
slot.imageEl.alt = cardRequest.altText || cardRequest.label || "Tarot compare image";
|
|
if (slot.zoomLayerEl) {
|
|
slot.zoomLayerEl.style.display = "flex";
|
|
}
|
|
slot.imageEl.style.display = "block";
|
|
slot.fallbackEl.style.display = "none";
|
|
} else {
|
|
slot.imageEl.removeAttribute("src");
|
|
slot.imageEl.alt = "";
|
|
if (slot.zoomLayerEl) {
|
|
slot.zoomLayerEl.style.display = "none";
|
|
}
|
|
slot.imageEl.style.display = "none";
|
|
slot.fallbackEl.textContent = cardRequest.missingReason || "Card image unavailable for this deck.";
|
|
slot.fallbackEl.style.display = "block";
|
|
}
|
|
});
|
|
|
|
applyZoomTransform();
|
|
}
|
|
|
|
function syncHelpUi() {
|
|
if (!helpButtonEl || !helpPanelEl) {
|
|
return;
|
|
}
|
|
|
|
const canShow = lightboxState.isOpen && !zoomed;
|
|
helpButtonEl.style.display = canShow ? "inline-flex" : "none";
|
|
helpPanelEl.style.display = canShow && lightboxState.helpOpen ? "flex" : "none";
|
|
helpButtonEl.textContent = lightboxState.helpOpen ? "Hide Help" : "Help";
|
|
}
|
|
|
|
function syncZoomControl() {
|
|
if (!zoomControlEl) {
|
|
return;
|
|
}
|
|
|
|
zoomControlEl.style.display = lightboxState.isOpen && !zoomed ? "flex" : "none";
|
|
if (zoomSliderEl) {
|
|
zoomSliderEl.value = String(Math.round(lightboxState.zoomScale * 100));
|
|
}
|
|
|
|
if (zoomValueEl) {
|
|
zoomValueEl.textContent = `${Math.round(lightboxState.zoomScale * 100)}%`;
|
|
}
|
|
}
|
|
|
|
function applyComparePresentation() {
|
|
if (!overlayEl || !backdropEl || !toolbarEl || !stageEl || !frameEl || !imageEl || !overlayImageEl || !compareButtonEl || !deckCompareButtonEl) {
|
|
return;
|
|
}
|
|
|
|
const isCompact = isCompactLightboxLayout();
|
|
|
|
if (!isCompact) {
|
|
settingsPanelEl.style.top = "72px";
|
|
settingsPanelEl.style.right = "24px";
|
|
settingsPanelEl.style.bottom = "auto";
|
|
settingsPanelEl.style.left = "auto";
|
|
settingsPanelEl.style.width = "min(320px, calc(100vw - 48px))";
|
|
settingsPanelEl.style.maxHeight = "none";
|
|
settingsPanelEl.style.overflowY = "visible";
|
|
helpPanelEl.style.top = "72px";
|
|
helpPanelEl.style.right = "24px";
|
|
helpPanelEl.style.bottom = "auto";
|
|
helpPanelEl.style.left = "auto";
|
|
helpPanelEl.style.width = "min(320px, calc(100vw - 48px))";
|
|
helpPanelEl.style.maxHeight = "none";
|
|
helpPanelEl.style.overflowY = "visible";
|
|
deckComparePanelEl.style.top = "72px";
|
|
deckComparePanelEl.style.right = "24px";
|
|
deckComparePanelEl.style.bottom = "auto";
|
|
deckComparePanelEl.style.left = "auto";
|
|
deckComparePanelEl.style.width = "min(280px, calc(100vw - 48px))";
|
|
deckComparePanelEl.style.maxHeight = "none";
|
|
deckComparePanelEl.style.overflowY = "visible";
|
|
}
|
|
|
|
compareButtonEl.hidden = zoomed
|
|
|| lightboxState.deckCompareMode
|
|
|| !lightboxState.allowOverlayCompare
|
|
|| (!isCompact && lightboxState.compareMode && !hasSecondaryCard());
|
|
compareButtonEl.textContent = lightboxState.compareMode ? "Done Overlay" : "Overlay";
|
|
syncSettingsUi();
|
|
syncHelpUi();
|
|
syncZoomControl();
|
|
syncExportButton();
|
|
syncOpacityControl();
|
|
syncDeckComparePicker();
|
|
syncComparePanels();
|
|
syncMobileInfoControls();
|
|
syncMobileNavigationControls();
|
|
|
|
if (lightboxState.deckCompareMode) {
|
|
overlayEl.style.pointerEvents = "none";
|
|
backdropEl.style.display = "block";
|
|
backdropEl.style.pointerEvents = "auto";
|
|
backdropEl.style.background = "rgba(0, 0, 0, 0.88)";
|
|
toolbarEl.style.top = isCompact ? "auto" : "24px";
|
|
toolbarEl.style.right = isCompact ? "12px" : "24px";
|
|
toolbarEl.style.bottom = isCompact ? "calc(12px + env(safe-area-inset-bottom, 0px))" : "auto";
|
|
toolbarEl.style.left = isCompact ? "12px" : "auto";
|
|
toolbarEl.style.flexDirection = isCompact ? "row" : "column";
|
|
toolbarEl.style.flexWrap = isCompact ? "wrap" : "nowrap";
|
|
toolbarEl.style.alignItems = isCompact ? "center" : "flex-end";
|
|
toolbarEl.style.justifyContent = isCompact ? "center" : "flex-start";
|
|
stageEl.style.top = "0";
|
|
stageEl.style.right = "0";
|
|
stageEl.style.bottom = "0";
|
|
stageEl.style.left = "0";
|
|
stageEl.style.display = "block";
|
|
stageEl.style.alignItems = "stretch";
|
|
stageEl.style.justifyContent = "stretch";
|
|
stageEl.style.width = "auto";
|
|
stageEl.style.height = "auto";
|
|
stageEl.style.transform = "none";
|
|
stageEl.style.pointerEvents = "auto";
|
|
compareGridEl.style.padding = isCompact
|
|
? (lightboxState.mobileInfoOpen ? "18px 12px 260px" : "18px 12px 84px")
|
|
: (lightboxState.mobileInfoOpen ? "clamp(210px, 30vh, 290px) 24px 24px" : "76px 24px 24px");
|
|
frameEl.style.display = "none";
|
|
primaryInfoEl.style.left = "24px";
|
|
primaryInfoEl.style.right = "24px";
|
|
primaryInfoEl.style.top = "72px";
|
|
primaryInfoEl.style.bottom = "auto";
|
|
primaryInfoEl.style.width = "auto";
|
|
primaryInfoEl.style.maxHeight = "clamp(140px, 24vh, 210px)";
|
|
primaryInfoEl.style.transform = "none";
|
|
secondaryInfoEl.style.display = "none";
|
|
if (mobileInfoPanelEl && !isCompact) {
|
|
mobileInfoPanelEl.style.display = "none";
|
|
}
|
|
syncMobileNavigationControls();
|
|
renderDeckCompareGrid();
|
|
return;
|
|
}
|
|
|
|
frameEl.style.display = "block";
|
|
compareGridEl.style.display = "none";
|
|
|
|
if (isCompact) {
|
|
overlayEl.style.pointerEvents = "none";
|
|
backdropEl.style.display = "block";
|
|
backdropEl.style.pointerEvents = "auto";
|
|
backdropEl.style.background = lightboxState.compareMode ? "rgba(0, 0, 0, 0.88)" : "rgba(0, 0, 0, 0.82)";
|
|
toolbarEl.style.top = "auto";
|
|
toolbarEl.style.right = "12px";
|
|
toolbarEl.style.bottom = "calc(12px + env(safe-area-inset-bottom, 0px))";
|
|
toolbarEl.style.left = "12px";
|
|
toolbarEl.style.flexDirection = "row";
|
|
toolbarEl.style.flexWrap = "wrap";
|
|
toolbarEl.style.alignItems = "center";
|
|
toolbarEl.style.justifyContent = "center";
|
|
settingsPanelEl.style.top = "auto";
|
|
settingsPanelEl.style.right = "12px";
|
|
settingsPanelEl.style.bottom = "calc(72px + env(safe-area-inset-bottom, 0px))";
|
|
settingsPanelEl.style.left = "12px";
|
|
settingsPanelEl.style.width = "auto";
|
|
settingsPanelEl.style.maxHeight = "min(56svh, 440px)";
|
|
settingsPanelEl.style.overflowY = "auto";
|
|
helpPanelEl.style.top = "auto";
|
|
helpPanelEl.style.right = "12px";
|
|
helpPanelEl.style.bottom = "calc(72px + env(safe-area-inset-bottom, 0px))";
|
|
helpPanelEl.style.left = "12px";
|
|
helpPanelEl.style.width = "auto";
|
|
helpPanelEl.style.maxHeight = "min(42svh, 360px)";
|
|
helpPanelEl.style.overflowY = "auto";
|
|
deckComparePanelEl.style.top = "auto";
|
|
deckComparePanelEl.style.right = "12px";
|
|
deckComparePanelEl.style.bottom = "calc(72px + env(safe-area-inset-bottom, 0px))";
|
|
deckComparePanelEl.style.left = "12px";
|
|
deckComparePanelEl.style.width = "auto";
|
|
deckComparePanelEl.style.maxHeight = "min(42svh, 360px)";
|
|
deckComparePanelEl.style.overflowY = "auto";
|
|
stageEl.style.top = "calc(12px + env(safe-area-inset-top, 0px))";
|
|
stageEl.style.right = "12px";
|
|
stageEl.style.bottom = "calc(84px + env(safe-area-inset-bottom, 0px))";
|
|
stageEl.style.left = "12px";
|
|
stageEl.style.display = "flex";
|
|
stageEl.style.alignItems = "center";
|
|
stageEl.style.justifyContent = "center";
|
|
stageEl.style.width = "auto";
|
|
stageEl.style.height = "auto";
|
|
stageEl.style.transform = "none";
|
|
stageEl.style.pointerEvents = "auto";
|
|
frameEl.style.position = "relative";
|
|
frameEl.style.width = "min(100%, 520px)";
|
|
frameEl.style.height = "100%";
|
|
frameEl.style.maxWidth = "520px";
|
|
frameEl.style.maxHeight = "100%";
|
|
frameEl.style.borderRadius = zoomed && hasSecondaryCard() ? "0" : "24px";
|
|
frameEl.style.background = zoomed && hasSecondaryCard() ? "transparent" : "rgba(11, 15, 26, 0.92)";
|
|
frameEl.style.boxShadow = zoomed && hasSecondaryCard() ? "none" : "0 24px 64px rgba(0, 0, 0, 0.44)";
|
|
frameEl.style.overflow = "hidden";
|
|
imageEl.style.width = "100%";
|
|
imageEl.style.height = "100%";
|
|
imageEl.style.maxWidth = "none";
|
|
imageEl.style.maxHeight = "none";
|
|
imageEl.style.objectFit = "contain";
|
|
overlayImageEl.style.display = hasSecondaryCard() ? "block" : "none";
|
|
primaryInfoEl.style.maxHeight = "min(78vh, 760px)";
|
|
primaryInfoEl.style.display = "none";
|
|
secondaryInfoEl.style.display = "none";
|
|
applyZoomTransform();
|
|
setOverlayOpacity(lightboxState.overlayOpacity);
|
|
return;
|
|
}
|
|
|
|
if (!lightboxState.compareMode) {
|
|
overlayEl.style.pointerEvents = "none";
|
|
backdropEl.style.display = "block";
|
|
backdropEl.style.pointerEvents = "auto";
|
|
backdropEl.style.background = "rgba(0, 0, 0, 0.82)";
|
|
toolbarEl.style.top = "24px";
|
|
toolbarEl.style.right = "24px";
|
|
toolbarEl.style.bottom = "auto";
|
|
toolbarEl.style.left = "auto";
|
|
toolbarEl.style.flexDirection = "column";
|
|
toolbarEl.style.flexWrap = "nowrap";
|
|
toolbarEl.style.alignItems = "flex-end";
|
|
toolbarEl.style.justifyContent = "flex-start";
|
|
stageEl.style.top = "0";
|
|
stageEl.style.right = "0";
|
|
stageEl.style.bottom = "0";
|
|
stageEl.style.left = "0";
|
|
stageEl.style.display = "block";
|
|
stageEl.style.alignItems = "stretch";
|
|
stageEl.style.justifyContent = "stretch";
|
|
stageEl.style.width = "auto";
|
|
stageEl.style.height = "auto";
|
|
stageEl.style.transform = "none";
|
|
stageEl.style.pointerEvents = "auto";
|
|
frameEl.style.position = "relative";
|
|
frameEl.style.width = "100%";
|
|
frameEl.style.height = "100%";
|
|
frameEl.style.maxWidth = "none";
|
|
frameEl.style.maxHeight = "none";
|
|
frameEl.style.borderRadius = "0";
|
|
frameEl.style.background = "transparent";
|
|
frameEl.style.boxShadow = "none";
|
|
frameEl.style.overflow = "hidden";
|
|
primaryInfoEl.style.left = "auto";
|
|
primaryInfoEl.style.right = "18px";
|
|
primaryInfoEl.style.top = "50%";
|
|
primaryInfoEl.style.bottom = "auto";
|
|
primaryInfoEl.style.width = "clamp(220px, 20vw, 320px)";
|
|
primaryInfoEl.style.maxHeight = "min(78vh, 760px)";
|
|
primaryInfoEl.style.transform = "translateY(-50%)";
|
|
imageEl.style.width = "100%";
|
|
imageEl.style.height = "100%";
|
|
imageEl.style.maxWidth = "none";
|
|
imageEl.style.maxHeight = "none";
|
|
imageEl.style.objectFit = "contain";
|
|
overlayImageEl.style.display = "none";
|
|
secondaryInfoEl.style.display = "none";
|
|
applyZoomTransform();
|
|
return;
|
|
}
|
|
|
|
overlayEl.style.pointerEvents = "none";
|
|
backdropEl.style.display = "none";
|
|
backdropEl.style.pointerEvents = "none";
|
|
toolbarEl.style.top = "18px";
|
|
toolbarEl.style.right = "18px";
|
|
toolbarEl.style.bottom = "auto";
|
|
toolbarEl.style.left = "auto";
|
|
toolbarEl.style.flexDirection = "column";
|
|
toolbarEl.style.flexWrap = "nowrap";
|
|
toolbarEl.style.alignItems = "flex-end";
|
|
toolbarEl.style.justifyContent = "flex-start";
|
|
stageEl.style.display = "block";
|
|
stageEl.style.alignItems = "stretch";
|
|
stageEl.style.justifyContent = "stretch";
|
|
stageEl.style.pointerEvents = "auto";
|
|
frameEl.style.position = "relative";
|
|
frameEl.style.width = "100%";
|
|
frameEl.style.height = "100%";
|
|
frameEl.style.maxWidth = "none";
|
|
frameEl.style.maxHeight = "none";
|
|
frameEl.style.overflow = "hidden";
|
|
imageEl.style.width = "100%";
|
|
imageEl.style.height = "100%";
|
|
imageEl.style.maxWidth = "none";
|
|
imageEl.style.maxHeight = "none";
|
|
imageEl.style.objectFit = "contain";
|
|
updateImageCursor();
|
|
|
|
if (zoomed && hasSecondaryCard()) {
|
|
stageEl.style.top = "0";
|
|
stageEl.style.right = "0";
|
|
stageEl.style.bottom = "0";
|
|
stageEl.style.left = "0";
|
|
stageEl.style.width = "auto";
|
|
stageEl.style.height = "auto";
|
|
stageEl.style.transform = "none";
|
|
frameEl.style.borderRadius = "0";
|
|
frameEl.style.background = "transparent";
|
|
frameEl.style.boxShadow = "none";
|
|
} else if (!hasSecondaryCard()) {
|
|
stageEl.style.top = "auto";
|
|
stageEl.style.right = "18px";
|
|
stageEl.style.bottom = "18px";
|
|
stageEl.style.left = "auto";
|
|
stageEl.style.width = "clamp(180px, 18vw, 280px)";
|
|
stageEl.style.height = "min(44vh, 520px)";
|
|
stageEl.style.transform = "none";
|
|
frameEl.style.borderRadius = "22px";
|
|
frameEl.style.background = "rgba(13, 13, 20, 0.9)";
|
|
frameEl.style.boxShadow = "0 24px 64px rgba(0, 0, 0, 0.5)";
|
|
primaryInfoEl.style.left = "auto";
|
|
primaryInfoEl.style.right = "calc(100% + 16px)";
|
|
primaryInfoEl.style.top = "50%";
|
|
primaryInfoEl.style.bottom = "auto";
|
|
primaryInfoEl.style.width = "clamp(220px, 22vw, 320px)";
|
|
primaryInfoEl.style.maxHeight = "min(78vh, 760px)";
|
|
primaryInfoEl.style.transform = "translateY(-50%)";
|
|
} else {
|
|
stageEl.style.top = "50%";
|
|
stageEl.style.right = "auto";
|
|
stageEl.style.bottom = "auto";
|
|
stageEl.style.left = "50%";
|
|
stageEl.style.width = "min(44vw, 560px)";
|
|
stageEl.style.height = "min(92vh, 1400px)";
|
|
stageEl.style.transform = "translate(-50%, -50%)";
|
|
frameEl.style.borderRadius = "28px";
|
|
frameEl.style.background = "rgba(11, 15, 26, 0.88)";
|
|
frameEl.style.boxShadow = "0 30px 90px rgba(0, 0, 0, 0.56)";
|
|
primaryInfoEl.style.left = "auto";
|
|
primaryInfoEl.style.right = "calc(100% + 10px)";
|
|
primaryInfoEl.style.top = "50%";
|
|
primaryInfoEl.style.bottom = "auto";
|
|
primaryInfoEl.style.width = "clamp(180px, 15vw, 220px)";
|
|
primaryInfoEl.style.maxHeight = "min(78vh, 760px)";
|
|
primaryInfoEl.style.transform = "translateY(-50%)";
|
|
secondaryInfoEl.style.left = "calc(100% + 10px)";
|
|
secondaryInfoEl.style.right = "auto";
|
|
secondaryInfoEl.style.top = "50%";
|
|
secondaryInfoEl.style.bottom = "auto";
|
|
secondaryInfoEl.style.width = "clamp(180px, 15vw, 220px)";
|
|
secondaryInfoEl.style.transform = "translateY(-50%)";
|
|
}
|
|
|
|
if (hasSecondaryCard()) {
|
|
overlayImageEl.style.display = "block";
|
|
} else {
|
|
overlayImageEl.style.display = "none";
|
|
secondaryInfoEl.style.display = "none";
|
|
}
|
|
|
|
applyZoomTransform();
|
|
setOverlayOpacity(lightboxState.overlayOpacity);
|
|
}
|
|
|
|
function resetZoom() {
|
|
if (!imageEl && !overlayImageEl) {
|
|
return;
|
|
}
|
|
|
|
clearActivePointerGesture();
|
|
clearActivePinchGesture();
|
|
suppressNextCardClick = false;
|
|
lightboxState.zoomOriginX = 50;
|
|
lightboxState.zoomOriginY = 50;
|
|
applyTransformOrigins();
|
|
|
|
zoomed = false;
|
|
applyZoomTransform();
|
|
}
|
|
|
|
function updateZoomOrigin(clientX, clientY, targetImage = imageEl, targetFrame = null) {
|
|
const referenceEl = targetFrame || targetImage;
|
|
if (!zoomed || !referenceEl) {
|
|
return;
|
|
}
|
|
|
|
const rect = referenceEl.getBoundingClientRect();
|
|
if (!rect.width || !rect.height) {
|
|
return;
|
|
}
|
|
|
|
const x = Math.min(100, Math.max(0, ((clientX - rect.left) / rect.width) * 100));
|
|
const y = Math.min(100, Math.max(0, ((clientY - rect.top) / rect.height) * 100));
|
|
lightboxState.zoomOriginX = x;
|
|
lightboxState.zoomOriginY = y;
|
|
applyTransformOrigins();
|
|
}
|
|
|
|
function handleCompactPointerDown(event) {
|
|
if (!shouldHandleCompactPointerGesture(event)) {
|
|
return false;
|
|
}
|
|
|
|
activePointerId = event.pointerId;
|
|
activePointerStartX = Number(event.clientX) || 0;
|
|
activePointerStartY = Number(event.clientY) || 0;
|
|
activePointerMoved = false;
|
|
|
|
if (zoomed) {
|
|
event.preventDefault();
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
function handleCompactPointerMove(event, targetImage = imageEl, targetFrame = null) {
|
|
if (!shouldHandleCompactPointerGesture(event) || event.pointerId !== activePointerId || !zoomed) {
|
|
return;
|
|
}
|
|
|
|
const deltaX = Math.abs((Number(event.clientX) || 0) - activePointerStartX);
|
|
const deltaY = Math.abs((Number(event.clientY) || 0) - activePointerStartY);
|
|
if (!activePointerMoved && Math.max(deltaX, deltaY) < 6) {
|
|
event.preventDefault();
|
|
return;
|
|
}
|
|
|
|
activePointerMoved = true;
|
|
suppressNextCardClick = true;
|
|
event.preventDefault();
|
|
updateZoomOrigin(event.clientX, event.clientY, targetImage, targetFrame);
|
|
}
|
|
|
|
function handleCompactPointerEnd(event, targetImage = imageEl) {
|
|
if (!shouldHandleCompactPointerGesture(event) || event.pointerId !== activePointerId) {
|
|
return;
|
|
}
|
|
|
|
if (activePointerMoved) {
|
|
suppressNextCardClick = true;
|
|
}
|
|
|
|
if (typeof targetImage?.releasePointerCapture === "function" && targetImage.hasPointerCapture?.(event.pointerId)) {
|
|
targetImage.releasePointerCapture(event.pointerId);
|
|
}
|
|
|
|
clearActivePointerGesture();
|
|
}
|
|
|
|
function handleCompactPinchStart(event, targetImage = imageEl, targetFrame = null) {
|
|
if (!lightboxState.isOpen || !isCompactLightboxLayout() || !targetImage || event.touches.length < 2) {
|
|
return false;
|
|
}
|
|
|
|
const midpoint = getTouchMidpoint(event.touches);
|
|
const distance = getTouchDistance(event.touches);
|
|
if (!midpoint || !(distance > 0)) {
|
|
return false;
|
|
}
|
|
|
|
clearActivePointerGesture();
|
|
activePinchGesture = {
|
|
targetImage,
|
|
targetFrame,
|
|
startDistance: distance,
|
|
startScale: zoomed ? lightboxState.zoomScale : 1
|
|
};
|
|
suppressNextCardClick = true;
|
|
event.preventDefault();
|
|
return true;
|
|
}
|
|
|
|
function handleCompactPinchMove(event) {
|
|
if (!activePinchGesture || event.touches.length < 2) {
|
|
return false;
|
|
}
|
|
|
|
const midpoint = getTouchMidpoint(event.touches);
|
|
const distance = getTouchDistance(event.touches);
|
|
if (!midpoint || !(distance > 0)) {
|
|
return false;
|
|
}
|
|
|
|
const nextScale = clampZoomScale(activePinchGesture.startScale * (distance / activePinchGesture.startDistance));
|
|
zoomed = nextScale > 1;
|
|
setZoomScale(nextScale);
|
|
if (zoomed) {
|
|
updateZoomOrigin(midpoint.x, midpoint.y, activePinchGesture.targetImage, activePinchGesture.targetFrame);
|
|
} else {
|
|
lightboxState.zoomOriginX = 50;
|
|
lightboxState.zoomOriginY = 50;
|
|
applyTransformOrigins();
|
|
}
|
|
|
|
suppressNextCardClick = true;
|
|
event.preventDefault();
|
|
return true;
|
|
}
|
|
|
|
function handleCompactPinchEnd(event) {
|
|
if (!activePinchGesture) {
|
|
return false;
|
|
}
|
|
|
|
if (event.touches.length >= 2) {
|
|
const targetImage = activePinchGesture.targetImage;
|
|
const targetFrame = activePinchGesture.targetFrame;
|
|
clearActivePinchGesture();
|
|
handleCompactPinchStart(event, targetImage, targetFrame);
|
|
return true;
|
|
}
|
|
|
|
clearActivePinchGesture();
|
|
return true;
|
|
}
|
|
|
|
function preventCompactTouchScroll(event) {
|
|
if (!lightboxState.isOpen || !isCompactLightboxLayout() || (!zoomed && !activePinchGesture)) {
|
|
return;
|
|
}
|
|
|
|
event.preventDefault();
|
|
}
|
|
|
|
function preventBrowserZoomGesture(event) {
|
|
if (!lightboxState.isOpen) {
|
|
return;
|
|
}
|
|
|
|
if (event.type === "wheel" && !event.ctrlKey) {
|
|
return;
|
|
}
|
|
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
}
|
|
|
|
function isPointOnCard(clientX, clientY, targetImage = imageEl, targetFrame = null) {
|
|
const frameElForHitTest = targetFrame || targetImage;
|
|
if (!targetImage || !frameElForHitTest) {
|
|
return false;
|
|
}
|
|
|
|
const rect = frameElForHitTest.getBoundingClientRect();
|
|
const naturalWidth = targetImage.naturalWidth;
|
|
const naturalHeight = targetImage.naturalHeight;
|
|
|
|
if (!rect.width || !rect.height || !naturalWidth || !naturalHeight) {
|
|
return true;
|
|
}
|
|
|
|
const frameAspect = rect.width / rect.height;
|
|
const imageAspect = naturalWidth / naturalHeight;
|
|
|
|
let renderWidth = rect.width;
|
|
let renderHeight = rect.height;
|
|
if (imageAspect > frameAspect) {
|
|
renderHeight = rect.width / imageAspect;
|
|
} else {
|
|
renderWidth = rect.height * imageAspect;
|
|
}
|
|
|
|
const left = rect.left + (rect.width - renderWidth) / 2;
|
|
const top = rect.top + (rect.height - renderHeight) / 2;
|
|
const right = left + renderWidth;
|
|
const bottom = top + renderHeight;
|
|
|
|
return clientX >= left && clientX <= right && clientY >= top && clientY <= bottom;
|
|
}
|
|
|
|
function ensure() {
|
|
if (overlayEl && imageEl && overlayImageEl) {
|
|
return;
|
|
}
|
|
|
|
overlayEl = document.createElement("div");
|
|
overlayEl.setAttribute("aria-hidden", "true");
|
|
overlayEl.setAttribute("role", "dialog");
|
|
overlayEl.setAttribute("aria-modal", "true");
|
|
overlayEl.tabIndex = -1;
|
|
overlayEl.style.position = "fixed";
|
|
overlayEl.style.inset = "0";
|
|
overlayEl.style.display = "none";
|
|
overlayEl.style.zIndex = "9999";
|
|
overlayEl.style.pointerEvents = "none";
|
|
overlayEl.style.overscrollBehavior = "contain";
|
|
|
|
settingsButtonEl = document.createElement("button");
|
|
settingsButtonEl.type = "button";
|
|
settingsButtonEl.textContent = "Settings";
|
|
settingsButtonEl.style.display = "none";
|
|
settingsButtonEl.style.alignItems = "center";
|
|
settingsButtonEl.style.justifyContent = "center";
|
|
settingsButtonEl.style.border = "1px solid rgba(255, 255, 255, 0.2)";
|
|
settingsButtonEl.style.background = "rgba(15, 23, 42, 0.84)";
|
|
settingsButtonEl.style.color = "#f8fafc";
|
|
settingsButtonEl.style.borderRadius = "999px";
|
|
settingsButtonEl.style.padding = "10px 14px";
|
|
settingsButtonEl.style.font = "600 13px/1.1 sans-serif";
|
|
settingsButtonEl.style.cursor = "pointer";
|
|
settingsButtonEl.style.backdropFilter = "blur(12px)";
|
|
|
|
helpButtonEl = document.createElement("button");
|
|
helpButtonEl.type = "button";
|
|
helpButtonEl.textContent = "Help";
|
|
helpButtonEl.style.display = "none";
|
|
helpButtonEl.style.alignItems = "center";
|
|
helpButtonEl.style.justifyContent = "center";
|
|
helpButtonEl.style.width = "100%";
|
|
helpButtonEl.style.border = "1px solid rgba(255, 255, 255, 0.2)";
|
|
helpButtonEl.style.background = "rgba(15, 23, 42, 0.84)";
|
|
helpButtonEl.style.color = "#f8fafc";
|
|
helpButtonEl.style.borderRadius = "999px";
|
|
helpButtonEl.style.padding = "10px 14px";
|
|
helpButtonEl.style.font = "600 13px/1.1 sans-serif";
|
|
helpButtonEl.style.cursor = "pointer";
|
|
helpButtonEl.style.backdropFilter = "blur(12px)";
|
|
|
|
settingsPanelEl = document.createElement("div");
|
|
settingsPanelEl.style.position = "fixed";
|
|
settingsPanelEl.style.top = "72px";
|
|
settingsPanelEl.style.right = "24px";
|
|
settingsPanelEl.style.display = "none";
|
|
settingsPanelEl.style.flexDirection = "column";
|
|
settingsPanelEl.style.gap = "10px";
|
|
settingsPanelEl.style.width = "min(320px, calc(100vw - 48px))";
|
|
settingsPanelEl.style.padding = "14px 16px";
|
|
settingsPanelEl.style.borderRadius = "18px";
|
|
settingsPanelEl.style.background = "rgba(2, 6, 23, 0.88)";
|
|
settingsPanelEl.style.border = "1px solid rgba(148, 163, 184, 0.16)";
|
|
settingsPanelEl.style.color = "#f8fafc";
|
|
settingsPanelEl.style.boxShadow = "0 16px 42px rgba(0, 0, 0, 0.34)";
|
|
settingsPanelEl.style.backdropFilter = "blur(12px)";
|
|
settingsPanelEl.style.pointerEvents = "auto";
|
|
settingsPanelEl.style.zIndex = "3";
|
|
|
|
const settingsTitleEl = document.createElement("div");
|
|
settingsTitleEl.textContent = "Lightbox Settings";
|
|
settingsTitleEl.style.font = "700 13px/1.3 sans-serif";
|
|
|
|
helpPanelEl = document.createElement("div");
|
|
helpPanelEl.style.position = "fixed";
|
|
helpPanelEl.style.top = "72px";
|
|
helpPanelEl.style.left = "24px";
|
|
helpPanelEl.style.display = "none";
|
|
helpPanelEl.style.flexDirection = "column";
|
|
helpPanelEl.style.gap = "8px";
|
|
helpPanelEl.style.width = "min(320px, calc(100vw - 48px))";
|
|
helpPanelEl.style.padding = "14px 16px";
|
|
helpPanelEl.style.borderRadius = "18px";
|
|
helpPanelEl.style.background = "rgba(2, 6, 23, 0.88)";
|
|
helpPanelEl.style.border = "1px solid rgba(148, 163, 184, 0.16)";
|
|
helpPanelEl.style.color = "#f8fafc";
|
|
helpPanelEl.style.boxShadow = "0 16px 42px rgba(0, 0, 0, 0.34)";
|
|
helpPanelEl.style.backdropFilter = "blur(12px)";
|
|
helpPanelEl.style.pointerEvents = "auto";
|
|
helpPanelEl.style.zIndex = "2";
|
|
|
|
const helpTitleEl = document.createElement("div");
|
|
helpTitleEl.textContent = "Lightbox Shortcuts";
|
|
helpTitleEl.style.font = "700 13px/1.3 sans-serif";
|
|
|
|
const helpListEl = document.createElement("div");
|
|
helpListEl.style.display = "flex";
|
|
helpListEl.style.flexDirection = "column";
|
|
helpListEl.style.gap = "6px";
|
|
helpListEl.style.font = "500 12px/1.4 sans-serif";
|
|
helpListEl.style.color = "rgba(226, 232, 240, 0.92)";
|
|
|
|
[
|
|
"Click card: toggle zoom at the clicked point",
|
|
"Left / Right: move cards, or move overlay card in compare mode",
|
|
"Overlay: pick a second card to compare",
|
|
"Compare: show the same card from other registered decks",
|
|
"Space: swap base and overlay cards",
|
|
"R: rotate base card, or rotate overlay card in compare mode",
|
|
"+ / -: zoom in or out in steps",
|
|
"W A S D: pan while zoomed",
|
|
"Escape or backdrop click: close"
|
|
].forEach((line) => {
|
|
const lineEl = document.createElement("div");
|
|
lineEl.textContent = line;
|
|
helpListEl.appendChild(lineEl);
|
|
});
|
|
|
|
helpPanelEl.append(helpTitleEl, helpListEl);
|
|
|
|
backdropEl = document.createElement("button");
|
|
backdropEl.type = "button";
|
|
backdropEl.setAttribute("aria-label", "Close enlarged tarot card");
|
|
backdropEl.style.position = "absolute";
|
|
backdropEl.style.inset = "0";
|
|
backdropEl.style.border = "none";
|
|
backdropEl.style.padding = "0";
|
|
backdropEl.style.margin = "0";
|
|
backdropEl.style.background = "rgba(0, 0, 0, 0.82)";
|
|
backdropEl.style.cursor = "pointer";
|
|
|
|
toolbarEl = document.createElement("div");
|
|
toolbarEl.style.position = "fixed";
|
|
toolbarEl.style.top = "24px";
|
|
toolbarEl.style.right = "24px";
|
|
toolbarEl.style.display = "flex";
|
|
toolbarEl.style.flexDirection = "column";
|
|
toolbarEl.style.alignItems = "flex-end";
|
|
toolbarEl.style.gap = "8px";
|
|
toolbarEl.style.pointerEvents = "auto";
|
|
toolbarEl.style.zIndex = "2";
|
|
|
|
compareButtonEl = document.createElement("button");
|
|
compareButtonEl.type = "button";
|
|
compareButtonEl.textContent = "Overlay";
|
|
compareButtonEl.style.border = "1px solid rgba(255, 255, 255, 0.2)";
|
|
compareButtonEl.style.background = "rgba(15, 23, 42, 0.84)";
|
|
compareButtonEl.style.color = "#f8fafc";
|
|
compareButtonEl.style.borderRadius = "999px";
|
|
compareButtonEl.style.padding = "10px 14px";
|
|
compareButtonEl.style.font = "600 13px/1.1 sans-serif";
|
|
compareButtonEl.style.cursor = "pointer";
|
|
compareButtonEl.style.backdropFilter = "blur(12px)";
|
|
compareButtonEl.style.display = "inline-flex";
|
|
compareButtonEl.style.alignItems = "center";
|
|
compareButtonEl.style.justifyContent = "center";
|
|
compareButtonEl.style.width = "100%";
|
|
|
|
deckCompareButtonEl = document.createElement("button");
|
|
deckCompareButtonEl.type = "button";
|
|
deckCompareButtonEl.textContent = "Compare";
|
|
deckCompareButtonEl.style.border = "1px solid rgba(255, 255, 255, 0.2)";
|
|
deckCompareButtonEl.style.background = "rgba(15, 23, 42, 0.84)";
|
|
deckCompareButtonEl.style.color = "#f8fafc";
|
|
deckCompareButtonEl.style.borderRadius = "999px";
|
|
deckCompareButtonEl.style.padding = "10px 14px";
|
|
deckCompareButtonEl.style.font = "600 13px/1.1 sans-serif";
|
|
deckCompareButtonEl.style.cursor = "pointer";
|
|
deckCompareButtonEl.style.backdropFilter = "blur(12px)";
|
|
deckCompareButtonEl.style.alignItems = "center";
|
|
deckCompareButtonEl.style.justifyContent = "center";
|
|
deckCompareButtonEl.style.width = "100%";
|
|
|
|
zoomControlEl = document.createElement("label");
|
|
zoomControlEl.style.display = "flex";
|
|
zoomControlEl.style.alignItems = "center";
|
|
zoomControlEl.style.justifyContent = "space-between";
|
|
zoomControlEl.style.width = "100%";
|
|
zoomControlEl.style.gap = "8px";
|
|
zoomControlEl.style.padding = "10px 14px";
|
|
zoomControlEl.style.border = "1px solid rgba(255, 255, 255, 0.2)";
|
|
zoomControlEl.style.borderRadius = "999px";
|
|
zoomControlEl.style.background = "rgba(15, 23, 42, 0.84)";
|
|
zoomControlEl.style.color = "#f8fafc";
|
|
zoomControlEl.style.font = "600 12px/1.1 sans-serif";
|
|
zoomControlEl.style.backdropFilter = "blur(12px)";
|
|
|
|
const zoomTextEl = document.createElement("span");
|
|
zoomTextEl.textContent = "Zoom";
|
|
|
|
zoomSliderEl = document.createElement("input");
|
|
zoomSliderEl.type = "range";
|
|
zoomSliderEl.min = "100";
|
|
zoomSliderEl.max = String(Math.round(LIGHTBOX_ZOOM_SCALE * 100));
|
|
zoomSliderEl.step = "10";
|
|
zoomSliderEl.value = String(Math.round(LIGHTBOX_ZOOM_SCALE * 100));
|
|
zoomSliderEl.style.width = "110px";
|
|
zoomSliderEl.style.cursor = "pointer";
|
|
|
|
zoomValueEl = document.createElement("span");
|
|
zoomValueEl.textContent = `${Math.round(LIGHTBOX_ZOOM_SCALE * 100)}%`;
|
|
zoomValueEl.style.minWidth = "42px";
|
|
zoomValueEl.style.textAlign = "right";
|
|
|
|
zoomControlEl.append(zoomTextEl, zoomSliderEl, zoomValueEl);
|
|
|
|
opacityControlEl = document.createElement("label");
|
|
opacityControlEl.style.display = "none";
|
|
opacityControlEl.style.alignItems = "center";
|
|
opacityControlEl.style.justifyContent = "space-between";
|
|
opacityControlEl.style.width = "100%";
|
|
opacityControlEl.style.gap = "8px";
|
|
opacityControlEl.style.padding = "10px 14px";
|
|
opacityControlEl.style.border = "1px solid rgba(255, 255, 255, 0.2)";
|
|
opacityControlEl.style.borderRadius = "999px";
|
|
opacityControlEl.style.background = "rgba(15, 23, 42, 0.84)";
|
|
opacityControlEl.style.color = "#f8fafc";
|
|
opacityControlEl.style.font = "600 12px/1.1 sans-serif";
|
|
opacityControlEl.style.backdropFilter = "blur(12px)";
|
|
|
|
const opacityTextEl = document.createElement("span");
|
|
opacityTextEl.textContent = "Overlay";
|
|
|
|
opacitySliderEl = document.createElement("input");
|
|
opacitySliderEl.type = "range";
|
|
opacitySliderEl.min = "5";
|
|
opacitySliderEl.max = "100";
|
|
opacitySliderEl.step = "5";
|
|
opacitySliderEl.value = String(Math.round(LIGHTBOX_COMPARE_DEFAULT_OVERLAY_OPACITY * 100));
|
|
opacitySliderEl.style.width = "110px";
|
|
opacitySliderEl.style.cursor = "pointer";
|
|
|
|
opacityValueEl = document.createElement("span");
|
|
opacityValueEl.textContent = `${Math.round(LIGHTBOX_COMPARE_DEFAULT_OVERLAY_OPACITY * 100)}%`;
|
|
opacityValueEl.style.minWidth = "34px";
|
|
opacityValueEl.style.textAlign = "right";
|
|
|
|
opacityControlEl.append(opacityTextEl, opacitySliderEl, opacityValueEl);
|
|
|
|
exportButtonEl = document.createElement("button");
|
|
exportButtonEl.type = "button";
|
|
exportButtonEl.textContent = "Export WebP";
|
|
exportButtonEl.style.display = "none";
|
|
exportButtonEl.style.alignItems = "center";
|
|
exportButtonEl.style.justifyContent = "center";
|
|
exportButtonEl.style.width = "100%";
|
|
exportButtonEl.style.border = "1px solid rgba(255, 255, 255, 0.2)";
|
|
exportButtonEl.style.background = "rgba(15, 23, 42, 0.84)";
|
|
exportButtonEl.style.color = "#f8fafc";
|
|
exportButtonEl.style.borderRadius = "999px";
|
|
exportButtonEl.style.padding = "10px 14px";
|
|
exportButtonEl.style.font = "600 13px/1.1 sans-serif";
|
|
exportButtonEl.style.cursor = "pointer";
|
|
exportButtonEl.style.backdropFilter = "blur(12px)";
|
|
|
|
deckComparePanelEl = document.createElement("div");
|
|
deckComparePanelEl.style.position = "fixed";
|
|
deckComparePanelEl.style.top = "24px";
|
|
deckComparePanelEl.style.right = "176px";
|
|
deckComparePanelEl.style.display = "none";
|
|
deckComparePanelEl.style.flexDirection = "column";
|
|
deckComparePanelEl.style.gap = "10px";
|
|
deckComparePanelEl.style.width = "min(280px, calc(100vw - 48px))";
|
|
deckComparePanelEl.style.padding = "14px 16px";
|
|
deckComparePanelEl.style.borderRadius = "18px";
|
|
deckComparePanelEl.style.background = "rgba(2, 6, 23, 0.88)";
|
|
deckComparePanelEl.style.border = "1px solid rgba(148, 163, 184, 0.16)";
|
|
deckComparePanelEl.style.color = "#f8fafc";
|
|
deckComparePanelEl.style.boxShadow = "0 16px 42px rgba(0, 0, 0, 0.34)";
|
|
deckComparePanelEl.style.backdropFilter = "blur(12px)";
|
|
deckComparePanelEl.style.pointerEvents = "auto";
|
|
deckComparePanelEl.style.touchAction = "manipulation";
|
|
deckComparePanelEl.style.zIndex = "2";
|
|
|
|
const deckCompareHeaderEl = document.createElement("div");
|
|
deckCompareHeaderEl.style.display = "flex";
|
|
deckCompareHeaderEl.style.alignItems = "center";
|
|
deckCompareHeaderEl.style.justifyContent = "space-between";
|
|
deckCompareHeaderEl.style.gap = "10px";
|
|
|
|
const deckCompareTitleEl = document.createElement("div");
|
|
deckCompareTitleEl.textContent = "Compare Registered Decks";
|
|
deckCompareTitleEl.style.font = "700 13px/1.3 sans-serif";
|
|
|
|
const deckCompareCloseButtonEl = document.createElement("button");
|
|
deckCompareCloseButtonEl.type = "button";
|
|
deckCompareCloseButtonEl.textContent = "Close";
|
|
deckCompareCloseButtonEl.style.padding = "6px 10px";
|
|
deckCompareCloseButtonEl.style.borderRadius = "999px";
|
|
deckCompareCloseButtonEl.style.border = "1px solid rgba(248, 250, 252, 0.16)";
|
|
deckCompareCloseButtonEl.style.background = "rgba(15, 23, 42, 0.44)";
|
|
deckCompareCloseButtonEl.style.color = "rgba(248, 250, 252, 0.92)";
|
|
deckCompareCloseButtonEl.style.cursor = "pointer";
|
|
deckCompareCloseButtonEl.style.font = "600 11px/1.2 sans-serif";
|
|
|
|
deckCompareHeaderEl.append(deckCompareTitleEl, deckCompareCloseButtonEl);
|
|
|
|
deckCompareMessageEl = document.createElement("div");
|
|
deckCompareMessageEl.style.font = "500 12px/1.4 sans-serif";
|
|
deckCompareMessageEl.style.color = "rgba(226, 232, 240, 0.84)";
|
|
|
|
deckCompareDeckListEl = document.createElement("div");
|
|
deckCompareDeckListEl.style.display = "flex";
|
|
deckCompareDeckListEl.style.flexDirection = "column";
|
|
deckCompareDeckListEl.style.gap = "8px";
|
|
|
|
deckComparePanelEl.append(deckCompareHeaderEl, deckCompareMessageEl, deckCompareDeckListEl);
|
|
|
|
mobileInfoButtonEl = document.createElement("button");
|
|
mobileInfoButtonEl.type = "button";
|
|
mobileInfoButtonEl.textContent = "Info";
|
|
mobileInfoButtonEl.style.display = "none";
|
|
mobileInfoButtonEl.style.border = "1px solid rgba(255, 255, 255, 0.2)";
|
|
mobileInfoButtonEl.style.background = "rgba(15, 23, 42, 0.84)";
|
|
mobileInfoButtonEl.style.color = "#f8fafc";
|
|
mobileInfoButtonEl.style.borderRadius = "999px";
|
|
mobileInfoButtonEl.style.padding = "10px 14px";
|
|
mobileInfoButtonEl.style.font = "600 13px/1.1 sans-serif";
|
|
mobileInfoButtonEl.style.cursor = "pointer";
|
|
mobileInfoButtonEl.style.backdropFilter = "blur(12px)";
|
|
mobileInfoButtonEl.style.alignItems = "center";
|
|
mobileInfoButtonEl.style.justifyContent = "center";
|
|
mobileInfoButtonEl.style.width = "100%";
|
|
|
|
mobileInfoPrimaryTabEl = document.createElement("button");
|
|
mobileInfoPrimaryTabEl.type = "button";
|
|
mobileInfoPrimaryTabEl.textContent = "Base";
|
|
mobileInfoPrimaryTabEl.style.display = "none";
|
|
mobileInfoPrimaryTabEl.style.border = "1px solid rgba(255, 255, 255, 0.2)";
|
|
mobileInfoPrimaryTabEl.style.background = "rgba(15, 23, 42, 0.84)";
|
|
mobileInfoPrimaryTabEl.style.color = "#f8fafc";
|
|
mobileInfoPrimaryTabEl.style.borderRadius = "999px";
|
|
mobileInfoPrimaryTabEl.style.padding = "10px 14px";
|
|
mobileInfoPrimaryTabEl.style.font = "600 13px/1.1 sans-serif";
|
|
mobileInfoPrimaryTabEl.style.cursor = "pointer";
|
|
mobileInfoPrimaryTabEl.style.backdropFilter = "blur(12px)";
|
|
mobileInfoPrimaryTabEl.style.alignItems = "center";
|
|
mobileInfoPrimaryTabEl.style.justifyContent = "center";
|
|
mobileInfoPrimaryTabEl.style.width = "100%";
|
|
|
|
mobileInfoSecondaryTabEl = document.createElement("button");
|
|
mobileInfoSecondaryTabEl.type = "button";
|
|
mobileInfoSecondaryTabEl.textContent = "Overlay";
|
|
mobileInfoSecondaryTabEl.style.display = "none";
|
|
mobileInfoSecondaryTabEl.style.border = "1px solid rgba(255, 255, 255, 0.2)";
|
|
mobileInfoSecondaryTabEl.style.background = "rgba(15, 23, 42, 0.84)";
|
|
mobileInfoSecondaryTabEl.style.color = "#f8fafc";
|
|
mobileInfoSecondaryTabEl.style.borderRadius = "999px";
|
|
mobileInfoSecondaryTabEl.style.padding = "10px 14px";
|
|
mobileInfoSecondaryTabEl.style.font = "600 13px/1.1 sans-serif";
|
|
mobileInfoSecondaryTabEl.style.cursor = "pointer";
|
|
mobileInfoSecondaryTabEl.style.backdropFilter = "blur(12px)";
|
|
mobileInfoSecondaryTabEl.style.alignItems = "center";
|
|
mobileInfoSecondaryTabEl.style.justifyContent = "center";
|
|
mobileInfoSecondaryTabEl.style.width = "100%";
|
|
|
|
settingsPanelEl.append(
|
|
settingsTitleEl,
|
|
compareButtonEl,
|
|
deckCompareButtonEl,
|
|
mobileInfoButtonEl,
|
|
mobileInfoPrimaryTabEl,
|
|
mobileInfoSecondaryTabEl,
|
|
exportButtonEl,
|
|
helpButtonEl,
|
|
zoomControlEl,
|
|
opacityControlEl
|
|
);
|
|
toolbarEl.append(settingsButtonEl);
|
|
|
|
stageEl = document.createElement("div");
|
|
stageEl.style.position = "fixed";
|
|
stageEl.style.top = "0";
|
|
stageEl.style.right = "0";
|
|
stageEl.style.bottom = "0";
|
|
stageEl.style.left = "0";
|
|
stageEl.style.pointerEvents = "auto";
|
|
stageEl.style.overflow = "visible";
|
|
stageEl.style.overscrollBehavior = "contain";
|
|
stageEl.style.transition = "top 220ms ease, right 220ms ease, bottom 220ms ease, left 220ms ease, width 220ms ease, height 220ms ease, transform 220ms ease";
|
|
stageEl.style.transform = "none";
|
|
stageEl.style.zIndex = "1";
|
|
|
|
frameEl = document.createElement("div");
|
|
frameEl.style.position = "relative";
|
|
frameEl.style.width = "100%";
|
|
frameEl.style.height = "100%";
|
|
frameEl.style.overflow = "hidden";
|
|
frameEl.style.touchAction = "none";
|
|
frameEl.style.overscrollBehavior = "contain";
|
|
frameEl.style.transition = "border-radius 220ms ease, background 220ms ease, box-shadow 220ms ease";
|
|
|
|
baseLayerEl = document.createElement("div");
|
|
baseLayerEl.style.position = "absolute";
|
|
baseLayerEl.style.inset = "0";
|
|
baseLayerEl.style.transform = "scale(1)";
|
|
baseLayerEl.style.transformOrigin = "50% 50%";
|
|
baseLayerEl.style.transition = "transform 120ms ease-out";
|
|
|
|
overlayLayerEl = document.createElement("div");
|
|
overlayLayerEl.style.position = "absolute";
|
|
overlayLayerEl.style.inset = "0";
|
|
overlayLayerEl.style.transform = "scale(1)";
|
|
overlayLayerEl.style.transformOrigin = "50% 50%";
|
|
overlayLayerEl.style.transition = "transform 120ms ease-out";
|
|
overlayLayerEl.style.pointerEvents = "none";
|
|
|
|
compareGridEl = document.createElement("div");
|
|
compareGridEl.style.position = "absolute";
|
|
compareGridEl.style.inset = "0";
|
|
compareGridEl.style.display = "none";
|
|
compareGridEl.style.gridTemplateColumns = "repeat(1, minmax(0, 1fr))";
|
|
compareGridEl.style.gap = "14px";
|
|
compareGridEl.style.alignItems = "stretch";
|
|
compareGridEl.style.padding = "76px 24px 24px";
|
|
compareGridEl.style.boxSizing = "border-box";
|
|
|
|
function createCompareGridSlot() {
|
|
const slotEl = document.createElement("div");
|
|
slotEl.style.display = "none";
|
|
slotEl.style.flexDirection = "column";
|
|
slotEl.style.minWidth = "0";
|
|
slotEl.style.minHeight = "0";
|
|
slotEl.style.borderRadius = "22px";
|
|
slotEl.style.background = "rgba(11, 15, 26, 0.76)";
|
|
slotEl.style.border = "1px solid rgba(148, 163, 184, 0.12)";
|
|
slotEl.style.boxShadow = "0 24px 64px rgba(0, 0, 0, 0.36)";
|
|
slotEl.style.overflow = "hidden";
|
|
|
|
const headerEl = document.createElement("div");
|
|
headerEl.style.display = "flex";
|
|
headerEl.style.alignItems = "center";
|
|
headerEl.style.justifyContent = "space-between";
|
|
headerEl.style.gap = "10px";
|
|
headerEl.style.padding = "10px 12px";
|
|
headerEl.style.background = "rgba(15, 23, 42, 0.72)";
|
|
headerEl.style.borderBottom = "1px solid rgba(148, 163, 184, 0.1)";
|
|
|
|
const badgeEl = document.createElement("span");
|
|
badgeEl.style.font = "700 11px/1.2 sans-serif";
|
|
badgeEl.style.letterSpacing = "0.08em";
|
|
badgeEl.style.textTransform = "uppercase";
|
|
badgeEl.style.color = "#f8fafc";
|
|
|
|
const cardLabelEl = document.createElement("span");
|
|
cardLabelEl.style.font = "500 11px/1.3 sans-serif";
|
|
cardLabelEl.style.color = "rgba(226, 232, 240, 0.84)";
|
|
cardLabelEl.style.textAlign = "right";
|
|
cardLabelEl.style.whiteSpace = "nowrap";
|
|
cardLabelEl.style.overflow = "hidden";
|
|
cardLabelEl.style.textOverflow = "ellipsis";
|
|
|
|
headerEl.append(badgeEl, cardLabelEl);
|
|
|
|
const mediaEl = document.createElement("div");
|
|
mediaEl.style.position = "relative";
|
|
mediaEl.style.flex = "1 1 auto";
|
|
mediaEl.style.minHeight = "0";
|
|
mediaEl.style.display = "flex";
|
|
mediaEl.style.alignItems = "center";
|
|
mediaEl.style.justifyContent = "center";
|
|
mediaEl.style.padding = "16px";
|
|
mediaEl.style.background = "rgba(2, 6, 23, 0.4)";
|
|
mediaEl.style.overflow = "hidden";
|
|
mediaEl.style.touchAction = "none";
|
|
mediaEl.style.overscrollBehavior = "contain";
|
|
|
|
const zoomLayerEl = document.createElement("div");
|
|
zoomLayerEl.style.position = "absolute";
|
|
zoomLayerEl.style.inset = "16px";
|
|
zoomLayerEl.style.display = "flex";
|
|
zoomLayerEl.style.alignItems = "center";
|
|
zoomLayerEl.style.justifyContent = "center";
|
|
zoomLayerEl.style.transform = "scale(1)";
|
|
zoomLayerEl.style.transformOrigin = "50% 50%";
|
|
zoomLayerEl.style.transition = "transform 120ms ease-out";
|
|
|
|
const compareImageEl = document.createElement("img");
|
|
compareImageEl.alt = "Tarot compare image";
|
|
compareImageEl.style.width = "100%";
|
|
compareImageEl.style.height = "100%";
|
|
compareImageEl.style.objectFit = "contain";
|
|
compareImageEl.style.cursor = "zoom-in";
|
|
compareImageEl.style.transform = "rotate(0deg)";
|
|
compareImageEl.style.transformOrigin = "center center";
|
|
compareImageEl.style.transition = "transform 120ms ease-out";
|
|
compareImageEl.style.touchAction = "none";
|
|
compareImageEl.style.userSelect = "none";
|
|
|
|
const fallbackEl = document.createElement("div");
|
|
fallbackEl.style.display = "none";
|
|
fallbackEl.style.maxWidth = "260px";
|
|
fallbackEl.style.padding = "16px";
|
|
fallbackEl.style.textAlign = "center";
|
|
fallbackEl.style.font = "600 13px/1.45 sans-serif";
|
|
fallbackEl.style.color = "rgba(226, 232, 240, 0.88)";
|
|
|
|
zoomLayerEl.appendChild(compareImageEl);
|
|
mediaEl.append(zoomLayerEl, fallbackEl);
|
|
slotEl.append(headerEl, mediaEl);
|
|
|
|
return {
|
|
slotEl,
|
|
headerEl,
|
|
badgeEl,
|
|
cardLabelEl,
|
|
mediaEl,
|
|
zoomLayerEl,
|
|
imageEl: compareImageEl,
|
|
fallbackEl
|
|
};
|
|
}
|
|
|
|
compareGridSlots = [createCompareGridSlot(), createCompareGridSlot(), createCompareGridSlot()];
|
|
compareGridSlots.forEach((slot) => {
|
|
compareGridEl.appendChild(slot.slotEl);
|
|
});
|
|
|
|
imageEl = document.createElement("img");
|
|
imageEl.alt = "Tarot card enlarged image";
|
|
imageEl.style.width = "100%";
|
|
imageEl.style.height = "100%";
|
|
imageEl.style.objectFit = "contain";
|
|
imageEl.style.cursor = "zoom-in";
|
|
imageEl.style.transform = "rotate(0deg)";
|
|
imageEl.style.transformOrigin = "center center";
|
|
imageEl.style.transition = "transform 120ms ease-out, opacity 180ms ease";
|
|
imageEl.style.touchAction = "none";
|
|
imageEl.style.userSelect = "none";
|
|
|
|
overlayImageEl = document.createElement("img");
|
|
overlayImageEl.alt = "Tarot card overlay image";
|
|
overlayImageEl.style.width = "100%";
|
|
overlayImageEl.style.height = "100%";
|
|
overlayImageEl.style.objectFit = "contain";
|
|
overlayImageEl.style.opacity = String(LIGHTBOX_COMPARE_DEFAULT_OVERLAY_OPACITY);
|
|
overlayImageEl.style.pointerEvents = "none";
|
|
overlayImageEl.style.display = "none";
|
|
overlayImageEl.style.transform = "rotate(0deg)";
|
|
overlayImageEl.style.transformOrigin = "center center";
|
|
overlayImageEl.style.transition = "opacity 180ms ease";
|
|
|
|
function createInfoPanel() {
|
|
const panelEl = document.createElement("div");
|
|
panelEl.style.position = "absolute";
|
|
panelEl.style.display = "none";
|
|
panelEl.style.flexDirection = "column";
|
|
panelEl.style.gap = "10px";
|
|
panelEl.style.padding = "14px 16px";
|
|
panelEl.style.borderRadius = "18px";
|
|
panelEl.style.background = "rgba(2, 6, 23, 0.8)";
|
|
panelEl.style.border = "1px solid rgba(148, 163, 184, 0.16)";
|
|
panelEl.style.color = "#f8fafc";
|
|
panelEl.style.backdropFilter = "blur(12px)";
|
|
panelEl.style.boxShadow = "0 16px 42px rgba(0, 0, 0, 0.34)";
|
|
panelEl.style.transition = "opacity 180ms ease, transform 180ms ease";
|
|
panelEl.style.transform = "translateY(-50%)";
|
|
panelEl.style.pointerEvents = "none";
|
|
panelEl.style.maxHeight = "min(78vh, 760px)";
|
|
panelEl.style.overflowY = "auto";
|
|
|
|
const titleEl = document.createElement("div");
|
|
titleEl.style.font = "700 13px/1.3 sans-serif";
|
|
titleEl.style.color = "#f8fafc";
|
|
|
|
const groupsEl = document.createElement("div");
|
|
groupsEl.style.display = "flex";
|
|
groupsEl.style.flexDirection = "column";
|
|
groupsEl.style.gap = "0";
|
|
|
|
const hintEl = document.createElement("div");
|
|
hintEl.style.font = "500 11px/1.35 sans-serif";
|
|
hintEl.style.color = "rgba(226, 232, 240, 0.82)";
|
|
|
|
panelEl.append(titleEl, groupsEl, hintEl);
|
|
return { panelEl, titleEl, groupsEl, hintEl };
|
|
}
|
|
|
|
const primaryPanel = createInfoPanel();
|
|
primaryInfoEl = primaryPanel.panelEl;
|
|
primaryTitleEl = primaryPanel.titleEl;
|
|
primaryGroupsEl = primaryPanel.groupsEl;
|
|
primaryHintEl = primaryPanel.hintEl;
|
|
|
|
const secondaryPanel = createInfoPanel();
|
|
secondaryInfoEl = secondaryPanel.panelEl;
|
|
secondaryTitleEl = secondaryPanel.titleEl;
|
|
secondaryGroupsEl = secondaryPanel.groupsEl;
|
|
secondaryHintEl = secondaryPanel.hintEl;
|
|
|
|
mobileInfoPanelEl = document.createElement("div");
|
|
mobileInfoPanelEl.style.position = "absolute";
|
|
mobileInfoPanelEl.style.left = "12px";
|
|
mobileInfoPanelEl.style.right = "12px";
|
|
mobileInfoPanelEl.style.bottom = "12px";
|
|
mobileInfoPanelEl.style.display = "none";
|
|
mobileInfoPanelEl.style.flexDirection = "column";
|
|
mobileInfoPanelEl.style.gap = "10px";
|
|
mobileInfoPanelEl.style.padding = "14px 16px";
|
|
mobileInfoPanelEl.style.borderRadius = "18px";
|
|
mobileInfoPanelEl.style.background = "rgba(2, 6, 23, 0.86)";
|
|
mobileInfoPanelEl.style.border = "1px solid rgba(148, 163, 184, 0.16)";
|
|
mobileInfoPanelEl.style.color = "#f8fafc";
|
|
mobileInfoPanelEl.style.backdropFilter = "blur(12px)";
|
|
mobileInfoPanelEl.style.boxShadow = "0 16px 42px rgba(0, 0, 0, 0.34)";
|
|
mobileInfoPanelEl.style.maxHeight = "min(46%, 320px)";
|
|
mobileInfoPanelEl.style.overflowY = "auto";
|
|
mobileInfoPanelEl.style.pointerEvents = "auto";
|
|
mobileInfoPanelEl.style.zIndex = "3";
|
|
|
|
mobileInfoTitleEl = document.createElement("div");
|
|
mobileInfoTitleEl.style.font = "700 13px/1.3 sans-serif";
|
|
mobileInfoTitleEl.style.color = "#f8fafc";
|
|
|
|
mobileInfoGroupsEl = document.createElement("div");
|
|
mobileInfoGroupsEl.style.display = "flex";
|
|
mobileInfoGroupsEl.style.flexDirection = "column";
|
|
mobileInfoGroupsEl.style.gap = "0";
|
|
|
|
mobileInfoHintEl = document.createElement("div");
|
|
mobileInfoHintEl.style.font = "500 11px/1.35 sans-serif";
|
|
mobileInfoHintEl.style.color = "rgba(226, 232, 240, 0.82)";
|
|
|
|
mobileInfoPanelEl.append(mobileInfoTitleEl, mobileInfoGroupsEl, mobileInfoHintEl);
|
|
|
|
function createMobileNavButton(label, ariaLabel) {
|
|
const buttonEl = document.createElement("button");
|
|
buttonEl.type = "button";
|
|
buttonEl.textContent = label;
|
|
buttonEl.setAttribute("aria-label", ariaLabel);
|
|
buttonEl.style.position = "fixed";
|
|
buttonEl.style.top = "auto";
|
|
buttonEl.style.display = "none";
|
|
buttonEl.style.alignItems = "center";
|
|
buttonEl.style.justifyContent = "center";
|
|
buttonEl.style.width = "44px";
|
|
buttonEl.style.height = "44px";
|
|
buttonEl.style.border = "1px solid rgba(255, 255, 255, 0.2)";
|
|
buttonEl.style.borderRadius = "999px";
|
|
buttonEl.style.background = "rgba(15, 23, 42, 0.84)";
|
|
buttonEl.style.color = "#f8fafc";
|
|
buttonEl.style.font = "700 20px/1 sans-serif";
|
|
buttonEl.style.cursor = "pointer";
|
|
buttonEl.style.backdropFilter = "blur(12px)";
|
|
buttonEl.style.transform = "none";
|
|
buttonEl.style.pointerEvents = "auto";
|
|
buttonEl.style.zIndex = "6";
|
|
return buttonEl;
|
|
}
|
|
|
|
mobilePrevButtonEl = createMobileNavButton("<", "Previous card");
|
|
mobilePrevButtonEl.style.left = "12px";
|
|
mobileNextButtonEl = createMobileNavButton(">", "Next card");
|
|
mobileNextButtonEl.style.right = "12px";
|
|
|
|
baseLayerEl.appendChild(imageEl);
|
|
overlayLayerEl.appendChild(overlayImageEl);
|
|
frameEl.append(baseLayerEl, overlayLayerEl, mobileInfoPanelEl);
|
|
stageEl.append(frameEl, compareGridEl, primaryInfoEl, secondaryInfoEl);
|
|
overlayEl.append(backdropEl, stageEl, toolbarEl, settingsPanelEl, deckComparePanelEl, helpPanelEl, mobilePrevButtonEl, mobileNextButtonEl);
|
|
|
|
const close = () => {
|
|
if (!overlayEl || !imageEl || !overlayImageEl) {
|
|
return;
|
|
}
|
|
|
|
lightboxState.isOpen = false;
|
|
lightboxState.compareMode = false;
|
|
lightboxState.deckCompareMode = false;
|
|
lightboxState.allowOverlayCompare = false;
|
|
lightboxState.allowDeckCompare = false;
|
|
lightboxState.primaryCard = null;
|
|
lightboxState.secondaryCard = null;
|
|
lightboxState.activeDeckId = "";
|
|
lightboxState.activeDeckLabel = "";
|
|
lightboxState.availableCompareDecks = [];
|
|
lightboxState.selectedCompareDeckIds = [];
|
|
lightboxState.deckCompareCards = [];
|
|
lightboxState.maxCompareDecks = 2;
|
|
lightboxState.deckComparePickerOpen = false;
|
|
lightboxState.deckCompareMessage = "";
|
|
lightboxState.sequenceIds = [];
|
|
lightboxState.resolveCardById = null;
|
|
lightboxState.resolveDeckCardById = null;
|
|
lightboxState.onSelectCardId = null;
|
|
lightboxState.overlayOpacity = LIGHTBOX_COMPARE_DEFAULT_OVERLAY_OPACITY;
|
|
lightboxState.zoomScale = LIGHTBOX_ZOOM_SCALE;
|
|
lightboxState.settingsMenuOpen = false;
|
|
lightboxState.helpOpen = false;
|
|
lightboxState.primaryRotated = false;
|
|
lightboxState.overlayRotated = false;
|
|
setInfoPanelOpen(false, { persist: false });
|
|
lightboxState.mobileInfoView = "primary";
|
|
lightboxState.exportInProgress = false;
|
|
clearActivePointerGesture();
|
|
suppressNextCardClick = false;
|
|
overlayEl.style.display = "none";
|
|
overlayEl.setAttribute("aria-hidden", "true");
|
|
imageEl.removeAttribute("src");
|
|
imageEl.alt = "Tarot card enlarged image";
|
|
overlayImageEl.removeAttribute("src");
|
|
overlayImageEl.alt = "";
|
|
overlayImageEl.style.display = "none";
|
|
resetZoom();
|
|
syncHelpUi();
|
|
syncComparePanels();
|
|
syncOpacityControl();
|
|
syncDeckComparePicker();
|
|
renderDeckCompareGrid();
|
|
|
|
if (previousFocusedEl instanceof HTMLElement) {
|
|
previousFocusedEl.focus({ preventScroll: true });
|
|
}
|
|
previousFocusedEl = null;
|
|
};
|
|
|
|
function toggleCompareMode() {
|
|
if (!lightboxState.allowOverlayCompare || !lightboxState.primaryCard) {
|
|
return;
|
|
}
|
|
|
|
lightboxState.compareMode = !lightboxState.compareMode;
|
|
if (!lightboxState.compareMode) {
|
|
clearSecondaryCard();
|
|
}
|
|
applyComparePresentation();
|
|
}
|
|
|
|
function setSecondaryCard(cardRequest, syncSelection = false) {
|
|
const normalizedCard = normalizeCardRequest(cardRequest);
|
|
if (!normalizedCard.src || !normalizedCard.cardId || normalizedCard.cardId === lightboxState.primaryCard?.cardId) {
|
|
return false;
|
|
}
|
|
|
|
lightboxState.secondaryCard = normalizedCard;
|
|
if (isCompactLightboxLayout()) {
|
|
lightboxState.mobileInfoView = "overlay";
|
|
}
|
|
overlayImageEl.src = normalizedCard.src;
|
|
overlayImageEl.alt = normalizedCard.altText;
|
|
overlayImageEl.style.display = "block";
|
|
overlayImageEl.style.opacity = String(lightboxState.overlayOpacity);
|
|
if (syncSelection && typeof lightboxState.onSelectCardId === "function") {
|
|
lightboxState.onSelectCardId(normalizedCard.cardId);
|
|
}
|
|
applyComparePresentation();
|
|
return true;
|
|
}
|
|
|
|
function stepSecondaryCard(direction) {
|
|
const sequence = Array.isArray(lightboxState.sequenceIds) ? lightboxState.sequenceIds : [];
|
|
if (!lightboxState.compareMode || sequence.length < 2 || typeof lightboxState.resolveCardById !== "function") {
|
|
return;
|
|
}
|
|
|
|
const anchorId = lightboxState.secondaryCard?.cardId || lightboxState.primaryCard?.cardId;
|
|
const startIndex = sequence.indexOf(anchorId);
|
|
if (startIndex < 0) {
|
|
return;
|
|
}
|
|
|
|
for (let offset = 1; offset <= sequence.length; offset += 1) {
|
|
const nextIndex = (startIndex + direction * offset + sequence.length) % sequence.length;
|
|
const nextCardId = sequence[nextIndex];
|
|
if (!nextCardId || nextCardId === lightboxState.primaryCard?.cardId) {
|
|
continue;
|
|
}
|
|
|
|
const nextCard = resolveCardRequestById(nextCardId);
|
|
if (nextCard && setSecondaryCard(nextCard, true)) {
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
function stepPrimaryCard(direction) {
|
|
const sequence = Array.isArray(lightboxState.sequenceIds) ? lightboxState.sequenceIds : [];
|
|
if (lightboxState.compareMode || sequence.length < 2 || typeof lightboxState.resolveCardById !== "function") {
|
|
return;
|
|
}
|
|
|
|
const startIndex = sequence.indexOf(lightboxState.primaryCard?.cardId);
|
|
if (startIndex < 0) {
|
|
return;
|
|
}
|
|
|
|
const nextIndex = (startIndex + direction + sequence.length) % sequence.length;
|
|
const nextCardId = sequence[nextIndex];
|
|
const nextCard = resolveCardRequestById(nextCardId);
|
|
if (!nextCard?.src) {
|
|
return;
|
|
}
|
|
|
|
lightboxState.primaryCard = nextCard;
|
|
imageEl.src = nextCard.src;
|
|
imageEl.alt = nextCard.altText;
|
|
resetZoom();
|
|
if (lightboxState.deckCompareMode) {
|
|
syncDeckCompareCards();
|
|
} else {
|
|
clearSecondaryCard();
|
|
}
|
|
if (typeof lightboxState.onSelectCardId === "function") {
|
|
lightboxState.onSelectCardId(nextCard.cardId);
|
|
}
|
|
applyComparePresentation();
|
|
}
|
|
|
|
function swapCompareCards() {
|
|
if (!lightboxState.compareMode || !lightboxState.primaryCard?.src || !lightboxState.secondaryCard?.src) {
|
|
return;
|
|
}
|
|
|
|
const nextPrimaryCard = lightboxState.secondaryCard;
|
|
const nextSecondaryCard = lightboxState.primaryCard;
|
|
|
|
lightboxState.primaryCard = nextPrimaryCard;
|
|
lightboxState.secondaryCard = nextSecondaryCard;
|
|
|
|
imageEl.src = nextPrimaryCard.src;
|
|
imageEl.alt = nextPrimaryCard.altText;
|
|
overlayImageEl.src = nextSecondaryCard.src;
|
|
overlayImageEl.alt = nextSecondaryCard.altText;
|
|
overlayImageEl.style.display = "block";
|
|
overlayImageEl.style.opacity = String(lightboxState.overlayOpacity);
|
|
|
|
if (typeof lightboxState.onSelectCardId === "function") {
|
|
lightboxState.onSelectCardId(nextPrimaryCard.cardId);
|
|
}
|
|
|
|
applyComparePresentation();
|
|
}
|
|
|
|
function shouldIgnoreGlobalKeydown(event) {
|
|
const target = event.target;
|
|
if (!(target instanceof HTMLElement)) {
|
|
return false;
|
|
}
|
|
|
|
if (!overlayEl?.contains(target)) {
|
|
return false;
|
|
}
|
|
|
|
return target instanceof HTMLInputElement
|
|
|| target instanceof HTMLTextAreaElement
|
|
|| target instanceof HTMLSelectElement
|
|
|| target instanceof HTMLButtonElement;
|
|
}
|
|
|
|
function restoreLightboxFocus() {
|
|
if (!overlayEl || !lightboxState.isOpen) {
|
|
return;
|
|
}
|
|
|
|
requestAnimationFrame(() => {
|
|
if (overlayEl && lightboxState.isOpen) {
|
|
overlayEl.focus({ preventScroll: true });
|
|
}
|
|
});
|
|
}
|
|
|
|
backdropEl.addEventListener("click", close);
|
|
helpButtonEl.addEventListener("click", () => {
|
|
lightboxState.helpOpen = !lightboxState.helpOpen;
|
|
if (lightboxState.helpOpen) {
|
|
closeSettingsMenu();
|
|
}
|
|
syncHelpUi();
|
|
restoreLightboxFocus();
|
|
});
|
|
settingsButtonEl.addEventListener("click", (event) => {
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
toggleSettingsMenu();
|
|
restoreLightboxFocus();
|
|
});
|
|
settingsPanelEl.addEventListener("pointerdown", (event) => {
|
|
event.stopPropagation();
|
|
});
|
|
settingsPanelEl.addEventListener("click", (event) => {
|
|
event.stopPropagation();
|
|
});
|
|
compareButtonEl.addEventListener("click", () => {
|
|
toggleCompareMode();
|
|
restoreLightboxFocus();
|
|
});
|
|
deckCompareButtonEl.addEventListener("click", () => {
|
|
if (shouldSuppressDeckCompareToggle()) {
|
|
restoreLightboxFocus();
|
|
return;
|
|
}
|
|
toggleDeckComparePanel();
|
|
restoreLightboxFocus();
|
|
});
|
|
deckComparePanelEl.addEventListener("pointerdown", (event) => {
|
|
event.stopPropagation();
|
|
});
|
|
deckComparePanelEl.addEventListener("click", (event) => {
|
|
event.stopPropagation();
|
|
});
|
|
deckCompareCloseButtonEl.addEventListener("click", (event) => {
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
suppressDeckCompareToggle();
|
|
closeDeckComparePanel();
|
|
applyComparePresentation();
|
|
restoreLightboxFocus();
|
|
});
|
|
mobileInfoButtonEl.addEventListener("click", () => {
|
|
setInfoPanelOpen(!lightboxState.mobileInfoOpen);
|
|
applyComparePresentation();
|
|
restoreLightboxFocus();
|
|
});
|
|
mobileInfoPrimaryTabEl.addEventListener("click", () => {
|
|
lightboxState.mobileInfoView = "primary";
|
|
applyComparePresentation();
|
|
restoreLightboxFocus();
|
|
});
|
|
mobileInfoSecondaryTabEl.addEventListener("click", () => {
|
|
lightboxState.mobileInfoView = "overlay";
|
|
applyComparePresentation();
|
|
restoreLightboxFocus();
|
|
});
|
|
zoomSliderEl.addEventListener("input", () => {
|
|
setZoomScale(Number(zoomSliderEl.value) / 100);
|
|
});
|
|
zoomSliderEl.addEventListener("change", restoreLightboxFocus);
|
|
zoomSliderEl.addEventListener("pointerup", restoreLightboxFocus);
|
|
opacitySliderEl.addEventListener("input", () => {
|
|
setOverlayOpacity(Number(opacitySliderEl.value) / 100);
|
|
});
|
|
exportButtonEl.addEventListener("click", async (event) => {
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
await exportCurrentLightboxView();
|
|
});
|
|
opacitySliderEl.addEventListener("change", restoreLightboxFocus);
|
|
opacitySliderEl.addEventListener("pointerup", restoreLightboxFocus);
|
|
mobilePrevButtonEl.addEventListener("click", (event) => {
|
|
event.stopPropagation();
|
|
if (lightboxState.compareMode) {
|
|
stepSecondaryCard(-1);
|
|
} else {
|
|
stepPrimaryCard(-1);
|
|
}
|
|
restoreLightboxFocus();
|
|
});
|
|
mobileNextButtonEl.addEventListener("click", (event) => {
|
|
event.stopPropagation();
|
|
if (lightboxState.compareMode) {
|
|
stepSecondaryCard(1);
|
|
} else {
|
|
stepPrimaryCard(1);
|
|
}
|
|
restoreLightboxFocus();
|
|
});
|
|
|
|
imageEl.addEventListener("click", (event) => {
|
|
event.stopPropagation();
|
|
if (consumeSuppressedCardClick()) {
|
|
return;
|
|
}
|
|
|
|
if (!isPointOnCard(event.clientX, event.clientY)) {
|
|
if (lightboxState.compareMode) {
|
|
return;
|
|
}
|
|
|
|
close();
|
|
return;
|
|
}
|
|
|
|
if (!zoomed) {
|
|
zoomed = true;
|
|
applyZoomTransform();
|
|
updateZoomOrigin(event.clientX, event.clientY);
|
|
applyComparePresentation();
|
|
return;
|
|
}
|
|
|
|
resetZoom();
|
|
applyComparePresentation();
|
|
});
|
|
|
|
imageEl.addEventListener("mousemove", (event) => {
|
|
updateZoomOrigin(event.clientX, event.clientY);
|
|
});
|
|
|
|
imageEl.addEventListener("pointerdown", (event) => {
|
|
if (handleCompactPointerDown(event)) {
|
|
imageEl.setPointerCapture?.(event.pointerId);
|
|
}
|
|
});
|
|
|
|
imageEl.addEventListener("pointermove", (event) => {
|
|
handleCompactPointerMove(event, imageEl, null);
|
|
});
|
|
|
|
imageEl.addEventListener("pointerup", (event) => {
|
|
handleCompactPointerEnd(event, imageEl);
|
|
});
|
|
|
|
imageEl.addEventListener("pointercancel", (event) => {
|
|
handleCompactPointerEnd(event, imageEl);
|
|
});
|
|
|
|
imageEl.addEventListener("touchstart", (event) => {
|
|
handleCompactPinchStart(event, imageEl, null);
|
|
}, { passive: false });
|
|
|
|
imageEl.addEventListener("touchmove", preventCompactTouchScroll, { passive: false });
|
|
imageEl.addEventListener("touchmove", (event) => {
|
|
handleCompactPinchMove(event);
|
|
}, { passive: false });
|
|
imageEl.addEventListener("touchend", (event) => {
|
|
handleCompactPinchEnd(event);
|
|
}, { passive: false });
|
|
imageEl.addEventListener("touchcancel", (event) => {
|
|
handleCompactPinchEnd(event);
|
|
}, { passive: false });
|
|
|
|
imageEl.addEventListener("mouseleave", () => {
|
|
if (zoomed) {
|
|
lightboxState.zoomOriginX = 50;
|
|
lightboxState.zoomOriginY = 50;
|
|
applyTransformOrigins();
|
|
}
|
|
});
|
|
|
|
compareGridSlots.forEach((slot) => {
|
|
slot.imageEl.addEventListener("click", (event) => {
|
|
event.stopPropagation();
|
|
if (consumeSuppressedCardClick()) {
|
|
return;
|
|
}
|
|
|
|
if (!isPointOnCard(event.clientX, event.clientY, slot.imageEl, slot.mediaEl)) {
|
|
close();
|
|
return;
|
|
}
|
|
|
|
if (!zoomed) {
|
|
zoomed = true;
|
|
applyZoomTransform();
|
|
updateZoomOrigin(event.clientX, event.clientY, slot.imageEl, slot.mediaEl);
|
|
applyComparePresentation();
|
|
return;
|
|
}
|
|
|
|
resetZoom();
|
|
applyComparePresentation();
|
|
});
|
|
|
|
slot.imageEl.addEventListener("mousemove", (event) => {
|
|
updateZoomOrigin(event.clientX, event.clientY, slot.imageEl, slot.mediaEl);
|
|
});
|
|
|
|
slot.imageEl.addEventListener("pointerdown", (event) => {
|
|
if (handleCompactPointerDown(event)) {
|
|
slot.imageEl.setPointerCapture?.(event.pointerId);
|
|
}
|
|
});
|
|
|
|
slot.imageEl.addEventListener("pointermove", (event) => {
|
|
handleCompactPointerMove(event, slot.imageEl, slot.mediaEl);
|
|
});
|
|
|
|
slot.imageEl.addEventListener("pointerup", (event) => {
|
|
handleCompactPointerEnd(event, slot.imageEl);
|
|
});
|
|
|
|
slot.imageEl.addEventListener("pointercancel", (event) => {
|
|
handleCompactPointerEnd(event, slot.imageEl);
|
|
});
|
|
|
|
slot.imageEl.addEventListener("touchstart", (event) => {
|
|
handleCompactPinchStart(event, slot.imageEl, slot.mediaEl);
|
|
}, { passive: false });
|
|
|
|
slot.imageEl.addEventListener("touchmove", preventCompactTouchScroll, { passive: false });
|
|
slot.imageEl.addEventListener("touchmove", (event) => {
|
|
handleCompactPinchMove(event);
|
|
}, { passive: false });
|
|
slot.imageEl.addEventListener("touchend", (event) => {
|
|
handleCompactPinchEnd(event);
|
|
}, { passive: false });
|
|
slot.imageEl.addEventListener("touchcancel", (event) => {
|
|
handleCompactPinchEnd(event);
|
|
}, { passive: false });
|
|
|
|
slot.imageEl.addEventListener("mouseleave", () => {
|
|
if (zoomed) {
|
|
lightboxState.zoomOriginX = 50;
|
|
lightboxState.zoomOriginY = 50;
|
|
applyTransformOrigins();
|
|
}
|
|
});
|
|
});
|
|
|
|
document.addEventListener("keydown", (event) => {
|
|
if (event.key === "Escape") {
|
|
close();
|
|
return;
|
|
}
|
|
|
|
if (shouldIgnoreGlobalKeydown(event)) {
|
|
return;
|
|
}
|
|
|
|
if (lightboxState.isOpen && event.code === "Space" && lightboxState.compareMode && hasSecondaryCard()) {
|
|
event.preventDefault();
|
|
swapCompareCards();
|
|
return;
|
|
}
|
|
|
|
if (lightboxState.isOpen && isRotateKey(event)) {
|
|
event.preventDefault();
|
|
toggleRotation();
|
|
return;
|
|
}
|
|
|
|
if (lightboxState.isOpen && isZoomInKey(event)) {
|
|
event.preventDefault();
|
|
stepZoom(1);
|
|
return;
|
|
}
|
|
|
|
if (lightboxState.isOpen && isZoomOutKey(event)) {
|
|
event.preventDefault();
|
|
stepZoom(-1);
|
|
return;
|
|
}
|
|
|
|
if (lightboxState.isOpen && zoomed && isPanUpKey(event)) {
|
|
event.preventDefault();
|
|
stepPan(0, -LIGHTBOX_PAN_STEP);
|
|
return;
|
|
}
|
|
|
|
if (lightboxState.isOpen && zoomed && isPanLeftKey(event)) {
|
|
event.preventDefault();
|
|
stepPan(-LIGHTBOX_PAN_STEP, 0);
|
|
return;
|
|
}
|
|
|
|
if (lightboxState.isOpen && zoomed && isPanDownKey(event)) {
|
|
event.preventDefault();
|
|
stepPan(0, LIGHTBOX_PAN_STEP);
|
|
return;
|
|
}
|
|
|
|
if (lightboxState.isOpen && zoomed && isPanRightKey(event)) {
|
|
event.preventDefault();
|
|
stepPan(LIGHTBOX_PAN_STEP, 0);
|
|
return;
|
|
}
|
|
|
|
if (!lightboxState.isOpen || !LIGHTBOX_COMPARE_SEQUENCE_STEP_KEYS.has(event.key)) {
|
|
return;
|
|
}
|
|
|
|
event.preventDefault();
|
|
if (lightboxState.compareMode) {
|
|
stepSecondaryCard(event.key === "ArrowRight" ? 1 : -1);
|
|
return;
|
|
}
|
|
|
|
stepPrimaryCard(event.key === "ArrowRight" ? 1 : -1);
|
|
});
|
|
|
|
document.addEventListener("wheel", preventBrowserZoomGesture, {
|
|
capture: true,
|
|
passive: false
|
|
});
|
|
["gesturestart", "gesturechange", "gestureend"].forEach((eventName) => {
|
|
document.addEventListener(eventName, preventBrowserZoomGesture, {
|
|
capture: true,
|
|
passive: false
|
|
});
|
|
});
|
|
|
|
window.addEventListener("resize", () => {
|
|
if (!lightboxState.isOpen) {
|
|
return;
|
|
}
|
|
|
|
applyComparePresentation();
|
|
});
|
|
|
|
document.body.appendChild(overlayEl);
|
|
|
|
overlayEl.closeLightbox = close;
|
|
overlayEl.setSecondaryCard = setSecondaryCard;
|
|
overlayEl.applyComparePresentation = applyComparePresentation;
|
|
}
|
|
|
|
function open(srcOrOptions, altText, extraOptions) {
|
|
const request = normalizeOpenRequest(srcOrOptions, altText, extraOptions);
|
|
const normalizedPrimary = normalizeCardRequest(request);
|
|
|
|
if (!normalizedPrimary.src) {
|
|
return;
|
|
}
|
|
|
|
ensure();
|
|
if (!overlayEl || !imageEl || !overlayImageEl) {
|
|
return;
|
|
}
|
|
|
|
const canCompare = Boolean(
|
|
request.allowOverlayCompare
|
|
&& normalizedPrimary.cardId
|
|
&& Array.isArray(request.sequenceIds)
|
|
&& request.sequenceIds.length > 1
|
|
&& typeof request.resolveCardById === "function"
|
|
);
|
|
const canDeckCompare = Boolean(
|
|
request.allowDeckCompare
|
|
&& normalizedPrimary.cardId
|
|
&& typeof request.resolveDeckCardById === "function"
|
|
);
|
|
|
|
if (lightboxState.isOpen && lightboxState.compareMode && lightboxState.allowOverlayCompare && canCompare && normalizedPrimary.cardId) {
|
|
if (normalizedPrimary.cardId === lightboxState.primaryCard?.cardId) {
|
|
return;
|
|
}
|
|
overlayEl.setSecondaryCard?.(normalizedPrimary, false);
|
|
return;
|
|
}
|
|
|
|
lightboxState.isOpen = true;
|
|
lightboxState.compareMode = false;
|
|
lightboxState.deckCompareMode = false;
|
|
lightboxState.allowOverlayCompare = canCompare;
|
|
lightboxState.allowDeckCompare = canDeckCompare;
|
|
lightboxState.primaryCard = normalizedPrimary;
|
|
lightboxState.activeDeckId = String(request.activeDeckId || normalizedPrimary.deckId || "").trim();
|
|
lightboxState.activeDeckLabel = String(request.activeDeckLabel || normalizedPrimary.deckLabel || lightboxState.activeDeckId).trim();
|
|
lightboxState.availableCompareDecks = canDeckCompare
|
|
? normalizeDeckOptions(request.availableCompareDecks).filter((deck) => deck.id !== lightboxState.activeDeckId)
|
|
: [];
|
|
lightboxState.selectedCompareDeckIds = [];
|
|
lightboxState.deckCompareCards = [];
|
|
lightboxState.maxCompareDecks = Number.isInteger(Number(request.maxCompareDecks)) && Number(request.maxCompareDecks) > 0
|
|
? Number(request.maxCompareDecks)
|
|
: 2;
|
|
lightboxState.deckComparePickerOpen = false;
|
|
lightboxState.deckCompareMessage = "";
|
|
lightboxState.sequenceIds = canCompare ? [...request.sequenceIds] : [];
|
|
lightboxState.resolveCardById = canCompare ? request.resolveCardById : null;
|
|
lightboxState.resolveDeckCardById = canDeckCompare ? request.resolveDeckCardById : null;
|
|
lightboxState.onSelectCardId = canCompare && typeof request.onSelectCardId === "function"
|
|
? request.onSelectCardId
|
|
: null;
|
|
lightboxState.overlayOpacity = LIGHTBOX_COMPARE_DEFAULT_OVERLAY_OPACITY;
|
|
lightboxState.zoomScale = LIGHTBOX_ZOOM_SCALE;
|
|
lightboxState.settingsMenuOpen = false;
|
|
lightboxState.helpOpen = false;
|
|
lightboxState.primaryRotated = false;
|
|
lightboxState.overlayRotated = false;
|
|
setInfoPanelOpen(getPersistedInfoPanelVisibility(), { persist: false });
|
|
lightboxState.mobileInfoView = "primary";
|
|
|
|
imageEl.src = normalizedPrimary.src;
|
|
imageEl.alt = normalizedPrimary.altText;
|
|
clearSecondaryCard();
|
|
resetZoom();
|
|
previousFocusedEl = document.activeElement instanceof HTMLElement ? document.activeElement : null;
|
|
overlayEl.style.display = "block";
|
|
overlayEl.setAttribute("aria-hidden", "false");
|
|
overlayEl.applyComparePresentation?.();
|
|
overlayEl.focus({ preventScroll: true });
|
|
}
|
|
|
|
window.TarotUiLightbox = {
|
|
...(window.TarotUiLightbox || {}),
|
|
open
|
|
};
|
|
})(); |