1- import React , { useState , useEffect , useCallback } from "react" ;
1+ import React , { useState , useEffect , useCallback , useRef , useLayoutEffect } from "react" ;
22
33import type { ExcalidrawImperativeAPI } from "@atyrode/excalidraw/types" ;
44import { Stack , Button , Section , Tooltip } from "@atyrode/excalidraw" ;
5- import { FilePlus2 } from "lucide-react" ;
5+ import { FilePlus2 , ChevronLeft , ChevronRight } from "lucide-react" ;
66import { useAllPads , useSaveCanvas , useRenamePad , useDeletePad , PadData } from "../api/hooks" ;
77import { queryClient } from "../api/queryClient" ;
88import {
@@ -12,7 +12,9 @@ import {
1212 getStoredActivePad ,
1313 loadPadData ,
1414 saveCurrentPadBeforeSwitching ,
15- createNewPad
15+ createNewPad ,
16+ setScrollIndex ,
17+ getStoredScrollIndex
1618} from "../utils/canvasUtils" ;
1719import TabContextMenu from "./TabContextMenu" ;
1820import "./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