@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,151 @@
1
+ 'use client'
2
+
3
+ import { Cpu, Gauge, HardDrive, MemoryStick } from 'lucide-react'
4
+ import { useTranslation } from 'react-i18next'
5
+
6
+ import { useKoharuStore } from '@aphrody/koharu-ui/lib/store'
7
+ import {
8
+ Popover,
9
+ PopoverContent,
10
+ PopoverHeader,
11
+ PopoverTitle,
12
+ PopoverTrigger,
13
+ } from '../../internal/components/popover'
14
+ import { Tooltip, TooltipContent, TooltipTrigger } from '../../internal/components/tooltip'
15
+
16
+ /** Native telemetry for the Koharu process and the accelerator used by models. */
17
+ export function ResourceMonitor() {
18
+ const { t } = useTranslation()
19
+ const resources = useKoharuStore((state) => state.resources)
20
+ if (!resources) return null
21
+
22
+ const device =
23
+ resources.devices.find((candidate) => candidate.selected) ?? resources.devices[0] ?? null
24
+ const cpu = percent(resources.process_cpu)
25
+ const ram = percentOf(resources.process_memory, resources.system_memory)
26
+ const ramUsed = bytes(resources.process_memory)
27
+ const ramTotal = bytes(resources.system_memory)
28
+ const gpu = device?.utilization == null ? null : percent(device.utilization)
29
+ const vram =
30
+ device?.memory_used != null && device.memory_budget != null
31
+ ? memory(device.memory_used, device.memory_budget)
32
+ : null
33
+
34
+ return (
35
+ <div className='ml-auto shrink-0'>
36
+ <Popover>
37
+ <Tooltip>
38
+ <PopoverTrigger
39
+ render={
40
+ <TooltipTrigger
41
+ render={
42
+ <button
43
+ type='button'
44
+ aria-label={t('resources.summary', { ram, cpu })}
45
+ className='flex items-center gap-1 text-[10px] text-muted-foreground tabular-nums outline-none focus-visible:ring-2 focus-visible:ring-ring/25'
46
+ />
47
+ }
48
+ >
49
+ <span className='flex h-7 w-14 items-center justify-between rounded-lg bg-primary/[0.07] px-1.5 hover:bg-primary/[0.1] hover:text-foreground'>
50
+ <MemoryStick className='size-3.5 shrink-0' />
51
+ {ram}
52
+ </span>
53
+ <span className='flex h-7 w-14 items-center justify-between rounded-lg bg-primary/[0.07] px-1.5 hover:bg-primary/[0.1] hover:text-foreground'>
54
+ <Cpu className='size-3.5 shrink-0' />
55
+ {cpu}
56
+ </span>
57
+ </TooltipTrigger>
58
+ }
59
+ />
60
+ <TooltipContent side='top'>
61
+ {t('resources.tooltip', { ramUsed, ramTotal, cpu })}
62
+ </TooltipContent>
63
+ </Tooltip>
64
+ <PopoverContent
65
+ align='end'
66
+ side='top'
67
+ className='w-72 gap-0 overflow-hidden rounded-xl border-0 bg-[var(--surface-floating)] p-0 shadow-[var(--shadow-panel)] ring-1 ring-foreground/[0.06]'
68
+ >
69
+ <PopoverHeader className='border-b border-border/50 px-3 py-2'>
70
+ <PopoverTitle className='flex items-center gap-2 text-[11px]'>
71
+ <Gauge className='size-3.5 text-primary' />
72
+ {t('resources.title')}
73
+ </PopoverTitle>
74
+ </PopoverHeader>
75
+ <div className='divide-y divide-border/50 px-1.5 py-1 text-[11px]'>
76
+ <ResourceRow
77
+ icon={Cpu}
78
+ label={t('resources.cpu')}
79
+ value={cpu}
80
+ detail={t('resources.processUtilization')}
81
+ />
82
+ <ResourceRow
83
+ icon={MemoryStick}
84
+ label={t('resources.ram')}
85
+ value={ram}
86
+ detail={t('resources.usedOfTotal', { used: ramUsed, total: ramTotal })}
87
+ />
88
+ {device && (
89
+ <ResourceRow
90
+ icon={HardDrive}
91
+ label={device.name}
92
+ value={gpu ?? '—'}
93
+ detail={
94
+ vram ? t('resources.vramValue', { value: vram }) : t('resources.vramUnavailable')
95
+ }
96
+ />
97
+ )}
98
+ </div>
99
+ </PopoverContent>
100
+ </Popover>
101
+ </div>
102
+ )
103
+ }
104
+
105
+ function ResourceRow({
106
+ icon: Icon,
107
+ label,
108
+ value,
109
+ detail,
110
+ }: {
111
+ icon: typeof Cpu
112
+ label: string
113
+ value: string
114
+ detail: string
115
+ }) {
116
+ return (
117
+ <div className='grid grid-cols-[16px_minmax(0,1fr)_auto] items-center gap-x-2.5 px-2 py-2'>
118
+ <Icon className='size-3.5 shrink-0 text-muted-foreground' />
119
+ <Tooltip>
120
+ <TooltipTrigger render={<span className='min-w-0' />}>
121
+ <span className='block truncate text-foreground'>{label}</span>
122
+ <span className='block truncate text-[9px] text-muted-foreground'>{detail}</span>
123
+ </TooltipTrigger>
124
+ <TooltipContent side='left'>
125
+ {label}: {detail}
126
+ </TooltipContent>
127
+ </Tooltip>
128
+ <span className='text-right font-medium text-foreground tabular-nums'>{value}</span>
129
+ </div>
130
+ )
131
+ }
132
+
133
+ function memory(used: number, total: number): string {
134
+ const unit = total >= 1024 ** 3 ? 1024 ** 3 : 1024 ** 2
135
+ return `${(used / unit).toFixed(1)}/${(total / unit).toFixed(1)} ${unit === 1024 ** 3 ? 'GB' : 'MB'}`
136
+ }
137
+
138
+ function percent(value: number): string {
139
+ return `${Math.min(Math.max(0, value), 100).toFixed(0)}%`
140
+ }
141
+
142
+ function percentOf(used: number, total: number): string {
143
+ if (total <= 0) return '—'
144
+ return percent(Math.min((used / total) * 100, 100))
145
+ }
146
+
147
+ function bytes(value: number): string {
148
+ if (value >= 1024 ** 3) return `${(value / 1024 ** 3).toFixed(1)} GB`
149
+ if (value >= 1024 ** 2) return `${(value / 1024 ** 2).toFixed(1)} MB`
150
+ return `${Math.round(value / 1024)} KB`
151
+ }
@@ -0,0 +1,50 @@
1
+ 'use client'
2
+
3
+ import { useState } from 'react'
4
+ import { useTranslation } from 'react-i18next'
5
+ import { invoke, isTauri } from '@aphrody/api/core'
6
+
7
+ import { AgentPanel } from '@aphrody/koharu-ui/components/editor/AgentPanel'
8
+ import { ShenronExportPanel } from '@aphrody/koharu-ui/components/ShenronExportPanel'
9
+ import { Inspector } from '@aphrody/koharu-ui/components/editor/Inspector'
10
+ import { rightSidebarPanels, type RightSidebarPanel } from '@aphrody/koharu-ui/navigation'
11
+ import { Button } from '../../internal/components/button'
12
+
13
+ const shenronHost = {
14
+ isTauri,
15
+ invoke: <T,>(command: string, args: Record<string, unknown>) => invoke<T>(command, args),
16
+ }
17
+
18
+ export function RightSidebar() {
19
+ const { t } = useTranslation()
20
+ const [panel, setPanel] = useState<RightSidebarPanel>('properties')
21
+
22
+ return (
23
+ <aside className='flex h-full min-h-0 flex-col bg-[var(--surface-panel)]'>
24
+ <div className='flex h-10 shrink-0 items-center gap-1 border-b border-border/80 px-2.5'>
25
+ {rightSidebarPanels.map(({ id, icon: Icon, label }) => (
26
+ <Button
27
+ key={id}
28
+ variant={panel === id ? 'secondary' : 'ghost'}
29
+ size='sm'
30
+ className='h-7 flex-1 text-[10px]'
31
+ onClick={() => setPanel(id)}
32
+ >
33
+ <Icon className='size-3' /> {t(label)}
34
+ </Button>
35
+ ))}
36
+ </div>
37
+ <div className={panel === 'agent' ? 'flex min-h-0 flex-1 flex-col' : 'hidden'}>
38
+ <AgentPanel />
39
+ </div>
40
+ <div className={panel === 'properties' ? 'min-h-0 flex-1' : 'hidden'}>
41
+ <Inspector />
42
+ </div>
43
+ {panel === 'shenron' && (
44
+ <div className='flex min-h-0 flex-1 flex-col'>
45
+ <ShenronExportPanel host={shenronHost} />
46
+ </div>
47
+ )}
48
+ </aside>
49
+ )
50
+ }
@@ -0,0 +1,184 @@
1
+ 'use client'
2
+
3
+ import { useRef, type PointerEvent as ReactPointerEvent, type RefObject } from 'react'
4
+
5
+ import {
6
+ cssFrame,
7
+ pagePoint,
8
+ resizeFrame,
9
+ rotateFrame,
10
+ type Camera,
11
+ type ResizeHandle,
12
+ } from '@aphrody/koharu-ui/lib/geometry'
13
+ import type { EntityId, Frame, Point, TransformFrame } from '@aphrody/koharu-bridge/protocol'
14
+
15
+ interface SelectionControlsProps {
16
+ container: RefObject<HTMLDivElement | null>
17
+ element: EntityId
18
+ frame: Frame
19
+ camera: Camera
20
+ edgesOnly: boolean
21
+ onTransformStart: (elements: TransformFrame[]) => void
22
+ onTransformFrame: (elements: TransformFrame[]) => void
23
+ onTransformEnd: () => void
24
+ }
25
+
26
+ type ControlGesture =
27
+ | { kind: 'resize'; pointer: number; original: Frame; handle: ResizeHandle }
28
+ | { kind: 'rotate'; pointer: number; original: Frame; start: Point }
29
+
30
+ const handles: Array<{
31
+ handle: ResizeHandle
32
+ left: string
33
+ top: string
34
+ cursor: string
35
+ }> = [
36
+ { handle: 'nw', left: '0%', top: '0%', cursor: 'nwse-resize' },
37
+ { handle: 'n', left: '50%', top: '0%', cursor: 'ns-resize' },
38
+ { handle: 'ne', left: '100%', top: '0%', cursor: 'nesw-resize' },
39
+ { handle: 'e', left: '100%', top: '50%', cursor: 'ew-resize' },
40
+ { handle: 'se', left: '100%', top: '100%', cursor: 'nwse-resize' },
41
+ { handle: 's', left: '50%', top: '100%', cursor: 'ns-resize' },
42
+ { handle: 'sw', left: '0%', top: '100%', cursor: 'nesw-resize' },
43
+ { handle: 'w', left: '0%', top: '50%', cursor: 'ew-resize' },
44
+ ]
45
+
46
+ export function SelectionControls({
47
+ container,
48
+ element,
49
+ frame,
50
+ camera,
51
+ edgesOnly,
52
+ onTransformStart,
53
+ onTransformFrame,
54
+ onTransformEnd,
55
+ }: SelectionControlsProps) {
56
+ const gesture = useRef<ControlGesture | null>(null)
57
+ const position = cssFrame(frame, camera)
58
+
59
+ const eventPoint = (event: ReactPointerEvent<HTMLDivElement>): Point | null => {
60
+ const bounds = container.current?.getBoundingClientRect()
61
+ return bounds ? pagePoint(event.clientX, event.clientY, bounds, camera) : null
62
+ }
63
+
64
+ const capture = (event: ReactPointerEvent<HTMLDivElement>) => {
65
+ event.preventDefault()
66
+ event.stopPropagation()
67
+ event.currentTarget.setPointerCapture(event.pointerId)
68
+ }
69
+
70
+ const startResize = (handle: ResizeHandle) => (event: ReactPointerEvent<HTMLDivElement>) => {
71
+ if (event.button !== 0 || gesture.current) return
72
+ capture(event)
73
+ gesture.current = { kind: 'resize', pointer: event.pointerId, original: frame, handle }
74
+ onTransformStart([{ element, frame }])
75
+ }
76
+
77
+ const startRotate = (event: ReactPointerEvent<HTMLDivElement>) => {
78
+ if (event.button !== 0 || gesture.current) return
79
+ const start = eventPoint(event)
80
+ if (!start) return
81
+ capture(event)
82
+ gesture.current = { kind: 'rotate', pointer: event.pointerId, original: frame, start }
83
+ onTransformStart([{ element, frame }])
84
+ }
85
+
86
+ const update = (event: ReactPointerEvent<HTMLDivElement>) => {
87
+ const current = gesture.current
88
+ if (!current || current.pointer !== event.pointerId) return
89
+ event.preventDefault()
90
+ event.stopPropagation()
91
+ const point = eventPoint(event)
92
+ if (!point) return
93
+ const next =
94
+ current.kind === 'resize'
95
+ ? resizeFrame(
96
+ current.original,
97
+ current.handle,
98
+ point,
99
+ window.devicePixelRatio / camera.zoom,
100
+ )
101
+ : rotateFrame(current.original, current.start, point)
102
+ onTransformFrame([{ element, frame: next }])
103
+ }
104
+
105
+ const finish = (event: ReactPointerEvent<HTMLDivElement>) => {
106
+ const current = gesture.current
107
+ if (!current || current.pointer !== event.pointerId) return
108
+ event.preventDefault()
109
+ event.stopPropagation()
110
+ gesture.current = null
111
+ if (event.currentTarget.hasPointerCapture(event.pointerId)) {
112
+ event.currentTarget.releasePointerCapture(event.pointerId)
113
+ }
114
+ onTransformEnd()
115
+ }
116
+
117
+ const lostCapture = (event: ReactPointerEvent<HTMLDivElement>) => {
118
+ const current = gesture.current
119
+ if (!current || current.pointer !== event.pointerId) return
120
+ gesture.current = null
121
+ onTransformEnd()
122
+ }
123
+
124
+ const pointerEvents = {
125
+ onPointerMove: update,
126
+ onPointerUp: finish,
127
+ onPointerCancel: finish,
128
+ onLostPointerCapture: lostCapture,
129
+ }
130
+
131
+ return (
132
+ <div
133
+ data-testid='selection-controls'
134
+ className='pointer-events-none absolute box-border border border-[var(--canvas-selection)]'
135
+ style={{
136
+ left: position.left,
137
+ top: position.top,
138
+ width: position.width,
139
+ height: position.height,
140
+ transform: `rotate(${position.angle}deg)`,
141
+ transformOrigin: '50% 50%',
142
+ }}
143
+ >
144
+ {handles.map(({ handle, left, top, cursor }) => (
145
+ <div
146
+ key={handle}
147
+ data-canvas-control
148
+ data-resize-handle={handle}
149
+ className='pointer-events-auto absolute grid size-3.5 -translate-x-1/2 -translate-y-1/2 touch-none place-items-center'
150
+ style={{
151
+ left,
152
+ top,
153
+ cursor,
154
+ width:
155
+ edgesOnly && (handle === 'n' || handle === 's')
156
+ ? 'max(14px, calc(100% - 14px))'
157
+ : undefined,
158
+ height:
159
+ edgesOnly && (handle === 'e' || handle === 'w')
160
+ ? 'max(14px, calc(100% - 14px))'
161
+ : undefined,
162
+ }}
163
+ onPointerDown={startResize(handle)}
164
+ {...pointerEvents}
165
+ >
166
+ {!edgesOnly && (
167
+ <span className='size-1.5 rounded-[1px] border border-primary-foreground bg-[var(--canvas-selection)] shadow-[0_0_0_1px_rgb(0_0_0/0.12)]' />
168
+ )}
169
+ </div>
170
+ ))}
171
+
172
+ <span className='pointer-events-none absolute top-0 left-1/2 h-2 w-px -translate-x-1/2 -translate-y-full bg-[var(--canvas-selection)]' />
173
+ <div
174
+ data-canvas-control
175
+ data-rotate-handle
176
+ className='pointer-events-auto absolute top-[-16px] left-1/2 grid size-3.5 -translate-x-1/2 -translate-y-1/2 cursor-alias touch-none place-items-center'
177
+ onPointerDown={startRotate}
178
+ {...pointerEvents}
179
+ >
180
+ <span className='size-1.5 rounded-[1px] border border-primary-foreground bg-[var(--canvas-selection)] shadow-[0_0_0_1px_rgb(0_0_0/0.12)]' />
181
+ </div>
182
+ </div>
183
+ )
184
+ }
@@ -0,0 +1,41 @@
1
+ 'use client'
2
+
3
+ import { useTranslation } from 'react-i18next'
4
+
5
+ import { usePage } from '@aphrody/koharu-ui/lib/queries'
6
+ import { useKoharuStore } from '@aphrody/koharu-ui/lib/store'
7
+ import { Slider } from '../../internal/components/slider'
8
+
9
+ interface StatusBarProps {
10
+ onZoomChange: (zoom: number) => void
11
+ }
12
+
13
+ export function StatusBar({ onZoomChange }: StatusBarProps) {
14
+ const { t } = useTranslation()
15
+ const page = usePage().data
16
+ const camera = useKoharuStore((state) => state.camera)
17
+ const zoom = Math.round(camera.zoom * 100)
18
+
19
+ return (
20
+ <footer className='flex h-7 shrink-0 items-center gap-3 border-t border-border/80 bg-[var(--surface-toolbar)] px-2.5 text-[10px] text-muted-foreground'>
21
+ <div className='flex w-36 shrink-0 items-center gap-2'>
22
+ <Slider
23
+ aria-label={t('status.zoom')}
24
+ min={10}
25
+ max={800}
26
+ step={5}
27
+ value={Math.min(800, Math.max(10, zoom))}
28
+ className='w-24 shrink-0 [&_[data-slot=slider-thumb]]:size-2'
29
+ onValueChange={(value) => onZoomChange(value / 100)}
30
+ />
31
+ <span className='w-9 shrink-0 text-right text-foreground tabular-nums'>{zoom}%</span>
32
+ </div>
33
+ <div className='flex-1' />
34
+ {page && (
35
+ <span className='tabular-nums'>
36
+ {page.size.width} × {page.size.height} px
37
+ </span>
38
+ )}
39
+ </footer>
40
+ )
41
+ }
@@ -0,0 +1,182 @@
1
+ 'use client'
2
+
3
+ import {
4
+ Brush,
5
+ Eraser,
6
+ Hand,
7
+ Minus,
8
+ MousePointer2,
9
+ Pipette,
10
+ Plus,
11
+ Sparkles,
12
+ Type,
13
+ } from 'lucide-react'
14
+ import { useTranslation } from 'react-i18next'
15
+
16
+ import { ColorWell } from '@aphrody/koharu-ui/components/controls/ColorWell'
17
+ import { usePage } from '@aphrody/koharu-ui/lib/queries'
18
+ import {
19
+ isBrushTool,
20
+ MAX_BRUSH_DIAMETER,
21
+ MIN_BRUSH_DIAMETER,
22
+ useKoharuStore,
23
+ type CanvasTool,
24
+ } from '@aphrody/koharu-ui/lib/store'
25
+ import { Button } from '../../internal/components/button'
26
+ import {
27
+ NumberField,
28
+ NumberFieldDecrement,
29
+ NumberFieldGroup,
30
+ NumberFieldIncrement,
31
+ NumberFieldInput,
32
+ } from '../../internal/components/number-field'
33
+ import {
34
+ Popover,
35
+ PopoverContent,
36
+ PopoverTitle,
37
+ PopoverTrigger,
38
+ } from '../../internal/components/popover'
39
+ import { Slider } from '../../internal/components/slider'
40
+ import { Tooltip, TooltipContent, TooltipTrigger } from '../../internal/components/tooltip'
41
+
42
+ const tools = [
43
+ ['select', MousePointer2],
44
+ ['text', Type],
45
+ ['draw', Brush],
46
+ ['eraser', Eraser],
47
+ ['color_picker', Pipette],
48
+ ['remove', Sparkles],
49
+ ['pan', Hand],
50
+ ] as const satisfies ReadonlyArray<readonly [CanvasTool, typeof MousePointer2]>
51
+
52
+ export function ToolBar() {
53
+ const { t } = useTranslation()
54
+ const page = usePage().data
55
+ const active = useKoharuStore((state) => state.tool)
56
+ const brush = useKoharuStore((state) => state.brush)
57
+ const setTool = useKoharuStore((state) => state.setTool)
58
+ const setBrush = useKoharuStore((state) => state.setBrush)
59
+ const shortcuts = useKoharuStore((state) => state.shortcuts)
60
+ const hasBrush = isBrushTool(active)
61
+
62
+ return (
63
+ <aside className='absolute top-3 left-3 z-20 flex w-11 flex-col rounded-2xl border border-border bg-[var(--surface-floating)] p-1 shadow-[var(--shadow-toolrail)]'>
64
+ <div className='flex flex-col items-center py-0.5'>
65
+ {tools.map(([tool, Icon], index) => (
66
+ <div key={tool} className='contents'>
67
+ {index === tools.length - 1 && <span className='my-1 h-px w-5 bg-border' />}
68
+ <Tooltip>
69
+ <TooltipTrigger
70
+ render={
71
+ <Button
72
+ type='button'
73
+ variant='ghost'
74
+ size='icon'
75
+ disabled={!page}
76
+ aria-label={t(`tools.${tool}`)}
77
+ data-active={active === tool}
78
+ className='relative text-muted-foreground hover:bg-foreground/[0.06] hover:text-foreground disabled:opacity-30 data-[active=true]:bg-accent data-[active=true]:text-accent-foreground'
79
+ onClick={() => setTool(tool)}
80
+ />
81
+ }
82
+ >
83
+ <Icon className='size-4' />
84
+ </TooltipTrigger>
85
+ <TooltipContent side='right'>
86
+ {t(`tools.${tool}`)}
87
+ <span className='ml-2 opacity-60'>{shortcuts[tool].toUpperCase()}</span>
88
+ </TooltipContent>
89
+ </Tooltip>
90
+ </div>
91
+ ))}
92
+ </div>
93
+
94
+ {hasBrush && (
95
+ <div className='flex flex-col items-center gap-1 border-t border-border/80 py-1.5'>
96
+ {active === 'draw' && (
97
+ <ColorWell value={brush.color} onChange={(color) => setBrush({ ...brush, color })} />
98
+ )}
99
+ <BrushSize
100
+ value={brush.diameter}
101
+ onChange={(diameter) => setBrush({ ...brush, diameter })}
102
+ />
103
+ </div>
104
+ )}
105
+ </aside>
106
+ )
107
+ }
108
+
109
+ function BrushSize({ value, onChange }: { value: number; onChange: (value: number) => void }) {
110
+ const { t } = useTranslation()
111
+ const roundedValue = Math.round(value)
112
+
113
+ return (
114
+ <Popover>
115
+ <Tooltip>
116
+ <PopoverTrigger
117
+ render={
118
+ <TooltipTrigger
119
+ render={
120
+ <Button
121
+ type='button'
122
+ variant='ghost'
123
+ size='icon'
124
+ aria-label={t('tools.brushSizePixels', { size: roundedValue })}
125
+ className='size-8 rounded-xl font-sans text-[11px] leading-none font-medium tracking-[-0.02em] text-muted-foreground tabular-nums hover:bg-foreground/[0.06] hover:text-foreground'
126
+ />
127
+ }
128
+ >
129
+ {roundedValue}
130
+ </TooltipTrigger>
131
+ }
132
+ />
133
+ <TooltipContent side='right'>
134
+ {t('tools.brushSizeShort', { size: roundedValue })}
135
+ </TooltipContent>
136
+ </Tooltip>
137
+ <PopoverContent
138
+ side='right'
139
+ align='center'
140
+ sideOffset={8}
141
+ className='w-48 gap-2.5 rounded-xl p-2.5'
142
+ >
143
+ <div className='flex items-center justify-between gap-3'>
144
+ <PopoverTitle className='text-[11px]'>{t('tools.brushSize')}</PopoverTitle>
145
+ <NumberField
146
+ min={MIN_BRUSH_DIAMETER}
147
+ max={MAX_BRUSH_DIAMETER}
148
+ step={1}
149
+ value={roundedValue}
150
+ className='w-20'
151
+ onValueChange={(next) => {
152
+ if (next !== null) onChange(clamp(next, MIN_BRUSH_DIAMETER, MAX_BRUSH_DIAMETER))
153
+ }}
154
+ >
155
+ <NumberFieldGroup className='h-7'>
156
+ <NumberFieldDecrement aria-label={t('tools.decreaseBrushSize')}>
157
+ <Minus />
158
+ </NumberFieldDecrement>
159
+ <NumberFieldInput aria-label={t('tools.brushSizeInput')} />
160
+ <NumberFieldIncrement aria-label={t('tools.increaseBrushSize')}>
161
+ <Plus />
162
+ </NumberFieldIncrement>
163
+ </NumberFieldGroup>
164
+ </NumberField>
165
+ </div>
166
+ <Slider
167
+ aria-label={t('tools.brushSize')}
168
+ min={MIN_BRUSH_DIAMETER}
169
+ max={MAX_BRUSH_DIAMETER}
170
+ step={1}
171
+ value={value}
172
+ className='py-1 [&_[data-slot=slider-thumb]]:size-2.5'
173
+ onValueChange={onChange}
174
+ />
175
+ </PopoverContent>
176
+ </Popover>
177
+ )
178
+ }
179
+
180
+ function clamp(value: number, min: number, max: number): number {
181
+ return Math.min(max, Math.max(min, Number.isFinite(value) ? value : min))
182
+ }