735 lines
19 KiB
JavaScript
735 lines
19 KiB
JavaScript
const express = require("express");
|
|
const OpenAI = require("openai");
|
|
const cors = require("cors");
|
|
require("dotenv").config();
|
|
|
|
const app = express();
|
|
app.use(cors());
|
|
app.use(express.json({ limit: "10mb" }));
|
|
|
|
const client = new OpenAI({ apiKey: process.env.OPENAI_API_KEY });
|
|
|
|
// ---- API: analyze image ----
|
|
app.post("/analyze", async (req, res) => {
|
|
try {
|
|
const { imageUrl } = req.body;
|
|
if (!imageUrl) return res.status(400).json({ error: "imageUrl is required" });
|
|
|
|
const aiRes = await client.chat.completions.create({
|
|
model: "gpt-4o-mini", // upgraded: better vision accuracy
|
|
temperature: 0,
|
|
response_format: { type: "json_object" },
|
|
messages: [
|
|
{
|
|
role: "system",
|
|
content: "You are a RAM hardware OCR expert. Extract text from RAM module labels and return only valid JSON, no markdown."
|
|
},
|
|
{
|
|
role: "user",
|
|
content: [
|
|
{
|
|
type: "text",
|
|
text: `Read all text on this RAM module label and return JSON:
|
|
|
|
{
|
|
"modules": [
|
|
{
|
|
"brand": "<manufacturer name>",
|
|
"part_number": "<exact part number>",
|
|
"specs": "<capacity, rank, speed spec line>",
|
|
"raw_label": "<all visible text on label, space-separated>"
|
|
}
|
|
],
|
|
"total_modules": <integer>
|
|
}`
|
|
},
|
|
{
|
|
type: "image_url",
|
|
image_url: { url: imageUrl, detail: "high" } // request high detail
|
|
}
|
|
]
|
|
}
|
|
]
|
|
});
|
|
|
|
const raw = aiRes.choices[0].message.content;
|
|
const data = JSON.parse(raw);
|
|
|
|
res.json(data);
|
|
} catch (err) {
|
|
console.error(err);
|
|
res.status(500).json({ error: err.message });
|
|
}
|
|
});
|
|
|
|
// ---- serve UI ----
|
|
app.get("/", (_req, res) => {
|
|
res.send(`<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8"/>
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
|
<title>RAM OCR Inspector</title>
|
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;600;700&family=Syne:wght@400;600;800&display=swap" rel="stylesheet">
|
|
<style>
|
|
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
|
|
|
:root {
|
|
--bg: #0a0c10;
|
|
--surface: #111318;
|
|
--surface2: #181c24;
|
|
--border: #252a35;
|
|
--border-glow: #2a7fff44;
|
|
--text: #e8eaf0;
|
|
--text-muted: #6b7280;
|
|
--accent: #2a7fff;
|
|
--accent-dim: #1a4f99;
|
|
--green: #00e5a0;
|
|
--yellow: #f5c518;
|
|
--red: #ff4d6a;
|
|
--mono: 'JetBrains Mono', monospace;
|
|
--sans: 'Syne', sans-serif;
|
|
}
|
|
|
|
body {
|
|
background: var(--bg);
|
|
color: var(--text);
|
|
font-family: var(--sans);
|
|
min-height: 100vh;
|
|
display: flex;
|
|
flex-direction: column;
|
|
}
|
|
|
|
/* ── Header ── */
|
|
header {
|
|
padding: 18px 32px;
|
|
border-bottom: 1px solid var(--border);
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 14px;
|
|
background: var(--surface);
|
|
}
|
|
.logo-icon {
|
|
width: 34px; height: 34px;
|
|
background: var(--accent);
|
|
border-radius: 8px;
|
|
display: grid; place-items: center;
|
|
font-size: 16px;
|
|
}
|
|
header h1 {
|
|
font-size: 18px;
|
|
font-weight: 800;
|
|
letter-spacing: -0.3px;
|
|
}
|
|
header span {
|
|
font-family: var(--mono);
|
|
font-size: 11px;
|
|
color: var(--text-muted);
|
|
background: var(--surface2);
|
|
border: 1px solid var(--border);
|
|
padding: 2px 8px;
|
|
border-radius: 4px;
|
|
margin-left: 6px;
|
|
}
|
|
|
|
/* ── Main layout ── */
|
|
main {
|
|
flex: 1;
|
|
display: grid;
|
|
grid-template-columns: 1fr 380px;
|
|
gap: 0;
|
|
overflow: hidden;
|
|
}
|
|
|
|
/* ── Left panel ── */
|
|
.left {
|
|
display: flex;
|
|
flex-direction: column;
|
|
padding: 24px;
|
|
gap: 20px;
|
|
overflow-y: auto;
|
|
border-right: 1px solid var(--border);
|
|
}
|
|
|
|
.input-row {
|
|
display: flex;
|
|
gap: 10px;
|
|
align-items: center;
|
|
}
|
|
.input-row input {
|
|
flex: 1;
|
|
background: var(--surface2);
|
|
border: 1px solid var(--border);
|
|
color: var(--text);
|
|
font-family: var(--mono);
|
|
font-size: 13px;
|
|
padding: 10px 14px;
|
|
border-radius: 8px;
|
|
outline: none;
|
|
transition: border-color .2s;
|
|
}
|
|
.input-row input:focus { border-color: var(--accent); }
|
|
.input-row input::placeholder { color: var(--text-muted); }
|
|
|
|
button#analyzeBtn {
|
|
background: var(--accent);
|
|
color: #fff;
|
|
border: none;
|
|
font-family: var(--sans);
|
|
font-weight: 700;
|
|
font-size: 13px;
|
|
padding: 10px 22px;
|
|
border-radius: 8px;
|
|
cursor: pointer;
|
|
transition: background .2s, transform .1s;
|
|
white-space: nowrap;
|
|
}
|
|
button#analyzeBtn:hover { background: #1a6fe8; }
|
|
button#analyzeBtn:active { transform: scale(.97); }
|
|
button#analyzeBtn:disabled { background: var(--accent-dim); cursor: not-allowed; }
|
|
|
|
/* ── Canvas wrapper ── */
|
|
.canvas-wrap {
|
|
position: relative;
|
|
display: inline-block;
|
|
line-height: 0;
|
|
border-radius: 10px;
|
|
overflow: hidden;
|
|
border: 1px solid var(--border);
|
|
align-self: flex-start;
|
|
max-width: 100%;
|
|
}
|
|
.canvas-wrap img {
|
|
display: block;
|
|
max-width: 100%;
|
|
height: auto;
|
|
}
|
|
.canvas-wrap canvas {
|
|
position: absolute;
|
|
top: 0; left: 0;
|
|
width: 100%;
|
|
height: 100%;
|
|
cursor: crosshair;
|
|
}
|
|
|
|
.placeholder {
|
|
border: 2px dashed var(--border);
|
|
border-radius: 10px;
|
|
height: 280px;
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: center;
|
|
justify-content: center;
|
|
gap: 8px;
|
|
color: var(--text-muted);
|
|
font-family: var(--mono);
|
|
font-size: 13px;
|
|
}
|
|
.placeholder .icon { font-size: 36px; opacity: .4; }
|
|
|
|
/* ── Word list ── */
|
|
.word-list-wrap {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 6px;
|
|
}
|
|
.section-label {
|
|
font-family: var(--mono);
|
|
font-size: 10px;
|
|
text-transform: uppercase;
|
|
letter-spacing: 1.5px;
|
|
color: var(--text-muted);
|
|
padding-bottom: 4px;
|
|
border-bottom: 1px solid var(--border);
|
|
}
|
|
.word-chips {
|
|
display: flex;
|
|
flex-wrap: wrap;
|
|
gap: 6px;
|
|
}
|
|
.chip {
|
|
font-family: var(--mono);
|
|
font-size: 12px;
|
|
padding: 4px 10px;
|
|
background: var(--surface2);
|
|
border: 1px solid var(--border);
|
|
border-radius: 5px;
|
|
cursor: pointer;
|
|
transition: background .15s, border-color .15s, color .15s;
|
|
user-select: none;
|
|
}
|
|
.chip:hover { background: var(--accent); border-color: var(--accent); color: #fff; }
|
|
.chip.low-conf { border-color: var(--yellow); color: var(--yellow); }
|
|
.chip.dim { opacity: .45; }
|
|
|
|
/* ── Tooltip ── */
|
|
.tooltip {
|
|
position: fixed;
|
|
background: #fff;
|
|
color: #000;
|
|
font-family: var(--mono);
|
|
font-size: 12px;
|
|
padding: 4px 10px;
|
|
border-radius: 5px;
|
|
pointer-events: none;
|
|
opacity: 0;
|
|
transition: opacity .15s;
|
|
z-index: 999;
|
|
white-space: nowrap;
|
|
}
|
|
.tooltip.show { opacity: 1; }
|
|
|
|
/* ── Right panel ── */
|
|
.right {
|
|
display: flex;
|
|
flex-direction: column;
|
|
overflow: hidden;
|
|
}
|
|
|
|
.tabs {
|
|
display: flex;
|
|
border-bottom: 1px solid var(--border);
|
|
background: var(--surface);
|
|
}
|
|
.tab {
|
|
padding: 12px 20px;
|
|
font-family: var(--mono);
|
|
font-size: 12px;
|
|
color: var(--text-muted);
|
|
cursor: pointer;
|
|
border-bottom: 2px solid transparent;
|
|
transition: color .2s, border-color .2s;
|
|
user-select: none;
|
|
}
|
|
.tab.active { color: var(--accent); border-bottom-color: var(--accent); }
|
|
.tab:hover:not(.active) { color: var(--text); }
|
|
|
|
.tab-content {
|
|
flex: 1;
|
|
overflow-y: auto;
|
|
padding: 20px;
|
|
display: none;
|
|
}
|
|
.tab-content.active { display: block; }
|
|
|
|
/* ── Module cards ── */
|
|
.module-card {
|
|
background: var(--surface2);
|
|
border: 1px solid var(--border);
|
|
border-radius: 10px;
|
|
padding: 16px;
|
|
margin-bottom: 14px;
|
|
}
|
|
.module-card .brand {
|
|
font-size: 16px;
|
|
font-weight: 800;
|
|
color: var(--accent);
|
|
margin-bottom: 10px;
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
}
|
|
.badge {
|
|
font-family: var(--mono);
|
|
font-size: 10px;
|
|
padding: 2px 7px;
|
|
border-radius: 4px;
|
|
background: var(--accent-dim);
|
|
color: var(--accent);
|
|
}
|
|
.kv-grid {
|
|
display: grid;
|
|
grid-template-columns: 110px 1fr;
|
|
gap: 6px 12px;
|
|
font-family: var(--mono);
|
|
font-size: 12px;
|
|
}
|
|
.kv-grid .k { color: var(--text-muted); }
|
|
.kv-grid .v { color: var(--text); word-break: break-all; }
|
|
.kv-grid .v.good { color: var(--green); }
|
|
|
|
/* ── JSON panel ── */
|
|
pre#jsonOut {
|
|
font-family: var(--mono);
|
|
font-size: 11.5px;
|
|
line-height: 1.7;
|
|
color: #a8b4cc;
|
|
white-space: pre-wrap;
|
|
word-break: break-all;
|
|
}
|
|
|
|
/* ── Status bar ── */
|
|
.status {
|
|
padding: 8px 24px;
|
|
background: var(--surface);
|
|
border-top: 1px solid var(--border);
|
|
font-family: var(--mono);
|
|
font-size: 11px;
|
|
color: var(--text-muted);
|
|
display: flex;
|
|
gap: 20px;
|
|
align-items: center;
|
|
}
|
|
.status .dot {
|
|
width: 7px; height: 7px;
|
|
border-radius: 50%;
|
|
background: var(--border);
|
|
display: inline-block;
|
|
}
|
|
.status .dot.ready { background: var(--green); box-shadow: 0 0 6px var(--green); }
|
|
.status .dot.loading { background: var(--yellow); animation: blink 1s infinite; }
|
|
|
|
@keyframes blink {
|
|
0%, 100% { opacity: 1; }
|
|
50% { opacity: .3; }
|
|
}
|
|
|
|
/* ── Copy toast ── */
|
|
.toast {
|
|
position: fixed;
|
|
bottom: 30px; right: 30px;
|
|
background: var(--green);
|
|
color: #000;
|
|
font-family: var(--mono);
|
|
font-size: 13px;
|
|
font-weight: 600;
|
|
padding: 10px 18px;
|
|
border-radius: 8px;
|
|
opacity: 0;
|
|
transform: translateY(8px);
|
|
transition: opacity .25s, transform .25s;
|
|
pointer-events: none;
|
|
z-index: 1000;
|
|
}
|
|
.toast.show { opacity: 1; transform: translateY(0); }
|
|
|
|
/* scrollbar */
|
|
::-webkit-scrollbar { width: 6px; }
|
|
::-webkit-scrollbar-track { background: transparent; }
|
|
::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
|
|
<header>
|
|
<div class="logo-icon">🔍</div>
|
|
<h1>RAM OCR Inspector</h1>
|
|
<span>gpt-4o · high detail</span>
|
|
</header>
|
|
|
|
<main>
|
|
<!-- LEFT -->
|
|
<div class="left">
|
|
<div class="input-row">
|
|
<input id="url" type="url" placeholder="Paste image URL of RAM module…"/>
|
|
<button id="analyzeBtn" onclick="run()">Analyze</button>
|
|
</div>
|
|
|
|
<div id="imgArea">
|
|
<div class="placeholder">
|
|
<span class="icon">🖥️</span>
|
|
<span>Image will appear here</span>
|
|
</div>
|
|
</div>
|
|
|
|
<div id="wordListArea"></div>
|
|
</div>
|
|
|
|
<!-- RIGHT -->
|
|
<div class="right">
|
|
<div class="tabs">
|
|
<div class="tab active" onclick="switchTab('modules')">Modules</div>
|
|
<div class="tab" onclick="switchTab('json')">Raw JSON</div>
|
|
</div>
|
|
|
|
<div id="tab-modules" class="tab-content active">
|
|
<p style="color:var(--text-muted); font-family:var(--mono); font-size:13px;">
|
|
Module info will appear after analysis.
|
|
</p>
|
|
</div>
|
|
|
|
<div id="tab-json" class="tab-content">
|
|
<pre id="jsonOut">// No data yet</pre>
|
|
</div>
|
|
</div>
|
|
</main>
|
|
|
|
<div class="status">
|
|
<span class="dot" id="statusDot"></span>
|
|
<span id="statusText">Ready</span>
|
|
<span id="wordCount"></span>
|
|
</div>
|
|
|
|
<div class="tooltip" id="tooltip"></div>
|
|
<div class="toast" id="toast"></div>
|
|
|
|
<script>
|
|
let currentWords = [];
|
|
let hoveredIdx = -1;
|
|
let imgEl = null;
|
|
let canvasEl = null;
|
|
|
|
function switchTab(name) {
|
|
document.querySelectorAll('.tab').forEach((t,i) => {
|
|
t.classList.toggle('active', ['modules','json'][i] === name);
|
|
});
|
|
document.querySelectorAll('.tab-content').forEach(c => {
|
|
c.classList.toggle('active', c.id === 'tab-' + name);
|
|
});
|
|
}
|
|
|
|
function setStatus(state, msg) {
|
|
const dot = document.getElementById('statusDot');
|
|
dot.className = 'dot ' + (state === 'loading' ? 'loading' : state === 'ready' ? 'ready' : '');
|
|
document.getElementById('statusText').textContent = msg;
|
|
}
|
|
|
|
function showToast(msg) {
|
|
const t = document.getElementById('toast');
|
|
t.textContent = msg;
|
|
t.classList.add('show');
|
|
setTimeout(() => t.classList.remove('show'), 1800);
|
|
}
|
|
|
|
async function run() {
|
|
const url = document.getElementById('url').value.trim();
|
|
if (!url) return;
|
|
|
|
const btn = document.getElementById('analyzeBtn');
|
|
btn.disabled = true;
|
|
btn.textContent = 'Analyzing…';
|
|
setStatus('loading', 'Sending to GPT-4o…');
|
|
|
|
document.getElementById('tab-modules').innerHTML = \`<p style="color:var(--text-muted);font-family:var(--mono);font-size:13px">Analyzing…</p>\`;
|
|
document.getElementById('jsonOut').textContent = '// Waiting…';
|
|
|
|
try {
|
|
const res = await fetch('/analyze', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ imageUrl: url })
|
|
});
|
|
const data = await res.json();
|
|
|
|
if (data.error) throw new Error(data.error);
|
|
|
|
currentWords = data.words || [];
|
|
|
|
document.getElementById('jsonOut').textContent = JSON.stringify(data, null, 2);
|
|
renderModules(data);
|
|
renderWordChips(currentWords);
|
|
drawImage(url, currentWords);
|
|
|
|
document.getElementById('wordCount').textContent =
|
|
\`\${currentWords.length} words · \${data.total_modules || 0} module(s)\`;
|
|
setStatus('ready', 'Done');
|
|
} catch (err) {
|
|
setStatus('', 'Error: ' + err.message);
|
|
document.getElementById('tab-modules').innerHTML =
|
|
\`<p style="color:var(--red);font-family:var(--mono);font-size:13px">\${err.message}</p>\`;
|
|
} finally {
|
|
btn.disabled = false;
|
|
btn.textContent = 'Analyze';
|
|
}
|
|
}
|
|
|
|
function renderModules(data) {
|
|
const panel = document.getElementById('tab-modules');
|
|
if (!data.modules || !data.modules.length) {
|
|
panel.innerHTML = '<p style="color:var(--text-muted);font-family:var(--mono);font-size:13px">No modules detected.</p>';
|
|
return;
|
|
}
|
|
|
|
panel.innerHTML = data.modules.map((m, i) => \`
|
|
<div class="module-card">
|
|
<div class="brand">
|
|
\${m.brand || 'Unknown'}
|
|
<span class="badge">Module \${i + 1}</span>
|
|
</div>
|
|
<div class="kv-grid">
|
|
\${row('Part №', m.part_number)}
|
|
\${row('Type', m.type, 'good')}
|
|
\${row('Capacity', m.capacity, 'good')}
|
|
\${row('Speed', m.speed)}
|
|
\${row('Voltage', m.voltage)}
|
|
\${row('Specs', m.specs)}
|
|
\${row('Label', m.raw_label)}
|
|
</div>
|
|
</div>
|
|
\`).join('');
|
|
}
|
|
|
|
function row(k, v, cls = '') {
|
|
if (!v) return '';
|
|
return \`<span class="k">\${k}</span><span class="v \${cls}">\${v}</span>\`;
|
|
}
|
|
|
|
function renderWordChips(words) {
|
|
const area = document.getElementById('wordListArea');
|
|
if (!words.length) { area.innerHTML = ''; return; }
|
|
|
|
area.innerHTML = \`
|
|
<div class="word-list-wrap">
|
|
<div class="section-label">Detected tokens — click to highlight · click chip to copy</div>
|
|
<div class="word-chips">
|
|
\${words.map((w, i) => {
|
|
const conf = w.confidence ?? 1;
|
|
const cls = conf < 0.7 ? 'chip low-conf' : 'chip';
|
|
return \`<div class="\${cls}" id="chip-\${i}" onmouseenter="highlightWord(\${i})" onmouseleave="clearHighlight()" onclick="copyWord('\${w.text.replace(/'/g, "\\\\'")}', \${i})">\${w.text}</div>\`;
|
|
}).join('')}
|
|
</div>
|
|
</div>
|
|
\`;
|
|
}
|
|
|
|
function drawImage(url, words) {
|
|
const area = document.getElementById('imgArea');
|
|
area.innerHTML = \`<div class="canvas-wrap"><img id="theImg" crossorigin="anonymous"/><canvas id="theCanvas"></canvas></div>\`;
|
|
|
|
imgEl = document.getElementById('theImg');
|
|
canvasEl = document.getElementById('theCanvas');
|
|
|
|
imgEl.onload = () => {
|
|
const W = imgEl.naturalWidth;
|
|
const H = imgEl.naturalHeight;
|
|
canvasEl.width = W;
|
|
canvasEl.height = H;
|
|
drawBoxes(words, -1);
|
|
setupCanvasEvents(words);
|
|
};
|
|
|
|
imgEl.src = url;
|
|
}
|
|
|
|
function drawBoxes(words, activeIdx) {
|
|
if (!canvasEl || !imgEl) return;
|
|
const ctx = canvasEl.getContext('2d');
|
|
const W = canvasEl.width;
|
|
const H = canvasEl.height;
|
|
|
|
ctx.clearRect(0, 0, W, H);
|
|
|
|
words.forEach((w, i) => {
|
|
const [nx, ny, nw, nh] = w.bbox;
|
|
const x = nx * W, y = ny * H, bw = nw * W, bh = nh * H;
|
|
const conf = w.confidence ?? 1;
|
|
|
|
const isActive = i === activeIdx;
|
|
|
|
if (isActive) {
|
|
// Glow highlight
|
|
ctx.shadowColor = '#2a7fff';
|
|
ctx.shadowBlur = 14;
|
|
ctx.fillStyle = 'rgba(42,127,255,0.20)';
|
|
ctx.fillRect(x, y, bw, bh);
|
|
ctx.strokeStyle = '#2a7fff';
|
|
ctx.lineWidth = 2;
|
|
} else {
|
|
ctx.shadowBlur = 0;
|
|
if (conf < 0.7) {
|
|
ctx.strokeStyle = 'rgba(245,197,24,0.7)';
|
|
} else {
|
|
ctx.strokeStyle = 'rgba(0,229,160,0.65)';
|
|
}
|
|
ctx.lineWidth = 1;
|
|
}
|
|
|
|
ctx.strokeRect(x + .5, y + .5, bw, bh);
|
|
ctx.shadowBlur = 0;
|
|
|
|
// Label above box
|
|
if (isActive || (nw * W > 20 && nh * H > 10)) {
|
|
const label = w.text;
|
|
const fs = Math.max(9, Math.min(13, bh * 0.55));
|
|
ctx.font = \`600 \${fs}px JetBrains Mono, monospace\`;
|
|
const tw = ctx.measureText(label).width;
|
|
|
|
const lx = Math.min(x, W - tw - 4);
|
|
const ly = y > fs + 4 ? y - 3 : y + bh + fs + 2;
|
|
|
|
ctx.fillStyle = isActive ? '#2a7fff' : (conf < 0.7 ? '#f5c518' : '#00e5a0');
|
|
ctx.globalAlpha = isActive ? 1 : 0.75;
|
|
ctx.fillText(label, lx, ly);
|
|
ctx.globalAlpha = 1;
|
|
}
|
|
});
|
|
}
|
|
|
|
function setupCanvasEvents(words) {
|
|
const tooltip = document.getElementById('tooltip');
|
|
|
|
canvasEl.addEventListener('mousemove', (e) => {
|
|
const rect = canvasEl.getBoundingClientRect();
|
|
const scaleX = canvasEl.width / rect.width;
|
|
const scaleY = canvasEl.height / rect.height;
|
|
const cx = (e.clientX - rect.left) * scaleX;
|
|
const cy = (e.clientY - rect.top) * scaleY;
|
|
const W = canvasEl.width, H = canvasEl.height;
|
|
|
|
let found = -1;
|
|
words.forEach((w, i) => {
|
|
const [nx, ny, nw, nh] = w.bbox;
|
|
if (cx >= nx*W && cx <= (nx+nw)*W && cy >= ny*H && cy <= (ny+nh)*H) found = i;
|
|
});
|
|
|
|
if (found !== hoveredIdx) {
|
|
hoveredIdx = found;
|
|
drawBoxes(words, hoveredIdx);
|
|
}
|
|
|
|
if (found >= 0) {
|
|
const w = words[found];
|
|
const conf = ((w.confidence ?? 1) * 100).toFixed(0);
|
|
tooltip.textContent = \`\${w.text} · conf \${conf}%\`;
|
|
tooltip.style.left = (e.clientX + 14) + 'px';
|
|
tooltip.style.top = (e.clientY - 24) + 'px';
|
|
tooltip.classList.add('show');
|
|
} else {
|
|
tooltip.classList.remove('show');
|
|
}
|
|
});
|
|
|
|
canvasEl.addEventListener('mouseleave', () => {
|
|
hoveredIdx = -1;
|
|
drawBoxes(words, -1);
|
|
tooltip.classList.remove('show');
|
|
});
|
|
|
|
canvasEl.addEventListener('click', (e) => {
|
|
const rect = canvasEl.getBoundingClientRect();
|
|
const scaleX = canvasEl.width / rect.width;
|
|
const scaleY = canvasEl.height / rect.height;
|
|
const cx = (e.clientX - rect.left) * scaleX;
|
|
const cy = (e.clientY - rect.top) * scaleY;
|
|
const W = canvasEl.width, H = canvasEl.height;
|
|
|
|
words.forEach((w) => {
|
|
const [nx, ny, nw, nh] = w.bbox;
|
|
if (cx >= nx*W && cx <= (nx+nw)*W && cy >= ny*H && cy <= (ny+nh)*H) {
|
|
navigator.clipboard.writeText(w.text).then(() => showToast('Copied: ' + w.text));
|
|
}
|
|
});
|
|
});
|
|
}
|
|
|
|
function highlightWord(i) {
|
|
hoveredIdx = i;
|
|
drawBoxes(currentWords, i);
|
|
}
|
|
|
|
function clearHighlight() {
|
|
hoveredIdx = -1;
|
|
drawBoxes(currentWords, -1);
|
|
}
|
|
|
|
function copyWord(text, i) {
|
|
navigator.clipboard.writeText(text).then(() => showToast('Copied: ' + text));
|
|
}
|
|
</script>
|
|
|
|
</body>
|
|
</html>`);
|
|
});
|
|
|
|
app.listen(3000, () => console.log("Server running at http://localhost:3000")); |