Creating an Interactive Image Hotspot Editor in Vue 3

This article presents a comprehensive solution for implementing an image hotspot editor using Vue 3. The component alows users to define clickable regions on an image, move and resize them, and assign metadata such as links. We will explore two implementations: one using TypeScript with the Composition API, and another using standard JavaScript.

Vue 3 + TypeScript Implementation

This version leverages TypeScript for type safety and uses a declarative rendering approach with v-for to manage hotspot elements. The logic is designed to support drawing in any directoin, calculating coordinates dynamically.

<template>
  <el-dialog v-model="visible" fullscreen append-to-body title="Edit Hotspots" @close="handleClose">
    <div class="editor-container">
      <!-- Left Panel: Image Canvas -->
      <div class="canvas-panel">
        <div 
          ref="containerRef" 
          class="image-wrapper"
          @mousedown="startDrawing"
          @mousemove="onDrawing"
          @mouseup="stopDrawing"
          @mouseleave="stopDrawing"
        >
          <img :src="imageData.url" class="base-image" draggable="false" />
          
          <!-- Drawing Preview -->
          <div v-if="isDrawing" class="hotspot-preview" :style="previewStyle"></div>

          <!-- Existing Hotspots -->
          <div 
            v-for="(spot, index) in imageData.zones" 
            :key="index" 
            class="hotspot-item"
            :style="getHotspotStyle(spot)"
            @mousedown.stop="startMoving(index, $event)"
          >
            <div class="hotspot-content">
              <span>{{ spot.name }}</span>
            </div>
            <button class="delete-btn" @click.stop="removeSpot(index)">×</button>
            <div class="resize-handle" @mousedown.stop="startResizing(index, $event)"></div>
          </div>
        </div>
      </div>

      <!-- Right Panel: List Editor -->
      <div class="config-panel">
        <h3>Hotspot List</h3>
        <div v-for="(spot, index) in imageData.zones" :key="index" class="config-item">
          <el-input v-model="spot.name" placeholder="Name" />
          <el-input v-model="spot.link" placeholder="URL" />
          <el-button type="danger" @click="removeSpot(index)">Delete</el-button>
        </div>
      </div>
    </div>
    <template #footer>
      <el-button type="primary" @click="saveChanges">Confirm</el-button>
    </template>
  </el-dialog>
</template>

<script lang="ts" setup>
import { ref, reactive, computed, watch } from 'vue';

interface Coordinates {
  x: number;
  y: number;
  width: number;
  height: number;
}

interface Hotspot extends Coordinates {
  name: string;
  link: string;
}

interface Props {
  modelValue: { url: string; zones: Hotspot[] };
}

const props = defineProps<Props>();
const emit = defineEmits(['update:modelValue']);

const visible = ref(false);
const containerRef = ref<HTMLElement | null>(null);

const imageData = reactive({
  url: '',
  zones: [] as Hotspot[]
});

// Watch for external changes
watch(() => props.modelValue, (newVal) => {
  imageData.url = newVal.url;
  imageData.zones = JSON.parse(JSON.stringify(newVal.zones));
}, { immediate: true, deep: true });

// Drawing State
const isDrawing = ref(false);
const startPoint = reactive({ x: 0, y: 0 });
const currentPoint = reactive({ x: 0, y: 0 });

// Interaction Mode
const interactionMode = ref<'draw' | 'move' | 'resize' | null>(null);
const activeIndex = ref(-1);
const dragOffset = reactive({ x: 0, y: 0 });

// Computed style for the preview rectangle
const previewStyle = computed(() => {
  const w = Math.abs(currentPoint.x - startPoint.x);
  const h = Math.abs(currentPoint.y - startPoint.y);
  const l = Math.min(startPoint.x, currentPoint.x);
  const t = Math.min(startPoint.y, currentPoint.y);
  
  return {
    left: `${l}px`,
    top: `${t}px`,
    width: `${w}px`,
    height: `${h}px`
  };
});

// Helper to get coordinate relative to container
const getRelativeCoords = (e: MouseEvent) => {
  if (!containerRef.value) return { x: 0, y: 0 };
  const rect = containerRef.value.getBoundingClientRect();
  return {
    x: e.clientX - rect.left,
    y: e.clientY - rect.top
  };
};

