From cf6b2611aac9956611b2a73a4944e8bc92b4641a Mon Sep 17 00:00:00 2001 From: Nose Date: Sun, 8 Mar 2026 06:22:27 -0700 Subject: [PATCH] added compare deck to house of card focus --- app/card-images.js | 5 +- app/ui-tarot-lightbox.js | 628 ++++++++++++++++++++++++++++++++++++++- app/ui-tarot.js | 62 +++- 3 files changed, 671 insertions(+), 24 deletions(-) diff --git a/app/card-images.js b/app/card-images.js index 3247add..a68b659 100644 --- a/app/card-images.js +++ b/app/card-images.js @@ -695,8 +695,9 @@ return `${manifest.basePath}/${relativePath}`; } - function resolveTarotCardImage(cardName) { - const activePath = resolveWithDeck(activeDeckId, cardName); + function resolveTarotCardImage(cardName, optionsOrDeckId) { + const { resolvedDeckId } = resolveDeckOptions(optionsOrDeckId); + const activePath = resolveWithDeck(resolvedDeckId, cardName); if (activePath) { return encodeURI(activePath); } diff --git a/app/ui-tarot-lightbox.js b/app/ui-tarot-lightbox.js index 98a19c2..a5bed48 100644 --- a/app/ui-tarot-lightbox.js +++ b/app/ui-tarot-lightbox.js @@ -7,6 +7,10 @@ let helpButtonEl = null; let helpPanelEl = null; let compareButtonEl = null; + let deckCompareButtonEl = null; + let deckComparePanelEl = null; + let deckCompareMessageEl = null; + let deckCompareDeckListEl = null; let zoomControlEl = null; let zoomSliderEl = null; let zoomValueEl = null; @@ -17,6 +21,7 @@ let frameEl = null; let baseLayerEl = null; let overlayLayerEl = null; + let compareGridEl = null; let imageEl = null; let overlayImageEl = null; let primaryInfoEl = null; @@ -27,6 +32,7 @@ let secondaryTitleEl = null; let secondaryGroupsEl = null; let secondaryHintEl = null; + let compareGridSlots = []; let zoomed = false; let previousFocusedEl = null; @@ -39,11 +45,22 @@ 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, @@ -113,10 +130,93 @@ 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; + } + + lightboxState.deckCompareCards = lightboxState.selectedCompareDeckIds + .slice(0, lightboxState.maxCompareDecks) + .map((deckId) => resolveDeckCardRequest(lightboxState.primaryCard.cardId, deckId)) + .filter(Boolean); + } + + 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; @@ -145,6 +245,77 @@ syncOpacityControl(); } + function clearDeckCompareState() { + lightboxState.deckCompareMode = false; + lightboxState.selectedCompareDeckIds = []; + lightboxState.deckCompareCards = []; + lightboxState.deckComparePickerOpen = false; + lightboxState.deckCompareMessage = ""; + } + + function updateDeckCompareMode(deckIds, preservePanel = true) { + const uniqueDeckIds = [...new Set((Array.isArray(deckIds) ? deckIds : []).map((deckId) => String(deckId || "").trim()).filter(Boolean))] + .filter((deckId) => deckId !== lightboxState.activeDeckId) + .slice(0, lightboxState.maxCompareDecks); + + lightboxState.selectedCompareDeckIds = uniqueDeckIds; + lightboxState.deckCompareMode = uniqueDeckIds.length > 0; + lightboxState.deckCompareMessage = ""; + + if (!lightboxState.deckCompareMode) { + lightboxState.deckCompareCards = []; + if (!preservePanel) { + lightboxState.deckComparePickerOpen = false; + } + return; + } + + lightboxState.compareMode = false; + clearSecondaryCard(); + syncDeckCompareCards(); + } + + function toggleDeckCompareSelection(deckId) { + const normalizedDeckId = String(deckId || "").trim(); + if (!normalizedDeckId) { + return; + } + + const nextSelection = [...lightboxState.selectedCompareDeckIds]; + const existingIndex = nextSelection.indexOf(normalizedDeckId); + if (existingIndex >= 0) { + nextSelection.splice(existingIndex, 1); + updateDeckCompareMode(nextSelection); + applyComparePresentation(); + return; + } + + if (nextSelection.length >= lightboxState.maxCompareDecks) { + lightboxState.deckCompareMessage = `You can compare up to ${lightboxState.maxCompareDecks} decks at once.`; + applyComparePresentation(); + return; + } + + nextSelection.push(normalizedDeckId); + updateDeckCompareMode(nextSelection); + applyComparePresentation(); + } + + function toggleDeckComparePanel() { + if (!lightboxState.allowDeckCompare) { + lightboxState.deckComparePickerOpen = true; + lightboxState.deckCompareMessage = "Add another registered deck to use deck compare."; + applyComparePresentation(); + return; + } + + lightboxState.deckComparePickerOpen = !lightboxState.deckComparePickerOpen; + lightboxState.deckCompareMessage = lightboxState.availableCompareDecks.length + ? "" + : "Add another registered deck to use deck compare."; + applyComparePresentation(); + } + function setOverlayOpacity(value) { const opacity = clampOverlayOpacity(value); lightboxState.overlayOpacity = opacity; @@ -164,11 +335,21 @@ } 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 = zoomed ? "zoom-out" : "zoom-in"; + imageEl.style.cursor = nextCursor; } function buildRotationTransform(rotated) { @@ -186,6 +367,15 @@ function applyTransformOrigins(originX = lightboxState.zoomOriginX, originY = lightboxState.zoomOriginY) { const nextOrigin = `${originX}% ${originY}%`; + if (lightboxState.deckCompareMode) { + compareGridSlots.forEach((slot) => { + if (slot?.imageEl) { + slot.imageEl.style.transformOrigin = nextOrigin; + } + }); + return; + } + if (baseLayerEl) { baseLayerEl.style.transformOrigin = nextOrigin; } @@ -200,6 +390,20 @@ const showPrimaryRotation = isPrimaryRotationActive(); const showOverlayRotation = isOverlayRotationActive(); + if (lightboxState.deckCompareMode) { + compareGridSlots.forEach((slot) => { + if (!slot?.imageEl) { + return; + } + + slot.imageEl.style.transform = `scale(${activeZoomScale}) ${buildRotationTransform(lightboxState.primaryRotated)}`; + }); + + applyTransformOrigins(); + updateImageCursor(); + return; + } + if (baseLayerEl) { baseLayerEl.style.transform = `scale(${activeZoomScale})`; } @@ -309,6 +513,12 @@ return; } + if (lightboxState.deckCompareMode) { + lightboxState.primaryRotated = !lightboxState.primaryRotated; + applyZoomTransform(); + return; + } + if (lightboxState.compareMode && hasSecondaryCard()) { lightboxState.overlayRotated = !lightboxState.overlayRotated; } else { @@ -384,6 +594,12 @@ } function syncComparePanels() { + if (lightboxState.deckCompareMode) { + renderComparePanel(primaryInfoEl, primaryTitleEl, primaryGroupsEl, primaryHintEl, null, "", "", false); + renderComparePanel(secondaryInfoEl, secondaryTitleEl, secondaryGroupsEl, secondaryHintEl, null, "", "", false); + return; + } + const isComparing = lightboxState.compareMode; const overlaySelected = hasSecondaryCard(); const showPrimaryPanel = Boolean(lightboxState.isOpen && lightboxState.allowOverlayCompare && lightboxState.primaryCard?.label && !zoomed); @@ -421,10 +637,144 @@ return; } + if (lightboxState.deckCompareMode) { + opacityControlEl.style.display = "none"; + return; + } + opacityControlEl.style.display = lightboxState.compareMode && hasSecondaryCard() && !zoomed ? "flex" : "none"; setOverlayOpacity(lightboxState.overlayOpacity); } + 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 + ? `Choose up to ${lightboxState.maxCompareDecks} extra decks.` + : "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 >= lightboxState.maxCompareDecks; + 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", () => { + 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", () => { + updateDeckCompareMode([]); + applyComparePresentation(); + restoreLightboxFocus(); + }); + deckCompareDeckListEl.appendChild(clearButtonEl); + } + } + + function renderDeckCompareGrid() { + if (!compareGridEl || !compareGridSlots.length) { + return; + } + + 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"; + 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.gridTemplateColumns = `repeat(${Math.max(1, visibleCards.length)}, minmax(0, 1fr))`; + + compareGridSlots.forEach((slot, index) => { + const cardRequest = visibleCards[index] || null; + if (!cardRequest) { + slot.slotEl.style.display = "none"; + slot.imageEl.removeAttribute("src"); + slot.imageEl.style.display = "none"; + slot.fallbackEl.style.display = "none"; + return; + } + + slot.slotEl.style.display = "flex"; + 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"; + slot.imageEl.style.display = "block"; + slot.fallbackEl.style.display = "none"; + } else { + slot.imageEl.removeAttribute("src"); + slot.imageEl.alt = ""; + 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; @@ -452,15 +802,45 @@ } function applyComparePresentation() { - if (!overlayEl || !backdropEl || !toolbarEl || !stageEl || !frameEl || !imageEl || !overlayImageEl || !compareButtonEl) { + if (!overlayEl || !backdropEl || !toolbarEl || !stageEl || !frameEl || !imageEl || !overlayImageEl || !compareButtonEl || !deckCompareButtonEl) { return; } - compareButtonEl.hidden = zoomed || !lightboxState.allowOverlayCompare || (lightboxState.compareMode && !hasSecondaryCard()); + compareButtonEl.hidden = zoomed + || lightboxState.deckCompareMode + || !lightboxState.allowOverlayCompare + || (lightboxState.compareMode && !hasSecondaryCard()); compareButtonEl.textContent = lightboxState.compareMode ? "Done Overlay" : "Overlay"; syncHelpUi(); syncZoomControl(); syncOpacityControl(); + syncDeckComparePicker(); + + 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 = "24px"; + toolbarEl.style.right = "24px"; + toolbarEl.style.left = "auto"; + 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"; + stageEl.style.pointerEvents = "auto"; + frameEl.style.display = "none"; + primaryInfoEl.style.display = "none"; + secondaryInfoEl.style.display = "none"; + renderDeckCompareGrid(); + return; + } + + frameEl.style.display = "block"; + compareGridEl.style.display = "none"; if (!lightboxState.compareMode) { overlayEl.style.pointerEvents = "none"; @@ -603,12 +983,12 @@ applyZoomTransform(); } - function updateZoomOrigin(clientX, clientY) { - if (!zoomed || !imageEl) { + function updateZoomOrigin(clientX, clientY, targetImage = imageEl) { + if (!zoomed || !targetImage) { return; } - const rect = imageEl.getBoundingClientRect(); + const rect = targetImage.getBoundingClientRect(); if (!rect.width || !rect.height) { return; } @@ -620,14 +1000,14 @@ applyTransformOrigins(); } - function isPointOnCard(clientX, clientY) { - if (!imageEl) { + function isPointOnCard(clientX, clientY, targetImage = imageEl) { + if (!targetImage) { return false; } - const rect = imageEl.getBoundingClientRect(); - const naturalWidth = imageEl.naturalWidth; - const naturalHeight = imageEl.naturalHeight; + const rect = targetImage.getBoundingClientRect(); + const naturalWidth = targetImage.naturalWidth; + const naturalHeight = targetImage.naturalHeight; if (!rect.width || !rect.height || !naturalWidth || !naturalHeight) { return true; @@ -721,6 +1101,7 @@ "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 up to two 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", @@ -768,6 +1149,18 @@ compareButtonEl.style.cursor = "pointer"; compareButtonEl.style.backdropFilter = "blur(12px)"; + 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)"; + zoomControlEl = document.createElement("label"); zoomControlEl.style.display = "flex"; zoomControlEl.style.alignItems = "center"; @@ -830,7 +1223,40 @@ opacityControlEl.append(opacityTextEl, opacitySliderEl, opacityValueEl); - toolbarEl.append(compareButtonEl, zoomControlEl, opacityControlEl); + 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.zIndex = "2"; + + const deckCompareTitleEl = document.createElement("div"); + deckCompareTitleEl.textContent = "Compare Registered Decks"; + deckCompareTitleEl.style.font = "700 13px/1.3 sans-serif"; + + 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(deckCompareTitleEl, deckCompareMessageEl, deckCompareDeckListEl); + + toolbarEl.append(compareButtonEl, deckCompareButtonEl, zoomControlEl, opacityControlEl); stageEl = document.createElement("div"); stageEl.style.position = "fixed"; @@ -866,6 +1292,100 @@ 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"; + + 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 = "scale(1) rotate(0deg)"; + compareImageEl.style.transformOrigin = "center center"; + compareImageEl.style.transition = "transform 120ms ease-out"; + 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)"; + + mediaEl.append(compareImageEl, fallbackEl); + slotEl.append(headerEl, mediaEl); + + return { + slotEl, + badgeEl, + cardLabelEl, + 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%"; @@ -939,8 +1459,8 @@ baseLayerEl.appendChild(imageEl); overlayLayerEl.appendChild(overlayImageEl); frameEl.append(baseLayerEl, overlayLayerEl); - stageEl.append(frameEl, primaryInfoEl, secondaryInfoEl); - overlayEl.append(backdropEl, stageEl, toolbarEl, helpButtonEl, helpPanelEl); + stageEl.append(frameEl, compareGridEl, primaryInfoEl, secondaryInfoEl); + overlayEl.append(backdropEl, stageEl, toolbarEl, deckComparePanelEl, helpButtonEl, helpPanelEl); const close = () => { if (!overlayEl || !imageEl || !overlayImageEl) { @@ -949,15 +1469,28 @@ 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.helpOpen = false; + lightboxState.primaryRotated = false; + lightboxState.overlayRotated = false; overlayEl.style.display = "none"; overlayEl.setAttribute("aria-hidden", "true"); imageEl.removeAttribute("src"); @@ -969,6 +1502,8 @@ syncHelpUi(); syncComparePanels(); syncOpacityControl(); + syncDeckComparePicker(); + renderDeckCompareGrid(); if (previousFocusedEl instanceof HTMLElement) { previousFocusedEl.focus({ preventScroll: true }); @@ -1054,7 +1589,11 @@ imageEl.src = nextCard.src; imageEl.alt = nextCard.altText; resetZoom(); - clearSecondaryCard(); + if (lightboxState.deckCompareMode) { + syncDeckCompareCards(); + } else { + clearSecondaryCard(); + } if (typeof lightboxState.onSelectCardId === "function") { lightboxState.onSelectCardId(nextCard.cardId); } @@ -1124,6 +1663,10 @@ toggleCompareMode(); restoreLightboxFocus(); }); + deckCompareButtonEl.addEventListener("click", () => { + toggleDeckComparePanel(); + restoreLightboxFocus(); + }); zoomSliderEl.addEventListener("input", () => { setZoomScale(Number(zoomSliderEl.value) / 100); }); @@ -1170,6 +1713,39 @@ } }); + compareGridSlots.forEach((slot) => { + slot.imageEl.addEventListener("click", (event) => { + event.stopPropagation(); + if (!isPointOnCard(event.clientX, event.clientY, slot.imageEl)) { + close(); + return; + } + + if (!zoomed) { + zoomed = true; + applyZoomTransform(); + updateZoomOrigin(event.clientX, event.clientY, slot.imageEl); + applyComparePresentation(); + return; + } + + resetZoom(); + applyComparePresentation(); + }); + + slot.imageEl.addEventListener("mousemove", (event) => { + updateZoomOrigin(event.clientX, event.clientY, slot.imageEl); + }); + + slot.imageEl.addEventListener("mouseleave", () => { + if (zoomed) { + lightboxState.zoomOriginX = 50; + lightboxState.zoomOriginY = 50; + applyTransformOrigins(); + } + }); + }); + document.addEventListener("keydown", (event) => { if (event.key === "Escape") { close(); @@ -1268,6 +1844,11 @@ && 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) { @@ -1279,16 +1860,33 @@ 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.helpOpen = false; + lightboxState.primaryRotated = false; + lightboxState.overlayRotated = false; imageEl.src = normalizedPrimary.src; imageEl.alt = normalizedPrimary.altText; diff --git a/app/ui-tarot.js b/app/ui-tarot.js index 8e34c3b..a98ac27 100644 --- a/app/ui-tarot.js +++ b/app/ui-tarot.js @@ -1,5 +1,12 @@ (function () { - const { resolveTarotCardImage, resolveTarotCardThumbnail, getTarotCardDisplayName, getTarotCardSearchAliases } = window.TarotCardImages || {}; + const { + resolveTarotCardImage, + resolveTarotCardThumbnail, + getTarotCardDisplayName, + getTarotCardSearchAliases, + getDeckOptions, + getActiveDeck + } = window.TarotCardImages || {}; const tarotHouseUi = window.TarotHouseUi || {}; const tarotRelationsUi = window.TarotRelationsUi || {}; const tarotCardDerivations = window.TarotCardDerivations || {}; @@ -686,29 +693,60 @@ ?.scrollIntoView({ block: "nearest" }); } - function buildLightboxCardRequestById(cardIdToResolve) { + function getRegisteredDeckOptionMap() { + const entries = typeof getDeckOptions === "function" ? getDeckOptions() : []; + return new Map( + (Array.isArray(entries) ? entries : []) + .map((entry) => ({ + id: String(entry?.id || "").trim(), + label: String(entry?.label || entry?.id || "").trim() + })) + .filter((entry) => entry.id) + .map((entry) => [entry.id, entry]) + ); + } + + function getRegisteredDeckList() { + return Array.from(getRegisteredDeckOptionMap().values()); + } + + function buildDeckLightboxCardRequest(cardIdToResolve, deckIdToResolve = "") { const card = state.cards.find((entry) => entry.id === cardIdToResolve); if (!card) { return null; } + const resolvedDeckId = String(deckIdToResolve || getActiveDeck?.() || "").trim(); + const trumpNumber = Number.isFinite(Number(card?.number)) ? Number(card.number) : undefined; + const deckOptions = resolvedDeckId ? { deckId: resolvedDeckId, trumpNumber } : { trumpNumber }; const src = typeof resolveTarotCardImage === "function" - ? resolveTarotCardImage(card.name) + ? resolveTarotCardImage(card.name, deckOptions) : ""; - if (!src) { - return null; - } + const deckMeta = resolvedDeckId ? getRegisteredDeckOptionMap().get(resolvedDeckId) : null; + const label = (typeof getTarotCardDisplayName === "function" + ? getTarotCardDisplayName(card.name, deckOptions) + : "") || getDisplayCardName(card) || card.name || "Tarot card enlarged image"; - const label = getDisplayCardName(card) || card.name || "Tarot card enlarged image"; return { src, altText: label, label, cardId: card.id, + deckId: resolvedDeckId, + deckLabel: deckMeta?.label || resolvedDeckId, compareDetails: tarotDetailRenderer.buildCompareDetails?.(card) || [] }; } + function buildLightboxCardRequestById(cardIdToResolve) { + const request = buildDeckLightboxCardRequest(cardIdToResolve, getActiveDeck?.() || ""); + if (!request?.src) { + return null; + } + + return request; + } + function renderList(elements) { if (!elements?.tarotCardListEl) { return; @@ -760,15 +798,25 @@ openCardLightbox: (src, altText, options = {}) => { const cardId = String(options?.cardId || "").trim(); const primaryCardRequest = cardId ? buildLightboxCardRequestById(cardId) : null; + const activeDeckId = String(getActiveDeck?.() || primaryCardRequest?.deckId || "").trim(); + const availableCompareDecks = getRegisteredDeckList().filter((deck) => deck.id && deck.id !== activeDeckId); window.TarotUiLightbox?.open?.({ src: primaryCardRequest?.src || src, altText: primaryCardRequest?.altText || altText || "Tarot card enlarged image", label: primaryCardRequest?.label || altText || "Tarot card enlarged image", cardId: primaryCardRequest?.cardId || cardId, + deckId: primaryCardRequest?.deckId || activeDeckId, + deckLabel: primaryCardRequest?.deckLabel || "", compareDetails: primaryCardRequest?.compareDetails || [], allowOverlayCompare: true, + allowDeckCompare: Boolean(cardId), + activeDeckId, + activeDeckLabel: primaryCardRequest?.deckLabel || "", + availableCompareDecks, + maxCompareDecks: 2, sequenceIds: state.cards.map((card) => card.id), resolveCardById: buildLightboxCardRequestById, + resolveDeckCardById: buildDeckLightboxCardRequest, onSelectCardId: (nextCardId) => { const latestElements = getElements(); selectCardById(nextCardId, latestElements);