Compare commits
4 Commits
d0d58fa386
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 2d2f053e4a | |||
| 11a7eb5f9b | |||
| 24e91899f4 | |||
| b7b01b327c |
+202
-118
@@ -1,7 +1,7 @@
|
|||||||
// ==UserScript==
|
// ==UserScript==
|
||||||
// @name Game Datacards - Improved
|
// @name Game Datacards - Improved
|
||||||
// @namespace local
|
// @namespace local
|
||||||
// @version 1.2
|
// @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
|
// @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/*
|
// @match *://game-datacards.eu/*
|
||||||
// @grant none
|
// @grant none
|
||||||
@@ -196,90 +196,11 @@
|
|||||||
const FONT_STYLE_ID = 'gdc-card-font';
|
const FONT_STYLE_ID = 'gdc-card-font';
|
||||||
const FONT_PREF_KEY = 'gdc-card-font-enabled';
|
const FONT_PREF_KEY = 'gdc-card-font-enabled';
|
||||||
|
|
||||||
// The official sizes are point sizes on a printed card, and CSS pt units say
|
// Family swap only: sizes, weights and casing are left to the app so the two
|
||||||
// nothing about how large the app happens to draw that card. So instead of
|
// renderings can be compared side by side.
|
||||||
// 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() {
|
function cardFontCss() {
|
||||||
return `
|
return `
|
||||||
.unit, .unit * { font-family: ${CARD_FONT}, sans-serif !important; }
|
.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;
|
|
||||||
}
|
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -287,33 +208,11 @@
|
|||||||
return localStorage.getItem(FONT_PREF_KEY) === '1';
|
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) {
|
function applyCardFont(enabled) {
|
||||||
const existing = document.getElementById(FONT_STYLE_ID);
|
const existing = document.getElementById(FONT_STYLE_ID);
|
||||||
|
|
||||||
if (!enabled) {
|
if (!enabled) {
|
||||||
if (existing) existing.remove();
|
if (existing) existing.remove();
|
||||||
if (domObserver) {
|
|
||||||
domObserver.disconnect();
|
|
||||||
sizeObserver.disconnect();
|
|
||||||
domObserver = null;
|
|
||||||
sizeObserver = null;
|
|
||||||
}
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -321,15 +220,6 @@
|
|||||||
style.id = FONT_STYLE_ID;
|
style.id = FONT_STYLE_ID;
|
||||||
style.textContent = cardFontCss();
|
style.textContent = cardFontCss();
|
||||||
if (!existing) document.head.appendChild(style);
|
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() {
|
function syncCardFontButton() {
|
||||||
@@ -348,6 +238,193 @@
|
|||||||
syncCardFontButton();
|
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
|
// Rotate card backs for printing
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -408,20 +485,27 @@
|
|||||||
// Bootstrap
|
// 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());
|
applyCardFont(isCardFontEnabled());
|
||||||
|
refreshHeaderBackground().catch((e) => console.warn('Header background:', e));
|
||||||
|
|
||||||
var headerDone = addHeaderButtons();
|
var headerDone = addHeaderButtons();
|
||||||
var sidebarDone = addRotateButton();
|
var sidebarDone = addRotateButton();
|
||||||
|
var mountPending = false;
|
||||||
|
|
||||||
if (!headerDone || !sidebarDone) {
|
// 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 () {
|
var observer = new MutationObserver(function () {
|
||||||
|
if (mountPending) return;
|
||||||
|
mountPending = true;
|
||||||
|
requestAnimationFrame(function () {
|
||||||
|
mountPending = false;
|
||||||
if (!headerDone) headerDone = addHeaderButtons();
|
if (!headerDone) headerDone = addHeaderButtons();
|
||||||
if (!sidebarDone) sidebarDone = addRotateButton();
|
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 });
|
||||||
}
|
|
||||||
})();
|
})();
|
||||||
|
|||||||
Reference in New Issue
Block a user