735 lines
20 KiB
JavaScript
735 lines
20 KiB
JavaScript
(function () {
|
|
"use strict";
|
|
|
|
const MARKER_TYPES = {
|
|
entry: { label: "入场", color: "#00ff00" },
|
|
exit: { label: "出场", color: "#0099ff" },
|
|
stop: { label: "止损", color: "#ff3333" },
|
|
};
|
|
|
|
const ARROW_HEAD = 12;
|
|
const ARROW_STEM = 14;
|
|
const ARROW_WIDTH = 9;
|
|
const LINE_WIDTH = 2;
|
|
const HIT_RADIUS = 18;
|
|
const ZOOM_MIN = 0.25;
|
|
const ZOOM_MAX = 5;
|
|
const ZOOM_STEP = 0.15;
|
|
|
|
const fileInput = document.getElementById("file-input");
|
|
const dropZone = document.getElementById("drop-zone");
|
|
const dropPlaceholder = document.getElementById("drop-placeholder");
|
|
const viewport = document.getElementById("viewport");
|
|
const stage = document.getElementById("stage");
|
|
const chartImage = document.getElementById("chart-image");
|
|
const canvas = document.getElementById("overlay-canvas");
|
|
const ctx = canvas.getContext("2d");
|
|
const btnUndo = document.getElementById("btn-undo");
|
|
const btnClear = document.getElementById("btn-clear");
|
|
const btnDownload = document.getElementById("btn-download");
|
|
const statusHint = document.getElementById("status-hint");
|
|
const modeButtons = document.querySelectorAll(".mode-btn");
|
|
const zoomGroup = document.getElementById("zoom-group");
|
|
const zoomLabel = document.getElementById("zoom-label");
|
|
const btnZoomIn = document.getElementById("zoom-in");
|
|
const btnZoomOut = document.getElementById("zoom-out");
|
|
const btnZoomFit = document.getElementById("zoom-fit");
|
|
const directionRow = document.getElementById("direction-row");
|
|
const dirButtons = document.querySelectorAll(".dir-btn");
|
|
const dirCenter = document.getElementById("dir-center");
|
|
const angleSlider = document.getElementById("angle-slider");
|
|
const angleInput = document.getElementById("angle-input");
|
|
|
|
let currentMode = null;
|
|
let pendingAngle = 0;
|
|
let markers = [];
|
|
let baseWidth = 0;
|
|
let baseHeight = 0;
|
|
let zoom = 1;
|
|
let imageLoaded = false;
|
|
let uploadedFileName = "";
|
|
let dragIndex = -1;
|
|
let selectedIndex = -1;
|
|
let isDraggingMarker = false;
|
|
let didDragMove = false;
|
|
let isPanning = false;
|
|
let didPanMove = false;
|
|
let panStartX = 0;
|
|
let panStartY = 0;
|
|
let panScrollLeft = 0;
|
|
let panScrollTop = 0;
|
|
const PAN_THRESHOLD = 4;
|
|
|
|
function setHint(text) {
|
|
statusHint.textContent = text;
|
|
}
|
|
|
|
function normalizeAngle(deg) {
|
|
let a = Math.round(deg) % 360;
|
|
if (a < 0) a += 360;
|
|
return a;
|
|
}
|
|
|
|
function updateButtons() {
|
|
const hasImage = imageLoaded;
|
|
const hasMarkers = markers.length > 0;
|
|
btnUndo.disabled = !hasImage || !hasMarkers;
|
|
btnClear.disabled = !hasImage || !hasMarkers;
|
|
btnDownload.disabled = !hasImage;
|
|
btnZoomIn.disabled = !hasImage;
|
|
btnZoomOut.disabled = !hasImage;
|
|
btnZoomFit.disabled = !hasImage;
|
|
}
|
|
|
|
function getStageSize() {
|
|
return {
|
|
w: baseWidth * zoom,
|
|
h: baseHeight * zoom,
|
|
};
|
|
}
|
|
|
|
function ratioToXY(m) {
|
|
const { w, h } = getStageSize();
|
|
return { x: m.xRatio * w, y: m.yRatio * h };
|
|
}
|
|
|
|
function xyToRatio(x, y) {
|
|
const { w, h } = getStageSize();
|
|
return { xRatio: x / w, yRatio: y / h };
|
|
}
|
|
|
|
function getCanvasPoint(event) {
|
|
const rect = canvas.getBoundingClientRect();
|
|
const scaleX = canvas.width / rect.width;
|
|
const scaleY = canvas.height / rect.height;
|
|
return {
|
|
x: (event.clientX - rect.left) * scaleX,
|
|
y: (event.clientY - rect.top) * scaleY,
|
|
};
|
|
}
|
|
|
|
function dist(x1, y1, x2, y2) {
|
|
const dx = x1 - x2;
|
|
const dy = y1 - y2;
|
|
return Math.sqrt(dx * dx + dy * dy);
|
|
}
|
|
|
|
function findMarkerAt(x, y) {
|
|
for (let i = markers.length - 1; i >= 0; i--) {
|
|
const p = ratioToXY(markers[i]);
|
|
if (dist(x, y, p.x, p.y) <= HIT_RADIUS) {
|
|
return i;
|
|
}
|
|
}
|
|
return -1;
|
|
}
|
|
|
|
function findMarkerIndexByType(type) {
|
|
return markers.findIndex(function (m) {
|
|
return m.type === type;
|
|
});
|
|
}
|
|
|
|
function updateModeButtonStates() {
|
|
modeButtons.forEach(function (btn) {
|
|
const type = btn.dataset.mode;
|
|
btn.classList.toggle("has-marker", findMarkerIndexByType(type) >= 0);
|
|
btn.classList.toggle("active", currentMode === type);
|
|
});
|
|
}
|
|
|
|
function clearModeSelection() {
|
|
currentMode = null;
|
|
updateModeButtonStates();
|
|
}
|
|
|
|
function setMode(mode) {
|
|
if (currentMode === mode) {
|
|
clearModeSelection();
|
|
setHint("已取消选择 · 拖动空白处平移画布 · 点击入场/出场/止损开始标注");
|
|
return;
|
|
}
|
|
|
|
currentMode = mode;
|
|
const info = MARKER_TYPES[mode];
|
|
const existIdx = findMarkerIndexByType(mode);
|
|
|
|
if (existIdx >= 0) {
|
|
selectedIndex = existIdx;
|
|
syncAngleUI(markers[existIdx].angle);
|
|
setHint(
|
|
info.label +
|
|
" 已标注 · 单击图上可移动位置,或拖动箭头 · 再次点击「" +
|
|
info.label +
|
|
"」取消选择"
|
|
);
|
|
} else {
|
|
selectedIndex = -1;
|
|
setHint(
|
|
"已选择「" +
|
|
info.label +
|
|
"」· 在图上单击放置(仅一个)· 方向 " +
|
|
pendingAngle +
|
|
"°"
|
|
);
|
|
}
|
|
|
|
updateModeButtonStates();
|
|
redraw();
|
|
}
|
|
|
|
function drawArrowMarker(targetCtx, x, y, angleDeg, color, highlight) {
|
|
const w = ARROW_WIDTH;
|
|
const h = ARROW_HEAD;
|
|
const stem = ARROW_STEM;
|
|
const rad = (angleDeg * Math.PI) / 180;
|
|
|
|
targetCtx.save();
|
|
targetCtx.translate(x, y);
|
|
targetCtx.rotate(rad);
|
|
|
|
targetCtx.fillStyle = color;
|
|
targetCtx.strokeStyle = color;
|
|
targetCtx.lineWidth = highlight ? LINE_WIDTH + 1 : LINE_WIDTH;
|
|
targetCtx.lineCap = "round";
|
|
targetCtx.lineJoin = "round";
|
|
|
|
targetCtx.beginPath();
|
|
targetCtx.moveTo(0, 0);
|
|
targetCtx.lineTo(-w, h);
|
|
targetCtx.lineTo(w, h);
|
|
targetCtx.closePath();
|
|
targetCtx.fill();
|
|
|
|
targetCtx.beginPath();
|
|
targetCtx.moveTo(0, h);
|
|
targetCtx.lineTo(0, h + stem);
|
|
targetCtx.stroke();
|
|
|
|
if (highlight) {
|
|
targetCtx.beginPath();
|
|
targetCtx.arc(0, 0, HIT_RADIUS * 0.55, 0, Math.PI * 2);
|
|
targetCtx.strokeStyle = "rgba(255,255,255,0.45)";
|
|
targetCtx.lineWidth = 1.5;
|
|
targetCtx.setLineDash([4, 3]);
|
|
targetCtx.stroke();
|
|
targetCtx.setLineDash([]);
|
|
}
|
|
|
|
targetCtx.restore();
|
|
}
|
|
|
|
function redraw() {
|
|
if (!imageLoaded) return;
|
|
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
|
markers.forEach(function (m, i) {
|
|
const p = ratioToXY(m);
|
|
drawArrowMarker(
|
|
ctx,
|
|
p.x,
|
|
p.y,
|
|
m.angle,
|
|
m.color,
|
|
i === selectedIndex
|
|
);
|
|
});
|
|
}
|
|
|
|
function updateZoomLabel() {
|
|
zoomLabel.textContent = Math.round(zoom * 100) + "%";
|
|
}
|
|
|
|
function calculateBaseSize() {
|
|
const maxW = Math.max(viewport.clientWidth - 8, 320);
|
|
const maxH = Math.max(viewport.clientHeight - 8, 280);
|
|
const natW = chartImage.naturalWidth;
|
|
const natH = chartImage.naturalHeight;
|
|
const scale = Math.min(maxW / natW, maxH / natH);
|
|
baseWidth = natW * scale;
|
|
baseHeight = natH * scale;
|
|
}
|
|
|
|
function applyLayout() {
|
|
if (!imageLoaded) return;
|
|
const { w, h } = getStageSize();
|
|
const cw = Math.round(w);
|
|
const ch = Math.round(h);
|
|
|
|
stage.style.width = cw + "px";
|
|
stage.style.height = ch + "px";
|
|
chartImage.style.width = cw + "px";
|
|
chartImage.style.height = ch + "px";
|
|
canvas.width = cw;
|
|
canvas.height = ch;
|
|
canvas.style.width = cw + "px";
|
|
canvas.style.height = ch + "px";
|
|
|
|
updateZoomLabel();
|
|
redraw();
|
|
}
|
|
|
|
function setZoom(newZoom, clientX, clientY) {
|
|
const oldZoom = zoom;
|
|
newZoom = Math.max(ZOOM_MIN, Math.min(ZOOM_MAX, newZoom));
|
|
if (Math.abs(newZoom - oldZoom) < 0.001) return;
|
|
|
|
const rect = viewport.getBoundingClientRect();
|
|
const hasPointer =
|
|
typeof clientX === "number" && typeof clientY === "number";
|
|
const scrollX = viewport.scrollLeft;
|
|
const scrollY = viewport.scrollTop;
|
|
const px = hasPointer ? clientX - rect.left + scrollX : scrollX + rect.width / 2;
|
|
const py = hasPointer ? clientY - rect.top + scrollY : scrollY + rect.height / 2;
|
|
const ratioX = px / (baseWidth * oldZoom);
|
|
const ratioY = py / (baseHeight * oldZoom);
|
|
|
|
zoom = newZoom;
|
|
applyLayout();
|
|
|
|
if (hasPointer) {
|
|
viewport.scrollLeft = ratioX * baseWidth * zoom - (clientX - rect.left);
|
|
viewport.scrollTop = ratioY * baseHeight * zoom - (clientY - rect.top);
|
|
}
|
|
}
|
|
|
|
function resetZoomFit() {
|
|
zoom = 1;
|
|
applyLayout();
|
|
viewport.scrollLeft = 0;
|
|
viewport.scrollTop = 0;
|
|
}
|
|
|
|
function syncAngleUI(angle) {
|
|
const a = normalizeAngle(angle);
|
|
pendingAngle = a;
|
|
angleSlider.value = String(a);
|
|
angleInput.value = String(a);
|
|
dirCenter.textContent = a + "°";
|
|
dirButtons.forEach(function (btn) {
|
|
const btnAngle = parseInt(btn.dataset.angle, 10);
|
|
btn.classList.toggle("active", btnAngle === a);
|
|
});
|
|
}
|
|
|
|
function applyAngleToTarget(angle) {
|
|
const a = normalizeAngle(angle);
|
|
syncAngleUI(a);
|
|
if (selectedIndex >= 0) {
|
|
markers[selectedIndex].angle = a;
|
|
redraw();
|
|
setHint("已更新选中标记方向为 " + a + "°");
|
|
}
|
|
}
|
|
|
|
function selectMarker(index) {
|
|
selectedIndex = index;
|
|
if (index >= 0) {
|
|
syncAngleUI(markers[index].angle);
|
|
const label = MARKER_TYPES[markers[index].type].label;
|
|
setHint(
|
|
"已选中「" +
|
|
label +
|
|
"」· 可拖动移动或调整方向" +
|
|
(currentMode
|
|
? ""
|
|
: " · 点击对应类型按钮后可重新放置")
|
|
);
|
|
}
|
|
redraw();
|
|
}
|
|
|
|
function clearAnnotations() {
|
|
markers = [];
|
|
dragIndex = -1;
|
|
selectedIndex = -1;
|
|
isDraggingMarker = false;
|
|
didDragMove = false;
|
|
didPanMove = false;
|
|
clearModeSelection();
|
|
redraw();
|
|
updateButtons();
|
|
}
|
|
|
|
function showEditorUI(show) {
|
|
dropPlaceholder.classList.toggle("hidden", show);
|
|
viewport.classList.toggle("hidden", !show);
|
|
zoomGroup.hidden = !show;
|
|
directionRow.hidden = !show;
|
|
}
|
|
|
|
function getDownloadFileName() {
|
|
if (!uploadedFileName) {
|
|
return "标注1.png";
|
|
}
|
|
const lastDot = uploadedFileName.lastIndexOf(".");
|
|
const base =
|
|
lastDot > 0 ? uploadedFileName.slice(0, lastDot) : uploadedFileName;
|
|
return base + "1.png";
|
|
}
|
|
|
|
function loadImageFile(file) {
|
|
if (!file) return;
|
|
const validTypes = ["image/jpeg", "image/png"];
|
|
const ext = file.name.split(".").pop().toLowerCase();
|
|
const validExt = ["jpg", "jpeg", "png"].includes(ext);
|
|
if (!validTypes.includes(file.type) && !validExt) {
|
|
setHint("仅支持 JPG / PNG 格式");
|
|
return;
|
|
}
|
|
|
|
uploadedFileName = file.name;
|
|
|
|
const reader = new FileReader();
|
|
reader.onload = function (e) {
|
|
chartImage.onload = function () {
|
|
imageLoaded = true;
|
|
showEditorUI(true);
|
|
dropZone.classList.remove("drag-over");
|
|
zoom = 1;
|
|
clearAnnotations();
|
|
clearModeSelection();
|
|
syncAngleUI(0);
|
|
requestAnimationFrame(function () {
|
|
calculateBaseSize();
|
|
applyLayout();
|
|
viewport.scrollLeft = 0;
|
|
viewport.scrollTop = 0;
|
|
setHint(
|
|
"滚轮缩放 · 拖动空白处平移 · 先点「入场/出场/止损」再单击图上放置(各仅一个)"
|
|
);
|
|
updateButtons();
|
|
});
|
|
};
|
|
chartImage.onerror = function () {
|
|
setHint("图片加载失败,请换一张重试");
|
|
imageLoaded = false;
|
|
showEditorUI(false);
|
|
updateButtons();
|
|
};
|
|
chartImage.src = e.target.result;
|
|
};
|
|
reader.onerror = function () {
|
|
setHint("文件读取失败");
|
|
};
|
|
reader.readAsDataURL(file);
|
|
}
|
|
|
|
function placeOrMoveMarker(x, y) {
|
|
if (!currentMode) return;
|
|
|
|
const type = currentMode;
|
|
const info = MARKER_TYPES[type];
|
|
const ratio = xyToRatio(x, y);
|
|
const existIdx = findMarkerIndexByType(type);
|
|
|
|
if (existIdx >= 0) {
|
|
markers[existIdx].xRatio = ratio.xRatio;
|
|
markers[existIdx].yRatio = ratio.yRatio;
|
|
markers[existIdx].angle = pendingAngle;
|
|
selectedIndex = existIdx;
|
|
setHint(info.label + " 已移动到当前位置(" + pendingAngle + "°)");
|
|
} else {
|
|
markers.push({
|
|
xRatio: ratio.xRatio,
|
|
yRatio: ratio.yRatio,
|
|
type: type,
|
|
color: info.color,
|
|
angle: pendingAngle,
|
|
});
|
|
selectedIndex = markers.length - 1;
|
|
setHint("已放置「" + info.label + "」(" + pendingAngle + "°)");
|
|
}
|
|
|
|
redraw();
|
|
updateButtons();
|
|
updateModeButtonStates();
|
|
}
|
|
|
|
function exportImage() {
|
|
if (!imageLoaded) return;
|
|
|
|
const natW = chartImage.naturalWidth;
|
|
const natH = chartImage.naturalHeight;
|
|
|
|
const exportCanvas = document.createElement("canvas");
|
|
exportCanvas.width = natW;
|
|
exportCanvas.height = natH;
|
|
const exCtx = exportCanvas.getContext("2d");
|
|
|
|
exCtx.drawImage(chartImage, 0, 0, natW, natH);
|
|
|
|
for (const m of markers) {
|
|
drawArrowMarker(
|
|
exCtx,
|
|
m.xRatio * natW,
|
|
m.yRatio * natH,
|
|
m.angle,
|
|
m.color,
|
|
false
|
|
);
|
|
}
|
|
|
|
exportCanvas.toBlob(function (blob) {
|
|
if (!blob) {
|
|
setHint("导出失败,请重试");
|
|
return;
|
|
}
|
|
const url = URL.createObjectURL(blob);
|
|
const a = document.createElement("a");
|
|
const name = getDownloadFileName();
|
|
a.href = url;
|
|
a.download = name;
|
|
document.body.appendChild(a);
|
|
a.click();
|
|
document.body.removeChild(a);
|
|
URL.revokeObjectURL(url);
|
|
setHint("标注图已下载:" + name);
|
|
}, "image/png");
|
|
}
|
|
|
|
function updateCursor(x, y) {
|
|
if (isPanning) {
|
|
canvas.classList.remove("can-drag");
|
|
canvas.classList.add("can-pan");
|
|
return;
|
|
}
|
|
const idx = findMarkerAt(x, y);
|
|
canvas.classList.remove("can-pan");
|
|
if (idx >= 0) {
|
|
canvas.classList.add("can-drag");
|
|
} else {
|
|
canvas.classList.remove("can-drag");
|
|
canvas.classList.add("can-pan");
|
|
}
|
|
}
|
|
|
|
modeButtons.forEach(function (btn) {
|
|
btn.addEventListener("click", function (e) {
|
|
e.stopPropagation();
|
|
didDragMove = false;
|
|
didPanMove = false;
|
|
if (!imageLoaded) return;
|
|
setMode(btn.dataset.mode);
|
|
});
|
|
});
|
|
|
|
dirButtons.forEach(function (btn) {
|
|
btn.addEventListener("click", function () {
|
|
applyAngleToTarget(parseInt(btn.dataset.angle, 10));
|
|
});
|
|
});
|
|
|
|
angleSlider.addEventListener("input", function () {
|
|
applyAngleToTarget(parseInt(angleSlider.value, 10));
|
|
});
|
|
|
|
angleInput.addEventListener("change", function () {
|
|
let v = parseInt(angleInput.value, 10);
|
|
if (Number.isNaN(v)) v = 0;
|
|
applyAngleToTarget(v);
|
|
});
|
|
|
|
btnZoomIn.addEventListener("click", function () {
|
|
const rect = viewport.getBoundingClientRect();
|
|
setZoom(
|
|
zoom + ZOOM_STEP,
|
|
rect.left + rect.width / 2,
|
|
rect.top + rect.height / 2
|
|
);
|
|
});
|
|
|
|
btnZoomOut.addEventListener("click", function () {
|
|
const rect = viewport.getBoundingClientRect();
|
|
setZoom(
|
|
zoom - ZOOM_STEP,
|
|
rect.left + rect.width / 2,
|
|
rect.top + rect.height / 2
|
|
);
|
|
});
|
|
|
|
btnZoomFit.addEventListener("click", resetZoomFit);
|
|
|
|
viewport.addEventListener(
|
|
"wheel",
|
|
function (e) {
|
|
if (!imageLoaded) return;
|
|
e.preventDefault();
|
|
const delta = e.deltaY > 0 ? -ZOOM_STEP : ZOOM_STEP;
|
|
setZoom(zoom + delta, e.clientX, e.clientY);
|
|
},
|
|
{ passive: false }
|
|
);
|
|
|
|
fileInput.addEventListener("change", function () {
|
|
loadImageFile(fileInput.files[0]);
|
|
fileInput.value = "";
|
|
});
|
|
|
|
dropZone.addEventListener("dragover", function (e) {
|
|
e.preventDefault();
|
|
dropZone.classList.add("drag-over");
|
|
});
|
|
|
|
dropZone.addEventListener("dragleave", function (e) {
|
|
if (!dropZone.contains(e.relatedTarget)) {
|
|
dropZone.classList.remove("drag-over");
|
|
}
|
|
});
|
|
|
|
dropZone.addEventListener("drop", function (e) {
|
|
e.preventDefault();
|
|
dropZone.classList.remove("drag-over");
|
|
loadImageFile(e.dataTransfer.files[0]);
|
|
});
|
|
|
|
function startPan(e) {
|
|
if (!imageLoaded) return;
|
|
isPanning = true;
|
|
didPanMove = false;
|
|
panStartX = e.clientX;
|
|
panStartY = e.clientY;
|
|
panScrollLeft = viewport.scrollLeft;
|
|
panScrollTop = viewport.scrollTop;
|
|
viewport.classList.add("is-panning");
|
|
e.preventDefault();
|
|
}
|
|
|
|
canvas.addEventListener("mousedown", function (e) {
|
|
if (!imageLoaded) return;
|
|
|
|
if (e.button === 1 || e.button === 2) {
|
|
startPan(e);
|
|
return;
|
|
}
|
|
|
|
if (e.button !== 0) return;
|
|
e.preventDefault();
|
|
didDragMove = false;
|
|
didPanMove = false;
|
|
|
|
const pt = getCanvasPoint(e);
|
|
const idx = findMarkerAt(pt.x, pt.y);
|
|
|
|
if (idx >= 0) {
|
|
dragIndex = idx;
|
|
isDraggingMarker = true;
|
|
selectMarker(idx);
|
|
canvas.classList.add("can-drag");
|
|
return;
|
|
}
|
|
|
|
selectMarker(-1);
|
|
startPan(e);
|
|
});
|
|
|
|
canvas.addEventListener("mousemove", function (e) {
|
|
if (!imageLoaded) return;
|
|
|
|
if (isPanning) {
|
|
const dx = e.clientX - panStartX;
|
|
const dy = e.clientY - panStartY;
|
|
if (Math.abs(dx) > PAN_THRESHOLD || Math.abs(dy) > PAN_THRESHOLD) {
|
|
didPanMove = true;
|
|
}
|
|
viewport.scrollLeft = panScrollLeft - dx;
|
|
viewport.scrollTop = panScrollTop - dy;
|
|
return;
|
|
}
|
|
|
|
const pt = getCanvasPoint(e);
|
|
|
|
if (isDraggingMarker && dragIndex >= 0) {
|
|
didDragMove = true;
|
|
const ratio = xyToRatio(
|
|
Math.max(0, Math.min(canvas.width, pt.x)),
|
|
Math.max(0, Math.min(canvas.height, pt.y))
|
|
);
|
|
markers[dragIndex].xRatio = ratio.xRatio;
|
|
markers[dragIndex].yRatio = ratio.yRatio;
|
|
redraw();
|
|
return;
|
|
}
|
|
|
|
updateCursor(pt.x, pt.y);
|
|
});
|
|
|
|
function endPan() {
|
|
isPanning = false;
|
|
viewport.classList.remove("is-panning");
|
|
}
|
|
|
|
function endPointer() {
|
|
endPan();
|
|
isDraggingMarker = false;
|
|
dragIndex = -1;
|
|
canvas.classList.remove("can-drag", "can-pan");
|
|
}
|
|
|
|
canvas.addEventListener("mouseup", function () {
|
|
endPointer();
|
|
});
|
|
|
|
canvas.addEventListener("mouseleave", function () {
|
|
endPointer();
|
|
canvas.classList.remove("can-pan");
|
|
});
|
|
|
|
canvas.addEventListener("contextmenu", function (e) {
|
|
e.preventDefault();
|
|
});
|
|
|
|
canvas.addEventListener("click", function (e) {
|
|
if (!imageLoaded) return;
|
|
if (didDragMove || didPanMove) {
|
|
didDragMove = false;
|
|
didPanMove = false;
|
|
return;
|
|
}
|
|
if (!currentMode) return;
|
|
|
|
const pt = getCanvasPoint(e);
|
|
if (findMarkerAt(pt.x, pt.y) >= 0) return;
|
|
placeOrMoveMarker(pt.x, pt.y);
|
|
});
|
|
|
|
btnUndo.addEventListener("click", function () {
|
|
if (markers.length === 0) return;
|
|
const removed = markers.pop();
|
|
if (currentMode === removed.type) {
|
|
selectedIndex = -1;
|
|
} else if (selectedIndex >= markers.length) {
|
|
selectedIndex = markers.length - 1;
|
|
}
|
|
redraw();
|
|
updateButtons();
|
|
updateModeButtonStates();
|
|
setHint(
|
|
markers.length
|
|
? "已撤销「" + MARKER_TYPES[removed.type].label + "」"
|
|
: "已撤销全部标记"
|
|
);
|
|
});
|
|
|
|
btnClear.addEventListener("click", function () {
|
|
if (markers.length === 0) return;
|
|
if (!confirm("确定清空所有标注?")) return;
|
|
clearAnnotations();
|
|
setHint("已清空全部标注");
|
|
});
|
|
|
|
btnDownload.addEventListener("click", exportImage);
|
|
|
|
window.addEventListener("resize", function () {
|
|
if (!imageLoaded) return;
|
|
calculateBaseSize();
|
|
applyLayout();
|
|
});
|
|
|
|
window.addEventListener("mouseup", endPointer);
|
|
|
|
showEditorUI(false);
|
|
updateButtons();
|
|
updateModeButtonStates();
|
|
syncAngleUI(0);
|
|
})();
|