mirror of
https://github.com/Frooodle/Stirling-PDF.git
synced 2025-12-18 20:04:17 +01:00
Refactor legacy UI: performance, accessibility, and styling improvements
Performance: - Extract geometry calculation logic into useToolPanelGeometry hook with debounced resize handling (150ms) - Create reusable useLocalStorageState hook for cleaner state persistence - Optimize hook dependencies and memoization Code Organization: - Extract 80+ lines of geometry logic into dedicated hook - Create useFocusTrap hook for accessibility features - Add ToolPanelGeometry interface for type safety - Reduce ToolPanel component complexity Accessibility: - Add focus trap to legacy surface for keyboard navigation - Implement Tab/Shift+Tab cycling within modal - Respect prefers-reduced-motion for animations UX Improvements: - Add smooth exit animation for legacy mode (220ms slide-out) - Skip animations when reduced motion is preferred - Tool panel mode preference already persisted via ToolWorkflowContext Styling: - Add 27 CSS custom properties to centralize color-mix patterns - Replace pink accent colors with neutral tones matching UI - Remove gradient from legacy body background - Maintain tool item borders and hover effects with neutral colors 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
667a9b4867
commit
6ea920e9a7
@ -1,4 +1,4 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useEffect, useState, useRef } from 'react';
|
||||
import { ActionIcon, Group, ScrollArea, Switch, Text, Tooltip } from '@mantine/core';
|
||||
import ViewSidebarRoundedIcon from '@mui/icons-material/ViewSidebarRounded';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
@ -6,6 +6,7 @@ import ToolSearch from './toolPicker/ToolSearch';
|
||||
import LegacyToolList from './LegacyToolList';
|
||||
import { ToolRegistryEntry } from '../../data/toolsTaxonomy';
|
||||
import { ToolId } from '../../types/toolId';
|
||||
import { useFocusTrap } from '../../hooks/tools/useFocusTrap';
|
||||
import './ToolPanel.css';
|
||||
|
||||
interface LegacyToolSurfaceProps {
|
||||
@ -43,17 +44,36 @@ const LegacyToolSurface = ({
|
||||
geometry,
|
||||
}: LegacyToolSurfaceProps) => {
|
||||
const { t } = useTranslation();
|
||||
const [isExiting, setIsExiting] = useState(false);
|
||||
const surfaceRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Enable focus trap when surface is active
|
||||
useFocusTrap(surfaceRef, !isExiting);
|
||||
|
||||
const handleExit = () => {
|
||||
const prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||
|
||||
if (prefersReducedMotion) {
|
||||
onExitLegacyMode();
|
||||
return;
|
||||
}
|
||||
|
||||
setIsExiting(true);
|
||||
setTimeout(() => {
|
||||
onExitLegacyMode();
|
||||
}, 220); // Match animation duration (0.22s)
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') {
|
||||
onExitLegacyMode();
|
||||
handleExit();
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||
}, [onExitLegacyMode]);
|
||||
}, []);
|
||||
|
||||
const style = geometry
|
||||
? {
|
||||
@ -71,7 +91,10 @@ const LegacyToolSurface = ({
|
||||
role="region"
|
||||
aria-label={t('toolPanel.legacy.heading', 'All tools (legacy view)')}
|
||||
>
|
||||
<div className="tool-panel__legacy-surface-inner">
|
||||
<div
|
||||
ref={surfaceRef}
|
||||
className={`tool-panel__legacy-surface-inner ${isExiting ? 'tool-panel__legacy-surface-inner--exiting' : ''}`}
|
||||
>
|
||||
<header className="tool-panel__legacy-header">
|
||||
<div className="tool-panel__legacy-heading">
|
||||
<Text fw={700} size="lg">
|
||||
@ -86,7 +109,7 @@ const LegacyToolSurface = ({
|
||||
variant="subtle"
|
||||
radius="xl"
|
||||
size="lg"
|
||||
onClick={onExitLegacyMode}
|
||||
onClick={handleExit}
|
||||
aria-label={toggleLabel}
|
||||
>
|
||||
<ViewSidebarRoundedIcon fontSize="small" />
|
||||
|
||||
@ -1,3 +1,32 @@
|
||||
/* CSS Custom Properties for Legacy Mode */
|
||||
.tool-panel__legacy-surface-inner {
|
||||
--legacy-bg-surface-1: color-mix(in srgb, var(--bg-toolbar) 96%, transparent);
|
||||
--legacy-bg-surface-2: color-mix(in srgb, var(--bg-background) 90%, transparent);
|
||||
--legacy-bg-header: color-mix(in srgb, var(--bg-toolbar) 86%, transparent);
|
||||
--legacy-bg-controls-1: color-mix(in srgb, var(--bg-toolbar) 84%, transparent);
|
||||
--legacy-bg-controls-2: color-mix(in srgb, var(--bg-background) 72%, transparent);
|
||||
--legacy-bg-body-1: color-mix(in srgb, var(--bg-background) 86%, transparent);
|
||||
--legacy-bg-body-2: color-mix(in srgb, var(--bg-toolbar) 78%, transparent);
|
||||
--legacy-bg-group: color-mix(in srgb, var(--bg-toolbar) 82%, transparent);
|
||||
--legacy-bg-item: color-mix(in srgb, var(--bg-toolbar) 88%, transparent);
|
||||
--legacy-bg-list-item: color-mix(in srgb, var(--bg-toolbar) 86%, transparent);
|
||||
--legacy-bg-icon-detailed: color-mix(in srgb, var(--bg-muted) 75%, transparent);
|
||||
--legacy-bg-icon-compact: color-mix(in srgb, var(--bg-muted) 70%, transparent);
|
||||
--legacy-border-subtle-75: color-mix(in srgb, var(--border-subtle) 75%, transparent);
|
||||
--legacy-border-subtle-70: color-mix(in srgb, var(--border-subtle) 70%, transparent);
|
||||
--legacy-border-subtle-65: color-mix(in srgb, var(--border-subtle) 65%, transparent);
|
||||
--legacy-shadow-primary: color-mix(in srgb, var(--shadow-color, rgba(15, 23, 42, 0.55)) 25%, transparent);
|
||||
--legacy-shadow-secondary: color-mix(in srgb, var(--shadow-color, rgba(15, 23, 42, 0.35)) 30%, transparent);
|
||||
--legacy-shadow-group: color-mix(in srgb, var(--shadow-color, rgba(15, 23, 42, 0.45)) 18%, transparent);
|
||||
--legacy-accent-hover: color-mix(in srgb, var(--text-primary) 20%, var(--border-subtle));
|
||||
--legacy-accent-selected: color-mix(in srgb, var(--text-primary) 30%, var(--border-subtle));
|
||||
--legacy-accent-ring: color-mix(in srgb, var(--text-primary) 15%, transparent);
|
||||
--legacy-accent-list-bg: color-mix(in srgb, var(--text-primary) 8%, var(--bg-toolbar));
|
||||
--legacy-accent-list-border: color-mix(in srgb, var(--text-primary) 20%, var(--border-subtle));
|
||||
--legacy-text-icon: color-mix(in srgb, var(--text-primary) 90%, var(--text-muted));
|
||||
--legacy-text-icon-compact: color-mix(in srgb, var(--text-primary) 88%, var(--text-muted));
|
||||
}
|
||||
|
||||
.tool-panel {
|
||||
position: relative;
|
||||
transition: width 0.3s ease, max-width 0.3s ease;
|
||||
@ -62,17 +91,21 @@
|
||||
background:
|
||||
linear-gradient(
|
||||
140deg,
|
||||
color-mix(in srgb, var(--bg-toolbar) 96%, transparent),
|
||||
color-mix(in srgb, var(--bg-background) 90%, transparent)
|
||||
var(--legacy-bg-surface-1),
|
||||
var(--legacy-bg-surface-2)
|
||||
)
|
||||
padding-box;
|
||||
border: 1px solid color-mix(in srgb, var(--border-subtle) 75%, transparent);
|
||||
border: 1px solid var(--legacy-border-subtle-75);
|
||||
box-shadow:
|
||||
0 24px 64px color-mix(in srgb, var(--shadow-color, rgba(15, 23, 42, 0.55)) 25%, transparent),
|
||||
0 6px 18px color-mix(in srgb, var(--shadow-color, rgba(15, 23, 42, 0.35)) 30%, transparent);
|
||||
0 24px 64px var(--legacy-shadow-primary),
|
||||
0 6px 18px var(--legacy-shadow-secondary);
|
||||
backdrop-filter: blur(18px);
|
||||
overflow: hidden;
|
||||
animation: tool-panel-legacy-slide 0.28s ease forwards;
|
||||
animation: tool-panel-legacy-slide-in 0.28s ease forwards;
|
||||
}
|
||||
|
||||
.tool-panel__legacy-surface-inner--exiting {
|
||||
animation: tool-panel-legacy-slide-out 0.22s ease forwards;
|
||||
}
|
||||
|
||||
.tool-panel__legacy-header {
|
||||
@ -81,10 +114,10 @@
|
||||
align-items: flex-start;
|
||||
gap: 1rem;
|
||||
padding: 1.25rem 1.75rem;
|
||||
border-bottom: 1px solid color-mix(in srgb, var(--border-subtle) 70%, transparent);
|
||||
border-bottom: 1px solid var(--legacy-border-subtle-70);
|
||||
background: linear-gradient(
|
||||
180deg,
|
||||
color-mix(in srgb, var(--bg-toolbar) 86%, transparent),
|
||||
var(--legacy-bg-header),
|
||||
transparent 85%
|
||||
);
|
||||
}
|
||||
@ -100,11 +133,11 @@
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
padding: 1rem 1.75rem;
|
||||
border-bottom: 1px solid color-mix(in srgb, var(--border-subtle) 70%, transparent);
|
||||
border-bottom: 1px solid var(--legacy-border-subtle-70);
|
||||
background: linear-gradient(
|
||||
180deg,
|
||||
color-mix(in srgb, var(--bg-toolbar) 84%, transparent),
|
||||
color-mix(in srgb, var(--bg-background) 72%, transparent)
|
||||
var(--legacy-bg-controls-1),
|
||||
var(--legacy-bg-controls-2)
|
||||
);
|
||||
}
|
||||
|
||||
@ -115,11 +148,7 @@
|
||||
.tool-panel__legacy-body {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
background: linear-gradient(
|
||||
180deg,
|
||||
color-mix(in srgb, var(--bg-background) 86%, transparent),
|
||||
color-mix(in srgb, var(--bg-toolbar) 78%, transparent)
|
||||
);
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.tool-panel__legacy-scroll {
|
||||
@ -149,9 +178,9 @@
|
||||
margin: 0 0 1.5rem;
|
||||
padding: 0.65rem 0.75rem 1rem;
|
||||
border-radius: 1rem;
|
||||
background: color-mix(in srgb, var(--bg-toolbar) 82%, transparent);
|
||||
border: 1px solid color-mix(in srgb, var(--border-subtle) 65%, transparent);
|
||||
box-shadow: 0 14px 32px color-mix(in srgb, var(--shadow-color, rgba(15, 23, 42, 0.45)) 18%, transparent);
|
||||
background: var(--legacy-bg-group);
|
||||
border: 1px solid var(--legacy-border-subtle-65);
|
||||
box-shadow: 0 14px 32px var(--legacy-shadow-group);
|
||||
break-inside: avoid;
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
@ -179,9 +208,9 @@
|
||||
gap: 0.75rem;
|
||||
align-items: flex-start;
|
||||
padding: 0.85rem 0.95rem;
|
||||
border: 1px solid color-mix(in srgb, var(--border-subtle) 70%, transparent);
|
||||
border: 1px solid var(--legacy-border-subtle-70);
|
||||
border-radius: 0.95rem;
|
||||
background: color-mix(in srgb, var(--bg-toolbar) 88%, transparent);
|
||||
background: var(--legacy-bg-item);
|
||||
backdrop-filter: blur(6px);
|
||||
cursor: pointer;
|
||||
transition: transform 0.2s ease, border-color 0.2s ease, box-shadow 0.2s ease;
|
||||
@ -190,7 +219,7 @@
|
||||
}
|
||||
|
||||
.tool-panel__legacy-item:focus-visible {
|
||||
outline: 2px solid var(--accent-primary, var(--mantine-color-pink-6));
|
||||
outline: 2px solid var(--legacy-accent-selected);
|
||||
outline-offset: 3px;
|
||||
}
|
||||
|
||||
@ -202,13 +231,13 @@
|
||||
|
||||
.tool-panel__legacy-item:hover:not([aria-disabled="true"]):not(:disabled) {
|
||||
transform: translateY(-2px);
|
||||
border-color: color-mix(in srgb, var(--accent-primary, var(--mantine-color-pink-6)) 38%, var(--border-subtle));
|
||||
border-color: var(--legacy-accent-hover);
|
||||
box-shadow: var(--shadow-xl, 0 18px 34px rgba(15, 23, 42, 0.14));
|
||||
}
|
||||
|
||||
.tool-panel__legacy-item--selected {
|
||||
border-color: color-mix(in srgb, var(--accent-primary, var(--mantine-color-pink-6)) 52%, var(--border-subtle));
|
||||
box-shadow: 0 0 0 2px color-mix(in srgb, var(--accent-primary, var(--mantine-color-pink-6)) 28%, transparent);
|
||||
border-color: var(--legacy-accent-selected);
|
||||
box-shadow: 0 0 0 2px var(--legacy-accent-ring);
|
||||
}
|
||||
|
||||
.tool-panel__legacy-item--detailed {
|
||||
@ -222,8 +251,8 @@
|
||||
width: 2.75rem;
|
||||
height: 2.75rem;
|
||||
border-radius: 0.75rem;
|
||||
background: color-mix(in srgb, var(--bg-muted) 75%, transparent);
|
||||
color: color-mix(in srgb, var(--text-primary) 90%, var(--text-muted));
|
||||
background: var(--legacy-bg-icon-detailed);
|
||||
color: var(--legacy-text-icon);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@ -265,7 +294,7 @@
|
||||
border-radius: 0.65rem;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s ease, transform 0.2s ease;
|
||||
background: color-mix(in srgb, var(--bg-toolbar) 86%, transparent);
|
||||
background: var(--legacy-bg-list-item);
|
||||
border: 1px solid transparent;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
@ -279,8 +308,8 @@
|
||||
|
||||
.tool-panel__legacy-list-item:hover:not([aria-disabled="true"]):not(:disabled),
|
||||
.tool-panel__legacy-list-item--selected {
|
||||
background: color-mix(in srgb, var(--accent-primary, var(--mantine-color-pink-6)) 22%, var(--bg-toolbar));
|
||||
border-color: color-mix(in srgb, var(--accent-primary, var(--mantine-color-pink-6)) 30%, var(--border-subtle));
|
||||
background: var(--legacy-accent-list-bg);
|
||||
border-color: var(--legacy-accent-list-border);
|
||||
}
|
||||
|
||||
.tool-panel__legacy-list-item--selected {
|
||||
@ -294,8 +323,8 @@
|
||||
width: 2.1rem;
|
||||
height: 2.1rem;
|
||||
border-radius: 0.6rem;
|
||||
background: color-mix(in srgb, var(--bg-muted) 70%, transparent);
|
||||
color: color-mix(in srgb, var(--text-primary) 88%, var(--text-muted));
|
||||
background: var(--legacy-bg-icon-compact);
|
||||
color: var(--legacy-text-icon-compact);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@ -314,7 +343,7 @@
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
@keyframes tool-panel-legacy-slide {
|
||||
@keyframes tool-panel-legacy-slide-in {
|
||||
from {
|
||||
transform: translateX(-6%) scaleX(0.85);
|
||||
opacity: 0;
|
||||
@ -325,6 +354,32 @@
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes tool-panel-legacy-slide-out {
|
||||
from {
|
||||
transform: translateX(0) scaleX(1);
|
||||
opacity: 1;
|
||||
}
|
||||
to {
|
||||
transform: translateX(-6%) scaleX(0.85);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.tool-panel__legacy-surface-inner {
|
||||
animation: none !important;
|
||||
}
|
||||
|
||||
.tool-panel__mode-toggle {
|
||||
transition: none;
|
||||
}
|
||||
|
||||
.tool-panel__legacy-item,
|
||||
.tool-panel__legacy-list-item {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1440px) {
|
||||
.tool-panel__legacy-content {
|
||||
padding-inline: 1.5rem;
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { useEffect, useMemo, useState, useLayoutEffect } from 'react';
|
||||
import { useMemo } from 'react';
|
||||
import { useRainbowThemeContext } from '../shared/RainbowThemeProvider';
|
||||
import { useToolWorkflow } from '../../contexts/ToolWorkflowContext';
|
||||
import ToolPicker from './ToolPicker';
|
||||
@ -14,6 +14,8 @@ import ViewSidebarRoundedIcon from '@mui/icons-material/ViewSidebarRounded';
|
||||
import DashboardCustomizeRoundedIcon from '@mui/icons-material/DashboardCustomizeRounded';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import LegacyToolSurface from './LegacyToolSurface';
|
||||
import { useToolPanelGeometry } from '../../hooks/tools/useToolPanelGeometry';
|
||||
import { useLocalStorageState } from '../../hooks/tools/useLocalStorageState';
|
||||
import './ToolPanel.css';
|
||||
|
||||
// No props needed - component uses context
|
||||
@ -42,81 +44,15 @@ export default function ToolPanel() {
|
||||
|
||||
const isLegacyMode = toolPanelMode === 'legacy';
|
||||
const legacyExpanded = isLegacyMode && leftPanelView === 'toolPicker' && !isMobile;
|
||||
const [legacyGeometry, setLegacyGeometry] = useState<{ left: number; top: number; width: number; height: number } | null>(null);
|
||||
|
||||
const LEGACY_DESCRIPTION_STORAGE_KEY = 'legacyToolDescriptions';
|
||||
const [showLegacyDescriptions, setShowLegacyDescriptions] = useState(() => {
|
||||
if (typeof window === 'undefined') {
|
||||
return true;
|
||||
}
|
||||
|
||||
const stored = window.localStorage.getItem(LEGACY_DESCRIPTION_STORAGE_KEY);
|
||||
if (stored === null) {
|
||||
return true;
|
||||
}
|
||||
return stored === 'true';
|
||||
// Use custom hooks for state management
|
||||
const [showLegacyDescriptions, setShowLegacyDescriptions] = useLocalStorageState('legacyToolDescriptions', true);
|
||||
const legacyGeometry = useToolPanelGeometry({
|
||||
enabled: legacyExpanded,
|
||||
toolPanelRef,
|
||||
quickAccessRef,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined') {
|
||||
return;
|
||||
}
|
||||
|
||||
window.localStorage.setItem(LEGACY_DESCRIPTION_STORAGE_KEY, String(showLegacyDescriptions));
|
||||
}, [showLegacyDescriptions]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!legacyExpanded) {
|
||||
setLegacyGeometry(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const panelEl = toolPanelRef.current;
|
||||
if (!panelEl) {
|
||||
setLegacyGeometry(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const rightRailEl = () => document.querySelector('[data-sidebar="right-rail"]') as HTMLElement | null;
|
||||
|
||||
const updateGeometry = () => {
|
||||
const rect = panelEl.getBoundingClientRect();
|
||||
const rail = rightRailEl();
|
||||
const rightOffset = rail ? Math.max(0, window.innerWidth - rail.getBoundingClientRect().left) : 0;
|
||||
const width = Math.max(360, window.innerWidth - rect.left - rightOffset);
|
||||
const height = Math.max(rect.height, window.innerHeight - rect.top);
|
||||
setLegacyGeometry({
|
||||
left: rect.left,
|
||||
top: rect.top,
|
||||
width,
|
||||
height,
|
||||
});
|
||||
};
|
||||
|
||||
updateGeometry();
|
||||
|
||||
const handleResize = () => updateGeometry();
|
||||
window.addEventListener('resize', handleResize);
|
||||
|
||||
let resizeObserver: ResizeObserver | null = null;
|
||||
if (typeof ResizeObserver !== 'undefined') {
|
||||
resizeObserver = new ResizeObserver(() => updateGeometry());
|
||||
resizeObserver.observe(panelEl);
|
||||
if (quickAccessRef.current) {
|
||||
resizeObserver.observe(quickAccessRef.current);
|
||||
}
|
||||
const rail = rightRailEl();
|
||||
if (rail) {
|
||||
resizeObserver.observe(rail);
|
||||
}
|
||||
}
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('resize', handleResize);
|
||||
resizeObserver?.disconnect();
|
||||
};
|
||||
}, [legacyExpanded, quickAccessRef, toolPanelRef]);
|
||||
|
||||
const toggleLabel = isLegacyMode
|
||||
? t('toolPanel.toggle.sidebar', 'Switch to sidebar mode')
|
||||
: t('toolPanel.toggle.legacy', 'Switch to legacy mode');
|
||||
|
||||
74
frontend/src/hooks/tools/useFocusTrap.ts
Normal file
74
frontend/src/hooks/tools/useFocusTrap.ts
Normal file
@ -0,0 +1,74 @@
|
||||
import { useEffect, RefObject } from 'react';
|
||||
|
||||
const FOCUSABLE_ELEMENTS = [
|
||||
'a[href]',
|
||||
'button:not([disabled])',
|
||||
'input:not([disabled])',
|
||||
'select:not([disabled])',
|
||||
'textarea:not([disabled])',
|
||||
'[tabindex]:not([tabindex="-1"])',
|
||||
].join(', ');
|
||||
|
||||
export function useFocusTrap(containerRef: RefObject<HTMLElement>, enabled: boolean = true) {
|
||||
useEffect(() => {
|
||||
if (!enabled || !containerRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const container = containerRef.current;
|
||||
const getFocusableElements = () =>
|
||||
Array.from(container.querySelectorAll<HTMLElement>(FOCUSABLE_ELEMENTS));
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key !== 'Tab') {
|
||||
return;
|
||||
}
|
||||
|
||||
const focusableElements = getFocusableElements();
|
||||
if (focusableElements.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const firstElement = focusableElements[0];
|
||||
const lastElement = focusableElements[focusableElements.length - 1];
|
||||
const activeElement = document.activeElement as HTMLElement;
|
||||
|
||||
// Check if focus is within the container
|
||||
if (!container.contains(activeElement)) {
|
||||
event.preventDefault();
|
||||
firstElement.focus();
|
||||
return;
|
||||
}
|
||||
|
||||
// Shift + Tab (backwards)
|
||||
if (event.shiftKey) {
|
||||
if (activeElement === firstElement) {
|
||||
event.preventDefault();
|
||||
lastElement.focus();
|
||||
}
|
||||
}
|
||||
// Tab (forwards)
|
||||
else {
|
||||
if (activeElement === lastElement) {
|
||||
event.preventDefault();
|
||||
firstElement.focus();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Focus first element on mount
|
||||
const focusableElements = getFocusableElements();
|
||||
if (focusableElements.length > 0) {
|
||||
// Small delay to ensure the element is fully rendered
|
||||
setTimeout(() => {
|
||||
focusableElements[0]?.focus();
|
||||
}, 100);
|
||||
}
|
||||
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('keydown', handleKeyDown);
|
||||
};
|
||||
}, [containerRef, enabled]);
|
||||
}
|
||||
30
frontend/src/hooks/tools/useLocalStorageState.ts
Normal file
30
frontend/src/hooks/tools/useLocalStorageState.ts
Normal file
@ -0,0 +1,30 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
|
||||
export function useLocalStorageState<T>(key: string, defaultValue: T): [T, (value: T) => void] {
|
||||
const [state, setState] = useState<T>(() => {
|
||||
if (typeof window === 'undefined') {
|
||||
return defaultValue;
|
||||
}
|
||||
|
||||
const stored = window.localStorage.getItem(key);
|
||||
if (stored === null) {
|
||||
return defaultValue;
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.parse(stored) as T;
|
||||
} catch {
|
||||
return defaultValue;
|
||||
}
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined') {
|
||||
return;
|
||||
}
|
||||
|
||||
window.localStorage.setItem(key, JSON.stringify(state));
|
||||
}, [key, state]);
|
||||
|
||||
return [state, setState];
|
||||
}
|
||||
101
frontend/src/hooks/tools/useToolPanelGeometry.ts
Normal file
101
frontend/src/hooks/tools/useToolPanelGeometry.ts
Normal file
@ -0,0 +1,101 @@
|
||||
import { useLayoutEffect, useState, RefObject } from 'react';
|
||||
|
||||
export interface ToolPanelGeometry {
|
||||
left: number;
|
||||
top: number;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
interface UseToolPanelGeometryOptions {
|
||||
enabled: boolean;
|
||||
toolPanelRef: RefObject<HTMLDivElement>;
|
||||
quickAccessRef: RefObject<HTMLDivElement>;
|
||||
}
|
||||
|
||||
export function useToolPanelGeometry({
|
||||
enabled,
|
||||
toolPanelRef,
|
||||
quickAccessRef,
|
||||
}: UseToolPanelGeometryOptions) {
|
||||
const [geometry, setGeometry] = useState<ToolPanelGeometry | null>(null);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!enabled) {
|
||||
setGeometry(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const panelEl = toolPanelRef.current;
|
||||
if (!panelEl) {
|
||||
setGeometry(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const rightRailEl = () => document.querySelector('[data-sidebar="right-rail"]') as HTMLElement | null;
|
||||
|
||||
let timeoutId: number | null = null;
|
||||
|
||||
const updateGeometry = () => {
|
||||
// Debounce: clear any pending update
|
||||
if (timeoutId !== null) {
|
||||
window.clearTimeout(timeoutId);
|
||||
}
|
||||
|
||||
// Schedule update after 150ms of inactivity
|
||||
timeoutId = window.setTimeout(() => {
|
||||
const rect = panelEl.getBoundingClientRect();
|
||||
const rail = rightRailEl();
|
||||
const rightOffset = rail ? Math.max(0, window.innerWidth - rail.getBoundingClientRect().left) : 0;
|
||||
const width = Math.max(360, window.innerWidth - rect.left - rightOffset);
|
||||
const height = Math.max(rect.height, window.innerHeight - rect.top);
|
||||
setGeometry({
|
||||
left: rect.left,
|
||||
top: rect.top,
|
||||
width,
|
||||
height,
|
||||
});
|
||||
timeoutId = null;
|
||||
}, 150);
|
||||
};
|
||||
|
||||
// Initial geometry calculation (no debounce)
|
||||
const rect = panelEl.getBoundingClientRect();
|
||||
const rail = rightRailEl();
|
||||
const rightOffset = rail ? Math.max(0, window.innerWidth - rail.getBoundingClientRect().left) : 0;
|
||||
const width = Math.max(360, window.innerWidth - rect.left - rightOffset);
|
||||
const height = Math.max(rect.height, window.innerHeight - rect.top);
|
||||
setGeometry({
|
||||
left: rect.left,
|
||||
top: rect.top,
|
||||
width,
|
||||
height,
|
||||
});
|
||||
|
||||
const handleResize = () => updateGeometry();
|
||||
window.addEventListener('resize', handleResize);
|
||||
|
||||
let resizeObserver: ResizeObserver | null = null;
|
||||
if (typeof ResizeObserver !== 'undefined') {
|
||||
resizeObserver = new ResizeObserver(() => updateGeometry());
|
||||
resizeObserver.observe(panelEl);
|
||||
if (quickAccessRef.current) {
|
||||
resizeObserver.observe(quickAccessRef.current);
|
||||
}
|
||||
const rail = rightRailEl();
|
||||
if (rail) {
|
||||
resizeObserver.observe(rail);
|
||||
}
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (timeoutId !== null) {
|
||||
window.clearTimeout(timeoutId);
|
||||
}
|
||||
window.removeEventListener('resize', handleResize);
|
||||
resizeObserver?.disconnect();
|
||||
};
|
||||
}, [enabled, quickAccessRef, toolPanelRef]);
|
||||
|
||||
return geometry;
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user