mirror of
https://github.com/MrUnknownDE/VRCX.git
synced 2026-04-17 22:03:50 +02:00
improve image cropper
This commit is contained in:
@@ -19,7 +19,112 @@
|
||||
:stencil-props="{ aspectRatio, movable: !loading, resizable: !loading }"
|
||||
:move-image="!loading"
|
||||
:resize-image="!loading"
|
||||
image-restriction="stencil" />
|
||||
:image-restriction="freeMode ? 'none' : 'stencil'"
|
||||
@change="onCropperChange" />
|
||||
|
||||
<!-- Toolbar -->
|
||||
<div class="flex items-center justify-center gap-1 mt-3">
|
||||
<TooltipWrapper :content="t('dialog.image_crop.rotate_left')">
|
||||
<Button
|
||||
size="icon-sm"
|
||||
variant="outline"
|
||||
class="rounded-full h-8 w-8"
|
||||
:disabled="loading"
|
||||
@click="cropperRef?.rotate(-90)">
|
||||
<RotateCcw class="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipWrapper>
|
||||
<TooltipWrapper :content="t('dialog.image_crop.rotate_right')">
|
||||
<Button
|
||||
size="icon-sm"
|
||||
variant="outline"
|
||||
class="rounded-full h-8 w-8"
|
||||
:disabled="loading"
|
||||
@click="cropperRef?.rotate(90)">
|
||||
<RotateCw class="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipWrapper>
|
||||
|
||||
<div class="w-px h-5 bg-border mx-1" />
|
||||
|
||||
<TooltipWrapper :content="t('dialog.image_crop.flip_h')">
|
||||
<Button
|
||||
size="icon-sm"
|
||||
variant="outline"
|
||||
class="rounded-full h-8 w-8"
|
||||
:disabled="loading"
|
||||
@click="cropperRef?.flip(true, false)">
|
||||
<FlipHorizontal class="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipWrapper>
|
||||
<TooltipWrapper :content="t('dialog.image_crop.flip_v')">
|
||||
<Button
|
||||
size="icon-sm"
|
||||
variant="outline"
|
||||
class="rounded-full h-8 w-8"
|
||||
:disabled="loading"
|
||||
@click="cropperRef?.flip(false, true)">
|
||||
<FlipVertical class="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipWrapper>
|
||||
|
||||
<div class="w-px h-5 bg-border mx-1" />
|
||||
|
||||
<TooltipWrapper :content="t('dialog.image_crop.zoom_out')">
|
||||
<Button
|
||||
size="icon-sm"
|
||||
variant="ghost"
|
||||
class="rounded-full h-7 w-7"
|
||||
:disabled="loading"
|
||||
@click="cropperRef?.zoom(0.8)">
|
||||
<ZoomOut class="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</TooltipWrapper>
|
||||
<Slider
|
||||
v-model="zoomSliderValue"
|
||||
:min="0"
|
||||
:max="100"
|
||||
:step="1"
|
||||
:disabled="loading"
|
||||
class="w-28"
|
||||
@value-commit="onZoomCommit" />
|
||||
<TooltipWrapper :content="t('dialog.image_crop.zoom_in')">
|
||||
<Button
|
||||
size="icon-sm"
|
||||
variant="ghost"
|
||||
class="rounded-full h-7 w-7"
|
||||
:disabled="loading"
|
||||
@click="cropperRef?.zoom(1.2)">
|
||||
<ZoomIn class="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</TooltipWrapper>
|
||||
|
||||
<div class="w-px h-5 bg-border mx-1" />
|
||||
|
||||
<TooltipWrapper
|
||||
:content="freeMode ? t('dialog.image_crop.mode_fit') : t('dialog.image_crop.mode_free')">
|
||||
<Button
|
||||
size="icon-sm"
|
||||
:variant="freeMode ? 'default' : 'outline'"
|
||||
class="rounded-full h-8 w-8"
|
||||
:disabled="loading"
|
||||
@click="freeMode = !freeMode">
|
||||
<Expand v-if="freeMode" class="h-4 w-4" />
|
||||
<Frame v-else class="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipWrapper>
|
||||
|
||||
<TooltipWrapper :content="t('dialog.image_crop.reset')">
|
||||
<Button
|
||||
size="icon-sm"
|
||||
variant="outline"
|
||||
class="rounded-full h-8 w-8"
|
||||
:disabled="loading"
|
||||
@click="handleReset">
|
||||
<RefreshCw class="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipWrapper>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
@@ -38,13 +143,27 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import {
|
||||
Expand,
|
||||
FlipHorizontal,
|
||||
FlipVertical,
|
||||
Frame,
|
||||
RefreshCw,
|
||||
RotateCcw,
|
||||
RotateCw,
|
||||
ZoomIn,
|
||||
ZoomOut
|
||||
} from 'lucide-vue-next';
|
||||
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||
import { ref, watch } from 'vue';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Cropper } from 'vue-advanced-cropper';
|
||||
import { Slider } from '@/components/ui/slider';
|
||||
import { Spinner } from '@/components/ui/spinner';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
import TooltipWrapper from '@/components/ui/tooltip/TooltipWrapper.vue';
|
||||
|
||||
import { useImageCropper } from '../../composables/useImageCropper';
|
||||
|
||||
import 'vue-advanced-cropper/dist/style.css';
|
||||
@@ -73,7 +192,16 @@
|
||||
const emit = defineEmits(['update:open', 'confirm']);
|
||||
|
||||
const loading = ref(false);
|
||||
// Attention: cropperRef is used
|
||||
const freeMode = ref(false);
|
||||
|
||||
const zoomSliderValue = ref([50]);
|
||||
const lastZoomRatio = ref(1);
|
||||
|
||||
const MIN_ZOOM_RATIO = 0.3;
|
||||
const MAX_ZOOM_RATIO = 5;
|
||||
const LOG_MIN = Math.log(MIN_ZOOM_RATIO);
|
||||
const LOG_MAX = Math.log(MAX_ZOOM_RATIO);
|
||||
|
||||
const { cropperRef, cropperImageSrc, resetCropState, loadImageForCrop, getCroppedBlob } = useImageCropper();
|
||||
|
||||
watch(
|
||||
@@ -90,16 +218,56 @@
|
||||
(open) => {
|
||||
if (!open) {
|
||||
loading.value = false;
|
||||
freeMode.value = false;
|
||||
zoomSliderValue.value = [50];
|
||||
lastZoomRatio.value = 1;
|
||||
resetCropState();
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* @param result
|
||||
*/
|
||||
function onCropperChange(result) {
|
||||
if (!result.visibleArea || !result.image) return;
|
||||
const ratio = result.image.width / result.visibleArea.width;
|
||||
lastZoomRatio.value = ratio;
|
||||
const normalized = ((Math.log(ratio) - LOG_MIN) / (LOG_MAX - LOG_MIN)) * 100;
|
||||
zoomSliderValue.value = [Math.max(0, Math.min(100, Math.round(normalized)))];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param value
|
||||
*/
|
||||
function onZoomCommit(value) {
|
||||
const target = value[0];
|
||||
const targetRatio = Math.exp(LOG_MIN + (target / 100) * (LOG_MAX - LOG_MIN));
|
||||
const factor = targetRatio / lastZoomRatio.value;
|
||||
cropperRef.value?.zoom(factor);
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
*/
|
||||
function handleReset() {
|
||||
freeMode.value = false;
|
||||
cropperRef.value?.reset();
|
||||
zoomSliderValue.value = [50];
|
||||
lastZoomRatio.value = 1;
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
*/
|
||||
function cancelCrop() {
|
||||
resetCropState();
|
||||
emit('update:open', false);
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
*/
|
||||
async function onConfirmCrop() {
|
||||
loading.value = true;
|
||||
try {
|
||||
|
||||
526
src/composables/__tests__/useImageCropper.test.js
Normal file
526
src/composables/__tests__/useImageCropper.test.js
Normal file
@@ -0,0 +1,526 @@
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
|
||||
|
||||
vi.mock('vue-sonner', () => ({
|
||||
toast: { error: vi.fn() }
|
||||
}));
|
||||
|
||||
vi.mock('vue-i18n', () => ({
|
||||
useI18n: () => ({ t: (key) => key })
|
||||
}));
|
||||
|
||||
import {
|
||||
applyTransforms,
|
||||
cropImage,
|
||||
useImageCropper
|
||||
} from '../useImageCropper';
|
||||
|
||||
// ─── Helpers ─────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
*
|
||||
* @param width
|
||||
* @param height
|
||||
*/
|
||||
function makeImage(width, height) {
|
||||
return { width, height };
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param root0
|
||||
* @param root0.left
|
||||
* @param root0.top
|
||||
* @param root0.width
|
||||
* @param root0.height
|
||||
* @param root0.imgW
|
||||
* @param root0.imgH
|
||||
* @param root0.rotate
|
||||
* @param root0.flipH
|
||||
* @param root0.flipV
|
||||
*/
|
||||
function makeCropperResult({
|
||||
left = 0,
|
||||
top = 0,
|
||||
width = 100,
|
||||
height = 75,
|
||||
imgW = 100,
|
||||
imgH = 75,
|
||||
rotate = 0,
|
||||
flipH = false,
|
||||
flipV = false
|
||||
} = {}) {
|
||||
return {
|
||||
coordinates: { left, top, width, height },
|
||||
image: {
|
||||
width: imgW,
|
||||
height: imgH,
|
||||
transforms: {
|
||||
rotate,
|
||||
flip: { horizontal: flipH, vertical: flipV }
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
let mockCtx;
|
||||
let canvasInstances;
|
||||
|
||||
/**
|
||||
*
|
||||
*/
|
||||
function setupCanvasMocks() {
|
||||
canvasInstances = [];
|
||||
mockCtx = {
|
||||
drawImage: vi.fn(),
|
||||
fillRect: vi.fn(),
|
||||
translate: vi.fn(),
|
||||
rotate: vi.fn(),
|
||||
scale: vi.fn(),
|
||||
fillStyle: ''
|
||||
};
|
||||
|
||||
const origCreateElement = document.createElement.bind(document);
|
||||
vi.spyOn(document, 'createElement').mockImplementation((tag) => {
|
||||
if (tag === 'canvas') {
|
||||
const canvas = {
|
||||
width: 0,
|
||||
height: 0,
|
||||
getContext: vi.fn(() => mockCtx),
|
||||
toDataURL: vi.fn(() => 'data:image/jpeg;base64,mock'),
|
||||
toBlob: vi.fn((cb) =>
|
||||
cb(new Blob(['mock'], { type: 'image/png' }))
|
||||
)
|
||||
};
|
||||
canvasInstances.push(canvas);
|
||||
return canvas;
|
||||
}
|
||||
return origCreateElement(tag);
|
||||
});
|
||||
|
||||
return { mockCtx, canvasInstances };
|
||||
}
|
||||
|
||||
// ─── useImageCropper composable ──────────────────────────────────────
|
||||
|
||||
describe('useImageCropper', () => {
|
||||
test('returns all expected properties', () => {
|
||||
const result = useImageCropper();
|
||||
expect(result).toHaveProperty('cropperRef');
|
||||
expect(result).toHaveProperty('cropperImageSrc');
|
||||
expect(result).toHaveProperty('resetCropState');
|
||||
expect(result).toHaveProperty('loadImageForCrop');
|
||||
expect(result).toHaveProperty('getCroppedBlob');
|
||||
});
|
||||
|
||||
test('initial state is correct', () => {
|
||||
const { cropperRef, cropperImageSrc } = useImageCropper();
|
||||
expect(cropperRef.value).toBeNull();
|
||||
expect(cropperImageSrc.value).toBe('');
|
||||
});
|
||||
|
||||
test('resetCropState clears all state', () => {
|
||||
const { cropperImageSrc, resetCropState } = useImageCropper();
|
||||
cropperImageSrc.value = 'data:image/png;base64,test';
|
||||
resetCropState();
|
||||
expect(cropperImageSrc.value).toBe('');
|
||||
});
|
||||
|
||||
test('getCroppedBlob returns null when cropperRef is null', async () => {
|
||||
const { getCroppedBlob } = useImageCropper();
|
||||
const result = await getCroppedBlob();
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
test('getCroppedBlob returns null when getResult returns no coordinates', async () => {
|
||||
const { getCroppedBlob, cropperRef } = useImageCropper();
|
||||
cropperRef.value = { getResult: () => ({}) };
|
||||
const result = await getCroppedBlob();
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── cropImage ───────────────────────────────────────────────────────
|
||||
|
||||
describe('cropImage', () => {
|
||||
beforeEach(() => {
|
||||
setupCanvasMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
test('returns null when no cropperResult', async () => {
|
||||
const result = await cropImage(makeImage(100, 75), 1, null);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
test('returns null when cropperResult has no coordinates', async () => {
|
||||
const result = await cropImage(makeImage(100, 75), 1, { image: {} });
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
test('returns null when no originalImage', async () => {
|
||||
const result = await cropImage(null, 1, makeCropperResult());
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
test('returns original file when no transforms and crop covers full image', async () => {
|
||||
const file = new File(['test'], 'test.png', { type: 'image/png' });
|
||||
const img = makeImage(200, 150);
|
||||
const cropResult = makeCropperResult({
|
||||
left: 0,
|
||||
top: 0,
|
||||
width: 200,
|
||||
height: 150,
|
||||
imgW: 200,
|
||||
imgH: 150
|
||||
});
|
||||
|
||||
const result = await cropImage(img, 1, cropResult, file);
|
||||
expect(result).toBe(file);
|
||||
});
|
||||
|
||||
test('returns original file when crop is within 1px tolerance', async () => {
|
||||
const file = new File(['test'], 'test.png', { type: 'image/png' });
|
||||
const img = makeImage(200, 150);
|
||||
const cropResult = makeCropperResult({
|
||||
left: 1,
|
||||
top: 1,
|
||||
width: 199,
|
||||
height: 149,
|
||||
imgW: 200,
|
||||
imgH: 150
|
||||
});
|
||||
|
||||
const result = await cropImage(img, 1, cropResult, file);
|
||||
expect(result).toBe(file);
|
||||
});
|
||||
|
||||
test('does NOT return original file when crop is partial', async () => {
|
||||
const file = new File(['test'], 'test.png', { type: 'image/png' });
|
||||
const img = makeImage(200, 150);
|
||||
const cropResult = makeCropperResult({
|
||||
left: 10,
|
||||
top: 10,
|
||||
width: 100,
|
||||
height: 75,
|
||||
imgW: 200,
|
||||
imgH: 150
|
||||
});
|
||||
|
||||
const result = await cropImage(img, 1, cropResult, file);
|
||||
expect(result).not.toBe(file);
|
||||
expect(result).toBeInstanceOf(Blob);
|
||||
});
|
||||
|
||||
test('does NOT return original file when transforms are applied', async () => {
|
||||
const file = new File(['test'], 'test.png', { type: 'image/png' });
|
||||
const img = makeImage(200, 150);
|
||||
const cropResult = makeCropperResult({
|
||||
left: 0,
|
||||
top: 0,
|
||||
width: 200,
|
||||
height: 150,
|
||||
imgW: 200,
|
||||
imgH: 150,
|
||||
rotate: 90
|
||||
});
|
||||
|
||||
const result = await cropImage(img, 1, cropResult, file);
|
||||
expect(result).not.toBe(file);
|
||||
expect(result).toBeInstanceOf(Blob);
|
||||
});
|
||||
|
||||
test('respects previewScale when computing crop dimensions', async () => {
|
||||
const img = makeImage(1600, 1200);
|
||||
const cropResult = makeCropperResult({
|
||||
left: 50,
|
||||
top: 50,
|
||||
width: 200,
|
||||
height: 150,
|
||||
imgW: 400,
|
||||
imgH: 300
|
||||
});
|
||||
|
||||
await cropImage(img, 0.25, cropResult);
|
||||
|
||||
// cropW = 200 / 0.25 = 800, cropH = 150 / 0.25 = 600
|
||||
const cropCanvas = canvasInstances[canvasInstances.length - 1];
|
||||
expect(cropCanvas.width).toBe(800);
|
||||
expect(cropCanvas.height).toBe(600);
|
||||
});
|
||||
|
||||
test('fills canvas with white before drawing', async () => {
|
||||
const img = makeImage(200, 150);
|
||||
const cropResult = makeCropperResult({
|
||||
left: 10,
|
||||
top: 10,
|
||||
width: 50,
|
||||
height: 50,
|
||||
imgW: 200,
|
||||
imgH: 150
|
||||
});
|
||||
|
||||
await cropImage(img, 1, cropResult);
|
||||
|
||||
expect(mockCtx.fillStyle).toBe('#ffffff');
|
||||
expect(mockCtx.fillRect).toHaveBeenCalledWith(0, 0, 50, 50);
|
||||
});
|
||||
|
||||
test('draws image with negative crop offset', async () => {
|
||||
const img = makeImage(200, 150);
|
||||
const cropResult = makeCropperResult({
|
||||
left: 10,
|
||||
top: 20,
|
||||
width: 100,
|
||||
height: 75,
|
||||
imgW: 200,
|
||||
imgH: 150
|
||||
});
|
||||
|
||||
await cropImage(img, 1, cropResult);
|
||||
|
||||
expect(mockCtx.drawImage).toHaveBeenCalledWith(img, -10, -20);
|
||||
});
|
||||
|
||||
test('creates canvas via toBlob with image/png format', async () => {
|
||||
const img = makeImage(200, 150);
|
||||
const cropResult = makeCropperResult({
|
||||
left: 10,
|
||||
top: 10,
|
||||
width: 50,
|
||||
height: 50,
|
||||
imgW: 200,
|
||||
imgH: 150
|
||||
});
|
||||
|
||||
const result = await cropImage(img, 1, cropResult);
|
||||
|
||||
const cropCanvas = canvasInstances[canvasInstances.length - 1];
|
||||
expect(cropCanvas.toBlob).toHaveBeenCalledWith(
|
||||
expect.any(Function),
|
||||
'image/png'
|
||||
);
|
||||
expect(result).toBeInstanceOf(Blob);
|
||||
});
|
||||
|
||||
test('does not return original file when no file parameter provided', async () => {
|
||||
const img = makeImage(200, 150);
|
||||
const cropResult = makeCropperResult({
|
||||
left: 0,
|
||||
top: 0,
|
||||
width: 200,
|
||||
height: 150,
|
||||
imgW: 200,
|
||||
imgH: 150
|
||||
});
|
||||
|
||||
const result = await cropImage(img, 1, cropResult);
|
||||
// No file passed, so it should crop even if covering full image
|
||||
expect(result).toBeInstanceOf(Blob);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── cropImage with transforms ───────────────────────────────────────
|
||||
|
||||
describe('cropImage with transforms', () => {
|
||||
beforeEach(() => {
|
||||
setupCanvasMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
test('creates intermediate transform canvas for rotation', async () => {
|
||||
const img = makeImage(200, 150);
|
||||
const cropResult = makeCropperResult({
|
||||
left: 0,
|
||||
top: 0,
|
||||
width: 150,
|
||||
height: 200,
|
||||
imgW: 200,
|
||||
imgH: 150,
|
||||
rotate: 90
|
||||
});
|
||||
|
||||
await cropImage(img, 1, cropResult);
|
||||
|
||||
// Two canvases: one for transform, one for crop
|
||||
expect(canvasInstances.length).toBe(2);
|
||||
|
||||
// Transform canvas dimensions for 90° rotation: W×H -> H×W
|
||||
const transformCanvas = canvasInstances[0];
|
||||
expect(transformCanvas.width).toBe(150); // height becomes width
|
||||
expect(transformCanvas.height).toBe(200); // width becomes height
|
||||
});
|
||||
|
||||
test('applies rotation transform on intermediate canvas', async () => {
|
||||
const img = makeImage(200, 150);
|
||||
const cropResult = makeCropperResult({
|
||||
left: 0,
|
||||
top: 0,
|
||||
width: 150,
|
||||
height: 200,
|
||||
imgW: 200,
|
||||
imgH: 150,
|
||||
rotate: 90
|
||||
});
|
||||
|
||||
await cropImage(img, 1, cropResult);
|
||||
|
||||
expect(mockCtx.translate).toHaveBeenCalled();
|
||||
expect(mockCtx.rotate).toHaveBeenCalledWith(Math.PI / 2);
|
||||
});
|
||||
|
||||
test('applies horizontal flip via scale(-1, 1)', async () => {
|
||||
const img = makeImage(200, 150);
|
||||
const cropResult = makeCropperResult({
|
||||
left: 0,
|
||||
top: 0,
|
||||
width: 200,
|
||||
height: 150,
|
||||
imgW: 200,
|
||||
imgH: 150,
|
||||
flipH: true
|
||||
});
|
||||
|
||||
await cropImage(img, 1, cropResult);
|
||||
|
||||
expect(mockCtx.scale).toHaveBeenCalledWith(-1, 1);
|
||||
});
|
||||
|
||||
test('applies vertical flip via scale(1, -1)', async () => {
|
||||
const img = makeImage(200, 150);
|
||||
const cropResult = makeCropperResult({
|
||||
left: 0,
|
||||
top: 0,
|
||||
width: 200,
|
||||
height: 150,
|
||||
imgW: 200,
|
||||
imgH: 150,
|
||||
flipV: true
|
||||
});
|
||||
|
||||
await cropImage(img, 1, cropResult);
|
||||
|
||||
expect(mockCtx.scale).toHaveBeenCalledWith(1, -1);
|
||||
});
|
||||
|
||||
test('applies both flips together', async () => {
|
||||
const img = makeImage(200, 150);
|
||||
const cropResult = makeCropperResult({
|
||||
left: 0,
|
||||
top: 0,
|
||||
width: 200,
|
||||
height: 150,
|
||||
imgW: 200,
|
||||
imgH: 150,
|
||||
flipH: true,
|
||||
flipV: true
|
||||
});
|
||||
|
||||
await cropImage(img, 1, cropResult);
|
||||
|
||||
expect(mockCtx.scale).toHaveBeenCalledWith(-1, 1);
|
||||
expect(mockCtx.scale).toHaveBeenCalledWith(1, -1);
|
||||
});
|
||||
|
||||
test('does not create transform canvas when no transforms', async () => {
|
||||
const img = makeImage(200, 150);
|
||||
const cropResult = makeCropperResult({
|
||||
left: 10,
|
||||
top: 10,
|
||||
width: 50,
|
||||
height: 50,
|
||||
imgW: 200,
|
||||
imgH: 150
|
||||
});
|
||||
|
||||
await cropImage(img, 1, cropResult);
|
||||
|
||||
// Only one canvas: the crop canvas, no transform canvas
|
||||
expect(canvasInstances.length).toBe(1);
|
||||
expect(mockCtx.translate).not.toHaveBeenCalled();
|
||||
expect(mockCtx.rotate).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── applyTransforms ─────────────────────────────────────────────────
|
||||
|
||||
describe('applyTransforms', () => {
|
||||
beforeEach(() => {
|
||||
setupCanvasMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
test('returns canvas with swapped dimensions for 90° rotation', () => {
|
||||
const img = makeImage(200, 150);
|
||||
const result = applyTransforms(img, 90, false, false);
|
||||
expect(result.width).toBe(150);
|
||||
expect(result.height).toBe(200);
|
||||
});
|
||||
|
||||
test('returns canvas with same dimensions for 180° rotation', () => {
|
||||
const img = makeImage(200, 150);
|
||||
const result = applyTransforms(img, 180, false, false);
|
||||
expect(result.width).toBe(200);
|
||||
expect(result.height).toBe(150);
|
||||
});
|
||||
|
||||
test('returns canvas with swapped dimensions for 270° rotation', () => {
|
||||
const img = makeImage(200, 150);
|
||||
const result = applyTransforms(img, 270, false, false);
|
||||
expect(result.width).toBe(150);
|
||||
expect(result.height).toBe(200);
|
||||
});
|
||||
|
||||
test('returns canvas with same dimensions for 0° rotation', () => {
|
||||
const img = makeImage(200, 150);
|
||||
const result = applyTransforms(img, 0, false, false);
|
||||
expect(result.width).toBe(200);
|
||||
expect(result.height).toBe(150);
|
||||
});
|
||||
|
||||
test('calls translate to center of canvas', () => {
|
||||
const img = makeImage(200, 150);
|
||||
applyTransforms(img, 90, false, false);
|
||||
// For 90°: rotW=150, rotH=200
|
||||
expect(mockCtx.translate).toHaveBeenCalledWith(75, 100);
|
||||
});
|
||||
|
||||
test('calls scale for horizontal flip', () => {
|
||||
const img = makeImage(200, 150);
|
||||
applyTransforms(img, 0, true, false);
|
||||
expect(mockCtx.scale).toHaveBeenCalledWith(-1, 1);
|
||||
});
|
||||
|
||||
test('calls scale for vertical flip', () => {
|
||||
const img = makeImage(200, 150);
|
||||
applyTransforms(img, 0, false, true);
|
||||
expect(mockCtx.scale).toHaveBeenCalledWith(1, -1);
|
||||
});
|
||||
|
||||
test('does not call scale when no flips', () => {
|
||||
const img = makeImage(200, 150);
|
||||
applyTransforms(img, 90, false, false);
|
||||
expect(mockCtx.scale).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('draws image centered at origin', () => {
|
||||
const img = makeImage(200, 150);
|
||||
applyTransforms(img, 0, false, false);
|
||||
expect(mockCtx.drawImage).toHaveBeenCalledWith(img, -100, -75);
|
||||
});
|
||||
|
||||
test('handles square image correctly', () => {
|
||||
const img = makeImage(100, 100);
|
||||
const result = applyTransforms(img, 90, false, false);
|
||||
expect(result.width).toBe(100);
|
||||
expect(result.height).toBe(100);
|
||||
});
|
||||
});
|
||||
@@ -4,6 +4,98 @@ import { useI18n } from 'vue-i18n';
|
||||
|
||||
const MAX_PREVIEW_SIZE = 800;
|
||||
|
||||
/**
|
||||
* @param {HTMLImageElement|HTMLCanvasElement} img
|
||||
* @param {number} angleDeg
|
||||
* @param {boolean} flipH
|
||||
* @param {boolean} flipV
|
||||
* @returns {HTMLCanvasElement}
|
||||
*/
|
||||
export function applyTransforms(img, angleDeg, flipH, flipV) {
|
||||
const angleRad = (angleDeg * Math.PI) / 180;
|
||||
const absCos = Math.abs(Math.cos(angleRad));
|
||||
const absSin = Math.abs(Math.sin(angleRad));
|
||||
|
||||
const rotW = Math.round(img.width * absCos + img.height * absSin);
|
||||
const rotH = Math.round(img.width * absSin + img.height * absCos);
|
||||
|
||||
const cvs = document.createElement('canvas');
|
||||
cvs.width = rotW;
|
||||
cvs.height = rotH;
|
||||
const ctx = cvs.getContext('2d');
|
||||
|
||||
ctx.translate(rotW / 2, rotH / 2);
|
||||
ctx.rotate(angleRad);
|
||||
if (flipH) ctx.scale(-1, 1);
|
||||
if (flipV) ctx.scale(1, -1);
|
||||
ctx.drawImage(img, -img.width / 2, -img.height / 2);
|
||||
|
||||
return cvs;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {HTMLImageElement|HTMLCanvasElement} originalImage
|
||||
* @param {number} previewScale
|
||||
* @param {{ coordinates: object, image: object }} cropperResult
|
||||
* @param {File} [originalFile]
|
||||
* @returns {Promise<Blob|null>}
|
||||
*/
|
||||
export function cropImage(
|
||||
originalImage,
|
||||
previewScale,
|
||||
cropperResult,
|
||||
originalFile
|
||||
) {
|
||||
if (!cropperResult?.coordinates || !originalImage) {
|
||||
return Promise.resolve(null);
|
||||
}
|
||||
|
||||
const { coordinates } = cropperResult;
|
||||
const scale = previewScale;
|
||||
const transforms = cropperResult.image?.transforms || {};
|
||||
const angle = transforms.rotate || 0;
|
||||
const flipH = transforms.flip?.horizontal || false;
|
||||
const flipV = transforms.flip?.vertical || false;
|
||||
const hasTransform = angle !== 0 || flipH || flipV;
|
||||
|
||||
const cropX = Math.round(coordinates.left / scale);
|
||||
const cropY = Math.round(coordinates.top / scale);
|
||||
const cropW = Math.round(coordinates.width / scale);
|
||||
const cropH = Math.round(coordinates.height / scale);
|
||||
|
||||
if (!hasTransform) {
|
||||
const noCrop =
|
||||
cropX <= 1 &&
|
||||
cropY <= 1 &&
|
||||
Math.abs(cropW - originalImage.width) <= 1 &&
|
||||
Math.abs(cropH - originalImage.height) <= 1;
|
||||
if (noCrop && originalFile) {
|
||||
return Promise.resolve(originalFile);
|
||||
}
|
||||
}
|
||||
|
||||
const source = hasTransform
|
||||
? applyTransforms(originalImage, angle, flipH, flipV)
|
||||
: originalImage;
|
||||
|
||||
const cropCanvas = document.createElement('canvas');
|
||||
cropCanvas.width = cropW;
|
||||
cropCanvas.height = cropH;
|
||||
const ctx = cropCanvas.getContext('2d');
|
||||
|
||||
ctx.fillStyle = '#ffffff';
|
||||
ctx.fillRect(0, 0, cropW, cropH);
|
||||
|
||||
ctx.drawImage(source, -cropX, -cropY);
|
||||
|
||||
return new Promise((resolve) => {
|
||||
cropCanvas.toBlob(resolve, 'image/png');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
*/
|
||||
export function useImageCropper() {
|
||||
const { t } = useI18n();
|
||||
|
||||
@@ -12,6 +104,9 @@ export function useImageCropper() {
|
||||
const originalImage = ref(null);
|
||||
const previewScale = ref(1);
|
||||
|
||||
/**
|
||||
*
|
||||
*/
|
||||
function resetCropState() {
|
||||
cropperImageSrc.value = '';
|
||||
originalImage.value = null;
|
||||
@@ -19,7 +114,7 @@ export function useImageCropper() {
|
||||
}
|
||||
|
||||
/**
|
||||
* Downscaling for preview
|
||||
* Downscale for interactive preview, keep original for final crop.
|
||||
* @param {File} file
|
||||
*/
|
||||
function loadImageForCrop(file) {
|
||||
@@ -68,38 +163,12 @@ export function useImageCropper() {
|
||||
*/
|
||||
function getCroppedBlob(originalFile) {
|
||||
const result = cropperRef.value?.getResult();
|
||||
if (!result?.coordinates || !originalImage.value) {
|
||||
return Promise.resolve(null);
|
||||
}
|
||||
|
||||
const { coordinates } = result;
|
||||
const scale = previewScale.value;
|
||||
const srcX = Math.round(coordinates.left / scale);
|
||||
const srcY = Math.round(coordinates.top / scale);
|
||||
const srcW = Math.round(coordinates.width / scale);
|
||||
const srcH = Math.round(coordinates.height / scale);
|
||||
|
||||
const img = originalImage.value;
|
||||
const noCrop =
|
||||
srcX <= 1 &&
|
||||
srcY <= 1 &&
|
||||
Math.abs(srcW - img.width) <= 1 &&
|
||||
Math.abs(srcH - img.height) <= 1;
|
||||
|
||||
// pass no crop
|
||||
if (noCrop && originalFile) {
|
||||
return Promise.resolve(originalFile);
|
||||
}
|
||||
|
||||
const cropCanvas = document.createElement('canvas');
|
||||
cropCanvas.width = srcW;
|
||||
cropCanvas.height = srcH;
|
||||
const ctx = cropCanvas.getContext('2d');
|
||||
ctx.drawImage(img, srcX, srcY, srcW, srcH, 0, 0, srcW, srcH);
|
||||
|
||||
return new Promise((resolve) => {
|
||||
cropCanvas.toBlob(resolve, 'image/png');
|
||||
});
|
||||
return cropImage(
|
||||
originalImage.value,
|
||||
previewScale.value,
|
||||
result,
|
||||
originalFile
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@@ -1736,6 +1736,17 @@
|
||||
"create_animated_emoji": "Animated Emoji Generator",
|
||||
"crop_image": "Confirm Upload"
|
||||
},
|
||||
"image_crop": {
|
||||
"rotate_left": "Rotate Left",
|
||||
"rotate_right": "Rotate Right",
|
||||
"flip_h": "Flip Horizontal",
|
||||
"flip_v": "Flip Vertical",
|
||||
"zoom_in": "Zoom In",
|
||||
"zoom_out": "Zoom Out",
|
||||
"mode_fit": "Switch to Fit Mode",
|
||||
"mode_free": "Switch to Free Mode",
|
||||
"reset": "Reset"
|
||||
},
|
||||
"gallery_select": {
|
||||
"header": "Select Image",
|
||||
"gallery": "Photos",
|
||||
|
||||
Reference in New Issue
Block a user