Skip to content

Commit fd4cd7e

Browse files
committed
bug #3843 [Dropzone] Fix the drop zone appearing empty when the same file is dropped again (Kocal)
This PR was merged into the 3.x branch. Discussion ---------- [Dropzone] Fix the drop zone appearing empty when the same file is dropped again | Q | A | -------------- | --- | Bug fix? | yes | New feature? | no | Deprecations? | no | Documentation? | no | Issues | Fix #2552 | License | MIT Dragging a file over the drop zone makes `onDragEnter` reveal the input and hide the preview. Dropping a *different* file then fires `change`, `onInputChange` runs and restores the preview. Dropping the **same** file does not: Chrome fires no `change` event when the selection is unchanged, and `dragleave` does not fire either since the pointer never leaves the element. Nothing restores the preview, so the zone looks empty while a file is still selected. The controller now also listens for `drop` and restores the preview when the input still holds a file. The check is deferred so it runs after `input.files` has settled and after any `change` event, which makes it a no-op when the browser did fire one. Reproduced in Chrome before and after: with the published controller, the sequence pick, `dragenter`, `drop` leaves `preview: none` while `input.files.length` is 1; with this change it goes back to `preview: flex`. The added unit test covers the same sequence and fails without the fix. The e2e application only had a `multiple` Dropzone page, so this adds a single-file one at `/ux-dropzone/single` and registers it as an example, which is what made the browser check possible. Commits ------- 69669d3 [Dropzone] Fix the drop zone appearing empty when the same file is dropped again
2 parents 2e9d0a1 + 69669d3 commit fd4cd7e

8 files changed

Lines changed: 94 additions & 0 deletions

File tree

apps/e2e/src/Controller/DropzoneController.php

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,27 @@ public function index(): Response
2828
]);
2929
}
3030

