anycoder-d43a2884 / index.html
Taigertai's picture
Upload folder using huggingface_hub
432dd7f verified
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>Token Sheet Generator</title>
<meta name="viewport" content="width=device-width, initial-scale=1" />
<!-- Google Fonts -->
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;600&family=Roboto:wght@400;700&family=Roboto+Condensed:wght@400;700&family=Noto+Sans:wght@400;700&family=PT+Sans+Narrow:wght@400;700&family=Source+Code+Pro:wght@400;600;700&display=swap" rel="stylesheet">
<!-- Icons -->
<link href="https://unpkg.com/@phosphor-icons/web@2.1.1/src/regular/style.css" rel="stylesheet">
<!-- jsPDF for optional PDF export -->
<script src="https://cdn.jsdelivr.net/npm/jspdf@2.5.1/dist/jspdf.umd.min.js"></script>
<style>
:root{
--bg: #0b0d10;
--panel: #12161b;
--panel-2: #0e1318;
--muted: #a8b3c5;
--text: #e8edf5;
--brand: #76b7ff;
--accent: #74e0c0;
--danger: #ff7a7a;
--ring: 0 0 0 2px color-mix(in hsl, var(--brand) 40%, transparent);
--radius: 14px;
--gap: clamp(12px, 1.5vw, 18px);
--shadow-1: 0 2px 20px rgba(0,0,0,.35), 0 10px 40px rgba(0,0,0,.25);
--shadow-2: 0 1px 0 rgba(255,255,255,.05) inset, 0 0 0 1px rgba(255,255,255,.04) inset;
--input-bg: color-mix(in hsl, var(--panel) 85%, #000 15%);
--grid: 12px;
}
*{box-sizing:border-box}
html,body{height:100%}
body{
margin:0;
background: radial-gradient(1200px 800px at 10% -10%, #12171e 0%, #0b0d10 40%, #0b0d10 100%), var(--bg);
color: var(--text);
font-family: Inter, system-ui, -apple-system, Segoe UI, Roboto, "Noto Sans", "PT Sans Narrow", "Source Code Pro", Arial, sans-serif;
line-height: 1.35;
}
header{
position: sticky;
top:0;
background: color-mix(in hsl, var(--panel) 86%, #000 14%);
backdrop-filter: saturate(1.2) blur(10px);
border-bottom: 1px solid rgba(255,255,255,.06);
z-index:10;
}
.header-inner{
max-width: 1200px;
margin: 0 auto;
padding: 16px clamp(14px, 2.2vw, 24px);
display:flex;
align-items:center;
gap:14px;
}
.brand{
display:flex;
align-items:center;
gap:10px;
font-weight:700;
letter-spacing:.2px;
}
.brand .logo{
width:32px;height:32px;border-radius:10px;
background: conic-gradient(from 210deg, var(--brand), var(--accent), #b09cff, var(--brand));
box-shadow: inset 0 0 18px rgba(255,255,255,.25), 0 6px 18px rgba(0,0,0,.5);
position: relative;
}
.brand .logo::after{
content:"";
position:absolute; inset: 3px 3px auto auto;
width:9px;height:9px;border-radius:50%;
background: #fff3; filter: blur(1px);
}
.brand-title{
font-size: clamp(16px, 2.2vw, 20px);
}
.brand small{
color: var(--muted); font-weight:600; opacity:.8;
}
.spacer{flex:1}
.link-anycoder{
color: var(--accent);
text-decoration: none;
font-weight: 600;
display:inline-flex; align-items:center; gap:8px;
padding:8px 12px; border-radius: 999px;
background: linear-gradient(180deg, rgba(116,224,192,.16), rgba(116,224,192,.08));
border: 1px solid rgba(116,224,192,.35);
}
.link-anycoder:hover{
filter: brightness(1.05);
box-shadow: 0 0 0 3px rgba(116,224,192,.15);
}
main{
max-width: 1200px;
margin: 18px auto 40px;
padding: 0 clamp(14px, 2.2vw, 24px);
display:grid;
grid-template-columns: minmax(280px, 420px) 1fr;
gap: var(--gap);
}
.panel{
background: linear-gradient(180deg, color-mix(in hsl, var(--panel) 92%, #000 8%), color-mix(in hsl, var(--panel-2) 92%, #000 8%));
border-radius: var(--radius);
box-shadow: var(--shadow-1), var(--shadow-2);
border: 1px solid rgba(255,255,255,.06);
overflow: clip;
}
.form{
padding: clamp(14px, 2.4vw, 20px);
display: grid;
gap: 14px;
}
.section{
border: 1px dashed rgba(255,255,255,.08);
border-radius: 12px;
padding: 12px;
display: grid;
gap: 10px;
background:
linear-gradient(180deg, rgba(255,255,255,.03), transparent 40%),
repeating-linear-gradient(90deg, transparent 0 var(--grid), rgba(255,255,255,.02) var(--grid) calc(var(--grid)*2));
}
.section h3{
margin: 2px 0 6px;
font-size: 14px;
color: var(--muted);
text-transform: uppercase;
letter-spacing: .12em;
font-weight: 700;
display:flex; align-items:center; gap:8px;
}
.row{
display:grid;
grid-template-columns: 1fr 1fr;
gap: 10px;
}
.row-3{
display:grid;
grid-template-columns: 1fr 1fr 1fr;
gap: 10px;
}
.field{
display:grid; gap:6px;
}
.field label{
font-size: 12px; color: var(--muted);
display:flex; align-items:center; gap:8px;
}
.hint{
font-size: 11px; color: var(--muted); opacity:.85;
}
textarea, select, input[type="number"], input[type="text"]{
width: 100%;
padding: 10px 12px;
background: var(--input-bg);
border: 1px solid rgba(255,255,255,.09);
color: var(--text);
border-radius: 10px;
outline: none;
transition: .15s border, .15s box-shadow, .15s background;
font-size: 14px;
}
textarea{
min-height: 140px; resize: vertical;
font-family: ui-monospace, "Source Code Pro", "Cascadia Code", "SF Mono", Menlo, Consolas, monospace;
}
select:hover, input:hover, textarea:hover{
border-color: rgba(255,255,255,.16);
}
select:focus, input:focus, textarea:focus{
border-color: var(--brand);
box-shadow: var(--ring);
}
.preset-buttons{
display:flex; flex-wrap: wrap; gap:8px;
}
.chip{
border: 1px solid rgba(255,255,255,.14);
background: linear-gradient(180deg, rgba(255,255,255,.05), rgba(255,255,255,.02));
color: var(--text);
border-radius: 999px;
padding: 8px 12px;
font-size: 13px;
cursor: pointer;
user-select: none;
}
.chip:hover{ border-color: var(--brand); box-shadow: var(--ring); }
.actions{
display:flex; flex-wrap:wrap; gap:10px; margin-top: 6px;
align-items: center;
}
.btn{
appearance: none;
border: 1px solid rgba(255,255,255,.14);
color: var(--text);
background: linear-gradient(180deg, rgba(118,183,255,.22), rgba(118,183,255,.10));
padding: 10px 14px;
border-radius: 12px;
cursor: pointer;
font-weight: 700;
letter-spacing:.2px;
display:inline-flex; align-items:center; gap:10px;
transition: .15s transform, .15s box-shadow, .15s filter, .2s opacity;
}
.btn.secondary{
background: linear-gradient(180deg, rgba(255,255,255,.08), rgba(255,255,255,.03));
}
.btn.accent{
background: linear-gradient(180deg, rgba(116,224,192,.25), rgba(116,224,192,.12));
border-color: rgba(116,224,192,.45);
}
.btn.danger{
background: linear-gradient(180deg, rgba(255,122,122,.25), rgba(255,122,122,.12));
border-color: rgba(255,122,122,.45);
}
.btn[disabled]{ opacity:.5; cursor: not-allowed; }
.btn:hover{ transform: translateY(-1px); filter: brightness(1.06); }
.btn:active{ transform: translateY(0); }
.inline{
display: inline-flex; gap:10px; align-items:center; flex-wrap: wrap;
}
.preview{
padding: clamp(10px, 2vw, 16px);
display:grid;
gap: 12px;
}
.preview-header{
display:flex; align-items:center; justify-content:space-between; gap:10px;
padding: 10px 12px;
background: linear-gradient(180deg, rgba(255,255,255,.04), rgba(255,255,255,.02));
border: 1px solid rgba(255,255,255,.08);
border-radius: 12px;
flex-wrap: wrap;
}
.stat{
color: var(--muted);
font-size: 13px;
}
.canvas-wrap{
position: relative;
width: 100%;
aspect-ratio: 8.5 / 11;
background:
radial-gradient(800px 400px at 80% -20%, rgba(116,224,192,.08), transparent 40%),
radial-gradient(700px 320px at 10% 110%, rgba(118,183,255,.10), transparent 40%),
linear-gradient(180deg, #0d1117, #0a0d12);
border-radius: 16px;
border: 1px solid rgba(255,255,255,.08);
box-shadow: var(--shadow-1);
overflow: hidden;
display:grid;
place-items: center;
padding: clamp(8px, 1.6vw, 16px);
}
canvas{
max-width: 100%;
max-height: 100%;
border-radius: 10px;
background: #fff;
image-rendering: -webkit-optimize-contrast;
image-rendering: crisp-edges;
}
.footer-tip{
color: var(--muted);
font-size: 12px;
text-align: center;
padding: 8px 10px 16px;
opacity:.9;
}
.toggle{
display:inline-grid; grid-auto-flow: column; gap: 6px; align-items:center;
background: linear-gradient(180deg, rgba(255,255,255,.06), rgba(255,255,255,.03));
border: 1px solid rgba(255,255,255,.12);
padding: 4px; border-radius: 999px;
}
.toggle button{
border: none; background: transparent; color: var(--muted); padding: 6px 10px; border-radius: 999px; cursor: pointer; font-weight:700;
}
.toggle button.active{
color: var(--text);
background: linear-gradient(180deg, rgba(118,183,255,.22), rgba(118,183,255,.10));
}
@media (max-width: 980px){
main{ grid-template-columns: 1fr; }
.canvas-wrap{ aspect-ratio: 8.5 / 11; }
}
</style>
</head>
<body>
<header>
<div class="header-inner">
<div class="brand">
<div class="logo"></div>
<div class="brand-title">Token Sheet Generator <small>browser-only</small></div>
</div>
<div class="spacer"></div>
<a class="link-anycoder" href="https://huggingface.co/spaces/akhaliq/anycoder" target="_blank" rel="noopener">
<i class="ph ph-code"></i>
Built with anycoder
</a>
</div>
</header>
<main>
<!-- Controls -->
<section class="panel">
<form class="form" id="controlForm" onsubmit="return false;">
<div class="section">
<h3><i class="ph ph-text-aa"></i> Token Labels</h3>
<div class="field">
<label for="labels">Enter one label per line</label>
<textarea id="labels" placeholder="Goblin
Orc Warrior
Healing Potion
+1 Sword
Treasure Chest"></textarea>
<div class="hint">Top half of each token is blank for art. The label stays strictly in the bottom half. Long labels will wrap and then shrink to fit.</div>
</div>
<div class="inline">
<button class="btn secondary" id="shuffleBtn" title="Shuffle labels"><i class="ph ph-shuffle"></i> Shuffle</button>
<button class="btn secondary" id="sortBtn" title="Sort labels"><i class="ph ph-sort-ascending"></i> Sort A→Z</button>
<button class="btn danger" id="clearBtn" title="Clear labels"><i class="ph ph-trash"></i> Clear</button>
</div>
</div>
<div class="section">
<h3><i class="ph ph-squares-four"></i> Token Size</h3>
<div class="preset-buttons" id="presetButtons">
<button class="chip" data-w="10" data-h="10" data-unit="mm">1 cm square (10×10 mm)</button>
<button class="chip" data-w="10" data-h="18" data-unit="mm">Scrabble tile (10×18 mm)</button>
<button class="chip" data-w="25.4" data-h="25.4" data-unit="in">1 inch token (25.4×25.4 mm)</button>
<button class="chip" data-w="50" data-h="50" data-unit="mm">Large 50×50 mm</button>
<button class="chip" data-w="2" data-h="2" data-unit="in">2 inch square</button>
</div>
<div class="row">
<div class="field">
<label for="tokenWidth">Custom width</label>
<input type="number" id="tokenWidth" step="0.1" min="1" value="25.4">
</div>
<div class="field">
<label for="tokenHeight">Custom height</label>
<input type="number" id="tokenHeight" step="0.1" min="1" value="25.4">
</div>
</div>
<div class="row">
<div class="field">
<label for="unit">Units</label>
<select id="unit">
<option value="mm">mm</option>
<option value="in" selected>in</option>
</select>
</div>
<div class="field">
<label for="fontFamily">Font</label>
<select id="fontFamily">
<option value="Inter">Inter</option>
<option value="Roboto" selected>Roboto</option>
<option value="Roboto Condensed">Roboto Condensed</option>
<option value="Noto Sans">Noto Sans</option>
<option value="PT Sans Narrow">PT Sans Narrow</option>
<option value="Source Code Pro">Source Code Pro (monospace)</option>
</select>
</div>
</div>
<div class="row">
<div class="field">
<label for="cornerRadius">Corner radius</label>
<input type="number" id="cornerRadius" min="0" step="0.5" value="2">
<div class="hint">Rounded token corners in mm.</div>
</div>
<div class="field">
<label for="divider">Divider</label>
<div class="toggle" id="dividerToggle" role="tablist" aria-label="Divider style">
<button type="button" data-div="line" class="active"><i class="ph ph-minus"></i> Line</button>
<button type="button" data-div="none"><i class="ph ph-x"></i> None</button>
</div>
</div>
</div>
</div>
<div class="section">
<h3><i class="ph ph-printer"></i> Page Settings</h3>
<div class="row-3">
<div class="field">
<label for="pageSize">Page size</label>
<select id="pageSize">
<option value="letter" selected>US Letter (8.5×11 in)</option>
<option value="a4">A4 (210×297 mm)</option>
</select>
</div>
<div class="field">
<label for="dpi">DPI</label>
<input type="number" id="dpi" min="72" max="1200" step="1" value="300">
</div>
<div class="field">
<label for="margin">Margins</label>
<input type="number" id="margin" min="0" step="0.1" value="10">
<div class="hint">Margin in mm (default 10 mm). Cut lines will be drawn on the outer margin edges.</div>
</div>
</div>
<div class="actions">
<button class="btn" id="renderBtn"><i class="ph ph-magic-wand"></i> Render Sheet</button>
<button class="btn secondary" id="downloadPNGBtn" disabled><i class="ph ph-image"></i> Download PNG</button>
<button class="btn accent" id="downloadPDFBtn" disabled><i class="ph ph-file-pdf"></i> Download PDF</button>
<span class="inline" style="margin-left:auto">
<label class="hint" for="cutMarks" style="display:inline-flex;align-items:center;gap:8px">
<input id="cutMarks" type="checkbox" checked>
Show cut marks
</label>
</span>
</div>
</div>
<div class="section">
<h3><i class="ph ph-palette"></i> Style</h3>
<div class="row">
<div class="field">
<label for="textColor">Text color</label>
<input type="color" id="textColor" value="#000000">
</div>
<div class="field">
<label for="borderColor">Border color</label>
<input type="color" id="borderColor" value="#1c1f25">
</div>
</div>
<div class="row">
<div class="field">
<label for="fillTop">Top fill</label>
<input type="color" id="fillTop" value="#ffffff">
</div>
<div class="field">
<label for="fillBottom">Bottom fill</label>
<input type="color" id="fillBottom" value="#ffffff">
</div>
</div>
<div class="hint">Use identical colors for a flat look, or slightly different tones for a subtle two-zone token.</div>
</div>
<div class="section">
<h3><i class="ph ph-info"></i> Tips</h3>
<div class="hint">
- Use higher DPI (e.g., 300) for sharp print quality.<br>
- Choose a narrow font for more characters per token, like Roboto Condensed or PT Sans Narrow. For fixed-width aesthetics, use Source Code Pro.<br>
- Tokens are placed in a grid; only as many labels as you enter are drawn.
</div>
</div>
</form>
</section>
<!-- Preview -->
<section class="panel">
<div class="preview">
<div class="preview-header">
<div class="stat" id="statPage">Page: —</div>
<div class="stat" id="statGrid">Grid: —</div>
<div class="stat" id="statCount">Tokens drawn: —</div>
</div>
<div class="canvas-wrap" id="canvasWrap" aria-live="polite">
<canvas id="sheetCanvas"></canvas>
</div>
<div class="footer-tip">The on-screen preview shows your page at screen resolution; exports use full DPI for printing.</div>
</div>
</section>
</main>
<script>
// Helpers to convert units to pixels at a given DPI.
const INCH_TO_MM = 25.4;
function mmToPx(mm, dpi){
return (mm / INCH_TO_MM) * dpi;
}
function inToPx(inches, dpi){
return inches * dpi;
}
// Page sizes in inches and mm.
const PAGE_SIZES = {
letter: { wIn: 8.5, hIn: 11, wMm: 215.9, hMm: 279.4, label: 'US Letter' },
a4: { wIn: 8.27, hIn: 11.69, wMm: 210, hMm: 297, label: 'A4' }
};
// UI Elements
const els = {
labels: document.getElementById('labels'),
presetButtons: document.getElementById('presetButtons'),
tokenWidth: document.getElementById('tokenWidth'),
tokenHeight: document.getElementById('tokenHeight'),
unit: document.getElementById('unit'),
fontFamily: document.getElementById('fontFamily'),
pageSize: document.getElementById('pageSize'),
dpi: document.getElementById('dpi'),
margin: document.getElementById('margin'),
renderBtn: document.getElementById('renderBtn'),
downloadPNGBtn: document.getElementById('downloadPNGBtn'),
downloadPDFBtn: document.getElementById('downloadPDFBtn'),
canvas: document.getElementById('sheetCanvas'),
canvasWrap: document.getElementById('canvasWrap'),
statPage: document.getElementById('statPage'),
statGrid: document.getElementById('statGrid'),
statCount: document.getElementById('statCount'),
cornerRadius: document.getElementById('cornerRadius'),
dividerToggle: document.getElementById('dividerToggle'),
cutMarks: document.getElementById('cutMarks'),
textColor: document.getElementById('textColor'),
borderColor: document.getElementById('borderColor'),
fillTop: document.getElementById('fillTop'),
fillBottom: document.getElementById('fillBottom'),
shuffleBtn: document.getElementById('shuffleBtn'),
sortBtn: document.getElementById('sortBtn'),
clearBtn: document.getElementById('clearBtn'),
};
let dividerStyle = 'line'; // 'line' | 'none'
// Maintain current render state
let lastRender = null;
// Fit canvas element to correct pixel size and adjust preview ratio
function sizeCanvasForPage(ctx, pageKey, dpi){
const page = PAGE_SIZES[pageKey];
const pxW = inToPx(page.wIn, dpi);
const pxH = inToPx(page.hIn, dpi);
els.canvas.width = Math.round(pxW);
els.canvas.height = Math.round(pxH);
// Update preview aspect to match page
els.canvasWrap.style.aspectRatio = `${page.wIn} / ${page.hIn}`;
// Draw white background
ctx.fillStyle = '#ffffff';
ctx.fillRect(0, 0, els.canvas.width, els.canvas.height);
}
// Parse labels
function getLabels(){
return els.labels.value
.split(/\r?\n/)
.map(s => s.trim())
.filter(Boolean);
}
// Convert token size to pixels
function tokenSizeToPx(width, height, unit, dpi){
if(unit === 'mm'){
return { w: mmToPx(width, dpi), h: mmToPx(height, dpi) };
}else{ // inches
return { w: inToPx(width, dpi), h: inToPx(height, dpi) };
}
}
// Rounded rect utility
function roundRectPath(ctx, x, y, w, h, r){
const rr = Math.max(0, Math.min(r, Math.min(w, h)/2));
ctx.beginPath();
ctx.moveTo(x+rr, y);
ctx.lineTo(x+w-rr, y);
ctx.quadraticCurveTo(x+w, y, x+w, y+rr);
ctx.lineTo(x+w, y+h-rr);
ctx.quadraticCurveTo(x+w, y+h, x+w-rr, y+h);
ctx.lineTo(x+rr, y+h);
ctx.quadraticCurveTo(x, y+h, x, y+h-rr);
ctx.lineTo(x, y+rr);
ctx.quadraticCurveTo(x, y, x+rr, y);
ctx.closePath();
}
// Draw single token
function drawToken(ctx, x, y, w, h, label, fontFamily, options){
const { textColor, borderColor, fillTop, fillBottom, divider, radiusPx } = options;
// Fill halves with proper clipping to keep top/bottom separated
// Full token clipping for border radii
ctx.save();
roundRectPath(ctx, x, y, w, h, radiusPx);
ctx.clip();
// Top half fill
ctx.fillStyle = fillTop;
ctx.fillRect(x, y, w, h/2);
// Bottom half fill
ctx.fillStyle = fillBottom;
ctx.fillRect(x, y + h/2, w, h/2);
// Divider between halves (drawn inside the clipped token)
if(divider === 'line'){
ctx.beginPath();
ctx.moveTo(x, y + h/2);
ctx.lineTo(x + w, y + h/2);
ctx.lineWidth = Math.max(1, Math.floor(Math.min(w,h)*0.01));
ctx.strokeStyle = '#888';
ctx.globalAlpha = 0.5;
ctx.stroke();
}
ctx.restore();
// Border
ctx.save();
roundRectPath(ctx, x, y, w, h, radiusPx);
ctx.lineWidth = Math.max(1, Math.floor(Math.min(w, h) * 0.02));
ctx.strokeStyle = '#000000';
ctx.stroke();
ctx.lineWidth = Math.max(1, Math.floor(Math.min(w, h) * 0.012));
ctx.strokeStyle = borderColor;
ctx.stroke();
ctx.restore();
// Strict bottom-half text region
const pad = Math.max(4, Math.floor(Math.min(w, h) * 0.06));
const textBox = {
x: x + pad,
y: y + Math.ceil(h/2) + pad, // start strictly below midline
w: w - pad*2,
h: Math.floor(h/2 - pad*2) // confined to bottom half only
};
// Base font size relative to token height
const baseFontPx = Math.max(6, Math.floor(h * 0.18));
// Fit text (wrap + shrink)
drawFittedText(ctx, label, textBox, baseFontPx, 6, fontFamily, textColor);
}
// Measure wrapping lines for a given font size
function wrapLines(ctx, text, maxWidth){
const words = text.split(/\s+/);
const lines = [];
let current = '';
for(const word of words){
const tentative = current ? current + ' ' + word : word;
const w = ctx.measureText(tentative).width;
if(w <= maxWidth || current === ''){
current = tentative;
}else{
lines.push(current);
current = word;
}
}
if(current) lines.push(current);
return lines;
}
// Draw text centered inside box, shrinking font to fit height and width
function drawFittedText(ctx, text, box, baseFont, minFont, fontFamily, color){
let fontPx = baseFont;
let lines;
while(fontPx >= minFont){
ctx.font = `${fontPx}px "${fontFamily}", system-ui, sans-serif`;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
lines = wrapLines(ctx, text, box.w);
const lineHeight = Math.round(fontPx * 1.15);
const totalHeight = lines.length * lineHeight;
const tooWide = lines.some(line => ctx.measureText(line).width > box.w);
if(totalHeight <= box.h && !tooWide){
break;
}
fontPx -= 1;
}
if(fontPx < minFont){
fontPx = minFont;
ctx.font = `${fontPx}px "${fontFamily}", system-ui, sans-serif`;
let txt = text;
while(ctx.measureText(txt + '…').width > box.w && txt.length > 1){
txt = txt.slice(0, -1);
}
lines = [txt + (txt !== text ? '…' : '')];
}
const lineHeight = Math.round(fontPx * 1.15);
const totalHeight = lines.length * lineHeight;
// Keep vertically centered within the bottom half only
const startY = box.y + Math.max(0, (box.h - totalHeight)/2) + lineHeight/2;
ctx.fillStyle = color || '#000';
lines.forEach((line, i) => {
const lx = box.x + box.w/2;
const ly = startY + i * lineHeight;
ctx.fillText(line, lx, ly);
});
}
// Draw outside margin cut lines (crop marks) around the printable area
function drawOuterCutLines(ctx, marginPx, pageW, pageH){
const len = Math.max(12, Math.round(Math.min(pageW, pageH) * 0.015)); // length of crop marks
const w = pageW, h = pageH;
const x1 = marginPx, x2 = w - marginPx;
const y1 = marginPx, y2 = h - marginPx;
ctx.save();
ctx.strokeStyle = '#0b0f18';
ctx.globalAlpha = 0.7;
ctx.lineWidth = 1;
// Corners - outside the content area
// Top-left
ctx.beginPath();
ctx.moveTo(x1 - len, y1); ctx.lineTo(x1, y1);
ctx.moveTo(x1, y1 - len); ctx.lineTo(x1, y1);
ctx.stroke();
// Top-right
ctx.beginPath();
ctx.moveTo(x2 + len, y1); ctx.lineTo(x2, y1);
ctx.moveTo(x2, y1 - len); ctx.lineTo(x2, y1);
ctx.stroke();
// Bottom-left
ctx.beginPath();
ctx.moveTo(x1 - len, y2); ctx.lineTo(x1, y2);
ctx.moveTo(x1, y2 + len); ctx.lineTo(x1, y2);
ctx.stroke();
// Bottom-right
ctx.beginPath();
ctx.moveTo(x2 + len, y2); ctx.lineTo(x2, y2);
ctx.moveTo(x2, y2 + len); ctx.lineTo(x2, y2);
ctx.stroke();
ctx.restore();
}
// Render the full sheet
function render(){
const labels = getLabels();
const dpi = Math.max(72, Number(els.dpi.value) || 300);
const pageKey = els.pageSize.value;
const page = PAGE_SIZES[pageKey];
const marginMm = Math.max(0, Number(els.margin.value) || 10);
// token size inputs
const tokenW = Math.max(1, Number(els.tokenWidth.value) || 25.4);
const tokenH = Math.max(1, Number(els.tokenHeight.value) || 25.4);
const unit = els.unit.value;
const fontFamily = els.fontFamily.value;
const ctx = els.canvas.getContext('2d');
// Size canvas to page at DPI
sizeCanvasForPage(ctx, pageKey, dpi);
// Compute sizes in px
const marginPx = mmToPx(marginMm, dpi);
const tokenPx = tokenSizeToPx(tokenW, tokenH, unit, dpi);
const tokenWpx = Math.floor(tokenPx.w);
const tokenHpx = Math.floor(tokenPx.h);
// corner radius
const cornerMm = Math.max(0, Number(els.cornerRadius.value) || 0);
const radiusPx = mmToPx(cornerMm, dpi);
// Content area
const contentW = els.canvas.width - marginPx * 2;
const contentH = els.canvas.height - marginPx * 2;
// Grid count
const cols = Math.max(0, Math.floor(contentW / tokenWpx));
const rows = Math.max(0, Math.floor(contentH / tokenHpx));
const perPage = cols * rows;
// Start origin
const originX = Math.round(marginPx + (contentW - cols * tokenWpx) / 2);
const originY = Math.round(marginPx + (contentH - rows * tokenHpx) / 2);
// Outside margin cut lines
if(els.cutMarks.checked){
drawOuterCutLines(ctx, marginPx, els.canvas.width, els.canvas.height);
}
// Background page content area outline (subtle)
if(els.cutMarks.checked){
ctx.save();
ctx.strokeStyle = '#111827';
ctx.globalAlpha = 0.16;
ctx.setLineDash([4, 6]);
ctx.strokeRect(marginPx, marginPx, contentW, contentH);
ctx.restore();
}
// Draw tokens
let drawn = 0;
const options = {
textColor: els.textColor.value,
borderColor: els.borderColor.value,
fillTop: els.fillTop.value,
fillBottom: els.fillBottom.value,
divider: dividerStyle,
radiusPx
};
for(let r=0; r<rows; r++){
for(let c=0; c<cols; c++){
const idx = r*cols + c;
if(idx >= labels.length) break;
const x = originX + c*tokenWpx;
const y = originY + r*tokenHpx;
// Optional token cut rectangles (inner grid guide)
if(els.cutMarks.checked){
ctx.save();
ctx.strokeStyle = '#111827';
ctx.globalAlpha = 0.18;
ctx.setLineDash([3,4]);
ctx.strokeRect(x, y, tokenWpx, tokenHpx);
ctx.restore();
}
drawToken(ctx, x, y, tokenWpx, tokenHpx, labels[idx], fontFamily, options);
drawn++;
}
}
// Update stats
els.statPage.textContent = `Page: ${page.label}${dpi} DPI • ${Math.round(els.canvas.width)}×${Math.round(els.canvas.height)} px`;
els.statGrid.textContent = `Grid: ${cols} × ${rows} (${perPage} per page) • Token ${tokenW}×${tokenH} ${unit}`;
els.statCount.textContent = `Tokens drawn: ${drawn}/${labels.length}`;
// Enable exports if we rendered at least once
const canExport = (cols > 0 && rows > 0);
els.downloadPNGBtn.disabled = !canExport;
els.downloadPDFBtn.disabled = !canExport;
// Save state
lastRender = {
dpi, pageKey, marginMm, tokenW, tokenH, unit, fontFamily, cols, rows, perPage, labels, drawn,
widthPx: els.canvas.width, heightPx: els.canvas.height
};
}
// Export PNG
function downloadPNG(){
if(!lastRender) return;
const dataURL = els.canvas.toDataURL("image/png");
const a = document.createElement('a');
a.href = dataURL;
const page = PAGE_SIZES[lastRender.pageKey];
a.download = `tokens_${page.label.replace(/\s+/g,'').toLowerCase()}_${lastRender.dpi}dpi.png`;
a.click();
}
// Export PDF using jsPDF
async function downloadPDF(){
if(!lastRender) return;
const { jsPDF } = window.jspdf;
const page = PAGE_SIZES[lastRender.pageKey];
const unit = "in";
const pdf = new jsPDF({ orientation: page.wIn > page.hIn ? 'landscape' : 'portrait', unit, format: [page.wIn, page.hIn] });
const dataURL = els.canvas.toDataURL("image/png");
pdf.addImage(dataURL, 'PNG', 0, 0, page.wIn, page.hIn);
pdf.save(`tokens_${page.label.replace(/\s+/g,'').toLowerCase()}_${lastRender.dpi}dpi.pdf`);
}
// UI events
els.renderBtn.addEventListener('click', render);
els.downloadPNGBtn.addEventListener('click', downloadPNG);
els.downloadPDFBtn.addEventListener('click', downloadPDF);
// Apply preset buttons
els.presetButtons.addEventListener('click', (e)=>{
if(e.target.closest('.chip')){
e.preventDefault();
const btn = e.target.closest('.chip');
const w = parseFloat(btn.dataset.w);
const h = parseFloat(btn.dataset.h);
const unit = btn.dataset.unit;
els.tokenWidth.value = w;
els.tokenHeight.value = h;
els.unit.value = unit;
pulse(btn);
debouncedRender();
}
});
// Divider toggle
els.dividerToggle.addEventListener('click', (e)=>{
const b = e.target.closest('button');
if(!b) return;
[...els.dividerToggle.querySelectorAll('button')].forEach(x=>x.classList.remove('active'));
b.classList.add('active');
dividerStyle = b.dataset.div;
debouncedRender();
});
// Label helpers
els.shuffleBtn.addEventListener('click', ()=>{
const items = getLabels();
for(let i=items.length-1;i>0;i--){
const j = Math.floor(Math.random()*(i+1));
[items[i],items[j]] = [items[j],items[i]];
}
els.labels.value = items.join('\n');
debouncedRender();
});
els.sortBtn.addEventListener('click', ()=>{
const items = getLabels().sort((a,b)=>a.localeCompare(b));
els.labels.value = items.join('\n');
debouncedRender();
});
els.clearBtn.addEventListener('click', ()=>{
if(confirm('Clear all labels?')){
els.labels.value = '';
debouncedRender();
}
});
// Little pulse effect for feedback
function pulse(el){
el.animate([
{ transform: 'translateY(0)', filter: 'brightness(1)' },
{ transform: 'translateY(-2px)', filter: 'brightness(1.15)' },
{ transform: 'translateY(0)', filter: 'brightness(1)' }
], { duration: 240, easing: 'ease-out' });
}
// Live preview on important changes (debounced)
const debouncedRender = debounce(render, 200);
['labels','tokenWidth','tokenHeight','unit','fontFamily','pageSize','dpi','margin',
'cornerRadius','cutMarks','textColor','borderColor','fillTop','fillBottom']
.forEach(id => {
const el = document.getElementById(id);
el.addEventListener('input', debouncedRender);
el.addEventListener('change', debouncedRender);
});
function debounce(fn, wait){
let t; return (...args)=>{
clearTimeout(t);
t = setTimeout(()=>fn.apply(this,args), wait);
};
}
// Initial demo content and render
els.labels.value = [
'Goblin Scout',
'Orc Warrior',
'Healing Potion',
'Treasure Chest',
'Boss: Ancient Dragon',
'Mystic Doorway',
'Trap: Poison Darts',
'Key Fragment',
'Arcane Crystal',
'Torch',
'Rogue Adept',
'Ancient Tome of Forgotten Realms'
].join('\n');
render();
</script>
</body>
</html>