Files
SpineParticlesWeb/src/views/PivotEditor.vue
T
2026-10-28 16:13:18 +08:00

400 lines
18 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" data-matomo-section="pivot" :class="{ 'pivot-large': large }">
<div class="pivot-toolbar">
<div class="pivot-hint">
<span class="pivot-hint-icon">⊕</span>
<span>{{ $t('pivot.title') }}</span>
</div>
<div class="pivot-zoom">
<button type="button" :disabled="zoom <= 100" :title="$t('pivot.zoomOut')" @click="zoomBy(-25)">−</button>
<span>{{ zoom }}%</span>
<button type="button" :disabled="zoom >= 400" :title="$t('pivot.zoomIn')" @click="zoomBy(25)">+</button>
</div>
<button v-if="!large" type="button" class="pivot-expand" :title="$t('pivot.expand')" @click="$emit('fullscreen')">⤢</button>
</div>
<div
ref="stageRef"
class="pivot-stage"
:class="{ panning }"
:title="$t('pivot.interaction')"
@pointerdown="onPointerDown"
@wheel.prevent="onWheel"
@contextmenu.prevent
>
<div v-if="previewUrl" ref="boxRef" class="pivot-box" :style="boxStyle">
<img :src="previewUrl" :alt="$t('pivot.previewAlt')" 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">{{ $t('pivot.noImage') }}</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">↺ {{ $t('pivot.resetCenter') }}</button>
</div>
<div class="pivot-card">
<div class="pivot-card-head"><span><span class="pivot-card-icon">◎</span> {{ $t('pivot.coordinates') }}</span></div>
<div class="pivot-coords">
<label>X:
<input type="number" min="-1000" max="1000" step="0.1" :value="anchorXText" @input="updateCoord('x', $event)" />
<span>%</span>
</label>
<label>Y:
<input type="number" min="-1000" max="1000" 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> {{ $t('pivot.rotationTest') }}</span>
<span class="pivot-rotate-state">{{ rotationMode === 'static' ? $t('pivot.static') : rotationMode === 'spin' ? $t('pivot.spin') : $t('pivot.swing') }}</span>
</div>
<div class="pivot-rotate-body">
<div class="pivot-rotate-modes">
<button type="button" :class="{ on: rotationMode === 'static' }" @click="rotationMode = 'static'">{{ $t('pivot.static') }}</button>
<button type="button" :class="{ on: rotationMode === 'spin' }" @click="rotationMode = 'spin'">↻ {{ $t('pivot.spin') }}</button>
<button type="button" :class="{ on: rotationMode === 'swing' }" @click="rotationMode = 'swing'">⇄ {{ $t('pivot.swing') }}</button>
</div>
<div class="pivot-rotate-preview">
<img
v-if="previewUrl"
:src="previewUrl"
:alt="$t('pivot.rotationAlt')"
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'
import { useI18n } from 'vue-i18n'
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 { t } = useI18n()
const presets = computed(() => [
{ icon: '↖', label: t('pivot.topLeft'), x: 0, y: 0 },
{ icon: '↑', label: t('pivot.top'), x: 50, y: 0 },
{ icon: '↗', label: t('pivot.topRight'), x: 100, y: 0 },
{ icon: '←', label: t('pivot.left'), x: 0, y: 50 },
{ icon: '◯', label: t('pivot.center'), x: 50, y: 50 },
{ icon: '→', label: t('pivot.right'), x: 100, y: 50 },
{ icon: '↙', label: t('pivot.bottomLeft'), x: 0, y: 100 },
{ icon: '↓', label: t('pivot.bottom'), x: 50, y: 100 },
{ icon: '↘', label: t('pivot.bottomRight'), 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
// 轴点允许落在图片之外;这里只做 ±1000% 的防护上限。
const COORD_LIMIT_PERCENT = 1000
const clampCoordPercent = (value: number) =>
Math.min(COORD_LIMIT_PERCENT, Math.max(-COORD_LIMIT_PERCENT, Number.isFinite(value) ? value : 0))
const anchorXText = computed(() => clampCoordPercent(props.anchorX * 100).toFixed(1))
const anchorYText = computed(() => clampCoordPercent(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 + (clampCoordPercent(props.anchorX * 100) / 100 - 0.5) * fit.value.w * scale,
y: stageSize.value.h / 2 + pan.value.y + (clampCoordPercent(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: `${clampCoordPercent(props.anchorX * 100)}% ${clampCoordPercent(props.anchorY * 100)}%`,
}
})
function setAnchor(x: number, y: number) {
const limit = COORD_LIMIT_PERCENT / 100
emit('update:anchorX', Math.min(limit, Math.max(-limit, Number.isFinite(x) ? x : 0)))
emit('update:anchorY', Math.min(limit, Math.max(-limit, 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(clampCoordPercent(props.anchorX * 100) - preset.x) < 0.05
&& Math.abs(clampCoordPercent(props.anchorY * 100) - preset.y) < 0.05
}
function updateCoord(axis: 'x' | 'y', event: Event) {
const value = clampCoordPercent(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>