alphabetical index
Massimo Melina committed
May 10, 2026 at 00:03 UTC
abf4fb8c0381b2da1f75f4f6d4b32cfb18a2f14e
4 files changed
+199
-67
frontend/src/BrowseFiles.ts
+17
-66
@@ -21,6 +21,7 @@ import { ClipBar } from './clip'
21
import { fileShow, getShowComponent } from './show'
22
import i18n from './i18n'
23
import { dragFilesSource } from './dragFiles'
24
+import { PAGE_SEPARATOR_CLASS, Paging, scrollIntoView } from './Paging'
25
const { t, useI18N } = i18n
26
27
export const MISSING_PERM = "Missing permission"
@@ -66,24 +67,24 @@ function FilesList() {
67
const snap = useSnapState()
68
const midnight = useMidnight() // as an optimization, we calculate this only once per list and pass it down
69
const pageSize = Math.max(1, Math.floor(snap.page_size ?? 100))
69
- const [page, setPage] = useState(0)
70
+ const [offset, setOffset] = useState(0)
71
const [extraPages, setExtraPages] = useState(0)
72
const [scrolledPages, setScrolledPages] = useState(0)
73
const [atBottom, setAtBottom] = useState(false)
73
- const offset = page * pageSize
74
const theList = snap.filteredList || snap.list
75
const total = theList.length
76
const nPages = Math.ceil(total / pageSize)
77
+ const page = Math.floor(offset / pageSize)
78
const pageEnd = offset + pageSize * (1+extraPages) - 1
79
const thisPage = theList.slice(offset, pageEnd + 1)
80
80
- useEffect(() => setPage(0), [theList[0]]) // reset page if the list changes
81
+ useEffect(() => setOffset(0), [theList[0]]) // reset page if the list changes
82
// reset scrolling if the page changes
83
useEffect(() => {
84
document.scrollingElement?.scrollTo(0, 0)
85
setExtraPages(0)
86
setScrolledPages(0)
86
- }, [page])
87
+ }, [offset])
88
89
// continuous-scrolling
90
const calcScrolledPages = useMemo(() =>
@@ -94,7 +95,7 @@ function FilesList() {
95
setAtBottom(window.innerHeight + Math.ceil(window.scrollY) >= document.body.offsetHeight)
96
}, 200),
97
[])
97
- const canAddPage = page + extraPages < nPages - 1
98
+ const canAddPage = pageEnd < total - 1
99
useEffect(() => domOn('scroll', () => {
100
if (!theList.length) return
101
const timeToAdd = window.innerHeight * 1.3 + window.scrollY >= document.body.offsetHeight // 30vh before the end
@@ -168,7 +169,7 @@ function FilesList() {
169
if (offset && (ret < 0 || ret > pageEnd))
170
ret = search(0) // search again on the whole list
171
if (ret >= 0)
171
- setPage(Math.floor(ret / pageSize))
172
+ setOffset(Math.floor(ret / pageSize) * pageSize)
173
return ret
174
175
function search(offset: number) {
@@ -209,12 +210,17 @@ function FilesList() {
210
if (pleaseGoBottom)
211
setGoBottom(true)
212
if (i < page || i > page + extraPages)
212
- return setPage(i)
213
+ return setOffset(i * pageSize)
214
i -= page + 1
215
const el = i < 0 ? ref.current?.querySelector('*')
216
: document.querySelectorAll('.' + PAGE_SEPARATOR_CLASS)[i]
217
scrollIntoView(el, 'center')
217
- }, [page, extraPages])
218
+ }, [page, extraPages, pageSize])
219
+ const changePageToIndex = useCallback((i: number) => {
220
+ setFocus('')
221
+ // alphabetical paging is entry-anchored, so the chosen group starts at the top instead of at the numeric page boundary
222
+ setOffset(i)
223
+ }, [])
224
225
const {t} = useI18N()
226
@@ -242,67 +248,14 @@ function FilesList() {
248
current: page + scrolledPages,
249
atBottom,
250
pageSize,
251
+ list: theList as DirEntry[],
252
+ showAlphabet: snap.sort_by === 'name' && !snap.invert_order,
253
changePage,
254
+ changePageToIndex,
255
})
256
)
257
}
258
250
-interface PagingProps {
251
- nPages: number
252
- current: number
253
- atBottom: boolean
254
- pageSize: number
255
- changePage: (newPage:number, goBottom?:boolean) => void
256
-}
257
-const Paging = memo(({ nPages, current, pageSize, changePage, atBottom }: PagingProps) => {
258
- useEffect(() => {
259
- document.body.style.overflowY = 'scroll'
260
- return () => { document.body.style.overflowY = '' }
261
- }, [])
262
- const lastScrollTimeRef = useRef(0)
263
- useEffect(() => domOn('scroll', () => lastScrollTimeRef.current = Date.now()), [])
264
- const ref = useRef<HTMLElement>()
265
- useEffect(() => { // in case the page changed using the continuous-scrolling, we want to re-center, but only if it happened for a user interaction different from the scrolling
266
- if (Date.now() - lastScrollTimeRef.current > 500)
267
- scrollIntoView(ref.current, 'nearest')
268
- }, [current])
269
- const shrink = nPages > 20
270
- const from = _.floor(current, -1)
271
- const to = from + 10
272
- return h('div', { id: 'paging' },
273
- h('button', {
274
- title: t('go_first', "Go to first item"),
275
- className: !current ? 'toggled' : undefined,
276
- onClick() { changePage(0) },
277
- }, hIcon('to_start')),
278
- h('div', { id: 'paging-middle' }, // using sticky first/last would prevent scrollIntoView from working
279
- _.range(1, nPages).map(i => {
280
- if (shrink && i % 10 && (i < from || i >= to))
281
- return false
282
- const pageStart = i * pageSize
283
- return h('button', {
284
- key: i,
285
- ...i === current && { className: 'toggled', ref },
286
- onClick: () => changePage(i),
287
- }, shrink && !(i % 10) && pageStart >= 1000 ? (pageStart / 1000) + 'K' : pageStart)
288
- })
289
- ),
290
- h('button', {
291
- title: t('go_last', "Go to last item"),
292
- className: atBottom ? 'toggled' : undefined,
293
- onClick(){ changePage(nPages-1, true) }
294
- }, hIcon('to_end')),
295
- )
296
-})
297
-
298
-function scrollIntoView(el: Element | undefined | null, block: ScrollLogicalPosition) {
299
- if (!el) return
300
- try { el.scrollIntoView({ block }) }
301
- catch { // firefox 52 rejects modern scrollIntoView options, so we fall back to the legacy boolean signature
302
- el.scrollIntoView(block === 'center')
303
- }
304
-}
305
-
259
export function useMidnight() {
260
const [midnight, setMidnight] = useState(calcMidnight)
261
useEffect(() => {
@@ -319,8 +272,6 @@ export function useMidnight() {
272
}
273
}
274
322
-const PAGE_SEPARATOR_CLASS = 'page-separator'
323
-
275
interface EntryProps { entry: DirEntry, midnight: Date, separator?: string }
276
const Entry = ({ entry, midnight, separator }: EntryProps) => {
277
const { uri, isFolder, name, n } = entry
frontend/src/Paging.ts
new
+150
@@ -0,0 +1,150 @@
1
+// This file is part of HFS - Copyright 2021-2023, Massimo Melina <a@rejetto.com> - License https://www.gnu.org/licenses/gpl-3.0.txt
2
+
3
+import { createElement as h, memo, useEffect, useMemo, useRef, useState } from 'react'
4
+import _ from 'lodash'
5
+import { DirEntry } from './state'
6
+import { domOn, getHFS, hIcon } from './misc'
7
+import i18n from './i18n'
8
+const { t } = i18n
9
+
10
+export const PAGE_SEPARATOR_CLASS = 'page-separator'
11
+
12
+interface PagingProps {
13
+ nPages: number
14
+ current: number
15
+ atBottom: boolean
16
+ pageSize: number
17
+ list: DirEntry[]
18
+ showAlphabet: boolean
19
+ changePage: (newPage:number, goBottom?:boolean) => void
20
+ changePageToIndex: (entryIndex: number) => void
21
+}
22
+
23
+interface AlphabetGroup {
24
+ label: string
25
+ index: number
26
+}
27
+
28
+export const Paging = memo(({ nPages, current, pageSize, list, showAlphabet, changePage, changePageToIndex, atBottom }: PagingProps) => {
29
+ const [alphabetOpen, setAlphabetOpen] = useState(false)
30
+ useEffect(() => {
31
+ document.body.style.overflowY = 'scroll'
32
+ return () => { document.body.style.overflowY = '' }
33
+ }, [])
34
+ const lastScrollTimeRef = useRef(0)
35
+ useEffect(() => domOn('scroll', () => lastScrollTimeRef.current = Date.now()), [])
36
+ const ref = useRef<HTMLElement>()
37
+ useEffect(() => { // in case the page changed using the continuous-scrolling, we want to re-center, but only if it happened for a user interaction different from the scrolling
38
+ if (Date.now() - lastScrollTimeRef.current > 500)
39
+ scrollIntoView(ref.current, 'nearest')
40
+ }, [current])
41
+ const shrink = nPages > 20
42
+ const from = _.floor(current, -1)
43
+ const to = from + 10
44
+ const alphabetGroups = useMemo(() => showAlphabet ? getAlphabetGroups(list) : [], [list, showAlphabet])
45
+ useEffect(() => {
46
+ if (!alphabetGroups.length)
47
+ setAlphabetOpen(false)
48
+ }, [alphabetGroups.length])
49
+ return h('div', { id: 'paging' },
50
+ h('button', {
51
+ title: t('go_first', "Go to first item"),
52
+ className: !current ? 'toggled' : undefined,
53
+ onClick() { changePage(0) },
54
+ }, hIcon('to_start')),
55
+ h('div', { id: 'paging-middle' }, // using sticky first/last would prevent scrollIntoView from working
56
+ _.range(1, nPages).map(i => {
57
+ if (shrink && i % 10 && (i < from || i >= to))
58
+ return false
59
+ const pageStart = i * pageSize
60
+ return h('button', {
61
+ key: i,
62
+ ...i === current && { className: 'toggled', ref },
63
+ onClick: () => changePage(i),
64
+ }, shrink && !(i % 10) && pageStart >= 1000 ? (pageStart / 1000) + 'K' : pageStart)
65
+ })
66
+ ),
67
+ h('button', {
68
+ title: t('go_last', "Go to last item"),
69
+ className: atBottom ? 'toggled' : undefined,
70
+ onClick(){ changePage(nPages-1, true) }
71
+ }, hIcon('to_end')),
72
+ Boolean(alphabetGroups.length) && h(AlphabetPaging, {
73
+ groups: alphabetGroups,
74
+ open: alphabetOpen,
75
+ toggleOpen: () => setAlphabetOpen(x => !x),
76
+ close: () => setAlphabetOpen(false),
77
+ changePage: i => {
78
+ setAlphabetOpen(false)
79
+ changePageToIndex(i)
80
+ },
81
+ }),
82
+ )
83
+})
84
+
85
+interface AlphabetPagingProps {
86
+ groups: AlphabetGroup[]
87
+ open: boolean
88
+ toggleOpen: () => void
89
+ close: () => void
90
+ changePage: (entryIndex: number) => void
91
+}
92
+
93
+function AlphabetPaging({ groups, open, toggleOpen, close, changePage }: AlphabetPagingProps) {
94
+ const ref = useRef<HTMLElement>()
95
+ useEffect(() => {
96
+ if (!open) return
97
+ return domOn('pointerdown', ev => {
98
+ const el = ref.current
99
+ // the outside listener is global, so clicks inside the popup must be ignored here
100
+ if (el && ev.target instanceof Node && !el.contains(ev.target))
101
+ close()
102
+ })
103
+ }, [open, close])
104
+ return h('div', { ref, id: 'alphabet-paging', className: open ? 'open' : undefined },
105
+ open && h('div', { id: 'alphabet-paging-bar' },
106
+ groups.map(({ label, index }) =>
107
+ h('button', {
108
+ key: label,
109
+ onClick: () => changePage(index),
110
+ }, label))
111
+ ),
112
+ h('button', {
113
+ id: 'alphabet-paging-toggle',
114
+ title: t('alpha_idx', "Alphabetical index"),
115
+ onClick: toggleOpen,
116
+ }, t('alpha_idx_button', "AZ"))
117
+ )
118
+}
119
+
120
+function getAlphabetGroups(list: DirEntry[]) {
121
+ const groups: AlphabetGroup[] = []
122
+ const seen = new Set<string>()
123
+ list.forEach((entry, index) => {
124
+ const label = getAlphabetGroup(entry.name)
125
+ if (!label) return
126
+ if (seen.has(label)) return
127
+ seen.add(label)
128
+ groups.push({ label, index })
129
+ })
130
+ // the list order can include non-letter entries and custom filename collation; the index stays tied to the first real entry
131
+ return groups.length > 1 ? groups.sort((a, b) => getHFS().textSortCompare(a.label, b.label)) : []
132
+}
133
+
134
+function getAlphabetGroup(name: string) {
135
+ const first = Array.from(name.trim())[0] || ''
136
+ if (!first) return ''
137
+ const latin = first.normalize('NFD').replace(/\p{Diacritic}/gu, '').toUpperCase()
138
+ if (/^[A-Z]$/.test(latin))
139
+ return latin
140
+ const upper = first.toLocaleUpperCase()
141
+ return /\p{Letter}/u.test(upper) ? upper : ''
142
+}
143
+
144
+export function scrollIntoView(el: Element | undefined | null, block: ScrollLogicalPosition) {
145
+ if (!el) return
146
+ try { el.scrollIntoView({ block }) }
147
+ catch { // firefox 52 rejects modern scrollIntoView options, so we fall back to the legacy boolean signature
148
+ el.scrollIntoView(block === 'center')
149
+ }
150
+}
frontend/src/index.scss
+30
-1
@@ -457,7 +457,7 @@ button .icon + .label {
457
position: sticky;
458
bottom: 0;
459
display: flex;
460
- gap: .1em;
460
+ gap: .3em;
461
background-color: var(--bg);
462
padding: 0 0.2em 0.2em;
463
&>button { z-index: 1; }
@@ -468,6 +468,7 @@ button .icon + .label {
468
display: flex;
469
gap: .5em;
470
flex: 1;
471
+ min-width: 0;
472
overflow-x: auto;
473
}
474
#paging-middle>button {
@@ -481,6 +482,34 @@ button .icon + .label {
482
white-space: nowrap;
483
padding: .5em; /* fit more buttons on screen */
484
}
485
+ #alphabet-paging {
486
+ position: relative;
487
+ z-index: 2;
488
+ display: flex;
489
+ margin-left: .2em;
490
+ &.open #alphabet-paging-toggle {
491
+ min-width: 2.4em;
492
+ }
493
+ }
494
+ #alphabet-paging-toggle {
495
+ min-width: 2.8em;
496
+ }
497
+ #alphabet-paging-bar {
498
+ position: absolute;
499
+ right: 100%;
500
+ bottom: 0;
501
+ display: flex;
502
+ gap: .2em;
503
+ max-width: calc(100vw - 5.5em);
504
+ overflow-x: auto;
505
+ padding: .2em .2em .2em .5em;
506
+ background: var(--bg);
507
+ box-shadow: -.2em 0 .3em .2em var(--bg);
508
+ button {
509
+ min-width: 2.2em;
510
+ padding: .5em .6em;
511
+ }
512
+ }
513
}
514
515
.upload-toolbar {
src/langs/hfs-lang-en.json
+2
@@ -33,6 +33,8 @@
33
"stopped_before": "Stopped before finding anything",
34
"empty_list": "Nothing here",
35
"filter_none": "No match for this filter",
36
+ "alpha_idx": "Alphabetical index",
37
+ "alpha_idx_button": "AZ",
38
39
"Admin-panel": "Admin-panel",
40
"Login": "Login",