贴图轴点

This commit is contained in:
tianmo committed 2026-10-22 11:46:00 +08:00
1 parent c041503004
commit 4d54c87681
13 files changed
+2294 -1248

No files matched your search

+393
View File
@@ -0,0 +1,393 @@
<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>