DefaultFilePicker.vue 6.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318
  1. <template>
  2. <div
  3. :class="attrs.class"
  4. :style="attrs.style"
  5. @click="pickFile"
  6. @dragover="handleDragOver"
  7. @dragleave="handleDragLeave"
  8. @drop="handleDrop"
  9. >
  10. <div v-if="label || $slots.label" class="q-pl-xs">
  11. <slot name="label">
  12. <span>{{ label }}</span>
  13. </slot>
  14. <span v-if="required" class="text-negative q-ml-xs">*</span>
  15. </div>
  16. <q-field
  17. v-model="model"
  18. v-bind="inputAttrs"
  19. borderless
  20. hide-bottom-space
  21. :rules="rules"
  22. :error="error"
  23. :error-message="errorMessage"
  24. class="image-preview-container"
  25. >
  26. <div
  27. class=""
  28. :class="{
  29. 'has-image': preview,
  30. 'is-dragging': isDragging,
  31. }"
  32. >
  33. <template v-if="!preview">
  34. <q-icon
  35. :name="
  36. isDragging
  37. ? 'mdi-upload'
  38. : type === 'image'
  39. ? 'mdi-image-plus'
  40. : 'mdi-file-plus'
  41. "
  42. size="48px"
  43. color="grey-6"
  44. class="absolute-center"
  45. />
  46. <div
  47. class="text-caption text-grey-6 text-center absolute-bottom q-pb-sm q-px-md"
  48. >
  49. {{
  50. isDragging
  51. ? $t("common.ui.file.drag_and_drop")
  52. : type == "image"
  53. ? $t("common.ui.file.click_select_image")
  54. : $t("common.ui.file.click_select")
  55. }}
  56. </div>
  57. </template>
  58. <q-img
  59. v-else-if="type === 'image'"
  60. :src="preview"
  61. fit="cover"
  62. class="full-height"
  63. />
  64. <div v-else class="position-relative column full-height flex-center">
  65. <q-icon name="mdi-file-check" size="48px" color="grey-6" />
  66. <div
  67. class="absolute-bottom text-caption text-grey-6 text-center q-mb-sm q-px-md"
  68. >
  69. {{ model.name }}
  70. </div>
  71. </div>
  72. <div v-if="preview" class="absolute-top-right q-ma-xs">
  73. <q-btn
  74. flat
  75. dense
  76. round
  77. color="negative"
  78. icon="mdi-close"
  79. @click.stop="clearFile"
  80. />
  81. </div>
  82. </div>
  83. <q-file
  84. v-show="false"
  85. ref="fileInputRef"
  86. v-model="model"
  87. :accept="accept"
  88. />
  89. </q-field>
  90. </div>
  91. </template>
  92. <script setup>
  93. import {
  94. ref,
  95. watch,
  96. onUnmounted,
  97. useTemplateRef,
  98. useAttrs,
  99. computed,
  100. onBeforeMount,
  101. } from "vue";
  102. defineOptions({
  103. inheritAttrs: false,
  104. });
  105. const { label, rules, accept, type, initialImage } = defineProps({
  106. label: {
  107. type: String,
  108. default: "Select Image",
  109. },
  110. rules: {
  111. type: Array,
  112. default: () => [],
  113. },
  114. accept: {
  115. type: String,
  116. default: "image/*",
  117. },
  118. type: {
  119. type: String,
  120. default: "image",
  121. },
  122. initialImage: {
  123. type: String,
  124. default: null,
  125. },
  126. error: {
  127. type: Boolean,
  128. default: false,
  129. },
  130. errorMessage: {
  131. type: String,
  132. default: "",
  133. },
  134. });
  135. const attrs = useAttrs();
  136. const fileInputRef = useTemplateRef("fileInputRef");
  137. const model = defineModel({ type: [File, String, null], default: null });
  138. const base64File = defineModel("base64File", { type: String, default: null });
  139. const isDragging = ref(false);
  140. const preview = ref(initialImage || null);
  141. const required = ref(false);
  142. let objectUrl = null;
  143. const cleanupObjectURL = () => {
  144. if (objectUrl) {
  145. URL.revokeObjectURL(objectUrl);
  146. objectUrl = null;
  147. }
  148. };
  149. const generateBase64 = (file) => {
  150. if (!file) {
  151. base64File.value = null;
  152. return;
  153. }
  154. const reader = new FileReader();
  155. reader.onload = (e) => {
  156. base64File.value = e.target.result;
  157. };
  158. reader.onerror = () => {
  159. console.error("FileReader failed to read file.");
  160. base64File.value = null;
  161. };
  162. reader.readAsDataURL(file);
  163. };
  164. const pickFile = () => {
  165. fileInputRef.value?.pickFiles();
  166. };
  167. const clearFile = () => {
  168. model.value = null;
  169. };
  170. const handleDragOver = (event) => {
  171. event.preventDefault();
  172. event.dataTransfer.dropEffect = "copy";
  173. isDragging.value = true;
  174. };
  175. const handleDragLeave = () => {
  176. isDragging.value = false;
  177. };
  178. const handleDrop = (event) => {
  179. event.preventDefault();
  180. isDragging.value = false;
  181. const file = event.dataTransfer?.files?.[0];
  182. if (!file) return;
  183. const acceptedMime = accept;
  184. if (acceptedMime.endsWith("/*")) {
  185. const baseMime = acceptedMime.replace("/*", "");
  186. if (file.type.startsWith(baseMime + "/")) {
  187. model.value = file;
  188. }
  189. } else {
  190. if (
  191. acceptedMime
  192. .split(",")
  193. .map((m) => m.trim())
  194. .includes(file.type)
  195. ) {
  196. model.value = file;
  197. }
  198. }
  199. };
  200. const inputAttrs = computed(() => {
  201. // eslint-disable-next-line
  202. const { class: _, style: __, ...rest } = attrs;
  203. return rest;
  204. });
  205. watch(model, (newFile) => {
  206. cleanupObjectURL();
  207. if (newFile && newFile instanceof File) {
  208. if (type === "image") {
  209. objectUrl = URL.createObjectURL(newFile);
  210. preview.value = objectUrl;
  211. } else {
  212. preview.value = "file_selected";
  213. }
  214. generateBase64(newFile);
  215. } else {
  216. preview.value = initialImage || null;
  217. base64File.value = null;
  218. }
  219. });
  220. watch(
  221. () => rules,
  222. (values) => {
  223. values.forEach((r) => {
  224. if (r?.$id === "required") return (required.value = true);
  225. });
  226. },
  227. );
  228. onBeforeMount(() => {
  229. rules.forEach((r) => {
  230. if (r?.$id === "required") return (required.value = true);
  231. });
  232. });
  233. onUnmounted(cleanupObjectURL);
  234. </script>
  235. <style lang="scss">
  236. @use "sass:map";
  237. @use "src/css/quasar.variables.scss";
  238. .image-preview-container {
  239. display: flex;
  240. justify-content: center;
  241. align-items: center;
  242. .q-field__inner {
  243. .body--dark & {
  244. --image-bg-color: #{map.get($colors-dark, "surface")};
  245. --image-border-color: #{map.get($colors-dark, "primary")};
  246. --image-border-hover-color: #{map.get($colors-dark, "primary-dark")};
  247. }
  248. .body--light & {
  249. --image-bg-color: #{map.get($colors, "surface")};
  250. --image-border-color: #{map.get($colors, "primary")};
  251. --image-border-hover-color: #{map.get($colors, "primary-dark")};
  252. }
  253. display: flex;
  254. flex-direction: column;
  255. transition: all 0.3s;
  256. &.is-dragging {
  257. border-color: var(--image-border-hover-color);
  258. background-color: var(--image-bg-color);
  259. opacity: 0.8;
  260. }
  261. &.has-image {
  262. border-style: solid;
  263. margin: auto;
  264. }
  265. }
  266. .q-field__control {
  267. height: 100%;
  268. min-height: 200px;
  269. border: 2px dashed var(--image-border-color);
  270. border-radius: 8px;
  271. cursor: pointer;
  272. }
  273. .q-field__control-container {
  274. justify-content: center;
  275. }
  276. .q-field__append {
  277. position: absolute;
  278. top: -5px;
  279. right: 10px;
  280. }
  281. }
  282. </style>