Skip to content

Commit cf42788

Browse files
committed
feat: scanning via image file drag-and-drop
Allow users to scan image files or website embedded images. Especialls useful as fallback for devices without installed cameras. Introducing `detect` event for deeper insights of scanning results. Close #26 #46
1 parent d12ce42 commit cf42788

File tree

5 files changed

+156
-34
lines changed

5 files changed

+156
-34
lines changed

src/components/QrcodeReader.vue

Lines changed: 72 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,13 @@
11
<template lang="html">
22
<div class="qrcode-reader">
33
<div class="qrcode-reader__inner-wrapper">
4-
<div class="qrcode-reader__overlay">
4+
<div
5+
class="qrcode-reader__overlay"
6+
@drop.prevent.stop="onDrop"
7+
@dragover.prevent.stop
8+
@dragenter.prevent.stop
9+
@dragleave.prevent.stop
10+
>
511
<slot></slot>
612
</div>
713

@@ -19,8 +25,9 @@
1925
</template>
2026

2127
<script>
22-
import * as Scanner from '../misc/Scanner.js'
23-
import Camera from '../misc/Camera.js'
28+
import * as Scanner from '../misc/scanner.js'
29+
import Camera from '../misc/camera.js'
30+
import { imageDataFromFile, imageDataFromUrl } from '../misc/image-data.js'
2431
import isBoolean from 'lodash/isBoolean'
2532
2633
export default {
@@ -186,28 +193,76 @@ export default {
186193
this.camera.stop()
187194
}
188195
189-
this.camera = await Camera(this.constraints, this.$refs.video)
196+
if (this.videoConstraints === false) {
197+
this.camera = null
198+
} else {
199+
this.camera = await Camera(this.constraints, this.$refs.video)
200+
}
190201
},
191202
192203
startScanning () {
193204
Scanner.keepScanning(this.camera, {
194-
decodeHandler: this.onDecode,
195205
locateHandler: this.onLocate,
206+
detectHandler: scanResult => this.onDetect('stream', scanResult),
196207
shouldContinue: () => this.shouldScan,
197208
minDelay: this.scanInterval,
198209
})
199210
},
200211
201-
onDecode (content) {
202-
this.$emit('decode', content)
203-
},
204-
205212
onLocate (location) {
206213
if (this.trackRepaintFunction !== null) {
207214
this.repaintTrack(location)
208215
}
209216
},
210217
218+
async onDetect (source, promise) {
219+
this.$emit('detect', (async () => {
220+
const data = await promise
221+
222+
return { source, ...data }
223+
})())
224+
225+
try {
226+
const { content } = await promise
227+
228+
if (content !== null) {
229+
this.$emit('decode', content)
230+
}
231+
} catch (error) {
232+
// fail silently
233+
}
234+
},
235+
236+
onDrop ({ dataTransfer }) {
237+
const droppedFiles = [...dataTransfer.files]
238+
239+
droppedFiles.forEach(this.onDropFile)
240+
241+
const droppedUrl = dataTransfer.getData('text')
242+
243+
if (droppedUrl !== '') {
244+
this.onDropUrl(droppedUrl)
245+
}
246+
},
247+
248+
async onDropFile (file) {
249+
this.onDetect('file', (async () => {
250+
const imageData = await imageDataFromFile(file)
251+
const scanResult = Scanner.scan(imageData)
252+
253+
return scanResult
254+
})())
255+
},
256+
257+
async onDropUrl (url) {
258+
this.onDetect('url', (async () => {
259+
const imageData = await imageDataFromUrl(url)
260+
const scanResult = Scanner.scan(imageData)
261+
262+
return scanResult
263+
})())
264+
},
265+
211266
/**
212267
* The coordinates are based on the original camera resolution but the
213268
* video element is responsive and scales with space available. Therefore
@@ -220,14 +275,16 @@ export default {
220275
const widthRatio = this.camera.displayWidth / this.camera.resolutionWidth
221276
const heightRatio = this.camera.displayHeight / this.camera.resolutionHeight
222277
223-
Object.keys(location).forEach(key => {
224-
const { x, y } = location[key]
225-
226-
location[key].x = Math.floor(x * widthRatio)
227-
location[key].y = Math.floor(y * heightRatio)
278+
const normalizeEntry = ({ x, y }) => ({
279+
x: Math.floor(x * widthRatio),
280+
y: Math.floor(y * heightRatio),
228281
})
229282
230-
return location
283+
const joinObjects = (objA, objB) => ({ ...objA, ...objB })
284+
285+
return Object.entries(location)
286+
.map(([ key, val ]) => [ key, normalizeEntry(val) ])
287+
.reduce(joinObjects, {})
231288
}
232289
},
233290
@@ -258,7 +315,6 @@ export default {
258315
}
259316
260317
.qrcode-reader__inner-wrapper {
261-
display: inline-block;
262318
position: relative;
263319
}
264320

src/misc/Camera.js renamed to src/misc/camera.js

Lines changed: 2 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,8 @@
1+
import { imageDataFromVideo } from './image-data.js'
12

23
class Camera {
34

45
constructor (videoEl, stream) {
5-
const canvas = document.createElement('canvas')
6-
canvas.width = videoEl.videoWidth
7-
canvas.height = videoEl.videoHeight
8-
9-
this.canvasCtx = canvas.getContext('2d')
106
this.videoEl = videoEl
117
this.stream = stream
128
}
@@ -34,15 +30,7 @@ class Camera {
3430
}
3531

3632
captureFrame () {
37-
this.canvasCtx.drawImage(
38-
this.videoEl, 0, 0,
39-
this.resolutionWidth,
40-
this.resolutionHeight
41-
)
42-
43-
return this.canvasCtx.getImageData(
44-
0, 0, this.resolutionWidth, this.resolutionHeight
45-
)
33+
return imageDataFromVideo(this.videoEl)
4634
}
4735

4836
}

src/misc/errors.js

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
2+
export class DropImageFetchError extends Error {
3+
constructor () {
4+
super('can\'t process cross-origin image')
5+
6+
this.name = 'DropImageFetchError'
7+
}
8+
}
9+
10+
export class DropImageDecodeError extends Error {
11+
constructor () {
12+
super('drag-and-dropped file is not of type image and can\'t be decoded')
13+
14+
this.name = 'DropImageDecodeError'
15+
}
16+
}

src/misc/image-data.js

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
import { DropImageFetchError, DropImageDecodeError } from './errors.js'
2+
3+
const canvas = document.createElement('canvas')
4+
const canvasCtx = canvas.getContext('2d')
5+
6+
export function imageDataFromImage (imageElement) {
7+
canvas.width = imageElement.naturalWidth
8+
canvas.height = imageElement.naturalHeight
9+
10+
const bounds = [0, 0, canvas.width, canvas.height]
11+
12+
canvasCtx.drawImage(imageElement, ...bounds)
13+
14+
return canvasCtx.getImageData(...bounds)
15+
}
16+
17+
export function imageDataFromVideo (videoElement) {
18+
canvas.width = videoElement.videoWidth
19+
canvas.height = videoElement.videoHeight
20+
21+
const bounds = [0, 0, canvas.width, canvas.height]
22+
23+
canvasCtx.drawImage(videoElement, ...bounds)
24+
25+
return canvasCtx.getImageData(...bounds)
26+
}
27+
28+
export async function imageDataFromUrl (url) {
29+
if (url.startsWith('http') && url.includes(location.host) === false) {
30+
throw new DropImageFetchError()
31+
}
32+
33+
const image = document.createElement('img')
34+
35+
const imageLoadedPromise = new Promise((resolve, reject) => {
36+
image.onload = resolve
37+
})
38+
39+
image.src = url
40+
41+
await imageLoadedPromise
42+
43+
return imageDataFromImage(image)
44+
}
45+
46+
export async function imageDataFromFile (file) {
47+
if (/image.*/.test(file.type)) {
48+
const reader = new FileReader()
49+
50+
const readerLoadedPromise = new Promise((resolve, reject) => {
51+
reader.onload = event => resolve(event.target.result)
52+
})
53+
54+
reader.readAsDataURL(file)
55+
56+
const dataURL = await readerLoadedPromise
57+
58+
return imageDataFromUrl(dataURL)
59+
} else {
60+
throw new DropImageDecodeError()
61+
}
62+
}

src/misc/Scanner.js renamed to src/misc/scanner.js

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ export function scan (imageData) {
1515
location = result.location
1616
}
1717

18-
return { content, location }
18+
return { content, location, imageData }
1919
}
2020

2121
/**
@@ -24,8 +24,8 @@ export function scan (imageData) {
2424
*/
2525
export function keepScanning (camera, options) {
2626
const {
27-
decodeHandler,
2827
locateHandler,
28+
detectHandler,
2929
shouldContinue,
3030
minDelay,
3131
} = options
@@ -46,8 +46,8 @@ export function keepScanning (camera, options) {
4646
const imageData = camera.captureFrame()
4747
const { content, location } = scan(imageData)
4848

49-
if (content !== null && content !== contentBefore) {
50-
decodeHandler(content)
49+
if (content !== contentBefore && content !== null) {
50+
detectHandler({ content, location, imageData })
5151
}
5252

5353
if (location !== locationBefore) {

0 commit comments

Comments
 (0)