Files
photobooth/src/components/ImageEditor.vue
T
2026-05-25 19:07:12 +07:00

188 lines
5.7 KiB
Vue

<script setup>
import { ref, onMounted } from 'vue'
const props = defineProps({
src: { type: String, required: true },
outputWidth: { type: Number, default: 440 },
outputHeight: { type: Number, default: 290 },
initialTransform: { type: Object, default: null },
})
const emit = defineEmits(["apply", "cancel"]);
const imgRef = ref(null)
const containerRef = ref(null)
const pos = ref({ x: 0, y: 0 })
const scale = ref(1)
const rotating = ref(0)
let dragging = false
let last = { x: 0, y: 0 }
onMounted(() => {
// initialize scale to fit image into viewport once loaded
const img = imgRef.value
img.onload = () => {
const sw = props.outputWidth
const sh = props.outputHeight
if (props.initialTransform) {
// rehydrate previous transform
scale.value = props.initialTransform.scale || 1
rotating.value = props.initialTransform.rotate || 0
pos.value = { x: props.initialTransform.x || 0, y: props.initialTransform.y || 0 }
// if transform was created with different output size, scale positions accordingly
if (props.initialTransform.outputWidth && props.initialTransform.outputWidth !== sw) {
const sx = sw / props.initialTransform.outputWidth
const sy = sh / props.initialTransform.outputHeight
pos.value.x = pos.value.x * sx
pos.value.y = pos.value.y * sy
scale.value = scale.value * ((sx + sy) / 2)
}
} else {
const r = Math.max(sw / img.naturalWidth, sh / img.naturalHeight)
scale.value = r
pos.value = { x: (sw - img.naturalWidth * scale.value) / 2, y: (sh - img.naturalHeight * scale.value) / 2 }
}
}
})
function onPointerDown(e) {
dragging = true
last = { x: e.clientX, y: e.clientY }
window.addEventListener('pointermove', onPointerMove)
window.addEventListener('pointerup', onPointerUp)
}
function onPointerMove(e) {
if (!dragging) return
const dx = e.clientX - last.x
const dy = e.clientY - last.y
pos.value.x += dx
pos.value.y += dy
last = { x: e.clientX, y: e.clientY }
}
function onPointerUp() {
dragging = false
window.removeEventListener('pointermove', onPointerMove)
window.removeEventListener('pointerup', onPointerUp)
}
function onWheel(e) {
e.preventDefault()
const delta = -e.deltaY
const factor = delta > 0 ? 1.08 : 0.92
// zoom around mouse position
const rect = containerRef.value.getBoundingClientRect()
const mx = e.clientX - rect.left
const my = e.clientY - rect.top
const prevScale = scale.value
scale.value = Math.max(0.2, Math.min(6, scale.value * factor))
// adjust pos so point under cursor remains
pos.value.x = mx - ((mx - pos.value.x) * (scale.value / prevScale))
pos.value.y = my - ((my - pos.value.y) * (scale.value / prevScale))
}
function presetFit() {
const img = imgRef.value
const sw = props.outputWidth
const sh = props.outputHeight
const r = Math.min(sw / img.naturalWidth, sh / img.naturalHeight)
scale.value = r
pos.value = { x: (sw - img.naturalWidth * r) / 2, y: (sh - img.naturalHeight * r) / 2 }
}
function presetFill() {
const img = imgRef.value
const sw = props.outputWidth
const sh = props.outputHeight
const r = Math.max(sw / img.naturalWidth, sh / img.naturalHeight)
scale.value = r
pos.value = { x: (sw - img.naturalWidth * r) / 2, y: (sh - img.naturalHeight * r) / 2 }
}
async function apply() {
const img = imgRef.value
const canvas = document.createElement('canvas')
canvas.width = props.outputWidth
canvas.height = props.outputHeight
const ctx = canvas.getContext('2d')
ctx.save()
ctx.translate(pos.value.x, pos.value.y)
ctx.rotate((rotating.value * Math.PI) / 180)
ctx.scale(scale.value, scale.value)
ctx.drawImage(img, 0, 0)
ctx.restore()
const dataUrl = canvas.toDataURL('image/png')
const transform = {
x: pos.value.x,
y: pos.value.y,
scale: scale.value,
rotate: rotating.value,
outputWidth: props.outputWidth,
outputHeight: props.outputHeight,
naturalWidth: img.naturalWidth,
naturalHeight: img.naturalHeight,
}
emit('apply', { dataUrl, transform })
}
</script>
<template>
<div class="editor-overlay">
<div class="editor">
<div class="viewport" :style="{ width: `${outputWidth}px`, height: `${outputHeight}px` }" ref="containerRef" @pointerdown="onPointerDown" @wheel.prevent="onWheel">
<img :src="src" ref="imgRef" class="editable" :style="{ transform: `translate(${pos.x}px, ${pos.y}px) scale(${scale}) rotate(${rotating}deg)` }" />
</div>
<div class="controls">
<div class="presets">
<button @click="presetFit">Fit</button>
<button @click="presetFill">Fill</button>
<label>Rotate <input type="range" min="-180" max="180" v-model.number="rotating" /></label>
</div>
<div class="actions">
<button class="ghost" @click="$emit('cancel')">Cancel</button>
<button class="primary" @click="apply">Apply</button>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.editor-overlay {
position: fixed;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
background: rgba(0,0,0,0.5);
z-index: 60;
}
.editor {
background: #fff;
padding: 18px;
border-radius: 12px;
box-shadow: 0 20px 50px rgba(0,0,0,0.4);
display: grid;
gap: 12px;
}
.viewport {
position: relative;
overflow: hidden;
background: #f3f3f3;
}
.editable {
position: absolute;
left: 0;
top: 0;
user-select: none;
touch-action: none;
}
.controls {
display: flex;
justify-content: space-between;
gap: 12px;
}
.presets button { margin-right: 8px }
.primary { background: #c85a7c; color: #fff; padding: 8px 14px; border: none; border-radius: 8px }
.ghost { background: transparent; border: 1px solid #ddd; padding: 8px 14px; border-radius: 8px }
</style>