improve image cropper

This commit is contained in:
pa
2026-03-08 18:12:27 +09:00
parent 97c79bef78
commit 729793dda2
4 changed files with 809 additions and 35 deletions

View File

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

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

View File

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

View File

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