ScanToAdd
Scan a product's code to add it to a list, by the camera, a photo or typing, with a beep, a haptic and a count of repeats.
Demo
Scan to add
Point the camera at a barcode, or type it below.
Nothing scanned yet.
Nothing yet.
Get it
It needs theme.qwe, as every component does; or take the whole kit, quillo-ui.qwe. Each component brings its own styles to the pages that show it.
$ curl -O https://quillolang.org/components/ui/theme.qwe$ curl -O https://quillolang.org/components/ui/scan_to_add.qwemain.qwe
import ui/theme.qwe use theme_cssuse ui/scan_to_add.qweroute GET / = <html><head><style>{theme_css}</style></head><body class="ui-root">...</body></html>Example
The code of the preview above.
demos/scan_to_add.qwe
let scan_to_add_demo_catalog = [ { code: "4006381333931", name: "Marker pen", price: "1.90" }, { code: "5901234123457", name: "Notebook, A5", price: "4.25" }, { code: "0012345678905", name: "Sticky notes", price: "2.10" }, { code: "96385074", name: "Eraser", price: "0.80" },]component ScanToAddDemoLive() { state last = "Nothing yet." <div.demo-stack> <ScanToAdd catalog={scan_to_add_demo_catalog} gap={600} on_scan={(c, how) => { last = `${c} by ${how}` }}/> <p.demo-note id="scan-demo-last">{last}</p> </div>}view ScanToAddDemo() = <ScanToAddDemoLive/>Source
ui/scan_to_add.qwe
// ScanToAdd: scan a product's barcode to add it to a list: by the camera, from a photo,// or typed. Each code that counts gives a beep and a haptic, and the same code again adds// one to its row, so the list shows how many of each. A code held in view counts once.//// iOS has no BarcodeDetector, so the camera's picture is also read here, in Quillo:// EAN-13, EAN-8 and UPC-A, the codes on products, from the rows of each frame. Where the// browser has BarcodeDetector that is used first. A camera refused or missing says so,// with Settings in the phone apps, and the photo and typing still work.//// import ui/scan_to_add.qwe use ScanToAdd//// <ScanToAdd catalog={[{ code: "4006381333931", name: "Marker pen", price: "1.90" }]}// on_change={(rows) => { list = rows }}/>//// catalog: the products: each { code, name, price }. A code not in it is still added, as// "Not in the catalog". UPC-A and EAN-13 are one number, so 012345678905 finds 0012345678905.// title: the heading ("Scan to add"). id: a prefix for its ids, unique on the page ("ui-scanadd"). gap: ms a code must leave the view before it// counts again (1500). sound: starts on (true); the person can turn it off. currency ("USD"),// locale. on_change(rows): after each change, each row { code, name, qty, known, price }.// on_scan(code, how): each code that counted; how is "camera", "photo" or "typed".//// scan_to_add_row(luma) reads a line of an image, each pixel's brightness 0 to 255, and// gives the digits or null. scan_to_add_read(text) checks a typed code.import ui/theme.qwe use ui_clsimport ui/button.qwe use Buttonimport ui/price.qwe use price_textlet scan_to_add_css = css`.ui-scanadd { display: grid; gap: 12px; min-width: 0; container-type: inline-size; color: var(--ui-fg); font: 14px/1.4 var(--ui-font); }.ui-scanadd h3 { margin: 0; font-size: 17px; }.ui-scanadd-cols { display: grid; gap: 12px; min-width: 0; }.ui-scanadd-col { display: grid; gap: 12px; min-width: 0; align-content: start; }.ui-scanadd-view { position: relative; display: grid; place-items: center; min-height: 140px; overflow: hidden; border-radius: var(--ui-radius); background: #0f0e17; color: #fff; aspect-ratio: 16 / 10;}.ui-scanadd-view video { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; }.ui-scanadd-aim { position: absolute; inset: 22% 8%; border: 2px solid rgba(255, 255, 255, .85); border-radius: 8px; pointer-events: none; }.ui-scanadd-aim::after { content: ""; position: absolute; left: 6px; right: 6px; top: 50%; height: 2px; background: #ff5a5a; }.ui-scanadd-msg { position: relative; margin: 0; padding: 12px 16px; text-align: center; }.ui-scanadd-acts { display: flex; flex-wrap: wrap; gap: 8px; }.ui-scanadd-form { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 8px; align-items: end; margin: 0; }.ui-scanadd-form label { display: grid; gap: 2px; min-width: 0; color: var(--ui-muted); font-size: 13px; }/* 16 px: anything smaller and iOS zooms the page when the field is touched. */.ui-scanadd-form input { box-sizing: border-box; width: 100%; height: 46px; padding: 0 10px; border: 1px solid var(--ui-border); border-radius: var(--ui-radius-sm); background: var(--ui-surface); color: var(--ui-fg); font: 16px var(--ui-font);}.ui-scanadd-form input:focus { outline: none; border-color: var(--ui-accent); box-shadow: var(--ui-focus); }.ui-scanadd-said { min-height: 20px; margin: 0; font-weight: 600; }.ui-scanadd-bad { color: var(--ui-danger); }.ui-scanadd-rows { display: grid; gap: 6px; margin: 0; padding: 0; list-style: none; }.ui-scanadd-row { display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: center; gap: 4px 10px; padding: 6px 4px 6px 12px; border-radius: var(--ui-radius-sm); background: var(--ui-subtle);}.ui-scanadd-name { min-width: 0; font-weight: 600; overflow-wrap: anywhere; }.ui-scanadd-code { display: block; color: var(--ui-muted); font: 12px var(--ui-mono); }.ui-scanadd-unknown { display: block; color: var(--ui-warning); font-size: 12px; font-weight: 400; }.ui-scanadd-qty { display: inline-flex; align-items: center; }.ui-scanadd-step { display: grid; place-items: center; min-width: 44px; height: 44px; padding: 0; border: 0; border-radius: var(--ui-radius-sm); background: transparent; color: var(--ui-fg); font: 500 20px var(--ui-font); cursor: pointer; touch-action: manipulation;}.ui-scanadd-step:focus-visible { outline: none; box-shadow: var(--ui-focus); }.ui-scanadd-n { min-width: 34px; text-align: center; font-weight: 700; font-variant-numeric: tabular-nums; }.ui-scanadd-sum { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 8px; margin: 0; color: var(--ui-muted); }.ui-scanadd-empty { margin: 0; color: var(--ui-muted); }@container (min-width: 600px) { .ui-scanadd-cols { grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: 20px; align-items: start; } }`// EAN's digits as the widths of their space, bar, space, bar, in modules. The left half's// other form (G) is the same read backwards, and the right half has these with the colours// turned round, which gives the same widths.let scan_to_add_widths = [[3, 2, 1, 1], [2, 2, 2, 1], [2, 1, 2, 2], [1, 4, 1, 1], [1, 1, 3, 2], [1, 2, 3, 1], [1, 1, 1, 4], [1, 3, 1, 2], [1, 2, 1, 3], [3, 1, 1, 2]]// Which forms the six left digits of an EAN-13 take gives its first digit.let scan_to_add_first = ["LLLLLL", "LLGLGG", "LLGGLG", "LLGGGL", "LGLLGG", "LGGLLG", "LGGGLL", "LGLGLG", "LGLGGL", "LGGLGL"]// The GS1 check digit of the digits before it.fn scan_to_add_check(body: list) -> int { let n = len(body) let total = enumerate(body) | reduce(0, (a, p) => a + p[1] * ((n - p[0]) % 2 == 1 ? 3 : 1)) (10 - total % 10) % 10}fn scan_to_add_digit_list(s: str) -> list = split(s, "") | map((c) => int(c))// A thing typed: { code, error }. Spaces and hyphens are fine. code is the digits;// error says what is wrong, naming the digit that would be right.fn scan_to_add_read(text) -> map { let t = re.replace(slice(str(text ?? ""), 0, 60), "[ -]", "") if t == "" { return { code: null, error: "Type a code first." } } if !re.test(t, "^[0-9]{1,40}$") { return { code: t, error: "free" } } if !contains([8, 12, 13], len(t)) { return { code: null, error: "A product code has 8, 12 or 13 digits." } } let d = scan_to_add_digit_list(t) let right = scan_to_add_check(slice(d, 0, len(d) - 1)) d[len(d) - 1] == right ? { code: t, error: null } : { code: null, error: `That code does not check: its last digit should be ${right}.` }}// UPC-A is an EAN-13 with a 0 in front: the same product, however it was read.fn scan_to_add_key(code: str) -> str = len(code) == 12 && re.test(code, "^[0-9]{12}$") ? "0" + code : code// Which digit four widths are, against a list of patterns: { d, off }, off being how far// they are from it. 7 modules to a digit, so the widths are scaled to that first.fn scan_to_add_match(w: list, flip: bool) -> map { let unit = 7 / max([1, sum(w)]) let scaled = w | map((x) => x * unit) let tries = for [d, p] in enumerate(scan_to_add_widths) { let pat = flip ? reverse(p) : p { d: d, off: abs(scaled[0] - pat[0]) + abs(scaled[1] - pat[1]) + abs(scaled[2] - pat[2]) + abs(scaled[3] - pat[3]) } } sort_by(tries, (t) => t.off)[0]}// A run of a guard: each width near one module.fn scan_to_add_guard(runs: list, at: int, n: int, unit) -> bool = range(at, at + n) | all((i) => runs[i] > unit * 0.4 && runs[i] < unit * 2)// Reads a code starting at a bar: `half` digits a side, EAN-13 with 6 and EAN-8 with 4.fn scan_to_add_at(runs: list, start: int, half: int) { let count = 3 + half * 4 + 5 + half * 4 + 3 if start + count > len(runs) { return null } let span = slice(runs, start, start + count) let unit = sum(span) / (half == 6 ? 95 : 67) if !scan_to_add_guard(runs, start, 3, unit) { return null } if start > 0 && runs[start - 1] < unit * 3 { return null } let middle = start + 3 + half * 4 if !scan_to_add_guard(runs, middle, 5, unit) || !scan_to_add_guard(runs, middle + 5 + half * 4, 3, unit) { return null } let left = for i in range(0, half) { let w = slice(runs, start + 3 + i * 4, start + 7 + i * 4) let plain = scan_to_add_match(w, false) let other = scan_to_add_match(w, true) half == 4 || plain.off <= other.off ? { d: plain.d, form: "L", off: plain.off } : { d: other.d, form: "G", off: other.off } } let right = for i in range(0, half) => scan_to_add_match(slice(runs, middle + 5 + i * 4, middle + 9 + i * 4), false) if (left + right) | any((x) => x.off > 1.5) { return null } let digits = (left | map((x) => x.d)) + (right | map((x) => x.d)) let all_digits = if half == 4 { digits } else { let first = scan_to_add_first | index_of(left | map((x) => x.form) | join("")) if first == null { return null } [first] + digits } let body = slice(all_digits, 0, len(all_digits) - 1) all_digits[len(all_digits) - 1] == scan_to_add_check(body) ? all_digits | join("") : null}// The widths of the light and dark runs along a line, the first being dark: { runs, dark }.// Light falls unevenly across a phone's picture, so each pixel is judged against the// brightest and darkest near it, by blocks of a twentieth of the line.fn scan_to_add_runs(luma: list) -> list { let n = len(luma) let block = max([8, floor(n / 20)]) let count = ceil(n / block) let parts = for i in range(0, count) => slice(luma, i * block, (i + 1) * block) let lows = parts | map((p) => min(p)) let highs = parts | map((p) => max(p)) let whole = (min(lows) + max(highs)) / 2 let cuts = for i in range(0, count) { let a = max([0, i - 1]) let b = min([count, i + 2]) let lo = min(slice(lows, a, b)) let hi = max(slice(highs, a, b)) hi - lo < 40 ? whole : (lo + hi) / 2 } // Start at the first dark pixel, then the widths by turns: dark, light, dark. let runs = [] let width = 0 let dark = null for [i, v] in enumerate(luma) { let now_dark = v < cuts[floor(i / block)] if dark == null { if now_dark { dark = true width = 1 } } else if now_dark == dark { width += 1 } else { runs = runs | push(width) width = 1 dark = now_dark } } runs}// The code along one line, read each way, or null.fn scan_to_add_row(luma: list) { if len(luma) < 95 { return null } let runs = scan_to_add_runs(luma) for way in [runs, reverse(runs)] { let tops = range(0, floor(max([0, len(way) - 42]) / 2) + 1) | map((i) => i * 2) for start in tops { let long = scan_to_add_at(way, start, 6) if long != null { return long } let short = scan_to_add_at(way, start, 4) if short != null { return short } } } null}// Whether a code seen counts: not the same one again before it has left the view. The// time of the last sighting moves with each sighting, so one held still counts once.fn scan_to_add_fresh(last, code: str, t, gap: int) -> bool = last == null || last.code != code || t - last.at >= gap// A list with one more of a code: at the top if it is new, in its place if not.fn scan_to_add_added(rows: list, code: str, catalog: list) -> list { let k = scan_to_add_key(code) if rows | any((r) => r.key == k) { return rows | map((r) => r.key == k ? set(r, "qty", r.qty + 1) : r) } let item = catalog | find((c) => type_of(c) == "map" && scan_to_add_key(str(get(c, "code") ?? "")) == k) [{ key: k, code: code, name: item != null ? str(get(item, "name") ?? code) : code, known: item != null, price: item != null ? get(item, "price") : null, qty: 1 }] + rows}// What a row comes to in all: rows with no price count nothing.fn scan_to_add_total(rows: list) { rows | reduce(dec("0"), (a, r) => { let p = r.price != null ? dec(r.price) : null p == null || is_err(p) ? a : a + p * r.qty })}component ScanToAdd(catalog: list = [], title: str = "Scan to add", id: str = "ui-scanadd", gap: int = 1500, sound: bool = true, currency: str = "USD", locale: str? = null, on_change = null, on_scan = null) { style global scan_to_add_css state rows = [] state text = "" state said = "" state bad = false state camera = "off" // off, starting, live, none (there is none), refused, paused (the app left the screen) state stream = null state video = null state picker = null state canvas = null state detector = null // the browser's BarcodeDetector, where there is one state busy = false // a frame is being read state last = null // the last code seen: { code, at } state beeps = sound state audio = null state asked = false // Clear pressed once state native = false // in a phone app, which can open Settings let told = () => { on_change && on_change(rows) } let stop = () => { if stream != null { for t in js("Array.from", stream.getTracks()) { t.stop() } stream = null } } let feel = (good) => { let w = js("window") if w.quilloNative != null { js("quilloNative.haptic", good ? "success" : "warning") } else if js("navigator").vibrate != null { js("navigator").vibrate(good ? 30 : [40, 60, 40]) } } let beep = (good) => { if beeps && audio != null { let o = audio.createOscillator() let g = audio.createGain() o.frequency.value = good ? 1480 : 330 g.gain.value = 0.08 o.connect(g) g.connect(audio.destination) o.start() o.stop(audio.currentTime + (good ? 0.09 : 0.22)) } } // A page may sound only after a tap: each way in makes it here, from one. let wake_sound = () => { if audio == null { let w = js("window") let make = w.AudioContext != null ? js_new("AudioContext") : (w.webkitAudioContext != null ? js_new("webkitAudioContext") : null) if make != null && !is_err(make) { audio = make } } if audio != null && audio.state == "suspended" { audio.resume() } } let tally = (code, how) => { rows = scan_to_add_added(rows, code, catalog) let row = rows | find((r) => r.key == scan_to_add_key(code)) said = row.qty > 1 ? `${row.name}: ${row.qty}` : `${row.name} added` bad = false asked = false beep(true) feel(true) told() on_scan && on_scan(code, how) } let seen = (raw, how) => { let r = scan_to_add_read(raw) let known = catalog | any((c) => type_of(c) == "map" && str(get(c, "code") ?? "") == r.code) if r.error == null || (r.error == "free" && known) { let t = now() let fresh = how != "camera" || scan_to_add_fresh(last, r.code, t, gap) last = { code: r.code, at: t } if fresh { tally(r.code, how) } } else if how != "camera" { said = r.error == "free" ? "That is not a product code." : r.error bad = true beep(false) feel(false) } } let read_frame = (source, wide, high) => { let d = js("document") if canvas == null { canvas = d.createElement("canvas") } let w = min([wide, 1024]) let h = round(high * w / wide) canvas.width = w canvas.height = h let c = canvas.getContext("2d", { willReadFrequently: true }) c.drawImage(source, 0, 0, w, h) let found = null for part in [0.5, 0.42, 0.58, 0.34, 0.66] { if found == null { let px = js("Array.from", c.getImageData(0, floor(h * part), w, 1).data) let luma = for i in range(0, w) => px[i * 4] * 0.3 + px[i * 4 + 1] * 0.59 + px[i * 4 + 2] * 0.11 found = scan_to_add_row(luma) } } // A phone held across the code: the same again, down the middle. if found == null { let px = js("Array.from", c.getImageData(floor(w / 2), 0, 1, h).data) found = scan_to_add_row(for i in range(0, h) => px[i * 4] * 0.3 + px[i * 4 + 1] * 0.59 + px[i * 4 + 2] * 0.11) } found } let tick = () => { if camera == "live" && !busy && video != null && video.readyState >= 2 && video.videoWidth > 0 { if detector != null { busy = true detector.detect(video).then( (found) => { busy = false if len(found) > 0 { seen(found[0].rawValue, "camera") } }, (_e = null) => { busy = false }) } else { let code = read_frame(video, video.videoWidth, video.videoHeight) if code != null { seen(code, "camera") } } } } if camera == "live" { every(240, tick) } let start = () => { wake_sound() let md = js("navigator").mediaDevices if md == null || md.getUserMedia == null { camera = "none" } else { camera = "starting" let w = js("window") detector = null if w.BarcodeDetector != null { // It throws for a format the device lacks: then the reading here does the work. let made = js("Promise.resolve", js_new("Promise", (done) => { done(js_new("BarcodeDetector", { formats: ["ean_13", "ean_8", "upc_a"] })) })) if !is_err(made) { detector = made } } md.getUserMedia({ video: { facingMode: "environment", width: { ideal: 1280 } }, audio: false }).then( (s) => { stream = s camera = "live" video.srcObject = s video.play().catch((_ = null) => { busy = false }) }, (e = null) => { native = w.quilloNative != null camera = e.name == "NotAllowedError" || e.name == "SecurityError" ? "refused" : "none" }) } } let quit = () => { stop() camera = "off" } let photo = (file) => { wake_sound() if js("window").createImageBitmap == null { said = "This browser cannot read a photo. Type the code." bad = true } else { js("createImageBitmap", file).then( (b) => { let code = read_frame(b, b.width, b.height) b.close() if code != null { seen(code, "photo") } else { said = "No code found in that photo. Try closer, with the code straight." bad = true beep(false) feel(false) } }, (_e = null) => { said = "That photo could not be read." bad = true }) } } let total = scan_to_add_total(rows) let many = rows | reduce(0, (n, r) => n + r.qty) <div.ui-scanadd @mount={(_el) => { let d = js("document") // iOS stops the camera when the app leaves the screen, and it does not come back alone. let away = (_e = null) => { if d.visibilityState == "hidden" { if camera == "live" { stop() camera = "paused" } } else if camera == "paused" { start() } } d.addEventListener("visibilitychange", away) () => { d.removeEventListener("visibilitychange", away) stop() } }}> <h3>{title}</h3> <div.ui-scanadd-cols> <div.ui-scanadd-col> <div.ui-scanadd-view> <video muted={true} playsinline={true} autoplay={true} hidden={camera != "live"} aria-label="The camera's picture" @mount={(el) => { // Set here as well: the attribute only starts it muted, and iOS plays inline only if muted. el.muted = true video = el }}></video> {camera == "live" && <div.ui-scanadd-aim aria-hidden="true"></div>} {camera != "live" && <p.ui-scanadd-msg role="status">{match camera { "starting" => "Asking for the camera…" "none" => "This device has no camera this page can use. Take a photo, or type the code." "refused" => native ? "The camera was refused. Turn it on in Settings, or type the code." : "The camera was refused. Allow it in your browser's settings, or type the code." "paused" => "The camera stopped when you left." _ => "Point the camera at a barcode, or type it below." }}</p>} </div> <div.ui-scanadd-acts> {camera == "live" ? <Button variant="outline" size="lg" on_click={quit}>Stop camera</Button> : <Button size="lg" on_click={start}>Start camera</Button>} {camera == "refused" && native && <Button variant="outline" size="lg" on_click={() => { js("quilloNative.openSettings") }}>Open Settings</Button>} <Button variant="outline" size="lg" on_click={() => { picker.click() }}>Photo of a code</Button> <input type="file" accept="image/*" capture="environment" hidden={true} aria-label="Photo of a code" @mount={(el) => { picker = el }} @change={(e) => { let f = e.target.files[0] if f != null { photo(f) } e.value = "" }}/> <Button variant="ghost" size="lg" on_click={() => { beeps = !beeps; wake_sound() }}>{beeps ? "Sound on" : "Sound off"}</Button> </div> <form.ui-scanadd-form @submit={(e) => { e.preventDefault() wake_sound() let was = text seen(was, "typed") if !bad { text = "" } }}> <label for={`${id}-code`}>Code <input id={`${id}-code`} inputmode="numeric" autocomplete="off" enterkeyhint="go" value={text} @input={(e) => { text = e.value }}/> </label> <Button type="submit" size="lg">Add</Button> </form> <p class={ui_cls(["ui-scanadd-said", bad && "ui-scanadd-bad"])} role="status">{said}</p> </div> <div.ui-scanadd-col> {len(rows) == 0 ? <p.ui-scanadd-empty>Nothing scanned yet.</p> : <ul.ui-scanadd-rows> {for r in rows => <li.ui-scanadd-row key={r.key}> <div.ui-scanadd-name> {r.name} <span.ui-scanadd-code>{r.code}</span> {!r.known && <span.ui-scanadd-unknown>Not in the catalog</span>} </div> <div.ui-scanadd-qty role="group" aria-label={`How many of ${r.name}`}> <button.ui-scanadd-step type="button" aria-label={r.qty == 1 ? `Remove ${r.name}` : `One fewer ${r.name}`} @click={() => { rows = r.qty == 1 ? rows | filter((x) => x.key != r.key) : rows | map((x) => x.key == r.key ? set(x, "qty", x.qty - 1) : x) told() }}>−</button> <span.ui-scanadd-n aria-label={`${r.qty} scanned`}>{r.qty}</span> <button.ui-scanadd-step type="button" aria-label={`One more ${r.name}`} @click={() => { rows = rows | map((x) => x.key == r.key ? set(x, "qty", x.qty + 1) : x) told() }}>+</button> </div> </li>} </ul>} {len(rows) > 0 && <p.ui-scanadd-sum> <span>{many} scanned, {len(rows)} different{total > 0 ? `, ${price_text(total, currency, locale)}` : ""}</span> <Button variant="ghost" size="lg" on_click={() => { if asked { rows = [] asked = false said = "" told() } else { asked = true } }}>{asked ? "Clear them all?" : "Clear"}</Button> </p>} </div> </div> </div>}Related
AssetLookupScan or type an asset tag to see its record, history and what to do, with near matches and a report for a tag nobody knows.3 views · 7 downloadsCartSheetA cart in a sheet with quantities, swipe to remove with undo, live totals in decimals, a free-shipping note and a checkout button.2 views · 9 downloadsCheckoutSheetA one-screen checkout in a sheet with address, delivery, payment and a live total, every part validated, and a slide to pay.2 views · 6 downloadsFilterSheetFacets in a sheet, with result counts updating live, clear, apply, the count on the button, and a state kept for next time.3 views · 5 downloadsOrderStatusCardThe stages of an order with a progress bar, an estimate from data, and what to do when it is late.3 views · 6 downloadsPhotoEvidencePhotos stamped with the time and place into the picture, with counts of what is still needed, tags, and a queue for when there is no connection.2 views · 5 downloads
Thread
Thread 0 posts
On ScanToAdd: questions, tips, corrections, and code that uses it.
No posts yet. The first one opens the thread.