Skip to content

feat: add support for chunked uploads #60

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 1 commit into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions config/livewire-dropzone.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
<?php

return [

/*
|--------------------------------------------------------------------------------------------
| Livewire Dropzone Chunk Size
|--------------------------------------------------------------------------------------------
|
| The chunk size (in bytes) used for file uploads.
|
*/

'chunk_size' => env('LIVEWIRE_DROPZONE_CHUNK_SIZE', 1024 * 1024 * 5), // 5MB
];
104 changes: 71 additions & 33 deletions resources/views/livewire/dropzone.blade.php
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,9 @@
x-data="dropzone({
_this: @this,
uuid: @js($uuid),
multiple: @js($multiple),
})"
@dragenter.prevent.document="onDragenter($event)"
@dragleave.prevent="onDragleave($event)"
@dragover.prevent="onDragover($event)"
@dragleave.prevent="isDragging = false"
@dragover.prevent="isDragging = true"
@drop.prevent="onDrop"
class="block antialiased"
>
Expand Down Expand Up @@ -41,13 +39,13 @@ class="block antialiased"
</div>
<input
x-ref="input"
wire:model="upload"
type="file"
class="hidden"
x-on:livewire-upload-start="isLoading = true"
x-on:livewire-upload-cancel="isLoading = false"
x-on:livewire-upload-finish="isLoading = false"
x-on:livewire-upload-error="console.log('livewire-dropzone upload error')"
x-on:change.prevent="onChange"
@if(! is_null($this->accept)) accept="{{ $this->accept }}" @endif
@if($multiple === true) multiple @endif
>
Expand Down Expand Up @@ -118,50 +116,90 @@ class="hidden"

