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 @@ - +