const startDrawing = (e: MouseEvent) => {
  // Only start if clicking the container directly, not a hotspot
  if ((e.target as HTMLElement).classList.contains('hotspot-item')) return;
  
  const coords = getRelativeCoords(e);
  startPoint.x = coords.x;
  startPoint.y = coords.y;
  currentPoint.x = coords.x;
  currentPoint.y = coords.y;
  isDrawing.value = true;
  interactionMode.value = 'draw';
};

const onDrawing = (e: MouseEvent) => {
  if (!isDrawing.value) return;
  
  const coords = getRelativeCoords(e);
  
  if (interactionMode.value === 'draw') {
    currentPoint.x = coords.x;
    currentPoint.y = coords.y;
  } else if (interactionMode.value === 'move' && activeIndex.value !== -1) {
    const spot = imageData.zones[activeIndex.value];
    spot.x = Math.max(0, coords.x - dragOffset.x);
    spot.y = Math.max(0, coords.y - dragOffset.y);
    // Boundary checks omitted for brevity
  } else if (interactionMode.value === 'resize' && activeIndex.value !== -1) {
    const spot = imageData.zones[activeIndex.value];
    spot.width = Math.max(10, coords.x - spot.x);
    spot.height = Math.max(10, coords.y - spot.y);
  }
};

const stopDrawing = () => {
  if (interactionMode.value === 'draw') {
    const w = Math.abs(currentPoint.x - startPoint.x);
    const h = Math.abs(currentPoint.y - startPoint.y);
    
    if (w > 15 && h > 15) {
      imageData.zones.push({
        x: Math.min(startPoint.x, currentPoint.x),
        y: Math.min(startPoint.y, currentPoint.y),
        width: w,
        height: h,
        name: `Area ${imageData.zones.length + 1}`,
        link: ''
      });
    }
  }
  
  isDrawing.value = false;
  interactionMode.value = null;
  activeIndex.value = -1;
};

const startMoving = (index: number, e: MouseEvent) => {
  interactionMode.value = 'move';
  activeIndex.value = index;
  isDrawing.value = true; // Reuse flag for mouse tracking
  
  const coords = getRelativeCoords(e);
  dragOffset.x = coords.x - imageData.zones[index].x;
  dragOffset.y = coords.y - imageData.zones[index].y;
};

const startResizing = (index: number, e: MouseEvent) => {
  interactionMode.value = 'resize';
  activeIndex.value = index;
  isDrawing.value = true;
};

const removeSpot = (index: number) => {
  imageData.zones.splice(index, 1);
};

const getHotspotStyle = (spot: Hotspot) => {
  return {
    left: `${spot.x}px`,
    top: `${spot.y}px`,
    width: `${spot.width}px`,
    height: `${spot.height}px`
  };
};

const handleClose = () => {
  visible.value = false;
};

const saveChanges = () => {
  emit('update:modelValue', JSON.parse(JSON.stringify(imageData)));
  handleClose();
};

const open = () => {
  visible.value = true;
};

defineExpose({ open });
</script>

<style scoped>
.editor-container {
  display: flex;
  height: 80vh;
  gap: 20px;
}

.canvas-panel {
  flex: 2;
  overflow: auto;
  background: #f5f5f5;
  position: relative;
}

.image-wrapper {
  position: relative;
  display: inline-block;
  cursor: crosshair;
}

.base-image {
  display: block;
  max-width: 100%;
  user-select: none;
}

.hotspot-preview {
  position: absolute;
  border: 2px dashed #1890ff;
  background: rgba(24, 144, 255, 0.2);
  pointer-events: none;
}

.hotspot-item {
  position: absolute;
  border: 1px solid #40a9ff;
  background: rgba(24, 144, 255, 0.15);
  cursor: move;
  box-sizing: border-box;
}

.hotspot-content {
  padding: 5px;
  font-size: 12px;
  color: #333;
}

.delete-btn {
  position: absolute;
  top: 0;
  right: 0;
  background: #ff4d4f;
  color: white;
  border: none;
  border-radius: 0 0 0 4px;
  cursor: pointer;
  width: 20px;
  height: 20px;
}

