@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,45 @@
1
+ "use client"
2
+
3
+ import { Toggle as TogglePrimitive } from "@base-ui/react/toggle"
4
+ import { cva, type VariantProps } from "class-variance-authority"
5
+
6
+ import { cn } from "../lib/utils"
7
+
8
+ const toggleVariants = cva(
9
+ "group/toggle inline-flex items-center justify-center gap-1 rounded-lg text-sm font-medium whitespace-nowrap transition-all outline-none hover:bg-muted hover:text-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 aria-pressed:bg-muted data-[state=on]:bg-muted dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
10
+ {
11
+ variants: {
12
+ variant: {
13
+ default: "bg-transparent",
14
+ outline: "border border-input bg-transparent hover:bg-muted",
15
+ },
16
+ size: {
17
+ default:
18
+ "h-8 min-w-8 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
19
+ sm: "h-7 min-w-7 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5",
20
+ lg: "h-9 min-w-9 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
21
+ },
22
+ },
23
+ defaultVariants: {
24
+ variant: "default",
25
+ size: "default",
26
+ },
27
+ }
28
+ )
29
+
30
+ function Toggle({
31
+ className,
32
+ variant = "default",
33
+ size = "default",
34
+ ...props
35
+ }: TogglePrimitive.Props & VariantProps<typeof toggleVariants>) {
36
+ return (
37
+ <TogglePrimitive
38
+ data-slot="toggle"
39
+ className={cn(toggleVariants({ variant, size, className }))}
40
+ {...props}
41
+ />
42
+ )
43
+ }
44
+
45
+ export { Toggle, toggleVariants }
@@ -0,0 +1,67 @@
1
+ "use client"
2
+
3
+ import { Tooltip as TooltipPrimitive } from "@base-ui/react/tooltip"
4
+
5
+ import { cn } from "../lib/utils"
6
+
7
+ function TooltipProvider({
8
+ delay = 0,
9
+ ...props
10
+ }: TooltipPrimitive.Provider.Props) {
11
+ return (
12
+ <TooltipPrimitive.Provider
13
+ data-slot="tooltip-provider"
14
+ delay={delay}
15
+ {...props}
16
+ />
17
+ )
18
+ }
19
+
20
+ function Tooltip({ ...props }: TooltipPrimitive.Root.Props) {
21
+ return <TooltipPrimitive.Root data-slot="tooltip" {...props} />
22
+ }
23
+
24
+ function TooltipTrigger({ ...props }: TooltipPrimitive.Trigger.Props) {
25
+ return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />
26
+ }
27
+
28
+ function TooltipContent({
29
+ className,
30
+ side = "top",
31
+ sideOffset = 4,
32
+ align = "center",
33
+ alignOffset = 0,
34
+ children,
35
+ ...props
36
+ }: TooltipPrimitive.Popup.Props &
37
+ Pick<
38
+ TooltipPrimitive.Positioner.Props,
39
+ "align" | "alignOffset" | "side" | "sideOffset"
40
+ >) {
41
+ return (
42
+ <TooltipPrimitive.Portal>
43
+ <TooltipPrimitive.Positioner
44
+ align={align}
45
+ alignOffset={alignOffset}
46
+ side={side}
47
+ sideOffset={sideOffset}
48
+ className="isolate z-50"
49
+ >
50
+ <TooltipPrimitive.Popup
51
+ data-koharu-ui-layer=""
52
+ data-slot="tooltip-content"
53
+ className={cn(
54
+ "z-50 inline-flex w-fit max-w-xs origin-(--transform-origin) items-center gap-1.5 rounded-md bg-foreground px-3 py-1.5 text-xs text-background has-data-[slot=kbd]:pr-1.5 data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 **:data-[slot=kbd]:relative **:data-[slot=kbd]:isolate **:data-[slot=kbd]:z-50 **:data-[slot=kbd]:rounded-sm data-[state=delayed-open]:animate-in data-[state=delayed-open]:fade-in-0 data-[state=delayed-open]:zoom-in-95 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
55
+ className
56
+ )}
57
+ {...props}
58
+ >
59
+ {children}
60
+ <TooltipPrimitive.Arrow className="z-50 size-2.5 translate-y-[calc(-50%-2px)] rotate-45 rounded-[2px] bg-foreground fill-foreground data-[side=bottom]:top-1 data-[side=inline-end]:top-1/2! data-[side=inline-end]:-left-1 data-[side=inline-end]:-translate-y-1/2 data-[side=inline-start]:top-1/2! data-[side=inline-start]:-right-1 data-[side=inline-start]:-translate-y-1/2 data-[side=left]:top-1/2! data-[side=left]:-right-1 data-[side=left]:-translate-y-1/2 data-[side=right]:top-1/2! data-[side=right]:-left-1 data-[side=right]:-translate-y-1/2 data-[side=top]:-bottom-2.5" />
61
+ </TooltipPrimitive.Popup>
62
+ </TooltipPrimitive.Positioner>
63
+ </TooltipPrimitive.Portal>
64
+ )
65
+ }
66
+
67
+ export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }
@@ -0,0 +1,19 @@
1
+ import * as React from "react"
2
+
3
+ const MOBILE_BREAKPOINT = 768
4
+
5
+ export function useIsMobile() {
6
+ const [isMobile, setIsMobile] = React.useState<boolean | undefined>(undefined)
7
+
8
+ React.useEffect(() => {
9
+ const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`)
10
+ const onChange = () => {
11
+ setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
12
+ }
13
+ mql.addEventListener("change", onChange)
14
+ setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
15
+ return () => mql.removeEventListener("change", onChange)
16
+ }, [])
17
+
18
+ return !!isMobile
19
+ }
@@ -0,0 +1,6 @@
1
+ import { clsx, type ClassValue } from 'clsx'
2
+ import { twMerge } from 'tailwind-merge'
3
+
4
+ export function cn(...inputs: ClassValue[]) {
5
+ return twMerge(clsx(inputs))
6
+ }
@@ -0,0 +1,73 @@
1
+ /* SPDX-License-Identifier: Apache-2.0 */
2
+ @source '../../components/**/*.{ts,tsx}';
3
+ @source '../**/*.{ts,tsx}';
4
+
5
+ /* Translate the Koharu-internal primitive class vocabulary to host M3 roles. */
6
+ @theme inline {
7
+ --radius-sm: var(--md-sys-shape-corner-extra-small);
8
+ --radius-md: var(--md-sys-shape-corner-small);
9
+ --radius-lg: var(--md-sys-shape-corner-medium);
10
+ --radius-xl: var(--md-sys-shape-corner-large);
11
+ --radius-2xl: var(--md-sys-shape-corner-extra-large);
12
+ --radius-3xl: var(--md-sys-shape-corner-extra-large-increased);
13
+ --radius-4xl: var(--md-sys-shape-corner-extra-extra-large);
14
+
15
+ --color-background: var(--md-sys-color-surface);
16
+ --color-foreground: var(--md-sys-color-on-surface);
17
+ --color-card: var(--md-sys-color-surface-container-low);
18
+ --color-card-foreground: var(--md-sys-color-on-surface);
19
+ --color-popover: var(--md-sys-color-surface-container-high);
20
+ --color-popover-foreground: var(--md-sys-color-on-surface);
21
+ --color-primary: var(--md-sys-color-primary);
22
+ --color-primary-foreground: var(--md-sys-color-on-primary);
23
+ --color-secondary: var(--md-sys-color-secondary-container);
24
+ --color-secondary-foreground: var(--md-sys-color-on-secondary-container);
25
+ --color-muted: var(--md-sys-color-surface-container-highest);
26
+ --color-muted-foreground: var(--md-sys-color-on-surface-variant);
27
+ --color-accent: var(--md-sys-color-tertiary-container);
28
+ --color-accent-foreground: var(--md-sys-color-on-tertiary-container);
29
+ --color-destructive: var(--md-sys-color-error);
30
+ --color-border: var(--md-sys-color-outline-variant);
31
+ --color-input: var(--md-sys-color-outline);
32
+ --color-ring: var(--md-sys-color-primary);
33
+ --color-chart-1: var(--md-sys-color-primary);
34
+ --color-chart-2: var(--md-sys-color-secondary);
35
+ --color-chart-3: var(--md-sys-color-tertiary);
36
+ --color-chart-4: var(--md-sys-color-error);
37
+ --color-chart-5: var(--md-sys-color-inverse-primary);
38
+ --color-sidebar: var(--md-sys-color-surface-container);
39
+ --color-sidebar-foreground: var(--md-sys-color-on-surface);
40
+ --color-sidebar-primary: var(--md-sys-color-primary);
41
+ --color-sidebar-primary-foreground: var(--md-sys-color-on-primary);
42
+ --color-sidebar-accent: var(--md-sys-color-secondary-container);
43
+ --color-sidebar-accent-foreground: var(--md-sys-color-on-secondary-container);
44
+ --color-sidebar-border: var(--md-sys-color-outline-variant);
45
+ --color-sidebar-ring: var(--md-sys-color-primary);
46
+
47
+ --shadow-sm: var(--md-sys-elevation-level1);
48
+ --shadow-md: var(--md-sys-elevation-level2);
49
+ --shadow-lg: var(--md-sys-elevation-level3);
50
+ --shadow-xl: var(--md-sys-elevation-level4);
51
+ --shadow-2xl: var(--md-sys-elevation-level5);
52
+ }
53
+
54
+ @layer base {
55
+ :is([data-koharu-ui], [data-koharu-ui-layer]) *,
56
+ [data-koharu-ui-layer] {
57
+ border-color: var(--md-sys-color-outline-variant);
58
+ }
59
+
60
+ [data-koharu-ui] {
61
+ color: var(--md-sys-color-on-surface);
62
+ background-color: var(--md-sys-color-surface);
63
+ font-size: 18px;
64
+ line-height: 1.2;
65
+ }
66
+
67
+ [data-koharu-ui-layer] {
68
+ color: var(--md-sys-color-on-surface);
69
+ background-color: var(--md-sys-color-surface);
70
+ font-size: 18px;
71
+ line-height: 1.2;
72
+ }
73
+ }
@@ -0,0 +1,85 @@
1
+ 'use client'
2
+
3
+ import { commands } from '@aphrody/koharu-bridge/protocol'
4
+ import type {
5
+ PipelineConfig,
6
+ Preferences,
7
+ ProviderPreferences,
8
+ TypesettingConfig,
9
+ } from '@aphrody/koharu-bridge/protocol'
10
+
11
+ import { receiveError, receivePreferences, receiveTranslationModels } from './store'
12
+
13
+ type Command<Args extends unknown[], Result> = (...args: Args) => Promise<Result>
14
+
15
+ let translationModelsRequest: Promise<void> | null = null
16
+ let translationModelsGeneration = 0
17
+ let preferencesWriteGeneration = 0
18
+ let preferencesWriteQueue: Promise<void> = Promise.resolve()
19
+
20
+ export async function call<Args extends unknown[], Result>(
21
+ command: Command<Args, Result>,
22
+ ...args: Args
23
+ ): Promise<Result> {
24
+ try {
25
+ return await command(...args)
26
+ } catch (error) {
27
+ throw report(error)
28
+ }
29
+ }
30
+
31
+ export function dispatch<Args extends unknown[], Result>(
32
+ command: Command<Args, Result>,
33
+ ...args: Args
34
+ ): void {
35
+ void call(command, ...args).catch(() => undefined)
36
+ }
37
+
38
+ export async function refreshPreferences(): Promise<void> {
39
+ const generation = preferencesWriteGeneration
40
+ await preferencesWriteQueue
41
+ const preferences = await call(commands.getPreferences)
42
+ if (generation === preferencesWriteGeneration) receivePreferences(preferences)
43
+ }
44
+
45
+ export function savePreferences(
46
+ pipeline: PipelineConfig,
47
+ providers: ProviderPreferences,
48
+ typesetting: TypesettingConfig,
49
+ ): Promise<Preferences> {
50
+ preferencesWriteGeneration += 1
51
+ const pending = preferencesWriteQueue
52
+ .catch(() => undefined)
53
+ .then(() => call(commands.savePreferences, pipeline, providers, typesetting))
54
+ preferencesWriteQueue = pending.then(
55
+ () => undefined,
56
+ () => undefined,
57
+ )
58
+ return pending
59
+ }
60
+
61
+ export function refreshTranslationModels(force = false): Promise<void> {
62
+ if (!force && translationModelsRequest) return translationModelsRequest
63
+
64
+ const generation = ++translationModelsGeneration
65
+ const request = call(commands.getTranslationModels)
66
+ .then((models) => {
67
+ if (generation === translationModelsGeneration) receiveTranslationModels(models)
68
+ })
69
+ .finally(() => {
70
+ if (translationModelsRequest === request) translationModelsRequest = null
71
+ })
72
+ translationModelsRequest = request
73
+ return request
74
+ }
75
+
76
+ function report(error: unknown): Error {
77
+ const message =
78
+ error instanceof Error
79
+ ? error.message
80
+ : typeof error === 'string'
81
+ ? error
82
+ : 'The native application returned an unknown error.'
83
+ receiveError(message)
84
+ return error instanceof Error ? error : new Error(message)
85
+ }
@@ -0,0 +1,46 @@
1
+ import type { Layer } from '@aphrody/koharu-bridge/protocol'
2
+
3
+ export function isTextLayer(layer: Layer): layer is Extract<Layer, { type: 'text' }> {
4
+ return layer.type === 'text'
5
+ }
6
+
7
+ export function isGroupLayer(layer: Layer): layer is Extract<Layer, { type: 'group' }> {
8
+ return layer.type === 'group'
9
+ }
10
+
11
+ export function isLockedLayer(layer: Layer): boolean {
12
+ return layer.type === 'artwork'
13
+ }
14
+
15
+ export function layerChildren(layers: Layer[], parent: string): Layer[] {
16
+ return layers.filter((layer) => layer.parent === parent)
17
+ }
18
+
19
+ export function expandLayerSelection(layers: Layer[], selected: string[]): string[] {
20
+ const result: string[] = []
21
+ const visit = (id: string) => {
22
+ const layer = layers.find((candidate) => candidate.id === id)
23
+ if (!layer) return
24
+ if (isGroupLayer(layer)) {
25
+ for (const child of layerChildren(layers, id)) visit(child.id)
26
+ } else if (!result.includes(id)) {
27
+ result.push(id)
28
+ }
29
+ }
30
+ for (const id of selected) visit(id)
31
+ return result
32
+ }
33
+
34
+ export function effectiveLayerVisibility(layers: Layer[], layer: Layer) {
35
+ let visible = layer.visibility.visible
36
+ let opacity = layer.visibility.opacity
37
+ let parent = layer.parent
38
+ while (parent) {
39
+ const group = layers.find((candidate) => candidate.id === parent)
40
+ if (!group || !isGroupLayer(group)) break
41
+ visible &&= group.visibility.visible
42
+ opacity *= group.visibility.opacity
43
+ parent = group.parent
44
+ }
45
+ return { visible, opacity }
46
+ }
@@ -0,0 +1,272 @@
1
+ import type { EntityId, Frame, Layer, Point, TransformFrame } from '@aphrody/koharu-bridge/protocol'
2
+
3
+ import { effectiveLayerVisibility, isTextLayer } from './document'
4
+
5
+ const minimumFrameSize = 1e-6
6
+
7
+ export interface Camera {
8
+ zoom: number
9
+ translation: [number, number]
10
+ }
11
+
12
+ export interface CssFrame {
13
+ left: number
14
+ top: number
15
+ width: number
16
+ height: number
17
+ angle: number
18
+ }
19
+
20
+ export type ResizeHandle = 'nw' | 'n' | 'ne' | 'e' | 'se' | 's' | 'sw' | 'w'
21
+
22
+ const resizeDirections: Record<ResizeHandle, Point> = {
23
+ nw: { x: -1, y: -1 },
24
+ n: { x: 0, y: -1 },
25
+ ne: { x: 1, y: -1 },
26
+ e: { x: 1, y: 0 },
27
+ se: { x: 1, y: 1 },
28
+ s: { x: 0, y: 1 },
29
+ sw: { x: -1, y: 1 },
30
+ w: { x: -1, y: 0 },
31
+ }
32
+
33
+ export function physicalPoint(clientX: number, clientY: number, bounds: DOMRect): Point {
34
+ const dpr = window.devicePixelRatio
35
+ return { x: (clientX - bounds.x) * dpr, y: (clientY - bounds.y) * dpr }
36
+ }
37
+
38
+ export function pagePoint(
39
+ clientX: number,
40
+ clientY: number,
41
+ bounds: DOMRect,
42
+ camera: Camera,
43
+ ): Point {
44
+ const point = physicalPoint(clientX, clientY, bounds)
45
+ return {
46
+ x: (point.x - camera.translation[0]) / camera.zoom,
47
+ y: (point.y - camera.translation[1]) / camera.zoom,
48
+ }
49
+ }
50
+
51
+ export function draftFrame(start: Point, end: Point): Frame {
52
+ return {
53
+ x: Math.min(start.x, end.x),
54
+ y: Math.min(start.y, end.y),
55
+ width: Math.max(1, Math.abs(end.x - start.x)),
56
+ height: Math.max(1, Math.abs(end.y - start.y)),
57
+ angle_degrees: 0,
58
+ }
59
+ }
60
+
61
+ export function selectableLayer(layer: Layer): boolean {
62
+ return layer.type === 'text' || layer.type === 'image'
63
+ }
64
+
65
+ export function layerFrame(layer: Layer): Frame | null {
66
+ const points =
67
+ layer.type === 'text' || layer.type === 'image' || layer.type === 'artwork'
68
+ ? layer.geometry?.points
69
+ : null
70
+ if (!points?.length || points.some((point) => !finite(point.x, point.y))) return null
71
+ if (layer.type === 'text' && layer.angle_degrees !== null) {
72
+ if (!finite(layer.angle_degrees)) return null
73
+ const angle = (layer.angle_degrees * Math.PI) / 180
74
+ const cos = Math.cos(angle)
75
+ const sin = Math.sin(angle)
76
+ const xs = points.map((point) => point.x * cos + point.y * sin)
77
+ const ys = points.map((point) => -point.x * sin + point.y * cos)
78
+ const left = Math.min(...xs)
79
+ const top = Math.min(...ys)
80
+ const right = Math.max(...xs)
81
+ const bottom = Math.max(...ys)
82
+ const width = right - left
83
+ const height = bottom - top
84
+ const centerX = (left + right) / 2
85
+ const centerY = (top + bottom) / 2
86
+ return width > minimumFrameSize && height > minimumFrameSize
87
+ ? {
88
+ x: centerX * cos - centerY * sin - width / 2,
89
+ y: centerX * sin + centerY * cos - height / 2,
90
+ width,
91
+ height,
92
+ angle_degrees: layer.angle_degrees,
93
+ }
94
+ : null
95
+ }
96
+ if (points.length === 4) {
97
+ const [topLeft, topRight, bottomRight, bottomLeft] = points
98
+ const top: [number, number] = [topRight.x - topLeft.x, topRight.y - topLeft.y]
99
+ const right: [number, number] = [bottomRight.x - topRight.x, bottomRight.y - topRight.y]
100
+ const bottom: [number, number] = [bottomLeft.x - bottomRight.x, bottomLeft.y - bottomRight.y]
101
+ const left: [number, number] = [topLeft.x - bottomLeft.x, topLeft.y - bottomLeft.y]
102
+ const width = Math.hypot(...top)
103
+ const height = Math.hypot(...right)
104
+ if (width > minimumFrameSize && height > minimumFrameSize) {
105
+ const scale = Math.max(width, height, 1)
106
+ const oppositeLengthsMatch =
107
+ Math.abs(Math.hypot(...bottom) - width) <= scale * 1e-6 &&
108
+ Math.abs(Math.hypot(...left) - height) <= scale * 1e-6
109
+ const perpendicular = Math.abs(top[0] * right[0] + top[1] * right[1]) <= width * height * 1e-6
110
+ const diagonalsBisect =
111
+ Math.abs(topLeft.x + bottomRight.x - topRight.x - bottomLeft.x) <= scale * 1e-6 &&
112
+ Math.abs(topLeft.y + bottomRight.y - topRight.y - bottomLeft.y) <= scale * 1e-6
113
+ if (oppositeLengthsMatch && perpendicular && diagonalsBisect) {
114
+ const centerX = points.reduce((sum, point) => sum + point.x, 0) * 0.25
115
+ const centerY = points.reduce((sum, point) => sum + point.y, 0) * 0.25
116
+ return {
117
+ x: centerX - width * 0.5,
118
+ y: centerY - height * 0.5,
119
+ width,
120
+ height,
121
+ angle_degrees: (Math.atan2(top[1], top[0]) * 180) / Math.PI,
122
+ }
123
+ }
124
+ }
125
+ }
126
+
127
+ const xs = points.map((point) => point.x)
128
+ const ys = points.map((point) => point.y)
129
+ const x = Math.min(...xs)
130
+ const y = Math.min(...ys)
131
+ const width = Math.max(...xs) - x
132
+ const height = Math.max(...ys) - y
133
+ return width > minimumFrameSize && height > minimumFrameSize
134
+ ? { x, y, width, height, angle_degrees: 0 }
135
+ : null
136
+ }
137
+
138
+ export function controlFrame(
139
+ layer: Layer,
140
+ frames: Readonly<Record<EntityId, Frame>>,
141
+ ): Frame | null {
142
+ const frame = isTextLayer(layer) ? frames[layer.id] : undefined
143
+ return frame && validFrame(frame) ? frame : layerFrame(layer)
144
+ }
145
+
146
+ export function hitTestLayers(
147
+ layers: Layer[],
148
+ point: Point,
149
+ frames: Readonly<Record<EntityId, Frame>>,
150
+ ): Layer | null {
151
+ for (let index = layers.length - 1; index >= 0; index -= 1) {
152
+ const layer = layers[index]
153
+ const visibility = effectiveLayerVisibility(layers, layer)
154
+ if (!selectableLayer(layer) || !visibility.visible || visibility.opacity <= 0) continue
155
+ const frame = controlFrame(layer, frames)
156
+ if (frame && frameContains(frame, point)) return layer
157
+ }
158
+ return null
159
+ }
160
+
161
+ export function frameContains(frame: Frame, point: Point): boolean {
162
+ const centerX = frame.x + frame.width * 0.5
163
+ const centerY = frame.y + frame.height * 0.5
164
+ const angle = (-frame.angle_degrees * Math.PI) / 180
165
+ const cos = Math.cos(angle)
166
+ const sin = Math.sin(angle)
167
+ const x = point.x - centerX
168
+ const y = point.y - centerY
169
+ const localX = x * cos - y * sin
170
+ const localY = x * sin + y * cos
171
+ return Math.abs(localX) <= frame.width * 0.5 && Math.abs(localY) <= frame.height * 0.5
172
+ }
173
+
174
+ export function cssFrame(frame: Frame, camera: Camera): CssFrame {
175
+ const dpr = window.devicePixelRatio
176
+ const scale = camera.zoom / dpr
177
+ return {
178
+ left: (frame.x * camera.zoom + camera.translation[0]) / dpr,
179
+ top: (frame.y * camera.zoom + camera.translation[1]) / dpr,
180
+ width: frame.width * scale,
181
+ height: frame.height * scale,
182
+ angle: frame.angle_degrees,
183
+ }
184
+ }
185
+
186
+ export function resizeFrame(
187
+ frame: Frame,
188
+ handle: ResizeHandle,
189
+ point: Point,
190
+ minimumSize: number,
191
+ ): Frame {
192
+ const direction = resizeDirections[handle]
193
+ const angle = (frame.angle_degrees * Math.PI) / 180
194
+ const widthAxis = { x: Math.cos(angle), y: Math.sin(angle) }
195
+ const heightAxis = { x: -widthAxis.y, y: widthAxis.x }
196
+ const center = { x: frame.x + frame.width * 0.5, y: frame.y + frame.height * 0.5 }
197
+ const anchor = {
198
+ x:
199
+ center.x -
200
+ widthAxis.x * direction.x * frame.width * 0.5 -
201
+ heightAxis.x * direction.y * frame.height * 0.5,
202
+ y:
203
+ center.y -
204
+ widthAxis.y * direction.x * frame.width * 0.5 -
205
+ heightAxis.y * direction.y * frame.height * 0.5,
206
+ }
207
+ const delta = { x: point.x - anchor.x, y: point.y - anchor.y }
208
+ const width = direction.x
209
+ ? Math.max(minimumSize, direction.x * dot(delta, widthAxis))
210
+ : frame.width
211
+ const height = direction.y
212
+ ? Math.max(minimumSize, direction.y * dot(delta, heightAxis))
213
+ : frame.height
214
+ const nextCenter = {
215
+ x:
216
+ anchor.x +
217
+ widthAxis.x * direction.x * width * 0.5 +
218
+ heightAxis.x * direction.y * height * 0.5,
219
+ y:
220
+ anchor.y +
221
+ widthAxis.y * direction.x * width * 0.5 +
222
+ heightAxis.y * direction.y * height * 0.5,
223
+ }
224
+ return {
225
+ ...frame,
226
+ x: nextCenter.x - width * 0.5,
227
+ y: nextCenter.y - height * 0.5,
228
+ width,
229
+ height,
230
+ }
231
+ }
232
+
233
+ export function rotateFrame(frame: Frame, start: Point, point: Point): Frame {
234
+ const center = { x: frame.x + frame.width * 0.5, y: frame.y + frame.height * 0.5 }
235
+ const from = { x: start.x - center.x, y: start.y - center.y }
236
+ const to = { x: point.x - center.x, y: point.y - center.y }
237
+ if (
238
+ Math.hypot(from.x, from.y) <= minimumFrameSize ||
239
+ Math.hypot(to.x, to.y) <= minimumFrameSize
240
+ ) {
241
+ return frame
242
+ }
243
+ const delta = (Math.atan2(from.x * to.y - from.y * to.x, dot(from, to)) * 180) / Math.PI
244
+ return { ...frame, angle_degrees: normalizeDegrees(frame.angle_degrees + delta) }
245
+ }
246
+
247
+ export function translateFrames(originals: TransformFrame[], delta: Point): TransformFrame[] {
248
+ return originals.map(({ element, frame }) => ({
249
+ element,
250
+ frame: { ...frame, x: frame.x + delta.x, y: frame.y + delta.y },
251
+ }))
252
+ }
253
+
254
+ function finite(...values: number[]): boolean {
255
+ return values.every(Number.isFinite)
256
+ }
257
+
258
+ function dot(left: Point, right: Point): number {
259
+ return left.x * right.x + left.y * right.y
260
+ }
261
+
262
+ function normalizeDegrees(value: number): number {
263
+ return ((((value + 180) % 360) + 360) % 360) - 180
264
+ }
265
+
266
+ function validFrame(frame: Frame): boolean {
267
+ return (
268
+ finite(frame.x, frame.y, frame.width, frame.height, frame.angle_degrees) &&
269
+ frame.width > minimumFrameSize &&
270
+ frame.height > minimumFrameSize
271
+ )
272
+ }
@@ -0,0 +1,54 @@
1
+ 'use client'
2
+
3
+ import i18n, { type Resource } from 'i18next'
4
+ import LanguageDetector from 'i18next-browser-languagedetector'
5
+ import LocalStorageBackend from 'i18next-localstorage-backend'
6
+ import { initReactI18next } from 'react-i18next'
7
+
8
+ import enUS from '@aphrody/koharu-ui/locales/en-US/translation.json'
9
+ import esES from '@aphrody/koharu-ui/locales/es-ES/translation.json'
10
+ import jaJP from '@aphrody/koharu-ui/locales/ja-JP/translation.json'
11
+ import koKR from '@aphrody/koharu-ui/locales/ko-KR/translation.json'
12
+ import ptBR from '@aphrody/koharu-ui/locales/pt-BR/translation.json'
13
+ import ruRU from '@aphrody/koharu-ui/locales/ru-RU/translation.json'
14
+ import trTR from '@aphrody/koharu-ui/locales/tr-TR/translation.json'
15
+ import zhCN from '@aphrody/koharu-ui/locales/zh-CN/translation.json'
16
+ import zhTW from '@aphrody/koharu-ui/locales/zh-TW/translation.json'
17
+
18
+ export const resources = {
19
+ 'en-US': { translation: enUS },
20
+ 'zh-CN': { translation: zhCN },
21
+ 'zh-TW': { translation: zhTW },
22
+ 'ja-JP': { translation: jaJP },
23
+ 'ru-RU': { translation: ruRU },
24
+ 'es-ES': { translation: esES },
25
+ 'tr-TR': { translation: trTR },
26
+ 'ko-KR': { translation: koKR },
27
+ 'pt-BR': { translation: ptBR },
28
+ } satisfies Resource
29
+
30
+ export type SupportedLanguage = keyof typeof resources
31
+
32
+ export const supportedLanguages = Object.keys(resources) as SupportedLanguage[]
33
+
34
+ i18n
35
+ .use(LocalStorageBackend)
36
+ .use(LanguageDetector)
37
+ .use(initReactI18next)
38
+ .init({
39
+ resources,
40
+ supportedLngs: supportedLanguages,
41
+ fallbackLng: 'en-US',
42
+ detection: {
43
+ order: ['localStorage', 'navigator'],
44
+ caches: ['localStorage'],
45
+ },
46
+ interpolation: {
47
+ escapeValue: false, // not needed for react as it escapes by default
48
+ },
49
+ react: {
50
+ useSuspense: false,
51
+ },
52
+ })
53
+
54
+ export default i18n