feat: right-click context menu and Delete key for chart drawings
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -2928,6 +2928,72 @@ body.login-page {
|
||||
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 {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
|
||||
@@ -64,6 +64,9 @@
|
||||
let dragActive = false;
|
||||
let dragStartPx = null;
|
||||
let pathPreviewPt = null;
|
||||
let menuEl = null;
|
||||
let unsubClick = null;
|
||||
let mainBound = false;
|
||||
|
||||
function uid() {
|
||||
return "d" + Date.now().toString(36) + Math.random().toString(36).slice(2, 7);
|
||||
@@ -685,18 +688,25 @@
|
||||
const h = hostEl.clientHeight;
|
||||
ctx.clearRect(0, 0, w, h);
|
||||
drawings.forEach(function (d) {
|
||||
if (d.hidden) ctx.globalAlpha = 0.14;
|
||||
renderDrawing(ctx, d, w, h, d.id === selectedId);
|
||||
if (d.hidden) ctx.globalAlpha = 1;
|
||||
});
|
||||
if (draft) {
|
||||
const preview = draft.type === "path" ? pathPreviewPt : null;
|
||||
renderDrawing(ctx, draft, w, h, true, preview);
|
||||
}
|
||||
if (activeTool === "cursor" && selectedId) {
|
||||
const sel = getDrawingById(selectedId);
|
||||
if (sel) drawSelectionOverlay(ctx, sel);
|
||||
}
|
||||
}
|
||||
|
||||
function commitDrawing(d) {
|
||||
if (!d || !d.type) return;
|
||||
d.id = uid();
|
||||
drawings.push(d);
|
||||
selectedId = d.id;
|
||||
draft = null;
|
||||
pathPreviewPt = null;
|
||||
saveDrawings();
|
||||
@@ -719,17 +729,222 @@
|
||||
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--) {
|
||||
if (hitTestDrawing(drawings[i], x, y)) {
|
||||
drawings.splice(i, 1);
|
||||
selectedId = null;
|
||||
saveDrawings();
|
||||
scheduleRedraw();
|
||||
return true;
|
||||
if (hitTestDrawing(drawings[i], x, y)) return drawings[i];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
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) {
|
||||
@@ -891,9 +1106,28 @@
|
||||
}
|
||||
|
||||
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();
|
||||
finishPath();
|
||||
selectDrawing(d.id);
|
||||
showContextMenu(ev.clientX, ev.clientY, d);
|
||||
}
|
||||
|
||||
function distSeg(px, py, x1, y1, x2, y2) {
|
||||
@@ -1041,20 +1275,66 @@
|
||||
canvasEl.addEventListener("pointercancel", onPointerUp);
|
||||
canvasEl.addEventListener("dblclick", onDblClick);
|
||||
canvasEl.addEventListener("contextmenu", onContextMenu);
|
||||
document.addEventListener("keydown", function (ev) {
|
||||
const page = document.getElementById("page-market");
|
||||
if (!page || page.classList.contains("hidden")) return;
|
||||
if (ev.key === "Escape" && (draft || dragActive)) {
|
||||
document.addEventListener("keydown", onDrawKeydown);
|
||||
}
|
||||
|
||||
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();
|
||||
return;
|
||||
}
|
||||
if (ev.key === "Enter" && activeTool === "path" && draft && draft.type === "path") {
|
||||
finishPath();
|
||||
if (!menuEl || menuEl.classList.contains("hidden")) {
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
chart = opts.chart || null;
|
||||
series = opts.series || null;
|
||||
@@ -1066,6 +1346,8 @@
|
||||
mountCanvasOverlay();
|
||||
bindToolbar();
|
||||
bindCanvas();
|
||||
bindMainEl();
|
||||
bindChartClick();
|
||||
setActiveTool("cursor");
|
||||
if (chart && chart.timeScale) {
|
||||
if (unsubRange) {
|
||||
@@ -1098,6 +1380,13 @@
|
||||
} catch (_) {}
|
||||
unsubRange = null;
|
||||
}
|
||||
if (unsubClick) {
|
||||
try {
|
||||
unsubClick();
|
||||
} catch (_) {}
|
||||
unsubClick = null;
|
||||
}
|
||||
hideContextMenu();
|
||||
setChartInteraction(true);
|
||||
}
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
<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'" />
|
||||
<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>
|
||||
<body>
|
||||
<div class="app-bg" aria-hidden="true"></div>
|
||||
@@ -170,7 +170,7 @@
|
||||
</div>
|
||||
<div class="market-chart-body">
|
||||
<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>
|
||||
</button>
|
||||
<button type="button" class="market-draw-btn" data-tool="hline" title="水平线">
|
||||
@@ -392,8 +392,8 @@
|
||||
|
||||
<div id="toast"></div>
|
||||
<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.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-v5"></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/app.js?v=20260607-hub-archive-v1"></script>
|
||||
|
||||
Reference in New Issue
Block a user