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:
Paulo Castellano 2026-07-04 16:16:45 -03:00 committed by GitHub
commit d6c40d425c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
32 changed files with 2510 additions and 100 deletions

View 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

View file

@ -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

View file

@ -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-*

View file

@ -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
View file

@ -1,6 +1,7 @@
/.phpunit.cache
/bootstrap/ssr
/node_modules
/tests/Browser/Screenshots
/public/build
/lang/php_*.json
/public/hot

View file

@ -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

File diff suppressed because it is too large Load diff

View file

@ -21,6 +21,12 @@
'uploading' => 'جارٍ الرفع...',
'remove' => 'إزالة الصورة',
'hint' => 'موصى به: صورة مربعة، بحد أقصى 2 ميغابايت.',
'crop_title' => 'قص الصورة',
'crop_description' => 'حدّد المنطقة التي تريد الاحتفاظ بها.',
'crop_hint' => 'اسحب للتحريك، أو زاوية لتغيير الحجم.',
'crop_save' => 'حفظ',
'crop_cancel' => 'إلغاء',
'crop_error' => 'تعذّر تحميل هذه الصورة. جرّب ملفًا آخر.',
],
'timezone' => [

View file

@ -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' => [

View file

@ -21,6 +21,12 @@
'uploading' => 'Μεταφόρτωση...',
'remove' => 'Αφαίρεση φωτογραφίας',
'hint' => 'Συνιστάται: τετράγωνη εικόνα, έως 2 MB.',
'crop_title' => 'Περικοπή εικόνας',
'crop_description' => 'Επιλέξτε την περιοχή που θέλετε να κρατήσετε.',
'crop_hint' => 'Σύρετε για μετακίνηση ή μια γωνία για αλλαγή μεγέθους.',
'crop_save' => 'Αποθήκευση',
'crop_cancel' => 'Άκυρο',
'crop_error' => 'Δεν ήταν δυνατή η φόρτωση αυτής της εικόνας. Δοκιμάστε άλλο αρχείο.',
],
'timezone' => [

View file

@ -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' => [

View file

@ -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' => [

View file

@ -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' => [

View file

@ -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' => [

View file

@ -21,6 +21,12 @@
'uploading' => 'アップロード中...',
'remove' => '写真を削除',
'hint' => '推奨: 正方形の画像、最大 2 MB。',
'crop_title' => '画像を切り抜く',
'crop_description' => '残したい範囲を選択してください。',
'crop_hint' => 'ドラッグで移動、角でサイズ変更できます。',
'crop_save' => '保存',
'crop_cancel' => 'キャンセル',
'crop_error' => 'この画像を読み込めませんでした。別のファイルをお試しください。',
],
'timezone' => [

View file

@ -21,6 +21,12 @@
'uploading' => '업로드 중...',
'remove' => '사진 제거',
'hint' => '권장: 정사각형 이미지, 최대 2MB.',
'crop_title' => '이미지 자르기',
'crop_description' => '남길 영역을 선택하세요.',
'crop_hint' => '드래그하여 이동하거나 모서리로 크기를 조절하세요.',
'crop_save' => '저장',
'crop_cancel' => '취소',
'crop_error' => '이 이미지를 불러올 수 없습니다. 다른 파일을 사용해 보세요.',
],
'timezone' => [

View file

@ -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' => [

View file

@ -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' => [

View file

@ -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' => [

View file

@ -21,6 +21,12 @@
'uploading' => 'Загрузка...',
'remove' => 'Удалить фото',
'hint' => 'Рекомендуется: квадратное изображение, до 2 МБ.',
'crop_title' => 'Обрезать изображение',
'crop_description' => 'Выберите область, которую нужно оставить.',
'crop_hint' => 'Перетащите, чтобы переместить, или угол — чтобы изменить размер.',
'crop_save' => 'Сохранить',
'crop_cancel' => 'Отмена',
'crop_error' => 'Не удалось загрузить это изображение. Попробуйте другой файл.',
],
'timezone' => [

View file

@ -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' => [

View file

@ -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
View file

@ -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",

View file

@ -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",

View 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>

View file

@ -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>

View 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);
};

View 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
View 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;
}

View file

@ -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

View file

@ -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

Binary file not shown.

After

Width:  |  Height:  |  Size: 624 B