ptouch-api/web/label.js
2025-10-28 19:25:41 +01:00

366 lines
11 KiB
JavaScript

const elementsTable = document.getElementById('elements');
/** @type HTMLCanvasElement preview */
const preview = document.getElementById('preview');
const tapeDiv = document.getElementById('tape');
const renderCtx = preview.getContext("2d");
const elementsTableBody = elementsTable.getElementsByTagName('tbody')[0];
const addTextButton = document.getElementById('add-text');
const addImageButton = document.getElementById('add-image');
/** @type HTMLInputElement printButton */
const printButton = document.getElementById('print');
const widthInput = document.getElementById('canvas-width');
const widthUnit = document.getElementById('canvas-width-unit');
const autoHelp = document.getElementById('autoHelp');
let tapeWidthMM = 0;
let defaultFontSize = 24;
let backgroundColor = "white";
let textColor = "white";
let images = new Map();
const nextElementId = () => {
let rows = elementsTableBody.getElementsByTagName('tr');
if (rows.length === 0) {
return 1;
} else {
return parseInt(rows[0].dataset.elementId, 10) + 1;
}
};
/**
* @typedef ElementSpec
* @type {object}
* @property {string} tag
* @property {Object.<string, string>} props
* @property {Object.<string, string>} attributes
* @property {Object.<string, any>} data
* @property {Object.<string, Function>} events
* @property {string[]} clases
* @property {(ElementSpec|string)[]} children
*/
/**
*
*
* @param {string|ElementSpec} spec
* @returns {HTMLElement|Text}
*/
const el = (spec) => {
if (typeof spec === "string") {
return document.createTextNode(spec);
}
let element = document.createElement(spec.tag);
for (const [key, value] of Object.entries(spec.props || {})) {
element[key] = value;
}
for (const [key, value] of Object.entries(spec.attributes || {})) {
element.setAttribute(key, value);
}
for (const [key, value] of Object.entries(spec.data || {})) {
element.dataset[key] = value;
}
for (const [key, value] of Object.entries(spec.events || {})) {
element.addEventListener(key, value);
}
for (const c of spec.classes || []) {
element.classList.add(c);
}
for (const child of spec.children || []) {
element.appendChild(el(child));
}
return element;
}
const addElementControls = (row) => {
row.appendChild(el({
tag: 'td',
classes: ['size'],
children: ['Size', {
tag: 'input',
events: {input: render},
props: {
type: 'number',
value: defaultFontSize.toString(),
}
}]
}));
row.appendChild(el({
tag: 'td',
classes: ['pos'],
children: ['Position', {
tag: 'select',
events: {change: render},
classes: ['pos_base'],
children: [
{tag: 'option', props: {value: 'left'}, children: ['Left']},
{tag: 'option', props: {value: 'center'}, children: ['Center']},
{tag: 'option', props: {value: 'right'}, children: ['Right']},
],
}, {
tag: 'input',
events: {input: render},
classes: ['pos_x'],
props: {
type: 'number',
value: "0",
}
}, {
tag: 'input',
events: {input: render},
classes: ['pos_y'],
props: {
type: 'number',
value: "0",
}
}]
}));
row.appendChild(el({
tag: 'td',
classes: ['delete'],
children: [{
tag: 'input',
events: {click: removeElement},
props: {
type: 'button',
value: 'x',
}
}]
}));
}
const addTextElement = () => {
let id = nextElementId();
let row = el({
tag: 'tr',
data: {elementId: id, type: 'text'},
children: [{
tag: 'td',
classes: ['text'],
props: {colSpan: 2},
children: [{
tag: 'input',
events: {input: render},
props: {
type: 'text',
}
}]
}]
});
addElementControls(row);
elementsTableBody.appendChild(row);
row.querySelector('input').focus();
};
const loadImage = (event) => {
/** @type HTMLInputElement input */
const input = event.target;
const row = event.target.parentElement.parentElement;
let file = input.files[0];
if (file.type.startsWith('image')) {
let image = row.getElementsByTagName('img')[0];
image.addEventListener('load', render);
image.src = URL.createObjectURL(file);
}
};
const addImageElement = () => {
let id = nextElementId();
let imageId = `image-input-${id}`;
let row = el({
tag: 'tr',
data: {elementId: id, type: 'image'},
children: [{
tag: 'td',
classes: ['image'],
children: [{
tag: 'input',
events: {change: loadImage},
props: {
id: imageId,
type: 'file',
focus: 'focused',
}
},{
tag: 'label',
events: {change: loadImage},
children: "Browse...",
attributes: {
for: imageId,
}
}]
}, {
tag: 'td',
classes: ['preview'],
children: [{tag: 'img'}]
}]
});
addElementControls(row);
elementsTableBody.appendChild(row);
};
const removeElement = (event) => {
let row = event.target.parentElement.parentElement;
let id = row.dataset.elementId;
row.remove();
images.delete(id);
render();
};
const getElements = () => {
let rows = elementsTableBody.getElementsByTagName('tr');
let elements = [];
for (let row of rows) {
let size = parseInt(row.querySelector('.size input').value, 10);
let x = parseInt(row.querySelector('.pos_x').value, 10);
let y = parseInt(row.querySelector('.pos_y').value, 10);
let base = row.querySelector('.pos_base').value;
if (row.dataset.type === 'text') {
let text = row.querySelector('.text input').value;
elements.push({
type: 'text',
text, size, x, y, base,
width: (renderCtx) => {
renderCtx.font = `${size}px serif`;
let textSize = renderCtx.measureText(text);
return textSize.width;
}
});
} else if (row.dataset.type === 'image') {
let image = row.getElementsByTagName('img')[0];
let ratio = image.naturalWidth / image.naturalHeight;
let width = size * ratio;
elements.push({
type: 'image',
image, size, x, y, base,
width: (_renderCtx) => width,
});
}
}
return elements;
}
const render = () => {
let elements = getElements();
if (widthUnit.value === "auto") {
let autoWidth = 20;
for (let element of elements) {
if (element.base === 'left') {
autoWidth = Math.max(autoWidth, element.x + element.width(renderCtx));
}
}
preview.width = autoWidth;
}
let width = preview.width;
let height = preview.height;
renderCtx.filter = '';
renderCtx.fillStyle = backgroundColor;
renderCtx.fillRect(0, 0, width, height);
renderCtx.fillStyle = textColor;
for (let element of elements) {
let width = element.width(renderCtx);
if (element.type === 'text') {
renderCtx.filter = '';
renderCtx.font = `${element.size}px serif`;
let textSize = renderCtx.measureText(element.text);
let textHeight = textSize.fontBoundingBoxAscent - textSize.emHeightDescent;
renderCtx.fillText(element.text, calcX(element.base, element.x, width), element.y + textHeight);
} else if (element.type === 'image' && element.image.naturalWidth > 0) {
renderCtx.filter = 'grayscale() saturate(0%) brightness(70%) contrast(1000%)';
if (textColor === 'white') {
renderCtx.filter = renderCtx.filter + ' invert()';
}
renderCtx.drawImage(element.image, calcX(element.base, element.x, width), element.y, width, element.size);
}
}
}
const calcX = (base, x, width) => {
let imageWidth = preview.width;
switch (base) {
case 'left':
return x;
case 'right':
return (imageWidth - width) + x;
case 'center':
return ((imageWidth - width) / 2) + x;
}
}
const resize = () => {
let width = parseInt(widthInput.value, 10);
const pxPerMM = preview.height / tapeWidthMM;
if (widthUnit.value === "mm") {
preview.width = width * pxPerMM;
} else {
preview.width = width;
}
render()
};
const print = () => {
printButton.disabled = true;
preview.toBlob(blob => {
blob.arrayBuffer().then(png => fetch(apiRoot + '/print', {
method: 'PUT',
body: png,
}))
.then(() => {
printButton.disabled = false;
});
}, 'image/png');
};
addTextButton.addEventListener('click', addTextElement);
addImageButton.addEventListener('click', addImageElement);
printButton.addEventListener('click', print);
widthInput.addEventListener('input', resize);
widthUnit.addEventListener('change', () => {
const pxPerMM = preview.height / tapeWidthMM;
if (widthUnit.value === "mm") {
widthInput.value = (preview.width / pxPerMM).toFixed(0);
widthInput.disabled = null;
widthInput.hidden = false;
autoHelp.classList.remove('show');
} else if (widthUnit.value === "px") {
widthInput.value = preview.width;
widthInput.hidden = false;
widthInput.disabled = null;
autoHelp.classList.remove('show');
} else {
widthInput.disabled = "disabled";
widthInput.hidden = true;
autoHelp.classList.add('show');
}
});
widthUnit.dispatchEvent(new Event('change'));
const apiRoot = "";
fetch(apiRoot + '/status')
.then(res => res.json())
/** @param {{pixel_width: number, media_width: number, margin_width: number, tape_color: string, text_color: string}} status */
.then(status => {
preview.height = status.pixel_width;
defaultFontSize = status.pixel_width;
backgroundColor = status.tape_color;
textColor = status.text_color;
tapeWidthMM = status.media_width;
const pxPerMM = preview.height / tapeWidthMM;
const marginPx = status.margin_width * pxPerMM;
tapeDiv.style.padding = `${marginPx}px 0`;
tapeDiv.style.height = `${status.pixel_width}px`;
render();
addTextElement();
})