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
153 lines
6.4 KiB
TypeScript
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>
|
|
);
|
|
}
|