512 lines
17 KiB
JavaScript
512 lines
17 KiB
JavaScript
// ==UserScript==
|
|
// @name Game Datacards - Improved
|
|
// @namespace local
|
|
// @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
|
|
// @run-at document-idle
|
|
// @require https://cdn.jsdelivr.net/npm/fflate@0.8.3/umd/index.js
|
|
// @downloadURL https://git.luxick.de/luxick/scripts/raw/branch/master/js/game-datcards-improved.user.js
|
|
// @updateURL https://git.luxick.de/luxick/scripts/raw/branch/master/js/game-datcards-improved.user.js
|
|
// ==/UserScript==
|
|
|
|
(function () {
|
|
'use strict';
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Image export / import (IndexedDB <-> ZIP)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const DB_NAME = 'CardImagesDB';
|
|
const STORE = 'images';
|
|
|
|
function openDB() {
|
|
return new Promise((resolve, reject) => {
|
|
const req = indexedDB.open(DB_NAME);
|
|
req.onsuccess = () => resolve(req.result);
|
|
req.onerror = () => reject(req.error);
|
|
});
|
|
}
|
|
|
|
function getAll(db) {
|
|
return new Promise((resolve, reject) => {
|
|
const tx = db.transaction(STORE, 'readonly');
|
|
const req = tx.objectStore(STORE).getAll();
|
|
req.onsuccess = () => resolve(req.result);
|
|
req.onerror = () => reject(req.error);
|
|
});
|
|
}
|
|
|
|
function putAll(db, records) {
|
|
return new Promise((resolve, reject) => {
|
|
const tx = db.transaction(STORE, 'readwrite');
|
|
const store = tx.objectStore(STORE);
|
|
records.forEach((r) => store.put(r));
|
|
tx.oncomplete = () => resolve();
|
|
tx.onerror = () => reject(tx.error);
|
|
});
|
|
}
|
|
|
|
function blobToU8(blob) {
|
|
return new Promise((resolve, reject) => {
|
|
const reader = new FileReader();
|
|
reader.onloadend = () => resolve(new Uint8Array(reader.result));
|
|
reader.onerror = () => reject(reader.error);
|
|
reader.readAsArrayBuffer(blob);
|
|
});
|
|
}
|
|
|
|
function extFromType(type) {
|
|
if (!type) return 'bin';
|
|
const map = { 'image/png': 'png', 'image/jpeg': 'jpg', 'image/webp': 'webp', 'image/gif': 'gif' };
|
|
return map[type] || type.split('/')[1] || 'bin';
|
|
}
|
|
|
|
async function doExport() {
|
|
const db = await openDB();
|
|
const records = await getAll(db);
|
|
if (!records.length) {
|
|
alert('No images found in the database.');
|
|
return;
|
|
}
|
|
|
|
const files = {};
|
|
const manifest = [];
|
|
|
|
for (const rec of records) {
|
|
if (!(rec.image instanceof Blob)) {
|
|
console.warn('Record image is not a Blob, skipping binary for', rec.id);
|
|
}
|
|
const ext = extFromType(rec.type || (rec.image && rec.image.type));
|
|
const imgName = `images/${rec.id}.${ext}`;
|
|
|
|
if (rec.image instanceof Blob) {
|
|
files[imgName] = await blobToU8(rec.image);
|
|
}
|
|
|
|
const meta = { ...rec };
|
|
delete meta.image;
|
|
meta.__file = imgName;
|
|
manifest.push(meta);
|
|
}
|
|
|
|
files['manifest.json'] = fflate.strToU8(JSON.stringify(manifest, null, 2));
|
|
|
|
const zipped = fflate.zipSync(files, { level: 6 });
|
|
const blob = new Blob([zipped], { type: 'application/zip' });
|
|
const url = URL.createObjectURL(blob);
|
|
const a = document.createElement('a');
|
|
a.href = url;
|
|
a.download = 'game-datacards-images.zip';
|
|
a.click();
|
|
URL.revokeObjectURL(url);
|
|
console.log(`Exported ${records.length} images to ZIP.`);
|
|
}
|
|
|
|
async function doImport() {
|
|
const input = document.createElement('input');
|
|
input.type = 'file';
|
|
input.accept = '.zip,application/zip';
|
|
input.onchange = async () => {
|
|
const file = input.files[0];
|
|
if (!file) return;
|
|
|
|
const buf = new Uint8Array(await file.arrayBuffer());
|
|
const entries = fflate.unzipSync(buf);
|
|
|
|
if (!entries['manifest.json']) {
|
|
alert('No manifest.json in the ZIP. Cannot import.');
|
|
return;
|
|
}
|
|
|
|
const manifest = JSON.parse(fflate.strFromU8(entries['manifest.json']));
|
|
const records = [];
|
|
|
|
for (const meta of manifest) {
|
|
const rec = { ...meta };
|
|
const fileName = meta.__file;
|
|
delete rec.__file;
|
|
|
|
if (fileName && entries[fileName]) {
|
|
rec.image = new Blob([entries[fileName]], { type: rec.type || 'application/octet-stream' });
|
|
rec.size = entries[fileName].byteLength;
|
|
}
|
|
records.push(rec);
|
|
}
|
|
|
|
const db = await openDB();
|
|
await putAll(db, records);
|
|
alert(`Imported ${records.length} images. Reload the page to see them.`);
|
|
};
|
|
input.click();
|
|
}
|
|
|
|
const EXPORT_ICON =
|
|
'<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path><polyline points="7 10 12 15 17 10"></polyline><line x1="12" x2="12" y1="15" y2="3"></line></svg>';
|
|
|
|
const FONT_ICON =
|
|
'<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="4 7 4 4 20 4 20 7"></polyline><line x1="9" x2="15" y1="20" y2="20"></line><line x1="12" x2="12" y1="4" y2="20"></line></svg>';
|
|
|
|
const IMPORT_ICON =
|
|
'<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path><polyline points="17 8 12 3 7 8"></polyline><line x1="12" x2="12" y1="3" y2="15"></line></svg>';
|
|
|
|
function mkBtn(id, title, iconSvg, fn) {
|
|
const b = document.createElement('button');
|
|
b.id = id;
|
|
b.className = 'app-header-icon-btn';
|
|
b.title = title;
|
|
b.setAttribute('aria-label', title);
|
|
b.innerHTML = iconSvg;
|
|
b.onclick = () => {
|
|
const r = fn();
|
|
if (r && typeof r.catch === 'function') r.catch((e) => alert('Error: ' + e.message));
|
|
};
|
|
return b;
|
|
}
|
|
|
|
function addHeaderButtons() {
|
|
if (document.getElementById('gdc-img-export')) return true;
|
|
|
|
const settingsBtn = document.querySelector('.app-header-settings-btn');
|
|
if (!settingsBtn) return false;
|
|
|
|
// Wrap our two buttons in a header group so spacing matches the rest.
|
|
const group = document.createElement('div');
|
|
group.className = 'app-header-group';
|
|
group.appendChild(mkBtn('gdc-img-export', 'Export card images', EXPORT_ICON, doExport));
|
|
group.appendChild(mkBtn('gdc-img-import', 'Import card images', IMPORT_ICON, doImport));
|
|
group.appendChild(mkBtn('gdc-card-font', 'Card font: Conduit ITC', FONT_ICON, toggleCardFont));
|
|
|
|
// Insert the group right before the settings button's group,
|
|
// so the buttons sit to the left of the cog.
|
|
const settingsGroup = settingsBtn.closest('.app-header-group');
|
|
settingsGroup.parentNode.insertBefore(group, settingsGroup);
|
|
syncCardFontButton();
|
|
return true;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Official 10th edition card font
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// The font has to be installed locally; the extra names cover the different
|
|
// family names the foundry/converters use for the same face.
|
|
const CARD_FONT = "'ConduitITCStd', 'Conduit ITC Std', 'ConduitITC Std', 'Conduit ITC'";
|
|
const FONT_STYLE_ID = 'gdc-card-font';
|
|
const FONT_PREF_KEY = 'gdc-card-font-enabled';
|
|
|
|
// Family swap only: sizes, weights and casing are left to the app so the two
|
|
// renderings can be compared side by side.
|
|
function cardFontCss() {
|
|
return `
|
|
.unit, .unit * { font-family: ${CARD_FONT}, sans-serif !important; }
|
|
`;
|
|
}
|
|
|
|
function isCardFontEnabled() {
|
|
return localStorage.getItem(FONT_PREF_KEY) === '1';
|
|
}
|
|
|
|
function applyCardFont(enabled) {
|
|
const existing = document.getElementById(FONT_STYLE_ID);
|
|
|
|
if (!enabled) {
|
|
if (existing) existing.remove();
|
|
return;
|
|
}
|
|
|
|
const style = existing || document.createElement('style');
|
|
style.id = FONT_STYLE_ID;
|
|
style.textContent = cardFontCss();
|
|
if (!existing) document.head.appendChild(style);
|
|
}
|
|
|
|
function syncCardFontButton() {
|
|
const btn = document.getElementById('gdc-card-font');
|
|
if (!btn) return;
|
|
const on = isCardFontEnabled();
|
|
btn.style.color = on ? '#e8b923' : '';
|
|
btn.title = on ? 'Card font: Conduit ITC (on)' : 'Card font: Conduit ITC (off)';
|
|
btn.setAttribute('aria-pressed', on ? 'true' : 'false');
|
|
}
|
|
|
|
function toggleCardFont() {
|
|
const next = !isCardFontEnabled();
|
|
localStorage.setItem(FONT_PREF_KEY, next ? '1' : '0');
|
|
applyCardFont(next);
|
|
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
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function rotateCardBacks() {
|
|
var wrappers = document.querySelectorAll('.unit-card-back-wrapper');
|
|
var count = 0;
|
|
|
|
wrappers.forEach(function (wrapper) {
|
|
var outer = wrapper.parentElement;
|
|
if (!outer) return;
|
|
|
|
var current = outer.style.rotate;
|
|
if (current === '180deg') {
|
|
outer.style.rotate = '0deg';
|
|
} else {
|
|
outer.style.rotate = '180deg';
|
|
}
|
|
count++;
|
|
});
|
|
|
|
console.log('Rotated ' + count + ' card back(s).');
|
|
return count;
|
|
}
|
|
|
|
function createRotateButton() {
|
|
var button = document.createElement('button');
|
|
button.id = 'rotate-card-backs-button';
|
|
button.textContent = 'Rotate card backs';
|
|
button.style.display = 'block';
|
|
button.style.width = '100%';
|
|
button.style.marginTop = '10px';
|
|
button.style.padding = '8px 12px';
|
|
button.style.background = '#381a3a';
|
|
button.style.color = 'white';
|
|
button.style.border = 'none';
|
|
button.style.borderRadius = '4px';
|
|
button.style.cursor = 'pointer';
|
|
button.style.fontFamily = 'sans-serif';
|
|
button.style.fontSize = '13px';
|
|
|
|
button.addEventListener('click', rotateCardBacks);
|
|
return button;
|
|
}
|
|
|
|
function addRotateButton() {
|
|
if (document.getElementById('rotate-card-backs-button')) return true;
|
|
|
|
var sidebar = document.querySelector('.print-settings-scroll');
|
|
if (!sidebar) return false;
|
|
|
|
sidebar.appendChild(createRotateButton());
|
|
console.log('Rotate card backs button added to the sidebar.');
|
|
return true;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Bootstrap
|
|
// ---------------------------------------------------------------------------
|
|
|
|
applyCardFont(isCardFontEnabled());
|
|
refreshHeaderBackground().catch((e) => console.warn('Header background:', e));
|
|
|
|
var headerDone = addHeaderButtons();
|
|
var sidebarDone = addRotateButton();
|
|
var mountPending = false;
|
|
|
|
// 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();
|
|
addHeaderBgBox();
|
|
});
|
|
});
|
|
|
|
observer.observe(document.documentElement, { childList: true, subtree: true });
|
|
})();
|