[#181] Add paste ability for files to textarea
closes #181 Signed-off-by: Knut Ahlers <knut@ahlers.me>
This commit is contained in:
parent
6e2f20aa53
commit
b41db78745
5 changed files with 178 additions and 52 deletions
|
@ -36,6 +36,7 @@
|
||||||
v-model="secret"
|
v-model="secret"
|
||||||
class="form-control"
|
class="form-control"
|
||||||
:rows="2"
|
:rows="2"
|
||||||
|
@pasteFile="handlePasteFile"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
|
@ -50,7 +51,7 @@
|
||||||
type="file"
|
type="file"
|
||||||
multiple
|
multiple
|
||||||
:accept="$root.customize.acceptedFileTypes"
|
:accept="$root.customize.acceptedFileTypes"
|
||||||
@change="updateFileMeta"
|
@change="handleSelectFiles"
|
||||||
>
|
>
|
||||||
<div class="form-text">
|
<div class="form-text">
|
||||||
{{ $t('text-max-filesize', { maxSize: bytesToHuman(maxFileSize) }) }}
|
{{ $t('text-max-filesize', { maxSize: bytesToHuman(maxFileSize) }) }}
|
||||||
|
@ -67,6 +68,14 @@
|
||||||
>
|
>
|
||||||
{{ $t('text-max-filesize-exceeded', { curSize: bytesToHuman(fileSize), maxSize: bytesToHuman(maxFileSize) }) }}
|
{{ $t('text-max-filesize-exceeded', { curSize: bytesToHuman(fileSize), maxSize: bytesToHuman(maxFileSize) }) }}
|
||||||
</div>
|
</div>
|
||||||
|
<FilesDisplay
|
||||||
|
v-if="attachedFiles.length > 0"
|
||||||
|
class="mt-3"
|
||||||
|
:can-delete="true"
|
||||||
|
:track-download="false"
|
||||||
|
:files="attachedFiles"
|
||||||
|
@fileClicked="deleteFile"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-6 col-12 order-2 order-md-1">
|
<div class="col-md-6 col-12 order-2 order-md-1">
|
||||||
<button
|
<button
|
||||||
|
@ -117,6 +126,7 @@
|
||||||
|
|
||||||
import appCrypto from '../crypto.js'
|
import appCrypto from '../crypto.js'
|
||||||
import { bytesToHuman } from '../helpers'
|
import { bytesToHuman } from '../helpers'
|
||||||
|
import FilesDisplay from './fileDisplay.vue'
|
||||||
import GrowArea from './growarea.vue'
|
import GrowArea from './growarea.vue'
|
||||||
import OTSMeta from '../ots-meta'
|
import OTSMeta from '../ots-meta'
|
||||||
|
|
||||||
|
@ -148,7 +158,7 @@ const passwordCharset = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRS
|
||||||
const passwordLength = 20
|
const passwordLength = 20
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
components: { GrowArea },
|
components: { FilesDisplay, GrowArea },
|
||||||
|
|
||||||
computed: {
|
computed: {
|
||||||
canCreate() {
|
canCreate() {
|
||||||
|
@ -222,6 +232,7 @@ export default {
|
||||||
|
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
|
attachedFiles: [],
|
||||||
canWrite: null,
|
canWrite: null,
|
||||||
createRunning: false,
|
createRunning: false,
|
||||||
fileSize: 0,
|
fileSize: 0,
|
||||||
|
@ -268,9 +279,9 @@ export default {
|
||||||
const meta = new OTSMeta()
|
const meta = new OTSMeta()
|
||||||
meta.secret = this.secret
|
meta.secret = this.secret
|
||||||
|
|
||||||
if (this.$refs.createSecretFiles) {
|
if (this.attachedFiles.length > 0) {
|
||||||
for (const f of [...this.$refs.createSecretFiles.files]) {
|
for (const f of this.attachedFiles) {
|
||||||
meta.files.push(f)
|
meta.files.push(f.fileObj)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -317,6 +328,37 @@ export default {
|
||||||
return false
|
return false
|
||||||
},
|
},
|
||||||
|
|
||||||
|
deleteFile(fileId) {
|
||||||
|
this.attachedFiles = [...this.attachedFiles].filter(file => file.id !== fileId)
|
||||||
|
this.updateFileMeta()
|
||||||
|
},
|
||||||
|
|
||||||
|
handlePasteFile(file) {
|
||||||
|
this.attachedFiles.push({
|
||||||
|
fileObj: file,
|
||||||
|
id: window.crypto.randomUUID(),
|
||||||
|
name: file.name,
|
||||||
|
size: file.size,
|
||||||
|
type: file.type,
|
||||||
|
})
|
||||||
|
this.updateFileMeta()
|
||||||
|
},
|
||||||
|
|
||||||
|
handleSelectFiles() {
|
||||||
|
for (const file of this.$refs.createSecretFiles.files) {
|
||||||
|
this.attachedFiles.push({
|
||||||
|
fileObj: file,
|
||||||
|
id: window.crypto.randomUUID(),
|
||||||
|
name: file.name,
|
||||||
|
size: file.size,
|
||||||
|
type: file.type,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
this.updateFileMeta()
|
||||||
|
|
||||||
|
this.$refs.createSecretFiles.value = ''
|
||||||
|
},
|
||||||
|
|
||||||
isAcceptedBy(fileMeta, accept) {
|
isAcceptedBy(fileMeta, accept) {
|
||||||
if (/^(?:[a-z]+|\*)\/(?:[a-zA-Z0-9.+_-]+|\*)$/.test(accept)) {
|
if (/^(?:[a-z]+|\*)\/(?:[a-zA-Z0-9.+_-]+|\*)$/.test(accept)) {
|
||||||
// That's likely supposed to be a mime-type
|
// That's likely supposed to be a mime-type
|
||||||
|
@ -332,12 +374,12 @@ export default {
|
||||||
|
|
||||||
updateFileMeta() {
|
updateFileMeta() {
|
||||||
let cumSize = 0
|
let cumSize = 0
|
||||||
for (const f of [...this.$refs.createSecretFiles.files]) {
|
for (const f of this.attachedFiles) {
|
||||||
cumSize += f.size
|
cumSize += f.size
|
||||||
}
|
}
|
||||||
|
|
||||||
this.fileSize = cumSize
|
this.fileSize = cumSize
|
||||||
this.selectedFileMeta = [...this.$refs.createSecretFiles.files].map(file => ({
|
this.selectedFileMeta = this.attachedFiles.map(file => ({
|
||||||
name: file.name,
|
name: file.name,
|
||||||
type: file.type,
|
type: file.type,
|
||||||
}))
|
}))
|
||||||
|
|
90
src/components/fileDisplay.vue
Normal file
90
src/components/fileDisplay.vue
Normal file
|
@ -0,0 +1,90 @@
|
||||||
|
<template>
|
||||||
|
<div class="list-group mb-3">
|
||||||
|
<a
|
||||||
|
v-for="file in files"
|
||||||
|
:key="file.id"
|
||||||
|
class="cursor-pointer list-group-item list-group-item-action font-monospace d-flex align-items-center"
|
||||||
|
:href="file.url"
|
||||||
|
:download="file.name"
|
||||||
|
@click="handleClick(file)"
|
||||||
|
>
|
||||||
|
<i :class="fasFileType(file.type)" />
|
||||||
|
<span>{{ file.name }}</span>
|
||||||
|
<span class="ms-auto">{{ bytesToHuman(file.size) }}</span>
|
||||||
|
<template v-if="trackDownload">
|
||||||
|
<i
|
||||||
|
v-if="!hasDownloaded[file.id]"
|
||||||
|
class="fas fa-fw fa-download ms-2 text-warning"
|
||||||
|
/>
|
||||||
|
<i
|
||||||
|
v-else
|
||||||
|
class="fas fa-fw fa-circle-check ms-2 text-success"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
<template v-if="canDelete">
|
||||||
|
<i
|
||||||
|
class="fas fa-fw fa-trash ms-2 text-danger"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import { bytesToHuman } from '../helpers'
|
||||||
|
|
||||||
|
export default {
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
hasDownloaded: {},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
methods: {
|
||||||
|
bytesToHuman,
|
||||||
|
|
||||||
|
fasFileType(type) {
|
||||||
|
return [
|
||||||
|
'fas',
|
||||||
|
'fa-fw',
|
||||||
|
'me-2',
|
||||||
|
...[
|
||||||
|
{ icon: ['fa-file-pdf'], match: /application\/pdf/ },
|
||||||
|
{ icon: ['fa-file-audio'], match: /^audio\// },
|
||||||
|
{ icon: ['fa-file-image'], match: /^image\// },
|
||||||
|
{ icon: ['fa-file-lines'], match: /^text\// },
|
||||||
|
{ icon: ['fa-file-video'], match: /^video\// },
|
||||||
|
{ icon: ['fa-file-zipper'], match: /^application\/(gzip|x-tar|zip)$/ },
|
||||||
|
{ icon: ['fa-file-circle-question'], match: /.*/ },
|
||||||
|
].filter(el => el.match.test(type))[0].icon,
|
||||||
|
].join(' ')
|
||||||
|
},
|
||||||
|
|
||||||
|
handleClick(file) {
|
||||||
|
this.$set(this.hasDownloaded, file.id, true)
|
||||||
|
this.$emit('fileClicked', file.id)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
name: 'AppFileDisplay',
|
||||||
|
|
||||||
|
props: {
|
||||||
|
canDelete: {
|
||||||
|
default: false,
|
||||||
|
required: false,
|
||||||
|
type: Boolean,
|
||||||
|
},
|
||||||
|
|
||||||
|
files: {
|
||||||
|
required: true,
|
||||||
|
type: Array,
|
||||||
|
},
|
||||||
|
|
||||||
|
trackDownload: {
|
||||||
|
default: true,
|
||||||
|
required: false,
|
||||||
|
type: Boolean,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
</script>
|
|
@ -3,6 +3,7 @@
|
||||||
ref="area"
|
ref="area"
|
||||||
v-model="data"
|
v-model="data"
|
||||||
style="resize: none;"
|
style="resize: none;"
|
||||||
|
@paste="handlePaste"
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
@ -33,7 +34,31 @@ export default {
|
||||||
getStyle(name) {
|
getStyle(name) {
|
||||||
return parseInt(getComputedStyle(this.$refs.area, null)[name])
|
return parseInt(getComputedStyle(this.$refs.area, null)[name])
|
||||||
},
|
},
|
||||||
|
|
||||||
|
handlePaste(evt) {
|
||||||
|
if ([...evt.clipboardData.items]
|
||||||
|
.filter(item => item.kind !== 'string')
|
||||||
|
.length === 0) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* We have something else than text, prevent using clipboard and
|
||||||
|
* pasting and emit an event containing the file data
|
||||||
|
*/
|
||||||
|
evt.stopPropagation()
|
||||||
|
evt.preventDefault()
|
||||||
|
|
||||||
|
for (const item of evt.clipboardData.items) {
|
||||||
|
if (item.kind === 'string') {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
this.$emit('pasteFile', item.getAsFile())
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
|
||||||
mounted() {
|
mounted() {
|
||||||
this.changeSize()
|
this.changeSize()
|
||||||
|
|
|
@ -56,28 +56,7 @@
|
||||||
</div>
|
</div>
|
||||||
<template v-if="files.length > 0">
|
<template v-if="files.length > 0">
|
||||||
<p v-html="$t('text-attached-files')" />
|
<p v-html="$t('text-attached-files')" />
|
||||||
<div class="list-group mb-3">
|
<FilesDisplay :files="files" />
|
||||||
<a
|
|
||||||
v-for="file in files"
|
|
||||||
:key="file.name"
|
|
||||||
class="list-group-item list-group-item-action font-monospace d-flex align-items-center"
|
|
||||||
:href="file.url"
|
|
||||||
:download="file.name"
|
|
||||||
@click="$set(hasDownloaded, file.name, true)"
|
|
||||||
>
|
|
||||||
<i :class="fasFileType(file.type)" />
|
|
||||||
<span>{{ file.name }}</span>
|
|
||||||
<span class="ms-auto">{{ bytesToHuman(file.size) }}</span>
|
|
||||||
<i
|
|
||||||
v-if="!hasDownloaded[file.name]"
|
|
||||||
class="fas fa-fw fa-download ms-2 text-warning"
|
|
||||||
/>
|
|
||||||
<i
|
|
||||||
v-else
|
|
||||||
class="fas fa-fw fa-circle-check ms-2 text-success"
|
|
||||||
/>
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</template>
|
</template>
|
||||||
<p v-html="$t('text-hint-burned')" />
|
<p v-html="$t('text-hint-burned')" />
|
||||||
</template>
|
</template>
|
||||||
|
@ -88,17 +67,16 @@
|
||||||
import appClipboardButton from './clipboard-button.vue'
|
import appClipboardButton from './clipboard-button.vue'
|
||||||
import appCrypto from '../crypto.js'
|
import appCrypto from '../crypto.js'
|
||||||
import appQrButton from './qr-button.vue'
|
import appQrButton from './qr-button.vue'
|
||||||
import { bytesToHuman } from '../helpers'
|
import FilesDisplay from './fileDisplay.vue'
|
||||||
import GrowArea from './growarea.vue'
|
import GrowArea from './growarea.vue'
|
||||||
import OTSMeta from '../ots-meta'
|
import OTSMeta from '../ots-meta'
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
components: { GrowArea, appClipboardButton, appQrButton },
|
components: { FilesDisplay, GrowArea, appClipboardButton, appQrButton },
|
||||||
|
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
files: [],
|
files: [],
|
||||||
hasDownloaded: {},
|
|
||||||
popover: null,
|
popover: null,
|
||||||
secret: null,
|
secret: null,
|
||||||
secretContentBlobURL: null,
|
secretContentBlobURL: null,
|
||||||
|
@ -107,25 +85,6 @@ export default {
|
||||||
},
|
},
|
||||||
|
|
||||||
methods: {
|
methods: {
|
||||||
bytesToHuman,
|
|
||||||
|
|
||||||
fasFileType(type) {
|
|
||||||
return [
|
|
||||||
'fas',
|
|
||||||
'fa-fw',
|
|
||||||
'me-2',
|
|
||||||
...[
|
|
||||||
{ icon: ['fa-file-pdf'], match: /application\/pdf/ },
|
|
||||||
{ icon: ['fa-file-audio'], match: /^audio\// },
|
|
||||||
{ icon: ['fa-file-image'], match: /^image\// },
|
|
||||||
{ icon: ['fa-file-lines'], match: /^text\// },
|
|
||||||
{ icon: ['fa-file-video'], match: /^video\// },
|
|
||||||
{ icon: ['fa-file-zipper'], match: /^application\/(gzip|x-tar|zip)$/ },
|
|
||||||
{ icon: ['fa-file-circle-question'], match: /.*/ },
|
|
||||||
].filter(el => el.match.test(type))[0].icon,
|
|
||||||
].join(' ')
|
|
||||||
},
|
|
||||||
|
|
||||||
// requestSecret requests the encrypted secret from the backend
|
// requestSecret requests the encrypted secret from the backend
|
||||||
requestSecret() {
|
requestSecret() {
|
||||||
this.secretLoading = true
|
this.secretLoading = true
|
||||||
|
@ -161,7 +120,13 @@ export default {
|
||||||
file.arrayBuffer()
|
file.arrayBuffer()
|
||||||
.then(ab => {
|
.then(ab => {
|
||||||
const blobURL = window.URL.createObjectURL(new Blob([ab], { type: file.type }))
|
const blobURL = window.URL.createObjectURL(new Blob([ab], { type: file.type }))
|
||||||
this.files.push({ name: file.name, size: ab.byteLength, type: file.type, url: blobURL })
|
this.files.push({
|
||||||
|
id: window.crypto.randomUUID(),
|
||||||
|
name: file.name,
|
||||||
|
size: ab.byteLength,
|
||||||
|
type: file.type,
|
||||||
|
url: blobURL,
|
||||||
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
this.secretLoading = false
|
this.secretLoading = false
|
||||||
|
|
|
@ -8,4 +8,8 @@ $web-font-path: '';
|
||||||
textarea {
|
textarea {
|
||||||
font-family: monospace !important;
|
font-family: monospace !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.cursor-pointer {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
}
|
}
|
Loading…
Reference in a new issue