diff --git a/web/app/layout.tsx b/web/app/layout.tsx index 25b0df8..8744e46 100644 --- a/web/app/layout.tsx +++ b/web/app/layout.tsx @@ -2,6 +2,7 @@ import "./globals.css"; import type { Metadata, Viewport } from "next"; import { AlertsToaster } from "../components/AlertsToaster"; import { PwaRegister } from "../components/PwaRegister"; +import { ShortcutsHelp } from "../components/ShortcutsHelp"; import { TopNav } from "../components/TopNav"; export const metadata: Metadata = { @@ -36,6 +37,7 @@ export default function RootLayout({ children }: { children: React.ReactNode }) {children} + diff --git a/web/components/ShortcutsHelp.tsx b/web/components/ShortcutsHelp.tsx new file mode 100644 index 0000000..7f19381 --- /dev/null +++ b/web/components/ShortcutsHelp.tsx @@ -0,0 +1,147 @@ +"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: ["↑", "↓"], 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(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 ( + <> + + {open && ( +
{ + if (e.target === e.currentTarget) setOpen(false); + }} + > +
+
+

키보드 단축키

+ +
+ +

+ 입력 필드에 포커스가 있으면 단축키는 동작하지 않습니다. +

+
+
+ )} + + ); +} + +// scope 별로 묶어 한 줄에 [키] — 설명 형태로 표시. 같은 scope 가 연속되면 헤더 생략해서 +// 시각적 노이즈 줄임. +function ShortcutTable() { + let prevScope: string | undefined; + return ( +
    + {SHORTCUTS.map((s, i) => { + const showScope = s.scope && s.scope !== prevScope; + prevScope = s.scope; + return ( +
  • + {showScope && ( +
    + {s.scope} +
    + )} +
    + {s.label} + + {s.keys.map((k, j) => ( + + {j > 0 && +} + + {k} + + + ))} + +
    +
  • + ); + })} +
+ ); +} diff --git a/web/components/StockChart.tsx b/web/components/StockChart.tsx index 74acd9b..c141465 100644 --- a/web/components/StockChart.tsx +++ b/web/components/StockChart.tsx @@ -793,7 +793,15 @@ export function StockChart({ chart, prediction, targets }: Props) { ) { return; } - if (e.key === "f" || e.key === "F") { + // Ctrl+F / Cmd+F (브라우저 찾기) / Alt+F (브라우저 메뉴) / Shift+F 는 가로채지 않음. + // 단순 F 만 차트 전체화면 토글로 사용. + if ( + !e.ctrlKey && + !e.metaKey && + !e.altKey && + !e.shiftKey && + e.key.toLowerCase() === "f" + ) { e.preventDefault(); void toggleFullscreen(); } else if (e.key === "Escape" && fullscreen && !document.fullscreenElement) {