Skip to content

Commit c829991

Browse files
committed
fix: unlock font-size field for global editing without selection
The font-size input, increment, and decrement buttons were disabled unless a text node was selected, blocking the global workflow: typing a value with nothing selected should apply to every text box on the page, and clearing it should reset all boxes to auto. - Switch the disabled guard from !selectedNode to !hasNodes so the controls stay enabled whenever the page has text boxes. - Route commits through applyStyleToSelected when a node is selected and applyStyleToAll otherwise, so the existing per-node behavior is preserved. - Buffer the input in local state (globalFontSizeInput) while in global scope, otherwise the controlled value resets to '' on each keystroke because currentFontSize stays undefined with no selection. Closes #649.
1 parent 2107843 commit c829991

2 files changed

Lines changed: 116 additions & 12 deletions

File tree

ui/components/panels/RenderControlsPanel.tsx

Lines changed: 61 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -148,6 +148,14 @@ export function RenderControlsPanel() {
148148

149149
const sectionRef = useRef<HTMLDivElement>(null)
150150
const [sectionWidth, setSectionWidth] = useState<number>(0)
151+
// Buffers font-size input in global scope (no selection) so multi-digit
152+
// values can be typed before committing — the controlled `value` would
153+
// otherwise reset to '' on each keystroke because `currentFontSize` stays
154+
// undefined when nothing is selected.
155+
const [globalFontSizeInput, setGlobalFontSizeInput] = useState('')
156+
useEffect(() => {
157+
setGlobalFontSizeInput('')
158+
}, [selectedNode?.id, page?.id])
151159

152160
useEffect(() => {
153161
if (!sectionRef.current) return
@@ -565,10 +573,20 @@ export function RenderControlsPanel() {
565573
variant='ghost'
566574
size='icon-sm'
567575
className='size-6 shrink-0 rounded-r-none border-r'
568-
disabled={!selectedNode}
576+
disabled={!hasNodes}
569577
onClick={() => {
570-
const next = Math.max(6, Math.round((currentFontSize ?? 16) - 1))
571-
applyStyleToSelected({ fontSize: next })
578+
const base = selectedNode
579+
? (currentFontSize ?? 16)
580+
: globalFontSizeInput !== ''
581+
? Number.parseInt(globalFontSizeInput, 10)
582+
: 16
583+
const next = Math.max(6, Math.round((Number.isFinite(base) ? base : 16) - 1))
584+
if (selectedNode) {
585+
applyStyleToSelected({ fontSize: next })
586+
} else {
587+
setGlobalFontSizeInput(String(next))
588+
applyStyleToAll({ fontSize: next })
589+
}
572590
}}
573591
>
574592
<MinusIcon className='size-3' />
@@ -581,27 +599,58 @@ export function RenderControlsPanel() {
581599
inputMode='numeric'
582600
className='h-6 min-w-0 flex-1 [appearance:textfield] rounded-none border-0 px-0.5 text-center text-xs shadow-none focus-visible:ring-0 [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none'
583601
data-testid='render-font-size'
584-
disabled={!selectedNode}
585-
value={currentFontSize !== undefined ? Math.round(currentFontSize) : ''}
602+
disabled={!hasNodes}
603+
value={
604+
selectedNode
605+
? currentFontSize !== undefined
606+
? Math.round(currentFontSize)
607+
: ''
608+
: globalFontSizeInput
609+
}
586610
placeholder='auto'
587611
onChange={(event) => {
588-
if (event.target.value === '') {
589-
applyStyleToSelected({ fontSize: null })
612+
const raw = event.target.value
613+
if (selectedNode) {
614+
if (raw === '') {
615+
applyStyleToSelected({ fontSize: null })
616+
return
617+
}
618+
const parsed = Number.parseInt(raw, 10)
619+
if (!Number.isFinite(parsed) || parsed < 1) return
620+
applyStyleToSelected({ fontSize: Math.min(300, parsed) })
621+
return
622+
}
623+
// Global scope: buffer locally so multi-digit values survive
624+
// re-renders, then commit to every text box.
625+
setGlobalFontSizeInput(raw)
626+
if (raw === '') {
627+
applyStyleToAll({ fontSize: null })
628+
return
590629
}
591-
const parsed = Number.parseInt(event.target.value, 10)
630+
const parsed = Number.parseInt(raw, 10)
592631
if (!Number.isFinite(parsed) || parsed < 1) return
593-
applyStyleToSelected({ fontSize: Math.min(300, parsed) })
632+
applyStyleToAll({ fontSize: Math.min(300, parsed) })
594633
}}
595634
/>
596635
<Button
597636
type='button'
598637
variant='ghost'
599638
size='icon-sm'
600639
className='size-6 shrink-0 rounded-l-none border-l'
601-
disabled={!selectedNode}
640+
disabled={!hasNodes}
602641
onClick={() => {
603-
const next = Math.min(300, Math.round((currentFontSize ?? 16) + 1))
604-
applyStyleToSelected({ fontSize: next })
642+
const base = selectedNode
643+
? (currentFontSize ?? 16)
644+
: globalFontSizeInput !== ''
645+
? Number.parseInt(globalFontSizeInput, 10)
646+
: 16
647+
const next = Math.min(300, Math.round((Number.isFinite(base) ? base : 16) + 1))
648+
if (selectedNode) {
649+
applyStyleToSelected({ fontSize: next })
650+
} else {
651+
setGlobalFontSizeInput(String(next))
652+
applyStyleToAll({ fontSize: next })
653+
}
605654
}}
606655
>
607656
<PlusIcon className='size-3' />

ui/tests/components/RenderControlsPanel.test.tsx

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -170,6 +170,61 @@ describe('RenderControlsPanel Font Assignment', () => {
170170
expect(input).toHaveAttribute('placeholder', 'auto')
171171
})
172172

173+
it('typing a font size with no selection applies the size to all text boxes', async () => {
174+
renderWithQuery(<RenderControlsPanel />)
175+
176+
// No selection — global scope
177+
const input = (await screen.findByTestId('render-font-size')) as HTMLInputElement
178+
expect(input).not.toBeDisabled()
179+
180+
await userEvent.clear(input)
181+
await userEvent.type(input, '24')
182+
183+
await waitFor(() => expect(sceneActions.applyOp).toHaveBeenCalled())
184+
const op = (sceneActions.applyOp as any).mock.calls.at(-1)[0]
185+
expect(op).toHaveProperty('batch')
186+
expect(op.batch.ops).toHaveLength(2)
187+
for (const sub of op.batch.ops) {
188+
expect(sub.updateNode.patch.data.text.style.fontSize).toBe(24)
189+
}
190+
})
191+
192+
it('clearing the font size with no selection resets all boxes to auto', async () => {
193+
server.use(
194+
http.get('/api/v1/scene.json', () =>
195+
HttpResponse.json(
196+
sceneWithTextNodes([
197+
{
198+
id: 't1',
199+
kind: { text: { style: { fontFamilies: ['Arial'], fontSize: 20 } } },
200+
},
201+
{
202+
id: 't2',
203+
kind: { text: { style: { fontFamilies: ['Arial'], fontSize: 20 } } },
204+
},
205+
]),
206+
),
207+
),
208+
)
209+
210+
renderWithQuery(<RenderControlsPanel />)
211+
212+
const input = (await screen.findByTestId('render-font-size')) as HTMLInputElement
213+
// Type a value first so the buffer has content to clear.
214+
await userEvent.type(input, '30')
215+
await userEvent.clear(input)
216+
217+
await waitFor(() => {
218+
const calls = (sceneActions.applyOp as any).mock.calls
219+
expect(calls.length).toBeGreaterThan(0)
220+
const op = calls.at(-1)[0]
221+
expect(op).toHaveProperty('batch')
222+
for (const sub of op.batch.ops) {
223+
expect(sub.updateNode.patch.data.text.style.fontSize).toBeNull()
224+
}
225+
})
226+
})
227+
173228
it('opening the font color picker commits effective black as an explicit color', async () => {
174229
server.use(
175230
http.get('/api/v1/scene.json', () =>

0 commit comments

Comments
 (0)