diff --git a/app/styles.css b/app/styles.css index cf2d41f..c398118 100644 --- a/app/styles.css +++ b/app/styles.css @@ -1666,6 +1666,10 @@ overflow: hidden; } + .tarot-frame-card-picker.is-browse-mode { + grid-template-rows: auto auto minmax(0, 1fr); + } + .tarot-frame-card-picker[hidden] { display: none !important; } @@ -1677,6 +1681,66 @@ gap: 12px; } + .tarot-frame-card-insert-menu { + position: fixed; + z-index: 41; + width: min(240px, calc(100vw - 24px)); + display: grid; + gap: 6px; + padding: 8px; + border: 1px solid rgba(129, 140, 248, 0.3); + border-radius: 16px; + background: + radial-gradient(circle at top, rgba(99, 102, 241, 0.18), transparent 36%), + linear-gradient(180deg, rgba(15, 23, 42, 0.98), rgba(2, 6, 23, 0.98)); + box-shadow: 0 24px 70px rgba(0, 0, 0, 0.42); + } + + .tarot-frame-card-insert-menu[hidden] { + display: none !important; + } + + .tarot-frame-card-insert-menu-item { + padding: 11px 12px; + border: 1px solid rgba(99, 102, 241, 0.22); + border-radius: 12px; + background: rgba(30, 41, 59, 0.58); + color: #e2e8f0; + text-align: left; + font-size: 13px; + font-weight: 700; + cursor: pointer; + } + + .tarot-frame-card-insert-menu-item:hover, + .tarot-frame-card-insert-menu-item:focus-visible { + border-color: rgba(165, 180, 252, 0.9); + background: rgba(49, 46, 129, 0.34); + color: #f8fafc; + } + + .tarot-frame-card-custom-editor { + position: fixed; + z-index: 42; + width: min(420px, calc(100vw - 24px)); + max-height: min(78vh, 720px); + display: grid; + grid-template-rows: auto minmax(0, 1fr); + gap: 12px; + padding: 14px; + border: 1px solid rgba(129, 140, 248, 0.3); + border-radius: 20px; + background: + radial-gradient(circle at top, rgba(99, 102, 241, 0.18), transparent 36%), + linear-gradient(180deg, rgba(15, 23, 42, 0.98), rgba(2, 6, 23, 0.98)); + box-shadow: 0 24px 70px rgba(0, 0, 0, 0.42); + overflow: hidden; + } + + .tarot-frame-card-custom-editor[hidden] { + display: none !important; + } + .tarot-frame-card-picker-title { color: #f8fafc; font-size: 15px; @@ -1776,6 +1840,19 @@ color: #f8fafc; } + .tarot-frame-card-picker-option.is-custom-action { + border-style: dashed; + border-color: rgba(125, 211, 252, 0.38); + background: + linear-gradient(180deg, rgba(14, 116, 144, 0.18), rgba(15, 23, 42, 0.78)); + } + + .tarot-frame-card-picker-option.is-custom-action:hover { + border-color: rgba(103, 232, 249, 0.82); + background: + linear-gradient(180deg, rgba(8, 145, 178, 0.28), rgba(15, 23, 42, 0.9)); + } + .tarot-frame-card-picker-option strong { font-size: 12px; line-height: 1.35; @@ -1797,6 +1874,179 @@ background: rgba(15, 23, 42, 0.24); } + .tarot-frame-card-picker-editor { + display: grid; + align-content: start; + gap: 12px; + min-height: 0; + overflow: auto; + padding-right: 2px; + } + + .tarot-frame-card-picker-editor-heading { + display: grid; + gap: 4px; + padding: 12px; + border: 1px solid rgba(125, 211, 252, 0.22); + border-radius: 16px; + background: linear-gradient(180deg, rgba(8, 145, 178, 0.16), rgba(15, 23, 42, 0.64)); + color: #dbeafe; + } + + .tarot-frame-card-picker-editor-heading strong { + font-size: 13px; + letter-spacing: 0.04em; + text-transform: uppercase; + } + + .tarot-frame-card-picker-editor-heading span { + color: #bae6fd; + font-size: 12px; + line-height: 1.35; + } + + .tarot-frame-card-picker-editor-field { + display: grid; + gap: 6px; + } + + .tarot-frame-card-picker-editor-field span, + .tarot-frame-card-picker-editor-preview-label { + color: #cbd5e1; + font-size: 11px; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; + } + + .tarot-frame-card-picker-editor-field input[type="text"], + .tarot-frame-card-picker-editor-field textarea, + .tarot-frame-card-picker-editor-field input[type="file"] { + width: 100%; + padding: 10px 12px; + border: 1px solid rgba(129, 140, 248, 0.24); + border-radius: 12px; + background: rgba(15, 23, 42, 0.84); + color: #f8fafc; + font-size: 13px; + box-sizing: border-box; + } + + .tarot-frame-card-picker-editor-field textarea { + min-height: 84px; + resize: vertical; + } + + .tarot-frame-card-picker-editor-field input[type="color"] { + width: 100%; + min-height: 46px; + padding: 6px; + border: 1px solid rgba(129, 140, 248, 0.24); + border-radius: 12px; + background: rgba(15, 23, 42, 0.84); + box-sizing: border-box; + cursor: pointer; + } + + .tarot-frame-card-picker-editor-image-meta { + color: #94a3b8; + font-size: 11px; + line-height: 1.35; + } + + .tarot-frame-card-picker-editor-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); + gap: 10px; + } + + .tarot-frame-card-picker-editor-inline-actions, + .tarot-frame-card-picker-editor-actions { + display: flex; + flex-wrap: wrap; + gap: 8px; + } + + .tarot-frame-card-picker-editor-clear, + .tarot-frame-card-picker-editor-cancel, + .tarot-frame-card-picker-editor-save, + .tarot-frame-card-picker-editor-remove { + padding: 9px 12px; + border-radius: 12px; + font-size: 12px; + font-weight: 700; + cursor: pointer; + } + + .tarot-frame-card-picker-editor-clear, + .tarot-frame-card-picker-editor-cancel { + border: 1px solid rgba(148, 163, 184, 0.28); + background: rgba(15, 23, 42, 0.78); + color: #e2e8f0; + } + + .tarot-frame-card-picker-editor-save { + border: 1px solid rgba(34, 197, 94, 0.28); + background: linear-gradient(180deg, rgba(21, 128, 61, 0.92), rgba(20, 83, 45, 0.96)); + color: #f0fdf4; + } + + .tarot-frame-card-picker-editor-save:disabled, + .tarot-frame-card-picker-editor-clear:disabled { + opacity: 0.5; + cursor: not-allowed; + } + + .tarot-frame-card-picker-editor-remove { + margin-left: auto; + border: 1px solid rgba(248, 113, 113, 0.28); + background: linear-gradient(180deg, rgba(153, 27, 27, 0.92), rgba(69, 10, 10, 0.96)); + color: #fef2f2; + } + + .tarot-frame-card-picker-editor-preview-wrap { + display: grid; + gap: 8px; + padding: 12px; + border: 1px solid rgba(71, 85, 105, 0.48); + border-radius: 16px; + background: rgba(15, 23, 42, 0.42); + } + + .tarot-frame-card-picker-editor-preview { + display: grid; + place-items: center; + min-height: 190px; + padding: 8px; + border-radius: 16px; + background: + radial-gradient(circle at top, rgba(99, 102, 241, 0.16), transparent 44%), + linear-gradient(180deg, rgba(30, 41, 59, 0.6), rgba(15, 23, 42, 0.82)); + } + + .tarot-frame-card-picker-editor-preview .tarot-frame-card-text-face { + width: min(180px, 100%); + aspect-ratio: 5 / 7; + border-radius: 18px; + overflow: hidden; + border: 1px solid rgba(148, 163, 184, 0.24); + box-shadow: 0 18px 36px rgba(0, 0, 0, 0.3); + } + + .tarot-frame-card-picker-editor-empty { + display: grid; + place-items: center; + min-height: 160px; + padding: 16px; + border: 1px dashed rgba(125, 211, 252, 0.28); + border-radius: 16px; + color: #cbd5e1; + font-size: 12px; + line-height: 1.45; + text-align: center; + background: rgba(15, 23, 42, 0.36); + } + .tarot-frame-slot.is-drop-target { box-shadow: 0 0 0 2px #f59e0b, 0 0 0 6px rgba(245, 158, 11, 0.18); border-radius: 8px; @@ -1921,6 +2171,59 @@ padding: 5px 4px; } + .tarot-frame-card-text-face.is-custom-label { + border: 1px dashed rgba(147, 197, 253, 0.48); + background: + radial-gradient(circle at top, rgba(125, 211, 252, 0.16), transparent 58%), + linear-gradient(180deg, rgba(22, 78, 99, 0.52), rgba(15, 23, 42, 0.96)); + color: #f8fafc; + } + + .tarot-frame-card-text-face.is-custom-label.is-custom-visual { + border-style: solid; + border-color: rgba(186, 230, 253, 0.72); + text-shadow: 0 2px 10px rgba(15, 23, 42, 0.88); + } + + .tarot-frame-card-text-face.is-custom-label.is-custom-visual:not(.has-custom-image) { + background-image: none; + } + + .tarot-frame-card-text-face.is-custom-label.is-custom-visual-only { + border-style: solid; + border-color: rgba(186, 230, 253, 0.52); + } + + .tarot-frame-card-text-face.is-custom-label.is-custom-blank { + gap: 0; + padding: 0; + } + + .tarot-frame-card-text-face.is-custom-label.is-custom-hero .tarot-frame-card-text-primary { + font-size: clamp(15px, 1.45vw, 24px); + line-height: 1; + letter-spacing: 0.01em; + } + + .tarot-frame-card-text-face.is-custom-label.is-custom-short .tarot-frame-card-text-primary { + font-size: clamp(12px, 1.1vw, 18px); + line-height: 1.05; + } + + .tarot-frame-card-text-face.is-custom-label.is-custom-medium .tarot-frame-card-text-primary { + font-size: clamp(9px, 0.92vw, 14px); + line-height: 1.1; + } + + .tarot-frame-card-text-face.is-custom-label .tarot-frame-card-text-secondary { + color: rgba(224, 242, 254, 0.84); + } + + .tarot-frame-card-text-face.is-custom-label.is-custom-hero .tarot-frame-card-text-secondary, + .tarot-frame-card-text-face.is-custom-label.is-custom-short .tarot-frame-card-text-secondary { + font-size: clamp(7px, 0.74vw, 10px); + } + .tarot-frame-card-text-face.is-top-hebrew .tarot-frame-card-text-primary { font-size: clamp(11px, 1vw, 16px); line-height: 1; @@ -1992,6 +2295,11 @@ display: block; } + .tarot-frame-drag-ghost .tarot-frame-card-text-face { + height: 100%; + padding: 10px 8px; + } + .tarot-frame-drag-ghost-label { position: absolute; left: 6px; @@ -2010,6 +2318,77 @@ box-sizing: border-box; } + .tarot-frame-drag-trash { + position: fixed; + right: 18px; + bottom: 18px; + z-index: 130; + display: grid; + grid-template-columns: auto 1fr; + align-items: center; + gap: 10px; + min-width: 220px; + padding: 12px 14px; + border: 1px solid rgba(248, 113, 113, 0.34); + border-radius: 18px; + background: linear-gradient(180deg, rgba(69, 10, 10, 0.94), rgba(28, 25, 23, 0.96)); + color: #fee2e2; + box-shadow: 0 22px 46px rgba(0, 0, 0, 0.34); + } + + .tarot-frame-drag-trash[hidden] { + display: none !important; + } + + .tarot-frame-drag-trash.is-active { + border-color: rgba(252, 165, 165, 0.92); + background: linear-gradient(180deg, rgba(153, 27, 27, 0.96), rgba(69, 10, 10, 0.98)); + transform: scale(1.04); + } + + .tarot-frame-drag-trash-icon { + width: 38px; + height: 38px; + display: grid; + place-items: center; + border-radius: 12px; + background: rgba(255, 255, 255, 0.08); + color: #fecaca; + } + + .tarot-frame-drag-trash-icon svg { + width: 22px; + height: 22px; + fill: currentColor; + } + + .tarot-frame-drag-trash-copy { + display: grid; + gap: 2px; + min-width: 0; + } + + .tarot-frame-drag-trash-copy strong { + font-size: 12px; + letter-spacing: 0.06em; + text-transform: uppercase; + } + + .tarot-frame-drag-trash-copy span { + color: rgba(254, 226, 226, 0.82); + font-size: 12px; + line-height: 1.3; + } + + @media (max-width: 760px) { + .tarot-frame-drag-trash { + left: 12px; + right: 12px; + bottom: 12px; + min-width: 0; + } + } + body.is-tarot-frame-dragging { cursor: grabbing; -webkit-user-select: none; diff --git a/app/ui-tarot-frame.js b/app/ui-tarot-frame.js index 4fb79b9..bb14767 100644 --- a/app/ui-tarot-frame.js +++ b/app/ui-tarot-frame.js @@ -60,6 +60,8 @@ const FRAME_LONG_PRESS_DELAY_MS = 460; const FRAME_LONG_PRESS_MOVE_TOLERANCE = 10; 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_HEIGHT = Math.round((EXPORT_SLOT_WIDTH * TAROT_CARD_HEIGHT_RATIO) / TAROT_CARD_WIDTH_RATIO); const EXPORT_CARD_INSET = 0; @@ -194,10 +196,17 @@ panGesture: null, pinchGesture: null, longPress: null, + cardInsertMenu: { + open: false, + slotId: "" + }, cardPicker: { open: false, slotId: "", - query: "" + query: "", + mode: "browse", + editingCardId: "", + editorImageData: "" }, suppressClick: false, showInfo: true, @@ -207,6 +216,7 @@ gridFocusMode: false, currentLayoutId: "frames", customLayouts: [], + customCards: new Map(), layoutNotesById: {}, exportInProgress: false, exportFormat: "webp", @@ -228,10 +238,25 @@ setHouseBottomInfoMode: () => {} }; + let cardInsertMenuEl = null; let cardPickerEl = null; let cardPickerTitleEl = null; let cardPickerSearchEl = null; let cardPickerSectionsEl = null; + let cardPickerSearchWrapEl = null; + let cardPickerEditorHeadingEl = null; + let cardPickerEditorTitleEl = null; + let cardPickerEditorSubtitleEl = null; + let cardPickerEditorColorEl = null; + let cardPickerEditorImageEl = null; + let cardPickerEditorImageUploadEl = null; + let cardPickerEditorImageClearEl = null; + let cardPickerEditorPreviewEl = null; + let cardPickerEditorSaveEl = null; + let cardPickerEditorRemoveEl = null; + let cardCustomEditorEl = null; + let cardCustomEditorTitleEl = null; + let dragTrashEl = null; let pendingGridViewportRestoreFrameId = 0; let activeTouchGestureCapture = false; @@ -508,7 +533,7 @@ } function captureSlotAssignmentsSnapshot(cards = getCards()) { - const validCardIds = new Set(cards.map((card) => getCardId(card)).filter(Boolean)); + const validCardIds = new Set(Array.from(getCardMap(cards).keys())); return [...state.slotAssignments.entries()] .map(([slotId, cardId]) => ({ slotId: String(slotId || "").trim(), @@ -525,6 +550,25 @@ }); } + function captureCustomCardsSnapshot() { + const assignedCustomIds = new Set( + Array.from(state.slotAssignments.values()) + .map((cardId) => String(cardId || "").trim()) + .filter((cardId) => isCustomFrameCardId(cardId)) + ); + + return Array.from(state.customCards.values()) + .filter((card) => assignedCustomIds.has(getCardId(card))) + .map((card) => ({ + id: getCardId(card), + customText: normalizeCustomCardText(card?.customText), + backgroundColor: normalizeCustomCardBackgroundColor(card?.backgroundColor), + backgroundImage: normalizeCustomCardBackgroundImage(card?.backgroundImage) + })) + .filter((card) => card.id && (card.customText || card.backgroundColor || card.backgroundImage)) + .sort((left, right) => left.id.localeCompare(right.id)); + } + function normalizeSavedLayoutRecord(rawLayout) { const label = normalizeLayoutLabel(rawLayout?.label || rawLayout?.name); if (!label) { @@ -540,6 +584,11 @@ })) .filter((entry) => isValidSlotId(entry.slotId) && entry.cardId) : []; + const customCards = Array.isArray(rawLayout?.customCards) + ? rawLayout.customCards + .map((entry) => normalizeSavedCustomCardRecord(entry)) + .filter(Boolean) + : []; const settings = normalizeFrameSettingsSnapshot(rawLayout?.settings); const createdAt = String(rawLayout?.createdAt || rawLayout?.updatedAt || "").trim(); const note = normalizeLayoutNote(rawLayout?.note); @@ -557,6 +606,7 @@ } ], slotAssignments, + customCards, settings, note, createdAt, @@ -613,6 +663,7 @@ id: layout.id, label: layout.label, slotAssignments: layout.slotAssignments, + customCards: Array.isArray(layout.customCards) ? layout.customCards : [], settings: layout.settings, note: normalizeLayoutNote(layout.note), createdAt: layout.createdAt || new Date().toISOString() @@ -905,7 +956,7 @@ cardPickerTitleEl = document.createElement("div"); cardPickerTitleEl.className = "tarot-frame-card-picker-title"; - cardPickerTitleEl.textContent = "Place Tarot Card"; + cardPickerTitleEl.textContent = "Browse Stock Cards"; const closeButtonEl = document.createElement("button"); closeButtonEl.type = "button"; @@ -917,13 +968,13 @@ headEl.append(cardPickerTitleEl, closeButtonEl); - const searchWrapEl = document.createElement("label"); - searchWrapEl.className = "tarot-frame-card-picker-search"; + cardPickerSearchWrapEl = document.createElement("label"); + cardPickerSearchWrapEl.className = "tarot-frame-card-picker-search"; const searchLabelEl = document.createElement("span"); - searchLabelEl.textContent = "Search Cards & Associations"; + searchLabelEl.textContent = "Search Cards, Associations, or Labels"; cardPickerSearchEl = document.createElement("input"); cardPickerSearchEl.type = "search"; - cardPickerSearchEl.placeholder = "Find by card, planet, sign, decan..."; + cardPickerSearchEl.placeholder = "Find by card, planet, sign, decan, label..."; cardPickerSearchEl.autocomplete = "off"; cardPickerSearchEl.spellcheck = false; cardPickerSearchEl.addEventListener("input", () => { @@ -931,12 +982,11 @@ persistCardPickerQuery(); renderCardPickerSections(); }); - searchWrapEl.append(searchLabelEl, cardPickerSearchEl); + cardPickerSearchWrapEl.append(searchLabelEl, cardPickerSearchEl); cardPickerSectionsEl = document.createElement("div"); cardPickerSectionsEl.className = "tarot-frame-card-picker-sections"; - - cardPickerEl.append(headEl, searchWrapEl, cardPickerSectionsEl); + cardPickerEl.append(headEl, cardPickerSearchWrapEl, cardPickerSectionsEl); cardPickerEl.addEventListener("click", (event) => { event.stopPropagation(); const target = event.target; @@ -944,26 +994,397 @@ return; } - const option = target.closest(".tarot-frame-card-picker-option[data-card-id]"); - if (!(option instanceof HTMLButtonElement)) { + const cardOption = target.closest(".tarot-frame-card-picker-option[data-card-id]"); + if (!(cardOption instanceof HTMLButtonElement)) { return; } - placeCardInSlot(state.cardPicker.slotId, option.dataset.cardId); + placeCardInSlot(state.cardPicker.slotId, cardOption.dataset.cardId); closeCardPicker(); }); document.body.appendChild(cardPickerEl); } + function createCardInsertMenuElements() { + if (cardInsertMenuEl) { + return; + } + + cardInsertMenuEl = document.createElement("div"); + cardInsertMenuEl.className = "tarot-frame-card-insert-menu"; + cardInsertMenuEl.hidden = true; + + const libraryButtonEl = document.createElement("button"); + libraryButtonEl.type = "button"; + libraryButtonEl.className = "tarot-frame-card-insert-menu-item"; + libraryButtonEl.dataset.cardInsertAction = "library"; + libraryButtonEl.textContent = "Card > Library"; + + const customButtonEl = document.createElement("button"); + customButtonEl.type = "button"; + customButtonEl.className = "tarot-frame-card-insert-menu-item"; + customButtonEl.dataset.cardInsertAction = "custom"; + customButtonEl.textContent = "Card > Custom"; + + cardInsertMenuEl.append(libraryButtonEl, customButtonEl); + cardInsertMenuEl.addEventListener("click", (event) => { + event.stopPropagation(); + const target = event.target; + if (!(target instanceof Element)) { + return; + } + + const actionButton = target.closest("[data-card-insert-action]"); + if (!(actionButton instanceof HTMLButtonElement)) { + return; + } + + const slotId = String(state.cardInsertMenu.slotId || "").trim(); + const anchor = getCardPickerAnchor(slotId); + closeCardInsertMenu(); + if (actionButton.dataset.cardInsertAction === "custom") { + openCustomCardEditor(slotId, "", { anchorX: anchor.x, anchorY: anchor.y, reposition: true }); + return; + } + + openCardPicker(slotId, anchor.x, anchor.y); + }); + + document.body.appendChild(cardInsertMenuEl); + } + + function createCustomCardEditorElements() { + if (cardCustomEditorEl) { + return; + } + + cardCustomEditorEl = document.createElement("div"); + cardCustomEditorEl.className = "tarot-frame-card-custom-editor"; + cardCustomEditorEl.hidden = true; + + const headEl = document.createElement("div"); + headEl.className = "tarot-frame-card-picker-head"; + + cardCustomEditorTitleEl = document.createElement("div"); + cardCustomEditorTitleEl.className = "tarot-frame-card-picker-title"; + cardCustomEditorTitleEl.textContent = "Create Custom Card"; + + const closeButtonEl = document.createElement("button"); + closeButtonEl.type = "button"; + closeButtonEl.className = "tarot-frame-card-picker-close"; + closeButtonEl.textContent = "Close"; + closeButtonEl.addEventListener("click", () => { + const slotId = String(state.cardPicker.slotId || "").trim(); + const editingCardId = String(state.cardPicker.editingCardId || "").trim(); + closeCustomCardEditor(); + if (!editingCardId && slotId) { + const anchor = getCardPickerAnchor(slotId); + openCardInsertMenu(slotId, anchor.x, anchor.y); + } + }); + + headEl.append(cardCustomEditorTitleEl, closeButtonEl); + + const editorEl = document.createElement("div"); + editorEl.className = "tarot-frame-card-picker-editor"; + + cardPickerEditorHeadingEl = document.createElement("div"); + cardPickerEditorHeadingEl.className = "tarot-frame-card-picker-editor-heading"; + const editorHeadingTitleEl = document.createElement("strong"); + editorHeadingTitleEl.textContent = "Custom Card"; + const editorHeadingCopyEl = document.createElement("span"); + editorHeadingCopyEl.textContent = "Leave text blank if you want a color or image-only card."; + cardPickerEditorHeadingEl.append(editorHeadingTitleEl, editorHeadingCopyEl); + + const titleFieldEl = document.createElement("label"); + titleFieldEl.className = "tarot-frame-card-picker-editor-field"; + const titleLabelEl = document.createElement("span"); + titleLabelEl.textContent = "Title"; + cardPickerEditorTitleEl = document.createElement("input"); + cardPickerEditorTitleEl.type = "text"; + cardPickerEditorTitleEl.maxLength = 120; + cardPickerEditorTitleEl.placeholder = "Optional title"; + titleFieldEl.append(titleLabelEl, cardPickerEditorTitleEl); + + const subtitleFieldEl = document.createElement("label"); + subtitleFieldEl.className = "tarot-frame-card-picker-editor-field"; + const subtitleLabelEl = document.createElement("span"); + subtitleLabelEl.textContent = "Subtitle / Notes"; + cardPickerEditorSubtitleEl = document.createElement("textarea"); + cardPickerEditorSubtitleEl.rows = 3; + cardPickerEditorSubtitleEl.maxLength = 240; + cardPickerEditorSubtitleEl.placeholder = "Optional secondary text"; + subtitleFieldEl.append(subtitleLabelEl, cardPickerEditorSubtitleEl); + + const visualGridEl = document.createElement("div"); + visualGridEl.className = "tarot-frame-card-picker-editor-grid"; + + const colorFieldEl = document.createElement("label"); + colorFieldEl.className = "tarot-frame-card-picker-editor-field"; + const colorLabelEl = document.createElement("span"); + colorLabelEl.textContent = "Background Color"; + cardPickerEditorColorEl = document.createElement("input"); + cardPickerEditorColorEl.type = "color"; + cardPickerEditorColorEl.value = "#164e63"; + colorFieldEl.append(colorLabelEl, cardPickerEditorColorEl); + + const imageFieldEl = document.createElement("label"); + imageFieldEl.className = "tarot-frame-card-picker-editor-field"; + const imageLabelEl = document.createElement("span"); + imageLabelEl.textContent = "Background Image"; + cardPickerEditorImageUploadEl = document.createElement("input"); + cardPickerEditorImageUploadEl.type = "file"; + cardPickerEditorImageUploadEl.accept = "image/*"; + cardPickerEditorImageEl = document.createElement("span"); + cardPickerEditorImageEl.className = "tarot-frame-card-picker-editor-image-meta"; + cardPickerEditorImageEl.textContent = "No image selected."; + imageFieldEl.append(imageLabelEl, cardPickerEditorImageUploadEl, cardPickerEditorImageEl); + + visualGridEl.append(colorFieldEl, imageFieldEl); + + const imageActionsEl = document.createElement("div"); + imageActionsEl.className = "tarot-frame-card-picker-editor-inline-actions"; + cardPickerEditorImageClearEl = document.createElement("button"); + cardPickerEditorImageClearEl.type = "button"; + cardPickerEditorImageClearEl.className = "tarot-frame-card-picker-editor-clear"; + cardPickerEditorImageClearEl.textContent = "Remove Image"; + imageActionsEl.appendChild(cardPickerEditorImageClearEl); + + const previewWrapEl = document.createElement("div"); + previewWrapEl.className = "tarot-frame-card-picker-editor-preview-wrap"; + const previewLabelEl = document.createElement("span"); + previewLabelEl.className = "tarot-frame-card-picker-editor-preview-label"; + previewLabelEl.textContent = "Preview"; + cardPickerEditorPreviewEl = document.createElement("div"); + cardPickerEditorPreviewEl.className = "tarot-frame-card-picker-editor-preview"; + previewWrapEl.append(previewLabelEl, cardPickerEditorPreviewEl); + + const editorActionsEl = document.createElement("div"); + editorActionsEl.className = "tarot-frame-card-picker-editor-actions"; + const editorCancelEl = document.createElement("button"); + editorCancelEl.type = "button"; + editorCancelEl.className = "tarot-frame-card-picker-editor-cancel"; + editorCancelEl.dataset.cardPickerEditorCancel = "true"; + editorCancelEl.textContent = "Back"; + cardPickerEditorSaveEl = document.createElement("button"); + cardPickerEditorSaveEl.type = "button"; + cardPickerEditorSaveEl.className = "tarot-frame-card-picker-editor-save"; + cardPickerEditorSaveEl.dataset.cardPickerEditorSave = "true"; + cardPickerEditorSaveEl.textContent = "Save Custom Card"; + cardPickerEditorRemoveEl = document.createElement("button"); + cardPickerEditorRemoveEl.type = "button"; + cardPickerEditorRemoveEl.className = "tarot-frame-card-picker-editor-remove"; + cardPickerEditorRemoveEl.dataset.cardPickerEditorRemove = "true"; + cardPickerEditorRemoveEl.textContent = "Delete Card"; + cardPickerEditorRemoveEl.hidden = true; + editorActionsEl.append(editorCancelEl, cardPickerEditorSaveEl, cardPickerEditorRemoveEl); + + [cardPickerEditorTitleEl, cardPickerEditorSubtitleEl].forEach((field) => { + field.addEventListener("input", () => { + updateCustomCardEditorPreview(); + }); + }); + + cardPickerEditorColorEl.addEventListener("input", () => { + cardPickerEditorColorEl.dataset.explicit = "true"; + updateCustomCardEditorPreview(); + }); + + cardPickerEditorImageUploadEl.addEventListener("change", () => { + const file = cardPickerEditorImageUploadEl.files?.[0] || null; + if (!file) { + return; + } + if (Number(file.size || 0) > 1_500_000) { + window.alert("Please choose an image under about 1.5 MB so it can be stored with the layout."); + cardPickerEditorImageUploadEl.value = ""; + return; + } + + const reader = new FileReader(); + reader.onload = () => { + state.cardPicker.editorImageData = normalizeCustomCardBackgroundImage(reader.result); + cardPickerEditorImageUploadEl.value = ""; + updateCustomCardEditorPreview(); + }; + reader.onerror = () => { + window.alert("Unable to read that image file."); + cardPickerEditorImageUploadEl.value = ""; + }; + reader.readAsDataURL(file); + }); + + cardPickerEditorImageClearEl.addEventListener("click", () => { + state.cardPicker.editorImageData = ""; + if (cardPickerEditorImageUploadEl) { + cardPickerEditorImageUploadEl.value = ""; + } + updateCustomCardEditorPreview(); + }); + + editorEl.append( + cardPickerEditorHeadingEl, + titleFieldEl, + subtitleFieldEl, + visualGridEl, + imageActionsEl, + previewWrapEl, + editorActionsEl + ); + + cardCustomEditorEl.append(headEl, editorEl); + cardCustomEditorEl.addEventListener("click", (event) => { + event.stopPropagation(); + const target = event.target; + if (!(target instanceof Element)) { + return; + } + + const editorCancel = target.closest("[data-card-picker-editor-cancel='true']"); + if (editorCancel instanceof HTMLButtonElement) { + const slotId = String(state.cardPicker.slotId || "").trim(); + const editingCardId = String(state.cardPicker.editingCardId || "").trim(); + closeCustomCardEditor(); + if (!editingCardId && slotId) { + const anchor = getCardPickerAnchor(slotId); + openCardInsertMenu(slotId, anchor.x, anchor.y); + } + return; + } + + const editorSave = target.closest("[data-card-picker-editor-save='true']"); + if (editorSave instanceof HTMLButtonElement) { + const didSave = submitCustomCardEditor(); + if (didSave) { + closeCustomCardEditor(); + } + return; + } + + const editorRemove = target.closest("[data-card-picker-editor-remove='true']"); + if (editorRemove instanceof HTMLButtonElement) { + const didRemove = removeEditedCustomCard(); + if (didRemove) { + closeCustomCardEditor(); + } + } + }); + + document.body.appendChild(cardCustomEditorEl); + } + function closeCardPicker() { state.cardPicker.open = false; state.cardPicker.slotId = ""; + state.cardPicker.mode = "browse"; + state.cardPicker.editingCardId = ""; + state.cardPicker.editorImageData = ""; if (cardPickerSearchEl) { cardPickerSearchEl.value = state.cardPicker.query; } if (cardPickerEl) { cardPickerEl.hidden = true; } + if (cardPickerEditorImageUploadEl) { + cardPickerEditorImageUploadEl.value = ""; + } + } + + function positionCardInsertMenu(anchorX, anchorY) { + if (!(cardInsertMenuEl instanceof HTMLElement)) { + return; + } + + cardInsertMenuEl.hidden = false; + cardInsertMenuEl.style.visibility = "hidden"; + requestAnimationFrame(() => { + if (!(cardInsertMenuEl instanceof HTMLElement)) { + return; + } + + const panelWidth = cardInsertMenuEl.offsetWidth || 220; + const panelHeight = cardInsertMenuEl.offsetHeight || 120; + const margin = 12; + const viewportWidth = window.innerWidth; + const viewportHeight = window.innerHeight; + let left = Math.min(Math.max(anchorX + 10, margin), viewportWidth - panelWidth - margin); + let top = Math.min(Math.max(anchorY + 10, margin), viewportHeight - panelHeight - margin); + if (top > viewportHeight - panelHeight - margin) { + top = Math.max(margin, anchorY - panelHeight - 10); + } + + cardInsertMenuEl.style.left = `${left}px`; + cardInsertMenuEl.style.top = `${top}px`; + cardInsertMenuEl.style.visibility = "visible"; + }); + } + + function closeCardInsertMenu() { + state.cardInsertMenu.open = false; + state.cardInsertMenu.slotId = ""; + if (cardInsertMenuEl) { + cardInsertMenuEl.hidden = true; + } + } + + function positionCustomCardEditor(anchorX, anchorY) { + if (!(cardCustomEditorEl instanceof HTMLElement)) { + return; + } + + cardCustomEditorEl.hidden = false; + cardCustomEditorEl.style.visibility = "hidden"; + requestAnimationFrame(() => { + if (!(cardCustomEditorEl instanceof HTMLElement)) { + return; + } + + const panelWidth = cardCustomEditorEl.offsetWidth || 360; + const panelHeight = cardCustomEditorEl.offsetHeight || 520; + const margin = 12; + const viewportWidth = window.innerWidth; + const viewportHeight = window.innerHeight; + let left = Math.min(Math.max(anchorX + 10, margin), viewportWidth - panelWidth - margin); + let top = Math.min(Math.max(anchorY + 10, margin), viewportHeight - panelHeight - margin); + if (top > viewportHeight - panelHeight - margin) { + top = Math.max(margin, anchorY - panelHeight - 10); + } + if (viewportWidth <= 760) { + left = Math.max(margin, Math.round((viewportWidth - panelWidth) / 2)); + } + + cardCustomEditorEl.style.left = `${left}px`; + cardCustomEditorEl.style.top = `${top}px`; + cardCustomEditorEl.style.visibility = "visible"; + }); + } + + function closeCustomCardEditor() { + state.cardPicker.editingCardId = ""; + state.cardPicker.editorImageData = ""; + if (cardCustomEditorEl) { + cardCustomEditorEl.hidden = true; + } + if (cardPickerEditorImageUploadEl) { + cardPickerEditorImageUploadEl.value = ""; + } + } + + function openCardInsertMenu(slotId, anchorX, anchorY) { + const targetSlotId = String(slotId || "").trim(); + if (!isValidSlotId(targetSlotId)) { + return; + } + + createCardInsertMenuElements(); + closeCardPicker(); + closeCustomCardEditor(); + state.cardInsertMenu.open = true; + state.cardInsertMenu.slotId = targetSlotId; + positionCardInsertMenu(anchorX, anchorY); + requestAnimationFrame(() => { + cardInsertMenuEl?.querySelector("[data-card-insert-action='library']")?.focus?.({ preventScroll: true }); + }); } function appendCardPickerSearchValue(terms, value) { @@ -1034,16 +1455,275 @@ return Array.from(terms).join(" "); } + function matchesCardPickerTerms(queryTerms, haystack) { + if (!queryTerms.length) { + return true; + } + + return queryTerms.every((term) => haystack.includes(term)); + } + + function getCardPickerAnchor(slotId) { + const slotButton = getSlotElement(slotId)?.querySelector(".tarot-frame-card") || null; + if (slotButton instanceof HTMLElement) { + const rect = slotButton.getBoundingClientRect(); + return { + x: rect.left + (rect.width / 2), + y: rect.top + (rect.height / 2) + }; + } + + return { + x: Math.round(window.innerWidth / 2), + y: Math.round(window.innerHeight / 2) + }; + } + + function syncCardPickerTitle() { + if (!(cardPickerTitleEl instanceof HTMLElement)) { + return; + } + + const slotText = describeSlot(state.cardPicker.slotId); + cardPickerTitleEl.textContent = `Browse Stock Cards for ${slotText}`; + } + + function repositionCardPickerForCurrentSlot() { + if (!(cardPickerEl instanceof HTMLElement) || cardPickerEl.hidden || !state.cardPicker.open) { + return; + } + + const anchor = getCardPickerAnchor(state.cardPicker.slotId); + positionCardPicker(anchor.x, anchor.y); + } + + function setCardPickerMode(mode = "browse") { + state.cardPicker.mode = "browse"; + + syncCardPickerTitle(); + + if (cardPickerEl) { + cardPickerEl.classList.toggle("is-browse-mode", state.cardPicker.mode === "browse"); + } + + if (cardPickerSearchWrapEl) { + cardPickerSearchWrapEl.hidden = false; + } + if (cardPickerSectionsEl) { + cardPickerSectionsEl.hidden = false; + } + + requestAnimationFrame(() => { + repositionCardPickerForCurrentSlot(); + }); + + if (state.cardPicker.mode === "browse") { + if (cardPickerSearchEl) { + cardPickerSearchEl.value = state.cardPicker.query; + } + renderCardPickerSections(); + requestAnimationFrame(() => { + cardPickerSearchEl?.focus({ preventScroll: true }); + }); + return; + } + + } + + function populateCustomCardEditor(card = null) { + const parsedText = parseCustomCardText(card?.customText); + if (cardPickerEditorHeadingEl) { + const titleEl = cardPickerEditorHeadingEl.querySelector("strong"); + if (titleEl) { + titleEl.textContent = card ? "Edit Custom Card" : "Create Custom Card"; + } + } + if (cardPickerEditorTitleEl) { + cardPickerEditorTitleEl.value = parsedText.primary || ""; + } + if (cardPickerEditorSubtitleEl) { + cardPickerEditorSubtitleEl.value = parsedText.secondary || ""; + } + if (cardPickerEditorColorEl) { + const backgroundColor = normalizeCustomCardBackgroundColor(card?.backgroundColor); + cardPickerEditorColorEl.value = backgroundColor || "#164e63"; + cardPickerEditorColorEl.dataset.explicit = backgroundColor ? "true" : "false"; + } + state.cardPicker.editorImageData = normalizeCustomCardBackgroundImage(card?.backgroundImage); + if (cardPickerEditorImageUploadEl) { + cardPickerEditorImageUploadEl.value = ""; + } + if (cardPickerEditorRemoveEl) { + cardPickerEditorRemoveEl.hidden = !card; + } + updateCustomCardEditorPreview(); + } + + function getCustomCardEditorDraft() { + const title = normalizeLabelText(cardPickerEditorTitleEl?.value || ""); + const subtitle = normalizeLabelText(cardPickerEditorSubtitleEl?.value || ""); + const customText = normalizeCustomCardText([title, subtitle].filter(Boolean).join("\n")); + const backgroundColor = cardPickerEditorColorEl?.dataset.explicit === "true" + ? normalizeCustomCardBackgroundColor(cardPickerEditorColorEl?.value || "") + : ""; + const backgroundImage = normalizeCustomCardBackgroundImage(state.cardPicker.editorImageData); + return { + title, + subtitle, + customText, + backgroundColor, + backgroundImage + }; + } + + function updateCustomCardEditorPreview() { + if (!(cardPickerEditorPreviewEl instanceof HTMLElement)) { + return; + } + + const draft = getCustomCardEditorDraft(); + const previewCard = createCustomFrameCardRecord(draft, `${FRAME_CUSTOM_CARD_PREFIX}preview`); + cardPickerEditorPreviewEl.replaceChildren(); + + if (!previewCard) { + const emptyPreviewEl = document.createElement("div"); + emptyPreviewEl.className = "tarot-frame-card-picker-editor-empty"; + emptyPreviewEl.textContent = "Add text, a color, or an image to build the card preview."; + cardPickerEditorPreviewEl.appendChild(emptyPreviewEl); + if (cardPickerEditorSaveEl) { + cardPickerEditorSaveEl.disabled = true; + } + if (cardPickerEditorImageClearEl) { + cardPickerEditorImageClearEl.disabled = !draft.backgroundImage; + } + if (cardPickerEditorImageEl) { + cardPickerEditorImageEl.textContent = draft.backgroundImage ? "Uploaded image ready." : "No image selected."; + } + return; + } + + if (cardPickerEditorSaveEl) { + cardPickerEditorSaveEl.disabled = false; + } + if (cardPickerEditorImageClearEl) { + cardPickerEditorImageClearEl.disabled = !draft.backgroundImage; + } + if (cardPickerEditorImageEl) { + cardPickerEditorImageEl.textContent = draft.backgroundImage ? "Uploaded image ready." : "No image selected."; + } + + cardPickerEditorPreviewEl.appendChild(createCardTextFaceElement(buildCardTextFaceModel(previewCard))); + } + + function openCustomCardEditor(slotId, existingCardId = "", options = {}) { + const targetSlotId = String(slotId || "").trim(); + if (!isValidSlotId(targetSlotId)) { + return false; + } + + createCustomCardEditorElements(); + closeCardInsertMenu(); + closeCardPicker(); + state.cardPicker.slotId = targetSlotId; + state.cardPicker.editingCardId = String(existingCardId || "").trim(); + const existingCard = state.cardPicker.editingCardId ? state.customCards.get(state.cardPicker.editingCardId) || null : null; + + if (cardCustomEditorTitleEl) { + cardCustomEditorTitleEl.textContent = `${existingCard ? "Edit" : "Create"} Custom Card at ${describeSlot(targetSlotId)}`; + } + populateCustomCardEditor(existingCard); + + if (!cardCustomEditorEl || cardCustomEditorEl.hidden || options.reposition) { + const anchor = getCardPickerAnchor(targetSlotId); + positionCustomCardEditor( + Number.isFinite(options.anchorX) ? options.anchorX : anchor.x, + Number.isFinite(options.anchorY) ? options.anchorY : anchor.y + ); + } else { + cardCustomEditorEl.hidden = false; + } + requestAnimationFrame(() => { + cardPickerEditorTitleEl?.focus({ preventScroll: true }); + cardPickerEditorTitleEl?.select?.(); + }); + return true; + } + + function placeCustomCardInSlot(slotId, customCardInput, existingCardId = "") { + const targetSlotId = String(slotId || "").trim(); + if (!isValidSlotId(targetSlotId)) { + return false; + } + + const nextCard = createCustomFrameCardRecord(customCardInput, existingCardId || createCustomFrameCardId()); + if (!nextCard) { + return false; + } + + state.customCards.set(nextCard.id, nextCard); + const previousSlotId = findAssignedSlotIdByCardId(nextCard.id); + if (previousSlotId && previousSlotId !== targetSlotId) { + state.slotAssignments.delete(previousSlotId); + } + + state.slotAssignments.set(targetSlotId, nextCard.id); + pruneUnusedCustomCards(); + state.layoutReady = true; + render({ preserveViewport: true }); + syncControls(); + setStatus(`${existingCardId ? "Custom card updated" : "Custom card placed"} at ${describeSlot(targetSlotId)}.`); + return true; + } + + function submitCustomCardEditor() { + const targetSlotId = String(state.cardPicker.slotId || "").trim(); + if (!isValidSlotId(targetSlotId)) { + return false; + } + + const draft = getCustomCardEditorDraft(); + if (!draft.customText && !draft.backgroundColor && !draft.backgroundImage) { + window.alert("Add text, a color, or an image before saving this custom card."); + return false; + } + + return placeCustomCardInSlot(targetSlotId, draft, state.cardPicker.editingCardId); + } + + function removeEditedCustomCard() { + const targetSlotId = String(state.cardPicker.slotId || "").trim(); + const targetCardId = String(state.cardPicker.editingCardId || "").trim(); + if (!isValidSlotId(targetSlotId) || !targetCardId) { + return false; + } + + state.slotAssignments.delete(targetSlotId); + state.customCards.delete(targetCardId); + pruneUnusedCustomCards(); + state.layoutReady = true; + render({ preserveViewport: true }); + syncControls(); + setStatus(`Custom card removed from ${describeSlot(targetSlotId)}.`); + return true; + } + + function editCustomCard(slotId, cardId) { + const targetSlotId = String(slotId || "").trim(); + const targetCardId = String(cardId || "").trim(); + const existingCard = state.customCards.get(targetCardId) || null; + if (!isValidSlotId(targetSlotId) || !existingCard) { + return false; + } + + return openCustomCardEditor(targetSlotId, targetCardId, { reposition: true }); + } + function buildCardPickerSections() { const cards = getCards(); const queryTerms = normalizeKey(state.cardPicker.query).split(/\s+/).filter(Boolean); const matchesQuery = (card) => { - if (!queryTerms.length) { - return true; - } - const haystack = buildCardPickerSearchText(card); - return queryTerms.every((term) => haystack.includes(term)); + return matchesCardPickerTerms(queryTerms, haystack); }; const majorCards = cards @@ -1099,7 +1779,7 @@ if (!sections.length) { const emptyEl = document.createElement("div"); emptyEl.className = "tarot-frame-card-picker-empty"; - emptyEl.textContent = "No tarot cards match that search."; + emptyEl.textContent = "No stock tarot cards match that search."; cardPickerSectionsEl.appendChild(emptyEl); return; } @@ -1128,18 +1808,18 @@ const gridEl = document.createElement("div"); gridEl.className = "tarot-frame-card-picker-grid"; - group.items.forEach((card) => { + group.items.forEach((item) => { const buttonEl = document.createElement("button"); buttonEl.type = "button"; buttonEl.className = "tarot-frame-card-picker-option"; - buttonEl.dataset.cardId = getCardId(card); + buttonEl.dataset.cardId = getCardId(item); const titleEl = document.createElement("strong"); - titleEl.textContent = getDisplayCardName(card); + titleEl.textContent = getDisplayCardName(item); const metaEl = document.createElement("span"); - metaEl.textContent = card?.arcana === "Major" - ? `Trump ${Number.isFinite(Number(card?.number)) ? Number(card.number) : ""}`.trim() - : [normalizeLabelText(card?.rank), normalizeLabelText(card?.suit)].filter(Boolean).join(" · "); + metaEl.textContent = item?.arcana === "Major" + ? `Trump ${Number.isFinite(Number(item?.number)) ? Number(item.number) : ""}`.trim() + : [normalizeLabelText(item?.rank), normalizeLabelText(item?.suit)].filter(Boolean).join(" · "); buttonEl.append(titleEl, metaEl); gridEl.appendChild(buttonEl); }); @@ -1185,19 +1865,16 @@ function openCardPicker(slotId, anchorX, anchorY) { createCardPickerElements(); + closeCardInsertMenu(); state.cardPicker.open = true; state.cardPicker.slotId = String(slotId || "").trim(); - if (cardPickerTitleEl) { - cardPickerTitleEl.textContent = `Place Tarot Card at ${describeSlot(slotId)}`; - } + state.cardPicker.editingCardId = ""; + state.cardPicker.editorImageData = ""; if (cardPickerSearchEl) { cardPickerSearchEl.value = state.cardPicker.query; } - renderCardPickerSections(); + setCardPickerMode("browse"); positionCardPicker(anchorX, anchorY); - requestAnimationFrame(() => { - cardPickerSearchEl?.focus({ preventScroll: true }); - }); } function findAssignedSlotIdByCardId(cardId) { @@ -1232,6 +1909,7 @@ } state.slotAssignments.set(targetSlotId, targetCardId); + pruneUnusedCustomCards(); state.layoutReady = true; render({ preserveViewport: true }); syncControls(); @@ -1250,6 +1928,7 @@ } state.slotAssignments.clear(); + state.customCards.clear(); state.layoutReady = true; render(); syncControls(); @@ -1283,7 +1962,7 @@ return; } state.suppressClick = true; - openCardPicker(activeGesture.slotId, activeGesture.startX, activeGesture.startY); + openCardInsertMenu(activeGesture.slotId, activeGesture.startX, activeGesture.startY); }, FRAME_LONG_PRESS_DELAY_MS) }; } @@ -1625,12 +2304,178 @@ return Array.isArray(cards) ? cards : []; } + function isCustomFrameCardId(cardId) { + return String(cardId || "").trim().startsWith(FRAME_CUSTOM_CARD_PREFIX); + } + + function isCustomFrameCard(card) { + return Boolean(card) && isCustomFrameCardId(getCardId(card)); + } + + function normalizeCustomCardText(value) { + return String(value || "") + .replace(/\r/g, "") + .split("\n") + .map((line) => String(line || "").trim()) + .filter(Boolean) + .join("\n") + .trim(); + } + + function normalizeCustomCardBackgroundColor(value) { + const normalized = String(value || "").trim(); + return /^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/.test(normalized) ? normalized : ""; + } + + function normalizeCustomCardBackgroundImage(value) { + return String(value || "").trim(); + } + + function parseCustomCardText(value) { + const normalized = normalizeCustomCardText(value); + if (!normalized) { + return { + raw: "", + primary: "", + secondary: "" + }; + } + + const separator = normalized.includes("|") ? "|" : "\n"; + const segments = normalized + .split(separator) + .map((segment) => normalizeLabelText(segment)) + .filter(Boolean); + + return { + raw: normalized, + primary: segments[0] || "Label", + secondary: segments.slice(1).join(" · ") + }; + } + + function buildCustomLabelFaceClassName(parsedText, hasVisual = false) { + const primaryLength = String(parsedText?.primary || "").replace(/\s+/g, "").length; + const secondaryLength = String(parsedText?.secondary || "").replace(/\s+/g, "").length; + const classes = ["is-custom-label"]; + + if (hasVisual) { + classes.push("is-custom-visual"); + } + + if (!primaryLength && !secondaryLength) { + classes.push("is-custom-blank"); + if (hasVisual) { + classes.push("is-custom-visual-only"); + } + return classes.join(" "); + } + + if (!secondaryLength && primaryLength <= 4) { + classes.push("is-custom-hero"); + } else if (!secondaryLength && primaryLength <= 10) { + classes.push("is-custom-short"); + } else if (primaryLength <= 14 && secondaryLength <= 28) { + classes.push("is-custom-medium"); + } + + if (primaryLength >= 18 || secondaryLength >= 34) { + classes.push("is-dense"); + } + + return classes.join(" "); + } + + function createCustomFrameCardId() { + return `${FRAME_CUSTOM_CARD_PREFIX}${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`; + } + + function createCustomFrameCardRecord(input, id = createCustomFrameCardId()) { + const customText = typeof input === "string" + ? normalizeCustomCardText(input) + : normalizeCustomCardText(input?.customText || [input?.title, input?.subtitle].filter(Boolean).join("\n")); + const backgroundColor = typeof input === "string" ? "" : normalizeCustomCardBackgroundColor(input?.backgroundColor); + const backgroundImage = typeof input === "string" ? "" : normalizeCustomCardBackgroundImage(input?.backgroundImage); + const parsed = parseCustomCardText(customText); + if (!parsed.raw && !backgroundColor && !backgroundImage) { + return null; + } + + return { + id: isCustomFrameCardId(id) ? id : createCustomFrameCardId(), + frameCardType: "custom-text", + arcana: "Custom", + name: parsed.primary || "Custom Card", + customText: parsed.raw, + summary: parsed.secondary || "", + backgroundColor, + backgroundImage + }; + } + + function normalizeSavedCustomCardRecord(rawCard) { + const cardId = String(rawCard?.id || "").trim(); + const text = normalizeCustomCardText(rawCard?.customText || rawCard?.text || rawCard?.name); + const backgroundColor = normalizeCustomCardBackgroundColor(rawCard?.backgroundColor); + const backgroundImage = normalizeCustomCardBackgroundImage(rawCard?.backgroundImage); + if (!text && !backgroundColor && !backgroundImage) { + return null; + } + + return createCustomFrameCardRecord({ + customText: text, + backgroundColor, + backgroundImage + }, cardId); + } + function getCardId(card) { return String(card?.id || "").trim(); } function getCardMap(cards) { - return new Map(cards.map((card) => [getCardId(card), card])); + const map = new Map(cards.map((card) => [getCardId(card), card])); + state.customCards.forEach((card, cardId) => { + if (cardId) { + map.set(cardId, card); + } + }); + return map; + } + + function removeCardFromSlot(slotId) { + const targetSlotId = String(slotId || "").trim(); + if (!isValidSlotId(targetSlotId)) { + return null; + } + + const cardId = String(state.slotAssignments.get(targetSlotId) || "").trim(); + if (!cardId) { + return null; + } + + const card = getCardMap(getCards()).get(cardId) || null; + state.slotAssignments.delete(targetSlotId); + if (isCustomFrameCardId(cardId)) { + state.customCards.delete(cardId); + } + pruneUnusedCustomCards(); + state.layoutReady = true; + return card; + } + + function pruneUnusedCustomCards() { + const assignedCustomIds = new Set( + Array.from(state.slotAssignments.values()) + .map((cardId) => String(cardId || "").trim()) + .filter((cardId) => isCustomFrameCardId(cardId)) + ); + + Array.from(state.customCards.keys()).forEach((cardId) => { + if (!assignedCustomIds.has(cardId)) { + state.customCards.delete(cardId); + } + }); } function getRelation(card, type) { @@ -1865,6 +2710,10 @@ return ""; } + if (isCustomFrameCard(card)) { + return ""; + } + const deckOptions = resolveDeckOptions(card) || undefined; return String( tarotCardImages.resolveTarotCardThumbnail?.(card.name, deckOptions) @@ -1874,6 +2723,10 @@ } function getDisplayCardName(card) { + if (isCustomFrameCard(card)) { + return parseCustomCardText(card?.customText).primary || "Custom Card"; + } + const label = tarotCardImages.getTarotCardDisplayName?.(card?.name, resolveDeckOptions(card) || undefined); return String(label || card?.name || "Tarot").trim() || "Tarot"; } @@ -2168,6 +3021,10 @@ return null; } + if (isCustomFrameCard(card)) { + return null; + } + return card.arcana === "Major" ? buildHouseTopLabel(card) : buildHouseBottomLabel(card); } @@ -2176,6 +3033,10 @@ return true; } + if (isCustomFrameCard(card)) { + return false; + } + if (card.arcana === "Major") { return config.getHouseTopCardsVisible?.() !== false; } @@ -2184,6 +3045,19 @@ } function buildCardTextFaceModel(card) { + if (isCustomFrameCard(card)) { + const customText = parseCustomCardText(card?.customText); + const backgroundColor = normalizeCustomCardBackgroundColor(card?.backgroundColor); + const backgroundImage = normalizeCustomCardBackgroundImage(card?.backgroundImage); + return { + primary: customText.primary || "", + secondary: customText.secondary, + className: buildCustomLabelFaceClassName(customText, Boolean(backgroundColor || backgroundImage)), + backgroundColor, + backgroundImage + }; + } + const label = state.showInfo ? buildHouseLabel(card) : null; const displayName = normalizeLabelText(getDisplayCardName(card)); @@ -2252,6 +3126,7 @@ const layoutPreset = getLayoutPreset(layoutId) || LAYOUT_PRESETS[0]; state.currentLayoutId = layoutPreset.id; state.slotAssignments.clear(); + state.customCards.clear(); layoutPreset.buildPlacements(cards).forEach((placement) => { state.slotAssignments.set(getSlotId(placement.row, placement.column), placement.cardId); @@ -2269,6 +3144,7 @@ return; } + state.customCards = new Map((savedLayout.customCards || []).map((card) => [getCardId(card), card])); const cardMap = getCardMap(cards); state.currentLayoutId = savedLayout.id; state.slotAssignments.clear(); @@ -2304,7 +3180,9 @@ : "saved settings"); const infoLabel = layout?.settings?.showInfo === false ? "info hidden" : "info visible"; const noteLabel = normalizeLayoutNote(layout?.note || state.layoutNotesById?.[layout?.id]) ? "note added" : "no note"; - return `${layout?.slotAssignments?.length || 0} saved slots · ${zoomLabel} · ${infoLabel} · ${noteLabel}`; + const customLabelCount = Array.isArray(layout?.customCards) ? layout.customCards.length : 0; + const customLabelText = customLabelCount ? ` · ${customLabelCount} label${customLabelCount === 1 ? "" : "s"}` : ""; + return `${layout?.slotAssignments?.length || 0} saved slots${customLabelText} · ${zoomLabel} · ${infoLabel} · ${noteLabel}`; } function createLayoutOptionButton(layout, isActive) { @@ -2430,6 +3308,7 @@ id: existingLayout?.id || activeSavedLayout?.id || createSavedLayoutId(), label, slotAssignments: captureSlotAssignmentsSnapshot(cards), + customCards: captureCustomCardsSnapshot(), settings: buildFrameSettingsSnapshot(), note: getLayoutNote(state.currentLayoutId), createdAt: existingLayout?.createdAt || activeSavedLayout?.createdAt || new Date().toISOString() @@ -2488,6 +3367,10 @@ return ""; } + if (isCustomFrameCard(card)) { + return ""; + } + const label = buildHouseLabel(card); const structuredLabel = normalizeLabelText([label?.primary, label?.secondary].filter(Boolean).join(" · ")); if (structuredLabel) { @@ -2697,11 +3580,27 @@ function createCardTextFaceElement(faceModel) { const faceEl = document.createElement("span"); faceEl.className = `tarot-frame-card-text-face${faceModel?.className ? ` ${faceModel.className}` : ""}`; + const backgroundColor = normalizeCustomCardBackgroundColor(faceModel?.backgroundColor); + const backgroundImage = normalizeCustomCardBackgroundImage(faceModel?.backgroundImage); - const primaryEl = document.createElement("span"); - primaryEl.className = "tarot-frame-card-text-primary"; - primaryEl.textContent = faceModel?.primary || "Tarot"; - faceEl.appendChild(primaryEl); + if (backgroundImage) { + faceEl.classList.add("has-custom-image"); + faceEl.style.backgroundColor = backgroundColor || ""; + faceEl.style.backgroundImage = `url("${backgroundImage.replace(/"/g, '\\"')}")`; + faceEl.style.backgroundSize = "cover"; + faceEl.style.backgroundPosition = "center"; + faceEl.style.backgroundRepeat = "no-repeat"; + } else if (backgroundColor) { + faceEl.style.background = backgroundColor; + faceEl.style.backgroundImage = "none"; + } + + if (faceModel?.primary) { + const primaryEl = document.createElement("span"); + primaryEl.className = "tarot-frame-card-text-primary"; + primaryEl.textContent = faceModel.primary; + faceEl.appendChild(primaryEl); + } if (faceModel?.secondary) { const secondaryEl = document.createElement("span"); @@ -3141,19 +4040,57 @@ image.src = imageSrc; image.alt = ""; ghost.appendChild(image); + } else { + ghost.appendChild(createCardTextFaceElement(buildCardTextFaceModel(card))); } if (state.showInfo) { const label = document.createElement("span"); label.className = "tarot-frame-drag-ghost-label"; label.textContent = getCardOverlayLabel(card); - ghost.appendChild(label); + if (label.textContent) { + ghost.appendChild(label); + } } document.body.appendChild(ghost); return ghost; } + function createDragTrashElement() { + if (dragTrashEl instanceof HTMLElement) { + return dragTrashEl; + } + + dragTrashEl = document.createElement("div"); + dragTrashEl.className = "tarot-frame-drag-trash"; + dragTrashEl.dataset.frameDragTrash = "true"; + dragTrashEl.hidden = true; + + const iconEl = document.createElement("span"); + iconEl.className = "tarot-frame-drag-trash-icon"; + iconEl.setAttribute("aria-hidden", "true"); + iconEl.innerHTML = ""; + + const copyEl = document.createElement("div"); + copyEl.className = "tarot-frame-drag-trash-copy"; + const titleEl = document.createElement("strong"); + titleEl.textContent = "Trash"; + const detailEl = document.createElement("span"); + detailEl.textContent = "Drop here to remove from the frame"; + copyEl.append(titleEl, detailEl); + + dragTrashEl.append(iconEl, copyEl); + document.body.appendChild(dragTrashEl); + return dragTrashEl; + } + + function setDragTrashState(visible, active = false) { + const nextDragTrashEl = createDragTrashElement(); + nextDragTrashEl.hidden = !visible; + nextDragTrashEl.classList.toggle("is-active", Boolean(visible && active)); + } + function moveGhost(ghostEl, clientX, clientY) { if (!(ghostEl instanceof HTMLElement)) { return; @@ -3165,6 +4102,16 @@ function updateHoverSlotFromPoint(clientX, clientY, sourceSlotId) { const target = document.elementFromPoint(clientX, clientY); + const trashTarget = target instanceof Element ? target.closest("[data-frame-drag-trash='true']") : null; + if (state.drag) { + state.drag.deleteActive = Boolean(trashTarget); + setDragTrashState(Boolean(state.drag.started), Boolean(state.drag.deleteActive)); + } + if (trashTarget) { + setHoverSlot(""); + return; + } + const slot = target instanceof Element ? target.closest(".tarot-frame-slot[data-slot-id]") : null; const nextSlotId = slot instanceof HTMLElement ? String(slot.dataset.slotId || "") : ""; setHoverSlot(nextSlotId && nextSlotId !== sourceSlotId ? nextSlotId : ""); @@ -3176,6 +4123,7 @@ ghostEl.remove(); } }); + setDragTrashState(false, false); document.body.classList.remove("is-tarot-frame-dragging"); } @@ -3243,12 +4191,17 @@ return `row ${rowText || "?"}, column ${columnText || "?"}`; } - function openCardLightbox(cardId) { + function openCardLightbox(cardId, slotId = "") { const card = getCardMap(getCards()).get(String(cardId || "").trim()) || null; if (!card) { return; } + if (isCustomFrameCard(card)) { + editCustomCard(slotId || findAssignedSlotIdByCardId(cardId), getCardId(card)); + return; + } + if (typeof config.openCardLightbox === "function") { config.openCardLightbox(getCardId(card), { onSelectCardId: () => {} @@ -3321,6 +4274,7 @@ : 0, started: false, hoverSlotId: "", + deleteActive: false, ghostEl: null, sourceButton: cardButton }; @@ -3372,6 +4326,7 @@ state.drag.started = true; state.drag.ghostEl = createDragGhost(card); getSlotElement(state.drag.sourceSlotId)?.classList.add("is-drag-source"); + setDragTrashState(true, false); document.body.classList.add("is-tarot-frame-dragging"); state.suppressClick = true; } @@ -3392,17 +4347,22 @@ const sourceSlotId = state.drag.sourceSlotId; const targetSlotId = state.drag.hoverSlotId; + const deleteActive = Boolean(state.drag.deleteActive); const draggedCard = getCardMap(getCards()).get(state.drag.cardId) || null; const moved = Boolean(targetSlotId && targetSlotId !== sourceSlotId); - if (moved) { + if (deleteActive) { + removeCardFromSlot(sourceSlotId); + render({ preserveViewport: true }); + setStatus(`${getDisplayCardName(draggedCard)} removed from the frame grid.`); + } else if (moved) { swapOrMoveSlots(sourceSlotId, targetSlotId); render({ preserveViewport: true }); setStatus(`${getDisplayCardName(draggedCard)} snapped to ${describeSlot(targetSlotId)}.`); } cleanupDrag(); - if (!moved) { + if (!moved && !deleteActive) { state.suppressClick = false; } } @@ -3452,7 +4412,7 @@ return; } - openCardLightbox(cardButton.dataset.cardId); + openCardLightbox(cardButton.dataset.cardId, cardButton.dataset.slotId); } function handleNativeDragStart(event) { @@ -3472,6 +4432,17 @@ return; } + const customButton = target.closest(".tarot-frame-card[data-card-id][data-slot-id]"); + if (customButton instanceof HTMLButtonElement) { + const customCard = getCardMap(getCards()).get(String(customButton.dataset.cardId || "").trim()) || null; + if (isCustomFrameCard(customCard)) { + event.preventDefault(); + event.stopPropagation(); + editCustomCard(String(customButton.dataset.slotId || ""), String(customButton.dataset.cardId || "")); + return; + } + } + const emptyButton = target.closest(".tarot-frame-card.is-empty[data-slot-id]"); if (!(emptyButton instanceof HTMLButtonElement)) { return; @@ -3479,7 +4450,7 @@ event.preventDefault(); event.stopPropagation(); - openCardPicker(String(emptyButton.dataset.slotId || ""), event.clientX, event.clientY); + openCardInsertMenu(String(emptyButton.dataset.slotId || ""), event.clientX, event.clientY); } function handleDocumentClick(event) { @@ -3523,6 +4494,14 @@ changed = true; } + if (state.cardInsertMenu.open && cardInsertMenuEl && !cardInsertMenuEl.contains(target)) { + closeCardInsertMenu(); + } + + if (cardCustomEditorEl && !cardCustomEditorEl.hidden && !cardCustomEditorEl.contains(target)) { + closeCustomCardEditor(); + } + if (state.cardPicker.open && cardPickerEl && !cardPickerEl.contains(target)) { closeCardPicker(); } @@ -3551,6 +4530,12 @@ state.layoutMenuOpen = false; changed = true; } + if (state.cardInsertMenu.open) { + closeCardInsertMenu(); + } + if (cardCustomEditorEl && !cardCustomEditorEl.hidden) { + closeCustomCardEditor(); + } if (state.cardPicker.open) { closeCardPicker(); } @@ -3638,21 +4623,60 @@ context.drawImage(image, drawX, drawY, drawWidth, drawHeight); } + function drawImageCover(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.max(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 drawTextFaceToCanvas(context, x, y, width, height, faceModel) { - const primaryText = normalizeLabelText(faceModel?.primary || "Tarot"); + const primaryText = normalizeLabelText(faceModel?.primary); const secondaryText = normalizeLabelText(faceModel?.secondary); const maxWidth = width - 12; + const faceClasses = String(faceModel?.className || "").split(/\s+/).filter(Boolean); + const hasFaceClass = (className) => faceClasses.includes(className); context.save(); - const primaryFontSize = faceModel?.className === "is-top-hebrew" && primaryText.length <= 3 ? 14 : 10; - const primaryFontFamily = faceModel?.className === "is-top-hebrew" + if (!primaryText && !secondaryText) { + context.restore(); + return; + } + + const primaryFontSize = hasFaceClass("is-top-hebrew") && primaryText.length <= 3 + ? 14 + : (hasFaceClass("is-custom-hero") + ? 18 + : (hasFaceClass("is-custom-short") + ? 15 + : (hasFaceClass("is-custom-medium") ? 13 : 10))); + const primaryFontFamily = hasFaceClass("is-top-hebrew") ? "'Segoe UI Symbol', 'Noto Sans Hebrew', 'Segoe UI', sans-serif" : "'Segoe UI', sans-serif"; context.font = `700 ${primaryFontSize}px ${primaryFontFamily}`; const primaryLines = wrapCanvasText(context, primaryText, maxWidth, secondaryText ? 3 : 4); const secondaryLines = secondaryText ? wrapCanvasText(context, secondaryText, maxWidth, 3) : []; - const primaryLineHeight = faceModel?.className === "is-top-hebrew" && primaryText.length <= 3 ? 14 : 11; - const secondaryLineHeight = 9; + const primaryLineHeight = hasFaceClass("is-top-hebrew") && primaryText.length <= 3 + ? 14 + : (hasFaceClass("is-custom-hero") + ? 18 + : (hasFaceClass("is-custom-short") + ? 15 + : (hasFaceClass("is-custom-medium") ? 13 : 11))); + const secondaryFontSize = hasFaceClass("is-custom-hero") ? 8 : (hasFaceClass("is-custom-short") ? 8 : 7); + const secondaryLineHeight = hasFaceClass("is-custom-hero") ? 10 : 9; const totalHeight = (primaryLines.length * primaryLineHeight) + (secondaryLines.length ? 4 + (secondaryLines.length * secondaryLineHeight) : 0); let currentY = y + ((height - totalHeight) / 2) + primaryLineHeight; @@ -3668,7 +4692,7 @@ if (secondaryLines.length) { currentY += 2; context.fillStyle = "rgba(248, 250, 252, 0.78)"; - context.font = "500 7px 'Segoe UI', sans-serif"; + context.font = `500 ${secondaryFontSize}px 'Segoe UI', sans-serif`; secondaryLines.forEach((line) => { context.fillText(line, x + (width / 2), currentY, maxWidth); currentY += secondaryLineHeight; @@ -3678,7 +4702,7 @@ context.restore(); } - function drawSlotToCanvas(context, x, y, width, height, card, image) { + function drawSlotToCanvas(context, x, y, width, height, card, image, customBackgroundImage = null) { if (!card) { context.save(); context.setLineDash([6, 6]); @@ -3695,6 +4719,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); context.save(); drawRoundedRectPath(context, cardX, cardY, cardWidth, cardHeight, 0); @@ -3716,9 +4741,17 @@ currentY += lineHeight; }); } else { - context.fillStyle = EXPORT_PANEL; + const hasCustomLabelClass = String(faceModel?.className || "").split(/\s+/).includes("is-custom-label"); + context.fillStyle = normalizeCustomCardBackgroundColor(faceModel?.backgroundColor) || (hasCustomLabelClass ? "#123548" : EXPORT_PANEL); context.fillRect(cardX, cardY, cardWidth, cardHeight); - drawTextFaceToCanvas(context, cardX, cardY, cardWidth, cardHeight, buildCardTextFaceModel(card)); + if (customBackgroundImage) { + drawImageCover(context, customBackgroundImage, cardX, cardY, cardWidth, cardHeight); + context.fillStyle = faceModel?.primary || faceModel?.secondary + ? "rgba(15, 23, 42, 0.38)" + : "rgba(15, 23, 42, 0.16)"; + context.fillRect(cardX, cardY, cardWidth, cardHeight); + } + drawTextFaceToCanvas(context, cardX, cardY, cardWidth, cardHeight, faceModel); } context.restore(); @@ -3823,13 +4856,37 @@ resolvedImages.set(getCardId(card), image || null); })); + const customBackgroundImageCache = new Map(); + state.customCards.forEach((card) => { + const src = normalizeCustomCardBackgroundImage(card?.backgroundImage); + if (src && !customBackgroundImageCache.has(src)) { + customBackgroundImageCache.set(src, loadCardImage(src)); + } + }); + + const resolvedCustomBackgroundImages = new Map(); + await Promise.all(Array.from(state.customCards.values()).map(async (card) => { + const src = normalizeCustomCardBackgroundImage(card?.backgroundImage); + const image = src ? await customBackgroundImageCache.get(src) : null; + resolvedCustomBackgroundImages.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_WIDTH + EXPORT_GRID_GAP)); const y = EXPORT_PADDING + ((row - 1) * (EXPORT_SLOT_HEIGHT + EXPORT_GRID_GAP)); - drawSlotToCanvas(context, x, y, EXPORT_SLOT_WIDTH, EXPORT_SLOT_HEIGHT, card, card ? resolvedImages.get(getCardId(card)) : null); + drawSlotToCanvas( + context, + x, + y, + EXPORT_SLOT_WIDTH, + EXPORT_SLOT_HEIGHT, + card, + card ? resolvedImages.get(getCardId(card)) : null, + card ? resolvedCustomBackgroundImages.get(getCardId(card)) : null + ); } } diff --git a/index.html b/index.html index 6c00bb6..2a7c4b2 100644 --- a/index.html +++ b/index.html @@ -16,7 +16,7 @@ - +
@@ -1195,7 +1195,7 @@ - +