feat(conversation): Add resizable embedding projector panel with improved UX
- Larger default size (700x550) for better readability - Resizable from all 8 edges/corners with visual SE grip indicator - Clearer button icons (18px, strokeWidth 2.5) - Draggable, minimizable, pinnable panel - Dark theme and mobile responsive support
This commit is contained in:
parent
d9892dba6f
commit
82aa655522
3 changed files with 838 additions and 0 deletions
|
|
@ -0,0 +1,360 @@
|
|||
/**
|
||||
* ConversationEmbeddingPanel.css
|
||||
*
|
||||
* Styles for the draggable, resizable embedding projector panel.
|
||||
* Based on InstitutionInfoPanel.css and RdfNodeDetailsPanel.css patterns.
|
||||
*/
|
||||
|
||||
/* ============================================================================
|
||||
Main Panel Container
|
||||
============================================================================ */
|
||||
|
||||
.conversation-embedding-panel {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
background: white;
|
||||
border: 1px solid var(--border-color, #e5e5e5);
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.12);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
z-index: 150;
|
||||
transition: box-shadow 0.2s ease;
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
.conversation-embedding-panel--dragging,
|
||||
.conversation-embedding-panel--resizing {
|
||||
box-shadow: 0 12px 48px rgba(0, 0, 0, 0.18);
|
||||
cursor: grabbing !important;
|
||||
}
|
||||
|
||||
.conversation-embedding-panel--minimized {
|
||||
height: 48px !important;
|
||||
}
|
||||
|
||||
.conversation-embedding-panel--simple {
|
||||
/* Width/height handled inline by component */
|
||||
}
|
||||
|
||||
/* ============================================================================
|
||||
Resize Handles
|
||||
============================================================================ */
|
||||
|
||||
.conversation-embedding-panel__resize-handle {
|
||||
position: absolute;
|
||||
background: transparent;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
/* North and South edges */
|
||||
.conversation-embedding-panel__resize-handle--n,
|
||||
.conversation-embedding-panel__resize-handle--s {
|
||||
left: 12px;
|
||||
right: 12px;
|
||||
height: 8px;
|
||||
cursor: ns-resize;
|
||||
}
|
||||
|
||||
.conversation-embedding-panel__resize-handle--n {
|
||||
top: -4px;
|
||||
}
|
||||
|
||||
.conversation-embedding-panel__resize-handle--s {
|
||||
bottom: -4px;
|
||||
}
|
||||
|
||||
/* East and West edges */
|
||||
.conversation-embedding-panel__resize-handle--e,
|
||||
.conversation-embedding-panel__resize-handle--w {
|
||||
top: 12px;
|
||||
bottom: 12px;
|
||||
width: 8px;
|
||||
cursor: ew-resize;
|
||||
}
|
||||
|
||||
.conversation-embedding-panel__resize-handle--e {
|
||||
right: -4px;
|
||||
}
|
||||
|
||||
.conversation-embedding-panel__resize-handle--w {
|
||||
left: -4px;
|
||||
}
|
||||
|
||||
/* Corner handles */
|
||||
.conversation-embedding-panel__resize-handle--ne,
|
||||
.conversation-embedding-panel__resize-handle--nw,
|
||||
.conversation-embedding-panel__resize-handle--se,
|
||||
.conversation-embedding-panel__resize-handle--sw {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
.conversation-embedding-panel__resize-handle--ne {
|
||||
top: -4px;
|
||||
right: -4px;
|
||||
cursor: nesw-resize;
|
||||
}
|
||||
|
||||
.conversation-embedding-panel__resize-handle--nw {
|
||||
top: -4px;
|
||||
left: -4px;
|
||||
cursor: nwse-resize;
|
||||
}
|
||||
|
||||
.conversation-embedding-panel__resize-handle--se {
|
||||
bottom: -4px;
|
||||
right: -4px;
|
||||
cursor: nwse-resize;
|
||||
}
|
||||
|
||||
.conversation-embedding-panel__resize-handle--sw {
|
||||
bottom: -4px;
|
||||
left: -4px;
|
||||
cursor: nesw-resize;
|
||||
}
|
||||
|
||||
/* Visual indicator for SE corner (most commonly used for resize) */
|
||||
.conversation-embedding-panel__resize-handle--se::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
right: 6px;
|
||||
bottom: 6px;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-right: 2px solid rgba(0, 0, 0, 0.15);
|
||||
border-bottom: 2px solid rgba(0, 0, 0, 0.15);
|
||||
border-radius: 0 0 3px 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.conversation-embedding-panel__resize-handle--se:hover::after {
|
||||
border-color: rgba(10, 61, 250, 0.4);
|
||||
}
|
||||
|
||||
/* ============================================================================
|
||||
Header (Drag Handle)
|
||||
============================================================================ */
|
||||
|
||||
.conversation-embedding-panel__header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 12px 16px;
|
||||
border-bottom: none;
|
||||
background: linear-gradient(135deg, #0a3dfa 0%, #1e50ff 100%);
|
||||
flex-shrink: 0;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.conversation-embedding-panel--minimized .conversation-embedding-panel__header {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.conversation-embedding-panel__title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 0.9375rem;
|
||||
font-weight: 600;
|
||||
color: white;
|
||||
margin: 0;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.conversation-embedding-panel__title svg {
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* ============================================================================
|
||||
Controls - Larger, clearer buttons
|
||||
============================================================================ */
|
||||
|
||||
.conversation-embedding-panel__controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.conversation-embedding-panel__sources-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 4px 10px;
|
||||
background: linear-gradient(135deg, #22c55e 0%, #16a34a 100%);
|
||||
color: white;
|
||||
border-radius: 12px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
margin-right: 4px;
|
||||
}
|
||||
|
||||
.conversation-embedding-panel__control-btn,
|
||||
.conversation-embedding-panel__close-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
border: 1px solid rgba(255, 255, 255, 0.3);
|
||||
border-radius: 6px;
|
||||
color: white;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.conversation-embedding-panel__control-btn svg,
|
||||
.conversation-embedding-panel__close-btn svg {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
.conversation-embedding-panel__control-btn:hover {
|
||||
background: rgba(255, 255, 255, 0.35);
|
||||
border-color: rgba(255, 255, 255, 0.5);
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
.conversation-embedding-panel__control-btn--active {
|
||||
background: rgba(255, 255, 255, 0.4);
|
||||
border-color: rgba(255, 255, 255, 0.6);
|
||||
}
|
||||
|
||||
.conversation-embedding-panel__close-btn:hover {
|
||||
background: rgba(220, 38, 38, 0.9);
|
||||
border-color: rgba(220, 38, 38, 1);
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
/* ============================================================================
|
||||
Content
|
||||
============================================================================ */
|
||||
|
||||
.conversation-embedding-panel__content {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.conversation-embedding-panel__loading,
|
||||
.conversation-embedding-panel__empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
height: 100%;
|
||||
color: #666;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.conversation-embedding-panel__loading-icon {
|
||||
animation: spin 1s linear infinite;
|
||||
color: var(--primary-blue, #0a3dfa);
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
from { transform: rotate(0deg); }
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.conversation-embedding-panel__empty svg {
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.conversation-embedding-panel__empty p {
|
||||
margin: 0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* ============================================================================
|
||||
Dark Theme
|
||||
============================================================================ */
|
||||
|
||||
[data-theme="dark"] .conversation-embedding-panel {
|
||||
background: var(--bg-primary-dark, #1a1a2e);
|
||||
border-color: var(--border-color-dark, #333);
|
||||
}
|
||||
|
||||
[data-theme="dark"] .conversation-embedding-panel__header {
|
||||
background: linear-gradient(135deg, #1a4fff 0%, #3366ff 100%);
|
||||
}
|
||||
|
||||
[data-theme="dark"] .conversation-embedding-panel__title {
|
||||
color: white;
|
||||
}
|
||||
|
||||
[data-theme="dark"] .conversation-embedding-panel__control-btn,
|
||||
[data-theme="dark"] .conversation-embedding-panel__close-btn {
|
||||
color: white;
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
border-color: rgba(255, 255, 255, 0.25);
|
||||
}
|
||||
|
||||
[data-theme="dark"] .conversation-embedding-panel__control-btn:hover {
|
||||
background: rgba(255, 255, 255, 0.3);
|
||||
border-color: rgba(255, 255, 255, 0.4);
|
||||
}
|
||||
|
||||
[data-theme="dark"] .conversation-embedding-panel__close-btn:hover {
|
||||
background: rgba(220, 38, 38, 0.9);
|
||||
border-color: rgba(220, 38, 38, 1);
|
||||
}
|
||||
|
||||
[data-theme="dark"] .conversation-embedding-panel__loading,
|
||||
[data-theme="dark"] .conversation-embedding-panel__empty {
|
||||
color: #999;
|
||||
}
|
||||
|
||||
[data-theme="dark"] .conversation-embedding-panel__resize-handle--se::after {
|
||||
border-color: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
[data-theme="dark"] .conversation-embedding-panel__resize-handle--se:hover::after {
|
||||
border-color: rgba(100, 150, 255, 0.5);
|
||||
}
|
||||
|
||||
/* ============================================================================
|
||||
Responsive Adjustments
|
||||
============================================================================ */
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.conversation-embedding-panel {
|
||||
width: calc(100vw - 32px) !important;
|
||||
max-width: 500px;
|
||||
}
|
||||
|
||||
.conversation-embedding-panel__sources-badge {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.conversation-embedding-panel__resize-handle {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.conversation-embedding-panel {
|
||||
width: calc(100vw - 16px) !important;
|
||||
left: 8px !important;
|
||||
right: 8px !important;
|
||||
transform: translateY(var(--panel-y, 0)) !important;
|
||||
}
|
||||
|
||||
.conversation-embedding-panel__title {
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.conversation-embedding-panel__control-btn,
|
||||
.conversation-embedding-panel__close-btn {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,474 @@
|
|||
/**
|
||||
* ConversationEmbeddingPanel.tsx
|
||||
*
|
||||
* A draggable, floating panel that displays the Embedding Projector visualization.
|
||||
* Shows embeddings retrieved during RAG queries for the current conversation.
|
||||
*
|
||||
* Features:
|
||||
* - Draggable by header (matches InstitutionInfoPanel pattern)
|
||||
* - Close button (X)
|
||||
* - Pin button (keeps panel open during navigation)
|
||||
* - Minimize button (collapses to header only)
|
||||
* - Escape key to close
|
||||
* - Stays within viewport bounds
|
||||
* - Highlights RAG source embeddings
|
||||
*
|
||||
* Based on InstitutionInfoPanel.tsx drag pattern.
|
||||
*/
|
||||
|
||||
import React, { useState, useRef, useEffect, useCallback, memo } from 'react';
|
||||
import {
|
||||
X,
|
||||
Minimize2,
|
||||
Maximize2,
|
||||
Lock,
|
||||
Unlock,
|
||||
Layers,
|
||||
Loader2,
|
||||
Info,
|
||||
} from 'lucide-react';
|
||||
import { EmbeddingProjector, type EmbeddingPoint } from '../database/EmbeddingProjector';
|
||||
import { isTargetInsideAny } from '../../utils/dom';
|
||||
import './ConversationEmbeddingPanel.css';
|
||||
|
||||
// Panel dimensions - larger default for better readability
|
||||
const PANEL_WIDTH = 700;
|
||||
const PANEL_HEIGHT = 550;
|
||||
const PANEL_HEIGHT_MINIMIZED = 48;
|
||||
const PANEL_HEIGHT_SIMPLE = 450;
|
||||
const PANEL_WIDTH_SIMPLE = 550;
|
||||
const PANEL_MARGIN = 16;
|
||||
const MIN_VISIBLE_WIDTH = 100;
|
||||
const MIN_VISIBLE_HEIGHT = 100;
|
||||
// Resize constraints
|
||||
const MIN_WIDTH = 400;
|
||||
const MIN_HEIGHT = 300;
|
||||
const MAX_WIDTH = 1200;
|
||||
const MAX_HEIGHT = 900;
|
||||
|
||||
// Resize direction type
|
||||
type ResizeDirection = 'n' | 's' | 'e' | 'w' | 'ne' | 'nw' | 'se' | 'sw' | null;
|
||||
|
||||
interface Position {
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
interface Size {
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
export interface ConversationEmbeddingPanelProps {
|
||||
/** Embedding points to display */
|
||||
points: EmbeddingPoint[];
|
||||
/** Whether embeddings are currently loading */
|
||||
isLoading?: boolean;
|
||||
/** Indices of points to highlight (RAG sources) */
|
||||
highlightedIndices?: number[];
|
||||
/** Called when user closes the panel */
|
||||
onClose: () => void;
|
||||
/** Panel title */
|
||||
title?: string;
|
||||
/** Translation function - accepts any string key */
|
||||
t: (key: string) => string;
|
||||
/** Current language */
|
||||
language: 'nl' | 'en';
|
||||
/** Use simple/compact mode */
|
||||
simpleMode?: boolean;
|
||||
/** Whether panel is pinned */
|
||||
isPinned?: boolean;
|
||||
/** Called when pin state changes */
|
||||
onTogglePin?: () => void;
|
||||
/** Called when a context point is selected */
|
||||
onContextSelect?: (point: EmbeddingPoint) => void;
|
||||
/** Initial position (if not specified, defaults to bottom-right) */
|
||||
initialPosition?: Position;
|
||||
}
|
||||
|
||||
/**
|
||||
* Draggable embedding projector panel for conversation page.
|
||||
*/
|
||||
const ConversationEmbeddingPanelComponent: React.FC<ConversationEmbeddingPanelProps> = ({
|
||||
points,
|
||||
isLoading = false,
|
||||
highlightedIndices = [],
|
||||
onClose,
|
||||
title,
|
||||
t,
|
||||
language,
|
||||
simpleMode = false,
|
||||
isPinned = false,
|
||||
onTogglePin,
|
||||
onContextSelect,
|
||||
initialPosition,
|
||||
}) => {
|
||||
const panelRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Draggable state
|
||||
const [position, setPosition] = useState<Position | null>(null);
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const dragStartRef = useRef<{ x: number; y: number; posX: number; posY: number } | null>(null);
|
||||
|
||||
// Resize state
|
||||
const [size, setSize] = useState<Size | null>(null);
|
||||
const [isResizing, setIsResizing] = useState(false);
|
||||
const [resizeDirection, setResizeDirection] = useState<ResizeDirection>(null);
|
||||
const resizeStartRef = useRef<{
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
posX: number;
|
||||
posY: number;
|
||||
} | null>(null);
|
||||
|
||||
// Minimize state
|
||||
const [isMinimized, setIsMinimized] = useState(false);
|
||||
|
||||
// Track if user has manually positioned this panel
|
||||
const hasUserPositioned = useRef(false);
|
||||
|
||||
// Calculate default panel dimensions based on mode
|
||||
const defaultPanelWidth = simpleMode ? PANEL_WIDTH_SIMPLE : PANEL_WIDTH;
|
||||
const defaultPanelHeight = simpleMode ? PANEL_HEIGHT_SIMPLE : PANEL_HEIGHT;
|
||||
|
||||
// Use user-resized dimensions or defaults
|
||||
const panelWidth = size?.width ?? defaultPanelWidth;
|
||||
const panelHeight = isMinimized ? PANEL_HEIGHT_MINIMIZED : (size?.height ?? defaultPanelHeight);
|
||||
|
||||
// Calculate initial position (bottom-right by default) and size
|
||||
useEffect(() => {
|
||||
// If position already exists (from dragging or previous render), keep it
|
||||
if (position && hasUserPositioned.current) return;
|
||||
|
||||
// If we already have a position, don't recalculate
|
||||
if (position) return;
|
||||
|
||||
const viewportWidth = window.innerWidth;
|
||||
const viewportHeight = window.innerHeight;
|
||||
|
||||
// Set initial size if not set
|
||||
if (!size) {
|
||||
setSize({ width: defaultPanelWidth, height: defaultPanelHeight });
|
||||
}
|
||||
|
||||
let x: number;
|
||||
let y: number;
|
||||
|
||||
if (initialPosition) {
|
||||
x = initialPosition.x;
|
||||
y = initialPosition.y;
|
||||
} else {
|
||||
// Default to bottom-right corner
|
||||
x = viewportWidth - defaultPanelWidth - PANEL_MARGIN;
|
||||
y = viewportHeight - defaultPanelHeight - PANEL_MARGIN;
|
||||
}
|
||||
|
||||
// Clamp to viewport bounds
|
||||
x = Math.max(PANEL_MARGIN, Math.min(x, viewportWidth - defaultPanelWidth - PANEL_MARGIN));
|
||||
y = Math.max(PANEL_MARGIN, Math.min(y, viewportHeight - defaultPanelHeight - PANEL_MARGIN));
|
||||
|
||||
setPosition({ x, y });
|
||||
}, [initialPosition, position, defaultPanelWidth, defaultPanelHeight, size]);
|
||||
|
||||
// Drag handlers
|
||||
const handleMouseDown = useCallback((e: React.MouseEvent) => {
|
||||
// Don't start drag if clicking on buttons or links
|
||||
if (isTargetInsideAny(e.target, ['button', 'a', 'input', 'select'])) {
|
||||
return;
|
||||
}
|
||||
|
||||
e.preventDefault();
|
||||
setIsDragging(true);
|
||||
|
||||
if (panelRef.current && position) {
|
||||
dragStartRef.current = {
|
||||
x: e.clientX,
|
||||
y: e.clientY,
|
||||
posX: position.x,
|
||||
posY: position.y,
|
||||
};
|
||||
}
|
||||
}, [position]);
|
||||
|
||||
const handleMouseMove = useCallback((e: MouseEvent) => {
|
||||
if (!isDragging || !dragStartRef.current) return;
|
||||
|
||||
const deltaX = e.clientX - dragStartRef.current.x;
|
||||
const deltaY = e.clientY - dragStartRef.current.y;
|
||||
|
||||
const newX = dragStartRef.current.posX + deltaX;
|
||||
const newY = dragStartRef.current.posY + deltaY;
|
||||
|
||||
// Clamp to viewport bounds (allow some overflow for edge docking)
|
||||
const maxX = window.innerWidth - MIN_VISIBLE_WIDTH;
|
||||
const maxY = window.innerHeight - MIN_VISIBLE_HEIGHT;
|
||||
|
||||
setPosition({
|
||||
x: Math.max(-panelWidth + MIN_VISIBLE_WIDTH, Math.min(newX, maxX)),
|
||||
y: Math.max(0, Math.min(newY, maxY)),
|
||||
});
|
||||
}, [isDragging, panelWidth]);
|
||||
|
||||
const handleMouseUp = useCallback(() => {
|
||||
// Mark that user has positioned this panel
|
||||
if (isDragging) {
|
||||
hasUserPositioned.current = true;
|
||||
}
|
||||
setIsDragging(false);
|
||||
dragStartRef.current = null;
|
||||
}, [isDragging]);
|
||||
|
||||
// Global mouse event listeners for dragging
|
||||
useEffect(() => {
|
||||
if (isDragging) {
|
||||
window.addEventListener('mousemove', handleMouseMove);
|
||||
window.addEventListener('mouseup', handleMouseUp);
|
||||
document.body.style.cursor = 'grabbing';
|
||||
document.body.style.userSelect = 'none';
|
||||
} else {
|
||||
document.body.style.cursor = '';
|
||||
document.body.style.userSelect = '';
|
||||
}
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('mousemove', handleMouseMove);
|
||||
window.removeEventListener('mouseup', handleMouseUp);
|
||||
document.body.style.cursor = '';
|
||||
document.body.style.userSelect = '';
|
||||
};
|
||||
}, [isDragging, handleMouseMove, handleMouseUp]);
|
||||
|
||||
// Resize handlers
|
||||
const handleResizeMouseDown = useCallback((direction: ResizeDirection) => (e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setIsResizing(true);
|
||||
setResizeDirection(direction);
|
||||
|
||||
if (position && size) {
|
||||
resizeStartRef.current = {
|
||||
x: e.clientX,
|
||||
y: e.clientY,
|
||||
width: size.width,
|
||||
height: size.height,
|
||||
posX: position.x,
|
||||
posY: position.y,
|
||||
};
|
||||
}
|
||||
}, [position, size]);
|
||||
|
||||
const handleResizeMouseMove = useCallback((e: MouseEvent) => {
|
||||
if (!isResizing || !resizeStartRef.current || !resizeDirection) return;
|
||||
|
||||
const deltaX = e.clientX - resizeStartRef.current.x;
|
||||
const deltaY = e.clientY - resizeStartRef.current.y;
|
||||
|
||||
let newWidth = resizeStartRef.current.width;
|
||||
let newHeight = resizeStartRef.current.height;
|
||||
let newX = resizeStartRef.current.posX;
|
||||
let newY = resizeStartRef.current.posY;
|
||||
|
||||
// Handle horizontal resize
|
||||
if (resizeDirection.includes('e')) {
|
||||
newWidth = Math.min(MAX_WIDTH, Math.max(MIN_WIDTH, resizeStartRef.current.width + deltaX));
|
||||
}
|
||||
if (resizeDirection.includes('w')) {
|
||||
const potentialWidth = resizeStartRef.current.width - deltaX;
|
||||
if (potentialWidth >= MIN_WIDTH && potentialWidth <= MAX_WIDTH) {
|
||||
newWidth = potentialWidth;
|
||||
newX = resizeStartRef.current.posX + deltaX;
|
||||
}
|
||||
}
|
||||
|
||||
// Handle vertical resize
|
||||
if (resizeDirection.includes('s')) {
|
||||
newHeight = Math.min(MAX_HEIGHT, Math.max(MIN_HEIGHT, resizeStartRef.current.height + deltaY));
|
||||
}
|
||||
if (resizeDirection.includes('n')) {
|
||||
const potentialHeight = resizeStartRef.current.height - deltaY;
|
||||
if (potentialHeight >= MIN_HEIGHT && potentialHeight <= MAX_HEIGHT) {
|
||||
newHeight = potentialHeight;
|
||||
newY = resizeStartRef.current.posY + deltaY;
|
||||
}
|
||||
}
|
||||
|
||||
setSize({ width: newWidth, height: newHeight });
|
||||
|
||||
// Update position if resizing from north or west
|
||||
if (resizeDirection.includes('w') || resizeDirection.includes('n')) {
|
||||
setPosition({ x: newX, y: newY });
|
||||
}
|
||||
}, [isResizing, resizeDirection]);
|
||||
|
||||
const handleResizeMouseUp = useCallback(() => {
|
||||
setIsResizing(false);
|
||||
setResizeDirection(null);
|
||||
resizeStartRef.current = null;
|
||||
}, []);
|
||||
|
||||
// Global mouse event listeners for resizing
|
||||
useEffect(() => {
|
||||
if (isResizing) {
|
||||
window.addEventListener('mousemove', handleResizeMouseMove);
|
||||
window.addEventListener('mouseup', handleResizeMouseUp);
|
||||
document.body.style.userSelect = 'none';
|
||||
|
||||
// Set cursor based on direction
|
||||
const cursorMap: Record<string, string> = {
|
||||
n: 'ns-resize',
|
||||
s: 'ns-resize',
|
||||
e: 'ew-resize',
|
||||
w: 'ew-resize',
|
||||
ne: 'nesw-resize',
|
||||
sw: 'nesw-resize',
|
||||
nw: 'nwse-resize',
|
||||
se: 'nwse-resize',
|
||||
};
|
||||
document.body.style.cursor = resizeDirection ? cursorMap[resizeDirection] : '';
|
||||
}
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('mousemove', handleResizeMouseMove);
|
||||
window.removeEventListener('mouseup', handleResizeMouseUp);
|
||||
if (!isDragging) {
|
||||
document.body.style.cursor = '';
|
||||
document.body.style.userSelect = '';
|
||||
}
|
||||
};
|
||||
}, [isResizing, resizeDirection, handleResizeMouseMove, handleResizeMouseUp, isDragging]);
|
||||
|
||||
// Close on Escape key (only if not pinned)
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape' && !isPinned) {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||
}, [onClose, isPinned]);
|
||||
|
||||
// Toggle minimize
|
||||
const handleToggleMinimize = useCallback(() => {
|
||||
setIsMinimized(prev => !prev);
|
||||
}, []);
|
||||
|
||||
// Don't render if no position yet
|
||||
if (!position) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const displayTitle = title || t('embeddingProjector');
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={panelRef}
|
||||
className={`conversation-embedding-panel ${isMinimized ? 'conversation-embedding-panel--minimized' : ''} ${simpleMode ? 'conversation-embedding-panel--simple' : ''} ${isDragging ? 'conversation-embedding-panel--dragging' : ''} ${isResizing ? 'conversation-embedding-panel--resizing' : ''}`}
|
||||
style={{
|
||||
transform: `translate(${position.x}px, ${position.y}px)`,
|
||||
width: panelWidth,
|
||||
height: panelHeight,
|
||||
}}
|
||||
>
|
||||
{/* Resize handles (hidden when minimized) */}
|
||||
{!isMinimized && (
|
||||
<>
|
||||
<div className="conversation-embedding-panel__resize-handle conversation-embedding-panel__resize-handle--n" onMouseDown={handleResizeMouseDown('n')} />
|
||||
<div className="conversation-embedding-panel__resize-handle conversation-embedding-panel__resize-handle--s" onMouseDown={handleResizeMouseDown('s')} />
|
||||
<div className="conversation-embedding-panel__resize-handle conversation-embedding-panel__resize-handle--e" onMouseDown={handleResizeMouseDown('e')} />
|
||||
<div className="conversation-embedding-panel__resize-handle conversation-embedding-panel__resize-handle--w" onMouseDown={handleResizeMouseDown('w')} />
|
||||
<div className="conversation-embedding-panel__resize-handle conversation-embedding-panel__resize-handle--ne" onMouseDown={handleResizeMouseDown('ne')} />
|
||||
<div className="conversation-embedding-panel__resize-handle conversation-embedding-panel__resize-handle--nw" onMouseDown={handleResizeMouseDown('nw')} />
|
||||
<div className="conversation-embedding-panel__resize-handle conversation-embedding-panel__resize-handle--se" onMouseDown={handleResizeMouseDown('se')} />
|
||||
<div className="conversation-embedding-panel__resize-handle conversation-embedding-panel__resize-handle--sw" onMouseDown={handleResizeMouseDown('sw')} />
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Draggable Header */}
|
||||
<div
|
||||
className="conversation-embedding-panel__header"
|
||||
onMouseDown={handleMouseDown}
|
||||
style={{ cursor: isDragging ? 'grabbing' : 'grab' }}
|
||||
>
|
||||
<h3 className="conversation-embedding-panel__title">
|
||||
<Layers size={18} />
|
||||
{displayTitle}
|
||||
</h3>
|
||||
|
||||
<div className="conversation-embedding-panel__controls">
|
||||
{/* Sources badge */}
|
||||
{highlightedIndices.length > 0 && (
|
||||
<span className="conversation-embedding-panel__sources-badge">
|
||||
{highlightedIndices.length} {t('sourcesUsed')}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Pin button */}
|
||||
{onTogglePin && (
|
||||
<button
|
||||
className={`conversation-embedding-panel__control-btn ${isPinned ? 'conversation-embedding-panel__control-btn--active' : ''}`}
|
||||
onClick={onTogglePin}
|
||||
title={isPinned ? (language === 'nl' ? 'Losmaken' : 'Unpin') : (language === 'nl' ? 'Vastzetten' : 'Pin')}
|
||||
aria-label={isPinned ? 'Unpin' : 'Pin'}
|
||||
>
|
||||
{isPinned ? <Unlock size={18} strokeWidth={2.5} /> : <Lock size={18} strokeWidth={2.5} />}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Minimize button */}
|
||||
<button
|
||||
className="conversation-embedding-panel__control-btn"
|
||||
onClick={handleToggleMinimize}
|
||||
title={isMinimized ? (language === 'nl' ? 'Uitvouwen' : 'Expand') : (language === 'nl' ? 'Minimaliseren' : 'Minimize')}
|
||||
aria-label={isMinimized ? 'Expand' : 'Minimize'}
|
||||
>
|
||||
{isMinimized ? <Maximize2 size={18} strokeWidth={2.5} /> : <Minimize2 size={18} strokeWidth={2.5} />}
|
||||
</button>
|
||||
|
||||
{/* Close button */}
|
||||
<button
|
||||
className="conversation-embedding-panel__close-btn"
|
||||
onClick={onClose}
|
||||
title={language === 'nl' ? 'Sluiten' : 'Close'}
|
||||
aria-label="Close"
|
||||
>
|
||||
<X size={18} strokeWidth={2.5} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content (hidden when minimized) */}
|
||||
{!isMinimized && (
|
||||
<div className="conversation-embedding-panel__content">
|
||||
{isLoading ? (
|
||||
<div className="conversation-embedding-panel__loading">
|
||||
<Loader2 className="conversation-embedding-panel__loading-icon" size={24} />
|
||||
<span>{t('loadingEmbeddings')}</span>
|
||||
</div>
|
||||
) : points.length > 0 ? (
|
||||
<EmbeddingProjector
|
||||
points={points}
|
||||
title={displayTitle}
|
||||
simpleMode={simpleMode}
|
||||
highlightedIndices={highlightedIndices}
|
||||
showContextButton={true}
|
||||
onContextSelect={onContextSelect}
|
||||
/>
|
||||
) : (
|
||||
<div className="conversation-embedding-panel__empty">
|
||||
<Info size={24} />
|
||||
<p>{language === 'nl' ? 'Geen embeddings beschikbaar' : 'No embeddings available'}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Memoize to prevent unnecessary re-renders
|
||||
export const ConversationEmbeddingPanel = memo(ConversationEmbeddingPanelComponent);
|
||||
|
||||
export default ConversationEmbeddingPanel;
|
||||
|
|
@ -24,3 +24,7 @@ export type { ConversationNetworkGraphProps } from './ConversationNetworkGraph';
|
|||
|
||||
export { ConversationSocialNetworkGraph, convertRetrievedResultsToGraph } from './ConversationSocialNetworkGraph';
|
||||
export type { ConversationSocialNetworkGraphProps } from './ConversationSocialNetworkGraph';
|
||||
|
||||
// Draggable Embedding Projector panel
|
||||
export { ConversationEmbeddingPanel } from './ConversationEmbeddingPanel';
|
||||
export type { ConversationEmbeddingPanelProps } from './ConversationEmbeddingPanel';
|
||||
|
|
|
|||
Loading…
Reference in a new issue