stash
This commit is contained in:
parent
f87b689e27
commit
0c025db799
16 changed files with 555 additions and 105 deletions
|
|
@ -149,6 +149,10 @@ class StorageLocationViewSet(viewsets.ModelViewSet):
|
||||||
serializer_class = StorageLocationSerializer
|
serializer_class = StorageLocationSerializer
|
||||||
authentication_classes = [SignatureAuthentication]
|
authentication_classes = [SignatureAuthentication]
|
||||||
permission_classes = [IsAuthenticated]
|
permission_classes = [IsAuthenticated]
|
||||||
|
# Detail routes address a location by its owner-scoped id, not the internal row id. See
|
||||||
|
# docs/implementation.md#inventory-detail-routes-use-owner-scoped-ids.
|
||||||
|
lookup_field = 'id'
|
||||||
|
lookup_url_kwarg = 'pk'
|
||||||
|
|
||||||
def get_queryset(self):
|
def get_queryset(self):
|
||||||
if type(self.request.user) == KnownIdentity and self.request.user.user.exists():
|
if type(self.request.user) == KnownIdentity and self.request.user.user.exists():
|
||||||
|
|
|
||||||
|
|
@ -169,7 +169,27 @@ class ItemTag(models.Model):
|
||||||
inventory_item = models.ForeignKey(InventoryItem, on_delete=models.CASCADE)
|
inventory_item = models.ForeignKey(InventoryItem, on_delete=models.CASCADE)
|
||||||
|
|
||||||
|
|
||||||
|
class OwnerStorageLocationSequence(models.Model):
|
||||||
|
"""Tracks the last StorageLocation id handed out per owner for sequential, gapless allocation
|
||||||
|
(see StorageLocation.create_for_owner)."""
|
||||||
|
owner = models.ForeignKey(ToolshedUser, on_delete=models.CASCADE, related_name='+', unique=True)
|
||||||
|
last_id = models.PositiveIntegerField(default=0)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def allocate(cls, *, owner):
|
||||||
|
with transaction.atomic():
|
||||||
|
seq, _ = cls.objects.select_for_update().get_or_create(owner=owner)
|
||||||
|
seq.last_id += 1
|
||||||
|
seq.save(update_fields=['last_id'])
|
||||||
|
return seq.last_id
|
||||||
|
|
||||||
|
|
||||||
class StorageLocation(models.Model):
|
class StorageLocation(models.Model):
|
||||||
|
internal_id = models.AutoField(primary_key=True)
|
||||||
|
# Externally visible id, sequential/gapless within the owner's own locations (see
|
||||||
|
# OwnerStorageLocationSequence), never internal_id; always allocate via create_for_owner, not
|
||||||
|
# .objects.create().
|
||||||
|
id = models.PositiveIntegerField(editable=False)
|
||||||
name = models.CharField(max_length=255)
|
name = models.CharField(max_length=255)
|
||||||
description = models.TextField(null=True, blank=True)
|
description = models.TextField(null=True, blank=True)
|
||||||
category = models.ForeignKey(Category, on_delete=models.CASCADE, null=True, blank=True,
|
category = models.ForeignKey(Category, on_delete=models.CASCADE, null=True, blank=True,
|
||||||
|
|
@ -177,10 +197,23 @@ class StorageLocation(models.Model):
|
||||||
parent = models.ForeignKey('self', on_delete=models.CASCADE, null=True, blank=True, related_name='children')
|
parent = models.ForeignKey('self', on_delete=models.CASCADE, null=True, blank=True, related_name='children')
|
||||||
owner = models.ForeignKey(ToolshedUser, on_delete=models.CASCADE, related_name='storage_locations')
|
owner = models.ForeignKey(ToolshedUser, on_delete=models.CASCADE, related_name='storage_locations')
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
constraints = [
|
||||||
|
models.UniqueConstraint(fields=['owner', 'id'], name='storagelocation_unique_owner_scoped_id'),
|
||||||
|
]
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
parent = str(self.parent) + "/" if self.parent else ""
|
parent = str(self.parent) + "/" if self.parent else ""
|
||||||
return parent + self.name
|
return parent + self.name
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def create_for_owner(cls, *, owner, **kwargs):
|
||||||
|
"""The only supported way to create a StorageLocation: atomically allocates the next id
|
||||||
|
for this owner's scope."""
|
||||||
|
with transaction.atomic():
|
||||||
|
next_id = OwnerStorageLocationSequence.allocate(owner=owner)
|
||||||
|
return cls.objects.create(owner=owner, id=next_id, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
class WorkflowInstance(models.Model):
|
class WorkflowInstance(models.Model):
|
||||||
slug = models.CharField(max_length=255)
|
slug = models.CharField(max_length=255)
|
||||||
|
|
|
||||||
|
|
@ -339,13 +339,17 @@ def import_locations(user, data):
|
||||||
if category_path:
|
if category_path:
|
||||||
category = get_or_create_category(category_path)
|
category = get_or_create_category(category_path)
|
||||||
|
|
||||||
location, _ = StorageLocation.objects.update_or_create(
|
defaults = {
|
||||||
owner=user, name=name, parent=parent,
|
|
||||||
defaults={
|
|
||||||
'description': row.get('description', '') or '',
|
'description': row.get('description', '') or '',
|
||||||
'category': category,
|
'category': category,
|
||||||
},
|
}
|
||||||
)
|
try:
|
||||||
|
location = StorageLocation.objects.get(owner=user, name=name, parent=parent)
|
||||||
|
for field, value in defaults.items():
|
||||||
|
setattr(location, field, value)
|
||||||
|
location.save(update_fields=list(defaults.keys()))
|
||||||
|
except StorageLocation.DoesNotExist:
|
||||||
|
location = StorageLocation.create_for_owner(owner=user, name=name, parent=parent, **defaults)
|
||||||
resolved_by_path[path] = location
|
resolved_by_path[path] = location
|
||||||
imported += 1
|
imported += 1
|
||||||
except Exception as error:
|
except Exception as error:
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
from django.core.exceptions import ObjectDoesNotExist
|
||||||
from rest_framework import serializers
|
from rest_framework import serializers
|
||||||
from authentication.models import KnownIdentity, ToolshedUser, FriendRequestIncoming, Group, GroupInviteIncoming
|
from authentication.models import KnownIdentity, ToolshedUser, FriendRequestIncoming, Group, GroupInviteIncoming
|
||||||
from authentication.serializers import OwnerSerializer, GroupOwnerSerializer
|
from authentication.serializers import OwnerSerializer, GroupOwnerSerializer
|
||||||
|
|
@ -150,15 +151,50 @@ class CategorySerializer(serializers.ModelSerializer):
|
||||||
return resolve_category_handle(data.split("/")[-1])
|
return resolve_category_handle(data.split("/")[-1])
|
||||||
|
|
||||||
|
|
||||||
|
class OwnerScopedPrimaryKeyRelatedField(serializers.PrimaryKeyRelatedField):
|
||||||
|
"""Resolves/represents by the owner-scoped `id` rather than the model's internal pk, scoped to
|
||||||
|
the requesting user - StorageLocation.parent points at another StorageLocation, whose publicly
|
||||||
|
visible identity is now the owner-scoped id (see StorageLocation.create_for_owner), not
|
||||||
|
internal_id."""
|
||||||
|
|
||||||
|
def use_pk_only_optimization(self):
|
||||||
|
# False: to_representation needs the owner-scoped `id`, not just the internal pk that the
|
||||||
|
# PKOnlyObject optimization would otherwise limit us to.
|
||||||
|
return False
|
||||||
|
|
||||||
|
def get_queryset(self):
|
||||||
|
queryset = super().get_queryset()
|
||||||
|
request = self.context.get('request')
|
||||||
|
if request is not None and type(request.user) == KnownIdentity and request.user.user.exists():
|
||||||
|
return queryset.filter(owner=request.user.user.get())
|
||||||
|
return queryset.none()
|
||||||
|
|
||||||
|
def to_internal_value(self, data):
|
||||||
|
queryset = self.get_queryset()
|
||||||
|
try:
|
||||||
|
if isinstance(data, bool):
|
||||||
|
raise TypeError
|
||||||
|
return queryset.get(id=data)
|
||||||
|
except ObjectDoesNotExist:
|
||||||
|
self.fail('does_not_exist', pk_value=data)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
self.fail('incorrect_type', data_type=type(data).__name__)
|
||||||
|
|
||||||
|
def to_representation(self, value):
|
||||||
|
return value.id
|
||||||
|
|
||||||
|
|
||||||
class StorageLocationSerializer(serializers.ModelSerializer):
|
class StorageLocationSerializer(serializers.ModelSerializer):
|
||||||
owner = OwnerSerializer(read_only=True)
|
owner = OwnerSerializer(read_only=True)
|
||||||
category = serializers.CharField(required=False, allow_null=True, allow_blank=True)
|
category = serializers.CharField(required=False, allow_null=True, allow_blank=True)
|
||||||
|
parent = OwnerScopedPrimaryKeyRelatedField(queryset=StorageLocation.objects.all(), required=False,
|
||||||
|
allow_null=True)
|
||||||
path = serializers.SerializerMethodField()
|
path = serializers.SerializerMethodField()
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = StorageLocation
|
model = StorageLocation
|
||||||
fields = ['id', 'name', 'description', 'path', 'category', 'owner', 'parent']
|
fields = ['id', 'name', 'description', 'path', 'category', 'owner', 'parent']
|
||||||
read_only_fields = ['path']
|
read_only_fields = ['id', 'path']
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def get_path(obj):
|
def get_path(obj):
|
||||||
|
|
@ -166,6 +202,9 @@ class StorageLocationSerializer(serializers.ModelSerializer):
|
||||||
return StorageLocationSerializer.get_path(obj.parent) + "/" + obj.name
|
return StorageLocationSerializer.get_path(obj.parent) + "/" + obj.name
|
||||||
return obj.name
|
return obj.name
|
||||||
|
|
||||||
|
def create(self, validated_data):
|
||||||
|
return StorageLocation.create_for_owner(**validated_data)
|
||||||
|
|
||||||
|
|
||||||
class ItemPropertySerializer(serializers.ModelSerializer):
|
class ItemPropertySerializer(serializers.ModelSerializer):
|
||||||
property = PropertySerializer(read_only=True)
|
property = PropertySerializer(read_only=True)
|
||||||
|
|
@ -195,6 +234,8 @@ class InventoryItemSerializer(serializers.ModelSerializer):
|
||||||
properties = ItemPropertySerializer(many=True, required=False, source='itemproperty_set')
|
properties = ItemPropertySerializer(many=True, required=False, source='itemproperty_set')
|
||||||
category = CategorySerializer(required=False, allow_null=True)
|
category = CategorySerializer(required=False, allow_null=True)
|
||||||
files = FileSerializer(many=True, read_only=True)
|
files = FileSerializer(many=True, read_only=True)
|
||||||
|
storage_location = OwnerScopedPrimaryKeyRelatedField(queryset=StorageLocation.objects.all(), required=False,
|
||||||
|
allow_null=True)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = InventoryItem
|
model = InventoryItem
|
||||||
|
|
|
||||||
|
|
@ -49,11 +49,11 @@ class InventoryTestMixin(CategoryTestMixin, TagTestMixin, PropertyTestMixin):
|
||||||
|
|
||||||
class LocationTestMixin:
|
class LocationTestMixin:
|
||||||
def prepare_locations(self):
|
def prepare_locations(self):
|
||||||
self.f['loc1'] = StorageLocation.objects.create(name='loc1', owner=self.f['local_user1'])
|
self.f['loc1'] = StorageLocation.create_for_owner(name='loc1', owner=self.f['local_user1'])
|
||||||
self.f['loc2'] = StorageLocation.objects.create(name='loc2', owner=self.f['local_user1'],
|
self.f['loc2'] = StorageLocation.create_for_owner(name='loc2', owner=self.f['local_user1'],
|
||||||
category=self.f['cat1'])
|
category=self.f['cat1'])
|
||||||
self.f['loc3'] = StorageLocation.objects.create(name='loc3', owner=self.f['local_user1'], parent=self.f['loc1'])
|
self.f['loc3'] = StorageLocation.create_for_owner(name='loc3', owner=self.f['local_user1'], parent=self.f['loc1'])
|
||||||
self.f['loc4'] = StorageLocation.objects.create(name='loc4', owner=self.f['local_user1'], parent=self.f['loc1'],
|
self.f['loc4'] = StorageLocation.create_for_owner(name='loc4', owner=self.f['local_user1'], parent=self.f['loc1'],
|
||||||
category=self.f['cat1'])
|
category=self.f['cat1'])
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
BIN
frontend/src/assets/fonts/label/Inter-Regular.woff2
Normal file
BIN
frontend/src/assets/fonts/label/Inter-Regular.woff2
Normal file
Binary file not shown.
|
|
@ -32,13 +32,156 @@
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
import * as BIcons from "bootstrap-icons-vue";
|
import * as BIcons from "bootstrap-icons-vue";
|
||||||
import {loadAnyDCode} from "../../vendor/anyd-qr.js";
|
import {loadAnyDCode, toGrayFrame} from "../../vendor/anyd-qr.js";
|
||||||
|
// Same file as above, but as a plain URL (Vite's `?url` suffix skips bundling it into this
|
||||||
|
// module) - the decode worker built in createDecodeWorker() below needs a fetchable/importable
|
||||||
|
// URL for its own, independent `import()`, since a worker has no access to this module's own
|
||||||
|
// import bindings.
|
||||||
|
import anydUrl from "../../vendor/anyd-qr.js?url";
|
||||||
import cameraManager from "@/cameraManager.js";
|
import cameraManager from "@/cameraManager.js";
|
||||||
|
|
||||||
// How long a detected code's overlay box stays drawn before fading, so it doesn't linger once
|
// 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.
|
// the code has moved out of frame.
|
||||||
const OVERLAY_CLEAR_MS = 2000;
|
const OVERLAY_CLEAR_MS = 2000;
|
||||||
|
|
||||||
|
// Capture-loop tuning, matching prototypes/anydcode-wasm's advanced camera demo's own
|
||||||
|
// defaults/findings (see its parameter-sweep-findings.md) but at a rate suited to a
|
||||||
|
// battery-conscious inventory-scanning UI rather than that demo's max-responsiveness dial.
|
||||||
|
const CAPTURE_FPS = 4;
|
||||||
|
const CAPTURE_DOWNSCALE = 2;
|
||||||
|
|
||||||
|
// Milliseconds to suppress re-emitting a symbology+text pair already reported - independent of
|
||||||
|
// (and shorter than) COOLDOWN_MS below, since it throttles noisy duplicate "scan" events even
|
||||||
|
// on ticks where a candidate did get decoded (e.g. because the worker was otherwise idle).
|
||||||
|
const EMIT_DEDUPE_MS = 800;
|
||||||
|
|
||||||
|
// Location-based cooldown: skip re-decoding a candidate whose box overlaps a recent successful
|
||||||
|
// decode while the worker has other work pending, so a code sitting still in frame doesn't keep
|
||||||
|
// re-paying decode2d's cost every tick. Ported the IoU-overlap idea (not the code) from
|
||||||
|
// prototypes/anydcode-wasm/packages/qr-wasm/advanced_demo/pipeline.js's createCooldownTracker.
|
||||||
|
const COOLDOWN_MS = 1500;
|
||||||
|
const COOLDOWN_IOU_THRESHOLD = 0.3;
|
||||||
|
|
||||||
|
// A crop bigger than this (px, longer side) is almost always oversampled for its module count,
|
||||||
|
// so shrink it before decode2d - cheaper, and no less reliable for a close-up code - capped at
|
||||||
|
// MAX_DECODE_DOWNSCALE so a genuinely complex/high-version code that happens to fill the frame
|
||||||
|
// isn't pushed below a safe pixels-per-module floor. Values as validated by the advanced demo's
|
||||||
|
// parameter sweep (see its parameter-sweep-findings.md).
|
||||||
|
const DECODE_TARGET_CROP_SIZE = 400;
|
||||||
|
const MAX_DECODE_DOWNSCALE = 3;
|
||||||
|
|
||||||
|
// A queued decode job older than this is dropped unsent rather than dispatched - by the time the
|
||||||
|
// (single) worker would reach it, the frame it came from is stale enough that decoding it is
|
||||||
|
// wasted work. MAX_DECODE_QUEUE bounds how many candidates can be waiting at once; there's only
|
||||||
|
// one worker, so this is a queue depth, not a pool size.
|
||||||
|
const MAX_QUEUE_AGE_MS = 1000;
|
||||||
|
const MAX_DECODE_QUEUE = 4;
|
||||||
|
|
||||||
|
function boundingBox(corners) {
|
||||||
|
let x0 = Infinity, y0 = Infinity, x1 = -Infinity, y1 = -Infinity;
|
||||||
|
for (const [x, y] of corners) {
|
||||||
|
if (x < x0) x0 = x;
|
||||||
|
if (y < y0) y0 = y;
|
||||||
|
if (x > x1) x1 = x;
|
||||||
|
if (y > y1) y1 = y;
|
||||||
|
}
|
||||||
|
return {x0, y0, x1, y1};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Intersection-over-union of two `{x0,y0,x1,y1}` boxes, 0 (no overlap) to 1 (identical).
|
||||||
|
function iou(a, b) {
|
||||||
|
const x0 = Math.max(a.x0, b.x0);
|
||||||
|
const y0 = Math.max(a.y0, b.y0);
|
||||||
|
const x1 = Math.min(a.x1, b.x1);
|
||||||
|
const y1 = Math.min(a.y1, b.y1);
|
||||||
|
const interArea = Math.max(0, x1 - x0) * Math.max(0, y1 - y0);
|
||||||
|
if (interArea === 0) return 0;
|
||||||
|
const areaA = (a.x1 - a.x0) * (a.y1 - a.y0);
|
||||||
|
const areaB = (b.x1 - b.x0) * (b.y1 - b.y0);
|
||||||
|
return interArea / (areaA + areaB - interArea);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Crop a sub-frame out of `luma` (row-major, `width`x`height`), expanding by `pad` pixels and
|
||||||
|
// clamping to the source bounds, so a tight `locate()` box still leaves the decoder its quiet
|
||||||
|
// zone.
|
||||||
|
function cropGray(width, height, luma, box, pad) {
|
||||||
|
const x0 = Math.max(0, Math.floor(box.x0 - pad));
|
||||||
|
const y0 = Math.max(0, Math.floor(box.y0 - pad));
|
||||||
|
const x1 = Math.min(width, Math.ceil(box.x1 + pad));
|
||||||
|
const y1 = Math.min(height, Math.ceil(box.y1 + pad));
|
||||||
|
const w = Math.max(1, x1 - x0);
|
||||||
|
const h = Math.max(1, y1 - y0);
|
||||||
|
const out = new Uint8Array(w * h);
|
||||||
|
for (let row = 0; row < h; row++) {
|
||||||
|
const srcStart = (y0 + row) * width + x0;
|
||||||
|
out.set(luma.subarray(srcStart, srcStart + w), row * w);
|
||||||
|
}
|
||||||
|
return {width: w, height: h, luma: out, offsetX: x0, offsetY: y0};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pick how much to shrink this candidate's crop before decode2d(), from its own detected size -
|
||||||
|
// `1` (no change) for the common small/typical case; only a candidate detected as unusually
|
||||||
|
// large gets downscaled, and only by as much as its size actually calls for.
|
||||||
|
function chooseDecodeDownscale(box, targetSize, maxDownscale) {
|
||||||
|
const size = Math.max(box.x1 - box.x0, box.y1 - box.y0);
|
||||||
|
return Math.min(maxDownscale, Math.max(1, Math.floor(size / targetSize)));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Crop `box` (expanded by `pad`, clamped to the frame) for decoding, downscaling by
|
||||||
|
// `decodeDownscale` in the same step when that's `> 1`. At `1` this is exactly `cropGray`; a
|
||||||
|
// real downscale instead re-samples from `sourceCanvas` (the RGBA source) so the browser's own
|
||||||
|
// image scaling does the antialiased averaging a manual subsample of an already-grayscale array
|
||||||
|
// would not.
|
||||||
|
function cropForDecode(width, height, luma, box, pad, decodeDownscale, sourceCanvas, scratchCanvas, scratchCtx) {
|
||||||
|
if (decodeDownscale === 1) {
|
||||||
|
return {...cropGray(width, height, luma, box, pad), decodeDownscale};
|
||||||
|
}
|
||||||
|
const x0 = Math.max(0, Math.floor(box.x0 - pad));
|
||||||
|
const y0 = Math.max(0, Math.floor(box.y0 - pad));
|
||||||
|
const x1 = Math.min(width, Math.ceil(box.x1 + pad));
|
||||||
|
const y1 = Math.min(height, Math.ceil(box.y1 + pad));
|
||||||
|
const srcW = Math.max(1, x1 - x0);
|
||||||
|
const srcH = Math.max(1, y1 - y0);
|
||||||
|
const dstW = Math.max(1, Math.round(srcW / decodeDownscale));
|
||||||
|
const dstH = Math.max(1, Math.round(srcH / decodeDownscale));
|
||||||
|
if (scratchCanvas.width !== dstW || scratchCanvas.height !== dstH) {
|
||||||
|
scratchCanvas.width = dstW;
|
||||||
|
scratchCanvas.height = dstH;
|
||||||
|
}
|
||||||
|
scratchCtx.drawImage(sourceCanvas, x0, y0, srcW, srcH, 0, 0, dstW, dstH);
|
||||||
|
const cropLuma = toGrayFrame(scratchCtx.getImageData(0, 0, dstW, dstH)).luma;
|
||||||
|
return {width: dstW, height: dstH, luma: cropLuma, offsetX: x0, offsetY: y0, decodeDownscale};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Builds the off-main-thread decode2d()/decode1d() half of the pipeline: a single Worker (its
|
||||||
|
// source assembled from a template string, not a separate file, to keep this pipeline contained
|
||||||
|
// in this component) that dynamically imports the same anyd-qr.js bundle already used on the
|
||||||
|
// main thread for locate(), so a slow decode never blocks capture, detection, or this
|
||||||
|
// component's own overlay/UI rendering. It gets its own wasm instance - a worker's wasm memory
|
||||||
|
// isn't shared with the main thread - via that import's own `loadAnyDCode()` cache, exactly like
|
||||||
|
// the main thread's `anydPromise` below.
|
||||||
|
function createDecodeWorker() {
|
||||||
|
const absoluteAnydUrl = new URL(anydUrl, window.location.href).href;
|
||||||
|
const source = `
|
||||||
|
import { loadAnyDCode } from ${JSON.stringify(absoluteAnydUrl)};
|
||||||
|
const anydPromise = loadAnyDCode();
|
||||||
|
self.onmessage = async (event) => {
|
||||||
|
const {jobId, width, height, luma} = event.data;
|
||||||
|
try {
|
||||||
|
const anyd = await anydPromise;
|
||||||
|
const codes = anyd.decodeImage({width, height, luma: new Uint8Array(luma)});
|
||||||
|
self.postMessage({type: "result", jobId, codes});
|
||||||
|
} catch (err) {
|
||||||
|
self.postMessage({type: "error", jobId, message: err?.message ?? String(err)});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
`;
|
||||||
|
const blobUrl = URL.createObjectURL(new Blob([source], {type: "application/javascript"}));
|
||||||
|
const worker = new Worker(blobUrl, {type: "module"});
|
||||||
|
URL.revokeObjectURL(blobUrl);
|
||||||
|
return worker;
|
||||||
|
}
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
name: "CameraScanner",
|
name: "CameraScanner",
|
||||||
components: {
|
components: {
|
||||||
|
|
@ -173,22 +316,182 @@ export default {
|
||||||
this.localSelectedCameraId = cameraManager.getSelectedCameraId() || "";
|
this.localSelectedCameraId = cameraManager.getSelectedCameraId() || "";
|
||||||
|
|
||||||
const anyd = await this.anydPromise;
|
const anyd = await this.anydPromise;
|
||||||
this.scanner = anyd.createCameraScanner(this.$refs.video, {
|
this.detectWasm = anyd.raw;
|
||||||
fps: 4,
|
this.seen = new Map();
|
||||||
downscale: 2,
|
this.cooldownEntries = [];
|
||||||
onDecode: (codes) => {
|
clearInterval(this.captureTimer);
|
||||||
this.$emit("scan", codes);
|
this.captureTimer = setInterval(() => this.captureTick(), 1000 / CAPTURE_FPS);
|
||||||
this.drawOverlay(codes);
|
|
||||||
},
|
|
||||||
onError: (err) => console.error("[camera scan]", err),
|
|
||||||
});
|
|
||||||
this.scanner.start();
|
|
||||||
this.cameraRunning = true;
|
this.cameraRunning = true;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
this.$emit("error", `Camera error: ${e.message ?? e}`);
|
this.$emit("error", `Camera error: ${e.message ?? e}`);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// The tick driving the whole pipeline: grab+downscale the current video frame, run
|
||||||
|
// `locate()` right here on the main thread (cheap enough not to need a worker of its
|
||||||
|
// own), then hand each candidate crop off to the decode worker rather than decoding it
|
||||||
|
// inline - see createDecodeWorker()'s doc comment for why.
|
||||||
|
captureTick() {
|
||||||
|
const video = this.$refs.video;
|
||||||
|
if (!video || !video.videoWidth || !video.videoHeight || !this.detectWasm) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const width = Math.max(1, Math.round(video.videoWidth / CAPTURE_DOWNSCALE));
|
||||||
|
const height = Math.max(1, Math.round(video.videoHeight / CAPTURE_DOWNSCALE));
|
||||||
|
if (this.captureCanvas.width !== width || this.captureCanvas.height !== height) {
|
||||||
|
this.captureCanvas.width = width;
|
||||||
|
this.captureCanvas.height = height;
|
||||||
|
}
|
||||||
|
this.captureCtx.drawImage(video, 0, 0, width, height);
|
||||||
|
const frame = toGrayFrame(this.captureCtx.getImageData(0, 0, width, height));
|
||||||
|
|
||||||
|
let candidates;
|
||||||
|
try {
|
||||||
|
candidates = this.detectWasm.locate(width, height, frame.luma);
|
||||||
|
} catch (err) {
|
||||||
|
this.$emit("error", `Camera error: ${err.message ?? err}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!Array.isArray(candidates)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = performance.now();
|
||||||
|
for (const candidate of candidates) {
|
||||||
|
if (!candidate?.corners) continue;
|
||||||
|
const box = boundingBox(candidate.corners);
|
||||||
|
const nativeBox = {
|
||||||
|
x0: box.x0 * CAPTURE_DOWNSCALE,
|
||||||
|
y0: box.y0 * CAPTURE_DOWNSCALE,
|
||||||
|
x1: box.x1 * CAPTURE_DOWNSCALE,
|
||||||
|
y1: box.y1 * CAPTURE_DOWNSCALE,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Something decoded near here recently, and the worker has other work pending -
|
||||||
|
// skip re-decoding this candidate rather than spend it re-reading a code that's
|
||||||
|
// (almost certainly) still just sitting there. If the worker/queue is otherwise
|
||||||
|
// idle, decode anyway: there's no contention to save, and it keeps this
|
||||||
|
// location's cooldown entry from going stale between real hits.
|
||||||
|
if ((this.decodeWorkerBusy || this.decodeQueue.length > 0) && this.findCooldownNear(nativeBox, now)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const decodeDownscale = chooseDecodeDownscale(box, DECODE_TARGET_CROP_SIZE, MAX_DECODE_DOWNSCALE);
|
||||||
|
const crop = cropForDecode(
|
||||||
|
width, height, frame.luma, box, 4, decodeDownscale,
|
||||||
|
this.captureCanvas, this.decodeScratchCanvas, this.decodeScratchCtx);
|
||||||
|
this.enqueueDecodeJob(crop, decodeDownscale, now);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
enqueueDecodeJob(crop, decodeDownscale, now) {
|
||||||
|
this.pruneStaleJobs(now);
|
||||||
|
if (this.decodeQueue.length >= MAX_DECODE_QUEUE) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.decodeQueue.push({
|
||||||
|
jobId: ++this.jobCounter,
|
||||||
|
generation: this.captureGeneration,
|
||||||
|
enqueuedAt: now,
|
||||||
|
payload: {jobId: this.jobCounter, width: crop.width, height: crop.height, luma: crop.luma.buffer},
|
||||||
|
transfer: [crop.luma.buffer],
|
||||||
|
offsetX: crop.offsetX,
|
||||||
|
offsetY: crop.offsetY,
|
||||||
|
downscale: CAPTURE_DOWNSCALE,
|
||||||
|
decodeDownscale,
|
||||||
|
});
|
||||||
|
this.dispatchNextJob();
|
||||||
|
},
|
||||||
|
|
||||||
|
// FIFO order means the front of the queue is always the oldest, so the first
|
||||||
|
// non-expired entry means nothing behind it can be expired either - one pass from the
|
||||||
|
// front is sufficient.
|
||||||
|
pruneStaleJobs(now) {
|
||||||
|
while (this.decodeQueue.length > 0 && now - this.decodeQueue[0].enqueuedAt > MAX_QUEUE_AGE_MS) {
|
||||||
|
this.decodeQueue.shift();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
dispatchNextJob() {
|
||||||
|
if (this.decodeWorkerBusy) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.pruneStaleJobs(performance.now());
|
||||||
|
const job = this.decodeQueue.shift();
|
||||||
|
if (!job) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.decodeWorkerBusy = true;
|
||||||
|
this.pendingJob = job;
|
||||||
|
this.decodeWorker.postMessage(job.payload, job.transfer);
|
||||||
|
},
|
||||||
|
|
||||||
|
// The worker only ever saw this one candidate's crop, so any `box` it reports is
|
||||||
|
// relative to that crop's own pixels - coarser than the capture frame's if this
|
||||||
|
// candidate got its own decode-time downscale. Scale back up by that factor to land in
|
||||||
|
// the crop's own space, then apply its offset, then the capture downscale in effect
|
||||||
|
// when this job was dispatched, to land on native video pixels.
|
||||||
|
handleWorkerMessage(event) {
|
||||||
|
const msg = event.data;
|
||||||
|
const job = this.pendingJob;
|
||||||
|
this.decodeWorkerBusy = false;
|
||||||
|
this.pendingJob = null;
|
||||||
|
|
||||||
|
if (msg.type === "error") {
|
||||||
|
console.error("[camera scan]", msg.message);
|
||||||
|
} else if (job && job.jobId === msg.jobId && job.generation === this.captureGeneration) {
|
||||||
|
const now = performance.now();
|
||||||
|
const codes = (msg.codes ?? []).map((code) => ({
|
||||||
|
...code,
|
||||||
|
box: code.box && {
|
||||||
|
x0: (code.box.x0 * job.decodeDownscale + job.offsetX) * job.downscale,
|
||||||
|
y0: (code.box.y0 * job.decodeDownscale + job.offsetY) * job.downscale,
|
||||||
|
x1: (code.box.x1 * job.decodeDownscale + job.offsetX) * job.downscale,
|
||||||
|
y1: (code.box.y1 * job.decodeDownscale + job.offsetY) * job.downscale,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
for (const code of codes) {
|
||||||
|
if (code.box) this.rememberCooldown(code.box, now);
|
||||||
|
}
|
||||||
|
|
||||||
|
const dedupeNow = Date.now();
|
||||||
|
const fresh = codes.filter((code) => {
|
||||||
|
const key = `${code.type}|${code.text ?? ""}`;
|
||||||
|
const lastSeen = this.seen.get(key);
|
||||||
|
if (lastSeen !== undefined && dedupeNow - lastSeen < EMIT_DEDUPE_MS) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
this.seen.set(key, dedupeNow);
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
if (fresh.length > 0) {
|
||||||
|
this.$emit("scan", fresh);
|
||||||
|
this.drawOverlay(fresh);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.dispatchNextJob();
|
||||||
|
},
|
||||||
|
|
||||||
|
rememberCooldown(box, now) {
|
||||||
|
const existing = this.cooldownEntries.find((e) => iou(e.box, box) >= COOLDOWN_IOU_THRESHOLD);
|
||||||
|
if (existing) {
|
||||||
|
existing.box = box;
|
||||||
|
existing.decodedAt = now;
|
||||||
|
} else {
|
||||||
|
this.cooldownEntries.push({box, decodedAt: now});
|
||||||
|
}
|
||||||
|
// Prune well past the cooldown window so a long session doesn't accumulate forever.
|
||||||
|
this.cooldownEntries = this.cooldownEntries.filter((e) => now - e.decodedAt < COOLDOWN_MS * 4);
|
||||||
|
},
|
||||||
|
|
||||||
|
findCooldownNear(box, now) {
|
||||||
|
return this.cooldownEntries.some(
|
||||||
|
(e) => now - e.decodedAt < COOLDOWN_MS && iou(e.box, box) >= COOLDOWN_IOU_THRESHOLD);
|
||||||
|
},
|
||||||
|
|
||||||
async switchCamera(cameraId) {
|
async switchCamera(cameraId) {
|
||||||
if (!this.cameraRunning) return;
|
if (!this.cameraRunning) return;
|
||||||
try {
|
try {
|
||||||
|
|
@ -200,8 +503,15 @@ export default {
|
||||||
},
|
},
|
||||||
|
|
||||||
stopCamera() {
|
stopCamera() {
|
||||||
this.scanner?.stop();
|
clearInterval(this.captureTimer);
|
||||||
this.scanner = null;
|
this.captureTimer = null;
|
||||||
|
this.decodeQueue = [];
|
||||||
|
// Bumped so a decode job dispatched before this stop - still in flight in the
|
||||||
|
// worker - is recognized as stale and discarded once its result arrives (see
|
||||||
|
// handleWorkerMessage), rather than applied against whatever's running by then.
|
||||||
|
// decodeWorkerBusy/pendingJob are deliberately left alone: they resolve themselves
|
||||||
|
// when that in-flight result lands.
|
||||||
|
this.captureGeneration++;
|
||||||
cameraManager.closeStream();
|
cameraManager.closeStream();
|
||||||
if (this.$refs.video) {
|
if (this.$refs.video) {
|
||||||
this.$refs.video.srcObject = null;
|
this.$refs.video.srcObject = null;
|
||||||
|
|
@ -235,8 +545,28 @@ export default {
|
||||||
// in-flight load; loadAnyDCode also memoizes, so later calls elsewhere in the app are
|
// in-flight load; loadAnyDCode also memoizes, so later calls elsewhere in the app are
|
||||||
// free too.
|
// free too.
|
||||||
this.anydPromise = loadAnyDCode();
|
this.anydPromise = loadAnyDCode();
|
||||||
this.scanner = null;
|
this.detectWasm = null;
|
||||||
this.clearOverlayTimer = null;
|
this.clearOverlayTimer = null;
|
||||||
|
this.captureTimer = null;
|
||||||
|
this.captureGeneration = 0;
|
||||||
|
this.seen = new Map();
|
||||||
|
this.cooldownEntries = [];
|
||||||
|
|
||||||
|
// decode2d()/decode1d() run in this worker, off the main thread, so a slow decode never
|
||||||
|
// stalls locate()/capture or this component's own rendering - see createDecodeWorker().
|
||||||
|
// Long-lived for the component's lifetime (not recreated per start/stop) so switching
|
||||||
|
// cameras or restarting the scan doesn't pay wasm-instantiation cost again.
|
||||||
|
this.decodeWorker = createDecodeWorker();
|
||||||
|
this.decodeWorker.onmessage = (event) => this.handleWorkerMessage(event);
|
||||||
|
this.decodeWorkerBusy = false;
|
||||||
|
this.pendingJob = null;
|
||||||
|
this.jobCounter = 0;
|
||||||
|
this.decodeQueue = [];
|
||||||
|
|
||||||
|
this.captureCanvas = typeof OffscreenCanvas !== "undefined" ? new OffscreenCanvas(1, 1) : document.createElement("canvas");
|
||||||
|
this.captureCtx = this.captureCanvas.getContext("2d");
|
||||||
|
this.decodeScratchCanvas = typeof OffscreenCanvas !== "undefined" ? new OffscreenCanvas(1, 1) : document.createElement("canvas");
|
||||||
|
this.decodeScratchCtx = this.decodeScratchCanvas.getContext("2d");
|
||||||
},
|
},
|
||||||
mounted() {
|
mounted() {
|
||||||
window.addEventListener("resize", this.syncOverlaySize);
|
window.addEventListener("resize", this.syncOverlaySize);
|
||||||
|
|
@ -260,6 +590,7 @@ export default {
|
||||||
this.$refs.video?.removeEventListener("loadedmetadata", this.onVideoResize);
|
this.$refs.video?.removeEventListener("loadedmetadata", this.onVideoResize);
|
||||||
this.$refs.video?.removeEventListener("resize", this.onVideoResize);
|
this.$refs.video?.removeEventListener("resize", this.onVideoResize);
|
||||||
clearTimeout(this.clearOverlayTimer);
|
clearTimeout(this.clearOverlayTimer);
|
||||||
|
this.decodeWorker.terminate();
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
|
||||||
|
|
@ -90,12 +90,12 @@
|
||||||
background: #fff;
|
background: #fff;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Corner badge flagging *why* a layout that's otherwise available (fields filled in) still
|
/* Corner badge flagging *why* a layout failed to render - a capacity error (label.js's "bigger
|
||||||
failed to render - e.g. label.js's "bigger code than the tape allows" or "doesn't fit on this
|
code than the tape allows"/"doesn't fit on this tape") or label-layouts.js's templateContent
|
||||||
tape" errors (see redraw()'s catch). Shows the short label directly (e.g. "too big") rather than
|
throwing "missing data" because a field it needs isn't filled in yet (see redraw()'s catch).
|
||||||
just an icon, so the reason reads at a glance; the full sentence is still the title tooltip. Not
|
Shows the short label directly (e.g. "too big", "missing data") rather than just an icon, so the
|
||||||
shown for the more common "fields not filled in yet" case (see isSelectable/unavailableReason)
|
reason reads at a glance; the full sentence is still the title tooltip. Layered on top of the
|
||||||
since that's already conveyed by the greyed-out thumbnail. */
|
greyed-out/unselectable state isSelectable/unavailableReason already apply for the same case. */
|
||||||
.template-thumb-warning {
|
.template-thumb-warning {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: .35rem;
|
top: .35rem;
|
||||||
|
|
@ -276,17 +276,8 @@ export default {
|
||||||
if (!canvas) {
|
if (!canvas) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const content = templateContent(t, this.fields);
|
|
||||||
if (!this.isAvailable(t) || !content) {
|
|
||||||
canvas.width = 1;
|
|
||||||
canvas.height = 1;
|
|
||||||
delete this.failed[t.id];
|
|
||||||
delete this.warned[t.id];
|
|
||||||
delete this.qrInfo[t.id];
|
|
||||||
delete this.textInfo[t.id];
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
try {
|
try {
|
||||||
|
const content = templateContent(t, this.fields);
|
||||||
const {warnings, qrInfo, textInfo} = this.tape
|
const {warnings, qrInfo, textInfo} = this.tape
|
||||||
? drawLabel(canvas, this.tape, content, "along")
|
? drawLabel(canvas, this.tape, content, "along")
|
||||||
: drawFallbackLabel(canvas, content, "along");
|
: drawFallbackLabel(canvas, content, "along");
|
||||||
|
|
|
||||||
|
|
@ -86,14 +86,39 @@ export const LABEL_TEMPLATES = [
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "rmqr-url", name: "rMQR Token", description: "The code with the encoded text printed next to it.",
|
id: "rmqr-url", name: "rMQR Token", description: "The code with the encoded text printed next to it.",
|
||||||
required_vars: ["shortUrl","itemId"], tags: ["external"],
|
required_vars: ["shortUrl", "itemId"], tags: ["external"],
|
||||||
layout: [{type: "rmqr", content: c => c.shortUrl},GAP,{type: "text", content: c => c.itemId?.toString().padStart(4, "0")}]
|
layout: [{type: "rmqr", content: c => c.shortUrl}, GAP, {
|
||||||
|
type: "text",
|
||||||
|
content: c => c.itemId?.toString().padStart(4, "0")
|
||||||
|
}]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "mqr-tokem-id", name: "rMQR Token", description: "The code with the encoded text printed next to it.",
|
id: "mqr-tokem-id", name: "rMQR Token", description: "The code with the encoded text printed next to it.",
|
||||||
required_vars: ["shortId","itemId"], tags: ["internal"],
|
required_vars: ["shortId", "itemId"], tags: ["internal"],
|
||||||
layout: [{type: "mqr", content: c => c.shortId},GAP,{type: "text", content: c => c.itemId?.toString().padStart(4, "0")}]
|
layout: [{type: "mqr", content: c => c.shortId}, GAP, {
|
||||||
},...QR_ONLY_TEMPLATES,
|
type: "text",
|
||||||
|
content: c => c.itemId?.toString().padStart(4, "0")
|
||||||
|
}]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "location-rmqr-url", name: "rMQR Token", description: "The code with the encoded text printed next to it.",
|
||||||
|
required_vars: ["shortUrl", "locationId"], tags: ["external"],
|
||||||
|
layout: [{type: "rmqr", content: c => c.shortUrl}, GAP, {
|
||||||
|
type: "text",
|
||||||
|
content: c => c.locationId?.toString().padStart(4, "0")
|
||||||
|
}]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "location-mqr-tokem-id",
|
||||||
|
name: "rMQR Token",
|
||||||
|
description: "The code with the encoded text printed next to it.",
|
||||||
|
required_vars: ["shortId", "locationId"],
|
||||||
|
tags: ["internal"],
|
||||||
|
layout: [{type: "mqr", content: c => c.shortId}, GAP, {
|
||||||
|
type: "text",
|
||||||
|
content: c => c.locationId?.toString().padStart(4, "0")
|
||||||
|
}]
|
||||||
|
}, ...QR_ONLY_TEMPLATES,
|
||||||
|
|
||||||
{
|
{
|
||||||
id: "qr-text", name: "QR code + text", description: "The code with the encoded text printed next to it.",
|
id: "qr-text", name: "QR code + text", description: "The code with the encoded text printed next to it.",
|
||||||
|
|
@ -110,7 +135,7 @@ export const LABEL_TEMPLATES = [
|
||||||
id: "id-qr-text-vertical", name: "ID + QR code + text below",
|
id: "id-qr-text-vertical", name: "ID + QR code + text below",
|
||||||
description: "The code with the encoded text printed below it.",
|
description: "The code with the encoded text printed below it.",
|
||||||
required_vars: ["itemId", "text", "userHandle"],
|
required_vars: ["itemId", "text", "userHandle"],
|
||||||
layout: [[{type: "text", content: c => "Item: "+c.itemId}, GAP, {
|
layout: [[{type: "text", content: c => "Item: " + c.itemId}, GAP, {
|
||||||
type: "qr",
|
type: "qr",
|
||||||
content: c => c.text
|
content: c => c.text
|
||||||
}, GAP, {type: "text", content: c => c.userHandle}]]
|
}, GAP, {type: "text", content: c => c.userHandle}]]
|
||||||
|
|
@ -148,6 +173,17 @@ export const LABEL_TEMPLATES = [
|
||||||
required_vars: ["userHandle", "itemId"], tags: ["internal"],
|
required_vars: ["userHandle", "itemId"], tags: ["internal"],
|
||||||
layout: [{type: "text", content: c => [c.userHandle, c.itemId]}]
|
layout: [{type: "text", content: c => [c.userHandle, c.itemId]}]
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: "location-id", name: "Location ID", description: "Just the bare storage location id, as text only.",
|
||||||
|
required_vars: ["locationId"], tags: ["internal"],
|
||||||
|
layout: [{type: "text", content: c => c.locationId}]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "owner-id-text-location", name: "Owner + location ID",
|
||||||
|
description: "The owner's handle and the location id, as two lines of text - no code.",
|
||||||
|
required_vars: ["userHandle", "locationId"], tags: ["internal"],
|
||||||
|
layout: [{type: "text", content: c => [c.userHandle, c.locationId]}]
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: "item-url-qr-handle", name: "Item URL + handle",
|
id: "item-url-qr-handle", name: "Item URL + handle",
|
||||||
description: "Scannable item URL, with the item's compact handle printed alongside.",
|
description: "Scannable item URL, with the item's compact handle printed alongside.",
|
||||||
|
|
@ -272,14 +308,15 @@ export function templateIsAvailable(t, fields) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Resolves t's content leaves against fields into a tree for label.js's
|
// Resolves t's content leaves against fields into a tree for label.js's
|
||||||
// drawLabel/drawFallbackLabel, or null if nothing to render yet.
|
// drawLabel/drawFallbackLabel. Throws the same {message, short} shape as label.js's own
|
||||||
|
// capacity errors (see docs/implementation.md#missing-data-is-a-templatecontent-error) when a
|
||||||
|
// leaf isn't resolved yet, rather than returning null, so a caller's single catch around
|
||||||
|
// drawLabel/drawFallbackLabel handles both without a separate isAvailable pre-check.
|
||||||
export function templateContent(t, fields) {
|
export function templateContent(t, fields) {
|
||||||
const tree = mapTree(t.layout, leaf => leaf.type === "empty" ? leaf : {...leaf, value: leaf.content(fields)});
|
if (!templateIsAvailable(t, fields)) {
|
||||||
let hasContent = false;
|
const err = new Error("This layout needs more fields filled in before it can be drawn.");
|
||||||
walkLeaves(tree, leaf => {
|
err.short = "missing data";
|
||||||
if (leaf.type !== "empty" && leaf.value && (!Array.isArray(leaf.value) || leaf.value.some(Boolean))) {
|
throw err;
|
||||||
hasContent = true;
|
|
||||||
}
|
}
|
||||||
});
|
return mapTree(t.layout, leaf => leaf.type === "empty" ? leaf : {...leaf, value: leaf.content(fields)});
|
||||||
return hasContent ? tree : null;
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -81,14 +81,18 @@ const TEXT_REFERENCE_PX = 100; /* font size text leaves measure their natural a
|
||||||
|
|
||||||
// Below 10px, a general-purpose sans-serif gets illegible, so drawTextLeaf switches to a bitmap
|
// Below 10px, a general-purpose sans-serif gets illegible, so drawTextLeaf switches to a bitmap
|
||||||
// font (Tom Thumb/Silkscreen) instead; see the empirical rationale (font choice, `scale`, and why
|
// font (Tom Thumb/Silkscreen) instead; see the empirical rationale (font choice, `scale`, and why
|
||||||
// sizes aren't dpi-adjusted) at docs/implementation.md#pixel-font-selection.
|
// sizes aren't dpi-adjusted) at docs/implementation.md#pixel-font-selection. The 10px+ tier names
|
||||||
const PIXEL_FONT_TIERS = [
|
// Inter explicitly (see ../scss/_label-fonts.scss) rather than falling back to the CSS generic
|
||||||
{belowPx: 8, family: "Tom Thumb", scale: 3.2},
|
// "sans-serif" keyword, which resolves to a different real font per browser/OS and would make the
|
||||||
{belowPx: 10, family: "Silkscreen"},
|
// same label print differently depending on where it was rendered from.
|
||||||
|
const FONT_TIERS = [
|
||||||
|
{belowPx: 8, family: "Tom Thumb", scale: 3.2, pixel: true},
|
||||||
|
{belowPx: 10, family: "Silkscreen", pixel: true},
|
||||||
|
{belowPx: Infinity, family: "Inter"},
|
||||||
];
|
];
|
||||||
|
|
||||||
function fontFamilyFor(fontPx) {
|
function fontFamilyFor(fontPx) {
|
||||||
return PIXEL_FONT_TIERS.find(t => fontPx < t.belowPx) ?? {family: "sans-serif"};
|
return FONT_TIERS.find(t => fontPx < t.belowPx);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Below this font size (px) or physical height (mm) - Tom Thumb's real-Chromium-tested legibility
|
// Below this font size (px) or physical height (mm) - Tom Thumb's real-Chromium-tested legibility
|
||||||
|
|
@ -291,11 +295,12 @@ function checkTextSizes(node, referencePx, pxPerMm, warnings, textInfo) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Always measures in sans-serif at the reference size, since the eventual font (see
|
// Always measures in Inter at the reference size, since the eventual font (see FONT_TIERS) isn't
|
||||||
// PIXEL_FONT_TIERS) isn't known until layoutTree sizes the box this aspect ratio feeds into; the
|
// known until layoutTree sizes the box this aspect ratio feeds into; the resulting mismatch (when
|
||||||
// resulting mismatch is invisible in practice, and checkTextSizes still catches real failures.
|
// a pixel font tier ends up chosen instead) is invisible in practice, and checkTextSizes still
|
||||||
|
// catches real failures.
|
||||||
function measureTextBlock(ctx, lines, referencePx) {
|
function measureTextBlock(ctx, lines, referencePx) {
|
||||||
ctx.font = `${referencePx}px sans-serif`;
|
ctx.font = `${referencePx}px "Inter"`;
|
||||||
const width = Math.max(...lines.map(line => ctx.measureText(line).width));
|
const width = Math.max(...lines.map(line => ctx.measureText(line).width));
|
||||||
const height = referencePx * 1.15 * lines.length;
|
const height = referencePx * 1.15 * lines.length;
|
||||||
return {width, height};
|
return {width, height};
|
||||||
|
|
@ -342,20 +347,18 @@ function drawQrLeaf(ctx, node) {
|
||||||
// there's no "too small to draw" case left to special-case here.
|
// there's no "too small to draw" case left to special-case here.
|
||||||
function drawTextLeaf(ctx, node, referencePx) {
|
function drawTextLeaf(ctx, node, referencePx) {
|
||||||
const fontPx = effectiveFontPx(node, referencePx);
|
const fontPx = effectiveFontPx(node, referencePx);
|
||||||
const {family, scale = 1} = fontFamilyFor(fontPx);
|
const {family, scale = 1, pixel: isPixelFont = false} = fontFamilyFor(fontPx);
|
||||||
const isPixelFont = family !== "sans-serif";
|
|
||||||
// Position (not size - fontPx is already a whole pixel) still needs snapping for pixel fonts
|
// Position (not size - fontPx is already a whole pixel) still needs snapping for pixel fonts
|
||||||
// to stay grid-aligned, since node.box.x/y are ordinary (fractional) layout math; sans-serif is
|
// to stay grid-aligned, since node.box.x/y are ordinary (fractional) layout math; Inter is
|
||||||
// left exact since anti-aliasing handles fractional positions fine.
|
// left exact since anti-aliasing handles fractional positions fine.
|
||||||
const snap = isPixelFont ? Math.round : (v) => v;
|
const snap = isPixelFont ? Math.round : (v) => v;
|
||||||
// `scale` (Tom Thumb only, see PIXEL_FONT_TIERS above) corrects the size handed to ctx.font
|
// `scale` (Tom Thumb only, see FONT_TIERS above) corrects the size handed to ctx.font for its
|
||||||
// for its real ink; fontPx itself stays the logical size used for centering/stacking math.
|
// real ink; fontPx itself stays the logical size used for centering/stacking math.
|
||||||
ctx.font = `${fontPx * scale}px "${family}"`;
|
ctx.font = `${fontPx * scale}px "${family}"`;
|
||||||
// Canvas text silently falls back if drawn before a not-yet-loaded font resolves, unlike DOM
|
// Canvas text silently falls back if drawn before a not-yet-loaded font resolves, unlike DOM
|
||||||
// text. See docs/implementation.md#canvas-font-loading.
|
// text. See docs/implementation.md#canvas-font-loading. Every tier now names a real,
|
||||||
if (isPixelFont) {
|
// self-hosted webfont (see FONT_TIERS above), so this always applies, not just to pixel fonts.
|
||||||
document.fonts.load(ctx.font);
|
document.fonts.load(ctx.font);
|
||||||
}
|
|
||||||
ctx.textAlign = "center";
|
ctx.textAlign = "center";
|
||||||
const centerX = snap(node.box.x + node.box.width / 2);
|
const centerX = snap(node.box.x + node.box.width / 2);
|
||||||
const lineHeight = node.box.height / node.lines.length;
|
const lineHeight = node.box.height / node.lines.length;
|
||||||
|
|
@ -527,9 +530,9 @@ export function drawFallbackLabel(canvas, content, orientation = "along") {
|
||||||
// print label should show/encode; keyed by `kind` so each kind's format is defined in one place.
|
// print label should show/encode; keyed by `kind` so each kind's format is defined in one place.
|
||||||
export const LABEL_CONTENT_BUILDERS = {
|
export const LABEL_CONTENT_BUILDERS = {
|
||||||
// The self-contained Item URL (see docs/design-in-progress/items-labels.md), built from just
|
// The self-contained Item URL (see docs/design-in-progress/items-labels.md), built from just
|
||||||
// the prefill's {userHandle, id}; the short link (Print.vue's `shortUrl`) needs an async store
|
// the prefill's {userHandle, item}; the short link (Print.vue's `shortUrl`) needs an async store
|
||||||
// lookup, so it stays a separate field/template rather than being baked in here.
|
// lookup, so it stays a separate field/template rather than being baked in here.
|
||||||
"item": ({userHandle, id}) => `${window.location.origin}/i/${encodeHandleForUrl(userHandle)}/${id}`,
|
"item": ({userHandle, item}) => `${window.location.origin}/i/${encodeHandleForUrl(userHandle)}/${item}`,
|
||||||
// Storage locations have no long-form URL route yet (see router.js), so `text` starts blank;
|
// Storage locations have no long-form URL route yet (see router.js), so `text` starts blank;
|
||||||
// the short link and any future location template still work via the base vars below.
|
// the short link and any future location template still work via the base vars below.
|
||||||
};
|
};
|
||||||
|
|
@ -542,7 +545,7 @@ export function buildLabelContent(prefill) {
|
||||||
return build ? build(prefill.components) : "";
|
return build ? build(prefill.components) : "";
|
||||||
}
|
}
|
||||||
|
|
||||||
// Splits a prefill's {userHandle, id} into label-layouts.js's user/domain base vars (the same way
|
// Splits a prefill's {userHandle, item/location} into label-layouts.js's user/domain base vars (the same way
|
||||||
// store.js's lookupServer does), tagging on whichever id field the resource's templates key
|
// store.js's lookupServer does), tagging on whichever id field the resource's templates key
|
||||||
// required_vars by.
|
// required_vars by.
|
||||||
function splitUserHandle(userHandle) {
|
function splitUserHandle(userHandle) {
|
||||||
|
|
@ -560,19 +563,19 @@ function splitUserHandle(userHandle) {
|
||||||
// computed live elsewhere (see DERIVED_VARS, Print.vue's `shortUrl`), and omitting a field
|
// computed live elsewhere (see DERIVED_VARS, Print.vue's `shortUrl`), and omitting a field
|
||||||
// (rather than leaving it present-but-empty) signals "not available" to templateIsAvailable.
|
// (rather than leaving it present-but-empty) signals "not available" to templateIsAvailable.
|
||||||
const LABEL_FIELD_BUILDERS = {
|
const LABEL_FIELD_BUILDERS = {
|
||||||
"item": ({userHandle, id}) => {
|
"item": ({userHandle, item}) => {
|
||||||
const split = splitUserHandle(userHandle);
|
const split = splitUserHandle(userHandle);
|
||||||
if (!split || !id) {
|
if (!split || !item) {
|
||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
return {...split, itemId: String(id)};
|
return {...split, itemId: String(item)};
|
||||||
},
|
},
|
||||||
"storage-location": ({userHandle, id}) => {
|
"storage-location": ({userHandle, location}) => {
|
||||||
const split = splitUserHandle(userHandle);
|
const split = splitUserHandle(userHandle);
|
||||||
if (!split || !id) {
|
if (!split || !location) {
|
||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
return {...split, locationId: String(id)};
|
return {...split, locationId: String(location)};
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -139,7 +139,9 @@ const routes = [{path: '/', component: Dashboard, meta: {requiresAuth: true}}, {
|
||||||
meta: {requiresAuth: true},
|
meta: {requiresAuth: true},
|
||||||
props: route => {
|
props: route => {
|
||||||
const {kind, ...components} = route.query;
|
const {kind, ...components} = route.query;
|
||||||
return {prefill: kind ? {kind, components} : null};
|
// queryFields lets any base var (e.g. ?text=...) be set directly, without going through
|
||||||
|
// a `kind` builder - see Print.vue's varValues.
|
||||||
|
return {prefill: kind ? {kind, components} : null, queryFields: components};
|
||||||
}
|
}
|
||||||
}, {
|
}, {
|
||||||
path: '/scan',
|
path: '/scan',
|
||||||
|
|
|
||||||
|
|
@ -94,6 +94,7 @@ $body-color: $gray-700;
|
||||||
@import "dropdown";
|
@import "dropdown";
|
||||||
@import "pixel-fonts";
|
@import "pixel-fonts";
|
||||||
@import "pixel-fonts-candidates";
|
@import "pixel-fonts-candidates";
|
||||||
|
@import "label-fonts";
|
||||||
|
|
||||||
#root, body, html {
|
#root, body, html {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
|
|
|
||||||
|
|
@ -154,7 +154,7 @@ export default {
|
||||||
// See docs/implementation.md#print-link-shape-for-personal-items.
|
// See docs/implementation.md#print-link-shape-for-personal-items.
|
||||||
printLinkFor(item) {
|
printLinkFor(item) {
|
||||||
if (!item.owner) return null
|
if (!item.owner) return null
|
||||||
return {path: '/print', query: {kind: 'item', userHandle: item.owner, id: item.id}}
|
return {path: '/print', query: {kind: 'item', userHandle: item.owner, item: item.id}}
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
async mounted() {
|
async mounted() {
|
||||||
|
|
|
||||||
|
|
@ -51,7 +51,7 @@
|
||||||
Delete
|
Delete
|
||||||
</button>
|
</button>
|
||||||
<button v-if="canPrint" class="btn btn-secondary"
|
<button v-if="canPrint" class="btn btn-secondary"
|
||||||
@click="$router.push({path: '/print', query: {kind: 'item', userHandle: decodedHandle, id}})">
|
@click="$router.push({path: '/print', query: {kind: 'item', userHandle: decodedHandle, item: id}})">
|
||||||
<b-icon-printer></b-icon-printer>
|
<b-icon-printer></b-icon-printer>
|
||||||
Print label
|
Print label
|
||||||
</button>
|
</button>
|
||||||
|
|
|
||||||
|
|
@ -57,7 +57,7 @@
|
||||||
</div>
|
</div>
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<p class="text-muted small mb-3">
|
<p class="text-muted small mb-3">
|
||||||
Nine pixel/bitmap-style fonts found in frontend/public, rendered live from the "Text"
|
Ten pixel/bitmap-style fonts found in frontend/public, rendered live from the "Text"
|
||||||
field above at a range of sizes so they can be judged the same way Tom Thumb/Silkscreen
|
field above at a range of sizes so they can be judged the same way Tom Thumb/Silkscreen
|
||||||
were. <strong>CodersCrux</strong> and <strong>712Serif</strong> fail to load as web
|
were. <strong>CodersCrux</strong> and <strong>712Serif</strong> fail to load as web
|
||||||
fonts at all - Chromium's OTS sanitizer rejects their cmap table - so their cells below
|
fonts at all - Chromium's OTS sanitizer rejects their cmap table - so their cells below
|
||||||
|
|
@ -86,7 +86,7 @@
|
||||||
<h5 class="card-title mb-0">Label preview</h5>
|
<h5 class="card-title mb-0">Label preview</h5>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<div class="label-preview mb-3" v-show="selectedContent">
|
<div class="label-preview mb-3" v-show="fallbackReady">
|
||||||
<canvas ref="fallbackCanvas"></canvas>
|
<canvas ref="fallbackCanvas"></canvas>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -144,7 +144,7 @@
|
||||||
Connect a printer to preview and print a label.
|
Connect a printer to preview and print a label.
|
||||||
</p>
|
</p>
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<div class="preview-row mb-3" v-show="selectedContent">
|
<div class="preview-row mb-3" v-show="labelBitmap">
|
||||||
<div class="ruler-v">
|
<div class="ruler-v">
|
||||||
<div class="ruler-v-corner"></div>
|
<div class="ruler-v-corner"></div>
|
||||||
<div class="ruler ruler-v-ticks"
|
<div class="ruler ruler-v-ticks"
|
||||||
|
|
@ -306,6 +306,12 @@ export default {
|
||||||
prefill: {
|
prefill: {
|
||||||
type: Object,
|
type: Object,
|
||||||
default: null
|
default: null
|
||||||
|
},
|
||||||
|
// Every query param other than `kind` (router.js), so any base var - e.g. ?text=... - can
|
||||||
|
// be set directly without needing a `kind` builder in label-layouts.js.
|
||||||
|
queryFields: {
|
||||||
|
type: Object,
|
||||||
|
default: () => ({})
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
|
|
@ -327,13 +333,14 @@ export default {
|
||||||
// Scan-reliability messages from label.js's drawLabel/drawFallbackLabel (too few px per QR module, or too small in mm) - the label still rendered/prints fine, just flagged as a risk.
|
// Scan-reliability messages from label.js's drawLabel/drawFallbackLabel (too few px per QR module, or too small in mm) - the label still rendered/prints fine, just flagged as a risk.
|
||||||
warnings: [],
|
warnings: [],
|
||||||
|
|
||||||
// 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.
|
// 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. queryFields is applied last so an explicit ?text=... etc. always wins over a `kind` builder's computed value.
|
||||||
varValues: {
|
varValues: {
|
||||||
...Object.fromEntries(BASE_VARS.map(v => [v, ""])),
|
...Object.fromEntries(BASE_VARS.map(v => [v, ""])),
|
||||||
text: buildLabelContent(this.prefill),
|
text: buildLabelContent(this.prefill),
|
||||||
// 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.
|
// 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,
|
webdomain: window.location.origin,
|
||||||
...buildLabelFields(this.prefill),
|
...buildLabelFields(this.prefill),
|
||||||
|
...Object.fromEntries(BASE_VARS.filter(v => v in this.queryFields).map(v => [v, this.queryFields[v]])),
|
||||||
},
|
},
|
||||||
copies: 1,
|
copies: 1,
|
||||||
selectedTemplate: LABEL_TEMPLATES[0].id,
|
selectedTemplate: LABEL_TEMPLATES[0].id,
|
||||||
|
|
@ -347,7 +354,7 @@ export default {
|
||||||
brotherCliExample: "brother_ql --model QL-000 --printer usb://0000:0000 print --label 00 label.png",
|
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",
|
niimbotCliExample: "niimprint --model b00 --conn usb print --density 3 --image label.png",
|
||||||
|
|
||||||
// Nine pixel/bitmap font candidates for legibility testing only (see the disabled card above); not used by label.js's real PIXEL_FONT_TIERS.
|
// Ten pixel/bitmap font candidates for legibility testing only (see the disabled card above); not used by label.js's real FONT_TIERS.
|
||||||
candidateFonts: [
|
candidateFonts: [
|
||||||
{family: "Pixelon"},
|
{family: "Pixelon"},
|
||||||
{family: "Pixelbasel"},
|
{family: "Pixelbasel"},
|
||||||
|
|
@ -358,6 +365,7 @@ export default {
|
||||||
{family: "6pxExpert"},
|
{family: "6pxExpert"},
|
||||||
{family: "Jersey10"},
|
{family: "Jersey10"},
|
||||||
{family: "Jersey15"},
|
{family: "Jersey15"},
|
||||||
|
{family: "Terminus"},
|
||||||
],
|
],
|
||||||
candidateSizes: [5, 6, 7, 8, 10, 12, 16, 20],
|
candidateSizes: [5, 6, 7, 8, 10, 12, 16, 20],
|
||||||
candidateZoom: 4,
|
candidateZoom: 4,
|
||||||
|
|
@ -395,9 +403,6 @@ export default {
|
||||||
currentTemplate() {
|
currentTemplate() {
|
||||||
return LABEL_TEMPLATES.find(t => t.id === this.selectedTemplate) || LABEL_TEMPLATES[0];
|
return LABEL_TEMPLATES.find(t => t.id === this.selectedTemplate) || LABEL_TEMPLATES[0];
|
||||||
},
|
},
|
||||||
selectedContent() {
|
|
||||||
return templateContent(this.currentTemplate, this.fields);
|
|
||||||
},
|
|
||||||
deviceRows() {
|
deviceRows() {
|
||||||
if (!this.blob) {
|
if (!this.blob) {
|
||||||
return [];
|
return [];
|
||||||
|
|
@ -625,8 +630,7 @@ export default {
|
||||||
|
|
||||||
redraw() {
|
redraw() {
|
||||||
this.labelBitmap = null;
|
this.labelBitmap = null;
|
||||||
const content = this.selectedContent;
|
if (!this.tape) {
|
||||||
if (!this.tape || !content) {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const canvas = this.$refs.labelCanvas;
|
const canvas = this.$refs.labelCanvas;
|
||||||
|
|
@ -636,6 +640,7 @@ export default {
|
||||||
this.resizeObserver.observe(canvas.parentElement);
|
this.resizeObserver.observe(canvas.parentElement);
|
||||||
let textSizesPx, warnings;
|
let textSizesPx, warnings;
|
||||||
try {
|
try {
|
||||||
|
const content = templateContent(this.currentTemplate, this.fields);
|
||||||
({textSizesPx, warnings} = drawLabel(canvas, this.tape, content, this.orientation));
|
({textSizesPx, warnings} = drawLabel(canvas, this.tape, content, this.orientation));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
this.error = e.message;
|
this.error = e.message;
|
||||||
|
|
@ -653,10 +658,6 @@ export default {
|
||||||
|
|
||||||
redrawFallback() {
|
redrawFallback() {
|
||||||
this.fallbackReady = false;
|
this.fallbackReady = false;
|
||||||
const content = this.selectedContent;
|
|
||||||
if (!content) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const canvas = this.$refs.fallbackCanvas;
|
const canvas = this.$refs.fallbackCanvas;
|
||||||
if (!canvas) {
|
if (!canvas) {
|
||||||
return;
|
return;
|
||||||
|
|
@ -664,6 +665,7 @@ export default {
|
||||||
this.resizeObserver.observe(canvas.parentElement);
|
this.resizeObserver.observe(canvas.parentElement);
|
||||||
let warnings;
|
let warnings;
|
||||||
try {
|
try {
|
||||||
|
const content = templateContent(this.currentTemplate, this.fields);
|
||||||
({warnings} = drawFallbackLabel(canvas, content, this.orientation));
|
({warnings} = drawFallbackLabel(canvas, content, this.orientation));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
this.error = e.message;
|
this.error = e.message;
|
||||||
|
|
@ -739,7 +741,7 @@ export default {
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
// 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.
|
// 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. Thresholded through canvasToBitmap/bitmapToCanvas afterwards, same as redraw()'s real label preview, so a candidate is judged on the same 1-bit dots the printer would actually fire, not the anti-aliased canvas render.
|
||||||
drawCandidateCell(canvas, family, fontPx) {
|
drawCandidateCell(canvas, family, fontPx) {
|
||||||
if (!canvas) {
|
if (!canvas) {
|
||||||
return;
|
return;
|
||||||
|
|
@ -758,6 +760,7 @@ export default {
|
||||||
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||||
ctx.fillStyle = "#000";
|
ctx.fillStyle = "#000";
|
||||||
ctx.fillText(text, 2, (canvas.height + up - down) / 2);
|
ctx.fillText(text, 2, (canvas.height + up - down) / 2);
|
||||||
|
bitmapToCanvas(canvas, canvasToBitmap(canvas));
|
||||||
canvas.style.width = `${canvas.width * this.candidateZoom}px`;
|
canvas.style.width = `${canvas.width * this.candidateZoom}px`;
|
||||||
canvas.style.height = `${canvas.height * this.candidateZoom}px`;
|
canvas.style.height = `${canvas.height * this.candidateZoom}px`;
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -136,7 +136,7 @@ export default {
|
||||||
// Routes to Print.vue with this location's raw identity, same shape as Inventory.vue's
|
// 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. See docs/implementation.md#print-link-shape-for-storage-locations.
|
||||||
printLinkFor(location) {
|
printLinkFor(location) {
|
||||||
return {path: '/print', query: {kind: 'storage-location', userHandle: location.owner, id: location.id}}
|
return {path: '/print', query: {kind: 'storage-location', userHandle: location.owner, location: location.id}}
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
async mounted() {
|
async mounted() {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue