Files
code-canvas-generator/public/app.js
2026-05-19 15:32:05 +02:00

314 lines
9.7 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'use strict';
const els = {
form: document.getElementById('controls'),
type: document.getElementById('type'),
typeList: document.getElementById('type-list'),
input: document.getElementById('input'),
data: document.getElementById('data'),
size: document.getElementById('size'),
margin: document.getElementById('margin'),
scale: document.getElementById('scale'),
rotate: document.getElementById('rotate'),
fg: document.getElementById('fg'),
bg: document.getElementById('bg'),
includetext: document.getElementById('includetext'),
textalign: document.getElementById('textalign'),
heightmm: document.getElementById('heightmm'),
eclevel: document.getElementById('eclevel'),
parsefnc: document.getElementById('parsefnc'),
parse: document.getElementById('parse'),
sample: document.getElementById('sample'),
copyPage: document.getElementById('copy-page'),
copyImage: document.getElementById('copy-image'),
download: document.getElementById('download'),
canvas: document.getElementById('canvas'),
status: document.getElementById('status'),
meta: document.getElementById('meta'),
imageUrl: document.getElementById('image-url')
};
const DEFAULTS = {
type: 'qrcode',
input: 'text',
data: 'https://example.com/?hello=world',
size: '256',
margin: '0',
scale: '4',
rotate: 'N',
fg: '#000000',
bg: '#ffffff',
includetext: 'false',
textalign: 'center'
};
let renderGeneration = 0;
let popularTypes = [];
let debounceTimer = null;
main().catch((error) => {
setStatus(`Startup error: ${error.message || error}`, true);
});
async function main() {
await loadTypes();
hydrateFromQuery();
bindEvents();
await render();
}
async function loadTypes() {
const response = await fetch('/api/types');
if (!response.ok) {
throw new Error('Could not load supported code types.');
}
const payload = await response.json();
popularTypes = payload.popular || [];
els.typeList.innerHTML = '';
for (const item of popularTypes) {
const option = document.createElement('option');
option.value = item.id;
option.label = `${item.label} · ${item.kind}`;
els.typeList.appendChild(option);
}
}
function bindEvents() {
els.form.addEventListener('input', scheduleRender);
els.form.addEventListener('change', scheduleRender);
els.sample.addEventListener('click', async () => {
const type = els.type.value.trim() || DEFAULTS.type;
try {
const response = await fetch(`/api/sample/${encodeURIComponent(type)}`);
const payload = await response.json();
if (!response.ok) {
throw new Error(payload.error || 'No sample available.');
}
els.data.value = payload.data;
scheduleRender();
} catch (error) {
setStatus(error.message || String(error), true);
}
});
els.copyPage.addEventListener('click', async () => {
const url = buildPageUrl();
await copyText(url, 'Page URL copied');
});
els.copyImage.addEventListener('click', async () => {
const url = buildAbsoluteImageUrl();
await copyText(url, 'Image URL copied');
});
els.download.addEventListener('click', () => {
const filename = filenameForCurrentCode();
els.canvas.toBlob((blob) => {
if (!blob) {
setStatus('Could not create PNG blob.', true);
return;
}
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = filename;
document.body.appendChild(link);
link.click();
link.remove();
URL.revokeObjectURL(url);
setStatus('PNG downloaded');
}, 'image/png');
});
}
function hydrateFromQuery() {
const params = new URLSearchParams(window.location.search);
setValue(els.type, params.get('type') || params.get('bcid') || DEFAULTS.type);
setValue(els.input, params.get('input') || params.get('format') || DEFAULTS.input);
setValue(els.data, params.get('data') ?? params.get('text') ?? DEFAULTS.data);
setValue(els.size, params.get('size') || params.get('codeSize') || DEFAULTS.size);
setValue(els.margin, params.get('margin') || DEFAULTS.margin);
setValue(els.scale, params.get('scale') || DEFAULTS.scale);
setValue(els.rotate, params.get('rotate') || DEFAULTS.rotate);
setValue(els.fg, normalizeColorForInput(params.get('fg') || params.get('foreground') || DEFAULTS.fg));
setValue(els.bg, normalizeColorForInput(params.get('bg') || params.get('background') || DEFAULTS.bg));
setValue(els.includetext, params.get('includetext') || DEFAULTS.includetext);
setValue(els.textalign, params.get('textalign') || DEFAULTS.textalign);
setValue(els.heightmm, params.get('heightmm') || params.get('height') || '');
setValue(els.eclevel, params.get('eclevel') || '');
setValue(els.parsefnc, params.get('parsefnc') || '');
setValue(els.parse, params.get('parse') || '');
}
function setValue(element, value) {
element.value = value;
}
function normalizeColorForInput(value) {
const stripped = String(value || '').trim().replace(/^#/, '');
return /^[0-9a-fA-F]{6}$/.test(stripped) ? `#${stripped}` : value;
}
function scheduleRender() {
window.clearTimeout(debounceTimer);
debounceTimer = window.setTimeout(() => {
render().catch((error) => setStatus(error.message || String(error), true));
}, 120);
}
async function render() {
const generation = ++renderGeneration;
const params = buildParams();
const imagePath = `/api/code.png?${params.toString()}`;
const imageUrl = `${window.location.origin}${imagePath}`;
els.imageUrl.value = imageUrl;
updateMeta();
window.history.replaceState(null, '', buildPageUrl());
setStatus('Rendering…');
const response = await fetch(imagePath, { headers: { Accept: 'image/png, application/json' } });
if (generation !== renderGeneration) {
return;
}
if (!response.ok) {
const message = await readError(response);
setStatus(message, true);
return;
}
const blob = await response.blob();
const image = await blobToImage(blob);
if (generation !== renderGeneration) {
URL.revokeObjectURL(image.src);
return;
}
drawImageToCanvas(image);
URL.revokeObjectURL(image.src);
setStatus('Ready');
}
function buildParams() {
const params = new URLSearchParams();
params.set('type', els.type.value.trim() || DEFAULTS.type);
params.set('input', els.input.value || DEFAULTS.input);
params.set('data', els.data.value);
params.set('size', els.size.value || DEFAULTS.size);
params.set('margin', els.margin.value || DEFAULTS.margin);
params.set('scale', els.scale.value || DEFAULTS.scale);
params.set('rotate', els.rotate.value || DEFAULTS.rotate);
params.set('fg', stripHash(els.fg.value || DEFAULTS.fg));
params.set('bg', stripHash(els.bg.value || DEFAULTS.bg));
params.set('includetext', els.includetext.value || DEFAULTS.includetext);
params.set('textalign', els.textalign.value || DEFAULTS.textalign);
setOptional(params, 'heightmm', els.heightmm.value);
setOptional(params, 'eclevel', els.eclevel.value);
setOptional(params, 'parsefnc', els.parsefnc.value);
setOptional(params, 'parse', els.parse.value);
return params;
}
function setOptional(params, key, value) {
if (value !== undefined && String(value).trim() !== '') {
params.set(key, String(value).trim());
}
}
function buildPageUrl() {
return `${window.location.origin}${window.location.pathname}?${buildParams().toString()}`;
}
function buildAbsoluteImageUrl() {
return `${window.location.origin}/api/code.png?${buildParams().toString()}`;
}
function stripHash(color) {
return String(color || '').replace(/^#/, '');
}
function updateMeta() {
const size = Math.max(0, Number.parseInt(els.size.value || DEFAULTS.size, 10));
const margin = Math.max(0, Number.parseInt(els.margin.value || DEFAULTS.margin, 10));
const output = size + margin * 2;
els.meta.textContent = `Output: ${output} × ${output} px`;
}
function drawImageToCanvas(image) {
const canvas = els.canvas;
canvas.width = image.naturalWidth;
canvas.height = image.naturalHeight;
const ctx = canvas.getContext('2d');
ctx.imageSmoothingEnabled = false;
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.drawImage(image, 0, 0);
}
function blobToImage(blob) {
return new Promise((resolve, reject) => {
const url = URL.createObjectURL(blob);
const img = new Image();
img.onload = () => resolve(img);
img.onerror = () => {
URL.revokeObjectURL(url);
reject(new Error('Could not load generated image.'));
};
img.src = url;
});
}
async function readError(response) {
const contentType = response.headers.get('content-type') || '';
if (contentType.includes('application/json')) {
const payload = await response.json();
return payload.error || `Render failed with HTTP ${response.status}`;
}
return response.text();
}
async function copyText(text, successMessage) {
try {
if (navigator.clipboard && window.isSecureContext) {
await navigator.clipboard.writeText(text);
} else {
fallbackCopy(text);
}
setStatus(successMessage);
} catch (error) {
setStatus(`Copy failed: ${error.message || error}`, true);
}
}
function fallbackCopy(text) {
const textarea = document.createElement('textarea');
textarea.value = text;
textarea.setAttribute('readonly', '');
textarea.style.position = 'fixed';
textarea.style.top = '-1000px';
document.body.appendChild(textarea);
textarea.select();
document.execCommand('copy');
textarea.remove();
}
function filenameForCurrentCode() {
const safeType = (els.type.value || DEFAULTS.type).replace(/[^a-z0-9_-]+/gi, '-').slice(0, 64);
const size = els.size.value || DEFAULTS.size;
const margin = els.margin.value || DEFAULTS.margin;
return `${safeType}-${size}px-m${margin}.png`;
}
function setStatus(message, error = false) {
els.status.textContent = message;
els.status.dataset.error = error ? 'true' : 'false';
}