@script
<script>
Alpine.data('dropzone', ({ _this, uuid, multiple }) => {
Alpine.data('dropzone', ({ _this, uuid }) => {
return ({
chunks: [],
totalChunks: 0,
uploadedChunks: [],
isDragging: false,
isDropped: false,
isLoading: false,

onChange(e) {
const files = [...e.target.files];

onDrop(e) {
this.isDropped = true
this.isDragging = false
files.forEach((file, index) => this.createChunks(index, file));

const file = multiple ? e.dataTransfer.files : e.dataTransfer.files[0]

const args = ['upload', file, () => {
// Upload completed
this.isLoading = false
}, (error) => {
// An error occurred while uploading
console.log('livewire-dropzone upload error', error);
}, () => {
// Uploading is in progress
this.isLoading = true
}];

// Upload file(s)
multiple ? _this.uploadMultiple(...args) : _this.upload(...args)
this.uploadChunks()
},
onDragenter() {
this.isDragging = true
},
onDragleave() {
onDrop(e) {
this.isDragging = false
},
onDragover() {
this.isDragging = true

const files = [...e.dataTransfer.files]

files.forEach((file, index) => this.createChunks(index, file));

this.uploadChunks()
},
cancelUpload() {
_this.cancelUpload('upload')
_this.cancelUpload('chunk')

this.isLoading = false
},
removeUpload(tmpFilename) {
// Dispatch an event to remove the temporarily uploaded file
_this.dispatch(uuid + ':fileRemoved', { tmpFilename })
},
createChunks(index, file) {
let start = 0;
const chunkSize = @js($chunkSize);
this.chunks[index] = [];

// Split file into chunks and add a name property to each blob
while (start < file.size) {
const end = Math.min(start + chunkSize, file.size);
const chunk = file.slice(start, end);
const chunkNo = Math.ceil(start / chunkSize) + 1;
chunk.name = `${file.name}.${chunkNo}.part`;
this.chunks[index].push(chunk);
start = end;
}
},
async uploadChunks() {
this.isLoading = true

for(const [index, file] of Object.entries(this.chunks)) {
this.uploadedChunks[index] = 0;

for (const chunk of file) {
const onUploadComplete = () => {
this.uploadedChunks[index]++;

// If all chunks are uploaded, merge them
if (this.uploadedChunks[index] === this.chunks[index].length) {
this.isLoading = false;
this.chunks[index] = [];

_this.call('mergeChunks');
}
};

const onUploadError = (error) => {
this.isLoading = false;
this.chunks[index] = [];

console.error('livewire-dropzone upload error', error);
};

const onUploading = () => {
this.isLoading = true;
};

const args = ['chunk', chunk, onUploadComplete, onUploadError, onUploading];

_this.upload(...args);
}
}
},
});
})
</script>
Expand Down
63 changes: 45 additions & 18 deletions src/Http/Livewire/Dropzone.php
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,17 @@
namespace Dasundev\LivewireDropzone\Http\Livewire;

use Illuminate\Contracts\View\View;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use Illuminate\Validation\ValidationException;
use Livewire\Attributes\Computed;
use Livewire\Attributes\Locked;
use Livewire\Attributes\Modelable;
use Livewire\Attributes\On;
use Livewire\Component;
use Livewire\Features\SupportFileUploads\FileUploadConfiguration;
use Livewire\Features\SupportFileUploads\TemporaryUploadedFile;
use Livewire\WithFileUploads;

Expand All @@ -26,18 +30,20 @@ class Dropzone extends Component
#[Locked]
public string $uuid;

public $upload;

public string $error;

public bool $multiple;

public $chunk;

public $chunks = [];

public $file;

public function rules(): array
{
$field = $this->multiple ? 'upload.*' : 'upload';

return [
$field => [...$this->rules],
'file' => [...$this->rules],
];
}

Expand All @@ -49,9 +55,31 @@ public function mount(array $rules = [], bool $multiple = false): void
$this->files = [];
}

public function updatedUpload(): void
/**
* Called after updating the chunk property.
*/
public function updatedChunk($value): void
{
$this->chunks[] = $value;
}

/**
* Merge uploaded file chunks into a single file.
*
* @throws \Livewire\Features\SupportFileUploads\FileNotPreviewableException
*/
public function mergeChunks(): void
{
$this->reset('error');
$disk = FileUploadConfiguration::disk();
$path = null;

foreach ($this->chunks as $chunk) {
$path = Storage::disk($disk)->putFileAs('/'.FileUploadConfiguration::path(), $chunk, TemporaryUploadedFile::generateHashNameWithOriginalNameEmbedded(UploadedFile::fake()->create(preg_replace('/\.\d+\.part/', '', $chunk->getClientOriginalName()))));
}

$path = File::basename($path);

$this->file = TemporaryUploadedFile::createFromLivewire($path);

try {
$this->validate();
Expand All @@ -62,21 +90,17 @@ public function updatedUpload(): void
return;
}

$this->upload = $this->multiple
? $this->upload
: [$this->upload];

foreach ($this->upload as $upload) {
$this->handleUpload($upload);
}
$this->dispatchTempFileAddedEvent($this->file);

$this->reset('upload');
$this->reset('chunks', 'error');
}

/**
* Handle the uploaded file and dispatch an event with file details.
* Dispatch an event with the details of the uploaded temporary file.
*
* @throws \Livewire\Features\SupportFileUploads\FileNotPreviewableException
*/
public function handleUpload(TemporaryUploadedFile $file): void
public function dispatchTempFileAddedEvent(TemporaryUploadedFile $file): void
{
$this->dispatch("{$this->uuid}:fileAdded", [
'tmpFilename' => $file->getFilename(),
Expand Down Expand Up @@ -161,13 +185,16 @@ public function maxFileSize(): ?string
/**
* Checks if the provided MIME type corresponds to an image.
*/
#[Computed]
public function isImageMime($mime): bool
{
return in_array($mime, ['png', 'gif', 'bmp', 'svg', 'jpeg', 'jpg']);
}

public function render(): View
{
return view('livewire-dropzone::livewire.dropzone');
return view('livewire-dropzone::livewire.dropzone', [
'chunkSize' => config('livewire-dropzone.chunk_size'),
]);
}
}
1 change: 1 addition & 0 deletions src/LivewireDropzoneServiceProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ public function configurePackage(Package $package): void
{
$package
->name('livewire-dropzone')
->hasConfigFile()
->hasViews();
}

Expand Down
28 changes: 25 additions & 3 deletions tests/Feature/DropzoneTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -18,12 +18,34 @@
->assertSet('multiple', true);
});

it('can upload file', function () {
$dropzone = Livewire\Livewire::test(Dropzone::class);
it('can upload valid file', function () {
$dropzone = Livewire\Livewire::test(Dropzone::class, [
'rules' => ['mimes:pdf'],
]);

$uuid = $dropzone->get('uuid');

// valid chunk
$chunk = 'foo.pdf.1.part';

$dropzone
->set('upload', UploadedFile::fake()->image('foo.png'))
->set('chunk', UploadedFile::fake()->create($chunk))
->call('mergeChunks')
->assertDispatched("$uuid:fileAdded");
});

it('can not upload invalid file', function () {
$dropzone = Livewire\Livewire::test(Dropzone::class, [
'rules' => ['mimes:pdf'],
]);

$uuid = $dropzone->get('uuid');

// invalid chunk
$chunk = 'foo.png.1.part';

$dropzone
->set('chunk', UploadedFile::fake()->create($chunk))
->call('mergeChunks')
->assertNotDispatched("$uuid:fileAdded");
});
2 changes: 1 addition & 1 deletion workbench/resources/views/welcome.blade.php
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
<form wire:submit="submit" class="w-full">
<livewire:dropzone
wire:model="files"
:rules="['image','mimes:png,jpeg','max:10420']"
:rules="['mimes:png,jpeg,avi','max:10420']"
:multiple="true" />
</form>