Files
scripts/js/game-datcards-improved.user.js
T
2026-07-22 18:50:56 +02:00

428 lines
15 KiB
JavaScript

// ==UserScript==
// @name Game Datacards - Improved
// @namespace local
// @version 1.2
// @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';
// The official sizes are point sizes on a printed card, and CSS pt units say
// nothing about how large the app happens to draw that card. So instead of
// using pt directly, measure each card and derive how many layout pixels one
// point of the printed card is worth; --gdc-pt below carries that value.
// Printed datacard width, 7in. Raising it shrinks all card text and lowering
// it grows it, so it doubles as the tuning knob for the overall type size:
// localStorage.setItem('gdc-card-width-pt', 560)
const CARD_WIDTH_PT = 504;
function cardWidthPt() {
return Number(localStorage.getItem('gdc-card-width-pt')) || CARD_WIDTH_PT;
}
function pt(size) {
return `calc(${size} * var(--gdc-pt, 1.333px))`;
}
// offsetWidth is the untransformed layout width, so this stays correct
// whether the app zooms cards by scaling the layout or by a CSS transform.
function updateFontScale() {
const reference = cardWidthPt();
document.querySelectorAll('.unit').forEach((unit) => {
const width = unit.offsetWidth;
if (!width) return;
const value = width / reference + 'px';
if (unit.style.getPropertyValue('--gdc-pt') !== value) {
unit.style.setProperty('--gdc-pt', value);
}
});
}
function cardFontCss() {
return `
.unit, .unit * { font-family: ${CARD_FONT}, sans-serif !important; }
/* Unit name: extra bold 15pt */
.unit .name_container .name {
font-weight: 800 !important;
font-size: ${pt(15)} !important;
}
/* Unit description: light italic 8pt */
.unit .name_container .subname,
.unit .header .description {
font-weight: 300 !important;
font-style: italic !important;
font-size: ${pt(8)} !important;
}
/* Unit values: bold 14pt */
.unit .stats_container .value,
.unit .stats_container .value-text {
font-weight: 700 !important;
font-size: ${pt(14)} !important;
}
/* General weapon and abilities text: regular or bold 7.5pt */
.unit .stats_container .caption,
.unit .invul .title,
.unit .heading .title,
.unit .ability .name,
.unit .ability .title,
.unit .footer .title,
.unit .footer .value {
font-weight: 700 !important;
font-size: ${pt(7.5)} !important;
}
.unit .weapon .value,
.unit .ability .value,
.unit .ability .description,
.unit .description-container .description {
font-weight: 400 !important;
font-size: ${pt(7.5)} !important;
}
/* Weapon abilities (the keyword/rule chips): bold 5.5pt */
.unit .keyword-button,
.unit .keyword-button span,
.unit .rule-button,
.unit .rule-button span {
font-weight: 700 !important;
font-size: ${pt(5.5)} !important;
}
`;
}
function isCardFontEnabled() {
return localStorage.getItem(FONT_PREF_KEY) === '1';
}
// Cards get re-rendered on every edit and resized by the zoom control, so the
// scale has to be recomputed rather than set once.
var domObserver = null;
var sizeObserver = null;
var scalePending = false;
function scheduleFontScale() {
if (scalePending) return;
scalePending = true;
requestAnimationFrame(() => {
scalePending = false;
updateFontScale();
document.querySelectorAll('.unit').forEach((unit) => sizeObserver.observe(unit));
});
}
function applyCardFont(enabled) {
const existing = document.getElementById(FONT_STYLE_ID);
if (!enabled) {
if (existing) existing.remove();
if (domObserver) {
domObserver.disconnect();
sizeObserver.disconnect();
domObserver = null;
sizeObserver = null;
}
return;
}
const style = existing || document.createElement('style');
style.id = FONT_STYLE_ID;
style.textContent = cardFontCss();
if (!existing) document.head.appendChild(style);
if (!domObserver) {
// childList only: the scale is written to the cards as an inline custom
// property, and watching attributes here would retrigger on our own write.
sizeObserver = new ResizeObserver(scheduleFontScale);
domObserver = new MutationObserver(scheduleFontScale);
domObserver.observe(document.body, { childList: true, subtree: true });
}
scheduleFontScale();
}
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();
}
// ---------------------------------------------------------------------------
// 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
// ---------------------------------------------------------------------------
// 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());
var headerDone = addHeaderButtons();
var sidebarDone = addRotateButton();
if (!headerDone || !sidebarDone) {
var observer = new MutationObserver(function () {
if (!headerDone) headerDone = addHeaderButtons();
if (!sidebarDone) sidebarDone = addRotateButton();
if (headerDone && sidebarDone) observer.disconnect();
});
observer.observe(document.documentElement, { childList: true, subtree: true });
}
})();