133 lines
4.8 KiB
TypeScript
133 lines
4.8 KiB
TypeScript
import { useEffect, useRef, useState, type PointerEvent as ReactPointerEvent } from "react";
|
|
import { useAtlasStore, type BodyRegion } from "../store/useAtlasStore.js";
|
|
|
|
const REGIONS: { region: BodyRegion; label: string }[] = [
|
|
{ region: "cabeca", label: "Cabeça" },
|
|
{ region: "pescoco", label: "Pescoço" },
|
|
{ region: "torax", label: "Tórax" },
|
|
{ region: "abdome", label: "Abdome" },
|
|
{ region: "pelve", label: "Pelve" },
|
|
{ region: "membros_superiores", label: "Membros Sup." },
|
|
{ region: "membros_inferiores", label: "Membros Inf." },
|
|
{ region: "corpo_inteiro", label: "Corpo Inteiro" },
|
|
];
|
|
|
|
const STORAGE_KEY = "atlas.quickRegionNav.pos";
|
|
|
|
function loadStoredPos(): { x: number; y: number } | null {
|
|
try {
|
|
const raw = localStorage.getItem(STORAGE_KEY);
|
|
return raw ? JSON.parse(raw) : null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
// Navegação rápida por região — em vez de precisar acertar o clique numa
|
|
// estrutura minúscula pra girar/aproximar a câmera manualmente, o usuário
|
|
// pede direto pra "ir" numa região do corpo. Arrastável pela alcinha (⠿),
|
|
// pois a barra pode cair em cima de uma parte do modelo que o usuário
|
|
// queira ver — a posição escolhida fica salva entre visitas.
|
|
export function QuickRegionNav() {
|
|
const focusOnRegion = useAtlasStore((s) => s.focusOnRegion);
|
|
const [pos, setPos] = useState<{ x: number; y: number } | null>(() => loadStoredPos());
|
|
const elRef = useRef<HTMLDivElement>(null);
|
|
const dragRef = useRef<{ startX: number; startY: number; origX: number; origY: number } | null>(null);
|
|
|
|
function clamp(x: number, y: number) {
|
|
const el = elRef.current;
|
|
const parent = el?.offsetParent as HTMLElement | null;
|
|
if (!el || !parent) return { x, y };
|
|
const maxX = Math.max(0, parent.clientWidth - el.offsetWidth);
|
|
const maxY = Math.max(0, parent.clientHeight - el.offsetHeight);
|
|
return { x: Math.min(Math.max(0, x), maxX), y: Math.min(Math.max(0, y), maxY) };
|
|
}
|
|
|
|
function onGripPointerDown(e: ReactPointerEvent) {
|
|
const el = elRef.current;
|
|
const parent = el?.offsetParent as HTMLElement | null;
|
|
if (!el || !parent) return;
|
|
// Antes do primeiro arrasto a barra fica centralizada via CSS transform
|
|
// (translate-x-1/2) — offsetLeft/offsetTop ignoram transform, então
|
|
// calculamos a posição visual real a partir do bounding rect pra não
|
|
// dar aquele "pulo" no primeiro clique.
|
|
const elRect = el.getBoundingClientRect();
|
|
const parentRect = parent.getBoundingClientRect();
|
|
dragRef.current = {
|
|
startX: e.clientX,
|
|
startY: e.clientY,
|
|
origX: elRect.left - parentRect.left,
|
|
origY: elRect.top - parentRect.top,
|
|
};
|
|
(e.target as HTMLElement).setPointerCapture(e.pointerId);
|
|
}
|
|
|
|
function onGripPointerMove(e: ReactPointerEvent) {
|
|
if (!dragRef.current) return;
|
|
const dx = e.clientX - dragRef.current.startX;
|
|
const dy = e.clientY - dragRef.current.startY;
|
|
setPos(clamp(dragRef.current.origX + dx, dragRef.current.origY + dy));
|
|
}
|
|
|
|
function onGripPointerUp() {
|
|
dragRef.current = null;
|
|
setPos((current) => {
|
|
if (current) {
|
|
try {
|
|
localStorage.setItem(STORAGE_KEY, JSON.stringify(current));
|
|
} catch {
|
|
// ignore
|
|
}
|
|
}
|
|
return current;
|
|
});
|
|
}
|
|
|
|
// Se a janela mudar de tamanho (ex: girar o celular), reaplica o clamp
|
|
// pra barra não ficar presa fora da área visível.
|
|
useEffect(() => {
|
|
if (!pos) return;
|
|
function onResize() {
|
|
setPos((current) => (current ? clamp(current.x, current.y) : current));
|
|
}
|
|
window.addEventListener("resize", onResize);
|
|
return () => window.removeEventListener("resize", onResize);
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, []);
|
|
|
|
return (
|
|
<div
|
|
ref={elRef}
|
|
style={pos ? { position: "absolute", left: pos.x, top: pos.y } : undefined}
|
|
className={
|
|
"flex items-center gap-1.5 bg-panel/90 border border-white/10 rounded-lg pl-1.5 pr-3 py-2 shadow-lg " +
|
|
(pos
|
|
? "max-w-[92vw]"
|
|
: "absolute top-14 md:top-3 left-1/2 -translate-x-1/2 max-w-[92vw] md:max-w-[90%]")
|
|
}
|
|
>
|
|
<div
|
|
onPointerDown={onGripPointerDown}
|
|
onPointerMove={onGripPointerMove}
|
|
onPointerUp={onGripPointerUp}
|
|
className="shrink-0 cursor-move select-none text-slate-500 hover:text-slate-300 px-1 touch-none"
|
|
title="Arrastar"
|
|
aria-label="Arrastar menu de regiões"
|
|
>
|
|
⠿
|
|
</div>
|
|
<div className="flex gap-1.5 overflow-x-auto md:flex-wrap">
|
|
{REGIONS.map((r) => (
|
|
<button
|
|
key={r.region}
|
|
onClick={() => focusOnRegion(r.region)}
|
|
className="shrink-0 text-xs px-3 py-1.5 rounded-full bg-white/5 hover:bg-accent hover:text-white text-slate-300 transition-colors"
|
|
>
|
|
{r.label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|