vue实现图标提示热点新增修改删除

170 阅读8分钟

图标提示热点组件封装

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

2024-11-10 10-39-40-c-1731208611194.gif

依赖安装

组件

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;
        /* hover时可见 */
        visibility: visible;
        /* hover时显示 */
    }
}
.cursor-grab{
    cursor: grab;
}
.cursor-grabbing{
    cursor: grabbing;
}

IconTipsHotspotModel.ts定义热定类型

  • 定义图标提示class
/**
 * 生成唯一id
 */
function generateUUID() {
    let d = new Date().getTime(); //Timestamp
    let d2 = (window.performance && window.performance.now && (window.performance.now() * 1000)) || 0; //Time in microseconds since page-load or 0 if unsupported
    return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function (c) {
        let r = Math.random() * 16; //random number between 0 and 16
        if (d > 0) { //Use timestamp until depleted
            r = (d + r) % 16 | 0;
            d = Math.floor(d / 16);
        } else { //Use microseconds since page-load if supported
            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;// 唯一id
    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">
            <!-- 普通图片:<img class="hotspot-image" :src="url" /> -->
            <!-- 帧图片:<FrameImage class="hotspot-image" :url="ICON" :totalFrames="10" :animationDuration="1" /> -->
            <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;
}

// 热点拖拽修改位置,后面是交给threejs中CSS2DRender进行管理,动态改变CSS2DObject的位置
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>
        <!-- 渲染ICON热点 -->
        <IconTipsHotspot model="edit" v-for="(compParams, index) in iconTipsHotspotList" :key="index"
            v-bind="compParams" @delete="deleteHotspot" @edit="editHotspot" @changePosition="changeHotspotPosition" />
        <!-- 新增or修改热点属性弹框 -->
        <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>