"use client"; import { useEffect, useRef, useState, type PointerEvent as ReactPointerEvent, type RefObject, } from "react"; import { useAppStore } from "@/components/state-provider"; import { useFileEditors } from "@/hooks/use-file-editors"; import { revealFileAtLine, type OpenFileFn } from "@/lib/diff/walkthrough-reveal"; import { clearWalkthroughEditorAnchor, computeWalkthroughConnectorPath, isWalkthroughAnchorTargetVisible, useWalkthroughEditorAnchor, type WalkthroughViewportRect, } from "@/lib/walkthrough-editor-anchor"; import { WalkthroughStepInner, useHasActiveWalkthroughStep } from "./walkthrough-step-card"; import { cn } from "@kandev/ui/lib/utils"; const DESKTOP_QUERY = "(min-width: 640px)"; const CARD_WIDTH = 400; const VIEWPORT_MARGIN = 16; function viewportRectFromDomRect(rect: DOMRect): WalkthroughViewportRect { return { left: rect.left, top: rect.top, right: rect.right, bottom: rect.bottom, width: rect.width, height: rect.height, }; } function clampPosition(pos: { x: number; y: number }, width: number, height: number) { return { x: Math.min(window.innerWidth - width - VIEWPORT_MARGIN, Math.max(VIEWPORT_MARGIN, pos.x)), y: Math.min(window.innerHeight - height - VIEWPORT_MARGIN, Math.max(VIEWPORT_MARGIN, pos.y)), }; } function defaultDesktopPosition() { return { x: Math.max(VIEWPORT_MARGIN, window.innerWidth - CARD_WIDTH - 24), y: 80, }; } function useIsDesktop() { const [isDesktop, setIsDesktop] = useState(false); useEffect(() => { const media = window.matchMedia(DESKTOP_QUERY); const update = () => setIsDesktop(media.matches); update(); media.addEventListener("change", update); return () => media.removeEventListener("change", update); }, []); return isDesktop; } function useDraggableWindow(cardRef: RefObject, isDesktop: boolean) { const [position, setPosition] = useState<{ x: number; y: number } | null>(null); useEffect(() => { if (!isDesktop) return; setPosition((current) => current ?? defaultDesktopPosition()); }, [isDesktop]); const onPointerDown = (event: ReactPointerEvent) => { if (!isDesktop) return; const target = event.target as HTMLElement; if (!target.closest("[data-walkthrough-drag-handle]")) return; if (target.closest("button,input,textarea,a")) return; const card = cardRef.current; if (!card) return; event.preventDefault(); card.setPointerCapture(event.pointerId); const rect = card.getBoundingClientRect(); const offset = { x: event.clientX - rect.left, y: event.clientY - rect.top }; const onMove = (moveEvent: PointerEvent) => { setPosition( clampPosition( { x: moveEvent.clientX - offset.x, y: moveEvent.clientY - offset.y }, rect.width, rect.height, ), ); }; const onUp = () => { card.releasePointerCapture(event.pointerId); window.removeEventListener("pointermove", onMove); window.removeEventListener("pointerup", onUp); }; window.addEventListener("pointermove", onMove); window.addEventListener("pointerup", onUp); }; return { position, onPointerDown }; } function useCardRect( cardRef: RefObject, position: { x: number; y: number } | null, anchorKey: string | undefined, ) { const [rect, setRect] = useState(null); useEffect(() => { const card = cardRef.current; if (!card) return; const update = () => setRect(viewportRectFromDomRect(card.getBoundingClientRect())); update(); const observer = new ResizeObserver(update); observer.observe(card); window.addEventListener("resize", update); return () => { observer.disconnect(); window.removeEventListener("resize", update); }; }, [cardRef, position, anchorKey]); return rect; } function useVisibleWalkthroughAnchor() { const anchor = useWalkthroughEditorAnchor(); useEffect(() => { if (!anchor?.container) return; const check = () => { if (!isWalkthroughAnchorTargetVisible(anchor.container ?? null, anchor.rect)) { clearWalkthroughEditorAnchor(anchor.key); } }; check(); const interval = window.setInterval(check, 150); window.addEventListener("resize", check); window.addEventListener("scroll", check, true); return () => { window.clearInterval(interval); window.removeEventListener("resize", check); window.removeEventListener("scroll", check, true); }; }, [anchor]); return anchor; } function WalkthroughConnector({ cardRect }: { cardRect: WalkthroughViewportRect | null }) { const anchor = useVisibleWalkthroughAnchor(); if (!anchor || !cardRect) return null; if (anchor.container && !isWalkthroughAnchorTargetVisible(anchor.container, anchor.rect)) { return null; } const path = computeWalkthroughConnectorPath(cardRect, anchor.rect); if (!path) return null; return ( ); } /** * The primary walkthrough surface: a floating card that, for each step, opens the * step's file in its *current state* and reveals/centers the anchored line in the * editor. Works uniformly for changed and unchanged files (onboarding / whole-repo * tours), so it does not depend on the file being part of a diff. */ export function WalkthroughFloatingWindow({ onClose, onSelectFile, }: { onClose: () => void; onSelectFile?: OpenFileFn; }) { const hasStep = useHasActiveWalkthroughStep(); const { openFile: defaultOpenFile } = useFileEditors(); const openFile = onSelectFile ?? defaultOpenFile; const cardRef = useRef(null); const isDesktop = useIsDesktop(); const { position, onPointerDown } = useDraggableWindow(cardRef, isDesktop); const anchor = useWalkthroughEditorAnchor(); const cardRect = useCardRect(cardRef, position, anchor?.key); // Select primitives (not a fresh object) so the selector stays referentially // stable — returning a new object here would loop the store subscription. const stepFile = useAppStore((s) => { const taskId = s.tasks.activeTaskId; if (!taskId) return null; const wt = s.walkthroughs.byTaskId[taskId]; const idx = s.walkthroughs.activeStepByTaskId[taskId] ?? 0; return wt?.steps[idx]?.file ?? null; }); const stepLine = useAppStore((s) => { const taskId = s.tasks.activeTaskId; if (!taskId) return 0; const wt = s.walkthroughs.byTaskId[taskId]; const idx = s.walkthroughs.activeStepByTaskId[taskId] ?? 0; return wt?.steps[idx]?.line ?? 0; }); const stepRepo = useAppStore((s) => { const taskId = s.tasks.activeTaskId; if (!taskId) return undefined; const wt = s.walkthroughs.byTaskId[taskId]; const idx = s.walkthroughs.activeStepByTaskId[taskId] ?? 0; return wt?.steps[idx]?.repo; }); // Open the step's file (current state) and reveal its line whenever the step changes. useEffect(() => { if (stepFile) revealFileAtLine(openFile, stepFile, stepLine, stepRepo); }, [stepFile, stepLine, stepRepo, openFile]); if (!hasStep) return null; return ( <> {isDesktop ? : null}
); }