@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,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
|
+
}
|