From 0957e4da1566a027d9de5a6bb28d132ef103438c Mon Sep 17 00:00:00 2001 From: antoine Date: Thu, 12 Jun 2025 11:53:45 +0200 Subject: [PATCH] Allow svg thumbnail --- .../form/components/fields/upload/Upload.vue | 79 +++++++--------- .../fields/upload/util/thumbnail.ts | 16 ++++ .../Eloquent/Uploads/Thumbnails/Thumbnail.php | 93 ++++++++++++------- .../Eloquent/Uploads/SharpUploadModelTest.php | 10 ++ 4 files changed, 121 insertions(+), 77 deletions(-) create mode 100644 resources/js/form/components/fields/upload/util/thumbnail.ts diff --git a/resources/js/form/components/fields/upload/Upload.vue b/resources/js/form/components/fields/upload/Upload.vue index cf2b3fd00..b59c9e443 100644 --- a/resources/js/form/components/fields/upload/Upload.vue +++ b/resources/js/form/components/fields/upload/Upload.vue @@ -41,6 +41,7 @@ DialogTitle } from "@/components/ui/dialog"; import { rotate, rotateTo } from "@/form/components/fields/upload/util/rotate"; + import { createThumbnail } from "@/form/components/fields/upload/util/thumbnail"; const props = defineProps & { asEditorEmbed?: boolean, @@ -93,7 +94,6 @@ 'validation_rule[]': props.field.validationRule, }, }) - .use(ThumbnailGenerator, { thumbnailWidth: 300, thumbnailHeight: 300, thumbnailType: 'image/png' }) .use(XHRUpload, { endpoint: route('code16.sharp.api.form.upload'), fieldName: 'file', @@ -102,42 +102,44 @@ 'X-CSRF-TOKEN': getCsrfToken(), }, }) - .on('file-added', (file) => { + .on('file-added', async (file) => { emit('clear'); uppyFile.value = uppy.getFile(file.id); - console.log('file-added', JSON.parse(JSON.stringify(uppyFile.value))); + if(file.type === 'image/svg+xml') { + transformedImg.value = URL.createObjectURL(file.data); + } else if(file.type?.startsWith('image/')) { + const preview = await createThumbnail(file, { width: 300, height: 300 }); + emit('thumbnail', preview); + uppy.setFileState(file.id, { preview }); + uppyFile.value = uppy.getFile(file.id); + console.log('thumbnail:generated', JSON.parse(JSON.stringify(uppyFile.value))); + + if(canTransform(file.name, file.type) && props.field.imageCropRatio) { + const cropper = await new Promise((resolve) => { + const container = document.createElement('div'); + const image = document.createElement('img'); + image.src = preview; + container.appendChild(image); + return new Cropper(image, { + aspectRatio: props.field.imageCropRatio[0] / props.field.imageCropRatio[1], + autoCropArea: 1, + ready: (e) => { + resolve(e.currentTarget.cropper); + }, + }) + }); + await onImageTransform(cropper); + cropper.destroy(); + } else if(props.persistThumbnailUrl) { + const response = await fetch(preview); + const blob = await response.blob(); + transformedImg.value = URL.createObjectURL(blob); + } + } }) .on('restriction-failed', (file, error) => { emit('error', error.message, file.data); }) - .on('thumbnail:generated', async (file, preview) => { - const { field } = props; - emit('thumbnail', preview); - uppyFile.value = uppy.getFile(file.id); - console.log('thumbnail:generated', JSON.parse(JSON.stringify(uppyFile.value))); - - if(canTransform(file.name, file.type) && field.imageCropRatio) { - const cropper = await new Promise((resolve) => { - const container = document.createElement('div'); - const image = document.createElement('img'); - image.src = preview; - container.appendChild(image); - return new Cropper(image, { - aspectRatio: field.imageCropRatio[0] / field.imageCropRatio[1], - autoCropArea: 1, - ready: (e) => { - resolve(e.currentTarget.cropper); - }, - }) - }); - await onImageTransform(cropper); - cropper.destroy(); - } else if(props.persistThumbnailUrl) { - const response = await fetch(preview); - const blob = await response.blob(); - transformedImg.value = URL.createObjectURL(blob); - } - }) .on('upload', () => { emit('uploading', true); }) @@ -183,7 +185,8 @@ const extension = fileName?.match(/\.[0-9a-z]+$/i)[0]; return props.field.imageTransformable && (!props.field.imageTransformableFileTypes || props.field.imageTransformableFileTypes?.includes(extension)) - && mimeType?.startsWith('image/'); + && mimeType?.startsWith('image/') + && mimeType !== 'image/svg+xml'; } const isDraggingOver = ref(false); @@ -279,17 +282,7 @@ } }); } else if(uppyFile.value) { - editModalImageUrl.value = await new Promise(resolve => { - new Uppy() - .use(ThumbnailGenerator, { thumbnailWidth: 1200, thumbnailHeight: 1000, thumbnailType: 'image/png' }) - .on('thumbnail:generated', (file, preview) => { - resolve(preview); - }) - .addFile({ - ...(uppyFile.value as MinimalRequiredUppyFile<{}, {}>), - preview: null - }); - }); + editModalImageUrl.value = await createThumbnail(uppyFile.value, { width: 1200, height: 1000 }); } } diff --git a/resources/js/form/components/fields/upload/util/thumbnail.ts b/resources/js/form/components/fields/upload/util/thumbnail.ts new file mode 100644 index 000000000..927eca8f1 --- /dev/null +++ b/resources/js/form/components/fields/upload/util/thumbnail.ts @@ -0,0 +1,16 @@ +import Uppy, { MinimalRequiredUppyFile, UppyFile } from "@uppy/core"; +import ThumbnailGenerator from "@uppy/thumbnail-generator"; + + +export function createThumbnail(file: UppyFile, { width, height }: { width: number, height: number }) { + return new Promise((resolve, reject) => { + new Uppy() + .use(ThumbnailGenerator, { thumbnailWidth: width, thumbnailHeight: height, thumbnailType: 'image/png' }) + .on('thumbnail:generated', (thumbnailFile, preview) => resolve(preview)) + .on('thumbnail:error', (error) => reject(error)) + .addFile({ + ...(file as MinimalRequiredUppyFile<{}, {}>), + preview: null + }); + }); +} diff --git a/src/Form/Eloquent/Uploads/Thumbnails/Thumbnail.php b/src/Form/Eloquent/Uploads/Thumbnails/Thumbnail.php index 282d9b6f1..8f62c077e 100644 --- a/src/Form/Eloquent/Uploads/Thumbnails/Thumbnail.php +++ b/src/Form/Eloquent/Uploads/Thumbnails/Thumbnail.php @@ -143,48 +143,55 @@ private function generateThumbnail(string $thumbnailPath, ?int $width, ?int $hei $thumbnailDisk->makeDirectory(dirname($thumbnailPath)); } - try { - $sourceImg = $this->imageManager->read( - Storage::disk($sourceDisk)->get($sourceRelativeFilePath), + if ($this->shouldOnlyCopy()) { + $thumbnailDisk->put( + $thumbnailPath, + Storage::disk($sourceDisk)->get($sourceRelativeFilePath) ); - - // Transformation filters - if ($this->transformationFilters) { - if ($rotate = Arr::get($this->transformationFilters, 'rotate.angle')) { - $sourceImg->rotate($rotate); + } else { + try { + $sourceImg = $this->imageManager->read( + Storage::disk($sourceDisk)->get($sourceRelativeFilePath), + ); + + // Transformation filters + if ($this->transformationFilters) { + if ($rotate = Arr::get($this->transformationFilters, 'rotate.angle')) { + $sourceImg->rotate($rotate); + } + + if ($cropData = Arr::get($this->transformationFilters, 'crop')) { + $sourceImg->crop( + intval(round($sourceImg->width() * $cropData['width'])), + intval(round($sourceImg->height() * $cropData['height'])), + intval(round($sourceImg->width() * $cropData['x'])), + intval(round($sourceImg->height() * $cropData['y'])), + ); + } } - if ($cropData = Arr::get($this->transformationFilters, 'crop')) { - $sourceImg->crop( - intval(round($sourceImg->width() * $cropData['width'])), - intval(round($sourceImg->height() * $cropData['height'])), - intval(round($sourceImg->width() * $cropData['x'])), - intval(round($sourceImg->height() * $cropData['y'])), - ); + // Custom modifiers + $alreadyResized = false; + foreach ($this->modifiers as $modifier) { + $modifierInstance = $this->resolveModifierClass($modifier); + if ($modifierInstance) { + $sourceImg->modify($modifierInstance); + $alreadyResized = $alreadyResized || $modifierInstance->resized(); + } } - } - // Custom modifiers - $alreadyResized = false; - foreach ($this->modifiers as $modifier) { - $modifierInstance = $this->resolveModifierClass($modifier); - if ($modifierInstance) { - $sourceImg->modify($modifierInstance); - $alreadyResized = $alreadyResized || $modifierInstance->resized(); + // Resize if needed + if (! $alreadyResized) { + $sourceImg->scaleDown($width, $height); } - } - // Resize if needed - if (! $alreadyResized) { - $sourceImg->scaleDown($width, $height); + $thumbnailDisk->put( + $thumbnailPath, + $sourceImg->encode($this->resolveEncoder()) + ); + } catch (FileNotFoundException|EncoderException|DecoderException) { + return null; } - - $thumbnailDisk->put( - $thumbnailPath, - $sourceImg->encode($this->resolveEncoder()) - ); - } catch (FileNotFoundException|EncoderException|DecoderException) { - return null; } } @@ -239,6 +246,14 @@ private function resolveThumbnailExtension(): string private function resolveThumbnailPath(?int $width = null, ?int $height = null): string { + if ($this->shouldOnlyCopy()) { + return sprintf( + '%s/%s', + sharp()->config()->get('uploads.thumbnails_dir'), + $this->uploadModel->file_name + ); + } + $thumbDirNameAppender = sprintf( '%s%s_q-%s', $this->transformationFilters ? '_'.md5(serialize($this->transformationFilters)) : '', @@ -261,6 +276,16 @@ private function resolveThumbnailPath(?int $width = null, ?int $height = null): return Str::replace('//', '/', $thumbnailPath); } + private function shouldOnlyCopy(): bool + { + return in_array( + pathinfo($this->uploadModel->file_name, PATHINFO_EXTENSION), + [ + 'svg', + ] + ); + } + public function __toString() { // Return URL when Thumbnail is used as a string diff --git a/tests/Unit/Form/Eloquent/Uploads/SharpUploadModelTest.php b/tests/Unit/Form/Eloquent/Uploads/SharpUploadModelTest.php index 3e1addf8f..3f1b634dd 100644 --- a/tests/Unit/Form/Eloquent/Uploads/SharpUploadModelTest.php +++ b/tests/Unit/Form/Eloquent/Uploads/SharpUploadModelTest.php @@ -126,6 +126,16 @@ ->toBeTrue(); }); +it('allows to create SVG thumbnail by only copying the file', function () { + $file = createImage('local', 'test.svg'); + $upload = createSharpUploadModel($file); + + expect($upload->thumbnail(150, 150)) + ->toStartWith('/storage/thumbnails/data/test.svg') + ->and(Storage::disk('public')->exists('thumbnails/data/test.svg')) + ->toBeTrue(); +}); + function createSharpUploadModel(string $file, ?object $model = null, ?string $modelKey = 'test'): SharpUploadModel { return SharpUploadModel::create([