From 2c6a583646afebb1646c3010df390ed7798d9ed1 Mon Sep 17 00:00:00 2001 From: SimonBroekaert Date: Wed, 26 Nov 2025 16:25:39 +0100 Subject: [PATCH 1/2] feature - full rewrite for v3 --- .editorconfig | 15 + .gitattributes | 20 + .github/FUNDING.yml | 1 + .github/ISSUE_TEMPLATE/bug.yml | 66 ++ .github/ISSUE_TEMPLATE/config.yml | 11 + .github/dependabot.yml | 19 + .github/workflows/dependabot-auto-merge.yml | 33 + .../workflows/fix-php-code-style-issues.yml | 28 + .github/workflows/phpstan.yml | 28 + .github/workflows/run-tests.yml | 60 ++ .gitignore | 33 + CHANGELOG.md | 96 ++ LICENSE.md | 21 + README.md | 810 ++++++++++++++++ composer.json | 82 ++ config/image-library.php | 52 + database/factories/ImageFactory.php | 70 ++ database/factories/SourceImageFactory.php | 31 + .../migrations/create_images_table.php.stub | 42 + .../create_source_images_table.php.stub | 39 + .../cleanup_image_library_upgrade.php.stub | 31 + .../post_image_library_upgrade.php.stub | 38 + .../pre_image_library_upgrade.php.stub | 24 + phpstan-baseline.neon | 0 phpstan.neon.dist | 12 + phpunit.xml.dist | 31 + pint.json | 56 ++ resources/fake/fake.csv | 1 + resources/fake/fake.doc | 1 + resources/fake/fake.exe | Bin 0 -> 1024 bytes resources/fake/fake.jpeg | Bin 0 -> 3429 bytes resources/fake/fake.jpg | Bin 0 -> 2959 bytes resources/fake/fake.json | 1 + resources/fake/fake.md | 3 + resources/fake/fake.pdf | Bin 0 -> 1024 bytes resources/fake/fake.png | Bin 0 -> 1571 bytes resources/fake/fake.txt | 1 + resources/fake/fake.webp | Bin 0 -> 1574 bytes resources/fake/fake.xlsx | 1 + resources/fake/fake.xml | 1 + resources/fake/fake.yaml | 2 + resources/fake/fake.zip | Bin 0 -> 1024 bytes resources/lang/en/breakpoints.php | 12 + .../ImageLibraryServiceProvider.php.stub | 21 + resources/views/.gitkeep | 0 resources/views/components/image.blade.php | 21 + resources/views/components/scripts.blade.php | 113 +++ src/Commands/UpgradeCommand.php | 212 ++++ src/Components/Image.php | 79 ++ src/Components/Scripts.php | 18 + src/Contracts/ConfiguresBreakpoints.php | 18 + src/Entities/AspectRatio.php | 41 + src/Entities/CropData.php | 39 + src/Entities/ImageContext.php | 914 ++++++++++++++++++ src/Enums/Breakpoint.php | 63 ++ src/Facades/ImageLibrary.php | 50 + src/ImageLibrary.php | 172 ++++ src/ImageLibraryServiceProvider.php | 44 + src/Jobs/GenerateImageVersionJob.php | 117 +++ .../GenerateResponsiveImageVersionsJob.php | 124 +++ src/Models/Image.php | 356 +++++++ src/Models/SourceImage.php | 219 +++++ src/Providers/ImageLibraryServiceProvider.php | 25 + src/Traits/HasImages.php | 95 ++ tests/ArchTest.php | 7 + tests/Fixtures/Factories/UserFactory.php | 36 + tests/Fixtures/Models/User.php | 64 ++ .../Providers/ImageLibraryServiceProvider.php | 45 + tests/Pest.php | 12 + tests/TestCase.php | 43 + tests/Unit/Entities/AspectRatioTest.php | 34 + tests/Unit/Entities/CropDataTest.php | 28 + tests/Unit/Entities/ImageContextTest.php | 883 +++++++++++++++++ tests/Unit/Enums/BreakpointTest.php | 55 ++ tests/Unit/ImageLibraryTest.php | 195 ++++ .../Unit/Jobs/GenerateImageVersionJobTest.php | 224 +++++ ...GenerateResponsiveImageVersionsJobTest.php | 82 ++ tests/Unit/Models/ImageTest.php | 622 ++++++++++++ tests/Unit/Models/SourceImageTest.php | 361 +++++++ tests/Unit/Traits/HasImagesTest.php | 259 +++++ 80 files changed, 7463 insertions(+) create mode 100644 .editorconfig create mode 100644 .gitattributes create mode 100644 .github/FUNDING.yml create mode 100644 .github/ISSUE_TEMPLATE/bug.yml create mode 100644 .github/ISSUE_TEMPLATE/config.yml create mode 100644 .github/dependabot.yml create mode 100644 .github/workflows/dependabot-auto-merge.yml create mode 100644 .github/workflows/fix-php-code-style-issues.yml create mode 100644 .github/workflows/phpstan.yml create mode 100644 .github/workflows/run-tests.yml create mode 100644 .gitignore create mode 100644 CHANGELOG.md create mode 100644 LICENSE.md create mode 100644 README.md create mode 100644 composer.json create mode 100644 config/image-library.php create mode 100644 database/factories/ImageFactory.php create mode 100644 database/factories/SourceImageFactory.php create mode 100644 database/migrations/create_images_table.php.stub create mode 100644 database/migrations/create_source_images_table.php.stub create mode 100644 database/migrations/upgrade/cleanup_image_library_upgrade.php.stub create mode 100644 database/migrations/upgrade/post_image_library_upgrade.php.stub create mode 100644 database/migrations/upgrade/pre_image_library_upgrade.php.stub create mode 100644 phpstan-baseline.neon create mode 100644 phpstan.neon.dist create mode 100644 phpunit.xml.dist create mode 100644 pint.json create mode 100644 resources/fake/fake.csv create mode 100644 resources/fake/fake.doc create mode 100644 resources/fake/fake.exe create mode 100644 resources/fake/fake.jpeg create mode 100644 resources/fake/fake.jpg create mode 100644 resources/fake/fake.json create mode 100644 resources/fake/fake.md create mode 100644 resources/fake/fake.pdf create mode 100644 resources/fake/fake.png create mode 100644 resources/fake/fake.txt create mode 100644 resources/fake/fake.webp create mode 100644 resources/fake/fake.xlsx create mode 100644 resources/fake/fake.xml create mode 100644 resources/fake/fake.yaml create mode 100644 resources/fake/fake.zip create mode 100644 resources/lang/en/breakpoints.php create mode 100644 resources/stubs/ImageLibraryServiceProvider.php.stub create mode 100644 resources/views/.gitkeep create mode 100644 resources/views/components/image.blade.php create mode 100644 resources/views/components/scripts.blade.php create mode 100644 src/Commands/UpgradeCommand.php create mode 100644 src/Components/Image.php create mode 100644 src/Components/Scripts.php create mode 100644 src/Contracts/ConfiguresBreakpoints.php create mode 100644 src/Entities/AspectRatio.php create mode 100644 src/Entities/CropData.php create mode 100644 src/Entities/ImageContext.php create mode 100644 src/Enums/Breakpoint.php create mode 100644 src/Facades/ImageLibrary.php create mode 100755 src/ImageLibrary.php create mode 100644 src/ImageLibraryServiceProvider.php create mode 100644 src/Jobs/GenerateImageVersionJob.php create mode 100644 src/Jobs/GenerateResponsiveImageVersionsJob.php create mode 100644 src/Models/Image.php create mode 100644 src/Models/SourceImage.php create mode 100644 src/Providers/ImageLibraryServiceProvider.php create mode 100644 src/Traits/HasImages.php create mode 100644 tests/ArchTest.php create mode 100644 tests/Fixtures/Factories/UserFactory.php create mode 100644 tests/Fixtures/Models/User.php create mode 100644 tests/Fixtures/Providers/ImageLibraryServiceProvider.php create mode 100644 tests/Pest.php create mode 100644 tests/TestCase.php create mode 100644 tests/Unit/Entities/AspectRatioTest.php create mode 100644 tests/Unit/Entities/CropDataTest.php create mode 100644 tests/Unit/Entities/ImageContextTest.php create mode 100644 tests/Unit/Enums/BreakpointTest.php create mode 100644 tests/Unit/ImageLibraryTest.php create mode 100644 tests/Unit/Jobs/GenerateImageVersionJobTest.php create mode 100644 tests/Unit/Jobs/GenerateResponsiveImageVersionsJobTest.php create mode 100644 tests/Unit/Models/ImageTest.php create mode 100644 tests/Unit/Models/SourceImageTest.php create mode 100644 tests/Unit/Traits/HasImagesTest.php diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..dd9a2b5 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,15 @@ +root = true + +[*] +charset = utf-8 +end_of_line = lf +indent_size = 4 +indent_style = space +insert_final_newline = true +trim_trailing_whitespace = true + +[*.md] +trim_trailing_whitespace = false + +[*.{yml,yaml}] +indent_size = 2 diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..c09f81e --- /dev/null +++ b/.gitattributes @@ -0,0 +1,20 @@ +# Path-based git attributes +# https://www.kernel.org/pub/software/scm/git/docs/gitattributes.html + +# Ignore all test and documentation with "export-ignore". +/.github export-ignore +/.gitattributes export-ignore +/.gitignore export-ignore +/phpunit.xml.dist export-ignore +/art export-ignore +/docs export-ignore +/tests export-ignore +/workbench export-ignore +/.editorconfig export-ignore +/.php_cs.dist.php export-ignore +/psalm.xml export-ignore +/psalm.xml.dist export-ignore +/testbench.yaml export-ignore +/UPGRADING.md export-ignore +/phpstan.neon.dist export-ignore +/phpstan-baseline.neon export-ignore diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 0000000..8a70f3a --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1 @@ +github: outer-web diff --git a/.github/ISSUE_TEMPLATE/bug.yml b/.github/ISSUE_TEMPLATE/bug.yml new file mode 100644 index 0000000..8136330 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug.yml @@ -0,0 +1,66 @@ +name: Bug Report +description: Report an Issue or Bug with the Package +title: "[Bug]: " +labels: ["bug"] +body: + - type: markdown + attributes: + value: | + We're sorry to hear you have a problem. Can you help us solve it by providing the following details. + - type: textarea + id: what-happened + attributes: + label: What happened? + description: What did you expect to happen? + placeholder: I cannot currently do X thing because when I do, it breaks X thing. + validations: + required: true + - type: textarea + id: how-to-reproduce + attributes: + label: How to reproduce the bug + description: How did this occur, please add any config values used and provide a set of reliable steps if possible. + placeholder: When I do X I see Y. + validations: + required: true + - type: input + id: package-version + attributes: + label: Package Version + description: What version of our Package are you running? Please be as specific as possible + placeholder: 2.0.0 + validations: + required: true + - type: input + id: php-version + attributes: + label: PHP Version + description: What version of PHP are you running? Please be as specific as possible + placeholder: 8.2.0 + validations: + required: true + - type: input + id: laravel-version + attributes: + label: Laravel Version + description: What version of Laravel are you running? Please be as specific as possible + placeholder: 9.0.0 + validations: + required: true + - type: dropdown + id: operating-systems + attributes: + label: Which operating systems does this happen with? + description: You may select more than one. + multiple: true + options: + - macOS + - Windows + - Linux + - type: textarea + id: notes + attributes: + label: Notes + description: Use this field to provide any other notes that you feel might be relevant to the issue. + validations: + required: false diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..00fe959 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,11 @@ +blank_issues_enabled: false +contact_links: + - name: Ask a question + url: https://github.com/outerweb/media-library/discussions/new?category=q-a + about: Ask the community for help + - name: Request a feature + url: https://github.com/outerweb/media-library/discussions/new?category=ideas + about: Share ideas for new features + - name: Report a security issue + url: https://github.com/outerweb/media-library/security/policy + about: Learn how to notify us for sensitive bugs diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..39b1580 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,19 @@ +# Please see the documentation for all configuration options: +# https://help.github.com/github/administering-a-repository/configuration-options-for-dependency-updates + +version: 2 +updates: + + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "weekly" + labels: + - "dependencies" + + - package-ecosystem: "composer" + directory: "/" + schedule: + interval: "weekly" + labels: + - "dependencies" diff --git a/.github/workflows/dependabot-auto-merge.yml b/.github/workflows/dependabot-auto-merge.yml new file mode 100644 index 0000000..cc8c94c --- /dev/null +++ b/.github/workflows/dependabot-auto-merge.yml @@ -0,0 +1,33 @@ +name: dependabot-auto-merge +on: pull_request_target + +permissions: + pull-requests: write + contents: write + +jobs: + dependabot: + runs-on: ubuntu-latest + timeout-minutes: 5 + if: ${{ github.actor == 'dependabot[bot]' }} + steps: + + - name: Dependabot metadata + id: metadata + uses: dependabot/fetch-metadata@v2.4.0 + with: + github-token: "${{ secrets.GITHUB_TOKEN }}" + + - name: Auto-merge Dependabot PRs for semver-minor updates + if: ${{steps.metadata.outputs.update-type == 'version-update:semver-minor'}} + run: gh pr merge --auto --merge "$PR_URL" + env: + PR_URL: ${{github.event.pull_request.html_url}} + GITHUB_TOKEN: ${{secrets.GITHUB_TOKEN}} + + - name: Auto-merge Dependabot PRs for semver-patch updates + if: ${{steps.metadata.outputs.update-type == 'version-update:semver-patch'}} + run: gh pr merge --auto --merge "$PR_URL" + env: + PR_URL: ${{github.event.pull_request.html_url}} + GITHUB_TOKEN: ${{secrets.GITHUB_TOKEN}} diff --git a/.github/workflows/fix-php-code-style-issues.yml b/.github/workflows/fix-php-code-style-issues.yml new file mode 100644 index 0000000..f30644a --- /dev/null +++ b/.github/workflows/fix-php-code-style-issues.yml @@ -0,0 +1,28 @@ +name: Fix PHP code style issues + +on: + push: + paths: + - '**.php' + +permissions: + contents: write + +jobs: + php-code-styling: + runs-on: ubuntu-latest + timeout-minutes: 5 + + steps: + - name: Checkout code + uses: actions/checkout@v5 + with: + ref: ${{ github.head_ref }} + + - name: Fix PHP code style issues + uses: aglipanci/laravel-pint-action@2.6 + + - name: Commit changes + uses: stefanzweifel/git-auto-commit-action@v6 + with: + commit_message: Fix styling diff --git a/.github/workflows/phpstan.yml b/.github/workflows/phpstan.yml new file mode 100644 index 0000000..1e5dc43 --- /dev/null +++ b/.github/workflows/phpstan.yml @@ -0,0 +1,28 @@ +name: PHPStan + +on: + push: + paths: + - '**.php' + - 'phpstan.neon.dist' + - '.github/workflows/phpstan.yml' + +jobs: + phpstan: + name: phpstan + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - uses: actions/checkout@v5 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: '8.4' + coverage: none + + - name: Install composer dependencies + uses: ramsey/composer-install@v3 + + - name: Run PHPStan + run: ./vendor/bin/phpstan --error-format=github diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml new file mode 100644 index 0000000..873a0b9 --- /dev/null +++ b/.github/workflows/run-tests.yml @@ -0,0 +1,60 @@ +name: run-tests + +on: + push: + paths: + - "**.php" + - ".github/workflows/run-tests.yml" + - "phpunit.xml.dist" + - "composer.json" + - "composer.lock" + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + test: + runs-on: ${{ matrix.os }} + timeout-minutes: 5 + strategy: + fail-fast: true + matrix: + os: [ubuntu-latest, windows-latest] + php: [8.4] + laravel: [12.*, 11.*] + stability: [prefer-stable] + include: + - laravel: 12.* + testbench: 10.* + - laravel: 11.* + testbench: 9.* + + name: P${{ matrix.php }} - L${{ matrix.laravel }} - ${{ matrix.stability }} - ${{ matrix.os }} + + steps: + - name: Checkout code + uses: actions/checkout@v5 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php }} + extensions: dom, curl, libxml, mbstring, zip, pcntl, pdo, sqlite, pdo_sqlite, bcmath, soap, intl, gd, exif, iconv, imagick, fileinfo + coverage: none + + - name: Setup problem matchers + run: | + echo "::add-matcher::${{ runner.tool_cache }}/php.json" + echo "::add-matcher::${{ runner.tool_cache }}/phpunit.json" + + - name: Install dependencies + run: | + composer require "laravel/framework:${{ matrix.laravel }}" "orchestra/testbench:${{ matrix.testbench }}" --no-interaction --no-update + composer update --${{ matrix.stability }} --prefer-dist --no-interaction + + - name: List Installed Dependencies + run: composer show -D + + - name: Execute tests + run: vendor/bin/pest --ci diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7a431b0 --- /dev/null +++ b/.gitignore @@ -0,0 +1,33 @@ +# Composer Related +composer.lock +/vendor + +# Frontend Assets +/node_modules + +# Logs +npm-debug.log +yarn-error.log + +# Caches +.phpunit.cache +.phpunit.result.cache +/build + +# IDE Helper +_ide_helper.php +_ide_helper_models.php +.phpstorm.meta.php + +# Editors +/.idea +/.fleet +/.vscode + +# Misc +phpunit.xml +phpstan.neon +testbench.yaml +/docs +/coverage +TODO.md diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..0055fd9 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,96 @@ +# Changelog + +All notable changes to `image library` will be documented in this file. + +## 3.0.0 - 2025-11-26 + +### Changed + +- Complete rewrite of the package. Please refer to the (upgrade guide)[UPGRADE.md] for more information. + +## 2.7.0 - 2025-02-27 + +### Added + +- Added support for Laravel 12. + +## 2.6.0 - 2025-01-31 + +### Fixed + +- Use with('conversions') to eager load the conversions in the Image model. + +## 2.5.0 - 2024-03-27 + +### Fixed + +- Added the $force = true on the ImageConversion observers to force the generation of the conversions. This is intended as the existing conversions should be overridden by the new ones. + +## 2.4.2 - 2024-03-12 + +### Added + +- Added support for Laravel 11. + +## 2.4.1 - 2024-03-09 + +### Fixed + +- Fixed an issue where `getbasePath` method could return null instead of a string and break the `url` and `path` methods of the Image model. + +## 2.4.0 - 2024-03-04 + +### Added + +- Added a `intersectionObserver` to the scripts blade component to be better at dynamically setting the image sizes attribute of the image tag. This is done to fix issues where the image is hidden and becomes visible after a user action. (e.g. a hidden tab that becomes visible after a user clicks on it.) + +## 2.3.0 - 2024-03-04 + +### Added + +- Added a `createSync` method to the `ConversionDefinition` entity to inform the image library to dispatch the generateConversion job synchronously. This is done to make the thumbnail generation conversion visible immediately after uploading an image when using a async queue driver. + +## 2.2.1 - 2024-03-04 + +### Changed + +- Improved the installation process by utilizing more of the Spatie package install command. + +## 2.1.0 - 2024-03-28 + +### Fixed + +- Fixed a bug where the javascript code that dynamically sets the image width as the sizes attribute of the image tag caused an infinite loop because the `load` event listener kept firing. +- Fixed several issues with the rendering of the image and picture tags when the image was set to `NULL`. + +## 2.0.0 - 2024-02-26 + +### Added + +- Added `Outerweb\ImageLibrary\Entities\ConversionDefinition::label(string $label)` method to set the label of the conversion. By default, the label will be the name of the conversion. +- Added `Outerweb\ImageLibrary\Entities\ConversionDefinition::translateLabel(bool $doTranslateLabel = true)` method to set whether the label should be translated. By default, the label will not be translated. This method will take the value of the label and put it through the `__()` function. + +### Changed + +- Changed the javascript code that dynamically sets the image width as the sizes attribute of the image tag. The new code takes into account any rerendering in the browser through a MutationObserver. So when livewire or any javascript library rerenders (a part of) the page, the image width will be recalculated and set as the sizes attribute of the image tag. + +### Fixed + +- Fixed a bug where the webp variants of the responsive variants did not get deleted when the conversions get regenerated. + +## 1.2.0 - 2024-02-19 + +### Added + +- Added `Outerweb\ImageLibrary\Facades\ImageLibrary::isSpatieTranslatable()` method to check the value of the config variable `spatie_translatable`. + +## 1.1.0 - 2024-02-19 + +### Changed + +- Image blade component will now fallback to the original image if the requested conversion is not (yet) available. +- Config variable spatie_translatable is now set to false by default. + +## 1.0.0 - 2024-02-15 + +- Initial release diff --git a/LICENSE.md b/LICENSE.md new file mode 100644 index 0000000..b939f99 --- /dev/null +++ b/LICENSE.md @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) Outerweb + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..4df247e --- /dev/null +++ b/README.md @@ -0,0 +1,810 @@ +# Image Library + +[![Latest Version on Packagist](https://img.shields.io/packagist/v/outerweb/image-library.svg?style=flat-square)](https://packagist.org/packages/outerweb/image-library) +[![GitHub Tests Action Status](https://img.shields.io/github/actions/workflow/status/outerweb/image-library/run-tests.yml?branch=main&label=tests&style=flat-square)](https://github.com/outer-web/image-library/actions?query=workflow%3Arun-tests+branch%3Amain) +[![GitHub Code Style Action Status](https://img.shields.io/github/actions/workflow/status/outerweb/image-library/fix-php-code-style-issues.yml?branch=main&label=code%20style&style=flat-square)](https://github.com/outer-web/image-library/actions?query=workflow%3A"Fix+PHP+code+style+issues"+branch%3Amain) +[![Total Downloads](https://img.shields.io/packagist/dt/outerweb/image-library.svg?style=flat-square)](https://packagist.org/packages/outerweb/image-library) + +A powerful Laravel package for managing images with responsive breakpoints, automatic optimization, and contextual configurations. Store and link images to your models with advanced features like automatic WebP generation, responsive image versions, and flexible image contexts. + +> ⚠️ **Caution:** V3 is a complete rewrite of the package and logic. Please take a look at the [upgrade guide](./docs/upgrade-to-v3.md) before upgrading from v2.x to v3.x. + +## Table of Contents + +- [Requirements](#requirements) +- [Installation](#installation) +- [Core concepts](#core-concepts) + - [SourceImages](#sourceimages) + - [Images](#images) + - [ImageContexts](#imagecontexts) + - [Breakpoints](#breakpoints) +- [Configuration](#configuration) + - [The config file](#the-config-file) + - [Javascript](#javascript) + - [Defining ImageContexts](#defining-imagecontexts) + - [Custom Breakpoints](#custom-breakpoints) +- [Usage](#usage) + - [Uploading an image](#uploading-an-image) + - [Attaching an image to your model](#attaching-an-image-to-your-model) + - [Using your model image(s)](#using-your-model-images) + - [Rendering images](#rendering-images) +- [Upgrading](#upgrading) +- [Changelog](#changelog) +- [License](#license) + +## Requirements + +This package uses [spatie/image](https://github.com/spatie/image) for image manipulations, so it requires the GD or Imagick PHP extension. + +## Installation + +You can install the package via composer: + +```bash +composer require outerweb/image-library +``` + +Run the install command to publish the migrations, config file, and service provider: + +```bash +php artisan image-library:install +``` + +This will: + +- Publish the configuration file to `config/image-library.php` +- Copy and register the `ImageLibraryServiceProvider` in your application +- Publish the database migrations +- Optionally run the migrations + +## Core concepts + +### SourceImages + +SourceImages are the original images uploaded to the system. They are not directly linked to any model. They are meant for internal use in this package. + +### Images + +Images are the link between a SourceImage and your Model(s). The Image has a `BelongsTo` relationship to the SourceImage and a `MorphTo` relationship to your Model. + +You can see these as an instance of the uploaded images in a specific use case. You can define the use case using the `context` attribute on the Image model. + +### ImageContexts + +ImageContexts allow you to define a configuration for images used in a specific way. Examples include "profile_picture", "thumbnail", "gallery_entry", "hero", etc. + +They are fully customizable and should be defined in the ImageServiceProvider or in a custom service provider. + +Images get generated based on the defined ImageContext when the Image model gets created or updated. This is based on the image_context_hash that is stored per Image. It is a hashed version of the whole configuration so that changes in the context will trigger regeneration of the images. + +#### WebP versions + +This package also generates WebP versions of images for better performance in modern browsers. You can configure whether to generate WebP versions globally in the config file or per ImageContext. + +#### Responsive versions + +The package supports generating multiple responsive versions of images based on defined breakpoints. You can configure the sizes and aspect ratios for each breakpoint in the ImageContext, ensuring optimal display across different devices. + +Each breakpoint can have a minimum and maximum width defined in the context. This allows the package to generate only necessary image sizes based on your design requirements. + +### Breakpoints + +Breakpoints define responsive screen sizes for image optimization. The package uses a Breakpoint enum that follows Tailwind CSS conventions, allowing you to specify different image configurations for various screen sizes. + +Available breakpoints: + +- **`Breakpoint::Small`** (`'sm'`): 640px and up - Mobile devices in landscape, small tablets +- **`Breakpoint::Medium`** (`'md'`): 768px and up - Tablets in portrait mode +- **`Breakpoint::Large`** (`'lg'`): 1024px and up - Tablets in landscape, small desktops +- **`Breakpoint::ExtraLarge`** (`'xl'`): 1280px and up - Desktop screens +- **`Breakpoint::ExtraExtraLarge`** (`'2xl'`): 1536px and up - Large desktop screens + +You can use these breakpoints to define different aspect ratios, sizes, crop positions, and effects for different screen sizes, ensuring optimal image display across all devices. + +> **Note:** If the default breakpoints don't match your design system, you can create custom breakpoints. See [Custom Breakpoints](#custom-breakpoints) in the Configuration section. + +## Configuration + +### The config file + +The config file allows you to customize various aspects of the image library. Some key configuration options include: + +- **`defaults.disk`**: The default filesystem disk for storing images if not specified during upload +- **`generate.webp`**: Automatically generate WebP versions of images if not specified in the image context +- **`generate.responsive_versions`**: Generate multiple sizes for responsive images if not specified in the image context +- **`defaults.crop_position`**: Default crop position for image transformations if not specified in the image context +- **`models`**: Customize the Eloquent models used by the package to easily extend functionality +- **`spatie_image.driver`**: Choose between 'gd' or 'imagick' for image manipulations + +### Javascript + +The package includes a JavaScript component that automatically sets the `sizes` attribute on `picture` elements rendered by the package. This ensures that the browser selects the most appropriate image size based on the actual display size of the image. + +To include the script, add the following Blade component to the `` section of your layout: + +```blade + +``` + +### Defining ImageContexts + +ImageContexts are defined in your application's `ImageLibraryServiceProvider` that gets published during installation. This provider extends the base service provider and allows you to define contexts in the `imageContexts()` method: + +```php +label(fn (): string => __('Profile Picture')) + ->aspectRatio(AspectRatio::make(1, 1)) + ->allowsMultiple(false), + + // Gallery images - 16:9 aspect ratio, multiple images allowed + ImageContext::make('gallery') + ->label(fn (): string => __('Gallery')) + ->aspectRatio(AspectRatio::make(16, 9)) + ->allowsMultiple(true), + + // Thumbnail - square with responsive sizing + ImageContext::make('thumbnail') + ->label(fn (): string => __('Thumbnail')) + ->aspectRatio(AspectRatio::make(1, 1)) + ->maxWidth([ + Breakpoint::Small->value => 150, + Breakpoint::Medium->value => 200, + Breakpoint::Large->value => 250, + ]) + ->allowsMultiple(false), + ]; + } +} +``` + +### Configuration Methods + +ImageContexts provide extensive configuration options for different responsive breakpoints and image processing needs: + +#### Label + +You can define a human-readable label for each context to use in your UI. + +``` +ImageContext::make('thumbnail') + ->label('Thumbnail') +``` + +If you need localization, you can define the label using a closure: + +```php +ImageContext::make('thumbnail') + ->label(fn() => __('Thumbnail')) +``` + +If you need information about the ImageContext in the label, you can use the provided `ImageContext` instance: + +```php +ImageContext::make('thumbnail') + ->label(fn(ImageContext $context) => __('Image Context: :context', ['context' => $context->key])) +``` + +#### Allowing multiple images + +You can specify whether multiple images are allowed in this context: + +```php +ImageContext::make('gallery') + ->allowsMultiple(true); +``` + +#### Generating WebP versions + +By default, WebP versions are generated based on the global config. You can override this per context: + +```php +ImageContext::make('thumbnail') + ->generateWebP(false); +``` + +#### Generating responsive versions + +By default, responsive versions are generated based on the global config. You can override this per context: + +```php +ImageContext::make('thumbnail') + ->generateResponsiveVersions(false); +``` + +#### Aspect Ratio + +The aspect ratio can be configured per `Breakpoint` in one of the following ways: + +```php +// Single aspect ratio for all breakpoints +ImageContext::make('thumbnail') + ->aspectRatio(AspectRatio::make(1, 1)); + +// Different aspect ratios per breakpoint +ImageContext::make('thumbnail') + ->aspectRatio([ + Breakpoint::Small->value => AspectRatio::make(1, 1), + Breakpoint::Medium->value => AspectRatio::make(4, 3), + Breakpoint::Large->value => AspectRatio::make(16, 9), + Breakpoint::ExtraLarge->value => AspectRatio::make(16, 9), + Breakpoint::ExtraExtraLarge->value => AspectRatio::make(2, 1), + ]); + +// Per breakpoint +ImageContext::make('thumbnail') + ->aspectRatioForBreakpoint(Breakpoint::Small, AspectRatio::make(1, 1)) + +// From a Breakpoint and up +ImageContext::make('thumbnail') + ->aspectRatioFromBreakpoint(Breakpoint::Medium, AspectRatio::make(16, 9)) + +// Up till a Breakpoint +ImageContext::make('thumbnail') + ->aspectRatioUpToBreakpoint(Breakpoint::Large, AspectRatio::make(4, 3)) + +// Between two Breakpoints +ImageContext::make('thumbnail') + ->aspectRatioBetweenBreakpoints(Breakpoint::Small, Breakpoint::Large, AspectRatio::make(1, 1)); +``` + +#### Minimum width + +You can define the minimum width of the image used in your design per `Breakpoint` in one of the following ways: + +```php +// Single minimum width for all breakpoints +ImageContext::make('thumbnail') + ->minWidth(150) + +// Different minimum widths per breakpoint +ImageContext::make('thumbnail') + ->minWidth([ + Breakpoint::Small->value => 100, + Breakpoint::Medium->value => 150, + Breakpoint::Large->value => 200, + Breakpoint::ExtraLarge->value => 250, + Breakpoint::ExtraExtraLarge->value => 300, + ]); + +// Per breakpoint +ImageContext::make('thumbnail') + ->minWidthForBreakpoint(Breakpoint::Small, 100); + +// From a Breakpoint and up +ImageContext::make('thumbnail') + ->minWidthFromBreakpoint(Breakpoint::Medium, 150); + +// Up till a Breakpoint +ImageContext::make('thumbnail') + ->minWidthUpToBreakpoint(Breakpoint::Large, 200); + +// Between two Breakpoints +ImageContext::make('thumbnail') + ->minWidthBetweenBreakpoints(Breakpoint::Small, Breakpoint::Large, 150); +``` + +#### Maximum width + +You can define the maximum width of the image used in your design per `Breakpoint` in one of the following ways: + +```php +// Single maximum width for all breakpoints +ImageContext::make('thumbnail') + ->maxWidth(250); + +// Different maximum widths per breakpoint +ImageContext::make('thumbnail') + ->maxWidth([ + Breakpoint::Small->value => 150, + Breakpoint::Medium->value => 200, + Breakpoint::Large->value => 250, + Breakpoint::ExtraLarge->value => 300, + Breakpoint::ExtraExtraLarge->value => 350, + ]); + +// Per breakpoint +ImageContext::make('thumbnail') + ->maxWidthForBreakpoint(Breakpoint::Small, 150); + +// From a Breakpoint and up +ImageContext::make('thumbnail') + ->maxWidthFromBreakpoint(Breakpoint::Medium, 200); + +// Up till a Breakpoint +ImageContext::make('thumbnail') + ->maxWidthUpToBreakpoint(Breakpoint::Large, 250); + +// Between two Breakpoints +ImageContext::make('thumbnail') + ->maxWidthBetweenBreakpoints(Breakpoint::Small, Breakpoint::Large, 200); +``` + +#### Crop Position + +By default, the crop position from the config file is used. You can override this per context and per `Breakpoint` in one of the following ways: + +```php +// Single crop position for all breakpoints +ImageContext::make('thumbnail') + ->cropPosition(CropPosition::Center); + +// Different crop positions per breakpoint +ImageContext::make('thumbnail') + ->cropPosition([ + Breakpoint::Small->value => CropPosition::Top, + Breakpoint::Medium->value => CropPosition::Center, + Breakpoint::Large->value => CropPosition::Bottom, + Breakpoint::ExtraLarge->value => CropPosition::Center, + Breakpoint::ExtraExtraLarge->value => CropPosition::Center, + ]); + +// Per breakpoint +ImageContext::make('thumbnail') + ->cropPositionForBreakpoint(Breakpoint::Small, CropPosition::Top); + +// From a Breakpoint and up +ImageContext::make('thumbnail') + ->cropPositionFromBreakpoint(Breakpoint::Medium, CropPosition::Center); + +// Up till a Breakpoint +ImageContext::make('thumbnail') + ->cropPositionUpToBreakpoint(Breakpoint::Large, CropPosition::Bottom); + +// Between two Breakpoints +ImageContext::make('thumbnail') + ->cropPositionBetweenBreakpoints(Breakpoint::Small, Breakpoint::Large, CropPosition::Center); +``` + +#### Blur + +You can apply a blur effect to images in this context per `Breakpoint` in one of the following ways: + +```php +// Single blur value for all breakpoints +ImageContext::make('thumbnail') + ->blur(10); + +// Different blur values per breakpoint +ImageContext::make('thumbnail') + ->blur([ + Breakpoint::Small->value => 5, + Breakpoint::Medium->value => 10, + Breakpoint::Large->value => 15, + Breakpoint::ExtraLarge->value => 20, + Breakpoint::ExtraExtraLarge->value => 25, + ]); + +// Per breakpoint +ImageContext::make('thumbnail') + ->blurForBreakpoint(Breakpoint::Small, 5); + +// From a Breakpoint and up +ImageContext::make('thumbnail') + ->blurFromBreakpoint(Breakpoint::Medium, 10); + +// Up till a Breakpoint +ImageContext::make('thumbnail') + ->blurUpToBreakpoint(Breakpoint::Large, 15); + +// Between two Breakpoints +ImageContext::make('thumbnail') + ->blurBetweenBreakpoints(Breakpoint::Small, Breakpoint::Large, 10); +``` + +#### Greyscale + +You can apply a greyscale effect to images in this context per `Breakpoint` in one of the following ways: + +```php +// Single greyscale value for all breakpoints +ImageContext::make('thumbnail') + ->greyscale(true); // or even ->grayscale(true) + +// Different greyscale values per breakpoint +ImageContext::make('thumbnail') + ->greyscale([ + Breakpoint::Small->value => false, + Breakpoint::Medium->value => true, + Breakpoint::Large->value => false, + Breakpoint::ExtraLarge->value => true, + Breakpoint::ExtraExtraLarge->value => false, + ]); + +// Per breakpoint +ImageContext::make('thumbnail') + ->greyscaleForBreakpoint(Breakpoint::Small, false); // or even ->grayscaleForBreakpoint(Breakpoint::Small, false); + +// From a Breakpoint and up +ImageContext::make('thumbnail') + ->greyscaleFromBreakpoint(Breakpoint::Medium, true); // or even ->grayscaleFromBreakpoint(Breakpoint::Medium, true); + +// Up till a Breakpoint +ImageContext::make('thumbnail') + ->greyscaleUpToBreakpoint(Breakpoint::Large, false); // or even ->grayscaleUpToBreakpoint(Breakpoint::Large, false); + +// Between two Breakpoints +ImageContext::make('thumbnail') + ->greyscaleBetweenBreakpoints(Breakpoint::Small, Breakpoint::Large, true); // or even ->grayscaleBetweenBreakpoints(Breakpoint::Small, Breakpoint::Large, true); +``` + +#### Sepia + +You can apply a sepia effect to images in this context per `Breakpoint` in one of the following ways: + +```php +// Single sepia value for all breakpoints +ImageContext::make('thumbnail') + ->sepia(true); + +// Different sepia values per breakpoint +ImageContext::make('thumbnail') + ->sepia([ + Breakpoint::Small->value => false, + Breakpoint::Medium->value => true, + Breakpoint::Large->value => false, + Breakpoint::ExtraLarge->value => true, + Breakpoint::ExtraExtraLarge->value => false, + ]); + +// Per breakpoint +ImageContext::make('thumbnail') + ->sepiaForBreakpoint(Breakpoint::Small, false); + +// From a Breakpoint and up +ImageContext::make('thumbnail') + ->sepiaFromBreakpoint(Breakpoint::Medium, true); + +// Up till a Breakpoint +ImageContext::make('thumbnail') + ->sepiaUpToBreakpoint(Breakpoint::Large, false); + +// Between two Breakpoints +ImageContext::make('thumbnail') + ->sepiaBetweenBreakpoints(Breakpoint::Small, Breakpoint::Large, true); +``` + +### Preparing your model(s) + +### Using the HasImages Trait + +Add the `HasImages` trait to any Eloquent model that should support image attachments: + +```php +morphOne(Image::class, 'model') + ->where('context', 'featured'); + } + + /** + * Multiple gallery images relationship + */ + public function galleryImages(): MorphMany + { + return $this->morphMany(Image::class, 'model') + ->where('context', 'gallery'); + } + + /** + * Images for a specific layout block (useful for page builders) + */ + public function getLayoutBlockImages(int $blockId): MorphMany + { + return $this->images() + ->whereJsonContains('custom_properties->layout_block_id', $blockId); + } +} +``` + +### Custom Breakpoints + +If the default breakpoints don't match your design system, you can create a custom breakpoint enum. This is useful when you need different screen size thresholds or additional breakpoints. + +#### Creating a Custom Breakpoint Enum + +First, create a custom enum that implements the `ConfiguresBreakpoints` contract: + +```php +sort(fn ($a, $b) => $a->getMinWidth() <=> $b->getMinWidth()) + ->all(); + } + + public function getLabel(): string + { + return match ($this) { + self::Mobile => 'Mobile', + self::Tablet => 'Tablet', + self::Desktop => 'Desktop', + self::UltraWide => 'Ultra Wide', + }; + } + + public function getMinWidth(): int + { + return match ($this) { + self::Mobile => 320, + self::Tablet => 768, + self::Desktop => 1200, + self::UltraWide => 1920, + }; + } + + public function getMaxWidth(): ?int + { + $index = array_search($this, self::sortedCases(), true); + $next = self::sortedCases()[$index + 1] ?? null; + + return $next ? $next->getMinWidth() - 1 : null; + } + + public function getSlug(): string + { + return Str::of($this->value) + ->lower() + ->slug() + ->toString(); + } +} +``` + +#### Configuring the Custom Breakpoint Enum + +Update your `config/image-library.php` file to use your custom enum: + +```php +'enums' => [ + 'breakpoint' => App\Enums\CustomBreakpoint::class, +], +``` + +## Usage + +### Uploading an image + +Upload images from `UploadedFile` instances (typically from form submissions) to create `SourceImage` records: + +#### Basic Upload + +```php +use Outerweb\ImageLibrary\Facades\ImageLibrary; + +// Basic upload using default settings +$sourceImage = ImageLibrary::upload($request->file('image')); + +// The SourceImage is now stored and optimized, ready to be attached to models +``` + +#### Upload with Custom Attributes + +```php +// Upload to specific disk +$sourceImage = ImageLibrary::upload($request->file('image'), [ + 'disk' => 's3', +]); + +// Upload with custom properties and metadata +$sourceImage = ImageLibrary::upload($request->file('image'), [ + 'disk' => 's3', + 'custom_properties' => [ + 'photographer' => 'John Doe', + 'license' => 'Creative Commons', + 'shoot_date' => '2024-01-15', + 'camera_model' => 'Canon EOS R5' + ], +]); +``` + +#### What Happens During Upload + +1. **Automatic optimization**: Images are processed using Spatie Image with your configured driver (GD/Imagick) +2. **Metadata extraction**: Width, height, file size, and MIME type are automatically detected and stored +3. **UUID generation**: A unique identifier is created for organized file storage +4. **File organization**: Images are stored in a structured directory: `{base_path}/{uuid}/original.{extension}` +5. **Database record**: A `SourceImage` model is created with all metadata + +### Attaching an image to your model + +After uploading a `SourceImage`, attach it to your models using the context system: + +#### Basic Attachment + +```php +// Upload the source image +$sourceImage = ImageLibrary::upload($request->file('image')); + +// Get your model +$product = Product::find(1); + +// Attach with a context +$image = $product->attachImage($sourceImage, [ + 'context' => 'thumbnail' +]); + +// The image is now attached and will be processed according to the context configuration +``` + +#### Advanced Attachment Examples + +```php +// Attach with multilingual alt text +$image = $product->attachImage($sourceImage, [ + 'context' => 'featured_image', + 'alt_text' => [ + 'en' => 'Taylor Otwell driving his lamborghini', + 'nl' => 'Taylor Otwell rijdt in zijn lamborghini', + ] +]); + +// Attach with custom properties and metadata +$image = $product->attachImage($sourceImage, [ + 'context' => 'gallery', + 'custom_properties' => [ + 'photographer' => 'Jane Smith', + 'copyright' => '© 2024 Company Name', + 'location' => 'Swiss Alps', + 'camera_settings' => [ + 'aperture' => 'f/2.8', + 'shutter_speed' => '1/500s', + 'iso' => 200 + ] + ], + 'alt_text' => [ + 'en' => 'Mountain landscape photography' + ] +]); +``` + +#### Attaching to a custom relationship + +When using custom relationships, you can still use the `attachImage` method. You can specify the relationship to use: + +```php +$image = $product->attachImage($sourceImage, [ + 'context' => 'featured' +], 'featuredImage'); +``` + +#### Context-Specific Behavior + +The `attachImage` method will replace the existing image if the context is not configured to allow multiple images. This ensures that single-image contexts always have only one associated image. + +### Using your model image(s) + +You can access your model's images through the `images` relationship or any custom relationships you've defined. + +```php +// Get all images +$images = $product->images; + +// Query images by context +$thumbnails = $product->images() + ->where('context', 'thumbnail') + ->get(); + +// Query images with specific custom properties +$landscapeImages = $product->images() + ->where('context', 'gallery') + ->whereJsonContains('custom_properties->layout_builder_block_id', $blockId) + ->get(); +``` + +### Rendering images + +You can render images in your views using the provided view component: + +```blade + +``` + +This will render a `picture` element with the following: + +- a `source` element per `Breakpoint` with responsive image urls +- a `source` element per `Breakpoint` for the WebP versions of the responsive image urls +- an `img` element with the default image url, alt text, and any additional attributes you provide + +Make sure you added the script component to the `` of your layout: + +```blade + +``` + +This script will set all `sizes` attributes of the picture elements automatically when: + +- The page is loaded +- The viewport is resized +- The picture element is added to the viewport +- The picture element width changes + +## Upgrading + +### From v2.x to v3.0 + +This is a major version with breaking changes. See the [Upgrade guide](./docs/upgrade-to-v3.md) for detailed instructions. + +## Changelog + +Please see [CHANGELOG](CHANGELOG.md) for more information on what has changed recently. + +## License + +The MIT License (MIT). Please see [License File](LICENSE.md) for more information. diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..a3f6cf1 --- /dev/null +++ b/composer.json @@ -0,0 +1,82 @@ +{ + "name": "outerweb/image-library", + "description": "Store and link files to your models", + "keywords": [ + "Outerweb", + "laravel", + "image-library" + ], + "homepage": "https://github.com/outer-web/image-library", + "license": "MIT", + "authors": [ + { + "name": "Outerweb", + "email": "info@outerweb.be", + "role": "Developer" + } + ], + "require": { + "php": "^8.4", + "illuminate/contracts": "^11.0||^12.0", + "illuminate/database": "^11.0||^12.0", + "illuminate/support": "^11.0||^12.0", + "nesbot/carbon": "^3.10", + "outerweb/enum-helpers": "*", + "spatie/eloquent-sortable": "^4.5", + "spatie/image": "^3.8", + "spatie/laravel-package-tools": "^1.16", + "spatie/laravel-translatable": "^6.11", + "spatie/temporary-directory": "^2.3" + }, + "require-dev": { + "laravel/pint": "^1.14", + "nunomaduro/collision": "^8.8", + "larastan/larastan": "^3.0", + "orchestra/testbench": "^10.0.0||^9.0.0", + "pestphp/pest": "^4.0", + "pestphp/pest-plugin-arch": "^4.0", + "pestphp/pest-plugin-laravel": "^4.0", + "phpstan/extension-installer": "^1.4", + "phpstan/phpstan-deprecation-rules": "^2.0", + "phpstan/phpstan-phpunit": "^2.0" + }, + "autoload": { + "psr-4": { + "Outerweb\\ImageLibrary\\": "src/", + "Outerweb\\ImageLibrary\\Database\\Factories\\": "database/factories/" + } + }, + "autoload-dev": { + "psr-4": { + "Outerweb\\ImageLibrary\\Tests\\": "tests/", + "Workbench\\App\\": "workbench/app/" + } + }, + "scripts": { + "post-autoload-dump": "@composer run prepare", + "prepare": "@php vendor/bin/testbench package:discover --ansi", + "analyse": "vendor/bin/phpstan analyse", + "test": "vendor/bin/pest", + "test-coverage": "vendor/bin/pest --coverage", + "format": "vendor/bin/pint" + }, + "config": { + "sort-packages": true, + "allow-plugins": { + "pestphp/pest-plugin": true, + "phpstan/extension-installer": true + } + }, + "extra": { + "laravel": { + "providers": [ + "Outerweb\\ImageLibrary\\ImageLibraryServiceProvider" + ], + "aliases": { + "ImageLibrary": "Outerweb\\ImageLibrary\\Facades\\ImageLibrary" + } + } + }, + "minimum-stability": "dev", + "prefer-stable": true +} diff --git a/config/image-library.php b/config/image-library.php new file mode 100644 index 0000000..507faf9 --- /dev/null +++ b/config/image-library.php @@ -0,0 +1,52 @@ + [ + 'crop_position' => CropPosition::Center, + 'disk' => 'public', + 'temporary_url' => [ + 'default' => [ + 'enabled' => false, + 'expiration_minutes' => 5, + ], + 's3' => [ + 'enabled' => true, + ], + ], + ], + 'enums' => [ + 'breakpoint' => Breakpoint::class, + ], + 'generate' => [ + 'webp' => true, + 'responsive_versions' => true, + ], + 'models' => [ + 'image' => Image::class, + 'source_image' => SourceImage::class, + ], + 'paths' => [ + 'base' => 'image-library', + ], + 'queue' => [ + 'connection' => env('QUEUE_CONNECTION', 'sync'), + 'queue' => 'default', + ], + 'spatie_image' => [ + 'driver' => ImageDriver::Imagick, + ], + 'responsive_images' => [ + 'width_difference_threshold' => 100, + 'size_step_multiplier' => 0.7, + 'min_width' => 100, + ], +]; diff --git a/database/factories/ImageFactory.php b/database/factories/ImageFactory.php new file mode 100644 index 0000000..dcc26fa --- /dev/null +++ b/database/factories/ImageFactory.php @@ -0,0 +1,70 @@ + + */ +class ImageFactory extends Factory +{ + public function definition(): array + { + return [ + 'source_image_id' => ImageLibrary::getSourceImageModel()::factory(), + 'disk' => function (array $attributes) { + return ImageLibrary::getSourceImageModel()::find($attributes['source_image_id'])?->disk ?? ImageLibrary::getDefaultDisk(); + }, + 'context' => fake()->randomElement(ImageLibrary::getImageContexts()), + 'crop_data' => function (array $attributes): array { + $sourceImage = ImageLibrary::getSourceImageModel()::find($attributes['source_image_id']); + + return collect(ImageLibrary::getBreakpointEnum()::sortedCases())->mapWithKeys(function ($breakpoint) use ($sourceImage): array { + $maxWidth = $sourceImage->width ?? 4000; + $maxHeight = $sourceImage->height ?? 4000; + + $width = fake()->numberBetween(10, $maxWidth); + $height = fake()->numberBetween(10, $maxHeight); + + return [$breakpoint->value => new CropData( + x: fake()->numberBetween(0, max(0, $maxWidth - $width)), + y: fake()->numberBetween(0, max(0, $maxHeight - $height)), + width: $width, + height: $height, + )]; + }) + ->all(); + }, + 'alt_text' => fake()->boolean() ? collect(ImageLibrary::getSupportedLocales()) + ->mapWithKeys(fn (string $locale) => [$locale => fake()->sentence()]) + ->all() : null, + ]; + } + + public function forModel(Model $model): self + { + return $this->state(function () use ($model) { + return [ + 'model_type' => $model::class, + 'model_id' => $model->getKey(), + ]; + }); + } + + public function forContext(ImageContext $context): self + { + return $this->state(function () use ($context) { + return [ + 'context' => $context, + ]; + }); + } +} diff --git a/database/factories/SourceImageFactory.php b/database/factories/SourceImageFactory.php new file mode 100644 index 0000000..31eb31e --- /dev/null +++ b/database/factories/SourceImageFactory.php @@ -0,0 +1,31 @@ + + */ +class SourceImageFactory extends Factory +{ + public function definition(): array + { + return [ + 'disk' => ImageLibrary::getDefaultDisk(), + 'name' => fake()->word(), + 'extension' => fake()->randomElement(['jpg', 'png', 'webp']), + 'mime_type' => fake()->mimeType(), + 'width' => fake()->numberBetween(100, 4000), + 'height' => fake()->numberBetween(100, 4000), + 'size' => fake()->numberBetween(1000, 1000000), + 'alt_text' => fake()->boolean() ? collect(ImageLibrary::getSupportedLocales()) + ->mapWithKeys(fn (string $locale) => [$locale => fake()->sentence()]) + ->all() : null, + ]; + } +} diff --git a/database/migrations/create_images_table.php.stub b/database/migrations/create_images_table.php.stub new file mode 100644 index 0000000..0461ec7 --- /dev/null +++ b/database/migrations/create_images_table.php.stub @@ -0,0 +1,42 @@ +id(); + $table->uuid() + ->unique() + ->index(); + $table->morphs('model'); + $table->foreignId('source_image_id') + ->constrained() + ->cascadeOnDelete(); + $table->string('context'); + $table->string('context_configuration_hash'); + $table->unsignedInteger('sort_order'); + $table->string('disk'); + $table->json('crop_data') + ->nullable(); + $table->json('alt_text') + ->nullable(); + $table->json('custom_properties') + ->nullable(); + $table->timestamps(); + + $table->index(['context', 'context_configuration_hash']); + }); + } + + public function down() + { + Schema::dropIfExists('images'); + } +}; diff --git a/database/migrations/create_source_images_table.php.stub b/database/migrations/create_source_images_table.php.stub new file mode 100644 index 0000000..128ceda --- /dev/null +++ b/database/migrations/create_source_images_table.php.stub @@ -0,0 +1,39 @@ +id(); + $table->uuid() + ->unique() + ->index(); + $table->string('disk'); + $table->string('name') + ->index(); + $table->string('extension'); + $table->string('mime_type') + ->index(); + $table->unsignedInteger('width'); + $table->unsignedInteger('height'); + $table->unsignedBigInteger('size'); + $table->json('alt_text') + ->nullable(); + $table->json('custom_properties') + ->nullable(); + $table->timestamps(); + }); + } + + public function down() + { + Schema::dropIfExists('source_images'); + } +}; diff --git a/database/migrations/upgrade/cleanup_image_library_upgrade.php.stub b/database/migrations/upgrade/cleanup_image_library_upgrade.php.stub new file mode 100644 index 0000000..3b2dac7 --- /dev/null +++ b/database/migrations/upgrade/cleanup_image_library_upgrade.php.stub @@ -0,0 +1,31 @@ +cursor() as $oldRecord) { + $path = "{$oldRecord->uuid}/original.{$oldRecord->file_extension}"; + + Storage::disk($oldRecord->disk)->delete($path); + } + + Schema::drop('tmp_images'); + } + + Schema::dropIfExists('image_conversions'); + } + + public function down() + { + // Sorry, this migration is not reversible. + } +}; diff --git a/database/migrations/upgrade/post_image_library_upgrade.php.stub b/database/migrations/upgrade/post_image_library_upgrade.php.stub new file mode 100644 index 0000000..8845ce9 --- /dev/null +++ b/database/migrations/upgrade/post_image_library_upgrade.php.stub @@ -0,0 +1,38 @@ +cursor() as $oldRecord) { + $path = "{$oldRecord->uuid}/original.{$oldRecord->file_extension}"; + + $tmpFile = sys_get_temp_dir().'/'.basename($path); + + $file = Storage::disk($oldRecord->disk)->get($path); + + file_put_contents($tmpFile, $file); + + $file = new UploadedFile($tmpFile, basename($path)); + + ImageLibrary::upload($file, [ + 'uuid' => $oldRecord->uuid, + ]); + } + } + + public function down() + { + // + } +}; diff --git a/database/migrations/upgrade/pre_image_library_upgrade.php.stub b/database/migrations/upgrade/pre_image_library_upgrade.php.stub new file mode 100644 index 0000000..82654ed --- /dev/null +++ b/database/migrations/upgrade/pre_image_library_upgrade.php.stub @@ -0,0 +1,24 @@ + + + + + tests + + + + + + + + ./src + + + diff --git a/pint.json b/pint.json new file mode 100644 index 0000000..c5175a6 --- /dev/null +++ b/pint.json @@ -0,0 +1,56 @@ +{ + "preset": "laravel", + "notPath": ["tests/TestCase.php"], + "rules": { + "array_push": true, + "backtick_to_shell_exec": true, + "date_time_immutable": true, + "declare_strict_types": true, + "lowercase_keywords": true, + "lowercase_static_reference": true, + "fully_qualified_strict_types": true, + "global_namespace_import": { + "import_classes": true, + "import_constants": true, + "import_functions": true + }, + "mb_str_functions": true, + "modernize_types_casting": true, + "new_with_parentheses": false, + "no_superfluous_elseif": true, + "no_useless_else": true, + "no_multiple_statements_per_line": true, + "ordered_class_elements": { + "order": [ + "use_trait", + "case", + "constant", + "constant_public", + "constant_protected", + "constant_private", + "property_public", + "property_protected", + "property_private", + "construct", + "destruct", + "magic", + "phpunit", + "method_abstract", + "method_public_static", + "method_public", + "method_protected_static", + "method_protected", + "method_private_static", + "method_private" + ], + "sort_algorithm": "none" + }, + "ordered_interfaces": true, + "ordered_traits": true, + "protected_to_private": true, + "self_accessor": true, + "self_static_accessor": true, + "strict_comparison": true, + "visibility_required": true + } +} diff --git a/resources/fake/fake.csv b/resources/fake/fake.csv new file mode 100644 index 0000000..599acbf --- /dev/null +++ b/resources/fake/fake.csv @@ -0,0 +1 @@ +Lorem ipsum dolor sit amet, consectetur adipiscing elit. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Lorem ipsum dolor sit amet, consectetur adipiscing elit \ No newline at end of file diff --git a/resources/fake/fake.doc b/resources/fake/fake.doc new file mode 100644 index 0000000..599acbf --- /dev/null +++ b/resources/fake/fake.doc @@ -0,0 +1 @@ +Lorem ipsum dolor sit amet, consectetur adipiscing elit. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Lorem ipsum dolor sit amet, consectetur adipiscing elit \ No newline at end of file diff --git a/resources/fake/fake.exe b/resources/fake/fake.exe new file mode 100644 index 0000000000000000000000000000000000000000..c45ab535543fe89deb13411e2dc4d2ff126e93a6 GIT binary patch literal 1024 zcmV+b1poV{@@zxVJx3T;_F_yd%Rl}L;tD^l(3$oLes}|nNz!io9`C$ZxU=1u{cF;Z z;*>%r`X!epz<@n~FfX@K1o`iq>JEA)txxodj74!uvN!35H2gtS3#vU5wyIEzbXIqia zkZ{<_%u=6w2zJa2Laz>2XK<)x#j8t40K%U3NgIJvFBcv2y$#&?o^f+RGWq`U!#C%8o!6HMk3AvK2O@gcJKo83FIXEf!+UW%&?@O~i!v&@^; zFk@xgIRYK&kgrI22!TYCVVZ+i!zgTA8of+`hsjGEF(E@C__~6=K(P!aM6y9FdLPH< z>pl(?ycH3=u<_a67o|JpHruWd7kRolX4wS37H?4k}_E`67={=fC=LjM=Yl-P3J-%^^d-AjEQ@3o3+;u#?i$ybnUEdm>=}{ ziU9$!Al<|3)k z_WfW9XNQ>Ldn<0yY)l**WL@O@CBD~HY%XYcyzNEw(oY}NL{`~}xtFEI^ox2;N^6Yfp}#vz^fLQ+LuEk?7Jta>d-|*V zBD%TTC4fdg5Kx#*$*O-)FU~2z?**g0snr;xcCr;_poU%O#$Sv9QGyg-DJCl`9uOD& zQ_PUf!c~W<*71G$Y+h>60jIPD;F}Sbl4Ggfe4TG_9i#1~z7pm6wH(p%E(sN7m~AH~ ut-sm2&$viv6!22h`ND=p#35}GnM0We_!4Gtgb!@^DT%1XNcm@5?OIqL*82tk literal 0 HcmV?d00001 diff --git a/resources/fake/fake.jpeg b/resources/fake/fake.jpeg new file mode 100644 index 0000000000000000000000000000000000000000..0a7d53110ba37966622cbda17109226092766746 GIT binary patch literal 3429 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 literal 0 HcmV?d00001 diff --git a/resources/fake/fake.jpg b/resources/fake/fake.jpg new file mode 100644 index 0000000000000000000000000000000000000000..e01c94d2cbe97489f2a4101673127e91a4ae4677 GIT binary patch literal 2959 zcmeH_>o?m88pnS@UFuSoX(_@^+ez1!s=Aeu?Ccb$C5(28#wAC!NJ*3;>XtKWwyu>i zY3mY|-5N=!L5jGBs6|t?DHReDm(_@*HrbV-Nr3^7XXuD>QsS z|C105P=5ED*zd*v{sRWAOBtEsx#;$xo%+Nn|H`YcJ)>-Js}xItb#i9s$7*t&P-G<^ zmbokJ_Mm;Ntz@)BP8nAvV7aM#7nP=~Qd+v84GE{dO>%jEM$B1m-0ASCl6Cq0l32A* zadGS$REtyuME8_Am~fuOT@JEAgvj^Vf< zkzc9)$oqgruKAAf=IUEB3lTD<1_;1cpwH!#III1n#=E!G)le6hAx~sU3UBx&tAXi~ z{My~QWKVZ91aeN#RqCUv=?yPSMMH8>8P%EZry|q2*I8c91>pRD;jF`2>>b-&S5qQN z`1wd{!OIkzy9*Ec9#bSxgFHy(36zXB+X=o&cH7M_Cmj zbU#R|JbaM(^%9v-u?gl8(NTu9kNM7z6tA&SIG=OQ?nHmbbmGKW#PO-7^{o=_lmaS6 z2>O@w3hqqJg-~S7`t|6-j_eKhtK;kEJ9uREX*k2T2{CbYaW^fyxcxnH)9*=2NJbGo z(D5?|Yk#}v*$umPJLFWB?7TE4sZ;)#ieJGR2O9_XaH702uDNYsa zv)|gQ@D9KTVP!IB2I0po+7b4Q4Ggw?o-FQK=`L@TM?=K45dtJ9FN<=5>wqHi=_7?k z{1&+~Y%`*U!E!d*-qYi&3#aRX{Yi(erQ0pmFqnaH%GiZYqbnBViNz+h|D_wHu_JDz z`2~#a9i%*uyR>1!^z!@(c|Si|;6t*1-->zsFeux}IAw!e;8*@pQV?=&%WluDNEtbq zOY`h}s>}|XYp1g5`S#vOzmA(s+nfbY?wiGPj8-duzgpjtw$yWNC~1udYA_2ADs`X(*Q54$`~Kt zaT8{8lQ%Q~0bQL^kTHHc*Q{Z3vAQ|hM-b36_ND_re^P{qoW*5d=+Xd%_s7EPH+WFN zJts6&GNb@|JnQGSU0NFW2^y*a9(l9&G{Bt48k~WV;D@1`&*j#tbcP1FmE;!rgRrx| zwaUFa&UPNxFCRzYl;PSh9A~gH6Q39Fh<*K`bhIkuh_qosw3VFIPI@xyv^^B^fiAd6 zA>kw?{VOm2{Fx#9RVNlynKS2EaPmgZ{S`&J=xj8L4a!4uccUz;K(;R@7uG4UqEC z=@viENTh@x^Sum-|AUtXaPrjv&(oO5Mv0nKDxAKn_a!&Q+Q)`Dm%CGIm6nSt18{Hc*ch-r1h#Tk3F17 z{Q!%fB|KBr{z`!r`s~uP6rg1v&-@Pcpu1qb{zDJ=#tmj*@F@{}2)?}2cNNm2deZGO zPapZU*W0&+CJC`nIn3r>d^gwf@-8nfGhSU7{mR`&R}{FCS?k6$sJ&Acf2^IviK8@< z41?lF@0M_nL2lBOt{e?e9@8pWr@xOD7i}jvF4^2OR4a};rgBTH4!TMoV(WE?NC1v+?ZxK)2a#`J6%f>&vgFO79 zYJ+#W<`Gdvjl7TxNxNRdTMWS-D#8)wQ8U?Za!Q$Aut<4Xq^AwLD>-b`U4XmR6^1Lf zs+|!_tMSTHs)m3p%5whTSPrLyUiU4A7(Co^7sQe$+q@Cg52HPZ0Z)g?k0EvO@!XwM zfo{-LM_|x!id#FmT^SUzqxXHfOh}#;z6y?A3P9dbOFTxN^1H%DUShp(xni!4X2(>w zUWTB{(a8}K6m87To4@HvXU8Hw)=z_P&f7U|Ti#PMW&|+(c?I}gEvaO~Sm6^lu`~s5 z5kPE~=-h{-ItTpx!YTh}n-O%*jb| literal 0 HcmV?d00001 diff --git a/resources/fake/fake.json b/resources/fake/fake.json new file mode 100644 index 0000000..0198cb5 --- /dev/null +++ b/resources/fake/fake.json @@ -0,0 +1 @@ +{"message":"Lorem ipsum dolor sit amet, consectetur adipiscing elit. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Lorem ipsum dolor sit amet, consectetur adipiscing elit"} \ No newline at end of file diff --git a/resources/fake/fake.md b/resources/fake/fake.md new file mode 100644 index 0000000..0fec534 --- /dev/null +++ b/resources/fake/fake.md @@ -0,0 +1,3 @@ +# Fake File + +Lorem ipsum dolor sit amet, consectetur adipiscing elit. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Lorem ipsum dolor sit amet, consectetur adipiscing elit \ No newline at end of file diff --git a/resources/fake/fake.pdf b/resources/fake/fake.pdf new file mode 100644 index 0000000000000000000000000000000000000000..599acbfbda79e5a8cd06f73755c27f175ed02f5b GIT binary patch literal 1024 zcmeH`(Fp)C2*p-;0~|L=mnc?>P-{`U|IKmW0Qm_?GiQaIxQb!R93hFWoDax$FeT@8 W^dU!rWKZyte9;OlIaSWz@Av@wo_kIJ literal 0 HcmV?d00001 diff --git a/resources/fake/fake.png b/resources/fake/fake.png new file mode 100644 index 0000000000000000000000000000000000000000..933bc62df35e7d7a8a2ea69e92954c153661422a GIT binary patch literal 1571 zcmcJPZB&v66vrQPlTB^y)XLLPYV{y3v+%XV%q=x}RH#g-nWl;FZB2Mw+L1&NQDM_ikx?IZ&OY?9|Cj%{|9gJ--cPsa5HWDI z_1D$_0IY`a{-FS{${qkLu6||?fG2ClYs|Kpf)6DCK#n5-oVx%3i{|Lu1OS}I0)RRi z0C431V3$$Vd3dk6u_`7w&>t`{uRIr-D+C=P1Ry4@H`rKvE=9(?BQ2zNP67@|?{oGcv?crU`MK0ie<98Qr&R2Xzu~>cMwyd!$csa1mUhl?^)@u?${=P%x? zTQ9;Bwcq#3nW0^SC<9dwSsx`~wDOgcm|OyhN}l+nkWz zS+lS0F8@_j^>Ll(XkOzRFI0{mN*i z1?qa%$lMA{&P^)os60aZOeK>4tjG^y%_)0_4kAmaJmLYyP@*PvK4{zl=CeWHAkV)A ze&XR=fvL&e_bS-&AQF+;J*vN<9wDlE+$)ZVCKM6H%~?jXd>cDkX~R6YAnVahv3db< zEc7S_qQ#TQ@>9#&*x52C;t5GLl6d*dfqt+=$u(M*MdiwGKaaR3hb558i^cYPwlsBI zF6~9K8IRL%&Z#Y7hplk6N;Rez`2?~j#S@%ZQs(P*@IqHjDhEHK>{a4b8Sxxbn)6I0 zMS4^lA=>SAb}md@5G6O%?9EwP9@Xpn_(7M^hF3H9bj=!}_hfYyk$mP0X68MG!_GTz z@@bmI7pMEt(3Ai!E!ou2VYt{q_AU`8rs+~y!ds<#8IdeG%mAlwOh3+K7Be_GTVbz9 z5j^u^!R~_|m3b0lbV2qA_0TD zP-c=ulP!x`n0|^TEwPpD;$pEgsikcyoOE2b7VhElpuuw9{HI3kRk!(8?tB^Z5;dKa zpW8JYsaw9IzScT0P4c+joJ5vYr8XtkD7-mN+<5*bVQPJE;TU6tr*#@cSgBIQYt{%=VSZ7z)Fc%k`3l`_$?u5nS zu-Fjt;4l9J(x|aX@!1~+d~A}pnSpKpZJ;Nm#nI>|(=tBHB%^{C-kk{r5dC@kqtE{f Dq7$sw literal 0 HcmV?d00001 diff --git a/resources/fake/fake.txt b/resources/fake/fake.txt new file mode 100644 index 0000000..599acbf --- /dev/null +++ b/resources/fake/fake.txt @@ -0,0 +1 @@ +Lorem ipsum dolor sit amet, consectetur adipiscing elit. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Lorem ipsum dolor sit amet, consectetur adipiscing elit \ No newline at end of file diff --git a/resources/fake/fake.webp b/resources/fake/fake.webp new file mode 100644 index 0000000000000000000000000000000000000000..b139af12ba701e36c1adde237e4d27521d6576ee GIT binary patch literal 1574 zcmc&zdpOez03CV>vs^xuw`}Hl7uNK!CK1J!LQP0iXhq(IZHg>e*F2h94AmV2S zpI5||=H^wdH`@?-o5$>T?{{B+-@ooX|D5yB`Mz_`_Z@dc@rKj?{ z#yNpl#yEcbNIP&J<1BtVfrJlZncJx!CkO zToNjrT_*+j!ALBzveZXnB~cc>UPnv)j%c4C734d$blEnKT1wCMrJ!n#0lYmpkvgSr zEg(c|bY2}WyLR-6uK^&S7De2D?xCTw3wkp>~$Ce^xE*g7^nyaV`%j$sJ48F7`Eg?FdY!WM$R3Cv|( zLB=JJO%eBr7P}+0W`~WCwf;{N%`;x5+(OR(-t*tBDF!s5-gd1$^1HE@J=|~m5Zb&RF&tyEyoO?|cJ9`QR zwtG|_Ko#Ktu=?TvR4Pn9Y*FfcyeDEXAGH z9rkXpLWze&USY&qzNn3|GqDW!I!TWN|__c5WI?wB}@$ zeLWNvif=PXBbjO@!i)zBI|BK&Z0p@YB@)mrN}JURUM7kiRveTCZb2$X$!i=EJE&^* zwR~YORjrj_?W1cMO9SJ#e>B+7cdU$()iA*z_j|Cl^g1W(&(&`NOEA~hWgQw2cx!UYFlfxuGq^ID~QHuo9jW@Mpw z7e)BF56LZACXAr2U&l>L*WUB*D$sLKV)%KouTS5W1KKnC5e^}bWty6FDAZGQB6ATNHUzrq|bUguvW)xX|g-Te`MgP>DCxLDEb#v8^l z_nrSFCgvN$9udE~04DkIr{t4@Te3Nq7Qvw*6JvKPw?(S=0!`dHsk8CHmqT4ulFdh?Q`;}2|3|3rQHOw~a* zlO0FZ;mEDzr08t$b!;tlg;Q$Uf@g_Ciu9-a774zNSjgkV~Pb&_0%h;VaMXUCy}|Lorem ipsum dolor sit amet, consectetur adipiscing elit. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Lorem ipsum dolor sit amet, consectetur adipiscing elit \ No newline at end of file diff --git a/resources/fake/fake.yaml b/resources/fake/fake.yaml new file mode 100644 index 0000000..5dc4650 --- /dev/null +++ b/resources/fake/fake.yaml @@ -0,0 +1,2 @@ +message: | + Lorem ipsum dolor sit amet, consectetur adipiscing elit. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Lorem ipsum dolor sit amet, consectetur adipiscing elit \ No newline at end of file diff --git a/resources/fake/fake.zip b/resources/fake/fake.zip new file mode 100644 index 0000000000000000000000000000000000000000..5bb740e51239533b27a7b0087e8f97a241448893 GIT binary patch literal 1024 zcmV+b1poWkodS$7mU6Mfg&fIcZ$A?+BgJ$<&rJRFe~eP=+>%Sv<xm;uf+JF|KtRyN71NDhr9o$-osXci9J!)ViFqjm);B&B@qI3P@&H0~nT z_!rVd(+dnd*#|`2cG9%eq5ViUz5h5mO|GEsIv-wHTh?oKwd@`-l&TT|QS7hw4>kWR z{bnx{nGz+}2LPx07eZP1iMFi9ej>m!DyBxBe%~pQGq9>y-sFqJKirVxJRuxC;=n2V zNSFz`Kr^qJG+1-pT`|o~=wjMMWCa|6b8LL}D&FlwJVrD#vHvJw)Z@0yU@hn;1==a# zt>oE3E+1!WFoOd$uAngqlX(A%2JEImG<%xLot(D`J$JJTCtu`5K(nz)JtIE}HSv=8 zu?&5jac4bHLvXyz4SCbYgVld9hmWu^?5Vy?EYp5*!sPOU|Nki`p{eFice0fW6u~mg zLr*K&HU{tP5~K#sf2rm3>wxEgKW-%+`9hrbTz^P$tAi;+onOihd0AeuH~vD?n4+g? z9$5+>kloyJUY4N}ZgBZt?jiW$>S|97alEFFp&$7uEVF28{^uAVRx5we_9N`Ak;Zd-V4RlvwBb zvQupENsYdoi65>i z@0cg(HJYV{X@s~M;ogKqw*vXr@sXskXO=+8F)ELiv(^%DGX*SrO5=b#{#IV$_-j6g zB=eg)z|fiAN9bxcdJGpkQ_3>#$#u5>U2*a#tX!?0195XA1ZA%8b)WyXHcrr8j>YE~ zm18{I_7mZmvUoGL(cBvr5Fv`3xnJl5Tnog6HPs<;61<~Q!;^YV_pZI9QMJoZX%?F> z`+3zO-2fj%96Am9k=U5vJ#X_0>VLA5g$Gj*b40%FCl3yPd{49LdT7H4MB=WLZg~lp uznU-oLa38;#QksssGg|9$s#Ey3E?3wf)O#z0$LwpQ-`IEl=L2nRS%=*3IPEC literal 0 HcmV?d00001 diff --git a/resources/lang/en/breakpoints.php b/resources/lang/en/breakpoints.php new file mode 100644 index 0000000..d4fc958 --- /dev/null +++ b/resources/lang/en/breakpoints.php @@ -0,0 +1,12 @@ + 'Extra Small', + 'sm' => 'Small', + 'md' => 'Medium', + 'lg' => 'Large', + 'xl' => 'Extra Large', + '2xl' => 'Extra Extra Large', +]; diff --git a/resources/stubs/ImageLibraryServiceProvider.php.stub b/resources/stubs/ImageLibraryServiceProvider.php.stub new file mode 100644 index 0000000..a8f7787 --- /dev/null +++ b/resources/stubs/ImageLibraryServiceProvider.php.stub @@ -0,0 +1,21 @@ +label(fn (): string => __('Thumbnail')) + ->aspectRatio(AspectRatio::make(1, 1)), + ]; + } +} diff --git a/resources/views/.gitkeep b/resources/views/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/resources/views/components/image.blade.php b/resources/views/components/image.blade.php new file mode 100644 index 0000000..ea2f59e --- /dev/null +++ b/resources/views/components/image.blade.php @@ -0,0 +1,21 @@ +@php + $attributes = $attributes->merge([ + 'src' => $image->sourceImage->url(), + 'alt' => $image->alt_text ?? $image->sourceImage->alt_text, + 'sizes' => '1px', + 'data-image-library' => 'image', + 'data-image-library-id' => $image->uuid, + ]); +@endphp + + + @foreach ($sources as $source) + + @endforeach + + diff --git a/resources/views/components/scripts.blade.php b/resources/views/components/scripts.blade.php new file mode 100644 index 0000000..f83da25 --- /dev/null +++ b/resources/views/components/scripts.blade.php @@ -0,0 +1,113 @@ + diff --git a/src/Commands/UpgradeCommand.php b/src/Commands/UpgradeCommand.php new file mode 100644 index 0000000..290c398 --- /dev/null +++ b/src/Commands/UpgradeCommand.php @@ -0,0 +1,212 @@ +info('The upgrade will go through the following steps:'); + $this->line('1. Check if an upgrade is needed.'); + $this->line('2. Create the source_images table migration if it does not exist.'); + $this->line('3. Create the new images table migration if it does not exist.'); + $this->line('4. Create pre upgrade migration file if it does not exist. This will rename the existing images table to tmp_images'); + $this->line('5. Create post upgrade migration file if it does not exist. This will create a source_image for each old image and upload the file to the correct location.'); + $this->line('6. Ask to run the migrations if any were created.'); + $this->line('7. Inform you to migrate your custom tables and data as needed.'); + $this->line('8. Ask to create a cleanup migration to remove old image library data.'); + $this->line('9. Ask to run the cleanup migration.'); + + if (! $this->confirm('Do you wish to proceed with the upgrade?', true)) { + $this->warn('Upgrade cancelled.'); + + return Command::SUCCESS; + } + + $this->info('1. Checking if an upgrade is needed...'); + + $imagesTableMigrations = collect(File::files(database_path('migrations'))) + ->filter(function ($file) { + return str_ends_with($file->getFilename(), 'create_images_table.php'); + }) + ->sortBy(fn ($file) => $file->getCTime()); + + if ($imagesTableMigrations->isEmpty()) { + $this->warn('No existing images table migration found. No upgrade needed.'); + + return Command::SUCCESS; + } + + $this->line('Upgrade needed. Proceeding to the next steps...'); + + $hasAddedMigrations = false; + + $this->info('2. Creating source_images table migration if it does not exist...'); + + $sourceImagesTableMigrations = collect(File::files(database_path('migrations'))) + ->filter(function ($file) { + return str_ends_with($file->getFilename(), 'create_source_images_table.php'); + }) + ->sortBy(fn ($file) => $file->getCTime()); + + if ($sourceImagesTableMigrations->isEmpty()) { + File::copy( + __DIR__.'/../../database/migrations/create_source_images_table.php.stub', + database_path('migrations/'.Carbon::now()->format('Y_m_d_His').'_create_source_images_table.php') + ); + + $this->line('Created source_images table migration.'); + + $hasAddedMigrations = true; + } else { + $this->line('source_images table migration already exists. Skipping...'); + } + + $this->info('3. Creating new images table migration if it does not exist...'); + + if ($imagesTableMigrations->count() === 1) { + File::copy( + __DIR__.'/../../database/migrations/create_images_table.php.stub', + database_path('migrations/'.Carbon::now()->addSecond()->format('Y_m_d_His').'_create_images_table.php') + ); + + $hasAddedMigrations = true; + + $imagesTableMigrations = collect(File::files(database_path('migrations'))) + ->filter(function ($file) { + return str_ends_with($file->getFilename(), 'create_images_table.php'); + }) + ->sortBy(fn ($file) => $file->getCTime()); + + $this->line('Created new images table migration.'); + } else { + $this->line('New images table migration already exists. Skipping...'); + } + + $preUpgradeMigrationExists = collect(File::files(database_path('migrations'))) + ->contains(function ($file) { + return str_ends_with($file->getFilename(), 'pre_image_library_upgrade.php'); + }); + + $postUpgradeMigrationExists = collect(File::files(database_path('migrations'))) + ->contains(function ($file) { + return str_ends_with($file->getFilename(), 'post_image_library_upgrade.php'); + }); + + if (! $preUpgradeMigrationExists || ! $postUpgradeMigrationExists) { + $createImagesTableMigrationTimestamp = pathinfo($imagesTableMigrations->last()->getFilename(), PATHINFO_FILENAME); + $createImagesTableMigrationTimestamp = mb_substr($createImagesTableMigrationTimestamp, 0, 17); + $createImagesTableMigrationTimestamp = Carbon::createFromFormat('Y_m_d_His', $createImagesTableMigrationTimestamp); + + $preUpgradeMigrationPath = __DIR__.'/../../database/migrations/upgrade/pre_image_library_upgrade.php.stub'; + $postUpgradeMigrationPath = __DIR__.'/../../database/migrations/upgrade/post_image_library_upgrade.php.stub'; + + $this->info('4. Creating pre upgrade migration if it does not exist...'); + + if (! $preUpgradeMigrationExists) { + File::copy( + $preUpgradeMigrationPath, + database_path('migrations/'.$createImagesTableMigrationTimestamp->subMinute()->format('Y_m_d_His').'_pre_image_library_upgrade.php') + ); + + $hasAddedMigrations = true; + + $this->line('Created pre upgrade migration.'); + } else { + $this->line('Pre upgrade migration already exists. Skipping...'); + } + + $this->info('5. Creating post upgrade migration if it does not exist...'); + + if (! $postUpgradeMigrationExists) { + File::copy( + $postUpgradeMigrationPath, + database_path('migrations/'.$createImagesTableMigrationTimestamp->addMinute()->format('Y_m_d_His').'_post_image_library_upgrade.php') + ); + + $hasAddedMigrations = true; + + $this->line('Created post upgrade migration.'); + } else { + $this->line('Post upgrade migration already exists. Skipping...'); + } + } else { + $this->info('4. Creating pre upgrade migration if it does not exist...'); + $this->line('Pre upgrade migration already exists. Skipping...'); + $this->info('5. Creating post upgrade migration if it does not exist...'); + $this->line('Post upgrade migration already exists. Skipping...'); + } + + $this->info('6. Checking if any migrations were created...'); + + if ($hasAddedMigrations && $this->confirm('Would you like to run the migrations now?')) { + $this->line('Running migrations...'); + + $this->call('migrate'); + } else { + $this->line('No new migrations were created or you chose not to run them now.'); + } + + $this->info('7. Now is the time to migrate your custom tables and data as needed.'); + $this->line('You should follow the instructions in the README.md to set up your models and Image Contexts.'); + $this->line(''); + $this->line('You can use the tmp_images table as a reference for your migrations.'); + $this->line('The uuid column of the records in the tmp_images table corresponds to the uuid column in the new source_images table.'); + $this->line('You can use the following id mapping query:'); + $this->line(''); + $this->line('$mapping = DB::table(\'tmp_images\')'); + $this->line(' ->join(\'source_images\', \'tmp_images.uuid\', \'=\', \'source_images.uuid\')'); + $this->line(' ->pluck("tmp_images.id as old_id", "source_images.id as new_id");'); + $this->line(''); + $this->line('Now, for each instance of your models, run the following:'); + $this->line(''); + $this->line('foreach (YourModel::cursor() as $model) {'); + $this->line(' $model->attachImage('); + $this->line(' SourceImage::find($mapping[$model->{your_old_id_reference}]), // Replace {your_old_id_reference} with the old image ID reference column'); + $this->line(' ['); + $this->line(' \'context\' => \'{your-context-key}\', // Replace {your-context-key} with the appropriate context key'); + $this->line(' // Add any other attributes you need here'); + $this->line(' ]'); + $this->line(' );'); + $this->line('}'); + $this->line(''); + $this->line('Make sure to replace {your_old_id_reference} and {your-context-key} with the appropriate values for your application.'); + + $done = false; + + while (! $done) { + $done = $this->confirm('Have you completed your custom data migrations?'); + } + + if ($this->confirm('Would you like to clean up old image library data now?')) { + $this->line('Cleaning up old image library data...'); + + File::copy( + __DIR__.'/../../database/migrations/upgrade/cleanup_image_library_upgrade.php.stub', + database_path('migrations/'.Carbon::now()->format('Y_m_d_His').'_cleanup_image_library_upgrade.php') + ); + + $this->comment('Published the cleanup migration file.'); + + if ($this->confirm('Would you like to run the migrations now?')) { + $this->comment('Running migrations...'); + + $this->call('migrate'); + + $this->info('Old image library data cleaned up successfully.'); + } + } + + return Command::SUCCESS; + } +} diff --git a/src/Components/Image.php b/src/Components/Image.php new file mode 100644 index 0000000..34caea1 --- /dev/null +++ b/src/Components/Image.php @@ -0,0 +1,79 @@ + collect(ImageLibrary::getBreakpointEnum()::sortedCases()) + ->map(function (ConfiguresBreakpoints $case): array { + return array_filter([ + (object) [ + 'media' => $this->getMediaQueryForBreakpoint($case), + 'srcset' => $this->getSrcsetForBreakpoint($case), + 'type' => $this->image->sourceImage->mime_type, + ], + $this->image->context?->getGenerateWebP() + ? (object) [ + 'media' => $this->getMediaQueryForBreakpoint($case), + 'srcset' => $this->getSrcsetForBreakpoint($case, 'webp'), + 'type' => 'image/webp', + ] + : null, + ]); + }) + ->flatten(1), + ]); + } + + private function getMediaQueryForBreakpoint(ConfiguresBreakpoints $breakpoint): string + { + $conditions = []; + + if (! is_null($breakpoint->getMinWidth()) && array_search($breakpoint, ImageLibrary::getBreakpointEnum()::sortedCases()) !== 0) { + $conditions[] = '(min-width: '.$breakpoint->getMinWidth().'px)'; + } + + if (! is_null($breakpoint->getMaxWidth())) { + $conditions[] = '(max-width: '.$breakpoint->getMaxWidth().'px)'; + } + + return implode(' and ', $conditions); + } + + private function getSrcsetForBreakpoint(ConfiguresBreakpoints $breakpoint, ?string $extension = null): string + { + if (! $this->image->context?->getGenerateResponsiveVersions()) { + return $this->image->urlForBreakpoint($breakpoint, $extension); + } + + return $this->image->getResponsiveRelativePathsForBreakpoint($breakpoint, $extension) + ->map(function (string $path) use ($breakpoint): string { + if (preg_match('/_w(\d+)\./', $path, $m)) { + $width = (int) $m[1]; + } else { + $width = $this->image->context->getMaxWidthForBreakpoint($breakpoint) + ?? $this->image->sourceImage->width; + } + + $url = $this->image->urlForRelativePath($path); + + return "{$url} {$width}w"; + }) + ->implode(', '); + } +} diff --git a/src/Components/Scripts.php b/src/Components/Scripts.php new file mode 100644 index 0000000..47b7199 --- /dev/null +++ b/src/Components/Scripts.php @@ -0,0 +1,18 @@ +horizontal = $horizontal; + $this->vertical = $vertical; + } + + public function __toString(): string + { + return "{$this->horizontal}:{$this->vertical}"; + } + + public static function make(int $horizontal, int $vertical): self + { + return new self($horizontal, $vertical); + } + + public function toString(): string + { + return (string) $this; + } + + public function toArray(): array + { + return [ + 'horizontal' => $this->horizontal, + 'vertical' => $this->vertical, + ]; + } +} diff --git a/src/Entities/CropData.php b/src/Entities/CropData.php new file mode 100644 index 0000000..af76be2 --- /dev/null +++ b/src/Entities/CropData.php @@ -0,0 +1,39 @@ +width = $width; + $this->height = $height; + $this->x = $x; + $this->y = $y; + } + + public static function make(int $width, int $height, ?int $x = null, ?int $y = null): self + { + return new self($width, $height, $x, $y); + } + + public function toArray(): array + { + return [ + 'width' => $this->width, + 'height' => $this->height, + 'x' => $this->x, + 'y' => $this->y, + ]; + } +} diff --git a/src/Entities/ImageContext.php b/src/Entities/ImageContext.php new file mode 100644 index 0000000..11de46a --- /dev/null +++ b/src/Entities/ImageContext.php @@ -0,0 +1,914 @@ + */ + protected array $aspectRatioByBreakpoint = []; + + /** @var array> */ + protected array $minWidthByBreakpoint = []; + + /** @var array> */ + protected array $maxWidthByBreakpoint = []; + + /** @var array */ + protected array $cropPositionByBreakpoint = []; + + /** @var array */ + protected array $blurByBreakpoint = []; + + /** @var array */ + protected array $greyscaleByBreakpoint = []; + + /** @var array */ + protected array $sepiaByBreakpoint = []; + + protected bool $allowsMultiple = false; + + protected ?bool $generateWebP = null; + + protected ?bool $generateResponsiveVersions = null; + + public function __construct(string $key) + { + $this->key = $key; + } + + public static function make(string $key): self + { + return new self($key); + } + + public function getKey(): string + { + return $this->key; + } + + public function getConfigurationHash(): string + { + return md5(json_encode($this->toArray(), JSON_THROW_ON_ERROR)); + } + + public function label(string|Closure|null $label): self + { + $this->label = $label; + + return $this; + } + + public function getLabel(): ?string + { + return blank($this->label) + ? Str::title(str_replace('_', ' ', $this->key)) + : $this->evaluate($this->label); + } + + /** @param AspectRatio|array $aspectRatio */ + public function aspectRatio(AspectRatio|array $aspectRatio): self + { + $this->aspectRatioByBreakpoint = $this->getBreakpoints() + ->mapWithKeys(function (ConfiguresBreakpoints $breakpoint) use ($aspectRatio) { + if (is_array($aspectRatio)) { + if (! array_key_exists($breakpoint->value, $aspectRatio)) { + throw new InvalidArgumentException("Aspect ratio for breakpoint '{$breakpoint->value}' is not defined for ImageContext with key '{$this->key}'."); + } + + return [$breakpoint->value => $aspectRatio[$breakpoint->value]]; + } + + return [$breakpoint->value => $aspectRatio]; + }) + ->all(); + + return $this; + } + + public function aspectRatioForBreakpoint(ConfiguresBreakpoints $breakpoint, AspectRatio $aspectRatio): self + { + $this->aspectRatioByBreakpoint[$breakpoint->value] = $aspectRatio; + + return $this; + } + + public function aspectRatioFromBreakpoint(ConfiguresBreakpoints $breakpoint, AspectRatio $aspectRatio): self + { + $index = $this->getBreakpoints() + ->search(function (ConfiguresBreakpoints $bp) use ($breakpoint) { + return $bp->value === $breakpoint->value; + }); + + $this->getBreakpoints() + ->slice($index) + ->each(function (ConfiguresBreakpoints $bp) use ($aspectRatio) { + $this->aspectRatioByBreakpoint[$bp->value] = $aspectRatio; + }); + + return $this; + } + + public function aspectRatioToBreakpoint(ConfiguresBreakpoints $breakpoint, AspectRatio $aspectRatio): self + { + $index = $this->getBreakpoints() + ->search(function (ConfiguresBreakpoints $bp) use ($breakpoint) { + return $bp->value === $breakpoint->value; + }); + + $this->getBreakpoints() + ->slice(0, $index + 1) + ->each(function (ConfiguresBreakpoints $bp) use ($aspectRatio) { + $this->aspectRatioByBreakpoint[$bp->value] = $aspectRatio; + }); + + return $this; + } + + public function aspectRatioBetweenBreakpoints(ConfiguresBreakpoints $startBreakpoint, ConfiguresBreakpoints $endBreakpoint, AspectRatio $aspectRatio): self + { + $breakpoints = $this->getBreakpoints(); + + $startIndex = $breakpoints->search(function (ConfiguresBreakpoints $bp) use ($startBreakpoint) { + return $bp->value === $startBreakpoint->value; + }); + + $endIndex = $breakpoints->search(function (ConfiguresBreakpoints $bp) use ($endBreakpoint) { + return $bp->value === $endBreakpoint->value; + }); + + $breakpoints + ->slice($startIndex, $endIndex - $startIndex + 1) + ->each(function (ConfiguresBreakpoints $bp) use ($aspectRatio) { + $this->aspectRatioByBreakpoint[$bp->value] = $aspectRatio; + }); + + return $this; + } + + /** @return array */ + public function getAspectRatioByBreakpoint(): array + { + return $this->aspectRatioByBreakpoint; + } + + public function getAspectRatioForBreakpoint(ConfiguresBreakpoints $breakpoint): ?AspectRatio + { + return $this->aspectRatioByBreakpoint[$breakpoint->value] ?? null; + } + + /** @param int|array $minWidth */ + public function minWidth(int|array $minWidth): self + { + $this->minWidthByBreakpoint = $this->getBreakpoints() + ->mapWithKeys(function (ConfiguresBreakpoints $breakpoint) use ($minWidth) { + if (is_array($minWidth)) { + if (! array_key_exists($breakpoint->value, $minWidth)) { + throw new InvalidArgumentException("Min width for breakpoint '{$breakpoint->value}' is not defined for ImageContext with key '{$this->key}'."); + } + + return [$breakpoint->value => $minWidth[$breakpoint->value]]; + } + + return [$breakpoint->value => $minWidth]; + }) + ->all(); + + return $this; + } + + public function minWidthForBreakpoint(ConfiguresBreakpoints $breakpoint, int $minWidth): self + { + $this->minWidthByBreakpoint[$breakpoint->value] = $minWidth; + + return $this; + } + + public function minWidthFromBreakpoint(ConfiguresBreakpoints $breakpoint, int $minWidth): self + { + $index = $this->getBreakpoints() + ->search(function (ConfiguresBreakpoints $bp) use ($breakpoint) { + return $bp->value === $breakpoint->value; + }); + + $this->getBreakpoints() + ->slice($index) + ->each(function (ConfiguresBreakpoints $bp) use ($minWidth) { + $this->minWidthByBreakpoint[$bp->value] = $minWidth; + }); + + return $this; + } + + public function minWidthToBreakpoint(ConfiguresBreakpoints $breakpoint, int $minWidth): self + { + $index = $this->getBreakpoints() + ->search(function (ConfiguresBreakpoints $bp) use ($breakpoint) { + return $bp->value === $breakpoint->value; + }); + + $this->getBreakpoints() + ->slice(0, $index + 1) + ->each(function (ConfiguresBreakpoints $bp) use ($minWidth) { + $this->minWidthByBreakpoint[$bp->value] = $minWidth; + }); + + return $this; + } + + public function minWidthBetweenBreakpoints(ConfiguresBreakpoints $startBreakpoint, ConfiguresBreakpoints $endBreakpoint, int $minWidth): self + { + $breakpoints = $this->getBreakpoints(); + + $startIndex = $breakpoints->search(function (ConfiguresBreakpoints $bp) use ($startBreakpoint) { + return $bp->value === $startBreakpoint->value; + }); + + $endIndex = $breakpoints->search(function (ConfiguresBreakpoints $bp) use ($endBreakpoint) { + return $bp->value === $endBreakpoint->value; + }); + + $breakpoints + ->slice($startIndex, $endIndex - $startIndex + 1) + ->each(function (ConfiguresBreakpoints $bp) use ($minWidth) { + $this->minWidthByBreakpoint[$bp->value] = $minWidth; + }); + + return $this; + } + + /** @return array */ + public function getMinWidthByBreakpoint(): array + { + return $this->minWidthByBreakpoint; + } + + public function getMinWidthForBreakpoint(ConfiguresBreakpoints $breakpoint): ?int + { + return $this->minWidthByBreakpoint[$breakpoint->value] ?? null; + } + + /** @param int|array $maxWidth */ + public function maxWidth(int|array $maxWidth): self + { + $this->maxWidthByBreakpoint = $this->getBreakpoints() + ->mapWithKeys(function (ConfiguresBreakpoints $breakpoint) use ($maxWidth) { + if (is_array($maxWidth)) { + if (! array_key_exists($breakpoint->value, $maxWidth)) { + throw new InvalidArgumentException("Max width for breakpoint '{$breakpoint->value}' is not defined for ImageContext with key '{$this->key}'."); + } + } + + return [$breakpoint->value => is_array($maxWidth) ? $maxWidth[$breakpoint->value] : $maxWidth]; + }) + ->all(); + + return $this; + } + + public function maxWidthForBreakpoint(ConfiguresBreakpoints $breakpoint, int $maxWidth): self + { + $this->maxWidthByBreakpoint[$breakpoint->value] = $maxWidth; + + return $this; + } + + public function maxWidthFromBreakpoint(ConfiguresBreakpoints $breakpoint, int $maxWidth): self + { + $index = $this->getBreakpoints() + ->search(function (ConfiguresBreakpoints $bp) use ($breakpoint) { + return $bp->value === $breakpoint->value; + }); + + $this->getBreakpoints() + ->slice($index) + ->each(function (ConfiguresBreakpoints $bp) use ($maxWidth) { + $this->maxWidthByBreakpoint[$bp->value] = $maxWidth; + }); + + return $this; + } + + public function maxWidthToBreakpoint(ConfiguresBreakpoints $breakpoint, int $maxWidth): self + { + $index = $this->getBreakpoints() + ->search(function (ConfiguresBreakpoints $bp) use ($breakpoint) { + return $bp->value === $breakpoint->value; + }); + + $this->getBreakpoints() + ->slice(0, $index + 1) + ->each(function (ConfiguresBreakpoints $bp) use ($maxWidth) { + $this->maxWidthByBreakpoint[$bp->value] = $maxWidth; + }); + + return $this; + } + + public function maxWidthBetweenBreakpoints(ConfiguresBreakpoints $startBreakpoint, ConfiguresBreakpoints $endBreakpoint, int $maxWidth): self + { + $breakpoints = $this->getBreakpoints(); + + $startIndex = $breakpoints->search(function (ConfiguresBreakpoints $bp) use ($startBreakpoint) { + return $bp->value === $startBreakpoint->value; + }); + + $endIndex = $breakpoints->search(function (ConfiguresBreakpoints $bp) use ($endBreakpoint) { + return $bp->value === $endBreakpoint->value; + }); + + $breakpoints + ->slice($startIndex, $endIndex - $startIndex + 1) + ->each(function (ConfiguresBreakpoints $bp) use ($maxWidth) { + $this->maxWidthByBreakpoint[$bp->value] = $maxWidth; + }); + + return $this; + } + + /** @return array */ + public function getMaxWidthByBreakpoint(): array + { + return $this->maxWidthByBreakpoint; + } + + public function getMaxWidthForBreakpoint(ConfiguresBreakpoints $breakpoint): ?int + { + return $this->maxWidthByBreakpoint[$breakpoint->value] ?? null; + } + + /** @param CropPosition|string|array $cropPosition */ + public function cropPosition(CropPosition|string|array $cropPosition): self + { + $this->cropPositionByBreakpoint = $this->getBreakpoints() + ->mapWithKeys(function (ConfiguresBreakpoints $breakpoint) use ($cropPosition) { + if (is_array($cropPosition)) { + if (! array_key_exists($breakpoint->value, $cropPosition)) { + throw new InvalidArgumentException("Crop position for breakpoint '{$breakpoint->value}' is not defined for ImageContext with key '{$this->key}'."); + } + + $cropPosition = $cropPosition[$breakpoint->value]; + $cropPosition = $cropPosition instanceof CropPosition ? $cropPosition : CropPosition::from($cropPosition); + + return [$breakpoint->value => $cropPosition]; + } + + $cropPosition = $cropPosition instanceof CropPosition ? $cropPosition : CropPosition::from($cropPosition); + + return [$breakpoint->value => $cropPosition]; + }) + ->all(); + + return $this; + } + + public function cropPositionForBreakpoint(ConfiguresBreakpoints $breakpoint, CropPosition|string $cropPosition): self + { + $cropPosition = $cropPosition instanceof CropPosition ? $cropPosition : CropPosition::from($cropPosition); + + $this->cropPositionByBreakpoint[$breakpoint->value] = $cropPosition; + + return $this; + } + + public function cropPositionFromBreakpoint(ConfiguresBreakpoints $breakpoint, CropPosition|string $cropPosition): self + { + $cropPosition = $cropPosition instanceof CropPosition ? $cropPosition : CropPosition::from($cropPosition); + + $index = $this->getBreakpoints() + ->search(function (ConfiguresBreakpoints $bp) use ($breakpoint) { + return $bp->value === $breakpoint->value; + }); + + $this->getBreakpoints() + ->slice($index) + ->each(function (ConfiguresBreakpoints $bp) use ($cropPosition) { + $this->cropPositionByBreakpoint[$bp->value] = $cropPosition; + }); + + return $this; + } + + public function cropPositionToBreakpoint(ConfiguresBreakpoints $breakpoint, CropPosition|string $cropPosition): self + { + $cropPosition = $cropPosition instanceof CropPosition ? $cropPosition : CropPosition::from($cropPosition); + + $index = $this->getBreakpoints() + ->search(function (ConfiguresBreakpoints $bp) use ($breakpoint) { + return $bp->value === $breakpoint->value; + }); + + $this->getBreakpoints() + ->slice(0, $index + 1) + ->each(function (ConfiguresBreakpoints $bp) use ($cropPosition) { + $this->cropPositionByBreakpoint[$bp->value] = $cropPosition; + }); + + return $this; + } + + public function cropPositionBetweenBreakpoints(ConfiguresBreakpoints $startBreakpoint, ConfiguresBreakpoints $endBreakpoint, CropPosition|string $cropPosition): self + { + $cropPosition = $cropPosition instanceof CropPosition ? $cropPosition : CropPosition::from($cropPosition); + + $breakpoints = $this->getBreakpoints(); + + $startIndex = $breakpoints->search(function (ConfiguresBreakpoints $bp) use ($startBreakpoint) { + return $bp->value === $startBreakpoint->value; + }); + + $endIndex = $breakpoints->search(function (ConfiguresBreakpoints $bp) use ($endBreakpoint) { + return $bp->value === $endBreakpoint->value; + }); + + $breakpoints + ->slice($startIndex, $endIndex - $startIndex + 1) + ->each(function (ConfiguresBreakpoints $bp) use ($cropPosition) { + $this->cropPositionByBreakpoint[$bp->value] = $cropPosition; + }); + + return $this; + } + + /** @return array */ + public function getCropPositionByBreakpoint(): array + { + return $this->cropPositionByBreakpoint; + } + + public function getCropPositionForBreakpoint(ConfiguresBreakpoints $breakpoint): ?CropPosition + { + return $this->cropPositionByBreakpoint[$breakpoint->value] ?? ImageLibrary::getDefaultCropPosition(); + } + + /** @param int|array $blur */ + public function blur(int|array $blur): self + { + $this->blurByBreakpoint = $this->getBreakpoints() + ->mapWithKeys(function (ConfiguresBreakpoints $breakpoint) use ($blur) { + if (is_array($blur)) { + if (! array_key_exists($breakpoint->value, $blur)) { + throw new InvalidArgumentException("Blur for breakpoint '{$breakpoint->value}' is not defined for ImageContext with key '{$this->key}'."); + } + + $blurValue = $blur[$breakpoint->value]; + + $this->validateBlurValue($blurValue, $breakpoint); + + return [$breakpoint->value => $blurValue]; + } + + $this->validateBlurValue($blur); + + return [$breakpoint->value => $blur]; + }) + ->all(); + + return $this; + } + + public function validateBlurValue(mixed $blur, ?ConfiguresBreakpoints $breakpoint = null): void + { + if (! is_int($blur)) { + throw new InvalidArgumentException( + $breakpoint + ? "Blur value for breakpoint '{$breakpoint->value}' must be an integer for ImageContext with key '{$this->key}'." + : "Blur value must be an integer for ImageContext with key '{$this->key}'." + ); + } + + if ($blur < 0 || $blur > 100) { + throw new InvalidArgumentException( + $breakpoint + ? "Blur value for breakpoint '{$breakpoint->value}' must be between 0 and 100 for ImageContext with key '{$this->key}'." + : "Blur value must be between 0 and 100 for ImageContext with key '{$this->key}'." + ); + } + } + + public function blurForBreakpoint(ConfiguresBreakpoints $breakpoint, int $blur): self + { + $this->validateBlurValue($blur, $breakpoint); + + $this->blurByBreakpoint[$breakpoint->value] = $blur; + + return $this; + } + + public function blurFromBreakpoint(ConfiguresBreakpoints $breakpoint, int $blur): self + { + $this->validateBlurValue($blur, $breakpoint); + + $index = $this->getBreakpoints() + ->search(function (ConfiguresBreakpoints $bp) use ($breakpoint) { + return $bp->value === $breakpoint->value; + }); + + $this->getBreakpoints() + ->slice($index) + ->each(function (ConfiguresBreakpoints $bp) use ($blur) { + $this->blurByBreakpoint[$bp->value] = $blur; + }); + + return $this; + } + + public function blurToBreakpoint(ConfiguresBreakpoints $breakpoint, int $blur): self + { + $this->validateBlurValue($blur, $breakpoint); + + $index = $this->getBreakpoints() + ->search(function (ConfiguresBreakpoints $bp) use ($breakpoint) { + return $bp->value === $breakpoint->value; + }); + + $this->getBreakpoints() + ->slice(0, $index + 1) + ->each(function (ConfiguresBreakpoints $bp) use ($blur) { + $this->blurByBreakpoint[$bp->value] = $blur; + }); + + return $this; + } + + public function blurBetweenBreakpoints(ConfiguresBreakpoints $startBreakpoint, ConfiguresBreakpoints $endBreakpoint, int $blur): self + { + $this->validateBlurValue($blur); + + $breakpoints = $this->getBreakpoints(); + + $startIndex = $breakpoints->search(function (ConfiguresBreakpoints $bp) use ($startBreakpoint) { + return $bp->value === $startBreakpoint->value; + }); + + $endIndex = $breakpoints->search(function (ConfiguresBreakpoints $bp) use ($endBreakpoint) { + return $bp->value === $endBreakpoint->value; + }); + + $breakpoints + ->slice($startIndex, $endIndex - $startIndex + 1) + ->each(function (ConfiguresBreakpoints $bp) use ($blur) { + $this->blurByBreakpoint[$bp->value] = $blur; + }); + + return $this; + } + + /** @return array */ + public function getBlurByBreakpoint(): array + { + return $this->blurByBreakpoint; + } + + public function getBlurForBreakpoint(ConfiguresBreakpoints $breakpoint): ?int + { + return $this->blurByBreakpoint[$breakpoint->value] ?? null; + } + + /** @param bool|array $greyscale */ + public function greyscale(bool|array $greyscale = true): self + { + $this->greyscaleByBreakpoint = $this->getBreakpoints() + ->mapWithKeys(function (ConfiguresBreakpoints $breakpoint) use ($greyscale) { + if (is_array($greyscale)) { + if (! array_key_exists($breakpoint->value, $greyscale)) { + throw new InvalidArgumentException("Greyscale for breakpoint '{$breakpoint->value}' is not defined for ImageContext with key '{$this->key}'."); + } + + $grayscaleValue = $greyscale[$breakpoint->value]; + + $this->validateGreyscaleValue($grayscaleValue, $breakpoint); + + return [$breakpoint->value => $grayscaleValue]; + } + + return [$breakpoint->value => $greyscale]; + }) + ->all(); + + return $this; + } + + public function validateGreyscaleValue(mixed $greyscale, ?ConfiguresBreakpoints $breakpoint = null): void + { + if (! is_bool($greyscale)) { + throw new InvalidArgumentException( + $breakpoint + ? "Greyscale value for breakpoint '{$breakpoint->value}' must be a boolean for ImageContext with key '{$this->key}'." + : "Greyscale value must be a boolean for ImageContext with key '{$this->key}'." + ); + } + } + + /** @param bool|array $greyscale */ + public function grayscale(bool|array $greyscale = true): self + { + return $this->greyscale($greyscale); + } + + public function greyscaleForBreakpoint(ConfiguresBreakpoints $breakpoint, bool $greyscale = true): self + { + $this->greyscaleByBreakpoint[$breakpoint->value] = $greyscale; + + return $this; + } + + public function grayscaleForBreakpoint(ConfiguresBreakpoints $breakpoint, bool $greyscale = true): self + { + return $this->greyscaleForBreakpoint($breakpoint, $greyscale); + } + + public function greyscaleFromBreakpoint(ConfiguresBreakpoints $breakpoint, bool $greyscale = true): self + { + $index = $this->getBreakpoints() + ->search(function (ConfiguresBreakpoints $bp) use ($breakpoint) { + return $bp->value === $breakpoint->value; + }); + + $this->getBreakpoints() + ->slice($index) + ->each(function (ConfiguresBreakpoints $bp) use ($greyscale) { + $this->greyscaleByBreakpoint[$bp->value] = $greyscale; + }); + + return $this; + } + + public function grayscaleFromBreakpoint(ConfiguresBreakpoints $breakpoint, bool $greyscale = true): self + { + return $this->greyscaleFromBreakpoint($breakpoint, $greyscale); + } + + public function greyscaleToBreakpoint(ConfiguresBreakpoints $breakpoint, bool $greyscale = true): self + { + $index = $this->getBreakpoints() + ->search(function (ConfiguresBreakpoints $bp) use ($breakpoint) { + return $bp->value === $breakpoint->value; + }); + + $this->getBreakpoints() + ->slice(0, $index + 1) + ->each(function (ConfiguresBreakpoints $bp) use ($greyscale) { + $this->greyscaleByBreakpoint[$bp->value] = $greyscale; + }); + + return $this; + } + + public function grayscaleToBreakpoint(ConfiguresBreakpoints $breakpoint, bool $greyscale = true): self + { + return $this->greyscaleToBreakpoint($breakpoint, $greyscale); + } + + public function greyscaleBetweenBreakpoints(ConfiguresBreakpoints $startBreakpoint, ConfiguresBreakpoints $endBreakpoint, bool $greyscale = true): self + { + $breakpoints = $this->getBreakpoints(); + + $startIndex = $breakpoints->search(function (ConfiguresBreakpoints $bp) use ($startBreakpoint) { + return $bp->value === $startBreakpoint->value; + }); + + $endIndex = $breakpoints->search(function (ConfiguresBreakpoints $bp) use ($endBreakpoint) { + return $bp->value === $endBreakpoint->value; + }); + + $breakpoints + ->slice($startIndex, $endIndex - $startIndex + 1) + ->each(function (ConfiguresBreakpoints $bp) use ($greyscale) { + $this->greyscaleByBreakpoint[$bp->value] = $greyscale; + }); + + return $this; + } + + public function grayscaleBetweenBreakpoints(ConfiguresBreakpoints $startBreakpoint, ConfiguresBreakpoints $endBreakpoint, bool $greyscale = true): self + { + return $this->greyscaleBetweenBreakpoints($startBreakpoint, $endBreakpoint, $greyscale); + } + + /** @return array */ + public function getGreyscaleByBreakpoint(): array + { + return $this->greyscaleByBreakpoint; + } + + /** @return array */ + public function getGrayscaleByBreakpoint(): array + { + return $this->getGreyscaleByBreakpoint(); + } + + public function getGreyscaleForBreakpoint(ConfiguresBreakpoints $breakpoint): ?bool + { + return $this->greyscaleByBreakpoint[$breakpoint->value] ?? null; + } + + public function getGrayscaleForBreakpoint(ConfiguresBreakpoints $breakpoint): ?bool + { + return $this->getGreyscaleForBreakpoint($breakpoint); + } + + /** @param bool|array $sepia */ + public function sepia(bool|array $sepia = true): self + { + $this->sepiaByBreakpoint = $this->getBreakpoints() + ->mapWithKeys(function (ConfiguresBreakpoints $breakpoint) use ($sepia) { + if (is_array($sepia)) { + if (! array_key_exists($breakpoint->value, $sepia)) { + throw new InvalidArgumentException("Sepia for breakpoint '{$breakpoint->value}' is not defined for ImageContext with key '{$this->key}'."); + } + + $sepiaValue = $sepia[$breakpoint->value]; + + $this->validateSepiaValue($sepiaValue, $breakpoint); + + return [$breakpoint->value => $sepiaValue]; + } + + return [$breakpoint->value => $sepia]; + }) + ->all(); + + return $this; + } + + public function validateSepiaValue(mixed $sepia, ?ConfiguresBreakpoints $breakpoint = null): void + { + if (! is_bool($sepia)) { + throw new InvalidArgumentException( + $breakpoint + ? "Sepia value for breakpoint '{$breakpoint->value}' must be a boolean for ImageContext with key '{$this->key}'." + : "Sepia value must be a boolean for ImageContext with key '{$this->key}'." + ); + } + } + + public function sepiaForBreakpoint(ConfiguresBreakpoints $breakpoint, bool $sepia = true): self + { + $this->sepiaByBreakpoint[$breakpoint->value] = $sepia; + + return $this; + } + + public function sepiaFromBreakpoint(ConfiguresBreakpoints $breakpoint, bool $sepia = true): self + { + $index = $this->getBreakpoints() + ->search(function (ConfiguresBreakpoints $bp) use ($breakpoint) { + return $bp->value === $breakpoint->value; + }); + + $this->getBreakpoints() + ->slice($index) + ->each(function (ConfiguresBreakpoints $bp) use ($sepia) { + $this->sepiaByBreakpoint[$bp->value] = $sepia; + }); + + return $this; + } + + public function sepiaToBreakpoint(ConfiguresBreakpoints $breakpoint, bool $sepia = true): self + { + $index = $this->getBreakpoints() + ->search(function (ConfiguresBreakpoints $bp) use ($breakpoint) { + return $bp->value === $breakpoint->value; + }); + + $this->getBreakpoints() + ->slice(0, $index + 1) + ->each(function (ConfiguresBreakpoints $bp) use ($sepia) { + $this->sepiaByBreakpoint[$bp->value] = $sepia; + }); + + return $this; + } + + public function sepiaBetweenBreakpoints(ConfiguresBreakpoints $startBreakpoint, ConfiguresBreakpoints $endBreakpoint, bool $sepia = true): self + { + $breakpoints = $this->getBreakpoints(); + + $startIndex = $breakpoints->search(function (ConfiguresBreakpoints $bp) use ($startBreakpoint) { + return $bp->value === $startBreakpoint->value; + }); + + $endIndex = $breakpoints->search(function (ConfiguresBreakpoints $bp) use ($endBreakpoint) { + return $bp->value === $endBreakpoint->value; + }); + + $breakpoints + ->slice($startIndex, $endIndex - $startIndex + 1) + ->each(function (ConfiguresBreakpoints $bp) use ($sepia) { + $this->sepiaByBreakpoint[$bp->value] = $sepia; + }); + + return $this; + } + + /** @return array */ + public function getSepiaByBreakpoint(): array + { + return $this->sepiaByBreakpoint; + } + + public function getSepiaForBreakpoint(ConfiguresBreakpoints $breakpoint): ?bool + { + return $this->sepiaByBreakpoint[$breakpoint->value] ?? null; + } + + public function allowsMultiple(bool $allowsMultiple = true): self + { + $this->allowsMultiple = $allowsMultiple; + + return $this; + } + + public function getAllowsMultiple(): bool + { + return $this->allowsMultiple; + } + + public function generateWebP(bool $generateWebP = true): self + { + $this->generateWebP = $generateWebP; + + return $this; + } + + public function getGenerateWebP(): bool + { + return $this->generateWebP ?? ImageLibrary::shouldGenerateWebp(); + } + + public function generateResponsiveVersions(bool $generateResponsiveVersions = true): self + { + $this->generateResponsiveVersions = $generateResponsiveVersions; + + return $this; + } + + public function getGenerateResponsiveVersions(): bool + { + return $this->generateResponsiveVersions ?? ImageLibrary::shouldGenerateResponsiveVersions(); + } + + public function toArray(): array + { + return [ + 'key' => $this->key, + 'label' => $this->label, + 'aspectRatioByBreakpoint' => array_map( + fn (AspectRatio $ar) => $ar->toArray(), + $this->aspectRatioByBreakpoint + ), + 'blurByBreakpoint' => $this->blurByBreakpoint, + 'greyscaleByBreakpoint' => $this->greyscaleByBreakpoint, + 'sepiaByBreakpoint' => $this->sepiaByBreakpoint, + 'allowsMultiple' => $this->allowsMultiple, + 'generateWebP' => $this->generateWebP, + 'generateResponsiveVersions' => $this->generateResponsiveVersions, + ]; + } + + /** + * @template T + * + * @param T | callable(): T $value + * @return T + */ + private function evaluate(mixed $value): mixed + { + if (! $value instanceof Closure) { + return $value; + } + + $dependencies = collect(new ReflectionFunction($value)->getParameters()) + ->map(function (ReflectionParameter $parameter) { + return match ($parameter->getName()) { + 'imageContext' => $this, + default => null, + }; + }) + ->all(); + + return $value(...$dependencies); + } + + private function getBreakpoints(): Collection + { + return collect(ImageLibrary::getBreakpointEnum()::sortedCases()); + } +} diff --git a/src/Enums/Breakpoint.php b/src/Enums/Breakpoint.php new file mode 100644 index 0000000..f0dda11 --- /dev/null +++ b/src/Enums/Breakpoint.php @@ -0,0 +1,63 @@ +sort(fn ($a, $b) => $a->getMinWidth() <=> $b->getMinWidth()) + ->all(); + } + + public function getLabel(): string + { + return match ($this) { + self::Small => __('image-library::breakpoints.sm'), + self::Medium => __('image-library::breakpoints.md'), + self::Large => __('image-library::breakpoints.lg'), + self::ExtraLarge => __('image-library::breakpoints.xl'), + self::ExtraExtraLarge => __('image-library::breakpoints.2xl'), + }; + } + + public function getMinWidth(): int + { + return match ($this) { + self::Small => 640, + self::Medium => 768, + self::Large => 1024, + self::ExtraLarge => 1280, + self::ExtraExtraLarge => 1536, + }; + } + + public function getMaxWidth(): ?int + { + $index = array_search($this, self::sortedCases(), true); + + $next = self::sortedCases()[$index + 1] ?? null; + + return $next ? $next->getMinWidth() - 1 : null; + } + + public function getSlug(): string + { + return Str::of($this->value) + ->lower() + ->slug() + ->toString(); + } +} diff --git a/src/Facades/ImageLibrary.php b/src/Facades/ImageLibrary.php new file mode 100644 index 0000000..c2a9a7a --- /dev/null +++ b/src/Facades/ImageLibrary.php @@ -0,0 +1,50 @@ + getBreakpointEnum() + * @method static class-string getImageModel() + * @method static class-string getSourceImageModel() + * @method static SpatieImage getSpatieImage() + * @method static void registerImageContexts(array $imageContexts) + * @method static void registerImageContext(ImageContext $imageContext) + * @method static void removeImageContext(ImageContext $imageContext) + * @method static array getImageContexts() + * @method static ?ImageContext getImageContextByKey(?string $key) + * @method static SourceImage upload(UploadedFile $file, array $attributes = []) + * @method static bool shouldUseTemporaryUrlsForDisk(string $disk) + * @method static int getTemporaryUrlExpirationMinutesForDisk(string $disk) + * @method static string getDefaultDisk() + * @method static array getSupportedLocales() + * @method static CropPosition getDefaultCropPosition() + * @method static bool shouldGenerateWebp() + * @method static bool shouldGenerateResponsiveVersions() + * @method static string getDefaultQueueConnection() + * @method static string getDefaultQueue() + * @method static string getBasePath() + * @method static float getResponsiveImageSizeStepMultiplier() + * @method static int getResponsiveImageWidthDifferenceThreshold() + * @method static int getResponsiveImageMinWidth() + * + * @see \Outerweb\ImageLibrary\ImageLibrary + */ +class ImageLibrary extends Facade +{ + protected static function getFacadeAccessor(): string + { + return \Outerweb\ImageLibrary\ImageLibrary::class; + } +} diff --git a/src/ImageLibrary.php b/src/ImageLibrary.php new file mode 100755 index 0000000..2162b40 --- /dev/null +++ b/src/ImageLibrary.php @@ -0,0 +1,172 @@ + + */ + public function getBreakpointEnum(): string + { + return Config::get('image-library.enums.breakpoint'); + } + + /** + * @return class-string + */ + public function getImageModel(): string + { + return Config::get('image-library.models.image'); + } + + /** + * @return class-string + */ + public function getSourceImageModel(): string + { + return Config::get('image-library.models.source_image'); + } + + public function getSpatieImage(): SpatieImage + { + return SpatieImage::useImageDriver(Config::get('image-library.spatie_image.driver')); + } + + /** + * @param array $imageContexts + */ + public function registerImageContexts(array $imageContexts): void + { + foreach ($imageContexts as $imageContext) { + if (! $imageContext instanceof ImageContext) { + throw new InvalidArgumentException('Expected instance of ImageContext, but got '.gettype($imageContext).' instead.'); + } + + $this->imageContexts[$imageContext->getKey()] = $imageContext; + } + } + + public function registerImageContext(ImageContext $imageContext): void + { + $this->imageContexts[$imageContext->getKey()] = $imageContext; + } + + public function removeImageContext(ImageContext $imageContext): void + { + unset($this->imageContexts[$imageContext->getKey()]); + } + + /** + * @return array + */ + public function getImageContexts(): array + { + return array_values($this->imageContexts); + } + + public function getImageContextByKey(?string $key): ?ImageContext + { + if (blank($key)) { + return null; + } + + return $this->imageContexts[$key] ?? null; + } + + public function upload(UploadedFile $file, array $attributes = []): SourceImage + { + return $this->getSourceImageModel()::upload($file, $attributes); + } + + public function shouldGenerateWebp(): bool + { + return Config::get('image-library.generate.webp', true); + } + + public function shouldGenerateResponsiveVersions(): bool + { + return Config::get('image-library.generate.responsive_versions', true); + } + + public function shouldUseTemporaryUrlsForDisk(string $disk): bool + { + if (! Config::has("image-library.defaults.temporary_url.$disk")) { + return Config::get('image-library.defaults.temporary_url.default.enabled', false); + } + + return Config::get("image-library.defaults.temporary_url.$disk.enabled", false); + } + + public function getTemporaryUrlExpirationMinutesForDisk(string $disk): int + { + if (! Config::has("image-library.defaults.temporary_url.$disk")) { + return Config::get('image-library.defaults.temporary_url.default.expiration_minutes', 5); + } + + return Config::get("image-library.defaults.temporary_url.$disk.expiration_minutes", 5); + } + + public function getDefaultDisk(): string + { + return Config::string('image-library.defaults.disk'); + } + + /** @return array */ + public function getSupportedLocales(): array + { + return Config::array('app.supported_locales', [Config::string('app.locale')]); + } + + public function getDefaultCropPosition(): CropPosition + { + $position = Config::get('image-library.defaults.crop_position', CropPosition::Center); + + return $position instanceof CropPosition ? $position : CropPosition::from($position); + } + + public function getDefaultQueueConnection(): string + { + return Config::string('image-library.queue.connection'); + } + + public function getDefaultQueue(): string + { + return Config::string('image-library.queue.queue'); + } + + public function getBasePath(): string + { + return Config::string('image-library.paths.base'); + } + + public function getResponsiveImageSizeStepMultiplier(): float + { + return Config::float('image-library.responsive_images.size_step_multiplier', 0.7); + } + + public function getResponsiveImageWidthDifferenceThreshold(): int + { + return Config::integer('image-library.responsive_images.width_difference_threshold', 50); + } + + public function getResponsiveImageMinWidth(): int + { + return Config::integer('image-library.responsive_images.min_width', 100); + } +} diff --git a/src/ImageLibraryServiceProvider.php b/src/ImageLibraryServiceProvider.php new file mode 100644 index 0000000..59bbfcc --- /dev/null +++ b/src/ImageLibraryServiceProvider.php @@ -0,0 +1,44 @@ +name('image-library') + ->hasConfigFile() + ->hasCommands([ + UpgradeCommand::class, + ]) + ->hasMigrations([ + 'create_source_images_table', + 'create_images_table', + ]) + ->publishesServiceProvider('ImageLibraryServiceProvider') + ->hasViews() + ->hasViewComponents( + 'image-library', + Image::class, + Scripts::class, + ) + ->hasInstallCommand(function (InstallCommand $command) { + $command + ->publishConfigFile() + ->copyAndRegisterServiceProviderInApp() + ->publishMigrations() + ->askToRunMigrations() + ->askToStarRepoOnGitHub('outer-web/image-library'); + }); + } +} diff --git a/src/Jobs/GenerateImageVersionJob.php b/src/Jobs/GenerateImageVersionJob.php new file mode 100644 index 0000000..0b0d53d --- /dev/null +++ b/src/Jobs/GenerateImageVersionJob.php @@ -0,0 +1,117 @@ +imageId = $imageId instanceof Image ? $imageId->getKey() : $imageId; + + $this->onConnection(ImageLibrary::getDefaultQueueConnection()); + $this->onQueue(ImageLibrary::getDefaultQueue()); + } + + public function handle(): void + { + if ($this->batch()?->cancelled()) { + // @codeCoverageIgnoreStart + return; + // @codeCoverageIgnoreEnd + } + + $image = ImageLibrary::getImageModel()::query() + ->findOrFail($this->imageId); + + $temporaryPath = new TemporaryDirectory()->create()->path($this->breakpoint->getSlug().'-'.$image->uuid.'.'.$image->sourceImage->extension); + + File::put($temporaryPath, $image->sourceImage->get()); + + $file = ImageLibrary::getSpatieImage() + ->loadFile($temporaryPath) + ->optimize(); + + $cropData = $image->crop_data[$this->breakpoint->value] ?? null; + + if (! is_null($cropData)) { + if (is_null($cropData->x) || is_null($cropData->y)) { + $file->crop($cropData->width, $cropData->height, $image->context->getCropPositionForBreakpoint($this->breakpoint)); + } else { + $file->manualCrop( + $cropData->width, + $cropData->height, + $cropData->x, + $cropData->y, + ); + } + } else { + $fileWidth = $file->getWidth(); + $maxWidth = min($image->context->getMaxWidthForBreakpoint($this->breakpoint) ?? $fileWidth, $fileWidth); + $maxHeight = $file->getHeight(); + $aspectRatio = $image->context->getAspectRatioForBreakpoint($this->breakpoint); + + $possibleWidth = $maxHeight * $aspectRatio->horizontal / $aspectRatio->vertical; + $possibleHeight = $maxWidth * $aspectRatio->vertical / $aspectRatio->horizontal; + + // @codeCoverageIgnoreStart + if ($possibleWidth <= $maxWidth) { + $width = (int) round($possibleWidth); + $height = $maxHeight; + } else { + $width = $maxWidth; + $height = (int) round($possibleHeight); + } + // @codeCoverageIgnoreEnd + + $file->crop($width, $height, $image->context->getCropPositionForBreakpoint($this->breakpoint)); + } + + $breakpointMaxWidth = $image->context->getMaxWidthForBreakpoint($this->breakpoint); + + if (! is_null($breakpointMaxWidth)) { + $file->fit(Fit::Max, $breakpointMaxWidth); + } + + $blur = $image->context->getBlurForBreakpoint($this->breakpoint); + if (is_int($blur)) { + $file->blur($blur); + } + + $greyscale = $image->context->getGreyscaleForBreakpoint($this->breakpoint); + if ($greyscale === true) { + $file->greyscale(); + } + + $sepia = $image->context->getSepiaForBreakpoint($this->breakpoint); + if ($sepia === true) { + $file->sepia(); + } + + // Create directory + Storage::disk($image->disk)->makeDirectory($image->getRelativeBasePath()); + + $file->save($image->getAbsolutePathForBreakpoint($this->breakpoint)); + + if ($image->context->getGenerateWebP()) { + $file->save($image->getAbsolutePathForBreakpoint($this->breakpoint, 'webp')); + } + } +} diff --git a/src/Jobs/GenerateResponsiveImageVersionsJob.php b/src/Jobs/GenerateResponsiveImageVersionsJob.php new file mode 100644 index 0000000..b5354fd --- /dev/null +++ b/src/Jobs/GenerateResponsiveImageVersionsJob.php @@ -0,0 +1,124 @@ +imageId = $imageId instanceof Image ? $imageId->getKey() : $imageId; + + $this->onConnection(ImageLibrary::getDefaultQueueConnection()); + $this->onQueue(ImageLibrary::getDefaultQueue()); + } + + public function handle(): void + { + if ($this->batch()?->cancelled()) { + // @codeCoverageIgnoreStart + return; + // @codeCoverageIgnoreEnd + } + + $image = ImageLibrary::getImageModel()::query() + ->findOrFail($this->imageId); + + $temporaryPath = new TemporaryDirectory()->create()->path($this->breakpoint->getSlug().'-'.$image->uuid.'.'.$image->sourceImage->extension); + + File::put($temporaryPath, $image->getForBreakpoint($this->breakpoint)); + + $widths = $this->calculateWidths( + $temporaryPath, + $image, + ); + + foreach ($widths as $width) { + $file = ImageLibrary::getSpatieImage() + ->loadFile($temporaryPath) + ->fit(Fit::Max, $width); + + // Create directory + Storage::disk($image->disk)->makeDirectory($image->getRelativeBasePath()); + + $file->save(Str::of($image->getAbsolutePathForBreakpoint($this->breakpoint)) + ->replaceLast('.'.$image->sourceImage->extension, '_w'.$width.'.'.$image->sourceImage->extension) + ->toString()); + + if ($image->context->getGenerateWebP()) { + $file->save( + Str::of($image->getAbsolutePathForBreakpoint($this->breakpoint, 'webp')) + ->replaceLast('.webp', '_w'.$width.'.webp') + ->toString() + ); + } + } + } + + private function calculateWidths(string $path, Image $image): array + { + $file = ImageLibrary::getSpatieImage()->loadFile($path); + + $fileWidth = $file->getWidth(); + $fileHeight = $file->getHeight(); + $fileSize = File::size($path); + + $contextMaxWidth = $image->context->getMaxWidthForBreakpoint($this->breakpoint); + $contextMinWidth = $image->context->getMinWidthForBreakpoint($this->breakpoint); + + $breakpointMinWidth = $this->breakpoint->getMinWidth(); + + $minWidth = min(is_null($contextMaxWidth) ? ($breakpointMinWidth ?? 0) : ($contextMinWidth ?? 0), ImageLibrary::getResponsiveImageMinWidth()); + + $ratio = $fileHeight / $fileWidth; + $area = $fileWidth * $fileHeight; + $pixelPrice = $fileSize / $area; + + $sizeStepMultiplier = ImageLibrary::getResponsiveImageSizeStepMultiplier(); + $widthDiffThreshold = ImageLibrary::getResponsiveImageWidthDifferenceThreshold(); + + $widths = []; + $predictedFileSize = $fileSize; + $prevWidth = $fileWidth; + + while (true) { + $predictedFileSize *= $sizeStepMultiplier; + + $newWidth = (int) floor(sqrt(($predictedFileSize / $pixelPrice) / $ratio)); + + if ($newWidth < $minWidth || $newWidth >= $prevWidth) { + break; + } + + if (($prevWidth - $newWidth) < $widthDiffThreshold) { + $prevWidth = $newWidth; + + continue; + } + + $widths[] = $newWidth; + + $prevWidth = $newWidth; + } + + return array_values(array_unique($widths)); + } +} diff --git a/src/Models/Image.php b/src/Models/Image.php new file mode 100644 index 0000000..f963297 --- /dev/null +++ b/src/Models/Image.php @@ -0,0 +1,356 @@ + $crop_data + * @property array|string|null $alt_text + * @property array|null $custom_properties + * @property CarbonInterface $created_at + * @property CarbonInterface $updated_at + * + * @psalm-property-write ImageContext|string $context + * @psalm-property-write array|null $alt_text + * + * @psalm-property-read ImageContext $context + * @psalm-property-read string|null $alt_text + * + * @method static ImageFactory factory($count = null, $state = []) + */ +#[UseFactory(ImageFactory::class)] +class Image extends Model implements Sortable +{ + use HasFactory; + use HasTranslations; + use SortableTrait; + + public $sortable = [ + 'order_column_name' => 'sort_order', + 'sort_when_creating' => true, + ]; + + public $translatable = [ + 'alt_text', + ]; + + protected $fillable = [ + 'uuid', + 'model_type', + 'model_id', + 'source_image_id', + 'context', + 'context_configuration_hash', + 'sort_order', + 'disk', + 'crop_data', + 'alt_text', + 'custom_properties', + ]; + + protected $attributes = [ + 'custom_properties' => '{}', + ]; + + public function model(): MorphTo + { + return $this->morphTo(); + } + + public function sourceImage(): BelongsTo + { + return $this->belongsTo(ImageLibrary::getSourceImageModel()); + } + + public function buildSortQuery(): Builder + { + return static::query() + ->where('model_type', $this->model_type) + ->where('model_id', $this->model_id) + ->where('context', $this->context->getKey()); + } + + public function generate(): void + { + $this->deleteFiles(); + + Bus::chain([ + Bus::batch( + collect(ImageLibrary::getBreakpointEnum()::sortedCases()) + ->map(function (ConfiguresBreakpoints $breakpoint) { + return new GenerateImageVersionJob($this->getKey(), $breakpoint); + }) + ->all() + ), + Bus::batch( + collect(ImageLibrary::getBreakpointEnum()::sortedCases()) + ->map(function (ConfiguresBreakpoints $breakpoint) { + return new GenerateResponsiveImageVersionsJob($this->getKey(), $breakpoint); + }) + ->all() + ), + ]) + ->onConnection(ImageLibrary::getDefaultQueueConnection()) + ->onQueue(ImageLibrary::getDefaultQueue()) + ->dispatch(); + } + + public function getRelativeBasePath(): string + { + return $this->sourceImage->getRelativeBasePath().'/'.$this->uuid; + } + + public function getAbsoluteBasePath(): string + { + return Storage::disk($this->disk)->path($this->getRelativeBasePath()); + } + + public function getRelativePathForBreakpoint(ConfiguresBreakpoints $breakpoint, ?string $extension = null): string + { + $extension ??= $this->sourceImage->extension; + + return $this->getRelativeBasePath().'/'.urlencode($breakpoint->getSlug()).'.'.$extension; + } + + public function getAbsolutePathForBreakpoint(ConfiguresBreakpoints $breakpoint, ?string $extension = null): string + { + return Storage::disk($this->disk)->path($this->getRelativePathForBreakpoint($breakpoint, $extension)); + } + + public function getResponsiveRelativePathsForBreakpoint(ConfiguresBreakpoints $breakpoint, ?string $extension = null): Collection + { + $extension ??= $this->sourceImage->extension; + + $files = Storage::disk($this->disk)->files($this->getRelativeBasePath()); + + return collect($files) + ->filter(function (string $file) use ($breakpoint, $extension): bool { + return ( + Str::startsWith($file, $this->getRelativeBasePath().'/'.urlencode($breakpoint->getSlug()).'_w') + || $file === $this->getRelativePathForBreakpoint($breakpoint, $extension) + ) + && Str::endsWith($file, '.'.$extension); + }) + ->values(); + } + + public function getResponsiveAbsolutePathsForBreakpoint(ConfiguresBreakpoints $breakpoint, ?string $extension = null): Collection + { + return collect($this->getResponsiveRelativePathsForBreakpoint($breakpoint, $extension)) + ->map(function (string $path): string { + return Storage::disk($this->disk)->path($path); + }); + } + + public function getForBreakpoint(ConfiguresBreakpoints $breakpoint, ?string $extension = null): ?string + { + return Storage::disk($this->disk)->get($this->getRelativePathForBreakpoint($breakpoint, $extension)); + } + + public function existsForBreakpoint(ConfiguresBreakpoints $breakpoint, ?string $extension = null): bool + { + return Storage::disk($this->disk)->exists($this->getRelativePathForBreakpoint($breakpoint, $extension)); + } + + public function missingForBreakpoint(ConfiguresBreakpoints $breakpoint, ?string $extension = null): bool + { + return Storage::disk($this->disk)->missing($this->getRelativePathForBreakpoint($breakpoint, $extension)); + } + + public function downloadForBreakpoint(ConfiguresBreakpoints $breakpoint, ?string $extension = null): StreamedResponse + { + return Storage::disk($this->disk)->download($this->getRelativePathForBreakpoint($breakpoint, $extension)); + } + + public function urlForBreakpoint(ConfiguresBreakpoints $breakpoint, ?string $extension = null): string + { + $path = $this->getRelativePathForBreakpoint($breakpoint, $extension); + + if (ImageLibrary::shouldUseTemporaryUrlsForDisk($this->disk)) { + return $this->temporaryUrlForRelativePath($path); + } + + return $this->urlForRelativePath($path); + } + + public function urlForRelativePath(string $relativePath): string + { + if (ImageLibrary::shouldUseTemporaryUrlsForDisk($this->disk)) { + return $this->temporaryUrlForRelativePath($relativePath); + } + + return Storage::disk($this->disk)->url($relativePath); + } + + public function temporaryUrlForRelativePath(string $relativePath, ?CarbonInterface $expiration = null, array $options = []): string + { + return Storage::disk($this->disk)->temporaryUrl( + $relativePath, + $expiration ?? now()->addMinutes(ImageLibrary::getTemporaryUrlExpirationMinutesForDisk($this->disk)), + $options, + ); + } + + public function deleteFiles(): void + { + Storage::disk($this->disk)->deleteDirectory($this->getRelativeBasePath()); + } + + protected static function booted(): void + { + static::saving(function (self $model) { + $model->uuid ??= (string) Str::uuid(); + + $model->updateContextConfigurationHash(); + }); + + static::created(function (self $model) { + $model->generate(); + }); + + static::updated(function (self $model) { + if ($model->wasChanged(['context_configuration_hash', 'crop_data'])) { + $model->generate(); + } + }); + + static::deleting(function (self $model) { + $model->deleteFiles(); + }); + } + + /** @return Attribute */ + protected function disk(): Attribute + { + return Attribute::make( + get: fn (?string $value): string => $value ?? $this->sourceImage->disk, + set: fn (?string $value): string => $value ?? $this->sourceImage->disk, + ); + } + + /** @return Attribute */ + protected function context(): Attribute + { + return Attribute::make( + get: fn (?string $value): ?ImageContext => ImageLibrary::getImageContextByKey($value), + set: fn (ImageContext|string $value): string => is_string($value) ? $value : $value->getKey(), + ); + } + + /** @return Attribute, string> */ + protected function cropData(): Attribute + { + return Attribute::make( + get: function (?string $value): array { + if (blank($value)) { + return $this->generateCropData(null); + } + + $data = json_decode($value, true); + + if (! is_array($data)) { + return $this->generateCropData(null); + } + + return $this->generateCropData($data); + }, + set: function (array|CropData|null $value): string { + return json_encode($this->generateCropData($value)); + }, + ); + } + + /** + * @return array{ + * alt_text: 'array', + * custom_properties: 'array', + * } + */ + protected function casts(): array + { + return [ + 'alt_text' => 'array', + 'custom_properties' => 'array', + ]; + } + + private function updateContextConfigurationHash(): void + { + $this->context_configuration_hash = $this->context->getConfigurationHash(); + } + + /** + * @param array|null|CropData $cropData + * @return array + */ + private function generateCropData(array|CropData|null $cropData): array + { + if ($cropData instanceof CropData || is_null($cropData)) { + return collect(ImageLibrary::getBreakpointEnum()::sortedCases()) + ->mapWithKeys(function (BackedEnum $case) use ($cropData): array { + return [$case->value => $cropData]; + }) + ->all(); + } + + return collect(ImageLibrary::getBreakpointEnum()::sortedCases()) + ->mapWithKeys(function (BackedEnum $case) use ($cropData): array { + $data = $cropData[$case->value] ?? null; + + if ($data instanceof CropData) { + return [$case->value => $data]; + } + + if ( + is_null($data) + || (! isset($data['width'], $data['height'])) + ) { + return [$case->value => null]; + } + + return [$case->value => CropData::make( + $data['width'], + $data['height'], + $data['x'] ?? null, + $data['y'] ?? null, + )]; + }) + ->all(); + } +} diff --git a/src/Models/SourceImage.php b/src/Models/SourceImage.php new file mode 100644 index 0000000..b34c950 --- /dev/null +++ b/src/Models/SourceImage.php @@ -0,0 +1,219 @@ +|string|null $alt_text + * @property array|null $custom_properties + * @property-read \Illuminate\Database\Eloquent\Collection $images + * @property CarbonInterface $created_at + * @property CarbonInterface $updated_at + * + * @psalm-property-write array|null $alt_text + * + * @psalm-property-read string|null $alt_text + */ +#[UseFactory(SourceImageFactory::class)] +class SourceImage extends Model +{ + use HasFactory; + use HasTranslations; + + public $translatable = [ + 'alt_text', + ]; + + protected $fillable = [ + 'uuid', + 'disk', + 'name', + 'extension', + 'mime_type', + 'width', + 'height', + 'size', + 'alt_text', + 'custom_properties', + ]; + + protected $attributes = [ + 'custom_properties' => '{}', + ]; + + public static function upload(UploadedFile $file, array $attributes = []): self + { + try { + DB::beginTransaction(); + + $temporaryPath = new TemporaryDirectory()->create()->path($file->getClientOriginalName()); + + $optimizedFile = ImageLibrary::getSpatieImage() + ->loadFile($file->getRealPath()) + ->optimize() + ->save($temporaryPath); + + $model = static::query() + ->create(array_merge( + [ + 'disk' => ImageLibrary::getDefaultDisk(), + 'name' => pathinfo($file->getClientOriginalName(), PATHINFO_FILENAME), + ], + $attributes, + [ + 'extension' => $file->getClientOriginalExtension(), + 'mime_type' => $file->getClientMimeType(), + 'width' => $optimizedFile->getWidth(), + 'height' => $optimizedFile->getHeight(), + 'size' => filesize($temporaryPath), + ] + )); + + // Create directory + Storage::disk($model->disk)->makeDirectory($model->getRelativeBasePath()); + + $optimizedFile->save($model->getAbsolutePath()); + + DB::commit(); + + return $model; + } catch (Throwable $exception) { + DB::rollBack(); + + if (isset($model)) { + $model->deleteFiles(); + } + + throw $exception; + } + } + + public function images(): HasMany + { + return $this->hasMany(ImageLibrary::getImageModel()); + } + + public function getRelativeBasePath(): string + { + return ImageLibrary::getBasePath().'/'.$this->uuid; + } + + public function getAbsoluteBasePath(): string + { + return Storage::disk($this->disk)->path($this->getRelativeBasePath()); + } + + public function getRelativePath(): string + { + return $this->getRelativeBasePath().'/original'.'.'.$this->extension; + } + + public function getAbsolutePath(): string + { + return Storage::disk($this->disk)->path($this->getRelativePath()); + } + + public function get(): ?string + { + return Storage::disk($this->disk)->get($this->getRelativePath()); + } + + public function exists(): bool + { + return Storage::disk($this->disk)->exists($this->getRelativePath()); + } + + public function missing(): bool + { + return Storage::disk($this->disk)->missing($this->getRelativePath()); + } + + public function download(): StreamedResponse + { + return Storage::disk($this->disk)->download($this->getRelativePath()); + } + + public function url(): string + { + if (ImageLibrary::shouldUseTemporaryUrlsForDisk($this->disk)) { + return $this->temporaryUrl(); + } + + return Storage::disk($this->disk)->url($this->getRelativePath()); + } + + public function temporaryUrl(?CarbonInterface $expiration = null, array $options = []): string + { + return Storage::disk($this->disk)->temporaryUrl( + $this->getRelativePath(), + $expiration ?? now()->addMinutes(ImageLibrary::getTemporaryUrlExpirationMinutesForDisk($this->disk)), + $options, + ); + } + + public function deleteFiles(): void + { + Storage::disk($this->disk)->deleteDirectory($this->getRelativeBasePath()); + } + + protected static function booted(): void + { + static::saving(function (self $model) { + $model->uuid ??= (string) Str::uuid(); + }); + + static::deleting(function (self $model) { + $model->deleteFiles(); + }); + } + + /** + * @return array{ + * alt_text: 'array', + * custom_properties: 'array', + * } + */ + protected function casts(): array + { + return [ + 'alt_text' => 'array', + 'custom_properties' => 'array', + ]; + } + + /** @return Attribute */ + protected function nameWithExtension(): Attribute + { + return Attribute::get( + fn (): string => $this->name.'.'.$this->extension, + ); + } +} diff --git a/src/Providers/ImageLibraryServiceProvider.php b/src/Providers/ImageLibraryServiceProvider.php new file mode 100644 index 0000000..875255c --- /dev/null +++ b/src/Providers/ImageLibraryServiceProvider.php @@ -0,0 +1,25 @@ +imageContexts()); + } + + /** @return array */ + public function imageContexts(): array + { + // @codeCoverageIgnoreStart + return []; + // @codeCoverageIgnoreEnd + } +} diff --git a/src/Traits/HasImages.php b/src/Traits/HasImages.php new file mode 100644 index 0000000..653085d --- /dev/null +++ b/src/Traits/HasImages.php @@ -0,0 +1,95 @@ +morphMany(ImageLibrary::getImageModel(), 'model'); + } + + public function attachImage(SourceImage $image, array $attributes = [], string $relation = 'images'): Image + { + if (! array_key_exists('context', $attributes)) { + throw new InvalidArgumentException('You must provide a context when attaching an image.'); + } + + try { + DB::beginTransaction(); + + $relationType = $this->validateRelationType($relation); + + $model = new (ImageLibrary::getImageModel())(array_merge( + [ + 'disk' => $image->disk, + ], + $attributes, + [ + 'model_type' => $this->getMorphClass(), + 'model_id' => $this->getKey(), + 'source_image_id' => $image->id, + ] + )); + + if ($relationType === MorphOne::class) { + $this->{$relation} + ?->delete(); + } else { + $context = $attributes['context'] instanceof ImageContext + ? $attributes['context'] + : ImageLibrary::getImageContextByKey($attributes['context']); + + if ($context->getAllowsMultiple() === false) { + $this->{$relation}() + ->where('context', $context->getKey()) + ->get() + ->each->delete(); + } + } + + $this->{$relation}()->save($model); + + $this->unsetRelation($relation); + + DB::commit(); + + return $model; + } catch (Throwable $e) { + DB::rollBack(); + + throw $e; + } + } + + private function validateRelationType(string $relation): string + { + if (! method_exists($this, $relation)) { + throw new InvalidArgumentException("Relation {$relation} does not exist on the model."); + } + + $instance = $this->{$relation}(); + + if ($instance instanceof MorphOne) { + return MorphOne::class; + } + + if ($instance instanceof MorphMany) { + return MorphMany::class; + } + + throw new InvalidArgumentException("Relation {$relation} is not a valid MorphOne or MorphMany relation."); + } +} diff --git a/tests/ArchTest.php b/tests/ArchTest.php new file mode 100644 index 0000000..0160ae2 --- /dev/null +++ b/tests/ArchTest.php @@ -0,0 +1,7 @@ +expect(['dd', 'dump', 'ray']) + ->each->not->toBeUsed(); diff --git a/tests/Fixtures/Factories/UserFactory.php b/tests/Fixtures/Factories/UserFactory.php new file mode 100644 index 0000000..f4b6c26 --- /dev/null +++ b/tests/Fixtures/Factories/UserFactory.php @@ -0,0 +1,36 @@ + + */ +class UserFactory extends Factory +{ + protected static ?string $password; + + public function definition(): array + { + return [ + 'name' => fake()->name(), + 'email' => fake()->unique()->safeEmail(), + 'email_verified_at' => now(), + 'password' => static::$password ??= Hash::make('password'), + 'remember_token' => Str::random(10), + ]; + } + + public function unverified(): static + { + return $this->state(fn (array $attributes): array => [ + 'email_verified_at' => null, + ]); + } +} diff --git a/tests/Fixtures/Models/User.php b/tests/Fixtures/Models/User.php new file mode 100644 index 0000000..fca4244 --- /dev/null +++ b/tests/Fixtures/Models/User.php @@ -0,0 +1,64 @@ +morphOne(Image::class, 'model'); + } + + public function gallery(): MorphMany + { + return $this->morphMany(Image::class, 'model'); + } + + public function friends(): HasMany + { + return $this->hasMany(self::class, 'user_id'); + } + + /** + * @return array{ + * email_verified_at: 'datetime', + * password: 'hashed', + * } + */ + protected function casts(): array + { + return [ + 'email_verified_at' => 'datetime', + 'password' => 'hashed', + ]; + } +} diff --git a/tests/Fixtures/Providers/ImageLibraryServiceProvider.php b/tests/Fixtures/Providers/ImageLibraryServiceProvider.php new file mode 100644 index 0000000..d9b6409 --- /dev/null +++ b/tests/Fixtures/Providers/ImageLibraryServiceProvider.php @@ -0,0 +1,45 @@ +aspectRatio( + AspectRatio::make(1, 1) + ) + ->maxWidth([ + Breakpoint::Small->value => 300, + Breakpoint::Medium->value => 600, + Breakpoint::Large->value => 900, + Breakpoint::ExtraLarge->value => 1200, + Breakpoint::ExtraExtraLarge->value => 1500, + ]) + ->allowsMultiple(false), + ImageContext::make('context-multiple') + ->aspectRatio( + AspectRatio::make(1, 1) + ) + ->maxWidth([ + Breakpoint::Small->value => 300, + Breakpoint::Medium->value => 600, + Breakpoint::Large->value => 900, + Breakpoint::ExtraLarge->value => 1200, + Breakpoint::ExtraExtraLarge->value => 1500, + ]) + ->allowsMultiple(true), + ]; + } +} diff --git a/tests/Pest.php b/tests/Pest.php new file mode 100644 index 0000000..bbd3383 --- /dev/null +++ b/tests/Pest.php @@ -0,0 +1,12 @@ +in(__DIR__) + ->beforeEach(function () { + Storage::fake('public'); + }); diff --git a/tests/TestCase.php b/tests/TestCase.php new file mode 100644 index 0000000..f81be25 --- /dev/null +++ b/tests/TestCase.php @@ -0,0 +1,43 @@ +set('database.default', 'testing'); + config()->set('app.key', 'base64:'.base64_encode(random_bytes(32))); + + Model::shouldBeStrict(true); + + foreach (\Illuminate\Support\Facades\File::allFiles(__DIR__.'/../database/migrations') as $migration) { + (include $migration->getRealPath())->up(); + } + } + + protected function defineDatabaseMigrations(): void + { + $this->loadLaravelMigrations(); + } + + protected function getPackageProviders($app) + { + return [ + ImageLibraryServiceProvider::class, + TestFixtureImageLibraryServiceProvider::class, + ]; + } +} diff --git a/tests/Unit/Entities/AspectRatioTest.php b/tests/Unit/Entities/AspectRatioTest.php new file mode 100644 index 0000000..54753dd --- /dev/null +++ b/tests/Unit/Entities/AspectRatioTest.php @@ -0,0 +1,34 @@ +toBeInstanceOf(AspectRatio::class) + ->horizontal->toBe(4) + ->vertical->toBe(3); +}); + +it('can be converted to string', function () { + $aspectRatio = new AspectRatio(4, 3); + + expect((string) $aspectRatio) + ->toBe('4:3'); + + expect($aspectRatio->toString()) + ->toBe('4:3'); +}); + +it('can be converted to array', function () { + $aspectRatio = new AspectRatio(16, 9); + + expect($aspectRatio->toArray()) + ->toBe([ + 'horizontal' => 16, + 'vertical' => 9, + ]); +}); diff --git a/tests/Unit/Entities/CropDataTest.php b/tests/Unit/Entities/CropDataTest.php new file mode 100644 index 0000000..f649d48 --- /dev/null +++ b/tests/Unit/Entities/CropDataTest.php @@ -0,0 +1,28 @@ +toBeInstanceOf(CropData::class) + ->width->toBe(100) + ->height->toBe(100) + ->x->toBe(10) + ->y->toBe(20); +}); + +it('can be converted to array', function () { + $cropData = CropData::make(100, 100, 10, 20); + + expect($cropData->toArray()) + ->toBe([ + 'width' => 100, + 'height' => 100, + 'x' => 10, + 'y' => 20, + ]); +}); diff --git a/tests/Unit/Entities/ImageContextTest.php b/tests/Unit/Entities/ImageContextTest.php new file mode 100644 index 0000000..f06ff88 --- /dev/null +++ b/tests/Unit/Entities/ImageContextTest.php @@ -0,0 +1,883 @@ +toBeInstanceOf(ImageContext::class); +}); + +it('can get and get the key', function () { + $imageContext = new ImageContext('thumbnail'); + + expect($imageContext->getKey()) + ->toBe('thumbnail'); +}); + +it('can get a hash string of the configuration', function () { + $imageContext1 = ImageContext::make('thumbnail') + ->label('Thumbnail Image') + ->aspectRatio(AspectRatio::make(16, 9)) + ->blur(5) + ->grayscale(true) + ->sepia(false); + + $imageContext2 = ImageContext::make('thumbnail') + ->label('Thumbnail Image') + ->aspectRatio(AspectRatio::make(16, 9)) + ->blur(5) + ->grayscale(true) + ->sepia(false); + + expect($imageContext1->getConfigurationHash()) + ->toBeString(); + + expect($imageContext1->getConfigurationHash()) + ->toEqual($imageContext2->getConfigurationHash()); +}); + +describe('label', function () { + it('can set and get the label as string', function () { + $imageContext = ImageContext::make('thumbnail') + ->label('Thumbnail Image'); + + expect($imageContext->getLabel()) + ->toBe('Thumbnail Image'); + }); + + it('can set and get the label as closure', function () { + $imageContext = ImageContext::make('thumbnail') + ->label(function (ImageContext $imageContext) { + return Str::title($imageContext->getKey()).' Image from Closure'; + }); + + expect($imageContext->getLabel()) + ->toBe('Thumbnail Image from Closure'); + }); +}); + +describe('aspect ratio', function () { + it('can set and get the aspect ratio for all breakpoints', function () { + $aspectRatio = AspectRatio::make(16, 9); + + $imageContext = ImageContext::make('thumbnail') + ->aspectRatio($aspectRatio); + + expect($imageContext->getAspectRatioByBreakpoint()) + ->toHaveCount(count(Breakpoint::cases())) + ->each->toBe($aspectRatio); + }); + + it('can set and get the aspect ratio per breakpoint', function () { + $mobileAspectRatio = AspectRatio::make(4, 3); + $desktopAspectRatio = AspectRatio::make(16, 9); + + $imageContext = ImageContext::make('thumbnail') + ->aspectRatio([ + Breakpoint::Small->value => $mobileAspectRatio, + Breakpoint::Medium->value => $mobileAspectRatio, + Breakpoint::Large->value => $desktopAspectRatio, + Breakpoint::ExtraLarge->value => $desktopAspectRatio, + Breakpoint::ExtraExtraLarge->value => $desktopAspectRatio, + ]); + + expect($imageContext->getAspectRatioByBreakpoint()) + ->toHaveCount(count(Breakpoint::cases())) + ->and($imageContext->getAspectRatioForBreakpoint(Breakpoint::Small))->toBe($mobileAspectRatio) + ->and($imageContext->getAspectRatioForBreakpoint(Breakpoint::Medium))->toBe($mobileAspectRatio) + ->and($imageContext->getAspectRatioForBreakpoint(Breakpoint::Large))->toBe($desktopAspectRatio) + ->and($imageContext->getAspectRatioForBreakpoint(Breakpoint::ExtraLarge))->toBe($desktopAspectRatio) + ->and($imageContext->getAspectRatioForBreakpoint(Breakpoint::ExtraExtraLarge))->toBe($desktopAspectRatio); + }); + + it('can set and get the aspect ratio for a specific breakpoint', function () { + $aspectRatio1 = AspectRatio::make(4, 3); + $aspectRatio2 = AspectRatio::make(16, 9); + + $imageContext = ImageContext::make('thumbnail') + ->aspectRatio($aspectRatio2) + ->aspectRatioForBreakpoint(Breakpoint::Small, $aspectRatio1); + + expect($imageContext->getAspectRatioByBreakpoint()) + ->toHaveCount(count(Breakpoint::cases())) + ->and($imageContext->getAspectRatioForBreakpoint(Breakpoint::Small))->toBe($aspectRatio1) + ->and($imageContext->getAspectRatioForBreakpoint(Breakpoint::Medium))->toBe($aspectRatio2) + ->and($imageContext->getAspectRatioForBreakpoint(Breakpoint::Large))->toBe($aspectRatio2) + ->and($imageContext->getAspectRatioForBreakpoint(Breakpoint::ExtraLarge))->toBe($aspectRatio2) + ->and($imageContext->getAspectRatioForBreakpoint(Breakpoint::ExtraExtraLarge))->toBe($aspectRatio2); + }); + + it('can set and get the aspect ratio for all breakpoints after and including a specific breakpoint', function () { + $aspectRatio1 = AspectRatio::make(4, 3); + $aspectRatio2 = AspectRatio::make(16, 9); + + $imageContext = ImageContext::make('thumbnail') + ->aspectRatio($aspectRatio1) + ->aspectRatioFromBreakpoint(Breakpoint::Large, $aspectRatio2); + + expect($imageContext->getAspectRatioByBreakpoint()) + ->toHaveCount(count(Breakpoint::cases())) + ->and($imageContext->getAspectRatioForBreakpoint(Breakpoint::Small))->toBe($aspectRatio1) + ->and($imageContext->getAspectRatioForBreakpoint(Breakpoint::Medium))->toBe($aspectRatio1) + ->and($imageContext->getAspectRatioForBreakpoint(Breakpoint::Large))->toBe($aspectRatio2) + ->and($imageContext->getAspectRatioForBreakpoint(Breakpoint::ExtraLarge))->toBe($aspectRatio2) + ->and($imageContext->getAspectRatioForBreakpoint(Breakpoint::ExtraExtraLarge))->toBe($aspectRatio2); + }); + + it('can set and get the aspect ratio for all breakpoints before and including a specific breakpoint', function () { + $aspectRatio1 = AspectRatio::make(4, 3); + $aspectRatio2 = AspectRatio::make(16, 9); + + $imageContext = ImageContext::make('thumbnail') + ->aspectRatio($aspectRatio1) + ->aspectRatioToBreakpoint(Breakpoint::Large, $aspectRatio2); + + expect($imageContext->getAspectRatioByBreakpoint()) + ->toHaveCount(count(Breakpoint::cases())) + ->and($imageContext->getAspectRatioForBreakpoint(Breakpoint::Small))->toBe($aspectRatio2) + ->and($imageContext->getAspectRatioForBreakpoint(Breakpoint::Medium))->toBe($aspectRatio2) + ->and($imageContext->getAspectRatioForBreakpoint(Breakpoint::Large))->toBe($aspectRatio2) + ->and($imageContext->getAspectRatioForBreakpoint(Breakpoint::ExtraLarge))->toBe($aspectRatio1) + ->and($imageContext->getAspectRatioForBreakpoint(Breakpoint::ExtraExtraLarge))->toBe($aspectRatio1); + }); + + it('can set and get the aspect ratio for all breakpoints between 2 breakpoints', function () { + $aspectRatio1 = AspectRatio::make(4, 3); + $aspectRatio2 = AspectRatio::make(16, 9); + + $imageContext = ImageContext::make('thumbnail') + ->aspectRatio($aspectRatio1) + ->aspectRatioBetweenBreakpoints(Breakpoint::Medium, Breakpoint::ExtraLarge, $aspectRatio2); + + expect($imageContext->getAspectRatioByBreakpoint()) + ->toHaveCount(count(Breakpoint::cases())) + ->and($imageContext->getAspectRatioForBreakpoint(Breakpoint::Small))->toBe($aspectRatio1) + ->and($imageContext->getAspectRatioForBreakpoint(Breakpoint::Medium))->toBe($aspectRatio2) + ->and($imageContext->getAspectRatioForBreakpoint(Breakpoint::Large))->toBe($aspectRatio2) + ->and($imageContext->getAspectRatioForBreakpoint(Breakpoint::ExtraLarge))->toBe($aspectRatio2) + ->and($imageContext->getAspectRatioForBreakpoint(Breakpoint::ExtraExtraLarge))->toBe($aspectRatio1); + }); + + it('throws an exception when aspect ratio for a breakpoint is not defined', function () { + ImageContext::make('thumbnail') + ->aspectRatio([ + Breakpoint::Small->value => AspectRatio::make(4, 3), + Breakpoint::Large->value => AspectRatio::make(16, 9), + ]); + })->throws(InvalidArgumentException::class, "Aspect ratio for breakpoint 'md' is not defined for ImageContext with key 'thumbnail'."); +}); + +describe('minWidth', function () { + it('can set and get the min width for all breakpoints', function () { + $imageContext = ImageContext::make('thumbnail') + ->minWidth(320); + + expect($imageContext->getMinWidthByBreakpoint()) + ->toHaveCount(count(Breakpoint::cases())) + ->each->toBe(320); + }); + + it('can set and get the min width per breakpoint', function () { + $imageContext = ImageContext::make('thumbnail') + ->minWidth([ + Breakpoint::Small->value => 320, + Breakpoint::Medium->value => 480, + Breakpoint::Large->value => 768, + Breakpoint::ExtraLarge->value => 1024, + Breakpoint::ExtraExtraLarge->value => 1280, + ]); + + expect($imageContext->getMinWidthByBreakpoint()) + ->toHaveCount(count(Breakpoint::cases())) + ->and($imageContext->getMinWidthForBreakpoint(Breakpoint::Small))->toBe(320) + ->and($imageContext->getMinWidthForBreakpoint(Breakpoint::Medium))->toBe(480) + ->and($imageContext->getMinWidthForBreakpoint(Breakpoint::Large))->toBe(768) + ->and($imageContext->getMinWidthForBreakpoint(Breakpoint::ExtraLarge))->toBe(1024) + ->and($imageContext->getMinWidthForBreakpoint(Breakpoint::ExtraExtraLarge))->toBe(1280); + }); + + it('can set and get the min width for a specific breakpoint', function () { + $imageContext = ImageContext::make('thumbnail') + ->minWidth(320) + ->minWidthForBreakpoint(Breakpoint::Small, 480); + + expect($imageContext->getMinWidthByBreakpoint()) + ->toHaveCount(count(Breakpoint::cases())) + ->and($imageContext->getMinWidthForBreakpoint(Breakpoint::Small))->toBe(480) + ->and($imageContext->getMinWidthForBreakpoint(Breakpoint::Medium))->toBe(320) + ->and($imageContext->getMinWidthForBreakpoint(Breakpoint::Large))->toBe(320) + ->and($imageContext->getMinWidthForBreakpoint(Breakpoint::ExtraLarge))->toBe(320) + ->and($imageContext->getMinWidthForBreakpoint(Breakpoint::ExtraExtraLarge))->toBe(320); + }); + + it('can set and get the min width for all breakpoints after and including a specific breakpoint', function () { + $imageContext = ImageContext::make('thumbnail') + ->minWidth(320) + ->minWidthFromBreakpoint(Breakpoint::Large, 768); + + expect($imageContext->getMinWidthByBreakpoint()) + ->toHaveCount(count(Breakpoint::cases())) + ->and($imageContext->getMinWidthForBreakpoint(Breakpoint::Small))->toBe(320) + ->and($imageContext->getMinWidthForBreakpoint(Breakpoint::Medium))->toBe(320) + ->and($imageContext->getMinWidthForBreakpoint(Breakpoint::Large))->toBe(768) + ->and($imageContext->getMinWidthForBreakpoint(Breakpoint::ExtraLarge))->toBe(768) + ->and($imageContext->getMinWidthForBreakpoint(Breakpoint::ExtraExtraLarge))->toBe(768); + }); + + it('can set and get the min width for all breakpoints before and including a specific breakpoint', function () { + $imageContext = ImageContext::make('thumbnail') + ->minWidth(768) + ->minWidthToBreakpoint(Breakpoint::Large, 320); + + expect($imageContext->getMinWidthByBreakpoint()) + ->toHaveCount(count(Breakpoint::cases())) + ->and($imageContext->getMinWidthForBreakpoint(Breakpoint::Small))->toBe(320) + ->and($imageContext->getMinWidthForBreakpoint(Breakpoint::Medium))->toBe(320) + ->and($imageContext->getMinWidthForBreakpoint(Breakpoint::Large))->toBe(320) + ->and($imageContext->getMinWidthForBreakpoint(Breakpoint::ExtraLarge))->toBe(768) + ->and($imageContext->getMinWidthForBreakpoint(Breakpoint::ExtraExtraLarge))->toBe(768); + }); + + it('can set and get the min width for all breakpoints between 2 breakpoints', function () { + $imageContext = ImageContext::make('thumbnail') + ->minWidth(320) + ->minWidthBetweenBreakpoints(Breakpoint::Medium, Breakpoint::ExtraLarge, 768); + + expect($imageContext->getMinWidthByBreakpoint()) + ->toHaveCount(count(Breakpoint::cases())) + ->and($imageContext->getMinWidthForBreakpoint(Breakpoint::Small))->toBe(320) + ->and($imageContext->getMinWidthForBreakpoint(Breakpoint::Medium))->toBe(768) + ->and($imageContext->getMinWidthForBreakpoint(Breakpoint::Large))->toBe(768) + ->and($imageContext->getMinWidthForBreakpoint(Breakpoint::ExtraLarge))->toBe(768) + ->and($imageContext->getMinWidthForBreakpoint(Breakpoint::ExtraExtraLarge))->toBe(320); + }); + + it('throws an exception when min width for a breakpoint is not defined', function () { + ImageContext::make('thumbnail') + ->minWidth([ + Breakpoint::Small->value => 320, + Breakpoint::Large->value => 768, + ]); + })->throws(InvalidArgumentException::class, "Min width for breakpoint 'md' is not defined for ImageContext with key 'thumbnail'."); +}); + +describe('maxWidth', function () { + it('can set and get the min width for all breakpoints', function () { + $imageContext = ImageContext::make('thumbnail') + ->maxWidth(320); + + expect($imageContext->getMaxWidthByBreakpoint()) + ->toHaveCount(count(Breakpoint::cases())) + ->each->toBe(320); + }); + + it('can set and get the min width per breakpoint', function () { + $imageContext = ImageContext::make('thumbnail') + ->maxWidth([ + Breakpoint::Small->value => 320, + Breakpoint::Medium->value => 480, + Breakpoint::Large->value => 768, + Breakpoint::ExtraLarge->value => 1024, + Breakpoint::ExtraExtraLarge->value => 1280, + ]); + + expect($imageContext->getMaxWidthByBreakpoint()) + ->toHaveCount(count(Breakpoint::cases())) + ->and($imageContext->getMaxWidthForBreakpoint(Breakpoint::Small))->toBe(320) + ->and($imageContext->getMaxWidthForBreakpoint(Breakpoint::Medium))->toBe(480) + ->and($imageContext->getMaxWidthForBreakpoint(Breakpoint::Large))->toBe(768) + ->and($imageContext->getMaxWidthForBreakpoint(Breakpoint::ExtraLarge))->toBe(1024) + ->and($imageContext->getMaxWidthForBreakpoint(Breakpoint::ExtraExtraLarge))->toBe(1280); + }); + + it('can set and get the min width for a specific breakpoint', function () { + $imageContext = ImageContext::make('thumbnail') + ->maxWidth(320) + ->maxWidthForBreakpoint(Breakpoint::Small, 480); + + expect($imageContext->getMaxWidthByBreakpoint()) + ->toHaveCount(count(Breakpoint::cases())) + ->and($imageContext->getMaxWidthForBreakpoint(Breakpoint::Small))->toBe(480) + ->and($imageContext->getMaxWidthForBreakpoint(Breakpoint::Medium))->toBe(320) + ->and($imageContext->getMaxWidthForBreakpoint(Breakpoint::Large))->toBe(320) + ->and($imageContext->getMaxWidthForBreakpoint(Breakpoint::ExtraLarge))->toBe(320) + ->and($imageContext->getMaxWidthForBreakpoint(Breakpoint::ExtraExtraLarge))->toBe(320); + }); + + it('can set and get the min width for all breakpoints after and including a specific breakpoint', function () { + $imageContext = ImageContext::make('thumbnail') + ->maxWidth(320) + ->maxWidthFromBreakpoint(Breakpoint::Large, 768); + + expect($imageContext->getMaxWidthByBreakpoint()) + ->toHaveCount(count(Breakpoint::cases())) + ->and($imageContext->getMaxWidthForBreakpoint(Breakpoint::Small))->toBe(320) + ->and($imageContext->getMaxWidthForBreakpoint(Breakpoint::Medium))->toBe(320) + ->and($imageContext->getMaxWidthForBreakpoint(Breakpoint::Large))->toBe(768) + ->and($imageContext->getMaxWidthForBreakpoint(Breakpoint::ExtraLarge))->toBe(768) + ->and($imageContext->getMaxWidthForBreakpoint(Breakpoint::ExtraExtraLarge))->toBe(768); + }); + + it('can set and get the min width for all breakpoints before and including a specific breakpoint', function () { + $imageContext = ImageContext::make('thumbnail') + ->maxWidth(768) + ->maxWidthToBreakpoint(Breakpoint::Large, 320); + + expect($imageContext->getMaxWidthByBreakpoint()) + ->toHaveCount(count(Breakpoint::cases())) + ->and($imageContext->getMaxWidthForBreakpoint(Breakpoint::Small))->toBe(320) + ->and($imageContext->getMaxWidthForBreakpoint(Breakpoint::Medium))->toBe(320) + ->and($imageContext->getMaxWidthForBreakpoint(Breakpoint::Large))->toBe(320) + ->and($imageContext->getMaxWidthForBreakpoint(Breakpoint::ExtraLarge))->toBe(768) + ->and($imageContext->getMaxWidthForBreakpoint(Breakpoint::ExtraExtraLarge))->toBe(768); + }); + + it('can set and get the min width for all breakpoints between 2 breakpoints', function () { + $imageContext = ImageContext::make('thumbnail') + ->maxWidth(320) + ->maxWidthBetweenBreakpoints(Breakpoint::Medium, Breakpoint::ExtraLarge, 768); + + expect($imageContext->getMaxWidthByBreakpoint()) + ->toHaveCount(count(Breakpoint::cases())) + ->and($imageContext->getMaxWidthForBreakpoint(Breakpoint::Small))->toBe(320) + ->and($imageContext->getMaxWidthForBreakpoint(Breakpoint::Medium))->toBe(768) + ->and($imageContext->getMaxWidthForBreakpoint(Breakpoint::Large))->toBe(768) + ->and($imageContext->getMaxWidthForBreakpoint(Breakpoint::ExtraLarge))->toBe(768) + ->and($imageContext->getMaxWidthForBreakpoint(Breakpoint::ExtraExtraLarge))->toBe(320); + }); + + it('throws an exception when min width for a breakpoint is not defined', function () { + ImageContext::make('thumbnail') + ->maxWidth([ + Breakpoint::Small->value => 320, + Breakpoint::Large->value => 768, + ]); + })->throws(InvalidArgumentException::class, "Max width for breakpoint 'md' is not defined for ImageContext with key 'thumbnail'."); +}); + +describe('cropPosition', function () { + it('can set and get the crop position for all breakpoints', function () { + $imageContext = ImageContext::make('thumbnail') + ->cropPosition('center'); + + expect($imageContext->getCropPositionByBreakpoint()) + ->toHaveCount(count(Breakpoint::cases())) + ->each->toBe(CropPosition::Center); + }); + + it('can set and get the crop position per breakpoint', function () { + $imageContext = ImageContext::make('thumbnail') + ->cropPosition([ + Breakpoint::Small->value => 'top', + Breakpoint::Medium->value => 'bottom', + Breakpoint::Large->value => 'left', + Breakpoint::ExtraLarge->value => 'right', + Breakpoint::ExtraExtraLarge->value => 'center', + ]); + + expect($imageContext->getCropPositionByBreakpoint()) + ->toHaveCount(count(Breakpoint::cases())) + ->and($imageContext->getCropPositionForBreakpoint(Breakpoint::Small))->toBe(CropPosition::Top) + ->and($imageContext->getCropPositionForBreakpoint(Breakpoint::Medium))->toBe(CropPosition::Bottom) + ->and($imageContext->getCropPositionForBreakpoint(Breakpoint::Large))->toBe(CropPosition::Left) + ->and($imageContext->getCropPositionForBreakpoint(Breakpoint::ExtraLarge))->toBe(CropPosition::Right) + ->and($imageContext->getCropPositionForBreakpoint(Breakpoint::ExtraExtraLarge))->toBe(CropPosition::Center); + }); + + it('falls back to the default crop position if not set', function () { + $imageContext = ImageContext::make('thumbnail'); + + expect($imageContext->getCropPositionForBreakpoint(Breakpoint::Small)) + ->toBe(ImageLibrary::getDefaultCropPosition()); + }); + + it('can use the spatie CropPosition enums', function () { + $imageContext = ImageContext::make('thumbnail') + ->cropPosition(CropPosition::Center); + + expect($imageContext->getCropPositionForBreakpoint(Breakpoint::Small)) + ->toBe(CropPosition::Center); + }); + + it('throws an exception when crop position for a breakpoint is not defined', function () { + ImageContext::make('thumbnail') + ->cropPosition([ + Breakpoint::Small->value => 'top', + Breakpoint::Large->value => 'bottom', + ]); + })->throws(InvalidArgumentException::class, "Crop position for breakpoint 'md' is not defined for ImageContext with key 'thumbnail'."); + + it('can set and get the crop position for a specific breakpoint', function () { + $imageContext = ImageContext::make('thumbnail') + ->cropPosition('center') + ->cropPositionForBreakpoint(Breakpoint::Small, 'topLeft'); + + expect($imageContext->getCropPositionByBreakpoint()) + ->toHaveCount(count(Breakpoint::cases())) + ->and($imageContext->getCropPositionForBreakpoint(Breakpoint::Small))->toBe(CropPosition::TopLeft) + ->and($imageContext->getCropPositionForBreakpoint(Breakpoint::Medium))->toBe(CropPosition::Center) + ->and($imageContext->getCropPositionForBreakpoint(Breakpoint::Large))->toBe(CropPosition::Center) + ->and($imageContext->getCropPositionForBreakpoint(Breakpoint::ExtraLarge))->toBe(CropPosition::Center) + ->and($imageContext->getCropPositionForBreakpoint(Breakpoint::ExtraExtraLarge))->toBe(CropPosition::Center); + }); + + it('can set and get the crop position for all breakpoints after and including a specific breakpoint', function () { + $imageContext = ImageContext::make('thumbnail') + ->cropPosition('topLeft') + ->cropPositionFromBreakpoint(Breakpoint::Large, 'bottomRight'); + + expect($imageContext->getCropPositionByBreakpoint()) + ->toHaveCount(count(Breakpoint::cases())) + ->and($imageContext->getCropPositionForBreakpoint(Breakpoint::Small))->toBe(CropPosition::TopLeft) + ->and($imageContext->getCropPositionForBreakpoint(Breakpoint::Medium))->toBe(CropPosition::TopLeft) + ->and($imageContext->getCropPositionForBreakpoint(Breakpoint::Large))->toBe(CropPosition::BottomRight) + ->and($imageContext->getCropPositionForBreakpoint(Breakpoint::ExtraLarge))->toBe(CropPosition::BottomRight) + ->and($imageContext->getCropPositionForBreakpoint(Breakpoint::ExtraExtraLarge))->toBe(CropPosition::BottomRight); + }); + + it('can set and get the crop position for all breakpoints before and including a specific breakpoint', function () { + $imageContext = ImageContext::make('thumbnail') + ->cropPosition('topLeft') + ->cropPositionToBreakpoint(Breakpoint::Large, 'bottomRight'); + + expect($imageContext->getCropPositionByBreakpoint()) + ->toHaveCount(count(Breakpoint::cases())) + ->and($imageContext->getCropPositionForBreakpoint(Breakpoint::Small))->toBe(CropPosition::BottomRight) + ->and($imageContext->getCropPositionForBreakpoint(Breakpoint::Medium))->toBe(CropPosition::BottomRight) + ->and($imageContext->getCropPositionForBreakpoint(Breakpoint::Large))->toBe(CropPosition::BottomRight) + ->and($imageContext->getCropPositionForBreakpoint(Breakpoint::ExtraLarge))->toBe(CropPosition::TopLeft) + ->and($imageContext->getCropPositionForBreakpoint(Breakpoint::ExtraExtraLarge))->toBe(CropPosition::TopLeft); + }); + + it('can set and get the crop position for all breakpoints between 2 breakpoints', function () { + $imageContext = ImageContext::make('thumbnail') + ->cropPosition('topLeft') + ->cropPositionBetweenBreakpoints(Breakpoint::Medium, Breakpoint::ExtraLarge, 'bottomRight'); + + expect($imageContext->getCropPositionByBreakpoint()) + ->toHaveCount(count(Breakpoint::cases())) + ->and($imageContext->getCropPositionForBreakpoint(Breakpoint::Small))->toBe(CropPosition::TopLeft) + ->and($imageContext->getCropPositionForBreakpoint(Breakpoint::Medium))->toBe(CropPosition::BottomRight) + ->and($imageContext->getCropPositionForBreakpoint(Breakpoint::Large))->toBe(CropPosition::BottomRight) + ->and($imageContext->getCropPositionForBreakpoint(Breakpoint::ExtraLarge))->toBe(CropPosition::BottomRight) + ->and($imageContext->getCropPositionForBreakpoint(Breakpoint::ExtraExtraLarge))->toBe(CropPosition::TopLeft); + }); +}); + +describe('blur', function () { + it('can set and get the blur for all breakpoints', function () { + $imageContext = ImageContext::make('thumbnail') + ->blur(5); + + expect($imageContext->getBlurByBreakpoint()) + ->toHaveCount(count(Breakpoint::cases())) + ->each->toBe(5); + }); + + test('blur must be at least 0', function () { + ImageContext::make('thumbnail') + ->blur(-1); + })->throws(InvalidArgumentException::class, "Blur value must be between 0 and 100 for ImageContext with key 'thumbnail'."); + + test('blur must be at least 0 for each breakpoint', function () { + ImageContext::make('thumbnail') + ->blur([ + Breakpoint::Small->value => 5, + Breakpoint::Medium->value => -2, + ]); + })->throws(InvalidArgumentException::class, "Blur value for breakpoint 'md' must be between 0 and 100 for ImageContext with key 'thumbnail'."); + + test('blur must be at most 100', function () { + ImageContext::make('thumbnail') + ->blur(101); + })->throws(InvalidArgumentException::class, "Blur value must be between 0 and 100 for ImageContext with key 'thumbnail'."); + + test('blur must be at most 100 for each breakpoint', function () { + ImageContext::make('thumbnail') + ->blur([ + Breakpoint::Small->value => 50, + Breakpoint::Medium->value => 150, + ]); + })->throws(InvalidArgumentException::class, "Blur value for breakpoint 'md' must be between 0 and 100 for ImageContext with key 'thumbnail'."); + + test('blur must be an integer for each breakpoint', function () { + ImageContext::make('thumbnail') + ->blur([ + Breakpoint::Small->value => 5, + Breakpoint::Medium->value => 'high', + ]); + })->throws(InvalidArgumentException::class, "Blur value for breakpoint 'md' must be an integer for ImageContext with key 'thumbnail'."); + + it('can set and get the blur per breakpoint', function () { + $imageContext = ImageContext::make('thumbnail') + ->blur([ + Breakpoint::Small->value => 2, + Breakpoint::Medium->value => 4, + Breakpoint::Large->value => 6, + Breakpoint::ExtraLarge->value => 8, + Breakpoint::ExtraExtraLarge->value => 10, + ]); + + expect($imageContext->getBlurByBreakpoint()) + ->toHaveCount(count(Breakpoint::cases())) + ->and($imageContext->getBlurForBreakpoint(Breakpoint::Small))->toBe(2) + ->and($imageContext->getBlurForBreakpoint(Breakpoint::Medium))->toBe(4) + ->and($imageContext->getBlurForBreakpoint(Breakpoint::Large))->toBe(6) + ->and($imageContext->getBlurForBreakpoint(Breakpoint::ExtraLarge))->toBe(8) + ->and($imageContext->getBlurForBreakpoint(Breakpoint::ExtraExtraLarge))->toBe(10); + }); + + it('can set and get the blur for a specific breakpoint', function () { + $imageContext = ImageContext::make('thumbnail') + ->blur(0) + ->blurForBreakpoint(Breakpoint::Small, 5); + + expect($imageContext->getBlurByBreakpoint()) + ->toHaveCount(count(Breakpoint::cases())) + ->and($imageContext->getBlurForBreakpoint(Breakpoint::Small))->toBe(5) + ->and($imageContext->getBlurForBreakpoint(Breakpoint::Medium))->toBe(0) + ->and($imageContext->getBlurForBreakpoint(Breakpoint::Large))->toBe(0) + ->and($imageContext->getBlurForBreakpoint(Breakpoint::ExtraLarge))->toBe(0) + ->and($imageContext->getBlurForBreakpoint(Breakpoint::ExtraExtraLarge))->toBe(0); + }); + + it('can set and get the blur for all breakpoints after and including a specific breakpoint', function () { + $imageContext = ImageContext::make('thumbnail') + ->blur(0) + ->blurFromBreakpoint(Breakpoint::Large, 10); + + expect($imageContext->getBlurByBreakpoint()) + ->toHaveCount(count(Breakpoint::cases())) + ->and($imageContext->getBlurForBreakpoint(Breakpoint::Small))->toBe(0) + ->and($imageContext->getBlurForBreakpoint(Breakpoint::Medium))->toBe(0) + ->and($imageContext->getBlurForBreakpoint(Breakpoint::Large))->toBe(10) + ->and($imageContext->getBlurForBreakpoint(Breakpoint::ExtraLarge))->toBe(10) + ->and($imageContext->getBlurForBreakpoint(Breakpoint::ExtraExtraLarge))->toBe(10); + }); + + it('can set and get the blur for all breakpoints before and including a specific breakpoint', function () { + $imageContext = ImageContext::make('thumbnail') + ->blur(0) + ->blurToBreakpoint(Breakpoint::Large, 10); + + expect($imageContext->getBlurByBreakpoint()) + ->toHaveCount(count(Breakpoint::cases())) + ->and($imageContext->getBlurForBreakpoint(Breakpoint::Small))->toBe(10) + ->and($imageContext->getBlurForBreakpoint(Breakpoint::Medium))->toBe(10) + ->and($imageContext->getBlurForBreakpoint(Breakpoint::Large))->toBe(10) + ->and($imageContext->getBlurForBreakpoint(Breakpoint::ExtraLarge))->toBe(0) + ->and($imageContext->getBlurForBreakpoint(Breakpoint::ExtraExtraLarge))->toBe(0); + }); + + it('can set and get the blur for all breakpoints between 2 breakpoints', function () { + $imageContext = ImageContext::make('thumbnail') + ->blur(0) + ->blurBetweenBreakpoints(Breakpoint::Medium, Breakpoint::ExtraLarge, 10); + + expect($imageContext->getBlurByBreakpoint()) + ->toHaveCount(count(Breakpoint::cases())) + ->and($imageContext->getBlurForBreakpoint(Breakpoint::Small))->toBe(0) + ->and($imageContext->getBlurForBreakpoint(Breakpoint::Medium))->toBe(10) + ->and($imageContext->getBlurForBreakpoint(Breakpoint::Large))->toBe(10) + ->and($imageContext->getBlurForBreakpoint(Breakpoint::ExtraLarge))->toBe(10) + ->and($imageContext->getBlurForBreakpoint(Breakpoint::ExtraExtraLarge))->toBe(0); + }); + + it('throws an exception when blur for a breakpoint is not defined', function () { + ImageContext::make('thumbnail') + ->blur([ + Breakpoint::Small->value => 5, + Breakpoint::Large->value => 10, + ]); + })->throws(InvalidArgumentException::class, "Blur for breakpoint 'md' is not defined for ImageContext with key 'thumbnail'."); + + it('returns null if not defined', function () { + $imageContext = ImageContext::make('thumbnail'); + + expect($imageContext->getBlurForBreakpoint(Breakpoint::Small))->toBeNull(); + }); +}); + +describe('grayscale', function () { + it('can set and get the grayscale for all breakpoints', function () { + $imageContext = ImageContext::make('thumbnail') + ->grayscale(true); + + expect($imageContext->getGrayscaleByBreakpoint()) + ->toHaveCount(count(Breakpoint::cases())) + ->each->toBeTrue(); + }); + + it('can set and get the grayscale per breakpoint', function () { + $imageContext = ImageContext::make('thumbnail') + ->grayscale([ + Breakpoint::Small->value => true, + Breakpoint::Medium->value => false, + Breakpoint::Large->value => true, + Breakpoint::ExtraLarge->value => false, + Breakpoint::ExtraExtraLarge->value => true, + ]); + + expect($imageContext->getGrayscaleByBreakpoint()) + ->toHaveCount(count(Breakpoint::cases())) + ->and($imageContext->getGrayscaleForBreakpoint(Breakpoint::Small))->toBeTrue() + ->and($imageContext->getGrayscaleForBreakpoint(Breakpoint::Medium))->toBeFalse() + ->and($imageContext->getGrayscaleForBreakpoint(Breakpoint::Large))->toBeTrue() + ->and($imageContext->getGrayscaleForBreakpoint(Breakpoint::ExtraLarge))->toBeFalse() + ->and($imageContext->getGrayscaleForBreakpoint(Breakpoint::ExtraExtraLarge))->toBeTrue(); + }); + + it('can set and get the grayscale for a specific breakpoint', function () { + $imageContext = ImageContext::make('thumbnail') + ->grayscale(false) + ->grayscaleForBreakpoint(Breakpoint::Small, true); + + expect($imageContext->getGrayscaleByBreakpoint()) + ->toHaveCount(count(Breakpoint::cases())) + ->and($imageContext->getGrayscaleForBreakpoint(Breakpoint::Small))->toBeTrue() + ->and($imageContext->getGrayscaleForBreakpoint(Breakpoint::Medium))->toBeFalse() + ->and($imageContext->getGrayscaleForBreakpoint(Breakpoint::Large))->toBeFalse() + ->and($imageContext->getGrayscaleForBreakpoint(Breakpoint::ExtraLarge))->toBeFalse() + ->and($imageContext->getGrayscaleForBreakpoint(Breakpoint::ExtraExtraLarge))->toBeFalse(); + }); + + it('can set and get the grayscale for all breakpoints after and including a specific breakpoint', function () { + $imageContext = ImageContext::make('thumbnail') + ->grayscale(false) + ->grayscaleFromBreakpoint(Breakpoint::Large, true); + + expect($imageContext->getGrayscaleByBreakpoint()) + ->toHaveCount(count(Breakpoint::cases())) + ->and($imageContext->getGrayscaleForBreakpoint(Breakpoint::Small))->toBeFalse() + ->and($imageContext->getGrayscaleForBreakpoint(Breakpoint::Medium))->toBeFalse() + ->and($imageContext->getGrayscaleForBreakpoint(Breakpoint::Large))->toBeTrue() + ->and($imageContext->getGrayscaleForBreakpoint(Breakpoint::ExtraLarge))->toBeTrue() + ->and($imageContext->getGrayscaleForBreakpoint(Breakpoint::ExtraExtraLarge))->toBeTrue(); + }); + + it('can set and get the grayscale for all breakpoints before and including a specific breakpoint', function () { + $imageContext = ImageContext::make('thumbnail') + ->grayscale(false) + ->grayscaleToBreakpoint(Breakpoint::Large, true); + + expect($imageContext->getGrayscaleByBreakpoint()) + ->toHaveCount(count(Breakpoint::cases())) + ->and($imageContext->getGrayscaleForBreakpoint(Breakpoint::Small))->toBeTrue() + ->and($imageContext->getGrayscaleForBreakpoint(Breakpoint::Medium))->toBeTrue() + ->and($imageContext->getGrayscaleForBreakpoint(Breakpoint::Large))->toBeTrue() + ->and($imageContext->getGrayscaleForBreakpoint(Breakpoint::ExtraLarge))->toBeFalse() + ->and($imageContext->getGrayscaleForBreakpoint(Breakpoint::ExtraExtraLarge))->toBeFalse(); + }); + + it('can set and get the grayscale for all breakpoints between 2 breakpoints', function () { + $imageContext = ImageContext::make('thumbnail') + ->grayscale(false) + ->grayscaleBetweenBreakpoints(Breakpoint::Medium, Breakpoint::ExtraLarge, true); + + expect($imageContext->getGrayscaleByBreakpoint()) + ->toHaveCount(count(Breakpoint::cases())) + ->and($imageContext->getGrayscaleForBreakpoint(Breakpoint::Small))->toBeFalse() + ->and($imageContext->getGrayscaleForBreakpoint(Breakpoint::Medium))->toBeTrue() + ->and($imageContext->getGrayscaleForBreakpoint(Breakpoint::Large))->toBeTrue() + ->and($imageContext->getGrayscaleForBreakpoint(Breakpoint::ExtraLarge))->toBeTrue() + ->and($imageContext->getGrayscaleForBreakpoint(Breakpoint::ExtraExtraLarge))->toBeFalse(); + }); + + it('throws an exception when grayscale for a breakpoint is not defined', function () { + ImageContext::make('thumbnail') + ->grayscale([ + Breakpoint::Small->value => true, + Breakpoint::Large->value => false, + ]); + })->throws(InvalidArgumentException::class, "Greyscale for breakpoint 'md' is not defined for ImageContext with key 'thumbnail'."); + + it('returns null if not defined', function () { + $imageContext = ImageContext::make('thumbnail'); + + expect($imageContext->getGrayscaleForBreakpoint(Breakpoint::Small))->toBeNull(); + }); + + test('grayscale accepts only boolean values for each breakpoint', function () { + ImageContext::make('thumbnail') + ->grayscale([ + Breakpoint::Small->value => true, + Breakpoint::Medium->value => 'yes', + ]); + })->throws(InvalidArgumentException::class, "Greyscale value for breakpoint 'md' must be a boolean for ImageContext with key 'thumbnail'."); +}); + +describe('sepia', function () { + it('can set and get the sepia for all breakpoints', function () { + $imageContext = ImageContext::make('thumbnail') + ->sepia(true); + + expect($imageContext->getSepiaByBreakpoint()) + ->toHaveCount(count(Breakpoint::cases())) + ->each->toBeTrue(); + }); + + it('can set and get the sepia per breakpoint', function () { + $imageContext = ImageContext::make('thumbnail') + ->sepia([ + Breakpoint::Small->value => true, + Breakpoint::Medium->value => false, + Breakpoint::Large->value => true, + Breakpoint::ExtraLarge->value => false, + Breakpoint::ExtraExtraLarge->value => true, + ]); + + expect($imageContext->getSepiaByBreakpoint()) + ->toHaveCount(count(Breakpoint::cases())) + ->and($imageContext->getSepiaForBreakpoint(Breakpoint::Small))->toBeTrue() + ->and($imageContext->getSepiaForBreakpoint(Breakpoint::Medium))->toBeFalse() + ->and($imageContext->getSepiaForBreakpoint(Breakpoint::Large))->toBeTrue() + ->and($imageContext->getSepiaForBreakpoint(Breakpoint::ExtraLarge))->toBeFalse() + ->and($imageContext->getSepiaForBreakpoint(Breakpoint::ExtraExtraLarge))->toBeTrue(); + }); + + it('can set and get the sepia for a specific breakpoint', function () { + $imageContext = ImageContext::make('thumbnail') + ->sepia(false) + ->sepiaForBreakpoint(Breakpoint::Small, true); + + expect($imageContext->getSepiaByBreakpoint()) + ->toHaveCount(count(Breakpoint::cases())) + ->and($imageContext->getSepiaForBreakpoint(Breakpoint::Small))->toBeTrue() + ->and($imageContext->getSepiaForBreakpoint(Breakpoint::Medium))->toBeFalse() + ->and($imageContext->getSepiaForBreakpoint(Breakpoint::Large))->toBeFalse() + ->and($imageContext->getSepiaForBreakpoint(Breakpoint::ExtraLarge))->toBeFalse() + ->and($imageContext->getSepiaForBreakpoint(Breakpoint::ExtraExtraLarge))->toBeFalse(); + }); + + it('can set and get the sepia for all breakpoints after and including a specific breakpoint', function () { + $imageContext = ImageContext::make('thumbnail') + ->sepia(false) + ->sepiaFromBreakpoint(Breakpoint::Large, true); + + expect($imageContext->getSepiaByBreakpoint()) + ->toHaveCount(count(Breakpoint::cases())) + ->and($imageContext->getSepiaForBreakpoint(Breakpoint::Small))->toBeFalse() + ->and($imageContext->getSepiaForBreakpoint(Breakpoint::Medium))->toBeFalse() + ->and($imageContext->getSepiaForBreakpoint(Breakpoint::Large))->toBeTrue() + ->and($imageContext->getSepiaForBreakpoint(Breakpoint::ExtraLarge))->toBeTrue() + ->and($imageContext->getSepiaForBreakpoint(Breakpoint::ExtraExtraLarge))->toBeTrue(); + }); + + it('can set and get the sepia for all breakpoints before and including a specific breakpoint', function () { + $imageContext = ImageContext::make('thumbnail') + ->sepia(false) + ->sepiaToBreakpoint(Breakpoint::Large, true); + + expect($imageContext->getSepiaByBreakpoint()) + ->toHaveCount(count(Breakpoint::cases())) + ->and($imageContext->getSepiaForBreakpoint(Breakpoint::Small))->toBeTrue() + ->and($imageContext->getSepiaForBreakpoint(Breakpoint::Medium))->toBeTrue() + ->and($imageContext->getSepiaForBreakpoint(Breakpoint::Large))->toBeTrue() + ->and($imageContext->getSepiaForBreakpoint(Breakpoint::ExtraLarge))->toBeFalse() + ->and($imageContext->getSepiaForBreakpoint(Breakpoint::ExtraExtraLarge))->toBeFalse(); + }); + + it('can set and get the sepia for all breakpoints between 2 breakpoints', function () { + $imageContext = ImageContext::make('thumbnail') + ->sepia(false) + ->sepiaBetweenBreakpoints(Breakpoint::Medium, Breakpoint::ExtraLarge, true); + + expect($imageContext->getSepiaByBreakpoint()) + ->toHaveCount(count(Breakpoint::cases())) + ->and($imageContext->getSepiaForBreakpoint(Breakpoint::Small))->toBeFalse() + ->and($imageContext->getSepiaForBreakpoint(Breakpoint::Medium))->toBeTrue() + ->and($imageContext->getSepiaForBreakpoint(Breakpoint::Large))->toBeTrue() + ->and($imageContext->getSepiaForBreakpoint(Breakpoint::ExtraLarge))->toBeTrue() + ->and($imageContext->getSepiaForBreakpoint(Breakpoint::ExtraExtraLarge))->toBeFalse(); + }); + + it('throws an exception when sepia for a breakpoint is not defined', function () { + ImageContext::make('thumbnail') + ->sepia([ + Breakpoint::Small->value => true, + Breakpoint::Large->value => false, + ]); + })->throws(InvalidArgumentException::class, "Sepia for breakpoint 'md' is not defined for ImageContext with key 'thumbnail'."); + + it('returns null if not defined', function () { + $imageContext = ImageContext::make('thumbnail'); + + expect($imageContext->getSepiaForBreakpoint(Breakpoint::Small))->toBeNull(); + }); + + test('sepia accepts only boolean values for each breakpoint', function () { + ImageContext::make('thumbnail') + ->sepia([ + Breakpoint::Small->value => true, + Breakpoint::Medium->value => 'yes', + ]); + })->throws(InvalidArgumentException::class, "Sepia value for breakpoint 'md' must be a boolean for ImageContext with key 'thumbnail'."); +}); + +describe('allowsMultiple', function () { + it('can set and get allowsMultiple', function () { + $imageContext = ImageContext::make('gallery') + ->allowsMultiple(true); + + expect($imageContext->getAllowsMultiple()) + ->toBeTrue(); + }); +}); + +describe('generateWebP', function () { + it('can set and get generateWebP', function () { + $imageContext = ImageContext::make('thumbnail') + ->generateWebP(true); + + expect($imageContext->getGenerateWebP()) + ->toBeTrue(); + }); + + it('falls back to the config value if not set', function () { + $imageContext = ImageContext::make('thumbnail'); + + Config::set('image-library.generate.webp', true); + + expect($imageContext->getGenerateWebP()) + ->toBeTrue(); + + Config::set('image-library.generate.webp', false); + + expect($imageContext->getGenerateWebP()) + ->toBeFalse(); + }); +}); + +describe('generateResponsiveVersions', function () { + it('can set and get generateResponsiveVersions', function () { + $imageContext = ImageContext::make('thumbnail') + ->generateResponsiveVersions(true); + + expect($imageContext->getGenerateResponsiveVersions()) + ->toBeTrue(); + }); + + it('falls back to the config value if not set', function () { + $imageContext = ImageContext::make('thumbnail'); + + Config::set('image-library.generate.responsive_versions', true); + + expect($imageContext->getGenerateResponsiveVersions()) + ->toBeTrue(); + + Config::set('image-library.generate.responsive_versions', false); + + expect($imageContext->getGenerateResponsiveVersions()) + ->toBeFalse(); + }); +}); diff --git a/tests/Unit/Enums/BreakpointTest.php b/tests/Unit/Enums/BreakpointTest.php new file mode 100644 index 0000000..7b22be2 --- /dev/null +++ b/tests/Unit/Enums/BreakpointTest.php @@ -0,0 +1,55 @@ +toBeArray() + ->toHaveCount(count(Breakpoint::cases())); + + for ($i = 0; $i < count($sorted) - 1; $i++) { + expect($sorted[$i]->getMinWidth()) + ->toBeLessThan($sorted[$i + 1]->getMinWidth()); + } +}); + +test('each breakpoint has a label', function (Breakpoint $breakpoint) { + expect($breakpoint->getLabel()) + ->toBeString() + ->not->toBeEmpty(); +}) + ->with('breakpoints'); + +test('each breakpoint has a minimum width', function (Breakpoint $breakpoint) { + expect($breakpoint->getMinWidth()) + ->toBeInt() + ->toBeGreaterThan(0); +}) + ->with('breakpoints'); + +test('each breakpoint has a maximum width except the last one', function (Breakpoint $breakpoint) { + if ($breakpoint === array_last(Breakpoint::sortedCases())) { + expect($breakpoint->getMaxWidth()) + ->toBeNull(); + } else { + expect($breakpoint->getMaxWidth()) + ->toBeInt() + ->toBeGreaterThan($breakpoint->getMinWidth()); + } +}) + ->with('breakpoints'); + +test('each breakpoint has a slug', function (Breakpoint $breakpoint) { + expect($breakpoint->getSlug()) + ->toBeString() + ->not->toBeEmpty(); +}) + ->with('breakpoints'); diff --git a/tests/Unit/ImageLibraryTest.php b/tests/Unit/ImageLibraryTest.php new file mode 100644 index 0000000..d67bfd5 --- /dev/null +++ b/tests/Unit/ImageLibraryTest.php @@ -0,0 +1,195 @@ +toBeString(); + + expect(class_exists($enumClass)) + ->toBeTrue(); + + expect($enumClass) + ->toEqual(Breakpoint::class); +}); + +it('has a method to return the image model class', function () { + $modelClass = ImageLibrary::getImageModel(); + + expect($modelClass) + ->toBeString(); + + expect(class_exists($modelClass)) + ->toBeTrue(); + + expect($modelClass) + ->toEqual(Image::class); +}); + +it('has a method to return the source image model class', function () { + $modelClass = ImageLibrary::getSourceImageModel(); + + expect($modelClass) + ->toBeString(); + + expect(class_exists($modelClass)) + ->toBeTrue(); + + expect($modelClass) + ->toEqual(SourceImage::class); +}); + +it('has a method to return a Spatie Image instance', function () { + $spatieImage = ImageLibrary::getSpatieImage(); + + expect($spatieImage) + ->toBeInstanceOf(SpatieImage::class); +}); + +it('can register one or more image contexts', function () { + ImageLibrary::registerImageContexts([ + ImageContext::make('context-single'), + ImageContext::make('context-multiple'), + ]); + + expect(count(ImageLibrary::getImageContexts())) + ->toEqual(2); +}); + +it('throws an exception when registering invalid image contexts', function () { + ImageLibrary::registerImageContexts([ + ImageContext::make('context-single'), + 'invalid-context', + ]); +})->throws(InvalidArgumentException::class, 'Expected instance of ImageContext, but got string instead.'); + +it('can register a single image context', function () { + ImageLibrary::registerImageContext( + ImageContext::make('context-single') + ); + ImageLibrary::registerImageContext( + ImageContext::make('context-multiple') + ); + + expect(count(ImageLibrary::getImageContexts())) + ->toEqual(2); +}); + +it('can remove an image context', function () { + $imageContext = ImageContext::make('context-single'); + $imageContext = ImageContext::make('context-multiple'); + + ImageLibrary::registerImageContext($imageContext); + + expect(count(ImageLibrary::getImageContexts())) + ->toEqual(2); + + ImageLibrary::removeImageContext($imageContext); + + expect(count(ImageLibrary::getImageContexts())) + ->toEqual(1); +}); + +it('can get all registered image contexts', function () { + ImageLibrary::registerImageContexts([ + ImageContext::make('context-single'), + ImageContext::make('context-multiple'), + ]); + + $imageContexts = ImageLibrary::getImageContexts(); + + expect($imageContexts) + ->toBeArray() + ->toHaveCount(2); +}); + +it('can get an image context by its key', function () { + $imageContext = ImageContext::make('context-single'); + + ImageLibrary::registerImageContext($imageContext); + + $fetchedContext = ImageLibrary::getImageContextByKey('context-single'); + + expect($fetchedContext) + ->toBeInstanceOf(ImageContext::class) + ->and($fetchedContext?->getKey()) + ->toEqual('context-single'); +}); + +it('returns null when getting an image context by a non-existent key', function () { + $fetchedContext = ImageLibrary::getImageContextByKey('non-existent-key'); + + expect($fetchedContext) + ->toBeNull(); +}); + +it('returns null when getting an image context by a blank key', function () { + expect(ImageLibrary::getImageContextByKey('')) + ->toBeNull(); + + expect(ImageLibrary::getImageContextByKey(null)) + ->toBeNull(); +}); + +it('has a method to upload a file as a source image', function () { + $file = UploadedFile::fake()->image('example-image.jpg', 10, 10); + + $sourceImage = ImageLibrary::upload($file); + + expect($sourceImage) + ->toBeInstanceOf(SourceImage::class) + ->and($sourceImage->disk) + ->toEqual('public') + ->and($sourceImage->name) + ->toEqual('example-image') + ->and($sourceImage->extension) + ->toEqual('jpg') + ->and($sourceImage->mime_type) + ->toEqual('image/jpeg') + ->and($sourceImage->width) + ->toEqual(10) + ->and($sourceImage->height) + ->toEqual(10); + + Storage::disk($sourceImage->disk) + ->assertExists($sourceImage->getRelativePath()); +}); + +it('has a method to determine if temporary URLs should be used for a given disk', function () { + $usesTemporaryUrls = ImageLibrary::shouldUseTemporaryUrlsForDisk('s3'); + + expect($usesTemporaryUrls) + ->toBeBool(); +}); + +it('returns the default value when determining if temporary URLs should be used for a disk not explicitly configured', function () { + $usesTemporaryUrls = ImageLibrary::shouldUseTemporaryUrlsForDisk('non-existent-disk'); + + expect($usesTemporaryUrls) + ->toEqual(false); +}); + +it('has a method to determine the temporary URL expiration time for a given disk', function () { + $expirationTime = ImageLibrary::getTemporaryUrlExpirationMinutesForDisk('s3'); + + expect($expirationTime) + ->toBeInt(); +}); + +it('returns the default value when determining the temporary URL expiration time for a disk not explicitly configured', function () { + $expirationTime = ImageLibrary::getTemporaryUrlExpirationMinutesForDisk('non-existent-disk'); + + expect($expirationTime) + ->toBeInt(); +}); diff --git a/tests/Unit/Jobs/GenerateImageVersionJobTest.php b/tests/Unit/Jobs/GenerateImageVersionJobTest.php new file mode 100644 index 0000000..8d61d29 --- /dev/null +++ b/tests/Unit/Jobs/GenerateImageVersionJobTest.php @@ -0,0 +1,224 @@ +create(); + + $file = UploadedFile::fake()->image('example-image.jpg', 10, 10); + + $sourceImage = SourceImage::upload($file); + + $image = Image::factory() + ->forModel($user) + ->create([ + 'source_image_id' => $sourceImage->id, + ]); + + $job = new GenerateImageVersionJob($image->id, Breakpoint::Small); + + expect($job->connection)->toBe(Config::string('image-library.queue.connection')); +}); + +it('is dispatched on the correction queue', function () { + $user = User::factory() + ->create(); + + $file = UploadedFile::fake()->image('example-image.jpg', 10, 10); + + $sourceImage = SourceImage::upload($file); + + $image = Image::factory() + ->forModel($user) + ->create([ + 'source_image_id' => $sourceImage->id, + ]); + + $job = new GenerateImageVersionJob($image->id, Breakpoint::Small); + + expect($job->queue)->toBe(Config::string('image-library.queue.queue')); +}); + +it('generates an image per breakpoint', function () { + $user = User::factory() + ->create(); + + $file = UploadedFile::fake()->image('example-image.jpg', 1000, 1000); + + $sourceImage = SourceImage::upload($file); + + $image = Image::factory() + ->forModel($user) + ->create([ + 'source_image_id' => $sourceImage->id, + ]); + + $breakpoint = Breakpoint::Small; + + $job = new GenerateImageVersionJob($image->id, $breakpoint); + + $job->handle(); + + Storage::disk($image->disk) + ->assertExists($image->getRelativePathForBreakpoint($breakpoint)); +}); + +it('can generate an image if the x and y crop coordinates are null', function () { + $user = User::factory() + ->create(); + + $file = UploadedFile::fake()->image('example-image.jpg', 1000, 1000); + + $sourceImage = SourceImage::upload($file); + + $image = Image::factory() + ->forModel($user) + ->create([ + 'source_image_id' => $sourceImage->id, + 'crop_data' => [ + Breakpoint::Small->value => [ + 'width' => 500, + 'height' => 500, + 'x' => null, + 'y' => null, + ], + ], + ]); + + $breakpoint = Breakpoint::Small; + + $job = new GenerateImageVersionJob($image->id, $breakpoint); + + $job->handle(); + + Storage::disk($image->disk) + ->assertExists($image->getRelativePathForBreakpoint($breakpoint)); +}); + +it('can apply blur', function () { + $user = User::factory() + ->create(); + + ImageLibrary::registerImageContext( + ImageContext::make('blur-test-context') + ->blur(10) + ); + + $file = UploadedFile::fake()->image('example-image.jpg', 1000, 1000); + + $sourceImage = SourceImage::upload($file); + + $image = Image::factory() + ->forModel($user) + ->create([ + 'source_image_id' => $sourceImage->id, + 'context' => 'blur-test-context', + ]); + + $breakpoint = Breakpoint::Small; + + $job = new GenerateImageVersionJob($image->id, $breakpoint); + + $job->handle(); + + Storage::disk($image->disk) + ->assertExists($image->getRelativePathForBreakpoint($breakpoint)); +}); + +it('can apply greyscale', function () { + $user = User::factory() + ->create(); + + ImageLibrary::registerImageContext( + ImageContext::make('greyscale-test-context') + ->greyscale(true) + ); + + $file = UploadedFile::fake()->image('example-image.jpg', 1000, 1000); + + $sourceImage = SourceImage::upload($file); + + $image = Image::factory() + ->forModel($user) + ->create([ + 'source_image_id' => $sourceImage->id, + 'context' => 'greyscale-test-context', + ]); + + $breakpoint = Breakpoint::Small; + + $job = new GenerateImageVersionJob($image->id, $breakpoint); + + $job->handle(); + + Storage::disk($image->disk) + ->assertExists($image->getRelativePathForBreakpoint($breakpoint)); +}); + +it('can apply sepia', function () { + $user = User::factory() + ->create(); + + ImageLibrary::registerImageContext( + ImageContext::make('sepia-test-context') + ->sepia(true) + ); + + $file = UploadedFile::fake()->image('example-image.jpg', 1000, 1000); + + $sourceImage = SourceImage::upload($file); + + $image = Image::factory() + ->forModel($user) + ->create([ + 'source_image_id' => $sourceImage->id, + 'context' => 'sepia-test-context', + ]); + + $breakpoint = Breakpoint::Small; + + $job = new GenerateImageVersionJob($image->id, $breakpoint); + + $job->handle(); + + Storage::disk($image->disk) + ->assertExists($image->getRelativePathForBreakpoint($breakpoint)); +}); + +it('applies default cropping if no crop_data is set', function () { + $user = User::factory() + ->create(); + + $file = UploadedFile::fake()->image('example-image.jpg', 1000, 1000); + + $sourceImage = SourceImage::upload($file); + + $image = Image::factory() + ->forModel($user) + ->create([ + 'source_image_id' => $sourceImage->id, + 'context' => 'context-single', + 'crop_data' => [], + ]); + + $breakpoint = Breakpoint::Small; + + $job = new GenerateImageVersionJob($image->id, $breakpoint); + + $job->handle(); + + Storage::disk($image->disk) + ->assertExists($image->getRelativePathForBreakpoint($breakpoint)); +}); diff --git a/tests/Unit/Jobs/GenerateResponsiveImageVersionsJobTest.php b/tests/Unit/Jobs/GenerateResponsiveImageVersionsJobTest.php new file mode 100644 index 0000000..9b80f9a --- /dev/null +++ b/tests/Unit/Jobs/GenerateResponsiveImageVersionsJobTest.php @@ -0,0 +1,82 @@ +create(); + + $file = UploadedFile::fake()->image('example-image.jpg', 10, 10); + + $sourceImage = SourceImage::upload($file); + + $image = Image::factory() + ->forModel($user) + ->create([ + 'source_image_id' => $sourceImage->id, + ]); + + new GenerateImageVersionJob($image->id, Breakpoint::Small)->handle(); + + $job = new GenerateResponsiveImageVersionsJob($image->id, Breakpoint::Small); + + expect($job->connection)->toBe(Config::string('image-library.queue.connection')); +}); + +it('is dispatched on the correction queue', function () { + $user = User::factory() + ->create(); + + $file = UploadedFile::fake()->image('example-image.jpg', 10, 10); + + $sourceImage = SourceImage::upload($file); + + $image = Image::factory() + ->forModel($user) + ->create([ + 'source_image_id' => $sourceImage->id, + ]); + + new GenerateImageVersionJob($image->id, Breakpoint::Small)->handle(); + + $job = new GenerateResponsiveImageVersionsJob($image->id, Breakpoint::Small); + + expect($job->queue)->toBe(Config::string('image-library.queue.queue')); +}); + +it('can generate multiple responsive image versions', function () { + $user = User::factory() + ->create(); + + $file = UploadedFile::fake()->image('example-image.jpg', 1920, 1080); + + $sourceImage = SourceImage::upload($file); + + $image = Image::factory() + ->forModel($user) + ->create([ + 'source_image_id' => $sourceImage->id, + 'context' => 'context-single', + ]); + + new GenerateImageVersionJob($image->id, Breakpoint::ExtraExtraLarge)->handle(); + + new GenerateResponsiveImageVersionsJob($image->id, Breakpoint::ExtraExtraLarge) + ->handle(); + + expect($image->getResponsiveRelativePathsForBreakpoint(Breakpoint::ExtraExtraLarge)) + ->toBeInstanceOf(Collection::class); + + expect($image->getResponsiveRelativePathsForBreakpoint(Breakpoint::ExtraExtraLarge)->count()) + ->toBeGreaterThan(0); +}); diff --git a/tests/Unit/Models/ImageTest.php b/tests/Unit/Models/ImageTest.php new file mode 100644 index 0000000..feef79e --- /dev/null +++ b/tests/Unit/Models/ImageTest.php @@ -0,0 +1,622 @@ +aspectRatio( + AspectRatio::make(1, 1) + ), + ); +}); + +describe('mutators and casts', function (): void { + it('has a translatable alt_text attribute', function (): void { + $user = User::factory() + ->create(); + + $image = Image::factory() + ->forModel($user) + ->create([ + 'alt_text' => [ + 'en' => 'An example image', + 'nl' => 'Een voorbeeldafbeelding', + ], + ]); + + expect($image->getTranslations('alt_text')) + ->toEqual([ + 'en' => 'An example image', + 'nl' => 'Een voorbeeldafbeelding', + ]); + + expect($image->alt_text) + ->toEqual('An example image'); + + app()->setLocale('nl'); + + expect($image->alt_text) + ->toEqual('Een voorbeeldafbeelding'); + }); + + it('casts the custom_properties attribute to an array', function (): void { + $user = User::factory() + ->create(); + + $image = Image::factory() + ->forModel($user) + ->create([ + 'custom_properties' => [ + 'photographer' => 'John Doe', + 'location' => 'New York', + ], + ]); + + expect($image->custom_properties) + ->toEqual([ + 'photographer' => 'John Doe', + 'location' => 'New York', + ]); + }); + + it('always generates crop data for each breakpoint based on the inputted value', function (): void { + $user = User::factory() + ->create(); + + $image = Image::factory() + ->forModel($user) + ->create([ + 'crop_data' => CropData::make(10, 10, 100, 100), + ]) + ->refresh(); + + expect($image->crop_data) + ->toHaveCount(count(Breakpoint::cases())); + + foreach (Breakpoint::cases() as $breakpoint) { + expect($image->crop_data[$breakpoint->value]) + ->toBeInstanceOf(CropData::class); + } + }); + + test('crop_data returns null for each breakpoint if null is provided', function (): void { + $user = User::factory() + ->create(); + + $sourceImage = SourceImage::factory() + ->create(); + + DB::table('images') + ->insert([ + 'id' => 1, + 'source_image_id' => $sourceImage->id, + 'model_type' => User::class, + 'model_id' => $user->id, + 'disk' => 'public', + 'uuid' => Str::uuid(), + 'context' => 'thumbnail', + 'context_configuration_hash' => ImageLibrary::getImageContextByKey('thumbnail') + ?->getConfigurationHash(), + 'crop_data' => null, + 'sort_order' => 0, + 'created_at' => now(), + 'updated_at' => now(), + ]); + + $image = Image::query() + ->first(); + + expect($image->crop_data) + ->toHaveCount(count(Breakpoint::cases())); + + foreach (Breakpoint::cases() as $breakpoint) { + expect($image->crop_data[$breakpoint->value]) + ->toBeNull(); + } + }); + + test('crop_data returns null on wrongly structured data (not an array)', function (): void { + $user = User::factory() + ->create(); + + $sourceImage = SourceImage::factory() + ->create(); + + DB::table('images') + ->insert([ + 'id' => 1, + 'source_image_id' => $sourceImage->id, + 'model_type' => User::class, + 'model_id' => $user->id, + 'disk' => 'public', + 'uuid' => Str::uuid(), + 'context' => 'thumbnail', + 'context_configuration_hash' => ImageLibrary::getImageContextByKey('thumbnail') + ?->getConfigurationHash(), + 'crop_data' => '"invalid structure"', + 'sort_order' => 0, + 'created_at' => now(), + 'updated_at' => now(), + ]); + + $image = Image::query() + ->first(); + + expect($image->crop_data) + ->toHaveCount(count(Breakpoint::cases())); + + foreach (Breakpoint::cases() as $breakpoint) { + expect($image->crop_data[$breakpoint->value]) + ->toBeNull(); + } + }); + + test('crop_data returns null on wrongly structured data (missing keys)', function (): void { + $user = User::factory() + ->create(); + + $sourceImage = SourceImage::factory() + ->create(); + + DB::table('images') + ->insert([ + 'id' => 1, + 'source_image_id' => $sourceImage->id, + 'model_type' => User::class, + 'model_id' => $user->id, + 'disk' => 'public', + 'uuid' => Str::uuid(), + 'context' => 'thumbnail', + 'context_configuration_hash' => ImageLibrary::getImageContextByKey('thumbnail') + ?->getConfigurationHash(), + 'crop_data' => json_encode([ + 'small' => [ + 'width' => 100, + 'height' => 100, + // Missing x and y + ], + ]), + 'sort_order' => 0, + 'created_at' => now(), + 'updated_at' => now(), + ]); + + $image = Image::query() + ->first(); + + expect($image->crop_data) + ->toHaveCount(count(Breakpoint::cases())); + + foreach (Breakpoint::cases() as $breakpoint) { + expect($image->crop_data[$breakpoint->value]) + ->toBeNull(); + } + }); +}); + +describe('methods', function (): void { + describe('getRelativeBasePath', function (): void { + it('returns the correct relative base path', function (): void { + $user = User::factory() + ->create(); + + $image = Image::factory() + ->forModel($user) + ->create(); + + expect($image->getRelativeBasePath()) + ->toBe("media-library/{$image->sourceImage->uuid}/{$image->uuid}"); + }); + }); + + describe('getAbsoluteBasePath', function (): void { + it('returns the correct absolute base path', function (): void { + $user = User::factory() + ->create(); + + $image = Image::factory() + ->forModel($user) + ->create(); + + expect($image->getAbsoluteBasePath()) + ->toBe(Storage::disk($image->disk)->path("media-library/{$image->sourceImage->uuid}/{$image->uuid}")); + }); + }); + + describe('getRelativePathForBreakpoint', function (): void { + it('returns the correct relative path for the given breakpoint', function (): void { + $user = User::factory() + ->create(); + + $image = Image::factory() + ->forModel($user) + ->create(); + + expect($image->getRelativePathForBreakpoint(Breakpoint::Small)) + ->toBe("media-library/{$image->sourceImage->uuid}/{$image->uuid}/sm.{$image->sourceImage->extension}"); + + expect($image->getRelativePathForBreakpoint(Breakpoint::Medium, 'png')) + ->toBe("media-library/{$image->sourceImage->uuid}/{$image->uuid}/md.png"); + }); + }); + + describe('getAbsolutePathForBreakpoint', function (): void { + it('returns the correct absolute path for the given breakpoint', function (): void { + $user = User::factory() + ->create(); + + $image = Image::factory() + ->forModel($user) + ->create(); + + expect($image->getAbsolutePathForBreakpoint(Breakpoint::Small)) + ->toBe(Storage::disk($image->disk)->path("media-library/{$image->sourceImage->uuid}/{$image->uuid}/sm.{$image->sourceImage->extension}")); + + expect($image->getAbsolutePathForBreakpoint(Breakpoint::Medium, 'png')) + ->toBe(Storage::disk($image->disk)->path("media-library/{$image->sourceImage->uuid}/{$image->uuid}/md.png")); + }); + }); + + describe('getForBreakpoint', function (): void { + it('returns the image content for the given breakpoint', function (): void { + $user = User::factory() + ->create(); + + $file = UploadedFile::fake()->image('example-image.jpg', 10, 10); + + $sourceImage = SourceImage::upload($file); + + $image = Image::factory() + ->forModel($user) + ->create([ + 'source_image_id' => $sourceImage->id, + ]); + + GenerateImageVersionJob::dispatchSync($image, Breakpoint::Small); + + $breakpointImage = $image->getForBreakpoint(Breakpoint::Small); + + expect($breakpointImage) + ->toBeString() + ->not->toBeEmpty(); + }); + }); + + describe('existsForBreakpoint', function (): void { + it('returns whether an image exists for the given breakpoint', function (): void { + $user = User::factory() + ->create(); + + $file = UploadedFile::fake()->image('example-image.jpg', 10, 10); + + $sourceImage = SourceImage::upload($file); + + $image = Image::factory() + ->forModel($user) + ->create([ + 'source_image_id' => $sourceImage->id, + ]); + + expect($image->existsForBreakpoint(Breakpoint::Small)) + ->toBeFalse(); + + GenerateImageVersionJob::dispatchSync($image, Breakpoint::Small); + + expect($image->existsForBreakpoint(Breakpoint::Small)) + ->toBeTrue(); + }); + }); + + describe('missingForBreakpoint', function (): void { + it('returns whether an image is missing for the given breakpoint', function (): void { + $user = User::factory() + ->create(); + + $file = UploadedFile::fake()->image('example-image.jpg', 10, 10); + + $sourceImage = SourceImage::upload($file); + + $image = Image::factory() + ->forModel($user) + ->create([ + 'source_image_id' => $sourceImage->id, + ]); + + expect($image->missingForBreakpoint(Breakpoint::Small)) + ->toBeTrue(); + + GenerateImageVersionJob::dispatchSync($image, Breakpoint::Small); + + expect($image->missingForBreakpoint(Breakpoint::Small)) + ->toBeFalse(); + }); + }); + + describe('downloadForBreakpoint', function (): void { + it('returns a download response for the given breakpoint', function (): void { + $user = User::factory() + ->create(); + + $file = UploadedFile::fake()->image('example-image.jpg', 10, 10); + + $sourceImage = SourceImage::upload($file); + + $image = Image::factory() + ->forModel($user) + ->create([ + 'source_image_id' => $sourceImage->id, + ]); + + GenerateImageVersionJob::dispatchSync($image, Breakpoint::Small); + + $response = $image->downloadForBreakpoint(Breakpoint::Small); + + expect($response) + ->toBeInstanceOf(StreamedResponse::class); + }); + }); + + describe('urlForBreakpoint', function (): void { + it('returns a URL for the given breakpoint', function (): void { + $user = User::factory() + ->create(); + + $file = UploadedFile::fake()->image('example-image.jpg', 10, 10); + + $sourceImage = SourceImage::upload($file); + + $image = Image::factory() + ->forModel($user) + ->create([ + 'source_image_id' => $sourceImage->id, + ]); + + GenerateImageVersionJob::dispatchSync($image, Breakpoint::Small); + + $url = $image->urlForBreakpoint(Breakpoint::Small); + + expect($url) + ->toBeString() + ->not->toBeEmpty(); + }); + + it('can return a temporary URL if configured to do so', function (): void { + $user = User::factory() + ->create(); + + $file = UploadedFile::fake()->image('example-image.jpg', 10, 10); + + $sourceImage = SourceImage::upload($file); + + $image = Image::factory() + ->forModel($user) + ->create([ + 'source_image_id' => $sourceImage->id, + ]); + + GenerateImageVersionJob::dispatchSync($image, Breakpoint::Small); + + ImageLibrary::partialMock(); + + ImageLibrary::shouldReceive('shouldUseTemporaryUrlsForDisk') + ->with($image->disk) + ->andReturn(true); + + $url = $image->urlForBreakpoint(Breakpoint::Small); + + expect($url) + ->toBeString() + ->not->toBeEmpty(); + }); + }); + + describe('temporaryUrlForBreakpoint', function (): void { + it('can return a temporary URL of the source image file', function (): void { + $user = User::factory() + ->create(); + + $file = UploadedFile::fake()->image('example-image.jpg', 10, 10); + + $sourceImage = SourceImage::upload($file); + + $image = Image::factory() + ->forModel($user) + ->create([ + 'source_image_id' => $sourceImage->id, + ]); + + GenerateImageVersionJob::dispatchSync($image, Breakpoint::Small); + + $url = $image->temporaryUrlForBreakpoint(Breakpoint::Small, now()->addMinutes(30)); + + expect($url) + ->toBeString() + ->not->toBeEmpty(); + }); + }); + + describe('getResponsiveRelativePathsForBreakpoint', function (): void { + it('returns multiple responsive relative paths for the given breakpoint', function (): void { + $user = User::factory() + ->create(); + + $file = UploadedFile::fake()->image('example-image.jpg', 1200, 800); + + $sourceImage = SourceImage::upload($file); + + $image = Image::factory() + ->forModel($user) + ->create([ + 'source_image_id' => $sourceImage->id, + ]); + + new GenerateImageVersionJob($image->id, Breakpoint::Large)->handle(); + + new GenerateResponsiveImageVersionsJob($image->id, Breakpoint::Large) + ->handle(); + + expect($image->getResponsiveRelativePathsForBreakpoint(Breakpoint::Large)) + ->toBeInstanceOf(Collection::class); + + expect($image->getResponsiveRelativePathsForBreakpoint(Breakpoint::Large)->count()) + ->toBeGreaterThan(0); + }); + }); + + describe('getResponsiveAbsolutePathsForBreakpoint', function (): void { + it('returns multiple responsive absolute paths for the given breakpoint', function (): void { + $user = User::factory() + ->create(); + + $file = UploadedFile::fake()->image('example-image.jpg', 1200, 800); + + $sourceImage = SourceImage::upload($file); + + $image = Image::factory() + ->forModel($user) + ->create([ + 'source_image_id' => $sourceImage->id, + ]); + + new GenerateImageVersionJob($image->id, Breakpoint::Large)->handle(); + + new GenerateResponsiveImageVersionsJob($image->id, Breakpoint::Large) + ->handle(); + + expect($image->getResponsiveAbsolutePathsForBreakpoint(Breakpoint::Large)) + ->toBeInstanceOf(Collection::class); + + expect($image->getResponsiveAbsolutePathsForBreakpoint(Breakpoint::Large)->count()) + ->toBeGreaterThan(0); + }); + }); +}); + +describe('observers & events', function (): void { + it('generates a UUID on saving if not set', function (): void { + $user = User::factory() + ->create(); + + $image = Image::factory() + ->forModel($user) + ->create([ + 'uuid' => null, + ]); + + expect($image->uuid) + ->toBeString() + ->not->toBeEmpty(); + }); + + it('generates the context_configuration_hash on saving, even if set', function (): void { + $user = User::factory() + ->create(); + + $image = Image::factory() + ->forModel($user) + ->create([ + 'context' => 'thumbnail', + 'context_configuration_hash' => null, + ]); + + $expectedHash = ImageLibrary::getImageContextByKey('thumbnail') + ?->getConfigurationHash(); + + expect($image->context_configuration_hash) + ->toBe($expectedHash); + + ImageLibrary::registerImageContext( + ImageContext::make('thumbnail2') + ->aspectRatio( + AspectRatio::make(4, 3) + ), + ); + + $image->context = 'thumbnail2'; + $image->save(); + + $expectedHash = ImageLibrary::getImageContextByKey('thumbnail2') + ?->getConfigurationHash(); + + expect($image->context_configuration_hash) + ->toBe($expectedHash); + }); + + it('dispatches multiple jobs via a bus chain after creating an image', function (): void { + Bus::fake(); + + $user = User::factory() + ->create(); + + Image::factory() + ->forModel($user) + ->create(); + + Bus::assertChained([ + Bus::chainedBatch(function (PendingBatch $batch) { + return $batch->jobs->count() === count(Breakpoint::cases()) + && $batch->jobs->every(fn ($job) => $job instanceof GenerateImageVersionJob); + }), + Bus::chainedBatch(function (PendingBatch $batch) { + return $batch->jobs->count() === count(Breakpoint::cases()) + && $batch->jobs->every(fn ($job) => $job instanceof GenerateResponsiveImageVersionsJob); + }), + ]); + }); +}); + +describe('relations', function (): void { + it('morphs to a model', function (): void { + $user = User::factory() + ->create(); + + $image = Image::factory() + ->forModel($user) + ->create(); + + expect($image->model()) + ->toBeInstanceOf(MorphTo::class); + + expect($image->model) + ->toBeInstanceOf(User::class); + }); + + it('belongs to a source image', function (): void { + $user = User::factory() + ->create(); + + $image = Image::factory() + ->forModel($user) + ->create(); + + expect($image->sourceImage()) + ->toBeInstanceOf(BelongsTo::class); + + expect($image->sourceImage) + ->toBeInstanceOf(SourceImage::class); + }); +}); + +describe('scopes', function (): void {}); diff --git a/tests/Unit/Models/SourceImageTest.php b/tests/Unit/Models/SourceImageTest.php new file mode 100644 index 0000000..76827b7 --- /dev/null +++ b/tests/Unit/Models/SourceImageTest.php @@ -0,0 +1,361 @@ +aspectRatio( + AspectRatio::make(1, 1) + ), + ); +}); + +describe('mutators and casts', function (): void { + it('has a translatable alt_text attribute', function (): void { + $image = SourceImage::factory() + ->create([ + 'alt_text' => [ + 'en' => 'An example image', + 'nl' => 'Een voorbeeldafbeelding', + ], + ]); + + expect($image->getTranslations('alt_text')) + ->toEqual([ + 'en' => 'An example image', + 'nl' => 'Een voorbeeldafbeelding', + ]); + + expect($image->alt_text) + ->toEqual('An example image'); + + app()->setLocale('nl'); + + expect($image->alt_text) + ->toEqual('Een voorbeeldafbeelding'); + }); + + it('casts the custom_properties attribute to an array', function (): void { + $image = SourceImage::factory() + ->create([ + 'custom_properties' => [ + 'photographer' => 'John Doe', + 'location' => 'New York', + ], + ]); + + expect($image->custom_properties) + ->toEqual([ + 'photographer' => 'John Doe', + 'location' => 'New York', + ]); + }); + + it('has a name_with_extension attribute', function (): void { + $image = SourceImage::factory() + ->create([ + 'name' => 'example-image', + 'extension' => 'jpg', + ]); + + expect($image->name_with_extension) + ->toEqual('example-image.jpg'); + }); +}); + +describe('methods', function (): void { + it('can return the relative base path', function (): void { + $image = SourceImage::factory() + ->create(); + + expect($image->getRelativeBasePath()) + ->toEqual('media-library/'.$image->uuid); + }); + + it('can return the absolute base path', function (): void { + $image = SourceImage::factory() + ->create(); + + expect($image->getAbsoluteBasePath()) + ->toEqual(Storage::disk($image->disk)->path('media-library/'.$image->uuid)); + }); + + it('can return the relative path', function (): void { + $image = SourceImage::factory() + ->create([ + 'name' => 'example-image', + 'extension' => 'png', + ]); + + expect($image->getRelativePath()) + ->toEqual('media-library/'.$image->uuid.'/original.png'); + }); + + it('can return the absolute path', function (): void { + $image = SourceImage::factory() + ->create([ + 'name' => 'example-image', + 'extension' => 'png', + ]); + + expect($image->getAbsolutePath()) + ->toEqual(Storage::disk($image->disk)->path('media-library/'.$image->uuid.'/original.png')); + }); + + describe('upload', function (): void { + it('can upload a file as a source image', function (): void { + $file = UploadedFile::fake()->image('example-image.jpg', 10, 10); + + $sourceImage = SourceImage::upload($file); + + expect($sourceImage) + ->toBeInstanceOf(SourceImage::class) + ->and($sourceImage->disk) + ->toEqual('public') + ->and($sourceImage->name) + ->toEqual('example-image') + ->and($sourceImage->extension) + ->toEqual('jpg') + ->and($sourceImage->mime_type) + ->toEqual('image/jpeg') + ->and($sourceImage->width) + ->toEqual(10) + ->and($sourceImage->height) + ->toEqual(10); + + Storage::disk($sourceImage->disk) + ->assertExists($sourceImage->getRelativePath()); + }); + + it('cleans up if it fails to upload a file', function (): void { + /** @var TestCase $this */ + $this->expectException(Throwable::class); + + // Create a mock file that will throw an exception when accessed + $file = Mockery::mock(UploadedFile::class); + $file->shouldReceive('getClientOriginalName')->andReturn('corrupt-image.jpg'); + $file->shouldReceive('getClientOriginalExtension')->andReturn('jpg'); + $file->shouldReceive('getClientMimeType')->andReturn('image/jpeg'); + $file->shouldReceive('getRealPath')->andThrow(new RuntimeException('Failed to read file')); + + try { + SourceImage::upload($file); + } catch (Throwable $e) { + expect(Storage::disk('public')->allFiles('media-library')) + ->toBeEmpty(); + + throw $e; + } + }); + + it('cleans up the model if it fails to upload a file', function (): void { + /** @var TestCase $this */ + $this->expectException(Throwable::class); + + $file = UploadedFile::fake()->image('corrupt-image.jpg', 10, 10); + + // Create a mock storage disk that will throw an exception when making a directory + Storage::shouldReceive('makeDirectory')->andThrow(new RuntimeException('Failed to create directory')); + + try { + SourceImage::upload($file); + } catch (Throwable $e) { + expect(Storage::disk('public')->allFiles('media-library')) + ->toBeEmpty(); + + throw $e; + } + }); + }); + + describe('get', function (): void { + it('can get the content of the source image file', function (): void { + $file = UploadedFile::fake()->image('example-image.jpg', 10, 10); + + $sourceImage = SourceImage::upload($file); + + $content = $sourceImage->get(); + + expect($content) + ->toBeString() + ->not->toBeEmpty(); + }); + }); + + describe('exists', function (): void { + it('can check if the source image file exists', function (): void { + $file = UploadedFile::fake()->image('example-image.jpg', 10, 10); + + $sourceImage = SourceImage::upload($file); + + expect($sourceImage->exists()) + ->toBeTrue(); + + // Delete the file + Storage::disk($sourceImage->disk)->delete($sourceImage->getRelativePath()); + + expect($sourceImage->exists()) + ->toBeFalse(); + }); + }); + + describe('missing', function (): void { + it('can check if the source image file is missing', function (): void { + $file = UploadedFile::fake()->image('example-image.jpg', 10, 10); + + $sourceImage = SourceImage::upload($file); + + expect($sourceImage->missing()) + ->toBeFalse(); + + // Delete the file + Storage::disk($sourceImage->disk)->delete($sourceImage->getRelativePath()); + + expect($sourceImage->missing()) + ->toBeTrue(); + }); + }); + + describe('download', function (): void { + it('can download the source image file', function (): void { + $file = UploadedFile::fake()->image('example-image.jpg', 10, 10); + + $sourceImage = SourceImage::upload($file); + + $content = $sourceImage->download(); + + expect($content) + ->toBeInstanceOf(StreamedResponse::class); + }); + }); + + describe('url', function (): void { + it('can return the URL of the source image file', function (): void { + $file = UploadedFile::fake()->image('example-image.jpg', 10, 10); + + $sourceImage = SourceImage::upload($file); + + $url = $sourceImage->url(); + + expect($url) + ->toBeString() + ->not->toBeEmpty(); + }); + + it('can return a temporary URL if configured to do so', function (): void { + $file = UploadedFile::fake()->image('example-image.jpg', 10, 10); + + $sourceImage = SourceImage::upload($file); + + ImageLibrary::partialMock(); + + ImageLibrary::shouldReceive('shouldUseTemporaryUrlsForDisk') + ->with($sourceImage->disk) + ->andReturn(true); + + $url = $sourceImage->url(); + + expect($url) + ->toBeString() + ->not->toBeEmpty(); + }); + }); + + describe('temporaryUrl', function (): void { + it('can return a temporary URL of the source image file', function (): void { + $file = UploadedFile::fake()->image('example-image.jpg', 10, 10); + + $sourceImage = SourceImage::upload($file); + + $temporaryUrl = $sourceImage->temporaryUrl(now()->addMinutes(30)); + + expect($temporaryUrl) + ->toBeString() + ->not->toBeEmpty(); + }); + + it('uses the default expiration time if none is provided', function (): void { + $file = UploadedFile::fake()->image('example-image.jpg', 10, 10); + + $sourceImage = SourceImage::upload($file); + + ImageLibrary::partialMock(); + + ImageLibrary::shouldReceive('getTemporaryUrlExpirationMinutesForDisk') + ->with($sourceImage->disk) + ->andReturn(15); + + $temporaryUrl = $sourceImage->temporaryUrl(); + + expect($temporaryUrl) + ->toBeString() + ->not->toBeEmpty(); + }); + }); +}); + +describe('observers & events', function (): void { + it('generates a UUID on saving if not set', function (): void { + $image = SourceImage::factory() + ->create([ + 'uuid' => null, + ]); + + expect($image->uuid) + ->toBeString() + ->not->toBeEmpty(); + }); + + it('deletes all files on deletion', function (): void { + $file = UploadedFile::fake()->image('example-image.jpg', 10, 10); + + $sourceImage = SourceImage::upload($file); + + expect(Storage::disk($sourceImage->disk)->exists($sourceImage->getRelativeBasePath())) + ->toBeTrue(); + + $sourceImage->delete(); + + expect(Storage::disk($sourceImage->disk)->exists($sourceImage->getRelativeBasePath())) + ->toBeFalse(); + }); +}); + +describe('relations', function (): void { + it('has many images', function (): void { + $sourceImage = SourceImage::factory() + ->create(); + + $user = User::factory() + ->create(); + + Image::factory() + ->count(3) + ->forModel($user) + ->create([ + 'source_image_id' => $sourceImage->id, + ]); + + expect($sourceImage->images()) + ->toBeInstanceOf(HasMany::class); + + expect($sourceImage->images) + ->toHaveCount(3) + ->each->toBeInstanceOf(Image::class); + }); +}); + +describe('scopes', function (): void {}); diff --git a/tests/Unit/Traits/HasImagesTest.php b/tests/Unit/Traits/HasImagesTest.php new file mode 100644 index 0000000..3cbb9fb --- /dev/null +++ b/tests/Unit/Traits/HasImagesTest.php @@ -0,0 +1,259 @@ +create(); + + expect($user->images()) + ->toBeInstanceOf(MorphMany::class); + + Image::factory() + ->forModel($user) + ->create(); + + expect($user->images) + ->toHaveCount(1) + ->each->toBeInstanceOf(Image::class); +}); + +test('a model using the trait must include a context when attaching an image', function () { + $user = User::factory() + ->create(); + + $sourceImage = SourceImage::factory() + ->create(); + + $user->attachImage($sourceImage); +})->throws(InvalidArgumentException::class); + +test('a model using the trait has the attachImage method', function () { + $user = User::factory() + ->create(); + + $sourceImage = SourceImage::factory() + ->create(); + + $context = ImageLibrary::getImageContextByKey('context-single'); + + $image = $user->attachImage($sourceImage, ['context' => $context]); + + expect($image) + ->toBeInstanceOf(Image::class) + ->and($image->model_type)->toBe($user->getMorphClass()) + ->and($image->model_id)->toBe($user->getKey()) + ->and($image->source_image_id)->toBe($sourceImage->id) + ->and($image->disk)->toBe($sourceImage->disk) + ->and($image->context->getKey())->toEqual($context->getKey()); + + expect($user->images) + ->toHaveCount(1) + ->first()->id->toBe($image->id); +}); + +test('a model using the trait can use an ImageContext when attaching an image', function () { + $user = User::factory() + ->create(); + + $sourceImage = SourceImage::factory() + ->create(); + + $context = ImageLibrary::getImageContextByKey('context-single'); + + $image = $user->attachImage($sourceImage, ['context' => $context]); + + expect($image) + ->toBeInstanceOf(Image::class) + ->and($image->context->getKey())->toEqual($context->getKey()); +}); + +test('a model using the trait can use an ImageContext key when attaching an image', function () { + $user = User::factory() + ->create(); + + $sourceImage = SourceImage::factory() + ->create(); + + $contextKey = 'context-single'; + + $image = $user->attachImage($sourceImage, ['context' => $contextKey]); + + expect($image) + ->toBeInstanceOf(Image::class) + ->and($image->context->getKey())->toBe($contextKey); +}); + +test('a model using the trait can attach an image to a custom MorphOne relation', function () { + $user = User::factory() + ->create(); + + $sourceImage1 = SourceImage::factory() + ->create(); + + $sourceImage2 = SourceImage::factory() + ->create(); + + $context = ImageLibrary::getImageContextByKey('context-single'); + + $profilePicture1 = $user->attachImage($sourceImage1, ['context' => $context], 'profilePicture'); + + expect($profilePicture1) + ->toBeInstanceOf(Image::class); + + expect($user->profilePicture) + ->id->toBe($profilePicture1->id); + + $profilePicture2 = $user->attachImage($sourceImage2, ['context' => $context], 'profilePicture'); + + expect($profilePicture2) + ->toBeInstanceOf(Image::class) + ->and($profilePicture2->id)->not->toBe($profilePicture1->id); + + expect($user->profilePicture) + ->id->toBe($profilePicture2->id); + + expect(Image::where('model_type', $user->getMorphClass()) + ->where('model_id', $user->getKey()) + ->count())->toBe(1); +}); + +test('a model using the trait can attach an image to a custom MorphMany relation', function () { + $user = User::factory() + ->create(); + + $sourceImage1 = SourceImage::factory() + ->create(); + + $sourceImage2 = SourceImage::factory() + ->create(); + + $context = ImageLibrary::getImageContextByKey('context-multiple'); + + $galleryImage1 = $user->attachImage($sourceImage1, ['context' => $context], 'gallery'); + + expect($galleryImage1) + ->toBeInstanceOf(Image::class); + + expect($user->gallery) + ->toHaveCount(1) + ->first()->id->toBe($galleryImage1->id); + + $galleryImage2 = $user->attachImage($sourceImage2, ['context' => $context], 'gallery'); + + expect($galleryImage2) + ->toBeInstanceOf(Image::class) + ->and($galleryImage2->id)->not->toBe($galleryImage1->id); + + expect($user->gallery) + ->toHaveCount(2) + ->pluck('id')->toContain($galleryImage1->id) + ->and($user->gallery) + ->pluck('id')->toContain($galleryImage2->id); +}); + +test('attaching an image to an invalid relation throws an exception', function () { + $user = User::factory() + ->create(); + + $sourceImage = SourceImage::factory() + ->create(); + + $context = ImageLibrary::getImageContextByKey('context-single'); + + $user->attachImage($sourceImage, ['context' => $context], 'invalidRelation'); +})->throws(InvalidArgumentException::class, 'Relation invalidRelation does not exist on the model.'); + +test('attaching an image to a relation that is not MorphOne or MorphMany throws an exception', function () { + $user = User::factory() + ->create(); + + $sourceImage = SourceImage::factory() + ->create(); + + $context = ImageLibrary::getImageContextByKey('context-single'); + + $user->attachImage($sourceImage, ['context' => $context], 'friends'); +})->throws(InvalidArgumentException::class, 'Relation friends is not a valid MorphOne or MorphMany relation.'); + +test('a model using the trait can attach an image with custom properties', function () { + $user = User::factory() + ->create(); + + $sourceImage = SourceImage::factory() + ->create(); + + $context = ImageLibrary::getImageContextByKey('context-single'); + + $customProperties = [ + 'caption' => 'A custom caption', + ]; + + $image = $user->attachImage($sourceImage, [ + 'context' => $context, + 'custom_properties' => $customProperties, + ]); + + expect($image) + ->toBeInstanceOf(Image::class) + ->and($image->custom_properties)->toEqual($customProperties); +}); + +test('attaching two images to a single-image context removes the previous image', function () { + $user = User::factory() + ->create(); + + $sourceImage1 = SourceImage::factory() + ->create(); + + $sourceImage2 = SourceImage::factory() + ->create(); + + $context = ImageLibrary::getImageContextByKey('context-single'); + + $image1 = $user->attachImage($sourceImage1, ['context' => $context]); + + expect($user->images) + ->toHaveCount(1) + ->first()->id->toBe($image1->id); + + $image2 = $user->attachImage($sourceImage2, ['context' => $context]); + + expect($user->images) + ->toHaveCount(1) + ->first()->id->toBe($image2->id) + ->and($image2->id)->not->toBe($image1->id); +}); + +test('attaching two images to a multiple-image context keeps both images', function () { + $user = User::factory() + ->create(); + + $sourceImage1 = SourceImage::factory() + ->create(); + + $sourceImage2 = SourceImage::factory() + ->create(); + + $context = ImageLibrary::getImageContextByKey('context-multiple'); + + $image1 = $user->attachImage($sourceImage1, ['context' => $context]); + + expect($user->images) + ->toHaveCount(1) + ->first()->id->toBe($image1->id); + + $image2 = $user->attachImage($sourceImage2, ['context' => $context]); + + expect($user->images) + ->toHaveCount(2) + ->pluck('id')->toContain($image1->id) + ->and($user->images) + ->pluck('id')->toContain($image2->id); +}); From 5254edadd11e1385301be2da5302372c5fc24db9 Mon Sep 17 00:00:00 2001 From: SimonBroekaert Date: Wed, 26 Nov 2025 16:45:50 +0100 Subject: [PATCH 2/2] feature - full test coverage --- .github/workflows/run-tests.yml | 6 +- .gitignore | 2 - CHANGELOG.md | 4 +- README.md | 79 +++- composer.json | 1 + config/image-library.php | 1 + database/factories/ImageFactory.php | 5 +- .../migrations/create_images_table.php.stub | 2 + .../create_source_images_table.php.stub | 3 + docs/images/github-banner.png | Bin 0 -> 21931 bytes docs/upgrade-to-v3.md | 116 +++++ phpstan.neon.dist | 5 +- resources/lang/af/translations.php | 14 + resources/lang/ak/translations.php | 14 + resources/lang/am/translations.php | 14 + resources/lang/ar/translations.php | 14 + resources/lang/as/translations.php | 14 + resources/lang/az/translations.php | 14 + resources/lang/be/translations.php | 14 + resources/lang/bg/translations.php | 14 + resources/lang/bho/translations.php | 14 + resources/lang/bm/translations.php | 14 + resources/lang/bn/translations.php | 14 + resources/lang/bs/translations.php | 14 + resources/lang/ca/translations.php | 14 + resources/lang/ceb/translations.php | 14 + resources/lang/ckb/translations.php | 14 + resources/lang/cs/translations.php | 14 + resources/lang/cy/translations.php | 14 + resources/lang/da/translations.php | 14 + resources/lang/de/translations.php | 14 + resources/lang/de_CH/translations.php | 14 + resources/lang/doi/translations.php | 14 + resources/lang/ee/translations.php | 14 + resources/lang/el/translations.php | 14 + resources/lang/en/breakpoints.php | 12 - resources/lang/en/translations.php | 14 + resources/lang/en_CA/translations.php | 14 + resources/lang/eo/translations.php | 14 + resources/lang/es/translations.php | 14 + resources/lang/et/translations.php | 14 + resources/lang/eu/translations.php | 14 + resources/lang/fa/translations.php | 14 + resources/lang/fi/translations.php | 14 + resources/lang/fil/translations.php | 14 + resources/lang/fr/translations.php | 14 + resources/lang/fy/translations.php | 14 + resources/lang/ga/translations.php | 14 + resources/lang/gd/translations.php | 14 + resources/lang/gl/translations.php | 14 + resources/lang/gu/translations.php | 14 + resources/lang/ha/translations.php | 14 + resources/lang/haw/translations.php | 14 + resources/lang/he/translations.php | 14 + resources/lang/hi/translations.php | 14 + resources/lang/hr/translations.php | 14 + resources/lang/hu/translations.php | 14 + resources/lang/hy/translations.php | 14 + resources/lang/id/translations.php | 14 + resources/lang/ig/translations.php | 14 + resources/lang/is/translations.php | 14 + resources/lang/it/translations.php | 14 + resources/lang/ja/translations.php | 14 + resources/lang/ka/translations.php | 14 + resources/lang/kk/translations.php | 14 + resources/lang/km/translations.php | 14 + resources/lang/kn/translations.php | 14 + resources/lang/ko/translations.php | 14 + resources/lang/ku/translations.php | 14 + resources/lang/ky/translations.php | 14 + resources/lang/lb/translations.php | 14 + resources/lang/lg/translations.php | 14 + resources/lang/ln/translations.php | 14 + resources/lang/lo/translations.php | 14 + resources/lang/lt/translations.php | 14 + resources/lang/lv/translations.php | 14 + resources/lang/mai/translations.php | 14 + resources/lang/mg/translations.php | 14 + resources/lang/mi/translations.php | 14 + resources/lang/mk/translations.php | 14 + resources/lang/ml/translations.php | 14 + resources/lang/mn/translations.php | 14 + resources/lang/mni_Mtei/translations.php | 14 + resources/lang/mr/translations.php | 14 + resources/lang/ms/translations.php | 14 + resources/lang/mt/translations.php | 14 + resources/lang/my/translations.php | 14 + resources/lang/nb/translations.php | 14 + resources/lang/ne/translations.php | 14 + resources/lang/nl/translations.php | 14 + resources/lang/nn/translations.php | 14 + resources/lang/oc/translations.php | 14 + resources/lang/om/translations.php | 14 + resources/lang/or/translations.php | 14 + resources/lang/pa/translations.php | 14 + resources/lang/pl/translations.php | 14 + resources/lang/ps/translations.php | 14 + resources/lang/pt/translations.php | 14 + resources/lang/pt_BR/translations.php | 14 + resources/lang/qu/translations.php | 14 + resources/lang/ro/translations.php | 14 + resources/lang/ru/translations.php | 14 + resources/lang/rw/translations.php | 14 + resources/lang/sa/translations.php | 14 + resources/lang/sc/translations.php | 14 + resources/lang/sd/translations.php | 14 + resources/lang/si/translations.php | 14 + resources/lang/sk/translations.php | 14 + resources/lang/sl/translations.php | 14 + resources/lang/sn/translations.php | 14 + resources/lang/so/translations.php | 14 + resources/lang/sq/translations.php | 14 + resources/lang/sr_Cyrl/translations.php | 14 + resources/lang/sr_Latn/translations.php | 14 + resources/lang/sr_Latn_ME/translations.php | 14 + resources/lang/su/translations.php | 14 + resources/lang/sv/translations.php | 14 + resources/lang/sw/translations.php | 14 + resources/lang/ta/translations.php | 14 + resources/lang/te/translations.php | 14 + resources/lang/tg/translations.php | 14 + resources/lang/th/translations.php | 14 + resources/lang/ti/translations.php | 14 + resources/lang/tk/translations.php | 14 + resources/lang/tl/translations.php | 14 + resources/lang/tr/translations.php | 14 + resources/lang/tt/translations.php | 14 + resources/lang/ug/translations.php | 14 + resources/lang/uk/translations.php | 14 + resources/lang/ur/translations.php | 14 + resources/lang/uz_Cyrl/translations.php | 14 + resources/lang/uz_Latn/translations.php | 14 + resources/lang/vi/translations.php | 14 + resources/lang/xh/translations.php | 14 + resources/lang/yi/translations.php | 14 + resources/lang/yo/translations.php | 14 + resources/lang/zh_CN/translations.php | 14 + resources/lang/zh_HK/translations.php | 14 + resources/lang/zh_TW/translations.php | 14 + resources/lang/zu/translations.php | 14 + resources/views/components/image.blade.php | 11 +- src/Commands/GenerateCommand.php | 50 +++ src/Commands/UpgradeCommand.php | 2 + src/Components/Image.php | 48 ++- src/Components/Scripts.php | 3 +- src/Contracts/ConfiguresBreakpoints.php | 4 +- src/Entities/AspectRatio.php | 5 + src/Entities/CropData.php | 18 +- src/Entities/ImageContext.php | 405 ++++++++++++++++-- src/Enums/Breakpoint.php | 14 +- src/Facades/ImageLibrary.php | 4 + src/ImageLibrary.php | 22 +- src/ImageLibraryServiceProvider.php | 3 + src/Jobs/GenerateImageVersionJob.php | 102 +++-- .../GenerateResponsiveImageVersionsJob.php | 6 +- src/Models/Image.php | 90 +++- src/Models/SourceImage.php | 2 +- src/Traits/HasImages.php | 2 +- testbench.yaml | 4 + .../Providers/ImageLibraryServiceProvider.php | 4 +- tests/Pest.php | 2 + tests/TestCase.php | 17 +- tests/Unit/Components/ImageTest.php | 260 +++++++++++ tests/Unit/Components/ScriptsTest.php | 27 ++ tests/Unit/Entities/CropDataTest.php | 12 +- tests/Unit/Entities/ImageContextTest.php | 401 +++++++++-------- tests/Unit/Enums/BreakpointTest.php | 4 +- tests/Unit/ImageLibraryTest.php | 2 +- .../Unit/Jobs/GenerateImageVersionJobTest.php | 36 ++ ...GenerateResponsiveImageVersionsJobTest.php | 8 +- tests/Unit/Models/ImageTest.php | 257 ++++++++++- tests/Unit/Models/SourceImageTest.php | 12 +- 172 files changed, 3462 insertions(+), 389 deletions(-) create mode 100644 docs/images/github-banner.png create mode 100644 docs/upgrade-to-v3.md create mode 100644 resources/lang/af/translations.php create mode 100644 resources/lang/ak/translations.php create mode 100644 resources/lang/am/translations.php create mode 100644 resources/lang/ar/translations.php create mode 100644 resources/lang/as/translations.php create mode 100644 resources/lang/az/translations.php create mode 100644 resources/lang/be/translations.php create mode 100644 resources/lang/bg/translations.php create mode 100644 resources/lang/bho/translations.php create mode 100644 resources/lang/bm/translations.php create mode 100644 resources/lang/bn/translations.php create mode 100644 resources/lang/bs/translations.php create mode 100644 resources/lang/ca/translations.php create mode 100644 resources/lang/ceb/translations.php create mode 100644 resources/lang/ckb/translations.php create mode 100644 resources/lang/cs/translations.php create mode 100644 resources/lang/cy/translations.php create mode 100644 resources/lang/da/translations.php create mode 100644 resources/lang/de/translations.php create mode 100644 resources/lang/de_CH/translations.php create mode 100644 resources/lang/doi/translations.php create mode 100644 resources/lang/ee/translations.php create mode 100644 resources/lang/el/translations.php delete mode 100644 resources/lang/en/breakpoints.php create mode 100644 resources/lang/en/translations.php create mode 100644 resources/lang/en_CA/translations.php create mode 100644 resources/lang/eo/translations.php create mode 100644 resources/lang/es/translations.php create mode 100644 resources/lang/et/translations.php create mode 100644 resources/lang/eu/translations.php create mode 100644 resources/lang/fa/translations.php create mode 100644 resources/lang/fi/translations.php create mode 100644 resources/lang/fil/translations.php create mode 100644 resources/lang/fr/translations.php create mode 100644 resources/lang/fy/translations.php create mode 100644 resources/lang/ga/translations.php create mode 100644 resources/lang/gd/translations.php create mode 100644 resources/lang/gl/translations.php create mode 100644 resources/lang/gu/translations.php create mode 100644 resources/lang/ha/translations.php create mode 100644 resources/lang/haw/translations.php create mode 100644 resources/lang/he/translations.php create mode 100644 resources/lang/hi/translations.php create mode 100644 resources/lang/hr/translations.php create mode 100644 resources/lang/hu/translations.php create mode 100644 resources/lang/hy/translations.php create mode 100644 resources/lang/id/translations.php create mode 100644 resources/lang/ig/translations.php create mode 100644 resources/lang/is/translations.php create mode 100644 resources/lang/it/translations.php create mode 100644 resources/lang/ja/translations.php create mode 100644 resources/lang/ka/translations.php create mode 100644 resources/lang/kk/translations.php create mode 100644 resources/lang/km/translations.php create mode 100644 resources/lang/kn/translations.php create mode 100644 resources/lang/ko/translations.php create mode 100644 resources/lang/ku/translations.php create mode 100644 resources/lang/ky/translations.php create mode 100644 resources/lang/lb/translations.php create mode 100644 resources/lang/lg/translations.php create mode 100644 resources/lang/ln/translations.php create mode 100644 resources/lang/lo/translations.php create mode 100644 resources/lang/lt/translations.php create mode 100644 resources/lang/lv/translations.php create mode 100644 resources/lang/mai/translations.php create mode 100644 resources/lang/mg/translations.php create mode 100644 resources/lang/mi/translations.php create mode 100644 resources/lang/mk/translations.php create mode 100644 resources/lang/ml/translations.php create mode 100644 resources/lang/mn/translations.php create mode 100644 resources/lang/mni_Mtei/translations.php create mode 100644 resources/lang/mr/translations.php create mode 100644 resources/lang/ms/translations.php create mode 100644 resources/lang/mt/translations.php create mode 100644 resources/lang/my/translations.php create mode 100644 resources/lang/nb/translations.php create mode 100644 resources/lang/ne/translations.php create mode 100644 resources/lang/nl/translations.php create mode 100644 resources/lang/nn/translations.php create mode 100644 resources/lang/oc/translations.php create mode 100644 resources/lang/om/translations.php create mode 100644 resources/lang/or/translations.php create mode 100644 resources/lang/pa/translations.php create mode 100644 resources/lang/pl/translations.php create mode 100644 resources/lang/ps/translations.php create mode 100644 resources/lang/pt/translations.php create mode 100644 resources/lang/pt_BR/translations.php create mode 100644 resources/lang/qu/translations.php create mode 100644 resources/lang/ro/translations.php create mode 100644 resources/lang/ru/translations.php create mode 100644 resources/lang/rw/translations.php create mode 100644 resources/lang/sa/translations.php create mode 100644 resources/lang/sc/translations.php create mode 100644 resources/lang/sd/translations.php create mode 100644 resources/lang/si/translations.php create mode 100644 resources/lang/sk/translations.php create mode 100644 resources/lang/sl/translations.php create mode 100644 resources/lang/sn/translations.php create mode 100644 resources/lang/so/translations.php create mode 100644 resources/lang/sq/translations.php create mode 100644 resources/lang/sr_Cyrl/translations.php create mode 100644 resources/lang/sr_Latn/translations.php create mode 100644 resources/lang/sr_Latn_ME/translations.php create mode 100644 resources/lang/su/translations.php create mode 100644 resources/lang/sv/translations.php create mode 100644 resources/lang/sw/translations.php create mode 100644 resources/lang/ta/translations.php create mode 100644 resources/lang/te/translations.php create mode 100644 resources/lang/tg/translations.php create mode 100644 resources/lang/th/translations.php create mode 100644 resources/lang/ti/translations.php create mode 100644 resources/lang/tk/translations.php create mode 100644 resources/lang/tl/translations.php create mode 100644 resources/lang/tr/translations.php create mode 100644 resources/lang/tt/translations.php create mode 100644 resources/lang/ug/translations.php create mode 100644 resources/lang/uk/translations.php create mode 100644 resources/lang/ur/translations.php create mode 100644 resources/lang/uz_Cyrl/translations.php create mode 100644 resources/lang/uz_Latn/translations.php create mode 100644 resources/lang/vi/translations.php create mode 100644 resources/lang/xh/translations.php create mode 100644 resources/lang/yi/translations.php create mode 100644 resources/lang/yo/translations.php create mode 100644 resources/lang/zh_CN/translations.php create mode 100644 resources/lang/zh_HK/translations.php create mode 100644 resources/lang/zh_TW/translations.php create mode 100644 resources/lang/zu/translations.php create mode 100644 src/Commands/GenerateCommand.php create mode 100644 testbench.yaml create mode 100644 tests/Unit/Components/ImageTest.php create mode 100644 tests/Unit/Components/ScriptsTest.php diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml index 873a0b9..2fd359e 100644 --- a/.github/workflows/run-tests.yml +++ b/.github/workflows/run-tests.yml @@ -21,14 +21,12 @@ jobs: fail-fast: true matrix: os: [ubuntu-latest, windows-latest] - php: [8.4] - laravel: [12.*, 11.*] + php: [8.5] + laravel: [12.*] stability: [prefer-stable] include: - laravel: 12.* testbench: 10.* - - laravel: 11.* - testbench: 9.* name: P${{ matrix.php }} - L${{ matrix.laravel }} - ${{ matrix.stability }} - ${{ matrix.os }} diff --git a/.gitignore b/.gitignore index 7a431b0..e263e1c 100644 --- a/.gitignore +++ b/.gitignore @@ -27,7 +27,5 @@ _ide_helper_models.php # Misc phpunit.xml phpstan.neon -testbench.yaml -/docs /coverage TODO.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 0055fd9..53034e1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,11 +2,11 @@ All notable changes to `image library` will be documented in this file. -## 3.0.0 - 2025-11-26 +## 3.0.0 - 2025-12-22 ### Changed -- Complete rewrite of the package. Please refer to the (upgrade guide)[UPGRADE.md] for more information. +- Complete rewrite of the package. Please refer to the (upgrade guide)[./docs/upgrade-to-v3.md] for more information. ## 2.7.0 - 2025-02-27 diff --git a/README.md b/README.md index 4df247e..17febce 100644 --- a/README.md +++ b/README.md @@ -97,12 +97,14 @@ Available breakpoints: - **`Breakpoint::Medium`** (`'md'`): 768px and up - Tablets in portrait mode - **`Breakpoint::Large`** (`'lg'`): 1024px and up - Tablets in landscape, small desktops - **`Breakpoint::ExtraLarge`** (`'xl'`): 1280px and up - Desktop screens -- **`Breakpoint::ExtraExtraLarge`** (`'2xl'`): 1536px and up - Large desktop screens +- **`Breakpoint::DoubleExtraLarge`** (`'2xl'`): 1536px and up - Large desktop screens You can use these breakpoints to define different aspect ratios, sizes, crop positions, and effects for different screen sizes, ensuring optimal image display across all devices. > **Note:** If the default breakpoints don't match your design system, you can create custom breakpoints. See [Custom Breakpoints](#custom-breakpoints) in the Configuration section. +If you don't need responsive images, you can disable breakpoints globally in the config file or per ImageContext. + ## Configuration ### The config file @@ -110,10 +112,12 @@ You can use these breakpoints to define different aspect ratios, sizes, crop pos The config file allows you to customize various aspects of the image library. Some key configuration options include: - **`defaults.disk`**: The default filesystem disk for storing images if not specified during upload +- **`use_breakpoints`**: Enable or disable responsive breakpoints globally - **`generate.webp`**: Automatically generate WebP versions of images if not specified in the image context - **`generate.responsive_versions`**: Generate multiple sizes for responsive images if not specified in the image context - **`defaults.crop_position`**: Default crop position for image transformations if not specified in the image context - **`models`**: Customize the Eloquent models used by the package to easily extend functionality +- **`enums`**: Customize the enums used by the package to easily extend functionality - **`spatie_image.driver`**: Choose between 'gd' or 'imagick' for image manipulations ### Javascript @@ -169,6 +173,11 @@ class ImageLibraryServiceProvider extends BaseServiceProvider Breakpoint::Large->value => 250, ]) ->allowsMultiple(false), + + // Image that does not use breakpoints + ImageContext::make('no_breakpoints') + ->label(fn (): string => __('No Breakpoints')) + ->useBreakpoints(false) ]; } } @@ -219,6 +228,17 @@ ImageContext::make('thumbnail') ->generateWebP(false); ``` +#### Using breakpoints + +By default, breakpoints are used based on the global config. You can override this per context: + +```php +ImageContext::make('thumbnail') + ->useBreakpoints(false); +``` + +> ⚠️ **Caution:** Make sure to call `->useBreakpoints(false)` before any other methods that depend on breakpoints, such as `aspectRatio()`, `minWidth()`, `maxWidth()`, etc. Otherwise, you may encounter errors since those methods check if breakpoints are enabled or not. + #### Generating responsive versions By default, responsive versions are generated based on the global config. You can override this per context: @@ -228,6 +248,8 @@ ImageContext::make('thumbnail') ->generateResponsiveVersions(false); ``` +> **Note:** If you disable breakpoints for an ImageContext, responsive versions will also be disabled as these are only generated when breakpoints are used. + #### Aspect Ratio The aspect ratio can be configured per `Breakpoint` in one of the following ways: @@ -244,7 +266,7 @@ ImageContext::make('thumbnail') Breakpoint::Medium->value => AspectRatio::make(4, 3), Breakpoint::Large->value => AspectRatio::make(16, 9), Breakpoint::ExtraLarge->value => AspectRatio::make(16, 9), - Breakpoint::ExtraExtraLarge->value => AspectRatio::make(2, 1), + Breakpoint::DoubleExtraLarge->value => AspectRatio::make(2, 1), ]); // Per breakpoint @@ -280,7 +302,7 @@ ImageContext::make('thumbnail') Breakpoint::Medium->value => 150, Breakpoint::Large->value => 200, Breakpoint::ExtraLarge->value => 250, - Breakpoint::ExtraExtraLarge->value => 300, + Breakpoint::DoubleExtraLarge->value => 300, ]); // Per breakpoint @@ -316,7 +338,7 @@ ImageContext::make('thumbnail') Breakpoint::Medium->value => 200, Breakpoint::Large->value => 250, Breakpoint::ExtraLarge->value => 300, - Breakpoint::ExtraExtraLarge->value => 350, + Breakpoint::DoubleExtraLarge->value => 350, ]); // Per breakpoint @@ -352,7 +374,7 @@ ImageContext::make('thumbnail') Breakpoint::Medium->value => CropPosition::Center, Breakpoint::Large->value => CropPosition::Bottom, Breakpoint::ExtraLarge->value => CropPosition::Center, - Breakpoint::ExtraExtraLarge->value => CropPosition::Center, + Breakpoint::DoubleExtraLarge->value => CropPosition::Center, ]); // Per breakpoint @@ -388,7 +410,7 @@ ImageContext::make('thumbnail') Breakpoint::Medium->value => 10, Breakpoint::Large->value => 15, Breakpoint::ExtraLarge->value => 20, - Breakpoint::ExtraExtraLarge->value => 25, + Breakpoint::DoubleExtraLarge->value => 25, ]); // Per breakpoint @@ -424,7 +446,7 @@ ImageContext::make('thumbnail') Breakpoint::Medium->value => true, Breakpoint::Large->value => false, Breakpoint::ExtraLarge->value => true, - Breakpoint::ExtraExtraLarge->value => false, + Breakpoint::DoubleExtraLarge->value => false, ]); // Per breakpoint @@ -460,7 +482,7 @@ ImageContext::make('thumbnail') Breakpoint::Medium->value => true, Breakpoint::Large->value => false, Breakpoint::ExtraLarge->value => true, - Breakpoint::ExtraExtraLarge->value => false, + Breakpoint::DoubleExtraLarge->value => false, ]); // Per breakpoint @@ -634,6 +656,25 @@ Update your `config/image-library.php` file to use your custom enum: ], ``` +### Disabling breakpoints + +If you application or specific context does not require image versions per breakpoint, you can disable breakpoints: + +#### Globally + +```php +'use_breakpoints' => false, +``` + +#### Per ImageContext + +```php +ImageContext::make('thumbnail') + ->useBreakpoints(false); +``` + +> ⚠️ **Caution:** Make sure to call `->useBreakpoints(false)` before any other methods that depend on breakpoints, such as `aspectRatio()`, `minWidth()`, `maxWidth()`, etc. Otherwise, you may encounter errors since those methods check if breakpoints are enabled or not. + ## Usage ### Uploading an image @@ -795,6 +836,28 @@ This script will set all `sizes` attributes of the picture elements automaticall - The picture element is added to the viewport - The picture element width changes +## (Re)generating images + +You can (re)generate images using the following artisan command: + +```bash +php artisan image-library:generate +``` + +This will (re)generate all images files for all `image` records in the database based on their associated `ImageContext` configuration. + +You can also (re)generate image files for a specific image: + +```bash +php artisan image-library:generate {id} +``` + +Or for multiple images: + +```bash +php artisan image-library:generate {id1} {id2} {id3} +``` + ## Upgrading ### From v2.x to v3.0 diff --git a/composer.json b/composer.json index a3f6cf1..b55ce1d 100644 --- a/composer.json +++ b/composer.json @@ -22,6 +22,7 @@ "illuminate/support": "^11.0||^12.0", "nesbot/carbon": "^3.10", "outerweb/enum-helpers": "*", + "outerweb/filament-translatable-fields": "^4.0", "spatie/eloquent-sortable": "^4.5", "spatie/image": "^3.8", "spatie/laravel-package-tools": "^1.16", diff --git a/config/image-library.php b/config/image-library.php index 507faf9..d5ea68a 100644 --- a/config/image-library.php +++ b/config/image-library.php @@ -26,6 +26,7 @@ 'enums' => [ 'breakpoint' => Breakpoint::class, ], + 'use_breakpoints' => true, 'generate' => [ 'webp' => true, 'responsive_versions' => true, diff --git a/database/factories/ImageFactory.php b/database/factories/ImageFactory.php index dcc26fa..555df17 100644 --- a/database/factories/ImageFactory.php +++ b/database/factories/ImageFactory.php @@ -21,7 +21,7 @@ public function definition(): array return [ 'source_image_id' => ImageLibrary::getSourceImageModel()::factory(), 'disk' => function (array $attributes) { - return ImageLibrary::getSourceImageModel()::find($attributes['source_image_id'])?->disk ?? ImageLibrary::getDefaultDisk(); + return ImageLibrary::getSourceImageModel()::find($attributes['source_image_id'])->disk ?? ImageLibrary::getDefaultDisk(); }, 'context' => fake()->randomElement(ImageLibrary::getImageContexts()), 'crop_data' => function (array $attributes): array { @@ -39,6 +39,9 @@ public function definition(): array y: fake()->numberBetween(0, max(0, $maxHeight - $height)), width: $width, height: $height, + rotate: fake()->randomElement([0, 90, 180, 270]), + scaleX: fake()->randomElement([1, -1]), + scaleY: fake()->randomElement([1, -1]), )]; }) ->all(); diff --git a/database/migrations/create_images_table.php.stub b/database/migrations/create_images_table.php.stub index 0461ec7..b100a77 100644 --- a/database/migrations/create_images_table.php.stub +++ b/database/migrations/create_images_table.php.stub @@ -32,6 +32,8 @@ return new class extends Migration $table->timestamps(); $table->index(['context', 'context_configuration_hash']); + $table->index(['context', 'sort_order']); + $table->index('created_at'); }); } diff --git a/database/migrations/create_source_images_table.php.stub b/database/migrations/create_source_images_table.php.stub index 128ceda..6de76db 100644 --- a/database/migrations/create_source_images_table.php.stub +++ b/database/migrations/create_source_images_table.php.stub @@ -29,6 +29,9 @@ return new class extends Migration $table->json('custom_properties') ->nullable(); $table->timestamps(); + + $table->index(['created_at', 'disk']); + $table->index(['name', 'disk']); }); } diff --git a/docs/images/github-banner.png b/docs/images/github-banner.png new file mode 100644 index 0000000000000000000000000000000000000000..4c7f0ec4098e52774c8a76baedccf921a839425c GIT binary patch literal 21931 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�tO{RRJ-4{1eFDK@Hb)Mh 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 SQmoB;#q_eJVbvvt5r>&m= literal 0 HcmV?d00001 diff --git a/docs/upgrade-to-v3.md b/docs/upgrade-to-v3.md new file mode 100644 index 0000000..270f025 --- /dev/null +++ b/docs/upgrade-to-v3.md @@ -0,0 +1,116 @@ +# Upgrade Guide + +## Upgrading to version 3.0.0 + +> ⚠️ **Caution:** This is a complete rewrite of the package and logic. Please review the changes carefully and back up your database and filesystem before proceeding. + +### Prerequisites + +Before starting the upgrade process: + +1. **Create a full backup** of your database and file storage +2. **Test the upgrade process** in a staging environment first +3. **Review your custom code** that uses the image library to understand required changes + +### Step 1: Update the image-library config file + +The config file has been completely rewritten. You must re-publish it: + +```bash +php artisan vendor:publish --tag=image-library-config --force +``` + +### Step 2: Run the upgrade command + +The package includes an automated upgrade command to migrate your existing data: + +```bash +php artisan image-library:upgrade +``` + +The upgrade command will walk you through the following 9 steps: + +1. **Check if an upgrade is needed** - Looks for existing images table migrations +2. **Create source_images table migration** - New table for storing source image files +3. **Create new images table migration** - Updated schema for the images table +4. **Create pre-upgrade migration** - Renames existing `images` table to `tmp_images` (see [Pre-upgrade Migration Details](#pre-upgrade-migration-details)) +5. **Create post-upgrade migration** - Migrates data from old to new structure (see [Post-upgrade Migration Details](#post-upgrade-migration-details)) +6. **Run migrations** - Prompts to execute the new migrations +7. **Manual data migration prompt** - Pauses for you to migrate custom model relationships (see [Migrating Custom Model Relationships](#migrating-custom-model-relationships)) +8. **Cleanup old data** - Offers to create cleanup migration (see [Cleanup Migration Details](#cleanup-migration-details)) +9. **Run cleanup migration** - Optionally executes the cleanup to remove old data + +## Detailed Migration Information + +### Pre-upgrade Migration Details + +The pre-upgrade migration (`pre_image_library_upgrade.php`) performs a simple but critical step: + +- **Renames the existing `images` table to `tmp_images`** +- This preserves all your existing image data during the upgrade process +- The old data remains accessible for mapping to the new structure + +### Post-upgrade Migration Details + +The post-upgrade migration (`post_image_library_upgrade.php`) handles the complex data migration: + +- **Reads each record from the `tmp_images` table** +- **Creates corresponding `SourceImage` records** with the same UUID +- **Uploads image files to the new storage structure** while preserving UUIDs +- **Maintains file integrity** by copying files from old to new locations + +This migration ensures that: + +- All existing image files are preserved +- UUIDs remain consistent for data mapping +- Files are properly structured in the new system + +### Migrating Custom Model Relationships + +After the automated migration completes, you need to update your models and relationships manually. + +Follow the configuration steps in the [README](README.md). + +#### Migrate existing image relationships + +Use the provided mapping query to connect old images to new structure: + +```php +// Get the ID mapping between old and new images +$mapping = DB::table('tmp_images') + ->join('source_images', 'tmp_images.uuid', '=', 'source_images.uuid') + ->pluck('source_images.id', 'tmp_images.id'); + +// For each of your models, attach the images using the new system +foreach (YourModel::cursor() as $model) { + // Replace 'old_image_id' with your actual column name + $oldImageId = $model->old_image_id; + + if (isset($mapping[$oldImageId])) { + $sourceImage = SourceImage::find($mapping[$oldImageId]); + + $model->attachImage($sourceImage, [ + 'context' => 'your-context-key', // Replace with your context + // Add any other attributes you need + ]); + } +} +``` + +### Cleanup Migration Details + +The cleanup migration (`cleanup_image_library_upgrade.php`) performs final cleanup: + +- **Deletes old image files** from storage (`{uuid}/original.{extension}` format) +- **Drops the `tmp_images` table** (your backup of the original data) +- **Drops the `image_conversions` table** if it exists from the old system + +> ⚠️ **Warning:** This migration is **not reversible**. Ensure everything works correctly before running cleanup. + +#### Manual verification before cleanup + +Before running the cleanup, verify your migration was successful: + +1. Check that all your models can access their images correctly +2. Verify image display in your application works as expected +3. Test image upload and manipulation functionality diff --git a/phpstan.neon.dist b/phpstan.neon.dist index ab1b4c3..7835db3 100644 --- a/phpstan.neon.dist +++ b/phpstan.neon.dist @@ -5,8 +5,11 @@ parameters: level: 5 paths: - src - - config - database tmpDir: build/phpstan checkOctaneCompatibility: true checkModelProperties: true + ignoreErrors: + - + identifiers: + - trait.unused diff --git a/resources/lang/af/translations.php b/resources/lang/af/translations.php new file mode 100644 index 0000000..901ff70 --- /dev/null +++ b/resources/lang/af/translations.php @@ -0,0 +1,14 @@ + [ + 'xs' => 'Ekstra Klein', + 'sm' => 'Klein', + 'md' => 'Medium', + 'lg' => 'Groot', + 'xl' => 'Ekstra Groot', + '2xl' => 'Dubbel Ekstra Groot', + ], +]; diff --git a/resources/lang/ak/translations.php b/resources/lang/ak/translations.php new file mode 100644 index 0000000..32329d8 --- /dev/null +++ b/resources/lang/ak/translations.php @@ -0,0 +1,14 @@ + [ + 'xs' => 'Ketewa', + 'sm' => 'Ketewa', + 'md' => 'Mfimfini', + 'lg' => 'Kɛse', + 'xl' => 'Kɛse Papa', + '2xl' => 'Kɛse Papa Mmienu', + ], +]; diff --git a/resources/lang/am/translations.php b/resources/lang/am/translations.php new file mode 100644 index 0000000..ac768f4 --- /dev/null +++ b/resources/lang/am/translations.php @@ -0,0 +1,14 @@ + [ + 'xs' => 'ተጨማሪ ትንሽ', + 'sm' => 'ትንሽ', + 'md' => 'መካከለኛ', + 'lg' => 'ትልቅ', + 'xl' => 'ተጨማሪ ትልቅ', + '2xl' => 'ድርብ ተጨማሪ ትልቅ', + ], +]; diff --git a/resources/lang/ar/translations.php b/resources/lang/ar/translations.php new file mode 100644 index 0000000..ef0a98c --- /dev/null +++ b/resources/lang/ar/translations.php @@ -0,0 +1,14 @@ + [ + 'xs' => 'صغير جداً', + 'sm' => 'صغير', + 'md' => 'متوسط', + 'lg' => 'كبير', + 'xl' => 'كبير جداً', + '2xl' => 'كبير جداً مضاعف', + ], +]; diff --git a/resources/lang/as/translations.php b/resources/lang/as/translations.php new file mode 100644 index 0000000..db50eb9 --- /dev/null +++ b/resources/lang/as/translations.php @@ -0,0 +1,14 @@ + [ + 'xs' => 'অতিৰিক্ত সৰু', + 'sm' => 'সৰু', + 'md' => 'মধ্যম', + 'lg' => 'ডাঙৰ', + 'xl' => 'অতিৰিক্ত ডাঙৰ', + '2xl' => 'দুগুণ অতিৰিক্ত ডাঙৰ', + ], +]; diff --git a/resources/lang/az/translations.php b/resources/lang/az/translations.php new file mode 100644 index 0000000..ed0a8a6 --- /dev/null +++ b/resources/lang/az/translations.php @@ -0,0 +1,14 @@ + [ + 'xs' => 'Çox Kiçik', + 'sm' => 'Kiçik', + 'md' => 'Orta', + 'lg' => 'Böyük', + 'xl' => 'Çox Böyük', + '2xl' => 'İkiqat Böyük', + ], +]; diff --git a/resources/lang/be/translations.php b/resources/lang/be/translations.php new file mode 100644 index 0000000..0901419 --- /dev/null +++ b/resources/lang/be/translations.php @@ -0,0 +1,14 @@ + [ + 'xs' => 'Вельмі малы', + 'sm' => 'Малы', + 'md' => 'Сярэдні', + 'lg' => 'Вялікі', + 'xl' => 'Вельмі вялікі', + '2xl' => 'Падвойны вялікі', + ], +]; diff --git a/resources/lang/bg/translations.php b/resources/lang/bg/translations.php new file mode 100644 index 0000000..128d079 --- /dev/null +++ b/resources/lang/bg/translations.php @@ -0,0 +1,14 @@ + [ + 'xs' => 'Много малък', + 'sm' => 'Малък', + 'md' => 'Среден', + 'lg' => 'Голям', + 'xl' => 'Много голям', + '2xl' => 'Двойно голям', + ], +]; diff --git a/resources/lang/bho/translations.php b/resources/lang/bho/translations.php new file mode 100644 index 0000000..6dd7c22 --- /dev/null +++ b/resources/lang/bho/translations.php @@ -0,0 +1,14 @@ + [ + 'xs' => 'बहुत छोट', + 'sm' => 'छोट', + 'md' => 'मध्यम', + 'lg' => 'बड़', + 'xl' => 'बहुत बड़', + '2xl' => 'दुगुना बड़', + ], +]; diff --git a/resources/lang/bm/translations.php b/resources/lang/bm/translations.php new file mode 100644 index 0000000..022700f --- /dev/null +++ b/resources/lang/bm/translations.php @@ -0,0 +1,14 @@ + [ + 'xs' => 'Fitinin', + 'sm' => 'Dògoroba', + 'md' => 'Cèmancè', + 'lg' => 'Ba', + 'xl' => 'Ba kosɛbɛ', + '2xl' => 'Ba fla', + ], +]; diff --git a/resources/lang/bn/translations.php b/resources/lang/bn/translations.php new file mode 100644 index 0000000..41cca6e --- /dev/null +++ b/resources/lang/bn/translations.php @@ -0,0 +1,14 @@ + [ + 'xs' => 'অতিরিক্ত ছোট', + 'sm' => 'ছোট', + 'md' => 'মাঝারি', + 'lg' => 'বড়', + 'xl' => 'অতিরিক্ত বড়', + '2xl' => 'দ্বিগুণ বড়', + ], +]; diff --git a/resources/lang/bs/translations.php b/resources/lang/bs/translations.php new file mode 100644 index 0000000..680a34f --- /dev/null +++ b/resources/lang/bs/translations.php @@ -0,0 +1,14 @@ + [ + 'xs' => 'Vrlo mali', + 'sm' => 'Mali', + 'md' => 'Srednji', + 'lg' => 'Veliki', + 'xl' => 'Vrlo veliki', + '2xl' => 'Dvostruko veliki', + ], +]; diff --git a/resources/lang/ca/translations.php b/resources/lang/ca/translations.php new file mode 100644 index 0000000..d9516f4 --- /dev/null +++ b/resources/lang/ca/translations.php @@ -0,0 +1,14 @@ + [ + 'xs' => 'Extra petit', + 'sm' => 'Petit', + 'md' => 'Mitjà', + 'lg' => 'Gran', + 'xl' => 'Extra gran', + '2xl' => 'Doble extra gran', + ], +]; diff --git a/resources/lang/ceb/translations.php b/resources/lang/ceb/translations.php new file mode 100644 index 0000000..4007638 --- /dev/null +++ b/resources/lang/ceb/translations.php @@ -0,0 +1,14 @@ + [ + 'xs' => 'Gamay kaayo', + 'sm' => 'Gamay', + 'md' => 'Tunga', + 'lg' => 'Dako', + 'xl' => 'Dako kaayo', + '2xl' => 'Doble nga dako', + ], +]; diff --git a/resources/lang/ckb/translations.php b/resources/lang/ckb/translations.php new file mode 100644 index 0000000..4a6ec1b --- /dev/null +++ b/resources/lang/ckb/translations.php @@ -0,0 +1,14 @@ + [ + 'xs' => 'زۆر بچووک', + 'sm' => 'بچووک', + 'md' => 'ناوەند', + 'lg' => 'گەورە', + 'xl' => 'زۆر گەورە', + '2xl' => 'دووقات گەورە', + ], +]; diff --git a/resources/lang/cs/translations.php b/resources/lang/cs/translations.php new file mode 100644 index 0000000..287f80d --- /dev/null +++ b/resources/lang/cs/translations.php @@ -0,0 +1,14 @@ + [ + 'xs' => 'Extra malý', + 'sm' => 'Malý', + 'md' => 'Střední', + 'lg' => 'Velký', + 'xl' => 'Extra velký', + '2xl' => 'Dvojitě velký', + ], +]; diff --git a/resources/lang/cy/translations.php b/resources/lang/cy/translations.php new file mode 100644 index 0000000..586ddb0 --- /dev/null +++ b/resources/lang/cy/translations.php @@ -0,0 +1,14 @@ + [ + 'xs' => 'Bach iawn', + 'sm' => 'Bach', + 'md' => 'Canolig', + 'lg' => 'Mawr', + 'xl' => 'Mawr iawn', + '2xl' => 'Dwbwl mawr', + ], +]; diff --git a/resources/lang/da/translations.php b/resources/lang/da/translations.php new file mode 100644 index 0000000..95548d3 --- /dev/null +++ b/resources/lang/da/translations.php @@ -0,0 +1,14 @@ + [ + 'xs' => 'Ekstra lille', + 'sm' => 'Lille', + 'md' => 'Medium', + 'lg' => 'Stor', + 'xl' => 'Ekstra stor', + '2xl' => 'Dobbelt stor', + ], +]; diff --git a/resources/lang/de/translations.php b/resources/lang/de/translations.php new file mode 100644 index 0000000..d888306 --- /dev/null +++ b/resources/lang/de/translations.php @@ -0,0 +1,14 @@ + [ + 'xs' => 'Extra Klein', + 'sm' => 'Klein', + 'md' => 'Mittel', + 'lg' => 'Groß', + 'xl' => 'Extra Groß', + '2xl' => 'Doppelt Groß', + ], +]; diff --git a/resources/lang/de_CH/translations.php b/resources/lang/de_CH/translations.php new file mode 100644 index 0000000..8e5d6f5 --- /dev/null +++ b/resources/lang/de_CH/translations.php @@ -0,0 +1,14 @@ + [ + 'xs' => 'Extra Chli', + 'sm' => 'Chli', + 'md' => 'Mittel', + 'lg' => 'Gross', + 'xl' => 'Extra Gross', + '2xl' => 'Dopplet Gross', + ], +]; diff --git a/resources/lang/doi/translations.php b/resources/lang/doi/translations.php new file mode 100644 index 0000000..a0c78dd --- /dev/null +++ b/resources/lang/doi/translations.php @@ -0,0 +1,14 @@ + [ + 'xs' => 'बहुत छोटा', + 'sm' => 'छोटा', + 'md' => 'मध्यम', + 'lg' => 'बड़ा', + 'xl' => 'बहुत बड़ा', + '2xl' => 'दुगुना बड़ा', + ], +]; diff --git a/resources/lang/ee/translations.php b/resources/lang/ee/translations.php new file mode 100644 index 0000000..7ec0f45 --- /dev/null +++ b/resources/lang/ee/translations.php @@ -0,0 +1,14 @@ + [ + 'xs' => 'Sue akpa', + 'sm' => 'Sue', + 'md' => 'Titina', + 'lg' => 'Lolo', + 'xl' => 'Lolo akpa', + '2xl' => 'Lolo eve', + ], +]; diff --git a/resources/lang/el/translations.php b/resources/lang/el/translations.php new file mode 100644 index 0000000..c523557 --- /dev/null +++ b/resources/lang/el/translations.php @@ -0,0 +1,14 @@ + [ + 'xs' => 'Πολύ μικρό', + 'sm' => 'Μικρό', + 'md' => 'Μεσαίο', + 'lg' => 'Μεγάλο', + 'xl' => 'Πολύ μεγάλο', + '2xl' => 'Διπλά μεγάλο', + ], +]; diff --git a/resources/lang/en/breakpoints.php b/resources/lang/en/breakpoints.php deleted file mode 100644 index d4fc958..0000000 --- a/resources/lang/en/breakpoints.php +++ /dev/null @@ -1,12 +0,0 @@ - 'Extra Small', - 'sm' => 'Small', - 'md' => 'Medium', - 'lg' => 'Large', - 'xl' => 'Extra Large', - '2xl' => 'Extra Extra Large', -]; diff --git a/resources/lang/en/translations.php b/resources/lang/en/translations.php new file mode 100644 index 0000000..d6bb29d --- /dev/null +++ b/resources/lang/en/translations.php @@ -0,0 +1,14 @@ + [ + 'xs' => 'Extra Small', + 'sm' => 'Small', + 'md' => 'Medium', + 'lg' => 'Large', + 'xl' => 'Extra Large', + '2xl' => 'Double Extra Large', + ], +]; diff --git a/resources/lang/en_CA/translations.php b/resources/lang/en_CA/translations.php new file mode 100644 index 0000000..d6bb29d --- /dev/null +++ b/resources/lang/en_CA/translations.php @@ -0,0 +1,14 @@ + [ + 'xs' => 'Extra Small', + 'sm' => 'Small', + 'md' => 'Medium', + 'lg' => 'Large', + 'xl' => 'Extra Large', + '2xl' => 'Double Extra Large', + ], +]; diff --git a/resources/lang/eo/translations.php b/resources/lang/eo/translations.php new file mode 100644 index 0000000..dabc7c1 --- /dev/null +++ b/resources/lang/eo/translations.php @@ -0,0 +1,14 @@ + [ + 'xs' => 'Tre malgranda', + 'sm' => 'Malgranda', + 'md' => 'Meza', + 'lg' => 'Granda', + 'xl' => 'Tre granda', + '2xl' => 'Duoble granda', + ], +]; diff --git a/resources/lang/es/translations.php b/resources/lang/es/translations.php new file mode 100644 index 0000000..dd545d6 --- /dev/null +++ b/resources/lang/es/translations.php @@ -0,0 +1,14 @@ + [ + 'xs' => 'Extra pequeño', + 'sm' => 'Pequeño', + 'md' => 'Mediano', + 'lg' => 'Grande', + 'xl' => 'Extra grande', + '2xl' => 'Doble extra grande', + ], +]; diff --git a/resources/lang/et/translations.php b/resources/lang/et/translations.php new file mode 100644 index 0000000..32b2391 --- /dev/null +++ b/resources/lang/et/translations.php @@ -0,0 +1,14 @@ + [ + 'xs' => 'Eriti väike', + 'sm' => 'Väike', + 'md' => 'Keskmine', + 'lg' => 'Suur', + 'xl' => 'Eriti suur', + '2xl' => 'Topelt suur', + ], +]; diff --git a/resources/lang/eu/translations.php b/resources/lang/eu/translations.php new file mode 100644 index 0000000..b7a5910 --- /dev/null +++ b/resources/lang/eu/translations.php @@ -0,0 +1,14 @@ + [ + 'xs' => 'Oso txikia', + 'sm' => 'Txikia', + 'md' => 'Ertaina', + 'lg' => 'Handia', + 'xl' => 'Oso handia', + '2xl' => 'Bikoitza handia', + ], +]; diff --git a/resources/lang/fa/translations.php b/resources/lang/fa/translations.php new file mode 100644 index 0000000..350b707 --- /dev/null +++ b/resources/lang/fa/translations.php @@ -0,0 +1,14 @@ + [ + 'xs' => 'خیلی کوچک', + 'sm' => 'کوچک', + 'md' => 'متوسط', + 'lg' => 'بزرگ', + 'xl' => 'خیلی بزرگ', + '2xl' => 'دوبرابر بزرگ', + ], +]; diff --git a/resources/lang/fi/translations.php b/resources/lang/fi/translations.php new file mode 100644 index 0000000..fc353a3 --- /dev/null +++ b/resources/lang/fi/translations.php @@ -0,0 +1,14 @@ + [ + 'xs' => 'Erittäin pieni', + 'sm' => 'Pieni', + 'md' => 'Keskikokoinen', + 'lg' => 'Suuri', + 'xl' => 'Erittäin suuri', + '2xl' => 'Kaksinkertainen suuri', + ], +]; diff --git a/resources/lang/fil/translations.php b/resources/lang/fil/translations.php new file mode 100644 index 0000000..dab20b3 --- /dev/null +++ b/resources/lang/fil/translations.php @@ -0,0 +1,14 @@ + [ + 'xs' => 'Napaka-liit', + 'sm' => 'Maliit', + 'md' => 'Katamtaman', + 'lg' => 'Malaki', + 'xl' => 'Napaka-laki', + '2xl' => 'Doble laki', + ], +]; diff --git a/resources/lang/fr/translations.php b/resources/lang/fr/translations.php new file mode 100644 index 0000000..846ba6f --- /dev/null +++ b/resources/lang/fr/translations.php @@ -0,0 +1,14 @@ + [ + 'xs' => 'Très petit', + 'sm' => 'Petit', + 'md' => 'Moyen', + 'lg' => 'Grand', + 'xl' => 'Très grand', + '2xl' => 'Double grand', + ], +]; diff --git a/resources/lang/fy/translations.php b/resources/lang/fy/translations.php new file mode 100644 index 0000000..eccfdc2 --- /dev/null +++ b/resources/lang/fy/translations.php @@ -0,0 +1,14 @@ + [ + 'xs' => 'Ekstra lyts', + 'sm' => 'Lyts', + 'md' => 'Middel', + 'lg' => 'Grut', + 'xl' => 'Ekstra grut', + '2xl' => 'Dûbel grut', + ], +]; diff --git a/resources/lang/ga/translations.php b/resources/lang/ga/translations.php new file mode 100644 index 0000000..4b98827 --- /dev/null +++ b/resources/lang/ga/translations.php @@ -0,0 +1,14 @@ + [ + 'xs' => 'An-bheag', + 'sm' => 'Beag', + 'md' => 'Meánach', + 'lg' => 'Mór', + 'xl' => 'An-mhór', + '2xl' => 'Dúbailte mór', + ], +]; diff --git a/resources/lang/gd/translations.php b/resources/lang/gd/translations.php new file mode 100644 index 0000000..5bd83ae --- /dev/null +++ b/resources/lang/gd/translations.php @@ -0,0 +1,14 @@ + [ + 'xs' => 'Glè bheag', + 'sm' => 'Beag', + 'md' => 'Meadhanach', + 'lg' => 'Mòr', + 'xl' => 'Glè mhòr', + '2xl' => 'Dùbailte mòr', + ], +]; diff --git a/resources/lang/gl/translations.php b/resources/lang/gl/translations.php new file mode 100644 index 0000000..7a3058e --- /dev/null +++ b/resources/lang/gl/translations.php @@ -0,0 +1,14 @@ + [ + 'xs' => 'Extra pequeno', + 'sm' => 'Pequeno', + 'md' => 'Mediano', + 'lg' => 'Grande', + 'xl' => 'Extra grande', + '2xl' => 'Dobre grande', + ], +]; diff --git a/resources/lang/gu/translations.php b/resources/lang/gu/translations.php new file mode 100644 index 0000000..79867f5 --- /dev/null +++ b/resources/lang/gu/translations.php @@ -0,0 +1,14 @@ + [ + 'xs' => 'ખૂબ નાનું', + 'sm' => 'નાનું', + 'md' => 'મધ્યમ', + 'lg' => 'મોટું', + 'xl' => 'ખૂબ મોટું', + '2xl' => 'બમણું મોટું', + ], +]; diff --git a/resources/lang/ha/translations.php b/resources/lang/ha/translations.php new file mode 100644 index 0000000..5f98245 --- /dev/null +++ b/resources/lang/ha/translations.php @@ -0,0 +1,14 @@ + [ + 'xs' => 'Ƙarami sosai', + 'sm' => 'Ƙarami', + 'md' => 'Matsakaici', + 'lg' => 'Babba', + 'xl' => 'Babba sosai', + '2xl' => 'Babba sau biyu', + ], +]; diff --git a/resources/lang/haw/translations.php b/resources/lang/haw/translations.php new file mode 100644 index 0000000..e75a80e --- /dev/null +++ b/resources/lang/haw/translations.php @@ -0,0 +1,14 @@ + [ + 'xs' => 'Liʻiliʻi loa', + 'sm' => 'Liʻiliʻi', + 'md' => 'Waena', + 'lg' => 'Nui', + 'xl' => 'Nui loa', + '2xl' => 'Nui lua', + ], +]; diff --git a/resources/lang/he/translations.php b/resources/lang/he/translations.php new file mode 100644 index 0000000..a4ff440 --- /dev/null +++ b/resources/lang/he/translations.php @@ -0,0 +1,14 @@ + [ + 'xs' => 'קטן מאוד', + 'sm' => 'קטן', + 'md' => 'בינוני', + 'lg' => 'גדול', + 'xl' => 'גדול מאוד', + '2xl' => 'גדול כפול', + ], +]; diff --git a/resources/lang/hi/translations.php b/resources/lang/hi/translations.php new file mode 100644 index 0000000..a0c78dd --- /dev/null +++ b/resources/lang/hi/translations.php @@ -0,0 +1,14 @@ + [ + 'xs' => 'बहुत छोटा', + 'sm' => 'छोटा', + 'md' => 'मध्यम', + 'lg' => 'बड़ा', + 'xl' => 'बहुत बड़ा', + '2xl' => 'दुगुना बड़ा', + ], +]; diff --git a/resources/lang/hr/translations.php b/resources/lang/hr/translations.php new file mode 100644 index 0000000..680a34f --- /dev/null +++ b/resources/lang/hr/translations.php @@ -0,0 +1,14 @@ + [ + 'xs' => 'Vrlo mali', + 'sm' => 'Mali', + 'md' => 'Srednji', + 'lg' => 'Veliki', + 'xl' => 'Vrlo veliki', + '2xl' => 'Dvostruko veliki', + ], +]; diff --git a/resources/lang/hu/translations.php b/resources/lang/hu/translations.php new file mode 100644 index 0000000..127f6ca --- /dev/null +++ b/resources/lang/hu/translations.php @@ -0,0 +1,14 @@ + [ + 'xs' => 'Extra kicsi', + 'sm' => 'Kicsi', + 'md' => 'Közepes', + 'lg' => 'Nagy', + 'xl' => 'Extra nagy', + '2xl' => 'Dupla nagy', + ], +]; diff --git a/resources/lang/hy/translations.php b/resources/lang/hy/translations.php new file mode 100644 index 0000000..39d2513 --- /dev/null +++ b/resources/lang/hy/translations.php @@ -0,0 +1,14 @@ + [ + 'xs' => 'Շատ փոքր', + 'sm' => 'Փոքր', + 'md' => 'Միջին', + 'lg' => 'Մեծ', + 'xl' => 'Շատ մեծ', + '2xl' => 'Կրկնակի մեծ', + ], +]; diff --git a/resources/lang/id/translations.php b/resources/lang/id/translations.php new file mode 100644 index 0000000..d003a87 --- /dev/null +++ b/resources/lang/id/translations.php @@ -0,0 +1,14 @@ + [ + 'xs' => 'Sangat kecil', + 'sm' => 'Kecil', + 'md' => 'Sedang', + 'lg' => 'Besar', + 'xl' => 'Sangat besar', + '2xl' => 'Dua kali besar', + ], +]; diff --git a/resources/lang/ig/translations.php b/resources/lang/ig/translations.php new file mode 100644 index 0000000..5173282 --- /dev/null +++ b/resources/lang/ig/translations.php @@ -0,0 +1,14 @@ + [ + 'xs' => 'Obere nta', + 'sm' => 'Obere', + 'md' => 'Nkezi', + 'lg' => 'Nnukwu', + 'xl' => 'Nnukwu nke ukwu', + '2xl' => 'Okpukpu abụọ', + ], +]; diff --git a/resources/lang/is/translations.php b/resources/lang/is/translations.php new file mode 100644 index 0000000..5202924 --- /dev/null +++ b/resources/lang/is/translations.php @@ -0,0 +1,14 @@ + [ + 'xs' => 'Ofur lítill', + 'sm' => 'Lítill', + 'md' => 'Miðlungs', + 'lg' => 'Stór', + 'xl' => 'Ofur stór', + '2xl' => 'Tvöfalt stór', + ], +]; diff --git a/resources/lang/it/translations.php b/resources/lang/it/translations.php new file mode 100644 index 0000000..e6463fe --- /dev/null +++ b/resources/lang/it/translations.php @@ -0,0 +1,14 @@ + [ + 'xs' => 'Extra piccolo', + 'sm' => 'Piccolo', + 'md' => 'Medio', + 'lg' => 'Grande', + 'xl' => 'Extra grande', + '2xl' => 'Doppio grande', + ], +]; diff --git a/resources/lang/ja/translations.php b/resources/lang/ja/translations.php new file mode 100644 index 0000000..3ac10ff --- /dev/null +++ b/resources/lang/ja/translations.php @@ -0,0 +1,14 @@ + [ + 'xs' => '超小', + 'sm' => '小', + 'md' => '中', + 'lg' => '大', + 'xl' => '超大', + '2xl' => '特大', + ], +]; diff --git a/resources/lang/ka/translations.php b/resources/lang/ka/translations.php new file mode 100644 index 0000000..02f9c4f --- /dev/null +++ b/resources/lang/ka/translations.php @@ -0,0 +1,14 @@ + [ + 'xs' => 'ძალიან პატარა', + 'sm' => 'პატარა', + 'md' => 'საშუალო', + 'lg' => 'დიდი', + 'xl' => 'ძალიან დიდი', + '2xl' => 'ორმაგი დიდი', + ], +]; diff --git a/resources/lang/kk/translations.php b/resources/lang/kk/translations.php new file mode 100644 index 0000000..01dee14 --- /dev/null +++ b/resources/lang/kk/translations.php @@ -0,0 +1,14 @@ + [ + 'xs' => 'Өте кіші', + 'sm' => 'Кіші', + 'md' => 'Орташа', + 'lg' => 'Үлкен', + 'xl' => 'Өте үлкен', + '2xl' => 'Қос үлкен', + ], +]; diff --git a/resources/lang/km/translations.php b/resources/lang/km/translations.php new file mode 100644 index 0000000..6c256a9 --- /dev/null +++ b/resources/lang/km/translations.php @@ -0,0 +1,14 @@ + [ + 'xs' => 'តូចបំផុត', + 'sm' => 'តូច', + 'md' => 'កម្រិតមធ្យម', + 'lg' => 'ធំ', + 'xl' => 'ធំបំផុត', + '2xl' => 'ធំទ្វេដង', + ], +]; diff --git a/resources/lang/kn/translations.php b/resources/lang/kn/translations.php new file mode 100644 index 0000000..55e4687 --- /dev/null +++ b/resources/lang/kn/translations.php @@ -0,0 +1,14 @@ + [ + 'xs' => 'ಅತಿ ಚಿಕ್ಕ', + 'sm' => 'ಚಿಕ್ಕ', + 'md' => 'ಮಧ್ಯಮ', + 'lg' => 'ದೊಡ್ಡ', + 'xl' => 'ಅತಿ ದೊಡ್ಡ', + '2xl' => 'ಎರಡು ಪಟ್ಟು ದೊಡ್ಡ', + ], +]; diff --git a/resources/lang/ko/translations.php b/resources/lang/ko/translations.php new file mode 100644 index 0000000..5ef2d3e --- /dev/null +++ b/resources/lang/ko/translations.php @@ -0,0 +1,14 @@ + [ + 'xs' => '매우 작음', + 'sm' => '작음', + 'md' => '보통', + 'lg' => '큼', + 'xl' => '매우 큼', + '2xl' => '두배 큼', + ], +]; diff --git a/resources/lang/ku/translations.php b/resources/lang/ku/translations.php new file mode 100644 index 0000000..685d706 --- /dev/null +++ b/resources/lang/ku/translations.php @@ -0,0 +1,14 @@ + [ + 'xs' => 'Piçûk e zor', + 'sm' => 'Piçûk', + 'md' => 'Navîn', + 'lg' => 'Mezin', + 'xl' => 'Mezin e zor', + '2xl' => 'Du caran mezin', + ], +]; diff --git a/resources/lang/ky/translations.php b/resources/lang/ky/translations.php new file mode 100644 index 0000000..bcdeae9 --- /dev/null +++ b/resources/lang/ky/translations.php @@ -0,0 +1,14 @@ + [ + 'xs' => 'Өтө кичине', + 'sm' => 'Кичине', + 'md' => 'Орточо', + 'lg' => 'Чоң', + 'xl' => 'Өтө чоң', + '2xl' => 'Эки эсе чоң', + ], +]; diff --git a/resources/lang/lb/translations.php b/resources/lang/lb/translations.php new file mode 100644 index 0000000..863433a --- /dev/null +++ b/resources/lang/lb/translations.php @@ -0,0 +1,14 @@ + [ + 'xs' => 'Extra kleng', + 'sm' => 'Kleng', + 'md' => 'Mëttel', + 'lg' => 'Grouss', + 'xl' => 'Extra grouss', + '2xl' => 'Duebel grouss', + ], +]; diff --git a/resources/lang/lg/translations.php b/resources/lang/lg/translations.php new file mode 100644 index 0000000..202cb7c --- /dev/null +++ b/resources/lang/lg/translations.php @@ -0,0 +1,14 @@ + [ + 'xs' => 'Katono nnyo', + 'sm' => 'Katono', + 'md' => 'Wakati', + 'lg' => 'Kinene', + 'xl' => 'Kinene nnyo', + '2xl' => 'Kinene emirundi ebiri', + ], +]; diff --git a/resources/lang/ln/translations.php b/resources/lang/ln/translations.php new file mode 100644 index 0000000..b9e50d2 --- /dev/null +++ b/resources/lang/ln/translations.php @@ -0,0 +1,14 @@ + [ + 'xs' => 'Moke mingi', + 'sm' => 'Moke', + 'md' => 'Kati na kati', + 'lg' => 'Monene', + 'xl' => 'Monene mingi', + '2xl' => 'Monene mbala mibale', + ], +]; diff --git a/resources/lang/lo/translations.php b/resources/lang/lo/translations.php new file mode 100644 index 0000000..84921c3 --- /dev/null +++ b/resources/lang/lo/translations.php @@ -0,0 +1,14 @@ + [ + 'xs' => 'ນ້ອຍທີ່ສຸດ', + 'sm' => 'ນ້ອຍ', + 'md' => 'ກາງ', + 'lg' => 'ໃຫຍ່', + 'xl' => 'ໃຫຍ່ທີ່ສຸດ', + '2xl' => 'ໃຫຍ່ສອງເທົ່າ', + ], +]; diff --git a/resources/lang/lt/translations.php b/resources/lang/lt/translations.php new file mode 100644 index 0000000..1c0dd3c --- /dev/null +++ b/resources/lang/lt/translations.php @@ -0,0 +1,14 @@ + [ + 'xs' => 'Labai mažas', + 'sm' => 'Mažas', + 'md' => 'Vidutinis', + 'lg' => 'Didelis', + 'xl' => 'Labai didelis', + '2xl' => 'Dvigubai didelis', + ], +]; diff --git a/resources/lang/lv/translations.php b/resources/lang/lv/translations.php new file mode 100644 index 0000000..0c26d3a --- /dev/null +++ b/resources/lang/lv/translations.php @@ -0,0 +1,14 @@ + [ + 'xs' => 'Ļoti mazs', + 'sm' => 'Mazs', + 'md' => 'Vidējs', + 'lg' => 'Liels', + 'xl' => 'Ļoti liels', + '2xl' => 'Divkārt liels', + ], +]; diff --git a/resources/lang/mai/translations.php b/resources/lang/mai/translations.php new file mode 100644 index 0000000..9a3b959 --- /dev/null +++ b/resources/lang/mai/translations.php @@ -0,0 +1,14 @@ + [ + 'xs' => 'बहुत छोट', + 'sm' => 'छोट', + 'md' => 'मध्यम', + 'lg' => 'पैघ', + 'xl' => 'बहुत पैघ', + '2xl' => 'दूगुन पैघ', + ], +]; diff --git a/resources/lang/mg/translations.php b/resources/lang/mg/translations.php new file mode 100644 index 0000000..26cdda8 --- /dev/null +++ b/resources/lang/mg/translations.php @@ -0,0 +1,14 @@ + [ + 'xs' => 'Kely indrindra', + 'sm' => 'Kely', + 'md' => 'Antonony', + 'lg' => 'Lehibe', + 'xl' => 'Lehibe indrindra', + '2xl' => 'Roa heny lehibe', + ], +]; diff --git a/resources/lang/mi/translations.php b/resources/lang/mi/translations.php new file mode 100644 index 0000000..e1bd593 --- /dev/null +++ b/resources/lang/mi/translations.php @@ -0,0 +1,14 @@ + [ + 'xs' => 'Iti rawa', + 'sm' => 'Iti', + 'md' => 'Tauwaenga', + 'lg' => 'Rahi', + 'xl' => 'Rahi rawa', + '2xl' => 'Rahi takirua', + ], +]; diff --git a/resources/lang/mk/translations.php b/resources/lang/mk/translations.php new file mode 100644 index 0000000..a73c1a8 --- /dev/null +++ b/resources/lang/mk/translations.php @@ -0,0 +1,14 @@ + [ + 'xs' => 'Многу мал', + 'sm' => 'Мал', + 'md' => 'Среден', + 'lg' => 'Голем', + 'xl' => 'Многу голем', + '2xl' => 'Двојно голем', + ], +]; diff --git a/resources/lang/ml/translations.php b/resources/lang/ml/translations.php new file mode 100644 index 0000000..cfa542d --- /dev/null +++ b/resources/lang/ml/translations.php @@ -0,0 +1,14 @@ + [ + 'xs' => 'വളരെ ചെറുത്', + 'sm' => 'ചെറുത്', + 'md' => 'ഇടത്തരം', + 'lg' => 'വലുത്', + 'xl' => 'വളരെ വലുത്', + '2xl' => 'ഇരട്ടി വലുത്', + ], +]; diff --git a/resources/lang/mn/translations.php b/resources/lang/mn/translations.php new file mode 100644 index 0000000..2994184 --- /dev/null +++ b/resources/lang/mn/translations.php @@ -0,0 +1,14 @@ + [ + 'xs' => 'Маш жижиг', + 'sm' => 'Жижиг', + 'md' => 'Дундаж', + 'lg' => 'Том', + 'xl' => 'Маш том', + '2xl' => 'Давхар том', + ], +]; diff --git a/resources/lang/mni_Mtei/translations.php b/resources/lang/mni_Mtei/translations.php new file mode 100644 index 0000000..994a67b --- /dev/null +++ b/resources/lang/mni_Mtei/translations.php @@ -0,0 +1,14 @@ + [ + 'xs' => 'অসুক খুদিংবা', + 'sm' => 'খুদিংবা', + 'md' => 'মধোংবা', + 'lg' => 'অচৌবা', + 'xl' => 'অসুক অচৌবা', + '2xl' => 'অনি অচৌবা', + ], +]; diff --git a/resources/lang/mr/translations.php b/resources/lang/mr/translations.php new file mode 100644 index 0000000..8002d54 --- /dev/null +++ b/resources/lang/mr/translations.php @@ -0,0 +1,14 @@ + [ + 'xs' => 'खूप लहान', + 'sm' => 'लहान', + 'md' => 'मध्यम', + 'lg' => 'मोठे', + 'xl' => 'खूप मोठे', + '2xl' => 'दुप्पट मोठे', + ], +]; diff --git a/resources/lang/ms/translations.php b/resources/lang/ms/translations.php new file mode 100644 index 0000000..763dcf2 --- /dev/null +++ b/resources/lang/ms/translations.php @@ -0,0 +1,14 @@ + [ + 'xs' => 'Sangat kecil', + 'sm' => 'Kecil', + 'md' => 'Sederhana', + 'lg' => 'Besar', + 'xl' => 'Sangat besar', + '2xl' => 'Dua kali besar', + ], +]; diff --git a/resources/lang/mt/translations.php b/resources/lang/mt/translations.php new file mode 100644 index 0000000..65c3428 --- /dev/null +++ b/resources/lang/mt/translations.php @@ -0,0 +1,14 @@ + [ + 'xs' => 'Żgħir ħafna', + 'sm' => 'Żgħir', + 'md' => 'Medju', + 'lg' => 'Kbir', + 'xl' => 'Kbir ħafna', + '2xl' => 'Kbir darbtejn', + ], +]; diff --git a/resources/lang/my/translations.php b/resources/lang/my/translations.php new file mode 100644 index 0000000..eb0716c --- /dev/null +++ b/resources/lang/my/translations.php @@ -0,0 +1,14 @@ + [ + 'xs' => 'အလွန်သေး', + 'sm' => 'သေး', + 'md' => 'အလတ်စား', + 'lg' => 'ကြီး', + 'xl' => 'အလွန်ကြီး', + '2xl' => 'နှစ်ဆကြီး', + ], +]; diff --git a/resources/lang/nb/translations.php b/resources/lang/nb/translations.php new file mode 100644 index 0000000..9619bb6 --- /dev/null +++ b/resources/lang/nb/translations.php @@ -0,0 +1,14 @@ + [ + 'xs' => 'Ekstra liten', + 'sm' => 'Liten', + 'md' => 'Medium', + 'lg' => 'Stor', + 'xl' => 'Ekstra stor', + '2xl' => 'Dobbelt stor', + ], +]; diff --git a/resources/lang/ne/translations.php b/resources/lang/ne/translations.php new file mode 100644 index 0000000..9cdce39 --- /dev/null +++ b/resources/lang/ne/translations.php @@ -0,0 +1,14 @@ + [ + 'xs' => 'धेरै सानो', + 'sm' => 'सानो', + 'md' => 'मध्यम', + 'lg' => 'ठूलो', + 'xl' => 'धेरै ठूलो', + '2xl' => 'दुई गुणा ठूलो', + ], +]; diff --git a/resources/lang/nl/translations.php b/resources/lang/nl/translations.php new file mode 100644 index 0000000..63a742d --- /dev/null +++ b/resources/lang/nl/translations.php @@ -0,0 +1,14 @@ + [ + 'xs' => 'Extra klein', + 'sm' => 'Klein', + 'md' => 'Medium', + 'lg' => 'Groot', + 'xl' => 'Extra groot', + '2xl' => 'Dubbel groot', + ], +]; diff --git a/resources/lang/nn/translations.php b/resources/lang/nn/translations.php new file mode 100644 index 0000000..9619bb6 --- /dev/null +++ b/resources/lang/nn/translations.php @@ -0,0 +1,14 @@ + [ + 'xs' => 'Ekstra liten', + 'sm' => 'Liten', + 'md' => 'Medium', + 'lg' => 'Stor', + 'xl' => 'Ekstra stor', + '2xl' => 'Dobbelt stor', + ], +]; diff --git a/resources/lang/oc/translations.php b/resources/lang/oc/translations.php new file mode 100644 index 0000000..3c8b8d7 --- /dev/null +++ b/resources/lang/oc/translations.php @@ -0,0 +1,14 @@ + [ + 'xs' => 'Pichon de mai', + 'sm' => 'Pichon', + 'md' => 'Mejan', + 'lg' => 'Grand', + 'xl' => 'Grand de mai', + '2xl' => 'Doble grand', + ], +]; diff --git a/resources/lang/om/translations.php b/resources/lang/om/translations.php new file mode 100644 index 0000000..5d396e1 --- /dev/null +++ b/resources/lang/om/translations.php @@ -0,0 +1,14 @@ + [ + 'xs' => 'Baay\'ee xiqqaa', + 'sm' => 'Xiqqaa', + 'md' => 'Gidduu', + 'lg' => 'Guddaa', + 'xl' => 'Baay\'ee guddaa', + '2xl' => 'Dachaa guddaa', + ], +]; diff --git a/resources/lang/or/translations.php b/resources/lang/or/translations.php new file mode 100644 index 0000000..67e838f --- /dev/null +++ b/resources/lang/or/translations.php @@ -0,0 +1,14 @@ + [ + 'xs' => 'ବହୁତ ଛୋଟ', + 'sm' => 'ଛୋଟ', + 'md' => 'ମଧ୍ୟମ', + 'lg' => 'ବଡ଼', + 'xl' => 'ବହୁତ ବଡ଼', + '2xl' => 'ଦ୍ୱିଗୁଣ ବଡ଼', + ], +]; diff --git a/resources/lang/pa/translations.php b/resources/lang/pa/translations.php new file mode 100644 index 0000000..b23f491 --- /dev/null +++ b/resources/lang/pa/translations.php @@ -0,0 +1,14 @@ + [ + 'xs' => 'ਬਹੁਤ ਛੋਟਾ', + 'sm' => 'ਛੋਟਾ', + 'md' => 'ਮੱਧਮ', + 'lg' => 'ਵੱਡਾ', + 'xl' => 'ਬਹੁਤ ਵੱਡਾ', + '2xl' => 'ਦੁਗਣਾ ਵੱਡਾ', + ], +]; diff --git a/resources/lang/pl/translations.php b/resources/lang/pl/translations.php new file mode 100644 index 0000000..9c71b58 --- /dev/null +++ b/resources/lang/pl/translations.php @@ -0,0 +1,14 @@ + [ + 'xs' => 'Bardzo mały', + 'sm' => 'Mały', + 'md' => 'Średni', + 'lg' => 'Duży', + 'xl' => 'Bardzo duży', + '2xl' => 'Podwójnie duży', + ], +]; diff --git a/resources/lang/ps/translations.php b/resources/lang/ps/translations.php new file mode 100644 index 0000000..2a4be15 --- /dev/null +++ b/resources/lang/ps/translations.php @@ -0,0 +1,14 @@ + [ + 'xs' => 'ډېر کشر', + 'sm' => 'کشر', + 'md' => 'منځنی', + 'lg' => 'لوی', + 'xl' => 'ډېر لوی', + '2xl' => 'دوه ځله لوی', + ], +]; diff --git a/resources/lang/pt/translations.php b/resources/lang/pt/translations.php new file mode 100644 index 0000000..cc46909 --- /dev/null +++ b/resources/lang/pt/translations.php @@ -0,0 +1,14 @@ + [ + 'xs' => 'Extra pequeno', + 'sm' => 'Pequeno', + 'md' => 'Médio', + 'lg' => 'Grande', + 'xl' => 'Extra grande', + '2xl' => 'Duplo grande', + ], +]; diff --git a/resources/lang/pt_BR/translations.php b/resources/lang/pt_BR/translations.php new file mode 100644 index 0000000..3851a7b --- /dev/null +++ b/resources/lang/pt_BR/translations.php @@ -0,0 +1,14 @@ + [ + 'xs' => 'Extra pequeno', + 'sm' => 'Pequeno', + 'md' => 'Médio', + 'lg' => 'Grande', + 'xl' => 'Extra grande', + '2xl' => 'Duplo extra grande', + ], +]; diff --git a/resources/lang/qu/translations.php b/resources/lang/qu/translations.php new file mode 100644 index 0000000..cc1597b --- /dev/null +++ b/resources/lang/qu/translations.php @@ -0,0 +1,14 @@ + [ + 'xs' => 'Ancha huch\'uy', + 'sm' => 'Huch\'uy', + 'md' => 'Chawpi', + 'lg' => 'Hatun', + 'xl' => 'Ancha hatun', + '2xl' => 'Iskay kuti hatun', + ], +]; diff --git a/resources/lang/ro/translations.php b/resources/lang/ro/translations.php new file mode 100644 index 0000000..516d2e0 --- /dev/null +++ b/resources/lang/ro/translations.php @@ -0,0 +1,14 @@ + [ + 'xs' => 'Extra mic', + 'sm' => 'Mic', + 'md' => 'Mediu', + 'lg' => 'Mare', + 'xl' => 'Extra mare', + '2xl' => 'Dublu mare', + ], +]; diff --git a/resources/lang/ru/translations.php b/resources/lang/ru/translations.php new file mode 100644 index 0000000..2af094d --- /dev/null +++ b/resources/lang/ru/translations.php @@ -0,0 +1,14 @@ + [ + 'xs' => 'Очень малый', + 'sm' => 'Малый', + 'md' => 'Средний', + 'lg' => 'Большой', + 'xl' => 'Очень большой', + '2xl' => 'Двойной большой', + ], +]; diff --git a/resources/lang/rw/translations.php b/resources/lang/rw/translations.php new file mode 100644 index 0000000..7860267 --- /dev/null +++ b/resources/lang/rw/translations.php @@ -0,0 +1,14 @@ + [ + 'xs' => 'Gito cyane', + 'sm' => 'Gito', + 'md' => 'Hagati', + 'lg' => 'Ginini', + 'xl' => 'Kinini cyane', + '2xl' => 'Inshuro ebyiri', + ], +]; diff --git a/resources/lang/sa/translations.php b/resources/lang/sa/translations.php new file mode 100644 index 0000000..6d3a8a3 --- /dev/null +++ b/resources/lang/sa/translations.php @@ -0,0 +1,14 @@ + [ + 'xs' => 'अतिलघु', + 'sm' => 'लघु', + 'md' => 'मध्यम', + 'lg' => 'महत्', + 'xl' => 'अतिमहत्', + '2xl' => 'द्विगुणमहत्', + ], +]; diff --git a/resources/lang/sc/translations.php b/resources/lang/sc/translations.php new file mode 100644 index 0000000..23c0eaa --- /dev/null +++ b/resources/lang/sc/translations.php @@ -0,0 +1,14 @@ + [ + 'xs' => 'Pitzinnu de tottu', + 'sm' => 'Pitzinnu', + 'md' => 'Mediu', + 'lg' => 'Mannu', + 'xl' => 'Mannu de tottu', + '2xl' => 'Doppiu mannu', + ], +]; diff --git a/resources/lang/sd/translations.php b/resources/lang/sd/translations.php new file mode 100644 index 0000000..050c33b --- /dev/null +++ b/resources/lang/sd/translations.php @@ -0,0 +1,14 @@ + [ + 'xs' => 'تمام ننڍو', + 'sm' => 'ننڍو', + 'md' => 'وچولو', + 'lg' => 'وڏو', + 'xl' => 'تمام وڏو', + '2xl' => 'ٻيڻو وڏو', + ], +]; diff --git a/resources/lang/si/translations.php b/resources/lang/si/translations.php new file mode 100644 index 0000000..34cd6ee --- /dev/null +++ b/resources/lang/si/translations.php @@ -0,0 +1,14 @@ + [ + 'xs' => 'ඉතා කුඩා', + 'sm' => 'කුඩා', + 'md' => 'මධ්‍යම', + 'lg' => 'විශාල', + 'xl' => 'ඉතා විශාල', + '2xl' => 'ද්විත්ව විශාල', + ], +]; diff --git a/resources/lang/sk/translations.php b/resources/lang/sk/translations.php new file mode 100644 index 0000000..e0ed387 --- /dev/null +++ b/resources/lang/sk/translations.php @@ -0,0 +1,14 @@ + [ + 'xs' => 'Extra malý', + 'sm' => 'Malý', + 'md' => 'Stredný', + 'lg' => 'Veľký', + 'xl' => 'Extra veľký', + '2xl' => 'Dvojito veľký', + ], +]; diff --git a/resources/lang/sl/translations.php b/resources/lang/sl/translations.php new file mode 100644 index 0000000..4ee789b --- /dev/null +++ b/resources/lang/sl/translations.php @@ -0,0 +1,14 @@ + [ + 'xs' => 'Zelo majhen', + 'sm' => 'Majhen', + 'md' => 'Srednji', + 'lg' => 'Velik', + 'xl' => 'Zelo velik', + '2xl' => 'Dvojno velik', + ], +]; diff --git a/resources/lang/sn/translations.php b/resources/lang/sn/translations.php new file mode 100644 index 0000000..c96dd96 --- /dev/null +++ b/resources/lang/sn/translations.php @@ -0,0 +1,14 @@ + [ + 'xs' => 'Duku kwazvo', + 'sm' => 'Duku', + 'md' => 'Pakati', + 'lg' => 'Hombe', + 'xl' => 'Hombe kwazvo', + '2xl' => 'Kaviri hombe', + ], +]; diff --git a/resources/lang/so/translations.php b/resources/lang/so/translations.php new file mode 100644 index 0000000..86b58aa --- /dev/null +++ b/resources/lang/so/translations.php @@ -0,0 +1,14 @@ + [ + 'xs' => 'Aad u yar', + 'sm' => 'Yar', + 'md' => 'Dhexdhexaad', + 'lg' => 'Weyn', + 'xl' => 'Aad u weyn', + '2xl' => 'Laba jeer weyn', + ], +]; diff --git a/resources/lang/sq/translations.php b/resources/lang/sq/translations.php new file mode 100644 index 0000000..ddbaed4 --- /dev/null +++ b/resources/lang/sq/translations.php @@ -0,0 +1,14 @@ + [ + 'xs' => 'Shumë e vogël', + 'sm' => 'E vogël', + 'md' => 'Mesatare', + 'lg' => 'E madhe', + 'xl' => 'Shumë e madhe', + '2xl' => 'Dyfisht e madhe', + ], +]; diff --git a/resources/lang/sr_Cyrl/translations.php b/resources/lang/sr_Cyrl/translations.php new file mode 100644 index 0000000..82f3046 --- /dev/null +++ b/resources/lang/sr_Cyrl/translations.php @@ -0,0 +1,14 @@ + [ + 'xs' => 'Врло мали', + 'sm' => 'Мали', + 'md' => 'Средњи', + 'lg' => 'Велики', + 'xl' => 'Врло велики', + '2xl' => 'Двоструко велики', + ], +]; diff --git a/resources/lang/sr_Latn/translations.php b/resources/lang/sr_Latn/translations.php new file mode 100644 index 0000000..680a34f --- /dev/null +++ b/resources/lang/sr_Latn/translations.php @@ -0,0 +1,14 @@ + [ + 'xs' => 'Vrlo mali', + 'sm' => 'Mali', + 'md' => 'Srednji', + 'lg' => 'Veliki', + 'xl' => 'Vrlo veliki', + '2xl' => 'Dvostruko veliki', + ], +]; diff --git a/resources/lang/sr_Latn_ME/translations.php b/resources/lang/sr_Latn_ME/translations.php new file mode 100644 index 0000000..680a34f --- /dev/null +++ b/resources/lang/sr_Latn_ME/translations.php @@ -0,0 +1,14 @@ + [ + 'xs' => 'Vrlo mali', + 'sm' => 'Mali', + 'md' => 'Srednji', + 'lg' => 'Veliki', + 'xl' => 'Vrlo veliki', + '2xl' => 'Dvostruko veliki', + ], +]; diff --git a/resources/lang/su/translations.php b/resources/lang/su/translations.php new file mode 100644 index 0000000..2a3aea6 --- /dev/null +++ b/resources/lang/su/translations.php @@ -0,0 +1,14 @@ + [ + 'xs' => 'Leutik pisan', + 'sm' => 'Leutik', + 'md' => 'Sedeng', + 'lg' => 'Ageung', + 'xl' => 'Ageung pisan', + '2xl' => 'Dua kali ageung', + ], +]; diff --git a/resources/lang/sv/translations.php b/resources/lang/sv/translations.php new file mode 100644 index 0000000..05793a4 --- /dev/null +++ b/resources/lang/sv/translations.php @@ -0,0 +1,14 @@ + [ + 'xs' => 'Extra liten', + 'sm' => 'Liten', + 'md' => 'Medium', + 'lg' => 'Stor', + 'xl' => 'Extra stor', + '2xl' => 'Dubbelt stor', + ], +]; diff --git a/resources/lang/sw/translations.php b/resources/lang/sw/translations.php new file mode 100644 index 0000000..8a95b1e --- /dev/null +++ b/resources/lang/sw/translations.php @@ -0,0 +1,14 @@ + [ + 'xs' => 'Ndogo sana', + 'sm' => 'Ndogo', + 'md' => 'Wastani', + 'lg' => 'Kubwa', + 'xl' => 'Kubwa sana', + '2xl' => 'Kubwa mara mbili', + ], +]; diff --git a/resources/lang/ta/translations.php b/resources/lang/ta/translations.php new file mode 100644 index 0000000..cb7e026 --- /dev/null +++ b/resources/lang/ta/translations.php @@ -0,0 +1,14 @@ + [ + 'xs' => 'மிக சிறிய', + 'sm' => 'சிறிய', + 'md' => 'நடுத்தர', + 'lg' => 'பெரிய', + 'xl' => 'மிக பெரிய', + '2xl' => 'இரட்டை பெரிய', + ], +]; diff --git a/resources/lang/te/translations.php b/resources/lang/te/translations.php new file mode 100644 index 0000000..e1261b1 --- /dev/null +++ b/resources/lang/te/translations.php @@ -0,0 +1,14 @@ + [ + 'xs' => 'చాలా చిన్న', + 'sm' => 'చిన్న', + 'md' => 'మధ్యమ', + 'lg' => 'పెద్ద', + 'xl' => 'చాలా పెద్ద', + '2xl' => 'రెండింతల పెద్ద', + ], +]; diff --git a/resources/lang/tg/translations.php b/resources/lang/tg/translations.php new file mode 100644 index 0000000..fe761ff --- /dev/null +++ b/resources/lang/tg/translations.php @@ -0,0 +1,14 @@ + [ + 'xs' => 'Хеле хурд', + 'sm' => 'Хурд', + 'md' => 'Миёна', + 'lg' => 'Калон', + 'xl' => 'Хеле калон', + '2xl' => 'Ду маротиба калон', + ], +]; diff --git a/resources/lang/th/translations.php b/resources/lang/th/translations.php new file mode 100644 index 0000000..3ada910 --- /dev/null +++ b/resources/lang/th/translations.php @@ -0,0 +1,14 @@ + [ + 'xs' => 'เล็กมาก', + 'sm' => 'เล็ก', + 'md' => 'กลาง', + 'lg' => 'ใหญ่', + 'xl' => 'ใหญ่มาก', + '2xl' => 'ใหญ่สองเท่า', + ], +]; diff --git a/resources/lang/ti/translations.php b/resources/lang/ti/translations.php new file mode 100644 index 0000000..782f9fe --- /dev/null +++ b/resources/lang/ti/translations.php @@ -0,0 +1,14 @@ + [ + 'xs' => 'ኣዝዩ ንእሽቶ', + 'sm' => 'ንእሽቶ', + 'md' => 'ማእከላይ', + 'lg' => 'ዓቢ', + 'xl' => 'ኣዝዩ ዓቢ', + '2xl' => 'ክልተ ዓቢ', + ], +]; diff --git a/resources/lang/tk/translations.php b/resources/lang/tk/translations.php new file mode 100644 index 0000000..d61fbc2 --- /dev/null +++ b/resources/lang/tk/translations.php @@ -0,0 +1,14 @@ + [ + 'xs' => 'Gaty kiçi', + 'sm' => 'Kiçi', + 'md' => 'Orta', + 'lg' => 'Uly', + 'xl' => 'Gaty uly', + '2xl' => 'Goşa uly', + ], +]; diff --git a/resources/lang/tl/translations.php b/resources/lang/tl/translations.php new file mode 100644 index 0000000..fd67fde --- /dev/null +++ b/resources/lang/tl/translations.php @@ -0,0 +1,14 @@ + [ + 'xs' => 'Sobrang liit', + 'sm' => 'Maliit', + 'md' => 'Katamtaman', + 'lg' => 'Malaki', + 'xl' => 'Sobrang laki', + '2xl' => 'Doble laki', + ], +]; diff --git a/resources/lang/tr/translations.php b/resources/lang/tr/translations.php new file mode 100644 index 0000000..aedc867 --- /dev/null +++ b/resources/lang/tr/translations.php @@ -0,0 +1,14 @@ + [ + 'xs' => 'Çok küçük', + 'sm' => 'Küçük', + 'md' => 'Orta', + 'lg' => 'Büyük', + 'xl' => 'Çok büyük', + '2xl' => 'Çifte büyük', + ], +]; diff --git a/resources/lang/tt/translations.php b/resources/lang/tt/translations.php new file mode 100644 index 0000000..1c7f825 --- /dev/null +++ b/resources/lang/tt/translations.php @@ -0,0 +1,14 @@ + [ + 'xs' => 'Бик кече', + 'sm' => 'Кече', + 'md' => 'Урта', + 'lg' => 'Зур', + 'xl' => 'Бик зур', + '2xl' => 'Икеләтә зур', + ], +]; diff --git a/resources/lang/ug/translations.php b/resources/lang/ug/translations.php new file mode 100644 index 0000000..620f97a --- /dev/null +++ b/resources/lang/ug/translations.php @@ -0,0 +1,14 @@ + [ + 'xs' => 'بەك كىچىك', + 'sm' => 'كىچىك', + 'md' => 'ئوتتۇرا', + 'lg' => 'چوڭ', + 'xl' => 'بەك چوڭ', + '2xl' => 'ئىككى ھەسسە چوڭ', + ], +]; diff --git a/resources/lang/uk/translations.php b/resources/lang/uk/translations.php new file mode 100644 index 0000000..dee62f4 --- /dev/null +++ b/resources/lang/uk/translations.php @@ -0,0 +1,14 @@ + [ + 'xs' => 'Дуже малий', + 'sm' => 'Малий', + 'md' => 'Середній', + 'lg' => 'Великий', + 'xl' => 'Дуже великий', + '2xl' => 'Подвійно великий', + ], +]; diff --git a/resources/lang/ur/translations.php b/resources/lang/ur/translations.php new file mode 100644 index 0000000..2506364 --- /dev/null +++ b/resources/lang/ur/translations.php @@ -0,0 +1,14 @@ + [ + 'xs' => 'بہت چھوٹا', + 'sm' => 'چھوٹا', + 'md' => 'درمیانہ', + 'lg' => 'بڑا', + 'xl' => 'بہت بڑا', + '2xl' => 'دوگنا بڑا', + ], +]; diff --git a/resources/lang/uz_Cyrl/translations.php b/resources/lang/uz_Cyrl/translations.php new file mode 100644 index 0000000..c486ee2 --- /dev/null +++ b/resources/lang/uz_Cyrl/translations.php @@ -0,0 +1,14 @@ + [ + 'xs' => 'Жуда кичик', + 'sm' => 'Кичик', + 'md' => 'Ўрта', + 'lg' => 'Катта', + 'xl' => 'Жуда катта', + '2xl' => 'Икки марта катта', + ], +]; diff --git a/resources/lang/uz_Latn/translations.php b/resources/lang/uz_Latn/translations.php new file mode 100644 index 0000000..8f68c47 --- /dev/null +++ b/resources/lang/uz_Latn/translations.php @@ -0,0 +1,14 @@ + [ + 'xs' => 'Juda kichik', + 'sm' => 'Kichik', + 'md' => 'O\'rta', + 'lg' => 'Katta', + 'xl' => 'Juda katta', + '2xl' => 'Ikki marta katta', + ], +]; diff --git a/resources/lang/vi/translations.php b/resources/lang/vi/translations.php new file mode 100644 index 0000000..3f0e2ac --- /dev/null +++ b/resources/lang/vi/translations.php @@ -0,0 +1,14 @@ + [ + 'xs' => 'Rất nhỏ', + 'sm' => 'Nhỏ', + 'md' => 'Trung bình', + 'lg' => 'Lớn', + 'xl' => 'Rất lớn', + '2xl' => 'Gấp đôi lớn', + ], +]; diff --git a/resources/lang/xh/translations.php b/resources/lang/xh/translations.php new file mode 100644 index 0000000..73f584d --- /dev/null +++ b/resources/lang/xh/translations.php @@ -0,0 +1,14 @@ + [ + 'xs' => 'Encinci kakhulu', + 'sm' => 'Encinci', + 'md' => 'Phakathi', + 'lg' => 'Enkulu', + 'xl' => 'Enkulu kakhulu', + '2xl' => 'Enkulu kabini', + ], +]; diff --git a/resources/lang/yi/translations.php b/resources/lang/yi/translations.php new file mode 100644 index 0000000..7a8ce86 --- /dev/null +++ b/resources/lang/yi/translations.php @@ -0,0 +1,14 @@ + [ + 'xs' => 'זייער קליין', + 'sm' => 'קליין', + 'md' => 'מיטלער', + 'lg' => 'גרויס', + 'xl' => 'זייער גרויס', + '2xl' => 'טאָפּל גרויס', + ], +]; diff --git a/resources/lang/yo/translations.php b/resources/lang/yo/translations.php new file mode 100644 index 0000000..f4efb54 --- /dev/null +++ b/resources/lang/yo/translations.php @@ -0,0 +1,14 @@ + [ + 'xs' => 'Kekere pupọ', + 'sm' => 'Kekere', + 'md' => 'Arin', + 'lg' => 'Nla', + 'xl' => 'Nla pupọ', + '2xl' => 'Nla meji', + ], +]; diff --git a/resources/lang/zh_CN/translations.php b/resources/lang/zh_CN/translations.php new file mode 100644 index 0000000..3ac10ff --- /dev/null +++ b/resources/lang/zh_CN/translations.php @@ -0,0 +1,14 @@ + [ + 'xs' => '超小', + 'sm' => '小', + 'md' => '中', + 'lg' => '大', + 'xl' => '超大', + '2xl' => '特大', + ], +]; diff --git a/resources/lang/zh_HK/translations.php b/resources/lang/zh_HK/translations.php new file mode 100644 index 0000000..e756256 --- /dev/null +++ b/resources/lang/zh_HK/translations.php @@ -0,0 +1,14 @@ + [ + 'xs' => '超細', + 'sm' => '細', + 'md' => '中', + 'lg' => '大', + 'xl' => '超大', + '2xl' => '特大', + ], +]; diff --git a/resources/lang/zh_TW/translations.php b/resources/lang/zh_TW/translations.php new file mode 100644 index 0000000..3ac10ff --- /dev/null +++ b/resources/lang/zh_TW/translations.php @@ -0,0 +1,14 @@ + [ + 'xs' => '超小', + 'sm' => '小', + 'md' => '中', + 'lg' => '大', + 'xl' => '超大', + '2xl' => '特大', + ], +]; diff --git a/resources/lang/zu/translations.php b/resources/lang/zu/translations.php new file mode 100644 index 0000000..0861f42 --- /dev/null +++ b/resources/lang/zu/translations.php @@ -0,0 +1,14 @@ + [ + 'xs' => 'Omncane kakhulu', + 'sm' => 'Omncane', + 'md' => 'Maphakathi', + 'lg' => 'Omkhulu', + 'xl' => 'Omkhulu kakhulu', + '2xl' => 'Omkhulu kabili', + ], +]; diff --git a/resources/views/components/image.blade.php b/resources/views/components/image.blade.php index ea2f59e..d05bc0d 100644 --- a/resources/views/components/image.blade.php +++ b/resources/views/components/image.blade.php @@ -1,8 +1,11 @@ @php + $useBreakpoints = $useBreakpoints ?? true; + $src = $useBreakpoints ? $image->sourceImage->url() : $image->urlForBreakpoint(); + $attributes = $attributes->merge([ - 'src' => $image->sourceImage->url(), + 'src' => $src, 'alt' => $image->alt_text ?? $image->sourceImage->alt_text, - 'sizes' => '1px', + 'sizes' => $useBreakpoints ? '1px' : null, 'data-image-library' => 'image', 'data-image-library-id' => $image->uuid, ]); @@ -11,10 +14,10 @@ @foreach ($sources as $source) media) media="{{ $source->media }}" @endif srcset="{{ $source->srcset }}" type="{{ $source->type }}" - sizes="1px" + @if ($useBreakpoints) sizes="1px" @endif /> @endforeach diff --git a/src/Commands/GenerateCommand.php b/src/Commands/GenerateCommand.php new file mode 100644 index 0000000..06a0b1c --- /dev/null +++ b/src/Commands/GenerateCommand.php @@ -0,0 +1,50 @@ +argument('imageIds'); + + $model = ImageLibrary::getImageModel(); + + $query = $model::query() + ->when(! empty($imageIds), fn ($query) => $query->whereIn((new $model)->getKeyName(), $imageIds)); + + $count = $query->count(); + + if ($count === 0) { + $this->info('No images found to generate.'); + + return Command::SUCCESS; + } + + $this->info("Generating {$count} image(s)…"); + $this->newLine(1); + + $bar = $this->output->createProgressBar($count); + $bar->start(); + + foreach ($query->cursor() as $image) { + $image->generate(); + $bar->advance(); + } + + $bar->finish(); + $this->newLine(2); + $this->info('All images generated successfully.'); + + return Command::SUCCESS; + } +} diff --git a/src/Commands/UpgradeCommand.php b/src/Commands/UpgradeCommand.php index 290c398..7b4094c 100644 --- a/src/Commands/UpgradeCommand.php +++ b/src/Commands/UpgradeCommand.php @@ -8,6 +8,7 @@ use Illuminate\Support\Carbon; use Illuminate\Support\Facades\File; +// @codeCoverageIgnoreStart class UpgradeCommand extends Command { protected $signature = 'image-library:upgrade'; @@ -210,3 +211,4 @@ public function handle(): int return Command::SUCCESS; } } +// @codeCoverageIgnoreEnd diff --git a/src/Components/Image.php b/src/Components/Image.php index 34caea1..4c1c355 100644 --- a/src/Components/Image.php +++ b/src/Components/Image.php @@ -4,6 +4,7 @@ namespace Outerweb\ImageLibrary\Components; +use Closure; use Illuminate\View\Component; use Illuminate\View\View; use Outerweb\ImageLibrary\Contracts\ConfiguresBreakpoints; @@ -13,13 +14,20 @@ class Image extends Component { public function __construct( - public ImageModel $image, + public ?ImageModel $image = null, ) {} - public function render(): View + public function shouldRender(): bool { - return view('image-library::components.image', [ - 'sources' => collect(ImageLibrary::getBreakpointEnum()::sortedCases()) + return ! is_null($this->image); + } + + public function render(): View|Closure|string + { + if ($this->image->context->getUseBreakpoints()) { + $useBreakpoints = true; + + $sources = collect(ImageLibrary::getBreakpointEnum()::sortedCases()) ->map(function (ConfiguresBreakpoints $case): array { return array_filter([ (object) [ @@ -27,7 +35,7 @@ public function render(): View 'srcset' => $this->getSrcsetForBreakpoint($case), 'type' => $this->image->sourceImage->mime_type, ], - $this->image->context?->getGenerateWebP() + $this->image->context->getGenerateWebP() ? (object) [ 'media' => $this->getMediaQueryForBreakpoint($case), 'srcset' => $this->getSrcsetForBreakpoint($case, 'webp'), @@ -36,7 +44,29 @@ public function render(): View : null, ]); }) - ->flatten(1), + ->flatten(1); + } else { + $useBreakpoints = false; + + $sources = array_filter([ + (object) [ + 'media' => '', + 'srcset' => $this->image->urlForBreakpoint(null), + 'type' => $this->image->sourceImage->mime_type, + ], + $this->image->context->getGenerateWebP() + ? (object) [ + 'media' => '', + 'srcset' => $this->image->urlForBreakpoint(null, 'webp'), + 'type' => 'image/webp', + ] + : null, + ]); + } + + return view('image-library::components.image', [ + 'sources' => $sources, + 'useBreakpoints' => $useBreakpoints, ]); } @@ -44,7 +74,7 @@ private function getMediaQueryForBreakpoint(ConfiguresBreakpoints $breakpoint): { $conditions = []; - if (! is_null($breakpoint->getMinWidth()) && array_search($breakpoint, ImageLibrary::getBreakpointEnum()::sortedCases()) !== 0) { + if (array_search($breakpoint, ImageLibrary::getBreakpointEnum()::sortedCases()) !== 0) { $conditions[] = '(min-width: '.$breakpoint->getMinWidth().'px)'; } @@ -57,7 +87,7 @@ private function getMediaQueryForBreakpoint(ConfiguresBreakpoints $breakpoint): private function getSrcsetForBreakpoint(ConfiguresBreakpoints $breakpoint, ?string $extension = null): string { - if (! $this->image->context?->getGenerateResponsiveVersions()) { + if (! $this->image->context->getGenerateResponsiveVersions()) { return $this->image->urlForBreakpoint($breakpoint, $extension); } @@ -66,7 +96,7 @@ private function getSrcsetForBreakpoint(ConfiguresBreakpoints $breakpoint, ?stri if (preg_match('/_w(\d+)\./', $path, $m)) { $width = (int) $m[1]; } else { - $width = $this->image->context->getMaxWidthForBreakpoint($breakpoint) + $width = $this->image->context->getMaxWidth($breakpoint) ?? $this->image->sourceImage->width; } diff --git a/src/Components/Scripts.php b/src/Components/Scripts.php index 47b7199..d5bf7ae 100644 --- a/src/Components/Scripts.php +++ b/src/Components/Scripts.php @@ -4,6 +4,7 @@ namespace Outerweb\ImageLibrary\Components; +use Closure; use Illuminate\View\Component; use Illuminate\View\View; @@ -11,7 +12,7 @@ class Scripts extends Component { public function __construct() {} - public function render(): View + public function render(): View|Closure|string { return view('image-library::components.scripts'); } diff --git a/src/Contracts/ConfiguresBreakpoints.php b/src/Contracts/ConfiguresBreakpoints.php index 750328d..58afee3 100644 --- a/src/Contracts/ConfiguresBreakpoints.php +++ b/src/Contracts/ConfiguresBreakpoints.php @@ -4,7 +4,9 @@ namespace Outerweb\ImageLibrary\Contracts; -interface ConfiguresBreakpoints +use BackedEnum; + +interface ConfiguresBreakpoints extends BackedEnum { public static function sortedCases(): array; diff --git a/src/Entities/AspectRatio.php b/src/Entities/AspectRatio.php index ed80599..ec11e6c 100644 --- a/src/Entities/AspectRatio.php +++ b/src/Entities/AspectRatio.php @@ -31,6 +31,11 @@ public function toString(): string return (string) $this; } + public function toFloat(): float + { + return round($this->horizontal / $this->vertical, 2); + } + public function toArray(): array { return [ diff --git a/src/Entities/CropData.php b/src/Entities/CropData.php index af76be2..cf5b939 100644 --- a/src/Entities/CropData.php +++ b/src/Entities/CropData.php @@ -14,17 +14,26 @@ class CropData public ?int $y = null; - public function __construct(int $width, int $height, ?int $x = null, ?int $y = null) + public int $rotate = 0; + + public int $scaleX = 1; + + public int $scaleY = 1; + + public function __construct(int $width, int $height, ?int $x = null, ?int $y = null, int $rotate = 0, int $scaleX = 1, int $scaleY = 1) { $this->width = $width; $this->height = $height; $this->x = $x; $this->y = $y; + $this->rotate = $rotate; + $this->scaleX = $scaleX; + $this->scaleY = $scaleY; } - public static function make(int $width, int $height, ?int $x = null, ?int $y = null): self + public static function make(int $width, int $height, ?int $x = null, ?int $y = null, int $rotate = 0, int $scaleX = 1, int $scaleY = 1): self { - return new self($width, $height, $x, $y); + return new self($width, $height, $x, $y, $rotate, $scaleX, $scaleY); } public function toArray(): array @@ -34,6 +43,9 @@ public function toArray(): array 'height' => $this->height, 'x' => $this->x, 'y' => $this->y, + 'rotate' => $this->rotate, + 'scaleX' => $this->scaleX, + 'scaleY' => $this->scaleY, ]; } } diff --git a/src/Entities/ImageContext.php b/src/Entities/ImageContext.php index 11de46a..94cf20a 100644 --- a/src/Entities/ImageContext.php +++ b/src/Entities/ImageContext.php @@ -43,6 +43,8 @@ class ImageContext protected bool $allowsMultiple = false; + protected ?bool $useBreakpoints = null; + protected ?bool $generateWebP = null; protected ?bool $generateResponsiveVersions = null; @@ -84,6 +86,18 @@ public function getLabel(): ?string /** @param AspectRatio|array $aspectRatio */ public function aspectRatio(AspectRatio|array $aspectRatio): self { + if (! $this->getUseBreakpoints()) { + if (is_array($aspectRatio)) { + throw new InvalidArgumentException("Aspect ratio must be an instance of AspectRatio when breakpoints are disabled for ImageContext with key '{$this->key}'."); + } + + $this->aspectRatioByBreakpoint = [ + 'default' => $aspectRatio, + ]; + + return $this; + } + $this->aspectRatioByBreakpoint = $this->getBreakpoints() ->mapWithKeys(function (ConfiguresBreakpoints $breakpoint) use ($aspectRatio) { if (is_array($aspectRatio)) { @@ -103,6 +117,10 @@ public function aspectRatio(AspectRatio|array $aspectRatio): self public function aspectRatioForBreakpoint(ConfiguresBreakpoints $breakpoint, AspectRatio $aspectRatio): self { + if (! $this->getUseBreakpoints()) { + throw new InvalidArgumentException("Cannot set aspect ratio for breakpoint when breakpoints are disabled for ImageContext with key '{$this->key}'."); + } + $this->aspectRatioByBreakpoint[$breakpoint->value] = $aspectRatio; return $this; @@ -110,6 +128,10 @@ public function aspectRatioForBreakpoint(ConfiguresBreakpoints $breakpoint, Aspe public function aspectRatioFromBreakpoint(ConfiguresBreakpoints $breakpoint, AspectRatio $aspectRatio): self { + if (! $this->getUseBreakpoints()) { + throw new InvalidArgumentException("Cannot set aspect ratio from breakpoint when breakpoints are disabled for ImageContext with key '{$this->key}'."); + } + $index = $this->getBreakpoints() ->search(function (ConfiguresBreakpoints $bp) use ($breakpoint) { return $bp->value === $breakpoint->value; @@ -126,6 +148,10 @@ public function aspectRatioFromBreakpoint(ConfiguresBreakpoints $breakpoint, Asp public function aspectRatioToBreakpoint(ConfiguresBreakpoints $breakpoint, AspectRatio $aspectRatio): self { + if (! $this->getUseBreakpoints()) { + throw new InvalidArgumentException("Cannot set aspect ratio to breakpoint when breakpoints are disabled for ImageContext with key '{$this->key}'."); + } + $index = $this->getBreakpoints() ->search(function (ConfiguresBreakpoints $bp) use ($breakpoint) { return $bp->value === $breakpoint->value; @@ -142,6 +168,10 @@ public function aspectRatioToBreakpoint(ConfiguresBreakpoints $breakpoint, Aspec public function aspectRatioBetweenBreakpoints(ConfiguresBreakpoints $startBreakpoint, ConfiguresBreakpoints $endBreakpoint, AspectRatio $aspectRatio): self { + if (! $this->getUseBreakpoints()) { + throw new InvalidArgumentException("Cannot set aspect ratio between breakpoints when breakpoints are disabled for ImageContext with key '{$this->key}'."); + } + $breakpoints = $this->getBreakpoints(); $startIndex = $breakpoints->search(function (ConfiguresBreakpoints $bp) use ($startBreakpoint) { @@ -161,20 +191,44 @@ public function aspectRatioBetweenBreakpoints(ConfiguresBreakpoints $startBreakp return $this; } - /** @return array */ - public function getAspectRatioByBreakpoint(): array + public function getAspectRatio(?ConfiguresBreakpoints $breakpoint = null): ?AspectRatio { - return $this->aspectRatioByBreakpoint; + if (! $this->getUseBreakpoints()) { + return $this->aspectRatioByBreakpoint['default'] ?? null; + } + + if (is_null($breakpoint)) { + throw new InvalidArgumentException("Breakpoint is required when breakpoints are enabled for ImageContext with key '{$this->key}'."); + } + + return $this->aspectRatioByBreakpoint[$breakpoint->value] ?? null; } - public function getAspectRatioForBreakpoint(ConfiguresBreakpoints $breakpoint): ?AspectRatio + /** @return array */ + public function getAspectRatioByBreakpoint(): array { - return $this->aspectRatioByBreakpoint[$breakpoint->value] ?? null; + if (! $this->getUseBreakpoints()) { + throw new InvalidArgumentException("Cannot get aspect ratios by breakpoint when breakpoints are disabled for ImageContext with key '{$this->key}'."); + } + + return $this->aspectRatioByBreakpoint; } /** @param int|array $minWidth */ public function minWidth(int|array $minWidth): self { + if (! $this->getUseBreakpoints()) { + if (is_array($minWidth)) { + throw new InvalidArgumentException("Min width must be an integer when breakpoints are disabled for ImageContext with key '{$this->key}'."); + } + + $this->minWidthByBreakpoint = [ + 'default' => $minWidth, + ]; + + return $this; + } + $this->minWidthByBreakpoint = $this->getBreakpoints() ->mapWithKeys(function (ConfiguresBreakpoints $breakpoint) use ($minWidth) { if (is_array($minWidth)) { @@ -194,6 +248,10 @@ public function minWidth(int|array $minWidth): self public function minWidthForBreakpoint(ConfiguresBreakpoints $breakpoint, int $minWidth): self { + if (! $this->getUseBreakpoints()) { + throw new InvalidArgumentException("Cannot set min width for breakpoint when breakpoints are disabled for ImageContext with key '{$this->key}'."); + } + $this->minWidthByBreakpoint[$breakpoint->value] = $minWidth; return $this; @@ -201,6 +259,10 @@ public function minWidthForBreakpoint(ConfiguresBreakpoints $breakpoint, int $mi public function minWidthFromBreakpoint(ConfiguresBreakpoints $breakpoint, int $minWidth): self { + if (! $this->getUseBreakpoints()) { + throw new InvalidArgumentException("Cannot set min width from breakpoint when breakpoints are disabled for ImageContext with key '{$this->key}'."); + } + $index = $this->getBreakpoints() ->search(function (ConfiguresBreakpoints $bp) use ($breakpoint) { return $bp->value === $breakpoint->value; @@ -217,6 +279,10 @@ public function minWidthFromBreakpoint(ConfiguresBreakpoints $breakpoint, int $m public function minWidthToBreakpoint(ConfiguresBreakpoints $breakpoint, int $minWidth): self { + if (! $this->getUseBreakpoints()) { + throw new InvalidArgumentException("Cannot set min width to breakpoint when breakpoints are disabled for ImageContext with key '{$this->key}'."); + } + $index = $this->getBreakpoints() ->search(function (ConfiguresBreakpoints $bp) use ($breakpoint) { return $bp->value === $breakpoint->value; @@ -233,6 +299,10 @@ public function minWidthToBreakpoint(ConfiguresBreakpoints $breakpoint, int $min public function minWidthBetweenBreakpoints(ConfiguresBreakpoints $startBreakpoint, ConfiguresBreakpoints $endBreakpoint, int $minWidth): self { + if (! $this->getUseBreakpoints()) { + throw new InvalidArgumentException("Cannot set min width between breakpoints when breakpoints are disabled for ImageContext with key '{$this->key}'."); + } + $breakpoints = $this->getBreakpoints(); $startIndex = $breakpoints->search(function (ConfiguresBreakpoints $bp) use ($startBreakpoint) { @@ -252,20 +322,44 @@ public function minWidthBetweenBreakpoints(ConfiguresBreakpoints $startBreakpoin return $this; } - /** @return array */ - public function getMinWidthByBreakpoint(): array + public function getMinWidth(?ConfiguresBreakpoints $breakpoint = null): ?int { - return $this->minWidthByBreakpoint; + if (! $this->getUseBreakpoints()) { + return $this->minWidthByBreakpoint['default'] ?? null; + } + + if (is_null($breakpoint)) { + throw new InvalidArgumentException("Breakpoint is required when breakpoints are enabled for ImageContext with key '{$this->key}'."); + } + + return $this->minWidthByBreakpoint[$breakpoint->value] ?? null; } - public function getMinWidthForBreakpoint(ConfiguresBreakpoints $breakpoint): ?int + /** @return array */ + public function getMinWidthByBreakpoint(): array { - return $this->minWidthByBreakpoint[$breakpoint->value] ?? null; + if (! $this->getUseBreakpoints()) { + throw new InvalidArgumentException("Cannot get min widths by breakpoint when breakpoints are disabled for ImageContext with key '{$this->key}'."); + } + + return $this->minWidthByBreakpoint; } /** @param int|array $maxWidth */ public function maxWidth(int|array $maxWidth): self { + if (! $this->getUseBreakpoints()) { + if (is_array($maxWidth)) { + throw new InvalidArgumentException("Max width must be an integer when breakpoints are disabled for ImageContext with key '{$this->key}'."); + } + + $this->maxWidthByBreakpoint = [ + 'default' => $maxWidth, + ]; + + return $this; + } + $this->maxWidthByBreakpoint = $this->getBreakpoints() ->mapWithKeys(function (ConfiguresBreakpoints $breakpoint) use ($maxWidth) { if (is_array($maxWidth)) { @@ -283,6 +377,10 @@ public function maxWidth(int|array $maxWidth): self public function maxWidthForBreakpoint(ConfiguresBreakpoints $breakpoint, int $maxWidth): self { + if (! $this->getUseBreakpoints()) { + throw new InvalidArgumentException("Cannot set max width for breakpoint when breakpoints are disabled for ImageContext with key '{$this->key}'."); + } + $this->maxWidthByBreakpoint[$breakpoint->value] = $maxWidth; return $this; @@ -290,6 +388,10 @@ public function maxWidthForBreakpoint(ConfiguresBreakpoints $breakpoint, int $ma public function maxWidthFromBreakpoint(ConfiguresBreakpoints $breakpoint, int $maxWidth): self { + if (! $this->getUseBreakpoints()) { + throw new InvalidArgumentException("Cannot set max width from breakpoint when breakpoints are disabled for ImageContext with key '{$this->key}'."); + } + $index = $this->getBreakpoints() ->search(function (ConfiguresBreakpoints $bp) use ($breakpoint) { return $bp->value === $breakpoint->value; @@ -306,6 +408,10 @@ public function maxWidthFromBreakpoint(ConfiguresBreakpoints $breakpoint, int $m public function maxWidthToBreakpoint(ConfiguresBreakpoints $breakpoint, int $maxWidth): self { + if (! $this->getUseBreakpoints()) { + throw new InvalidArgumentException("Cannot set max width to breakpoint when breakpoints are disabled for ImageContext with key '{$this->key}'."); + } + $index = $this->getBreakpoints() ->search(function (ConfiguresBreakpoints $bp) use ($breakpoint) { return $bp->value === $breakpoint->value; @@ -322,6 +428,10 @@ public function maxWidthToBreakpoint(ConfiguresBreakpoints $breakpoint, int $max public function maxWidthBetweenBreakpoints(ConfiguresBreakpoints $startBreakpoint, ConfiguresBreakpoints $endBreakpoint, int $maxWidth): self { + if (! $this->getUseBreakpoints()) { + throw new InvalidArgumentException("Cannot set max width between breakpoints when breakpoints are disabled for ImageContext with key '{$this->key}'."); + } + $breakpoints = $this->getBreakpoints(); $startIndex = $breakpoints->search(function (ConfiguresBreakpoints $bp) use ($startBreakpoint) { @@ -341,20 +451,46 @@ public function maxWidthBetweenBreakpoints(ConfiguresBreakpoints $startBreakpoin return $this; } - /** @return array */ - public function getMaxWidthByBreakpoint(): array + public function getMaxWidth(?ConfiguresBreakpoints $breakpoint = null): ?int { - return $this->maxWidthByBreakpoint; + if (! $this->getUseBreakpoints()) { + return $this->maxWidthByBreakpoint['default'] ?? null; + } + + if (is_null($breakpoint)) { + throw new InvalidArgumentException("Breakpoint is required when breakpoints are enabled for ImageContext with key '{$this->key}'."); + } + + return $this->maxWidthByBreakpoint[$breakpoint->value] ?? null; } - public function getMaxWidthForBreakpoint(ConfiguresBreakpoints $breakpoint): ?int + /** @return array */ + public function getMaxWidthByBreakpoint(): array { - return $this->maxWidthByBreakpoint[$breakpoint->value] ?? null; + if (! $this->getUseBreakpoints()) { + throw new InvalidArgumentException("Cannot get max widths by breakpoint when breakpoints are disabled for ImageContext with key '{$this->key}'."); + } + + return $this->maxWidthByBreakpoint; } /** @param CropPosition|string|array $cropPosition */ public function cropPosition(CropPosition|string|array $cropPosition): self { + if (! $this->getUseBreakpoints()) { + if (is_array($cropPosition)) { + throw new InvalidArgumentException("Crop position must be an instance of CropPosition or string when breakpoints are disabled for ImageContext with key '{$this->key}'."); + } + + $cropPosition = $cropPosition instanceof CropPosition ? $cropPosition : CropPosition::from($cropPosition); + + $this->cropPositionByBreakpoint = [ + 'default' => $cropPosition, + ]; + + return $this; + } + $this->cropPositionByBreakpoint = $this->getBreakpoints() ->mapWithKeys(function (ConfiguresBreakpoints $breakpoint) use ($cropPosition) { if (is_array($cropPosition)) { @@ -379,6 +515,10 @@ public function cropPosition(CropPosition|string|array $cropPosition): self public function cropPositionForBreakpoint(ConfiguresBreakpoints $breakpoint, CropPosition|string $cropPosition): self { + if (! $this->getUseBreakpoints()) { + throw new InvalidArgumentException("Cannot set crop position for breakpoint when breakpoints are disabled for ImageContext with key '{$this->key}'."); + } + $cropPosition = $cropPosition instanceof CropPosition ? $cropPosition : CropPosition::from($cropPosition); $this->cropPositionByBreakpoint[$breakpoint->value] = $cropPosition; @@ -388,6 +528,10 @@ public function cropPositionForBreakpoint(ConfiguresBreakpoints $breakpoint, Cro public function cropPositionFromBreakpoint(ConfiguresBreakpoints $breakpoint, CropPosition|string $cropPosition): self { + if (! $this->getUseBreakpoints()) { + throw new InvalidArgumentException("Cannot set crop position from breakpoint when breakpoints are disabled for ImageContext with key '{$this->key}'."); + } + $cropPosition = $cropPosition instanceof CropPosition ? $cropPosition : CropPosition::from($cropPosition); $index = $this->getBreakpoints() @@ -406,6 +550,10 @@ public function cropPositionFromBreakpoint(ConfiguresBreakpoints $breakpoint, Cr public function cropPositionToBreakpoint(ConfiguresBreakpoints $breakpoint, CropPosition|string $cropPosition): self { + if (! $this->getUseBreakpoints()) { + throw new InvalidArgumentException("Cannot set crop position to breakpoint when breakpoints are disabled for ImageContext with key '{$this->key}'."); + } + $cropPosition = $cropPosition instanceof CropPosition ? $cropPosition : CropPosition::from($cropPosition); $index = $this->getBreakpoints() @@ -424,6 +572,10 @@ public function cropPositionToBreakpoint(ConfiguresBreakpoints $breakpoint, Crop public function cropPositionBetweenBreakpoints(ConfiguresBreakpoints $startBreakpoint, ConfiguresBreakpoints $endBreakpoint, CropPosition|string $cropPosition): self { + if (! $this->getUseBreakpoints()) { + throw new InvalidArgumentException("Cannot set crop position between breakpoints when breakpoints are disabled for ImageContext with key '{$this->key}'."); + } + $cropPosition = $cropPosition instanceof CropPosition ? $cropPosition : CropPosition::from($cropPosition); $breakpoints = $this->getBreakpoints(); @@ -445,20 +597,46 @@ public function cropPositionBetweenBreakpoints(ConfiguresBreakpoints $startBreak return $this; } - /** @return array */ - public function getCropPositionByBreakpoint(): array + public function getCropPosition(?ConfiguresBreakpoints $breakpoint = null): ?CropPosition { - return $this->cropPositionByBreakpoint; + if (! $this->getUseBreakpoints()) { + return $this->cropPositionByBreakpoint['default'] ?? ImageLibrary::getDefaultCropPosition(); + } + + if (is_null($breakpoint)) { + throw new InvalidArgumentException("Breakpoint is required when breakpoints are enabled for ImageContext with key '{$this->key}'."); + } + + return $this->cropPositionByBreakpoint[$breakpoint->value] ?? ImageLibrary::getDefaultCropPosition(); } - public function getCropPositionForBreakpoint(ConfiguresBreakpoints $breakpoint): ?CropPosition + /** @return array */ + public function getCropPositionByBreakpoint(): array { - return $this->cropPositionByBreakpoint[$breakpoint->value] ?? ImageLibrary::getDefaultCropPosition(); + if (! $this->getUseBreakpoints()) { + throw new InvalidArgumentException("Cannot get crop positions by breakpoint when breakpoints are disabled for ImageContext with key '{$this->key}'."); + } + + return $this->cropPositionByBreakpoint; } /** @param int|array $blur */ public function blur(int|array $blur): self { + if (! $this->getUseBreakpoints()) { + if (is_array($blur)) { + throw new InvalidArgumentException("Blur must be an integer when breakpoints are disabled for ImageContext with key '{$this->key}'."); + } + + $this->validateBlurValue($blur); + + $this->blurByBreakpoint = [ + 'default' => $blur, + ]; + + return $this; + } + $this->blurByBreakpoint = $this->getBreakpoints() ->mapWithKeys(function (ConfiguresBreakpoints $breakpoint) use ($blur) { if (is_array($blur)) { @@ -503,6 +681,10 @@ public function validateBlurValue(mixed $blur, ?ConfiguresBreakpoints $breakpoin public function blurForBreakpoint(ConfiguresBreakpoints $breakpoint, int $blur): self { + if (! $this->getUseBreakpoints()) { + throw new InvalidArgumentException("Cannot set blur for breakpoint when breakpoints are disabled for ImageContext with key '{$this->key}'."); + } + $this->validateBlurValue($blur, $breakpoint); $this->blurByBreakpoint[$breakpoint->value] = $blur; @@ -512,6 +694,10 @@ public function blurForBreakpoint(ConfiguresBreakpoints $breakpoint, int $blur): public function blurFromBreakpoint(ConfiguresBreakpoints $breakpoint, int $blur): self { + if (! $this->getUseBreakpoints()) { + throw new InvalidArgumentException("Cannot set blur from breakpoint when breakpoints are disabled for ImageContext with key '{$this->key}'."); + } + $this->validateBlurValue($blur, $breakpoint); $index = $this->getBreakpoints() @@ -530,6 +716,10 @@ public function blurFromBreakpoint(ConfiguresBreakpoints $breakpoint, int $blur) public function blurToBreakpoint(ConfiguresBreakpoints $breakpoint, int $blur): self { + if (! $this->getUseBreakpoints()) { + throw new InvalidArgumentException("Cannot set blur to breakpoint when breakpoints are disabled for ImageContext with key '{$this->key}'."); + } + $this->validateBlurValue($blur, $breakpoint); $index = $this->getBreakpoints() @@ -548,6 +738,10 @@ public function blurToBreakpoint(ConfiguresBreakpoints $breakpoint, int $blur): public function blurBetweenBreakpoints(ConfiguresBreakpoints $startBreakpoint, ConfiguresBreakpoints $endBreakpoint, int $blur): self { + if (! $this->getUseBreakpoints()) { + throw new InvalidArgumentException("Cannot set blur between breakpoints when breakpoints are disabled for ImageContext with key '{$this->key}'."); + } + $this->validateBlurValue($blur); $breakpoints = $this->getBreakpoints(); @@ -569,20 +763,46 @@ public function blurBetweenBreakpoints(ConfiguresBreakpoints $startBreakpoint, C return $this; } - /** @return array */ - public function getBlurByBreakpoint(): array + public function getBlur(?ConfiguresBreakpoints $breakpoint = null): ?int { - return $this->blurByBreakpoint; + if (! $this->getUseBreakpoints()) { + return $this->blurByBreakpoint['default'] ?? null; + } + + if (is_null($breakpoint)) { + throw new InvalidArgumentException("Breakpoint is required when breakpoints are enabled for ImageContext with key '{$this->key}'."); + } + + return $this->blurByBreakpoint[$breakpoint->value] ?? null; } - public function getBlurForBreakpoint(ConfiguresBreakpoints $breakpoint): ?int + /** @return array */ + public function getBlurByBreakpoint(): array { - return $this->blurByBreakpoint[$breakpoint->value] ?? null; + if (! $this->getUseBreakpoints()) { + throw new InvalidArgumentException("Cannot get blur values by breakpoint when breakpoints are disabled for ImageContext with key '{$this->key}'."); + } + + return $this->blurByBreakpoint; } /** @param bool|array $greyscale */ public function greyscale(bool|array $greyscale = true): self { + if (! $this->getUseBreakpoints()) { + if (is_array($greyscale)) { + throw new InvalidArgumentException("Greyscale must be a boolean when breakpoints are disabled for ImageContext with key '{$this->key}'."); + } + + $this->validateGreyscaleValue($greyscale); + + $this->greyscaleByBreakpoint = [ + 'default' => $greyscale, + ]; + + return $this; + } + $this->greyscaleByBreakpoint = $this->getBreakpoints() ->mapWithKeys(function (ConfiguresBreakpoints $breakpoint) use ($greyscale) { if (is_array($greyscale)) { @@ -623,6 +843,10 @@ public function grayscale(bool|array $greyscale = true): self public function greyscaleForBreakpoint(ConfiguresBreakpoints $breakpoint, bool $greyscale = true): self { + if (! $this->getUseBreakpoints()) { + throw new InvalidArgumentException("Cannot set greyscale for breakpoint when breakpoints are disabled for ImageContext with key '{$this->key}'."); + } + $this->greyscaleByBreakpoint[$breakpoint->value] = $greyscale; return $this; @@ -635,6 +859,10 @@ public function grayscaleForBreakpoint(ConfiguresBreakpoints $breakpoint, bool $ public function greyscaleFromBreakpoint(ConfiguresBreakpoints $breakpoint, bool $greyscale = true): self { + if (! $this->getUseBreakpoints()) { + throw new InvalidArgumentException("Cannot set greyscale from breakpoint when breakpoints are disabled for ImageContext with key '{$this->key}'."); + } + $index = $this->getBreakpoints() ->search(function (ConfiguresBreakpoints $bp) use ($breakpoint) { return $bp->value === $breakpoint->value; @@ -656,6 +884,10 @@ public function grayscaleFromBreakpoint(ConfiguresBreakpoints $breakpoint, bool public function greyscaleToBreakpoint(ConfiguresBreakpoints $breakpoint, bool $greyscale = true): self { + if (! $this->getUseBreakpoints()) { + throw new InvalidArgumentException("Cannot set greyscale to breakpoint when breakpoints are disabled for ImageContext with key '{$this->key}'."); + } + $index = $this->getBreakpoints() ->search(function (ConfiguresBreakpoints $bp) use ($breakpoint) { return $bp->value === $breakpoint->value; @@ -677,6 +909,10 @@ public function grayscaleToBreakpoint(ConfiguresBreakpoints $breakpoint, bool $g public function greyscaleBetweenBreakpoints(ConfiguresBreakpoints $startBreakpoint, ConfiguresBreakpoints $endBreakpoint, bool $greyscale = true): self { + if (! $this->getUseBreakpoints()) { + throw new InvalidArgumentException("Cannot set greyscale between breakpoints when breakpoints are disabled for ImageContext with key '{$this->key}'."); + } + $breakpoints = $this->getBreakpoints(); $startIndex = $breakpoints->search(function (ConfiguresBreakpoints $bp) use ($startBreakpoint) { @@ -701,31 +937,57 @@ public function grayscaleBetweenBreakpoints(ConfiguresBreakpoints $startBreakpoi return $this->greyscaleBetweenBreakpoints($startBreakpoint, $endBreakpoint, $greyscale); } - /** @return array */ - public function getGreyscaleByBreakpoint(): array + public function getGreyscale(?ConfiguresBreakpoints $breakpoint = null): ?bool { - return $this->greyscaleByBreakpoint; + if (! $this->getUseBreakpoints()) { + return $this->greyscaleByBreakpoint['default'] ?? null; + } + + if (is_null($breakpoint)) { + throw new InvalidArgumentException("Breakpoint is required when breakpoints are enabled for ImageContext with key '{$this->key}'."); + } + + return $this->greyscaleByBreakpoint[$breakpoint->value] ?? null; } - /** @return array */ - public function getGrayscaleByBreakpoint(): array + public function getGrayscale(?ConfiguresBreakpoints $breakpoint = null): ?bool { - return $this->getGreyscaleByBreakpoint(); + return $this->getGreyscale($breakpoint); } - public function getGreyscaleForBreakpoint(ConfiguresBreakpoints $breakpoint): ?bool + /** @return array */ + public function getGreyscaleByBreakpoint(): array { - return $this->greyscaleByBreakpoint[$breakpoint->value] ?? null; + if (! $this->getUseBreakpoints()) { + throw new InvalidArgumentException("Cannot get greyscale values by breakpoint when breakpoints are disabled for ImageContext with key '{$this->key}'."); + } + + return $this->greyscaleByBreakpoint; } - public function getGrayscaleForBreakpoint(ConfiguresBreakpoints $breakpoint): ?bool + /** @return array */ + public function getGrayscaleByBreakpoint(): array { - return $this->getGreyscaleForBreakpoint($breakpoint); + return $this->getGreyscaleByBreakpoint(); } /** @param bool|array $sepia */ public function sepia(bool|array $sepia = true): self { + if (! $this->getUseBreakpoints()) { + if (is_array($sepia)) { + throw new InvalidArgumentException("Sepia must be a boolean when breakpoints are disabled for ImageContext with key '{$this->key}'."); + } + + $this->validateSepiaValue($sepia); + + $this->sepiaByBreakpoint = [ + 'default' => $sepia, + ]; + + return $this; + } + $this->sepiaByBreakpoint = $this->getBreakpoints() ->mapWithKeys(function (ConfiguresBreakpoints $breakpoint) use ($sepia) { if (is_array($sepia)) { @@ -760,6 +1022,10 @@ public function validateSepiaValue(mixed $sepia, ?ConfiguresBreakpoints $breakpo public function sepiaForBreakpoint(ConfiguresBreakpoints $breakpoint, bool $sepia = true): self { + if (! $this->getUseBreakpoints()) { + throw new InvalidArgumentException("Cannot set sepia for breakpoint when breakpoints are disabled for ImageContext with key '{$this->key}'."); + } + $this->sepiaByBreakpoint[$breakpoint->value] = $sepia; return $this; @@ -767,6 +1033,10 @@ public function sepiaForBreakpoint(ConfiguresBreakpoints $breakpoint, bool $sepi public function sepiaFromBreakpoint(ConfiguresBreakpoints $breakpoint, bool $sepia = true): self { + if (! $this->getUseBreakpoints()) { + throw new InvalidArgumentException("Cannot set sepia from breakpoint when breakpoints are disabled for ImageContext with key '{$this->key}'."); + } + $index = $this->getBreakpoints() ->search(function (ConfiguresBreakpoints $bp) use ($breakpoint) { return $bp->value === $breakpoint->value; @@ -783,6 +1053,10 @@ public function sepiaFromBreakpoint(ConfiguresBreakpoints $breakpoint, bool $sep public function sepiaToBreakpoint(ConfiguresBreakpoints $breakpoint, bool $sepia = true): self { + if (! $this->getUseBreakpoints()) { + throw new InvalidArgumentException("Cannot set sepia to breakpoint when breakpoints are disabled for ImageContext with key '{$this->key}'."); + } + $index = $this->getBreakpoints() ->search(function (ConfiguresBreakpoints $bp) use ($breakpoint) { return $bp->value === $breakpoint->value; @@ -799,6 +1073,10 @@ public function sepiaToBreakpoint(ConfiguresBreakpoints $breakpoint, bool $sepia public function sepiaBetweenBreakpoints(ConfiguresBreakpoints $startBreakpoint, ConfiguresBreakpoints $endBreakpoint, bool $sepia = true): self { + if (! $this->getUseBreakpoints()) { + throw new InvalidArgumentException("Cannot set sepia between breakpoints when breakpoints are disabled for ImageContext with key '{$this->key}'."); + } + $breakpoints = $this->getBreakpoints(); $startIndex = $breakpoints->search(function (ConfiguresBreakpoints $bp) use ($startBreakpoint) { @@ -818,15 +1096,27 @@ public function sepiaBetweenBreakpoints(ConfiguresBreakpoints $startBreakpoint, return $this; } - /** @return array */ - public function getSepiaByBreakpoint(): array + public function getSepia(?ConfiguresBreakpoints $breakpoint = null): ?bool { - return $this->sepiaByBreakpoint; + if (! $this->getUseBreakpoints()) { + return $this->sepiaByBreakpoint['default'] ?? null; + } + + if (is_null($breakpoint)) { + throw new InvalidArgumentException("Breakpoint is required when breakpoints are enabled for ImageContext with key '{$this->key}'."); + } + + return $this->sepiaByBreakpoint[$breakpoint->value] ?? null; } - public function getSepiaForBreakpoint(ConfiguresBreakpoints $breakpoint): ?bool + /** @return array */ + public function getSepiaByBreakpoint(): array { - return $this->sepiaByBreakpoint[$breakpoint->value] ?? null; + if (! $this->getUseBreakpoints()) { + throw new InvalidArgumentException("Cannot get sepia values by breakpoint when breakpoints are disabled for ImageContext with key '{$this->key}'."); + } + + return $this->sepiaByBreakpoint; } public function allowsMultiple(bool $allowsMultiple = true): self @@ -841,6 +1131,18 @@ public function getAllowsMultiple(): bool return $this->allowsMultiple; } + public function useBreakpoints(bool $useBreakpoints = true): self + { + $this->useBreakpoints = $useBreakpoints; + + return $this; + } + + public function getUseBreakpoints(): bool + { + return $this->useBreakpoints ?? ImageLibrary::shouldUseBreakpoints(); + } + public function generateWebP(bool $generateWebP = true): self { $this->generateWebP = $generateWebP; @@ -870,14 +1172,23 @@ public function toArray(): array return [ 'key' => $this->key, 'label' => $this->label, - 'aspectRatioByBreakpoint' => array_map( - fn (AspectRatio $ar) => $ar->toArray(), - $this->aspectRatioByBreakpoint - ), - 'blurByBreakpoint' => $this->blurByBreakpoint, - 'greyscaleByBreakpoint' => $this->greyscaleByBreakpoint, - 'sepiaByBreakpoint' => $this->sepiaByBreakpoint, + 'aspectRatio' => $this->getUseBreakpoints() + ? array_map( + fn (AspectRatio $ar) => $ar->toArray(), + $this->aspectRatioByBreakpoint + ) + : ($this->aspectRatioByBreakpoint['default'] ?? null)?->toArray(), + 'blur' => $this->getUseBreakpoints() + ? $this->blurByBreakpoint + : ($this->blurByBreakpoint['default'] ?? null), + 'grayscale' => $this->getUseBreakpoints() + ? $this->greyscaleByBreakpoint + : ($this->greyscaleByBreakpoint['default'] ?? null), + 'sepia' => $this->getUseBreakpoints() + ? $this->sepiaByBreakpoint + : ($this->sepiaByBreakpoint['default'] ?? null), 'allowsMultiple' => $this->allowsMultiple, + 'useBreakpoints' => $this->useBreakpoints, 'generateWebP' => $this->generateWebP, 'generateResponsiveVersions' => $this->generateResponsiveVersions, ]; diff --git a/src/Enums/Breakpoint.php b/src/Enums/Breakpoint.php index f0dda11..2f88c1b 100644 --- a/src/Enums/Breakpoint.php +++ b/src/Enums/Breakpoint.php @@ -13,7 +13,7 @@ enum Breakpoint: string implements ConfiguresBreakpoints case Medium = 'md'; case Large = 'lg'; case ExtraLarge = 'xl'; - case ExtraExtraLarge = '2xl'; + case DoubleExtraLarge = '2xl'; public static function sortedCases(): array { @@ -25,11 +25,11 @@ public static function sortedCases(): array public function getLabel(): string { return match ($this) { - self::Small => __('image-library::breakpoints.sm'), - self::Medium => __('image-library::breakpoints.md'), - self::Large => __('image-library::breakpoints.lg'), - self::ExtraLarge => __('image-library::breakpoints.xl'), - self::ExtraExtraLarge => __('image-library::breakpoints.2xl'), + self::Small => __('image-library::translations.breakpoints.sm'), + self::Medium => __('image-library::translations.breakpoints.md'), + self::Large => __('image-library::translations.breakpoints.lg'), + self::ExtraLarge => __('image-library::translations.breakpoints.xl'), + self::DoubleExtraLarge => __('image-library::translations.breakpoints.2xl'), }; } @@ -40,7 +40,7 @@ public function getMinWidth(): int self::Medium => 768, self::Large => 1024, self::ExtraLarge => 1280, - self::ExtraExtraLarge => 1536, + self::DoubleExtraLarge => 1536, }; } diff --git a/src/Facades/ImageLibrary.php b/src/Facades/ImageLibrary.php index c2a9a7a..8063440 100644 --- a/src/Facades/ImageLibrary.php +++ b/src/Facades/ImageLibrary.php @@ -18,6 +18,9 @@ * @method static class-string getBreakpointEnum() * @method static class-string getImageModel() * @method static class-string getSourceImageModel() + * @method static string getImageModelKeyName() + * @method static string getImageModelSortOrderColumnName() + * @method static string getSourceImageModelKeyName() * @method static SpatieImage getSpatieImage() * @method static void registerImageContexts(array $imageContexts) * @method static void registerImageContext(ImageContext $imageContext) @@ -30,6 +33,7 @@ * @method static string getDefaultDisk() * @method static array getSupportedLocales() * @method static CropPosition getDefaultCropPosition() + * @method static bool shouldUseBreakpoints() * @method static bool shouldGenerateWebp() * @method static bool shouldGenerateResponsiveVersions() * @method static string getDefaultQueueConnection() diff --git a/src/ImageLibrary.php b/src/ImageLibrary.php index 2162b40..9897040 100755 --- a/src/ImageLibrary.php +++ b/src/ImageLibrary.php @@ -35,6 +35,16 @@ public function getImageModel(): string return Config::get('image-library.models.image'); } + public function getImageModelKeyName(): string + { + return (new (self::getImageModel())())->getKeyName(); + } + + public function getImageModelSortOrderColumnName(): string + { + return (new (self::getImageModel())())->determineOrderColumnName(); + } + /** * @return class-string */ @@ -43,13 +53,18 @@ public function getSourceImageModel(): string return Config::get('image-library.models.source_image'); } + public function getSourceImageModelKeyName(): string + { + return (new (self::getSourceImageModel())())->getKeyName(); + } + public function getSpatieImage(): SpatieImage { return SpatieImage::useImageDriver(Config::get('image-library.spatie_image.driver')); } /** - * @param array $imageContexts + * @param array $imageContexts */ public function registerImageContexts(array $imageContexts): void { @@ -94,6 +109,11 @@ public function upload(UploadedFile $file, array $attributes = []): SourceImage return $this->getSourceImageModel()::upload($file, $attributes); } + public function shouldUseBreakpoints(): bool + { + return Config::get('image-library.use_breakpoints', true); + } + public function shouldGenerateWebp(): bool { return Config::get('image-library.generate.webp', true); diff --git a/src/ImageLibraryServiceProvider.php b/src/ImageLibraryServiceProvider.php index 59bbfcc..88b207c 100644 --- a/src/ImageLibraryServiceProvider.php +++ b/src/ImageLibraryServiceProvider.php @@ -4,6 +4,7 @@ namespace Outerweb\ImageLibrary; +use Outerweb\ImageLibrary\Commands\GenerateCommand; use Outerweb\ImageLibrary\Commands\UpgradeCommand; use Outerweb\ImageLibrary\Components\Image; use Outerweb\ImageLibrary\Components\Scripts; @@ -19,8 +20,10 @@ public function configurePackage(Package $package): void ->name('image-library') ->hasConfigFile() ->hasCommands([ + GenerateCommand::class, UpgradeCommand::class, ]) + ->hasTranslations() ->hasMigrations([ 'create_source_images_table', 'create_images_table', diff --git a/src/Jobs/GenerateImageVersionJob.php b/src/Jobs/GenerateImageVersionJob.php index 0b0d53d..d1aa3bd 100644 --- a/src/Jobs/GenerateImageVersionJob.php +++ b/src/Jobs/GenerateImageVersionJob.php @@ -13,6 +13,8 @@ use Outerweb\ImageLibrary\Facades\ImageLibrary; use Outerweb\ImageLibrary\Models\Image; use Spatie\Image\Enums\Fit; +use Spatie\Image\Enums\FlipDirection; +use Spatie\Image\Enums\Orientation; use Spatie\TemporaryDirectory\TemporaryDirectory; class GenerateImageVersionJob implements ShouldQueue @@ -22,7 +24,7 @@ class GenerateImageVersionJob implements ShouldQueue public function __construct( public mixed $imageId, - public ConfiguresBreakpoints $breakpoint, + public ?ConfiguresBreakpoints $breakpoint = null, ) { $this->imageId = $imageId instanceof Image ? $imageId->getKey() : $imageId; @@ -41,7 +43,8 @@ public function handle(): void $image = ImageLibrary::getImageModel()::query() ->findOrFail($this->imageId); - $temporaryPath = new TemporaryDirectory()->create()->path($this->breakpoint->getSlug().'-'.$image->uuid.'.'.$image->sourceImage->extension); + $slug = $this->breakpoint?->getSlug() ?? 'default'; + $temporaryPath = new TemporaryDirectory()->create()->path($slug.'-'.$image->uuid.'.'.$image->sourceImage->extension); File::put($temporaryPath, $image->sourceImage->get()); @@ -49,11 +52,15 @@ public function handle(): void ->loadFile($temporaryPath) ->optimize(); - $cropData = $image->crop_data[$this->breakpoint->value] ?? null; + $cropDataKey = $this->breakpoint->value ?? 'default'; + $cropData = $image->crop_data[$cropDataKey] ?? null; if (! is_null($cropData)) { if (is_null($cropData->x) || is_null($cropData->y)) { - $file->crop($cropData->width, $cropData->height, $image->context->getCropPositionForBreakpoint($this->breakpoint)); + $cropPosition = $image->context + ? $image->context->getCropPosition($this->breakpoint) + : ImageLibrary::getDefaultCropPosition(); + $file->crop($cropData->width, $cropData->height, $cropPosition); } else { $file->manualCrop( $cropData->width, @@ -62,47 +69,66 @@ public function handle(): void $cropData->y, ); } - } else { - $fileWidth = $file->getWidth(); - $maxWidth = min($image->context->getMaxWidthForBreakpoint($this->breakpoint) ?? $fileWidth, $fileWidth); - $maxHeight = $file->getHeight(); - $aspectRatio = $image->context->getAspectRatioForBreakpoint($this->breakpoint); - $possibleWidth = $maxHeight * $aspectRatio->horizontal / $aspectRatio->vertical; - $possibleHeight = $maxWidth * $aspectRatio->vertical / $aspectRatio->horizontal; + if (is_int($cropData->scaleX) && $cropData->scaleX === -1) { + $file->flip(FlipDirection::Horizontal); + } - // @codeCoverageIgnoreStart - if ($possibleWidth <= $maxWidth) { - $width = (int) round($possibleWidth); - $height = $maxHeight; - } else { - $width = $maxWidth; - $height = (int) round($possibleHeight); + if (is_int($cropData->scaleY) && $cropData->scaleY === -1) { + $file->flip(FlipDirection::Vertical); } - // @codeCoverageIgnoreEnd - $file->crop($width, $height, $image->context->getCropPositionForBreakpoint($this->breakpoint)); + if (is_int($cropData->rotate) && $cropData->rotate !== 0) { + $orientation = Orientation::tryFrom((int) ($cropData->rotate)); + + if ($orientation) { + $file->orientation($orientation); + } + } + } elseif ($image->context) { + $fileWidth = $file->getWidth(); + $maxWidth = min($image->context->getMaxWidth($this->breakpoint) ?? $fileWidth, $fileWidth); + $aspectRatio = $image->context->getAspectRatio($this->breakpoint); + $cropPosition = $image->context->getCropPosition($this->breakpoint); + + if ($aspectRatio) { + $maxHeight = $file->getHeight(); + $possibleWidth = $maxHeight * $aspectRatio->horizontal / $aspectRatio->vertical; + $possibleHeight = $maxWidth * $aspectRatio->vertical / $aspectRatio->horizontal; + + // @codeCoverageIgnoreStart + if ($possibleWidth <= $maxWidth) { + $width = (int) round($possibleWidth); + $height = $maxHeight; + } else { + $width = $maxWidth; + $height = (int) round($possibleHeight); + } + // @codeCoverageIgnoreEnd + + $file->crop($width, $height, $cropPosition); + } } - $breakpointMaxWidth = $image->context->getMaxWidthForBreakpoint($this->breakpoint); + if ($image->context) { + $breakpointMaxWidth = $image->context->getMaxWidth($this->breakpoint); - if (! is_null($breakpointMaxWidth)) { - $file->fit(Fit::Max, $breakpointMaxWidth); - } + if (! is_null($breakpointMaxWidth)) { + $file->fit(Fit::Max, $breakpointMaxWidth); + } - $blur = $image->context->getBlurForBreakpoint($this->breakpoint); - if (is_int($blur)) { - $file->blur($blur); - } + $blur = $image->context->getBlur($this->breakpoint); + if (is_int($blur)) { + $file->blur($blur); + } - $greyscale = $image->context->getGreyscaleForBreakpoint($this->breakpoint); - if ($greyscale === true) { - $file->greyscale(); - } + if ($image->context->getGreyscale($this->breakpoint)) { + $file->greyscale(); + } - $sepia = $image->context->getSepiaForBreakpoint($this->breakpoint); - if ($sepia === true) { - $file->sepia(); + if ($image->context->getSepia($this->breakpoint)) { + $file->sepia(); + } } // Create directory @@ -110,7 +136,11 @@ public function handle(): void $file->save($image->getAbsolutePathForBreakpoint($this->breakpoint)); - if ($image->context->getGenerateWebP()) { + $shouldGenerateWebP = $image->context + ? $image->context->getGenerateWebP() + : ImageLibrary::shouldGenerateWebp(); + + if ($shouldGenerateWebP) { $file->save($image->getAbsolutePathForBreakpoint($this->breakpoint, 'webp')); } } diff --git a/src/Jobs/GenerateResponsiveImageVersionsJob.php b/src/Jobs/GenerateResponsiveImageVersionsJob.php index b5354fd..6765bbc 100644 --- a/src/Jobs/GenerateResponsiveImageVersionsJob.php +++ b/src/Jobs/GenerateResponsiveImageVersionsJob.php @@ -81,12 +81,12 @@ private function calculateWidths(string $path, Image $image): array $fileHeight = $file->getHeight(); $fileSize = File::size($path); - $contextMaxWidth = $image->context->getMaxWidthForBreakpoint($this->breakpoint); - $contextMinWidth = $image->context->getMinWidthForBreakpoint($this->breakpoint); + $contextMaxWidth = $image->context->getMaxWidth($this->breakpoint); + $contextMinWidth = $image->context->getMinWidth($this->breakpoint); $breakpointMinWidth = $this->breakpoint->getMinWidth(); - $minWidth = min(is_null($contextMaxWidth) ? ($breakpointMinWidth ?? 0) : ($contextMinWidth ?? 0), ImageLibrary::getResponsiveImageMinWidth()); + $minWidth = min(is_null($contextMaxWidth) ? $breakpointMinWidth : ($contextMinWidth ?? 0), ImageLibrary::getResponsiveImageMinWidth()); $ratio = $fileHeight / $fileWidth; $area = $fileWidth * $fileHeight; diff --git a/src/Models/Image.php b/src/Models/Image.php index f963297..b6fe4e9 100644 --- a/src/Models/Image.php +++ b/src/Models/Image.php @@ -17,6 +17,7 @@ use Illuminate\Support\Facades\Bus; use Illuminate\Support\Facades\Storage; use Illuminate\Support\Str; +use InvalidArgumentException; use Outerweb\ImageLibrary\Contracts\ConfiguresBreakpoints; use Outerweb\ImageLibrary\Database\Factories\ImageFactory; use Outerweb\ImageLibrary\Entities\CropData; @@ -88,6 +89,10 @@ class Image extends Model implements Sortable 'custom_properties' => '{}', ]; + protected $with = [ + 'sourceImage', + ]; + public function model(): MorphTo { return $this->morphTo(); @@ -110,6 +115,17 @@ public function generate(): void { $this->deleteFiles(); + if (! $this->context->getUseBreakpoints()) { + Bus::batch([ + new GenerateImageVersionJob($this->getKey(), null), + ]) + ->onConnection(ImageLibrary::getDefaultQueueConnection()) + ->onQueue(ImageLibrary::getDefaultQueue()) + ->dispatch(); + + return; + } + Bus::chain([ Bus::batch( collect(ImageLibrary::getBreakpointEnum()::sortedCases()) @@ -141,14 +157,22 @@ public function getAbsoluteBasePath(): string return Storage::disk($this->disk)->path($this->getRelativeBasePath()); } - public function getRelativePathForBreakpoint(ConfiguresBreakpoints $breakpoint, ?string $extension = null): string + public function getRelativePathForBreakpoint(?ConfiguresBreakpoints $breakpoint = null, ?string $extension = null): string { $extension ??= $this->sourceImage->extension; + if (is_null($breakpoint)) { + if (! $this->context->getUseBreakpoints()) { + return $this->getRelativeBasePath().'/default.'.$extension; + } + + throw new InvalidArgumentException('Breakpoint must be provided when context uses breakpoints.'); + } + return $this->getRelativeBasePath().'/'.urlencode($breakpoint->getSlug()).'.'.$extension; } - public function getAbsolutePathForBreakpoint(ConfiguresBreakpoints $breakpoint, ?string $extension = null): string + public function getAbsolutePathForBreakpoint(?ConfiguresBreakpoints $breakpoint = null, ?string $extension = null): string { return Storage::disk($this->disk)->path($this->getRelativePathForBreakpoint($breakpoint, $extension)); } @@ -183,22 +207,22 @@ public function getForBreakpoint(ConfiguresBreakpoints $breakpoint, ?string $ext return Storage::disk($this->disk)->get($this->getRelativePathForBreakpoint($breakpoint, $extension)); } - public function existsForBreakpoint(ConfiguresBreakpoints $breakpoint, ?string $extension = null): bool + public function existsForBreakpoint(?ConfiguresBreakpoints $breakpoint = null, ?string $extension = null): bool { return Storage::disk($this->disk)->exists($this->getRelativePathForBreakpoint($breakpoint, $extension)); } - public function missingForBreakpoint(ConfiguresBreakpoints $breakpoint, ?string $extension = null): bool + public function missingForBreakpoint(?ConfiguresBreakpoints $breakpoint = null, ?string $extension = null): bool { return Storage::disk($this->disk)->missing($this->getRelativePathForBreakpoint($breakpoint, $extension)); } - public function downloadForBreakpoint(ConfiguresBreakpoints $breakpoint, ?string $extension = null): StreamedResponse + public function downloadForBreakpoint(?ConfiguresBreakpoints $breakpoint = null, ?string $extension = null): StreamedResponse { return Storage::disk($this->disk)->download($this->getRelativePathForBreakpoint($breakpoint, $extension)); } - public function urlForBreakpoint(ConfiguresBreakpoints $breakpoint, ?string $extension = null): string + public function urlForBreakpoint(?ConfiguresBreakpoints $breakpoint = null, ?string $extension = null): string { $path = $this->getRelativePathForBreakpoint($breakpoint, $extension); @@ -264,12 +288,12 @@ protected function disk(): Attribute ); } - /** @return Attribute */ + /** @return Attribute */ protected function context(): Attribute { return Attribute::make( get: fn (?string $value): ?ImageContext => ImageLibrary::getImageContextByKey($value), - set: fn (ImageContext|string $value): string => is_string($value) ? $value : $value->getKey(), + set: fn (ImageContext|string|null $value): ?string => is_null($value) ? null : (is_string($value) ? $value : $value->getKey()), ); } @@ -316,11 +340,48 @@ private function updateContextConfigurationHash(): void } /** - * @param array|null|CropData $cropData + * @param array|null|CropData $cropData * @return array */ private function generateCropData(array|CropData|null $cropData): array { + $contextKey = $this->getRawOriginal('context') ?? $this->attributes['context'] ?? null; + $context = $contextKey ? ImageLibrary::getImageContextByKey($contextKey) : null; + + if (is_null($context)) { + return []; + } + + if (! $context->getUseBreakpoints()) { + if ($cropData instanceof CropData || is_null($cropData)) { + return ['default' => $cropData]; + } + + if (array_key_exists('default', $cropData)) { + $cropData = $cropData['default']; + } + + if ( + ! is_array($cropData) + || ! array_key_exists('width', $cropData) + || ! array_key_exists('height', $cropData) + ) { + return ['default' => null]; + } + + return [ + 'default' => CropData::make( + $cropData['width'], + $cropData['height'], + $cropData['x'] ?? null, + $cropData['y'] ?? null, + $cropData['rotate'] ?? 0, + $cropData['scaleX'] ?? 1, + $cropData['scaleY'] ?? 1, + ), + ]; + } + if ($cropData instanceof CropData || is_null($cropData)) { return collect(ImageLibrary::getBreakpointEnum()::sortedCases()) ->mapWithKeys(function (BackedEnum $case) use ($cropData): array { @@ -333,13 +394,11 @@ private function generateCropData(array|CropData|null $cropData): array ->mapWithKeys(function (BackedEnum $case) use ($cropData): array { $data = $cropData[$case->value] ?? null; - if ($data instanceof CropData) { - return [$case->value => $data]; - } - if ( is_null($data) - || (! isset($data['width'], $data['height'])) + || ! is_array($data) + || ! array_key_exists('width', $data) + || ! array_key_exists('height', $data) ) { return [$case->value => null]; } @@ -349,6 +408,9 @@ private function generateCropData(array|CropData|null $cropData): array $data['height'], $data['x'] ?? null, $data['y'] ?? null, + $data['rotate'] ?? 0, + $data['scaleX'] ?? 1, + $data['scaleY'] ?? 1, )]; }) ->all(); diff --git a/src/Models/SourceImage.php b/src/Models/SourceImage.php index b34c950..f177fb0 100644 --- a/src/Models/SourceImage.php +++ b/src/Models/SourceImage.php @@ -209,7 +209,7 @@ protected function casts(): array ]; } - /** @return Attribute */ + /** @return Attribute */ protected function nameWithExtension(): Attribute { return Attribute::get( diff --git a/src/Traits/HasImages.php b/src/Traits/HasImages.php index 653085d..1895aed 100644 --- a/src/Traits/HasImages.php +++ b/src/Traits/HasImages.php @@ -40,7 +40,7 @@ public function attachImage(SourceImage $image, array $attributes = [], string $ [ 'model_type' => $this->getMorphClass(), 'model_id' => $this->getKey(), - 'source_image_id' => $image->id, + 'source_image_id' => $image->getKey(), ] )); diff --git a/testbench.yaml b/testbench.yaml new file mode 100644 index 0000000..89125d4 --- /dev/null +++ b/testbench.yaml @@ -0,0 +1,4 @@ +laravel: '@testbench' + +providers: + - Outerweb\ImageLibrary\ImageLibraryServiceProvider diff --git a/tests/Fixtures/Providers/ImageLibraryServiceProvider.php b/tests/Fixtures/Providers/ImageLibraryServiceProvider.php index d9b6409..e4ec69e 100644 --- a/tests/Fixtures/Providers/ImageLibraryServiceProvider.php +++ b/tests/Fixtures/Providers/ImageLibraryServiceProvider.php @@ -25,7 +25,7 @@ public function imageContexts(): array Breakpoint::Medium->value => 600, Breakpoint::Large->value => 900, Breakpoint::ExtraLarge->value => 1200, - Breakpoint::ExtraExtraLarge->value => 1500, + Breakpoint::DoubleExtraLarge->value => 1500, ]) ->allowsMultiple(false), ImageContext::make('context-multiple') @@ -37,7 +37,7 @@ public function imageContexts(): array Breakpoint::Medium->value => 600, Breakpoint::Large->value => 900, Breakpoint::ExtraLarge->value => 1200, - Breakpoint::ExtraExtraLarge->value => 1500, + Breakpoint::DoubleExtraLarge->value => 1500, ]) ->allowsMultiple(true), ]; diff --git a/tests/Pest.php b/tests/Pest.php index bbd3383..60f253e 100644 --- a/tests/Pest.php +++ b/tests/Pest.php @@ -2,6 +2,7 @@ declare(strict_types=1); +use Illuminate\Support\Facades\Bus; use Illuminate\Support\Facades\Storage; use Outerweb\ImageLibrary\Tests\TestCase; @@ -9,4 +10,5 @@ ->in(__DIR__) ->beforeEach(function () { Storage::fake('public'); + Bus::fake(); }); diff --git a/tests/TestCase.php b/tests/TestCase.php index f81be25..cea04f6 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -4,33 +4,26 @@ namespace Outerweb\ImageLibrary\Tests; -use Illuminate\Database\Eloquent\Model; +use Illuminate\Support\Facades\File; use Orchestra\Testbench\TestCase as Orchestra; use Outerweb\ImageLibrary\ImageLibraryServiceProvider; use Outerweb\ImageLibrary\Tests\Fixtures\Providers\ImageLibraryServiceProvider as TestFixtureImageLibraryServiceProvider; class TestCase extends Orchestra { - protected function setUp(): void - { - parent::setUp(); - } - public function getEnvironmentSetUp($app) { config()->set('database.default', 'testing'); config()->set('app.key', 'base64:'.base64_encode(random_bytes(32))); - - Model::shouldBeStrict(true); - - foreach (\Illuminate\Support\Facades\File::allFiles(__DIR__.'/../database/migrations') as $migration) { - (include $migration->getRealPath())->up(); - } } protected function defineDatabaseMigrations(): void { $this->loadLaravelMigrations(); + + foreach (File::files(__DIR__.'/../database/migrations') as $migration) { + (include $migration->getRealPath())->up(); + } } protected function getPackageProviders($app) diff --git a/tests/Unit/Components/ImageTest.php b/tests/Unit/Components/ImageTest.php new file mode 100644 index 0000000..8b567fa --- /dev/null +++ b/tests/Unit/Components/ImageTest.php @@ -0,0 +1,260 @@ +aspectRatio(AspectRatio::make(1, 1)) + ->generateWebP(true) + ->generateResponsiveVersions(true) + ); + + ImageLibrary::registerImageContext( + ImageContext::make('simple') + ->aspectRatio(AspectRatio::make(1, 1)) + ->generateWebP(false) + ->generateResponsiveVersions(false) + ); +}); + +describe('Image Component', function (): void { + it('can be constructed with an image model', function (): void { + $user = User::factory()->create(); + $image = ImageModel::factory() + ->forModel($user) + ->create(); + + $component = new Image($image); + + expect($component->image) + ->toBe($image); + }); + + it('renders the correct view', function (): void { + $user = User::factory()->create(); + $image = ImageModel::factory() + ->forModel($user) + ->create([ + 'context' => 'thumbnail', + ]); + + $component = new Image($image); + $view = $component->render(); + + expect($view) + ->toBeInstanceOf(View::class); + + expect($view->getName()) + ->toBe('image-library::components.image'); + + expect($view->getData()) + ->toHaveKey('sources') + ->and($view->getData()['sources']) + ->toBeInstanceOf(Illuminate\Support\Collection::class); + }); + + it('generates sources with WebP when enabled', function (): void { + $user = User::factory()->create(); + + $file = UploadedFile::fake()->image('example-image.jpg', 1200, 800); + $sourceImage = SourceImage::upload($file); + + $image = ImageModel::factory() + ->forModel($user) + ->create([ + 'source_image_id' => $sourceImage->getKey(), + 'context' => 'thumbnail', + ]); + + // Generate the image versions for testing + GenerateImageVersionJob::dispatchSync($image, Breakpoint::Small); + + $component = new Image($image); + $view = $component->render(); + $sources = $view->getData()['sources']; + + // Should include both regular and WebP sources + expect($sources->count())->toBeGreaterThan(0); + + // Check that we have WebP sources + $hasWebP = $sources->contains(function ($source) { + return $source->type === 'image/webp'; + }); + expect($hasWebP)->toBeTrue(); + }); + + it('does not generate WebP sources when disabled', function (): void { + $user = User::factory()->create(); + + $file = UploadedFile::fake()->image('example-image.jpg', 1200, 800); + $sourceImage = SourceImage::upload($file); + + $image = ImageModel::factory() + ->forModel($user) + ->create([ + 'source_image_id' => $sourceImage->getKey(), + 'context' => 'simple', + ]); + + $component = new Image($image); + $view = $component->render(); + $sources = $view->getData()['sources']; + + // Should not include WebP sources + $hasWebP = $sources->contains(function ($source) { + return $source->type === 'image/webp'; + }); + expect($hasWebP)->toBeFalse(); + }); + + it('generates media queries for breakpoints', function (): void { + $user = User::factory()->create(); + $image = ImageModel::factory() + ->forModel($user) + ->create([ + 'context' => 'thumbnail', + ]); + + $component = new Image($image); + $view = $component->render(); + $sources = $view->getData()['sources']; + + // Should have media queries for different breakpoints + expect($sources->count())->toBeGreaterThan(0); + + $sources->each(function ($source) { + expect($source)->toHaveProperty('media'); + expect($source)->toHaveProperty('srcset'); + expect($source)->toHaveProperty('type'); + }); + }); + + it('handles responsive versions when enabled', function (): void { + $user = User::factory()->create(); + + $file = UploadedFile::fake()->image('example-image.jpg', 1200, 800); + $sourceImage = SourceImage::upload($file); + + $image = ImageModel::factory() + ->forModel($user) + ->create([ + 'source_image_id' => $sourceImage->getKey(), + 'context' => 'thumbnail', // has generateResponsiveVersions = true + ]); + + $component = new Image($image); + $view = $component->render(); + $sources = $view->getData()['sources']; + + expect($sources->count())->toBeGreaterThan(0); + + // Check that the context supports responsive versions + expect($image->context->getGenerateResponsiveVersions())->toBeTrue(); + }); + + it('extracts width from responsive image filenames with width patterns', function (): void { + $user = User::factory()->create(); + + $file = UploadedFile::fake()->image('example-image.jpg', 1200, 800); + $sourceImage = SourceImage::upload($file); + + $image = ImageModel::factory() + ->forModel($user) + ->create([ + 'source_image_id' => $sourceImage->getKey(), + 'context' => 'thumbnail', + ]); + + // Create fake responsive image files to simulate the responsive versions + $basePath = $image->getRelativeBasePath(); + Storage::fake('public'); + Storage::disk('public')->put($basePath.'/sm_w400.jpg', 'fake image content'); + Storage::disk('public')->put($basePath.'/sm_w800.jpg', 'fake image content'); + Storage::disk('public')->put($basePath.'/sm.jpg', 'fake image content'); + + $component = new Image($image); + $view = $component->render(); + $sources = $view->getData()['sources']; + + expect($sources->count())->toBeGreaterThan(0); + + // Verify that srcsets contain width patterns + $sources->each(function ($source) { + expect($source->srcset)->toBeString(); + }); + }); + + it('handles simple srcset when responsive versions are disabled', function (): void { + $user = User::factory()->create(); + + $file = UploadedFile::fake()->image('example-image.jpg', 1200, 800); + $sourceImage = SourceImage::upload($file); + + $image = ImageModel::factory() + ->forModel($user) + ->create([ + 'source_image_id' => $sourceImage->getKey(), + 'context' => 'simple', // has generateResponsiveVersions = false + ]); + + GenerateImageVersionJob::dispatchSync($image, Breakpoint::Small); + + $component = new Image($image); + $view = $component->render(); + $sources = $view->getData()['sources']; + + expect($sources->count())->toBeGreaterThan(0); + + // When responsive versions are disabled, srcset should be simple URL + $sources->each(function ($source) { + expect($source->srcset)->toBeString()->not->toBeEmpty(); + }); + }); + + it('component renders sources without media queries when image does not use breakpoints', function () { + $sourceImage = SourceImage::factory()->create(); + $user = User::factory()->create(); + + $context = ImageContext::make('email') + ->useBreakpoints(false); + + ImageLibrary::registerImageContext($context); + + $image = ImageModel::create([ + 'source_image_id' => $sourceImage->id, + 'model_type' => $user->getMorphClass(), + 'model_id' => $user->id, + 'context' => $context, + 'disk' => 'public', + ]); + + $component = new Image($image); + $view = $component->render(); + + expect($view)->toBeInstanceOf(View::class); + + $viewData = $view->getData(); + expect($viewData)->toHaveKey('useBreakpoints'); + expect($viewData['useBreakpoints'])->toBeFalse(); + expect($viewData['sources'])->toBeArray(); + + // Check that sources don't have media queries + foreach ($viewData['sources'] as $source) { + expect($source->media)->toBe(''); + } + }); +}); diff --git a/tests/Unit/Components/ScriptsTest.php b/tests/Unit/Components/ScriptsTest.php new file mode 100644 index 0000000..9fbbe4d --- /dev/null +++ b/tests/Unit/Components/ScriptsTest.php @@ -0,0 +1,27 @@ +toBeInstanceOf(Scripts::class); + }); + + it('renders the correct view', function (): void { + $component = new Scripts(); + + $view = $component->render(); + + expect($view) + ->toBeInstanceOf(View::class); + + expect($view->getName()) + ->toBe('image-library::components.scripts'); + }); +}); diff --git a/tests/Unit/Entities/CropDataTest.php b/tests/Unit/Entities/CropDataTest.php index f649d48..c6ea1d3 100644 --- a/tests/Unit/Entities/CropDataTest.php +++ b/tests/Unit/Entities/CropDataTest.php @@ -5,18 +5,21 @@ use Outerweb\ImageLibrary\Entities\CropData; it('has a make method', function () { - $cropData = CropData::make(100, 100, 10, 20); + $cropData = CropData::make(100, 100, 10, 20, 30, -1, 1); expect($cropData) ->toBeInstanceOf(CropData::class) ->width->toBe(100) ->height->toBe(100) ->x->toBe(10) - ->y->toBe(20); + ->y->toBe(20) + ->rotate->toBe(30) + ->scaleX->toBe(-1) + ->scaleY->toBe(1); }); it('can be converted to array', function () { - $cropData = CropData::make(100, 100, 10, 20); + $cropData = CropData::make(100, 100, 10, 20, 30, -1, 1); expect($cropData->toArray()) ->toBe([ @@ -24,5 +27,8 @@ 'height' => 100, 'x' => 10, 'y' => 20, + 'rotate' => 30, + 'scaleX' => -1, + 'scaleY' => 1, ]); }); diff --git a/tests/Unit/Entities/ImageContextTest.php b/tests/Unit/Entities/ImageContextTest.php index f06ff88..021e173 100644 --- a/tests/Unit/Entities/ImageContextTest.php +++ b/tests/Unit/Entities/ImageContextTest.php @@ -64,6 +64,13 @@ expect($imageContext->getLabel()) ->toBe('Thumbnail Image from Closure'); }); + + it('falls back to a generated label from the key when no label is set', function () { + $imageContext = ImageContext::make('user_profile_image'); + + expect($imageContext->getLabel()) + ->toBe('User Profile Image'); + }); }); describe('aspect ratio', function () { @@ -88,16 +95,16 @@ Breakpoint::Medium->value => $mobileAspectRatio, Breakpoint::Large->value => $desktopAspectRatio, Breakpoint::ExtraLarge->value => $desktopAspectRatio, - Breakpoint::ExtraExtraLarge->value => $desktopAspectRatio, + Breakpoint::DoubleExtraLarge->value => $desktopAspectRatio, ]); expect($imageContext->getAspectRatioByBreakpoint()) ->toHaveCount(count(Breakpoint::cases())) - ->and($imageContext->getAspectRatioForBreakpoint(Breakpoint::Small))->toBe($mobileAspectRatio) - ->and($imageContext->getAspectRatioForBreakpoint(Breakpoint::Medium))->toBe($mobileAspectRatio) - ->and($imageContext->getAspectRatioForBreakpoint(Breakpoint::Large))->toBe($desktopAspectRatio) - ->and($imageContext->getAspectRatioForBreakpoint(Breakpoint::ExtraLarge))->toBe($desktopAspectRatio) - ->and($imageContext->getAspectRatioForBreakpoint(Breakpoint::ExtraExtraLarge))->toBe($desktopAspectRatio); + ->and($imageContext->getAspectRatio(Breakpoint::Small))->toBe($mobileAspectRatio) + ->and($imageContext->getAspectRatio(Breakpoint::Medium))->toBe($mobileAspectRatio) + ->and($imageContext->getAspectRatio(Breakpoint::Large))->toBe($desktopAspectRatio) + ->and($imageContext->getAspectRatio(Breakpoint::ExtraLarge))->toBe($desktopAspectRatio) + ->and($imageContext->getAspectRatio(Breakpoint::DoubleExtraLarge))->toBe($desktopAspectRatio); }); it('can set and get the aspect ratio for a specific breakpoint', function () { @@ -110,11 +117,11 @@ expect($imageContext->getAspectRatioByBreakpoint()) ->toHaveCount(count(Breakpoint::cases())) - ->and($imageContext->getAspectRatioForBreakpoint(Breakpoint::Small))->toBe($aspectRatio1) - ->and($imageContext->getAspectRatioForBreakpoint(Breakpoint::Medium))->toBe($aspectRatio2) - ->and($imageContext->getAspectRatioForBreakpoint(Breakpoint::Large))->toBe($aspectRatio2) - ->and($imageContext->getAspectRatioForBreakpoint(Breakpoint::ExtraLarge))->toBe($aspectRatio2) - ->and($imageContext->getAspectRatioForBreakpoint(Breakpoint::ExtraExtraLarge))->toBe($aspectRatio2); + ->and($imageContext->getAspectRatio(Breakpoint::Small))->toBe($aspectRatio1) + ->and($imageContext->getAspectRatio(Breakpoint::Medium))->toBe($aspectRatio2) + ->and($imageContext->getAspectRatio(Breakpoint::Large))->toBe($aspectRatio2) + ->and($imageContext->getAspectRatio(Breakpoint::ExtraLarge))->toBe($aspectRatio2) + ->and($imageContext->getAspectRatio(Breakpoint::DoubleExtraLarge))->toBe($aspectRatio2); }); it('can set and get the aspect ratio for all breakpoints after and including a specific breakpoint', function () { @@ -127,11 +134,11 @@ expect($imageContext->getAspectRatioByBreakpoint()) ->toHaveCount(count(Breakpoint::cases())) - ->and($imageContext->getAspectRatioForBreakpoint(Breakpoint::Small))->toBe($aspectRatio1) - ->and($imageContext->getAspectRatioForBreakpoint(Breakpoint::Medium))->toBe($aspectRatio1) - ->and($imageContext->getAspectRatioForBreakpoint(Breakpoint::Large))->toBe($aspectRatio2) - ->and($imageContext->getAspectRatioForBreakpoint(Breakpoint::ExtraLarge))->toBe($aspectRatio2) - ->and($imageContext->getAspectRatioForBreakpoint(Breakpoint::ExtraExtraLarge))->toBe($aspectRatio2); + ->and($imageContext->getAspectRatio(Breakpoint::Small))->toBe($aspectRatio1) + ->and($imageContext->getAspectRatio(Breakpoint::Medium))->toBe($aspectRatio1) + ->and($imageContext->getAspectRatio(Breakpoint::Large))->toBe($aspectRatio2) + ->and($imageContext->getAspectRatio(Breakpoint::ExtraLarge))->toBe($aspectRatio2) + ->and($imageContext->getAspectRatio(Breakpoint::DoubleExtraLarge))->toBe($aspectRatio2); }); it('can set and get the aspect ratio for all breakpoints before and including a specific breakpoint', function () { @@ -144,11 +151,11 @@ expect($imageContext->getAspectRatioByBreakpoint()) ->toHaveCount(count(Breakpoint::cases())) - ->and($imageContext->getAspectRatioForBreakpoint(Breakpoint::Small))->toBe($aspectRatio2) - ->and($imageContext->getAspectRatioForBreakpoint(Breakpoint::Medium))->toBe($aspectRatio2) - ->and($imageContext->getAspectRatioForBreakpoint(Breakpoint::Large))->toBe($aspectRatio2) - ->and($imageContext->getAspectRatioForBreakpoint(Breakpoint::ExtraLarge))->toBe($aspectRatio1) - ->and($imageContext->getAspectRatioForBreakpoint(Breakpoint::ExtraExtraLarge))->toBe($aspectRatio1); + ->and($imageContext->getAspectRatio(Breakpoint::Small))->toBe($aspectRatio2) + ->and($imageContext->getAspectRatio(Breakpoint::Medium))->toBe($aspectRatio2) + ->and($imageContext->getAspectRatio(Breakpoint::Large))->toBe($aspectRatio2) + ->and($imageContext->getAspectRatio(Breakpoint::ExtraLarge))->toBe($aspectRatio1) + ->and($imageContext->getAspectRatio(Breakpoint::DoubleExtraLarge))->toBe($aspectRatio1); }); it('can set and get the aspect ratio for all breakpoints between 2 breakpoints', function () { @@ -161,11 +168,11 @@ expect($imageContext->getAspectRatioByBreakpoint()) ->toHaveCount(count(Breakpoint::cases())) - ->and($imageContext->getAspectRatioForBreakpoint(Breakpoint::Small))->toBe($aspectRatio1) - ->and($imageContext->getAspectRatioForBreakpoint(Breakpoint::Medium))->toBe($aspectRatio2) - ->and($imageContext->getAspectRatioForBreakpoint(Breakpoint::Large))->toBe($aspectRatio2) - ->and($imageContext->getAspectRatioForBreakpoint(Breakpoint::ExtraLarge))->toBe($aspectRatio2) - ->and($imageContext->getAspectRatioForBreakpoint(Breakpoint::ExtraExtraLarge))->toBe($aspectRatio1); + ->and($imageContext->getAspectRatio(Breakpoint::Small))->toBe($aspectRatio1) + ->and($imageContext->getAspectRatio(Breakpoint::Medium))->toBe($aspectRatio2) + ->and($imageContext->getAspectRatio(Breakpoint::Large))->toBe($aspectRatio2) + ->and($imageContext->getAspectRatio(Breakpoint::ExtraLarge))->toBe($aspectRatio2) + ->and($imageContext->getAspectRatio(Breakpoint::DoubleExtraLarge))->toBe($aspectRatio1); }); it('throws an exception when aspect ratio for a breakpoint is not defined', function () { @@ -194,16 +201,16 @@ Breakpoint::Medium->value => 480, Breakpoint::Large->value => 768, Breakpoint::ExtraLarge->value => 1024, - Breakpoint::ExtraExtraLarge->value => 1280, + Breakpoint::DoubleExtraLarge->value => 1280, ]); expect($imageContext->getMinWidthByBreakpoint()) ->toHaveCount(count(Breakpoint::cases())) - ->and($imageContext->getMinWidthForBreakpoint(Breakpoint::Small))->toBe(320) - ->and($imageContext->getMinWidthForBreakpoint(Breakpoint::Medium))->toBe(480) - ->and($imageContext->getMinWidthForBreakpoint(Breakpoint::Large))->toBe(768) - ->and($imageContext->getMinWidthForBreakpoint(Breakpoint::ExtraLarge))->toBe(1024) - ->and($imageContext->getMinWidthForBreakpoint(Breakpoint::ExtraExtraLarge))->toBe(1280); + ->and($imageContext->getMinWidth(Breakpoint::Small))->toBe(320) + ->and($imageContext->getMinWidth(Breakpoint::Medium))->toBe(480) + ->and($imageContext->getMinWidth(Breakpoint::Large))->toBe(768) + ->and($imageContext->getMinWidth(Breakpoint::ExtraLarge))->toBe(1024) + ->and($imageContext->getMinWidth(Breakpoint::DoubleExtraLarge))->toBe(1280); }); it('can set and get the min width for a specific breakpoint', function () { @@ -213,11 +220,11 @@ expect($imageContext->getMinWidthByBreakpoint()) ->toHaveCount(count(Breakpoint::cases())) - ->and($imageContext->getMinWidthForBreakpoint(Breakpoint::Small))->toBe(480) - ->and($imageContext->getMinWidthForBreakpoint(Breakpoint::Medium))->toBe(320) - ->and($imageContext->getMinWidthForBreakpoint(Breakpoint::Large))->toBe(320) - ->and($imageContext->getMinWidthForBreakpoint(Breakpoint::ExtraLarge))->toBe(320) - ->and($imageContext->getMinWidthForBreakpoint(Breakpoint::ExtraExtraLarge))->toBe(320); + ->and($imageContext->getMinWidth(Breakpoint::Small))->toBe(480) + ->and($imageContext->getMinWidth(Breakpoint::Medium))->toBe(320) + ->and($imageContext->getMinWidth(Breakpoint::Large))->toBe(320) + ->and($imageContext->getMinWidth(Breakpoint::ExtraLarge))->toBe(320) + ->and($imageContext->getMinWidth(Breakpoint::DoubleExtraLarge))->toBe(320); }); it('can set and get the min width for all breakpoints after and including a specific breakpoint', function () { @@ -227,11 +234,11 @@ expect($imageContext->getMinWidthByBreakpoint()) ->toHaveCount(count(Breakpoint::cases())) - ->and($imageContext->getMinWidthForBreakpoint(Breakpoint::Small))->toBe(320) - ->and($imageContext->getMinWidthForBreakpoint(Breakpoint::Medium))->toBe(320) - ->and($imageContext->getMinWidthForBreakpoint(Breakpoint::Large))->toBe(768) - ->and($imageContext->getMinWidthForBreakpoint(Breakpoint::ExtraLarge))->toBe(768) - ->and($imageContext->getMinWidthForBreakpoint(Breakpoint::ExtraExtraLarge))->toBe(768); + ->and($imageContext->getMinWidth(Breakpoint::Small))->toBe(320) + ->and($imageContext->getMinWidth(Breakpoint::Medium))->toBe(320) + ->and($imageContext->getMinWidth(Breakpoint::Large))->toBe(768) + ->and($imageContext->getMinWidth(Breakpoint::ExtraLarge))->toBe(768) + ->and($imageContext->getMinWidth(Breakpoint::DoubleExtraLarge))->toBe(768); }); it('can set and get the min width for all breakpoints before and including a specific breakpoint', function () { @@ -241,11 +248,11 @@ expect($imageContext->getMinWidthByBreakpoint()) ->toHaveCount(count(Breakpoint::cases())) - ->and($imageContext->getMinWidthForBreakpoint(Breakpoint::Small))->toBe(320) - ->and($imageContext->getMinWidthForBreakpoint(Breakpoint::Medium))->toBe(320) - ->and($imageContext->getMinWidthForBreakpoint(Breakpoint::Large))->toBe(320) - ->and($imageContext->getMinWidthForBreakpoint(Breakpoint::ExtraLarge))->toBe(768) - ->and($imageContext->getMinWidthForBreakpoint(Breakpoint::ExtraExtraLarge))->toBe(768); + ->and($imageContext->getMinWidth(Breakpoint::Small))->toBe(320) + ->and($imageContext->getMinWidth(Breakpoint::Medium))->toBe(320) + ->and($imageContext->getMinWidth(Breakpoint::Large))->toBe(320) + ->and($imageContext->getMinWidth(Breakpoint::ExtraLarge))->toBe(768) + ->and($imageContext->getMinWidth(Breakpoint::DoubleExtraLarge))->toBe(768); }); it('can set and get the min width for all breakpoints between 2 breakpoints', function () { @@ -255,11 +262,11 @@ expect($imageContext->getMinWidthByBreakpoint()) ->toHaveCount(count(Breakpoint::cases())) - ->and($imageContext->getMinWidthForBreakpoint(Breakpoint::Small))->toBe(320) - ->and($imageContext->getMinWidthForBreakpoint(Breakpoint::Medium))->toBe(768) - ->and($imageContext->getMinWidthForBreakpoint(Breakpoint::Large))->toBe(768) - ->and($imageContext->getMinWidthForBreakpoint(Breakpoint::ExtraLarge))->toBe(768) - ->and($imageContext->getMinWidthForBreakpoint(Breakpoint::ExtraExtraLarge))->toBe(320); + ->and($imageContext->getMinWidth(Breakpoint::Small))->toBe(320) + ->and($imageContext->getMinWidth(Breakpoint::Medium))->toBe(768) + ->and($imageContext->getMinWidth(Breakpoint::Large))->toBe(768) + ->and($imageContext->getMinWidth(Breakpoint::ExtraLarge))->toBe(768) + ->and($imageContext->getMinWidth(Breakpoint::DoubleExtraLarge))->toBe(320); }); it('throws an exception when min width for a breakpoint is not defined', function () { @@ -288,16 +295,16 @@ Breakpoint::Medium->value => 480, Breakpoint::Large->value => 768, Breakpoint::ExtraLarge->value => 1024, - Breakpoint::ExtraExtraLarge->value => 1280, + Breakpoint::DoubleExtraLarge->value => 1280, ]); expect($imageContext->getMaxWidthByBreakpoint()) ->toHaveCount(count(Breakpoint::cases())) - ->and($imageContext->getMaxWidthForBreakpoint(Breakpoint::Small))->toBe(320) - ->and($imageContext->getMaxWidthForBreakpoint(Breakpoint::Medium))->toBe(480) - ->and($imageContext->getMaxWidthForBreakpoint(Breakpoint::Large))->toBe(768) - ->and($imageContext->getMaxWidthForBreakpoint(Breakpoint::ExtraLarge))->toBe(1024) - ->and($imageContext->getMaxWidthForBreakpoint(Breakpoint::ExtraExtraLarge))->toBe(1280); + ->and($imageContext->getMaxWidth(Breakpoint::Small))->toBe(320) + ->and($imageContext->getMaxWidth(Breakpoint::Medium))->toBe(480) + ->and($imageContext->getMaxWidth(Breakpoint::Large))->toBe(768) + ->and($imageContext->getMaxWidth(Breakpoint::ExtraLarge))->toBe(1024) + ->and($imageContext->getMaxWidth(Breakpoint::DoubleExtraLarge))->toBe(1280); }); it('can set and get the min width for a specific breakpoint', function () { @@ -307,11 +314,11 @@ expect($imageContext->getMaxWidthByBreakpoint()) ->toHaveCount(count(Breakpoint::cases())) - ->and($imageContext->getMaxWidthForBreakpoint(Breakpoint::Small))->toBe(480) - ->and($imageContext->getMaxWidthForBreakpoint(Breakpoint::Medium))->toBe(320) - ->and($imageContext->getMaxWidthForBreakpoint(Breakpoint::Large))->toBe(320) - ->and($imageContext->getMaxWidthForBreakpoint(Breakpoint::ExtraLarge))->toBe(320) - ->and($imageContext->getMaxWidthForBreakpoint(Breakpoint::ExtraExtraLarge))->toBe(320); + ->and($imageContext->getMaxWidth(Breakpoint::Small))->toBe(480) + ->and($imageContext->getMaxWidth(Breakpoint::Medium))->toBe(320) + ->and($imageContext->getMaxWidth(Breakpoint::Large))->toBe(320) + ->and($imageContext->getMaxWidth(Breakpoint::ExtraLarge))->toBe(320) + ->and($imageContext->getMaxWidth(Breakpoint::DoubleExtraLarge))->toBe(320); }); it('can set and get the min width for all breakpoints after and including a specific breakpoint', function () { @@ -321,11 +328,11 @@ expect($imageContext->getMaxWidthByBreakpoint()) ->toHaveCount(count(Breakpoint::cases())) - ->and($imageContext->getMaxWidthForBreakpoint(Breakpoint::Small))->toBe(320) - ->and($imageContext->getMaxWidthForBreakpoint(Breakpoint::Medium))->toBe(320) - ->and($imageContext->getMaxWidthForBreakpoint(Breakpoint::Large))->toBe(768) - ->and($imageContext->getMaxWidthForBreakpoint(Breakpoint::ExtraLarge))->toBe(768) - ->and($imageContext->getMaxWidthForBreakpoint(Breakpoint::ExtraExtraLarge))->toBe(768); + ->and($imageContext->getMaxWidth(Breakpoint::Small))->toBe(320) + ->and($imageContext->getMaxWidth(Breakpoint::Medium))->toBe(320) + ->and($imageContext->getMaxWidth(Breakpoint::Large))->toBe(768) + ->and($imageContext->getMaxWidth(Breakpoint::ExtraLarge))->toBe(768) + ->and($imageContext->getMaxWidth(Breakpoint::DoubleExtraLarge))->toBe(768); }); it('can set and get the min width for all breakpoints before and including a specific breakpoint', function () { @@ -335,11 +342,11 @@ expect($imageContext->getMaxWidthByBreakpoint()) ->toHaveCount(count(Breakpoint::cases())) - ->and($imageContext->getMaxWidthForBreakpoint(Breakpoint::Small))->toBe(320) - ->and($imageContext->getMaxWidthForBreakpoint(Breakpoint::Medium))->toBe(320) - ->and($imageContext->getMaxWidthForBreakpoint(Breakpoint::Large))->toBe(320) - ->and($imageContext->getMaxWidthForBreakpoint(Breakpoint::ExtraLarge))->toBe(768) - ->and($imageContext->getMaxWidthForBreakpoint(Breakpoint::ExtraExtraLarge))->toBe(768); + ->and($imageContext->getMaxWidth(Breakpoint::Small))->toBe(320) + ->and($imageContext->getMaxWidth(Breakpoint::Medium))->toBe(320) + ->and($imageContext->getMaxWidth(Breakpoint::Large))->toBe(320) + ->and($imageContext->getMaxWidth(Breakpoint::ExtraLarge))->toBe(768) + ->and($imageContext->getMaxWidth(Breakpoint::DoubleExtraLarge))->toBe(768); }); it('can set and get the min width for all breakpoints between 2 breakpoints', function () { @@ -349,11 +356,11 @@ expect($imageContext->getMaxWidthByBreakpoint()) ->toHaveCount(count(Breakpoint::cases())) - ->and($imageContext->getMaxWidthForBreakpoint(Breakpoint::Small))->toBe(320) - ->and($imageContext->getMaxWidthForBreakpoint(Breakpoint::Medium))->toBe(768) - ->and($imageContext->getMaxWidthForBreakpoint(Breakpoint::Large))->toBe(768) - ->and($imageContext->getMaxWidthForBreakpoint(Breakpoint::ExtraLarge))->toBe(768) - ->and($imageContext->getMaxWidthForBreakpoint(Breakpoint::ExtraExtraLarge))->toBe(320); + ->and($imageContext->getMaxWidth(Breakpoint::Small))->toBe(320) + ->and($imageContext->getMaxWidth(Breakpoint::Medium))->toBe(768) + ->and($imageContext->getMaxWidth(Breakpoint::Large))->toBe(768) + ->and($imageContext->getMaxWidth(Breakpoint::ExtraLarge))->toBe(768) + ->and($imageContext->getMaxWidth(Breakpoint::DoubleExtraLarge))->toBe(320); }); it('throws an exception when min width for a breakpoint is not defined', function () { @@ -382,22 +389,22 @@ Breakpoint::Medium->value => 'bottom', Breakpoint::Large->value => 'left', Breakpoint::ExtraLarge->value => 'right', - Breakpoint::ExtraExtraLarge->value => 'center', + Breakpoint::DoubleExtraLarge->value => 'center', ]); expect($imageContext->getCropPositionByBreakpoint()) ->toHaveCount(count(Breakpoint::cases())) - ->and($imageContext->getCropPositionForBreakpoint(Breakpoint::Small))->toBe(CropPosition::Top) - ->and($imageContext->getCropPositionForBreakpoint(Breakpoint::Medium))->toBe(CropPosition::Bottom) - ->and($imageContext->getCropPositionForBreakpoint(Breakpoint::Large))->toBe(CropPosition::Left) - ->and($imageContext->getCropPositionForBreakpoint(Breakpoint::ExtraLarge))->toBe(CropPosition::Right) - ->and($imageContext->getCropPositionForBreakpoint(Breakpoint::ExtraExtraLarge))->toBe(CropPosition::Center); + ->and($imageContext->getCropPosition(Breakpoint::Small))->toBe(CropPosition::Top) + ->and($imageContext->getCropPosition(Breakpoint::Medium))->toBe(CropPosition::Bottom) + ->and($imageContext->getCropPosition(Breakpoint::Large))->toBe(CropPosition::Left) + ->and($imageContext->getCropPosition(Breakpoint::ExtraLarge))->toBe(CropPosition::Right) + ->and($imageContext->getCropPosition(Breakpoint::DoubleExtraLarge))->toBe(CropPosition::Center); }); it('falls back to the default crop position if not set', function () { $imageContext = ImageContext::make('thumbnail'); - expect($imageContext->getCropPositionForBreakpoint(Breakpoint::Small)) + expect($imageContext->getCropPosition(Breakpoint::Small)) ->toBe(ImageLibrary::getDefaultCropPosition()); }); @@ -405,7 +412,7 @@ $imageContext = ImageContext::make('thumbnail') ->cropPosition(CropPosition::Center); - expect($imageContext->getCropPositionForBreakpoint(Breakpoint::Small)) + expect($imageContext->getCropPosition(Breakpoint::Small)) ->toBe(CropPosition::Center); }); @@ -424,11 +431,11 @@ expect($imageContext->getCropPositionByBreakpoint()) ->toHaveCount(count(Breakpoint::cases())) - ->and($imageContext->getCropPositionForBreakpoint(Breakpoint::Small))->toBe(CropPosition::TopLeft) - ->and($imageContext->getCropPositionForBreakpoint(Breakpoint::Medium))->toBe(CropPosition::Center) - ->and($imageContext->getCropPositionForBreakpoint(Breakpoint::Large))->toBe(CropPosition::Center) - ->and($imageContext->getCropPositionForBreakpoint(Breakpoint::ExtraLarge))->toBe(CropPosition::Center) - ->and($imageContext->getCropPositionForBreakpoint(Breakpoint::ExtraExtraLarge))->toBe(CropPosition::Center); + ->and($imageContext->getCropPosition(Breakpoint::Small))->toBe(CropPosition::TopLeft) + ->and($imageContext->getCropPosition(Breakpoint::Medium))->toBe(CropPosition::Center) + ->and($imageContext->getCropPosition(Breakpoint::Large))->toBe(CropPosition::Center) + ->and($imageContext->getCropPosition(Breakpoint::ExtraLarge))->toBe(CropPosition::Center) + ->and($imageContext->getCropPosition(Breakpoint::DoubleExtraLarge))->toBe(CropPosition::Center); }); it('can set and get the crop position for all breakpoints after and including a specific breakpoint', function () { @@ -438,11 +445,11 @@ expect($imageContext->getCropPositionByBreakpoint()) ->toHaveCount(count(Breakpoint::cases())) - ->and($imageContext->getCropPositionForBreakpoint(Breakpoint::Small))->toBe(CropPosition::TopLeft) - ->and($imageContext->getCropPositionForBreakpoint(Breakpoint::Medium))->toBe(CropPosition::TopLeft) - ->and($imageContext->getCropPositionForBreakpoint(Breakpoint::Large))->toBe(CropPosition::BottomRight) - ->and($imageContext->getCropPositionForBreakpoint(Breakpoint::ExtraLarge))->toBe(CropPosition::BottomRight) - ->and($imageContext->getCropPositionForBreakpoint(Breakpoint::ExtraExtraLarge))->toBe(CropPosition::BottomRight); + ->and($imageContext->getCropPosition(Breakpoint::Small))->toBe(CropPosition::TopLeft) + ->and($imageContext->getCropPosition(Breakpoint::Medium))->toBe(CropPosition::TopLeft) + ->and($imageContext->getCropPosition(Breakpoint::Large))->toBe(CropPosition::BottomRight) + ->and($imageContext->getCropPosition(Breakpoint::ExtraLarge))->toBe(CropPosition::BottomRight) + ->and($imageContext->getCropPosition(Breakpoint::DoubleExtraLarge))->toBe(CropPosition::BottomRight); }); it('can set and get the crop position for all breakpoints before and including a specific breakpoint', function () { @@ -452,11 +459,11 @@ expect($imageContext->getCropPositionByBreakpoint()) ->toHaveCount(count(Breakpoint::cases())) - ->and($imageContext->getCropPositionForBreakpoint(Breakpoint::Small))->toBe(CropPosition::BottomRight) - ->and($imageContext->getCropPositionForBreakpoint(Breakpoint::Medium))->toBe(CropPosition::BottomRight) - ->and($imageContext->getCropPositionForBreakpoint(Breakpoint::Large))->toBe(CropPosition::BottomRight) - ->and($imageContext->getCropPositionForBreakpoint(Breakpoint::ExtraLarge))->toBe(CropPosition::TopLeft) - ->and($imageContext->getCropPositionForBreakpoint(Breakpoint::ExtraExtraLarge))->toBe(CropPosition::TopLeft); + ->and($imageContext->getCropPosition(Breakpoint::Small))->toBe(CropPosition::BottomRight) + ->and($imageContext->getCropPosition(Breakpoint::Medium))->toBe(CropPosition::BottomRight) + ->and($imageContext->getCropPosition(Breakpoint::Large))->toBe(CropPosition::BottomRight) + ->and($imageContext->getCropPosition(Breakpoint::ExtraLarge))->toBe(CropPosition::TopLeft) + ->and($imageContext->getCropPosition(Breakpoint::DoubleExtraLarge))->toBe(CropPosition::TopLeft); }); it('can set and get the crop position for all breakpoints between 2 breakpoints', function () { @@ -466,11 +473,11 @@ expect($imageContext->getCropPositionByBreakpoint()) ->toHaveCount(count(Breakpoint::cases())) - ->and($imageContext->getCropPositionForBreakpoint(Breakpoint::Small))->toBe(CropPosition::TopLeft) - ->and($imageContext->getCropPositionForBreakpoint(Breakpoint::Medium))->toBe(CropPosition::BottomRight) - ->and($imageContext->getCropPositionForBreakpoint(Breakpoint::Large))->toBe(CropPosition::BottomRight) - ->and($imageContext->getCropPositionForBreakpoint(Breakpoint::ExtraLarge))->toBe(CropPosition::BottomRight) - ->and($imageContext->getCropPositionForBreakpoint(Breakpoint::ExtraExtraLarge))->toBe(CropPosition::TopLeft); + ->and($imageContext->getCropPosition(Breakpoint::Small))->toBe(CropPosition::TopLeft) + ->and($imageContext->getCropPosition(Breakpoint::Medium))->toBe(CropPosition::BottomRight) + ->and($imageContext->getCropPosition(Breakpoint::Large))->toBe(CropPosition::BottomRight) + ->and($imageContext->getCropPosition(Breakpoint::ExtraLarge))->toBe(CropPosition::BottomRight) + ->and($imageContext->getCropPosition(Breakpoint::DoubleExtraLarge))->toBe(CropPosition::TopLeft); }); }); @@ -525,16 +532,16 @@ Breakpoint::Medium->value => 4, Breakpoint::Large->value => 6, Breakpoint::ExtraLarge->value => 8, - Breakpoint::ExtraExtraLarge->value => 10, + Breakpoint::DoubleExtraLarge->value => 10, ]); expect($imageContext->getBlurByBreakpoint()) ->toHaveCount(count(Breakpoint::cases())) - ->and($imageContext->getBlurForBreakpoint(Breakpoint::Small))->toBe(2) - ->and($imageContext->getBlurForBreakpoint(Breakpoint::Medium))->toBe(4) - ->and($imageContext->getBlurForBreakpoint(Breakpoint::Large))->toBe(6) - ->and($imageContext->getBlurForBreakpoint(Breakpoint::ExtraLarge))->toBe(8) - ->and($imageContext->getBlurForBreakpoint(Breakpoint::ExtraExtraLarge))->toBe(10); + ->and($imageContext->getBlur(Breakpoint::Small))->toBe(2) + ->and($imageContext->getBlur(Breakpoint::Medium))->toBe(4) + ->and($imageContext->getBlur(Breakpoint::Large))->toBe(6) + ->and($imageContext->getBlur(Breakpoint::ExtraLarge))->toBe(8) + ->and($imageContext->getBlur(Breakpoint::DoubleExtraLarge))->toBe(10); }); it('can set and get the blur for a specific breakpoint', function () { @@ -544,11 +551,11 @@ expect($imageContext->getBlurByBreakpoint()) ->toHaveCount(count(Breakpoint::cases())) - ->and($imageContext->getBlurForBreakpoint(Breakpoint::Small))->toBe(5) - ->and($imageContext->getBlurForBreakpoint(Breakpoint::Medium))->toBe(0) - ->and($imageContext->getBlurForBreakpoint(Breakpoint::Large))->toBe(0) - ->and($imageContext->getBlurForBreakpoint(Breakpoint::ExtraLarge))->toBe(0) - ->and($imageContext->getBlurForBreakpoint(Breakpoint::ExtraExtraLarge))->toBe(0); + ->and($imageContext->getBlur(Breakpoint::Small))->toBe(5) + ->and($imageContext->getBlur(Breakpoint::Medium))->toBe(0) + ->and($imageContext->getBlur(Breakpoint::Large))->toBe(0) + ->and($imageContext->getBlur(Breakpoint::ExtraLarge))->toBe(0) + ->and($imageContext->getBlur(Breakpoint::DoubleExtraLarge))->toBe(0); }); it('can set and get the blur for all breakpoints after and including a specific breakpoint', function () { @@ -558,11 +565,11 @@ expect($imageContext->getBlurByBreakpoint()) ->toHaveCount(count(Breakpoint::cases())) - ->and($imageContext->getBlurForBreakpoint(Breakpoint::Small))->toBe(0) - ->and($imageContext->getBlurForBreakpoint(Breakpoint::Medium))->toBe(0) - ->and($imageContext->getBlurForBreakpoint(Breakpoint::Large))->toBe(10) - ->and($imageContext->getBlurForBreakpoint(Breakpoint::ExtraLarge))->toBe(10) - ->and($imageContext->getBlurForBreakpoint(Breakpoint::ExtraExtraLarge))->toBe(10); + ->and($imageContext->getBlur(Breakpoint::Small))->toBe(0) + ->and($imageContext->getBlur(Breakpoint::Medium))->toBe(0) + ->and($imageContext->getBlur(Breakpoint::Large))->toBe(10) + ->and($imageContext->getBlur(Breakpoint::ExtraLarge))->toBe(10) + ->and($imageContext->getBlur(Breakpoint::DoubleExtraLarge))->toBe(10); }); it('can set and get the blur for all breakpoints before and including a specific breakpoint', function () { @@ -572,11 +579,11 @@ expect($imageContext->getBlurByBreakpoint()) ->toHaveCount(count(Breakpoint::cases())) - ->and($imageContext->getBlurForBreakpoint(Breakpoint::Small))->toBe(10) - ->and($imageContext->getBlurForBreakpoint(Breakpoint::Medium))->toBe(10) - ->and($imageContext->getBlurForBreakpoint(Breakpoint::Large))->toBe(10) - ->and($imageContext->getBlurForBreakpoint(Breakpoint::ExtraLarge))->toBe(0) - ->and($imageContext->getBlurForBreakpoint(Breakpoint::ExtraExtraLarge))->toBe(0); + ->and($imageContext->getBlur(Breakpoint::Small))->toBe(10) + ->and($imageContext->getBlur(Breakpoint::Medium))->toBe(10) + ->and($imageContext->getBlur(Breakpoint::Large))->toBe(10) + ->and($imageContext->getBlur(Breakpoint::ExtraLarge))->toBe(0) + ->and($imageContext->getBlur(Breakpoint::DoubleExtraLarge))->toBe(0); }); it('can set and get the blur for all breakpoints between 2 breakpoints', function () { @@ -586,11 +593,11 @@ expect($imageContext->getBlurByBreakpoint()) ->toHaveCount(count(Breakpoint::cases())) - ->and($imageContext->getBlurForBreakpoint(Breakpoint::Small))->toBe(0) - ->and($imageContext->getBlurForBreakpoint(Breakpoint::Medium))->toBe(10) - ->and($imageContext->getBlurForBreakpoint(Breakpoint::Large))->toBe(10) - ->and($imageContext->getBlurForBreakpoint(Breakpoint::ExtraLarge))->toBe(10) - ->and($imageContext->getBlurForBreakpoint(Breakpoint::ExtraExtraLarge))->toBe(0); + ->and($imageContext->getBlur(Breakpoint::Small))->toBe(0) + ->and($imageContext->getBlur(Breakpoint::Medium))->toBe(10) + ->and($imageContext->getBlur(Breakpoint::Large))->toBe(10) + ->and($imageContext->getBlur(Breakpoint::ExtraLarge))->toBe(10) + ->and($imageContext->getBlur(Breakpoint::DoubleExtraLarge))->toBe(0); }); it('throws an exception when blur for a breakpoint is not defined', function () { @@ -604,7 +611,7 @@ it('returns null if not defined', function () { $imageContext = ImageContext::make('thumbnail'); - expect($imageContext->getBlurForBreakpoint(Breakpoint::Small))->toBeNull(); + expect($imageContext->getBlur(Breakpoint::Small))->toBeNull(); }); }); @@ -625,16 +632,16 @@ Breakpoint::Medium->value => false, Breakpoint::Large->value => true, Breakpoint::ExtraLarge->value => false, - Breakpoint::ExtraExtraLarge->value => true, + Breakpoint::DoubleExtraLarge->value => true, ]); expect($imageContext->getGrayscaleByBreakpoint()) ->toHaveCount(count(Breakpoint::cases())) - ->and($imageContext->getGrayscaleForBreakpoint(Breakpoint::Small))->toBeTrue() - ->and($imageContext->getGrayscaleForBreakpoint(Breakpoint::Medium))->toBeFalse() - ->and($imageContext->getGrayscaleForBreakpoint(Breakpoint::Large))->toBeTrue() - ->and($imageContext->getGrayscaleForBreakpoint(Breakpoint::ExtraLarge))->toBeFalse() - ->and($imageContext->getGrayscaleForBreakpoint(Breakpoint::ExtraExtraLarge))->toBeTrue(); + ->and($imageContext->getGrayscale(Breakpoint::Small))->toBeTrue() + ->and($imageContext->getGrayscale(Breakpoint::Medium))->toBeFalse() + ->and($imageContext->getGrayscale(Breakpoint::Large))->toBeTrue() + ->and($imageContext->getGrayscale(Breakpoint::ExtraLarge))->toBeFalse() + ->and($imageContext->getGrayscale(Breakpoint::DoubleExtraLarge))->toBeTrue(); }); it('can set and get the grayscale for a specific breakpoint', function () { @@ -644,11 +651,11 @@ expect($imageContext->getGrayscaleByBreakpoint()) ->toHaveCount(count(Breakpoint::cases())) - ->and($imageContext->getGrayscaleForBreakpoint(Breakpoint::Small))->toBeTrue() - ->and($imageContext->getGrayscaleForBreakpoint(Breakpoint::Medium))->toBeFalse() - ->and($imageContext->getGrayscaleForBreakpoint(Breakpoint::Large))->toBeFalse() - ->and($imageContext->getGrayscaleForBreakpoint(Breakpoint::ExtraLarge))->toBeFalse() - ->and($imageContext->getGrayscaleForBreakpoint(Breakpoint::ExtraExtraLarge))->toBeFalse(); + ->and($imageContext->getGrayscale(Breakpoint::Small))->toBeTrue() + ->and($imageContext->getGrayscale(Breakpoint::Medium))->toBeFalse() + ->and($imageContext->getGrayscale(Breakpoint::Large))->toBeFalse() + ->and($imageContext->getGrayscale(Breakpoint::ExtraLarge))->toBeFalse() + ->and($imageContext->getGrayscale(Breakpoint::DoubleExtraLarge))->toBeFalse(); }); it('can set and get the grayscale for all breakpoints after and including a specific breakpoint', function () { @@ -658,11 +665,11 @@ expect($imageContext->getGrayscaleByBreakpoint()) ->toHaveCount(count(Breakpoint::cases())) - ->and($imageContext->getGrayscaleForBreakpoint(Breakpoint::Small))->toBeFalse() - ->and($imageContext->getGrayscaleForBreakpoint(Breakpoint::Medium))->toBeFalse() - ->and($imageContext->getGrayscaleForBreakpoint(Breakpoint::Large))->toBeTrue() - ->and($imageContext->getGrayscaleForBreakpoint(Breakpoint::ExtraLarge))->toBeTrue() - ->and($imageContext->getGrayscaleForBreakpoint(Breakpoint::ExtraExtraLarge))->toBeTrue(); + ->and($imageContext->getGrayscale(Breakpoint::Small))->toBeFalse() + ->and($imageContext->getGrayscale(Breakpoint::Medium))->toBeFalse() + ->and($imageContext->getGrayscale(Breakpoint::Large))->toBeTrue() + ->and($imageContext->getGrayscale(Breakpoint::ExtraLarge))->toBeTrue() + ->and($imageContext->getGrayscale(Breakpoint::DoubleExtraLarge))->toBeTrue(); }); it('can set and get the grayscale for all breakpoints before and including a specific breakpoint', function () { @@ -672,11 +679,11 @@ expect($imageContext->getGrayscaleByBreakpoint()) ->toHaveCount(count(Breakpoint::cases())) - ->and($imageContext->getGrayscaleForBreakpoint(Breakpoint::Small))->toBeTrue() - ->and($imageContext->getGrayscaleForBreakpoint(Breakpoint::Medium))->toBeTrue() - ->and($imageContext->getGrayscaleForBreakpoint(Breakpoint::Large))->toBeTrue() - ->and($imageContext->getGrayscaleForBreakpoint(Breakpoint::ExtraLarge))->toBeFalse() - ->and($imageContext->getGrayscaleForBreakpoint(Breakpoint::ExtraExtraLarge))->toBeFalse(); + ->and($imageContext->getGrayscale(Breakpoint::Small))->toBeTrue() + ->and($imageContext->getGrayscale(Breakpoint::Medium))->toBeTrue() + ->and($imageContext->getGrayscale(Breakpoint::Large))->toBeTrue() + ->and($imageContext->getGrayscale(Breakpoint::ExtraLarge))->toBeFalse() + ->and($imageContext->getGrayscale(Breakpoint::DoubleExtraLarge))->toBeFalse(); }); it('can set and get the grayscale for all breakpoints between 2 breakpoints', function () { @@ -686,11 +693,11 @@ expect($imageContext->getGrayscaleByBreakpoint()) ->toHaveCount(count(Breakpoint::cases())) - ->and($imageContext->getGrayscaleForBreakpoint(Breakpoint::Small))->toBeFalse() - ->and($imageContext->getGrayscaleForBreakpoint(Breakpoint::Medium))->toBeTrue() - ->and($imageContext->getGrayscaleForBreakpoint(Breakpoint::Large))->toBeTrue() - ->and($imageContext->getGrayscaleForBreakpoint(Breakpoint::ExtraLarge))->toBeTrue() - ->and($imageContext->getGrayscaleForBreakpoint(Breakpoint::ExtraExtraLarge))->toBeFalse(); + ->and($imageContext->getGrayscale(Breakpoint::Small))->toBeFalse() + ->and($imageContext->getGrayscale(Breakpoint::Medium))->toBeTrue() + ->and($imageContext->getGrayscale(Breakpoint::Large))->toBeTrue() + ->and($imageContext->getGrayscale(Breakpoint::ExtraLarge))->toBeTrue() + ->and($imageContext->getGrayscale(Breakpoint::DoubleExtraLarge))->toBeFalse(); }); it('throws an exception when grayscale for a breakpoint is not defined', function () { @@ -704,7 +711,7 @@ it('returns null if not defined', function () { $imageContext = ImageContext::make('thumbnail'); - expect($imageContext->getGrayscaleForBreakpoint(Breakpoint::Small))->toBeNull(); + expect($imageContext->getGrayscale(Breakpoint::Small))->toBeNull(); }); test('grayscale accepts only boolean values for each breakpoint', function () { @@ -733,16 +740,16 @@ Breakpoint::Medium->value => false, Breakpoint::Large->value => true, Breakpoint::ExtraLarge->value => false, - Breakpoint::ExtraExtraLarge->value => true, + Breakpoint::DoubleExtraLarge->value => true, ]); expect($imageContext->getSepiaByBreakpoint()) ->toHaveCount(count(Breakpoint::cases())) - ->and($imageContext->getSepiaForBreakpoint(Breakpoint::Small))->toBeTrue() - ->and($imageContext->getSepiaForBreakpoint(Breakpoint::Medium))->toBeFalse() - ->and($imageContext->getSepiaForBreakpoint(Breakpoint::Large))->toBeTrue() - ->and($imageContext->getSepiaForBreakpoint(Breakpoint::ExtraLarge))->toBeFalse() - ->and($imageContext->getSepiaForBreakpoint(Breakpoint::ExtraExtraLarge))->toBeTrue(); + ->and($imageContext->getSepia(Breakpoint::Small))->toBeTrue() + ->and($imageContext->getSepia(Breakpoint::Medium))->toBeFalse() + ->and($imageContext->getSepia(Breakpoint::Large))->toBeTrue() + ->and($imageContext->getSepia(Breakpoint::ExtraLarge))->toBeFalse() + ->and($imageContext->getSepia(Breakpoint::DoubleExtraLarge))->toBeTrue(); }); it('can set and get the sepia for a specific breakpoint', function () { @@ -752,11 +759,11 @@ expect($imageContext->getSepiaByBreakpoint()) ->toHaveCount(count(Breakpoint::cases())) - ->and($imageContext->getSepiaForBreakpoint(Breakpoint::Small))->toBeTrue() - ->and($imageContext->getSepiaForBreakpoint(Breakpoint::Medium))->toBeFalse() - ->and($imageContext->getSepiaForBreakpoint(Breakpoint::Large))->toBeFalse() - ->and($imageContext->getSepiaForBreakpoint(Breakpoint::ExtraLarge))->toBeFalse() - ->and($imageContext->getSepiaForBreakpoint(Breakpoint::ExtraExtraLarge))->toBeFalse(); + ->and($imageContext->getSepia(Breakpoint::Small))->toBeTrue() + ->and($imageContext->getSepia(Breakpoint::Medium))->toBeFalse() + ->and($imageContext->getSepia(Breakpoint::Large))->toBeFalse() + ->and($imageContext->getSepia(Breakpoint::ExtraLarge))->toBeFalse() + ->and($imageContext->getSepia(Breakpoint::DoubleExtraLarge))->toBeFalse(); }); it('can set and get the sepia for all breakpoints after and including a specific breakpoint', function () { @@ -766,11 +773,11 @@ expect($imageContext->getSepiaByBreakpoint()) ->toHaveCount(count(Breakpoint::cases())) - ->and($imageContext->getSepiaForBreakpoint(Breakpoint::Small))->toBeFalse() - ->and($imageContext->getSepiaForBreakpoint(Breakpoint::Medium))->toBeFalse() - ->and($imageContext->getSepiaForBreakpoint(Breakpoint::Large))->toBeTrue() - ->and($imageContext->getSepiaForBreakpoint(Breakpoint::ExtraLarge))->toBeTrue() - ->and($imageContext->getSepiaForBreakpoint(Breakpoint::ExtraExtraLarge))->toBeTrue(); + ->and($imageContext->getSepia(Breakpoint::Small))->toBeFalse() + ->and($imageContext->getSepia(Breakpoint::Medium))->toBeFalse() + ->and($imageContext->getSepia(Breakpoint::Large))->toBeTrue() + ->and($imageContext->getSepia(Breakpoint::ExtraLarge))->toBeTrue() + ->and($imageContext->getSepia(Breakpoint::DoubleExtraLarge))->toBeTrue(); }); it('can set and get the sepia for all breakpoints before and including a specific breakpoint', function () { @@ -780,11 +787,11 @@ expect($imageContext->getSepiaByBreakpoint()) ->toHaveCount(count(Breakpoint::cases())) - ->and($imageContext->getSepiaForBreakpoint(Breakpoint::Small))->toBeTrue() - ->and($imageContext->getSepiaForBreakpoint(Breakpoint::Medium))->toBeTrue() - ->and($imageContext->getSepiaForBreakpoint(Breakpoint::Large))->toBeTrue() - ->and($imageContext->getSepiaForBreakpoint(Breakpoint::ExtraLarge))->toBeFalse() - ->and($imageContext->getSepiaForBreakpoint(Breakpoint::ExtraExtraLarge))->toBeFalse(); + ->and($imageContext->getSepia(Breakpoint::Small))->toBeTrue() + ->and($imageContext->getSepia(Breakpoint::Medium))->toBeTrue() + ->and($imageContext->getSepia(Breakpoint::Large))->toBeTrue() + ->and($imageContext->getSepia(Breakpoint::ExtraLarge))->toBeFalse() + ->and($imageContext->getSepia(Breakpoint::DoubleExtraLarge))->toBeFalse(); }); it('can set and get the sepia for all breakpoints between 2 breakpoints', function () { @@ -794,11 +801,11 @@ expect($imageContext->getSepiaByBreakpoint()) ->toHaveCount(count(Breakpoint::cases())) - ->and($imageContext->getSepiaForBreakpoint(Breakpoint::Small))->toBeFalse() - ->and($imageContext->getSepiaForBreakpoint(Breakpoint::Medium))->toBeTrue() - ->and($imageContext->getSepiaForBreakpoint(Breakpoint::Large))->toBeTrue() - ->and($imageContext->getSepiaForBreakpoint(Breakpoint::ExtraLarge))->toBeTrue() - ->and($imageContext->getSepiaForBreakpoint(Breakpoint::ExtraExtraLarge))->toBeFalse(); + ->and($imageContext->getSepia(Breakpoint::Small))->toBeFalse() + ->and($imageContext->getSepia(Breakpoint::Medium))->toBeTrue() + ->and($imageContext->getSepia(Breakpoint::Large))->toBeTrue() + ->and($imageContext->getSepia(Breakpoint::ExtraLarge))->toBeTrue() + ->and($imageContext->getSepia(Breakpoint::DoubleExtraLarge))->toBeFalse(); }); it('throws an exception when sepia for a breakpoint is not defined', function () { @@ -812,7 +819,7 @@ it('returns null if not defined', function () { $imageContext = ImageContext::make('thumbnail'); - expect($imageContext->getSepiaForBreakpoint(Breakpoint::Small))->toBeNull(); + expect($imageContext->getSepia(Breakpoint::Small))->toBeNull(); }); test('sepia accepts only boolean values for each breakpoint', function () { @@ -881,3 +888,23 @@ ->toBeFalse(); }); }); + +describe('optional breakpoints', function () { + it('throws exception when trying to set array values for context that does not use breakpoints', function () { + $context = ImageContext::make('email') + ->useBreakpoints(false); + + expect(fn () => $context->aspectRatio(['sm' => AspectRatio::make(16, 9)])) + ->toThrow(InvalidArgumentException::class, 'Aspect ratio must be an instance of AspectRatio when breakpoints are disabled'); + }); + + it('can set single values for context that does not use breakpoints', function () { + $context = ImageContext::make('email') + ->useBreakpoints(false) + ->aspectRatio(AspectRatio::make(16, 9)); + + expect($context->getAspectRatio())->not->toBeNull(); + expect($context->getAspectRatio()->horizontal)->toBe(16); + expect($context->getAspectRatio()->vertical)->toBe(9); + }); +}); diff --git a/tests/Unit/Enums/BreakpointTest.php b/tests/Unit/Enums/BreakpointTest.php index 7b22be2..1833ca1 100644 --- a/tests/Unit/Enums/BreakpointTest.php +++ b/tests/Unit/Enums/BreakpointTest.php @@ -36,7 +36,9 @@ ->with('breakpoints'); test('each breakpoint has a maximum width except the last one', function (Breakpoint $breakpoint) { - if ($breakpoint === array_last(Breakpoint::sortedCases())) { + $breakpoints = Breakpoint::sortedCases(); + + if ($breakpoint === $breakpoints[array_key_last($breakpoints)]) { expect($breakpoint->getMaxWidth()) ->toBeNull(); } else { diff --git a/tests/Unit/ImageLibraryTest.php b/tests/Unit/ImageLibraryTest.php index d67bfd5..c3ee09d 100644 --- a/tests/Unit/ImageLibraryTest.php +++ b/tests/Unit/ImageLibraryTest.php @@ -123,7 +123,7 @@ expect($fetchedContext) ->toBeInstanceOf(ImageContext::class) - ->and($fetchedContext?->getKey()) + ->and($fetchedContext->getKey()) ->toEqual('context-single'); }); diff --git a/tests/Unit/Jobs/GenerateImageVersionJobTest.php b/tests/Unit/Jobs/GenerateImageVersionJobTest.php index 8d61d29..22e6513 100644 --- a/tests/Unit/Jobs/GenerateImageVersionJobTest.php +++ b/tests/Unit/Jobs/GenerateImageVersionJobTest.php @@ -5,6 +5,7 @@ use Illuminate\Http\UploadedFile; use Illuminate\Support\Facades\Config; use Illuminate\Support\Facades\Storage; +use Outerweb\ImageLibrary\Entities\AspectRatio; use Outerweb\ImageLibrary\Entities\ImageContext; use Outerweb\ImageLibrary\Enums\Breakpoint; use Outerweb\ImageLibrary\Facades\ImageLibrary; @@ -107,12 +108,45 @@ ->assertExists($image->getRelativePathForBreakpoint($breakpoint)); }); +it('can generate an image if the x and y crop coordinates are set', function () { + $user = User::factory() + ->create(); + + $file = UploadedFile::fake()->image('example-image.jpg', 1000, 1000); + + $sourceImage = SourceImage::upload($file); + + $image = Image::factory() + ->forModel($user) + ->create([ + 'source_image_id' => $sourceImage->id, + 'crop_data' => [ + Breakpoint::Small->value => [ + 'width' => 500, + 'height' => 500, + 'x' => 100, + 'y' => 100, + ], + ], + ]); + + $breakpoint = Breakpoint::Small; + + $job = new GenerateImageVersionJob($image->id, $breakpoint); + + $job->handle(); + + Storage::disk($image->disk) + ->assertExists($image->getRelativePathForBreakpoint($breakpoint)); +}); + it('can apply blur', function () { $user = User::factory() ->create(); ImageLibrary::registerImageContext( ImageContext::make('blur-test-context') + ->aspectRatio(AspectRatio::make(1, 1)) ->blur(10) ); @@ -143,6 +177,7 @@ ImageLibrary::registerImageContext( ImageContext::make('greyscale-test-context') + ->aspectRatio(AspectRatio::make(1, 1)) ->greyscale(true) ); @@ -173,6 +208,7 @@ ImageLibrary::registerImageContext( ImageContext::make('sepia-test-context') + ->aspectRatio(AspectRatio::make(1, 1)) ->sepia(true) ); diff --git a/tests/Unit/Jobs/GenerateResponsiveImageVersionsJobTest.php b/tests/Unit/Jobs/GenerateResponsiveImageVersionsJobTest.php index 9b80f9a..d2119fc 100644 --- a/tests/Unit/Jobs/GenerateResponsiveImageVersionsJobTest.php +++ b/tests/Unit/Jobs/GenerateResponsiveImageVersionsJobTest.php @@ -69,14 +69,14 @@ 'context' => 'context-single', ]); - new GenerateImageVersionJob($image->id, Breakpoint::ExtraExtraLarge)->handle(); + new GenerateImageVersionJob($image->id, Breakpoint::DoubleExtraLarge)->handle(); - new GenerateResponsiveImageVersionsJob($image->id, Breakpoint::ExtraExtraLarge) + new GenerateResponsiveImageVersionsJob($image->id, Breakpoint::DoubleExtraLarge) ->handle(); - expect($image->getResponsiveRelativePathsForBreakpoint(Breakpoint::ExtraExtraLarge)) + expect($image->getResponsiveRelativePathsForBreakpoint(Breakpoint::DoubleExtraLarge)) ->toBeInstanceOf(Collection::class); - expect($image->getResponsiveRelativePathsForBreakpoint(Breakpoint::ExtraExtraLarge)->count()) + expect($image->getResponsiveRelativePathsForBreakpoint(Breakpoint::DoubleExtraLarge)->count()) ->toBeGreaterThan(0); }); diff --git a/tests/Unit/Models/ImageTest.php b/tests/Unit/Models/ImageTest.php index feef79e..a3bdfee 100644 --- a/tests/Unit/Models/ImageTest.php +++ b/tests/Unit/Models/ImageTest.php @@ -8,7 +8,9 @@ use Illuminate\Http\UploadedFile; use Illuminate\Support\Collection; use Illuminate\Support\Facades\Bus; +use Illuminate\Support\Facades\Config; use Illuminate\Support\Facades\DB; +use Illuminate\Support\Facades\Queue; use Illuminate\Support\Facades\Storage; use Illuminate\Support\Str; use Outerweb\ImageLibrary\Entities\AspectRatio; @@ -88,7 +90,8 @@ $image = Image::factory() ->forModel($user) ->create([ - 'crop_data' => CropData::make(10, 10, 100, 100), + 'context' => 'thumbnail', + 'crop_data' => CropData::make(10, 10, 100, 100, 0, 1, 1), ]) ->refresh(); @@ -227,7 +230,7 @@ ->create(); expect($image->getRelativeBasePath()) - ->toBe("media-library/{$image->sourceImage->uuid}/{$image->uuid}"); + ->toBe("image-library/{$image->sourceImage->uuid}/{$image->uuid}"); }); }); @@ -241,7 +244,7 @@ ->create(); expect($image->getAbsoluteBasePath()) - ->toBe(Storage::disk($image->disk)->path("media-library/{$image->sourceImage->uuid}/{$image->uuid}")); + ->toBe(Storage::disk($image->disk)->path("image-library/{$image->sourceImage->uuid}/{$image->uuid}")); }); }); @@ -255,10 +258,10 @@ ->create(); expect($image->getRelativePathForBreakpoint(Breakpoint::Small)) - ->toBe("media-library/{$image->sourceImage->uuid}/{$image->uuid}/sm.{$image->sourceImage->extension}"); + ->toBe("image-library/{$image->sourceImage->uuid}/{$image->uuid}/sm.{$image->sourceImage->extension}"); expect($image->getRelativePathForBreakpoint(Breakpoint::Medium, 'png')) - ->toBe("media-library/{$image->sourceImage->uuid}/{$image->uuid}/md.png"); + ->toBe("image-library/{$image->sourceImage->uuid}/{$image->uuid}/md.png"); }); }); @@ -272,10 +275,10 @@ ->create(); expect($image->getAbsolutePathForBreakpoint(Breakpoint::Small)) - ->toBe(Storage::disk($image->disk)->path("media-library/{$image->sourceImage->uuid}/{$image->uuid}/sm.{$image->sourceImage->extension}")); + ->toBe(Storage::disk($image->disk)->path("image-library/{$image->sourceImage->uuid}/{$image->uuid}/sm.{$image->sourceImage->extension}")); expect($image->getAbsolutePathForBreakpoint(Breakpoint::Medium, 'png')) - ->toBe(Storage::disk($image->disk)->path("media-library/{$image->sourceImage->uuid}/{$image->uuid}/md.png")); + ->toBe(Storage::disk($image->disk)->path("image-library/{$image->sourceImage->uuid}/{$image->uuid}/md.png")); }); }); @@ -294,7 +297,8 @@ 'source_image_id' => $sourceImage->id, ]); - GenerateImageVersionJob::dispatchSync($image, Breakpoint::Small); + $job = new GenerateImageVersionJob($image, Breakpoint::Small); + $job->handle(); $breakpointImage = $image->getForBreakpoint(Breakpoint::Small); @@ -322,7 +326,8 @@ expect($image->existsForBreakpoint(Breakpoint::Small)) ->toBeFalse(); - GenerateImageVersionJob::dispatchSync($image, Breakpoint::Small); + $job = new GenerateImageVersionJob($image, Breakpoint::Small); + $job->handle(); expect($image->existsForBreakpoint(Breakpoint::Small)) ->toBeTrue(); @@ -347,7 +352,8 @@ expect($image->missingForBreakpoint(Breakpoint::Small)) ->toBeTrue(); - GenerateImageVersionJob::dispatchSync($image, Breakpoint::Small); + $job = new GenerateImageVersionJob($image, Breakpoint::Small); + $job->handle(); expect($image->missingForBreakpoint(Breakpoint::Small)) ->toBeFalse(); @@ -369,7 +375,8 @@ 'source_image_id' => $sourceImage->id, ]); - GenerateImageVersionJob::dispatchSync($image, Breakpoint::Small); + $job = new GenerateImageVersionJob($image, Breakpoint::Small); + $job->handle(); $response = $image->downloadForBreakpoint(Breakpoint::Small); @@ -432,8 +439,8 @@ }); }); - describe('temporaryUrlForBreakpoint', function (): void { - it('can return a temporary URL of the source image file', function (): void { + describe('urlForRelativePath', function (): void { + it('returns a URL for the given relative path', function (): void { $user = User::factory() ->create(); @@ -447,9 +454,128 @@ 'source_image_id' => $sourceImage->id, ]); - GenerateImageVersionJob::dispatchSync($image, Breakpoint::Small); + $relativePath = "image-library/{$image->sourceImage->uuid}/{$image->uuid}/test-file.jpg"; + + $url = $image->urlForRelativePath($relativePath); + + expect($url) + ->toBeString() + ->not->toBeEmpty() + ->toContain($relativePath); + }); + + it('returns a temporary URL if configured to use temporary URLs for the disk', function (): void { + $user = User::factory() + ->create(); + + $file = UploadedFile::fake()->image('example-image.jpg', 10, 10); + + $sourceImage = SourceImage::upload($file); + + $image = Image::factory() + ->forModel($user) + ->create([ + 'source_image_id' => $sourceImage->id, + ]); + + ImageLibrary::partialMock(); + + ImageLibrary::shouldReceive('shouldUseTemporaryUrlsForDisk') + ->with($image->disk) + ->andReturn(true); + + ImageLibrary::shouldReceive('getTemporaryUrlExpirationMinutesForDisk') + ->with($image->disk) + ->andReturn(60); + + $relativePath = "image-library/{$image->sourceImage->uuid}/{$image->uuid}/test-file.jpg"; + + $url = $image->urlForRelativePath($relativePath); + + expect($url) + ->toBeString() + ->not->toBeEmpty(); + }); + }); + + describe('temporaryUrlForRelativePath', function (): void { + it('returns a temporary URL for the given relative path', function (): void { + $user = User::factory() + ->create(); + + $file = UploadedFile::fake()->image('example-image.jpg', 10, 10); + + $sourceImage = SourceImage::upload($file); + + $image = Image::factory() + ->forModel($user) + ->create([ + 'source_image_id' => $sourceImage->id, + ]); + + ImageLibrary::partialMock(); + + ImageLibrary::shouldReceive('getTemporaryUrlExpirationMinutesForDisk') + ->with($image->disk) + ->andReturn(60); + + $relativePath = "image-library/{$image->sourceImage->uuid}/{$image->uuid}/test-file.jpg"; + + $url = $image->temporaryUrlForRelativePath($relativePath); + + expect($url) + ->toBeString() + ->not->toBeEmpty(); + }); + + it('accepts custom expiration time', function (): void { + $user = User::factory() + ->create(); + + $file = UploadedFile::fake()->image('example-image.jpg', 10, 10); + + $sourceImage = SourceImage::upload($file); + + $image = Image::factory() + ->forModel($user) + ->create([ + 'source_image_id' => $sourceImage->id, + ]); + + $relativePath = "image-library/{$image->sourceImage->uuid}/{$image->uuid}/test-file.jpg"; + $customExpiration = now()->addHours(2); + + $url = $image->temporaryUrlForRelativePath($relativePath, $customExpiration); + + expect($url) + ->toBeString() + ->not->toBeEmpty(); + }); + + it('accepts custom options for temporary URL generation', function (): void { + $user = User::factory() + ->create(); + + $file = UploadedFile::fake()->image('example-image.jpg', 10, 10); + + $sourceImage = SourceImage::upload($file); + + $image = Image::factory() + ->forModel($user) + ->create([ + 'source_image_id' => $sourceImage->id, + ]); + + ImageLibrary::partialMock(); + + ImageLibrary::shouldReceive('getTemporaryUrlExpirationMinutesForDisk') + ->with($image->disk) + ->andReturn(60); + + $relativePath = "image-library/{$image->sourceImage->uuid}/{$image->uuid}/test-file.jpg"; + $options = ['ResponseContentType' => 'image/jpeg']; - $url = $image->temporaryUrlForBreakpoint(Breakpoint::Small, now()->addMinutes(30)); + $url = $image->temporaryUrlForRelativePath($relativePath, null, $options); expect($url) ->toBeString() @@ -620,3 +746,104 @@ }); describe('scopes', function (): void {}); + +describe('optional breakpoints', function (): void { + it('can create an image with a context that does not use breakpoints', function () { + $sourceImage = SourceImage::factory()->create(); + $user = User::factory()->create(); + + $context = ImageContext::make('email') + ->useBreakpoints(false); + + ImageLibrary::registerImageContext($context); + + $image = Image::create([ + 'source_image_id' => $sourceImage->id, + 'model_type' => $user->getMorphClass(), + 'model_id' => $user->id, + 'context' => $context, + 'disk' => 'public', + ]); + + expect($image->context->getUseBreakpoints())->toBeFalse(); + }); + + it('dispatches GenerateImageVersionJob with null breakpoint when context does not use breakpoints', function () { + Queue::fake(); + Bus::fake(); + + $sourceImage = SourceImage::factory()->create(); + $user = User::factory()->create(); + + $context = ImageContext::make('email') + ->useBreakpoints(false); + + ImageLibrary::registerImageContext($context); + + $image = Image::create([ + 'source_image_id' => $sourceImage->id, + 'model_type' => $user->getMorphClass(), + 'model_id' => $user->id, + 'context' => $context, + 'disk' => 'public', + ]); + + Bus::assertBatched(function ($batch) use ($image) { + return $batch->jobs->contains(function ($job) use ($image) { + return $job instanceof GenerateImageVersionJob + && $job->imageId === $image->id + && is_null($job->breakpoint); + }); + }); + }); + + it('dispatches GenerateImageVersionJob with null breakpoint when global config disables breakpoints', function () { + Queue::fake(); + Bus::fake(); + Config::set('image-library.use_breakpoints', false); + + $sourceImage = SourceImage::factory()->create(); + $user = User::factory()->create(); + + // Create a context that inherits global config (no explicit useBreakpoints call) + $context = ImageContext::make('global-disabled'); + ImageLibrary::registerImageContext($context); + + $image = Image::create([ + 'source_image_id' => $sourceImage->id, + 'model_type' => $user->getMorphClass(), + 'model_id' => $user->id, + 'context' => $context, + 'disk' => 'public', + ]); + + Bus::assertBatched(function ($batch) use ($image) { + return $batch->jobs->contains(function ($job) use ($image) { + return $job instanceof GenerateImageVersionJob + && $job->imageId === $image->id + && is_null($job->breakpoint); + }); + }); + }); + + it('can get image paths without specifying breakpoint when breakpoints are disabled', function () { + $sourceImage = SourceImage::factory()->create(); + $user = User::factory()->create(); + + $context = ImageContext::make('email') + ->useBreakpoints(false); + + ImageLibrary::registerImageContext($context); + + $image = Image::create([ + 'source_image_id' => $sourceImage->id, + 'model_type' => $user->getMorphClass(), + 'model_id' => $user->id, + 'context' => $context, + 'disk' => 'public', + ]); + + expect($image->getRelativePathForBreakpoint())->toBe($image->getRelativeBasePath().'/default.'.$sourceImage->extension); + expect($image->getRelativePathForBreakpoint(null, 'webp'))->toBe($image->getRelativeBasePath().'/default.webp'); + }); +}); diff --git a/tests/Unit/Models/SourceImageTest.php b/tests/Unit/Models/SourceImageTest.php index 76827b7..5febb0b 100644 --- a/tests/Unit/Models/SourceImageTest.php +++ b/tests/Unit/Models/SourceImageTest.php @@ -82,7 +82,7 @@ ->create(); expect($image->getRelativeBasePath()) - ->toEqual('media-library/'.$image->uuid); + ->toEqual('image-library/'.$image->uuid); }); it('can return the absolute base path', function (): void { @@ -90,7 +90,7 @@ ->create(); expect($image->getAbsoluteBasePath()) - ->toEqual(Storage::disk($image->disk)->path('media-library/'.$image->uuid)); + ->toEqual(Storage::disk($image->disk)->path('image-library/'.$image->uuid)); }); it('can return the relative path', function (): void { @@ -101,7 +101,7 @@ ]); expect($image->getRelativePath()) - ->toEqual('media-library/'.$image->uuid.'/original.png'); + ->toEqual('image-library/'.$image->uuid.'/original.png'); }); it('can return the absolute path', function (): void { @@ -112,7 +112,7 @@ ]); expect($image->getAbsolutePath()) - ->toEqual(Storage::disk($image->disk)->path('media-library/'.$image->uuid.'/original.png')); + ->toEqual(Storage::disk($image->disk)->path('image-library/'.$image->uuid.'/original.png')); }); describe('upload', function (): void { @@ -154,7 +154,7 @@ try { SourceImage::upload($file); } catch (Throwable $e) { - expect(Storage::disk('public')->allFiles('media-library')) + expect(Storage::disk('public')->allFiles('image-library')) ->toBeEmpty(); throw $e; @@ -173,7 +173,7 @@ try { SourceImage::upload($file); } catch (Throwable $e) { - expect(Storage::disk('public')->allFiles('media-library')) + expect(Storage::disk('public')->allFiles('image-library')) ->toBeEmpty(); throw $e;