Skip to content

Commit 79759ae

Browse files
committed
[Dropzone] Add multiple file preview
1 parent 01fcd17 commit 79759ae

File tree

9 files changed

+907
-163
lines changed

9 files changed

+907
-163
lines changed

src/Dropzone/CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
11
# CHANGELOG
22

3+
## 2.24
4+
5+
- Support multiple files preview
6+
37
## 2.20
48

59
- Enable file replacement via "drag-and-drop"
Lines changed: 36 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,48 @@
11
import { Controller } from '@hotwired/stimulus';
22
export default class extends Controller {
33
readonly inputTarget: HTMLInputElement;
4-
readonly placeholderTarget: HTMLDivElement;
5-
readonly previewTarget: HTMLDivElement;
6-
readonly previewClearButtonTarget: HTMLButtonElement;
7-
readonly previewFilenameTarget: HTMLDivElement;
8-
readonly previewImageTarget: HTMLDivElement;
4+
readonly placeholderTarget: HTMLElement;
5+
readonly previewTargets: HTMLElement[];
6+
readonly previewContainerTarget: HTMLElement;
7+
readonly previewTemplateTarget: HTMLTemplateElement;
8+
readonly optionsValue: any;
9+
static values: {
10+
options: {
11+
type: ObjectConstructor;
12+
default: {
13+
preview: {
14+
style: string;
15+
can_open_file_picker: boolean;
16+
can_toggle_placeholder: boolean;
17+
};
18+
};
19+
};
20+
};
921
static targets: string[];
22+
files: Map<string, File>;
1023
initialize(): void;
1124
connect(): void;
1225
disconnect(): void;
1326
clear(): void;
1427
onInputChange(event: any): void;
15-
_populateImagePreview(file: Blob): void;
16-
onDragEnter(): void;
1728
onDragLeave(event: any): void;
29+
onDragOver(event: any): void;
30+
onDrop(event: any): void;
31+
onPreviewContainerClick(event: any): void;
32+
onPreviewButtonClick(event: any): void;
1833
private dispatchEvent;
34+
private addFiles;
35+
private buildPreview;
36+
private refreshPreview;
37+
private isImage;
38+
private get isMultiple();
39+
private updateFileInput;
40+
private formatBytes;
41+
private get firstFile();
42+
private get isLegacy();
43+
private refreshLegacyPreview;
44+
private showLegacyPreview;
45+
private hideLegacyPreview;
46+
private showLegacyFileInput;
47+
private hideLegacyFileInput;
1948
}
Lines changed: 227 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -1,79 +1,265 @@
11
import { Controller } from '@hotwired/stimulus';
22

