图标提示热点组件封装
- 编辑全景图时有添加热点热点功能,vue如何实现图标提示热点新增修改删除

依赖安装
组件
common.css文件
// 底部提示
.tips-bottom {
position: relative;
&::before {
content: '';
background: transparent;
border: 6px solid transparent;
border-bottom-color: #383838;
pointer-events: none;
transition: .3s ease;
position: absolute;
bottom: -12px;
left: 50%;
transform: translate(-50%, -50%);
}
&::after {
content: attr(tips);
background: #383838;
color: #fff;
padding: 8px 10px;
font-size: 12px;
line-height: 12px;
white-space: nowrap;
font-family: Helvetica, Arial, sans-serif;
font-weight: bold;
border-radius: 3px;
pointer-events: none;
transition: .3s ease;
position: absolute;
bottom: -48px;
left: 50%;
transform: translate(-50%, -50%);
}
}
// 上侧提示
.tips-top {
position: relative;
&::before {
content: '';
background: transparent;
border: 6px solid transparent;
border-top-color: #383838;
pointer-events: none;
transition: .3s ease;
position: absolute;
top: 0;
left: 50%;
transform: translate(-50%, -50%);
}
&::after {
content: attr(tips);
background: #383838;
color: #fff;
padding: 8px 10px;
font-size: 12px;
line-height: 12px;
white-space: nowrap;
font-family: Helvetica, Arial, sans-serif;
font-weight: bold;
border-radius: 3px;
pointer-events: none;
transition: .3s ease;
position: absolute;
top: -20px;
left: 50%;
transform: translate(-50%, -50%);
}
}
// 左侧提示
.tips-left {
position: relative;
&::before {
content: '';
background: transparent;
border: 6px solid transparent;
border-left-color: #383838;
pointer-events: none;
transition: .3s ease;
position: absolute;
right: 100%;
bottom: 50%;
margin-right: -6px;
margin-bottom: -6px;
}
&::after {
content: attr(tips);
background: #383838;
color: #fff;
padding: 8px 10px;
font-size: 12px;
line-height: 12px;
white-space: nowrap;
font-family: Helvetica, Arial, sans-serif;
font-weight: bold;
border-radius: 3px;
pointer-events: none;
transition: .3s ease;
position: absolute;
right: 100%;
bottom: 50%;
margin-right: 6px;
margin-bottom: -14px;
}
}
// 右侧提示
.tips-right {
position: relative;
&::before {
content: '';
position: absolute;
left: 100%;
bottom: 50%;
margin-left: -6px;
margin-bottom: -6px;
background: transparent;
border: 6px solid transparent;
border-right-color: #383838;
pointer-events: none;
transition: .3s ease;
}
&::after {
content: attr(tips);
position: absolute;
left: 100%;
bottom: 50%;
margin-left: 6px;
margin-bottom: -14px;
background: #383838;
color: #fff;
padding: 8px 10px;
font-size: 12px;
line-height: 12px;
white-space: nowrap;
font-family: Helvetica, Arial, sans-serif;
font-weight: bold;
border-radius: 3px;
pointer-events: none;
transition: .3s ease;
}
}
// 隐藏tips
.tips-hidden {
&::before,
&::after {
opacity: 0;
visibility: hidden;
transition: opacity 0.5s ease, visibility 0.5s ease;
}
}
// 鼠标移上去时显示
.tips-hover {
&:hover::before,
&:hover::after {
opacity: 1;
visibility: visible;
}
}
.cursor-grab{
cursor: grab;
}
.cursor-grabbing{
cursor: grabbing;
}
IconTipsHotspotModel.ts定义热定类型
function generateUUID() {
let d = new Date().getTime();
let d2 = (window.performance && window.performance.now && (window.performance.now() * 1000)) || 0;
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function (c) {
let r = Math.random() * 16;
if (d > 0) {
r = (d + r) % 16 | 0;
d = Math.floor(d / 16);
} else {
r = (d2 + r) % 16 | 0;
d2 = Math.floor(d2 / 16);
}
return (c === 'x' ? r : (r & 0x3 | 0x8)).toString(16);
}).replace(/-/g, 'N').toUpperCase();
}
type TipsPosition = 'top' | 'bottom' | 'left' | 'right';
export default class IconTipsHotspotModel {
id: string;
url: string;
totalFrames?: number | undefined;
animationDuration?: number | undefined;
width: number;
height: number;
showTips?: boolean = false;
isHoverShowTips?: boolean = false;
tips?: string = '';
tipsPosition?: TipsPosition = 'top';
iconRotate?: number = 0;
iconScale?: number = 1;
left?: number;
top?: number;
constructor(url: string, width: number, height: number) {
this.id = generateUUID();
this.url = url;
this.width = width;
this.height = height;
}
}
FrameImage.vue动画帧组件
IconTipsHotspot.vue热点组件
<script setup lang="ts">
import { ref, computed } from 'vue'
import FrameImage from './FrameImage.vue'
import '../css/common.scss'
import { Delete, Edit } from '@element-plus/icons-vue'
type TipsPosition = 'top' | 'bottom' | 'left' | 'right';
const emit = defineEmits(['changePosition', 'delete', 'edit',]);
const props = withDefaults(defineProps<{
model?: 'edit' | 'view',
id: string,
url: string,
totalFrames?: number | undefined,
animationDuration?: number | undefined,
width: number,
height: number,
showTips?: boolean,
isHoverShowTips?: boolean,
tips?: string,
tipsPosition?: TipsPosition,
iconRotate?: number,
iconScale?: number,
left?: number;
top?: number;
}>(), {
model: () => ('view'),
width: () => (60),
height: () => (60),
showTips: () => (false),
isHoverShowTips: () => (false),
tipsPosition: () => ('top'),
iconRotate: () => (0),
iconScale: () => (1)
})
const isHovered = ref(false);
let hideTimeout: number | undefined = undefined;
const mouseoverHotspot = () => {
if (props.model == 'view') {
return;
}
if (hideTimeout) {
clearTimeout(hideTimeout);
hideTimeout = undefined;
}
isHovered.value = true;
}
const mouseleaveHotspot = () => {
if (props.model == 'view') {
return;
}
if (isHovered.value) {
hideTimeout = setTimeout(() => {
isHovered.value = false;
hideTimeout = undefined;
}, 500);
}
}
const dragging = ref(false)
const start = ref([0, 0])
const draggableRef = ref();
function startDrag(event: MouseEvent) {
if (props.model == 'view') {
return;
}
dragging.value = true;
const { clientX, clientY } = event;
start.value = [clientX, clientY];
document.addEventListener('mousemove', onDrag);
document.addEventListener('mouseup', stopDrag);
document.addEventListener('mouseleave', stopDrag);
event.preventDefault();
event.stopPropagation();
}
function onDrag(event: MouseEvent) {
if (!dragging.value) return;
const { clientX, clientY } = event;
const moveLeft = clientX - start.value[0];
const moveTop = clientY - start.value[1];
if (draggableRef.value) {
const { offsetLeft: left, offsetTop: top } = draggableRef.value;
const [newLeft, newTop] = [left + moveLeft, top + moveTop];
emit('changePosition', props.id, [newLeft, newTop]);
}
start.value = [clientX, clientY];
}
function stopDrag(event: MouseEvent) {
dragging.value = false;
document.removeEventListener('mousemove', onDrag);
document.removeEventListener('mouseup', stopDrag);
document.removeEventListener('mouseleave', stopDrag);
event.preventDefault();
event.preventDefault();
}
const comp = computed(() => {
const { totalFrames, animationDuration } = props;
if (totalFrames && animationDuration) {
return FrameImage;
}
return 'img';
})
const getTipsClass = computed(() => {
const { tipsPosition, showTips, isHoverShowTips, tips } = props;
return {
'cursor-grab': !dragging.value && props.model == 'edit',
'cursor-grabbing': dragging.value && props.model == 'edit',
'tips-top': tipsPosition == 'top',
'tips-bottom': tipsPosition == 'bottom',
'tips-left': tipsPosition == 'left',
'tips-right': tipsPosition == 'right',
'tips-hidden': !showTips || isHoverShowTips || !tips,
'tips-hover': showTips && isHoverShowTips && tips,
'target-selector-visible': isHovered.value
};
})
</script>
<template>
<div ref="draggableRef" :style="{ width: `${width}px`, height: `${height}px`, left: `${left}px`, top: `${top}px` }"
class="hotspot-01" :class="getTipsClass" :tips="tips" @mouseover="mouseoverHotspot"
@mouseleave="mouseleaveHotspot">
<div @mousedown="startDrag" :style="{ transform: `scale(${iconScale})` }" class="hotspot-image-wrapper">
<component :style="{ transform: `rotate(${iconRotate}deg)` }" class="hotspot-image" :is="comp"
ref="componentRef" :src="url" :url="url" :totalFrames="totalFrames"
:animationDuration="animationDuration"></component>
</div>
<div class="hotspot-tool hotspot-remove">
<el-button @click="emit('delete', id)" class="icon" type="danger" :icon="Delete" circle />
</div>
<div class="hotspot-tool hotspot-edit">
<el-button @click="emit('edit', id)" class="icon" type="primary" :icon="Edit" circle />
</div>
</div>
</template>
<style scoped lang="scss">
.hotspot-01 {
position: absolute;
user-select: none;
display: flex;
align-items: center;
justify-content: center;
.hotspot-image-wrapper {
width: 100%;
height: 100%;
user-select: none;
.hotspot-image {
width: 100%;
height: 100%;
opacity: .8;
border: 0;
user-select: none;
}
}
.hotspot-tool {
width: 25px;
height: 25px;
position: absolute;
border-radius: 50%;
// background-color: #333;
transition-delay: 1s;
transition: bottom 200ms, left 200ms, right 200ms, transform 200ms, opacity 200ms;
opacity: 0;
pointer-events: none; // 不能被鼠标选中
display: flex;
align-items: center;
justify-content: center;
.icon {
width: 100%;
height: 100%;
}
}
}
.target-selector-visible {
.hotspot-tool {
opacity: 1;
transition-delay: 0s;
cursor: pointer;
pointer-events: unset; // 不能被鼠标选中
}
.hotspot-remove {
bottom: 0;
left: 0;
transform: translateX(-20px) translateY(20px);
}
.hotspot-edit {
bottom: 0;
right: 0;
transform: translateX(20px) translateY(20px);
}
}
</style>
IconTipsHotspotFormDialog.vue编辑热点属性弹框
<script setup lang="ts">
import { ref, reactive, watch } from 'vue'
import FrameImage from './FrameImage.vue'
import ICON from '../trends10001.png'
import ICON2 from '../audio002.png'
import IconTipsHotspotModel from '../models/IconTipsHotspotModel'
import IconTipsHotspot from './IconTipsHotspot.vue'
const emit = defineEmits(['update:modelValue', 'confirm']);
const props = withDefaults(defineProps<{
modelValue: boolean,
}>(), {
modelValue: () => (false),
})
const model = ref<'new' | 'edit'>('new');
const form = reactive<IconTipsHotspotModel>(new IconTipsHotspotModel('', 60, 60));
watch(() => form.url, (newValue) => {
if (newValue == ICON) {
form.totalFrames = 10;
form.animationDuration = 1;
} else {
form.totalFrames = undefined;
form.animationDuration = undefined;
}
})
const newForm = () => {
model.value = 'new';
Object.assign(form, new IconTipsHotspotModel(ICON2, 60, 60));
}
const editForm = (f: IconTipsHotspotModel) => {
model.value = 'edit';
Object.assign(form, f);
}
defineExpose({ newForm, editForm })
const confirm = () => {
emit('confirm', model.value, { ...form });
}
</script>
<template>
<el-dialog :model-value="modelValue" :title="`${model == 'new' ? '新增' : '编辑'}热点`" width="800"
:before-close="(done: () => void) => emit('update:modelValue', false)" destroy-on-close>
<div>
<el-form :model="form" label-width="auto" size="small">
<div style="display: flex;align-items: center;justify-content: space-between;">
<el-form-item label="热点宽px">
<el-input v-model="form.width" />
</el-form-item>
<el-form-item label="热点高px">
<el-input v-model="form.height" />
</el-form-item>
</div>
<el-form-item label="选择图标">
<el-radio-group v-model="form.url">
<el-radio :value="ICON2" size="large">
<img class="icon" :src="ICON2" />
</el-radio>
<el-radio :value="ICON" size="large">
<FrameImage class="icon" :url="ICON" :totalFrames="10" :animationDuration="1" />
</el-radio>
</el-radio-group>
</el-form-item>
<div style="display: flex;align-items: center;justify-content: space-between;">
<el-form-item label="图标地址">
<el-input v-model="form.url" disabled />
</el-form-item>
<el-form-item label="总帧数">
<el-input v-model="form.totalFrames" disabled />
</el-form-item>
<el-form-item label="持续时间秒">
<el-input v-model="form.animationDuration" disabled />
</el-form-item>
</div>
<el-form-item label="图标旋转角度">
<el-slider v-model="form.iconRotate" :max="180" />
</el-form-item>
<el-form-item label="图标缩放倍数">
<el-slider v-model="form.iconScale" :min="0" :max="2" :step="0.1" />
</el-form-item>
<div style="display: flex;align-items: center;justify-content: space-between;">
<el-form-item label="是否显示提示">
<el-switch v-model="form.showTips" inline-prompt active-text="是" inactive-text="否" />
</el-form-item>
<el-form-item v-if="form.showTips" label="是否鼠标移上去时显示提示">
<el-switch v-model="form.isHoverShowTips" inline-prompt active-text="是" inactive-text="否" />
</el-form-item>
<el-form-item v-if="form.showTips" label="提示位置">
<el-radio-group v-model="form.tipsPosition">
<el-radio-button label="上" value="top" />
<el-radio-button label="下" value="bottom" />
<el-radio-button label="左" value="left" />
<el-radio-button label="右" value="right" />
</el-radio-group>
</el-form-item>
</div>
<el-form-item v-if="form.showTips" label="提示文字">
<el-input v-model="form.tips" />
</el-form-item>
<el-form-item>
<div
style="width:100%;height:200px;position:relative;display:flex;align-items: center;justify-content:center;border: 1px solid #f2f2f2;background-color: black;">
<IconTipsHotspot v-bind="form" style="left: unset;top:unset;"/>
</div>
</el-form-item>
</el-form>
</div>
<template #footer>
<div class="dialog-footer">
<el-button @click="emit('update:modelValue', false)">取消</el-button>
<el-button type="primary" @click="confirm()">确认</el-button>
</div>
</template>
</el-dialog>
</template>
<style scoped lang="scss">
.icon {
width: 50px;
height: 50px;
}
</style>
HotspotView.vue编辑页面组件
<script setup lang="ts">
import { ref } from 'vue'
import IconTipsHotspotFormDialog from './components/IconTipsHotspotFormDialog.vue'
import IconTipsHotspot from './components/IconTipsHotspot.vue'
import './css/common.scss'
import IconTipsHotspotModel from './models/IconTipsHotspotModel'
const iconTipsHotspotFormDialogRef = ref<InstanceType<typeof IconTipsHotspotFormDialog>>();
const iconTipsHotspotList = ref<Array<IconTipsHotspotModel>>([]);
const showIconTipsHotspotFormDialog = ref(false);
const newIconTipsHotspot = () => {
iconTipsHotspotFormDialogRef.value?.newForm();
showIconTipsHotspotFormDialog.value = true;
}
const changeHotspotPosition = (id: string, [left, top]: Array<number>) => {
console.log('热点位置修改:' + id + [left, top]);
const find = iconTipsHotspotList.value.find(i => i.id == id);
if (find) {
find.left = left;
find.top = top;
}
}
const deleteHotspot = (id: string) => {
console.log('删除热点:' + id);
const findIndex = iconTipsHotspotList.value.findIndex(i => i.id == id);
iconTipsHotspotList.value.splice(findIndex, 1);
}
const editHotspot = (id: string) => {
console.log('编辑热点:' + id);
const find = iconTipsHotspotList.value.find(i => i.id == id);
if (find) {
iconTipsHotspotFormDialogRef.value?.editForm(find);
showIconTipsHotspotFormDialog.value = true;
}
}
const confirmIconTipsHotspotForm = (model: string, form: IconTipsHotspotModel) => {
console.log(model, form)
if (model == 'new') {
iconTipsHotspotList.value.push({
...form
});
} else {
const find = iconTipsHotspotList.value.find(i => i.id == form.id);
if (find) {
Object.assign(find, form);
}
}
showIconTipsHotspotFormDialog.value = false;
}
</script>
<template>
<div class="content-in">
<el-button class="new-btn" type="primary" @click="newIconTipsHotspot">新增热点</el-button>
<IconTipsHotspot model="edit" v-for="(compParams, index) in iconTipsHotspotList" :key="index"
v-bind="compParams" @delete="deleteHotspot" @edit="editHotspot" @changePosition="changeHotspotPosition" />
<IconTipsHotspotFormDialog ref="iconTipsHotspotFormDialogRef" v-model="showIconTipsHotspotFormDialog"
@confirm="confirmIconTipsHotspotForm" />
</div>
</template>
<style scoped lang="scss">
.content-in {
flex: 1;
overflow: hidden;
position: relative;
display: flex;
align-items: center;
justify-content: center;
.new-btn {
position: absolute;
right: 0px;
top: 0px;
}
}
</style>