diff --git a/frontend/src/assets/fonts/pixel/LICENSE.md b/frontend/src/assets/fonts/pixel/LICENSE.md new file mode 100644 index 0000000..c8e62bf --- /dev/null +++ b/frontend/src/assets/fonts/pixel/LICENSE.md @@ -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. diff --git a/frontend/src/assets/fonts/pixel/PICO-8.ttf b/frontend/src/assets/fonts/pixel/PICO-8.ttf new file mode 100644 index 0000000..7085f45 Binary files /dev/null and b/frontend/src/assets/fonts/pixel/PICO-8.ttf differ diff --git a/frontend/src/assets/fonts/pixel/Silkscreen-Regular.woff2 b/frontend/src/assets/fonts/pixel/Silkscreen-Regular.woff2 new file mode 100644 index 0000000..285a17a Binary files /dev/null and b/frontend/src/assets/fonts/pixel/Silkscreen-Regular.woff2 differ diff --git a/frontend/src/assets/fonts/pixel/TomThumb.ttf b/frontend/src/assets/fonts/pixel/TomThumb.ttf new file mode 100644 index 0000000..1e85fd9 Binary files /dev/null and b/frontend/src/assets/fonts/pixel/TomThumb.ttf differ diff --git a/frontend/src/label-layouts.js b/frontend/src/label-layouts.js index f258cae..ae413b9 100644 --- a/frontend/src/label-layouts.js +++ b/frontend/src/label-layouts.js @@ -8,33 +8,33 @@ const GAP = {type: "empty", "min-width": "1mm", "min-height": "1mm"}; export const LABEL_TEMPLATES = [ { id: "qr", name: "QR code only", description: "Just the code - smallest label, prints fastest.", - required_vars: ["value"], - layout: [{type: "qrcode", content: c => c.value}] + required_vars: ["text"], + 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.", - required_vars: ["value"], - layout: [{type: "qrcode", content: c => c.value}, GAP, {type: "text", content: c => c.value?.split("\n")}] + required_vars: ["text"], + 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", description: "The code with the encoded text printed below it.", - required_vars: ["value"], - layout: [[{type: "qrcode", content: c => c.value}, GAP, {type: "text", content: c => c.value?.split("\n")}]] + required_vars: ["text"], + 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", 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, { type: "qrcode", - content: c => c.value + content: c => c.text }, GAP, {type: "text", content: c => c.userHandle}]] }, { id: "text", name: "Text only", description: "No code, just the text itself, as large as it fits.", - required_vars: ["value"], - layout: [{type: "text", content: c => c.value?.split("\n")}] + required_vars: ["text"], + layout: [{type: "text", content: c => c.text?.split("\n")}] }, { 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 // 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, -// derived) `calc` reads - declared up front rather than inferred from calc's body so BASE_VARS -// below can include a var like "webdomain" that only feeds a calculation and that no template -// ever references directly. +// another derived one - see itemUrl/itemHandle below, which both read the derived userHandle) +// `calc` reads - declared up front rather than inferred from calc's body so BASE_VARS below can +// 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 = { + // 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 // 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 diff --git a/frontend/src/label.js b/frontend/src/label.js index ad51fba..39bd9cb 100644 --- a/frontend/src/label.js +++ b/frontend/src/label.js @@ -1,4 +1,5 @@ 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 */ @@ -40,7 +41,26 @@ export function tapeFromStatus(status) { */ 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 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) { 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 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 @@ -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) { ctx.font = `${referencePx}px sans-serif`; 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) { 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; - 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.textAlign = "center"; const centerX = node.box.x + node.box.width / 2; @@ -238,6 +262,7 @@ function drawTextLeaf(ctx, node, referencePx) { ctx.fillText(line, centerX, y); y += lineHeight; } + return fontPx; } // 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(); } -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)) { - node.forEach(child => drawTree(ctx, child, referencePx)); + node.forEach(child => drawTree(ctx, child, referencePx, textSizesPx)); return; } if (node.type === "qrcode") { drawQrLeaf(ctx, node); } 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. 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."); } layoutTree(tree, false, width, height, pxPerMm); - assertLeavesFit(tree); return {tree, width}; } /* 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) { const maxLength = tape.printLengthPx ? 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); 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 */ @@ -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 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 - 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) { const measureCtx = canvas.getContext("2d"); const pxPerMm = FALLBACK_DPI / 25.4; @@ -349,7 +381,9 @@ export function drawFallbackLabel(canvas, content) { ctx.fillStyle = "#000"; 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 @@ -360,7 +394,7 @@ export const LABEL_CONTENT_BUILDERS = { // 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 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) { @@ -378,11 +412,20 @@ export function buildLabelContent(prefill) { // (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. 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}) => { if (!user || !id) { 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), + }; }, }; diff --git a/frontend/src/scss/_pixel-fonts.scss b/frontend/src/scss/_pixel-fonts.scss new file mode 100644 index 0000000..dadf862 --- /dev/null +++ b/frontend/src/scss/_pixel-fonts.scss @@ -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; +} diff --git a/frontend/src/scss/toolshed.scss b/frontend/src/scss/toolshed.scss index b06de08..a95db2a 100644 --- a/frontend/src/scss/toolshed.scss +++ b/frontend/src/scss/toolshed.scss @@ -92,6 +92,7 @@ $body-color: $gray-700; @import "forms"; @import "tags"; @import "dropdown"; +@import "pixel-fonts"; #root, body, html { height: 100%; diff --git a/frontend/src/views/Print.vue b/frontend/src/views/Print.vue index dfdd84b..c286d57 100644 --- a/frontend/src/views/Print.vue +++ b/frontend/src/views/Print.vue @@ -20,9 +20,9 @@
-
+
- @@ -88,7 +88,7 @@
Label
- {{ tape.mediaWidthMm }} mm tape + {{ tape.mediaWidthMm }} mm tape{{ textSizesSummary }}

@@ -99,7 +99,7 @@

+ :style="{height: (tape.mediaWidthMm * tapePxPerMm) + 'px'}"> @@ -108,7 +108,7 @@ :style="{top: t.pos + 'px'}">{{ t.mm }}
-
+
{{ t.mm }}
-
- + +
+
+ +
@@ -207,8 +214,17 @@ const BLOB_URL = "/vendor/libweblabel.js"; 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 RULER_TICK_MM = 1; /* finest tick spacing the mm ruler draws */ -const RULER_MAJOR_EVERY_MM = 5; /* every Nth tick is taller and labeled with its mm value */ +// 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. +const RULER_TIERS = [ + {aboveMm: 0, tickMm: 1, majorEveryMm: 5}, + {aboveMm: 100, tickMm: 1, majorEveryMm: 10}, + {aboveMm: 500, tickMm: 5, majorEveryMm: 25}, +]; export default { name: "Print", @@ -244,17 +260,21 @@ export default { // either one changes. 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. + textSizesPx: [], // 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 - // typed directly, so they're only ever shown (see the `fields` computed below), never - // stored here. Prefilled from the ?kind=…&… query params where + // 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. userHandle isn't stuck depending on a prefill that never - // arrives. + // template needing e.g. domain isn't stuck depending on a prefill that never arrives. varValues: { ...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 // 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. @@ -327,17 +347,46 @@ export default { 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. + 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 // margin included, since that's real physical tape too). horizontalRulerTicks() { if (!this.tape || !this.printedWidthPx) { 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() { - 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 , 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: { @@ -377,12 +426,15 @@ export default { 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 - // via tapePxPerMm - shared by the horizontal/vertical ruler computeds above. + // 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. rulerTicks(totalMm) { + const {tickMm, majorEveryMm} = this.rulerTier; const ticks = []; - for (let mm = 0; mm <= totalMm; mm += RULER_TICK_MM) { - ticks.push({mm, pos: mm * this.tapePxPerMm, major: mm % RULER_MAJOR_EVERY_MM === 0}); + for (let mm = 0; mm <= totalMm; mm += tickMm) { + ticks.push({mm, pos: mm * this.tapePxPerMm, major: mm % majorEveryMm === 0}); } return ticks; }, @@ -463,13 +515,15 @@ export default { return; } this.resizeObserver.observe(canvas.parentElement); + let textSizesPx; try { - drawLabel(canvas, this.tape, content); + ({textSizesPx} = drawLabel(canvas, this.tape, content)); } catch (e) { this.error = e.message; return; } this.error = null; + this.textSizesPx = textSizesPx; const bitmap = canvasToBitmap(canvas); bitmapToCanvas(canvas, bitmap); this.labelBitmap = bitmap; @@ -593,7 +647,7 @@ export default { .label-preview { overflow-x: auto; padding: .75rem; - background: rgba(127, 127, 127, .08); + //background: rgba(127, 127, 127, .08); border-radius: .35rem; text-align: center; max-height: 300px; @@ -602,7 +656,7 @@ export default { .label-preview canvas { display: inline-block; 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 @@ -613,22 +667,38 @@ export default { align-items: flex-start; } -/* The single horizontal scroll region for a label wider than its card - the horizontal ruler and - the canvas are both direct children of this, so they scroll in lockstep with no JS needed to - keep them in sync. */ -.preview-scroll { +/* 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. */ +.preview-track { flex: 1 1 auto; min-width: 0; - overflow-x: auto; } -/* Overrides the standalone rule above: nested here, .label-preview must neither clip nor center - its canvas - overflow-x:visible lets the canvas's true width "bubble up" to .preview-scroll's - own scrollbar instead of scrolling twice, 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. */ -.preview-scroll .label-preview { +/* 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. */ +.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-full { + display: flex; + flex-direction: column; + justify-content: center; + background: rgba(127, 127, 127, .08); + border-radius: .35rem; } .ruler-v { @@ -645,13 +715,11 @@ export default { .ruler-v-ticks { position: relative; - margin-top: .75rem; /* .label-preview's own padding, so tick 0 meets the canvas, not its box */ } .ruler-h { position: relative; 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); }