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
+380 -89
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,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)],