homebox/frontend/composables/use-quick-search.ts

136 lines
4.4 KiB
TypeScript
Raw Normal View History

import { getRealFormat, isValid } from "gtin";
// Convenient representations of gtin's format values.
enum CodeFormat {
EAN13 = "GTIN-13", // 13-digit EAN
UPCA = "GTIN-12", // 12-digit UPC-A
}
function isSearchShortcut(event: KeyboardEvent): boolean {
return (event.metaKey || event.ctrlKey) && event.key === "f";
}
/**
* For our purposes, a valid barcode fragment is any digit, with no
* standard modifiers enabled (shift, ctrl, meta):
*/
function isCodeFragment(event: KeyboardEvent): boolean {
return !Number.isNaN(parseInt(event.key)) && !event.ctrlKey && !event.metaKey && !event.shiftKey;
}
/**
* Performs a naive check to ensure something is a EAN13 or UPC-A code.
*/
function isValidIshCode(code: string): boolean {
try {
// We check the format in advance, since we're only accepting EAN13 and UPC-A here.
// There's no -real- reason for this limitation, besides protecting against feature
// creep from supporting a larger number of formats.
const realFormat = getRealFormat(code);
return realFormat === CodeFormat.EAN13 || (realFormat === CodeFormat.UPCA && isValid(code));
} catch (err) {
// gtin.getRealFormat will throw on codes that are absolutely invalid,
// as opposed to returning false for codes that look correct but fail
// a format-specific check.
return false;
}
}
/**
* Ignore code input if the user is actually trying to write anywhere:
*/
function isEventTargetInput(event: KeyboardEvent) {
const tagName = (event.target as HTMLElement).tagName;
return tagName != null && tagName.toUpperCase() === "INPUT";
}
/**
* Provides utilities to:
*
* - Quickly open a global search utility, that forwards to the item page.
* - Transparently listens for UPC/EAN-like barcode input, and automatically
* generates an appropriate search query for valid inputs; this allows consumer
* USB barcode scanners to be used as a quick shortcut.
*/
export function useQuickSearch() {
const router = useRouter();
/**
* Tracks if the quick search dialog is active.
*
* We 'steal' cmd/ctrl+F from the user, but allow them to go back to
* the browser-default search by tapping the shortcut twice.
*/
const isActive = ref(false);
/**
* codeBuffer acts as intermediate buffer state for a partial
* EAN/UPC code.
*/
const codeBuffer = ref("");
function onKeyDown(event: KeyboardEvent) {
if (isSearchShortcut(event)) {
// If quick search is already active, and the user taps cmd+F, get it out of the way
// and allow the default browser search to kick in:
if (isActive.value) {
isActive.value = false;
} else {
isActive.value = true;
event.preventDefault();
}
} else if (isCodeFragment(event) && !isEventTargetInput(event)) {
const fragment = event.key;
// Push this code fragment into our buffer. At this point we also
// ensure we have no more than 13 numbers in the buffer, and if that
// is the case, we clear it ahead of starting what we assume to be
// a new code:
if (codeBuffer.value.length < 13) {
codeBuffer.value = `${codeBuffer.value}${fragment}`;
} else {
// Reset the buffer:
codeBuffer.value = fragment;
}
} else if (event.key === "Enter" && isValidIshCode(codeBuffer.value)) {
// If we have an active code buffer that seems valid, and the user presses Enter,
// we want to generate a new search query from this code, as long as it seems valid.
//
// Consumer/most(?) USB barcode scanners will terminate valid codes with an Enter key press,
// which is what we're reacting to here.
const validCode = codeBuffer.value;
// Regardless of what we do next, we also clear the code buffer here:
codeBuffer.value = "";
// TODO: Is there a good reason to not expose custom fields via search syntax?
router.push({
path: "/items",
query: {
q: "",
fieldSelector: "true",
fields: [`Barcode=${validCode}`],
},
});
} else {
// Every other key press resets the buffer - this applies to non-code values,
// and also to pressing the Enter key without a valid code;
codeBuffer.value = "";
}
}
onMounted(() => {
window.addEventListener("keydown", onKeyDown);
});
onUnmounted(() => {
window.removeEventListener("keydown", onKeyDown);
});
return {
codeBuffer,
isActive,
};
}