394 lines
17 KiB
Vue
394 lines
17 KiB
Vue
<template>
|
||
<div class="pivot-editor" :class="{ 'pivot-large': large }">
|
||
<div class="pivot-toolbar">
|
||
<div class="pivot-hint">
|
||
<span class="pivot-hint-icon">⊕</span>
|
||
<span>贴图轴点</span>
|
||
</div>
|
||
<div class="pivot-zoom">
|
||
<button type="button" :disabled="zoom <= 100" title="缩小" @click="zoomBy(-25)">−</button>
|
||
<span>{{ zoom }}%</span>
|
||
<button type="button" :disabled="zoom >= 400" title="放大" @click="zoomBy(25)">+</button>
|
||
</div>
|
||
<button v-if="!large" type="button" class="pivot-expand" title="放大到全屏操作" @click="$emit('fullscreen')">⤢</button>
|
||
</div>
|
||
|
||
<div
|
||
ref="stageRef"
|
||
class="pivot-stage"
|
||
:class="{ panning }"
|
||
title="左键点击/拖动设置轴点 · 右键拖动画布 · 滚轮缩放"
|
||
@pointerdown="onPointerDown"
|
||
@wheel.prevent="onWheel"
|
||
@contextmenu.prevent
|
||
>
|
||
<div v-if="previewUrl" ref="boxRef" class="pivot-box" :style="boxStyle">
|
||
<img :src="previewUrl" alt="轴点预览" draggable="false" @load="onImageLoad" />
|
||
</div>
|
||
<template v-if="previewUrl">
|
||
<!-- 准线与轴点放在缩放容器之外:尺寸恒定,准线贯穿整个交互区。 -->
|
||
<div class="pivot-cross vertical" :style="{ left: `${pivotScreen.x}px` }"></div>
|
||
<div class="pivot-cross horizontal" :style="{ top: `${pivotScreen.y}px` }"></div>
|
||
<div class="pivot-marker" :style="{ left: `${pivotScreen.x}px`, top: `${pivotScreen.y}px` }"></div>
|
||
<div class="pivot-badge" :style="{ left: `${pivotScreen.x}px`, top: `${pivotScreen.y}px` }">X: {{ anchorXText }}% | Y: {{ anchorYText }}%</div>
|
||
</template>
|
||
<div v-else class="pivot-empty">未选择图片</div>
|
||
</div>
|
||
|
||
<div class="pivot-bottom">
|
||
<div class="pivot-card">
|
||
<div class="pivot-presets">
|
||
<button
|
||
v-for="preset in presets"
|
||
:key="preset.label"
|
||
type="button"
|
||
class="pivot-preset"
|
||
:class="{ on: isPreset(preset) }"
|
||
:title="`${preset.label}(${preset.x}%, ${preset.y}%)`"
|
||
:aria-label="preset.label"
|
||
@click="pickPreset(preset)"
|
||
>{{ preset.icon }}</button>
|
||
</div>
|
||
<button type="button" class="pivot-reset" @click="resetCenter">↺ 复位居中 (50%, 50%)</button>
|
||
</div>
|
||
<div class="pivot-card">
|
||
<div class="pivot-card-head"><span><span class="pivot-card-icon">◎</span> 轴点坐标(%)</span></div>
|
||
<div class="pivot-coords">
|
||
<label>X:
|
||
<input type="number" min="0" max="100" step="0.1" :value="anchorXText" @input="updateCoord('x', $event)" />
|
||
<span>%</span>
|
||
</label>
|
||
<label>Y:
|
||
<input type="number" min="0" max="100" step="0.1" :value="anchorYText" @input="updateCoord('y', $event)" />
|
||
<span>%</span>
|
||
</label>
|
||
</div>
|
||
<!-- 旋转测试只在大图精修时显示,避免内联面板过高。 -->
|
||
<template v-if="large">
|
||
<div class="pivot-card-head pivot-rotate-head">
|
||
<span><span class="pivot-card-icon">✥</span> 轴心旋转测试</span>
|
||
<span class="pivot-rotate-state">{{ rotationMode === 'static' ? '静止' : rotationMode === 'spin' ? '自转' : '摆动' }}</span>
|
||
</div>
|
||
<div class="pivot-rotate-body">
|
||
<div class="pivot-rotate-modes">
|
||
<button type="button" :class="{ on: rotationMode === 'static' }" @click="rotationMode = 'static'">静止</button>
|
||
<button type="button" :class="{ on: rotationMode === 'spin' }" @click="rotationMode = 'spin'">↻ 自转</button>
|
||
<button type="button" :class="{ on: rotationMode === 'swing' }" @click="rotationMode = 'swing'">⇄ 摆动</button>
|
||
</div>
|
||
<div class="pivot-rotate-preview">
|
||
<img
|
||
v-if="previewUrl"
|
||
:src="previewUrl"
|
||
alt="旋转测试"
|
||
draggable="false"
|
||
:class="rotationMode"
|
||
:style="rotateStyle"
|
||
/>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||
|
||
const props = withDefaults(defineProps<{
|
||
previewUrl: string
|
||
anchorX: number
|
||
anchorY: number
|
||
large?: boolean
|
||
}>(), {
|
||
large: false,
|
||
})
|
||
|
||
const emit = defineEmits<{
|
||
'update:anchorX': [number]
|
||
'update:anchorY': [number]
|
||
fullscreen: []
|
||
}>()
|
||
|
||
const presets = [
|
||
{ icon: '↖', label: '左上', x: 0, y: 0 },
|
||
{ icon: '↑', label: '正上', x: 50, y: 0 },
|
||
{ icon: '↗', label: '右上', x: 100, y: 0 },
|
||
{ icon: '←', label: '正左', x: 0, y: 50 },
|
||
{ icon: '◯', label: '正中', x: 50, y: 50 },
|
||
{ icon: '→', label: '正右', x: 100, y: 50 },
|
||
{ icon: '↙', label: '左下', x: 0, y: 100 },
|
||
{ icon: '↓', label: '正下·底部', x: 50, y: 100 },
|
||
{ icon: '↘', label: '右下', x: 100, y: 100 },
|
||
]
|
||
|
||
const stageRef = ref<HTMLElement | null>(null)
|
||
const boxRef = ref<HTMLElement | null>(null)
|
||
const stageSize = ref({ w: 0, h: 0 })
|
||
const natural = ref({ w: 1, h: 1 })
|
||
const zoom = ref(100)
|
||
const pan = ref({ x: 0, y: 0 })
|
||
const panning = ref(false)
|
||
const rotationMode = ref<'static' | 'spin' | 'swing'>('static')
|
||
|
||
let observer: ResizeObserver | null = null
|
||
|
||
const clampPercent = (value: number) => Math.min(100, Math.max(0, Number.isFinite(value) ? value : 0))
|
||
const anchorXText = computed(() => (clampPercent(props.anchorX * 100)).toFixed(1))
|
||
const anchorYText = computed(() => (clampPercent(props.anchorY * 100)).toFixed(1))
|
||
|
||
function contain(width: number, height: number) {
|
||
const ratio = natural.value.w / Math.max(1, natural.value.h)
|
||
let w = width
|
||
let h = w / ratio
|
||
if (h > height) { h = height; w = h * ratio }
|
||
return { w: Math.max(1, w), h: Math.max(1, h) }
|
||
}
|
||
|
||
const fit = computed(() => {
|
||
const pad = props.large ? 26 : 12
|
||
const width = Math.max(1, stageSize.value.w - pad * 2)
|
||
const height = Math.max(1, stageSize.value.h - pad * 2)
|
||
return contain(width, height)
|
||
})
|
||
|
||
const boxStyle = computed(() => ({
|
||
width: `${fit.value.w}px`,
|
||
height: `${fit.value.h}px`,
|
||
transform: `translate(${pan.value.x}px, ${pan.value.y}px) scale(${zoom.value / 100})`,
|
||
}))
|
||
|
||
/** 轴点在交互区中的屏幕坐标:跟随缩放/平移,但轴点本身不随缩放变大。 */
|
||
const pivotScreen = computed(() => {
|
||
const scale = zoom.value / 100
|
||
return {
|
||
x: stageSize.value.w / 2 + pan.value.x + (clampPercent(props.anchorX * 100) / 100 - 0.5) * fit.value.w * scale,
|
||
y: stageSize.value.h / 2 + pan.value.y + (clampPercent(props.anchorY * 100) / 100 - 0.5) * fit.value.h * scale,
|
||
}
|
||
})
|
||
|
||
const rotateStyle = computed(() => {
|
||
const size = props.large ? 128 : 72
|
||
const box = contain(size, size)
|
||
return {
|
||
width: `${box.w}px`,
|
||
height: `${box.h}px`,
|
||
transformOrigin: `${clampPercent(props.anchorX * 100)}% ${clampPercent(props.anchorY * 100)}%`,
|
||
}
|
||
})
|
||
|
||
function setAnchor(x: number, y: number) {
|
||
emit('update:anchorX', Math.min(1, Math.max(0, Number.isFinite(x) ? x : 0)))
|
||
emit('update:anchorY', Math.min(1, Math.max(0, Number.isFinite(y) ? y : 0)))
|
||
}
|
||
|
||
function updateFromPointer(event: PointerEvent) {
|
||
const box = boxRef.value
|
||
if (!box) return
|
||
const rect = box.getBoundingClientRect()
|
||
if (rect.width <= 0 || rect.height <= 0) return
|
||
setAnchor(
|
||
(event.clientX - rect.left) / rect.width,
|
||
(event.clientY - rect.top) / rect.height,
|
||
)
|
||
}
|
||
|
||
function onPointerDown(event: PointerEvent) {
|
||
if (!props.previewUrl) return
|
||
if (event.button === 2) {
|
||
startPan(event)
|
||
return
|
||
}
|
||
if (event.button !== 0) return
|
||
event.preventDefault()
|
||
updateFromPointer(event)
|
||
const move = (ev: PointerEvent) => updateFromPointer(ev)
|
||
const up = () => {
|
||
window.removeEventListener('pointermove', move)
|
||
window.removeEventListener('pointerup', up)
|
||
window.removeEventListener('pointercancel', up)
|
||
}
|
||
window.addEventListener('pointermove', move)
|
||
window.addEventListener('pointerup', up)
|
||
window.addEventListener('pointercancel', up)
|
||
}
|
||
|
||
/** 右键拖动画布:只平移视图,不改变轴点。 */
|
||
function startPan(event: PointerEvent) {
|
||
event.preventDefault()
|
||
panning.value = true
|
||
const startX = event.clientX
|
||
const startY = event.clientY
|
||
const originX = pan.value.x
|
||
const originY = pan.value.y
|
||
const move = (ev: PointerEvent) => {
|
||
pan.value = { x: originX + ev.clientX - startX, y: originY + ev.clientY - startY }
|
||
}
|
||
const up = () => {
|
||
panning.value = false
|
||
window.removeEventListener('pointermove', move)
|
||
window.removeEventListener('pointerup', up)
|
||
window.removeEventListener('pointercancel', up)
|
||
}
|
||
window.addEventListener('pointermove', move)
|
||
window.addEventListener('pointerup', up)
|
||
window.addEventListener('pointercancel', up)
|
||
}
|
||
|
||
/** 滚轮以鼠标位置为中心缩放画布,100% ~ 400%。 */
|
||
function onWheel(event: WheelEvent) {
|
||
const factor = event.deltaY < 0 ? 1.12 : 1 / 1.12
|
||
const nextZoom = Math.min(400, Math.max(100, Math.round(zoom.value * factor)))
|
||
if (nextZoom === zoom.value) return
|
||
const stage = stageRef.value
|
||
if (!stage) {
|
||
zoom.value = nextZoom
|
||
return
|
||
}
|
||
const rect = stage.getBoundingClientRect()
|
||
const cursorX = event.clientX - rect.left
|
||
const cursorY = event.clientY - rect.top
|
||
const ratio = nextZoom / zoom.value
|
||
// 未变换时图片盒中心与舞台中心重合;缩放时保持鼠标下的点不动。
|
||
pan.value = {
|
||
x: pan.value.x + (cursorX - rect.width / 2 - pan.value.x) * (1 - ratio),
|
||
y: pan.value.y + (cursorY - rect.height / 2 - pan.value.y) * (1 - ratio),
|
||
}
|
||
zoom.value = nextZoom
|
||
}
|
||
|
||
function onImageLoad(event: Event) {
|
||
const image = event.target as HTMLImageElement
|
||
natural.value = { w: image.naturalWidth || 1, h: image.naturalHeight || 1 }
|
||
}
|
||
|
||
function zoomBy(delta: number) {
|
||
zoom.value = Math.min(400, Math.max(100, zoom.value + delta))
|
||
}
|
||
|
||
function resetCenter() {
|
||
setAnchor(0.5, 0.5)
|
||
}
|
||
|
||
function pickPreset(preset: { x: number; y: number }) {
|
||
setAnchor(preset.x / 100, preset.y / 100)
|
||
}
|
||
|
||
function isPreset(preset: { x: number; y: number }) {
|
||
return Math.abs(clampPercent(props.anchorX * 100) - preset.x) < 0.05
|
||
&& Math.abs(clampPercent(props.anchorY * 100) - preset.y) < 0.05
|
||
}
|
||
|
||
function updateCoord(axis: 'x' | 'y', event: Event) {
|
||
const value = clampPercent(Number((event.target as HTMLInputElement).value))
|
||
if (axis === 'x') emit('update:anchorX', value / 100)
|
||
else emit('update:anchorY', value / 100)
|
||
}
|
||
|
||
watch(() => props.previewUrl, () => {
|
||
natural.value = { w: 1, h: 1 }
|
||
pan.value = { x: 0, y: 0 }
|
||
zoom.value = 100
|
||
})
|
||
|
||
onMounted(() => {
|
||
if (!stageRef.value) return
|
||
observer = new ResizeObserver((entries) => {
|
||
const rect = entries[0]?.contentRect
|
||
if (rect) stageSize.value = { w: rect.width, h: rect.height }
|
||
})
|
||
observer.observe(stageRef.value)
|
||
})
|
||
|
||
onBeforeUnmount(() => {
|
||
observer?.disconnect()
|
||
observer = null
|
||
})
|
||
</script>
|
||
|
||
<style scoped>
|
||
.pivot-editor { margin-top: 8px; padding: 8px; border: 1px solid #2e3c55; border-radius: 8px; background: #121a27; }
|
||
.pivot-toolbar { display: flex; align-items: center; gap: 7px; margin-bottom: 7px; }
|
||
.pivot-hint { flex: 1; min-width: 0; display: flex; align-items: center; gap: 5px; color: #9aa8c4; font-size: 10px; line-height: 1.3; }
|
||
.pivot-hint-icon { color: #8f8cff; font-size: 13px; }
|
||
.pivot-zoom { display: flex; align-items: center; gap: 2px; padding: 2px; border: 1px solid #34435e; border-radius: 6px; background: #182234; }
|
||
.pivot-zoom button { width: 20px; height: 20px; padding: 0; border: 0; border-radius: 4px; background: transparent; color: #a9b6d0; font-size: 13px; cursor: pointer; }
|
||
.pivot-zoom button:hover:not(:disabled) { background: #2a3853; color: #fff; }
|
||
.pivot-zoom button:disabled { opacity: .3; cursor: default; }
|
||
.pivot-zoom span { min-width: 36px; text-align: center; color: #c7d1e6; font-size: 10px; font-variant-numeric: tabular-nums; }
|
||
.pivot-expand { width: 24px; height: 24px; padding: 0; border: 1px solid #34435e; border-radius: 6px; background: #182234; color: #a9b6d0; font-size: 13px; cursor: pointer; }
|
||
.pivot-expand:hover { border-color: #7774ff; color: #fff; }
|
||
|
||
.pivot-stage {
|
||
position: relative; display: flex; align-items: center; justify-content: center;
|
||
width: 100%; height: 156px; overflow: hidden;
|
||
border: 1px solid #2f3d57; border-radius: 8px;
|
||
background-color: #10161f;
|
||
background-image:
|
||
linear-gradient(45deg, #1a2230 25%, transparent 25%),
|
||
linear-gradient(-45deg, #1a2230 25%, transparent 25%),
|
||
linear-gradient(45deg, transparent 75%, #1a2230 75%),
|
||
linear-gradient(-45deg, transparent 75%, #1a2230 75%);
|
||
background-size: 16px 16px;
|
||
background-position: 0 0, 0 8px, 8px -8px, -8px 0;
|
||
touch-action: none; cursor: crosshair;
|
||
}
|
||
.pivot-large .pivot-stage { height: 340px; background-size: 22px 22px; background-position: 0 0, 0 11px, 11px -11px, -11px 0; }
|
||
.pivot-stage.panning { cursor: grabbing; }
|
||
.pivot-box { position: relative; flex: 0 0 auto; }
|
||
.pivot-box > img { display: block; width: 100%; height: 100%; object-fit: fill; user-select: none; pointer-events: none; }
|
||
.pivot-empty { color: #4e5a70; font-size: 12px; }
|
||
.pivot-cross { position: absolute; pointer-events: none; background: rgba(126, 118, 255, .75); box-shadow: 0 0 3px rgba(126, 118, 255, .6); }
|
||
.pivot-cross.vertical { top: 0; bottom: 0; width: 1px; transform: translateX(-.5px); }
|
||
.pivot-cross.horizontal { left: 0; right: 0; height: 1px; transform: translateY(-.5px); }
|
||
.pivot-marker {
|
||
position: absolute; width: 13px; height: 13px; transform: translate(-50%, -50%);
|
||
border: 2px solid #c9c6ff; border-radius: 50%;
|
||
background: rgba(96, 95, 240, .85);
|
||
box-shadow: 0 0 0 3px rgba(96, 95, 240, .28), 0 0 10px rgba(96, 95, 240, .8);
|
||
pointer-events: none;
|
||
}
|
||
.pivot-badge {
|
||
position: absolute; transform: translate(-50%, 14px);
|
||
padding: 2px 6px; border: 1px solid #6f6bc7; border-radius: 4px;
|
||
background: rgba(25, 28, 58, .92); color: #b9b6ff;
|
||
font-size: 9px; white-space: nowrap; pointer-events: none;
|
||
}
|
||
|
||
.pivot-bottom { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: 8px; margin-top: 8px; }
|
||
.pivot-large .pivot-bottom { gap: 14px; margin-top: 14px; }
|
||
.pivot-card { min-width: 0; padding: 7px; border: 1px solid #28344a; border-radius: 8px; background: #151d2b; }
|
||
.pivot-card-head { display: flex; align-items: center; justify-content: space-between; gap: 5px; margin-bottom: 6px; color: #b9c4da; font-size: 10px; }
|
||
.pivot-card-icon { color: #8f8cff; }
|
||
.pivot-presets { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 4px; }
|
||
.pivot-preset { height: 30px; padding: 0; border: 1px solid #2f3d57; border-radius: 5px; background: #1b2537; color: #aab6cf; font-size: 16px; line-height: 1; cursor: pointer; }
|
||
.pivot-preset:hover { border-color: #5d5aa8; color: #d9d8ff; }
|
||
.pivot-preset.on { border-color: #7774ff; background: #4a47b5; color: #fff; }
|
||
.pivot-reset { display: block; width: 100%; height: 26px; margin-top: 6px; padding: 0 6px; border: 1px solid #2f3d57; border-radius: 5px; background: #1a2435; color: #96a4c0; font-size: 10px; cursor: pointer; white-space: nowrap; }
|
||
.pivot-reset:hover { border-color: #5d5aa8; background: #232f46; color: #d9d8ff; }
|
||
.pivot-coords { display: flex; flex-direction: column; gap: 5px; }
|
||
.pivot-large .pivot-coords { flex-direction: row; gap: 8px; }
|
||
.pivot-coords label { flex: 1; display: flex; align-items: center; gap: 3px; color: #8d9ab5; font-size: 10px; }
|
||
.pivot-coords input { width: 100%; min-width: 0; height: 24px; padding: 0 4px; border: 1px solid #34435e; border-radius: 4px; background: #101827; color: #f0b465; font-size: 11px; text-align: right; }
|
||
.pivot-rotate-head { margin-top: 10px; }
|
||
.pivot-rotate-state { color: #6f7c96; font-size: 9px; }
|
||
.pivot-rotate-body { display: flex; align-items: center; gap: 7px; }
|
||
.pivot-rotate-modes { display: flex; flex-direction: column; gap: 4px; flex: 1; }
|
||
.pivot-rotate-modes button { height: 24px; padding: 0 5px; border: 1px solid #2f3d57; border-radius: 5px; background: #1b2537; color: #93a0ba; font-size: 9px; cursor: pointer; }
|
||
.pivot-rotate-modes button.on { border-color: #7774ff; background: #4a47b5; color: #fff; }
|
||
.pivot-rotate-preview { display: flex; align-items: center; justify-content: center; width: 78px; height: 78px; flex: 0 0 78px; border: 1px solid #2b374d; border-radius: 6px; background: #10161f; overflow: hidden; }
|
||
.pivot-large .pivot-rotate-preview { width: 128px; height: 128px; flex-basis: 128px; }
|
||
.pivot-rotate-preview img { display: block; }
|
||
.pivot-rotate-preview img.spin { animation: pivot-spin 2s linear infinite; }
|
||
.pivot-rotate-preview img.swing { animation: pivot-swing 1.1s ease-in-out infinite alternate; }
|
||
@keyframes pivot-spin { to { transform: rotate(360deg); } }
|
||
@keyframes pivot-swing { from { transform: rotate(-28deg); } to { transform: rotate(28deg); } }
|
||
|
||
.pivot-large .pivot-hint { font-size: 12px; }
|
||
.pivot-large .pivot-card-head { font-size: 12px; }
|
||
.pivot-large .pivot-preset { height: 42px; font-size: 20px; }
|
||
.pivot-large .pivot-reset { height: 34px; margin-top: 8px; font-size: 12px; }
|
||
.pivot-large .pivot-coords input { height: 34px; font-size: 14px; }
|
||
.pivot-large .pivot-rotate-modes button { height: 34px; font-size: 12px; }
|
||
</style>
|