Merge pull request #174 from trypostit/feat/avatar-logo-crop-native
Crop the avatar/logo before upload with a native selection cropper
This commit is contained in:
commit
d6c40d425c
32 changed files with 2510 additions and 100 deletions
65
.github/actions/setup-laravel/action.yml
vendored
Normal file
65
.github/actions/setup-laravel/action.yml
vendored
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
name: 'Setup Laravel test environment'
|
||||
description: 'Set up PHP, Composer, optional Node, the app key, database, and Passport keys for a test job.'
|
||||
|
||||
inputs:
|
||||
node:
|
||||
description: 'Also set up Node.js and install npm dependencies.'
|
||||
default: 'false'
|
||||
db-port:
|
||||
description: 'Host port mapped to the Postgres service.'
|
||||
required: true
|
||||
redis-port:
|
||||
description: 'Host port mapped to the Redis service.'
|
||||
required: true
|
||||
|
||||
runs:
|
||||
using: 'composite'
|
||||
steps:
|
||||
- name: Setup PHP
|
||||
uses: shivammathur/setup-php@v2
|
||||
with:
|
||||
php-version: '8.4'
|
||||
extensions: dom, curl, libxml, mbstring, zip, pcntl, pdo, pdo_pgsql, bcmath, intl, gd, redis
|
||||
coverage: none
|
||||
|
||||
- name: Setup Node.js
|
||||
if: inputs.node == 'true'
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: '22'
|
||||
cache: 'npm'
|
||||
|
||||
- name: Cache Composer dependencies
|
||||
uses: actions/cache@v6
|
||||
with:
|
||||
path: vendor
|
||||
key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.lock') }}
|
||||
restore-keys: ${{ runner.os }}-composer-
|
||||
|
||||
- name: Prepare environment
|
||||
shell: bash
|
||||
run: cp .env.ci .env
|
||||
|
||||
- name: Install Composer dependencies
|
||||
shell: bash
|
||||
run: composer install --no-interaction --prefer-dist --optimize-autoloader
|
||||
|
||||
- name: Install npm dependencies
|
||||
if: inputs.node == 'true'
|
||||
shell: bash
|
||||
run: npm ci
|
||||
|
||||
- name: Generate application key
|
||||
shell: bash
|
||||
run: php artisan key:generate
|
||||
|
||||
- name: Run migrations
|
||||
shell: bash
|
||||
env:
|
||||
DB_PORT: ${{ inputs.db-port }}
|
||||
REDIS_PORT: ${{ inputs.redis-port }}
|
||||
run: php artisan migrate --force
|
||||
|
||||
- name: Generate Passport keys
|
||||
shell: bash
|
||||
run: php artisan passport:keys --force
|
||||
2
.github/workflows/lint.yml
vendored
2
.github/workflows/lint.yml
vendored
|
|
@ -17,7 +17,7 @@ jobs:
|
|||
quality:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- name: Setup PHP
|
||||
uses: shivammathur/setup-php@v2
|
||||
|
|
|
|||
6
.github/workflows/release-docker.yml
vendored
6
.github/workflows/release-docker.yml
vendored
|
|
@ -36,7 +36,7 @@ jobs:
|
|||
platform: ${{ matrix.platform }}
|
||||
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Docker metadata (labels)
|
||||
id: meta
|
||||
|
|
@ -76,7 +76,7 @@ jobs:
|
|||
touch "${{ runner.temp }}/digests/${digest#sha256:}"
|
||||
|
||||
- name: Upload digest
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: digests-${{ env.PLATFORM_PAIR }}
|
||||
path: ${{ runner.temp }}/digests/*
|
||||
|
|
@ -92,7 +92,7 @@ jobs:
|
|||
packages: write
|
||||
steps:
|
||||
- name: Download digests
|
||||
uses: actions/download-artifact@v4
|
||||
uses: actions/download-artifact@v7
|
||||
with:
|
||||
path: ${{ runner.temp }}/digests
|
||||
pattern: digests-*
|
||||
|
|
|
|||
132
.github/workflows/tests.yml
vendored
132
.github/workflows/tests.yml
vendored
|
|
@ -6,18 +6,19 @@ on:
|
|||
pull_request:
|
||||
branches: [main, develop]
|
||||
|
||||
env:
|
||||
DB_CONNECTION: pgsql
|
||||
DB_DATABASE: trypost_test
|
||||
DB_USERNAME: postgres
|
||||
DB_PASSWORD: password
|
||||
BROADCAST_CONNECTION: "null"
|
||||
CACHE_STORE: array
|
||||
QUEUE_CONNECTION: sync
|
||||
SESSION_DRIVER: array
|
||||
|
||||
jobs:
|
||||
tests:
|
||||
backend:
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
DB_CONNECTION: pgsql
|
||||
DB_DATABASE: trypost_test
|
||||
DB_USERNAME: postgres
|
||||
DB_PASSWORD: password
|
||||
BROADCAST_CONNECTION: "null"
|
||||
CACHE_STORE: array
|
||||
QUEUE_CONNECTION: sync
|
||||
SESSION_DRIVER: array
|
||||
|
||||
services:
|
||||
postgres:
|
||||
|
|
@ -46,54 +47,89 @@ jobs:
|
|||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Setup PHP
|
||||
uses: shivammathur/setup-php@v2
|
||||
- name: Setup test environment
|
||||
uses: ./.github/actions/setup-laravel
|
||||
with:
|
||||
php-version: '8.4'
|
||||
extensions: dom, curl, libxml, mbstring, zip, pcntl, pdo, pdo_pgsql, bcmath, intl, gd, redis
|
||||
coverage: none
|
||||
db-port: ${{ job.services.postgres.ports['5432'] }}
|
||||
redis-port: ${{ job.services.redis.ports['6379'] }}
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
- name: Run backend tests
|
||||
env:
|
||||
DB_PORT: ${{ job.services.postgres.ports['5432'] }}
|
||||
REDIS_PORT: ${{ job.services.redis.ports['6379'] }}
|
||||
run: php artisan test --compact --parallel
|
||||
|
||||
e2e:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
shard: [1, 2]
|
||||
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:16
|
||||
env:
|
||||
POSTGRES_USER: postgres
|
||||
POSTGRES_PASSWORD: password
|
||||
POSTGRES_DB: trypost_test
|
||||
ports:
|
||||
- 5432/tcp
|
||||
options: >-
|
||||
--health-cmd="pg_isready"
|
||||
--health-interval=10s
|
||||
--health-timeout=5s
|
||||
--health-retries=3
|
||||
|
||||
redis:
|
||||
image: redis:7
|
||||
ports:
|
||||
- 6379/tcp
|
||||
options: >-
|
||||
--health-cmd="redis-cli ping"
|
||||
--health-interval=10s
|
||||
--health-timeout=5s
|
||||
--health-retries=3
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Setup test environment
|
||||
uses: ./.github/actions/setup-laravel
|
||||
with:
|
||||
node-version: '22'
|
||||
cache: 'npm'
|
||||
|
||||
- name: Cache Composer dependencies
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: vendor
|
||||
key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.lock') }}
|
||||
restore-keys: ${{ runner.os }}-composer-
|
||||
|
||||
- name: Prepare environment
|
||||
run: cp .env.ci .env
|
||||
|
||||
- name: Install Composer dependencies
|
||||
run: composer install --no-interaction --prefer-dist --optimize-autoloader
|
||||
|
||||
- name: Install npm dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Generate application key
|
||||
run: php artisan key:generate
|
||||
node: 'true'
|
||||
db-port: ${{ job.services.postgres.ports['5432'] }}
|
||||
redis-port: ${{ job.services.redis.ports['6379'] }}
|
||||
|
||||
- name: Build assets
|
||||
run: npm run build
|
||||
|
||||
- name: Run migrations
|
||||
run: php artisan migrate --force
|
||||
- name: Install Playwright browsers
|
||||
run: npx playwright install --with-deps chromium
|
||||
|
||||
- name: Run browser tests
|
||||
env:
|
||||
DB_PORT: ${{ job.services.postgres.ports['5432'] }}
|
||||
REDIS_PORT: ${{ job.services.redis.ports['6379'] }}
|
||||
run: php artisan test tests/Browser --compact --shard=${{ matrix.shard }}/2
|
||||
|
||||
- name: Generate Passport keys
|
||||
run: php artisan passport:keys --force
|
||||
- name: Upload Playwright artifacts
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: playwright-artifacts-${{ matrix.shard }}
|
||||
path: tests/Browser/Screenshots
|
||||
if-no-files-found: ignore
|
||||
retention-days: 7
|
||||
|
||||
- name: Run tests
|
||||
run: php artisan test --compact --parallel
|
||||
env:
|
||||
DB_PORT: ${{ job.services.postgres.ports['5432'] }}
|
||||
REDIS_PORT: ${{ job.services.redis.ports['6379'] }}
|
||||
e2e-gate:
|
||||
if: always()
|
||||
needs: e2e
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Require all e2e shards to pass
|
||||
run: '[ "${{ needs.e2e.result }}" = "success" ] || exit 1'
|
||||
|
|
|
|||
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -1,6 +1,7 @@
|
|||
/.phpunit.cache
|
||||
/bootstrap/ssr
|
||||
/node_modules
|
||||
/tests/Browser/Screenshots
|
||||
/public/build
|
||||
/lang/php_*.json
|
||||
/public/hot
|
||||
|
|
|
|||
|
|
@ -74,6 +74,7 @@
|
|||
"mockery/mockery": "^1.6",
|
||||
"nunomaduro/collision": "^8.6",
|
||||
"pestphp/pest": "^4.4",
|
||||
"pestphp/pest-plugin-browser": "^4.3",
|
||||
"pestphp/pest-plugin-laravel": "^4.1"
|
||||
},
|
||||
"autoload": {
|
||||
|
|
@ -120,6 +121,10 @@
|
|||
"@test:lint",
|
||||
"@php artisan test"
|
||||
],
|
||||
"test:all": [
|
||||
"@test",
|
||||
"@php artisan test tests/Browser"
|
||||
],
|
||||
"post-autoload-dump": [
|
||||
"Illuminate\\Foundation\\ComposerScripts::postAutoloadDump",
|
||||
"@php artisan package:discover --ansi"
|
||||
|
|
|
|||
1573
composer.lock
generated
1573
composer.lock
generated
File diff suppressed because it is too large
Load diff
|
|
@ -21,6 +21,12 @@
|
|||
'uploading' => 'جارٍ الرفع...',
|
||||
'remove' => 'إزالة الصورة',
|
||||
'hint' => 'موصى به: صورة مربعة، بحد أقصى 2 ميغابايت.',
|
||||
'crop_title' => 'قص الصورة',
|
||||
'crop_description' => 'حدّد المنطقة التي تريد الاحتفاظ بها.',
|
||||
'crop_hint' => 'اسحب للتحريك، أو زاوية لتغيير الحجم.',
|
||||
'crop_save' => 'حفظ',
|
||||
'crop_cancel' => 'إلغاء',
|
||||
'crop_error' => 'تعذّر تحميل هذه الصورة. جرّب ملفًا آخر.',
|
||||
],
|
||||
|
||||
'timezone' => [
|
||||
|
|
|
|||
|
|
@ -21,6 +21,12 @@
|
|||
'uploading' => 'Wird hochgeladen...',
|
||||
'remove' => 'Foto entfernen',
|
||||
'hint' => 'Empfohlen: quadratisches Bild, max. 2 MB.',
|
||||
'crop_title' => 'Bild zuschneiden',
|
||||
'crop_description' => 'Wählen Sie den Bereich aus, den Sie behalten möchten.',
|
||||
'crop_hint' => 'Zum Verschieben ziehen oder an einer Ecke die Größe ändern.',
|
||||
'crop_save' => 'Speichern',
|
||||
'crop_cancel' => 'Abbrechen',
|
||||
'crop_error' => 'Dieses Bild konnte nicht geladen werden. Versuchen Sie eine andere Datei.',
|
||||
],
|
||||
|
||||
'timezone' => [
|
||||
|
|
|
|||
|
|
@ -21,6 +21,12 @@
|
|||
'uploading' => 'Μεταφόρτωση...',
|
||||
'remove' => 'Αφαίρεση φωτογραφίας',
|
||||
'hint' => 'Συνιστάται: τετράγωνη εικόνα, έως 2 MB.',
|
||||
'crop_title' => 'Περικοπή εικόνας',
|
||||
'crop_description' => 'Επιλέξτε την περιοχή που θέλετε να κρατήσετε.',
|
||||
'crop_hint' => 'Σύρετε για μετακίνηση ή μια γωνία για αλλαγή μεγέθους.',
|
||||
'crop_save' => 'Αποθήκευση',
|
||||
'crop_cancel' => 'Άκυρο',
|
||||
'crop_error' => 'Δεν ήταν δυνατή η φόρτωση αυτής της εικόνας. Δοκιμάστε άλλο αρχείο.',
|
||||
],
|
||||
|
||||
'timezone' => [
|
||||
|
|
|
|||
|
|
@ -21,6 +21,12 @@
|
|||
'uploading' => 'Uploading...',
|
||||
'remove' => 'Remove photo',
|
||||
'hint' => 'Recommended: square image, max 2 MB.',
|
||||
'crop_title' => 'Crop image',
|
||||
'crop_description' => 'Select the area you want to keep.',
|
||||
'crop_hint' => 'Drag to move, or a corner to resize.',
|
||||
'crop_save' => 'Save',
|
||||
'crop_cancel' => 'Cancel',
|
||||
'crop_error' => 'Couldn\'t load this image. Try another file.',
|
||||
],
|
||||
|
||||
'timezone' => [
|
||||
|
|
|
|||
|
|
@ -21,6 +21,12 @@
|
|||
'uploading' => 'Subiendo...',
|
||||
'remove' => 'Eliminar foto',
|
||||
'hint' => 'Recomendado: imagen cuadrada, máximo 2 MB.',
|
||||
'crop_title' => 'Recortar imagen',
|
||||
'crop_description' => 'Selecciona el área que quieres conservar.',
|
||||
'crop_hint' => 'Arrastra para mover o una esquina para redimensionar.',
|
||||
'crop_save' => 'Guardar',
|
||||
'crop_cancel' => 'Cancelar',
|
||||
'crop_error' => 'No se pudo cargar esta imagen. Prueba con otro archivo.',
|
||||
],
|
||||
|
||||
'timezone' => [
|
||||
|
|
|
|||
|
|
@ -21,6 +21,12 @@
|
|||
'uploading' => 'Import en cours...',
|
||||
'remove' => 'Supprimer la photo',
|
||||
'hint' => 'Recommandé : image carrée, 2 Mo maximum.',
|
||||
'crop_title' => 'Recadrer l\'image',
|
||||
'crop_description' => 'Sélectionnez la zone à conserver.',
|
||||
'crop_hint' => 'Faites glisser pour déplacer, ou un coin pour redimensionner.',
|
||||
'crop_save' => 'Enregistrer',
|
||||
'crop_cancel' => 'Annuler',
|
||||
'crop_error' => 'Impossible de charger cette image. Essayez un autre fichier.',
|
||||
],
|
||||
|
||||
'timezone' => [
|
||||
|
|
|
|||
|
|
@ -21,6 +21,12 @@
|
|||
'uploading' => 'Caricamento in corso...',
|
||||
'remove' => 'Rimuovi foto',
|
||||
'hint' => 'Consigliato: immagine quadrata, max 2 MB.',
|
||||
'crop_title' => 'Ritaglia immagine',
|
||||
'crop_description' => 'Seleziona l\'area da mantenere.',
|
||||
'crop_hint' => 'Trascina per spostare o un angolo per ridimensionare.',
|
||||
'crop_save' => 'Salva',
|
||||
'crop_cancel' => 'Annulla',
|
||||
'crop_error' => 'Impossibile caricare questa immagine. Prova un altro file.',
|
||||
],
|
||||
|
||||
'timezone' => [
|
||||
|
|
|
|||
|
|
@ -21,6 +21,12 @@
|
|||
'uploading' => 'アップロード中...',
|
||||
'remove' => '写真を削除',
|
||||
'hint' => '推奨: 正方形の画像、最大 2 MB。',
|
||||
'crop_title' => '画像を切り抜く',
|
||||
'crop_description' => '残したい範囲を選択してください。',
|
||||
'crop_hint' => 'ドラッグで移動、角でサイズ変更できます。',
|
||||
'crop_save' => '保存',
|
||||
'crop_cancel' => 'キャンセル',
|
||||
'crop_error' => 'この画像を読み込めませんでした。別のファイルをお試しください。',
|
||||
],
|
||||
|
||||
'timezone' => [
|
||||
|
|
|
|||
|
|
@ -21,6 +21,12 @@
|
|||
'uploading' => '업로드 중...',
|
||||
'remove' => '사진 제거',
|
||||
'hint' => '권장: 정사각형 이미지, 최대 2MB.',
|
||||
'crop_title' => '이미지 자르기',
|
||||
'crop_description' => '남길 영역을 선택하세요.',
|
||||
'crop_hint' => '드래그하여 이동하거나 모서리로 크기를 조절하세요.',
|
||||
'crop_save' => '저장',
|
||||
'crop_cancel' => '취소',
|
||||
'crop_error' => '이 이미지를 불러올 수 없습니다. 다른 파일을 사용해 보세요.',
|
||||
],
|
||||
|
||||
'timezone' => [
|
||||
|
|
|
|||
|
|
@ -21,6 +21,12 @@
|
|||
'uploading' => 'Uploaden...',
|
||||
'remove' => 'Foto verwijderen',
|
||||
'hint' => 'Aanbevolen: vierkante afbeelding, max. 2 MB.',
|
||||
'crop_title' => 'Afbeelding bijsnijden',
|
||||
'crop_description' => 'Selecteer het gebied dat je wilt behouden.',
|
||||
'crop_hint' => 'Sleep om te verplaatsen of een hoek om te vergroten of verkleinen.',
|
||||
'crop_save' => 'Opslaan',
|
||||
'crop_cancel' => 'Annuleren',
|
||||
'crop_error' => 'Kan deze afbeelding niet laden. Probeer een ander bestand.',
|
||||
],
|
||||
|
||||
'timezone' => [
|
||||
|
|
|
|||
|
|
@ -21,6 +21,12 @@
|
|||
'uploading' => 'Przesyłanie...',
|
||||
'remove' => 'Usuń zdjęcie',
|
||||
'hint' => 'Zalecane: kwadratowe zdjęcie, maks. 2 MB.',
|
||||
'crop_title' => 'Przytnij obraz',
|
||||
'crop_description' => 'Zaznacz obszar, który chcesz zachować.',
|
||||
'crop_hint' => 'Przeciągnij, aby przesunąć, lub róg, aby zmienić rozmiar.',
|
||||
'crop_save' => 'Zapisz',
|
||||
'crop_cancel' => 'Anuluj',
|
||||
'crop_error' => 'Nie można załadować tego obrazu. Spróbuj innego pliku.',
|
||||
],
|
||||
|
||||
'timezone' => [
|
||||
|
|
|
|||
|
|
@ -21,6 +21,12 @@
|
|||
'uploading' => 'Enviando...',
|
||||
'remove' => 'Remover foto',
|
||||
'hint' => 'Recomendado: imagem quadrada, máximo 2 MB.',
|
||||
'crop_title' => 'Cortar imagem',
|
||||
'crop_description' => 'Selecione a área que deseja manter.',
|
||||
'crop_hint' => 'Arraste para mover ou um canto para redimensionar.',
|
||||
'crop_save' => 'Salvar',
|
||||
'crop_cancel' => 'Cancelar',
|
||||
'crop_error' => 'Não foi possível carregar esta imagem. Tente outro arquivo.',
|
||||
],
|
||||
|
||||
'timezone' => [
|
||||
|
|
|
|||
|
|
@ -21,6 +21,12 @@
|
|||
'uploading' => 'Загрузка...',
|
||||
'remove' => 'Удалить фото',
|
||||
'hint' => 'Рекомендуется: квадратное изображение, до 2 МБ.',
|
||||
'crop_title' => 'Обрезать изображение',
|
||||
'crop_description' => 'Выберите область, которую нужно оставить.',
|
||||
'crop_hint' => 'Перетащите, чтобы переместить, или угол — чтобы изменить размер.',
|
||||
'crop_save' => 'Сохранить',
|
||||
'crop_cancel' => 'Отмена',
|
||||
'crop_error' => 'Не удалось загрузить это изображение. Попробуйте другой файл.',
|
||||
],
|
||||
|
||||
'timezone' => [
|
||||
|
|
|
|||
|
|
@ -21,6 +21,12 @@
|
|||
'uploading' => 'Yükleniyor...',
|
||||
'remove' => 'Fotoğrafı kaldır',
|
||||
'hint' => 'Önerilen: kare görsel, en fazla 2 MB.',
|
||||
'crop_title' => 'Görseli kırp',
|
||||
'crop_description' => 'Saklamak istediğiniz alanı seçin.',
|
||||
'crop_hint' => 'Taşımak için sürükleyin veya boyutlandırmak için bir köşeyi sürükleyin.',
|
||||
'crop_save' => 'Kaydet',
|
||||
'crop_cancel' => 'İptal',
|
||||
'crop_error' => 'Bu görsel yüklenemedi. Başka bir dosya deneyin.',
|
||||
],
|
||||
|
||||
'timezone' => [
|
||||
|
|
|
|||
|
|
@ -21,6 +21,12 @@
|
|||
'uploading' => '上传中…',
|
||||
'remove' => '移除照片',
|
||||
'hint' => '推荐:正方形图片,最大 2 MB。',
|
||||
'crop_title' => '裁剪图片',
|
||||
'crop_description' => '选择要保留的区域。',
|
||||
'crop_hint' => '拖动以移动,拖动边角可调整大小。',
|
||||
'crop_save' => '保存',
|
||||
'crop_cancel' => '取消',
|
||||
'crop_error' => '无法加载此图片。请尝试其他文件。',
|
||||
],
|
||||
|
||||
'timezone' => [
|
||||
|
|
|
|||
90
package-lock.json
generated
90
package-lock.json
generated
|
|
@ -55,6 +55,7 @@
|
|||
"eslint-plugin-import": "^2.32.0",
|
||||
"eslint-plugin-vue": "^9.32.0",
|
||||
"laravel-echo": "^2.3.0",
|
||||
"playwright": "^1.61.1",
|
||||
"prettier": "^3.4.2",
|
||||
"prettier-plugin-organize-imports": "^4.1.0",
|
||||
"prettier-plugin-tailwindcss": "^0.6.11",
|
||||
|
|
@ -1448,9 +1449,6 @@
|
|||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -1467,9 +1465,6 @@
|
|||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -1486,9 +1481,6 @@
|
|||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -1505,9 +1497,6 @@
|
|||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -1524,9 +1513,6 @@
|
|||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -1543,9 +1529,6 @@
|
|||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -1855,9 +1838,6 @@
|
|||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -1875,9 +1855,6 @@
|
|||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -1894,9 +1871,6 @@
|
|||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -1914,9 +1888,6 @@
|
|||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -7204,9 +7175,6 @@
|
|||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -7227,9 +7195,6 @@
|
|||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -7250,9 +7215,6 @@
|
|||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -7273,9 +7235,6 @@
|
|||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -7866,6 +7825,53 @@
|
|||
"url": "https://github.com/sponsors/jonschlinkert"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright": {
|
||||
"version": "1.61.1",
|
||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.61.1.tgz",
|
||||
"integrity": "sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright-core": "1.61.1"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"fsevents": "2.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright-core": {
|
||||
"version": "1.61.1",
|
||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.61.1.tgz",
|
||||
"integrity": "sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"playwright-core": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright/node_modules/fsevents": {
|
||||
"version": "2.3.2",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
|
||||
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/possible-typed-array-names": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz",
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@
|
|||
"eslint-plugin-import": "^2.32.0",
|
||||
"eslint-plugin-vue": "^9.32.0",
|
||||
"laravel-echo": "^2.3.0",
|
||||
"playwright": "^1.61.1",
|
||||
"prettier": "^3.4.2",
|
||||
"prettier-plugin-organize-imports": "^4.1.0",
|
||||
"prettier-plugin-tailwindcss": "^0.6.11",
|
||||
|
|
|
|||
368
resources/js/components/ImageCropperDialog.vue
Normal file
368
resources/js/components/ImageCropperDialog.vue
Normal file
|
|
@ -0,0 +1,368 @@
|
|||
<script setup lang="ts">
|
||||
import { computed, nextTick, onBeforeUnmount, ref, watch } from 'vue';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import {
|
||||
clampSelection,
|
||||
containScale,
|
||||
type Corner,
|
||||
defaultSelection,
|
||||
resizeSelection,
|
||||
resolveOutputFileName,
|
||||
resolveOutputMime,
|
||||
type SourceRect,
|
||||
} from '@/lib/imageCrop';
|
||||
|
||||
type Props = {
|
||||
open: boolean;
|
||||
src: string | null;
|
||||
fileName?: string;
|
||||
mimeType?: string;
|
||||
outputSize?: number;
|
||||
};
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
fileName: 'image.png',
|
||||
mimeType: 'image/png',
|
||||
outputSize: 512,
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:open', value: boolean): void;
|
||||
(e: 'cropped', file: File): void;
|
||||
}>();
|
||||
|
||||
const viewportEl = ref<HTMLElement | null>(null);
|
||||
const imageEl = ref<HTMLImageElement | null>(null);
|
||||
const viewportSize = ref(0);
|
||||
const natural = ref({ width: 0, height: 0 });
|
||||
const selection = ref<SourceRect>({ sx: 0, sy: 0, sw: 0, sh: 0 });
|
||||
const processing = ref(false);
|
||||
const initialized = ref(false);
|
||||
const imageError = ref(false);
|
||||
|
||||
const MIN_SELECTION_RATIO = 0.1;
|
||||
|
||||
let dragMode: 'move' | Corner | null = null;
|
||||
let activePointerId: number | null = null;
|
||||
let dragStart = { pointerX: 0, pointerY: 0, selection: { sx: 0, sy: 0, sw: 0, sh: 0 } as SourceRect };
|
||||
let resizeObserver: ResizeObserver | null = null;
|
||||
|
||||
const ready = computed(() => viewportSize.value > 0 && natural.value.width > 0);
|
||||
|
||||
const scale = computed(() => containScale(natural.value.width, natural.value.height, viewportSize.value));
|
||||
|
||||
const minSourceSize = computed(() => Math.min(natural.value.width, natural.value.height) * MIN_SELECTION_RATIO);
|
||||
|
||||
const imageDisplay = computed(() => {
|
||||
const width = natural.value.width * scale.value;
|
||||
const height = natural.value.height * scale.value;
|
||||
|
||||
return {
|
||||
width,
|
||||
height,
|
||||
left: (viewportSize.value - width) / 2,
|
||||
top: (viewportSize.value - height) / 2,
|
||||
};
|
||||
});
|
||||
|
||||
const imageStyle = computed(() => ({
|
||||
left: `${imageDisplay.value.left}px`,
|
||||
top: `${imageDisplay.value.top}px`,
|
||||
width: `${imageDisplay.value.width}px`,
|
||||
height: `${imageDisplay.value.height}px`,
|
||||
}));
|
||||
|
||||
const selectionStyle = computed(() => ({
|
||||
left: `${imageDisplay.value.left + selection.value.sx * scale.value}px`,
|
||||
top: `${imageDisplay.value.top + selection.value.sy * scale.value}px`,
|
||||
width: `${selection.value.sw * scale.value}px`,
|
||||
height: `${selection.value.sh * scale.value}px`,
|
||||
boxShadow: '0 0 0 9999px rgba(0, 0, 0, 0.5)',
|
||||
}));
|
||||
|
||||
const outputMime = computed(() => resolveOutputMime(props.mimeType));
|
||||
|
||||
const outputFileName = computed(() => resolveOutputFileName(props.fileName, outputMime.value));
|
||||
|
||||
const maybeInitialize = () => {
|
||||
if (!ready.value || initialized.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
selection.value = defaultSelection(natural.value.width, natural.value.height);
|
||||
initialized.value = true;
|
||||
};
|
||||
|
||||
const measure = () => {
|
||||
const el = viewportEl.value;
|
||||
|
||||
if (!el) {
|
||||
return;
|
||||
}
|
||||
|
||||
viewportSize.value = el.clientWidth;
|
||||
maybeInitialize();
|
||||
};
|
||||
|
||||
const onImageLoad = () => {
|
||||
const img = imageEl.value;
|
||||
|
||||
if (!img) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (img.naturalWidth === 0 || img.naturalHeight === 0) {
|
||||
imageError.value = true;
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
natural.value = { width: img.naturalWidth, height: img.naturalHeight };
|
||||
maybeInitialize();
|
||||
};
|
||||
|
||||
const onImageError = () => {
|
||||
imageError.value = true;
|
||||
};
|
||||
|
||||
const sourcePoint = (clientX: number, clientY: number): { px: number; py: number } => {
|
||||
const box = viewportEl.value!.getBoundingClientRect();
|
||||
|
||||
return {
|
||||
px: (clientX - box.left - imageDisplay.value.left) / scale.value,
|
||||
py: (clientY - box.top - imageDisplay.value.top) / scale.value,
|
||||
};
|
||||
};
|
||||
|
||||
const beginDrag = (mode: 'move' | Corner, event: PointerEvent) => {
|
||||
if (!ready.value || dragMode) {
|
||||
return;
|
||||
}
|
||||
|
||||
dragMode = mode;
|
||||
activePointerId = event.pointerId;
|
||||
dragStart = { pointerX: event.clientX, pointerY: event.clientY, selection: { ...selection.value } };
|
||||
viewportEl.value?.setPointerCapture(event.pointerId);
|
||||
};
|
||||
|
||||
const endDrag = (event: PointerEvent) => {
|
||||
dragMode = null;
|
||||
activePointerId = null;
|
||||
|
||||
if (viewportEl.value?.hasPointerCapture(event.pointerId)) {
|
||||
viewportEl.value.releasePointerCapture(event.pointerId);
|
||||
}
|
||||
};
|
||||
|
||||
const onSelectionPointerDown = (event: PointerEvent) => {
|
||||
beginDrag('move', event);
|
||||
};
|
||||
|
||||
const onHandlePointerDown = (corner: Corner, event: PointerEvent) => {
|
||||
beginDrag(corner, event);
|
||||
};
|
||||
|
||||
const onPointerMove = (event: PointerEvent) => {
|
||||
if (!dragMode || event.pointerId !== activePointerId) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (dragMode === 'move') {
|
||||
selection.value = clampSelection(
|
||||
{
|
||||
sx: dragStart.selection.sx + (event.clientX - dragStart.pointerX) / scale.value,
|
||||
sy: dragStart.selection.sy + (event.clientY - dragStart.pointerY) / scale.value,
|
||||
sw: dragStart.selection.sw,
|
||||
sh: dragStart.selection.sh,
|
||||
},
|
||||
natural.value.width,
|
||||
natural.value.height,
|
||||
minSourceSize.value,
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const { px, py } = sourcePoint(event.clientX, event.clientY);
|
||||
selection.value = resizeSelection(
|
||||
dragStart.selection,
|
||||
dragMode,
|
||||
px,
|
||||
py,
|
||||
natural.value.width,
|
||||
natural.value.height,
|
||||
minSourceSize.value,
|
||||
);
|
||||
};
|
||||
|
||||
const onPointerUp = (event: PointerEvent) => {
|
||||
if (!dragMode || event.pointerId !== activePointerId) {
|
||||
return;
|
||||
}
|
||||
|
||||
endDrag(event);
|
||||
};
|
||||
|
||||
const close = () => {
|
||||
emit('update:open', false);
|
||||
};
|
||||
|
||||
const save = () => {
|
||||
const img = imageEl.value;
|
||||
|
||||
if (!img || !ready.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = props.outputSize;
|
||||
canvas.height = props.outputSize;
|
||||
|
||||
const context = canvas.getContext('2d');
|
||||
|
||||
if (!context) {
|
||||
return;
|
||||
}
|
||||
|
||||
const rect = selection.value;
|
||||
processing.value = true;
|
||||
|
||||
try {
|
||||
context.drawImage(img, rect.sx, rect.sy, rect.sw, rect.sh, 0, 0, props.outputSize, props.outputSize);
|
||||
canvas.toBlob(
|
||||
(blob) => {
|
||||
processing.value = false;
|
||||
|
||||
if (!blob) {
|
||||
return;
|
||||
}
|
||||
|
||||
emit('cropped', new File([blob], outputFileName.value, { type: outputMime.value }));
|
||||
close();
|
||||
},
|
||||
outputMime.value,
|
||||
0.92,
|
||||
);
|
||||
} catch {
|
||||
processing.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
watch(
|
||||
() => props.open,
|
||||
async (isOpen) => {
|
||||
if (isOpen) {
|
||||
initialized.value = false;
|
||||
processing.value = false;
|
||||
imageError.value = false;
|
||||
dragMode = null;
|
||||
activePointerId = null;
|
||||
await nextTick();
|
||||
measure();
|
||||
|
||||
if (viewportEl.value && !resizeObserver) {
|
||||
resizeObserver = new ResizeObserver(() => measure());
|
||||
resizeObserver.observe(viewportEl.value);
|
||||
}
|
||||
} else {
|
||||
dragMode = null;
|
||||
activePointerId = null;
|
||||
resizeObserver?.disconnect();
|
||||
resizeObserver = null;
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
watch(
|
||||
() => props.src,
|
||||
() => {
|
||||
initialized.value = false;
|
||||
imageError.value = false;
|
||||
natural.value = { width: 0, height: 0 };
|
||||
},
|
||||
);
|
||||
|
||||
onBeforeUnmount(() => resizeObserver?.disconnect());
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Dialog :open="open" @update:open="emit('update:open', $event)">
|
||||
<DialogContent class="sm:max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{{ $t('common.photo_upload.crop_title') }}</DialogTitle>
|
||||
<DialogDescription>{{ $t('common.photo_upload.crop_description') }}</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div
|
||||
ref="viewportEl"
|
||||
class="relative aspect-square w-full touch-none select-none overflow-hidden rounded-xl border-2 border-foreground bg-muted"
|
||||
@pointermove="onPointerMove"
|
||||
@pointerup="onPointerUp"
|
||||
@pointercancel="onPointerUp"
|
||||
@wheel.prevent
|
||||
>
|
||||
<img
|
||||
v-if="src && !imageError"
|
||||
ref="imageEl"
|
||||
:src="src"
|
||||
alt=""
|
||||
draggable="false"
|
||||
class="pointer-events-none absolute max-w-none"
|
||||
:style="imageStyle"
|
||||
@load="onImageLoad"
|
||||
@error="onImageError"
|
||||
/>
|
||||
<div
|
||||
v-if="imageError"
|
||||
class="absolute inset-0 flex items-center justify-center p-4 text-center text-sm text-muted-foreground"
|
||||
>
|
||||
{{ $t('common.photo_upload.crop_error') }}
|
||||
</div>
|
||||
<div
|
||||
v-else-if="ready"
|
||||
class="absolute cursor-move"
|
||||
:style="selectionStyle"
|
||||
@pointerdown="onSelectionPointerDown"
|
||||
>
|
||||
<div class="pointer-events-none absolute inset-0 border-2 border-white shadow-[0_0_0_1px_rgba(0,0,0,0.4)]" />
|
||||
<span
|
||||
class="absolute left-0 top-0 size-3 cursor-nwse-resize rounded-sm border border-foreground bg-white"
|
||||
@pointerdown.stop="onHandlePointerDown('nw', $event)"
|
||||
/>
|
||||
<span
|
||||
class="absolute right-0 top-0 size-3 cursor-nesw-resize rounded-sm border border-foreground bg-white"
|
||||
@pointerdown.stop="onHandlePointerDown('ne', $event)"
|
||||
/>
|
||||
<span
|
||||
class="absolute bottom-0 left-0 size-3 cursor-nesw-resize rounded-sm border border-foreground bg-white"
|
||||
@pointerdown.stop="onHandlePointerDown('sw', $event)"
|
||||
/>
|
||||
<span
|
||||
class="absolute bottom-0 right-0 size-3 cursor-nwse-resize rounded-sm border border-foreground bg-white"
|
||||
@pointerdown.stop="onHandlePointerDown('se', $event)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="text-center text-xs text-muted-foreground">{{ $t('common.photo_upload.crop_hint') }}</p>
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="button" data-testid="crop-save" :disabled="processing || !ready" @click="save">
|
||||
{{ $t('common.photo_upload.crop_save') }}
|
||||
</Button>
|
||||
<Button type="button" variant="outline" @click="close">
|
||||
{{ $t('common.photo_upload.crop_cancel') }}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</template>
|
||||
|
|
@ -2,8 +2,9 @@
|
|||
import { router } from '@inertiajs/vue3';
|
||||
import { IconTrash } from '@tabler/icons-vue';
|
||||
import { trans } from 'laravel-vue-i18n';
|
||||
import { ref } from 'vue';
|
||||
import { ref, watch } from 'vue';
|
||||
|
||||
import ImageCropperDialog from '@/components/ImageCropperDialog.vue';
|
||||
import { Avatar } from '@/components/ui/avatar';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
|
|
@ -34,6 +35,17 @@ const props = withDefaults(defineProps<Props>(), {
|
|||
const fileInput = ref<HTMLInputElement | null>(null);
|
||||
const uploading = ref(false);
|
||||
|
||||
const cropOpen = ref(false);
|
||||
const cropSrc = ref<string | null>(null);
|
||||
const cropFileName = ref('image.png');
|
||||
const cropMime = ref('image/png');
|
||||
|
||||
watch(cropOpen, (isOpen) => {
|
||||
if (!isOpen) {
|
||||
cropSrc.value = null;
|
||||
}
|
||||
});
|
||||
|
||||
const sizeClasses = {
|
||||
sm: 'size-16',
|
||||
md: 'size-20',
|
||||
|
|
@ -63,6 +75,22 @@ const handleFileChange = (event: Event) => {
|
|||
return;
|
||||
}
|
||||
|
||||
cropFileName.value = file.name || 'image.png';
|
||||
cropMime.value = file.type || 'image/png';
|
||||
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
cropSrc.value = reader.result as string;
|
||||
cropOpen.value = true;
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
|
||||
if (fileInput.value) {
|
||||
fileInput.value.value = '';
|
||||
}
|
||||
};
|
||||
|
||||
const uploadCropped = (file: File) => {
|
||||
uploading.value = true;
|
||||
|
||||
router.post(
|
||||
|
|
@ -72,9 +100,6 @@ const handleFileChange = (event: Event) => {
|
|||
forceFormData: true,
|
||||
onFinish: () => {
|
||||
uploading.value = false;
|
||||
if (fileInput.value) {
|
||||
fileInput.value.value = '';
|
||||
}
|
||||
},
|
||||
},
|
||||
);
|
||||
|
|
@ -140,5 +165,13 @@ const handleDelete = () => {
|
|||
{{ $t('common.photo_upload.hint') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<ImageCropperDialog
|
||||
v-model:open="cropOpen"
|
||||
:src="cropSrc"
|
||||
:file-name="cropFileName"
|
||||
:mime-type="cropMime"
|
||||
@cropped="uploadCropped"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
|
|
|||
76
resources/js/lib/imageCrop.ts
Normal file
76
resources/js/lib/imageCrop.ts
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
export type SourceRect = {
|
||||
sx: number;
|
||||
sy: number;
|
||||
sw: number;
|
||||
sh: number;
|
||||
};
|
||||
|
||||
export type Corner = 'nw' | 'ne' | 'sw' | 'se';
|
||||
|
||||
const DEFAULT_SELECTION_RATIO = 0.8;
|
||||
|
||||
const ENCODABLE_MIMES = ['image/jpeg', 'image/png', 'image/webp'];
|
||||
const EXTENSIONS: Record<string, string> = { 'image/jpeg': 'jpg', 'image/png': 'png', 'image/webp': 'webp' };
|
||||
|
||||
export const resolveOutputMime = (mimeType: string): string =>
|
||||
ENCODABLE_MIMES.includes(mimeType) ? mimeType : 'image/png';
|
||||
|
||||
export const resolveOutputFileName = (fileName: string, mime: string): string =>
|
||||
`${fileName.replace(/\.[^./]*$/, '') || 'image'}.${EXTENSIONS[mime] ?? 'png'}`;
|
||||
|
||||
export const containScale = (naturalWidth: number, naturalHeight: number, viewport: number): number => {
|
||||
if (naturalWidth <= 0 || naturalHeight <= 0) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
return Math.min(viewport / naturalWidth, viewport / naturalHeight);
|
||||
};
|
||||
|
||||
export const defaultSelection = (naturalWidth: number, naturalHeight: number): SourceRect => {
|
||||
const size = Math.min(naturalWidth, naturalHeight) * DEFAULT_SELECTION_RATIO;
|
||||
|
||||
return {
|
||||
sx: (naturalWidth - size) / 2,
|
||||
sy: (naturalHeight - size) / 2,
|
||||
sw: size,
|
||||
sh: size,
|
||||
};
|
||||
};
|
||||
|
||||
export const clampSelection = (
|
||||
selection: SourceRect,
|
||||
naturalWidth: number,
|
||||
naturalHeight: number,
|
||||
minSize: number,
|
||||
): SourceRect => {
|
||||
const maxSize = Math.min(naturalWidth, naturalHeight);
|
||||
const size = Math.min(Math.max(selection.sw, minSize), maxSize);
|
||||
const sx = Math.min(Math.max(selection.sx, 0), naturalWidth - size);
|
||||
const sy = Math.min(Math.max(selection.sy, 0), naturalHeight - size);
|
||||
|
||||
return { sx, sy, sw: size, sh: size };
|
||||
};
|
||||
|
||||
export const resizeSelection = (
|
||||
selection: SourceRect,
|
||||
corner: Corner,
|
||||
px: number,
|
||||
py: number,
|
||||
naturalWidth: number,
|
||||
naturalHeight: number,
|
||||
minSize: number,
|
||||
): SourceRect => {
|
||||
const right = selection.sx + selection.sw;
|
||||
const bottom = selection.sy + selection.sh;
|
||||
|
||||
const anchorX = corner === 'nw' || corner === 'sw' ? right : selection.sx;
|
||||
const anchorY = corner === 'nw' || corner === 'ne' ? bottom : selection.sy;
|
||||
const horizontal = corner === 'ne' || corner === 'se' ? 1 : -1;
|
||||
const vertical = corner === 'sw' || corner === 'se' ? 1 : -1;
|
||||
|
||||
const size = Math.max(horizontal * (px - anchorX), vertical * (py - anchorY), minSize);
|
||||
const sx = horizontal === 1 ? anchorX : anchorX - size;
|
||||
const sy = vertical === 1 ? anchorY : anchorY - size;
|
||||
|
||||
return clampSelection({ sx, sy, sw: size, sh: size }, naturalWidth, naturalHeight, minSize);
|
||||
};
|
||||
131
tests/Browser/ImageCropperTest.php
Normal file
131
tests/Browser/ImageCropperTest.php
Normal file
|
|
@ -0,0 +1,131 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\User;
|
||||
|
||||
/**
|
||||
* Inject a real image into the hidden file input and dispatch `change`, driving
|
||||
* the same flow a user's file selection would. The plugin's attach() sends
|
||||
* localPaths, which Playwright rejects over its websocket connection, so a
|
||||
* DataTransfer is used instead. The poll waits for the SPA to mount first.
|
||||
*/
|
||||
function selectPhoto(mixed $page): void
|
||||
{
|
||||
$base64 = base64_encode((string) file_get_contents(base_path('tests/fixtures/crop-quadrants.png')));
|
||||
|
||||
$page->script(<<<JS
|
||||
(async () => {
|
||||
const findInput = () => document.querySelector('input[type="file"]');
|
||||
for (let attempt = 0; attempt < 50 && !findInput(); attempt++) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
}
|
||||
const input = findInput();
|
||||
const bytes = Uint8Array.from(atob('{$base64}'), (character) => character.charCodeAt(0));
|
||||
const file = new File([bytes], 'logo.png', { type: 'image/png' });
|
||||
const data = new DataTransfer();
|
||||
data.items.add(file);
|
||||
input.files = data.files;
|
||||
input.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
})();
|
||||
JS);
|
||||
}
|
||||
|
||||
/**
|
||||
* Capture the next multipart upload the page sends, keeping the uploaded File so
|
||||
* the test can decode it. The Pest browser server does not parse multipart
|
||||
* bodies (its file handling is an open TODO), so we assert the crop dispatches
|
||||
* a valid image rather than that it persists — persistence is covered by
|
||||
* ProfileUpdateTest.
|
||||
*/
|
||||
function recordUpload(mixed $page): void
|
||||
{
|
||||
$page->script(<<<'JS'
|
||||
(() => {
|
||||
window.__uploadRequest = null;
|
||||
const open = XMLHttpRequest.prototype.open;
|
||||
const send = XMLHttpRequest.prototype.send;
|
||||
XMLHttpRequest.prototype.open = function (method, url) {
|
||||
this.__method = method;
|
||||
this.__url = url;
|
||||
return open.apply(this, arguments);
|
||||
};
|
||||
XMLHttpRequest.prototype.send = function (body) {
|
||||
if (body instanceof FormData) {
|
||||
const photo = body.get('photo');
|
||||
window.__uploadFile = photo;
|
||||
window.__uploadRequest = {
|
||||
method: this.__method,
|
||||
url: this.__url,
|
||||
keys: [...body.keys()],
|
||||
size: photo instanceof File ? photo.size : 0,
|
||||
type: photo instanceof File ? photo.type : null,
|
||||
};
|
||||
}
|
||||
return send.apply(this, arguments);
|
||||
};
|
||||
})();
|
||||
JS);
|
||||
}
|
||||
|
||||
test('cropping a selected photo dispatches a valid 512x512 avatar upload', function () {
|
||||
$this->actingAs(User::factory()->create());
|
||||
|
||||
$page = visit(route('app.profile.edit'));
|
||||
|
||||
selectPhoto($page);
|
||||
recordUpload($page);
|
||||
|
||||
$page->click('@crop-save')
|
||||
->assertNoJavaScriptErrors();
|
||||
|
||||
$request = json_decode((string) $page->script(<<<'JS'
|
||||
(async () => {
|
||||
for (let attempt = 0; attempt < 80 && !window.__uploadRequest; attempt++) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
}
|
||||
if (!window.__uploadRequest) {
|
||||
return 'null';
|
||||
}
|
||||
const bitmap = await createImageBitmap(window.__uploadFile);
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = bitmap.width;
|
||||
canvas.height = bitmap.height;
|
||||
const context = canvas.getContext('2d');
|
||||
context.drawImage(bitmap, 0, 0);
|
||||
const sample = (x, y) => Array.from(context.getImageData(x, y, 1, 1).data);
|
||||
return JSON.stringify({
|
||||
...window.__uploadRequest,
|
||||
width: bitmap.width,
|
||||
height: bitmap.height,
|
||||
pixels: {
|
||||
topLeft: sample(128, 128),
|
||||
topRight: sample(384, 128),
|
||||
bottomLeft: sample(128, 384),
|
||||
bottomRight: sample(384, 384),
|
||||
},
|
||||
});
|
||||
})();
|
||||
JS), true);
|
||||
|
||||
expect($request)->not->toBeNull()
|
||||
->and($request['method'])->toBe('POST')
|
||||
->and($request['url'])->toContain(route('app.profile.upload-photo', absolute: false))
|
||||
->and($request['keys'])->toContain('photo')
|
||||
->and($request['size'])->toBeGreaterThan(0)
|
||||
->and($request['type'])->toBe('image/png')
|
||||
->and($request['width'])->toBe(512)
|
||||
->and($request['height'])->toBe(512);
|
||||
|
||||
$isColour = function (array $pixel, array $rgb): bool {
|
||||
return abs($pixel[0] - $rgb[0]) <= 24
|
||||
&& abs($pixel[1] - $rgb[1]) <= 24
|
||||
&& abs($pixel[2] - $rgb[2]) <= 24
|
||||
&& $pixel[3] >= 250;
|
||||
};
|
||||
|
||||
expect($isColour($request['pixels']['topLeft'], [255, 0, 0]))->toBeTrue()
|
||||
->and($isColour($request['pixels']['topRight'], [0, 255, 0]))->toBeTrue()
|
||||
->and($isColour($request['pixels']['bottomLeft'], [0, 0, 255]))->toBeTrue()
|
||||
->and($isColour($request['pixels']['bottomRight'], [255, 255, 0]))->toBeTrue();
|
||||
});
|
||||
14
tests/BrowserTestCase.php
Normal file
14
tests/BrowserTestCase.php
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests;
|
||||
|
||||
abstract class BrowserTestCase extends TestCase
|
||||
{
|
||||
/**
|
||||
* Browser tests drive a real browser and load the built Vite assets, so the
|
||||
* manifest must not be faked away.
|
||||
*/
|
||||
protected bool $fakesVite = false;
|
||||
}
|
||||
|
|
@ -9,6 +9,7 @@
|
|||
use App\Models\User;
|
||||
use App\Models\Workspace;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Tests\BrowserTestCase;
|
||||
use Tests\TestCase;
|
||||
|
||||
/*
|
||||
|
|
@ -26,6 +27,10 @@
|
|||
->use(RefreshDatabase::class)
|
||||
->in('Feature', 'Unit');
|
||||
|
||||
pest()->extend(BrowserTestCase::class)
|
||||
->use(RefreshDatabase::class)
|
||||
->in('Browser');
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Expectations
|
||||
|
|
|
|||
|
|
@ -17,10 +17,18 @@ abstract class TestCase extends BaseTestCase
|
|||
*/
|
||||
protected $seed = true;
|
||||
|
||||
/**
|
||||
* Whether to fake the Vite manifest. Browser tests drive a real browser and
|
||||
* need the built assets, so they opt out via BrowserTestCase.
|
||||
*/
|
||||
protected bool $fakesVite = true;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
$this->withoutVite();
|
||||
if ($this->fakesVite) {
|
||||
$this->withoutVite();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
BIN
tests/fixtures/crop-quadrants.png
vendored
Normal file
BIN
tests/fixtures/crop-quadrants.png
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 624 B |
Loading…
Reference in a new issue