<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>Konva 可疑区域编辑器(非编辑模式显示锚点版)</title>
<style>
body { margin: 0; padding: 20px; font-family: sans-serif; }
#container { border: 1px solid #ccc; position: relative; overflow: hidden; }
#controls {
margin-top: 10px;
display: flex;
gap: 10px;
align-items: center;
}
button {
padding: 8px 16px;
}
.form-popup {
position: fixed;
top: 50%; left: 50%;
transform: translate(-50%, -50%);
background: white;
padding: 20px;
box-shadow: 0 4px 12px rgba(0,0,0,0.3);
z-index: 1000;
display: none;
}
.form-popup label { display: block; margin: 8px 0 4px; }
.form-popup select, .form-popup button {
width: 100%; padding: 6px; margin: 4px 0;
}
.icon-btn {
width: 40px;
height: 40px;
display: flex;
align-items: center;
justify-content: center;
font-size: 18px;
cursor: pointer;
border: 1px solid #ccc;
background: white;
}
.icon-btn.active {
background: #007bff;
color: white;
}
</style>
</head>
<body>
<div id="container"></div>
<div id="controls">
<button id="editBtn">进入编辑模式</button>
<button id="undoBtn">撤销 (Ctrl+Z)</button>
<button id="zoomInBtn">放大</button>
<button id="zoomOutBtn">缩小</button>
<div id="zoomDisplay">100%</div>
<div class="icon-btn" id="handBtn" title="拖拽模式">✋</div>
</div>
<div class="form-popup" id="popup">
<label>操作类型:</label>
<select id="opType">
<option value="add">新增</option>
<option value="edit">修改</option>
</select>
<label>瑕疵类型:</label>
<select id="defectType">
<option value="scratch">划痕</option>
<option value="stain">污渍</option>
<option value="dent">凹陷</option>
<option value="irregular">不规则瑕疵</option>
</select>
<button onclick="saveRegion()">确定</button>
<button onclick="cancelRegion()">取消</button>
</div>
<script src="https://unpkg.com/konva@9/konva.min.js"></script>
<script>
// ========== 模拟后端数据 ==========
const imageUrl = 'https://picsum.photos/800/600';
// 生成一个复杂不规则形状(例如:扰动的五角星)
function generateIrregularShape(cx, cy, outerR = 60, innerR = 25, spikes = 5) {
const points = [];
for (let i = 0; i < spikes * 2; i++) {
const radius = i % 2 === 0 ? outerR : innerR;
const angle = Math.PI / 2 + (i * Math.PI) / spikes;
const x = cx + radius * Math.cos(angle) + (Math.random() - 0.5) * 10; // 加随机扰动
const y = cy + radius * Math.sin(angle) + (Math.random() - 0.5) * 10;
points.push(x, y);
}
return points;
}
const irregularPoints = generateIrregularShape(600, 200);
const suspiciousRegions = [
{ id: 1, points: [100,100, 200,100, 200,200, 100,200], defectType: 'scratch' },
{ id: 2, points: [300,150, 350,120, 400,150, 375,200, 325,200], defectType: 'stain' },
{ id: 3, points: irregularPoints, defectType: 'irregular' },
];
// ========== 初始化 Konva ==========
const stage = new Konva.Stage({
container: 'container',
width: 800,
height: 600,
});
const layer = new Konva.Layer();
stage.add(layer);
let imageObj = new Image();
imageObj.onload = () => {
const image = new Konva.Image({
x: 0,
y: 0,
image: imageObj,
width: 800,
height: 600,
listening: false, // 不监听图片点击
});
layer.add(image);
drawRegions(suspiciousRegions);
layer.draw();
};
imageObj.src = imageUrl;
// ========== 全局状态 ==========
let isEditing = false;
let isDragging = false;
let isDragModeActive = false;
let regions = [];
let currentDrawingPoints = [];
let drawingLine = null;
let drawingAnchors = [];
let history = []; // 操作历史栈
let tempNewRegion = null; // 临时存储新绘制的区域,等待用户确认
let scale = 1; // 当前缩放级别
const minScale = 1; // 100%
const maxScale = 199.9; // 19990%
let offsetX = 0; // X轴偏移
let offsetY = 0; // Y轴偏移
let isDraggingAnchor = false; // 标记是否有锚点正在被拖拽
// ========== 操作历史管理 ==========
function addToHistory(operation) {
history.push(operation);
// 限制历史长度,避免内存过大
if (history.length > 50) {
history.shift();
}
console.log('操作已记录到历史:', operation);
}
function undoLastOperation() {
if (history.length === 0) {
alert('没有可撤销的操作');
return;
}
const operation = history.pop();
console.log('执行撤销操作:', operation.type, '剩余历史:', history.length);
switch (operation.type) {
case 'addRegion':
// 删除最新添加的区域
const regionIndex = regions.findIndex(r => r.id === operation.regionId);
if (regionIndex !== -1) {
const regionToRemove = regions[regionIndex];
regionToRemove.shape.destroy();
regionToRemove.anchors.forEach(anchor => anchor.destroy());
regions.splice(regionIndex, 1);
layer.batchDraw();
}
break;
case 'modifyRegion':
// 恢复到修改前的状态
const regionToRestore = regions.find(r => r.id === operation.regionId);
if (regionToRestore) {
console.log('撤销前状态:', regionToRestore.originalPoints);
console.log('要恢复到状态:', operation.oldPoints);
// 将原始坐标转换为缩放坐标
const scaledOldPoints = operation.oldPoints.map(p => p * scale);
// 1. 设置线条的点
regionToRestore.shape.points(scaledOldPoints);
// 2. 同步更新区域的点数据
regionToRestore.points = [...operation.oldPoints];
// 3. 更新所有锚点的位置
for (let i = 0; i < regionToRestore.anchors.length; i++) {
const anchor = regionToRestore.anchors[i];
const pointIndex = i * 2;
anchor.x(scaledOldPoints[pointIndex]);
anchor.y(scaledOldPoints[pointIndex + 1]);
}
// 4. 重绘
layer.batchDraw();
console.log('区域已恢复到之前状态');
} else {
console.warn('找不到要恢复的区域:', operation.regionId);
}
break;
default:
console.warn('未知操作类型:', operation.type);
}
}
// ========== 绘制所有区域 ==========
function drawRegions(regionData) {
regions = [];
regionData.forEach(data => {
createRegion(data.points, data.id, data.defectType);
});
}
// 创建锚点(用于已存在的区域)
function createAnchor(x, y, line, index, region, isReadOnly = false) {
const anchor = new Konva.Circle({
x: x,
y: y,
radius: isReadOnly ? 5 : 8, // 非编辑模式下稍小一点
fill: isReadOnly ? '#ff6600' : '#ff0000', // 非编辑用橙色,编辑用红色
stroke: '#ffffff',
strokeWidth: isReadOnly ? 1 : 2,
draggable: !isReadOnly, // 非编辑模式不可拖拽
visible: true, // 默认始终可见
dragBoundFunc: isReadOnly ? undefined : function (pos) {
return {
x: Math.max(0, Math.min(800 * scale, pos.x)),
y: Math.max(0, Math.min(600 * scale, pos.y)),
};
}
});
if (!isReadOnly) {
// 只有可编辑时才绑定拖拽事件
let dragStartPoints = null;
anchor.on('dragstart', function () {
isDraggingAnchor = true; // 标记有锚点正在被拖拽
// 保存当前线条的点作为原始状态(转换为原始坐标)
const currentScaledPoints = line.points();
dragStartPoints = [];
for (let i = 0; i < currentScaledPoints.length; i++) {
dragStartPoints.push(currentScaledPoints[i] / scale);
}
console.log('拖拽开始,保存原始点:', dragStartPoints);
});
// 拖拽移动时实时更新
anchor.on('dragmove', function () {
// 获取当前线条的所有点
const currentPoints = line.points().slice(); // 复制数组
// 更新对应索引的点
currentPoints[index] = this.x();
currentPoints[index + 1] = this.y();
// 设置新的点到线条
line.points(currentPoints);
// 同步更新区域的原始坐标点
const updatedOriginalPoints = [];
for (let i = 0; i < currentPoints.length; i++) {
updatedOriginalPoints.push(currentPoints[i] / scale);
}
region.points = [...updatedOriginalPoints];
// 重绘图层
layer.batchDraw();
});
// 拖拽结束时记录历史
anchor.on('dragend', function () {
isDraggingAnchor = false; // 标记锚点拖拽结束
if (dragStartPoints) {
// 获取拖拽结束后的状态(转换为原始坐标)
const currentScaledPoints = line.points();
const dragEndPoints = [];
for (let i = 0; i < currentScaledPoints.length; i++) {
dragEndPoints.push(currentScaledPoints[i] / scale);
}
// 只有当点确实发生变化时才记录
const pointsChanged = JSON.stringify(dragStartPoints) !== JSON.stringify(dragEndPoints);
if (pointsChanged) {
console.log('拖拽结束,原始点:', dragStartPoints, '结束点:', dragEndPoints);
addToHistory({
type: 'modifyRegion',
regionId: region.id,
oldPoints: [...dragStartPoints], // 拖拽开始时的状态
newPoints: [...dragEndPoints] // 拖拽结束时的状态
});
}
}
});
}
return anchor;
}
// 创建区域
function createRegion(points, id = Date.now(), defectType = 'scratch') {
// 将原始坐标转换为缩放坐标
const scaledPoints = points.map(p => p * scale);
const line = new Konva.Line({
points: scaledPoints,
closed: true,
stroke: '#ff0000',
strokeWidth: 3, // 固定线条粗细,不随缩放变化
fill: 'rgba(255,0,0,0.1)', // 固定透明度
listening: true,
});
const anchors = [];
for (let i = 0; i < scaledPoints.length; i += 2) {
const anchor = createAnchor(scaledPoints[i], scaledPoints[i + 1], line, i, { points: [...points], id, defectType }, !isEditing);
anchors.push(anchor);
}
const region = { id, shape: line, anchors, points: [...points], originalPoints: [...points], defectType };
regions.push(region);
layer.add(line);
anchors.forEach(a => layer.add(a));
setupHover(region);
return region;
}
// ========== 悬停高亮 ==========
function setupHover(region) {
// 鼠标进入区域
region.shape.on('mouseenter', () => {
// 高亮当前区域
region.shape.stroke('#00ff00').strokeWidth(4).fill('rgba(0,255,0,0.2)'); // 悬停时加粗
// 非高亮其他区域
regions.forEach(r => {
if (r !== region) {
r.shape.stroke('#ff0000').strokeWidth(3).fill('rgba(255,0,0,0.1)');
}
});
layer.batchDraw();
});
// 鼠标离开区域
region.shape.on('mouseleave', () => {
// 恢复当前区域样式
region.shape.stroke('#ff0000').strokeWidth(3).fill('rgba(255,0,0,0.1)');
// 恢复其他区域样式
regions.forEach(r => {
if (r !== region) {
r.shape.stroke('#ff0000').strokeWidth(3).fill('rgba(255,0,0,0.1)');
}
});
layer.batchDraw();
});
}
// ========== 精确缩放功能 ==========
function updateScale(newScale, centerPoint) {
// 如果当前有锚点正在被拖拽,推迟缩放操作
if (isDraggingAnchor) {
console.log('有锚点正在被拖拽,推迟缩放操作');
// 稍后尝试再次缩放
setTimeout(() => {
if (!isDraggingAnchor) {
// 如果拖拽已经结束,则执行缩放
updateScale(newScale, centerPoint);
} else {
// 如果拖拽仍在进行,继续等待
console.log('拖拽仍在进行,继续等待');
}
}, 100);
return;
}
// 限制缩放范围
const oldScale = scale;
scale = Math.max(minScale, Math.min(maxScale, newScale));
// 更新缩放显示
document.getElementById('zoomDisplay').textContent = Math.round(scale * 100) + '%';
// 计算缩放后的位置变化,保持中心点不变
if (centerPoint) {
// 将鼠标位置转换为原始图片坐标系
const mousePointTo = {
x: (centerPoint.x - offsetX) / oldScale,
y: (centerPoint.y - offsetY) / oldScale
};
// 计算新的偏移量,使鼠标位置保持不变
offsetX = centerPoint.x - mousePointTo.x * scale;
offsetY = centerPoint.y - mousePointTo.y * scale;
// 限制偏移边界,防止图片完全移出视口
const maxX = Math.max(0, 800 * scale - stage.width());
const maxY = Math.max(0, 600 * scale - stage.height());
offsetX = Math.max(-maxX, Math.min(0, offsetX));
offsetY = Math.max(-maxY, Math.min(0, offsetY));
// 应用新的位置
stage.position({ x: offsetX, y: offsetY });
}
// 重新绘制所有内容
const imageNode = layer.findOne('Image');
if (imageNode) {
imageNode.width(800 * scale);
imageNode.height(600 * scale);
}
// 重新绘制所有区域
regions.forEach(region => {
// 关键:使用当前线条的实际缩放坐标,而不是原始坐标
// 这样可以保留拖拽后的新坐标
const currentScaledPoints = region.shape.points(); // 获取当前线条的实际缩放坐标
// 重新计算基于新缩放比例的坐标
const currentOriginalPoints = [];
for (let i = 0; i < currentScaledPoints.length; i++) {
currentOriginalPoints.push(currentScaledPoints[i] / oldScale); // 转换回原始坐标
}
// 将原始坐标转换为新缩放级别的坐标
const newScaledPoints = currentOriginalPoints.map(p => p * scale);
// 更新线条
region.shape.points(newScaledPoints);
region.shape.strokeWidth(3); // 保持线条粗细不变
region.shape.fill('rgba(255,0,0,0.1)'); // 保持透明度不变
// 更新锚点位置(保持大小不变)
for (let i = 0; i < region.anchors.length; i++) {
const anchor = region.anchors[i];
const pointIndex = i * 2;
anchor.x(newScaledPoints[pointIndex]);
anchor.y(newScaledPoints[pointIndex + 1]);
// 锚点的大小和描边宽度保持不变
}
// 更新区域的原始坐标记录
region.points = [...currentOriginalPoints];
});
layer.batchDraw();
}
// ========== 鼠标滚轮缩放 ==========
stage.on('wheel', (e) => {
e.evt.preventDefault();
const oldScale = scale;
const pointer = stage.getPointerPosition();
// 计算缩放增量
let direction = e.evt.deltaY > 0 ? -1 : 1;
const zoomIntensity = 0.1; // 缩放强度
const newScale = oldScale * (1 + direction * zoomIntensity);
// 更新缩放,基于鼠标位置
updateScale(newScale, pointer);
});
// ========== 拖拽功能 ==========
function enableDragMode() {
isDragModeActive = true;
stage.container().style.cursor = 'grab';
let isDraggingStage = false;
let lastPointerPosition;
stage.on('mousedown touchstart', (e) => {
if (!isDragModeActive) return;
isDraggingStage = true;
lastPointerPosition = stage.getPointerPosition();
stage.container().style.cursor = 'grabbing';
});
stage.on('mousemove touchmove', (e) => {
if (!isDragModeActive || !isDraggingStage) return;
const pos = stage.getPointerPosition();
const dx = pos.x - lastPointerPosition.x;
const dy = pos.y - lastPointerPosition.y;
offsetX += dx;
offsetY += dy;
// 限制拖拽边界
const maxX = Math.max(0, 800 * scale - stage.width());
const maxY = Math.max(0, 600 * scale - stage.height());
offsetX = Math.max(-maxX, Math.min(0, offsetX));
offsetY = Math.max(-maxY, Math.min(0, offsetY));
stage.position({ x: offsetX, y: offsetY });
lastPointerPosition = pos;
stage.batchDraw();
});
stage.on('mouseup touchend', () => {
isDraggingStage = false;
stage.container().style.cursor = 'grab';
});
stage.on('mouseleave', () => {
isDraggingStage = false;
stage.container().style.cursor = 'default';
});
}
function disableDragMode() {
isDragModeActive = false;
stage.container().style.cursor = 'default';
stage.off('mousedown touchstart mousemove touchmove mouseup touchend mouseleave');
}
// ========== 编辑模式切换 ==========
document.getElementById('editBtn').addEventListener('click', () => {
// 退出拖拽模式
if (isDragModeActive) {
document.getElementById('handBtn').classList.remove('active');
disableDragMode();
}
isEditing = !isEditing;
document.getElementById('editBtn').textContent = isEditing ? '退出编辑' : '进入编辑模式';
// 保存当前区域数据
const regionData = regions.map(r => ({
id: r.id,
points: [...r.points],
defectType: r.defectType
}));
// 清空图层中的所有区域和锚点
regions.forEach(r => {
r.shape.destroy();
r.anchors.forEach(a => a.destroy());
});
regions = [];
// 重新绘制所有区域(根据新的 isEditing 状态)
drawRegions(regionData);
// 处理绘制模式
if (isEditing) {
enableDrawingMode();
} else {
disableDrawingMode();
}
layer.batchDraw();
});
// ========== 撤销功能 ==========
document.getElementById('undoBtn').addEventListener('click', () => {
undoLastOperation();
});
// 监听键盘事件(Ctrl+Z)
document.addEventListener('keydown', (e) => {
if ((e.ctrlKey || e.metaKey) && e.key === 'z') {
e.preventDefault();
undoLastOperation();
}
});
// ========== 缩放按钮 ==========
document.getElementById('zoomInBtn').addEventListener('click', () => {
const pointer = stage.getPointerPosition() || { x: stage.width() / 2, y: stage.height() / 2 };
updateScale(scale * 1.2, pointer);
});
document.getElementById('zoomOutBtn').addEventListener('click', () => {
const pointer = stage.getPointerPosition() || { x: stage.width() / 2, y: stage.height() / 2 };
updateScale(scale / 1.2, pointer);
});
// ========== 手掌拖拽按钮 ==========
document.getElementById('handBtn').addEventListener('click', () => {
// 退出编辑模式
if (isEditing) {
isEditing = false;
document.getElementById('editBtn').textContent = '进入编辑模式';
// 保存当前区域数据
const regionData = regions.map(r => ({
id: r.id,
points: [...r.points],
defectType: r.defectType
}));
// 清空图层中的所有区域和锚点
regions.forEach(r => {
r.shape.destroy();
r.anchors.forEach(a => a.destroy());
});
regions = [];
// 重新绘制所有区域(根据新的 isEditing 状态)
drawRegions(regionData);
disableDrawingMode();
}
// 切换拖拽模式
const handBtn = document.getElementById('handBtn');
if (isDragModeActive) {
handBtn.classList.remove('active');
disableDragMode();
} else {
handBtn.classList.add('active');
enableDragMode();
}
layer.batchDraw();
});
// ========== 绘制新区域 ==========
function enableDrawingMode() {
stage.on('click tap', handleStageClick);
stage.on('mousemove', handleMouseMove);
}
function disableDrawingMode() {
stage.off('click tap', handleStageClick);
stage.off('mousemove', handleMouseMove);
resetDrawingState();
}
function resetDrawingState() {
currentDrawingPoints = [];
if (drawingLine) drawingLine.destroy();
drawingLine = null;
// 销毁所有绘制中的锚点
drawingAnchors.forEach(anchor => anchor.destroy());
drawingAnchors = [];
layer.draw();
}
function createDrawingAnchor(x, y, isStart = false) {
const anchor = new Konva.Circle({
x: x,
y: y,
radius: isStart ? 9 : 7, // 起点稍大,其他锚点固定大小
fill: isStart ? '#ff6b6b' : '#4ecdc4', // 起点为红色,其他为青色
stroke: '#ffffff', // 白色描边
strokeWidth: 2,
listening: true, // 此次需要监听点击事件
name: isStart ? 'start-anchor' : 'normal-anchor',
});
// 单击起始锚点即闭合(更可靠)
if (isStart) {
anchor.on('click', function (e) {
e.cancelBubble = true; // 防止冒泡到 stage
if (!isEditing || currentDrawingPoints.length < 6) return;
// 闭合路径(不重复添加起点)
finishDrawing([...currentDrawingPoints]);
});
// 可选:悬停高亮
anchor.on('mouseenter', () => {
anchor.stroke('#ffff00').strokeWidth(3);
layer.batchDraw();
});
anchor.on('mouseleave', () => {
anchor.stroke('#ffffff').strokeWidth(2);
layer.batchDraw();
});
}
return anchor;
}
function handleStageClick(e) {
// 如果点击的是起始锚点,Konva 会优先触发 anchor 的 click,不会进入这里
// 所以这里只处理"空白区域"或"普通点"的点击
if (e.target?.name() === 'start-anchor') {
return; // 应由 anchor 自己处理
}
if (!isEditing) return;
const pos = stage.getPointerPosition();
// 将屏幕坐标转换为原始图片坐标(缩放前的坐标)
const originalX = (pos.x - offsetX) / scale;
const originalY = (pos.y - offsetY) / scale;
// 第一次点击:记录起点并创建起点锚点
if (currentDrawingPoints.length === 0) {
currentDrawingPoints.push(originalX, originalY);
const scaledX = originalX * scale;
const scaledY = originalY * scale;
const startAnchor = createDrawingAnchor(scaledX, scaledY, true);
drawingAnchors.push(startAnchor);
layer.add(startAnchor);
layer.draw();
return;
}
// 普通点:添加到路径
currentDrawingPoints.push(originalX, originalY);
// 创建普通锚点(使用缩放后的坐标)
const scaledX = originalX * scale;
const scaledY = originalY * scale;
const anchor = createDrawingAnchor(scaledX, scaledY, false);
drawingAnchors.push(anchor);
layer.add(anchor);
// 更新虚线
if (!drawingLine) {
drawingLine = new Konva.Line({
points: currentDrawingPoints.map(p => p * scale), // 缩放后的点
stroke: '#ffff00', // 亮黄色连接线
strokeWidth: 2, // 固定虚线粗细
dash: [5, 5],
closed: false,
listening: false,
});
layer.add(drawingLine);
} else {
drawingLine.points(currentDrawingPoints.map(p => p * scale));
drawingLine.stroke('#ffff00'); // 亮黄色连接线
}
layer.batchDraw();
}
function handleMouseMove(e) {
if (!drawingLine || currentDrawingPoints.length === 0) return;
const pos = stage.getPointerPosition();
// 将鼠标位置转换为原始图片坐标
const originalX = (pos.x - offsetX) / scale;
const originalY = (pos.y - offsetY) / scale;
const pts = [...currentDrawingPoints, originalX, originalY];
drawingLine.points(pts.map(p => p * scale)); // 缩放后的点
drawingLine.stroke('#ffff00'); // 亮黄色连接线
layer.batchDraw();
}
function finishDrawing(points) {
if (points.length < 6) {
alert('至少需要3个点才能形成区域');
resetDrawingState();
return;
}
// 创建新的临时区域,但先不加入regions数组
const newRegion = createRegion(points, Date.now(), 'scratch');
tempNewRegion = newRegion; // 临时存储
// 重置绘制状态,但保留新创建的临时区域
resetDrawingState();
// 弹窗让用户确认是否保存
document.getElementById('popup').style.display = 'block';
document.getElementById('opType').value = 'add';
}
// ========== 弹窗 ==========
function closePopup() {
document.getElementById('popup').style.display = 'none';
}
function saveRegion() {
const defectType = document.getElementById('defectType').value;
if (tempNewRegion) {
// 更新临时区域的瑕疵类型
tempNewRegion.defectType = defectType;
// 记录添加区域的历史操作
addToHistory({
type: 'addRegion',
regionId: tempNewRegion.id,
points: [...tempNewRegion.points]
});
console.log('新区域已保存,瑕疵类型:', defectType);
}
closePopup();
tempNewRegion = null; // 清空临时存储
layer.batchDraw();
}
function cancelRegion() {
if (tempNewRegion) {
// 从画布上移除临时区域
tempNewRegion.shape.destroy();
tempNewRegion.anchors.forEach(anchor => anchor.destroy());
// 从regions数组中移除
const index = regions.findIndex(r => r.id === tempNewRegion.id);
if (index !== -1) {
regions.splice(index, 1);
}
console.log('临时区域已取消');
}
closePopup();
tempNewRegion = null; // 清空临时存储
layer.batchDraw();
}
// 初始化缩放显示
document.getElementById('zoomDisplay').textContent = Math.round(scale * 100) + '%';
</script>
</body>
</html>