bounding-box-demo / index.html
arnodjiang's picture
Upload 2 files
3ac2d65 verified
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Bounding Box Demo</title>
<style>
:root {
color-scheme: light;
--bg: #f3f6fb;
--surface: #ffffff;
--surface-strong: #f8fafc;
--ink: #172033;
--muted: #667085;
--line: #d8e0ea;
--accent: #2563eb;
--accent-strong: #1d4ed8;
--accent-soft: #eaf1ff;
--teal: #0f9f8f;
--danger: #dc2626;
--danger-soft: #fee2e2;
--shadow: 0 20px 60px rgba(15, 23, 42, 0.10);
--radius: 8px;
}
* {
box-sizing: border-box;
}
html,
body {
width: 100%;
min-height: 100%;
margin: 0;
overflow-x: hidden;
background: var(--bg);
color: var(--ink);
font-family:
Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont,
"Segoe UI", "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei",
sans-serif;
letter-spacing: 0;
}
button,
input,
textarea {
font: inherit;
}
button {
border: 0;
}
.app {
min-height: 100vh;
padding: 18px;
}
.topbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
width: min(1480px, 100%);
margin: 0 auto 14px;
}
.brand {
display: flex;
flex-direction: column;
gap: 3px;
min-width: 0;
}
.brand-kicker {
color: var(--teal);
font-size: 12px;
font-weight: 800;
text-transform: uppercase;
}
h1 {
margin: 0;
color: var(--ink);
font-size: 28px;
line-height: 1.1;
font-weight: 850;
letter-spacing: 0;
}
.top-actions {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 10px;
flex-wrap: wrap;
}
.button,
.upload-button {
min-height: 38px;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 0 14px;
border-radius: var(--radius);
background: var(--surface);
color: var(--ink);
border: 1px solid var(--line);
cursor: pointer;
font-weight: 750;
white-space: nowrap;
transition:
transform 0.16s ease,
border-color 0.16s ease,
background 0.16s ease,
color 0.16s ease;
}
.button:hover,
.upload-button:hover {
transform: translateY(-1px);
border-color: #b8c7da;
background: var(--surface-strong);
}
.button:disabled {
opacity: 0.45;
cursor: not-allowed;
transform: none;
}
.button.primary,
.upload-button.primary {
color: #fff;
border-color: var(--accent);
background: var(--accent);
}
.button.primary:hover,
.upload-button.primary:hover {
background: var(--accent-strong);
}
.button.danger {
color: var(--danger);
background: var(--danger-soft);
border-color: #fecaca;
}
.upload-button input {
display: none;
}
.workspace {
width: min(1480px, 100%);
margin: 0 auto;
display: grid;
grid-template-columns: minmax(0, 1fr) 360px;
gap: 14px;
align-items: stretch;
}
.stage-panel,
.panel {
border: 1px solid var(--line);
background: var(--surface);
box-shadow: var(--shadow);
}
.stage-panel {
min-width: 0;
border-radius: var(--radius);
overflow: hidden;
display: flex;
flex-direction: column;
}
.stage-toolbar {
min-height: 58px;
display: flex;
align-items: center;
justify-content: flex-end;
gap: 12px;
padding: 10px 12px;
border-bottom: 1px solid var(--line);
background: var(--surface-strong);
}
.stage-meta {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 8px;
min-width: 0;
color: var(--muted);
font-size: 13px;
font-weight: 700;
}
.pill {
max-width: 260px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
padding: 5px 9px;
border-radius: 999px;
border: 1px solid var(--line);
background: var(--surface);
}
.canvas-wrap {
position: relative;
min-height: 560px;
height: calc(100vh - 170px);
background:
linear-gradient(45deg, #eef2f7 25%, transparent 25%) 0 0 / 22px 22px,
linear-gradient(45deg, transparent 75%, #eef2f7 75%) 0 0 / 22px 22px,
linear-gradient(45deg, transparent 75%, #eef2f7 75%) 11px 11px / 22px 22px,
linear-gradient(45deg, #eef2f7 25%, #f8fafc 25%) 11px 11px / 22px 22px;
overflow: hidden;
}
.canvas-wrap.is-dragover {
outline: 3px solid rgba(37, 99, 235, 0.34);
outline-offset: -6px;
}
canvas {
display: block;
width: 100%;
height: 100%;
touch-action: none;
}
.empty-state {
position: absolute;
inset: 50% auto auto 50%;
transform: translate(-50%, -50%);
width: min(390px, calc(100% - 48px));
padding: 22px;
border-radius: var(--radius);
background: rgba(255, 255, 255, 0.92);
border: 1px dashed #b8c7da;
text-align: center;
color: var(--muted);
pointer-events: none;
}
.empty-title {
margin: 0 0 5px;
color: var(--ink);
font-size: 18px;
font-weight: 850;
}
.empty-subtitle {
margin: 0;
font-size: 13px;
font-weight: 650;
}
.sidebar {
display: flex;
min-width: 0;
flex-direction: column;
gap: 14px;
}
.panel {
border-radius: var(--radius);
padding: 14px;
}
.panel-title {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
margin: 0 0 12px;
color: var(--ink);
font-size: 15px;
font-weight: 850;
}
.field {
display: flex;
min-width: 0;
flex-direction: column;
gap: 6px;
color: var(--muted);
font-size: 12px;
font-weight: 800;
text-transform: uppercase;
}
.field input,
.field select,
.field textarea {
width: 100%;
min-width: 0;
border: 1px solid var(--line);
border-radius: var(--radius);
background: var(--surface-strong);
color: var(--ink);
outline: none;
transition:
border-color 0.16s ease,
box-shadow 0.16s ease,
background 0.16s ease;
}
.field input,
.field select {
height: 38px;
padding: 0 10px;
font-weight: 750;
}
.field select {
appearance: none;
background-image:
linear-gradient(45deg, transparent 50%, var(--muted) 50%),
linear-gradient(135deg, var(--muted) 50%, transparent 50%);
background-position:
calc(100% - 16px) 16px,
calc(100% - 11px) 16px;
background-size: 5px 5px;
background-repeat: no-repeat;
padding-right: 30px;
}
.field textarea {
min-height: 176px;
padding: 10px;
resize: vertical;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
font-size: 12px;
line-height: 1.45;
}
.field input:focus,
.field select:focus,
.field textarea:focus {
border-color: rgba(37, 99, 235, 0.78);
box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.12);
background: #fff;
}
.field input:disabled {
color: #98a2b3;
background: #f2f4f7;
}
.coord-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px;
margin-bottom: 10px;
}
.field.full {
margin-bottom: 12px;
}
.paste-grid {
display: grid;
grid-template-columns: minmax(0, 1fr) 112px;
gap: 10px;
margin-bottom: 2px;
}
.result-stack {
display: flex;
flex-direction: column;
gap: 8px;
}
.result-row {
display: grid;
grid-template-columns: minmax(0, 1fr) 58px;
gap: 8px;
align-items: center;
padding: 10px;
border: 1px solid var(--line);
border-radius: var(--radius);
background: var(--surface-strong);
}
.result-text {
min-width: 0;
display: flex;
flex-direction: column;
gap: 5px;
}
.result-label {
color: var(--muted);
font-size: 12px;
font-weight: 850;
text-transform: uppercase;
}
.result-code {
min-height: 24px;
overflow-wrap: anywhere;
color: var(--ink);
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
font-size: 12px;
line-height: 1.5;
white-space: pre-wrap;
}
.copy-button {
width: 58px;
height: 34px;
border-radius: 6px;
border: 1px solid var(--line);
background: #fff;
color: var(--accent);
cursor: pointer;
font-size: 12px;
font-weight: 850;
}
.copy-button:hover:not(:disabled) {
border-color: rgba(37, 99, 235, 0.45);
background: var(--accent-soft);
}
.copy-button:disabled {
color: #98a2b3;
cursor: not-allowed;
background: #f2f4f7;
}
.status {
min-height: 22px;
margin: 10px auto 0;
width: min(1480px, 100%);
color: var(--muted);
font-size: 12px;
font-weight: 700;
}
@media (max-width: 980px) {
.app {
padding: 12px;
}
.topbar,
.workspace {
width: 100%;
}
.topbar {
align-items: flex-start;
flex-direction: column;
}
.top-actions {
width: 100%;
justify-content: flex-start;
}
.workspace {
grid-template-columns: 1fr;
}
.canvas-wrap {
min-height: 420px;
height: 58vh;
}
.stage-meta {
width: 100%;
justify-content: flex-start;
flex-wrap: wrap;
}
.sidebar {
display: grid;
grid-template-columns: 1fr;
}
}
@media (max-width: 520px) {
h1 {
font-size: 23px;
}
.button,
.upload-button {
width: 100%;
}
.top-actions,
.paste-grid {
grid-template-columns: 1fr;
}
.top-actions {
display: grid;
grid-template-columns: 1fr;
}
.coord-grid {
grid-template-columns: 1fr;
}
}
</style>
</head>
<body>
<div class="app">
<header class="topbar">
<div class="brand">
<span class="brand-kicker">Hugging Face Space</span>
<h1>Bounding Box Annotator</h1>
</div>
<div class="top-actions">
<label class="upload-button primary" for="fileInput">
上传图片
<input id="fileInput" type="file" accept="image/*">
</label>
</div>
</header>
<main class="workspace">
<section class="stage-panel" aria-label="image annotation canvas">
<div class="stage-toolbar">
<div class="stage-meta">
<span class="pill" id="imageMeta">未上传图片</span>
<span class="pill" id="boxMeta">未标注</span>
</div>
</div>
<div class="canvas-wrap" id="canvasWrap">
<canvas id="canvas"></canvas>
<div class="empty-state" id="emptyState">
<p class="empty-title">上传图片开始标注</p>
<p class="empty-subtitle">坐标以原始图片像素为准</p>
</div>
</div>
</section>
<aside class="sidebar">
<section class="panel">
<h2 class="panel-title">坐标编辑</h2>
<div class="coord-grid">
<label class="field">X1
<input id="x1Input" type="number" min="0" step="1" disabled>
</label>
<label class="field">Y1
<input id="y1Input" type="number" min="0" step="1" disabled>
</label>
<label class="field">X2
<input id="x2Input" type="number" min="1" step="1" disabled>
</label>
<label class="field">Y2
<input id="y2Input" type="number" min="1" step="1" disabled>
</label>
</div>
<div class="paste-grid">
<label class="field">粘贴坐标
<input id="pasteInput" type="text" placeholder="[x1,y1,x2,y2]" disabled>
</label>
<label class="field">类型
<select id="pasteMode" disabled>
<option value="absolute">绝对</option>
<option value="scale1000">0-1000</option>
<option value="relative">0-1</option>
</select>
</label>
</div>
</section>
<section class="panel">
<h2 class="panel-title">坐标结果</h2>
<div class="result-stack">
<div class="result-row">
<div class="result-text">
<div class="result-label">绝对坐标 x1 y1 x2 y2</div>
<div class="result-code" id="absoluteOutput">未选择</div>
</div>
<button class="copy-button" type="button" data-copy="absoluteOutput" disabled>复制</button>
</div>
<div class="result-row">
<div class="result-text">
<div class="result-label">绝对坐标 x y w h</div>
<div class="result-code" id="sizeOutput">未选择</div>
</div>
<button class="copy-button" type="button" data-copy="sizeOutput" disabled>复制</button>
</div>
<div class="result-row">
<div class="result-text">
<div class="result-label">相对坐标 x1 y1 x2 y2</div>
<div class="result-code" id="relativeOutput">未选择</div>
</div>
<button class="copy-button" type="button" data-copy="relativeOutput" disabled>复制</button>
</div>
<div class="result-row">
<div class="result-text">
<div class="result-label">相对坐标 0-1000 x1 y1 x2 y2</div>
<div class="result-code" id="relative1000Output">未选择</div>
</div>
<button class="copy-button" type="button" data-copy="relative1000Output" disabled>复制</button>
</div>
<div class="result-row">
<div class="result-text">
<div class="result-label">YOLO 格式 cx cy w h</div>
<div class="result-code" id="yoloOutput">未选择</div>
</div>
<button class="copy-button" type="button" data-copy="yoloOutput" disabled>复制</button>
</div>
</div>
</section>
</aside>
</main>
<div class="status" id="statusLine"></div>
</div>
<script>
(() => {
"use strict";
const $ = (id) => document.getElementById(id);
const canvas = $("canvas");
const ctx = canvas.getContext("2d");
const canvasWrap = $("canvasWrap");
const emptyState = $("emptyState");
const controls = {
fileInput: $("fileInput"),
x1Input: $("x1Input"),
y1Input: $("y1Input"),
x2Input: $("x2Input"),
y2Input: $("y2Input"),
pasteInput: $("pasteInput"),
pasteMode: $("pasteMode"),
imageMeta: $("imageMeta"),
boxMeta: $("boxMeta"),
absoluteOutput: $("absoluteOutput"),
sizeOutput: $("sizeOutput"),
relativeOutput: $("relativeOutput"),
relative1000Output: $("relative1000Output"),
yoloOutput: $("yoloOutput"),
copyButtons: Array.from(document.querySelectorAll("[data-copy]")),
statusLine: $("statusLine")
};
const palette = [
"#2563eb",
"#0f9f8f",
"#f59e0b",
"#dc2626",
"#7c3aed",
"#0891b2",
"#16a34a",
"#e11d48"
];
const state = {
image: null,
imageName: "",
box: null,
drag: null,
scale: 1,
offsetX: 0,
offsetY: 0,
canvasWidth: 1,
canvasHeight: 1,
dpr: 1
};
function clamp(value, min, max) {
if (!Number.isFinite(value)) {
return min;
}
if (max < min) {
return min;
}
return Math.min(max, Math.max(min, value));
}
function imageWidth() {
return state.image ? state.image.naturalWidth : 0;
}
function imageHeight() {
return state.image ? state.image.naturalHeight : 0;
}
function selectedBox() {
return state.box;
}
function makeBox(x, y, width, height) {
return {
x,
y,
width,
height,
color: palette[0]
};
}
function constrainBox(box) {
const maxW = imageWidth();
const maxH = imageHeight();
if (!maxW || !maxH) {
return;
}
box.x = clamp(Math.round(box.x), 0, Math.max(0, maxW - 1));
box.y = clamp(Math.round(box.y), 0, Math.max(0, maxH - 1));
box.width = clamp(Math.round(box.width), 1, maxW - box.x);
box.height = clamp(Math.round(box.height), 1, maxH - box.y);
}
function constrainAllBoxes() {
if (state.box) {
constrainBox(state.box);
}
}
function imageToCanvas(point) {
return {
x: state.offsetX + point.x * state.scale,
y: state.offsetY + point.y * state.scale
};
}
function eventToImagePoint(event) {
const rect = canvas.getBoundingClientRect();
const x = (event.clientX - rect.left - state.offsetX) / state.scale;
const y = (event.clientY - rect.top - state.offsetY) / state.scale;
return {
x: clamp(x, 0, imageWidth()),
y: clamp(y, 0, imageHeight())
};
}
function handlePoints(box) {
const x1 = box.x;
const y1 = box.y;
const x2 = box.x + box.width;
const y2 = box.y + box.height;
const cx = box.x + box.width / 2;
const cy = box.y + box.height / 2;
return [
{ name: "nw", x: x1, y: y1 },
{ name: "n", x: cx, y: y1 },
{ name: "ne", x: x2, y: y1 },
{ name: "e", x: x2, y: cy },
{ name: "se", x: x2, y: y2 },
{ name: "s", x: cx, y: y2 },
{ name: "sw", x: x1, y: y2 },
{ name: "w", x: x1, y: cy }
];
}
function hitTest(point) {
const box = state.box;
if (!box) {
return null;
}
const tolerance = Math.max(6 / state.scale, 4);
for (const handle of handlePoints(box)) {
if (
Math.abs(point.x - handle.x) <= tolerance &&
Math.abs(point.y - handle.y) <= tolerance
) {
return { box, action: "resize", handle: handle.name };
}
}
if (
point.x >= box.x &&
point.x <= box.x + box.width &&
point.y >= box.y &&
point.y <= box.y + box.height
) {
return { box, action: "move", handle: null };
}
return null;
}
function cursorForHit(hit) {
if (!hit) {
return state.image ? "crosshair" : "default";
}
if (hit.action === "move") {
return "move";
}
const map = {
n: "ns-resize",
s: "ns-resize",
e: "ew-resize",
w: "ew-resize",
nw: "nwse-resize",
se: "nwse-resize",
ne: "nesw-resize",
sw: "nesw-resize"
};
return map[hit.handle] || "default";
}
function resizeCanvas() {
const rect = canvasWrap.getBoundingClientRect();
const width = Math.max(1, Math.round(rect.width));
const height = Math.max(1, Math.round(rect.height));
const dpr = window.devicePixelRatio || 1;
state.canvasWidth = width;
state.canvasHeight = height;
state.dpr = dpr;
canvas.width = Math.round(width * dpr);
canvas.height = Math.round(height * dpr);
canvas.style.width = `${width}px`;
canvas.style.height = `${height}px`;
draw();
}
function drawImageSurface() {
if (!state.image) {
emptyState.style.display = "block";
return;
}
emptyState.style.display = "none";
const padding = state.canvasWidth < 700 ? 16 : 28;
const availableWidth = Math.max(1, state.canvasWidth - padding * 2);
const availableHeight = Math.max(1, state.canvasHeight - padding * 2);
state.scale = Math.min(
availableWidth / imageWidth(),
availableHeight / imageHeight()
);
state.scale = Math.max(0.01, state.scale);
const drawWidth = imageWidth() * state.scale;
const drawHeight = imageHeight() * state.scale;
state.offsetX = (state.canvasWidth - drawWidth) / 2;
state.offsetY = (state.canvasHeight - drawHeight) / 2;
ctx.save();
ctx.fillStyle = "#ffffff";
ctx.shadowColor = "rgba(15, 23, 42, 0.22)";
ctx.shadowBlur = 22;
ctx.shadowOffsetY = 12;
ctx.fillRect(state.offsetX, state.offsetY, drawWidth, drawHeight);
ctx.restore();
ctx.drawImage(state.image, state.offsetX, state.offsetY, drawWidth, drawHeight);
}
function drawBoxes() {
const box = state.box;
if (!state.image || !box) {
return;
}
const topLeft = imageToCanvas({ x: box.x, y: box.y });
const width = box.width * state.scale;
const height = box.height * state.scale;
ctx.save();
ctx.lineWidth = 3;
ctx.strokeStyle = box.color;
ctx.fillStyle = `${box.color}1f`;
ctx.fillRect(topLeft.x, topLeft.y, width, height);
ctx.strokeRect(topLeft.x, topLeft.y, width, height);
const size = 8;
ctx.fillStyle = "#ffffff";
ctx.strokeStyle = box.color;
ctx.lineWidth = 2;
for (const handle of handlePoints(box)) {
const point = imageToCanvas(handle);
ctx.beginPath();
ctx.rect(point.x - size / 2, point.y - size / 2, size, size);
ctx.fill();
ctx.stroke();
}
ctx.restore();
}
function draw() {
ctx.setTransform(state.dpr, 0, 0, state.dpr, 0, 0);
ctx.clearRect(0, 0, state.canvasWidth, state.canvasHeight);
ctx.fillStyle = "#f8fafc";
ctx.fillRect(0, 0, state.canvasWidth, state.canvasHeight);
drawImageSurface();
drawBoxes();
}
function formatRatio(value) {
return Number.isFinite(value) ? value.toFixed(6) : "0.000000";
}
function formatList(values) {
return `[${values.join(",")}]`;
}
function formatScale1000(value) {
return Number.isFinite(value) ? Math.round(value * 1000) : 0;
}
function toCorners(box) {
return {
x1: box.x,
y1: box.y,
x2: box.x + box.width,
y2: box.y + box.height
};
}
function resultLines(box) {
if (!box || !state.image) {
return null;
}
const { x1, y1, x2, y2 } = toCorners(box);
const cx = box.x + box.width / 2;
const cy = box.y + box.height / 2;
const iw = imageWidth();
const ih = imageHeight();
return {
absolute: formatList([x1, y1, x2, y2]),
size: formatList([box.x, box.y, box.width, box.height]),
relative: formatList([
formatRatio(x1 / iw),
formatRatio(y1 / ih),
formatRatio(x2 / iw),
formatRatio(y2 / ih)
]),
relative1000: formatList([
formatScale1000(x1 / iw),
formatScale1000(y1 / ih),
formatScale1000(x2 / iw),
formatScale1000(y2 / ih)
]),
yolo: formatList([
formatRatio(cx / iw),
formatRatio(cy / ih),
formatRatio(box.width / iw),
formatRatio(box.height / ih)
])
};
}
function updateResults(selected) {
const values = resultLines(selected);
if (!values) {
controls.absoluteOutput.textContent = "未选择";
controls.sizeOutput.textContent = "未选择";
controls.relativeOutput.textContent = "未选择";
controls.relative1000Output.textContent = "未选择";
controls.yoloOutput.textContent = "未选择";
controls.copyButtons.forEach((button) => {
button.disabled = true;
});
return;
}
controls.absoluteOutput.textContent = values.absolute;
controls.sizeOutput.textContent = values.size;
controls.relativeOutput.textContent = values.relative;
controls.relative1000Output.textContent = values.relative1000;
controls.yoloOutput.textContent = values.yolo;
controls.copyButtons.forEach((button) => {
button.disabled = false;
});
}
function updateControls() {
const hasImage = Boolean(state.image);
const selected = selectedBox();
const hasSelected = Boolean(selected);
const disabled = !hasSelected;
controls.pasteInput.disabled = !hasImage;
controls.pasteMode.disabled = !hasImage;
for (const input of [
controls.x1Input,
controls.y1Input,
controls.x2Input,
controls.y2Input
]) {
input.disabled = disabled;
}
if (selected) {
const { x1, y1, x2, y2 } = toCorners(selected);
controls.x1Input.value = x1;
controls.y1Input.value = y1;
controls.x2Input.value = x2;
controls.y2Input.value = y2;
} else {
controls.x1Input.value = "";
controls.y1Input.value = "";
controls.x2Input.value = "";
controls.y2Input.value = "";
}
controls.imageMeta.textContent = hasImage
? `${state.imageName} · ${imageWidth()} × ${imageHeight()}`
: "未上传图片";
controls.boxMeta.textContent = hasSelected ? "已标注" : "未标注";
updateResults(selected);
}
function setStatus(message) {
controls.statusLine.textContent = message;
}
function sync() {
constrainAllBoxes();
draw();
updateControls();
}
function loadImageFile(file) {
if (!file || !file.type.startsWith("image/")) {
setStatus("请选择图片文件");
return;
}
const reader = new FileReader();
reader.onload = () => {
const image = new Image();
image.onload = () => {
state.image = image;
state.imageName = file.name || "uploaded-image";
state.box = null;
controls.pasteInput.value = "";
setStatus(`已载入 ${state.imageName}`);
sync();
};
image.onerror = () => setStatus("图片读取失败");
image.src = reader.result;
};
reader.onerror = () => setStatus("图片读取失败");
reader.readAsDataURL(file);
}
function setBoxFromCorners(x1, y1, x2, y2) {
if (!state.image) {
return;
}
const left = clamp(Math.round(Math.min(x1, x2)), 0, Math.max(0, imageWidth() - 1));
const top = clamp(Math.round(Math.min(y1, y2)), 0, Math.max(0, imageHeight() - 1));
const right = clamp(Math.round(Math.max(x1, x2)), left + 1, imageWidth());
const bottom = clamp(Math.round(Math.max(y1, y2)), top + 1, imageHeight());
state.box = makeBox(left, top, right - left, bottom - top);
sync();
}
function clearBox() {
state.box = null;
sync();
}
function updateSelectedFromInputs() {
if (!selectedBox() || !state.image) {
return;
}
const values = [
Number.parseFloat(controls.x1Input.value),
Number.parseFloat(controls.y1Input.value),
Number.parseFloat(controls.x2Input.value),
Number.parseFloat(controls.y2Input.value)
];
if (!values.every(Number.isFinite)) {
return;
}
setBoxFromCorners(...values);
}
function parseCoordinateList(value) {
const numbers = value.match(/-?\d+(?:\.\d+)?/g);
if (!numbers || numbers.length < 4) {
return null;
}
return numbers.slice(0, 4).map(Number);
}
function applyPastedCoordinates() {
if (!state.image) {
return;
}
const coords = parseCoordinateList(controls.pasteInput.value);
if (!coords) {
return;
}
let [x1, y1, x2, y2] = coords;
if (controls.pasteMode.value === "scale1000") {
x1 = (x1 / 1000) * imageWidth();
y1 = (y1 / 1000) * imageHeight();
x2 = (x2 / 1000) * imageWidth();
y2 = (y2 / 1000) * imageHeight();
} else if (controls.pasteMode.value === "relative") {
x1 *= imageWidth();
y1 *= imageHeight();
x2 *= imageWidth();
y2 *= imageHeight();
}
setBoxFromCorners(x1, y1, x2, y2);
setStatus("坐标已应用");
}
function moveBox(box, startBox, deltaX, deltaY) {
box.x = clamp(startBox.x + deltaX, 0, imageWidth() - startBox.width);
box.y = clamp(startBox.y + deltaY, 0, imageHeight() - startBox.height);
box.width = startBox.width;
box.height = startBox.height;
}
function resizeBox(box, startBox, deltaX, deltaY, handle) {
const minSize = 2;
let x1 = startBox.x;
let y1 = startBox.y;
let x2 = startBox.x + startBox.width;
let y2 = startBox.y + startBox.height;
if (handle.includes("w")) {
x1 = clamp(startBox.x + deltaX, 0, x2 - minSize);
}
if (handle.includes("e")) {
x2 = clamp(startBox.x + startBox.width + deltaX, x1 + minSize, imageWidth());
}
if (handle.includes("n")) {
y1 = clamp(startBox.y + deltaY, 0, y2 - minSize);
}
if (handle.includes("s")) {
y2 = clamp(startBox.y + startBox.height + deltaY, y1 + minSize, imageHeight());
}
box.x = x1;
box.y = y1;
box.width = x2 - x1;
box.height = y2 - y1;
}
function onPointerDown(event) {
if (!state.image) {
return;
}
const point = eventToImagePoint(event);
const hit = hitTest(point);
if (hit) {
state.drag = {
action: hit.action,
handle: hit.handle,
start: point,
startBox: { ...hit.box }
};
canvas.setPointerCapture(event.pointerId);
sync();
return;
}
const box = makeBox(point.x, point.y, 1, 1);
state.box = box;
state.drag = {
action: "create",
handle: null,
start: point,
startBox: { ...box }
};
canvas.setPointerCapture(event.pointerId);
sync();
}
function onPointerMove(event) {
if (!state.image) {
canvas.style.cursor = "default";
return;
}
const point = eventToImagePoint(event);
if (!state.drag) {
canvas.style.cursor = cursorForHit(hitTest(point));
return;
}
const box = state.box;
if (!box) {
return;
}
const deltaX = point.x - state.drag.start.x;
const deltaY = point.y - state.drag.start.y;
if (state.drag.action === "create") {
const x1 = clamp(Math.min(state.drag.start.x, point.x), 0, imageWidth());
const y1 = clamp(Math.min(state.drag.start.y, point.y), 0, imageHeight());
const x2 = clamp(Math.max(state.drag.start.x, point.x), 0, imageWidth());
const y2 = clamp(Math.max(state.drag.start.y, point.y), 0, imageHeight());
box.x = x1;
box.y = y1;
box.width = Math.max(1, x2 - x1);
box.height = Math.max(1, y2 - y1);
} else if (state.drag.action === "move") {
moveBox(box, state.drag.startBox, deltaX, deltaY);
} else if (state.drag.action === "resize") {
resizeBox(box, state.drag.startBox, deltaX, deltaY, state.drag.handle);
}
sync();
}
function onPointerUp(event) {
if (!state.drag) {
return;
}
const createdBox = state.drag.action === "create" ? selectedBox() : null;
if (createdBox && (createdBox.width < 3 || createdBox.height < 3)) {
state.box = null;
}
state.drag = null;
if (canvas.hasPointerCapture(event.pointerId)) {
canvas.releasePointerCapture(event.pointerId);
}
sync();
}
function copyText(text) {
if (!text || text === "未选择") {
return;
}
const fallback = () => {
const textarea = document.createElement("textarea");
textarea.value = text;
textarea.style.position = "fixed";
textarea.style.opacity = "0";
document.body.append(textarea);
textarea.focus();
textarea.select();
document.execCommand("copy");
textarea.remove();
};
if (navigator.clipboard && window.isSecureContext) {
navigator.clipboard
.writeText(text)
.then(() => setStatus("已复制"))
.catch(() => {
fallback();
setStatus("已复制");
});
} else {
fallback();
setStatus("已复制");
}
}
controls.fileInput.addEventListener("change", (event) => {
loadImageFile(event.target.files[0]);
event.target.value = "";
});
for (const input of [
controls.x1Input,
controls.y1Input,
controls.x2Input,
controls.y2Input
]) {
input.addEventListener("input", updateSelectedFromInputs);
}
controls.pasteInput.addEventListener("input", applyPastedCoordinates);
controls.pasteMode.addEventListener("change", applyPastedCoordinates);
controls.copyButtons.forEach((button) => {
button.addEventListener("click", () => {
copyText($(button.dataset.copy).textContent);
});
});
canvas.addEventListener("pointerdown", onPointerDown);
canvas.addEventListener("pointermove", onPointerMove);
canvas.addEventListener("pointerup", onPointerUp);
canvas.addEventListener("pointercancel", onPointerUp);
canvasWrap.addEventListener("dragover", (event) => {
event.preventDefault();
canvasWrap.classList.add("is-dragover");
});
canvasWrap.addEventListener("dragleave", () => {
canvasWrap.classList.remove("is-dragover");
});
canvasWrap.addEventListener("drop", (event) => {
event.preventDefault();
canvasWrap.classList.remove("is-dragover");
loadImageFile(event.dataTransfer.files[0]);
});
document.addEventListener("keydown", (event) => {
const activeTag = document.activeElement ? document.activeElement.tagName : "";
const isTyping = ["INPUT", "TEXTAREA"].includes(activeTag);
if (isTyping) {
return;
}
if (event.key === "Delete" || event.key === "Backspace") {
clearBox();
}
});
new ResizeObserver(resizeCanvas).observe(canvasWrap);
sync();
})();
</script>
</body>
</html>