This commit is contained in:
dekun
2026-05-27 14:41:13 +08:00
parent dc1b4989b4
commit 518e517150
3 changed files with 495 additions and 90 deletions
+125 -13
View File
@@ -57,12 +57,106 @@ body {
gap: 8px; gap: 8px;
} }
.toolbar-row {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 12px;
margin-top: 10px;
}
.row-label {
font-size: 0.8rem;
color: var(--text-muted);
min-width: 4.5em;
}
.toolbar-hint { .toolbar-hint {
margin-top: 8px; margin-top: 8px;
font-size: 0.8rem; font-size: 0.8rem;
color: var(--text-muted); color: var(--text-muted);
} }
.zoom-group {
display: inline-flex;
align-items: center;
gap: 4px;
border: 1px solid var(--border);
border-radius: 4px;
padding: 2px 4px;
}
.zoom-label {
min-width: 3.2em;
text-align: center;
font-size: 0.8rem;
color: var(--text-muted);
}
.btn-icon {
min-width: 32px;
padding: 7px 10px;
font-size: 1rem;
line-height: 1;
}
.direction-pad {
display: grid;
grid-template-columns: repeat(3, 36px);
grid-template-rows: repeat(3, 32px);
gap: 4px;
}
.dir-btn {
min-width: 0;
padding: 4px;
font-size: 0.95rem;
}
.dir-btn.active {
background: rgba(74, 158, 255, 0.25);
border-color: var(--accent);
color: var(--accent);
}
.dir-center {
display: flex;
align-items: center;
justify-content: center;
font-size: 0.7rem;
color: var(--text-muted);
background: var(--bg);
border-radius: 4px;
border: 1px dashed var(--border);
}
.angle-slider-wrap {
display: inline-flex;
align-items: center;
gap: 8px;
font-size: 0.8rem;
color: var(--text-muted);
}
.angle-slider-wrap input[type="range"] {
width: 120px;
accent-color: var(--accent);
}
.angle-input {
width: 52px;
padding: 4px 6px;
border: 1px solid var(--border);
border-radius: 4px;
background: var(--bg);
color: var(--text);
font-size: 0.8rem;
}
.angle-unit {
color: var(--text-muted);
}
.btn { .btn {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
@@ -146,25 +240,35 @@ body {
.workspace { .workspace {
flex: 1; flex: 1;
display: flex; display: flex;
align-items: center; flex-direction: column;
justify-content: center;
padding: 16px; padding: 16px;
overflow: auto;
min-height: 0; min-height: 0;
overflow: hidden;
} }
.drop-zone { .drop-zone {
width: 100%; width: 100%;
max-width: 1400px; max-width: 1400px;
margin: 0 auto;
flex: 1;
min-height: 400px; min-height: 400px;
border: 2px dashed var(--border); border: 2px dashed var(--border);
border-radius: 8px; border-radius: 8px;
display: flex; display: flex;
flex-direction: column;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
transition: border-color 0.2s, background 0.2s; transition: border-color 0.2s, background 0.2s;
} }
.drop-zone:has(.viewport:not(.hidden)) {
border-style: solid;
border-color: var(--border);
background: #111;
padding: 0;
align-items: stretch;
}
.drop-zone.drag-over { .drop-zone.drag-over {
border-color: var(--accent); border-color: var(--accent);
background: rgba(74, 158, 255, 0.06); background: rgba(74, 158, 255, 0.06);
@@ -187,24 +291,32 @@ body {
font-size: 0.8rem; font-size: 0.8rem;
} }
.canvas-wrap {
position: relative;
line-height: 0;
max-width: 100%;
}
.hidden { .hidden {
display: none !important; display: none !important;
} }
.canvas-wrap.hidden { .viewport {
display: none; flex: 1;
width: 100%;
min-height: 420px;
max-height: calc(100vh - 240px);
overflow: auto;
background: #111;
cursor: default;
}
.viewport.is-panning {
cursor: grabbing;
}
.stage {
position: relative;
line-height: 0;
margin: 0 auto;
} }
#chart-image { #chart-image {
display: block; display: block;
max-width: 100%;
height: auto;
pointer-events: none; pointer-events: none;
user-select: none; user-select: none;
} }
+33 -5
View File
@@ -15,14 +15,40 @@
<input type="file" id="file-input" accept="image/jpeg,image/png,.jpg,.jpeg,.png" hidden> <input type="file" id="file-input" accept="image/jpeg,image/png,.jpg,.jpeg,.png" hidden>
</label> </label>
<div class="mode-group" role="group" aria-label="标注模式"> <div class="mode-group" role="group" aria-label="标注模式">
<button type="button" class="btn mode-btn active" data-mode="entry">入场</button> <button type="button" class="btn mode-btn active" data-mode="entry">入场</button>
<button type="button" class="btn mode-btn" data-mode="exit">出场</button> <button type="button" class="btn mode-btn" data-mode="exit">出场</button>
<button type="button" class="btn mode-btn" data-mode="stop">止损</button> <button type="button" class="btn mode-btn" data-mode="stop">止损</button>
</div>
<div class="zoom-group" id="zoom-group" hidden>
<button type="button" class="btn btn-icon" id="zoom-out" title="缩小"></button>
<span class="zoom-label" id="zoom-label">100%</span>
<button type="button" class="btn btn-icon" id="zoom-in" title="放大">+</button>
<button type="button" class="btn" id="zoom-fit" title="适应窗口">适应</button>
</div> </div>
<button type="button" class="btn" id="btn-undo" disabled>撤销</button> <button type="button" class="btn" id="btn-undo" disabled>撤销</button>
<button type="button" class="btn" id="btn-clear" disabled>清空全部</button> <button type="button" class="btn" id="btn-clear" disabled>清空全部</button>
<button type="button" class="btn btn-accent" id="btn-download" disabled>下载标注图</button> <button type="button" class="btn btn-accent" id="btn-download" disabled>下载标注图</button>
</div> </div>
<div class="toolbar-row direction-row" id="direction-row" hidden>
<span class="row-label">箭头方向</span>
<div class="direction-pad" role="group" aria-label="箭头方向">
<button type="button" class="btn dir-btn" data-angle="315" title="左上"></button>
<button type="button" class="btn dir-btn active" data-angle="0" title="向上"></button>
<button type="button" class="btn dir-btn" data-angle="45" title="右上"></button>
<button type="button" class="btn dir-btn" data-angle="270" title="向左"></button>
<span class="dir-center" id="dir-center" title="当前角度"></span>
<button type="button" class="btn dir-btn" data-angle="90" title="向右"></button>
<button type="button" class="btn dir-btn" data-angle="225" title="左下"></button>
<button type="button" class="btn dir-btn" data-angle="180" title="向下"></button>
<button type="button" class="btn dir-btn" data-angle="135" title="右下"></button>
</div>
<label class="angle-slider-wrap">
<span>角度</span>
<input type="range" id="angle-slider" min="0" max="359" value="0" step="1">
<input type="number" id="angle-input" min="0" max="359" value="0" class="angle-input">
<span class="angle-unit">°</span>
</label>
</div>
<p class="toolbar-hint" id="status-hint">请上传 K 线截图(JPG / PNG),支持点击或拖拽</p> <p class="toolbar-hint" id="status-hint">请上传 K 线截图(JPG / PNG),支持点击或拖拽</p>
</header> </header>
@@ -33,18 +59,20 @@
<p>将 K 线截图拖拽到此处,或点击顶部「上传图片」</p> <p>将 K 线截图拖拽到此处,或点击顶部「上传图片」</p>
<p class="drop-sub">支持 JPG、PNG,数据仅在浏览器本地处理</p> <p class="drop-sub">支持 JPG、PNG,数据仅在浏览器本地处理</p>
</div> </div>
<div class="canvas-wrap hidden" id="canvas-wrap"> <div class="viewport hidden" id="viewport">
<div class="stage" id="stage">
<img id="chart-image" alt="K线图" draggable="false"> <img id="chart-image" alt="K线图" draggable="false">
<canvas id="overlay-canvas"></canvas> <canvas id="overlay-canvas"></canvas>
</div> </div>
</div> </div>
</div>
</main> </main>
<footer class="footer"> <footer class="footer">
<span>入场 <i class="legend entry"></i></span> <span>入场 <i class="legend entry"></i></span>
<span>出场 <i class="legend exit"></i></span> <span>出场 <i class="legend exit"></i></span>
<span>止损 <i class="legend stop"></i></span> <span>止损 <i class="legend stop"></i></span>
<span class="footer-note">单击添加箭头 · 拖拽调整位置 · 可连续标注多个点位</span> <span class="footer-note">滚轮缩放 · 右键拖动画布 · 方向面板/滑块设置箭头朝向</span>
</footer> </footer>
<script src="js/app.js"></script> <script src="js/app.js"></script>
+330 -65
View File
@@ -2,9 +2,9 @@
"use strict"; "use strict";
const MARKER_TYPES = { const MARKER_TYPES = {
entry: { label: "入场", color: "#00ff00", direction: "up" }, entry: { label: "入场", color: "#00ff00" },
exit: { label: "出场", color: "#0099ff", direction: "down" }, exit: { label: "出场", color: "#0099ff" },
stop: { label: "止损", color: "#ff3333", direction: "down" }, stop: { label: "止损", color: "#ff3333" },
}; };
const ARROW_HEAD = 12; const ARROW_HEAD = 12;
@@ -12,11 +12,15 @@
const ARROW_WIDTH = 9; const ARROW_WIDTH = 9;
const LINE_WIDTH = 2; const LINE_WIDTH = 2;
const HIT_RADIUS = 18; 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 fileInput = document.getElementById("file-input");
const dropZone = document.getElementById("drop-zone"); const dropZone = document.getElementById("drop-zone");
const dropPlaceholder = document.getElementById("drop-placeholder"); const dropPlaceholder = document.getElementById("drop-placeholder");
const canvasWrap = document.getElementById("canvas-wrap"); const viewport = document.getElementById("viewport");
const stage = document.getElementById("stage");
const chartImage = document.getElementById("chart-image"); const chartImage = document.getElementById("chart-image");
const canvas = document.getElementById("overlay-canvas"); const canvas = document.getElementById("overlay-canvas");
const ctx = canvas.getContext("2d"); const ctx = canvas.getContext("2d");
@@ -25,26 +29,70 @@
const btnDownload = document.getElementById("btn-download"); const btnDownload = document.getElementById("btn-download");
const statusHint = document.getElementById("status-hint"); const statusHint = document.getElementById("status-hint");
const modeButtons = document.querySelectorAll(".mode-btn"); 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 = "entry"; let currentMode = "entry";
let pendingAngle = 0;
let markers = []; let markers = [];
let displayWidth = 0; let baseWidth = 0;
let displayHeight = 0; let baseHeight = 0;
let zoom = 1;
let imageLoaded = false; let imageLoaded = false;
let dragIndex = -1; let dragIndex = -1;
let selectedIndex = -1;
let isDragging = false; let isDragging = false;
let didDragMove = false; let didDragMove = false;
let isPanning = false;
let panStartX = 0;
let panStartY = 0;
let panScrollLeft = 0;
let panScrollTop = 0;
function setHint(text) { function setHint(text) {
statusHint.textContent = text; statusHint.textContent = text;
} }
function normalizeAngle(deg) {
let a = Math.round(deg) % 360;
if (a < 0) a += 360;
return a;
}
function updateButtons() { function updateButtons() {
const hasImage = imageLoaded; const hasImage = imageLoaded;
const hasMarkers = markers.length > 0; const hasMarkers = markers.length > 0;
btnUndo.disabled = !hasImage || !hasMarkers; btnUndo.disabled = !hasImage || !hasMarkers;
btnClear.disabled = !hasImage || !hasMarkers; btnClear.disabled = !hasImage || !hasMarkers;
btnDownload.disabled = !hasImage; 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) { function getCanvasPoint(event) {
@@ -57,10 +105,6 @@
}; };
} }
function getMarkerDirection(type) {
return MARKER_TYPES[type].direction;
}
function dist(x1, y1, x2, y2) { function dist(x1, y1, x2, y2) {
const dx = x1 - x2; const dx = x1 - x2;
const dy = y1 - y2; const dy = y1 - y2;
@@ -69,77 +113,185 @@
function findMarkerAt(x, y) { function findMarkerAt(x, y) {
for (let i = markers.length - 1; i >= 0; i--) { for (let i = markers.length - 1; i >= 0; i--) {
if (dist(x, y, markers[i].x, markers[i].y) <= HIT_RADIUS) { const p = ratioToXY(markers[i]);
if (dist(x, y, p.x, p.y) <= HIT_RADIUS) {
return i; return i;
} }
} }
return -1; return -1;
} }
function drawArrowMarker(targetCtx, x, y, type, color) { function drawArrowMarker(targetCtx, x, y, angleDeg, color, highlight) {
const direction = getMarkerDirection(type);
const w = ARROW_WIDTH; const w = ARROW_WIDTH;
const h = ARROW_HEAD; const h = ARROW_HEAD;
const stem = ARROW_STEM; const stem = ARROW_STEM;
const rad = (angleDeg * Math.PI) / 180;
targetCtx.save();
targetCtx.translate(x, y);
targetCtx.rotate(rad);
targetCtx.fillStyle = color; targetCtx.fillStyle = color;
targetCtx.strokeStyle = color; targetCtx.strokeStyle = color;
targetCtx.lineWidth = LINE_WIDTH; targetCtx.lineWidth = highlight ? LINE_WIDTH + 1 : LINE_WIDTH;
targetCtx.lineCap = "round"; targetCtx.lineCap = "round";
targetCtx.lineJoin = "round"; targetCtx.lineJoin = "round";
if (direction === "up") {
targetCtx.beginPath(); targetCtx.beginPath();
targetCtx.moveTo(x, y); targetCtx.moveTo(0, 0);
targetCtx.lineTo(x - w, y + h); targetCtx.lineTo(-w, h);
targetCtx.lineTo(x + w, y + h); targetCtx.lineTo(w, h);
targetCtx.closePath(); targetCtx.closePath();
targetCtx.fill(); targetCtx.fill();
targetCtx.beginPath(); targetCtx.beginPath();
targetCtx.moveTo(x, y + h); targetCtx.moveTo(0, h);
targetCtx.lineTo(x, y + h + stem); targetCtx.lineTo(0, h + stem);
targetCtx.stroke(); targetCtx.stroke();
} else {
if (highlight) {
targetCtx.beginPath(); targetCtx.beginPath();
targetCtx.moveTo(x, y); targetCtx.arc(0, 0, HIT_RADIUS * 0.55, 0, Math.PI * 2);
targetCtx.lineTo(x - w, y - h); targetCtx.strokeStyle = "rgba(255,255,255,0.45)";
targetCtx.lineTo(x + w, y - h); targetCtx.lineWidth = 1.5;
targetCtx.closePath(); targetCtx.setLineDash([4, 3]);
targetCtx.fill();
targetCtx.beginPath();
targetCtx.moveTo(x, y - h);
targetCtx.lineTo(x, y - h - stem);
targetCtx.stroke(); targetCtx.stroke();
targetCtx.setLineDash([]);
} }
targetCtx.restore();
} }
function redraw() { function redraw() {
if (!imageLoaded) return; if (!imageLoaded) return;
ctx.clearRect(0, 0, canvas.width, canvas.height); ctx.clearRect(0, 0, canvas.width, canvas.height);
for (const m of markers) { markers.forEach(function (m, i) {
drawArrowMarker(ctx, m.x, m.y, m.type, m.color); 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 syncCanvasSize() { function resetZoomFit() {
displayWidth = chartImage.offsetWidth; zoom = 1;
displayHeight = chartImage.offsetHeight; applyLayout();
canvas.width = Math.round(displayWidth); viewport.scrollLeft = 0;
canvas.height = Math.round(displayHeight); viewport.scrollTop = 0;
canvas.style.width = displayWidth + "px"; }
canvas.style.height = displayHeight + "px";
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);
setHint(
"已选中标记,可调整方向或拖拽移动;点击空白处添加新标记"
);
}
redraw(); redraw();
} }
function clearAnnotations() { function clearAnnotations() {
markers = []; markers = [];
dragIndex = -1; dragIndex = -1;
selectedIndex = -1;
isDragging = false; isDragging = false;
didDragMove = false; didDragMove = false;
redraw(); redraw();
updateButtons(); updateButtons();
} }
function showEditorUI(show) {
dropPlaceholder.classList.toggle("hidden", show);
viewport.classList.toggle("hidden", !show);
zoomGroup.hidden = !show;
directionRow.hidden = !show;
}
function loadImageFile(file) { function loadImageFile(file) {
if (!file) return; if (!file) return;
const validTypes = ["image/jpeg", "image/png"]; const validTypes = ["image/jpeg", "image/png"];
@@ -154,16 +306,20 @@
reader.onload = function (e) { reader.onload = function (e) {
chartImage.onload = function () { chartImage.onload = function () {
imageLoaded = true; imageLoaded = true;
dropPlaceholder.classList.add("hidden"); showEditorUI(true);
canvasWrap.classList.remove("hidden");
dropZone.classList.remove("drag-over"); dropZone.classList.remove("drag-over");
zoom = 1;
clearAnnotations(); clearAnnotations();
syncAngleUI(0);
requestAnimationFrame(function () { requestAnimationFrame(function () {
syncCanvasSize(); calculateBaseSize();
applyLayout();
viewport.scrollLeft = 0;
viewport.scrollTop = 0;
setHint( setHint(
"当前模式:" + "滚轮缩放 · 右键/中键拖动画布 · 设置方向后单击添加 " +
MARKER_TYPES[currentMode].label + MARKER_TYPES[currentMode].label +
" — 在图上单击添加箭头标记,可拖拽调整" " 标记"
); );
updateButtons(); updateButtons();
}); });
@@ -171,6 +327,7 @@
chartImage.onerror = function () { chartImage.onerror = function () {
setHint("图片加载失败,请换一张重试"); setHint("图片加载失败,请换一张重试");
imageLoaded = false; imageLoaded = false;
showEditorUI(false);
updateButtons(); updateButtons();
}; };
chartImage.src = e.target.result; chartImage.src = e.target.result;
@@ -184,15 +341,25 @@
function addMarker(x, y) { function addMarker(x, y) {
const type = currentMode; const type = currentMode;
const info = MARKER_TYPES[type]; const info = MARKER_TYPES[type];
markers.push({ x: x, y: y, type: type, color: info.color }); const ratio = xyToRatio(x, y);
markers.push({
xRatio: ratio.xRatio,
yRatio: ratio.yRatio,
type: type,
color: info.color,
angle: pendingAngle,
});
selectedIndex = markers.length - 1;
redraw(); redraw();
updateButtons(); updateButtons();
setHint( setHint(
"已添加 " + "已添加 " +
info.label + info.label +
" 标记(共 " + "" +
pendingAngle +
"°,共 " +
markers.length + markers.length +
" 个)— 可切换模式继续标注" " 个)"
); );
} }
@@ -201,8 +368,6 @@
const natW = chartImage.naturalWidth; const natW = chartImage.naturalWidth;
const natH = chartImage.naturalHeight; const natH = chartImage.naturalHeight;
const scaleX = natW / displayWidth;
const scaleY = natH / displayHeight;
const exportCanvas = document.createElement("canvas"); const exportCanvas = document.createElement("canvas");
exportCanvas.width = natW; exportCanvas.width = natW;
@@ -214,10 +379,11 @@
for (const m of markers) { for (const m of markers) {
drawArrowMarker( drawArrowMarker(
exCtx, exCtx,
m.x * scaleX, m.xRatio * natW,
m.y * scaleY, m.yRatio * natH,
m.type, m.angle,
m.color m.color,
false
); );
} }
@@ -251,6 +417,7 @@
} }
function updateCursor(x, y) { function updateCursor(x, y) {
if (isPanning) return;
const idx = findMarkerAt(x, y); const idx = findMarkerAt(x, y);
if (idx >= 0) { if (idx >= 0) {
canvas.classList.add("can-drag"); canvas.classList.add("can-drag");
@@ -270,17 +437,65 @@
didDragMove = false; didDragMove = false;
if (imageLoaded) { if (imageLoaded) {
setHint( setHint(
"当前模式" + "当前:" +
MARKER_TYPES[currentMode].label + MARKER_TYPES[currentMode].label +
" — 单击添加箭头,可连续标注多个点位" " · 方向 " +
pendingAngle +
"° · 单击添加标记"
); );
} }
}); });
}); });
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 () { fileInput.addEventListener("change", function () {
const file = fileInput.files[0]; loadImageFile(fileInput.files[0]);
loadImageFile(file);
fileInput.value = ""; fileInput.value = "";
}); });
@@ -298,12 +513,29 @@
dropZone.addEventListener("drop", function (e) { dropZone.addEventListener("drop", function (e) {
e.preventDefault(); e.preventDefault();
dropZone.classList.remove("drag-over"); dropZone.classList.remove("drag-over");
const file = e.dataTransfer.files[0]; loadImageFile(e.dataTransfer.files[0]);
loadImageFile(file);
}); });
function startPan(e) {
if (!imageLoaded) return;
isPanning = true;
panStartX = e.clientX;
panStartY = e.clientY;
panScrollLeft = viewport.scrollLeft;
panScrollTop = viewport.scrollTop;
viewport.classList.add("is-panning");
e.preventDefault();
}
canvas.addEventListener("mousedown", function (e) { canvas.addEventListener("mousedown", function (e) {
if (!imageLoaded) return; if (!imageLoaded) return;
if (e.button === 1 || e.button === 2) {
startPan(e);
return;
}
if (e.button !== 0) return;
e.preventDefault(); e.preventDefault();
const pt = getCanvasPoint(e); const pt = getCanvasPoint(e);
const idx = findMarkerAt(pt.x, pt.y); const idx = findMarkerAt(pt.x, pt.y);
@@ -311,18 +543,32 @@
dragIndex = idx; dragIndex = idx;
isDragging = true; isDragging = true;
didDragMove = false; didDragMove = false;
selectMarker(idx);
canvas.classList.add("can-drag"); canvas.classList.add("can-drag");
} else {
selectMarker(-1);
} }
}); });
canvas.addEventListener("mousemove", function (e) { canvas.addEventListener("mousemove", function (e) {
if (!imageLoaded) return; if (!imageLoaded) return;
if (isPanning) {
viewport.scrollLeft = panScrollLeft - (e.clientX - panStartX);
viewport.scrollTop = panScrollTop - (e.clientY - panStartY);
return;
}
const pt = getCanvasPoint(e); const pt = getCanvasPoint(e);
if (isDragging && dragIndex >= 0) { if (isDragging && dragIndex >= 0) {
didDragMove = true; didDragMove = true;
markers[dragIndex].x = Math.max(0, Math.min(canvas.width, pt.x)); const ratio = xyToRatio(
markers[dragIndex].y = Math.max(0, Math.min(canvas.height, pt.y)); 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(); redraw();
return; return;
} }
@@ -330,17 +576,30 @@
updateCursor(pt.x, pt.y); updateCursor(pt.x, pt.y);
}); });
canvas.addEventListener("mouseup", function () { function endPan() {
isPanning = false;
viewport.classList.remove("is-panning");
}
canvas.addEventListener("mouseup", function (e) {
if (e.button === 1 || e.button === 2 || isPanning) {
endPan();
}
isDragging = false; isDragging = false;
dragIndex = -1; dragIndex = -1;
}); });
canvas.addEventListener("mouseleave", function () { canvas.addEventListener("mouseleave", function () {
endPan();
isDragging = false; isDragging = false;
dragIndex = -1; dragIndex = -1;
canvas.classList.remove("can-drag"); canvas.classList.remove("can-drag");
}); });
canvas.addEventListener("contextmenu", function (e) {
e.preventDefault();
});
canvas.addEventListener("click", function (e) { canvas.addEventListener("click", function (e) {
if (!imageLoaded) return; if (!imageLoaded) return;
if (didDragMove) { if (didDragMove) {
@@ -355,6 +614,9 @@
btnUndo.addEventListener("click", function () { btnUndo.addEventListener("click", function () {
if (markers.length === 0) return; if (markers.length === 0) return;
markers.pop(); markers.pop();
if (selectedIndex >= markers.length) {
selectedIndex = markers.length - 1;
}
redraw(); redraw();
updateButtons(); updateButtons();
setHint( setHint(
@@ -374,11 +636,14 @@
btnDownload.addEventListener("click", exportImage); btnDownload.addEventListener("click", exportImage);
window.addEventListener("resize", function () { window.addEventListener("resize", function () {
if (imageLoaded) { if (!imageLoaded) return;
syncCanvasSize(); calculateBaseSize();
} applyLayout();
}); });
dropPlaceholder.classList.remove("hidden"); window.addEventListener("mouseup", endPan);
showEditorUI(false);
updateButtons(); updateButtons();
syncAngleUI(0);
})(); })();