Files
SpineParticlesWeb/src/views/PivotEditor.vue
T
2026-10-22 11:46:00 +08:00

394 lines
17 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>