31+
#[Route('/single', name: 'single')]
32+
public function single(Request $request): Response
33+
{
34+
$form = $this->createFormBuilder()
35+
->add('photo', DropzoneType::class, ['required' => false])
36+
->getForm()
37+
;
38+
39+
$form->handleRequest($request);
40+
41+
$uploadedFile = null;
42+
if ($form->isSubmitted() && $form->isValid()) {
43+
$uploadedFile = $form->get('photo')->getData()?->getClientOriginalName();
44+
}
45+
46+
return $this->render('ux_dropzone/single.html.twig', [
47+
'form' => $form,
48+
'uploadedFile' => $uploadedFile,
49+
]);
50+
}
51+
3152
#[Route('/multiple', name: 'multiple')]
3253
public function multiple(Request $request): Response
3354
{

apps/e2e/src/Repository/ExampleRepository.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ public function __construct()
3737
new Example(UxPackage::ChartJs, 'Pie chart with options', 'A pie chart with custom options to control the appearance and behavior.', 'app_ux_chartjs_pie_with_options'),
3838
new Example(UxPackage::Cropperjs, 'Image cropper', 'Crop an image with Cropper.js using default options.', 'app_ux_cropperjs_crop'),
3939
new Example(UxPackage::Cropperjs, 'Image cropper with aspect ratio', 'Crop an image with a fixed 16:9 aspect ratio constraint.', 'app_ux_cropperjs_crop_with_aspect_ratio'),
40+
new Example(UxPackage::Dropzone, 'Single file upload', 'Upload one file, with a preview and a clear button.', 'app_ux_dropzone_single'),
4041
new Example(UxPackage::Dropzone, 'Multiple file upload', 'Upload several files at once: accumulate across picks, preview each, and remove individually.', 'app_ux_dropzone_multiple'),
4142
new Example(UxPackage::LiveComponent, 'Examples filtering', 'On this page, you can filter all examples by query terms, and observe how the UI and URLs update during and after processing.', 'app_home'),
4243
new Example(UxPackage::LiveComponent, 'Counter', 'A basic counter that you can increment or decrement.', 'app_ux_live_component_counter'),
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
{% extends 'example.html.twig' %}
2+
3+
{% block example %}
4+
<div class="row">
5+
<div class="col-md-8 mx-auto">
6+
{% if uploadedFile %}
7+
<div class="alert alert-success mb-4" id="upload-success">
8+
Uploaded: {{ uploadedFile }}
9+
</div>
10+
{% endif %}
11+
12+
{{ form_start(form, {attr: {'data-turbo': 'false'}}) }}
13+
{{ form_row(form.photo) }}
14+
<div class="mt-3">
15+
<button type="submit" class="btn btn-primary" id="upload-submit">Upload</button>
16+
</div>
17+
{{ form_end(form) }}
18+
</div>
19+
</div>
20+
{% endblock %}

src/Dropzone/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
- Add support for multiple file uploads: accumulate files across selections, preview each file, and remove them individually
66
- Add a `remove_label` option to label the per-file remove button in `multiple` mode
7+
- Fix the drop zone appearing empty after dropping the very same file again, which Chrome reports without firing a `change` event
78

89
## 3.0.0
910

src/Dropzone/assets/dist/controller.d.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ declare class export_default extends Controller {
2727
_populateImagePreview(target: HTMLElement, file: Blob): void;
2828
onDragEnter(): void;
2929
onDragLeave(event: any): void;
30+
onDrop(): void;
3031
private connectMultiple;
3132
onMultipleChange(): void;
3233
private syncMultiple;

src/Dropzone/assets/dist/controller.js

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ var _Class = class extends Controller {
55
this.onInputChange = this.onInputChange.bind(this);
66
this.onDragEnter = this.onDragEnter.bind(this);
77
this.onDragLeave = this.onDragLeave.bind(this);
8+
this.onDrop = this.onDrop.bind(this);
89
this.onMultipleChange = this.onMultipleChange.bind(this);
910
}
1011
connect() {
@@ -18,6 +19,7 @@ var _Class = class extends Controller {
1819
this.inputTarget.addEventListener("change", this.onInputChange);
1920
this.element.addEventListener("dragenter", this.onDragEnter);
2021
this.element.addEventListener("dragleave", this.onDragLeave);
22+
this.element.addEventListener("drop", this.onDrop);
2123
this.dispatchEvent("connect");
2224
}
2325
disconnect() {
@@ -29,6 +31,7 @@ var _Class = class extends Controller {
2931
this.inputTarget.removeEventListener("change", this.onInputChange);
3032
this.element.removeEventListener("dragenter", this.onDragEnter);
3133
this.element.removeEventListener("dragleave", this.onDragLeave);
34+
this.element.removeEventListener("drop", this.onDrop);
3235
}
3336
clear() {
3437
this.inputTarget.value = "";
@@ -73,6 +76,14 @@ var _Class = class extends Controller {
7376
this.previewTarget.style.display = "block";
7477
}
7578
}
79+
onDrop() {
80+
setTimeout(() => {
81+
if (!this.inputTarget.files?.length) return;
82+
this.inputTarget.style.display = "none";
83+
this.placeholderTarget.style.display = "none";
84+
this.previewTarget.style.display = "flex";
85+
});
86+
}
7687
connectMultiple() {
7788
this.dataTransfer = new DataTransfer();
7889
for (const file of Array.from(this.inputTarget.files ?? [])) this.dataTransfer.items.add(file);

src/Dropzone/assets/src/controller.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ export default class extends Controller {
4444
this.onInputChange = this.onInputChange.bind(this);
4545
this.onDragEnter = this.onDragEnter.bind(this);
4646
this.onDragLeave = this.onDragLeave.bind(this);
47+
this.onDrop = this.onDrop.bind(this);
4748
this.onMultipleChange = this.onMultipleChange.bind(this);
4849
}
4950

@@ -69,6 +70,9 @@ export default class extends Controller {
6970
// Add dragleave event listener
7071
this.element.addEventListener('dragleave', this.onDragLeave);
7172

73+
// Add drop event listener
74+
this.element.addEventListener('drop', this.onDrop);
75+
7276
this.dispatchEvent('connect');
7377
}
7478

@@ -82,6 +86,7 @@ export default class extends Controller {
8286
this.inputTarget.removeEventListener('change', this.onInputChange);
8387
this.element.removeEventListener('dragenter', this.onDragEnter);
8488
this.element.removeEventListener('dragleave', this.onDragLeave);
89+
this.element.removeEventListener('drop', this.onDrop);
8590
}
8691

8792
clear() {
@@ -152,6 +157,21 @@ export default class extends Controller {
152157
}
153158
}
154159

160+
onDrop() {
161+
// Chrome does not fire "change" when the very same file is picked again, so
162+
// onInputChange never runs to undo what onDragEnter did and the zone looks empty
163+
// while a file is still selected. Deferred so input.files and any "change" landed first.
164+
setTimeout(() => {
165+
if (!this.inputTarget.files?.length) {
166+
return;
167+
}
168+
169+
this.inputTarget.style.display = 'none';
170+
this.placeholderTarget.style.display = 'none';
171+
this.previewTarget.style.display = 'flex';
172+
});
173+
}
174+
155175
// --- Multiple-file handling ------------------------------------------------
156176

157177
private connectMultiple() {

src/Dropzone/assets/test/unit/controller.test.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -169,6 +169,25 @@ describe('DropzoneController', () => {
169169
await waitFor(() => expect(getByTestId(container, 'placeholder')).toHaveStyle({ display: 'none' }));
170170
await waitFor(() => expect(getByTestId(container, 'preview')).toHaveStyle({ display: 'block' }));
171171
});
172+
173+
it('restores the preview when the same file is dropped again', async () => {
174+
startStimulus();
175+
176+
const input = getByTestId(container, 'input') as HTMLInputElement;
177+
const file = new File(['content'], 'a.txt', { type: 'text/plain' });
178+
Object.defineProperty(input, 'files', { configurable: true, writable: true, value: [file] });
179+
180+
// Dragging over reveals the input and hides the preview
181+
getByTestId(container, 'container').dispatchEvent(new Event('dragenter'));
182+
await waitFor(() => expect(getByTestId(container, 'preview')).toHaveStyle({ display: 'none' }));
183+
184+
// Chrome does not fire "change" when the very same file is picked again
185+
getByTestId(container, 'container').dispatchEvent(new Event('drop', { bubbles: true }));
186+
187+
await waitFor(() => expect(getByTestId(container, 'input')).toHaveStyle({ display: 'none' }));
188+
await waitFor(() => expect(getByTestId(container, 'placeholder')).toHaveStyle({ display: 'none' }));
189+
await waitFor(() => expect(getByTestId(container, 'preview')).toHaveStyle({ display: 'flex' }));
190+
});
172191
});
173192

174193
describe('DropzoneController (multiple)', () => {

0 commit comments

Comments
 (0)