feat: floating glass navigation and action bars in modals
Replace inline Save/Discard/Reset buttons with sticky glass-bubble pills that float at the bottom of scrollable modal content. Make SpaceSettings tab sidebar sticky with glass material. Convert RoleEditView "Back to roles" into a sticky glass pill at the top. UserSettings Log Out + Save always visible in a glass pill with separator. Also includes floating position hook and popover/tooltip improvements from prior work.
This commit is contained in:
@@ -1,7 +1,9 @@
|
|||||||
import React, { useMemo, useRef, useEffect } from 'react';
|
import React, { useMemo, useRef, useEffect } from 'react';
|
||||||
|
import { createPortal } from 'react-dom';
|
||||||
import type { MemberWithUser } from '@backspace/shared';
|
import type { MemberWithUser } from '@backspace/shared';
|
||||||
import { Avatar } from '../ui/Avatar';
|
import { Avatar } from '../ui/Avatar';
|
||||||
import { useSpaceStore } from '../../stores/spaceStore';
|
import { useSpaceStore } from '../../stores/spaceStore';
|
||||||
|
import { useFloatingPosition } from '../../hooks/useFloatingPosition';
|
||||||
|
|
||||||
const MAX_RESULTS = 8;
|
const MAX_RESULTS = 8;
|
||||||
|
|
||||||
@@ -9,13 +11,15 @@ interface MentionPopoverProps {
|
|||||||
query: string;
|
query: string;
|
||||||
selectedIndex: number;
|
selectedIndex: number;
|
||||||
onSelect: (member: MemberWithUser) => void;
|
onSelect: (member: MemberWithUser) => void;
|
||||||
|
anchorRef: React.RefObject<HTMLElement | null>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function MentionPopover({ query, selectedIndex, onSelect }: MentionPopoverProps) {
|
export function MentionPopover({ query, selectedIndex, onSelect, anchorRef }: MentionPopoverProps) {
|
||||||
const members = useSpaceStore((s) => s.members);
|
const members = useSpaceStore((s) => s.members);
|
||||||
const spaces = useSpaceStore((s) => s.spaces);
|
const spaces = useSpaceStore((s) => s.spaces);
|
||||||
const currentSpaceId = useSpaceStore((s) => s.currentSpaceId);
|
const currentSpaceId = useSpaceStore((s) => s.currentSpaceId);
|
||||||
const selectedRef = useRef<HTMLDivElement>(null);
|
const selectedRef = useRef<HTMLDivElement>(null);
|
||||||
|
const floatingRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
const ownerId = spaces.find((s) => s.id === currentSpaceId)?.ownerId;
|
const ownerId = spaces.find((s) => s.id === currentSpaceId)?.ownerId;
|
||||||
|
|
||||||
@@ -30,6 +34,12 @@ export function MentionPopover({ query, selectedIndex, onSelect }: MentionPopove
|
|||||||
.slice(0, MAX_RESULTS);
|
.slice(0, MAX_RESULTS);
|
||||||
}, [members, query]);
|
}, [members, query]);
|
||||||
|
|
||||||
|
const { style } = useFloatingPosition(anchorRef, floatingRef, {
|
||||||
|
placement: 'top',
|
||||||
|
offset: 4,
|
||||||
|
enabled: filtered.length > 0,
|
||||||
|
});
|
||||||
|
|
||||||
// Scroll selected item into view
|
// Scroll selected item into view
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
selectedRef.current?.scrollIntoView({ block: 'nearest' });
|
selectedRef.current?.scrollIntoView({ block: 'nearest' });
|
||||||
@@ -46,8 +56,8 @@ export function MentionPopover({ query, selectedIndex, onSelect }: MentionPopove
|
|||||||
return undefined;
|
return undefined;
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return createPortal(
|
||||||
<div className="absolute bottom-full left-0 w-[280px] mb-1 z-50">
|
<div ref={floatingRef} style={style} className="w-[280px]">
|
||||||
<div className="bg-surface-elevated rounded-lg shadow-[0_0_0_1px_rgba(0,0,0,0.15),0_8px_16px_rgba(0,0,0,0.24)] overflow-hidden max-h-[320px] overflow-y-auto scrollbar-thin">
|
<div className="bg-surface-elevated rounded-lg shadow-[0_0_0_1px_rgba(0,0,0,0.15),0_8px_16px_rgba(0,0,0,0.24)] overflow-hidden max-h-[320px] overflow-y-auto scrollbar-thin">
|
||||||
<div className="px-2 py-1.5 text-[11px] font-bold text-txt-tertiary uppercase tracking-wider">
|
<div className="px-2 py-1.5 text-[11px] font-bold text-txt-tertiary uppercase tracking-wider">
|
||||||
Members
|
Members
|
||||||
@@ -88,6 +98,7 @@ export function MentionPopover({ query, selectedIndex, onSelect }: MentionPopove
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>,
|
||||||
|
document.body,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ export function MessageInput({ channelId, channelName }: MessageInputProps) {
|
|||||||
const [mentionState, setMentionState] = useState<MentionState | null>(null);
|
const [mentionState, setMentionState] = useState<MentionState | null>(null);
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||||
|
const inputContainerRef = useRef<HTMLDivElement>(null);
|
||||||
const sendMessage = useChatStore((s) => s.sendMessage);
|
const sendMessage = useChatStore((s) => s.sendMessage);
|
||||||
const replyTo = useChatStore((s) => s.replyTo);
|
const replyTo = useChatStore((s) => s.replyTo);
|
||||||
const setReplyTo = useChatStore((s) => s.setReplyTo);
|
const setReplyTo = useChatStore((s) => s.setReplyTo);
|
||||||
@@ -249,6 +250,7 @@ export function MessageInput({ channelId, channelName }: MessageInputProps) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div
|
<div
|
||||||
|
ref={inputContainerRef}
|
||||||
className={`relative bg-surface-input md:bg-transparent ${replyTo ? 'rounded-b-lg' : 'rounded-lg md:rounded-none'} overflow-visible`}
|
className={`relative bg-surface-input md:bg-transparent ${replyTo ? 'rounded-b-lg' : 'rounded-lg md:rounded-none'} overflow-visible`}
|
||||||
onDrop={handleDrop}
|
onDrop={handleDrop}
|
||||||
onDragOver={handleDragOver}
|
onDragOver={handleDragOver}
|
||||||
@@ -259,6 +261,7 @@ export function MessageInput({ channelId, channelName }: MessageInputProps) {
|
|||||||
query={mentionState.query}
|
query={mentionState.query}
|
||||||
selectedIndex={mentionState.selectedIndex}
|
selectedIndex={mentionState.selectedIndex}
|
||||||
onSelect={selectMention}
|
onSelect={selectMention}
|
||||||
|
anchorRef={inputContainerRef}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -196,20 +196,24 @@ function StreamingLimitsPanel() {
|
|||||||
<div className="p-2 bg-status-online/10 border border-status-online/30 rounded text-status-online text-sm">Settings saved</div>
|
<div className="p-2 bg-status-online/10 border border-status-online/30 rounded text-status-online text-sm">Settings saved</div>
|
||||||
)}
|
)}
|
||||||
{hasChanges && (
|
{hasChanges && (
|
||||||
<div className="flex items-center gap-2">
|
<div className="sticky bottom-0 z-10 pointer-events-none">
|
||||||
<button
|
<div className="flex justify-center pt-3 pb-1">
|
||||||
onClick={handleSave}
|
<div className="glass-bubble rounded-full px-4 py-2 flex items-center gap-2 animate-slide-up pointer-events-auto">
|
||||||
disabled={saving}
|
<button
|
||||||
className="px-4 py-1.5 bg-accent-primary hover:bg-accent-primary/80 text-white text-sm font-medium rounded transition-colors disabled:opacity-50"
|
onClick={handleReset}
|
||||||
>
|
className="px-3 py-1 text-sm text-txt-tertiary hover:text-txt-secondary transition-colors"
|
||||||
{saving ? 'Saving...' : 'Save'}
|
>
|
||||||
</button>
|
Reset
|
||||||
<button
|
</button>
|
||||||
onClick={handleReset}
|
<button
|
||||||
className="px-4 py-1.5 text-sm text-txt-tertiary hover:text-txt-secondary transition-colors"
|
onClick={handleSave}
|
||||||
>
|
disabled={saving}
|
||||||
Reset
|
className="px-3 py-1.5 bg-accent-primary hover:bg-accent-primary/80 text-white text-sm font-medium rounded-full transition-colors disabled:opacity-50"
|
||||||
</button>
|
>
|
||||||
|
{saving ? 'Saving...' : 'Save'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -331,20 +335,24 @@ function DiscoveryPanel({ spaceId }: { spaceId: string }) {
|
|||||||
<div className="p-2 bg-status-online/10 border border-status-online/30 rounded text-status-online text-sm">Settings saved</div>
|
<div className="p-2 bg-status-online/10 border border-status-online/30 rounded text-status-online text-sm">Settings saved</div>
|
||||||
)}
|
)}
|
||||||
{hasChanges && (
|
{hasChanges && (
|
||||||
<div className="flex items-center gap-2">
|
<div className="sticky bottom-0 z-10 pointer-events-none">
|
||||||
<button
|
<div className="flex justify-center pt-3 pb-1">
|
||||||
onClick={handleSave}
|
<div className="glass-bubble rounded-full px-4 py-2 flex items-center gap-2 animate-slide-up pointer-events-auto">
|
||||||
disabled={saving}
|
<button
|
||||||
className="px-4 py-1.5 bg-accent-primary hover:bg-accent-primary/80 text-white text-sm font-medium rounded transition-colors disabled:opacity-50"
|
onClick={handleReset}
|
||||||
>
|
className="px-3 py-1 text-sm text-txt-tertiary hover:text-txt-secondary transition-colors"
|
||||||
{saving ? 'Saving...' : 'Save'}
|
>
|
||||||
</button>
|
Reset
|
||||||
<button
|
</button>
|
||||||
onClick={handleReset}
|
<button
|
||||||
className="px-4 py-1.5 text-sm text-txt-tertiary hover:text-txt-secondary transition-colors"
|
onClick={handleSave}
|
||||||
>
|
disabled={saving}
|
||||||
Reset
|
className="px-3 py-1.5 bg-accent-primary hover:bg-accent-primary/80 text-white text-sm font-medium rounded-full transition-colors disabled:opacity-50"
|
||||||
</button>
|
>
|
||||||
|
{saving ? 'Saving...' : 'Save'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -479,7 +487,7 @@ export function SpaceSettingsModal() {
|
|||||||
if (!space || !currentSpaceId) return null;
|
if (!space || !currentSpaceId) return null;
|
||||||
|
|
||||||
const tabClass = (t: typeof tab) =>
|
const tabClass = (t: typeof tab) =>
|
||||||
`w-full text-left px-3 py-1.5 rounded text-sm transition-colors ${
|
`w-full text-left px-2.5 py-1.5 rounded text-sm transition-colors ${
|
||||||
tab === t ? 'bg-interactive-selected text-txt-primary' : 'text-txt-tertiary hover:text-txt-secondary hover:bg-interactive-hover'
|
tab === t ? 'bg-interactive-selected text-txt-primary' : 'text-txt-tertiary hover:text-txt-secondary hover:bg-interactive-hover'
|
||||||
}`;
|
}`;
|
||||||
|
|
||||||
@@ -487,28 +495,30 @@ export function SpaceSettingsModal() {
|
|||||||
<Modal isOpen={isOpen} onClose={closeModal} title="Space Settings" maxWidth="max-w-xl">
|
<Modal isOpen={isOpen} onClose={closeModal} title="Space Settings" maxWidth="max-w-xl">
|
||||||
<div className="flex gap-4">
|
<div className="flex gap-4">
|
||||||
{/* Tabs */}
|
{/* Tabs */}
|
||||||
<div className="w-32 flex-shrink-0 space-y-1">
|
<div className="w-32 flex-shrink-0 sticky top-0 self-start z-10">
|
||||||
<button onClick={() => setTab('overview')} className={tabClass('overview')}>
|
<div className="glass-bubble rounded-lg p-1.5 space-y-0.5">
|
||||||
Overview
|
<button onClick={() => setTab('overview')} className={tabClass('overview')}>
|
||||||
</button>
|
Overview
|
||||||
{canManageSpace && (
|
|
||||||
<button onClick={() => setTab('discovery')} className={tabClass('discovery')}>
|
|
||||||
Discovery
|
|
||||||
</button>
|
</button>
|
||||||
)}
|
{canManageSpace && (
|
||||||
<button onClick={() => setTab('members')} className={tabClass('members')}>
|
<button onClick={() => setTab('discovery')} className={tabClass('discovery')}>
|
||||||
Members
|
Discovery
|
||||||
</button>
|
</button>
|
||||||
{canManageRoles && (
|
)}
|
||||||
<button onClick={() => setTab('roles')} className={tabClass('roles')}>
|
<button onClick={() => setTab('members')} className={tabClass('members')}>
|
||||||
Roles
|
Members
|
||||||
</button>
|
</button>
|
||||||
)}
|
{canManageRoles && (
|
||||||
{isAdmin && (
|
<button onClick={() => setTab('roles')} className={tabClass('roles')}>
|
||||||
<button onClick={() => setTab('streaming')} className={tabClass('streaming')}>
|
Roles
|
||||||
Streaming
|
</button>
|
||||||
</button>
|
)}
|
||||||
)}
|
{isAdmin && (
|
||||||
|
<button onClick={() => setTab('streaming')} className={tabClass('streaming')}>
|
||||||
|
Streaming
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Content */}
|
{/* Content */}
|
||||||
|
|||||||
@@ -172,20 +172,25 @@ export function UserSettingsModal() {
|
|||||||
{/* Connected Instances */}
|
{/* Connected Instances */}
|
||||||
<ConnectedInstances />
|
<ConnectedInstances />
|
||||||
|
|
||||||
<div className="flex items-center justify-between pt-2">
|
<div className="sticky bottom-0 z-10 pointer-events-none">
|
||||||
<button
|
<div className="flex justify-center pt-3 pb-1">
|
||||||
onClick={handleLogout}
|
<div className="glass-bubble rounded-full px-3 py-2 flex items-center gap-3 pointer-events-auto">
|
||||||
className="px-4 py-2 text-sm text-txt-danger hover:bg-accent-rose/10 rounded transition-colors"
|
<button
|
||||||
>
|
onClick={handleLogout}
|
||||||
Log Out
|
className="px-3 py-1 text-sm text-txt-danger hover:bg-accent-rose/10 rounded-full transition-colors"
|
||||||
</button>
|
>
|
||||||
<button
|
Log Out
|
||||||
onClick={handleSave}
|
</button>
|
||||||
disabled={isLoading}
|
<div className="w-px h-5 bg-white/10" />
|
||||||
className="px-4 py-2 bg-accent-primary hover:bg-accent-primary/80 text-white text-sm font-medium rounded transition-colors disabled:opacity-50"
|
<button
|
||||||
>
|
onClick={handleSave}
|
||||||
{isLoading ? 'Saving...' : 'Save Changes'}
|
disabled={isLoading}
|
||||||
</button>
|
className="px-3 py-1.5 bg-accent-primary hover:bg-accent-primary/80 text-white text-sm font-medium rounded-full transition-colors disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{isLoading ? 'Saving...' : 'Save Changes'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|||||||
@@ -229,20 +229,24 @@ export function OverviewPanel({ spaceId }: OverviewPanelProps) {
|
|||||||
<div className="p-2 bg-status-online/10 border border-status-online/30 rounded text-status-online text-sm">Settings saved</div>
|
<div className="p-2 bg-status-online/10 border border-status-online/30 rounded text-status-online text-sm">Settings saved</div>
|
||||||
)}
|
)}
|
||||||
{canManageSpace && hasChanges && (
|
{canManageSpace && hasChanges && (
|
||||||
<div className="flex items-center gap-2">
|
<div className="sticky bottom-0 z-10 pointer-events-none">
|
||||||
<button
|
<div className="flex justify-center pt-3 pb-1">
|
||||||
onClick={handleSave}
|
<div className="glass-bubble rounded-full px-4 py-2 flex items-center gap-2 animate-slide-up pointer-events-auto">
|
||||||
disabled={saving || uploadingIcon || !spaceName.trim()}
|
<button
|
||||||
className="px-4 py-1.5 bg-accent-primary hover:bg-accent-primary/80 text-white text-sm font-medium rounded transition-colors disabled:opacity-50"
|
onClick={handleDiscard}
|
||||||
>
|
className="px-3 py-1 text-sm text-txt-tertiary hover:text-txt-secondary transition-colors"
|
||||||
{saving ? 'Saving...' : 'Save'}
|
>
|
||||||
</button>
|
Discard
|
||||||
<button
|
</button>
|
||||||
onClick={handleDiscard}
|
<button
|
||||||
className="px-4 py-1.5 text-sm text-txt-tertiary hover:text-txt-secondary transition-colors"
|
onClick={handleSave}
|
||||||
>
|
disabled={saving || uploadingIcon || !spaceName.trim()}
|
||||||
Discard
|
className="px-3 py-1.5 bg-accent-primary hover:bg-accent-primary/80 text-white text-sm font-medium rounded-full transition-colors disabled:opacity-50"
|
||||||
</button>
|
>
|
||||||
|
{saving ? 'Saving...' : 'Save'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -242,15 +242,17 @@ function RoleEditView({ role, spaceId, onBack, onDeleted }: RoleEditViewProps) {
|
|||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{/* Back button */}
|
{/* Back button */}
|
||||||
<button
|
<div className="sticky top-0 z-10 pointer-events-none pb-3">
|
||||||
onClick={onBack}
|
<button
|
||||||
className="flex items-center gap-1 text-sm text-txt-tertiary hover:text-txt-secondary transition-colors"
|
onClick={onBack}
|
||||||
>
|
className="glass-bubble rounded-full px-3 py-1.5 flex items-center gap-1 text-sm text-txt-tertiary hover:text-txt-secondary transition-colors pointer-events-auto"
|
||||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
>
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15 19l-7-7 7-7" />
|
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||||
</svg>
|
<path strokeLinecap="round" strokeLinejoin="round" d="M15 19l-7-7 7-7" />
|
||||||
Back to roles
|
</svg>
|
||||||
</button>
|
Back to roles
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Role Name (not editable for @everyone) */}
|
{/* Role Name (not editable for @everyone) */}
|
||||||
{!isEveryone && (
|
{!isEveryone && (
|
||||||
@@ -362,20 +364,24 @@ function RoleEditView({ role, spaceId, onBack, onDeleted }: RoleEditViewProps) {
|
|||||||
<div className="p-2 bg-status-online/10 border border-status-online/30 rounded text-status-online text-sm">Role saved</div>
|
<div className="p-2 bg-status-online/10 border border-status-online/30 rounded text-status-online text-sm">Role saved</div>
|
||||||
)}
|
)}
|
||||||
{hasChanges && (
|
{hasChanges && (
|
||||||
<div className="flex items-center gap-2">
|
<div className="sticky bottom-0 z-10 pointer-events-none">
|
||||||
<button
|
<div className="flex justify-center pt-3 pb-1">
|
||||||
onClick={handleSave}
|
<div className="glass-bubble rounded-full px-4 py-2 flex items-center gap-2 animate-slide-up pointer-events-auto">
|
||||||
disabled={saving || (!isEveryone && !draftName.trim())}
|
<button
|
||||||
className="px-4 py-1.5 bg-accent-primary hover:bg-accent-primary/80 text-white text-sm font-medium rounded transition-colors disabled:opacity-50"
|
onClick={handleDiscard}
|
||||||
>
|
className="px-3 py-1 text-sm text-txt-tertiary hover:text-txt-secondary transition-colors"
|
||||||
{saving ? 'Saving...' : 'Save'}
|
>
|
||||||
</button>
|
Discard
|
||||||
<button
|
</button>
|
||||||
onClick={handleDiscard}
|
<button
|
||||||
className="px-4 py-1.5 text-sm text-txt-tertiary hover:text-txt-secondary transition-colors"
|
onClick={handleSave}
|
||||||
>
|
disabled={saving || (!isEveryone && !draftName.trim())}
|
||||||
Discard
|
className="px-3 py-1.5 bg-accent-primary hover:bg-accent-primary/80 text-white text-sm font-medium rounded-full transition-colors disabled:opacity-50"
|
||||||
</button>
|
>
|
||||||
|
{saving ? 'Saving...' : 'Save'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -49,6 +49,8 @@ export function ContextMenu({ items, children }: ContextMenuProps) {
|
|||||||
if (rect.bottom > window.innerHeight) {
|
if (rect.bottom > window.innerHeight) {
|
||||||
newPosition.y = window.innerHeight - rect.height - 8;
|
newPosition.y = window.innerHeight - rect.height - 8;
|
||||||
}
|
}
|
||||||
|
if (newPosition.x < 8) newPosition.x = 8;
|
||||||
|
if (newPosition.y < 8) newPosition.y = 8;
|
||||||
|
|
||||||
if (newPosition.x !== position.x || newPosition.y !== position.y) {
|
if (newPosition.x !== position.x || newPosition.y !== position.y) {
|
||||||
setPosition(newPosition);
|
setPosition(newPosition);
|
||||||
@@ -62,7 +64,7 @@ export function ContextMenu({ items, children }: ContextMenuProps) {
|
|||||||
{isOpen && (
|
{isOpen && (
|
||||||
<div
|
<div
|
||||||
ref={menuRef}
|
ref={menuRef}
|
||||||
className="fixed z-[200] min-w-[180px] py-1.5 bg-surface-elevated rounded-md shadow-elevation-high animate-fade-in"
|
className="fixed z-[200] min-w-[180px] py-1.5 bg-surface-elevated rounded-md shadow-elevation-high animate-fade-in max-h-[calc(100vh-16px)] overflow-y-auto scrollbar-thin"
|
||||||
style={{ left: position.x, top: position.y }}
|
style={{ left: position.x, top: position.y }}
|
||||||
>
|
>
|
||||||
{items.map((item, i) => (
|
{items.map((item, i) => (
|
||||||
|
|||||||
@@ -30,9 +30,9 @@ export function Modal({ isOpen, onClose, title, children, maxWidth = 'max-w-md'
|
|||||||
className="absolute inset-0 bg-surface-overlay"
|
className="absolute inset-0 bg-surface-overlay"
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
/>
|
/>
|
||||||
<div className={`relative ${maxWidth} w-full mx-4 bg-surface-elevated rounded-lg shadow-xl animate-slide-up`}>
|
<div className={`relative ${maxWidth} w-full mx-4 max-h-[calc(100vh-2rem)] flex flex-col bg-surface-elevated rounded-lg shadow-xl animate-slide-up`}>
|
||||||
{title && (
|
{title && (
|
||||||
<div className="flex items-center justify-between px-4 pt-4">
|
<div className="flex items-center justify-between px-4 pt-4 flex-shrink-0">
|
||||||
<h2 className="text-xl font-bold text-txt-primary">{title}</h2>
|
<h2 className="text-xl font-bold text-txt-primary">{title}</h2>
|
||||||
<button
|
<button
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
@@ -44,7 +44,7 @@ export function Modal({ isOpen, onClose, title, children, maxWidth = 'max-w-md'
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="p-4">
|
<div className="p-4 overflow-y-auto scrollbar-thin flex-1 min-h-0">
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
import React, { useState, useRef, useEffect } from 'react';
|
import React, { useState, useRef, useEffect } from 'react';
|
||||||
|
import { createPortal } from 'react-dom';
|
||||||
|
import { useFloatingPosition } from '../../hooks/useFloatingPosition';
|
||||||
|
|
||||||
interface TooltipProps {
|
interface TooltipProps {
|
||||||
content: string;
|
content: string;
|
||||||
@@ -10,6 +12,14 @@ interface TooltipProps {
|
|||||||
export function Tooltip({ content, children, position = 'right', delay = 200 }: TooltipProps) {
|
export function Tooltip({ content, children, position = 'right', delay = 200 }: TooltipProps) {
|
||||||
const [isVisible, setIsVisible] = useState(false);
|
const [isVisible, setIsVisible] = useState(false);
|
||||||
const timeoutRef = useRef<ReturnType<typeof setTimeout>>();
|
const timeoutRef = useRef<ReturnType<typeof setTimeout>>();
|
||||||
|
const anchorRef = useRef<HTMLDivElement>(null);
|
||||||
|
const floatingRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
const { style } = useFloatingPosition(anchorRef, floatingRef, {
|
||||||
|
placement: position,
|
||||||
|
offset: 8,
|
||||||
|
enabled: isVisible,
|
||||||
|
});
|
||||||
|
|
||||||
const show = () => {
|
const show = () => {
|
||||||
timeoutRef.current = setTimeout(() => setIsVisible(true), delay);
|
timeoutRef.current = setTimeout(() => setIsVisible(true), delay);
|
||||||
@@ -26,22 +36,18 @@ export function Tooltip({ content, children, position = 'right', delay = 200 }:
|
|||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const positionClasses: Record<string, string> = {
|
|
||||||
top: 'bottom-full left-1/2 -translate-x-1/2 mb-2',
|
|
||||||
right: 'left-full top-1/2 -translate-y-1/2 ml-2',
|
|
||||||
bottom: 'top-full left-1/2 -translate-x-1/2 mt-2',
|
|
||||||
left: 'right-full top-1/2 -translate-y-1/2 mr-2',
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative inline-flex" onMouseEnter={show} onMouseLeave={hide}>
|
<div ref={anchorRef} className="relative inline-flex" onMouseEnter={show} onMouseLeave={hide}>
|
||||||
{children}
|
{children}
|
||||||
{isVisible && (
|
{isVisible && createPortal(
|
||||||
<div
|
<div
|
||||||
className={`absolute z-[200] px-3 py-1.5 text-sm font-medium text-txt-primary bg-surface-elevated rounded-md shadow-elevation-high whitespace-nowrap pointer-events-none ${positionClasses[position]}`}
|
ref={floatingRef}
|
||||||
|
style={style}
|
||||||
|
className="px-3 py-1.5 text-sm font-medium text-txt-primary bg-surface-elevated rounded-md shadow-elevation-high whitespace-nowrap pointer-events-none"
|
||||||
>
|
>
|
||||||
{content}
|
{content}
|
||||||
</div>
|
</div>,
|
||||||
|
document.body,
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
import React, { useEffect, useRef } from 'react';
|
import React, { useEffect, useRef } from 'react';
|
||||||
|
import { createPortal } from 'react-dom';
|
||||||
import { useTrackStats, AudioTrackStat, VideoTrackStat } from '../../hooks/useTrackStats';
|
import { useTrackStats, AudioTrackStat, VideoTrackStat } from '../../hooks/useTrackStats';
|
||||||
import { getActiveRoom } from '../../hooks/useLiveKit';
|
import { getActiveRoom } from '../../hooks/useLiveKit';
|
||||||
|
import { useFloatingPosition } from '../../hooks/useFloatingPosition';
|
||||||
|
|
||||||
interface ConnectionInfoPopoverProps {
|
interface ConnectionInfoPopoverProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
|
anchorRef: React.RefObject<HTMLElement | null>;
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatBitrate(kbps: number): string {
|
function formatBitrate(kbps: number): string {
|
||||||
@@ -110,10 +113,16 @@ function VideoTrackRow({ track }: { track: VideoTrackStat }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ConnectionInfoPopover({ open, onClose }: ConnectionInfoPopoverProps) {
|
export function ConnectionInfoPopover({ open, onClose, anchorRef }: ConnectionInfoPopoverProps) {
|
||||||
const popoverRef = useRef<HTMLDivElement>(null);
|
const popoverRef = useRef<HTMLDivElement>(null);
|
||||||
const stats = useTrackStats(open);
|
const stats = useTrackStats(open);
|
||||||
|
|
||||||
|
const { style } = useFloatingPosition(anchorRef, popoverRef, {
|
||||||
|
placement: 'top',
|
||||||
|
offset: 12,
|
||||||
|
enabled: open,
|
||||||
|
});
|
||||||
|
|
||||||
// Click-outside to close
|
// Click-outside to close
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) return;
|
if (!open) return;
|
||||||
@@ -130,16 +139,17 @@ export function ConnectionInfoPopover({ open, onClose }: ConnectionInfoPopoverPr
|
|||||||
|
|
||||||
const room = getActiveRoom();
|
const room = getActiveRoom();
|
||||||
|
|
||||||
return (
|
return createPortal(
|
||||||
<div
|
<div
|
||||||
ref={popoverRef}
|
ref={popoverRef}
|
||||||
className="absolute bottom-full left-1/2 -translate-x-1/2 mb-3 w-[300px] glass rounded-lg z-50 overflow-hidden"
|
style={style}
|
||||||
|
className="w-[300px] glass rounded-lg overflow-hidden"
|
||||||
>
|
>
|
||||||
<div className="px-3 py-2 border-b border-border-hard">
|
<div className="px-3 py-2 border-b border-border-hard">
|
||||||
<span className="text-[14px] font-bold text-txt-primary">Connection Info</span>
|
<span className="text-[14px] font-bold text-txt-primary">Connection Info</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="px-3 py-2">
|
<div className="px-3 py-2 max-h-[calc(100vh-32px)] overflow-y-auto scrollbar-thin">
|
||||||
{!room ? (
|
{!room ? (
|
||||||
<div className="text-[12px] text-txt-tertiary py-2 text-center">Not connected</div>
|
<div className="text-[12px] text-txt-tertiary py-2 text-center">Not connected</div>
|
||||||
) : !stats ? (
|
) : !stats ? (
|
||||||
@@ -197,6 +207,7 @@ export function ConnectionInfoPopover({ open, onClose }: ConnectionInfoPopoverPr
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>,
|
||||||
|
document.body,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,15 @@
|
|||||||
import React, { useRef, useEffect } from 'react';
|
import React, { useRef, useEffect } from 'react';
|
||||||
|
import { createPortal } from 'react-dom';
|
||||||
import { useVoiceStore } from '../../stores/voiceStore';
|
import { useVoiceStore } from '../../stores/voiceStore';
|
||||||
import type { ScreenShareConfig } from '../../stores/voiceStore';
|
import type { ScreenShareConfig } from '../../stores/voiceStore';
|
||||||
import { useSettingsStore } from '../../stores/settingsStore';
|
import { useSettingsStore } from '../../stores/settingsStore';
|
||||||
import { buildScreenShareOptions } from '../../utils/screenShare';
|
import { buildScreenShareOptions } from '../../utils/screenShare';
|
||||||
|
import { useFloatingPosition } from '../../hooks/useFloatingPosition';
|
||||||
|
|
||||||
interface ScreenShareSettingsPopoverProps {
|
interface ScreenShareSettingsPopoverProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
|
anchorRef: React.RefObject<HTMLElement | null>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ALL_RESOLUTIONS: { value: ScreenShareConfig['height']; label: string }[] = [
|
const ALL_RESOLUTIONS: { value: ScreenShareConfig['height']; label: string }[] = [
|
||||||
@@ -45,12 +48,18 @@ function formatKbps(kbps: number): string {
|
|||||||
: `${kbps} kbps`;
|
: `${kbps} kbps`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ScreenShareSettingsPopover({ open, onClose }: ScreenShareSettingsPopoverProps) {
|
export function ScreenShareSettingsPopover({ open, onClose, anchorRef }: ScreenShareSettingsPopoverProps) {
|
||||||
const popoverRef = useRef<HTMLDivElement>(null);
|
const popoverRef = useRef<HTMLDivElement>(null);
|
||||||
const config = useVoiceStore((s) => s.screenShareConfig);
|
const config = useVoiceStore((s) => s.screenShareConfig);
|
||||||
const setConfig = useVoiceStore((s) => s.setScreenShareConfig);
|
const setConfig = useVoiceStore((s) => s.setScreenShareConfig);
|
||||||
const limits = useSettingsStore((s) => s.streamingLimits);
|
const limits = useSettingsStore((s) => s.streamingLimits);
|
||||||
|
|
||||||
|
const { style } = useFloatingPosition(anchorRef, popoverRef, {
|
||||||
|
placement: 'top',
|
||||||
|
offset: 12,
|
||||||
|
enabled: open,
|
||||||
|
});
|
||||||
|
|
||||||
const BITRATE_MIN = limits?.minBitrateKbps ?? 500;
|
const BITRATE_MIN = limits?.minBitrateKbps ?? 500;
|
||||||
const BITRATE_MAX = limits?.maxBitrateKbps ?? 20000;
|
const BITRATE_MAX = limits?.maxBitrateKbps ?? 20000;
|
||||||
const BITRATE_STEP = limits?.bitrateStepKbps ?? 500;
|
const BITRATE_STEP = limits?.bitrateStepKbps ?? 500;
|
||||||
@@ -104,10 +113,11 @@ export function ScreenShareSettingsPopover({ open, onClose }: ScreenShareSetting
|
|||||||
const pillSelected = 'bg-accent-primary text-white';
|
const pillSelected = 'bg-accent-primary text-white';
|
||||||
const pillUnselected = 'bg-surface-elevated text-txt-secondary hover:bg-interactive-hover';
|
const pillUnselected = 'bg-surface-elevated text-txt-secondary hover:bg-interactive-hover';
|
||||||
|
|
||||||
return (
|
return createPortal(
|
||||||
<div
|
<div
|
||||||
ref={popoverRef}
|
ref={popoverRef}
|
||||||
className="absolute bottom-full left-1/2 -translate-x-1/2 mb-3 w-[260px] glass rounded-lg z-50 overflow-hidden"
|
style={style}
|
||||||
|
className="w-[260px] glass rounded-lg overflow-hidden"
|
||||||
>
|
>
|
||||||
<div className="px-3 py-2 border-b border-border-hard">
|
<div className="px-3 py-2 border-b border-border-hard">
|
||||||
<span className="text-[14px] font-bold text-txt-primary">Stream Settings</span>
|
<span className="text-[14px] font-bold text-txt-primary">Stream Settings</span>
|
||||||
@@ -215,6 +225,7 @@ export function ScreenShareSettingsPopover({ open, onClose }: ScreenShareSetting
|
|||||||
{formatBitrate(result.publish.videoEncoding.maxBitrate)} · {formatDegradation(result.overdrive.degradationPreference)}
|
{formatBitrate(result.publish.videoEncoding.maxBitrate)} · {formatDegradation(result.overdrive.degradationPreference)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>,
|
||||||
|
document.body,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ export function StreamTile({ tile, large }: StreamTileProps) {
|
|||||||
// Context menu state
|
// Context menu state
|
||||||
const [contextMenu, setContextMenu] = useState<{ x: number; y: number } | null>(null);
|
const [contextMenu, setContextMenu] = useState<{ x: number; y: number } | null>(null);
|
||||||
const [qualityPopoverOpen, setQualityPopoverOpen] = useState(false);
|
const [qualityPopoverOpen, setQualityPopoverOpen] = useState(false);
|
||||||
|
const qualityBtnRef = useRef<HTMLButtonElement>(null);
|
||||||
|
|
||||||
// --- VIDEO --- use LiveKit's track.attach() to register the element
|
// --- VIDEO --- use LiveKit's track.attach() to register the element
|
||||||
// with the adaptive stream observer (enables SFU layer switching by viewport size)
|
// with the adaptive stream observer (enables SFU layer switching by viewport size)
|
||||||
@@ -260,6 +261,7 @@ export function StreamTile({ tile, large }: StreamTileProps) {
|
|||||||
</div>
|
</div>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<button
|
<button
|
||||||
|
ref={qualityBtnRef}
|
||||||
onClick={() => setQualityPopoverOpen(!qualityPopoverOpen)}
|
onClick={() => setQualityPopoverOpen(!qualityPopoverOpen)}
|
||||||
className="w-full flex items-center justify-between px-2 py-1.5 text-sm text-txt-secondary hover:bg-interactive-hover rounded transition-colors"
|
className="w-full flex items-center justify-between px-2 py-1.5 text-sm text-txt-secondary hover:bg-interactive-hover rounded transition-colors"
|
||||||
>
|
>
|
||||||
@@ -277,6 +279,7 @@ export function StreamTile({ tile, large }: StreamTileProps) {
|
|||||||
<ScreenShareSettingsPopover
|
<ScreenShareSettingsPopover
|
||||||
open={qualityPopoverOpen}
|
open={qualityPopoverOpen}
|
||||||
onClose={() => setQualityPopoverOpen(false)}
|
onClose={() => setQualityPopoverOpen(false)}
|
||||||
|
anchorRef={qualityBtnRef}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { useEffect, useState } from 'react';
|
import React, { useEffect, useState, useRef } from 'react';
|
||||||
import { useVoiceStore } from '../../stores/voiceStore';
|
import { useVoiceStore } from '../../stores/voiceStore';
|
||||||
import { useUIStore } from '../../stores/uiStore';
|
import { useUIStore } from '../../stores/uiStore';
|
||||||
import { getActiveRoom } from '../../hooks/useLiveKit';
|
import { getActiveRoom } from '../../hooks/useLiveKit';
|
||||||
@@ -27,6 +27,7 @@ export function VoiceControlBar() {
|
|||||||
const currentVoiceChannelId = useVoiceStore((s) => s.currentVoiceChannelId);
|
const currentVoiceChannelId = useVoiceStore((s) => s.currentVoiceChannelId);
|
||||||
const voiceOrigin = currentVoiceChannelId ? getChannelOrigin(currentVoiceChannelId) : '';
|
const voiceOrigin = currentVoiceChannelId ? getChannelOrigin(currentVoiceChannelId) : '';
|
||||||
const [qualityOpen, setQualityOpen] = useState(false);
|
const [qualityOpen, setQualityOpen] = useState(false);
|
||||||
|
const qualityBtnRef = useRef<HTMLButtonElement>(null);
|
||||||
|
|
||||||
const handleMute = React.useCallback(async () => {
|
const handleMute = React.useCallback(async () => {
|
||||||
toggleMic();
|
toggleMic();
|
||||||
@@ -207,21 +208,20 @@ export function VoiceControlBar() {
|
|||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* Video Quality */}
|
{/* Video Quality */}
|
||||||
<div className="relative">
|
<button
|
||||||
<button
|
ref={qualityBtnRef}
|
||||||
onClick={() => setQualityOpen(!qualityOpen)}
|
onClick={() => setQualityOpen(!qualityOpen)}
|
||||||
className={qualityOpen
|
className={qualityOpen
|
||||||
? `${btnBase} bg-surface-channel text-txt-primary`
|
? `${btnBase} bg-surface-channel text-txt-primary`
|
||||||
: btnDefault
|
: btnDefault
|
||||||
}
|
}
|
||||||
title="Video Quality"
|
title="Video Quality"
|
||||||
>
|
>
|
||||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor">
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor">
|
||||||
<path d="M19.14 12.94c.04-.3.06-.61.06-.94 0-.32-.02-.64-.07-.94l2.03-1.58c.18-.14.23-.41.12-.61l-1.92-3.32c-.12-.22-.37-.29-.59-.22l-2.39.96c-.5-.38-1.03-.7-1.62-.94l-.36-2.54c-.04-.24-.24-.41-.48-.41h-3.84c-.24 0-.43.17-.47.41l-.36 2.54c-.59.24-1.13.57-1.62.94l-2.39-.96c-.22-.08-.47 0-.59.22L2.74 8.87c-.12.21-.08.47.12.61l2.03 1.58c-.05.3-.07.62-.07.94s.02.64.07.94l-2.03 1.58c-.18.14-.23.41-.12.61l1.92 3.32c.12.22.37.29.59.22l2.39-.96c.5.38 1.03.7 1.62.94l.36 2.54c.05.24.24.41.48.41h3.84c.24 0 .44-.17.47-.41l.36-2.54c.59-.24 1.13-.56 1.62-.94l2.39.96c.22.08.47 0 .59-.22l1.92-3.32c.12-.22.07-.47-.12-.61l-2.01-1.58zM12 15.6c-1.98 0-3.6-1.62-3.6-3.6s1.62-3.6 3.6-3.6 3.6 1.62 3.6 3.6-1.62 3.6-3.6 3.6z" />
|
<path d="M19.14 12.94c.04-.3.06-.61.06-.94 0-.32-.02-.64-.07-.94l2.03-1.58c.18-.14.23-.41.12-.61l-1.92-3.32c-.12-.22-.37-.29-.59-.22l-2.39.96c-.5-.38-1.03-.7-1.62-.94l-.36-2.54c-.04-.24-.24-.41-.48-.41h-3.84c-.24 0-.43.17-.47.41l-.36 2.54c-.59.24-1.13.57-1.62.94l-2.39-.96c-.22-.08-.47 0-.59.22L2.74 8.87c-.12.21-.08.47.12.61l2.03 1.58c-.05.3-.07.62-.07.94s.02.64.07.94l-2.03 1.58c-.18.14-.23.41-.12.61l1.92 3.32c.12.22.37.29.59.22l2.39-.96c.5.38 1.03.7 1.62.94l.36 2.54c.05.24.24.41.48.41h3.84c.24 0 .44-.17.47-.41l.36-2.54c.59-.24 1.13-.56 1.62-.94l2.39.96c.22.08.47 0 .59-.22l1.92-3.32c.12-.22.07-.47-.12-.61l-2.01-1.58zM12 15.6c-1.98 0-3.6-1.62-3.6-3.6s1.62-3.6 3.6-3.6 3.6 1.62 3.6 3.6-1.62 3.6-3.6 3.6z" />
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
<ScreenShareSettingsPopover open={qualityOpen} onClose={() => setQualityOpen(false)} />
|
<ScreenShareSettingsPopover open={qualityOpen} onClose={() => setQualityOpen(false)} anchorRef={qualityBtnRef} />
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Separator */}
|
{/* Separator */}
|
||||||
<div className="w-[1px] h-6 bg-white/10 mx-0.5" />
|
<div className="w-[1px] h-6 bg-white/10 mx-0.5" />
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState, useRef } from 'react';
|
||||||
import { useVoiceStore } from '../../stores/voiceStore';
|
import { useVoiceStore } from '../../stores/voiceStore';
|
||||||
import { useSpaceStore, getChannelOrigin } from '../../stores/spaceStore';
|
import { useSpaceStore, getChannelOrigin } from '../../stores/spaceStore';
|
||||||
import { getActiveRoom } from '../../hooks/useLiveKit';
|
import { getActiveRoom } from '../../hooks/useLiveKit';
|
||||||
@@ -24,6 +24,8 @@ export function VoiceControls() {
|
|||||||
const channels = useSpaceStore((s) => s.channels);
|
const channels = useSpaceStore((s) => s.channels);
|
||||||
const [showScreenShareSettings, setShowScreenShareSettings] = useState(false);
|
const [showScreenShareSettings, setShowScreenShareSettings] = useState(false);
|
||||||
const [showConnectionInfo, setShowConnectionInfo] = useState(false);
|
const [showConnectionInfo, setShowConnectionInfo] = useState(false);
|
||||||
|
const connectionBtnRef = useRef<HTMLButtonElement>(null);
|
||||||
|
const qualityBtnRef = useRef<HTMLButtonElement>(null);
|
||||||
|
|
||||||
const activeDmCall = useVoiceStore((s) => s.activeDmCall);
|
const activeDmCall = useVoiceStore((s) => s.activeDmCall);
|
||||||
|
|
||||||
@@ -109,6 +111,7 @@ export function VoiceControls() {
|
|||||||
{/* Row 1: Signal icon + status text + disconnect */}
|
{/* Row 1: Signal icon + status text + disconnect */}
|
||||||
<div className="relative flex items-center gap-2 px-3 pt-3 pb-1">
|
<div className="relative flex items-center gap-2 px-3 pt-3 pb-1">
|
||||||
<button
|
<button
|
||||||
|
ref={connectionBtnRef}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setShowConnectionInfo(!showConnectionInfo);
|
setShowConnectionInfo(!showConnectionInfo);
|
||||||
if (!showConnectionInfo) setShowScreenShareSettings(false);
|
if (!showConnectionInfo) setShowScreenShareSettings(false);
|
||||||
@@ -146,6 +149,7 @@ export function VoiceControls() {
|
|||||||
<ConnectionInfoPopover
|
<ConnectionInfoPopover
|
||||||
open={showConnectionInfo}
|
open={showConnectionInfo}
|
||||||
onClose={() => setShowConnectionInfo(false)}
|
onClose={() => setShowConnectionInfo(false)}
|
||||||
|
anchorRef={connectionBtnRef}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -189,6 +193,7 @@ export function VoiceControls() {
|
|||||||
|
|
||||||
{/* Video Quality */}
|
{/* Video Quality */}
|
||||||
<button
|
<button
|
||||||
|
ref={qualityBtnRef}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setShowScreenShareSettings(!showScreenShareSettings);
|
setShowScreenShareSettings(!showScreenShareSettings);
|
||||||
if (!showScreenShareSettings) setShowConnectionInfo(false);
|
if (!showScreenShareSettings) setShowConnectionInfo(false);
|
||||||
@@ -238,6 +243,7 @@ export function VoiceControls() {
|
|||||||
<ScreenShareSettingsPopover
|
<ScreenShareSettingsPopover
|
||||||
open={showScreenShareSettings}
|
open={showScreenShareSettings}
|
||||||
onClose={() => setShowScreenShareSettings(false)}
|
onClose={() => setShowScreenShareSettings(false)}
|
||||||
|
anchorRef={qualityBtnRef}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -0,0 +1,166 @@
|
|||||||
|
import { type RefObject, type CSSProperties, useState, useLayoutEffect, useCallback } from 'react';
|
||||||
|
|
||||||
|
type Placement = 'top' | 'bottom' | 'left' | 'right';
|
||||||
|
|
||||||
|
interface UseFloatingPositionOptions {
|
||||||
|
placement: Placement;
|
||||||
|
offset?: number;
|
||||||
|
enabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UseFloatingPositionResult {
|
||||||
|
style: CSSProperties;
|
||||||
|
actualPlacement: Placement;
|
||||||
|
}
|
||||||
|
|
||||||
|
const VIEWPORT_PADDING = 8;
|
||||||
|
|
||||||
|
const oppositePlacement: Record<Placement, Placement> = {
|
||||||
|
top: 'bottom',
|
||||||
|
bottom: 'top',
|
||||||
|
left: 'right',
|
||||||
|
right: 'left',
|
||||||
|
};
|
||||||
|
|
||||||
|
function computePosition(
|
||||||
|
anchorRect: DOMRect,
|
||||||
|
floatingWidth: number,
|
||||||
|
floatingHeight: number,
|
||||||
|
placement: Placement,
|
||||||
|
offset: number,
|
||||||
|
): { top: number; left: number; actualPlacement: Placement } {
|
||||||
|
const vw = window.innerWidth;
|
||||||
|
const vh = window.innerHeight;
|
||||||
|
|
||||||
|
let top = 0;
|
||||||
|
let left = 0;
|
||||||
|
let actual = placement;
|
||||||
|
|
||||||
|
// Compute initial position on primary axis
|
||||||
|
if (placement === 'top') {
|
||||||
|
top = anchorRect.top - floatingHeight - offset;
|
||||||
|
left = anchorRect.left + anchorRect.width / 2 - floatingWidth / 2;
|
||||||
|
} else if (placement === 'bottom') {
|
||||||
|
top = anchorRect.bottom + offset;
|
||||||
|
left = anchorRect.left + anchorRect.width / 2 - floatingWidth / 2;
|
||||||
|
} else if (placement === 'left') {
|
||||||
|
top = anchorRect.top + anchorRect.height / 2 - floatingHeight / 2;
|
||||||
|
left = anchorRect.left - floatingWidth - offset;
|
||||||
|
} else {
|
||||||
|
top = anchorRect.top + anchorRect.height / 2 - floatingHeight / 2;
|
||||||
|
left = anchorRect.right + offset;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Flip: if overflowing on primary axis, try the opposite side
|
||||||
|
if (placement === 'top' && top < VIEWPORT_PADDING) {
|
||||||
|
const flippedTop = anchorRect.bottom + offset;
|
||||||
|
if (flippedTop + floatingHeight <= vh - VIEWPORT_PADDING) {
|
||||||
|
top = flippedTop;
|
||||||
|
actual = 'bottom';
|
||||||
|
}
|
||||||
|
} else if (placement === 'bottom' && top + floatingHeight > vh - VIEWPORT_PADDING) {
|
||||||
|
const flippedTop = anchorRect.top - floatingHeight - offset;
|
||||||
|
if (flippedTop >= VIEWPORT_PADDING) {
|
||||||
|
top = flippedTop;
|
||||||
|
actual = 'top';
|
||||||
|
}
|
||||||
|
} else if (placement === 'left' && left < VIEWPORT_PADDING) {
|
||||||
|
const flippedLeft = anchorRect.right + offset;
|
||||||
|
if (flippedLeft + floatingWidth <= vw - VIEWPORT_PADDING) {
|
||||||
|
left = flippedLeft;
|
||||||
|
actual = 'right';
|
||||||
|
}
|
||||||
|
} else if (placement === 'right' && left + floatingWidth > vw - VIEWPORT_PADDING) {
|
||||||
|
const flippedLeft = anchorRect.left - floatingWidth - offset;
|
||||||
|
if (flippedLeft >= VIEWPORT_PADDING) {
|
||||||
|
left = flippedLeft;
|
||||||
|
actual = 'left';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clamp: keep within viewport on cross axis
|
||||||
|
if (actual === 'top' || actual === 'bottom') {
|
||||||
|
left = Math.max(VIEWPORT_PADDING, Math.min(left, vw - floatingWidth - VIEWPORT_PADDING));
|
||||||
|
} else {
|
||||||
|
top = Math.max(VIEWPORT_PADDING, Math.min(top, vh - floatingHeight - VIEWPORT_PADDING));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Also clamp primary axis as last resort
|
||||||
|
top = Math.max(VIEWPORT_PADDING, Math.min(top, vh - floatingHeight - VIEWPORT_PADDING));
|
||||||
|
left = Math.max(VIEWPORT_PADDING, Math.min(left, vw - floatingWidth - VIEWPORT_PADDING));
|
||||||
|
|
||||||
|
return { top, left, actualPlacement: actual };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useFloatingPosition(
|
||||||
|
anchorRef: RefObject<HTMLElement | null>,
|
||||||
|
floatingRef: RefObject<HTMLElement | null>,
|
||||||
|
options: UseFloatingPositionOptions,
|
||||||
|
): UseFloatingPositionResult {
|
||||||
|
const { placement, offset = 8, enabled = true } = options;
|
||||||
|
|
||||||
|
const [result, setResult] = useState<{ top: number; left: number; actualPlacement: Placement }>({
|
||||||
|
top: -9999,
|
||||||
|
left: -9999,
|
||||||
|
actualPlacement: placement,
|
||||||
|
});
|
||||||
|
|
||||||
|
const update = useCallback(() => {
|
||||||
|
const anchor = anchorRef.current;
|
||||||
|
const floating = floatingRef.current;
|
||||||
|
if (!anchor || !floating) return;
|
||||||
|
|
||||||
|
const anchorRect = anchor.getBoundingClientRect();
|
||||||
|
const floatingRect = floating.getBoundingClientRect();
|
||||||
|
|
||||||
|
const pos = computePosition(
|
||||||
|
anchorRect,
|
||||||
|
floatingRect.width,
|
||||||
|
floatingRect.height,
|
||||||
|
placement,
|
||||||
|
offset,
|
||||||
|
);
|
||||||
|
|
||||||
|
setResult((prev) => {
|
||||||
|
if (prev.top === pos.top && prev.left === pos.left && prev.actualPlacement === pos.actualPlacement) {
|
||||||
|
return prev;
|
||||||
|
}
|
||||||
|
return pos;
|
||||||
|
});
|
||||||
|
}, [anchorRef, floatingRef, placement, offset]);
|
||||||
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
if (!enabled) return;
|
||||||
|
update();
|
||||||
|
|
||||||
|
// Observe resize of both elements
|
||||||
|
const anchor = anchorRef.current;
|
||||||
|
const floating = floatingRef.current;
|
||||||
|
const targets: Element[] = [];
|
||||||
|
if (anchor) targets.push(anchor);
|
||||||
|
if (floating) targets.push(floating);
|
||||||
|
|
||||||
|
const ro = new ResizeObserver(update);
|
||||||
|
targets.forEach((t) => ro.observe(t));
|
||||||
|
|
||||||
|
// Also update on scroll/resize
|
||||||
|
window.addEventListener('scroll', update, true);
|
||||||
|
window.addEventListener('resize', update);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
ro.disconnect();
|
||||||
|
window.removeEventListener('scroll', update, true);
|
||||||
|
window.removeEventListener('resize', update);
|
||||||
|
};
|
||||||
|
}, [enabled, update, anchorRef, floatingRef]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
style: {
|
||||||
|
position: 'fixed',
|
||||||
|
top: result.top,
|
||||||
|
left: result.left,
|
||||||
|
zIndex: 200,
|
||||||
|
},
|
||||||
|
actualPlacement: result.actualPlacement,
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user