.resize-handle {
  position: absolute;
  bottom: 0;
  right: 0;
  width: 10px;
  height: 10px;
  cursor: nwse-resize;
  background: transparent;
  border-top: 2px solid #40a9ff;
  border-left: 2px solid #40a9ff;
}

.config-panel {
  flex: 1;
  border-left: 1px solid #eee;
  padding-left: 20px;
  overflow-y: auto;
}

.config-item {
  display: flex;
  gap: 10px;
  margin-bottom: 15px;
}
</style>

Vue 3 + JavaScript Implementation

This implementation uses standard JavaScript with the Composition API. Instead of calculating styles on the fly, it uses a simpler state management approach and focuses on imperative updates during interaction events.

<template>
  <div class="hotspot-editor">
    <el-button @click="openEditor">Open Editor</el-button>
    
    <el-dialog v-model="dialogVisible" title="Image Hotspot Configuration" width="90%" top="5vh">
      <div class="main-content">
        <div class="image-stage" ref="stageRef">
          <img :src="modelValue.url" style="width: 100%; pointer-events: none;" />
          
          <!-- Render areas -->
          <div 
            v-for="(area, idx) in localAreas" 
            :key="idx" 
            class="hotspot-zone"
            :style="{
              left: area.x + 'px', 
              top: area.y + 'px', 
              width: area.w + 'px', 
              height: area.h + 'px'
            }"
            @mousedown="initMove($event, idx)"
          >
            <div class="zone-label">{{ area.label }}</div>
            <i class="remove-icon" @click.stop="deleteArea(idx)">×</i>
            <span class="resize-dot" @mousedown.stop="initResize($event, idx)"></span>
          </div>

          <!-- Temporary drawing box -->
          <div 
            v-show="isDragging" 
            class="draw-box" 
            :style="drawStyle"
          ></div>
        </div>

        <div class="settings-panel">
          <div v-for="(area, idx) in localAreas" :key="'form-' + idx" class="form-row">
            <el-input v-model="area.label" size="small" placeholder="Label" />
            <el-input v-model="area.url" size="small" placeholder="Link URL" />
          </div>
        </div>
      </div>
      
      <template #footer>
        <el-button @click="dialogVisible = false">Cancel</el-button>
        <el-button type="primary" @click="submitData">Save</el-button>
      </template>
    </el-dialog>
  </div>
</template>

<script setup>
import { ref, reactive, watch } from 'vue';

const props = defineProps({
  modelValue: Object
});

const emit = defineEmits(['update:modelValue']);

const dialogVisible = ref(false);
const stageRef = ref(null);

// Local working copy
const localAreas = ref([]);

// Mouse state
const isDragging = ref(false);
const drawStart = reactive({ x: 0, y: 0 });
const drawEnd = reactive({ x: 0, y: 0 });
const drawStyle = reactive({
  left: '0px', top: '0px', width: '0px', height: '0px'
});

const activeAreaIndex = ref(-1);
const mode = ref(''); // 'move' or 'resize'

watch(dialogVisible, (val) => {
  if (val) {
    // Deep clone initial data
    localAreas.value = JSON.parse(JSON.stringify(props.modelValue.areas || []));
  }
});

const openEditor = () => {
  dialogVisible.value = true;
};

const getPos = (e) => {
  const rect = stageRef.value.getBoundingClientRect();
  return {
    x: e.clientX - rect.left,
    y: e.clientY - rect.top
  };
};

// Global mouse handlers for clean interaction
const handleMouseMove = (e) => {
  if (!isDragging.value) return;
  
  const pos = getPos(e);
  
  if (mode.value === 'draw') {
    drawEnd.x = pos.x;
    drawEnd.y = pos.y;
    
    // Update visual style
    const w = Math.abs(drawEnd.x - drawStart.x);
    const h = Math.abs(drawEnd.y - drawStart.y);
    drawStyle.left = Math.min(drawStart.x, drawEnd.x) + 'px';
    drawStyle.top = Math.min(drawStart.y, drawEnd.y) + 'px';
    drawStyle.width = w + 'px';
    drawStyle.height = h + 'px';
    
  } else if (mode.value === 'move' && activeAreaIndex.value !== -1) {
    const area = localAreas.value[activeAreaIndex.value];
    area.x = pos.x - area.offsetX;
    area.y = pos.y - area.offsetY;
    
  } else if (mode.value === 'resize' && activeAreaIndex.value !== -1) {
    const area = localAreas.value[activeAreaIndex.value];
    area.w = Math.max(20, pos.x - area.x);
    area.h = Math.max(20, pos.y - area.y);
  }
};

