From 4872e814c968b92ef4009d422fec466eacf60b52 Mon Sep 17 00:00:00 2001 From: Nose Date: Mon, 13 Apr 2026 14:28:03 -0700 Subject: [PATCH] updated tarot frame for mobile and desktop usability --- app/data-service.js | 9 +- app/styles.css | 151 +++++- app/ui-alphabet-gematria.js | 157 +++++- app/ui-alphabet.js | 7 +- app/ui-tarot-frame.js | 980 +++++++++++++++++++++++++++++++++--- index.html | 15 +- 6 files changed, 1217 insertions(+), 102 deletions(-) diff --git a/app/data-service.js b/app/data-service.js index add7ebd..ca99a65 100644 --- a/app/data-service.js +++ b/app/data-service.js @@ -378,9 +378,14 @@ })); } - async function loadGematriaWordsByValue(value) { + async function loadGematriaWordsByValue(value, options = {}) { + const ciphers = Array.isArray(options?.ciphers) + ? options.ciphers.map((cipherId) => String(cipherId || "").trim()).filter(Boolean).join(",") + : String(options?.ciphers || "").trim(); + return fetchJson(buildApiUrl("/api/v1/gematria/words", { - value + value, + ciphers })); } diff --git a/app/styles.css b/app/styles.css index c398118..8715773 100644 --- a/app/styles.css +++ b/app/styles.css @@ -907,6 +907,30 @@ flex-wrap: wrap; } + .tarot-frame-selection-chip { + padding: 10px 14px; + border: 1px solid rgba(56, 189, 248, 0.55); + border-radius: 999px; + background: linear-gradient(180deg, rgba(8, 47, 73, 0.96), rgba(12, 74, 110, 0.98)); + color: #e0f2fe; + cursor: pointer; + font-size: 13px; + font-weight: 800; + letter-spacing: 0.02em; + box-shadow: 0 10px 24px rgba(2, 132, 199, 0.18); + } + + .tarot-frame-selection-chip:hover, + .tarot-frame-selection-chip:focus-visible { + border-color: rgba(125, 211, 252, 0.92); + background: linear-gradient(180deg, rgba(12, 74, 110, 0.98), rgba(14, 116, 144, 1)); + color: #f0f9ff; + } + + .tarot-frame-selection-chip[hidden] { + display: none !important; + } + .tarot-frame-layout-panel { position: absolute; top: calc(100% + 10px); @@ -1681,7 +1705,8 @@ gap: 12px; } - .tarot-frame-card-insert-menu { + .tarot-frame-card-insert-menu, + .tarot-frame-card-action-menu { position: fixed; z-index: 41; width: min(240px, calc(100vw - 24px)); @@ -1696,11 +1721,13 @@ box-shadow: 0 24px 70px rgba(0, 0, 0, 0.42); } - .tarot-frame-card-insert-menu[hidden] { + .tarot-frame-card-insert-menu[hidden], + .tarot-frame-card-action-menu[hidden] { display: none !important; } - .tarot-frame-card-insert-menu-item { + .tarot-frame-card-insert-menu-item, + .tarot-frame-card-action-menu-item { padding: 11px 12px; border: 1px solid rgba(99, 102, 241, 0.22); border-radius: 12px; @@ -1713,7 +1740,9 @@ } .tarot-frame-card-insert-menu-item:hover, - .tarot-frame-card-insert-menu-item:focus-visible { + .tarot-frame-card-insert-menu-item:focus-visible, + .tarot-frame-card-action-menu-item:hover, + .tarot-frame-card-action-menu-item:focus-visible { border-color: rgba(165, 180, 252, 0.9); background: rgba(49, 46, 129, 0.34); color: #f8fafc; @@ -2052,6 +2081,11 @@ border-radius: 8px; } + .tarot-frame-slot.is-selected { + box-shadow: 0 0 0 2px #38bdf8, 0 0 0 6px rgba(56, 189, 248, 0.2); + border-radius: 8px; + } + .tarot-frame-slot.is-drag-source { opacity: 0.42; } @@ -2072,6 +2106,8 @@ } .tarot-frame-card { + --frame-card-rotation: 0deg; + --frame-card-hover-lift: 0px; position: absolute; inset: 0; padding: 0; @@ -2086,6 +2122,12 @@ user-select: none; touch-action: none; -webkit-touch-callout: none; + transform: translateY(var(--frame-card-hover-lift)) rotate(var(--frame-card-rotation)); + transform-origin: center center; + } + + .tarot-frame-card.is-flipped { + --frame-card-rotation: 180deg; } .tarot-frame-card.is-empty { @@ -2096,13 +2138,18 @@ } .tarot-frame-card:hover { - transform: translateY(-2px); + --frame-card-hover-lift: -2px; filter: drop-shadow(0 10px 18px rgba(15, 23, 42, 0.38)); } + .tarot-frame-card.is-selected { + filter: drop-shadow(0 12px 20px rgba(14, 165, 233, 0.34)); + } + .tarot-frame-card.is-empty:hover { border-color: transparent; box-shadow: none; + --frame-card-hover-lift: 0px; transform: none; } @@ -2112,6 +2159,7 @@ } .tarot-frame-card:hover { + --frame-card-hover-lift: 0px; transform: none; filter: none; } @@ -2288,6 +2336,28 @@ transform: translate(-50%, -50%); } + .tarot-frame-drag-ghost.is-flipped { + transform: translate(-50%, -50%) rotate(180deg); + } + + .tarot-frame-drag-ghost-count { + position: absolute; + top: 8px; + right: 8px; + min-width: 26px; + height: 26px; + padding: 0 8px; + border-radius: 999px; + display: grid; + place-items: center; + background: rgba(56, 189, 248, 0.94); + color: #082f49; + font-size: 12px; + font-weight: 800; + line-height: 1; + box-shadow: 0 8px 20px rgba(2, 132, 199, 0.34); + } + .tarot-frame-drag-ghost img { width: 100%; height: 100%; @@ -2410,6 +2480,13 @@ width: 100%; } + .tarot-frame-selection-chip { + order: -1; + width: 100%; + justify-content: center; + text-align: center; + } + .tarot-frame-settings-panel { left: 0; right: auto; @@ -2430,8 +2507,8 @@ } .tarot-frame-card-badge { - font-size: 7px; - padding: 3px 4px; + font-size: 11px; + padding: 4px 5px; } .tarot-frame-card-text-face { @@ -4227,6 +4304,56 @@ font-size: 13px; } + .alpha-gematria-cipher[hidden] { + display: none; + } + + .alpha-gematria-reverse-ciphers { + display: flex; + flex-wrap: wrap; + gap: 8px; + } + + .alpha-gematria-reverse-ciphers[hidden] { + display: none; + } + + .alpha-gematria-reverse-cipher-option { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 6px 10px; + border: 1px solid #3f3f46; + border-radius: 999px; + background: #101018; + color: #d4d4d8; + cursor: pointer; + -webkit-user-select: none; + user-select: none; + } + + .alpha-gematria-reverse-cipher-option:has(input:checked) { + border-color: #6366f1; + background: #1c1b35; + color: #eef2ff; + } + + .alpha-gematria-reverse-cipher-option input { + margin: 0; + accent-color: #818cf8; + } + + .alpha-gematria-reverse-cipher-name { + font-size: 12px; + line-height: 1.2; + } + + .alpha-gematria-reverse-cipher-hint { + color: #a1a1aa; + font-size: 11px; + line-height: 1.4; + } + .alpha-gematria-input { min-height: 54px; resize: vertical; @@ -4330,15 +4457,17 @@ } @media (max-width: 900px) { - .alpha-gematria-controls.is-input-priority-mode, - .alpha-gematria-controls:has(.alpha-gematria-cipher:disabled) { + .alpha-gematria-controls.is-input-priority-mode { grid-template-columns: minmax(0, 1fr); } - .alpha-gematria-controls.is-input-priority-mode .alpha-gematria-field-cipher, - .alpha-gematria-controls:has(.alpha-gematria-cipher:disabled) .alpha-gematria-field-cipher { + .alpha-gematria-controls.is-input-priority-mode .alpha-gematria-field-cipher { display: none; } + + .alpha-gematria-controls.is-reverse-cipher-mode { + grid-template-columns: minmax(0, 1fr); + } } .alpha-tabs { diff --git a/app/ui-alphabet-gematria.js b/app/ui-alphabet-gematria.js index d1a090d..234eae2 100644 --- a/app/ui-alphabet-gematria.js +++ b/app/ui-alphabet-gematria.js @@ -12,7 +12,9 @@ modeEls: [], matchesEl: null, inputLabelEl: null, - cipherLabelEl: null + cipherLabelEl: null, + reverseCiphersEl: null, + reverseCipherHintEl: null }) }; @@ -23,6 +25,7 @@ activeCipherId: "", forwardInputText: "", reverseInputText: "", + reverseSelectedCipherIds: null, anagramInputText: "", dictionaryInputText: "", activeMode: "forward", @@ -52,7 +55,9 @@ modeEls: [], matchesEl: null, inputLabelEl: null, - cipherLabelEl: null + cipherLabelEl: null, + reverseCiphersEl: null, + reverseCipherHintEl: null }; } @@ -273,6 +278,80 @@ return ciphers.find((cipher) => cipher.id === selectedId) || ciphers[0]; } + function getGematriaCiphers() { + const db = state.db || getFallbackGematriaDb(); + return Array.isArray(db.ciphers) ? db.ciphers : []; + } + + function normalizeSelectedReverseCipherIds(rawCipherIds) { + const ciphers = getGematriaCiphers(); + const requestedIds = Array.isArray(rawCipherIds) + ? rawCipherIds.map((cipherId) => String(cipherId || "").trim()).filter(Boolean) + : []; + const requestedIdSet = new Set(requestedIds); + + return ciphers + .map((cipher) => cipher.id) + .filter((cipherId) => requestedIdSet.has(cipherId)); + } + + function getDefaultReverseCipherIds() { + const activeCipher = getActiveGematriaCipher(); + if (activeCipher?.id) { + return [activeCipher.id]; + } + + const firstCipher = getGematriaCiphers()[0]; + return firstCipher?.id ? [firstCipher.id] : []; + } + + function getSelectedReverseCipherIds() { + if (state.reverseSelectedCipherIds === null) { + return getDefaultReverseCipherIds(); + } + + return normalizeSelectedReverseCipherIds(state.reverseSelectedCipherIds); + } + + function setSelectedReverseCipherIds(rawCipherIds) { + state.reverseSelectedCipherIds = normalizeSelectedReverseCipherIds(rawCipherIds); + } + + function renderReverseCipherOptions() { + const { reverseCiphersEl } = getElements(); + if (!reverseCiphersEl) { + return; + } + + const ciphers = getGematriaCiphers(); + const selectedCipherIds = new Set(getSelectedReverseCipherIds()); + const fragment = document.createDocumentFragment(); + + ciphers.forEach((cipher) => { + const optionEl = document.createElement("label"); + optionEl.className = "alpha-gematria-reverse-cipher-option"; + + const checkboxEl = document.createElement("input"); + checkboxEl.type = "checkbox"; + checkboxEl.value = cipher.id; + checkboxEl.checked = selectedCipherIds.has(cipher.id); + checkboxEl.setAttribute("aria-label", cipher.name); + optionEl.appendChild(checkboxEl); + + const textEl = document.createElement("span"); + textEl.className = "alpha-gematria-reverse-cipher-name"; + textEl.textContent = cipher.name; + if (cipher.description) { + textEl.title = cipher.description; + } + optionEl.appendChild(textEl); + + fragment.appendChild(optionEl); + }); + + reverseCiphersEl.replaceChildren(fragment); + } + function renderGematriaCipherOptions() { const { cipherEl } = getElements(); if (!cipherEl) { @@ -296,6 +375,14 @@ const activeCipher = getActiveGematriaCipher(); state.activeCipherId = activeCipher?.id || ""; cipherEl.value = state.activeCipherId; + + if (state.reverseSelectedCipherIds === null) { + state.reverseSelectedCipherIds = getDefaultReverseCipherIds(); + } else { + state.reverseSelectedCipherIds = normalizeSelectedReverseCipherIds(state.reverseSelectedCipherIds); + } + + renderReverseCipherOptions(); } function setMatchesMessage(matchesEl, message) { @@ -332,12 +419,14 @@ modeEls, matchesEl, inputLabelEl, - cipherLabelEl + cipherLabelEl, + reverseCiphersEl, + reverseCipherHintEl } = getElements(); const reverseMode = isReverseMode(); const anagramMode = isAnagramMode(); - const dictionaryMode = isDictionaryMode(); + const dictionaryMode = isDictionaryMode(); const radioEls = getModeElements(modeEls); radioEls.forEach((element) => { @@ -351,16 +440,32 @@ } if (cipherLabelEl) { - cipherLabelEl.textContent = (reverseMode || anagramMode || dictionaryMode) ? "Cipher (not used in this mode)" : "Cipher"; + cipherLabelEl.textContent = reverseMode + ? "Ciphers" + : ((anagramMode || dictionaryMode) ? "Cipher (not used in this mode)" : "Cipher"); } if (cipherEl) { const disableCipher = reverseMode || anagramMode || dictionaryMode; + const hideCipherField = anagramMode || dictionaryMode; cipherEl.disabled = disableCipher; + cipherEl.hidden = reverseMode; const cipherFieldEl = cipherEl.closest(".alpha-gematria-field"); const controlsEl = cipherEl.closest(".alpha-gematria-controls"); - cipherFieldEl?.classList.toggle("is-disabled", disableCipher); - controlsEl?.classList.toggle("is-input-priority-mode", disableCipher); + cipherFieldEl?.classList.toggle("is-disabled", hideCipherField); + controlsEl?.classList.toggle("is-input-priority-mode", hideCipherField); + controlsEl?.classList.toggle("is-reverse-cipher-mode", reverseMode); + } + + if (reverseCiphersEl) { + if (reverseMode) { + renderReverseCipherOptions(); + } + reverseCiphersEl.hidden = !reverseMode; + } + + if (reverseCipherHintEl) { + reverseCipherHintEl.hidden = !reverseMode; } if (inputEl) { @@ -400,12 +505,15 @@ } async function loadReverseLookup(value) { - const cacheKey = String(value); + const selectedCipherIds = getSelectedReverseCipherIds(); + const cacheKey = `${String(value)}::${selectedCipherIds.join(",")}`; if (state.reverseLookupCache.has(cacheKey)) { return state.reverseLookupCache.get(cacheKey); } - const payload = await window.TarotDataService?.loadGematriaWordsByValue?.(value); + const payload = await window.TarotDataService?.loadGematriaWordsByValue?.(value, { + ciphers: selectedCipherIds + }); state.reverseLookupCache.set(cacheKey, payload); return payload; } @@ -493,7 +601,7 @@ .map((cipher) => `${String(cipher?.name || cipher?.id || "Unknown")} ${formatCount(cipher?.count)}`) .join(" · "); - breakdownEl.textContent = `Found ${formatCount(displayCount)} matches across ${formatCount(displayCipherCount)} ciphers.${topCipherSummary ? ` Top ciphers: ${topCipherSummary}.` : ""}${displayCount > visibleMatches.length ? ` Showing first ${formatCount(visibleMatches.length)}.` : ""}`; + breakdownEl.textContent = `Found ${formatCount(displayCount)} matches across ${formatCount(displayCipherCount)} selected ciphers.${topCipherSummary ? ` Top ciphers: ${topCipherSummary}.` : ""}${displayCount > visibleMatches.length ? ` Showing first ${formatCount(visibleMatches.length)}.` : ""}`; const fragment = document.createDocumentFragment(); visibleMatches.forEach((match) => { @@ -546,11 +654,20 @@ } const rawValue = state.reverseInputText; + const selectedCipherIds = getSelectedReverseCipherIds(); if (!String(rawValue || "").trim()) { resultEl.textContent = "Value: --"; - breakdownEl.textContent = "Enter a whole number to find words across all available ciphers."; + breakdownEl.textContent = "Enter a whole number and choose one or more ciphers to narrow reverse matches."; matchesEl.hidden = false; - setMatchesMessage(matchesEl, "Reverse lookup searches the API-backed gematria word index."); + setMatchesMessage(matchesEl, "Reverse lookup searches the API-backed gematria word index using the selected ciphers only."); + return; + } + + if (!selectedCipherIds.length) { + resultEl.textContent = "Value: --"; + breakdownEl.textContent = "Choose at least one cipher before running reverse lookup."; + matchesEl.hidden = false; + setMatchesMessage(matchesEl, "Select one or more ciphers to limit reverse gematria matches."); return; } @@ -865,7 +982,7 @@ } function bindGematriaListeners() { - const { cipherEl, inputEl, modeEls } = getElements(); + const { cipherEl, inputEl, modeEls, reverseCiphersEl } = getElements(); if (state.listenersBound || !cipherEl || !inputEl) { return; } @@ -900,6 +1017,20 @@ }); }); + reverseCiphersEl?.addEventListener("change", (event) => { + const target = event.target; + if (!(target instanceof HTMLInputElement) || target.type !== "checkbox") { + return; + } + + const nextSelectedCipherIds = Array.from(reverseCiphersEl.querySelectorAll("input[type='checkbox']:checked")) + .map((element) => String(element.value || "").trim()) + .filter(Boolean); + setSelectedReverseCipherIds(nextSelectedCipherIds); + renderReverseCipherOptions(); + renderGematriaResult(); + }); + state.listenersBound = true; } diff --git a/app/ui-alphabet.js b/app/ui-alphabet.js index 1ac9ff2..b153274 100644 --- a/app/ui-alphabet.js +++ b/app/ui-alphabet.js @@ -53,6 +53,7 @@ let searchInputEl, searchClearEl, typeFilterEl; let gematriaCipherEl, gematriaInputEl, gematriaResultEl, gematriaBreakdownEl; let gematriaModeEls, gematriaMatchesEl, gematriaInputLabelEl, gematriaCipherLabelEl; + let gematriaReverseCiphersEl, gematriaReverseCipherHintEl; function getElements() { listEl = document.getElementById("alpha-letter-list"); @@ -77,6 +78,8 @@ gematriaMatchesEl = document.getElementById("alpha-gematria-matches"); gematriaInputLabelEl = document.getElementById("alpha-gematria-input-label"); gematriaCipherLabelEl = document.getElementById("alpha-gematria-cipher-label"); + gematriaReverseCiphersEl = document.getElementById("alpha-gematria-reverse-ciphers"); + gematriaReverseCipherHintEl = document.getElementById("alpha-gematria-reverse-cipher-hint"); } function getGematriaElements() { @@ -89,7 +92,9 @@ modeEls: gematriaModeEls, matchesEl: gematriaMatchesEl, inputLabelEl: gematriaInputLabelEl, - cipherLabelEl: gematriaCipherLabelEl + cipherLabelEl: gematriaCipherLabelEl, + reverseCiphersEl: gematriaReverseCiphersEl, + reverseCipherHintEl: gematriaReverseCipherHintEl }; } diff --git a/app/ui-tarot-frame.js b/app/ui-tarot-frame.js index bb14767..e5fcaa1 100644 --- a/app/ui-tarot-frame.js +++ b/app/ui-tarot-frame.js @@ -190,6 +190,8 @@ layoutReady: false, cardSignature: "", slotAssignments: new Map(), + slotFlips: new Map(), + selectedSlotIds: new Set(), statusMessage: "Loading tarot cards...", drag: null, panMode: false, @@ -200,6 +202,13 @@ open: false, slotId: "" }, + cardActionMenu: { + open: false, + slotId: "", + cardId: "", + ignoreClickUntil: 0, + clickArmed: false + }, cardPicker: { open: false, slotId: "", @@ -239,6 +248,11 @@ }; let cardInsertMenuEl = null; + let cardActionMenuEl = null; + let cardActionMenuSelectEl = null; + let cardActionMenuFlipEl = null; + let cardActionMenuOpenEl = null; + let cardActionMenuRemoveEl = null; let cardPickerEl = null; let cardPickerTitleEl = null; let cardPickerSearchEl = null; @@ -284,6 +298,7 @@ tarotFrameBoardEl: document.getElementById("tarot-frame-board"), tarotFrameStatusEl: document.getElementById("tarot-frame-status"), tarotFrameOverviewEl: document.getElementById("tarot-frame-overview"), + tarotFrameSelectionChipEl: document.getElementById("tarot-frame-selection-chip"), tarotFramePanToggleEl: document.getElementById("tarot-frame-pan-toggle"), tarotFrameFocusToggleEl: document.getElementById("tarot-frame-focus-toggle"), tarotFrameFocusExitEl: document.getElementById("tarot-frame-focus-exit"), @@ -316,6 +331,30 @@ return String(value || "").replace(/\s+/g, " ").trim(); } + function syncSelectionChip() { + const { tarotFrameSelectionChipEl } = getElements(); + if (!(tarotFrameSelectionChipEl instanceof HTMLButtonElement)) { + return; + } + + const selectedCount = getSelectedSlotIds().length; + tarotFrameSelectionChipEl.hidden = !(selectedCount > 0); + tarotFrameSelectionChipEl.disabled = !(selectedCount > 0) || Boolean(state.exportInProgress); + tarotFrameSelectionChipEl.classList.toggle("is-active", selectedCount > 0); + tarotFrameSelectionChipEl.textContent = selectedCount === 1 + ? "1 Selected · Clear" + : `${selectedCount} Selected · Clear`; + tarotFrameSelectionChipEl.setAttribute( + "aria-label", + selectedCount > 0 + ? `Clear selection of ${selectedCount} card${selectedCount === 1 ? "" : "s"}` + : "No cards selected" + ); + tarotFrameSelectionChipEl.title = selectedCount > 0 + ? "Clear the current multi-card selection" + : ""; + } + function isSmallCard(card) { return card?.arcana === "Minor" && MINOR_RANKS.has(String(card?.rank || "")) @@ -478,6 +517,31 @@ && column <= MASTER_GRID_SIZE; } + function parseSlotId(value) { + const match = String(value || "").trim().match(/^(\d+):(\d+)$/); + if (!match) { + return null; + } + + const row = Number(match[1]); + const column = Number(match[2]); + if (!Number.isInteger(row) || !Number.isInteger(column)) { + return null; + } + + return { row, column }; + } + + function compareSlotIds(left, right) { + const leftSlot = parseSlotId(left); + const rightSlot = parseSlotId(right); + if (!leftSlot || !rightSlot) { + return String(left || "").localeCompare(String(right || "")); + } + + return (leftSlot.row - rightSlot.row) || (leftSlot.column - rightSlot.column); + } + function buildFrameSettingsSnapshot() { const topModes = config.getHouseTopInfoModes?.() || {}; const bottomModes = config.getHouseBottomInfoModes?.() || {}; @@ -537,7 +601,8 @@ return [...state.slotAssignments.entries()] .map(([slotId, cardId]) => ({ slotId: String(slotId || "").trim(), - cardId: String(cardId || "").trim() + cardId: String(cardId || "").trim(), + isFlipped: Boolean(state.slotFlips.get(String(slotId || "").trim())) })) .filter((entry) => isValidSlotId(entry.slotId) && validCardIds.has(entry.cardId)) .sort((left, right) => { @@ -580,7 +645,8 @@ ? rawLayout.slotAssignments .map((entry) => ({ slotId: String(entry?.slotId || "").trim(), - cardId: String(entry?.cardId || "").trim() + cardId: String(entry?.cardId || "").trim(), + isFlipped: Boolean(entry?.isFlipped) })) .filter((entry) => isValidSlotId(entry.slotId) && entry.cardId) : []; @@ -1327,6 +1393,204 @@ } } + function createCardActionMenuElements() { + if (cardActionMenuEl) { + return; + } + + cardActionMenuEl = document.createElement("div"); + cardActionMenuEl.className = "tarot-frame-card-action-menu"; + cardActionMenuEl.hidden = true; + + cardActionMenuSelectEl = document.createElement("button"); + cardActionMenuSelectEl.type = "button"; + cardActionMenuSelectEl.className = "tarot-frame-card-action-menu-item"; + cardActionMenuSelectEl.dataset.cardAction = "select"; + cardActionMenuSelectEl.textContent = "Select Multiple"; + + cardActionMenuFlipEl = document.createElement("button"); + cardActionMenuFlipEl.type = "button"; + cardActionMenuFlipEl.className = "tarot-frame-card-action-menu-item"; + cardActionMenuFlipEl.dataset.cardAction = "flip"; + cardActionMenuFlipEl.textContent = "Flip Card"; + + cardActionMenuOpenEl = document.createElement("button"); + cardActionMenuOpenEl.type = "button"; + cardActionMenuOpenEl.className = "tarot-frame-card-action-menu-item"; + cardActionMenuOpenEl.dataset.cardAction = "open"; + cardActionMenuOpenEl.textContent = "Open Card"; + + cardActionMenuRemoveEl = document.createElement("button"); + cardActionMenuRemoveEl.type = "button"; + cardActionMenuRemoveEl.className = "tarot-frame-card-action-menu-item"; + cardActionMenuRemoveEl.dataset.cardAction = "remove"; + cardActionMenuRemoveEl.textContent = "Remove Card"; + + cardActionMenuEl.append(cardActionMenuSelectEl, cardActionMenuFlipEl, cardActionMenuOpenEl, cardActionMenuRemoveEl); + cardActionMenuEl.addEventListener("pointerdown", () => { + state.cardActionMenu.clickArmed = true; + }); + cardActionMenuEl.addEventListener("click", (event) => { + event.stopPropagation(); + if (!state.cardActionMenu.clickArmed) { + event.preventDefault(); + return; + } + + state.cardActionMenu.clickArmed = false; + + const target = event.target; + if (!(target instanceof Element)) { + return; + } + + const actionButton = target.closest("[data-card-action]"); + if (!(actionButton instanceof HTMLButtonElement)) { + return; + } + + const slotId = String(state.cardActionMenu.slotId || "").trim(); + const cardId = String(state.cardActionMenu.cardId || "").trim(); + const action = String(actionButton.dataset.cardAction || "").trim(); + closeCardActionMenu(); + + if (action === "select") { + const selectionUpdate = setSelectedSlotIds( + isSlotSelected(slotId) + ? getSelectedSlotIds().filter((selectedSlotId) => selectedSlotId !== slotId) + : [...getSelectedSlotIds(), slotId] + ); + if (selectionUpdate.changed) { + render({ preserveViewport: true }); + } + setStatus(buildSelectionStatusMessage(selectionUpdate.slotIds.length)); + return; + } + + if (action === "flip") { + if (toggleCardFlip(slotId)) { + render({ preserveViewport: true }); + } + return; + } + + if (action === "remove") { + const removedCard = removeCardFromSlot(slotId); + if (removedCard) { + render({ preserveViewport: true }); + setStatus(`${getDisplayCardName(removedCard)} removed from ${describeSlot(slotId)}.`); + } + return; + } + + openCardLightbox(cardId, slotId); + }); + + document.body.appendChild(cardActionMenuEl); + } + + function positionCardActionMenu(anchorX, anchorY) { + if (!(cardActionMenuEl instanceof HTMLElement)) { + return; + } + + cardActionMenuEl.hidden = false; + cardActionMenuEl.style.visibility = "hidden"; + requestAnimationFrame(() => { + if (!(cardActionMenuEl instanceof HTMLElement)) { + return; + } + + const panelWidth = cardActionMenuEl.offsetWidth || 220; + const panelHeight = cardActionMenuEl.offsetHeight || 120; + 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); + } + + cardActionMenuEl.style.left = `${left}px`; + cardActionMenuEl.style.top = `${top}px`; + cardActionMenuEl.style.visibility = "visible"; + }); + } + + function closeCardActionMenu() { + state.cardActionMenu.open = false; + state.cardActionMenu.slotId = ""; + state.cardActionMenu.cardId = ""; + state.cardActionMenu.ignoreClickUntil = 0; + state.cardActionMenu.clickArmed = false; + if (cardActionMenuEl) { + cardActionMenuEl.hidden = true; + } + } + + function syncCardActionMenu(slotId, cardId) { + const targetSlotId = String(slotId || state.cardActionMenu.slotId || "").trim(); + const targetCardId = String(cardId || state.cardActionMenu.cardId || "").trim(); + const card = getCardMap(getCards()).get(targetCardId) || null; + const flipped = isSlotFlipped(targetSlotId); + + if (cardActionMenuSelectEl) { + cardActionMenuSelectEl.textContent = isSlotSelected(targetSlotId) + ? "Remove From Selection" + : (getSelectedSlotIds().length ? "Add To Selection" : "Select Multiple"); + } + + if (cardActionMenuFlipEl) { + cardActionMenuFlipEl.textContent = flipped ? "Flip Upright" : "Flip Upside Down"; + } + if (cardActionMenuOpenEl) { + cardActionMenuOpenEl.textContent = isCustomFrameCard(card) ? "Edit Custom Card" : "Open Card"; + } + if (cardActionMenuRemoveEl) { + cardActionMenuRemoveEl.textContent = "Remove Card"; + } + } + + function openCardActionMenu(slotId, cardId, anchorX, anchorY, options = {}) { + const targetSlotId = String(slotId || "").trim(); + const targetCardId = String(cardId || "").trim(); + if (!isValidSlotId(targetSlotId) || !targetCardId) { + return; + } + + const sameMenuAlreadyOpen = Boolean( + state.cardActionMenu.open + && state.cardActionMenu.slotId === targetSlotId + && state.cardActionMenu.cardId === targetCardId + ); + const now = Number(window.performance.now()) || 0; + if (sameMenuAlreadyOpen && now < Number(state.cardActionMenu.ignoreClickUntil || 0)) { + return; + } + + createCardActionMenuElements(); + closeCardInsertMenu(); + closeCardPicker(); + state.cardActionMenu.open = true; + state.cardActionMenu.slotId = targetSlotId; + state.cardActionMenu.cardId = targetCardId; + state.cardActionMenu.ignoreClickUntil = options.fromTouchLongPress + ? now + 500 + : (sameMenuAlreadyOpen ? Number(state.cardActionMenu.ignoreClickUntil || 0) : 0); + state.cardActionMenu.clickArmed = !options.fromTouchLongPress; + syncCardActionMenu(targetSlotId, targetCardId); + positionCardActionMenu( + options.fromTouchLongPress ? anchorX + 72 : anchorX, + options.fromTouchLongPress ? anchorY + 72 : anchorY + ); + if (options.focusFirstButton === true) { + requestAnimationFrame(() => { + cardActionMenuFlipEl?.focus?.({ preventScroll: true }); + }); + } + } + function positionCustomCardEditor(anchorX, anchorY) { if (!(cardCustomEditorEl instanceof HTMLElement)) { return; @@ -1662,11 +1926,16 @@ state.customCards.set(nextCard.id, nextCard); const previousSlotId = findAssignedSlotIdByCardId(nextCard.id); + const inheritedFlip = previousSlotId && previousSlotId !== targetSlotId ? isSlotFlipped(previousSlotId) : isSlotFlipped(targetSlotId); if (previousSlotId && previousSlotId !== targetSlotId) { - state.slotAssignments.delete(previousSlotId); + clearSlotAssignmentState(previousSlotId); } - state.slotAssignments.set(targetSlotId, nextCard.id); + assignCardToSlot( + targetSlotId, + nextCard.id, + inheritedFlip + ); pruneUnusedCustomCards(); state.layoutReady = true; render({ preserveViewport: true }); @@ -1697,7 +1966,7 @@ return false; } - state.slotAssignments.delete(targetSlotId); + clearSlotAssignmentState(targetSlotId); state.customCards.delete(targetCardId); pruneUnusedCustomCards(); state.layoutReady = true; @@ -1904,11 +2173,16 @@ } const previousSlotId = findAssignedSlotIdByCardId(targetCardId); + const inheritedFlip = previousSlotId && previousSlotId !== targetSlotId ? isSlotFlipped(previousSlotId) : isSlotFlipped(targetSlotId); if (previousSlotId && previousSlotId !== targetSlotId) { - state.slotAssignments.delete(previousSlotId); + clearSlotAssignmentState(previousSlotId); } - state.slotAssignments.set(targetSlotId, targetCardId); + assignCardToSlot( + targetSlotId, + targetCardId, + inheritedFlip + ); pruneUnusedCustomCards(); state.layoutReady = true; render({ preserveViewport: true }); @@ -1928,7 +2202,9 @@ } state.slotAssignments.clear(); + state.slotFlips.clear(); state.customCards.clear(); + state.selectedSlotIds.clear(); state.layoutReady = true; render(); syncControls(); @@ -1945,7 +2221,7 @@ state.longPress = null; } - function scheduleLongPress(slotId, event) { + function scheduleLongPress(slotId, event, action = "insert") { clearLongPressGesture(); document.addEventListener("pointermove", handleLongPressPointerMove); document.addEventListener("pointerup", handleLongPressPointerUp); @@ -1953,6 +2229,7 @@ state.longPress = { pointerId: event.pointerId, slotId, + action, startX: event.clientX, startY: event.clientY, timerId: window.setTimeout(() => { @@ -1962,6 +2239,15 @@ return; } state.suppressClick = true; + cleanupDrag(); + if (activeGesture.action === "card") { + const cardId = String(state.slotAssignments.get(String(activeGesture.slotId || "").trim()) || "").trim(); + if (cardId) { + openCardActionMenu(activeGesture.slotId, cardId, activeGesture.startX, activeGesture.startY, { focusFirstButton: false, fromTouchLongPress: true }); + } + return; + } + openCardInsertMenu(activeGesture.slotId, activeGesture.startX, activeGesture.startY); }, FRAME_LONG_PRESS_DELAY_MS) }; @@ -2291,6 +2577,109 @@ finishPanGesture(); } + function isEditableTarget(target) { + if (!(target instanceof Element)) { + return false; + } + + return Boolean(target.closest("input, textarea, select, [contenteditable=''], [contenteditable='true']")); + } + + function getGridViewportCenterAnchor() { + const viewportEl = getGridViewportElement(); + if (!(viewportEl instanceof HTMLElement)) { + return null; + } + + const rect = viewportEl.getBoundingClientRect(); + if (!(rect.width > 0 && rect.height > 0)) { + return null; + } + + return { + anchorClientX: rect.left + (rect.width / 2), + anchorClientY: rect.top + (rect.height / 2) + }; + } + + function normalizeWheelDelta(event) { + const mode = Number(event?.deltaMode) || 0; + const multiplier = mode === 1 ? 16 : mode === 2 ? Math.max(1, window.innerHeight || 1) : 1; + return { + deltaX: (Number(event?.deltaX) || 0) * multiplier, + deltaY: (Number(event?.deltaY) || 0) * multiplier + }; + } + + function handleBoardWheel(event) { + if (state.exportInProgress) { + return; + } + + const viewportEl = getGridViewportElement(); + if (!(viewportEl instanceof HTMLElement)) { + return; + } + + const deltas = normalizeWheelDelta(event); + if (event.ctrlKey) { + event.preventDefault(); + const centerAnchor = getGridViewportCenterAnchor(); + const anchorClientX = Number.isFinite(Number(event.clientX)) ? Number(event.clientX) : centerAnchor?.anchorClientX; + const anchorClientY = Number.isFinite(Number(event.clientY)) ? Number(event.clientY) : centerAnchor?.anchorClientY; + const zoomFactor = Math.exp(-(deltas.deltaY || 0) * 0.002); + const nextScale = clampFrameGridZoomScale(getGridZoomScale() * zoomFactor); + setGridZoomScale(nextScale, { + preserveViewport: false, + anchorClientX, + anchorClientY, + statusMessage: "" + }); + state.suppressClick = true; + return; + } + + if (!(Math.abs(deltas.deltaX) > 0 || Math.abs(deltas.deltaY) > 0)) { + return; + } + + event.preventDefault(); + viewportEl.scrollLeft += deltas.deltaX; + viewportEl.scrollTop += deltas.deltaY; + state.suppressClick = true; + } + + function getFrameGridZoomShortcutDirection(event) { + if (!(event instanceof KeyboardEvent)) { + return 0; + } + + const key = String(event.key || ""); + const code = String(event.code || ""); + if (key === "+" || key === "=" || code === "NumpadAdd") { + return 1; + } + + if (key === "-" || key === "_" || code === "NumpadSubtract") { + return -1; + } + + return 0; + } + + function shouldHandleFrameZoomShortcut(event) { + const { tarotFrameSectionEl } = getElements(); + if (!(tarotFrameSectionEl instanceof HTMLElement) || tarotFrameSectionEl.hidden) { + return false; + } + + if (state.cardInsertMenu.open || state.cardPicker.open || (cardCustomEditorEl && !cardCustomEditorEl.hidden)) { + return false; + } + + return !isEditableTarget(event?.target); + } + function normalizeLookupCardName(value) { return String(value || "") .trim() @@ -2455,7 +2844,8 @@ } const card = getCardMap(getCards()).get(cardId) || null; - state.slotAssignments.delete(targetSlotId); + clearSlotAssignmentState(targetSlotId); + state.selectedSlotIds.delete(targetSlotId); if (isCustomFrameCardId(cardId)) { state.customCards.delete(cardId); } @@ -3114,6 +3504,162 @@ return `${row}:${column}`; } + function isSlotSelected(slotId) { + return state.selectedSlotIds.has(String(slotId || "").trim()); + } + + function getSelectedSlotIds() { + return Array.from(state.selectedSlotIds) + .map((slotId) => String(slotId || "").trim()) + .filter((slotId) => isValidSlotId(slotId) && String(state.slotAssignments.get(slotId) || "").trim()) + .sort(compareSlotIds); + } + + function setSelectedSlotIds(slotIds) { + const nextSlotIds = Array.from(new Set( + (Array.isArray(slotIds) ? slotIds : []) + .map((slotId) => String(slotId || "").trim()) + .filter((slotId) => isValidSlotId(slotId) && String(state.slotAssignments.get(slotId) || "").trim()) + )).sort(compareSlotIds); + const changed = nextSlotIds.length !== state.selectedSlotIds.size + || nextSlotIds.some((slotId) => !state.selectedSlotIds.has(slotId)); + + state.selectedSlotIds = new Set(nextSlotIds); + syncSelectionChip(); + return { + changed, + slotIds: nextSlotIds + }; + } + + function buildSelectionStatusMessage(count = getSelectedSlotIds().length) { + if (!(count > 0)) { + return "Card selection cleared."; + } + + return `${count} card${count === 1 ? "" : "s"} selected. Drag any selected card to move ${count === 1 ? "it" : "them"} together. Ctrl/Cmd-click adjusts the set on desktop, and touch users can long-press a card for Select Multiple.`; + } + + function getDragSourceSlotIds(anchorSlotId) { + const targetSlotId = String(anchorSlotId || "").trim(); + if (!isValidSlotId(targetSlotId)) { + return []; + } + + const selectedSlotIds = getSelectedSlotIds(); + if (selectedSlotIds.length > 1 && selectedSlotIds.includes(targetSlotId)) { + return selectedSlotIds; + } + + return [targetSlotId]; + } + + function isSlotFlipped(slotId) { + const targetSlotId = String(slotId || "").trim(); + return Boolean(targetSlotId && state.slotFlips.get(targetSlotId)); + } + + function setSlotFlipState(slotId, isFlipped) { + const targetSlotId = String(slotId || "").trim(); + if (!isValidSlotId(targetSlotId)) { + return; + } + + if (isFlipped) { + state.slotFlips.set(targetSlotId, true); + return; + } + + state.slotFlips.delete(targetSlotId); + } + + function clearSlotAssignmentState(slotId) { + const targetSlotId = String(slotId || "").trim(); + if (!isValidSlotId(targetSlotId)) { + return; + } + + state.slotAssignments.delete(targetSlotId); + state.slotFlips.delete(targetSlotId); + } + + function assignCardToSlot(slotId, cardId, isFlipped = false) { + const targetSlotId = String(slotId || "").trim(); + const targetCardId = String(cardId || "").trim(); + if (!isValidSlotId(targetSlotId) || !targetCardId) { + return false; + } + + state.slotAssignments.set(targetSlotId, targetCardId); + setSlotFlipState(targetSlotId, isFlipped); + return true; + } + + function moveSlotAssignment(sourceSlotId, targetSlotId) { + const fromSlotId = String(sourceSlotId || "").trim(); + const toSlotId = String(targetSlotId || "").trim(); + if (!isValidSlotId(fromSlotId) || !isValidSlotId(toSlotId)) { + return false; + } + + const cardId = String(state.slotAssignments.get(fromSlotId) || "").trim(); + if (!cardId) { + return false; + } + + const isFlipped = isSlotFlipped(fromSlotId); + clearSlotAssignmentState(fromSlotId); + assignCardToSlot(toSlotId, cardId, isFlipped); + return true; + } + + function swapSlotAssignments(sourceSlotId, targetSlotId) { + const fromSlotId = String(sourceSlotId || "").trim(); + const toSlotId = String(targetSlotId || "").trim(); + if (!isValidSlotId(fromSlotId) || !isValidSlotId(toSlotId)) { + return false; + } + + const sourceCardId = String(state.slotAssignments.get(fromSlotId) || "").trim(); + const targetCardId = String(state.slotAssignments.get(toSlotId) || "").trim(); + if (!sourceCardId) { + return false; + } + + const sourceFlip = isSlotFlipped(fromSlotId); + const targetFlip = isSlotFlipped(toSlotId); + + state.slotAssignments.set(toSlotId, sourceCardId); + setSlotFlipState(toSlotId, sourceFlip); + if (targetCardId) { + state.slotAssignments.set(fromSlotId, targetCardId); + setSlotFlipState(fromSlotId, targetFlip); + } else { + clearSlotAssignmentState(fromSlotId); + } + + return true; + } + + function toggleCardFlip(slotId) { + const targetSlotId = String(slotId || "").trim(); + if (!isValidSlotId(targetSlotId)) { + return false; + } + + const cardId = String(state.slotAssignments.get(targetSlotId) || "").trim(); + if (!cardId) { + return false; + } + + const nextIsFlipped = !isSlotFlipped(targetSlotId); + setSlotFlipState(targetSlotId, nextIsFlipped); + state.layoutReady = true; + const card = getCardMap(getCards()).get(cardId) || null; + setStatus(`${getDisplayCardName(card)} flipped ${nextIsFlipped ? "upside down" : "upright"} at ${describeSlot(targetSlotId)}.`); + return true; + } + function setStatus(message) { state.statusMessage = String(message || "").trim(); const { tarotFrameStatusEl } = getElements(); @@ -3126,10 +3672,12 @@ const layoutPreset = getLayoutPreset(layoutId) || LAYOUT_PRESETS[0]; state.currentLayoutId = layoutPreset.id; state.slotAssignments.clear(); + state.slotFlips.clear(); + state.selectedSlotIds.clear(); state.customCards.clear(); layoutPreset.buildPlacements(cards).forEach((placement) => { - state.slotAssignments.set(getSlotId(placement.row, placement.column), placement.cardId); + assignCardToSlot(getSlotId(placement.row, placement.column), placement.cardId); }); state.layoutReady = true; @@ -3148,9 +3696,11 @@ const cardMap = getCardMap(cards); state.currentLayoutId = savedLayout.id; state.slotAssignments.clear(); + state.slotFlips.clear(); + state.selectedSlotIds.clear(); savedLayout.slotAssignments.forEach((entry) => { if (cardMap.has(entry.cardId)) { - state.slotAssignments.set(entry.slotId, entry.cardId); + assignCardToSlot(entry.slotId, entry.cardId, Boolean(entry.isFlipped)); } }); applyFrameSettingsSnapshot(savedLayout.settings); @@ -3614,17 +4164,23 @@ function createSlot(row, column, card) { const slotId = getSlotId(row, column); + const flipped = isSlotFlipped(slotId); + const selected = isSlotSelected(slotId); const slotEl = document.createElement("div"); slotEl.className = "tarot-frame-slot"; slotEl.dataset.slotId = slotId; slotEl.style.gridRow = String(row); slotEl.style.gridColumn = String(column); - if (state.drag?.sourceSlotId === slotId) { + if (selected) { + slotEl.classList.add("is-selected"); + } + + if (Array.isArray(state.drag?.sourceSlotIds) && state.drag.sourceSlotIds.includes(slotId)) { slotEl.classList.add("is-drag-source"); } - if (state.drag?.hoverSlotId === slotId && state.drag?.started) { + if (Array.isArray(state.drag?.hoverSlotIds) && state.drag.hoverSlotIds.includes(slotId) && state.drag?.started) { slotEl.classList.add("is-drop-target"); } @@ -3633,6 +4189,9 @@ button.className = "tarot-frame-card"; button.dataset.slotId = slotId; button.draggable = false; + button.classList.toggle("is-flipped", flipped); + button.classList.toggle("is-selected", selected); + button.dataset.flipped = flipped ? "true" : "false"; if (!card) { slotEl.classList.add("is-empty-slot"); @@ -3647,8 +4206,8 @@ } button.dataset.cardId = getCardId(card); - button.setAttribute("aria-label", `${getDisplayCardName(card)} in row ${row}, column ${column}`); - button.title = getDisplayCardName(card); + button.setAttribute("aria-label", `${getDisplayCardName(card)} in row ${row}, column ${column}${flipped ? ", flipped upside down" : ""}${selected ? ", selected for group move" : ""}`); + button.title = `${getDisplayCardName(card)}${flipped ? " (flipped upside down)" : ""}`; const showImage = shouldShowCardImage(card); @@ -3787,6 +4346,9 @@ return; } + state.selectedSlotIds = new Set(getSelectedSlotIds()); + syncSelectionChip(); + const preserveViewport = options.preserveViewport === true; const viewportSnapshot = preserveViewport ? captureGridViewportSnapshot() : null; @@ -3890,6 +4452,7 @@ function syncControls() { const { + tarotFrameSelectionChipEl, tarotFramePanToggleEl, tarotFrameFocusToggleEl, tarotFrameFocusExitEl, @@ -3918,6 +4481,10 @@ } = getElements(); const activeLayout = getLayoutDefinition(); + if (tarotFrameSelectionChipEl) { + syncSelectionChip(); + } + if (tarotFramePanToggleEl) { tarotFramePanToggleEl.setAttribute("aria-pressed", state.panMode ? "true" : "false"); tarotFramePanToggleEl.classList.toggle("is-active", state.panMode); @@ -4015,24 +4582,135 @@ return document.querySelector(`.tarot-frame-slot[data-slot-id="${slotId}"]`); } - function setHoverSlot(slotId) { - const previous = state.drag?.hoverSlotId; - if (previous && previous !== slotId) { - getSlotElement(previous)?.classList.remove("is-drop-target"); - } + function setHoverSlots(slotIds) { + const nextSlotIds = Array.isArray(slotIds) + ? Array.from(new Set(slotIds.map((slotId) => String(slotId || "").trim()).filter(Boolean))) + : []; + const previousSlotIds = Array.isArray(state.drag?.hoverSlotIds) ? state.drag.hoverSlotIds : []; + + previousSlotIds.forEach((slotId) => { + if (!nextSlotIds.includes(slotId)) { + getSlotElement(slotId)?.classList.remove("is-drop-target"); + } + }); if (state.drag) { - state.drag.hoverSlotId = slotId || ""; + state.drag.hoverSlotIds = nextSlotIds; + state.drag.hoverSlotId = nextSlotIds[0] || ""; } - if (slotId) { + nextSlotIds.forEach((slotId) => { getSlotElement(slotId)?.classList.add("is-drop-target"); - } + }); } - function createDragGhost(card) { + function buildGroupDropPlan(anchorSourceSlotId, anchorTargetSlotId, dragSlotIds) { + const anchorSource = parseSlotId(anchorSourceSlotId); + const anchorTarget = parseSlotId(anchorTargetSlotId); + const normalizedSlotIds = Array.isArray(dragSlotIds) + ? dragSlotIds.map((slotId) => String(slotId || "").trim()).filter((slotId) => isValidSlotId(slotId)) + : []; + + if (!anchorSource || !anchorTarget || !normalizedSlotIds.length) { + return { + valid: false, + moved: false, + targetSlotIds: [], + moves: [], + reason: "" + }; + } + + const sourceSet = new Set(normalizedSlotIds); + const moves = []; + for (const sourceSlotId of normalizedSlotIds) { + const sourceSlot = parseSlotId(sourceSlotId); + const cardId = String(state.slotAssignments.get(sourceSlotId) || "").trim(); + if (!sourceSlot || !cardId) { + return { + valid: false, + moved: false, + targetSlotIds: [], + moves: [], + reason: "" + }; + } + + const targetRow = anchorTarget.row + (sourceSlot.row - anchorSource.row); + const targetColumn = anchorTarget.column + (sourceSlot.column - anchorSource.column); + if (targetRow < 1 || targetRow > MASTER_GRID_SIZE || targetColumn < 1 || targetColumn > MASTER_GRID_SIZE) { + return { + valid: false, + moved: false, + targetSlotIds: [], + moves: [], + reason: "Group move would push part of the selection outside the frame grid." + }; + } + + const targetSlotId = getSlotId(targetRow, targetColumn); + const existingCardId = String(state.slotAssignments.get(targetSlotId) || "").trim(); + if (normalizedSlotIds.length > 1 && existingCardId && !sourceSet.has(targetSlotId)) { + return { + valid: false, + moved: false, + targetSlotIds: [], + moves: [], + reason: "Group move needs open destination slots for every selected card." + }; + } + + moves.push({ + sourceSlotId, + targetSlotId, + cardId, + isFlipped: isSlotFlipped(sourceSlotId) + }); + } + + const targetSlotIds = moves.map((move) => move.targetSlotId).sort(compareSlotIds); + return { + valid: new Set(targetSlotIds).size === targetSlotIds.length, + moved: moves.some((move) => move.sourceSlotId !== move.targetSlotId), + targetSlotIds, + moves, + reason: "" + }; + } + + function applyGroupMove(plan) { + if (!plan?.valid || !Array.isArray(plan.moves) || !plan.moves.length) { + return false; + } + + plan.moves.forEach((move) => { + clearSlotAssignmentState(move.sourceSlotId); + }); + plan.moves.forEach((move) => { + assignCardToSlot(move.targetSlotId, move.cardId, move.isFlipped); + }); + state.layoutReady = true; + pruneUnusedCustomCards(); + return true; + } + + function createDragGhost(card, options = {}) { const ghost = document.createElement("div"); ghost.className = "tarot-frame-drag-ghost"; + const groupSize = Math.max(1, Number(options.groupSize) || 1); + + const sourceSlotId = findAssignedSlotIdByCardId(getCardId(card)); + if (isSlotFlipped(sourceSlotId)) { + ghost.classList.add("is-flipped"); + } + + if (groupSize > 1) { + const countEl = document.createElement("span"); + countEl.className = "tarot-frame-drag-ghost-count"; + countEl.textContent = String(groupSize); + countEl.setAttribute("aria-label", `${groupSize} selected cards`); + ghost.appendChild(countEl); + } const imageSrc = resolveCardThumbnail(card); if (imageSrc) { @@ -4108,13 +4786,44 @@ setDragTrashState(Boolean(state.drag.started), Boolean(state.drag.deleteActive)); } if (trashTarget) { - setHoverSlot(""); + if (state.drag) { + state.drag.dropPlan = null; + state.drag.invalidDropMessage = ""; + } + setHoverSlots([]); return; } const slot = target instanceof Element ? target.closest(".tarot-frame-slot[data-slot-id]") : null; const nextSlotId = slot instanceof HTMLElement ? String(slot.dataset.slotId || "") : ""; - setHoverSlot(nextSlotId && nextSlotId !== sourceSlotId ? nextSlotId : ""); + if (!state.drag) { + return; + } + + const dragSlotIds = Array.isArray(state.drag.sourceSlotIds) && state.drag.sourceSlotIds.length + ? state.drag.sourceSlotIds + : [sourceSlotId]; + if (dragSlotIds.length > 1) { + const plan = nextSlotId && nextSlotId !== sourceSlotId + ? buildGroupDropPlan(sourceSlotId, nextSlotId, dragSlotIds) + : null; + state.drag.dropPlan = plan?.valid ? plan : null; + state.drag.invalidDropMessage = !plan || plan.valid ? "" : plan.reason; + setHoverSlots(plan?.valid && plan.moved ? plan.targetSlotIds : []); + return; + } + + state.drag.dropPlan = nextSlotId && nextSlotId !== sourceSlotId + ? { + valid: true, + moved: true, + targetSlotIds: [nextSlotId], + moves: [], + reason: "" + } + : null; + state.drag.invalidDropMessage = ""; + setHoverSlots(nextSlotId && nextSlotId !== sourceSlotId ? [nextSlotId] : []); } function removeOrphanedDragGhosts() { @@ -4149,8 +4858,10 @@ } } - setHoverSlot(""); - getSlotElement(state.drag.sourceSlotId)?.classList.remove("is-drag-source"); + setHoverSlots([]); + (Array.isArray(state.drag.sourceSlotIds) ? state.drag.sourceSlotIds : [state.drag.sourceSlotId]).forEach((slotId) => { + getSlotElement(slotId)?.classList.remove("is-drag-source"); + }); if (state.drag.ghostEl instanceof HTMLElement) { state.drag.ghostEl.remove(); } @@ -4176,14 +4887,7 @@ } function swapOrMoveSlots(sourceSlotId, targetSlotId) { - const sourceCardId = String(state.slotAssignments.get(sourceSlotId) || ""); - const targetCardId = String(state.slotAssignments.get(targetSlotId) || ""); - state.slotAssignments.set(targetSlotId, sourceCardId); - if (targetCardId) { - state.slotAssignments.set(sourceSlotId, targetCardId); - } else { - state.slotAssignments.delete(sourceSlotId); - } + swapSlotAssignments(sourceSlotId, targetSlotId); } function describeSlot(slotId) { @@ -4252,28 +4956,57 @@ return; } + const pointerType = String(event.pointerType || "").toLowerCase(); + 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")) { + if (emptyButton instanceof HTMLButtonElement && (pointerType === "touch" || pointerType === "pen")) { event.preventDefault(); scheduleLongPress(String(emptyButton.dataset.slotId || ""), event); } return; } + const sourceSlotId = String(cardButton.dataset.slotId || "").trim(); + const toggleSelectionShortcut = pointerType !== "touch" && (event.ctrlKey || event.metaKey); + if (toggleSelectionShortcut) { + const selectionUpdate = setSelectedSlotIds( + isSlotSelected(sourceSlotId) + ? getSelectedSlotIds().filter((slotId) => slotId !== sourceSlotId) + : [...getSelectedSlotIds(), sourceSlotId] + ); + if (selectionUpdate.changed) { + render({ preserveViewport: true }); + } + setStatus(buildSelectionStatusMessage(selectionUpdate.slotIds.length)); + state.suppressClick = true; + event.preventDefault(); + return; + } + + if (getSelectedSlotIds().length && pointerType !== "touch" && !isSlotSelected(sourceSlotId)) { + setSelectedSlotIds([]); + } + + const dragSourceSlotIds = getDragSourceSlotIds(sourceSlotId); + state.drag = { pointerId: event.pointerId, - pointerType: String(event.pointerType || "").toLowerCase(), - sourceSlotId: String(cardButton.dataset.slotId || ""), + pointerType, + sourceSlotId, + sourceSlotIds: dragSourceSlotIds.length ? dragSourceSlotIds : [sourceSlotId], cardId: String(cardButton.dataset.cardId || ""), startX: event.clientX, startY: event.clientY, - touchEligibleAt: String(event.pointerType || "").toLowerCase() === "touch" + touchEligibleAt: pointerType === "touch" ? (Number(event.timeStamp) || window.performance.now()) + FRAME_TOUCH_DRAG_ACTIVATION_DELAY_MS : 0, started: false, hoverSlotId: "", + hoverSlotIds: [], + dropPlan: null, + invalidDropMessage: "", deleteActive: false, ghostEl: null, sourceButton: cardButton @@ -4287,8 +5020,9 @@ } } - if (String(event.pointerType || "").toLowerCase() === "touch") { + if (pointerType === "touch") { event.preventDefault(); + scheduleLongPress(String(cardButton.dataset.slotId || ""), event, "card"); } detachPointerListeners(); @@ -4303,6 +5037,10 @@ return; } + if (state.drag.pointerType === "touch" && state.longPress?.pointerId === event.pointerId) { + return; + } + if (state.drag.pointerType === "touch" && (state.pinchGesture || (state.panGesture && state.panGesture.source === "touch"))) { cleanupDrag(); state.suppressClick = true; @@ -4324,8 +5062,10 @@ } state.drag.started = true; - state.drag.ghostEl = createDragGhost(card); - getSlotElement(state.drag.sourceSlotId)?.classList.add("is-drag-source"); + state.drag.ghostEl = createDragGhost(card, { groupSize: state.drag.sourceSlotIds.length }); + state.drag.sourceSlotIds.forEach((slotId) => { + getSlotElement(slotId)?.classList.add("is-drag-source"); + }); setDragTrashState(true, false); document.body.classList.add("is-tarot-frame-dragging"); state.suppressClick = true; @@ -4346,19 +5086,43 @@ } const sourceSlotId = state.drag.sourceSlotId; + const dragSlotIds = Array.isArray(state.drag.sourceSlotIds) && state.drag.sourceSlotIds.length + ? state.drag.sourceSlotIds + : [sourceSlotId]; const targetSlotId = state.drag.hoverSlotId; const deleteActive = Boolean(state.drag.deleteActive); + const dropPlan = state.drag.dropPlan; const draggedCard = getCardMap(getCards()).get(state.drag.cardId) || null; - const moved = Boolean(targetSlotId && targetSlotId !== sourceSlotId); + const singleWasSelected = dragSlotIds.length === 1 && isSlotSelected(sourceSlotId); + const moved = dragSlotIds.length > 1 + ? Boolean(dropPlan?.valid && dropPlan.moved) + : Boolean(targetSlotId && targetSlotId !== sourceSlotId); if (deleteActive) { - removeCardFromSlot(sourceSlotId); + dragSlotIds.forEach((slotId) => { + removeCardFromSlot(slotId); + }); + if (dragSlotIds.length > 1) { + setSelectedSlotIds([]); + } render({ preserveViewport: true }); - setStatus(`${getDisplayCardName(draggedCard)} removed from the frame grid.`); + setStatus(dragSlotIds.length > 1 + ? `Removed ${dragSlotIds.length} selected cards from the frame grid.` + : `${getDisplayCardName(draggedCard)} removed from the frame grid.`); + } else if (dragSlotIds.length > 1 && dropPlan?.valid && dropPlan.moved) { + applyGroupMove(dropPlan); + setSelectedSlotIds(dropPlan.targetSlotIds); + render({ preserveViewport: true }); + setStatus(`${dragSlotIds.length} selected cards moved together. Anchor card snapped to ${describeSlot(targetSlotId)}.`); } else if (moved) { swapOrMoveSlots(sourceSlotId, targetSlotId); + if (singleWasSelected) { + setSelectedSlotIds([targetSlotId]); + } render({ preserveViewport: true }); setStatus(`${getDisplayCardName(draggedCard)} snapped to ${describeSlot(targetSlotId)}.`); + } else if (dragSlotIds.length > 1 && state.drag.invalidDropMessage) { + setStatus(state.drag.invalidDropMessage); } cleanupDrag(); @@ -4374,7 +5138,21 @@ } if (!state.drag.started) { + const sourceSlotId = state.drag.sourceSlotId; + const shouldToggleTouchSelection = state.drag.pointerType === "touch" && getSelectedSlotIds().length > 0; cleanupDrag(); + if (shouldToggleTouchSelection) { + const selectionUpdate = setSelectedSlotIds( + isSlotSelected(sourceSlotId) + ? getSelectedSlotIds().filter((slotId) => slotId !== sourceSlotId) + : [...getSelectedSlotIds(), sourceSlotId] + ); + if (selectionUpdate.changed) { + render({ preserveViewport: true }); + } + setStatus(buildSelectionStatusMessage(selectionUpdate.slotIds.length)); + state.suppressClick = true; + } return; } @@ -4402,13 +5180,34 @@ return; } - const cardButton = target.closest(".tarot-frame-card[data-card-id]"); - if (!(cardButton instanceof HTMLButtonElement)) { + if (state.suppressClick) { + state.suppressClick = false; return; } - if (state.suppressClick) { - state.suppressClick = false; + const cardButton = target.closest(".tarot-frame-card[data-card-id]"); + if (!(cardButton instanceof HTMLButtonElement)) { + const emptyButton = target.closest(".tarot-frame-card.is-empty[data-slot-id]"); + if (emptyButton instanceof HTMLButtonElement && getSelectedSlotIds().length > 0) { + const selectionUpdate = setSelectedSlotIds([]); + if (selectionUpdate.changed) { + render({ preserveViewport: true }); + } + setStatus(buildSelectionStatusMessage(0)); + } + return; + } + + if (isSlotSelected(cardButton.dataset.slotId)) { + return; + } + + if (getSelectedSlotIds().length > 0) { + const selectionUpdate = setSelectedSlotIds([]); + if (selectionUpdate.changed) { + render({ preserveViewport: true }); + } + setStatus(buildSelectionStatusMessage(0)); return; } @@ -4432,15 +5231,18 @@ return; } - const customButton = target.closest(".tarot-frame-card[data-card-id][data-slot-id]"); - if (customButton instanceof HTMLButtonElement) { - const customCard = getCardMap(getCards()).get(String(customButton.dataset.cardId || "").trim()) || null; - if (isCustomFrameCard(customCard)) { - event.preventDefault(); - event.stopPropagation(); - editCustomCard(String(customButton.dataset.slotId || ""), String(customButton.dataset.cardId || "")); - return; - } + const occupiedButton = target.closest(".tarot-frame-card[data-card-id][data-slot-id]"); + if (occupiedButton instanceof HTMLButtonElement) { + event.preventDefault(); + event.stopPropagation(); + openCardActionMenu( + String(occupiedButton.dataset.slotId || ""), + String(occupiedButton.dataset.cardId || ""), + event.clientX, + event.clientY, + { focusFirstButton: false } + ); + return; } const emptyButton = target.closest(".tarot-frame-card.is-empty[data-slot-id]"); @@ -4498,6 +5300,10 @@ closeCardInsertMenu(); } + if (state.cardActionMenu.open && cardActionMenuEl && !cardActionMenuEl.contains(target)) { + closeCardActionMenu(); + } + if (cardCustomEditorEl && !cardCustomEditorEl.hidden && !cardCustomEditorEl.contains(target)) { closeCustomCardEditor(); } @@ -4512,10 +5318,26 @@ } function handleDocumentKeydown(event) { + const zoomDirection = getFrameGridZoomShortcutDirection(event); + if (zoomDirection && shouldHandleFrameZoomShortcut(event)) { + event.preventDefault(); + setGridZoomStepIndex(state.gridZoomStepIndex + zoomDirection); + return; + } + if (event.key !== "Escape") { return; } + if (getSelectedSlotIds().length > 0) { + const selectionUpdate = setSelectedSlotIds([]); + if (selectionUpdate.changed) { + render({ preserveViewport: true }); + } + setStatus(buildSelectionStatusMessage(0)); + return; + } + if (state.gridFocusMode) { setGridFocusMode(false); return; @@ -4533,6 +5355,9 @@ if (state.cardInsertMenu.open) { closeCardInsertMenu(); } + if (state.cardActionMenu.open) { + closeCardActionMenu(); + } if (cardCustomEditorEl && !cardCustomEditorEl.hidden) { closeCustomCardEditor(); } @@ -4702,15 +5527,8 @@ context.restore(); } - function drawSlotToCanvas(context, x, y, width, height, card, image, customBackgroundImage = null) { + function drawSlotToCanvas(context, x, y, width, height, card, image, customBackgroundImage = null, isFlipped = false) { if (!card) { - context.save(); - context.setLineDash([6, 6]); - context.lineWidth = 1.5; - context.strokeStyle = "rgba(148, 163, 184, 0.42)"; - drawRoundedRectPath(context, x + 1, y + 1, width - 2, height - 2, 10); - context.stroke(); - context.restore(); return; } @@ -4722,6 +5540,11 @@ const faceModel = showImage ? null : buildCardTextFaceModel(card); context.save(); + if (isFlipped) { + context.translate(cardX + (cardWidth / 2), cardY + (cardHeight / 2)); + context.rotate(Math.PI); + context.translate(-(cardX + (cardWidth / 2)), -(cardY + (cardHeight / 2))); + } drawRoundedRectPath(context, cardX, cardY, cardWidth, cardHeight, 0); context.clip(); if (showImage && image) { @@ -4753,7 +5576,6 @@ } drawTextFaceToCanvas(context, cardX, cardY, cardWidth, cardHeight, faceModel); } - context.restore(); if (showImage && state.showInfo) { const overlayText = getCardOverlayLabel(card); @@ -4778,6 +5600,8 @@ }); } } + + context.restore(); } function loadCardImage(src) { @@ -4885,7 +5709,8 @@ EXPORT_SLOT_HEIGHT, card, card ? resolvedImages.get(getCardId(card)) : null, - card ? resolvedCustomBackgroundImages.get(getCardId(card)) : null + card ? resolvedCustomBackgroundImages.get(getCardId(card)) : null, + card ? isSlotFlipped(slotId) : false ); } } @@ -4927,6 +5752,7 @@ const { tarotFrameBoardEl, tarotFrameOverviewEl, + tarotFrameSelectionChipEl, tarotFramePanToggleEl, tarotFrameFocusToggleEl, tarotFrameFocusExitEl, @@ -4957,6 +5783,7 @@ tarotFrameBoardEl.addEventListener("click", handleBoardClick); tarotFrameBoardEl.addEventListener("dragstart", handleNativeDragStart); tarotFrameBoardEl.addEventListener("contextmenu", handleBoardContextMenu); + tarotFrameBoardEl.addEventListener("wheel", handleBoardWheel, { passive: false }); tarotFrameBoardEl.addEventListener("touchstart", handleBoardTouchStart, { passive: false }); } @@ -4998,6 +5825,21 @@ }); } + if (tarotFrameSelectionChipEl) { + tarotFrameSelectionChipEl.addEventListener("click", (event) => { + event.stopPropagation(); + if (state.exportInProgress || !getSelectedSlotIds().length) { + return; + } + + const selectionUpdate = setSelectedSlotIds([]); + if (selectionUpdate.changed) { + render({ preserveViewport: true }); + } + setStatus(buildSelectionStatusMessage(0)); + }); + } + if (tarotFramePanToggleEl) { tarotFramePanToggleEl.addEventListener("click", (event) => { event.stopPropagation(); diff --git a/index.html b/index.html index 2a7c4b2..523bdfa 100644 --- a/index.html +++ b/index.html @@ -16,7 +16,7 @@ - +
@@ -313,9 +313,10 @@

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. 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.

+

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 desktop, Ctrl/Cmd-click builds a multi-card selection and dragging any selected card moves the whole group. On touch screens, long-press a card and choose Select Multiple, then drag any selected card to move the set; two-finger pan still moves around the board. Use Full Screen when you want an uncluttered board view for panning and placement.

+ @@ -805,10 +806,12 @@
-