mirror of
https://github.com/MrUnknownDE/VRCX.git
synced 2026-05-07 06:56:04 +02:00
improve image cropper
This commit is contained in:
@@ -19,7 +19,112 @@
|
|||||||
:stencil-props="{ aspectRatio, movable: !loading, resizable: !loading }"
|
:stencil-props="{ aspectRatio, movable: !loading, resizable: !loading }"
|
||||||
:move-image="!loading"
|
:move-image="!loading"
|
||||||
:resize-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>
|
</div>
|
||||||
|
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
@@ -38,13 +143,27 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<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 { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||||
import { ref, watch } from 'vue';
|
import { ref, watch } from 'vue';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Cropper } from 'vue-advanced-cropper';
|
import { Cropper } from 'vue-advanced-cropper';
|
||||||
|
import { Slider } from '@/components/ui/slider';
|
||||||
import { Spinner } from '@/components/ui/spinner';
|
import { Spinner } from '@/components/ui/spinner';
|
||||||
import { useI18n } from 'vue-i18n';
|
import { useI18n } from 'vue-i18n';
|
||||||
|
|
||||||
|
import TooltipWrapper from '@/components/ui/tooltip/TooltipWrapper.vue';
|
||||||
|
|
||||||
import { useImageCropper } from '../../composables/useImageCropper';
|
import { useImageCropper } from '../../composables/useImageCropper';
|
||||||
|
|
||||||
import 'vue-advanced-cropper/dist/style.css';
|
import 'vue-advanced-cropper/dist/style.css';
|
||||||
@@ -73,7 +192,16 @@
|
|||||||
const emit = defineEmits(['update:open', 'confirm']);
|
const emit = defineEmits(['update:open', 'confirm']);
|
||||||
|
|
||||||
const loading = ref(false);
|
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();
|
const { cropperRef, cropperImageSrc, resetCropState, loadImageForCrop, getCroppedBlob } = useImageCropper();
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
@@ -90,16 +218,56 @@
|
|||||||
(open) => {
|
(open) => {
|
||||||
if (!open) {
|
if (!open) {
|
||||||
loading.value = false;
|
loading.value = false;
|
||||||
|
freeMode.value = false;
|
||||||
|
zoomSliderValue.value = [50];
|
||||||
|
lastZoomRatio.value = 1;
|
||||||
resetCropState();
|
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() {
|
function cancelCrop() {
|
||||||
resetCropState();
|
resetCropState();
|
||||||
emit('update:open', false);
|
emit('update:open', false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
*/
|
||||||
async function onConfirmCrop() {
|
async function onConfirmCrop() {
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -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;
|
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() {
|
export function useImageCropper() {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
|
|
||||||
@@ -12,6 +104,9 @@ export function useImageCropper() {
|
|||||||
const originalImage = ref(null);
|
const originalImage = ref(null);
|
||||||
const previewScale = ref(1);
|
const previewScale = ref(1);
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
*/
|
||||||
function resetCropState() {
|
function resetCropState() {
|
||||||
cropperImageSrc.value = '';
|
cropperImageSrc.value = '';
|
||||||
originalImage.value = null;
|
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
|
* @param {File} file
|
||||||
*/
|
*/
|
||||||
function loadImageForCrop(file) {
|
function loadImageForCrop(file) {
|
||||||
@@ -68,38 +163,12 @@ export function useImageCropper() {
|
|||||||
*/
|
*/
|
||||||
function getCroppedBlob(originalFile) {
|
function getCroppedBlob(originalFile) {
|
||||||
const result = cropperRef.value?.getResult();
|
const result = cropperRef.value?.getResult();
|
||||||
if (!result?.coordinates || !originalImage.value) {
|
return cropImage(
|
||||||
return Promise.resolve(null);
|
originalImage.value,
|
||||||
}
|
previewScale.value,
|
||||||
|
result,
|
||||||
const { coordinates } = result;
|
originalFile
|
||||||
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 {
|
return {
|
||||||
|
|||||||
@@ -1736,6 +1736,17 @@
|
|||||||
"create_animated_emoji": "Animated Emoji Generator",
|
"create_animated_emoji": "Animated Emoji Generator",
|
||||||
"crop_image": "Confirm Upload"
|
"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": {
|
"gallery_select": {
|
||||||
"header": "Select Image",
|
"header": "Select Image",
|
||||||
"gallery": "Photos",
|
"gallery": "Photos",
|
||||||
|
|||||||
Reference in New Issue
Block a user