stash
This commit is contained in:
parent
ce1e5f1d62
commit
c345372382
9 changed files with 233 additions and 82 deletions
9
frontend/src/assets/fonts/pixel/LICENSE.md
Normal file
9
frontend/src/assets/fonts/pixel/LICENSE.md
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
# Pixel fonts used for small label text (see ../../../scss/_pixel-fonts.scss)
|
||||||
|
|
||||||
|
- **Tom Thumb** (`TomThumb.ttf`) - by Brian Swetland, TTF conversion by gheja
|
||||||
|
(https://github.com/gheja/tom-thumb-ttf). Licensed CC0 or CC-BY 3.0 (original:
|
||||||
|
https://robey.lag.net/2010/01/23/tiny-monospace-font.html).
|
||||||
|
- **PICO-8** (`PICO-8.ttf`) - reproduction by Jacob Pierce
|
||||||
|
(https://github.com/jacobpierce/pico-8-font). MIT License, Copyright (c) 2016 Jacob Pierce.
|
||||||
|
- **Silkscreen** (`Silkscreen-Regular.woff2`) - by Jason Kottke, served via Google Fonts
|
||||||
|
(https://fonts.google.com/specimen/Silkscreen). SIL Open Font License 1.1.
|
||||||
BIN
frontend/src/assets/fonts/pixel/PICO-8.ttf
Normal file
BIN
frontend/src/assets/fonts/pixel/PICO-8.ttf
Normal file
Binary file not shown.
BIN
frontend/src/assets/fonts/pixel/Silkscreen-Regular.woff2
Normal file
BIN
frontend/src/assets/fonts/pixel/Silkscreen-Regular.woff2
Normal file
Binary file not shown.
BIN
frontend/src/assets/fonts/pixel/TomThumb.ttf
Normal file
BIN
frontend/src/assets/fonts/pixel/TomThumb.ttf
Normal file
Binary file not shown.
|
|
@ -8,33 +8,33 @@ const GAP = {type: "empty", "min-width": "1mm", "min-height": "1mm"};
|
||||||
export const LABEL_TEMPLATES = [
|
export const LABEL_TEMPLATES = [
|
||||||
{
|
{
|
||||||
id: "qr", name: "QR code only", description: "Just the code - smallest label, prints fastest.",
|
id: "qr", name: "QR code only", description: "Just the code - smallest label, prints fastest.",
|
||||||
required_vars: ["value"],
|
required_vars: ["text"],
|
||||||
layout: [{type: "qrcode", content: c => c.value}]
|
layout: [{type: "qrcode", content: c => c.text}]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "qr-text", name: "QR code + text", description: "The code with the encoded text printed next to it.",
|
id: "qr-text", name: "QR code + text", description: "The code with the encoded text printed next to it.",
|
||||||
required_vars: ["value"],
|
required_vars: ["text"],
|
||||||
layout: [{type: "qrcode", content: c => c.value}, GAP, {type: "text", content: c => c.value?.split("\n")}]
|
layout: [{type: "qrcode", content: c => c.text}, GAP, {type: "text", content: c => c.text?.split("\n")}]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "qr-text-below", name: "QR code + text below",
|
id: "qr-text-below", name: "QR code + text below",
|
||||||
description: "The code with the encoded text printed below it.",
|
description: "The code with the encoded text printed below it.",
|
||||||
required_vars: ["value"],
|
required_vars: ["text"],
|
||||||
layout: [[{type: "qrcode", content: c => c.value}, GAP, {type: "text", content: c => c.value?.split("\n")}]]
|
layout: [[{type: "qrcode", content: c => c.text}, GAP, {type: "text", content: c => c.text?.split("\n")}]]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "id-qr-text-vertical", name: "ID + QR code + text below",
|
id: "id-qr-text-vertical", name: "ID + QR code + text below",
|
||||||
description: "The code with the encoded text printed below it.",
|
description: "The code with the encoded text printed below it.",
|
||||||
required_vars: ["itemId", "value", "userHandle"],
|
required_vars: ["itemId", "text", "userHandle"],
|
||||||
layout: [[{type: "text", content: c => "Item: "+c.itemId}, GAP, {
|
layout: [[{type: "text", content: c => "Item: "+c.itemId}, GAP, {
|
||||||
type: "qrcode",
|
type: "qrcode",
|
||||||
content: c => c.value
|
content: c => c.text
|
||||||
}, GAP, {type: "text", content: c => c.userHandle}]]
|
}, GAP, {type: "text", content: c => c.userHandle}]]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "text", name: "Text only", description: "No code, just the text itself, as large as it fits.",
|
id: "text", name: "Text only", description: "No code, just the text itself, as large as it fits.",
|
||||||
required_vars: ["value"],
|
required_vars: ["text"],
|
||||||
layout: [{type: "text", content: c => c.value?.split("\n")}]
|
layout: [{type: "text", content: c => c.text?.split("\n")}]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "item-handle", name: "Item handle",
|
id: "item-handle", name: "Item handle",
|
||||||
|
|
@ -105,10 +105,19 @@ export const KNOWN_VARS = [...new Set(LABEL_TEMPLATES.flatMap(t => t.required_va
|
||||||
// A derived var is a format string calculated from other vars rather than typed directly - it
|
// A derived var is a format string calculated from other vars rather than typed directly - it
|
||||||
// doesn't get its own input, just a read-only, live-recalculated display next to the ones that
|
// doesn't get its own input, just a read-only, live-recalculated display next to the ones that
|
||||||
// do (see Print.vue and withDerivedVars below). `inputs` names every var (base or, in principle,
|
// do (see Print.vue and withDerivedVars below). `inputs` names every var (base or, in principle,
|
||||||
// derived) `calc` reads - declared up front rather than inferred from calc's body so BASE_VARS
|
// another derived one - see itemUrl/itemHandle below, which both read the derived userHandle)
|
||||||
// below can include a var like "webdomain" that only feeds a calculation and that no template
|
// `calc` reads - declared up front rather than inferred from calc's body so BASE_VARS below can
|
||||||
// ever references directly.
|
// include a var like "webdomain" that only feeds a calculation and that no template ever
|
||||||
|
// references directly. Declaration order matters here: withDerivedVars runs these in a single
|
||||||
|
// pass, so a derived var must be declared after every other derived var it depends on.
|
||||||
export const DERIVED_VARS = {
|
export const DERIVED_VARS = {
|
||||||
|
// The full owner handle (see federation.md's Unique Handles section / ToolshedUser's
|
||||||
|
// separate username/domain columns) - kept as two base vars (user, domain) rather than one,
|
||||||
|
// since that's how the account itself is actually shaped, with this just the display/URL form.
|
||||||
|
userHandle: {
|
||||||
|
inputs: ["user", "domain"],
|
||||||
|
calc: (f) => `${f.user}@${f.domain}`,
|
||||||
|
},
|
||||||
// The self-contained Item URL (see docs/design-in-progress/items-labels.md) - what a printed
|
// The self-contained Item URL (see docs/design-in-progress/items-labels.md) - what a printed
|
||||||
// label actually encodes, since scanning it has to resolve the right frontend/backend/item
|
// label actually encodes, since scanning it has to resolve the right frontend/backend/item
|
||||||
// with no other context, not just this browser's history. `webdomain` defaults to this
|
// with no other context, not just this browser's history. `webdomain` defaults to this
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import QRCode from "qrcode";
|
import QRCode from "qrcode";
|
||||||
|
import {encodeHandleForUrl} from "@/handle-url";
|
||||||
|
|
||||||
const TRAILING_PADDING_PX = 3; /* blank columns after the cut, same idea as the leading margin */
|
const TRAILING_PADDING_PX = 3; /* blank columns after the cut, same idea as the leading margin */
|
||||||
|
|
||||||
|
|
@ -40,7 +41,26 @@ export function tapeFromStatus(status) {
|
||||||
*/
|
*/
|
||||||
const QR_ASPECT = 1; /* a QR code is always square */
|
const QR_ASPECT = 1; /* a QR code is always square */
|
||||||
const TEXT_REFERENCE_PX = 100; /* font size text leaves measure their natural aspect ratio at */
|
const TEXT_REFERENCE_PX = 100; /* font size text leaves measure their natural aspect ratio at */
|
||||||
const MIN_READABLE_TEXT_PX = 8; /* below this, a layout is rejected rather than rendered unreadably small */
|
|
||||||
|
// Below 10px, a general-purpose sans-serif gets blurry/illegible, so drawTextLeaf switches to
|
||||||
|
// whichever of these bitmap-style fonts (see ../scss/_pixel-fonts.scss) is designed closest to -
|
||||||
|
// and no smaller than - the box's actual effective size. Ordered smallest first; fontFamilyFor
|
||||||
|
// below picks the first tier whose belowPx clears the requested size, sans-serif once none do.
|
||||||
|
const PIXEL_FONT_TIERS = [
|
||||||
|
{belowPx: 6, family: "Tom Thumb"}, /* designed for ~5px */
|
||||||
|
{belowPx: 7, family: "PICO-8"}, /* designed for ~6px */
|
||||||
|
{belowPx: 10, family: "Silkscreen"}, /* designed for ~7-8px */
|
||||||
|
];
|
||||||
|
|
||||||
|
function fontFamilyFor(fontPx) {
|
||||||
|
return PIXEL_FONT_TIERS.find(t => fontPx < t.belowPx)?.family ?? "sans-serif";
|
||||||
|
}
|
||||||
|
|
||||||
|
// A pixel font (see PIXEL_FONT_TIERS above) is what makes a size down here still legible - a
|
||||||
|
// plain sans-serif this small would fail the old MIN_READABLE_TEXT_PX=8 floor that predates them.
|
||||||
|
// Below this, drawTextLeaf leaves that one field blank rather than drawing illegible ink - see
|
||||||
|
// there for why that's a quieter failure than rejecting the whole label over it.
|
||||||
|
const MIN_READABLE_TEXT_PX = 5;
|
||||||
|
|
||||||
function isSplit(node) {
|
function isSplit(node) {
|
||||||
return Array.isArray(node);
|
return Array.isArray(node);
|
||||||
|
|
@ -134,26 +154,6 @@ function positionTree(node, ownAxis, x, y) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Walks the tree once it's been sized (layoutTree) and given a hard minimum to check against -
|
|
||||||
this is the text-too-small-to-read validation, kept as one pass over the already-final boxes
|
|
||||||
rather than scattered through the drawing code, so nothing gets mutated on the canvas before
|
|
||||||
every leaf is confirmed to fit. The equivalent QR-doesn't-fit check already happened earlier,
|
|
||||||
in snapQrToCrispSize. */
|
|
||||||
function assertLeavesFit(node) {
|
|
||||||
if (isSplit(node)) {
|
|
||||||
node.forEach(child => assertLeavesFit(child));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (node.type === "text") {
|
|
||||||
const fontPx = TEXT_REFERENCE_PX * (node.box.height / node.naturalHeight);
|
|
||||||
if (fontPx < MIN_READABLE_TEXT_PX) {
|
|
||||||
throw new Error("This text doesn't fit this layout even at the smallest readable size — "
|
|
||||||
+ "try a shorter value, a wider tape, or a different layout.");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// "qrcode"/"empty" leaves have nothing left to check by this point.
|
|
||||||
}
|
|
||||||
|
|
||||||
/* A QR code needs an integer number of pixels per module to render crisply rather than blurring
|
/* A QR code needs an integer number of pixels per module to render crisply rather than blurring
|
||||||
at a fractional scale, so its true size is whatever that rounds down to - almost never the
|
at a fractional scale, so its true size is whatever that rounds down to - almost never the
|
||||||
scale-free square a bare aspect ratio of 1 would suggest. Called once every qrcode leaf has a
|
scale-free square a bare aspect ratio of 1 would suggest. Called once every qrcode leaf has a
|
||||||
|
|
@ -180,6 +180,12 @@ function snapQrToCrispSize(node) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Always measures in sans-serif at the fixed reference size, even though drawTextLeaf may end up
|
||||||
|
// actually drawing in one of PIXEL_FONT_TIERS' fonts - the final effective size (and so which
|
||||||
|
// font applies) isn't known until layoutTree has already sized the box this aspect ratio feeds
|
||||||
|
// into. The pixel fonts are close enough in proportion for basic Latin/digits that the tiny
|
||||||
|
// resulting mismatch is invisible in practice at these sizes, and the MIN_READABLE_TEXT_PX check
|
||||||
|
// still catches anything that genuinely doesn't fit.
|
||||||
function measureTextBlock(ctx, lines, referencePx) {
|
function measureTextBlock(ctx, lines, referencePx) {
|
||||||
ctx.font = `${referencePx}px sans-serif`;
|
ctx.font = `${referencePx}px sans-serif`;
|
||||||
const width = Math.max(...lines.map(line => ctx.measureText(line).width));
|
const width = Math.max(...lines.map(line => ctx.measureText(line).width));
|
||||||
|
|
@ -224,10 +230,28 @@ function drawQrLeaf(ctx, node) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Returns the effective font size drawn at (or that would have been, if it's too small to draw -
|
||||||
|
// see below) - drawTree collects these into drawLabel/drawFallbackLabel's textSizesPx.
|
||||||
function drawTextLeaf(ctx, node, referencePx) {
|
function drawTextLeaf(ctx, node, referencePx) {
|
||||||
const fontPx = referencePx * (node.box.height / node.naturalHeight);
|
const fontPx = referencePx * (node.box.height / node.naturalHeight);
|
||||||
|
// Even the smallest PIXEL_FONT_TIERS entry stops being legible below this - rather than
|
||||||
|
// reject the whole label over one field that's too small (the old behavior), just leave this
|
||||||
|
// leaf blank; its box was already accounted for, so nothing else in the layout shifts.
|
||||||
|
if (fontPx < MIN_READABLE_TEXT_PX) {
|
||||||
|
return fontPx;
|
||||||
|
}
|
||||||
const lineHeight = node.box.height / node.lines.length;
|
const lineHeight = node.box.height / node.lines.length;
|
||||||
ctx.font = `${fontPx}px sans-serif`;
|
const family = fontFamilyFor(fontPx);
|
||||||
|
ctx.font = `${fontPx}px "${family}"`;
|
||||||
|
// A @font-face family already in use elsewhere on the page loads in time for this, but canvas
|
||||||
|
// text silently falls back to the next font in the stack (there isn't one here, so the
|
||||||
|
// browser default) if drawn before its first-ever load finishes - unlike DOM text, a canvas
|
||||||
|
// fillText never waits or repaints on its own once the real font arrives. Kicking off the load
|
||||||
|
// here means only that very first draw at a given size risks the fallback; every redraw after
|
||||||
|
// it (Print.vue's live preview redraws on every keystroke) picks up the real font.
|
||||||
|
if (family !== "sans-serif") {
|
||||||
|
document.fonts.load(ctx.font);
|
||||||
|
}
|
||||||
ctx.textBaseline = "middle";
|
ctx.textBaseline = "middle";
|
||||||
ctx.textAlign = "center";
|
ctx.textAlign = "center";
|
||||||
const centerX = node.box.x + node.box.width / 2;
|
const centerX = node.box.x + node.box.width / 2;
|
||||||
|
|
@ -238,6 +262,7 @@ function drawTextLeaf(ctx, node, referencePx) {
|
||||||
ctx.fillText(line, centerX, y);
|
ctx.fillText(line, centerX, y);
|
||||||
y += lineHeight;
|
y += lineHeight;
|
||||||
}
|
}
|
||||||
|
return fontPx;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Flip this to true (in a debugger or a local edit) to outline every leaf's box - including
|
// Flip this to true (in a debugger or a local edit) to outline every leaf's box - including
|
||||||
|
|
@ -256,15 +281,19 @@ function drawDebugBorder(ctx, node) {
|
||||||
ctx.restore();
|
ctx.restore();
|
||||||
}
|
}
|
||||||
|
|
||||||
function drawTree(ctx, node, referencePx) {
|
// `textSizesPx` collects each "text" leaf's effective font size as drawTree walks the tree - see
|
||||||
|
// drawLabel/drawFallbackLabel, which hand it back to the caller (Print.vue shows it alongside the
|
||||||
|
// tape width) so a field rendering blank (see drawTextLeaf's MIN_READABLE_TEXT_PX check) shows up
|
||||||
|
// as a suspiciously small size here rather than just silently not being there.
|
||||||
|
function drawTree(ctx, node, referencePx, textSizesPx) {
|
||||||
if (isSplit(node)) {
|
if (isSplit(node)) {
|
||||||
node.forEach(child => drawTree(ctx, child, referencePx));
|
node.forEach(child => drawTree(ctx, child, referencePx, textSizesPx));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (node.type === "qrcode") {
|
if (node.type === "qrcode") {
|
||||||
drawQrLeaf(ctx, node);
|
drawQrLeaf(ctx, node);
|
||||||
} else if (node.type === "text") {
|
} else if (node.type === "text") {
|
||||||
drawTextLeaf(ctx, node, referencePx);
|
textSizesPx.push(drawTextLeaf(ctx, node, referencePx));
|
||||||
}
|
}
|
||||||
// "empty" leaves carry no ink - their box just reserves the space.
|
// "empty" leaves carry no ink - their box just reserves the space.
|
||||||
if (DEBUG_LEAF_BORDERS) {
|
if (DEBUG_LEAF_BORDERS) {
|
||||||
|
|
@ -298,12 +327,13 @@ function layoutContent(ctx, content, height, maxLength, referencePx, pxPerMm) {
|
||||||
+ "try a shorter value, a different layout, or a bigger label.");
|
+ "try a shorter value, a different layout, or a bigger label.");
|
||||||
}
|
}
|
||||||
layoutTree(tree, false, width, height, pxPerMm);
|
layoutTree(tree, false, width, height, pxPerMm);
|
||||||
assertLeavesFit(tree);
|
|
||||||
return {tree, width};
|
return {tree, width};
|
||||||
}
|
}
|
||||||
|
|
||||||
/* The tape-fed layout - draws a fully resolved content tree (see templateContent) at the tape's
|
/* The tape-fed layout - draws a fully resolved content tree (see templateContent) at the tape's
|
||||||
real pixel dimensions. See DEBUG_LEAF_BORDERS above to outline every leaf's box. */
|
real pixel dimensions. See DEBUG_LEAF_BORDERS above to outline every leaf's box. Returns
|
||||||
|
{textSizesPx}: each "text" leaf's effective font size, in the tree's own left-to-right,
|
||||||
|
top-to-bottom order. */
|
||||||
export function drawLabel(canvas, tape, content) {
|
export function drawLabel(canvas, tape, content) {
|
||||||
const maxLength = tape.printLengthPx
|
const maxLength = tape.printLengthPx
|
||||||
? tape.printLengthPx - tape.leadPx - TRAILING_PADDING_PX
|
? tape.printLengthPx - tape.leadPx - TRAILING_PADDING_PX
|
||||||
|
|
@ -324,7 +354,9 @@ export function drawLabel(canvas, tape, content) {
|
||||||
|
|
||||||
const originX = tape.leadPx + Math.floor((width - tape.leadPx - TRAILING_PADDING_PX - contentWidth) / 2);
|
const originX = tape.leadPx + Math.floor((width - tape.leadPx - TRAILING_PADDING_PX - contentWidth) / 2);
|
||||||
positionTree(tree, false, originX, 0);
|
positionTree(tree, false, originX, 0);
|
||||||
drawTree(ctx, tree, TEXT_REFERENCE_PX);
|
const textSizesPx = [];
|
||||||
|
drawTree(ctx, tree, TEXT_REFERENCE_PX, textSizesPx);
|
||||||
|
return {textSizesPx};
|
||||||
}
|
}
|
||||||
|
|
||||||
const FALLBACK_LABEL_HEIGHT_PX = 200; /* reference height the no-webusb preview/PNG scales from */
|
const FALLBACK_LABEL_HEIGHT_PX = 200; /* reference height the no-webusb preview/PNG scales from */
|
||||||
|
|
@ -333,7 +365,7 @@ const FALLBACK_DPI = 203; /* reference resolution for turning "empty" leaves' m
|
||||||
/* The no-webusb preview/PNG - same layout tree and renderer as drawLabel, just scaled from a
|
/* The no-webusb preview/PNG - same layout tree and renderer as drawLabel, just scaled from a
|
||||||
fixed reference height instead of a real tape's, and with no maxLength (there's no physical
|
fixed reference height instead of a real tape's, and with no maxLength (there's no physical
|
||||||
tape to run out of, so the canvas just grows to fit) and no printer feed margin, since there's
|
tape to run out of, so the canvas just grows to fit) and no printer feed margin, since there's
|
||||||
no real print head here to keep clear of. */
|
no real print head here to keep clear of. Returns {textSizesPx}, see drawLabel. */
|
||||||
export function drawFallbackLabel(canvas, content) {
|
export function drawFallbackLabel(canvas, content) {
|
||||||
const measureCtx = canvas.getContext("2d");
|
const measureCtx = canvas.getContext("2d");
|
||||||
const pxPerMm = FALLBACK_DPI / 25.4;
|
const pxPerMm = FALLBACK_DPI / 25.4;
|
||||||
|
|
@ -349,7 +381,9 @@ export function drawFallbackLabel(canvas, content) {
|
||||||
ctx.fillStyle = "#000";
|
ctx.fillStyle = "#000";
|
||||||
|
|
||||||
positionTree(tree, false, 0, 0);
|
positionTree(tree, false, 0, 0);
|
||||||
drawTree(ctx, tree, TEXT_REFERENCE_PX);
|
const textSizesPx = [];
|
||||||
|
drawTree(ctx, tree, TEXT_REFERENCE_PX, textSizesPx);
|
||||||
|
return {textSizesPx};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Turns a {kind, components} prefill (see Print.vue's `prefill` prop) into the literal string a
|
// Turns a {kind, components} prefill (see Print.vue's `prefill` prop) into the literal string a
|
||||||
|
|
@ -360,7 +394,7 @@ export const LABEL_CONTENT_BUILDERS = {
|
||||||
// The self-contained Item URL (see docs/design-in-progress/items-labels.md) - what a
|
// The self-contained Item URL (see docs/design-in-progress/items-labels.md) - what a
|
||||||
// printed label actually encodes, since scanning it has to resolve the right
|
// printed label actually encodes, since scanning it has to resolve the right
|
||||||
// frontend/backend/item with no other context, not just this browser's history.
|
// frontend/backend/item with no other context, not just this browser's history.
|
||||||
"item-url": ({user, id}) => `${window.location.origin}/i/${user}/${id}`,
|
"item-url": ({user, id}) => `${window.location.origin}/i/${encodeHandleForUrl(user)}/${id}`,
|
||||||
};
|
};
|
||||||
|
|
||||||
export function buildLabelContent(prefill) {
|
export function buildLabelContent(prefill) {
|
||||||
|
|
@ -378,11 +412,20 @@ export function buildLabelContent(prefill) {
|
||||||
// (rather than present-but-empty) is what label-layouts.js's templateIsAvailable treats as "not
|
// (rather than present-but-empty) is what label-layouts.js's templateIsAvailable treats as "not
|
||||||
// available", so builders should only include a field once its inputs actually check out.
|
// available", so builders should only include a field once its inputs actually check out.
|
||||||
const LABEL_FIELD_BUILDERS = {
|
const LABEL_FIELD_BUILDERS = {
|
||||||
|
// `user` here is already a full "user@domain" handle (that's the form login usernames take -
|
||||||
|
// see Login.vue/store.js), so it's split into label-layouts.js's separate `user`/`domain`
|
||||||
|
// base vars the same way store.js's own lookupServer does, rather than stuffing the whole
|
||||||
|
// handle into one field the way userHandle (now derived from these two) used to be.
|
||||||
"item-url": ({user, id}) => {
|
"item-url": ({user, id}) => {
|
||||||
if (!user || !id) {
|
if (!user || !id) {
|
||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
return {userHandle: user, itemId: String(id)};
|
const at = user.indexOf("@");
|
||||||
|
return {
|
||||||
|
user: at === -1 ? user : user.slice(0, at),
|
||||||
|
domain: at === -1 ? "" : user.slice(at + 1),
|
||||||
|
itemId: String(id),
|
||||||
|
};
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
21
frontend/src/scss/_pixel-fonts.scss
Normal file
21
frontend/src/scss/_pixel-fonts.scss
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
// Specialized bitmap-style fonts label.js's drawTextLeaf switches to below an effective text
|
||||||
|
// size of 10px, where a general-purpose sans-serif gets blurry/illegible - each is designed for
|
||||||
|
// (and named after) roughly the pixel size it's used at. See
|
||||||
|
// ../assets/fonts/pixel/LICENSE.md for sources/licenses.
|
||||||
|
@font-face {
|
||||||
|
font-family: "Tom Thumb";
|
||||||
|
src: url("../assets/fonts/pixel/TomThumb.ttf") format("truetype");
|
||||||
|
font-display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: "PICO-8";
|
||||||
|
src: url("../assets/fonts/pixel/PICO-8.ttf") format("truetype");
|
||||||
|
font-display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: "Silkscreen";
|
||||||
|
src: url("../assets/fonts/pixel/Silkscreen-Regular.woff2") format("woff2");
|
||||||
|
font-display: block;
|
||||||
|
}
|
||||||
|
|
@ -92,6 +92,7 @@ $body-color: $gray-700;
|
||||||
@import "forms";
|
@import "forms";
|
||||||
@import "tags";
|
@import "tags";
|
||||||
@import "dropdown";
|
@import "dropdown";
|
||||||
|
@import "pixel-fonts";
|
||||||
|
|
||||||
#root, body, html {
|
#root, body, html {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
|
|
|
||||||
|
|
@ -20,9 +20,9 @@
|
||||||
</div>
|
</div>
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<div class="row g-3">
|
<div class="row g-3">
|
||||||
<div :class="v === 'value' ? 'col-12' : 'col-md-6'" v-for="v in baseVars" :key="v">
|
<div :class="v === 'text' ? 'col-12' : 'col-md-6'" v-for="v in baseVars" :key="v">
|
||||||
<label class="form-label">{{ varLabel(v) }}</label>
|
<label class="form-label">{{ varLabel(v) }}</label>
|
||||||
<textarea v-if="v === 'value'" class="form-control" rows="3"
|
<textarea v-if="v === 'text'" class="form-control" rows="3"
|
||||||
v-model="varValues[v]"
|
v-model="varValues[v]"
|
||||||
placeholder="https://example.com/…"></textarea>
|
placeholder="https://example.com/…"></textarea>
|
||||||
<input v-else type="text" class="form-control" v-model="varValues[v]">
|
<input v-else type="text" class="form-control" v-model="varValues[v]">
|
||||||
|
|
@ -88,7 +88,7 @@
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="card-header d-flex justify-content-between align-items-center">
|
<div class="card-header d-flex justify-content-between align-items-center">
|
||||||
<h5 class="card-title mb-0">Label</h5>
|
<h5 class="card-title mb-0">Label</h5>
|
||||||
<small v-if="tape" class="text-muted">{{ tape.mediaWidthMm }} mm tape</small>
|
<small v-if="tape" class="text-muted">{{ tape.mediaWidthMm }} mm tape{{ textSizesSummary }}</small>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<p v-if="!tape" class="text-muted">
|
<p v-if="!tape" class="text-muted">
|
||||||
|
|
@ -99,7 +99,7 @@
|
||||||
<div class="ruler-v">
|
<div class="ruler-v">
|
||||||
<div class="ruler-v-corner"></div>
|
<div class="ruler-v-corner"></div>
|
||||||
<div class="ruler ruler-v-ticks"
|
<div class="ruler ruler-v-ticks"
|
||||||
:style="{height: (tape.printAreaPx * zoom) + 'px'}">
|
:style="{height: (tape.mediaWidthMm * tapePxPerMm) + 'px'}">
|
||||||
<span v-for="t in verticalRulerTicks" :key="'tick-' + t.mm"
|
<span v-for="t in verticalRulerTicks" :key="'tick-' + t.mm"
|
||||||
class="tick" :class="{'tick-major': t.major}"
|
class="tick" :class="{'tick-major': t.major}"
|
||||||
:style="{top: t.pos + 'px'}"></span>
|
:style="{top: t.pos + 'px'}"></span>
|
||||||
|
|
@ -108,7 +108,7 @@
|
||||||
:style="{top: t.pos + 'px'}">{{ t.mm }}</span>
|
:style="{top: t.pos + 'px'}">{{ t.mm }}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="preview-scroll">
|
<div class="preview-track">
|
||||||
<div class="ruler ruler-h"
|
<div class="ruler ruler-h"
|
||||||
:style="{width: (printedWidthPx * zoom) + 'px'}">
|
:style="{width: (printedWidthPx * zoom) + 'px'}">
|
||||||
<span v-for="t in horizontalRulerTicks" :key="'tick-' + t.mm"
|
<span v-for="t in horizontalRulerTicks" :key="'tick-' + t.mm"
|
||||||
|
|
@ -118,8 +118,15 @@
|
||||||
:key="'label-' + t.mm" class="tick-label"
|
:key="'label-' + t.mm" class="tick-label"
|
||||||
:style="{left: t.pos + 'px'}">{{ t.mm }}</span>
|
:style="{left: t.pos + 'px'}">{{ t.mm }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="label-preview">
|
<!-- The tape's full physical width, printable area included - the
|
||||||
<canvas ref="labelCanvas"></canvas>
|
print head can't mark all the way to the tape's outer edges, so
|
||||||
|
the canvas (printAreaPx tall) is narrower than this and centered
|
||||||
|
within it; the rest is real, if unprintable, tape margin. -->
|
||||||
|
<div class="tape-full"
|
||||||
|
:style="{height: (tape.mediaWidthMm * tapePxPerMm) + 'px'}">
|
||||||
|
<div class="label-preview">
|
||||||
|
<canvas ref="labelCanvas"></canvas>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -207,8 +214,17 @@ const BLOB_URL = "/vendor/libweblabel.js";
|
||||||
|
|
||||||
const MAX_ZOOM = 4; /* never magnify the preview more than this */
|
const MAX_ZOOM = 4; /* never magnify the preview more than this */
|
||||||
const MAX_PREVIEW_HEIGHT_PX = 300; /* never let the on-screen preview grow taller than this */
|
const MAX_PREVIEW_HEIGHT_PX = 300; /* never let the on-screen preview grow taller than this */
|
||||||
const RULER_TICK_MM = 1; /* finest tick spacing the mm ruler draws */
|
// How far apart plain and labeled/major ticks sit, both coarser the longer the ruler itself runs
|
||||||
const RULER_MAJOR_EVERY_MM = 5; /* every Nth tick is taller and labeled with its mm value */
|
// - tightly spaced ticks (and their labels) get too cramped to read/render once there are enough
|
||||||
|
// of them. Ordered smallest threshold first; rulerTicks below uses the last entry whose `aboveMm`
|
||||||
|
// the ruler's own length clears, so add a finer/coarser tier here rather than growing a pile of
|
||||||
|
// separate constants. Every tier's majorEveryMm is a multiple of its own tickMm, so major ticks
|
||||||
|
// always land on a tick that's actually drawn.
|
||||||
|
const RULER_TIERS = [
|
||||||
|
{aboveMm: 0, tickMm: 1, majorEveryMm: 5},
|
||||||
|
{aboveMm: 100, tickMm: 1, majorEveryMm: 10},
|
||||||
|
{aboveMm: 500, tickMm: 5, majorEveryMm: 25},
|
||||||
|
];
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
name: "Print",
|
name: "Print",
|
||||||
|
|
@ -244,17 +260,21 @@ export default {
|
||||||
// either one changes.
|
// either one changes.
|
||||||
zoom: 1,
|
zoom: 1,
|
||||||
printedWidthPx: 0,
|
printedWidthPx: 0,
|
||||||
|
// Each "text" leaf's effective font size in the current render (see label.js's
|
||||||
|
// drawLabel) - shown alongside the tape width so a field rendering blank (too small
|
||||||
|
// even for the smallest pixel font) shows up as a suspiciously tiny number here rather
|
||||||
|
// than just silently not being there.
|
||||||
|
textSizesPx: [],
|
||||||
|
|
||||||
// One input per *base* template variable (see label-layouts.js's BASE_VARS) - the
|
// One input per *base* template variable (see label-layouts.js's BASE_VARS) - the
|
||||||
// derived ones (itemUrl, itemHandle) are format strings calculated from these, not
|
// derived ones (userHandle, itemUrl, itemHandle) are format strings calculated from
|
||||||
// typed directly, so they're only ever shown (see the `fields` computed below), never
|
// these, not typed directly, so they're only ever shown (see the `fields` computed
|
||||||
// stored here. Prefilled from the ?kind=…&… query params where
|
// below), never stored here. Prefilled from the ?kind=…&… query params where
|
||||||
// buildLabelContent/buildLabelFields have a value for them, editable from there so a
|
// buildLabelContent/buildLabelFields have a value for them, editable from there so a
|
||||||
// template needing e.g. userHandle isn't stuck depending on a prefill that never
|
// template needing e.g. domain isn't stuck depending on a prefill that never arrives.
|
||||||
// arrives.
|
|
||||||
varValues: {
|
varValues: {
|
||||||
...Object.fromEntries(BASE_VARS.map(v => [v, ""])),
|
...Object.fromEntries(BASE_VARS.map(v => [v, ""])),
|
||||||
value: buildLabelContent(this.prefill),
|
text: buildLabelContent(this.prefill),
|
||||||
// Defaults to wherever this page itself is being served from - editable since any
|
// Defaults to wherever this page itself is being served from - editable since any
|
||||||
// frontend can resolve any handle (see label-layouts.js's DERIVED_VARS.itemUrl),
|
// frontend can resolve any handle (see label-layouts.js's DERIVED_VARS.itemUrl),
|
||||||
// so a label doesn't have to point back at this particular one.
|
// so a label doesn't have to point back at this particular one.
|
||||||
|
|
@ -327,17 +347,46 @@ export default {
|
||||||
tapePxPerMm() {
|
tapePxPerMm() {
|
||||||
return this.tape ? (this.tape.dpi / 25.4) * this.zoom : 0;
|
return this.tape ? (this.tape.dpi / 25.4) * this.zoom : 0;
|
||||||
},
|
},
|
||||||
|
// The physical length, in mm, each ruler axis actually needs to cover - see
|
||||||
|
// horizontalRulerTicks/verticalRulerTicks below for what each one measures and why.
|
||||||
|
horizontalTotalMm() {
|
||||||
|
return (this.tape && this.printedWidthPx) ? this.printedWidthPx / (this.tape.dpi / 25.4) : 0;
|
||||||
|
},
|
||||||
|
verticalTotalMm() {
|
||||||
|
return this.tape ? this.tape.mediaWidthMm : 0;
|
||||||
|
},
|
||||||
|
// The single RULER_TIERS entry both rulers draw from, keyed off whichever axis is
|
||||||
|
// physically longer - so a long label's ruler doesn't end up coarser (or finer) than the
|
||||||
|
// tape-width ruler right next to it just because the other axis happens to be shorter.
|
||||||
|
rulerTier() {
|
||||||
|
return RULER_TIERS.filter(t => Math.max(this.horizontalTotalMm, this.verticalTotalMm) >= t.aboveMm)
|
||||||
|
.at(-1);
|
||||||
|
},
|
||||||
// Ticks along the tape's length (the printed bitmap's actual width, lead/trailing feed
|
// Ticks along the tape's length (the printed bitmap's actual width, lead/trailing feed
|
||||||
// margin included, since that's real physical tape too).
|
// margin included, since that's real physical tape too).
|
||||||
horizontalRulerTicks() {
|
horizontalRulerTicks() {
|
||||||
if (!this.tape || !this.printedWidthPx) {
|
if (!this.tape || !this.printedWidthPx) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
return this.rulerTicks(this.printedWidthPx / (this.tape.dpi / 25.4));
|
return this.rulerTicks(this.horizontalTotalMm);
|
||||||
},
|
},
|
||||||
// Ticks across the tape's fixed physical width.
|
// Ticks across the tape's full physical width, mediaWidthMm - not printAreaPx/dpi: a
|
||||||
|
// print head can't reach the tape's outer edges, so the printable area (see .tape-full in
|
||||||
|
// the template) is genuinely narrower than the tape itself, by an amount that isn't a
|
||||||
|
// fixed/predictable fraction of it. The ruler still has to show the *whole* tape - its
|
||||||
|
// container is sized from mediaWidthMm too (see the template's inline height) precisely so
|
||||||
|
// these ticks can't run past it, the way they did when both were sized from printAreaPx.
|
||||||
verticalRulerTicks() {
|
verticalRulerTicks() {
|
||||||
return this.tape ? this.rulerTicks(this.tape.mediaWidthMm) : [];
|
return this.tape ? this.rulerTicks(this.verticalTotalMm) : [];
|
||||||
|
},
|
||||||
|
// "(5px, 23px)" for the current render's text leaves (see data's textSizesPx), or "" once
|
||||||
|
// there's nothing to show - appended straight onto the tape-width <small>, so the blank
|
||||||
|
// string here just means that text is left with no trailing space.
|
||||||
|
textSizesSummary() {
|
||||||
|
if (!this.textSizesPx.length) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
return ` (${this.textSizesPx.map(px => Math.round(px) + "px").join(", ")})`;
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
watch: {
|
watch: {
|
||||||
|
|
@ -377,12 +426,15 @@ export default {
|
||||||
return v.replace(/([a-z])([A-Z])/g, "$1 $2").replace(/^./, c => c.toUpperCase());
|
return v.replace(/([a-z])([A-Z])/g, "$1 $2").replace(/^./, c => c.toUpperCase());
|
||||||
},
|
},
|
||||||
|
|
||||||
// One tick per RULER_TICK_MM from 0 up to totalMm, each positioned in on-screen pixels
|
// Ticks from 0 up to totalMm, each positioned in on-screen pixels via tapePxPerMm - shared
|
||||||
// via tapePxPerMm - shared by the horizontal/vertical ruler computeds above.
|
// by the horizontal/vertical ruler computeds above. Both the plain tick spacing and the
|
||||||
|
// labeled/major one come from the shared rulerTier (see above), not from this totalMm, so
|
||||||
|
// both rulers always coarsen together once *either* axis is long enough to need it.
|
||||||
rulerTicks(totalMm) {
|
rulerTicks(totalMm) {
|
||||||
|
const {tickMm, majorEveryMm} = this.rulerTier;
|
||||||
const ticks = [];
|
const ticks = [];
|
||||||
for (let mm = 0; mm <= totalMm; mm += RULER_TICK_MM) {
|
for (let mm = 0; mm <= totalMm; mm += tickMm) {
|
||||||
ticks.push({mm, pos: mm * this.tapePxPerMm, major: mm % RULER_MAJOR_EVERY_MM === 0});
|
ticks.push({mm, pos: mm * this.tapePxPerMm, major: mm % majorEveryMm === 0});
|
||||||
}
|
}
|
||||||
return ticks;
|
return ticks;
|
||||||
},
|
},
|
||||||
|
|
@ -463,13 +515,15 @@ export default {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.resizeObserver.observe(canvas.parentElement);
|
this.resizeObserver.observe(canvas.parentElement);
|
||||||
|
let textSizesPx;
|
||||||
try {
|
try {
|
||||||
drawLabel(canvas, this.tape, content);
|
({textSizesPx} = drawLabel(canvas, this.tape, content));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
this.error = e.message;
|
this.error = e.message;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.error = null;
|
this.error = null;
|
||||||
|
this.textSizesPx = textSizesPx;
|
||||||
const bitmap = canvasToBitmap(canvas);
|
const bitmap = canvasToBitmap(canvas);
|
||||||
bitmapToCanvas(canvas, bitmap);
|
bitmapToCanvas(canvas, bitmap);
|
||||||
this.labelBitmap = bitmap;
|
this.labelBitmap = bitmap;
|
||||||
|
|
@ -593,7 +647,7 @@ export default {
|
||||||
.label-preview {
|
.label-preview {
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
padding: .75rem;
|
padding: .75rem;
|
||||||
background: rgba(127, 127, 127, .08);
|
//background: rgba(127, 127, 127, .08);
|
||||||
border-radius: .35rem;
|
border-radius: .35rem;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
max-height: 300px;
|
max-height: 300px;
|
||||||
|
|
@ -602,7 +656,7 @@ export default {
|
||||||
.label-preview canvas {
|
.label-preview canvas {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
background: #fff;
|
background: #fff;
|
||||||
box-shadow: 0 0 0 1px rgba(127, 127, 127, .5);
|
//box-shadow: 0 0 0 1px rgba(127, 127, 127, .5);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* The tape-fed preview's mm ruler (see Print.vue's template/script) - a horizontal track above
|
/* The tape-fed preview's mm ruler (see Print.vue's template/script) - a horizontal track above
|
||||||
|
|
@ -613,22 +667,38 @@ export default {
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* The single horizontal scroll region for a label wider than its card - the horizontal ruler and
|
/* Holds the horizontal ruler and the canvas - deliberately never scrollable (no overflow-x:auto):
|
||||||
the canvas are both direct children of this, so they scroll in lockstep with no JS needed to
|
fitZoom's zoom always satisfies `canvas.width * zoom <= available`, so the canvas can never
|
||||||
keep them in sync. */
|
actually be wider than this has room for, and a scrollbar here would let the ruler and canvas
|
||||||
.preview-scroll {
|
drift apart (or just look broken) for no reason. min-width:0 only lets this flex item shrink
|
||||||
|
to the card's real available width - it doesn't enable scrolling. */
|
||||||
|
.preview-track {
|
||||||
flex: 1 1 auto;
|
flex: 1 1 auto;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
overflow-x: auto;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Overrides the standalone rule above: nested here, .label-preview must neither clip nor center
|
/* Overrides the standalone rule above: nested here, .label-preview must neither scroll nor center
|
||||||
its canvas - overflow-x:visible lets the canvas's true width "bubble up" to .preview-scroll's
|
its canvas - overflow-x:visible (never auto) rules out a second, inner scrollbar, and
|
||||||
own scrollbar instead of scrolling twice, and text-align:left keeps the canvas flush with the
|
text-align:left keeps the canvas flush with the ruler's zero tick instead of drifting to the
|
||||||
ruler's zero tick instead of drifting to the middle of whatever spare width this card has. */
|
middle of whatever spare width this card has. padding:0 so the canvas's own edges are exactly
|
||||||
.preview-scroll .label-preview {
|
this box's edges too - the ruler's ticks (see .ruler-h/.ruler-v-ticks below) line up with those
|
||||||
|
same edges, so any padding here would leave the ticks and the actual canvas misaligned. */
|
||||||
|
.preview-track .label-preview {
|
||||||
overflow-x: visible;
|
overflow-x: visible;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The tape's full physical width (see the template) - a print head can't mark all the way to a
|
||||||
|
tape's outer edges, so .label-preview/the canvas is narrower than this and centered within it
|
||||||
|
(the print area sits centered on the tape, with equal margin on both sides); a faint tint
|
||||||
|
distinguishes the margin as real (if blank, unprintable) tape rather than empty space. */
|
||||||
|
.tape-full {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
background: rgba(127, 127, 127, .08);
|
||||||
|
border-radius: .35rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ruler-v {
|
.ruler-v {
|
||||||
|
|
@ -645,13 +715,11 @@ export default {
|
||||||
|
|
||||||
.ruler-v-ticks {
|
.ruler-v-ticks {
|
||||||
position: relative;
|
position: relative;
|
||||||
margin-top: .75rem; /* .label-preview's own padding, so tick 0 meets the canvas, not its box */
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.ruler-h {
|
.ruler-h {
|
||||||
position: relative;
|
position: relative;
|
||||||
height: 1.6rem;
|
height: 1.6rem;
|
||||||
margin-left: .75rem; /* .label-preview's own padding, so tick 0 meets the canvas, not its box */
|
|
||||||
color: rgba(127, 127, 127, .9);
|
color: rgba(127, 127, 127, .9);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue