@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,1073 @@
1
+ 'use client'
2
+
3
+ import type { TFunction } from 'i18next'
4
+ import {
5
+ AlignCenter,
6
+ AlignLeft,
7
+ AlignRight,
8
+ ArrowDown,
9
+ ArrowUp,
10
+ Brush,
11
+ ChevronDown,
12
+ Eye,
13
+ EyeOff,
14
+ Folder,
15
+ Image as ImageIcon,
16
+ Layers3,
17
+ Lock,
18
+ Minus,
19
+ Plus,
20
+ RotateCcw,
21
+ Trash2,
22
+ Type,
23
+ } from 'lucide-react'
24
+ import { type MouseEvent, useEffect, useId, useMemo, useRef, useState } from 'react'
25
+ import { useTranslation } from 'react-i18next'
26
+
27
+ import { ColorWell } from '@aphrody/koharu-ui/components/controls/ColorWell'
28
+ import { CommitTextarea } from '@aphrody/koharu-ui/components/controls/CommitTextarea'
29
+ import { FontPicker } from '@aphrody/koharu-ui/components/controls/FontPicker'
30
+ import { call } from '@aphrody/koharu-ui/lib/backend'
31
+ import {
32
+ expandLayerSelection,
33
+ isGroupLayer,
34
+ isLockedLayer,
35
+ isTextLayer,
36
+ layerChildren,
37
+ } from '@aphrody/koharu-ui/lib/document'
38
+ import { pageKey, projectKey, queryClient, refresh, useFonts, usePage } from '@aphrody/koharu-ui/lib/queries'
39
+ import { useKoharuStore } from '@aphrody/koharu-ui/lib/store'
40
+ import { previewCanvasOpacity } from '@aphrody/koharu-bridge/canvas'
41
+ import {
42
+ commands,
43
+ type EntityId,
44
+ type FontFamily,
45
+ type FontStyle,
46
+ type Layer,
47
+ type TextAlignment,
48
+ type Typography,
49
+ type WritingMode,
50
+ } from '@aphrody/koharu-bridge/protocol'
51
+ import { Button } from '../../internal/components/button'
52
+ import {
53
+ DropdownMenu,
54
+ DropdownMenuContent,
55
+ DropdownMenuRadioGroup,
56
+ DropdownMenuRadioItem,
57
+ DropdownMenuTrigger,
58
+ } from '../../internal/components/dropdown-menu'
59
+ import {
60
+ NumberField,
61
+ NumberFieldDecrement,
62
+ NumberFieldGroup,
63
+ NumberFieldIncrement,
64
+ NumberFieldInput,
65
+ } from '../../internal/components/number-field'
66
+ import { ScrollArea } from '../../internal/components/scroll-area'
67
+ import {
68
+ Select,
69
+ SelectContent,
70
+ SelectItem,
71
+ SelectTrigger,
72
+ SelectValue,
73
+ } from '../../internal/components/select'
74
+ import { Slider } from '../../internal/components/slider'
75
+ import { Tooltip, TooltipContent, TooltipTrigger } from '../../internal/components/tooltip'
76
+
77
+ const defaultFont: FontFamily = {
78
+ name: 'CCWildWords',
79
+ metadata: {
80
+ primary_script: 'latn',
81
+ scripts: ['latn'],
82
+ languages: ['en'],
83
+ category: 'HANDWRITING',
84
+ classifications: ['comic', 'dialogue'],
85
+ use_cases: ['comic-dialogue', 'word-balloons'],
86
+ },
87
+ sources: ['bundled'],
88
+ faces: [
89
+ {
90
+ postscript_name: 'CCWildWords-Regular',
91
+ weight: 400,
92
+ weight_range: null,
93
+ style: 'normal',
94
+ },
95
+ ],
96
+ }
97
+
98
+ const defaultTypography: Typography = {
99
+ preferred_font: null,
100
+ font_weight: 400,
101
+ font_style: 'normal',
102
+ size: null,
103
+ auto_fit: true,
104
+ color: [0, 0, 0, 255],
105
+ stroke_color: [255, 255, 255, 255],
106
+ stroke_width: 0,
107
+ alignment: null,
108
+ writing_mode: null,
109
+ }
110
+
111
+ export function Inspector() {
112
+ const { t } = useTranslation()
113
+ return (
114
+ <aside className='flex h-full min-h-0 flex-col bg-[var(--surface-panel)]'>
115
+ <div className='flex h-8 shrink-0 items-center gap-1.5 border-b border-border/80 px-2'>
116
+ <Type className='size-3 text-primary' />
117
+ <h2 className='text-[10px] font-semibold'>{t('inspector.type')}</h2>
118
+ </div>
119
+
120
+ <section className='h-48 min-w-0 shrink-0 overflow-hidden border-b'>
121
+ <TypeInspector />
122
+ </section>
123
+
124
+ <section className='flex min-h-0 flex-1 flex-col'>
125
+ <LayersInspector />
126
+ </section>
127
+ </aside>
128
+ )
129
+ }
130
+
131
+ function TypeInspector() {
132
+ const { t } = useTranslation()
133
+ const borderWidthId = useId()
134
+ const page = usePage().data
135
+ const selectedIds = useKoharuStore((state) => state.selectedLayers)
136
+ const availableFonts = useFonts().data
137
+ const expandedSelection = page ? expandLayerSelection(page.layers, selectedIds) : []
138
+ const selected =
139
+ page?.layers.filter(isTextLayer).filter((layer) => expandedSelection.includes(layer.id)) ?? []
140
+ const current = selected[0]
141
+ const [draft, setDraft] = useState<{
142
+ layer: EntityId
143
+ typography: Typography
144
+ } | null>(null)
145
+ const updateSequence = useRef(0)
146
+
147
+ useEffect(() => setDraft(null), [current?.id])
148
+
149
+ const apply = (update: (value: Typography) => Typography) => {
150
+ if (!selected.length) return
151
+ const updates = selected.map((layer) => ({
152
+ layer: layer.id,
153
+ typography: update(layer.typography ?? defaultTypography),
154
+ }))
155
+ const optimistic = current && updates.find(({ layer }) => layer === current.id)
156
+ if (optimistic) setDraft(optimistic)
157
+ const sequence = ++updateSequence.current
158
+ void call(commands.setTypography, updates)
159
+ .then(() => refresh(projectKey, pageKey))
160
+ .catch(() => undefined)
161
+ .finally(() => {
162
+ if (updateSequence.current === sequence) setDraft(null)
163
+ })
164
+ }
165
+
166
+ const typography =
167
+ current && draft?.layer === current.id
168
+ ? draft.typography
169
+ : (current?.typography ?? defaultTypography)
170
+ const disabled = !current
171
+ const families = useMemo(() => {
172
+ const available = availableFonts ?? []
173
+ return available.some(
174
+ (family) => normalizeFontName(family.name) === normalizeFontName(defaultFont.name),
175
+ )
176
+ ? available
177
+ : [...available, defaultFont]
178
+ }, [availableFonts])
179
+ const size = Math.round((typography.size ?? 24) * 100) / 100
180
+ const weight = typography.font_weight ?? 400
181
+ const selectedFamily = findFontFamily(families, typography.preferred_font ?? defaultFont.name)
182
+ const styles = usableFontStyles(selectedFamily)
183
+ const style = styles.includes(typography.font_style ?? 'normal')
184
+ ? (typography.font_style ?? 'normal')
185
+ : (styles[0] ?? 'normal')
186
+ const weights = usableFontWeights(selectedFamily, style)
187
+ const strokeWidth = typography.stroke_width ?? 0
188
+ const strokeColor = typography.stroke_color ?? defaultTypography.stroke_color!
189
+ const strokeEnabled = strokeWidth > 0 && strokeColor[3] > 0
190
+ const displayedStrokeWidth = strokeWidth > 0 ? strokeWidth : 1.5
191
+ const writingMode = typography.writing_mode ?? 'Horizontal'
192
+ const writingModeChoice = typography.writing_mode ?? 'Auto'
193
+ const effectiveAlignment =
194
+ typography.alignment ?? (writingMode === 'Vertical' ? 'Start' : 'Center')
195
+
196
+ return (
197
+ <div className='min-w-0 p-2' data-testid='type-inspector' aria-disabled={disabled}>
198
+ <div className='grid min-w-0 gap-1.5'>
199
+ <div className='grid min-w-0 grid-cols-[minmax(0,1fr)_2.5rem] gap-1.5'>
200
+ <InspectorField label={t('inspector.font')}>
201
+ <FontPicker
202
+ value={typography.preferred_font ?? defaultFont.name}
203
+ families={families}
204
+ disabled={disabled}
205
+ size='sm'
206
+ onChange={(preferred_font) => {
207
+ const family = findFontFamily(families, preferred_font)
208
+ const nextStyles = usableFontStyles(family)
209
+ const fontStyle = nextStyles.includes(style) ? style : (nextStyles[0] ?? 'normal')
210
+ const nextWeights = usableFontWeights(family, fontStyle)
211
+ const fontWeight = nearestFontWeight(nextWeights, weight)
212
+ apply((value) => ({
213
+ ...value,
214
+ preferred_font,
215
+ font_weight: fontWeight,
216
+ font_style: fontStyle,
217
+ }))
218
+ }}
219
+ />
220
+ </InspectorField>
221
+ <InspectorField label={t('inspector.color')}>
222
+ <ColorWell
223
+ label={t('inspector.textColor')}
224
+ size='sm'
225
+ disabled={disabled}
226
+ value={rgbaToHex(typography.color ?? defaultTypography.color!)}
227
+ onChange={(color) => apply((value) => ({ ...value, color: hexToRgba(color) }))}
228
+ />
229
+ </InspectorField>
230
+ </div>
231
+
232
+ <div className='grid min-w-0 grid-cols-[minmax(0,1fr)_4.25rem_4.75rem] gap-1.5'>
233
+ <InspectorField label={t('inspector.size')}>
234
+ <FontSizeField
235
+ disabled={disabled}
236
+ value={size}
237
+ autoFit={typography.auto_fit}
238
+ onChange={(next) => apply((value) => ({ ...value, size: next, auto_fit: false }))}
239
+ onAutoFit={() =>
240
+ apply((value) => ({
241
+ ...value,
242
+ size: value.size ?? size,
243
+ auto_fit: true,
244
+ }))
245
+ }
246
+ />
247
+ </InspectorField>
248
+ <InspectorField label={t('inspector.weight')}>
249
+ <Select
250
+ disabled={disabled}
251
+ value={String(weight)}
252
+ onValueChange={(font_weight) =>
253
+ apply((value) => ({
254
+ ...value,
255
+ font_weight: Number(font_weight),
256
+ }))
257
+ }
258
+ >
259
+ <SelectTrigger
260
+ size='sm'
261
+ aria-label={t('inspector.fontWeight')}
262
+ className='w-full min-w-0'
263
+ >
264
+ <SelectValue />
265
+ </SelectTrigger>
266
+ <SelectContent>
267
+ {weights.map((value) => (
268
+ <SelectItem key={value} value={String(value)}>
269
+ {value}
270
+ </SelectItem>
271
+ ))}
272
+ </SelectContent>
273
+ </Select>
274
+ </InspectorField>
275
+ <InspectorField label={t('inspector.style')}>
276
+ <Select
277
+ disabled={disabled}
278
+ value={style}
279
+ onValueChange={(font_style) => {
280
+ const nextStyle = font_style as FontStyle
281
+ const nextWeights = usableFontWeights(selectedFamily, nextStyle)
282
+ apply((value) => ({
283
+ ...value,
284
+ font_style: nextStyle,
285
+ font_weight: nearestFontWeight(nextWeights, weight),
286
+ }))
287
+ }}
288
+ >
289
+ <SelectTrigger
290
+ size='sm'
291
+ aria-label={t('inspector.fontStyle')}
292
+ className='w-full min-w-0'
293
+ >
294
+ <SelectValue>{t(`inspector.fontStyles.${style}`)}</SelectValue>
295
+ </SelectTrigger>
296
+ <SelectContent align='end'>
297
+ {styles.map((value) => (
298
+ <SelectItem key={value} value={value}>
299
+ {t(`inspector.fontStyles.${value}`)}
300
+ </SelectItem>
301
+ ))}
302
+ </SelectContent>
303
+ </Select>
304
+ </InspectorField>
305
+ </div>
306
+
307
+ <div className='grid min-w-0 grid-cols-[minmax(0,1fr)_minmax(6.5rem,1fr)] gap-1.5'>
308
+ <InspectorField label={t('inspector.alignment')}>
309
+ <div className='grid h-6 grid-cols-3 rounded-md border border-input bg-background p-px'>
310
+ {(
311
+ [
312
+ [
313
+ 'Start',
314
+ AlignLeft,
315
+ writingMode === 'Vertical' ? t('inspector.alignTop') : t('inspector.alignLeft'),
316
+ ],
317
+ [
318
+ 'Center',
319
+ AlignCenter,
320
+ writingMode === 'Vertical'
321
+ ? t('inspector.alignMiddle')
322
+ : t('inspector.alignCenter'),
323
+ ],
324
+ [
325
+ 'End',
326
+ AlignRight,
327
+ writingMode === 'Vertical'
328
+ ? t('inspector.alignBottom')
329
+ : t('inspector.alignRight'),
330
+ ],
331
+ ] as const
332
+ ).map(([alignment, Icon, label]) => (
333
+ <button
334
+ key={alignment}
335
+ type='button'
336
+ aria-label={label}
337
+ aria-pressed={effectiveAlignment === alignment}
338
+ disabled={disabled}
339
+ data-active={effectiveAlignment === alignment}
340
+ className='grid place-items-center rounded-[4px] text-muted-foreground hover:text-foreground disabled:cursor-not-allowed disabled:opacity-40 data-[active=true]:bg-primary data-[active=true]:text-primary-foreground data-[active=true]:hover:bg-primary/90'
341
+ onClick={() =>
342
+ apply((value) => ({
343
+ ...value,
344
+ alignment: alignment as TextAlignment,
345
+ }))
346
+ }
347
+ >
348
+ <Icon className='size-3' />
349
+ </button>
350
+ ))}
351
+ </div>
352
+ </InspectorField>
353
+ <InspectorField label={t('inspector.direction')}>
354
+ <Select
355
+ disabled={disabled}
356
+ value={writingModeChoice}
357
+ onValueChange={(writing_mode) =>
358
+ apply((value) => ({
359
+ ...value,
360
+ writing_mode: writing_mode === 'Auto' ? null : (writing_mode as WritingMode),
361
+ }))
362
+ }
363
+ >
364
+ <SelectTrigger size='sm' aria-label={t('inspector.textDirection')} className='w-full'>
365
+ <SelectValue />
366
+ </SelectTrigger>
367
+ <SelectContent>
368
+ <SelectItem value='Auto'>{t('inspector.auto')}</SelectItem>
369
+ <SelectItem value='Horizontal'>{t('inspector.horizontal')}</SelectItem>
370
+ <SelectItem value='Vertical'>{t('inspector.vertical')}</SelectItem>
371
+ </SelectContent>
372
+ </Select>
373
+ </InspectorField>
374
+ </div>
375
+
376
+ <div className='grid min-w-0 grid-cols-[2.75rem_minmax(5.5rem,1fr)] items-end gap-1.5'>
377
+ <InspectorField label={t('inspector.border')}>
378
+ <ColorWell
379
+ label={t('inspector.borderColor')}
380
+ size='sm'
381
+ disabled={disabled}
382
+ allowTransparent
383
+ value={strokeEnabled ? rgbaToHex(strokeColor) : null}
384
+ onChange={(stroke_color) => {
385
+ apply((value) => {
386
+ const width = value.stroke_width ?? 0
387
+ if (stroke_color !== null) {
388
+ return {
389
+ ...value,
390
+ stroke_color: hexToRgba(stroke_color),
391
+ stroke_width: width > 0 ? width : 1.5,
392
+ }
393
+ }
394
+ const [red, green, blue] = value.stroke_color ?? defaultTypography.stroke_color!
395
+ return {
396
+ ...value,
397
+ stroke_color: [red, green, blue, 0],
398
+ stroke_width: width > 0 ? width : 1.5,
399
+ }
400
+ })
401
+ }}
402
+ />
403
+ </InspectorField>
404
+ <InspectorField label={t('inspector.width')}>
405
+ <NumberField
406
+ id={borderWidthId}
407
+ name='border-width'
408
+ className='min-w-0'
409
+ disabled={disabled}
410
+ value={displayedStrokeWidth}
411
+ min={0.5}
412
+ max={32}
413
+ step={0.5}
414
+ onValueChange={(next) => {
415
+ if (next !== null && next >= 0.5 && next <= 32) {
416
+ apply((value) => ({ ...value, stroke_width: next }))
417
+ }
418
+ }}
419
+ >
420
+ <NumberFieldGroup>
421
+ <NumberFieldDecrement aria-label={t('inspector.decreaseBorderWidth')}>
422
+ <Minus />
423
+ </NumberFieldDecrement>
424
+ <NumberFieldInput aria-label={t('inspector.borderWidth')} />
425
+ <NumberFieldIncrement aria-label={t('inspector.increaseBorderWidth')}>
426
+ <Plus />
427
+ </NumberFieldIncrement>
428
+ </NumberFieldGroup>
429
+ </NumberField>
430
+ </InspectorField>
431
+ </div>
432
+ </div>
433
+ </div>
434
+ )
435
+ }
436
+
437
+ function findFontFamily(families: FontFamily[], name: string): FontFamily | undefined {
438
+ return families.find((family) => normalizeFontName(family.name) === normalizeFontName(name))
439
+ }
440
+
441
+ function usableFontStyles(family: FontFamily | undefined): FontStyle[] {
442
+ if (!family) return ['normal']
443
+ const styles = new Set(family.faces.map((font) => font.style))
444
+ const available = (['normal', 'italic', 'oblique'] satisfies FontStyle[]).filter((style) =>
445
+ styles.has(style),
446
+ )
447
+ return available.length ? available : ['normal']
448
+ }
449
+
450
+ function usableFontWeights(family: FontFamily | undefined, style: FontStyle): number[] {
451
+ if (!family) return [400]
452
+ const styled = family.faces.filter((font) => font.style === style)
453
+ const faces = styled.length ? styled : family.faces
454
+ const weights = new Set(faces.map((font) => font.weight))
455
+ for (const face of faces) {
456
+ if (!face.weight_range) continue
457
+ weights.add(face.weight_range.minimum)
458
+ weights.add(face.weight_range.maximum)
459
+ for (let weight = 100; weight <= 900; weight += 100) {
460
+ if (weight >= face.weight_range.minimum && weight <= face.weight_range.maximum) {
461
+ weights.add(weight)
462
+ }
463
+ }
464
+ }
465
+ return [...weights].toSorted((left, right) => left - right)
466
+ }
467
+
468
+ function nearestFontWeight(weights: number[], target: number): number {
469
+ return weights.reduce((nearest, weight) =>
470
+ Math.abs(weight - target) < Math.abs(nearest - target) ? weight : nearest,
471
+ )
472
+ }
473
+
474
+ function normalizeFontName(value: string): string {
475
+ return value.normalize('NFKC').trim().replace(/\s+/g, ' ').toLowerCase()
476
+ }
477
+
478
+ function displayedLayers(layers: Layer[], page: EntityId) {
479
+ const indexes = new Map(layers.map((layer, index) => [layer.id, index]))
480
+ const rows: { layer: Layer; index: number; depth: number }[] = []
481
+ const append = (layer: Layer, depth: number) => {
482
+ rows.push({ layer, index: indexes.get(layer.id) ?? 0, depth })
483
+ if (!isGroupLayer(layer)) return
484
+ const children = layerChildren(layers, layer.id)
485
+ const ordered = layer.role === 'text' ? children : [...children].toReversed()
486
+ for (const child of ordered) append(child, depth + 1)
487
+ }
488
+ const roots = layers.filter((layer) => (layer.parent ?? page) === page).toReversed()
489
+ for (const layer of roots) append(layer, 0)
490
+ return rows
491
+ }
492
+
493
+ function LayersInspector() {
494
+ const { t } = useTranslation()
495
+ const page = usePage().data
496
+ const selected = useKoharuStore((state) => state.selectedLayers)
497
+ const selectLayers = useKoharuStore((state) => state.selectLayers)
498
+ const [expandedLayer, setExpandedLayer] = useState<EntityId | null>(
499
+ selected.length === 1 ? (selected[0] ?? null) : null,
500
+ )
501
+ const [movingLayer, setMovingLayer] = useState<EntityId | null>(null)
502
+ const anchor = useRef<EntityId | null>(null)
503
+
504
+ useEffect(() => {
505
+ setExpandedLayer(selected.length === 1 ? (selected[0] ?? null) : null)
506
+ }, [selected])
507
+
508
+ const layers = useMemo(() => (page ? displayedLayers(page.layers, page.id) : []), [page])
509
+
510
+ if (!page) return <EmptyInspector>{t('inspector.selectPage')}</EmptyInspector>
511
+
512
+ const move = (layer: Layer, displayDelta: number) => {
513
+ if (movingLayer !== null || isLockedLayer(layer)) return
514
+ const parent = layer.parent ?? page.id
515
+ const storedSiblings = page.layers.filter(
516
+ (candidate) => !isLockedLayer(candidate) && (candidate.parent ?? page.id) === parent,
517
+ )
518
+ const parentLayer = page.layers.find((candidate) => candidate.id === parent)
519
+ const shownSiblings =
520
+ parentLayer && isGroupLayer(parentLayer) && parentLayer.role === 'text'
521
+ ? storedSiblings
522
+ : [...storedSiblings].toReversed()
523
+ const shownSource = shownSiblings.findIndex((candidate) => candidate.id === layer.id)
524
+ const shownTarget = shownSource + displayDelta
525
+ const targetLayer = shownSiblings[shownTarget]
526
+ if (shownSource < 0 || !targetLayer) return
527
+ const target = storedSiblings.findIndex((candidate) => candidate.id === targetLayer.id)
528
+
529
+ setMovingLayer(layer.id)
530
+ void call(commands.moveLayer, layer.id, parent, target).then(
531
+ (next) => {
532
+ queryClient.setQueryData(pageKey, next)
533
+ setMovingLayer(null)
534
+ void refresh(projectKey)
535
+ },
536
+ () => setMovingLayer(null),
537
+ )
538
+ }
539
+
540
+ const deleteLayer = (layer: EntityId) =>
541
+ void call(commands.deleteLayers, [layer])
542
+ .then(() => {
543
+ if (selected.includes(layer)) {
544
+ selectLayers(selected.filter((selectedLayer) => selectedLayer !== layer))
545
+ }
546
+ setExpandedLayer((current) => (current === layer ? null : current))
547
+ return refresh(projectKey, pageKey)
548
+ })
549
+ .catch(() => undefined)
550
+
551
+ // Modifier semantics mirror the page rail: ctrl/meta toggles, shift selects a display range.
552
+ const selectLayer = (layer: EntityId, additive: boolean, range: boolean) => {
553
+ if (!additive && !range) {
554
+ anchor.current = layer
555
+ if (selected.length === 1 && selected[0] === layer) {
556
+ setExpandedLayer((current) => (current === layer ? null : layer))
557
+ return
558
+ }
559
+ selectLayers([layer])
560
+ setExpandedLayer(layer)
561
+ return
562
+ }
563
+ const order = layers.map((row) => row.layer.id)
564
+ const anchorIndex = anchor.current ? order.indexOf(anchor.current) : -1
565
+ if (range && anchorIndex >= 0) {
566
+ const targetIndex = order.indexOf(layer)
567
+ const span = layers
568
+ .slice(Math.min(anchorIndex, targetIndex), Math.max(anchorIndex, targetIndex) + 1)
569
+ .filter((row) => !isLockedLayer(row.layer))
570
+ .map((row) => row.layer.id)
571
+ selectLayers(additive ? [...selected, ...span] : span)
572
+ return
573
+ }
574
+ anchor.current = layer
575
+ if (!additive) {
576
+ selectLayers([layer])
577
+ return
578
+ }
579
+ selectLayers(
580
+ selected.includes(layer)
581
+ ? selected.filter((selectedLayer) => selectedLayer !== layer)
582
+ : [...selected, layer],
583
+ )
584
+ }
585
+
586
+ return (
587
+ <div className='flex min-h-0 flex-1 flex-col'>
588
+ <header className='flex h-8 shrink-0 items-center gap-1.5 border-b border-border/80 px-2'>
589
+ <Layers3 className='size-3 text-primary' />
590
+ <h2 className='text-[10px] font-semibold'>{t('layers.title')}</h2>
591
+ <span className='text-[9px] text-muted-foreground tabular-nums'>
592
+ {page.layers.filter((layer) => !isGroupLayer(layer)).length}
593
+ </span>
594
+ </header>
595
+
596
+ <ScrollArea className='min-h-0 flex-1'>
597
+ <div className='py-0.5'>
598
+ {layers.map(({ layer, index, depth }) => {
599
+ const locked = isLockedLayer(layer)
600
+ const storedSiblings = page.layers.filter(
601
+ (candidate) =>
602
+ !isLockedLayer(candidate) &&
603
+ (candidate.parent ?? page.id) === (layer.parent ?? page.id),
604
+ )
605
+ const parentLayer = page.layers.find((candidate) => candidate.id === layer.parent)
606
+ const siblings =
607
+ parentLayer && isGroupLayer(parentLayer) && parentLayer.role === 'text'
608
+ ? storedSiblings
609
+ : [...storedSiblings].toReversed()
610
+ const position = siblings.findIndex((candidate) => candidate.id === layer.id)
611
+ return (
612
+ <LayerRow
613
+ key={`${layer.type}:${layer.id}`}
614
+ layer={layer}
615
+ index={index}
616
+ depth={depth}
617
+ selected={selected.includes(layer.id)}
618
+ expanded={!locked && expandedLayer === layer.id}
619
+ locked={locked}
620
+ onSelect={(event) =>
621
+ selectLayer(layer.id, event.ctrlKey || event.metaKey, event.shiftKey)
622
+ }
623
+ onToggle={() =>
624
+ void call(commands.setVisibility, [layer.id], !layer.visibility.visible, null)
625
+ .then(() => refresh(projectKey, pageKey))
626
+ .catch(() => undefined)
627
+ }
628
+ onMove={(delta) => move(layer, delta)}
629
+ canMoveUp={!locked && position > 0}
630
+ canMoveDown={!locked && position >= 0 && position < siblings.length - 1}
631
+ reordering={movingLayer !== null}
632
+ onDelete={isGroupLayer(layer) ? undefined : () => deleteLayer(layer.id)}
633
+ />
634
+ )
635
+ })}
636
+ {layers.length === 0 && <EmptyInspector>{t('layers.empty')}</EmptyInspector>}
637
+ </div>
638
+ </ScrollArea>
639
+ </div>
640
+ )
641
+ }
642
+
643
+ function LayerRow({
644
+ layer,
645
+ index,
646
+ depth,
647
+ selected,
648
+ expanded,
649
+ locked,
650
+ onSelect,
651
+ onToggle,
652
+ onMove,
653
+ canMoveUp,
654
+ canMoveDown,
655
+ reordering,
656
+ onDelete,
657
+ }: {
658
+ layer: Layer
659
+ index: number
660
+ depth: number
661
+ selected: boolean
662
+ expanded: boolean
663
+ locked: boolean
664
+ onSelect: (event: MouseEvent<HTMLButtonElement>) => void
665
+ onToggle: () => void
666
+ onMove: (delta: number) => void
667
+ canMoveUp: boolean
668
+ canMoveDown: boolean
669
+ reordering: boolean
670
+ onDelete?: () => void
671
+ }) {
672
+ const { t } = useTranslation()
673
+ const name = localizedLayerName(layer, index, t)
674
+ const detail = localizedLayerKind(layer, t)
675
+ const Icon = layerIcon(layer)
676
+ return (
677
+ <div className='group min-w-0 px-1 py-px' style={{ paddingLeft: `${depth * 10 + 4}px` }}>
678
+ <div
679
+ data-selected={selected}
680
+ data-expanded={expanded}
681
+ className='min-w-0 overflow-hidden rounded-lg transition-colors duration-150 data-[selected=true]:bg-accent motion-reduce:transition-none'
682
+ >
683
+ <div className='relative flex min-w-0 items-center gap-0.5'>
684
+ <button
685
+ type='button'
686
+ aria-label={t('layers.edit', { name })}
687
+ aria-expanded={locked ? undefined : expanded}
688
+ disabled={locked}
689
+ className='flex min-w-0 flex-1 items-center gap-1.5 rounded-lg px-1.5 py-1 text-left hover:bg-foreground/[0.05] focus-visible:ring-2 focus-visible:ring-ring/25'
690
+ onClick={onSelect}
691
+ >
692
+ <Icon className='size-3.5 shrink-0 text-muted-foreground' />
693
+ <span className='min-w-0 flex-1'>
694
+ <span className='block truncate text-[11px] font-medium'>{name}</span>
695
+ <span className='block truncate text-[9px] leading-3 text-muted-foreground capitalize'>
696
+ {detail}
697
+ </span>
698
+ </span>
699
+ </button>
700
+ {!locked && (
701
+ <div
702
+ className={`pointer-events-none absolute top-1/2 z-10 flex -translate-y-1/2 rounded-md bg-background/80 p-0.5 opacity-0 shadow-sm ring-1 ring-border/40 backdrop-blur-md transition-opacity duration-150 group-hover:pointer-events-auto group-hover:opacity-100 focus-within:pointer-events-auto focus-within:opacity-100 motion-reduce:transition-none ${expanded ? 'right-7' : 'right-[3.25rem]'}`}
703
+ >
704
+ <button
705
+ type='button'
706
+ aria-label={t('layers.moveUp', { name })}
707
+ disabled={reordering || !canMoveUp}
708
+ className='grid size-5 place-items-center rounded-sm text-muted-foreground hover:bg-foreground/[0.07] hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring/25 disabled:pointer-events-none disabled:opacity-30'
709
+ onClick={() => onMove(-1)}
710
+ >
711
+ <ArrowUp className='size-3' />
712
+ </button>
713
+ <button
714
+ type='button'
715
+ aria-label={t('layers.moveDown', { name })}
716
+ disabled={reordering || !canMoveDown}
717
+ className='grid size-5 place-items-center rounded-sm text-muted-foreground hover:bg-foreground/[0.07] hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring/25 disabled:pointer-events-none disabled:opacity-30'
718
+ onClick={() => onMove(1)}
719
+ >
720
+ <ArrowDown className='size-3' />
721
+ </button>
722
+ </div>
723
+ )}
724
+ {!expanded && (
725
+ <span className='w-7 shrink-0 text-right text-[9px] text-muted-foreground tabular-nums'>
726
+ {Math.round(layer.visibility.opacity * 100)}%
727
+ </span>
728
+ )}
729
+ {locked ? (
730
+ <Tooltip>
731
+ <TooltipTrigger
732
+ render={
733
+ <span
734
+ role='img'
735
+ className='grid size-6 shrink-0 place-items-center text-muted-foreground'
736
+ aria-label={t('layers.lockedLabel', { name })}
737
+ />
738
+ }
739
+ >
740
+ <Lock className='size-3.5' />
741
+ </TooltipTrigger>
742
+ <TooltipContent side='left'>{t('layers.locked')}</TooltipContent>
743
+ </Tooltip>
744
+ ) : (
745
+ <button
746
+ type='button'
747
+ aria-label={
748
+ layer.visibility.visible ? t('layers.hide', { name }) : t('layers.show', { name })
749
+ }
750
+ className='grid size-6 shrink-0 place-items-center rounded-md text-muted-foreground hover:bg-foreground/[0.06] hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring/25'
751
+ onClick={onToggle}
752
+ >
753
+ {layer.visibility.visible ? (
754
+ <Eye className='size-3.5' />
755
+ ) : (
756
+ <EyeOff className='size-3.5' />
757
+ )}
758
+ </button>
759
+ )}
760
+ </div>
761
+ {expanded && (
762
+ <div className='animate-in duration-150 fade-in slide-in-from-top-1 motion-reduce:animate-none'>
763
+ <LayerEditor layer={layer} onDelete={onDelete} />
764
+ </div>
765
+ )}
766
+ </div>
767
+ </div>
768
+ )
769
+ }
770
+
771
+ function LayerEditor({ layer, onDelete }: { layer: Layer; onDelete?: () => void }) {
772
+ const { t } = useTranslation()
773
+ const name = localizedLayerName(layer, 0, t)
774
+ const [opacity, setOpacity] = useState(layer.visibility.opacity * 100)
775
+
776
+ useEffect(() => {
777
+ setOpacity(layer.visibility.opacity * 100)
778
+ }, [layer.id, layer.visibility.opacity])
779
+
780
+ const commitOpacity = (next: number) => {
781
+ void call(commands.setVisibility, [layer.id], null, next / 100)
782
+ .then(() => refresh(projectKey, pageKey))
783
+ .catch(() => {
784
+ setOpacity(layer.visibility.opacity * 100)
785
+ previewCanvasOpacity(layer.id, null)
786
+ })
787
+ }
788
+
789
+ const previewOpacity = (next: number) => {
790
+ setOpacity(next)
791
+ previewCanvasOpacity(layer.id, next / 100)
792
+ }
793
+
794
+ const resetTextFrame = () => {
795
+ if (!isTextLayer(layer) || !layer.automatic_region || !layer.geometry) return
796
+ void call(commands.setGeometry, [{ layer: layer.id, points: null }])
797
+ .then(() => refresh(projectKey, pageKey))
798
+ .catch(() => undefined)
799
+ }
800
+
801
+ return (
802
+ <div className='grid min-w-0 gap-1.5 px-1.5 pt-0.5 pb-1.5'>
803
+ <div className='flex min-w-0 items-center gap-1.5'>
804
+ <span className='shrink-0 text-[8px] font-medium text-muted-foreground uppercase'>
805
+ {t('inspector.opacity')}
806
+ </span>
807
+ <div className='flex min-w-0 flex-1 items-center gap-1.5'>
808
+ <Slider
809
+ aria-label={t('layers.opacityLabel', { name })}
810
+ min={0}
811
+ max={100}
812
+ step={1}
813
+ value={opacity}
814
+ className='[&_[data-slot=slider-thumb]]:size-2'
815
+ onValueChange={previewOpacity}
816
+ onValueCommitted={commitOpacity}
817
+ />
818
+ <span className='w-7 shrink-0 text-right text-[8px] text-muted-foreground tabular-nums'>
819
+ {Math.round(opacity)}%
820
+ </span>
821
+ </div>
822
+ {onDelete && (
823
+ <Tooltip>
824
+ <TooltipTrigger
825
+ render={
826
+ <Button
827
+ type='button'
828
+ variant='ghost'
829
+ size='icon-xs'
830
+ aria-label={t('layers.delete', { name })}
831
+ className='size-5 rounded-md text-muted-foreground hover:text-foreground'
832
+ onClick={onDelete}
833
+ />
834
+ }
835
+ >
836
+ <Trash2 className='size-3' />
837
+ </TooltipTrigger>
838
+ <TooltipContent side='left'>{t('layers.delete', { name })}</TooltipContent>
839
+ </Tooltip>
840
+ )}
841
+ </div>
842
+ {isTextLayer(layer) && (
843
+ <>
844
+ <div className='flex h-5 min-w-0 items-center gap-1.5'>
845
+ <span className='text-[8px] font-medium text-muted-foreground uppercase'>
846
+ {t('inspector.placement')}
847
+ </span>
848
+ <span className='rounded-md bg-foreground/[0.055] px-1.5 py-0.5 text-[9px] leading-none text-foreground/75'>
849
+ {layer.geometry
850
+ ? t('inspector.customFrame')
851
+ : layer.automatic_region
852
+ ? t('inspector.autoFit')
853
+ : t('inspector.unplaced')}
854
+ </span>
855
+ {layer.geometry && layer.automatic_region && (
856
+ <Tooltip>
857
+ <TooltipTrigger
858
+ render={
859
+ <Button
860
+ type='button'
861
+ variant='ghost'
862
+ size='xs'
863
+ aria-label={t('inspector.resetAutoFit')}
864
+ className='ml-auto h-5 gap-1 rounded-md px-1.5 text-[9px] font-normal text-muted-foreground hover:text-foreground'
865
+ onClick={resetTextFrame}
866
+ />
867
+ }
868
+ >
869
+ <RotateCcw className='size-3' />
870
+ {t('common.reset')}
871
+ </TooltipTrigger>
872
+ <TooltipContent side='left'>{t('inspector.resetAutoFit')}</TooltipContent>
873
+ </Tooltip>
874
+ )}
875
+ </div>
876
+ <InspectorField label={t('inspector.source')}>
877
+ <CommitTextarea
878
+ data-testid={`edit-source-${layer.id}`}
879
+ aria-label={t('layers.sourceLabel', { name })}
880
+ wrap='soft'
881
+ className='max-h-14 min-h-8 w-full max-w-full min-w-0 resize-y overflow-y-auto rounded-md bg-background px-1.5 py-1 text-[12px] leading-4 md:text-[12px]'
882
+ value={layer.content.source?.text ?? ''}
883
+ onCommit={(text) =>
884
+ void call(commands.setSourceText, layer.id, text)
885
+ .then(() => refresh(projectKey, pageKey))
886
+ .catch(() => undefined)
887
+ }
888
+ />
889
+ </InspectorField>
890
+ <InspectorField label={t('inspector.translation')}>
891
+ <CommitTextarea
892
+ data-testid={`edit-translation-${layer.id}`}
893
+ aria-label={t('layers.translationLabel', { name })}
894
+ wrap='soft'
895
+ className='max-h-16 min-h-9 w-full max-w-full min-w-0 resize-y overflow-y-auto rounded-md border-primary/25 bg-background px-1.5 py-1 text-[12px] leading-4 md:text-[12px]'
896
+ value={layer.content.translation?.text ?? ''}
897
+ onCommit={(text) =>
898
+ void call(commands.setTranslation, layer.id, text.trim() ? text : null)
899
+ .then(() => refresh(projectKey, pageKey))
900
+ .catch(() => undefined)
901
+ }
902
+ />
903
+ </InspectorField>
904
+ </>
905
+ )}
906
+ </div>
907
+ )
908
+ }
909
+
910
+ function InspectorField({ label, children }: { label: string; children: React.ReactNode }) {
911
+ return (
912
+ <div className='grid min-w-0 gap-0.5'>
913
+ <span className='text-[8px] font-medium tracking-[0.06em] text-muted-foreground uppercase'>
914
+ {label}
915
+ </span>
916
+ {children}
917
+ </div>
918
+ )
919
+ }
920
+
921
+ const fontSizePresets = [8, 9, 10, 12, 14, 16, 18, 20, 24, 28, 32, 36, 48, 64, 72, 96]
922
+
923
+ function FontSizeField({
924
+ value,
925
+ autoFit,
926
+ disabled,
927
+ onChange,
928
+ onAutoFit,
929
+ }: {
930
+ value: number
931
+ autoFit: boolean
932
+ disabled: boolean
933
+ onChange: (value: number) => void
934
+ onAutoFit: () => void
935
+ }) {
936
+ const { t } = useTranslation()
937
+ const id = useId()
938
+ const [draft, setDraft] = useState<number | null>(value)
939
+
940
+ useEffect(() => {
941
+ setDraft(autoFit ? null : value)
942
+ }, [autoFit, value])
943
+
944
+ const select = (choice: string) => {
945
+ if (choice === 'auto') {
946
+ setDraft(null)
947
+ onAutoFit()
948
+ return
949
+ }
950
+ const size = Number(choice)
951
+ if (Number.isFinite(size) && size > 0 && size <= 300) {
952
+ setDraft(size)
953
+ onChange(size)
954
+ }
955
+ }
956
+
957
+ const commit = (next: number | null) => {
958
+ if (next !== null && next > 0 && next <= 300) {
959
+ onChange(next)
960
+ } else {
961
+ setDraft(autoFit ? null : value)
962
+ }
963
+ }
964
+
965
+ return (
966
+ <NumberField
967
+ id={id}
968
+ name='font-size'
969
+ className='min-w-0'
970
+ disabled={disabled}
971
+ value={draft}
972
+ min={0.5}
973
+ max={300}
974
+ step={0.5}
975
+ onValueChange={setDraft}
976
+ onValueCommitted={commit}
977
+ >
978
+ <NumberFieldGroup>
979
+ <NumberFieldInput
980
+ data-testid='type-size'
981
+ aria-label={t('inspector.fontSize')}
982
+ placeholder={t('inspector.auto')}
983
+ className='px-2 text-left'
984
+ />
985
+ <DropdownMenu>
986
+ <DropdownMenuTrigger
987
+ disabled={disabled}
988
+ aria-label={t('inspector.chooseFontSize')}
989
+ className='grid size-6 shrink-0 place-items-center border-l border-input text-muted-foreground outline-none hover:bg-muted hover:text-foreground focus-visible:bg-muted focus-visible:text-foreground disabled:pointer-events-none disabled:opacity-40'
990
+ >
991
+ <ChevronDown className='size-3' />
992
+ </DropdownMenuTrigger>
993
+ <DropdownMenuContent
994
+ align='end'
995
+ className='w-28 min-w-28 border border-border/50 p-0.5 shadow-sm ring-0'
996
+ >
997
+ <DropdownMenuRadioGroup value={autoFit ? 'auto' : String(value)} onValueChange={select}>
998
+ <DropdownMenuRadioItem value='auto' className='min-h-6 py-0.5 text-[10px]'>
999
+ {t('inspector.auto')}
1000
+ </DropdownMenuRadioItem>
1001
+ {fontSizePresets.map((size) => (
1002
+ <DropdownMenuRadioItem
1003
+ key={size}
1004
+ value={String(size)}
1005
+ className='min-h-6 py-0.5 text-[10px] tabular-nums'
1006
+ >
1007
+ {size}
1008
+ </DropdownMenuRadioItem>
1009
+ ))}
1010
+ </DropdownMenuRadioGroup>
1011
+ </DropdownMenuContent>
1012
+ </DropdownMenu>
1013
+ </NumberFieldGroup>
1014
+ </NumberField>
1015
+ )
1016
+ }
1017
+
1018
+ function EmptyInspector({ children }: { children: React.ReactNode }) {
1019
+ return (
1020
+ <div className='px-4 py-8 text-center text-[10px] leading-4 text-muted-foreground'>
1021
+ {children}
1022
+ </div>
1023
+ )
1024
+ }
1025
+
1026
+ function rgbaToHex([red, green, blue]: [number, number, number, number]): string {
1027
+ return `#${[red, green, blue]
1028
+ .map((channel) => channel.toString(16).padStart(2, '0'))
1029
+ .join('')}`.toUpperCase()
1030
+ }
1031
+
1032
+ function hexToRgba(hex: string): [number, number, number, number] {
1033
+ const value = hex.replace('#', '')
1034
+ return [
1035
+ Number.parseInt(value.slice(0, 2), 16),
1036
+ Number.parseInt(value.slice(2, 4), 16),
1037
+ Number.parseInt(value.slice(4, 6), 16),
1038
+ 255,
1039
+ ]
1040
+ }
1041
+
1042
+ function layerIcon(layer: Layer): typeof Type {
1043
+ if (layer.type === 'group') return Folder
1044
+ if (layer.type === 'raster') return Brush
1045
+ if (layer.type === 'text') return Type
1046
+ return ImageIcon
1047
+ }
1048
+
1049
+ function localizedLayerName(layer: Layer, index: number, t: TFunction): string {
1050
+ if (layer.type === 'group' || layer.type === 'raster') return layer.name
1051
+ if (layer.type === 'text') {
1052
+ const text = layer.content.translation?.text || layer.content.source?.text
1053
+ return text?.trim() || t('layers.textName', { index: index + 1 })
1054
+ }
1055
+ if (layer.type === 'artwork') return t('layers.originalArtwork')
1056
+ return t('layers.imageName', { index: index + 1 })
1057
+ }
1058
+
1059
+ function localizedLayerKind(layer: Layer, t: TFunction): string {
1060
+ if (layer.type === 'group') {
1061
+ return t(layer.role === 'text' ? 'layers.kinds.textGroup' : 'layers.kinds.group')
1062
+ }
1063
+ if (layer.type === 'raster') {
1064
+ return t(`layers.kinds.${layer.kind}`, { defaultValue: layer.kind })
1065
+ }
1066
+ if (layer.type === 'text') {
1067
+ const role = layer.content.role?.split('.').at(-1)
1068
+ if (role === 'dialogue') return t('layers.kinds.dialogue')
1069
+ if (role === 'free-text') return t('layers.kinds.freeText')
1070
+ return t('layers.kinds.text')
1071
+ }
1072
+ return t('layers.kinds.image')
1073
+ }