This commit is contained in:
j3d1 2026-08-24 15:57:17 +02:00
parent 8d96bc97c4
commit ed04d98bf1
54 changed files with 661 additions and 1214 deletions

View file

@ -163,10 +163,7 @@
:key="'label-' + t.mm" class="tick-label"
:style="{left: t.pos + 'px'}">{{ t.mm }}</span>
</div>
<!-- The tape's full physical width, printable area included - the
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. -->
<!-- Tape's full physical width; canvas is narrower/centered, margin is real tape. See docs/implementation.md#tape-full-print-margin. -->
<div class="tape-full"
:style="{height: (tape.mediaWidthMm * tapePxPerMm) + 'px'}">
<div class="label-preview">
@ -248,6 +245,7 @@
<div class="row">
<div class="col-12">
<label-layout-preview :fields="fields" :value="selectedTemplate"
:recent-template-ids="recentTemplateIds"
@input="selectedTemplate = $event"></label-layout-preview>
</div>
</div>
@ -268,33 +266,23 @@ import {tapeFromStatus, drawLabel, drawFallbackLabel, buildLabelContent, buildLa
import {LABEL_TEMPLATES, BASE_VARS, DERIVED_VARS, withDerivedVars, templateContent} from "@/label-layouts.js";
import {shortenedRoute} from "@/router";
// The extra "Calculated" fields (and label-layouts.js "Short link (QR code)" template input) this
// view adds on top of label-layouts.js's own DERIVED_VARS - resolving either needs the current
// identityIdByHandle map (see store.js's fetchIdMap) to turn a handle into the numeric
// owner_identity_id short-id.js's 'item'/'storage_location' kinds encode, so neither can be a pure
// fields->value calc like the others and both live here instead of in label-layouts.js. Excluded
// from baseVars below since, unlike every other entry KNOWN_VARS picks up from a template's
// required_vars, neither is ever typed directly.
// Print.vue-local calculated fields on top of label-layouts.js's DERIVED_VARS. See
// docs/implementation.md#calculated-short-link-fields.
const SHORT_URL_VAR = "shortUrl";
// The bare short-id.js token itself (e.g. "~AbCd12"), with no domain or leading "/" - what
// shortUrl's own path is built from (see fields()/the shortId method below), for a label that
// wants just the compact code rather than a full scannable URL.
// See docs/implementation.md#calculated-short-link-fields.
const SHORT_ID_VAR = "shortId";
// Served verbatim from public/vendor/ rather than bundled: libweblabel.js's
// own emscripten glue resolves its .wasm sibling relative to *its own*
// import.meta.url at runtime, so both files need to keep sitting together,
// unhashed, at a stable URL - not a Vite-fingerprinted asset path.
// Served unbundled so its wasm sibling stays resolvable. See docs/implementation.md#libweblabel-served-unbundled.
const BLOB_URL = "/vendor/libweblabel.js";
// localStorage key for the most-recently-printed template ids (see rememberPrintedTemplate/loadRecentTemplateIds), same naming style as cameraManager.js's recentCameraIds.
const RECENT_TEMPLATES_KEY = "recentLabelTemplateIds";
// How many recently-printed templates LabelLayoutPreview.vue bubbles to the front of the grid.
const MAX_RECENT_TEMPLATES = 4;
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 */
// How far apart plain and labeled/major ticks sit, both coarser the longer the ruler itself runs
// - 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.
// Tick spacing tiers, coarser the longer the ruler runs. See docs/implementation.md#ruler-tier-selection.
const RULER_TIERS = [
{aboveMm: 0, tickMm: 1, majorEveryMm: 5},
{aboveMm: 100, tickMm: 1, majorEveryMm: 10},
@ -309,10 +297,7 @@ export default {
...BIcons
},
props: {
// {kind, components} prefilled from the ?kind=& query params when arriving from e.g.
// an item's "Print label" button (see InventoryDetail.vue) - the router turns those
// query params into this prop (router.js's /print route), rather than the component
// reading $route directly. buildLabelContent turns it into the literal string below.
// {kind, components} from the ?kind=& query params (router.js's /print route builds this prop); buildLabelContent turns it into the text field below.
prefill: {
type: Object,
default: null
@ -329,40 +314,25 @@ export default {
connected: null,
tape: null,
labelBitmap: null,
// The tape-fed preview's current on-screen scale and printed pixel width (see
// fitZoom/redraw) - tracked reactively, rather than read straight off the canvas
// element, purely so the mm ruler below can recompute its tick positions whenever
// either one changes.
// Tracked reactively (not read off the canvas) so the mm ruler can recompute tick positions when either changes (see fitZoom/redraw).
zoom: 1,
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.
// Each "text" leaf's effective font size (see label.js's drawLabel), shown beside the tape width so a too-small-to-render field reads as a suspiciously tiny number rather than silently absent.
textSizesPx: [],
// One input per *base* template variable (see label-layouts.js's BASE_VARS) - the
// derived ones (userHandle, itemUrl, itemHandle) are format strings calculated from
// these, not typed directly, so they're only ever shown (see the `fields` computed
// below), never stored here. Prefilled from the ?kind=& query params where
// buildLabelContent/buildLabelFields have a value for them, editable from there so a
// template needing e.g. domain isn't stuck depending on a prefill that never arrives.
// One input per BASE_VARS entry; derived vars (userHandle/itemUrl/itemHandle) are calculated-only (see the `fields` computed), never stored here. Prefilled from query params but left editable.
varValues: {
...Object.fromEntries(BASE_VARS.map(v => [v, ""])),
text: buildLabelContent(this.prefill),
// 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),
// so a label doesn't have to point back at this particular one.
// Defaults to this page's own origin; editable since any frontend can resolve any handle, so a label needn't point back at this one.
webdomain: window.location.origin,
...buildLabelFields(this.prefill),
},
copies: 1,
selectedTemplate: LABEL_TEMPLATES[0].id,
// "along" draws a layout reading along the tape's feed direction (the usual case -
// constrained by the tape's cross-web width, growing as long as the content needs);
// "across" turns it 90deg, constrained by that same width but along the *other* axis
// instead, so it reads across the tape rather than along it. See label.js's
// drawLabel/drawFallbackLabel for how that turn is actually drawn.
// Ids of the last MAX_RECENT_TEMPLATES distinct templates printed/downloaded, most recent first. See rememberPrintedTemplate/loadRecentTemplateIds.
recentTemplateIds: [],
// "along" reads along the tape's feed direction (usual case, width-constrained); "across" turns 90deg on that same width instead. See label.js's drawLabel/drawFallbackLabel.
orientation: "along",
fallbackReady: false,
@ -370,11 +340,7 @@ export default {
brotherCliExample: "brother_ql --model QL-000 --printer usb://0000:0000 print --label 00 label.png",
niimbotCliExample: "niimprint --model b00 --conn usb print --density 3 --image label.png",
// Nine pixel/bitmap-style font candidates found in frontend/public (see
// ../assets/fonts/pixel-candidates/LICENSE.md) - none of these are used by label.js's
// real PIXEL_FONT_TIERS; this card exists purely so they can be judged the same way
// Tom Thumb/Silkscreen were, against real (live-typed) content instead of a fixed
// example.
// Nine pixel/bitmap font candidates for legibility testing only (see the disabled card above); not used by label.js's real PIXEL_FONT_TIERS.
candidateFonts: [
{family: "Pixelon"},
{family: "Pixelbasel"},
@ -392,27 +358,16 @@ export default {
},
computed: {
...mapGetters(["identityIdByHandle"]),
// The base variables the "Label content" form renders an input for, and the derived ones
// it instead calculates and lists read-only beside that form - plain passthroughs, but
// keep the template from importing label-layouts.js just for these. SHORT_URL_VAR is
// excluded here even though the "Short link (QR code)" template's required_vars puts it in
// BASE_VARS (it isn't a label-layouts.js DERIVED_VARS entry) - see fields() below for why
// it's calculated, not typed. SHORT_ID_VAR isn't referenced by any template's
// required_vars (so isn't actually in BASE_VARS today), filtered out too in case one ever
// is.
// Plain passthroughs of the form's base/derived vars, keeping the template from importing
// label-layouts.js just for these. SHORT_URL_VAR/SHORT_ID_VAR exclusion: see
// docs/implementation.md#calculated-short-link-fields.
baseVars() {
return BASE_VARS.filter(v => v !== SHORT_URL_VAR && v !== SHORT_ID_VAR);
},
derivedVars() {
return [...Object.keys(DERIVED_VARS), SHORT_ID_VAR, SHORT_URL_VAR];
},
// Named content fields the templates draw from: the form's own base vars, plus every
// DERIVED_VARS format string calculated live from those - so typing a userHandle and
// itemId (whether by hand or via prefill) recalculates itemUrl/itemHandle the same way
// either way. A blank/uncalculated value is dropped rather than passed through as an
// empty string, so it reads as *absent* to templateIsAvailable/templateContent the same
// way a prefill that never supplied it would - that's what LabelLayoutPreview.vue greys a
// template's thumbnail out on.
// Named content fields the templates draw from, dropping blank values. See docs/implementation.md#fields-computed-dropping-blank-values.
fields() {
const base = {};
for (const v of BASE_VARS) {
@ -454,48 +409,34 @@ export default {
canPrint() {
return Boolean(this.tape && this.labelBitmap && !this.busy);
},
// On-screen pixels per real millimeter of tape, at the preview's current zoom - what
// turns a physical mm into a tick position the ruler can actually draw. Only meaningful
// for the tape-fed preview (see redraw's printedWidthPx) - the no-webusb fallback preview
// isn't fed from any particular real tape/dpi, so it gets no ruler (see the template).
// px per real mm at current zoom; meaningful only for the tape-fed preview (the no-webusb fallback has no real tape/dpi, so no ruler).
tapePxPerMm() {
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.
// Physical mm length each ruler axis must cover; see horizontal/verticalRulerTicks for what each measures.
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.
// Shared tier keyed off whichever axis is longer. See docs/implementation.md#ruler-tier-selection.
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
// margin included, since that's real physical tape too).
// Ticks along the tape's printed length, feed margins included (still real tape).
horizontalRulerTicks() {
if (!this.tape || !this.printedWidthPx) {
return [];
}
return this.rulerTicks(this.horizontalTotalMm);
},
// 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.
// Ticks across the tape's full width, not just the printable area. See docs/implementation.md#tape-full-print-margin.
verticalRulerTicks() {
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.
// e.g. "(5px, 23px)", or "" so it appends cleanly onto the tape-width <small> with nothing shown.
textSizesSummary() {
if (!this.textSizesPx.length) {
return "";
@ -529,10 +470,7 @@ export default {
this.redrawFallback();
}
},
// Covers connecting/disconnecting/switching printers - anything that changes the
// tape dimensions redraw() sizes the canvas from. flush: 'post' because the canvas
// itself only exists once `tape` is truthy (see the v-if/v-else in the template), so
// this has to run after Vue has actually mounted it, not before.
// Catches printer connect/disconnect/switch; flush:'post' since the canvas only exists once `tape` is truthy (template's v-if).
tape: {
handler() {
this.redraw();
@ -543,24 +481,12 @@ export default {
methods: {
...mapActions(["fetchIdMap"]),
// Turns a camelCase variable name (see label-layouts.js's KNOWN_VARS) into a form label,
// e.g. "itemHandle" -> "Item Handle" - so adding a new template variable doesn't also
// require hand-writing a label for it here.
// camelCase -> Title Case (e.g. "itemHandle" -> "Item Handle") so a new template var needs no hand-written label.
varLabel(v) {
return v.replace(/([a-z])([A-Z])/g, "$1 $2").replace(/^./, c => c.toUpperCase());
},
// The same shortened link Inventory.vue/StorageLocation.vue's own shortIdLink builds for
// one of their rows, but as the bare token (see short-id.js's encodeShortId) rather than a
// router target or full URL - shortenedRoute's own leading "/" is stripped since this is a
// plain display field/potential label content, not something this view itself navigates
// to; fields() below reattaches a domain and slash to build shortUrl from this same value,
// so the two can never disagree. Which short-id.js kind applies depends on which id field
// the current prefill's LABEL_FIELD_BUILDERS populated (see label.js) - itemId for an
// item, locationId for a storage location - rather than trusting the prefill's own `kind`
// directly, so hand-typing userHandle+itemId with no prefill at all still resolves this
// the same way. Falls back to no value - same as an unresolved DERIVED_VARS entry - until
// identityIdByHandle has loaded (see mounted's fetchIdMap) or if the handle isn't in it.
// Builds the bare short-id.js token for the current fields. See docs/implementation.md#shortid-resolution.
shortId(f) {
if (!f.userHandle) {
return null;
@ -580,10 +506,7 @@ export default {
return null;
},
// Ticks from 0 up to totalMm, each positioned in on-screen pixels via tapePxPerMm - shared
// 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.
// Ticks 0..totalMm via tapePxPerMm, shared by both ruler computeds; spacing comes from the shared rulerTier, so both rulers coarsen together.
rulerTicks(totalMm) {
const {tickMm, majorEveryMm} = this.rulerTier;
const ticks = [];
@ -593,6 +516,31 @@ export default {
return ticks;
},
// Reads the recently-printed template ids back from localStorage; same try/catch shape as
// cameraManager.js's getRecentCameras since either a disabled/full localStorage shouldn't
// break printing.
loadRecentTemplateIds() {
try {
const saved = localStorage.getItem(RECENT_TEMPLATES_KEY);
return saved ? JSON.parse(saved) : [];
} catch (e) {
return [];
}
},
// Moves `id` to the front of the recent-templates list (deduping any earlier occurrence),
// capped to MAX_RECENT_TEMPLATES, so LabelLayoutPreview.vue's grid always bubbles the
// last four printed/downloaded layouts to the top.
rememberPrintedTemplate(id) {
const updated = [id, ...this.recentTemplateIds.filter(t => t !== id)].slice(0, MAX_RECENT_TEMPLATES);
this.recentTemplateIds = updated;
try {
localStorage.setItem(RECENT_TEMPLATES_KEY, JSON.stringify(updated));
} catch (e) {
// Best-effort only - a disabled/full localStorage shouldn't block printing.
}
},
async guard(fn) {
this.error = null;
this.busy = true;
@ -607,9 +555,7 @@ export default {
async refreshDevices() {
this.devices = (await navigator.usb.getDevices()).map((d) => markRaw(d));
/* A printer unplugged while open is off the list: the handle it was
opened through is gone, so drop it rather than keep the label
card open on a connection that no longer exists. */
/* A printer unplugged while open drops from the list: its handle is gone, so close the card rather than keep it open on a dead connection. */
if (this.connected !== null && !this.devices.includes(this.connected)) {
this.connected = null;
this.tape = null;
@ -640,9 +586,7 @@ export default {
connect(index) {
this.guard(async () => {
// Only one open device connection at a time - pressing Connect on a different
// printer while one is already open implicitly disconnects it first, rather
// than requiring an explicit Disconnect click.
// Only one open connection at a time: connecting a different printer disconnects the current one first.
await this.closeConnection();
const device = this.devices[index];
this.blob.setDevices([device]);
@ -716,29 +660,17 @@ export default {
link.download = "label.png";
link.href = canvas.toDataURL("image/png");
link.click();
this.rememberPrintedTemplate(this.selectedTemplate);
},
/* Fit the preview to its card without ever needing a horizontal
scrollbar for a label this small, magnifying short labels up to
MAX_ZOOM rather than showing them at native (tiny) size - and never
past MAX_PREVIEW_HEIGHT_PX tall, however long/wide the label itself
runs. Returns the zoom actually used, so callers that care (see
redraw's ruler bookkeeping) don't have to re-derive it. */
// Fits the preview to its card, magnifying up to MAX_ZOOM/MAX_PREVIEW_HEIGHT_PX. See docs/implementation.md#fit-zoom-preview-scaling.
fitZoom(canvas) {
const available = canvas.parentElement.clientWidth;
if (!(available > 0)) {
return 1;
}
const rawZoom = Math.min(MAX_ZOOM, available / canvas.width, MAX_PREVIEW_HEIGHT_PX / canvas.height);
// When magnifying, round DOWN to a whole number: image-rendering:pixelated below
// only actually looks crisp when every source pixel maps to the *same* number of
// screen pixels - at a fractional zoom (the overwhelmingly common case, since rawZoom
// is just whatever ratio the tape/card happen to produce) some source pixels get
// rounded up to one extra screen pixel and others don't, unevenly warping fine,
// already-pixel-perfect detail like a crisp QR module or a tiny bitmap font glyph.
// Flooring (never rounding/ceiling) keeps the same "never bigger than available
// space" guarantee rawZoom already had. Shrinking (zoom < 1) has no equivalent "whole
// factor" to snap to - downsampling always blends source pixels - so it's left as-is.
// Floors rather than rounds/ceils when magnifying, to keep image-rendering:pixelated crisp. See docs/implementation.md#fit-zoom-preview-scaling.
const zoom = rawZoom >= 1 ? Math.max(1, Math.floor(rawZoom)) : rawZoom;
canvas.style.width = `${canvas.width * zoom}px`;
canvas.style.height = `${canvas.height * zoom}px`;
@ -750,12 +682,11 @@ export default {
this.guard(async () => {
const copies = Math.max(1, Math.min(20, Number(this.copies) || 1));
await this.blob.printBitmap(this.labelBitmap, {copies});
this.rememberPrintedTemplate(this.selectedTemplate);
});
},
/* Re-fits whichever canvas sits in a resized container - covers a window resize, but
also a sidebar toggle, a font finishing loading, or any other layout change that
isn't a window resize at all. Debounced since ResizeObserver can fire in bursts. */
// Re-fits the resized container's canvas (window resize, sidebar toggle, font load, etc.); debounced since ResizeObserver can fire in bursts.
handleContainerResize(entries) {
clearTimeout(this.resizeTimer);
this.resizeTimer = setTimeout(() => {
@ -772,9 +703,7 @@ export default {
this.guard(() => this.refreshDevices());
},
// A plain :ref="key" inside v-for would still get Vue's refInFor array-collecting
// behavior (see LabelLayoutPreview.vue's setTemplateCanvasRef for the same pattern), so
// this keys the canvases by the caller's own composite key string explicitly instead.
// Works around Vue's refInFor array-collecting behavior for :ref in v-for, same as LabelLayoutPreview.vue's setTemplateCanvasRef.
setCandidateCanvasRef(key, el) {
if (el) {
this.candidateCanvases[key] = el;
@ -783,12 +712,7 @@ export default {
}
},
// Draws the live "Text" field's value into a single canvas at the exact size/family
// given - no layout math, no snapping, just ctx.font as requested, so what's judged here
// is the font itself rather than anything label.js's real pipeline does to it. Canvas size
// is measured from the text itself, and ink-centered vertically (see label.js's
// drawTextLeaf for the same idea) so a font with unreliable declared metrics still lands
// fully inside the canvas instead of clipped off the top/bottom.
// Renders the "Text" field at exact size/family with no layout snapping, so the font itself is judged; ink-centered vertically (like label.js's drawTextLeaf) so unreliable metrics don't clip.
drawCandidateCell(canvas, family, fontPx) {
if (!canvas) {
return;
@ -828,20 +752,16 @@ export default {
this.resizeObserver = null;
this.resizeTimer = null;
this.candidateCanvases = {};
this.recentTemplateIds = this.loadRecentTemplateIds();
},
async mounted() {
this.drawCandidateFontTests();
// Not awaited: itemShortUrl just reads whatever identityIdByHandle currently holds (see
// the fields computed), so this resolving after first render only means it shows ""
// briefly rather than blocking the rest of mounted's (unrelated) printer/wasm setup.
// Not awaited: resolving late just means shortUrl briefly shows "" instead of blocking the unrelated printer/wasm setup below.
this.fetchIdMap().catch(e => {
this.error = e.message;
});
this.resizeObserver = new ResizeObserver(this.handleContainerResize);
// Kicked off here rather than awaited immediately, so it loads concurrently with
// MultiPrinterBlob below instead of serializing two independent wasm fetches - every
// redraw()/redrawFallback() call below still waits on it first, since a qr/mqr/rmqr leaf
// throws (see label.js's encodeQr) until it resolves.
// Loaded concurrently with MultiPrinterBlob below rather than serialized. See docs/implementation.md#concurrent-wasm-loading.
const qrReady = preloadQrEncoder();
if (!("usb" in navigator)) {
this.usbSupported = false;
@ -888,40 +808,26 @@ export default {
//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 canvas and a vertical one to its left, both ticked in real physical millimeters rather than
preview pixels, since what they're measuring is the actual label. */
/* The tape-fed preview's mm ruler: horizontal track above canvas, vertical to its left, both ticked in real physical mm. See docs/implementation.md#mm-ruler-layout. */
.preview-row {
display: flex;
align-items: flex-start;
}
/* Holds the horizontal ruler and the canvas - deliberately never scrollable (no overflow-x:auto):
fitZoom's zoom always satisfies `canvas.width * zoom <= available`, so the canvas can never
actually be wider than this has room for, and a scrollbar here would let the ruler and canvas
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. */
/* Ruler/canvas container deliberately never scrollable. See docs/implementation.md#preview-track-no-scroll-invariant. */
.preview-track {
flex: 1 1 auto;
min-width: 0;
}
/* Overrides the standalone rule above: nested here, .label-preview must neither scroll nor center
its canvas - overflow-x:visible (never auto) rules out a second, inner scrollbar, and
text-align:left keeps the canvas flush with the ruler's zero tick instead of drifting to the
middle of whatever spare width this card has. padding:0 so the canvas's own edges are exactly
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. */
/* Nested override: .label-preview must neither scroll nor center its canvas here. See docs/implementation.md#label-preview-override-in-preview-track. */
.preview-track .label-preview {
overflow-x: visible;
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's full physical width; canvas is narrower/centered, faint tint marks the real unprintable margin. See docs/implementation.md#tape-full-print-margin. */
.tape-full {
display: flex;
flex-direction: column;
@ -936,8 +842,7 @@ export default {
color: rgba(127, 127, 127, .9);
}
/* Matches .ruler-h's own height below - the vertical ruler's ticks start only after this, so tick
0 lines up with the canvas's top edge rather than the horizontal ruler sitting above it. */
/* Matches .ruler-h's height so the vertical ruler's tick 0 lines up with the canvas's top edge. */
.ruler-v-corner {
height: 1.6rem;
}
@ -957,9 +862,7 @@ export default {
background: currentColor;
}
/* Ticks anchor to the edge nearest the canvas (right for the vertical ruler, bottom for the
horizontal one) and grow outward from it, so they read as pointing at the label; the mm labels
sit on the opposite, outer edge, out of the ticks' way. */
/* Ticks anchor to the edge nearest the canvas and grow outward (pointing at the label); mm labels sit on the opposite outer edge. */
.ruler-v-ticks .tick {
right: 0;
width: .4rem;
@ -987,8 +890,7 @@ export default {
white-space: nowrap;
}
/* transform, not a fixed em nudge, so the label's actual center - not its edge - lands on the
tick's mm position (t.pos, set inline), whatever the text's width/height happens to be. */
/* transform (not a fixed em nudge) centers the label on the tick's mm position regardless of text size. */
.ruler-v-ticks .tick-label {
left: 0;
transform: translateY(-50%);