stash
This commit is contained in:
parent
8d96bc97c4
commit
ed04d98bf1
54 changed files with 661 additions and 1214 deletions
|
|
@ -128,9 +128,8 @@ export default {
|
|||
},
|
||||
methods: {
|
||||
...mapActions(["fetchInventoryItems", "deleteInventoryItem", "fetchStorageLocations", "fetchIdMap"]),
|
||||
// This list is always the viewer's own personal items (fetchInventoryItems has no
|
||||
// group filter) - the owner handle is always their own, but it's always included
|
||||
// rather than special-cased, so /inventory/:handle/:id has exactly one shape.
|
||||
// Always the viewer's own items (fetchInventoryItems has no group filter); the owner
|
||||
// handle is always included so /inventory/:handle/:id has exactly one shape.
|
||||
itemRoute(item) {
|
||||
return `/inventory/${encodeHandleForUrl(this.user)}/${item.id}`
|
||||
},
|
||||
|
|
@ -151,13 +150,8 @@ export default {
|
|||
if (owner_identity_id === undefined) return null
|
||||
return shortenedRoute({kind: 'item', owner_identity_id, item_local_id: item.id})
|
||||
},
|
||||
// Routes to Print.vue with this item's own raw identity - userHandle + id, the same shape
|
||||
// InventoryDetail.vue's own Print label button sends - rather than any pre-built link, so
|
||||
// the print page can derive every item template (item-handle, owner-handle, item-url,
|
||||
// the short link, …) itself and isn't tied to whichever one this button "suggests".
|
||||
// Group-owned items have no individual owner handle - short-id.js's group_item kind
|
||||
// resolves them via owner_group instead (see shortIdLink above) - so there's no
|
||||
// {userHandle, id} to build here yet; they get no print link until that's supported too.
|
||||
// Routes to Print.vue with this item's raw identity rather than a pre-built link.
|
||||
// See docs/implementation.md#print-link-shape-for-personal-items.
|
||||
printLinkFor(item) {
|
||||
if (!item.owner) return null
|
||||
return {path: '/print', query: {kind: 'item', userHandle: item.owner, id: item.id}}
|
||||
|
|
|
|||
|
|
@ -98,16 +98,13 @@ export default {
|
|||
decodedHandle() {
|
||||
return decodeHandleFromUrl(this.handle)
|
||||
},
|
||||
// Edit/Delete apply once the viewer is actually authorized to act on this item - their
|
||||
// own item, or a group they belong to - not just anyone who can view it
|
||||
// (get_shared_item's friends_or_self() also lets a friend view a shared item, but never
|
||||
// act on it).
|
||||
// Edit/Delete require actual authorization (own item or member group), not just view
|
||||
// access - get_shared_item's friends_or_self() lets a friend view but never act.
|
||||
canEdit() {
|
||||
return this.decodedHandle === this.user || this.decodedHandle in this.groupIdByHandle
|
||||
},
|
||||
// Printed labels only support a personal owner handle so far (see label.js's
|
||||
// splitUserHandle/Inventory.vue's printLinkFor) - group items don't get a print link
|
||||
// until that's supported too.
|
||||
// splitUserHandle); group items get no print link until that's supported too.
|
||||
canPrint() {
|
||||
return this.decodedHandle === this.user
|
||||
}
|
||||
|
|
|
|||
|
|
@ -98,9 +98,9 @@ export default {
|
|||
},
|
||||
data() {
|
||||
return {
|
||||
// Fetched fresh by {handle, id} on mount rather than found by id alone in whatever
|
||||
// happens to already be cached (loaded_items mixes personal, group and search
|
||||
// fetches, and id is only unique within one owner's own items - see InventoryDetail.vue).
|
||||
// Fetched fresh by {handle, id} on mount rather than looked up by id alone -
|
||||
// loaded_items mixes personal/group/search fetches, and id is only unique per owner
|
||||
// (see InventoryDetail.vue).
|
||||
item: {
|
||||
tags: [],
|
||||
properties: [],
|
||||
|
|
|
|||
|
|
@ -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%);
|
||||
|
|
|
|||
|
|
@ -4,6 +4,12 @@
|
|||
<div class="container-fluid p-0">
|
||||
<h1 class="h3 mb-3">Scan a code</h1>
|
||||
|
||||
<div class="form-check form-switch mb-3">
|
||||
<input class="form-check-input" type="checkbox" role="switch" id="visitFirstMatch"
|
||||
v-model="visitFirstMatch">
|
||||
<label class="form-check-label" for="visitFirstMatch">Visit first match</label>
|
||||
</div>
|
||||
|
||||
<div v-if="error" class="alert alert-danger" role="alert">{{ error }}</div>
|
||||
|
||||
<div v-if="!insecureContext" class="alert alert-warning">
|
||||
|
|
@ -24,7 +30,17 @@
|
|||
<span class="scan-result-type">{{ c.type }}</span>
|
||||
<span v-if="c.metaText" class="text-muted"> {{ c.metaText }}</span>
|
||||
<span class="text-muted"> @ {{ c.time }}</span>
|
||||
<div class="text-break">{{ c.text }}</div>
|
||||
<div class="text-break">
|
||||
<a v-if="c.link?.href" :href="c.link.href" target="_blank"
|
||||
rel="noopener noreferrer">{{ c.text }}</a>
|
||||
<template v-else-if="c.link">
|
||||
<router-link :to="c.link.to">{{ c.text }}</router-link>
|
||||
<span v-if="c.error" class="text-danger"> → {{ c.error }}</span>
|
||||
<span v-else-if="c.description" class="text-muted"> → {{ c.description }}</span>
|
||||
<span v-else-if="c.description === undefined" class="text-muted"> → resolving…</span>
|
||||
</template>
|
||||
<template v-else>{{ c.text }}</template>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
|
@ -81,7 +97,17 @@
|
|||
<li v-for="(r, i) in fileResults" :key="i">
|
||||
<span class="scan-result-type">{{ r.type }}</span>
|
||||
<span v-if="r.metaText" class="text-muted"> {{ r.metaText }}</span>
|
||||
<div class="text-break">{{ r.text }}</div>
|
||||
<div class="text-break">
|
||||
<a v-if="r.link?.href" :href="r.link.href" target="_blank"
|
||||
rel="noopener noreferrer">{{ r.text }}</a>
|
||||
<template v-else-if="r.link">
|
||||
<router-link :to="r.link.to">{{ r.text }}</router-link>
|
||||
<span v-if="r.error" class="text-danger"> → {{ r.error }}</span>
|
||||
<span v-else-if="r.description" class="text-muted"> → {{ r.description }}</span>
|
||||
<span v-else-if="r.description === undefined" class="text-muted"> → resolving…</span>
|
||||
</template>
|
||||
<template v-else>{{ r.text }}</template>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
|
@ -94,14 +120,61 @@
|
|||
</template>
|
||||
|
||||
<script>
|
||||
import {mapActions} from "vuex";
|
||||
import * as BIcons from "bootstrap-icons-vue";
|
||||
import BaseLayout from "@/components/BaseLayout.vue";
|
||||
import {loadAnyDCode} from "../../vendor/anyd-qr.js";
|
||||
import cameraManager from "@/cameraManager.js";
|
||||
import {encodeHandleForUrl, expandedRoute} from "@/router";
|
||||
import {decodeShortId, deserializeShortId} from "@/short-id";
|
||||
|
||||
// A decode result's metadata (see anyd-qr.js's SymbolMetadata) as one short, human-readable
|
||||
// string, e.g. "(v7, ec=M, mask=3)" - shared by both the "from image" and "from camera" result
|
||||
// lists below rather than each formatting it its own way.
|
||||
// A scanned label can encode any of these (see label-layouts.js's DERIVED_VARS): a full
|
||||
// self-contained URL (itemUrl/shortUrl), a bare short-id.js token with no domain at all (the
|
||||
// "mqr"/id-only layout), or the compact no-URL "<userHandle>:<itemId>" form (itemHandle). Detects
|
||||
// which and returns a link target, or null if the text doesn't match any known format. `decoded`/
|
||||
// `itemHandle` carry enough of the parsed token for describeLink (below) to also resolve a
|
||||
// human-readable "what this points at" - not needed for the two URL cases, which link to
|
||||
// somewhere already showing that.
|
||||
const ITEM_HANDLE_RE = /^(#?[^\s@:/#+~]+@[^\s@:/#+~]+):(\d+)$/;
|
||||
|
||||
function classifyScanText(text) {
|
||||
if (!text) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
const url = new URL(text);
|
||||
// Same-origin URLs (the common case - a label printed by this same app) get routed
|
||||
// in-app instead of forcing a full page reload through an <a> tag. `immediate`: a URL
|
||||
// needs no async lookup to confirm it's real (unlike the token/handle formats below), so
|
||||
// it's already "resolved" the moment it's classified - see resolveDescription and
|
||||
// maybeVisitFirstMatch.
|
||||
return url.origin === window.location.origin
|
||||
? {to: url.pathname + url.search + url.hash, immediate: true}
|
||||
: {href: url.href};
|
||||
} catch {
|
||||
// Not an absolute URL - fall through to the other known formats below.
|
||||
}
|
||||
if (text.startsWith('~')) {
|
||||
try {
|
||||
const decoded = deserializeShortId(decodeShortId(text));
|
||||
// expandedRoute needs idmap already loaded to resolve item/group_item (see
|
||||
// router.js's NEEDS_IDMAP) - fall back to the token's own URL (ShortId.vue resolves
|
||||
// it from there, same as a cold-opened short link) when it can't yet.
|
||||
return {to: expandedRoute(decoded) || '/' + text, decoded};
|
||||
} catch {
|
||||
return null; // starts with '~' but isn't a real short id - leave as plain text
|
||||
}
|
||||
}
|
||||
const handleMatch = text.match(ITEM_HANDLE_RE);
|
||||
if (handleMatch) {
|
||||
const [, handle, id] = handleMatch;
|
||||
return {to: `/inventory/${encodeHandleForUrl(handle)}/${id}`, itemHandle: {handle, id}};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Formats a decode result's metadata (see anyd-qr.js's SymbolMetadata) as a short string like
|
||||
// "(v7, ec=M, mask=3)"; shared by the "from image" and "from camera" result lists below.
|
||||
function metaSummary(metadata) {
|
||||
const parts = [];
|
||||
if (metadata.version != null) {
|
||||
|
|
@ -119,8 +192,8 @@ function metaSummary(metadata) {
|
|||
return parts.length ? `(${parts.join(", ")})` : "";
|
||||
}
|
||||
|
||||
// How long a camera-detected code's bounding box stays drawn on the overlay before fading, so a
|
||||
// one-off decode doesn't leave a stale box on screen once the code's moved out of frame.
|
||||
// How long a detected code's overlay box stays drawn before fading, so it doesn't linger once
|
||||
// the code has moved out of frame.
|
||||
const OVERLAY_CLEAR_MS = 2000;
|
||||
|
||||
export default {
|
||||
|
|
@ -135,6 +208,10 @@ export default {
|
|||
insecureContext: window.isSecureContext,
|
||||
insecureOrigin: `${window.location.protocol}//${window.location.hostname}`,
|
||||
|
||||
// One-shot: cleared by maybeVisitFirstMatch as soon as it navigates, so it doesn't
|
||||
// keep firing router.push for every later scan/decode while left switched on.
|
||||
visitFirstMatch: false,
|
||||
|
||||
dropHover: false,
|
||||
hasFileImage: false,
|
||||
fileResults: [],
|
||||
|
|
@ -148,8 +225,8 @@ export default {
|
|||
};
|
||||
},
|
||||
computed: {
|
||||
// Switches camera the moment the select changes, rather than waiting for an explicit
|
||||
// "apply" step - matches prototypes/camera-inputs/InputPhoto.vue's selectedCameraId.
|
||||
// Switches camera immediately on selection change (no explicit "apply" step), matching
|
||||
// prototypes/camera-inputs/InputPhoto.vue's selectedCameraId.
|
||||
selectedCameraId: {
|
||||
get() {
|
||||
return this.localSelectedCameraId;
|
||||
|
|
@ -163,9 +240,109 @@ export default {
|
|||
},
|
||||
},
|
||||
methods: {
|
||||
// Draws `file`/a pasted or dropped Blob onto fileCanvas and decodes whatever's in it -
|
||||
// shared by the file input, drag&drop and paste handlers below rather than each
|
||||
// duplicating the createImageBitmap/getImageData/decodeImage sequence.
|
||||
...mapActions(["fetchItemByHandle", "fetchGroup", "fetchStorageLocations", "fetchIdMap"]),
|
||||
|
||||
// Resolves entry.link into entry.description ("[#7] Cordless drill") for the non-URL
|
||||
// formats classifyScanText recognizes - mutates the already-rendered entry in place once
|
||||
// the lookup lands, rather than delaying the log/result list from showing the raw scanned
|
||||
// text and link immediately. Left as `undefined` (template shows "resolving...") while in
|
||||
// flight, and settles to a string or `null` (nothing else known to show, e.g. a workflow
|
||||
// short id, or the owner/item genuinely couldn't be resolved).
|
||||
//
|
||||
// descriptionCache (keyed by the raw scanned text, shared across cameraLog and
|
||||
// fileResults) memoizes the outcome - a still-in-frame code gets re-decoded and
|
||||
// re-logged several times a second (see logDecode), and re-scanning the same printed
|
||||
// label later is common too, so without this every repeat would re-fire the same
|
||||
// fetchItemByHandle/fetchGroup/fetchStorageLocations/fetchIdMap round trip. Caching the
|
||||
// in-flight promise itself (not just its settled value) also dedupes concurrent lookups
|
||||
// for the same still-in-frame code, rather than firing one request per decode.
|
||||
resolveDescription(entry) {
|
||||
const {link, text} = entry;
|
||||
if (!link || link.href) {
|
||||
return; // full URLs already show where they go - see this feature's ask
|
||||
}
|
||||
if (link.immediate) {
|
||||
this.maybeVisitFirstMatch(link);
|
||||
return;
|
||||
}
|
||||
if (!this.descriptionCache.has(text)) {
|
||||
// Wrapped as {error} rather than swallowed to null: a lookup can fail for very
|
||||
// different reasons (not logged in, item not shared with this viewer, a genuine
|
||||
// network error) and collapsing them all to "no description" made every one of
|
||||
// them look identical to "nothing to show" - undiagnosable from the UI.
|
||||
this.descriptionCache.set(text, this.describeLink(link).catch(e => ({error: e.message ?? String(e)})));
|
||||
}
|
||||
this.descriptionCache.get(text).then(result => {
|
||||
if (result && typeof result === "object") {
|
||||
entry.description = null;
|
||||
entry.error = result.error;
|
||||
return;
|
||||
}
|
||||
entry.description = result;
|
||||
// Only a truthy description confirms the target actually exists - a null/empty
|
||||
// one (unresolvable, or a kind with no title lookup wired up) shouldn't count as
|
||||
// a "match" to auto-visit.
|
||||
if (result) {
|
||||
this.maybeVisitFirstMatch(link);
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
// Sends the viewer straight to the first scan this session that's confirmed to resolve
|
||||
// (immediately for a plain URL, or once resolveDescription confirms a real target for a
|
||||
// token/handle) while the "Visit first match" toggle is on. One-shot: switches the toggle
|
||||
// back off so it doesn't fire again for every later scan of the same or another code.
|
||||
maybeVisitFirstMatch(link) {
|
||||
if (!this.visitFirstMatch || !link?.to) {
|
||||
return;
|
||||
}
|
||||
this.visitFirstMatch = false;
|
||||
this.$router.push(link.to);
|
||||
},
|
||||
|
||||
async describeLink(link) {
|
||||
if (link.itemHandle) {
|
||||
return this.describeItem(link.itemHandle.handle, link.itemHandle.id);
|
||||
}
|
||||
const decoded = link.decoded;
|
||||
if (!decoded) {
|
||||
return null;
|
||||
}
|
||||
if (decoded.kind === "item" || decoded.kind === "group_item") {
|
||||
const byId = decoded.kind === "item"
|
||||
? this.$store.getters.identityHandleById
|
||||
: this.$store.getters.groupHandleById;
|
||||
const ownerId = decoded.kind === "item" ? decoded.owner_identity_id : decoded.owner_group_id;
|
||||
let handle = byId[ownerId];
|
||||
if (handle === undefined) {
|
||||
await this.fetchIdMap();
|
||||
handle = (decoded.kind === "item"
|
||||
? this.$store.getters.identityHandleById
|
||||
: this.$store.getters.groupHandleById)[ownerId];
|
||||
}
|
||||
return handle === undefined ? null : this.describeItem(handle, decoded.item_local_id);
|
||||
}
|
||||
if (decoded.kind === "group") {
|
||||
const group = await this.fetchGroup({id: decoded.group_id});
|
||||
return group ? group.handle : null;
|
||||
}
|
||||
if (decoded.kind === "storage_location") {
|
||||
if (!this.$store.state.storage_locations.length) {
|
||||
await this.fetchStorageLocations();
|
||||
}
|
||||
const location = this.$store.state.storage_locations.find(l => l.id === decoded.storage_location_id);
|
||||
return location ? `[#${location.id}] ${location.name}` : null;
|
||||
}
|
||||
return null; // workflow, category, file: no per-item title lookup wired up yet
|
||||
},
|
||||
|
||||
async describeItem(handle, id) {
|
||||
const item = await this.fetchItemByHandle({handle, id});
|
||||
return item ? `[#${item.id}] ${item.name}` : null;
|
||||
},
|
||||
|
||||
// Draws `file`/a pasted or dropped Blob onto fileCanvas and decodes it; shared by the file
|
||||
// input, drag&drop, and paste handlers below to avoid duplicating this sequence.
|
||||
async decodeBlob(file) {
|
||||
this.error = null;
|
||||
try {
|
||||
|
|
@ -180,7 +357,11 @@ export default {
|
|||
const results = anyd.decodeImage(imageData);
|
||||
this.drawBoxes(ctx, results);
|
||||
this.hasFileImage = true;
|
||||
this.fileResults = results.map(r => ({...r, metaText: metaSummary(r.metadata)}));
|
||||
this.fileResults = results.map(r => ({
|
||||
...r, metaText: metaSummary(r.metadata), link: classifyScanText(r.text),
|
||||
description: undefined, error: null
|
||||
}));
|
||||
this.fileResults.forEach(this.resolveDescription);
|
||||
} catch (e) {
|
||||
this.error = e.message ?? String(e);
|
||||
}
|
||||
|
|
@ -233,9 +414,8 @@ export default {
|
|||
}
|
||||
},
|
||||
|
||||
// Device labels are only populated once camera permission has actually been granted, so
|
||||
// this is called again right after getUserMedia resolves in startCamera, not just once
|
||||
// up front.
|
||||
// Device labels only populate once permission is granted, so this is called again right
|
||||
// after getUserMedia resolves in startCamera, not just once up front.
|
||||
async populateCameraList() {
|
||||
if (!navigator.mediaDevices?.enumerateDevices) {
|
||||
return;
|
||||
|
|
@ -264,21 +444,30 @@ export default {
|
|||
},
|
||||
|
||||
logDecode(code) {
|
||||
this.cameraLog.unshift({
|
||||
const text = code.text ?? "";
|
||||
const entry = {
|
||||
type: code.type,
|
||||
text: code.text ?? "",
|
||||
text,
|
||||
metaText: metaSummary(code.metadata),
|
||||
time: new Date().toLocaleTimeString(),
|
||||
});
|
||||
// Caps the log rather than letting it grow forever - old entries are trimmed off the
|
||||
// end, same as CameraScanner's own dedupe window makes them stale for re-matching.
|
||||
link: classifyScanText(text),
|
||||
description: undefined,
|
||||
error: null,
|
||||
};
|
||||
this.cameraLog.unshift(entry);
|
||||
// Resolve against cameraLog[0], not the plain `entry` object above: Vue's reactivity
|
||||
// tracks property sets through the reactive proxy unshift() just installed, and
|
||||
// mutating the pre-insertion raw object later bypasses that proxy entirely, so the
|
||||
// description would never appear to update (stuck on "resolving...") even once the
|
||||
// lookup actually finished.
|
||||
this.resolveDescription(this.cameraLog[0]);
|
||||
// Caps the log (old entries trimmed) rather than growing forever, matching
|
||||
// CameraScanner's own dedupe window that makes them stale for re-matching.
|
||||
this.cameraLog.length = Math.min(this.cameraLog.length, 20);
|
||||
},
|
||||
|
||||
// Draws every detected code's box (in the video's own native pixel space, scaled to
|
||||
// however large the overlay is actually displayed) and fades them out after
|
||||
// OVERLAY_CLEAR_MS - a one-off decode shouldn't leave a stale box on screen once the code
|
||||
// has moved out of frame.
|
||||
// Draws each detected code's box (video's native pixel space, scaled to the overlay's
|
||||
// displayed size) and fades it out after OVERLAY_CLEAR_MS.
|
||||
drawOverlay(codes) {
|
||||
console.log("drawOverlay", codes);
|
||||
const overlay = this.$refs.overlay;
|
||||
|
|
@ -307,12 +496,8 @@ export default {
|
|||
() => ctx.clearRect(0, 0, overlay.width, overlay.height), OVERLAY_CLEAR_MS);
|
||||
},
|
||||
|
||||
// Attaches `stream` to the video element - shared by startCamera and the
|
||||
// camera-switch/reconnect paths so the scanner (which just keeps reading frames off the
|
||||
// same video element) never needs to be recreated. videoWidth/videoHeight aren't known yet
|
||||
// right after play() - the video's own "resize" event (see onVideoResize) is what tells us
|
||||
// the new aspect ratio has actually taken effect, which is also when the overlay needs to
|
||||
// be resized to match.
|
||||
// Attaches `stream` to the video element so the scanner never needs recreating. See
|
||||
// docs/implementation.md#video-stream-attach-and-resize-sync.
|
||||
setupVideoStream(stream) {
|
||||
const video = this.$refs.video;
|
||||
if (!video) return;
|
||||
|
|
@ -320,10 +505,8 @@ export default {
|
|||
video.play();
|
||||
},
|
||||
|
||||
// Fires on the video element's own "resize"/"loadedmetadata" events - i.e. whenever its
|
||||
// intrinsic width/height actually change (initial load, or switching to a camera with a
|
||||
// different native resolution/aspect ratio) - so the overlay canvas is resized to match the
|
||||
// video's *new* rendered size rather than the stale one from before the switch.
|
||||
// Resizes the overlay to match the video's new size once its intrinsic dimensions change.
|
||||
// See docs/implementation.md#video-stream-attach-and-resize-sync.
|
||||
onVideoResize() {
|
||||
const video = this.$refs.video;
|
||||
if (!video || !video.videoWidth) {
|
||||
|
|
@ -382,9 +565,8 @@ export default {
|
|||
}
|
||||
},
|
||||
|
||||
// The camera manager already tries a fallback device on disconnect (see cameraManager.js)
|
||||
// and only fires 'camera-disconnected' once none is left, so by the time this runs there's
|
||||
// nothing left to fall back to and the running scan session has to stop.
|
||||
// cameraManager already tries a fallback device and only fires this event once none
|
||||
// remain, so the running scan session has to stop here.
|
||||
handleCameraDisconnected(event) {
|
||||
if (!this.cameraRunning) return;
|
||||
this.error = `Camera error: ${event.detail?.error || "Camera disconnected"}`;
|
||||
|
|
@ -401,12 +583,14 @@ export default {
|
|||
},
|
||||
},
|
||||
created() {
|
||||
// Kept as a promise (rather than awaited here) so every caller - decodeBlob, startCamera -
|
||||
// shares the same in-flight load instead of each triggering its own; loadAnyDCode's own
|
||||
// memoization (see anyd-qr.js) means a second call anywhere else in the app is free too.
|
||||
// Kept as a promise (not awaited) so decodeBlob and startCamera share one in-flight load;
|
||||
// loadAnyDCode also memoizes, so later calls elsewhere in the app are free too.
|
||||
this.anydPromise = loadAnyDCode();
|
||||
this.scanner = null;
|
||||
this.clearOverlayTimer = null;
|
||||
// Not component data: resolveDescription's cached values are read back into each entry's
|
||||
// own (reactive) `description` field, so the cache itself never needs to be reactive.
|
||||
this.descriptionCache = new Map();
|
||||
},
|
||||
mounted() {
|
||||
window.addEventListener("paste", this.onPaste);
|
||||
|
|
|
|||
|
|
@ -107,9 +107,9 @@ export default {
|
|||
return files.filter(file => file.mime_type.startsWith("image/"));
|
||||
},
|
||||
loadResults() {
|
||||
// Search results are always personal-or-friend (see inventory_items() in
|
||||
// toolshed/api/inventory.py - it never yields a group's items), so the owner is
|
||||
// always a plain user handle - one route shape, no owner-is-me special case.
|
||||
// Search results are always personal-or-friend (inventory_items() in
|
||||
// toolshed/api/inventory.py never yields group items), so owner is always a plain
|
||||
// user handle - one route shape, no owner-is-me special case.
|
||||
this.fetchSearchResults({query: this.query}).then((results) => {
|
||||
this.search_results = results.map(e => (
|
||||
{...e, route: `/inventory/${encodeHandleForUrl(e.owner)}/${e.id}`}))
|
||||
|
|
|
|||
|
|
@ -77,10 +77,7 @@ const EXAMPLES = [
|
|||
{kind: 'workflow', owner_identity_id: 2, workflow_id: 9},
|
||||
];
|
||||
|
||||
// Debug-only display helper, kept out of short-id.js's production library: mirrors its bit-packing
|
||||
// rules (2-bit kind tag with an all-ones escape, 4-bit continuation chunks, see
|
||||
// docs/handles-and-shortids.md) just to show them, recomputed straight from an already-serialized
|
||||
// `ints` list rather than re-parsing a token.
|
||||
// Debug-only mirror of short-id.js's bit-packing (2-bit kind tag + 4-bit chunks, see docs/handles-and-shortids.md), recomputed from `ints` for display only.
|
||||
const KIND_TAG_BITS = 2;
|
||||
const CHUNK_BITS = 4;
|
||||
const DIRECT_KIND_COUNT = 2 ** KIND_TAG_BITS - 1;
|
||||
|
|
@ -159,10 +156,7 @@ export default {
|
|||
}
|
||||
},
|
||||
watch: {
|
||||
// expandedRoute reads Vuex getters derived from state.idmap (see buildExpandedRoute in
|
||||
// router.js), so it re-evaluates on its own once fetchIdMap resolves below - this just
|
||||
// catches that and finishes the redirect the router's own (synchronous, can't-await)
|
||||
// redirect couldn't.
|
||||
// expandedRoute re-evaluates once fetchIdMap resolves (see buildExpandedRoute in router.js); finishes the redirect the router's synchronous guard couldn't.
|
||||
expandedRoute(url) {
|
||||
if (url) {
|
||||
this.$router.replace(url);
|
||||
|
|
|
|||
|
|
@ -133,11 +133,8 @@ export default {
|
|||
if (owner_identity_id === undefined) return null
|
||||
return shortenedRoute({kind: 'storage_location', owner_identity_id, storage_location_id: location.id})
|
||||
},
|
||||
// Routes to Print.vue with this location's own raw identity - userHandle + id - rather
|
||||
// than any pre-built link, the same shape Inventory.vue's printLinkFor sends for an item
|
||||
// (see label.js's "storage-location" LABEL_FIELD_BUILDERS entry). Locations are always
|
||||
// individually owned (see StorageLocationViewSet.get_queryset), so unlike Inventory.vue's
|
||||
// version this never has to fall back to "no metadata available".
|
||||
// Routes to Print.vue with this location's raw identity, same shape as Inventory.vue's
|
||||
// printLinkFor. See docs/implementation.md#print-link-shape-for-storage-locations.
|
||||
printLinkFor(location) {
|
||||
return {path: '/print', query: {kind: 'storage-location', userHandle: location.owner, id: location.id}}
|
||||
},
|
||||
|
|
|
|||
|
|
@ -3395,8 +3395,7 @@ sagittis lacus vel augue laoreet rutrum faucibus.">
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Navbar
|
||||
================================================== -->
|
||||
<!-- Navbar -->
|
||||
<div class="bs-docs-section clearfix">
|
||||
<div class="row">
|
||||
<div class="col-sm-12">
|
||||
|
|
@ -3507,8 +3506,7 @@ sagittis lacus vel augue laoreet rutrum faucibus.">
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Typography
|
||||
================================================== -->
|
||||
<!-- Typography -->
|
||||
<div class="bs-docs-section">
|
||||
<div class="row">
|
||||
<div class="col-sm-12">
|
||||
|
|
@ -3613,8 +3611,7 @@ sagittis lacus vel augue laoreet rutrum faucibus.">
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tables
|
||||
================================================== -->
|
||||
<!-- Tables -->
|
||||
<div class="bs-docs-section">
|
||||
|
||||
<div class="row">
|
||||
|
|
@ -3701,8 +3698,7 @@ sagittis lacus vel augue laoreet rutrum faucibus.">
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Forms
|
||||
================================================== -->
|
||||
<!-- Forms -->
|
||||
<div class="bs-docs-section">
|
||||
<div class="row">
|
||||
<div class="col-sm-12">
|
||||
|
|
@ -3969,8 +3965,7 @@ sagittis lacus vel augue laoreet rutrum faucibus.">
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Navs
|
||||
================================================== -->
|
||||
<!-- Navs -->
|
||||
<div class="bs-docs-section">
|
||||
|
||||
<div class="row">
|
||||
|
|
@ -4224,8 +4219,7 @@ sagittis lacus vel augue laoreet rutrum faucibus.">
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Indicators
|
||||
================================================== -->
|
||||
<!-- Indicators -->
|
||||
<div class="bs-docs-section">
|
||||
|
||||
<div class="row">
|
||||
|
|
@ -4343,8 +4337,7 @@ sagittis lacus vel augue laoreet rutrum faucibus.">
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Progress
|
||||
================================================== -->
|
||||
<!-- Progress -->
|
||||
<div class="bs-docs-section">
|
||||
|
||||
<div class="row">
|
||||
|
|
@ -4443,8 +4436,7 @@ sagittis lacus vel augue laoreet rutrum faucibus.">
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Containers
|
||||
================================================== -->
|
||||
<!-- Containers -->
|
||||
<div class="bs-docs-section">
|
||||
|
||||
<div class="row">
|
||||
|
|
@ -4765,8 +4757,7 @@ sagittis lacus vel augue laoreet rutrum faucibus.">
|
|||
|
||||
</div>
|
||||
|
||||
<!-- Dialogs
|
||||
================================================== -->
|
||||
<!-- Dialogs -->
|
||||
<div class="bs-docs-section">
|
||||
|
||||
<div class="row">
|
||||
|
|
|
|||
|
|
@ -260,9 +260,7 @@ export default {
|
|||
},
|
||||
|
||||
workflowComponent() {
|
||||
// Return the single component implementing the whole workflow, if any,
|
||||
// using the workflow component registry. The component itself decides
|
||||
// what to render based on the `step` prop it receives.
|
||||
// The returned component decides what to render itself, based on the `step` prop it receives.
|
||||
const workflowType = this.workflowInstance?.slug;
|
||||
return workflowType ? getWorkflowComponent(workflowType) : null;
|
||||
},
|
||||
|
|
|
|||
|
|
@ -231,8 +231,6 @@ export default {
|
|||
|
||||
const newWorkflow = await this.createWorkflow(workflowData);
|
||||
|
||||
// Immediately navigate to the workflow detail view
|
||||
// Get the first step from the workflow definition
|
||||
const firstStep = workflow.stepDefinitions?.[0]?.step || 1;
|
||||
this.$router.push({
|
||||
name: 'workflow-detail',
|
||||
|
|
@ -251,8 +249,6 @@ export default {
|
|||
},
|
||||
async viewWorkflowDetails(workflow) {
|
||||
console.log('Viewing details for workflow:', workflow);
|
||||
// Navigate to the workflow detail view
|
||||
// Use the workflow's current step if available, otherwise use the first step
|
||||
const currentStep = workflow.current_step ||
|
||||
workflow.payload?.current_step ||
|
||||
getWorkflow(workflow.name)?.stepDefinitions?.[0]?.step ||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue