Crop the avatar/logo before upload with a dependency-free cropper
Selecting an avatar or workspace logo now opens a crop dialog (drag + zoom)
before uploading, so the image is framed the way it renders. The crop is
performed client-side and the resized 512x512 result is what gets uploaded.
This reworks the idea from #131 without its cropper dependency: vue-advanced-cropper
was last released ~2 years ago and we did not want an unmaintained package for
something this load-bearing. What we need is narrow (fixed 1:1, a circle/square
mask, fixed-size output), so a small canvas-based cropper covers it:
- imageCrop.ts: pure transform math (cover-fit, clamp, zoom, viewport->source).
- ImageCropperDialog.vue: CSS-transform preview, pointer drag, wheel/button zoom,
a ResizeObserver to measure the modal (no requestAnimationFrame timing hacks),
and a canvas toBlob only on save.
- PhotoUpload.vue: opens the cropper on file select; the mask shape follows the
display shape (round avatar / square logo) instead of always being round.
- crop_* strings added to all 15 locales.
Also installs Pest browser testing (pest-plugin-browser + Playwright) and adds a
browser test for the crop flow. TestCase only calls withoutVite() for non-browser
tests, since browser tests need the real Vite assets to boot the SPA. The Pest
browser server does not parse multipart uploads, so the test asserts the crop
dispatches the correct upload request; endpoint persistence stays covered by
ProfileUpdateTest.
2026-07-04 00:46:19 +00:00
|
|
|
<?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
|
|
|
|
|
{
|
2026-07-04 12:56:38 +00:00
|
|
|
$base64 = base64_encode((string) file_get_contents(base_path('tests/fixtures/crop-quadrants.png')));
|
Crop the avatar/logo before upload with a dependency-free cropper
Selecting an avatar or workspace logo now opens a crop dialog (drag + zoom)
before uploading, so the image is framed the way it renders. The crop is
performed client-side and the resized 512x512 result is what gets uploaded.
This reworks the idea from #131 without its cropper dependency: vue-advanced-cropper
was last released ~2 years ago and we did not want an unmaintained package for
something this load-bearing. What we need is narrow (fixed 1:1, a circle/square
mask, fixed-size output), so a small canvas-based cropper covers it:
- imageCrop.ts: pure transform math (cover-fit, clamp, zoom, viewport->source).
- ImageCropperDialog.vue: CSS-transform preview, pointer drag, wheel/button zoom,
a ResizeObserver to measure the modal (no requestAnimationFrame timing hacks),
and a canvas toBlob only on save.
- PhotoUpload.vue: opens the cropper on file select; the mask shape follows the
display shape (round avatar / square logo) instead of always being round.
- crop_* strings added to all 15 locales.
Also installs Pest browser testing (pest-plugin-browser + Playwright) and adds a
browser test for the crop flow. TestCase only calls withoutVite() for non-browser
tests, since browser tests need the real Vite assets to boot the SPA. The Pest
browser server does not parse multipart uploads, so the test asserts the crop
dispatches the correct upload request; endpoint persistence stays covered by
ProfileUpdateTest.
2026-07-04 00:46:19 +00:00
|
|
|
|
|
|
|
|
$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);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
2026-07-04 01:07:23 +00:00
|
|
|
* 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.
|
Crop the avatar/logo before upload with a dependency-free cropper
Selecting an avatar or workspace logo now opens a crop dialog (drag + zoom)
before uploading, so the image is framed the way it renders. The crop is
performed client-side and the resized 512x512 result is what gets uploaded.
This reworks the idea from #131 without its cropper dependency: vue-advanced-cropper
was last released ~2 years ago and we did not want an unmaintained package for
something this load-bearing. What we need is narrow (fixed 1:1, a circle/square
mask, fixed-size output), so a small canvas-based cropper covers it:
- imageCrop.ts: pure transform math (cover-fit, clamp, zoom, viewport->source).
- ImageCropperDialog.vue: CSS-transform preview, pointer drag, wheel/button zoom,
a ResizeObserver to measure the modal (no requestAnimationFrame timing hacks),
and a canvas toBlob only on save.
- PhotoUpload.vue: opens the cropper on file select; the mask shape follows the
display shape (round avatar / square logo) instead of always being round.
- crop_* strings added to all 15 locales.
Also installs Pest browser testing (pest-plugin-browser + Playwright) and adds a
browser test for the crop flow. TestCase only calls withoutVite() for non-browser
tests, since browser tests need the real Vite assets to boot the SPA. The Pest
browser server does not parse multipart uploads, so the test asserts the crop
dispatches the correct upload request; endpoint persistence stays covered by
ProfileUpdateTest.
2026-07-04 00:46:19 +00:00
|
|
|
*/
|
|
|
|
|
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) {
|
2026-07-04 01:07:23 +00:00
|
|
|
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,
|
2026-07-04 12:56:38 +00:00
|
|
|
type: photo instanceof File ? photo.type : null,
|
2026-07-04 01:07:23 +00:00
|
|
|
};
|
Crop the avatar/logo before upload with a dependency-free cropper
Selecting an avatar or workspace logo now opens a crop dialog (drag + zoom)
before uploading, so the image is framed the way it renders. The crop is
performed client-side and the resized 512x512 result is what gets uploaded.
This reworks the idea from #131 without its cropper dependency: vue-advanced-cropper
was last released ~2 years ago and we did not want an unmaintained package for
something this load-bearing. What we need is narrow (fixed 1:1, a circle/square
mask, fixed-size output), so a small canvas-based cropper covers it:
- imageCrop.ts: pure transform math (cover-fit, clamp, zoom, viewport->source).
- ImageCropperDialog.vue: CSS-transform preview, pointer drag, wheel/button zoom,
a ResizeObserver to measure the modal (no requestAnimationFrame timing hacks),
and a canvas toBlob only on save.
- PhotoUpload.vue: opens the cropper on file select; the mask shape follows the
display shape (round avatar / square logo) instead of always being round.
- crop_* strings added to all 15 locales.
Also installs Pest browser testing (pest-plugin-browser + Playwright) and adds a
browser test for the crop flow. TestCase only calls withoutVite() for non-browser
tests, since browser tests need the real Vite assets to boot the SPA. The Pest
browser server does not parse multipart uploads, so the test asserts the crop
dispatches the correct upload request; endpoint persistence stays covered by
ProfileUpdateTest.
2026-07-04 00:46:19 +00:00
|
|
|
}
|
|
|
|
|
return send.apply(this, arguments);
|
|
|
|
|
};
|
|
|
|
|
})();
|
|
|
|
|
JS);
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-04 01:07:23 +00:00
|
|
|
test('cropping a selected photo dispatches a valid 512x512 avatar upload', function () {
|
Crop the avatar/logo before upload with a dependency-free cropper
Selecting an avatar or workspace logo now opens a crop dialog (drag + zoom)
before uploading, so the image is framed the way it renders. The crop is
performed client-side and the resized 512x512 result is what gets uploaded.
This reworks the idea from #131 without its cropper dependency: vue-advanced-cropper
was last released ~2 years ago and we did not want an unmaintained package for
something this load-bearing. What we need is narrow (fixed 1:1, a circle/square
mask, fixed-size output), so a small canvas-based cropper covers it:
- imageCrop.ts: pure transform math (cover-fit, clamp, zoom, viewport->source).
- ImageCropperDialog.vue: CSS-transform preview, pointer drag, wheel/button zoom,
a ResizeObserver to measure the modal (no requestAnimationFrame timing hacks),
and a canvas toBlob only on save.
- PhotoUpload.vue: opens the cropper on file select; the mask shape follows the
display shape (round avatar / square logo) instead of always being round.
- crop_* strings added to all 15 locales.
Also installs Pest browser testing (pest-plugin-browser + Playwright) and adds a
browser test for the crop flow. TestCase only calls withoutVite() for non-browser
tests, since browser tests need the real Vite assets to boot the SPA. The Pest
browser server does not parse multipart uploads, so the test asserts the crop
dispatches the correct upload request; endpoint persistence stays covered by
ProfileUpdateTest.
2026-07-04 00:46:19 +00:00
|
|
|
$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));
|
|
|
|
|
}
|
2026-07-04 01:07:23 +00:00
|
|
|
if (!window.__uploadRequest) {
|
|
|
|
|
return 'null';
|
|
|
|
|
}
|
|
|
|
|
const bitmap = await createImageBitmap(window.__uploadFile);
|
2026-07-04 12:56:38 +00:00
|
|
|
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),
|
|
|
|
|
},
|
|
|
|
|
});
|
Crop the avatar/logo before upload with a dependency-free cropper
Selecting an avatar or workspace logo now opens a crop dialog (drag + zoom)
before uploading, so the image is framed the way it renders. The crop is
performed client-side and the resized 512x512 result is what gets uploaded.
This reworks the idea from #131 without its cropper dependency: vue-advanced-cropper
was last released ~2 years ago and we did not want an unmaintained package for
something this load-bearing. What we need is narrow (fixed 1:1, a circle/square
mask, fixed-size output), so a small canvas-based cropper covers it:
- imageCrop.ts: pure transform math (cover-fit, clamp, zoom, viewport->source).
- ImageCropperDialog.vue: CSS-transform preview, pointer drag, wheel/button zoom,
a ResizeObserver to measure the modal (no requestAnimationFrame timing hacks),
and a canvas toBlob only on save.
- PhotoUpload.vue: opens the cropper on file select; the mask shape follows the
display shape (round avatar / square logo) instead of always being round.
- crop_* strings added to all 15 locales.
Also installs Pest browser testing (pest-plugin-browser + Playwright) and adds a
browser test for the crop flow. TestCase only calls withoutVite() for non-browser
tests, since browser tests need the real Vite assets to boot the SPA. The Pest
browser server does not parse multipart uploads, so the test asserts the crop
dispatches the correct upload request; endpoint persistence stays covered by
ProfileUpdateTest.
2026-07-04 00:46:19 +00:00
|
|
|
})();
|
|
|
|
|
JS), true);
|
|
|
|
|
|
|
|
|
|
expect($request)->not->toBeNull()
|
|
|
|
|
->and($request['method'])->toBe('POST')
|
2026-07-04 01:07:23 +00:00
|
|
|
->and($request['url'])->toContain(route('app.profile.upload-photo', absolute: false))
|
|
|
|
|
->and($request['keys'])->toContain('photo')
|
|
|
|
|
->and($request['size'])->toBeGreaterThan(0)
|
2026-07-04 12:56:38 +00:00
|
|
|
->and($request['type'])->toBe('image/png')
|
2026-07-04 01:07:23 +00:00
|
|
|
->and($request['width'])->toBe(512)
|
|
|
|
|
->and($request['height'])->toBe(512);
|
2026-07-04 12:56:38 +00:00
|
|
|
|
|
|
|
|
$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();
|
Crop the avatar/logo before upload with a dependency-free cropper
Selecting an avatar or workspace logo now opens a crop dialog (drag + zoom)
before uploading, so the image is framed the way it renders. The crop is
performed client-side and the resized 512x512 result is what gets uploaded.
This reworks the idea from #131 without its cropper dependency: vue-advanced-cropper
was last released ~2 years ago and we did not want an unmaintained package for
something this load-bearing. What we need is narrow (fixed 1:1, a circle/square
mask, fixed-size output), so a small canvas-based cropper covers it:
- imageCrop.ts: pure transform math (cover-fit, clamp, zoom, viewport->source).
- ImageCropperDialog.vue: CSS-transform preview, pointer drag, wheel/button zoom,
a ResizeObserver to measure the modal (no requestAnimationFrame timing hacks),
and a canvas toBlob only on save.
- PhotoUpload.vue: opens the cropper on file select; the mask shape follows the
display shape (round avatar / square logo) instead of always being round.
- crop_* strings added to all 15 locales.
Also installs Pest browser testing (pest-plugin-browser + Playwright) and adds a
browser test for the crop flow. TestCase only calls withoutVite() for non-browser
tests, since browser tests need the real Vite assets to boot the SPA. The Pest
browser server does not parse multipart uploads, so the test asserts the crop
dispatches the correct upload request; endpoint persistence stays covered by
ProfileUpdateTest.
2026-07-04 00:46:19 +00:00
|
|
|
});
|