4cc39f5069
Full rename across files, identifiers, CSS classes, and user-facing strings (en/vi locales, ARCHITECTURE.md). Replaces the hand-drawn animated cat avatar with the pig-superhero mascot artwork, split into parts to keep per-eye cursor tracking and blink animation working; whole-mascot mood transforms (breathe/bob/shake/tilt) and new zzz/bang/sparkle overlays carry the rest of the mood expression since the traced art has no shared palette to key off of.
204 lines
8.7 KiB
TypeScript
204 lines
8.7 KiB
TypeScript
/**
|
|
* @file useSagiPosition.ts
|
|
* @description AssistiveTouch-style draggable docking for the Sagi avatar. The
|
|
* avatar follows the pointer 1:1 while dragging (via Pointer Capture, so it
|
|
* keeps tracking even if the cursor outruns it), and on release snaps to the
|
|
* nearest left/right edge, remembering its vertical offset (persisted as a
|
|
* viewport fraction so it survives resizes). A small movement threshold tells
|
|
* a drag apart from a tap so dragging never opens the panel.
|
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
|
*/
|
|
/* =============================================================================
|
|
* MODULE_GUIDE — extended in-file reference (comments only; safe to read, never executed)
|
|
* =============================================================================
|
|
* **Purpose:** Sagi is the optional on-screen mascot assistant — quips, intents, and lightweight event reactions layered above the dashboard chrome. React hook: isolates side effects and subscription wiring so presentational components stay declarative.
|
|
*
|
|
* ## Design constraints
|
|
* - Local-first: no telemetry leaves the machine unless the user configures webhooks.
|
|
* - Fail-safe hooks path on the server must never block Claude Code; UI mirrors that
|
|
* philosophy by degrading gracefully (empty states, stale badges, reconnect loops).
|
|
* - Destructive flows stay behind explicit confirmation modals and server-side gates.
|
|
* - Internationalization: user-visible strings belong in i18n JSON, not literals here.
|
|
*
|
|
* ## Remote data & SSH
|
|
* Remote Data Sources let operators aggregate multiple machines. SSH entries describe
|
|
* how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every
|
|
* scoped GET via `?sources=`. Health checks and import history surface in Settings.
|
|
*
|
|
* ## Internal dependencies
|
|
* - `./prefs`
|
|
*
|
|
* ## Public surface
|
|
* - `SAGI_SIZE` — exported API; see TSDoc on the symbol for behavior.
|
|
* - `SAGI_MARGIN` — exported API; see TSDoc on the symbol for behavior.
|
|
* - `SagiPlacement` — exported API; see TSDoc on the symbol for behavior.
|
|
* - `useSagiPosition` — exported API; see TSDoc on the symbol for behavior.
|
|
*
|
|
* ## Testing pointers
|
|
* - Prefer colocated `__tests__` with Vitest + Testing Library for UI.
|
|
* - Server contract changes require `npm run test:server` and OpenAPI sync.
|
|
* - MCP edits: `npm run mcp:typecheck` and `npm run mcp:build`.
|
|
*
|
|
* ## Related docs
|
|
* - `ARCHITECTURE.md` — hooks → API → SQLite → WebSocket → UI pipeline.
|
|
* - `docs/API.md` — REST reference.
|
|
* - `.claude/skills/file-headers/` — mandatory `@author` header policy.
|
|
* ============================================================================= */
|
|
/* -----------------------------------------------------------------------------
|
|
* EXPORT CATALOG — quick index of symbols defined below (documentation only).
|
|
* -----------------------------------------------------------------------------
|
|
* **SAGI_SIZE**
|
|
* Part of this module's public contract. Downstream imports should treat
|
|
* the signature and return type as stable unless release notes say otherwise.
|
|
* When behavior changes, update the `@file` overview and relevant tests.
|
|
*
|
|
* **SAGI_MARGIN**
|
|
* Part of this module's public contract. Downstream imports should treat
|
|
* the signature and return type as stable unless release notes say otherwise.
|
|
* When behavior changes, update the `@file` overview and relevant tests.
|
|
*
|
|
* **SagiPlacement**
|
|
* Part of this module's public contract. Downstream imports should treat
|
|
* the signature and return type as stable unless release notes say otherwise.
|
|
* When behavior changes, update the `@file` overview and relevant tests.
|
|
*
|
|
* **useSagiPosition**
|
|
* Part of this module's public contract. Downstream imports should treat
|
|
* the signature and return type as stable unless release notes say otherwise.
|
|
* When behavior changes, update the `@file` overview and relevant tests.
|
|
*
|
|
* ----------------------------------------------------------------------------- */
|
|
|
|
import { useCallback, useEffect, useRef, useState } from "react";
|
|
import { sagiPrefs, type SagiPos } from "./prefs";
|
|
import type { PointerEvent as ReactPointerEvent } from "react";
|
|
|
|
// Avatar footprint + edge gap, in px. SIZE matches SagiAvatar's default size.
|
|
export const SAGI_SIZE = 60;
|
|
export const SAGI_MARGIN = 16;
|
|
const DRAG_THRESHOLD = 5;
|
|
|
|
const vw = () => (typeof window !== "undefined" ? window.innerWidth : 1024);
|
|
const vh = () => (typeof window !== "undefined" ? window.innerHeight : 768);
|
|
|
|
function defaultPos(): SagiPos {
|
|
return { side: "right", y: 0.5 }; // right edge, vertically centered
|
|
}
|
|
|
|
/** Resting top-left screen coords for a docked position. */
|
|
function restingScreen(pos: SagiPos) {
|
|
const avail = Math.max(0, vh() - SAGI_SIZE - 2 * SAGI_MARGIN);
|
|
const left = pos.side === "left" ? SAGI_MARGIN : vw() - SAGI_SIZE - SAGI_MARGIN;
|
|
const top = SAGI_MARGIN + pos.y * avail;
|
|
return { left, top };
|
|
}
|
|
|
|
export interface SagiPlacement {
|
|
/** Avatar top-left, in screen px. */
|
|
left: number;
|
|
top: number;
|
|
size: number;
|
|
side: "left" | "right";
|
|
/** True when the avatar sits in the lower half - flyouts open upward. */
|
|
openUp: boolean;
|
|
dragging: boolean;
|
|
onPointerDown: (e: ReactPointerEvent) => void;
|
|
onPointerMove: (e: ReactPointerEvent) => void;
|
|
onPointerUp: (e: ReactPointerEvent) => void;
|
|
/** Returns true (once) if a drag just ended, so the click handler can skip. */
|
|
consumeDrag: () => boolean;
|
|
}
|
|
|
|
export function useSagiPosition(): SagiPlacement {
|
|
const [pos, setPos] = useState<SagiPos>(() => sagiPrefs.getPos() ?? defaultPos());
|
|
const [drag, setDrag] = useState<{ left: number; top: number } | null>(null);
|
|
const [, force] = useState(0); // re-derive resting coords on resize
|
|
|
|
const draggedRef = useRef(false);
|
|
const startRef = useRef<{ px: number; py: number; left: number; top: number } | null>(null);
|
|
const movedRef = useRef(false);
|
|
// Latest dragged coords, mirrored in a ref so pointerup can read them
|
|
// synchronously - the setDrag state may not have committed yet under React's
|
|
// event batching, so we never rely on its functional-updater `cur`.
|
|
const liveRef = useRef<{ left: number; top: number } | null>(null);
|
|
|
|
useEffect(() => {
|
|
const onResize = () => force((n) => n + 1);
|
|
window.addEventListener("resize", onResize);
|
|
return () => window.removeEventListener("resize", onResize);
|
|
}, []);
|
|
|
|
const resting = restingScreen(pos);
|
|
const screen = drag ?? resting;
|
|
|
|
const onPointerDown = useCallback(
|
|
(e: ReactPointerEvent) => {
|
|
if (e.button !== undefined && e.button !== 0) return;
|
|
// Capture so the avatar keeps receiving move/up events even when the
|
|
// pointer leaves it - essential for a fast, 1:1 drag.
|
|
try {
|
|
(e.currentTarget as Element).setPointerCapture?.(e.pointerId);
|
|
} catch {
|
|
/* capture unsupported - window-free fallback still works via props */
|
|
}
|
|
startRef.current = { px: e.clientX, py: e.clientY, left: screen.left, top: screen.top };
|
|
movedRef.current = false;
|
|
},
|
|
[screen.left, screen.top]
|
|
);
|
|
|
|
const onPointerMove = useCallback((e: ReactPointerEvent) => {
|
|
const start = startRef.current;
|
|
if (!start) return;
|
|
const dx = e.clientX - start.px;
|
|
const dy = e.clientY - start.py;
|
|
if (!movedRef.current && Math.hypot(dx, dy) < DRAG_THRESHOLD) return;
|
|
movedRef.current = true;
|
|
const left = Math.min(vw() - SAGI_SIZE - SAGI_MARGIN, Math.max(SAGI_MARGIN, start.left + dx));
|
|
const top = Math.min(vh() - SAGI_SIZE - SAGI_MARGIN, Math.max(SAGI_MARGIN, start.top + dy));
|
|
liveRef.current = { left, top };
|
|
setDrag({ left, top });
|
|
}, []);
|
|
|
|
const onPointerUp = useCallback((e: ReactPointerEvent) => {
|
|
try {
|
|
(e.currentTarget as Element).releasePointerCapture?.(e.pointerId);
|
|
} catch {
|
|
/* ignore */
|
|
}
|
|
const live = liveRef.current;
|
|
if (live) {
|
|
draggedRef.current = true;
|
|
const side: "left" | "right" = live.left + SAGI_SIZE / 2 < vw() / 2 ? "left" : "right";
|
|
const avail = Math.max(1, vh() - SAGI_SIZE - 2 * SAGI_MARGIN);
|
|
const y = Math.min(1, Math.max(0, (live.top - SAGI_MARGIN) / avail));
|
|
const next: SagiPos = { side, y };
|
|
sagiPrefs.setPos(next);
|
|
setPos(next);
|
|
setDrag(null); // leave drag mode; resting coords (with transition) take over
|
|
}
|
|
liveRef.current = null;
|
|
startRef.current = null;
|
|
movedRef.current = false;
|
|
}, []);
|
|
|
|
const consumeDrag = useCallback(() => {
|
|
const was = draggedRef.current;
|
|
draggedRef.current = false;
|
|
return was;
|
|
}, []);
|
|
|
|
return {
|
|
left: screen.left,
|
|
top: screen.top,
|
|
size: SAGI_SIZE,
|
|
side: pos.side,
|
|
openUp: screen.top + SAGI_SIZE / 2 > vh() / 2,
|
|
dragging: drag !== null,
|
|
onPointerDown,
|
|
onPointerMove,
|
|
onPointerUp,
|
|
consumeDrag,
|
|
};
|
|
}
|