314 lines
9.7 KiB
JavaScript
314 lines
9.7 KiB
JavaScript
'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';
|
||
}
|