first commit
This commit is contained in:
@@ -0,0 +1,187 @@
|
||||
<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>
|
||||
Reference in New Issue
Block a user