357 lines
9.7 KiB
Vue
357 lines
9.7 KiB
Vue
<script setup>
|
|
import { computed, onBeforeUnmount, ref, watch } from 'vue';
|
|
import { Cropper } from 'vue-advanced-cropper';
|
|
import 'vue-advanced-cropper/dist/style.css';
|
|
|
|
const props = defineProps({
|
|
modelValue: {
|
|
type: Boolean,
|
|
default: false,
|
|
},
|
|
title: {
|
|
type: String,
|
|
default: 'Edit image',
|
|
},
|
|
confirmLabel: {
|
|
type: String,
|
|
default: 'Save image',
|
|
},
|
|
aspectRatio: {
|
|
type: Number,
|
|
default: 1,
|
|
},
|
|
uploadLabel: {
|
|
type: String,
|
|
default: 'Choose image',
|
|
},
|
|
isSaving: {
|
|
type: Boolean,
|
|
default: false,
|
|
},
|
|
initialUrl: {
|
|
type: String,
|
|
default: '',
|
|
},
|
|
sourceLabel: {
|
|
type: String,
|
|
default: 'Image URL',
|
|
},
|
|
loadLabel: {
|
|
type: String,
|
|
default: 'Load URL',
|
|
},
|
|
});
|
|
|
|
const emit = defineEmits(['update:modelValue', 'save']);
|
|
|
|
const cropper = ref(null);
|
|
const imageUrl = ref(null);
|
|
const remoteUrl = ref('');
|
|
const error = ref(null);
|
|
|
|
const isReady = computed(() => Boolean(imageUrl.value));
|
|
|
|
function closeDialog() {
|
|
emit('update:modelValue', false);
|
|
}
|
|
|
|
function revokeImageUrl() {
|
|
if (imageUrl.value?.startsWith('blob:')) {
|
|
URL.revokeObjectURL(imageUrl.value);
|
|
}
|
|
}
|
|
|
|
function resetState() {
|
|
revokeImageUrl();
|
|
imageUrl.value = props.initialUrl || null;
|
|
remoteUrl.value = props.initialUrl || '';
|
|
error.value = null;
|
|
}
|
|
|
|
function onFileSelected(value) {
|
|
const file = Array.isArray(value) ? value[0] : value;
|
|
|
|
if (!file) {
|
|
return;
|
|
}
|
|
|
|
revokeImageUrl();
|
|
imageUrl.value = URL.createObjectURL(file);
|
|
error.value = null;
|
|
}
|
|
|
|
function loadImageFromUrl() {
|
|
if (!remoteUrl.value) {
|
|
error.value = 'Enter an image URL before loading it.';
|
|
return;
|
|
}
|
|
|
|
revokeImageUrl();
|
|
imageUrl.value = remoteUrl.value;
|
|
error.value = null;
|
|
}
|
|
|
|
function zoom(factor) {
|
|
cropper.value?.zoom(factor);
|
|
}
|
|
|
|
function rotate(angle) {
|
|
cropper.value?.rotate(angle);
|
|
}
|
|
|
|
async function saveCrop() {
|
|
const result = cropper.value?.getResult();
|
|
const canvas = result?.canvas;
|
|
|
|
if (!canvas) {
|
|
error.value = 'Select an image before saving.';
|
|
return;
|
|
}
|
|
|
|
const blob = await new Promise(resolve => canvas.toBlob(resolve, 'image/png', 0.92));
|
|
|
|
if (!blob) {
|
|
error.value = 'The edited image could not be prepared.';
|
|
return;
|
|
}
|
|
|
|
const file = new File([blob], 'cropped-image.png', { type: 'image/png' });
|
|
const dataUrl = canvas.toDataURL('image/png', 0.92);
|
|
await emit('save', { file, dataUrl, sourceUrl: remoteUrl.value || imageUrl.value });
|
|
}
|
|
|
|
watch(
|
|
() => props.modelValue,
|
|
isOpen => {
|
|
if (isOpen) {
|
|
resetState();
|
|
} else {
|
|
resetState();
|
|
}
|
|
}
|
|
);
|
|
|
|
onBeforeUnmount(() => {
|
|
revokeImageUrl();
|
|
});
|
|
</script>
|
|
|
|
<template>
|
|
<v-dialog
|
|
:model-value="modelValue"
|
|
max-width="920"
|
|
@update:model-value="emit('update:modelValue', $event)"
|
|
>
|
|
<div class="cropper-card">
|
|
<div class="cropper-header">
|
|
<div>
|
|
<div class="cropper-eyebrow">Image editor</div>
|
|
<h2>{{ title }}</h2>
|
|
</div>
|
|
<v-btn variant="text" :ripple="false"
|
|
class="plain-button"
|
|
:disabled="isSaving"
|
|
@click="closeDialog"
|
|
>
|
|
Close
|
|
</v-btn>
|
|
</div>
|
|
|
|
<div class="cropper-actions">
|
|
<v-file-input
|
|
:label="uploadLabel"
|
|
accept="image/*"
|
|
:disabled="isSaving"
|
|
density="compact"
|
|
variant="outlined"
|
|
hide-details
|
|
@update:model-value="onFileSelected"
|
|
/>
|
|
<div class="url-controls">
|
|
<v-text-field
|
|
v-model="remoteUrl"
|
|
type="url"
|
|
class="url-input"
|
|
:placeholder="sourceLabel"
|
|
:disabled="isSaving"
|
|
density="compact"
|
|
variant="outlined"
|
|
hide-details
|
|
/>
|
|
<v-btn variant="text" :ripple="false"
|
|
class="action-button secondary"
|
|
:disabled="isSaving"
|
|
@click="loadImageFromUrl"
|
|
>
|
|
{{ loadLabel }}
|
|
</v-btn>
|
|
</div>
|
|
<v-btn variant="text" :ripple="false"
|
|
class="action-button secondary"
|
|
:disabled="!isReady || isSaving"
|
|
@click="zoom(1.15)"
|
|
>
|
|
Zoom in
|
|
</v-btn>
|
|
<v-btn variant="text" :ripple="false"
|
|
class="action-button secondary"
|
|
:disabled="!isReady || isSaving"
|
|
@click="zoom(0.85)"
|
|
>
|
|
Zoom out
|
|
</v-btn>
|
|
<v-btn variant="text" :ripple="false"
|
|
class="action-button secondary"
|
|
:disabled="!isReady || isSaving"
|
|
@click="rotate(-90)"
|
|
>
|
|
Rotate left
|
|
</v-btn>
|
|
<v-btn variant="text" :ripple="false"
|
|
class="action-button secondary"
|
|
:disabled="!isReady || isSaving"
|
|
@click="rotate(90)"
|
|
>
|
|
Rotate right
|
|
</v-btn>
|
|
</div>
|
|
|
|
<div
|
|
v-if="error"
|
|
class="error-message"
|
|
>
|
|
{{ error }}
|
|
</div>
|
|
|
|
<div
|
|
v-if="isReady"
|
|
class="cropper-stage"
|
|
>
|
|
<Cropper
|
|
ref="cropper"
|
|
:src="imageUrl"
|
|
:stencil-props="{ aspectRatio }"
|
|
image-restriction="stencil"
|
|
/>
|
|
</div>
|
|
<div
|
|
v-else
|
|
class="empty-state"
|
|
>
|
|
Choose an image to crop and upload.
|
|
</div>
|
|
|
|
<div class="footer-actions">
|
|
<v-btn variant="text" :ripple="false"
|
|
class="action-button secondary"
|
|
:disabled="isSaving"
|
|
@click="closeDialog"
|
|
>
|
|
Cancel
|
|
</v-btn>
|
|
<v-btn variant="text" :ripple="false"
|
|
class="action-button"
|
|
:disabled="!isReady || isSaving"
|
|
@click="saveCrop"
|
|
>
|
|
<v-progress-circular
|
|
v-if="isSaving"
|
|
indeterminate
|
|
:size="16"
|
|
:width="2"
|
|
/>
|
|
<span>{{ isSaving ? 'Saving...' : confirmLabel }}</span>
|
|
</v-btn>
|
|
</div>
|
|
</div>
|
|
</v-dialog>
|
|
</template>
|
|
|
|
<style scoped>
|
|
@reference "@/assets/main.css";
|
|
.cropper-card {
|
|
@apply flex flex-col gap-5 rounded-[1.75rem] border p-5;
|
|
background: var(--app-surface-raised);
|
|
border-color: var(--app-border-subtle);
|
|
}
|
|
|
|
.cropper-header {
|
|
@apply flex items-start justify-between gap-4;
|
|
}
|
|
|
|
.cropper-eyebrow {
|
|
@apply text-xs font-bold uppercase tracking-[0.24em];
|
|
color: var(--app-color-on-tertiary);
|
|
}
|
|
|
|
.cropper-header h2 {
|
|
@apply mt-2 text-2xl font-black;
|
|
color: var(--app-color-on-surface);
|
|
}
|
|
|
|
.cropper-actions,
|
|
.footer-actions {
|
|
@apply flex flex-wrap gap-3;
|
|
}
|
|
|
|
.url-controls {
|
|
@apply flex min-w-full flex-wrap gap-3 md:min-w-0 md:flex-1;
|
|
}
|
|
|
|
.url-input {
|
|
@apply min-w-0 flex-1 rounded-full border px-4 py-3 text-sm;
|
|
border-color: var(--app-border-subtle);
|
|
background: rgba(255, 255, 255, 0.92);
|
|
color: var(--app-color-on-surface);
|
|
}
|
|
|
|
.footer-actions {
|
|
@apply justify-end;
|
|
}
|
|
|
|
.action-button,
|
|
.plain-button {
|
|
@apply inline-flex items-center justify-center gap-2 rounded-full px-4 py-3 text-sm font-bold transition;
|
|
}
|
|
|
|
.action-button {
|
|
background: var(--app-color-on-surface);
|
|
color: var(--app-color-on-primary);
|
|
}
|
|
|
|
.action-button.secondary,
|
|
.plain-button {
|
|
background: rgba(255, 255, 255, 0.84);
|
|
color: var(--app-color-on-surface);
|
|
border: 1px solid var(--app-border-subtle);
|
|
}
|
|
|
|
.cropper-stage {
|
|
@apply overflow-hidden rounded-[1.5rem] border;
|
|
height: 28rem;
|
|
border-color: var(--app-border-subtle);
|
|
background: var(--app-color-on-primary);
|
|
}
|
|
|
|
.empty-state,
|
|
.error-message {
|
|
@apply rounded-[1.25rem] border p-4 text-sm;
|
|
}
|
|
|
|
.empty-state {
|
|
border-color: var(--app-border-subtle);
|
|
color: var(--app-text-muted);
|
|
background: rgba(255, 250, 242, 0.9);
|
|
}
|
|
|
|
.error-message {
|
|
border-color: rgba(185, 28, 28, 0.12);
|
|
color: var(--app-danger-muted);
|
|
background: rgba(254, 226, 226, 0.75);
|
|
}
|
|
|
|
.hidden-input {
|
|
display: none;
|
|
}
|
|
</style>
|