Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
73 changes: 61 additions & 12 deletions ui/components/panels/RenderControlsPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,14 @@ export function RenderControlsPanel() {

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

useEffect(() => {
if (!sectionRef.current) return
Expand Down Expand Up @@ -565,10 +573,20 @@ export function RenderControlsPanel() {
variant='ghost'
size='icon-sm'
className='size-6 shrink-0 rounded-r-none border-r'
disabled={!selectedNode}
disabled={!hasNodes}
onClick={() => {
const next = Math.max(6, Math.round((currentFontSize ?? 16) - 1))
applyStyleToSelected({ fontSize: next })
const base = selectedNode
? (currentFontSize ?? 16)
: globalFontSizeInput !== ''
? Number.parseInt(globalFontSizeInput, 10)
: 16
const next = Math.max(6, Math.round((Number.isFinite(base) ? base : 16) - 1))
if (selectedNode) {
applyStyleToSelected({ fontSize: next })
} else {
setGlobalFontSizeInput(String(next))
applyStyleToAll({ fontSize: next })
}
}}
>
<MinusIcon className='size-3' />
Expand All @@ -581,27 +599,58 @@ export function RenderControlsPanel() {
inputMode='numeric'
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'
data-testid='render-font-size'
disabled={!selectedNode}
value={currentFontSize !== undefined ? Math.round(currentFontSize) : ''}
disabled={!hasNodes}
value={
selectedNode
? currentFontSize !== undefined
? Math.round(currentFontSize)
: ''
: globalFontSizeInput
}
placeholder='auto'
onChange={(event) => {
if (event.target.value === '') {
applyStyleToSelected({ fontSize: null })
const raw = event.target.value
if (selectedNode) {
if (raw === '') {
applyStyleToSelected({ fontSize: null })
return
}
const parsed = Number.parseInt(raw, 10)
if (!Number.isFinite(parsed) || parsed < 1) return
applyStyleToSelected({ fontSize: Math.min(300, parsed) })
return
}
// Global scope: buffer locally so multi-digit values survive
// re-renders, then commit to every text box.
setGlobalFontSizeInput(raw)
if (raw === '') {
applyStyleToAll({ fontSize: null })
return
}
const parsed = Number.parseInt(event.target.value, 10)
const parsed = Number.parseInt(raw, 10)
if (!Number.isFinite(parsed) || parsed < 1) return
applyStyleToSelected({ fontSize: Math.min(300, parsed) })
applyStyleToAll({ fontSize: Math.min(300, parsed) })
}}
/>
<Button
type='button'
variant='ghost'
size='icon-sm'
className='size-6 shrink-0 rounded-l-none border-l'
disabled={!selectedNode}
disabled={!hasNodes}
onClick={() => {
const next = Math.min(300, Math.round((currentFontSize ?? 16) + 1))
applyStyleToSelected({ fontSize: next })
const base = selectedNode
? (currentFontSize ?? 16)
: globalFontSizeInput !== ''
? Number.parseInt(globalFontSizeInput, 10)
: 16
const next = Math.min(300, Math.round((Number.isFinite(base) ? base : 16) + 1))
if (selectedNode) {
applyStyleToSelected({ fontSize: next })
} else {
setGlobalFontSizeInput(String(next))
applyStyleToAll({ fontSize: next })
}
}}
>
<PlusIcon className='size-3' />
Expand Down
55 changes: 55 additions & 0 deletions ui/tests/components/RenderControlsPanel.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,61 @@ describe('RenderControlsPanel Font Assignment', () => {
expect(input).toHaveAttribute('placeholder', 'auto')
})

it('typing a font size with no selection applies the size to all text boxes', async () => {
renderWithQuery(<RenderControlsPanel />)

// No selection — global scope
const input = (await screen.findByTestId('render-font-size')) as HTMLInputElement
expect(input).not.toBeDisabled()

await userEvent.clear(input)
await userEvent.type(input, '24')

await waitFor(() => expect(sceneActions.applyOp).toHaveBeenCalled())
const op = (sceneActions.applyOp as any).mock.calls.at(-1)[0]
expect(op).toHaveProperty('batch')
expect(op.batch.ops).toHaveLength(2)
for (const sub of op.batch.ops) {
expect(sub.updateNode.patch.data.text.style.fontSize).toBe(24)
}
})

it('clearing the font size with no selection resets all boxes to auto', async () => {
server.use(
http.get('/api/v1/scene.json', () =>
HttpResponse.json(
sceneWithTextNodes([
{
id: 't1',
kind: { text: { style: { fontFamilies: ['Arial'], fontSize: 20 } } },
},
{
id: 't2',
kind: { text: { style: { fontFamilies: ['Arial'], fontSize: 20 } } },
},
]),
),
),
)

renderWithQuery(<RenderControlsPanel />)

const input = (await screen.findByTestId('render-font-size')) as HTMLInputElement
// Type a value first so the buffer has content to clear.
await userEvent.type(input, '30')
await userEvent.clear(input)

await waitFor(() => {
const calls = (sceneActions.applyOp as any).mock.calls
expect(calls.length).toBeGreaterThan(0)
const op = calls.at(-1)[0]
expect(op).toHaveProperty('batch')
for (const sub of op.batch.ops) {
expect(sub.updateNode.patch.data.text.style.fontSize).toBeNull()
}
})
})

it('opening the font color picker commits effective black as an explicit color', async () => {
server.use(
http.get('/api/v1/scene.json', () =>
Expand Down
Loading