33
class default_1 extends Controller {
4+
constructor() {
5+
super(...arguments);
6+
this.files = new Map();
7+
}
48
initialize() {
59
this.clear = this.clear.bind(this);
610
this.onInputChange = this.onInputChange.bind(this);
7-
this.onDragEnter = this.onDragEnter.bind(this);
811
this.onDragLeave = this.onDragLeave.bind(this);
12+
this.onDragOver = this.onDragOver.bind(this);
13+
this.onDrop = this.onDrop.bind(this);
14+
this.onPreviewButtonClick = this.onPreviewButtonClick.bind(this);
15+
this.onPreviewContainerClick = this.onPreviewContainerClick.bind(this);
916
}
1017
connect() {
1118
this.clear();
12-
this.previewClearButtonTarget.addEventListener('click', this.clear);
1319
this.inputTarget.addEventListener('change', this.onInputChange);
14-
this.element.addEventListener('dragenter', this.onDragEnter);
1520
this.element.addEventListener('dragleave', this.onDragLeave);
21+
this.element.addEventListener('dragover', this.onDragOver);
22+
this.element.addEventListener('drop', this.onDrop);
23+
if (!this.isLegacy && this.optionsValue.preview.can_open_file_picker) {
24+
this.previewContainerTarget.addEventListener('click', this.onPreviewContainerClick);
25+
}
1626
this.dispatchEvent('connect');
1727
}
1828
disconnect() {
19-
this.previewClearButtonTarget.removeEventListener('click', this.clear);
29+
this.clear();
2030
this.inputTarget.removeEventListener('change', this.onInputChange);
21-
this.element.removeEventListener('dragenter', this.onDragEnter);
2231
this.element.removeEventListener('dragleave', this.onDragLeave);
32+
this.element.removeEventListener('dragover', this.onDragOver);
33+
this.element.removeEventListener('drop', this.onDrop);
34+
if (!this.isLegacy && this.optionsValue.preview.can_open_file_picker) {
35+
this.previewContainerTarget.removeEventListener('click', this.onPreviewContainerClick);
36+
}
2337
}
2438
clear() {
25-
this.inputTarget.value = '';
26-
this.inputTarget.style.display = 'block';
27-
this.placeholderTarget.style.display = 'block';
28-
this.previewTarget.style.display = 'none';
29-
this.previewImageTarget.style.display = 'none';
30-
this.previewImageTarget.style.backgroundImage = 'none';
31-
this.previewFilenameTarget.textContent = '';
39+
this.files.clear();
40+
this.updateFileInput();
41+
this.refreshPreview();
42+
this.element.classList.remove('dropzone-active');
43+
if (this.isLegacy) {
44+
this.showLegacyFileInput();
45+
}
3246
this.dispatchEvent('clear');
3347
}
3448
onInputChange(event) {
35-
const file = event.target.files[0];
36-
if (typeof file === 'undefined') {
49+
const files = Array.from(event.target.files).filter((file) => typeof file !== 'undefined');
50+
if (files.length === 0) {
3751
return;
3852
}
39-
this.inputTarget.style.display = 'none';
40-
this.placeholderTarget.style.display = 'none';
41-
this.previewFilenameTarget.textContent = file.name;
42-
this.previewTarget.style.display = 'flex';
43-
this.previewImageTarget.style.display = 'none';
44-
if (file.type && file.type.indexOf('image') !== -1) {
45-
this._populateImagePreview(file);
53+
this.files.clear();
54+
this.addFiles(files);
55+
this.refreshPreview();
56+
this.dispatchEvent('change', this.isLegacy ? this.firstFile : Array.from(this.files.values()));
57+
}
58+
onDragLeave(event) {
59+
event.preventDefault();
60+
if (!this.element.contains(event.relatedTarget)) {
61+
this.element.classList.remove('dropzone-active');
62+
if (this.isLegacy) {
63+
this.hideLegacyFileInput();
64+
this.showLegacyPreview();
65+
}
66+
}
67+
}
68+
onDragOver(event) {
69+
event.preventDefault();
70+
this.element.classList.add('dropzone-active');
71+
if (this.isLegacy) {
72+
this.hideLegacyPreview();
73+
this.showLegacyFileInput();
4674
}
47-
this.dispatchEvent('change', file);
4875
}
49-
_populateImagePreview(file) {
50-
if (typeof FileReader === 'undefined') {
76+
onDrop(event) {
77+
event.preventDefault();
78+
const files = Array.from(event.dataTransfer.files).filter((file) => typeof file !== 'undefined');
79+
if (files.length === 0) {
5180
return;
5281
}
53-
const reader = new FileReader();
54-
reader.addEventListener('load', (event) => {
55-
this.previewImageTarget.style.display = 'block';
56-
this.previewImageTarget.style.backgroundImage = `url("${event.target.result}")`;
57-
});
58-
reader.readAsDataURL(file);
82+
if (!this.isMultiple) {
83+
this.files.clear();
84+
}
85+
this.addFiles(files);
86+
this.updateFileInput();
87+
this.refreshPreview();
88+
this.element.classList.remove('dropzone-active');
89+
this.dispatchEvent('change', Array.from(this.files.values()));
5990
}
60-
onDragEnter() {
61-
this.inputTarget.style.display = 'block';
62-
this.placeholderTarget.style.display = 'block';
63-
this.previewTarget.style.display = 'none';
91+
onPreviewContainerClick(event) {
92+
event.stopPropagation();
93+
this.inputTarget.click();
6494
}
65-
onDragLeave(event) {
66-
event.preventDefault();
67-
if (!this.element.contains(event.relatedTarget)) {
68-
this.inputTarget.style.display = 'none';
69-
this.placeholderTarget.style.display = 'none';
70-
this.previewTarget.style.display = 'block';
95+
onPreviewButtonClick(event) {
96+
event.stopPropagation();
97+
if (this.isLegacy) {
98+
return this.clear();
7199
}
100+
const button = event.currentTarget;
101+
button.removeEventListener('click', this.onPreviewButtonClick);
102+
const preview = button.closest('.dropzone-preview');
103+
preview.remove();
104+
if (!button.dataset.filename) {
105+
return;
106+
}
107+
this.files.delete(button.dataset.filename);
108+
this.updateFileInput();
109+
this.refreshPreview();
72110
}
73111
dispatchEvent(name, payload = {}) {
74112
this.dispatch(name, { detail: payload, prefix: 'dropzone' });
75113
}
114+
addFiles(files) {
115+
for (const file of files) {
116+
this.files.set(file.name, file);
117+
}
118+
}
119+
buildPreview(file, el) {
120+
if (!el) {
121+
el = this.previewTemplateTarget.content.firstElementChild?.cloneNode(true);
122+
}
123+
const button = el.querySelector('.dropzone-preview-button');
124+
if (button) {
125+
button.dataset.filename = file.name;
126+
button.addEventListener('click', this.onPreviewButtonClick);
127+
}
128+
const filename = el.querySelector('.dropzone-preview-filename');
129+
if (filename) {
130+
filename.textContent = file.name;
131+
}
132+
const size = el.querySelector('.dropzone-preview-file-size');
133+
if (size) {
134+
size.textContent = this.formatBytes(file.size);
135+
}
136+
const image = el.querySelector('.dropzone-preview-image');
137+
if (image && this.isImage(file) && typeof FileReader !== 'undefined') {
138+
const reader = new FileReader();
139+
image.classList.add('dropzone-preview-image-hidden');
140+
reader.addEventListener('load', (event) => {
141+
image.querySelector('.dropzone-preview-image-placeholder')?.remove();
142+
image.style.backgroundImage = `url('${event.target.result}')`;
143+
image.classList.remove('dropzone-preview-image-hidden');
144+
});
145+
reader.readAsDataURL(file);
146+
}
147+
return el;
148+
}
149+
refreshPreview() {
150+
if (this.isLegacy) {
151+
return this.refreshLegacyPreview();
152+
}
153+
this.element.classList.add('dropzone-preview-container-hidden');
154+
for (const preview of this.previewTargets) {
155+
preview.querySelector('.dropzone-preview-button')?.removeEventListener('click', this.onPreviewButtonClick);
156+
preview.remove();
157+
}
158+
for (const file of this.files.values()) {
159+
const preview = this.buildPreview(file);
160+
this.previewContainerTarget.appendChild(preview);
161+
}
162+
if (this.previewTargets.length > 0) {
163+
this.element.classList.remove('dropzone-preview-container-hidden');
164+
}
165+
const canToggle = this.optionsValue.preview.can_toggle_placeholder;
166+
if (canToggle) {
167+
const hide = this.previewTargets.length > 0 &&
168+
(canToggle === true || (canToggle === 'auto' && this.previewTargets.length < 2));
169+
this.element.classList.toggle('dropzone-placeholder-hidden', hide);
170+
}
171+
}
172+
isImage(file) {
173+
return typeof file.type !== 'undefined' && file.type.indexOf('image') !== -1;
174+
}
175+
get isMultiple() {
176+
return this.inputTarget.multiple;
177+
}
178+
updateFileInput() {
179+
const dataTransfer = new DataTransfer();
180+
for (const file of this.files.values()) {
181+
dataTransfer.items.add(file);
182+
}
183+
this.inputTarget.files = dataTransfer.files;
184+
}
185+
formatBytes(bytes, decimals = 2) {
186+
if (bytes === 0)
187+
return '0 Bytes';
188+
const k = 1024;
189+
const dm = decimals || 2;
190+
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'];
191+
const i = Math.floor(Math.log(bytes) / Math.log(k));
192+
return `${Number.parseFloat((bytes / k ** i).toFixed(dm))} ${sizes[i]}`;
193+
}
194+
get firstFile() {
195+
return this.files.values().next().value;
196+
}
197+
get isLegacy() {
198+
return this.optionsValue.preview.style === 'legacy';
199+
}
200+
refreshLegacyPreview() {
201+
const preview = this.previewTargets[0];
202+
const image = preview.querySelector('.dropzone-preview-image');
203+
const filename = preview.querySelector('.dropzone-preview-filename');
204+
const file = this.firstFile;
205+
if (!file) {
206+
this.hideLegacyPreview();
207+
if (filename) {
208+
filename.textContent = '';
209+
}
210+
if (image) {
211+
image.style.display = 'none';
212+
image.style.backgroundImage = 'none';
213+
}
214+
return;
215+
}
216+
this.buildPreview(file, preview);
217+
const fileCount = this.files.size;
218+
if (filename && fileCount > 1) {
219+
filename.textContent += ` +${fileCount - 1}`;
220+
filename.title = Array.from(this.files.values())
221+
.map((file) => file.name)
222+
.join('\n');
223+
}
224+
if (image) {
225+
if (this.isImage(file)) {
226+
image.style.display = 'block';
227+
}
228+
else {
229+
image.style.display = 'none';
230+
image.style.backgroundImage = 'none';
231+
}
232+
}
233+
this.showLegacyPreview();
234+
this.hideLegacyFileInput();
235+
}
236+
showLegacyPreview() {
237+
this.previewTargets[0].style.display = 'flex';
238+
}
239+
hideLegacyPreview() {
240+
this.previewTargets[0].style.display = 'none';
241+
}
242+
showLegacyFileInput() {
243+
this.inputTarget.style.display = 'block';
244+
this.placeholderTarget.style.display = 'block';
245+
}
246+
hideLegacyFileInput() {
247+
this.inputTarget.style.display = 'none';
248+
this.placeholderTarget.style.display = 'none';
249+
}
76250
}
77-
default_1.targets = ['input', 'placeholder', 'preview', 'previewClearButton', 'previewFilename', 'previewImage'];
251+
default_1.values = {
252+
options: {
253+
type: Object,
254+
default: {
255+
preview: {
256+
style: 'legacy',
257+
can_open_file_picker: true,
258+
can_toggle_placeholder: true,
259+
},
260+
},
261+
},
262+
};
263+
default_1.targets = ['input', 'placeholder', 'preview', 'previewContainer', 'previewTemplate'];
78264

79265
export { default_1 as default };

src/Dropzone/assets/dist/style.min.css

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)