diff --git a/app/styles.css b/app/styles.css index c3429d8..8f93890 100644 --- a/app/styles.css +++ b/app/styles.css @@ -397,6 +397,7 @@ } #tarot-frame-section { height: calc(100vh - 61px); + height: calc(100dvh - 61px); background: #18181b; box-sizing: border-box; overflow: auto; @@ -957,6 +958,69 @@ line-height: 1.35; } + .tarot-frame-layout-section-title { + padding: 2px 2px 0; + color: #a5b4fc; + font-size: 11px; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; + } + + .tarot-frame-layout-save-btn { + width: 100%; + padding: 10px 12px; + border: 1px solid rgba(129, 140, 248, 0.5); + border-radius: 12px; + background: linear-gradient(180deg, rgba(67, 56, 202, 0.92), rgba(49, 46, 129, 0.96)); + color: #eef2ff; + cursor: pointer; + font-size: 12px; + font-weight: 700; + letter-spacing: 0.03em; + text-transform: uppercase; + } + + .tarot-frame-layout-save-btn:hover { + border-color: rgba(165, 180, 252, 0.9); + background: linear-gradient(180deg, rgba(79, 70, 229, 0.96), rgba(67, 56, 202, 0.98)); + } + + .tarot-frame-layout-entry { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 8px; + align-items: stretch; + } + + .tarot-frame-layout-delete-btn { + align-self: stretch; + padding: 10px 12px; + border: 1px solid rgba(248, 113, 113, 0.28); + border-radius: 12px; + background: rgba(69, 10, 10, 0.36); + color: #fecaca; + cursor: pointer; + font-size: 12px; + font-weight: 700; + } + + .tarot-frame-layout-delete-btn:hover { + border-color: rgba(252, 165, 165, 0.58); + background: rgba(127, 29, 29, 0.5); + color: #fee2e2; + } + + .tarot-frame-layout-empty-note { + padding: 10px 12px; + border: 1px dashed rgba(99, 102, 241, 0.28); + border-radius: 12px; + color: #cbd5e1; + font-size: 12px; + line-height: 1.4; + background: rgba(15, 23, 42, 0.28); + } + .tarot-frame-action-btn { padding: 10px 14px; border: 1px solid #4c1d95; @@ -974,6 +1038,13 @@ background: linear-gradient(180deg, #4338ca, #312e81); } + .tarot-frame-action-btn.is-active { + border-color: #a5b4fc; + background: linear-gradient(180deg, #4f46e5, #3730a3); + color: #f8fafc; + box-shadow: 0 0 0 1px rgba(165, 180, 252, 0.16); + } + .tarot-frame-settings-panel { position: absolute; top: calc(100% + 10px); @@ -1045,6 +1116,22 @@ line-height: 1.45; } + .tarot-frame-settings-actions { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 10px; + } + + .tarot-frame-clear-btn { + border-color: rgba(248, 113, 113, 0.4); + background: linear-gradient(180deg, rgba(127, 29, 29, 0.92), rgba(69, 10, 10, 0.96)); + } + + .tarot-frame-clear-btn:hover { + border-color: rgba(252, 165, 165, 0.72); + background: linear-gradient(180deg, rgba(153, 27, 27, 0.96), rgba(127, 29, 29, 0.98)); + } + .tarot-frame-checkbox-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); @@ -1127,6 +1214,45 @@ min-width: 0; } + .tarot-frame-overview { + display: grid; + grid-template-columns: minmax(0, 1.45fr) minmax(320px, 0.95fr); + gap: 14px; + align-items: stretch; + } + + .tarot-frame-overview-summary, + .tarot-frame-notes-card { + display: grid; + gap: 14px; + padding: 18px; + border: 1px solid #27272a; + border-radius: 22px; + background: + radial-gradient(circle at top, rgba(59, 130, 246, 0.08), transparent 34%), + linear-gradient(180deg, #161622 0%, #0f0f17 100%); + box-shadow: 0 22px 54px rgba(0, 0, 0, 0.2); + min-width: 0; + } + + .tarot-frame-overview-head, + .tarot-frame-panel-head { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 12px; + min-width: 0; + flex-wrap: wrap; + } + + .tarot-frame-overview-eyebrow { + color: #93c5fd; + font-size: 11px; + font-weight: 700; + letter-spacing: 0.12em; + text-transform: uppercase; + } + .tarot-frame-panel { --frame-grid-zoom-scale: 1; --frame-base-cell-width: clamp(32px, 2.6vw, 46px); @@ -1136,27 +1262,17 @@ --frame-gap: calc(var(--frame-base-gap) * var(--frame-grid-zoom-scale)); display: grid; grid-template-columns: minmax(0, 1fr); - gap: 14px; - padding: 18px; + gap: 12px; + padding: 14px; border: 1px solid #27272a; border-radius: 22px; background: - radial-gradient(circle at top, rgba(59, 130, 246, 0.08), transparent 34%), - linear-gradient(180deg, #161622 0%, #0f0f17 100%); - box-shadow: 0 22px 54px rgba(0, 0, 0, 0.24); + linear-gradient(180deg, rgba(22, 22, 34, 0.92), rgba(10, 10, 18, 0.98)); + box-shadow: 0 20px 48px rgba(0, 0, 0, 0.22); overflow: hidden; min-width: 0; } - .tarot-frame-panel-head { - display: flex; - align-items: flex-start; - justify-content: space-between; - gap: 12px; - min-width: 0; - flex-wrap: wrap; - } - .tarot-frame-panel-title { margin: 0; font-size: 17px; @@ -1187,11 +1303,129 @@ .tarot-frame-grid-viewport { width: min(100%, calc(100vw - 52px)); max-width: calc(100vw - 52px); + min-height: clamp(520px, 68dvh, 900px); + max-height: min(84dvh, 1080px); margin-left: auto; margin-right: auto; overflow: auto; overscroll-behavior: contain; min-width: 0; + touch-action: pan-x pan-y; + } + + .tarot-frame-focus-exit { + display: inline-flex; + align-items: center; + justify-content: center; + justify-self: end; + padding: 10px 14px; + border: 1px solid rgba(191, 219, 254, 0.34); + border-radius: 999px; + background: rgba(15, 23, 42, 0.86); + color: #eff6ff; + cursor: pointer; + font-size: 12px; + font-weight: 700; + letter-spacing: 0.03em; + box-shadow: 0 12px 34px rgba(0, 0, 0, 0.3); + } + + .tarot-frame-focus-exit:hover { + border-color: rgba(147, 197, 253, 0.7); + background: rgba(30, 41, 59, 0.94); + } + + .tarot-frame-focus-exit[hidden] { + display: none !important; + } + + body.is-tarot-frame-focus-lock { + overflow: hidden; + } + + #tarot-frame-section.is-grid-focus { + position: fixed; + inset: 61px 0 0 0; + z-index: 88; + overflow: hidden; + background: + radial-gradient(circle at top, rgba(59, 130, 246, 0.14), transparent 30%), + rgba(2, 6, 23, 0.92); + -webkit-backdrop-filter: blur(10px); + backdrop-filter: blur(10px); + } + + #tarot-frame-section.is-grid-focus .tarot-frame-view { + display: flex; + flex-direction: column; + height: 100%; + padding: 10px; + overflow: hidden; + } + + #tarot-frame-section.is-grid-focus .tarot-frame-shell { + min-height: 100%; + grid-template-rows: auto minmax(0, 1fr); + align-content: stretch; + } + + #tarot-frame-section.is-grid-focus .tarot-frame-header, + #tarot-frame-section.is-grid-focus .tarot-frame-overview-host, + #tarot-frame-section.is-grid-focus .tarot-frame-status { + display: none !important; + } + + #tarot-frame-section.is-grid-focus .tarot-frame-focus-exit { + display: inline-flex !important; + margin: 0 0 8px auto; + position: relative; + z-index: 90; + } + + #tarot-frame-section.is-grid-focus .tarot-frame-board-grid { + height: 100%; + min-height: 0; + position: relative; + z-index: 89; + } + + #tarot-frame-section.is-grid-focus .tarot-frame-panel { + height: 100%; + padding: 10px; + border-color: rgba(96, 165, 250, 0.3); + box-shadow: 0 28px 72px rgba(0, 0, 0, 0.42); + } + + #tarot-frame-section.is-grid-focus .tarot-frame-grid-viewport { + width: 100%; + max-width: none; + min-height: 0; + max-height: none; + height: 100%; + } + + .tarot-frame-grid-viewport.is-pan-enabled { + cursor: grab; + touch-action: none; + -webkit-user-select: none; + user-select: none; + -webkit-touch-callout: none; + } + + .tarot-frame-grid-viewport.is-pan-enabled .tarot-frame-card { + cursor: inherit; + } + + .tarot-frame-grid-viewport.is-pan-enabled.is-panning { + cursor: grabbing; + } + + .tarot-frame-grid-viewport.is-panning { + cursor: grabbing; + } + + .tarot-frame-grid-viewport.is-touch-gesture-active { + touch-action: none; } .tarot-frame-grid-track { @@ -1203,7 +1437,7 @@ .tarot-frame-legend { display: grid; - grid-template-columns: repeat(3, minmax(0, 1fr)); + grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; } @@ -1219,6 +1453,116 @@ line-height: 1.4; } + .tarot-frame-notes-card { + align-content: start; + } + + .tarot-frame-notes-head { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 10px; + flex-wrap: wrap; + } + + .tarot-frame-notes-title { + margin: 0; + color: #f8fafc; + font-size: 15px; + line-height: 1.2; + } + + .tarot-frame-notes-copy { + margin: 6px 0 0; + color: #94a3b8; + font-size: 12px; + line-height: 1.45; + } + + .tarot-frame-notes-badge { + align-self: flex-start; + padding: 6px 10px; + border-radius: 999px; + border: 1px solid rgba(96, 165, 250, 0.28); + background: rgba(15, 23, 42, 0.72); + color: #bfdbfe; + font-size: 11px; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; + white-space: nowrap; + } + + .tarot-frame-notes-field { + display: grid; + gap: 8px; + color: #e2e8f0; + font-size: 12px; + font-weight: 700; + letter-spacing: 0.04em; + text-transform: uppercase; + } + + .tarot-frame-notes-field textarea { + width: 100%; + min-height: 176px; + resize: vertical; + padding: 12px 14px; + border: 1px solid rgba(96, 165, 250, 0.22); + border-radius: 16px; + background: rgba(8, 15, 28, 0.82); + color: #e2e8f0; + font: inherit; + font-size: 13px; + line-height: 1.55; + letter-spacing: 0; + text-transform: none; + box-sizing: border-box; + } + + .tarot-frame-notes-field textarea::placeholder { + color: #64748b; + } + + .tarot-frame-notes-field textarea:focus { + outline: none; + border-color: rgba(125, 211, 252, 0.66); + box-shadow: 0 0 0 3px rgba(56, 189, 248, 0.12); + } + + .tarot-frame-notes-footer { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + flex-wrap: wrap; + color: #94a3b8; + font-size: 12px; + line-height: 1.4; + } + + .tarot-frame-notes-clear { + padding: 9px 12px; + border: 1px solid rgba(96, 165, 250, 0.22); + border-radius: 999px; + background: rgba(15, 23, 42, 0.56); + color: #dbeafe; + cursor: pointer; + font-size: 12px; + font-weight: 700; + letter-spacing: 0.03em; + } + + .tarot-frame-notes-clear:hover { + border-color: rgba(125, 211, 252, 0.5); + background: rgba(30, 41, 59, 0.82); + } + + .tarot-frame-notes-clear:disabled { + cursor: default; + opacity: 0.45; + } + .tarot-frame-legend-item strong { color: #f8fafc; font-size: 12px; @@ -1237,6 +1581,52 @@ min-width: max-content; } + @media (max-width: 980px) { + .tarot-frame-overview { + grid-template-columns: minmax(0, 1fr); + } + } + + @media (max-width: 760px) { + #tarot-frame-section { + height: calc(100svh - 61px); + height: calc(100dvh - 61px); + } + + .tarot-frame-view { + padding: 10px; + } + + .tarot-frame-shell { + gap: 10px; + } + + .tarot-frame-grid-viewport { + min-height: clamp(680px, 84dvh, 1240px); + max-height: min(92dvh, 1320px); + } + + .tarot-frame-legend { + grid-template-columns: minmax(0, 1fr); + } + + .tarot-frame-notes-field textarea { + min-height: 112px; + } + + .tarot-frame-focus-exit { + padding: 9px 12px; + } + + #tarot-frame-section.is-grid-focus .tarot-frame-view { + padding: 8px; + } + + #tarot-frame-section.is-grid-focus .tarot-frame-focus-exit { + margin-bottom: 6px; + } + } + .tarot-frame-slot { position: relative; width: var(--frame-cell-width); @@ -1249,6 +1639,155 @@ border: 1px dashed rgba(148, 163, 184, 0.4); } + .tarot-frame-card-picker { + position: fixed; + z-index: 40; + width: min(420px, calc(100vw - 24px)); + max-height: min(78vh, 720px); + display: grid; + grid-template-rows: auto auto minmax(0, 1fr); + gap: 12px; + padding: 14px; + border: 1px solid rgba(129, 140, 248, 0.3); + border-radius: 20px; + background: + radial-gradient(circle at top, rgba(99, 102, 241, 0.18), transparent 36%), + linear-gradient(180deg, rgba(15, 23, 42, 0.98), rgba(2, 6, 23, 0.98)); + box-shadow: 0 24px 70px rgba(0, 0, 0, 0.42); + overflow: hidden; + } + + .tarot-frame-card-picker[hidden] { + display: none !important; + } + + .tarot-frame-card-picker-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + } + + .tarot-frame-card-picker-title { + color: #f8fafc; + font-size: 15px; + font-weight: 700; + line-height: 1.3; + } + + .tarot-frame-card-picker-close { + padding: 8px 12px; + border: 1px solid rgba(148, 163, 184, 0.28); + border-radius: 999px; + background: rgba(15, 23, 42, 0.78); + color: #e2e8f0; + cursor: pointer; + font-size: 12px; + font-weight: 700; + } + + .tarot-frame-card-picker-search { + display: grid; + gap: 6px; + color: #cbd5e1; + font-size: 11px; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; + } + + .tarot-frame-card-picker-search input { + width: 100%; + padding: 10px 12px; + border: 1px solid rgba(129, 140, 248, 0.24); + border-radius: 12px; + background: rgba(15, 23, 42, 0.84); + color: #f8fafc; + font-size: 13px; + } + + .tarot-frame-card-picker-sections { + display: grid; + align-content: start; + gap: 14px; + min-height: 0; + overflow: auto; + overscroll-behavior: contain; + padding-right: 2px; + touch-action: pan-y; + } + + .tarot-frame-card-picker-section { + display: grid; + gap: 8px; + padding: 12px; + border: 1px solid rgba(71, 85, 105, 0.48); + border-radius: 16px; + background: rgba(15, 23, 42, 0.42); + } + + .tarot-frame-card-picker-section-title { + margin: 0; + color: #eef2ff; + font-size: 13px; + font-weight: 700; + letter-spacing: 0.04em; + text-transform: uppercase; + } + + .tarot-frame-card-picker-subtitle { + color: #a5b4fc; + font-size: 11px; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; + } + + .tarot-frame-card-picker-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); + gap: 8px; + } + + .tarot-frame-card-picker-option { + display: grid; + gap: 4px; + padding: 10px 12px; + border: 1px solid rgba(99, 102, 241, 0.24); + border-radius: 12px; + background: rgba(30, 41, 59, 0.58); + color: #e2e8f0; + text-align: left; + cursor: pointer; + } + + .tarot-frame-card-picker-option:hover { + border-color: rgba(165, 180, 252, 0.9); + background: rgba(67, 56, 202, 0.28); + color: #f8fafc; + } + + .tarot-frame-card-picker-option strong { + font-size: 12px; + line-height: 1.35; + } + + .tarot-frame-card-picker-option span { + color: #94a3b8; + font-size: 11px; + line-height: 1.3; + } + + .tarot-frame-card-picker-empty { + padding: 12px; + border: 1px dashed rgba(99, 102, 241, 0.28); + border-radius: 12px; + color: #cbd5e1; + font-size: 12px; + line-height: 1.4; + background: rgba(15, 23, 42, 0.24); + } + .tarot-frame-slot.is-drop-target { box-shadow: 0 0 0 2px #f59e0b, 0 0 0 6px rgba(245, 158, 11, 0.18); border-radius: 8px; diff --git a/app/ui-tarot-detail.js b/app/ui-tarot-detail.js index 298d828..23c6a62 100644 --- a/app/ui-tarot-detail.js +++ b/app/ui-tarot-detail.js @@ -340,7 +340,7 @@ { title: "Letter", relations: detailRelations.hebrewRelations }, { title: "Planet / Ruler", relations: detailRelations.planetRelations }, { - title: detailRelations.hasSignWindowRelations ? "Signs" : (detailRelations.hasDecanSummaryRelations ? "Decan" : "Sign / Decan"), + title: detailRelations.hasSignWindowRelations ? "Signs" : (detailRelations.hasDecanSummaryRelations ? "Decans" : "Sign / Decan"), relations: detailRelations.zodiacRelationsWithRulership }, { title: "Element", relations: detailRelations.elementRelations }, diff --git a/app/ui-tarot-frame.js b/app/ui-tarot-frame.js index c4a60f4..6e4b7fa 100644 --- a/app/ui-tarot-frame.js +++ b/app/ui-tarot-frame.js @@ -48,7 +48,17 @@ pisces: "02-19" }; const MASTER_GRID_SIZE = 14; - const FRAME_GRID_ZOOM_STEPS = [1, 1.2, 1.4, 1.7, 2]; + const FRAME_GRID_ZOOM_STEPS = [1, 1.2, 1.4, 1.7, 2, 2.4, 3, 3.6, 4.2]; + const FRAME_GRID_MIN_SCALE = 0.8; + const FRAME_GRID_MAX_SCALE = 5.2; + const FRAME_CUSTOM_LAYOUTS_STORAGE_KEY = "tarot-frame-custom-layouts-v1"; + const FRAME_ACTIVE_LAYOUT_STORAGE_KEY = "tarot-frame-active-layout-v1"; + const FRAME_CARD_PICKER_QUERY_STORAGE_KEY = "tarot-frame-card-picker-query-v1"; + const FRAME_LAYOUT_NOTES_STORAGE_KEY = "tarot-frame-layout-notes-v1"; + const HOUSE_TOP_INFO_MODE_IDS = ["hebrew", "planet", "zodiac", "trump", "path", "date"]; + const HOUSE_BOTTOM_INFO_MODE_IDS = ["zodiac", "decan", "month", "ruler", "date"]; + const FRAME_LONG_PRESS_DELAY_MS = 460; + const FRAME_LONG_PRESS_MOVE_TOLERANCE = 10; const EXPORT_SLOT_WIDTH = 120; const EXPORT_SLOT_HEIGHT = Math.round((EXPORT_SLOT_WIDTH * TAROT_CARD_HEIGHT_RATIO) / TAROT_CARD_WIDTH_RATIO); const EXPORT_CARD_INSET = 0; @@ -179,14 +189,27 @@ slotAssignments: new Map(), statusMessage: "Loading tarot cards...", drag: null, + panMode: false, + panGesture: null, + pinchGesture: null, + longPress: null, + cardPicker: { + open: false, + slotId: "", + query: "" + }, suppressClick: false, showInfo: true, settingsOpen: false, layoutMenuOpen: false, + gridFocusMode: false, currentLayoutId: "frames", + customLayouts: [], + layoutNotesById: {}, exportInProgress: false, exportFormat: "webp", - gridZoomStepIndex: 0 + gridZoomStepIndex: 0, + gridZoomScale: FRAME_GRID_ZOOM_STEPS[0] }; let config = { @@ -203,6 +226,13 @@ setHouseBottomInfoMode: () => {} }; + let cardPickerEl = null; + let cardPickerTitleEl = null; + let cardPickerSearchEl = null; + let cardPickerSectionsEl = null; + let pendingGridViewportRestoreFrameId = 0; + let activeTouchGestureCapture = false; + function buildPerimeterPath(size, rowOffset = 1, columnOffset = 1) { const path = []; for (let column = 0; column < size; column += 1) { @@ -222,8 +252,14 @@ function getElements() { return { + tarotFrameSectionEl: document.getElementById("tarot-frame-section"), + tarotFrameViewEl: document.getElementById("tarot-frame-view"), tarotFrameBoardEl: document.getElementById("tarot-frame-board"), tarotFrameStatusEl: document.getElementById("tarot-frame-status"), + tarotFrameOverviewEl: document.getElementById("tarot-frame-overview"), + tarotFramePanToggleEl: document.getElementById("tarot-frame-pan-toggle"), + tarotFrameFocusToggleEl: document.getElementById("tarot-frame-focus-toggle"), + tarotFrameFocusExitEl: document.getElementById("tarot-frame-focus-exit"), tarotFrameLayoutToggleEl: document.getElementById("tarot-frame-layout-toggle"), tarotFrameLayoutPanelEl: document.getElementById("tarot-frame-layout-panel"), tarotFrameSettingsToggleEl: document.getElementById("tarot-frame-settings-toggle"), @@ -244,14 +280,11 @@ tarotFrameHouseBottomInfoMonthEl: document.getElementById("tarot-frame-house-bottom-info-month"), tarotFrameHouseBottomInfoRulerEl: document.getElementById("tarot-frame-house-bottom-info-ruler"), tarotFrameHouseBottomInfoDateEl: document.getElementById("tarot-frame-house-bottom-info-date"), + tarotFrameClearGridEl: document.getElementById("tarot-frame-clear-grid"), tarotFrameExportWebpEl: document.getElementById("tarot-frame-export-webp") }; } - function getLayoutOptionElements() { - return Array.from(document.querySelectorAll(".tarot-frame-layout-option[data-layout-preset-id]")); - } - function normalizeLabelText(value) { return String(value || "").replace(/\s+/g, " ").trim(); } @@ -320,8 +353,33 @@ return `${Array.isArray(cards) ? cards.length : 0} cards ready. Drag cards freely and use Settings to change the grid zoom for any layout.`; } + function clampFrameGridZoomScale(value) { + const numericValue = Number(value); + if (!Number.isFinite(numericValue)) { + return FRAME_GRID_ZOOM_STEPS[0]; + } + + return Math.min(FRAME_GRID_MAX_SCALE, Math.max(FRAME_GRID_MIN_SCALE, numericValue)); + } + + function getNearestFrameZoomStepIndex(scale) { + const safeScale = clampFrameGridZoomScale(scale); + let bestIndex = 0; + let bestDistance = Number.POSITIVE_INFINITY; + + FRAME_GRID_ZOOM_STEPS.forEach((step, index) => { + const distance = Math.abs(step - safeScale); + if (distance < bestDistance) { + bestDistance = distance; + bestIndex = index; + } + }); + + return bestIndex; + } + function getGridZoomScale() { - return FRAME_GRID_ZOOM_STEPS[state.gridZoomStepIndex] || FRAME_GRID_ZOOM_STEPS[0]; + return clampFrameGridZoomScale(state.gridZoomScale); } function buildPanelCountText(cards = getCards()) { @@ -332,6 +390,1196 @@ return String(value || "").trim().toLowerCase(); } + function readStorageValue(key) { + try { + return String(window.localStorage?.getItem?.(key) || ""); + } catch (_error) { + return ""; + } + } + + function writeStorageValue(key, value) { + try { + window.localStorage?.setItem?.(key, value); + return true; + } catch (_error) { + return false; + } + } + + function removeStorageValue(key) { + try { + window.localStorage?.removeItem?.(key); + return true; + } catch (_error) { + return false; + } + } + + function normalizeLayoutLabel(value) { + return String(value || "") + .replace(/\s+/g, " ") + .trim() + .slice(0, 64); + } + + function normalizeLayoutNote(value) { + return String(value || "") + .replace(/\r\n?/g, "\n") + .replace(/\u0000/g, "") + .trim() + .slice(0, 1600); + } + + function createSavedLayoutId() { + return `saved-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`; + } + + function isValidSlotId(value) { + const match = String(value || "").trim().match(/^(\d+):(\d+)$/); + if (!match) { + return false; + } + + const row = Number(match[1]); + const column = Number(match[2]); + return Number.isInteger(row) + && Number.isInteger(column) + && row >= 1 + && row <= MASTER_GRID_SIZE + && column >= 1 + && column <= MASTER_GRID_SIZE; + } + + function buildFrameSettingsSnapshot() { + const topModes = config.getHouseTopInfoModes?.() || {}; + const bottomModes = config.getHouseBottomInfoModes?.() || {}; + + return { + showInfo: Boolean(state.showInfo), + gridZoomScale: getGridZoomScale(), + gridZoomStepIndex: state.gridZoomStepIndex, + houseTopCardsVisible: config.getHouseTopCardsVisible?.() !== false, + houseBottomCardsVisible: config.getHouseBottomCardsVisible?.() !== false, + houseTopInfoModes: HOUSE_TOP_INFO_MODE_IDS.reduce((result, mode) => { + result[mode] = Boolean(topModes[mode]); + return result; + }, {}), + houseBottomInfoModes: HOUSE_BOTTOM_INFO_MODE_IDS.reduce((result, mode) => { + result[mode] = Boolean(bottomModes[mode]); + return result; + }, {}) + }; + } + + function normalizeFrameSettingsSnapshot(rawSettings) { + const fallback = buildFrameSettingsSnapshot(); + const raw = rawSettings && typeof rawSettings === "object" ? rawSettings : {}; + const rawZoomIndex = Number(raw.gridZoomStepIndex); + const rawZoomScale = Number(raw.gridZoomScale); + const normalizedZoomScale = Number.isFinite(rawZoomScale) + ? clampFrameGridZoomScale(rawZoomScale) + : (Number.isFinite(rawZoomIndex) + ? clampFrameGridZoomScale(FRAME_GRID_ZOOM_STEPS[Math.max(0, Math.min(FRAME_GRID_ZOOM_STEPS.length - 1, rawZoomIndex))]) + : fallback.gridZoomScale); + return { + showInfo: raw.showInfo === undefined ? fallback.showInfo : Boolean(raw.showInfo), + gridZoomScale: normalizedZoomScale, + gridZoomStepIndex: Number.isFinite(rawZoomIndex) + ? Math.max(0, Math.min(FRAME_GRID_ZOOM_STEPS.length - 1, rawZoomIndex)) + : getNearestFrameZoomStepIndex(normalizedZoomScale), + houseTopCardsVisible: raw.houseTopCardsVisible === undefined ? fallback.houseTopCardsVisible : Boolean(raw.houseTopCardsVisible), + houseBottomCardsVisible: raw.houseBottomCardsVisible === undefined ? fallback.houseBottomCardsVisible : Boolean(raw.houseBottomCardsVisible), + houseTopInfoModes: HOUSE_TOP_INFO_MODE_IDS.reduce((result, mode) => { + result[mode] = raw.houseTopInfoModes?.[mode] === undefined + ? fallback.houseTopInfoModes[mode] + : Boolean(raw.houseTopInfoModes[mode]); + return result; + }, {}), + houseBottomInfoModes: HOUSE_BOTTOM_INFO_MODE_IDS.reduce((result, mode) => { + result[mode] = raw.houseBottomInfoModes?.[mode] === undefined + ? fallback.houseBottomInfoModes[mode] + : Boolean(raw.houseBottomInfoModes[mode]); + return result; + }, {}) + }; + } + + function captureSlotAssignmentsSnapshot(cards = getCards()) { + const validCardIds = new Set(cards.map((card) => getCardId(card)).filter(Boolean)); + return [...state.slotAssignments.entries()] + .map(([slotId, cardId]) => ({ + slotId: String(slotId || "").trim(), + cardId: String(cardId || "").trim() + })) + .filter((entry) => isValidSlotId(entry.slotId) && validCardIds.has(entry.cardId)) + .sort((left, right) => { + const [leftRow, leftColumn] = left.slotId.split(":").map(Number); + const [rightRow, rightColumn] = right.slotId.split(":").map(Number); + if (leftRow !== rightRow) { + return leftRow - rightRow; + } + return leftColumn - rightColumn; + }); + } + + function normalizeSavedLayoutRecord(rawLayout) { + const label = normalizeLayoutLabel(rawLayout?.label || rawLayout?.name); + if (!label) { + return null; + } + + const id = String(rawLayout?.id || "").trim() || createSavedLayoutId(); + const slotAssignments = Array.isArray(rawLayout?.slotAssignments) + ? rawLayout.slotAssignments + .map((entry) => ({ + slotId: String(entry?.slotId || "").trim(), + cardId: String(entry?.cardId || "").trim() + })) + .filter((entry) => isValidSlotId(entry.slotId) && entry.cardId) + : []; + const settings = normalizeFrameSettingsSnapshot(rawLayout?.settings); + const createdAt = String(rawLayout?.createdAt || rawLayout?.updatedAt || "").trim(); + const note = normalizeLayoutNote(rawLayout?.note); + + return { + id, + label, + title: label, + subtitle: "Saved browser layout with custom card positions, frame display settings, and optional notes.", + statusMessage: `${label} layout loaded from browser storage.`, + legendItems: [ + { + title: "Saved Layout", + description: "Custom card positions, frame settings, and notes stored only in this browser." + } + ], + slotAssignments, + settings, + note, + createdAt, + isCustom: true + }; + } + + function loadLayoutNotesFromStorage() { + const rawValue = readStorageValue(FRAME_LAYOUT_NOTES_STORAGE_KEY); + if (!rawValue) { + state.layoutNotesById = {}; + return; + } + + try { + const parsed = JSON.parse(rawValue); + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + state.layoutNotesById = {}; + return; + } + + state.layoutNotesById = Object.entries(parsed).reduce((result, [layoutId, note]) => { + const normalizedId = String(layoutId || "").trim(); + const normalizedNote = normalizeLayoutNote(note); + if (normalizedId && normalizedNote) { + result[normalizedId] = normalizedNote; + } + return result; + }, {}); + } catch (_error) { + state.layoutNotesById = {}; + } + } + + function loadSavedLayoutsFromStorage() { + const rawValue = readStorageValue(FRAME_CUSTOM_LAYOUTS_STORAGE_KEY); + if (!rawValue) { + state.customLayouts = []; + return; + } + + try { + const parsed = JSON.parse(rawValue); + state.customLayouts = Array.isArray(parsed) + ? parsed.map((entry) => normalizeSavedLayoutRecord(entry)).filter(Boolean) + : []; + } catch (_error) { + state.customLayouts = []; + } + } + + function persistSavedLayouts() { + return writeStorageValue(FRAME_CUSTOM_LAYOUTS_STORAGE_KEY, JSON.stringify(state.customLayouts.map((layout) => ({ + id: layout.id, + label: layout.label, + slotAssignments: layout.slotAssignments, + settings: layout.settings, + note: normalizeLayoutNote(layout.note), + createdAt: layout.createdAt || new Date().toISOString() + })))); + } + + function persistLayoutNotes() { + const entries = Object.entries(state.layoutNotesById || {}).reduce((result, [layoutId, note]) => { + const normalizedId = String(layoutId || "").trim(); + const normalizedNote = normalizeLayoutNote(note); + if (normalizedId && normalizedNote) { + result[normalizedId] = normalizedNote; + } + return result; + }, {}); + + if (!Object.keys(entries).length) { + removeStorageValue(FRAME_LAYOUT_NOTES_STORAGE_KEY); + return true; + } + + return writeStorageValue(FRAME_LAYOUT_NOTES_STORAGE_KEY, JSON.stringify(entries)); + } + + function persistActiveLayoutId(layoutId = state.currentLayoutId) { + const nextId = String(layoutId || "").trim(); + if (!nextId) { + removeStorageValue(FRAME_ACTIVE_LAYOUT_STORAGE_KEY); + return; + } + writeStorageValue(FRAME_ACTIVE_LAYOUT_STORAGE_KEY, nextId); + } + + function restoreActiveLayoutId() { + const storedId = String(readStorageValue(FRAME_ACTIVE_LAYOUT_STORAGE_KEY) || "").trim(); + if (!storedId) { + return; + } + + const nextLayout = getLayoutDefinition(storedId); + state.currentLayoutId = nextLayout.id; + } + + function persistCardPickerQuery(query = state.cardPicker.query) { + writeStorageValue(FRAME_CARD_PICKER_QUERY_STORAGE_KEY, String(query || "")); + } + + function restoreCardPickerQuery() { + state.cardPicker.query = String(readStorageValue(FRAME_CARD_PICKER_QUERY_STORAGE_KEY) || ""); + } + + function getLayoutNote(layoutId = state.currentLayoutId) { + const nextLayoutId = String(layoutId || "").trim(); + if (!nextLayoutId) { + return ""; + } + + const storedNote = normalizeLayoutNote(state.layoutNotesById?.[nextLayoutId]); + if (storedNote) { + return storedNote; + } + + return normalizeLayoutNote(getSavedLayout(nextLayoutId)?.note); + } + + function setLayoutNote(layoutId, note, options = {}) { + const nextLayoutId = String(layoutId || "").trim(); + if (!nextLayoutId) { + return; + } + + const normalizedNote = normalizeLayoutNote(note); + if (normalizedNote) { + state.layoutNotesById[nextLayoutId] = normalizedNote; + } else { + delete state.layoutNotesById[nextLayoutId]; + } + + const savedLayout = getSavedLayout(nextLayoutId); + if (savedLayout) { + savedLayout.note = normalizedNote; + persistSavedLayouts(); + } + + persistLayoutNotes(); + + if (options.updateUi !== false) { + updateLayoutNotesUi(); + } + } + + function getLayoutNotePlaceholder(layoutDefinition = getLayoutDefinition()) { + if (layoutDefinition?.id === "house") { + return "Add placement notes, reading rules, or reminders for this House of Cards arrangement."; + } + return "Add your own notes for this layout: intentions, spread logic, card placement rules, or reminders."; + } + + function updateLayoutNotesUi() { + const { tarotFrameOverviewEl } = getElements(); + if (!(tarotFrameOverviewEl instanceof HTMLElement)) { + return; + } + + const currentNote = getLayoutNote(); + + const noteFieldEl = tarotFrameOverviewEl.querySelector("#tarot-frame-layout-note"); + if (noteFieldEl instanceof HTMLTextAreaElement) { + noteFieldEl.value = currentNote; + noteFieldEl.placeholder = getLayoutNotePlaceholder(getLayoutDefinition()); + noteFieldEl.disabled = Boolean(state.exportInProgress); + } + + const noteClearEl = tarotFrameOverviewEl.querySelector("[data-frame-note-clear='true']"); + if (noteClearEl instanceof HTMLButtonElement) { + noteClearEl.disabled = !currentNote || Boolean(state.exportInProgress); + } + + const noteBadgeEl = tarotFrameOverviewEl.querySelector(".tarot-frame-notes-badge"); + if (noteBadgeEl instanceof HTMLElement) { + noteBadgeEl.textContent = currentNote ? "Saved" : "Optional"; + } + } + + function applyGridFocusModeUi() { + const { + tarotFrameSectionEl, + tarotFrameFocusToggleEl, + tarotFrameFocusExitEl + } = getElements(); + + if (tarotFrameSectionEl instanceof HTMLElement) { + tarotFrameSectionEl.classList.toggle("is-grid-focus", Boolean(state.gridFocusMode)); + } + + document.body.classList.toggle("is-tarot-frame-focus-lock", Boolean(state.gridFocusMode)); + + if (tarotFrameFocusToggleEl) { + tarotFrameFocusToggleEl.setAttribute("aria-pressed", state.gridFocusMode ? "true" : "false"); + tarotFrameFocusToggleEl.classList.toggle("is-active", Boolean(state.gridFocusMode)); + tarotFrameFocusToggleEl.textContent = state.gridFocusMode ? "Exit Full Screen" : "Full Screen"; + tarotFrameFocusToggleEl.disabled = Boolean(state.exportInProgress); + } + + if (tarotFrameFocusExitEl) { + tarotFrameFocusExitEl.hidden = !state.gridFocusMode; + tarotFrameFocusExitEl.disabled = Boolean(state.exportInProgress); + } + } + + function setGridFocusMode(nextFocus) { + const shouldFocus = Boolean(nextFocus); + if (state.gridFocusMode === shouldFocus) { + applyGridFocusModeUi(); + return; + } + + state.gridFocusMode = shouldFocus; + state.settingsOpen = false; + state.layoutMenuOpen = false; + finishPanGesture(); + clearLongPressGesture(); + if (state.cardPicker.open) { + closeCardPicker(); + } + cleanupDrag(); + syncControls(); + updateViewportInteractionState(); + setStatus(shouldFocus + ? "Full-screen frame mode enabled. Tap outside the board or press Escape to exit." + : "Full-screen frame mode closed."); + } + + function applyFrameSettingsSnapshot(rawSettings) { + const settings = normalizeFrameSettingsSnapshot(rawSettings); + state.showInfo = settings.showInfo; + state.gridZoomScale = settings.gridZoomScale; + state.gridZoomStepIndex = settings.gridZoomStepIndex; + config.setHouseTopCardsVisible?.(settings.houseTopCardsVisible); + config.setHouseBottomCardsVisible?.(settings.houseBottomCardsVisible); + HOUSE_TOP_INFO_MODE_IDS.forEach((mode) => { + config.setHouseTopInfoMode?.(mode, settings.houseTopInfoModes[mode]); + }); + HOUSE_BOTTOM_INFO_MODE_IDS.forEach((mode) => { + config.setHouseBottomInfoMode?.(mode, settings.houseBottomInfoModes[mode]); + }); + } + + function getSuitSortIndex(suit) { + const suitIndex = EXTRA_SUIT_ORDER.indexOf(normalizeKey(suit)); + return suitIndex === -1 ? EXTRA_SUIT_ORDER.length : suitIndex; + } + + function getMinorRankSortIndex(rank) { + const rankName = String(rank || "").trim(); + const lookup = { + Two: 2, + Three: 3, + Four: 4, + Five: 5, + Six: 6, + Seven: 7, + Eight: 8, + Nine: 9, + Ten: 10 + }; + return lookup[rankName] || 999; + } + + function getCourtRankSortIndex(rank) { + const lookup = { + Knight: 0, + Queen: 1, + Prince: 2, + Princess: 3 + }; + return lookup[String(rank || "").trim()] ?? 999; + } + + function getGridViewportElement() { + const { tarotFrameBoardEl } = getElements(); + const viewportEl = tarotFrameBoardEl?.querySelector(".tarot-frame-grid-viewport"); + return viewportEl instanceof HTMLElement ? viewportEl : null; + } + + function updateViewportInteractionState() { + const viewportEl = getGridViewportElement(); + if (!(viewportEl instanceof HTMLElement)) { + return; + } + + viewportEl.classList.toggle("is-pan-enabled", Boolean(state.panMode)); + viewportEl.classList.toggle("is-panning", Boolean(state.panGesture)); + viewportEl.classList.toggle( + "is-touch-gesture-active", + Boolean(state.pinchGesture || (state.panGesture && state.panGesture.source === "touch")) + ); + } + + function syncActiveTouchGestureCapture() { + const shouldCapture = Boolean(state.pinchGesture || (state.panGesture && state.panGesture.source === "touch")); + if (shouldCapture === activeTouchGestureCapture) { + return; + } + + activeTouchGestureCapture = shouldCapture; + const method = shouldCapture ? "addEventListener" : "removeEventListener"; + document[method]("touchmove", handleBoardTouchMove, { passive: false }); + document[method]("touchend", handleBoardTouchEnd, { passive: false }); + document[method]("touchcancel", handleBoardTouchCancel, { passive: false }); + } + + function createCardPickerElements() { + if (cardPickerEl) { + return; + } + + cardPickerEl = document.createElement("div"); + cardPickerEl.className = "tarot-frame-card-picker"; + cardPickerEl.hidden = true; + + const headEl = document.createElement("div"); + headEl.className = "tarot-frame-card-picker-head"; + + cardPickerTitleEl = document.createElement("div"); + cardPickerTitleEl.className = "tarot-frame-card-picker-title"; + cardPickerTitleEl.textContent = "Place Tarot Card"; + + const closeButtonEl = document.createElement("button"); + closeButtonEl.type = "button"; + closeButtonEl.className = "tarot-frame-card-picker-close"; + closeButtonEl.textContent = "Close"; + closeButtonEl.addEventListener("click", () => { + closeCardPicker(); + }); + + headEl.append(cardPickerTitleEl, closeButtonEl); + + const searchWrapEl = document.createElement("label"); + searchWrapEl.className = "tarot-frame-card-picker-search"; + const searchLabelEl = document.createElement("span"); + searchLabelEl.textContent = "Search Cards & Associations"; + cardPickerSearchEl = document.createElement("input"); + cardPickerSearchEl.type = "search"; + cardPickerSearchEl.placeholder = "Find by card, planet, sign, decan..."; + cardPickerSearchEl.autocomplete = "off"; + cardPickerSearchEl.spellcheck = false; + cardPickerSearchEl.addEventListener("input", () => { + state.cardPicker.query = String(cardPickerSearchEl.value || ""); + persistCardPickerQuery(); + renderCardPickerSections(); + }); + searchWrapEl.append(searchLabelEl, cardPickerSearchEl); + + cardPickerSectionsEl = document.createElement("div"); + cardPickerSectionsEl.className = "tarot-frame-card-picker-sections"; + + cardPickerEl.append(headEl, searchWrapEl, cardPickerSectionsEl); + cardPickerEl.addEventListener("click", (event) => { + event.stopPropagation(); + const target = event.target; + if (!(target instanceof Element)) { + return; + } + + const option = target.closest(".tarot-frame-card-picker-option[data-card-id]"); + if (!(option instanceof HTMLButtonElement)) { + return; + } + + placeCardInSlot(state.cardPicker.slotId, option.dataset.cardId); + closeCardPicker(); + }); + document.body.appendChild(cardPickerEl); + } + + function closeCardPicker() { + state.cardPicker.open = false; + state.cardPicker.slotId = ""; + if (cardPickerSearchEl) { + cardPickerSearchEl.value = state.cardPicker.query; + } + if (cardPickerEl) { + cardPickerEl.hidden = true; + } + } + + function appendCardPickerSearchValue(terms, value) { + const normalizedValue = normalizeLabelText(value); + if (!normalizedValue) { + return; + } + + terms.add(normalizeKey(normalizedValue)); + } + + function appendCardPickerSearchValuesFromObject(terms, value, depth = 0) { + if (depth > 4 || value === null || value === undefined) { + return; + } + + if (Array.isArray(value)) { + value.forEach((entry) => { + appendCardPickerSearchValuesFromObject(terms, entry, depth + 1); + }); + return; + } + + if (typeof value === "object") { + Object.values(value).forEach((entry) => { + appendCardPickerSearchValuesFromObject(terms, entry, depth + 1); + }); + return; + } + + appendCardPickerSearchValue(terms, value); + } + + function buildCardPickerSearchText(card) { + const terms = new Set(); + + [ + getDisplayCardName(card), + card?.name, + card?.arcana, + card?.rank, + card?.suit, + card?.number, + card?.summary, + card?.hebrewLetterId, + card?.kabbalahPathNumber, + buildHebrewLabel(card)?.primary, + buildHebrewLabel(card)?.secondary, + buildPlanetLabel(card)?.primary, + buildMajorZodiacLabel(card)?.primary, + buildTrumpNumberLabel(card)?.primary, + buildPathNumberLabel(card)?.primary, + buildZodiacLabel(card)?.primary, + buildZodiacLabel(card)?.secondary, + buildDecanLabel(card)?.primary, + buildDecanLabel(card)?.secondary, + buildDateLabel(card)?.primary, + buildDateLabel(card)?.secondary, + buildMonthLabel(card)?.primary, + buildRulerLabel(card)?.primary, + getCardOverlayDate(card) + ].forEach((value) => { + appendCardPickerSearchValue(terms, value); + }); + + appendCardPickerSearchValuesFromObject(terms, card?.relations || []); + + return Array.from(terms).join(" "); + } + + function buildCardPickerSections() { + const cards = getCards(); + const queryTerms = normalizeKey(state.cardPicker.query).split(/\s+/).filter(Boolean); + const matchesQuery = (card) => { + if (!queryTerms.length) { + return true; + } + + const haystack = buildCardPickerSearchText(card); + return queryTerms.every((term) => haystack.includes(term)); + }; + + const majorCards = cards + .filter((card) => card?.arcana === "Major" && matchesQuery(card)) + .sort((left, right) => Number(left?.number) - Number(right?.number)); + + const minorSuitGroups = EXTRA_SUIT_ORDER.map((suitId) => { + const items = cards + .filter((card) => card?.arcana === "Minor" + && MINOR_RANKS.has(String(card?.rank || "")) + && normalizeKey(card?.suit) === suitId + && matchesQuery(card)) + .sort((left, right) => getMinorRankSortIndex(left?.rank) - getMinorRankSortIndex(right?.rank)); + return { + title: normalizeLabelText(items[0]?.suit || suitId), + items + }; + }).filter((group) => group.items.length); + + const courtSuitGroups = EXTRA_SUIT_ORDER.map((suitId) => { + const items = cards + .filter((card) => card?.arcana === "Minor" + && !MINOR_RANKS.has(String(card?.rank || "")) + && String(card?.rank || "").trim() !== "Ace" + && normalizeKey(card?.suit) === suitId + && matchesQuery(card)) + .sort((left, right) => getCourtRankSortIndex(left?.rank) - getCourtRankSortIndex(right?.rank)); + return { + title: normalizeLabelText(items[0]?.suit || suitId), + items + }; + }).filter((group) => group.items.length); + + const aceCards = cards + .filter((card) => card?.arcana === "Minor" && String(card?.rank || "").trim() === "Ace" && matchesQuery(card)) + .sort((left, right) => getSuitSortIndex(left?.suit) - getSuitSortIndex(right?.suit)); + + return [ + { title: "Major Arcana", groups: [{ title: "", items: majorCards }] }, + { title: "Minor Arcana", groups: minorSuitGroups }, + { title: "Court Cards", groups: courtSuitGroups }, + { title: "Aces", groups: [{ title: "", items: aceCards }] } + ].filter((section) => section.groups.some((group) => group.items.length)); + } + + function renderCardPickerSections() { + if (!(cardPickerSectionsEl instanceof HTMLElement)) { + return; + } + + cardPickerSectionsEl.replaceChildren(); + const sections = buildCardPickerSections(); + if (!sections.length) { + const emptyEl = document.createElement("div"); + emptyEl.className = "tarot-frame-card-picker-empty"; + emptyEl.textContent = "No tarot cards match that search."; + cardPickerSectionsEl.appendChild(emptyEl); + return; + } + + sections.forEach((section) => { + const sectionEl = document.createElement("section"); + sectionEl.className = "tarot-frame-card-picker-section"; + + const sectionTitleEl = document.createElement("h4"); + sectionTitleEl.className = "tarot-frame-card-picker-section-title"; + sectionTitleEl.textContent = section.title; + sectionEl.appendChild(sectionTitleEl); + + section.groups.forEach((group) => { + if (!group.items.length) { + return; + } + + if (group.title) { + const groupTitleEl = document.createElement("div"); + groupTitleEl.className = "tarot-frame-card-picker-subtitle"; + groupTitleEl.textContent = group.title; + sectionEl.appendChild(groupTitleEl); + } + + const gridEl = document.createElement("div"); + gridEl.className = "tarot-frame-card-picker-grid"; + + group.items.forEach((card) => { + const buttonEl = document.createElement("button"); + buttonEl.type = "button"; + buttonEl.className = "tarot-frame-card-picker-option"; + buttonEl.dataset.cardId = getCardId(card); + + const titleEl = document.createElement("strong"); + titleEl.textContent = getDisplayCardName(card); + const metaEl = document.createElement("span"); + metaEl.textContent = card?.arcana === "Major" + ? `Trump ${Number.isFinite(Number(card?.number)) ? Number(card.number) : ""}`.trim() + : [normalizeLabelText(card?.rank), normalizeLabelText(card?.suit)].filter(Boolean).join(" · "); + buttonEl.append(titleEl, metaEl); + gridEl.appendChild(buttonEl); + }); + + sectionEl.appendChild(gridEl); + }); + + cardPickerSectionsEl.appendChild(sectionEl); + }); + } + + function positionCardPicker(anchorX, anchorY) { + if (!(cardPickerEl instanceof HTMLElement)) { + return; + } + + cardPickerEl.hidden = false; + cardPickerEl.style.visibility = "hidden"; + requestAnimationFrame(() => { + if (!(cardPickerEl instanceof HTMLElement)) { + return; + } + + const panelWidth = cardPickerEl.offsetWidth || 360; + const panelHeight = cardPickerEl.offsetHeight || 420; + const margin = 12; + const viewportWidth = window.innerWidth; + const viewportHeight = window.innerHeight; + let left = Math.min(Math.max(anchorX + 10, margin), viewportWidth - panelWidth - margin); + let top = Math.min(Math.max(anchorY + 10, margin), viewportHeight - panelHeight - margin); + if (top > viewportHeight - panelHeight - margin) { + top = Math.max(margin, anchorY - panelHeight - 10); + } + if (viewportWidth <= 760) { + left = Math.max(margin, Math.round((viewportWidth - panelWidth) / 2)); + } + + cardPickerEl.style.left = `${left}px`; + cardPickerEl.style.top = `${top}px`; + cardPickerEl.style.visibility = "visible"; + }); + } + + function openCardPicker(slotId, anchorX, anchorY) { + createCardPickerElements(); + state.cardPicker.open = true; + state.cardPicker.slotId = String(slotId || "").trim(); + if (cardPickerTitleEl) { + cardPickerTitleEl.textContent = `Place Tarot Card at ${describeSlot(slotId)}`; + } + if (cardPickerSearchEl) { + cardPickerSearchEl.value = state.cardPicker.query; + } + renderCardPickerSections(); + positionCardPicker(anchorX, anchorY); + requestAnimationFrame(() => { + cardPickerSearchEl?.focus({ preventScroll: true }); + }); + } + + function findAssignedSlotIdByCardId(cardId) { + const targetCardId = String(cardId || "").trim(); + if (!targetCardId) { + return ""; + } + + for (const [slotId, assignedCardId] of state.slotAssignments.entries()) { + if (String(assignedCardId || "").trim() === targetCardId) { + return slotId; + } + } + return ""; + } + + function placeCardInSlot(slotId, cardId) { + const targetSlotId = String(slotId || "").trim(); + const targetCardId = String(cardId || "").trim(); + if (!isValidSlotId(targetSlotId) || !targetCardId) { + return; + } + + const card = getCardMap(getCards()).get(targetCardId) || null; + if (!card) { + return; + } + + const previousSlotId = findAssignedSlotIdByCardId(targetCardId); + if (previousSlotId && previousSlotId !== targetSlotId) { + state.slotAssignments.delete(previousSlotId); + } + + state.slotAssignments.set(targetSlotId, targetCardId); + state.layoutReady = true; + render({ preserveViewport: true }); + syncControls(); + setStatus(`${getDisplayCardName(card)} placed at ${describeSlot(targetSlotId)}.`); + } + + function clearGrid() { + if (!state.slotAssignments.size) { + setStatus("The Tarot Frame grid is already empty."); + return; + } + + const shouldClear = window.confirm("Clear every card from the current Tarot Frame grid?"); + if (!shouldClear) { + return; + } + + state.slotAssignments.clear(); + state.layoutReady = true; + render(); + syncControls(); + setStatus("Tarot Frame grid cleared. Use the card picker or a layout preset to repopulate it."); + } + + function clearLongPressGesture() { + if (state.longPress?.timerId) { + window.clearTimeout(state.longPress.timerId); + } + document.removeEventListener("pointermove", handleLongPressPointerMove); + document.removeEventListener("pointerup", handleLongPressPointerUp); + document.removeEventListener("pointercancel", handleLongPressPointerCancel); + state.longPress = null; + } + + function scheduleLongPress(slotId, event) { + clearLongPressGesture(); + document.addEventListener("pointermove", handleLongPressPointerMove); + document.addEventListener("pointerup", handleLongPressPointerUp); + document.addEventListener("pointercancel", handleLongPressPointerCancel); + state.longPress = { + pointerId: event.pointerId, + slotId, + startX: event.clientX, + startY: event.clientY, + timerId: window.setTimeout(() => { + const activeGesture = state.longPress; + clearLongPressGesture(); + if (!activeGesture) { + return; + } + state.suppressClick = true; + openCardPicker(activeGesture.slotId, activeGesture.startX, activeGesture.startY); + }, FRAME_LONG_PRESS_DELAY_MS) + }; + } + + function updateLongPress(event) { + if (!state.longPress || event.pointerId !== state.longPress.pointerId) { + return; + } + + if (Math.hypot(event.clientX - state.longPress.startX, event.clientY - state.longPress.startY) > FRAME_LONG_PRESS_MOVE_TOLERANCE) { + clearLongPressGesture(); + } + } + + function finishLongPress(event) { + if (!state.longPress || event.pointerId !== state.longPress.pointerId) { + return; + } + clearLongPressGesture(); + } + + function handleLongPressPointerMove(event) { + updateLongPress(event); + } + + function handleLongPressPointerUp(event) { + finishLongPress(event); + } + + function handleLongPressPointerCancel(event) { + finishLongPress(event); + } + + function getTouchPanAnchor(touches) { + if (!touches || touches.length < 1) { + return null; + } + + let totalX = 0; + let totalY = 0; + let count = 0; + + Array.from(touches).forEach((touch) => { + if (!touch) { + return; + } + totalX += Number(touch.clientX) || 0; + totalY += Number(touch.clientY) || 0; + count += 1; + }); + + if (!count) { + return null; + } + + return { + x: totalX / count, + y: totalY / count + }; + } + + 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 startPanGesture(event, options = {}) { + const viewportEl = getGridViewportElement(); + if (!(viewportEl instanceof HTMLElement)) { + return; + } + + clearLongPressGesture(); + if (state.drag) { + cleanupDrag(); + } + + const source = String(options.source || "pointer").trim() || "pointer"; + const startX = Number(options.startX ?? event?.clientX); + const startY = Number(options.startY ?? event?.clientY); + state.panGesture = { + source, + pointerId: source === "pointer" ? event?.pointerId : null, + startX, + startY, + startScrollLeft: viewportEl.scrollLeft, + startScrollTop: viewportEl.scrollTop + }; + updateViewportInteractionState(); + syncActiveTouchGestureCapture(); + if (source === "pointer") { + document.addEventListener("pointermove", handlePanPointerMove); + document.addEventListener("pointerup", handlePanPointerUp); + document.addEventListener("pointercancel", handlePanPointerCancel); + event?.preventDefault?.(); + } + } + + function startTouchPanGesture(event) { + const anchor = getTouchPanAnchor(event?.touches); + if (!anchor) { + return; + } + + startPanGesture(null, { + source: "touch", + startX: anchor.x, + startY: anchor.y + }); + state.suppressClick = true; + event.preventDefault(); + } + + function startTouchPinchGesture(event) { + const anchor = getTouchPanAnchor(event?.touches); + const distance = getTouchDistance(event?.touches); + const viewportEl = getGridViewportElement(); + if (!anchor || !(distance > 0) || !(viewportEl instanceof HTMLElement)) { + return; + } + + clearLongPressGesture(); + if (state.drag) { + cleanupDrag(); + } + + state.pinchGesture = { + startDistance: distance, + startScale: getGridZoomScale(), + startAnchorX: anchor.x, + startAnchorY: anchor.y, + startScrollLeft: viewportEl.scrollLeft, + startScrollTop: viewportEl.scrollTop + }; + finishPanGesture(); + syncActiveTouchGestureCapture(); + updateViewportInteractionState(); + state.suppressClick = true; + event.preventDefault(); + } + + function finishTouchPinchGesture() { + state.pinchGesture = null; + syncActiveTouchGestureCapture(); + updateViewportInteractionState(); + } + + function finishPanGesture() { + if (!state.panGesture) { + return; + } + + state.panGesture = null; + document.removeEventListener("pointermove", handlePanPointerMove); + document.removeEventListener("pointerup", handlePanPointerUp); + document.removeEventListener("pointercancel", handlePanPointerCancel); + syncActiveTouchGestureCapture(); + updateViewportInteractionState(); + } + + function handlePanPointerMove(event) { + if (!state.panGesture || state.panGesture.source !== "pointer" || event.pointerId !== state.panGesture.pointerId) { + return; + } + + const viewportEl = getGridViewportElement(); + if (!(viewportEl instanceof HTMLElement)) { + finishPanGesture(); + return; + } + + viewportEl.scrollLeft = state.panGesture.startScrollLeft - (event.clientX - state.panGesture.startX); + viewportEl.scrollTop = state.panGesture.startScrollTop - (event.clientY - state.panGesture.startY); + state.suppressClick = true; + event.preventDefault(); + } + + function handlePanPointerUp(event) { + if (!state.panGesture || state.panGesture.source !== "pointer" || event.pointerId !== state.panGesture.pointerId) { + return; + } + finishPanGesture(); + } + + function handlePanPointerCancel(event) { + if (!state.panGesture || state.panGesture.source !== "pointer" || event.pointerId !== state.panGesture.pointerId) { + return; + } + finishPanGesture(); + } + + function handleBoardTouchStart(event) { + if (event.touches.length >= 3) { + startTouchPanGesture(event); + return; + } + + if (event.touches.length !== 2) { + return; + } + + startTouchPinchGesture(event); + } + + function handleBoardTouchMove(event) { + if (state.pinchGesture) { + if (event.touches.length >= 3) { + finishTouchPinchGesture(); + startTouchPanGesture(event); + return; + } + + if (event.touches.length !== 2) { + finishTouchPinchGesture(); + return; + } + + const anchor = getTouchPanAnchor(event.touches); + const distance = getTouchDistance(event.touches); + const viewportEl = getGridViewportElement(); + if (!anchor || !(distance > 0) || !(viewportEl instanceof HTMLElement)) { + finishTouchPinchGesture(); + return; + } + + const pinchRatio = distance / state.pinchGesture.startDistance; + const nextScale = clampFrameGridZoomScale(state.pinchGesture.startScale * pinchRatio); + setGridZoomScale(nextScale, { + preserveViewport: false, + anchorClientX: anchor.x, + anchorClientY: anchor.y, + statusMessage: "" + }); + state.suppressClick = true; + event.preventDefault(); + return; + } + + if (!state.panGesture || state.panGesture.source !== "touch") { + return; + } + + if (event.touches.length === 2) { + finishPanGesture(); + startTouchPinchGesture(event); + return; + } + + if (event.touches.length < 3) { + finishPanGesture(); + return; + } + + const anchor = getTouchPanAnchor(event.touches); + if (!anchor) { + finishPanGesture(); + return; + } + + const viewportEl = getGridViewportElement(); + if (!(viewportEl instanceof HTMLElement)) { + finishPanGesture(); + return; + } + + viewportEl.scrollLeft = state.panGesture.startScrollLeft - (anchor.x - state.panGesture.startX); + viewportEl.scrollTop = state.panGesture.startScrollTop - (anchor.y - state.panGesture.startY); + state.suppressClick = true; + event.preventDefault(); + } + + function handleBoardTouchEnd(event) { + if (state.pinchGesture) { + if (event.touches.length >= 3) { + finishTouchPinchGesture(); + startTouchPanGesture(event); + return; + } + + if (event.touches.length === 2) { + startTouchPinchGesture(event); + return; + } + + finishTouchPinchGesture(); + return; + } + + if (!state.panGesture || state.panGesture.source !== "touch") { + return; + } + + if (event.touches.length >= 3) { + startTouchPanGesture(event); + return; + } + + if (event.touches.length === 2) { + finishPanGesture(); + startTouchPinchGesture(event); + return; + } + + finishPanGesture(); + } + + function handleBoardTouchCancel() { + finishTouchPinchGesture(); + if (!state.panGesture || state.panGesture.source !== "touch") { + return; + } + + finishPanGesture(); + } + function normalizeLookupCardName(value) { return String(value || "") .trim() @@ -545,7 +1793,19 @@ } function getLayoutPreset(layoutId = state.currentLayoutId) { - return LAYOUT_PRESETS.find((preset) => preset.id === normalizeKey(layoutId)) || LAYOUT_PRESETS[0]; + return LAYOUT_PRESETS.find((preset) => preset.id === normalizeKey(layoutId)) || null; + } + + function getSavedLayout(layoutId = state.currentLayoutId) { + return state.customLayouts.find((layout) => layout.id === String(layoutId || "").trim()) || null; + } + + function getLayoutDefinition(layoutId = state.currentLayoutId) { + return getSavedLayout(layoutId) || getLayoutPreset(layoutId) || LAYOUT_PRESETS[0]; + } + + function isCustomLayout(layoutId = state.currentLayoutId) { + return Boolean(getSavedLayout(layoutId)); } function buildCardSignature(cards) { @@ -957,7 +2217,7 @@ } function applyLayoutPreset(layoutId = state.currentLayoutId, cards = getCards(), nextStatusMessage = "") { - const layoutPreset = getLayoutPreset(layoutId); + const layoutPreset = getLayoutPreset(layoutId) || LAYOUT_PRESETS[0]; state.currentLayoutId = layoutPreset.id; state.slotAssignments.clear(); @@ -966,11 +2226,210 @@ }); state.layoutReady = true; + persistActiveLayoutId(layoutPreset.id); setStatus(nextStatusMessage || layoutPreset.statusMessage || buildReadyStatus(cards)); } + function applySavedLayout(layoutId = state.currentLayoutId, cards = getCards(), nextStatusMessage = "") { + const savedLayout = getSavedLayout(layoutId); + if (!savedLayout) { + applyLayoutPreset("frames", cards, nextStatusMessage); + return; + } + + const cardMap = getCardMap(cards); + state.currentLayoutId = savedLayout.id; + state.slotAssignments.clear(); + savedLayout.slotAssignments.forEach((entry) => { + if (cardMap.has(entry.cardId)) { + state.slotAssignments.set(entry.slotId, entry.cardId); + } + }); + applyFrameSettingsSnapshot(savedLayout.settings); + state.layoutReady = true; + persistActiveLayoutId(savedLayout.id); + setStatus(nextStatusMessage || savedLayout.statusMessage || `${savedLayout.label} layout applied to the master grid.`); + } + + function applyLayoutSelection(layoutId = state.currentLayoutId, cards = getCards(), nextStatusMessage = "") { + if (isCustomLayout(layoutId)) { + applySavedLayout(layoutId, cards, nextStatusMessage); + return; + } + + applyLayoutPreset(layoutId, cards, nextStatusMessage); + } + function resetLayout(cards = getCards(), nextStatusMessage = "") { - applyLayoutPreset(state.currentLayoutId, cards, nextStatusMessage); + applyLayoutSelection(state.currentLayoutId, cards, nextStatusMessage); + } + + function buildSavedLayoutMenuDescription(layout) { + const zoomLabel = Number.isFinite(Number(layout?.settings?.gridZoomScale)) + ? `${Math.round(clampFrameGridZoomScale(layout.settings.gridZoomScale) * 100)}% zoom` + : (Number.isFinite(Number(layout?.settings?.gridZoomStepIndex)) + ? `${Math.round((FRAME_GRID_ZOOM_STEPS[layout.settings.gridZoomStepIndex] || FRAME_GRID_ZOOM_STEPS[0]) * 100)}% zoom` + : "saved settings"); + const infoLabel = layout?.settings?.showInfo === false ? "info hidden" : "info visible"; + const noteLabel = normalizeLayoutNote(layout?.note || state.layoutNotesById?.[layout?.id]) ? "note added" : "no note"; + return `${layout?.slotAssignments?.length || 0} saved slots · ${zoomLabel} · ${infoLabel} · ${noteLabel}`; + } + + function createLayoutOptionButton(layout, isActive) { + const button = document.createElement("button"); + button.type = "button"; + button.className = "tarot-frame-layout-option"; + button.dataset.layoutId = layout.id; + button.setAttribute("role", "menuitemradio"); + button.setAttribute("aria-checked", isActive ? "true" : "false"); + button.classList.toggle("is-active", isActive); + button.disabled = Boolean(state.exportInProgress); + + const titleEl = document.createElement("strong"); + titleEl.textContent = layout.label; + const descriptionEl = document.createElement("span"); + descriptionEl.textContent = layout.isCustom + ? buildSavedLayoutMenuDescription(layout) + : (layout.id === "house" + ? "The legacy house composition rebuilt inside the 14x14 snap grid." + : "The current master frame with top-row extras and nested chronological rings."); + button.append(titleEl, descriptionEl); + return button; + } + + function renderLayoutPanel() { + const { tarotFrameLayoutPanelEl } = getElements(); + if (!(tarotFrameLayoutPanelEl instanceof HTMLElement)) { + return; + } + + tarotFrameLayoutPanelEl.replaceChildren(); + + const saveButtonEl = document.createElement("button"); + saveButtonEl.type = "button"; + saveButtonEl.className = "tarot-frame-layout-save-btn"; + saveButtonEl.dataset.layoutSaveAction = "true"; + saveButtonEl.textContent = "Save Current Layout"; + saveButtonEl.disabled = Boolean(state.exportInProgress); + tarotFrameLayoutPanelEl.appendChild(saveButtonEl); + + const builtInHeadingEl = document.createElement("div"); + builtInHeadingEl.className = "tarot-frame-layout-section-title"; + builtInHeadingEl.textContent = "Built-in Layouts"; + tarotFrameLayoutPanelEl.appendChild(builtInHeadingEl); + + LAYOUT_PRESETS.forEach((layout) => { + tarotFrameLayoutPanelEl.appendChild(createLayoutOptionButton(layout, state.currentLayoutId === layout.id)); + }); + + const savedHeadingEl = document.createElement("div"); + savedHeadingEl.className = "tarot-frame-layout-section-title"; + savedHeadingEl.textContent = "Saved Layouts"; + tarotFrameLayoutPanelEl.appendChild(savedHeadingEl); + + if (!state.customLayouts.length) { + const emptyEl = document.createElement("div"); + emptyEl.className = "tarot-frame-layout-empty-note"; + emptyEl.textContent = "Save a layout to keep custom card positions and frame settings in this browser."; + tarotFrameLayoutPanelEl.appendChild(emptyEl); + return; + } + + state.customLayouts.forEach((layout) => { + const rowEl = document.createElement("div"); + rowEl.className = "tarot-frame-layout-entry"; + rowEl.appendChild(createLayoutOptionButton(layout, state.currentLayoutId === layout.id)); + + const deleteButtonEl = document.createElement("button"); + deleteButtonEl.type = "button"; + deleteButtonEl.className = "tarot-frame-layout-delete-btn"; + deleteButtonEl.dataset.layoutDeleteId = layout.id; + deleteButtonEl.textContent = "Delete"; + deleteButtonEl.disabled = Boolean(state.exportInProgress); + deleteButtonEl.setAttribute("aria-label", `Delete saved layout ${layout.label}`); + rowEl.appendChild(deleteButtonEl); + tarotFrameLayoutPanelEl.appendChild(rowEl); + }); + } + + function saveCurrentLayout() { + const cards = getCards(); + if (!cards.length) { + setStatus("Tarot cards are still loading..."); + return; + } + + const activeSavedLayout = getSavedLayout(state.currentLayoutId); + const suggestedName = activeSavedLayout?.label || ""; + const inputName = window.prompt("Save current Tarot Frame layout as:", suggestedName); + if (inputName === null) { + return; + } + + const label = normalizeLayoutLabel(inputName); + if (!label) { + setStatus("Layout save cancelled. Enter a name to save this arrangement."); + return; + } + + const existingLayout = state.customLayouts.find((layout) => normalizeKey(layout.label) === normalizeKey(label)) || null; + if (existingLayout && existingLayout.id !== activeSavedLayout?.id) { + const shouldOverwrite = window.confirm(`Replace the saved layout \"${existingLayout.label}\"?`); + if (!shouldOverwrite) { + return; + } + } + + const savedLayout = normalizeSavedLayoutRecord({ + id: existingLayout?.id || activeSavedLayout?.id || createSavedLayoutId(), + label, + slotAssignments: captureSlotAssignmentsSnapshot(cards), + settings: buildFrameSettingsSnapshot(), + note: getLayoutNote(state.currentLayoutId), + createdAt: existingLayout?.createdAt || activeSavedLayout?.createdAt || new Date().toISOString() + }); + if (!savedLayout) { + setStatus("Unable to save this layout."); + return; + } + + state.customLayouts = [...state.customLayouts.filter((layout) => layout.id !== savedLayout.id), savedLayout] + .sort((left, right) => String(left.label || "").localeCompare(String(right.label || ""))); + state.currentLayoutId = savedLayout.id; + setLayoutNote(savedLayout.id, savedLayout.note, { updateUi: false }); + persistSavedLayouts(); + persistActiveLayoutId(savedLayout.id); + render(); + syncControls(); + setStatus(`Saved layout \"${savedLayout.label}\" to this browser.`); + } + + function deleteSavedLayout(layoutId) { + const savedLayout = getSavedLayout(layoutId); + if (!savedLayout) { + return; + } + + const shouldDelete = window.confirm(`Delete the saved layout \"${savedLayout.label}\" from this browser?`); + if (!shouldDelete) { + return; + } + + state.customLayouts = state.customLayouts.filter((layout) => layout.id !== savedLayout.id); + delete state.layoutNotesById[savedLayout.id]; + persistSavedLayouts(); + persistLayoutNotes(); + + const cards = getCards(); + if (state.currentLayoutId === savedLayout.id) { + applyLayoutPreset("frames", cards, `Deleted saved layout \"${savedLayout.label}\". Frames layout applied to the master grid.`); + render(); + syncControls(); + return; + } + + syncControls(); + setStatus(`Deleted saved layout \"${savedLayout.label}\" from this browser.`); } function getAssignedCard(slotId, cardMap) { @@ -1086,6 +2545,109 @@ }); } + function captureGridViewportSnapshot() { + const viewportEl = getGridViewportElement(); + if (!(viewportEl instanceof HTMLElement)) { + return null; + } + + return { + scrollLeft: viewportEl.scrollLeft, + scrollTop: viewportEl.scrollTop + }; + } + + function captureGridViewportAnchor(clientX, clientY, scale = getGridZoomScale()) { + const viewportEl = getGridViewportElement(); + if (!(viewportEl instanceof HTMLElement)) { + return null; + } + + const rect = viewportEl.getBoundingClientRect(); + if (!(rect.width > 0 && rect.height > 0 && scale > 0)) { + return null; + } + + const offsetX = Math.min(Math.max((Number(clientX) || 0) - rect.left, 0), rect.width); + const offsetY = Math.min(Math.max((Number(clientY) || 0) - rect.top, 0), rect.height); + + return { + offsetX, + offsetY, + contentX: (viewportEl.scrollLeft + offsetX) / scale, + contentY: (viewportEl.scrollTop + offsetY) / scale + }; + } + + function cancelPendingGridViewportRestore() { + if (!pendingGridViewportRestoreFrameId) { + return; + } + + window.cancelAnimationFrame(pendingGridViewportRestoreFrameId); + pendingGridViewportRestoreFrameId = 0; + } + + function applyClampedGridViewportScroll(viewportEl, scrollLeft, scrollTop) { + if (!(viewportEl instanceof HTMLElement)) { + return; + } + + const maxScrollLeft = Math.max(0, viewportEl.scrollWidth - viewportEl.clientWidth); + const maxScrollTop = Math.max(0, viewportEl.scrollHeight - viewportEl.clientHeight); + viewportEl.scrollLeft = Math.min(Math.max(Number(scrollLeft) || 0, 0), maxScrollLeft); + viewportEl.scrollTop = Math.min(Math.max(Number(scrollTop) || 0, 0), maxScrollTop); + } + + function restoreGridViewport(snapshot) { + if (!snapshot) { + return; + } + + const viewportEl = getGridViewportElement(); + if (!(viewportEl instanceof HTMLElement)) { + return; + } + + cancelPendingGridViewportRestore(); + applyClampedGridViewportScroll(viewportEl, snapshot.scrollLeft, snapshot.scrollTop); + + pendingGridViewportRestoreFrameId = window.requestAnimationFrame(() => { + pendingGridViewportRestoreFrameId = 0; + const activeViewportEl = getGridViewportElement(); + if (!(activeViewportEl instanceof HTMLElement)) { + return; + } + + applyClampedGridViewportScroll(activeViewportEl, snapshot.scrollLeft, snapshot.scrollTop); + }); + } + + function restoreGridViewportAnchor(anchorSnapshot, scale = getGridZoomScale()) { + if (!anchorSnapshot) { + return; + } + + const applyAnchor = () => { + const viewportEl = getGridViewportElement(); + if (!(viewportEl instanceof HTMLElement) || !(scale > 0)) { + return; + } + + const targetScrollLeft = (Number(anchorSnapshot.contentX) * scale) - Number(anchorSnapshot.offsetX); + const targetScrollTop = (Number(anchorSnapshot.contentY) * scale) - Number(anchorSnapshot.offsetY); + applyClampedGridViewportScroll(viewportEl, targetScrollLeft, targetScrollTop); + }; + + cancelPendingGridViewportRestore(); + applyAnchor(); + + pendingGridViewportRestoreFrameId = window.requestAnimationFrame(() => { + pendingGridViewportRestoreFrameId = 0; + applyAnchor(); + }); + } + function createCardTextFaceElement(faceModel) { const faceEl = document.createElement("span"); faceEl.className = `tarot-frame-card-text-face${faceModel?.className ? ` ${faceModel.className}` : ""}`; @@ -1130,6 +2692,7 @@ if (!card) { slotEl.classList.add("is-empty-slot"); button.classList.add("is-empty"); + button.setAttribute("aria-label", `Empty slot at row ${row}, column ${column}`); button.tabIndex = -1; const emptyEl = document.createElement("span"); emptyEl.className = "tarot-frame-slot-empty"; @@ -1191,39 +2754,108 @@ return legendEl; } - function render() { - const { tarotFrameBoardEl } = getElements(); - if (!tarotFrameBoardEl) { - return; - } + function createOverview(layoutPreset, cards = getCards()) { + const overviewEl = document.createElement("section"); + overviewEl.className = "tarot-frame-overview"; - const cards = getCards(); - const cardMap = getCardMap(cards); - const layoutPreset = getLayoutPreset(); - tarotFrameBoardEl.replaceChildren(); - - const panelEl = document.createElement("section"); - panelEl.className = "tarot-frame-panel tarot-frame-panel--master"; - panelEl.style.setProperty("--frame-grid-zoom-scale", String(getGridZoomScale())); + const summaryEl = document.createElement("div"); + summaryEl.className = "tarot-frame-overview-summary"; const headEl = document.createElement("div"); - headEl.className = "tarot-frame-panel-head"; + headEl.className = "tarot-frame-overview-head"; const titleWrapEl = document.createElement("div"); + const eyebrowEl = document.createElement("div"); + eyebrowEl.className = "tarot-frame-overview-eyebrow"; + eyebrowEl.textContent = layoutPreset?.isCustom ? "Saved Layout" : "Layout Guide"; const titleEl = document.createElement("h3"); titleEl.className = "tarot-frame-panel-title"; titleEl.textContent = layoutPreset.title; const subtitleEl = document.createElement("p"); subtitleEl.className = "tarot-frame-panel-subtitle"; subtitleEl.textContent = layoutPreset.subtitle; - titleWrapEl.append(titleEl, subtitleEl); + titleWrapEl.append(eyebrowEl, titleEl, subtitleEl); const countEl = document.createElement("span"); countEl.className = "tarot-frame-panel-count"; countEl.textContent = buildPanelCountText(cards); headEl.append(titleWrapEl, countEl); + summaryEl.appendChild(headEl); - panelEl.append(headEl, createLegend(layoutPreset)); + if (Array.isArray(layoutPreset.legendItems) && layoutPreset.legendItems.length) { + summaryEl.appendChild(createLegend(layoutPreset)); + } + + const notesEl = document.createElement("section"); + notesEl.className = "tarot-frame-notes-card"; + + const notesHeadEl = document.createElement("div"); + notesHeadEl.className = "tarot-frame-notes-head"; + const notesTitleWrapEl = document.createElement("div"); + const notesTitleEl = document.createElement("h4"); + notesTitleEl.className = "tarot-frame-notes-title"; + notesTitleEl.textContent = "Layout Notes"; + const notesCopyEl = document.createElement("p"); + notesCopyEl.className = "tarot-frame-notes-copy"; + notesCopyEl.textContent = "Saved automatically in this browser for the current layout."; + notesTitleWrapEl.append(notesTitleEl, notesCopyEl); + const notesBadgeEl = document.createElement("span"); + notesBadgeEl.className = "tarot-frame-notes-badge"; + notesBadgeEl.textContent = getLayoutNote() ? "Saved" : "Optional"; + notesHeadEl.append(notesTitleWrapEl, notesBadgeEl); + + const noteFieldEl = document.createElement("label"); + noteFieldEl.className = "tarot-frame-notes-field"; + const noteLabelEl = document.createElement("span"); + noteLabelEl.textContent = "Custom text / notes"; + const noteInputEl = document.createElement("textarea"); + noteInputEl.id = "tarot-frame-layout-note"; + noteInputEl.rows = 7; + noteInputEl.maxLength = 1600; + noteInputEl.placeholder = getLayoutNotePlaceholder(layoutPreset); + noteInputEl.value = getLayoutNote(); + noteInputEl.disabled = Boolean(state.exportInProgress); + noteFieldEl.append(noteLabelEl, noteInputEl); + + const notesFooterEl = document.createElement("div"); + notesFooterEl.className = "tarot-frame-notes-footer"; + const notesHintEl = document.createElement("span"); + notesHintEl.textContent = layoutPreset?.isCustom + ? "This note stays with the saved layout and reopens with it." + : "Use this area for placement reminders, timing, or custom reading instructions."; + const clearButtonEl = document.createElement("button"); + clearButtonEl.type = "button"; + clearButtonEl.className = "tarot-frame-notes-clear"; + clearButtonEl.dataset.frameNoteClear = "true"; + clearButtonEl.textContent = "Clear Note"; + clearButtonEl.disabled = !getLayoutNote() || Boolean(state.exportInProgress); + notesFooterEl.append(notesHintEl, clearButtonEl); + + notesEl.append(notesHeadEl, noteFieldEl, notesFooterEl); + overviewEl.append(summaryEl, notesEl); + return overviewEl; + } + + function render(options = {}) { + const { tarotFrameBoardEl, tarotFrameOverviewEl } = getElements(); + if (!tarotFrameBoardEl || !tarotFrameOverviewEl) { + return; + } + + const preserveViewport = options.preserveViewport === true; + const viewportSnapshot = preserveViewport ? captureGridViewportSnapshot() : null; + + const cards = getCards(); + const cardMap = getCardMap(cards); + const layoutPreset = getLayoutDefinition(); + tarotFrameOverviewEl.replaceChildren(); + tarotFrameBoardEl.replaceChildren(); + + tarotFrameOverviewEl.appendChild(createOverview(layoutPreset, cards)); + + const panelEl = document.createElement("section"); + panelEl.className = "tarot-frame-panel tarot-frame-panel--master"; + panelEl.style.setProperty("--frame-grid-zoom-scale", String(getGridZoomScale())); const gridViewportEl = document.createElement("div"); gridViewportEl.className = "tarot-frame-grid-viewport"; @@ -1246,35 +2878,75 @@ gridViewportEl.appendChild(gridTrackEl); panelEl.appendChild(gridViewportEl); tarotFrameBoardEl.appendChild(panelEl); + updateViewportInteractionState(); + if (preserveViewport) { + restoreGridViewport(viewportSnapshot); + return; + } + centerGridViewport(); } - function applyGridZoomState() { - const { tarotFrameBoardEl } = getElements(); + function applyGridZoomState(options = {}) { + const { tarotFrameBoardEl, tarotFrameOverviewEl } = getElements(); const panelEl = tarotFrameBoardEl?.querySelector(".tarot-frame-panel--master"); if (!(panelEl instanceof HTMLElement)) { return; } + const anchorSnapshot = options.anchorSnapshot || null; + const preserveViewport = options.preserveViewport !== false; + const viewportSnapshot = preserveViewport ? captureGridViewportSnapshot() : null; + panelEl.style.setProperty("--frame-grid-zoom-scale", String(getGridZoomScale())); - const countEl = panelEl.querySelector(".tarot-frame-panel-count"); + const countEl = tarotFrameOverviewEl?.querySelector(".tarot-frame-panel-count"); if (countEl instanceof HTMLElement) { countEl.textContent = buildPanelCountText(); } + if (anchorSnapshot) { + restoreGridViewportAnchor(anchorSnapshot, getGridZoomScale()); + return; + } + + if (preserveViewport) { + restoreGridViewport(viewportSnapshot); + return; + } + centerGridViewport(); } + function setGridZoomScale(nextScale, options = {}) { + const anchorSnapshot = options.anchorClientX === undefined || options.anchorClientY === undefined + ? null + : captureGridViewportAnchor(options.anchorClientX, options.anchorClientY, getGridZoomScale()); + const safeScale = clampFrameGridZoomScale(nextScale); + state.gridZoomScale = safeScale; + state.gridZoomStepIndex = getNearestFrameZoomStepIndex(safeScale); + applyGridZoomState({ + preserveViewport: options.preserveViewport !== false, + anchorSnapshot + }); + if (options.statusMessage !== "") { + setStatus(options.statusMessage || `Frame grid zoom ${Math.round(getGridZoomScale() * 100)}%. This setting applies to every Frame layout.`); + } + } + function setGridZoomStepIndex(nextIndex) { const safeIndex = Math.max(0, Math.min(FRAME_GRID_ZOOM_STEPS.length - 1, Number(nextIndex) || 0)); state.gridZoomStepIndex = safeIndex; - applyGridZoomState(); + state.gridZoomScale = FRAME_GRID_ZOOM_STEPS[safeIndex] || FRAME_GRID_ZOOM_STEPS[0]; + applyGridZoomState({ preserveViewport: true }); setStatus(`Frame grid zoom ${Math.round(getGridZoomScale() * 100)}%. This setting applies to every Frame layout.`); } function syncControls() { const { + tarotFramePanToggleEl, + tarotFrameFocusToggleEl, + tarotFrameFocusExitEl, tarotFrameLayoutToggleEl, tarotFrameLayoutPanelEl, tarotFrameSettingsToggleEl, @@ -1295,27 +2967,33 @@ tarotFrameHouseBottomInfoMonthEl, tarotFrameHouseBottomInfoRulerEl, tarotFrameHouseBottomInfoDateEl, + tarotFrameClearGridEl, tarotFrameExportWebpEl, } = getElements(); - const layoutPreset = getLayoutPreset(); + const activeLayout = getLayoutDefinition(); + + if (tarotFramePanToggleEl) { + tarotFramePanToggleEl.setAttribute("aria-pressed", state.panMode ? "true" : "false"); + tarotFramePanToggleEl.classList.toggle("is-active", state.panMode); + tarotFramePanToggleEl.textContent = state.panMode ? "Panning" : "Pan Grid"; + tarotFramePanToggleEl.disabled = Boolean(state.exportInProgress); + } + + if (tarotFrameFocusToggleEl || tarotFrameFocusExitEl) { + applyGridFocusModeUi(); + } if (tarotFrameLayoutToggleEl) { tarotFrameLayoutToggleEl.setAttribute("aria-expanded", state.layoutMenuOpen ? "true" : "false"); - tarotFrameLayoutToggleEl.textContent = `Layout: ${layoutPreset.label}`; + tarotFrameLayoutToggleEl.textContent = `Layout: ${activeLayout.label}`; tarotFrameLayoutToggleEl.disabled = Boolean(state.exportInProgress); } if (tarotFrameLayoutPanelEl) { tarotFrameLayoutPanelEl.hidden = !state.layoutMenuOpen; + renderLayoutPanel(); } - getLayoutOptionElements().forEach((button) => { - const isActive = String(button.dataset.layoutPresetId || "") === layoutPreset.id; - button.classList.toggle("is-active", isActive); - button.setAttribute("aria-checked", isActive ? "true" : "false"); - button.disabled = Boolean(state.exportInProgress); - }); - if (tarotFrameSettingsToggleEl) { tarotFrameSettingsToggleEl.setAttribute("aria-expanded", state.settingsOpen ? "true" : "false"); tarotFrameSettingsToggleEl.textContent = state.settingsOpen ? "Hide Settings" : "Settings"; @@ -1370,6 +3048,10 @@ tarotFrameHouseBottomCardsVisibleEl.disabled = Boolean(state.exportInProgress); } + if (tarotFrameClearGridEl) { + tarotFrameClearGridEl.disabled = Boolean(state.exportInProgress); + } + if (tarotFrameExportWebpEl) { const supportsWebp = isExportFormatSupported("webp"); tarotFrameExportWebpEl.hidden = !supportsWebp; @@ -1379,6 +3061,8 @@ tarotFrameExportWebpEl.title = "Download the current frame grid arrangement as a WebP image."; } } + + updateLayoutNotesUi(); } function getSlotElement(slotId) { @@ -1523,12 +3207,31 @@ function handlePointerDown(event) { const target = event.target; - if (!(target instanceof Element) || event.button !== 0) { + if (!(target instanceof Element)) { + return; + } + + if (event.button === 1) { + startPanGesture(event, { source: "pointer" }); + return; + } + + if (event.button !== 0) { + return; + } + + if (state.panMode) { + startPanGesture(event); return; } const cardButton = target.closest(".tarot-frame-card[data-slot-id][data-card-id]"); if (!(cardButton instanceof HTMLButtonElement)) { + const emptyButton = target.closest(".tarot-frame-card.is-empty[data-slot-id]"); + if (emptyButton instanceof HTMLButtonElement && (event.pointerType === "touch" || event.pointerType === "pen")) { + event.preventDefault(); + scheduleLongPress(String(emptyButton.dataset.slotId || ""), event); + } return; } @@ -1563,6 +3266,7 @@ } function handlePointerMove(event) { + updateLongPress(event); if (!state.drag || event.pointerId !== state.drag.pointerId) { return; } @@ -1603,7 +3307,7 @@ if (moved) { swapOrMoveSlots(sourceSlotId, targetSlotId); - render(); + render({ preserveViewport: true }); setStatus(`${getDisplayCardName(draggedCard)} snapped to ${describeSlot(targetSlotId)}.`); } @@ -1614,6 +3318,7 @@ } function handlePointerUp(event) { + finishLongPress(event); if (!state.drag || event.pointerId !== state.drag.pointerId) { return; } @@ -1627,6 +3332,7 @@ } function handlePointerCancel(event) { + finishLongPress(event); if (!state.drag || event.pointerId !== state.drag.pointerId) { return; } @@ -1636,6 +3342,11 @@ } function handleBoardClick(event) { + if (state.panMode) { + state.suppressClick = false; + return; + } + const target = event.target; if (!(target instanceof Element)) { return; @@ -1665,6 +3376,22 @@ } } + function handleBoardContextMenu(event) { + const target = event.target; + if (!(target instanceof Element)) { + return; + } + + const emptyButton = target.closest(".tarot-frame-card.is-empty[data-slot-id]"); + if (!(emptyButton instanceof HTMLButtonElement)) { + return; + } + + event.preventDefault(); + event.stopPropagation(); + openCardPicker(String(emptyButton.dataset.slotId || ""), event.clientX, event.clientY); + } + function handleDocumentClick(event) { const target = event.target; if (!(target instanceof Node)) { @@ -1672,12 +3399,29 @@ } const { + tarotFrameSectionEl, + tarotFrameBoardEl, + tarotFrameFocusToggleEl, + tarotFrameFocusExitEl, tarotFrameSettingsPanelEl, tarotFrameSettingsToggleEl, tarotFrameLayoutPanelEl, tarotFrameLayoutToggleEl } = getElements(); + if (state.gridFocusMode && tarotFrameSectionEl?.contains(target)) { + const targetElement = target instanceof Element ? target : null; + const clickedInsideBoard = Boolean(targetElement?.closest("#tarot-frame-board")); + const clickedOnFocusControl = Boolean( + tarotFrameFocusToggleEl?.contains(target) + || tarotFrameFocusExitEl?.contains(target) + ); + if (!clickedInsideBoard && !clickedOnFocusControl) { + setGridFocusMode(false); + return; + } + } + let changed = false; if (state.settingsOpen && !tarotFrameSettingsPanelEl?.contains(target) && !tarotFrameSettingsToggleEl?.contains(target)) { state.settingsOpen = false; @@ -1689,6 +3433,10 @@ changed = true; } + if (state.cardPicker.open && cardPickerEl && !cardPickerEl.contains(target)) { + closeCardPicker(); + } + if (changed) { syncControls(); } @@ -1699,6 +3447,11 @@ return; } + if (state.gridFocusMode) { + setGridFocusMode(false); + return; + } + let changed = false; if (state.settingsOpen) { state.settingsOpen = false; @@ -1708,6 +3461,9 @@ state.layoutMenuOpen = false; changed = true; } + if (state.cardPicker.open) { + closeCardPicker(); + } if (changed) { syncControls(); @@ -2023,6 +3779,10 @@ function bindEvents() { const { tarotFrameBoardEl, + tarotFrameOverviewEl, + tarotFramePanToggleEl, + tarotFrameFocusToggleEl, + tarotFrameFocusExitEl, tarotFrameLayoutToggleEl, tarotFrameLayoutPanelEl, tarotFrameSettingsToggleEl, @@ -2042,12 +3802,82 @@ tarotFrameHouseBottomInfoMonthEl, tarotFrameHouseBottomInfoRulerEl, tarotFrameHouseBottomInfoDateEl, + tarotFrameClearGridEl, tarotFrameExportWebpEl } = getElements(); if (tarotFrameBoardEl) { tarotFrameBoardEl.addEventListener("pointerdown", handlePointerDown); tarotFrameBoardEl.addEventListener("click", handleBoardClick); tarotFrameBoardEl.addEventListener("dragstart", handleNativeDragStart); + tarotFrameBoardEl.addEventListener("contextmenu", handleBoardContextMenu); + tarotFrameBoardEl.addEventListener("touchstart", handleBoardTouchStart, { passive: false }); + } + + if (tarotFrameOverviewEl) { + tarotFrameOverviewEl.addEventListener("input", (event) => { + const target = event.target; + if (!(target instanceof HTMLTextAreaElement) || target.id !== "tarot-frame-layout-note") { + return; + } + + setLayoutNote(state.currentLayoutId, target.value, { updateUi: false }); + const badgeEl = tarotFrameOverviewEl.querySelector(".tarot-frame-notes-badge"); + if (badgeEl instanceof HTMLElement) { + badgeEl.textContent = getLayoutNote() ? "Saved" : "Optional"; + } + const clearButton = tarotFrameOverviewEl.querySelector("[data-frame-note-clear='true']"); + if (clearButton instanceof HTMLButtonElement) { + clearButton.disabled = !getLayoutNote() || Boolean(state.exportInProgress); + } + }); + + tarotFrameOverviewEl.addEventListener("click", (event) => { + const target = event.target; + if (!(target instanceof Element)) { + return; + } + + const clearButton = target.closest("[data-frame-note-clear='true']"); + if (!(clearButton instanceof HTMLButtonElement)) { + return; + } + + setLayoutNote(state.currentLayoutId, ""); + }); + } + + if (tarotFramePanToggleEl) { + tarotFramePanToggleEl.addEventListener("click", (event) => { + event.stopPropagation(); + if (state.exportInProgress) { + return; + } + state.panMode = !state.panMode; + finishPanGesture(); + clearLongPressGesture(); + syncControls(); + updateViewportInteractionState(); + setStatus(state.panMode + ? "Pan mode enabled. Drag inside the frame grid to move around." + : "Pan mode disabled. Drag cards to rearrange the layout."); + }); + } + + if (tarotFrameFocusToggleEl) { + tarotFrameFocusToggleEl.addEventListener("click", (event) => { + event.stopPropagation(); + if (state.exportInProgress) { + return; + } + setGridFocusMode(!state.gridFocusMode); + }); + } + + if (tarotFrameFocusExitEl) { + tarotFrameFocusExitEl.addEventListener("click", (event) => { + event.stopPropagation(); + setGridFocusMode(false); + }); } if (tarotFrameLayoutToggleEl) { @@ -2068,7 +3898,23 @@ tarotFrameLayoutPanelEl.addEventListener("click", (event) => { event.stopPropagation(); const target = event.target; - const option = target instanceof Element ? target.closest(".tarot-frame-layout-option[data-layout-preset-id]") : null; + if (!(target instanceof Element)) { + return; + } + + const saveButton = target.closest("[data-layout-save-action='true']"); + if (saveButton instanceof HTMLButtonElement) { + saveCurrentLayout(); + return; + } + + const deleteButton = target.closest(".tarot-frame-layout-delete-btn[data-layout-delete-id]"); + if (deleteButton instanceof HTMLButtonElement) { + deleteSavedLayout(deleteButton.dataset.layoutDeleteId); + return; + } + + const option = target.closest(".tarot-frame-layout-option[data-layout-id]"); if (!(option instanceof HTMLButtonElement)) { return; } @@ -2078,7 +3924,8 @@ return; } - applyLayoutPreset(option.dataset.layoutPresetId, cards, `${getLayoutPreset(option.dataset.layoutPresetId).label} layout applied to the master grid.`); + const selectedLayout = getLayoutDefinition(option.dataset.layoutId); + applyLayoutSelection(option.dataset.layoutId, cards, `${selectedLayout.label} layout applied to the master grid.`); state.layoutMenuOpen = false; render(); syncControls(); @@ -2150,6 +3997,12 @@ }); } + if (tarotFrameClearGridEl) { + tarotFrameClearGridEl.addEventListener("click", () => { + clearGrid(); + }); + } + document.addEventListener("click", handleDocumentClick); document.addEventListener("keydown", handleDocumentKeydown); } @@ -2170,7 +4023,7 @@ const signature = buildCardSignature(cards); if (!state.layoutReady || state.cardSignature !== signature) { state.cardSignature = signature; - applyLayoutPreset(state.currentLayoutId, cards); + applyLayoutSelection(state.currentLayoutId, cards); } else { setStatus(state.statusMessage || buildReadyStatus(cards)); } @@ -2189,7 +4042,12 @@ return; } + loadSavedLayoutsFromStorage(); + loadLayoutNotesFromStorage(); + restoreActiveLayoutId(); + restoreCardPickerQuery(); bindEvents(); + createCardPickerElements(); syncControls(); state.initialized = true; } @@ -2202,9 +4060,9 @@ resetLayout, setLayoutPreset(layoutId, options = {}) { const cards = getCards(); - state.currentLayoutId = getLayoutPreset(layoutId).id; + state.currentLayoutId = getLayoutDefinition(layoutId).id; if (cards.length && options.reapply !== false) { - applyLayoutPreset(state.currentLayoutId, cards, options.statusMessage || `${getLayoutPreset(layoutId).label} layout applied to the master grid.`); + applyLayoutSelection(state.currentLayoutId, cards, options.statusMessage || `${getLayoutDefinition(layoutId).label} layout applied to the master grid.`); render(); } syncControls(); diff --git a/app/ui-tarot-lightbox.js b/app/ui-tarot-lightbox.js index 735d227..2934981 100644 --- a/app/ui-tarot-lightbox.js +++ b/app/ui-tarot-lightbox.js @@ -22,6 +22,7 @@ let opacityControlEl = null; let opacitySliderEl = null; let opacityValueEl = null; + let exportButtonEl = null; let stageEl = null; let frameEl = null; let baseLayerEl = null; @@ -50,6 +51,7 @@ let activePointerStartX = 0; let activePointerStartY = 0; let activePointerMoved = false; + let activePinchGesture = null; let suppressNextCardClick = false; let suppressDeckCompareToggleUntil = 0; @@ -58,6 +60,9 @@ 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, @@ -88,7 +93,8 @@ mobileInfoOpen: false, mobileInfoView: "primary", zoomOriginX: 50, - zoomOriginY: 50 + zoomOriginY: 50, + exportInProgress: false }; function hasSecondaryCard() { @@ -150,6 +156,41 @@ 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; @@ -177,6 +218,591 @@ 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 []; @@ -391,6 +1017,8 @@ lightboxState.selectedCompareDeckIds = uniqueDeckIds; lightboxState.deckCompareMode = uniqueDeckIds.length > 0; lightboxState.deckCompareMessage = ""; + setInfoPanelOpen(getPersistedInfoPanelVisibility(), { persist: false }); + lightboxState.mobileInfoView = "primary"; if (!lightboxState.deckCompareMode) { lightboxState.deckCompareCards = []; @@ -705,6 +1333,84 @@ 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; @@ -751,13 +1457,49 @@ ); } + 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 canShowInfo = Boolean( + const canShowDeckCompareInfo = Boolean( + lightboxState.isOpen + && !zoomed + && lightboxState.deckCompareMode + && lightboxState.primaryCard?.label + ); + const canShowOverlayInfo = Boolean( lightboxState.isOpen && isCompact && !zoomed @@ -765,6 +1507,7 @@ && lightboxState.allowOverlayCompare && lightboxState.primaryCard?.label ); + const canShowInfo = canShowDeckCompareInfo || canShowOverlayInfo; const hasOverlayInfo = Boolean(lightboxState.compareMode && hasSecondaryCard() && lightboxState.secondaryCard?.label); const activeView = getActiveMobileInfoView(); @@ -772,8 +1515,8 @@ mobileInfoButtonEl.textContent = lightboxState.mobileInfoOpen ? "Hide Info" : "Info"; mobileInfoButtonEl.setAttribute("aria-pressed", lightboxState.mobileInfoOpen ? "true" : "false"); - mobileInfoPrimaryTabEl.style.display = canShowInfo && lightboxState.mobileInfoOpen && hasOverlayInfo ? "inline-flex" : "none"; - mobileInfoSecondaryTabEl.style.display = canShowInfo && lightboxState.mobileInfoOpen && hasOverlayInfo ? "inline-flex" : "none"; + 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"); @@ -862,12 +1605,61 @@ function syncComparePanels() { if (lightboxState.deckCompareMode) { - renderComparePanel(primaryInfoEl, primaryTitleEl, primaryGroupsEl, primaryHintEl, null, "", "", false); + 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); - renderMobileInfoPanel(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(); @@ -1069,7 +1861,9 @@ const visibleCards = [lightboxState.primaryCard, ...lightboxState.deckCompareCards].filter(Boolean); compareGridEl.style.display = "grid"; compareGridEl.style.gap = isCompact ? "6px" : "14px"; - compareGridEl.style.padding = isCompact ? "8px 6px 88px" : "76px 24px 24px"; + 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"; @@ -1196,6 +1990,7 @@ syncSettingsUi(); syncHelpUi(); syncZoomControl(); + syncExportButton(); syncOpacityControl(); syncDeckComparePicker(); syncComparePanels(); @@ -1226,11 +2021,19 @@ stageEl.style.height = "auto"; stageEl.style.transform = "none"; stageEl.style.pointerEvents = "auto"; - compareGridEl.style.padding = isCompact ? "18px 12px 84px" : "76px 24px 24px"; + 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.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) { + if (mobileInfoPanelEl && !isCompact) { mobileInfoPanelEl.style.display = "none"; } syncMobileNavigationControls(); @@ -1301,6 +2104,7 @@ 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(); @@ -1346,6 +2150,7 @@ 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%"; @@ -1413,6 +2218,7 @@ 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%"; @@ -1430,6 +2236,7 @@ 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"; @@ -1456,6 +2263,7 @@ } clearActivePointerGesture(); + clearActivePinchGesture(); suppressNextCardClick = false; lightboxState.zoomOriginX = 50; lightboxState.zoomOriginY = 50; @@ -1534,8 +2342,75 @@ 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) { + if (!lightboxState.isOpen || !isCompactLightboxLayout() || (!zoomed && !activePinchGesture)) { return; } @@ -1824,6 +2699,22 @@ 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"; @@ -1932,6 +2823,7 @@ mobileInfoButtonEl, mobileInfoPrimaryTabEl, mobileInfoSecondaryTabEl, + exportButtonEl, helpButtonEl, zoomControlEl, opacityControlEl @@ -2259,8 +3151,9 @@ lightboxState.helpOpen = false; lightboxState.primaryRotated = false; lightboxState.overlayRotated = false; - lightboxState.mobileInfoOpen = false; + setInfoPanelOpen(false, { persist: false }); lightboxState.mobileInfoView = "primary"; + lightboxState.exportInProgress = false; clearActivePointerGesture(); suppressNextCardClick = false; overlayEl.style.display = "none"; @@ -2476,7 +3369,7 @@ restoreLightboxFocus(); }); mobileInfoButtonEl.addEventListener("click", () => { - lightboxState.mobileInfoOpen = !lightboxState.mobileInfoOpen; + setInfoPanelOpen(!lightboxState.mobileInfoOpen); applyComparePresentation(); restoreLightboxFocus(); }); @@ -2498,6 +3391,11 @@ 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) => { @@ -2568,7 +3466,20 @@ 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) { @@ -2624,7 +3535,20 @@ 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) { @@ -2796,7 +3720,7 @@ lightboxState.helpOpen = false; lightboxState.primaryRotated = false; lightboxState.overlayRotated = false; - lightboxState.mobileInfoOpen = false; + setInfoPanelOpen(getPersistedInfoPanelVisibility(), { persist: false }); lightboxState.mobileInfoView = "primary"; imageEl.src = normalizedPrimary.src; diff --git a/index.html b/index.html index 9dfb156..3627272 100644 --- a/index.html +++ b/index.html @@ -16,7 +16,7 @@ - +
+