@aphrody/koharu-ui 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (123) hide show
  1. package/README.md +37 -2
  2. package/package.json +70 -4
  3. package/src/assets/NotoSansCJK-VF.otf +0 -0
  4. package/src/assets/OFL.txt +92 -0
  5. package/src/components/ClientOnly.tsx +21 -0
  6. package/src/components/ShenronExportPanel.tsx +213 -0
  7. package/src/components/app/AboutDialog.tsx +64 -0
  8. package/src/components/app/KoharuApp.tsx +58 -0
  9. package/src/components/app/StartupView.tsx +67 -0
  10. package/src/components/app/TitleBar.tsx +322 -0
  11. package/src/components/app/Updater.tsx +143 -0
  12. package/src/components/app/WindowChrome.tsx +137 -0
  13. package/src/components/controls/ColorSampling.tsx +70 -0
  14. package/src/components/controls/ColorWell.tsx +139 -0
  15. package/src/components/controls/CommitTextarea.tsx +63 -0
  16. package/src/components/controls/FontPicker.tsx +543 -0
  17. package/src/components/controls/ModelPicker.tsx +178 -0
  18. package/src/components/controls/OutputPicker.tsx +170 -0
  19. package/src/components/editor/ActivityCenter.tsx +234 -0
  20. package/src/components/editor/AgentPanel.tsx +352 -0
  21. package/src/components/editor/CanvasCommandBar.tsx +38 -0
  22. package/src/components/editor/CanvasOverlay.tsx +194 -0
  23. package/src/components/editor/CanvasWorkspace.tsx +947 -0
  24. package/src/components/editor/Editor.tsx +56 -0
  25. package/src/components/editor/InferenceControl.tsx +457 -0
  26. package/src/components/editor/Inspector.tsx +1073 -0
  27. package/src/components/editor/PageRail.tsx +581 -0
  28. package/src/components/editor/ResourceMonitor.tsx +151 -0
  29. package/src/components/editor/RightSidebar.tsx +50 -0
  30. package/src/components/editor/SelectionControls.tsx +184 -0
  31. package/src/components/editor/StatusBar.tsx +41 -0
  32. package/src/components/editor/ToolBar.tsx +182 -0
  33. package/src/components/editor/useCanvas.ts +194 -0
  34. package/src/components/preferences/GenerationPreferences.tsx +118 -0
  35. package/src/components/preferences/KoharuSettings.tsx +229 -0
  36. package/src/components/preferences/PipelinePreferences.tsx +173 -0
  37. package/src/components/preferences/PreferenceFields.tsx +149 -0
  38. package/src/components/preferences/ProviderPreferences.tsx +173 -0
  39. package/src/components/preferences/SettingsPage.tsx +336 -0
  40. package/src/components/preferences/TranslationPreferences.tsx +187 -0
  41. package/src/components/preferences/TypesettingPreferences.tsx +142 -0
  42. package/src/components/preferences/models.ts +103 -0
  43. package/src/components/start/StartView.tsx +277 -0
  44. package/src/index.ts +59 -0
  45. package/src/internal/components/accordion.tsx +72 -0
  46. package/src/internal/components/alert-dialog.tsx +189 -0
  47. package/src/internal/components/alert.tsx +76 -0
  48. package/src/internal/components/aspect-ratio.tsx +22 -0
  49. package/src/internal/components/attachment.tsx +207 -0
  50. package/src/internal/components/avatar.tsx +109 -0
  51. package/src/internal/components/badge.tsx +52 -0
  52. package/src/internal/components/breadcrumb.tsx +125 -0
  53. package/src/internal/components/bubble.tsx +128 -0
  54. package/src/internal/components/button-group.tsx +87 -0
  55. package/src/internal/components/button.tsx +58 -0
  56. package/src/internal/components/card.tsx +103 -0
  57. package/src/internal/components/checkbox.tsx +29 -0
  58. package/src/internal/components/collapsible.tsx +21 -0
  59. package/src/internal/components/combobox.tsx +298 -0
  60. package/src/internal/components/context-menu.tsx +272 -0
  61. package/src/internal/components/dialog.tsx +162 -0
  62. package/src/internal/components/direction.tsx +6 -0
  63. package/src/internal/components/drawer.tsx +230 -0
  64. package/src/internal/components/dropdown-menu.tsx +278 -0
  65. package/src/internal/components/empty.tsx +104 -0
  66. package/src/internal/components/field.tsx +238 -0
  67. package/src/internal/components/hover-card.tsx +52 -0
  68. package/src/internal/components/input-group.tsx +158 -0
  69. package/src/internal/components/input.tsx +20 -0
  70. package/src/internal/components/item.tsx +201 -0
  71. package/src/internal/components/kbd.tsx +26 -0
  72. package/src/internal/components/label.tsx +20 -0
  73. package/src/internal/components/marker.tsx +71 -0
  74. package/src/internal/components/menubar.tsx +280 -0
  75. package/src/internal/components/message.tsx +92 -0
  76. package/src/internal/components/native-select.tsx +61 -0
  77. package/src/internal/components/navigation-menu.tsx +168 -0
  78. package/src/internal/components/number-field.tsx +68 -0
  79. package/src/internal/components/pagination.tsx +130 -0
  80. package/src/internal/components/popover.tsx +91 -0
  81. package/src/internal/components/progress.tsx +83 -0
  82. package/src/internal/components/radio-group.tsx +38 -0
  83. package/src/internal/components/resizable.tsx +50 -0
  84. package/src/internal/components/scroll-area.tsx +88 -0
  85. package/src/internal/components/select.tsx +191 -0
  86. package/src/internal/components/separator.tsx +25 -0
  87. package/src/internal/components/sheet.tsx +140 -0
  88. package/src/internal/components/sidebar.tsx +723 -0
  89. package/src/internal/components/skeleton.tsx +13 -0
  90. package/src/internal/components/slider.tsx +56 -0
  91. package/src/internal/components/spinner.tsx +10 -0
  92. package/src/internal/components/switch.tsx +32 -0
  93. package/src/internal/components/table.tsx +116 -0
  94. package/src/internal/components/tabs.tsx +82 -0
  95. package/src/internal/components/textarea.tsx +18 -0
  96. package/src/internal/components/toast.tsx +235 -0
  97. package/src/internal/components/toggle-group.tsx +89 -0
  98. package/src/internal/components/toggle.tsx +45 -0
  99. package/src/internal/components/tooltip.tsx +67 -0
  100. package/src/internal/hooks/use-mobile.ts +19 -0
  101. package/src/internal/lib/utils.ts +6 -0
  102. package/src/internal/styles/globals.css +73 -0
  103. package/src/lib/backend.ts +85 -0
  104. package/src/lib/document.ts +46 -0
  105. package/src/lib/geometry.ts +272 -0
  106. package/src/lib/i18n.ts +54 -0
  107. package/src/lib/queries.ts +110 -0
  108. package/src/lib/store.ts +191 -0
  109. package/src/lib/translation.ts +35 -0
  110. package/src/locales/en-US/translation.json +536 -0
  111. package/src/locales/es-ES/translation.json +536 -0
  112. package/src/locales/ja-JP/translation.json +536 -0
  113. package/src/locales/ko-KR/translation.json +536 -0
  114. package/src/locales/pt-BR/translation.json +536 -0
  115. package/src/locales/ru-RU/translation.json +536 -0
  116. package/src/locales/tr-TR/translation.json +536 -0
  117. package/src/locales/zh-CN/translation.json +536 -0
  118. package/src/locales/zh-TW/translation.json +536 -0
  119. package/src/navigation.ts +28 -0
  120. package/src/providers.tsx +132 -0
  121. package/src/shenron-pipeline-env.d.ts +14 -0
  122. package/src/styles.css +246 -0
  123. package/tsconfig.json +21 -0
