188 lines
5.7 KiB
Vue
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>
|