|
1 | | -import { createContext, useContext, useState, useMemo, useRef, type ReactNode } from 'react'; |
2 | 1 | import { useControllableState } from '@radix-ui/react-use-controllable-state'; |
| 2 | +import type { BibleVersion } from '@youversion/platform-core'; |
3 | 3 | import { |
4 | 4 | useFilteredVersions, |
5 | | - useVersion, |
6 | | - useVersions, |
7 | 5 | useLanguages, |
8 | 6 | useTheme, |
| 7 | + useVersion, |
| 8 | + useVersions, |
9 | 9 | } from '@youversion/platform-react-hooks'; |
| 10 | +import { ArrowLeft, Globe, Search } from 'lucide-react'; |
| 11 | +import { |
| 12 | + createContext, |
| 13 | + type ReactNode, |
| 14 | + useContext, |
| 15 | + useEffect, |
| 16 | + useMemo, |
| 17 | + useRef, |
| 18 | + useState, |
| 19 | +} from 'react'; |
| 20 | +import { cn } from '@/lib/utils'; |
| 21 | +import { Badge } from './ui/badge'; |
10 | 22 | import { Button } from './ui/button'; |
11 | 23 | import { Input } from './ui/input'; |
12 | | -import { Popover, PopoverTrigger, PopoverContent } from './ui/popover'; |
13 | | -import { Item, ItemGroup, ItemMedia, ItemContent, ItemTitle, ItemDescription } from './ui/item'; |
14 | | -import type { BibleVersion } from '@youversion/platform-core'; |
15 | | -import { Search, Globe, ArrowLeft } from 'lucide-react'; |
16 | | -import { Badge } from './ui/badge'; |
17 | | -import { cn } from '@/lib/utils'; |
| 24 | +import { Item, ItemContent, ItemDescription, ItemGroup, ItemMedia, ItemTitle } from './ui/item'; |
| 25 | +import { Popover, PopoverContent, PopoverTrigger } from './ui/popover'; |
| 26 | + |
| 27 | +// Displays a version abbreviation (e.g., "NIV", "KJV2") centered within a fixed-size icon. |
| 28 | +// Dynamically scales the font size to fit the text within the container with padding. |
| 29 | +function VersionAbbreviationIcon({ text }: { text: string }) { |
| 30 | + const containerRef = useRef<HTMLDivElement>(null); |
| 31 | + const prefixRef = useRef<HTMLDivElement>(null); |
| 32 | + const [prefixSize, setPrefixSize] = useState(20); |
| 33 | + |
| 34 | + // Split abbreviation into letters and numbers (e.g., "KJV2" → "KJV", "2") |
| 35 | + const match = /^(.+?)(\d+)$/.exec(text) || []; |
| 36 | + const prefix = match[1] || text; |
| 37 | + const digits = match[2]; |
| 38 | + |
| 39 | + useEffect(() => { |
| 40 | + const container = containerRef.current; |
| 41 | + const prefixElement = prefixRef.current; |
| 42 | + if (!container || !prefixElement) return; |
| 43 | + |
| 44 | + // Calculate the maximum font size that fits the text within container bounds |
| 45 | + const calculateSize = (element: HTMLElement | null) => { |
| 46 | + if (!element) return 20; |
| 47 | + |
| 48 | + const containerWidth = container.offsetWidth; |
| 49 | + const containerHeight = container.offsetHeight; |
| 50 | + // Target 70% of width for horizontal padding, max 40% height for vertical spacing |
| 51 | + const targetWidth = containerWidth * 0.7; |
| 52 | + const maxHeight = containerHeight * 0.4; |
| 53 | + |
| 54 | + let currentSize = 20; |
| 55 | + let ratio = 1; |
| 56 | + |
| 57 | + // Iteratively converge on the optimal size (5 iterations sufficient for convergence) |
| 58 | + for (let i = 0; i < 5; i++) { |
| 59 | + element.style.fontSize = `${currentSize}px`; |
| 60 | + const currentWidth = element.scrollWidth; |
| 61 | + const currentHeight = element.offsetHeight; |
| 62 | + |
| 63 | + if (currentWidth > 0) { |
| 64 | + const widthRatio = targetWidth / currentWidth; |
| 65 | + const heightRatio = maxHeight / currentHeight; |
| 66 | + // Use the more restrictive constraint (width or height) |
| 67 | + ratio = Math.min(widthRatio, heightRatio); |
| 68 | + currentSize = currentSize * ratio; |
| 69 | + } |
| 70 | + } |
| 71 | + |
| 72 | + // Ensure minimum readable size of 12px |
| 73 | + return Math.max(12, currentSize); |
| 74 | + }; |
| 75 | + |
| 76 | + const updateSizes = () => { |
| 77 | + const newPrefixSize = calculateSize(prefixElement); |
| 78 | + setPrefixSize(newPrefixSize); |
| 79 | + }; |
| 80 | + |
| 81 | + // Recalculate when container size changes (e.g., window resize, theme switch) |
| 82 | + const resizeObserver = new ResizeObserver(updateSizes); |
| 83 | + resizeObserver.observe(container); |
| 84 | + updateSizes(); |
| 85 | + |
| 86 | + return () => { |
| 87 | + resizeObserver.disconnect(); |
| 88 | + }; |
| 89 | + }, []); |
| 90 | + |
| 91 | + return ( |
| 92 | + <div |
| 93 | + ref={containerRef} |
| 94 | + className="yv:flex yv:flex-col yv:w-full yv:h-full yv:px-2 yv:font-serif yv:leading-none yv:font-bold yv:items-center yv:justify-center" |
| 95 | + > |
| 96 | + <div ref={prefixRef} className="yv:whitespace-nowrap" style={{ fontSize: `${prefixSize}px` }}> |
| 97 | + {prefix} |
| 98 | + </div> |
| 99 | + {digits && ( |
| 100 | + <div className="yv:whitespace-nowrap" style={{ fontSize: `${prefixSize}px` }}> |
| 101 | + {digits} |
| 102 | + </div> |
| 103 | + )} |
| 104 | + </div> |
| 105 | + ); |
| 106 | +} |
18 | 107 |
|
19 | 108 | type LanguageOption = { |
20 | 109 | id: string; |
@@ -255,19 +344,9 @@ function Content() { |
255 | 344 | > |
256 | 345 | <ItemMedia |
257 | 346 | variant="icon" |
258 | | - className="yv:rounded-[8px] yv:size-12 yv:border-border yv:p-1 yv:flex yv:flex-col yv:justify-center" |
| 347 | + className="yv:rounded-[8px] yv:size-12 yv:border-border yv:flex yv:flex-col yv:justify-center yv:items-center" |
259 | 348 | > |
260 | | - {(() => { |
261 | | - const match = /^(.+?)(\d+)$/.exec(version.localized_abbreviation) || []; |
262 | | - const prefix = match[1] || version.localized_abbreviation; |
263 | | - const digits = match[2]; |
264 | | - return ( |
265 | | - <div className="yv:font-serif yv:text-sm yv:leading-none yv:font-bold yv:text-center"> |
266 | | - <div>{prefix}</div> |
267 | | - {digits && <div>{digits}</div>} |
268 | | - </div> |
269 | | - ); |
270 | | - })()} |
| 349 | + <VersionAbbreviationIcon text={version.localized_abbreviation} /> |
271 | 350 | </ItemMedia> |
272 | 351 | <ItemContent> |
273 | 352 | <ItemTitle className="yv:line-clamp-2 yv:text-left"> |
@@ -334,7 +413,11 @@ function Content() { |
334 | 413 | aria-label={language.englishName} |
335 | 414 | asChild |
336 | 415 | > |
337 | | - <button className="yv:w-full" onClick={() => handleSelectLanguage(language.id)}> |
| 416 | + <button |
| 417 | + className="yv:w-full" |
| 418 | + onClick={() => handleSelectLanguage(language.id)} |
| 419 | + type="button" |
| 420 | + > |
338 | 421 | <ItemContent> |
339 | 422 | <ItemTitle className="yv:line-clamp-2">{language.englishName}</ItemTitle> |
340 | 423 | </ItemContent> |
|
0 commit comments