Files
stock_chart_site/web/components/ShortcutsHelp.tsx
claude-owner 7312d0aadc feat(chart): Alt+Click removes nearest horizontal line
IPriceLine has no native click event, so use a coordinate-matching pattern:
  - On Alt+Click, compute candle.priceToCoordinate for each hline
  - Find the one whose y is closest to the click y
  - If within ±6px, remove it via removeLine

Complements the chips ✕ button — clicking the line itself on the chart is
more natural than aiming at a small ✕. Shift+Click for add still wins when
both modifiers are held (more frequent, checked first).

- StockChart toolbar title and ShortcutsHelp updated to surface the gesture
2026-05-29 00:42:31 +09:00

153 lines
6.4 KiB
TypeScript

"use client";
// 글로벌 키보드 단축키 헬프 오버레이.
// `?` (Shift+/) 로 열고 ESC 또는 바깥 클릭 / 닫기 버튼으로 닫음.
// 다른 슬라이스에서 키보드 단축키를 추가해도 발견 가능성을 유지하기 위한 공용 패널.
//
// 등록되는 단축키는 여기 한 곳에서 정의 — 실제 핸들러는 각 컴포넌트가 가지고 있고,
// 이 컴포넌트는 단순 표시 + `?` 토글만 담당. 단일 출처(Single Source of Truth)는 아니지만,
// 이 사이트가 사용하는 단축키 수가 적어 수동 동기화로 충분.
import { useEffect, useRef, useState } from "react";
type Shortcut = {
keys: string[]; // ['Ctrl', 'K'] 같은 표시용 토큰. 시각적으로 + 로 join.
label: string;
scope?: string; // '전역' / '종목 페이지' 등 — group 헤더 라벨.
};
const SHORTCUTS: Shortcut[] = [
{ scope: "전역", keys: ["/"], label: "종목 검색창 포커스" },
{ scope: "전역", keys: ["Ctrl", "K"], label: "종목 검색창 포커스 (Mac: ⌘K)" },
{ scope: "전역", keys: ["Esc"], label: "팝오버 / 오버레이 닫기" },
{ scope: "전역", keys: ["?"], label: "이 도움말 열기 / 닫기" },
{ scope: "종목 페이지", keys: ["F"], label: "차트 전체화면 토글" },
{ scope: "종목 페이지", keys: ["H"], label: "현재 가격에 수평선 추가" },
{ scope: "종목 페이지", keys: ["Shift", "클릭"], label: "차트 위 클릭 위치에 수평선 추가" },
{ scope: "종목 페이지", keys: ["Alt", "클릭"], label: "차트의 가장 가까운 수평선 제거" },
{ scope: "종목 페이지", keys: ["←", "→"], label: "기간 탭 이전 / 다음" },
{ scope: "종목 페이지", keys: ["S"], label: "관심종목 추가 / 제거" },
{ scope: "검색 팝오버", keys: ["↑", "↓"], label: "결과 이동" },
{ scope: "검색 팝오버", keys: ["Enter"], label: "선택한 종목으로 이동" },
];
function isEditableTarget(t: EventTarget | null): boolean {
if (!(t instanceof HTMLElement)) return false;
const tag = t.tagName;
if (tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT") return true;
if (t.isContentEditable) return true;
return false;
}
export function ShortcutsHelp() {
const [open, setOpen] = useState(false);
const closeBtnRef = useRef<HTMLButtonElement | null>(null);
// `?` 키 = 토글. 입력 중에는 무시 (사용자가 실제 ? 입력하려는 의도).
// ESC 는 모달 안에서 onKeyDown 으로도 처리하지만, 글로벌 핸들러로도 닫음 — 포커스가
// 모달 밖에 있어도 닫히도록.
useEffect(() => {
if (typeof window === "undefined") return;
const onKey = (e: KeyboardEvent) => {
if (e.key === "?" && !isEditableTarget(e.target)) {
e.preventDefault();
setOpen((v) => !v);
return;
}
if (e.key === "Escape" && open) {
setOpen(false);
}
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [open]);
// 열릴 때 닫기 버튼에 포커스 — 키보드 사용자가 바로 Tab 흐름을 잡을 수 있게.
useEffect(() => {
if (open) closeBtnRef.current?.focus();
}, [open]);
// 화면 우하단 floating 버튼 — 모바일/마우스 사용자가 키보드 없이도 발견 가능.
// 모달이 열려 있으면 같은 버튼이 닫기 역할.
return (
<>
<button
type="button"
onClick={() => setOpen((v) => !v)}
className="fixed bottom-4 right-4 z-30 flex h-8 w-8 items-center justify-center rounded-full border border-zinc-700 bg-zinc-900/80 text-xs text-zinc-300 shadow-lg backdrop-blur transition hover:border-zinc-500 hover:text-zinc-100"
title="키보드 단축키 (?)"
aria-label="키보드 단축키 도움말"
aria-expanded={open}
>
?
</button>
{open && (
<div
role="dialog"
aria-modal="true"
aria-label="키보드 단축키"
className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4 backdrop-blur-sm"
onClick={(e) => {
if (e.target === e.currentTarget) setOpen(false);
}}
>
<div className="w-full max-w-md rounded-lg border border-zinc-700 bg-zinc-950 p-5 shadow-2xl">
<div className="mb-3 flex items-center justify-between">
<h2 className="text-sm font-semibold text-zinc-100"> </h2>
<button
ref={closeBtnRef}
type="button"
onClick={() => setOpen(false)}
className="rounded-md border border-zinc-700 px-2 py-0.5 text-[11px] text-zinc-400 hover:border-zinc-500 hover:text-zinc-200"
aria-label="닫기"
>
(Esc)
</button>
</div>
<ShortcutTable />
<p className="mt-3 text-[10px] text-zinc-500">
.
</p>
</div>
</div>
)}
</>
);
}
// scope 별로 묶어 한 줄에 [키] — 설명 형태로 표시. 같은 scope 가 연속되면 헤더 생략해서
// 시각적 노이즈 줄임.
function ShortcutTable() {
let prevScope: string | undefined;
return (
<ul className="space-y-1.5">
{SHORTCUTS.map((s, i) => {
const showScope = s.scope && s.scope !== prevScope;
prevScope = s.scope;
return (
<li key={i}>
{showScope && (
<div className="mb-0.5 mt-2 first:mt-0 text-[10px] uppercase tracking-wide text-zinc-500">
{s.scope}
</div>
)}
<div className="flex items-center justify-between gap-3 text-[12px]">
<span className="text-zinc-300">{s.label}</span>
<span className="flex shrink-0 items-center gap-1">
{s.keys.map((k, j) => (
<span key={j} className="flex items-center gap-1">
{j > 0 && <span className="text-[10px] text-zinc-600">+</span>}
<kbd className="rounded border border-zinc-700 bg-zinc-900 px-1.5 py-0.5 font-mono text-[10px] text-zinc-200">
{k}
</kbd>
</span>
))}
</span>
</div>
</li>
);
})}
</ul>
);
}