From aa4572668e5b14383c71c71eafb3dd34b99e0a97 Mon Sep 17 00:00:00 2001 From: Nose Date: Sat, 4 Apr 2026 03:39:29 -0700 Subject: [PATCH] update to frame and tarot times --- app/styles.css | 569 ++++++++++- app/ui-tarot-detail.js | 2 +- app/ui-tarot-frame.js | 1950 +++++++++++++++++++++++++++++++++++++- app/ui-tarot-lightbox.js | 952 ++++++++++++++++++- index.html | 23 +- 5 files changed, 3414 insertions(+), 82 deletions(-) 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 @@ - +
@@ -313,9 +313,11 @@

Tarot Frame

-

Arrange all 78 tarot cards inside one master 14x14 grid, then switch between the Frames and House of Cards presets without leaving the page. Use the shared settings panel to change the grid zoom for any layout.

+

Arrange all 78 tarot cards inside one master 14x14 grid, then switch between the Frames and House of Cards presets without leaving the page. On touch screens, use two fingers to pan around the board; on desktop, trackpad scroll and middle-mouse drag both pan the grid. Use Full Screen when you want an uncluttered board view for panning and placement.

+ +
- +
+ + +
+
Loading tarot cards...
+
@@ -1118,7 +1129,7 @@ - + @@ -1136,7 +1147,7 @@ - + @@ -1178,7 +1189,7 @@ - +