diff --git a/js/game-datcards-improved.user.js b/js/game-datcards-improved.user.js index 643497a..1050013 100644 --- a/js/game-datcards-improved.user.js +++ b/js/game-datcards-improved.user.js @@ -1,7 +1,7 @@ // ==UserScript== // @name Game Datacards - Improved // @namespace local -// @version 1.4 +// @version 1.5 // @description Adds card image export/import (ZIP) to the header, a button to rotate card backs for printing and a toggle for the official 10th edition card font // @match *://game-datacards.eu/* // @grant none @@ -238,6 +238,193 @@ syncCardFontButton(); } + // --------------------------------------------------------------------------- + // Header background image + // --------------------------------------------------------------------------- + + // Kept in a database of our own rather than the app's CardImagesDB: that store + // is keyed by card id and round-tripped by the app's own import/export, so a + // foreign record in it would be fragile. + const STYLING_DB = 'GdcCardStylingDB'; + const STYLING_STORE = 'settings'; + const HEADER_BG_ID = 'header-background'; + const HEADER_BG_STYLE_ID = 'gdc-header-bg-style'; + + function openStylingDB() { + return new Promise((resolve, reject) => { + const req = indexedDB.open(STYLING_DB, 1); + req.onupgradeneeded = () => { + const db = req.result; + if (!db.objectStoreNames.contains(STYLING_STORE)) { + db.createObjectStore(STYLING_STORE, { keyPath: 'id' }); + } + }; + req.onsuccess = () => resolve(req.result); + req.onerror = () => reject(req.error); + }); + } + + function stylingRequest(mode, run) { + return openStylingDB().then( + (db) => + new Promise((resolve, reject) => { + const req = run(db.transaction(STYLING_STORE, mode).objectStore(STYLING_STORE)); + req.onsuccess = () => resolve(req.result); + req.onerror = () => reject(req.error); + }) + ); + } + + function readHeaderBackground() { + return stylingRequest('readonly', (store) => store.get(HEADER_BG_ID)); + } + + function writeHeaderBackground(blob) { + return stylingRequest('readwrite', (store) => + store.put({ id: HEADER_BG_ID, image: blob, type: blob.type, size: blob.size }) + ); + } + + function eraseHeaderBackground() { + return stylingRequest('readwrite', (store) => store.delete(HEADER_BG_ID)); + } + + var headerBgUrl = null; + + async function refreshHeaderBackground() { + const record = await readHeaderBackground(); + + if (headerBgUrl) { + URL.revokeObjectURL(headerBgUrl); + headerBgUrl = null; + } + + let style = document.getElementById(HEADER_BG_STYLE_ID); + if (!style) { + style = document.createElement('style'); + style.id = HEADER_BG_STYLE_ID; + document.head.appendChild(style); + } + + if (record && record.image instanceof Blob) { + headerBgUrl = URL.createObjectURL(record.image); + // The background sits on .header so it spans the stat line too, and the + // container holding the model artwork is cleared so it doesn't cover it. + style.textContent = ` + .unit .header { + background-image: url("${headerBgUrl}") !important; + background-size: cover !important; + background-position: center !important; + background-repeat: no-repeat !important; + } + .unit .header .header_container { background-color: transparent !important; } + `; + } else { + style.textContent = ''; + } + + updateHeaderBgBox(); + } + + async function pickHeaderBackground() { + const input = document.createElement('input'); + input.type = 'file'; + input.accept = 'image/*'; + input.onchange = async () => { + const file = input.files[0]; + if (!file) return; + await writeHeaderBackground(file); + await refreshHeaderBackground(); + }; + input.click(); + } + + async function removeHeaderBackground() { + await eraseHeaderBackground(); + await refreshHeaderBackground(); + } + + function mkStylingButton(label, fn) { + const b = document.createElement('button'); + b.type = 'button'; + b.textContent = label; + b.style.flex = '1'; + b.style.padding = '4px 8px'; + b.style.background = '#381a3a'; + b.style.color = 'white'; + b.style.border = 'none'; + b.style.borderRadius = '4px'; + b.style.cursor = 'pointer'; + b.style.fontSize = '12px'; + b.onclick = () => { + const r = fn(); + if (r && typeof r.catch === 'function') r.catch((e) => alert('Error: ' + e.message)); + }; + return b; + } + + function createHeaderBgBox() { + const box = document.createElement('div'); + box.id = 'gdc-header-bg-box'; + box.style.marginTop = '8px'; + + const label = document.createElement('div'); + label.textContent = 'Header background'; + label.style.fontSize = '12px'; + label.style.marginBottom = '4px'; + box.appendChild(label); + + const preview = document.createElement('img'); + preview.id = 'gdc-header-bg-preview'; + preview.style.width = '100%'; + preview.style.height = '48px'; + preview.style.objectFit = 'cover'; + preview.style.borderRadius = '4px'; + preview.style.marginBottom = '4px'; + box.appendChild(preview); + + const row = document.createElement('div'); + row.style.display = 'flex'; + row.style.gap = '4px'; + row.appendChild(mkStylingButton('Upload', pickHeaderBackground)); + row.appendChild(mkStylingButton('Remove', removeHeaderBackground)); + box.appendChild(row); + + return box; + } + + function updateHeaderBgBox() { + const preview = document.getElementById('gdc-header-bg-preview'); + if (!preview) return; + preview.src = headerBgUrl || ''; + preview.style.display = headerBgUrl ? 'block' : 'none'; + } + + // Panels are matched on their visible label because the app's own class names + // are hashed and change between builds. + function findCollapseBox(label) { + const headers = document.querySelectorAll('.ant-collapse-header-text'); + for (const header of headers) { + if (header.textContent.trim().toLowerCase() !== label) continue; + const item = header.closest('.ant-collapse-item'); + const box = item && item.querySelector('.ant-collapse-content > .ant-collapse-content-box'); + if (box) return box; + } + return null; + } + + function addHeaderBgBox() { + if (document.getElementById('gdc-header-bg-box')) return true; + + // Ant only renders the panel body once it has been expanded for the first time. + const box = findCollapseBox('styling'); + if (!box) return false; + + box.appendChild(createHeaderBgBox()); + updateHeaderBgBox(); + return true; + } + // --------------------------------------------------------------------------- // Rotate card backs for printing // --------------------------------------------------------------------------- @@ -298,20 +485,27 @@ // Bootstrap // --------------------------------------------------------------------------- - // The app renders after load, and the print sidebar only exists on the print - // view, so keep watching the DOM until both mount points have been handled. applyCardFont(isCardFontEnabled()); + refreshHeaderBackground().catch((e) => console.warn('Header background:', e)); var headerDone = addHeaderButtons(); var sidebarDone = addRotateButton(); + var mountPending = false; - if (!headerDone || !sidebarDone) { - var observer = new MutationObserver(function () { + // The app renders after load, the print sidebar only exists on the print view, + // and the editor form is re-rendered whenever another card is selected, so the + // observer stays connected: every add* below is a no-op once its target is in + // place, and re-runs if the app throws it away. + var observer = new MutationObserver(function () { + if (mountPending) return; + mountPending = true; + requestAnimationFrame(function () { + mountPending = false; if (!headerDone) headerDone = addHeaderButtons(); if (!sidebarDone) sidebarDone = addRotateButton(); - if (headerDone && sidebarDone) observer.disconnect(); + addHeaderBgBox(); }); + }); - observer.observe(document.documentElement, { childList: true, subtree: true }); - } + observer.observe(document.documentElement, { childList: true, subtree: true }); })();