diff --git a/src/components/streak/KapBuddy.tsx b/src/components/streak/KapBuddy.tsx index f58649f..71e8250 100644 --- a/src/components/streak/KapBuddy.tsx +++ b/src/components/streak/KapBuddy.tsx @@ -1,44 +1,117 @@ -import { useEffect, useState } from "react"; -import { X } from "lucide-react"; +import { useCallback, useEffect, useRef, useState, type PointerEvent as ReactPointerEvent } from "react"; +import { GripHorizontal, RotateCcw, X } from "lucide-react"; import { useKapLook } from "@/hooks/useKapLook"; import { useStreak } from "@/hooks/useStreak"; -import { BUDDY_EVENT, BUDDY_KEY, readBuddy, writeBuddy } from "@/lib/kap-buddy"; +import { BUDDY_EVENT, BUDDY_KEY, placeBuddy, readBuddy, readBuddyPos, writeBuddy, writeBuddyPos, type BuddyPos } from "@/lib/kap-buddy"; import { SYNC_EVENT } from "@/lib/account-sync"; import { FLAME_TIERS } from "@/lib/streak"; +import { cn } from "@/lib/utils"; import { Kap3D } from "./Kap3D"; -/** Kap in the corner while you practice: nods along, stokes his flame, jumps at typos. */ +const BOX = { width: 128, height: 162 }; +const viewport = () => ({ width: window.innerWidth, height: window.innerHeight }); +/** Hands the keyboard back to the practice screen, so typing carries on after a move. */ +const backToTyping = (from: Element) => requestAnimationFrame(() => from.closest(".workspace-shell")?.focus({ preventScroll: true })); + +/** + * Kap beside the editor while you practice: nods along, stokes his flame, + * jumps at typos. The grip on top moves him anywhere; dragging Kap himself + * still spins him. + */ export function KapBuddy() { const [on, setOn] = useState(readBuddy); + const [pos, setPos] = useState(readBuddyPos); + const [view, setView] = useState(viewport); + const [dragging, setDragging] = useState(false); + const drag = useRef<{ dx: number; dy: number } | null>(null); const look = useKapLook(); const status = useStreak(); useEffect(() => { const refresh = () => setOn(readBuddy()); const onStorage = (event: StorageEvent) => event.key === BUDDY_KEY && refresh(); + const onResize = () => setView(viewport()); window.addEventListener(BUDDY_EVENT, refresh); window.addEventListener(SYNC_EVENT, refresh); window.addEventListener("storage", onStorage); + window.addEventListener("resize", onResize); return () => { window.removeEventListener(BUDDY_EVENT, refresh); window.removeEventListener(SYNC_EVENT, refresh); window.removeEventListener("storage", onStorage); + window.removeEventListener("resize", onResize); }; }, []); + const place = placeBuddy(pos, BOX, view); + + const startDrag = (event: ReactPointerEvent) => { + event.preventDefault(); + event.currentTarget.setPointerCapture(event.pointerId); + drag.current = { dx: event.clientX - place.left, dy: event.clientY - place.top }; + setDragging(true); + }; + + const moveDrag = (event: ReactPointerEvent) => { + if (!drag.current) return; + const next = placeBuddy({ x: (event.clientX - drag.current.dx) / view.width, y: (event.clientY - drag.current.dy) / view.height }, BOX, view); + setPos({ x: next.left / view.width, y: next.top / view.height }); + }; + + const endDrag = useCallback((event: ReactPointerEvent) => { + backToTyping(event.currentTarget); + if (!drag.current) return; + drag.current = null; + setDragging(false); + setPos((current) => { + writeBuddyPos(current); + return current; + }); + }, []); + if (!on) return null; return ( -
- - + ); } diff --git a/src/lib/kap-buddy.ts b/src/lib/kap-buddy.ts index 654fefc..dfabf8d 100644 --- a/src/lib/kap-buddy.ts +++ b/src/lib/kap-buddy.ts @@ -23,3 +23,42 @@ export function writeBuddy(on: boolean) { } window.dispatchEvent(new CustomEvent(BUDDY_EVENT)); } + +/* ---- Where he stands: dragged anywhere, kept on screen ---- */ + +export const BUDDY_POS_KEY = "codey_kap_buddy_pos"; +const MARGIN = 8; + +/** Top-left corner as fractions of the viewport, so it survives a resize. */ +export interface BuddyPos { + x: number; + y: number; +} + +/** Pixel position of the box, clamped so the whole of Kap stays visible. */ +export function placeBuddy(pos: BuddyPos | null, box: { width: number; height: number }, view: { width: number; height: number }) { + const maxX = Math.max(MARGIN, view.width - box.width - MARGIN); + const maxY = Math.max(MARGIN, view.height - box.height - MARGIN); + // Default: bottom-left, beside the editor. + const left = pos ? pos.x * view.width : 12; + const top = pos ? pos.y * view.height : view.height - box.height - 12; + return { left: Math.min(maxX, Math.max(MARGIN, left)), top: Math.min(maxY, Math.max(MARGIN, top)) }; +} + +export function readBuddyPos(): BuddyPos | null { + try { + const value = JSON.parse(localStorage.getItem(BUDDY_POS_KEY) ?? "null") as BuddyPos | null; + return value && Number.isFinite(value.x) && Number.isFinite(value.y) ? { x: value.x, y: value.y } : null; + } catch { + return null; + } +} + +export function writeBuddyPos(pos: BuddyPos | null) { + try { + if (pos) localStorage.setItem(BUDDY_POS_KEY, JSON.stringify(pos)); + else localStorage.removeItem(BUDDY_POS_KEY); + } catch { + // This tab only. + } +} diff --git a/src/lib/kap-events.test.ts b/src/lib/kap-events.test.ts index 6343e7f..4d893ee 100644 --- a/src/lib/kap-events.test.ts +++ b/src/lib/kap-events.test.ts @@ -25,3 +25,18 @@ describe("kap events", () => { expect((listener.mock.calls[0][0] as CustomEvent).detail).toEqual({ type: "key", error: true }); }); }); + +describe("kap buddy position", async () => { + const { placeBuddy } = await import("./kap-buddy"); + const box = { width: 128, height: 162 }; + const view = { width: 1440, height: 900 }; + + it("starts bottom-left", () => { + expect(placeBuddy(null, box, view)).toEqual({ left: 12, top: 900 - 162 - 12 }); + }); + + it("keeps Kap on screen when dragged off or the window shrinks", () => { + expect(placeBuddy({ x: -1, y: 2 }, box, view)).toEqual({ left: 8, top: 900 - 162 - 8 }); + expect(placeBuddy({ x: 0.95, y: 0.5 }, box, { width: 1280, height: 700 })).toEqual({ left: 1280 - 128 - 8, top: 350 }); + }); +});