Download ui/components/panels/RenderControlsPanel.tsx from LLbros/trans4: direct link, hf CLI and curl.
- Browser
- Download file 22.1 kB
-
https://huggingface.co/spaces/LLbros/trans4/resolve/main/ui/components/panels/RenderControlsPanel.tsx
- Command line
-
hf download hf://spaces/LLbros/trans4/ui/components/panels/RenderControlsPanel.tsx
-
curl -L -o RenderControlsPanel.tsx https://huggingface.co/spaces/LLbros/trans4/resolve/main/ui/components/panels/RenderControlsPanel.tsx
22.1 kB
| '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> | |
| ) | |
| } | |