Skip to content

Commit c137c69

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

File tree

9 files changed

+916
-163
lines changed

9 files changed

+916
-163
lines changed

src/Dropzone/CHANGELOG.md

+4
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"
+36-7
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
}
+236-41
Original file line numberDiff line numberDiff line change
@@ -1,79 +1,274 @@
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
156+
.querySelector('.dropzone-preview-button')
157+
?.removeEventListener('click', this.onPreviewButtonClick);
158+
preview.remove();
159+
}
160+
for (const file of this.files.values()) {
161+
const preview = this.buildPreview(file);
162+
this.previewContainerTarget.appendChild(preview);
163+
}
164+
if (this.previewTargets.length > 0) {
165+
this.element.classList.remove('dropzone-preview-container-hidden');
166+
}
167+
const canToggle = this.optionsValue.preview.can_toggle_placeholder;
168+
if (canToggle) {
169+
const hide = this.previewTargets.length > 0 &&
170+
(canToggle === true ||
171+
(canToggle === 'auto' && this.previewTargets.length < 2));
172+
this.element.classList.toggle('dropzone-placeholder-hidden', hide);
173+
}
174+
}
175+
isImage(file) {
176+
return (typeof file.type !== 'undefined' && file.type.indexOf('image') !== -1);
177+
}
178+
get isMultiple() {
179+
return this.inputTarget.multiple;
180+
}
181+
updateFileInput() {
182+
const dataTransfer = new DataTransfer();
183+
for (const file of this.files.values()) {
184+
dataTransfer.items.add(file);
185+
}
186+
this.inputTarget.files = dataTransfer.files;
187+
}
188+
formatBytes(bytes, decimals = 2) {
189+
if (bytes === 0)
190+
return '0 Bytes';
191+
const k = 1024;
192+
const dm = decimals || 2;
193+
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'];
194+
const i = Math.floor(Math.log(bytes) / Math.log(k));
195+
return `${Number.parseFloat((bytes / k ** i).toFixed(dm))} ${sizes[i]}`;
196+
}
197+
get firstFile() {
198+
return this.files.values().next().value;
199+
}
200+
get isLegacy() {
201+
return this.optionsValue.preview.style === 'legacy';
202+
}
203+
refreshLegacyPreview() {
204+
const preview = this.previewTargets[0];
205+
const image = preview.querySelector('.dropzone-preview-image');
206+
const filename = preview.querySelector('.dropzone-preview-filename');
207+
const file = this.firstFile;
208+
if (!file) {
209+
this.hideLegacyPreview();
210+
if (filename) {
211+
filename.textContent = '';
212+
}
213+
if (image) {
214+
image.style.display = 'none';
215+
image.style.backgroundImage = 'none';
216+
}
217+
return;
218+
}
219+
this.buildPreview(file, preview);
220+
const fileCount = this.files.size;
221+
if (filename && fileCount > 1) {
222+
filename.textContent += ` +${fileCount - 1}`;
223+
filename.title = Array.from(this.files.values())
224+
.map((file) => file.name)
225+
.join('\n');
226+
}
227+
if (image) {
228+
if (this.isImage(file)) {
229+
image.style.display = 'block';
230+
}
231+
else {
232+
image.style.display = 'none';
233+
image.style.backgroundImage = 'none';
234+
}
235+
}
236+
this.showLegacyPreview();
237+
this.hideLegacyFileInput();
238+
}
239+
showLegacyPreview() {
240+
this.previewTargets[0].style.display = 'flex';
241+
}
242+
hideLegacyPreview() {
243+
this.previewTargets[0].style.display = 'none';
244+
}
245+
showLegacyFileInput() {
246+
this.inputTarget.style.display = 'block';
247+
this.placeholderTarget.style.display = 'block';
248+
}
249+
hideLegacyFileInput() {
250+
this.inputTarget.style.display = 'none';
251+
this.placeholderTarget.style.display = 'none';
252+
}
76253
}
77-
default_1.targets = ['input', 'placeholder', 'preview', 'previewClearButton', 'previewFilename', 'previewImage'];
254+
default_1.values = {
255+
options: {
256+
type: Object,
257+
default: {
258+
preview: {
259+
style: 'legacy',
260+
can_open_file_picker: true,
261+
can_toggle_placeholder: true,
262+
},
263+
},
264+
},
265+
};
266+
default_1.targets = [
267+
'input',
268+
'placeholder',
269+
'preview',
270+
'previewContainer',
271+
'previewTemplate',
272+
];
78273

79274
export { default_1 as default };

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

+1-1
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)