diff --git a/manual_trading_hub/static/app.css b/manual_trading_hub/static/app.css index c22a7da..7ee6c21 100644 --- a/manual_trading_hub/static/app.css +++ b/manual_trading_hub/static/app.css @@ -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%; diff --git a/manual_trading_hub/static/chart_draw.js b/manual_trading_hub/static/chart_draw.js index bfdc387..d04276b 100644 --- a/manual_trading_hub/static/chart_draw.js +++ b/manual_trading_hub/static/chart_draw.js @@ -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 = + '
' + + label + + "
" + + '" + + '" + + '" + + '
' + + ''; + 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); } diff --git a/manual_trading_hub/static/index.html b/manual_trading_hub/static/index.html index 50e14bb..9a2f33c 100644 --- a/manual_trading_hub/static/index.html +++ b/manual_trading_hub/static/index.html @@ -15,7 +15,7 @@ - + @@ -170,7 +170,7 @@