@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.
- package/README.md +37 -2
- package/package.json +70 -4
- package/src/assets/NotoSansCJK-VF.otf +0 -0
- package/src/assets/OFL.txt +92 -0
- package/src/components/ClientOnly.tsx +21 -0
- package/src/components/ShenronExportPanel.tsx +213 -0
- package/src/components/app/AboutDialog.tsx +64 -0
- package/src/components/app/KoharuApp.tsx +58 -0
- package/src/components/app/StartupView.tsx +67 -0
- package/src/components/app/TitleBar.tsx +322 -0
- package/src/components/app/Updater.tsx +143 -0
- package/src/components/app/WindowChrome.tsx +137 -0
- package/src/components/controls/ColorSampling.tsx +70 -0
- package/src/components/controls/ColorWell.tsx +139 -0
- package/src/components/controls/CommitTextarea.tsx +63 -0
- package/src/components/controls/FontPicker.tsx +543 -0
- package/src/components/controls/ModelPicker.tsx +178 -0
- package/src/components/controls/OutputPicker.tsx +170 -0
- package/src/components/editor/ActivityCenter.tsx +234 -0
- package/src/components/editor/AgentPanel.tsx +352 -0
- package/src/components/editor/CanvasCommandBar.tsx +38 -0
- package/src/components/editor/CanvasOverlay.tsx +194 -0
- package/src/components/editor/CanvasWorkspace.tsx +947 -0
- package/src/components/editor/Editor.tsx +56 -0
- package/src/components/editor/InferenceControl.tsx +457 -0
- package/src/components/editor/Inspector.tsx +1073 -0
- package/src/components/editor/PageRail.tsx +581 -0
- package/src/components/editor/ResourceMonitor.tsx +151 -0
- package/src/components/editor/RightSidebar.tsx +50 -0
- package/src/components/editor/SelectionControls.tsx +184 -0
- package/src/components/editor/StatusBar.tsx +41 -0
- package/src/components/editor/ToolBar.tsx +182 -0
- package/src/components/editor/useCanvas.ts +194 -0
- package/src/components/preferences/GenerationPreferences.tsx +118 -0
- package/src/components/preferences/KoharuSettings.tsx +229 -0
- package/src/components/preferences/PipelinePreferences.tsx +173 -0
- package/src/components/preferences/PreferenceFields.tsx +149 -0
- package/src/components/preferences/ProviderPreferences.tsx +173 -0
- package/src/components/preferences/SettingsPage.tsx +336 -0
- package/src/components/preferences/TranslationPreferences.tsx +187 -0
- package/src/components/preferences/TypesettingPreferences.tsx +142 -0
- package/src/components/preferences/models.ts +103 -0
- package/src/components/start/StartView.tsx +277 -0
- package/src/index.ts +59 -0
- package/src/internal/components/accordion.tsx +72 -0
- package/src/internal/components/alert-dialog.tsx +189 -0
- package/src/internal/components/alert.tsx +76 -0
- package/src/internal/components/aspect-ratio.tsx +22 -0
- package/src/internal/components/attachment.tsx +207 -0
- package/src/internal/components/avatar.tsx +109 -0
- package/src/internal/components/badge.tsx +52 -0
- package/src/internal/components/breadcrumb.tsx +125 -0
- package/src/internal/components/bubble.tsx +128 -0
- package/src/internal/components/button-group.tsx +87 -0
- package/src/internal/components/button.tsx +58 -0
- package/src/internal/components/card.tsx +103 -0
- package/src/internal/components/checkbox.tsx +29 -0
- package/src/internal/components/collapsible.tsx +21 -0
- package/src/internal/components/combobox.tsx +298 -0
- package/src/internal/components/context-menu.tsx +272 -0
- package/src/internal/components/dialog.tsx +162 -0
- package/src/internal/components/direction.tsx +6 -0
- package/src/internal/components/drawer.tsx +230 -0
- package/src/internal/components/dropdown-menu.tsx +278 -0
- package/src/internal/components/empty.tsx +104 -0
- package/src/internal/components/field.tsx +238 -0
- package/src/internal/components/hover-card.tsx +52 -0
- package/src/internal/components/input-group.tsx +158 -0
- package/src/internal/components/input.tsx +20 -0
- package/src/internal/components/item.tsx +201 -0
- package/src/internal/components/kbd.tsx +26 -0
- package/src/internal/components/label.tsx +20 -0
- package/src/internal/components/marker.tsx +71 -0
- package/src/internal/components/menubar.tsx +280 -0
- package/src/internal/components/message.tsx +92 -0
- package/src/internal/components/native-select.tsx +61 -0
- package/src/internal/components/navigation-menu.tsx +168 -0
- package/src/internal/components/number-field.tsx +68 -0
- package/src/internal/components/pagination.tsx +130 -0
- package/src/internal/components/popover.tsx +91 -0
- package/src/internal/components/progress.tsx +83 -0
- package/src/internal/components/radio-group.tsx +38 -0
- package/src/internal/components/resizable.tsx +50 -0
- package/src/internal/components/scroll-area.tsx +88 -0
- package/src/internal/components/select.tsx +191 -0
- package/src/internal/components/separator.tsx +25 -0
- package/src/internal/components/sheet.tsx +140 -0
- package/src/internal/components/sidebar.tsx +723 -0
- package/src/internal/components/skeleton.tsx +13 -0
- package/src/internal/components/slider.tsx +56 -0
- package/src/internal/components/spinner.tsx +10 -0
- package/src/internal/components/switch.tsx +32 -0
- package/src/internal/components/table.tsx +116 -0
- package/src/internal/components/tabs.tsx +82 -0
- package/src/internal/components/textarea.tsx +18 -0
- package/src/internal/components/toast.tsx +235 -0
- package/src/internal/components/toggle-group.tsx +89 -0
- package/src/internal/components/toggle.tsx +45 -0
- package/src/internal/components/tooltip.tsx +67 -0
- package/src/internal/hooks/use-mobile.ts +19 -0
- package/src/internal/lib/utils.ts +6 -0
- package/src/internal/styles/globals.css +73 -0
- package/src/lib/backend.ts +85 -0
- package/src/lib/document.ts +46 -0
- package/src/lib/geometry.ts +272 -0
- package/src/lib/i18n.ts +54 -0
- package/src/lib/queries.ts +110 -0
- package/src/lib/store.ts +191 -0
- package/src/lib/translation.ts +35 -0
- package/src/locales/en-US/translation.json +536 -0
- package/src/locales/es-ES/translation.json +536 -0
- package/src/locales/ja-JP/translation.json +536 -0
- package/src/locales/ko-KR/translation.json +536 -0
- package/src/locales/pt-BR/translation.json +536 -0
- package/src/locales/ru-RU/translation.json +536 -0
- package/src/locales/tr-TR/translation.json +536 -0
- package/src/locales/zh-CN/translation.json +536 -0
- package/src/locales/zh-TW/translation.json +536 -0
- package/src/navigation.ts +28 -0
- package/src/providers.tsx +132 -0
- package/src/shenron-pipeline-env.d.ts +14 -0
- package/src/styles.css +246 -0
- 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
|
+
}
|