227 lines
6.5 KiB
Vue
227 lines
6.5 KiB
Vue
<template>
|
|
<div class="grid-canvas" ref="container">
|
|
<canvas ref="canvas" :width="width" :height="height" />
|
|
</div>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { ref, onMounted, onBeforeUnmount, watch } from 'vue';
|
|
|
|
const props = defineProps({
|
|
masterImageUrl: { type: String, required: true },
|
|
cellSize: { type: Number, required: true },
|
|
});
|
|
|
|
const emit = defineEmits(['selection-changed']);
|
|
|
|
const container = ref(null);
|
|
const canvas = ref(null);
|
|
let ctx = null;
|
|
|
|
const width = 1200;
|
|
const height = 800;
|
|
|
|
let isPanning = false;
|
|
let start = { x: 0, y: 0 };
|
|
const offset = { x: 0, y: 0 };
|
|
let scale = 1;
|
|
let pinchActive = false;
|
|
let lastDistance = 0;
|
|
|
|
// selection in pixel coordinates relative to canvas (for drawing)
|
|
let selection = null; // {x,y,w,h} in pixels
|
|
let selecting = false; // whether we are drawing a selection rect
|
|
|
|
function drawGrid() {
|
|
if (!ctx) return;
|
|
ctx.clearRect(0, 0, width, height);
|
|
|
|
// Draw master image background if available
|
|
if (bgImage && bgImage.complete) {
|
|
ctx.drawImage(bgImage, offset.x, offset.y, width * scale, height * scale);
|
|
} else {
|
|
ctx.fillStyle = '#f3f3f3';
|
|
ctx.fillRect(0, 0, width, height);
|
|
}
|
|
|
|
// Draw grid lines
|
|
ctx.strokeStyle = 'rgba(0,0,0,0.08)';
|
|
const step = props.cellSize * scale;
|
|
for (let x = offset.x % step; x < width; x += step) {
|
|
ctx.beginPath();
|
|
ctx.moveTo(x, 0);
|
|
ctx.lineTo(x, height);
|
|
ctx.stroke();
|
|
}
|
|
for (let y = offset.y % step; y < height; y += step) {
|
|
ctx.beginPath();
|
|
ctx.moveTo(0, y);
|
|
ctx.lineTo(width, y);
|
|
ctx.stroke();
|
|
}
|
|
|
|
// Draw selection (pixel coords)
|
|
if (selection) {
|
|
ctx.fillStyle = 'rgba(34,197,94,0.2)';
|
|
ctx.fillRect(selection.x, selection.y, selection.w, selection.h);
|
|
ctx.strokeStyle = 'rgba(34,197,94,0.8)';
|
|
ctx.strokeRect(selection.x, selection.y, selection.w, selection.h);
|
|
}
|
|
}
|
|
|
|
let bgImage = null;
|
|
watch(() => props.masterImageUrl, (v) => {
|
|
if (!v) return;
|
|
bgImage = new Image();
|
|
bgImage.src = v;
|
|
bgImage.onload = () => drawGrid();
|
|
});
|
|
|
|
onMounted(() => {
|
|
const c = canvas.value;
|
|
ctx = c.getContext('2d');
|
|
drawGrid();
|
|
|
|
c.addEventListener('pointerdown', (e) => {
|
|
// Determine if this is a selection or a pan.
|
|
// Desktop: hold Shift to select. Touch: treat as selection.
|
|
const rect = c.getBoundingClientRect();
|
|
const canvasX = e.clientX - rect.left;
|
|
const canvasY = e.clientY - rect.top;
|
|
|
|
if (e.pointerType === 'touch' || e.shiftKey) {
|
|
selecting = true;
|
|
selection = { x: canvasX, y: canvasY, w: 0, h: 0 };
|
|
// emit initial empty selection
|
|
emit('selection-changed', null);
|
|
} else {
|
|
isPanning = true;
|
|
start = { x: e.clientX, y: e.clientY };
|
|
}
|
|
});
|
|
|
|
// Touch pinch handlers (for older browsers this may be redundant with pointer events)
|
|
c.addEventListener('touchstart', (e) => {
|
|
if (e.touches && e.touches.length === 2) {
|
|
pinchActive = true;
|
|
const dx = e.touches[0].clientX - e.touches[1].clientX;
|
|
const dy = e.touches[0].clientY - e.touches[1].clientY;
|
|
lastDistance = Math.hypot(dx, dy);
|
|
}
|
|
}, { passive: false });
|
|
|
|
c.addEventListener('touchmove', (e) => {
|
|
if (!pinchActive || !e.touches || e.touches.length < 2) return;
|
|
const dx = e.touches[0].clientX - e.touches[1].clientX;
|
|
const dy = e.touches[0].clientY - e.touches[1].clientY;
|
|
const dist = Math.hypot(dx, dy);
|
|
if (lastDistance <= 0) {
|
|
lastDistance = dist;
|
|
return;
|
|
}
|
|
const factor = dist / lastDistance;
|
|
const newScale = Math.min(4, Math.max(0.5, scale * factor));
|
|
// adjust offset to keep midpoint stable
|
|
const rect = c.getBoundingClientRect();
|
|
const midX = (e.touches[0].clientX + e.touches[1].clientX) / 2 - rect.left;
|
|
const midY = (e.touches[0].clientY + e.touches[1].clientY) / 2 - rect.top;
|
|
|
|
// compute world coords of midpoint
|
|
const worldX = (midX - offset.x) / scale;
|
|
const worldY = (midY - offset.y) / scale;
|
|
|
|
scale = newScale;
|
|
|
|
// recompute offset so world point stays under midpoint
|
|
offset.x = midX - worldX * scale;
|
|
offset.y = midY - worldY * scale;
|
|
|
|
lastDistance = dist;
|
|
drawGrid();
|
|
e.preventDefault();
|
|
}, { passive: false });
|
|
|
|
c.addEventListener('touchend', (e) => {
|
|
if (!e.touches || e.touches.length < 2) {
|
|
pinchActive = false;
|
|
lastDistance = 0;
|
|
}
|
|
});
|
|
|
|
c.addEventListener('pointermove', (e) => {
|
|
const rect = c.getBoundingClientRect();
|
|
const canvasX = e.clientX - rect.left;
|
|
const canvasY = e.clientY - rect.top;
|
|
|
|
if (selecting && selection) {
|
|
const x = Math.min(selection.x, canvasX);
|
|
const y = Math.min(selection.y, canvasY);
|
|
const w = Math.abs(canvasX - selection.x);
|
|
const h = Math.abs(canvasY - selection.y);
|
|
selection.x = x;
|
|
selection.y = y;
|
|
selection.w = w;
|
|
selection.h = h;
|
|
drawGrid();
|
|
|
|
// Map pixel selection to cell units and emit
|
|
const relX = selection.x - offset.x;
|
|
const relY = selection.y - offset.y;
|
|
const cellSize = props.cellSize;
|
|
const cellX = Math.max(0, Math.floor(relX / cellSize));
|
|
const cellY = Math.max(0, Math.floor(relY / cellSize));
|
|
const wCells = Math.max(1, Math.ceil(selection.w / cellSize));
|
|
const hCells = Math.max(1, Math.ceil(selection.h / cellSize));
|
|
|
|
emit('selection-changed', { x: cellX, y: cellY, w: wCells, h: hCells });
|
|
return;
|
|
}
|
|
|
|
if (!isPanning) return;
|
|
const dx = e.clientX - start.x;
|
|
const dy = e.clientY - start.y;
|
|
offset.x += dx;
|
|
offset.y += dy;
|
|
start = { x: e.clientX, y: e.clientY };
|
|
drawGrid();
|
|
});
|
|
|
|
c.addEventListener('pointerup', () => {
|
|
if (selecting && selection) {
|
|
// finalize selection and emit one last time
|
|
const relX = selection.x - offset.x;
|
|
const relY = selection.y - offset.y;
|
|
const cellSize = props.cellSize;
|
|
const cellX = Math.max(0, Math.floor(relX / cellSize));
|
|
const cellY = Math.max(0, Math.floor(relY / cellSize));
|
|
const wCells = Math.max(1, Math.ceil(selection.w / cellSize));
|
|
const hCells = Math.max(1, Math.ceil(selection.h / cellSize));
|
|
|
|
emit('selection-changed', { x: cellX, y: cellY, w: wCells, h: hCells });
|
|
selecting = false;
|
|
}
|
|
isPanning = false;
|
|
});
|
|
});
|
|
|
|
onBeforeUnmount(() => {
|
|
// cleanup if needed
|
|
});
|
|
|
|
// Note: This is a small scaffold: selection math and precise cell mapping
|
|
// will be expanded in future iterations. Emit selection-changed with cell units.
|
|
|
|
</script>
|
|
|
|
<style scoped>
|
|
.grid-canvas {
|
|
width: 100%;
|
|
height: 100%;
|
|
background: #fff;
|
|
}
|
|
canvas {
|
|
max-width: 100%;
|
|
border: 1px solid rgba(0,0,0,0.06);
|
|
}
|
|
</style>
|