@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,947 @@
1
+ 'use client'
2
+
3
+ import { useCallback, useEffect, useRef, useState } from 'react'
4
+ import { useTranslation } from 'react-i18next'
5
+
6
+ import { useColorSampling } from '@aphrody/koharu-ui/components/controls/ColorSampling'
7
+ import { CanvasCommandBar } from '@aphrody/koharu-ui/components/editor/CanvasCommandBar'
8
+ import { CanvasOverlay } from '@aphrody/koharu-ui/components/editor/CanvasOverlay'
9
+ import { StatusBar } from '@aphrody/koharu-ui/components/editor/StatusBar'
10
+ import { ToolBar } from '@aphrody/koharu-ui/components/editor/ToolBar'
11
+ import { useCanvas } from '@aphrody/koharu-ui/components/editor/useCanvas'
12
+ import { call } from '@aphrody/koharu-ui/lib/backend'
13
+ import { expandLayerSelection } from '@aphrody/koharu-ui/lib/document'
14
+ import {
15
+ controlFrame,
16
+ draftFrame,
17
+ hitTestLayers,
18
+ pagePoint,
19
+ physicalPoint,
20
+ selectableLayer,
21
+ translateFrames,
22
+ } from '@aphrody/koharu-ui/lib/geometry'
23
+ import {
24
+ pageKey,
25
+ pagesKey,
26
+ preparedPageKey,
27
+ projectKey,
28
+ queryClient,
29
+ refresh,
30
+ usePage,
31
+ usePages,
32
+ } from '@aphrody/koharu-ui/lib/queries'
33
+ import {
34
+ isBrushTool,
35
+ MAX_BRUSH_DIAMETER,
36
+ MIN_BRUSH_DIAMETER,
37
+ receiveError,
38
+ useKoharuStore,
39
+ type CanvasTool,
40
+ } from '@aphrody/koharu-ui/lib/store'
41
+ import { prefetchCanvasPages, workspaceColor, type CanvasColor } from '@aphrody/koharu-bridge/canvas'
42
+ import { commands, type Frame, type Point, type TransformFrame } from '@aphrody/koharu-bridge/protocol'
43
+ import { Button } from '../../internal/components/button'
44
+
45
+ const BRUSH_DIAMETER_STEP = 4
46
+
47
+ const canvasCursors = {
48
+ select: undefined,
49
+ text: 'text',
50
+ draw: 'none',
51
+ eraser: 'none',
52
+ color_picker: 'crosshair',
53
+ remove: 'none',
54
+ pan: 'grab',
55
+ } as const satisfies Record<CanvasTool, string | undefined>
56
+
57
+ type Gesture =
58
+ | { kind: 'pan'; pointer: number; start: Point; translation: [number, number] }
59
+ | { kind: 'move'; pointer: number; start: Point; originals: TransformFrame[] }
60
+ | { kind: 'text'; pointer: number; start: Point; frame: Frame }
61
+ | StrokeGesture
62
+
63
+ interface StrokeGesture {
64
+ kind: 'paint' | 'erase' | 'inpaint'
65
+ pointer: number
66
+ revision: number
67
+ layer: string | null
68
+ points: Point[]
69
+ diameter: number
70
+ color?: CanvasColor
71
+ }
72
+
73
+ interface CanvasView {
74
+ zoom: number
75
+ translation: [number, number]
76
+ }
77
+
78
+ interface StrokeUpdate {
79
+ kind: 'paint' | 'erase' | 'inpaint'
80
+ points: Point[]
81
+ }
82
+
83
+ export function CanvasWorkspace() {
84
+ const { t } = useTranslation()
85
+ const surface = useRef<HTMLDivElement>(null)
86
+ const [canvasElement, setCanvasElement] = useState<HTMLCanvasElement | null>(null)
87
+ const gesture = useRef<Gesture | null>(null)
88
+ const previousPageIndex = useRef<number | null>(null)
89
+ const spaceHeld = useRef(false)
90
+ const transformActive = useRef(false)
91
+ const transformRevision = useRef<number | null>(null)
92
+ const transformFinal = useRef<TransformFrame[]>([])
93
+ const commitPending = useRef(false)
94
+ const commandQueue = useRef<Promise<void>>(Promise.resolve())
95
+ const [previews, setPreviews] = useState<Record<string, Frame>>({})
96
+ const [draft, setDraft] = useState<Frame | null>(null)
97
+ const [hovered, setHovered] = useState<string | null>(null)
98
+ const [cursor, setCursor] = useState<Point | null>(null)
99
+ const colorSampling = useColorSampling()
100
+
101
+ const page = usePage().data
102
+ const pages = usePages().data
103
+ const camera = useKoharuStore((state) => state.camera)
104
+ const canvasPage = useKoharuStore((state) => state.canvasPage)
105
+ const canvasRevision = useKoharuStore((state) => state.canvasRevision)
106
+ const canvasGeneration = useKoharuStore((state) => state.canvasGeneration)
107
+ const canvasSize = useKoharuStore((state) => state.canvasSize)
108
+ const fitCanvasRequest = useKoharuStore((state) => state.fitCanvasRequest)
109
+ const layerFrames = useKoharuStore((state) => state.layerFrames)
110
+ const tool = useKoharuStore((state) => state.tool)
111
+ const brush = useKoharuStore((state) => state.brush)
112
+ const selected = useKoharuStore((state) => state.selectedLayers)
113
+ const selectLayers = useKoharuStore((state) => state.selectLayers)
114
+ const setTool = useKoharuStore((state) => state.setTool)
115
+ const setBrush = useKoharuStore((state) => state.setBrush)
116
+ const requestCanvasFit = useKoharuStore((state) => state.requestCanvasFit)
117
+ const canvasState = useCanvas(canvasElement, canvasRevision, canvasGeneration)
118
+ const canvas = canvasState.canvas
119
+ const pageId = page?.id
120
+ const pageWidth = canvasSize[0] || page?.size.width
121
+ const pageHeight = canvasSize[1] || page?.size.height
122
+ const activeRaster =
123
+ selected.length === 1
124
+ ? page?.layers.find((layer) => layer.id === selected[0] && layer.type === 'raster')
125
+ : undefined
126
+
127
+ const enqueue = useCallback(<Result,>(operation: () => Promise<Result>): Promise<Result> => {
128
+ const pending = commandQueue.current.then(operation)
129
+ commandQueue.current = pending.then(
130
+ () => undefined,
131
+ () => undefined,
132
+ )
133
+ return pending
134
+ }, [])
135
+
136
+ const transformUpdates = useFrameCommand((elements: TransformFrame[]) =>
137
+ canvas?.updateTransform(elements),
138
+ )
139
+ const strokeUpdates = useFrameCommand(
140
+ ({ points }: StrokeUpdate) => canvas?.extendStroke(points),
141
+ mergeStrokeUpdates,
142
+ )
143
+
144
+ const beginTransform = useCallback(
145
+ (elements: TransformFrame[]) => {
146
+ if (
147
+ !canvas ||
148
+ canvasRevision === null ||
149
+ !elements.length ||
150
+ transformActive.current ||
151
+ commitPending.current
152
+ )
153
+ return
154
+ transformUpdates.clear()
155
+ transformActive.current = true
156
+ transformRevision.current = canvasRevision
157
+ transformFinal.current = elements
158
+ setPreviews(Object.fromEntries(elements.map(({ element, frame }) => [element, frame])))
159
+ try {
160
+ canvas.beginTransform(elements)
161
+ } catch (error) {
162
+ transformActive.current = false
163
+ receiveError(errorMessage(error))
164
+ }
165
+ },
166
+ [canvas, canvasRevision, transformUpdates],
167
+ )
168
+
169
+ const updateTransform = useCallback(
170
+ (elements: TransformFrame[]) => {
171
+ if (!transformActive.current) return
172
+ transformFinal.current = elements
173
+ setPreviews(Object.fromEntries(elements.map(({ element, frame }) => [element, frame])))
174
+ transformUpdates.schedule(elements)
175
+ },
176
+ [transformUpdates],
177
+ )
178
+
179
+ const finishTransform = useCallback(() => {
180
+ if (!transformActive.current) return
181
+ transformUpdates.commit()
182
+ transformActive.current = false
183
+ const revision = transformRevision.current
184
+ const elements = transformFinal.current
185
+ transformRevision.current = null
186
+ try {
187
+ canvas?.finishTransform()
188
+ } catch (error) {
189
+ receiveError(errorMessage(error))
190
+ setPreviews({})
191
+ return
192
+ }
193
+ if (revision === null) {
194
+ canvas?.cancelTransform()
195
+ setPreviews({})
196
+ return
197
+ }
198
+ commitPending.current = true
199
+ void enqueue(() => call(commands.commitTransform, revision, elements))
200
+ .then((revision) => (revision === null ? undefined : refresh(projectKey, pagesKey, pageKey)))
201
+ .catch(() => canvas?.cancelTransform())
202
+ .finally(() => {
203
+ commitPending.current = false
204
+ setPreviews({})
205
+ })
206
+ }, [canvas, enqueue, transformUpdates])
207
+
208
+ const cancelGesture = useCallback(() => {
209
+ const current = gesture.current
210
+ gesture.current = null
211
+ if (current?.kind === 'paint' || current?.kind === 'erase' || current?.kind === 'inpaint') {
212
+ strokeUpdates.clear()
213
+ canvas?.cancelStroke()
214
+ }
215
+ if (transformActive.current) {
216
+ transformUpdates.clear()
217
+ transformActive.current = false
218
+ transformRevision.current = null
219
+ canvas?.cancelTransform()
220
+ }
221
+ setDraft(null)
222
+ setPreviews({})
223
+ }, [canvas, strokeUpdates, transformUpdates])
224
+
225
+ const fitCanvas = useCallback(() => {
226
+ const element = surface.current
227
+ if (!element || !pageId || pageWidth === undefined || pageHeight === undefined) return
228
+ const bounds = element.getBoundingClientRect()
229
+ const dpr = window.devicePixelRatio
230
+ const next = containCamera(bounds.width * dpr, bounds.height * dpr, pageWidth, pageHeight)
231
+ useKoharuStore.setState({ camera: { ...next, fitted: true } })
232
+ }, [pageHeight, pageId, pageWidth])
233
+
234
+ const report = useCallback(() => {
235
+ const element = surface.current
236
+ if (!element || !canvas) return
237
+ const bounds = element.getBoundingClientRect()
238
+ canvas.resize(bounds.width, bounds.height, window.devicePixelRatio, workspaceColor())
239
+ if (useKoharuStore.getState().camera.fitted) fitCanvas()
240
+ }, [canvas, fitCanvas])
241
+
242
+ const setZoom = useCallback((zoom: number) => {
243
+ const element = surface.current
244
+ if (!element) return
245
+ const bounds = element.getBoundingClientRect()
246
+ const dpr = window.devicePixelRatio
247
+ const current = useKoharuStore.getState().camera
248
+ const center = { x: bounds.width * dpr * 0.5, y: bounds.height * dpr * 0.5 }
249
+ const pageX = (center.x - current.translation[0]) / current.zoom
250
+ const pageY = (center.y - current.translation[1]) / current.zoom
251
+ useKoharuStore.setState({
252
+ camera: {
253
+ zoom,
254
+ translation: [center.x - pageX * zoom, center.y - pageY * zoom],
255
+ fitted: false,
256
+ },
257
+ })
258
+ }, [])
259
+
260
+ useEffect(() => {
261
+ const element = surface.current
262
+ if (!element) return
263
+ report()
264
+ const resize = new ResizeObserver(report)
265
+ const theme = new MutationObserver(report)
266
+ resize.observe(element)
267
+ theme.observe(document.documentElement, { attributes: true, attributeFilter: ['class'] })
268
+ window.addEventListener('resize', report)
269
+ window.visualViewport?.addEventListener('resize', report)
270
+ return () => {
271
+ resize.disconnect()
272
+ theme.disconnect()
273
+ window.removeEventListener('resize', report)
274
+ window.visualViewport?.removeEventListener('resize', report)
275
+ }
276
+ }, [report])
277
+
278
+ useEffect(() => {
279
+ canvas?.setView(camera.zoom, camera.translation)
280
+ }, [canvas, camera])
281
+
282
+ useEffect(() => {
283
+ if (
284
+ !pageId ||
285
+ canvasPage !== pageId ||
286
+ canvasState.generation !== canvasGeneration ||
287
+ canvasState.status !== 'ready' ||
288
+ !pages?.length
289
+ )
290
+ return
291
+ const index = pages.findIndex((candidate) => candidate.id === pageId)
292
+ if (index < 0) return
293
+ const previous = previousPageIndex.current
294
+ previousPageIndex.current = index
295
+ const direction = previous !== null && index < previous ? -1 : 1
296
+ const adjacent = pages[index + direction]?.id ?? pages[index - direction]?.id
297
+ if (adjacent) {
298
+ void prefetchCanvasPages([adjacent])
299
+ .then((prepared) => {
300
+ for (const page of prepared) {
301
+ queryClient.setQueryData(preparedPageKey(page.page.id), page)
302
+ }
303
+ })
304
+ .catch(() => undefined)
305
+ }
306
+ }, [canvasGeneration, canvasPage, canvasState.generation, canvasState.status, pageId, pages])
307
+
308
+ useEffect(() => {
309
+ fitCanvas()
310
+ }, [fitCanvas, fitCanvasRequest])
311
+
312
+ useEffect(() => cancelGesture, [cancelGesture, canvasGeneration, canvasRevision, page?.id, tool])
313
+
314
+ useEffect(() => {
315
+ const editable = (target: EventTarget | null) =>
316
+ target instanceof HTMLInputElement ||
317
+ target instanceof HTMLTextAreaElement ||
318
+ (target instanceof HTMLElement && target.isContentEditable)
319
+
320
+ const down = (event: KeyboardEvent) => {
321
+ if (event.key === 'Alt') {
322
+ event.preventDefault()
323
+ return
324
+ }
325
+ if (editable(event.target)) return
326
+ const state = useKoharuStore.getState()
327
+ if (event.code === 'Space') {
328
+ spaceHeld.current = true
329
+ event.preventDefault()
330
+ return
331
+ }
332
+ const command = event.ctrlKey || event.metaKey
333
+ if (command && event.key.toLowerCase() === 'z') {
334
+ event.preventDefault()
335
+ void call(event.shiftKey ? commands.redo : commands.undo)
336
+ .then(() => refresh(projectKey, pagesKey, pageKey))
337
+ .catch(() => undefined)
338
+ return
339
+ }
340
+ if (command && event.key.toLowerCase() === 'a' && page) {
341
+ event.preventDefault()
342
+ selectLayers(page.layers.filter(selectableLayer).map((layer) => layer.id))
343
+ return
344
+ }
345
+ if (
346
+ (event.key === 'Delete' || event.key === 'Backspace') &&
347
+ state.selectedLayers.length > 0
348
+ ) {
349
+ event.preventDefault()
350
+ void call(commands.deleteLayers, state.selectedLayers)
351
+ .then(() => refresh(projectKey, pagesKey, pageKey))
352
+ .catch(() => undefined)
353
+ return
354
+ }
355
+ if (event.key.toLowerCase() === state.shortcuts.fit) {
356
+ requestCanvasFit()
357
+ return
358
+ }
359
+ if (event.key === 'Escape') {
360
+ cancelGesture()
361
+ colorSampling?.cancel()
362
+ selectLayers([])
363
+ return
364
+ }
365
+ const next = (
366
+ ['select', 'text', 'draw', 'eraser', 'color_picker', 'remove', 'pan'] as const
367
+ ).find((action) => state.shortcuts[action] === event.key.toLowerCase())
368
+ if (next) setTool(next)
369
+ }
370
+
371
+ const up = (event: KeyboardEvent) => {
372
+ if (event.key === 'Alt') event.preventDefault()
373
+ if (event.code === 'Space') spaceHeld.current = false
374
+ }
375
+ const blur = () => {
376
+ spaceHeld.current = false
377
+ cancelGesture()
378
+ }
379
+
380
+ window.addEventListener('keydown', down)
381
+ window.addEventListener('keyup', up)
382
+ window.addEventListener('blur', blur)
383
+ return () => {
384
+ window.removeEventListener('keydown', down)
385
+ window.removeEventListener('keyup', up)
386
+ window.removeEventListener('blur', blur)
387
+ }
388
+ }, [cancelGesture, colorSampling, page, requestCanvasFit, selectLayers, setTool])
389
+
390
+ const clientPagePoint = (clientX: number, clientY: number) =>
391
+ pagePoint(
392
+ clientX,
393
+ clientY,
394
+ surface.current!.getBoundingClientRect(),
395
+ useKoharuStore.getState().camera,
396
+ )
397
+
398
+ const clientPhysicalPoint = (clientX: number, clientY: number) =>
399
+ physicalPoint(clientX, clientY, surface.current!.getBoundingClientRect())
400
+
401
+ const framesFor = (layers: string[]): TransformFrame[] =>
402
+ expandLayerSelection(page?.layers ?? [], layers).flatMap((id) => {
403
+ const layer = page?.layers.find((candidate) => candidate.id === id)
404
+ const frame = layer && selectableLayer(layer) ? controlFrame(layer, layerFrames) : null
405
+ return frame ? [{ element: id, frame }] : []
406
+ })
407
+
408
+ const moveGesture = (
409
+ pointer: number,
410
+ samples: ReadonlyArray<{ clientX: number; clientY: number }>,
411
+ ) => {
412
+ const current = gesture.current
413
+ const sample = samples.at(-1)
414
+ if (!page || !sample) return
415
+ const physical = clientPhysicalPoint(sample.clientX, sample.clientY)
416
+ setCursor(physical)
417
+ if (!current || current.pointer !== pointer) {
418
+ if (tool === 'select') {
419
+ setHovered(
420
+ hitTestLayers(page.layers, clientPagePoint(sample.clientX, sample.clientY), layerFrames)
421
+ ?.id ?? null,
422
+ )
423
+ }
424
+ return
425
+ }
426
+
427
+ if (current.kind === 'pan') {
428
+ const bounds = surface.current!.getBoundingClientRect()
429
+ const dpr = window.devicePixelRatio
430
+ let translation: [number, number] = [
431
+ current.translation[0] + physical.x - current.start.x,
432
+ current.translation[1] + physical.y - current.start.y,
433
+ ]
434
+ translation = clampCameraTranslation(
435
+ translation,
436
+ camera.zoom,
437
+ pageWidth ?? page.size.width,
438
+ pageHeight ?? page.size.height,
439
+ bounds.width * dpr,
440
+ bounds.height * dpr,
441
+ dpr,
442
+ )
443
+ useKoharuStore.setState({ camera: { zoom: camera.zoom, translation, fitted: false } })
444
+ return
445
+ }
446
+
447
+ const points = samples.map((value) => clientPagePoint(value.clientX, value.clientY))
448
+ const point = points.at(-1)!
449
+ if (current.kind === 'move') {
450
+ updateTransform(
451
+ translateFrames(current.originals, {
452
+ x: point.x - current.start.x,
453
+ y: point.y - current.start.y,
454
+ }),
455
+ )
456
+ } else if (current.kind === 'text') {
457
+ current.frame = draftFrame(current.start, point)
458
+ setDraft(current.frame)
459
+ } else if (current.kind === 'paint' || current.kind === 'erase' || current.kind === 'inpaint') {
460
+ current.points.push(...points)
461
+ strokeUpdates.schedule({ kind: current.kind, points })
462
+ }
463
+ }
464
+
465
+ const finishGesture = () => {
466
+ const current = gesture.current
467
+ gesture.current = null
468
+ if (!current || !page) return
469
+ if (current.kind === 'move') {
470
+ finishTransform()
471
+ } else if (current.kind === 'text') {
472
+ const pointText =
473
+ current.frame.width < 4 / camera.zoom && current.frame.height < 4 / camera.zoom
474
+ setDraft(null)
475
+ void (
476
+ pointText
477
+ ? call(commands.addPointText, current.start)
478
+ : call(commands.addTextBox, current.frame)
479
+ )
480
+ .then((result) => {
481
+ selectLayers([result.layer])
482
+ return refresh(projectKey, pagesKey, pageKey)
483
+ })
484
+ .catch(() => undefined)
485
+ } else if (current.kind === 'paint' || current.kind === 'erase' || current.kind === 'inpaint') {
486
+ strokeUpdates.commit()
487
+ try {
488
+ canvas?.finishStroke()
489
+ } catch (error) {
490
+ receiveError(errorMessage(error))
491
+ return
492
+ }
493
+ const operation =
494
+ current.kind === 'paint'
495
+ ? enqueue(() =>
496
+ call(commands.commitPaint, current.revision, current.layer, current.points, {
497
+ diameter: current.diameter,
498
+ color: current.color!,
499
+ }),
500
+ ).then((result) => {
501
+ selectLayers([result.layer])
502
+ return refresh(projectKey, pagesKey, pageKey)
503
+ })
504
+ : current.kind === 'erase'
505
+ ? enqueue(() =>
506
+ call(
507
+ commands.commitErase,
508
+ current.revision,
509
+ current.layer!,
510
+ current.points,
511
+ current.diameter,
512
+ ),
513
+ ).then((result) => {
514
+ selectLayers([result.layer])
515
+ return refresh(projectKey, pagesKey, pageKey)
516
+ })
517
+ : enqueue(() =>
518
+ call(commands.commitInpaint, current.revision, current.points, current.diameter),
519
+ )
520
+ commitPending.current = true
521
+ void operation
522
+ .catch(() => canvas?.cancelStroke())
523
+ .finally(() => (commitPending.current = false))
524
+ }
525
+ }
526
+
527
+ return (
528
+ <main className='relative flex h-full min-h-0 min-w-0 flex-col overflow-hidden rounded-tl-2xl bg-[var(--surface-canvas)]'>
529
+ <CanvasCommandBar />
530
+ <div className='relative flex min-h-0 min-w-0 flex-1'>
531
+ <ToolBar />
532
+ <div
533
+ ref={surface}
534
+ tabIndex={0}
535
+ aria-label={t('canvas.surface')}
536
+ aria-busy={page ? canvasState.status !== 'ready' : undefined}
537
+ className='relative min-h-0 min-w-0 flex-1 touch-none overflow-hidden bg-[var(--surface-canvas)] outline-none'
538
+ style={{
539
+ cursor: page && canvasState.status === 'ready' ? canvasCursors[tool] : undefined,
540
+ }}
541
+ onContextMenu={(event) => event.preventDefault()}
542
+ onPointerDown={(event) => {
543
+ if (
544
+ !page ||
545
+ !canvas ||
546
+ canvasState.status !== 'ready' ||
547
+ canvasRevision === null ||
548
+ commitPending.current ||
549
+ event.button > 1
550
+ )
551
+ return
552
+ if (event.target instanceof Element && event.target.closest('[data-canvas-control]'))
553
+ return
554
+ event.currentTarget.focus()
555
+ event.currentTarget.setPointerCapture(event.pointerId)
556
+ const physical = clientPhysicalPoint(event.clientX, event.clientY)
557
+ const point = clientPagePoint(event.clientX, event.clientY)
558
+ setCursor(physical)
559
+
560
+ if (event.button === 1 || tool === 'pan' || spaceHeld.current) {
561
+ gesture.current = {
562
+ kind: 'pan',
563
+ pointer: event.pointerId,
564
+ start: physical,
565
+ translation: camera.translation,
566
+ }
567
+ } else if (tool === 'select') {
568
+ const target = hitTestLayers(page.layers, point, layerFrames)
569
+ const additive = event.shiftKey || event.ctrlKey || event.metaKey
570
+ if (!target) {
571
+ if (!additive) selectLayers([])
572
+ return
573
+ }
574
+ const next = additive
575
+ ? selected.includes(target.id)
576
+ ? selected.filter((id) => id !== target.id)
577
+ : [...selected, target.id]
578
+ : selected.includes(target.id)
579
+ ? selected
580
+ : [target.id]
581
+ selectLayers(next)
582
+ if (!next.includes(target.id)) return
583
+ const originals = framesFor(next)
584
+ if (!originals.length) return
585
+ gesture.current = { kind: 'move', pointer: event.pointerId, start: point, originals }
586
+ beginTransform(originals)
587
+ } else if (tool === 'text') {
588
+ const frame = draftFrame(point, point)
589
+ gesture.current = { kind: 'text', pointer: event.pointerId, start: point, frame }
590
+ setDraft(frame)
591
+ } else if (tool === 'draw') {
592
+ strokeUpdates.clear()
593
+ const color = hexToRgba(brush.color)
594
+ gesture.current = {
595
+ kind: 'paint',
596
+ pointer: event.pointerId,
597
+ revision: canvasRevision,
598
+ layer: activeRaster?.id ?? null,
599
+ points: [point],
600
+ diameter: brush.diameter,
601
+ color,
602
+ }
603
+ try {
604
+ canvas.beginStroke({
605
+ kind: 'paint',
606
+ layer: activeRaster?.id ?? null,
607
+ point,
608
+ diameter: brush.diameter,
609
+ color,
610
+ })
611
+ } catch (error) {
612
+ gesture.current = null
613
+ receiveError(errorMessage(error))
614
+ }
615
+ } else if (tool === 'eraser') {
616
+ if (!activeRaster) {
617
+ receiveError('Select a paint or cleanup layer before using the Eraser.')
618
+ return
619
+ }
620
+ strokeUpdates.clear()
621
+ gesture.current = {
622
+ kind: 'erase',
623
+ pointer: event.pointerId,
624
+ revision: canvasRevision,
625
+ layer: activeRaster.id,
626
+ points: [point],
627
+ diameter: brush.diameter,
628
+ }
629
+ try {
630
+ canvas.beginStroke({
631
+ kind: 'erase',
632
+ layer: activeRaster.id,
633
+ point,
634
+ diameter: brush.diameter,
635
+ })
636
+ } catch (error) {
637
+ gesture.current = null
638
+ receiveError(errorMessage(error))
639
+ }
640
+ } else if (tool === 'remove') {
641
+ strokeUpdates.clear()
642
+ gesture.current = {
643
+ kind: 'inpaint',
644
+ pointer: event.pointerId,
645
+ revision: canvasRevision,
646
+ layer: null,
647
+ points: [point],
648
+ diameter: brush.diameter,
649
+ }
650
+ try {
651
+ canvas.beginStroke({
652
+ kind: 'inpaint',
653
+ layer: null,
654
+ point,
655
+ diameter: brush.diameter,
656
+ })
657
+ } catch (error) {
658
+ gesture.current = null
659
+ receiveError(errorMessage(error))
660
+ }
661
+ } else if (tool === 'color_picker') {
662
+ void canvas
663
+ .sampleColor(physical)
664
+ .then((color) => {
665
+ const hex = rgbaToHex(color)
666
+ if (!colorSampling?.complete(hex)) setBrush({ ...brush, color: hex })
667
+ })
668
+ .catch((error: unknown) => receiveError(errorMessage(error)))
669
+ }
670
+ event.preventDefault()
671
+ }}
672
+ onPointerMove={(event) => {
673
+ if (!page) return
674
+ const coalesced = event.nativeEvent.getCoalescedEvents?.() ?? [event.nativeEvent]
675
+ moveGesture(event.pointerId, coalesced.length ? coalesced : [event.nativeEvent])
676
+ }}
677
+ onPointerUp={(event) => {
678
+ moveGesture(event.pointerId, [event.nativeEvent])
679
+ finishGesture()
680
+ if (event.currentTarget.hasPointerCapture(event.pointerId)) {
681
+ event.currentTarget.releasePointerCapture(event.pointerId)
682
+ }
683
+ }}
684
+ onPointerCancel={() => cancelGesture()}
685
+ onPointerLeave={(event) => {
686
+ if (!event.currentTarget.hasPointerCapture(event.pointerId)) {
687
+ setHovered(null)
688
+ setCursor(null)
689
+ }
690
+ }}
691
+ onWheel={(event) => {
692
+ if (!page) return
693
+ event.preventDefault()
694
+ if (event.altKey && isBrushTool(tool)) {
695
+ if (event.deltaY !== 0) {
696
+ const currentBrush = useKoharuStore.getState().brush
697
+ const delta = event.deltaY < 0 ? BRUSH_DIAMETER_STEP : -BRUSH_DIAMETER_STEP
698
+ const nextDiameter = clamp(
699
+ currentBrush.diameter + delta,
700
+ MIN_BRUSH_DIAMETER,
701
+ MAX_BRUSH_DIAMETER,
702
+ )
703
+ if (nextDiameter !== currentBrush.diameter) {
704
+ setBrush({ ...currentBrush, diameter: nextDiameter })
705
+ }
706
+ }
707
+ return
708
+ }
709
+ const point = clientPhysicalPoint(event.clientX, event.clientY)
710
+ const current = useKoharuStore.getState().camera
711
+ let zoom = current.zoom
712
+ let translation = current.translation
713
+ if (event.ctrlKey) {
714
+ zoom = clamp(current.zoom * Math.exp(-event.deltaY * 0.0015), 0.02, 16)
715
+ const pageX = (point.x - current.translation[0]) / current.zoom
716
+ const pageY = (point.y - current.translation[1]) / current.zoom
717
+ translation = [point.x - pageX * zoom, point.y - pageY * zoom]
718
+ } else {
719
+ const dpr = window.devicePixelRatio
720
+ let deltaX = event.deltaX
721
+ let deltaY = event.deltaY
722
+ if (event.shiftKey && deltaX === 0) {
723
+ deltaX = deltaY
724
+ deltaY = 0
725
+ }
726
+ translation = [
727
+ current.translation[0] - deltaX * dpr,
728
+ current.translation[1] - deltaY * dpr,
729
+ ]
730
+ }
731
+ const bounds = event.currentTarget.getBoundingClientRect()
732
+ const dpr = window.devicePixelRatio
733
+ translation = clampCameraTranslation(
734
+ translation,
735
+ zoom,
736
+ pageWidth ?? page.size.width,
737
+ pageHeight ?? page.size.height,
738
+ bounds.width * dpr,
739
+ bounds.height * dpr,
740
+ dpr,
741
+ )
742
+ useKoharuStore.setState({ camera: { zoom, translation, fitted: false } })
743
+ }}
744
+ >
745
+ <canvas
746
+ ref={setCanvasElement}
747
+ data-testid='webgpu-canvas'
748
+ aria-hidden
749
+ className='pointer-events-none absolute inset-0 block size-full'
750
+ />
751
+ {page && canvasState.status === 'ready' && (
752
+ <CanvasOverlay
753
+ page={page}
754
+ camera={camera}
755
+ selected={selected}
756
+ hovered={hovered}
757
+ frames={layerFrames}
758
+ previews={previews}
759
+ draft={draft}
760
+ cursor={cursor}
761
+ brushSize={brush.diameter}
762
+ showBrushCursor={isBrushTool(tool)}
763
+ onTransformStart={beginTransform}
764
+ onTransformFrame={updateTransform}
765
+ onTransformEnd={finishTransform}
766
+ />
767
+ )}
768
+ {page && canvasState.status === 'error' && (
769
+ <div
770
+ role='alert'
771
+ className={
772
+ canvasState.hasFrame
773
+ ? 'absolute inset-x-0 top-3 z-20 flex justify-center px-3'
774
+ : 'absolute inset-0 z-20 grid place-items-center bg-[var(--surface-canvas)]/90 p-6'
775
+ }
776
+ >
777
+ <div
778
+ className={
779
+ canvasState.hasFrame
780
+ ? 'flex max-w-sm flex-col items-center gap-2 rounded-md border border-border bg-popover/95 p-3 text-center shadow-sm'
781
+ : 'flex max-w-sm flex-col items-center gap-2 text-center'
782
+ }
783
+ >
784
+ <p className='text-sm font-medium text-foreground'>{t('canvas.unavailable')}</p>
785
+ {canvasState.error && (
786
+ <p className='text-xs leading-relaxed text-muted-foreground'>
787
+ {canvasState.error.message}
788
+ </p>
789
+ )}
790
+ <Button
791
+ data-canvas-control
792
+ size='sm'
793
+ variant='outline'
794
+ className='mt-1'
795
+ onClick={canvasState.retry}
796
+ >
797
+ {t('errors.tryAgain')}
798
+ </Button>
799
+ </div>
800
+ </div>
801
+ )}
802
+ {!page && (
803
+ <div className='pointer-events-none absolute inset-0 grid place-items-center'>
804
+ <p className='text-[12px] text-muted-foreground'>{t('canvas.empty')}</p>
805
+ </div>
806
+ )}
807
+ </div>
808
+ </div>
809
+ <StatusBar onZoomChange={setZoom} />
810
+ </main>
811
+ )
812
+ }
813
+
814
+ function hexToRgba(hex: string): [number, number, number, number] {
815
+ const value = hex.replace('#', '')
816
+ return [
817
+ Number.parseInt(value.slice(0, 2), 16),
818
+ Number.parseInt(value.slice(2, 4), 16),
819
+ Number.parseInt(value.slice(4, 6), 16),
820
+ 255,
821
+ ]
822
+ }
823
+
824
+ function rgbaToHex(color: [number, number, number, number]): string {
825
+ return `#${color
826
+ .slice(0, 3)
827
+ .map((channel) => channel.toString(16).padStart(2, '0'))
828
+ .join('')}`.toUpperCase()
829
+ }
830
+
831
+ function clamp(value: number, minimum: number, maximum: number): number {
832
+ return Math.min(maximum, Math.max(minimum, value))
833
+ }
834
+
835
+ function containCamera(
836
+ viewportWidth: number,
837
+ viewportHeight: number,
838
+ pageWidth: number,
839
+ pageHeight: number,
840
+ ): CanvasView {
841
+ if (viewportWidth <= 0 || viewportHeight <= 0 || pageWidth <= 0 || pageHeight <= 0) {
842
+ return { zoom: 1, translation: [0, 0] }
843
+ }
844
+ const zoom = Math.max(
845
+ Number.EPSILON,
846
+ Math.min(viewportWidth / pageWidth, viewportHeight / pageHeight),
847
+ )
848
+ return {
849
+ zoom,
850
+ translation: [
851
+ (viewportWidth - pageWidth * zoom) * 0.5,
852
+ (viewportHeight - pageHeight * zoom) * 0.5,
853
+ ],
854
+ }
855
+ }
856
+
857
+ function errorMessage(error: unknown): string {
858
+ if (error instanceof Error) return error.message
859
+ return typeof error === 'string' ? error : 'The WebGPU canvas returned an unknown error.'
860
+ }
861
+
862
+ function clampCameraTranslation(
863
+ translation: [number, number],
864
+ zoom: number,
865
+ pageWidth: number,
866
+ pageHeight: number,
867
+ viewportWidth: number,
868
+ viewportHeight: number,
869
+ dpr: number,
870
+ ): [number, number] {
871
+ const minOverlap = 100 * dpr
872
+ const minX = minOverlap - pageWidth * zoom
873
+ const minY = minOverlap - pageHeight * zoom
874
+ const maxX = viewportWidth - minOverlap
875
+ const maxY = viewportHeight - minOverlap
876
+
877
+ const x =
878
+ minX <= maxX ? clamp(translation[0], minX, maxX) : (viewportWidth - pageWidth * zoom) * 0.5
879
+ const y =
880
+ minY <= maxY ? clamp(translation[1], minY, maxY) : (viewportHeight - pageHeight * zoom) * 0.5
881
+ return [x, y]
882
+ }
883
+
884
+ function mergeStrokeUpdates(current: StrokeUpdate, next: StrokeUpdate): StrokeUpdate {
885
+ if (current.kind !== next.kind) return next
886
+ current.points.push(...next.points)
887
+ return current
888
+ }
889
+
890
+ function useFrameCommand<Value>(
891
+ execute: (value: Value) => void | Promise<unknown>,
892
+ merge?: (current: Value, next: Value) => Value,
893
+ ): FrameCommand<Value> {
894
+ const executeRef = useRef(execute)
895
+ executeRef.current = execute
896
+ const command = useRef<FrameCommand<Value> | null>(null)
897
+ command.current ??= new FrameCommand((value) => executeRef.current(value), merge)
898
+
899
+ useEffect(() => () => command.current?.clear(), [])
900
+ return command.current
901
+ }
902
+
903
+ class FrameCommand<Value> {
904
+ private pending: Value | undefined
905
+ private frame: number | null = null
906
+
907
+ constructor(
908
+ private readonly execute: (value: Value) => void | Promise<unknown>,
909
+ private readonly merge: (current: Value, next: Value) => Value = (_current, next) => next,
910
+ ) {}
911
+
912
+ schedule(value: Value): void {
913
+ this.pending = this.pending === undefined ? value : this.merge(this.pending, value)
914
+ if (this.frame !== null) return
915
+ this.frame = requestAnimationFrame(() => {
916
+ this.frame = null
917
+ this.executePending()
918
+ })
919
+ }
920
+
921
+ commit(): void {
922
+ if (this.frame !== null) {
923
+ cancelAnimationFrame(this.frame)
924
+ this.frame = null
925
+ }
926
+ this.executePending()
927
+ }
928
+
929
+ clear(): void {
930
+ if (this.frame !== null) cancelAnimationFrame(this.frame)
931
+ this.frame = null
932
+ this.pending = undefined
933
+ }
934
+
935
+ private executePending(): void {
936
+ const value = this.pending
937
+ if (value === undefined) return
938
+ this.pending = undefined
939
+ try {
940
+ void Promise.resolve(this.execute(value)).catch((error: unknown) =>
941
+ receiveError(errorMessage(error)),
942
+ )
943
+ } catch (error) {
944
+ receiveError(errorMessage(error))
945
+ }
946
+ }
947
+ }