Lixen commited on
Commit
9b80af1
·
unverified ·
1 Parent(s): a154740

feat: Refactor Brush Tool Settings to Sub-ToolRail panel (#537)

Browse files
ui/components/canvas/SubToolRail.tsx ADDED
@@ -0,0 +1,114 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ 'use client'
2
+
3
+ import { AnimatePresence, motion } from 'motion/react'
4
+ import * as React from 'react'
5
+ import { useTranslation } from 'react-i18next'
6
+
7
+ import { ColorPicker } from '@/components/ui/color-picker'
8
+ import { Input } from '@/components/ui/input'
9
+ import { Slider } from '@/components/ui/slider'
10
+ import { useEditorUiStore } from '@/lib/stores/editorUiStore'
11
+ import { usePreferencesStore } from '@/lib/stores/preferencesStore'
12
+
13
+ export function SubToolRail() {
14
+ const mode = useEditorUiStore((state) => state.mode)
15
+ const isBrushTool = mode === 'brush' || mode === 'eraser' || mode === 'repairBrush'
16
+
17
+ const brushConfig = usePreferencesStore((state) => state.brushConfig)
18
+ const setBrushConfig = usePreferencesStore((state) => state.setBrushConfig)
19
+ const { t } = useTranslation()
20
+
21
+ // Local state for live updates
22
+ const [localSize, setLocalSize] = React.useState(brushConfig.size)
23
+
24
+ // Sync when store changes
25
+ React.useEffect(() => {
26
+ setLocalSize(brushConfig.size)
27
+ }, [brushConfig.size])
28
+
29
+ return (
30
+ <AnimatePresence>
31
+ {isBrushTool && (
32
+ <motion.div
33
+ initial={{ x: -20, opacity: 0 }}
34
+ animate={{ x: 0, opacity: 1 }}
35
+ exit={{ x: -20, opacity: 0 }}
36
+ transition={{ duration: 0.2, ease: 'easeOut' }}
37
+ className='absolute top-14 left-11 z-50 ml-1 flex w-[260px] flex-col overflow-hidden rounded-xl border border-border bg-card shadow-2xl'
38
+ data-testid='sub-tool-rail'
39
+ >
40
+ <div className='space-y-4 p-4'>
41
+ {/* Brush Size */}
42
+ <div className='space-y-2'>
43
+ <p id='brush-size-label' className='text-[11px] font-medium text-muted-foreground'>
44
+ {t('toolbar.brushSize')}
45
+ </p>
46
+ <div className='flex items-center gap-2'>
47
+ <Slider
48
+ min={8}
49
+ max={128}
50
+ step={4}
51
+ value={[localSize]}
52
+ onValueChange={(vals) => setLocalSize(vals[0] ?? localSize)}
53
+ onValueCommit={(vals) => setBrushConfig({ size: vals[0] ?? localSize })}
54
+ className='flex-1'
55
+ aria-labelledby='brush-size-label'
56
+ />
57
+ <div className='flex shrink-0 items-center gap-1.5'>
58
+ <Input
59
+ value={localSize}
60
+ readOnly
61
+ aria-label='Brush size value'
62
+ className='h-8 w-11 border-border/50 bg-muted/20 px-1 text-center text-[11px]'
63
+ />
64
+ <span
65
+ className='w-4 text-[10px] font-medium text-muted-foreground'
66
+ aria-hidden='true'
67
+ >
68
+ px
69
+ </span>
70
+ </div>
71
+ </div>
72
+ </div>
73
+
74
+ {/* Color Picker Section */}
75
+ <AnimatePresence initial={false}>
76
+ {mode === 'brush' && (
77
+ <motion.div
78
+ initial={{ height: 0, opacity: 0 }}
79
+ animate={{ height: 'auto', opacity: 1 }}
80
+ exit={{ height: 0, opacity: 0 }}
81
+ transition={{ duration: 0.2, ease: 'easeInOut' }}
82
+ className='overflow-hidden border-t border-border/30 pt-2'
83
+ >
84
+ <div className='flex items-center justify-between'>
85
+ <p
86
+ id='brush-color-label'
87
+ className='text-[11px] font-medium text-muted-foreground'
88
+ >
89
+ {t('toolbar.brushColor')}
90
+ </p>
91
+ <div className='flex items-center gap-2'>
92
+ <span
93
+ className='font-mono text-[10px] text-muted-foreground uppercase'
94
+ aria-hidden='true'
95
+ >
96
+ {brushConfig.color}
97
+ </span>
98
+ <ColorPicker
99
+ value={brushConfig.color}
100
+ onChange={(color) => setBrushConfig({ color })}
101
+ className='size-5 rounded-md'
102
+ aria-labelledby='brush-color-label'
103
+ />
104
+ </div>
105
+ </div>
106
+ </motion.div>
107
+ )}
108
+ </AnimatePresence>
109
+ </div>
110
+ </motion.div>
111
+ )}
112
+ </AnimatePresence>
113
+ )
114
+ }
ui/components/canvas/ToolRail.tsx CHANGED
@@ -5,9 +5,6 @@ import type { ComponentType } from 'react'
5
  import { useTranslation } from 'react-i18next'
6
 
7
  import { Button } from '@/components/ui/button'
8
- import { ColorPicker } from '@/components/ui/color-picker'
9
- import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
10
- import { Slider } from '@/components/ui/slider'
11
  import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
12
  import { useEditorUiStore } from '@/lib/stores/editorUiStore'
13
  import { usePreferencesStore } from '@/lib/stores/preferencesStore'
@@ -65,19 +62,6 @@ export function ToolRail() {
65
  {MODES.map((item) => {
66
  const label = t(item.labelKey)
67
 
68
- // Brush tool gets a popover
69
- if (item.value === 'brush') {
70
- return (
71
- <BrushToolWithPopover
72
- key={item.value}
73
- item={item}
74
- label={label}
75
- isActive={item.value === mode}
76
- onSelect={() => setMode(item.value)}
77
- />
78
- )
79
- }
80
-
81
  return (
82
  <Tooltip key={item.value}>
83
  <TooltipTrigger asChild>
@@ -105,98 +89,3 @@ export function ToolRail() {
105
  </div>
106
  )
107
  }
108
-
109
- function BrushToolWithPopover({
110
- item,
111
- label,
112
- isActive,
113
- onSelect,
114
- }: {
115
- item: ModeDefinition
116
- label: string
117
- isActive: boolean
118
- onSelect: () => void
119
- }) {
120
- const {
121
- brushConfig: { size: brushSize, color: brushColor },
122
- shortcuts,
123
- setBrushConfig,
124
- } = usePreferencesStore()
125
- const { t } = useTranslation()
126
-
127
- return (
128
- <Popover>
129
- <Tooltip>
130
- <TooltipTrigger asChild>
131
- <PopoverTrigger asChild>
132
- <Button
133
- variant='ghost'
134
- size='icon-sm'
135
- data-testid={item.testId}
136
- data-active={isActive}
137
- onClick={onSelect}
138
- className='border border-transparent text-muted-foreground data-[active=true]:border-primary data-[active=true]:bg-accent data-[active=true]:text-primary'
139
- aria-label={label}
140
- >
141
- <item.icon className='h-4 w-4' />
142
- </Button>
143
- </PopoverTrigger>
144
- </TooltipTrigger>
145
- <TooltipContent side='right' sideOffset={8}>
146
- {shortcuts[item.value as keyof typeof shortcuts]
147
- ? `${label} (${shortcuts[item.value as keyof typeof shortcuts].toUpperCase()})`
148
- : label}
149
- </TooltipContent>
150
- </Tooltip>
151
- <PopoverContent side='right' align='start' className='w-56'>
152
- <div className='space-y-4 text-sm'>
153
- <div className='space-y-2'>
154
- <p className='text-xs font-medium text-muted-foreground uppercase'>
155
- {t('toolbar.brushSize')}
156
- </p>
157
- <div className='flex items-center gap-2'>
158
- <Slider
159
- data-testid='brush-size-slider'
160
- className='flex-1 [&_[data-slot=slider-range]]:bg-primary [&_[data-slot=slider-thumb]]:size-3 [&_[data-slot=slider-thumb]]:border-primary [&_[data-slot=slider-thumb]]:bg-primary [&_[data-slot=slider-track]]:bg-primary/20'
161
- min={8}
162
- max={128}
163
- step={4}
164
- value={[brushSize]}
165
- onValueChange={(vals) => setBrushConfig({ size: vals[0] ?? brushSize })}
166
- />
167
- <Tooltip>
168
- <TooltipTrigger asChild>
169
- <span className='w-10 cursor-help text-right text-muted-foreground tabular-nums'>
170
- {brushSize}px
171
- </span>
172
- </TooltipTrigger>
173
- <TooltipContent side='bottom'>
174
- {t('toolbar.brushSize')} ({shortcuts.decreaseBrushSize}{' '}
175
- {shortcuts.increaseBrushSize})
176
- </TooltipContent>
177
- </Tooltip>
178
- </div>
179
- </div>
180
- <div className='space-y-2'>
181
- <p className='text-xs font-medium text-muted-foreground uppercase'>
182
- {t('toolbar.brushColor')}
183
- </p>
184
- <div className='flex items-center gap-2'>
185
- <ColorPicker
186
- value={brushColor}
187
- onChange={(color) => setBrushConfig({ color })}
188
- className='h-8 w-8'
189
- triggerTestId='brush-color-trigger'
190
- pickerTestId='brush-color-picker'
191
- swatchTestId='brush-color-swatch'
192
- inputTestId='brush-color-input'
193
- pickButtonTestId='brush-color-pick'
194
- />
195
- <span className='text-xs text-muted-foreground'>{brushColor}</span>
196
- </div>
197
- </div>
198
- </div>
199
- </PopoverContent>
200
- </Popover>
201
- )
202
- }
 
5
  import { useTranslation } from 'react-i18next'
6
 
7
  import { Button } from '@/components/ui/button'
 
 
 
8
  import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
9
  import { useEditorUiStore } from '@/lib/stores/editorUiStore'
10
  import { usePreferencesStore } from '@/lib/stores/preferencesStore'
 
62
  {MODES.map((item) => {
63
  const label = t(item.labelKey)
64
 
 
 
 
 
 
 
 
 
 
 
 
 
 
65
  return (
66
  <Tooltip key={item.value}>
67
  <TooltipTrigger asChild>
 
89
  </div>
90
  )
91
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
ui/components/canvas/Workspace.tsx CHANGED
@@ -12,6 +12,7 @@ import {
12
  setCanvasDocumentSize,
13
  setCanvasViewport,
14
  } from '@/components/canvas/canvasViewport'
 
15
  import { TextBlockLayer } from '@/components/canvas/TextBlockLayer'
16
  import { ToolRail } from '@/components/canvas/ToolRail'
17
  import {
@@ -286,8 +287,9 @@ export function Workspace() {
286
  )
287
 
288
  return (
289
- <div className='flex min-h-0 min-w-0 flex-1 bg-muted'>
290
  <ToolRail />
 
291
  <div className='relative flex min-h-0 min-w-0 flex-1 flex-col'>
292
  <CanvasToolbar />
293
  <ScrollAreaPrimitive.Root className='flex min-h-0 min-w-0 flex-1'>
 
12
  setCanvasDocumentSize,
13
  setCanvasViewport,
14
  } from '@/components/canvas/canvasViewport'
15
+ import { SubToolRail } from '@/components/canvas/SubToolRail'
16
  import { TextBlockLayer } from '@/components/canvas/TextBlockLayer'
17
  import { ToolRail } from '@/components/canvas/ToolRail'
18
  import {
 
287
  )
288
 
289
  return (
290
+ <div className='relative flex min-h-0 min-w-0 flex-1 bg-muted'>
291
  <ToolRail />
292
+ <SubToolRail />
293
  <div className='relative flex min-h-0 min-w-0 flex-1 flex-col'>
294
  <CanvasToolbar />
295
  <ScrollAreaPrimitive.Root className='flex min-h-0 min-w-0 flex-1'>
ui/components/ui/color-picker.tsx CHANGED
@@ -17,6 +17,8 @@ type ColorPickerProps = {
17
  swatchTestId?: string
18
  inputTestId?: string
19
  pickButtonTestId?: string
 
 
20
  }
21
 
22
  type EyeDropperWindow = Window & {
@@ -40,6 +42,8 @@ export function ColorPicker({
40
  swatchTestId,
41
  inputTestId,
42
  pickButtonTestId,
 
 
43
  }: ColorPickerProps) {
44
  const [localColor, setLocalColor] = useState(value)
45
  const dragging = useRef(false)
@@ -82,6 +86,8 @@ export function ColorPicker({
82
  <button
83
  data-testid={triggerTestId}
84
  disabled={disabled}
 
 
85
  className={cn(
86
  'flex h-7 w-7 cursor-pointer items-center justify-center rounded-md border border-input transition hover:border-border disabled:cursor-not-allowed disabled:opacity-50',
87
  className,
 
17
  swatchTestId?: string
18
  inputTestId?: string
19
  pickButtonTestId?: string
20
+ 'aria-label'?: string
21
+ 'aria-labelledby'?: string
22
  }
23
 
24
  type EyeDropperWindow = Window & {
 
42
  swatchTestId,
43
  inputTestId,
44
  pickButtonTestId,
45
+ 'aria-label': ariaLabel,
46
+ 'aria-labelledby': ariaLabelledBy,
47
  }: ColorPickerProps) {
48
  const [localColor, setLocalColor] = useState(value)
49
  const dragging = useRef(false)
 
86
  <button
87
  data-testid={triggerTestId}
88
  disabled={disabled}
89
+ aria-label={ariaLabel}
90
+ aria-labelledby={ariaLabelledBy}
91
  className={cn(
92
  'flex h-7 w-7 cursor-pointer items-center justify-center rounded-md border border-input transition hover:border-border disabled:cursor-not-allowed disabled:opacity-50',
93
  className,
ui/tests/components/SubToolRail.test.tsx ADDED
@@ -0,0 +1,81 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { screen } from '@testing-library/react'
2
+ import { beforeEach, describe, expect, it, vi } from 'vitest'
3
+
4
+ import { SubToolRail } from '@/components/canvas/SubToolRail'
5
+ import { useEditorUiStore } from '@/lib/stores/editorUiStore'
6
+ import { usePreferencesStore } from '@/lib/stores/preferencesStore'
7
+
8
+ import { renderWithQuery } from '../helpers'
9
+
10
+ // Mock framer-motion to avoid animation issues in tests
11
+ vi.mock('motion/react', async (importOriginal) => {
12
+ const actual = await importOriginal<typeof import('motion/react')>()
13
+ return {
14
+ ...actual,
15
+ motion: {
16
+ ...actual.motion,
17
+ div: ({ children, ...props }: any) => <div {...props}>{children}</div>,
18
+ },
19
+ AnimatePresence: ({ children }: any) => <>{children}</>,
20
+ }
21
+ })
22
+
23
+ describe('SubToolRail', () => {
24
+ beforeEach(() => {
25
+ useEditorUiStore.setState({ mode: 'select' })
26
+ usePreferencesStore.setState({
27
+ brushConfig: {
28
+ size: 36,
29
+ color: '#ffffff',
30
+ },
31
+ })
32
+ })
33
+
34
+ it('renders nothing when select tool is active', () => {
35
+ const { container } = renderWithQuery(<SubToolRail />)
36
+ expect(container.firstChild).toBeNull()
37
+ })
38
+
39
+ it('renders when brush tool is active', () => {
40
+ useEditorUiStore.setState({ mode: 'brush' })
41
+ renderWithQuery(<SubToolRail />)
42
+ expect(screen.getByTestId('sub-tool-rail')).toBeInTheDocument()
43
+ expect(screen.getByText('toolbar.brushSize')).toBeInTheDocument()
44
+ })
45
+
46
+ it('renders when eraser tool is active', () => {
47
+ useEditorUiStore.setState({ mode: 'eraser' })
48
+ renderWithQuery(<SubToolRail />)
49
+ expect(screen.getByTestId('sub-tool-rail')).toBeInTheDocument()
50
+ })
51
+
52
+ it('renders when repairBrush tool is active', () => {
53
+ useEditorUiStore.setState({ mode: 'repairBrush' })
54
+ renderWithQuery(<SubToolRail />)
55
+ expect(screen.getByTestId('sub-tool-rail')).toBeInTheDocument()
56
+ })
57
+
58
+ it('shows color picker only for brush tool', () => {
59
+ // Check Brush tool
60
+ useEditorUiStore.setState({ mode: 'brush' })
61
+ const { rerender } = renderWithQuery(<SubToolRail />)
62
+ expect(screen.getByText('toolbar.brushColor')).toBeInTheDocument()
63
+
64
+ // Switch to Eraser
65
+ useEditorUiStore.setState({ mode: 'eraser' })
66
+ rerender(<SubToolRail />)
67
+ expect(screen.queryByText('toolbar.brushColor')).not.toBeInTheDocument()
68
+
69
+ // Switch to Repair Brush
70
+ useEditorUiStore.setState({ mode: 'repairBrush' })
71
+ rerender(<SubToolRail />)
72
+ expect(screen.queryByText('toolbar.brushColor')).not.toBeInTheDocument()
73
+ })
74
+
75
+ it('displays the correct brush size', () => {
76
+ useEditorUiStore.setState({ mode: 'brush' })
77
+ usePreferencesStore.setState({ brushConfig: { size: 64, color: '#ff0000' } })
78
+ renderWithQuery(<SubToolRail />)
79
+ expect(screen.getByDisplayValue('64')).toBeInTheDocument()
80
+ })
81
+ })