trypost/resources/js/components/ImageCropperDialog.vue
Paulo Castellano 36ccc570e0 Rewrite the image cropper as a movable/resizable selection box
Replace the move-image-behind-a-fixed-frame model with a selection box over the fully visible (contain-fit) image: drag to move, corner handles to resize, locked to 1:1 for the square avatar/logo output. The default selection is inset so the crop outline is always visible, handles sit inside the frame so they are never clipped, and wheel/pinch zoom is swallowed. Reset drag state on open/close, guard pointer capture and multi-touch, and fail-safe the canvas encode. Update the crop copy in all 15 locales to match the selection model.
2026-07-04 09:56:31 -03:00

368 lines
11 KiB
Vue

<script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, ref, watch } from 'vue';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import {
clampSelection,
containScale,
type Corner,
defaultSelection,
resizeSelection,
resolveOutputFileName,
resolveOutputMime,
type SourceRect,
} from '@/lib/imageCrop';
type Props = {
open: boolean;
src: string | null;
fileName?: string;
mimeType?: string;
outputSize?: number;
};
const props = withDefaults(defineProps<Props>(), {
fileName: 'image.png',
mimeType: 'image/png',
outputSize: 512,
});
const emit = defineEmits<{
(e: 'update:open', value: boolean): void;
(e: 'cropped', file: File): void;
}>();
const viewportEl = ref<HTMLElement | null>(null);
const imageEl = ref<HTMLImageElement | null>(null);
const viewportSize = ref(0);
const natural = ref({ width: 0, height: 0 });
const selection = ref<SourceRect>({ sx: 0, sy: 0, sw: 0, sh: 0 });
const processing = ref(false);
const initialized = ref(false);
const imageError = ref(false);
const MIN_SELECTION_RATIO = 0.1;
let dragMode: 'move' | Corner | null = null;
let activePointerId: number | null = null;
let dragStart = { pointerX: 0, pointerY: 0, selection: { sx: 0, sy: 0, sw: 0, sh: 0 } as SourceRect };
let resizeObserver: ResizeObserver | null = null;
const ready = computed(() => viewportSize.value > 0 && natural.value.width > 0);
const scale = computed(() => containScale(natural.value.width, natural.value.height, viewportSize.value));
const minSourceSize = computed(() => Math.min(natural.value.width, natural.value.height) * MIN_SELECTION_RATIO);
const imageDisplay = computed(() => {
const width = natural.value.width * scale.value;
const height = natural.value.height * scale.value;
return {
width,
height,
left: (viewportSize.value - width) / 2,
top: (viewportSize.value - height) / 2,
};
});
const imageStyle = computed(() => ({
left: `${imageDisplay.value.left}px`,
top: `${imageDisplay.value.top}px`,
width: `${imageDisplay.value.width}px`,
height: `${imageDisplay.value.height}px`,
}));
const selectionStyle = computed(() => ({
left: `${imageDisplay.value.left + selection.value.sx * scale.value}px`,
top: `${imageDisplay.value.top + selection.value.sy * scale.value}px`,
width: `${selection.value.sw * scale.value}px`,
height: `${selection.value.sh * scale.value}px`,
boxShadow: '0 0 0 9999px rgba(0, 0, 0, 0.5)',
}));
const outputMime = computed(() => resolveOutputMime(props.mimeType));
const outputFileName = computed(() => resolveOutputFileName(props.fileName, outputMime.value));
const maybeInitialize = () => {
if (!ready.value || initialized.value) {
return;
}
selection.value = defaultSelection(natural.value.width, natural.value.height);
initialized.value = true;
};
const measure = () => {
const el = viewportEl.value;
if (!el) {
return;
}
viewportSize.value = el.clientWidth;
maybeInitialize();
};
const onImageLoad = () => {
const img = imageEl.value;
if (!img) {
return;
}
if (img.naturalWidth === 0 || img.naturalHeight === 0) {
imageError.value = true;
return;
}
natural.value = { width: img.naturalWidth, height: img.naturalHeight };
maybeInitialize();
};
const onImageError = () => {
imageError.value = true;
};
const sourcePoint = (clientX: number, clientY: number): { px: number; py: number } => {
const box = viewportEl.value!.getBoundingClientRect();
return {
px: (clientX - box.left - imageDisplay.value.left) / scale.value,
py: (clientY - box.top - imageDisplay.value.top) / scale.value,
};
};
const beginDrag = (mode: 'move' | Corner, event: PointerEvent) => {
if (!ready.value || dragMode) {
return;
}
dragMode = mode;
activePointerId = event.pointerId;
dragStart = { pointerX: event.clientX, pointerY: event.clientY, selection: { ...selection.value } };
viewportEl.value?.setPointerCapture(event.pointerId);
};
const endDrag = (event: PointerEvent) => {
dragMode = null;
activePointerId = null;
if (viewportEl.value?.hasPointerCapture(event.pointerId)) {
viewportEl.value.releasePointerCapture(event.pointerId);
}
};
const onSelectionPointerDown = (event: PointerEvent) => {
beginDrag('move', event);
};
const onHandlePointerDown = (corner: Corner, event: PointerEvent) => {
beginDrag(corner, event);
};
const onPointerMove = (event: PointerEvent) => {
if (!dragMode || event.pointerId !== activePointerId) {
return;
}
if (dragMode === 'move') {
selection.value = clampSelection(
{
sx: dragStart.selection.sx + (event.clientX - dragStart.pointerX) / scale.value,
sy: dragStart.selection.sy + (event.clientY - dragStart.pointerY) / scale.value,
sw: dragStart.selection.sw,
sh: dragStart.selection.sh,
},
natural.value.width,
natural.value.height,
minSourceSize.value,
);
return;
}
const { px, py } = sourcePoint(event.clientX, event.clientY);
selection.value = resizeSelection(
dragStart.selection,
dragMode,
px,
py,
natural.value.width,
natural.value.height,
minSourceSize.value,
);
};
const onPointerUp = (event: PointerEvent) => {
if (!dragMode || event.pointerId !== activePointerId) {
return;
}
endDrag(event);
};
const close = () => {
emit('update:open', false);
};
const save = () => {
const img = imageEl.value;
if (!img || !ready.value) {
return;
}
const canvas = document.createElement('canvas');
canvas.width = props.outputSize;
canvas.height = props.outputSize;
const context = canvas.getContext('2d');
if (!context) {
return;
}
const rect = selection.value;
processing.value = true;
try {
context.drawImage(img, rect.sx, rect.sy, rect.sw, rect.sh, 0, 0, props.outputSize, props.outputSize);
canvas.toBlob(
(blob) => {
processing.value = false;
if (!blob) {
return;
}
emit('cropped', new File([blob], outputFileName.value, { type: outputMime.value }));
close();
},
outputMime.value,
0.92,
);
} catch {
processing.value = false;
}
};
watch(
() => props.open,
async (isOpen) => {
if (isOpen) {
initialized.value = false;
processing.value = false;
imageError.value = false;
dragMode = null;
activePointerId = null;
await nextTick();
measure();
if (viewportEl.value && !resizeObserver) {
resizeObserver = new ResizeObserver(() => measure());
resizeObserver.observe(viewportEl.value);
}
} else {
dragMode = null;
activePointerId = null;
resizeObserver?.disconnect();
resizeObserver = null;
}
},
);
watch(
() => props.src,
() => {
initialized.value = false;
imageError.value = false;
natural.value = { width: 0, height: 0 };
},
);
onBeforeUnmount(() => resizeObserver?.disconnect());
</script>
<template>
<Dialog :open="open" @update:open="emit('update:open', $event)">
<DialogContent class="sm:max-w-lg">
<DialogHeader>
<DialogTitle>{{ $t('common.photo_upload.crop_title') }}</DialogTitle>
<DialogDescription>{{ $t('common.photo_upload.crop_description') }}</DialogDescription>
</DialogHeader>
<div
ref="viewportEl"
class="relative aspect-square w-full touch-none select-none overflow-hidden rounded-xl border-2 border-foreground bg-muted"
@pointermove="onPointerMove"
@pointerup="onPointerUp"
@pointercancel="onPointerUp"
@wheel.prevent
>
<img
v-if="src && !imageError"
ref="imageEl"
:src="src"
alt=""
draggable="false"
class="pointer-events-none absolute max-w-none"
:style="imageStyle"
@load="onImageLoad"
@error="onImageError"
/>
<div
v-if="imageError"
class="absolute inset-0 flex items-center justify-center p-4 text-center text-sm text-muted-foreground"
>
{{ $t('common.photo_upload.crop_error') }}
</div>
<div
v-else-if="ready"
class="absolute cursor-move"
:style="selectionStyle"
@pointerdown="onSelectionPointerDown"
>
<div class="pointer-events-none absolute inset-0 border-2 border-white shadow-[0_0_0_1px_rgba(0,0,0,0.4)]" />
<span
class="absolute left-0 top-0 size-3 cursor-nwse-resize rounded-sm border border-foreground bg-white"
@pointerdown.stop="onHandlePointerDown('nw', $event)"
/>
<span
class="absolute right-0 top-0 size-3 cursor-nesw-resize rounded-sm border border-foreground bg-white"
@pointerdown.stop="onHandlePointerDown('ne', $event)"
/>
<span
class="absolute bottom-0 left-0 size-3 cursor-nesw-resize rounded-sm border border-foreground bg-white"
@pointerdown.stop="onHandlePointerDown('sw', $event)"
/>
<span
class="absolute bottom-0 right-0 size-3 cursor-nwse-resize rounded-sm border border-foreground bg-white"
@pointerdown.stop="onHandlePointerDown('se', $event)"
/>
</div>
</div>
<p class="text-center text-xs text-muted-foreground">{{ $t('common.photo_upload.crop_hint') }}</p>
<DialogFooter>
<Button type="button" data-testid="crop-save" :disabled="processing || !ready" @click="save">
{{ $t('common.photo_upload.crop_save') }}
</Button>
<Button type="button" variant="outline" @click="close">
{{ $t('common.photo_upload.crop_cancel') }}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</template>