Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 60 additions & 0 deletions frontend/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
"preview": "vite preview"
},
"dependencies": {
"@floating-ui/react": "^0.27.19",
"@react-oauth/google": "^0.13.4",
"@supabase/supabase-js": "^2.98.0",
"axios": "^1.13.5",
Expand Down
26 changes: 22 additions & 4 deletions frontend/src/components/study-room/FloatingMenu.tsx
Original file line number Diff line number Diff line change
@@ -1,18 +1,35 @@
import React from 'react';
import { cn } from '../../lib/utils';
import { aiActions, type ActionKey } from './constants';
import { useFloating, shift, flip, offset, autoUpdate, inline } from '@floating-ui/react';

interface FloatingMenuProps {
x: number;
y: number;
virtualElement: any;
onAction: (action: ActionKey) => void;
}

export const FloatingMenu: React.FC<FloatingMenuProps> = ({ x, y, onAction }) => {
export const FloatingMenu: React.FC<FloatingMenuProps> = ({ virtualElement, onAction }) => {
const { refs, floatingStyles } = useFloating({
placement: 'bottom-start',
elements: {
reference: virtualElement,
},
middleware: [
inline(),
offset(8),
flip({ fallbackPlacements: ['top-start', 'bottom-end', 'top-end'] }),
shift({ padding: 16, crossAxis: true })
],
whileElementsMounted: autoUpdate,
});

if (!virtualElement) return null;

return (
<div
ref={refs.setFloating}
style={floatingStyles}
className="fixed z-50 animate-in fade-in zoom-in duration-200"
style={{ left: x, top: y }}
>
<div className="flex flex-col gap-1 p-2 bg-surface-overlay/95 border border-border/50 rounded-2xl shadow-modal backdrop-blur-xl min-w-[160px]">
{aiActions.map((action) => (
Expand All @@ -37,3 +54,4 @@ export const FloatingMenu: React.FC<FloatingMenuProps> = ({ x, y, onAction }) =>
</div>
);
};

46 changes: 25 additions & 21 deletions frontend/src/pages/StudyRoom.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -125,7 +125,7 @@ const StudyRoom: React.FC = () => {
}, [id]);

/* ── Floating menu state ── */
const [menuPosition, setMenuPosition] = useState<{ x: number; y: number } | null>(null);
const [virtualElement, setVirtualElement] = useState<any>(null);
const [pendingText, setPendingText] = useState<string | null>(null);

/* ── Panel width state (RAF‑throttled drag) ── */
Expand Down Expand Up @@ -173,35 +173,40 @@ const StudyRoom: React.FC = () => {

/* ── Text selection handler ── */
const handleMouseUp = useCallback(() => {
const selection = window.getSelection();
const text = selection?.toString().trim();

if (text && text.length > 0) {
const range = selection!.getRangeAt(0);
const rect = range.getBoundingClientRect();

const menuX = Math.max(8, rect.left + rect.width / 2 - 200);
const menuY = rect.bottom + 8;

setPendingText(text);
setMenuPosition({ x: menuX, y: menuY });
}
// Small timeout ensures selection is fully resolved and allows dismissing
setTimeout(() => {
const selection = window.getSelection();
const text = selection?.toString().trim();

if (text && text.length > 0) {
// Deep copy the range so it doesn't get messed up when selection changes subtly
const range = selection!.getRangeAt(0).cloneRange();
setPendingText(text);
setVirtualElement({
getBoundingClientRect: () => range.getBoundingClientRect(),
getClientRects: () => range.getClientRects()
});
} else {
setPendingText(null);
setVirtualElement(null);
}
}, 10);
}, []);

/* ── Click-outside to dismiss menu ── */
useEffect(() => {
function handleClickOutside(e: MouseEvent) {
const target = e.target as HTMLElement;
if (target.closest('[class*="animate-in"]')) return;
setMenuPosition(null);
setVirtualElement(null);
setPendingText(null);
}

if (menuPosition) {
if (virtualElement) {
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}
}, [menuPosition]);
}, [virtualElement]);

/* ── Handle action click ── */
async function handleAction(action: ActionKey) {
Expand All @@ -217,7 +222,7 @@ const StudyRoom: React.FC = () => {
};

setMessages((prev) => [...prev, userMessage]);
setMenuPosition(null);
setVirtualElement(null);
setPendingText(null);
setIsTyping(true);

Expand Down Expand Up @@ -458,10 +463,9 @@ const StudyRoom: React.FC = () => {
</div>

{/* ── Floating Action Menu ── */}
{menuPosition && pendingText && (
{virtualElement && pendingText && (
<FloatingMenu
x={menuPosition.x}
y={menuPosition.y}
virtualElement={virtualElement}
onAction={handleAction}
/>
)}
Expand Down
Loading