Skip to content

Instantly share code, notes, and snippets.

@sploders101
Last active September 17, 2023 15:25
Show Gist options
  • Select an option

  • Save sploders101/f1a3bb46ed4b8a5897d2153a846addc9 to your computer and use it in GitHub Desktop.

Select an option

Save sploders101/f1a3bb46ed4b8a5897d2153a846addc9 to your computer and use it in GitHub Desktop.
Vuetify file drop box (drag-n-drop enabled, no directory support, uses vuetify-loader)
<template>
<!-- Drop box -->
<div class="dropzone"
@dragover.prevent
@dragleave="dragleave"
@dragenter="dragenter"
@drop="drop"
ref="dropzone"
>
<!-- Box Label -->
<slot v-if="!disableLabel"><h1>{{label || "Upload Box"}}</h1></slot>
<!-- Upload Button -->
<v-btn @click="$refs.filebtn.click()">
<v-icon left dark>cloud_upload</v-icon>
Upload
</v-btn>
<!-- Indicate files can be dropped in here -->
<p v-if="!disableHint">
*Files can also be dropped in the box
</p>
<!-- Indicate selected files -->
<div class="input-container">
<v-input
v-for="file in files"
:key="file.name"
append-icon="close"
@click:append="remove(file)"
prepend-icon="insert_drive_file"
>
{{file.name}}
</v-input>
</div>
<!-- Hidden upload button to bring up file selection dialog -->
<input
ref="filebtn"
class="filebtn"
type="file"
:multiple="multiple"
:accept="
validatedAccept &&
[
...validatedAccept.extensions,
...validatedAccept.mimetypes,
].join(',')
"
@input="upload"
>
</div>
</template>
<script lang="ts">
import { Component, Vue, Prop, Ref, Watch } from "vue-property-decorator";
import {} from "vuetify/lib";
@Component export default class extends Vue {
// State
/** Counter for enter/leave events (necessary due to issues with rippling) */
hoverCounter: number = 0;
/** Reflects the files currently hovering over the box (used for type checking) */
hoveringContent: DataTransferItemList | null = null;
matchAnything = /.*/;
// Props
/** Replaces the default "Upload Box" text */
@Prop(String) readonly label!: string | undefined;
/**
* Array of selected files
* .sync/v-model not needed for :files="[]", because the array is passed by
* reference and this component only uses reactive functions for modification
*/
@Prop(Array) readonly files!: File[];
/** Same as <input type=file> */
@Prop(String) readonly accept!: string | undefined;
/** Same as <input type=file> */
@Prop(Boolean) readonly multiple!: boolean | undefined;
/** Disables the "Upload box" label */
@Prop(Boolean) readonly disableLabel!: boolean | undefined;
/** Disables the "*Files can also be dropped in the box" hint */
@Prop(Boolean) readonly disableHint!: boolean | undefined;
// Refs
@Ref() readonly filebtn!: HTMLInputElement;
@Ref() readonly dropzone!: HTMLDivElement;
// Watchers
@Watch("multiple")
onMultipleChanged(val: boolean) {
if(!val) {
this.files.splice(0, this.files.length - 1);
}
}
@Watch("hoveringContent")
onhoveringContentChanged(val: DataTransferItemList) {
// If a file is hovering
if(val) {
// If we have type checking and we're using mimetypes only
if(this.accept && this.accept.length && this.validTypes.extensions.length === 0) {
let shouldDim = false;
// For each file hovering over the box...
for(let i = 0; i < val.length; i++) {
if(
// Check the type against all our mime types
this.validTypes.mimetypes.reduce((prev, regex) => prev || !!(val[i].type.match(regex)), false as boolean)
) {
shouldDim = true;
break;
}
}
// If we found a match, dim the box
if(shouldDim) this.dropzone.style.backgroundColor = "rgba(0, 0, 0, 0.25)";
// If not, we can't definitively typecheck, so...
} else {
// Check that we have a file in there
let shouldDim = false;
for(let i = 0; i < val.length; i++) {
if(
val[i].kind === "file"
) {
shouldDim = true;
break;
}
}
// ... and dim the box
if(shouldDim) this.dropzone.style.backgroundColor = "rgba(0, 0, 0, 0.25)";
}
// Otherwise...
} else {
// Un-dim the box
this.dropzone.style.backgroundColor = "";
}
}
@Watch("hoverCounter")
onHoverCounterChanged(val: number) {
if(val === 0) this.hoveringContent = null;
}
/**
* Turn validatedAccept result into regex arrays for checking dropped files.
* Each regex pattern checks for its corresponding accept filter.
* If no accept filters can be properly validated, everything will match.
* @returns {
* extensions: Regex[],
* mimetypes: Regex[],
* }
*/
get validTypes() {
if(this.validatedAccept) {
return {
extensions: this.validatedAccept.extensions
.map((ext) => ext.replace(/(\W)/g, "\\$1")) // Escape all potential regex tokens
.map((rgxstr) => new RegExp(`${rgxstr}$`, "i")), // Transform into regex to look for extension
mimetypes: this.validatedAccept.mimetypes
.map((mt) => mt.replace(/([\-\+\/])/g, "\\$1")) // Escape special characters
.map((mt) => mt.replace(/\*/g, "(?:[A-Za-z0-9\\-\\+]*)*")) // Enable wildcards
.map((rgxstr) => new RegExp(`^${rgxstr}$`)), // Transform into regex
};
} else {
// If we haven't been given any filters...
return {
extensions: [this.matchAnything],
mimetypes: [this.matchAnything],
};
}
}
/**
* Validate & filter accept property and separate into categories
* @returns {
* extensions: string[],
* mimetypes: string[],
* } | null
*/
get validatedAccept() {
if(this.accept) {
return {
extensions:
this.accept.split(",")
.filter((type) =>
type.match(/^\.(?!.*\/)/)), // Get only extension filters
mimetypes:
this.accept.split(",")
.filter((type) =>
type.match(/^(?:(?:[A-Za-z0-9\-\+]*)|\*)\/(?:(?:[A-Za-z0-9\-\+]*)|\*)$/)), // Get only mimetype filters
};
} else {
return null;
}
}
// Methods
/** Manages <input type="file">'s state to integrate it with the rest of the box */
upload() {
for(let i = 0; i < this.filebtn.files!.length; i++) {
if(!this.multiple) this.files.splice(0, this.files.length);
const shouldPush =
this.validTypes.extensions
.reduce((prev, regex) => prev || !!(this.filebtn.files![i].name.match(regex)), false as boolean) ||
this.validTypes.mimetypes
.reduce((prev, regex) => prev || !!(this.filebtn.files![i].type.match(regex)), false as boolean);
if(shouldPush) this.files.push(this.filebtn.files![i]);
}
this.filebtn.value = "";
}
/** Keep track of what is being dragged over the box, and count enter events (fix for event rippling issues) */
dragenter(e: DragEvent) {
this.hoveringContent = e.dataTransfer!.items;
this.hoverCounter++;
}
/** Counts leave events (fix for event rippling issues) */
dragleave(e: DragEvent) {
this.hoverCounter--;
}
/** Validates and keeps track of dropped content */
drop(e: DragEvent) {
e.preventDefault(); // Keep from leaving the page
this.hoverCounter = 0; // Content can't be dragged out, so go ahead and reset the counter
if(e.dataTransfer!.items) {
const rejected = []; // Keeps track of rejected items for reporting at the end
for(let i = 0; i < e.dataTransfer!.items.length; i++) {
if(e.dataTransfer!.items[i].kind === "file") {
// Directories are not supported. Skip any that are found
if(e.dataTransfer!.items[i].webkitGetAsEntry) {
const entry = e.dataTransfer!.items[i].webkitGetAsEntry();
if(entry.isDirectory) {
rejected.push(entry.name);
continue;
}
}
const file = e.dataTransfer!.items[i].getAsFile();
if(file) {
const shouldPush = // Check against Regex arrays from accept property
this.validTypes.extensions.reduce((prev, regex) => prev || !!(file.name.match(regex)), false as boolean) ||
this.validTypes.mimetypes .reduce((prev, regex) => prev || !!(file.type.match(regex)), false as boolean);
if(shouldPush) {
if(this.multiple) {
// Remove duplicates
this.files.filter((currFile) => currFile.name === file.name)
.forEach((fileToRemove) => this.files.splice(this.files.indexOf(fileToRemove), 1));
} else {
// Remove all
this.files.splice(0, this.files.length);
}
this.files.push(file);
} else {
rejected.push(file); // Keep track of rejected files
continue;
}
} else continue;
}
}
// Emit rejected files
if(rejected.length) this.$emit("rejectedFiles", rejected);
}
}
/** Removes attachment per user's request */
remove(file: File) {
const arr = (this.files as File[]);
arr.splice(arr.indexOf(file), 1);
this.$emit("update", null);
}
}
</script>
<style lang="scss" scoped>
h1 {
font-size: 1.5em;
font-weight: 400;
font-family: Roboto, sans-serif;
color: hsla(0,0%,100%,.7);
}
p {
margin: 0;
font-size: 0.75em;
font-weight: 100;
}
.dropzone {
display: flex;
flex-flow: column nowrap;
justify-content: center;
align-items: center;
padding: 20px;
border: 2px dashed hsla(0,0%,100%,.7);
border-radius: 20px;
overflow: hidden;
transition: background-color 0.2s;
}
div.input-container {
min-width: 50%;
}
.v-input {
::v-deep div.v-input__control {
div.v-input__slot {
margin-top: 4px;
margin-bottom: 0 !important;
}
div.v-messages {
display: none;
}
}
}
input.filebtn {
display: none;
}
</style>
@MEGApixel23
Copy link
Copy Markdown

