diff --git a/app/styles.css b/app/styles.css index 9f4528c..02983aa 100644 --- a/app/styles.css +++ b/app/styles.css @@ -2050,6 +2050,34 @@ color: #f8fafc; } + .tarot-frame-card-action-deck { + display: grid; + gap: 6px; + padding: 8px 2px 2px; + color: #cbd5e1; + font-size: 11px; + font-weight: 700; + letter-spacing: 0.05em; + text-transform: uppercase; + } + + .tarot-frame-card-action-deck-select { + width: 100%; + padding: 9px 10px; + border: 1px solid rgba(99, 102, 241, 0.34); + border-radius: 12px; + background: rgba(15, 23, 42, 0.7); + color: #f8fafc; + font-size: 13px; + font-weight: 600; + } + + .tarot-frame-card-action-deck-actions { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 6px; + } + .tarot-frame-card-custom-editor { position: fixed; z-index: 42; @@ -3357,6 +3385,15 @@ font-size: 12px; line-height: 1.2; } + .detail-pane-export-controls { + margin-left: auto; + display: inline-flex; + align-items: center; + gap: 8px; + } + .detail-export-btn { + white-space: nowrap; + } .planet-meta-grid { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); @@ -4771,6 +4808,90 @@ word-break: break-word; } + .alpha-gematria-keyboard { + display: grid; + gap: 8px; + padding: 10px; + border: 1px solid #2f2f39; + border-radius: 12px; + background: #0f0f17; + } + + .alpha-gematria-keyboard[hidden] { + display: none; + } + + .alpha-gematria-keyboard-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + } + + .alpha-gematria-keyboard-label { + color: #a1a1aa; + font-size: 11px; + letter-spacing: 0.02em; + text-transform: uppercase; + } + + .alpha-gematria-keyboard-script { + min-width: 140px; + padding: 5px 8px; + border-radius: 6px; + border: 1px solid #3f3f46; + background: #09090b; + color: #f4f4f5; + font-size: 12px; + } + + .alpha-gematria-keyboard-grid { + display: grid; + grid-template-columns: repeat(9, minmax(0, 1fr)); + gap: 6px; + } + + .alpha-gematria-keyboard-grid.is-disabled { + opacity: 0.6; + pointer-events: none; + } + + .alpha-gematria-keyboard-key, + .alpha-gematria-keyboard-action { + border: 1px solid #3f3f46; + border-radius: 8px; + background: #161622; + color: #f4f4f5; + font-size: 15px; + line-height: 1; + min-height: 36px; + padding: 6px; + cursor: pointer; + } + + .alpha-gematria-keyboard-key:hover, + .alpha-gematria-keyboard-action:hover { + border-color: #6366f1; + background: #1d1d31; + } + + .alpha-gematria-keyboard-actions { + display: grid; + grid-template-columns: repeat(5, minmax(0, 1fr)); + gap: 6px; + } + + .alpha-gematria-keyboard-actions.is-disabled { + opacity: 0.6; + pointer-events: none; + } + + .alpha-gematria-keyboard-action { + font-size: 12px; + min-height: 34px; + padding: 6px 8px; + } + .alpha-gematria-matches { display: grid; grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); @@ -4865,6 +4986,10 @@ .alpha-gematria-controls.is-reverse-cipher-mode { grid-template-columns: minmax(0, 1fr); } + + .alpha-gematria-keyboard-actions { + grid-template-columns: repeat(3, minmax(0, 1fr)); + } } .alpha-tabs { diff --git a/app/ui-alphabet-gematria.js b/app/ui-alphabet-gematria.js index 4323771..b2c39f9 100644 --- a/app/ui-alphabet-gematria.js +++ b/app/ui-alphabet-gematria.js @@ -14,7 +14,11 @@ inputLabelEl: null, cipherLabelEl: null, reverseCiphersEl: null, - reverseCipherHintEl: null + reverseCipherHintEl: null, + keyboardEl: null, + keyboardScriptEl: null, + keyboardGridEl: null, + keyboardActionsEl: null }) }; @@ -35,7 +39,8 @@ dictionaryLookupCache: new Map(), reverseRequestId: 0, anagramRequestId: 0, - dictionaryRequestId: 0 + dictionaryRequestId: 0, + keyboardScriptId: "english" }; function getAlphabets() { @@ -57,10 +62,184 @@ inputLabelEl: null, cipherLabelEl: null, reverseCiphersEl: null, - reverseCipherHintEl: null + reverseCipherHintEl: null, + keyboardEl: null, + keyboardScriptEl: null, + keyboardGridEl: null, + keyboardActionsEl: null }; } + function uniqueChars(values) { + return Array.from(new Set((Array.isArray(values) ? values : []) + .map((value) => String(value || "").trim()) + .filter(Boolean))); + } + + function collectAlphabetChars(entries, keys) { + const sourceEntries = Array.isArray(entries) ? entries : []; + const sourceKeys = Array.isArray(keys) ? keys : []; + const chars = []; + + sourceEntries.forEach((entry) => { + sourceKeys.forEach((key) => { + const value = String(entry?.[key] || "").trim(); + if (!value) { + return; + } + + const firstChar = [...value][0] || ""; + if (firstChar) { + chars.push(firstChar); + } + }); + }); + + return uniqueChars(chars); + } + + function getKeyboardLayouts() { + const db = state.db || getFallbackGematriaDb(); + const alphabets = getAlphabets() || {}; + const englishChars = uniqueChars(String(db.baseAlphabet || "abcdefghijklmnopqrstuvwxyz").toLowerCase().split("")); + const hebrewChars = collectAlphabetChars(alphabets.hebrew, ["char"]); + const greekChars = collectAlphabetChars([...(alphabets.greek || []), ...(alphabets.greekArchaic || [])], ["charLower", "char", "charFinal"]); + const arabicChars = collectAlphabetChars(alphabets.arabic, ["char"]); + const enochianChars = collectAlphabetChars(alphabets.enochian, ["char"]); + + return [ + { id: "english", label: "English", chars: englishChars }, + { id: "hebrew", label: "Hebrew", chars: hebrewChars }, + { id: "greek", label: "Greek", chars: greekChars }, + { id: "arabic", label: "Arabic", chars: arabicChars }, + { id: "enochian", label: "Enochian", chars: enochianChars } + ].filter((layout) => layout.chars.length > 0); + } + + function setGematriaInputValue(nextValue) { + const { inputEl } = getElements(); + if (!(inputEl instanceof HTMLTextAreaElement)) { + return; + } + + inputEl.value = String(nextValue || ""); + inputEl.dispatchEvent(new Event("input", { bubbles: true })); + inputEl.focus({ preventScroll: true }); + } + + function insertGematriaText(insertValue) { + const { inputEl } = getElements(); + if (!(inputEl instanceof HTMLTextAreaElement)) { + return; + } + + const text = String(insertValue || ""); + if (!text) { + return; + } + + const start = Number.isFinite(inputEl.selectionStart) ? inputEl.selectionStart : inputEl.value.length; + const end = Number.isFinite(inputEl.selectionEnd) ? inputEl.selectionEnd : start; + const nextValue = `${inputEl.value.slice(0, start)}${text}${inputEl.value.slice(end)}`; + setGematriaInputValue(nextValue); + + const nextCaret = start + text.length; + inputEl.setSelectionRange(nextCaret, nextCaret); + } + + function backspaceGematriaText() { + const { inputEl } = getElements(); + if (!(inputEl instanceof HTMLTextAreaElement)) { + return; + } + + const start = Number.isFinite(inputEl.selectionStart) ? inputEl.selectionStart : inputEl.value.length; + const end = Number.isFinite(inputEl.selectionEnd) ? inputEl.selectionEnd : start; + if (start === 0 && end === 0) { + return; + } + + let nextStart = start; + let nextEnd = end; + if (start === end) { + nextStart = Math.max(0, start - 1); + } + + const nextValue = `${inputEl.value.slice(0, nextStart)}${inputEl.value.slice(nextEnd)}`; + setGematriaInputValue(nextValue); + inputEl.setSelectionRange(nextStart, nextStart); + } + + function renderKeyboardLayout() { + const { keyboardScriptEl, keyboardGridEl } = getElements(); + if (!(keyboardScriptEl instanceof HTMLSelectElement) || !(keyboardGridEl instanceof HTMLElement)) { + return; + } + + const layouts = getKeyboardLayouts(); + if (!layouts.length) { + keyboardScriptEl.replaceChildren(); + keyboardGridEl.replaceChildren(); + return; + } + + const preferredLayoutId = String(state.keyboardScriptId || "english").trim(); + const activeLayout = layouts.find((layout) => layout.id === preferredLayoutId) || layouts[0]; + state.keyboardScriptId = activeLayout.id; + + keyboardScriptEl.replaceChildren(); + layouts.forEach((layout) => { + const optionEl = document.createElement("option"); + optionEl.value = layout.id; + optionEl.textContent = layout.label; + keyboardScriptEl.appendChild(optionEl); + }); + keyboardScriptEl.value = state.keyboardScriptId; + + const fragment = document.createDocumentFragment(); + activeLayout.chars.forEach((char) => { + const buttonEl = document.createElement("button"); + buttonEl.type = "button"; + buttonEl.className = "alpha-gematria-keyboard-key"; + buttonEl.dataset.keyboardInsert = char; + buttonEl.textContent = char; + fragment.appendChild(buttonEl); + }); + keyboardGridEl.replaceChildren(fragment); + keyboardGridEl.setAttribute("lang", state.keyboardScriptId); + } + + function handleKeyboardAction(action) { + const normalizedAction = String(action || "").trim().toLowerCase(); + if (!normalizedAction) { + return; + } + + if (normalizedAction === "backspace") { + backspaceGematriaText(); + return; + } + + if (normalizedAction === "space") { + insertGematriaText(" "); + return; + } + + if (normalizedAction === "star") { + insertGematriaText("*"); + return; + } + + if (normalizedAction === "question") { + insertGematriaText("?"); + return; + } + + if (normalizedAction === "clear") { + setGematriaInputValue(""); + } + } + function isReverseMode() { return state.activeMode === "reverse"; } @@ -142,14 +321,57 @@ function addScriptCharMapEntry(map, scriptChar, mappedLetters) { const key = String(scriptChar || "").trim(); - const value = String(mappedLetters || "").trim(); - if (!key || !value) { + if (!key) { + return; + } + + const value = typeof mappedLetters === "string" + ? { mappedLetters: String(mappedLetters || "").trim() } + : mappedLetters; + if (!value || (typeof value === "object" && !Number.isFinite(Number(value.ordinal)) && !String(value.mappedLetters || "").trim())) { return; } map.set(key, value); } + function getEntryOrdinal(entry, fallbackOrdinal) { + const explicitOrdinal = Number(entry?.index); + if (Number.isFinite(explicitOrdinal) && explicitOrdinal > 0) { + return Math.trunc(explicitOrdinal); + } + return Math.trunc(Number(fallbackOrdinal) || 0); + } + + function addScriptOrdinalEntries(map, entries, charKeys) { + const sourceEntries = Array.isArray(entries) ? entries : []; + const sourceCharKeys = Array.isArray(charKeys) ? charKeys : []; + + sourceEntries.forEach((entry, entryIndex) => { + const ordinal = getEntryOrdinal(entry, entryIndex + 1); + if (!ordinal) { + return; + } + + sourceCharKeys.forEach((charKey) => { + const charValue = String(entry?.[charKey] || "").trim(); + if (!charValue) { + return; + } + + const firstChar = [...charValue][0] || ""; + if (!firstChar) { + return; + } + + addScriptCharMapEntry(map, firstChar, { + ordinal, + displayChar: firstChar + }); + }); + }); + } + function buildGematriaScriptMap(baseAlphabet) { const map = new Map(); const alphabets = getAlphabets() || {}; @@ -157,35 +379,51 @@ const greekClassicalLetters = Array.isArray(alphabets.greek) ? alphabets.greek : []; const greekArchaicLetters = Array.isArray(alphabets.greekArchaic) ? alphabets.greekArchaic : []; const greekLetters = [...greekClassicalLetters, ...greekArchaicLetters]; + const arabicLetters = Array.isArray(alphabets.arabic) ? alphabets.arabic : []; + const enochianLetters = Array.isArray(alphabets.enochian) ? alphabets.enochian : []; + + addScriptOrdinalEntries(map, hebrewLetters, ["char"]); + addScriptOrdinalEntries(map, greekLetters, ["char", "charLower", "charFinal"]); + addScriptOrdinalEntries(map, arabicLetters, ["char"]); + addScriptOrdinalEntries(map, enochianLetters, ["char"]); hebrewLetters.forEach((entry) => { const mapped = transliterationToBaseLetters(entry?.transliteration, baseAlphabet); - addScriptCharMapEntry(map, entry?.char, mapped); + if (!map.has(String(entry?.char || "").trim())) { + addScriptCharMapEntry(map, entry?.char, mapped); + } }); greekLetters.forEach((entry) => { const mapped = transliterationToBaseLetters(entry?.transliteration, baseAlphabet); - addScriptCharMapEntry(map, entry?.char, mapped); - addScriptCharMapEntry(map, entry?.charLower, mapped); - addScriptCharMapEntry(map, entry?.charFinal, mapped); - }); - - const hebrewFinalForms = { - ך: "k", - ם: "m", - ן: "n", - ף: "p", - ץ: "t" - }; - - Object.entries(hebrewFinalForms).forEach(([char, mapped]) => { - if (!map.has(char) && baseAlphabet.includes(mapped)) { - addScriptCharMapEntry(map, char, mapped); + if (!map.has(String(entry?.char || "").trim())) { + addScriptCharMapEntry(map, entry?.char, mapped); + } + if (!map.has(String(entry?.charLower || "").trim())) { + addScriptCharMapEntry(map, entry?.charLower, mapped); + } + if (!map.has(String(entry?.charFinal || "").trim())) { + addScriptCharMapEntry(map, entry?.charFinal, mapped); } }); - if (!map.has("ς") && baseAlphabet.includes("s")) { - addScriptCharMapEntry(map, "ς", "s"); + const hebrewFinalForms = { + ך: "כ", + ם: "מ", + ן: "נ", + ף: "פ", + ץ: "צ" + }; + + Object.entries(hebrewFinalForms).forEach(([char, sourceChar]) => { + const sourceEntry = map.get(sourceChar); + if (!map.has(char) && sourceEntry) { + addScriptCharMapEntry(map, char, sourceEntry); + } + }); + + if (!map.has("ς") && map.has("σ")) { + addScriptCharMapEntry(map, "ς", map.get("σ")); } return map; @@ -423,7 +661,11 @@ inputLabelEl, cipherLabelEl, reverseCiphersEl, - reverseCipherHintEl + reverseCipherHintEl, + keyboardEl, + keyboardScriptEl, + keyboardGridEl, + keyboardActionsEl } = getElements(); const reverseMode = isReverseMode(); @@ -483,9 +725,29 @@ } } + if (keyboardEl) { + keyboardEl.hidden = reverseMode; + } + + if (keyboardScriptEl) { + keyboardScriptEl.disabled = reverseMode; + } + + if (keyboardGridEl) { + keyboardGridEl.classList.toggle("is-disabled", reverseMode); + } + + if (keyboardActionsEl) { + keyboardActionsEl.classList.toggle("is-disabled", reverseMode); + } + if (!reverseMode && !anagramMode && !dictionaryMode) { clearReverseMatches(matchesEl); } + + if (!reverseMode) { + renderKeyboardLayout(); + } } function parseReverseLookupValue(rawValue) { @@ -901,10 +1163,44 @@ let count = 0; [...normalizedInput].forEach((char) => { - const mappedLetters = baseAlphabet.includes(char) - ? char - : (scriptMap.get(char) || ""); + if (baseAlphabet.includes(char)) { + const index = baseAlphabet.indexOf(char); + if (index < 0) { + return; + } + const value = Number(cipher.values[index]); + if (!Number.isFinite(value)) { + return; + } + + count += 1; + total += value; + letterParts.push(`${char.toUpperCase()}(${value})`); + return; + } + + const mappedEntry = scriptMap.get(char); + if (!mappedEntry) { + return; + } + + const ordinal = Number(mappedEntry.ordinal); + if (Number.isFinite(ordinal) && ordinal > 0) { + const index = Math.trunc(ordinal) - 1; + const value = Number(cipher.values[index]); + if (!Number.isFinite(value)) { + return; + } + + const displayChar = String(mappedEntry.displayChar || char || "").trim() || char; + count += 1; + total += value; + letterParts.push(`${displayChar}(${value})`); + return; + } + + const mappedLetters = String(mappedEntry.mappedLetters || ""); if (!mappedLetters) { return; } @@ -984,7 +1280,7 @@ } function bindGematriaListeners() { - const { cipherEl, inputEl, modeEls, reverseCiphersEl } = getElements(); + const { cipherEl, inputEl, modeEls, reverseCiphersEl, keyboardScriptEl, keyboardGridEl, keyboardActionsEl } = getElements(); if (state.listenersBound || !cipherEl || !inputEl) { return; } @@ -1033,6 +1329,39 @@ renderGematriaResult(); }); + keyboardScriptEl?.addEventListener("change", () => { + state.keyboardScriptId = String(keyboardScriptEl.value || "english").trim() || "english"; + renderKeyboardLayout(); + }); + + keyboardGridEl?.addEventListener("click", (event) => { + const target = event.target; + if (!(target instanceof Element)) { + return; + } + + const keyButton = target.closest("[data-keyboard-insert]"); + if (!(keyButton instanceof HTMLButtonElement) || keyButton.disabled) { + return; + } + + insertGematriaText(String(keyButton.dataset.keyboardInsert || "")); + }); + + keyboardActionsEl?.addEventListener("click", (event) => { + const target = event.target; + if (!(target instanceof Element)) { + return; + } + + const actionButton = target.closest("[data-keyboard-action]"); + if (!(actionButton instanceof HTMLButtonElement) || actionButton.disabled) { + return; + } + + handleKeyboardAction(actionButton.dataset.keyboardAction); + }); + state.listenersBound = true; } @@ -1048,6 +1377,7 @@ void loadGematriaDb().then(() => { refreshScriptMap((state.db || getFallbackGematriaDb()).baseAlphabet); renderGematriaCipherOptions(); + renderKeyboardLayout(); renderGematriaResult(); }); } diff --git a/app/ui-alphabet.js b/app/ui-alphabet.js index 7487555..2cb650d 100644 --- a/app/ui-alphabet.js +++ b/app/ui-alphabet.js @@ -57,6 +57,7 @@ let gematriaCipherEl, gematriaInputEl, gematriaResultEl, gematriaBreakdownEl; let gematriaModeEls, gematriaMatchesEl, gematriaInputLabelEl, gematriaCipherLabelEl; let gematriaReverseCiphersEl, gematriaReverseCipherHintEl; + let gematriaKeyboardEl, gematriaKeyboardScriptEl, gematriaKeyboardGridEl, gematriaKeyboardActionsEl; function getElements() { alphabetLettersSectionEl = document.getElementById("alphabet-letters-section"); @@ -88,6 +89,10 @@ gematriaCipherLabelEl = document.getElementById("alpha-gematria-cipher-label"); gematriaReverseCiphersEl = document.getElementById("alpha-gematria-reverse-ciphers"); gematriaReverseCipherHintEl = document.getElementById("alpha-gematria-reverse-cipher-hint"); + gematriaKeyboardEl = document.getElementById("alpha-gematria-keyboard"); + gematriaKeyboardScriptEl = document.getElementById("alpha-gematria-keyboard-script"); + gematriaKeyboardGridEl = document.getElementById("alpha-gematria-keyboard-grid"); + gematriaKeyboardActionsEl = document.getElementById("alpha-gematria-keyboard-actions"); } function getGematriaElements() { @@ -102,7 +107,11 @@ inputLabelEl: gematriaInputLabelEl, cipherLabelEl: gematriaCipherLabelEl, reverseCiphersEl: gematriaReverseCiphersEl, - reverseCipherHintEl: gematriaReverseCipherHintEl + reverseCipherHintEl: gematriaReverseCipherHintEl, + keyboardEl: gematriaKeyboardEl, + keyboardScriptEl: gematriaKeyboardScriptEl, + keyboardGridEl: gematriaKeyboardGridEl, + keyboardActionsEl: gematriaKeyboardActionsEl }; } diff --git a/app/ui-chrome.js b/app/ui-chrome.js index b6af9cc..601f3aa 100644 --- a/app/ui-chrome.js +++ b/app/ui-chrome.js @@ -8,6 +8,7 @@ const sidebarControllers = new WeakMap(); const sidebarControllerRegistry = new Map(); const detailControllers = new WeakMap(); + const detailExportInProgress = new WeakSet(); const AUTO_COLLAPSE_ENTRY_SELECTOR = [ ".planet-list-item", ".tarot-list-item", @@ -403,6 +404,7 @@ const sectionId = layout.closest("section")?.id || `layout-${index + 1}`; const panelId = detailPanel.id || `${sectionId}-detail-panel`; detailPanel.id = panelId; + ensureDetailPaneExportControl(detailPanel, sectionId); const detailStorageKey = `${DETAIL_COLLAPSE_STORAGE_PREFIX}${sectionId}`; const sidebarStorageKey = `${SIDEBAR_COLLAPSE_STORAGE_PREFIX}${sectionId}`; @@ -439,6 +441,166 @@ }); } + function sanitizeExportToken(value, fallback = "detail") { + const normalized = String(value || "") + .trim() + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, ""); + return normalized || fallback; + } + + function canvasToWebpBlob(canvas, quality = 1) { + return new Promise((resolve, reject) => { + canvas.toBlob((blob) => { + if (blob) { + resolve(blob); + return; + } + reject(new Error("Detail pane export failed.")); + }, "image/webp", quality); + }); + } + + async function exportDetailPaneAsWebp(detailPanel, sectionId, buttonEl = null) { + if (!(detailPanel instanceof HTMLElement) || detailExportInProgress.has(detailPanel)) { + return; + } + + detailExportInProgress.add(detailPanel); + const originalButtonLabel = buttonEl instanceof HTMLButtonElement ? buttonEl.textContent : ""; + if (buttonEl instanceof HTMLButtonElement) { + buttonEl.disabled = true; + buttonEl.textContent = "Exporting..."; + } + + let exportBlobUrl = ""; + let sandboxEl = null; + + try { + const html2canvas = window.html2canvas; + if (typeof html2canvas !== "function") { + throw new Error("Detail export library is unavailable. Refresh the page and try again."); + } + + const exportClone = detailPanel.cloneNode(true); + if (!(exportClone instanceof HTMLElement)) { + throw new Error("Detail pane is not ready to export."); + } + + exportClone.querySelectorAll(".detail-pane-export-controls").forEach((node) => node.remove()); + + const contentWidth = Math.max( + 320, + Math.ceil(detailPanel.clientWidth || detailPanel.getBoundingClientRect().width || 0) + ); + const contentHeight = Math.max( + 240, + Math.ceil(detailPanel.scrollHeight || detailPanel.getBoundingClientRect().height || 0) + ); + + sandboxEl = document.createElement("div"); + sandboxEl.style.position = "fixed"; + sandboxEl.style.left = "-100000px"; + sandboxEl.style.top = "0"; + sandboxEl.style.width = `${contentWidth}px`; + sandboxEl.style.height = `${contentHeight}px`; + sandboxEl.style.overflow = "hidden"; + sandboxEl.style.pointerEvents = "none"; + sandboxEl.style.opacity = "0"; + + exportClone.style.width = `${contentWidth}px`; + exportClone.style.minWidth = `${contentWidth}px`; + exportClone.style.maxWidth = `${contentWidth}px`; + exportClone.style.height = `${contentHeight}px`; + exportClone.style.minHeight = `${contentHeight}px`; + exportClone.style.maxHeight = `${contentHeight}px`; + exportClone.style.overflow = "visible"; + + sandboxEl.appendChild(exportClone); + document.body.appendChild(sandboxEl); + + const exportScale = Math.max(2, Math.min(2.5, Number(window.devicePixelRatio) || 1)); + const canvas = await html2canvas(exportClone, { + backgroundColor: null, + scale: exportScale, + useCORS: true, + allowTaint: false, + logging: false, + width: contentWidth, + height: contentHeight, + windowWidth: contentWidth, + windowHeight: contentHeight, + scrollX: 0, + scrollY: 0, + imageTimeout: 12000, + onclone(clonedDocument) { + clonedDocument.querySelectorAll(".detail-pane-export-controls").forEach((node) => node.remove()); + } + }); + + const exportBlob = await canvasToWebpBlob(canvas, 1); + exportBlobUrl = URL.createObjectURL(exportBlob); + + const headingEl = detailPanel.querySelector("h1, h2, h3"); + const labelToken = sanitizeExportToken(headingEl?.textContent || detailPanel.id || "detail-pane", "detail-pane"); + const sectionToken = sanitizeExportToken(sectionId, "section"); + const dateToken = new Date().toISOString().slice(0, 10); + + const downloadLink = document.createElement("a"); + downloadLink.href = exportBlobUrl; + downloadLink.download = `${sectionToken}-${labelToken}-${dateToken}.webp`; + document.body.appendChild(downloadLink); + downloadLink.click(); + downloadLink.remove(); + } catch (error) { + window.alert(error instanceof Error ? error.message : "Unable to export this detail pane."); + } finally { + detailExportInProgress.delete(detailPanel); + if (buttonEl instanceof HTMLButtonElement) { + buttonEl.disabled = false; + buttonEl.textContent = originalButtonLabel || "Export WebP"; + } + + if (sandboxEl instanceof HTMLElement) { + sandboxEl.remove(); + } + + if (exportBlobUrl) { + URL.revokeObjectURL(exportBlobUrl); + } + } + } + + function ensureDetailPaneExportControl(detailPanel, sectionId) { + if (!(detailPanel instanceof HTMLElement) || detailPanel.querySelector(".detail-pane-export-controls")) { + return; + } + + const hostEl = detailPanel.querySelector(".detail-sequence-nav") + || detailPanel.querySelector(".planet-detail-heading") + || detailPanel.querySelector(".tarot-detail-heading") + || detailPanel.querySelector(".kab-detail-heading") + || detailPanel; + if (!(hostEl instanceof HTMLElement)) { + return; + } + + const controlsEl = document.createElement("div"); + controlsEl.className = "detail-pane-export-controls"; + + const exportButtonEl = document.createElement("button"); + exportButtonEl.type = "button"; + exportButtonEl.className = "detail-sequence-btn detail-export-btn"; + exportButtonEl.textContent = "Export WebP"; + exportButtonEl.addEventListener("click", () => { + exportDetailPaneAsWebp(detailPanel, sectionId, exportButtonEl); + }); + + controlsEl.appendChild(exportButtonEl); + hostEl.appendChild(controlsEl); + } + function setTopbarDropdownOpen(dropdownEl, isOpen) { if (!(dropdownEl instanceof HTMLElement)) { return; diff --git a/app/ui-tarot-frame.js b/app/ui-tarot-frame.js index a62210b..67a13a0 100644 --- a/app/ui-tarot-frame.js +++ b/app/ui-tarot-frame.js @@ -62,7 +62,7 @@ const FRAME_TOUCH_DRAG_ACTIVATION_DELAY_MS = 140; const FRAME_CUSTOM_CARD_PREFIX = "frame-custom-text:"; const FRAME_CUSTOM_CARD_ACTION_ID = "create-custom-text"; - const EXPORT_SLOT_WIDTH = 120; + const EXPORT_SLOT_WIDTH = 180; const EXPORT_SLOT_HEIGHT = Math.round((EXPORT_SLOT_WIDTH * TAROT_CARD_HEIGHT_RATIO) / TAROT_CARD_WIDTH_RATIO); const EXPORT_CARD_INSET = 0; const EXPORT_GRID_GAP = 10; @@ -76,7 +76,7 @@ webp: { mimeType: "image/webp", extension: "webp", - quality: 0.98 + quality: 1 } }; const FRAME_LAYOUT_GROUPS = [ @@ -226,6 +226,8 @@ currentLayoutId: "frames", customLayouts: [], customCards: new Map(), + slotDeckOverrides: new Map(), + frameDeckId: "", layoutNotesById: {}, exportInProgress: false, exportFormat: "webp", @@ -253,6 +255,9 @@ let cardActionMenuFlipEl = null; let cardActionMenuOpenEl = null; let cardActionMenuRemoveEl = null; + let cardActionMenuDeckApplyEl = null; + let cardActionMenuDeckResetEl = null; + let cardActionMenuDeckSelectEl = null; let cardPickerEl = null; let cardPickerTitleEl = null; let cardPickerSearchEl = null; @@ -307,6 +312,7 @@ tarotFrameSettingsToggleEl: document.getElementById("tarot-frame-settings-toggle"), tarotFrameSettingsPanelEl: document.getElementById("tarot-frame-settings-panel"), tarotFrameGridZoomEl: document.getElementById("tarot-frame-grid-zoom"), + tarotFrameDeckSelectEl: document.getElementById("tarot-frame-deck-select"), tarotFrameShowInfoEl: document.getElementById("tarot-frame-show-info"), tarotFrameHouseSettingsEl: document.getElementById("tarot-frame-house-settings"), tarotFrameHouseTopCardsVisibleEl: document.getElementById("tarot-frame-house-top-cards-visible"), @@ -542,12 +548,130 @@ return (leftSlot.row - rightSlot.row) || (leftSlot.column - rightSlot.column); } + function normalizeDeckIdValue(deckId) { + return String(deckId || "").trim().toLowerCase(); + } + + function getDeckOptionsList() { + const options = Array.isArray(tarotCardImages.getDeckOptions?.()) + ? tarotCardImages.getDeckOptions() + : []; + + const seen = new Set(); + return options + .map((option) => ({ + id: normalizeDeckIdValue(option?.id), + label: normalizeLabelText(option?.label || option?.id) + })) + .filter((option) => { + if (!option.id || seen.has(option.id)) { + return false; + } + seen.add(option.id); + return true; + }); + } + + function getFallbackDeckId() { + const options = getDeckOptionsList(); + const activeDeckId = normalizeDeckIdValue(tarotCardImages.getActiveDeck?.()); + if (activeDeckId && options.some((option) => option.id === activeDeckId)) { + return activeDeckId; + } + return options[0]?.id || ""; + } + + function normalizeFrameDeckId(deckId) { + const normalizedDeckId = normalizeDeckIdValue(deckId); + if (!normalizedDeckId) { + return ""; + } + + return getDeckOptionsList().some((option) => option.id === normalizedDeckId) + ? normalizedDeckId + : ""; + } + + function getFrameDeckId() { + const normalized = normalizeFrameDeckId(state.frameDeckId); + return normalized || getFallbackDeckId(); + } + + function getSlotDeckOverride(slotId) { + const targetSlotId = String(slotId || "").trim(); + if (!isValidSlotId(targetSlotId)) { + return ""; + } + + return normalizeFrameDeckId(state.slotDeckOverrides.get(targetSlotId)); + } + + function getResolvedSlotDeckId(slotId) { + return getSlotDeckOverride(slotId) || getFrameDeckId(); + } + + function setFrameDeckId(deckId) { + const nextDeckId = normalizeFrameDeckId(deckId); + const previousDeckId = normalizeFrameDeckId(state.frameDeckId); + state.frameDeckId = nextDeckId; + return previousDeckId !== nextDeckId; + } + + function setSlotDeckOverride(slotId, deckId) { + const targetSlotId = String(slotId || "").trim(); + if (!isValidSlotId(targetSlotId)) { + return false; + } + + const nextDeckId = normalizeFrameDeckId(deckId); + const previousDeckId = getSlotDeckOverride(targetSlotId); + if (!nextDeckId) { + state.slotDeckOverrides.delete(targetSlotId); + return Boolean(previousDeckId); + } + + state.slotDeckOverrides.set(targetSlotId, nextDeckId); + return previousDeckId !== nextDeckId; + } + + function buildDeckSelectOptions(selectEl, selectedDeckId, includeDefaultOption = false) { + if (!(selectEl instanceof HTMLSelectElement)) { + return; + } + + const options = getDeckOptionsList(); + const resolvedFrameDeckId = getFrameDeckId(); + const requestedDeckId = normalizeDeckIdValue(selectedDeckId); + const normalizedSelectedDeckId = options.some((option) => option.id === requestedDeckId) + ? requestedDeckId + : (includeDefaultOption ? "" : resolvedFrameDeckId || options[0]?.id || ""); + + selectEl.replaceChildren(); + if (includeDefaultOption) { + const defaultOptionEl = document.createElement("option"); + defaultOptionEl.value = ""; + const defaultDeckLabel = options.find((option) => option.id === resolvedFrameDeckId)?.label || "Current"; + defaultOptionEl.textContent = `Frame Default (${defaultDeckLabel})`; + selectEl.appendChild(defaultOptionEl); + } + + options.forEach((option) => { + const optionEl = document.createElement("option"); + optionEl.value = option.id; + optionEl.textContent = option.label || option.id; + selectEl.appendChild(optionEl); + }); + + selectEl.value = normalizedSelectedDeckId; + } + function buildFrameSettingsSnapshot() { const topModes = config.getHouseTopInfoModes?.() || {}; const bottomModes = config.getHouseBottomInfoModes?.() || {}; return { showInfo: Boolean(state.showInfo), + frameDeckId: normalizeFrameDeckId(state.frameDeckId), gridZoomScale: getGridZoomScale(), gridZoomStepIndex: state.gridZoomStepIndex, houseTopCardsVisible: config.getHouseTopCardsVisible?.() !== false, @@ -575,6 +699,9 @@ : fallback.gridZoomScale); return { showInfo: raw.showInfo === undefined ? fallback.showInfo : Boolean(raw.showInfo), + frameDeckId: raw.frameDeckId === undefined + ? normalizeFrameDeckId(fallback.frameDeckId) + : normalizeFrameDeckId(raw.frameDeckId), gridZoomScale: normalizedZoomScale, gridZoomStepIndex: Number.isFinite(rawZoomIndex) ? Math.max(0, Math.min(FRAME_GRID_ZOOM_STEPS.length - 1, rawZoomIndex)) @@ -602,7 +729,8 @@ .map(([slotId, cardId]) => ({ slotId: String(slotId || "").trim(), cardId: String(cardId || "").trim(), - isFlipped: Boolean(state.slotFlips.get(String(slotId || "").trim())) + isFlipped: Boolean(state.slotFlips.get(String(slotId || "").trim())), + deckId: getSlotDeckOverride(slotId) })) .filter((entry) => isValidSlotId(entry.slotId) && validCardIds.has(entry.cardId)) .sort((left, right) => { @@ -646,7 +774,8 @@ .map((entry) => ({ slotId: String(entry?.slotId || "").trim(), cardId: String(entry?.cardId || "").trim(), - isFlipped: Boolean(entry?.isFlipped) + isFlipped: Boolean(entry?.isFlipped), + deckId: normalizeFrameDeckId(entry?.deckId) })) .filter((entry) => isValidSlotId(entry.slotId) && entry.cardId) : []; @@ -915,6 +1044,7 @@ function applyFrameSettingsSnapshot(rawSettings) { const settings = normalizeFrameSettingsSnapshot(rawSettings); state.showInfo = settings.showInfo; + state.frameDeckId = normalizeFrameDeckId(settings.frameDeckId); state.gridZoomScale = settings.gridZoomScale; state.gridZoomStepIndex = settings.gridZoomStepIndex; config.setHouseTopCardsVisible?.(settings.houseTopCardsVisible); @@ -1426,7 +1556,36 @@ cardActionMenuRemoveEl.dataset.cardAction = "remove"; cardActionMenuRemoveEl.textContent = "Remove Card"; - cardActionMenuEl.append(cardActionMenuSelectEl, cardActionMenuFlipEl, cardActionMenuOpenEl, cardActionMenuRemoveEl); + const cardActionMenuDeckWrapEl = document.createElement("label"); + cardActionMenuDeckWrapEl.className = "tarot-frame-card-action-deck"; + const cardActionMenuDeckLabelEl = document.createElement("span"); + cardActionMenuDeckLabelEl.textContent = "Card Deck"; + cardActionMenuDeckSelectEl = document.createElement("select"); + cardActionMenuDeckSelectEl.className = "tarot-frame-card-action-deck-select"; + cardActionMenuDeckWrapEl.append(cardActionMenuDeckLabelEl, cardActionMenuDeckSelectEl); + + const cardActionMenuDeckActionsEl = document.createElement("div"); + cardActionMenuDeckActionsEl.className = "tarot-frame-card-action-deck-actions"; + cardActionMenuDeckApplyEl = document.createElement("button"); + cardActionMenuDeckApplyEl.type = "button"; + cardActionMenuDeckApplyEl.className = "tarot-frame-card-action-menu-item"; + cardActionMenuDeckApplyEl.dataset.cardAction = "deck-apply"; + cardActionMenuDeckApplyEl.textContent = "Apply Deck"; + cardActionMenuDeckResetEl = document.createElement("button"); + cardActionMenuDeckResetEl.type = "button"; + cardActionMenuDeckResetEl.className = "tarot-frame-card-action-menu-item"; + cardActionMenuDeckResetEl.dataset.cardAction = "deck-reset"; + cardActionMenuDeckResetEl.textContent = "Use Frame Deck"; + cardActionMenuDeckActionsEl.append(cardActionMenuDeckApplyEl, cardActionMenuDeckResetEl); + + cardActionMenuEl.append( + cardActionMenuSelectEl, + cardActionMenuFlipEl, + cardActionMenuOpenEl, + cardActionMenuRemoveEl, + cardActionMenuDeckWrapEl, + cardActionMenuDeckActionsEl + ); cardActionMenuEl.addEventListener("pointerdown", () => { state.cardActionMenu.clickArmed = true; }); @@ -1452,9 +1611,9 @@ 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") { + closeCardActionMenu(); const selectionUpdate = setSelectedSlotIds( isSlotSelected(slotId) ? getSelectedSlotIds().filter((selectedSlotId) => selectedSlotId !== slotId) @@ -1468,6 +1627,7 @@ } if (action === "flip") { + closeCardActionMenu(); if (toggleCardFlip(slotId)) { render({ preserveViewport: true }); } @@ -1475,14 +1635,46 @@ } if (action === "remove") { + closeCardActionMenu(); const removedCard = removeCardFromSlot(slotId); if (removedCard) { render({ preserveViewport: true }); - setStatus(`${getDisplayCardName(removedCard)} removed from ${describeSlot(slotId)}.`); + setStatus(`${getDisplayCardName(removedCard, slotId)} removed from ${describeSlot(slotId)}.`); } return; } + if (action === "deck-apply") { + const selectedDeckId = normalizeFrameDeckId(cardActionMenuDeckSelectEl?.value); + const changed = setSlotDeckOverride(slotId, selectedDeckId); + closeCardActionMenu(); + if (changed) { + render({ preserveViewport: true }); + const card = getCardMap(getCards()).get(cardId) || null; + if (selectedDeckId) { + const deckLabel = getDeckOptionsList().find((option) => option.id === selectedDeckId)?.label || selectedDeckId; + setStatus(`${getDisplayCardName(card, slotId)} now uses the ${deckLabel} deck at ${describeSlot(slotId)}.`); + } else { + const frameDeckLabel = getDeckOptionsList().find((option) => option.id === getFrameDeckId())?.label || "frame default"; + setStatus(`${getDisplayCardName(card, slotId)} now follows the frame deck (${frameDeckLabel}) at ${describeSlot(slotId)}.`); + } + } + return; + } + + if (action === "deck-reset") { + const changed = setSlotDeckOverride(slotId, ""); + closeCardActionMenu(); + if (changed) { + render({ preserveViewport: true }); + const card = getCardMap(getCards()).get(cardId) || null; + const frameDeckLabel = getDeckOptionsList().find((option) => option.id === getFrameDeckId())?.label || "frame default"; + setStatus(`${getDisplayCardName(card, slotId)} now follows the frame deck (${frameDeckLabel}) at ${describeSlot(slotId)}.`); + } + return; + } + + closeCardActionMenu(); openCardLightbox(cardId, slotId); }); @@ -1550,6 +1742,17 @@ if (cardActionMenuRemoveEl) { cardActionMenuRemoveEl.textContent = "Remove Card"; } + + buildDeckSelectOptions(cardActionMenuDeckSelectEl, getSlotDeckOverride(targetSlotId), true); + if (cardActionMenuDeckSelectEl) { + cardActionMenuDeckSelectEl.disabled = Boolean(state.exportInProgress); + } + if (cardActionMenuDeckResetEl) { + cardActionMenuDeckResetEl.disabled = !getSlotDeckOverride(targetSlotId) || Boolean(state.exportInProgress); + } + if (cardActionMenuDeckApplyEl) { + cardActionMenuDeckApplyEl.disabled = Boolean(state.exportInProgress); + } } function openCardActionMenu(slotId, cardId, anchorX, anchorY, options = {}) { @@ -1927,6 +2130,9 @@ state.customCards.set(nextCard.id, nextCard); const previousSlotId = findAssignedSlotIdByCardId(nextCard.id); const inheritedFlip = previousSlotId && previousSlotId !== targetSlotId ? isSlotFlipped(previousSlotId) : isSlotFlipped(targetSlotId); + const inheritedDeckId = previousSlotId && previousSlotId !== targetSlotId + ? getSlotDeckOverride(previousSlotId) + : getSlotDeckOverride(targetSlotId); if (previousSlotId && previousSlotId !== targetSlotId) { clearSlotAssignmentState(previousSlotId); } @@ -1934,7 +2140,8 @@ assignCardToSlot( targetSlotId, nextCard.id, - inheritedFlip + inheritedFlip, + inheritedDeckId ); pruneUnusedCustomCards(); state.layoutReady = true; @@ -2174,6 +2381,9 @@ const previousSlotId = findAssignedSlotIdByCardId(targetCardId); const inheritedFlip = previousSlotId && previousSlotId !== targetSlotId ? isSlotFlipped(previousSlotId) : isSlotFlipped(targetSlotId); + const inheritedDeckId = previousSlotId && previousSlotId !== targetSlotId + ? getSlotDeckOverride(previousSlotId) + : getSlotDeckOverride(targetSlotId); if (previousSlotId && previousSlotId !== targetSlotId) { clearSlotAssignmentState(previousSlotId); } @@ -2181,13 +2391,14 @@ assignCardToSlot( targetSlotId, targetCardId, - inheritedFlip + inheritedFlip, + inheritedDeckId ); pruneUnusedCustomCards(); state.layoutReady = true; render({ preserveViewport: true }); syncControls(); - setStatus(`${getDisplayCardName(card)} placed at ${describeSlot(targetSlotId)}.`); + setStatus(`${getDisplayCardName(card, targetSlotId)} placed at ${describeSlot(targetSlotId)}.`); } function clearGrid() { @@ -2203,6 +2414,7 @@ state.slotAssignments.clear(); state.slotFlips.clear(); + state.slotDeckOverrides.clear(); state.customCards.clear(); state.selectedSlotIds.clear(); state.layoutReady = true; @@ -3079,8 +3291,8 @@ return cards.map((card) => getCardId(card)).filter(Boolean).sort().join("|"); } - function resolveDeckOptions(card) { - const deckId = String(tarotCardImages.getActiveDeck?.() || "").trim(); + function resolveDeckOptions(card, slotId = "") { + const deckId = getResolvedSlotDeckId(slotId); const trumpNumber = card?.arcana === "Major" && Number.isFinite(Number(card?.number)) ? Number(card.number) : undefined; @@ -3095,7 +3307,7 @@ }; } - function resolveCardThumbnail(card) { + function resolveCardThumbnail(card, slotId = "") { if (!card) { return ""; } @@ -3104,7 +3316,7 @@ return ""; } - const deckOptions = resolveDeckOptions(card) || undefined; + const deckOptions = resolveDeckOptions(card, slotId) || undefined; return String( tarotCardImages.resolveTarotCardThumbnail?.(card.name, deckOptions) || tarotCardImages.resolveTarotCardImage?.(card.name, deckOptions) @@ -3112,12 +3324,12 @@ ).trim(); } - function getDisplayCardName(card) { + function getDisplayCardName(card, slotId = "") { if (isCustomFrameCard(card)) { return parseCustomCardText(card?.customText).primary || "Custom Card"; } - const label = tarotCardImages.getTarotCardDisplayName?.(card?.name, resolveDeckOptions(card) || undefined); + const label = tarotCardImages.getTarotCardDisplayName?.(card?.name, resolveDeckOptions(card, slotId) || undefined); return String(label || card?.name || "Tarot").trim() || "Tarot"; } @@ -3434,7 +3646,7 @@ return config.getHouseBottomCardsVisible?.() !== false; } - function buildCardTextFaceModel(card) { + function buildCardTextFaceModel(card, slotId = "") { if (isCustomFrameCard(card)) { const customText = parseCustomCardText(card?.customText); const backgroundColor = normalizeCustomCardBackgroundColor(card?.backgroundColor); @@ -3449,7 +3661,7 @@ } const label = state.showInfo ? buildHouseLabel(card) : null; - const displayName = normalizeLabelText(getDisplayCardName(card)); + const displayName = normalizeLabelText(getDisplayCardName(card, slotId)); if (card?.arcana !== "Major" && label?.primary) { return { @@ -3581,9 +3793,10 @@ state.slotAssignments.delete(targetSlotId); state.slotFlips.delete(targetSlotId); + state.slotDeckOverrides.delete(targetSlotId); } - function assignCardToSlot(slotId, cardId, isFlipped = false) { + function assignCardToSlot(slotId, cardId, isFlipped = false, deckId = "") { const targetSlotId = String(slotId || "").trim(); const targetCardId = String(cardId || "").trim(); if (!isValidSlotId(targetSlotId) || !targetCardId) { @@ -3592,6 +3805,7 @@ state.slotAssignments.set(targetSlotId, targetCardId); setSlotFlipState(targetSlotId, isFlipped); + setSlotDeckOverride(targetSlotId, deckId); return true; } @@ -3608,8 +3822,9 @@ } const isFlipped = isSlotFlipped(fromSlotId); + const deckId = getSlotDeckOverride(fromSlotId); clearSlotAssignmentState(fromSlotId); - assignCardToSlot(toSlotId, cardId, isFlipped); + assignCardToSlot(toSlotId, cardId, isFlipped, deckId); return true; } @@ -3628,12 +3843,16 @@ const sourceFlip = isSlotFlipped(fromSlotId); const targetFlip = isSlotFlipped(toSlotId); + const sourceDeckId = getSlotDeckOverride(fromSlotId); + const targetDeckId = getSlotDeckOverride(toSlotId); state.slotAssignments.set(toSlotId, sourceCardId); setSlotFlipState(toSlotId, sourceFlip); + setSlotDeckOverride(toSlotId, sourceDeckId); if (targetCardId) { state.slotAssignments.set(fromSlotId, targetCardId); setSlotFlipState(fromSlotId, targetFlip); + setSlotDeckOverride(fromSlotId, targetDeckId); } else { clearSlotAssignmentState(fromSlotId); } @@ -3656,7 +3875,7 @@ setSlotFlipState(targetSlotId, nextIsFlipped); state.layoutReady = true; const card = getCardMap(getCards()).get(cardId) || null; - setStatus(`${getDisplayCardName(card)} flipped ${nextIsFlipped ? "upside down" : "upright"} at ${describeSlot(targetSlotId)}.`); + setStatus(`${getDisplayCardName(card, targetSlotId)} flipped ${nextIsFlipped ? "upside down" : "upright"} at ${describeSlot(targetSlotId)}.`); return true; } @@ -3673,6 +3892,7 @@ state.currentLayoutId = layoutPreset.id; state.slotAssignments.clear(); state.slotFlips.clear(); + state.slotDeckOverrides.clear(); state.selectedSlotIds.clear(); state.customCards.clear(); @@ -3697,10 +3917,11 @@ state.currentLayoutId = savedLayout.id; state.slotAssignments.clear(); state.slotFlips.clear(); + state.slotDeckOverrides.clear(); state.selectedSlotIds.clear(); savedLayout.slotAssignments.forEach((entry) => { if (cardMap.has(entry.cardId)) { - assignCardToSlot(entry.slotId, entry.cardId, Boolean(entry.isFlipped)); + assignCardToSlot(entry.slotId, entry.cardId, Boolean(entry.isFlipped), entry.deckId); } }); applyFrameSettingsSnapshot(savedLayout.settings); @@ -3912,7 +4133,7 @@ return cardMap.get(cardId) || null; } - function getCardOverlayLabel(card) { + function getCardOverlayLabel(card, slotId = "") { if (!state.showInfo) { return ""; } @@ -3927,7 +4148,7 @@ return structuredLabel; } - return getCardOverlayDate(card) || formatMonthDay(getRelation(card, "decan")?.data?.dateStart) || getDisplayCardName(card); + return getCardOverlayDate(card) || formatMonthDay(getRelation(card, "decan")?.data?.dateStart) || getDisplayCardName(card, slotId); } function getOccupiedGridBounds(gridTrackEl) { @@ -4206,18 +4427,18 @@ } button.dataset.cardId = getCardId(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)" : ""}`; + button.setAttribute("aria-label", `${getDisplayCardName(card, slotId)} in row ${row}, column ${column}${flipped ? ", flipped upside down" : ""}${selected ? ", selected for group move" : ""}`); + button.title = `${getDisplayCardName(card, slotId)}${flipped ? " (flipped upside down)" : ""}`; const showImage = shouldShowCardImage(card); - const imageSrc = resolveCardThumbnail(card); + const imageSrc = resolveCardThumbnail(card, slotId); if (showImage && imageSrc) { const imageCached = tarotCardImages.isImageLoaded?.(imageSrc) === true; const image = document.createElement("img"); image.className = "tarot-frame-card-image"; image.src = imageSrc; - image.alt = getDisplayCardName(card); + image.alt = getDisplayCardName(card, slotId); image.loading = imageCached ? "eager" : "lazy"; image.decoding = "async"; image.draggable = false; @@ -4225,16 +4446,16 @@ } else if (showImage) { const fallback = document.createElement("span"); fallback.className = "tarot-frame-card-fallback"; - fallback.textContent = getDisplayCardName(card); + fallback.textContent = getDisplayCardName(card, slotId); button.appendChild(fallback); } else { - button.appendChild(createCardTextFaceElement(buildCardTextFaceModel(card))); + button.appendChild(createCardTextFaceElement(buildCardTextFaceModel(card, slotId))); } if (showImage && state.showInfo) { const overlay = document.createElement("span"); overlay.className = "tarot-frame-card-badge"; - overlay.textContent = getCardOverlayLabel(card); + overlay.textContent = getCardOverlayLabel(card, slotId); button.appendChild(overlay); } @@ -4462,6 +4683,7 @@ tarotFrameSettingsToggleEl, tarotFrameSettingsPanelEl, tarotFrameGridZoomEl, + tarotFrameDeckSelectEl, tarotFrameShowInfoEl, tarotFrameHouseSettingsEl, tarotFrameHouseTopCardsVisibleEl, @@ -4523,6 +4745,11 @@ tarotFrameGridZoomEl.disabled = Boolean(state.exportInProgress); } + if (tarotFrameDeckSelectEl) { + buildDeckSelectOptions(tarotFrameDeckSelectEl, normalizeFrameDeckId(state.frameDeckId) || getFrameDeckId(), false); + tarotFrameDeckSelectEl.disabled = Boolean(state.exportInProgress); + } + if (tarotFrameShowInfoEl) { tarotFrameShowInfoEl.checked = Boolean(state.showInfo); tarotFrameShowInfoEl.disabled = Boolean(state.exportInProgress); @@ -4665,7 +4892,8 @@ sourceSlotId, targetSlotId, cardId, - isFlipped: isSlotFlipped(sourceSlotId) + isFlipped: isSlotFlipped(sourceSlotId), + deckId: getSlotDeckOverride(sourceSlotId) }); } @@ -4688,7 +4916,7 @@ clearSlotAssignmentState(move.sourceSlotId); }); plan.moves.forEach((move) => { - assignCardToSlot(move.targetSlotId, move.cardId, move.isFlipped); + assignCardToSlot(move.targetSlotId, move.cardId, move.isFlipped, move.deckId); }); state.layoutReady = true; pruneUnusedCustomCards(); @@ -4713,20 +4941,20 @@ ghost.appendChild(countEl); } - const imageSrc = resolveCardThumbnail(card); + const imageSrc = resolveCardThumbnail(card, sourceSlotId); if (imageSrc) { const image = document.createElement("img"); image.src = imageSrc; image.alt = ""; ghost.appendChild(image); } else { - ghost.appendChild(createCardTextFaceElement(buildCardTextFaceModel(card))); + ghost.appendChild(createCardTextFaceElement(buildCardTextFaceModel(card, sourceSlotId))); } if (state.showInfo) { const label = document.createElement("span"); label.className = "tarot-frame-drag-ghost-label"; - label.textContent = getCardOverlayLabel(card); + label.textContent = getCardOverlayLabel(card, sourceSlotId); if (label.textContent) { ghost.appendChild(label); } @@ -4914,7 +5142,7 @@ return; } - const deckOptions = resolveDeckOptions(card); + const deckOptions = resolveDeckOptions(card, slotId); const src = String( tarotCardImages.resolveTarotCardImage?.(card.name, deckOptions) || tarotCardImages.resolveTarotCardThumbnail?.(card.name, deckOptions) @@ -4925,13 +5153,13 @@ return; } - const label = getDisplayCardName(card); + const label = getDisplayCardName(card, slotId); window.TarotUiLightbox?.open?.({ src, altText: label, label, cardId: getCardId(card), - deckId: String(tarotCardImages.getActiveDeck?.() || "").trim() + deckId: getResolvedSlotDeckId(slotId) }); } @@ -5528,7 +5756,7 @@ context.restore(); } - function drawSlotToCanvas(context, x, y, width, height, card, image, customBackgroundImage = null, isFlipped = false) { + function drawSlotToCanvas(context, x, y, width, height, card, image, customBackgroundImage = null, isFlipped = false, slotId = "") { if (!card) { return; } @@ -5538,7 +5766,7 @@ const cardWidth = width - (EXPORT_CARD_INSET * 2); const cardHeight = height - (EXPORT_CARD_INSET * 2); const showImage = shouldShowCardImage(card); - const faceModel = showImage ? null : buildCardTextFaceModel(card); + const faceModel = showImage ? null : buildCardTextFaceModel(card, slotId); context.save(); if (isFlipped) { @@ -5557,7 +5785,7 @@ context.textAlign = "center"; context.textBaseline = "middle"; context.font = "700 14px 'Segoe UI', sans-serif"; - const lines = wrapCanvasText(context, getDisplayCardName(card), cardWidth - 18, 4); + const lines = wrapCanvasText(context, getDisplayCardName(card, slotId), cardWidth - 18, 4); const lineHeight = 18; let currentY = cardY + (cardHeight / 2) - (((Math.max(1, lines.length) - 1) * lineHeight) / 2); lines.forEach((line) => { @@ -5579,7 +5807,7 @@ } if (showImage && state.showInfo) { - const overlayText = getCardOverlayLabel(card); + const overlayText = getCardOverlayLabel(card, slotId); if (overlayText) { const overlayHeight = 30; const overlayX = cardX + 4; @@ -5605,47 +5833,91 @@ context.restore(); } - function loadCardImage(src) { + function isCrossOriginImageSource(src) { + const normalizedSrc = String(src || "").trim(); + if (!normalizedSrc) { + return false; + } + + if (normalizedSrc.startsWith("data:") || normalizedSrc.startsWith("blob:")) { + return false; + } + + try { + const sourceUrl = new URL(normalizedSrc, window.location.href); + return sourceUrl.origin !== window.location.origin; + } catch (_error) { + return false; + } + } + + function loadImageElementFromUrl(src, { crossOrigin = "", revokeOnLoad = false } = {}) { return new Promise((resolve) => { - const normalizedSrc = String(src || "").trim(); - if (!normalizedSrc) { - resolve(null); - return; - } - - const ensureLoaded = window.TarotCardImages?.ensureImageLoaded; - const attachImageFallback = () => { - const image = new Image(); - image.decoding = "async"; - image.onload = () => resolve(image); - image.onerror = () => resolve(null); - image.src = normalizedSrc; - }; - - if (typeof ensureLoaded === "function") { - Promise.resolve(ensureLoaded(normalizedSrc)) - .then((cachedImage) => { - if (cachedImage?.naturalWidth) { - resolve(cachedImage); - return; - } - - attachImageFallback(); - }) - .catch(() => { - attachImageFallback(); - }); - return; - } - const image = new Image(); image.decoding = "async"; - image.onload = () => resolve(image); - image.onerror = () => resolve(null); - image.src = normalizedSrc; + if (crossOrigin) { + image.crossOrigin = crossOrigin; + } + + image.onload = () => { + if (revokeOnLoad) { + try { + URL.revokeObjectURL(src); + } catch (_error) { + // Ignore object URL cleanup failures. + } + } + resolve(image); + }; + image.onerror = () => { + if (revokeOnLoad) { + try { + URL.revokeObjectURL(src); + } catch (_error) { + // Ignore object URL cleanup failures. + } + } + resolve(null); + }; + image.src = src; }); } + async function loadCardImage(src) { + const normalizedSrc = String(src || "").trim(); + if (!normalizedSrc) { + return null; + } + + // Export uses blob-backed image URLs so the canvas remains origin-clean. + try { + const response = await fetch(normalizedSrc, { + method: "GET", + mode: "cors", + credentials: "omit", + cache: "force-cache" + }); + if (response.ok) { + const blob = await response.blob(); + if (blob.size > 0) { + const blobUrl = URL.createObjectURL(blob); + const image = await loadImageElementFromUrl(blobUrl, { revokeOnLoad: true }); + if (image) { + return image; + } + } + } + } catch (_error) { + // Fall through to direct image loading path. + } + + if (isCrossOriginImageSource(normalizedSrc)) { + return null; + } + + return loadImageElementFromUrl(normalizedSrc, { crossOrigin: "anonymous" }); + } + function isExportFormatSupported(format) { const exportFormat = EXPORT_FORMATS[format]; if (!exportFormat) { @@ -5660,13 +5932,17 @@ function canvasToBlobByFormat(canvas, format) { const exportFormat = EXPORT_FORMATS[format] || EXPORT_FORMATS.webp; return new Promise((resolve, reject) => { - canvas.toBlob((blob) => { - if (blob) { - resolve(blob); - return; - } - reject(new Error("Canvas export failed.")); - }, exportFormat.mimeType, exportFormat.quality); + try { + canvas.toBlob((blob) => { + if (blob) { + resolve(blob); + return; + } + reject(new Error("Canvas export failed. Some deck images may block export without CORS access.")); + }, exportFormat.mimeType, exportFormat.quality); + } catch (_error) { + reject(new Error("Canvas export failed because one or more images could not be read for export (CORS).")); + } }); } @@ -5678,7 +5954,7 @@ const contentHeight = (MASTER_GRID_SIZE * EXPORT_SLOT_HEIGHT) + ((MASTER_GRID_SIZE - 1) * EXPORT_GRID_GAP); const canvasWidth = contentWidth + (EXPORT_PADDING * 2); const canvasHeight = contentHeight + (EXPORT_PADDING * 2); - const scale = Math.max(1.5, Math.min(2, Number(window.devicePixelRatio) || 1)); + const scale = Math.max(2, Math.min(2.5, Number(window.devicePixelRatio) || 1)); const canvas = document.createElement("canvas"); canvas.width = Math.ceil(canvasWidth * scale); canvas.height = Math.ceil(canvasHeight * scale); @@ -5698,7 +5974,8 @@ const imageCache = new Map(); cards.forEach((card) => { - const src = resolveCardThumbnail(card); + const slotId = findAssignedSlotIdByCardId(getCardId(card)); + const src = resolveCardThumbnail(card, slotId); if (src && !imageCache.has(src)) { imageCache.set(src, loadCardImage(src)); } @@ -5706,7 +5983,8 @@ const resolvedImages = new Map(); await Promise.all(cards.map(async (card) => { - const src = resolveCardThumbnail(card); + const slotId = findAssignedSlotIdByCardId(getCardId(card)); + const src = resolveCardThumbnail(card, slotId); const image = src ? await imageCache.get(src) : null; resolvedImages.set(getCardId(card), image || null); })); @@ -5741,7 +6019,8 @@ card, card ? resolvedImages.get(getCardId(card)) : null, card ? resolvedCustomBackgroundImages.get(getCardId(card)) : null, - card ? isSlotFlipped(slotId) : false + card ? isSlotFlipped(slotId) : false, + slotId ); } } @@ -5792,6 +6071,7 @@ tarotFrameSettingsToggleEl, tarotFrameSettingsPanelEl, tarotFrameGridZoomEl, + tarotFrameDeckSelectEl, tarotFrameShowInfoEl, tarotFrameHouseTopCardsVisibleEl, tarotFrameHouseTopInfoHebrewEl, @@ -6002,6 +6282,17 @@ }); } + if (tarotFrameDeckSelectEl) { + tarotFrameDeckSelectEl.addEventListener("change", () => { + if (setFrameDeckId(tarotFrameDeckSelectEl.value)) { + render({ preserveViewport: true }); + } + syncControls(); + const deckLabel = getDeckOptionsList().find((option) => option.id === getFrameDeckId())?.label || "selected"; + setStatus(`Frame deck set to ${deckLabel}. Use card action menu to override individual slots.`); + }); + } + [ [tarotFrameHouseTopCardsVisibleEl, (checked) => config.setHouseTopCardsVisible?.(checked)], [tarotFrameHouseTopInfoHebrewEl, (checked) => config.setHouseTopInfoMode?.("hebrew", checked)], diff --git a/index.html b/index.html index fa60457..19a09e2 100644 --- a/index.html +++ b/index.html @@ -460,6 +460,10 @@ +