1- import { h } from 'preact' ;
2- import { useId , useMemo } from 'preact/hooks' ;
1+ import { Fragment , h } from 'preact' ;
2+ import { useContext , useEffect , useId , useLayoutEffect , useMemo , useRef , useState } from 'preact/hooks' ;
33import { memo } from 'preact/compat' ;
44import cn from 'classnames' ;
55
66import styles from './Favorites.module.css' ;
7- import { Placeholder , PlusIconMemo , TileMemo } from './Tile.js' ;
87import { ShowHideButton } from '../../components/ShowHideButton.jsx' ;
98import { useTypedTranslationWith } from '../../types.js' ;
109import { usePlatformName } from '../../settings.provider.js' ;
1110import { useDropzoneSafeArea } from '../../dropzone.js' ;
11+ import { TileRow } from './TileRow.js' ;
12+ import { FavoritesContext } from './FavoritesProvider.js' ;
1213
1314/**
1415 * @typedef {import('../../../../../types/new-tab.js').Expansion } Expansion
1516 * @typedef {import('../../../../../types/new-tab.js').Favorite } Favorite
1617 * @typedef {import('../../../../../types/new-tab.js').FavoritesOpenAction['target'] } OpenTarget
1718 */
1819export const FavoritesMemo = memo ( Favorites ) ;
20+ export const ROW_CAPACITY = 6 ;
21+ /**
22+ * Note: These values MUST match exactly what's defined in the CSS.
23+ */
24+ const ITEM_HEIGHT = 98 ;
25+ const ROW_GAP = 8 ;
1926
2027/**
2128 * Favorites Grid.
@@ -30,65 +37,260 @@ export const FavoritesMemo = memo(Favorites);
3037 * @param {() => void } props.add
3138 */
3239export function Favorites ( { gridRef, favorites, expansion, toggle, openContextMenu, openFavorite, add } ) {
33- const platformName = usePlatformName ( ) ;
3440 const { t } = useTypedTranslationWith ( /** @type {import('../strings.json') } */ ( { } ) ) ;
35- const safeArea = useDropzoneSafeArea ( ) ;
36-
37- const ROW_CAPACITY = 6 ;
3841
3942 // see: https://www.w3.org/WAI/ARIA/apg/patterns/accordion/examples/accordion/
4043 const WIDGET_ID = useId ( ) ;
4144 const TOGGLE_ID = useId ( ) ;
4245
43- const ITEM_PREFIX = useId ( ) ;
44- const placeholders = calculatePlaceholders ( favorites . length , ROW_CAPACITY ) ;
4546 const hiddenCount = expansion === 'collapsed' ? favorites . length - ROW_CAPACITY : 0 ;
47+ const rowHeight = ITEM_HEIGHT + ROW_GAP ;
48+ const canToggleExpansion = favorites . length >= ROW_CAPACITY ;
4649
47- // only recompute the list
48- const items = useMemo ( ( ) => {
49- return favorites
50- . map ( ( item , index ) => {
51- return (
52- < TileMemo
53- url = { item . url }
54- faviconSrc = { item . favicon ?. src }
55- faviconMax = { item . favicon ?. maxAvailableSize }
56- title = { item . title }
57- key = { item . id + item . favicon ?. src + item . favicon ?. maxAvailableSize }
58- id = { item . id }
59- index = { index }
50+ return (
51+ < div class = { cn ( styles . root , ! canToggleExpansion && styles . bottomSpace ) } data-testid = "FavoritesConfigured" >
52+ < VirtualizedGridRows
53+ WIDGET_ID = { WIDGET_ID }
54+ favorites = { favorites }
55+ rowHeight = { rowHeight }
56+ add = { add }
57+ expansion = { expansion }
58+ openFavorite = { openFavorite }
59+ openContextMenu = { openContextMenu }
60+ />
61+ { canToggleExpansion && (
62+ < div
63+ className = { cn ( {
64+ [ styles . showhide ] : true ,
65+ [ styles . showhideVisible ] : canToggleExpansion ,
66+ } ) }
67+ >
68+ < ShowHideButton
69+ buttonAttrs = { {
70+ 'aria-expanded' : expansion === 'expanded' ,
71+ 'aria-pressed' : expansion === 'expanded' ,
72+ 'aria-controls' : WIDGET_ID ,
73+ id : TOGGLE_ID ,
74+ } }
75+ text = {
76+ expansion === 'expanded' ? t ( 'favorites_show_less' ) : t ( 'favorites_show_more' , { count : String ( hiddenCount ) } )
77+ }
78+ onClick = { toggle }
6079 />
61- ) ;
62- } )
63- . concat (
64- Array . from ( { length : placeholders } ) . map ( ( _ , index ) => {
65- if ( index === 0 ) {
66- return < PlusIconMemo key = "placeholder-plus" onClick = { add } /> ;
67- }
68- return < Placeholder key = { `placeholder-${ index } ` } /> ;
69- } ) ,
70- ) ;
71- } , [ favorites , placeholders , ITEM_PREFIX , add ] ) ;
80+ </ div >
81+ ) }
82+ </ div >
83+ ) ;
84+ }
85+
86+ /**
87+ * Favorites Grid. This will take a list of Favorites, split it into chunks (rows)
88+ * and then layout the rows based on an offset from the top of the container.
89+ *
90+ * Doing this means we can just render the items in the current viewport
91+ *
92+ * @param {object } props
93+ * @param {string } props.WIDGET_ID
94+ * @param {number } props.rowHeight
95+ * @param {Expansion } props.expansion
96+ * @param {Favorite[] } props.favorites
97+ * @param {(id: string) => void } props.openContextMenu
98+ * @param {(id: string, url: string, target: OpenTarget) => void } props.openFavorite
99+ * @param {() => void } props.add
100+ */
101+ function VirtualizedGridRows ( { WIDGET_ID , rowHeight, favorites, expansion, openFavorite, openContextMenu, add } ) {
102+ const platformName = usePlatformName ( ) ;
103+
104+ // convert the list of favorites into chunks of length ROW_CAPACITY
105+ const rows = useMemo ( ( ) => {
106+ const chunked = [ ] ;
107+ let inner = [ ] ;
108+ for ( let i = 0 ; i < favorites . length ; i ++ ) {
109+ inner . push ( favorites [ i ] ) ;
110+ if ( inner . length === ROW_CAPACITY ) {
111+ chunked . push ( inner . slice ( ) ) ;
112+ inner = [ ] ;
113+ }
114+ if ( i === favorites . length - 1 ) {
115+ chunked . push ( inner . slice ( ) ) ;
116+ inner = [ ] ;
117+ }
118+ }
119+ return chunked ;
120+ } , [ favorites ] ) ;
121+
122+ // get a ref for the favorites' grid, this will allow it to receive drop events,
123+ // and the ref can also be used for reading the offset (eg: if other elements are above it)
124+ const safeAreaRef = /** @type {import("preact").RefObject<HTMLDivElement> } */ ( useDropzoneSafeArea ( ) ) ;
125+ const containerHeight = expansion === 'collapsed' ? rowHeight : rows . length * rowHeight ;
126+
127+ return (
128+ < div
129+ className = { styles . grid }
130+ style = { { height : containerHeight + 'px' } }
131+ id = { WIDGET_ID }
132+ ref = { safeAreaRef }
133+ onContextMenu = { getContextMenuHandler ( openContextMenu ) }
134+ onClick = { getOnClickHandler ( openFavorite , platformName ) }
135+ >
136+ { rows . length === 0 && < TileRow key = { 'empty-rows' } items = { [ ] } topOffset = { 0 } add = { add } /> }
137+ { rows . length > 0 && < Inner rows = { rows } safeAreaRef = { safeAreaRef } rowHeight = { rowHeight } add = { add } /> }
138+ </ div >
139+ ) ;
140+ }
141+
142+ /**
143+ * This is a potentially expensive operation. Especially when going from 'collapsed' to expanded. So, we force
144+ * the tiles to render after the main thread is cleared by NOT using the 'expansion' from the parent, but instead
145+ * subscribing to the same update asynchronously. If we accepted the 'expansion' prop in this component and used
146+ * it directly, it would cause the browser to lock up (on slow devices) when expanding from 1 row to a full screen.
147+ *
148+ * @param {object } props
149+ * @param {Favorite[][] } props.rows
150+ * @param {import("preact").RefObject<HTMLDivElement> } props.safeAreaRef
151+ * @param {number } props.rowHeight
152+ * @param {()=>void } props.add
153+ */
154+ function Inner ( { rows, safeAreaRef, rowHeight, add } ) {
155+ const { onConfigChanged, state } = useContext ( FavoritesContext ) ;
156+ const [ expansion , setExpansion ] = useState ( state . config ?. expansion || 'collapsed' ) ;
157+
158+ // force the children to be rendered after the main thread is cleared
159+ useEffect ( ( ) => {
160+ return onConfigChanged ( ( config ) => {
161+ // when expanding, wait for the main thread to be clear
162+ if ( config . expansion === 'expanded' ) {
163+ setTimeout ( ( ) => {
164+ setExpansion ( config . expansion ) ;
165+ } , 0 ) ;
166+ } else {
167+ setExpansion ( config . expansion ) ;
168+ }
169+ } ) ;
170+ } , [ onConfigChanged ] ) ;
171+
172+ // set the start/end indexes of the elements
173+ const [ { start, end } , setVisibleRange ] = useState ( { start : 0 , end : 1 } ) ;
72174
175+ // hold a mutable value that we update on resize
176+ const gridOffset = useRef ( 0 ) ;
177+
178+ // When called, make the expensive call to `getBoundingClientRect` to determine the offset of
179+ // the grid wrapper.
180+ function updateGlobals ( ) {
181+ if ( ! safeAreaRef . current ) return ;
182+ const rec = safeAreaRef . current . getBoundingClientRect ( ) ;
183+ gridOffset . current = rec . y + window . scrollY ;
184+ }
185+
186+ // decide which the start/end indexes should be, based on scroll position.
187+ // NOTE: this is called on scroll, so must not incur expensive checks/measurements - math only!
188+ function setVisibleRows ( ) {
189+ if ( ! safeAreaRef . current ) return console . warn ( 'cannot access ref' ) ;
190+ if ( ! gridOffset . current ) return console . warn ( 'cannot access ref' ) ;
191+ const offset = gridOffset . current ;
192+ const end = window . scrollY + window . innerHeight - offset ;
193+ let start ;
194+ if ( offset > window . scrollY ) {
195+ start = 0 ;
196+ } else {
197+ start = window . scrollY - offset ;
198+ }
199+ const startIndex = Math . floor ( start / rowHeight ) ;
200+ const endIndex = Math . min ( Math . ceil ( end / rowHeight ) , rows . length ) ;
201+ setVisibleRange ( { start : startIndex , end : endIndex } ) ;
202+ }
203+
204+ useLayoutEffect ( ( ) => {
205+ // always update globals first
206+ updateGlobals ( ) ;
207+
208+ // and set visible rows once the size is known
209+ setVisibleRows ( ) ;
210+
211+ const controller = new AbortController ( ) ;
212+ window . addEventListener (
213+ 'resize' ,
214+ ( ) => {
215+ updateGlobals ( ) ;
216+ setVisibleRows ( ) ;
217+ } ,
218+ { signal : controller . signal } ,
219+ ) ;
220+
221+ window . addEventListener (
222+ 'scroll' ,
223+ ( ) => {
224+ setVisibleRows ( ) ;
225+ } ,
226+ { signal : controller . signal } ,
227+ ) ;
228+
229+ return ( ) => {
230+ controller . abort ( ) ;
231+ } ;
232+ } , [ rows . length ] ) ;
233+
234+ // now, we decide which items to show based on the widget expansion
235+ // prettier-ignore
236+ const subsetOfRowsToRender = expansion === 'collapsed'
237+ // if it's 'collapsed', just 1 row to show (the first one)
238+ ? [ rows [ 0 ] ]
239+ // otherwise, select the window between start/end
240+ // the '+ 1' is an additional row to render offscreen - which helps with keyboard navigation.
241+ : rows . slice ( start , end + 1 ) ;
242+
243+ // read a global property on <html> to determine if an element was recently dropped.
244+ // this is used for animation (the pulse) - it's easier this way because the act of dropping
245+ // a tile can cause it to render inside a different row, meaning the `key` is invalidated and so the dom-node is recreated
246+ const dropped = document . documentElement . dataset . dropped ;
247+
248+ return (
249+ < Fragment >
250+ { subsetOfRowsToRender . map ( ( items , rowIndex ) => {
251+ const topOffset = ( start + rowIndex ) * rowHeight ;
252+ const keyed = `-${ start + rowIndex } -` ;
253+ return < TileRow key = { keyed } dropped = { dropped } items = { items } topOffset = { topOffset } add = { add } /> ;
254+ } ) }
255+ </ Fragment >
256+ ) ;
257+ }
258+
259+ /**
260+ * Handle right-clicks
261+ *
262+ * @param {(id: string) => void } openContextMenu
263+ */
264+ function getContextMenuHandler ( openContextMenu ) {
73265 /**
74266 * @param {MouseEvent } event
75267 */
76- function onContextMenu ( event ) {
268+ return ( event ) => {
77269 let target = /** @type {HTMLElement|null } */ ( event . target ) ;
78270 while ( target && target !== event . currentTarget ) {
79- if ( typeof target . dataset . id === 'string' ) {
271+ if ( typeof target . dataset . id === 'string' && 'href' in target && typeof target . href === 'string' ) {
80272 event . preventDefault ( ) ;
81273 event . stopImmediatePropagation ( ) ;
82274 return openContextMenu ( target . dataset . id ) ;
83275 } else {
84276 target = target . parentElement ;
85277 }
86278 }
87- }
279+ } ;
280+ }
281+
282+ /**
283+ * Following a click on a favorite, walk up the DOM from the clicked
284+ * element to find the <a>. This is done to prevent needing a click handler
285+ * on every element.
286+ * @param {(id: string, url: string, target: OpenTarget) => void } openFavorite
287+ * @param {ImportMeta['platform'] } platformName
288+ */
289+ function getOnClickHandler ( openFavorite , platformName ) {
88290 /**
89291 * @param {MouseEvent } event
90292 */
91- function onClick ( event ) {
293+ return ( event ) => {
92294 let target = /** @type {HTMLElement|null } */ ( event . target ) ;
93295 while ( target && target !== event . currentTarget ) {
94296 if ( typeof target . dataset . id === 'string' && 'href' in target && typeof target . href === 'string' ) {
@@ -105,53 +307,5 @@ export function Favorites({ gridRef, favorites, expansion, toggle, openContextMe
105307 target = target . parentElement ;
106308 }
107309 }
108- }
109-
110- const canToggleExpansion = items . length > ROW_CAPACITY ;
111-
112- return (
113- < div class = { cn ( styles . root , ! canToggleExpansion && styles . bottomSpace ) } data-testid = "FavoritesConfigured" >
114- < div class = { styles . grid } id = { WIDGET_ID } ref = { safeArea } onContextMenu = { onContextMenu } onClick = { onClick } >
115- { items . slice ( 0 , expansion === 'expanded' ? undefined : ROW_CAPACITY ) }
116- </ div >
117- { canToggleExpansion && (
118- < div
119- className = { cn ( {
120- [ styles . showhide ] : true ,
121- [ styles . showhideVisible ] : canToggleExpansion ,
122- } ) }
123- >
124- < ShowHideButton
125- buttonAttrs = { {
126- 'aria-expanded' : expansion === 'expanded' ,
127- 'aria-pressed' : expansion === 'expanded' ,
128- 'aria-controls' : WIDGET_ID ,
129- id : TOGGLE_ID ,
130- } }
131- text = {
132- expansion === 'expanded' ? t ( 'favorites_show_less' ) : t ( 'favorites_show_more' , { count : String ( hiddenCount ) } )
133- }
134- onClick = { toggle }
135- />
136- </ div >
137- ) }
138- </ div >
139- ) ;
140- }
141-
142- /**
143- * @param {number } totalItems
144- * @param {number } itemsPerRow
145- * @return {number|number }
146- */
147- function calculatePlaceholders ( totalItems , itemsPerRow ) {
148- if ( totalItems === 0 ) return itemsPerRow ;
149- if ( totalItems === itemsPerRow ) return 1 ;
150- // Calculate how many items are left over in the last row
151- const itemsInLastRow = totalItems % itemsPerRow ;
152-
153- // If there are leftover items, calculate the placeholders needed to fill the last row
154- const placeholders = itemsInLastRow > 0 ? itemsPerRow - itemsInLastRow : 1 ;
155-
156- return placeholders ;
310+ } ;
157311}
0 commit comments