Lifesaver! Thanks, buddy. :)

@shainegordon
Copy link
Copy Markdown

shainegordon commented Oct 14, 2020

Javascript version, using MDI-Icons

<template>
    <!-- Drop box -->
    <div class="dropzone" @dragover.prevent @dragleave="dragleave" @dragenter="dragenter" @drop="drop" ref="dropzone">
        <!-- Box Label -->
        <slot v-if="!disableLabel"
            ><h1>{{ label || 'Upload Box' }}</h1></slot
        >
        <!-- Upload Button -->
        <v-btn @click="$refs.filebtn.click()">
            <v-icon left dark>mdi-cloud-upload</v-icon>
            Upload
        </v-btn>
        <!-- Indicate files can be dropped in here -->
        <p v-if="!disableHint">*Files can also be dropped in the box</p>
        <!-- Indicate selected files -->
        <div class="input-container">
            <v-input
                v-for="file in files"
                :key="file.name"
                append-icon="mdi-close"
                @click:append="remove(file)"
                prepend-icon="mdi-file"
            >
                {{ file.name }}
            </v-input>
        </div>
        <!-- Hidden upload button to bring up file selection dialog -->
        <input
            ref="filebtn"
            class="filebtn"
            type="file"
            :multiple="multiple"
            :accept="validatedAccept && [...validatedAccept.extensions, ...validatedAccept.mimetypes].join(',')"
            @input="upload"
        />
    </div>
