@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,352 @@
1
+ 'use client'
2
+
3
+ import { Channel } from '@aphrody/api/core'
4
+ import { Bot, CircleStop, LogOut, Send, Sparkles } from 'lucide-react'
5
+ import { useEffect, useMemo, useState } from 'react'
6
+ import { useTranslation } from 'react-i18next'
7
+
8
+ import { call } from '@aphrody/koharu-ui/lib/backend'
9
+ import { pageKey, pagesKey, projectKey, refresh } from '@aphrody/koharu-ui/lib/queries'
10
+ import {
11
+ commands,
12
+ type AgentStatus,
13
+ type Config,
14
+ type Event,
15
+ type LoginEvent,
16
+ type Reasoning,
17
+ type RunId,
18
+ } from '@aphrody/koharu-bridge/protocol'
19
+ import { Badge } from '@aphrody/m3-primitives'
20
+ import { Button } from '../../internal/components/button'
21
+ import { ScrollArea } from '../../internal/components/scroll-area'
22
+ import {
23
+ Select,
24
+ SelectContent,
25
+ SelectItem,
26
+ SelectTrigger,
27
+ SelectValue,
28
+ } from '../../internal/components/select'
29
+ import { Textarea } from '../../internal/components/textarea'
30
+
31
+ interface ConversationMessage {
32
+ id: string
33
+ role: 'user' | 'assistant'
34
+ text: string
35
+ error?: boolean
36
+ }
37
+
38
+ export function AgentPanel() {
39
+ const { t } = useTranslation()
40
+ const [status, setStatus] = useState<AgentStatus | null>(null)
41
+ const [loginEvent, setLoginEvent] = useState<LoginEvent | null>(null)
42
+ const [loggingIn, setLoggingIn] = useState(false)
43
+ const [messages, setMessages] = useState<ConversationMessage[]>([])
44
+ const [prompt, setPrompt] = useState('')
45
+ const [running, setRunning] = useState<RunId | null>(null)
46
+ const [activity, setActivity] = useState<string | null>(null)
47
+
48
+ useEffect(() => {
49
+ void call(commands.getAgentStatus)
50
+ .then(setStatus)
51
+ .catch(() => undefined)
52
+ }, [])
53
+
54
+ const selectedModel = useMemo(
55
+ () =>
56
+ status?.models.find((model) => model.id === status.config.model) ?? status?.models[0] ?? null,
57
+ [status],
58
+ )
59
+
60
+ if (!status) {
61
+ return (
62
+ <div className='grid min-h-0 flex-1 place-items-center p-6 text-[11px] text-muted-foreground'>
63
+ {t('agent.loading')}
64
+ </div>
65
+ )
66
+ }
67
+
68
+ if (!status.account) {
69
+ const code = loginEvent?.type === 'device_code' ? loginEvent.user_code : null
70
+ return (
71
+ <div className='flex min-h-0 flex-1 flex-col items-center justify-center p-6 text-center'>
72
+ <div className='grid size-11 place-items-center rounded-2xl bg-primary/10 text-primary'>
73
+ <Sparkles className='size-5' />
74
+ </div>
75
+ <h3 className='mt-4 text-sm font-semibold'>{t('agent.signInTitle')}</h3>
76
+ <p className='mt-1 max-w-64 text-[11px] leading-5 text-muted-foreground'>
77
+ {t('agent.signInDescription')}
78
+ </p>
79
+ {code ? (
80
+ <div className='mt-4 w-full max-w-64 rounded-xl border bg-muted/40 p-3'>
81
+ <p className='text-[10px] text-muted-foreground'>{t('agent.deviceCode')}</p>
82
+ <p className='mt-1 font-mono text-lg font-semibold tracking-[0.18em] select-text'>
83
+ {code}
84
+ </p>
85
+ <p className='mt-1 text-[10px] leading-4 text-muted-foreground'>
86
+ {t('agent.deviceCodeHint')}
87
+ </p>
88
+ </div>
89
+ ) : null}
90
+ <Button className='mt-4 w-full max-w-64' disabled={loggingIn} onClick={() => void login()}>
91
+ {loggingIn ? t('agent.signingIn') : t('agent.signIn')}
92
+ </Button>
93
+ </div>
94
+ )
95
+ }
96
+
97
+ return (
98
+ <div className='flex min-h-0 flex-1 flex-col'>
99
+ <div className='grid shrink-0 gap-2 border-b px-3 py-2.5'>
100
+ <div className='flex min-w-0 items-center gap-2'>
101
+ <div className='min-w-0 flex-1'>
102
+ <p className='truncate text-[11px] font-medium'>{status.account.email ?? 'Codex'}</p>
103
+ <p className='truncate text-[9px] text-muted-foreground'>
104
+ {status.account.plan ?? t('agent.account')}
105
+ </p>
106
+ </div>
107
+ <Button
108
+ variant='ghost'
109
+ size='icon-sm'
110
+ aria-label={t('agent.signOut')}
111
+ disabled={running !== null}
112
+ onClick={() => void logout()}
113
+ >
114
+ <LogOut />
115
+ </Button>
116
+ </div>
117
+ <div className='grid grid-cols-[minmax(0,1fr)_7rem] gap-1.5'>
118
+ <Select
119
+ value={status.config.model ?? 'automatic'}
120
+ onValueChange={(model) => {
121
+ const selected =
122
+ status.models.find((candidate) => candidate.id === model) ?? status.models[0]
123
+ const reasoning =
124
+ selected &&
125
+ selected.reasoning.length > 0 &&
126
+ !selected.reasoning.includes(status.config.reasoning)
127
+ ? selected.reasoning[0]
128
+ : status.config.reasoning
129
+ void saveConfig({
130
+ model: model === 'automatic' ? null : model,
131
+ reasoning,
132
+ })
133
+ }}
134
+ >
135
+ <SelectTrigger size='sm' className='w-full min-w-0' aria-label={t('agent.model')}>
136
+ <SelectValue />
137
+ </SelectTrigger>
138
+ <SelectContent align='start'>
139
+ <SelectItem value='automatic'>{t('agent.automatic')}</SelectItem>
140
+ {status.models.map((model) => (
141
+ <SelectItem key={model.id} value={model.id}>
142
+ {model.name}
143
+ </SelectItem>
144
+ ))}
145
+ </SelectContent>
146
+ </Select>
147
+ <Select
148
+ value={status.config.reasoning}
149
+ onValueChange={(reasoning) =>
150
+ void saveConfig({ ...status.config, reasoning: reasoning as Reasoning })
151
+ }
152
+ >
153
+ <SelectTrigger size='sm' className='w-full min-w-0' aria-label={t('agent.reasoning')}>
154
+ <SelectValue />
155
+ </SelectTrigger>
156
+ <SelectContent align='end'>
157
+ {(selectedModel?.reasoning ?? [status.config.reasoning]).map((reasoning) => (
158
+ <SelectItem key={reasoning} value={reasoning}>
159
+ {t(`agent.reasoningLevels.${reasoning}`)}
160
+ </SelectItem>
161
+ ))}
162
+ </SelectContent>
163
+ </Select>
164
+ </div>
165
+ </div>
166
+
167
+ <ScrollArea className='min-h-0 flex-1'>
168
+ {messages.length ? (
169
+ <div className='grid gap-3 p-3'>
170
+ {messages.map((message) => (
171
+ <div
172
+ key={message.id}
173
+ data-role={message.role}
174
+ className='flex data-[role=user]:justify-end'
175
+ >
176
+ <div
177
+ data-role={message.role}
178
+ data-error={message.error || undefined}
179
+ className='max-w-[90%] rounded-xl bg-muted px-3 py-2 text-[11px] leading-5 whitespace-pre-wrap select-text data-[error=true]:bg-destructive/10 data-[error=true]:text-destructive data-[role=user]:bg-primary data-[role=user]:text-primary-foreground'
180
+ >
181
+ {message.text || (message.role === 'assistant' ? t('agent.working') : '')}
182
+ </div>
183
+ </div>
184
+ ))}
185
+ {activity ? (
186
+ <div className='flex items-center gap-1.5 text-[10px] text-muted-foreground'>
187
+ <span className='size-1.5 rounded-full bg-primary' />
188
+ {activity}
189
+ </div>
190
+ ) : null}
191
+ </div>
192
+ ) : (
193
+ <div className='grid min-h-64 place-items-center p-6 text-center'>
194
+ <div>
195
+ <Bot className='mx-auto size-6 text-primary' />
196
+ <h3 className='mt-3 text-xs font-semibold'>{t('agent.emptyTitle')}</h3>
197
+ <p className='mt-1 max-w-56 text-[10px] leading-4 text-muted-foreground'>
198
+ {t('agent.emptyDescription')}
199
+ </p>
200
+ </div>
201
+ </div>
202
+ )}
203
+ </ScrollArea>
204
+
205
+ <div className='shrink-0 border-t p-2.5'>
206
+ <div className='rounded-xl border bg-background p-1.5 focus-within:border-ring'>
207
+ <Textarea
208
+ id='agent-message'
209
+ value={prompt}
210
+ disabled={running !== null}
211
+ aria-label={t('agent.message')}
212
+ placeholder={t('agent.placeholder')}
213
+ className='max-h-36 min-h-16 resize-none border-0 bg-transparent px-1.5 py-1 text-[11px] leading-5 shadow-none focus-visible:ring-0'
214
+ onChange={(event) => setPrompt(event.target.value)}
215
+ onKeyDown={(event) => {
216
+ if (event.key === 'Enter' && !event.shiftKey) {
217
+ event.preventDefault()
218
+ void send()
219
+ }
220
+ }}
221
+ />
222
+ <div className='flex items-center justify-between gap-2 pt-1'>
223
+ <Badge
224
+ variant='outline'
225
+ className='h-5 max-w-44 truncate px-1.5 text-[9px] font-normal'
226
+ >
227
+ {selectedModel?.name ?? t('agent.automatic')}
228
+ </Badge>
229
+ {running ? (
230
+ <Button
231
+ variant='outline'
232
+ size='icon-sm'
233
+ aria-label={t('agent.cancel')}
234
+ onClick={() => void call(commands.cancelAgent, running).catch(() => undefined)}
235
+ >
236
+ <CircleStop />
237
+ </Button>
238
+ ) : (
239
+ <Button
240
+ size='icon-sm'
241
+ aria-label={t('agent.send')}
242
+ disabled={!prompt.trim()}
243
+ onClick={() => void send()}
244
+ >
245
+ <Send />
246
+ </Button>
247
+ )}
248
+ </div>
249
+ </div>
250
+ </div>
251
+ </div>
252
+ )
253
+
254
+ async function login() {
255
+ setLoggingIn(true)
256
+ setLoginEvent({ type: 'progress', message: t('agent.signingIn') })
257
+ const channel = new Channel<LoginEvent>()
258
+ channel.onmessage = setLoginEvent
259
+ try {
260
+ setStatus(await call(commands.loginAgent, channel))
261
+ setLoginEvent(null)
262
+ } finally {
263
+ setLoggingIn(false)
264
+ }
265
+ }
266
+
267
+ async function logout() {
268
+ setStatus(await call(commands.logoutAgent))
269
+ setMessages([])
270
+ setActivity(null)
271
+ }
272
+
273
+ async function saveConfig(config: Config) {
274
+ const saved = await call(commands.saveAgentConfig, config)
275
+ setStatus((current) => (current ? { ...current, config: saved } : current))
276
+ }
277
+
278
+ async function send() {
279
+ const message = prompt.trim()
280
+ if (!message || running) return
281
+ const id = crypto.randomUUID()
282
+ const assistant = crypto.randomUUID()
283
+ let settled = false
284
+ setPrompt('')
285
+ setMessages((current) => [
286
+ ...current,
287
+ { id, role: 'user', text: message },
288
+ { id: assistant, role: 'assistant', text: '' },
289
+ ])
290
+ setActivity(t('agent.working'))
291
+
292
+ const channel = new Channel<Event>()
293
+ channel.onmessage = (event) => {
294
+ switch (event.type) {
295
+ case 'started':
296
+ setRunning(event.run)
297
+ break
298
+ case 'text_delta':
299
+ updateAssistant((text) => text + event.delta)
300
+ break
301
+ case 'reasoning_delta':
302
+ setActivity(t('agent.thinking'))
303
+ break
304
+ case 'tool_started':
305
+ setActivity(t('agent.applying'))
306
+ break
307
+ case 'tool_finished':
308
+ if (event.changed) void refresh(projectKey, pagesKey, pageKey)
309
+ setActivity(t('agent.working'))
310
+ break
311
+ case 'completed':
312
+ settled = true
313
+ updateAssistant(() => event.message)
314
+ setRunning(null)
315
+ setActivity(null)
316
+ void refresh(projectKey, pagesKey, pageKey)
317
+ break
318
+ case 'failed':
319
+ settled = true
320
+ updateAssistant(() => event.message, true)
321
+ setRunning(null)
322
+ setActivity(null)
323
+ break
324
+ case 'cancelled':
325
+ settled = true
326
+ updateAssistant((text) => text || t('agent.cancelled'))
327
+ setRunning(null)
328
+ setActivity(null)
329
+ break
330
+ }
331
+ }
332
+ try {
333
+ const run = await call(commands.runAgent, message, channel)
334
+ if (!settled) setRunning(run)
335
+ } catch (error) {
336
+ settled = true
337
+ updateAssistant(() => (error instanceof Error ? error.message : t('agent.failed')), true)
338
+ setRunning(null)
339
+ setActivity(null)
340
+ }
341
+
342
+ function updateAssistant(update: (text: string) => string, error = false) {
343
+ setMessages((current) =>
344
+ current.map((item) =>
345
+ item.id === assistant
346
+ ? { ...item, text: update(item.text), error: error || item.error }
347
+ : item,
348
+ ),
349
+ )
350
+ }
351
+ }
352
+ }
@@ -0,0 +1,38 @@
1
+ 'use client'
2
+
3
+ import { InferenceControl } from '@aphrody/koharu-ui/components/editor/InferenceControl'
4
+ import { call } from '@aphrody/koharu-ui/lib/backend'
5
+ import { usePage } from '@aphrody/koharu-ui/lib/queries'
6
+ import { pipelineStages, useKoharuStore, type PipelineScope } from '@aphrody/koharu-ui/lib/store'
7
+ import { commands, type Scope, type Stage } from '@aphrody/koharu-bridge/protocol'
8
+
9
+ export function CanvasCommandBar() {
10
+ const page = usePage().data
11
+ const selectedPages = useKoharuStore((state) => state.selectedPages)
12
+ const jobs = useKoharuStore((state) => state.jobs)
13
+ const running = Object.values(jobs).find((job) => job.state === 'running')
14
+
15
+ const run = (selection: PipelineScope, stages: Stage[]) => {
16
+ if (!page) return
17
+ const scope: Scope =
18
+ selection === 'project'
19
+ ? { scope: 'project' }
20
+ : selection === 'selected-pages'
21
+ ? { scope: 'pages', value: selectedPages }
22
+ : { scope: 'pages', value: [page.id] }
23
+ const operation =
24
+ stages.length === pipelineStages.length
25
+ ? ({ operation: 'full' } as const)
26
+ : stages.length === 1
27
+ ? ({ operation: 'only', stage: stages[0]! } as const)
28
+ : ({ operation: 'stages', stages } as const)
29
+ void call(commands.process, scope, operation).catch(() => undefined)
30
+ }
31
+
32
+ return (
33
+ <header className='flex h-10 shrink-0 items-center gap-2 border-b border-border/80 bg-[var(--surface-toolbar)] px-2.5'>
34
+ <div className='min-w-0 flex-1' />
35
+ <InferenceControl disabled={!page || Boolean(running)} onRun={run} />
36
+ </header>
37
+ )
38
+ }
@@ -0,0 +1,194 @@
1
+ 'use client'
2
+
3
+ import { useMemo, useRef } from 'react'
4
+
5
+ import { SelectionControls } from '@aphrody/koharu-ui/components/editor/SelectionControls'
6
+ import { effectiveLayerVisibility, expandLayerSelection, isTextLayer } from '@aphrody/koharu-ui/lib/document'
7
+ import { controlFrame, cssFrame, selectableLayer, type Camera } from '@aphrody/koharu-ui/lib/geometry'
8
+ import type {
9
+ EntityId,
10
+ Frame,
11
+ Geometry,
12
+ Page,
13
+ Point,
14
+ TransformFrame,
15
+ } from '@aphrody/koharu-bridge/protocol'
16
+
17
+ interface CanvasOverlayProps {
18
+ page: Page
19
+ camera: Camera
20
+ selected: EntityId[]
21
+ hovered: EntityId | null
22
+ frames: Readonly<Record<EntityId, Frame>>
23
+ previews: Readonly<Record<EntityId, Frame>>
24
+ draft: Frame | null
25
+ cursor: Point | null
26
+ brushSize: number
27
+ showBrushCursor: boolean
28
+ onTransformStart: (elements: TransformFrame[]) => void
29
+ onTransformFrame: (elements: TransformFrame[]) => void
30
+ onTransformEnd: () => void
31
+ }
32
+
33
+ export function CanvasOverlay({
34
+ page,
35
+ camera,
36
+ selected,
37
+ hovered,
38
+ frames,
39
+ previews,
40
+ draft,
41
+ cursor,
42
+ brushSize,
43
+ showBrushCursor,
44
+ onTransformStart,
45
+ onTransformFrame,
46
+ onTransformEnd,
47
+ }: CanvasOverlayProps) {
48
+ const root = useRef<HTMLDivElement>(null)
49
+ const expandedSelection = useMemo(
50
+ () => expandLayerSelection(page.layers, selected),
51
+ [page.layers, selected],
52
+ )
53
+ const selectedIds = useMemo(() => new Set(expandedSelection), [expandedSelection])
54
+ const multipleSelected = expandedSelection.length > 1
55
+ const layers = useMemo(
56
+ () =>
57
+ page.layers.flatMap((layer) => {
58
+ const visibility = effectiveLayerVisibility(page.layers, layer)
59
+ if (!visibility.visible || visibility.opacity <= 0) return []
60
+ const frame = previews[layer.id] ?? controlFrame(layer, frames)
61
+ return frame ? [{ layer, frame, opacity: visibility.opacity }] : []
62
+ }),
63
+ [page.layers, previews, frames],
64
+ )
65
+ const selectedLayer =
66
+ expandedSelection.length === 1
67
+ ? page.layers.find((layer) => layer.id === expandedSelection[0])
68
+ : undefined
69
+ const selectedTextLayer = selectedLayer && isTextLayer(selectedLayer) ? selectedLayer : undefined
70
+ const automaticRegion = selectedTextLayer?.automatic_region
71
+ ? page.regions.find((region) => region.id === selectedTextLayer.automatic_region)
72
+ : undefined
73
+ const selectionControl = multipleSelected
74
+ ? undefined
75
+ : layers.find(({ layer }) => selectedIds.has(layer.id) && selectableLayer(layer))
76
+ const scale = camera.zoom / window.devicePixelRatio
77
+
78
+ return (
79
+ <div
80
+ ref={root}
81
+ data-testid='canvas-overlay'
82
+ className='pointer-events-none absolute inset-0 overflow-hidden'
83
+ aria-hidden
84
+ >
85
+ {automaticRegion && (
86
+ <AutomaticRegionOverlay geometry={automaticRegion.geometry} camera={camera} />
87
+ )}
88
+ {layers.map(({ layer, frame, opacity }) => {
89
+ const position = cssFrame(frame, camera)
90
+ const selected = selectedIds.has(layer.id) && selectableLayer(layer)
91
+ const highlighted = !selected && hovered === layer.id
92
+ return (
93
+ <div
94
+ key={layer.id}
95
+ data-element={layer.id}
96
+ className='absolute box-border bg-transparent'
97
+ style={{
98
+ left: position.left,
99
+ top: position.top,
100
+ width: position.width,
101
+ height: position.height,
102
+ transform: `rotate(${position.angle}deg)`,
103
+ transformOrigin: '50% 50%',
104
+ border:
105
+ highlighted || (selected && multipleSelected)
106
+ ? '1px solid var(--canvas-selection)'
107
+ : undefined,
108
+ opacity,
109
+ willChange: selected ? 'left, top, width, height, transform' : undefined,
110
+ }}
111
+ />
112
+ )
113
+ })}
114
+
115
+ {draft && <DraftOverlay frame={draft} camera={camera} />}
116
+ {showBrushCursor && cursor && (
117
+ <div
118
+ className='absolute rounded-full border border-white/95 shadow-[0_0_0_1px_rgb(0_0_0/0.9),0_1px_3px_rgb(0_0_0/0.45)]'
119
+ style={{
120
+ left: cursor.x / window.devicePixelRatio - (brushSize * scale) / 2,
121
+ top: cursor.y / window.devicePixelRatio - (brushSize * scale) / 2,
122
+ width: brushSize * scale,
123
+ height: brushSize * scale,
124
+ }}
125
+ />
126
+ )}
127
+
128
+ {selectionControl && (
129
+ <SelectionControls
130
+ container={root}
131
+ element={selectionControl.layer.id}
132
+ frame={selectionControl.frame}
133
+ camera={camera}
134
+ edgesOnly={Boolean(selectedTextLayer)}
135
+ onTransformStart={onTransformStart}
136
+ onTransformFrame={onTransformFrame}
137
+ onTransformEnd={onTransformEnd}
138
+ />
139
+ )}
140
+ </div>
141
+ )
142
+ }
143
+
144
+ function AutomaticRegionOverlay({ geometry, camera }: { geometry: Geometry; camera: Camera }) {
145
+ const dpr = window.devicePixelRatio
146
+ const points = geometry.points
147
+ .map(
148
+ (point) =>
149
+ `${(point.x * camera.zoom + camera.translation[0]) / dpr},${(point.y * camera.zoom + camera.translation[1]) / dpr}`,
150
+ )
151
+ .join(' ')
152
+ if (!points) return null
153
+
154
+ return (
155
+ <svg className='absolute inset-0 size-full overflow-hidden' data-testid='text-fit-region'>
156
+ <polygon
157
+ points={points}
158
+ fill='none'
159
+ stroke='var(--canvas-region-contrast)'
160
+ strokeWidth='7'
161
+ strokeDasharray='10 6'
162
+ strokeLinecap='round'
163
+ strokeLinejoin='round'
164
+ vectorEffect='non-scaling-stroke'
165
+ />
166
+ <polygon
167
+ points={points}
168
+ fill='none'
169
+ stroke='var(--canvas-region-stroke)'
170
+ strokeWidth='3'
171
+ strokeDasharray='10 6'
172
+ strokeLinecap='round'
173
+ strokeLinejoin='round'
174
+ vectorEffect='non-scaling-stroke'
175
+ />
176
+ </svg>
177
+ )
178
+ }
179
+
180
+ function DraftOverlay({ frame, camera }: { frame: Frame; camera: Camera }) {
181
+ const position = cssFrame(frame, camera)
182
+ return (
183
+ <div
184
+ className='absolute border border-dashed border-primary bg-primary/5'
185
+ style={{
186
+ left: position.left,
187
+ top: position.top,
188
+ width: position.width,
189
+ height: position.height,
190
+ transform: `rotate(${position.angle}deg)`,
191
+ }}
192
+ />
193
+ )
194
+ }