diff --git a/.changeset/smooth-lands-draw.md b/.changeset/smooth-lands-draw.md new file mode 100644 index 000000000..0f0f83f94 --- /dev/null +++ b/.changeset/smooth-lands-draw.md @@ -0,0 +1,5 @@ +--- +"@knocklabs/react": patch +--- + +[Guide] Add hotkey support for moving focus in guide toolbar diff --git a/packages/react/src/modules/guide/components/Toolbar/V2/FocusChin.tsx b/packages/react/src/modules/guide/components/Toolbar/V2/FocusChin.tsx index a043433a6..a94ac0e06 100644 --- a/packages/react/src/modules/guide/components/Toolbar/V2/FocusChin.tsx +++ b/packages/react/src/modules/guide/components/Toolbar/V2/FocusChin.tsx @@ -7,6 +7,7 @@ import { ChevronLeft, ChevronRight, X } from "lucide-react"; import * as React from "react"; import { GUIDE_ROW_DATA_SELECTOR } from "./GuideRow"; +import { Kbd } from "./Kbd"; import { sharedTooltipProps } from "./helpers"; import { InspectionResultOk } from "./useInspectGuideClientStore"; @@ -14,6 +15,9 @@ import { InspectionResultOk } from "./useInspectGuideClientStore"; // reducing how often consecutive next/prev clicks trigger a scroll. const SCROLL_OVERSHOOT = 60; +const FOCUS_PREV_HOTKEY = ","; +const FOCUS_NEXT_HOTKEY = "/"; + const maybeScrollGuideIntoView = (container: HTMLElement, guideKey: string) => { requestAnimationFrame(() => { const el = container.querySelector( @@ -44,6 +48,50 @@ const maybeScrollGuideIntoView = (container: HTMLElement, guideKey: string) => { }); }; +type FocusNav = { + guides: InspectionResultOk["guides"]; + currentKey: string | undefined; + focusGuide: (guideKey: string) => void; +}; + +const focusPrev = ({ guides, currentKey, focusGuide }: FocusNav) => { + const selectableGuides = guides.filter( + (g) => !!g.annotation.selectable.status, + ); + if (selectableGuides.length === 0) return; + + if (!currentKey) { + focusGuide(selectableGuides[selectableGuides.length - 1]!.key); + return; + } + + const currIndex = selectableGuides.findIndex((g) => g.key === currentKey); + const prevGuide = + currIndex <= 0 ? undefined : selectableGuides[currIndex - 1]; + if (!prevGuide) return; + focusGuide(prevGuide.key); +}; + +const focusNext = ({ guides, currentKey, focusGuide }: FocusNav) => { + const selectableGuides = guides.filter( + (g) => !!g.annotation.selectable.status, + ); + if (selectableGuides.length === 0) return; + + if (!currentKey) { + focusGuide(selectableGuides[0]!.key); + return; + } + + const currIndex = selectableGuides.findIndex((g) => g.key === currentKey); + const nextGuide = + currIndex < 0 || currIndex + 1 > selectableGuides.length - 1 + ? undefined + : selectableGuides[currIndex + 1]; + if (!nextGuide) return; + focusGuide(nextGuide.key); +}; + type Props = { guides: InspectionResultOk["guides"]; guideListRef: React.RefObject; @@ -56,14 +104,50 @@ export const FocusChin = ({ guides, guideListRef }: Props) => { })); const focusedKeys = Object.keys(debugSettings?.focusedGuideKeys || {}); - const isFocused = focusedKeys.length > 0; + const currentKey = focusedKeys[0]; + + const focusGuide = React.useCallback( + (guideKey: string) => { + client.setDebug({ + ...client.store.state.debug, + focusedGuideKeys: { [guideKey]: true }, + }); + if (guideListRef.current) { + maybeScrollGuideIntoView(guideListRef.current, guideKey); + } + }, + [client, guideListRef], + ); + + // Latest-ref so the window keydown listener below can attach once and always + // read the current guides / focused key without needing to re-subscribe. + const latestRef = React.useRef({ guides, currentKey, focusGuide }); + React.useEffect(() => { + latestRef.current = { guides, currentKey, focusGuide }; + }); + + React.useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (!e.ctrlKey || e.repeat) return; + if (e.key === FOCUS_PREV_HOTKEY) { + e.preventDefault(); + focusPrev(latestRef.current); + } else if (e.key === FOCUS_NEXT_HOTKEY) { + e.preventDefault(); + focusNext(latestRef.current); + } + }; + window.addEventListener("keydown", handleKeyDown); + return () => { + window.removeEventListener("keydown", handleKeyDown); + }; + }, []); + if (!isFocused) { return null; } - const currentKey = focusedKeys[0]!; - return ( { Focus mode: {currentKey} - + + Focus previous guide + + ctrl + , + + + } + {...sharedTooltipProps} + >