'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() 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 (
{t('textBlocks.emptyPrompt')}
) } 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): 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, 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): boolean => { if (selectedNodes.length === 0) return false applyStyleToNodes(selectedNodes, updates, 'Multi-block style update') return true } const applyStyleToAll = (updates: Partial) => { 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 (
{/* Scope */}
{scopeLabel}
{/* Font + Color */}
{t('render.fontLabel')} {t('render.fontColorLabel')}
{ if (selectedNode) { applyStyleToSelected({ fontFamilies: [value] }) return } usePreferencesStore.getState().setDefaultFont(value) }} />
{ if (open) commitCurrentFontColorIfImplicit() }} onChange={(hex) => { const nextColor = hexToColor(hex, currentColor[3] ?? 255) if (applyStyleToSelected({ color: nextColor })) return applyStyleToAll({ color: nextColor }) }} className='size-7' />
{/* Size / Effect / Align */}
{t('render.fontSizeLabel')} {t('render.effectLabel')} {t('render.alignLabel')}
{ const parsed = Number.parseInt(event.target.value, 10) if (!Number.isFinite(parsed) || parsed < 1) return applyStyleToSelected({ fontSize: Math.min(300, parsed) }) }} />
{effectItems.map((item) => { const active = currentEffect[item.key] const Icon = item.Icon return ( {item.label} ) })}
{textAlignItems.map((item) => { const active = effectiveAlign === item.value const Icon = item.Icon return ( {item.label} ) })}
{/* Border / Stroke */}
{t('render.effectBorder')}
{t('render.effectBorder')}
{ applyStrokeSetting({ ...currentStroke, color: hexToColor( hex, (currentStroke.color ?? DEFAULT_STROKE_COLOR)[3] ?? 255, ), }) }} className='size-7' />
{t('render.strokeColorLabel')}
{ const parsed = Number.parseFloat(event.target.value) if (!Number.isFinite(parsed)) return updateStrokeWidth(parsed) }} />
) }