177 lines
4.5 KiB
TypeScript
177 lines
4.5 KiB
TypeScript
import {
|
|
createContext,
|
|
useContext,
|
|
useCallback,
|
|
useEffect,
|
|
useState,
|
|
useRef,
|
|
type ReactNode,
|
|
type RefObject,
|
|
} from 'react';
|
|
|
|
// ---------- Types ----------
|
|
|
|
export interface AISection {
|
|
id: string; // e.g. "project-tasks", "tasks-list", "timeline-chart"
|
|
label: string; // Human-readable, e.g. "Tasks", "Project Timeline"
|
|
ref: RefObject<HTMLElement | null>;
|
|
projectId?: string; // If section is project-scoped
|
|
}
|
|
|
|
interface FloatingChatState {
|
|
isOpen: boolean;
|
|
activeSectionId: string | null;
|
|
position: { x: number; y: number; width: number };
|
|
morphTargetId: string | null;
|
|
projectId?: string;
|
|
}
|
|
|
|
interface FloatingChatContextValue {
|
|
// State
|
|
state: FloatingChatState;
|
|
sections: Map<string, AISection>;
|
|
|
|
// Section registry
|
|
registerSection: (section: AISection) => void;
|
|
unregisterSection: (id: string) => void;
|
|
|
|
// Actions
|
|
openAtSection: (sectionId: string) => void;
|
|
moveToSection: (sectionId: string) => void;
|
|
close: () => void;
|
|
setMorphTarget: (id: string | null) => void;
|
|
}
|
|
|
|
// ---------- Constants ----------
|
|
|
|
const CHAT_WIDTH = 380;
|
|
const CHAT_HEIGHT = 420;
|
|
const PADDING = 16;
|
|
|
|
// ---------- Position computation ----------
|
|
|
|
function computeAnchorPosition(
|
|
sectionRef: RefObject<HTMLElement | null>,
|
|
): { x: number; y: number; width: number } {
|
|
const el = sectionRef.current;
|
|
if (!el) return { x: PADDING, y: PADDING, width: CHAT_WIDTH };
|
|
|
|
const rect = el.getBoundingClientRect();
|
|
|
|
// Anchor to top-right of section, offset inward
|
|
let x = rect.right - CHAT_WIDTH - PADDING;
|
|
let y = rect.top + PADDING;
|
|
|
|
// Edge-collision clamping
|
|
x = Math.max(PADDING, Math.min(x, window.innerWidth - CHAT_WIDTH - PADDING));
|
|
y = Math.max(
|
|
PADDING,
|
|
Math.min(y, window.innerHeight - CHAT_HEIGHT - PADDING),
|
|
);
|
|
|
|
return { x, y, width: CHAT_WIDTH };
|
|
}
|
|
|
|
// ---------- Context ----------
|
|
|
|
const FloatingChatCtx = createContext<FloatingChatContextValue | null>(null);
|
|
|
|
export function useFloatingChat(): FloatingChatContextValue {
|
|
const ctx = useContext(FloatingChatCtx);
|
|
if (!ctx)
|
|
throw new Error('useFloatingChat must be used within FloatingChatProvider');
|
|
return ctx;
|
|
}
|
|
|
|
// Convenience hook for pages to register a section
|
|
export function useAISection(section: AISection): void {
|
|
const { registerSection, unregisterSection } = useFloatingChat();
|
|
|
|
useEffect(() => {
|
|
registerSection(section);
|
|
return () => unregisterSection(section.id);
|
|
}, [section.id, registerSection, unregisterSection]);
|
|
}
|
|
|
|
// ---------- Provider ----------
|
|
|
|
export function FloatingChatProvider({ children }: { children: ReactNode }) {
|
|
const sectionsRef = useRef<Map<string, AISection>>(new Map());
|
|
const [sections, setSections] = useState<Map<string, AISection>>(new Map());
|
|
const [state, setState] = useState<FloatingChatState>({
|
|
isOpen: false,
|
|
activeSectionId: null,
|
|
position: { x: 0, y: 0, width: CHAT_WIDTH },
|
|
morphTargetId: null,
|
|
});
|
|
|
|
const registerSection = useCallback((section: AISection) => {
|
|
sectionsRef.current.set(section.id, section);
|
|
setSections(new Map(sectionsRef.current));
|
|
}, []);
|
|
|
|
const unregisterSection = useCallback((id: string) => {
|
|
sectionsRef.current.delete(id);
|
|
setSections(new Map(sectionsRef.current));
|
|
}, []);
|
|
|
|
const openAtSection = useCallback((sectionId: string) => {
|
|
const section = sectionsRef.current.get(sectionId);
|
|
if (!section) return;
|
|
|
|
const position = computeAnchorPosition(section.ref);
|
|
|
|
setState({
|
|
isOpen: true,
|
|
activeSectionId: sectionId,
|
|
position,
|
|
morphTargetId: null,
|
|
projectId: section.projectId,
|
|
});
|
|
}, []);
|
|
|
|
const moveToSection = useCallback((sectionId: string) => {
|
|
const section = sectionsRef.current.get(sectionId);
|
|
if (!section) return;
|
|
|
|
const position = computeAnchorPosition(section.ref);
|
|
|
|
setState((prev) => ({
|
|
...prev,
|
|
activeSectionId: sectionId,
|
|
position,
|
|
projectId: section.projectId,
|
|
}));
|
|
}, []);
|
|
|
|
const close = useCallback(() => {
|
|
setState((prev) => ({
|
|
...prev,
|
|
isOpen: false,
|
|
activeSectionId: null,
|
|
morphTargetId: null,
|
|
}));
|
|
}, []);
|
|
|
|
const setMorphTarget = useCallback((id: string | null) => {
|
|
setState((prev) => ({ ...prev, morphTargetId: id }));
|
|
}, []);
|
|
|
|
return (
|
|
<FloatingChatCtx.Provider
|
|
value={{
|
|
state,
|
|
sections,
|
|
registerSection,
|
|
unregisterSection,
|
|
openAtSection,
|
|
moveToSection,
|
|
close,
|
|
setMorphTarget,
|
|
}}
|
|
>
|
|
{children}
|
|
</FloatingChatCtx.Provider>
|
|
);
|
|
}
|