</template>

<script>
export default {
    props: {
        label: {
            type: String,
            required: false,
        },
        files: {
            type: Array,
            required: true,
        },
        accept: {
            type: String,
            required: false,
        },
        multiple: {
            type: Boolean,
            required: false,
        },
        disableLabel: {
            type: Boolean,
            required: false,
        },
        disableHint: {
            type: Boolean,
            required: false,
        },
    },
    data() {
        return {
            hoverCounter: 0,
            hoveringContent: null,
            matchAnything: /.*/,
        }
    },
    computed: {
        filebtn: {
            cache: false,
            get() {
                return this.$refs.filebtn
            },
        },
        dropzone: {
            cache: false,
            get() {
                return this.$refs.dropzone
            },
        },
        validTypes() {
            if (this.validatedAccept) {
                return {
                    extensions: this.validatedAccept.extensions
                        .map(ext => ext.replace(/(\W)/g, '\\$1')) // Escape all potential regex tokens
                        .map(rgxstr => new RegExp(`${rgxstr}$`, 'i')), // Transform into regex to look for extension
                    mimetypes: this.validatedAccept.mimetypes
                        .map(mt => mt.replace(/([-+/])/g, '\\$1')) // Escape special characters
                        .map(mt => mt.replace(/\*/g, '(?:[A-Za-z0-9\\-\\+]*)*')) // Enable wildcards
                        .map(rgxstr => new RegExp(`^${rgxstr}$`)), // Transform into regex
                }
            } else {
                // If we haven't been given any filters...
                return {
                    extensions: [this.matchAnything],
                    mimetypes: [this.matchAnything],
                }
            }
        },
        validatedAccept() {
            if (this.accept) {
                return {
                    extensions: this.accept.split(',').filter(type => type.match(/^\.(?!.*\/)/)), // Get only extension filters
                    mimetypes: this.accept
                        .split(',')
                        .filter(type => type.match(/^(?:(?:[A-Za-z0-9\-+]*)|\*)\/(?:(?:[A-Za-z0-9\-+.]*)|\*)$/)), // Get only mimetype filters
                }
            } else {
                return null
            }
        },
    },
    methods: {
        upload() {
            const files = this.filebtn.files ?? []
            for (let i = 0; i < files.length; i++) {
                if (!this.multiple) {
                    this.files.splice(0, this.files.length)
                }
                const shouldPush =
                    this.validTypes.extensions.reduce((prev, regex) => prev || !!files[i].name.match(regex), false) ||
                    this.validTypes.mimetypes.reduce((prev, regex) => prev || !!files[i].type.match(regex), false)
                if (shouldPush) {
                    this.files.push(files[i])
                }
            }
            this.filebtn.value = ''
        },
        dragenter(e) {
            this.hoveringContent = e.dataTransfer.items
            this.hoverCounter++
        },
        /** Counts leave events (fix for event rippling issues) */
        dragleave(e) {
            this.hoverCounter--
        },
        /** Validates and keeps track of dropped content */
        drop(e) {
            e.preventDefault() // Keep from leaving the page
            this.hoverCounter = 0 // Content can't be dragged out, so go ahead and reset the counter
            if (e.dataTransfer.items) {
                const rejected = [] // Keeps track of rejected items for reporting at the end
                for (let i = 0; i < e.dataTransfer.items.length; i++) {
                    if (e.dataTransfer.items[i].kind === 'file') {
                        // Directories are not supported. Skip any that are found
                        if (e.dataTransfer.items[i].webkitGetAsEntry) {
                            const entry = e.dataTransfer.items[i].webkitGetAsEntry()
                            if (entry.isDirectory) {
                                rejected.push(entry.name)
                                continue
                            }
                        }
                        const file = e.dataTransfer.items[i].getAsFile()
                        if (file) {
                            const shouldPush = // Check against Regex arrays from accept property
                                this.validTypes.extensions.reduce(
                                    (prev, regex) => prev || !!file.name.match(regex),
                                    false,
                                ) ||
                                this.validTypes.mimetypes.reduce(
                                    (prev, regex) => prev || !!file.type.match(regex),
                                    false,
                                )
                            if (shouldPush) {
                                if (this.multiple) {
                                    // Remove duplicates
                                    this.files
                                        .filter(currFile => currFile.name === file.name)
                                        .forEach(fileToRemove => this.files.splice(this.files.indexOf(fileToRemove), 1))
                                } else {
                                    // Remove all
                                    this.files.splice(0, this.files.length)
                                }
                                this.files.push(file)
                            } else {
                                rejected.push(file) // Keep track of rejected files
                                continue
                            }
                        } else {
                            continue
                        }
                    }
                }
                // Emit rejected files
                if (rejected.length) {
                    this.$emit('rejectedFiles', rejected)
                }
            }
        },
        /** Removes attachment per user's request */
        remove(file) {
            const arr = this.files
            arr.splice(arr.indexOf(file), 1)
            this.$emit('update', null)
        },
    },
    watch: {
        multiple(val) {
            if (!val) {
                this.files.splice(0, this.files.length - 1)
            }
        },
        hoveringContent(val) {
            // If a file is hovering
            if (val) {
                // If we have type checking and we're using mimetypes only
                if (this.accept && this.accept.length && this.validTypes.extensions.length === 0) {
                    let shouldDim = false
                    // For each file hovering over the box...
                    for (let i = 0; i < val.length; i++) {
                        if (
                            // Check the type against all our mime types
                            this.validTypes.mimetypes.reduce((prev, regex) => prev || !!val[i].type.match(regex))
                        ) {
                            shouldDim = true
                            break
                        }
                    }
                    // If we found a match, dim the box
                    if (shouldDim) {
                        this.dropzone.style.backgroundColor = 'rgba(0, 0, 0, 0.25)'
                    }
                    // If not, we can't definitively typecheck, so...
                } else {
                    // Check that we have a file in there
                    let shouldDim = false
                    for (let i = 0; i < val.length; i++) {
                        if (val[i].kind === 'file') {
                            shouldDim = true
                            break
                        }
                    }
                    // ... and dim the box
                    if (shouldDim) {
                        this.dropzone.style.backgroundColor = 'rgba(0, 0, 0, 0.25)'
                    }
                }
                // Otherwise...
            } else {
                // Un-dim the box
                this.dropzone.style.backgroundColor = ''
            }
        },
        hoverCounter(val) {
            if (val === 0) {
                this.hoveringContent = null
            }
        },
    },
}
</script>

