Skip to content

Commit 975b83d

Browse files
committed
feat: enhance tab navigation and styling
- Added scroll buttons for navigating through tabs, improving user experience when there are more pads than can fit in the view. - Implemented functionality to store and retrieve the current scroll index in local storage, ensuring consistent tab visibility across sessions. - Updated tab styles for better layout and responsiveness, including new styles for tab content and positioning. - Refactored Tabs component to handle horizontal scrolling via mouse wheel events, enhancing navigation efficiency.
1 parent 2322fea commit 975b83d

File tree

3 files changed

+287
-37
lines changed

3 files changed

+287
-37
lines changed

src/frontend/src/ui/Tabs.scss

Lines changed: 83 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,33 +1,65 @@
11
.tabs-bar {
22
margin-inline-start: 0.6rem;
33
height: var(--lg-button-size);
4+
position: relative;
45

56
Button {
67
height: var(--lg-button-size) !important;
7-
width: auto !important; // Changed from fixed width to auto
8-
min-width: var(--lg-button-size) !important;
8+
width: 100px !important;
9+
min-width: 100px !important;
910
border: none !important;
10-
margin-right: 0.5rem;
11+
margin-right: 0.6rem;
12+
text-overflow: ellipsis;
13+
overflow: hidden;
14+
white-space: nowrap;
1115

1216
&.active-pad {
1317
background-color: #cc6d24 !important;
1418
color: var(--color-on-primary) !important;
1519
font-weight: bold;
20+
21+
.tab-position {
22+
color: var(--color-on-primary) !important;
23+
}
1624
}
1725

1826
&.creating-pad {
1927
opacity: 0.6;
2028
cursor: not-allowed;
2129
}
30+
31+
.tab-content {
32+
position: relative;
33+
width: 100%;
34+
height: 100%;
35+
display: flex;
36+
flex-direction: column;
37+
justify-content: center;
38+
39+
.tab-position {
40+
position: absolute;
41+
bottom: -7px;
42+
right: -4px;
43+
font-size: 9px;
44+
opacity: 0.7;
45+
color: var(--keybinding-color);
46+
font-weight: normal;
47+
}
48+
}
49+
}
50+
51+
.tabs-wrapper {
52+
display: flex;
53+
flex-direction: row;
54+
align-items: center;
55+
position: relative;
2256
}
2357

2458
.tabs-container {
2559
display: flex;
2660
flex-direction: row;
2761
align-items: center;
28-
overflow-x: auto;
29-
max-width: 100%;
30-
padding-bottom: 5px; // Add padding to ensure scrollbar doesn't overlap content
62+
position: relative;
3163

3264
.loading-indicator {
3365
font-size: 0.8rem;
@@ -36,8 +68,53 @@
3668
}
3769
}
3870

71+
.scroll-buttons-container {
72+
display: flex;
73+
flex-direction: row;
74+
align-items: center;
75+
}
76+
77+
.scroll-button {
78+
height: var(--lg-button-size) !important;
79+
width: var(--lg-button-size) !important; // Square button
80+
display: flex;
81+
align-items: center;
82+
justify-content: center;
83+
background-color: var(--button-bg, var(--island-bg-color));
84+
border: none;
85+
cursor: pointer;
86+
z-index: 1;
87+
margin-right: 0.6rem !important;
88+
border-radius: var(--border-radius-lg);
89+
transition: background-color 0.2s ease;
90+
color: #bdbdbd; // Light gray color for the icons
91+
flex-shrink: 0; // Prevent button from shrinking
92+
min-width: unset !important; // Override any min-width inheritance
93+
max-width: unset !important; // Override any max-width inheritance
94+
95+
&:hover:not(.disabled) {
96+
color: #ffffff;
97+
}
98+
99+
&:active:not(.disabled) {
100+
color: #ffffff;
101+
}
102+
103+
&.disabled {
104+
color: #575757; // Light gray color for the icons
105+
opacity: 1;
106+
cursor: default;
107+
}
108+
109+
&.left {
110+
margin-right: 4px; // Add a small margin between left button and tabs
111+
}
112+
113+
}
114+
39115
.new-tab-button-container {
40116
Button {
117+
min-width: auto !important;
41118
width: var(--lg-button-size) !important;
42119
}
43120
}

src/frontend/src/ui/Tabs.tsx

Lines changed: 176 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
1-
import React, { useState, useEffect, useCallback } from "react";
1+
import React, { useState, useEffect, useCallback, useRef, useLayoutEffect } from "react";
22

33
import type { ExcalidrawImperativeAPI } from "@atyrode/excalidraw/types";
44
import { Stack, Button, Section, Tooltip } from "@atyrode/excalidraw";
5-
import { FilePlus2 } from "lucide-react";
5+
import { FilePlus2, ChevronLeft, ChevronRight } from "lucide-react";
66
import { useAllPads, useSaveCanvas, useRenamePad, useDeletePad, PadData } from "../api/hooks";
77
import { queryClient } from "../api/queryClient";
88
import {
@@ -12,7 +12,9 @@ import {
1212
getStoredActivePad,
1313
loadPadData,
1414
saveCurrentPadBeforeSwitching,
15-
createNewPad
15+
createNewPad,
16+
setScrollIndex,
17+
getStoredScrollIndex
1618
} from "../utils/canvasUtils";
1719
import TabContextMenu from "./TabContextMenu";
1820
import "./Tabs.scss";
@@ -30,6 +32,8 @@ const Tabs: React.FC<TabsProps> = ({
3032
const appState = excalidrawAPI.getAppState();
3133
const [isCreatingPad, setIsCreatingPad] = useState(false);
3234
const [activePadId, setActivePadId] = useState<string | null>(null);
35+
const [startPadIndex, setStartPadIndex] = useState(getStoredScrollIndex());
36+
const PADS_PER_PAGE = 5; // Show 5 pads at a time
3337

3438
// Context menu state
3539
const [contextMenu, setContextMenu] = useState<{
@@ -99,6 +103,16 @@ const Tabs: React.FC<TabsProps> = ({
99103

100104
// Update the query cache with the new data
101105
queryClient.setQueryData(['allPads'], updatedPads);
106+
107+
// Recompute the startPadIndex to avoid visual artifacts
108+
// If deleting a pad would result in an empty space at the end of the tab bar
109+
if (startPadIndex > 0 && startPadIndex + PADS_PER_PAGE > updatedPads.length) {
110+
// Calculate the new index that ensures the tab bar is filled properly
111+
// but never goes below 0
112+
const newIndex = Math.max(0, updatedPads.length - PADS_PER_PAGE);
113+
setStartPadIndex(newIndex);
114+
setScrollIndex(newIndex);
115+
}
102116
}
103117
},
104118
onError: (error) => {
@@ -181,22 +195,117 @@ const Tabs: React.FC<TabsProps> = ({
181195
}
182196
};
183197

198+
// Navigation functions - move by 1 pad at a time
199+
const showPreviousPads = () => {
200+
const newIndex = Math.max(0, startPadIndex - 1);
201+
setStartPadIndex(newIndex);
202+
setScrollIndex(newIndex);
203+
};
204+
205+
const showNextPads = () => {
206+
if (pads) {
207+
const newIndex = Math.min(startPadIndex + 1, Math.max(0, pads.length - PADS_PER_PAGE));
208+
setStartPadIndex(newIndex);
209+
setScrollIndex(newIndex);
210+
}
211+
};
212+
213+
// Create a dependency that only changes when the number of pads changes or pad IDs change
214+
const padStructure = React.useMemo(() => {
215+
return pads ? pads.map(pad => pad.id) : [];
216+
}, [pads]);
217+
218+
// We've removed the auto-centering feature that would automatically position the active pad in the middle of the tab bar
219+
220+
// Create a ref for the tabs wrapper to handle wheel events
221+
const tabsWrapperRef = useRef<HTMLDivElement>(null);
222+
223+
// Track last wheel event time to throttle scrolling
224+
const lastWheelTimeRef = useRef<number>(0);
225+
const wheelThrottleMs = 70; // Minimum time between wheel actions in milliseconds
226+
227+
// Set up wheel event listener with passive: false to properly prevent default behavior
228+
useLayoutEffect(() => {
229+
const handleWheel = (e: WheelEvent) => {
230+
// Always prevent default to stop page navigation
231+
e.preventDefault();
232+
e.stopPropagation();
233+
234+
// Throttle wheel events to prevent too rapid scrolling
235+
const now = Date.now();
236+
if (now - lastWheelTimeRef.current < wheelThrottleMs) {
237+
return;
238+
}
239+
240+
// Update last wheel time
241+
lastWheelTimeRef.current = now;
242+
243+
// Prioritize horizontal scrolling (deltaX) if present
244+
if (Math.abs(e.deltaX) > Math.abs(e.deltaY)) {
245+
// Horizontal scrolling
246+
if (e.deltaX > 0 && pads && startPadIndex < pads.length - PADS_PER_PAGE) {
247+
showNextPads();
248+
} else if (e.deltaX < 0 && startPadIndex > 0) {
249+
showPreviousPads();
250+
}
251+
} else {
252+
// Vertical scrolling - treat down as right, up as left (common convention)
253+
if (e.deltaY > 0 && pads && startPadIndex < pads.length - PADS_PER_PAGE) {
254+
showNextPads();
255+
} else if (e.deltaY < 0 && startPadIndex > 0) {
256+
showPreviousPads();
257+
}
258+
}
259+
};
260+
261+
const tabsWrapper = tabsWrapperRef.current;
262+
if (tabsWrapper) {
263+
// Add wheel event listener with passive: false option
264+
tabsWrapper.addEventListener('wheel', handleWheel, { passive: false });
265+
266+
// Clean up the event listener when component unmounts
267+
return () => {
268+
tabsWrapper.removeEventListener('wheel', handleWheel);
269+
};
270+
}
271+
}, [pads, startPadIndex, PADS_PER_PAGE]); // Dependencies needed for boundary checks
272+
184273
return (
185274
<>
186275
<div className="tabs-bar">
187276
<Stack.Col gap={2}>
188277
<Section heading="canvasActions">
189278
{!appState.viewModeEnabled && (
190-
<div className="tabs-container">
191-
{/* Loading indicator */}
192-
{isLoading && (
193-
<div className="loading-indicator">
194-
Loading pads...
279+
<>
280+
<div
281+
className="tabs-wrapper"
282+
ref={tabsWrapperRef}
283+
>
284+
{/* New pad button - moved to the beginning */}
285+
<div className="new-tab-button-container">
286+
<Tooltip label={isCreatingPad ? "Creating new pad..." : "New pad"} children={
287+
<Button
288+
onSelect={isCreatingPad ? () => {} : handleCreateNewPad}
289+
className={isCreatingPad ? "creating-pad" : ""}
290+
children={
291+
<div className="ToolIcon__icon">
292+
<FilePlus2 />
293+
</div>
294+
}
295+
/>
296+
} />
195297
</div>
196-
)}
298+
299+
<div className="tabs-container">
300+
{/* Loading indicator */}
301+
{isLoading && (
302+
<div className="loading-indicator">
303+
Loading pads...
304+
</div>
305+
)}
197306

198-
{/* List all pads */}
199-
{!isLoading && pads && pads.map((pad) => (
307+
{/* List visible pads (5 at a time) */}
308+
{!isLoading && pads && pads.slice(startPadIndex, startPadIndex + PADS_PER_PAGE).map((pad) => (
200309
<div
201310
key={pad.id}
202311
onContextMenu={(e) => {
@@ -210,40 +319,76 @@ const Tabs: React.FC<TabsProps> = ({
210319
});
211320
}}
212321
>
213-
{/* Only show tooltip if name is longer than 32 characters */}
214-
{pad.display_name.length > 32 ? (
322+
{/* Only show tooltip if name is likely to be truncated (more than ~15 characters) */}
323+
{pad.display_name.length > 8 ? (
215324
<Tooltip label={pad.display_name} children={
216325
<Button
217326
onSelect={() => handlePadSelect(pad)}
218-
children={`${pad.display_name.substring(0, 32)}...`}
219327
className={activePadId === pad.id ? "active-pad" : ""}
328+
children={
329+
<div className="tab-content">
330+
{pad.display_name}
331+
<span className="tab-position">{startPadIndex + pads.slice(startPadIndex, startPadIndex + PADS_PER_PAGE).indexOf(pad) + 1}</span>
332+
</div>
333+
}
220334
/>
221335
} />
222336
) : (
223337
<Button
224338
onSelect={() => handlePadSelect(pad)}
225-
children={pad.display_name}
226339
className={activePadId === pad.id ? "active-pad" : ""}
340+
children={
341+
<div className="tab-content">
342+
{pad.display_name}
343+
<span className="tab-position">{startPadIndex + pads.slice(startPadIndex, startPadIndex + PADS_PER_PAGE).indexOf(pad) + 1}</span>
344+
</div>
345+
}
227346
/>
228347
)}
229348
</div>
230-
))}
231-
232-
{/* New pad button */}
233-
<div className="new-tab-button-container">
234-
<Tooltip label={isCreatingPad ? "Creating new pad..." : "New pad"} children={
235-
<Button
236-
onSelect={isCreatingPad ? () => {} : handleCreateNewPad}
237-
className={isCreatingPad ? "creating-pad" : ""}
238-
children={
239-
<div className="ToolIcon__icon">
240-
<FilePlus2 />
241-
</div>
242-
}
243-
/>
244-
} />
349+
))}
350+
351+
</div>
352+
353+
{/* Left scroll button - only visible when there are more pads than can fit in the view */}
354+
{pads && pads.length > PADS_PER_PAGE && (
355+
<React.Fragment key={`left-tooltip-${startPadIndex}`}>
356+
<Tooltip
357+
label={`Scroll to the left${startPadIndex > 0 ? `\n(${startPadIndex} more)` : ''}`}
358+
children={
359+
<button
360+
className={`scroll-button left ${startPadIndex > 0 ? '' : 'disabled'}`}
361+
onClick={showPreviousPads}
362+
aria-label="Show previous pads"
363+
disabled={startPadIndex <= 0}
364+
>
365+
<ChevronLeft size={20} />
366+
</button>
367+
}
368+
/>
369+
</React.Fragment>
370+
)}
371+
372+
{/* Right scroll button - only visible when there are more pads than can fit in the view */}
373+
{pads && pads.length > PADS_PER_PAGE && (
374+
<React.Fragment key={`right-tooltip-${startPadIndex}`}>
375+
<Tooltip
376+
label={`Scroll to the right${pads && pads.length - (startPadIndex + PADS_PER_PAGE) > 0 ? `\n(${Math.max(0, pads.length - (startPadIndex + PADS_PER_PAGE))} more)` : ''}`}
377+
children={
378+
<button
379+
className={`scroll-button right ${pads && startPadIndex < pads.length - PADS_PER_PAGE ? '' : 'disabled'}`}
380+
onClick={showNextPads}
381+
aria-label="Show next pads"
382+
disabled={!pads || startPadIndex >= pads.length - PADS_PER_PAGE}
383+
>
384+
<ChevronRight size={20} />
385+
</button>
386+
}
387+
/>
388+
</React.Fragment>
389+
)}
245390
</div>
246-
</div>
391+
</>
247392
)}
248393
</Section>
249394
</Stack.Col>

0 commit comments

Comments
 (0)