fix(ui): harden queue controls and floating menus

This commit is contained in:
NimBold
2026-07-28 23:49:46 +03:30
parent 807663cf03
commit 23288ca4e8
10 changed files with 828 additions and 160 deletions
+160 -122
View File
@@ -1,4 +1,5 @@
import React, { useState, useEffect, useMemo, useRef, useCallback, useLayoutEffect } from 'react';
import { createPortal } from 'react-dom';
import { useDownloadStore, DownloadItem, MAIN_QUEUE_ID } from '../store/useDownloadStore';
import { useDownloadProgressStore } from '../store/downloadProgressStore';
import { useToast } from '../contexts/ToastContext';
@@ -6,7 +7,7 @@ import { useSettingsStore } from '../store/useSettingsStore';
import { SidebarFilter } from './Sidebar';
import {
Play, Pause, Plus, FileText, Image as ImageIcon, Music, Film, Box, Archive, FileQuestion,
ArrowDownCircle, ArrowUp, ArrowDown, Command, ChevronRight, ChevronUp, ChevronDown, MoreHorizontal,
ArrowDownCircle, ArrowUp, ArrowDown, Command, ChevronUp, ChevronDown, MoreHorizontal,
AlignLeft, AlignCenter, AlignRight, GripVertical
} from 'lucide-react';
import { DownloadItem as DownloadItemComponent } from './DownloadItem';
@@ -52,6 +53,8 @@ import {
moveSelectedBlockToIndex
} from '../utils/queueOrdering';
import { updateDownloadSelection } from '../utils/downloadSelection';
import { clampFloatingPosition } from '../utils/floatingPosition';
import { FloatingQueueSubmenu } from './FloatingQueueSubmenu';
export interface DownloadTableStatusSummary {
summary: DownloadSummary;
@@ -153,7 +156,10 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter, onSummaryC
openDeleteModal,
redownload,
moveInQueue,
moveManyInQueueToPosition
moveManyInQueueToPosition,
startAll,
pauseAll,
startSelected
} = useDownloadStore();
const progressMap = useDownloadProgressStore(state => state.progressMap);
const { addToast } = useToast();
@@ -273,8 +279,11 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter, onSummaryC
const [columnDragState, setColumnDragState] = useState<ColumnDragState | null>(null);
const [columnDragOrder, setColumnDragOrder] = useState<DownloadTableColumnKey[] | null>(null);
const [columnMenu, setColumnMenu] = useState<ColumnMenuState | null>(null);
const [columnMenuPosition, setColumnMenuPosition] = useState<{ x: number; y: number } | null>(null);
const [contextMenuPosition, setContextMenuPosition] = useState<{ x: number; y: number } | null>(null);
const columnMenuRef = useRef<HTMLDivElement>(null);
const contextMenuRef = useRef<HTMLDivElement>(null);
const [columnDropFlashKey, setColumnDropFlashKey] = useState<DownloadTableColumnKey | null>(null);
const [, setMenuViewportVersion] = useState(0);
const columnWidthsRef = useRef(columnWidths);
const columnOrderRef = useRef(columnOrder);
const normalizedColumnWidths = columnWidthsRef.current.map((width, index) =>
@@ -689,30 +698,21 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter, onSummaryC
};
const clampMenuPosition = useCallback((x: number, y: number, menuWidth: number, menuHeight: number) => {
const workspaceRect = downloadsViewRef.current?.getBoundingClientRect();
const minX = Math.max(8, (workspaceRect?.left ?? 0) + 8);
const minY = Math.max(8, (workspaceRect?.top ?? 0) + 8);
const maxX = Math.max(minX, Math.min(
window.innerWidth - menuWidth - 8,
(workspaceRect?.right ?? window.innerWidth) - menuWidth - 8
));
const maxY = Math.max(minY, Math.min(
window.innerHeight - menuHeight - 8,
(workspaceRect?.bottom ?? window.innerHeight) - menuHeight - 8
));
return {
x: Math.min(Math.max(minX, x), maxX),
y: Math.min(Math.max(minY, y), maxY),
};
return clampFloatingPosition(
x,
y,
menuWidth,
menuHeight,
window.innerWidth,
window.innerHeight
);
}, []);
const openColumnMenu = (key: DownloadTableColumnKey, x: number, y: number) => {
const position = clampMenuPosition(x, y, 188, 220);
setContextMenu(null);
setColumnMenu({
key,
...position,
});
setColumnMenuPosition(position);
setColumnMenu({ key, x, y });
};
const setColumnAlignment = (alignment: DownloadColumnAlignment) => {
@@ -837,12 +837,69 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter, onSummaryC
};
}, []);
useEffect(() => {
if (!columnMenu && !contextMenu) return;
const updateMenuViewport = () => setMenuViewportVersion(version => version + 1);
window.addEventListener('resize', updateMenuViewport);
return () => window.removeEventListener('resize', updateMenuViewport);
}, [columnMenu, contextMenu]);
useLayoutEffect(() => {
if (!columnMenu || !columnMenuRef.current) return;
const updateColumnMenuPosition = () => {
const menu = columnMenuRef.current;
if (!menu) return;
const rect = menu.getBoundingClientRect();
const nextPosition = clampMenuPosition(
columnMenu.x,
columnMenu.y,
menu.offsetWidth || rect.width,
menu.offsetHeight || rect.height
);
setColumnMenuPosition(current => (
current?.x === nextPosition.x && current.y === nextPosition.y
? current
: nextPosition
));
};
updateColumnMenuPosition();
const resizeObserver = typeof ResizeObserver === 'undefined'
? null
: new ResizeObserver(updateColumnMenuPosition);
resizeObserver?.observe(columnMenuRef.current);
window.addEventListener('resize', updateColumnMenuPosition);
return () => {
resizeObserver?.disconnect();
window.removeEventListener('resize', updateColumnMenuPosition);
};
}, [columnMenu, clampMenuPosition]);
useLayoutEffect(() => {
if (!contextMenu || !contextMenuRef.current) return;
const updateContextMenuPosition = () => {
const menu = contextMenuRef.current;
if (!menu) return;
const rect = menu.getBoundingClientRect();
const nextPosition = clampMenuPosition(
contextMenu.x,
contextMenu.y,
menu.offsetWidth || rect.width,
menu.offsetHeight || rect.height
);
setContextMenuPosition(current => (
current?.x === nextPosition.x && current.y === nextPosition.y
? current
: nextPosition
));
};
updateContextMenuPosition();
const resizeObserver = typeof ResizeObserver === 'undefined'
? null
: new ResizeObserver(updateContextMenuPosition);
resizeObserver?.observe(contextMenuRef.current);
window.addEventListener('resize', updateContextMenuPosition);
return () => {
resizeObserver?.disconnect();
window.removeEventListener('resize', updateContextMenuPosition);
};
}, [contextMenu, clampMenuPosition]);
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
@@ -1599,6 +1656,10 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter, onSummaryC
() => countDownloadActions(selectedDownloads),
[selectedDownloads]
);
const hasStartableDownloads = downloads.some(download =>
download.status === 'queued' || canStartDownload(download.status)
);
const hasPausableDownloads = downloads.some(download => canPauseDownload(download.status));
const summaryDownloads = selectedDownloads.length > 0 ? selectedDownloads : filteredDownloads;
const downloadSummary = useMemo(
() => summarizeDownloads(summaryDownloads, progressMap),
@@ -1702,10 +1763,9 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter, onSummaryC
setLastSelectedId(menu.id);
}
setColumnMenu(null);
setContextMenu({
...menu,
...clampMenuPosition(menu.x, menu.y, 200, 300),
});
const position = clampMenuPosition(menu.x, menu.y, 200, 300);
setContextMenuPosition(position);
setContextMenu(menu);
}, [clampMenuPosition]);
const handleMoveInQueue = useCallback((id: string, direction: 'up' | 'down') => {
@@ -1804,15 +1864,6 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter, onSummaryC
}
}, [showInteractionError]);
const resumeItemsSequentially = useCallback(async (items: DownloadItem[]) => {
for (const item of items) {
const current = useDownloadStore.getState().downloads.find(download => download.id === item.id);
if (current && canStartDownload(current.status)) {
await handleResume(current);
}
}
}, [handleResume]);
const getCurrentSelectedDownloads = useCallback(() => {
const selected = selectedIdsRef.current;
return useDownloadStore.getState().downloads.filter(download => selected.has(download.id));
@@ -1841,10 +1892,40 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter, onSummaryC
}, [getCurrentSelectedDownloads, handlePause, t]);
const handleResumeSelected = useCallback(() => {
const items = getCurrentSelectedDownloads().filter(download => canStartDownload(download.status));
if (items.length === 0) return;
void resumeItemsSequentially(items);
}, [getCurrentSelectedDownloads, resumeItemsSequentially]);
const ids = Array.from(selectedIdsRef.current);
if (ids.length === 0) return;
void startSelected(ids).catch(error => {
showInteractionError(t($ => $.downloadTable.resumeFailed), error);
});
}, [showInteractionError, startSelected, t]);
const handleStartAll = useCallback(() => {
void startAll().catch(error => {
showInteractionError(t($ => $.downloadTable.resumeFailed), error);
});
}, [showInteractionError, startAll, t]);
const handlePauseAll = useCallback(async () => {
const currentDownloads = useDownloadStore.getState().downloads;
const pausableDownloads = currentDownloads.filter(download => canPauseDownload(download.status));
if (pausableDownloads.length === 0) return;
const nonResumableCount = pausableDownloads.filter(download => download.resumable === false).length;
if (nonResumableCount > 0) {
const confirmPause = window.confirm(
nonResumableCount === 1
? t($ => $.downloadTable.nonResumableOne)
: t($ => $.downloadTable.nonResumableMany, { count: nonResumableCount })
);
if (!confirmPause) return;
}
try {
await pauseAll();
} catch (error) {
showInteractionError(t($ => $.downloadTable.pauseFailed), error);
}
}, [pauseAll, showInteractionError, t]);
const handleDelete = (ids: string | string[]) => {
openDeleteModal(ids);
@@ -1924,12 +2005,6 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter, onSummaryC
const columnAlignmentStyle = (key: DownloadTableColumnKey): React.CSSProperties => ({
'--column-justify': COLUMN_ALIGNMENT_JUSTIFY[columnAlignments[key]],
} as React.CSSProperties);
const columnMenuPosition = columnMenu
? clampMenuPosition(columnMenu.x, columnMenu.y, 188, 220)
: null;
const contextMenuPosition = contextMenu
? clampMenuPosition(contextMenu.x, contextMenu.y, 200, 300)
: null;
const queueDragPreviewItem = queueDragState?.active
? queueDragState.ids
.map(id => queueReorderableDownloads.find(download => download.id === id))
@@ -1956,10 +2031,8 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter, onSummaryC
<button
className="main-control-button"
disabled={sortedDownloads.length === 0}
onClick={() => {
void resumeItemsSequentially(sortedDownloads.filter(d => canStartDownload(d.status)));
}}
disabled={!hasStartableDownloads}
onClick={handleStartAll}
title={t($ => $.downloadTable.resumeAll)}
>
<Play size={15} fill="currentColor" />
@@ -1967,23 +2040,8 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter, onSummaryC
<button
className="main-control-button"
disabled={sortedDownloads.length === 0}
onClick={() => {
const toPause = sortedDownloads.filter(d => canPauseDownload(d.status));
const nonResumableCount = toPause.filter(d => d.resumable === false).length;
if (nonResumableCount > 0) {
const confirmPause = window.confirm(
nonResumableCount === 1
? t($ => $.downloadTable.nonResumableOne)
: t($ => $.downloadTable.nonResumableMany, { count: nonResumableCount })
);
if (!confirmPause) {
return;
}
}
// Skip the individual check by passing a flag to handlePause, or just invoking directly.
toPause.forEach(d => handlePause(d.id, true));
}}
disabled={!hasPausableDownloads}
onClick={() => void handlePauseAll()}
title={t($ => $.downloadTable.pauseAll)}
>
<Pause size={15} fill="currentColor" />
@@ -2246,10 +2304,11 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter, onSummaryC
</div>
</div>
{columnMenu && (
{columnMenu && createPortal(
<div
role="menu"
className="download-column-menu app-modal fixed z-50 min-w-[188px] overflow-hidden py-1.5 text-[12px] font-medium text-text-primary"
ref={columnMenuRef}
className="download-column-menu app-modal fixed z-[70] min-w-[188px] max-h-[calc(100vh-16px)] overflow-y-auto overflow-x-hidden py-1.5 text-[12px] font-medium text-text-primary"
style={{ top: columnMenuPosition?.y, left: columnMenuPosition?.x }}
onClick={event => event.stopPropagation()}
>
@@ -2285,14 +2344,16 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter, onSummaryC
<GripVertical size={14} aria-hidden="true" />
<span>{t($ => $.downloadTable.columnOptions.resetLayout)}</span>
</button>
</div>
</div>,
document.body
)}
{/* Floating Context Menu */}
{contextMenu && contextItem && (
{contextMenu && contextItem && createPortal(
<div
role="menu"
className="app-modal fixed z-50 min-w-[180px] overflow-visible py-1.5 text-[12px] font-medium text-text-primary"
ref={contextMenuRef}
className="app-modal fixed z-[70] min-w-[180px] max-h-[calc(100vh-16px)] overflow-y-auto overflow-x-hidden py-1.5 text-[12px] font-medium text-text-primary"
style={{
top: contextMenuPosition?.y,
left: contextMenuPosition?.x,
@@ -2340,28 +2401,16 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter, onSummaryC
)}
{itemsToQueue.length > 0 && (
<div className="group relative">
<button
type="button"
aria-haspopup="menu"
className="w-full text-left px-3 py-2 hover:bg-item-hover transition-colors flex justify-between items-center"
>
{t($ => $.downloadTable.addToQueue)}
<ChevronRight size={14} className="download-context-menu-chevron" />
</button>
<div className="download-context-submenu absolute top-0 min-w-[150px] bg-bg-modal border border-border-modal rounded-lg shadow-lg py-1.5 z-50" role="menu">
{queues.map(q => (
<button key={q.id} role="menuitem" onClick={() => {
setContextMenu(null);
void assignToQueue(itemsToQueue.map(item => item.id), q.id).catch(error => {
showInteractionError(t($ => $.downloadTable.moveManyFailed), error);
});
}} className="w-full text-left px-3 py-2 hover:bg-item-hover transition-colors text-[12px]">
{q.name}
</button>
))}
</div>
</div>
<FloatingQueueSubmenu
label={t($ => $.downloadTable.addToQueue)}
queues={queues}
onSelect={q => {
setContextMenu(null);
void assignToQueue(itemsToQueue.map(item => item.id), q.id).catch(error => {
showInteractionError(t($ => $.downloadTable.moveManyFailed), error);
});
}}
/>
)}
<div className="h-[1px] bg-border-modal/60 my-1.5 mx-2"></div>
@@ -2463,28 +2512,16 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter, onSummaryC
)}
{contextItem.status !== 'completed' && (
<div className="group relative">
<button
type="button"
aria-haspopup="menu"
className="w-full text-left px-3 py-2 hover:bg-item-hover transition-colors flex justify-between items-center"
>
{t($ => $.downloadTable.addToQueue)}
<ChevronRight size={14} className="download-context-menu-chevron" />
</button>
<div className="download-context-submenu absolute top-0 min-w-[150px] bg-bg-modal border border-border-modal rounded-lg shadow-lg py-1.5 z-50" role="menu">
{queues.map(q => (
<button key={q.id} role="menuitem" onClick={() => {
setContextMenu(null);
void assignToQueue([contextItem.id], q.id).catch(error => {
showInteractionError(t($ => $.downloadTable.moveOneFailed), error);
});
}} className="w-full text-left px-3 py-2 hover:bg-item-hover transition-colors text-[12px]">
{q.name}
</button>
))}
</div>
</div>
<FloatingQueueSubmenu
label={t($ => $.downloadTable.addToQueue)}
queues={queues}
onSelect={q => {
setContextMenu(null);
void assignToQueue([contextItem.id], q.id).catch(error => {
showInteractionError(t($ => $.downloadTable.moveOneFailed), error);
});
}}
/>
)}
<div className="h-[1px] bg-border-modal/60 my-1.5 mx-2"></div>
@@ -2547,7 +2584,8 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter, onSummaryC
</button>
</>
)}
</div>
</div>,
document.body
)}
</div>
+195
View File
@@ -0,0 +1,195 @@
import React, { useCallback, useLayoutEffect, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import { ChevronRight } from 'lucide-react';
import type { Queue } from '../store/useDownloadStore';
import { positionFloatingSubmenu, type FloatingSubmenuPosition } from '../utils/floatingPosition';
interface FloatingQueueSubmenuProps {
label: React.ReactNode;
queues: Queue[];
onSelect: (queue: Queue) => void;
}
const CLOSE_DELAY = 140;
export const FloatingQueueSubmenu: React.FC<FloatingQueueSubmenuProps> = ({ label, queues, onSelect }) => {
const triggerRef = useRef<HTMLDivElement>(null);
const menuRef = useRef<HTMLDivElement>(null);
const closeTimerRef = useRef<number | null>(null);
const [isOpen, setIsOpen] = useState(false);
const [position, setPosition] = useState<FloatingSubmenuPosition | null>(null);
const isRtl = typeof document !== 'undefined' && document.documentElement.dir === 'rtl';
const openKey = isRtl ? 'ArrowLeft' : 'ArrowRight';
const closeKey = isRtl ? 'ArrowRight' : 'ArrowLeft';
const clearCloseTimer = useCallback(() => {
if (closeTimerRef.current !== null) {
window.clearTimeout(closeTimerRef.current);
closeTimerRef.current = null;
}
}, []);
const closeMenu = useCallback(() => {
clearCloseTimer();
setIsOpen(false);
setPosition(null);
}, [clearCloseTimer]);
const scheduleClose = useCallback(() => {
clearCloseTimer();
closeTimerRef.current = window.setTimeout(() => {
const activeElement = document.activeElement;
if (
triggerRef.current?.contains(activeElement) ||
menuRef.current?.contains(activeElement)
) {
closeTimerRef.current = null;
return;
}
closeTimerRef.current = null;
setIsOpen(false);
setPosition(null);
}, CLOSE_DELAY);
}, [clearCloseTimer]);
const openMenu = useCallback(() => {
clearCloseTimer();
setIsOpen(true);
}, [clearCloseTimer]);
useLayoutEffect(() => {
if (!isOpen) return;
const updatePosition = () => {
const trigger = triggerRef.current;
const menu = menuRef.current;
if (!trigger || !menu) return;
const triggerRect = trigger.getBoundingClientRect();
const menuRect = menu.getBoundingClientRect();
const nextPosition = positionFloatingSubmenu(
triggerRect,
menu.offsetWidth || menuRect.width,
menu.offsetHeight || menuRect.height,
window.innerWidth,
window.innerHeight,
8,
4,
isRtl ? 'left' : 'right'
);
setPosition(current => (
current?.x === nextPosition.x &&
current.y === nextPosition.y &&
current.side === nextPosition.side
? current
: nextPosition
));
};
updatePosition();
const resizeObserver = typeof ResizeObserver === 'undefined'
? null
: new ResizeObserver(updatePosition);
if (menuRef.current) resizeObserver?.observe(menuRef.current);
window.addEventListener('resize', updatePosition);
window.addEventListener('scroll', updatePosition, true);
return () => {
resizeObserver?.disconnect();
window.removeEventListener('resize', updatePosition);
window.removeEventListener('scroll', updatePosition, true);
};
}, [isOpen, isRtl]);
useLayoutEffect(() => () => clearCloseTimer(), [clearCloseTimer]);
return (
<div
ref={triggerRef}
className="relative"
onMouseEnter={openMenu}
onMouseLeave={scheduleClose}
onFocus={openMenu}
onBlur={event => {
if (!event.currentTarget.contains(event.relatedTarget as Node | null)) {
scheduleClose();
}
}}
>
<button
type="button"
aria-haspopup="menu"
aria-expanded={isOpen}
className="w-full text-start px-3 py-2 hover:bg-item-hover transition-colors flex justify-between items-center"
onClick={event => {
event.stopPropagation();
if (isOpen) closeMenu();
else openMenu();
}}
onKeyDown={event => {
if (event.key === 'Escape') {
event.preventDefault();
event.stopPropagation();
closeMenu();
event.currentTarget.focus();
}
if (event.key === openKey) {
event.preventDefault();
openMenu();
window.requestAnimationFrame(() => menuRef.current?.querySelector<HTMLElement>('[role="menuitem"]')?.focus());
}
if (event.key === closeKey && isOpen) {
event.preventDefault();
event.stopPropagation();
closeMenu();
event.currentTarget.focus();
}
}}
>
{label}
<ChevronRight size={14} aria-hidden="true" className="download-context-menu-chevron" />
</button>
{isOpen && createPortal(
<div
ref={menuRef}
role="menu"
data-side={position?.side}
className={`download-context-submenu fixed min-w-[150px] max-w-[min(280px,calc(100vw-16px))] max-h-[calc(100vh-16px)] overflow-y-auto overflow-x-hidden bg-bg-modal border border-border-modal rounded-lg shadow-lg py-1.5 z-[80] ${position ? 'is-open' : ''}`}
style={{
top: position?.y ?? 8,
left: position?.x ?? 8,
}}
onMouseEnter={clearCloseTimer}
onMouseLeave={scheduleClose}
onFocus={clearCloseTimer}
onBlur={event => {
if (!event.currentTarget.contains(event.relatedTarget as Node | null)) {
scheduleClose();
}
}}
onKeyDown={event => {
if (event.key !== 'Escape' && event.key !== 'ArrowLeft') return;
event.preventDefault();
event.stopPropagation();
closeMenu();
triggerRef.current?.querySelector<HTMLElement>('button')?.focus();
}}
onClick={event => event.stopPropagation()}
>
{queues.map(queue => (
<button
key={queue.id}
type="button"
role="menuitem"
title={queue.name}
className="w-full min-w-0 text-start px-3 py-2 hover:bg-item-hover transition-colors text-[12px] truncate"
onClick={() => onSelect(queue)}
>
{queue.name}
</button>
))}
</div>,
document.body
)}
</div>
);
};
+47 -5
View File
@@ -1,4 +1,5 @@
import { useEffect, useRef, useState } from 'react';
import { useEffect, useLayoutEffect, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import { invokeCommand as invoke } from '../ipc';
import { save } from '@tauri-apps/plugin-dialog';
import { homeDir } from '@tauri-apps/api/path';
@@ -8,6 +9,7 @@ import { WindowDragRegion } from './WindowDragRegion';
import { useToast } from '../contexts/ToastContext';
import { useSettingsStore } from '../store/useSettingsStore';
import { useTranslation } from 'react-i18next';
import { clampFloatingPosition } from '../utils/floatingPosition';
import {
MAX_LOG_LINES,
appendBoundedLogEntries,
@@ -26,6 +28,8 @@ export default function LogsView() {
const [logs, setLogs] = useState<LogEntry[]>([]);
const [levelFilter, setLevelFilter] = useState<LogEntry['level'] | 'All'>('All');
const [contextMenu, setContextMenu] = useState<{ x: number; y: number; text: string } | null>(null);
const [contextMenuPosition, setContextMenuPosition] = useState<{ x: number; y: number } | null>(null);
const contextMenuRef = useRef<HTMLDivElement>(null);
const [pageVisible, setPageVisible] = useState(() => document.visibilityState !== 'hidden');
const homeDirectoryRef = useRef('');
const scrollRef = useRef<HTMLDivElement>(null);
@@ -161,12 +165,48 @@ export default function LogsView() {
e.preventDefault();
const selection = window.getSelection()?.toString();
if (selection && selection.trim().length > 0) {
const position = clampFloatingPosition(e.clientX, e.clientY, 150, 50, window.innerWidth, window.innerHeight);
setContextMenuPosition(position);
setContextMenu({ x: e.clientX, y: e.clientY, text: selection });
} else {
setContextMenu(null);
}
};
useLayoutEffect(() => {
if (!contextMenu || !contextMenuRef.current) return;
const updateContextMenuPosition = () => {
const menu = contextMenuRef.current;
if (!menu) return;
const rect = menu.getBoundingClientRect();
const nextPosition = clampFloatingPosition(
contextMenu.x,
contextMenu.y,
menu.offsetWidth || rect.width,
menu.offsetHeight || rect.height,
window.innerWidth,
window.innerHeight
);
setContextMenuPosition(current => (
current?.x === nextPosition.x && current.y === nextPosition.y
? current
: nextPosition
));
};
updateContextMenuPosition();
const resizeObserver = typeof ResizeObserver === 'undefined'
? null
: new ResizeObserver(updateContextMenuPosition);
resizeObserver?.observe(contextMenuRef.current);
window.addEventListener('resize', updateContextMenuPosition);
return () => {
resizeObserver?.disconnect();
window.removeEventListener('resize', updateContextMenuPosition);
};
}, [contextMenu]);
const handleCopy = async () => {
if (contextMenu?.text) {
try {
@@ -354,11 +394,12 @@ export default function LogsView() {
</div>
{/* Context Menu */}
{contextMenu && (
{contextMenu && createPortal(
<div
role="menu"
className="app-modal fixed z-50 min-w-[150px] overflow-visible py-1.5 text-[12px] font-medium text-text-primary"
style={{ left: Math.min(contextMenu.x, window.innerWidth - 150), top: Math.min(contextMenu.y, window.innerHeight - 50) }}
ref={contextMenuRef}
className="app-modal fixed z-[70] min-w-[150px] max-h-[calc(100vh-16px)] overflow-y-auto overflow-x-hidden py-1.5 text-[12px] font-medium text-text-primary"
style={{ left: contextMenuPosition?.x, top: contextMenuPosition?.y }}
onClick={(e) => e.stopPropagation()}
>
<button
@@ -368,7 +409,8 @@ export default function LogsView() {
<Copy size={13} className="me-2 text-text-secondary" />
{t($ => $.logs.copy)}
</button>
</div>
</div>,
document.body
)}
</div>
);