trans1 / ui /components /panels /RenderControlsPanel.tsx
Mayo
fix: black color not apply
3cd559b unverified
'use client'
import {
AlignCenterIcon,
AlignLeftIcon,
AlignRightIcon,
BoldIcon,
ItalicIcon,
MinusIcon,
PlusIcon,
SquareIcon,
} from 'lucide-react'
import { type ComponentType, useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import { Button } from '@/components/ui/button'
import { ColorPicker } from '@/components/ui/color-picker'
import { FontSelect } from '@/components/ui/font-select'
import { Input } from '@/components/ui/input'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import {
useCurrentPage,
useSelectedTextNode,
useSelectedTextNodes,
useTextNodes,
type TextNodeEntry,
} from '@/hooks/useCurrentPage'
import { useGetGoogleFontsCatalog, useListFonts } from '@/lib/api/default/default'
import type {
FontFaceInfo,
FontPrediction,
Op,
TextAlign,
TextShaderEffect,
TextStrokeStyle,
TextStyle,
} from '@/lib/api/schemas'
import { applyOp, queueAutoRender } from '@/lib/io/scene'
import { ops } from '@/lib/ops'
import { useEditorUiStore } from '@/lib/stores/editorUiStore'
import { usePreferencesStore } from '@/lib/stores/preferencesStore'
import { cn } from '@/lib/utils'
const DEFAULT_COLOR: number[] = [0, 0, 0, 255]
const DEFAULT_STROKE_COLOR: number[] = [255, 255, 255, 255]
const DEFAULT_STROKE_WIDTH = 1.6
const MIN_STROKE_WIDTH = 0.2
const MAX_STROKE_WIDTH = 24
const STROKE_WIDTH_STEP = 0.1
const DEFAULT_FONT_FACES: FontFaceInfo[] = [
{
familyName: 'Arial',
postScriptName: 'ArialMT',
source: 'system',
cached: true,
},
]
const clampByte = (v: number) => Math.max(0, Math.min(255, Math.round(v)))
const clampStrokeWidth = (v: number) =>
Number(Math.max(MIN_STROKE_WIDTH, Math.min(MAX_STROKE_WIDTH, v)).toFixed(1))
const colorToHex = (color: number[]) =>
`#${color
.slice(0, 3)
.map((v) => clampByte(v).toString(16).padStart(2, '0'))
.join('')}`
const hexToColor = (value: string, alpha: number): number[] => {
const normalized = value.replace('#', '')
if (normalized.length !== 6) return [0, 0, 0, clampByte(alpha)]
const r = Number.parseInt(normalized.slice(0, 2), 16)
const g = Number.parseInt(normalized.slice(2, 4), 16)
const b = Number.parseInt(normalized.slice(4, 6), 16)
if ([r, g, b].some((c) => Number.isNaN(c))) return [0, 0, 0, clampByte(alpha)]
return [r, g, b, clampByte(alpha)]
}
const uniqueFontFaces = (values: FontFaceInfo[]) => {
const seen = new Set<string>()
return values.filter((v) => {
if (!v.postScriptName || seen.has(v.postScriptName)) return false
seen.add(v.postScriptName)
return true
})
}
const findFontFace = (fonts: FontFaceInfo[], value?: string) => {
if (!value) return undefined
return fonts.find(
(f) =>
f.postScriptName === value || f.familyName === value || f.familyName.trim() === value.trim(),
)
}
const fallbackFontFace = (value?: string): FontFaceInfo | undefined => {
const normalized = value?.trim()
if (!normalized) return undefined
return {
familyName: normalized,
postScriptName: normalized,
source: 'system',
cached: true,
}
}
const normalizeStroke = (stroke?: TextStrokeStyle | null): TextStrokeStyle => ({
enabled: stroke?.enabled ?? true,
color: stroke?.color ?? DEFAULT_STROKE_COLOR,
widthPx: stroke?.widthPx ?? null,
})
const normalizeEffect = (effect?: TextShaderEffect | null): TextShaderEffect => ({
bold: effect?.bold ?? false,
italic: effect?.italic ?? false,
})
const predictionColor = (prediction?: FontPrediction | null): number[] | undefined => {
const tc = prediction?.textColor
if (!tc || tc.length < 3) return undefined
return [clampByte(tc[0]), clampByte(tc[1]), clampByte(tc[2]), 255]
}
// Mirrors renderer precedence: explicit style color → predicted color → black.
const effectiveColorOf = (style?: TextStyle | null, prediction?: FontPrediction | null): number[] =>
style?.color ?? predictionColor(prediction) ?? DEFAULT_COLOR
const hasExplicitColor = (node: TextNodeEntry) => Array.isArray(node.data.style?.color)
export function RenderControlsPanel() {
const { t } = useTranslation()
const page = useCurrentPage()
const textNodes = useTextNodes()
const selectedNode = useSelectedTextNode()
const selectedNodes = useSelectedTextNodes()
const { data: availableFonts = [] } = useListFonts()
useGetGoogleFontsCatalog() // prefetch catalog so picker can decorate Google entries
const appDefaultFont = usePreferencesStore((s) => s.defaultFont)
const renderEffect = useEditorUiStore((s) => s.renderEffect)
const setRenderEffect = useEditorUiStore((s) => s.setRenderEffect)
const setRenderStroke = useEditorUiStore((s) => s.setRenderStroke)
const sortedFonts = useMemo(() => {
return [...(availableFonts ?? [])].sort((a, b) => a.familyName.localeCompare(b.familyName))
}, [availableFonts])
if (!page) {
return (
<div className='flex items-center justify-center py-6 text-xs text-muted-foreground'>
{t('textBlocks.emptyPrompt')}
</div>
)
}
const firstNode = textNodes[0]
const hasNodes = textNodes.length > 0
const fontCandidates = uniqueFontFaces(
[
...sortedFonts,
...(appDefaultFont ? [fallbackFontFace(appDefaultFont)] : []),
...(selectedNode?.data.style?.fontFamilies?.slice(0, 1)?.map(fallbackFontFace) ?? []),
...(firstNode?.data.style?.fontFamilies?.slice(0, 1)?.map(fallbackFontFace) ?? []),
...DEFAULT_FONT_FACES,
].filter((v): v is FontFaceInfo => !!v),
)
const currentFontCandidate =
selectedNode?.data.style?.fontFamilies?.[0] ??
appDefaultFont ??
firstNode?.data.style?.fontFamilies?.[0] ??
(hasNodes ? fontCandidates[0]?.postScriptName : '')
const currentFontFace =
findFontFace(fontCandidates, currentFontCandidate) ?? fallbackFontFace(currentFontCandidate)
const currentFont = currentFontFace?.postScriptName ?? ''
const currentFontFamilyName = currentFontFace?.familyName
const selectedStyle = selectedNode?.data.style ?? firstNode?.data.style
const colorSource = selectedNode ?? firstNode
const currentColor = effectiveColorOf(colorSource?.data.style, colorSource?.data.fontPrediction)
const currentColorHex = colorToHex(currentColor)
const currentStroke = normalizeStroke(selectedStyle?.stroke)
const currentStrokeColorHex = colorToHex(currentStroke.color ?? DEFAULT_STROKE_COLOR)
const currentStrokeWidth = currentStroke.widthPx ?? DEFAULT_STROKE_WIDTH
const currentEffect = normalizeEffect(selectedStyle?.effect ?? renderEffect)
// The scene only persists manual overrides in `style.fontSize`. Font detector
// metadata describes the source text, not the renderer's current auto-fit size.
const currentFontSize: number | undefined = selectedNode?.data.style?.fontSize ?? undefined
const effectiveAlign: TextAlign =
selectedNode?.data.style?.textAlign ??
firstNode?.data.style?.textAlign ??
(selectedNode?.data.translation ? 'center' : 'left')
// ---------------------------------------------------------------------------
// Mutations
// ---------------------------------------------------------------------------
const buildStyleOp = (n: TextNodeEntry, updates: Partial<TextStyle>): Op => {
const current = n.data.style
const nextStyle: TextStyle = {
fontFamilies: updates.fontFamilies ?? current?.fontFamilies ?? [],
fontSize: updates.fontSize ?? current?.fontSize ?? null,
color: updates.color ?? effectiveColorOf(current, n.data.fontPrediction),
effect: updates.effect ?? current?.effect ?? null,
stroke: updates.stroke ?? current?.stroke ?? null,
textAlign: updates.textAlign ?? current?.textAlign ?? null,
}
return ops.updateNode(page!.id, n.id, {
data: { text: { style: nextStyle } } as never,
})
}
const applyStyleToNodes = (
nodes: TextNodeEntry[],
updates: Partial<TextStyle>,
label: string,
) => {
if (!page || nodes.length === 0) return
void (async () => {
const op =
nodes.length === 1
? buildStyleOp(nodes[0], updates)
: ops.batch(
label,
nodes.map((n) => buildStyleOp(n, updates)),
)
await applyOp(op)
queueAutoRender(page.id)
})()
}
const applyStyleToSelected = (updates: Partial<TextStyle>): boolean => {
if (selectedNodes.length === 0) return false
applyStyleToNodes(selectedNodes, updates, 'Multi-block style update')
return true
}
const applyStyleToAll = (updates: Partial<TextStyle>) => {
applyStyleToNodes(textNodes, updates, 'Bulk style update')
}
const commitCurrentFontColorIfImplicit = () => {
const targets = selectedNodes.length > 0 ? selectedNodes : textNodes
if (targets.every(hasExplicitColor)) return
applyStyleToNodes(targets, { color: currentColor }, 'Explicit font color update')
}
const applyStrokeSetting = (nextStroke: TextStrokeStyle) => {
if (applyStyleToSelected({ stroke: normalizeStroke(nextStroke) })) return
setRenderStroke({
enabled: nextStroke.enabled ?? true,
color: (nextStroke.color ?? DEFAULT_STROKE_COLOR) as [number, number, number, number],
widthPx: nextStroke.widthPx ?? undefined,
})
}
const updateStrokeWidth = (value: number) => {
applyStrokeSetting({ ...currentStroke, widthPx: clampStrokeWidth(value) })
}
const effectItems: {
key: 'italic' | 'bold'
label: string
Icon: ComponentType<{ className?: string }>
}[] = [
{ key: 'italic', label: t('render.effectItalic'), Icon: ItalicIcon },
{ key: 'bold', label: t('render.effectBold'), Icon: BoldIcon },
]
const textAlignItems: {
value: TextAlign
label: string
Icon: ComponentType<{ className?: string }>
}[] = [
{ value: 'left', label: t('render.alignLeft'), Icon: AlignLeftIcon },
{ value: 'center', label: t('render.alignCenter'), Icon: AlignCenterIcon },
{ value: 'right', label: t('render.alignRight'), Icon: AlignRightIcon },
]
const scopeLabel =
selectedNodes.length > 1
? t('render.fontScopeBlocksCount', { count: selectedNodes.length })
: selectedNode
? t('render.fontScopeBlockIndex', {
index: textNodes.findIndex((n) => n.id === selectedNode.id) + 1,
})
: t('render.fontScopeGlobal')
const scopeToneClass = selectedNode
? 'border-primary/20 bg-primary/10 text-primary'
: 'border-border/60 bg-muted text-muted-foreground'
return (
<div className='flex w-full min-w-0 flex-col gap-2'>
{/* Scope */}
<div className='flex items-center justify-end'>
<span
data-testid='render-scope-indicator'
className={cn(
'rounded-full border px-2 py-0.5 text-[10px] font-medium tracking-wide uppercase',
scopeToneClass,
)}
>
{scopeLabel}
</span>
</div>
{/* Font + Color */}
<div className='flex flex-col gap-0.5'>
<div className='flex items-baseline justify-between'>
<span className='text-[10px] font-medium text-muted-foreground uppercase'>
{t('render.fontLabel')}
</span>
<span className='text-[10px] font-medium text-muted-foreground uppercase'>
{t('render.fontColorLabel')}
</span>
</div>
<div className='flex min-w-0 items-center gap-1.5'>
<div className='min-w-0 flex-1'>
<FontSelect
data-testid='render-font-select'
value={currentFont}
options={fontCandidates}
disabled={fontCandidates.length === 0}
placeholder={t('render.fontPlaceholder')}
triggerStyle={
currentFontFamilyName ? { fontFamily: currentFontFamilyName } : undefined
}
onChange={(value) => {
if (selectedNode) {
applyStyleToSelected({ fontFamilies: [value] })
return
}
usePreferencesStore.getState().setDefaultFont(value)
}}
/>
</div>
<ColorPicker
value={currentColorHex}
disabled={!hasNodes}
triggerTestId='render-color-trigger'
pickerTestId='render-color-picker'
swatchTestId='render-color-swatch'
inputTestId='render-color-input'
pickButtonTestId='render-color-pick'
onOpenChange={(open) => {
if (open) commitCurrentFontColorIfImplicit()
}}
onChange={(hex) => {
const nextColor = hexToColor(hex, currentColor[3] ?? 255)
if (applyStyleToSelected({ color: nextColor })) return
applyStyleToAll({ color: nextColor })
}}
className='size-7'
/>
</div>
</div>
{/* Size / Effect / Align */}
<div className='grid w-full grid-cols-[minmax(0,1fr)_auto_auto] items-end gap-x-1.5'>
<span className='text-[10px] font-medium text-muted-foreground uppercase'>
{t('render.fontSizeLabel')}
</span>
<span className='text-[10px] font-medium text-muted-foreground uppercase'>
{t('render.effectLabel')}
</span>
<span className='text-[10px] font-medium text-muted-foreground uppercase'>
{t('render.alignLabel')}
</span>
<div className='flex min-w-0 items-center rounded-md border border-input bg-background shadow-xs'>
<Button
type='button'
variant='ghost'
size='icon-sm'
className='size-6 shrink-0 rounded-r-none border-r'
disabled={!selectedNode}
onClick={() => {
const next = Math.max(6, Math.round((currentFontSize ?? 16) - 1))
applyStyleToSelected({ fontSize: next })
}}
>
<MinusIcon className='size-3' />
</Button>
<Input
type='number'
step='1'
min='6'
max='300'
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) : ''}
placeholder='auto'
onChange={(event) => {
const parsed = Number.parseInt(event.target.value, 10)
if (!Number.isFinite(parsed) || parsed < 1) return
applyStyleToSelected({ 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}
onClick={() => {
const next = Math.min(300, Math.round((currentFontSize ?? 16) + 1))
applyStyleToSelected({ fontSize: next })
}}
>
<PlusIcon className='size-3' />
</Button>
</div>
<div className='flex items-center gap-0.5'>
{effectItems.map((item) => {
const active = currentEffect[item.key]
const Icon = item.Icon
return (
<Tooltip key={item.key}>
<TooltipTrigger asChild>
<Button
variant='outline'
size='icon-sm'
aria-label={item.label}
data-testid={`render-effect-toggle-${item.key}`}
className={cn(
'size-6 shrink-0',
active &&
'border-primary bg-primary text-primary-foreground hover:bg-primary/90',
)}
onClick={() => {
const nextEffect: TextShaderEffect = {
...currentEffect,
[item.key]: !active,
}
if (applyStyleToSelected({ effect: nextEffect })) return
setRenderEffect({
bold: nextEffect.bold ?? false,
italic: nextEffect.italic ?? false,
})
}}
>
<Icon className='size-3' />
</Button>
</TooltipTrigger>
<TooltipContent side='bottom' sideOffset={4}>
{item.label}
</TooltipContent>
</Tooltip>
)
})}
</div>
<div className='flex items-center gap-0.5'>
{textAlignItems.map((item) => {
const active = effectiveAlign === item.value
const Icon = item.Icon
return (
<Tooltip key={item.value}>
<TooltipTrigger asChild>
<Button
variant='outline'
size='icon-sm'
aria-label={item.label}
data-testid={`render-align-${item.value}`}
disabled={!hasNodes}
className={cn(
'size-6 shrink-0',
active &&
'border-primary bg-primary text-primary-foreground hover:bg-primary/90',
)}
onClick={() => {
if (applyStyleToSelected({ textAlign: item.value })) return
applyStyleToAll({ textAlign: item.value })
}}
>
<Icon className='size-3' />
</Button>
</TooltipTrigger>
<TooltipContent side='bottom' sideOffset={4}>
{item.label}
</TooltipContent>
</Tooltip>
)
})}
</div>
</div>
{/* Border / Stroke */}
<div className='flex flex-col gap-0.5'>
<span className='text-[10px] font-medium text-muted-foreground uppercase'>
{t('render.effectBorder')}
</span>
<div className='flex min-w-0 items-center gap-1'>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant='outline'
size='icon-sm'
data-testid='render-stroke-enable'
className={cn(
'size-7 shrink-0',
currentStroke.enabled &&
'border-primary bg-primary text-primary-foreground hover:bg-primary/90',
)}
onClick={() =>
applyStrokeSetting({ ...currentStroke, enabled: !currentStroke.enabled })
}
>
<SquareIcon className='size-3.5' />
</Button>
</TooltipTrigger>
<TooltipContent side='bottom' sideOffset={4}>
{t('render.effectBorder')}
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<div>
<ColorPicker
value={currentStrokeColorHex}
disabled={!hasNodes}
triggerTestId='render-stroke-color-trigger'
pickerTestId='render-stroke-color-picker'
swatchTestId='render-stroke-color-swatch'
inputTestId='render-stroke-color-input'
pickButtonTestId='render-stroke-color-pick'
onChange={(hex) => {
applyStrokeSetting({
...currentStroke,
color: hexToColor(
hex,
(currentStroke.color ?? DEFAULT_STROKE_COLOR)[3] ?? 255,
),
})
}}
className='size-7'
/>
</div>
</TooltipTrigger>
<TooltipContent side='bottom' sideOffset={4}>
{t('render.strokeColorLabel')}
</TooltipContent>
</Tooltip>
<div className='flex min-w-0 flex-1 items-center rounded-md border border-input bg-background shadow-xs'>
<Button
type='button'
variant='ghost'
size='icon-sm'
className='size-7 shrink-0 rounded-r-none border-r'
onClick={() => updateStrokeWidth(currentStrokeWidth - STROKE_WIDTH_STEP)}
>
<MinusIcon className='size-3' />
</Button>
<Input
type='number'
step={String(STROKE_WIDTH_STEP)}
min={String(MIN_STROKE_WIDTH)}
max={String(MAX_STROKE_WIDTH)}
inputMode='decimal'
className='h-7 min-w-0 flex-1 [appearance:textfield] rounded-none border-0 px-1 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-stroke-width'
value={
Number.isFinite(currentStrokeWidth) ? currentStrokeWidth : DEFAULT_STROKE_WIDTH
}
onChange={(event) => {
const parsed = Number.parseFloat(event.target.value)
if (!Number.isFinite(parsed)) return
updateStrokeWidth(parsed)
}}
/>
<Button
type='button'
variant='ghost'
size='icon-sm'
className='size-7 shrink-0 rounded-l-none border-l'
onClick={() => updateStrokeWidth(currentStrokeWidth + STROKE_WIDTH_STEP)}
>
<PlusIcon className='size-3' />
</Button>
</div>
</div>
</div>
</div>
)
}