const handleMouseUp = () => {
  if (mode.value === 'draw') {
    const w = parseInt(drawStyle.width);
    const h = parseInt(drawStyle.height);
    
    if (w > 10 && h > 10) {
      localAreas.value.push({
        x: parseInt(drawStyle.left),
        y: parseInt(drawStyle.top),
        w: w,
        h: h,
        label: 'New Area',
        url: ''
      });
    }
    // Reset style
    drawStyle.width = '0px';
  }
  
  isDragging.value = false;
  mode.value = '';
  activeAreaIndex.value = -1;
  
  // Remove global listeners
  document.removeEventListener('mousemove', handleMouseMove);
  document.removeEventListener('mouseup', handleMouseUp);
};

// Interaction initializers
const onStageMouseDown = (e) => {
  if (e.target !== stageRef.value && !e.target.matches('img')) return;
  
  const pos = getPos(e);
  mode.value = 'draw';
  isDragging.value = true;
  
  drawStart.x = pos.x;
  drawStart.y = pos.y;
  drawEnd.x = pos.x;
  drawEnd.y = pos.y;
  
  drawStyle.left = pos.x + 'px';
  drawStyle.top = pos.y + 'px';
  
  document.addEventListener('mousemove', handleMouseMove);
  document.addEventListener('mouseup', handleMouseUp);
};

const initMove = (e, index) => {
  mode.value = 'move';
  isDragging.value = true;
  activeAreaIndex.value = index;
  
  const pos = getPos(e);
  const area = localAreas.value[index];
  area.offsetX = pos.x - area.x;
  area.offsetY = pos.y - area.y;
  
  document.addEventListener('mousemove', handleMouseMove);
  document.addEventListener('mouseup', handleMouseUp);
};

const initResize = (e, index) => {
  mode.value = 'resize';
  isDragging.value = true;
  activeAreaIndex.value = index;
  
  document.addEventListener('mousemove', handleMouseMove);
  document.addEventListener('mouseup', handleMouseUp);
};

const deleteArea = (index) => {
  localAreas.value.splice(index, 1);
};

const submitData = () => {
  emit('update:modelValue', {
    ...props.modelValue,
    areas: JSON.parse(JSON.stringify(localAreas.value))
  });
  dialogVisible.value = false;
};
</script>

<style scoped>
.main-content {
  display: flex;
  gap: 20px;
  height: 70vh;
}

.image-stage {
  flex: 3;
  position: relative;
  overflow: hidden;
  background: #eee;
  user-select: none;
}

.draw-box, .hotspot-zone {
  position: absolute;
  border: 2px dashed #0050b3;
  background: rgba(0, 80, 179, 0.15);
}

.hotspot-zone {
  cursor: move;
  display: flex;
  align-items: center;
  justify-content: center;
  border-style: solid;
  border-color: #1890ff;
}

.zone-label {
  color: #1890ff;
  font-weight: bold;
  font-size: 12px;
  background: rgba(255,255,255,0.8);
  padding: 2px 5px;
  border-radius: 3px;
}

.remove-icon {
  position: absolute;
  top: -8px;
  right: -8px;
  background: red;
  color: white;
  border-radius: 50%;
  width: 16px;
  height: 16px;
  text-align: center;
  line-height: 14px;
  font-style: normal;
  cursor: pointer;
  font-size: 12px;
}

.resize-dot {
  position: absolute;
  right: 0;
  bottom: 0;
  width: 8px;
  height: 8px;
  background: #1890ff;
  cursor: nwse-resize;
}

.settings-panel {
  flex: 1;
  border-left: 1px solid #ddd;
  padding-left: 20px;
  overflow-y: auto;
}

.form-row {
  display: flex;
  gap: 10px;
  margin-bottom: 10px;
}
</style>

Tags: vue3 TypeScript Image Hotspot Component Development frontend

Posted on Tue, 25 Aug 2026 16:16:58 +0000 by mrgrinch12