| '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] |
| } |
|
|
| |
| 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() |
| 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) |
| |
| |
| 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') |
|
|
| |
| |
| |
|
|
| 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> |
| ) |
| } |
|
|