145 lines
3.3 KiB
Vue
145 lines
3.3 KiB
Vue
<script setup lang="ts">
|
|
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
|
import type { Hotspot } from '@engine/types'
|
|
import { useI18n } from '@/composables/useI18n'
|
|
|
|
const props = defineProps<{
|
|
hotspots: Hotspot[]
|
|
isImageScene: boolean
|
|
imageUrl?: string | null
|
|
contentSize?: { w: number; h: number } | null
|
|
}>()
|
|
|
|
const { t } = useI18n()
|
|
|
|
const emit = defineEmits<{
|
|
clickHotspot: [hotspotId: string]
|
|
}>()
|
|
|
|
const layerRef = ref<HTMLDivElement | null>(null)
|
|
const layerW = ref(window.innerWidth)
|
|
const layerH = ref(window.innerHeight)
|
|
|
|
function measure() {
|
|
if (layerRef.value) {
|
|
layerW.value = layerRef.value.clientWidth
|
|
layerH.value = layerRef.value.clientHeight
|
|
} else {
|
|
layerW.value = window.innerWidth
|
|
layerH.value = window.innerHeight
|
|
}
|
|
}
|
|
|
|
let resizeObs: ResizeObserver | null = null
|
|
|
|
onMounted(() => {
|
|
measure()
|
|
resizeObs = new ResizeObserver(() => measure())
|
|
if (layerRef.value) resizeObs.observe(layerRef.value)
|
|
window.addEventListener('resize', measure)
|
|
})
|
|
|
|
onUnmounted(() => {
|
|
resizeObs?.disconnect()
|
|
window.removeEventListener('resize', measure)
|
|
})
|
|
|
|
interface Rect {
|
|
left: string
|
|
top: string
|
|
width: string
|
|
height: string
|
|
}
|
|
|
|
const hotspotRects = computed<Rect[]>(() => {
|
|
const cw = layerW.value
|
|
const ch = layerH.value
|
|
const cs = props.contentSize
|
|
|
|
if (!cs || !cs.w || !cs.h) {
|
|
return props.hotspots.map((hs) => ({
|
|
left: `${(hs.x / (cs?.w || 1)) * 100}%`,
|
|
top: `${(hs.y / (cs?.h || 1)) * 100}%`,
|
|
width: `${(hs.width / (cs?.w || 1)) * 100}%`,
|
|
height: `${(hs.height / (cs?.h || 1)) * 100}%`,
|
|
}))
|
|
}
|
|
|
|
const scale = Math.min(cw / cs.w, ch / cs.h)
|
|
const renderW = cs.w * scale
|
|
const renderH = cs.h * scale
|
|
const ox = (cw - renderW) / 2
|
|
const oy = (ch - renderH) / 2
|
|
|
|
return props.hotspots.map((hs) => ({
|
|
left: `${ox + hs.x * scale}px`,
|
|
top: `${oy + hs.y * scale}px`,
|
|
width: `${hs.width * scale}px`,
|
|
height: `${hs.height * scale}px`,
|
|
}))
|
|
})
|
|
</script>
|
|
|
|
<template>
|
|
<div ref="layerRef" class="hotspot-layer" v-if="(hotspots.length > 0) || (isImageScene && imageUrl)">
|
|
<img v-if="isImageScene && imageUrl" :src="imageUrl" class="hotspot-image" />
|
|
|
|
<div
|
|
v-for="(hs, i) in hotspots"
|
|
:key="hs.id"
|
|
class="hotspot-rect"
|
|
:style="hotspotRects[i] as any"
|
|
@click.stop="emit('clickHotspot', hs.id)"
|
|
:title="t(hs.labelKey || hs.label)"
|
|
>
|
|
<span class="hotspot-label">{{ t(hs.labelKey || hs.label) }}</span>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<style scoped>
|
|
.hotspot-layer {
|
|
position: absolute;
|
|
inset: 0;
|
|
z-index: 8;
|
|
pointer-events: none;
|
|
}
|
|
|
|
.hotspot-image {
|
|
position: absolute;
|
|
inset: 0;
|
|
width: 100%;
|
|
height: 100%;
|
|
object-fit: contain;
|
|
}
|
|
|
|
.hotspot-rect {
|
|
position: absolute;
|
|
border: 2px solid rgba(255, 255, 255, 0.4);
|
|
background: rgba(255, 255, 255, 0.06);
|
|
border-radius: 4px;
|
|
cursor: pointer;
|
|
pointer-events: auto;
|
|
transition: background 0.15s, border-color 0.15s;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
}
|
|
|
|
.hotspot-rect:hover {
|
|
background: rgba(255, 255, 255, 0.2);
|
|
border-color: rgba(255, 255, 255, 0.8);
|
|
}
|
|
|
|
.hotspot-label {
|
|
font-size: 13px;
|
|
color: #fff;
|
|
background: rgba(0, 0, 0, 0.65);
|
|
padding: 3px 10px;
|
|
border-radius: 3px;
|
|
white-space: nowrap;
|
|
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.8);
|
|
pointer-events: none;
|
|
}
|
|
</style>
|