1223 lines
38 KiB
JavaScript
1223 lines
38 KiB
JavaScript
(function () {
|
|
"use strict";
|
|
|
|
const tarotCardImages = window.TarotCardImages || {};
|
|
const MONTH_LENGTHS = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
|
|
const MONTH_ABBR = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
|
|
const MINOR_RANKS = new Set(["Two", "Three", "Four", "Five", "Six", "Seven", "Eight", "Nine", "Ten"]);
|
|
const COURT_RANKS = new Set(["Knight", "Queen", "Prince"]);
|
|
const EXTRA_SUIT_ORDER = ["wands", "cups", "swords", "disks"];
|
|
const ZODIAC_START_TOKEN_BY_SIGN_ID = {
|
|
aries: "03-21",
|
|
taurus: "04-20",
|
|
gemini: "05-21",
|
|
cancer: "06-21",
|
|
leo: "07-23",
|
|
virgo: "08-23",
|
|
libra: "09-23",
|
|
scorpio: "10-23",
|
|
sagittarius: "11-22",
|
|
capricorn: "12-22",
|
|
aquarius: "01-20",
|
|
pisces: "02-19"
|
|
};
|
|
const MASTER_GRID_SIZE = 18;
|
|
const EXPORT_SLOT_SIZE = 120;
|
|
const EXPORT_CARD_INSET = 0;
|
|
const EXPORT_GRID_GAP = 10;
|
|
const EXPORT_PADDING = 28;
|
|
const EXPORT_BACKGROUND = "#0f0f17";
|
|
const EXPORT_PANEL = "#18181b";
|
|
const EXPORT_CARD_BORDER = "#475569";
|
|
const EXPORT_BADGE_BACKGROUND = "rgba(2, 6, 23, 0.9)";
|
|
const EXPORT_BADGE_TEXT = "#f8fafc";
|
|
const EXPORT_FORMATS = {
|
|
webp: {
|
|
mimeType: "image/webp",
|
|
extension: "webp",
|
|
quality: 0.98
|
|
}
|
|
};
|
|
const BOARD_LAYOUTS = [
|
|
{
|
|
id: "extra-cards",
|
|
title: "Extra Row",
|
|
description: "Top row for aces, princesses, and the non-zodiac majors.",
|
|
positions: Array.from({ length: MASTER_GRID_SIZE }, (_, index) => ({ row: 1, column: index + 1 })),
|
|
getOrderedCards(cards) {
|
|
return cards
|
|
.filter((card) => isExtraTopRowCard(card))
|
|
.sort(compareExtraTopRowCards);
|
|
}
|
|
},
|
|
{
|
|
id: "small-cards",
|
|
title: "Small Cards",
|
|
description: "Outer perimeter in chronological decan order.",
|
|
positions: buildPerimeterPath(10, 5, 5),
|
|
getOrderedCards(cards) {
|
|
return cards
|
|
.filter((card) => isSmallCard(card))
|
|
.sort((left, right) => compareDateTokens(getRelation(left, "decan")?.data?.dateStart, getRelation(right, "decan")?.data?.dateStart, "03-21"));
|
|
}
|
|
},
|
|
{
|
|
id: "court-dates",
|
|
title: "Court Dates",
|
|
description: "Inner left frame in chronological court-date order.",
|
|
positions: buildPerimeterPath(4, 8, 6),
|
|
getOrderedCards(cards) {
|
|
return cards
|
|
.filter((card) => isCourtDateCard(card))
|
|
.sort((left, right) => compareDateTokens(getRelation(left, "courtDateWindow")?.data?.dateStart, getRelation(right, "courtDateWindow")?.data?.dateStart, "11-12"));
|
|
}
|
|
},
|
|
{
|
|
id: "zodiac-trumps",
|
|
title: "Zodiac Trumps",
|
|
description: "Inner right frame in chronological zodiac order.",
|
|
positions: buildPerimeterPath(4, 8, 10),
|
|
getOrderedCards(cards) {
|
|
return cards
|
|
.filter((card) => isZodiacTrump(card))
|
|
.sort((left, right) => {
|
|
const leftSignId = normalizeKey(getRelation(left, "zodiacCorrespondence")?.data?.signId);
|
|
const rightSignId = normalizeKey(getRelation(right, "zodiacCorrespondence")?.data?.signId);
|
|
return compareDateTokens(ZODIAC_START_TOKEN_BY_SIGN_ID[leftSignId], ZODIAC_START_TOKEN_BY_SIGN_ID[rightSignId], "03-21");
|
|
});
|
|
}
|
|
}
|
|
];
|
|
|
|
const state = {
|
|
initialized: false,
|
|
layoutReady: false,
|
|
cardSignature: "",
|
|
slotAssignments: new Map(),
|
|
statusMessage: "Loading tarot cards...",
|
|
drag: null,
|
|
suppressClick: false,
|
|
showInfo: true,
|
|
settingsOpen: false,
|
|
exportInProgress: false,
|
|
exportFormat: "webp"
|
|
};
|
|
|
|
let config = {
|
|
ensureTarotSection: null,
|
|
getCards: () => []
|
|
};
|
|
|
|
function buildPerimeterPath(size, rowOffset = 1, columnOffset = 1) {
|
|
const path = [];
|
|
for (let column = 0; column < size; column += 1) {
|
|
path.push({ row: rowOffset, column: columnOffset + column });
|
|
}
|
|
for (let row = 1; row < size - 1; row += 1) {
|
|
path.push({ row: rowOffset + row, column: columnOffset + size - 1 });
|
|
}
|
|
for (let column = size - 1; column >= 0; column -= 1) {
|
|
path.push({ row: rowOffset + size - 1, column: columnOffset + column });
|
|
}
|
|
for (let row = size - 2; row >= 1; row -= 1) {
|
|
path.push({ row: rowOffset + row, column: columnOffset });
|
|
}
|
|
return path;
|
|
}
|
|
|
|
function getElements() {
|
|
return {
|
|
tarotFrameBoardEl: document.getElementById("tarot-frame-board"),
|
|
tarotFrameStatusEl: document.getElementById("tarot-frame-status"),
|
|
tarotFrameResetEl: document.getElementById("tarot-frame-reset"),
|
|
tarotFrameSettingsToggleEl: document.getElementById("tarot-frame-settings-toggle"),
|
|
tarotFrameSettingsPanelEl: document.getElementById("tarot-frame-settings-panel"),
|
|
tarotFrameShowInfoEl: document.getElementById("tarot-frame-show-info"),
|
|
tarotFrameExportWebpEl: document.getElementById("tarot-frame-export-webp")
|
|
};
|
|
}
|
|
|
|
function normalizeLabelText(value) {
|
|
return String(value || "").replace(/\s+/g, " ").trim();
|
|
}
|
|
|
|
function isSmallCard(card) {
|
|
return card?.arcana === "Minor"
|
|
&& MINOR_RANKS.has(String(card?.rank || ""))
|
|
&& Boolean(getRelation(card, "decan"));
|
|
}
|
|
|
|
function isCourtDateCard(card) {
|
|
return COURT_RANKS.has(String(card?.rank || ""))
|
|
&& Boolean(getRelation(card, "courtDateWindow"));
|
|
}
|
|
|
|
function isZodiacTrump(card) {
|
|
return card?.arcana === "Major"
|
|
&& Boolean(getRelation(card, "zodiacCorrespondence"));
|
|
}
|
|
|
|
function getExtraTopRowCategory(card) {
|
|
const rank = String(card?.rank || "").trim();
|
|
if (rank === "Ace") {
|
|
return 0;
|
|
}
|
|
if (card?.arcana === "Major") {
|
|
return 1;
|
|
}
|
|
if (rank === "Princess") {
|
|
return 2;
|
|
}
|
|
return 3;
|
|
}
|
|
|
|
function compareSuitOrder(leftSuit, rightSuit) {
|
|
const leftIndex = EXTRA_SUIT_ORDER.indexOf(normalizeKey(leftSuit));
|
|
const rightIndex = EXTRA_SUIT_ORDER.indexOf(normalizeKey(rightSuit));
|
|
const safeLeft = leftIndex === -1 ? EXTRA_SUIT_ORDER.length : leftIndex;
|
|
const safeRight = rightIndex === -1 ? EXTRA_SUIT_ORDER.length : rightIndex;
|
|
return safeLeft - safeRight;
|
|
}
|
|
|
|
function compareExtraTopRowCards(left, right) {
|
|
const categoryDiff = getExtraTopRowCategory(left) - getExtraTopRowCategory(right);
|
|
if (categoryDiff !== 0) {
|
|
return categoryDiff;
|
|
}
|
|
|
|
const category = getExtraTopRowCategory(left);
|
|
if (category === 0 || category === 2) {
|
|
return compareSuitOrder(left?.suit, right?.suit);
|
|
}
|
|
|
|
if (category === 1) {
|
|
return Number(left?.number) - Number(right?.number);
|
|
}
|
|
|
|
return String(left?.name || "").localeCompare(String(right?.name || ""));
|
|
}
|
|
|
|
function isExtraTopRowCard(card) {
|
|
return Boolean(card) && !isSmallCard(card) && !isCourtDateCard(card) && !isZodiacTrump(card);
|
|
}
|
|
|
|
function buildReadyStatus(cards) {
|
|
return `${Array.isArray(cards) ? cards.length : 0} cards ready. Drag any card to any grid square and it will snap into that spot.`;
|
|
}
|
|
|
|
function normalizeKey(value) {
|
|
return String(value || "").trim().toLowerCase();
|
|
}
|
|
|
|
function getCards() {
|
|
const cards = config.getCards?.();
|
|
return Array.isArray(cards) ? cards : [];
|
|
}
|
|
|
|
function getCardId(card) {
|
|
return String(card?.id || "").trim();
|
|
}
|
|
|
|
function getCardMap(cards) {
|
|
return new Map(cards.map((card) => [getCardId(card), card]));
|
|
}
|
|
|
|
function getRelation(card, type) {
|
|
return Array.isArray(card?.relations)
|
|
? card.relations.find((relation) => relation?.type === type) || null
|
|
: null;
|
|
}
|
|
|
|
function parseMonthDayToken(token) {
|
|
const match = String(token || "").trim().match(/^(\d{2})-(\d{2})$/);
|
|
if (!match) {
|
|
return null;
|
|
}
|
|
|
|
const month = Number(match[1]);
|
|
const day = Number(match[2]);
|
|
if (!Number.isInteger(month) || !Number.isInteger(day) || month < 1 || month > 12) {
|
|
return null;
|
|
}
|
|
|
|
return { month, day };
|
|
}
|
|
|
|
function formatMonthDay(token) {
|
|
const parsed = parseMonthDayToken(token);
|
|
if (!parsed) {
|
|
return "";
|
|
}
|
|
return `${MONTH_ABBR[parsed.month - 1]} ${parsed.day}`;
|
|
}
|
|
|
|
function decrementToken(token) {
|
|
const parsed = parseMonthDayToken(token);
|
|
if (!parsed) {
|
|
return null;
|
|
}
|
|
|
|
if (parsed.day > 1) {
|
|
return `${String(parsed.month).padStart(2, "0")}-${String(parsed.day - 1).padStart(2, "0")}`;
|
|
}
|
|
|
|
const previousMonth = parsed.month === 1 ? 12 : parsed.month - 1;
|
|
const previousDay = MONTH_LENGTHS[previousMonth - 1];
|
|
return `${String(previousMonth).padStart(2, "0")}-${String(previousDay).padStart(2, "0")}`;
|
|
}
|
|
|
|
function formatDateRange(startToken, endToken) {
|
|
const start = parseMonthDayToken(startToken);
|
|
const end = parseMonthDayToken(endToken);
|
|
if (!start || !end) {
|
|
return "";
|
|
}
|
|
|
|
const startMonth = MONTH_ABBR[start.month - 1];
|
|
const endMonth = MONTH_ABBR[end.month - 1];
|
|
if (start.month === end.month) {
|
|
return `${startMonth} ${start.day}-${end.day}`;
|
|
}
|
|
return `${startMonth} ${start.day}-${endMonth} ${end.day}`;
|
|
}
|
|
|
|
function toOrdinalDay(token) {
|
|
const parsed = parseMonthDayToken(token);
|
|
if (!parsed) {
|
|
return Number.POSITIVE_INFINITY;
|
|
}
|
|
|
|
const daysBeforeMonth = MONTH_LENGTHS.slice(0, parsed.month - 1).reduce((total, length) => total + length, 0);
|
|
return daysBeforeMonth + parsed.day;
|
|
}
|
|
|
|
function getCyclicDayValue(token, cycleStartToken) {
|
|
const value = toOrdinalDay(token);
|
|
const cycleStart = toOrdinalDay(cycleStartToken);
|
|
if (!Number.isFinite(value) || !Number.isFinite(cycleStart)) {
|
|
return Number.POSITIVE_INFINITY;
|
|
}
|
|
|
|
return (value - cycleStart + 365) % 365;
|
|
}
|
|
|
|
function compareDateTokens(leftToken, rightToken, cycleStartToken) {
|
|
return getCyclicDayValue(leftToken, cycleStartToken) - getCyclicDayValue(rightToken, cycleStartToken);
|
|
}
|
|
|
|
function buildCardSignature(cards) {
|
|
return cards.map((card) => getCardId(card)).filter(Boolean).sort().join("|");
|
|
}
|
|
|
|
function resolveDeckOptions(card) {
|
|
const deckId = String(tarotCardImages.getActiveDeck?.() || "").trim();
|
|
const trumpNumber = card?.arcana === "Major" && Number.isFinite(Number(card?.number))
|
|
? Number(card.number)
|
|
: undefined;
|
|
|
|
if (!deckId && !Number.isFinite(trumpNumber)) {
|
|
return null;
|
|
}
|
|
|
|
return {
|
|
...(deckId ? { deckId } : {}),
|
|
...(Number.isFinite(trumpNumber) ? { trumpNumber } : {})
|
|
};
|
|
}
|
|
|
|
function resolveCardThumbnail(card) {
|
|
if (!card) {
|
|
return "";
|
|
}
|
|
|
|
const deckOptions = resolveDeckOptions(card) || undefined;
|
|
return String(
|
|
tarotCardImages.resolveTarotCardThumbnail?.(card.name, deckOptions)
|
|
|| tarotCardImages.resolveTarotCardImage?.(card.name, deckOptions)
|
|
|| ""
|
|
).trim();
|
|
}
|
|
|
|
function getDisplayCardName(card) {
|
|
const label = tarotCardImages.getTarotCardDisplayName?.(card?.name, resolveDeckOptions(card) || undefined);
|
|
return String(label || card?.name || "Tarot").trim() || "Tarot";
|
|
}
|
|
|
|
function getCardOverlayDate(card) {
|
|
const decan = getRelation(card, "decan")?.data || null;
|
|
if (decan?.dateStart && decan?.dateEnd) {
|
|
return formatDateRange(decan.dateStart, decan.dateEnd);
|
|
}
|
|
|
|
const court = getRelation(card, "courtDateWindow")?.data || null;
|
|
if (court?.dateStart && court?.dateEnd) {
|
|
return formatDateRange(court.dateStart, court.dateEnd);
|
|
}
|
|
|
|
const zodiac = getRelation(card, "zodiacCorrespondence")?.data || null;
|
|
const signId = normalizeKey(zodiac?.signId);
|
|
const signStart = ZODIAC_START_TOKEN_BY_SIGN_ID[signId];
|
|
if (signStart) {
|
|
const signIds = Object.keys(ZODIAC_START_TOKEN_BY_SIGN_ID);
|
|
const index = signIds.indexOf(signId);
|
|
const nextSignId = signIds[(index + 1) % signIds.length];
|
|
const nextStart = ZODIAC_START_TOKEN_BY_SIGN_ID[nextSignId];
|
|
const endToken = decrementToken(nextStart);
|
|
return formatDateRange(signStart, endToken);
|
|
}
|
|
|
|
return "";
|
|
}
|
|
|
|
function getSlotId(row, column) {
|
|
return `${row}:${column}`;
|
|
}
|
|
|
|
function setStatus(message) {
|
|
state.statusMessage = String(message || "").trim();
|
|
const { tarotFrameStatusEl } = getElements();
|
|
if (tarotFrameStatusEl) {
|
|
tarotFrameStatusEl.textContent = state.statusMessage;
|
|
}
|
|
}
|
|
|
|
function resetLayout(cards = getCards(), nextStatusMessage = "") {
|
|
state.slotAssignments.clear();
|
|
|
|
BOARD_LAYOUTS.forEach((layout) => {
|
|
const orderedCards = layout.getOrderedCards(cards);
|
|
layout.positions.forEach((position, index) => {
|
|
state.slotAssignments.set(getSlotId(position.row, position.column), getCardId(orderedCards[index] || null));
|
|
});
|
|
});
|
|
|
|
state.layoutReady = true;
|
|
setStatus(nextStatusMessage || buildReadyStatus(cards));
|
|
}
|
|
|
|
function getAssignedCard(slotId, cardMap) {
|
|
const cardId = String(state.slotAssignments.get(slotId) || "").trim();
|
|
return cardMap.get(cardId) || null;
|
|
}
|
|
|
|
function getCardOverlayLabel(card) {
|
|
return getCardOverlayDate(card) || formatMonthDay(getRelation(card, "decan")?.data?.dateStart) || getDisplayCardName(card);
|
|
}
|
|
|
|
function createSlot(row, column, card) {
|
|
const slotId = getSlotId(row, column);
|
|
const slotEl = document.createElement("div");
|
|
slotEl.className = "tarot-frame-slot";
|
|
slotEl.dataset.slotId = slotId;
|
|
slotEl.style.gridRow = String(row);
|
|
slotEl.style.gridColumn = String(column);
|
|
|
|
if (state.drag?.sourceSlotId === slotId) {
|
|
slotEl.classList.add("is-drag-source");
|
|
}
|
|
|
|
if (state.drag?.hoverSlotId === slotId && state.drag?.started) {
|
|
slotEl.classList.add("is-drop-target");
|
|
}
|
|
|
|
const button = document.createElement("button");
|
|
button.type = "button";
|
|
button.className = "tarot-frame-card";
|
|
button.dataset.slotId = slotId;
|
|
button.draggable = false;
|
|
|
|
if (!card) {
|
|
slotEl.classList.add("is-empty-slot");
|
|
button.classList.add("is-empty");
|
|
button.tabIndex = -1;
|
|
const emptyEl = document.createElement("span");
|
|
emptyEl.className = "tarot-frame-slot-empty";
|
|
button.appendChild(emptyEl);
|
|
slotEl.appendChild(button);
|
|
return slotEl;
|
|
}
|
|
|
|
button.dataset.cardId = getCardId(card);
|
|
button.setAttribute("aria-label", `${getDisplayCardName(card)} in row ${row}, column ${column}`);
|
|
button.title = getDisplayCardName(card);
|
|
|
|
const imageSrc = resolveCardThumbnail(card);
|
|
if (imageSrc) {
|
|
const image = document.createElement("img");
|
|
image.className = "tarot-frame-card-image";
|
|
image.src = imageSrc;
|
|
image.alt = getDisplayCardName(card);
|
|
image.loading = "lazy";
|
|
image.decoding = "async";
|
|
image.draggable = false;
|
|
button.appendChild(image);
|
|
} else {
|
|
const fallback = document.createElement("span");
|
|
fallback.className = "tarot-frame-card-fallback";
|
|
fallback.textContent = getDisplayCardName(card);
|
|
button.appendChild(fallback);
|
|
}
|
|
|
|
if (state.showInfo) {
|
|
const overlay = document.createElement("span");
|
|
overlay.className = "tarot-frame-card-badge";
|
|
overlay.textContent = getCardOverlayLabel(card);
|
|
button.appendChild(overlay);
|
|
}
|
|
|
|
slotEl.appendChild(button);
|
|
return slotEl;
|
|
}
|
|
|
|
function createLegend() {
|
|
const legendEl = document.createElement("div");
|
|
legendEl.className = "tarot-frame-legend";
|
|
BOARD_LAYOUTS.forEach((layout) => {
|
|
const itemEl = document.createElement("div");
|
|
itemEl.className = "tarot-frame-legend-item";
|
|
|
|
const titleEl = document.createElement("strong");
|
|
titleEl.textContent = layout.title;
|
|
const textEl = document.createElement("span");
|
|
textEl.textContent = layout.description;
|
|
itemEl.append(titleEl, textEl);
|
|
legendEl.appendChild(itemEl);
|
|
});
|
|
return legendEl;
|
|
}
|
|
|
|
function render() {
|
|
const { tarotFrameBoardEl } = getElements();
|
|
if (!tarotFrameBoardEl) {
|
|
return;
|
|
}
|
|
|
|
const cards = getCards();
|
|
const cardMap = getCardMap(cards);
|
|
tarotFrameBoardEl.replaceChildren();
|
|
|
|
const panelEl = document.createElement("section");
|
|
panelEl.className = "tarot-frame-panel tarot-frame-panel--master";
|
|
|
|
const headEl = document.createElement("div");
|
|
headEl.className = "tarot-frame-panel-head";
|
|
|
|
const titleWrapEl = document.createElement("div");
|
|
const titleEl = document.createElement("h3");
|
|
titleEl.className = "tarot-frame-panel-title";
|
|
titleEl.textContent = "Master 18x18 Frame Grid";
|
|
const subtitleEl = document.createElement("p");
|
|
subtitleEl.className = "tarot-frame-panel-subtitle";
|
|
subtitleEl.textContent = "Top row holds the remaining 18 cards, while the centered frame keeps the small cards, court dates, and zodiac trumps grouped together. Every square on the grid is a snap target for custom layouts.";
|
|
titleWrapEl.append(titleEl, subtitleEl);
|
|
|
|
const countEl = document.createElement("span");
|
|
countEl.className = "tarot-frame-panel-count";
|
|
countEl.textContent = `${cards.length} cards / ${MASTER_GRID_SIZE * MASTER_GRID_SIZE} cells`;
|
|
headEl.append(titleWrapEl, countEl);
|
|
|
|
panelEl.append(headEl, createLegend());
|
|
|
|
const gridEl = document.createElement("div");
|
|
gridEl.className = "tarot-frame-grid tarot-frame-grid--master";
|
|
gridEl.classList.toggle("is-info-hidden", !state.showInfo);
|
|
gridEl.style.setProperty("--frame-grid-size", String(MASTER_GRID_SIZE));
|
|
|
|
for (let row = 1; row <= MASTER_GRID_SIZE; row += 1) {
|
|
for (let column = 1; column <= MASTER_GRID_SIZE; column += 1) {
|
|
gridEl.appendChild(createSlot(row, column, getAssignedCard(getSlotId(row, column), cardMap)));
|
|
}
|
|
}
|
|
|
|
panelEl.appendChild(gridEl);
|
|
tarotFrameBoardEl.appendChild(panelEl);
|
|
}
|
|
|
|
function syncControls() {
|
|
const {
|
|
tarotFrameSettingsToggleEl,
|
|
tarotFrameSettingsPanelEl,
|
|
tarotFrameShowInfoEl,
|
|
tarotFrameExportWebpEl,
|
|
tarotFrameResetEl
|
|
} = getElements();
|
|
|
|
if (tarotFrameSettingsToggleEl) {
|
|
tarotFrameSettingsToggleEl.setAttribute("aria-expanded", state.settingsOpen ? "true" : "false");
|
|
tarotFrameSettingsToggleEl.textContent = state.settingsOpen ? "Hide Settings" : "Settings";
|
|
tarotFrameSettingsToggleEl.disabled = Boolean(state.exportInProgress);
|
|
}
|
|
|
|
if (tarotFrameSettingsPanelEl) {
|
|
tarotFrameSettingsPanelEl.hidden = !state.settingsOpen;
|
|
}
|
|
|
|
if (tarotFrameShowInfoEl) {
|
|
tarotFrameShowInfoEl.checked = Boolean(state.showInfo);
|
|
tarotFrameShowInfoEl.disabled = Boolean(state.exportInProgress);
|
|
}
|
|
|
|
if (tarotFrameExportWebpEl) {
|
|
const supportsWebp = isExportFormatSupported("webp");
|
|
tarotFrameExportWebpEl.hidden = !supportsWebp;
|
|
tarotFrameExportWebpEl.disabled = Boolean(state.exportInProgress) || !supportsWebp;
|
|
tarotFrameExportWebpEl.textContent = state.exportInProgress ? "Exporting..." : "Export WebP";
|
|
if (supportsWebp) {
|
|
tarotFrameExportWebpEl.title = "Download the current frame grid arrangement as a WebP image.";
|
|
}
|
|
}
|
|
|
|
if (tarotFrameResetEl) {
|
|
tarotFrameResetEl.disabled = Boolean(state.exportInProgress);
|
|
}
|
|
}
|
|
|
|
function getSlotElement(slotId) {
|
|
return document.querySelector(`.tarot-frame-slot[data-slot-id="${slotId}"]`);
|
|
}
|
|
|
|
function setHoverSlot(slotId) {
|
|
const previous = state.drag?.hoverSlotId;
|
|
if (previous && previous !== slotId) {
|
|
getSlotElement(previous)?.classList.remove("is-drop-target");
|
|
}
|
|
|
|
if (state.drag) {
|
|
state.drag.hoverSlotId = slotId || "";
|
|
}
|
|
|
|
if (slotId) {
|
|
getSlotElement(slotId)?.classList.add("is-drop-target");
|
|
}
|
|
}
|
|
|
|
function createDragGhost(card) {
|
|
const ghost = document.createElement("div");
|
|
ghost.className = "tarot-frame-drag-ghost";
|
|
|
|
const imageSrc = resolveCardThumbnail(card);
|
|
if (imageSrc) {
|
|
const image = document.createElement("img");
|
|
image.src = imageSrc;
|
|
image.alt = "";
|
|
ghost.appendChild(image);
|
|
}
|
|
|
|
if (state.showInfo) {
|
|
const label = document.createElement("span");
|
|
label.className = "tarot-frame-drag-ghost-label";
|
|
label.textContent = getCardOverlayLabel(card);
|
|
ghost.appendChild(label);
|
|
}
|
|
|
|
document.body.appendChild(ghost);
|
|
return ghost;
|
|
}
|
|
|
|
function moveGhost(ghostEl, clientX, clientY) {
|
|
if (!(ghostEl instanceof HTMLElement)) {
|
|
return;
|
|
}
|
|
|
|
ghostEl.style.left = `${clientX}px`;
|
|
ghostEl.style.top = `${clientY}px`;
|
|
}
|
|
|
|
function updateHoverSlotFromPoint(clientX, clientY, sourceSlotId) {
|
|
const target = document.elementFromPoint(clientX, clientY);
|
|
const slot = target instanceof Element ? target.closest(".tarot-frame-slot[data-slot-id]") : null;
|
|
const nextSlotId = slot instanceof HTMLElement ? String(slot.dataset.slotId || "") : "";
|
|
setHoverSlot(nextSlotId && nextSlotId !== sourceSlotId ? nextSlotId : "");
|
|
}
|
|
|
|
function detachPointerListeners() {
|
|
document.removeEventListener("pointermove", handlePointerMove);
|
|
document.removeEventListener("pointerup", handlePointerUp);
|
|
document.removeEventListener("pointercancel", handlePointerCancel);
|
|
}
|
|
|
|
function cleanupDrag() {
|
|
if (!state.drag) {
|
|
return;
|
|
}
|
|
|
|
setHoverSlot("");
|
|
getSlotElement(state.drag.sourceSlotId)?.classList.remove("is-drag-source");
|
|
if (state.drag.ghostEl instanceof HTMLElement) {
|
|
state.drag.ghostEl.remove();
|
|
}
|
|
|
|
state.drag = null;
|
|
document.body.classList.remove("is-tarot-frame-dragging");
|
|
detachPointerListeners();
|
|
}
|
|
|
|
function swapOrMoveSlots(sourceSlotId, targetSlotId) {
|
|
const sourceCardId = String(state.slotAssignments.get(sourceSlotId) || "");
|
|
const targetCardId = String(state.slotAssignments.get(targetSlotId) || "");
|
|
state.slotAssignments.set(targetSlotId, sourceCardId);
|
|
if (targetCardId) {
|
|
state.slotAssignments.set(sourceSlotId, targetCardId);
|
|
} else {
|
|
state.slotAssignments.delete(sourceSlotId);
|
|
}
|
|
}
|
|
|
|
function describeSlot(slotId) {
|
|
const [rowText, columnText] = String(slotId || "").split(":");
|
|
return `row ${rowText || "?"}, column ${columnText || "?"}`;
|
|
}
|
|
|
|
function openCardLightbox(cardId) {
|
|
const card = getCardMap(getCards()).get(String(cardId || "").trim()) || null;
|
|
if (!card) {
|
|
return;
|
|
}
|
|
|
|
const deckOptions = resolveDeckOptions(card);
|
|
const src = String(
|
|
tarotCardImages.resolveTarotCardImage?.(card.name, deckOptions)
|
|
|| tarotCardImages.resolveTarotCardThumbnail?.(card.name, deckOptions)
|
|
|| ""
|
|
).trim();
|
|
|
|
if (!src) {
|
|
return;
|
|
}
|
|
|
|
const label = getDisplayCardName(card);
|
|
window.TarotUiLightbox?.open?.({
|
|
src,
|
|
altText: label,
|
|
label,
|
|
cardId: getCardId(card),
|
|
deckId: String(tarotCardImages.getActiveDeck?.() || "").trim()
|
|
});
|
|
}
|
|
|
|
function handlePointerDown(event) {
|
|
const target = event.target;
|
|
if (!(target instanceof Element) || event.button !== 0) {
|
|
return;
|
|
}
|
|
|
|
const cardButton = target.closest(".tarot-frame-card[data-slot-id][data-card-id]");
|
|
if (!(cardButton instanceof HTMLButtonElement)) {
|
|
return;
|
|
}
|
|
|
|
state.drag = {
|
|
pointerId: event.pointerId,
|
|
sourceSlotId: String(cardButton.dataset.slotId || ""),
|
|
cardId: String(cardButton.dataset.cardId || ""),
|
|
startX: event.clientX,
|
|
startY: event.clientY,
|
|
started: false,
|
|
hoverSlotId: "",
|
|
ghostEl: null
|
|
};
|
|
|
|
detachPointerListeners();
|
|
document.addEventListener("pointermove", handlePointerMove);
|
|
document.addEventListener("pointerup", handlePointerUp);
|
|
document.addEventListener("pointercancel", handlePointerCancel);
|
|
}
|
|
|
|
function handlePointerMove(event) {
|
|
if (!state.drag || event.pointerId !== state.drag.pointerId) {
|
|
return;
|
|
}
|
|
|
|
const movedEnough = Math.hypot(event.clientX - state.drag.startX, event.clientY - state.drag.startY) >= 6;
|
|
if (!state.drag.started && movedEnough) {
|
|
const card = getCardMap(getCards()).get(state.drag.cardId) || null;
|
|
if (!card) {
|
|
cleanupDrag();
|
|
return;
|
|
}
|
|
|
|
state.drag.started = true;
|
|
state.drag.ghostEl = createDragGhost(card);
|
|
getSlotElement(state.drag.sourceSlotId)?.classList.add("is-drag-source");
|
|
document.body.classList.add("is-tarot-frame-dragging");
|
|
state.suppressClick = true;
|
|
}
|
|
|
|
if (!state.drag.started) {
|
|
return;
|
|
}
|
|
|
|
moveGhost(state.drag.ghostEl, event.clientX, event.clientY);
|
|
updateHoverSlotFromPoint(event.clientX, event.clientY, state.drag.sourceSlotId);
|
|
event.preventDefault();
|
|
}
|
|
|
|
function finishDrop() {
|
|
if (!state.drag) {
|
|
return;
|
|
}
|
|
|
|
const sourceSlotId = state.drag.sourceSlotId;
|
|
const targetSlotId = state.drag.hoverSlotId;
|
|
const draggedCard = getCardMap(getCards()).get(state.drag.cardId) || null;
|
|
const moved = Boolean(targetSlotId && targetSlotId !== sourceSlotId);
|
|
|
|
if (moved) {
|
|
swapOrMoveSlots(sourceSlotId, targetSlotId);
|
|
render();
|
|
setStatus(`${getDisplayCardName(draggedCard)} snapped to ${describeSlot(targetSlotId)}.`);
|
|
}
|
|
|
|
cleanupDrag();
|
|
if (!moved) {
|
|
state.suppressClick = false;
|
|
}
|
|
}
|
|
|
|
function handlePointerUp(event) {
|
|
if (!state.drag || event.pointerId !== state.drag.pointerId) {
|
|
return;
|
|
}
|
|
|
|
if (!state.drag.started) {
|
|
cleanupDrag();
|
|
return;
|
|
}
|
|
|
|
finishDrop();
|
|
}
|
|
|
|
function handlePointerCancel(event) {
|
|
if (!state.drag || event.pointerId !== state.drag.pointerId) {
|
|
return;
|
|
}
|
|
|
|
cleanupDrag();
|
|
state.suppressClick = false;
|
|
}
|
|
|
|
function handleBoardClick(event) {
|
|
const target = event.target;
|
|
if (!(target instanceof Element)) {
|
|
return;
|
|
}
|
|
|
|
const cardButton = target.closest(".tarot-frame-card[data-card-id]");
|
|
if (!(cardButton instanceof HTMLButtonElement)) {
|
|
return;
|
|
}
|
|
|
|
if (state.suppressClick) {
|
|
state.suppressClick = false;
|
|
return;
|
|
}
|
|
|
|
openCardLightbox(cardButton.dataset.cardId);
|
|
}
|
|
|
|
function handleNativeDragStart(event) {
|
|
const target = event.target;
|
|
if (!(target instanceof Element)) {
|
|
return;
|
|
}
|
|
|
|
if (target.closest(".tarot-frame-card")) {
|
|
event.preventDefault();
|
|
}
|
|
}
|
|
|
|
function handleDocumentClick(event) {
|
|
if (!state.settingsOpen) {
|
|
return;
|
|
}
|
|
|
|
const target = event.target;
|
|
if (!(target instanceof Node)) {
|
|
return;
|
|
}
|
|
|
|
const { tarotFrameSettingsPanelEl, tarotFrameSettingsToggleEl } = getElements();
|
|
if (tarotFrameSettingsPanelEl?.contains(target) || tarotFrameSettingsToggleEl?.contains(target)) {
|
|
return;
|
|
}
|
|
|
|
state.settingsOpen = false;
|
|
syncControls();
|
|
}
|
|
|
|
function handleDocumentKeydown(event) {
|
|
if (!state.settingsOpen || event.key !== "Escape") {
|
|
return;
|
|
}
|
|
|
|
state.settingsOpen = false;
|
|
syncControls();
|
|
}
|
|
|
|
function drawRoundedRectPath(context, x, y, width, height, radius) {
|
|
const nextRadius = Math.max(0, Math.min(radius, width / 2, height / 2));
|
|
context.beginPath();
|
|
context.moveTo(x + nextRadius, y);
|
|
context.lineTo(x + width - nextRadius, y);
|
|
context.quadraticCurveTo(x + width, y, x + width, y + nextRadius);
|
|
context.lineTo(x + width, y + height - nextRadius);
|
|
context.quadraticCurveTo(x + width, y + height, x + width - nextRadius, y + height);
|
|
context.lineTo(x + nextRadius, y + height);
|
|
context.quadraticCurveTo(x, y + height, x, y + height - nextRadius);
|
|
context.lineTo(x, y + nextRadius);
|
|
context.quadraticCurveTo(x, y, x + nextRadius, y);
|
|
context.closePath();
|
|
}
|
|
|
|
function fitCanvasLabelText(context, text, maxWidth) {
|
|
const normalized = normalizeLabelText(text);
|
|
if (!normalized || context.measureText(normalized).width <= maxWidth) {
|
|
return normalized;
|
|
}
|
|
|
|
let result = normalized;
|
|
while (result.length > 1 && context.measureText(`${result}...`).width > maxWidth) {
|
|
result = result.slice(0, -1).trimEnd();
|
|
}
|
|
return `${result}...`;
|
|
}
|
|
|
|
function wrapCanvasText(context, text, maxWidth, maxLines = 2) {
|
|
const normalized = normalizeLabelText(text);
|
|
if (!normalized) {
|
|
return [];
|
|
}
|
|
|
|
const words = normalized.split(/\s+/).filter(Boolean);
|
|
const lines = [];
|
|
let current = "";
|
|
words.forEach((word) => {
|
|
const next = current ? `${current} ${word}` : word;
|
|
if (current && context.measureText(next).width > maxWidth) {
|
|
lines.push(current);
|
|
current = word;
|
|
} else {
|
|
current = next;
|
|
}
|
|
});
|
|
if (current) {
|
|
lines.push(current);
|
|
}
|
|
|
|
if (lines.length <= maxLines) {
|
|
return lines;
|
|
}
|
|
|
|
const clipped = lines.slice(0, Math.max(1, maxLines));
|
|
clipped[clipped.length - 1] = fitCanvasLabelText(context, clipped[clipped.length - 1], maxWidth);
|
|
return clipped;
|
|
}
|
|
|
|
function drawImageContain(context, image, x, y, width, height) {
|
|
if (!(image instanceof HTMLImageElement) && !(image instanceof ImageBitmap)) {
|
|
return;
|
|
}
|
|
|
|
const sourceWidth = Number(image.width || image.naturalWidth || 0);
|
|
const sourceHeight = Number(image.height || image.naturalHeight || 0);
|
|
if (!(sourceWidth > 0 && sourceHeight > 0)) {
|
|
return;
|
|
}
|
|
|
|
const scale = Math.min(width / sourceWidth, height / sourceHeight);
|
|
const drawWidth = sourceWidth * scale;
|
|
const drawHeight = sourceHeight * scale;
|
|
const drawX = x + ((width - drawWidth) / 2);
|
|
const drawY = y + ((height - drawHeight) / 2);
|
|
context.drawImage(image, drawX, drawY, drawWidth, drawHeight);
|
|
}
|
|
|
|
function drawSlotToCanvas(context, x, y, size, card, image) {
|
|
if (!card) {
|
|
context.save();
|
|
context.setLineDash([6, 6]);
|
|
context.lineWidth = 1.5;
|
|
context.strokeStyle = "rgba(148, 163, 184, 0.42)";
|
|
drawRoundedRectPath(context, x + 1, y + 1, size - 2, size - 2, 10);
|
|
context.stroke();
|
|
context.restore();
|
|
return;
|
|
}
|
|
|
|
const cardX = x + EXPORT_CARD_INSET;
|
|
const cardY = y + EXPORT_CARD_INSET;
|
|
const cardSize = size - (EXPORT_CARD_INSET * 2);
|
|
|
|
context.save();
|
|
drawRoundedRectPath(context, cardX, cardY, cardSize, cardSize, 0);
|
|
context.clip();
|
|
if (image) {
|
|
drawImageContain(context, image, cardX, cardY, cardSize, cardSize);
|
|
} else {
|
|
context.fillStyle = EXPORT_PANEL;
|
|
context.fillRect(cardX, cardY, cardSize, cardSize);
|
|
context.fillStyle = "#f8fafc";
|
|
context.textAlign = "center";
|
|
context.textBaseline = "middle";
|
|
context.font = "700 14px 'Segoe UI', sans-serif";
|
|
const lines = wrapCanvasText(context, getDisplayCardName(card), cardSize - 18, 4);
|
|
const lineHeight = 18;
|
|
let currentY = cardY + (cardSize / 2) - (((Math.max(1, lines.length) - 1) * lineHeight) / 2);
|
|
lines.forEach((line) => {
|
|
context.fillText(line, cardX + (cardSize / 2), currentY, cardSize - 18);
|
|
currentY += lineHeight;
|
|
});
|
|
}
|
|
context.restore();
|
|
|
|
if (state.showInfo) {
|
|
const overlayText = getCardOverlayLabel(card);
|
|
if (overlayText) {
|
|
const overlayHeight = 30;
|
|
const overlayX = cardX + 4;
|
|
const overlayY = cardY + cardSize - overlayHeight - 4;
|
|
const overlayWidth = cardSize - 8;
|
|
drawRoundedRectPath(context, overlayX, overlayY, overlayWidth, overlayHeight, 8);
|
|
context.fillStyle = EXPORT_BADGE_BACKGROUND;
|
|
context.fill();
|
|
context.fillStyle = EXPORT_BADGE_TEXT;
|
|
context.textAlign = "center";
|
|
context.textBaseline = "middle";
|
|
context.font = "700 11px 'Segoe UI', sans-serif";
|
|
const lines = wrapCanvasText(context, overlayText, overlayWidth - 10, 2);
|
|
const lineHeight = 12;
|
|
let currentY = overlayY + (overlayHeight / 2) - (((Math.max(1, lines.length) - 1) * lineHeight) / 2);
|
|
lines.forEach((line) => {
|
|
context.fillText(line, overlayX + (overlayWidth / 2), currentY, overlayWidth - 10);
|
|
currentY += lineHeight;
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
function loadCardImage(src) {
|
|
return new Promise((resolve) => {
|
|
const image = new Image();
|
|
image.crossOrigin = "anonymous";
|
|
image.decoding = "async";
|
|
image.onload = () => resolve(image);
|
|
image.onerror = () => resolve(null);
|
|
image.src = src;
|
|
});
|
|
}
|
|
|
|
function isExportFormatSupported(format) {
|
|
const exportFormat = EXPORT_FORMATS[format];
|
|
if (!exportFormat) {
|
|
return false;
|
|
}
|
|
|
|
const probeCanvas = document.createElement("canvas");
|
|
const dataUrl = probeCanvas.toDataURL(exportFormat.mimeType);
|
|
return dataUrl.startsWith(`data:${exportFormat.mimeType}`);
|
|
}
|
|
|
|
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);
|
|
});
|
|
}
|
|
|
|
async function exportImage(format = "webp") {
|
|
const cards = getCards();
|
|
const cardMap = getCardMap(cards);
|
|
const exportFormat = EXPORT_FORMATS[format] || EXPORT_FORMATS.webp;
|
|
const contentSize = (MASTER_GRID_SIZE * EXPORT_SLOT_SIZE) + ((MASTER_GRID_SIZE - 1) * EXPORT_GRID_GAP);
|
|
const canvasSize = contentSize + (EXPORT_PADDING * 2);
|
|
const scale = Math.max(1.5, Math.min(2, Number(window.devicePixelRatio) || 1));
|
|
const canvas = document.createElement("canvas");
|
|
canvas.width = Math.ceil(canvasSize * scale);
|
|
canvas.height = Math.ceil(canvasSize * scale);
|
|
canvas.style.width = `${canvasSize}px`;
|
|
canvas.style.height = `${canvasSize}px`;
|
|
|
|
const context = canvas.getContext("2d");
|
|
if (!context) {
|
|
throw new Error("Canvas context is unavailable.");
|
|
}
|
|
|
|
context.scale(scale, scale);
|
|
context.imageSmoothingEnabled = true;
|
|
context.imageSmoothingQuality = "high";
|
|
context.fillStyle = EXPORT_BACKGROUND;
|
|
context.fillRect(0, 0, canvasSize, canvasSize);
|
|
|
|
const imageCache = new Map();
|
|
cards.forEach((card) => {
|
|
const src = resolveCardThumbnail(card);
|
|
if (src && !imageCache.has(src)) {
|
|
imageCache.set(src, loadCardImage(src));
|
|
}
|
|
});
|
|
|
|
const resolvedImages = new Map();
|
|
await Promise.all(cards.map(async (card) => {
|
|
const src = resolveCardThumbnail(card);
|
|
const image = src ? await imageCache.get(src) : null;
|
|
resolvedImages.set(getCardId(card), image || null);
|
|
}));
|
|
|
|
for (let row = 1; row <= MASTER_GRID_SIZE; row += 1) {
|
|
for (let column = 1; column <= MASTER_GRID_SIZE; column += 1) {
|
|
const slotId = getSlotId(row, column);
|
|
const card = getAssignedCard(slotId, cardMap);
|
|
const x = EXPORT_PADDING + ((column - 1) * (EXPORT_SLOT_SIZE + EXPORT_GRID_GAP));
|
|
const y = EXPORT_PADDING + ((row - 1) * (EXPORT_SLOT_SIZE + EXPORT_GRID_GAP));
|
|
drawSlotToCanvas(context, x, y, EXPORT_SLOT_SIZE, card, card ? resolvedImages.get(getCardId(card)) : null);
|
|
}
|
|
}
|
|
|
|
const blob = await canvasToBlobByFormat(canvas, format);
|
|
const blobUrl = URL.createObjectURL(blob);
|
|
const downloadLink = document.createElement("a");
|
|
const stamp = new Date().toISOString().slice(0, 10);
|
|
downloadLink.href = blobUrl;
|
|
downloadLink.download = `tarot-frame-grid-${stamp}.${exportFormat.extension}`;
|
|
document.body.appendChild(downloadLink);
|
|
downloadLink.click();
|
|
downloadLink.remove();
|
|
setTimeout(() => URL.revokeObjectURL(blobUrl), 1000);
|
|
}
|
|
|
|
async function exportFrame(format = "webp") {
|
|
if (state.exportInProgress) {
|
|
return;
|
|
}
|
|
|
|
state.exportInProgress = true;
|
|
state.exportFormat = format;
|
|
syncControls();
|
|
|
|
try {
|
|
await exportImage(format);
|
|
setStatus(`Downloaded a ${String(format || "webp").toUpperCase()} export of the current frame grid.`);
|
|
} catch (error) {
|
|
window.alert(error instanceof Error ? error.message : "Unable to export the Tarot Frame image.");
|
|
} finally {
|
|
state.exportInProgress = false;
|
|
state.exportFormat = "webp";
|
|
syncControls();
|
|
}
|
|
}
|
|
|
|
function bindEvents() {
|
|
const {
|
|
tarotFrameBoardEl,
|
|
tarotFrameResetEl,
|
|
tarotFrameSettingsToggleEl,
|
|
tarotFrameSettingsPanelEl,
|
|
tarotFrameShowInfoEl,
|
|
tarotFrameExportWebpEl
|
|
} = getElements();
|
|
if (tarotFrameBoardEl) {
|
|
tarotFrameBoardEl.addEventListener("pointerdown", handlePointerDown);
|
|
tarotFrameBoardEl.addEventListener("click", handleBoardClick);
|
|
tarotFrameBoardEl.addEventListener("dragstart", handleNativeDragStart);
|
|
}
|
|
|
|
if (tarotFrameResetEl) {
|
|
tarotFrameResetEl.addEventListener("click", () => {
|
|
const cards = getCards();
|
|
if (!cards.length) {
|
|
return;
|
|
}
|
|
resetLayout(cards, "Master grid reset to the default chronological frame arrangement.");
|
|
render();
|
|
});
|
|
}
|
|
|
|
if (tarotFrameSettingsToggleEl) {
|
|
tarotFrameSettingsToggleEl.addEventListener("click", (event) => {
|
|
event.stopPropagation();
|
|
if (state.exportInProgress) {
|
|
return;
|
|
}
|
|
state.settingsOpen = !state.settingsOpen;
|
|
syncControls();
|
|
});
|
|
}
|
|
|
|
if (tarotFrameSettingsPanelEl) {
|
|
tarotFrameSettingsPanelEl.addEventListener("click", (event) => {
|
|
event.stopPropagation();
|
|
});
|
|
}
|
|
|
|
if (tarotFrameShowInfoEl) {
|
|
tarotFrameShowInfoEl.addEventListener("change", () => {
|
|
state.showInfo = Boolean(tarotFrameShowInfoEl.checked);
|
|
render();
|
|
syncControls();
|
|
});
|
|
}
|
|
|
|
if (tarotFrameExportWebpEl) {
|
|
tarotFrameExportWebpEl.addEventListener("click", () => {
|
|
exportFrame("webp");
|
|
});
|
|
}
|
|
|
|
document.addEventListener("click", handleDocumentClick);
|
|
document.addEventListener("keydown", handleDocumentKeydown);
|
|
}
|
|
|
|
async function ensureTarotFrameSection(referenceData, magickDataset) {
|
|
if (typeof config.ensureTarotSection === "function") {
|
|
await config.ensureTarotSection(referenceData, magickDataset);
|
|
}
|
|
|
|
const cards = getCards();
|
|
if (!cards.length) {
|
|
setStatus("Tarot cards are still loading...");
|
|
return;
|
|
}
|
|
|
|
const signature = buildCardSignature(cards);
|
|
if (!state.layoutReady || state.cardSignature !== signature) {
|
|
state.cardSignature = signature;
|
|
resetLayout(cards);
|
|
} else {
|
|
setStatus(state.statusMessage || buildReadyStatus(cards));
|
|
}
|
|
|
|
render();
|
|
syncControls();
|
|
}
|
|
|
|
function init(nextConfig = {}) {
|
|
config = {
|
|
...config,
|
|
...nextConfig
|
|
};
|
|
|
|
if (state.initialized) {
|
|
return;
|
|
}
|
|
|
|
bindEvents();
|
|
syncControls();
|
|
state.initialized = true;
|
|
}
|
|
|
|
window.TarotFrameUi = {
|
|
...(window.TarotFrameUi || {}),
|
|
init,
|
|
ensureTarotFrameSection,
|
|
render,
|
|
resetLayout,
|
|
exportImage,
|
|
isExportFormatSupported
|
|
};
|
|
})(); |