feat: right-click context menu and Delete key for chart drawings

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
dekun
2026-06-08 13:02:28 +08:00
parent d659cf7a4c
commit 94679f10d0
3 changed files with 375 additions and 20 deletions
+66
View File
@@ -2928,6 +2928,72 @@ body.login-page {
pointer-events: auto; pointer-events: auto;
} }
.market-draw-menu {
position: fixed;
z-index: 1200;
min-width: 168px;
padding: 4px 0;
border: 1px solid var(--border-soft);
border-radius: 8px;
background: var(--panel-bg, #1a1f2e);
box-shadow: 0 8px 28px rgba(0, 0, 0, 0.45);
}
.market-draw-menu.hidden {
display: none;
}
.market-draw-menu-head {
padding: 6px 12px 4px;
font-size: 0.72rem;
font-weight: 600;
color: var(--muted);
text-transform: none;
}
.market-draw-menu-item {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
padding: 7px 12px;
border: 0;
background: transparent;
color: var(--text);
font-size: 0.82rem;
font-family: var(--font);
text-align: left;
cursor: pointer;
}
.market-draw-menu-item:hover:not(:disabled) {
background: var(--inset-surface);
}
.market-draw-menu-item:disabled {
opacity: 0.45;
cursor: not-allowed;
}
.market-draw-menu-item.is-danger {
color: #f87171;
}
.market-draw-menu-sep {
border: 0;
border-top: 1px solid var(--border-soft);
margin: 4px 0;
}
.market-draw-menu-kbd {
margin-left: 12px;
padding: 1px 5px;
border-radius: 4px;
background: var(--inset-surface);
color: var(--muted);
font-size: 0.68rem;
}
.market-exchange-badge { .market-exchange-badge {
position: absolute; position: absolute;
left: 50%; left: 50%;
+305 -16
View File
@@ -64,6 +64,9 @@
let dragActive = false; let dragActive = false;
let dragStartPx = null; let dragStartPx = null;
let pathPreviewPt = null; let pathPreviewPt = null;
let menuEl = null;
let unsubClick = null;
let mainBound = false;
function uid() { function uid() {
return "d" + Date.now().toString(36) + Math.random().toString(36).slice(2, 7); return "d" + Date.now().toString(36) + Math.random().toString(36).slice(2, 7);
@@ -685,18 +688,25 @@
const h = hostEl.clientHeight; const h = hostEl.clientHeight;
ctx.clearRect(0, 0, w, h); ctx.clearRect(0, 0, w, h);
drawings.forEach(function (d) { drawings.forEach(function (d) {
if (d.hidden) ctx.globalAlpha = 0.14;
renderDrawing(ctx, d, w, h, d.id === selectedId); renderDrawing(ctx, d, w, h, d.id === selectedId);
if (d.hidden) ctx.globalAlpha = 1;
}); });
if (draft) { if (draft) {
const preview = draft.type === "path" ? pathPreviewPt : null; const preview = draft.type === "path" ? pathPreviewPt : null;
renderDrawing(ctx, draft, w, h, true, preview); renderDrawing(ctx, draft, w, h, true, preview);
} }
if (activeTool === "cursor" && selectedId) {
const sel = getDrawingById(selectedId);
if (sel) drawSelectionOverlay(ctx, sel);
}
} }
function commitDrawing(d) { function commitDrawing(d) {
if (!d || !d.type) return; if (!d || !d.type) return;
d.id = uid(); d.id = uid();
drawings.push(d); drawings.push(d);
selectedId = d.id;
draft = null; draft = null;
pathPreviewPt = null; pathPreviewPt = null;
saveDrawings(); saveDrawings();
@@ -719,17 +729,222 @@
return 0; return 0;
} }
function tryEraseAt(x, y) { function getDrawingById(id) {
for (let i = 0; i < drawings.length; i++) {
if (drawings[i].id === id) return drawings[i];
}
return null;
}
function pickDrawingAt(x, y) {
for (let i = drawings.length - 1; i >= 0; i--) { for (let i = drawings.length - 1; i >= 0; i--) {
if (hitTestDrawing(drawings[i], x, y)) { if (hitTestDrawing(drawings[i], x, y)) return drawings[i];
drawings.splice(i, 1); }
selectedId = null; return null;
saveDrawings(); }
scheduleRedraw();
return true; function selectDrawing(id) {
selectedId = id || null;
scheduleRedraw();
}
function deselectDrawing() {
if (!selectedId) return;
selectedId = null;
hideContextMenu();
scheduleRedraw();
}
function removeDrawing(id, opts) {
if (!id) return;
const force = !!(opts && opts.force);
const d = getDrawingById(id);
if (!d) return;
if (d.locked && !force) return;
drawings = drawings.filter(function (item) {
return item.id !== id;
});
if (selectedId === id) selectedId = null;
hideContextMenu();
saveDrawings();
scheduleRedraw();
}
function removeSelectedDrawing() {
if (!selectedId) return;
removeDrawing(selectedId);
}
function cloneDrawing(id) {
const src = getDrawingById(id);
if (!src || src.locked) return;
const copy = JSON.parse(JSON.stringify(src));
copy.id = uid();
copy.locked = false;
const candles = getCandles();
const timeStep = candles.length > 1 ? Math.abs(candles[1].time - candles[0].time) : 60;
copy.points = (copy.points || []).map(function (p, idx) {
return {
time: p.time + timeStep * (idx + 1),
price: p.price * 1.001,
};
});
if (copy.text) copy.text = String(copy.text);
drawings.push(copy);
selectedId = copy.id;
saveDrawings();
scheduleRedraw();
}
function toggleDrawingLock(id) {
const d = getDrawingById(id);
if (!d) return;
d.locked = !d.locked;
saveDrawings();
scheduleRedraw();
}
function toggleDrawingHide(id) {
const d = getDrawingById(id);
if (!d) return;
d.hidden = !d.hidden;
if (d.hidden && selectedId === id) selectedId = null;
hideContextMenu();
saveDrawings();
scheduleRedraw();
}
function isTypingTarget(el) {
if (!el) return false;
const tag = (el.tagName || "").toUpperCase();
return tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT" || !!el.isContentEditable;
}
function ensureContextMenu() {
if (menuEl) return menuEl;
menuEl = document.createElement("div");
menuEl.id = "market-draw-menu";
menuEl.className = "market-draw-menu hidden";
menuEl.setAttribute("role", "menu");
document.body.appendChild(menuEl);
menuEl.addEventListener("click", function (ev) {
const btn = ev.target.closest("[data-action]");
if (!btn || btn.disabled) return;
const action = btn.getAttribute("data-action");
const id = menuEl._targetId;
if (!id) return;
ev.preventDefault();
ev.stopPropagation();
if (action === "clone") cloneDrawing(id);
else if (action === "toggle-lock") toggleDrawingLock(id);
else if (action === "toggle-hide") toggleDrawingHide(id);
else if (action === "remove") removeDrawing(id, { force: true });
if (action !== "remove" && action !== "toggle-hide") {
const d = getDrawingById(id);
if (d) showContextMenu(menuEl._clientX, menuEl._clientY, d);
else hideContextMenu();
}
});
document.addEventListener("pointerdown", function (ev) {
if (!menuEl || menuEl.classList.contains("hidden")) return;
if (!menuEl.contains(ev.target)) hideContextMenu();
});
return menuEl;
}
function hideContextMenu() {
if (!menuEl) return;
menuEl.classList.add("hidden");
menuEl._targetId = null;
}
function showContextMenu(clientX, clientY, d) {
if (!d) return;
const menu = ensureContextMenu();
const label = TOOL_LABELS[d.type] || d.type;
const locked = !!d.locked;
menu.innerHTML =
'<div class="market-draw-menu-head">' +
label +
"</div>" +
'<button type="button" class="market-draw-menu-item" data-action="clone"' +
(locked ? " disabled" : "") +
">克隆</button>" +
'<button type="button" class="market-draw-menu-item" data-action="toggle-lock">' +
(locked ? "解锁" : "锁定") +
"</button>" +
'<button type="button" class="market-draw-menu-item" data-action="toggle-hide">' +
(d.hidden ? "显示" : "隐藏") +
"</button>" +
'<hr class="market-draw-menu-sep" />' +
'<button type="button" class="market-draw-menu-item is-danger" data-action="remove">移除 <span class="market-draw-menu-kbd">Del</span></button>';
menu.classList.remove("hidden");
menu._targetId = d.id;
menu._clientX = clientX;
menu._clientY = clientY;
menu.style.visibility = "hidden";
menu.style.left = "0px";
menu.style.top = "0px";
const mw = menu.offsetWidth;
const mh = menu.offsetHeight;
const pad = 8;
let left = clientX;
let top = clientY;
if (left + mw > window.innerWidth - pad) left = window.innerWidth - mw - pad;
if (top + mh > window.innerHeight - pad) top = window.innerHeight - mh - pad;
if (left < pad) left = pad;
if (top < pad) top = pad;
menu.style.left = left + "px";
menu.style.top = top + "px";
menu.style.visibility = "";
}
function drawSelectionOverlay(ctx, d) {
if (!d) return;
const pts = d.points || [];
const handleColor = d.locked ? "#94a3b8" : "#2962ff";
pts.forEach(function (p, idx) {
const x = timeToX(p.time);
const y = priceToY(p.price);
if (x == null || y == null) return;
const large = d.type === "path" && idx === pts.length - 1;
drawHandle(ctx, x, y, large, handleColor);
});
if ((d.type === "trend" || d.type === "channel") && pts.length >= 2) {
const x1 = timeToX(pts[0].time);
const y1 = priceToY(pts[0].price);
const x2 = timeToX(pts[1].time);
const y2 = priceToY(pts[1].price);
if (x1 != null && y1 != null && x2 != null && y2 != null) {
const angle = (Math.atan2(y2 - y1, x2 - x1) * 180) / Math.PI;
const text = angle.toFixed(2) + "°";
ctx.font = "11px sans-serif";
const tw = ctx.measureText(text).width;
const bx = x2 + 10;
const by = y2 - 8;
ctx.fillStyle = "rgba(15, 23, 42, 0.88)";
ctx.fillRect(bx - 4, by - 12, tw + 8, 18);
ctx.fillStyle = "#f8fafc";
ctx.fillText(text, bx, by);
} }
} }
return false; if (d.locked) {
const anchor = pts[0];
if (!anchor) return;
const ax = timeToX(anchor.time);
const ay = priceToY(anchor.price);
if (ax == null || ay == null) return;
ctx.font = "10px sans-serif";
ctx.fillStyle = "#94a3b8";
ctx.fillText("已锁定", ax + 8, ay - 8);
}
}
function tryEraseAt(x, y) {
const d = pickDrawingAt(x, y);
if (!d || d.locked) return false;
removeDrawing(d.id);
return true;
} }
function onPointerDown(ev) { function onPointerDown(ev) {
@@ -891,9 +1106,28 @@
} }
function onContextMenu(ev) { function onContextMenu(ev) {
if (activeTool !== "path" || !draft || draft.type !== "path") return; if (activeTool === "path" && draft && draft.type === "path") {
ev.preventDefault();
ev.stopPropagation();
finishPath();
}
}
function onMainContextMenu(ev) {
if (!hostEl) return;
if (activeTool === "path" && draft && draft.type === "path") return;
if (draft || dragActive) return;
const rect = hostEl.getBoundingClientRect();
const x = ev.clientX - rect.left;
const y = ev.clientY - rect.top;
const d = pickDrawingAt(x, y);
if (!d) {
hideContextMenu();
return;
}
ev.preventDefault(); ev.preventDefault();
finishPath(); selectDrawing(d.id);
showContextMenu(ev.clientX, ev.clientY, d);
} }
function distSeg(px, py, x1, y1, x2, y2) { function distSeg(px, py, x1, y1, x2, y2) {
@@ -1041,20 +1275,66 @@
canvasEl.addEventListener("pointercancel", onPointerUp); canvasEl.addEventListener("pointercancel", onPointerUp);
canvasEl.addEventListener("dblclick", onDblClick); canvasEl.addEventListener("dblclick", onDblClick);
canvasEl.addEventListener("contextmenu", onContextMenu); canvasEl.addEventListener("contextmenu", onContextMenu);
document.addEventListener("keydown", function (ev) { document.addEventListener("keydown", onDrawKeydown);
const page = document.getElementById("page-market"); }
if (!page || page.classList.contains("hidden")) return;
if (ev.key === "Escape" && (draft || dragActive)) { function onDrawKeydown(ev) {
const page = document.getElementById("page-market");
if (!page || page.classList.contains("hidden")) return;
if (isTypingTarget(ev.target)) return;
if (ev.key === "Escape") {
if (draft || dragActive) {
cancelDraft(); cancelDraft();
return; return;
} }
if (ev.key === "Enter" && activeTool === "path" && draft && draft.type === "path") { if (!menuEl || menuEl.classList.contains("hidden")) {
finishPath(); deselectDrawing();
} else {
hideContextMenu();
}
return;
}
if (ev.key === "Enter" && activeTool === "path" && draft && draft.type === "path") {
finishPath();
ev.preventDefault();
return;
}
if (
(ev.key === "Delete" || ev.key === "Backspace") &&
activeTool === "cursor" &&
selectedId
) {
const d = getDrawingById(selectedId);
if (d && !d.locked) {
removeSelectedDrawing();
ev.preventDefault(); ev.preventDefault();
} }
}
}
function bindChartClick() {
if (!chart || typeof chart.subscribeClick !== "function") return;
if (unsubClick) {
try {
unsubClick();
} catch (_) {}
unsubClick = null;
}
unsubClick = chart.subscribeClick(function (param) {
if (activeTool !== "cursor" || !param || !param.point) return;
hideContextMenu();
const d = pickDrawingAt(param.point.x, param.point.y);
if (d) selectDrawing(d.id);
else deselectDrawing();
}); });
} }
function bindMainEl() {
if (!mainEl || mainBound) return;
mainBound = true;
mainEl.addEventListener("contextmenu", onMainContextMenu);
}
function attach(opts) { function attach(opts) {
chart = opts.chart || null; chart = opts.chart || null;
series = opts.series || null; series = opts.series || null;
@@ -1066,6 +1346,8 @@
mountCanvasOverlay(); mountCanvasOverlay();
bindToolbar(); bindToolbar();
bindCanvas(); bindCanvas();
bindMainEl();
bindChartClick();
setActiveTool("cursor"); setActiveTool("cursor");
if (chart && chart.timeScale) { if (chart && chart.timeScale) {
if (unsubRange) { if (unsubRange) {
@@ -1098,6 +1380,13 @@
} catch (_) {} } catch (_) {}
unsubRange = null; unsubRange = null;
} }
if (unsubClick) {
try {
unsubClick();
} catch (_) {}
unsubClick = null;
}
hideContextMenu();
setChartInteraction(true); setChartInteraction(true);
} }
+4 -4
View File
@@ -15,7 +15,7 @@
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin /> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600&family=Orbitron:wght@500;600;700&display=swap" rel="stylesheet" media="print" onload="this.media='all'" /> <link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600&family=Orbitron:wght@500;600;700&display=swap" rel="stylesheet" media="print" onload="this.media='all'" />
<noscript><link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600&family=Orbitron:wght@500;600;700&display=swap" rel="stylesheet" /></noscript> <noscript><link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600&family=Orbitron:wght@500;600;700&display=swap" rel="stylesheet" /></noscript>
<link rel="stylesheet" href="/assets/app.css?v=20260608-market-draw-v4" /> <link rel="stylesheet" href="/assets/app.css?v=20260608-market-draw-v5" />
</head> </head>
<body> <body>
<div class="app-bg" aria-hidden="true"></div> <div class="app-bg" aria-hidden="true"></div>
@@ -170,7 +170,7 @@
</div> </div>
<div class="market-chart-body"> <div class="market-chart-body">
<aside id="market-draw-toolbar" class="market-draw-toolbar" aria-label="画线工具"> <aside id="market-draw-toolbar" class="market-draw-toolbar" aria-label="画线工具">
<button type="button" class="market-draw-btn is-active" data-tool="cursor" title="光标(拖动缩放图表"> <button type="button" class="market-draw-btn is-active" data-tool="cursor" title="光标(点击选中,右键管理,Del 删除">
<svg viewBox="0 0 24 24" aria-hidden="true"><path fill="currentColor" d="M4 4l7 16 2.5-5.5L20 12z"/></svg> <svg viewBox="0 0 24 24" aria-hidden="true"><path fill="currentColor" d="M4 4l7 16 2.5-5.5L20 12z"/></svg>
</button> </button>
<button type="button" class="market-draw-btn" data-tool="hline" title="水平线"> <button type="button" class="market-draw-btn" data-tool="hline" title="水平线">
@@ -392,8 +392,8 @@
<div id="toast"></div> <div id="toast"></div>
<script src="https://unpkg.com/lightweight-charts@4.2.0/dist/lightweight-charts.standalone.production.js"></script> <script src="https://unpkg.com/lightweight-charts@4.2.0/dist/lightweight-charts.standalone.production.js"></script>
<script src="/assets/chart_draw.js?v=20260608-market-draw-v4"></script> <script src="/assets/chart_draw.js?v=20260608-market-draw-v5"></script>
<script src="/assets/chart.js?v=20260608-market-draw-v4"></script> <script src="/assets/chart.js?v=20260608-market-draw-v5"></script>
<script src="/assets/archive.js?v=20260607-hub-archive-v6"></script> <script src="/assets/archive.js?v=20260607-hub-archive-v6"></script>
<script src="/assets/ai_review_render.js?v=2"></script> <script src="/assets/ai_review_render.js?v=2"></script>
<script src="/assets/app.js?v=20260607-hub-archive-v1"></script> <script src="/assets/app.js?v=20260607-hub-archive-v1"></script>