main
ts 148 lines 5.73 KB
Raw
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, useSnapState } 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 changePage: (newPage:number, goBottom?:boolean) => void
19 changePageToIndex: (entryIndex: number) => void
20 }
21
22 interface AlphabetGroup {
23 label: string
24 index: number
25 }
26
27 export const Paging = memo(({ nPages, current, pageSize, list, changePage, changePageToIndex, atBottom }: PagingProps) => {
28 const [alphabetOpen, setAlphabetOpen] = useState(false)
29 useEffect(() => {
30 document.body.style.overflowY = 'scroll'
31 return () => { document.body.style.overflowY = '' }
32 }, [])
33 const lastScrollTimeRef = useRef(0)
34 useEffect(() => domOn('scroll', () => lastScrollTimeRef.current = Date.now()), [])
35 const ref = useRef<HTMLElement>()
36 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
37 if (Date.now() - lastScrollTimeRef.current > 500)
38 scrollIntoView(ref.current, 'nearest')
39 }, [current])
40 const shrink = nPages > 20
41 const from = _.floor(current, -1)
42 const to = from + 10
43 const snap = useSnapState()
44 const alphabetGroups = useMemo(() => snap.sort_by === 'name' ? getAlphabetGroups(list, snap.invert_order) : [],
45 [list, snap.sort_by, snap.invert_order])
46 useEffect(() => {
47 if (!alphabetGroups.length)
48 setAlphabetOpen(false)
49 }, [alphabetGroups.length])
50 return h('div', { id: 'paging' },
51 h('button', {
52 title: t('go_first', "Go to first item"),
53 className: !current ? 'toggled' : undefined,
54 onClick() { changePage(0) },
55 }, hIcon('to_start')),
56 h('div', { id: 'paging-middle' }, // using sticky first/last would prevent scrollIntoView from working
57 _.range(1, nPages).map(i => {
58 if (shrink && i % 10 && (i < from || i >= to))
59 return false
60 const pageStart = i * pageSize
61 return h('button', {
62 key: i,
63 ...i === current && { className: 'toggled', ref },
64 onClick: () => changePage(i),
65 }, shrink && !(i % 10) && pageStart >= 1000 ? (pageStart / 1000) + 'K' : pageStart)
66 })
67 ),
68 h('button', {
69 title: t('go_last', "Go to last item"),
70 className: atBottom ? 'toggled' : undefined,
71 onClick(){ changePage(nPages-1, true) }
72 }, hIcon('to_end')),
73 alphabetGroups.length > 0 && h(AlphabetPaging, {
74 groups: alphabetGroups,
75 open: alphabetOpen,
76 toggleOpen: () => setAlphabetOpen(x => !x),
77 close: () => setAlphabetOpen(false),
78 changePage: i => {
79 setAlphabetOpen(false)
80 changePageToIndex(i)
81 },
82 }),
83 )
84 })
85
86 interface AlphabetPagingProps {
87 groups: AlphabetGroup[]
88 open: boolean
89 toggleOpen: () => void
90 close: () => void
91 changePage: (entryIndex: number) => void
92 }
93
94 function AlphabetPaging({ groups, open, toggleOpen, close, changePage }: AlphabetPagingProps) {
95 const ref = useRef<HTMLElement>()
96 useEffect(() => {
97 if (!open) return
98 return domOn('pointerdown', ev => {
99 const el = ref.current
100 // the outside listener is global, so clicks inside the popup must be ignored here
101 if (el && ev.target instanceof Node && !el.contains(ev.target))
102 close()
103 })
104 }, [open, close])
105 return h('div', { ref, id: 'alphabet-paging', className: open ? 'open' : undefined },
106 open && h('div', { id: 'alphabet-paging-bar' },
107 groups.map(({ label, index }) =>
108 h('button', {
109 key: label,
110 onClick: () => changePage(index),
111 }, label))
112 ),
113 h('button', {
114 id: 'alphabet-paging-toggle',
115 title: t('alpha_idx', "Alphabetical index"),
116 onClick: toggleOpen,
117 }, t('alpha_idx_button', "AZ"))
118 )
119 }
120
121 function getAlphabetGroups(list: DirEntry[], invertOrder: boolean) {
122 const groups: AlphabetGroup[] = []
123 const seen = new Set<string>()
124 list.forEach((entry, index) => {
125 const first = Array.from(entry.name.trim())[0] || ''
126 if (!first) return
127 const latin = first.normalize('NFD').replace(/\p{Diacritic}/gu, '').toUpperCase()
128 const upper = first.toLocaleUpperCase()
129 const label = /^[A-Z]$/.test(latin) ? latin
130 : /\p{Letter}/u.test(upper) ? upper
131 : ''
132 if (!label) return
133 if (seen.has(label)) return
134 seen.add(label)
135 groups.push({ label, index })
136 })
137 // the list order can include non-letter entries and custom filename collation; the index stays tied to the first real entry
138 const order = invertOrder ? -1 : 1
139 return groups.length > 1 ? groups.sort((a, b) => order * getHFS().textSortCompare(a.label, b.label)) : []
140 }
141
142 export function scrollIntoView(el: Element | undefined | null, block: ScrollLogicalPosition) {
143 if (!el) return
144 try { el.scrollIntoView({ block }) }
145 catch { // firefox 52 rejects modern scrollIntoView options, so we fall back to the legacy boolean signature
146 el.scrollIntoView(block === 'center')
147 }
148 }