@@ -0,0 +1,543 @@
1
+ 'use client'
2
+
3
+ import { observeElementRect, useVirtualizer } from '@tanstack/react-virtual'
4
+ import type { TFunction } from 'i18next'
5
+ import { ChevronDown, ListFilter, Search, X } from 'lucide-react'
6
+ import { useEffect, useMemo, useRef, useState } from 'react'
7
+ import { useTranslation } from 'react-i18next'
8
+
9
+ import { useFontPreview } from '@aphrody/koharu-ui/lib/queries'
10
+ import type { FontFamily, FontSource } from '@aphrody/koharu-bridge/protocol'
11
+ import { Badge } from '@aphrody/m3-primitives'
12
+ import { Button } from '../../internal/components/button'
13
+ import {
14
+ DropdownMenu,
15
+ DropdownMenuContent,
16
+ DropdownMenuItem,
17
+ DropdownMenuRadioGroup,
18
+ DropdownMenuRadioItem,
19
+ DropdownMenuSeparator,
20
+ DropdownMenuSub,
21
+ DropdownMenuSubContent,
22
+ DropdownMenuSubTrigger,
23
+ DropdownMenuTrigger,
24
+ } from '../../internal/components/dropdown-menu'
25
+ import { Empty, EmptyTitle } from '../../internal/components/empty'
26
+ import {
27
+ InputGroup,
28
+ InputGroupAddon,
29
+ InputGroupButton,
30
+ InputGroupInput,
31
+ } from '../../internal/components/input-group'
32
+ import { Popover, PopoverContent, PopoverTrigger } from '../../internal/components/popover'
33
+ import { ScrollArea } from '../../internal/components/scroll-area'
34
+ import { Separator } from '../../internal/components/separator'
35
+ import { cn } from '../../internal/lib/utils'
36
+
37
+ const rowHeight = 38
38
+ const listHeight = 248
39
+ const allFonts = '__all_fonts__'
40
+
41
+ type Filters = {
42
+ source: '' | FontSource
43
+ script: string
44
+ category: string
45
+ useCase: string
46
+ }
47
+
48
+ const emptyFilters: Filters = { source: '', script: '', category: '', useCase: '' }
49
+
50
+ export function FontPicker({
51
+ value,
52
+ families,
53
+ disabled,
54
+ size = 'default',
55
+ ariaLabel,
56
+ placeholder,
57
+ onChange,
58
+ }: {
59
+ value: string
60
+ families: FontFamily[]
61
+ disabled?: boolean
62
+ size?: 'default' | 'sm'
63
+ ariaLabel?: string
64
+ placeholder?: string
65
+ onChange: (family: string) => void
66
+ }) {
67
+ const { t } = useTranslation()
68
+ const [open, setOpen] = useState(false)
69
+ const [query, setQuery] = useState('')
70
+ const [filters, setFilters] = useState<Filters>(emptyFilters)
71
+ const input = useRef<HTMLInputElement>(null)
72
+ const orderedFamilies = useMemo(
73
+ () =>
74
+ [...families].toSorted(
75
+ (left, right) =>
76
+ scriptRank(left) - scriptRank(right) ||
77
+ left.name.localeCompare(right.name, undefined, {
78
+ numeric: true,
79
+ sensitivity: 'base',
80
+ }),
81
+ ),
82
+ [families],
83
+ )
84
+ const facets = useMemo(() => fontFacets(orderedFamilies), [orderedFamilies])
85
+ const results = useMemo(() => {
86
+ const normalized = normalizeFontName(query)
87
+ return orderedFamilies.filter((family) => {
88
+ const matchesQuery =
89
+ !normalized ||
90
+ [family.name, ...family.faces.map((face) => face.postscript_name)].some((name) =>
91
+ normalizeFontName(name).includes(normalized),
92
+ )
93
+ const matchesSource = !filters.source || family.sources.includes(filters.source)
94
+ const matchesScript =
95
+ !filters.script ||
96
+ family.metadata.primary_script === filters.script ||
97
+ family.metadata.scripts.includes(filters.script)
98
+ const matchesCategory = !filters.category || family.metadata.category === filters.category
99
+ const matchesUse =
100
+ !filters.useCase ||
101
+ family.metadata.classifications.includes(filters.useCase) ||
102
+ family.metadata.use_cases.includes(filters.useCase)
103
+ return matchesQuery && matchesSource && matchesScript && matchesCategory && matchesUse
104
+ })
105
+ }, [filters, orderedFamilies, query])
106
+ const selectedFamily = useMemo(
107
+ () =>
108
+ orderedFamilies.find((family) => normalizeFontName(family.name) === normalizeFontName(value)),
109
+ [orderedFamilies, value],
110
+ )
111
+ const activeFilterCount = Object.values(filters).filter(Boolean).length
112
+
113
+ return (
114
+ <Popover
115
+ open={open}
116
+ onOpenChange={(next) => {
117
+ setOpen(next)
118
+ if (!next) setQuery('')
119
+ }}
120
+ >
121
+ <PopoverTrigger
122
+ render={
123
+ <Button
124
+ variant='outline'
125
+ size={size === 'sm' ? 'xs' : 'default'}
126
+ className='w-full min-w-0 justify-between font-normal'
127
+ />
128
+ }
129
+ disabled={disabled}
130
+ aria-label={ariaLabel}
131
+ data-testid='type-font-picker'
132
+ className={cn(
133
+ 'w-full min-w-0 justify-between font-normal',
134
+ size === 'sm' ? 'h-6 gap-1 px-1.5 text-[11px]' : 'h-8 gap-2 px-2.5 text-[12px]',
135
+ )}
136
+ >
137
+ {selectedFamily ? (
138
+ <FontPreviewLabel
139
+ family={selectedFamily}
140
+ className='min-w-0 flex-1 py-1 [&>img]:max-h-3.5'
141
+ />
142
+ ) : (
143
+ <span className='truncate'>{value || placeholder || t('fontPicker.choose')}</span>
144
+ )}
145
+ <ChevronDown className='size-3.5 shrink-0 text-muted-foreground' />
146
+ </PopoverTrigger>
147
+ <PopoverContent
148
+ align='start'
149
+ className='w-(--anchor-width) min-w-44 gap-0 overflow-hidden rounded-lg p-0'
150
+ initialFocus={input}
151
+ >
152
+ <div className='border-b p-1'>
153
+ <InputGroup className='h-6 border-0 bg-muted/50 shadow-none focus-within:bg-background has-[>[data-align=inline-start]]:[&>input]:pl-0'>
154
+ <InputGroupAddon className='pr-1 pl-1.5'>
155
+ <Search className='size-3' />
156
+ </InputGroupAddon>
157
+ <InputGroupInput
158
+ ref={input}
159
+ value={query}
160
+ aria-label={t('fontPicker.search')}
161
+ placeholder={t('fontPicker.search')}
162
+ className='h-6 px-0 text-[11px]'
163
+ onChange={(event) => setQuery(event.currentTarget.value)}
164
+ />
165
+ <InputGroupAddon align='inline-end' className='gap-0.5 pr-0.5'>
166
+ {query && (
167
+ <InputGroupButton aria-label={t('common.clearSearch')} onClick={() => setQuery('')}>
168
+ <X />
169
+ </InputGroupButton>
170
+ )}
171
+ <FontFilterMenu
172
+ filters={filters}
173
+ facets={facets}
174
+ onChange={setFilters}
175
+ onClear={() => setFilters(emptyFilters)}
176
+ />
177
+ </InputGroupAddon>
178
+ </InputGroup>
179
+ </div>
180
+ <FontResultSummary
181
+ count={results.length}
182
+ activeFilterCount={activeFilterCount}
183
+ onClear={() => setFilters(emptyFilters)}
184
+ />
185
+ <Separator />
186
+ {open && (
187
+ <FontList
188
+ key={`${query}:${filters.source}:${filters.script}:${filters.category}:${filters.useCase}`}
189
+ families={results}
190
+ value={value}
191
+ onSelect={(family) => {
192
+ onChange(family)
193
+ setOpen(false)
194
+ }}
195
+ />
196
+ )}
197
+ </PopoverContent>
198
+ </Popover>
199
+ )
200
+ }
201
+
202
+ function FontFilterMenu({
203
+ filters,
204
+ facets,
205
+ onChange,
206
+ onClear,
207
+ }: {
208
+ filters: Filters
209
+ facets: ReturnType<typeof fontFacets>
210
+ onChange: (filters: Filters) => void
211
+ onClear: () => void
212
+ }) {
213
+ const { t } = useTranslation()
214
+ const activeFilterCount = Object.values(filters).filter(Boolean).length
215
+
216
+ return (
217
+ <DropdownMenu>
218
+ <DropdownMenuTrigger
219
+ render={
220
+ <InputGroupButton
221
+ aria-label={
222
+ activeFilterCount > 0
223
+ ? t('fontPicker.filterActiveLabel', { count: activeFilterCount })
224
+ : t('fontPicker.filter')
225
+ }
226
+ className={cn(activeFilterCount > 0 && 'bg-accent text-foreground')}
227
+ />
228
+ }
229
+ >
230
+ <ListFilter />
231
+ {activeFilterCount > 0 && (
232
+ <Badge className='h-3.5 min-w-3.5 px-1 text-[8px]'>{activeFilterCount}</Badge>
233
+ )}
234
+ </DropdownMenuTrigger>
235
+ <DropdownMenuContent align='end' sideOffset={6} className='w-40'>
236
+ <FilterSubmenu
237
+ label={t('fontPicker.source')}
238
+ allLabel={t('fontPicker.allSources')}
239
+ kind='source'
240
+ value={filters.source}
241
+ options={facets.sources}
242
+ onChange={(source) => onChange({ ...filters, source: source as Filters['source'] })}
243
+ />
244
+ <FilterSubmenu
245
+ label={t('fontPicker.script')}
246
+ allLabel={t('fontPicker.allScripts')}
247
+ kind='script'
248
+ value={filters.script}
249
+ options={facets.scripts}
250
+ onChange={(script) => onChange({ ...filters, script })}
251
+ />
252
+ <FilterSubmenu
253
+ label={t('fontPicker.style')}
254
+ allLabel={t('fontPicker.allStyles')}
255
+ kind='metadata'
256
+ value={filters.category}
257
+ options={facets.categories}
258
+ onChange={(category) => onChange({ ...filters, category })}
259
+ />
260
+ <FilterSubmenu
261
+ label={t('fontPicker.purpose')}
262
+ allLabel={t('fontPicker.allPurposes')}
263
+ kind='metadata'
264
+ value={filters.useCase}
265
+ options={facets.uses}
266
+ onChange={(useCase) => onChange({ ...filters, useCase })}
267
+ />
268
+ <DropdownMenuSeparator />
269
+ <DropdownMenuItem
270
+ disabled={activeFilterCount === 0}
271
+ className='text-[11px]'
272
+ onClick={onClear}
273
+ >
274
+ {t('fontPicker.clearFilters')}
275
+ </DropdownMenuItem>
276
+ </DropdownMenuContent>
277
+ </DropdownMenu>
278
+ )
279
+ }
280
+
281
+ function FilterSubmenu({
282
+ label,
283
+ allLabel,
284
+ kind,
285
+ value,
286
+ options,
287
+ onChange,
288
+ }: {
289
+ label: string
290
+ allLabel: string
291
+ kind: 'source' | 'script' | 'metadata'
292
+ value: string
293
+ options: string[]
294
+ onChange: (value: string) => void
295
+ }) {
296
+ const { t } = useTranslation()
297
+ return (
298
+ <DropdownMenuSub>
299
+ <DropdownMenuSubTrigger className='text-[11px]'>{label}</DropdownMenuSubTrigger>
300
+ <DropdownMenuSubContent className='max-h-60 w-44'>
301
+ <DropdownMenuRadioGroup
302
+ value={value || allFonts}
303
+ onValueChange={(next) => onChange(next === allFonts ? '' : next)}
304
+ >
305
+ <DropdownMenuRadioItem className='text-[11px]' value={allFonts}>
306
+ {allLabel}
307
+ </DropdownMenuRadioItem>
308
+ {options.map((option) => (
309
+ <DropdownMenuRadioItem className='text-[11px]' key={option} value={option}>
310
+ {formatFacet(kind, option, t)}
311
+ </DropdownMenuRadioItem>
312
+ ))}
313
+ </DropdownMenuRadioGroup>
314
+ </DropdownMenuSubContent>
315
+ </DropdownMenuSub>
316
+ )
317
+ }
318
+
319
+ function FontResultSummary({
320
+ count,
321
+ activeFilterCount,
322
+ onClear,
323
+ }: {
324
+ count: number
325
+ activeFilterCount: number
326
+ onClear: () => void
327
+ }) {
328
+ const { t } = useTranslation()
329
+ return (
330
+ <div className='flex h-6 items-center justify-between px-2 text-[9px] text-muted-foreground'>
331
+ <span>{t('fontPicker.fontCount', { count })}</span>
332
+ {activeFilterCount > 0 ? (
333
+ <Button variant='ghost' size='xs' className='h-5 px-1.5 text-[9px]' onClick={onClear}>
334
+ {t('fontPicker.activeFilterCount', { count: activeFilterCount })}
335
+ <X />
336
+ </Button>
337
+ ) : null}
338
+ </div>
339
+ )
340
+ }
341
+
342
+ function FontList({
343
+ families,
344
+ value,
345
+ onSelect,
346
+ }: {
347
+ families: FontFamily[]
348
+ value: string
349
+ onSelect: (family: string) => void
350
+ }) {
351
+ const { t } = useTranslation()
352
+ const list = useRef<HTMLDivElement>(null)
353
+ const selectedIndex = families.findIndex(
354
+ (family) => normalizeFontName(family.name) === normalizeFontName(value),
355
+ )
356
+ const virtualizer = useVirtualizer({
357
+ count: families.length,
358
+ getScrollElement: () => list.current,
359
+ getItemKey: (index) => normalizeFontName(families[index]?.name ?? String(index)),
360
+ estimateSize: () => rowHeight,
361
+ overscan: 6,
362
+ initialOffset: Math.max(
363
+ 0,
364
+ selectedIndex * rowHeight - Math.floor((listHeight / rowHeight - 1) / 2) * rowHeight,
365
+ ),
366
+ initialRect: { width: 240, height: listHeight },
367
+ observeElementRect: (instance, callback) =>
368
+ observeElementRect(instance, (rect) =>
369
+ callback({ width: rect.width || 240, height: rect.height || listHeight }),
370
+ ),
371
+ })
372
+
373
+ return (
374
+ <ScrollArea
375
+ viewportRef={list}
376
+ role='listbox'
377
+ aria-label={t('fontPicker.fonts')}
378
+ className='relative'
379
+ viewportClassName='overflow-x-hidden'
380
+ style={{
381
+ height:
382
+ families.length === 0
383
+ ? rowHeight * 2
384
+ : Math.min(listHeight, Math.max(rowHeight, families.length * rowHeight)),
385
+ }}
386
+ >
387
+ <div className='relative' style={{ height: virtualizer.getTotalSize() }}>
388
+ {virtualizer.getVirtualItems().map((virtualRow) => {
389
+ const family = families[virtualRow.index]
390
+ if (!family) return null
391
+ const selected = normalizeFontName(family.name) === normalizeFontName(value)
392
+ const source = t(
393
+ family.sources.includes('bundled')
394
+ ? 'fontPicker.sources.bundled'
395
+ : 'fontPicker.sources.system',
396
+ )
397
+ return (
398
+ <button
399
+ key={virtualRow.key}
400
+ type='button'
401
+ role='option'
402
+ aria-label={t('fontPicker.fontLabel', { family: family.name, source })}
403
+ aria-selected={selected}
404
+ className={cn(
405
+ 'absolute inset-x-0 top-0 flex h-9.5 w-full items-center px-2 text-left hover:bg-accent focus-visible:bg-accent focus-visible:outline-none',
406
+ selected && 'bg-accent text-accent-foreground',
407
+ )}
408
+ style={{ transform: `translateY(${virtualRow.start}px)` }}
409
+ onClick={() => onSelect(family.name)}
410
+ >
411
+ <div className='flex min-w-0 flex-1 flex-col justify-center gap-px'>
412
+ <FontPreviewLabel family={family} className='h-4.5 w-full min-w-0' />
413
+ <div className='flex min-w-0 items-center justify-between gap-2 text-[8px] leading-none text-muted-foreground'>
414
+ <span className='min-w-0 truncate'>{family.name}</span>
415
+ <span className='shrink-0'>{source}</span>
416
+ </div>
417
+ </div>
418
+ </button>
419
+ )
420
+ })}
421
+ </div>
422
+ {families.length === 0 && (
423
+ <Empty role='status' className='absolute inset-1 rounded-md p-2'>
424
+ <EmptyTitle className='text-[11px] text-muted-foreground'>
425
+ {t('fontPicker.noResults')}
426
+ </EmptyTitle>
427
+ </Empty>
428
+ )}
429
+ </ScrollArea>
430
+ )
431
+ }
432
+
433
+ function FontPreviewLabel({ family, className }: { family: FontFamily; className?: string }) {
434
+ const previewQuery = useFontPreview(family)
435
+ const preview = previewQuery.data
436
+ const [url, setUrl] = useState<string | null>(null)
437
+
438
+ useEffect(() => {
439
+ setUrl(null)
440
+ if (!preview) return
441
+ const next = URL.createObjectURL(new Blob([preview.buffer], { type: 'image/webp' }))
442
+ setUrl(next)
443
+ return () => URL.revokeObjectURL(next)
444
+ }, [preview])
445
+
446
+ return url ? (
447
+ <span className={cn('flex h-full min-w-0 items-center', className)}>
448
+ <img
449
+ src={url}
450
+ alt={family.name}
451
+ className='max-h-[18px] max-w-full object-contain object-left dark:invert'
452
+ />
453
+ </span>
454
+ ) : (
455
+ <span className={cn('truncate font-sans', className)}>{family.name}</span>
456
+ )
457
+ }
458
+
459
+ function fontFacets(families: FontFamily[]) {
460
+ return {
461
+ sources: unique(families.flatMap((family) => family.sources)),
462
+ scripts: unique(
463
+ families.flatMap((family) => [
464
+ ...(family.metadata.primary_script ? [family.metadata.primary_script] : []),
465
+ ...family.metadata.scripts,
466
+ ]),
467
+ ),
468
+ categories: unique(
469
+ families.flatMap((family) => (family.metadata.category ? [family.metadata.category] : [])),
470
+ ),
471
+ uses: unique(
472
+ families.flatMap((family) => [
473
+ ...family.metadata.classifications,
474
+ ...family.metadata.use_cases,
475
+ ]),
476
+ ),
477
+ }
478
+ }
479
+
480
+ function unique(values: string[]): string[] {
481
+ return [...new Set(values)].toSorted((left, right) =>
482
+ formatMetadata(left).localeCompare(formatMetadata(right), undefined, {
483
+ numeric: true,
484
+ sensitivity: 'base',
485
+ }),
486
+ )
487
+ }
488
+
489
+ function formatMetadata(value: string): string {
490
+ return value
491
+ .replaceAll('_', ' ')
492
+ .replaceAll('-', ' ')
493
+ .replace(/\b\w/g, (letter) => letter.toUpperCase())
494
+ }
495
+
496
+ function formatFacet(kind: 'source' | 'script' | 'metadata', value: string, t: TFunction): string {
497
+ if (kind === 'script') {
498
+ return t(`fontPicker.scripts.${value.toLowerCase()}`, { defaultValue: formatMetadata(value) })
499
+ }
500
+ if (kind === 'source') {
501
+ return t(`fontPicker.sources.${value}`, { defaultValue: formatMetadata(value) })
502
+ }
503
+ return formatMetadata(value)
504
+ }
505
+
506
+ function normalizeFontName(value: string): string {
507
+ return value.normalize('NFKC').trim().replace(/\s+/g, ' ').toLowerCase()
508
+ }
509
+
510
+ function scriptRank(family: FontFamily): number {
511
+ const script = family.metadata.primary_script
512
+ if (script === 'latn') return 0
513
+ if (script === 'cyrl') return 1
514
+ if (script === 'hani' || script === 'hira' || script === 'kana') return 2
515
+ if (script === 'hang') return 3
516
+ if (script === 'arab') return 4
517
+ if (script === 'hebr') return 5
518
+ if (['deva', 'beng', 'guru', 'gujr', 'taml', 'telu', 'knda', 'mlym'].includes(script ?? '')) {
519
+ return 6
520
+ }
521
+ if (script === 'thai') return 7
522
+ return fontNameScript(family.name)
523
+ }
524
+
525
+ function fontNameScript(name: string): number {
526
+ for (const character of name) {
527
+ if (/\p{Script=Latin}/u.test(character)) return 0
528
+ if (/\p{Script=Cyrillic}/u.test(character)) return 1
529
+ if (/\p{Script=Han}|\p{Script=Hiragana}|\p{Script=Katakana}/u.test(character)) return 2
530
+ if (/\p{Script=Hangul}/u.test(character)) return 3
531
+ if (/\p{Script=Arabic}/u.test(character)) return 4
532
+ if (/\p{Script=Hebrew}/u.test(character)) return 5
533
+ if (
534
+ /\p{Script=Devanagari}|\p{Script=Bengali}|\p{Script=Gurmukhi}|\p{Script=Gujarati}|\p{Script=Tamil}|\p{Script=Telugu}|\p{Script=Kannada}|\p{Script=Malayalam}/u.test(
535
+ character,
536
+ )
537
+ ) {
538
+ return 6
539
+ }
540
+ if (/\p{Script=Thai}/u.test(character)) return 7
541
+ }
542
+ return 8
543
+ }