update sequence nav to use new ui-sequence-nav component

This commit is contained in:
2026-07-06 19:38:49 -07:00
parent 5b1df48883
commit 3b666611c6
8 changed files with 1106 additions and 119 deletions
+125
View File
@@ -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 {
+355 -25
View File
@@ -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);
if (!map.has(String(entry?.char || "").trim())) {
addScriptCharMapEntry(map, entry?.char, mapped);
}
});
greekLetters.forEach((entry) => {
const mapped = transliterationToBaseLetters(entry?.transliteration, baseAlphabet);
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);
});
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("ς") && 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();
});
}
+10 -1
View File
@@ -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
};
}
+162
View File
@@ -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;
+366 -75
View File
@@ -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,45 +5833,89 @@
context.restore();
}
function loadCardImage(src) {
return new Promise((resolve) => {
function isCrossOriginImageSource(src) {
const normalizedSrc = String(src || "").trim();
if (!normalizedSrc) {
resolve(null);
return;
return false;
}
const ensureLoaded = window.TarotCardImages?.ensureImageLoaded;
const attachImageFallback = () => {
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 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);
};
if (typeof ensureLoaded === "function") {
Promise.resolve(ensureLoaded(normalizedSrc))
.then((cachedImage) => {
if (cachedImage?.naturalWidth) {
resolve(cachedImage);
return;
image.onerror = () => {
if (revokeOnLoad) {
try {
URL.revokeObjectURL(src);
} catch (_error) {
// Ignore object URL cleanup failures.
}
}
resolve(null);
};
image.src = src;
});
}
attachImageFallback();
})
.catch(() => {
attachImageFallback();
});
return;
async function loadCardImage(src) {
const normalizedSrc = String(src || "").trim();
if (!normalizedSrc) {
return null;
}
const image = new Image();
image.decoding = "async";
image.onload = () => resolve(image);
image.onerror = () => resolve(null);
image.src = normalizedSrc;
// 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) {
@@ -5660,13 +5932,17 @@
function canvasToBlobByFormat(canvas, format) {
const exportFormat = EXPORT_FORMATS[format] || EXPORT_FORMATS.webp;
return new Promise((resolve, reject) => {
try {
canvas.toBlob((blob) => {
if (blob) {
resolve(blob);
return;
}
reject(new Error("Canvas export failed."));
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)],
+19
View File
@@ -460,6 +460,10 @@
<option value="14">1400%</option>
</select>
</label>
<label class="tarot-frame-field" for="tarot-frame-deck-select">
<span>Frame Deck</span>
<select id="tarot-frame-deck-select"></select>
</label>
<label class="tarot-frame-toggle" for="tarot-frame-show-info">
<input id="tarot-frame-show-info" type="checkbox" checked>
<span>Display Info</span>
@@ -1017,6 +1021,20 @@
</div>
<div id="alpha-gematria-result" class="alpha-gematria-result">Total: --</div>
<div id="alpha-gematria-breakdown" class="alpha-gematria-breakdown">Type text to calculate.</div>
<div id="alpha-gematria-keyboard" class="alpha-gematria-keyboard" aria-label="On-screen language keyboard">
<div class="alpha-gematria-keyboard-head">
<label class="alpha-gematria-keyboard-label" for="alpha-gematria-keyboard-script">Keyboard</label>
<select id="alpha-gematria-keyboard-script" class="alpha-gematria-keyboard-script" aria-label="Choose keyboard language"></select>
</div>
<div id="alpha-gematria-keyboard-grid" class="alpha-gematria-keyboard-grid" role="group" aria-label="Language letters"></div>
<div id="alpha-gematria-keyboard-actions" class="alpha-gematria-keyboard-actions" role="group" aria-label="Keyboard actions">
<button type="button" class="alpha-gematria-keyboard-action" data-keyboard-action="backspace">Backspace</button>
<button type="button" class="alpha-gematria-keyboard-action" data-keyboard-action="space">Space</button>
<button type="button" class="alpha-gematria-keyboard-action" data-keyboard-action="star">*</button>
<button type="button" class="alpha-gematria-keyboard-action" data-keyboard-action="question">?</button>
<button type="button" class="alpha-gematria-keyboard-action" data-keyboard-action="clear">Clear</button>
</div>
</div>
<div id="alpha-gematria-matches" class="alpha-gematria-matches" aria-live="polite" hidden></div>
</div>
</section>
@@ -1347,6 +1365,7 @@
<script src="node_modules/@toast-ui/calendar/dist/toastui-calendar.min.js"></script>
<script src="node_modules/suncalc/suncalc.js"></script>
<script src="node_modules/astronomy-engine/astronomy.browser.min.js"></script>
<script src="node_modules/html2canvas/dist/html2canvas.min.js"></script>
<script src="app/astro-calcs.js"></script>
<script src="app/app-config.js?v=20260529-access-gating-01"></script>
<script src="app/data-service.js?v=20260601-now-polling-03"></script>
+50
View File
@@ -12,6 +12,7 @@
"@fontsource/noto-serif": "^5.2.9",
"@toast-ui/calendar": "^2.1.3",
"astronomy-engine": "^2.1.19",
"html2canvas": "^1.4.1",
"suncalc": "^1.9.0"
},
"devDependencies": {
@@ -318,6 +319,15 @@
"dev": true,
"license": "MIT"
},
"node_modules/base64-arraybuffer": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/base64-arraybuffer/-/base64-arraybuffer-1.0.2.tgz",
"integrity": "sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ==",
"license": "MIT",
"engines": {
"node": ">= 0.6.0"
}
},
"node_modules/basic-auth": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/basic-auth/-/basic-auth-2.0.1.tgz",
@@ -418,6 +428,15 @@
"node": ">= 0.4.0"
}
},
"node_modules/css-line-break": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/css-line-break/-/css-line-break-2.1.0.tgz",
"integrity": "sha512-FHcKFCZcAha3LwfVBhCQbW2nCNbkZXn7KVUJcsT5/P8YmfsVja0FMPJr0B903j/E69HUphKiV9iQArX8SDYA4w==",
"license": "MIT",
"dependencies": {
"utrie": "^1.0.2"
}
},
"node_modules/css-tree": {
"version": "3.2.1",
"resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz",
@@ -703,6 +722,19 @@
"node": ">=12"
}
},
"node_modules/html2canvas": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/html2canvas/-/html2canvas-1.4.1.tgz",
"integrity": "sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA==",
"license": "MIT",
"dependencies": {
"css-line-break": "^2.1.0",
"text-segmentation": "^1.0.3"
},
"engines": {
"node": ">=8.0.0"
}
},
"node_modules/http-proxy": {
"version": "1.18.1",
"resolved": "https://registry.npmjs.org/http-proxy/-/http-proxy-1.18.1.tgz",
@@ -1180,6 +1212,15 @@
"integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==",
"license": "MIT"
},
"node_modules/text-segmentation": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/text-segmentation/-/text-segmentation-1.0.3.tgz",
"integrity": "sha512-iOiPUo/BGnZ6+54OsWxZidGCsdU8YbE4PSpdPinp7DeMtUJNJBoJ/ouUSTJjHkh1KntHaltHl/gDs2FC4i5+Nw==",
"license": "MIT",
"dependencies": {
"utrie": "^1.0.2"
}
},
"node_modules/tldts": {
"version": "7.0.25",
"resolved": "https://registry.npmjs.org/tldts/-/tldts-7.0.25.tgz",
@@ -1265,6 +1306,15 @@
"dev": true,
"license": "MIT"
},
"node_modules/utrie": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/utrie/-/utrie-1.0.2.tgz",
"integrity": "sha512-1MLa5ouZiOmQzUbjbu9VmjLzn1QLXBhwpUa7kdLUQK+KQ5KA9I1vk5U4YHe/X2Ch7PYnJfWuWT+VbuxbGwljhw==",
"license": "MIT",
"dependencies": {
"base64-arraybuffer": "^1.0.2"
}
},
"node_modules/w3c-xmlserializer": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz",
+1
View File
@@ -7,6 +7,7 @@
"@fontsource/noto-serif": "^5.2.9",
"@toast-ui/calendar": "^2.1.3",
"astronomy-engine": "^2.1.19",
"html2canvas": "^1.4.1",
"suncalc": "^1.9.0"
},
"devDependencies": {