<style lang="scss" scoped>
h1 {
    font-size: 1.5em;
    font-weight: 400;
    font-family: Roboto, sans-serif;
    color: hsla(0, 0%, 100%, 0.7);
}
p {
    margin: 0;
    font-size: 0.75em;
    font-weight: 100;
}
.dropzone {
    display: flex;
    flex-flow: column nowrap;
    justify-content: center;
    align-items: center;
    padding: 20px;
    border: 2px dashed hsla(0, 0%, 100%, 0.7);
    border-radius: 20px;
    overflow: hidden;
    transition: background-color 0.2s;
}
div.input-container {
    min-width: 50%;
}
.v-input {
    ::v-deep div.v-input__control {
        div.v-input__slot {
            margin-top: 4px;
            margin-bottom: 0 !important;
        }
        div.v-messages {
            display: none;
        }
    }
}
input.filebtn {
    display: none;
}
</style>

@shainegordon
Copy link
Copy Markdown

shainegordon commented Oct 14, 2020

computed property validatedAccept - mimetypes regex should include a . to be able to register mimetype like application/vnd.openxmlformats-officedocument.spreadsheetml.sheet

mimetypes: this.accept
     .split(',')
     .filter(type => type.match(/^(?:(?:[A-Za-z0-9\-+]*)|\*)\/(?:(?:[A-Za-z0-9\-+.]*)|\*)$/)), // Get only mimetype filters

@hknp796
Copy link
Copy Markdown

hknp796 commented Mar 15, 2022

Nice thank you

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment