[#181] Add paste ability for files to textarea

closes #181

Signed-off-by: Knut Ahlers <knut@ahlers.me>
This commit is contained in:
Knut Ahlers 2024-10-26 14:46:12 +02:00
parent 6e2f20aa53
commit b41db78745
No known key found for this signature in database
5 changed files with 178 additions and 52 deletions

View File

@ -36,6 +36,7 @@
v-model="secret"
class="form-control"
:rows="2"
@pasteFile="handlePasteFile"
/>
</div>
<div
@ -50,7 +51,7 @@
type="file"
multiple
:accept="$root.customize.acceptedFileTypes"
@change="updateFileMeta"
@change="handleSelectFiles"
>
<div class="form-text">
{{ $t('text-max-filesize', { maxSize: bytesToHuman(maxFileSize) }) }}
@ -67,6 +68,14 @@
>
{{ $t('text-max-filesize-exceeded', { curSize: bytesToHuman(fileSize), maxSize: bytesToHuman(maxFileSize) }) }}
</div>
<FilesDisplay
v-if="attachedFiles.length > 0"
class="mt-3"
:can-delete="true"
:track-download="false"
:files="attachedFiles"
@fileClicked="deleteFile"
/>
</div>
<div class="col-md-6 col-12 order-2 order-md-1">
<button
@ -117,6 +126,7 @@
import appCrypto from '../crypto.js'
import { bytesToHuman } from '../helpers'
import FilesDisplay from './fileDisplay.vue'
import GrowArea from './growarea.vue'
import OTSMeta from '../ots-meta'
@ -148,7 +158,7 @@ const passwordCharset = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRS
const passwordLength = 20
export default {
components: { GrowArea },
components: { FilesDisplay, GrowArea },
computed: {
canCreate() {
@ -222,6 +232,7 @@ export default {
data() {
return {
attachedFiles: [],
canWrite: null,
createRunning: false,
fileSize: 0,
@ -268,9 +279,9 @@ export default {
const meta = new OTSMeta()
meta.secret = this.secret
if (this.$refs.createSecretFiles) {
for (const f of [...this.$refs.createSecretFiles.files]) {
meta.files.push(f)
if (this.attachedFiles.length > 0) {
for (const f of this.attachedFiles) {
meta.files.push(f.fileObj)
}
}
@ -317,6 +328,37 @@ export default {
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) {
if (/^(?:[a-z]+|\*)\/(?:[a-zA-Z0-9.+_-]+|\*)$/.test(accept)) {
// That's likely supposed to be a mime-type
@ -332,12 +374,12 @@ export default {
updateFileMeta() {
let cumSize = 0
for (const f of [...this.$refs.createSecretFiles.files]) {
for (const f of this.attachedFiles) {
cumSize += f.size
}
this.fileSize = cumSize
this.selectedFileMeta = [...this.$refs.createSecretFiles.files].map(file => ({
this.selectedFileMeta = this.attachedFiles.map(file => ({
name: file.name,
type: file.type,
}))

View 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>

View File

@ -3,6 +3,7 @@
ref="area"
v-model="data"
style="resize: none;"
@paste="handlePaste"
/>
</template>
@ -33,8 +34,32 @@ export default {
getStyle(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() {
this.changeSize()
},

View File

@ -56,28 +56,7 @@
</div>
<template v-if="files.length > 0">
<p v-html="$t('text-attached-files')" />
<div class="list-group mb-3">
<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>
<FilesDisplay :files="files" />
</template>
<p v-html="$t('text-hint-burned')" />
</template>
@ -88,17 +67,16 @@
import appClipboardButton from './clipboard-button.vue'
import appCrypto from '../crypto.js'
import appQrButton from './qr-button.vue'
import { bytesToHuman } from '../helpers'
import FilesDisplay from './fileDisplay.vue'
import GrowArea from './growarea.vue'
import OTSMeta from '../ots-meta'
export default {
components: { GrowArea, appClipboardButton, appQrButton },
components: { FilesDisplay, GrowArea, appClipboardButton, appQrButton },
data() {
return {
files: [],
hasDownloaded: {},
popover: null,
secret: null,
secretContentBlobURL: null,
@ -107,25 +85,6 @@ export default {
},
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() {
this.secretLoading = true
@ -161,7 +120,13 @@ export default {
file.arrayBuffer()
.then(ab => {
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

View File

@ -8,4 +8,8 @@ $web-font-path: '';
textarea {
font-family: monospace !important;
}
.cursor-pointer {
cursor: pointer;
}
}