add QR image upload to send pages

This commit is contained in:
2026-08-04 22:19:03 +08:00
parent 743700b82c
commit da0c56658b
6 changed files with 165 additions and 3 deletions
+1
View File
@@ -20,6 +20,7 @@
"d3-shape": "^3.2.0", "d3-shape": "^3.2.0",
"electrobun": "^1.18.1", "electrobun": "^1.18.1",
"env-paths": "^4.0.0", "env-paths": "^4.0.0",
"jsqr": "^1.4.0",
"mode-watcher": "^1.1.0", "mode-watcher": "^1.1.0",
"monero-ts": "^0.11.8", "monero-ts": "^0.11.8",
"qrcode": "^1.5.4", "qrcode": "^1.5.4",
@@ -0,0 +1,62 @@
<script lang="ts">
import { Button } from '$lib/components/ui/button/index.js'
import Loader from '$lib/components/loader.svelte'
import QrCodeIcon from '@lucide/svelte/icons/qr-code'
import { decodeQrFromFile } from '$lib/qr.js'
let {
onDecode,
disabled = false,
}: {
onDecode: (text: string) => void
disabled?: boolean
} = $props()
let fileInput: HTMLInputElement
let busy = $state(false)
let error = $state('')
const handleFile = async (e: Event) => {
const file = (e.target as HTMLInputElement).files?.[0]
if (!file) return
busy = true
error = ''
try {
const text = await decodeQrFromFile(file)
if (!text) throw new Error('No QR code found in the image')
onDecode(text)
} catch (err) {
error = err instanceof Error ? err.message : 'Failed to read QR code'
} finally {
busy = false
if (fileInput) fileInput.value = ''
}
}
</script>
<div class="flex items-center gap-2">
<input
bind:this={fileInput}
type="file"
accept="image/*"
class="hidden"
onchange={handleFile}
disabled={disabled || busy} />
<Button
type="button"
variant="outline"
size="sm"
class="h-7 gap-1.5"
disabled={disabled || busy}
onclick={() => fileInput?.click()}>
{#if busy}
<Loader class="size-3.5" />
{:else}
<QrCodeIcon size={14} />
{/if}
Scan QR
</Button>
{#if error}
<span class="text-xs text-red-500">{error}</span>
{/if}
</div>
+75
View File
@@ -0,0 +1,75 @@
import jsQR from 'jsqr'
export const decodeQrFromFile = (file: File): Promise<string> =>
new Promise((resolve, reject) => {
const url = URL.createObjectURL(file)
const img = new Image()
img.onload = () => {
try {
const canvas = document.createElement('canvas')
canvas.width = img.naturalWidth
canvas.height = img.naturalHeight
const ctx = canvas.getContext('2d')
if (!ctx) throw new Error('Canvas is not supported')
ctx.drawImage(img, 0, 0)
const { data, width, height } = ctx.getImageData(
0,
0,
canvas.width,
canvas.height,
)
resolve(jsQR(data, width, height)?.data ?? '')
} catch (e) {
reject(e)
} finally {
URL.revokeObjectURL(url)
}
}
img.onerror = () => {
URL.revokeObjectURL(url)
reject(new Error('Could not load the image'))
}
img.src = url
})
export type QrParseResult = {
address: string
amount?: string
destinationTag?: string
}
// Handles payment URIs (monero:ADDRESS?tx_amount=..., xrp:rADDR?amount=..&dt=..)
// as well as bare addresses. Returns the last path-like segment before any
// query string so prefixes and /pay paths are skipped.
export const parseQrPayload = (
text: string,
kind: 'xrp' | 'monero',
): QrParseResult => {
let body = text.trim().replace(/\s+/g, '')
body = body.replace(/^(?:[a-z0-9+.-]+):\/?\//i, '')
const [beforeQuery, queryRaw] = body.split('?')
const segments = beforeQuery.split('/')
const address = segments[segments.length - 1]
const params: Record<string, string> = {}
if (queryRaw) {
for (const pair of queryRaw.split('&')) {
const eq = pair.indexOf('=')
const key = (eq >= 0 ? pair.slice(0, eq) : pair).toLowerCase()
const value = eq >= 0 ? decodeURIComponent(pair.slice(eq + 1)) : ''
if (key) params[key] = value
}
}
if (kind === 'xrp') {
return {
address,
amount: params['amount'] || undefined,
destinationTag: params['dt'] || undefined,
}
}
return {
address,
amount: params['tx_amount'] || undefined,
}
}
@@ -19,6 +19,8 @@
SelectTrigger, SelectTrigger,
} from '$lib/components/ui/select/index.js' } from '$lib/components/ui/select/index.js'
import { navigate } from 'sv-router/generated' import { navigate } from 'sv-router/generated'
import QrUpload from '$lib/components/qr-upload.svelte'
import { parseQrPayload } from '$lib/qr.js'
const w = moneroWallet const w = moneroWallet
@@ -134,7 +136,15 @@
</div> </div>
<div class="space-y-1.5"> <div class="space-y-1.5">
<div class="flex items-center justify-between">
<label class="text-xs font-medium">Recipient Address</label> <label class="text-xs font-medium">Recipient Address</label>
<QrUpload
onDecode={(text) => {
const parsed = parseQrPayload(text, 'monero')
recipient = parsed.address
if (parsed.amount && !sendAll) amountXmr = parsed.amount
}} />
</div>
<Input <Input
placeholder="Monero address starting with 4..." placeholder="Monero address starting with 4..."
bind:value={recipient} /> bind:value={recipient} />
+11
View File
@@ -10,6 +10,8 @@
CardDescription, CardDescription,
} from '$lib/components/ui/card/index.js' } from '$lib/components/ui/card/index.js'
import { navigate } from 'sv-router/generated' import { navigate } from 'sv-router/generated'
import QrUpload from '$lib/components/qr-upload.svelte'
import { parseQrPayload } from '$lib/qr.js'
const w = xrpWallet const w = xrpWallet
@@ -78,7 +80,16 @@
}} }}
class="space-y-4"> class="space-y-4">
<div class="space-y-1.5"> <div class="space-y-1.5">
<div class="flex items-center justify-between">
<label class="text-xs font-medium">Recipient Address</label> <label class="text-xs font-medium">Recipient Address</label>
<QrUpload
onDecode={(text) => {
const parsed = parseQrPayload(text, 'xrp')
recipient = parsed.address
if (parsed.destinationTag) destinationTag = parsed.destinationTag
if (parsed.amount) amountXrp = parsed.amount
}} />
</div>
<Input <Input
placeholder="XRP address starting with r..." placeholder="XRP address starting with r..."
bind:value={recipient} /> bind:value={recipient} />
+4 -1
View File
@@ -18,6 +18,7 @@
"d3-shape": "^3.2.0", "d3-shape": "^3.2.0",
"electrobun": "^1.18.1", "electrobun": "^1.18.1",
"env-paths": "^4.0.0", "env-paths": "^4.0.0",
"jsqr": "^1.4.0",
"mode-watcher": "^1.1.0", "mode-watcher": "^1.1.0",
"monero-ts": "^0.11.8", "monero-ts": "^0.11.8",
"qrcode": "^1.5.4", "qrcode": "^1.5.4",
@@ -640,6 +641,8 @@
"json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="], "json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="],
"jsqr": ["jsqr@1.4.0", "", {}, "sha512-dxLob7q65Xg2DvstYkRpkYtmKm2sPJ9oFhrhmudT1dZvNFFTlroai3AWSpLey/w5vMcLBXRgOJsbXpdN9HzU/A=="],
"kleur": ["kleur@4.1.5", "", {}, "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ=="], "kleur": ["kleur@4.1.5", "", {}, "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ=="],
"koins": ["koins@workspace:apps/desktop"], "koins": ["koins@workspace:apps/desktop"],
@@ -846,7 +849,7 @@
"supports-color": ["supports-color@8.1.1", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q=="], "supports-color": ["supports-color@8.1.1", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q=="],
"sv-router": ["sv-router@0.18.0", "", { "dependencies": { "esm-env": "^1.2.2" }, "peerDependencies": { "svelte": "^5" }, "bin": { "sv-router": "./src/cli/index.js" } }, "sha512-gLky7FBGj8P6MAna1wxYniHJxMrXgaeTNnkZ7WH387ddRyUohfDtvEgWQFfB4oLL2XxWlw8Lfmk03TtBdK/JTA=="], "sv-router": ["sv-router@0.18.1", "", { "dependencies": { "esm-env": "^1.2.2" }, "peerDependencies": { "svelte": "^5" }, "bin": { "sv-router": "./src/cli/index.js" } }, "sha512-xMJxgDwmcWtLDopU6xdEiBICRN40ucTlK63GUZf6cQk8689LUaUD5FlMrdtTdPFRQ2kfJBeJgoaI1k+XOuIQSQ=="],
"svelte": ["svelte@5.55.5", "", { "dependencies": { "@jridgewell/remapping": "^2.3.4", "@jridgewell/sourcemap-codec": "^1.5.0", "@sveltejs/acorn-typescript": "^1.0.5", "@types/estree": "^1.0.5", "@types/trusted-types": "^2.0.7", "acorn": "^8.12.1", "aria-query": "5.3.1", "axobject-query": "^4.1.0", "clsx": "^2.1.1", "devalue": "^5.6.4", "esm-env": "^1.2.1", "esrap": "^2.2.4", "is-reference": "^3.0.3", "locate-character": "^3.0.0", "magic-string": "^0.30.11", "zimmerframe": "^1.1.2" } }, "sha512-2uCs/LZ9us+AktdzYJM8OcxQ8qnPS1kpaO7syGT/MgO+6Qr1Ybl+TqPq+97u7PHqmmMlye5ZkoyXONy5mjjAbw=="], "svelte": ["svelte@5.55.5", "", { "dependencies": { "@jridgewell/remapping": "^2.3.4", "@jridgewell/sourcemap-codec": "^1.5.0", "@sveltejs/acorn-typescript": "^1.0.5", "@types/estree": "^1.0.5", "@types/trusted-types": "^2.0.7", "acorn": "^8.12.1", "aria-query": "5.3.1", "axobject-query": "^4.1.0", "clsx": "^2.1.1", "devalue": "^5.6.4", "esm-env": "^1.2.1", "esrap": "^2.2.4", "is-reference": "^3.0.3", "locate-character": "^3.0.0", "magic-string": "^0.30.11", "zimmerframe": "^1.1.2" } }, "sha512-2uCs/LZ9us+AktdzYJM8OcxQ8qnPS1kpaO7syGT/MgO+6Qr1Ybl+TqPq+97u7PHqmmMlye5ZkoyXONy5mjjAbw=="],